Spring Boot

Spring Boot 예외 처리 전략 - @ControllerAdvice부터 ProblemDetail까지

백엔드 개발자 김승원 2026. 3. 26. 00:01

Spring Boot 예외 처리, 왜 전략이 필요한가?

실무에서 Spring Boot 애플리케이션을 개발하다 보면 예외 처리는 피할 수 없는 핵심 관심사입니다. 단순히 try-catch로 예외를 잡는 것을 넘어, 클라이언트에게 일관된 에러 응답을 제공하고, 유지보수가 용이한 구조를 갖추는 것이 중요합니다. 이 글에서는 Spring Boot에서 제공하는 다양한 예외 처리 메커니즘을 살펴보고, 실무에서 바로 적용할 수 있는 에러 핸들링 아키텍처를 구축해보겠습니다.

1. 기본적인 예외 처리 - @ExceptionHandler

@ExceptionHandler는 특정 컨트롤러 내에서 발생하는 예외를 처리하는 가장 기본적인 방법입니다. 해당 컨트롤러 클래스 안에 선언하면, 그 컨트롤러에서 발생하는 지정된 예외를 잡아서 처리합니다.

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

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        User user = userService.findById(id)
                .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다. ID: " + id));
        return ResponseEntity.ok(UserResponse.from(user));
    }

    @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);
    }
}

이 방식은 간단하지만 치명적인 단점이 있습니다. 모든 컨트롤러마다 동일한 예외 처리 로직을 반복해야 한다는 것입니다. 이 문제를 해결하는 것이 바로 @ControllerAdvice입니다.

2. 글로벌 예외 처리 - @ControllerAdvice

@ControllerAdvice는 모든 컨트롤러에 대해 전역적으로 예외를 처리할 수 있는 강력한 메커니즘입니다. @RestControllerAdvice@ControllerAdvice@ResponseBody를 합친 것으로, REST API에서 주로 사용합니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @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);
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
        ErrorResponse error = new ErrorResponse("INVALID_ARGUMENT", e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        List<String> errors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .toList();
        ErrorResponse error = new ErrorResponse("VALIDATION_ERROR", "입력값 검증 실패", errors);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("예상치 못한 에러 발생", e);
        ErrorResponse error = new ErrorResponse("INTERNAL_ERROR", "서버 내부 오류가 발생했습니다.");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

커스텀 에러 응답 클래스 설계

일관된 에러 응답을 위해 공통 ErrorResponse 클래스를 정의합니다.

@Getter
@Builder
public class ErrorResponse {
    private final String code;
    private final String message;
    private final List<String> details;
    private final LocalDateTime timestamp;

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

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

이렇게 하면 클라이언트는 항상 동일한 형태의 에러 응답을 받게 되어, 프론트엔드에서의 에러 처리가 훨씬 수월해집니다.

3. 비즈니스 예외 체계 설계

실무에서는 비즈니스 로직별로 예외를 계층적으로 설계하는 것이 좋습니다. 공통 비즈니스 예외 클래스를 만들고, 이를 상속하는 구체적인 예외 클래스를 정의합니다.

@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 message) {
        super(message);
        this.errorCode = errorCode;
    }
}

public class EntityNotFoundException extends BusinessException {
    public EntityNotFoundException(ErrorCode errorCode) {
        super(errorCode);
    }
}

public class DuplicateException extends BusinessException {
    public DuplicateException(ErrorCode errorCode) {
        super(errorCode);
    }
}

ErrorCode Enum 정의

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    // Common
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값입니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "서버 내부 오류"),

    // User
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."),
    DUPLICATE_EMAIL(HttpStatus.CONFLICT, "U002", "이미 존재하는 이메일입니다."),

    // Order
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "O001", "주문을 찾을 수 없습니다."),
    INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "O002", "재고가 부족합니다.");

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

ErrorCode를 Enum으로 관리하면 에러 코드의 중복을 방지하고, 한 곳에서 모든 에러를 관리할 수 있습니다.

4. ProblemDetail - RFC 7807 표준 에러 응답

Spring Framework 6(Spring Boot 3)부터는 RFC 7807 Problem Details 표준을 지원합니다. ProblemDetail 클래스를 사용하면 국제 표준에 맞는 에러 응답을 쉽게 구성할 수 있습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ProblemDetail handleEntityNotFound(EntityNotFoundException e) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, e.getMessage());
        problemDetail.setTitle("리소스를 찾을 수 없습니다");
        problemDetail.setType(URI.create("https://api.example.com/errors/not-found"));
        problemDetail.setProperty("errorCode", e.getErrorCode().getCode());
        problemDetail.setProperty("timestamp", LocalDateTime.now());
        return problemDetail;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException e) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "입력값 검증에 실패했습니다.");
        problemDetail.setTitle("유효성 검증 실패");
        problemDetail.setType(URI.create("https://api.example.com/errors/validation"));

        List<Map<String, String>> fieldErrors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> Map.of(
                        "field", error.getField(),
                        "message", error.getDefaultMessage() != null ? error.getDefaultMessage() : "유효하지 않은 값"
                ))
                .toList();
        problemDetail.setProperty("fieldErrors", fieldErrors);
        return problemDetail;
    }
}

ProblemDetail 응답 예시

위 핸들러를 통해 반환되는 JSON 응답은 다음과 같은 표준 형태를 갖습니다.

{
  "type": "https://api.example.com/errors/not-found",
  "title": "리소스를 찾을 수 없습니다",
  "status": 404,
  "detail": "사용자를 찾을 수 없습니다.",
  "instance": "/api/users/99",
  "errorCode": "U001",
  "timestamp": "2026-03-25T10:30:00"
}

ProblemDetail의 장점은 type, title, status, detail, instance라는 표준 필드 위에 setProperty로 커스텀 필드를 자유롭게 추가할 수 있다는 점입니다.

5. 실무 에러 핸들링 아키텍처 종합

실무에서 권장하는 전체 예외 처리 아키텍처를 정리하면 다음과 같습니다.

계층 역할 예시
ErrorCode Enum 에러 코드 중앙 관리 USER_NOT_FOUND, DUPLICATE_EMAIL
BusinessException 비즈니스 예외 추상 클래스 errorCode 필드 보유
구체 Exception 도메인별 예외 EntityNotFoundException, DuplicateException
@RestControllerAdvice 글로벌 예외 처리 ProblemDetail 또는 ErrorResponse 반환

서비스 계층에서의 예외 발생

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    @Transactional(readOnly = true)
    public UserResponse findById(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND));
        return UserResponse.from(user);
    }

    @Transactional
    public UserResponse createUser(UserCreateRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateException(ErrorCode.DUPLICATE_EMAIL);
        }
        User user = User.create(request.getName(), request.getEmail());
        return UserResponse.from(userRepository.save(user));
    }
}

6. 주의사항 및 베스트 프랙티스

  • 예외는 구체적으로 처리하세요. Exception.class를 최상위로 잡되, 구체적인 예외 핸들러를 먼저 선언하면 Spring이 가장 구체적인 핸들러를 우선 매칭합니다.
  • 로깅은 서버 에러에만 남기세요. 4xx 에러는 클라이언트 잘못이므로 WARN 레벨, 5xx 에러는 서버 문제이므로 ERROR 레벨로 로깅하는 것이 좋습니다.
  • 민감 정보를 노출하지 마세요. 스택 트레이스나 내부 구현 정보가 클라이언트에게 전달되지 않도록 주의합니다.
  • Spring Security 예외는 별도로 처리하세요. AuthenticationExceptionAccessDeniedException은 필터 체인에서 발생하므로 @ControllerAdvice로 잡히지 않을 수 있습니다. AuthenticationEntryPointAccessDeniedHandler를 구현하여 처리합니다.

마치며

Spring Boot의 예외 처리 전략은 단순한 에러 메시지 반환을 넘어, 시스템의 안정성과 개발 생산성에 직접적인 영향을 미칩니다. @ControllerAdvice로 전역 핸들링 구조를 잡고, ErrorCode Enum으로 에러를 중앙 관리하며, Spring Boot 3 이상이라면 ProblemDetail을 활용하여 표준화된 응답을 제공하는 것을 권장합니다. 프로젝트 초기에 예외 처리 아키텍처를 탄탄하게 잡아두면, 이후 기능 확장 시에도 일관된 에러 처리를 유지할 수 있습니다.