MVC2 7th Step

최보현·2022년 8월 15일
0

MVC

목록 보기
14/18
post-thumbnail

스프링 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() {}
}

필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리함

  • init(): 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출됨
  • doFilter(): 고객의 요청이 올 때 마다 해당 메서드가 호출됨, 필터의 로직을 구현하면 됨
  • destroy(): 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출됨

요청 로그 by 서플릿 필터

@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");
    }
}
  • 필터를 사용하려면 인터페이스를 구현 해야 함
  • HTTP 요청이 오면 doFilter가 호출됨
  • ServletRequest request는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스로, HTTP를 사용하면 위와 같이 다운 케스팅 하면 됨
    +) ServletRequest는 HttpServletRequest의 부모
    가장 중요한 부분
    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 을 사용 권장

인증 체크 by 서블릿 필터

@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 -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

  • 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출
  • 스프링 MVC가 제공하는 기능이기 때문에 결국 디스패처 서블릿 이후에 등장
  • 스프링 인터셉터에도 URL 패턴을 적용할 수 있는데, 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정할 수 있음

스프링 인터셉터 제한

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 {}
}

서블릿 필터 VS 스프링 인터셉터

  • 서블릿 필터의 경우 단순하게 doFilter() 하나만 제공
  • 인터셉터는 컨트롤러 호출 전( preHandle ), 호출 후( postHandle ), 요청 완료 이후( afterCompletion )와 같이 단계적으로 잘 세분화
  • 서블릿 필터의 경우 단순히 request , response 만 제공
  • 인터셉터는 어떤 컨트롤러( handler )가 호출되는지 호출 정보도 받을 수 있고 어떤 modelAndView 가 반환되는지 응답 정보도 받을 수 있음

호출 흐름

정상 흐름

  • preHandle : 컨트롤러 호출 전에 호출 (더 정확히는 핸들러 어댑터 호출 전에 호출)
    - preHandle 의 응답값이 true 이면 다음으로 진행하고, false 이면 더는 진행X
    • false인 경우 나머지 인터셉터는 물론이고, 핸들러 어댑터도 호출X
  • postHandle : 컨트롤러 호출 후에 호출 (더 정확히는 핸들러 어댑터 호출 후에 호출)
  • afterCompletion : 뷰가 렌더링 된 이후에 호출된다.

예외 상황

  • preHandle : 컨트롤러 호출 전에 호출
  • postHandle : 컨트롤러에서 예외가 발생하면 postHandle 은 호출X
  • afterCompletion : afterCompletion 은 항상 호출, 이 경우 예외( ex )를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력 가능

afterCompletion
예외가 발생하면 postHandle() 는 호출되지 않으므로 예외와 무관하게 공통 처리를 하려면 afterCompletion() 을 사용해야 함
예외가 발생하면 afterCompletion() 에 예외 정보( ex )를 포함해서 호출

요청 로그 by 스프링 인터셉터

@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);
        }
    }
}
  1. 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)

인증 체크 by 스프링 인터셉터

@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 만 구현

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어노테이션 생성

@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());
  }
  //...
}
profile
Novice Developer's Blog

0개의 댓글