[Spring Cloud] Gateway — Custom Filter

lujae
17 min readApr 8, 2024

--

이전에는 Gateway에 대한 개념과 Spring Cloud Gateway에서 어떻게 필터를 적용할 수 있는지를 간략하게 알아봤는데, 이번에는 Custom Filter에 대해 알아보겠습니다.

클라이언트로부터 오는 요청의 본문(body)을 해시하고, 그 해시 값을 요청 헤더에 추가하는 커스텀 필터 RequestHashingGatewayFilterFactory 를 생성합니다.

package com.example.mygateway;

import org.bouncycastle.util.encoders.Hex;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest;
import reactor.core.publisher.Mono;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.List;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR;

@Component
public class RequestHashingGatewayFilterFactory extends
AbstractGatewayFilterFactory<RequestHashingGatewayFilterFactory.Config> {

private static final String HASH_ATTR = "hash";
private static final String HASH_HEADER = "X-Hash";
private final List<HttpMessageReader<?>> messageReaders =
HandlerStrategies.withDefaults().messageReaders();

public RequestHashingGatewayFilterFactory() {
super(Config.class);
}

@Override
public GatewayFilter apply(Config config) {
MessageDigest digest = config.getMessageDigest();
return (exchange, chain) -> ServerWebExchangeUtils
.cacheRequestBodyAndRequest(exchange,
(httpRequest) -> ServerRequest
.create(exchange.mutate().request(httpRequest).build(), messageReaders)
.bodyToMono(String.class)
.doOnNext(requestPayload -> exchange
.getAttributes()
.put(HASH_ATTR, computeHash(digest, requestPayload)))
.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);

String hash = exchange.getAttribute(HASH_ATTR);
cachedRequest = cachedRequest.mutate()
.header(HASH_HEADER, hash)
.build();
return chain.filter(exchange.mutate()
.request(cachedRequest)
.build());
})));
}

@Override
public List<String> shortcutFieldOrder() {
return Collections.singletonList("algorithm");
}

private String computeHash(MessageDigest messageDigest, String requestPayload) {
return Hex.toHexString(messageDigest.digest(requestPayload.getBytes()));
}

static class Config {

private MessageDigest messageDigest;

public MessageDigest getMessageDigest() {
return messageDigest;
}

public void setAlgorithm(String algorithm) throws NoSuchAlgorithmException {
messageDigest = MessageDigest.getInstance(algorithm);
}
}
}



Pre Filter, Post Filter 구분

chain.filter(exchange) 전에 호출되는 필터는 Pre Filter로써 Proxy Request 생성전에 로직이 수행되고,

chain.filter(exchange).then(Mono.fromRunnable(() -> {

// 여기에 Post 필터 로직을 구현합니다.

}));
이런식으로 then 내부에서는 Post Filter의 로직을 구현합니다.

클래스 선언

Custom Filter 클래스는 AbstractGatewayFilterFactory 클래스를 상속해서 구현합니다. 이때 CustomFilter클래스에서 사용할 기타 정보를 담을 Config 클래스를 CustomFilter 클래스 내부에 정의합니다.

해당 Custom Filter는 Spring Context에 의해 관리 받도록 Component Annotation을 붙였습니다.

필터 로직

필터의 로직은 apply 메서드를 Override하여 정의할 수 있습니다. 해당 메서드는 GatewayFilter 인터페이스를 (exchange, chagin) 람다 형태로 반환합니다. exchage는 ServerWebExchange의 인스턴스입니다.

exchange.mutate().request(httpRequest)

Spring WebFlux의 ServerWebExchange 인스턴스를 변경하기 위한 명령입니다. 한 번의 요청-응답 사이클에 대한 모든 정보와 상태를 포함하고 있는 객체입니다. 이 객체는 현재 HTTP 요청과 응답에 대한 참조를 포함하고 있으며, 이를 통해 요청이나 응답을 조작할 수 있습니다.

mutate 메서드

  • mutate() 메서드는 ServerWebExchange의 변경 가능한 빌더를 반환합니다. 이 빌더는 ServerWebExchange의 현재 상태를 기반으로 하며, 여러 가지 변경 사항을 적용한 후 새로운 ServerWebExchange 인스턴스를 생성할 수 있게 해줍니다. 원본 exchange 객체는 불변 객체로 남아 있으며, 모든 변경 사항은 새로 생성된 exchange 인스턴스에만 적용됩니다.

request 메서드

  • request(httpRequest) 메서드는 빌더에 새로운 ServerHttpRequest 인스턴스를 설정합니다. 여기서 httpRequest는 변경하고 싶은 새로운 HTTP 요청 객체입니다. 이 방식을 통해, 예를 들어, 요청 헤더를 추가하거나 수정하는 등의 요청 변형 작업을 수행할 수 있습니다.

ServerWebExchangeUtils#cacheRequestBodyAndRequest 메서드

  • 웹 서버와 어플리케이션 사이에서 HTTP 요청 Body은 보통 Byte Buffer형태로 저장되고 전송됩니다. 데이터를 한 번 읽으면, 해당 부분 데이터는 소진 되기에, 같은 데이터를 읽으려면 데이터를 다시 로드해야합니다. 따라서, 요청 Body을 여러번 읽거나 여러 필터나 핸들러에서 접근해야하는 경우, 요청 Body의 내용을 별도로 캐싱하거나 저장합니다.

(httpRequest) -> ServerRequest.create(exchange.mutate().request(httpRequest).build(), messageReaders)

  • httpRequest: 여기서 httpRequestServerHttpRequest 타입의 객체입니다. ServerWebExchangeUtils.cacheRequestBodyAndRequest 메서드는 요청 본문을 캐싱하고, 캐싱한 결과를 다룰 수 있는 콜백 함수를 인자로 받습니다. 이 콜백 함수의 인자인 httpRequest는 캐싱 과정을 거친 후의 요청 객체입니다.
  • 해당 코드는 HttpRequest를 ServerRequest로 변환하여 리액티브하게 읽을 수 있도록 변환합니다.
  • messageReaders는 HTTP Body를 읽는데 사용됩니다.

Reactive Programming 패러다임
Spring에서의 리액티브(Reactive) 패러다임은 비동기적이고 논블로킹(non-blocking) 방식의 데이터 처리와 애플리케이션 개발을 위한 프로그래밍 모델을 말합니다.

.bodyToMono(String.class)

  • ServerRequest의 Body는 요청 본문을 String으로 변환하고, 리액티브 타입 Mono로 감싸 반환합니다. 비동기적으로 본문 데이터를 처리할 수 있게 해줍니다.

.doOnNext(requestPayload -> exchange.getAttributes()

.put(HASH_ATTR, computeHash(digest, requestPayload)))

  • Mono 스트림의 각 요소에 대해 실행되는 콜백을 등록합니다. 여기서는 요청 본문을 해시하고 그 값을 교환 속성에 저장합니다.
  • requestPayload는 HTTP 리퀘스트의 바디를 의미합니다.
  • 계산된 해시 값을 exchange의 속성 Map에 저장합니다. HASH_ATTR은 속성의 키입니다.

.then(Mono.defer(() -> {

  • 본문 처리가 완료되면 실행될 로직을 정의합니다. Mono.defer는 본문 처리가 완료될 때까지 지연시키기 위해 사용됩니다.

ServerHttpRequest cachedRequest = exchange.getAttribute(CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR);

  • 캐싱된 HTTP 요청을 exchange 속성에서 검색합니다.

Assert.notNull(cachedRequest, “cache request shouldn’t be null”);

  • 캐싱된 요청이 null이 아님을 확인합니다. null이면 예외를 발생시킵니다.

exchange.getAttributes() .remove(CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR);

  • 처리가 완료되었으므로 캐싱된 요청에 대한 참조를 교환 속성에서 제거합니다. 캐싱된 자원을 계속 가지고 있는 것은 메모리를 계속 점유하고 있다는 의미입니다.

cachedRequest = cachedRequest.mutate()
.header(HASH_HEADER, hash)
.build();

  • .mutate 메서드를 사용해, 변경 가능한 인스턴스를 새로 만들고, hash Header를 설정합니다.

return chain.filter(exchange.mutate()
.request(cachedRequest)
.build());

  • exchange의 변경가능한 버전을 만들어, request 정보를 cachedRequest로 수정후 새로운 인스턴스를 생성합니다.
  • chain.filter(…) 수정된 ServerWebExchange를 사용하여 나머지 필터 체인 처리를 합니다.

나머지 로직

@Override
public List<String> shortcutFieldOrder() {
return Collections.singletonList("algorithm");
}

필터 구성(configuration)에서 사용할 속성의 순서를 지정합니다. 반환하는 리스트는 YAML이나 properties 파일 등의 구성에서 필터를 선언할 때 필요한 파라미터의 순서와 이름을 정의합니다.

“algorithm”이라는 단일 요소를 포함하는 리스트를 반환하는데, 이는 RequestHashingGatewayFilterFactory 필터의 구성 객체(Config) 내에서 "algorithm" 속성이 유일한 Config 속성으로 사용됨을 나타냅니다. 여기서 “algorithm”이라는 문자열로 인해 Confing 클래스에서의 setAlgorithm이 자동으로 실행됩니다.

static class Config {

private MessageDigest messageDigest;

public MessageDigest getMessageDigest() {
return messageDigest;
}

public void setAlgorithm(String algorithm) throws NoSuchAlgorithmException {
messageDigest = MessageDigest.getInstance(algorithm);
}
}

MessageDigest

임의 길이의 데이터를 입력으로 받아 고정 길이의 해시값을 출력하는 클래스입니다. 해쉬 알고리즘을 지정하여 인스턴스를 생성할 수 있습니다.

application.yml

위와 같이 AbstractGatewayFilterFactory를 상속하는 클래스를 만들어 준다고 해서 Gateway에 필터가 적용되는 것이아니라, .yml 파일이나 .properties 파일을 통해 등록을 해줘야합니다.

설정 파일

spring:
application:
name: MyGateway

cloud:
gateway:
routes:
- id: demo
uri: https://httpbin.org
predicates:
- Path=/post/**
filters:
- RequestHashing=SHA-256

Custom Filter로 RequestHashingGatewayFilterFactory 클래스를 생성하였지만, 등록을 할 때는 RequestHashing 으로 등록을 하고 있습니다. 이는 Spring Cloud Gateway의 filter 네이밍 컨벤션에 따라, GatewayFilterFactory 라는 suffix를 생략하기 때문입니다.

RequestHashing=SHA-256

해당 문장은, RequestHasingGatewayFilterFactory 필터를 등록할 뿐 만 아니라, 파라미터로 SHA-256을 전달합니다.

Postman Test

설정한 필터가 정상적으로 작동하는 지 테스트를 하기 위해, API-Gateway Application을 실행시킨뒤 Postman 툴을 활용해서 http://localhost:8080/postHttp Post 요청을 보내보겠습니다.

그림1. postman 요청

그러면 다음과 같이 x-Hash에 해시 값이 생성된 것을 확인할 수 있습니다.

{
"args": {},
"data": "{\r\n \"data\": \"hello\"\r\n}",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
"Content-Length": "25",
"Content-Type": "application/json",
"Forwarded": "proto=http;host=\"localhost:8080\";for=\"[0:0:0:0:0:0:0:1]:51970\"",
"Host": "httpbin.org",
"Postman-Token": "c44c9455-ed7b-4e40-8aa4-08076e768f85",
"User-Agent": "PostmanRuntime/7.37.0",
"X-Amzn-Trace-Id": "Root=1-6613af2d-48654f036ef5dfea64c0f279",
"X-Forwarded-Host": "localhost:8080",
"X-Hash": "1a90eb0c2272ef2b7194dad0275418412425871ce3a994ff22ebad5108fe3b50"
},
"json": {
"data": "hello"
},
"origin": "0:0:0:0:0:0:0:1, 221.150.27.190",
"url": "https://localhost:8080/post"
}

Referecne

--

--