예외 깊게 살펴보기, 예외 동적으로 던지기
예외 처리의 중요성과 동적으로 예외를 던지는 방법에 대해 살펴보며, 코드 품질을 개선하는 기법을 논의합니다.
이번에 코드를 작성하던 중 코드 리뷰에서 아쉬운 점에 대한 리뷰를 받았다.
문제의 코드 예시이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ...BackOfficeSaveDto
public void validate() {
try {
option.validate();
} catch (IllegalArgumentException e) {
throw new BusinessException("백 오피스 저장 중 문제 발생: %s".formatted(e.getMessage()), e);
}
}
// ...ClientSaveDto
public void validate() {
try {
option.validate();
} catch (IllegalArgumentException e) {
throw new BusinessException("클라이언트 저장 중 문제 발생: %s".formatted(e.getMessage()), e);
}
}
// Option
public void validate() {
if (model.isInactive()) {
throw new IllegalArgumentException("모델이 비활성화 입니다. 모델 타입: %s".formatted(model.name()));
}
}
- 중복된 코드가 발생한다. -
DRY ( Do not repeat yourself! )
- 예외를 잡아서 단순 변환만 해서 던진다.
단순하게 생각해서 try-catch 로 관심사를 다르게 하려고 했는데 무겁고, 보기 좋지 않은 코드인걸 깨달았다.
해당 부분은 다소 애매할 수 있다. 코드 품질 개선 기법 1편: 한 번 엎지른 error는 다시 주워 담지 못한다 아티클에서
만약 호출자의 코드가 결정되지 않으면 복구 가능 여부를 판단할 수 없는 경우에는 일단 다루기 쉬운 방식으로 에러를 반환한 후 호출자 측에서 다른 에러로 변환하는 것도 고려해야 합니다.
라는 내용이 있다. 이 또한 코드 컨벤션 및 기준의 차이일 수 있다. 🙂
예외를 왜 가볍게 사용하면 안되는지, 예외를 원하는 대로 던지는 방법에 대해 살펴보자.
예외
객체 생성 오버헤드
Throwable
인터페이스 만들 때 JVM 힙에서 객체 메모리 할당
detailMessage
,cause
,stackTrace
등 여러 필드를 가짐 (특히 스택 트레이스는 무겁다.)- 예외 객체는 거의 사용 후 바로 버려지는 경향이 있음 (
short-lived
) - GC 비용이 커짐
스택 트레이스 수집 비용
자바에서 예외는 추적이 용이하게 스택 트레이스를 활용한다.
1
2
3
4
public Throwable(String message) {
fillInStackTrace();
detailMessage = message;
}
Throwable 클래스의 생성자에는 fillInStackTrace()
메소드가 있다.
1
2
3
4
5
6
7
8
public synchronized Throwable fillInStackTrace() {
if (stackTrace != null ||
backtrace != null /* Out of protocol state */) {
fillInStackTrace(0);
stackTrace = UNASSIGNED_STACK;
}
return this;
}
이 부분에서 즉시 채워지지 않는다. -
private static final StackTraceElement[] UNASSIGNED_STACK = new StackTraceElement[0]
Stream과 비슷하게 필요할 때 (getStackeTrace()
,printStackTrace
등) 채워서 보여준다.
1
2
3
4
5
6
7
8
9
10
static StackTraceElement[] of(Object x, int depth) {
StackTraceElement[] stackTrace = new StackTraceElement[depth];
for (int i = 0; i < depth; i++) {
stackTrace[i] = new StackTraceElement();
}
// VM to fill in StackTraceElement
initStackTraceElements(stackTrace, x, depth);
return of(stackTrace);
}
VM 은 예외가 발생한 스레드 식별
, 프레임 포인터 스텝 추적
( 스레드의 호출 스택을 프레임 단위로 올라감 ), 라인 번호 조회
등등
보기만 해도 뭔가 많아 보이지 않는가?
1
2
3
4
5
6
7
8
9
10
at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
( 이렇게 모든 요소들을 보여주는건 마법처럼 일어나는게 아니다. 💣 )
결국 예외가 스택에 추가될 때마다 이러한 작업이 발생한다.
스택 트레이스로 인한 GC
예외 객체와 수집된 StackTraceElement
는 곧바로 참조가 끊겨 GC 대상이 된다. -> GC 회수가 늘어나며 애플리케이션 전체 응답성 저하
01:10:27.252 [http-nio-8080-exec-9] [INFO ] [c.m.i.a.t.c.Controller] - StackTrace 길이 : 234
Spring 로직에서 발생한 예외
1
2
3
public void validate() {
option.validate();
}
검증 부분에서 예외가 발생하면? ``01:10:27.252 [http-nio-8080-exec-9] [INFO ] [c.m.i.a.t.c.Controller] - StackTrace 길이 : 234` Spring AOP 로 인해 어마어마한 스택 트레이스가 생성된다.
좀 더 들어가보자. JOL(Java Object Layout)
을 활용해서 직접적인 메모리 구조를 분석해보자.
스택 트레이스 배열은 960 바이트를 가진다. ( 16 + 940 + 4(패딩) = 960 으로 추정 )
5c2129458 960 [Ljava.lang.StackTraceElement; .stackTrace [(object), (object) ...]
5c212c3c8 48 java.lang.StackTraceElement .stackTrace[187] (object)
간단히 계산해보면 960 + 48 *
234 = 12,192 이상의 바이트를 차지한다.
예전 마리오가 4KB로 돌아갔다고 하는데… 우리는 예외 4개도 못 띄운다 ㅋㅋ
1
2
Deep size: 18536 bytes
Retained objects count: 300
JOL 은 이렇게 정보를 제공해준다.
물론 이미 발생한 하나의 예외에서 한 개 쯤 올라간다고 해도
1
2
Deep size: 18592 bytes
Retained objects count: 301
56 바이트 정도로 드라마틱 하게 올라가지는 않는다.
성능 저하 유발
예외 발생 여부는 드문 분기로 처리되어, 정상 경로
가 예측되어 파이프라인이 채워질 수 있는데 예외 발생 시 예측이 틀리며 파이프라인이 플러시 되고 다시 채워져야 한다. -> 높은 사이클 지연 발생
JIT 컴파일러가 예외 가능성이 있는 블록은 최적화 대상에서 제외할 수 있다.
동적으로 던지기
그러면 한 번이라도 더 불필요한 예외를 던지기 위해서 할 수 있는 방법들은 뭐가 있을까?
Flag, Status
1
2
3
4
5
6
7
public void validate(boolean isBusiness) {
if (isBusiness) {
throw new BusinessException("...");
} else {
throw new IllegalArgumentException("...");
}
}
1
2
3
4
5
6
7
public void validate(ExceptionStatus status) {
switch (status) {
case PRODUCTION -> ...
case DEVELOP -> ...
default -> ...
}
}
어떤 경우일때, 어떤 예외를 던진다.
, 이 규칙은 변할 가능성이 없다.
와 같은 상황 일때는 이런 분기문이 더 명확한 코드가 작성될 수 있다.
-> 하지만, 대부분의 코드들은 이런 변화를 예상하지 못하고, 장담하기 어려울 것이다.
Reflection
언제든 바뀔수 있게 플래그 없이 외부에서 클래스를 넘겨주는 것도 방법이다.
1
2
3
4
5
6
7
8
9
10
public void validate() {
option.validateModelType(IllegalArgumentException.class);
}
default void validateModelType(Class<? extends RuntimeException> clazz) {
if (getModelType().isInactive()) {
throw createExceptionInstance(clazz,
"비활성화된 모델 타입입니다. (modelType: %s)".formatted(getModelType()));`
}
}
1
2
3
4
5
6
7
8
9
10
11
@SuppressWarnings("unchecked")
static <E extends RuntimeException> E createExceptionInstance(
Class<? extends Exception> exceptionType, String message) {
try {
return (E) exceptionType.getConstructor(String.class).newInstance(message);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(
"예외 생성에 실패했습니다: " + exceptionType.getName(), e);
}
}
사용하는 측에서 클래스를 넘겨주고, 내부에서 클래스 정보를 기반으로 생성해준다. 매번 리플렉션을 사용하기 싫다면
1
2
3
4
private static final Map<
Class<? extends RuntimeException>,
Function<String, ? extends RuntimeException>
> EXCEPTION_FACTORIES = Map.of(IllegalArgumentException.class, IllegalArgumentException::new);
MAP 에 미리 넣어두는 것도 하나의 방법
함수형
현재의 코드는 다소 아쉬운 점이 많다.
throw createExceptionInstance(clazz, "비활성화된 모델 타입입니다. (modelType: %s)".formatted(getModelType()));
- 우선 메소드를 호출해야만 한다. 특히, static 메소드를 호출한다.
- 클래스 정보를 매개변수로 넘겨준다.
- 리플렉션을 활용하거나 MAP에 미리 넣어두는 등의 작업을 필요하다.
이 3가지의 문제점을 함수형으로 우아하게 해결해버릴 수 있다. 😎
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 문자열을 받아서 예외를 생성하는 함수형 인터페이스
* <p>
* 생성되는 예외를 외부에서 결정하고 싶을 때 사용합니다.
* ( 똑같은 validation 로직을 사용하더라도, CustomException과 IllegalArgumentException을 다르게 throw )
* * @param <E> Exception 을 상속받는 타입
*/
@FunctionalInterface
public interface ExceptionCreator<E extends Exception> {
/**
* 메시지를 사용하여 예외를 생성합니다.
* <p>
* EX) imageToImageOption.validate(IllegalArgumentException::new);
* * @param message 예외에 사용될 메시지
* @return 생성된 예외
*/
E create(String message);
}
문자열을 받아서 함수형을 생성하는 함수형 인터페이스를 만들면
1
2
3
4
public void validate(ExceptionCreator<? extends RuntimeException> exceptionCreator) {
throw exceptionCreator.create(
"열거형에 존재하지 않는 외부 요청으로 처리 불가능합니다. 외부 요청 옵션: %s".formatted(externalApiOption));
}
메소드를 호출하지 않고, 직접 자신이 생성 + 클래스 정보 역시도 알 필요가 없다.
1
2
3
4
5
6
7
public void validate() {
option.validate(InvalidInputException::new);
}
public void validate() {
option.validate();
}
근데, 결국 어느 부분에선 ExceptionCreator<? extends RuntimeException> exceptionCreator
매개변수를 복잡하게 넘겨야 한다.
결론
빠르게 코드를 작성해야 한다면 단순히 try-catch 를 통해 변환을, 조금 더 깔끔한 코드를 고려한다면 자신이 적절하게 판단을 해서 선택하면 된다. 적절히 팀원들이 이해할 수 있는, 그리고 만족할 수 있는 컨벤션을 만들어나가자.