프로젝트 생성
- 다음과 같이 스프링 프로젝트를 생성하자.
서블릿 예외 처리
스프링 사용되기 이전에 순수 서블릿 컨테이너는 예외를 어떻게 처리하는 지 알아보자.
서블릿은 Exception
, response.sendError(Http 상태 코드, 오류 메시지)
총 2가지 방식으로 예외 처리를 지원한다.
Exception
자바 직접 실행
자바의 메인 메서드를 직접 실행하는 경우 main
이라는 쓰레드가 실행된다. 실행 도중에 예외를 잡지 못하고 처음 실행한 main 메서드를 넘어서 예외가 던져지게 된다면, 예외 정보를 남기고 해당 쓰레드는 종료된다.
웹 애플리케이션
웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다. 예외를 처리하지 않고 서블릿 컨테이너를 넘게되면 어떻게 동작할까?
컨트롤러(예외 발생) -> 인터셉터 -> 서블릿 -> 필터 -> WAS
- 다음과 같이 WAS 까지 예외가 전달된다면 어떻게 될지 알아보자.
server.error.whitelabel.enabled=false
- application.properties 에 다음과 같이 기본 에러페이지 설정을 없애자.
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Slf4j
@Controller
public class ServletExController {
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
}
/error-ex
를 호출하면 에러 발생하도록 유도해보자.
- 다음과 같이 톰캣이 기본으로 제공하는 500 에러 화면을 볼 수 있다.
response.sendError(Http 상태코드, 오류 메시지)
오류가 발생했을 때 HttpServletResponse
가 제공하는 sendError
라는 메서드를 사용해도 된다.
이것을 호출한다고 당장 예외가 발생하는 것은 아니지만, 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.
이 메서드를 사용하면 HTTP 상태 코드와 오류 메시지도 추가할 수 있다.
@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()
메서드를 사용해서 오류 처리를 할 수 있다.
서블릿 예외 처리 - 오류화면 제공
서블릿은 Exception
예외가 발생해서 서블릿 밖으로 전달되거나, response.sendError()
가 호출 되었을 때 각각의 상황에 맞춘 오류 처리 기능을 제공한다. 이 기능을 사용하면 친절한 오류 처리 화면을 보여줄 수 있다.
package hello.exception.sevlet;
import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@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);
}
}
WebServerFactoryCustomizer
를 상속받아서 구현할 페이지를 지정할 수 있다.@Component
를 통해 스프링 빈을 등록해줘야 한다는 점을 주의하자.
package hello.exception.sevlet;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
@Controller
public class ErrorPageController {
@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
return "error-page/404";
}
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
return "error-page/500";
}
}
- 다음과 같이 에러 페이지를 처리하기 위한 컨트롤러를 생성하자.
<!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>
- 다음과 같이 에러 처리를 위한 뷰를 생성하고 테스트 해보면 정상 동작하는 것을 볼 수 있다.
- 정상적으로 생성한 오류 페이지가 화면에 렌더링 된 것을 볼 수 있다.
서블릿 예외 처리 - 오류 페이지 작동 원리
WAS <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외 시작)
WAS (예외 페이지 요청) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> 뷰
예외가 WAS 까지 전달되면, WAS는 해당 예외가 오류 페이지 정보에 있는 지 확인한다. 이 때 설정된 오류 페이지가 존재한다면 다시 페이지를 요청한다. 오류 정보를 request
의 attribute
에 추가해서 넘긴다.
package hello.exception.sevlet;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
@Controller
public class ErrorPageController {
//RequestDispatcher 상수로 정의되어 있음
public static final String ERROR_EXCEPTION = "javax.servlet.error.exception";
public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type";
public static final String ERROR_MESSAGE = "javax.servlet.error.message";
public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri";
public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name";
public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";
@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
printErrorInfo(request);
return "error-page/404";
}
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
printErrorInfo(request);
return "error-page/500";
}
private void printErrorInfo(HttpServletRequest request) {
log.info("ERROR_EXCEPTION: {}", request.getAttribute(ERROR_EXCEPTION));
log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE));
log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
}
}
- 다음과 같이 WAS 예외를 담아서 보낸 정보인
request.attribute
에 대한 정보를 확인할 수 있다.
서블릿 예외 처리 - 필터
필터는 서블릿이 제공하는 기능이다.
오류가 발생하면 WAS내부에서 다시 호출한다는 것을 알았다. 이때 필터, 서블릿, 인터셉터 모두 다시 호출된다. 로그인의 경우를 생각해보면, 필터나 인터셉터에서 처리된 로그인을 다시 호출하게되면 매우 비효율적이다.
즉, 클라이언트로부터 발생한 정상적인 오류 페이지 요청인지, 오류 페이지를 출력하기 위한 요청인지 구분할 수 있어야한다. 이 기능을 DispatcherType
이라는 추가정보를 제공한다.
DispatcherType
log.info("dispatchType={}", request.getDispatcherType());
REQUEST
: 클라이언트 요청ERROR
: 오류 요청FORWARD
: MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP 호출할 때INCLUDE
: 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때ASYNC
: 서블릿 비동기 호출
필터와 DispatcherType
package hello.exception.filter;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("log filter init");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST [{}][{}][{}]", uuid, request.getDispatcherType(), requestURI);
chain.doFilter(request, response);
} catch (Exception e) {
log.info("EXCEPTION {}", e.getMessage());
throw e;
} finally {
log.info("RESPONSE [{}][{}][{}]", uuid, request.getDispatcherType(), requestURI);
}
}
@Override
public void destroy() {
log.info("log filter destroy");
}
}
Filter
를 상속받아서doFilter
부분에DispatcherType()
을 로깅하자.
package hello.exception;
import hello.exception.filter.LogFilter;
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;
import javax.servlet.DispatcherType;
import javax.servlet.Filter;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1);
filterFilterRegistrationBean.addUrlPatterns("/*");
filterFilterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
return filterFilterRegistrationBean;
}
}
Filter
클래스를 빈 등록하면 확인해볼 수 있다. 디폴트가DispatcherType.REQUEST
가 들어있기 때문에 클라이언트의 요청이 있는 경우에만 필터가 적용된다.
서블릿 예외 처리 - 인터셉터
package hello.exception.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
private static final String LOG_ID = "logId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
log.info("REQUEST [{}][{}][{}][{}]", uuid, request.getDispatcherType(), requestURI, 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 requestURI = request.getRequestURI();
String logId = (String) request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}][{}]", logId, request.getDispatcherType(), requestURI);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
- 인터셉터는 필터와 다르게 서블릿이 제공하는 기능이 아니라 스프링이 제공하는 기능으로
DispatcherType
과 무관하게 항상 호출된다.
@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/**"
);
}
- 인터셉터는 다음과 같이
excludePathPatterns
를 사ㅇ해서 경로 설정을 할 수 있다.
전체 흐름 정리
정상요청
WAS(/hello, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> VIEW
오류요청
1. WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
2. WAS <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 (예외 발생)
3. WAS 오류 페이지 확인
4. WAS(/error-page/500, dispatchType=ERROR) -> 필터(X) -> 서블릿 -> 인터셉터(X) -> 컨트롤러 -> VIEW
지금까지 서블릿이 제공하는 오류페이지에 대해서 정리해보았다. WebServerCustomizer를 만들고, ErrorPage를 추가하고, 에러처리 컨트롤러를 만드는 등 복잡한 과정이었다. 스프링은 이런 과정을 모두 기본으로 제공한다. 이어서 스프링이 제공하는 오류페이지 기능을 살펴보자.
스프링부트 - 오류 페이지 1
스프링부트가 제공하는 기본 오류 메커니즘을 적용하기 위해 WebServerCustomizer
에서 컴포넌트를 주석처리하자.
스프링부트는 /error
라는 경로로 기본 오류 페이지를 설정한다. 서블릿 밖으로 예외가 발생하거나, response.sendError()
가 호출되면 모든 오류는 /error
를 호출하게 된다.
BasicErrorController
라는 스프링 컨트롤러를 통해 /error
를 매핑해서 자동으로 처리한다. ErrorMvcAutoConfiguration
이라는 클래스가 오류 페이지를 자동으로 등록하는 역할을 한다.
BasicErrorController
다음과 같이 뷰 선택 우선순위를 갖고 처리하게 된다. 구체적일수록 우선순위를 갖는다.
뷰 템플릿
resources/templates/error.500.html
resources/templates/error.5xx.html
정적 리소스(static, public)
resources/static/error/400.html
resources/static/error/404.html
resources/static/error/4xx.html
적용 대상이 없을 때 뷰 이름
resources/templates/error.html
스프링부트 - 오류 페이지 2
* timestamp: Wed Jun 01 00:00:00 KST 2022
* 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`)
BasicErrorController
는 다음 정보를 model에 담아서 뷰에 전달한다. 뷰 템플릿은 이 값을 활용해서 출력할 수 있다.
- 하지만 출력해보면
null
이 나오는 부분이 있다. 즉, 오류 관련 정보를 클라이언트에게 노출하는 것이 좋지 않기 때문에 기본적으로 차단되어 있다. 노출하기 위해선 다음과 같은 설정이 필요하다.
server.error.include-exception=true
server.error.include-message=on_param
server.error.include-stacktrace=on_param
server.error.include-binding-errors=on_param
- application.properties에 다음과 같이 설정하여 볼 수 있다.
never
,always
,on_param
과 같이 사용할 수 있다.on_param
은 파라미터가 있으면 해당 정보를 노출한다./error-ex?message=&errors=&trace=
과 같이 사용한다.
실무에서는 이것들을 노출시키면 안된다. 간단한 오류화면을 보여주고, 오류 로그는 서버에 남겨서 처리해야한다.
에러 공통 처리 컨트롤러의 기능을 변경하고 싶으면
ErrorController
인터페이스를 상속 받아서 구현하거나BasicErrorController
를 상속받아서 기능을 추가하면 된다.
본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.