[Java] 예외 처리와 스택 트레이스
예외 하나가 발생했을 때 벌어지는 일
예외가 발생하면 자바는 스택 트레이스(Stack Trace)라는 형태로 발생 지점과 호출 경로를 기록한다. 스택 트레이스는 추상적인 로그 문자열이 아니라, 그 스레드가 실행 중이던 JVM(Java Virtual Machine) 스택의 스냅샷 그 자체다. 예외 처리를 제대로 이해하려면 try-catch와 throws 같은 언어 문법뿐 아니라, 그 아래에서 동작하는 스레드 스택 구조까지 함께 봐야 하는 이유가 여기에 있다.
| 구성 요소 | 역할 |
|---|---|
| Throwable | 모든 예외와 에러의 최상위 타입 |
| Checked Exception | 컴파일러가 처리를 강제한다. 호출자가 복구 책임을 진다 |
| Unchecked Exception | RuntimeException 계열. 프로그래머가 사전에 예방해야 한다 |
| Error | JVM 레벨의 심각한 문제. 애플리케이션이 복구할 수 없다 |
| try-with-resources | AutoCloseable 자원을 블록 종료 시 자동으로 닫는다 |
| Suppressed Exception | try-with-resources 에서 close 중 추가로 발생한 예외 |
| 원인 체인(Cause Chain) | 예외를 감싸 다시 던질 때 Caused by 로 이어지는 원본 예외 |
| 스택 트레이스 | 예외 발생 시점의 스레드 스택 프레임을 기록한 정보 |
| 스택 프레임(Stack Frame) | 메서드 호출마다 스레드 스택에 쌓이는 실행 단위 |
예외 계층 구조: Throwable, Error, Exception
자바의 모든 예외는 Throwable을 최상위로 한다. Throwable은 Error와 Exception으로 나뉘고, Exception은 다시 Checked Exception과 Unchecked Exception(RuntimeException의 자손)으로 나뉜다.
Checked Exception은 컴파일러가 try-catch 또는 throws로 처리를 강제한다. 파일 입출력, 네트워크, 데이터베이스처럼 호출자가 복구 책임을 져야 하는 외부 요인에 쓰이며, IOException과 SQLException, ClassNotFoundException이 대표적이다.
Unchecked Exception은 컴파일러가 체크하지 않는다. 대부분 프로그래머의 실수, 예를 들어 널 참조나 잘못된 인자로 발생하므로 사전 검증이나 코드 수정으로 예방해야 하며, NullPointerException과 IllegalArgumentException, ArrayIndexOutOfBoundsException이 대표적이다.
Error는 JVM 레벨의 심각한 문제로 애플리케이션이 복구할 수 없다. OutOfMemoryError와 StackOverflowError가 여기에 해당한다.
| 구분 | 컴파일러 강제 | 대표 예외 | 복구 책임 | 처리 방식 |
|---|---|---|---|---|
| Checked Exception | 있다(try-catch 또는 throws) | IOException, SQLException | 호출자 | 외부 요인이므로 반드시 처리한다 |
| Unchecked Exception | 없다 | NullPointerException, IllegalArgumentException | 프로그래머 | 사전 검증으로 예방한다 |
| Error | 없다 | OutOfMemoryError, StackOverflowError | 없음 | 복구 불가, catch 로 무시하지 않는다 |
직접 만든 예외를 이 계층에 끼워 넣을 때는 어떤 부모를 상속할지 판단해야 한다. Exception을 상속하면 Checked Exception이 되어 호출자가 반드시 처리하게 강제할 수 있고, RuntimeException을 상속하면 Unchecked Exception이 되어 호출부마다 catch를 강제하지 않는다. 복구가 불가능하거나 비즈니스 규칙 위반처럼 즉시 실패해야 하는 경우에는 RuntimeException 쪽을 상속하는 것이 낫다.
// 도메인 예외를 Unchecked 로 설계하면 호출부마다 catch 를 강제하지 않는다
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long orderId) {
super("주문을 찾을 수 없습니다: " + orderId);
}
}try-with-resources, 자원 해제 자동화
자바 7 이전에는 finally 블록에서 close() 를 명시적으로 호출해야 했다. close() 자체도 예외를 던질 수 있어 finally 안에 다시 try-catch를 둬야 했고, 이렇게 중첩된 close 예외를 어떻게 처리할지는 코드마다 제각각이었다.
// 자바 7 이전: 예외 안전성이 떨어지고 코드가 길다
FileReader fr = null;
try {
fr = new FileReader("file.txt");
// ...
} catch (IOException e) {
logger.error("파일 읽기 실패", e);
} finally {
if (fr != null) {
try {
fr.close();
} catch (IOException e) {
// close 자체도 예외를 던질 수 있어서 또 감싸야 한다
}
}
}자바 7 부터는 AutoCloseable을 구현한 자원에 try-with-resources를 쓸 수 있다. 컴파일러가 자동으로 close()를 삽입하고, 여러 자원을 동시에 열었을 때는 연 순서의 역순으로 닫는다.
try (FileReader fr = new FileReader("file.txt")) {
// ...
} catch (IOException e) {
logger.error("파일 읽기 실패", e);
}
// fr 은 블록을 빠져나갈 때 자동으로 close() 가 호출된다
try (
InputStream in = new FileInputStream("in.txt");
OutputStream out = new FileOutputStream("out.txt")
) {
in.transferTo(out);
}
// 닫는 순서: out 이 먼저, in 이 나중이다 (연 순서의 역순)수동 close 방식과 try-with-resources 를 정리하면 다음과 같다.
| 방식 | 코드 길이 | 예외 억제 처리 | close 누락 위험 |
|---|---|---|---|
| try-catch-finally 수동 close | 길고, catch 안에 catch 가 중첩된다 | 수동으로 처리해야 하고 대부분 생략된다 | 있다(null 체크 누락, finally 자체 누락) |
| try-with-resources | 짧고, 컴파일러가 close 를 삽입한다 | addSuppressed() 로 자동 연결된다 | 사실상 없다(컴파일러가 보장한다) |
예외를 로깅하기: Caused by 원인 체인
잘못된 패턴은 catch 블록에서 아무것도 하지 않거나, 원본 예외를 버린 채 새 예외를 던지는 패턴이다. 스택 트레이스는 디버깅 시 큰 도움을 주는데, 이를 로깅처리 하지않는다면 런타임에서 발생한 오류를 해결하기 어렵다.
// 잘못된 패턴: catch 블록이 비어 있어 문제가 일어났는지조차 알 수 없다
try {
riskyCall();
} catch (Exception e) {
// 아무것도 안 함
}
// 올바른 패턴: 로깅하고, 다시 던질 때는 원본을 cause 로 전달한다
try {
riskyCall();
} catch (Exception e) {
logger.error("riskyCall 실패", e);
throw new ServiceException("riskyCall 실패", e);
}생성자에 e를 함께 넘기면 Throwable 내부의 cause 필드가 채워지고, 출력 시 Caused by로 원본 예외가 이어서 나온다.
java.lang.RuntimeException: 주문 저장 실패
at Service.save(Service.java:20)
at Service.run(Service.java:10)
Caused by: java.sql.SQLException: connection refused
at Repository.query(Repository.java:45)
... 3 more맨 위 예외는 상위 계층에서 래핑한 결과이고, 실제 근본 원인은 맨 아래 Caused by 체인의 끝에 있다.
스레드 스택과 스택 프레임의 관계: 어디까지 읽어야 하는가
스택 트레이스를 깊이 이해하려면 JVM 메모리 구조의 스레드 스택과 스택 프레임을 함께 봐야 한다. JVM에서는 스레드 하나당 독립된 JVM Stack이 하나씩 할당된다. Heap과 Method Area(Metaspace)는 모든 스레드가 공유하지만, 스택은 스레드별로 완전히 독립적이다.
스택 프레임(Stack Frame)은 메서드 하나가 호출될 때마다 스레드 스택에 push 되는 단위이고, 메서드가 리턴되면 pop되어 사라진다. 각 프레임이 담는 정보는 다음과 같다.
| 구성 요소 | 담는 정보 |
|---|---|
| Local Variables | 메서드 매개변수, 지역 변수, this 참조 |
| Operand Stack | 바이트코드 연산에 쓰이는 임시 피연산자 |
| Frame Data | 상수 풀 참조, 반환 주소 등 |
main이 methodA를 부르고, methodA가 methodB를, methodB가 methodC를 부르는 동안 스레드 스택에는 프레임이 순서대로 쌓인다. methodC에서 예외가 발생하면 JVM은 그 순간 스레드 스택에 쌓인 모든 프레임을 위에서부터 훑어 각 프레임의 클래스명, 메서드명, 파일명, 라인 번호를 추출한다. 이것이 스택 트레이스이며, 한 줄은 스택 프레임 하나에 정확히 대응한다.
-Xss 옵션은 스레드 하나가 가질 수 있는 스택의 최대 크기를 지정하며, 기본값은 플랫폼과 JVM 버전에 따라 보통 512킬로바이트에서 1메가바이트 사이다. 재귀 호출이 깊어지거나 지역 변수가 많은 메서드가 계속 쌓이면 이 한계를 넘어서면서 StackOverflowError가 발생한다. -Xss를 키우면 더 깊은 재귀를 허용할 수 있지만, 스레드 하나가 차지하는 메모리도 함께 커지므로 플랫폼 스레드를 수천 개 띄워야 하는 상황에서는 이 트레이드오프가 전체 메모리 사용량에 직접 영향을 준다.
jstack으로 얻는 스레드 덤프(Thread Dump)도 같은 원리 위에 있다. 예외 하나의 스택 트레이스가 한 스레드가 한 시점에 쌓아 올린 프레임 목록이라면, 스레드 덤프는 그 순간 살아 있는 모든 스레드의 프레임 목록을 한꺼번에 캡처한 것이다. 어느 스레드가 어떤 락을 기다리며 멈춰 있는지 진단할 때도 각 스레드 섹션을 예외 스택 트레이스와 똑같은 방식으로 위에서 아래로 읽으면 된다.
jstack <pid> > thread_dump.txt
# 각 스레드 섹션도 예외 스택 트레이스와 같은 형식이다 (at 클래스.메서드(파일:라인))HikariCP의 leakDetectionThreshold 옵션이 반납되지 않는 커넥션을 잡은 코드의 스택 트레이스를 경고 로그로 남기는 것도 같은 원리다.