애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사라고 한다.
스프링 AOP를 사용해도 되지만, 웹과 관련된 공통 관심사를 처리할때는 서블릿에서 제공해주는 필터(Filter) 혹은 스프링에서 제공해주는 인터셉터를 이용하면 좋다.
HTTP Header
나 URL
정보들이 관심사를 처리하는 로직에 필요하기 때문이다. 필터나 인터셉터는 HttpServletRequest
과 같은 웹과 관련된 부가 기능들을 제공해준다.
필터는, 서블릿이 제공하는 문지기 같은 역할이다. 필터의 흐름은 다음과 같다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터 3 -> 서블릿 -> 컨트롤러
// 필터 체인
중요한 것은, 필터가 호출된 다음에 서블릿이 호출된다는 것이다. (ex) Dispatcher Servlet
) 참고로 필터는 특정 url 패턴에만 적용할 수 있다.
만약 공통 관심사로 로그인 인증을 처리하고자 한다면, 로그인이 안되어 있는 경우 필터에서 걸러져서 서블릿이 호출되지 않도록 하면 된다.
HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출 X)
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()
: 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다. 서블릿 필터는 다음 필터가 있으면 필터를 호출하고, 더 이상 필터가 없으면 서블릿을 호출한다. 따라서 만약 다음의 로직을 호출하지 않으면, 다음 단계로 진행되지 않는다는 점에 주의하자.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
...
chain.doFilter(request, response);
...
}
필터를 설정하려면 Filter
인터페이스를 구현한 이후에 설정 정보를 등록해줘야 한다.
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter(){
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
setOrder(1)
: 필터는 체인으로 동작하기 때문에 순서가 필요, 낮을 수록 먼저 동작addUrlPatterns("/*")
: 필터를 적용할 URL 패턴을 지정🔖 참고 사항
만약 HTTP 요청 시 같은 요청의 로그에 모두 같은 식별자를 공통으로 남기고 싶다면 LogBack mdc를 사용하면 된다.
모든 컨트롤러에 로그인 인증 여부 코드를 작성하는 것은 비효율적이기 때문에, 로그인 하지 않은 사용자는 상품 관리에 접근 불가능 하도록 구현해야 한다.
@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{
if(isLoginCheckPath(requestURI)){ //로그인 인증이 필요한 경로
HttpSession session = httpRequest.getSession(false);
if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return;
}
}
chain.doFilter(request,response);
}catch(Exception e){
throw e;
}finally{
log.info("인증 체크 필터 종료 {}", requestURI);
}
}
//화이트 리스트의 경우 인증 체크 X
private boolean isLoginCheckPath(String requestURI){
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
미인증 사용자의 경우 저장된 세션 정보가 없을테니 서블릿과 컨트롤러의 호출을 막기 위해 로그인 페이지로 리다이렉트 시키고 끝낸다. 즉, 요청이 끝나 이후 비즈니스 로직 동작에 영향을 주지 않도록 해야 하는 것이다.
이때, 로그인 하기 이전에 존재했던 페이지 경로를 기억해두기 위해 URL 쿼리 파라미터에 포함시킨다. 이후 로그인 성공 컨트롤러에서 저장해놨던 경로로 다시 리다이렉트 시켜주면 된다.
@PostMapping("/login")
public String loginV4(...@RequestParam(defaultValue = "/") String redirectURL..){
...
return "redirect:" + redirectURL; //없으면 홈(/)으로, 있으면 기존 화면으로
}
참고로 스프링 시큐리티도 필터 기반으로 동작한다.
스프링 인터셉터란, 웹과 관련된 공통 관심사를 효과적으로 처리할 수 있는 기술이다. 스프링 MVC가 제공하므로, 스프링 MVC 구조에 특화된 필터 기능을 제공한다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다. 필터와 마찬가지로 URL 패턴을 적용할 수 있지만 훨씬 정밀하게 설정 가능하다.
필터와 비슷하게 로그인 인증 흐름은 다음과 같으며 인터셉터 체인도 제공한다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출 X)
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) t hrowsException {}
}
메서드 인자를 보면 알 수 있듯이 어떤 핸들러가 요청되었는지, 에러 정보 등 필터보다 훨씬 더 많은 기능을 제공해준다.
preHandle
: 컨트롤러 호출 전에 호출된다. (핸들러 어댑터 호출 전)true
이면 다음으로 진행하고, false
이면 더이상 진행하지 않아 나머지 인터셉터나 핸들러 어댑터도 호출되지 않는다.postHandle
: 컨트롤러 호출 후에 호출된다. (핸들러 어댑터 호출 후)afterCompletion
: 뷰가 렌더링 된 이후 마지막에 호출된다. preHandle
: 컨트롤러 호출 전에 호출된다.postHandle
: 컨트롤러에서 예외가 발생하면, postHandle
은 호출되지 않는다.afterCompletion
: 예외와 무관하게 항상 호출되며, 포함된 예외 정보를 인자로 받을 수 있기 때문에 로그로 출력 가능하다.@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); // 요청 ID 저장
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();
Object logId = request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}][{}]", logId, requestURI, handler);
if (ex != null) {
log.error("afterCompletion error", ex);
}
}
}
HandlerMethod
란, @Controller
나 @RequestMapping
을 활용한 핸들러 매핑의 경우 넘어오는 핸들러 정보(컨트롤러)이다.
또한 인터셉터는 필터와 달리 호출 시점이 완전히 분리되기 때문에, request.setAttribute(LOG_ID, uuid)
을 통해 request
에 담아두고 사용하면 된다. 인터셉터 역시 싱글톤으로 관리되기 때문에 절대 전역 변수로 저장하면 안된다는 것 주의하자.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**") //하위 전부 다
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
}
addPathPatterns("/**")
: 인터셉터를 적용할 URL 패턴을 지정인증과 같은 경우는 컨트롤러 호출 전에만 필요하므로 preHandle
만 구현하면 되므로 필터에 비해 매우 간단해진 것을 볼 수 있다.
@Slf4j
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){
// 미인증 사용자면 로그인으로 redirect
response.sendRedirect("/login?redirectURL=" + requestURI);
return false;
}
return true;
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**") //세밀하게 적용 가능
.excludePathPatterns("/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error");
}
excludePathPatterns
에는 인터셉터를 사용 하지 않을 부분을 명시해주면 된다. 따라서 인터셉터에서 직접 제외할 경로를 정하는 것이 아닌, 인터셉터를 '설정'할 때 설정 정보로 넘겨주기만 하면 되기 때문에 필터보다 편리하다.
ArgumentResolver
를 활용하면, 다음과 같이 로그인 회원을 더 간편하게 찾을 수 있다.
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember) {
...
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
직접 만든 ArgumentResolver
가 동작하여, 자동으로 세션에 있는 로그인 회원을 찾아주고 없다면 null
을 반환한다.
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
return hasLoginAnnotation && hasMemberType; //true면 아래 메소드 실행
}
@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(SessionConst.LOGIN_MEMBER); //세션 존재 시 멤버 반환
}
}
supportsParameter()
@Login
애노테이션이 있으면서 Member 타입이면 해당 ArgumentResolver
가 사용된다. resolveArgument()
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
}