상품 관리나 상품 수정, 삭제 등의 작업은 로그인 한 사용자만 접근이 가능한데 현재 로그인을 하지 않은 사용자가 URL을 직접 호출하면 그 화면에 들어갈 수 있는 문제가 발생한다.
접근 권한을 필요로 하는 경우에는 컨트롤러에서 로그인 여부를 체크하면 되지만 똑같은 기능이 중복되는 문제가 발생한다. 이런 공통 관심사의 경우 스프링 AOP로도 해결이 가능하나 웹과 관련된 공통 관심사는 서블릿 필터 또는 스프링 인터셉터를 사용하는 것이 좋다. 웹과 관련된 공통 관심사를 처리할 때는 HTTP 헤더나 URL 정보들이 필요한데 서블릿 필터나 스프링 인터셉터는 HttpServletRequest
를 사용한다.
✔️서블릿 필터의 흐름
HTTP 요청 → WAS → 필터 → 서블릿 → 컨트롤러
필터는 서블릿이 호출되기 전에 호출이 된다. 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 된다. 필터는 특정 URL 패턴에 적용할 수 있다. /*
라고 하면 모든 요청에 필터가 적용이 된다. 스프링을 사용하는 경우 여기서 말하는 서블릿은 디스패처 서블릿이라고 생각하면 된다.
✔️필터 제한
HTTP 요청 → WAS → 필터 → 서블릿 → 컨트롤러(로그인한 사용자의 경우)
HTTP 요청 → WAS → 필터(비로그인 사용자의 경우, 서블릿 호출X, 컨트롤러 호출X)
✔️필터 체인
HTTP 요청 → WAS → 필터1 → 필터2 → 필터3 → 서블릿 → 컨트롤러
필터는 체인으로 구성되는데 중간에 필터를 여러 개 자유롭게 추가할 수 있다. 예를 들어 모든 요청을 남기는 필터를 먼저 만들고 그 다음 로그인 여부를 체크하는 필터를 만들 수 있다.
✔️필터 인터페이스
public interface Filter {
default void init(FilterConfig filterConfig) throws ServletException {
}
void doFilter(ServletRequest var1, ServletResponse var2, FilterChain var3) throws IOException, ServletException;
default void destroy() {
}
}
✔️스프링 MVC 1편 이미지
필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고 관리한다.
init()
: 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.doFilter()
: 고객 요청이 올 때마다 해당 메서드가 호출된다. 필터 로직 구현destroy()
: 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("log filter init");
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
log.info("log filter doFilter");
// ServletRequest - 부모 인터페이스, HttpServletRequest - 자식 인터페이스
HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
String requestURI = httpRequest.getRequestURI(); // 어떤 URL 요청인지
String uuid = UUID.randomUUID().toString(); // 어떤 사용자인지
try {
log.info("REQUEST [{}][{}]", uuid, requestURI);
filterChain.doFilter(servletRequest, servletResponse);
} catch (Exception e) {
throw e;
} finally {
log.info("RESPONSE [{}][{}]", uuid, requestURI);
}
}
@Override
public void destroy() {
log.info("log filter destroy");
}
}
public class LogFilter implements Filter
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException
doFilter
가 호출된다.ServletRequest
는 부모 인터페이스, HttpServletRequest
는 부모 인터페이스를 상속받는 자식 인터페이스이다.ServletRequest
는 HTTP 요청이 아닌 경우까지 고려한 것이다. HTTP를 사용하면 HttpServletRequest
를 다운캐스팅하여 사용하면 된다.filterChain.doFilter(servletRequest, servletResponse)
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1); // 필터 체인의 순서가 있기에 순서를 지정
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
필터를 등록하는 방법은 여러가지가 있다. 스프링 부트를 사용한다면 FilterRegistrationBean
을 사용해서 등록하면 된다.
filterFilterRegistrationBean.setFilter(new LogFilter())
: 등록 필터를 지정한다.filterFilterRegistrationBean.setOrder(1)
: 필터는 체인으로 동작한다. Order의 값이 작을수록 먼저 실행된다. 이 필터들의 순서를 지정한다.filterFilterRegistrationBean.addUrlPatterns("/*")
: 해당 필터를 적용할 URL 패턴을 지정한다. 한 번에 여러 패턴을 지정할 수 있다.@Slf4j
public class LoginCheckFilter implements Filter {
// 회원가입, 로그인, 메인
private final String[] whiteList = {"/", "/members/add", "/login", "/logout", "/css/*"};
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String requestURI = httpServletRequest.getRequestURI();
HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
try {
log.info("인증 체크 필터 시작={}", requestURI);
if (!isLoginCheck(requestURI)) {
log.info("인증 체크 로직 실행={}", requestURI);
HttpSession session = httpServletRequest.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청={}", requestURI);
httpServletResponse.sendRedirect("/login?redirectURL="+requestURI);
return;
}
}
filterChain.doFilter(servletRequest, servletResponse);
} catch (Exception e) {
throw e;
} finally {
log.info("인증 체크 필터 종료={}", requestURI);
}
}
// whiteList 경우 인증 체크X
// 매칭되는게 없으면 false, 매칭된다면 true
private boolean isLoginCheck(String requestURI) {
return PatternMatchUtils.simpleMatch(whiteList, requestURI);
}
}
private final String[] whiteList = {"/", "/members/add", "/login", "/logout", "/css/*"}
private boolean isLoginCheck(String requestURI)
doFilter()
를 호출한다.httpServletResponse.sendRedirect("/login?redirectURL="+requestURI)
return
을 통해 필터는 더는 진행하지 않는다.@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> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LoginCheckFilter());
filterFilterRegistrationBean.setOrder(2); // 필터 체인의 순서가 있기에 순서를 지정
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final SessionManager sessionManager;
private final MemberRepository memberRepository;
private final LoginService loginService;
// ...
@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute(name = "loginForm") LoginForm loginForm, BindingResult bindingResult,
@RequestParam(defaultValue = "/") String redirectURL, HttpServletRequest request) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
log.info("login={}", loginMember);
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// HttpSession 도입
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL;
}
}
"/login?redirectURL="+requestURI
에 요청 파라미터를 추가해서 요청했다.스프링 인터셉터도 서블릿 필터와 같이 웹 관련 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다. 서블릿 필터는 서블릿이 제공하는 기술, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다.
✔️스프링 인터셉터 흐름
HTTP 요청 → WAS → 필터 → 서블릿 → 스프링 인터셉터 → 컨트롤러
✔️스프링 인터셉터 제한
HTTP 요청 → WAS → 필터 → 서블릿 → 인터셉터1 → 인터셉터2 → 컨트롤러(로그인한 사용자)
HTTP 요청 → WAS → 필터 → 서블릿 → 스프링 인터셉터(적절치 못한 요청이라고 판단될 경우 컨트롤러 호출X, 비로그인한 사용자)
✔️스프링 인터셉터 체인
HTTP 요청 → WAS → 필터 → 서블릿 → 인터셉터1 → 인터셉터2 → ... → 컨트롤러
스프링 인터셉터는 체인으로 구성되는데, 필터와 같이 중간에 인터셉터를 자유롭게 추가할 수 있다. 서블릿 필터와 호출되는 순서가 다르고 제공하는 기능의 정교함과 사용 용이성 측면에서 비교하면 스프링 인터셉터가 더 편리하며 다양한 기능을 지원한다.
✔️스프링 인터셉터 인터페이스
스프링 인터셉터를 사용하려면 HandlerInterceptor
인터페이스를 구현하면 된다.
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 {
}
}
✔️스프링 인터셉터 호출 흐름
preHandle
: 컨트롤러 호출 전에 호출true
→ 다음으로 진행false
→ 다음으로 진행하지 않는다.postHandle
: 컨트롤러 호출 후 호출afterCompletion
: 뷰가 렌더링 된 이후 호출✔️스프링 인터셉터 예외 상황
preHandle
: 컨트롤러 호출 전에 호출postHandle
: 컨트롤러에서 예외 발생 시 postHandle
은 호출되지 않는다.afterCompletion
: afterCompletion
은 항상 호출된다. 예외를 파라미터로 받아 어떤 예외가 발생했는지 로그로 출력할 수 있다.afterCompletion
은 예외가 발생해도 호출된다. @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();
// 컨트롤러에서 예외가 발생하면 postHandle이 호출되지 않으나 afterCompletion은 예외가 발생해도 항상 호출된다.
request.setAttribute(LOG_ID, uuid);
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (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 uuid = (String) request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}][{}]", uuid, requestURI, handler);
if (ex != null) {
log.error("afterCompletionError", ex);
}
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
}
/pages/t?st.html
: ?
는 한 문자만 일치/resources/*.png
: resources
디렉터리 안에 있는 확장자가 png
인 파일들/resources/**
: resources
디렉터리 하위 모든 파일들@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("미인증 사용자 요청");
response.sendRedirect("/login?redirectURL="+requestURI);
return false;
}
return true;
}
}
@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("/", "/login", "/logout", "/members/add", "/css/**", "/*.ico");
}
}
addPathPattern
와 excludePathPattern
에 작성하면 된다. 기본적으로 모든 경로에 해당 인터셉터를 적용하되 홈(/**
), 회원가입(/members/add
), 로그인(/login
), 리소스 조회(/css/**
), 오류(/error
)와 같은 부분은 로그인 체크 인터셉터를 적용하지 않는다.✔️@Login 어노테이션
@Target(value = ElementType.PARAMETER) // 파라미터에서만 사용
@Retention(value = RetentionPolicy.RUNTIME) // 리플렉션 등을 활용할 수 있도록 런타임까지 어노테이션 정보가 남아있게끔
public @interface Login {
}
✔️HandlerMethodArgumentResolver 구현
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
log.info("supportsParameter 실행");
boolean hasLoginAnnotation = methodParameter.hasParameterAnnotation(Login.class);
boolean hasMemberType = Member.class.isAssignableFrom(methodParameter.getParameterType());
return hasMemberType && hasLoginAnnotation;
}
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
log.info("resolveArgument 실행");
HttpServletRequest nativeRequest = (HttpServletRequest) nativeWebRequest.getNativeRequest();
HttpSession session = nativeRequest.getSession(false); // 의미없는 세션 생성 방지
if (session == null) {
return null;
}
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
public boolean supportsParameter(MethodParameter methodParameter)
@Login
어노테이션이 있으면서 Member
타입이면 해당 ArgumentResolver
가 사용된다.public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception
member
객체를 찾아서 반환한다. 이후 스프링 MVC는 컨트롤러 메서드를 호출하면서 여기에서 반환된 member
객체를 파라미터에 전달한다. ✔️WebMvcConfigure 추가
@Configuration
public class WebConfig implements WebMvcConfigurer {
// ArgumentResolver 등록
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
}