지금까지 정리한 표준들은 요청이 들어오는 길을 다뤘다. 이 글은 반대 방향, 실패가 나가는 길을 다룬다.
그런데 앞선 표준들을 다시 보면 이미 정의되지 않은 것에 기대고 있다. 계층별 검증 표준과 Service 계층 assert 표준은 ErrorCodeException을 던지고, DTO 네이밍 표준은 @RestControllerAdvice가 응답을 만든다고 적어놨다. 정작 그 예외가 어떻게 생겼는지, 어떤 상태 코드로 나가는지는 아무 데도 없다.
그래서 지금 코드에는 세 가지 문제가 있다.
표준을 정하기 전에는 메시지만 받는 BusinessException 하나로 모든 실패를 표현했다. 아래 코드는 당시 문제를 보여준다.
throw new BusinessException("등록되지 않은 상품입니다."); // 404가 자연스럽다
throw new BusinessException("재고가 부족합니다."); // 409가 맞다
throw new BusinessException("존재하지 않는 주문입니다."); // 404가 자연스럽다
문자열만으로는 응답 상태와 안정적인 식별 코드를 복원할 수 없다.
- 전부 409로 나간다. 핸들러가
ErrorCodeException을 무조건CONFLICT로 매핑하기 때문이다 - 클라이언트가 원인으로 분기할 수 없다. 응답에
message문자열뿐이라, 그걸로 분기하면 문구만 다듬어도 클라이언트가 깨진다 - 로그를 어디서 남길지 정해지지 않았다. 같은 예외가 두 번 찍히거나 아예 안 찍힌다
이 글은 ErrorCode를 실패의 단일 출처로 삼아 셋을 한꺼번에 해결한다.
원칙 1. ErrorCode가 실패의 단일 출처다
실패 하나에 대해 알아야 할 것은 세 가지다 — 코드, HTTP 상태, 기본 메시지. 이걸 enum 한 곳에 모은다.
// common/error/DomainCode.java
@Getter
public enum DomainCode {
COMMON(0), // 특정 도메인에 속하지 않는 실패
ORDER(1),
PRODUCT(2),
USER(3);
private final int code;
DomainCode(int code) {
this.code = code;
}
}
// common/error/ErrorCode.java
@Getter
public enum ErrorCode {
// 400 — 요청 자체가 잘못됨
INVALID_REQUEST(400, DomainCode.COMMON, 1, "요청 값이 유효하지 않습니다."),
MALFORMED_REQUEST_BODY(400, DomainCode.COMMON, 2, "요청 본문을 해석할 수 없습니다."),
INVALID_PARAMETER(400, DomainCode.COMMON, 3, "요청 파라미터가 올바르지 않습니다."),
// 404 — 대상이 없음
ORDER_NOT_FOUND(404, DomainCode.ORDER, 1, "존재하지 않는 주문입니다."),
PRODUCT_NOT_FOUND(404, DomainCode.PRODUCT, 1, "등록되지 않은 상품입니다."),
// 409 — 현재 상태에서 불가능한 요청
STOCK_SHORTAGE(409, DomainCode.ORDER, 1, "재고가 부족합니다."),
ORDER_ALREADY_CONFIRMED(409, DomainCode.ORDER, 2, "이미 확정된 주문입니다."),
// 500 — 서버 문제
INTERNAL_ERROR(500, DomainCode.COMMON, 1, "서버 내부 오류가 발생했습니다.");
private final int status;
private final DomainCode domain;
private final int serial;
private final String defaultMessage;
ErrorCode(int status, DomainCode domain, int serial, String defaultMessage) {
this.status = status;
this.domain = domain;
this.serial = serial;
this.defaultMessage = defaultMessage;
}
/** 상태 3자리 + 도메인 3자리 + 일련번호 3자리. 409 / 001 / 001 → 409001001 */
public int getCode() {
return status * 1_000_000 + domain.getCode() * 1_000 + serial;
}
}
코드는 9자리, 세 조각으로 나뉜다
409001001은 통짜 숫자가 아니라 세 조각이다. 앞 409가 HTTP 상태, 가운데 001이 도메인(주문), 뒤 001이 그 조합 안에서의 일련번호다.
일련번호는 상태와 도메인의 조합마다 따로 흐른다. 404001001과 409001001은 둘 다 주문 도메인의 1번이지만 상태가 다르므로 다른 실패다. 도메인을 하나 추가해도 다른 도메인의 번호를 건드릴 일이 없다.
도메인 번호는 ErrorCode가 아니라 DomainCode가 갖는다. ErrorCode 상수 옆에 1, 2 같은 숫자를 직접 적으면 도메인이 늘어날 때마다 흩어진 숫자를 훑어야 한다. enum으로 빼면 번호가 한 파일에 모인다. 다만 같은 번호를 두 도메인에 주는 것을 컴파일러가 막아주지는 않는다 — 이건 여전히 눈으로 보는 규율이고, DomainCode 파일 하나만 보면 된다는 것이 이득의 전부다.
코드를 숫자로 직접 적지 않는 것이 핵심이다. 세 조각을 따로 받고 getCode()가 조립한다. 처음에는 반대 방향이었다 — 코드를 통짜로 적고 상태를 code / 1000으로 파생시켰다. 도메인 칸이 생기면서 9자리를 매번 정확히 세어 적어야 하는 부담이 커졌고, 파생 방향을 뒤집었다. 지금은 세 조각이 원본이고 코드가 파생이다. 방향만 바뀌었을 뿐 “관리하는 값이 하나뿐이라 서로 어긋날 수 없다”는 성질은 그대로다.
대신 잃은 것이 있다. 로그에서 본 409001001을 소스에서 그대로 검색할 수 없다. 이제 그 숫자는 어디에도 문자열로 존재하지 않기 때문이다. 상태·도메인·일련번호로 쪼개 읽고 DomainCode에서 도메인을 확인해야 한다. 숫자로 소스를 찾는 일보다 숫자를 정확하게 유지하는 일이 잦다고 보고 받아들인 거래다.
자릿수는 9자리가 끝이다. 도메인 1000개, 도메인당 상태별 1000개까지 담긴다. 10자리로 늘리면 int 상한(2147483647)을 넘어 long으로 바꿔야 하고, 그러면 응답 JSON의 code 타입까지 따라 바뀐다. 9자리가 int에 담을 수 있는 마지막 자릿수다.
enum 상수 이름은 읽히도록 남긴다
코드가 9자리 숫자라고 해서 상수 이름까지 숫자로 만들지 않는다.
throw ErrorCodeException.of(ErrorCode.STOCK_SHORTAGE); // 무슨 실패인지 바로 읽힌다
throw ErrorCodeException.of(ErrorCode.E409001001); // 코드를 찾아봐야 안다
이름과 코드는 용도가 다르다. 이름은 개발자가 코드를 읽을 때 쓰고, 9자리 숫자는 클라이언트가 분기하거나 로그를 검색할 때 쓴다. 둘 다 있어야 각자 자리에서 제 역할을 한다.
HttpStatus가 아니라 int를 쓰는 이유
Spring의 HttpStatus가 더 읽기 좋아 보이지만, 그러면 ErrorCode가 org.springframework.http를 import한다. 그리고 Domain이 ErrorCodeException을 던지는 순간 도메인이 웹 계층을 간접적으로 알게 된다.
// StockDomain — 영속성도 웹도 몰라야 하는 계층
public void validateOrderable(int requestedQuantity) {
if (availableQuantity < requestedQuantity) {
throw ErrorCodeException.of(ErrorCode.STOCK_SHORTAGE);
}
}
int로 담으면 ErrorCode는 프레임워크에 의존하지 않는 순수한 타입이 되고, Domain이 자유롭게 던질 수 있다. 여기 적힌 숫자는 “이 실패가 클라이언트에게 어떤 성격인지”를 분류한 값이지 HTTP 프레임워크에 대한 의존이 아니다.
원칙 2. 분류가 끝난 실패는 ErrorCodeException 하나로 운반한다
OrderNotFoundException, StockShortageException처럼 실패마다 클래스를 만들면 실패가 늘어날 때마다 파일이 늘어난다. 그런데 정작 대부분은 catch해서 구분 처리하지 않고 그대로 핸들러까지 올라간다.
처음에는 이 클래스를 BusinessException이라고 불렀다. 그런데 영속성 충돌과 외부 API 타임아웃도 같은 타입으로 번역하면서 이름과 역할이 충돌했다. 정해야 했던 건 이거였다 — 업무 실패와 기술 실패를 어떻게 운반할 것인가.
안 A — BusinessException의 뜻을 넓힌다. 원인을 파악하고 ErrorCode를 부여한 모든 실패를 비즈니스 예외라고 부른다. 얻는 것은 기존 이름과 사용처를 그대로 두는 것이다. 버린 이유는 결제 타임아웃을 BusinessException으로 던지는 코드가 업무 규칙 위반처럼 읽혀 타입의 실제 범위를 오해하게 만들기 때문이다.
안 B — BusinessException과 TechnicalException으로 나눈다. 코드만 읽어도 업무상 거절과 기술 장애가 구분된다. 얻는 것은 타입별 재시도 같은 처리를 붙이기 쉽다는 것이다. 버린 이유는 ORDER_CONCURRENT_UPDATE처럼 JPA가 발견했지만 사용자에게는 정상적인 409인 실패가 어느 쪽인지 사람마다 다르게 판단할 수 있고, 현재 응답·로그·배치 분기는 이미 예외 타입이 아니라 ErrorCode로 동작하기 때문이다.
안 C — 하나로 유지하고 ErrorCodeException으로 이름을 바꾼다. 이 타입은 업무 실패인지 기술 실패인지가 아니라 원인과 외부 계약을 이해해 ErrorCode까지 부여한 실패라는 뜻이다. 재고 부족, 주문 동시 수정, 결제 타임아웃을 모두 운반하되 구분은 ErrorCode가 한다. 예상하지 못했거나 아직 분류하지 못한 실패는 이 타입으로 감싸지 않고 원본 예외 그대로 올린다.
골랐다 — 안 C. 안 A의 거짓 이름과 안 B의 모호한 이중 분류를 피하면서 예외 클래스 하나라는 기존 구조를 유지한다. 대신 기존 BusinessException 사용처를 모두 바꿔야 하고, 기술 예외를 번역할 때 원인을 잃지 않도록 cause를 반드시 보존해야 한다.
@Getter
public class ErrorCodeException extends RuntimeException {
private final ErrorCode errorCode;
private ErrorCodeException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public static ErrorCodeException of(ErrorCode errorCode) {
return new ErrorCodeException(errorCode, errorCode.getDefaultMessage(), null);
}
public static ErrorCodeException of(ErrorCode errorCode, String message) {
return new ErrorCodeException(errorCode, message, null);
}
public static ErrorCodeException of(ErrorCode errorCode, Throwable cause) {
return new ErrorCodeException(errorCode, errorCode.getDefaultMessage(), cause);
}
public static ErrorCodeException of(
ErrorCode errorCode,
String message,
Throwable cause
) {
return new ErrorCodeException(errorCode, message, cause);
}
}
private 생성자 + 정적 팩토리 구조는 DTO 생성 표준과 같다. 생성 경로를 하나로 두는 이유도 같다.
// 기본 메시지로 충분한 경우
throw ErrorCodeException.of(ErrorCode.ORDER_NOT_FOUND);
// 상황 값을 담아야 하는 경우
throw ErrorCodeException.of(
ErrorCode.STOCK_SHORTAGE,
"재고가 부족합니다. 요청 %d개, 주문 가능 %d개".formatted(requested, available));
ErrorCode는 그대로이므로 클라이언트가 보는 코드는 변하지 않고, 사람이 읽는 메시지만 상세해진다.
외부 연동이나 영속성 예외처럼 원본 예외를 번역할 때는 cause를 보존한다. 그래야 5xx 로그의 스택트레이스가 실제 실패 지점까지 이어진다.
catch (SocketTimeoutException e) {
throw ErrorCodeException.of(ErrorCode.ORDER_PAYMENT_TIMEOUT, e);
}
반대로 원인과 대응을 모르는 예외까지 편의를 위해 감싸지 않는다.
// 이렇게 하지 않는다 — 미분류 버그가 알려진 실패처럼 보인다
catch (Exception e) {
throw ErrorCodeException.of(ErrorCode.INTERNAL_ERROR, e);
}
NullPointerException 같은 예상하지 못한 실패는 원본 그대로 Advice까지 올라가야 공통 500 핸들러가 스택트레이스를 남기고 새 결함으로 드러난다.
메시지를 직접 쓰는 기준
메시지를 아무 때나 덮어쓰면 같은 실패가 여러 문구로 흩어진다. 재고 부족을 다섯 곳에서 던지는데 문구가 조금씩 다르면 클라이언트도 QA도 헷갈린다. 그래서 기준을 둔다.
의미를 바꾸는 것이 아니라, 상황 값을 덧붙일 때만 직접 쓴다.
// ✅ 의미는 같고 숫자만 덧붙였다
"재고가 부족합니다. 요청 5개, 주문 가능 2개"
// ❌ 의미 자체가 달라졌다 — 이건 새 ErrorCode를 만들라는 신호다
ErrorCodeException.of(ErrorCode.STOCK_SHORTAGE, "판매가 종료된 상품입니다.");
기본 메시지와 의미가 달라져야 한다면 그건 다른 실패다. ErrorCode를 하나 추가하는 것이 맞다.
원칙 3. 응답에 code를 실어 클라이언트가 분기하게 한다
DTO 네이밍 표준의 HttpApiResponse<T>에 code 필드를 추가한다.
@Getter
public class HttpApiResponse<T> {
private final boolean success;
private final int status;
private final int code;
private final String message;
private final T data;
private final List<FieldError> errors;
private HttpApiResponse(
boolean success, int status, int code, String message, T data, List<FieldError> errors) {
this.success = success;
this.status = status;
this.code = code;
this.message = message;
this.data = data;
this.errors = errors;
}
public static <T> HttpApiResponse<T> ok(T data) {
return success(200, "OK", data);
}
public static HttpApiResponse<Void> ok() {
return success(200, "OK", null);
}
public static <T> HttpApiResponse<T> created(T data) {
return success(201, "Created", data);
}
public static <T> HttpApiResponse<T> accepted(T data) {
return success(202, "Accepted", data);
}
private static <T> HttpApiResponse<T> success(int status, String message, T data) {
int code = status * 1_000_000;
return new HttpApiResponse<>(true, status, code, message, data, List.of());
}
public static HttpApiResponse<Void> error(ErrorCode errorCode, String message) {
return new HttpApiResponse<>(
false, errorCode.getStatus(), errorCode.getCode(), message, null, List.of());
}
public static HttpApiResponse<Void> validationError(List<FieldError> errors) {
ErrorCode errorCode = ErrorCode.INVALID_REQUEST;
return new HttpApiResponse<>(
false, errorCode.getStatus(), errorCode.getCode(), errorCode.getDefaultMessage(), null, errors);
}
@Getter
public static class FieldError {
private final String field;
private final String message;
private FieldError(String field, String message) {
this.field = field;
this.message = message;
}
public static FieldError of(String field, String message) {
return new FieldError(field, message);
}
}
}
성공 응답에서도 code는 200000000으로 항상 채운다. errors를 빈 리스트로 두는 것과 같은 이유다 — 클라이언트가 필드 존재 여부로 분기하지 않게 하기 위해서다.
응답은 이렇게 나간다.
{
"success": false,
"status": 409,
"code": 409001001,
"message": "재고가 부족합니다. 요청 5개, 주문 가능 2개",
"data": null,
"errors": []
}
code는 계약이고 message는 설명이다. 클라이언트는 code로 분기하고, message는 사용자에게 보여주기만 한다. 이렇게 나누면 메시지 문구를 언제든 다듬어도 클라이언트가 깨지지 않는다.
원칙 4. 로그는 경계 한 곳에서만 남긴다
웹 요청에서는 모든 예외가 결국 핸들러까지 올라온다. 그러니 로그도 거기서만 남긴다. Service에서 로그를 남기고 예외를 던지면 같은 사건이 두 번 찍히고, 비즈니스 로직에 로깅 코드가 섞인다.
“한 곳”이 항상 Advice는 아니다. 규칙의 핵심은 “경계에서 한 번”이고, 그 경계는 진입점마다 다르다. 벌크 경로에는
DispatcherServlet이 없어 Advice가 아예 존재하지 않으므로StepExecutionListener가 그 자리를 맡는다. 어느 쪽이든 Service·Domain·Mapper에서는 로그를 남기지 않는다는 것이 지켜야 할 규칙이다. 배치 쪽 기준은 예외의 종류 원칙 8에 있다.
@Slf4j
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(ErrorCodeException.class)
public ResponseEntity<HttpApiResponse<Void>> handleErrorCode(ErrorCodeException e) {
ErrorCode errorCode = e.getErrorCode();
// 예외 클래스가 아니라 ErrorCode 의 상태로 가른다. ErrorCodeException 은
// 외부 연동 실패처럼 5xx 도 나르므로 무조건 warn 이면 그때 스택이 사라진다.
if (errorCode.getStatus() >= 500) {
log.error("[{}:{}] {}", errorCode.getCode(), errorCode.name(), e.getMessage(), e);
} else {
log.warn("[{}:{}] {}", errorCode.getCode(), errorCode.name(), e.getMessage()); // 스택트레이스 없음
}
return ResponseEntity
.status(errorCode.getStatus())
.body(HttpApiResponse.error(errorCode, e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<HttpApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
List<HttpApiResponse.FieldError> errors = e.getBindingResult().getFieldErrors().stream()
.map(error -> HttpApiResponse.FieldError.of(error.getField(), error.getDefaultMessage()))
.toList();
log.warn("[{}:{}] {}", ErrorCode.INVALID_REQUEST.getCode(), ErrorCode.INVALID_REQUEST.name(), errors);
return ResponseEntity
.status(ErrorCode.INVALID_REQUEST.getStatus())
.body(HttpApiResponse.validationError(errors));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<HttpApiResponse<Void>> handleNotReadable(HttpMessageNotReadableException e) {
ErrorCode errorCode = ErrorCode.MALFORMED_REQUEST_BODY;
log.warn("[{}:{}] {}", errorCode.getCode(), errorCode.name(), e.getMessage());
return ResponseEntity
.status(errorCode.getStatus())
.body(HttpApiResponse.error(errorCode, errorCode.getDefaultMessage()));
}
}
비즈니스 예외는 warn이고 스택트레이스를 남기지 않는다. 재고 부족은 버그가 아니라 정상적으로 일어나는 실패다. 여기에 스택트레이스를 찍으면 로그가 쓸모없어지고, 진짜 문제가 묻힌다.
로그 레벨 기준은 다음과 같다.
| 상황 | 레벨 | 스택트레이스 |
|---|---|---|
| 비즈니스 규칙 위반 (4xx) | warn |
남기지 않음 |
| 요청 형식 오류 (400) | warn |
남기지 않음 |
| 예상하지 못한 예외 (500) | error |
남김 |
기준을 한 줄로 줄이면 4xx는 warn, 5xx는 error다. ErrorCodeException이 외부 연동 실패처럼 5xx를 나르는 경우도 있으므로, 예외 클래스가 아니라 ErrorCode의 상태 코드로 판단한다. 자세한 내용은 예외의 종류에서 다룬다.
원칙 5. 예상하지 못한 예외는 내부 정보를 노출하지 않는다
마지막 핸들러는 잡히지 않은 모든 예외를 받는다. 여기서 중요한 건 로그에는 전부 남기고, 응답에는 아무것도 흘리지 않는 것이다.
@ExceptionHandler(Exception.class)
public ResponseEntity<HttpApiResponse<Void>> handleUnexpected(Exception e) {
ErrorCode errorCode = ErrorCode.INTERNAL_ERROR;
log.error("[{}:{}] 처리되지 않은 예외", errorCode.getCode(), errorCode.name(), e); // 스택트레이스 포함
return ResponseEntity
.status(errorCode.getStatus())
.body(HttpApiResponse.error(errorCode, errorCode.getDefaultMessage()));
// ^^^ e.getMessage()를 쓰지 않는다
}
e.getMessage()를 그대로 내보내면 SQL 조각, 내부 클래스명, 파일 경로가 클라이언트에게 새어나간다. 응답에는 고정된 기본 메시지만 담고, 원인은 로그에서 찾는다.
AssertionError는 이 핸들러에 잡히지 않는다
assert 표준에서 도달 불가 분기는 throw new AssertionError(...)로 막기로 했다. 그런데 AssertionError는 Exception이 아니라 Error를 상속한다.
@ExceptionHandler(Exception.class)는 이걸 못 잡는다. 그러면 서블릿 컨테이너 기본 오류 페이지가 나가면서 우리가 만든 응답 형식이 통째로 깨진다. -ea와 무관하게 운영에서도 실행되는 코드라 실제로 일어날 수 있는 일이다.
그래서 핸들러를 하나 더 둔다.
@ExceptionHandler(AssertionError.class)
public ResponseEntity<HttpApiResponse<Void>> handleAssertion(AssertionError e) {
ErrorCode errorCode = ErrorCode.INTERNAL_ERROR;
log.error("[{}:{}] 도달할 수 없는 분기에 진입", errorCode.getCode(), errorCode.name(), e);
return ResponseEntity
.status(errorCode.getStatus())
.body(HttpApiResponse.error(errorCode, errorCode.getDefaultMessage()));
}
판단 기준 정리
| 상황 | 수단 | 상태 | 로그 |
|---|---|---|---|
| 요청 형식 위반 | Bean Validation → MethodArgumentNotValidException |
400 + errors |
warn |
| JSON 파싱 실패 | HttpMessageNotReadableException |
400 | warn |
| 대상 없음 | ErrorCodeException + XXX_NOT_FOUND |
404 | warn |
| 현재 상태로 불가능 | ErrorCodeException + 해당 ErrorCode |
409 | warn |
| 도달 불가 분기 | AssertionError (직접 던짐) |
500 | error + 스택 |
| 그 외 전부 | Exception 핸들러 |
500 | error + 스택 |
| 프로그래머 실수 (개발/테스트) | assert |
— | — |
마지막 줄은 assert 표준의 영역이다. assert는 운영에서 꺼지므로 응답을 만들지 않는다. 사용자에게 보여야 하는 실패는 반드시 예외여야 한다.
이 표준을 정하기까지
출발점은 빚을 발견한 것이었다. 검증 표준과 assert 표준이 ErrorCodeException을 던지고, DTO 표준이 @RestControllerAdvice를 언급하는데, 정작 그것들을 정의한 곳이 없었다. 세 표준이 존재하지 않는 것을 전제하고 있었던 셈이다.
가장 먼저 눈에 띈 건 상태 코드였다. 핸들러가 ErrorCodeException을 무조건 409로 매핑하고 있었는데, 실제 코드에는 “존재하지 않는 주문”처럼 404가 자연스러운 것이 섞여 있었다. 예외에 상태 코드 정보가 없으니 핸들러가 구분할 방법이 없었다.
예외 클래스를 실패마다 만들지, 코드로 구분할지 고민했다. OrderNotFoundException 같은 걸 만들면 타입으로 구분되고 catch도 세분화할 수 있다. 그런데 따져보니 대부분의 비즈니스 예외는 catch하지 않고 그대로 핸들러까지 올라간다. 타입 구분이 실제로 쓰이지 않는데 클래스만 늘어나는 셈이라, ErrorCode enum으로 구분하고 예외 클래스는 하나만 두기로 했다. 상태 코드 매핑이 enum 한 곳에 모이는 것도 이득이었다.
나중에 클래스 이름과 실제 범위가 충돌했다. 영속성 충돌과 외부 API 타임아웃까지 BusinessException으로 번역하면서 더는 비즈니스 실패만 담는 타입이 아니었다. 이름의 뜻을 넓히는 안과 업무·기술 예외를 둘로 나누는 안을 검토했지만, 전자는 코드를 오해하게 만들고 후자는 동시 수정처럼 경계가 모호한 실패에 두 번째 분류를 요구했다. 그래서 분류가 끝나 ErrorCode가 부여된 실패라는 실제 역할을 이름으로 옮겨 ErrorCodeException으로 바꿨다. 예상하지 못한 실패는 감싸지 않고 원본 그대로 남기며, 알려진 기술 실패를 번역할 때는 cause를 보존한다.
코드는 처음에 6자리 숫자로 정했다. 로그에서 검색하기 좋고 클라이언트가 분기하기도 편해서다. 처음에는 enum 상수 이름(name())을 그대로 코드로 쓸까 생각했는데, 숫자 코드를 쓰기로 하면서 상수 이름은 이름대로 남기기로 했다. E409001001 같은 이름은 코드를 찾아봐야 무슨 실패인지 알 수 있어서, 정작 코드를 읽을 때 손해가 크다. 이름은 개발자용, 숫자는 클라이언트와 로그용으로 역할을 갈랐다.
대신 어긋날 여지는 코드 구성으로 없앴다. 앞 3자리를 HTTP 상태로 쓰고 code / 1000으로 파생시키면, STOCK_SHORTAGE(404, 409001, ...) 같은 불일치가 애초에 불가능해진다. 관리할 값이 하나로 줄어드는 셈이다.
나중에 6자리가 좁다는 걸 알았다. 뒤 3자리를 도메인별 대역으로 쪼개 쓰기로 했는데, 대역 폭을 100으로 잡으면 000~999 안에 칸이 열 개뿐이다. 도메인 열 개가 곧 이 시스템의 상한이 되는 셈이고, 그나마 공통 코드가 한 칸을 먹으면 아홉 개다. 대역 폭을 50으로 줄이면 도메인은 스무 개가 되지만 도메인당 코드가 50개로 쪼그라든다. 어느 쪽으로 잡아도 곧 다시 만질 숫자였다.
그래서 도메인에 자리를 따로 줬다. 상태 3자리 + 도메인 3자리 + 일련번호 3자리로 9자리가 됐다. 도메인 1000개, 도메인당 상태별 1000개다. 대역을 쪼개 쓰는 방식과 달리 도메인 칸이 독립되니 공통 코드를 000에 예약할 수 있고, 남의 대역을 침범하는 문제가 사라진다. 자릿수를 더 늘리지 않은 이유는 int 때문이다 — 10자리는 2147483647을 넘는다.
도메인 번호는 enum으로 뺐다. ErrorCode 상수마다 1, 2를 손으로 적으면 도메인이 늘 때 흩어진 숫자를 훑어야 한다. DomainCode로 모으면 번호가 한 파일에 있다. 다만 컴파일러가 중복을 막아주지는 않는다는 걸 짚어둔다 — 애초에 ErrorCode를 한 곳에 모은 이유가 “중복이 눈에 보이게”였고, 이건 그 범위를 더 좁힌 것이지 강제로 바꾼 게 아니다.
여기서 파생 방향이 뒤집혔다. 9자리를 통짜로 손으로 적는 건 현실적이지 않아서, 세 조각을 받고 getCode()가 조립하게 했다. 원래는 코드가 원본이고 상태가 파생이었는데 이제 반대다. 바뀐 건 방향이고, 지키려던 성질(“관리할 값이 하나라 어긋날 수 없다”)은 그대로다. 대가로 로그의 409001001을 소스에서 grep할 수 없게 됐다.
HttpStatus 대신 int를 쓴 이유는 나중에 발견했다. Domain이 ErrorCodeException을 던지는데, ErrorCode가 Spring의 HttpStatus를 들고 있으면 도메인이 웹 계층을 간접적으로 알게 된다. DTO 표준에서 Domain이 영속성을 모르게 하려고 공들였는데 여기서 웹이 새어 들어오면 앞뒤가 안 맞는다. int로 담으면 ErrorCode가 프레임워크에서 자유로워진다.
응답에 code를 넣은 건 계약과 설명을 분리하기 위해서다. 지금까지는 message밖에 없어서 클라이언트가 문자열로 분기해야 했는데, 그러면 문구를 다듬는 순간 클라이언트가 깨진다. code는 바뀌지 않는 계약, message는 언제든 다듬을 수 있는 설명으로 역할을 갈랐다.
로그를 핸들러로 모은 건 중복 때문이다. 발생 지점에서 남기면 맥락은 풍부하지만 같은 사건이 여러 번 찍히고, 비즈니스 로직에 로깅 코드가 섞인다. 모든 예외가 결국 핸들러로 올라온다는 점을 이용해 한 곳으로 몰았다. 대신 비즈니스 예외에는 스택트레이스를 남기지 않기로 했다 — 재고 부족은 버그가 아니라 정상적인 실패고, 여기에 스택을 찍으면 진짜 문제가 묻힌다.
마지막에 구멍을 하나 찾았다. assert 표준에서 도달 불가 분기에 AssertionError를 던지기로 했는데, 이건 Exception이 아니라 Error 계열이라 @ExceptionHandler(Exception.class)에 잡히지 않는다. -ea와 무관하게 운영에서도 실행되는 코드이므로, 그대로 두면 서블릿 기본 오류 페이지가 나가면서 응답 형식이 통째로 깨진다. 별도 핸들러를 추가해 막았다.
정리
ErrorCodeenum이 실패의 단일 출처다. 코드, HTTP 상태, 기본 메시지를 한 곳에 모은다- 코드는 9자리이고 상태·도메인·일련번호가 3자리씩이다. 코드를 직접 적지 않고 세 조각에서 조립해 서로 어긋날 수 없게 한다
- 도메인 번호는
DomainCodeenum 한 곳에 모은다.COMMON(0)을 공통으로 예약하고 도메인은 1번부터 붙인다 - enum 상수 이름은 읽히도록 남긴다. 이름은 개발자용, 9자리 숫자는 클라이언트와 로그용이다
HttpStatus가 아니라int를 담는다. Domain이 예외를 던져도 웹 계층을 알지 않게 된다- 분류가 끝난 실패는
ErrorCodeException하나로 운반한다. 업무·기술 실패의 구분은ErrorCode가 하고, 예상하지 못한 실패는 원본 그대로 올린다 - 응답의
code는 계약이고message는 설명이다. 클라이언트는code로 분기한다 - 로그는 경계 한 곳에서만 남긴다. 웹은
@RestControllerAdvice, 벌크는StepExecutionListener. 레벨은 상태 코드로 정한다 — 4xx는warn, 5xx는error - 500 응답에 내부 메시지를 노출하지 않는다. 원인은 로그에서 찾는다
AssertionError용 핸들러를 별도로 둔다.Error계열이라Exception핸들러에 잡히지 않는다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/exception-handling-standard/SKILL.md
---
name: exception-handling-standard
description: 예외 처리와 에러 응답 규칙. ErrorCode, ErrorCodeException, @RestControllerAdvice, 예외 로깅을 작성하거나 리뷰할 때 반드시 적용한다.
---
# 예외 처리 표준
실패에 대한 정보는 **`ErrorCode` enum 한 곳**에 모은다.
## ErrorCode
- `ErrorCode` enum에 9자리 숫자 코드와 기본 메시지를 정의한다.
- 코드는 9자리이며 **앞 3자리는 HTTP 상태, 가운데 3자리는 도메인, 뒤 3자리는 그 조합 안에서의 일련번호**다. 예: `409001001`은 409 / 주문 도메인 / 1번이다.
- 코드를 숫자 리터럴로 직접 적지 않는다. 상태·도메인·일련번호를 각각 받고 `getCode()`에서 `status * 1_000_000 + domain.getCode() * 1_000 + serial`로 조립한다. 세 조각이 원본이고 코드가 파생이므로 둘이 어긋날 수 없다.
- 도메인 번호는 `ErrorCode`에 적지 않고 `common/error`의 `DomainCode` enum에 모은다. `COMMON(0)`을 공통으로 예약하고 도메인은 1번부터 붙인다. package-structure-standard를 따른다.
- `getStatus()`는 `status` 필드를 그대로 반환한다.
- 도메인은 최대 1000개, 한 도메인이 한 HTTP 상태에서 쓸 수 있는 코드는 1000개다. 9자리가 `int` 범위에 들어가는 마지막 자릿수이므로 더 늘리지 않는다.
- enum 상수 이름은 `STOCK_SHORTAGE`처럼 의미가 읽히게 짓는다. `E409001001` 같이 코드를 반복하는 이름을 쓰지 않는다. 이름은 개발자가 코드를 읽을 때, 숫자는 클라이언트 분기와 로그 검색에 쓰는 것으로 역할이 다르다.
- HTTP 상태는 Spring의 `HttpStatus`가 아니라 `int`로 담는다. `ErrorCode`가 프레임워크에 의존하면 이를 던지는 Domain이 웹 계층을 간접적으로 알게 된다.
## ErrorCodeException
- 원인과 외부 계약을 이해해 `ErrorCode`까지 부여한 실패는 `ErrorCodeException` 하나로 운반한다. `OrderNotFoundException` 같은 구체 예외 클래스를 만들지 않는다.
- 업무 실패와 기술 실패의 구분은 예외 타입이 아니라 `ErrorCode`로 한다.
- private 생성자와 정적 팩토리 `of(ErrorCode)` / `of(ErrorCode, String)` / `of(ErrorCode, Throwable)` / `of(ErrorCode, String, Throwable)`로 만든다.
- 기본 메시지로 충분하면 `of(ErrorCode)`를, 상황 값을 담아야 하면 `of(ErrorCode, String)`을 쓴다.
- 외부 연동·영속성 예외처럼 원본 예외를 번역할 때는 `Throwable`을 받는 팩토리로 `cause`를 보존한다.
- `catch (Exception)`으로 예상하지 못했거나 아직 분류하지 못한 실패를 `ErrorCodeException`으로 감싸지 않는다. 원본 예외가 공통 500 핸들러까지 올라가야 새 결함을 발견할 수 있다.
- 메시지를 직접 쓰는 것은 **의미를 바꿀 때가 아니라 상황 값을 덧붙일 때만** 허용한다. 기본 메시지와 의미가 달라져야 한다면 그건 다른 실패이므로 `ErrorCode`를 새로 추가한다.
- Domain과 Service 모두 `ErrorCodeException`을 던질 수 있다.
## 응답
- `HttpApiResponse<T>`는 `int code` 필드를 가진다. 실패 시 `errorCode.getCode()`를 채우고 성공 코드는 http-success-response-standard를 따른다.
- `code`는 클라이언트가 분기하는 계약이고, `message`는 사람이 읽는 설명이다. 클라이언트가 `message` 문자열로 분기하게 만들지 않는다.
- 성공 응답에서도 `code`와 `errors`를 비워두지 않는다.
## @RestControllerAdvice
- 웹 요청의 예외 로그는 `@RestControllerAdvice` 한 곳에서만 남긴다. Service나 Domain에서 로그를 남기고 예외를 던지지 않는다.
- 규칙의 핵심은 "경계에서 한 번"이며 그 경계는 진입점마다 다르다. 벌크 경로는 Advice가 없으므로 `StepExecutionListener`가 그 자리를 맡는다. exception-taxonomy-standard를 따른다.
- 로그 레벨은 예외 클래스가 아니라 `ErrorCode`의 상태 코드로 정한다. 4xx는 `warn`으로 스택트레이스 없이, 5xx는 `error`로 스택트레이스와 함께 남긴다.
- 예상하지 못한 예외는 `error`로 스택트레이스와 함께 남긴다.
- 500 응답 본문에 `e.getMessage()`를 넣지 않는다. `ErrorCode.INTERNAL_ERROR`의 기본 메시지만 내보낸다. 내부 클래스명, SQL, 경로가 노출될 수 있다.
- `AssertionError`용 핸들러를 별도로 등록한다. `Error` 계열이라 `@ExceptionHandler(Exception.class)`에 잡히지 않아, 없으면 응답 형식이 깨진다.
- 상태 코드는 `ResponseEntity.status(errorCode.getStatus())`로 `ErrorCode`에서 가져온다. 핸들러에 상태 코드를 하드코딩하지 않는다.
GitHub Copilot — .github/instructions/exception-handling-standard.instructions.md
---
description: 예외 처리와 에러 응답 규칙
applyTo: "**/*.java"
---
- 실패 정보는 `ErrorCode` enum에 9자리 숫자 코드와 기본 메시지로 정의한다.
- 코드는 앞 3자리를 HTTP 상태, 가운데 3자리를 도메인, 뒤 3자리를 일련번호로 구성한다. 예: `409001001`.
- 코드를 숫자 리터럴로 적지 않는다. 상태·도메인·일련번호를 각각 받고 `getCode()`에서 `status * 1_000_000 + domain.getCode() * 1_000 + serial`로 조립한다.
- 도메인 번호는 `common/error`의 `DomainCode` enum에 모으고 `COMMON(0)`을 공통으로 예약한다.
- `getStatus()`는 `status` 필드를 그대로 반환한다.
- enum 상수 이름은 의미가 읽히게 짓고 `E409001001` 같이 코드를 반복하지 않는다.
- `ErrorCode`의 HTTP 상태는 `HttpStatus`가 아니라 `int`로 담는다.
- 분류가 끝나 `ErrorCode`가 부여된 실패는 `ErrorCodeException` 하나로 운반하고 구체 예외 클래스를 만들지 않는다.
- `ErrorCodeException`은 private 생성자와 정적 팩토리 `of(ErrorCode)` / `of(ErrorCode, String)` / `of(ErrorCode, Throwable)` / `of(ErrorCode, String, Throwable)`로 만든다.
- 원본 예외를 번역할 때는 `cause`를 보존한다.
- `catch (Exception)`으로 예상하지 못했거나 아직 분류하지 못한 실패를 `ErrorCodeException`으로 감싸지 않는다.
- 메시지를 직접 넘기는 것은 상황 값을 덧붙일 때만 한다. 의미가 달라지면 `ErrorCode`를 새로 추가한다.
- `HttpApiResponse<T>`는 `int code` 필드를 가지며 실패 시 `errorCode.getCode()`를 채운다. 성공 코드는 http-success-response-standard를 따른다.
- 클라이언트가 `message` 문자열로 분기하게 만들지 않는다.
- 웹 요청의 예외 로그는 `@RestControllerAdvice`에서만 남기고 Service나 Domain에서 남기지 않는다.
- 로그는 경계에서 한 번만 남긴다. 벌크 경로는 Advice가 없으므로 `StepExecutionListener`가 그 자리를 맡는다.
- 로그 레벨은 `ErrorCode`의 상태 코드로 정한다. 4xx는 `warn`, 5xx는 `error`에 스택트레이스를 남긴다.
- 예상하지 못한 예외는 `error`로 스택트레이스와 함께 남긴다.
- 500 응답 본문에 `e.getMessage()`를 넣지 않는다.
- `AssertionError` 전용 `@ExceptionHandler`를 등록한다.
- 핸들러에서 상태 코드를 하드코딩하지 말고 `errorCode.getStatus()`를 쓴다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기