ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] 필터와 인터셉터 차이, 로그인 인증 체크하기
    Back-End/Spring Boot 2022. 11. 2. 14:30

     

    지난번 포스팅에서 간단히 Filter, Interceptor, APO의 차이점과 개념에 대하 알아봤다. 이번엔 필터와 인터셉터로 로그 남기기와 로그인한 사용자만 특정 화면에 접근할 수 있도록 인증 체크하는 예제를 통해 알아보려고 한다.

     

     

    https://wonisdaily.tistory.com/89

     

    [Spring] Filter, Interceptor, AOP 차이점

    자바 웹 개발을 하다보면 공통적으로 처리해야 할 업무들이 많다. 예를 들어 로그인 관련(세션체크)처리, 권한체크, PC와 모바일웹의 분기처리, 로그, 페이지 인코딩 변환 등등. 공통 관련된 코드

    wonisdaily.tistory.com

     

     

     

    📑 공통 관심사

     

    로그인한 사용자만 상품 관리 페이지에 들어갈 수 있어야한다. 상품 관리 컨트롤러에서 로그인 여부를 체크하는 로직을 하나하나 작성하면 되겠지만, 등록, 조회, 수정, 삭제 등등 상품관리의 모든 컨트롤러 로직에 공통으로 로그인 여부를 확인해야한다. 이것은 너무 번거로운 일이고 코드 재사용성이나 유지보수도 힘들어진다. 이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사(cross-cutting concern)이라고 한다. 

     

    이러한 공통 관심사는 스프링의 AOP로 해결할 수 있지만, 웹과 관련된 공통 관심사는 서블릿 필터 또는 스프링 인터셉터를 사용하는 것이 좋다.  (특정 url이 들어오면 처리하고, 아님 안 할거야같은 게 web과 관련된 관심사)

     

    웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL의 정보들이 필요한데, 서블릿 필터나 스프링 인터셉터는 HttpServletRequest를 제공한다.

     

     

     

     

    📑 서블릿 필터 - 개념

     

    필터 흐름은 다음과 같다.

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

     

    필터를 적용하면 필터가 호출된 다음 서블릿이 호출된다. 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있으면 필터를 사용하면 된다. 참고로 필터는  특정 URL 패턴에 적용할 수 있다. 만일 /*라고 하면 모든 요청에 필터가 적용된다. 스프링을 사용할 경우 서블릿은 디스패처 서블릿으로 생각하면 된다.

     

     

    📌 필터 제한은 다음과 같다.

    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
    HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출X) //비 로그인 사용자

     

    필터에서 적절하지 않은 요청이라고 생각하면 거기서 끝을 낼 수 있다. 

     

     

    📌 필터 인터페이스

     

    package hello.login.web.filter;
    
    import javax.servlet.*;
    import java.io.IOException;
    
    public class FilterEx implements Filter {
        @Override
        public void init(FilterConfig filterConfig) throws ServletException {
            Filter.super.init(filterConfig);
        }
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            
        }
    
        @Override
        public void destroy() {
            Filter.super.destroy();
        }
    }

     

    필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고 관리한다.

     

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

    doFilter() : 고객의 요청이 올 때 마다 해당 메서드가 호출된다. 필터 로직 구현

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

     

     

     

    📑 서블릿 필터 - 요청 로그

     

    package hello.login.web.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 {
            log.info("log filter doFilter");
    
            //HttpServletRequest로 다운 캐스팅
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            // 웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL의 정보들이 필요
            String requestURI = httpRequest.getRequestURI();
            //HTTP 요청을 구분하기 위해 요청당 임의의 uuid 를 생성해둔다.
            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 destroy() {
            log.info("log filter destroy");
    
        }
    }

     

     

    HTTP 요청이 오면 doFilter가 호출된다. HTTP를 사용하면 HttpServletRequest와 같이 다운 케스팅해서 사용하면 된다. chain.doFilter(request,response) 응답과 요청을 보내서 다음 필터가 있으면 그 필터를 호출하고, 필터가 없으면 서블릿을 호출한다. 이는 다음에  나올 로그인 검증 필터에서 결과를 확인해보자.

     

     

    <WebConfig - 필터 설정>

    package hello.login;
    
    import hello.login.web.argumentresolver.LoginMemberArgumentResolver;
    import hello.login.web.filter.LogFilter;
    import hello.login.web.filter.LoginCheckFilter;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    import javax.servlet.Filter;
    import java.util.List;
    
    @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(new LogFilter())로 등록할 필터를 지정하고, 필터의 순서를 정한다. 그 후 필터를 적용할 URL 필터를 지정한다. 

     

     

     

    📑 서블릿 필터 - 인증 체크

     

    package hello.login.web.filter;
    
    import hello.login.web.session.SessionConst;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.util.PatternMatchUtils;
    
    import javax.servlet.*;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    import java.io.IOException;
    
    @Slf4j
    public class LoginCheckFilter implements Filter {
    
        //필터를 적용해도 기본 페이지와 리소스는 접근할 수 있게 해야한다.
        //이렇게 화이트 리스트로 설정해두면 인증과 무관하게 항상 접근할 수 있다.
        //아래 isLoginCheckPath() 메서드를 거쳐 URI에 따라 감별한다.
        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);
                        //로그인으로 redirect , 대신 현재 페이지 정보를 포함해서 그 페이지로 돌아가게 한다.
                        //만약 /items 경로에 접근했는데 미인증 사용자로 로그인 페이지 이동 후 로그인 완료하면 다시 /items로 이동
                        httpResponse.sendRedirect("/login?redirectURL="+requestURI);
                        //필터를 진행하지 않겠으며 다음 서블릿이나 컨트롤러 호출 안하겠다.
                        //why? redirect로 이동했으니까! 
                        return;
                    }
                }
    
                chain.doFilter(request,response);
            }catch (Exception e){
                throw e; //예외 로깅 가능 하지만, 톰캣가지 예외를 보내주어야 한다.
            }finally {
                log.info("인증 체크 필터 종료 {}",requestURI);
            }
        }
    
    
        /**
         * 화이트 리스트의 경우 인증 체크 안함
         */
    
        private boolean isLoginCheckPath(String requestURI){
            //서로 패턴이 매칭 되는가
            //화이트 리스트에 들어가지 않은 건 인증 체크 필요 하다.
            return !PatternMatchUtils.simpleMatch(whitelist,requestURI);
        }
    
    }

     

    여기서 redirect로 "/login"의 파라미터를 넘겼다. requestURI 정보를 넘긴것인데 그렇다면 컨트롤러에 return도 변경해줘야한다. 

     

    컨트롤러에서 중요한 건 @RequestParam 어노테이션으로 파라미터로 들어온 값을 String 반환값을 가진 redirectURL이라는 변수로 해당 컨트롤러에서 사용하겠다는 의미이다. 만일 파라미터로 넘어온 값이 없으면 기본 경로인 "/"로 이동한다. 만약 있으면 return "redirect:" + redirectURL;로 이동한다. 

     

    <컨트롤러>

    @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);
        return "redirect:" + redirectURL;
    }

     

     

    <WebConfig - loginCheckFilter() 추가>

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

     

    위의 로그 필터를 추가해준것과 마찬가지로 미리 만들어둔 WebConfig에 LoginCheckFilter를 적용해주겠다. setOrder를 2번으로 지정했기에 사용자 기록 로그 출력 후 로그인 인증 체크가 들어간다.

     

    아래 사진을 살펴보면 일단 log의 init 다음 dofilter가 실행되고 요청과 응답을 다음 필터인 loginCheckFilter로 넘겼기에 로그인 체크 필터 로직이 실행된다. 만일 whiteList에 없는 페이지에서 미인증 사용자의 요청이 들어오면 redirect로 login 페이지로 넘기는 걸 확인 가능하다. 

     

     

     

    /items 입력시 redirectURL=/items라는 파라미터로 넘어가고 로그인 성공시 파라미터의 값을 넘겨서 /items로 이동한다.

     

     

     

    📑 스프링 인터셉터 - 개념

     

    스프링 인터셉터 또한 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있다. 

     

    스프링 인터셉터 흐름을 살펴보자.

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

     

    스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다. 스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 때문에 결국 디스패처 서블릿 이후에등장한다. 

     

     

     

    📌 스프링 인터셉터 제한

    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 사용자
    HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출 X) // 비 로그인 사용자

     

    스프링 인터셉터 또한 체인으로 구성되어, 중간에 인터셉터를 자유롭게 추가할 수 있다. 따라서 필터와 마찬가지로 모든 로그를 찍으며 로그인 여부를 체크할 수 있다는 것이다.

     

    ★ 필터와 호출 순서만 다르고 제공하는 기능은 비슷해 보이지만, 인터셉터가 훨씬 편하다.! 

     

     

     

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

     

    package hello.login.web.interceptor;
    
    import org.springframework.web.servlet.HandlerInterceptor;
    import org.springframework.web.servlet.ModelAndView;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    
    public class InterceptorEx implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            return HandlerInterceptor.super.preHandle(request, response, handler);
        }
    
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
            HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
        }
    }

     

     

    서블릿 필터의 경우 단순하게 doFilter() 하나만 제공된다. 그러나 인터셉터는 컨트롤러 호출 전(preHandle), 호출 후 (postHandle), 요청 완료 후 (afterCompletion)과 같이 단계적으로 세부화 되어 있다. 

     

    서블릿 필터의 경우 단순히 request,response만 제공했지만, 인터셉터는 어떤 컨트롤러(handler)가 호출되는지 호출 정보도 받을 수 있고, 어떤 modelAndView가 반환되는지 응답 정보도 받을 수 있다. 

     

     

     

     

    ✔ preHandle : 컨트롤러 호출 전에 호출된다. ( 더 정확히는 핸들러 어댑터 호출 전에 호출된다.)

    preHandle의 응답값이 true면 다음으로 진행하고, false면 더는 진행하지 않는다. false인 경우 나머지 인터셉터는 물론이고, 핸들러 어댑터도 호출되지 않는다. 위 그림에서 1번이 끝!

     

    ✔ postHandle : 컨트롤러 호출 후에 호출 ( 정확히는 핸들러 어댑터 호출 후에 호출)

     

    ✔ afterCompletion : 뷰가 렌더링된 이후 호출 , afterCompletion은 항상 호출된다. 이 경우 예외(ex)를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력 가능. 

     

     

     

    📑 스프링 인터셉터 - 요청로그

     

    package hello.login.web.interceptor;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.method.HandlerMethod;
    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();
            //요청 로그를 구분하기 위한 uuid 를 생성한다.
            String uuid = UUID.randomUUID().toString();
    
            //서블릿 필터 경우 지역변수로 해결 가능하지만, 스프링 인터셉터는 호출 시점이 완전히 분리되어 있다.
            //따라서 post, after 모두에서 같은 uuid 값을 사용하려면 어딘가 담아둬야 한다.
            //LogInterceptor도 싱글톤처럼 사용되기에 request에 담아두었다가. after에서 request.getAttribute(LOG_ID)로 찾아서 사용
            request.setAttribute(LOG_ID,uuid);
    
            //@RequestMapping : HandlerMethod
            //정적 리소스 : ResourceHttpRequestHandler
            if(handler instanceof HandlerMethod){
                HandlerMethod hm = (HandlerMethod) handler;//호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
            }
    
            log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
            //true로 해야 다음 호출
            return true;
        }
    
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
            log.info("postHandler[{}]", modelAndView);
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    
            String requestURI = request.getRequestURI();
            Object logId = (String)request.getAttribute(LOG_ID);
            log.info("REQUEST [{}][{}][{}]", logId, requestURI, handler);
            if(ex!=null){
                log.error("afterCompletion error!!", ex);
            }
    
    
        }
    }

     

     

    if(handler instanceof HandlerMethod) 문장을 살폅자.

     

    스프링을 사용하면 일반적으로 @Controller, @RequestMapping을 활용한 핸들러 정보로 HandlerMethod가 넘어온다. 그러나 @Controller가 아닌 /resources/static와 같은 정적 리소스가 호출되는 경우 ResourceHttpRequestHanlder가 정보로 넘어오기에 타입 처리가 필요하다. 

     

    종료 로그를 postHandle이 아닌 afterCompletion에 실행한 이유는, 예외가 발생한 경우 postHandle이 호출되지 않기 때문이다. 

     

    인터셉터 등록은 로그인 검증 로직 처리후 같이 WebConfig에 등록하겠다. ! 

     

     

     

    📑 스프링 인터셉터 - 인증 체크

     

    package hello.login.web.interceptor;
    
    import hello.login.web.session.SessionConst;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    
    @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();
            if(session ==null || session.getAttribute(SessionConst.LOGIN_MEMBER)==null){
                log.info("미인증 사용자 요청");
                //로그인으로 redirect
                response.sendRedirect("/login?redirectURL="+requestURI);
                return false;
            }
    
            return true;
        }
    }

     

     

    인증 체크는 컨트롤러 호출 전에만 호출되면 되기에 preHandle만 구현하면 된다. HandlerInterceptor 인터페이스를 살펴보면 pre, post, after 메서드 모두 defualt 지정자로 지정되어 있어 따로 오버라이딩 해주지 않아도 정상 작동된다. 

     

     

     

     

     

    📌 WebConfig 설정

     

    package hello.login;
    
    import hello.login.web.argumentresolver.LoginMemberArgumentResolver;
    import hello.login.web.interceptor.LogInterceptor;
    import hello.login.web.interceptor.LoginCheckInterceptor;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.method.support.HandlerMethodArgumentResolver;
    import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    import javax.servlet.Filter;
    import java.util.List;
    
    @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");
        }
    
      
    }

     

     

    registry.addInterceptor(new ~~)로 인터셉터를 등록하며 메서드 체이닝을 통해 순서를 정하고 인터셉터를 적용할 URL 패턴을 지정한다. "/**"로 하면 모든 경로에 인터셉터를 등록하는데 excludePathPatterns를 통해 인터셉터에서 제외할 패턴을 지정한다.  필터와 비교해보면 인터셉터는 addPathPatterns, excludePathPatterns로 매우 정밀한 url 패턴을 지정할 수 있다. 

     

     

    위의 결과를 살펴보면 LogInterceptor의 postHandler는 모델 반환 값도 알려주며 Log다음 LoginCheck가 실행된 걸 확인할 수 있다. /items를 입력하면 필터에서와 동일하게 /login으로 이동하며 로그인의 값을 입력하면 home이 아닌 /items로 이동하는 걸 확인할 수 있다. 

    반응형

    댓글

Designed by Tistory.