로그인 처리 - 필터, 인터셉터
서블릿 필터
사용자만 상품 관리 페이지에 들어갈 수 있어야 한다는 요구사항을 살펴보자. 로그인하지 않은 사용자도 URL을 알고 있다면 직접 호출하여 관리 화면에 들어올 수 있다.
이 부분을 해결하려면 등록, 수정, 삭제, 조회 등등 상품관리의 모든 컨트롤러 로직에 공통으로 로그인 여부를 확인해야 한다. 이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사라고 한다. 스프링의 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 HTTP의 헤더나 URL의 정보가 필요한데, 이 부분을 HttpServletRequest
를 제공하는 서블릿 필터나 스프링 인터셉터를 사용하는 것이 바람직하다.
서블릿 필터 흐름
HTTP 요청
- WAS
- 필터
- 서블릿
- 컨트롤러
필터를 적용하면 필터가 호출된 다음에 서블릿이 호출된다. 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 된다. 참고로 필터는 특정 URL 패턴에 적용할 수 있다. /*
이라고 하면 모든 요청에 필터가 적용된다. 여기서의 서블릿은 스프링의 DispacherServlet
이라고 생각하면 된다. 필터는 체인 방식으로 중간에 필터를 자유롭게 추가할 수 있다.
서블릿 필터 인터페이스
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()
: 고객의 요청이 올 때 마다 해당 메서드가 호출. 즉, 필터의 로직을 구현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 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");
}
}
- 필터를 사용하려면 필터 인터페이스를 구현해야 한다.
ServletRequest
는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스로, HTTP를 사용하면 다음과 같이 다운 캐스팅을 해주어야 한다.chain.doFilter(request,response)
를 해줘야만 다음 필터가 있으면 호출하고, 없으면 서블릿을 호출한다.
서블릿 필터 적용
package hello.login;
import hello.login.web.filter.LogFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter()); // 필터 등록
filterFilterRegistrationBean.setOrder(1); // 필터별 우선 순위
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
@Configuration
에FilterRegistrationBean
을 사용해서 등록한다.setOrder()
는 필터 간 우선 순위를 지정할 수 있으며,addUrlPatterns("/*")
는 모든 URL을 지정한다.
실무에서 HTTP 요청 시 같은 요청의 식별자를 자동으로 남기는 방법은 logback mdc가 있다. 참고로만 알아두자.
서블릿 필터 - 인증 체크
package hello.login.web.filter;
import hello.login.web.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 {
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
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);
}
}
whitelist ={}
: 인증과 무관하게 항상 허용 해줄 URL 지정isLoginCheckPath()
: 화이트리스트를 제외한 모든 경우 인증 체크 로직 적용httpResponse.sendRedirect("/login?redirectURL=" +requestURI
: 미인증 사용자는 로그인 화면으로 이동하는데 로그인 이후에 홈으로 이동해버리면 원하는 경로를 다시 찾아가야하는 불편함이 있다. 즉, 로그인 성공 시 다음 URL의 기록을 합쳐서 보내는 것으로 이 기능은 추가로 개발이 필요하다.return
:redirect
를 사용했기 때문에 이후의 필터는 진행하지 않기 위해 사용한다.
import hello.login.web.filter.LogFilter;
import hello.login.web.filter.LoginCheckFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
@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> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
- 새로운 필터를 생성했으니 적용을 위해
@Configuration
에 등록하자.
@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());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 로그인 성공 처리
// 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
HttpSession session = request.getSession();
// 세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL;
}
- 이전에
redirectURL
을 쿼리스트링으로 넘겨서 처리하는 부분을 여기서 수행한다.@RequestParam
에 기본 값을/
로 받아오고 return문에 추가해주면 허용되지 않는 URL에 접속하여 로그인 화면으로 이동했을 때 로그인 후 이전의 화면을 볼 수 있게 된다.
서블릿 필터는 인터셉터는 제공하지 않는 강력한 기능이 있다. 바로
chain.doFilter(request, response)
를 다른 객체로 바꿀 수 있다는 점이다. 잘 사용하는 기능은 아니니 참고만 해두자.
스프링 인터셉터
스프링 인터셉터는 스프링 MVC가 제공하는 기술로 필터와 같이 웹과 관련된 공통 관심 사항을 처리하지만, 스프링 인터셉터가 더 많은 기능을 제공하고 사용방법이 다르다. 즉, 스프링 MVC를 사용하고 특별히 필터를 사용해야 하는 상황이 아니라면, 인터셉터를 사용하는 것이 더 편리하다.
스프링 인터셉터 흐름
HTTP 요청
- WAS
- 필터
- 서블릿
- 스프링 인터셉터
- 컨트롤러
스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출 된다. 스프링 인터셉터에도 URL 패턴을 적용할 수 있는데, 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정할 수 있다.
스프링 인터셉터 인터페이스
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 {
}
}
- 서블릿 필터의 경우
doFilter()
하나만 제공하지만, 인터셉터는 컨트롤러 호출 전(preHandle
), 호출 후 (postHandle
), 요청 완료 이후(afterCompletion
)와 같이 단계적으로 세분화 되어 있다. - 인터셉트는 어떤 컨트롤러(
handler
)가 호출되는지, 어떤 모델앤뷰가 반환되는지 응답 정보도 받을 수 있다.
예외가 발생하지 않는 경우의 인터셉터
preHandle
: 컨트롤러 호출 전에 호출된다. (정확히는 핸들러 어댑터 호출 전에 호출)
preHandle
의 응답이 true이면 다음으로 진행하고, false 이면 끝낸다.
postHandle
: 컨트롤러 호출 후에 호출된다. (정확히는 핸들러 어댑터 호출 후에 호출)
afterCompletion
: 뷰가 렌더링 된 이후에 호출된다.
예외가 발생한 경우의 인터셉터
preHandle
: 컨트롤러 호출 전에 호출
postHandle
: 컨트롤러에서 예외가 발생하면 호출되지 않는다.
afterCompletion
: 항상 호출된다. 이 경우 예외를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력할 수도 있다.
스프링 인터셉터 - 요청 로그
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 {
private 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; // boolean 형 타입으로 false 를 반환하면 종료 된다.
}
@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, handler);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
request.setAttribute()
: 스프링 인터셉터는 호출 시점이 완전히 분리되어 있어서preHandle
에서 지정한 값을 다른 시점에서 사용하려면request
에 담아두고getAttribute
로 가져와서 사용해야 한다.
package hello.login;
import hello.login.web.filter.LogFilter;
import hello.login.web.filter.LoginCheckFilter;
import hello.login.web.interceptor.LogInterceptor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.servlet.Filter;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1).addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
}
WebMvcConfigurer
가 제공하는addInterceptors()
를 사용해서 인터셉터를 등록할 수 있다.order
: 우선 순위를 적용 (낮을 수록 먼저) ,addPathPatterns("/**")
: 인터셉터를 적용할 URL 패턴 지정
? 한 문자 일치
* 경로(/) 안에서 0개 이상의 문자 일치
** 경로 끝까지 0개 이상의 경로(/) 일치
{spring} 경로(/)와 일치하고 spring이라는 변수로 캡처
{spring:[a-z]+} matches the regexp [a-z]+ as a path variable named "spring" {spring:[a-z]+} regexp [a-z]+ 와 일치하고, "spring" 경로 변수로 캡처
{*spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처
addPathPatterns
를 제공하는PathPattern
공식 문서
스프링 인터셉터 - 인증 체크
package hello.login.web.interceptor;
import hello.login.web.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
만 구현하면 된다.
@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"
);
}
}
- 추가로 등록한 인터셉터를 등록하자.
서블릿 필터보다 스프링 인터셉터가 개발자 입장에서 훨씬 편리하다는 것을 알아볼 수 있었다. 특별한 문제가 없으면 인터셉터를 사용하는 것이 좋다.
ArgumentResolver 활용
요청 매핑 핸들러 어댑터 구조에서 사용하는 ArgumentResolver
를 사용하면 로그인 회원을 편리하게 찾을 수 있다.
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
// 세션에 회원 데이터가 없으면
if (loginMember == null) {
return "home";
}
// 세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
@Login
애노테이션을 커스텀으로 만들어서ArgumentResolver
를 만들어서 동작해보자. 즉, 세션에 로그인 회원이 있는지를 찾고 없으면 null을 반환하는 방식이다.
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 {
}
@Target(ElementType.PARAMETER)
: 파라미터에만 사용@Retention(RetentionPolicy.RUNTIME)
: 런타임까지 애노테이션을 사용하겠다는 의미. 보통 커스텀 애노테이션을 만들면 이 설정을 한다고 보면 된다.
package hello.login.web.argumentresolver;
import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
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;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@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(SessionConst.LOGIN_MEMBER);
}
}
supportsParameter
에서 파라미터와 타입 여부를 확인한다.resolveArgument()
: 컨트롤러 호출 직전에 호출 되어 필요한 파라미터 정보를 생성해준다. 여기서는 세션에 있는 로그인 회원 정보인member
객체를 찾아서 반환해준다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
}
@Configuration
에 해당 정보를addArgumentResolvers()
에 등록하자.
실행해보면 결과는 동일하지만, 더 편리하게 로그인 회원 정보를 조회할 수 있다. 공통 작업이 필요할 때
ArgumentResolver
를 활용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다.
본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.