이 글에서 얻는 것

  • @ControllerAdvice로 전역 예외 처리를 구현할 수 있습니다.
  • @ExceptionHandler로 예외 타입별로 응답을 구분할 수 있습니다.
  • 일관된 에러 응답 포맷을 설계할 수 있습니다.
  • 커스텀 예외를 만들고 적절히 처리할 수 있습니다.

0) 예외 처리는 “사용자 경험"이다

문제 상황

❌ 예외 처리 없이:

@RestController
public class UserController {

    @GetMapping("/users/{id}")
    public User getUser(@PathVariable Long id) {
        // 예외 발생 시 Spring이 기본 에러 페이지 반환
        return userService.findById(id);  // UserNotFoundException!
    }
}

결과:

{
  "timestamp": "2025-12-16T10:30:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/users/999"
}

문제점:

  • 사용자에게 의미 없는 500 에러
  • 어떤 문제인지 알 수 없음
  • API마다 에러 형식이 다름
  • 로그에 스택 트레이스만 쌓임

해결: 전역 예외 처리

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        ErrorResponse error = new ErrorResponse(
            "USER_NOT_FOUND",
            e.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

결과:

{
  "errorCode": "USER_NOT_FOUND",
  "message": "사용자를 찾을 수 없습니다: ID=999",
  "timestamp": "2025-12-16T10:30:00"
}

1) @ControllerAdvice와 @RestControllerAdvice

1-1) @ControllerAdvice

@ControllerAdvice
public class GlobalExceptionHandler {

    // 모든 컨트롤러의 예외를 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        // ...
    }
}

특징:

  • 모든 @Controller에 적용
  • @ExceptionHandler와 함께 사용
  • View 이름 반환 가능

1-2) @RestControllerAdvice

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

    @ExceptionHandler(Exception.class)
    public ErrorResponse handleException(Exception e) {
        // 자동으로 JSON 변환
        return new ErrorResponse(e.getMessage());
    }
}

차이점:

@ControllerAdvice:
- View 이름 반환
- @ResponseBody 필요

@RestControllerAdvice:
- 객체 반환 → 자동 JSON 변환
- REST API에 적합

1-3) 특정 패키지/컨트롤러에만 적용

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

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

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

2) @ExceptionHandler

2-1) 기본 사용

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 특정 예외 처리
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        ErrorResponse error = ErrorResponse.builder()
            .errorCode("USER_NOT_FOUND")
            .message(e.getMessage())
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // 여러 예외를 동일하게 처리
    @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
    public ResponseEntity<ErrorResponse> handleBadRequest(RuntimeException e) {
        ErrorResponse error = ErrorResponse.builder()
            .errorCode("BAD_REQUEST")
            .message(e.getMessage())
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.badRequest().body(error);
    }

    // 모든 예외 처리 (마지막 방어선)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unexpected error occurred", e);

        ErrorResponse error = ErrorResponse.builder()
            .errorCode("INTERNAL_SERVER_ERROR")
            .message("서버 오류가 발생했습니다.")
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

2-2) HttpServletRequest 접근

@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(
        UserNotFoundException e,
        HttpServletRequest request) {

    log.warn("User not found: {} - {}", request.getRequestURI(), e.getMessage());

    ErrorResponse error = ErrorResponse.builder()
        .errorCode("USER_NOT_FOUND")
        .message(e.getMessage())
        .path(request.getRequestURI())
        .timestamp(LocalDateTime.now())
        .build();

    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}

3) 에러 응답 설계

3-1) ErrorResponse DTO

@Getter
@Builder
public class ErrorResponse {
    private String errorCode;       // 에러 코드 (고유 식별자)
    private String message;          // 사용자용 메시지
    private LocalDateTime timestamp; // 발생 시각
    private String path;             // 요청 경로

    // 선택적
    private List<FieldError> fieldErrors;  // Validation 에러 상세
}

@Getter
@AllArgsConstructor
public class FieldError {
    private String field;
    private String value;
    private String reason;
}

3-2) 에러 코드 관리

public enum ErrorCode {
    // 4xx Client Errors
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."),
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "O001", "주문을 찾을 수 없습니다."),
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "C001", "입력값이 올바르지 않습니다."),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "인증이 필요합니다."),
    FORBIDDEN(HttpStatus.FORBIDDEN, "A002", "권한이 없습니다."),

    // 5xx Server Errors
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 오류가 발생했습니다."),
    DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S002", "데이터베이스 오류가 발생했습니다.");

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

    ErrorCode(HttpStatus status, String code, String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }

    public HttpStatus getStatus() { return status; }
    public String getCode() { return code; }
    public String getMessage() { return message; }
}

사용:

@Getter
public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

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

    public BusinessException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }
}

// 비즈니스 로직에서
throw new BusinessException(ErrorCode.USER_NOT_FOUND, "사용자 ID: " + userId);

4) 커스텀 예외 계층

4-1) 예외 계층 설계

// 최상위 비즈니스 예외
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 message) {
        super(message);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

// 도메인별 예외
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다: ID=" + userId);
    }
}

public class OrderNotFoundException extends BusinessException {
    public OrderNotFoundException(Long orderId) {
        super(ErrorCode.ORDER_NOT_FOUND, "주문을 찾을 수 없습니다: ID=" + orderId);
    }
}

public class InvalidOrderStateException extends BusinessException {
    public InvalidOrderStateException(String currentState, String expectedState) {
        super(ErrorCode.INVALID_INPUT,
            String.format("주문 상태가 올바르지 않습니다. 현재: %s, 기대: %s", currentState, expectedState));
    }
}

4-2) 전역 예외 핸들러

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

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

        log.warn("Business exception: {} - {}", e.getErrorCode().getCode(), e.getMessage());

        ErrorResponse error = ErrorResponse.builder()
            .errorCode(e.getErrorCode().getCode())
            .message(e.getMessage())
            .path(request.getRequestURI())
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity
            .status(e.getErrorCode().getStatus())
            .body(error);
    }

    // Validation 예외
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException e,
            HttpServletRequest request) {

        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors().stream()
            .map(error -> new FieldError(
                error.getField(),
                error.getRejectedValue() != null ? error.getRejectedValue().toString() : null,
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());

        ErrorResponse error = ErrorResponse.builder()
            .errorCode("VALIDATION_FAILED")
            .message("입력값 검증에 실패했습니다.")
            .path(request.getRequestURI())
            .timestamp(LocalDateTime.now())
            .fieldErrors(fieldErrors)
            .build();

        return ResponseEntity.badRequest().body(error);
    }

    // 인증 예외
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(
            AccessDeniedException e,
            HttpServletRequest request) {

        log.warn("Access denied: {}", request.getRequestURI());

        ErrorResponse error = ErrorResponse.builder()
            .errorCode("ACCESS_DENIED")
            .message("접근 권한이 없습니다.")
            .path(request.getRequestURI())
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
    }

    // 모든 예외 (마지막 방어선)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception e,
            HttpServletRequest request) {

        log.error("Unexpected error occurred", e);

        ErrorResponse error = ErrorResponse.builder()
            .errorCode("INTERNAL_SERVER_ERROR")
            .message("서버 오류가 발생했습니다.")
            .path(request.getRequestURI())
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

5) Validation 예외 처리

5-1) Bean Validation

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

    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
        // @Valid 검증 실패 시 MethodArgumentNotValidException 발생
        User user = userService.createUser(request);
        return ResponseEntity.ok(user);
    }
}

// DTO
@Getter
@Setter
public class CreateUserRequest {
    @NotBlank(message = "이름은 필수입니다.")
    @Size(min = 2, max = 50, message = "이름은 2-50자 사이여야 합니다.")
    private String name;

    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "이메일 형식이 올바르지 않습니다.")
    private String email;

    @Min(value = 18, message = "18세 이상이어야 합니다.")
    private Integer age;
}

5-2) Validation 예외 처리

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
        MethodArgumentNotValidException e) {

    List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors().stream()
        .map(error -> new FieldError(
            error.getField(),
            error.getRejectedValue() != null ? error.getRejectedValue().toString() : null,
            error.getDefaultMessage()
        ))
        .collect(Collectors.toList());

    ErrorResponse error = ErrorResponse.builder()
        .errorCode("VALIDATION_FAILED")
        .message("입력값 검증에 실패했습니다.")
        .timestamp(LocalDateTime.now())
        .fieldErrors(fieldErrors)
        .build();

    return ResponseEntity.badRequest().body(error);
}

응답:

{
  "errorCode": "VALIDATION_FAILED",
  "message": "입력값 검증에 실패했습니다.",
  "timestamp": "2025-12-16T10:30:00",
  "fieldErrors": [
    {
      "field": "name",
      "value": "A",
      "reason": "이름은 2-50자 사이여야 합니다."
    },
    {
      "field": "email",
      "value": "invalid-email",
      "reason": "이메일 형식이 올바르지 않습니다."
    }
  ]
}

6) 실전 패턴

6-1) 환경별 에러 메시지

@RestControllerAdvice
public class GlobalExceptionHandler {

    @Value("${spring.profiles.active:dev}")
    private String activeProfile;

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unexpected error", e);

        // 운영 환경에서는 상세 정보 숨김
        String message = "prod".equals(activeProfile)
            ? "서버 오류가 발생했습니다."
            : e.getMessage();

        ErrorResponse error = ErrorResponse.builder()
            .errorCode("INTERNAL_SERVER_ERROR")
            .message(message)
            .timestamp(LocalDateTime.now())
            .build();

        // 개발 환경에서는 스택 트레이스 포함 (선택적)
        if (!"prod".equals(activeProfile)) {
            error.setStackTrace(ExceptionUtils.getStackTrace(e));
        }

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

6-2) 로깅 전략

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        // 비즈니스 예외는 WARN 레벨 (예상된 예외)
        log.warn("Business exception: {} - {}", e.getErrorCode().getCode(), e.getMessage());
        // ...
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        // 예상치 못한 예외는 ERROR 레벨
        log.error("Unexpected error occurred", e);
        // ...
    }
}

6-3) 알림 통합

@RestControllerAdvice
public class GlobalExceptionHandler {

    @Autowired
    private SlackNotifier slackNotifier;

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception e,
            HttpServletRequest request) {

        log.error("Critical error occurred", e);

        // 운영 환경에서 심각한 에러는 Slack 알림
        if ("prod".equals(activeProfile)) {
            slackNotifier.sendError(
                "Critical Error",
                String.format("Path: %s\nError: %s", request.getRequestURI(), e.getMessage())
            );
        }

        // ...
    }
}

7) 베스트 프랙티스

✅ 1. 명확한 에러 코드

// ✅ 좋은 예
throw new BusinessException(ErrorCode.USER_NOT_FOUND, "사용자 ID: " + userId);

// ❌ 나쁜 예
throw new RuntimeException("User not found");

✅ 2. 계층적 예외 처리

// 계층: BusinessException → UserNotFoundException
// 공통 처리 로직은 상위 예외에서
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
    // 모든 비즈니스 예외 공통 처리
}

✅ 3. 민감 정보 노출 방지

// ❌ 나쁜 예
throw new RuntimeException("Database connection failed: password=secret123");

// ✅ 좋은 예
throw new DatabaseException("데이터베이스 연결에 실패했습니다.");
// 상세 정보는 로그에만

✅ 4. 일관된 응답 형식

// 모든 에러 응답이 동일한 구조
{
  "errorCode": "...",
  "message": "...",
  "timestamp": "...",
  "path": "..."
}

8) 자주 하는 실수

❌ 실수 1: Exception을 너무 일찍 처리

// ❌ 나쁜 예: 컨트롤러에서 try-catch
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
    try {
        return ResponseEntity.ok(userService.findById(id));
    } catch (UserNotFoundException e) {
        return ResponseEntity.notFound().build();  // 응답 형식 불일치!
    }
}

// ✅ 좋은 예: 예외를 던지고 @ControllerAdvice에서 처리
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
    return userService.findById(id);  // 예외 발생 시 GlobalExceptionHandler가 처리
}

❌ 실수 2: 너무 포괄적인 예외 처리

// ❌ 나쁜 예
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception e) {
    return ResponseEntity.ok(new ErrorResponse("에러"));  // 모든 예외를 200으로!
}

// ✅ 좋은 예
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
    return ResponseEntity.status(e.getErrorCode().getStatus()).body(...);
}

❌ 실수 3: 로깅 누락

// ❌ 나쁜 예
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handle(Exception e) {
    return ResponseEntity.status(500).body(new ErrorResponse("에러"));
    // 로그 없음 → 디버깅 불가!
}

// ✅ 좋은 예
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handle(Exception e) {
    log.error("Unexpected error", e);  // 로깅!
    return ResponseEntity.status(500).body(new ErrorResponse("서버 오류"));
}

연습 (추천)

  1. 전역 예외 처리 구현

    • @RestControllerAdvice 작성
    • 커스텀 예외 계층 설계
    • ErrorResponse DTO 작성
  2. Validation 예외 처리

    • @Valid 적용
    • 상세한 필드 에러 반환
  3. 에러 코드 관리

    • ErrorCode enum 작성
    • 도메인별 에러 코드 정의

요약: 스스로 점검할 것

  • @ControllerAdvice로 전역 예외 처리를 구현할 수 있다
  • ErrorResponse로 일관된 에러 응답을 설계할 수 있다
  • 커스텀 예외 계층을 만들 수 있다
  • Validation 예외를 처리할 수 있다
  • 환경별로 적절한 에러 메시지를 제공할 수 있다

다음 단계

  • Spring Validation: /learning/deep-dive/deep-dive-spring-validation-response/
  • Spring Security 예외 처리: /learning/deep-dive/deep-dive-spring-security/
  • 로깅 전략: /learning/deep-dive/deep-dive-logging-strategy/