API Gateway 패턴은 MSA(Micro Service Architecture)에서 주로 언급되는 개념으로, 여러 서버에 도달하기 위한 관문 역할을 하는 서버를 별도로 만들어, 클라이언트가 요청을 보내는 단일 창구로서 활용하는 패턴이다.
최근 많은 서비스들이 MSA 형태로 서비스를 구축하면서 복잡도를 줄일 수 있게 되었고, 변경에 따른 영향을 최소화하면서 개발과 배포를 할 수 있게 되었다. 그러나 여기서 말하는 작은 단위의 서비스가 50개, 100개가 되었을 때, 이 많은 서비스들의 엔드포인트를 관리하는데 있어서 어려움이 생기고, 또 각각의 서비스마다 공통적으로 필요로하는 기능(ex, 인증/인가, 로깅)들을 중복으로 개발해야 한다는 문제점이 발생한다.
이러한 문제점을 해결하기 위해 등장한 것에 API Gateway이다. 클라이언트는 각 서비스의 엔드포인트 대신 API Gateway로 요청을 보낸다. 그리고 요청을 받은 API Gateway는 설정에 따라 클라이언트를 대신하여 각 엔드포인트로 요청을 보내고, 응답을 받으면 다시 클라이언트에게 전달하는 프록시 역할을 한다.
일반적인 스프링부트 서비스에서 @Controller
를 통해 요청을 받는 것과 달리 Spring Cloud Gateway는 오로지 요청을 전달하는 역할을 하기 때문에 , Configuration 위주로 작성하게 된다.
@Configuration
public class RoutingConfig {
@Bean
public RouteLocator gatewayRoute(RouteLocatorBuilder builder) {
return builder.routes()
.route("articles",
predicate -> predicate
.path("/articles/**")
.uri("http://localhost:8082"))
.route("auth",
predicate -> predicate
.path("/auth/**")
.filters(filter -> filter.rewritePath("/auth/(?<path>.*)", "/${path}"))
.uri("http://localhost:8081"))
.build();
}
}
RouteLocatorBuilder
를 통해 어떤 URL로 들어온 요청을 다시 어디로 보낼지를 설정한다.
route()
: Gateway 구성을 시작한다.
첫 번째 인자로는 어떤 경로인지 인식하기 위한 ID를 설정한다.
두 번째 인자로는 predicate.path()
를 통해 요청의 경로 조건을, predicate.uri()
를 통해 요청을 다시 보낼 도메인을 지정한다. 즉 만약 /articles/{id}
로 요청이 들어오면, http://localhost:8082/articles/{id}
로 요청이 보내진다.
위 예제에서 article 서버와 달리, auth 서버의 경우 여러 URL Root를 사용한다(/token
, /users
...). 따라서 predicate.filter()
메소드를 통해 요청 Path을 조작하기 위한 필터를 추가한다. 요청 Path를 조작하기 위해서는 filter.rewritePath()
메소드를 사용하면 된다. 이때 전달하는 값은 정규 표현식을 따른다. 위 예제 코드의 경우, /auth
뒤에 붙는 경로가 path
값으로 지정되고, 그 path
경로로 이동하도록 설정되어 있다. 즉 만약 /auth/token/issue
로 요청이 들어오면, http://localhost:8081/token/issue
로 요청이 보내진다.
Gateway가 모든 요청을 받아주는 만큼, 모든 요청에 대해 공통적으로 처리하고자 하는 기능이 있으면, 필터를 추가할 수 있다. 이때 GlobalFilter
인터페이스를 구현하고 빈 객체로 등록하면 Gateway에 자동으로 등록된다.
@Component
@Slf4j
public class PreLoggingFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String requestId = UUID.randomUUID().toString();
// PreLoggingFilter에서 요청을 식별할 수 있는 HTTP Header 작성하고
// PostLoggingFilter에서 Header를 바탕으로 실행에 걸린 시간 측정한다.
request.mutate() // 요청을 조작
.headers(httpHeaders -> {
httpHeaders.add("x-gateway-request-id", requestId);
httpHeaders.add("x-gateway-request-time", String.valueOf(Instant.now().toEpochMilli()));
})
.build();
log.info("start transaction: {}", requestId);
return chain.filter(exchange);
}
}
GlobalFilter
인터페이스를 구현한다.
ServerWebExchange exchange
: HttpServletRequest response
와 유사한 역할을 한다. exchange
를 통해 요청과 응답을 조작한다.
chain.filter(exchange)
: filterChain.doFilter()
와 유사한 역할을 한다. 이를 호출해야 요청과 응답이 전달된다.
request.mutate()
: 현재 HTTP 요청을 변형한다. 이 메소드를 호출함으로써 원하는 헤더를 추가하거나 경로를 변경할 수 있다. 마지막에 build()
를 호출하여 변경 사항을 적용한다.
@Component
@Slf4j
public class PostLoggingfilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 이 필터는 사용자에게 응답이 돌아간 후에 실행된다.
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
// PreLoggingFilter에서 만든 Header의 값을 받아오기
ServerHttpRequest request = exchange.getRequest();
String requestId = request.getHeaders().getFirst("x-gateway-request-id");
String requestTime = request.getHeaders().getFirst("x-gateway-request-time");
// 현재 시각
long timeEnd = Instant.now().toEpochMilli();
// PreLoggingFilter에서 기록한 요청 시각
long timeStart = Long.parseLong(requestTime);
log.info("Execution Time id: {}, timediff(ms): {}", requestId, timeEnd - timeStart);
}));
}
}
chain.filter()
의 결과를 반환해야 한다. PostLoggingFilter는 chain.filter(exchange).then()
를 통해 요청이 전달된 뒤 응답이 보내지기 전에 실행된다. 즉 PreLoggingFilter에서 조작했던 요청을 다시 PostLoggingFilter에서 확인하고, 요청이 처리되는데 걸린 시간을 로그로 남길 수 있다.Reference