킴의 레포지토리
어디서 JWT를 체크해야할까? Filter와 Interceptor의 장단점 분석 본문
이전 프로젝트에서 JWT로 사용자 인증 및 인가 로직을 구현할때 인터셉터를 활용했다. 이 경험을 바탕으로, 필터와 인터셉터의 사용 목적과 차이점을 다시 한번 고민해보기로 했다. 우선, 필터와 인터셉터가 왜 사용되는지 살펴본다. 이어서, 서블릿 컨텍스트와 어플리케이션 컨텍스트이 차이점 그로 인한 필터와 인터셉터의 주요 차이점에 대해 알아본다. JWT 토큰 검증 로직을 인터셉터에 구현한 이유를 분석하고, 이 로직을 필터에 적용할 경우의 방법과 장점을 분석해본다.
1. 필터와 인터셉터는 공통 관심사를 분리하기 위해 사용된다.
공통관심사는 비지니스 로직으로부터 분리되어야한다. 인증 및 인가, 로깅, 인코딩 등 어플리케이션의 다양한 로직에서 걸쳐 공통으로 적용되는 로직을 공통 관심사라고 한다. 공통 관심사를 위해 모든 컨트롤러 메서드에서 관련 코드를 작성하게 되면, 중복 코드가 증가하여 공통 관심사의 수정이 어렵고, 비지니스 로직과 공통 관심사를 구분하는 것이 어려워진다. 공통 관심사를 비지니스 로직으로부터 분리하여 모듈화함으로써, 이 문제를 해결할 수 있다. 이렇게 함으로써 클래스가 단일 책임 원칙(Single Responsiblity Principle)를 준수하며 공통 관심사에 변경이 필요할 경우 한 클래스만 수정하면 된다.
상황에 따라 공통 관심사를 분리하기 위해 필터나 인터셉터 중 하나를 사용할 수 있다. 특정 URL을 지정하지 않는 한 모든 요청은 필터와 인터셉터를 거치게 되지만, 필터와 인터셉터는 요청이 전달되는 순서와 접근할 수 있는 정보에서 차이가 난다.
2. 필터와 인터셉터의 차이
2-1. ServletContext와 ApplicationContext의 이해
필터와 인터셉터는 서로 다른 컨텍스트에서 작동한다. 필터는 서블릿 컨테이너(예: 톰캣)에 의해 관리되는 반면, 인터셉터는 스프링 컨테이너에 의해 관리된다. 이 차이는 접근할 수 있는 정보의 범위에 영향을 미친다. 서블릿 컨테이너는 스프링 컨테이너의 정보 즉, Root ApplicationContext와 Web ApplicationContext 그리고 이들 컨텍스트에 의해 관리되는 빈들에 접근할 수 없다.
한편, DispatcherServlet은 독특한 위치를 차지한다. 이 서블릿은 스프링 빈으로서 스프링 컨테이너 내에서 관리되면서 동시에 서블릿 컨텍스트에 속한다. DispatcherServlet을 통해 서블릿 컨테이너와 스프링 컨테이너 사이의 연결이 이루어진다.
다음은 서블릿 컨테이너와 스프링 컨테이너가 생성되고 초기화되는 과정이다.
- 웹 어플리케이션이 실행되면서 서블릿 컨테이너(예: Tomcat)가 활성화된다.
- 서블릿 컨테이너는 어플리케이션에 선언된 어노테이션이나 web.xml을 통해 Filter, Listener등을 찾고 초기화한다.
- Root ApplicationContext 생성: ContextLoaderListener의 contextInitialized() 메소드가 실행되어 Root ApplicationContext를 생성한다. Root ApplicationContext는 services, repositories와 같은 어플리케이션 전체에 걸쳐 공유되어야 하는 빈들을 생성하고 관리한다.
- DispatcherServlet 생성: 서블릿 컨테이너가 DispatcherServlet을 생성한다.
- Web AplicationContext 생성: DispatcherServlet이 초기화되면서 initWebApplicationContext() 메소드를 실행해 자체적인 Web ApplicationContext를 생성한다. 이때 Web ApplicationContext는 Root ApplicationContext를 상속하며, 주로 웹 관련 설정(컨트롤러, 뷰 리졸버) 빈을 생성하고 관리한다.
2-2. 요청 처리 순서
요청은 가장 먼저 필터에 도착한다. 정적 리소스 요청이나 Web Socket요청과 같이 Spring MVC와 무관한 요청들은 서블릿 컨테이너(혹은 그에 포함된 웹 서버)에서 처리되어 응답이 반환된다. 동적 컨텐츠에 대한 요청은 필터를 통과한 후 DispatcherServlet에 도착한 후, 컨트롤러가 실행되기 전에 인터셉터에 의해 처리된다. 컨트롤러 실행이 완료되면, 요청은 인터셉터를 거쳐 DispatcherServlet을 빠져나가고, 마지막으로 다시 필터를 거치게 된다. 이 과정을 자세히 살펴보자.
모든 요청은 서블릿 컨테이너에 등록된 필터를 먼저 거친다. 이때, 등록된 필터들은 필터체인을 통해 순차적으로 실행되고, 모든 필터의 실행이 완료되면 DispatcherServlet의 service()가 호출되어 요청과 응답이 전달된다.
다음은 FilterChain과 Filter의 로직을 간략히 표현한 SimpleFilterChain과 SampleFilter이다. SimpleFilterChain은 서블릿 컨테이너에 등록된 필터들에 대한 정보를 배열(filters)로 관리한다. 요청이 doFilter()에 도착하면 SimpleFilterChain은 pos를 이용해 다음으로 실행할 필터정보를 가져온다. pos는 등록된 여러개의 필터 중 다음으로 실행할 필터의 인덱스를 나타내며 filters에서 정보를 가져오고 난 후 pos를 1 증가시켜 다음 필터를 가리키도록 한다. 한 필터의 실행이 끝나면, 해당 필터는 매개변수로 받았던 FilterChain에 대한 참조를 이용해 doFilter()를 호출함으로써 다음 필터의 실행을 유도한다. 모든 필터의 실행이 끝나면, 마지막으로 등록된 서블릿(DispatcherServlet)의 service()가 호출되어 요청과 응답이 처리된다.
다음은 DispatcherServlet의 동작 과정을 간략히 표현한 코드이다. DispatcherServlet은 service() 내부에서 doDispatch()를 호출한다. doDispatch()에서는 먼저 요청에 요청에 매핑된 핸들러를 찾고, 해당 핸들러를 호출하기 전에 등록된 모든 인터셉터들의 preHandle()을 호출한다. 만약 어떤 인터셉터라도 false를 반환한다면, 그 시점에서 처리가 중단한다. 모든 인터셉터가 true를 반환하여 요청 처리를 계속하낟면, 실제 핸들러가 호출되어 컨트롤러로 요청이 전달됩니다. 컨트롤러에서의 처리가 완료된 후에는 해당 핸들러에 등록된 인터셉터들의 postHandle()을 호출한다. 모든 인터셉터의 postHandle() 실행이 완료된 후, 컨트롤러 반환값이 뷰 이름인 경우, 뷰를 렌더링한다. 위의 과정들 중 예외 발생 여부와 관계없이 최종적으로 finally 블록에서 처리되며, preHandle()에서 처리가 중단되었거나, 예외가 발생하였거나, 뷰 렌더가 완료된 후 인터셉터의 afterCompletion()이 호출된다.
필터를 모든 요청이 서블릿 컨테이너에 도달하는 가장 첫 번째 지점에서 실행된다. 조건에 부합하지 않는 요청의 경우, 필터를 통과하지 못하고 서블릿 컨테이너에 의해 즉시 반환된다. 이 과정을 통해 서버 리소스를 효율적으로 사용할 수 있다. 반면, 인터셉터는 모든 필터를 통과한 요청에 대해서만 작동하고, Spring MVC와 무관한 요청, 예를 들어 정적 리소스 요청이나 WebSocket 요청 등에는 인터셉터에 의한 공통 관심사가 적용되지 않는다.
2-3. 접근 가능한 정보의 차이
필터는 요청과 응답, 필터 체인을 매개변수로 받는다. 필터는 서블릿 컨테이너에 의해 관리되기 때문에 서블릿 컨텍스트 외에 다른 정보를 알기 어렵다. 예를 들어, Spring 컨테이너에 등록된 빈을 사용하고 싶어도 컨텍스트가 다르기 때문에 직접적이 사용이 어렵다. 그러나 이는 완전히 불가능한 것은 아니며, 아래에서 필터가 ApplicationContext에 접근하는 방법을 설명한다. 필터에서 던져진 예외는 서블릿 컨테이너의 기본 예외 처리 매커니즘에 의해 처리된다.
반면에 인터셉터는 요청과 응답 외에도 매핑된 핸들러 정보, ModelAndView, 그리고 발생한 예외에 대한 정보도 전달받는다. 또한, 스프링 컨테이너에 의해 관리되기 때문에 필요한 빈들을 주입(DI)받아 수 있다. 위 SimpleDispatcherServlet에서 볼 수 있듯, 인터셉터에 의해서 발생한 예외들은 @ExceptionHandler등 Spring에 의해 처리 된다.
2-4. 필터와 인터셉터의 사용 시나리오
필터와 인터셉터는 모두 공통 관심사를 모듈화하는데 사용될 수 있으며, 둘은 컨텍스트와 실행 순서에 있어서 차이가 있다. 필터는 서블릿 컨테이너에 의해 관리되며, 모든 요청에 대해 가장 먼저 실행되기 때문에 1) 조건에 맞지 않는 요청을 빠르게 반환하거나 2) **범용적인 공통 관심사(인코딩)**를 처리하는데 적합하다. 반면에, 인터셉터는 1) 핸들러 정보가 필요한 상황 2) 뷰의 렌더링 전에 Model의 데이터를 조작해야하는 상황 3) 스프링 빈을 주입받고 사용해야 하는 상황 4) 인터셉터에서 발생한 예외가 스프링에 의한 예외 처리가 필요한 상황등에서 사용할 수 있다.
3. Jwt 토큰 검증 로직: 필터와 인터셉터의 적용
3-1.Jwt 토큰을 Interceptor에서 검증했던 이유
프로젝트 요구사항은 다음과 같다. 프로젝트에는 인증이 필요한 회원 전용 API와 모든 사용자가 이용할 수 있는 API가 있다. 사용자의 역할(Role)이 USER인지 SELLER인지에 따라서 접근 할 수 있는 기능이 달라진다. REST API의 stateless 특성을 유지하기 위해, Jwt 토큰을 이용하여 인증 및 인가를 처리한다.
아래는 사용자 관련 API를 담당하는 UserController이다. 회원 전용 API에는 @Auth라는 커스텀 어노테이션이 붙어있으며, 이 어노테이션의 속성을 통해 해당 API에 필요한 권한(role)을 지정하였다. 예를 들어, 특정 회원의 정보를 조회하는 retrieveUserAccount() 에는 @Auth가 적용되어 있다. 반면에, 로그인 요청을 처리하는 retrieveUserLogin()은 인증하지 않은 사용자가 요청할 수 있으므로 @Auth가 적용되지 않았다.
Jwt 토큰의 검증은 인터셉터에서 처리된다. 아래는 핸들러 호출 전에 인터셉터의 preHandle()에서 Jwt 토큰을 검증하는 로직이다. @Auth 어노테이션이 적용된 핸들러의 경우, 토큰에 담긴 role을 확인하고, path variable이 있는 경우 path bariable과 토큰 정보가 일치하는지 확인한다. 이때, 스프링 컨테이너에 등록된 JwtService 빈을 주입받아 사용한다.
Jwt 토큰을 필터가 아닌 인터셉터에서 검증하기로 결정한 이유는 다음과 같다.
- 스프링 빈 주입: JwtService를 주입받아 사용할 수 있어야 한다. 필터는 스프링 컨테이너에 의해 관리되지 않기 때문에 스프링 빈을 주입받을 수 없다.
- 어노테이션으로 API 구분: @Auth 어노테이션을 통해 컨트롤러에서 회원 전용 API를 명확하게 구분할 수 있다. 필터나 인터셉터에서 URL로 구분하는 것은 프로그램이 커질수록 URL이 많아질수록 URL을 관리하는게 어려워지고, 특정 API가 회원 전용 API인지 확인하려면 컨트롤러에서 필터나 인터셉터를 따로 확인해야해 번거롭다고 생각했다.
- 핸들러 정보 접근: 핸들러에 적용된 어노테이션 정보를 읽을 수 있어야 한다.
- 스프링 예외 처리: 인증 실패시 예외를 던져 스프링의 글로벌 예외 처리기에 의해 처리될 수 있어야한다. 인증이 실패한 경우에도 다른 예외와 같이 응답에 오류 코드와 오류 메시지를 담아야 한다.
3-3 Filter를 활용한 JWT 토큰 검증
필터를 사용해 인증 및 인가를 처리를 한다면 허가되지 않은 사용자의 요청을 신속히 거부하여 서버 자원을 보다 효율적으로 사용할 수 있는 방법이다. 또한, 특정 서블릿에 한정되지 않고, 어플리케이션 전반에 걸쳐 범용적으로 적용될 수 있는 장점이 있다. 이는 정적 리소스 요청이나 DispatcherServlet을 거치지 않는 다른 요청들에도 인증 및 인가 로직을 일관되게 적용할 수 있음을 의미한다.
DelegatingFilterProxy를 사용하면 스프링 컨테이너에서 관리되는 빈을 필터로 사용할 수 있다 . DelegatingFilterProxy는 서블릿 필터로서 서블릿 컨테이너에 의해 관리되며, 스프링 빈으로 등록된 필터로 요청을 위임한다. 예를 들어, JwtTokenFilter을 스프링 빈으로 등록하고, DelegatingFilterProxy의 대상으로 지정한 후 DelegatingFilterProxy를 서블릿 컨테이너에 등록하면, JwtTokenFilter는 스프링 빈으로서 필요한 JwtService를 주입(DI)받을 수 있고, 서블릿 컨테이너에 직접 필터로 등록되지 않아도 필터 체인의 일부로 실행될 수 있다.
다만 이 방법은 핸들러 정보에 접근하기 어렵고, 스프링 예외 처리 매커니즘이 적용되지 않는다는 단점이 있다. 따라서, 컨트롤러 메서드에 어노테이션을 사용하는 대신 필터에 적용할 url을 명시적으로 지정해야한다. 또한, 인증과정에서 예외가 발생했을때는 필터 내에서 직접 응답에 오류 코드와 오류 메시지를 포함시켜 응답해야 한다.
DelegatingFilterProxy는 Spring Security에서도 중요한 역할을 한다. Spring Seucirty는 보안 관련 처리를 위한 여러 필터를 제공하며, 이 필터들을 관리하기 위해 내부적으로 FilterChainProxy를 사용한다. DelegatingFilterProxy는 이 FilterChainProxy에 요청을 위임하여, Spring Security의 필터 체인이 서블릿 필터 체인에 통합될 수 있도록 한다.
정리
- 필터는 서블릿 컨테이너에 의해 관리되고, 인터셉터는 스프링 컨테이너에 의해 관리되는 빈이다. 스프링 컨테이너는 Root ApplicationContext와 Web ApplicationContext로 이루어진다. Root ApplicationContext는 서블릿 컨테이너가 초기화될때 ContextLoaderListener를 통해 생성되고, Web ApplicationContext는 DispatcherServlet 초기화 과정에서 생성된다. Root ApplicationContext는 어플리케이션 전반에 공유되어야하는 service, repository와 같은 빈들을 관리하고, Web ApplicationContext는 Root ApplicationContext를 상속받으면서 웹과 관련된 controller, handler mapping, interceptor, exception resolver등을 관리한다.
- 필터는 요청이 서블릿 컨테이너에 도착하고 가장 처음으로 실행된다. 필터 체인을 통과한 요청은 DispatcherServlet에 도착한 요청은 handler 호출 전, 후, 뷰가 렌더되고 난 후 최종적으로 실행된다.
- 필터는 스프링 컨테이너의 빈들에 접근하기 어렵고, DI를 받을 수 없다. 인터셉터는 스프링 빈으로서 DI를 받을 수 있고, DispatcherServlet으로부터 매핑된 핸들러 정보등을 전달받으며, 스프링 예외처리기에 의해 예외가 처리된다.
- Jwt 토큰을 검증하기위해 Filter와 Interceptor 모두 사용할 수 있으며 각각의 장단점이 있다. Filter를 통해 처리하기 위해서는 Spring Bean을 주입받기 위해 필터를 빈으로 등록하고 DelegatingFilterProxy를 사용해 요청을 위임받아 필터의 일부로 동작할 수 있게 한다.
'study > spring' 카테고리의 다른 글
스프링 부트의 WAS 설정 알아보기 (1) | 2024.07.08 |
---|---|
인증(1) - 스프링 시큐리티의 OAuth2.0 로그인 과정과 네이버, 카카오 로그인 구현 (0) | 2024.05.14 |