2025. 12. 23. 02:03ㆍSpring 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가지를 기본으로 등록한다. (우선순위 순서)
- ExceptionHandlerExceptionResolver: (중요) @ExceptionHandler 처리.
- ResponseStatusExceptionResolver: HTTP 상태 코드 지정.
- 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)
- 컨트롤러에서 IllegalArgumentException 발생.
- ExceptionHandlerExceptionResolver가 동작.
- 컨트롤러 안에 @ExceptionHandler(IllegalArgumentException.class)가 붙은 메서드가 있는지 찾음.
- 찾았으면 그 메서드(illegalExHandle)를 호출.
- 메서드가 반환한 ErrorResult 객체를 JSON으로 변환해서 HTTP 응답 바디에 넣음.
- 중요: 여기서 요청 처리가 끝남. (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 예외 처리는 이것만 기억하면 된다.
- HTML 오류: BasicErrorController가 처리하게 둔다. (500.html 등)
- API 오류:
- HandlerExceptionResolver는 너무 어렵다. (원리만 이해하자)
- @ExceptionHandler를 사용해서 예외를 잡아 JSON으로 변환하여 반환한다.
- 이때 @RestControllerAdvice를 사용하여 예외 처리 로직을 컨트롤러와 완전히 분리한다.
이제 Vue.js 프론트엔드에서 어떤 예외가 발생하더라도, 백엔드는 일관된 포맷(code, message)의 JSON을 내려줄 수 있게 되었다. 이것이 실무 API 개발의 정석이다.
'Spring RoadMap > Spring MVC' 카테고리의 다른 글
| 스프링 MVC 2편 (11 - 마지막), 파일 업로드 (0) | 2025.12.23 |
|---|---|
| 스프링 MVC 2편 (10), 스프링 타입 컨버터 (0) | 2025.12.23 |
| 스프링 MVC 2편 (8), 예외 처리와 오류 페이지 (1) | 2025.12.23 |
| 스프링 MVC 2편 (7), 로그인 2 - 필터, 인터셉터 (0) | 2025.12.23 |
| 스프링 MVC 2편 (6), 로그인 1 - 쿠키, 세션 (0) | 2025.12.21 |