공공부하자개발 · 영어 학습 노트
자바
중급객체지향과 코어 라이브러리0/9 완료
  • 01상속, 다형성, 오버라이딩
  • 02추상 클래스 vs 인터페이스
  • 03예외 처리
  • 04java.lang 심화
  • 05컬렉션 프레임워크 딥다이브
  • 06메서드 활용 패턴 (중급)
  • 07Object 메서드와 비교
  • 08java.time 실무 날짜 계산
  • 09HTTP 와 JSON 기초
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 중급 › 03 / 9

예외 처리

섹션 7진행 0 / 9
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 예외 계층 구조

Java의 모든 예외는 Throwable을 루트로 하는 클래스 계층이다. 예외도 그냥 객체이며, 상속 관계로 분류된다.

text
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 블록 자체가 실행 불가능할 수 있다.

2.2 checked vs unchecked: 설계 논쟁

checked 예외(검사 예외)는 메서드 시그니처에 throws로 선언해야 하며, 호출자는 반드시 잡거나 다시 던져야 한다. 설계 의도는 "이 메서드는 이런 방식으로 실패할 수 있으니 호출자가 반드시 대비하라"는 API 문서의 강제였다.

그러나 실무에서는 checked 예외에 대한 비판이 크다.

관점 checked 옹호 checked 비판
문서화 실패 가능성이 시그니처에 명시됨 대부분의 호출자는 복구할 수 없어 그냥 다시 던짐 (throws Exception 남발)
안전성 실패 처리를 잊을 수 없음 귀찮아서 빈 catch로 삼키는 코드가 양산됨
캡슐화 — 하위 계층의 예외(SQLException)가 상위 계층 시그니처에 누출됨
함수형 — 람다/스트림에서 checked 예외를 던질 수 없어 매우 불편함
대안 — Kotlin·C#·Scala 는 checked 예외 없음. Spring 도 unchecked 로 전환

현대 Java 실무의 주류 관행:

  • 비즈니스 예외는 unchecked(RuntimeException 상속)로 설계한다. 호출자에게 처리를 강제하지 않고, 최상위(컨트롤러/글로벌 핸들러)에서 일괄 처리한다.
  • checked 예외는 정말로 호출자가 그 자리에서 복구 행동을 취할 수 있을 때만 쓴다(예: 재시도 가능한 일시적 네트워크 오류).
  • 라이브러리가 던지는 checked 예외(IOException, SQLException)는 경계에서 잡아 의미 있는 unchecked 예외로 전환한다(2.7절).

2.3 try-catch-finally 실행 순서

java
try {
    // 1. 실행
} catch (SpecificException e) {
    // 2. 예외 발생 시 (위에서부터 처음 매칭되는 catch 하나만)
} finally {
    // 3. 예외 여부와 무관하게 항상 실행
}

catch는 위에서 아래로 처음 매칭되는 하나만 실행되므로 구체적인 예외를 먼저, 일반적인 예외를 나중에 배치해야 한다. catch (Exception e)를 먼저 쓰고 그 아래에 catch (IOException e)를 쓰면 도달 불가능한 코드라서 컴파일 에러가 난다.

finally는 다음 상황에서도 실행된다.

  • try 블록에서 return을 했을 때 (return 값이 결정된 후, 메서드가 실제로 반환되기 전에 실행)
  • catch 블록에서 예외를 다시 던졌을 때
  • try 블록에서 break/continue로 빠져나갈 때

실행되지 않는 유일한 경우: System.exit(), JVM 크래시, 무한 루프.

return과 finally의 함정: try에서 return x;를 실행하면 반환값 x가 먼저 평가되어 저장된다. 그 뒤 finally가 실행되고, finally가 끝나면 저장된 값이 반환된다. finally에서 x를 바꿔도 반환값은 안 바뀐다(원시 타입의 경우).

그러나 finally에서 return을 하면 저장된 값을 덮어쓰고, 심지어 try에서 던진 예외까지 삼켜 버린다. finally에서 return하는 것은 절대 금지다.

java
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 반환 — 최악
}

2.4 멀티 catch

JDK 7부터 |로 여러 예외를 한 catch에서 처리할 수 있다. 처리 로직이 같은 예외들을 묶을 때 쓴다.

java
try {
    parseAndSave(input);
} catch (NumberFormatException | DateTimeParseException e) {
    log.warn("입력 형식 오류: {}", e.getMessage());   // e는 암묵적으로 final
}

제약: 멀티 catch의 예외들은 서로 상속 관계이면 안 된다(IOException | FileNotFoundException은 컴파일 에러 — 부모가 이미 자식을 포함하므로). e는 공통 상위 타입으로 취급되며 재할당 불가.

2.5 try-with-resources와 AutoCloseable

파일, 소켓, DB 커넥션, 스트림은 사용 후 반드시 닫아야 한다. 닫지 않으면 OS 파일 핸들, 커넥션 풀 슬롯이 고갈되어 어느 순간 서버 전체가 멈춘다(자원 누수, resource leak). JDK 7 이전에는 finally에서 수동으로 close()를 호출했는데, 그 코드가 지저분하고 실수하기 쉬웠다.

try-with-resources는 AutoCloseable 인터페이스(void close() throws Exception 하나)를 구현한 객체를 try 괄호 안에 선언하면, 블록이 끝날 때 자동으로 close()를 호출한다.

java
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: 항목으로 출력된다.

2.6 throw vs throws

키워드 위치 역할
throw 문장 (메서드 본문) 예외 객체를 실제로 던진다. throw new XxxException(...)
throws 메서드 선언부 이 메서드가 던질 수 있는 checked 예외를 선언한다. 처리는 호출자에게 위임

throw는 실행 흐름을 즉시 중단하고 스택을 거슬러 올라가며 catch를 찾는다. throws는 컴파일러에게 주는 정보이며 런타임 동작이 없다. unchecked 예외는 throws에 쓸 수 있지만 강제되지 않고, 문서화 목적으로만 의미가 있다.

2.7 예외 전환과 체이닝 (cause)

계층화된 애플리케이션(컨트롤러 → 서비스 → 리포지토리)에서 하위 계층의 예외가 상위로 그대로 올라오면 추상화가 깨진다. 서비스 계층이 SQLException을 알아야 한다면 DB를 바꿀 때 서비스 코드까지 바뀐다. 그래서 계층 경계에서 하위 예외를 상위 계층에 맞는 예외로 전환(translation)한다.

이때 반드시 원래 예외를 cause(원인)로 넘겨야 한다. 그러지 않으면 "왜 실패했는지"에 대한 스택트레이스가 사라진다.

java
try {
    return jdbc.query(sql);
} catch (SQLException e) {
    throw new DataAccessException("주문 조회 실패: orderId=" + id, e);   // e를 cause로 체이닝
}

체이닝된 예외는 스택트레이스에 Caused by:로 출력된다. getCause()로 꺼낼 수 있고, 여러 단계 체이닝도 가능하다. 모든 사용자 정의 예외는 (String message, Throwable cause) 생성자를 반드시 제공해야 한다.

2.8 사용자 정의 예외 설계

실무 비즈니스 예외 계층의 전형적인 구조:

text
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 필드로 묶을 수 있다.

2.9 실무 안티패턴

안티패턴 무엇이 문제인가
예외 삼키기 (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") 로그를 봐도 무슨 주문인지, 얼마인지 모름
핵심 원리
  • 2.1 예외 계층 구조
  • 2.2 checked vs unchecked: 설계 논쟁
  • 2.3 try-catch-finally 실행 순서
  • 2.4 멀티 catch
  • 2.5 try-with-resources와 AutoCloseable
  • 2.6 throw vs throws
  • 2.7 예외 전환과 체이닝 (cause)
  • 2.8 사용자 정의 예외 설계
  • 2.9 실무 안티패턴
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제