1. 개요

IOException 은 throws 를 안 붙이면 컴파일이 안 되는데 NullPointerException 은 아무 데서나 터져도 컴파일은 잘 된다. 처음 자바를 배울 때 이 차이가 왜 있는지 궁금했다. 이 글은 체크 예외와 언체크 예외가 왜 나뉘었는지, 실무에서는 어느 쪽을 던지는 게 맞는지 정리한다.


2. 핵심 내용

2-1. 계층 구조로 보는 차이

Throwable 아래 Error 와 Exception 이 있고, Exception 아래 RuntimeException 이 있다. 이 중 컴파일러가 처리(try-catch 또는 throws)를 강제하는 건 RuntimeException 을 뺀 Exception 계열, 즉 체크 예외뿐이다. RuntimeException 과 Error 는 언체크다.

// 체크 예외: throws 를 안 쓰면 컴파일 에러
void readFile(String path) throws IOException {
    Files.readAllBytes(Path.of(path));
}
 
// 언체크 예외: 컴파일러가 신경 안 씀
void divide(int a, int b) {
    int result = a / b; // b가 0이면 ArithmeticException, 컴파일은 통과
}

2-2. 설계 의도: 복구 가능성

체크 예외는 “호출한 쪽이 복구를 시도할 수 있는 상황”을 뜻하도록 설계됐다. 파일이 없으면 다른 경로를 물어볼 수 있고, 네트워크가 끊기면 재시도할 수 있다. 반면 언체크 예외는 대부분 프로그래밍 실수다. null 을 체크 안 했거나, 배열 범위를 넘었거나. 이런 건 호출부에서 “복구”할 수 있는 게 아니라 코드를 고쳐야 하는 문제다.

2-3. 실무에서는 왜 다들 언체크를 선호하나

이론은 그럴듯한데 실무에서는 체크 예외가 골칫거리가 되는 경우가 많다. 메서드 하나가 체크 예외를 던지면 그걸 호출하는 모든 메서드가 throws 를 달거나 catch 해야 한다. 콜 스택이 깊으면 이 전파가 끝없이 이어진다. 그래서 흔히 이렇게 감싼다.

try {
    repository.save(entity);
} catch (SQLException e) {
    throw new RuntimeException(e); // 체크를 언체크로 포장
}

Spring 이 SQLException 을 잡아서 언체크인 DataAccessException 계열로 바꿔주는 것도 같은 이유다. 실무 기준을 정리하면: 호출한 쪽이 그 자리에서 뭔가 다른 행동을 취할 수 있을 때만 체크 예외를 쓴다. 그게 아니면 커스텀 RuntimeException 을 만들어 던지는 게 유지보수에 유리하다. 비즈니스 예외(주문 취소 불가, 잔액 부족 같은)는 대부분 이 기준으로 언체크로 만든다.

2-4. 체크 예외의 대표 선수, InterruptedException

체크 예외 중 실무에서 자주 만나는 게 InterruptedException 이다. Thread.sleep() 이나 wait() 이 이걸 던지는데, 스레드가 인터럽트되면 잡아서 처리하라는 신호다. 이 예외는 스레드 동시성 코드에서 특히 자주 나오는데, 그 얘기는 자바 동시성 기초 에서 다시 다룬다.


3. 마무리

요약

  • 체크 예외는 RuntimeException 이 아닌 Exception 계열, 컴파일러가 처리를 강제한다.
  • 설계 의도는 “복구 가능한 상황”이지만, 실무에서는 전파 부담 때문에 언체크로 포장하는 경우가 많다.
  • 호출부가 그 자리에서 다른 행동을 취할 수 있을 때만 체크 예외를 쓰고, 나머지는 언체크 커스텀 예외로 만든다.
  • InterruptedException 은 동시성 코드에서 자주 마주치는 체크 예외의 대표 사례다.