• spring
  • spring-mvc
  • servlet

스프링 인터셉터와 예외 발생 시 오류 페이지 흐름

인터셉터의 호출 시점과 로그인 체크 구현, 예외가 WAS까지 전달된 뒤 오류 페이지가 다시 요청되는 흐름을 정리한다.

시리즈 · Spring8 / 14
  1. SOLID 원칙과 OCP·DIP를 지키는 방법
  2. 의존성 주입(DI)과 스프링 컨테이너
  3. 쓰레드 풀과 싱글톤 서블릿의 동시성 문제
  4. FrontController 패턴과 핸들러 어댑터로 스프링 MVC 구조 이해하기
  5. @ModelAttribute와 PRG 패턴으로 폼 요청 처리하기
  6. System.out 대신 SLF4J로 로그 남기기
  7. 세션 직접 만들어 보기와 HttpSession
  8. 스프링 인터셉터와 예외 발생 시 오류 페이지 흐름
  9. 서블릿 Part와 MultipartFile로 파일 업로드 처리하기
  10. JDBC로 CRUD 구현하기와 커넥션 풀, DataSource
  11. JDBC 트랜잭션에서 스프링 트랜잭션 동기화와 트랜잭션 AOP까지
  12. JPA 변경 감지(dirty checking)와 병합(merge)
  13. 서블릿 컨테이너 초기화와 스프링 컨테이너 등록 과정
  14. 싱글톤 빈의 동시성 문제와 ThreadLocal

스프링 인터셉터

스프링 인터셉터는 서블릿 필터처럼 웹과 관련된 공통 관심 사항을 처리하는 기술이다. 모든 요청 URL을 로그로 남기거나 로그인 여부를 확인하는 것처럼 여러 컨트롤러에 공통으로 필요한 로직을, 컨트롤러마다 호출하지 않고 한곳에 분리해 적용할 수 있다.

호출 흐름

인터셉터는 서블릿(DispatcherServlet)과 컨트롤러 사이에서, 컨트롤러 호출 직전에 호출된다.

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 (적절하지 않은 요청이면 컨트롤러 호출 X)

HandlerInterceptor

HandlerInterceptor 인터페이스를 구현하면 된다.

public interface HandlerInterceptor {

    default boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                              Object handler) throws Exception {
        return true;
    }

    default void postHandle(HttpServletRequest request, HttpServletResponse response,
                            Object handler, @Nullable ModelAndView modelAndView) throws Exception {
    }

    default void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, @Nullable Exception ex) throws Exception {
    }
}

DispatcherServlet, 핸들러 어댑터, 뷰 렌더링 사이에서 preHandle, postHandle, afterCompletion이 호출되는 흐름

정상 흐름은 다음과 같다.

  • preHandle: 컨트롤러 호출 전에 호출된다. true를 반환하면 다음으로 진행하고, false를 반환하면 나머지 인터셉터는 물론 핸들러 어댑터도 호출하지 않고 끝낸다.
  • postHandle: 컨트롤러 호출 후에 호출된다. ModelAndView를 파라미터로 받으므로 모델 정보도 확인할 수 있다.
  • afterCompletion: 뷰가 렌더링된 이후에 호출된다.

컨트롤러에서 예외가 발생하면 달라진다.

  • postHandle은 호출되지 않는다.
  • afterCompletion은 항상 호출되며, 파라미터로 예외(ex)를 받기 때문에 어떤 예외가 발생했는지 확인할 수 있다.

로그인 체크 인터셉터

인증은 컨트롤러 호출 전에만 확인하면 되므로 preHandle만 구현한다.

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        log.info("인증 체크 인터셉터 실행 {}", requestURI);

        HttpSession session = request.getSession(false);
        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미인증 사용자 요청");
            // 로그인 페이지로 redirect
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }

        return true;
    }
}

인터셉터 등록

적용할 경로와 제외할 경로를 등록 시점에 addPathPatterns, excludePathPatterns로 지정한다. 덕분에 인터셉터 내부에서 URL을 검사하는 코드를 둘 필요가 없다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginCheckInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/members/add", "/login", "/logout",
                        "/css/**", "/*.ico", "/error");
    }
}

인터셉터가 필터보다 더 많고 편리한 기능을 제공하므로, 꼭 필터를 써야 하는 상황이 아니라면 인터셉터를 사용한다.

예외와 오류 페이지

웹 애플리케이션은 요청별로 쓰레드가 할당되어 서블릿 컨테이너 안에서 실행된다. 애플리케이션에서 예외를 잡지 못하면 예외는 서블릿 밖, WAS까지 전달된다.

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

response.sendError()를 호출한 경우에도 비슷하다. WAS는 클라이언트에 응답하기 전에 sendError()가 호출되었는지 확인하고, 호출되었다면 오류 페이지를 보여 준다.

오류 페이지 요청 흐름

WAS는 오류를 인지하면 등록된 오류 페이지 경로를 내부에서 다시 요청한다.

1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외 발생)
2. WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

문제는 오류 페이지를 요청할 때 필터와 인터셉터가 다시 호출된다는 점이다. 로그인 체크처럼 이미 끝난 검증을 또 수행하는 것은 비효율적이다. 따라서 클라이언트의 요청인지, 오류 페이지를 위한 내부 요청인지 구분할 수 있어야 한다.

필터: DispatcherType

서블릿은 이를 구분할 수 있도록 DispatcherType이라는 정보를 제공한다.

  • 클라이언트가 처음 요청한 경우: dispatcherType=REQUEST
  • 오류 페이지를 위한 내부 호출: dispatcherType=ERROR

필터는 setDispatcherTypes로 어떤 타입의 요청에 적용할지 지정한다. 기본값은 REQUEST이므로 별도로 설정하지 않으면 오류 페이지 요청에는 필터가 호출되지 않는다. 아래처럼 ERROR를 추가하면 오류 페이지 요청에도 필터가 적용된다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        // REQUEST, ERROR 두 가지 경우에 필터를 호출한다.
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
        return filterRegistrationBean;
    }
}

인터셉터: excludePathPatterns

인터셉터는 서블릿이 아니라 스프링이 제공하는 기능이므로 DispatcherType과 무관하게 항상 호출된다. 대신 excludePathPatterns로 오류 페이지 경로를 제외하면 된다.

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LogInterceptor())
            .order(1)
            .addPathPatterns("/**")
            // 오류 페이지 경로 제외
            .excludePathPatterns("/css/**", "/*.ico", "/error", "/error-page/**");
}

스프링 부트의 오류 페이지

스프링 부트는 오류 페이지 등록과 오류 처리 컨트롤러를 기본으로 제공한다. 개발자는 정해진 경로에 오류 페이지 파일만 넣어 두면 된다.

  • 뷰 템플릿: resources/templates/error/500.html, resources/templates/error/5xx.html
  • 정적 리소스: resources/static/error/400.html, resources/static/error/4xx.html

참고

  • 김영한, 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 (인프런)