스프링 MVC 2편 (9), API 예외 처리

2025. 12. 23. 02:03Spring RoadMap/Spring MVC

백엔드 웹 개발 핵심 기술, 스프링 MVC - API 예외 처리

HTML 페이지 요청 시 오류가 나면 오류 페이지만 보여주면 된다. 하지만 API는 다르다. 클라이언트는 오류가 발생해도 JSON 데이터를 원한다.

  • HTML: 500 Error Page (사람이 봄)
  • API: {"code": "BAD_REQUEST", "message": "잘못된 입력입니다."} (기계/JS가 처리함)

이번 글에서는 스프링이 제공하는 강력한 API 예외 처리 메커니즘인

HandlerExceptionResolver@ExceptionHandler를 상세히 알아본다.


1. BasicErrorController의 한계

이전 섹션에서 배운 BasicErrorController는 API 예외 처리에도 사용할 수 있다. 요청 헤더(Accept)가 application/json이면 JSON을 반환하기 때문이다.

하지만 이 방식은 실무에서 사용하기 어렵다.

  • BasicErrorController는 단순히 status, error, path 같은 획일적인 JSON만 반환한다.
  • API마다, 또는 예외 종류마다 서로 다른 스펙의 에러 메시지가 필요할 때 유연하게 대처할 수 없다. (예: 회원 가입 실패 시엔 fieldErrors가 필요하고, 서버 오류 시엔 message만 필요함)

2. HandlerExceptionResolver (핵심 원리)

스프링 MVC는 예외 처리를 위해 HandlerExceptionResolver라는 인터페이스를 제공한다.

 

기존 흐름

컨트롤러(예외 발생!) -> 서블릿 -> WAS (상태 코드 500)

 

Resolver 적용 흐름

컨트롤러(예외 발생!) -> ExceptionResolver -> 서블릿 -> WAS (정상 응답 or 상태 코드 변환)

 

HandlerExceptionResolver 인터페이스

public interface HandlerExceptionResolver {
    ModelAndView resolveException(
        HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
}
  • 반환값 ModelAndView:
    • 빈 ModelAndView: 뷰를 렌더링하지 않고 정상 흐름으로 서블릿 리턴 (마치 예외가 없었던 것처럼 처리가능).
    • ModelAndView 지정: 해당 뷰를 렌더링 (HTML 오류 페이지).
    • null: 다음 Resolver를 찾아서 실행. 없으면 예외를 서블릿 밖으로 던짐(기존처럼 500).

2-1. 직접 Resolver 만들어보기 (이해용)

예를 들어, UserException이 발생하면 500이 아니라 400 상태 코드와 JSON을 내려주고 싶다고 가정하자.

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        
        try {
            if (ex instanceof UserException) {
                log.info("UserException resolver to 400");
                
                // 1. HTTP 상태 코드 변경 (500 -> 400)
                // response.sendError()를 쓰면 WAS가 다시 오류 페이지를 요청해버림.
                // 여기서는 예외를 여기서 "먹어버리고" 정상 응답으로 끝내는 것이 목표.
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST); 

                // 2. 응답 데이터 설정 (JSON)
                if ("application/json".equals(request.getHeader("accept"))) {
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());

                    String result = objectMapper.writeValueAsString(errorResult);

                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);
                    
                    // 빈 ModelAndView 반환 -> 뷰 렌더링 안 하고 끝냄!
                    return new ModelAndView(); 
                } else {
                    // HTML 요청이면 templates/error/400.html 렌더링
                    return new ModelAndView("error/400");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null; // 다른 예외는 다음 리졸버로 패스
    }
}

 

WebConfig 등록

@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new UserHandlerExceptionResolver());
}

 

단점: response.getWriter()를 직접 쓰고, JSON 변환도 수동으로 해야 한다. 너무 복잡하다. 그래서 스프링이 기본 제공하는 리졸버들을 사용한다.


3. 스프링이 제공하는 ExceptionResolver

스프링 부트는 다음 3가지를 기본으로 등록한다. (우선순위 순서)

  1. ExceptionHandlerExceptionResolver: (중요) @ExceptionHandler 처리.
  2. ResponseStatusExceptionResolver: HTTP 상태 코드 지정.
  3. DefaultHandlerExceptionResolver: 스프링 내부 예외 처리.

4. ResponseStatusExceptionResolver

예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.

4-1. @ResponseStatus 애노테이션

개발자가 직접 만든 예외 클래스에 붙여서 사용한다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

이 예외가 터지면, 리졸버가 response.sendError(400, ...)를 호출해서 400 오류로 바꿔준다. (메시지는 messages.properties에서 조회도 가능하다.)

4-2. ResponseStatusException

라이브러리 예외 등 코드를 수정할 수 없는 예외에 상태 코드를 붙이고 싶을 때 사용한다.

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}

5. DefaultHandlerExceptionResolver

스프링 내부에서 발생하는 예외를 처리한다. 대표적인 예가 파라미터 바인딩 시 타입이 안 맞을 때 발생하는 TypeMismatchException이다.

  • 원래대로라면: 예외가 발생했으니 500 에러가 나가야 한다.
  • 하지만: 클라이언트가 타입을 잘못 보낸 것이므로 400 에러가 맞다.
  • 동작: 이 리졸버가 TypeMismatchException을 잡아서 400 오류(response.sendError)로 바꿔준다.

6. @ExceptionHandler (최종 보스)

실무 API 예외 처리의 99%는 이것으로 해결한다. UserException이 터지면 JSON 포맷 A로 주고, RuntimeException이 터지면 포맷 B로 주고 싶다. 위의 방법들(sendError)은 WAS까지 갔다가 다시 오류 페이지 경로를 호출하는 등 복잡하고, 응답 데이터를 섬세하게 컨트롤하기 어렵다.

@ExceptionHandler는 예외가 발생했을 때 WAS까지 가지 않고, 해당 메서드에서 예외를 잡아서 정상 흐름처럼 응답을 바로 내려주는 방식이다.

6-1. ErrorResult 객체 정의

API 스펙에 맞는 에러 객체를 만든다.

@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message;
}

6-2. 컨트롤러에 핸들러 적용

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
        if (id.equals("bad")) {
            throw new IllegalArgumentException("입력 값 오류");
        }
        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }
        return new MemberDto(id, "hello " + id);
    }

    // 1. 특정 예외 처리 (IllegalArgumentException)
    @ResponseStatus(HttpStatus.BAD_REQUEST) // 400 설정 (이게 없으면 200 OK가 나감!)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    // 2. 사용자 정의 예외 처리
    // ResponseEntity를 쓰면 HTTP 상태 코드와 헤더를 동적으로 설정 가능
    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    // 3. 공통 예외 처리 (Exception - 최상위)
    // 위의 구체적인 예외들을 제외한 나머지 모든 예외가 여기서 잡힘
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
}

 

동작 원리 (ExceptionHandlerExceptionResolver)

  1. 컨트롤러에서 IllegalArgumentException 발생.
  2. ExceptionHandlerExceptionResolver가 동작.
  3. 컨트롤러 안에 @ExceptionHandler(IllegalArgumentException.class)가 붙은 메서드가 있는지 찾음.
  4. 찾았으면 그 메서드(illegalExHandle)를 호출.
  5. 메서드가 반환한 ErrorResult 객체를 JSON으로 변환해서 HTTP 응답 바디에 넣음.
  6. 중요: 여기서 요청 처리가 끝남. (WAS로 예외를 던지지 않고, 정상 응답인 것처럼 끝냄)

 


7. @ControllerAdvice (분리)

위처럼 컨트롤러 안에 예외 처리 로직을 넣으면, 컨트롤러 코드가 너무 지저분해진다. @ControllerAdvice를 사용하면 예외 처리 로직만 별도 클래스로 분리할 수 있다.

@Slf4j
@RestControllerAdvice(basePackages = "hello.springmvc.web") // 대상 패키지 지정 가능
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
}
  • @ControllerAdvice: 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.
  • @RestControllerAdvice: @ControllerAdvice + @ResponseBody와 같다. 응답을 JSON으로 내릴 때 편리하다.
  • 대상 지정: annotations, basePackages, assignableTypes(클래스) 등으로 적용 범위를 제한할 수 있다.

 


8. 정리

API 예외 처리는 이것만 기억하면 된다.

  1. HTML 오류: BasicErrorController가 처리하게 둔다. (500.html 등)
  2. API 오류:
    • HandlerExceptionResolver는 너무 어렵다. (원리만 이해하자)
    • @ExceptionHandler를 사용해서 예외를 잡아 JSON으로 변환하여 반환한다.
    • 이때 @RestControllerAdvice를 사용하여 예외 처리 로직을 컨트롤러와 완전히 분리한다.

이제 Vue.js 프론트엔드에서 어떤 예외가 발생하더라도, 백엔드는 일관된 포맷(code, message)의 JSON을 내려줄 수 있게 되었다. 이것이 실무 API 개발의 정석이다.