스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 : Filter, Interceptor

jkky98·2024년 8월 6일
0

Spring

목록 보기
20/77

공통 관심사

요구사항에 따르면, 로그인 한 사용자만이 로그인 페이지 이후의 기능들을 사용할 수 있다.(상품 관리 페이지) 현재 세션 로그인까지 구현을 완료하였으나, 여전히 주소창에 HOST:8080/items로 접근 시 상품 관리 화면에 들어갈 수 있다.

상품 관리 컨트롤러 메서드에서 로그인 여부를 체크하는 로직을 추가할 수 있으나 미래에 사용될 컨트롤러나 현재 다른 컨트롤러(Item, addItem)등에서도 로그인 여부를 체크할 로직이 들어가야겠다는 예상이 가능하다. 즉 같은 코드가 대부분의 컨트롤러 메서드 로직에 추가되어야 할 때 이러한 로직을 공통 관심사(cross-cutting concern)이라고 한다.

이러한 공통 관심사는 스프링 AOP로도 해결 가능하지만, 우리의 공통 관심사는 login과 관련된 사항이며 즉, Web과 관련된 공통관심사이다. web과 관련된 관심사 처리에는 결국 HTTP 정보들이 필요하기 때문에 AOP를 직접 개발하기보다는 이미 Servlet에서 지원하는 Servlet Filter 혹은 Spring Interceptor를 활용하는 편이 더 효율적이다.

Servlet Filter

서블릿 필터를 빈 컨테이너에 등록할 경우 다음과 같은 흐름이 만들어 진다. 또한 필터는 여러개를 순서(우선순위)가 있게 연결할 수 있다.(필터 체인)
HTTP 요청 -> WAS -> 필터1 -> 필터2 ... -> DispatcherServlet -> 컨트롤러

// 필터 인터페이스
 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() {}
}

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

  • init() : 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.
  • doFilter() : 고객의 요청이 올 때마다 해당 메서드가 호출된다. 필터에서 적용할 로직을 구현하면 된다.
  • destory() : 서블릿 컨테이너 종료시 호출된다.

필터 : LogFilter, LoginFilter 개발

@Slf4j
public class LogFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        log.info("log filter doFilter");

        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpRequest.getRequestURI();

        String uuid = UUID.randomUUID().toString();

        try {
            log.info("REQUEST [{}][{}]", uuid, requestURI);
            filterChain.doFilter(servletRequest, servletResponse); // 다음 필터 있으면 실행
        } catch (Exception e) {

        } finally {
            log.info("RESPONSE [{}][{}]", uuid, requestURI);
        }

    }

    @Override
    public void destroy() {
        log.info("log filter destroy");
    }
}

LogFilter는 요청 -> 응답 과정에서 일괄적인 로그를 찍어주기 위한 필터 클래스이다. uuid는 이 필터에서 출력된 로그임을 나타내기 위한 용도이다.

@Slf4j
public class LoginCheckFilter implements Filter {

    private static final String[] whiteList = {"/", "/members/add", "/login", "/logout", "/css/*"};

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpRequest.getRequestURI();

        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;

        try {
            log.info("인증 체크 필터 시작");
            if (isLoginCheckPath(requestURI)) {
                log.info("인증 체크 로직 실행");
                HttpSession session = httpRequest.getSession(false);
                if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
                    log.info("미인증 사용자 요청 {} ", requestURI);
                    //로그인으로 리다이렉트
                    httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    return;
                }
            }
            filterChain.doFilter(servletRequest, servletResponse);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("인증 체크 필터 종료");
        }
    }

    /**
     * 화이트 리스트의 경우 인증 체크X
     */
    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whiteList, requestURI);
    }
}

whiteList에는 LoginFilter를 거치지 않을 endPoint를 설정한다. 그리고 isLoginCheckPath()를 통해 요청 URI가 whiteList에 해당하지 않을 경우만 인증 체크 로직이 진행되게끔 설계했다.

인증체크 로직은 request객체로부터 HttpSession을 호출하여 session이 null 이거나 session에 로그인 객체에 대한 정보가 null일 경우 로그인 페이지로 redirect하게끔 설계했다.

sendRedirect("/login?redirectURL=" + requestURI)

보통의 웹사이트에서 접근한 페이지에 대해 로그인이 필요할 경우 로그인 창을 띄운다. 그리고 로그인에 성공하면 접근하려던 페이지에 자동으로 다시 접근되곤 한다. 이러한 기술을 적용하기 위해 로그인 요청을 받는 URI에 쿼리파라미터로 redirectURL을 주고, 로그인 성공시 로그인 컨트롤러는 이 쿼리 파라미터(redirectURL)로 리다이렉트 해주도록 컨트롤러를 수정하면 된다. 쿼리파라미터가 안들어온 기본 로그인 로직에 대해서는 home으로 가도록 redirect경로의 디폴트 값을 지정해주면 된다.

  @PostMapping("/login")
    public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
                          @RequestParam(defaultValue = "/") String redirectURL,
                          HttpServletRequest request) {
        ...
        return "redirect:" + redirectURL;
    }

만약 미인증에 걸려 로그인창으로 리다이렉트 됬다면 필터의 doFilter 메서드를 바로 종료시켜야 하므로 빈 리턴을 해준다.

필터 등록

필터를 등록하기위해 빈 컨테이너인 WebConfig 클래스를 만들고, 만든 필터들을 빈으로 등록해준다.

@Configuration
public class WebConfig {

    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
        filterFilterRegistrationBean.setFilter(new LogFilter());
        filterFilterRegistrationBean.setOrder(1);
        filterFilterRegistrationBean.addUrlPatterns("/*");

        return filterFilterRegistrationBean;
    }

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

        return filterFilterRegistrationBean;
    }

FilterRegistrationBean 타입을 return 하는 객체를 @Bean으로 등록하면 자동적으로 필터가 적용된다. 빈으로 등록할 메서드에 FilterRegistrationBean 인스턴스를 생성해서 setFilter를 통해 필터를 등록하고, setOrder를 통해 우선순위를 설정한다. addUrlPatterns를 통해 적용할 URL 범위를 설정할 수 있다.

  • 참고 : 필터는 스프링 인터셉터가 제공하지 않는 강력한 기능이 존재한다. 뒤에서 설명하겠지만 필터는 서블릿 호출 이전에 적용되는 개념이고 필터에서도 역시 WAS에 의해 request, response를 주입받는다. 그러므로 필터단에서 request, response를 수정해서 서블릿에 들어올 request, response를 수정해서 주입받을 수 있다.

스프링 인터셉터

다음은 스프링 인터셉터의 흐름이다. 서블릿 필터와 비슷하지만 다른 순서 위치를 가진다.
HTTP 요청 -> WAS -> 필터1 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 호출되며 스프링MVC가 제공하는 기술이기에 서블릿 이후에 등장한다.(스프링 MVC의 시작점은 디스패처 서블릿이며 서블릿 필터는 그 이전에 적용되는 서블릿 기술이다.)

HandlerInterceptor 구현

스프링 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.

구현할 메서드는 총 3개이지만 모두 default 메서드로 구현되어있어 필수로 구현하지 않아도 작동에 문제는 없다.

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 {
    }
}
  • preHandle : 핸들러(컨트롤러) 호출전 적용되는 로직을 작성할 수 있으며 request와 response 서블릿 객체 및 핸들러 객체를 파라미터로 가진다.
  • postHandle : 컨트롤러 호출 후 적용되는 로직 작성 가능
  • afterCompletion : 요청 완료 이후(View랜더링 완료 이후) 로직 작성 가능

필터에서 제공되는 doFilter와 달리 3가지 시점을 제공해준다. 또한 서블릿 필터는 단순히 request, response만 제공했지만 인터셉터는 handler에 대한 정보도 받을 수 있다.

preHandle의 boolean 리턴은 다음으로의 진행을 결정한다. 이때 다음이란 컨트롤러의 호출이나 다음 인터셉터 로직을 말한다. 만약 preHandle에서 예외가 발생해서 return false가 되었다면 postHandle은 호출되지 않지만 afterCompletion은 호출된다(finally와 비슷한 개념).

필터 -> 인터셉터로 변경

LogInterceptor


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

        // @RequestMapping: HandlerMethod
        // 정적 리소스: ResourceHttpRequestHandler

        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
        }

        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        return true; // 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 uuid = (String) request.getAttribute(LOG_ID); // uuid

        log.info("RESPONSE [{}][{}][{}]", uuid, requestURI, handler);

        if (ex != null) {
            log.error("afterCompletion error!!", ex);
        }
    }
}

이전과 같이 인터셉터 수준에서 log가 찍힌 것을 확인하기위해 UUID를 생성 및 적용한다. 우리 프로젝트의 모든 컨트롤러는 애노테이션 기반의 requestMapping을 사용중이므로 주입받는 핸들러에 대한 정보를 뽑기 위해 HandlerMethod임을 확인하고 로그 출력하는 부분을 만들었다.

postHandle과 afterCompletion에는 크게 중요한 로직이 들어가지는 않았지만 ModelandView 객체를 postHandle에서만 볼 수 있어 로그로 뽑아봤으며 afterCompletion은 컨트롤러 로직에서 예외가 발생하거나 preHandle에서 예외가 발생해도 실행되기 때문에 예외가 발생한다면 추출하는 기능을 넣어보았다.

LoginInterceptor

@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("미인증 사용자 요청");
            // 로그인으로 리다이렉트
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }

        return true;
    }
}

이전 필터내의 로직과 비슷하다.(로그인 인터셉터 자체가 postHandler와 afterCompletion가 필요 없어 바꾼 의미가 없어보인다. -> 그래도 인터셉터가 더 좋음! 기능이 더 많으니까)

인터셉터 등록

인터셉터 등록을 위해 WebConfig를 WebMvcConfigurer의 구현체로 만든다.

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

그리고 인터페이스에 존재하는 수많은 메서드 중에 addInterceptors를 오버라이드 메서드를 구현한다. 주입받을 registry에 위와 같이 등록할 수 있다. 체이닝 기법을 통해 매우 직관적이면서도 하나의 메서드안에 여러 인터셉터를 추가할 수 있는 점에서 이전의 필터보다 더 깔끔한 느낌을 주었다.

또한 필터로 구현시에 필터의 doFilter에서 whiteList를 따로 만들어 이를 피하도록 로직을 직접 짜야했지만 스프링 인터셉터의 경우 .excludePathPatterns()의 인자에 인터셉터를 적용받지 않을 URI를 넣어 바로 적용할 수 있는 점에서 서블릿 필터에 비해 우수한 편리함을 느낄 수 있었다.

HomeController 리펙터링

public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member member,
                                Model model)
                                
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member member,
                                    	  Model model)                                

위의 @SessionAttribute 또한 편리함을 제공해주었지만 일단 인자가 매우 길어진다. 이것을 커스텀 애노테이션 @Login을 만들어 @Login만 사용하도록 바꾸어버릴 수 있다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

위와 같이 커스텀 에노테이션을 작성해준다 @Target(ElementType.PARAMETER)의 경우 파라미터 수준에서 적용한다는 것이며 @Retention(RetentionPolicy.RUNTIME)는 런타임 내내 적용한다는 뜻이다. 구분되는 범위로 RetentionPolicy.SOURCE(컴파일 시점에만 적용), CLASS(애노테이션은 컴파일된 클래스 파일에 포함, 런타임에는 적용X)가 존재한다.

이제 @Login이 어떤 로직을 가지는지와 이것을 ArgumentResolver에 등록해주어야 한다.

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");

        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
        log.info("resolveArgument 실행");
        HttpServletRequest request = (HttpServletRequest) nativeWebRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}
  • supportsParameter : @Login 애노테이션이 존재하면서 Member 타입이면 이 LoginMemberArgumentResolver이 사용되도록 하기 위한 검증 메서드이다.
  • resolveArgument : 받아야 할 타입이 Member이므로 return으로 Member 타입이 와야하며, 세션에서 로그인 정보가 존재하는지 확인하는 로직을 작성한다. 확인이 되면 서버 세션에 등록된 로그인 정보로 하여금 Member 객체를 리턴한다.

정리

  • 웹과 관련된 공통 관심사는 필터와 인터셉터를 활용하자.
  • 인터셉터가 더 사용하기 좋다. 다만 필터는 request, response를 수정해서 컨트롤러단으로 보낼 수 있다는 특수성이 있다. 그러나 잘 사용되는 방식은 아니다.
  • 인터셉터가 왜 더 좋은지 이해하자.(시점 3개 지원, 더 많은 파라미터 지원, 등록 코드 편함)
  • 커스텀 애노테이션을 만들고 ArgumentResolver에 이를 등록하는 방법의 패턴을 익히자.
profile
자바집사의 거북이 수련법

0개의 댓글