GraphQL 그리고 MSA
MSA 환경에서 일하는 Front-end 개발자들을 만나면 나는 종종 이런 말을 듣는다.
주문서 화면을 만들 때 4~5개를 호출해서 조합해야 했어요. - 개발자 A
기부 상세 화면을 만드는데 같은 기부 번호로 여러 API를 호출해서 조합하고 있어요 - 개발자 B
벡엑드 개발자분이 API를 너무 잘 개 만들어 놔서 하나의 화면을 만들때 여러 번 호출하는게 너무 불편합니다. 에러 처리하기도 그렇고요. 한 번의 호출로 만들어 달라고 요청했는데 거부 당했습니다. - 개발자 C
무엇이 문제인가?
문제는 사용 예측이 어렵고 변화가 많은 Query 다
MSA 환경에서는 하나의 시스템을 여러 개의 작은 물리적인 시스템 즉 마이크로서비스로 나누고 각각의 서비스는 자신만의 데이터베이스를 사용하며 외부에 API(이 글에서 API는 별도 설명이 없으면 REST를 의미한다)를 노출한다.
일반적으로 클라이언트의 API 사용 패턴을 살펴보면 클라이언트 유형(Mobile App, Browser 등)이 다르더라도 CUD(Create,Update,Delete)는 비슷하지만 R(Read) 즉, 조회는 가지 각색임을 알 수 있는데 그 이유는 사용하는 맥락에 따라 화면이 달라지기 때문에 필요한 데이터가 다른 것이다.
Front-end에서 잘게 쪼개진 마아크로서비스의 API를 이용하여 화면을 만들 때 두 가지 문제에 직면한다. UnderFetching과 OverFetching이라고 부르는 문제이다.
먼저 UnderFeching은 클라이언트가 한 번의 호출로 원하는 데이터를 받지 못해서 여러 번 호출하는 것을 의미하는데 에러 처리 문제뿐만 아니라 API 호출 횟수를 증가시킨다.
OverFetching은 클라이언트가 불필요한 데이터를 받는 것을 의미한다. 클라이언트 유형이 다른데도 불구하고 하나의 조회 API를 제공한다면 가장 데이터가 가장 많이 필요한 클라이언트에 맞출 수밖에 없다. 그렇게 되면 다른 클라이언트는 사용하지 않는 불필요한 데이터를 받는다. OverFetching은 네트워크 낭비를 부른다.
API 명세는 쓰임새가 주도한다
UnderFeching/OverFetching을 자세히 다루기 전에 이런 문제가 발생하는 본질적인 문제를 얘기해 보자.
API는 크게 API를 만들어 공급하는 공급자(Provider)와 API를 사용하는 소비자(Consumer)로 나눌 수 있으며 공급자와 소비자의 접점에는 API 명세가 있다. 나는 앞서 조회에서 발생하는 UnderFetching/OverFetching 문제를 언급했지만 본질적인 원인을 따져보면 API를 사용하는 소비자가 아니라 API를 만드는 공급자 주도로 API가 만들어지기 때문이다.
API 명세를 만들 때 공급자 주도로 만들면 어떻게 될까? 일단 빨리 만들 수 있을 것이다. 내가 경험한 벡엔드 개발자들 중의 일부는 데이터베이스 테이블의 스키마를 거의 그대로 API 명세에 사용했다.
전에 경험했던 프로젝트에서 엔터티(데이터베이스 테이블과 매핑되는 객체)를 REST API로 가감없이 노출해서 사용했던 적이 있다. 결과적으로 클라이언트는 API로 내려오는 엔터티를 해석해야 했으며 엔터티 변경에 매우 취약했고 엔터티에 안에 있어야 할 풍부한 비즈니스 로직은 없어지고 단순한 데이터 운반체와 데이터베이스 테이블에 매핑만 하는 객체로 전락했다. - https://www.popit.kr/커머스-코드-자산화-개발일지-1-시작/
공급자 주도로 API 명세를 만들면 소비자는 이를 해석해야 한다. 왜냐하면 소비자의 쓰임새를 고려하지 않고 만들었기 때문에 이를 맞추는 과정에서 비용이 발생하는 것이다. 시스템을 만드는 과정에서 가장 큰 비용은 개발이 아니다. 바로 의사소통 비용이다.
일반적으로 시장에서는 고객이 우선이다. 고객에게 맞춘다. 고객에게 맞추지 못한 제품은 경쟁에서 밀려 도태된다. 이것은 매우 단순한 원리이다. API 역시 마찬가지이다. 나는 소비자의 쓰임새에 맞추어 API는 만들어져야 한다고 믿는다. 다시 말해 API 명세는 API를 만드는 공급자 아니라 API를 사용하는 소비자의 쓰임새가 주도해야 한다.
API Composition 패턴
UnderFeching 문제를 어떻게 해결할 수 있을까? API의 조합의 책임을 소비자가 아니라 공급자가 가져갈 수 있다. 소비자는 한 번의 호출로 데이터를 얻을 수 있을 테니 UnderFetching 문제를 해결할 수 있을 것이다. 크리스 리차드슨은 그의 책 <마이크로서비스 패턴>에서 API Composition 패턴으로 소개한다.
BFF(Backend for Frontend) 패턴
앞서 예시처럼 FindOrderService라는 서비스를 만들 수도 있지만 API 소비자는 유형이 다양해지면 그에 따라 원하는 데이터가 형태가 달라진다. 이렇게 발생하는 소비자의 OverFetching 문제는 BFF 패턴으로 해결할 수 있다. BFF(Backend for Frontend) 패턴은 소비자 유형에 따라 적합한 데이터 형태를 만든다.
소비자는 한 번에 내가 원하는 데이터만 받기 원한다
앞서 언급했듯이 API의 명세는 소비자 주도적이어야 한다. 그렇다면 소비자가 ‘원하는 데이터’는 어떻게 알 수 있을까?
소프트웨어 공학에서 요구사항을 말할 때면 빠지지 않고 나오는 말이 있다. 요구사항은 본인도 모른다는 것이다. 그래서 애자일 진영에서는 동작하는 코드를 만들어 빠르게 보여주고 피드백을 받아 점진적으로 시스템을 개선한다. API 역시 마찬가지이다. 소비자 역시 한 번에 자신이 원하는 데이터를 알기 어렵다. 여러 번 시도해 보고 필요한 데이터와 불필요한 데이터를 아는 것이다. 또한 원하는 데이터는 계속 변한다. API의 유연성이 필요한 지점이다.
관계형 데이터베이스에 질의할 때 사용하는 SQL을 떠올려 보자. 원하는 데이터를 얻기 위해 먼저 테이블 스키마를 확인한다. 그리고 SELECT 절에 원하는 칼럼을 적으면 해당 데이터만 준다. API도 마치 SQL처럼 스키마를 보고 원하는 데이터만 선택하여 받는다면 어떨까? 이렇게 하면 화면이 자주 바뀌는 환경에서 계속 API를 바꾸어야 하는 부담을 줄일 수 있을지 모른다. 이것이 GraphQL의 접근 방식이다.
GraphQL
GraphQL은 메타(Meta)에서 개발한 질의 언어(Query Language)로 HTTP 환경에서 동작한다. 아래는 REST와 GraphQL의 차이를 보여준다.
REST는 리소스에 접근할 때 리소스마다 다른 URL을 사용하는데 비해 GraphQL은 하나의 URL(UnderFetching)을 사용하고 리소스 특정하는 수단으로 스키마를 사용한다. API 소비자는 GraphQL 스키마를 확인하고 원하는 리소스를 HTTP Body에 기술하여 요청하면 GraphQL 서버는 해당 데이터(OverFetching)만 반환한다.
MSA 환경에서 API Composition의 책임을 GraphQL이 맡는다면?
설명이 길었다. 말보다는 코드다. 코드로 만들고 동작을 확인해 보자. 대강의 흐름은 아래와 같다.
- 클라이언트는 API Gateway로 원하는 데이터를 한 번만 요청한다.
- API Gateway는 요청을 인증 후 GraphQL Server로 라우팅한다.
- GraphQL Server는 마이크로서비스의 REST API를 호출, 조합하여 클라이언트가 원하는 데이터만 반환한다.
테스트 대역으로 마이크로서비스 만들기
위의 그림에서 OrderService와 TicketService를 만드는 것은 이 글에서 다루는 대상이 아니다. 따라서 Mountebank 을 사용하여 테스트 대역(Test Double)으로 만든다. 참고로 Mountebank은 Node 기반의 HTTP 서버를 테스트 대역으로 만들어 주는 도구이다. 아래와 같이 설치하고 실행한다.
1 2
$ npm install -g mountebank $ mb
Mountebank이 실행된 상태에서 아래 명령어를 실행하면 8081 포트로 OrderService가 만들어진다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
$ curl -i -X POST -H 'Content-Type: application/json' http://localhost:2525/imposters --data '{ "port": 8081, "protocol": "http", "recordRequests": true, "stubs": [ { "predicates": [ { "equals": { "method": "GET", "path": "/orders/100" } } ], "responses": [ { "is": { "statusCode": 200, "headers": { "Content-Type": "application/json" }, "body": "{\"id\":100,\"orderDate\":\"2022-01-04T00:00:00Z\",\"memberId\":200,\"subTotalAmount\":9091,\"vatAmount\":909,\"totalAmount\":10000,\"products\":[{\"id\":1222,\"name\":\"플래너(파랑)\",\"price\":5000,\"quantity\":1},{\"id\":1223,\"name\":\"플래너(빨강)\",\"price\":5000,\"quantity\":1}],\"member\":{\"id\":200,\"name\":\"유영모\"}}" } } ] }, { "responses": [ { "is": { "statusCode": 404 } } ] } ] }'
브라우저에서 http://localhost:8081/orders/100 접속하면 OrderService에서 아래와 같은 데이터를 반환하는 것을 확인할 수 있다.
이번에는 TicketService 차례다. Mountebank이 실행된 상태에서 아래 명령어를 실행하면 8082 포트로 TicketService가 만들어진다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
$ curl -i -X POST -H 'Content-Type: application/json' http://localhost:2525/imposters --data '{ "port": 8082, "protocol": "http", "recordRequests": true, "stubs": [ { "predicates": [ { "equals": { "method": "GET", "path": "/tickets", "query": { "orderId": "100" } } } ], "responses": [ { "is": { "statusCode": 200, "headers": { "Content-Type": "application/json" }, "body": "[{\"id\":233999,\"orderId\":100,\"requestedDeliveryTime\":\"2022-01-04T00:00:00Z\",\"preparedByTime\":\"2022-01-04T00:00:00Z\"}]" } } ] }, { "responses": [ { "is": { "statusCode": 404 } } ] } ] }'
브라우저에서 http://localhost:8082/tickets?orderId=100 접속하면 아래와 같은 데이터를 확인할 수 있다.
GraphQL 서버 만들기
앞서 만든 두 개의 서비스 API를 조합하는 GraphQL 서버를 만들어보자. GraphQL 서버의 구현체는 언어별도 다양하다. 이 글에서는 Golang으로 만든 gqlgen 을 사용한다.
먼저 아래 명령어로 프로젝트를 셋업한다.
1 2 3 4 5 6
$ mkdir example $ cd example $ go mod init example printf '// +build tools\npackage tools\nimport (_ "github.com/99designs/gqlgen"\n _ "github.com/99designs/gqlgen/graphql/introspection")' | gofmt > tools.go $ go mod tidy $ go run github.com/99designs/gqlgen init
작업이 끝나면 아래 명령어로 GraphQL 서버를 실행한다.
1
$ go run server.go
gqlgen GraphQL 서버는 기본적으로 웹 브라우저에서 쿼리를 테스트할 수 있는 환경(GrpahQL Playground)을 지원한다.
앞서 GraphQL은 스키마를 가진 언어라고 설명했다. gqlgen 은 정의된 스키마를 기준으로 코드를 자동 생성하여 개발한다.
스키마를 정의해 보자. 프로젝트 셋업시 자동 생성된 파일 중 schema.graphqls 파일을 아래 처럼 변경한다. 테스트를 위해 스키마를 서비스에서 반환하는 값과 다르게 작성하였다. GraphQL 스키마 정의에 대한 자세한 내용은 https://graphql.org/learn/schema/ 에서 찾을 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
type Order { id: ID! orderDate: String! member: Member! subTotalAmount: Int! vatAmount: Int! totalAmount: Int! lineItems: [LineItem!]! tickets: [Ticket!]! } type Member { id: ID!, name: String! } type LineItem { id: ID!, name: String!, price: Int! quantity: Int! } type Ticket { id: ID!, orderId: ID!, requestedDeliveryTime: String!, preparedByTime: String! } type Query { order(id: ID): Order }
아래 명령어로 변경된 스키마를 기준으로 코드를 생성한다.
1
$ go run github.com/99designs/gqlgen generate
명령어 실행 후 validation failed: packages.Load 오류 메시지를 확인할 수 있는데 shema_resolvers.go 파일의 아래 부분을 지우고 위의 명령어를 다시 실행한다.
이 작업이 끝나면 스키마에 정의된 요소들이 코드로 만들어진 것을 확인할 수 있다.
마지막으로 schema.resovlers.go 의 Order 함수 부분에 두 개 서비스 API를 호출하여 조합하는 코드를 작성한다. 전체 코드를 아래와 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. // Code generated by github.com/99designs/gqlgen version v0.17.22 import ( "article-graph-ql/graph/model" "context" "fmt" "github.com/bettercode-oss/rest" "strconv" ) // Order is the resolver for the order field. func (r *queryResolver) Order(ctx context.Context, id *string) (*model.Order, error) { order, err := fetchOrder(*id) if err != nil { return nil, err } tickets, err := fetchTickets(*id) if err != nil { return nil, err } order.Tickets = tickets return order, nil } func fetchOrder(orderId string) (*model.Order, error) { client := rest.Client{} orderResponse := struct { Id int `json:"id"` OrderDate string `json:"orderDate"` MemberId int `json:"memberId"` SubTotalAmount int `json:"subTotalAmount"` VatAmount int `json:"vatAmount"` TotalAmount int `json:"totalAmount"` Products []struct { Id int `json:"id"` Name string `json:"name"` Price int `json:"price"` Quantity int `json:"quantity"` } `json:"products"` Member struct { Id int `json:"id"` Name string `json:"name"` } `json:"member"` }{} err := client. Request(). SetResult(&orderResponse). Get(fmt.Sprintf("http://localhost:8081/orders/%v", orderId)) if err != nil { return nil, err } var lineItems []*model.LineItem for _, product := range orderResponse.Products { lineItems = append(lineItems, &model.LineItem{ ID: strconv.FormatInt(int64(product.Id), 10), Name: product.Name, Price: product.Price, Quantity: product.Quantity, }) } return &model.Order{ ID: strconv.FormatInt(int64(orderResponse.Id), 10), OrderDate: orderResponse.OrderDate, Member: &model.Member{ ID: strconv.FormatInt(int64(orderResponse.Member.Id), 10), Name: orderResponse.Member.Name, }, SubTotalAmount: orderResponse.SubTotalAmount, VatAmount: orderResponse.VatAmount, TotalAmount: orderResponse.TotalAmount, LineItems: lineItems, }, nil } func fetchTickets(orderId string) ([]*model.Ticket, error) { client := rest.Client{} var ticketResponse []struct { Id int `json:"id"` OrderId int `json:"orderId"` RequestedDeliveryTime string `json:"requestedDeliveryTime"` PreparedByTime string `json:"preparedByTime"` } err := client. Request(). SetResult(&ticketResponse). Get(fmt.Sprintf("http://localhost:8082/tickets?orderId=%v", orderId)) if err != nil { return nil, err } var tickets []*model.Ticket for _, res := range ticketResponse { tickets = append(tickets, &model.Ticket{ ID: strconv.FormatInt(int64(res.Id), 10), OrderID: strconv.FormatInt(int64(res.OrderId), 10), RequestedDeliveryTime: res.RequestedDeliveryTime, PreparedByTime: res.PreparedByTime, }) } return tickets, nil } // Query returns QueryResolver implementation. func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } type queryResolver struct{ *Resolver }
GraphQL 서버를 다시 실행하고 Playground에서 스키마의 원하는 필드만 기술하면 데이터를 반환하는 것을 확인할 수 있다.
Kong API Gateway 설치
API Gateway는 Kong을 사용한다. Kong을 설치하는 다양한 방법이 있지만 나는 도커를 사용했다.
https://docs.konghq.com/gateway/3.1.x/install/docker/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
$ docker network create kong-net $ docker run -d --name kong-database \ --network=kong-net \ -p 5432:5432 \ -e "POSTGRES_USER=kong" \ -e "POSTGRES_DB=kong" \ -e "POSTGRES_PASSWORD=kongpass" \ postgres:9.6 $ docker run -d --name kong-gateway \ --network=kong-net \ -e "KONG_DATABASE=postgres" \ -e "KONG_PG_HOST=kong-database" \ -e "KONG_PG_USER=kong" \ -e "KONG_PG_PASSWORD=kongpass" \ -e "KONG_PROXY_ACCESS_LOG=/dev/stdout" \ -e "KONG_ADMIN_ACCESS_LOG=/dev/stdout" \ -e "KONG_PROXY_ERROR_LOG=/dev/stderr" \ -e "KONG_ADMIN_ERROR_LOG=/dev/stderr" \ -e "KONG_ADMIN_LISTEN=0.0.0.0:8001, 0.0.0.0:8444 ssl" \ -p 8000:8000 \ -p 8443:8443 \ -p 127.0.0.1:8001:8001 \ -p 127.0.0.1:8444:8444 \ kong:3.1.1
Kong API Gateway와 GraphQL Server 연결
Kong에 벡엔드 서비스 API와 연결하기 위해서는 Kong Service와 Kong Route를 등록해야 한다.
아래 명령어로 Kong Service와 Kong Route를 등록한다.
1 2 3 4 5 6
$ curl -i -s -X POST http://localhost:8001/services \ --data name=composition-service \ --data url='http://docker.for.mac.localhost:8080' $ curl -i -X POST http://localhost:8001/services/composition-service/routes \ --data 'paths[]=/composition-service' \ --data name=composition-service-route
참고로 도커(Kong Gateway)에서 로컬 네트워크(GraphQL 서버)에 접근해야 하기 때문에 호스트를 docker.for.mac.localhost을 사용하였다.
이제 GraphQL 서버를 Kong Gateway로 연결을 완료 했다. 아래 Kong Gateway URL로 쿼리를 전송하면 반환하는 데이터를 확인할 수 있다.
http://localhost:8000/composition-service/query
브라우저에서 GraphQL 호출하기
브라우저에서 GraphQL을 호출해 보자. 브라우저는 동일 출처 정책(Same-origin policy)이라는 보안 정책이 있는데 Front-end와 Back-end를 분리할 때 일반적으로 다른 도메인을 사용하기 때문에 동일 출처 정책을 위반한다. 이를 해결하기 위한 방법이 CORS(Cross-Origin Resource Sharing)이다.
클라이언트에서 Kong Gateway를 호출하기 때문에 Kong Gateway에 CORS 설정이 필요하다.
https://docs.konghq.com/hub/kong-inc/cors/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
$ curl -X POST http://localhost:8001/services/composition-service/plugins \ --data "name=cors" \ --data "config.origins=*" \ --data "config.methods=GET" \ --data "config.methods=POST" \ --data "config.headers=Accept" \ --data "config.headers=Accept-Version" \ --data "config.headers=Content-Length" \ --data "config.headers=Content-MD5" \ --data "config.headers=Content-Type" \ --data "config.headers=Date" \ --data "config.headers=X-Auth-Token" \ --data "config.exposed_headers=X-Auth-Token" \ --data "config.credentials=true" \ --data "config.max_age=3600"
CORS 설정 후 Front-end에서는 아래와 같이 호출한다. 아래 코드는 Axois를 사용했다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
const data = { query: `{ orders { id, totalAmount lineItems { name, quantity }, member { id, name }, tickets { requestedDeliveryTime, preparedByTime } } }` } axios.post("http://localhost:8000/composition-service/query", data).then(res => { console.log("Graph QL Response"); console.log(res.data); }).catch(e => { // ... });
소프트웨어 엔지니어링은 트레이드 오프의 산물이다
자. 이제 문제가 모두 해결되었을까? 아니다. 조회에서 중요한 것이 캐싱인데 HTTP 구간에서 REST의 경우 ETag를 이용한 캐싱을 활용할 수 있지만 GraphQL은 어렵다. 이외에도 GraphQL의 단점은 존재한다. 결국 무언가를 얻으면 무언가를 잃는 것이다.
어떤 해결책이든 문제를 완전히 해결하지 못한다. 이 문제를 해결하면 다른 문제가 나오고 또 그 문제를 해결하면 또 다른 문제가 나오기 때문이다. 제럴드 와인버그는 그의 책 <대체 뭐가 문제야>에서 ‘끝 없는 문제의 사슬’이라 표현했다. 그리고 문제는 본질은 완전 해결에 있는 것이 아니라 덜 성가신 문제로 만드는 것이라 말했다. 프레드 브룩스가 말한 것처럼 은탄환은 없는 것이다.
나에게 기술의 선택에 있어 가장 중요한 것이 무엇이냐고 묻는다면 나는 ‘내가 처한 맥락’이라고 답할 것이다. 이 말은 어떤 환경에서는 GraphQL 보다 BFF가 더 나은 대안일 수 있다는 것이다. 내가 처한 맥락을 고려하지 않는 기술 선택은 아무리 좋다 해도 쓸모가 없으며 오히려 큰 손해를 불러오기도 한다.