개요
HTML 은 BasicErrorController를 통해서 오류 페이지를 뿌려 주면 되지만 JSON 같은 API는 하나의 오류페이지로 퉁 칠순 없다 각 상황에 맞는 오류 응답 스펙을 정하고 JSON으로 데이터를 내려 줘야 한다
만일 API 컨트롤러에서 오류가 발생하면 JSON 형식으로 응답이 나가야 하는데
<!DOCTYPE HTML>
<html>
<head>
</head>
<body>
...
</body>
이런 형식으로 응답이 나가면 안된다
이렇게 응답이 나가는 이유는 BasicController 에 응답이 도착할 때 Client의 Accept Header 에따라서 BasicErrorController 가 작동하는 방식이 달라지기 때문이다
스프링은 이러한 특정 AcceptHeader (여기서는 application/json) 을 조건으로 RequestMapping 하는 기능도 제공한다
BasicErrorController + @RequestMapping(produce)
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String,Object>> errorPage500Api(HttpServletRequest request,
HttpServletResponse response) {
log.info("API errorPage 500");
Map<String, Object> result = new HashMap<>();
Exception ex = (Exception)request.getAttribute(ERROR_EXCEPTION);
result.put("status", request.getAttribute(ERROR_STATUS_CODE));
result.put("message", ex.getMessage());
Integer statusCode = (Integer) request.getAttribute(ERROR_STATUS_CODE);
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
@RequestMapping에 produce 를 사용해서 사용자의 Accept Header의 Media 값을 다루면 된다
참고
Content-Type 과 관련한 @RequestMapping 인자값은 consumer 다
produce = Accept Header
consumer = Content-Type
이렇게 하면 Spring이 제공하는 BasicController가 AccpetHeader를 보고 JSON 응답이 나가야 되는 것을 인지하고
그거에 맞는 BasicErrorController를 호출하게 된다
실제로 스프링이 기본 제공하는 BasicController 코드를 보면 다음과 같다
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
/error 동일한 경로를 처리하는 errorHtml() , error() 두 메서드를 확인할 수 있다
- errorHtml() = produces = MediaType.TEXT_HTML_VALUE
= 클라이언트 요청의 Accept 해더 값이 text/html 인 경우에 호출 된다 - error() : 그외 경우에 호출되고 ResponseEntity로 HTTP Body에 JSON 데이터를 반환한다
호출 결과는 HTML 형식이 아닌 다음과 같은 JSON 형식으로 나가게 된다
{
"timestamp": "2021-04-28T00:00:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.RuntimeException",
"trace": "java.lang.RuntimeException: 잘못된 사용자\n\tat
hello.exception.web.api.ApiExceptionController.getMember(ApiExceptionControlle
r.java:19...,
"message": "잘못된 사용자",
"path": "/api/members/ex"
}
이렇게 스프링 부트는 BasicErrorController 가 제공하는 기본 정보들을 활용해서 오류 API를 생성해 준다.
하지만 문제가 있다
첫 번째
여전히 예외가 터져서 WAS까지 예외가 올라가면 무조건 500 Error로 고정시킨다
만일 사용자가 입력값을 잘못 입력해서 터지는 TypeMisMatchException 이 터져도 WAS는 상태코드를 500으로 고정 시킨다
(보통 사용자 실수로 유발한 상태코드는 400번대이다 )
두 번째
Controller에서 예외가 터지거나 , response.sendError를 하면 BasicErrorController를 호출하기 위해서 다시 WAS까지 올라가고 WAS에서 다시 url에 맞는 곳까지 다시 내려가야 한다
너무 복잡하고 비효율 적이다
API 예외 처리 - HandlerExceptionResolver 시작
위에 말한 두 가지 문제를 해결하기 위해서 등장한 게 HandlerExceptionResolver다
이름에서부터 벌써 어떤 역할을 하는지 알 수 있듯이 예외를 처리하는 역할을 한다
ExceptionResolver 적용 전

컨트롤러에서 Exception이 발생했으면 이전에는 WAS까지 예외가 전달되고 Interceptor의 afterCompletion 은
예외가 이미 DispatcherServlet 밖으로 던져진 뒤에 호출된다
그래서 afterCompletion에서 예외를 먹지도 못한다
ExceptionResolver 적용 후

하지만 ExceptionResolver가 도입되면서 핸들러에서 예외가 발생하면 중간에 ExceptionResolver가 먼저 예외를 확인해 본다
ExceptionResolver가 예외를 확인하고 자신이 예외를 처리할 수 있으면 예외를 해결한 후 정상 흐름으로 다시 바꾼다
만일 예외를 해결할 수 없으면 WAS로 예외를 던진다
참고
ExceptionResolver로 예외를 해결해도 postHandle() 은 호출 안된다
postHandle은 예외가 나면 그냥 호출이 안되는 것으로 알고 있자
ExceptionResolver의 동작 방식
먼저 ExceptionResolver 의 인터페이스를 확인해 보자
public interface HandlerExceptionResolver {
ModelAndView resolveException(
HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex);
}
ExceptionResolver는 반환값에 따라 동작을 다르게 한다
- 빈 ModelAndView 반환: 뷰를 렌더링 하지 않고 예외를 해결했다 판단해 정상 흐름으로 서블릿이 리턴된다
- ModelAndView 지정 : View 나 Model 등의 정보를 반환하면 뷰를 렌더링 한다
- null : null을 반환하면 다음 ExceptionResolver를 찾아서 실행하는데 만약 ExcpetionResolver가 없으면 예외 처리가 안되고 예외를 서블릿 밖으로 던진다
이러한 동작 방식을 통해서 반환값만 잘 던져주면 상태 코드를 바꿀 수도 , 바로 예외 페이지를 불러올 수도 있다
@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
log.info("IllegalArgumentException resolver to 400");
response.sendError(HttpServletResponse.SC_BAD_REQUEST,
ex.getMessage());
return new ModelAndView();
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
IllegalArgumentException 발생 (default 상태코드 : 500 ) --> ExecptionResolver가 예외를 먹음 --> 상태코드를 바꾸고 --> 빈 ModelAndView를 반환해서 정상흐름 -> WAS까지 올라가서 WAS가 response를 보고 400 에러 상태코드로 응답 생성
정리
ExceptionResolver를 사용하면 ExceptionResolver 가 WAS까지 예외를 올라가지 않고 자기가 해결할 수 있는 예외를 해결한다 그래서 예외를 발생해도 서블릿 컨테이너까지 예외가 전달되지 않는다
결과적으로 WAS 입장에선 정상 처리 된 거고 이렇게 예외를 이곳에서 모두 처리할 수 있는 것이 핵심이다
다 좋은데.. 직접 구현하려고 하니 너무 복잡하다 여태까지 그렇듯 스프링은 우리의 편의를 많이 봐준다
지금부터는 스프링이 제공하는 ExceptionResolver 들을 알아보자
스프링이 제공하는 ExceptionResolver
스프링은 우리에게 3개의 ExceptionResolver를 제공하는데 가장 편리하고 가장 많이 쓰는 건 첫 번째 ExceptionResolver다
- ExceptionHandlerExceptionResolver
- ResponseStatusExceptionResolver
- DefaultHandlerExcpetionResolver
차근차근 하나씩 알아보자
순서는 2번 3번 1번 순으로 알아보자!!
ResponseStatusExceptionResolver
ResponseStatusExceptionResolver 은 예외에 따라서 HTTP 상태코드를 바꿔 주는 역할을 한다
해당 Resolver는 2가지 대상을 Target으로 잡아 처리해 준다
- @ResponseStatus 가 달려 있는 예외
- ResponseStatusException 예외
@ResposneStatus
특별한 건 아니고 이전에 ExceptionResolver에서 response.sendError + 빈 ModelAndView를 반환으로 상태코드를 바꾸는걸
에노테이션으로 함축시킨 거다
흐름
에노테이션이 있네 → 내부에서 ExceptionResolver 호출 → 상태코드 바꾸고 response.sendError → 빈 껍데기 ModelAndView 반환 (= Exception 먹는 것) → WAS → BasicErrorController → 오류 응답
코드
@ResponseStatus(code= HttpStatus.BAD_REQUEST,reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
@ResponseStatus(code= HttpStatus.BAD_REQUEST,reason = "error.bad")
ResponseStatusException
@ResponseStatus는 편리하긴 하지만 하나의 단점이 있다
개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다 그니까 우리가 만든 Exception에는 어노테이션을 붙일 수 있지만 라이브러리가 만든 코드에는 못 붙이는 단점을 가지고 있다
그럴 때를 위해서 스프링은 ResponseStatusException 이란 예외를 제공한다
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2()
{
throw new ResponseStatusException(HttpStatus.NOT_FOUND,"error.bad",new IllegalArgumentException());
}
이렇게 ResponseStatusException 예외를 던질 때 상태코드와 , Message , 그리고 어떤 예외를 먹을 건지 던저주게 되면
위에서 배운 ExceptionResolver 가 동작해서 상태코드와 예외를 해결해 준다
앞서 ExceptionResolver의 예외 동작을 먼저 본 이유가 이거다
Spring은 Servlet 위에서 동작하고 있고 Servlet으로 개발을 하면 힘든 부분을 편하게 개발자를 위해서 제공해 주기 때문이다
DefaultHandlerExceptionResolver
스프링 내부에서 발생하는 스프링 예외를 해결한다
대표적으로 파라미터 타입이 안 맞으면 TypeMismatchExcpetion 이 발생하는데 , 이 경우 예외가 발생하기 때문에 그냥 두면 서블릿 컨테이너까지 올라가서 500으로 응답한다
이런 거는 클라이언트가 잘못 보낸 건데 이럴 때 400으로 status code로 바꿔야 하는데 Spring은 DefaultHandlerException이 이런 TypeMissMatch 예외가 발생하면 상태코드를 500→400으로 바꾼다
즉 말이 길었지만 Spring은 DefautlHandlerExceptionResolver를 통해 잘 알려진 예외들을 500으로 처리하지 않고 그것에 맞는 상태 코드로 바꿔 준다
한 줄로 정리하면 "내부에서 터진 예외를 500 오류로 내보내는 게 아니라 HTTP Spec에 맞춰서 내보낸다"
@ExceptionHandler
사실 스프링 예외 처리는 @ExceptionHandler 하나만 쓰면 끝이다 특히 API 관련 RestController는 이걸로 예외처리를 한다
하지만 단순히 사용하는 게 아니라 그 내부 과정이 어떻게 동작하는지 알기 위해서 지금까지 처음부터 하나하나씩 따라와 본 것이다
스프링은 @ExceptionHandler를 제공한다 이 어노테이션을 달아두면 ExceptionHandlerExceptionResolver 가 동작을 한다 대부분 이 기능을 사용한다
사용법은 간단하다 코드를 통해 확인해 보자
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler(UserException.class)
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
log.error("[exceptionHandler] 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 exHandler(Exception e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("ex", "내부 오류");
마치 Controller처럼 선언해서 사용하면 된다
중간에 보면 어떤 거는 @ResponseStatus를 통해 statusCode를 지정해 준 것도 있고 아닌 것도 있는데 그건 해당 Handler의 반환값의 차이다
핸들러가 ResponseEntity를 반환하면 ResponseEntity의 기능을 통해서 상태코드를 바꾸면 된다 위에 코드에선 다음과 같이 사용했다
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
이렇게 반환해도 되고 @RestController 안에 서 해당 핸들러를 만드는 것이기 때문에 return에 객체를 반환하면 자동으로 @RestController 내부의 @ResponseBody에 의해서 바디에 객체를 JSON으로 반환한다
이때는 상태코드를 지정할 수 없음으로 어노테이션을 사용해서 상태 코드를 반환했다
참고
어노테이션이 편리하긴 한데 사실은 HttpServletResponse response를 통해서 상태코드를 반환해도 된다
하지만 SpringMVC 구조상 Servlet과 관련된 기술은 Dispatcher 서블릿에서만 쓰고 Controller는 Controller의 역할에 집중하는 게 바람직한 구조 같다
예제를 통한 정리
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class )
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
이런 핸들러가 있으면 다음과 같은 실행 흐름을 통해 동작한다
실행흐름
- 컨트롤러를 호출한 결과예외가 발생했어로 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
- 예외가 발생했어로 ExceptionResolver 가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행된다.
- ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는@ExceptionHandler 가 있는지 확인한다.
- illegalExHandle()를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.
- @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 HTTP 상태 코드 400으로 응답한다.
SpringMVC를 쓴다
- 적절한 위치에 적절한 이름의 html을 만들어 BasicController를 이용하자
API를 사용하는 서버이다
- @ExceptionHandler를 이용하자
ControllerAdvice
@ExceptionHandler를 이용해서 Controller에서 예외를 깔끔하게 처리할 수 있다
코드로 보자면 다음과 같다
@RestController
public class myRestController {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class )
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler(UserException.class)
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
log.error("[exceptionHandler] 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 exHandler(Exception e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("ex", "내부 오류");
}
@GetMappint("/")
public HttpResponseEntity<String> hi (){
return new HttpResponseEntity<>("안녕~",HttpStatus.BAD_REQUEST);
}
}
정상 코드와 예외 코드가 하나의 컨트롤러에 섞여 있다
그래서 @ControllerAdvice 또는 @RestControllerAdvice를 사용하면 둘을 분리할 수 있다
즉 예외처리 관련된 @ExceptionHadler을 하나의 파일에만 모아둔다 Controlle와 분리하는 것!!
@ControllerAdvice
- @ControllerAdvice는 대상으로 지정한 컨트롤러에 @ExceptionHandler @InitBinder 기능을 부여해 주는 역할을 한다
- @ControllerAdvice에 대상을 지정 안 하면 모든 컨트롤러에 적용된다
@RestControllerAdvice(basePackages = "package hello.exception.api")
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class )
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler(UserException.class)
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
log.error("[exceptionHandler] 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 exHandler(Exception e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("ex", "내부 오류");
}
}

'🌿 스프링 > 스프링 MVC 2편' 카테고리의 다른 글
| 파일 업로드 (0) | 2026.02.15 |
|---|---|
| 서블릿 예외 처리와 오류 페이지 (0) | 2026.02.15 |
| 서블릿 필터와 스블릿 인터셉터 (0) | 2026.02.15 |
| 로그인 처리- 쿠키,세션 (0) | 2026.02.14 |
| Validation (0) | 2026.02.14 |