Spring @ControllerAdvice란?
Spring의 @ControllerAdvice는 전역 예외 처리, 모델 바인딩, 데이터 전처리를 담당하는 AOP 기반 컴포넌트입니다. 가장 많이 사용되는 기능은 @ExceptionHandler와 결합한 전역 예외 처리입니다. 각 컨트롤러에 try-catch를 반복하는 대신, 한 곳에서 모든 예외를 일관되게 처리할 수 있습니다.
기본 구조와 동작 원리
@RestControllerAdvice // @ControllerAdvice + @ResponseBody
@Slf4j
public class GlobalExceptionHandler {
// 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleBusinessException(BusinessException ex) {
log.warn("비즈니스 예외: {}", ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage());
}
// 엔티티 미발견
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(EntityNotFoundException ex) {
return ErrorResponse.of("NOT_FOUND", ex.getMessage());
}
// 최종 폴백: 예상치 못한 예외
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpected(Exception ex) {
log.error("예상치 못한 에러", ex);
return ErrorResponse.of("INTERNAL_ERROR", "서버 오류가 발생했습니다");
}
}
Spring은 예외 타입의 구체성 순서로 핸들러를 매칭합니다. BusinessException이 발생하면 Exception 핸들러보다 BusinessException 핸들러가 우선합니다. 상속 계층도 고려되어, 자식 클래스가 더 구체적으로 매칭됩니다.
예외 계층 설계
체계적인 예외 계층은 일관된 에러 처리의 기반입니다.
// 1. 기본 비즈니스 예외
@Getter
public abstract class BusinessException extends RuntimeException {
private final String errorCode;
private final HttpStatus status;
protected BusinessException(String errorCode, String message, HttpStatus status) {
super(message);
this.errorCode = errorCode;
this.status = status;
}
}
// 2. 구체적 예외들
public class EntityNotFoundException extends BusinessException {
public EntityNotFoundException(String entity, Object id) {
super("NOT_FOUND",
String.format("%s(id=%s)를 찾을 수 없습니다", entity, id),
HttpStatus.NOT_FOUND);
}
}
public class DuplicateException extends BusinessException {
public DuplicateException(String field, Object value) {
super("DUPLICATE",
String.format("%s '%s'이(가) 이미 존재합니다", field, value),
HttpStatus.CONFLICT);
}
}
public class InsufficientBalanceException extends BusinessException {
public InsufficientBalanceException(BigDecimal required, BigDecimal current) {
super("INSUFFICIENT_BALANCE",
String.format("잔액 부족: 필요 %s, 현재 %s", required, current),
HttpStatus.UNPROCESSABLE_ENTITY);
}
}
// 3. 사용
public Order createOrder(OrderRequest request) {
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new EntityNotFoundException("User", request.getUserId()));
if (user.getBalance().compareTo(request.getAmount()) < 0)
throw new InsufficientBalanceException(request.getAmount(), user.getBalance());
// ...
}
통합 에러 응답 포맷
API 소비자에게 일관된 에러 응답을 제공하는 것이 핵심입니다.
@Getter @Builder
public class ErrorResponse {
private final String code;
private final String message;
private final List<FieldError> errors;
private final String timestamp;
private final String path;
@Getter @Builder
public static class FieldError {
private final String field;
private final String value;
private final String reason;
}
public static ErrorResponse of(String code, String message) {
return ErrorResponse.builder()
.code(code)
.message(message)
.timestamp(LocalDateTime.now().toString())
.errors(Collections.emptyList())
.build();
}
public static ErrorResponse of(String code, String message,
BindingResult bindingResult) {
List<FieldError> fieldErrors = bindingResult.getFieldErrors().stream()
.map(error -> FieldError.builder()
.field(error.getField())
.value(error.getRejectedValue() != null
? error.getRejectedValue().toString() : "")
.reason(error.getDefaultMessage())
.build())
.toList();
return ErrorResponse.builder()
.code(code)
.message(message)
.timestamp(LocalDateTime.now().toString())
.errors(fieldErrors)
.build();
}
}
응답 예시:
{
"code": "VALIDATION_ERROR",
"message": "입력값 검증 실패",
"timestamp": "2026-03-24T21:00:00",
"path": "/api/users",
"errors": [
{ "field": "email", "value": "invalid", "reason": "올바른 이메일 형식이 아닙니다" },
{ "field": "age", "value": "-1", "reason": "0 이상이어야 합니다" }
]
}
검증 예외 통합 처리
Bean Validation, 바인딩 에러, 타입 불일치 등 다양한 검증 예외를 한 곳에서 처리합니다.
@RestControllerAdvice
public class ValidationExceptionHandler {
// @Valid 검증 실패 (RequestBody)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex,
HttpServletRequest request) {
return ErrorResponse.of("VALIDATION_ERROR", "입력값 검증 실패",
ex.getBindingResult());
}
// @Validated 검증 실패 (PathVariable, RequestParam)
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolation(ConstraintViolationException ex) {
List<ErrorResponse.FieldError> errors = ex.getConstraintViolations().stream()
.map(v -> ErrorResponse.FieldError.builder()
.field(extractFieldName(v.getPropertyPath()))
.value(v.getInvalidValue() != null ? v.getInvalidValue().toString() : "")
.reason(v.getMessage())
.build())
.toList();
return ErrorResponse.builder()
.code("VALIDATION_ERROR")
.message("파라미터 검증 실패")
.errors(errors)
.build();
}
// 타입 불일치 (예: String → Long 변환 실패)
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return ErrorResponse.of("TYPE_MISMATCH",
String.format("'%s' 파라미터 타입 오류: '%s'", ex.getName(), ex.getValue()));
}
// HTTP 메서드 불일치
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ErrorResponse handleMethodNotAllowed(HttpRequestMethodNotSupportedException ex) {
return ErrorResponse.of("METHOD_NOT_ALLOWED",
String.format("%s 메서드는 지원하지 않습니다. 허용: %s",
ex.getMethod(), ex.getSupportedHttpMethods()));
}
}
범위 제한과 우선순위
@ControllerAdvice의 적용 범위를 제한하면 모듈별로 다른 에러 처리가 가능합니다.
// 특정 패키지만
@RestControllerAdvice(basePackages = "com.example.api.admin")
public class AdminExceptionHandler { ... }
// 특정 컨트롤러만
@RestControllerAdvice(assignableTypes = {OrderController.class, PaymentController.class})
public class OrderExceptionHandler { ... }
// 특정 어노테이션이 붙은 컨트롤러만
@RestControllerAdvice(annotations = RestController.class)
public class RestExceptionHandler { ... }
// 우선순위 제어
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE) // 가장 먼저 매칭 시도
public class HighPriorityHandler {
@ExceptionHandler(PaymentException.class)
public ErrorResponse handlePayment(PaymentException ex) { ... }
}
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE) // 최종 폴백
public class FallbackHandler {
@ExceptionHandler(Exception.class)
public ErrorResponse handleAll(Exception ex) { ... }
}
@Order를 활용하면 여러 @ControllerAdvice 간의 우선순위를 명시적으로 제어할 수 있습니다. 도메인별 핸들러를 높은 우선순위로, 전역 폴백 핸들러를 낮은 우선순위로 설정하는 것이 일반적입니다. Spring ProblemDetail RFC 7807과 결합하면 표준 에러 응답 포맷을 구현할 수 있습니다.
ResponseEntityExceptionHandler 확장
Spring이 제공하는 기본 핸들러를 확장하면 프레임워크 예외도 커스터마이징할 수 있습니다.
@RestControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ErrorResponse body = ErrorResponse.of("VALIDATION_ERROR",
"입력값 검증 실패", ex.getBindingResult());
return ResponseEntity.status(status).body(body);
}
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ErrorResponse body = ErrorResponse.of("INVALID_JSON",
"요청 본문을 파싱할 수 없습니다");
return ResponseEntity.status(status).body(body);
}
// 커스텀 비즈니스 예외
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException ex) {
ErrorResponse body = ErrorResponse.of(ex.getErrorCode(), ex.getMessage());
return ResponseEntity.status(ex.getStatus()).body(body);
}
}
ResponseEntityExceptionHandler는 Spring MVC의 표준 예외 18종을 기본 처리합니다. 이를 상속하면 누락 없이 모든 프레임워크 예외를 커스터마이징할 수 있어 프로덕션에서 권장됩니다. Spring Security 인증 커스터마이징의 AuthenticationEntryPoint와 결합하면 인증/인가 에러도 통합 처리할 수 있습니다.
로깅과 모니터링 통합
@RestControllerAdvice
@Slf4j
public class ObservableExceptionHandler extends ResponseEntityExceptionHandler {
private final MeterRegistry meterRegistry;
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(
BusinessException ex, HttpServletRequest request) {
// 구조화된 로깅
log.warn("Business error: code={}, path={}, message={}",
ex.getErrorCode(), request.getRequestURI(), ex.getMessage());
// 메트릭 수집
meterRegistry.counter("api.errors",
"code", ex.getErrorCode(),
"path", request.getRequestURI()
).increment();
return ResponseEntity.status(ex.getStatus())
.body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(
Exception ex, HttpServletRequest request) {
// 예상치 못한 에러는 ERROR 레벨 + 스택 트레이스
log.error("Unexpected error: path={}", request.getRequestURI(), ex);
meterRegistry.counter("api.errors",
"code", "INTERNAL_ERROR",
"path", request.getRequestURI()
).increment();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of("INTERNAL_ERROR", "서버 오류가 발생했습니다"));
}
}
정리
Spring @ControllerAdvice는 전역 예외 처리의 중심입니다. 비즈니스 예외 계층을 설계하고, ErrorResponse로 일관된 응답 포맷을 유지하세요. ResponseEntityExceptionHandler를 상속하면 프레임워크 예외까지 빠짐없이 처리할 수 있습니다. @Order로 도메인별 핸들러 우선순위를 제어하고, Micrometer 메트릭으로 에러 패턴을 모니터링하는 것이 프로덕션 표준 패턴입니다.