..

Modern API Response Design: Exception Hierarchy and Error Codes

Overview


예외를 설계하지 않은 시스템은 결국 문자열에 의존하는 시스템이 된다.

처음에는 IllegalArgumentException, RuntimeException, Exception만으로도 기능 구현이 가능해 보인다. 하지만 서비스가 커질수록 문제가 드러난다. 같은 400 계열 오류인데도 메시지 형식은 제각각이고, 어떤 예외는 비즈니스 오류인지 인프라 장애인지 구분하기 어렵다. 클라이언트는 문장 파싱에 가까운 분기 처리를 하게 되고, 서버는 로그를 봐도 무엇이 의도된 실패인지 무엇이 진짜 장애인지 빠르게 판단하기 어려워진다.

그래서 예외에도 족보가 필요하다. 예외를 계층화하고, 사람이 아니라 시스템이 이해할 수 있는 ErrorCode를 중심에 두면 API 응답, 로깅, 모니터링, 운영 가시성이 모두 좋아진다.

평면적인 예외 정의가 만드는 문제


예외를 필요할 때마다 즉흥적으로 추가하면 보통 아래 문제로 이어진다.

  1. 같은 종류의 실패라도 예외 타입과 메시지가 제각각이다.
  2. HTTP 상태 코드 결정 기준이 예외마다 달라진다.
  3. 장애 로그에서 의도된 비즈니스 실패와 시스템 장애를 분리하기 어렵다.
  4. 프론트엔드가 안정적인 분기 기준 없이 message 문구에 의존하게 된다.

핵심 문제는 “예외가 곧 계약이어야 하는데, 계약 대신 우발적인 구현 세부사항이 응답으로 새고 있다”는 점이다.

설계 목표


계층형 예외와 ErrorCode를 설계할 때 목표는 단순하다.

  1. 모든 예외가 공통 계약을 가지게 한다.
  2. 사람이 읽는 메시지와 시스템이 읽는 코드를 분리한다.
  3. 예외의 성격을 계층으로 구분해 후속 처리 정책을 통일한다.
  4. API 응답 포맷, 로깅, 알림 정책의 기준점을 ErrorCode에 모은다.

BaseException은 무엇을 공통화해야 할까


TODO: 목차명변경 to BaseException:

BaseException의 역할은 “모든 커스텀 예외가 반드시 가져야 하는 최소 계약”을 강제하는 것이다. 최소한 아래 항목은 일관되게 묶는 편이 좋다.

  • errorCode: 클라이언트와 서버가 함께 참조할 수 있는 안정적인 식별자
  • message: 사람이 읽을 기본 설명
  • cause: 원인 예외 추적

실무에서는 여기에 HttpStatus를 직접 넣을지 고민하게 된다. 엄격한 헥사고날 아키텍처 관점에서는 도메인 계층이 HTTP를 모르도록 분리하는 편이 더 깔끔하다. 다만 Spring MVC 기반 API 서비스에서는 ErrorCode에 상태 코드를 함께 두는 방식이 구현 복잡도를 크게 줄여준다. 지금 단계에서는 실용적인 선택으로 HttpStatus를 포함하는 예시를 사용하겠다.

public interface ErrorCode {
    String code();
    HttpStatus status();
    String message();
}
public abstract class BaseException extends RuntimeException {

    private final ErrorCode errorCode;

    protected BaseException(ErrorCode errorCode) {
        super(errorCode.message());
        this.errorCode = errorCode;
    }

    protected BaseException(ErrorCode errorCode, Throwable cause) {
        super(errorCode.message(), cause);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

이렇게 하면 어떤 커스텀 예외든 최소한 errorCode를 통해 동일한 방식으로 해석할 수 있다.

ErrorCode에 무엇을 담아야 할까


ErrorCode는 문자열 상수 모음이 아니라, 응답 시스템의 표준 계약이어야 한다.

권장하는 최소 필드는 아래 정도다.

  • code: 예: COMMON-400, USER-404, ORDER-409
  • status: HTTP 상태 코드
  • message: 기본 사용자 메시지

예시:

public enum CommonErrorCode implements ErrorCode {

    INVALID_INPUT("COMMON-400", HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
    RESOURCE_NOT_FOUND("COMMON-404", HttpStatus.NOT_FOUND, "대상을 찾을 수 없습니다."),
    INTERNAL_SERVER_ERROR("COMMON-500", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");

    private final String code;
    private final HttpStatus status;
    private final String message;

    CommonErrorCode(String code, HttpStatus status, String message) {
        this.code = code;
        this.status = status;
        this.message = message;
    }

    @Override
    public String code() {
        return code;
    }

    @Override
    public HttpStatus status() {
        return status;
    }

    @Override
    public String message() {
        return message;
    }
}

여기서 중요한 점은 message보다 code가 더 중요하다는 것이다. 메시지는 문구 개선이나 다국어 처리 때문에 언제든 바뀔 수 있지만, code는 클라이언트와의 약속이므로 쉽게 바뀌면 안 된다.

예외를 계층으로 나누는 기준


예외 계층은 “어디서 발생했는가”보다 “어떤 성격의 실패인가”를 기준으로 나누는 편이 운영에 유리하다.

예를 들면 아래와 같이 가져갈 수 있다.

public abstract class DomainException extends BaseException {
    protected DomainException(ErrorCode errorCode) {
        super(errorCode);
    }
}

public abstract class ApplicationException extends BaseException {
    protected ApplicationException(ErrorCode errorCode) {
        super(errorCode);
    }
}

public abstract class InfrastructureException extends BaseException {
    protected InfrastructureException(ErrorCode errorCode, Throwable cause) {
        super(errorCode, cause);
    }
}

그리고 실제 예외는 더 구체적으로 만든다.

public class UserNotFoundException extends DomainException {
    public UserNotFoundException() {
        super(UserErrorCode.USER_NOT_FOUND);
    }
}

public class DuplicateEmailException extends DomainException {
    public DuplicateEmailException() {
        super(UserErrorCode.DUPLICATE_EMAIL);
    }
}

public class ExternalApiTimeoutException extends InfrastructureException {
    public ExternalApiTimeoutException(Throwable cause) {
        super(CommonErrorCode.INTERNAL_SERVER_ERROR, cause);
    }
}

이 구조가 유용한 이유는 단순하다.

  • DomainException은 의도된 비즈니스 실패로 분류할 수 있다.
  • InfrastructureException은 장애성 이벤트로 분류해 별도 알림이나 추적을 붙이기 쉽다.
  • 예외 타입만 봐도 “이 실패를 어떻게 다뤄야 하는가”가 빨리 보인다.

ErrorCode를 정의할 때 주의할 점


설계는 쉽지만, 운영 단계에서 흔히 무너지는 지점도 있다.

1. 메시지를 분기 기준으로 쓰지 말 것

클라이언트가 "이미 존재하는 이메일입니다." 같은 문구를 보고 분기하기 시작하면 계약이 아니라 우연에 의존하는 구조가 된다. 항상 분기의 기준은 code여야 한다.

2. 하나의 예외에 너무 많은 의미를 몰아넣지 말 것

BusinessException 하나로 모든 실패를 처리하면 계층을 만든 의미가 사라진다. 최상위 추상화는 적게 두되, 실제 운영에서 구분이 필요한 예외는 명시적으로 쪼개는 편이 낫다.

3. 내부 구현 정보를 message에 과도하게 노출하지 말 것

예외 메시지는 클라이언트에게 도움을 줘야지, 스택트레이스나 SQL 문장, 내부 호스트명 같은 정보를 누설하면 안 된다. 이 원칙은 Problem Details 표준이 강조하는 보안 원칙과도 맞닿아 있다.

4. ErrorCode는 바뀌지 않는 식별자여야 한다

USER_NOT_FOUND를 어느 날 NOT_FOUND_USER로 바꿔버리면 클라이언트 계약이 깨진다. 문구는 바꿔도 되지만 코드는 쉽게 바꾸면 안 된다.

이 설계가 실제로 주는 이점


계층형 예외와 ErrorCode를 도입하면 아래 변화가 생긴다.

  1. 클라이언트는 안정적인 code 기준으로 분기할 수 있다.
  2. 서버는 예외 타입과 코드 기준으로 로그 레벨, 알림 정책을 나눌 수 있다.
  3. 공통 응답 포맷이나 ProblemDetail 변환 로직이 단순해진다.
  4. 신규 개발자도 “어떤 예외를 던져야 하는가”를 쉽게 판단할 수 있다.

결국 예외를 잘 설계한다는 것은 단순히 클래스를 예쁘게 나누는 일이 아니다. 실패를 설명하는 언어를 팀 차원에서 표준화하는 일에 가깝다.

Closing


정리하면 BaseException은 모든 예외의 공통 계약을 강제하고, ErrorCode는 응답 시스템의 안정적인 기준점을 제공한다. 이 두 축이 먼저 정리돼야 이후 단계에서 공통 응답 포맷을 도입하든, ProblemDetail을 붙이든, GlobalExceptionHandler를 정리하든 흔들리지 않는다.

다음 글에서는 이 예외 계약을 실제 HTTP 응답으로 어떻게 번역할지, 그리고 왜 많은 팀이 결국 ProblemDetail로 수렴하는지 살펴보겠다.

References