Spring의 예외처리와 오류페이지
Spring

Spring의 예외처리와 오류페이지

예외 처리랑 오류 페이지를 만드는 작업을 왜 해야 하나요?

  • 웹 사이트 이용시 비정상적인 접근을 할 경우 서버에서 오류 페이지를 반환받은 경험이 있을것이다.
  • 위와 같은 상황에서 개발자는 사용자에게 정상적인 요청이 이루어지지 않은 이유를 알려줘야 할 필요가 있다.
  • Spring은 이러한 오류처리를 편리하게 제공한다.
  • 이 글에서는 Spring의 예외 처리와 오류페이지에 대해 설명하며 이러한 동작을 할 때 내부원리의 동작과정에 대해서 설명한다.

스프링의 예외처리는 어떻게 진행되나요?

  • 스프링은 ErrorPage를 자동으로 등록한다.
  • src/main/resources/error 경로의 HTML 파일을 오류 반환 HTML로 설정한다.
    • 파일 이름을 오류 번호대로 해야한다.
      • ex:)300.html, 3xx.html, 400.html, 500.html
      • 3xx의 xx는 해당 맨 앞 번호대의 오류 번호일 경우 응답한다.
  • 기본 경로를 기반으로 오류 처리번호가 구체적인 HTML를 View에 반환한다.
    • 예를 들어 4xx, 404가 html이 있을 경우 404 홈페이지가 요청될 경우 더 구체적인 404 에러페이지를 반환한다.
    • 정적 HTML이면 정적리소스 Directory에 넣으면 된다.
    • ex:)resources/static/error/400.html
    • 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶다면 뷰 템플릿 경로에 오류페이지를 넣으면 된다.
      • ex:)1. resources/templates/error/500.html
  • 이러한 로직들은 Spring의 BasicErrorController가 제공하는 기능이다.

BasicErrorControler

/*
 * Copyright 2012-2021 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.boot.autoconfigure.web.servlet.error;

import java.util.Collections;
import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.boot.web.servlet.server.AbstractServletWebServerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.Assert;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

/**
 * Basic global error {@link Controller @Controller}, rendering {@link ErrorAttributes}.
 * More specific errors can be handled either using Spring MVC abstractions (e.g.
 * {@code @ExceptionHandler}) or by adding servlet
 * {@link AbstractServletWebServerFactory#setErrorPages server error pages}.
 *
 * @author Dave Syer
 * @author Phillip Webb
 * @author Michael Stummvoll
 * @author Stephane Nicoll
 * @author Scott Frederick
 * @since 1.0.0
 * @see ErrorAttributes
 * @see ErrorProperties
 */
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {

    private final ErrorProperties errorProperties;

    /**
     * Create a new {@link BasicErrorController} instance.
     * @param errorAttributes the error attributes
     * @param errorProperties configuration properties
     */
    public BasicErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties) {
        this(errorAttributes, errorProperties, Collections.emptyList());
    }

    /**
     * Create a new {@link BasicErrorController} instance.
     * @param errorAttributes the error attributes
     * @param errorProperties configuration properties
     * @param errorViewResolvers error view resolvers
     */
    public BasicErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties,
            List<ErrorViewResolver> errorViewResolvers) {
        super(errorAttributes, errorViewResolvers);
        Assert.notNull(errorProperties, "ErrorProperties must not be null");
        this.errorProperties = errorProperties;
    }

    @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);
    }

    @ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
    public ResponseEntity<String> mediaTypeNotAcceptable(HttpServletRequest request) {
        HttpStatus status = getStatus(request);
        return ResponseEntity.status(status).build();
    }

    protected ErrorAttributeOptions getErrorAttributeOptions(HttpServletRequest request, MediaType mediaType) {
        ErrorAttributeOptions options = ErrorAttributeOptions.defaults();
        if (this.errorProperties.isIncludeException()) {
            options = options.including(Include.EXCEPTION);
        }
        if (isIncludeStackTrace(request, mediaType)) {
            options = options.including(Include.STACK_TRACE);
        }
        if (isIncludeMessage(request, mediaType)) {
            options = options.including(Include.MESSAGE);
        }
        if (isIncludeBindingErrors(request, mediaType)) {
            options = options.including(Include.BINDING_ERRORS);
        }
        return options;
    }

    /**
     * Determine if the stacktrace attribute should be included.
     * @param request the source request
     * @param produces the media type produced (or {@code MediaType.ALL})
     * @return if the stacktrace attribute should be included
     */
    protected boolean isIncludeStackTrace(HttpServletRequest request, MediaType produces) {
        switch (getErrorProperties().getIncludeStacktrace()) {
        case ALWAYS:
            return true;
        case ON_PARAM:
            return getTraceParameter(request);
        default:
            return false;
        }
    }

    /**
     * Determine if the message attribute should be included.
     * @param request the source request
     * @param produces the media type produced (or {@code MediaType.ALL})
     * @return if the message attribute should be included
     */
    protected boolean isIncludeMessage(HttpServletRequest request, MediaType produces) {
        switch (getErrorProperties().getIncludeMessage()) {
        case ALWAYS:
            return true;
        case ON_PARAM:
            return getMessageParameter(request);
        default:
            return false;
        }
    }

    /**
     * Determine if the errors attribute should be included.
     * @param request the source request
     * @param produces the media type produced (or {@code MediaType.ALL})
     * @return if the errors attribute should be included
     */
    protected boolean isIncludeBindingErrors(HttpServletRequest request, MediaType produces) {
        switch (getErrorProperties().getIncludeBindingErrors()) {
        case ALWAYS:
            return true;
        case ON_PARAM:
            return getErrorsParameter(request);
        default:
            return false;
        }
    }

    /**
     * Provide access to the error properties.
     * @return the error properties
     */
    protected ErrorProperties getErrorProperties() {
        return this.errorProperties;
    }

}
  • BasicErrorControlerr는 아래와 같은 정보를 Model에 담아 뷰에 전달한다.
    • timeStamp: 에러 발생시간
    • status : 상태
    • error : error 발생 원인
    • exception : 예외 정보
    • trace : 예외 trace
    • meesgae: 검증이 실패했을 경우의 object, 숫자등 정보들
    • errors : 에러 종류
    • path : 클라이언트 요청 경로
  • 뷰 템플릿을 활용해 이러한 값들을 출력할 수 있다.
<!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는 오류 정보를 model에 포함할 지에 대한 여부를 사용자에게 맡긴다.
  • 아래와 같은 옵션을 활용하여 Model에 데이터를 전할지 말지 결정한다./
server.error.include-exception=false //기본 화이트라벨 기반 오류 페이지
server.error.include-message=on_param//오류 message여부
server.error.include-stacktrace=on_param//오류 stacktrace 여부
server.error.include-binding-errors=on_param//오류 errrors 종류 여부

속성 종류

  • never : 사용하지 않음
  • always :항상 사용
  • on_param : 파라미터가 있을 때(message나 statrace옵션이 on_param이고 URL 쿼리 파라미터가 존재할 경우 오류를 출력한다는 의미이다.)

중간정리

  • 스프링의 에러처리는 BindingErrorController가 자동으로 처리해주기 때문에 개발자는 편리하게 error처리 홈페이지만 등록해서 사용하면 된다는것을 알 수 있다.
  • 그렇다면 그 내부 동작을 스프링이 아닌 순수 Servlet 컨테이너부터 살펴보자

서블릿 예외처리 방법

  • 서블릿은 Exceptionresponse.sendError(Http 오류메시지)를 활용해서 에러를 WAS에게 전달할 수 있다.
    • 만약 Exception이 Servlet 밖까지 나갈 경우에는 WAS에서 예외를 반환한다.
    • response.sendError()또한 예외처리가 안될 경우 WAS에서 최종적으로 예외처리를 진행한다.

서블릿의 오류 페이지 작동 원리

  • 서블릿은 Exception (예외)이 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError()가 호출되었을 때 설정된 오류 페이지를 찾는다.
  • 사용자 요청이 controller에 와서 예외가 발생하기까지의 흐름은 아래와 같다.
  • 요청 → WAS → 필터 → Servlet → 인터셉터 → Controller(예외 발생
  • 예외가 발생한 이후 response.sendError()의 흐름은 아래와 같다.
  • WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 (response.sendError())
  • 이때 WAS는 미리 등록한 해당 예외를 처리하는 오류 페이지 정보를 확인한다.
    • 과거에는 아래와 같이 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>
  • Spring Boot를 사용하면 아래와 같이 간편하게 등록이 가능하다.
package hello.exception;

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;

/**
 * WebServerFactoryCustomizer를 사용하게 되면 Spring의 기본 옵션인 BasciErrorController에서 처리하는 옵션대로 진행이 안된다.
 * 에러 경로를 커스터 마이징하고 싶을때 사용하자
 */
//@Component
//errorPage를 등록하는 과정
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);
    }
}
  • 오류 페이지 정보를 확인하고 다시 오류 페이지를 출력하기 위해 controller에게 오류를 재요청하게 된다.
  • WAS /error-page/500 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/ 500) -> View
  • 지금까지의 방식을 보면 비효율적인 부분이 한 가지 보인다.
  • 이미 한 번 요청이 일이나서 필터와 인터셉터를 걸치면서 검증된 호출 로직이 오류 페이지를 출력하기 위해 다시 한 번 필터와 인터셉터 호출이 일어나는점은 굉장히 비효율적이다.
  • 이러한 문제를 해결하기 위해 필터는 DispatchrType, 인터셉터는 excludePathPatterns를 지원하여 자체적으로 error관련 URL을 제외할 수 있다.
  • DispatchrType은 해당 타입의 요청일 경우에만 필터가 동작하도록 설정하는것을 의미한다.
  • 그렇기 때문에 Servlet은 기본값으로 Request로 설정되어있다. (ERROR는 동작할 이유가 없으니까!)
  • 필터의 DispatchrType의 종류는 아래와 같다.
    • REQUEST : 클라이언트 요청
    • ERROR : 오류 요청
    • FORWARD : MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때RequestDispatcher.forward(request, response);
    • INCLUDE : 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때 RequestDispatcher.include(request, response);
    • ASYNC : 서블릿 비동기 호출
  • 인터셉터는 http 요청이 올 경우 아래와 같이 excludePatterns를 활용해 에러페이지는 검증을 안하도록 제외 해야한다.
package hello.exception;

import hello.exception.filter.LogFilter;
import hello.exception.interceptor.LogInterceptor;
import hello.exception.resolver.MyHandlerExceptionResolver;
import hello.exception.resolver.UserHandlerExceptionResolver;
import org.apache.catalina.User;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.servlet.DispatcherType;
import javax.servlet.Filter;
import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    //error-page를 추가해주어 중복을 제거하였음
    public void addInterceptors(InterceptorRegistry registry){
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "*.ico", "/error-page/**");
    }
}

정리

  • Spring은 우리들의 예외처리 및 오류 페이지를 View에 반환해주작업을 BasicErrorController를 활용하여 자동으로 처리해준다.
  • 필터는 DispatchType을, 인터셉터는 excluedePatterns를 사용해 오류 페이지 반환시 불필요한 호출을 하지 않는다.
    • 인터셉터는 excludePatterns를 통해 항상 설정해주는것을 잊지말자

 

예제의 모든 코드

https://github.com/NamHyeop/Spring_Boot_Study/tree/master/4.spring-MVC2/exception

 

GitHub - NamHyeop/Spring_Boot_Study: Spring 공부를 하며 기록한 자료들입니다.

Spring 공부를 하며 기록한 자료들입니다. Contribute to NamHyeop/Spring_Boot_Study development by creating an account on GitHub.

github.com

REFERENCE.

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

 

'Spring' 카테고리의 다른 글

Spring의 API 예외 처리  (0) 2022.07.05
Spring의 필터와 인터셉터  (0) 2022.07.01
Spring의 쿠키,세션  (0) 2022.06.29
Spring의 Bean Validation  (0) 2022.06.28
Spring 메시지, 국제화  (0) 2022.06.27