예외가 발생하면 stackTrace는 어떻게 생성될까?
📌 서론
RuntimeException이 발생했을 때 (우리가 주로 커스텀 예외를 만들 때도 RuntimeException을 던진다.)
우리 개발자들은 예외가 발생하면 로그창에서 stackTrace 로그 내용을 분석하며 쉽게 오류사항을 찾아내곤 한다.
스프링은 발생하는 예외를 어떻게 처리하고 stackTrace에 그 내용을 남기는 걸까?
이번 포스트에서는 직접 RuntimeException 예외를 발생시키고 이 예외를 처리하는 코드를 따라가 보며 최종적으로 stackTrace를 만들어주는 코드를 분석해 보도록 하자.
이렇게 코드 분석을 따라가다 보면 알게 되는 점은 다음과 같을 것이다.
1. RuntimeException은 어떻게 처리되는가 (예외처리 코드의 흐름)
2. stackTrace의 정보들은 어떻게 세팅되는가 (우리가 로그에서 보는 예외 데이터들은 stackTrace 정보다.)
3. 예외 클래스가 implements 하는 Serializable 인터페이스가 무엇인가?
4. stackTrace가 서버 성능에 영향을 미치는가? 최적화 방법이 따로 존재하는가?
이번 포스트에는 중간중간 이해를 돕기 위한 설명 목차(serializable, stack) 들이 있다 보니 이 글을 읽으면서 예외처리에 대해서만 궁금했다면 이 내용들이 이해가 잘 안 가고 복잡하게 느껴질 수도 있을 것이다. 그렇다면 중간 설명은 바로 넘어가고 바로 실전 디버깅 정보를 읽어나가면 된다.
그러나 예외처리를 위한 클래스들이 왜 serializable 인터페이스를 구현하고 stackTrace의 stack이 무엇인지 간단하게라도 이해하고 넘어가야 공부의 의미가 있다고 생각하니 천천히라도 읽어보는 것을 추천한다.
1. 예외 코드 작성 및 흐름 이해하기
게시글 등록을 하는 메서드에서 RuntimeException을 던지도록 했다.
- mybatis를 사용했기에 mapper를 통해 유저정보를 받아오고 게시글을 등록하는 서비스 메서드였지만 하단의 모든 내용을 제거하고 throw로 RuntimeException을 발생시키도록 했다.
컨트롤러는 아래와 같이 작성했다.
- 호출 시 UserType이 USER라면 아래의 메서드가 동작하도록 설계되어 있으며 성공 시 201 Status를 반환한다.
마지막으로 원활한 테스트를 위해서 프로젝트에 전역 예외처리(@ControllerAdvice, @RestControllerAdvice)가 적용되어 있다면 @ExceptionHandler에서 Exception, RuntimeException관련 핸들러 코드는 주석처리하거나 제거한다.
- 전역 예외처리 코드를 만들고 예외를 발생시켜도 e.printStackTrace() 같은 메서드로 확인할 수 있겠지만 좀 더 원시에 가까운(설정하지 않은 순순한 상태의) 스프링, 자바의 stackTrace 메시지 처리를 보고 싶었다.
이제 http 요청을 보낼 것이다. 그럼 서비스 레이어의 register() 메서드가 호출되어 RuntimeException 예외를 던질 것이다.
- 참고로 아래의 이미지는 intellj에서 지원하는 http request 파일이다. (postman으로 해도 된다.)
http 요청이 발생하면 코드는 아래의 RuntimeException을 타게 된다.
- RuntimeException클래스에는 5개의 생성자가 존재하는데 나는 예외 메시지를 담아서 던졌기 때문에 2번째 생성자인 (String message)를 매게 변수로 받는 생성자가 사용될 것이다.
package java.lang;
public class RuntimeException extends Exception {
@java.io.Serial
static final long serialVersionUID = -7034897190745766939L;
public RuntimeException() {
super();
}
// 이 생성자가 호출된다.
public RuntimeException(String message) {
super(message);
}
public RuntimeException(String message, Throwable cause) {
super(message, cause);
}
public RuntimeException(Throwable cause) {
super(cause);
}
protected RuntimeException(String message, Throwable cause,
boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
RuntimeException의 생성자에서 super를 호출하면 부모인 Exception 클래스의 생성자를 호출한다.
- 이전과 같이 message를 전달받기 때문에 2번째 생성자가 호출된다.
package java.lang;
public class Exception extends Throwable {
@java.io.Serial
static final long serialVersionUID = -3387516993124229948L;
public Exception() {
super();
}
// 이 생성자가 호출된다.
public Exception(String message) {
super(message);
}
public Exception(String message, Throwable cause) {
super(message, cause);
}
public Exception(Throwable cause) {
super(cause);
}
protected Exception(String message, Throwable cause,
boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
Exception에서도 super를 호출하고 부모인 Throwable의 생성자가 호출된다.
- 이번에도 String message를 매개변수로 받는 2번째 생성자가 호출된다. 조금 다른 건 이제 예외처리의 가장 최상위 계층으로 왔기 때문에 super()를 호출하지 않고 fillInStackTrace() 메서드를 호출한다는 점이다.
package java.lang;
import java.io.*;
import java.util.*;
public class Throwable implements Serializable {
private static final long serialVersionUID = -3042686055658047285L;
public Throwable() {
fillInStackTrace();
}
public Throwable(String message) {
fillInStackTrace();
detailMessage = message;
}
public Throwable(String message, Throwable cause) {
fillInStackTrace();
detailMessage = message;
this.cause = cause;
}
public Throwable(Throwable cause) {
fillInStackTrace();
detailMessage = (cause==null ? null : cause.toString());
this.cause = cause;
}
protected Throwable(String message, Throwable cause,
boolean enableSuppression,
boolean writableStackTrace) {
if (writableStackTrace) {
fillInStackTrace();
} else { stackTrace = null; } detailMessage = message;
this.cause = cause;
if (!enableSuppression)
suppressedExceptions = null;
}
}
지금까지 예외의 흐름을 인텔리제이의 다이어그램으로 나타내면 아래와 같다.
- RuntimeException -> Exception -> Throwable 순으로 호출되고 Throwable은 인터페이스인 Serializable을 구현하고 있다.
2. 예외 클래스들이 구현하는 Serializable 인터페이스가 뭘까?
Serializable 인터페이스를 구현하는 건 자바에서 객체의 직렬화를 가능하게 하기 위한 것이다.
- 직렬화란, 객체의 상태를 바이트 스트림으로 변환하는 과정을 말한다. 이렇게 하면, 객체를 파일 시스템에 저장하거나, 네트워크를 통해 다른 JVM(Java Virtual Machine)으로 전송할 수 있게 된다.
- Throwable 클래스와 그 하위 클래스들이 Serializable 인터페이스를 구현하는 이유는, 예외나 에러 객체를 JVM 경계를 넘어 전송하거나 저장할 필요가 있을 때 유용하기 때문이다.
Seriablizable 인터페이스
- Serializable 인터페이스 자체에는 메서드가 없어서, 이 인터페이스를 구현한다고 해서 추가적으로 구현해야 할 메서드는 없다.
- 그냥 이 인터페이스를 구현하는 것만으로도, 자바의 직렬화 메커니즘을 통해 자동으로 객체를 직렬화하고 역직렬화할 수 있게 되는 것이다. 다만 직렬화되는 객체에 포함된 다른 객체(상속 객체)들도 모두 Serializable 인터페이스를 구현해야 한다.
- Throwable 클래스가 Serializable을 구현함으로써, 자바의 모든 예외와 에러 객체들은 이러한 직렬화와 역직렬화의 혜택을 받을 수 있게 되는 것이다.
PersonDTO라는 객체를 직렬화시켜 보자. (ObjectOutputStream를 사용한 직렬화)
- 기본적으로 자바의 Serializable 인터페이스를 구현함으로써 이루어지는 직렬화 과정은 ObjectOutputStream를 통해 진행되는 바이너리 직렬화다.
- 예를 들어, PersonDTO 클래스에 name과 age 두 개의 필드가 있다고 가정하고, 각각의 필드에 "Jinan"와 27이라는 값을 가지고 있다고 치자. 이 경우 직렬화 과정은 대략 다음과 같은 형태의 바이트 스트림을 생성할 것이다.
AC ED 00 05 73 72 00 0D 50 65 72 73 6F 6E 44 54 4F [클래스 이름 길이와 이름]
XX XX XX XX XX XX XX XX [시리얼 버전 UID]
02 00 02 [필드의 개수]
4C 00 04 6E 61 6D 65 74 00 12 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 [name 필드]
49 00 03 61 67 65 [age 필드]
78 [필드 끝]
74 00 08 4A 6F 68 6E 20 44 6F 65 [name 값]
XX XX XX XX [age 값]
근데 이건 너무 생소한 형태인데 우리가 자주 보는 Base64랑 다른 건가?
- 자바의 기본 직렬화 메커니즘을 통해 생성된 바이너리 데이터 스트림(위의 예시코드)을 Base64로 인코딩하면, ASCII 문자열로 이루어진 텍스트 형태로 변환된다. 이 텍스트 형태가 우리가 자주 보는 영어와 숫자로 이루어진 형태이며 이는 데이터를 텍스트 기반의 저장소나 네트워크 프로토콜을 통해 전송할 때 유용하게 사용할 수 있다.
- Base64 인코딩은 바이너리 데이터를 64개의 인쇄 가능한 ASCII 문자로만 구성된 문자열로 변환하는 과정이다. 이 방식은 데이터가 텍스트로만 이루어진 환경(예: JSON, XML 파일, HTML 폼 데이터)에서 바이너리 데이터를 안전하게 전송하거나 저장할 필요가 있을 때 자주 사용된다.
예를 들어, 자바에서 ObjectOutputStream을 사용해 객체를 직렬화한 후, 그 결과로 나온 바이너리 데이터를 Base64로 인코딩하는 과정은 대략 다음과 같이 이루어진다.
- ObjectOutputStream을 사용해 객체를 바이트 배열로 직렬화.
- 직렬화된 바이트 배열을 Base64로 인코딩하여 ASCII 문자열로 변환.
- 이렇게 변환된 Base64 인코딩 문자열을 저장하거나 네트워크를 통해 전송.
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(객체); // 객체를 직렬화
oos.close();
// 바이트 배열을 Base64 문자열로 인코딩
String base64Encoded = Base64.getEncoder().encodeToString(baos.toByteArray());
변환된 Base64 문자열 예시
- 이렇게 변환된 Base64 문자열은 영어와 숫자가 연속적으로 이어지는 형태를 가지며, 이는 바이너리 데이터를 안전하게 텍스트 형태로 표현한 것이다. 이 문자열은 필요에 따라 다시 바이너리 데이터로 디코딩할 수 있으며, 역직렬화 과정을 통해 원래의 자바 객체로 복원될 수 있다.
rO0ABXNyAA5QZXJzb25EVE8AAAAAAAAAAgACTABEbmFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO0wAA2FnZUkAAAB4cHQABUppbmFuAAAA
3. Throwable 클래스와 그 하위 클래스들은 왜 Serializable 인터페이스를 구현할까?
Throwable이 Serializable을 구현하는 건 다양한 상황에서 예외 처리 시스템이 원활하게 동작하도록 하기 위해서다.
1. 로깅과 오류 분석:
- 만약 서버를 운영할 때, 예외 상황이 발생하면 그 정보를 로그로 남기고 싶을 것이다. 이때 Throwable 객체가 Serializable이라는 점은 이 객체를 직렬화해서 파일이나 데이터베이스에 저장하기 쉽게 만들어 준다. 나중에 이 정보를 다시 읽어와서 분석할 수 있게 되는 것이다. 오류가 발생했을 때 무슨 일이 있었는지 정확하게 파악할 수 있도록 도와준다.
2. 애플리케이션 상태의 저장과 복구:
- 어떤 중요한 작업을 처리하다가 예외가 발생했고, 이 상태를 저장해 두었다가 나중에 다시 처리하고 싶을 수도 있다. 예를 들어, 네트워크 요청 처리 중에 예외가 발생해서 해당 요청을 나중에 다시 시도하고 싶은 경우가 이에 해당할 것이다. 이런 경우에도 Throwable 객체를 직렬화하여 상태를 저장했다가, 나중에 역직렬화해서 복구하고 다시 처리를 시도할 수 있다.
3. 단일 서버에서도 유용한 직렬화:
- 비록 단일 서버를 사용하고 있다고 해도, 시스템 내부에서 다양한 컴포넌트 간의 데이터 전송이 필요할 때가 있다. 예를 들어, 백그라운드 작업으로 처리되는 큐가 있을 수 있는데, 이 큐를 통해 예외 객체를 전달하고 싶을 수도 있다. 직렬화를 통해 이런 종류의 데이터 전송도 손쉽게 할 수 있다.
4. 자바 플랫폼 간의 예외 처리:
- Serializable 인터페이스의 구현은 자바 애플리케이션이 자바 생태계 내부, 즉 자바 플랫폼을 기반으로 하는 다른 시스템과 예외 정보를 효과적으로 전달하고 처리할 수 있도록 지원한다. 이는 자바의 직렬화 기능이 주로 자바 객체를 다른 자바 시스템과 공유하기 위해 설계되었기 때문이다. 이런 맥락에서 Throwable 클래스와 그 하위 클래스가 Serializable을 구현하는 것은 예외 객체를 직렬화하여, 자바 애플리케이션 간에 전송할 수 있게 하고, 수신 측에서는 이를 역직렬화하여 원래의 예외 객체로 복원할 수 있는 기능을 제공한다.
5. 예외 처리 클래스들을 보면 다음과 같이 작성되어 있다.
📌 궁금증: 디버깅할 때는 직렬화된 데이터를 본 기억이 없는 것 같은데?
Serializable 인터페이스가 구현된 객체라 하더라도, 디버깅 시에는 이 객체가 어떻게 직렬화될지의 바이너리 데이터 형태는 보여주지 않는다. 대신 객체의 실시간 상태, 즉 각 필드의 현재 값들을 표시해 준다. 이는 직렬화 과정이나 결과된 바이너리 데이터와는 별개로, 객체가 메모리에 어떤 상태로 존재하는지를 보여주는 것이다.
예를 들어, Throwable 객체를 디버깅할 때 해당 객체의 message, cause, stackTrace 등의 필드 값을 쉽게 볼 수 있지만, 이 객체가 실제로 어떻게 직렬화되어 바이트 스트림으로 변환되는지는 자동으로 보여주지 않는다. 직렬화된 데이터를 보고 싶다면, 직접 코드를 작성하여 직렬화 과정을 명시적으로 실행시키고 그 결과를 확인해야 한다.
디버깅 환경에서는 Serializable 객체든 아니든 객체의 내부 상태를 문자열로 보여주는 것이 일반적이며, 이는 개발자가 코드를 분석하고 문제를 해결하는 데 도움을 주기 위한 것이다. 직렬화 과정이나 직렬화된 데이터 자체는 별도의 처리를 통해 확인해야 한다.
4. 실전 예외 디버깅 및 분석
예외 처리의 기본적인 흐름과 관련 클래스에 대해서 이해해 봤으니 지금부터는 실제 http요청이 발생한 후 예외가 던져졌을 때 디버깅을 확인해 보며 예외처리와 stackTrace가 생성되는 과정을 분석해 보자
가장 먼저 RuntimeException의 2번째 생성자에 예외가 잡혔다. String message에는 내가 적어줬던 예외 메시지가 들어갔다.
- message에 postDTO도 같이 담아줬기 때문에 그 내용도 보이게 된다.
다음으로 super를 통해 RuntimeException의 부모인 Exception이 호출된다.
다음으로 super를 통해 Exception의 부모인 Throwable이 호출된다.
이제 호출된 Throwable의 생성자에서는 fillInStackTrace() 메서드를 호출한다.
- 지금부터는 설명이 조금 복잡하니 천천히 따라가 보도록 하자
가장 먼저 if문 조건을 통과하면 fillInStackTrace(0); 메서드가 호출된다.
- 아래의 이미지를 보면 이 메서드에는 native가 붙어있는 것을 확인할 수 있다. 대체 이게 뭘까?
지금부터 2개의 fillInStackTrace 메서드를 이해해 보자
가장 먼저 호출되는 public synchronized Throwable fillInStackTrace() 메서드
- 가장 먼저 호출되는 이 메서드는 Throwable 객체의 스택 트레이스를 현재 스레드의 실행 스택 정보로 채우는 역할을 한다. 즉, 이 메서드가 호출되면 JVM은 현재 스레드의 호출 스택을 캡처하여 Throwable 객체 내에 저장한다. 이 정보는 예외가 발생한 지점의 스택 트레이스를 나중에 분석할 수 있게 해 주며, printStackTrace() 메서드 등을 통해 확인할 수 있다.
public synchronized Throwable fillInStackTrace() {
if (stackTrace != null || backtrace != null /* Out of protocol state */ ) {
fillInStackTrace(0);
stackTrace = UNASSIGNED_STACK;
}
return this;
}
내부에서 호출되는 private native Throwable fillInStackTrace(int dummy) 메서드
- fillInStackTrace() 메서드 내부에서 호출되는 이 메서드는 실제 스택 트레이스를 채우는 로직을 담당한다. native 키워드가 붙어 있으므로, 이 메서드의 구현은 자바가 아닌 네이티브 코드(대부분 C나 C++)로 되어 있으며, JVM의 내부 구현에 속해 있다. private로 선언되어 있기 때문에, 이 메서드는 Throwable 클래스 내부에서만 호출될 수 있다.
private native Throwable fillInStackTrace(int dummy);
근데 스프링에서 native 메서드의 구현을 아무리 찾아봐도 없는데 자바, 스프링에서는 찾을 수 없는 코드인가?
- 그렇다. private native Throwable fillInStackTrace(int dummy) 메서드의 구현체는 자바 코드 내에 존재하지 않고, JVM의 네이티브 코드 측면에서 구현되어 있다. native 메서드는 자바 네이티브 인터페이스(Java Native Interface, JNI)를 통해 C나 C++ 같은 네이티브 프로그래밍 언어로 작성된 코드와 자바 코드 간의 상호 작용을 가능하게 해 준다.
- 이런 메서드들은 JVM 내부의 구현이나 운영체제 수준의 기능을 직접 호출할 때 사용된다. 따라서 fillInStackTrace의 구체적인 구현을 보고 싶다면, 자바가 아닌 JVM의 소스 코드나 해당 네이티브 라이브러리를 직접 확인해야 한다.
- 간단히 말해서, 네이티브 메서드들은 자바 코드에서 직접 볼 수 없는, JVM이나 운영체제 수준에서 제공하는 기능들을 자바에서 사용할 수 있게 해주는 일종의 다리 같은 역할을 한다. 그래서 자바 애플리케이션 개발자들은 대부분 이러한 구현 세부 사항을 직접 다룰 필요가 없고 대신 JVM이 이러한 복잡성을 추상화하고, 자바 코드에서는 간단하게 이 기능들을 사용할 수 있게 되는 것이다.
fillInStackTrace 메서드의 동작 과정
- fillInStackTrace() (public) 메서드가 호출되면
- 내부적으로 fillInStackTrace(0) (private, native) 메서드를 호출한다. 이때 0은 단순히 네이티브 메서드를 호출하기 위한 인자로, 실제 로직에는 큰 의미가 없다고 생각하면 된다.
- 네이티브 메서드인 fillInStackTrace(int dummy)는 JVM 내부적으로 현재 스레드의 스택 트레이스 정보를 수집하고, 이 정보를 Throwable 객체에 저장한다.
- 이후 public synchronized Throwable fillInStackTrace() 메서드는 this를 반환하여 메서드 체이닝이 가능하게 한다.
public synchronized Throwable fillInStackTrace() {
if (stackTrace != null || backtrace != null /* Out of protocol state */ ) {
fillInStackTrace(0);
stackTrace = UNASSIGNED_STACK;
}
return this;
}
private native Throwable fillInStackTrace(int dummy);
5. stackTrace 이해하기: 현재 스레드의 실행 스택 정보란?
바로 디버깅을 분석하는 것도 좋지만 위의 내용들이 쉽게 이해가 되지 않을 수도 있다. 그렇기에 스레드의 실행 스택 정보가 무엇인지 이해하고 넘어가도록 하자. (이미 알고 있다면 바로 6번으로 넘어가면 된다.)
1. 실행 스택(Execution Stack)이란?
- 실행 스택은 컴퓨터 프로그램이 실행되는 동안 메서드 호출과 지역 변수들을 관리하는 데 사용되는 데이터 구조다. 각 스레드는 자신만의 실행 스택을 가지며, 이 스택은 메서드가 호출될 때마다 그 메서드를 위한 스택 프레임이라는 블록을 스택에 추가하면서 성장한다. 반대로 메서드가 종료되면 해당 스택 프레임은 스택에서 제거된다.
2. 스택 프레임(Stack Frame)
스택 프레임은 특정 메서드의 호출과 그 메서드 실행 중에 생성된 지역 변수들을 포함한다. 프레임에는 다음과 같은 정보가 포함될 수 있다.
- 메서드 호출에 대한 정보: 호출된 메서드의 이름, 메서드를 호출한 메서드(호출자)에 대한 정보 등.
- 지역 변수: 메서드 내에서 선언된 변수.
- 작업 중인 데이터: 메서드 실행 중에 사용되는 임시 데이터.
3. 실행 스택의 동작
- 프로그램에서 메서드 A가 호출되면, JVM(Java Virtual Machine)은 실행 스택에 A를 위한 스택 프레임을 추가한다. 만약 A 내부에서 다른 메서드 B가 호출된다면, B를 위한 새로운 스택 프레임이 A의 프레임 위에 스택에 추가된다. B의 실행이 종료되면, B의 스택 프레임은 스택에서 제거되고 제어는 A로 돌아간다. 이 과정은 재귀적으로 계속된다.
4. tcpSchool에서 이 내용을 그림으로 아주 잘 정리해 주셨다. 그림을 통해 이해해 보자
- 함수(메서드)가 실행되면 아래와 같이 계속 스택 프레임이라는 블록이 스택내부에 추가된다.
- 함수(메서드)가 종료될 때마다 해당 스택 프레임 블록이 제거되는 것을 볼 수 있다.
자세한 내용은 아래의 링크에 들어가서 보는 것을 추천한다.
5. 스택 트레이스(Stack Trace)
- 지금 우리가 알아보고자 하는 것은 stackTrace다. 예외가 발생했을 때, JVM은 현재 실행 스택의 상태를 캡처하여 스택 트레이스 정보로 저장한다. 이 정보에는 예외가 발생한 지점까지의 메서드 호출 순서와 각 호출 지점의 파일 이름, 메서드 이름, 그리고 코드의 라인 번호 등이 포함될 수 있다.
- 결론적으로 "현재 스레드의 실행 스택 정보"란 현재 스레드에서 메서드 호출이 발생한 순서와 각 호출 지점의 세부 정보(메서드 이름, 파일 이름, 라인 번호 등)를 포함하는 스택의 상태를 의미한다. 이 정보는 예외가 발생할 때 생성되는 스택 트레이스를 통해 확인할 수 있다.
6. 내가 궁금했던 점 Q&A 정보
질문 1. 만약 메서드를 50번 호출했으면 실행 스택의 캡쳐본에는 50개의 모든 메서드가 실행순서대로 저장되어 있는 거야?
그렇다. 만약 프로그램에서 메서드가 중첩해서 50번 호출되었다면, 실행 스택의 캡처본에는 그 50개의 메서드 호출이 순서대로 저장된다. 이는 예외가 발생했을 때 생성되는 stackTrace 변수에 반영된다. stackTrace 배열에는 StackTraceElement 객체들이 저장되며, 각 객체는 개별 메서드 호출에 대한 정보(클래스 이름, 메서드 이름, 파일 이름, 라인 번호 등)를 담고 있다.
예외가 발생하면, JVM은 그 시점에서의 실행 스택을 캡처하여 이 배열을 초기화한다. 따라서, 만약 예외가 50번째 메서드 호출에서 발생했다면, stackTrace 배열에는 호출 스택의 맨 아래(처음 호출된 메서드)부터 예외가 발생한 지점까지의 모든 메서드 호출 정보가 순서대로 저장되어 있을 것이다.
질문 2. 간단한 메서드 호출인데도 스프링에서 디버깅을 해보면 70개가 넘는 스택 프레임이 쌓이던데 원래이래? (아래에서 보게 될 디버깅 이미지에서도 76개의 스택이 쌓여있는 것을 확인할 수 있다.)
스프링 프레임워크를 사용하는 애플리케이션에서는 실행 스택이 상당히 깊어질 수 있다. 이는 스프링이 제공하는 다양한 기능들인 AOP(Aspect-Oriented Programming), 트랜잭션 관리, 보안, MVC 패턴 처리 등을 실행하는 과정에서 여러 계층을 거치기 때문이다.
예를 들어, 간단한 HTTP 요청을 처리하는 경우에도 다음과 같은 여러 단계를 거친다.
1. 디스패처 서블릿이 요청을 받음
2. 필터와 인터셉터를 통과
3. 적절한 컨트롤러로 라우팅
4. 서비스 및 리포지토리 계층 호출 (비즈니스 로직 및 데이터베이스 접근)
5. AOP 관련 처리 (보안, 트랜잭션, 로깅 등)
6. 응답 생성 및 반환
각 단계는 스프링의 다양한 구성요소와 사용자 정의 코드를 실행하는 과정에서 추가적인 메서드 호출을 발생시킨다. 또한, 스프링의 프록시 기반 AOP 처리는 실제 대상 메서드를 호출하기 전후로 추가적인 메서드 호출(어드바이스 실행 등)을 수행한다. 이 모든 과정이 실행 스택에 추가되므로, 최종적으로 매우 깊은 실행 스택이 쌓이게 된다.
6. 실전 예외 디버깅: stackTrace에는 어떤 값이 세팅될까?
위의 글을 통해 stack에 대해서 간단히 이해했다면 지금부터는 기존에 디버깅을 진행하던 fillInStackTrace() 메서드를 호출했던 순간으로 되돌아가자. 위의 내용을 통해 fillInStackTrace()가 호출되어 내부적으로 다시 native 코드인 fillInStackTrace()를 호출해서 JVM 내부적으로 현재 스레드의 스택 트레이스 정보를 수집하고, 이 정보를 Throwable 객체에 저장한다는 것은 이해했다.
이제 fillInStackTrace 메서드 내부에 있는 stackTrace 변수에 세팅된 UNASSIGNED_STACK라는 값이 무엇인지 알아보자
값이 세팅되는 stackTrace 변수는 Throwable 클래스 내부에 아래와 같이 선언되어 있다.
- 이때 UNASSIGNED_STACK은 new StackTeaceElement[0]로 새롭게 생성되는데 이것을 알아보자.
private StackTraceElement[] stackTrace = UNASSIGNED_STACK;
private static final StackTraceElement[] UNASSIGNED_STACK = new StackTraceElement[0];
1. StackTraceElement:
- Java에서 StackTraceElement는 호출 스택의 각 프레임을 나타낸다. 각 요소는 클래스 이름, 메서드 이름, 파일 이름, 그리고 해당 지점의 라인 번호를 포함한다. 이 정보는 디버깅을 위해 매우 중요하며, 프로그램에서 어디서 예외가 발생했는지 정확히 알려준다.
2. UNASSIGNED_STACK:
- Throwable 객체가 생성될 때, stackTrace 필드는 UNASSIGNED_STACK로 초기화된다. 이는 예외 객체가 생성되었지만 아직 스택 트레이스 정보가 캡처되지 않았음을 나타낸다. UNASSIGNED_STACK은 길이가 0인 StackTraceElement 배열이다.
StackTraceElement 클래스는 아래와 같이 구현되어 있다. (내부에 메서드들은 더 존재한다.)
public final class StackTraceElement implements java.io.Serializable {
private String classLoaderName;
private String moduleName;
private String moduleVersion;
private String declaringClass;
private String methodName;
private String fileName;
private int lineNumber;
private byte format = 0; // Default to show all
public StackTraceElement(String declaringClass, String methodName,
String fileName, int lineNumber) {
this(null, null, null, declaringClass, methodName, fileName, lineNumber);
}
public StackTraceElement(String classLoaderName,
String moduleName, String moduleVersion,
String declaringClass, String methodName,
String fileName, int lineNumber) {
this.classLoaderName = classLoaderName;
this.moduleName = moduleName;
this.moduleVersion = moduleVersion;
this.declaringClass = Objects.requireNonNull(declaringClass, "Declaring class is null");
this.methodName = Objects.requireNonNull(methodName, "Method name is null");
this.fileName = fileName;
this.lineNumber = lineNumber;
}
private StackTraceElement() {}
public StackTraceElement[] getStackTrace() {
return getOurStackTrace().clone();
}
}
각각의 역할은 이해했다 이제 어떻게 stackTrace의 값이 세팅되는지 알아보자
- fillInStackTrace() 메서드는 예외 객체가 생성되는 시점에 자동으로 호출되어, 그 시점의 실행 스택 정보를 캡처하고, Throwable 객체 내의 stackTrace 필드에 이 정보를 저장한다. 이때 UNASSIGNED_STACK는 스택 트레이스 정보가 아직 할당되지 않은 상태를 나타내는 임시 값으로 사용된다.
- private native Throwable fillInStackTrace(int dummy); 메서드가 호출되면 JVM(Java Virtual Machine) 내부에서 스택 트레이스 정보를 캡처하고 처리한다. 이 메서드는 자바 코드에서 직접적으로 볼 수 없는 네이티브 코드(C/C++ 등)로 구현되어 있으며, JVM 구현에 따라 다를 수 있다.
- private native Throwable fillInStackTrace(int dummy); 네이티브 메서드 호출 후에는 UNASSIGNED_STACK에 처음 설정된 빈 배열(StackTraceElement[0] )이 아니라, 실제 스택 트레이스 정보가 담긴 새로운 StackTraceElement[] 배열이 stackTrace 필드에 할당된다.
📌 결론
결론적으로 fillInStackTrace() 메서드의 호출 결과로, stackTrace 필드는 현재 스레드의 실행 스택을 나타내는 StackTraceElement 객체의 배열로 채워진다. 이 배열에는 예외가 발생한 각 지점에서의 클래스 이름, 메서드 이름, 파일 이름, 그리고 해당 지점의 코드 라인 번호가 포함되어 있어서, 예외 발생 지점을 추적할 수 있게 해 준다.
이제 stackTrace 내부를 디버깅을 통해 살펴보면 실행 스택이 잘 세팅된 것을 확인할 수 있다.
- 이 과정을 이해하기 위해 참 길고 긴 과정을 거쳤는데 확인은 단번에 끝나버려서 약간 허무하기도 하다. 우리들은 이런 예외처리 과정을 몰라도 로깅을 통해 오류를 확인하며 개발, 수정이 가능한 걸 보면 스프링은 역시 개발자가 개발에만 집중할 수 있게 해주는 편리하고 강력한 프레임워크가 확실하다.
stackTrace가 세팅되는 것을 확인했으니 디버깅을 종료하자
- 콘솔창을 확인해 보면 에러 로그와 함께 stackTrace 로그가 남겨져있다. 로그 내용을 자세히 보면 위의 stackTrace 변수 내부에 세팅된 StackTraceElement의 값들이 콘솔에 적힌 stackTrace정보와 동일하다는 것을 알 수 있다.
stackTrace는 이런 과정을 거쳐 세팅되어 개발자의 오류 처리에 도움을 주고 있는 것이었다.
7. stackTrace는 성능에 영향이 있을까?
스택 트레이스를 찍는 것이 성능에 미치는 영향은 애플리케이션의 특성, 로그 출력 빈도, 시스템 환경 등 여러 요소에 따라 다르다. 그러나, 과도한 스택 트레이스 로깅은 다음과 같은 이유로 성능 저하를 유발할 수 있다고 한다.
1. 메모리 사용 증가:
- 스택 트레이스 정보는 많은 양의 메모리를 사용할 수 있으며, 이는 가비지 컬렉션(GC)의 부담을 증가시킬 수 있다.
2. I/O 부하 증가:
- 로그 파일에 스택 트레이스를 출력하는 것은 파일 시스템에 대한 I/O 작업을 수행하므로, 로깅이 많을수록 I/O 부하가 증가한다.
3. CPU 사용 증가:
- 스택 트레이스를 캡처하고 처리하는 과정에서 CPU 자원을 소모한다. 특히, 로깅 레벨이 낮고(debug 이하) 로깅이 빈번하게 발생하는 시스템에서 더 큰 영향을 미칠 수 있다.
그럼 어떻게 해야 stackTrace로 인한 성능 저하를 최소화시킬 수 있을까?
1. 로깅 레벨 조정(try-catch)
- 오류 로깅 시 ERROR 레벨로 제한하고, DEBUG 또는 INFO 레벨에서는 스택 트레이스를 출력하지 않도록 설정한다. 스택 트레이스가 반드시 필요한 경우에만 출력하도록 조정하는 것이 좋다.
- 예를 들어, 애플리케이션에서 예외가 발생했을 때 logger.error("에러 메시지", e); 같은 방식으로 로그를 남기면, 로그 레벨이 에러(ERROR)로 설정되어 있을 때 해당 예외의 스택 트레이스가 로그에 포함되어 출력된다. 반면, 로그 레벨을 에러보다 낮은 수준으로 설정하면, 이러한 에러 로그(스택 트레이스 포함)는 출력되지 않는다.
2. 로깅 레벨 조정(@ControllerAdvice, @RestControllerAdvice):
- try-catch를 사용하지 않고 예외 처리를 하지 않는 경우에 예외는 호출 스택을 따라 상위로 전파되어 애플리케이션의 다른 부분이나 최종적으로는 JVM에 의해 처리되게 된다. 이 경우, 스택 트레이스의 로깅 여부는 해당 예외를 캐치하고 처리하는 부분(예: 글로벌 예외 핸들러)에서 결정된다.
- 스프링 같은 경우 @ControllerAdvice나 @RestControllerAdvice를 사용해 애플리케이션 전반에서 발생하는 예외를 처리할 수 있는 글로벌 예외 핸들러를 구현할 수 있다. 이 핸들러 내에서 로깅을 어떻게 할지 결정할 수 있으며, 스택 트레이스를 로그에 포함시킬지 여부도 이곳에서 결정하게 된다.
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleException(Exception e) {
// 예외 로깅, 스택 트레이스 포함
logger.error("Unhandled exception occurred", e);
// 클라이언트에 보낼 에러 응답 생성 및 반환
...
}
}
- 위 예제 코드에서는 모든 Exception 타입의 예외를 처리하며, 예외 발생 시 logger를 사용해 에러 메시지와 함께 스택 트레이스를 로그에 기록한다. 만약 스택 트레이스의 로깅을 원치 않는다면, logger.error("Unhandled exception occurred", e); 부분을 logger.error("Unhandled exception occurred: " + e.getMessage());와 같이 수정하여 스택 트레이스 없이 에러 메시지만 로그에 남길 수도 있다.
3. 조건부 로깅 사용:
- 성능에 크게 영향을 주지 않으면서도 오류 분석에 필요한 정보를 충분히 제공할 수 있도록, 스택 트레이스의 일부만 로깅하거나 특정 조건에서만 로깅하도록 한다.
4. 비동기 로깅 활용:
- 로깅 작업 자체가 애플리케이션의 성능에 영향을 미치지 않도록, 로그를 비동기적으로 처리하는 로깅 프레임워크나 라이브러리를 사용한다. (Logback의 AsyncAppender, Log4j2의 LMAX Disruptor 라이브러리)
5. 모니터링 도구 활용:
- 시스템의 성능 모니터링 도구를 사용하여 로깅의 영향을 주기적으로 모니터링하고, 필요에 따라 로깅 전략을 조정한다. (Spring Boot Actuator, Micrometer)
8. 최적화 방법 찾아보기
Throwable 클래스 내부에는 세팅을 위한 fillInStackTrace() 메서드가 있는 만큼 반대로 값을 조회하는 getStackTrace()라는 메서드도 존재한다. 이 메서드는 StackTraceElement[]를 반환하는데 이 반환값 0번째 인덱스의 요소가 실제 예외가 발생된 지점이라고 생각하면 된다.
이번에 이 내용들을 분석해보다 보니 예외가 발생하면 stackTrace에 대한 모든 정보를 다 로그를 남겨야 할까? 이런 고민을 하게 되었다. 내가 오류를 찾을 때는 발생 지점에 대한 정보만 파악한 다음 그 부분(클래스나 메서드)을 다시 디버깅하면서 확실한 문제점을 찾곤 한다. 그렇다면 오류가 발생했던 지점에 대한 로그만 남긴다면 최적화가 가능하지 않을까? 그리고 이것은 위에서 설명한 StackTraceElement[]의 0번째 요소만 출력해도 되는 거 아닐까?라는 생각을 하게 되었다.
분명 나 말고도 다른 개발자분들이 같은 생각을 했을 것이라 생각했고 이미 이렇게 적용시킨 사례가 있는지 구글 검색을 통해 개발 블로그 글을 찾아봤는데 역시 이미 같은 생각을 하셨던 선배님의 글을 존재했고 작성해 주신 코드가 있어서 나도 적용시켜 봤다.
모든 코드를 적지는 않았지만 @RestControllerAdvice의 @ExceptionHandler 내부의 코드에 아래와 같이 적용시키면 된다.
StackTraceElement[] stackTrace = e.getStackTrace();
logger.error(e.getMessage(), stackTrace[0].toString());
이에 대한 자세한 내용은 아래 지식 공유자님의 블로그 글을 읽어보고 적용시켜 보면 될 것이라고 생각한다. (엄청 잘 적어주셨다.)
'Spring + Java' 카테고리의 다른 글
스프링에서 도메인 객체를 사용하는 건에 대해 (6) | 2024.08.31 |
---|---|
[Spring] synchronized를 사용한 동시성 문제 해결방법 (1) | 2024.06.07 |
[Spring] 자바 리플렉션과 생성자 주입의 관계 (1) | 2023.11.19 |
[Spring] 스프링은 추상화를 어떻게 적용했을까? (0) | 2023.11.02 |
[Spring] 스프링의 익명 클래스 활용 (0) | 2023.08.09 |