개발 배경
Spring 을 사용해 Restful api 서버를 개발하다 보면 Interceptor 를 사용할 때가 있다. Interceptor 를 구현하기 위해 보통 HandlerInterceptor 의 구현체를 구현하게 되는데, 여기엔 두 가지 단점이 있다.
1. CORS 에서 사용되는 Preflight 요청도 Interceptor 를 통과하게 된다. 그러므로, Preflight 요청은 전부 허용하도록 인터셉터 내부에 코드를 넣어야 한다.
2. HandlerInterceptor 는 URI 를 기반으로 등록해야 한다. 하지만, Restful api 에서는 URI 와 HTTP Method 를 통해 요청을 구분한다.
ex) GET /memos 와 POST /memos 는 다른 요청이다.
그러므로 POST /memos 에 대해서만 Interceptor 를 등록하고 싶다면 우선 /memos 에 Interceptor 를 등록한 후에 Interceptor 내부에서 HTTP Method 가 POST 일때만 로직이 동작하도록 구현해야 한다.
기존 Interceptor 의 예시 코드를 보자. POST /memos 에 대해서만 인증/인가 로직을 적용한다고 가정한다.
1. AuthInterceptor 생성
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// Preflight 요청이라면 pass
if(CorsUtils.isPreFlightRequest(request)){
return true;
}
// GET /memos 라면 pass
if (request.getMethod().equals("GET") && request.getRequestURI().equals("/memos")) {
return true;
}
// 인증/인가 로직
return true;
}
}
2. WebMvcConfigurer 의 구현체를 통해 /memos URI 에 AuthInterceptor 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthInterceptor()).addPathPatterns("/memos");
}
}
필자가 이 방식에서 느낀 단점은 두 가지이다.
1. Restful api 에서 사용되는 모든 Interceptor 에서 Preflight 요청을 pass 하는 로직을 구현해야 한다.
2. Interceptor 에서 자신이 어떤 URI와 HTTP Method 에 대해 등록되었는지를 매우 깊게 알고 있어야 한다. Spring 에서는 HandlerInterceptor를 어떤 URI 에 대해 동작하게 할 지 설정하는 책임을 WebMvcConfigurer 의 구현체가 담당하도록 설계했다. 하지만 현재 구조는 이를 위반하고 있다.
이 단점을 해결하기 위해 Restful api 서버에서 Interceptor 를 더 편리하게 사용할 수 있는 기능을 제공하는 RestInterceptor 라이브러리를 개발하게 되었다.
RestInterceptor?
RestInterceptor 는 URI 뿐만 아니라 HTTP Method 도 함께 등록할 수 있다. 위의 AuthInterceptor 를 등록할 때 /memos URI 에 대해서만 등록하는 것이 아니라 GET /memos 로 등록할 수 있는 기능을 제공한다.
이를 구현하기 위해 URI 와 HTTP Method 들을 저장하는 RestfulPattern 클래스를 구현했다.
RestfulPattern
public class RestfulPattern {
private final UriTemplate path;
private final Set<HttpMethod> methods;
private RestfulPattern(final UriTemplate path, final Set<HttpMethod> methods) {
this.path = path;
this.methods = methods;
}
/**
* Create a new instance of {@link RestfulPattern} with the given path and HTTP method Collections.
*/
public static RestfulPattern of(final String path, final Collection<HttpMethod> methods) {
return new RestfulPattern(new UriTemplate(path), new HashSet<>(methods));
}
/**
* Create a new instance of {@link RestfulPattern} with the given path and HTTP method.
*/
public static RestfulPattern of(final String path, final HttpMethod method) {
return new RestfulPattern(new UriTemplate(path), Set.of(method));
}
public static RestfulPatternBuilder builder() {
return new RestfulPatternBuilder();
}
/**
* Compare the request URI and HTTP method.
* <p> If the request URI and HTTP method match, return true.
*/
public boolean matches(final HttpServletRequest request) {
return methods.contains(HttpMethod.valueOf(request.getMethod())) && path.matches(request.getRequestURI());
}
public String getPath() {
return path.toString();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof RestfulPattern that)) {
return false;
}
if (!methods.equals(that.methods)) {
return false;
}
return Objects.equals(path, that.path) && Objects.equals(methods, that.methods);
}
@Override
public int hashCode() {
int result = methods.hashCode();
result = 31 * result + (path != null ? path.hashCode() : 0);
return result;
}
@Override
public String toString() {
return "RestfulPattern{" +
"methods=" + methods +
", path=" + path.toString() +
'}';
}
/**
* Builder for {@link RestfulPattern}.
* <p> This class is used to create a new instance of {@link RestfulPattern}.
* <p> Can set HTTP methods easily.
*/
public static class RestfulPatternBuilder {
private final Set<HttpMethod> methods;
private UriTemplate path;
public RestfulPatternBuilder() {
this.path = new UriTemplate("/**");
this.methods = new HashSet<>();
}
public RestfulPatternBuilder path(String path) {
this.path = new UriTemplate(path);
return this;
}
public RestfulPatternBuilder get() {
this.methods.add(HttpMethod.GET);
return this;
}
public RestfulPatternBuilder post() {
this.methods.add(HttpMethod.POST);
return this;
}
public RestfulPatternBuilder put() {
this.methods.add(HttpMethod.PUT);
return this;
}
public RestfulPatternBuilder delete() {
this.methods.add(HttpMethod.DELETE);
return this;
}
public RestfulPatternBuilder patch() {
this.methods.add(HttpMethod.PATCH);
return this;
}
public RestfulPatternBuilder trace() {
this.methods.add(HttpMethod.TRACE);
return this;
}
public RestfulPatternBuilder options() {
this.methods.add(HttpMethod.OPTIONS);
return this;
}
public RestfulPatternBuilder head() {
this.methods.add(HttpMethod.HEAD);
return this;
}
public RestfulPatternBuilder all() {
return get().post().put().delete().patch().trace().options().head();
}
public RestfulPattern build() {
if (methods.isEmpty()) {
return new RestfulPattern(path, Set.of(HttpMethod.values()));
}
return new RestfulPattern(path, methods);
}
}
}
RestfulPattern 은 특정 URI 에 대해 여러 가지 HTTP Method 를 저장할 수 있다. RestInterceptor 는 RestfulPattern 들을 저장하고, HTTP Request 가 이 RestfulPattern 과 match 하면 로직을 수행한다.
Builder 패턴을 사용해 RestfulPattern 을 생성하는 로직을 쉽게 만들어 주었다.
다음으로, RestufulPattern 을 통해 같은 URI 에 대해 특정 HTTP Method 에서만 preHandle 이 수행되는 RestInterceptor 를 구현했다.
RestInterceptor
public abstract class RestInterceptor implements HandlerInterceptor {
protected List<RestfulPattern> restfulPatterns = List.of();
@Override
public final boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (isPreFlightRequest(request) || shouldSkip(request)) {
return true;
}
return doInternal(request, response, handler);
}
/**
* Check if the request is a pre-flight request.
*/
private boolean isPreFlightRequest(final HttpServletRequest request) {
return CorsUtils.isPreFlightRequest(request);
}
/**
* Check if the request should be skipped.
* <p> If request path is not matched with any of the restfulPatterns, it should be skipped.
*/
private boolean shouldSkip(final HttpServletRequest request) {
return this.restfulPatterns.stream()
.noneMatch(pattern -> pattern.matches(request));
}
/**
* Core logic of {@link #preHandle(HttpServletRequest, HttpServletResponse, Object)}
* <p> This method should be implemented by subclasses. Default implementation returns true.
*/
protected boolean doInternal(HttpServletRequest request, HttpServletResponse response, Object handler) {
return true;
}
}
Template Method 패턴을 사용해 preHandle()을 템플릿 메서드로 구현했다. preHandle() 은 Preflight 요청이나 RestfulPattern 에 존재하지 않는 요청을 skip 한다. RestInterceptor를 상속받는 Interceptor는 preHandle() 의 실질적인 구현을 doInternal() 로 하면 된다.
의문이 들 수 있는 부분이 있다.
기존 Interceptor 는 URI 기반으로 등록했으니 RestInterceptor 는 RestfulPattern 을 기반으로 외부에서 등록하면 되는 것 아닌가? 왜 RestInterceptor 에서 RestfulPattern 을 저장하고 skip 해야 하는가?
이 부분에 대해 고민이 많았다. Interceptor 가 RestfulPattern 을 저장하고 있다면 완전한 의존성 분리라고 할 수 없다는 생각이 들었다. 하지만, URI 를 기반으로 Interceptor 를 등록하는 기존의 Spring 코드를 조사하는 과정에서 Adaptor 나 상속을 통해 기능을 확장할 수 없다는 판단을 했다. Interceptor 를 등록하는 클래스는 다른 Spring 코드들과 강한 의존성을 가지고 있었기 때문에 Spring 의 코드를 변경하지 않는 이상 불가능했다.
처음에는 이 문제를 해결하기 위해 Spring 프로젝트에 이슈를 만들어 공유하기도 했지만, Spring 프로젝트 관리자는 Interceptor 를 이런 식으로 확장할 계획이 없다고 했다.
이슈 링크: https://github.com/spring-projects/spring-framework/issues/33461
그래서 어쩔 수 없이 Interceptor 내부에서 RestfulPattern 들을 가지고 있게 설계했다. 하지만, Interceptor 는 자신이 어떤 RestfulPattern 에 등록되어있는지 몰라야 한다는 원칙을 지키기 위해 RestfulInterceptor 를 등록하고 설정하는 클래스를 설계했다.
RestInterceptor 를 어떻게 등록해야 하는가?
기존 Interceptor 등록 방식은 다음과 같다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthInterceptor()).addPathPatterns("/memos");
}
}
최대한 기존 방식과 호환되게 구현했다.
AuthInterceptor 가 RestInterceptor 를 상속받는다고 가정하고
POST /memos 에 대해 AuthInterceptor 를 등록해보겠다.
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final RestTestInterceptor testInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
RestInterceptorRegistry restInterceptorRegistry = new RestInterceptorRegistry(registry);
restInterceptorRegistry.addInterceptor(testInterceptor)
.addRestfulPatterns(RestfulPattern.of("/memos", HttpMethod.POST));
restInterceptorRegistry.build();
}
}
기존에 사용하는 InterceptorRegistry 를 통해 RestInterceptorRegisty 인스턴스를 생성한다.
addInterceptor 를 통해 Interceptor 를 등록하고, addRestfulPatterns 를 통해 어떤 RestfulPattern 에 해당 Interceptor 가 동작하도록 할지 설정할 수 있다. order() 메서드를 통해 동작 순서도 지정할 수 있다.
RestInterceptorRegistry 는 InterceptorRegistry 와 RestInterceptor 사이를 연결하기 위한 Adaptor 기능을 수행한다.
public class RestInterceptorRegistry {
private final InterceptorRegistry registry;
private final List<RestInterceptorRegistration> registrations = new ArrayList<>();
public RestInterceptorRegistry(InterceptorRegistry registry) {
this.registry = registry;
}
/**
* Adds the provided {@link RestInterceptor}.
*
* @param restInterceptor the restInterceptor to add
* @return an {@link RestInterceptorRegistration} that allows you optionally configure the registered
* restInterceptor further for example adding RestfulPatterns it should apply to.
*/
public RestInterceptorRegistration addInterceptor(RestInterceptor restInterceptor) {
RestInterceptorRegistration registration = new RestInterceptorRegistration(restInterceptor);
registrations.add(registration);
return registration;
}
/**
* Reflects the registrations in the registry. This method should be called after all the registrations are done.
* <p>Will be deprecated in the future.
*/
public void build() {
this.registrations.forEach(registration -> {
RestInterceptor restInterceptor = registration.getRestInterceptor();
registry.addInterceptor(restInterceptor)
.addPathPatterns(restInterceptor.restfulPatterns.stream()
.map(RestfulPattern::getPath)
.collect(Collectors.toList()))
.order(registration.getOrder());
});
}
}
build() 메서드 호출 시 등록되어있는 모든 RestInterceptorRegistration 들을 InterceptorRegistry 로 매핑한다.
이 과정에서 RestInterceptor 에 등록되어있는 모든 RestfulPatterns 들의 Path 를 등록한다.
build() 메서드를 반드시 호출해야 등록되는 구조인데, build() 메서드를 반드시 호출해야 하는 것이 바람직한 방법은 아니라고 생각해 추후 제거할 계획 중에 있다.
의존성 추가
build.gradle 에 다음을 추가한다. 현재는 v0.1 만 개발되어있는 상태이다.
repositories {
...
maven { url "https://jitpack.io" }
}
dependencies {
...
implementation 'com.github.Dh3356:rest_interceptor:v{version}'
// example: implementation 'com.github.Dh3356:rest_interceptor:v0.1'
}
포스팅에서 코드를 전부 설명하지는 못했다. 만약 코드가 궁금하거나 Issue 를 만들고자 하면 아래 레포지토리 링크로 들어오면 된다.
https://github.com/Dh3356/rest_interceptor
'Spring Boot' 카테고리의 다른 글
RestInterceptor 개발기 - build() 메서드 제거와 v1.0 릴리즈 (0) | 2024.12.23 |
---|---|
RestInterceptor 개발기 - 패턴 매칭 이슈 (1) | 2024.12.02 |
Redis 캐싱만 적용하면 끝일까? - Resilience4j 를 통한 Circuit Breaker 적용 (7) | 2024.10.15 |
Spring Boot에서 Redis를 활용해 데이터 캐싱하기(feat. Look Aside) (1) | 2024.06.17 |
Interceptor를 활용한 JWT 토큰 검증 (1) | 2024.06.07 |