본문 바로가기
Web/spring study

[Spring] 서블릿 필터, 스프링 인터셉터

by 장인이 2024. 3. 20.

0. 개요

 이전 게시물에서 로그인 기능을 만들고, 사용자가 로그인 시 식별할 수 있도록 하였다. 로그인을 한 사용자와 로그인을 하지 않은 사용자에게 서로 다른 홈 화면을 보여줌으로서, 로그인한 사용자만 상품 관리 화면으로 접근 가능할 수 있도록 해두었다. 하지만, 로그인하지 않은 사용자도 해당 주소를 직접 호출한다면? 아무 문제없이 상품 관리 화면에 들어갈 수 있게 된다...

 따라서 우리는 모든 상품 관리 컨트롤러에서 로그인 여부를 체크하는 로직을 작성하면 되지만, 수 많은 공통 로직이 발생할 것이다. 만일에 로그인 관련 로직이 변경되기라도 한다면? 모든 로직을 다 수정해야 한다. 이렇게 여러 로직에서 공통적으로 필요한 것을 공통 관심사라고 하며, 웹과 관련된 공통 관심사는 서블릿 필터 혹은 스프링 인터셉터로 구현할 수 있다.

 

 

1. 서블릿 필터

 서블릿 필터는 이름 그대로 "필터"이다.

 

필터 흐름

HTTP 요청  ->  WAS  ->  필터  ->  서블릿  ->  컨트롤러

 필터를 적용한다면, 필터가 호출 된 후 서블릿이 호출된다. 또한 필터는 특정 URL 패턴에 적용할 수 있다. 따라서 모든 요청에 로그를 남겨야 할경우 사용된다.

 

필터 제한

HTTP 요청  ->  WAS  ->  필터  ->  서블릿  ->  컨트롤러 //로그인한 사용자
HTTP 요청  ->  WAS  ->  필터 (조건에 충족하지 않으면, 서블릿 호출X) //로그인 X 사용자

 필터에서 조건을 만들어, 조건에 충족하지 않으면 서블릿을 호출하지 않고 끝낼 수 있다. 따라서 로그인 여부를 확인하기 좋은 방법이다.

 

필터 체인

HTTP 요청  ->  WAS  ->  필터1  -> 필터2  ->  ...  ->  서블릿  ->  컨트롤러

 필터는 체인 형식으로 이루어져 있으며, 중간에 필터를 자유롭게 추가할 수 있다.

 

Filter interface

public interface Filter {
    public default void init(FilterConfig filterConfig) throws ServletException{}
    
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
    
    public default void destroy() {}
}

 

 Filter 인터페이스를 구현한 후 bean으로 등록해서 사용가능하다. 그러면 서플릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리해준다.

- init() : 필터 초기화 메서드로, 서블릿 컨테이너가 생성될 때 호출된다.

- doFilter() : 고객의 요청이 올 때마다 호출된다. 여기에 필터 로직을 구현하면 된다.

- destroy() : 필터 종료 메서드로, 서플릿 컨테이너가 종료될 때 호출된다.

 

1) 서블릿 필터를 활용한 요청 로그

 우선 서블릿 필터를 활용하여 모든 요청에 로그를 남기는 필터를 개발하고 적용해보자.

@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, requestURI);
            chain.doFilter(request, response);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("RESPONSE [{}][{}]", uuid, requestURI);
        }
    }
    
    @Override
    public void destory() {
        log.info("log filter destory");
    }
}

 

 위에서 언급한 Filter 인터페이스를 구현하여 LogFilter를 만들었다.

 

 여기서 가장 중요한 것은 doFilter() 메서드이다. 모든 HTTP 요청이 오면, doFilter()가 호출된다. 파라미터로 ServletRequest를 받기 때문에, 다운 케스팅하여 사용하자. 

HttpServletRequest httpRequest = (HttpServletRequest) request;

 

 chain.doFilter(request, response); 이부분이 가장 중요하다. 다음 필터가 있으면 해당 필터를 호출, 필터가 없으면 서블릿을 호출하는데, 만약 해당 로직이 없다면 다음 단계로 진행 자체가 되지 않는다.

 

@Configuration
public class WebConfig {
    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }
}

 

 필터를 만들었다면, 등록을 해야된다! 스프링 부트가 제공하는 FilterRegistrationBean을 사용해서 등록하였다. setFilter()를 통해 등록할 필터를 지정하고, setOrder(1)을 통해 체인으로 동작하는 필터의 순서를 정해주자. 낮을수록 먼저 동작한다. addUrlPatterns("/*")을 통해 필터를 적용할 URL 패턴을 지정한다. 동시에 여러 패턴을 지정할 수 있다.

 

2) 서블릿 필터를 활용한 인증 체크

 이번에는 서블릿 필터를 활용해서 인증 체크 필터를 만들어보자. 로그인이 되지 않은 사용자는 몇가지 주소 빼고는 모두 접근하지 못하도록 해보자!

 

@Slf4j
public class LoginCheckFilter implements Filter {
    private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();
        
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        
        try {
            log.info("인증 체크 필터 시작 {}", requestURI);
            
            if (isLoginCheckPath(requestURI) {
                log.info("인증 체크 로직 실행 {}", requestURI);
                HttpSession session = httpRequest.getSession(false);
                if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
                    log.info("미인증 사용자 요청 {}", requestURI);
                    httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    return;
                }
            }
            
            chain.doFilter(request, response);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("인증 체크 필터 종료 {}", requestURI);
        }
    }
    
    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
    
}

 

 whitelist = {"/", ...}; 를 통해 로그인하지 않은 사용자도 접근할 수 있는 목록을 정하자. 홈 페이지, 로그인 및 회원가입 관련 페이지 등은 인증과 무관하게 허용하고, 나머지 모든 경로에 인증 체크 로직을 적용한다.

 

 isLoginCheckPath(String requestURI)해당 경로가 화이트 리스트에 없는지 여부를 체크하는 메서드이다. 경로가 화이트 리스트에 없는 경우가 true이므로, 별도의 함수를 만들어 관리한다.

 

 httpResponse.sendRedirect("/login?redirectURL=" + requestURI);를 통해 미인증 사용자는 로그인화면으로 돌려보낸다. 단 돌려보내진 사용자가 로그인에 성공할 경우, 홈 화면으로 돌아가기 보단 이전에 있던 페이지로 돌아가게 하기 위해 현재 요청한 경로인 requestURI를 쿼리 파라미터로 함께 전달한다.

 그 후 return;이 중요하다. chain.doFilter()가 호출되지 않은 시점에서 반환하기 때문에, 이후 필터는 물론이고 해당 코드에 의해 어떠한 서블릿, 컨트롤러 모두 실행되지 않는다. redirect가 응답으로 적용되고 해당 요청이 끝난다.

 

WebConfig 추가

@Bean
public FilterRegistrationBean loginCheckFilter() {
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
    filterRegistrationBean.setFilter(new LoginCheckFilter());
    filterRegistrationBean.setOrder(2);
    filterRegistrationBean.addUrlPatterns("/*");
    return filterRegistrationBean;
}

 

 setOrder(2)를 통해 순서를 2번으로 설정하였다. 따라서 로그 필터 실행 후, 로그인 필터가 적용된다.

 

 로그인에 성공하면 다시 처음 요청한 URL로 이동하는 기능을 추가하기 위해, 지난 게시물의 코드를 조금 수정해보자.

 

LoginController

@PostMapping("/login")
public String loginV4(
            @Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
            @RequestParam(defaultValue = "/") String redirectURL,
            HttpServletRequest request) {
            
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }
        
        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
        log.info("login? {}", loginMember);
        
        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }
        
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
        
        //redirectURL 적용하기
        return "redirect:" + redirectURL;
}

 

 쿼리 파라미터의 redirectURL 값을 입력받고, 값이 있을 경우 로그인 성공 시 해당 경로로 사용자를 redirect한다.

 

 

2. 스프링 인터셉터

 스프링 인터셉터도 웹과 관련된 공통 관심 사항을 해결할 수 있는 기술이다. 이는 스프링 MVC가 제공하며, 서블릿 필터 사용방법이 다르다.

 

스프링 인터셉터 흐름

HTTP 요청  ->  WAS  ->  필터  ->  서블릿  ->  스프링 인터셉터  ->  컨트롤러

 

 스프링 인터셉터DispatcherServlet과 컨트롤러 사이에서 실행된다. 스프링 MVC는 DispatcherServlet으로 값을 받는 것부터 시작되기 때문에, 결국 그 이후에 인터셉터를 사용할 수 있다. 

 

스프링 인터셉터 제한

HTTP 요청  ->  WAS  ->  필터  ->  서블릿  ->  스프링 인터셉터  ->  컨트롤러  //정상흐름
HTTP 요청  ->  WAS  ->  필터  ->  서블릿  ->  스프링 인터셉터 (조건에 맞지 않으면, 컨트롤러 호출 X) //비 로그인 사용자

 

 인터셉터도 필터와 마찬가지로 적절하지 않은 요청이라 판단되면 요청을 끝낼 수도 있다. 이로 인해 로그인 여부 체크와 같은 공통 관심 사항을 확인할 수 있다!

 

스프링 인터셉터 체인

HTTP 요청  ->  WAS  ->  필터  ->  서블릿  ->  인터셉터1  ->  인터셉터2  ->  컨트롤러

 

 스프링 인터셉터 또한 체인으로 구성된다. 마찬가지로 중간에 인터셉터를 자유롭게 삽입할 수 있고, 실행될 순서 또한 선택 가능하다.

 

 이렇게만 보면 실행되는 순서만 다르고, 기능은 비슷해보인다. 하지만 실제로 사용해보면 더 간편하고, 정교하며 다양한 기능을 제공한다.

 

스프링 인터셉터 인터페이스

public interface HandlerInterceptor {

    default boolean preHandle(HttpServletRequest request, HttpServletResponse response,
        Object handler) throws Exception {}
    
    default boolean 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 {}
}

 

 preHandle()은 컨트롤러 호출 전, 더 정확하게는 핸들러 어뎁터 호출 전에 호출된다. preHandle()의 반환값이 true라면 정상적으로 진행되고, 만일 반환값이 false라면 더 진행하지 않고 요청이 멈춘다

 postHandle()은 컨트롤러 호출 후, 더 정확하게는 컨트롤러가 호출한 핸들러 어뎁터 호출 후 호출된다.

 afterCompletion()은 뷰 렌더링 후에야 실행된다.

 

 만일 컨트롤러에서 예외가 발생한다면 어떻게 될까? 우선 preHandle()은 컨트롤러 전에 호출됨으로, 호출된다. postHandle()은 컨트롤러 후에 호출됨으로, 호출되지 않는다.

 여기서 특이한 점은, afterCompletion()은 가장 뒤에 위치해 있음에도 불구하고, 어떠한 경우에도 호출된다! 따라서 예외와 무관하게 공통 처리를 하려면 해당 메서드를 사용하자.

 

1) 스프링 인터셉터를 활용한 요청 로그

@Slf4j
public class LogInterceptor implements HandleInterceptor {
    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); //afterCompletion()에서 사용하기 위함
        
        //@RequestMapping 컨트롤러 -> HandlerMethod
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
        }
        
        log.info("REQUEST[{}][{}][{}]", uuid, 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, requestURI);
        if (ex != null) {
            log.error("afterCompletion error!!", ex);
        }
    }
}

 

 서블릿 필터와 다르게 파라미터에서 HttpServletRequest, HttpServletResponse를 받기 때문에, 별도로 캐스팅해줄 필요가 없음을 확인할 수 있다.

 또한, 컨트롤러 전에 호출되는 preHandle() 컨트롤러 후에 호출되는 afterCompletion()은 같은 요청이므로, 같은 uuid를 사용해야 된다. 하지만 호출 시점이 다르므로, request.setAttribute(LOG_ID, uuid); 를 통해 request에 담아두었다가, request.getAttribute(LOG_ID)를 통해 전달받자. 참고로 LogInterceptor 또한 싱글톤 처럼 사용되므로, 멤버변수를 사용하는 것은 위험하다.

 위에서 언급했듯이 preHandle()의 return값이 true이므로, 다음 인터셉터 혹은 컨트롤러가 정상적으로 호출될 것이다.

 

@Configuration
public class WebConfig implements WebMVCConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
    }
}

 

 WebMvcConfigurer가 제공하는 addInterceptor()를 사용해 인터셉터를 등록할 수 있다. order()를 통해 인터셉터의 호출 순서를 지정하며, 낮을수록 먼저 호출된다. 또한 인터셉터를 적용할 패턴, 제외할 패턴을 지정해줄 수 있다. 필터와 비교하면 매우 큰 장점이다.

 

2) 스프링 인터셉터를 활용한 인증 체크

 

@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("미인증 사용자 요청");
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }
        
        return true;
    }
}

 

 서블릿 필터에 비해 코드가 훨씬 간단하다. 또한 인증 체크는 컨트롤러 호출 전에만 호출되면 되므로, preHandle()만 구현하자.

 

WebConfig

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

 

 이렇게 인증 체크 인터셉터를 등록하면 된다. 전체적으로 정리해보았을때, 서블릿 필터와 비교해보면 매우 편리하다!

 

 

3. 정리

 결국, 공통 관심사를 처리하기 위해 서블릿 필터, 그리고 스프링 인터셉터를 사용해 보았다. 스프링 인터셉터가 훨씬 쉽고 간결했지만, 서블릿 필터를 한번 학습하고 넘어갔기 때문에 더 그런점도 없지 않았을까... 계속 공부해보자.

 

 

 

 

 


위 내용은 김영한 님의 인프런 강의 "스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술"의 내용과 강의자료를 토대로 작성된 게시글입니다.

강의 링크:

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

 

댓글