서비스 메서드에서 예외가 발생하면 클라이언트에게는 어떤 응답이 가나요? 500 에러 페이지? JSON 에러? 스프링은 예외를 어떻게 HTTP 응답으로 바꾸는 걸까요?

개념 정의

스프링 MVC의 예외 처리 는 컨트롤러나 서비스에서 발생한 예외를 잡아서 적절한 HTTP 응답(상태 코드, 에러 메시지)으로 변환하는 메커니즘입니다. @ExceptionHandler, @ControllerAdvice, ResponseStatusException 등을 사용합니다.

왜 필요한가

예외 처리가 없으면 모든 예외가 500 Internal Server Error로 반환됩니다. 스택 트레이스가 그대로 노출될 수도 있어 보안에도 위험합니다. 클라이언트에게 의미 있는 에러 메시지와 적절한 상태 코드를 돌려줘야 합니다.

내부 동작

예외 처리 우선순위

PLAINTEXT
1. 같은 컨트롤러의 @ExceptionHandler (로컬)
2. @ControllerAdvice의 @ExceptionHandler (글로벌)
3. HandlerExceptionResolver 체인
   a. ExceptionHandlerExceptionResolver (@ExceptionHandler 처리)
   b. ResponseStatusExceptionResolver (@ResponseStatus 처리)
   c. DefaultHandlerExceptionResolver (스프링 표준 예외 처리)
4. 서블릿 컨테이너 기본 에러 처리

DefaultHandlerExceptionResolver가 처리하는 예외들

PLAINTEXT
HttpRequestMethodNotSupportedException → 405 Method Not Allowed
HttpMediaTypeNotSupportedException     → 415 Unsupported Media Type
MissingServletRequestParameterException → 400 Bad Request
TypeMismatchException                   → 400 Bad Request
NoHandlerFoundException                 → 404 Not Found

코드 예제

커스텀 예외 정의

JAVA
// 비즈니스 예외
public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

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

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

public class EntityNotFoundException extends BusinessException {
    public EntityNotFoundException(String entity, Long id) {
        super(ErrorCode.ENTITY_NOT_FOUND);
    }
}

이어서 나머지 구현 부분입니다.

JAVA
// 에러 코드 열거형
public enum ErrorCode {
    ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다"),
    DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "이미 존재하는 리소스입니다"),
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다"),
    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다");

    private final HttpStatus status;
    private final String message;

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

    // getter
}

컨트롤러 로컬 @ExceptionHandler

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

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

    // 이 컨트롤러에서만 동작하는 예외 핸들러
    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
    }
}

글로벌 @ControllerAdvice

JAVA
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 비즈니스 예외 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException ex) {
        ErrorCode errorCode = ex.getErrorCode();
        return ResponseEntity
            .status(errorCode.getStatus())
            .body(new ErrorResponse(errorCode.name(), errorCode.getMessage()));
    }

이어서 각 예외 타입별 처리 메서드를 정의합니다.

JAVA
    // 검증 실패
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            fieldErrors.put(error.getField(), error.getDefaultMessage())
        );

        return ResponseEntity
            .badRequest()
            .body(new ErrorResponse("VALIDATION_FAILED", "입력값 검증 실패", fieldErrors));
    }

이어서 각 예외 타입별 처리 메서드를 정의합니다.

JAVA
    // 타입 변환 실패 (PathVariable, RequestParam)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
        String message = String.format("'%s' 파라미터의 값 '%s'이(가) 올바르지 않습니다",
            ex.getName(), ex.getValue());
        return ResponseEntity
            .badRequest()
            .body(new ErrorResponse("TYPE_MISMATCH", message));
    }

    // 기타 모든 예외 (최후의 방어선)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
        log.error("처리되지 않은 예외", ex);
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "서버 내부 오류가 발생했습니다"));
    }
}

이어서 나머지 구현 부분입니다.

JAVA
// 에러 응답 DTO
public record ErrorResponse(
    String code,
    String message,
    Map<String, String> fieldErrors
) {
    public ErrorResponse(String code, String message) {
        this(code, message, null);
    }
}

ResponseStatusException

간단한 경우에는 커스텀 예외 클래스 없이 직접 사용할 수 있습니다.

JAVA
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new ResponseStatusException(
            HttpStatus.NOT_FOUND,
            "ID " + id + "인 사용자를 찾을 수 없습니다"
        ));
}

ProblemDetail (RFC 7807) — Spring 6+

JAVA
@RestControllerAdvice
public class GlobalExceptionHandler {

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

응답 예시:

JSON
{
  "type": "https://api.example.com/errors/not-found",
  "title": "리소스를 찾을 수 없습니다",
  "status": 404,
  "detail": "ID 123인 사용자를 찾을 수 없습니다",
  "instance": "/api/users/123",
  "errorCode": "ENTITY_NOT_FOUND",
  "timestamp": "2026-03-19T12:00:00Z"
}

ProblemDetail을 기본 에러 응답 형식으로 활성화하려면:

YAML
spring:
  mvc:
    problemdetail:
      enabled: true

@ControllerAdvice 범위 제한

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

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

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

여러 @ControllerAdvice 간 순서

JAVA
@RestControllerAdvice
@Order(1) // 높은 우선순위
public class SecurityExceptionHandler {
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<?> handleAccessDenied(AccessDeniedException ex) { ... }
}

@RestControllerAdvice
@Order(2) // 낮은 우선순위
public class GeneralExceptionHandler {
    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleAll(Exception ex) { ... }
}

주의할 점

1. @ExceptionHandler(Exception.class)로 모든 예외를 잡으면 스프링 기본 에러 처리가 무력화된다

최상위 Exception.class를 잡으면 404, 405, 415 같은 스프링 기본 에러 응답까지 가로채서 동일한 에러 형식으로 반환됩니다. 클라이언트가 존재하지 않는 URL을 호출해도 404 대신 500 에러를 받게 되어, API 소비자가 혼란을 겪습니다. 구체적인 예외부터 처리하고, Exception 핸들러는 최후의 안전망으로만 사용하세요.

2. @ControllerAdvice가 API와 뷰 컨트롤러에 모두 적용되어 응답 형식이 꼬인다

@ControllerAdvice는 기본적으로 모든 컨트롤러에 적용됩니다. API 전용 에러 핸들러가 Thymeleaf 뷰 컨트롤러에도 적용되면, HTML 페이지 대신 JSON 에러가 반환됩니다. @ControllerAdvice(basePackages = "com.example.api")로 범위를 한정하세요.

3. 프로덕션에서 예외 응답에 스택 트레이스를 포함하면 보안 취약점이 된다

개발 편의를 위해 e.getStackTrace()를 응답에 포함하면, 프로덕션에서 패키지 구조, 클래스명, 라이브러리 버전 등 내부 정보가 외부에 노출됩니다. 공격자가 이 정보로 취약점을 파악할 수 있으므로, 스택 트레이스는 로그에만 기록하고 클라이언트에는 일반화된 에러 메시지만 반환하세요.

정리

  • 로컬 @ExceptionHandler가 글로벌 @ControllerAdvice보다 우선합니다
  • @ControllerAdvice에서 비즈니스 예외, 검증 예외, 기타 예외를 분리해서 처리하면 깔끔합니다
  • ResponseStatusException은 간단한 에러에 커스텀 예외 없이 사용할 수 있습니다
  • ProblemDetail(RFC 7807) 은 표준화된 에러 응답 형식으로, Spring 6+에서 네이티브 지원됩니다
  • 예외 핸들러에서 Exception.class를 잡는 최후의 방어선을 반드시 만들어두세요
댓글 로딩 중...