본문 바로가기

Spring Boot

Spring 라이브러리 개발기 - RestInterceptor v0.1

개발 배경


 
 Spring 을 사용해 Restful api 서버를 개발하다 보면 Interceptor 를 사용할 때가 있다. Interceptor 를 구현하기 위해 보통 HandlerInterceptor 의 구현체를 구현하게 되는데, 여기엔 두 가지 단점이 있다.
 

1. CORS 에서 사용되는 Preflight 요청도 Interceptor 를 통과하게 된다. 그러므로, Preflight 요청은 전부 허용하도록 인터셉터 내부에 코드를 넣어야 한다.

2. HandlerInterceptor 는 URI 를 기반으로 등록해야 한다. 하지만, Restful api 에서는 URI 와 HTTP Method 를 통해 요청을 구분한다.
ex) GET /memosPOST /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