예외 처리 표준에서 ErrorCodeBusinessException으로 실패를 다루는 방법을 정했다. 그런데 그 글이 다룬 것은 우리가 직접 던지는 예외뿐이다.

실제 서비스에서는 우리가 던지지 않은 예외가 훨씬 많이 나온다. 그리고 그중 일부는 우리가 만든 응답 형식을 통째로 무시하고 나간다. 이 글은 실패의 종류를 전부 늘어놓고, 각각을 어디서 잡을지 정한다.


분류 기준: 누구 잘못이고, 어디서 터지는가

graph LR C["클라이언트"] --> F["Security 필터"] F --> D["DispatcherServlet"] D --> CTRL["Controller"] CTRL --> SVC["Service"] SVC --> REPO["Repository"] SVC --> EXT["외부 API"] F -.->|"401/403
Advice 못 잡음"| X1["EntryPoint
AccessDeniedHandler"] D -.->|"400/405/415"| X2["@RestControllerAdvice"] SVC -.->|"비즈니스 위반"| X2 REPO -.->|"제약 위반"| X3["Repository에서 번역"] EXT -.->|"타임아웃/5xx"| X4["Client에서 번역"] style F fill:#2d3748,stroke:#f56565,stroke-width:3px,color:#e2e8f0 style X1 fill:#1a202c,stroke:#f56565,stroke-width:3px,color:#e2e8f0 style X2 fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style X3 fill:#2d3748,stroke:#ed8936,stroke-width:2px,color:#e2e8f0 style X4 fill:#2d3748,stroke:#ed8936,stroke-width:2px,color:#e2e8f0

핵심은 @RestControllerAdvice가 만능이 아니라는 것이다. 그 앞뒤로 새는 곳이 있고, 그 구멍마다 처리 지점을 따로 둬야 응답 형식이 유지된다.


원칙 1. 인증·인가 실패는 Advice 밖에서 처리한다

가장 놓치기 쉬운 지점이다. Spring Security 필터는 DispatcherServlet보다 앞에서 돈다. 그런데 @RestControllerAdvice는 DispatcherServlet의 예외 처리 단계에 있다.

[Security 필터] ← 토큰 만료가 여기서 터지면
      ↓                @RestControllerAdvice까지 도달하지 못한다
[DispatcherServlet] → [Controller] → ...

그대로 두면 인증 실패에서만 우리 형식이 아닌 응답이 나간다. 클라이언트는 401만 다르게 파싱해야 한다.

응답을 직접 써주는 컴포넌트를 하나 두고, AuthenticationEntryPointAccessDeniedHandler가 공유한다.

@Component
@RequiredArgsConstructor
public class SecurityResponseWriter {

    private final ObjectMapper objectMapper;

    public void write(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        response.setStatus(errorCode.getStatus());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());

        objectMapper.writeValue(
                response.getWriter(),
                HttpApiResponse.error(errorCode, errorCode.getDefaultMessage()));
    }
}
@Component
@RequiredArgsConstructor
public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final SecurityResponseWriter writer;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException e) throws IOException {
        writer.write(response, ErrorCode.UNAUTHENTICATED);
    }
}

@Component
@RequiredArgsConstructor
public class ApiAccessDeniedHandler implements AccessDeniedHandler {

    private final SecurityResponseWriter writer;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException e) throws IOException {
        writer.write(response, ErrorCode.ACCESS_DENIED);
    }
}
http.exceptionHandling(handler -> handler
        .authenticationEntryPoint(apiAuthenticationEntryPoint)
        .accessDeniedHandler(apiAccessDeniedHandler));

메서드 레벨 거부는 Advice가 잡을 수 있다

@PreAuthorize가 막는 경우는 DispatcherServlet 안에서 일어나므로 Advice가 잡을 수 있다. 그래서 AccessDeniedException 핸들러를 Advice에도 두게 되는데, 같은 ErrorCode를 쓰면 어느 쪽에서 잡히든 응답이 같다. 그게 목적이므로 중복이라고 볼 필요는 없다.


원칙 2. 영속성 예외는 Repository 구현체에서 번역한다

이메일 unique 제약을 어기면 Spring이 DataIntegrityViolationException을 던진다. 이걸 그냥 두면 Exception 핸들러로 떨어져 500 “서버 내부 오류”가 나간다. 실제로는 사용자 잘못(중복 가입)이므로 409가 맞다.

번역 위치는 Repository 구현체다. DTO 네이밍 표준에서 Entity와 MapperResult가 패키지 밖으로 안 나가게 했듯, 영속성 예외 타입도 Service로 새면 안 된다.

@Repository
@RequiredArgsConstructor
public class UserJpaRepository implements UserRepository {

    private final UserJpaEntityRepository jpaEntityRepository;
    private final UserEntityMapper mapper;

    @Override
    public UserDomain save(UserDomain user) {
        try {
            UserEntity saved = jpaEntityRepository.save(mapper.toEntity(user));
            return mapper.toDomain(saved);
        } catch (DataIntegrityViolationException e) {
            throw BusinessException.of(ErrorCode.DUPLICATE_EMAIL);
        } catch (OptimisticLockingFailureException e) {
            throw BusinessException.of(ErrorCode.CONCURRENT_UPDATE);
        }
    }
}

Service는 DataIntegrityViolationException이라는 타입 자체를 모른다. JPA에서 MyBatis로 바꿔도 Service 코드는 그대로다.

한계는 인정하고 간다

한 테이블에 unique 제약이 여러 개면, DataIntegrityViolationException만으로는 어느 제약이 깨졌는지 알 수 없다. 예외 메시지를 파싱하는 방법이 있지만 DB 벤더에 종속되므로 쓰지 않는다.

이런 경우는 저장 전에 조회해서 구체적인 ErrorCode로 막고, 제약 위반은 동시 요청에 대비한 최후의 방어선으로 남긴다.

// 평소에는 조회로 구체적인 실패를 알려주고
if (userRepository.existsByEmail(command.getEmail())) {
    throw BusinessException.of(ErrorCode.DUPLICATE_EMAIL);
}

// 동시에 들어온 요청은 Repository의 제약 위반이 막는다

원칙 3. 외부 연동 실패는 5xx로 구분한다

결제사가 죽어서 타임아웃이 나도 500으로 나가면, 장애 대응할 때 우리 서버 문제인지 상대 문제인지 로그를 파봐야 안다. 상태 코드만으로 구분되게 나눈다.

상황 상태 의미
상대가 5xx나 이상한 응답을 줌 502 상대 서비스 오류
연결 자체가 안 됨 503 상대 서비스 사용 불가
응답이 안 옴 504 상대 서비스 응답 지연

번역은 외부 연동 클라이언트에서 한다. 영속성 예외를 Repository에서 번역하는 것과 같은 이유다 — RestClientException 같은 타입이 Service로 새지 않게 한다.

@Component
@RequiredArgsConstructor
public class PaymentClient {

    private final RestClient restClient;

    public PaymentResult pay(PaymentCommand command) {
        try {
            return restClient.post()
                    .uri("/payments")
                    .body(command)
                    .retrieve()
                    .body(PaymentResult.class);

        } catch (ResourceAccessException e) {           // 타임아웃, 연결 실패
            throw BusinessException.of(ErrorCode.EXTERNAL_API_TIMEOUT);
        } catch (RestClientResponseException e) {       // 상대가 4xx/5xx 응답
            throw BusinessException.of(ErrorCode.EXTERNAL_API_ERROR);
        }
    }
}

원칙 4. 로그 레벨은 예외 클래스가 아니라 상태 코드로 정한다

여기서 앞선 표준을 하나 고쳐야 한다. 예외 처리 표준에서는 BusinessExceptionwarn이라고 정했는데, 이제 BusinessException이 외부 연동 실패(502/503/504)도 나르게 됐다.

결제사 장애를 warn으로 남기면 아무도 안 본다. 재고 부족과 결제사 다운은 대응이 완전히 다른 사건이다.

예외 클래스를 늘려서 구분할 수도 있지만, 이미 ErrorCode에 상태가 들어 있으므로 그걸로 판단하면 된다.

@ExceptionHandler(BusinessException.class)
public ResponseEntity<HttpApiResponse<Void>> handleBusiness(BusinessException e) {
    ErrorCode errorCode = e.getErrorCode();

    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()));
}

규칙은 하나다 — 4xx는 warn, 5xx는 error에 스택트레이스. 4xx는 클라이언트나 사용자 상황이고, 5xx는 우리가 손을 써야 하는 사건이기 때문이다.


원칙 5. Spring이 던지는 요청 오류도 등록한다

@Valid 실패와 JSON 파싱 실패만 처리하면 나머지는 500으로 샌다. 실제로는 전부 클라이언트 잘못(4xx)이다.

@ExceptionHandler({
        MethodArgumentTypeMismatchException.class,      // /orders/abc  (Long 자리에 문자)
        MissingServletRequestParameterException.class   // 필수 쿼리 파라미터 누락
})
public ResponseEntity<HttpApiResponse<Void>> handleBadRequest(Exception e) {
    return toResponse(ErrorCode.INVALID_REQUEST);
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<HttpApiResponse<Void>> handleMethodNotAllowed(Exception e) {
    return toResponse(ErrorCode.METHOD_NOT_ALLOWED);        // 405
}

@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<HttpApiResponse<Void>> handleUnsupportedMediaType(Exception e) {
    return toResponse(ErrorCode.UNSUPPORTED_MEDIA_TYPE);    // 415
}

@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<HttpApiResponse<Void>> handleNoHandler(Exception e) {
    return toResponse(ErrorCode.NO_HANDLER);                // 404
}

이것들을 등록하지 않으면 오타 하나로 잘못된 URL을 호출했을 때 500이 찍히고, 모니터링에서 서버 장애로 잡힌다.


원칙 6. Checked 예외를 만들지 않는다

우리 코드에서 extends Exception을 만들지 않는다. 이유가 두 가지인데, 두 번째가 결정적이다.

  1. 호출부마다 throwstry-catch가 번져서 계층 경계가 지저분해진다
  2. @Transactional은 기본적으로 unchecked 예외만 롤백한다

두 번째가 위험한 이유는 조용히 실패하기 때문이다.

// ❌ checked 예외를 던지면 트랜잭션이 커밋된다
@Transactional
public void createOrder(CreateOrderCommand command) throws PaymentFailedException {
    orderRepository.save(order);        // ← 이게 저장된 채로
    paymentClient.pay(command);         // ← 여기서 checked 예외가 나면 롤백되지 않는다
}

주문은 저장됐는데 결제는 안 된 상태가 남는다. 에러도 안 나고 로그도 정상이라, 데이터가 어긋난 걸 한참 뒤에 발견한다. assert 표준에서 경계한 “조용히 넘어가는 코드”의 가장 비싼 형태다.

BusinessExceptionRuntimeException을 상속하는 것도 이 때문이다.


원칙 7. Error 계열은 잡지 않는다

OutOfMemoryError, StackOverflowError잡아도 복구할 수 없다. 오히려 잡으면 망가진 상태로 계속 돌면서 더 이상한 증상을 만든다. 그대로 죽고 재시작되는 편이 낫다.

유일한 예외가 assert 표준에서 정한 AssertionError다. 이건 복구하려고 잡는 것이 아니라 응답 형식을 지키려고 잡는다. 잡아서 500을 내보내고 로그를 남길 뿐, 처리를 계속하지는 않는다.


ErrorCode 확장

지금까지 나온 실패를 6자리 코드 체계에 넣으면 이렇게 된다.

@Getter
public enum ErrorCode {

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

    // 401 — 인증
    UNAUTHENTICATED(401001, "인증이 필요합니다."),

    // 403 — 인가
    ACCESS_DENIED(403001, "접근 권한이 없습니다."),

    // 404 — 대상 없음
    NO_HANDLER(404000, "요청한 경로를 찾을 수 없습니다."),
    ORDER_NOT_FOUND(404001, "존재하지 않는 주문입니다."),
    PRODUCT_NOT_FOUND(404002, "등록되지 않은 상품입니다."),

    // 405 / 415 — 요청 방식
    METHOD_NOT_ALLOWED(405001, "지원하지 않는 요청 방식입니다."),
    UNSUPPORTED_MEDIA_TYPE(415001, "지원하지 않는 형식입니다."),

    // 409 — 현재 상태로 불가능
    STOCK_SHORTAGE(409001, "재고가 부족합니다."),
    ORDER_ALREADY_CONFIRMED(409002, "이미 확정된 주문입니다."),
    DUPLICATE_EMAIL(409003, "이미 가입된 이메일입니다."),
    CONCURRENT_UPDATE(409004, "다른 사용자가 먼저 수정했습니다."),

    // 500 — 우리 잘못
    INTERNAL_ERROR(500001, "서버 내부 오류가 발생했습니다."),

    // 502 / 503 / 504 — 외부 의존
    EXTERNAL_API_ERROR(502001, "외부 서비스 응답이 올바르지 않습니다."),
    EXTERNAL_API_UNAVAILABLE(503001, "외부 서비스를 일시적으로 사용할 수 없습니다."),
    EXTERNAL_API_TIMEOUT(504001, "외부 서비스 응답이 지연되고 있습니다.");
}

앞 3자리가 상태이므로 enum을 상태 코드 순으로 정렬하면 자연스럽게 종류별로 묶인다.


판단 기준 정리

실패 종류 어디서 잡는가 상태 로그
요청 형식 (@Valid, JSON) @RestControllerAdvice 400 warn
파라미터 타입·누락 @RestControllerAdvice 400 warn
인증 실패 (필터) AuthenticationEntryPoint 401 warn
인가 실패 (필터) AccessDeniedHandler 403 warn
인가 실패 (@PreAuthorize) @RestControllerAdvice 403 warn
경로 없음 / 405 / 415 @RestControllerAdvice 404·405·415 warn
비즈니스 규칙 위반 Service·Domain에서 던짐 4xx warn
DB 제약 위반 Repository 구현체에서 번역 409 warn
외부 API 실패 연동 클라이언트에서 번역 502·503·504 error
우리 코드 버그 @RestControllerAdvice 500 error + 스택
도달 불가 분기 AssertionError 핸들러 500 error + 스택
OutOfMemoryError 잡지 않는다

이 표준을 정하기까지

출발점은 “우리가 던지는 예외만 다뤘다”는 자각이었다. 앞선 표준은 BusinessException 하나를 잘 설계하는 데 집중했는데, 실제 서비스에서 나오는 예외는 대부분 우리가 던지지 않은 것들이다. 그것들을 늘어놓고 보니 처리 지점이 제각각이었다.

가장 놀란 건 인증 실패였다. Security 필터가 DispatcherServlet보다 앞에서 돈다는 건 알고 있었지만, 그게 @RestControllerAdvice를 우회한다는 것까지는 연결하지 못했다. 공들여 만든 HttpApiResponse 형식이 401에서만 깨지고 있었던 셈이다. AuthenticationEntryPointAccessDeniedHandler를 등록해 같은 형식으로 맞췄다.

DB 제약 위반은 500으로 나가고 있었다. 중복 가입 시도는 명백히 사용자 상황인데 “서버 내부 오류”로 응답하고 있었던 것이다. 번역 위치는 고민할 것도 없이 Repository 구현체였다 — Entity가 패키지 밖으로 안 나가듯 DataIntegrityViolationException도 Service로 새면 안 되기 때문이다. 다만 제약이 여러 개일 때 구분이 안 된다는 한계는 인정하고, 사전 조회로 구체적인 실패를 알려주고 제약 위반은 동시성 최후 방어선으로 쓰기로 했다.

외부 연동에서 로그 규칙이 어긋났다. 결제사 장애를 BusinessException으로 던지면 앞선 표준상 warn이 되는데, 그러면 아무도 안 본다. 예외 클래스를 하나 더 만들까 하다가, ErrorCode에 이미 상태가 들어 있으니 그걸로 판단하면 된다는 걸 깨달았다. 4xx는 warn, 5xx는 error로 바꾸니 예외 클래스는 그대로 하나로 유지되고 로그도 맞았다.

Checked 예외를 왜 만들지 않는지도 여기서 분명해졌다. 막연히 “불편해서”라고 생각했는데, 진짜 이유는 @Transactional이 unchecked만 롤백한다는 것이었다. checked 예외를 던지면 주문은 저장되고 결제는 실패한 상태가 조용히 커밋된다. 에러도 안 나고 로그도 정상이라 한참 뒤에 발견된다. assert 표준에서 경계한 “조용히 넘어가는 코드”의 가장 비싼 형태다.

마지막으로 잡지 말아야 할 것을 정했다. Error 계열은 복구가 불가능하므로 잡지 않는다. AssertionError만 예외인데, 그것도 복구하려는 게 아니라 응답 형식을 지키려고 잡는 것이라 성격이 다르다.


정리

  • @RestControllerAdvice는 만능이 아니다. 앞뒤로 새는 곳이 있고, 구멍마다 처리 지점을 따로 둔다
  • 인증·인가 실패는 AuthenticationEntryPointAccessDeniedHandler로 처리한다. Security 필터는 Advice보다 앞에서 돈다
  • 영속성 예외는 Repository 구현체에서 번역한다. DataIntegrityViolationException이 Service로 새지 않게 한다
  • 제약 위반은 최후 방어선이다. 평소에는 사전 조회로 구체적인 ErrorCode를 준다
  • 외부 연동 실패는 502·503·504로 구분한다. 상태 코드만으로 상대 장애임을 알 수 있어야 한다
  • 로그 레벨은 예외 클래스가 아니라 상태 코드로 정한다. 4xx는 warn, 5xx는 error + 스택트레이스
  • Spring이 던지는 요청 오류도 등록한다. 안 하면 잘못된 URL 하나가 500으로 찍힌다
  • checked 예외를 만들지 않는다. @Transactional이 롤백하지 않아 데이터가 조용히 어긋난다
  • Error 계열은 잡지 않는다. AssertionError만 형식 유지를 위해 예외로 둔다

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

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

---
name: exception-taxonomy-standard
description: 예외의 종류별 처리 위치 규칙. Security 예외, 영속성 예외, 외부 연동 실패, Spring 요청 오류를 다루거나 @RestControllerAdvice를 확장할 때 반드시 적용한다.
---

# 예외 종류별 처리 표준

`@RestControllerAdvice`는 모든 예외를 잡지 못한다. 실패의 종류마다 처리 지점이 다르다.

## 인증·인가
- Spring Security 필터에서 발생하는 인증·인가 실패는 `@RestControllerAdvice`에 도달하지 않는다. 필터가 `DispatcherServlet`보다 앞에서 실행되기 때문이다.
- `AuthenticationEntryPoint`(401)와 `AccessDeniedHandler`(403)를 등록하고, 그 안에서 `HttpApiResponse`를 직접 직렬화해 응답 형식을 맞춘다.
- `@PreAuthorize`로 인한 `AccessDeniedException`은 Advice에서도 잡힌다. 양쪽 모두 같은 `ErrorCode`를 쓰면 응답이 일관된다.

## 영속성 예외
- `DataIntegrityViolationException`, `OptimisticLockingFailureException` 등은 Repository 구현체에서 `catch``BusinessException`으로 번역한다.
- 영속성 예외 타입이 Service 계층으로 전파되게 두지 않는다.
- 제약이 여러 개라 원인을 구분할 수 없으면, 저장 전에 조회해서 구체적인 `ErrorCode`로 막고 제약 위반은 동시 요청 대비 최후 방어선으로만 쓴다.
- 예외 메시지를 파싱해 어떤 제약인지 알아내지 않는다. DB 벤더에 종속된다.

## 외부 연동
- 외부 API 호출 실패는 연동 클라이언트 클래스에서 `BusinessException`으로 번역한다. `RestClientException` 같은 타입이 Service로 전파되게 두지 않는다.
- 상태를 구분한다: 상대의 오류 응답은 502, 연결 불가는 503, 타임아웃은 504.

## Spring 요청 오류
- 다음 예외에 대한 핸들러를 `@RestControllerAdvice`에 등록한다. 등록하지 않으면 클라이언트 잘못이 500으로 기록된다.
  - `MethodArgumentTypeMismatchException`, `MissingServletRequestParameterException` → 400
  - `HttpRequestMethodNotSupportedException` → 405
  - `HttpMediaTypeNotSupportedException` → 415
  - `NoResourceFoundException` → 404

## 로그 레벨
- 예외 클래스가 아니라 `ErrorCode`의 상태 코드로 정한다.
- 4xx는 `warn`으로 스택트레이스 없이, 5xx는 `error`로 스택트레이스와 함께 남긴다.

## 예외 계층
- `extends Exception`인 checked 예외를 만들지 않는다. `@Transactional`이 기본적으로 unchecked 예외만 롤백하므로, checked 예외를 던지면 트랜잭션이 커밋되어 데이터가 어긋난다.
- `OutOfMemoryError``Error` 계열은 잡지 않는다. 복구할 수 없다.
- `AssertionError`만 예외로 잡되, 복구가 아니라 응답 형식 유지가 목적이다.

GitHub Copilot — .github/instructions/exception-taxonomy-standard.instructions.md

---
description: 예외 종류별 처리 위치 규칙
applyTo: "**/*.java"
---

- Security 필터의 인증·인가 실패는 `@RestControllerAdvice`가 잡지 못하므로 `AuthenticationEntryPoint``AccessDeniedHandler`를 등록한다.
- 그 핸들러 안에서 `HttpApiResponse`를 직접 직렬화해 응답 형식을 통일한다.
- `DataIntegrityViolationException``OptimisticLockingFailureException`은 Repository 구현체에서 `BusinessException`으로 번역한다.
- 영속성 예외 타입을 Service 계층으로 전파시키지 않는다.
- DB 예외 메시지를 파싱해 제약을 구분하지 않는다. 대신 저장 전 조회로 구체적인 실패를 알려준다.
- 외부 API 실패는 연동 클라이언트에서 `BusinessException`으로 번역하고 502(오류 응답)·503(연결 불가)·504(타임아웃)로 구분한다.
- `MethodArgumentTypeMismatchException`, `MissingServletRequestParameterException`(400), `HttpRequestMethodNotSupportedException`(405), `HttpMediaTypeNotSupportedException`(415), `NoResourceFoundException`(404) 핸들러를 등록한다.
- 로그 레벨은 `ErrorCode`의 상태 코드로 정한다. 4xx는 `warn`, 5xx는 `error`에 스택트레이스를 남긴다.
- checked 예외를 만들지 않는다. `@Transactional`이 unchecked 예외만 롤백하기 때문이다.
- `Error` 계열은 잡지 않는다. `AssertionError`만 응답 형식 유지를 위해 잡는다.

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

댓글남기기