[스프링 MVC 2편] 로그인 처리2 - 필터, 인터셉터
포스트
취소

[스프링 MVC 2편] 로그인 처리2 - 필터, 인터셉터

서블릿 필터 - 소개

  • 로그인 체크를 하지 않으면 어떤 일이 생길까?
    • 로그인을 하지 않았는데도 회원 관리가 가능하거나 상품 관리가 가능하거나 큰 문제가 생길 것이다.
  • 각 컨트롤러 메소드에서 일일이 로그인 체크 로직을 넣어준다면 로그인 여부를 알 수 있긴 할 것이다.
    • 다만 번거롭기도 번거롭지만, 향후 로그인 관련 로직이 변경되면 모든 로직을 다 수정해줘야 한다.
  • 이를 위해 스프링에서는 서블릿 필터, 스프링 인터셉터, AOP를 제공한다.
    • 이번에는 서블릿 필터를 알아보자.

서블릿 필터는 무슨 역할을 할까?

  • 필터는 기본적으로 서블릿이 지원하는 수문장 역할을 한다.

필터의 특성

  • 필터 흐름
    • HTTP 요청 => WAS => 필터 => 서블릿 => 컨트롤러
    • 필터를 적용하면 필터가 호출 된 다음에 서블릿이 호출된다.
      • 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 된다.
      • 참고로 필터는 특정 URL 패턴에 적용할 수 있다.
      • /*이라고 하면 모든 요청에 필터가 적용된다.
      • 참고로 스프링을 사용하는 경우 여기서 말하는 서블릿은 스프링의 디스패처 서블릿으로 생각하면 된다.
  • 필터 제한
    • (로그인 사용자) HTTP 요청 => WAS => 필터 => 서블릿 => 컨트롤러
    • (비 로그인 사용자) HTTP 요청 => WAS => 필터 (적절하지 않은 요청이라 판단, 서블릿 호출X)
    • 필터에서 적절하지 않은 요청이라고 판단하면 거기에서 끝을 낼 수도 있다. (로그인 여부 체크에 효과적이다.)
  • 필터 체인
    • HTTP 요청 => WAS => 필터1 => 필터2 => 필터3 => 서블릿 => 컨트롤러
    • 필터는 체인으로 구성되는데, 중간에 필터를 자유롭게 추가할 수 있다.
    • 예를 들어서 로그를 남기는 필터를 먼저 적용하고, 그 다음에 로그인 여부를 체크하는 필터를 만들 수 있다.
  • 필터 인터페이스
    • 필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리한다.
    • init()
      • 필터 초기화 메서드
      • 서블릿 컨테이너가 생성될 때 호출된다.
    • doFilter()
      • 고객의 요청이 올 때 마다 해당 메서드가 호출된다.
      • 필터의 로직을 구현하면 된다.
    • destroy()
      • 필터 종료 메서드
      • 서블릿 컨테이너가 종료될 때 호출된다.

서블릿 필터 - 요청 로그

  • 모든 요청을 로그로 남기는 필터를 만들고 적용해보자.

필터 만들기

package hello.login.web.filter;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;

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

필터 적용하기

package hello.login;

import hello.login.web.filter.LogFilter;
import jakarta.servlet.Filter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class WebConfig {
    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter()); //필터 종류 지정
        filterRegistrationBean.setOrder(1); //순번 지정
        filterRegistrationBean.addUrlPatterns("/*"); //적용할 URL 패턴 지정
        return filterRegistrationBean;
    }
}
  • @ServletComponentScan @WebFilter(filterName = "logFilter", urlPatterns = "/*")로도 필터 등록이 가능하긴 하다.
  • 다만 필터 순서 조절이 안 되니 그냥 FilterRegistrationBean을 사용하자.

서블릿 필터 - 인증 체크

  • 로그인 여부를 확인하는 필터를 만들고 적용해보자.

필터 만들기

package hello.login.web.filter;

import hello.login.web.SessionConst;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.PatternMatchUtils;

import java.io.IOException;

@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("loginMember") == null) {
                     log.info("미인증 사용자 요청 {}", requestURI);
                     //로그인으로 redirect
                     httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                     return; //여기가 중요, 미인증 사용자는 다음으로 진행하지 않고 끝!
                 }
             }
             chain.doFilter(request, response);
         } catch (Exception e) {
            throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
         } finally {
            log.info("인증 체크 필터 종료 {}", requestURI);
         }
     }

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

필터 적용하기

  • WebConfig에 추가해주자.
@Bean
public FilterRegistrationBean loginCheckFilter() {
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
    filterRegistrationBean.setFilter(new LoginCheckFilter()); //필터 종류 지정
    filterRegistrationBean.setOrder(2); //순번 지정
    filterRegistrationBean.addUrlPatterns("/*"); //적용할 URL 패턴 지정
    return filterRegistrationBean;
}

RedirectURL 처리

  • 로그인에 성공하면 처음 요청인 URL로 이동하는 기능을 개발해보자.
  • 그저 컨트롤러에 파라미터 하나만 추가하면 된다.
    • @RequestParam(defaultValue = "/") String redirectUrl
  • 그리고 로그인이 성공하면 해당 주소로 리다이렉트 시키기만 하면 된다.

서블릿 필터 - 객체 변환

  • 서블릿 필터에는 스프링 인터셉터는 제공하지 않는 객체 변환 기능이 있다.
  • chain.doFilter(request, response);를 호출해서 다음 필터 또는 서블릿을 호출할 때 requestresponse를 다른 객체로 바꿀 수 있다.
  • ServletRequestServletResponse를 구현한 다른 객체를 만들어서 넘기면 해당 객체가 다음 필터 또는 서블릿에서 사용된다.
  • 잘 사용하는 기능은 아니라서 그냥 이런 것도 있다라고 이해하면 된다.

스프링 인터셉터 - 소개

  • 서블릿 필터처럼 공통되는 부분을 처리하는 것을 동일하지만, 적용되는 순서와 범위, 사용방법이 다르다.

인터셉터의 특성

  • 스프링 인터셉터 흐름
    • HTTP 요청 => WAS => 필터 => 서블릿 => 스프링 인터셉터 => 컨트롤러
    • 스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출 된다.
    • 스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 때문에 결국 디스패처 서블릿 이후에 등장하게 된다.
    • 스프링 MVC의 시작점이 디스패처 서블릿이라고 생각해보면 이해가 될 것이다.
    • 스프링 인터셉터에도 URL 패턴을 적용할 수 있는데, 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정할 수 있다.
  • 스프링 인터셉터 제한
    • (로그인 사용자) HTTP 요청 => WAS => 필터 => 서블릿 => 스프링 인터셉터 => 컨트롤러
    • (비 로그인 사용자) HTTP 요청 => WAS => 필터 => 서블릿 => 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출 X)
    • 인터셉터에서 적절하지 않은 요청이라고 판단하면 거기에서 끝을 낼 수도 있다. 그래서 로그인 여부를 체크하기에 딱 좋다.
  • 스프링 인터셉터 체인
    • HTTP 요청 => WAS => 필터 => 서블릿 => 인터셉터1 => 인터셉터2 => 컨트롤러
    • 스프링 인터셉터는 체인으로 구성되는데, 중간에 인터셉터를 자유롭게 추가할 수 있다.
      • 예를 들어서 로그를 남기는 인터셉터를 먼저 적용하고, 그 다음에 로그인 여부를 체크하는 인터셉터를 만들 수 있다.
  • 스프링 인터셉터 인터페이스
    • 스프링의 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.
    • preHandle
      • 컨트롤러 호출 전에 호출된다. (더 정확히는 핸들러 어댑터 호출 전에 호출된다.)
      • preHandle의 응답값이 true면 다음으로 진행하고, false 이면 더는 진행하지 않는다.
      • false인경우 나머지 인터셉터는 물론이고, 핸들러 어댑터도 호출되지 않는다.
    • postHandle
      • 컨트롤러 호출 후에 호출된다. (더 정확히는 핸들러 어댑터 호출 후에 호출된다.)
    • afterCompletion
      • 뷰가 렌더링 된 이후에 호출된다.
      • 예외가 발생한 경우에도 항상 호출된다.

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

  • 로그를 출력하는 인터셉터를 만들어보자.

인터셉터 만들기

package hello.login.web.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

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);
        //@RequestMapping: HandlerMethod
        //정적 리소스: ResourceHttpRequestHandler
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler; //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
        }
        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        return true; //false 진행X
    }

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

인터셉터 적용하기

  • WebConfig에 추가해주자.
  • WebMvcConfigurer 인터페이스를 구현해줘야 한다.
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LogInterceptor())
            .order(1) //순번 지정
            .addPathPatterns("/**") //적용할 URL 패턴 지정
            .excludePathPatterns("/css/**", "/*.ico", "/error"); //예외 URL 패턴 지정
}

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

  • 이번에는 로그인 여부를 확인하는 인터셉터를 만들어보자.

인터셉터 만들기

package hello.login.web.interceptor;

import hello.login.web.SessionConst;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;

@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("loginMember") == null) {
            log.info("미인증 사용자 요청");
            //로그인으로 redirect
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }
        return true;
    }
}

인터셉터 적용하기

  • WebConfig의 addInterceptors에 추가해주자.
registry.addInterceptor(new LoginCheckInterceptor())
    .order(2) //순번 지정
    .addPathPatterns("/**") //적용할 URL 패턴 지정
    .excludePathPatterns(
            "/", "/members/add", "/login", "/logout",
            "/css/**", "/*.ico", "/error"
    ); //예외 URL 패턴 지정

ArgumentResolver 활용

  • 이번에는 ArgumentResolver를 통해서 로그인한 회원을 조금 편리하게 찾는 방법을 알아보자.
  • 전용 애노테이션을 생성해서 리졸버를 통해 해당 애노테이션이 존재하면 로그인한 회원 정보를 반환하는 방법이다.

애노테이션 생성

  • 우선 @Login 애노테이션을 만들어보자.
package hello.login.web.argumentresolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

리졸버 만들기

package hello.login.web.argumentresolver;

import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@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 parameter,
            ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            WebDataBinderFactory binderFactory
    ) throws Exception {
        log.info("resolveArgument 실행");
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }
        return session.getAttribute("loginMember");
    }
}

리졸버 적용하기

  • WebConfig에 추가해주자.
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
    resolvers.add(new LoginMemberArgumentResolver());
}

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.