Spring @ControllerAdvice 에러 처리

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 메트릭으로 에러 패턴을 모니터링하는 것이 프로덕션 표준 패턴입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux