ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 예외 처리와 오류 페이지
    Engineering WIKI/Spring Boot 2023. 11. 2. 00:13
    • 해당 내용은, '인프런 - 스프링 MVC 2편 - 김영한님 강의'를 개인적으로 개념정리를 위해 정리한 내용입니다.

    서블릿 예외 처리 - 시작

    • 스프링이 아닌 순수 서블릿 컨테이는 예외를 어떻게 처리하는지 알아보자
    • 서블릿은 다음 2가지 방식으로 예외를 처리한다.
      • Exception (예외)
      • response.sendError (HTTP 상태 코드, 오류 메시지)
    • Exception(예외)
      • 자바 직접 실행
        • 자바의 메인 메서드를 직접 실행하는 경우 main 이라는 이름의 쓰레드가 실행된다. 실행 도중에 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드는 종료된다.
      • 웹 애플리케이션
        • 웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다. 애플리케이션에서 예외가 발생했는데, 어디선가 try ~ catch로 예외를 잡아서 처리하면 아무런 문제가 없다. 그런데 만약에 애플리케이션에서 예외를 잡지 못하고, 서블릿 밖으로 까지 예외가 전달되면 어떻게 동작할까?
          • WAS(여기까지 전파) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외발생) 결국 톰캣 같은 WAS 까지 예외가 전달된다.
        • WAS는 예외가 올라오면 어떻게 처리해야 할까?
    • response.sendError(HTTP 상태 코드, 오류 메시지)
      • 오류가 발생했을 때 HttpServletResponse 가 제공하는 sendError 라는 메서드를 사용해도 된다.
      • 이것을 호출한다고 당장 예외가 발생하는 것은 아니지만, 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다. 이 메서드를 사용하면 HTTP 상태 코드와 오류 메시지도 추가할 수 있다. response.sendError(HTTP 상태 코드) response.sendError(HTTP 상태 코드, 오류 메시지)
      • ServletExController - 추가
      @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 흐름
        • WAS(sendError 호출 기록 확인) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러 (response.sendError())
        • response.sendError() 를 호출하면 response 내부에는 오류가 발생했다는 상태를 저장해둔다. 그리고 서블릿 컨테이너는 고객에게 응답 전에 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 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;
    
    @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); 
    	} 
    }
    • 해당 오류를 처리할 컨트롤러가 필요
    package hello.exception.servlet;
    
    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"; 
    	} 
    }

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

    • 서블릿은 Exception (예외)가 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError() 가 호출 되었을 때 설정된 오류 페이지를 찾는다
    • 예외 발생 흐름
      • WAS(여기까지 전파) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외발생)
    • sendError 흐름
      • WAS(sendError 호출 기록 확인) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러 (response.sendError())
    • 예를 들어서 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
    • 오류 정보 추가
      • WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 request 의 attribute 에 추가해서 넘겨준다.
      • 필요하면 오류 페이지에서 이렇게 전달된 오류 정보를 사용할 수 있다
    • ErrorPageController - 오류 출력
    package hello.exception.servlet; 
    
    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: ex=", request.getAttribute(ERROR_EXCEPTION)); 
    		log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE)); 
    		log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE)); // ex의 경우 NestedServletException 스프링이 한번 감싸서 반환 
    		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)); 
    		log.info("dispatchType={}", request.getDispatcherType()); 
    	}
     }
    • request.attribute에 서버가 담아준 정보
      • javax.servlet.error.exception : 예외
      • javax.servlet.error.exception_type : 예외 타입
      • javax.servlet.error.message : 오류 메시지
      • javax.servlet.error.request_uri : 클라이언트 요청 URI
      • javax.servlet.error.servlet_name : 오류가 발생한 서블릿 이름
      • javax.servlet.error.status_code : HTTP 상태 코드

    서블릿 예외 처리 - 필터

    • 오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 한번 호출이 발생한다.
    • 이때 필터, 서블릿, 인터셉터도 모두 다시 호출된다. 그런데 로그인 인증 체크 같은 경우를 생각해보면, 이미 한번 필터나, 인터셉터에서 로그인 체크를 완료했다.
    • 따라서 서버 내부에서 오류 페이지를 호출한다고 해서 해당 필터나 인터셉트가 한번 더 호출되는 것은 매우 비효율적이다.
    • 결국 클라이언트로 부터 발생한 정상 요청인지, 아니면 오류 페이지를 출력하기 위한 내부 요청인지 구분할 수 있어야 한다.
    • 서블릿은 이런 문제를 해결하기 위해 DispatcherType 이라는 추가 정보를 제공한다
    • DispatcherType
      • 필터는 이런 경우를 위해서 dispatcherTypes 라는 옵션을 제공한다.
      • 이전 강의의 마지막에 다음 로그를 추가했다.
        • log.info("dispatchType={}", request.getDispatcherType())
      • 그리고 출력해보면 오류 페이지에서 dispatchType=ERROR 로 나오는 것을 확인할 수 있다.
      • 고객이 처음 요청하면 dispatcherType=REQUEST 이다.
      • 이렇듯 서블릿 스펙은 실제 고객이 요청한 것인지, 서버가 내부에서 오류 페이지를 요청하는 것인지 DispatcherType 으로 구분할 수 있는 방법을 제공한다
      • javax.servlet.DispatcherType
    public enum DispatcherType { 
    	FORWARD, 
    	INCLUDE, 
    	REQUEST, 
    	ASYNC, 
    	ERROR
    }
    • DispatcherType
      • REQUEST : 클라이언트 요청
      • ERROR : 오류 요청
      • FORWARD : MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때
        • RequestDispatcher.forward(request, response);
      • INCLUDE : 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
        • RequestDispatcher.include(request, response);
      • ASYNC : 서블릿 비동기 호출
    • 필터와 DispatcherType
      • LogFilter - 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) { 
      			throw e; 
      		} finally { 
      			log.info("RESPONSE [{}][{}][{}]", uuid, request.getDispatcherType(), requestURI); 
      		} 
      } 
      	@Override public void destroy() { 
      		log.info("log filter destroy"); 
      	} 
      }
      
      • 로그를 출력하는 부분에 request.getDispatcherType() 을 추가해두었다
    • WebConfig
      • filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
      • 이렇게 두 가지를 모두 넣으면 클라이언트 요청은 물론이고, 오류 페이지 요청에서도 필터가 호출된다.
      • 아무것도 넣지 않으면 기본 값이 DispatcherType.REQUEST 이다. 즉 클라이언트의 요청이 있는 경우에만 필터가 적용된다.
      • 특별히 오류 페이지 경로도 필터를 적용할 것이 아니면, 기본 값을 그대로 사용하면 된다.
      • 물론 오류 페이지 요청 전용 필터를 적용하고 싶으면 DispatcherType.ERROR 만 지정하면 된다
    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> filterRegistrationBean = new FilterRegistrationBean<>(); 
    		filterRegistrationBean.setFilter(new LogFilter()); 
    		filterRegistrationBean.setOrder(1); 
    		filterRegistrationBean.addUrlPatterns("/*"); 
    		filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR); 
    		
    		return filterRegistrationBean; 
    	}
     }

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

    • LogInterceptor - DispatcherType 로그 추가
      • 앞서 필터의 경우에는 필터를 등록할 때 어떤 DispatcherType 인 경우에 필터를 적용할 지 선택할 수 있었다. 그런데 인터셉터는 서블릿이 제공하는 기능이 아니라 스프링이 제공하는 기능이다. 따라서 DispatcherType 과 무관하게 항상 호출된다
      • 대신에 인터셉터는 다음과 같이 요청 경로에 따라서 추가하거나 제외하기 쉽게 되어 있기 때문에, 이러한 설정을 사용해서 오류 페이지 경로를 excludePathPatterns 를 사용해서 빼주면 된다
      • LogInterceptor - DispatcherType 로그 추가
    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 { 
    	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); 
    		}
    	 } 
    }

     

     

    • 필터는 DispatchType 으로 중복 호출 제거 ( dispatchType=REQUEST )
    • 인터셉터는 경로 정보로 중복 호출 제거( excludePathPatterns("/error-page/")**
Designed by Tistory.