콘텐츠로 이동

Exception Handler

Exception Handler

왜 쓰는가?

컨트롤러마다 예외를 처리하면 중복 코드가 생기고 에러 응답 형식이 일관되지 않는다. ==@RestControllerAdvice==로 전역 예외 처리를 중앙화하고 일관된 에러 응답을 제공한다.

ErrorCode 설계

에러를 Enum으로 관리하면 코드와 메시지를 한 곳에서 관리할 수 있다.

@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    // 공통
    INVALID_INPUT_VALUE(400, "INVALID_INPUT_VALUE", "잘못된 입력값입니다"),
    INTERNAL_SERVER_ERROR(500, "INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다"),

    // 회원
    MEMBER_NOT_FOUND(404, "MEMBER_NOT_FOUND", "회원을 찾을 수 없습니다"),
    DUPLICATE_EMAIL(409, "DUPLICATE_EMAIL", "이미 사용 중인 이메일입니다"),

    // 주문
    ORDER_NOT_FOUND(404, "ORDER_NOT_FOUND", "주문을 찾을 수 없습니다"),
    INSUFFICIENT_STOCK(400, "INSUFFICIENT_STOCK", "재고가 부족합니다");

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

커스텀 예외

public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

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

// 구체적인 예외
public class MemberNotFoundException extends BusinessException {
    public MemberNotFoundException() {
        super(ErrorCode.MEMBER_NOT_FOUND);
    }
}

표준 에러 응답

@Getter
@Builder
public class ErrorResponse {
    private final int status;
    private final String code;
    private final String message;
    private final List<String> errors;

    public static ErrorResponse of(ErrorCode errorCode) {
        return ErrorResponse.builder()
            .status(errorCode.getStatus())
            .code(errorCode.getCode())
            .message(errorCode.getMessage())
            .build();
    }
}

GlobalExceptionHandler

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 비즈니스 예외
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
        ErrorCode errorCode = e.getErrorCode();
        log.warn("BusinessException: {}", e.getMessage());
        return ResponseEntity
            .status(errorCode.getStatus())
            .body(ErrorResponse.of(errorCode));
    }

    // 검증 예외
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        List<String> errors = e.getBindingResult().getFieldErrors()
            .stream()
            .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
            .toList();

        return ResponseEntity.badRequest().body(
            ErrorResponse.builder()
                .status(400)
                .code("INVALID_INPUT_VALUE")
                .message("입력값이 올바르지 않습니다")
                .errors(errors)
                .build()
        );
    }

    // 그 외 예외 (예상치 못한 오류)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unexpected error", e);
        return ResponseEntity.internalServerError()
            .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR));
    }
}

서비스에서 사용

public Member findById(Long id) {
    return memberRepository.findById(id)
        .orElseThrow(MemberNotFoundException::new);
}

단점 / 주의할 점

상황 문제 해결
예외 스택 트레이스를 응답에 포함 내부 구조 노출, 보안 위험 운영에서는 로그에만 기록
@ExceptionHandler 순서 충돌 상위 예외가 먼저 잡힘 구체적인 예외를 위에 배치
checked exception 사용 메서드 시그니처에 throws 전파 RuntimeException 상속으로 통일
예외 로그 누락 장애 원인 파악 불가 log.error로 스택 트레이스 기록

내부 동작 원리

예외가 터지면 어디서 잡히나?

컨트롤러 메서드에서 예외 발생
DispatcherServlet.processDispatchResult()
  → 등록된 HandlerExceptionResolver 목록 순회 (우선순위 순)
  1. ExceptionHandlerExceptionResolver       ← @ExceptionHandler 처리 (최우선)
  2. ResponseStatusExceptionResolver         ← @ResponseStatus 처리
  3. DefaultHandlerExceptionResolver         ← 스프링 기본 예외 처리 (400, 405 등)
  해결된 경우 → 해당 응답 반환
  해결 안 된 경우 → 서블릿 컨테이너로 예외 전파 → 500 Internal Server Error

ExceptionHandlerExceptionResolver — @ExceptionHandler 탐색 순서

예외 발생 (MemberNotFoundException)
  
ExceptionHandlerExceptionResolver.resolveException()
  
   현재 컨트롤러 클래스에서 @ExceptionHandler 탐색
        MemberController에 @ExceptionHandler(MemberNotFoundException.class) 있으면 실행
   없으면  @RestControllerAdvice / @ControllerAdvice 클래스에서 탐색
        GlobalExceptionHandler.handleBusiness() 찾아서 실행
   없으면  다음 HandlerExceptionResolver로 넘김
// @ExceptionHandler의 예외 매칭 규칙
// 가장 구체적인 타입이 먼저 매칭됨
@ExceptionHandler(MemberNotFoundException.class)  // 구체 예외 → 먼저 잡힘
public ResponseEntity<?> handleMember(MemberNotFoundException e) { ... }

@ExceptionHandler(BusinessException.class)  // 부모 예외 → 나중에 잡힘
public ResponseEntity<?> handleBusiness(BusinessException e) { ... }

@ExceptionHandler(Exception.class)  // 최상위 예외 → 마지막 안전망
public ResponseEntity<?> handleAll(Exception e) { ... }

@RestControllerAdvice 내부 — 왜 모든 컨트롤러에 적용되나?

@RestControllerAdvice = @ControllerAdvice + @ResponseBody @ControllerAdvice는 스프링 시작 시점에 모든 컨트롤러에 적용되는 AOP 형태의 글로벌 설정이다.

스프링 컨테이너 시작
  → @ControllerAdvice 빈 스캔
  → ExceptionHandlerExceptionResolver에 등록
  → 이후 모든 컨트롤러에서 예외 발생 시 여기를 먼저 참조

예외 처리 전체 흐름 — 실제 요청부터 응답까지

POST /api/members  (이메일 중복)
MemberController.create()
MemberService.save() → memberRepository.existsByEmail() → true
  → throw new DuplicateEmailException()  (BusinessException 상속)
  ↓ 예외 전파 (컨트롤러까지 올라옴)
DispatcherServlet
ExceptionHandlerExceptionResolver
  → GlobalExceptionHandler.handleBusiness() 매칭
  → ErrorCode.DUPLICATE_EMAIL (409, "이미 사용 중인 이메일")
  → ResponseEntity(status=409, body=ErrorResponse) 반환
HttpMessageConverter → JSON 직렬화
HTTP 409 응답:
{
  "status": 409,
  "code": "DUPLICATE_EMAIL",
  "message": "이미 사용 중인 이메일입니다"
}