예외 처리 표준에서 ErrorCode와 ErrorCodeException으로 실패를 다루는 방법을 정했다. 그런데 그 글이 다룬 것은 우리가 직접 던지는 예외뿐이다.
실제 서비스에서는 우리가 던지지 않은 예외가 훨씬 많이 나온다. 그리고 그중 일부는 우리가 만든 응답 형식을 통째로 무시하고 나간다. 이 글은 실패의 종류를 전부 늘어놓고, 각각을 어디서 잡을지 정한다.
분류 기준: 누구 잘못이고, 어디서 터지는가
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만 다르게 파싱해야 한다.
응답을 직접 써주는 컴포넌트를 하나 두고, AuthenticationEntryPoint와 AccessDeniedHandler가 공유한다.
@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 {
if (user.getId() == null) {
return mapper.toDomain(jpaEntityRepository.save(mapper.toEntity(user)));
}
UserEntity entity = jpaEntityRepository.findById(user.getId())
.orElseThrow(() -> ErrorCodeException.of(ErrorCode.USER_NOT_FOUND));
mapper.applyTo(entity, user);
return mapper.toDomain(entity);
} catch (DataIntegrityViolationException e) {
throw ErrorCodeException.of(ErrorCode.DUPLICATE_EMAIL, e);
} catch (OptimisticLockingFailureException e) {
throw ErrorCodeException.of(ErrorCode.USER_CONCURRENT_UPDATE, e);
}
}
}
Service는 DataIntegrityViolationException이라는 타입 자체를 모른다. JPA에서 MyBatis로 바꿔도 Service 코드는 그대로다.
OptimisticLockingFailureException을 잡으려면 그게 실제로 발생해야 한다. 이 글을 쓸 당시에는save(mapper.toEntity(user))한 줄이었는데, 그러면merge가 되어Domain이 나르지 않는@Version이 비어 넘어간다 — 잡기로 한 예외가 의도대로 발생하지 않는 상태였다. 위처럼 영속 Entity를 직접 고치면 JPA가 version을 검사하므로 이catch가 제 역할을 한다. Domain 영속화 표준에서 정리했다.
한계는 인정하고 간다
한 테이블에 unique 제약이 여러 개면, DataIntegrityViolationException만으로는 어느 제약이 깨졌는지 알 수 없다. 예외 메시지를 파싱하는 방법이 있지만 DB 벤더에 종속되므로 쓰지 않는다.
이런 경우는 저장 전에 조회해서 구체적인 ErrorCode로 막고, 제약 위반은 동시 요청에 대비한 최후의 방어선으로 남긴다.
// 평소에는 조회로 구체적인 실패를 알려주고
if (userRepository.existsByEmail(command.getEmail())) {
throw ErrorCodeException.of(ErrorCode.DUPLICATE_EMAIL);
}
// 동시에 들어온 요청은 Repository의 제약 위반이 막는다
원칙 2-1. DB 쿼리 타임아웃도 영속성 예외와 같은 자리에서 503으로 번역한다
스키마 마이그레이션 표준이 슬로우 쿼리 기준(300ms)을 정하면서, 그 기준을 넘겨 실제로 끊긴 쿼리가 무엇으로 응답해야 하는지가 드러났다. 지금까지 이 표준의 5xx는 전부 외부 API 쪽이었다. QueryTimeoutException이 잡히지 않으면 Exception 핸들러로 떨어져 500 “서버 내부 오류”가 나간다.
번역 위치는 원칙 2와 같다 — Repository 구현체. DB 타임아웃도 영속성 예외의 한 종류이고, DataIntegrityViolationException을 거기서 잡는 것과 같은 이유다.
정해야 했던 건 이거였다 — 상태 코드를 외부 연동처럼 502·503·504로 나눌 것인가, 하나로 통일할 것인가.
안 A — 외부 연동과 같은 삼분법을 쓴다. 얻는 것은 기존 표를 그대로 재사용한다는 점이다. 버린 이유는 원인을 나눌 축이 없다는 것이다. 외부 연동의 삼분법은 “상대가 오류를 줬는가·연결이 안 됐는가·응답이 없는가”처럼 상대의 상태로 갈린다. DB 타임아웃은 상대가 없다 — 원인은 “이쪽이 정한 시간 안에 못 끝냈다” 하나뿐이다. 없는 축을 나누면 ORDER_DB_ERROR(502에 대응)처럼 의미 없는 자리만 하나 늘어난다.
안 B — 503 하나로 통일한다. 얻는 것은 코드가 실제로 있는 원인의 개수와 맞는다는 점이다. 503은 “재시도하면 될 수도 있는, 클라이언트 잘못이 아닌 실패”라는 의미도 외부 연동의 503(연결 불가)과 같은 결이다.
골랐다 — 안 B. 안 A가 못 지켜주는 것은 코드 개수가 원인 개수와 맞는 것이다. 없는 축을 나누면 아무도 안 던지는 상수가 하나 생기고, 그건 다음 사람에게 「여기 들어갈 경우가 있나 보다」로 읽힌다.
대신 DB가 느린 것과 DB가 죽은 것이 같은 코드로 나간다. 지금은 원인이 하나뿐이라 맞지만, 나중에 둘을 갈라야 할 일이 생기면 그때는 이미 부르는 쪽이 503 하나에 맞춰 동작하고 있다. 코드를 쪼개는 값이 그때 더 비싸다.
// order/infra/OrderJpaRepository.java
@Override
public List<OrderDomain> findExpiredPendingOrders() {
try {
return jpaEntityRepository.findByStatusAndCreatedAtBefore(...)
.stream().map(mapper::toDomain).toList();
} catch (QueryTimeoutException e) {
throw ErrorCodeException.of(ErrorCode.ORDER_QUERY_TIMEOUT, e);
}
}
ErrorCode는 원칙 9의 도메인 대역 규칙대로 도메인별로 발급한다. ORDER_QUERY_TIMEOUT처럼 처리 대상 도메인을 남기고, COMMON으로 뭉치지 않는다.
원칙 3. 외부 연동 실패는 5xx로 구분한다
결제사가 죽어서 타임아웃이 나도 500으로 나가면, 장애 대응할 때 우리 서버 문제인지 상대 문제인지 로그를 파봐야 안다. 상태 코드만으로 구분되게 나눈다.
| 상황 | 상태 | 의미 |
|---|---|---|
| 상대가 5xx나 이상한 응답을 줌 | 502 | 상대 서비스 오류 |
| 연결 자체가 안 됨 | 503 | 상대 서비스 사용 불가 |
| 응답이 안 옴 | 504 | 상대 서비스 응답 지연 |
번역은 외부 연동 클라이언트에서 한다. 영속성 예외를 Repository에서 번역하는 것과 같은 이유다 — RestClientException 같은 타입이 Service로 새지 않게 한다.
아래 코드는 이 클라이언트를 도메인 하나만 쓴다는 전제 위에 있다. 그래서 ORDER_ 코드를 안에 박아도 된다. 두 번째 도메인이 같은 API를 부르기 시작하면 이 모양은 거짓말이 되고, 그때는 클라이언트를 common/client로 올린 뒤 번역을 도메인마다의 얇은 어댑터로 내린다. 기준은 ErrorCode 계약 표준 원칙 4·5에 있다.
// order/infra/PaymentClient.java — 주문만 쓰는 동안의 모양이다
@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) { // 연결 실패 · 응답 지연
// 위 표가 둘을 503과 504로 갈랐으므로 여기서도 갈라야 한다.
// 한 catch 로 묶으면 ORDER_PAYMENT_UNAVAILABLE 을 아무도 안 던진다.
ErrorCode code = e.getCause() instanceof ConnectException
? ErrorCode.ORDER_PAYMENT_UNAVAILABLE // 연결 자체가 안 됨
: ErrorCode.ORDER_PAYMENT_TIMEOUT; // 응답이 안 옴
throw ErrorCodeException.of(code, e);
} catch (RestClientResponseException e) { // 상대가 4xx/5xx 응답
throw ErrorCodeException.of(ErrorCode.ORDER_PAYMENT_API_ERROR, e);
}
}
}
원칙 4. 로그 레벨은 예외 클래스가 아니라 상태 코드로 정한다
여기서 앞선 표준을 하나 고쳐야 한다. 예외 처리 표준에서는 “ErrorCodeException은 warn“이라고 정했는데, 이제 ErrorCodeException이 외부 연동 실패(502/503/504)도 나르게 됐다.
결제사 장애를 warn으로 남기면 아무도 안 본다. 재고 부족과 결제사 다운은 대응이 완전히 다른 사건이다.
예외 클래스를 늘려서 구분할 수도 있지만, 이미 ErrorCode에 상태가 들어 있으므로 그걸로 판단하면 된다.
@ExceptionHandler(ErrorCodeException.class)
public ResponseEntity<HttpApiResponse<Void>> handleErrorCode(ErrorCodeException 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이 찍히고, 모니터링에서 서버 장애로 잡힌다.
위 코드의
INVALID_REQUEST·METHOD_NOT_ALLOWED·UNSUPPORTED_MEDIA_TYPE은 아래 원칙 9의 enum에 그 이름으로 없다. 원칙 9가 「식별 가능한 업무 도메인까지 세분화한다」로 가면서ORDER_INVALID_REQUEST처럼 도메인이 앞에 붙었기 때문이다. 이 절의 코드는 그 결정 전의 모양이고, 도메인을 어떻게 알아내는지는 ErrorCode 계약 표준이 URI의 리소스 세그먼트로 정했다.NO_HANDLER만 도메인을 식별할 수 없어 이름이 그대로다.
원칙 6. Checked 예외를 만들지 않는다
우리 코드에서 extends Exception을 만들지 않는다. 이유가 두 가지인데, 두 번째가 결정적이다.
- 호출부마다
throws나try-catch가 번져서 계층 경계가 지저분해진다 @Transactional은 기본적으로 unchecked 예외만 롤백한다
두 번째가 위험한 이유는 조용히 실패하기 때문이다.
// ❌ checked 예외를 던지면 트랜잭션이 커밋된다
@Transactional
public void createOrder(CreateOrderCommand command) throws PaymentFailedException {
orderRepository.save(order); // ← 이게 저장된 채로
paymentClient.pay(command); // ← 여기서 checked 예외가 나면 롤백되지 않는다
}
주문은 저장됐는데 결제는 안 된 상태가 남는다. 에러도 안 나고 로그도 정상이라, 데이터가 어긋난 걸 한참 뒤에 발견한다. assert 표준에서 경계한 “조용히 넘어가는 코드”의 가장 비싼 형태다.
ErrorCodeException이 RuntimeException을 상속하는 것도 이 때문이다.
원칙 7. Error 계열은 잡지 않는다
OutOfMemoryError, StackOverflowError는 잡아도 복구할 수 없다. 오히려 잡으면 망가진 상태로 계속 돌면서 더 이상한 증상을 만든다. 그대로 죽고 재시작되는 편이 낫다.
유일한 예외가 assert 표준에서 정한 AssertionError다. 이건 복구하려고 잡는 것이 아니라 응답 형식을 지키려고 잡는다. 잡아서 500을 내보내고 로그를 남길 뿐, 처리를 계속하지는 않는다.
원칙 8. 벌크 경로는 예외를 번역하지 않는다
여기까지의 원칙은 전부 웹 요청을 전제로 한다. 무슨 일이 있어도 예외가 Advice까지 올라온다는 전제다. 그런데 Repository 설계 표준에서 정한 벌크 경로에는 그 끝이 없다.
DispatcherServlet을 타지 않으니 @RestControllerAdvice가 없다. Repository 구현체도 없어서 원칙 2(“영속성 예외는 Repository 구현체에서 번역한다”)를 적용할 자리조차 없다. XxxBulkMapper를 부르는 건 ItemWriter다.
그래서 처음엔 ItemWriter가 번역하면 되겠다고 생각했는데, 두 군데서 막혔다.
하나, ErrorCode는 HTTP 상태를 앞자리로 들고 있다. 코드 자체가 상태 + 도메인 + 일련번호로 조립되므로 상태 없이는 코드를 만들 수조차 없다. 벌크 실패에 409나 404를 붙이면 아무 뜻도 없는 값을 억지로 채우게 된다.
둘, 이게 결정적이다 — Spring Batch는 예외 타입으로 분기한다.
.faultTolerant()
.skip(DataIntegrityViolationException.class).skipLimit(100) // 이 건만 건너뛴다
.retry(DeadlockLoserDataAccessException.class).retryLimit(3) // 이건 재시도
예외 처리 표준은 “구체 예외 클래스를 만들지 않고 ErrorCodeException 하나로 두고 구분은 ErrorCode로 한다”고 정했다. 웹에서는 맞다 — Advice가 어차피 한 곳에서 다 받으니 타입으로 구분할 이유가 없다. 그런데 배치에서 모든 실패를 ErrorCodeException으로 뭉치면 ErrorCode로는 구분되는데 프레임워크는 구분할 수 없다. skip/retry 정책을 세울 방법이 사라진다.
원칙 6에서 checked 예외를 금지한 이유와 정확히 같은 결이다 — 프레임워크가 타입으로 동작하는 지점에서는 타입을 뭉개면 안 된다.
그래서 벌크는 번역하지 않는다.
// order/bulk — try-catch가 없다. 원본 타입을 그대로 올린다
class OrderBulkStatusWriter implements ItemWriter<OrderBulkMapperParam> {
private final OrderBulkMapper bulkMapper;
@Override
public void write(Chunk<? extends OrderBulkMapperParam> chunk) {
bulkMapper.bulkUpdateStatus(chunk.getItems());
}
}
로그는 여전히 한 곳에서만 남긴다. 그 한 곳이 Advice가 아니라 리스너다.
@Slf4j
class BatchFailureLogger implements StepExecutionListener {
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
stepExecution.getFailureExceptions()
.forEach(e -> log.error("step={} 실패", stepExecution.getStepName(), e));
return stepExecution.getExitStatus();
}
}
원칙은 “로그는 경계에서 한 번”이고, 그 경계가 웹은 Advice, 벌크는 리스너다. 예외 처리 표준의 “@RestControllerAdvice 한 곳에서만”은 웹 경로에 한정된 표현이었고, 그 글도 함께 고쳤다.
대가. “실패 정보는 ErrorCode 한 곳에 모은다”는 원칙에서 벌크가 빠진다. 벌크 실패는 코드 번호로 검색할 수 없고, 예외 타입과 Spring Batch의 BATCH_STEP_EXECUTION.EXIT_MESSAGE로 추적해야 한다. 대신 프레임워크의 skip·retry·재시작을 그대로 쓸 수 있다. 배치에서는 후자가 더 중요하다고 판단했다 — 5만 건 중 3건이 제약 위반일 때 나머지를 살리는 게 코드 번호로 검색하는 것보다 값지다.
원칙 9. ErrorCode는 식별 가능한 업무 도메인까지 세분화한다
지금까지 나온 실패를 9자리 코드 체계에 넣으면 이렇게 된다.
@Getter
public enum ErrorCode {
// /orders 요청에서 도메인을 식별할 수 있는 프레임워크 실패
ORDER_INVALID_REQUEST(400, DomainCode.ORDER, 1, "주문 요청 값이 유효하지 않습니다."),
ORDER_MALFORMED_REQUEST_BODY(400, DomainCode.ORDER, 2, "주문 요청 본문을 해석할 수 없습니다."),
ORDER_INVALID_PARAMETER(400, DomainCode.ORDER, 3, "주문 요청 파라미터가 올바르지 않습니다."),
ORDER_UNAUTHENTICATED(401, DomainCode.ORDER, 1, "주문 요청에 인증이 필요합니다."),
ORDER_ACCESS_DENIED(403, DomainCode.ORDER, 1, "주문 접근 권한이 없습니다."),
// 어떤 도메인의 경로인지조차 식별할 수 없음
NO_HANDLER(404, DomainCode.COMMON, 1, "요청한 경로를 찾을 수 없습니다."),
ORDER_NOT_FOUND(404, DomainCode.ORDER, 1, "존재하지 않는 주문입니다."),
PRODUCT_NOT_FOUND(404, DomainCode.PRODUCT, 1, "등록되지 않은 상품입니다."),
ORDER_METHOD_NOT_ALLOWED(405, DomainCode.ORDER, 1, "지원하지 않는 주문 요청 방식입니다."),
ORDER_UNSUPPORTED_MEDIA_TYPE(415, DomainCode.ORDER, 1, "지원하지 않는 주문 요청 형식입니다."),
STOCK_SHORTAGE(409, DomainCode.ORDER, 1, "재고가 부족합니다."),
ORDER_ALREADY_CONFIRMED(409, DomainCode.ORDER, 2, "이미 확정된 주문입니다."),
DUPLICATE_EMAIL(409, DomainCode.USER, 1, "이미 가입된 이메일입니다."),
USER_CONCURRENT_UPDATE(409, DomainCode.USER, 2, "다른 사용자가 먼저 회원 정보를 수정했습니다."),
// 처리 대상 도메인을 끝내 식별할 수 없는 최후의 실패
INTERNAL_ERROR(500, DomainCode.COMMON, 1, "서버 내부 오류가 발생했습니다."),
ORDER_PAYMENT_API_ERROR(502, DomainCode.ORDER, 1, "결제 서비스 응답이 올바르지 않습니다."),
ORDER_PAYMENT_UNAVAILABLE(503, DomainCode.ORDER, 1, "결제 서비스를 일시적으로 사용할 수 없습니다."),
ORDER_QUERY_TIMEOUT(503, DomainCode.ORDER, 2, "주문 조회가 지연되고 있습니다."),
ORDER_PAYMENT_TIMEOUT(504, DomainCode.ORDER, 1, "결제 서비스 응답이 지연되고 있습니다.");
}
첫 인자가 상태이므로 enum을 상태 코드 순으로 정렬하면 자연스럽게 종류별로 묶인다.
정해야 했던 건 이거였다 — 프레임워크와 기술이 만든 실패도 처리 대상 도메인을 코드에 남길 것인가.
안 A — 발생 기술로 가른다. Spring MVC·Security·JPA가 만든 실패는 COMMON, Domain이 직접 만든 실패만 도메인 코드로 둔다. 얻는 것은 중앙 Advice와 Security 핸들러가 하나의 공통 코드만 반환하면 된다는 단순함이다. 버린 이유는 /orders의 잘못된 JSON과 /users의 잘못된 JSON이 같은 코드가 되고, 주문 저장 충돌과 회원 저장 충돌도 CONCURRENT_UPDATE 하나로 뭉쳐 코드만 보고 실패한 업무 영역을 좁힐 수 없기 때문이다.
안 B — 클라이언트의 복구 행동으로 가른다. 다시 조회처럼 같은 행동으로 복구하면 COMMON, 재결제처럼 행동이 달라질 때만 도메인 코드로 둔다. 얻는 것은 클라이언트 분기 수가 줄어드는 것이다. 버린 이유는 두 번째 사용처가 생기는 순간 기존 도메인 코드를 COMMON으로 옮겨야 하고, 그러면 바뀌지 않아야 할 공개 숫자 코드가 사용처 수에 따라 흔들리기 때문이다.
안 C — 식별 가능한 업무 도메인으로 가른다. 예외를 만든 기술이나 복구 행동과 상관없이 요청 경로·처리 대상·실행 중인 유스케이스에서 도메인을 식별할 수 있으면 그 도메인 코드를 쓴다. /orders의 JSON 파싱 실패는 ORDER_MALFORMED_REQUEST_BODY, 주문 저장 충돌은 ORDER_CONCURRENT_UPDATE, 주문 결제 타임아웃은 ORDER_PAYMENT_TIMEOUT이다. COMMON(0)은 잘못된 미등록 경로처럼 도메인을 전혀 식별할 수 없거나, 처리 대상을 끝내 특정하지 못한 최후의 실패에만 쓴다.
골랐다 — 안 C. 안 A와 안 B가 지워버리는 업무 맥락을 9자리 코드의 도메인 칸에 남기고, COMMON은 유지하되 예외적인 대역으로 좁힌다. 대신 같은 기술 예외라도 도메인마다 코드가 늘어나고, 중앙 Advice·Security 핸들러·공용 외부 연동 클라이언트가 현재 도메인을 어떻게 알아낼지 별도 설계가 필요하다. 이미 공개한 COMMON 코드를 도메인 코드로 바꾸는 마이그레이션도 코드 수명주기 규칙이 정해지기 전에는 자동으로 처리하지 않는다.
이 문단이 미룬 것은 ErrorCode 계약 표준이 갚았다. 도메인 판별은 URI의 리소스 세그먼트로, 공용 클라이언트는 도메인마다의 얇은 어댑터로, 수명주기는
@Deprecated로 남기고 번호를 재사용하지 않는 것으로 정했다. 그 글을 쓰면서 여기 적힌ORDER_METHOD_NOT_ALLOWED(405, ...)가 판별 방식을 하나 지웠다 — 405는 핸들러가 정해지기 전에 터져서HandlerMethod로는 도메인을 알 수 없다.
판단 기준 정리
| 실패 종류 | 어디서 잡는가 | 상태 | 로그 |
|---|---|---|---|
요청 형식 (@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 |
| DB 쿼리 타임아웃 | Repository 구현체에서 번역 | 503 | error |
| 외부 API 실패 | 연동 클라이언트에서 번역 | 502·503·504 | error |
| 우리 코드 버그 | @RestControllerAdvice |
500 | error + 스택 |
| 도달 불가 분기 | AssertionError 핸들러 |
500 | error + 스택 |
OutOfMemoryError 등 |
잡지 않는다 | — | — |
| 벌크 경로의 모든 실패 | 번역하지 않는다 — 원본 타입을 올린다 | 없음 (HTTP가 아니다) | StepExecutionListener에서 error + 스택 |
이 표준을 정하기까지
출발점은 “우리가 던지는 예외만 다뤘다”는 자각이었다. 앞선 표준은 ErrorCodeException 하나를 잘 설계하는 데 집중했는데, 실제 서비스에서 나오는 예외는 대부분 우리가 던지지 않은 것들이다. 그것들을 늘어놓고 보니 처리 지점이 제각각이었다.
가장 놀란 건 인증 실패였다. Security 필터가 DispatcherServlet보다 앞에서 돈다는 건 알고 있었지만, 그게 @RestControllerAdvice를 우회한다는 것까지는 연결하지 못했다. 공들여 만든 HttpApiResponse 형식이 401에서만 깨지고 있었던 셈이다. AuthenticationEntryPoint와 AccessDeniedHandler를 등록해 같은 형식으로 맞췄다.
DB 제약 위반은 500으로 나가고 있었다. 중복 가입 시도는 명백히 사용자 상황인데 “서버 내부 오류”로 응답하고 있었던 것이다. 번역 위치는 고민할 것도 없이 Repository 구현체였다 — Entity가 패키지 밖으로 안 나가듯 DataIntegrityViolationException도 Service로 새면 안 되기 때문이다. 다만 제약이 여러 개일 때 구분이 안 된다는 한계는 인정하고, 사전 조회로 구체적인 실패를 알려주고 제약 위반은 동시성 최후 방어선으로 쓰기로 했다.
외부 연동에서 로그 규칙이 어긋났다. 결제사 장애를 ErrorCodeException으로 던지면 앞선 표준상 warn이 되는데, 그러면 아무도 안 본다. 예외 클래스를 하나 더 만들까 하다가, ErrorCode에 이미 상태가 들어 있으니 그걸로 판단하면 된다는 걸 깨달았다. 4xx는 warn, 5xx는 error로 바꾸니 예외 클래스는 그대로 하나로 유지되고 로그도 맞았다.
Checked 예외를 왜 만들지 않는지도 여기서 분명해졌다. 막연히 “불편해서”라고 생각했는데, 진짜 이유는 @Transactional이 unchecked만 롤백한다는 것이었다. checked 예외를 던지면 주문은 저장되고 결제는 실패한 상태가 조용히 커밋된다. 에러도 안 나고 로그도 정상이라 한참 뒤에 발견된다. assert 표준에서 경계한 “조용히 넘어가는 코드”의 가장 비싼 형태다.
마지막으로 잡지 말아야 할 것을 정했다. Error 계열은 복구가 불가능하므로 잡지 않는다. AssertionError만 예외인데, 그것도 복구하려는 게 아니라 응답 형식을 지키려고 잡는 것이라 성격이 다르다.
벌크 경로는 Repository 설계 표준을 정한 뒤에 덧붙였다. 이 글의 원칙이 전부 “Advice까지 올라온다”는 전제 위에 있었는데, 벌크에는 그 끝이 없다는 걸 뒤늦게 알아차렸다. 처음엔 ItemWriter에서 ErrorCodeException으로 번역하려 했다. 그런데 Spring Batch의 skip/retry가 예외 타입으로 분기한다는 게 걸렸다 — 전부 ErrorCodeException으로 뭉치면 ErrorCode로는 구분되는데 프레임워크는 구분할 수 없다. 원칙 6에서 checked 예외를 금지한 이유와 같은 종류의 제약이었다. 그래서 배치는 번역하지 않고 원본 타입을 올리기로 했다. 대신 “로그는 한 곳에서”라는 원칙은 살렸고, 그 한 곳이 경로마다 다르다는 것으로 정리됐다 — 웹은 Advice, 배치는 StepExecutionListener.
DB 쿼리 타임아웃은 한참 뒤에 스키마 마이그레이션 표준이 슬로우 쿼리 기준을 정하면서 붙었다. 그 표준이 “300ms를 넘기면 끊는다”까지는 정했는데, 끊긴 뒤 무엇으로 응답하는지는 이 표준의 몫으로 남아 있었다. 외부 연동의 502·503·504 삼분법을 그대로 가져오려다 멈췄다 — DB 타임아웃에는 “상대”가 없다. 원인이 하나뿐인데 상태를 셋으로 나누면 안 쓰는 자리가 생긴다. 그래서 503 하나로 좁혔다.
마지막에는 COMMON을 기본값으로 두었던 결정을 뒤집었다. 처음에는 프레임워크·영속성·외부 연동 실패를 전부 COMMON에 넣었다. 하지만 코드가 실패 종류만 말하고 어느 업무에서 실패했는지는 지우고 있었다. 발생 기술과 클라이언트 행동을 기준으로 삼는 안도 검토했지만, 구현 교체나 사용처 증가에 따라 공개 코드가 흔들렸다. 그래서 식별 가능한 업무 도메인을 가장 먼저 코드에 남기고, COMMON은 도메인을 끝내 식별할 수 없을 때만 쓰는 예외 대역으로 좁혔다.
정리
@RestControllerAdvice는 만능이 아니다. 앞뒤로 새는 곳이 있고, 구멍마다 처리 지점을 따로 둔다- 인증·인가 실패는
AuthenticationEntryPoint와AccessDeniedHandler로 처리한다. Security 필터는 Advice보다 앞에서 돈다 - 영속성 예외는 Repository 구현체에서 번역한다.
DataIntegrityViolationException이 Service로 새지 않게 한다 - 제약 위반은 최후 방어선이다. 평소에는 사전 조회로 구체적인
ErrorCode를 준다 - DB 쿼리 타임아웃은 503 하나로 번역한다. 외부 연동과 달리 원인이 하나뿐이라 나눌 축이 없다
- 외부 연동 실패는 502·503·504로 구분한다. 상태 코드만으로 상대 장애임을 알 수 있어야 한다
- 로그 레벨은 예외 클래스가 아니라 상태 코드로 정한다. 4xx는
warn, 5xx는error+ 스택트레이스 - Spring이 던지는 요청 오류도 등록한다. 안 하면 잘못된 URL 하나가 500으로 찍힌다
ErrorCode는 식별 가능한 업무 도메인까지 세분화한다. 프레임워크·영속성·외부 연동 실패도 도메인을 알 수 있으면 도메인 코드를 쓰고,COMMON(0)은 도메인을 식별할 수 없을 때만 쓴다- checked 예외를 만들지 않는다.
@Transactional이 롤백하지 않아 데이터가 조용히 어긋난다 Error계열은 잡지 않는다.AssertionError만 형식 유지를 위해 예외로 둔다- 벌크 경로는 예외를 번역하지 않는다. 원본 타입을 올려 skip/retry가 동작하게 하고, 로그는
StepExecutionListener한 곳에서 남긴다 Domain을 거치는 배치 경로는 반대다. Service가 던진ErrorCodeException이 그대로 올라오므로ErrorCode가 살아 있고, 로그 레벨 규칙도 그대로 적용된다
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`해 `ErrorCodeException`으로 번역한다.
- 영속성 예외를 번역할 때는 원본 예외를 `cause`로 보존한다.
- `OptimisticLockingFailureException`이 실제로 발생하려면 `@Version`이 Entity에 있고 저장이 영속 Entity를 수정하는 방식이어야 한다. `merge`로 저장하면 이 예외를 신뢰할 수 없다. domain-persistence-standard를 따른다.
- 영속성 예외 타입이 Service 계층으로 전파되게 두지 않는다.
- 제약이 여러 개라 원인을 구분할 수 없으면, 저장 전에 조회해서 구체적인 `ErrorCode`로 막고 제약 위반은 동시 요청 대비 최후 방어선으로만 쓴다.
- 삭제 경로에서 나는 `DataIntegrityViolationException`은 409로 번역하지 않는다. `delete`가 있다는 것은 이미 참조가 없다고 판단했다는 뜻이므로 사용자 잘못이 아니라 설계 위반이다. 500으로 올리고 로그로 잡는다. aggregate-deletion-standard를 따른다.
- 예외 메시지를 파싱해 어떤 제약인지 알아내지 않는다. DB 벤더에 종속된다.
## 외부 연동
- 외부 API 호출 실패는 연동 클라이언트 클래스에서 `ErrorCodeException`으로 번역한다. `RestClientException` 같은 타입이 Service로 전파되게 두지 않는다.
- 외부 연동 예외를 번역할 때는 원본 예외를 `cause`로 보존한다.
- 상태를 구분한다: 상대의 오류 응답은 502, 연결 불가는 503, 타임아웃은 504.
## DB 쿼리 타임아웃
- DB 쿼리 타임아웃(`QueryTimeoutException` 등)은 영속성 예외와 같은 자리, Repository 구현체에서 `ErrorCodeException`으로 번역한다. 원본은 `cause`로 보존한다.
- 상태는 503 하나로 통일한다. 외부 연동과 달리 원인이 "이쪽 인프라가 정한 시간 안에 못 끝냈다" 하나뿐이라 502·504로 나눌 축이 없다.
- `ErrorCode`는 도메인별로 발급한다. `ORDER_QUERY_TIMEOUT`처럼 처리 대상 도메인을 남긴다.
## Spring 요청 오류
- 다음 예외에 대한 핸들러를 `@RestControllerAdvice`에 등록한다. 등록하지 않으면 클라이언트 잘못이 500으로 기록된다.
- `MethodArgumentTypeMismatchException`, `MissingServletRequestParameterException` → 400 (`INVALID_PARAMETER`)
- `BindException`(`@ModelAttribute` 검증 실패) → 400 + `errors` (`INVALID_REQUEST`)
- `HttpRequestMethodNotSupportedException` → 405
- `HttpMediaTypeNotSupportedException` → 415
- `NoResourceFoundException` → 404
## ErrorCode 도메인 대역
- 예외를 만든 기술과 상관없이 요청 경로, 처리 대상, 실행 중인 유스케이스에서 업무 도메인을 식별할 수 있으면 해당 `DomainCode`를 쓴다.
- 프레임워크 예외도 도메인을 식별할 수 있으면 `ORDER_MALFORMED_REQUEST_BODY`, `ORDER_METHOD_NOT_ALLOWED`처럼 도메인별 `ErrorCode`로 세분화한다.
- 영속성 충돌은 공통 `CONCURRENT_UPDATE`로 뭉치지 않고 `ORDER_CONCURRENT_UPDATE`, `USER_CONCURRENT_UPDATE`처럼 처리 대상 도메인을 남긴다.
- 외부 연동 실패도 호출 중인 업무를 식별할 수 있으면 `ORDER_PAYMENT_TIMEOUT`처럼 도메인과 연동 목적을 남긴다.
- `DomainCode.COMMON(0)`은 도메인을 전혀 식별할 수 없는 요청이나 처리 대상을 특정할 수 없는 최후의 실패에만 쓴다.
- 이미 공개한 COMMON 코드를 임의로 도메인 코드로 바꾸지 않는다. 기존 코드의 마이그레이션은 ErrorCode 수명주기 표준이 정해진 뒤 처리한다.
## 벌크 경로
- 벌크(`{도메인}/bulk`)에는 `@RestControllerAdvice`도 Repository 구현체도 없다. 위 원칙을 그대로 적용할 자리가 없다.
- 벌크는 예외를 번역하지 않는다. `ItemReader`/`ItemWriter`에서 `try-catch`로 `ErrorCodeException`으로 바꾸지 않고 원본 타입을 그대로 올린다.
- 이유는 Spring Batch의 skip/retry 정책이 예외 타입으로 분기하기 때문이다. 전부 `ErrorCodeException`으로 뭉치면 `.skip(X.class)` / `.retry(X.class)`를 세울 수 없다.
- `ErrorCode`를 벌크 실패에 붙이지 않는다. `ErrorCode`는 HTTP 상태를 앞자리로 들고 있어 벌크에서는 의미 없는 값이 된다.
- 벌크 실패 로그는 `StepExecutionListener`(또는 `ItemWriteListener`) 한 곳에서 남긴다. Writer나 Mapper에서 로그를 남기지 않는다.
- 즉 "로그는 경계에서 한 번"이라는 원칙은 유지하고, 그 경계가 웹은 Advice, 벌크는 리스너다.
## 배치 경로
- 배치(`{도메인}/batch`)는 `XxxUseCase`를 호출하므로 Service가 던진 `ErrorCodeException`이 그대로 올라온다. 다시 번역하지 않는다.
- 벌크와 달리 `ErrorCode`가 붙어 있으므로 로그 레벨 규칙(4xx는 `warn`, 5xx는 `error`)을 그대로 적용한다.
- 건너뛴 항목은 `SkipListener`에서 남기고 Writer나 Processor에서 로그를 남기지 않는다. batch-execution-standard를 따른다.
## 로그 레벨
- 예외 클래스가 아니라 `ErrorCode`의 상태 코드로 정한다.
- 4xx는 `warn`으로 스택트레이스 없이, 5xx는 `error`로 스택트레이스와 함께 남긴다.
- 벌크는 `ErrorCode`가 없으므로 이 규칙을 적용하지 않는다. Step 실패는 `error`로 스택트레이스와 함께 남긴다.
## 예외 계층
- `extends Exception`인 checked 예외를 만들지 않는다. `@Transactional`이 기본적으로 unchecked 예외만 롤백하므로, checked 예외를 던지면 트랜잭션이 커밋되어 데이터가 어긋난다.
- `OutOfMemoryError` 등 `Error` 계열은 잡지 않는다. 복구할 수 없다.
- `AssertionError`만 예외로 잡되, 복구가 아니라 응답 형식 유지가 목적이다.
GitHub Copilot — .github/instructions/exception-taxonomy-standard.instructions.md
---
description: 예외 종류별 처리 위치 규칙
applyTo: "**/infra/**/*.java, **/common/**/*.java, **/bulk/**/*.java, **/batch/**/*.java"
---
- Security 필터의 인증·인가 실패는 `@RestControllerAdvice`가 잡지 못하므로 `AuthenticationEntryPoint`와 `AccessDeniedHandler`를 등록한다.
- 그 핸들러 안에서 `HttpApiResponse`를 직접 직렬화해 응답 형식을 통일한다.
- `DataIntegrityViolationException`과 `OptimisticLockingFailureException`은 Repository 구현체에서 `ErrorCodeException`으로 번역한다.
- 영속성 예외를 번역할 때는 원본 예외를 `cause`로 보존한다.
- `OptimisticLockingFailureException`이 발생하려면 `@Version`이 Entity에 있고 저장이 영속 Entity를 수정하는 방식이어야 한다.
- 영속성 예외 타입을 Service 계층으로 전파시키지 않는다.
- DB 예외 메시지를 파싱해 제약을 구분하지 않는다. 대신 저장 전 조회로 구체적인 실패를 알려준다.
- 삭제 경로의 `DataIntegrityViolationException`은 409로 번역하지 않고 500으로 올린다. 사용자 잘못이 아니라 설계 위반이다.
- 외부 API 실패는 연동 클라이언트에서 `ErrorCodeException`으로 번역하고 502(오류 응답)·503(연결 불가)·504(타임아웃)로 구분한다.
- 외부 연동 예외를 번역할 때는 원본 예외를 `cause`로 보존한다.
- DB 쿼리 타임아웃(`QueryTimeoutException` 등)은 Repository 구현체에서 `ErrorCodeException`으로 번역하고 503 하나로 통일한다. `ErrorCode`는 도메인별로 발급한다.
- `MethodArgumentTypeMismatchException`, `MissingServletRequestParameterException`(400 `INVALID_PARAMETER`), `BindException`(400 `INVALID_REQUEST` + `errors`), `HttpRequestMethodNotSupportedException`(405), `HttpMediaTypeNotSupportedException`(415), `NoResourceFoundException`(404) 핸들러를 등록한다.
- 예외를 만든 기술과 상관없이 요청 경로, 처리 대상, 실행 중인 유스케이스에서 업무 도메인을 식별할 수 있으면 해당 `DomainCode`를 쓴다.
- 프레임워크·영속성·외부 연동 실패도 도메인을 알 수 있으면 도메인별 `ErrorCode`로 세분화한다. 공통 `CONCURRENT_UPDATE` 대신 `ORDER_CONCURRENT_UPDATE`처럼 이름과 도메인 칸에 업무 맥락을 남긴다.
- `DomainCode.COMMON(0)`은 도메인을 전혀 식별할 수 없거나 처리 대상을 특정할 수 없는 최후의 실패에만 쓴다.
- 이미 공개한 COMMON 코드를 임의로 도메인 코드로 바꾸지 않는다.
- 로그 레벨은 `ErrorCode`의 상태 코드로 정한다. 4xx는 `warn`, 5xx는 `error`에 스택트레이스를 남긴다.
- checked 예외를 만들지 않는다. `@Transactional`이 unchecked 예외만 롤백하기 때문이다.
- `Error` 계열은 잡지 않는다. `AssertionError`만 응답 형식 유지를 위해 잡는다.
- 벌크(`{도메인}/bulk`)에서는 예외를 번역하지 않고 원본 타입을 그대로 올린다. Spring Batch의 skip/retry가 예외 타입으로 분기하기 때문이다.
- 벌크 실패에 `ErrorCode`를 붙이지 않는다. `ErrorCode`는 HTTP 상태를 앞자리로 들고 있어 벌크에서 의미가 없다.
- 벌크 실패 로그는 `StepExecutionListener` 한 곳에서 `error` + 스택트레이스로 남기고, Writer나 Mapper에서는 로그를 남기지 않는다.
- 배치(`{도메인}/batch`)는 Service가 던진 `ErrorCodeException`을 그대로 올리고, `ErrorCode`가 있으므로 로그 레벨 규칙을 그대로 적용한다. 건너뛴 항목은 `SkipListener`에서 남긴다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기