Checked vs Unchecked 예외 — 언제 무엇을 써야 하나

Java Checked vs Unchecked 예외 완전 비교 — 컴파일러 강제 처리 차이, 복구 가능성 기준으로 선택하는 방법, Checked를 Unchecked로 래핑하는 패턴, Spring이 Unchecked를 선호하는 이유, 람다에서 Checked 예외 처리 문제

· 5 min read · PALDYN Team

지난 글에서 Java 예외 계층 구조를 살펴봤다. 이번에는 Checked vs Unchecked 예외의 차이와 실무에서 어떤 것을 선택해야 하는지 깊이 다룬다.

핵심 차이: 컴파일러 강제

Checked 예외는 컴파일러가 처리를 강제한다. catch로 잡거나 throws로 선언하지 않으면 컴파일 오류가 발생한다.

// Checked — 컴파일러가 강제
void readFile(String path) {
    Files.readString(Path.of(path)); // 컴파일 오류!
    // "Unhandled exception: java.io.IOException"
}

// 해결 1: catch
void readFile(String path) {
    try {
        Files.readString(Path.of(path));
    } catch (IOException e) {
        log.error("파일 읽기 실패", e);
    }
}

// 해결 2: throws 선언
void readFile(String path) throws IOException {
    Files.readString(Path.of(path));
}

Unchecked 예외는 아무런 선언 없이 어디서든 던질 수 있다.

// Unchecked — 선언 없이 던질 수 있음
void validate(String input) {
    if (input == null || input.isBlank()) {
        throw new IllegalArgumentException("입력값이 비어 있음");
    }
}

Checked vs Unchecked 핵심 비교

언제 어떤 것을 쓰나

Checked vs Unchecked 선택 결정 트리

Checked를 써야 하는 경우: 호출자가 예외를 합리적으로 처리(복구)할 수 있을 때. 파일이 없으면 다른 경로 시도, 네트워크 실패 시 재시도, DB 오류 시 롤백이 그 예다.

Unchecked를 써야 하는 경우: 프로그래밍 오류이거나, 호출자가 이 예외를 예측하고 처리하는 것이 불합리할 때. null 포인터, 배열 범위 초과, 잘못된 인수는 버그를 수정해야지 catch로 감추면 안 된다.

// 도서관 파일 읽기 — 복구 가능 → Checked
class BookRepository {
    Book load(String isbn) throws BookNotFoundException {
        Path path = bookDir.resolve(isbn + ".json");
        if (!Files.exists(path)) throw new BookNotFoundException(isbn);
        return parse(Files.readString(path));
    }
}

// 입력 검증 — 프로그래밍 오류 → Unchecked
class OrderService {
    Order createOrder(String productId, int quantity) {
        if (productId == null) throw new IllegalArgumentException("productId는 필수");
        if (quantity <= 0) throw new IllegalArgumentException("수량은 1 이상");
        ...
    }
}

Spring이 Unchecked를 선호하는 이유

Spring Framework는 대부분의 예외를 RuntimeException 기반으로 설계한다. DataAccessException, HttpClientErrorException 등이 모두 Unchecked다.

// Spring DataAccessException — Unchecked
// JDBC SQLException(Checked)를 래핑한 Unchecked
public interface UserRepository {
    User findById(long id); // throws 선언 없음
    // 내부적으로 DataAccessException(Unchecked)를 던질 수 있음
}

이유는 두 가지다. 첫째, API 오염 방지throws SQLException을 모든 메서드 시그니처에 전파하면 인터페이스가 구현 세부 사항에 오염된다. 둘째, AOP 트랜잭션 처리 — Spring @Transactional은 기본적으로 Unchecked 예외에만 롤백을 적용한다.

람다에서 Checked 예외 문제

Checked 예외는 람다/Stream 파이프라인에서 불편하다.

// 컴파일 오류: Function은 Checked 예외를 던지지 못함
List<String> contents = paths.stream()
    .map(p -> Files.readString(p)) // IOException — 컴파일 오류!
    .collect(Collectors.toList());

// 해결 1: try-catch로 감싸기 (장황함)
List<String> contents = paths.stream()
    .map(p -> {
        try {
            return Files.readString(p);
        } catch (IOException e) {
            throw new RuntimeException(e); // Unchecked로 래핑
        }
    })
    .collect(Collectors.toList());

// 해결 2: 헬퍼 메서드로 래핑
static <T, R> Function<T, R> wrap(CheckedFunction<T, R> fn) {
    return t -> {
        try { return fn.apply(t); }
        catch (Exception e) { throw new RuntimeException(e); }
    };
}

List<String> contents = paths.stream()
    .map(wrap(Files::readString))
    .collect(Collectors.toList());

이 불편함이 현대 Java에서 Checked 예외 사용이 줄어드는 또 다른 이유다.

Checked를 Unchecked로 래핑하는 패턴

레이어 경계에서 Checked를 Unchecked로 변환하는 것은 일반적인 패턴이다.

// Repository에서 Checked → Service로 Unchecked 전달
public class UserRepository {
    public User findById(long id) {
        try {
            return jdbcTemplate.queryForObject(SQL, User.class, id);
        } catch (EmptyResultDataAccessException e) {
            throw new UserNotFoundException("User not found: " + id, e);
            // UserNotFoundException extends RuntimeException
        }
    }
}

// 커스텀 Unchecked 예외 — 원인(cause)을 보존
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message, Throwable cause) {
        super(message, cause); // cause 보존 중요!
    }
}

예외를 래핑할 때 반드시 원인(cause)을 생성자에 전달한다. 그래야 스택 트레이스에 원인이 남아 디버깅이 가능하다. 원인을 버리는 것은 정보를 잃는 것이다.


지난 글: 예외 처리 개요 — Java 예외 계층 구조와 설계 원칙

다음 글: try-catch-finally — 예외 처리 구문 완전 분석


읽어주셔서 감사합니다. 😊