[Spring MVC 2편] 7. 로그인 - 필터, 인터셉터

HJ·2023년 1월 19일
0

Spring MVC 2편

목록 보기
7/13

김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard


1. 공통 관심 사항

  • 로그인 한 사용자만 상품 관리 페이지에 들어갈 수 있어야 하는데 로그인을 하지 않은 사용자가 url을 직접 호출하면 접속할 수 있는 문제

  • 이것을 방지하기 위해 로그인 여부를 체크하는 로직( 인증 로직 )이 필요한데 등록, 수정, 삭제 등 모든 로직에 하나씩 작성하는 것은 비효율적

  • 모든 로직에서 관심 있는 것을 공통 관심 사항이라고 하고 이러한 공통 관심사는 AOP로 해결할 수 있지만 웹과 관련된 공통 관심 사항은 서블릿 필터나 스프링 인터셉터를 사용하는 것이 좋다

  • 웹과 관련된 공통 관심 사항을 처리할 때 HTTP 헤더나 URL의 정보가 필요한데 서블릿 필터와 스프링 인터셉터는 HttpServletRequest를 제공

    • 필터 : 서블릿이 제공하는 기능

    • 인터셉터 : 스프링이 제공하는 기능




2. 서블릿 필터

2-1. 필터 흐름

HTTP 요청 ➜ WAS ➜ 필터 ➜ 서블릿( DispatcherServlet ) ➜ Controller

  • 필터는 서블릿이 지원하는 수문장, 필터가 호출된 다음에 서블릿이 호출

  • 필터가 적절하지 못한 요청이라고 판단하면 뒤의 서블릿을 호출하지 않는다

  • 필터는 특정 URL 패턴을 적용해 URL 마다 다르게 수행하는 것이 가능

  • 필터는 체인으로 구성되는데 중간에 여러 가지 필터를 적용할 수 있다

    • WAS ➜ 필터1 ➜ 필터2 ➜ .. ➜ 서블릿 ➜ Controller

2-2. 필터 인터페이스

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

    • WAS에서 호출하는 메서드

    • 고객의 요청이 올 때마다 해당 메서드가 호출

    • 이 메서드 내부에 필터의 로직을 구현한다

  • destroy() : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다




3.요청 로그 필터 구현

3-1. 로직 구현

public class LogFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        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);
        }
    }
}
  • init()destroy()는 생략

  • doFilter()

    • HTTP 요청이 오면 호출되는 메서드 ( 요청마다 호출 )

    • HTTP 요청이 아닌 경우까지 고려한 ServletRequest이기 때문에 위처럼 다운캐스팅을 시켜서 사용한다 ( ServletRequest는 HttpServletRequest의 부모 )

    • Response 사용 시에도 마찬가지로 다운캐스팅 후 사용 필요

  • chain.doFilter(request, response)

    • 다음 필터가 있으면 필터가 호출되고 없으면 서블릿이 호출

    • 위 코드를 실행하지 않으면 다음 단계로 진행되지 않는다


3-2. 필터 등록

@Configuration
public class WebConfig {

    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");

        return filterRegistrationBean;
    }
}
  • 구현한 필터를 사용하기 위해서는 등록을 해야한다

  • 스프링부트를 사용하면 FilterRegistrationBean을 사용해서 등록한다

  • 위처럼 등록을 해놓으면 스프링부트가 WAS를 띄울 때 filter를 같이 넣어준다

  • LogFilter를 @Component를 통해 스프링 빈으로 등록하고 WebConfig에서 @Autowired를 통해 주입받아 Filter에 등록하는 방법도 있다

  • @ServletComponentSacn 이나 @WebFilter 로 필터 등록이 가능하지만 필터 순서 조절이 되지 않는다

  • 참고> HTTP 요청시 같은 요청의 로그에 모두 같은 식별자를 자동으로 남기는 방법은 logback mdc을 검색


  • setFilter(new LogFilter()) : 등록할 필터를 지정

  • setOrder(1) : 필터는 체인으로 동작하기 때문에 순서가 필요 ( 낮을수록 먼저 동작 )

  • addUrlPatterns("/*") : 필터를 적용할 URL 패턴을 지정, 한 번에 여러 패턴 지정 가능




4. 인증 체크 필터 구현

4-1. 화이트 리스트 지정

public class LoginCheckFilter implements Filter {

    private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};

    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}
  • whitelist는 로그인 없이 접속 가능한 URL들을 지정한 것이다 ( 누구나 접근 가능한 URL )

  • isLoginCheckPath()

    • whitelist에 있는 URL은 인증 체크할 필요 없기 때문에 URL 체크를 위한 메서드

    • true를 반환하면 인증 체크 시도

    • whitelist에 있는 URL의 경우 앞의 ! 때문에 false를 반환하게 된다

  • WebConfig에서 필터를 등록할 때 적용할 URL 패턴을 지정해도 되지만 그렇게 하면 URL이 추가되었을 때 수정해야하는 문제점

  • 위처럼 whitelist를 만들면 체크하지 않을 URL을 제외한 모든 URL에 적용하기 때문에 체크가 필요한 URL이 추가되어도 수정하지 않아도 된다


4-2. 로직 구현

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

                httpResponse.sendRedirect("/login?redirectURL=" + requestURI);

                return;
            }
        }
        chain.doFilter(request, response);
    } catch (Exception e) {
        throw e;
    } finally {
        log.info("인증 체크 필터 종료 {}", requestURI);
    }
}
  • isLoginCheckPath(requestURI) : 현재 URL이 whitelist에 없는 URL이면 인증 체크 실행

  • httpResponse.sendRedirect("/login?redirectURL=" + requestURI);

    • 미인증 사용자가 로그인 화면으로 redirect 되었을 때 로그인 후 현재 페이지로 돌아오기 위해 쿼리 파라미터에 현재 URL을 추가

    • 전달된 URL로 리다이렉트를 위해 추가적으로 LoginController에서 로그인 후 리다이렉트 경로 수정이 필요

  • return;

    • 필터를 더 이상 진행하지 않는다

    • 뒤의 필터와 서블릿, 컨트롤러가 호출되지 않는다

    • 앞서 redirect 를 사용했기 때문에 redirect 가 응답으로 적용되고 요청이 끝난다


4-3. 리다이렉트 수정

// LoginController
@PostMapping("/login")
public String loginV4(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request
                        @RequestParam(defaultValue = "/") String redirectURL) {
    ...

    return "redirect:" + redirectURL;
}
  • LoginCheckFilter에서 로그인 화면으로 보낼 때 현재 URL을 쿼리 파라미터로 같이 넘김

  • 로그인 메서드에서 쿼리 파라미터로 들어온 URL을 @RequestParam으로 받고, 로그인 성공 시 이를 이용하여 redirect 시킨다

  • 즉, 로그인을 성공하면 기존에 있던 페이지로 바로 이동한다




5. 스프링 인터셉터

5-1. 스프링 인터셉터 흐름

HTTP 요청 ➜ WAS ➜ 필터 ➜ 서블릿 ➜ 스프링 인터셉터 ➜ Controller

  • 스프링 인터셉터는 DispatcherServlet과 Controller 사이에서 Controller 호출 직전에 호출

  • 스프링 MVC의 시작점이 DispatcherServlet이고 스프링 인터셉터는 스프링 MVC가 제공하는 기술이기 때문에 서블릿 뒤에 호출

  • 스프링 인터셉터가 적절하지 않은 요청이라고 판단하면 Controller를 호출하지 않는다

  • URL 패턴을 적용할 수 있는데 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정 가능

  • 스프링 인터셉터는 체인으로 구성되는데 중간에 여러 가지 인터셉터를 추가할 수 있다


5-2. 스프링 인터셉터 인터페이스

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

}
  • preHandle() : Controller 호출 전

  • postHandle() : Controller 호출 후

  • afterCompletion() : 요청 완료 이후

  • 어떤 Controller( Handler )가 호출되는지와 어떤 ModelAndView가 반환되는지 응답 정보도 알 수 있다


5-3. 스프링 인터셉터 호출 흐름

  • preHandle

    • 컨트롤러 호출 전에 호출

    • 정확히는 핸들러 어댑터 호출 전에 호출

    • preHandle() 의 응답값이 true 이면 다음으로 진행하고, false 이면 더 이상 진행하지 않는다 ( 뒤의 인터셉터와 핸들러 어댑터가 호출되지 않는다 )

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

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


5-4. 스프링 인터셉터 예외 상황

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

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

  • afterCompletion

    • 예외 발생과 관계 없이 항상 호출된다 ( 예외와 무관하게 공통 처리를 할 때 사용 )

    • 예외( ex )를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력할 수 있다

    • 정상적인 경우에 예외( ex )에 null을 포함해서 호출, 예외가 발생하면 예외 정보를 포함해서 호출




6. 요청 로그 인터셉터 구현

6-1. 로직 구현

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

        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함되어 있음
        }

        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        return true;
    }

    ...

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

        ...
    }
}
  • 요청 로그와 응답 로그를 남길 때 어떤 요청에 대한 어떤 응답인지 구분할 수 있어야 한다

  • postHandle 은 Controller 에서 에러가 발생하면 호출되지 않기 때문에 응답 로그 출력은 afterCompletion 에서 하는 것이 옳다

  • 어떤 요청에 대한 어떤 응답인지 알기 위해 요청에서 생성한 UUID 를 afterCompletion 에 넘겨야한다

  • 인터셉터는 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 위험 ➜ HttpServletRequest 에 UUID 를 담아둔다

  • 즉, preHandle()에서 지정한 값을 postHandle()이나 afterCompletion()에서 사용하기 위해 request에 담아둔다


  • request.setAttribute(LOG_ID, uuid); : UUID 를 HttpServletRequest에 담는다

    • 필터의 경우 doFilter() 하나의 메서드이기 때문에 지역 변수로 사용했지만 인터셉터는 메서드들의 호출 시점이 완전히 분리되어 있기 때문
  • return true : 다음 인터셉트나 Controller를 호출


  • afterCompletion 에서 request.getAttribute(LOG_ID)를 통해 UUID 값을 꺼낸 후 사용

6-2. 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
    }
}
  • 등록할 때 @Bean으로 등록하는 것이 아니라 메서드를 오버라이딩해서 등록한다

  • addPathPatterns("/**") : 인터셉터를 적용할 URL 패턴을 지정

  • excludePathPatterns("/css/**", "/*.ico", "/error") : 인터셉터를 적용하지 않을 경로들을 설정

  • 필터 등록에서 설명한 것처럼 스프링 빈으로 등록해서 사용 가능하다

  • 경로 패턴 방법


6-3. 참고> handler

// @RequestMapping 을 사용하면 handler 는 HandlerMethod 가 사용
// 정적 리소스를 사용하면 ResourceHttpRequestHandler
if (handler instanceof HandlerMethod) {
    HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함되어 있음
}
  • preHandle()은 핸들러 매핑이 끝난 후 실행된다

  • 핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라지기 때문에 타입에 따른 분기 처리가 필요


  • HandlerMethod

    • @Controller , @RequestMapping 을 활용한 핸들러 매핑을 사용할 때 핸들러 정보로 HandlerMethod 가 넘어온다

    • 즉, @Controller, @RequestMapping 요청에 사용하는 핸들러가 HandlerMethod

  • ResourceHttpRequestHandler

    • 정적 리소스가 호출 되는 경우 ResourceHttpRequestHandler가 핸들러 정보로 넘어온다

  • 아래 내용은 모두 해당 블로그를 참고하였습니다

  • HandlerMethod : @RequestMapping@GetMapping과 같은 하위 어노테이션이 붙은 메서드의 정보를 추상화한 객체

  • DispatcherServlet이 어플리케이션이 실행될 때 모든 Controller 빈의 메서드를 찾아 매핑 후보가 되는 메서드를 추출하여 HandlerMethod 형태로 저장

  • 실제 요청이 들어오면 조건에 맞는 HandlerMethod를 참조해서 매핑되는 메서드를 실행


  • HTTP 요청을 HandlerMethod 객체로 변환하는 작업이나 해당 요청에 매핑되는 HandlerMethod 를 반환하는 작업은 RequestMappingHandlerMapping 이 담당

  • RequestMappingHandlerMapping은 @Controller 로 작성된 모든 컨트롤러 빈을 파싱하여 HashMap으로 (요청 정보, 처리할 대상) 을 관리

  • 따라서 애플리케이션 컨텍스트가 초기화되면 RequestMappingHandlerMapping에 접근하여 저장된 매핑 정보와 핸들러 메소드 목록을 확인할 수 있다




7. 인증 체크 인터셉터 구현

7-1. 로직 구현

@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?redirect:" + requestURI);
        return false;
    }
    return true;
}

7-2. 등록

@Override
public void addInterceptors(InterceptorRegistry registry) {

    ...

    registry.addInterceptor(new LoginCheckInterceptor())
            .order(2)
            .addPathPatterns("/**")
            .excludePathPatterns("/", "members/add", "/login", "/logout", "/css/**", "/*ico", "/error");
}
  • 필터처럼 새로 등록하는 것이 아닌 오버라이딩한 하나의 메서드 안에서 여러 개를 등록한다

  • 필터는 등록할 때 적용할 URL만 작성할 수 있어서 whitelist를 따로 만들었지만 인터셉터는 등록하면서 제외할 URL을 지정할 수 있다




8. ArgumentResolver

8-1. ArgumentResolver를 이용한 로그인

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

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

    // 세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}
  • 세션에서 멤버를 찾아 파라미터에 넣어주는 과정을 어노테이션 하나로 편리하게 할 수 있다

  • ArgumentResolver 를 활용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다


8-2. 어노테이션 생성

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login { }
  • @Target(ElementType.PARAMETER) : 파라미터에만 사용

  • @Retention(RetentionPolicy.RUNTIME) : 리플렉션 등을 활용할 수 있도록 런타임까지 어노테이션 정보가 남아있음


8-3. ArgumentResolver 구현

public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {

        log.info("supportsParameter 실행");

        // parameter에 @Login이 있는지
        boolean hasParameterAnnotation = parameter.hasParameterAnnotation(Login.class);
        // parameter에 Member 클래스가 있는지
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

        // 위의 조건 두 개를 만족해야 실행
        return hasMemberType && hasParameterAnnotation;
    }

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

        // 세션이 null이면 HomeController @Login Member member 부분에 null을 넣는다
        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}
  • ArgumentResolver를 구현하지 않으면 @Login이 무엇인지 모르기 때문에 @ModelAttribute처럼 동작한다

  • @Login Member 형태를 만족하면 ( @Login 어노테이션이 있으면서 Member 타입이면) 위의 ArgumentResolver가 사용되도록 구현한 것

  • supportsParameter(MethodParameter parameter) : 파라미터 정보가 넘어옴

    • parameter.hasParameterAnnotation(Login.class); : 파라미터에 @Login 어노테이션이 있는지 확인

    • Member.class.isAssignableFrom(parameter.getParameterType()); : Member 클래스인지 확인

  • resolveArgument()

    • 컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성

    • 세션에 있는 로그인 회원 정보인 member 객체를 찾아서 반환하는 로직을 구현

    • 이후 스프링MVC는 컨트롤러의 메서드를 호출하면서 여기에서 반환된 member 객체를 파라미터에 전달


8-4. ArgumentResolver 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }
}
profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글