이 글은 김영한님의 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 강의중 예외 처리를 블로그장 입맛대로 요약한 것이며 강의 자료 및 출처는 가장 아래에서 확인할 수 있습니다.
1.서블릿의 예외 처리
서블릿은 다음 두가지 방식으로 예외 처리를 한다.
1-1. Exception
@GetMapping("/error-ex")
public void errorEx(){
throw new RuntimeException("예외 발생!");
}
위 요청을 실행해보면 500에러가 나는것을 확인할 수 있다.
이는 예외가 발생해서 WAS까지 전파되고, WAS가 서버 내부에서 처리할 수 없는 오류가 발생한 것으로 생각하여 500으로 반환하기 때문이다.
1-2. response.sendError(HTTP 상태코드, 오류 메시지)
response.sendError()는 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.
이 메서드는 상태 코드와 오류 메시지를 추가할 수 있다.
@GetMapping("/error-404")
public void error404(HttpServletResponse response) throws IOException{
response.sendError(404, "404 오류!");
}
위 두가지 예외의 흐름은 아래와 같다.
WAS ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러
여기까지가 기본적인 예외 처리이다.
2.스프링 부트가 지원하는 예외 처리
스프링 부트는 서블릿 오류 페이지를 직접 등록할수도 있고, 기본으로 제공하는 것을 사용할수도 있다.
2-1. 오류 페이지 직접 등록
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
}
}
HTTP상태코드와 URL 경로를 파라미터에 두면, 오류가 발생할시 설정한 URL경로를 다시 호출한다.
예를들어, RuntimeException오류가 발생하면
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
WAS는 등록되어 있던 위 ErrorPage를 읽어들여, 다시 "/error-page/500"를 호출하게 된다.
따라서 "/error-page/500"을 호출하는 컨트롤러를 아래와 같이 생성한다면
@Slf4j
@Controller
public class ErrorPageController {
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response){
log.info("errorPage 500");
return "error-page/500";
}
}
직접 커스텀한 error-page/500페이지를 반환할 수 있게된다.
2-2. 오류 페이지 기본 등록
스프링 부트는 직접 등록도 가능하지만 위 과정을 모두 기본으로 제공한다.
직접 등록하는 경우는 아래와 같이 진행했는데
1. WebServerCustomizer를 만들고
2. 예외 종류에 따라서 ErrorPage를 추가하고
3. 예외 처리용 컨트롤러 ErrorPageController를 만듦
부트는 아래와 같이 기본적으로 제공한다
1. ErrorPage를 "/error" 경로로 자동으로 추가하고,
2. 예외 처리용 컨트롤러 BasicErrorController 를 자동으로 등록한다.
자동으로 등록되어 있는 BasicErrorController를 살펴보자.
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
...
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
HttpStatus status = getStatus(request);
Map<String, Object> model = Collections
.unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = resolveErrorView(request, response, status, model);
return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity<>(status);
}
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity<>(body, status);
}
...
}
요청 타입이 TEXT Value라면 스프링 부트가 자동등록한 "error" modelAndView를 리턴하는것을 확인할 수 있고,
다른 요청 타입의 경우 ResponseEntity를 반환하는 것을 확인할 수 있다.
(위에서 한가지 몰랐던점은, 요청 경로가 동일해도 produces 요청 타입이 다르면 동일한 경로설정이 가능한점이다.)
참고로 BasicErrorController는 기본적인 로직이 모두 개발되어 있기때문에, 제공하는 룰과 우선순위에 따라 등록하면 화면 또한 직접 커스텀한 화면을 등록할 수 있다.
1. 뷰 템플릿
resource/templates/error/500.html
resource/templates/error/5xx.html
2. 정적 리소스
resource/static/error/400.html
resource/static/error/404.html
resource/static/error/4xx.html
3. 적용 대상이 없을 때 뷰 이름
resource/templates/error.html
해당 위치에 HTTP 상태 코드 이름의 뷰 파일을 넣으면 된다.
만약 오류 처리 화면을 못 찾는다면, 스프링부트는 자동으로 whitelabel오류 페이지를 적용하는데,
사용하지 않으려면 아래 설정을 적용하면 된다.
server.error.whitelabel.enabled=false
결론적으로 스프링 부트를 사용한다면 위 두가지 방법중 하나를 사용하면 간편하게 오류 페이지 설정을 할 수 있으며, 두가지를 함께 사용이 가능하지만, 직접 등록한 ErrorPage가 선순위(스프링은 더 구체적인것이 먼저)를 가진다.
3.요청 흐름에 따른 필터 및 인터셉터 설정
위에서 설명한 내용을 정리하자면 흐름은 다음과 같다.
WAS ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러
먼저, 예외가 WAS까지 전파되고,
WAS (/error-page/500 요청) → 필터 → 서블릿 → 인터셉터 → 컨트롤러(/error-page/500) → View
WAS에서 요청한 핸들러를 찾아 View까지 이동하게 된다.
여기서 중요한점은 다음 두가지이다.
1. 웹 브라우저는 서버 내부에서 이런 일이 일어나는지 전혀 모른다.
2. 오류 페이지 경로로 필터,서블릿,인터셉터,컨트롤러가 모두 다시 호출된다.
만약, 로그인 인증 체크 같은 경우를 생각해보면, 이미 한번 필터나 인터셉터에서 로그인 체크를 완료하고, 또다시 호출된다면 매우 비효율적일것이다.
결국 필터나 인터셉터에서 클라이언트의 요청만 처리하도록 해야하는데 이런 경우를 위해 서블릿은 DispatcherType을 제공한다.
직접 해당 enum 클래스를 살펴보면 아래와 같다.
package jakarta.servlet;
/**
* Enumeration of dispatcher types. Used both to define filter mappings and by Servlets to determine why they were
* called.
*
* @since Servlet 3.0
*/
public enum DispatcherType {
FORWARD, // 서블릿에서 다른 서블릿이나 JSP를 호출할때
INCLUDE, // 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
REQUEST, // 클라이언트 요청
ASYNC, // 서블릿 비동기 호출
ERROR // 오류 요청
}
위에서 문제가 됐던 두 요청은 클라이언트 요청을 의미하는 REQUEST, 오류 요청을 의미하는 ERROR인것을 알 수 있다.
이제 서블릿과 필터에선 저 DispatcherType으로 분기를 해야할것이다.
3-1. 필터와 DispatcherType
필터에선 아래와 같이 DispatcherType설정을 타입별로 추가할수있다.
@Bean
public FilterRegistrationBean logFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
// 클라이언트 요청만 받도록 설정
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST);
return filterRegistrationBean;
}
위와 같이 .setDispatcherTypes()를 통해 직접 설정할수 있다. 물론 여러개를 넣을수도 있다.
3-2.인터셉터와 DispatcherType
인터셉터는 서블릿이 아닌 스프링이 지원하는 기능이다.
서블릿이 제공하는 필터와 같이 .setDispatcherTypes()등을 사용하는 기능이 없다.
따라서 인터셉터는 요청 경로에 따른 설정이 쉽게 되어 있기때문에, 오류 페이지 경로를 설정해주면 된다.
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns(
"/css/**", "/*.ico",
"/error", "/error-page/**" //오류 페이지 경로
);
}
"/error", "/error-page/** 와 같이 오류 페이지 경로를 설정하여 인터셉터가 적용되지 않도록 설정한것을 확인할 수 있다.
4. HandlerExceptionResolver
스프링 MVC는 컨트롤러 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공하는데 이럴때 사용하는것이 HandlerExceptionResolver이다. 줄여서 ExceptionResolver라고도 한다.
아래 그림을 살펴보자
먼저, ExceptionResolver 적용 전에는 마치 위에서 쭉 살펴본 방법들과 동일하게 예외가 WAS까지 전파되는 것을 확인할 수 있다. 이후 WAS에서는 또 요청받은 URL로 호출하는 과정이 포함될것이다.
그렇다면, 적용 후는 어떻게될까?
예외를 WAS까지 전달하지 않고 ExceptionResolver내에서 처리를 완료한후 정상적인 흐름으로 변경시키는 모습을 확인할 수 있다. 즉, ExceptionResolver설정에 따라 WAS에서 다시 컨트롤러를 호출하는 과정을 제거 시켜줄수도 있다.
아래 HandlerExceptionResolver를 구현한 첫번째 예제를 살펴보자
4-1. HandlerExceptionResolver를 사용한 예외 상태 코드 변경
@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());
/**
* ModelAndView()를 리턴하는 것은 마치 try,catch를 하듯 Exception을 처리해서 정상 흐름으로 변경하는 것이 목적이다.
*/
return new ModelAndView();
}
}catch (IOException e){
log.error("resolver ex", e);
}
return null;
}
}
먼저 예외 상태 코드를 변환하는 예제이다.
예외가 발생하게 되면 위와같이 resolver에서 공통으로 .sendError()로 상태코드를 변환하여 보내줄수있다.
위 경우는 .sendError()를 통해 보내게 되므로 이 경우는 WAS까지 예외가 전파된다.
직접 필터에서 로그를 찍어보면, DispatcherType이 REQUEST, ERROR 두가지인것을 확인할 수 있다.
이제 다른 예제를 살펴보자.
4-2. HandlerExceptionResolver를 사용한 API 응답 처리
@Slf4j
public class UserHandleExceptionResolver 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");
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/500");
}
}
} catch (IOException e){
log.error("resolver ex", e);
}
return null;
}
}
위는 API응답 처리를 해주는 예제이다.
response.getWriter().println("hello")처럼 HTTP 응답 바디에 직접 데이터를 넣어줄 수 있다.
위는 따로 response.sendError()를 호출하지 않기에 따로 WAS에 예외를 전파하지 않는다.
즉, 위에서 봤던 그림과 같이 HandlerExceptionResolver를 사용하면 설정에 따라 예외 전파를 끊고, 정상 흐름으로 변경한다.
필터 로그를 확인해보면 DispatcherType이 REQUEST인것만 존재하는것을 확인할 수 있다.
참고로 위 두가지 예제처럼 직접 HandlerExceptionResolver를 구현하는 경우 아래와 같이 WebConfig에 등록하여야 한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
4-3. HandlerExceptionResolver 정리
컨트롤러에서 예외가 발생하면 ExceptionResolver에서 예외를 처리해버리고, 설정에따라 서블릿 컨테이너까지 예외 전파가 되지않게 할수있으며 스프링 MVC에서 예외 처리는 끝났으므로 WAS 입장에서는 정상 처리가 된것이다.
또한 예외를 이곳에서 모두 처리할 수 있다.
4-4. HandlerExceptionResolver의 한계
HandlerExceptionResolver덕에 예외를 한곳에서 처리하고, 불필요한 예외 전파를 막았지만 직접 구현하여 사용하면 API 오류 응답의 경우 response에 직접 응답 데이터를 넣어줘야 했고, ModelAndView를 반환하는것도 API에는 잘 맞지않는걸로 보인다. 스프링은 사실 이 문제를 해결하기 위해 @ExceptionHandler라는 기능을 제공한다. 아래 5번에서 살펴보자.
5.스프링이 제공하는 ExceptionResolver
스프링 부트는 자동으로 제공하는 ExceptionResolver가 있다.
대표적으로 다음과 같다.
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver ( 우선 순위 가장 아래 )
4-4에서 언급한 @ExceptionHandler를 처리하는 가장 중요한 1번은 맨 뒤에서 설명하고, 2,3번을 먼저 살펴보자
5-1. ResponseStatusExceptionResolver
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
위와 같이 상태 코드를 설정해줄수 있다.
내부적으로 .sendError()를 호출하는데 이는 WAS에서 다시 내부 요청함을 알수있다.
컨트롤러에서 아래와 같이 호출하면 위 상태코드와 메시지가 적용된다.
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
호출이후 WAS에선 다시 /error를 내부 요청하게 된다. 따라서 BasicErrorController에 있는 error()메서드가 실행된다.
ResponseStatus는 아래와 같이 어노테이션을 사용하지 않는 방법도 있다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
IllegalArgumentException());
}
5-2. DefaultHandlerExceptionResolver
스프링 내부의 기본 예외를 처리한다.
대표적인 예시로 @ModelAttribute 사용시 파라미터 바인딩 시점에 타입이 맞지 않는다면 TypeMismatchException이 발생하는데, 이를 가만히 둔다면 서블릿 컨테이너까지 올라가서 500이 떨어질것이다.
그러나 이 Resolver는 위같은 경우 400으로 변경해준다.
실제로 클래스 내부를 들여다보면 아래 코드가 있는것을 확인할 수 있다.
...
else if (ex instanceof TypeMismatchException theEx) {
return handleTypeMismatch(theEx, request, response, handler);
}
...
handleTypeMismatch에 들어가보면 결국 response.sendError()를 사용하는 것을 확인할 수 있는데, 이것은 또다시 WAS에서 오류 페이지(/error)를 요청하는것을 알 수 있다.
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
위 data에 Integer가 아닌 String타입으로 요청하게 되면 Status가 400으로 응답되는것을 확인할 수 있다.
5-3. ExceptionHandlerExceptionResolver
@ExceptionHandler를 처리한다. 현재 대부분의 API 예외 처리는 이 기능으로 해결한다.
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@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", "내부 오류");
}
@GetMapping("/api2/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);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
해당 컨트롤러에서 처리하고 싶은 예외를 @ExceptionHandler어노테이션에 선언해주면 된다.
참고로 ErrorResult객체는 아래와 같이 따로 선언하였다.
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
만약 /api2/members/bad를 호출하게 되면
위와 같은 결과가 떨어지게 된다.
위 호출의 흐름을 정리하자면 다음과 같다.
1. 컨트롤러를 호출하여 IllegalArgumentException 예외가 던져진다.
2. 스프링에 자동으로 등록되어 있는 ExceptionResolver중 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
3. ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.
4. illegalExHandle()를 실행하고 @RestController이기 때문에 자동으로 HTTP 컨버터가 사용되어 JSON응답이 내려간다.
5. @ResponseStatus(HttpStatus.BAD_REQUEST)지정에 따라 HTTP 상태 코드 400으로 응답한다.
@ResponseStatus는 어노테이션이므로 HTTP 응답 코드를 로직 내부에서 동적으로 변경할 수 없으므로 userExHandler()과 같이 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);
}
return에서 직접 HTTP 응답 코드를 지정하는것을 확인할 수 있다.
모든 컨트롤러에 @ExceptionHandler를 설정하는건 좀 그렇다
위 @ExceptionHandler는 각 컨트롤러에 있는 예외를 매핑하게 되는데 그러면 예외를 가지고 있는 모든 컨트롤러를 매핑하게 될 것이다. 따라서 스프링은 @ControllerAdvice와 @RestControllerAdvice를 제공한다.
위 ApiExceptionV2Controller에 있는 모든 @ExceptionController를 한곳에 몰아놓고 @RestControllerAdvice를 사용하면 이제 모든 컨트롤러에 해당 예외처리가 적용된다.
@Slf4j
@RestControllerAdvice
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", "내부 오류");
}
}
* 출처 자료
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
'Spring' 카테고리의 다른 글
Spring DB 정리 2) 트랜잭션 처리 및 예외 처리 (0) | 2024.02.13 |
---|---|
Spring DB 정리 1) JDBC,커넥션풀과 데이터소스 (0) | 2023.12.28 |
Spring MVC 정리 3) 검증 (0) | 2023.11.06 |
Spring MVC 정리 2)단계별로 구현하며 알아보는 스프링의 핵심 기술 (0) | 2023.10.25 |
Spring MVC 정리 1)웹 어플리케이션의 이해 (0) | 2023.10.20 |