핵심 요약
- 커스텀 예외(Custom Exception)는 “비정상 요청을 쳐낸다” 수준을 넘어, API 계약(HTTP 상태/에러 코드), 도메인 정책, 운영 관측성을 하나로 묶는 설계 도구입니다.
- Spring이 자동으로 던지는 예외(
MethodArgumentNotValidException,HttpMessageNotReadableException,ConstraintViolationException등)는 최전방 방어선이고, 커스텀 예외는 “형식은 맞지만 정책상 불가” 같은 비즈니스 규칙 위반을 표현합니다. - 실무에서는 예외를 무한정 늘리기보다, 공통 베이스 예외 + 에러 코드(ErrorCode) + 전역 예외 처리(@RestControllerAdvice) 조합으로 “일관된 에러 응답”과 “로그/모니터링 친화성”을 확보합니다.
- 추천 카테고리: Invalid(입력/상태), Resource(404/409), Auth(401/403), Business(422/409), External(502/503/504). 각 카테고리는 “누가 고칠 수 있는가(클라이언트/서버/외부)” 관점으로 나누면 깔끔합니다.
목차
- 1. 왜 Spring 서버에서 “커스텀 예외”가 실무 필수인가?
- 2. Spring이 자동으로 던지는 예외: 최전방 방어선의 역할
- 3. 커스텀 예외 설계 원칙: 누가 고칠 수 있는가로 분류하라
- 4. 에러 응답 스펙 표준화: errorCode + message + traceId
- 5. 추천 아키텍처: ErrorCode enum + AppException 베이스
- 6. 실무에서 사용하기 좋은 커스텀 예외 카탈로그
- 7. 전역 예외 처리(@RestControllerAdvice): 예외를 “한 곳”에서 끝내기
- 8. Validation 예외를 사용자 친화적으로 변환하는 법
- 9. 인증/인가 예외(401/403)와 Spring Security 연계 전략
- 10. 운영 관점: 로그 레벨, 알람, 재시도, 외부 연동 예외
- 11. 요약: 과하지 않게, 그러나 강력하게
Spring 서버 개발에서 예외(Exception)는 단순히 “에러를 던진다”가 아닙니다. 예외는 곧 API 사용법을 강제하는 규칙이고, 클라이언트에게 돌려줄 실패 계약이며, 운영자가 장애를 진단하는 단서입니다. 그래서 실무에서는 예외를 “코드 스타일”로만 보지 않고, HTTP 상태 코드/에러 코드/로깅/모니터링까지 포함해 일관된 시스템으로 관리합니다.
1. 왜 Spring 서버에서 “커스텀 예외”가 실무 필수인가?
입문 단계에서는 컨트롤러에서 try-catch를 쓰거나, IllegalArgumentException만 던져도 동작은 합니다. 하지만 서비스가 커질수록 다음 문제가 빠르게 터집니다.
- 클라이언트 입장: 실패 원인이 “입력 오류(400)”인지 “권한 없음(403)”인지 “서버 장애(500)”인지 구분이 안 된다.
- 개발자 입장: 로그에는 예외만 찍히는데 “무슨 상황”인지 추적이 어렵다(재현 비용 증가).
- 운영/모니터링: 모든 에러가 500으로 보이면 알람이 난무하거나, 반대로 진짜 장애가 묻힌다.
커스텀 예외는 이 문제를 정면으로 해결합니다. “이 실패는 사용자가 고쳐야 하는 실패인지”, “우리 정책 위반인지”, “외부 시스템 문제인지”를 타입과 코드로 명확히 표현하면, API와 운영이 동시에 안정됩니다.
2. Spring이 자동으로 던지는 예외: 최전방 방어선의 역할
Spring MVC는 컨트롤러에 도달하기 전후로 다양한 검증을 수행하며, 형식/파싱/검증 단계에서 표준 예외를 던집니다. 실무에서 자주 보는 대표 예외는 다음과 같습니다.
- HttpMessageNotReadableException: JSON이 깨졌거나 타입이 맞지 않음(예: 숫자 자리에 문자열)
- MethodArgumentNotValidException:
@Valid/@Validated - ConstraintViolationException:
@RequestParam,@PathVariable - IllegalArgumentException: 자바 기본 예외(서버 내부 파라미터가 명백히 잘못된 경우)
중요한 포인트는 이것입니다. 위 예외들은 “요청 형식이 잘못되었다” 같은 기계적 오류를 빠르게 걸러내는 데 탁월하지만, “우리 서비스 정책상 이 요청은 불가” 같은 도메인 규칙 위반까지 의미 있게 표현해주지는 못합니다. 그래서 커스텀 예외가 필요합니다.
DTO 검증(최전방) 예시
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record CreateOrderRequest(
@NotBlank String productCode,
@Min(1) int quantity
) { }
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public OrderResponse create(@RequestBody @Valid CreateOrderRequest request) {
return orderService.createOrder(request);
}
}
이 단계에서 걸러낼 수 있는 건 최대한 걸러내는 것이 좋습니다. 하지만 “재고 없음”, “이미 배송됨”, “쿠폰 정책 위반” 같은 건 DTO 검증으로는 표현이 부족합니다. 이 영역이 커스텀 예외의 무대입니다.
3. 커스텀 예외 설계 원칙: 누가 고칠 수 있는가로 분류하라
실무에서 예외를 아름답게 분류하는 가장 강력한 질문은 이것입니다.
- 이 문제는 누가 고칠 수 있는가?
이 질문으로 분류하면, HTTP 상태 코드와 모니터링 전략이 자연스럽게 따라옵니다.
- 클라이언트가 고쳐야 함: 400(Invalid), 401/403(Auth), 404(Not Found), 409(Conflict), 422(Unprocessable)
- 서버가 고쳐야 함: 500(Internal), 코드 버그/설계 결함
- 외부 시스템이 고쳐야 함: 502/503/504(Downstream 장애/지연)
이 분류를 “예외 타입”에 반영하면, 예외 자체가 문서가 됩니다. 예: DuplicateResourceException은 409이고, ResourceNotFoundException은 404라는 규칙이 코드로 고정됩니다.
4. 에러 응답 스펙 표준화: errorCode + message + traceId
커스텀 예외를 실무에서 제대로 쓰려면, 결국 “클라이언트에게 어떤 JSON을 줄 것인가”가 결정되어야 합니다. 추천하는 최소 스펙은 아래입니다.
- errorCode: 기계가 분기하기 좋은 코드(문자열/enum)
- message: 사람이 읽는 메시지(사용자 친화적)
- traceId: 로그/분산추적과 연결되는 상관관계 ID
- details: 검증 에러 필드 목록 등 부가 정보(선택)
표준 에러 응답 모델 예시
import java.time.Instant;
import java.util.Map;
public record ApiErrorResponse(
Instant timestamp,
int status,
String errorCode,
String message,
String path,
String traceId,
Map details
) {
public static ApiErrorResponse of(
int status,
String errorCode,
String message,
String path,
String traceId,
Map details
) {
return new ApiErrorResponse(Instant.now(), status, errorCode, message, path, traceId, details);
}
}
이렇게 “항상 같은 형태”로 내려주면, 프론트/모바일/서드파티는 일관된 방식으로 에러를 처리할 수 있고, 운영팀은 errorCode별로 대시보드를 만들 수 있습니다.
5. 추천 아키텍처: ErrorCode enum + AppException 베이스
커스텀 예외를 무작정 클래스만 늘리면, 예외는 많아지고 규칙은 흩어집니다. 실무에서 가장 견고한 패턴은 다음 조합입니다.
- ErrorCode enum: “HTTP 상태 + 코드 + 기본 메시지”를 중앙에서 관리
- AppException(베이스 예외): 모든 커스텀 예외가 공통적으로 가진 필드(ErrorCode, details)를 보유
- @RestControllerAdvice: AppException을 공통 스펙으로 변환
ErrorCode 설계 예시
import org.springframework.http.HttpStatus;
public enum ErrorCode {
// 400 - Invalid
INVALID_INPUT(HttpStatus.BAD_REQUEST, "INVALID_INPUT", "요청 값이 올바르지 않습니다."),
INVALID_STATUS(HttpStatus.CONFLICT, "INVALID_STATUS", "현재 상태에서는 요청을 처리할 수 없습니다."),
// 401/403 - Auth
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_TOKEN", "인증 토큰이 유효하지 않습니다."),
INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "INVALID_CREDENTIALS", "아이디 또는 비밀번호가 올바르지 않습니다."),
FORBIDDEN(HttpStatus.FORBIDDEN, "FORBIDDEN", "해당 작업을 수행할 권한이 없습니다."),
// 404/409 - Resource
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."),
DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "DUPLICATE_RESOURCE", "이미 존재하는 리소스입니다."),
// 422 - Business rule
BUSINESS_RULE_VIOLATION(HttpStatus.UNPROCESSABLE_ENTITY, "BUSINESS_RULE_VIOLATION", "비즈니스 규칙을 위반했습니다."),
INSUFFICIENT_BALANCE(HttpStatus.UNPROCESSABLE_ENTITY, "INSUFFICIENT_BALANCE", "잔액이 부족합니다."),
// 502/503 - External
EXTERNAL_SERVICE_ERROR(HttpStatus.BAD_GATEWAY, "EXTERNAL_SERVICE_ERROR", "외부 시스템 연동 중 오류가 발생했습니다."),
EXTERNAL_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "EXTERNAL_SERVICE_UNAVAILABLE", "외부 시스템이 일시적으로 불안정합니다."),
// 500 - Internal
INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "서버 오류가 발생했습니다.");
private final HttpStatus httpStatus;
private final String code;
private final String defaultMessage;
ErrorCode(HttpStatus httpStatus, String code, String defaultMessage) {
this.httpStatus = httpStatus;
this.code = code;
this.defaultMessage = defaultMessage;
}
public HttpStatus httpStatus() { return httpStatus; }
public String code() { return code; }
public String defaultMessage() { return defaultMessage; }
}
AppException(베이스 예외) 예시
import java.util.Map;
public class AppException extends RuntimeException {
private final ErrorCode errorCode;
private final Map details;
public AppException(ErrorCode errorCode) {
super(errorCode.defaultMessage());
this.errorCode = errorCode;
this.details = Map.of();
}
public AppException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
this.details = Map.of();
}
public AppException(ErrorCode errorCode, String message, Map details) {
super(message);
this.errorCode = errorCode;
this.details = details == null ? Map.of() : Map.copyOf(details);
}
public ErrorCode getErrorCode() { return errorCode; }
public Map getDetails() { return details; }
}
이 베이스 구조를 두면, 커스텀 예외 클래스는 “의미 있는 이름”만 제공하고, HTTP 매핑/응답 구조는 중앙에서 제어할 수 있습니다.
6. 실무에서 사용하기 좋은 커스텀 예외 카탈로그
이제 실전에서 가장 많이 쓰는 예외들을 카테고리별로 정리해 보겠습니다. 아래 목록은 “Invalid 관련 예외 + 리소스 + 권한 + 비즈니스 + 외부 연동”으로 확장된 형태입니다.
6-1) Invalid 계열: 입력은 들어왔지만 “정책상 불가”
Invalid 계열은 400/409에 주로 매핑됩니다. 입문자가 가장 많이 던지게 되는 예외 그룹입니다.
InvalidInputException
형식 검증(@Valid)은 통과했지만, “논리적으로 말이 안 되는 입력”을 표현합니다. 예: 시작일이 종료일보다 늦음, 수량-옵션 조합이 불가.
public class InvalidInputException extends AppException {
public InvalidInputException(String message) {
super(ErrorCode.INVALID_INPUT, message);
}
}
InvalidStatusException
상태 머신(주문/배송/결제/예약 등)에서 “현재 상태에서는 그 액션이 불가능”할 때 사용합니다. 실무에서는 400보다 409 Conflict가 더 의미 있는 경우가 많습니다(서버 상태와 충돌).
public class InvalidStatusException extends AppException {
public InvalidStatusException(String message) {
super(ErrorCode.INVALID_STATUS, message);
}
}
InvalidTokenException / InvalidCredentialsException
인증 실패는 401로 분리하는 것이 운영/보안 관점에서 매우 중요합니다. “토큰이 만료/조작됨”과 “로그인 정보 불일치”는 서로 다른 상황이므로 예외도 분리해두면 디버깅이 빨라집니다.
public class InvalidTokenException extends AppException {
public InvalidTokenException(String message) {
super(ErrorCode.INVALID_TOKEN, message);
}
}
public class InvalidCredentialsException extends AppException {
public InvalidCredentialsException(String message) {
super(ErrorCode.INVALID_CREDENTIALS, message);
}
}
6-2) Resource 계열: 없다(404), 이미 있다(409)
DB 중심 서비스에서 가장 재사용이 많은 그룹입니다.
ResourceNotFoundException
public class ResourceNotFoundException extends AppException {
public ResourceNotFoundException(String message) {
super(ErrorCode.RESOURCE_NOT_FOUND, message);
}
public static ResourceNotFoundException of(String resourceName, Object id) {
return new ResourceNotFoundException(resourceName + " not found. id=" + id);
}
}
DuplicateResourceException
회원가입 이메일 중복, 닉네임 중복처럼 “이미 존재하는 값으로 생성”을 시도할 때 사용합니다. 409로 매핑하면 클라이언트가 “중복 처리 UI”를 만들기 쉬워집니다.
public class DuplicateResourceException extends AppException {
public DuplicateResourceException(String message) {
super(ErrorCode.DUPLICATE_RESOURCE, message);
}
}
6-3) Authorization 계열: 인증은 됐지만 권한이 없음(403)
“내 글만 삭제 가능” 같은 소유권 검증은 403으로 분리하는 것이 좋습니다. Spring Security를 쓴다면 AccessDeniedException로 위임해도 되지만, 도메인 메시지를 담고 싶다면 커스텀 예외로 감싸는 전략도 가능합니다.
public class UnauthorizedAccessException extends AppException {
public UnauthorizedAccessException(String message) {
super(ErrorCode.FORBIDDEN, message);
}
}
6-4) Business rule 계열: “정책 위반”을 하나의 언어로
비즈니스 규칙은 서비스의 핵심입니다. “단순 invalid”로 뭉개면 추후 정책이 늘어날 때 유지보수성이 급격히 떨어집니다.
BusinessRuleViolationException
public class BusinessRuleViolationException extends AppException {
public BusinessRuleViolationException(String message) {
super(ErrorCode.BUSINESS_RULE_VIOLATION, message);
}
}
InsufficientBalanceException (결제/포인트 도메인)
public class InsufficientBalanceException extends AppException {
public InsufficientBalanceException(long current, long required) {
super(
ErrorCode.INSUFFICIENT_BALANCE,
"잔액이 부족합니다. current=" + current + ", required=" + required,
java.util.Map.of("current", current, "required", required)
);
}
}
422(Unprocessable Entity)를 쓰면 “형식은 맞지만 처리 불가”라는 의미가 살아납니다. 팀/조직 표준에 따라 400/409로 통일하는 곳도 있으니, 중요한 건 “일관성”입니다.
6-5) External 계열: 우리 탓이 아닌 실패를 502/503으로 분리
외부 결제(PG), 알림, 인증, 서드파티 API가 붙으면, 장애의 상당수가 “외부 요인”입니다. 이때 500으로 퉁치면 운영이 망가집니다. 추천 패턴은 “외부 연동 예외”를 별도 타입으로 분리하고, 다운스트림 응답/타임아웃을 details로 남기는 것입니다.
import java.util.Map;
public class ExternalServiceIntegrationException extends AppException {
public ExternalServiceIntegrationException(String serviceName, String message) {
super(ErrorCode.EXTERNAL_SERVICE_ERROR, serviceName + ": " + message);
}
public ExternalServiceIntegrationException(String serviceName, String message, Map details) {
super(ErrorCode.EXTERNAL_SERVICE_ERROR, serviceName + ": " + message, details);
}
public static ExternalServiceIntegrationException timeout(String serviceName, long timeoutMs) {
return new ExternalServiceIntegrationException(
serviceName,
"timeout",
Map.of("timeoutMs", timeoutMs)
);
}
}
실제 서비스 로직에서의 사용 예시(주문 취소)
public class OrderService {
public void cancelOrder(long orderId, OrderStatus status) {
if (orderId <= 0) {
// 서버 내부 파라미터 오류가 아니라 “요청 자체”의 문제라면 InvalidInput이 더 명확합니다.
throw new InvalidInputException("orderId must be positive. orderId=" + orderId);
}
if (status == OrderStatus.SHIPPED) {
throw new InvalidStatusException("이미 배송 시작된 주문은 취소할 수 없습니다.");
}
// 정상 취소 로직...
}
}
7. 전역 예외 처리(@RestControllerAdvice): 예외를 “한 곳”에서 끝내기
실무 표준은 컨트롤러마다 try-catch를 두지 않고, @RestControllerAdvice로 예외를 일괄 처리하는 것입니다. 이렇게 하면 “응답 스펙”을 한 곳에서 강제할 수 있고, 예외 추가/변경도 중앙에서 끝납니다.
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AppException.class)
public ResponseEntity handleAppException(AppException e, HttpServletRequest request) {
ErrorCode ec = e.getErrorCode();
String traceId = TraceIdHolder.get(); // 아래에서 구현 예시 제공
ApiErrorResponse body = ApiErrorResponse.of(
ec.httpStatus().value(),
ec.code(),
e.getMessage(),
request.getRequestURI(),
traceId,
e.getDetails()
);
return ResponseEntity.status(ec.httpStatus()).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity handleUnexpected(Exception e, HttpServletRequest request) {
// 내부 예외 메시지를 그대로 노출하지 않는 것이 안전합니다.
String traceId = TraceIdHolder.get();
ApiErrorResponse body = ApiErrorResponse.of(
500,
ErrorCode.INTERNAL_ERROR.code(),
ErrorCode.INTERNAL_ERROR.defaultMessage(),
request.getRequestURI(),
traceId,
Map.of()
);
return ResponseEntity.status(500).body(body);
}
}
이 구조의 장점은 명확합니다. 서비스/도메인 계층에서 예외만 던지면, HTTP 변환은 Advice가 책임집니다. 즉, 도메인은 HTTP에 오염되지 않고도 “실무 친화적인 응답”을 만들 수 있습니다.
8. Validation 예외를 사용자 친화적으로 변환하는 법
실무에서 빈번한 불만은 이것입니다. “@Valid는 좋은데, 기본 에러 응답이 프론트가 쓰기 어렵다.” 해결책은 전역 핸들러에서 Validation 예외를 별도 처리하여, field별 에러를 details로 내려주는 것입니다.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleMethodArgumentNotValid(
MethodArgumentNotValidException e,
HttpServletRequest request
) {
Map fieldErrors = new HashMap<>();
e.getBindingResult().getFieldErrors().forEach(err -> {
fieldErrors.put(err.getField(), err.getDefaultMessage());
});
String traceId = TraceIdHolder.get();
ApiErrorResponse body = ApiErrorResponse.of(
400,
ErrorCode.INVALID_INPUT.code(),
"요청 값 검증에 실패했습니다.",
request.getRequestURI(),
traceId,
Map.of("fieldErrors", fieldErrors)
);
return ResponseEntity.badRequest().body(body);
}
}
이렇게 하면 프론트는 errorCode를 보고 공통 처리하고, 필요하면 details.fieldErrors를 그대로 폼 에러에 매핑할 수 있습니다.
9. 인증/인가 예외(401/403)와 Spring Security 연계 전략
인증/인가 영역은 “커스텀 예외로 모두 해결”하기보다, Spring Security의 표준 흐름을 활용하는 편이 안정적인 경우가 많습니다.
- 401(인증 실패): AuthenticationEntryPoint에서 처리
- 403(권한 실패): AccessDeniedHandler에서 처리
다만 서비스 레벨에서 명확히 구분하고 싶다면, 위에서 소개한 InvalidTokenException, UnauthorizedAccessException 같은 타입을 “도메인 정책 위반”으로 활용할 수 있습니다. 중요한 건 응답이 401/403으로 일관되게 내려가도록 중앙에서 통제하는 것입니다.
10. 운영 관점: 로그 레벨, 알람, 재시도, 외부 연동 예외
커스텀 예외 설계를 운영까지 확장하면 다음 규칙이 강력합니다.
10-1) 4xx와 5xx는 로그/알람 정책이 달라야 한다
- 4xx(사용자 원인): warn 또는 info (트래픽이 많으면 샘플링 고려)
- 5xx(서버/외부 원인): error + 알람 대상
AppException 기반이면 errorCode로 분기하기 쉬워집니다. 예: EXTERNAL_SERVICE_UNAVAILABLE는 알람 대상, INVALID_INPUT은 알람 제외.
10-2) traceId를 붙여라: “로그만 보고도” 한 번에 추적하기
아래는 아주 단순한 traceId 홀더 예시입니다. 실무에서는 MDC와 연계하거나, 분산 추적(OpenTelemetry)과 붙이는 경우가 많습니다.
public class TraceIdHolder {
private static final ThreadLocal TRACE_ID = new ThreadLocal<>();
public static void set(String traceId) { TRACE_ID.set(traceId); }
public static String get() { return TRACE_ID.get() == null ? "-" : TRACE_ID.get(); }
public static void clear() { TRACE_ID.remove(); }
}
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String traceId = UUID.randomUUID().toString();
TraceIdHolder.set(traceId);
response.setHeader("X-Trace-Id", traceId);
try {
filterChain.doFilter(request, response);
} finally {
TraceIdHolder.clear();
}
}
}
이렇게 하면 클라이언트는 “X-Trace-Id”로 문의할 수 있고, 서버는 해당 traceId로 로그를 즉시 필터링할 수 있습니다.
10-3) 외부 연동 예외는 “재시도 가능 여부”까지 담아라
외부 시스템 장애는 “잠깐 기다리면 나아질 수 있는 문제”인 경우가 많습니다. 예외 details에 재시도 가능 여부, 다운스트림 서비스명, 타임아웃 등을 남겨두면 운영 자동화가 쉬워집니다.
throw new ExternalServiceIntegrationException(
"PG",
"결제 승인 실패",
java.util.Map.of(
"retryable", true,
"downstreamStatus", 503,
"timeoutMs", 2000
)
);
11. 요약: 과하지 않게, 그러나 강력하게
실무에서 좋은 커스텀 예외 전략은 “예외 종류를 많이 만드는 것”이 아니라, 일관된 분류와 일관된 응답을 만드는 것입니다. 다음 3가지만 지켜도 품질이 크게 올라갑니다.
- 분류 기준: 누가 고칠 수 있는가(클라이언트/서버/외부)로 예외를 나눈다.
- 표준화: ErrorCode + AppException + @RestControllerAdvice로 응답/로그를 통일한다.
- 운영성: traceId, details, 4xx/5xx 로그 정책까지 묶어 설계한다.
이 구조 위에서 InvalidInputException, InvalidStatusException, ResourceNotFoundException, DuplicateResourceException, UnauthorizedAccessException, ExternalServiceIntegrationException 같은 예외들은 “그 자체로 문서”가 됩니다. 그리고 팀이 커질수록 그 문서는 더 큰 가치를 만듭니다.