Spring 전역 예외 처리 설계

Spring 예외 처리 전략이 중요한 이유

REST API에서 일관된 에러 응답은 클라이언트 개발 경험을 좌우합니다. Spring Boot는 @ControllerAdvice@ExceptionHandler를 통해 전역 예외 처리를 제공하며, Spring Boot 3.x에서는 RFC 7807 ProblemDetail을 네이티브로 지원합니다.

이 글에서는 예외 처리 아키텍처, 커스텀 예외 계층 설계, ProblemDetail 활용, Bean Validation 에러 포맷팅, 그리고 실전 운영 패턴을 다룹니다.

@ControllerAdvice 기본 구조

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 특정 예외 타입 처리
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleNotFound(ResourceNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("리소스를 찾을 수 없습니다");
        problem.setProperty("resourceType", ex.getResourceType());
        problem.setProperty("resourceId", ex.getResourceId());
        return ResponseEntity.of(problem).build();
    }

    // 여러 예외를 하나의 핸들러로
    @ExceptionHandler({
        IllegalArgumentException.class,
        IllegalStateException.class
    })
    public ResponseEntity<ProblemDetail> handleBadRequest(RuntimeException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, ex.getMessage());
        problem.setTitle("잘못된 요청");
        return ResponseEntity.of(problem).build();
    }

    // 최종 폴백: 처리되지 않은 모든 예외
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
        log.error("예상치 못한 오류 발생", ex);
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다");
        problem.setTitle("Internal Server Error");
        // 스택트레이스는 절대 클라이언트에 노출하지 않음
        return ResponseEntity.of(problem).build();
    }
}

커스텀 예외 계층 설계

비즈니스 예외를 체계적으로 관리하려면 계층 구조가 필요합니다.

// 최상위 비즈니스 예외
@Getter
public abstract class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

    protected BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    protected BusinessException(ErrorCode errorCode, String detail) {
        super(detail);
        this.errorCode = errorCode;
    }
}

// ErrorCode Enum
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    // 공통
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값"),
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "C002", "리소스를 찾을 수 없음"),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "C003", "인증 필요"),
    FORBIDDEN(HttpStatus.FORBIDDEN, "C004", "권한 부족"),

    // 유저 도메인
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없음"),
    DUPLICATE_EMAIL(HttpStatus.CONFLICT, "U002", "이미 사용 중인 이메일"),
    INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U003", "비밀번호 조건 불일치"),

    // 주문 도메인
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "O001", "주문을 찾을 수 없음"),
    INSUFFICIENT_STOCK(HttpStatus.CONFLICT, "O002", "재고 부족"),
    ORDER_ALREADY_CANCELLED(HttpStatus.CONFLICT, "O003", "이미 취소된 주문");

    private final HttpStatus status;
    private final String code;
    private final String message;
}

// 구체적 예외
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super(ErrorCode.USER_NOT_FOUND, "사용자 ID: " + userId);
    }
}

public class DuplicateEmailException extends BusinessException {
    public DuplicateEmailException(String email) {
        super(ErrorCode.DUPLICATE_EMAIL, "이메일: " + email);
    }
}

public class InsufficientStockException extends BusinessException {
    private final Long productId;
    private final int requested;
    private final int available;

    public InsufficientStockException(Long productId, int requested, int available) {
        super(ErrorCode.INSUFFICIENT_STOCK,
            String.format("상품 %d: 요청 %d, 재고 %d", productId, requested, available));
        this.productId = productId;
        this.requested = requested;
        this.available = available;
    }
}

ProblemDetail: RFC 7807 표준 에러 응답

Spring Boot 3.x에서는 ProblemDetail을 1급 시민으로 지원합니다.

# application.yml — ProblemDetail 활성화
spring:
  mvc:
    problemdetails:
      enabled: true  # Spring Boot 3.x 기본값
// 응답 예시 (JSON)
{
  "type": "https://api.example.com/errors/insufficient-stock",
  "title": "재고 부족",
  "status": 409,
  "detail": "상품 42: 요청 5, 재고 2",
  "instance": "/api/orders",
  "code": "O002",
  "productId": 42,
  "requested": 5,
  "available": 2,
  "timestamp": "2026-02-27T19:00:00Z"
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ProblemDetail> handleBusiness(
            BusinessException ex, HttpServletRequest request) {

        ErrorCode code = ex.getErrorCode();
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            code.getStatus(), ex.getMessage());

        problem.setTitle(code.getMessage());
        problem.setType(URI.create("https://api.example.com/errors/" + code.name().toLowerCase().replace('_', '-')));
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("code", code.getCode());
        problem.setProperty("timestamp", Instant.now());

        // 추가 속성 (InsufficientStockException 등)
        if (ex instanceof InsufficientStockException stockEx) {
            problem.setProperty("productId", stockEx.getProductId());
            problem.setProperty("requested", stockEx.getRequested());
            problem.setProperty("available", stockEx.getAvailable());
        }

        return ResponseEntity.of(problem).build();
    }
}

Bean Validation 에러 처리

NestJS의 Pipe 검증처럼, Spring에서도 @Valid 검증 실패 시 일관된 에러 응답이 필요합니다.

// DTO
public record CreateUserRequest(
    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 50, message = "이름은 2~50자")
    String name,

    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이 아닙니다")
    String email,

    @NotBlank(message = "비밀번호는 필수입니다")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$",
             message = "비밀번호는 8자 이상, 영문+숫자 포함")
    String password
) {}

// Controller
@PostMapping("/users")
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
    return userService.create(request);
}
// Validation 에러 핸들러
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(
        MethodArgumentNotValidException ex) {

    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setTitle("입력값 검증 실패");
    problem.setProperty("code", "C001");

    List<Map<String, String>> fieldErrors = ex.getBindingResult()
        .getFieldErrors().stream()
        .map(error -> Map.of(
            "field", error.getField(),
            "message", error.getDefaultMessage(),
            "rejectedValue", String.valueOf(error.getRejectedValue())
        ))
        .toList();

    problem.setProperty("errors", fieldErrors);
    return ResponseEntity.of(problem).build();
}

// 응답 예시
// {
//   "title": "입력값 검증 실패",
//   "status": 400,
//   "code": "C001",
//   "errors": [
//     {"field": "email", "message": "올바른 이메일 형식이 아닙니다", "rejectedValue": "invalid"},
//     {"field": "password", "message": "비밀번호는 8자 이상, 영문+숫자 포함", "rejectedValue": "123"}
//   ]
// }

// @RequestParam 검증 실패 (ConstraintViolationException)
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ProblemDetail> handleConstraint(
        ConstraintViolationException ex) {

    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setTitle("파라미터 검증 실패");

    List<Map<String, String>> violations = ex.getConstraintViolations().stream()
        .map(v -> Map.of(
            "field", extractFieldName(v.getPropertyPath()),
            "message", v.getMessage()
        ))
        .toList();

    problem.setProperty("errors", violations);
    return ResponseEntity.of(problem).build();
}

운영 환경: 에러 로깅 전략

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 4xx → WARN 레벨 (클라이언트 실수)
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ProblemDetail> handleBusiness(BusinessException ex) {
        log.warn("비즈니스 예외: {} — {}", ex.getErrorCode(), ex.getMessage());
        // ...
    }

    // 5xx → ERROR 레벨 + 스택트레이스
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
        String errorId = UUID.randomUUID().toString().substring(0, 8);
        log.error("서버 오류 [{}]: {}", errorId, ex.getMessage(), ex);

        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        problem.setDetail("서버 오류가 발생했습니다. 오류 ID: " + errorId);
        // errorId로 로그 추적 가능
        problem.setProperty("errorId", errorId);
        return ResponseEntity.of(problem).build();
    }
}

Actuator + Micrometer와 연동하면 에러 코드별 발생 빈도를 메트릭으로 수집할 수 있습니다:

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ProblemDetail> handleBusiness(
        BusinessException ex, MeterRegistry registry) {

    // 에러 코드별 카운터 증가
    registry.counter("api.errors",
        "code", ex.getErrorCode().getCode(),
        "status", String.valueOf(ex.getErrorCode().getStatus().value())
    ).increment();

    // ...
}

Spring Security 예외 통합

// Spring Security 예외는 @ControllerAdvice보다 먼저 처리됨
// → SecurityFilterChain에서 직접 설정

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .exceptionHandling(ex -> ex
            .authenticationEntryPoint((request, response, authEx) -> {
                ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                    HttpStatus.UNAUTHORIZED, "인증이 필요합니다");
                problem.setProperty("code", "C003");
                response.setStatus(401);
                response.setContentType("application/problem+json");
                new ObjectMapper().writeValue(response.getWriter(), problem);
            })
            .accessDeniedHandler((request, response, accessEx) -> {
                ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                    HttpStatus.FORBIDDEN, "접근 권한이 없습니다");
                problem.setProperty("code", "C004");
                response.setStatus(403);
                response.setContentType("application/problem+json");
                new ObjectMapper().writeValue(response.getWriter(), problem);
            })
        )
        .build();
}

마무리

Spring 전역 예외 처리의 핵심은 일관성입니다. ErrorCode enum으로 코드를 중앙 관리하고, ProblemDetail(RFC 7807)로 표준화된 응답을 반환하며, 4xx는 WARN, 5xx는 ERROR + errorId로 로깅하세요. AOP와 결합하면 예외 발생 전후에 추가 횡단 로직도 삽입할 수 있습니다. 잘 설계된 에러 응답은 API 소비자와 운영팀 모두의 생산성을 높입니다.

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