서블릿 필터와 스프링 인터셉터
자 잘 생각해보면, 로그인 기능은 구현하였지만, 각 컨트롤러마다 해당 로그인 사용자에 대한 정보를 얻어오거나 애초에 로그인이 안되어 있다면 접근 불가하게 하도록 같은 코드를 싸그리 다 복붙 해야할까?
잘 아시다시피 스프링은 이런 미친 짓을 가만히 두고 볼 일이 없다. 물론 AOP라는 방식이 존재한다. 하지만 난 이거 아직 공부를 안한 것도 있고 필터와 인터셉터 방식이 물론 AOP 보다 기능이 덜 하지만 메서드 실행 이전에 서블릿 단위에서 실행되어서 compact 한 느낌이랄까. 아무튼 진행해 보겠다.
1. 공통점과 차이점
가장 큰 차이점으로는...
HTTP 요청 ->WAS-> 필터1 -> 필터2 -> 필터3 -> 서블릿(디스페처 서블릿) -> 스프링 인터셉터1 -> 인터셉터 2 -> 컨트롤러
위와 같이 서블릿을 기점으로 필터와 인터셉터의 위치가 다르다.
가장 큰 공통점으로는
컨트롤러 전에 동작해서 컨트롤러까지 가기 전에 리젝 시켜버린다. 즉, 오류날 가능성이 많이 줄어든다! 그렇기에 리젝 되었을 때의 redirectUrl 또한 등록해 주어야 겠징?
2. 필터
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() {}
}
2.1 필터 구현 즉, 사실상 init이나 destroy는 log 찍어보는거 아니면 크게 신경 쓸 것이 없으니~ doFilter만 잘 다듬어 보면 되는데 아래가 예시가 될 것 같다.
@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);
} }
위 코드에서 중요한 것은 chain이다. 저 chain을 명시하지 않으면,,, 필터를 통과해도 다음 필터로 넘어가지 못한다. 즉, 성공처리가 나야할 로직이 필터에서 끝나게 된다.. 조심조심
그럼 저렇게 필터 implements 하면 끝나? 다른 필터들도 올릴꺼고 서로 구분하고 싶은데 그런건 어뜨케?
--> 그러니깐! 등록 과정이 존재한다.
2.2 WebConfig를 통한 필터 등록
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new
FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
} }
위와 같이 setFilter로 추가해주고, setOrder로 순서를 정한다. 작을 수록 먼저한다. addUrlPatterns("/*")의 경우 인자값으로 보이다시피 존재하는 모든 파일에 대해 적용해라! 라는 뜻이다.
눈여겨 봐야 할점은 @Configuraiton과 @Bean 이겠죠!
어 근데 저기여.. 혹시 필터를 적용하고 싶지 않은 url 은 어떻게 할까요?
2.3 필터 예외 사항 만들기
이 부분에 대해서는 필터에서 특별한 메서드가 없다. 단지 doFilter에서 로직을 구현해야 할 뿐. 말 그대로 필터를 처리하지 않을 리스트를 만들어서 doFilter의 인자인 ServletRequest를 HttpServletRequest로 캐스팅 한뒤에 .getReqeustURL () 통해서 이 url이 필터 처리 않을 리스트라면 넘어가. 이런 식이다.
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(catch (Exception e) {
throw e;
} finally {
log.info("인증 체크 필터 종료 {}", requestURI);
}
위와 같은 느낌이다. 이때 중요한 것은 필터를 통해 걸러질 부분이 return;을 했다는 것인데, 이로써 다음 필터나 서블릿으로 가지 않고 종료된다.
또한 httpResponse에 sendRedirect() 부분 또한 눈여겨 보길 바란다.
3. 인터셉터
지금까지 보면 필터도 꽤나 훌륭하다. 하지만 굳이 옥의 티를 잡자면, chain을 꼭 넣어야 한다는 것과 필터 적용 안할 url 들에 대한 로직을 개발자가 직접 코드를 짜야한다는 문제, 마지막으로 init이나 destroy가 굳이 필요가 없기도 하다.
그렇게 인터셉터가 등장했다.
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 {
}
}
필터처럼 여기는 HandlerInterceptor 인터페이스를 상속 받아야 한다. 세가지 메서드가 있는데 비슷한 역할을 할 것 같지만 하나씩 짚어 주겠다.
preHandle(): 서블릿(디스페쳐 서블릿)이 HandlerAdaptor 호출 전에 호출 한다.boolean 답게 true이면 통과 false면 실패. 즉 필터의 doFilter메서드와 역할이 같다.
postHandle(): 핸들러 어뎁터가 ModelAndView 리턴 한 후에 호출 된다.
afterCompletion(): 뷰가 나간 후에 불린다.
어랏 afterCompletion의 용도는 뭐야? 라고 묻는 다면 만약 preHandle이 아닌 핸들러에서 예외 처리가 나면 디스페처 서블릿은postHandle()은 호출 하지 않는다. WAS에게 예외 사항을 던질 뿐이다. 물론 뷰 렌더링도 안한다. 하지만 afterCompletion은 꼭꼭 한다. 마치 try-catch-finally의 finally 처럼~~
사실 보다보면 인자에 Exception이 있고 postHandledms ModelAndView가 있드아.
즉, 예외정보를 좀 더 잘 볼 수 있게 도와준다.
3.1 로그인 인터셉터 구현
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
HttpSession session = request.getSession();
if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
response.sendRedirect("/home");
return false;
}
return true;
}
}
위를 보면 한가지 의문이 들 수 있다. 뭐야 왜 preHandle 만 있어? 인터페이스 코드를 보면 default가 선언 되어 있는 것을 볼 수 있다. 즉 꼭 구현 안해도 된다.
로직에서도 필터의 경우 ServletRequest를 던져주다 보니 HttpServletRequest로 캐스팅 해야 했지만, 여기는 아싸리 HttpServletRequest로 던져주어서,,, 고맙다,, 2줄이나 줄었엉~~!!
로직도 session 값이 없거나(false로 값을 던져 주어 확인 가능) 없는 세션 id가 넘어 왔을 경우에는 home으로 리다이렉트를 때려버린다!
게다가 chain도 안해도 돼! 너무 좋아.
3.2 인터셉터 등록
필터 또한 Config에서 등록 해야 한다.
바로 코드부터 보자.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginCheckInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/home","/member/add","/login","logout",
"/css/**","/*.ico","/error");
}
}
위 코드를 보니깐 단박에 차이가 느껴지는 것은 excludePathPatterns()이다. 보시다시피 해당 경로에 있는 파일들은 인터셉터를 적용하지 않는 다는 것~~
또한 보이는 점이 한가지 있는데, 경로 명이 조금 특이하다. 맞다. 인터셉터는 스프링이 제공하니 URL 경로는 서블릿이 제공하는 필터와 다르다. 물론, 더욱 자세하고, 세밀하게 설정할 수 있다.
근데 이제까지 저렇게 검증 방식은 만들어 놓고 어떻게 쓰는지 안보여주냐?
3.3 사용법
차피 필터는 안쓸 거 같아서 인터셉터 기준으로 보여주겠당.
@GetMapping("/")
public String homeLoginV3Spring(
@SessionAttribute(name = "loginMember", required = false) Member loginMember, Model model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
보다시피 @SessionAttribute(name,required)와 같이 이용해서 검증 과정을 자동화 했다.
잘 써먹어용.
4. 설마.. 귀찮아?
그러할 당신을 위해 굉장히 좋은 팁을 알려주자면, @Login과 같은 간단한 어노테이션으로 검증 할 수 있도록 argmentResolver를 이용해서 핸들러 호출 전에 인자 값을 핸들러에 맞게 바꾸어주는 녀석을 직접 설정해보자!
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
위와 같은 어노테이션을 선언하고
public class LoginMemberArgResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);//@Login어노테이션 붙어 있음?
boolean hasId = Member.class.isAssignableFrom(parameter.getParameterType());
//type이 멤버이거나 맴버 상속 받은 것임?
return hasLoginAnnotation && hasId;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
HttpSession session = request.getSession(false);//당연히 기존 세션 값이 있어야 함.
if(session == null){//일종의 더블 체크랄까.
return null;
}
return session.getAttribute("loginMember");//우리가 필요한 값의 타입에 맞게 골라주기
}
}
ArgumentResolver를 만든 다음에 (로직은 간단하고 주석이 자세하니깐)
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgResolver());
}
WebConfig 파일에 argumentResolver를 등록해주면 끝! 파라미터가 List인 경우는 당연히 argmentReslover가 여러개 들어갈 수도 있으니깐~~
이렇게 함으로써 controller에서 이렇게 호출하면 Member 인증 처리를 할 수 있다.
@GetMapping("/calender")
@ResponseBody
public List<GetCalenderRes> initPage(@Login Member member,@RequestParam Integer year, @RequestParam Integer month){
return service.myCalenderPage(member.getId(), year, month);
}
굉장히 편하지?!?!?!
다음은 검증에 대해 다루어 보겠당~~