참고자료
서블릿 예외 처리
결론을 먼저 말하겠다. 스프링 부트를 사용한다면 이 방식을 사용하지 말자. 구식이기도 하며 더 불편하다. 그렇지만, 모든 개념은 다 태초부터 이해해야 왜 지금 현재 사용하는 기능이 나타났고 이 기능이 어떤 불편함을 해결했는지를 이해할 수 있기 때문에 이 서블릿 예외 처리부터 시작할 뿐이다.
스프링을 사용해서 예외 처리를 편리하게 다루기 앞서, 서블릿 컨테이너는 예외 처리를 어떻게 하는지부터 알아보자.
서블릿은 다음 2가지 방식으로 예외 처리를 지원한다.
- Exception
- response.sendError(HTTP 상태 코드, 오류 메시지)
Exception
자바의 메인 메서드를 직접 실행하는 경우, main 이라는 이름의 스레드가 실행된다. 실행 도중에 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 스레드는 종료된다.
웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다. 애플리케이션에서 예외가 발생했는데 어디선가 try - catch로 예외를 잡아서 처리하면 아무 문제가 없다. 그런데 만약 애플리케이션에서 예외를 잡지 못하고 서블릿 밖으로까지 예외가 전달되면 어떻게 동작할까?
WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
결국 톰캣같은 WAS까지 예외가 전달된다. WAS는 예외가 올라오면 어떻게 처리해야 할까? 한번 테스트 해보자.
먼저 스프링 부트가 제공하는 기본 예외 페이지가 있는데 이건 꺼두자.
application.properties
server.error.whitelabel.enabled=false
ServletExController
package cwchoiit.exception.servlet;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import java.io.IOException;
@Slf4j
@Controller
public class ServletExController {
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
}
실행해보면 다음처럼 Tomcat이 기본으로 제공하는 오류 화면을 볼 수 있다.
Exception의 경우, 서버 내부에서 처리할 수 없는 오류가 발생한 것으로 생각해서 HTTP 상태 코드를 500으로 반환한다.
이번에는 아무 경로나 호출해보자. (http://localhost:8080/nopage)
Tomcat이 기본으로 제공하는 404 오류 화면을 볼 수 있다.
response.sendError(HTTP 상태코드, 오류 메시지)
오류가 발생했을 때, HttpServletResponse가 제공하는 sendError라는 메서드를 사용해도 된다. 이것을 호출한다고 당장 예외가 발생하는 것은 아니다. 그러나 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다. 이 메서드를 사용하면 HTTP 상태코드와 오류 메시지도 추가할 수 있다.
ServletExController
package cwchoiit.exception.servlet;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import java.io.IOException;
@Slf4j
@Controller
public class ServletExController {
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
@GetMapping("/error-404")
public void error404(HttpServletResponse response) throws IOException {
response.sendError(404, "404 오류");
}
@GetMapping("/error-500")
public void error500(HttpServletResponse response) throws IOException {
response.sendError(500);
}
}
이번에는 sendError 메서드를 사용해서 서블릿 컨테이너에게 오류가 발생했다고 알리는 Path 두 개를 만들었다.
sendError 흐름
WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 (response.sendError())
sendError를 호출하면, response 내부에는 오류가 발생했다는 상태를 저장해둔다. 그리고 서블릿 컨테이너는 고객에게 응답 전에 response에 sendError()가 호출되었는지 확인한다. 그리고 호출되었다면 설정한 오류코드에 맞추어 기본 오류 페이지를 보여준다.
마찬가지로 해당 경로로 요청을 날려보면, 다음 사진처럼 Tomcat이 제공하는 오류 페이지를 볼 수 있을것이다.
그러나, 서블릿 컨테이너가 제공하는 기본 예외 처리 화면은 사용자가 보기에 많이 불편하다. 그래서 의미있는 오류 화면을 제공해보자.
서블릿 예외 처리 - 오류 화면 제공
서블릿 컨테이너가 제공하는 기본 예외 처리 화면은 고객 친화적이지 않다. 서블릿이 제공하는 오류 화면을 커스텀할 수 있는 기능을 사용해보자. 서블릿은 Exception이 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError()가 호출되었을 때, 각각의 상황에 맞춘 오류 처리 기능을 제공한다.
과거에는 web.xml이라는 파일에 다음과 같이 오류 화면을 등록했다.
<web-app>
<error-page>
<error-code>404</error-code>
<location>/error-page/404.html</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/error-page/500.html</location>
</error-page>
<error-page>
<exception-type>java.lang.RuntimeException</exception-type>
<location>/error-page/500.html</location>
</error-page>
</web-app>
지금은 스프링 부트를 통해서 서블릿 컨테이너를 실행하기 때문에, 스프링 부트가 제공하는 기능을 사용해서 서블릿 오류 페이지 커스텀 등록하면 된다.
서블릿 오류 페이지 커스텀 등록
package cwchoiit.exception;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
@Override
public void customize(ConfigurableServletWebServerFactory 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);
}
}
- WebServerFactoryCustomizer<ConfigurableServletWebServerFactory>를 상속받는 클래스가 필요하다.
- 이 인터페이스가 구현해야 할 메서드인 customize()를 구현한다.
- response.sendError(404) 코드가 입력되면 이제 errorPage404를 호출한다. ("/error-page/404" 경로를 재 호출)
- response.sendError(500) 코드가 입력되면 이제 errorPage500을 호출한다.("/error-page/500" 경로를 재 호출)
- RuntimeException 또는 그 자식 타입의 예외가 호출되면 errorPageEx를 호출한다. ("/error-page/500" 경로를 재 호출)
그러니까 스프링 부트와 서블릿 오류 페이지를 이용해서 에러 화면 처리를 하려면 이렇게 하면 된다. 결국, sendError()이던 Exception이든 발생해서 WAS까지 올라오면, 서블릿 컨테이너가 위에 커스텀 한 경로의 컨트롤러를 재호출한다.
위 경로의 컨트롤러를 재호출한다는 의미는, 해당 컨트롤러가 있어야 한다는 말이다.
ErrorPageController
package cwchoiit.exception.servlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Slf4j
@Controller
@RequestMapping("/error-page")
public class ErrorPageController {
@RequestMapping("/404")
public String error404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
return "error-page/404";
}
@RequestMapping("/500")
public String error500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
return "error-page/500";
}
}
오류 처리 View
src/main/resources/templates/404.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>404 오류 화면</h2>
</div>
<div>
<p>오류 화면 입니다.</p>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
src/main/resources/templates/500.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>500 오류 화면</h2>
</div>
<div>
<p>오류 화면 입니다.</p>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
이렇게 화면과 에러 처리 컨트롤러를 만들고 나서 다시 아까 만든 이 녀석을 호출해보자.
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
이제는 Tomcat이 기본으로 제공하는 못생긴 화면이 아닌 개발자가 직접 만든 오류 화면으로 보여진다. (물론 이것도 못생긴 것 같다..)
위에서 잠깐 얘기했지만 이 오류 페이지가 보여지는 작동 원리는 Exception이든 sendError()이든 WAS까지 올라오면 예외 커스텀 처리를 했는지 보고, 했다면 그 경로에 대한 컨트롤러를 재호출하는 한번 왔다가 다시 가는 이런 방식이다. 아래에서 좀 더 자세히 말해보자.
서블릿 예외 처리 - 오류 페이지 작동 원리
서블릿은 Exception이 발생해서 서블릿 밖으로 전달되거나, response.sendError()가 호출되었을 때, 설정된 오류 페이지를 찾는다.
예외 발생 흐름
WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
sendError 흐름
WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(response.sendError())
WAS는 해당 예외를 처리하는 오류 페이지 정보를 확인한다.
`new ErrorPage(RuntimeException.class, "/error-page/500")`
예를 들어서, RuntimeException 예외가 WAS까지 전달이 되면, WAS는 오류 페이지 정보를 확인한다. 확인해보니 RuntimeException의 오류 페이지로 `/error-page/500`이 지정되어 있다. WAS는 오류 페이지를 출력하기 위해 `/error-page/500`을 다시 요청한다.
오류 페이지 요청 흐름
WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View
예외 발생과 오류 페이지 요청 흐름
1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View
중요한 점은 웹 브라우저(클라이언트)는 서버 내부에서 이런 일이 일어나는지 전혀 모른다는 점이다. 오직 서버 내부에서 오류 페이지를 찾기 위해 추가적인 호출을 한다.
정리를 하자면,
- 예외가 발생하거나 response.sendError()메서드를 호출해서 WAS까지 전파된다.
- WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다.
오류 정보 추가
WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 request의 attribute에 추가해서 넘겨준다. 필요하면 오류 페이지에서 이렇게 전달된 오류 정보를 사용할 수 있다.
그래서 실제로 어떤 오류 정보를 넘겨주는지 찍어보자.
ErrorPageController
package cwchoiit.exception.servlet;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Slf4j
@Controller
@RequestMapping("/error-page")
public class ErrorPageController {
@RequestMapping("/404")
public String error404(HttpServletRequest request, HttpServletResponse response) {
printErrorInfo(request);
log.info("errorPage 404");
return "error-page/404";
}
@RequestMapping("/500")
public String error500(HttpServletRequest request, HttpServletResponse response) {
printErrorInfo(request);
log.info("errorPage 500");
return "error-page/500";
}
private void printErrorInfo(HttpServletRequest request) {
log.info("ERROR_EXCEPTION: {}", request.getAttribute(RequestDispatcher.ERROR_EXCEPTION));
log.info("ERROR_EXCEPTION TYPE: {}", request.getAttribute(RequestDispatcher.ERROR_EXCEPTION_TYPE));
log.info("ERROR_EXCEPTION MESSAGE: {}", request.getAttribute(RequestDispatcher.ERROR_MESSAGE));
log.info("ERROR_REQUEST_URI: {}", request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI));
log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(RequestDispatcher.ERROR_SERVLET_NAME));
log.info("ERROR_STATUS_CODE: {}", request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE));
log.info("dispatcherType: {}", request.getDispatcherType());
}
}
- 기존에 만들어 둔 에러 페이지에 대한 컨트롤러에 여러 오류 정보를 출력하는 메서드 printErrorInfo()를 만들었다.
- 해당 메서드를 각 오류 페이지를 보여주는 경로에 추가해서 확인해보자.
500 에러 화면 호출 시
2024-09-07T13:14:20.932+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_EXCEPTION TYPE: class java.lang.RuntimeException
2024-09-07T13:14:20.932+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_EXCEPTION MESSAGE: Request processing failed: java.lang.RuntimeException: 예외 발생
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_REQUEST_URI: /error-ex
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_SERVLET_NAME: dispatcherServlet
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_STATUS_CODE: 500
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : dispatcherType: ERROR
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : errorPage 500
404 에러 화면 호출 시
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_EXCEPTION TYPE: null
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_EXCEPTION MESSAGE: 404 오류
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_REQUEST_URI: /error-404
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_SERVLET_NAME: dispatcherServlet
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_STATUS_CODE: 404
2024-09-07T13:15:03.976+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : dispatcherType: ERROR
2024-09-07T13:15:03.976+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : errorPage 404
이런식으로, 에러를 받은 서블릿이 해당 에러에 대한 정보를 request에 담아서 에러 화면을 출력하는 컨트롤러를 호출한다.
서블릿 예외 처리 - 필터
그럼 서블릿이 예외 처리를 하는 원리를 이해하면 이런 문제가 있어 보인다.
최초에 사용자가 요청을 해서 필터를 거쳤는데 컨트롤러 호출 시 에러가 발생해서 다시 이게 올라오면 WAS 내부에서 다시 한번 에러를 처리하는 컨트롤러를 호출하기 위해 필터 - 서블릿 - 인터셉터 - 컨트롤러를 재호출할텐데 그럼 필터나 인터셉터가 두번이나 호출될 것 같다. 맞다. 그래서 만약 로그인 체크 관련 필터나 인터셉터가 적용됐다면 그 체크를 두번이나 할 것이다. 서블릿은 이런 문제를 해결하기 위해 DispatcherType이라는 추가 정보를 제공한다.
DispatcherType
필터는 이런 경우를 위해서 dispatcherType 이라는 옵션을 제공한다. 아까 위에서 서블릿이 에러 정보와 관련된 내용들을 request에 담아서 에러 처리 컨트롤러를 재호출한다고 했는데, 그때 찍었던 로그 중 이런 부분이 있었다.
log.info("dispatcherType: {}", request.getDispatcherType());
그리고 출력을 해보면 오류 페이지에서 dispatcherType: ERROR라고 나오는 것을 확인할 수 있다.
2024-09-07T13:15:03.976+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : dispatcherType: ERROR
고객이 처음 요청할 땐 이 값은 REQUEST이다. 이렇게 서블릿 스펙은 실제 고객이 요청한 것인지, 서버가 내부적으로 오류 페이지를 요청하는 것인지 DispatcherType으로 구분할 수 있는 방법을 제공한다.
package jakarta.servlet;
public enum DispatcherType {
FORWARD,
INCLUDE,
REQUEST,
ASYNC,
ERROR;
private DispatcherType() {
}
}
- FORWARD: MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때
- INCLUDE: 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
- REQUEST: 클라이언트 요청
- ASYNC: 서블릿 비동기 호출
- ERROR: 오류 요청
그래서 필터에는 DispatcherType이 ERROR라면 필터를 적용하지 않는 어떤 방법이 있지 않을까? 맞다. 그리고 이게 Default이다.
필터와 DispatcherType
LogFilter
package cwchoiit.exception.servlet;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.util.UUID;
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("LogFilter init");
}
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
log.info("LogFilter doFilter");
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST: [{}][{}][{}]", uuid, request.getDispatcherType(), request.getRequestURI());
filterChain.doFilter(request, response);
} catch (Exception e) {
throw new ServletException(e);
} finally {
log.info("RESPONSE: [{}][{}][{}]", uuid, request.getDispatcherType(), request.getRequestURI());
}
}
@Override
public void destroy() {
log.info("LogFilter destroy");
}
}
예전에 로그인 포스팅때 사용했던 LogFilter를 가져와서 DispatcherType을 로그로 찍는것만 추가했다.
실제로 고객이 요청이 들어올땐 어떤게 찍히고, 서블릿이 내부적으로 오류 페이지를 호출할 땐 어떻게 찍히는지 보기 위함이다.
WebConfig
package cwchoiit.exception;
import cwchoiit.exception.servlet.LogFilter;
import jakarta.servlet.DispatcherType;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean<LogFilter> filterRegistrationBean() {
FilterRegistrationBean<LogFilter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
return filterRegistrationBean;
}
}
필터를 사용하려면 필터를 등록했어야 했다. 여기서 유심히 볼 부분은 setDispatcherTypes()이다. 지금 보면 REQUEST, ERROR 타입을 추가해줬다. 즉, 저 두개의 타입일 때 이 필터가 적용될 것이라는 의미이다. 그럼 여기서 ERROR일땐 필터를 적용하기 싫다면? 빼버리면 된다. 그리고 기본값이 REQUEST만 있는 형태이다.
이 상태에서 다시 요청을 해보자. 다음과 같이 로그가 찍힐 것이다.
- 보면 최초에 내가 브라우저를 통해 요청한 것은 DispatcherType이 REQUEST라고 찍혔다.
- 그렇게 요청에 대한 응답이 돌아왔는데 여기서 response.sendError(404, "404 오류")가 저장된 상태라 WAS는 해당 정보를 보고 이 에러를 처리할 컨트롤러를 찾고 그 컨트롤러에 요청을 내부적으로 다시 한번 더 한다.
- 그래서 실제로 그렇게 요청을 더 했다는 것을 DispatcherType이 ERROR로 찍힌 필터의 로그가 한번 더 찍힌것을 볼 수 있다. 이렇듯 만약, 적용할 DispatcherType에 ERROR도 추가하면 WAS가 내부적으로 오류 처리 페이지를 호출할때도 필터가 적용된다.
정리를 하자면,
그래서, 오류 페이지 경로도 필터를 적용할 것이 아니면 기본값 그대로를 사용하면 된다. 그게 아니라 오류 페이지 요청 전용 필터를 적용하고 싶으면 DispatcherType.ERROR만 적용해도 된다.
그럼 필터는 이렇게 알아봤는데 인터셉터는 어떻게 동작할까?
서블릿 예외 처리 - 인터셉터
인터셉터도 마찬가지로 두번 호출이 된다. 그리고 이 인터셉터도 역시 개발자가 원하면 두번 다 호출시킬수도 아닐수도 있다.
LogInterceptor
package cwchoiit.exception.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import java.util.UUID;
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uuid = UUID.randomUUID().toString();
request.setAttribute("uuid", uuid);
// @RequestMapping: HandlerMethod
// 정적 리소스: ResourceHttpRequestHandler
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler;
}
log.info("REQUEST [{}][{}][{}][{}]", uuid, request.getDispatcherType(), request.getRequestURI(), handler);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("postHandle [{}]", modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String uuid = (String) request.getAttribute("uuid");
String requestURI = request.getRequestURI();
log.info("RESPONSE [{}][{}][{}][{}]", uuid, request.getDispatcherType(), requestURI, handler);
if (ex != null) {
log.error("afterCompletion err", ex);
}
}
}
마찬가지로, 로그인 포스팅때 사용했던 LogInterceptor를 가져왔다. 그리고 로그 출력 시 DispatcherType을 추가하는 것만 달라졌다.
이 인터셉터도 사용하기 위해 등록해야 한다.
WebConfig
package cwchoiit.exception;
import cwchoiit.exception.interceptor.LogInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
}
}
인터셉터를 등록하기 위해 WebMvcConfigurer를 구현한다. 해당 인터페이스의 메서드인 addInterceptors를 사용해 등록하자.
여기서 인터셉터를 등록할때 이 인터셉터는 필터와 달리 DispatcherType을 지정하는 건 없다. 대신, 오류 페이지를 보여주는 컨트롤러의 경로를 제외시켜버리면 된다. 위 excludePathPatterns(..., "/error-page/**")에서 한 것처럼 말이다.
이 또한 마찬가지로, 에러 페이지 호출 컨트롤러에도 인터셉터를 적용하고자 하면 제외 경로에서 빼버리면 될 것이다.
그래서 만약, 저 "/error-page/**"을 빼고 인터셉터를 등록한 후 `/error-page/ex`를 브라우저에서 호출한다면,
브라우저에선 이렇게 에러 화면이 보일 것이고, 로그는 다음과 같이 보일 것이다.
- 첫번째 사용자 요청은 DispatcherType이 REQUEST이다.
- 에러가 발생했다. 에러가 발생하면 인터셉터는 postHandle이 호출되지 않는다고 했다. 그래서 호출되지 않았고 afterCompletion만 호출된 모습이다. 그리고 그 메서드 안에서는 에러가 있는 경우 에러를 출력하기 때문에 첫번째 에러가 찍혔다.
- 인터셉터에서 WAS까지 에러가 올라왔다. WAS에서 에러를 출력했다. 이게 두번째 에러 메시지다. 그리고 WAS는 에러를 처리하는 오류 페이지 정보를 확인하고 해당 컨트롤러를 내부적으로 재호출한다.
- 그때 인터셉터는 DispatcherType이 ERROR이다. 그리고 해당 오류 페이지를 화면에 출력한다. 해당 오류 페이지 처리를 하는 컨트롤러 경로에 오류 메시지를 찍는 메서드가 있었다. 그 메서드 때문에 세번째 에러 메시지가 찍힌 모습이다.
정리를 하자면,
필터든 인터셉터든 서블릿이 처리하는 예외 동작은 WAS까지 예외가 올라온 다음 WAS에서 해당 예외를 처리할 수 있는 커스텀 경로가 있는지 확인하고 있다면, 해당 경로로 재호출을 서버 내부적으로 실행한다. 여기서 필터와 인터셉터가 또 호출되는 것을 막고자 한다면 필터는 DispatcherType을 지정할 때 ERROR는 빼면 된다. 그리고 이게 기본값이라고 했다. 반면, 인터셉터는 DispatcherType을 지정하는 메서드는 따로없다. 대신 오류 페이지를 처리하는 컨트롤러에 대한 경로를 제외시키면 된다.
그래서 서블릿은 이렇게 예외를 처리하곤 한다. 근데 생각해보자. 불편하다. 어떤게 불편하냐면 new ErrorPage()로 특정 에러에 대한 오류 처리를 위한 경로를 직접 재정의 하는것과 그 경로에 대한 컨트롤러를 만드는 것이 불편하다. 이것이 자동으로 이루어지면 좋을것 같다. 그것을 스프링 부트가 해준다. 이제 스프링 부트를 사용할 때 오류 페이지를 처리하는 방법을 알아보자.
스프링 부트 - 오류 페이지 시작
이제 위에 한 것들은 전부 잊자! 왜냐하면 스프링 부트는 마법처럼 모든것을 다 자동으로 해주기 때문에!
일단, 잊기 전에 ㅋㅋㅋ 지금까지 예외 처리를 하기 위해 어떤 과정을 거쳤는지 다시 복기해보자.
- WebServerCustomizer를 만들어서 예외 종류에 따라 new ErrorPage()를 추가했다.
- 예외 처리용 컨트롤러(ErrorPageController)를 만들었다.
스프링 부트가 이 두가지를 모두 자동으로 해준다.
- ErrorPage를 자동으로 등록한다. 이때, `/error`라는 경로로 기본 오류 페이지를 설정한다.
- 그래서 서블릿 밖으로 예외가 발생하거나, response.sendError(...)가 호출되면 모든 오류는 `/error`를 호출하게 된다.
- BasicErrorController라는 스프링 컨트롤러를 자동으로 등록한다. 이 컨트롤러가 이제 `/error`를 매핑해서 처리하는 컨트롤러가 될 것이다.
이제 위에서 한 것들은 다 싹 다 지워버리고 우리는 딱 뭐만 하면 되냐면, 오류 페이지만 등록하면 된다.
"페이지 어디에 등록해요?" → `src/main/resources/templates/error` 경로에 만들면 된다.
그리고 또 기가막힌게 404에러가 발생했을 때 내가 404.html이라는 파일이 있으면 해당 오류 페이지를 불러오겠지만,
따로 404.html을 정의한 것은 없고 4xx.html 이라는 파일만 있으면 이 4xx.html 파일을 불러온다.
그래서 404, 400, 403 뭐 이런 페이지를 하나씩 일일이 다 만들어도 되고? 귀찮고 공통으로 묶어도 될 것 같다 싶으면 4xx.html 파일을 만들면 되는것이다.
src/main/resources/templates/error/4xx.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>4xx 오류 화면 스프링 부트 제공</h2>
</div>
<div>
<p>오류 화면 입니다.</p>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
src/main/resources/templates/error/404.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>404 오류 화면 스프링 부트 제공</h2>
</div>
<div>
<p>오류 화면 입니다.</p>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
src/main/resources/templates/error/500.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>500 오류 화면 스프링 부트 제공</h2>
</div>
<div>
<p>오류 화면 입니다.</p>
</div>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
이제 위에서 서블릿 예외 처리를 위해 사용했던 모든 코드들을 다 지우고 그냥 딱 이 세 개 파일만 만들고 실행해보자.
`/error-ex` 경로로 진입해보자.
내가 만든 500.html 파일이 노출된다. 와우!? 이러니 스프링 부트가 나에겐 최애일 수 밖에 없다.
그리고 여기서 조금만 더 나아가서 추가적인 기능도 사용할 수가 있는데, BasicErrorController는 다음 정보를 Model에 담아서 View에 전달한다. 뷰 템플릿은 이 값을 활용할수도 있다.
* timestamp: Fri Feb 05 00:00:00 KST 2021
* status: 400
* error: Bad Request
* exception: org.springframework.validation.BindException * trace: 예외 trace
* message: Validation failed for object='data'. Error count: 1
* errors: Errors(BindingResult)
* path: 클라이언트 요청 경로 (`/hello`)
그래서 예시로 한번 500.html 파일을 아래와 같이 수정해보자.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>500 오류 화면 스프링 부트 제공</h2>
</div>
<div>
<p>오류 화면 입니다.</p>
</div>
<ul>
<li>오류 정보</li>
<ul>
<li th:text="|timestamp: ${timestamp}|"></li>
<li th:text="|path: ${path}|"></li>
<li th:text="|status: ${status}|"></li>
<li th:text="|message: ${message}|"></li>
<li th:text="|error: ${error}|"></li>
<li th:text="|exception: ${exception}|"></li>
<li th:text="|errors: ${errors}|"></li>
<li th:text="|trace: ${trace}|"></li>
</ul>
</li>
</ul>
<hr class="my-4">
</div> <!-- /container -->
</body>
</html>
이러고 다시 실행해보면 이렇게 보일 것이다.
오류 정보에 대한 내용들이 이렇게 보여진다. 이건 스프링 부트가 자동으로 만들고 등록해주는 BasicErrorController가 정보를 넘겨준 것이다. 근데 보면, null이 많다. 왜 그러냐면, 기본적으로 이런 내용들을 사용자에게 노출하는건 안 좋은 행위이다. 개발자가 아닌 사람들은 무슨말인지도 모를거니와 해커들이 이런 내용을 보면 오히려 역이용해서 나쁜짓을 할 수 있기 때문에 말이다. 그래서 기본은 이 값을 다 지워버리지만 굳이 굳이 노출시킬수도 있다.
application.properties
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
server.error.include-binding-errors=always
이렇게 속성을 다 나올수 있게 해주면 된다. 그리고 다시 보면 이렇게 다 보인다.
이런 내용이 나오면, 해커들은 "아 이 라이브러리 사용하는구나? 이 라이브러리는 이런 취약점이 있으니까 이런점을 공격해야겠다." 뭐 이런식으로 말이다. 아무튼 이런 짓은 하지말고 이게 가능하다는 점만 이해하고 넘어가자.
모든 에러는 서버의 로그로 관리되어야 한다. 사용자나 외부로 노출시키면 안된다.
정리
이제 스프링 부트를 사용하면, 공통 오류 페이지를 아주아주 쉽게 만들어 낼 수 있다. 서블릿이 처리하는 과정을 이해하고 나서 보니 "아 이런 과정들이 자동으로 되어 있구나."를 이해할 수 있게 됐다. 그리구 혹시 모르지 않나? 스프링 사용 못하는 환경에서 개발을 할 수도..? 그럴땐 이 포스팅에서 배운 서블릿을 활용한 오류 페이지 처리를 하면 된다.
'Spring MVC' 카테고리의 다른 글
스프링의 타입 컨버터, 포맷터(1000 -> "1,000") 직접 만들어서 등록하고 사용하기 (0) | 2024.09.09 |
---|---|
Spring MVC 예외 처리(API) - 스프링 부트의 BasicErrorController, HandlerExceptionResolver, @ExceptionHandler, @ControllerAdvice (6) | 2024.09.07 |
ArgumentResolver 활용 (2) | 2024.09.04 |
로그인 처리2 - Servlet Filter, Spring MVC Interceptor (0) | 2024.09.04 |
로그인 처리 (0) | 2024.09.03 |