본 글은 인프런 김영한님의 스프링 완전 정복 로드맵을 기반으로 정리했습니다.
이전 글에서 세션과 쿠키를 사용해서 무상태 HTTP 프로토콜에서 로그인(상태)를 유지하는 방법을 알아봤다.
특정 기능을 로그인한 유저만 사용할 수 있도록 제한하고 싶은 상황이 있다고 생각해보자. 컨트롤러에서 세션을 조회해서 로그인 여부를 체크하는 로직을 작성할 수도 있다. 그러나, 로그인이 필요한 모든 컨트롤러에서 로그인 여부를 확인하는 것은 꽤나 번거로운 작업일 것이다. 더 큰 문제는 향후 로그인 처리 로직이 변경되면 일일이 모든 로직을 다 수정해야 하는 것이다.
이렇게 애플리케이션 전반에 걸친 공통 관심사를 cross-cutting-concern 이라고 한다. 공통 관심사를 스프링의 AOP로 해결할 수도 있다. 그러나, 웹과 관련된 공통 관심사는 이 글에서 설명할 서블릿 필터 혹은 스프링 인터셉터로 처리하는 것이 편하다. 웹과 관련된 관심사는 HTTP 헤더나 URL의 정보 등이 필요한데 서블릿 필터와 스프링 인터셉터는 HttpServletRequest
를 제공하기 때문에 필요한 정보들에 쉽게 접근할 수 있다. 필터와 인터셉터를 이용해 로깅과 로그인 여부 확인을 처리하는 방법을 살펴보자.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
필터는 서블릿 호출 전에 실행된다. 필터는 특정 URL 패턴에 적용할 수 있으며 필터에서 요청을 거절할 경우 서블릿이 호출되지 않는다. 스프링의 경우 서블릿을 디스패쳐 서블릿으로 생각하면 된다.
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
필터는 체인으로 구성되기 때문에 중간에 자유롭게 추가할 수 있다. 예를 들어, 로그를 남기는 필터를 먼저 적용하고 그 다음에 로그인 여부를 체크할 수 있다.
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() {}
}
필터는 필터 인터페이스를 구현하고 등록해서 사용할 수 있다. 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고 관리해준다. 여러 HTTP 요청이 동시에 필터를 사용하기 때문에 필드값을 사용하는 등 상태에 의존하게 구현하면 동시성 이슈가 발생할 수 있다.
init(), destroy() 는 default
메서드기 때문에 필요할 때 구현하면 되고 필수적으로는 핵심 로직을 작성하는 doFilter()
를 구현하면 된다.
요청마다 로그를 남기는 필터를 구현하고 등록해보자.
@Slf4j
public class LogFilter implements Filter {
@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);
}
}
}
필터는 ServletRequest
, ServletResponse
를 매개변수로 받는다. 필터는 HTTP 외의 요청까지도 고려해서 만든 인터페이스다. 대부분의 경우 HTTP를 사용하기 때문에 HttpServletRequest httpRequest = (HttpServletRequest) request;
와 같이 다운 캐스팅해서 사용하면 된다.
로그를 구분하기 위한 UUID를 사용하고 컨트롤러 호출 전후로 URI 정보를 로그로 남겼다. REQUEST를 먼저 로그로 찍고 요청이 완료된 뒤 RESPONSE를 로그로 찍는다.
중요한 부분은 chain.doFilter(request, response)
다. 필터는 체인으로 구성된다고 했다. 다음 필터를 수동으로 호출해줘야 하며 이 부분을 빼먹으면 다음 필터가 호출되지 않아 결과적으로 다음 단계로 진행되지 않는다. 당연히 서블릿도 호출되지 않고 그로 인해 컨트롤러도 호출되지 않을 것이다.
필터를 구현했다면 등록해줘야한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
필터를 등록하는 방법은 여러가지다. 스프링 부트에서는 FilterRegistrationBean
을 사용해서 등록하면 된다. FilterRegistrationBean
에 필터를 등록한다. 필터를 적용할 URL패턴을 지정해준다. 또, 필터는 체인으로 구성되기 때문에 순서를 지정해준다.
필터를 구현하고 등록까지 했다면 모든 요청에 대해 로그가 문제없이 찍힐 것이다.
참고로, 실무에서 HTTP 요청시 같은 요청에 대한 로그의 식별자를 남기는 방법이 궁금하다면 logback mdc로 검색헤보자.
로그인 여부를 체크하는 필터를 개발하고 적용해보자.
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;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestURI = httpRequest.getRequestURI();
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 {
}
}
private boolean isLoginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(whiteList, requestURI);
}
}
가장 먼저 ServletRequest
, ServletResponse
에는 별 기능이 없기 때문에 다운 캐스팅했다. HttpServletRequest
를 통해 요청 URI 정보를 가져온다.
문자열 배열 whitelist를 통해 필터를 적용하지 않을 URL패턴들을 지정했다. 홈, 회원가입, 로그인, 로그아웃, css리소스 등에는 로그인 여부와 상관 없이 접근할 수 있도록 했다. 해당 경로는 로그인 여부를 검사하지 않고 바로 chain.doFilter()
를 호출해서 다음 필터나 서블릿을 호출한다.
isLoginCheckPath()
내부 로직이 핵심이다. 필터를 적용하는 URL경로에 한해 HttpServletRequest.getSession(false)
를 통해 세션을 조회했다. 세션 자체가 없거나 세션에 로그인 정보가 없으면 HttpServletResponse.sendRedirect()
를 통해 로그인 페이지로 리디렉션 시킨후 return
했다. chain.doFilter()
를 호출하지 않았기 때문에 다음 필터나 서블릿이 호출되지 않는다. 당연히 컨트롤러 또한 호출되지 않는다.
구현을 했다면 등록해줘야 한다. WebConfig 에 다음을 추가하면 된다.
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
필터를 등록하고 순서를 2번으로 잡았다. 로그 필터 다음으로 적용된다. 로그 필터와 마찬가지로 모든 요청(/*)에 대해 적용하지만 내부적으로 whitelist를 통해 특정 경로들은 로그인 여부를 검사하지 않는다.
스프링 인터셉터도 서블릿 필터처럼 웹과 관련된 공통 관심사항을 해결하는 기술이다. 서블릿 필터는 이름처럼 서블릿 컨테이너에 등록되어 사용되는 서블릿이 제공하는 기술이다. 스프링 인터샙터는 스프링 MVC가 제공하는 기술이다. 둘 다 웹과 관련된 공통사항을 처리하지만 일반적으로, 인터셉터가 훨씬 강력한 기능을 제공하기 때문에 특별한 이유가 아니고선 서블릿 필터를 사용할 일은 거의 없다. 왜 그런지 살펴보자.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
스프링 인터셉터는 디스패쳐 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다. 인터셉터는 스프링이 제공하는 기능이기 때문에 결국 디스패쳐 서블릿 이후에 등장하게 된다. 필터와 적용되는 순서는 다르지만 필터와 마찬가지로 적절하지 않은 요청이라고 판단하면 이후 흐름을 진행하지 않고 컨트롤러가 호출되지 않도록 할 수 있다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
인터셉터도 필터처럼 체인을 구성하므로 중간에 추가하여 인터셉터간에 순서를 가질 수 있다.
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()
하나의 메서드에서 작성했다. 그렇기 때문에 핵심 로직, 예외 상황, 요청 이후를 각각 처리하기 위해 try-catch-finally
를 사용했다.
HandlerInterceptor
인터페이스는 호출 전(preHandle), 호출 후(postHandle), 요청 이후(afterCompletion)와 같이 단계적으로 세분화한 인터페이스를 제공한다. 핵심 로직, 요청 이후, 예외 상황을 각각의 제공하는 메서드를 오버라이딩하여 작성하면 된다. 모든 메서드가 default
기 때문에 필요한 부분만 선택적으로 구현하면 된다.
또, 서블릿 필터는 request
, response
만 제공하지만 인터셉터는 어떤 컨트롤러가 호출되는지 Object handler
를 통해 알 수 있다. 그리고 어떤 ModelAndView
가 반환되는지 응답 정보도 알 수 있다. 또, 예외가 발생한경우 Exception
정보도 제공한다.
위 그림은 정상흐름에서 인터셉터가 동작하는 순서를 나타낸다.
preHandle()
은 컨트롤러 호출 이 전에 호출된다. 컨트롤러가 종료되면 postHandle()
이 핸들러 어댑터가 반환한 ModelAndView
와 함께 호출된다. 클라이언트에게 응답이 나가고 난 뒤 afterCompletion()
이 호출된다.
컨트롤러에서 예외가 발생한 경우엔 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);
log.info("REQUEST [{}][{}]", uuid, requestURI);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
String requestURI = request.getRequestURI();
String uuid = (String)request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}]", uuid, requestURI);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
필터와 마찬가지로 요청 로그를 구분하기 위한 UUID와 요청 경로를 로그로 찍었다. 다만, 필터는 요청 이전과 이후를 하나의 메서드에서 처리하지만 인터셉터는 메서드가 분리되어 있으므로 UUID를 저장할 공간이 필요하다. 필드를 사용하고 싶을 수 있지만, 인터셉터도 필터와 마찬가지로 싱글톤이기 때문에 필드를 사용하면 동시성 이슈가 발생한다. 따라서, HttpServletRequest.setAttribute()
를 통해 요청 객체에 담아두었다. 조회할 때는 HttpServletRequest.getAttribute()
를 사용하면 된다.
필터는 ServletRequest
, ServletResponse
를 각각 다운캐스팅하여 사용해야 했지만 인터셉터는 HttpServletRequest
, HttpServletResponse
가 제공되므로 형변환의 수고가 없어진다.
또, 필터에선 chain.doFilter()
를 호출해주지 않으면 다음 필터 혹은 서블릿이 호출되지 않는다. 인터셉터의 preHandle()
은 반환값 boolean에 따라 다음 인터셉터나 컨트롤러 호출여부가 결정된다. 메서드 반환값은 필수로 지정해줘야 되므로 필터의 경우처럼 실수로 다음 호출을 하지 않는 일이 발생하지 않는다.
인터셉터도 구현했다면 등록해줘야한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
WebMvcConfigureer
의 addInterceptors
메서드를 오버라이딩해서 인터셉터를 등록할 수 있다. 필터와 마찬가지로 인터셉터의 순서를 지정해주고 적용할 URL 패턴을 지정할 수 있다. 필터에서 제외할 URL 패턴을 내부적으로 whitelist 등을 관리해서 직접 구현해줘야 했다면 인터셉터에선 excludePathPatterns()
을 통해 제외할 패턴을 지정해줄 수 있다.
필터의 URL 지정 패턴은 서블릿 URL 패턴이고 인터셉터의 URL 지정 패턴은 PathPattern 패턴이다. 자세한 문법은 링크를 참고해서 필요할 때 찾아서 사용하면 된다.
로그인 여부 확인을 인터셉터를 통해 구현해보자.
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("/login?redirectURL=" + requestURI);
return false;
}
return true;
}
}
로그인 여부는 컨트롤러 호출 이전에만 확인하면 되기 때문에 preHandle()
만 구현했다. 필터의 whitelist, 요청/응답 객체 형변환, try-catch-finally 등이 빠져서 코드가 매우 간결해진 것을 볼 수 있다.
등록은 다음과 같이 한다. 로그 인터셉터를 먼저 적용하고 로그인 인터셉터를 그 다음에 적용한다.
@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");
}
애플리케이션 전반에 걸친 관심사를 처리하는 방법은 크게 AOP, 필터, 인터셉터가 있다. 웹과 관련이 있는 로그인 처리 등의 공통 관심사는 필터, 인터셉터를 통해 처리하는 것이 더 편리하다. 그 중에서도 인터셉터가 필터에 비해 훨씬 강력하고 편리한 기능을 제공하므로 특별한 이유가 없다면 웹과 관련된 공통 관심사는 인터셉터를 통해 해결하도록 하자.