[백엔드 입문 #7] Spring 예외가 프론트 에러 응답이 되는 과정
프론트에서 매일 쓰는 코드가 있다.
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할 때까지.
- Validator에서 터진다.
- UseCase를 지나면서
@Transactional이 예외를 보고 롤백한다. (5편에서 본 내용) - Controller에도
try/catch가 없으니 그냥 통과한다. - 결국 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)를 공부할 예정이다.