
처음엔 외부 AI 서버의 에러만 처리하기 위해, 원본 DTO를 담는 RawAiErrorException을 만들었습니다.
@Getter
public class RawAiErrorException extends RuntimeException {
private final HttpStatus httpStatus;
// [문제점] AiErrorResponse 타입과 강하게 결합됨
private final AiErrorResponse rawErrorResponse;
// ...
}
하지만 여러 외부 서버(AI 서버, 결제 서버 등)와 연동한다면 위 예외는 AiErrorResponse 전용이라 재사용할 수 없었습니다.
이 문제를 해결하기 위해 '제네릭(Generic)'을 사용하여 공통 예외 클래스를 만들면 좋겠다는 생각이 들었습니다
// 처음에 구상했던 잘못된 예외 클래스
@Getter
// 💥 컴파일 에러: Generic class may not extend 'java.lang.Throwable'
public class RawExternalApiException<T> extends RuntimeException {
private final HttpStatus httpStatus;
private final T rawErrorResponse; // AI 에러, 결제 에러 등 모든 DTO를 받으려 함
// ...
}
AI 서버는 catch (RawExternalApiException<AiErrorResponse> e)로, 결제 서버는 catch (RawExternalApiException<PaymentErrorResponse> e)로 잡으면 되겠다고 생각했지만
이 코드는 "제네릭 클래스는 'java.lang.Throwable'을 확장할 수 없습니다"라는 컴파일 에러를 발생시켰습니다.
결론부터 말하면, Java 제네릭의 동작 방식(타입 소거)과 try-catch의 동작 방식(런타임)이 충돌하기 때문이었습니다.
.java)가 바이트코드(.class)로 번역되는 시점.실행되는 시점.컴파일 타임에 타입을 검사하는 데 사용된 제네릭 정보(예: List의 String)가, 런타임에는 모두 소거되고 List와 같은 원시 타입(Raw Type)만 남습니다.
즉, 런타임에 JVM이 아는 것은 List이 아니라, 그냥 List뿐인 것입니다.
반면 try-catch 문은 런타임에 동작합니다. try에서 예외가 발생하면, JVM은 그 예외 객체의 실제 타입을 런타임에 확인하고, 일치하는 catch 블록을 찾아 실행시킵니다.
여기서 문제가 발생합니다. 만약 Java가 제네릭 예외를 허용했다면, 아래 코드는 런타임에 어떻게 보일까요?
// 만약 제네릭 예외가 허용된다면...
try {
// ...
} catch (RawExternalApiException<AiErrorResponse> e) { // 1번
// AI 에러 처리
} catch (RawExternalApiException<PaymentErrorResponse> e) { // 2번
// 결제 에러 처리
}
A에서 설명했듯이, 제네릭은 런타임에 소거됩니다. 따라서 JVM의 눈에는 이 코드가 다음과 같이 보이게 됩니다.
// 런타임에 JVM이 실제로 보는 코드 (타입 소거 후)
try {
// ...
} catch (RawExternalApiException e) { // 1번
// ...
} catch (RawExternalApiException e) { // 2번
// ...
}
<AiErrorResponse>와 <PaymentErrorResponse> 정보가 사라지면서, 1번과 2번 catch 블록은 완벽히 똑같은 코드가 되어버립니다.
이 상황에서 RawExternalApiException이 발생했을 때, JVM은 1번과 2번 중 어느 블록을 실행해야 할지 전혀 알 수 없게 됩니다.
Java는 이런 치명적인 모호성을 원천적으로 차단하기 위해, "예외 클래스(Throwable의 하위 클래스)는 제네릭이 될 수 없다"라는 언어 규칙을 만든 것입니다.
그럼 "재사용 가능한 공통 예외"는 어떻게 만들 수 있었을까요?
🎯 전략: 예외 클래스는 제네릭이 될 수 없지만, 클래스 내의 메서드는 제네릭이 될 수 있습니다.
Object로 받기먼저, 예외 클래스 자체는 제네릭을 포기합니다. 대신 모든 타입을 담을 수 있는 Object로 필드를 선언합니다.
@Getter
public class RawExternalApiException extends RuntimeException {
private final HttpStatus httpStatus;
// [핵심 1] 'Object' 타입으로 선언하여 어떤 DTO든 담기
private final Object rawErrorResponse;
public RawExternalApiException(HttpStatus httpStatus, Object rawErrorResponse) {
this.rawErrorResponse = rawErrorResponse;
// ...
}
// ...
}
이제 catch (RawExternalApiException e)는 런타임에 아무런 모호성이 없으므로 잘 동작합니다.
Object로 담긴 DTO를 다시 AiErrorResponse 등으로 안전하게 형변환할 방법이 필요합니다. 여기서 제네릭 메서드를 사용합니다.
@Getter
public class RawExternalApiException extends RuntimeException {
// ... (이전 코드)
private final Object rawErrorResponse;
// ...
/**
* [핵심 2] 타입-안전한 제네릭 '메서드'
* 클래스가 아닌 '메서드'에 <T>를 선언하는 것은 허용됩니다.
* 호출하는 쪽에서 "어떤 타입"으로 꺼낼지 Class<T> 정보를 주면,
* 이 메서드가 런타임에 타입을 검사하고 변환(cast)해 줍니다.
*/
public <T> T getRawErrorResponse(Class<T> type) {
// 런타임에 타입이 일치하는지 검사
if (type.isInstance(rawErrorResponse)) {
return type.cast(rawErrorResponse);
}
// 타입이 안 맞으면 프로그래밍 오류!
throw new IllegalStateException("Type mismatch in RawErrorResponse. Expected "
+ type.getName() + " but got " + rawErrorResponse.getClass().getName());
}
}
이제 catch 블록에서 이 제네릭 메서드를 호출하여 DTO를 안전하게 꺼내 쓸 수 있습니다.
try {
// aiServer.call(); // 여기서 RawExternalApiException(..., new AiErrorResponse(...)) 발생!
} catch (RawExternalApiException e) {
// 1. Non-Generic 예외를 잡고 (OK)
// 2. '제네릭 메서드'를 호출하여 타입-안전하게 DTO 추출
AiErrorResponse dto = e.getRawErrorResponse(AiErrorResponse.class);
// ... (dto를 사용한 공통 예외 처리 및 번역 로직)
}
이 해결책은 rawErrorResponse 필드를 Object 타입으로 선언했습니다. 이 때문에 컴파일 타임에 타입이 올바른지 검사하는 이점을 포기해야 합니다.
즉, 예외를 만드는 시점에 컴파일러는 rawErrorResponse 필드에 엉뚱한 DTO가 들어가는 것을 막아주지 못합니다.
대신, 타입 검사 시점을 컴파일 타임에서 런타임으로 옮겼습니다. 즉, catch 블록에서 getRawErrorResponse 메서드를 호출할 때 비로소 타입 검사가 이루어지는 것입니다
물론 이 방식은 예외를 만드는 쪽과 처리하는 쪽이 어떤 DTO를 사용할지 서로 명확히 약속했다는 전제하에 안전하게 동작합니다.
저의 경우, ErrorHandler가 예외를 생성하고 AOP Aspect가 DTO 타입을 지정하여 예외를 처리하도록 설계하여 이 약속이 지켜지도록 했습니다. (이 내용은 추후 다뤄보도록 하겠습니다.)
이전까지 제네릭은 단순히 타입을 편리하게 쓰기 위한 문법 정도로만 생각했습니다. 하지만 이번 경험을 통해 제네릭이 본질적으로 컴파일 타임에 작동하는 개념임을 이해하게 되었습니다.