이번시간에는 지난시간의 세션과 쿠키를 사용한 로그인 처리 이후, 필터와 인터셉터를 배워보도록 하겠다.
필터와 인터셉터를 사용하는이유가, 지난 시간 글의 마지막에 설명했지만, 로그인을 하지 않더라도 URL로 get요청을 보내면 response를 받을 수 있다는것이다.
그러면 로그인을 한 사용자만 상품 관리 페이지에 들어가려면 어떻게 해야할까?
간단하게 구현하면,
컨트롤러의 메서드마다, 전부, 요청이 왔을때 로그인이 되어있는지 아닌지 검사를 하면 될것이다.
SessionAttribute를 사용하여서 null인지 아닌지 검사를 하면 되는것이다.
그러나, 이렇게 하면 까먹을 수도 있고, 이런 애플리케이션 여러 로직에서 공통으로 관심이 있는것을 공통 관심사라고 한다.
이런 공통 관심사는 스프링의 AOP로도 해결 할수도 있지만, 웹과 관련된 공통관심사를 처리할때는 서블릿 필터, 또는 스프링 인터셉터를 사용하는 것이 좋다.
필터는 서블릿이 지원하는 수문장이다.
필터의 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
was로 요청이 들어오면 필터를 거친 후에 서블릿이 호출된다. 스프링을 사용하는경우 서블릿이 디스페쳐 서블릿이다.
만약에 필터에서 적절하지 않은 요청이라고 판단하면 서블릿을 호출하지 않고 끝낼수있다.
필터는 체인으로 구성이 되는데 중간에 필터를 자유롭게 추가할 수 있다.
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 ->서블릿 -> 컨트롤러
필터 인터페이스
서블릿 컨테이너가 이 필터 인터페이스를 구현하고 등록해서 싱글톤 객체로 관리하고 생성한다.
init(): 필터 초기화 메서드, 서블릿 컨테이너가 생성될때 호출됨
doFilter(): 고객의 요청이 올때 마다 해당 메서드가 호출된다. 필터의 로직을 구현하면 된다.
destory(): 필터 종료 메서드, 서블릿 컨테이너가 종료될때 호출된다.
모든 요청에 대해서 로그를 남기는 필터를 만들어보자.
LogFilter
@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 httpServletRequest = (HttpServletRequest) request;
String requestURI = httpServletRequest.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");
}
}
일단 Filter를 구현하는 LogFilter를 만들면 오버라이되는 3개의 메서드를 모두 써줘야한다. init과 destory에는 각각그냥 로그를 찍어보고
doFilter부분에서는 파라미터가 ServletRequest인데 이게 HTTPServletRequest의 부모이다.
기능이 얼마 없으므로, 다운캐스팅을 해주고
uuid를 만들어서 request로그를 찍어준다.
중요한점은 try구문 뒤에 chain.doFilter를 호출해줘야한다.
그래야, chain을타고 그 다음 필터 또 그 다음 필터를 호출하고 없으면, 서블릿 호출 컨트롤러 호출해서 다 로직끝나면 chain.doFilter밑의 줄이 실행되고 try구문이 끝나면 finally가 실행되는것이다.
만약에 doFilter메서드 호출이 없으면 그냥 log.info찍고 바로 finally로 가서 서블릿과 컨트롤러 호출이 안될것이다.
그리고 이 필터를 등록시켜줘야하는데
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new
FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
필터등록은 FilterRegistrationBean을 사용한다.
setFilter(new LogFilter): 등록할 필터를 지정하고
setOrder(1): 필터는 체인으로 동작해서 순서가 낮을수록 먼저 동작한다. 정상적인 요청인지 비정상적인 요청인지 판단하지 않고 모든 요청에대해서 로그를 찍으니까 우선순위를 가장 높게 하였다.
addUrlPatterns("/"): 필터를 적용할 URL 패턴을 지정한다. /이니까 /로 시작하는 모든 URL패턴에 이 로그필터를 적용시킨다.
로그인 되지 않은 사용자는 상품 관리 뿐만 아니라 미래에 개발될 페이지에도 접근하지 못하게 해보자.
LoginCheckFilter - 인증 체크 필터
@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);
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);
}
}
우선 LoginCheckFilter는 필터의 구현체니까 doFilter를 오버라이딩 해준다. 어? 근데 init과 destory는? 할 수 있는데 이게 default라서 구현을 해도 안해도 된다.
whiteList를 설정해주는데, 로그인을 하지 않아도 접근이 가능해야하는 경로들을 설정해주었다.
doFilter메서드는 동일하게 HttpServletResponse,Request로 다운캐스팅을해주고
isLoginCheckPath메서드를 호출하여서 whiteList에 포함이 되는지 안되는지 확인한다.
이때 PatterMatchUtils의 simpleMatch메서드를 사용한다.
이러면 whitelist에 있는 경로와 requestURI의 값을 비교한다.
if문을 통과한다면, whiltelist에 포함이 되지 않았으므로, 로그인 유무를 확인해야한다.
httpRequest.getSession(false)로 세션을 가져와서, -> false로 한 이유는 false안적어주면 Session없을시 새로 생성하니까,
이 session의 .getAttribute에서 Key에 해당하는 SessionConst.LOGIN_MEMBER("loginMember")에 해당하는 Member를 가져온다.
여기서 어? 근데 왜 session == null을 왜 먼저 검사했지? 할수 있다. 그냥 어차피 getAttribute할꺼 session.getAttribute만 호출해서 검사하면 되지 않나? 싶은데
우리가, httpRequest.getSession(false)로 설정하여서 만약에 session이 없다면, session에 null값이 들어가있다. session == null을 먼저 검사하지않고, session.getAttribute를 할시에 null이면 null.getAttribute는 nullpoinException이므로 먼저 session이 null인지 검사를 해줘야한다.
만약에 null이라면 httpResponse.sendRedirect메서드를 호출하여 로그인으로 redirect시킨다.
그런데 여기서 /login?redirectURL = requestURI
로 만약, localhost::8080/items로 접근시
localhost::8080/login?redirectURL=/items로 보내주는데 그이유가, 로그인을 할시 홈화면으로 보내는게 아니라, 해당하는 redirectURL로 보내주기 위해서이다.
더 중요한부분은 return;으로 마무리 짓는것인데, 로그인을 하지 않은 사용자의 요청을 서블릿과 컨틀롤러가 호출되어 처리하면 안되므로 return으로 끝낸다.
이렇게 return을 하더라도 finally에 해당하는 구문은 실행되므로 괜찮다.
고로 앞서 redirect를 사용했기 때문에, redirect가 응답으로 적용되고 요청이 끝난다.
WebConfig - loginCheckFilter()추가
// @Bean
public FilterRegistrationBean logCheckFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
우선순위2번으로 check해준다. addURLPatterns에 /*으로 하여도 filter내부에서 whitelist검사를 하기 때문에 괜찮다.
Logincontroller-loginV4
여기서 이제 로그인에 성공하면 이전 요청한 URL로 redirect하는 기능을 확인해 보겠다.
@PostMapping("/login")
public String login(@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());
log.info("login? {}",loginMember);
if(loginMember==null){
bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);
return "redirect:"+redirectURL;
}
RequestParam을 통해서 redirectURL을 받는다.
로그인하지 않고 보낸 마지막 요청이 /items라면 redirectURL에는 /items가 들어가게된다.
defaultvalue는 /라서 그냥 로그인을 안했으면 home으로 이동하게 하였다.
간단하게 redirectURL을 적용하기 위해서
return에 redirect:+redirectURL를 해주면된다.
참고, 이렇게 webConfig에 필터를 적용할때 /*로 전체적용을하고 filter내부에서 whiteList를 설정하면 앞으로 개발할 API에 관계없이 검증이 가능하다.
만약에 검증이 필요없는 API라면 그냥 해당 URL을 whiteList에만 추가하면 되는것이다.
스프링 인터셉터 또한 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결 할 수 있는 기술이다.
서블릿 필터 - 서블릿이 제공
스프링 인터셉터 - 스프링 MVC가 제공
인터셉터는 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다. 왜냐하면 이건 스프링 MVC가 제공하는 기능이므로 결국 디스패처 서블릿 이후에 등장하게된다. 스프링 MVC의 시작점이 디스패처 서블릿이라고 생각해보면 이해가 될것이다.
스프링 인터셉터 인터페이스
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)와 같이 단계적으로 잘 세분화 되어있다.
스프링 인터셉터 호출 흐름
정상적으로 요청이 작동할때는
prehandle:컨트롤러 호출 전에 호출된다.(더 정확히는 핸들러 어댑터 호출전에 호출됨), 응답값이 true면 다음으로 진행 false면 더이상 진행 x false이면 나머지 인터셉터는 물론이고, 핸들러 어댑터 또한 호출되지 않는다.
posthandle: 컨트롤러 호출 후에 호출된다.
afterCompletion: 뷰가 랜더링 된 후에 호출된다.
스프링 인터셉터 예외
만약 예외가 발생하면,
preHandle: 컨트롤러 호출 전에 호출된다.
postHandle: 컨트롤러에서 예외가 발생되면, postHandle은 호출되지 않는다.
afterCompletion: 예외의 발생여부와 관계없이 afterCompletion은 항상 호출된다. 이경우 예외(ex)를 파라미터로 받기 때문에 어떤 예외가 발생했는지 로그를 찍을 수 있다.
정리하자면 인터셉터는 스프링 MVC구조에 특화된 필터기능을 제공한다. 고로, MVC패턴을 사용하면 인터셉터를 사용하는것이 더 편리하다.
@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);
if(handler instanceof HandlerMethod){
HandlerMethod hm = (HandlerMethod) handler;
}
log.info("REQUEST [{}][{}][{}]",uuid,requestURI,handler);
return 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 logId = (String)request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}]",logId,requestURI);
if(ex !=null){
log.error("afterCompletion error!!",ex);
}
}
}
우선 preHandle,postHandle,afterCompletion메서드를 오버라이드 해준다.
로그를 출력하기 위해서 동일하게 uuid를 생성해두는데, 서블릿 필터와 다르게, HttpServletRequest에다가 key:LOG_ID로 value uuid를 저장하고 있음을 확인할 수 있다.
왜그래야할까?
서블릿 필터에서는 try catch finally로 지역변수로 uuid를 사용하여도 가능하였는데,
여기서는 try와 finally에 해당하는 부분이 메서드로 아에 나눠져있다. preahndle postHandle afterCompletion
그래서 각 다른 메서드에서 사용하기 위해서는 HttpServletRequest에다가 담아둔다.
HttpServletRequest에다가 담아두는 이유는 요청이 들어왔다가 나갈때까지 HTTPServletRequest가 같음을 보장하는 생명주기를 가지고 있기 때문이다.
그리고 핸들러 정보도 가져올수있는데 log.info메서드의 인자를 보면 handler가 있음을 확인할수있다.
여기서 내가 의문점을 가진것이 왜? HandlerMethod로 캐스팅을 했지? 이 의문이 들었다.
왜냐하면 -> @Controller애노테이션같은 경우에 핸들러 매핑을할때, RequestMappingHandlerMapping이 매핑된다. 이 내용은 MVC1편의 후반부에 글을 작성해두었으니 그부분을 확인하면 되겠다.
그리고 핸들러가 HandlerMethod가 아니라 RequestMappingHandlerMapping타입으로 되야하는거 아닌가? 싶었는데
일단 @Controller방식은 RequestMappingHandlerMapping이 처리하는게 맞다. 이때 @Controller로 작성된 모든 컨트롤러를 찾고 파싱해서 HashMap으로<요청 정보, 처리할 대상>으로 관리한다.
요청정보->Http Method,URI등
처리할 대상 -> Handler Method 객체로 컨트롤러, 메소드등을 가지고 있음
그러므로, 요청이 오면 (Http Method,URI)등을 사용해서 요청 정보를 만들고, HashMap에서 처리할 대상 Handler Mehtod를 찾고 HandlerExecutionChain으로 감싸서 바환한다. 그이유는 컨트롤러로 요청을 넘겨주기 전에 처리해야하는 인터셉터등을 포함하기 위해서다.
고로 인터셉터에서 handler를 확인하기 위해서는 HandlerMethod로 확인하면 된다.
그리고 종료 로그를 postHandle이 아니라 afterCompletion에서 실행한 이유는, 예외 가 발생한 경우 postHandle이 호출되지 않기 때문에, afterCompletion은 예외가 발생해도 호출이 되는것을 보장한다.
또한, postHandle에서 modelAndView를 확인 할 수 도있다.
WebConfig-인터셉터 등록
등록은 WebMvcConfigurer가 제공하는 addInterceptors메서드를 사용하면된다.
addPathPatterns를 통해 인터셉터를 적용할 URL 패턴을 지정한다. 우리는 모든 요청에 요청 로그를 남길꺼니까 /**로 하였다.
서블릿 필터보다 훨씬 간단한다.
우선 여기서는 preHandle만 구현했는데 요청 로그처럼 postHandle과 afterCOmpletion은 구현을 안했다. 일단 default라서 반드시 오버라이드 하지 않아도, 애초에 요청을 보냈을때 핸들러 어댑터에 보내주기 전에 로그인이 안되어있으면 false를 반환하고 핸들러 어댑터로 안넘어 가면되기 때문이다.
그래서 session확인하는 로직은 서블릿 필터와 동일하다. sesstion.getAttribute(SessionCOnst.LOGIN_MEBER) 로 session을 가져왔을때 null이면 로그인이 안된거니까 login으로 리다이렉트 시켜주고, return false를 해준다.
그게 아니면 return true를 해준다.
등록
서블릿 필터에서는 whitelabel 배열을 만들어서 넣고 이랬는데 여기서는 그냥 .excluePathPatterns에다 추가하여서 제외 할 수 있따.
@Login애노테이션을 만들고 각 컨트롤러의 메서드에서 확인한다면 인터셉터를 만들 필요 없이 더 편한 부분도 있을 것이다.
이번에는 Login애노테이션을 만들고 확인하는 방법을 알아보겠다.
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model
model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
이런식으로 메서드의 파라미터에 @Login에노테이션을 붙여서 로그인을 했으면 loginMember에다가 넣어주고, 아니면 이제 return home으로 보내는 이런방식으로 구현해보겠다.
@Login애노테이션 생성
Target을 토앻서 파라미터에만 사용하고, 리플렉션등을 활용할 수 있도록 런타임까지 애노테이션 정보가 남아있게했다.
LoginMemberArgumentResolver생성
우선 supportsParameter에서 @Login파라미터가 아규먼트에 있는지, 또 그 파라미터에 해당하는 객체 type이 Member가 맞는지 검증을 해야한다.
그래서 hasLoginAnnotation에는 Login.class 우리가 만든 @Login애노테이션이 맞는지를 확인하고,
hasMemberType에서는 parameter.getParametType()하면 Member type이니까 Type이 Member인지 아닌지 검증한다.
그리고 이 두가지가 동일하게 맞다면, 이제 return에 true가 반환될것이다.
그다음에 실제 로직인 resolveArgument메서드에서는 HttpServletRequest에서 동일하게 session을 가져오면된다. 이 resolveargument메서드에서는 파라미터로 NativeWebRequest를 지원하므로 HttpServletRequest로 캐스팅을해주고 getSession을 해주고 null이면 return null 그게아니라 session이 있다면 이제 getAttribute로 SessionConst.Login_MEMBER로 Member를 가져와서 반환한다.
if문에 있는 sesison은 애초에 session자체가 없는경우이고, session이있다면 그 session에서 LOGIN_MEMBER가 있는지 확인하는게 return문이다. 없으면 null이반환된다.
WebMVConfigurer에 설정추가
실행해보면, 결과는 동일하지만, 더 편리하게 로그인 회원 정보를 조회 할 수 있다. 이렇게 ArgumentResuolver를 활용하면 공통작업이 필요할때 컨트롤러를 더욱 편리하게 사용할 수 있다.