웹 애플리케이션은 방화벽 내부에서 실행되므로 대역폭이 높고 지연시간이 짧은 LAN을 통해 서비스에 접속하지만, 다른 클라이언트는 방화벽 외부에 있으므로 상대적으로 대역폭이 낮고 지연이 높은 인터넷 또는 모바일 네트워크 환경에서 호출된다.
모놀리식 애플리케이션 API를 클라이언트가 단순 호출하는 방식. 그러나 마이크로서비스 아키텍처에서는 다음과 같은 단점이 있기때문에 클라이언트 → API 직접호출 방식을 거의 쓰지 않음.
소비자는 FTGO에 접속해서 주문을하고 이력을 관리한다.
주문상태, 지불상태, 음식점 관점에서의 주문 상태 등의 주문 기본 정보와 배달 중일 경우 현재 위치 및 예상 배달시간등의 배달 상태를 한눈에 볼 수 있는 주문 조회 뷰를 개발한다고 할때, 모놀리식 버전에서는 주문내역을 반환하는 API 끝점이 있어서 클라이언트가 원하는 정보를 한번요청으로 모두 가져올 수 있지만, 마이크로 서비스 버전은 주문 데이터가 여러곳에 분산되어 있다.
모바일 앱이 사용자에게 보여줄 데이터를 여러 번 요청해서 가져와야 한다.
상호작용이 너무 자주 발생되면 애플리케이션이 멎은것 처럼 보일수 있다. 인터넷은 LAN보다 대역폭이 훨씬 낮고 지연시간이 길다. 일반적으로 모바일 네트워크(인터넷)의 지연시간이 LAN보다 100배는 더 길다.
모바일 앱은 동시요청을 실행해서 지연을 최소화하기에 주문내역을 조회할때 더 높은 지연시간이 문제가 되지 않을 수 있다. 전체 응답시간이 요청 하나의 응답시간보다 길지 않기때문이다. 하지만 요청을 순차실행할 수 밖에 없는 상황이라면 UX가 형편없이 나빠질 것이다.
모바일 개발자가 복잡한 API 조합 코드를 작성할 일이 많아지게 되면 결국 UX개선 임무를 수행하기 힘들고 네트워크 요청이 많아진다면 모바일 기기의 배터리도 더 빨리 달 것이다.
애플리케이션이 발전함에 따라 기존 클라이언트와 호환되지 않는 변경을 하거나 시스템을 여러 서비스로 분해하는 체계를 건드려야 할 수 도 있다. 서비스에 관한 지식이 모바일 앱에 포함되어 있으면 서비스 API를 변경하기 아주 어려워진다. 모바일 애플리케이션은 서버쪽과 다르게 애플, 구글 승인에 따른 이슈로 인해 새버전을 출시하는게 늦어진다. 서비스 API를 모바일에 표출시키는 전략이 API를 발전시키는데 걸림돌이 될 수 있다.
클라이언트가 소비하기 어려운 프로토콜을 사용하는 서비스도 있다.
grpc, amqp 등 이런 종류의 프로토콜은 내부에서 잘 작동 되지만, 모바일 클라이언트가 소비하기 어려운 경우도 많다. 더욱이 방화벽에 친화적이지 않은 프로토콜도 있다.
웹 애플리케이션은 웹에 비친화적인 프로토콜에도 접근 가능하다. 웹은 대개 같은 조직에 있는 백엔드 서비스 개발팀과 긴밀한 협업하에 작업을 진행하므로 백엔드 서비스가 변경될 때마다 웹 애플리케이션도 쉽게 수정할 수 있다.
따라서 웹 애플리케이션이 직접 백엔드 서비스에 접근하는 것이 얼마든지 가능하다.
API 변경 시 업데이트 하기는 쉽지만 모바일 앱처럼 인터넷을 통해 서비스에 접근하기 때문에 네트워크 지연문제는 별반 다를 바 없다. 일반 모바일 앱보다 더 정교한 브라우저 기반의 UI(특히 데스크톱 UI)는 더 많은 서비스를 조합해야 할 필요가 있다. 따라서 인터넷으로 접속한 소비자, 음식점 등의 애플리케이션은 서비스 API를 효휼적으로 조합하기 어려울 것 이다.
FTGO도 다른 회사처럼 서드파티 개발자용 API를 제공한다. 외부 개발자들이 이 API를 응용해서 주문관리 애플리케이션을 개발 할수 있다. 서드파티 개발자에게는 안정된 API가 필요하다.
아무리 잘 나가는 회사라도 서드파티 개발자에게 무조건 새 API로 업그레이드 하라고 강요할 수 없다. API를 불안정하게 제공하면 경쟁사에게 개발자를 뺏길수 있기 때문에 서드파티 개발자용 API는 조심스럽게 발전시켜야 한다. 그래서 구 버전을 꽤 오랫동안 유지하는 경우가 흔하다.
장기간 하위 호환성을 관리할 책임을 백엔드 서비스 개발자에게 지우기란 현실적으로 어렵다. 따라서 서드파티 개발자에게 직접 서비스를 표출하는 대신 별도 팀에서 개발한 퍼블릭 API를 따로 가져가는 것이 좋다. 이런 퍼블릭API는 API 게이트웨이라는 아키텍쳐 컴포넌트로 구현한다.
API 게이트웨이는 방화벽 외부의 클라이언트가 애플리케이션 API 요청을 하는 단일 창구 역할을 하는 서비스 이다. 객체지향 설계 교과서에 나오는 파사드(facade) 패턴을 떠올리면 이해가 빠르다. 파사드 처럼 API 게이트웨이도 내부 애플리케이션 아키첵쳐를 캡슐화하고 자신의 클라이언트 API를 제공한다. 인증, 모니터링, 사용량 제한 등 부수적인 일도 담당한다.
API 게이트웨이는 요청 라우팅, API 조합, 프로토콜 변환을 관장한다. 외부 클라이언트의 API 요청은 모두 api 게이트웨이로 향하고, API 게이트웨이는 적절한 서비스로 요청을 보낸다. 여러 서비스의 호출결과를 취합하는 API 조합패턴 방식으로 요청을 처리하기도 하며, 클라이언트에 친화적인 프로토콜과 비친화적인 프로토콜 간 변환한다.
요청이 들어오면 라우팅 맵을 찾아보고 어느 서비스로 요청을 보낼지 결정한다. 이를테면 HTTP 메서드와 서비스의 HTTP URL을 매핑한 것이다. 엔진엑스 같은 웹서버의 리버스 프록시와 똑같다.
주문내역 조회 API는 API 조합패턴으로 데이터를 가져온다. 모바일 앱이 API 게이트웨이에 요청을 한번 하면 API 게이트웨이는 여러 서비스에서 주문 내역 데이터를 조회한다.
API 게이트웨이는 모바일 클라이언트가 요청 한 번으로 필요한 데이터를 조회할 수 있도록 대단위 API를 제공한다. 즉, 모바일 클라이언트는 getOrderDetails()
라는 API 게이트웨이에 한번만 요청하면 된다.
애플리케이션 내부에서 REST와 gRPC를 혼횽할 경우에도 외부 클라이언트에는 REST API를 제공할 수 있다. 프로토콜 변환이 필요한 경우 API 작업을 구현한 코드에서 외부 REST API ↔ 내부 gRPC API 변환을 한다.
API 게이트웨이는 만능 API를 제공한다. 개별 API는 각기 다른 클라이언트마다 요건도 천차만별이라는 문제가 있다.
예를 들어 서드파티 앱은 주문 내역 조회 API를 호출해서 전체 주문 내역을 반환받고 싶어하지만 모바일 클라이언트는 그 중 일부만 필요로 할 수 있다. 다양한 종류의 서드파티 애플리케이션을 서비스 해야 하는 퍼블릭 API는 서버가 어떤 필드와 객체를 반환해야 할지 클라이언트가 요청 시 지정하게 하면 되지만, 이렇게 클라이언트에 제어권을 순순히 내어주는 경우는 거의 없다.
그러므로 API 게이트웨이가 각 클라이언트에 맞춤 API를 제공하는 방법이 좋다. 모바일 앱별로 API를 달리할수도 있고 서드파티 개발자용 퍼블릭 API를 구현할 수 도 있다.
API 계층과 공통 계층으로 구성된 모듈 아키텍쳐 구조이다. API 계층에는 독립적인 하나 이상의 API 모듈이 있고, 각 API 모듈에는 특정 클라이언트용 API가 구현되어 있다. 공통 계층에는 엣지 기능 등의 공통 기능이 구현되어 있다.
API 모듈은 두가지 방법으로 각 API작업을 구현한다. 첫째, 서비스 API 하나에 직접 매핑되는 API 작업은 해당하는 각각의 서비스 API로 요청을 보낸다. 라우팅 규칙이 기술된 구성 파일을 읽어 들여 작동되는 범용 라우팅 모듈을 응용할 수 있다.
둘째, API를 조합하는 복잡한 API 작업은 사용자 정의 코드로 구현한다. API 작업을 구현한 코드는 가각 여러 서비스를 호출하여 결과를 조합하는 방법으로 요청을 처리한다.
API 게이트웨이를 전담할 팀을 따로 신설한다. ESB(엔터프라이즈 서비스 버스) 팀이 ESB 개발을 전담하는 SOA체제와 비슷하다. 그러나 모바일 앱 개발자가 어떤 서비스 API에 접근해야 할 경우, API 게이트웨이 팀에 공식 요청한 후 원하는 API가 표출될때 까지 마냥 기다릴 수는 없다. 이렇게 중앙에서 병목 현상이 발생하는 모양새는 느슨하게 결홥된 자율 팀을 지향하는 마이크로서비스 아키텍처의 사상과 배치된다.
넷플릭스에서 권장하는 바와 같이, API가 표출된 모듈은 해당 클라이언트팀이 소유하는 구조가 바람직하다. API 게이트웨이 팀은 공통 모듈 개발 및 게이트웨이 운영 이슈에 집중하는 것 이다.
매끄럽게 협업을 하려면 API게이트웨이 배포 파이프라인을 완전히 자동화해야 한다. 안그러면 클라이언트팀은 API 게이트웨이 팀이 새 버전을 배포할 때까지 마냥 기다려야 할 것이다.
문제는 책임소재가 불분명해 진다는 것 이다. 여러 팀 사람들이 동일한 코드베이스에 소스를 커밋하고, API게이트웨이 팀이 그 운영을 맡는 구조는 책임소재가 불분명 해지는 문제가 있다. SOA ESB만큼 나쁘지는 않아도 이렇게 각자의 책임소재가 흐릿해지면 ‘빌드한 사람이 임자다’ 라는 마이크로 서비스 아키텍처의 철학과 맞지 않게된다.
해결 방법은 각 클라이언트마다 API 게이트웨이를 따로 두는 BFF 패턴을 적용하는 것 이다. 이 패턴은 사운드 클라우드사의 팔카도와 그의 동료들이 창안했다. 각 API 모듈이 하나의 클라이언트 팀이 개발/운영하는 StandAlone한 API 게이트웨이가 되는 구조이다.
이론적으로는 게이트웨이 마다 다른 기술을 사용할수 있지만 공통 기능 코드가 중복될 우려가 있으므로 API 게이트웨이에 동일한 기술 스택을 적용하는 것이 좋다. 공통 기능은 API 게이트웨이 팀이 개발한 공유 라이브러리이다.
위의 단점들이 있지만, 필요시 BFF패턴을 이용하여 팀별로 API를 독립적으로 개발/배포 할 수 있으니 실제로 애플리케이션을 개발할 때에는 API 게이트웨이를 사용하는 편이 합리적이다
넷플릭스는 TV, 블루레이 플레이어, 스마트폰 등 수백 종류 기기에서 작동된다. 처음에 자사 스트리밍 서비스 API를 만능 스타일로 개발하고자 했지만, 기기 종류가 워낙 광범위하고 요건도 제각각이어서 그렇게 안 된다는 사실을 깨달았다. 현재 기기 별 API가 따로 구현된 게이트웨이를 사용하며 구현코드는 클라이언트 기기 팀이 소유/개발 한다.
게이트웨이 첫 버전에서는 각 클라이언트 팀이 API 라우팅/조합 등을 수행하는 그루비 스크립트를 작성해서 구현했다. 각 스크립트는 서비스팀에서 받은 자바 클라이언트 라이브러리로 하나 이상의 서비스 API를 호출하는 방식이다. 이 방식은 별 문제가 없었고 클라이언트 개발자는 스크립트를 수천개 작성했다. 넷플릭스 API 게이트웨이는 매일 수십억건의 요청을 처리하고, API 호출당 평균 6~7개의 백엔드 서비스가 관여한다. 이런 모놀리식 아키텍처가 너무 무겁고 관리하기 어렵다는 것을 넷플릭스는 깨달았다.
지금도 넷플릭스는 BFF 패턴과 유사한 API 게이트웨이 아키텍처로 이전하는 중이며, 클라이언트 팀은 새로운 아키텍쳐에서 Node.js 모듈로 개발한다. 각 API 모듈은 자체 도커 컨테이너로 실행되지만 스크립트가 서비스를 직접 호출하는 것이 아니라, 넷플릭스 팔코를 이용하여 서비스 API를 표출한 부차 ‘API 게이트웨이’를 호출한다.
넷플릭스 팔코는 선언적으로 API를 동적조합하는 API 기술로서, 클라이언트는 요청 한 번으로 여러 서비스를 호출할 수 있다. 이런 아키첵처 덕분에 API 모둘이 서로 분리되어 신뢰성/관측성이 향상되고 클라이언트 API 모듈은 독립적으로 확장할 수 있다
다음과 같은 문제를 검토해보자.
외부 요청은 모두 게이트웨이를 거쳐야하므로 성능이 매우 중요하다.
동기 I/O를 사용할지 비동기 I/O를 사용할지 하는 문제는 성능 및 확장성에 가장 큰 영향을 미치는 설계이다.
논블로킹 I/O를 쓰는 것이 전체적으로 정말 더 나은선택인지는 게이트웨이 요청 처리로직의 성격마다 다르다. 실제로 넷플릭스도 자사 엣지 서버인 주울을 NIO로 재작성했을 때 엇갈린 결과를 얻었다.
NIO를 적용한 이후 네트워크 접속 비용은 예상대로 감소했다. 접속할 때마다 스레드를 배정할 필요가 없기 때문이다. I/O 집약적 로직을 수행했던 주울 클러스터의 처리율은 25% 증가하고 CPU 사용량은 25%감소했다. 반면에 CPU 집약적 로직을 수행하는 주울 클러스터는 전혀 개선되지 않았다.
@RestController
public class OrderDetailController {
@RequstMapping("/order/{orderId}"}
public OrderDetails getOrderDetails(@PathVariable String orderId) {
OrderInfo orderInfo = orderService.findOrderById(orderId);
TicketInfo ticketInfo = KitchenService.findTicketByOrderId(orderId);
DeliveryInfo deliveryInfo = deliveryService.findDeliveryByOrderId(orderId);
BillInfo billInfo = accountingService.findBillByOrderId(orderId);
OrderDetails orderDetails = OrderDetails.makeOrderDetails(orderInfo, ticketInfo, deliveryInfo, billInfo);
return orderDetails;
}
서비스를 순차 호출하면 결국 각 서비스의 응답 시간을 합한 시간만큼 기다려야 한다. 응답시간을 줄이기 위해 동시에 서비스를 호출해야 한다. 위의 예제처럼 서비스 호출 간 디펜던시가 전혀 없는 경우, 모든 서비스를 동시 호출하면 응답 시간이 현저히 줄어든다.
부하 분산기 후면에 여러 게이트웨이 인스턴스를 두고 가동하면된다. 특정 인스턴스가 실패하면 부하분산기가 알아서 요청을 다른 인스턴스에 라우팅하게 될 것이다.
실패한 요청, 지연시간이 너무 긴 요청도 잘 처리해야 한다.
3장의 회로 차단기 패턴 (Circuit Breaker Pattern)을 이용하자.
서비스 디스커버리 패턴(3장)을 이용하면 API 게이트웨이 같은 서비스 클라이언트가 자신이 호출한 서비스 인스턴스의 네트워크 위치를 파악할 수 있다. 관측성 패턴(11장)을 활용하면 개발자가 애플리케이션 동작 상태를 모니터링하고 문제를 진단하는데 도움이 된다. API 게이트웨이도 다른 서비스처럼 아키텍처에 알맞게 선정된 패턴으로 구현해야 한다.
게이트웨이의 대표적인 역할.
API 게이트웨이를 구현하는 다음 두가지 방법.
아마존에서 제공하는 서비스이다. 하나 이상의 HTTP 메서드를 지원하는 REST 리소스 세트이다. 각각의(메소드, 리소스)를 백엔드(AWS람다함수, 서버에서 정의한 HTTP 서비스, AWS 서비스 등)로 라우팅할 수 있게 구성한다. 템플릿 기번 요청/응답을 반환하도록 구성도 가능하고 요청 인증기능도 내장 되어 있다.
단점은 API 조합을 지원하지 않기 때문에 직접 백엔드 서비스에 조합로직을 구현해야 한다. 그리고 주로 JSON 위주의 HTTPS만, 서버쪽 디스커버리 패턴만 지원한다. 애플리케이션은 보통 AWS ELB로 EC2 인스턴스나 ECS 컨테이너에 요청을 부하 분산한다. 이런 단점은 있지만 API 조합이 굳이 필요없다면 AWS 게이트웨이를 사용하면 된다.
http, https, 웹소켓, http/2용 부하 분산기이다. 제공하는 기능은 AWS API 게이트웨이와 비슷하다. AWS EC2인스턴스에서 가동중인 백엔드 서비스로 요청을 라우팅하는 규칙이 ALB에 정의되어 있다. API 게이트웨이로 기본적인 요건이 충족되지만 기능이 제한적이고 http 메서드 기반의 라우팅, API 조합, 인증같은 로직은 없다.
다음 두가지 이슈를 검토해야한다
라우팅, 사용량제한, 인증 같은 엣지 기능이 탑재된 프레임워크이다. 개념 자체는 재사용 가능 요청 인터셉터같은 서플릿 펄터, 또는 Node.js 익스프레스 미들웨어와 비슷하다. HTTP 요청을 변환하는 필터 체인을 적절히 조합해서 요청을 처리하고, 백엔드 서비스를 호출 후 클라이언트에 반환하기 직전에 응답을 가공한다. 피보탈사가 개발한 클라우드 주울을 사용하면 주울기반의 서버를 구성보다 관습 방식으로 아주 손쉽게 개발 가능하다.
경로 기반의 라우팅만 지원된다.
스프링 5, 부트2, 웹플럭스 등의 프레임워크를 토대로한 API 게이트웨이 프레임워크이다. 리액터 프로젝트는 Mono 추상체를 제공하는 NIO기반의 JVM 리액티브 프레임워크이다. 스프링 클라우드 게이트웨이는 단순하지만 범용적인 수단을 제공한다.
API 게이트웨이는 다음 패키지들로 구성된다.
orderConfiguration은 주문관련 요청을 라우팅하는 스프링 빈이 정의된 클래스이다. 라우팅 규칙은 HTTP 메서드, 헤더, 경로를 조합하여 정한다. API 요청을 백엔드 서비스 URL에 매핑하는 규칙은 orderProxyRouting빈에 정의되어있다. 예를 들어 경로가 /order로 시작하는 요청은 OrderService로 보낸다.
OrderHandlerRouting 빈은 orderProxyRouting 빈에 정의된 규칙을 재정의한 규칙을 정의한다. API 요청을 핸들러 메서드, 즉 스프링 MVC 컨트롤러 메서드와 동등한 스프링 웹플록스 메서드에 매핑하는 규칙이다.
OrderConfiguration은 /orders 끝점을 구현한 스프링 빈이 정의된 스프링 구성클래스이다.
스프링 웹플럭스 라우팅 DSL로 정의된 요청 라우팅 규칙은 orderProxyRouting, orderHandlerRouting 빈에 있다. orderHandlers 빈은 API를 조합하는 요청핸들러이다.
@Configuration
@EnableConfigurationProperties(OrderDestinations.class)
public class OrderConfiguration {
@Bean
public RouteLocator orderProxyRouting(RouteLocatorBuilder builder, OrderDestinations orderDestinations) {
return builder.routes()
.route(r -> r.path("/orders").and().method("POST").uri(orderDestinations.getOrderServiceUrl()))
// 기본적으로 /orders로 시작하는 요청은 모두 orderDestinations.orderSerivceUrl URL로 라우팅
.route(r -> r.path("/orders").and().method("PUT").uri(orderDestinations.getOrderServiceUrl()))
.route(r -> r.path("/orders/**").and().method("POST").uri(orderDestinations.getOrderServiceUrl()))
.route(r -> r.path("/orders/**").and().method("PUT").uri(orderDestinations.getOrderServiceUrl()))
.route(r -> r.path("/orders").and().method("GET").uri(orderDestinations.getOrderHistoryServiceUrl()))
.build();
}
@Bean
public RouterFunction<ServerResponse> orderHandlerRouting(OrderHandlers orderHandlers) {
// GET /orders/{orderId} 를 orderHandlers::getOrderDetails로 라우팅
return RouterFunctions.route(GET("/orders/{orderId}"), orderHandlers::getOrderDetails);
}
@Bean
public OrderHandlers orderHandlers(OrderServiceProxy orderService, KitchenService kitchenService,
DeliveryService deliveryService, AccountingService accountingService) {
/ // 사용자 정의(custom, 커스텀) 요청 처리 로직이 구현된 빈
return new OrderHandlers(orderService, kitchenService, deliveryService, accountingService);
}
@Bean
public WebClient webClient() {
return WebClient.create();
}
}
코드에서 보다시피, OrderDestinations는 백엔드 서비스 URL의 외부화 구성이 가능한 스프링 구성 프로퍼티 클래스이다.
package net.chrisrichardson.ftgo.apiagateway.orders;
import org.springframework.boot.context.properties.ConfigurationProperties;
import javax.validation.constraints.NotNull;
@ConfigurationProperties(prefix = "order.destinations")
public class OrderDestinations {
@NotNull
private String orderServiceUrl;
@NotNull
private String orderHistoryServiceUrl;
public String getOrderHistoryServiceUrl() {
return orderHistoryServiceUrl;
}
public void setOrderHistoryServiceUrl(String orderHistoryServiceUrl) {
this.orderHistoryServiceUrl = orderHistoryServiceUrl;
}
public String getOrderServiceUrl() {
return orderServiceUrl;
}
public void setOrderServiceUrl(String orderServiceUrl) {
this.orderServiceUrl = orderServiceUrl;
}
}
이 클래스 덕분에 주문 서비스 URL을 프로퍼티 파일에 order.destinations.orderServiceUrl로 지정하거나, OS 환경변수 ORDER_DESTINATIONS_ORDER_SERVIEC_URL로 지정할수 있다.
OrderHandlers 클래스에는 API 조합을 비롯한 사용자 정의로직이 구현된 요청 핸들러 메서드가 있다.
백엔드 서비스에 실제로 요청하는 여러 프록시 클래스가 이 클리스에 주입된다.
package net.chrisrichardson.ftgo.apiagateway.orders;
import net.chrisrichardson.ftgo.apiagateway.proxies.*;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple4;
import java.util.Optional;
import static org.springframework.web.reactive.function.BodyInserters.fromObject;
public class OrderHandlers {
private OrderServiceProxy orderService;
private KitchenService kitchenService;
private DeliveryService deliveryService;
private AccountingService accountingService;
public OrderHandlers(OrderServiceProxy orderService,
KitchenService kitchenService,
DeliveryService deliveryService,
AccountingService accountingService) {
this.orderService = orderService;
this.kitchenService = kitchenService;
this.deliveryService = deliveryService;
this.accountingService = accountingService;
}
public Mono<ServerResponse> getOrderDetails(ServerRequest serverRequest) {
String orderId = serverRequest.pathVariable("orderId");
Mono<OrderInfo> orderInfo = orderService.findOrderById(orderId);
Mono<Optional<TicketInfo>> ticketInfo = kitchenService
.findTicketById(orderId)
.map(Optional::of) // ticketInfo를 Optional<TicketInfo>로 변환
.onErrorReturn(Optional.empty()); // 서비스 호출이 실패하면 optional.empty() qksghks
Mono<Optional<DeliveryInfo>> deliveryInfo = deliveryService
.findDeliveryByOrderId(orderId)
.map(Optional::of)
.onErrorReturn(Optional.empty());
Mono<Optional<BillInfo>> billInfo = accountingService
.findBillByOrderId(orderId)
.map(Optional::of)
.onErrorReturn(Optional.empty());
Mono<Tuple4<OrderInfo, Optional<TicketInfo>, Optional<DeliveryInfo>, Optional<BillInfo>>> combined =
Mono.zip(orderInfo, ticketInfo, deliveryInfo, billInfo); //값 4개를 하나의 tuple4로 조합
Mono<OrderDetails> orderDetails = combined.map(OrderDetails::makeOrderDetails); // tuple4을 orderDetiails로 반환
return orderDetails.flatMap(od -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(fromObject(od)))
.onErrorResume(OrderNotFoundException.class, e -> ServerResponse.notFound().build());
}
}
getOrderDetails()는 API를 조합해서 주문내역을 조회하는 메서드이다. 리액터 프로젝트의 Mono 추상체를 이용하여 확장 가능한 리액티브 스타일로 작성했다. 자바 8의 CompletableFuture보다 더 기능이 많은 Mono 클래스에는 값 아니면 예외, 둘중 하나인 비동기 작업의 결과가 포함된다. 비동기 작업 결과 반환된 값을 변환하거나 조합할 수 있는 풍성한 API도 제공한다. Mono를 이용하면 동시성 코드를 간단하고 이해하기 쉽게 작성할 수 있다. 예제 8-4의 getOrderDetails()는 네 서비스를 병렬 호출한 결과를 조합해서 OrderDetails 객체를 생성한다.
getOrderDetails()는 스프링 웹플럭스에서 HTTP요청을 나타내는 ServerRequest객체를 매개변수로 받는다.
이 메서드는 다음과 같은 일을 수행한다.
getOrderDetails()는 Mono를 사용하기 때문에 어지럽고 읽기 어려운 콜백을 쓰지 않고도 서비스를 동시 호출하여 그 결과를 조합할 수 있다. Mono로 래핑된 서비스 API 호출 결과를 반환하는 서비스 프록시중 하나를 보자.
OrderServiceProxy는 주문 서비스용 원격 프록시 클래스이다. WebClient(스프링 웹플럭스의 리액티브 HTTP 클라이언트)로 주문서비스를 호출한다.
package net.chrisrichardson.ftgo.apiagateway.proxies;
import net.chrisrichardson.ftgo.apiagateway.orders.OrderDestinations;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@Service
public class OrderServiceProxy {
private OrderDestinations orderDestinations;
private WebClient client;
public OrderServiceProxy(OrderDestinations orderDestinations, WebClient client) {
this.orderDestinations = orderDestinations;
this.client = client;
}
public Mono<OrderInfo> findOrderById(String orderId) {
Mono<ClientResponse> response = client
.get()
.uri(orderDestinations.getOrderServiceUrl() + "/orders/{orderId}", orderId)
.exchange(); // 서비스 호출
return response.flatMap(resp -> {
switch (resp.statusCode()) {
case OK:
return resp.bodyToMono(OrderInfo.class); // 응답본문을 OrderInfo로 변환
case NOT_FOUND:
return Mono.error(new OrderNotFoundException());
default:
return Mono.error(new RuntimeException("Unknown" + resp.statusCode()));
}
});
}
}
OrderInfo를 조회하는 findOrderById()는 WebClient로 주문 서비스에 Http요청을 한다.
그리고 수신한 JSON 응답을 OrderInfo로 역직렬화 한다. WebClient는 리액티브 API를 갖고 있어서 응답을 Mono로 래핑하고, findOrder()는 flatMap()으로 Mono를 Mono로 변환한다. bodyToMono()는 그 이름처럼 응답 본문을 반환형 Mono로 바꾼다.
ApiGatewayApplication은 API게이트웨이의 main() 메서드가 위치한 표준 스프링 부트 메인 클래스이다.
package net.chrisrichardson.ftgo.apiagateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@SpringBootApplication
@ComponentScan
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
스프링 클라우드 게이트웨이는 API게이트웨이를 구현하기에 딱 알맞은 프레임워키이다. 간결한 라우팅 규칙 DSL로 기본적인 프록시 동작을 구성할 수 있고, API 조합 및 프로토콜 변환을 수행하는 핸들러 메서드로 쉽게 요청을 라우팅할 수 있다. 스프링 클라우드 게이트웨이는 확장 가능하면서 리액티브한 스프링5와 리액터 프로젝트 프레임워크가 잘 버무려진 작품이다. 그래프 기반의 쿼리 언어를 제공하는 GraphQL역시 직접 API 게이트웨이를 개발할 때 쓸만한 프레임워크이다.
주문내역을 반환하는 /orders
API를 게이트웨이에서 구현한다고 할때 여러 서비스에서 데이터를 가져오는 앤드포인트라 결과를 조합하는 API 조합 코드를 작성해야 한다.
클라이언트마다 필요한 데이터가 다를수도 있다. 쿼리매개변수로 구분짓거나 BFF패턴을 적용해서 여러 앤드포인트를 정의할수 있지만, 수많은 API 앤드포인트를 전부 이런식으로 처리하기엔 버겁다.
API 게이트웨이에서 별의 별 클라이언트를 지원하는 REST API를 구현하는것은 시간낭비이다.
그래프 기반 API프레임 워크는 그래프 기반의 스키마로 서버API를 구성하는 것이 핵심이 된다. 그림 8-9. 그래프 기반 스키마는 프로퍼티(필드) 및 다른 노드와 연관된 노드(타입)을 정의한다. 클라이언트는 그래프 노드와 이들의 프로퍼티/관계 단위로 필요한 데이터를 지정해서 조회하기 때문에 API 게이트웨이로 원하는 데이터를 한 번에 모두 가져올 수 있다.
그래프 기반 API의 기술장점 두가지.
NOTE: 스키마 주도 API 기술
GraphQL기반 API 게이트웨이는 Node.js 익스프레스 웹 프레임워크 및 아폴로 GraphQL 서버를 이용하여 자바스크립트로 개발한다.
GraphQL은 스키마 중심적이다. 서버 쪽 데이터 모델과 작업(클라이언트가 수행가능한 쿼리)의 구조를 정의한 타입들로 구성된다. GraphQL에는 여러 종류의 타입이 있다. 주로 많이 쓰는 객체형과 자바 이넘과 비슷한 두가지 타입만 사용한다.
GraphQL에서는 스키마에서 지원되는 쿼리를 필드로 정의한다. 스키마 쿼리는 관례상 Query라는 객체형을 선언해서 정의한다. Query객체의 각 필드는 옵션 매개변수가 있는 네임드 쿼리와 반환형이다.
아래는 GraphQL기반으로 작성한 FTGO API 게이트웨이이다.
각각 consumer, order, restaurant 엔티티와 대응된다. 스키마 쿼리를 정의한 Query 객체형도 있다.
const typeDefs = gql`
type Query { // 클라이언트에서 실행 가능한 쿼리를 정의
orders(consumerId : Int!): [Order]
order(orderId : Int!): Order
consumer(consumerId : Int!): Consumer
}
type Mutation {
createConsumer(c : ConsumerInfo) : Consumer
}
type Order {
orderId: ID,
consumerId : Int,
consumer: Consumer
restaurant: Restaurant
deliveryInfo : DeliveryInfo
}
type Restaurant {
id: ID
name: String
}
type Consumer {
id: ID // 소비자 식별용 ID
firstName: String
lastName: String
orders: [Order]
} // 한 명의 소비자는 주문을 여러 건 할 수 있음
input ConsumerInfo {
firstName: String
lastName: String
}
type DeliveryInfo {
status : DeliveryStatus
estimatedDeliveryTime : Int
assignedCourier :String
}
enum DeliveryStatus {
PREPARING
READY_FOR_PICKUP
PICKED_UP
DELIVERED
}
`;
자세히 보면 객체형 Consumer, Order, Restaurant, DeliveryInfo는 해당 자바 클래스와 구조가 비슷하다. 유일한 차이점은 식별자를 나타내는 ID형 뿐이다.
이 스키마에는 다음 세 쿼리가 정의되어 있다.
GraphQL은 클라이언트가 반환 데이터를 좌지우지할 수 있게 강력한 제어권을 부여한다. 비결은 클라이언트가 GraphQL쿼리를 실행하는 방법에 있다.
가장 큰 매력은 클라이언트가 반환 데이터를 쿼리언어로 자유롭게 제어할 수 있다는 점이다. 클라이언트는 쿼리 문서가 담긴 요청을 서버에 전송하여 쿼리를 실행한다. 다음과 같이 소비자가 firstName/lastName를 조회하는 간단한 쿼리는 쿼리명, 인수 값, 반환될 결과 객체 필드를 쿼리 문서에 명시한다.
이 쿼리는 주어진 Consumer의 firstName, lastName 필드를 반환한다. 이번에는 소비자 정보와 이 소비자가 한 주문들, 그리고 주문별 음식점 ID와 음식점명을 반환하는 약간 복잡한 쿼리이다.
이 쿼리는 소비자의 주문정보, 주문별 음식점 정보처럼 Consumer에 없는 데이터를 서버에 요청한다. 이렇게 GraphQL 클라이언트는 타동적으로 연관된 객체필드 등 원하는 데이터를 정확히 특정할 수 있습니다.
GraphQL 서버는 쿼리 실행 시 하나 이상의 데이터 저장소에서 요청한 데이터를 가져와야한다.
애플리케이션에서 GraphQL 서버가 데이터를 소유한 서비스 API를 하나하나 호출해야 할 것이다. 스키마에 정의된 객체형 필드에 리졸버 함수를 붙이면 GraphQL 스키마를 데이터 소스와 연관지을 수 있다. GraphQL 서버는 처음에는 최상위 쿼리로 데이터를 가져와 결과 객체들의 필드를 재귀 조회하는 리졸버 함수를 호출하여 API를 조회한다.
예제 8-8은 아폴로 GraphQL 서버를 이용하여 리졸버를 정의한 코드이다. 이중 중첩된 자바스크립트 객체를 생성한다. 최상위 프로퍼티는 각각 쿼리, 오더 같은 객체형과 대응된다. Order.cousnmer 같은 2차 프로퍼티는 각각 해당 필드의 리졸버 함수를 정의한다.
// **예제 8-8 graphQL 스키마 필드에 리졸버 함수를 붙인다.**
const resolvers = { // 리졸버 정의
Query: { // orders 쿼리 리졸버
orders: resolveOrders,
consumer: resolveConsumer,
order: resolveOrder
},
Mutation: {
createConsumer: createConsumer
},
Order: {
consumer: resolveOrderConsumer, //Order.consumer 필드 리졸버
restaurant: resolveOrderRestaurant,
deliveryInfo: resolveOrderDeliveryInfo
},
Consumer: {
orders: resolveConsumerOrders
}
};
리졸버 함수는 다음 세 매개변수를 받는다.
리졸버 함수는 단일 서비스를 호출하거나 API를 조합해서 여러 서비스의 데이터를 가져올 수 있다. 아폴로 GraphQL 서버의 리졸버 함수는 자바 CompletableFuture의 자바스크립트 버전에 해당하는 프라미스를 반환한다.
GraphQL은 재귀 알고리즘을 이용하여 리졸버 함수를 실행한다. 제일 먼저 Query문서에 지정된 최상위 쿼리의 리졸버 함수를 실행하고, 쿼리가 반환된 객체마다 Query 문서에 지정된 필드를 하나씩 순회한다. 만약 필드에 리졸버가 달려있으면 객체 및 Query문서의 인수를 리졸버에 전달하여 호출한다. 그리고 다시 이 리졸버가 반환된 객체들로 재귀한다.
이런식으로 리졸버를 실행하면 언젠가 여러 서비스에서 조회한 데이터로 가득한 Consumer 객체가 만들어진다.
GraphQL 쿼리실행시 각각의 리졸버는 독립적으로 실행되므로 서비스 왕복횟수가 길어지면 성능이 떨어질 위험이 있다. 주문이 N개 있으면 소비자 서비스에 한번, 주문 이력서비스에 한번, 음식점 서비스에 N번 호출하게 될 것이다.
NodeJs기반의 GraphQL은 데이터로더 모듈을 사용해서 배치/캐싱을 구현한다.
const DataLoader = require('dataloader');
class RestaurantServiceProxy {
constructor(options) {
// 데이터로더를 생성하여 batchFindRestaurants()를 배치 함수로 사용
this.dataLoader = new DataLoader(restaurantIds => this.batchFindRestaurants(restaurantIds));
}
// 주어진 음식점을 데이터로더로 로딩
findRestaurant(restaurantId) {
return this.dataLoader.load(restaurantId);
}
// 여러 음식점을 배치 로딩
batchFindRestaurants(restaurantIds) {
console.log("restaurantIds=", restaurantIds);
return Promise.all(restaurantIds.map(k => this.findRestaurantInternal(k)));
}
}
const { makeExecutableSchema } = require("graphql-tools");
const fetch = require("node-fetch");
const gql = String.raw;
// Construct a schema, using GraphQL schema language
// TODO need order(orderId) which does API composition
const typeDefs = gql` // graphQL 스키마 정의
type Query {
orders(consumerId : Int!): [Order]
order(orderId : Int!): Order
consumer(consumerId : Int!): Consumer
}
type Mutation {
createConsumer(c : ConsumerInfo) : Consumer
}
type Order {
orderId: ID,
consumerId : Int,
consumer: Consumer
restaurant: Restaurant
deliveryInfo : DeliveryInfo
}
type Restaurant {
id: ID
name: String
}
type Consumer {
id: ID
firstName: String
lastName: String
orders: [Order]
}
input ConsumerInfo {
firstName: String
lastName: String
}
type DeliveryInfo {
status : DeliveryStatus
estimatedDeliveryTime : Int
assignedCourier :String
}
enum DeliveryStatus {
PREPARING
READY_FOR_PICKUP
PICKED_UP
DELIVERED
}
`;
function resolveOrders(_, { consumerId }, context) {
return context.orderServiceProxy.findOrders(consumerId);
}
function resolveConsumer(_, { consumerId }, context) {
return context.consumerServiceProxy.findConsumer(consumerId);
}
function resolveOrder(_, { orderId }, context) {
return context.orderServiceProxy.findOrder(orderId);
}
function resolveOrderConsumer({consumerId}, args, context) {
return context.consumerServiceProxy.findConsumer(consumerId);
}
function resolveOrderRestaurant({restaurantId}, args, context) {
return context.restaurantServiceProxy.findRestaurant(restaurantId);
}
function resolveOrderDeliveryInfo({orderId}, args, context) {
return context.deliveryServiceProxy.findDeliveryForOrder(orderId);
}
function resolveConsumerOrders({id, orders}, args, context) {
return orders || context.orderServiceProxy.findOrders(id)
}
function createConsumer(_, { c: {firstName, lastName} }, context) {
return context.consumerServiceProxy.createConsumer(firstName, lastName);
}
const resolvers = { // 리졸버 정의
Query: {
orders: resolveOrders,
consumer: resolveConsumer,
order: resolveOrder
},
Mutation: {
createConsumer: createConsumer
},
Order: {
consumer: resolveOrderConsumer,
restaurant: resolveOrderRestaurant,
deliveryInfo: resolveOrderDeliveryInfo
},
Consumer: {
orders: resolveConsumerOrders
}
};
const schema = makeExecutableSchema({ typeDefs, resolvers }); // 스키마와 리졸버를 조합하여 실행가능한 스키마 생성
module.exports = { schema };
const app = express();
function makeContextWithDependencies(req: Request) { // 레포지터리를 리졸버에서 쓸 수 있게 컨텍스트에 주입
const orderServiceProxy = new OrderServiceProxy({baseUrl: process.env.ORDER_HISTORY_SERVICE_URL || "http://localhost:8080"});
const consumerServiceProxy = new ConsumerServiceProxy({baseUrl: process.env.CONSUMER_SERVICE_URL || "http://localhost:8080"});
const restaurantServiceProxy = new RestaurantServiceProxy({baseUrl: process.env.RESTAURANT_SERVICE_URL || "http://localhost:8080"});
return {orderServiceProxy, consumerServiceProxy, restaurantServiceProxy};
}
function makeGraphQLHandler() { // 실행 가능한 스키마를 상대로 graphQL 쿼리를 실행하는 익스프레스 요청 핸들러 생성
return graphqlExpress((req: Request) => {
console.log("req=", req.url);
return {schema: schema, context: makeContextWithDependencies(req)}
});
}
app.post('/graphql', bodyParser.json(), makeGraphQLHandler()); // POST /graphql, GET /graphql 끝점을 graphQL 서버로 라우팅
app.get('/graphql', makeGraphQLHandler());
app.listen(PORT);
GraphQL 서버는 HTTP 기반의 API를 제공하므로 적당한 HTTP 라이브러리를 이용해서 요청하면 된다.
하지만 요청 포맷을 적절히 잘 맞추어 주면서 클라이언트 쪽 캐싱등의 기능까지 제공하는 GraphQL 클라이언트 라이브러리를 사용하는 것이 더 쉽다.
class FtgoGraphQLClient {
constructor(options) {
this.client = new ApolloClient({...}_
}
findConsumerWithOrders(consumerId) {
return this.client.query({
variables: { cid: consumerId}, // $cid값 제공
query: gql`
query foo($cid : Int!) { // $cid를 Int형 변수로 정의
consumer(consumerId: $cid) { // 쿼리 매개변수 consumerId 값을 $cid로 세팅
id
firstName
lastName
orders { orderId restaurant { name } }
}
} `,
})
}
}
클라이언트가 원하는 데이터를 조회하는 다양한 쿼리를 FtgoGraphQLClient 클래스에 정의할 수 있다.
GraphQL
은 그래프 기반의 쿼리언어를 제공하는 프레임워크이다. 스프링 클라우드 게이트웨이와 더불어 API 게이트웨이 개발의 쌍벽을 이루는 기술이다. 그래프 지향 스키마를 작성하여 서버쪽 데이터 모델과 이 모델이 지원하는 퀴리를 기술한 후, 데이터를 조회하는 리졸버를 작성해서 스키마를 서비스에 매핑한다. GraphQL에 기반한 클라이언트는 서버가 정확히 어떤 데이터를 반환해야 하는지 기술된 스키마를 대상으로 쿼리한다. GraphQL 기반 API 게이트웨이는 다양한 클라이언트를 지원한다.