WAS 의 예외 처리 방식
우선적으로 서블릿 컨테이너가 예외 처리를 하는 방식을 알아야합니다.
서블릿 컨테이너는 2가지 방식으로 예외를 처리합니다.
- Exception 을 처리.
- response.sendError(상태코드, 메세지) 를 처리.
1. Exception 을 처리하는 경우
- WAS(서블릿 컨테이너) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러 의 흐름으로 진행됩니다.
- 컨트롤러에서 발생한 Exception 이 처리되지 않고 다시 WAS 까지 올라온다면 500 에러로 처리되고 WAS 자체의 오류 페이지를 화면에 보여줍니다.
2. response.sendError() 를 처리하는 경우
- 컨트롤러에서 response.sendError() 를 호출한 경우에는 Exception 이 발생하는 것은 아니지만 서블릿 컨테이너에게 예외가 발생했다는 것을 전달 가능합니다.
- WAS 에서 사용자에게 응답하기 전에 response 객체의 sendError() 메서드 호출 여부를 확인하고 호출되었다면 그 정보를 읽어 그에 맞게 WAS 자체의 오류 페이지로 응답합니다.
스프링이 예외 처리하는 방식
WAS 가 제공하는 오류 페이지들을 그대로 제공하는 것은 보기에 좋지 않습니다.
서블릿의 오류 페이지 등록 기능을 이용하면 직접 만든 화면을 보여줄 수 있습니다.
과거에는 아래와 같이 xml 파일로 등록을 했습니다.
<web-app>
<error-page>
<error-code>404</error-code>
<location>/error/404.html</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/error/500.html</location>
</error-page>
<error-page>
<exception-type>java.lang.RuntimeException</exception-type>
<location>/error/500.html</location>
</error-page>
</web-app>
최근에는 스프링 부트를 이용하기 때문에 스프링 부트 기능을 이용해봅시다.
1. ErrorPage
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error/500");
ErrorPage errorPageRuntimeException = new ErrorPage(RuntimeException.class, "/error/500");
factory.addErrorPages(errorPage404, errorPage500, errorPageRuntimeException);
}
}
404 에러가 발생하면 → “error/404” 요청
500 에러가 발생하면 → “error/500” 요청
RuntimeException 발생하면 → “error/500” 웹 요청
→ 요청을 처리해줄 컨트롤러와 오류에 맞게 보여줄 View 를 만들어야 합니다.
2. BasicErrorController
WebServerFactoryCustomizer 인터페이스를 구현하여 ErrorPage 들을 등록해주고 그것들을 처리해줄 컨트롤러를 만들어줘야했습니다.
스프링 부트는 이런 과정을 하지 않도록 “/error” 경로를 가지는 ErrorPage 를 제공합니다.
WAS 로 Exception 이 전달되거나 response.sendError() 가 호출되었으면 “/error” 경로가 호출됩니다.
이 때, “/error” 를 처리해줄 BasicErrorController 라는 것도 함께 등록됩니다.
BasicErrorController 에는 각 상태코드에 맞게 View 경로가 등록되어 있습니다.
개발자는 상태코드에 맞게 화면을 만들어서 넣어두면 됩니다. ex) 404.html, 4xx.html, 500.html
BasicErrorController 가 View 를 선택하는 우선순위는 아래와 같습니다.
- resources/templates/ 아래의 뷰 템플릿
- 404.html 과 같은 구체적인 오류 페이지 파일
- 4xx.html 과 같이 덜 구체적인 오류 페이지 파일
- resources/static/ 아래의 정적 리소스
- 404.html 과 같은 구체적인 오류 페이지 파일
- 4xx.html 과 같이 덜 구체적인 오류 페이지 파일
- 적용 대상이 없을 때는 error.html
BasicErrorController 는 API 로도 예외처리를 지원합니다.
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
두 메서드 모두 “/error” 경로를 처리합니다.
errorHtml 메서드는 HTTP 요청 헤더가 text/html 인 경우에 호출됩니다. 지금까지 위에서 설명한 경우에 해당합니다.
그 외의 경우는 error 메서드가 호출됩니다.
3. ExceptionResolver
위에서 WAS 까지 Exception 이 던져지면 500으로 처리한다고 설명하였습니다.
그런데 모든 Exception 을 500으로 처리할 수는 없습니다. 경우에 따라 400, 404 로 바꿔서 처리하고 싶으면 어떻게 해야할까요?
HandlerExceptionResolver 인터페이스를 구현하고, 구현한 클래스를 설정 파일에 등록하면됩니다.
public class CustomExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView();
}
} catch (IOException e) {
}
return null;
}
}
그 후, WebMvcConfigurer 를 구현한 WebConfig 에 등록합니다.
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new CustomExceptionResolver());
}
ExceptionResolver 에서 반환 값에 따라 어떤 식으로 동작하는지 알아봅시다.
- 빈 ModelAndView(): 뷰를 렌더링 하지 않고, 그냥 정상 흐름으로 WAS 에 전달.
- 값이 있는 ModelAndView: 지정한 뷰를 렌더링.
- null: 다음 ExceptionResolver 를 찾고, 없으면 예외를 서블릿 밖으로 던짐.
조금 더 나아가봅시다. 현재 CustomExceptionResolver 는 response.sendError() 로 IllegalArgumentException 를 400 에러로 변환해줬습니다.
이 때, 빈 ModelAndView 를 반환하여서 WAS 로 정상 응답으로 가지만 response.sendError() 를 호출했으니 다시 “/error” 가 호출되고 BasicErrorController 가 호출될 것입니다.
매번 이렇게 WAS 에서 “/error” 를 호출하여서 예외를 처리하는 것은 복잡합니다.
CustomExceptionResolver 내에서 예외를 마무리하도록 해봅시다.
public class CustomExceptionResolver implements HandlerExceptionResolver {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
if (ex instanceof CustomException) {
String acceptHeader = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
if ("application/json".equals(acceptHeader)) {
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);
return new ModelAndView();
} else {
//TEXT/HTML
return new ModelAndView("error/404");
}
}
} catch (IOException e) {
}
return null;
}
}
위와 같이 코드를 수정하면 예외를 WAS 로 전달하지도 않고, response.sendError() 를 호출하지도 않았습니다. HTTP 요청 헤더의 accept 값이 application/json 인 경우 json 으로 오류 정보를 보내주고, text/html 인 경우 오류 페이지를 보여줍니다.
즉, CustomExceptionResolver 내에서 예외 처리를 마무리한 것을 볼 수 있습니다.
하지만 이런 식으로 ExceptionResolver 를 구현하는 것은 굉장히 반복적이고 귀찮은 일입니다. 그렇기 때문에 스프링 부트에서 기본적으로 ExceptionResolver 를 제공합니다.
- ExceptionHandlerExceptionResolver
- ResponseStatusExceptionResolver
- DefaultHandlerExceptionResolver
위 3가지 ExceptionResolver 를 제공하고, 동작 우선순위도 위에 나열된 순서대로입니다.
ExceptionHandlerExceptionResolver 가 가장 중요하니 제일 나중에 설명하도록 하고, ResponseStatusExceptionResolver 를 알아보도록 하겠습니다.
3.1 ResponseStatusExceptionResolver
예외에 따라서 상태코드를 지정해주는 역할을 합니다.
- @ResponseStatus 가 지정된 예외
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청")
public class CustomException extends RuntimeException {
}
CustomException 이 컨트롤러 밖으로 나가서 디스패처 서블릿으로 전달되면 ResponseStatusExceptionResolver 가 @ResponseStatus 를 확인하고 상태 코드와 메시지를 담습니다.
ResponseStatusExceptionResolver 내부 코드를 보면 response.sendError() 를 통해서 호출합니다. 그렇기 때문에 결국 WAS 까지 예외가 전달되고 “/ error” 경로를 내부 요청하여 처리합니다.
- ResponseStatusException 의 경우
@ResponseStatus 는 사용자가 새롭게 정의한 Exception 인 경우에만 사용할 수 있습니다. 표준 예외인 경우에는 아래와 같이 사용할 수 있습니다.
@GetMapping("/error")
public String responseStatusException() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "잘못된 요청", new IllegalArgumentException());
}
3.2 DefaultHandlerExceptionResolver
스프링 내부에서 발생하는 예외들을 해결합니다.
예를 들자면, 파라미터를 바인딩할 시 타입이 맞지 않을 때, TypeMismatchException 이 발생합니다.
이것을 처리하지 않으면 앞서 이야기한대로 WAS 까지 올라가고 500 에러가 발생합니다.
파라미터 바인딩 시의 오류는 대부분 사용자의 실수로 인한 오류이기에 400 에러로 반환해주는 것이 옳습니다.
DefaultHandlerExceptionResolver 가 내부에서 이런 예외들을 HTTP 스펙에 맞는 에러로 처리해줍니다.
DefaultHandlerExceptionResolver 내부 코드를 보면 response.sendError() 를 호출하여 상태코드를 변환해줍니다. 그렇기에 WAS 에서 다시 “/error” 를 호출하고 BasicErrorController 를 호출하는 과정을 거칩니다.
3.3 ExceptionHandlerExceptionResolver
HTML 화면을 제공할 때는 BasicErrorController 나 HandlerExceptionResolver 를 사용하는 게 편합니다. API 예외처리 시에는 HandlerExceptionResolver 를 구현하면 response 에 직접 응답 데이터를 넣어줘야하는 불편함이 있습니다. 또, ModelAndView 를 반환해야하는데 이는 API 와는 맞지 않는 형태입니다.
또, 예외를 컨트롤러마다 다르게 적용하고 싶은 경우 처리하기 힘듭니다.
@ExceptionHandler 를 사용하면 해결할 수 있습니다.
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
return new ErrorResult("잘못된 요청", e.getMessage());
}
동작 흐름은 아래와 같습니다.
- 컨트롤러 호출 결과 IllegalArgumentException 예외가 던져집니다.
- ExceptionResolver 가 작동하고 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 호출됩니다.
- ExceptionHandlerExceptionResolver 가 해당 컨트롤러에 IllegalArgumentException 를 처리할 수 있는 @ExceptionHandler 가 있는지 확인합니다.
- illegalExHandle 메서드가 호출됩니다.
- ExceptionHandlerExceptionResolver 가 예외를 처리했으므로 원래는 200 처리되는 게 맞지만, @ResponseStatus(HttpStatus.BAD_REQUEST) 로 인해 400 처리로 변환됩니다.
4. ControllerAdvice
- 등장 배경
- @ExeptionHandler 는 해당 컨트롤러에서 발생하는 예외만 처리 가능합니다.
- 정상 로직과 예외 로직이 하나의 컨트롤러에 공존하기에 유지 보수에 좋지 않습니다.
- @ExeptionHandler 는 해당 컨트롤러에서 발생하는 예외만 처리 가능합니다.
@ControllerAdvice 를 알아봅시다.
대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 를 지정해주는 역할입니다.
대상으로 지정하지 않으면 모든 컨트롤러에 해당됩니다. 아래와 같이 대상을 지정할 수 있습니다.
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
'이론 > Spring' 카테고리의 다른 글
Spring DI 란? (0) | 2022.12.18 |
---|---|
빈 스코프에 따른 생명 주기 (0) | 2022.11.24 |
[Spring Boot] SpringApplication 파일 위치 (0) | 2022.01.20 |