Spring

[Spring] 로그인 처리 - 서블릿 필터, 스프링 인터셉터

이덩우 2023. 7. 27. 23:35

지난 포스팅 : https://dong-woo.tistory.com/98

 

[Spring] 로그인 처리 - 쿠키, 세션

- 요구사항 홈 화면 요구사항 홈 화면은 로그인 전과 후로 구분되어야 한다. 로그인 전 로그인 후 보안 요구사항 로그인 사용자만 상품관리에 접근하고, 관리할 수 있어야 한다. 로그인 하지 않

dong-woo.tistory.com

 

지난 포스팅에서 쿠키와 세션을 사용해 로그인 상태를 유지하는 방법에 대해서 알아봤다.

하지만 아직 적용시키지 못한 요구사항이 존재한다.

 

상품 관리 페이지는 로그인 한 상태로만 접근할 수 있어야 하는데,

URL로 직접 접근하게 되면 세션에 로그인 정보가 남아있지 않아도 상품 관리 페이지로 이동할 수 있는 문제가 발생한다.

 

이런 문제를 해결하기 위해서는 특정 페이지에 접근 시 로그인 상태를 체크하는 기능을 개발해야한다.

사실 이런 로그인 여부 체크라는 공통 관심사는 스프링 AOP로 해결할 수 있지만,

지금처럼 웹과 관련된 공통 관심사를 처리할 때는 아래 두 기능을 사용하면 좋다.

웹과 관련된 부가적인 기능을 많이 제공해주기 때문이다!

  1. 서블릿이 지원하는 필터
  2. 스프링이 지원하는 인터셉터

 

두 가지 모두 비슷한 기능을 한다. 하나씩 사용하는 방법을 알아보자.

 

 


 

- 서블릿 필터

필터는 서블릿이 지원하는 수문장이다. 

필터는 인터페이스로 구현되어 있다. 따라서 해당 인터페이스를 상속받은 구현 클래스를 만들어 사용하면 된다.

 

필터 요청 흐름 & 제한

  • HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러  // 로그인 사용자
  • HTTP 요청 -> WAS -> 필터 (적절하지 않은 요청이면 서블릿 호출 안함) // 비 로그인 사용자

 

필터 체인

  • 필터는 체인으로 구성된다. 여러 개의 필터를 자유롭게 추가할 수 있다!
  • HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러

 

필터 인터페이스 구현체

@Slf4j
public class LoginCheckFilter implements Filter {

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

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

}
  • 실제 필터 인터페이스를 상속받아 구현 클래스로 작성한 로그인 체크 필터이다.
  • 필터 인터페이스의 메소드는 총 세 가지이다.
    1. init() : 필터 초기화 메소드로 서블릿 컨테이너가 생성될 때 호출된다.
    2. doFilter() : 실제 필터의 로직을 작성하는 부분이다. HTTP 요청이 생길 때 마다 메소드가 호출된다.
    3. destroy() : 필터 종료 메소드로 서블릿 컨테이너가 종료될 때 호출된다.
  • 위 구현 클래스 코드를 보면 doFilter만 오버라이딩해서 작성했는데, 인터페이스의 메소드들이 전부 default로 선언되어 있기 때문에 메소드를 필수로 구현하지 않아도 된다.
  • 따라서 핵심 로직인 doFilter()만 작성했다.
  • 로직을 살펴보자
    1. 필터를 거치지 않을 요청 URL의 모음, 화이트 리스트를 만든다.
      로그인하러 로그인페이지로 이동했는데 로그인하지 않아서 접근할 수 없다는 경고가 나오면 당황스러울 것이다..ㅋㅋ
    2. 요청 URL이 화이트 리스트에 없다면, request에서 세션을 얻어와 유효한 로그인 정보가 있는지 확인한다.
    3. 없다면 로그인 페이지로 리다이렉트, 이때 요청 리다이렉트 파라미터로 requestURL을 같이 넣어준다.
      로그인 성공 시 요청이 들어왔던 페이지로 다시 보내주기 위함이다.
    4. 중요! 로직이 끝나면 다음 체인을 실행시키기 위해 chain.doFilter를 넣어줘야 한다. 작성하지 않으면 동작하지 않는다.
  • 3번에서 말했듯이 로그인 성공시 요청이 들어왔던 페이지로 리다이렉트 해주기 위해서는 LoginController를 약간 고쳐야한다.
@PostMapping("/login")
    public String loginV4(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult,
                          @RequestParam(defaultValue = "/") String redirectURL,
                          HttpServletRequest request) {
       .
       .
       .
       .
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, findMember);
        return "redirect:" + redirectURL;
    }
  • 우선 파라미터로 @RequestParam을 추가한다. 일반적인 경로로 로그인한 유저는 요청 파라미터가 없기 때문에 기본값으로 홈 경로를 넣어주고 리다이렉트 요청 파라미터를 가지고 있는 요청에는 해당 값을 받아온다.
  • return을 위와 같이 작성하면 된다.
  • 일반적인 경로로 로그인을 시도한 유저는 홈 경로로 이동한다.
  • 리다이렉트 파라미터를 가지고 있던 유저는 해당 페이지로 이동한다.
  • 이제 필터를 스프링 빈으로 등록하자. WebConfig라는 설정 정보 파일을 만들어서 직접 등록할 것이다.
@Configuration
public class WebConfig {

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

        return filterRegistrationBean;
    }
}
  • 로직은 아래 순서와 같다.
    1. FilterRegistrationBean 객체를 생성한다.
    2. setFilter() : 구현한 필터 클래스를 지정한다.
    3. setOrder() : 이전에 언급했듯, chain 기능으로 여러 필터를 동시에 사용할 수 있다. 순서를 지정해주는 것이다.
    4. addUrlPatterns() : 필터를 적용할 범위를 지정하는 것이다.
  • 모든 준비가 끝났다!

 


 

- 스프링 인터셉터

인터셉터는 스프링 MVC가 제공하는 기술이다.

적용되는 순서와 범위가 다르다.

 

스프링 인터셉터 흐름 & 제한

  • HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 // 로그인 사용자
  • HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 (적절하지 않은 요청이면 커트)  // 비 로그인 사용자

서블릿 필터와 마찬가지로 체인으로 구성되어 중간에 인터셉터를 자유롭게 추가할 수 있다.

 

 

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

public interface HandlerInterceptor {

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

	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 {
	}

}

요청 흐름

  1. preHandle() : 컨트롤러 호출 전에 호출된다. preHandle의 응답값이 true이면 뒷 단계로 넘어가고 false면 커트한다.
  2. postHandle() : 컨트롤러 호출 후 ModelAndView를 받아온 뒤 호출된다.
  3. afterCompletion() : 뷰까지 렌더링 된 이후에 호출된다. 컨트롤러에서 발생한 예외 정보를 받아온다.
    컨트롤러에서 예외가 발생하면 postHandle()은 호출되지 않기 때문에 예외와 무관한 공통처리를 원한다면 afterCompletion()을 사용해야 한다.

예외 상황

 

로그인 체크 인터셉터 구현체

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

        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미인증 사용자 접근");
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }
        return true;
    }
}
  • 서블릿 필터와 전반적으로 비슷하지만 더 간결하다.
  • 서블릿 필터와 다르게 파라미터로 처음부터 HttpServletRequest, HttpServletResponse를 제공한다. 
    캐스팅을 해줄 필요가 없다.
  • 또 다른 차이점은 화이트 리스트를 지금 만들어서 사용하고 있지 않다. 
  • 스프링의 인터셉터는 서블릿 필터와 다르게 스프링 빈 등록시 정말 자세한 URL 조건을 줄 수 있기 때문에 WebConfig에서 조건을 명시해줄 것이다.
  • WebConfig를 살펴보자
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**");

        registry.addInterceptor(new LogCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/members/add", "/login", "logout");
    }

}
  • 인터셉터를 스프링 빈으로 등록하기 위해서는 WebMvcConfigurer 인터페이스를 상속받아야 한다.
  • 그리고 @Bean으로 직접 등록하는게 아니라 WebMvcConfigurer에 있는 addInterceptors() 메소드를 오버라이딩 하는 방식으로 등록한다.
  • registry에 인터셉터 정보를 입력해주면 된다.
    1. addInterceptor() : 구현체로 만든 인터셉터 클래스를 등록한다.
    2. order() : 체인 순서를 지정해준다.
    3. addPathPatterns() : 적용시킬 URL을 지정해준다.
    4. excludePathPatterns() : 제외시킬 URL을 지정해준다. 이 기능 덕분에 구현 클래스에서 화이트 리스트를 만들지 않아도 됐다!
  • 끝이다! 서블릿 필터에 비해 조금 더 간편하게 사용할 수 있다.
  • 따라서 꼭 서블릿 필터를 사용해야 하는 상황이 아니라면 스프링 인터셉터를 사용하는 것을 권장한다.

 


 

- @Login 애노테이션 직접 만들기

ArgumentResolver를 사용해서 로그인 회원을 조금 편리하게 찾아보자.

기존의 HomeController를 살펴보자.

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

        if (findMember == null) {
            return "home";
        }

        //로그인 성공
        model.addAttribute("member", findMember);
        return "loginHome";
    }
  • @SessionAttribute()를 사용해 비교적 편하게 세션 정보를 얻어오긴 하지만 여전히 복잡하다.
  • 목적 자체가 세션에 있는 로그인 된 멤버 객체를 얻어오는 것이다.
  • @Login이라는 애노테이션을 직접 만들고 ArgumentResolver 기능을 사용해서 원하는 동작을 만들어보자.

 

HomeController 변경

@GetMapping("/")
    public String homeLoginV3ArgumentResolver(@Login Member findMember, Model model) {
        if (findMember == null) {
            return "home";
        }

        //로그인 성공
        model.addAttribute("member", findMember);
        return "loginHome";
    }
  • @Login 애노테이션을 파라미터로 사용했다.
  • 이제 애노테이션을 만들자.

 

@Login 애노테이션 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
  • 파라미터로 사용할 것이기 때문에 @Target에 파라미터 타입으로 넣어줬다.
  • 이제 이 애노테이션이 원하는 동작을 하도록 ArgumentResolver 인터페이스를 상속받은 구현 클래스를 만들어보자.

 

LoginArgumentResolver 작성

@Slf4j
public class LoginArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");
        boolean hasParameterAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

        return hasMemberType && hasParameterAnnotation;
    }

    @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();
        if (session == null) {
            return null;
        }
        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}
  • HandlerMethodArgumentResolver 인터페이스를 상속받은 구현 클래스를 만들었다.
  • 오버라이딩한 메소드는 총 두 가지이다.
    1. supportsParameter() : 두 가지를 체크할 것이다. 모두 만족해야 다음으로 넘어간다.
      • 메소드 파라미터에 @Login 애노테이션이 있는지
      • 파라미터로 주어진 객체의 타입이 Member 혹은 그 하위타입에 속하는지 
    2. resolveArgument() : @Login Member findMember에서 이제 findMember에 무슨 값을 넣을지 로직을 작성해주면 된다.
      먼저 세션을 얻어오고 세션이 비워져 있다면 findMember에 null을 넣어준다.
      아니라면 실제 세션에 담겨있는 회원 객체를 넣어주면 된다.
  • 완성이다! 이제 직접 만든 간단한 애노테이션을 사용할 수 있고 실행 시 같은 동작을 수행한다.

 

 

 

출처 : 인프런, 김영한의 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술