API Composition Pattern

nayoung
12 min readJul 21, 2024

--

MSA 환경에서 Spring Cloud Gateway를 추가해 라우팅, 필터 그리고 서킷브레이커로 이상을 감지하고 있었다.

하지만 ‘API Gateway Pattern에는 API Gateway가 없다’ 발표를 보니 게이트웨이를 너무 단순하게 생각한 것 같았다. API를 조합하지도 않고 provider 서비스가 게이트웨이를 역으로 호출하는 등 여러 문제가 발생하고 있었다.

https://www.kurly.com/main

위 페이지는 마켓컬리 개인 페이지다. 개인 페이지를 요청하면 개인 정보뿐만 아니라 주문 내역, 쿠폰 개수 등 여러 데이터가 노출된다.

만약 개인 정보, 주문 내역, 쿠폰 정보 등 여러 데이터가 서로 다른 서비스에서 관리되고 있다면 해당 페이지를 노출하기 위해 여러 서비스로 데이터를 요청해야 한다.

데이터 조합 및 노출 문제

위와 같이 클라이언트가 여러 서비스를 호출해 그 결과를 조합해도 되지만, 이는 클라이언트에게 여러 데이터를 자유롭게 조합할 권한을 준 것이고 그 조합이 어떤 문제를 일으키는지 예측할 수 없다.

중요한 정보 노출 가능성도 있는데 만약 개인 정보 API에 호출 시 비밀번호 데이터까지 리턴한다면 굳이 필요 없는 정보까지 외부로 노출하게 된다.

높은 지연시간

또한, 클라이언트가 여러 데이터를 얻기 위해 여러 API를 호출하는 비용이 발생한다. 인터넷은 LAN보다 대역폭이 훨씬 낮고 지연 시간이 길어 여러 API를 호출해 모든 데이터를 가져오기 위해 많은 시간이 소요될 수 있다.

복잡도 증가

클라이언트는 응답을 위해 1개의 API가 아닌, 여러 백엔드 서비스의 호출 방법을 알고 있어야 한다.

또한, 특정 API가 응답하지 않는 등 여러 오류와 Edge Case에 직접 대응해야 한다.

데이터 생산자/소비자 강한 결합

데이터 생산자(백엔드 서비스)와 소비자(클라이언트)가 강한 결합이므로 백엔드 서비스의 변경 작업이 클라이언트에게 영향을 미칠 수 있다.

API Composition

다음과 같이 클라이언트는 한 번의 호출로 모든 데이터를 받는 구조로 변경하면 네트워크 비용을 줄이고 필요한 데이터만 내보낼 수 있다.

API 조합은 API 게이트웨이에서 해도 되고, standalone 서비스로 구현해도 된다.

API 게이트웨이에서 API 조합을 제공하면 API Gateway Pattern이다. 만약 API Gateway로 단순 라우팅만 제공하고 있다면 Gateway Routing Pattern이다.

API 조합의 이점은 다음과 같다.

Centralized Orchestration

여러 API 간 상호 작용을 조정하는 중개자 역할을 한다. 클라이언트로 응답하기 전에 집계, 변환이 가능하다.

Single Endpoint

응답을 위해 여러 데이터가 필요해도 클라이언트는 단 하나의 endpoint에만 접근하면 된다. 여러 API가 아닌 single endpoint 호출로 네트워크 비용이 감소한다.

데이터 생산자/소비자 느슨한 결합

클라이언트(데이터 소비자)는 백엔드 서비스(데이터 생산자)에 다이렉트로 접근하지 않고, API 조합기를 통해 데이터를 소비한다. 그러므로 클라이언트를 신경 쓰지 않고 백엔드 서비스의 업데이트가 가능하다. (물론 API 조합기에는 영향을 미칠 수 있음)

API 조합의 단점은 다음과 같다.

가용성이 상대적으로 낮음

API 조합기와 데이터를 제공하는 여러 provider 서비스가 개입되는 구조이므로 monolithic에 비해 가용성이 낮다. 특정 서버가 다운되면 캐시 데이터로 대체하거나 일부 데이터만 리턴해 해결할 수 있다.

Data Inconsistency

여러 DB로 쿼리를 실행하고 Eventual consistency 기반으로 구현되었다면 일관되지 않은 데이터가 반환될 수 있다. 물론 이벤트 유실, 네트워크 지연 등 여러 문제가 발생하지 않는다면 시간이 지나고 데이터 일관성이 자연스럽게 맞춰질 것이다.

인메모리 조인

요청이 올 때마다 여러 provider 서비스로부터 데이터를 가져와 인메모리 조인 작업이 필요하다. 이는 CQRS(Command Query Responsibility Segregation) 패턴으로 해결할 수 있다.

목적에 맞는 API가 없는 경우

주문 목록이 아닌 특정 인덱스에 대한 주문 데이터를 제공하는 API만 존재한다면 주문 목록을 생성하기 위해 여러 주문을 일일이 요청해야 한다. 이 과정에서 네트워크 비용과 응답 지연이 발생할 수 있지만, 생성한 주문 목록을 캐싱하는 방법으로 해결할 수 있다.

데이터 책임 분리

기존에 구현한 개인 정보 페이지 API는 다음과 같이 처리된다.

  • API 게이트웨이는 계정 서비스로만 요청을 보내고
  • 계정 서비스는 OpenFeign을 사용해 주문 서비스로부터 주문 내역 데이터를 가져온다
  • 계정 서비스는 자신의 DB에서 고객의 개인 정보와 주문 서비스로부터 받은 주문 내역 데이터를 합쳐 API 게이트웨이로 응답한다

클라이언트는 API 한 번 호출로 개인 정보 데이터 + 주문 내역 데이터를 얻고 있지만, 계정 서비스는 다음과 같은 작업을 처리한다.

  • 주문 서비스로부터 가져온 데이터 중 일부만 노출하기 위한 작업
  • 주문 서비스 다운 시 대체 데이터 생성
  • 자신의 도메인 처리 (계정 데이터)

즉, 계정 서비스는 자신의 도메인이 아닌 주문 내역 데이터까지 신경 쓰는 구조가 되었다.

이는 특정 서비스의 개발자가 다른 비즈니스까지 책임을 맡게 되어 타 서비스들과의 결합도 증가 및 불필요한 복잡도 증가하고, 해당 기능에서 장애가 발생한다면 이는 비즈니스 계층의 장애이므로 다른 마이크로서비스에게 장애가 전파될 수 있다.

그러므로 위와 같이 여러 provider 서비스 앞단에서 API 조합기가 여러 데이터를 가져와 조합한 뒤 클라이언트에게 응답하는 방식으로 변경하면 다른 서비스의 도메인에 대해 신경 쓰지 않아도 된다.

API Gateway에서 API 조합하기

WebClient로 여러 API를 호출해 조합하는 아주 간단한 코드는 다음과 같다.

@Bean
public RouterFunction<ServerResponse> myPageRoutes(MyPageHandler myPageHandler) {
return RouterFunctions.route()
.GET("/my-page-details/{accountId}",
RequestPredicates.accept(MediaType.APPLICATION_JSON),
myPageHandler::getMyPageDetails)
.build();
}

http://localhost:8089/my-page-details/{accountId} 로 요청이 들어오면 계정 서비스와 주문 서비스의 API를 조합하는 함수를 호출한다. 이때 해당 함수의 리턴 타입은 Mono<ServerResponse> 이어야 한다.

HandlerFunction

handlerFunction 부분에는 pattern과 predicate와 일치하는 GET 요청을 처리하는 function을 작성하는데 함수형 인터페이스 HandlerFunction의 하나뿐인 handle 추상 메서드의 리턴타입이 Mono<T> 이다.

package org.springframework.web.reactive.function.server;

public abstract class RouterFunctions {

Builder GET(String pattern,
RequestPredicate predicate,
HandlerFunction<ServerResponse> handlerFunction);
package org.springframework.web.reactive.function.server;

@FunctionalInterface
public interface HandlerFunction<T extends ServerResponse> {
Mono<T> handle(ServerRequest request);
}

그러므로 handlerFunction 부분의 리턴 타입은 Mono<ServerResponse> 이어야 한다.

API Composition

사용자 서비스에서 데이터를 가지고 오는 코드는 다음과 같다.

private Mono<SimpleAccountDto> getAccount(ServerRequest serverRequest) {

Long accountId = Long.valueOf(serverRequest.pathVariable("accountId"));

return reactiveLoadBalancerService.chooseInstance("ACCOUNT-SERVICE")
.flatMap(i -> {
String url = String.format("http://%s:%d", i.getHost(), i.getPort());
return webClientBuilder.baseUrl(url).build()
.get()
.uri("/accounts/simple/{accountId}", accountId)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(SimpleAccountDto.class);
});
}

사용자 서비스와 주문 서비스의 API를 조합하는 코드는 다음과 같다.

public Mono<ServerResponse> getMyPageDetails(ServerRequest serverRequest) {

Mono<SimpleAccountDto> account = getAccount(serverRequest)
.doOnError(throwable -> log.error("Exception thrown by getAccount method: {}", throwable.getMessage()))
.onErrorResume(throwable -> Mono.empty())
.switchIfEmpty(Mono.error(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR)));

Mono<OrderListDto> orderList = getOrderList(serverRequest)
.doOnError(throwable -> log.error("Exception thrown by getOrderList method: {}", throwable.getMessage()))
.onErrorResume(throwable -> Mono.just(OrderListDto.emptyInstance()));

return Mono.zip(account, orderList)
.map(tuple -> MyPageDto.of(tuple.getT1(), tuple.getT2()))
.flatMap(myPageDto -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(myPageDto));
}

WebClient를 사용해 두 서비스로부터 데이터를 받고, 적절히 조합해 응답한다. 서비스 접근 불가능, 잘못된 데이터 요청 등 여러 예외 처리를 추가 작성하면 된다.

ReactiveLoadBalancer

만약 서비스가 랜덤 포트를 사용하고 있다면 ReactiveLoadBalancer를 사용해 서비스의 포트 번호, URI, 서비스 ID 등 여러 정보를 얻어 해당 서비스의 API를 호출하면 된다.

private final ReactiveLoadBalancer.Factory<ServiceInstance> loadBalancerFactory;

public Mono<ServiceInstance> chooseInstance(String serviceId) {
return Mono.from(loadBalancerFactory.getInstance(serviceId)
.choose())
.map(Response::getServer)
.doOnNext(instance -> log.info("Chose instance: {} for service: {}", instance, serviceId))
.doOnError(error -> log.error("Error choosing instance for service: {}", serviceId, error));
}

--

--