웹 애플리케이션에서 상품 등록, 수정, 삭제 등 로그인을 해야만 가능한 페이지를 관리하기 위해선 로그인 여부를 확인해야 한다.
이렇게 웹 애플리케이션에 여러 로직에서 공통으로 관심있는 것을 공통 관심사(cross-cutting concern)이라고 한다.
서블릿 필터
서블릿 필터는 서블릿이 지원하는 수문장이다.
왜냐하면 요청이 있을 때 컨트롤러단에 가기 전에 필터 그리고 서블릿이 호출되기 때문이다.
필터 흐름은 다음과 같다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
만약 적절하지 않는 요청이라 서블릿 호출이 안되는 경우
HTTP 요청 -> WAS -> 필터 끝!
필터가 여러개인 경우 필터첸이이라고 한다. 로그를 남기고 싶다면 이때 필터추가하면 된다.
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
필터 인터페이스
public interface Filter {
//필터 초기화로 서블릿 컨테이너 생성될 때 호출
default void init(FilterConfig filterConfig) throws ServletException {
}
//고객의 요청이 들어올 때 해당 메서도 호출
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException;
//필터 종료 메서드로 서블릿 컨테이너 종료될 때 호출
default void 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 request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("log filter doFilter");
//웹 요청이 아는 경우를 고려해 ServletRequest를 사용한 것으로 다운 캐스팅함
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
//임의의 uuid 생성
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");
}
}
이제 이 필터가 동작하도록 FilterRegistrationBean을 통해 설정한다.
@Configuration
public class WebConfig {
/**
* 필터 등록
*
* @ServletComponentScan, @WebFilter로도 등록 가능하지만 순서 조절이 안됨
*/
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
//등록할 필터 지정
filterFilterRegistrationBean.setFilter(new LogFilter());
//필터는 체인으로 동작하여 순서를 설정한다. 낮은 숫자일수록 먼저 동작한다.
filterFilterRegistrationBean.setOrder(1);
//필터를 적용할 URL 패턴 지정한다.
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
이제 궁극적으로 필요했던 부분 로그인 인증 체크 필터를 제작해본다.
@Slf4j
public class LoginCheckFilter implements Filter {
//로그인 안해도 되는 경로이다. css 같은 리소스도 접근하게 해야한다.
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);
//필터가 더이상 진행하지 않는다. 따라서 redirect 적용되고 서블릿, 컨트롤러가 호출되지 않는다.
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);
}
}
Config에서 필터 등록해주자
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
//로그인 체크 필터 등록
filterFilterRegistrationBean.setFilter(new LoginCheckFilter());
//순서는 두번째
filterFilterRegistrationBean.setOrder(2);
//모든 요청에 필터 적용
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
로그인 후 기존에 있던 주소로 옮겨가기 위해 redirectURL를 넘겨줬다.
로그인 요청에서 수정하자
@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form,
BindingResult bindingResult,
@RequestParam(defaultValue = "/") String redirectURL,
HttpServletRequest request) {
if(bindingResult.hasErrors()) {
return "login/loginHome";
}
LoginMember loginMember = loginService.login(form.getLoginId(), form.getPassword());
if(loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 마지 않습니다.");
return "login/loginHome";
}
//로그인 성공 처리
//세션이 있으면 기존 세션 반환하고 없으면 신규 세션을 생성한다.
HttpSession session = request.getSession(true); //기본값이 ture이다.
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL;
}
스프링 인터셉터
스프링 인터셉터도 서블릿 필터와 같이 웹에서 관련된 공통 관심사항을 효과적으로 관리할 수 있다.
서블릿 필터는 서블릿이 제공했다면, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다.
둘다 웹 관련이지만 적용되는 범위와 순서, 그리고 사용방법이 다르다.
스프링 인터셉터의 흐름은 다음과 같다.
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
스프링 인터셉터는 말그대로 스프링이 제공하는 기능이라 디스패치 서블릿 이후에 등장하게 된다.
서블릿 필터와 다르게 인터셉터는 호출전, 호출 후, 요청 완료 이후 단계적으로 세분화된다.
서블릿 필터는 request, response만 제공받았다면, 인터셉터는 어떤 컨트롤러(handle)가 호출되는지 호출 정보도 알 수 있다.
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 {
}
}
preHandler는 컨트롤러 호출 전에 호출된다. 정확히는 핸들러 어댑터 전에 호출된다.
이때 preHandler의 응답값이 true이면 다음으로 진행되고, false면 진행이 멈춘다.
postHandler는 컨트롤러 호출 후에 호출된다. 정확히는 핸들러 어댑터 호출 후에 호출된다.
afterCompletion은 뷰가 렌더링된 이후에 호출된다.
컨트롤러에서 예외가 발생하면 postHandler는 호출 되지 않는데 afterCompletion은 항상 호출된다.
그래서 예외 정보 또는 예외와 무관한 공통사항은 postHandler가 아닌 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();
//임으의 uuid 생성
String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
//@RequestMapping: HandlerMethod
//정적 리소스: ResourceHttpRequestHandler
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, handler);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
인터셉터를 사용하기 위해 등록한다.
@Configuration
public class WebInterConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
//인터셉터 등록
registry.addInterceptor(new LogInterceptor())
//순서 지정
.order(1)
//인터셉터를 적용할 URL
.addPathPatterns("/**")
//인터셉터에서 제외할 패턴 지정
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
}
이제 로그인 인증 체크하는 인터셉터를 만든다.
@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("미인증 사용자 요청");
//로그인으로 redirect
response.sendRedirect("/login?redirectURL=" + requestURI);
return false;
}
return true;
}
}
이제 인터셉터를 등록한다.
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns(
"/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error"
);
이번엔 요청 매핑 핸들러 어탭터 구조에서 ArgumentResolver를 이용해본다.
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
@Target(ElementType.PARAMETER) //파라미터에만 사용
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
//@Login 어노테이션이 있으면서 Member 타입이면 해당 리졸버가 사용된다.
@Override
public boolean supportsParameter(MethodParameter parameter) {
log.info("supportsParameter 실행");
boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
boolean hasMemberType = LoginMember.class.isAssignableFrom(parameter.getParameterType());
return hasLoginAnnotation && hasMemberType;
}
//컨트롤러 호출 직전에 호출되어서 필요한 파라미터 정보를 생성한다.
//세션에 있는 로그인 회원 정보인 member 객체를 찾아 반환해준다.
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
log.info("resolveArgument 실행");
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
}
김영한, 스프링 MVC 2편
'🖥️ Back > Spring' 카테고리의 다른 글
김영한 스프링 MVC 2편 - API 예외 처리 (0) | 2025.07.07 |
---|---|
김영한 스프링 MVC 2편 - 예외 처리와 오류 페이지 (0) | 2025.07.06 |
김영한 스프링 MVC 2편 - 로그인, 쿠키, 세션 (3) | 2025.07.05 |
김영한 스프링 MVC 2편 - Bean Validation (0) | 2025.07.04 |
김영한 스프링 MVC 2편 - Validation (0) | 2025.07.02 |