로그인 - 필터,인터셉터(2)

이정원·2024년 11월 22일
post-thumbnail

로그인을 하지 않은 사용자에게는 상품 관리 버튼이 보이지 않기 때문에 문제가 없어 보이지만 URL을 직접 호출하면 상품 관리 화면에 들어갈수 있는 큰 오류가 발생한다.

이렇게 등록, 수정, 삭제, 조회 등등 여러 로직에서 공통으로 인증에 대해서 관심을 가지고 있는것을 공통 관심사(cross-cutting concern)라고 한다.

1.서블릿 필터

필터는 서블릿이 지원하는 수문장이다.

필터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

일반적으로 위와 같이 요청에 대한 흐름이 이어진다.

필터 제한

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출X) //비 로그인 사용자

필터에서 로그인 여부를 체크해서 적절하지 않은 요청이라고 판단하면 제한을 거는 흐름이다.

필터 체인

HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러

중간에 자유롭게 필터를 추가할수 있다.

필터 인터페이스

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

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

1-1.요청 로그

사용자 요청에 대한 로그를 남겨보자.
Webconfig

@Configuration
public class WebConfig {

    @Bean
    public FilterRegistrationBean<Filter> logFilter(){
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter()); // 커스텀 필터
        filterRegistrationBean.setOrder(1); //순서
        filterRegistrationBean.addUrlPatterns("/*"); // URL 패턴

        return filterRegistrationBean;

    }
}

LogFilter

@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");
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();
        String uuid = UUID.randomUUID().toString();
        try {
            log.info("REQUEST [{}][{}]",uuid,requestURI);
            //필터 or 서블릿으로 필터를 넘긴다.
            chain.doFilter(request,response);
        }catch (Exception e){
            throw e;
        }finally {
            //모든 로직이 끝났을때 호출된다.
            log.info("REQUEST [{}][{}]",uuid,requestURI);
        }
    }

    @Override
    public void destroy() {
        log.info("log filter destroy");
    }
}

해당 코드는 사용자가 보내는 모든 http 요청에 대한 로그를 남기는 코드이다. Spring boot가 WAS를 띄울때 필터를 같이 넣어주는 설정을 한다.
만약 고객별 모든 요청에 대한 UUID를 남기고 싶다면 logback mdc를 적용하자.

chain.doFilter(request,response): 다음 필터가 존재하면 다음 필터를 호출하고 존재하지 않으면 서블릿을 호출한다.

1-2.인증 체크

로그인 되지 않은 사용자는 상품 관리 뿐만 아니라 미래에 개발될 페이지에도 접근하지 못하게 인증 체크 필터를 개발해보자.
로그인 체크 필터

@Slf4j
public class LoginCheckFilter implements Filter {
    //인증 체크가 불필요한 URI 리스트
    private static final String[] whiteList={"/","/members/add","/login","/logout","/css/*"};
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @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)){
                //whiteList에 포함 X
                log.info("인증 체크 로직 실행 {}",requestURI);
                HttpSession session = httpRequest.getSession(false);
                if(session==null || session.getAttribute(SessionConst.LOGIN_MEMBER)==null){
                    log.info("미인증 사용자 요청 {}",requestURI);
                    // 해당 코드는 로그인 페이지로 보내고 로그인 성공시 요청했던 url로 GET을 요청하기 위한 임시 코드
                    httpResponse.sendRedirect("login?redirectURL="+requestURI);
                    return;
                }
            }
            chain.doFilter(request,response);
        }catch (Exception e){
            throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
        }finally {
            log.info("인증 체크 핗터 종료 {}",requestURI);
        }

    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
    private boolean isLoginCheckPath(String requestURI){
        return !PatternMatchUtils.simpleMatch(whiteList,requestURI);
    }
}

WebConfig 등록

@Bean
    public FilterRegistrationBean loginCheckFilter(){
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LoginCheckFilter()); // 커스텀 필터
        filterRegistrationBean.setOrder(2); //순서
        filterRegistrationBean.addUrlPatterns("/*"); // URL 패턴

        return filterRegistrationBean;
    }

화이트 리스트를 제외한 모든 페이지에 대한 요청의 인증 체크를 진행하는 필터를 설정했다. 인증이 안된 사용자를 로그인 페이지로 돌려보내는것은 성공했지만 로그인 성공시 원래 요청했던 url로 가지 못한다. 해당 로직을 짜보자.

LoginController

@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";
        }
        //로그인 성공 처리 TODO
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);

        return "redirect:" +redirectURL;
    }

2.스프링 인터셉터

스프링 인터셉터도 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다. 서블릿 필터가 서블릿이 제공하는 기술이라면, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다.

스프링 인터셉터 호출 시점

HTTP 요청 -> WAS -> 필터 -> (Dispatcher)서블릿 -> 스프링 인터셉터 -> 컨트롤러

Dispatcher Servlet 호출 후 컨트롤러 직전에 호출된다. 스프링 인터셉터도 적절하지 않은 요청이라고 판단되면 제한을 하고 체인 기능도 적용할수 있다.

스프링 인터셉터 인터페이스
스프링의 인터셉터를 사용하려면 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 {
        
    }
}

서블릿 필터의 경우 단순하게 doFilter() 하나만 제공되지만 인터셉터는 컨트롤러 호출 전(preHandle), 호출 후(postHandle), 요청 완료 이후(afterCompletion)와 같이 단계적으로 잘 세분화 되어 있다.

1.prehandle에서 예외가 발생하는 경우:

  • 필수 헤더가 없는 경우
  • 잘못된 HTTP 메서드 사용
  • URI이 유효하지 않은 경우
  • 사용자 인증 실패
  • 유효성 검증 실패 등등

2.posthandle에서 예외가 발생하는 경우:

  • 컨트롤러가 반환한 데이터(JSON, ModelAndView 등)를 변환하는 과정에서 오류
  • 응답에 헤더를 추가하려는 과정에서 발생

3.afterCompletion에서 예외가 발생하는 경우:

  • 자원 정리 실패(데이터베이스 연결, 파일 핸들러)
  • 로깅 실패
  • 예외 처리 중 추가 예외

컨트롤러에서 예외 발생시 인터셉터 흐름

컨트롤러에서 예외 발생시

  • preHandle: 컨트롤러와 상관 없이 발동
  • postHandle: 호출 x
  • afterCompletion: 항상 호출

2-1.요청 로그 인터셉터

예외가 터진다면 UUID를 로그로 남기고 싶다. 하지만 prehandle에서 afterCompletion으로 넘길수 없다.(싱글톤으로 고객마다 다른 UUID가 디어야함) -> 파라미터 request에 setAttribute와 getAttribute로 받을수 있다.
로그 인터셉터

@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);
        log.info("REQUEST [{}][{}][{}]",uuid,requestURI,handler);
        if(handler instanceof HandlerMethod){
            HandlerMethod hm = (HandlerMethod) 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();
        Object logId = request.getAttribute(LOG_ID);
        log.info("RESPONSE [{}][{}][{}]",logId,requestURI,handler);
        if(ex!=null) {
            log.info("afterCompletion error!!", ex);
        }
    }
}

인터셉터 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**") //전체 경로
                .excludePathPatterns("/css/**","/*.ico","/error");  // 이 경로 제외하고
    }
}

Interceptor Path Pattern
* (Single Level Wildcard)

  • 현재 디렉토리(한 레벨)에서만 매칭됨

** (Multi-Level Wildcard)

  • 모든 하위 디렉토리까지 포함하여 매칭됨

HandlerMethod
핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라진다. 스프링을 사용하면 일반적으로 @Controller , @RequestMapping 을 활용한 핸들러 매핑을 사용하는데, 이 경우 핸들러 정보로 HandlerMethod 가 넘어온다.

ResourceHttpRequestHandler
@Controller 가 아니라 /resources/static 와 같은 정적 리소스가 호출 되는 경우 ResourceHttpRequestHandler 가 핸들러 정보로 넘어오기 때문에 타입에 따라서 처리가 필요하다.

2-2.인증 체크 인터셉터

로그인은 prehandle 메서드만 구현하면 된다.
로그인 체크 인터셉터

@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;
    }
}

사용자 인증 체크는 컨트롤러 호출전인 prehandle 에서만 검증을 진행하고 정상이면 return true, 비정상은 false를 반환한다.
로그인 체크 인터셉터 등록

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

3.ArgumentResolver

ArgumentResolver는 Spring MVC에서 컨트롤러 메서드의 파라미터를 바인딩할 때, 해당 파라미터를 해석하고 필요한 값을 제공하기 위해 사용된다.

@RequestParam, @PathVariable, @RequestBody, @ModelAttribute 등을 사용해 데이터를 바인딩.

직접 로그인된 사용자를 처리하는 Customazing Annotation을 만들어보자.
홈 컨트롤러

@GetMapping("/")
    public String homeLoginV3SpringArgumentResolver(
            @Login Member loginMember, Model model) {
        if(loginMember==null){
            return "home";
        }
        //세션이 있다면 로그인 창으로 이동
        model.addAttribute("member",loginMember);
        return "loginHome";
    }

Login 애노테이션

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {

}

LoginMemberArgumentResolver

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("suppoertsParameter 실행");
        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
        //두가지가 만족된다면 resolveArgument 실행
        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);

    }
}

Webconfig 등록

@Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }

0개의 댓글