필터는 Servlet이 제공하는 기능이고, 인터셉터는 Spring이 제공하는 기능이다.
요구사항 : 로그인한 사용자만 상품 관리 페이지로 접속할 수 있어야한다. 단순히 상품 관리 컨트롤러에서 로그인 여부를 체크하는 로직을 추가하면 되겠지만 등록/수정/삭제/조회 등 모든 컨트롤러에 추가하는 것은 매우 비효율적이다.
또한 향후에 로직이 변경될 때마다 모든 로직을 다시 수정해야한다.
이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사(Cross-Cutting Concern)라고 한다.
공통 관심사는 스프링의 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 Servlet의 필터나 Spring의 인터셉터를 사용하는 것이 좋다. 웹과 관련된 공통 관심사를 처리하기 위해서는 HTTP Header, URL 등과 같은 정보들이 필요한데,
Servlet 필터나 Spring 인터셉터는 HttpServletRequest를 제공하기 때문에 편리하게 처리할 수 있다.
Servlet의 Filter
필터의 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
필터를 적용하면 필터가 호출된 다음에 서블릿이 호출된다. 필터는 특정 URL 패턴에 적용할 수 있다.(모든 요청에도 적용 가능)
필터 제한
로그인 사용자 : HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
비로그인 사용자 : HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단해 서블릿 호출X)
필터에서 적절하지 않은 요청이라고 판단하면 서블릿을 호출하지 않을 수 있기 때문에 로그인 여부를 체크하기에 좋다.
필터 체인
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
필터는 체인으로 구성되는데, 중간에 필터를 자유롭게 여러개 추가할 수 있다.
필터 interface
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() {}
}
필터 interface를 구현하고 등록하면 Servlet Container가 필터를 싱글톤 객체로 생성하고 관리한다.
- init() : 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출
- doFilter() : 요청이 올 때 마다 호출, 필터의 로직을 구현하는 메서드
- destroy() : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출
간단하게 필터를 사용해보자. 모든 요청을 로그로 남기는 필터를 구현하자.
javax.servlet의 Filter 인터페이스를 구현했다.
doFilter()에 로직이 들어가는데 파라미터로 넘어오는 ServletRequest와 ServletResponse는 HttpServletRequest, HttpServletResponse의 부모이다. 따라서 별 기능이 없기 때문에 위와 같이 HttpServletRequest로 Down 캐스팅하여 사용한다. chain.doFiler()는 다음 필터가 있으면 필터를 호출하고 마지막 필터라면 서블릿을 호출한다. 이 로직을 호출하지 않으면 다음단계로 진행되지 않음.
단순히 요청URI와 UUID를 log로 남겼다.
서블릿 컨테이너에 등록하여야 최종적으로 필터가 적용되고 사용할 수 있다.
Config 파일을 만들고 그 안에 스프링 빈으로 등록했다. FilterRegistrationBean을 생성하고 setFilter를 통해 위에서 만든 필터를 적용시키고 setOrder를 통해 순서를 첫번째로 지정했다. addUrlPatterns를 통해 어느 URL에 적용될지를 지정해줄 수 있는데 현재는 "/*"로 모든 URL 에 적용시켰다.
@ServletComponentScan, @WebFilter(filterName = "필터이름", urlPatterns = "/*")로 필터 등록이 가능하지만 필터 순서 조절이 안됨. 스프링 부트를 사용한다면 FilterRegistrationBean을 사용하자.
서버를 구동하면 서블릿이 생성되면서 필터가 초기화된다. 이제 홈화면에 접속해보자.
필터가 적용되어 doFilter 메서드가 호출된 것을 확인할 수 있다.
HTTP 요청시 같은 요청의 로그에 모두 같은 식별자를 자동으로 남기는 방법은 logback mdc를 찾아보면 된다.
필터가 어떤 식으로 동작하는지를 알아봤으니 이제 로그인된 사용자만 접속할 수 있게 인증 처리를 필터로 처리해보자.
filter를 모든 URL 경로에 적용시키고 인증 체크를 하지 않을 경로의 배열인 whiteList를 만들어두고 메서드를 만들어
요청 url이 whiteList에 포함되어 있는지 여부를 체크하고 인증을 진행하는 로직으로 동작한다.
미인증 사용자일 경우에는 로그인 화면으로 redirect 시켜주는데 이 때 로그인 후에 접속하려했던 화면으로 보내주기 위해
쿼리스트링으로 redirect되기 전의 url을 남겨주었다. 그 후에 return;을 통해 더 이상 서블릿, 컨트롤로를 호출하지 않고 종료한다.
(로그인 안한 상태에서 상품관리 접속 -> 로그인 화면 -> 로그인 -> 홈화면 -> 다시 상품관리 접속)이 아니라
(로그인 안한 상태에서 상품관리 접속 -> 로그인 화면 -> 로그인 -> 상품관리 접속)을 하기 위한 것이다.
PatternMatchUtils는 Spring에서 제공하는 것으로 단순히 패턴이 매치되는지를 체크해주는 simpleMatch메서드를 가지고 있다.
이제 위에서 쿼리스트링으로 넘긴 redirect 이전 URL로 클라이언트를 다시 보내는 로직을 처리해보자.
@RequestParam으로 쿼리 파라미터를 받고 리다이렉트에 붙혀주어 구현하였다.
defaultValue가 "/"이기 때문에 없다면 "localhost:8080/"으로 redirect되고,
파라미터가 넘어왔다면 "localhost:8080/redirectURL 값"으로 redirect될 것이다.
스프링 인터셉터
스프링 인터셉터도 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다.
스프링 인터셉터는 스프링 MVC가 제공하는 기술로 적용되는 순서와 범위 그리고 사용방법이 서블릿 필터와는 다르다.
스프링 인터셉터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
- 스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다.
- 스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 때문에 디스패처 서블릿 이후에 호출한다.
- 스프링 인터셉터도 URL 패턴을 적용할 수 있는데, 이는 서블릿 URL 패턴과 다르고 매우 정밀하게 설정 가능
스프링 인터셉터 제한
로그인 사용자 : HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
비로그인 사용자 : HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(컨트롤러 호출 X)
- 적절하지 않은 요청이라고 판단하면 컨트롤러를 호출하지 않고 끝을 낼 수 있다.
스프링 인터셉터 체인
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
- 서블릿 필터와 마찬가지로 체인으로 구성되기 때문에 중간에 인터셉터를 여러개 추가할 수 있다.
스프링 인터셉터는 서블릿 필터보다 편리하고 더 정교하고 다양한 기능을 지원한다.
스프링 인터셉터 interface
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 {}
}
서블릿 필터는 doFilter()하나만 제공하지만, 인터셉터는 preHandle(컨트롤러 호출 전), postHandle(컨트롤러 호출 후),
afterCompletion(요청 완료 이후)와 같이 단계적으로 세분화되어 있다.
또한 어떤 컨트롤러가 호출되는지를 hanlder를 통해 호출 정보를 받을 수 있고, 어떤 ModelAndView가 반환되는지 응답 정보도 받을 수 있다.
스프링 인터셉터 호출 흐름
정상 흐름
preHandle : 컨트롤러 호출 전에 호출(핸들러 어댑터 호출 전에)
- preHandle의 응답값이 true이면 다음으로 진행하고, false면 더 진행하지 않는다.
- false라면 인터셉터는 물론 핸들러 어댑터도 호출되지 않음
postHandle : 컨트롤러 호출 후에 호출된다.(핸들러 어댑터 호출 후에)
afterCompletion : 뷰가 렌더링 된 후에 호출
예외 발생
컨트롤러에서 예외가 발생하면 postHandle은 호출되지 않는다.
afterCompletion은 항상 호출됨(이 경우 예외를 파라미터로 받아 예외를 출력할 수 있음)
먼저 동작과정을 살펴보기 위해 필터를 사용했던 것처럼 요청이 들어오면 로그를 남기는 인터셉터를 만들어보자.
먼저 스프링이 제공하는 HandlerInterceptor를 구현하는 인터셉터 클래스를 만든다.
==> preHandle, postHandle, afterCompletion을 Override한다.
필터는 ServletRequest와 ServletResponse를 받기 때문에 HttpServletRequest, HttpServletResponse로 다운캐스팅해야 했지만 인터셉터는 그렇지 않다.
UUID를 넘기기위해서 request에 담아주고 getAttribute하여 사용하는 식으로 구현하려한다.(LOG_ID는 상수)
스프링을 사용하면 일반적으로 @Controller , @RequestMapping을 활용한 핸들러 매핑을 사용하는데 이 경우 핸들러 정보로 HandlerMethod가 넘어온다.
정적 리소스가 호출 되는 경우 ResourceHttpRequestHandler 가 핸들러 정보로 넘어오기 때문에 타입에 따라서 처리가 필요하다.
조건문을 통해 파라미터로 넘어온 handler가 HandlerMethod의 인스턴스라면 캐스팅해준다.
저 핸들러에 호출할 컨트롤러에 대한 모든 정보가 포함되어 있다.
마지막으로 true를 반환해주어야 다음 로직(다음 인터셉터, 컨트롤러)이 실행된다.
다음으로 postHandle메서드는 modelAndView를 받기 때문에 로그로 modelAndView를 남겨주었다.
afterCompletion은 요청URI, request에 담긴 UUID, handler를 로그로 남기고 만약 에러가 있다면 로그로 남겨주었다.
인터셉터 또한 Config 파일에 등록시켜주어야하는데 필터와는 조금 방식이 다르다.
먼저 Config클래스가 WebMvcConfigurer를 구현하도록 하고 addInterceptors메서드를 Override해준다.
위와같이 addInterceptor를 통해 만든 인터셉터 클래스를 추가해주는데
chain형식으로 order(순서), addPathPatterns(적용할 경로), excludePathPatterns(적용하지 않을 경로)를 적어준다.
PathPattern
스프링이 제공하는 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이라는 변수로 캡처
/pages/t?st.html — matches /pages/test.html, /pages/tXst.html but not /pages/ toast.html
/resources/*.png — matches all .png files in the resources directory /resources/** — matches all files underneath the /resources/ path, including / resources/image.png and /resources/css/spring.css
/resources/{*path} — matches all files underneath the /resources/ path and captures their relative path in a variable named "path"; /resources/image.png will match with "path" → "/image.png", and /resources/css/spring.css will match with "path" → "/css/spring.css" /resources/{filename:\\w+}.dat will match /resources/spring.dat and assign the value "spring" to the filename variable
서버를 실행시키고 홈화면에 접속해보자. 현재 위에서 만든 필터도 적용되어 있기 때문에 필터가 먼저실행되고 인터셉터가 실행될 것이라 예상된다.
로그필터 -> 인증 필터 -> 로그 인터셉터 순으로 동작한 것을 확인할 수 있다. 또한 Controller에 대한 정보와 ModelAndView에 대한 정보도 인터셉터를 통해 잘 찍힌 것을 확인할 수 있다.
이번에는 사용자 인증 체크를 하는 인터셉터를 만들어보자.
스프링 제공하는 HandlerInterceptor는 interface이긴 하지만 메서드가 default(자바 8에 추가된 기능)로 기본 구현이 되어 있기 때문에 원하는 메서드만 Override하면 된다.인증 체크를 위해서는 preHandle 메서드만 있으면 되기 때문에 preHandle만 Override하였다.
인증 로직은 필터와 동일하다 .미인증 사용자라면 쿼리스트링으로 requestURI를 붙혀주고 로그인 화면으로 리다이렉트해준다. 그리고 false를 return해 더이상 진행되지 않도록 한다.
이 때 적용될 곳과 적용되지 않을 곳은 인터셉터를 등록하면서 설정하기 때문에 코드가 굉장히 간결해졌다.
서버를 실행시켜 원하는 대로 동작하는지 확인해보자.
미인증 사용자 접근
먼저 로그인하지 않은 상태로 /items로 접속하였다. 미인증 사용자의 요청이라 판단되어 더 이상 진행되지 않고 /login으로 리다이렉트해주는 것을 확인할 수 있다.
인증 사용자 접근
로그인한 상태로 /items로 접속하였다. 인증 로직을 통과하고 true가 return되어 컨트롤러와 ModelAndView가 잘 호출된 것을 확인할 수 있다. 또한 로그 인터셉터를 보면 model에 담긴 객체까지 확인할 수 있다.
스프링 인터셉터가 개발자 입장에서 훨씬 편리하고 세밀하게 사용할 수 있다.
특별한 문제가 없다면 인터셉터를 쓰자!
ArgumentResolver 활용
참고글 :
2021.05.11 - [Spring/Spring MVC] - HTTP Message Converter
ArgumentResolver를 활용하여 홈화면에서의 로그인한 사용자와 비로그인 사용자를 다른뷰로 가게하던 로직을 변경해보자. (내부 로직은 동일)
커스텀 애노테이션을 만들고 ArgumentResolver를 활용하자.
간단히 이야기하자면 @Target, @Retention은 메타 애노테이션이라고 하는데 "애노테이션을 위한 애노테이션"이다.
@Target : Java compiler 가 annotation 이 어디에 적용될지 결정
- 현재는 파라미터에 적용된다는 의미
ElementType.PACKAGE : 패키지 선언
ElementType.TYPE : 타입 선언
ElementType.ANNOTATION_TYPE : 어노테이션 타입 선언
ElementType.CONSTRUCTOR : 생성자 선언
ElementType.FIELD : 멤버 변수 선언
ElementType.LOCAL_VARIABLE : 지역 변수 선언
ElementType.METHOD : 메서드 선언
ElementType.PARAMETER : 전달인자 선언
ElementType.TYPE_PARAMETER : 전달인자 타입 선언
ElementType.TYPE_USE : 타입 선언
@Retention : 애노테이션이 적용되고 유지되는 범위(Scope)
RetentionPolicy.RUNTIME 은 컴파일 전까지만 유효. 즉, 컴파일 이후에는 사라지게 됩니다.
RetentionPolicy.CLASS 은 컴파일러가 클래스를 참조할 때가지 유효.
RetentionPolicy.SOURCE 은 컴파일 이후에도 JVM 에 의해서 계속 참조가 가능. 주로 리플렉션이나 로깅에 많이 사용함.
다음으로는 ArgumentResolver클래스를 만들어보자.(HandlerMethodArgumentResolver를 구현해야함)
먼저 supportsParameter가 호출되는데 @Login 애노테이션이 붙었는지, @Login애노테이션이 붙은 파라미터가 원하는 타입(여기서는 Member)인지를 검사하고 둘다 true라면 아래의 resolveArgument가 호출된다.
resolveArgument는 단순히 로그인한 사용자라면 세션에서 조회한 Member 객체를 반환하여 컨트롤러의 파라미터에 담고 그렇지 않다면 null을 담아준다.
사실상 컨트롤러에서 검증했던 과정을 ArgumentResolver를 사용하여 처리하고 있는 것이라고 할 수 있다.
이제 만들어진 ArgumentResovler를 Config 클래스를 통해 등록시키자.
WebMvcConfigurer 인터페이스의 default 메서드인 addArgumentResolvers를 Override하여 위에서 만든 ArgumentResolver를 추가해준다.
원래대로 로그인을 한 상태로 홈화면에 접속했을 때와 로그인을 하지 않은 상태로 홈화면에 접속했을 때가 다른 것을 확인할 수 있다.
@SessionAttribute를 사용했을 때보다 컨트롤러의 코드가 훨씬 간결해졌다. 또한 다른 컨트롤러에서도 재사용이 가능하므로 굉장히 좋은 방법이다!!!!!!
'Spring > Spring MVC' 카테고리의 다른 글
API 예외 처리 (0) | 2021.07.28 |
---|---|
예외 처리, 오류 페이지 (0) | 2021.07.24 |
쿠키, 세션 (0) | 2021.07.17 |
Bean Validation (0) | 2021.07.14 |
Validation (0) | 2021.07.07 |