프론트에서 매일 쓰는 코드가 있다.

try {
  await api.post('/outbound/ship', { ids });
} catch (e) {
  toast.error(e.response.data.message);   // "송장 번호가 발번되어 있지 않습니다."
}

이 message가 서버 어디에서 만들어지는지는 생각해 본 적이 없었다. 지난 글에서 출고 도메인 모델을 보면서 throw new ...Exception을 잔뜩 봤는데, 이번에는 그 예외가 어떻게 이 JSON이 되는지 따라가 봤다.

글에 나오는 코드는 이해를 돕기 위해 일부 단순화했다.


① 예외는 위로 올라간다

택배 출고를 송장 없이 출하하려고 하면 이런 순서로 흘러간다.

Controller.ship()
  └─ UseCase.execute()                    @Transactional
       └─ CarrierValidator.validate()
            └─ throw new NotIssuedTrackingNumberException(id)   💥

throw가 일어나면 그 아래 코드는 실행되지 않고, 예외가 자기를 부른 쪽으로 계속 올라간다. 누군가 catch할 때까지.

  1. Validator에서 터진다.
  2. UseCase를 지나면서 @Transactional이 예외를 보고 롤백한다. (5편에서 본 내용)
  3. Controller에도 try/catch가 없으니 그냥 통과한다.
  4. 결국 Spring까지 올라온다.

그런데 Controller를 아무리 찾아봐도 try/catch가 없었다. 그럼 누가 잡는 걸까?


② 전역 예외 처리기: @RestControllerAdvice

@RestControllerAdvice                       // 모든 Controller에서 올라온 예외를 여기서 잡는다
public class GlobalControllerException {

    @ExceptionHandler(AppException.class)  // AppException과 그 자식들이 올라오면 실행
    public ResponseEntity<AppResponse<Void>> handleAppException(AppException e) {
        if (e.isClientError()) {
            log.warn(e.getLogMessage());
        } else {
            log.error(e.getLogMessage(), e);
        }
        return ResponseEntity
            .status(e.getStatusCode())                                   // HTTP 상태 코드
            .body(AppResponse.fail(e.getMessage(), e.getErrorCode()));  // JSON body
    }
}

Controller 수백 개가 각자 try/catch를 쓰는 대신, 이 한 곳에서 모든 예외를 응답으로 바꾼다.

프론트에도 같은 생각으로 만든 게 있다.

  • axios interceptor: 모든 응답 에러를 한 곳에서 처리한다.
  • React ErrorBoundary: 하위 컴포넌트 어디서 터지든 위에서 잡아 에러 화면을 보여 준다.

그래서 프론트는 이런 응답을 받는다.

HTTP/1.1 422 Unprocessable Entity

{
  "data": null,
  "message": "송장 번호가 발번되어 있지 않습니다.",
  "errorCode": "outbound/not-issued-tracking-number",
  "validations": null
}

1편부터 계속 본 AppResponse가 성공일 때는 success(data), 실패일 때는 fail(message, errorCode)로 같은 모양을 유지한다. 프론트는 항상 같은 구조로 파싱하면 된다.


③ 예외 하나에 메시지가 세 개인 이유

public class NotIssuedTrackingNumberException extends AppRuleException {

    private static final String MESSAGE     = "송장 번호가 발번되어 있지 않습니다.";
    private static final String ERROR_CODE  = "outbound/not-issued-tracking-number";
    private static final String LOG_MESSAGE = "[송장 미발번] %d 주문 상태 변경 실패";

    public NotIssuedTrackingNumberException(Long id) {
        super(MESSAGE, ERROR_CODE, LOG_MESSAGE.formatted(id));
    }
}
필드 누가 보나 용도
message 창고 작업자 (토스트) 사람이 읽는 안내 문구
errorCode 프론트 코드 if로 분기하는 기계용 값
logMessage 백엔드 개발자 (서버 로그) 어떤 출고(id)에서 났는지 추적

로그에는 “출고 12345번에서 실패”처럼 id가 있어야 추적할 수 있다. 하지만 그걸 사용자 화면에 보여 줄 필요는 없다. 내부 정보는 로그에만 남기고 화면에는 안내 문구만 보낸다.

errorCode가 따로 있는 이유는 퀴즈에서 제대로 알게 됐다. 아래에서 다시 정리한다.


④ 예외 종류가 곧 HTTP 상태 코드

이 프로젝트의 예외는 상속 구조로 상태 코드가 정해져 있었다.

RuntimeException
└─ AppException (기본 500)
   ├─ AppValidationV2Exception    400  요청 형식이 틀림 (필수값 누락 등)
   ├─ AppUnAuthorizationException 401  로그인 안 함
   ├─ AppForbiddenException       403  로그인은 했는데 권한 없음
   ├─ AppNotFoundException        404  찾는 게 없음
   ├─ AppConflictException        409  다른 데이터와 충돌 (중복 등)
   ├─ AppRuleException            422  요청은 멀쩡한데 비즈니스 규칙 위반
   └─ AppServerException          500  서버 잘못

예외 클래스를 만들 때 어떤 부모를 extends 할지 고르는 게 곧 상태 코드를 고르는 것이다. NotIssuedTrackingNumberException은 AppRuleException을 상속했으니 422가 된다. 요청 형식은 맞는데 “송장 없이 출하 불가”라는 규칙에 걸렸기 때문이다.

처음에 헷갈렸던 건 400과 422의 차이였다.

  • 400: { "ids": "abc" }처럼 숫자 자리에 문자를 보냄 → 요청 자체가 틀림
  • 422: 요청은 완벽한데, 이 출고는 지금 출하할 수 없는 상태 → 규칙 위반

⑤ warn과 error를 나누는 이유

if (e.isClientError()) {          // 4xx
    log.warn(e.getLogMessage());
} else {                          // 5xx
    log.error(e.getLogMessage(), e);
}
  • 4xx: 작업자가 송장 없이 출하를 눌렀다. 시스템은 정상이고, 막아야 할 걸 잘 막았다. 하루에 수백 번 일어나도 괜찮다. → warn
  • 5xx: 서버 코드가 터졌다. 개발자가 고쳐야 한다. → error. 보통 Sentry나 Datadog 알림이 이걸 보고 울린다.

4xx까지 전부 error로 찍으면 새벽에 “작업자가 송장 없이 출하 누름” 알림이 수백 개 온다. 그러다 진짜 장애 알림이 그 사이에 묻힌다.


⑥ 실제 코드에서 보인 것들

404여야 할 것 같은데 422인 예외

public class NotFoundFeatureFlagException extends AppRuleException { ... }   // 422

이름은 NotFound인데 AppRuleException을 상속해서 404가 아니라 422가 나간다. 상태 코드별 예외가 나중에 생겨서 일관되지 않은 부분이 남아 있었다. 프론트에서 status === 404로 분기했다면 이 API에서는 걸리지 않는다.

에러인데 200 OK로 오는 응답

같은 처리기 아래쪽에 레거시용 코드가 있었다.

return ResponseEntity.ok(new LegacyValidationResponse(response));   // 에러인데 200

레거시 API는 에러도 HTTP 200으로 보내고, body 안에 에러 코드를 넣었다. 프론트에서 “응답은 200인데 왜 실패지?”를 겪었던 이유가 이거였다. 기존 프론트 코드가 이 형식에 맞춰져 있어서 하위 호환을 위해 남겨 둔 것이다.

아무도 예상하지 못한 예외

NullPointerException처럼 AppException이 아닌 예외는 위 처리기에 걸리지 않는다. Spring 기본 처리로 넘어가서 500과 Spring 기본 에러 JSON이 나간다. AppResponse 모양이 아니라서 프론트에서 e.response.data.message가 undefined가 될 수 있다.


퀴즈 풀어 보기

문제 1: 없는 출고를 조회하면?

출고 조회 API에 없는 id(999999)로 요청이 왔다. 새 예외 NotFoundOutboundException은 어떤 클래스를 상속해야 하고, 프론트는 어떤 상태 코드와 JSON을 받으며, 로그는 어느 레벨로 찍힐까?

AppNotFoundException을 상속한다고만 답하고 나머지는 빠뜨렸다. 부모를 정하면 나머지는 저절로 따라온다는 걸 연결하지 못한 것이다.

public class NotFoundOutboundException extends AppNotFoundException { ... }
HTTP/1.1 404 Not Found

{
  "data": null,
  "message": "출고 정보를 찾을 수 없습니다.",
  "errorCode": "outbound/not-found",
  "validations": null
}

404는 4xx라서 isClientError()가 true이고, 로그는 warn으로 찍힌다. 없는 id로 조회한 건 서버 버그가 아니니 알림을 울릴 일도 아니다. 부모 클래스 하나를 고르면 상태 코드, 응답, 로그 레벨이 한꺼번에 정해진다.

문제 2: DB 에러를 그대로 던지면?

catch (DataIntegrityViolationException e) {
    throw new AppRuleException(
        e.getMessage(),   // "Duplicate entry 'NEW_PICKING' for key 'uk_feature_flag_code'"
        "feature-flag/duplicate",
        e.getMessage()
    );
}

errorCode가 항상 똑같은 게 문제라고 답했는데, 짚은 곳이 틀렸다. “중복”이라는 한 가지 상황이니 errorCode가 하나인 건 오히려 맞다. 프론트는 이 값으로 분기하면 된다.

진짜 문제는 message 자리에 e.getMessage()를 넣은 것이었다. message는 토스트로 뜨는 문구라서, 작업자 화면에 이게 그대로 뜬다.

Duplicate entry 'NEW_PICKING' for key 'uk_feature_flag_code'
  • 작업자가 읽을 수 없다. 영어로 된 DB 에러라 무엇을 하라는 건지 알 수 없다.
  • 내부 정보가 새어 나간다. 제약 조건 이름, DB 종류 같은 서버 내부 구조가 브라우저까지 간다. 공격하는 쪽에는 좋은 힌트가 된다.
catch (DataIntegrityViolationException e) {
    throw new AppConflictException(                       // 중복은 409가 더 맞다
        "이미 등록된 기능 플래그 코드입니다.",                  // 사용자용 문구
        "feature-flag/duplicate",                          // 그대로
        "기능 플래그 코드 중복: " + e.getMessage()             // 원문은 로그에만
    );
}

DB나 라이브러리의 원문 에러는 로그에만 남기고, 화면에는 사람이 쓴 문구만 보낸다.

문제 3: 송장 발행 모달을 자동으로 띄우려면?

“송장이 없다는 에러가 나면 송장 발행 모달을 자동으로 띄워 주세요”라는 요구가 왔다. message와 errorCode 중 무엇으로 분기해야 할까?

처음엔 질문 자체가 무슨 뜻인지 몰랐다. 프론트에서 “이게 송장 없음 에러인지”를 판단해야 하는데, 그 판단 기준을 무엇으로 하느냐는 질문이었다.

// A: 문구로 판단
if (e.response.data.message === '송장 번호가 발번되어 있지 않습니다.') openModal();

// B: 코드로 판단
if (e.response.data.errorCode === 'outbound/not-issued-tracking-number') openModal();

답은 B다. 기획자가 문구를 “송장이 아직 없어요. 먼저 발행해 주세요”로 바꾸면 백엔드는 MESSAGE 상수만 고친다. 그러면 A는 문자열이 안 맞아서 모달이 조용히 안 뜬다. 에러도 나지 않아서 한참 뒤에야 알게 된다.

errorCode는 문구가 바뀌어도 그대로인 프론트와 백엔드 사이의 약속이다. 프론트에서 번역된 한국어 문장이 아니라 i18n 키(t('outbound.notIssued'))로 분기하는 것과 같은 이유다.

  message errorCode
보는 쪽 사람 (토스트) 코드 (if문)
바뀌어도 되나 언제든 바뀔 수 있음 바뀌면 안 됨
프론트에서 화면에 보여 주기만 분기할 때 사용

정리

개념 한 줄 요약 프론트 비유
예외 전파 Entity → UseCase(롤백) → Controller → 처리기 하위 컴포넌트 에러가 위로
전역 예외 처리기 @RestControllerAdvice 한 곳에서 응답으로 변환 axios interceptor, ErrorBoundary
message / errorCode / logMessage 사용자용 / 코드용 / 개발자용 토스트 문구 / i18n 키 / console
부모 예외 선택 상속한 부모가 상태 코드를 정함 error.response.status
warn / error 4xx는 warn, 5xx는 error(알림) —

프론트에서 늘 e.response.data.message를 꺼내 쓰기만 했는데, 그 한 줄 뒤에 “누가 읽을 문구인가”, “바뀌어도 되는 값인가”를 나눠 둔 설계가 있었다. 앞으로 에러 분기 코드를 짤 때는 문구가 아니라 errorCode를 먼저 찾게 될 것 같다.

다음 글에서는 이런 규칙들이 정말 지켜지는지 확인하는 테스트(JUnit, Mockito)를 공부할 예정이다.