Modern Beautiful API Response Design: Internal Exception Model
TOC
- Overview
- 평면적인 예외 정의의 문제
- 설계 목표
- 1. 공통 부모 예외
- 2. 계층별 예외 분리
- 3. 계층별 베이스 예외
- 4. 번역 책임의 위치
- 5. ErrorCode는 분류다
- 6. ProblemSpec은 공개 명세다
- 7. 커스텀 예외와의 결합
- Closing
Overview
예외를 설계하지 않은 시스템은 결국 문자열에 의존하게 된다.
처음에는 표준 예외만으로도 기능 구현이 가능해 보이지만, 서비스가 커질수록 문제가 드러난다. 예외 타입과 메시지 형식이 제각각이며, 비즈니스 오류인지 인프라 장애인지 구분하기 어렵다. 클라이언트는 문장 파싱에 가까운 분기 처리를 하게 되고, 서버는 로그를 봐도 무엇이 의도된 실패인지 판단하기 어렵다.
예외에도 족보가 필요하다. 예외를 계층화하면 실패의 출처와 책임을 더 명확하게 통제할 수 있다.
이 글의 범위는 서버 내부 예외 모델까지다. 즉, 공통 부모 예외, 계층별 예외, ErrorCode, ProblemSpec이 내부에서 어떤 책임을 가져야 하는지까지 설명한다.
평면적인 예외 정의의 문제
예외를 즉흥적으로 추가하면 다음과 같은 문제가 발생한다.
- 동일한 실패 상황에서도 예외 타입과 메시지가 파편화된다.
- 예외가 발생한 계층의 역할이 모호해진다.
- 로그에서 의도된 비즈니스 실패와 실제 시스템 장애를 분리하기 어렵다.
- 예외 번역 책임이 뒤섞여 응답 계약도 흔들린다.
설계 목표
계층형 예외 설계의 목표는 명확하다.
- 모든 커스텀 예외가 공통 부모를 따른다.
- 각 계층은 자기 책임에 맞는 예외만 던진다.
- 예외 번역 책임은 프레젠테이션 계층으로 밀어낸다.
1. 공통 부모 예외
모든 예외의 근간이 되는 BaseException은 최소한의 공통 계약만 가진다. 운영 코드에서는 JgitkinsException처럼 “명시 메시지가 없으면 기본 메시지로 fallback”하는 생성자 체계를 두는 편이 실용적이다.
public interface ErrorCode {
String getCode();
String getDefaultMessage();
}
public abstract class BaseException extends RuntimeException {
private final ErrorCode errorCode;
protected BaseException(ErrorCode errorCode) {
this(errorCode, null, null);
}
protected BaseException(ErrorCode errorCode, String message) {
this(errorCode, message, null);
}
protected BaseException(ErrorCode errorCode, String message, Throwable cause) {
super((message == null || message.isBlank())
? errorCode.getDefaultMessage()
: message, cause);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
2. 계층별 예외 분리
ApplicationException: 흐름 제어, 조회 결과, 권한DomainException: 비즈니스 규칙, 상태 전이, 정책 위반InfrastructureException: DB, 파일시스템, 외부 라이브러리 실패
이렇게 나누면 실패 원인을 “무슨 일이 일어났는가”뿐 아니라 “어느 계층 책임인가”까지 함께 표현할 수 있다.
3. 계층별 베이스 예외
이제 BaseException을 상속받아 계층별 최상위 예외를 만든다. 각 계층은 자기 계층의 명세만 받도록 제한해야 한다.
public abstract class DomainException extends BaseException {
private final DomainProblemSpec problemSpec;
protected DomainException(DomainProblemSpec problemSpec) {
super(problemSpec.getErrorCode());
this.problemSpec = problemSpec;
}
public String getSpecificCode() {
return problemSpec.getCode();
}
}
public abstract class ApplicationException extends BaseException {
private final ApplicationProblemSpec problemSpec;
protected ApplicationException(ApplicationProblemSpec problemSpec) {
super(problemSpec.getErrorCode());
this.problemSpec = problemSpec;
}
public String getSpecificCode() {
return problemSpec.getCode();
}
}
public abstract class InfrastructureException extends BaseException {
private final InfrastructureProblemSpec problemSpec;
protected InfrastructureException(InfrastructureProblemSpec problemSpec, Throwable cause) {
super(problemSpec.getErrorCode(), null, cause);
this.problemSpec = problemSpec;
}
public String getSpecificCode() {
return problemSpec.getCode();
}
}
이 구조는 도메인 로직에서 인프라 예외를 던지는 식의 계층 오염을 컴파일 타임에 줄여 준다.
4. 번역 책임의 위치
비즈니스 로직은 순수하게 예외만 던지고, 이를 실제 HTTP 응답으로 바꾸는 책임은 @RestControllerAdvice가 담당한다.
@RestControllerAdvice
public class GlobalExceptionAdvisor {
@ExceptionHandler(ApplicationException.class)
public ResponseEntity<CommonResponse<?>> handleApplicationException(ApplicationException e) {
HttpStatus status = determineHttpStatus(e.getErrorCode());
return ResponseEntity.status(status)
.body(CommonResponse.fail(e.getSpecificCode(), e.getMessage()));
}
}
이 시점까지는 아직 서버 내부 모델이다. 실제 외부 응답 포맷 자체는 다음 글에서 다룬다.
5. ErrorCode는 분류다
예외 계층만 나눈다고 계약이 완성되지는 않는다. 각 계층이 가질 수 있는 상태 범주도 정리해야 한다.
ErrorCode의 구현체인 enum에는 "USER-404" 같은 구체 코드를 넣지 않는다. 대신 각 계층이 가질 수 있는 본질적인 실패 범주만 둔다.
public enum DomainErrorCode implements ErrorCode {
RULE_VIOLATION("RULE_VIOLATION", "Domain rule violation"),
INVALID_STATE("INVALID_STATE", "Domain state transition is invalid"),
POLICY_VIOLATION("POLICY_VIOLATION", "Domain policy violation");
}
예를 들면:
ApplicationErrorCode: 흐름 제어, 인증/인가, 조회 결과DomainErrorCode: 비즈니스 규칙, 상태 전이, 정책 위반InfraErrorCode: DB, 파일시스템, 외부 라이브러리 실패
6. ProblemSpec은 공개 명세다
ProblemSpec이 필요한 이유는 단순히 "USER-404" 같은 문자열을 한곳에 모으기 위해서가 아니다. 더 중요한 목적은 계층별 상태 범주와 예외별 식별자를 분리하는 데 있다.
ApplicationErrorCode, DomainErrorCode, InfrastructureErrorCode는 각 계층이 가질 수 있는 본질적인 실패 범주만 표현해야 한다. 반대로 클라이언트가 분기 처리에 사용할 "USER-404", "REPO-409" 같은 식별자는 훨씬 더 구체적이고 자주 늘어나거나 바뀔 수 있다. 이 식별자까지 ErrorCode enum에 넣어버리면, 계층 enum이 범주 표현이 아니라 개별 사례 목록으로 비대해진다.
그래서 ProblemSpec은 계층 enum을 건드리지 않고도, 예외별 식별 코드, 기본 메시지, 메시지 키 같은 구체 명세를 중앙에서 관리하게 해 준다. 즉, ErrorCode는 분류, ProblemSpec은 공개 명세다.
public interface ProblemSpec<T extends ErrorCode> {
T getErrorCode();
String getCode();
String getDefaultMessage();
String getMessageKey();
}
실무에서는 공통 ProblemSpec 인터페이스 위에 계층별 카탈로그 enum을 두는 방식이 가장 단순하다.
public enum ApplicationProblemSpec implements ProblemSpec<ApplicationErrorCode> {
USER_NOT_FOUND(ApplicationErrorCode.NOT_FOUND, "USER-404", "User not found", "user.notFound");
// 필드, 생성자, getter 생략
}
public enum DomainProblemSpec implements ProblemSpec<DomainErrorCode> {
INVALID_ORDER_STATE(DomainErrorCode.INVALID_STATE, "ORDER-409", "Order state is invalid", "order.invalidState");
// 필드, 생성자, getter 생략
}
7. 커스텀 예외와의 결합
실제 비즈니스 로직에서는 상태(Enum)와 코드(String)를 예외 클래스 내부 문자열이 아니라, 계층별 ProblemSpec 카탈로그에서 결합한다.
public class UserNotFoundException extends ApplicationException {
public UserNotFoundException() {
super(ApplicationProblemSpec.USER_NOT_FOUND);
}
}
public class InvalidOrderStateException extends DomainException {
public InvalidOrderStateException() {
super(DomainProblemSpec.INVALID_ORDER_STATE);
}
}
이렇게 하면 예외 클래스는 “무슨 상황인가”만 표현하고, 코드 체계의 유일성과 명세 소유권은 ProblemSpec 카탈로그에 모을 수 있다.
Closing
예외 계층, ErrorCode, ProblemSpec을 함께 정리하면 내부 예외 모델의 뼈대가 완성된다. 이 글의 목적은 어디까지나 서버 내부에서 실패를 어떻게 모델링할 것인가를 정리하는 데 있다.
다음 글에서는 이 내부 예외 모델을 바탕으로 외부 에러 응답 계약을 어떻게 설계할지 다룬다.