Throwable 아래의 상속 구조가 컴파일러가 무엇을 강제하는지를 결정한다.
Throwable
├─ Error JVM 수준의 회복 불가 — OutOfMemoryError, StackOverflowError
│ 잡지 않는다 (잡아도 할 수 있는 게 없다)
└─ Exception 검사 예외(checked) — 반드시 처리하거나 선언해야 한다
└─ RuntimeException 비검사(unchecked) — 강제되지 않는다
NPE, IllegalArgumentException, IllegalStateException
어느 쪽으로 만들 것인가
- checkd 로 만든다 — 호출자가 '합리적으로 복구할 수 있는' 상황
- 예) 파일이 없다 → 기본값으로 대체하거나 다시 물어본다
unchecked 로 만든다 프로그래밍 오류 — 계약 위반
- 예) null 을 넘겼다, 상태가 잘못됐다, 인덱스가 범위 밖
판단 기준은 "호출자가 이걸 잡아서 뭔가 다르게 할 수 있는가" 다 할 수 있는 게 없다면 checked 로 강제해 봐야 잡고 무시할 뿐이다
예외를 삼키는 안티패턴
try { ... } catch (Exception e) { } // 최악 — 흔적조차 없다
try { ... } catch (Exception e) { e.printStackTrace(); } // 로그 체계 밖으로 샌다
// 감싸 던질 때는 원인을 반드시 붙인다
catch (SQLException e) {
throw new DataAccessException("주문 조회 실패 id=" + id, e); // ← cause
}
원인을 붙이지 않으면 Caused by: 사슬이 끊겨 진짜 발생 지점을 영영 못 찾는다.
InterruptedException 은 특별하다
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 인터럽트 상태를 복원한다
throw new RuntimeException(e);
}
잡으면 인터럽트 플래그가 지워지므로, 복원하지 않으면 상위의 종료 신호가 사라진다 — 스레드 풀이 안 꺼지는 흔한 원인이다.
스택트레이스는 비싸다
예외 생성 비용의 대부분은 fillInStackTrace() 다 스택을 훑어 프레임을 배열로 만든다 — 깊은 스택에서 수 µs
그래서 '흐름 제어에 예외를 쓰지 마라' 는 조언이 나온다
- 검증 실패마다 예외를 던지는 API 가 초당 수만 건을 처리하면
- 예외 생성만으로 CPU 를 태운다
정말 필요하면 스택트레이스를 끄는 생성자를 쓴다
- super(msg, cause, suppression, writableStackTrace=false)
같은 예외가 반복되면 JVM 이 지운다
-XX:+OmitStackTraceInFastThrow (기본 켜짐)
NPE 같은 예외가 같은 지점에서 반복되면 JIT 이 스택트레이스 없는 미리 만들어 둔 인스턴스로 바꿔 던진다
- 증상 — "java.lang.NullPointerException" 한 줄만 찍히고 원인 위치가 없다
- 운영에서만, 그것도 한참 지나서야 나타난다 (JIT 컴파일 후)
- 대응 — -XX:-OmitStackTraceInFastThrow 로 끄고 재현
Helpful NullPointerException (Java 14+)
a.getB().getC().getD() 에서 무엇이 null 인지 알려 준다
- "Cannot invoke String.length() because the return value of
- Order.getCustomer() is null"
Java 15 부터 기본 활성화다. 이것만으로 디버깅 시간이 크게 준다
면접 함정
- ❌ "checked 예외가 더 안전하다" → 강제만 될 뿐, 잡고 무시하면 오히려 더 위험하다.
- ❌ "Error도 잡아서 복구한다" → OOM 이후의 코드는 신뢰할 수 없다.