[Spring 완전 정복 #10] Spring 예외 처리 — @ControllerAdvice와 Custom Exception 설계

@ControllerAdvice + @ExceptionHandler 전역 예외 처리 구조, BusinessException + ErrorCode Enum으로 Custom Exception 계층 설계하는 실전 패턴을 정리한다.

예외 처리가 흩어져 있으면 생기는 문제

Spring 애플리케이션에서 예외 처리를 각 Controller마다 개별로 하면 두 가지 문제가 생긴다. 에러 응답 형식이 제각각이 되고, 같은 예외를 여러 곳에서 중복 처리하게 된다.

@ControllerAdvice는 이 문제를 해결한다. 전역 예외 핸들러를 하나 만들어 모든 Controller에서 발생하는 예외를 한 곳에서 처리한다.


@ControllerAdvice 기본 구조

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

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

    @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.of("VALIDATION_FAILED", errors.toString()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("Unhandled exception", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of("INTERNAL_ERROR", "서버 오류가 발생했습니다"));
    }
}

예외 처리 계층은 다음과 같다.

Controller 예외 발생
  → HandlerExceptionResolver (Spring MVC 내부)
  → @ControllerAdvice
  → 일관된 에러 응답 반환

Filter에서 발생한 예외는 DispatcherServlet 밖이라 @ControllerAdvice가 잡지 못한다. Filter 내부에서 직접 응답을 작성해야 한다.


에러 응답 DTO

클라이언트가 에러 상황을 파악할 수 있도록 일관된 형식으로 응답한다.

@Getter
public class ErrorResponse {
    private final String code;
    private final String message;
    private final LocalDateTime timestamp;

    private ErrorResponse(String code, String message) {
        this.code = code;
        this.message = message;
        this.timestamp = LocalDateTime.now();
    }

    public static ErrorResponse of(String code, String message) {
        return new ErrorResponse(code, message);
    }
}
{
  "code": "USER_NOT_FOUND",
  "message": "사용자를 찾을 수 없습니다: id=123",
  "timestamp": "2026-05-19T10:30:00"
}

code는 클라이언트가 에러 종류를 분기 처리하기 위한 값이고, message는 사람이 읽는 설명이다.


Custom Exception 설계 — ErrorCode Enum 패턴

에러 코드가 늘어날수록 관리가 중요해진다. ErrorCode Enum + BusinessException 기반 클래스 패턴이 실무에서 가장 많이 쓰인다.

1. ErrorCode Enum

@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    // 공통
    INVALID_INPUT(400, "INVALID_INPUT", "잘못된 입력입니다"),
    UNAUTHORIZED(401, "UNAUTHORIZED", "인증이 필요합니다"),
    FORBIDDEN(403, "FORBIDDEN", "권한이 없습니다"),

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

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

    // 서버
    INTERNAL_ERROR(500, "INTERNAL_ERROR", "서버 오류가 발생했습니다");

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

2. BusinessException 기반 클래스

public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

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

    public BusinessException(ErrorCode errorCode, String detail) {
        super(errorCode.getMessage() + ": " + detail);
        this.errorCode = errorCode;
    }

    public int getStatus() { return errorCode.getStatus(); }
    public String getCode() { return errorCode.getCode(); }
}

3. 도메인별 구체 예외 클래스

public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super(ErrorCode.USER_NOT_FOUND, "id=" + userId);
    }
}

public class InsufficientStockException extends BusinessException {
    public InsufficientStockException(Long productId, int requested, int available) {
        super(ErrorCode.INSUFFICIENT_STOCK,
            String.format("productId=%d, 요청=%d, 재고=%d", productId, requested, available));
    }
}

4. 서비스에서 사용

@Service
public class UserService {
    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }
}

5. 핸들러에서 통합 처리

@RestControllerAdvice
public class GlobalExceptionHandler {

    // BusinessException 계층 전체를 한 곳에서 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        return ResponseEntity
            .status(e.getStatus())
            .body(ErrorResponse.of(e.getCode(), e.getMessage()));
    }
}

새로운 예외가 생겨도 ErrorCode에 추가하고 BusinessException을 상속하면 핸들러 수정 없이 자동으로 처리된다.


HTTP 상태 코드 — 자주 쓰는 것

코드의미사용 상황
200 OK성공조회, 수정 성공
201 Created생성 성공POST로 리소스 생성
204 No Content성공, 바디 없음DELETE 성공
400 Bad Request잘못된 요청Validation 실패
401 Unauthorized미인증로그인 필요
403 Forbidden권한 없음인증됐지만 권한 부족
404 Not Found리소스 없음존재하지 않는 ID
409 Conflict충돌중복 이메일
500 Internal Error서버 오류예상치 못한 예외

401과 403의 차이를 명확히 해두자. 401은 “누구인지 모른다 → 로그인 필요”, 403은 “누구인지 알지만 권한이 없다"는 뜻이다.


마치며

Spring 예외 처리의 핵심은 두 가지다. @ControllerAdvice로 전역 핸들러를 만들어 에러 응답 형식을 통일하고, ErrorCode Enum + BusinessException 패턴으로 예외를 체계적으로 관리한다. 이 구조를 갖추면 새 예외 추가 시 ErrorCode에 한 줄 추가하는 것으로 끝난다.

다음 편에서는 Spring 테스트 — 단위/슬라이스/통합 테스트 전략을 정리한다.