[Spring] 로그인 처리 - 서블릿 필터, 스프링 인터셉터
지난 포스팅 : https://dong-woo.tistory.com/98
[Spring] 로그인 처리 - 쿠키, 세션
- 요구사항 홈 화면 요구사항 홈 화면은 로그인 전과 후로 구분되어야 한다. 로그인 전 로그인 후 보안 요구사항 로그인 사용자만 상품관리에 접근하고, 관리할 수 있어야 한다. 로그인 하지 않
dong-woo.tistory.com
지난 포스팅에서 쿠키와 세션을 사용해 로그인 상태를 유지하는 방법에 대해서 알아봤다.
하지만 아직 적용시키지 못한 요구사항이 존재한다.
상품 관리 페이지는 로그인 한 상태로만 접근할 수 있어야 하는데,
URL로 직접 접근하게 되면 세션에 로그인 정보가 남아있지 않아도 상품 관리 페이지로 이동할 수 있는 문제가 발생한다.
이런 문제를 해결하기 위해서는 특정 페이지에 접근 시 로그인 상태를 체크하는 기능을 개발해야한다.
사실 이런 로그인 여부 체크라는 공통 관심사는 스프링 AOP로 해결할 수 있지만,
지금처럼 웹과 관련된 공통 관심사를 처리할 때는 아래 두 기능을 사용하면 좋다.
웹과 관련된 부가적인 기능을 많이 제공해주기 때문이다!
- 서블릿이 지원하는 필터
- 스프링이 지원하는 인터셉터
두 가지 모두 비슷한 기능을 한다. 하나씩 사용하는 방법을 알아보자.
- 서블릿 필터
필터는 서블릿이 지원하는 수문장이다.
필터는 인터페이스로 구현되어 있다. 따라서 해당 인터페이스를 상속받은 구현 클래스를 만들어 사용하면 된다.
필터 요청 흐름 & 제한
- 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);
}
}
- 실제 필터 인터페이스를 상속받아 구현 클래스로 작성한 로그인 체크 필터이다.
- 필터 인터페이스의 메소드는 총 세 가지이다.
- init() : 필터 초기화 메소드로 서블릿 컨테이너가 생성될 때 호출된다.
- doFilter() : 실제 필터의 로직을 작성하는 부분이다. HTTP 요청이 생길 때 마다 메소드가 호출된다.
- destroy() : 필터 종료 메소드로 서블릿 컨테이너가 종료될 때 호출된다.
- 위 구현 클래스 코드를 보면 doFilter만 오버라이딩해서 작성했는데, 인터페이스의 메소드들이 전부 default로 선언되어 있기 때문에 메소드를 필수로 구현하지 않아도 된다.
- 따라서 핵심 로직인 doFilter()만 작성했다.
- 로직을 살펴보자
- 필터를 거치지 않을 요청 URL의 모음, 화이트 리스트를 만든다.
로그인하러 로그인페이지로 이동했는데 로그인하지 않아서 접근할 수 없다는 경고가 나오면 당황스러울 것이다..ㅋㅋ - 요청 URL이 화이트 리스트에 없다면, request에서 세션을 얻어와 유효한 로그인 정보가 있는지 확인한다.
- 없다면 로그인 페이지로 리다이렉트, 이때 요청 리다이렉트 파라미터로 requestURL을 같이 넣어준다.
로그인 성공 시 요청이 들어왔던 페이지로 다시 보내주기 위함이다. - 중요! 로직이 끝나면 다음 체인을 실행시키기 위해 chain.doFilter를 넣어줘야 한다. 작성하지 않으면 동작하지 않는다.
- 필터를 거치지 않을 요청 URL의 모음, 화이트 리스트를 만든다.
- 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;
}
}
- 로직은 아래 순서와 같다.
- FilterRegistrationBean 객체를 생성한다.
- setFilter() : 구현한 필터 클래스를 지정한다.
- setOrder() : 이전에 언급했듯, chain 기능으로 여러 필터를 동시에 사용할 수 있다. 순서를 지정해주는 것이다.
- 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 {
}
}
- preHandle() : 컨트롤러 호출 전에 호출된다. preHandle의 응답값이 true이면 뒷 단계로 넘어가고 false면 커트한다.
- postHandle() : 컨트롤러 호출 후 ModelAndView를 받아온 뒤 호출된다.
- 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에 인터셉터 정보를 입력해주면 된다.
- addInterceptor() : 구현체로 만든 인터셉터 클래스를 등록한다.
- order() : 체인 순서를 지정해준다.
- addPathPatterns() : 적용시킬 URL을 지정해준다.
- 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 인터페이스를 상속받은 구현 클래스를 만들었다.
- 오버라이딩한 메소드는 총 두 가지이다.
- supportsParameter() : 두 가지를 체크할 것이다. 모두 만족해야 다음으로 넘어간다.
- 메소드 파라미터에 @Login 애노테이션이 있는지
- 파라미터로 주어진 객체의 타입이 Member 혹은 그 하위타입에 속하는지
- resolveArgument() : @Login Member findMember에서 이제 findMember에 무슨 값을 넣을지 로직을 작성해주면 된다.
먼저 세션을 얻어오고 세션이 비워져 있다면 findMember에 null을 넣어준다.
아니라면 실제 세션에 담겨있는 회원 객체를 넣어주면 된다.
- supportsParameter() : 두 가지를 체크할 것이다. 모두 만족해야 다음으로 넘어간다.
- 완성이다! 이제 직접 만든 간단한 애노테이션을 사용할 수 있고 실행 시 같은 동작을 수행한다.
출처 : 인프런, 김영한의 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술