Java의 모든 예외는 Throwable을 루트로 하는 클래스 계층이다. 예외도 그냥 객체이며, 상속 관계로 분류된다.
Throwable
├── Error ← JVM 수준의 심각한 문제. 잡지 않는다
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── NoClassDefFoundError
└── Exception ← 애플리케이션이 처리할 수 있는 문제
├── IOException ← checked
│ ├── FileNotFoundException
│ └── SocketException
├── SQLException ← checked
├── InterruptedException ← checked
└── RuntimeException ← unchecked
├── NullPointerException
├── IllegalArgumentException
│ └── NumberFormatException
├── IllegalStateException
├── IndexOutOfBoundsException
├── ClassCastException
└── ArithmeticException| 분류 | 조상 | 컴파일러 강제 | 의미 | 대응 |
|---|---|---|---|---|
| Error | Error |
없음 | JVM/시스템 장애. 복구 불가 | 잡지 말 것. 프로세스 재시작 |
| Checked Exception | Exception (RuntimeException 제외) |
처리 또는 선언 강제 | 호출자가 예상하고 복구할 수 있는 상황 | try-catch 또는 throws |
| Unchecked Exception | RuntimeException |
없음 | 프로그래밍 오류 또는 복구 불가한 비즈니스 실패 | 원인 수정, 또는 상위에서 일괄 처리 |
Error를 잡지 말아야 하는 이유: OutOfMemoryError가 발생한 시점에는 이미 힙이 가득 차서, catch 블록에서 로그를 남기려고 문자열 하나 만드는 것조차 다시 OOM을 일으킬 수 있다. StackOverflowError도 마찬가지로 스택이 없어서 catch 블록 자체가 실행 불가능할 수 있다.
checked 예외(검사 예외)는 메서드 시그니처에 throws로 선언해야 하며, 호출자는 반드시 잡거나 다시 던져야 한다. 설계 의도는 "이 메서드는 이런 방식으로 실패할 수 있으니 호출자가 반드시 대비하라"는 API 문서의 강제였다.
그러나 실무에서는 checked 예외에 대한 비판이 크다.
| 관점 | checked 옹호 | checked 비판 |
|---|---|---|
| 문서화 | 실패 가능성이 시그니처에 명시됨 | 대부분의 호출자는 복구할 수 없어 그냥 다시 던짐 (throws Exception 남발) |
| 안전성 | 실패 처리를 잊을 수 없음 | 귀찮아서 빈 catch로 삼키는 코드가 양산됨 |
| 캡슐화 | — | 하위 계층의 예외(SQLException)가 상위 계층 시그니처에 누출됨 |
| 함수형 | — | 람다/스트림에서 checked 예외를 던질 수 없어 매우 불편함 |
| 대안 | — | Kotlin·C#·Scala 는 checked 예외 없음. Spring 도 unchecked 로 전환 |
현대 Java 실무의 주류 관행:
RuntimeException 상속)로 설계한다. 호출자에게 처리를 강제하지 않고, 최상위(컨트롤러/글로벌 핸들러)에서 일괄 처리한다.IOException, SQLException)는 경계에서 잡아 의미 있는 unchecked 예외로 전환한다(2.7절).try {
// 1. 실행
} catch (SpecificException e) {
// 2. 예외 발생 시 (위에서부터 처음 매칭되는 catch 하나만)
} finally {
// 3. 예외 여부와 무관하게 항상 실행
}catch는 위에서 아래로 처음 매칭되는 하나만 실행되므로 구체적인 예외를 먼저, 일반적인 예외를 나중에 배치해야 한다. catch (Exception e)를 먼저 쓰고 그 아래에 catch (IOException e)를 쓰면 도달 불가능한 코드라서 컴파일 에러가 난다.
finally는 다음 상황에서도 실행된다.
return을 했을 때 (return 값이 결정된 후, 메서드가 실제로 반환되기 전에 실행)break/continue로 빠져나갈 때실행되지 않는 유일한 경우: System.exit(), JVM 크래시, 무한 루프.
return과 finally의 함정: try에서 return x;를 실행하면 반환값 x가 먼저 평가되어 저장된다. 그 뒤 finally가 실행되고, finally가 끝나면 저장된 값이 반환된다. finally에서 x를 바꿔도 반환값은 안 바뀐다(원시 타입의 경우).
그러나 finally에서 return을 하면 저장된 값을 덮어쓰고, 심지어 try에서 던진 예외까지 삼켜 버린다. finally에서 return하는 것은 절대 금지다.
static int f() {
try { return 1; }
finally { System.out.println("finally"); } // 출력: finally, 반환: 1
}
static int g() {
try { throw new RuntimeException("boom"); }
finally { return 2; } // 예외가 사라지고 2 반환 — 최악
}JDK 7부터 |로 여러 예외를 한 catch에서 처리할 수 있다. 처리 로직이 같은 예외들을 묶을 때 쓴다.
try {
parseAndSave(input);
} catch (NumberFormatException | DateTimeParseException e) {
log.warn("입력 형식 오류: {}", e.getMessage()); // e는 암묵적으로 final
}제약: 멀티 catch의 예외들은 서로 상속 관계이면 안 된다(IOException | FileNotFoundException은 컴파일 에러 — 부모가 이미 자식을 포함하므로). e는 공통 상위 타입으로 취급되며 재할당 불가.
파일, 소켓, DB 커넥션, 스트림은 사용 후 반드시 닫아야 한다. 닫지 않으면 OS 파일 핸들, 커넥션 풀 슬롯이 고갈되어 어느 순간 서버 전체가 멈춘다(자원 누수, resource leak). JDK 7 이전에는 finally에서 수동으로 close()를 호출했는데, 그 코드가 지저분하고 실수하기 쉬웠다.
try-with-resources는 AutoCloseable 인터페이스(void close() throws Exception 하나)를 구현한 객체를 try 괄호 안에 선언하면, 블록이 끝날 때 자동으로 close()를 호출한다.
try (var in = new FileInputStream("a.txt");
var out = new FileOutputStream("b.txt")) {
in.transferTo(out);
} // 여기서 out.close() → in.close() 순서로 호출핵심 규칙:
| 규칙 | 설명 |
|---|---|
| 닫는 순서 | 선언의 역순. 나중에 연 것을 먼저 닫는다 (스택처럼) |
| 예외 시 | try 본문에서 예외가 나도 close()는 반드시 호출 |
| close()가 예외를 던지면 | 본문 예외가 주 예외(primary)가 되고, close 예외는 suppressed 예외로 주 예외에 첨부됨 |
| 본문 정상 + close() 예외 | close 예외가 그대로 던져짐 |
| 변수 | 암묵적 final. 재할당 불가 |
| JDK 9+ | 이미 선언된 effectively final 변수를 try (existingVar) 형태로 사용 가능 |
왜 역순인가? out은 in에 의존할 수 있다(예: 버퍼드 스트림이 원본 스트림을 감싼 경우). 감싼 것을 먼저 닫아야 버퍼가 플러시된 뒤 원본이 닫힌다. 순서가 뒤집히면 데이터가 유실된다.
suppressed 예외란? JDK 7 이전에는 본문 예외와 finally의 close 예외 중 하나만 살아남았다(나중 것이 앞의 것을 덮어씀). 이로 인해 진짜 원인(본문 예외)이 사라지는 문제가 있었다. try-with-resources는 본문 예외를 보존하고 close 예외를 addSuppressed()로 첨부한다.
e.getSuppressed()로 꺼내 볼 수 있으며 스택트레이스에도 Suppressed: 항목으로 출력된다.
| 키워드 | 위치 | 역할 |
|---|---|---|
throw |
문장 (메서드 본문) | 예외 객체를 실제로 던진다. throw new XxxException(...) |
throws |
메서드 선언부 | 이 메서드가 던질 수 있는 checked 예외를 선언한다. 처리는 호출자에게 위임 |
throw는 실행 흐름을 즉시 중단하고 스택을 거슬러 올라가며 catch를 찾는다. throws는 컴파일러에게 주는 정보이며 런타임 동작이 없다. unchecked 예외는 throws에 쓸 수 있지만 강제되지 않고, 문서화 목적으로만 의미가 있다.
계층화된 애플리케이션(컨트롤러 → 서비스 → 리포지토리)에서 하위 계층의 예외가 상위로 그대로 올라오면 추상화가 깨진다. 서비스 계층이 SQLException을 알아야 한다면 DB를 바꿀 때 서비스 코드까지 바뀐다. 그래서 계층 경계에서 하위 예외를 상위 계층에 맞는 예외로 전환(translation)한다.
이때 반드시 원래 예외를 cause(원인)로 넘겨야 한다. 그러지 않으면 "왜 실패했는지"에 대한 스택트레이스가 사라진다.
try {
return jdbc.query(sql);
} catch (SQLException e) {
throw new DataAccessException("주문 조회 실패: orderId=" + id, e); // e를 cause로 체이닝
}체이닝된 예외는 스택트레이스에 Caused by:로 출력된다. getCause()로 꺼낼 수 있고, 여러 단계 체이닝도 가능하다. 모든 사용자 정의 예외는 (String message, Throwable cause) 생성자를 반드시 제공해야 한다.
실무 비즈니스 예외 계층의 전형적인 구조:
RuntimeException
└── BusinessException (abstract) ← 에러 코드 + 메시지를 가진 공통 부모
├── NotFoundException ← 404 계열
│ ├── OrderNotFoundException
│ └── MemberNotFoundException
├── InvalidRequestException ← 400 계열
│ └── InsufficientStockException
└── ExternalServiceException ← 502 계열
└── PaymentDeclinedException설계 원칙:
| 원칙 | 이유 |
|---|---|
RuntimeException 상속 |
호출자에게 처리를 강제하지 않음. 최상위에서 일괄 처리 |
공통 추상 부모(BusinessException) |
글로벌 핸들러에서 catch (BusinessException e) 하나로 잡음 |
| 에러 코드(enum) 포함 | 클라이언트/로그에서 기계적으로 식별 가능. 메시지는 바뀌어도 코드는 안정적 |
| 문맥 정보 포함 (id, 금액 등) | 로그만 보고 재현 가능하게 |
| 중간 계층(NotFound 등)은 HTTP 상태나 처리 정책 단위로 | 핸들러가 상태 코드를 매핑하기 쉬움 |
이름은 ~Exception 접미사 |
관례 |
항상 cause 생성자 제공 |
전환 시 원인 보존 |
에러 코드는 문자열 상수보다 enum이 낫다. 오타를 컴파일러가 잡고, HTTP 상태나 기본 메시지를 enum 필드로 묶을 수 있다.
| 안티패턴 | 무엇이 문제인가 |
|---|---|
예외 삼키기 (catch (Exception e) {}) |
실패가 사라짐. 데이터 불일치가 조용히 진행되어 며칠 뒤 발견 |
| 로그만 찍고 계속 진행 | 삼키기와 동일. "로그 남겼으니 괜찮다"는 착각 |
catch (Exception e) / catch (Throwable t) 남용 |
NPE 같은 버그까지 잡아서 숨김. Error까지 잡으면 JVM 상태를 망침 |
스택트레이스 손실 (throw new X(e.getMessage())) |
cause를 안 넘겨서 원인 추적 불가 |
e.printStackTrace() |
stderr로만 나가 로그 시스템에 안 남음. 로거를 써야 함 |
흐름 제어용 예외 (try { arr[i] } catch (AIOOBE) { break; }) |
예외 생성은 스택 캡처 때문에 비쌈(일반 분기 대비 수십~수백 배). 의도도 불명확 |
throws Exception 선언 |
무엇이 실패하는지 정보 없음. 호출자도 Exception을 잡을 수밖에 없음 |
| finally에서 return | 예외 삼킴 + 반환값 덮어씀 |
예외 메시지에 문맥 없음 ("error") |
로그를 봐도 무슨 주문인지, 얼마인지 모름 |