Spring cloud gateway request body cache

2023. 1. 24. 23:08Development

Request body cache

Spring cloud gateway를 도입하는 과정에서 backend 로의 routing 전에 request body를 열어보아야하는 요구사항이 있었습니다.

 

특별히 손을 대지 않는 다면, filter들을 통해서 라우팅을 하는 과정에서 body가 읽혀지게 됩니다.

 

하지만 rest call에서 body는 상대편에서 보내오는 data stream을 직접 읽어오는 식이다보니 버퍼에 있는 내용을 꺼내게 되면 비게 됩니다.

 

버퍼에서 두번 읽을 수 없기 때문에 두 곳에서 사용을 하려면 처음 읽은 데이터를 어떤 식으로든 보존해 두었다가 재사용을 해야합니다. Spring cloud gateway에는 이를 cache를 통해서 해결하게끔 filter가 만들어져있고, 실제 사용하는 방법을 기록해두려고 합니다.

Global Ordered Filters

RemoveCachedBodyFilter / order = -2147483648
AdaptCachedBodyGlobalFilter / order = -2147482648
NettyWriteResponseFilter / order = -1
ForwardPathFilter / order = 0
GatewayMetricsFilter / order = 0
RouteToRequestUrlFilter / order = 10000
NoLoadBalancerClientFilter / order = 10150
WebsocketRoutingFilter / order = 2147483646
NettyRoutingFilter / order = 2147483647
ForwardRoutingFilter / order = 2147483647

위 리스트는 기본적으로 Spring cloud gateway에서 기본적으로 로딩이되는 필터들과 그 오더입니다.

살펴보면 RemoveCachedBodyFilter 라는 body를 컨트롤하고 있는 것 같아 보이는 필터가 이미 보입니다.
해당 코드를 살펴보면...

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange).doFinally(s -> {
            Object attribute = exchange.getAttributes().remove(CACHED_REQUEST_BODY_ATTR);
            if (attribute != null && attribute instanceof PooledDataBuffer) {
                PooledDataBuffer dataBuffer = (PooledDataBuffer) attribute;
                if (dataBuffer.isAllocated()) {
                    if (log.isTraceEnabled()) {
                        log.trace("releasing cached body in exchange attribute");
                    }
                    dataBuffer.release();
                }
            }
        });
    }

아직 잘은 모르지만 CACHED_REQUEST_BODY_ATTR 라는 키로 exchange의 내부에 저장되어있는 캐시로 보이는 어떠한 DataBuffer를 release하고 있음을 확인할 수 있습니다.
(Netty에서는 DataBuffer의 메모리를 Native단에 할당해 놓기 때문에 해당 Java object가 GC로 해제되어도 Native는 자동해제 되지 않습니다... 이렇게 밖에 할 수 없는 정말 다른 방법이 없는지 의문이기도 합니다만 어찌되었건... 그래서 직접 release를 해주지 않으면 Memory leak이 발생합니다.)

그럼 이제 CACHED_REQUEST_BODY_ATTR를 put하고 있는 곳을 찾아봐야겠습니다.

CacheRequestBodyGatewayFilterFactory.java

...
                return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange, (serverHttpRequest) -> {
                    final ServerRequest serverRequest = ServerRequest
                            .create(exchange.mutate().request(serverHttpRequest).build(), messageReaders);
                    return serverRequest.bodyToMono((config.getBodyClass())).doOnNext(objectValue -> {
                        exchange.getAttributes().put(ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR, objectValue);
                    }).then(Mono.defer(() -> {
                        ServerHttpRequest cachedRequest = exchange
                                .getAttribute(CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR);
                        Assert.notNull(cachedRequest, "cache request shouldn't be null");
                        exchange.getAttributes().remove(CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR);
                        return chain.filter(exchange.mutate().request(cachedRequest).build());
                    }));
                });
...

CacheRequestBodyGatewayFilterFactory 에서 값을 넣어주고 있는 것을 확인할 수 있다.

해당 필터는 Spring cloud gateway 내부에 존재하는 factory를 통해서 생성이되는 데, 기본적으로 로딩이 되는 것 같지는 않습니다.

일단 코드를 보면 serverRequest.bodyToMono((config.getBodyClass()) 를 이용해서 body를 한번 가져오고 이를 캐싱 하는 것을 확인할 수 있습니다. 해당 필터가 활성화된다면 CACHED_REQUEST_BODY_ATTR 값으로 exchange에 캐싱이되게 됩니다.

요구사항이 filter의 post동작에 request body를 확인하는 것이므로 해당 filter를 동작시키는 것만으로도 충분히 원하는 값을 얻을 수 있기 때문에 해당 필터를 셋팅하기로 하였습니다.

  cloud:
    gateway:
      default-filters:
        - name: CacheRequestBody
          args:
            body-class: java.lang.String

'GatewayFilterFactory' 를 제외하고 Factory 이름을 위와 같이 yml에 선언하게 되면 해당 필터가 활성화 됩니다. config 값으로 bodyToMono로 받을때 class type을 string으로 하기 위해서 위와 같이 추가로 args를 선언해주었습니다.

이제 빌드후 실행을 시켜보면

        Object cachedBody = exchange.getAttributes()
                .getOrDefault(ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR, EMPTY_STRING);

위 코드로 캐시된 바디를 string 타입으로 잘 얻어올 수 있음을 확인할 수 있었습니다.