⛓️

GRAPHQL Federation 과 MSA에 대한 생각

Created
9/25/2021, 6:59:00 AM
Tags
Backend
Subtitle
#Grahpql Federation #MSA
GRAPHQL Federation을 이야기 하기 전에 주절주절 잡설이 있습니다. 본론은 "3.Grahpql Federation"부터 시작하니 참고하시길 바랍니다.

1. Circular dependency Between Several API Server

최근에 회사에서 기능 개발을 하면서 참을수 없는 찝찝함을 느끼면서 어떻게 해결해볼까 고민하다 MSA를 찍어먹어보게 되었다.
회사 서비스의 백엔드 서버 구조는 이런식으로 이루어져 있는데 기능 개발을 하다 보면 이런 일이 발생한다.
1.
회사에서 더 이상 파이썬과 함께 하지 않을 예정. 덕분에 Django서버에서 처리해야할 API가 있다면 NodeJS 서버에서 API를 개발한 후에 Django에서는 해당 API를 요청하는 방식으로 개발됨
2.
데이터베이스 두 개를 분리해서 사용 중임. 각각의 데이터베이스는 1:1로 매칭되는 서버가 있음. 만약 그림에서와 같이 NodeJS SERVER-1에서 MySQL에 저장된 데이터를 다루려면 SERVER-2에 요청 해야 함.
이런 상황이다 보니 각각의 서버가 서로의 API에 많은 부분 의존하고 있는 상황이 되버렸다. 특히 SERVER-1SERVER-2는 Grahpql을 사용하는데 각각의 서버에 중복된 스키마를 입력해야 하는 상황이 오게 된다.
아래는 간단한 예시
# SERVER-1.schema ## Server2에서 게시글 타입. type Server2Post{ id title content } type Query { post(id:ID!): Server2Post! }
GraphQL
# SERVER-2.schema type Post { id title content } type Query { post(id:ID!): Post! }
GraphQL
위처럼 SERVER-1에서 Post를 처리하기 위해서는 중복된 스키마가 생기게 된다.

2. 두 개를 하나로 만들거나, 세 개로 만들거나

당장 생각났던 해결책은 클라이언트가 API마다 구분해서 요청하면 되지 않을까 라는 점이다.
SERVER-2 에서 처리할 수 있는 API는 SERVER-2SERVER-1 에서 처리할 수 있는건 SERVER-1 에서 말이다.
기존 구조
변경된 구조
이렇게 두고 보니 각자가 알아야 할 API 인터페이스들을 Client로 위임했구나. 라는 것을 깨달았다.
팀 전체가 풀스택 엔지니어들로 이루어져 있거나, 프로젝트 팀원들이 전체적으로 기능에 대한 이해도가 풍부하다면 나쁘지 않은 방법일 수도 있겠다 싶었다. 하지만 우리 팀은 백엔드와 프론트엔드 구분이 명확한지라 프론트엔드 엔지니어가 백엔드 사정을 너무 자세히 알아야 하는 위 구조는 기각이다 싶었다.
다만, 그림을 그리고 보니 의존성이 문제일수도 있겠다 라는 생각은 들었다.
내가 아는 바로는 의존성이 가지는 문제를 해결하기 위해선 다음과 같은 방법이 있다.
1.
두 개를 하나로 만든다. 하나의 기능에서 모든 것을 처리하는 만능 API를 만들면 의존성은 사라진다.
2.
두 개를 세개로 만든다. 두 개의 기능의 의존성을 가지는 매핑 API를 만들면 의존성은 질서를 가진다.
위의 예시도 2번에 해당 되는 상황이라고 볼 수 있다.
그럼 두가지 선택지를 살펴보자.

두 개를 하나로 만든다.

사실 달갑지 않은 상황이다. 각각의 프로젝트는 작은 수준도 아니고, 많은 개발자들이 작업에 참여하고 있다.
만약 이런 상황에서 프로젝트를 하나로 만든다면 프로젝트 설치나 기능 개발 시에 빌드/배포 에 대한 시간적인 비용등에 대한 부담이라던지, 또 작업자들간의 충돌(브랜치 등...)등이 더 복잡해 지는건 사양이였다.
프로젝트가 두 개로 분리됨에 있어서 장점이라고 느끼는 부분도 분명이 있었기 때문에, 시간을 내서 이것을 없애고 싶진 않았다.

두 개를 세개로 만든다.

위의 다이어그램을 그리고 나니, 확장성도 있고 문제도 좋게좋게 잘 해결될것만 같았다. 근데... 계속 살펴 보니 다음과 같은 생각이 들었다.
멋진 Gateway Server(임시 API King)을 만드는데 무엇을 등가교환해야 하는지 살펴보기 위해서 Apollo 문서에서 살펴봤던 Federation과 책장 구석에 박아뒀던 도커/쿠버네티스 책을 주섬주섬 꺼내들었다.

3. Apollo Federation

아래 예제 레파지토리를 첨부했다. 설명과는 무관하니 참고만 하시면 좋을꺼 같습니다.

Subgraphs 와 Supergraph

기본적인 컨셉은 여러가지 기본 Subgraph들과 하나의 Supergraph(Gateway)로 구성된다.
이 때, 스키마의 구성이 Gateway의 역할을 하기 위해서는 특별한 인터페이스에 일치 해야만 한다. https://www.apollographql.com/docs/federation/federation-spec/ 에서 해당 스펙을 확인할 수 있다.
본인만의 gateway를 만드는게 목적이 아니라면 apollo에서 제공하는 라이브러리를 이용할 수 있다.
npm install @apollo/federation
Subgraph가 아닌 경우에 일반적으로 ApolloServer에 넘겨주는 Schema는 일반적으로 아래와 같다.
const { ApolloServer, gql } = require('apollo-server'); const typeDefs = gql` type Query { me: User } type User { id: ID! username: String } `; const resolvers = { Query: { me() { return { id: "1", username: "@ava" } } } }; const server = new ApolloServer({ typeDefs, resolvers, }); server.listen(4001).then(({ url }) => { console.log(`🚀 Server ready at ${url}`); });
JavaScript
이를 Subgraph로 구성하기 위해서는 다음과 같은 수정이 필요하다.

TypeDefinition

const typeDefs = gql` type Query { me: User } type User { id: ID! username: String } `;
JavaScript
수정 전
const typeDefs = gql` extend type Query { me: User } type User @key(fields: "id") { id: ID! username: String } `;
JavaScript
수정 후
@key directive는 스키마 타입을 엔티티로 만들어준다.
여기서 엔티티란 다른 Subgraph들이 참조 및 확장할 수 있는 객체 유형이다.
속성값으로 유니크 한 키를 설정함으로써, 다른 Subgraph에서 해당 키를 통해 참조할 수 있다.
이렇게 선언된 엔티티는 Resolvers에서 다음과 같은 처리를 해주어야 한다.

Resolvers

const resolvers = { Query: { me() { return { id: "1", username: "@ava" } } } };
JavaScript
수정 전
const resolvers = { Query: { me() { return { id: "1", username: "@ava" } } }, User: { __resolveReference (user, context){ return context.fetchUserById(user.id) } } }
JavaScript
수정 후
resolversUser__resolveReference 메서드를 가지고 있다.
Gateway는 Subgraph에서 다른 Subgraph를 참조하는 경우가 발생할 시에 전달받은 @key 디렉티브로부터 엔티티를 가져오기위해 __resolveReference 메서드를 실행한다.
인자로는 @key의 field 인자값이며, 결과값으로 해당되는 타입의 결과를 반환해야한다.

ApolloServer Parameters

const server = new ApolloServer({ typeDefs, resolvers, });
JavaScript
수정 전
const { buildSubgraphSchema } = require('@apollo/federation'); ... const server = new ApolloServer({ schema: buildSubgraphSchema([{ typeDefs, resolvers }]) });
JavaScript
수정 후
Subgraph 인터페이스에 일치하기 위해 스키마를 buildSubgraphSchema 를 이용하여 전달한다.
수정된 전체 코드
const { ApolloServer, gql } = require('apollo-server'); const { buildSubgraphSchema } = require('@apollo/federation'); const typeDefs = gql` type Query { me: User } type User @key(fields: "id") { id: ID! username: String } `; const resolvers = { Query: { me() { return { id: "1", username: "@ava" } } }, User: { __resolveReference(user, { fetchUserById }){ return fetchUserById(user.id) } } } const server = new ApolloServer({ schema: buildSubgraphSchema([{ typeDefs, resolvers }]) }); server.listen(4001).then(({ url }) => { console.log(`🚀 Server ready at ${url}`); });
JavaScript

다른 Subgraphs로부터의 참조

Subgraph들은 다른 Subgraph들을 참조가능하다. 이를 위해서 그래프를 디자인할 때
Separation of concerns 라는 원리를 이용한다.
일반적인 Schema 구성
위의 스키마들은 관련된 스키마를 직접 참조하고 있다. 이 경우에 하위 그래프를 위한 타입이 내부에 존재하기 때문에 확장성이 떨어진다. 이는 곧 subgraph들을 분리 시키는데 있어서 어려움을 겪게 한다.
Separation of concerns를 기반으로한 그래프 설계는 다음과 같다.
각 graph 타입에 realation이 있는 subgraph가 있을 때 마다 extend를 통해 외부에서 확장하는 방식을 이용하여 설계되어 있다.
개인적으로 OOP에서 OCP를 준수하게 위해 사용하는 Command Pattern이나 Inversion of Control 과 유사하다고 느꼇다.

Gateway

Supergraph라고도 하며 이름 그대로 하위 스키마들의 Gateway 역할을 한다. 필요한 패키지는 다음과 같다.
npm install @apollo/gateway
Gateway를 생성하기 위해서는 SDL을 이용하는 방법과 serviceList라는 것을 컴포지션하는 방법이 있다.

1. SuperGraphSDL

const { ApolloServer } = require('apollo-server'); const { ApolloGateway } = require('@apollo/gateway'); const { readFileSync } = require('fs'); const supergraphSdl = readFileSync('./supergraph.graphql').toString(); const gateway = new ApolloGateway({ supergraphSdl }); const server = new ApolloServer({ gateway, }); server.listen().then(({ url }) => { console.log(`🚀 Gateway ready at ${url}`); }).catch(err => {console.error(err)});
JavaScript
ApolloGateway 를 위해서 SDL을 전달해야하는데 해당 graphql은 apollo에서 제공하는 rovercli를 이용하여 생성할 수 있다.
rover 는 apollo에서 일부 서드파티들을 편하게 사용하기 위해 제공되는 cli이다. 이를 통해 apollo studio의 api를 이용하거나 federation을 위한 document들을 생성할 수 있다.
npm install -g @apollo/rover
rover를 설치한 후에 supergraph 스키마파일을 생성하기 위해서 각각의 subgraph 스키마 주소를 전달해야 하는데, 아래와 같이 파일 방식과 URL방식 둘다 사용가능하다.
아래는 supergraph-config.yaml 의 예시
subgraphs: films: routing_url: http://localhost:4001 schema: file: ./user.graphql people: routing_url: https://production.example.com schema: subgraph_url: https://production.example.com
YAML
routing_url 는 gateway가 runtime에 참고할 subgraph 서비스들에 대한 URL을 작성해야 하고
schema.subgraph_url 혹은 schema.file는 rover가 supergraph를 생성하기 위해 필요한 subgraph 스키마들의 위치를 작성한다.
이제 다음 명령어를 실행하면 해당 subgraphs들이 composed한 supergraph 스키마 파일이 생성된다.
rover supergraph compose --config ./supergraph-config.yaml > supergraph.graphql

2. ServiceList

ServiceList 방식은 비교적 간단하게 작성할 수 있다.
어차피 각자의 service(subgraph)에 스키마가 다 정의되어 있기 때문에, 런타임에 subgraph에서 이를 그대로 참고하면 되기 때문이다.
const { ApolloServer } = require('apollo-server'); const { ApolloGateway } = require('@apollo/gateway'); const gateway = new ApolloGateway({ serviceList: [ { name: 'books', url: 'http://localhost:4001', }, { name: 'authors', url: 'http://localhost:4002', }, ], }); const server = new ApolloServer({ gateway, }); server.listen().then(({ url }) => { console.log(`🚀 Gateway ready at ${url}`); }).catch(err => {console.error(err)});
JavaScript
로컬개발에는 빠른 개발환경때문에 ServiceList 방식을 추천하나 운영서버에서는 service 서버에 에러가 발생할 시에 gateway도 SDL 컴포지션에 실패해서 예상치 못한 다운타임이 발생하거나, 업데이트 중 일관성없는 요청을 처리할 수 있기 때문에 권장되지 않는다고 한다.
이제 서버를 실행하면 Gateway가 제대로 동작하는 것을 확인 할 수 있다.

Apollo Studio 컴포지션

subgraphSDL과 serviceList방식 모두 문제가 있는데, subgraph가 변경되었을 때, 감지가 불가능하다는 점이다. 때문에 subgraph가 변경되면 gateway도 새로 스키마를 갱신해야 한다는 점이다.
이 때문에 Apollo에는 Apollo Studio를 쓰기를 권장한다.
Apollo Studio는 Gateway를 위해 Apollo Schema Registry와 Subgraph들이 수정을 할 때마다 이를 컴포지션한 후 업로드 하는 Uplink를 제공한다.
Gateway는 Uplink를 폴링하면서 변화가 있을 때마다 스키마를 갱신하여 다운타임을 없애는 방식을 사용할 수 있다.
아래 부터는 Gateway를 배포하는 과정이다.
1.
Apollo Studio에 접속하도록 하자. ( 계정이 없다면 회원가입을 하는걸로 ... ㅎ) 접속하고 팀을 구성하면 다음과 같은 대시보드를 확인할 수 있다.
2.
우측 상단의 New Graph를 클릭한 후에 나오는 팝업에서 Graph Deployed 선택한 후 Next를 선택한다.
3.
Apollo Server를 생성하기 위한 Key가 생성되는데 이를 이용하여 rover로부터 studio에 배포할 수 있다.
4.
아래의 명령어를 통해 rover에 인증을 한다.
rover config auth > [ 3번의 Apollo Studio에서 받은 APOLLO_KEY를 입력하면 된다.]
5.
다음 명령어를 통해 첫 번째 subgraph를 전달한다. 아래는 예시이다.
rover subgraph publish <GRAPH_NAME> \ --routing-url https://rover.apollo.dev/quickstart/products/graphql \ --schema ./products.graphql \ --name products
이 때 다음과 같은 에러가 발생할 수 있는데 이 경우에 --convert 옵션을 통해 force publish 하면 된다.
The graph My-Graph-3-fthtu@current is a non-federated graph. This operation is only possible for federated graphs.
Bash
만약 제대로 생성됬다면 좌측 상단처럼 타이틀 옆에 Federation이라는 라벨이 생긴다.
f.
남은 subgraph들도 해당 supergraph에 모두 publish한다.
g.
uplink를 포함한 환경변수를 등록하고 기존에 등록되어진 Gateway의 인자들을 제거한다.
APOLLO_KEY=<YOUR_GRAPH_API_KEY> APOLLO_GRAPH_REF=<YOUR_GRAPH_ID> APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT=<YOUR_GATEWAY_HOST_ENDPOINT>
Bash
const gateway = new ApolloGateway({ // supergraphSdl });
JavaScript
이상으로 Apollo Federation의 배포까지 진행해보았다.

4. 얻은것. 잃은 것. 잃어야 할 것.

나름대로 운영해야할 서비스라고 생각하고 구성해보면서 분명하게 얻는것과 잃는 것이 확실히 구분되어 지는 편이 였고 때문에 Federation이 적합한 상황이 있음을 느꼈다.
잃은 것은 다음과 같다.

늘어나는 요청. 그로 인한 속도 저하.

생각보다 체감이 될 정도로 속도차이가 났다.
특히 ReferenceQuery를 요청하는 경우 1:N 문제 같은 것들이 생겨서 적절한 Batch 처리가 필수적이라 느꼈다.
이외에도 Gateway에서 Cache도 처리량에 꽤 많은 영향을 줄 것으로 예상되서 이러한 설정들 역시 필수적이라 느꼈다.

서버 유지 보수를 위한 비용

테스트하는데 가장 까다로운 부분중에 하나가 무료로 여러 인스턴스를 호스팅할 수 있는 서비스를 찾는 일이였다. 하나의 클라우드서비스를 사용하고 있고, 서버를 마음대로 늘릴 수 있다면 문제가 되지 않겠지만 그렇지 않다면 생각치도 못하게 이런 부분이 걸림돌이 될 수도 있겠다 싶었다.
또한 모니터링에서도 언급하겠지만, Apollo Studio에 의존해야만 사용할 수 있는 기능들이 존재하기 때문에 클라우드 서비스를 선택하는것 자체도 어려울 것으로 보인다. ( 또, 여러 클라우드 서비스를 사용하면...... 팀 내에서 유지보수하는게 여간 까다로운게 아니다. )
추후에 확장 가능성을 고려하게 되면 orchestration해야할 서버가 늘어나면서 발생하는 유지보수 비용이나 학습비용도 크게 다가올 것으로 생각된다. ( 예제가 충분히 없다. )

모니터링

Federation를 적극적으로 지원하는 모니터링툴은 Apollo Studio밖에 없어 보인다. 마찬가지로 선택지가 없고, 기존의 모니터링툴을 사용하는게 어렵다는 점은 아쉬운 부분이다.

좀 더 복잡해진 스키마 선언

사실 그렇게 복잡한 그래프를 만들진 않아서 잘 모르겠는데, 더 어려워지지 않을까? ㅎㅎ

복잡해진 개발 환경

켜야 할 서버가 늘어나니 개발 환경 역시 복잡해진다. docker-compose나 k8s로 커버 가능하지만 이거 세팅하는거 자체가 복잡한 일이지 않나. 문제생길때마다 일이 늘어나는건 덤이다.
얻은 것은 다음과 같다.

API 간의 스키마 중복 제거

목적을 달성한 모습.

작업 간의 충돌 완화

서비스간의 작업(branch) 충돌이 일어나는건 줄어들 것이라 예상된다.

5. 총평

전체적으로 기술적인 난이도는 올라갔고, 서비스나 프로젝트에 대한 이해도에 대한 요구사항은 줄어들었다고 나름대로 결론지었다.
스키마 중복같은건 서비스를 아주 잘 알고 프로젝트에 진절머리날 정도로 익숙한 사람이라면 문제가 생기면 바로바로 알겠지만 새로운 팀원에게는 지옥도를 선사하기 딱 적합하다.
반대로 기술적인 난이도는... 기술력이 좋은 엔지니어가 오거나 그렇지 않은 경우에는 공부를 해야 해결되는 부분인데, 스타트업에서는 꽤 비용이 많이 드는 방법이라는 생각이 든다.
우리팀에서는 아직 그래도 프로젝트를 오래한 팀원들이 많고, 아직 재직의사가 확실해 보이고, 할 일이 산더미처럼 많기 때문에 일단은 federation라던지 msa는 당장 필요하지 않는 것으로 마무리 지었다.