전체 목록
SpringMedium#79

@ExceptionHandler와 @ControllerAdvice를 이용한 전역 예외 처리 방법을 설명해주세요.

#Spring#예외처리#ExceptionHandler#REST
힌트

컨트롤러 단위 vs 전역 예외 처리, @RestControllerAdvice를 생각해보세요.

정답 및 해설

@ExceptionHandler와 @ControllerAdvice를 이용한 전역 예외 처리 방법을 설명해주세요.

Spring MVC에서 예외 처리를 각 컨트롤러마다 따로 작성하면 코드 중복이 발생하고 일관성을 유지하기 어렵습니다. @ExceptionHandler@ControllerAdvice를 활용하면 예외 처리 코드를 한 곳에 집중하여 일관된 에러 응답 형식을 유지할 수 있습니다. 이는 유지보수성과 가독성을 크게 향상시킵니다.

@ExceptionHandler 기본 사용

컨트롤러 내부에서의 예외 처리

@ExceptionHandler를 특정 컨트롤러 클래스 내부에 선언하면, 해당 컨트롤러에서 발생한 예외만 처리합니다.

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public UserDto getUser(@PathVariable Long id) {
        return userService.findById(id); // UserNotFoundException 발생 가능
    }

    // 이 컨트롤러에서만 적용
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        ErrorResponse error = new ErrorResponse("USER_NOT_FOUND", e.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

한계점

// 문제: 모든 컨트롤러에 동일한 예외 처리 코드를 반복해야 함
@RestController
public class OrderController {
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(...) { /* 중복! */ }
}

@RestController
public class ProductController {
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(...) { /* 중복! */ }
}

@ControllerAdvice / @RestControllerAdvice

전역 예외 처리 클래스 구성

@ControllerAdvice(또는 @RestControllerAdvice)를 사용하면 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있습니다.

// @ControllerAdvice + @ResponseBody = @RestControllerAdvice
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // 특정 예외 처리
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        log.warn("사용자를 찾을 수 없음: {}", e.getMessage());
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("USER_NOT_FOUND", e.getMessage()));
    }

    // 유효성 검증 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationError(
            MethodArgumentNotValidException e) {
        List<String> errors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .collect(Collectors.toList());

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("VALIDATION_ERROR", "입력값 검증 실패", errors));
    }

    // 최상위 예외 처리 (모든 예상치 못한 예외)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception e) {
        log.error("예상치 못한 오류 발생", e);
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다"));
    }
}

구조화된 에러 응답 설계

ErrorResponse DTO

@Getter
@Builder
public class ErrorResponse {
    private final String code;          // 에러 코드 (비즈니스 에러 식별용)
    private final String message;       // 사용자 친화적 메시지
    private final List<String> details; // 상세 오류 목록 (검증 오류 등)
    private final LocalDateTime timestamp;
    private final String path;

    public ErrorResponse(String code, String message) {
        this(code, message, null, LocalDateTime.now(), null);
    }

    public ErrorResponse(String code, String message, List<String> details) {
        this(code, message, details, LocalDateTime.now(), null);
    }

    @JsonCreator
    public ErrorResponse(String code, String message,
                         List<String> details, LocalDateTime timestamp, String path) {
        this.code = code;
        this.message = message;
        this.details = details;
        this.timestamp = timestamp;
        this.path = path;
    }
}

응답 예시

// 404 Not Found
{
    "code": "USER_NOT_FOUND",
    "message": "ID 42에 해당하는 사용자를 찾을 수 없습니다",
    "details": null,
    "timestamp": "2025-03-01T10:30:00",
    "path": "/api/users/42"
}

// 400 Bad Request (유효성 검증 실패)
{
    "code": "VALIDATION_ERROR",
    "message": "입력값 검증 실패",
    "details": [
        "email: 올바른 이메일 형식이어야 합니다",
        "name: 이름은 필수입니다"
    ],
    "timestamp": "2025-03-01T10:30:00",
    "path": "/api/users"
}

커스텀 비즈니스 예외 계층 구조

예외 계층 설계

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

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

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

// 에러 코드 Enum
@Getter
@AllArgsConstructor
public enum ErrorCode {
    // 사용자 관련
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "사용자를 찾을 수 없습니다"),
    USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_002", "이미 존재하는 이메일입니다"),
    INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "USER_003", "비밀번호가 일치하지 않습니다"),

    // 주문 관련
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_001", "주문을 찾을 수 없습니다"),
    INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "ORDER_002", "재고가 부족합니다"),

    // 공통
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다"),
    ACCESS_DENIED(HttpStatus.FORBIDDEN, "COMMON_002", "접근 권한이 없습니다");

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

// 구체적인 예외 클래스
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super(ErrorCode.USER_NOT_FOUND);
    }
}

public class InsufficientStockException extends BusinessException {
    private final int availableStock;
    private final int requestedQuantity;

    public InsufficientStockException(int available, int requested) {
        super(ErrorCode.INSUFFICIENT_STOCK);
        this.availableStock = available;
        this.requestedQuantity = requested;
    }
}

ErrorCode 기반 전역 예외 처리

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 비즈니스 예외 통합 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException e, HttpServletRequest request) {

        ErrorCode errorCode = e.getErrorCode();
        log.warn("비즈니스 예외 발생 [{}] {} - URI: {}",
                errorCode.getCode(), e.getMessage(), request.getRequestURI());

        return ResponseEntity
                .status(errorCode.getHttpStatus())
                .body(ErrorResponse.builder()
                        .code(errorCode.getCode())
                        .message(errorCode.getMessage())
                        .timestamp(LocalDateTime.now())
                        .path(request.getRequestURI())
                        .build());
    }

    // @Valid 검증 실패
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException e, HttpServletRequest request) {

        List<String> details = e.getBindingResult().getFieldErrors()
                .stream()
                .map(fe -> String.format("[%s] %s", fe.getField(), fe.getDefaultMessage()))
                .collect(Collectors.toList());

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponse.builder()
                        .code("VALIDATION_FAILED")
                        .message("요청 데이터 검증에 실패했습니다")
                        .details(details)
                        .timestamp(LocalDateTime.now())
                        .path(request.getRequestURI())
                        .build());
    }

    // @PathVariable, @RequestParam 타입 불일치
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException e) {

        String message = String.format("파라미터 '%s'의 값 '%s'은(는) '%s' 타입이 아닙니다",
                e.getName(), e.getValue(),
                e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "Unknown");

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("TYPE_MISMATCH", message));
    }

    // 지원하지 않는 HTTP 메서드
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ErrorResponse> handleMethodNotSupported(
            HttpRequestMethodNotSupportedException e) {

        return ResponseEntity
                .status(HttpStatus.METHOD_NOT_ALLOWED)
                .body(new ErrorResponse("METHOD_NOT_ALLOWED",
                        e.getMethod() + " 메서드는 지원하지 않습니다"));
    }

    // 최상위 예외 (예상치 못한 모든 예외)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception e, HttpServletRequest request) {

        log.error("처리되지 않은 예외 발생 - URI: {}", request.getRequestURI(), e);

        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ErrorResponse.builder()
                        .code("INTERNAL_SERVER_ERROR")
                        .message("서버 내부 오류가 발생했습니다")
                        .timestamp(LocalDateTime.now())
                        .path(request.getRequestURI())
                        .build());
    }
}

@ControllerAdvice 적용 범위 제한

// 특정 패키지에만 적용
@RestControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler { }

// 특정 컨트롤러 클래스에만 적용
@RestControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
public class UserOrderExceptionHandler { }

// 특정 어노테이션이 있는 컨트롤러에만 적용
@RestControllerAdvice(annotations = RestController.class)
public class RestApiExceptionHandler { }

@ResponseStatus 어노테이션

간단한 경우 예외 클래스에 직접 HTTP 상태 코드를 지정할 수 있습니다.

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

// @ControllerAdvice 없이도 404 응답 자동 반환
// (단, 응답 본문 커스터마이징이 어렵기 때문에 @ControllerAdvice 방식 권장)

예외 처리 우선순위

예외 발생
    │
    ▼
해당 컨트롤러의 @ExceptionHandler 탐색
    │
  있음?
  ┌──┴──┐
  YES   NO
  │     │
  처리   ▼
     @ControllerAdvice의 @ExceptionHandler 탐색
         │
       있음?
       ┌──┴──┐
       YES   NO
       │     │
       처리   ▼
          Spring Boot의 기본 에러 처리 (/error)

요약 표

구분@ExceptionHandler (컨트롤러 내)@ControllerAdvice
적용 범위해당 컨트롤러만전체 (또는 지정 범위)
코드 위치컨트롤러 클래스 내부별도 클래스
재사용성낮음높음
우선순위높음 (먼저 적용)낮음 (폴백)
사용 권장컨트롤러 특화 예외공통 예외 처리
처리 대상권장 HTTP 상태 코드에러 코드 예시
리소스 없음404 Not FoundUSER_NOT_FOUND
유효성 검증 실패400 Bad RequestVALIDATION_FAILED
인증 실패401 UnauthorizedUNAUTHORIZED
권한 없음403 ForbiddenACCESS_DENIED
비즈니스 규칙 위반409 ConflictDUPLICATE_EMAIL
서버 내부 오류500 Internal Server ErrorINTERNAL_ERROR