요구사항을 보면 로그인 한 사용자만 상품 관리 페이지에 들어갈 수 있어야 한다.
앞에서 로그인을 하지 않은 사용자에게는 상품 관리 버튼이 보이지 않기 때문에 문제가 없어 보인다.
그런데 문제는 로그인 하지 않은 사용자도 다음 URL을 직접 호출하면 상품 관리 화면에 들어갈 수 있다는 점이다 🤔
이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 있는 것을 공통 관심사(cross-cutting concern) 라고 한다❗️
➡️ 웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL의 정보들이 필요한데, 서블릿 필터나 스프링 인터셉터는 HttpServletRequest
를 제공한다.
필터는 서블릿이 지원하는 수문장이다. 필터의 특성은 다음과 같다.
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()
: 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.package hello.login.web.filter;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;
@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");
}
}
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);
// 모든 url에 다 적용
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
필터를 등록하는 방법은 여러가지가 있지만, 스프링 부트를 사용한다면 FilterRegistrationBean
을 사용해서 등록하면 된다!
setFilter(new LogFilter())
: 등록할 필터를 지정한다.setOrder(1)
: 필터는 체인으로 동작한다. 따라서 순서가 필요하다. 낮을 수록 먼저 동작한다. addUrlPatterns("/*")
: 필터를 적용할 URL 패턴을 지정한다. 한번에 여러 패턴을 지정할 수 있다.package hello.login.web.filter;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.PatternMatchUtils;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
@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 {
log.info("인증 체크 필터 시작 {}", requestURI);
// 화이트리스트가 아니라면
if (isLoginCheckPath(requestURI)) {
log.info("인증 체크 로직 실행 {}", requestURI);
HttpSession session = httpRequest.getSession(false);
// session == null : 로그인 되지 않았다는 뜻
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청 {}", requestURI);
// 로그인으로 redirect
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) {
// PatternMatchUtils : (whitelist, requestURI) -> 패턴으로 매칭되는가
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
whitelist = {"/", "/members/add", "/login", "/logout","/css/*"};
isLoginCheckPath(requestURI)
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return;
// 로그인 체크 필터 등록
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
setFilter(new LoginCheckFilter())
: 로그인 필터를 등록한다.setOrder(2)
: 두번째 순서 = 로그 필터 다음에 로그인 필터 적용. addUrlPatterns("/*")
: 모든 요청에 로그인 필터를 적용한다.로그인에 성공하면 처음 요청한 URL로 이동하는 기능을 개발해보자😊
// 로그인에 성공하면 처음 요청한 URL로 이동하는 기능 추가
@PostMapping("/login")
public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
@RequestParam(defaultValue = "/") String redirectURL,
HttpServletRequest request) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//로그인 성공 처리
//세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL;
}
/login
에 redirectURL
요청 파라미터를 추가해서 요청했다. 서블릿 필터를 잘 사용한 덕분에 로그인 하지 않은 사용자는 나머지 경로에 들어갈 수 없게 되었다🤗
공통 관심사를 서블릿 필터를 사용해서 해결한 덕분에 향후 로그인 관련 정책이 변경되어도 이 부분만 변경하면 된다👍🏻
스프링 인터셉터도 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다❗️
(서블릿 필터가 서블릿이 제공하는 기술이라면, 스프링 인터셉터는 스프링 MVC가 제공하는 기술)
스프링의 인터셉터를 사용하려면 HandlerInterceptor
인터페이스를 구현하면 된다😊
📌 afterCompletion은 예외가 발생해도 호출된다
- 예외가 발생하면
postHandle()
는 호출되지 않으므로 예외와 무관하게 공통 처리를 하려면afterCompletion()
을 사용해야 한다.- 예외가 발생하면
afterCompletion()
에 예외 정보(ex
)를 포함해서 호출된다.
인터셉터는 스프링 MVC 구조에 특화된 필터 기능을 제공한다고 이해하면 된다.
스프링 MVC를 사용하고, 특별히 필터를 꼭 사용해야 하는 상황이 아니라면 인터셉터를 사용하는 것이 더 편리하다👍🏻
package hello.login.web.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
@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);
// @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);
}
}
}
request.setAttribute(LOG_ID, uuid)
preHandle
에서 지정한 값을 postHandle
, afterCompletion
에서 함께 사용하려면 어딘가에 담아두어야 한다.LogInterceptor
도 싱글톤 처럼 사용되기 때문에 맴버변수를 사용하면 위험하기 때문에 request
에 담아두었다. afterCompletion
에서 request.getAttribute(LOG_ID)
로 찾아서 사용한다.return true
true
는 정상 호출으로 다음 인터셉터나 컨트롤러가 호출된다.// 스프링 인터셉터 등록
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
// '/' 하위 전부 다
.addPathPatterns("/**")
// 전체 경로가 다 되지만 이 경로는 해당되지 않음
.excludePathPatterns("/css/**", "/*.ico", "/error");
WebMvcConfigurer
가 제공하는 addInterceptors()
를 사용해서 인터셉터를 등록할 수 있다.addPathPatterns
, excludePathPatterns
로 매우 정밀하게 URL 패턴을 지정할 수 있다.서블릿 필터에서 사용했던 인증 체크 기능을 스프링 인터셉터로 개발해보자😊
package hello.login.web.interceptor;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
// 로그인은 preHandle 만 있어도 됨
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);
// false 로 바로 끝내버림
return false;
}
return true;
}
}
preHandle
만 구현하면 됨! 매우 간-편🤗// 스프링 인터셉터 등록
@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");
}
ArgumentResolver
기능을 사용해서 로그인 회원을 조금 편리하게 찾아보자😊
@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
@Login
애노테이션이 있으면 직접 만든 ArgumentResolver
가 동작해서 자동으로 세션에 있는 로그인 회원을 찾아주고, 만약 세션에 없다면 null
을 반환한다!💡
@Login
애노테이션을 만들어야 컴파일 오류가 사라지므로 만들어 주기!
package hello.login.web.argumentresolver;
import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
@Override
// 지원하는 것인지 확인
public boolean supportsParameter(MethodParameter parameter) {
log.info("supportsParameter 실행");
boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
return hasLoginAnnotation && hasMemberType;
}
@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);
}
}
supportsParameter()
: @Login
애노테이션이 있으면서 Member
타입이면 해당 ArgumentResolver
가 사용된다.resolveArgument()
member
객체를 찾아서 반환)member
객체를 파라미터에 전달해준다.💡 WebMvcConfigurer에도 앞서 개발한
LoginMemberArgumentResolver
를 등록해주자!
실행해보면 결과는 동일하지만, 더 편리하게 로그인 회원 정보를 조회할 수 있다😊
이렇게 ArgumentResolver
를 활용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다👍🏻❗️
휘 휘 휘..~