[스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 08. 예외 처리와 오류 페이지

Turtle·2024년 8월 18일
0
post-thumbnail

🙄서블릿 예외 처리

서블릿에서의 예외 처리 방법

  • Exception(예외)
  • response.sendError(HTTP 상태 코드, 오류 메시지)

🙄서블릿 예외 처리 - Exception(예외)

웹 애플리케이션 실행 흐름

WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러

만약, 애플리케이션 실행 도중 컨트롤러에서 예외가 발생했다면?

WAS ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)

결과적으로 톰켓과 같은 WAS까지 예외가 전달된다. WAS에 예외가 전달되면 어떻게 처리가 되는지 알아보자. 스프링 부트가 제공하는 예외 설정 페이지 제공 기능을 끄기 위해 application.properties에 아래와 같이 작성한다.

server.error.whitelabel.enabled=false

컨트롤러를 작성하여 예외를 발생시키면 tomcat이 기본으로 제공하는 오류 화면을 볼 수 있다.

WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러 흐름으로 진행하여 컨트롤러에서 URL을 매핑시켜 해당하는 컨트롤러를 호출하였고 예외가 발생하여 다시 tomcat(WAS)으로 예외가 전달되어 위와 같은 화면이 사용자에게 보여지는 것이다.

🙄서블릿 예외 처리 - response.sendError()

오류가 발생했을 때 HttpServletResponse가 제공하는 sendError 라는 메서드를 사용할 수 있다. 이것을 호출한다고 위와 같이 Exception이 바로 발생하는 것은 아니지만 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.

웹 애플리케이션 실행 흐름

WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러

sendError 실행 흐름

WAS(sendError 호출 기록 확인) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(sendError 발생)

현재 오류 화면은 tomcat이 제공하는 기본 제공 오류 화면으로 사용자에게는 다소 불친절하다. 사용자 친화적인 오류 화면을 제공하도록 바꿀 수 있다.

🙄서블릿 예외 처리 - 오류 화면 제공

// 스프링 부트에서 제공하는 tomcat의 오류 페이지 대신 사용할 오류 페이지 커스터마이징
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
	@Override
	public void customize(ConfigurableWebServerFactory factory) {
		ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/400");
		ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
		ErrorPage errorPage = new ErrorPage(RuntimeException.class, "/error-page/500");

		factory.addErrorPages(errorPage);
		factory.addErrorPages(errorPage404);
		factory.addErrorPages(errorPage500);
	}
}
@Controller
@Slf4j
public class ErrorPageController {

	@RequestMapping("/error-page/404")	// @GetMapping (X)
	public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
		log.info("errorPage 404");
		return "/error-page/404";
	}

	@RequestMapping("/error-page/500")	// @GetMapping (X)
	public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
		log.info("errorPage 500");
		return "/error-page/500";
	}
}

오류 페이지는 예외를 다룰 때 해당 예외와 그 자식 타입의 오류를 함께 처리한다. 예를 들어, RuntimeException이 발생했다면 RuntimeException은 물론 그 하위 예외들도 처리가 된다.

이 오류가 발생했을 때 처리할 수 있는 컨트롤러가 필요하다. 예를 들어, RuntimeException 예외가 발생하면 위의 errorPage에서 지정한 /error-page/500이 호출된다.

🙄서블릿 예외 처리 - 오류 페이지 작동 원리

웹 애플리케이션 실행 흐름

WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러

만약, 애플리케이션 실행 도중 컨트롤러에서 예외가 발생했다면?

WAS ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)


웹 애플리케이션 실행 흐름

WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러

sendError 실행 흐름

WAS(sendError 호출 기록 확인) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(sendError 발생)

WAS는 해당 예외를 처리하는 오류 페이지 정보를 확인한다.

ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/400");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPage = new ErrorPage(RuntimeException.class, "/error-page/500");

예를 들어, 컨트롤러에서 RuntimeException 예외가 발생하면 WAS는 오류 페이지 정보를 확인하고 ErrorPage errorPage = new ErrorPage(RuntimeException.class, "/error-page/500")이 지정된 것을 확인하고 이 오류 페이지를 출력하기 위해 다시 /error-page/500를 호출한다. 결과적으로 사용자로부터의 요청과 이 요청에서의 예외 발생 흐름을 합해 나타내면 아래와 같다.

  • 정상 : WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(예외 발생)
  • 예외 : WAS(WAS로 전달) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)
  • 오류 페이지 : WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(오류 페이지)

WAS는 오류 페이지만을 단순히 다시 요청하는 것이 아니라 오류 정보를 HttpServletRequestattribute에 추가해서 넘겨준다. 필요하면 오류 페이지에서 오류 정보를 사용할 수 있다.

@Controller
@Slf4j
public class ErrorPageController {

	public static final String ERROR_EXCEPTION = "jakarta.servlet.error.exception";
	public static final String ERROR_EXCEPTION_TYPE = "jakarta.servlet.error.exception_type";
	public static final String ERROR_MESSAGE = "jakarta.servlet.error.message";
	public static final String ERROR_REQUEST_URI = "jakarta.servlet.error.request_uri";
	public static final String ERROR_SERVLET_NAME = "jakarta.servlet.error.servlet_name";
	public static final String ERROR_STATUS_CODE = "jakarta.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("dispatcher type: {}", request.getDispatcherType());
	}
}

실행 결과

2024-08-18T22:27:23.839+09:00  INFO 11960 --- [exception] [nio-8080-exec-7] h.exception.servlet.ErrorPageController  : errorPage 500
2024-08-18T22:27:23.839+09:00  INFO 11960 --- [exception] [nio-8080-exec-7] h.exception.servlet.ErrorPageController  : ERROR EXCEPTION: null
2024-08-18T22:27:23.839+09:00  INFO 11960 --- [exception] [nio-8080-exec-7] h.exception.servlet.ErrorPageController  : dispatcher type: ERROR

// ...

2024-08-18T22:27:08.503+09:00  INFO 11960 --- [exception] [nio-8080-exec-3] h.exception.servlet.ErrorPageController  : errorPage 404
2024-08-18T22:27:08.504+09:00  INFO 11960 --- [exception] [nio-8080-exec-3] h.exception.servlet.ErrorPageController  : ERROR EXCEPTION: null
2024-08-18T22:27:08.504+09:00  INFO 11960 --- [exception] [nio-8080-exec-3] h.exception.servlet.ErrorPageController  : dispatcher type: ERROR

🙄서블릿 예외 처리 - 필터

  • 정상 : WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(예외 발생)
  • 예외 : WAS(WAS로 전달) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)
  • 오류 페이지 : WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(오류 페이지)

위와 같이 오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 한 번 호출이 발생한다. 이 때, 필터, 서블릿, 인터셉터 등이 모두 다시 호출된다. 이미 거친 것들이기에 오류 페이지를 호출하기 위해 중간에 있는 것들이 한 번 더 호출되는 것은 비효율적이다.

결국 클라이언트로부터 발생한 정상 요청인지 아니면 오류 페이지를 제공하기 위한 내부 요청인지 구분할 수 있어야 한다. 서블릿은 이런 문제를 해결하기 위해 DispatcherType이라는 추가 정보를 제공한다.

❗DispatcherType

2024-08-18T22:27:08.504+09:00  INFO 11960 --- [exception] [nio-8080-exec-3] h.exception.servlet.ErrorPageController  : dispatcher type: ERROR

dispatcher type: ERROR인 부분을 볼 수 있는데 이 서블릿 스펙은 실제 고객으로부터의 요청인 것인지, 서버가 내부에서 오류 페이지를 요청하는 것인지 구분할 수 있는 방법을 제공한다.

public enum DispatcherType {
    FORWARD,		// MVC에서 배웠던 서블릿이나 다른 서블릿, 혹은 JSP를 호출 시
    INCLUDE,		// 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
    REQUEST,		// 클라이언트 요청
    ASYNC,			// 서블릿 비동기 호출
    ERROR;			// 오류 요청

    private DispatcherType() {
    }
}
@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Bean
	public FilterRegistrationBean filterRegistrationBean() {
		FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
		filterFilterRegistrationBean.setFilter(new LogFilter());
		filterFilterRegistrationBean.setOrder(1);
		filterFilterRegistrationBean.addUrlPatterns("/*");
		filterFilterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
		return filterFilterRegistrationBean;
	}
}

실행 결과

2024-08-18T22:43:38.253+09:00  INFO 10852 --- [exception] [nio-8080-exec-1] hello.exception.filter.LogFilter         : REQUEST [0840888a-26e5-4d8f-ae59-983e11af2bce][REQUEST][/error-500]
2024-08-18T22:43:38.303+09:00  INFO 10852 --- [exception] [nio-8080-exec-1] hello.exception.filter.LogFilter         : RESPONSE [0840888a-26e5-4d8f-ae59-983e11af2bce][REQUEST][/error-500]
2024-08-18T22:43:38.309+09:00  INFO 10852 --- [exception] [nio-8080-exec-1] hello.exception.filter.LogFilter         : REQUEST [db977e24-9d39-45a1-a891-784b7e7019de][ERROR][/error-page/500]
2024-08-18T22:43:38.311+09:00  INFO 10852 --- [exception] [nio-8080-exec-1] h.exception.servlet.ErrorPageController  : errorPage 500
2024-08-18T22:43:38.312+09:00  INFO 10852 --- [exception] [nio-8080-exec-1] h.exception.servlet.ErrorPageController  : ERROR EXCEPTION: null
2024-08-18T22:43:38.312+09:00  INFO 10852 --- [exception] [nio-8080-exec-1] h.exception.servlet.ErrorPageController  : dispatcher type: ERROR
2024-08-18T22:43:38.594+09:00  INFO 10852 --- [exception] [nio-8080-exec-1] hello.exception.filter.LogFilter         : RESPONSE [db977e24-9d39-45a1-a891-784b7e7019de][ERROR][/error-page/500]

위 필터는 클라이언트의 요청이 올 때(REQUEST), 오류 요청일 때(ERROR) 작동하는 필터이다.
filterFilterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR)와 같이 두 가지를 모두 넣으면 클라이언트 요청은 물론이고 오류 페이지 요청에서도 필터가 호출된다. 아무것도 넣지 않으면 기본 값은 REQUEST이다. 특별히 오류 페이지 경로도 필터를 적용할 것이 아니라면 기본 값 그대로 사용하면 된다.

🙄서블릿 예외 처리 - 인터셉터

@Slf4j
public class LogInterceptor implements HandlerInterceptor {
	public 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);
		}
	}
}

실행 결과

2024-08-18T22:59:31.638+09:00  INFO 22468 --- [exception] [nio-8080-exec-3] h.exception.interceptor.LogInterceptor   : REQUEST [5108e21b-c57a-4e6a-870c-167ee1b2e6ef][REQUEST][/error-ex][hello.exception.servlet.ServletExceptionController#errorEx()]
2024-08-18T22:59:31.638+09:00  INFO 22468 --- [exception] [nio-8080-exec-3] h.exception.interceptor.LogInterceptor   : RESPONSE [5108e21b-c57a-4e6a-870c-167ee1b2e6ef][REQUEST][/error-ex]
2024-08-18T22:59:31.639+09:00 ERROR 22468 --- [exception] [nio-8080-exec-3] h.exception.interceptor.LogInterceptor   : afterCompletion error!!

전체 흐름 정리

WAS(/hello, REQUEST) → 필터 → 서블릿 → 인터셉터 → 컨트롤러 → View 반환


예외 흐름 정리

  • 필터는 DispatcherType으로 중복 호출 제거(REQUEST)
  • 인터셉터는 excludePathPatterns으로 중복 호출 제거("/error-page/**")
  • 정상 : WAS(/error-ex, ERROR) → 필터 → 서블릿 → 인터셉터 → 컨트롤러(예외 발생)

  • 예외 : WAS(/error-ex, ERROR) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외 발생)

  • 오류 페이지 : WAS(/error-ex, ERROR) → 필터(X) → 서블릿 → 인터셉터(X) → 컨트롤러(오류 페이지) → View 반환

🙄스프링 부트 - 오류 페이지1

위와 같은 수동 작업을 스프링 부트는 모두 기본으로 제공한다.

  • ErrorPage를 자동으로 등록한다. 이 때, /error라는 경로로 기본 오류 페이지를 설정한다.
  • BasicErrorController라는 스프링 컨트롤러를 자동으로 등록한다.
@Slf4j
@Controller
public class ServletExceptionController {

	@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, "500 오류");
	}
	
	@GetMapping("/error-400")
	public void sendError(HttpServletResponse response) throws IOException {
		response.sendError(400, "4xx 오류");
	}
}

이렇게 작성한 후 개발자는 오류 페이지만을 등록한다. 정적 HTML이면 정적 리소스(static), 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶다면 뷰 템플릿(template) 경로에 오류 페이지 파일을 만들어 넣어두기만 하면 된다.

이 때, 뷰 템플릿이 정적 리소스보다 우선순위가 높고, 404, 500처럼 구체적인 것이 4xx, 5xx처럼 추상적인 것보다 우선순위가 높다.

🙄스프링 부트 - 오류 페이지2

스프링 부트는 BasicErrorController라는 스프링 컨트롤러를 자동으로 등록하는데 이 때, 다음과 같은 정보를 model에 담아서 뷰에 전달할 수 있다. 뷰 템플릿은 이 값을 활용해서 출력할 수 있다.

* 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`)
  • application.properties에 예외 관련 설정 추가(그러나 이 부분은 보안상의 이유로 추가하지 않는 것이 좋음)
# Error
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
server.error.include-binding-errors=always

0개의 댓글