스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - sec07
출처 : 스프링 MVC 2편
로그인 한 사용자만 상품 관리 페이지에 들어갈 수 있어야 하는데 로그인 하지 않은 사용자도 다음 URL을 직접 호출하면 상품 관리 화면에 들어갈 수 있다 낭패
=> 상품관리의 모든 컨트롤러 로직에 공통으로 로그인 여부를 확인해야 하고 수정하더라도 로그인과 관련된 로직이 변경될 때마다 로직을 다 수정해야 함 극혐
이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 있는 것을 공통 관심사(cross-cutting concern)라고 함 나의 로그인의 경우, 여러 로직에서 공통으로 인증에 대해서 관심을 가지고 있음
+) 이러한 공통 관심사는 스프링의 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 지금부터 설명할 서블릿 필터 또는 스프링 인터셉터를 사용하는 것이 좋음
서블릿이 지원하는 수문장
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
필터를 적용하면, 필터가 호출 된 다음에 서블릿이 호출됨 => 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 됨
+) 필터는 특정 URL 패턴에 적용 가능 /*
이라고 하면 모든 요청에 필터가 적용
+) 참고로 스프링을 사용하는 경우, 서블릿은 스프링의 디스패처 서블릿으로 생각하면 됨
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출X) //비 로그인 사용자
적절치 않은 요청으로 판단될 시 거기서 끝냄
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
필터는 체인으로 구성되는데, 중간에 필터를 자유롭게 추가 가능
ex) 로그를 남기는 필터를 먼저 적용하고 로그인 여부를 체크하는 필터를 생성 가능
//default라는 키워드가 있으면 인터페이스라고 할지라도 전부다 구현하지 않아도 됨
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() {}
}
필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리함
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("log filter inint");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("log filter doFilter");
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String uuid = UUID.randomUUID().toString();
//HTTP 요청을 구분하기 위해 요청당 임의의 uuid 를 생성
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");
}
}
ServletRequest request
는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스로, HTTP를 사용하면 위와 같이 다운 케스팅 하면 됨chain.doFilter(request, response);
스프링 부트를 사용한다면 FilterRegistrationBean
를 사용해서 등록
was를 띄울 때 필터도 같이 데리고 가서 뛰어줌
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new
FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
//등록할 필터 지정
filterRegistrationBean.setOrder(1);
//필터는 체인으로 동작해서 순서가 필요함, 낮을 수록 우선순위 상승
filterRegistrationBean.addUrlPatterns("/*");
//필터를 적용할 URL 패턴 지정
return filterRegistrationBean;
}
}
@ServletComponentScan @WebFilter(filterName = "logFilter", urlPatterns = "/*")
로 필터 등록이 가능하지만 필터 순서 조절 불가 => FilterRegistrationBean 을 사용 권장
@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);
if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null){
log.info("미인증 사용자 요청 {}", requestURI);
//로그인으로 redirect
httpResponse.sendRedirect("/login?redirectURL="+ requestURI);
return;
//필터를 더는 진행하지 않는다. 이후 필터는 물론 서블릿, 컨트롤러가 더는 호출되지 않는다. 앞서 redirect 를 사용했기 때문에 redirect 가 응답으로 적용되고 요청이 끝난다.
}
}
chain.doFilter(request, response);
} catch (Exception e){
throw e; //예외 로깅 가능 하지만, 톰켓까지 예외를 보내줘야 함
} finally {
log.info("인증 체크 필터 종료 {}", requestURI);
}
}
/**
* 화이트 리스트의 경우 인증 체크X
*/
private boolean isLoginCheckPath(String requestURI){
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
미인증 사용자는 로그인 화면으로 리다이렉트 함, BUT 로그인 이후에 다시 홈으로 이동해버리면, 원하는 경로를 다시 찾아가야 함
ex) 상품 관리 화면을 보려고 들어갔다가 로그인 화면으로 이동하면, 로그인 이후에 다시 상품 관리 화면으로 들어가는 것이 GOOD!
이를 위해서는 현재 요청한 경로인 requestURI
를 /login 에 쿼리 파라미터로 함께 전달
필터에는 다음에 설명할 스프링 인터셉터는 제공하지 않는, 아주 강력한 기능이 있는데
chain.doFilter(request, response);
를 호출해서 다음 필터 또는 서블릿을 호출할 때 request,response 를 다른 객체로 바꿀 수 있음! ServletRequest , ServletResponse 를 구현한 다른 객체를 만들어서 넘기면 해당 객체가 다음 필터 또는 서블릿에서 사용됨잘 사용하는 기능은 아니니 참고만
스프링 MVC가 제공하는 기술
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출X) // 비 로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
HandlerInterceptor 인터페이스를 구현하면 됨
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) throws Exception {}
}
afterCompletion
예외가 발생하면 postHandle() 는 호출되지 않으므로 예외와 무관하게 공통 처리를 하려면 afterCompletion() 을 사용해야 함
예외가 발생하면 afterCompletion() 에 예외 정보( ex )를 포함해서 호출
@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();
//요청 로그를 구분하기 위한 uuid 를 생성
request.setAttribute(LOG_ID,uuid);
//@RequestMapping: HandlerMethod
//정적 리소스: ResourceHttpRequestHandler
if(handler instanceof HandlerMethod){
HandlerMethod hm = (HandlerMethod) handler;//호출할 컨트롤러 메서드의 모든 정보가 포함되어 있음
}
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
return true;
//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)
로 찾아서 사용
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler; //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
}
HandlerMethod
핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라짐, 일반적으로 @Controller , @RequestMapping 을 활용한 핸들러 매핑을 사용하는데, 이 경우 핸들러 정보로 HandlerMethod 가 넘어옴
ResourceHttpRequestHandler
@Controller 가 아니라 /resources/static 와 같은 정적 리소스가 호출 되는 경우, ResourceHttpRequestHandler
가 핸들러 정보로 넘어오기 때문에 타입에 따라서 처리가 필요
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
//인터셉터를 적용할 URL 패턴 지정
.excludePathPatterns("/css/**", "/*.ico", "/error");
//인터셉터에서 제외할 패턴 지정
}
//...
}
PathPattern - 서블릿보다 더 정교하게 적용 가능
? 한 문자 일치
- 경로(/) 안에서 0개 이상의 문자 일치
* 경로 끝까지 0개 이상의 경로(/) 일치
{spring} 경로(/)와 일치하고 spring이라는 변수로 캡처
{spring:[a-z]+} matches the regexp [a-z]+ as a path variable named "spring"
{spring:[a-z]+} regexp [a-z]+ 와 일치하고, "spring" 경로 변수로 캡처
{spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처
[공식 문서](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/ springframework/web/util/pattern/PathPattern.html)
@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;
}
}
인증이라는 것은 컨트롤러 호출 전에만 호출되면 됨 => preHandle 만 구현
@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어노테이션 생성
@Target(ElementType.PARAMETER) //파라미터에만 사용
@Retention(RetentionPolicy.RUNTIME) //실제 동작할때까지 어노테이션을 남게 해줌
public @interface Login {
}
LoginMemberArgumentResolver 생성
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
//@Login 애노테이션이 있으면서 Member 타입이면 해당 ArgumentResolver
가 사용됨
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);
}
}
resolveArgument()
: 컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성해줌, 세션에 있는 로그인 회원 정보인 member 객체를 찾아서 반환해준 후, 스프링MVC는 컨트롤러의 메서드를 호출하면서 여기에서 반환된 member 객체를 파라미터에 전달해줌
그 후, WebConfigure에 LoginMemberArgumentResolver
를 등록하면 끄으으읕!
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver>
resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
//...
}