지금까지 정리한 표준들은 요청이 들어오는 길을 다뤘다. 이 글은 반대 방향, 실패가 나가는 길을 다룬다.

그런데 앞선 표준들을 다시 보면 이미 정의되지 않은 것에 기대고 있다. 계층별 검증 표준Service 계층 assert 표준BusinessException을 던지고, DTO 네이밍 표준@RestControllerAdvice가 응답을 만든다고 적어놨다. 정작 그 예외가 어떻게 생겼는지, 어떤 상태 코드로 나가는지는 아무 데도 없다.

그래서 지금 코드에는 세 가지 문제가 있다.

throw new BusinessException("등록되지 않은 상품입니다.");   // 404가 자연스럽다
throw new BusinessException("재고가 부족합니다.");           // 409가 맞다
throw new BusinessException("존재하지 않는 주문입니다.");     // 404가 자연스럽다
  • 전부 409로 나간다. 핸들러가 BusinessException을 무조건 CONFLICT로 매핑하기 때문이다
  • 클라이언트가 원인으로 분기할 수 없다. 응답에 message 문자열뿐이라, 그걸로 분기하면 문구만 다듬어도 클라이언트가 깨진다
  • 로그를 어디서 남길지 정해지지 않았다. 같은 예외가 두 번 찍히거나 아예 안 찍힌다

이 글은 ErrorCode를 실패의 단일 출처로 삼아 셋을 한꺼번에 해결한다.


원칙 1. ErrorCode가 실패의 단일 출처다

실패 하나에 대해 알아야 할 것은 세 가지다 — 코드, HTTP 상태, 기본 메시지. 이걸 enum 한 곳에 모은다.

@Getter
public enum ErrorCode {

    // 400 — 요청 자체가 잘못됨
    INVALID_REQUEST(400001, "요청 값이 유효하지 않습니다."),
    MALFORMED_REQUEST_BODY(400002, "요청 본문을 해석할 수 없습니다."),

    // 404 — 대상이 없음
    ORDER_NOT_FOUND(404001, "존재하지 않는 주문입니다."),
    PRODUCT_NOT_FOUND(404002, "등록되지 않은 상품입니다."),

    // 409 — 현재 상태에서 불가능한 요청
    STOCK_SHORTAGE(409001, "재고가 부족합니다."),
    ORDER_ALREADY_CONFIRMED(409002, "이미 확정된 주문입니다."),

    // 500 — 서버 문제
    INTERNAL_ERROR(500001, "서버 내부 오류가 발생했습니다.");

    private final int code;
    private final String defaultMessage;

    ErrorCode(int code, String defaultMessage) {
        this.code = code;
        this.defaultMessage = defaultMessage;
    }

    /** 앞 3자리가 HTTP 상태 코드다. 409001 → 409 */
    public int getStatus() {
        return code / 1000;
    }
}

코드는 6자리, 앞 3자리가 HTTP 상태다

409001에서 앞 3자리 409는 HTTP 상태, 뒤 3자리 001은 그 상태 안에서의 일련번호다.

상태를 별도 필드로 두지 않고 코드에서 뽑아내는 것이 핵심이다. 두 값을 따로 관리하면 STOCK_SHORTAGE(404, 409001, ...) 같은 실수가 가능한데, 파생시키면 그럴 수가 없다. 코드 하나만 정확히 적으면 상태는 자동으로 따라온다.

enum 상수 이름은 읽히도록 남긴다

코드가 6자리 숫자라고 해서 상수 이름까지 숫자로 만들지 않는다.

throw BusinessException.of(ErrorCode.STOCK_SHORTAGE);   // 무슨 실패인지 바로 읽힌다
throw BusinessException.of(ErrorCode.E409001);          // 코드를 찾아봐야 안다

이름과 코드는 용도가 다르다. 이름은 개발자가 코드를 읽을 때 쓰고, 6자리 숫자는 클라이언트가 분기하거나 로그를 검색할 때 쓴다. 둘 다 있어야 각자 자리에서 제 역할을 한다.

HttpStatus가 아니라 int를 쓰는 이유

Spring의 HttpStatus가 더 읽기 좋아 보이지만, 그러면 ErrorCodeorg.springframework.http를 import한다. 그리고 DomainBusinessException을 던지는 순간 도메인이 웹 계층을 간접적으로 알게 된다.

// StockDomain — 영속성도 웹도 몰라야 하는 계층
public void validateOrderable(int requestedQuantity) {
    if (availableQuantity < requestedQuantity) {
        throw BusinessException.of(ErrorCode.STOCK_SHORTAGE);
    }
}

int로 담으면 ErrorCode는 프레임워크에 의존하지 않는 순수한 타입이 되고, Domain이 자유롭게 던질 수 있다. 여기 적힌 숫자는 “이 실패가 클라이언트에게 어떤 성격인지”를 분류한 값이지 HTTP 프레임워크에 대한 의존이 아니다.


원칙 2. 비즈니스 예외 클래스는 하나만 둔다

OrderNotFoundException, StockShortageException처럼 실패마다 클래스를 만들면 실패가 늘어날 때마다 파일이 늘어난다. 그런데 정작 대부분은 catch해서 구분 처리하지 않고 그대로 핸들러까지 올라간다.

그래서 예외 클래스는 하나로 두고, 구분은 ErrorCode가 한다.

@Getter
public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

    private BusinessException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public static BusinessException of(ErrorCode errorCode) {
        return new BusinessException(errorCode, errorCode.getDefaultMessage());
    }

    // 상황별 상세 메시지가 필요할 때
    public static BusinessException of(ErrorCode errorCode, String message) {
        return new BusinessException(errorCode, message);
    }
}

private 생성자 + 정적 팩토리 구조는 DTO 생성 표준과 같다. 생성 경로를 하나로 두는 이유도 같다.

// 기본 메시지로 충분한 경우
throw BusinessException.of(ErrorCode.ORDER_NOT_FOUND);

// 상황 값을 담아야 하는 경우
throw BusinessException.of(
        ErrorCode.STOCK_SHORTAGE,
        "재고가 부족합니다. 요청 %d개, 주문 가능 %d개".formatted(requested, available));

ErrorCode는 그대로이므로 클라이언트가 보는 코드는 변하지 않고, 사람이 읽는 메시지만 상세해진다.

메시지를 직접 쓰는 기준

메시지를 아무 때나 덮어쓰면 같은 실패가 여러 문구로 흩어진다. 재고 부족을 다섯 곳에서 던지는데 문구가 조금씩 다르면 클라이언트도 QA도 헷갈린다. 그래서 기준을 둔다.

의미를 바꾸는 것이 아니라, 상황 값을 덧붙일 때만 직접 쓴다.

// ✅ 의미는 같고 숫자만 덧붙였다
"재고가 부족합니다. 요청 5개, 주문 가능 2개"

// ❌ 의미 자체가 달라졌다 — 이건 새 ErrorCode를 만들라는 신호다
BusinessException.of(ErrorCode.STOCK_SHORTAGE, "판매가 종료된 상품입니다.");

기본 메시지와 의미가 달라져야 한다면 그건 다른 실패다. ErrorCode를 하나 추가하는 것이 맞다.


원칙 3. 응답에 code를 실어 클라이언트가 분기하게 한다

DTO 네이밍 표준HttpApiResponse<T>code 필드를 추가한다.

@Getter
public class HttpApiResponse<T> {

    private static final int SUCCESS_CODE = 200000;

    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> success(T data) {
        return new HttpApiResponse<>(true, 200, SUCCESS_CODE, "OK", data, List.of());
    }

    public static <T> HttpApiResponse<T> success(int status, T data) {
        return new HttpApiResponse<>(true, status, SUCCESS_CODE, "OK", 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);
        }
    }
}

성공 응답에서도 code200000으로 항상 채운다. errors를 빈 리스트로 두는 것과 같은 이유다 — 클라이언트가 필드 존재 여부로 분기하지 않게 하기 위해서다.

응답은 이렇게 나간다.

{
  "success": false,
  "status": 409,
  "code": 409001,
  "message": "재고가 부족합니다. 요청 5개, 주문 가능 2개",
  "data": null,
  "errors": []
}

code는 계약이고 message는 설명이다. 클라이언트는 code로 분기하고, message는 사용자에게 보여주기만 한다. 이렇게 나누면 메시지 문구를 언제든 다듬어도 클라이언트가 깨지지 않는다.


원칙 4. 로그는 @RestControllerAdvice 한 곳에서만 남긴다

모든 예외는 결국 핸들러까지 올라온다. 그러니 로그도 거기서만 남긴다. Service에서 로그를 남기고 예외를 던지면 같은 사건이 두 번 찍히고, 비즈니스 로직에 로깅 코드가 섞인다.

@Slf4j
@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<HttpApiResponse<Void>> handleBusiness(BusinessException e) {
        ErrorCode errorCode = e.getErrorCode();
        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다. BusinessException이 외부 연동 실패처럼 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(...)로 막기로 했다. 그런데 AssertionErrorException이 아니라 Error를 상속한다.

graph TB T["Throwable"] --> EX["Exception"] T --> ER["Error"] EX --> RE["RuntimeException"] RE --> BE["BusinessException"] ER --> AE["AssertionError"] style EX fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style BE fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style ER fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0 style AE fill:#1a202c,stroke:#f56565,stroke-width:3px,color:#e2e8f0

@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
대상 없음 BusinessException + XXX_NOT_FOUND 404 warn
현재 상태로 불가능 BusinessException + 해당 ErrorCode 409 warn
도달 불가 분기 AssertionError (직접 던짐) 500 error + 스택
그 외 전부 Exception 핸들러 500 error + 스택
프로그래머 실수 (개발/테스트) assert

마지막 줄은 assert 표준의 영역이다. assert는 운영에서 꺼지므로 응답을 만들지 않는다. 사용자에게 보여야 하는 실패는 반드시 예외여야 한다.


이 표준을 정하기까지

출발점은 빚을 발견한 것이었다. 검증 표준과 assert 표준이 BusinessException을 던지고, DTO 표준이 @RestControllerAdvice를 언급하는데, 정작 그것들을 정의한 곳이 없었다. 세 표준이 존재하지 않는 것을 전제하고 있었던 셈이다.

가장 먼저 눈에 띈 건 상태 코드였다. 핸들러가 BusinessException을 무조건 409로 매핑하고 있었는데, 실제 코드에는 “존재하지 않는 주문”처럼 404가 자연스러운 것이 섞여 있었다. 예외에 상태 코드 정보가 없으니 핸들러가 구분할 방법이 없었다.

예외 클래스를 실패마다 만들지, 코드로 구분할지 고민했다. OrderNotFoundException 같은 걸 만들면 타입으로 구분되고 catch도 세분화할 수 있다. 그런데 따져보니 대부분의 비즈니스 예외는 catch하지 않고 그대로 핸들러까지 올라간다. 타입 구분이 실제로 쓰이지 않는데 클래스만 늘어나는 셈이라, ErrorCode enum으로 구분하고 예외 클래스는 하나만 두기로 했다. 상태 코드 매핑이 enum 한 곳에 모이는 것도 이득이었다.

코드는 6자리 숫자로 정했다. 로그에서 검색하기 좋고 클라이언트가 분기하기도 편해서다. 처음에는 enum 상수 이름(name())을 그대로 코드로 쓸까 생각했는데, 숫자 코드를 쓰기로 하면서 상수 이름은 이름대로 남기기로 했다. E409001 같은 이름은 코드를 찾아봐야 무슨 실패인지 알 수 있어서, 정작 코드를 읽을 때 손해가 크다. 이름은 개발자용, 숫자는 클라이언트와 로그용으로 역할을 갈랐다.

대신 어긋날 여지는 코드 구성으로 없앴다. 앞 3자리를 HTTP 상태로 쓰고 code / 1000으로 파생시키면, STOCK_SHORTAGE(404, 409001, ...) 같은 불일치가 애초에 불가능해진다. 관리할 값이 하나로 줄어드는 셈이다.

HttpStatus 대신 int를 쓴 이유는 나중에 발견했다. DomainBusinessException을 던지는데, ErrorCode가 Spring의 HttpStatus를 들고 있으면 도메인이 웹 계층을 간접적으로 알게 된다. DTO 표준에서 Domain이 영속성을 모르게 하려고 공들였는데 여기서 웹이 새어 들어오면 앞뒤가 안 맞는다. int로 담으면 ErrorCode가 프레임워크에서 자유로워진다.

응답에 code를 넣은 건 계약과 설명을 분리하기 위해서다. 지금까지는 message밖에 없어서 클라이언트가 문자열로 분기해야 했는데, 그러면 문구를 다듬는 순간 클라이언트가 깨진다. code는 바뀌지 않는 계약, message는 언제든 다듬을 수 있는 설명으로 역할을 갈랐다.

로그를 핸들러로 모은 건 중복 때문이다. 발생 지점에서 남기면 맥락은 풍부하지만 같은 사건이 여러 번 찍히고, 비즈니스 로직에 로깅 코드가 섞인다. 모든 예외가 결국 핸들러로 올라온다는 점을 이용해 한 곳으로 몰았다. 대신 비즈니스 예외에는 스택트레이스를 남기지 않기로 했다 — 재고 부족은 버그가 아니라 정상적인 실패고, 여기에 스택을 찍으면 진짜 문제가 묻힌다.

마지막에 구멍을 하나 찾았다. assert 표준에서 도달 불가 분기에 AssertionError를 던지기로 했는데, 이건 Exception이 아니라 Error 계열이라 @ExceptionHandler(Exception.class)에 잡히지 않는다. -ea와 무관하게 운영에서도 실행되는 코드이므로, 그대로 두면 서블릿 기본 오류 페이지가 나가면서 응답 형식이 통째로 깨진다. 별도 핸들러를 추가해 막았다.


정리

  • ErrorCode enum이 실패의 단일 출처다. 코드, HTTP 상태, 기본 메시지를 한 곳에 모은다
  • 코드는 6자리이고 앞 3자리가 HTTP 상태다. 상태를 code / 1000으로 파생시켜 둘이 어긋날 수 없게 한다
  • enum 상수 이름은 읽히도록 남긴다. 이름은 개발자용, 6자리 숫자는 클라이언트와 로그용이다
  • HttpStatus가 아니라 int를 담는다. Domain이 예외를 던져도 웹 계층을 알지 않게 된다
  • 비즈니스 예외 클래스는 하나만 둔다. 구분은 ErrorCode가 하고, 클래스는 늘리지 않는다
  • 응답의 code는 계약이고 message는 설명이다. 클라이언트는 code로 분기한다
  • 로그는 @RestControllerAdvice 한 곳에서만 남긴다. 레벨은 상태 코드로 정한다 — 4xx는 warn, 5xx는 error
  • 500 응답에 내부 메시지를 노출하지 않는다. 원인은 로그에서 찾는다
  • AssertionError용 핸들러를 별도로 둔다. Error 계열이라 Exception 핸들러에 잡히지 않는다

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/exception-handling-standard/SKILL.md

---
name: exception-handling-standard
description: 예외 처리와 에러 응답 규칙. ErrorCode, BusinessException, @RestControllerAdvice, 예외 로깅을 작성하거나 리뷰할 때 반드시 적용한다.
---

# 예외 처리 표준

실패에 대한 정보는 **`ErrorCode` enum 한 곳**에 모은다.

## ErrorCode
- `ErrorCode` enum에 6자리 숫자 코드와 기본 메시지를 정의한다.
- 코드는 6자리로 만들되 **앞 3자리는 HTTP 상태, 뒤 3자리는 그 상태 안에서의 일련번호**로 구성한다. 예: `409001`, `404002`.
- HTTP 상태를 별도 필드로 두지 않는다. `getStatus()`에서 `code / 1000`으로 파생시켜 둘이 어긋날 수 없게 한다.
- enum 상수 이름은 `STOCK_SHORTAGE`처럼 의미가 읽히게 짓는다. `E409001` 같이 코드를 반복하는 이름을 쓰지 않는다. 이름은 개발자가 코드를 읽을 때, 숫자는 클라이언트 분기와 로그 검색에 쓰는 것으로 역할이 다르다.
- HTTP 상태는 Spring의 `HttpStatus`가 아니라 `int`로 담는다. `ErrorCode`가 프레임워크에 의존하면 이를 던지는 Domain이 웹 계층을 간접적으로 알게 된다.

## BusinessException
- 비즈니스 예외 클래스는 `BusinessException` 하나만 둔다. `OrderNotFoundException` 같은 구체 예외 클래스를 만들지 않는다.
- 구분은 `ErrorCode`로 한다.
- private 생성자와 정적 팩토리 `of(ErrorCode)` / `of(ErrorCode, String)`로 만든다.
- 기본 메시지로 충분하면 `of(ErrorCode)`를, 상황 값을 담아야 하면 `of(ErrorCode, String)`을 쓴다.
- 메시지를 직접 쓰는 것은 **의미를 바꿀 때가 아니라 상황 값을 덧붙일 때만** 허용한다. 기본 메시지와 의미가 달라져야 한다면 그건 다른 실패이므로 `ErrorCode`를 새로 추가한다.
- Domain과 Service 모두 `BusinessException`을 던질 수 있다.

## 응답
- `HttpApiResponse<T>``int code` 필드를 가진다. 실패 시 `errorCode.getCode()`를, 성공 시 `200000`을 채운다.
- `code`는 클라이언트가 분기하는 계약이고, `message`는 사람이 읽는 설명이다. 클라이언트가 `message` 문자열로 분기하게 만들지 않는다.
- 성공 응답에서도 `code``errors`를 비워두지 않는다.

## @RestControllerAdvice
- 예외 로그는 `@RestControllerAdvice` 한 곳에서만 남긴다. Service나 Domain에서 로그를 남기고 예외를 던지지 않는다.
- 로그 레벨은 예외 클래스가 아니라 `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에 6자리 숫자 코드와 기본 메시지로 정의한다.
- 코드는 앞 3자리를 HTTP 상태, 뒤 3자리를 일련번호로 구성한다. 예: `409001`.
- HTTP 상태는 별도 필드로 두지 말고 `getStatus()`에서 `code / 1000`으로 파생시킨다.
- enum 상수 이름은 의미가 읽히게 짓고 `E409001` 같이 코드를 반복하지 않는다.
- `ErrorCode`의 HTTP 상태는 `HttpStatus`가 아니라 `int`로 담는다.
- 비즈니스 예외는 `BusinessException` 하나만 쓰고 구체 예외 클래스를 만들지 않는다.
- `BusinessException`은 private 생성자와 정적 팩토리 `of(ErrorCode)` / `of(ErrorCode, String)`로 만든다.
- 메시지를 직접 넘기는 것은 상황 값을 덧붙일 때만 한다. 의미가 달라지면 `ErrorCode`를 새로 추가한다.
- `HttpApiResponse<T>``int code` 필드를 가지며 실패 시 `errorCode.getCode()`, 성공 시 `200000`을 채운다.
- 클라이언트가 `message` 문자열로 분기하게 만들지 않는다.
- 예외 로그는 `@RestControllerAdvice`에서만 남기고 Service나 Domain에서 남기지 않는다.
- 로그 레벨은 `ErrorCode`의 상태 코드로 정한다. 4xx는 `warn`, 5xx는 `error`에 스택트레이스를 남긴다.
- 예상하지 못한 예외는 `error`로 스택트레이스와 함께 남긴다.
- 500 응답 본문에 `e.getMessage()`를 넣지 않는다.
- `AssertionError` 전용 `@ExceptionHandler`를 등록한다.
- 핸들러에서 상태 코드를 하드코딩하지 말고 `errorCode.getStatus()`를 쓴다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기