제육's 휘발성 코딩
반응형

로그인 처리 - 필터, 인터셉터

서블릿 필터

사용자만 상품 관리 페이지에 들어갈 수 있어야 한다는 요구사항을 살펴보자. 로그인하지 않은 사용자도 URL을 알고 있다면 직접 호출하여 관리 화면에 들어올 수 있다.

이 부분을 해결하려면 등록, 수정, 삭제, 조회 등등 상품관리의 모든 컨트롤러 로직에 공통으로 로그인 여부를 확인해야 한다. 이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사라고 한다. 스프링의 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 HTTP의 헤더나 URL의 정보가 필요한데, 이 부분을 HttpServletRequest를 제공하는 서블릿 필터나 스프링 인터셉터를 사용하는 것이 바람직하다.

서블릿 필터 흐름

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

필터를 적용하면 필터가 호출된 다음에 서블릿이 호출된다. 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 된다. 참고로 필터는 특정 URL 패턴에 적용할 수 있다. /*이라고 하면 모든 요청에 필터가 적용된다. 여기서의 서블릿은 스프링의 DispacherServlet이라고 생각하면 된다. 필터는 체인 방식으로 중간에 필터를 자유롭게 추가할 수 있다.

서블릿 필터 인터페이스

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");
        HttpServletRequest httpRequest = (HttpServletRequest)  request;
        String requestURI = httpRequest.getRequestURI();

        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");
    }
}
  • 필터를 사용하려면 필터 인터페이스를 구현해야 한다. ServletRequest는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스로, HTTP를 사용하면 다음과 같이 다운 캐스팅을 해주어야 한다. chain.doFilter(request,response)를 해줘야만 다음 필터가 있으면 호출하고, 없으면 서블릿을 호출한다.

서블릿 필터 적용

package hello.login;

import hello.login.web.filter.LogFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;

@Configuration
public class WebConfig {

  @Bean
  public FilterRegistrationBean logFilter() {
    FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>(); 
    filterFilterRegistrationBean.setFilter(new LogFilter()); // 필터 등록
    filterFilterRegistrationBean.setOrder(1); // 필터별 우선 순위
    filterFilterRegistrationBean.addUrlPatterns("/*");
    return filterFilterRegistrationBean;
  }
}
  • @ConfigurationFilterRegistrationBean을 사용해서 등록한다. setOrder()는 필터 간 우선 순위를 지정할 수 있으며, addUrlPatterns("/*")는 모든 URL을 지정한다.

실무에서 HTTP 요청 시 같은 요청의 식별자를 자동으로 남기는 방법은 logback mdc가 있다. 참고로만 알아두자.

서블릿 필터 - 인증 체크

image

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

  private boolean isLoginCheckPath(String requestURI) {
    return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
  }

}
  • whitelist ={} : 인증과 무관하게 항상 허용 해줄 URL 지정
  • isLoginCheckPath() : 화이트리스트를 제외한 모든 경우 인증 체크 로직 적용
  • httpResponse.sendRedirect("/login?redirectURL=" +requestURI : 미인증 사용자는 로그인 화면으로 이동하는데 로그인 이후에 홈으로 이동해버리면 원하는 경로를 다시 찾아가야하는 불편함이 있다. 즉, 로그인 성공 시 다음 URL의 기록을 합쳐서 보내는 것으로 이 기능은 추가로 개발이 필요하다.
  • return : redirect를 사용했기 때문에 이후의 필터는 진행하지 않기 위해 사용한다.
 import hello.login.web.filter.LogFilter;
 import hello.login.web.filter.LoginCheckFilter;
 import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;

 import javax.servlet.Filter;

 @Configuration
 public class WebConfig {

     @Bean
     public FilterRegistrationBean logFilter() {
         FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
         filterFilterRegistrationBean.setFilter(new LogFilter());
         filterFilterRegistrationBean.setOrder(1); // 필터별 우선 순위
         filterFilterRegistrationBean.addUrlPatterns("/*");
         return filterFilterRegistrationBean;
     }

     @Bean
     public FilterRegistrationBean loginCheckFilter() {
         FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
         filterRegistrationBean.setFilter(new LoginCheckFilter());
         filterRegistrationBean.setOrder(2);
         filterRegistrationBean.addUrlPatterns("/*");
         return filterRegistrationBean;
     }
 }
  • 새로운 필터를 생성했으니 적용을 위해 @Configuration에 등록하자.
@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;
}
  • 이전에 redirectURL을 쿼리스트링으로 넘겨서 처리하는 부분을 여기서 수행한다. @RequestParam에 기본 값을 /로 받아오고 return문에 추가해주면 허용되지 않는 URL에 접속하여 로그인 화면으로 이동했을 때 로그인 후 이전의 화면을 볼 수 있게 된다.

서블릿 필터는 인터셉터는 제공하지 않는 강력한 기능이 있다. 바로 chain.doFilter(request, response)를 다른 객체로 바꿀 수 있다는 점이다. 잘 사용하는 기능은 아니니 참고만 해두자.

스프링 인터셉터

스프링 인터셉터는 스프링 MVC가 제공하는 기술로 필터와 같이 웹과 관련된 공통 관심 사항을 처리하지만, 스프링 인터셉터가 더 많은 기능을 제공하고 사용방법이 다르다. 즉, 스프링 MVC를 사용하고 특별히 필터를 사용해야 하는 상황이 아니라면, 인터셉터를 사용하는 것이 더 편리하다.

스프링 인터셉터 흐름

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

스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출 된다. 스프링 인터셉터에도 URL 패턴을 적용할 수 있는데, 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정할 수 있다.

스프링 인터셉터 인터페이스

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)와 같이 단계적으로 세분화 되어 있다.
  • 인터셉트는 어떤 컨트롤러(handler)가 호출되는지, 어떤 모델앤뷰가 반환되는지 응답 정보도 받을 수 있다.

예외가 발생하지 않는 경우의 인터셉터

image

preHandle : 컨트롤러 호출 전에 호출된다. (정확히는 핸들러 어댑터 호출 전에 호출)

  • preHandle의 응답이 true이면 다음으로 진행하고, false 이면 끝낸다.

postHandle : 컨트롤러 호출 후에 호출된다. (정확히는 핸들러 어댑터 호출 후에 호출)

afterCompletion : 뷰가 렌더링 된 이후에 호출된다.

예외가 발생한 경우의 인터셉터

image

preHandle : 컨트롤러 호출 전에 호출

postHandle : 컨트롤러에서 예외가 발생하면 호출되지 않는다.

afterCompletion : 항상 호출된다. 이 경우 예외를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력할 수도 있다.

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

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 {
    private 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; // boolean 형 타입으로 false 를 반환하면 종료 된다.
    }

    @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() : 스프링 인터셉터는 호출 시점이 완전히 분리되어 있어서 preHandle 에서 지정한 값을 다른 시점에서 사용하려면 request에 담아두고 getAttribute로 가져와서 사용해야 한다.
package hello.login;

import hello.login.web.filter.LogFilter;
import hello.login.web.filter.LoginCheckFilter;
import hello.login.web.interceptor.LogInterceptor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.servlet.Filter;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1).addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
    }
}
  • WebMvcConfigurer가 제공하는 addInterceptors()를 사용해서 인터셉터를 등록할 수 있다.
  • order : 우선 순위를 적용 (낮을 수록 먼저) , addPathPatterns("/**") : 인터셉터를 적용할 URL 패턴 지정
? 한 문자 일치
* 경로(/) 안에서 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이라는 변수로 캡처
  • addPathPatterns 를 제공하는 PathPattern 공식 문서

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

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
    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만 구현하면 된다.
@Configuration
public class WebConfig implements WebMvcConfigurer {

  @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 활용

요청 매핑 핸들러 어댑터 구조에서 사용하는 ArgumentResolver를 사용하면 로그인 회원을 편리하게 찾을 수 있다.

@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {

  // 세션에 회원 데이터가 없으면
  if (loginMember == null) {
    return "home";
  }

  // 세션이 유지되면 로그인으로 이동
  model.addAttribute("member", loginMember);
  return "loginHome";
}
  • @Login 애노테이션을 커스텀으로 만들어서 ArgumentResolver를 만들어서 동작해보자. 즉, 세션에 로그인 회원이 있는지를 찾고 없으면 null을 반환하는 방식이다.
package hello.login.web.argumentresolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
  • @Target(ElementType.PARAMETER) : 파라미터에만 사용
  • @Retention(RetentionPolicy.RUNTIME) : 런타임까지 애노테이션을 사용하겠다는 의미. 보통 커스텀 애노테이션을 만들면 이 설정을 한다고 보면 된다.
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 에서 파라미터와 타입 여부를 확인한다.
  • resolveArgument() : 컨트롤러 호출 직전에 호출 되어 필요한 파라미터 정보를 생성해준다. 여기서는 세션에 있는 로그인 회원 정보인 member 객체를 찾아서 반환해준다.
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }
}
  • @Configuration에 해당 정보를 addArgumentResolvers() 에 등록하자.

실행해보면 결과는 동일하지만, 더 편리하게 로그인 회원 정보를 조회할 수 있다. 공통 작업이 필요할 때 ArgumentResolver를 활용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다.

 


본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^