GraphQL Federation으로 여러 GraphQL 서비스 통합하기

GraphQL Federation의 정의와 Apollo Federation을 이용한 구현 방법.

Do not index
Do not index
안녕하세요. 오토피디아의 서비스개발팀장을 맡고 있는 김승수 입니다. 오토피디아의 모놀리틱 GraphQL 서버의 기능들이 하나 둘 씩 별개의 마이크로서비스로 분리되어 나감에 따라 서비스개발팀에서 GraphQL Federation의 도입을 고민하게 되었습니다. 그 과정에서 GraphQL Federation의 이론과 Apollo Federation을 이용한 구현 방법에 대해 조사한 내용을 공유하려고 합니다.
 

GraphQL Federation의 목적

프론트엔드 개발자 입장에서 GraphQL의 가장 큰 장점은 하나의 요청으로 원하는 모든 데이터를 over/under-fetching 걱정 없이 쿼리할 수 있다는 것 입니다. 서버의 GraphQL schema와 GraphQL 쿼리문을 작성하는 방법만 안다면 REST API를 하나하나 호출하는 것보다 데이터를 훨씬 쉽게 가져올 수 있습니다. 이러한 특성 때문에 GraphQL은 BFF(Backend for Frontend)를 대체하는 방법이기도 합니다.
하지만 서버가 모놀리틱에서 여러 마이크로서비스로 쪼개진다면 어떨까요? 각 마이크로서비스를 관리하는 팀이 자신들이 담당하는 컨텍스트 개발에만 집중할 수 있게되므로, 이는 분명 백엔드 개발자에게는 좋은 방향입니다. 그러나, 프론트엔드 개발자 입장에서는 아닙니다. 한 서버, 즉 한 GraphQL schema에 요청하던 것을 여러 마이크로서비스의 GraphQL schema에 각각 쿼리를 작성해 요청하고, 요청 받은 데이터들을 프론트엔드에서 취합해야 합니다. 하나의 요청으로 원하는 모든 데이터를 쿼리할 수 있다는 GraphQL의 장점이 사라진 것 입니다.

GraphQL Federation이란?

GraphQL Federation이란 여러 GraphQL 마이크로서비스의 GraphQL schema를 조합하여 하나의 큰 GraphQL schema로 만드는 방식을 말합니다. 하나의 router가 각 마이크로서비스에서 제공하는 GraphQL schema(subgraph)를 합친 통합 GraphQL schema(supergraph)를 제공하고, 클라이언트는 이 supergraph를 보고 쿼리를 작성해 요청하면 됩니다. Router는 요청된 쿼리문을 분리하고, 각 분리된 쿼리문을 담당하는 마이크로서비스에 보낸 뒤, 돌아온 데이터들을 합쳐 클라이언트에게 돌려줍니다.
이러한 특성으로 GraphQL Federation은 위에서 언급한 문제를 해결할 수 있습니다. GraphQL Federation을 잘 활용하는 기업은 Netflix가 대표적입니다. Netflix에서 GraphQL Federation을 활용하는 방법에 대한 유투브 동영상도 있으니 시간 되실때 한 번 시청하시면 좋을 듯 합니다.
GraphQL Federation의 예시. 사용자, 상품, 리뷰 마이크로서비스의 schema를 router에서 조합하여 클라이언트에 제공한다.
출처: https://www.apollographql.com/docs/federation/
GraphQL Federation의 예시. 사용자, 상품, 리뷰 마이크로서비스의 schema를 router에서 조합하여 클라이언트에 제공한다. 출처: https://www.apollographql.com/docs/federation/

GraphQL 기본 개념

이 글에서는 Federation을 이해하기 위한 최소한의 GraphQL 개념들을 짚고 넘어가도록 하겠습니다. 이미 GraphQL에 익숙하신 분들은 다음장으로 넘어 가셔도 좋습니다. 또한 글 내용과 관련된 GraphQL 문서 링크를 글에 달아 두었으니, GraphQL에 대해 더 자세하게 알고 싶으신 분들은 참고 바랍니다.

Object type과 field

GraphQL에서는 그래프 형식으로 데이터를 표현합니다. 그래프의 가장 기본 단위는 여러 field를 포함하는 object type입니다.
예를 들어, ID, 이름, 가격을 포함하는 상품 데이터는 다음과 같은 GraphQL schema로 정의할 수 있습니다.
type Product {
	id: ID!
	name: String!
	price(in: Currency = KRW): Float!
}

enum Currency {
	KRW
	USD
}
위 GraphQL schema에서 Product는 object type, id, name, price는 field입니다. 각 field는 타입을 가지며, 기본적으로 StringIntFloatBoolean 그리고 ID type을 제공합니다. 타입 뒤의 ! 는 해당 field가 non-nullable임을 의미합니다. 또한 각 field는 argument를 가질 수 있습니다. price field는 in이라는 Currency enum type의 argument를 가지며, in argument의 기본값은 KRW입니다.

Object type간의 관계

GraphQL을 데이터를 그래프 형식으로 표현한다고 하였습니다. 그래프의 정점(vertex)가 object type이라면, 정점을 잇는 간선(edge)은 object type간의 관계입니다. 예를 들어, 한 상품이 ID와 별점, 본문을 포함하는 리뷰를 여러개 가질 수 있다면 다음과 같은 GraphQL schema로 정의할 수 있습니다.
type Review {
	id: ID!
	rating: Float!
  body: String!
	product: Product!
}

type Product {
	...
	reviews: [Review!]!
}
한 상품은 여러개의 리뷰를 가질 수 있고, 한 리뷰는 하나의 상품에 대한 것이라는 상품과 리뷰 간의 관계를 정의한 것입니다.

Query object type과 GraphQL query문

클라이언트는 이러한 GraphQL schema에 맞는 GraphQL 쿼리문을 작성하고 서버에 요청하여 데이터를 돌려 받게 됩니다. 그러려면 그래프를 탐색할 시작점이 필요합니다. GraphQL에서는 Query라는 특수한 object type이 그 역할을 합니다. (Query외에도 Mutation과 Subscription이 그래프 탐색 시작점의 역할을 하지만, 이 글에서는 다루지 않습니다.) 모든 상품 목록을 반환하는 products field를 다음과 같이 정의합니다.
type Query {
	products: [Product!]!
}
그러면 클라이언트는 다음과 같은 쿼리문을 통해 원하는 데이터를 조회할 수 있습니다.
query AllProductsAndTheirReviews {
	products {
		id
		name
		price
		reviews {
			id
			rating
			body
		}
	}
}
 

Field Resolver

서버는 일부 field에 대해 부모 object type과 field의 argument(존재한다면)로부터 field 값을 반환하는 함수인 field resolver를 작성합니다. GraphQL 서버는 클라이언트로부터의 쿼리문을 분석해 쿼리문의 각 field에 해당하는 field resolver를 실행하여 field에 대응하는 값을 가져와 클라이언트에게 돌려 주게 됩니다.
const resolvers = {
	Query: {
		products: async (parent, arguments, context, info) => {
			return await context.fetchAllProducts();
		}
	},
	Product: {
		reviews: async (parent, arguments, context, info) => {
			return await context.fetchReviewsByProductId(parent.id);
		}
  }
}
이 글의 설명보다 더 자세하게 GraphQL에 대해 알고 싶으시다면, GraphQL 공식 문서를 참고하시기 바랍니다.

Apollo Federation의 개념

23년 1월 현재, GraphQL Federation을 모두 직접 구현 할게 아니라면 사실상 선택지는 Apollo Federation 밖에 없는 상황입니다. 따라서 이 글에서는 Apollo Federation에 대해 설명하도록 하겠습니다. 이 글에서 사용하는 예시와 글의 흐름은 Apollo Federation 문서를 참고 하였으며, 글 내용과 관련된 문서 링크를 첨부 하였으니 참고 바랍니다.

모놀리틱에서 MSA로의 전환

개발팀이 상품을 관리하는 상품팀과, 리뷰를 관리하는 리뷰팀으로 분리되고, 서비스도 하나의 모놀리틱 서비스에서 상품 마이크로서비스와 리뷰 마이크로서비스로 분리 되었다고 가정합시다. 상품과 리뷰 마이크로서비스는 각각 다음과 같은 GraphQL schema를 제공합니다.
type Product {
	id: ID!
  name: String!
  price(in: Currency = KRW): Float!
}

type Query {
	products: [Product!]!
}
두 GraphQL schema를 단순히 합친다면, 상품과 리뷰와의 관계가 사라집니다. 이러한 문제를 해결하기 위해 GraphQL federation을 사용합니다.

Schema의 종류

연합된(federated) supergraph는 세가지 다른 schema를 사용합니다.
Subgraph, Supergraph, API schema 간의 관계.
출처: https://www.apollographql.com/docs/federation/federated-types/overview
Subgraph, Supergraph, API schema 간의 관계. 출처: https://www.apollographql.com/docs/federation/federated-types/overview
  1. Subgraph schema: Subgraph는 각 GraphQL (마이크로)서비스를 의미하며, 각 서비스가 제공하는 GraphQL schema를 subgraph schema라고 합니다.
  1. Supergraph schema: 모든 subgraph schema와, 거기에 어떤 subgraph가 어떤 field를 resolve할 수 있는지에 대한 정보를 포함하는 schema입니다.
  1. API schema: Supergraph schema에서 routing 정보를 제외한 schema로, 클라이언트에게 노출 되는 schema입니다.
예시 (출처)

Subgraph schema

User subgraph schema
type Query {
  me: User
}

type User @key(fields: "id") {
  id: ID!
  username: String! @shareable
}

# (Subgraph schemas include this to opt in to Federation 2 features.)
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable"])
Product subgraph schema
type Query {
  topProducts(first: Int = 5): [Product]
}

type Product @key(fields: "upc") {
  upc: String!
  name: String!
  price: Int
}

extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable"])
Review subgraph schema
type Review {
  body: String
  author: User @provides(fields: "username")
  product: Product
}

type User @key(fields: "id") {
  id: ID!
  username: String! @external
  reviews: [Review]
}

type Product @key(fields: "upc") {
  upc: String!
  reviews: [Review]
}

# (This subgraph uses additional federated directives)
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.0",
        import: ["@key", "@shareable", "@provides", "@external"])

Supergraph schema

schema
  @link(url: "https://specs.apollo.dev/link/v1.0")
  @link(url: "https://specs.apollo.dev/join/v0.2", for: EXECUTION)
{
  query: Query
}

directive @join__field(graph: join__Graph!, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION

directive @join__graph(name: String!, url: String!) on ENUM_VALUE

directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE

directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR

directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA

scalar join__FieldSet

enum join__Graph {
  PRODUCTS @join__graph(name: "products", url: "http://localhost:4003/graphql")
  REVIEWS @join__graph(name: "reviews", url: "http://localhost:4002/graphql")
  USERS @join__graph(name: "users", url: "http://localhost:4001/graphql")
}

scalar link__Import

enum link__Purpose {
  """
  `SECURITY` features provide metadata necessary to securely resolve fields.
  """
  SECURITY

  """
  `EXECUTION` features provide metadata necessary for operation execution.
  """
  EXECUTION
}

type Product
  @join__type(graph: PRODUCTS, key: "upc")
  @join__type(graph: REVIEWS, key: "upc")
{
  upc: String!
  name: String! @join__field(graph: PRODUCTS)
  price: Int @join__field(graph: PRODUCTS)
  reviews: [Review] @join__field(graph: REVIEWS)
}

type Query
  @join__type(graph: PRODUCTS)
  @join__type(graph: REVIEWS)
  @join__type(graph: USERS)
{
  topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS)
  me: User @join__field(graph: USERS)
}

type Review
  @join__type(graph: REVIEWS)
{
  body: String
  author: User @join__field(graph: REVIEWS, provides: "username")
  product: Product
}

type User
  @join__type(graph: REVIEWS, key: "id")
  @join__type(graph: USERS, key: "id")
{
  id: ID!
  username: String! @join__field(graph: REVIEWS, external: true) @join__field(graph: USERS)
  reviews: [Review] @join__field(graph: REVIEWS)
}

API schema

type Product {
  name: String!
  price: Int
  reviews: [Review]
  upc: String!
}

type Query {
  me: User
  topProducts(first: Int = 5): [Product]
}

type Review {
  author: User
  body: String
  product: Product
}

type User {
  id: ID!
  reviews: [Review]
  username: String!
}

Entity

여러 subgraph에 존재할 수 있는 object type을 entity라고 합니다. Product object type은 Review GraphQL schema에서도 존재 해야 하고(Reviewproduct: Product! field를 위해), Review object type은 Product GraphQL schema에서 존재 해야 하므로(Productreviews: [Review!]! field를 위해) ProductReview 모두 entity가 되어야 합니다.
한 subgraph에서 object type을 entity로 만들기 위해서는 object type에 @key directive를 추가하고, entity에 대한 reference resolver를 정의 해야 합니다.

@key directive

@key directive는 entity의 primary key를 정의합니다. 모든 entity는 자신의 primary key로 유일하게 식별될 수 있어야 합니다. Primary key를 통해 여러 subgraph에서 제공된 entity의 서로 다른 field 값들을 하나의 entity로 모을 수 있게 됩니다.
ProductReview 모두 id field가 primary key 역할을 하므로, 다음과 같이 정의할 수 있습니다.
type Product @key(fields: "id") {
	id: ID!
	price: Int!
	...
}

Reference resolver

@key directive는 “이 subgraph에 primary key를 제공하면 그에 해당하는 entity 값을 제공한다”라는 의미를 가집니다. Reference resolver는 primary key를 통해 entity 값을 반환하는 resolver를 의미합니다.
ProductReview에 대한 reference resolver를 다음과 같이 정의할 수 있습니다. Reference resolver의 인자로 제공되는 entity representation은 entity의 primary key field 값과 __typename을 제공합니다.
const resolvers = {
	Product: {
		__resolveReference(productRepresentation) {
			return fetchProductById(productRepresentation.id)
		}
	}
}

다른 subgraph의 entity를 참조하기

Entity는 여러 subgraph에 존재할 수 있는 object type이라고 하였습니다. Entity를 정의하는 방법에 대해서 알아 보았으니, 이제 entity를 여러 subgraph에서 사용해 봅시다.
상품 subgraph에 Product entity를 정의 하였습니다. 리뷰가 어떤 상품에 대한 리뷰인지를 나타내는 product field를 리뷰 subgraph의 Review entity에 추가해 봅시다.
type Review {
	id: ID!
	rating: Float!
  body: String!
	product: Product!
}
그러나 아직 리뷰 subgraph에 Product object type이 정의 되어 있지 않습니다. 상품 subgraph의 Product entity를 리뷰 subgraph에서 참조하기 위해서는 Product entity의 stub(mock과 비슷한 뜻)을 리뷰 subgraph에 다음과 같이 추가하면 됩니다.
type Product @key(fields: "id", resolvable: false) {
  id: ID!
}
리뷰 subgraph에서는 상품의 ID 외에는 상품에 대해 아는 것이 없습니다. 따라서, Product stub의 정의에는 @key field인 id만이 포함되어 있습니다. resolveable: false는 리뷰 subgraph가 Product entity의 reference resolver를 제공하지 않음을 의미합니다.

Query flow 예제

Query에 포함된 entity가 어떻게 resolve되는지에 대한 이해를 돕기 위해, 다음 query가 요청 되었다고 가정해 봅시다.
query GetReviewsWithProducts {
	latestReviews { # 리뷰 subgraph에 정의됨.
    rate
    product {
			id
			price # 리뷰 subgraph에 정의되지 않음.
    }
  }
}
Query가 latestReviews field로부터 시작하고, 이는 리뷰 subgraph에 정의되어 있으므로 리뷰 subgraph에서 query가 실행됩니다. 그러나 리뷰 subgraph에는 Product의 id가 아닌 price field가 정의되어 있지 않습니다. 이러한 문제를 해결하기 위해 router는 query plan을 생성합니다.

Query plan

위 query를 받았을 때, router는 latestReviews field는 리뷰 subgraph에 쿼리 해야하고, 각 리뷰의 product.price field는 상품 subgraph에 쿼리 해야한다는 사실을 알고 있습니다. 따라서, 먼저 리뷰 subgraph에 다음 query를 요청합니다.
query {
  latestReviews {
    score
    product {
      __typename
      id
    }
  }
}
상품 subgraph의 Product reference resolver에 전달하기 위한 __typename@key field인 id를 리뷰 subgraph에 쿼리합니다. 상품 subgraph가 쿼리 결과를 반환하면, 각 리뷰에 대한 상품마다 상품 subgraph에 다음 쿼리를 요청합니다.
query {
  _entities(representations: [...]) {
    ... on Product {
      price
    }
  }
}
_entities는 모든 subgraph에 자동으로 생성되어 추가되는 Query field입니다. Router는 이를 모든 entity에 직접 접근하기 위해 사용합니다. _entities field에 전달되는 representations arguments는 리뷰 subgraph에서 반환한 것으로, 다음과 같은 형태입니다.
[
  {
    "__typename": "Product",
    "id": "1"
  },
  {
    "__typename": "Product",
    "id": "2"
  }
	//...
]
Router는 위 쿼리에 대한 반환 결과로 각 상품의 price field값을 받습니다. Router는 리뷰와 상품 subgraph에서 반환한 결과를 조합하여 최종적으로 클라이언트에게 쿼리 결과를 반환힙니다.

@key 더 잘 사용하기

복수 @key

Entity를 유일하게 식별하는 field가 여러개라면, 한 entity에 여러 @key를 정의할 수 있습니다.
type Product @key(fields: "id") @key(fields: "sku") {
  id: ID!
  sku: String!
  name: String!
  price: Int
}
여러 다른 subgraph들이 entity를 다른 field로 식별하는 경우 복수의 @key를 사용하면 됩니다. 예를 들어, 리뷰 subgraph에서는 상품을 ID로 식별하고, 재고 subgraph에서는 상품을 SKU(store keeping unit)로 식별하는 경우, 위와 같이 Product entity의 idsku field를 모두 @key로 정의하면 됩니다.

복합(Compound) @key

@key는 nested field를 포함한 여러개의 field로 구성될 수 있습니다. 아래 예제에서는 User entity가 사용자의 ID와 사용자가 속한 조직의 ID로 유일하게 식별됩니다.
type User @key(fields: "id organization { id }") {
  id: ID!
  organization: Organization!
}

type Organization {
  id: ID!
}

다른 field를 참조해 계산되는 field 정의하기

Entity에 자신의 다른 field를 사용해 계산되는 field를 정의할 수 있습니다. 이때, 그 field를 계산하기 위한 다른 field가 field가 정의된 subgraph에 포함 되지 않은 field이면 문제가 생깁니다.
예를 들어, 배송 subgraph가 Product entity의 예상 배송료인 shippingEstimate을 추가합니다. 예상 배송료를 계산하기 위해서는 그 상품의 크기와 무게가 필요합니다. 이 경우 배송 subgraph의 shippingEstimate field를 계산하기 위한 sizeweight field는 배송 subgraph에 포함 되어 있지 않습니다. 지금까지 살펴 봤던 것처럼 router가 Product entity의 shippingEstimate field 값을 위해 배송 subgraph에 상품 ID만 전달한다면 shippingEstimate 값을 계산할 수 없게 됩니다.
type Product @key(fields: "id") {
  id: ID!
  shippingEstimate: String
}
이 때는 해당 field를 계산하기 위해 필요한 다른 field 목록을 @requires directive를 통해 정의하면 됩니다. 또한 require된 field들이 배송 subgraph에 정의 되어 있지 않으므로 sizeweight field를 정의합니다. 그러나 이 field을 배송 subgraph에서 resolve할 수는 없으므로, @external directive를 통해 배송 subgraph가 sizeweight field가 존재함은 알고 있지만, 배송 subgraph에서 해당 field들을 resolve할 수 없음을 정의합니다.
type Product @key(fields: "id") {
  id: ID!
  size: Int @external
  weight: Int @external
  shippingEstimate: String @requires(fields: "size weight")
}
위와 같이 정의하면, router는 shippingEstimate field가 포함한 query를 요청 받았을 때
  1. 상품 subgraph에서 상품의 sizeweight field를 쿼리하고,
  1. 배송 subgraph에 상품의 sizeweight 값을 전달하여 shippingEstimate field를 쿼리합니다.
필요한 field가 nested된 경우에도 다음과 같이 @required directive를 사용할 수 있습니다. 이 때 ProductDimensions object type은 상품과 배송 subgraph 모두에 동일하게 정의 되어 있어야 합니다.
type Product @key(fields: "id") {
  id: ID!
  dimensions: ProductDimensions @external
  shippingEstimate: String @requires(fields: "dimensions { size weight }")
}

type ProductDimensions {
	size: Int!
  weight: Int!
}

다른 subgraph의 field를 resolve하기

@key field와 같은 일부 예외를 제외하면 supergraph의 한 field는 하나의 subgraph만이 resolve해야 합니다. 그러나 여러 subgraph가 동일한 데이터 저장소에 접근할 수 있다면, 해당 데이터에 대한 field는 여러 subgraph에서 resolve 되어야 할 수 있습니다. 예를 들어 재고와 상품 subgraph 모두 상품 목록 데이터에 접근할 수 있다면, 재고 subgraph로 전달된 query의 상품 관련 field들을 상품 subgraph에 별도의 요청을 보내지 않고 재고 subgraph에서 모두 처리할 수 있으므로 응답 속도와 같은 성능이 올라갈 것입니다.
@shareable@provides directive를 사용해 위와 같은 상황을 처리할 수 있습니다.
  • @shareable directive는 해당 field를 이 subgraph가 항상 resolve할 수 있는 경우,
  • @provides directive는 해당 field가 특정 query path를 통해 요청된 경우에만 resolve할 수 있는 경우 사용합니다.
당연하게도 두 subgraph의 같은 field에 대한 resolver는 정확히 같은 결과를 반환 해야 합니다. 그렇지 않으면 동일한 query가 어느 subgraph에서 resolve 되는지에 따라 다른 결과가 반환될 수 있습니다. 처음 resolver를 정의할 때는 문제가 되지 않겠지만, 이미 존재하는 resolver를 수정하는 경우 다른 subgraph의 동일한 resolver도 같이 수정 해야 합니다.

@shareable directive

@shareable directive는 이 subgraph가 해당 field를 언제나 resolve할 수 있음을 의미합니다. 아래 예제에서 Product entity의 name field는 상품과 재고 subgraph 모두에서 항상 resolve될 수 있습니다.
# 상품 subgraph
type Product @key(fields: "id") {
  id: ID!
  name: String! @shareable
  price: Int
}

@provides directive

@provides directive는 해당 field가 특정 query path를 통해 요청된 경우에만 resolve할 수 있음을 의미합니다. 예를 들어, 상품 subgraph에서는 상품의 이름을 항상 resolve할 수 있지만, 재고 subgraph에서는 상품이 InStockCount object type의 product field로 요청된 경우에만 상품의 이름을 resolve할 수 있는 경우 다음과 같이 재고 subgraph를 정의합니다.
type InStockCount {
  product: Product! @provides(fields: "name")
  quantity: Int!
}

type Product @key(fields: "id") {
  id: ID!
  name: String! @external
  inStock: Boolean!
}
위 subgraph에서 @provides directive는 재고 subgraph가 InStockCount.product 경로로 요청된 상품의 name field를 resolve할 수 있음을 의미합니다. 또한 @external directive는 기본적으로는 재고 subgraph가 상품의 name field를 resolve할 수 없음을 의미합니다.

GraphQL Federation의 한계

지금까지 GraphQL federation이 무엇이고 Apollo federation으로 GraphQL federation을 구현하는 방법에 대해 알아 보았습니다. 그러나 GraphQL federation에는 다음과 같은 한계가 있습니다.

Apollo Federation외의 대안이 없음

GraphQL federation을 위한 router를 직접 구현할게 아니라면, 23년 1월 현재 Apollo federation 외의 대안이 딱히 존재하지 않습니다. Router를 구현하는게 불가능한 일은 아니지만, GraphQL을 사용하다 이제 막 MSA로 전환하는 기업에서 router를 직접 구현할 정도의 리소스를 투입하는 것은 현실적으로 어렵습니다.
또한 Apollo federation이 현재 Subscription을 지원하지 않습니다. 따라서 여러 subgraph에서 subscription을 제공하는 경우 subscription 요청을 별도로 처리하는 router를 직접 개발 해야 합니다.

N+1 문제

N+1 문제는 GraphQL federation보다는 GraphQL 자체의 문제이지만 GraphQL federation 사용 시에 더 부각되는 문제입니다. 예를 들어, 모든 상품 목록을 반환하는 products query가 있습니다. 상품이 자신의 모든 리뷰를 반환하는 reviews field를 가지고, 리뷰는 이 리뷰가 도움이 되었다고 평가한 사용자의 목록을 반환하는 users field를 가진다고 생각해 봅시다. 이때 아래와 같은 AllProducts query가 요청됩니다. 전체 상품 개수가 100개이고, 상품당 100개의 리뷰를 가지고, 리뷰 당 도움이 된 사용자가 100명인 경우 반환 해야 하는 object type의 개수는 100 + 100*100 + 100*100*100, 즉 1010100(일백만일만일백)개가 됩니다. 이때 상품, 리뷰, 사용자 subgraph가 분리 되어 있는 경우 사용자 subgraph는 백만개의 사용자 쿼리를 하나의 큰 요청이 아닌 각각 다른 요청으로 받게 됩니다. 백만개의 요청이 DB connection을 하나씩 점유할 것이므로 사용자 subgraph는 백만개의 요청을 모두 처리하기 전까지 다른 요청을 처리할 수 없게 됩니다.
type Query {
	products: [Product!]!
}

type Product {
	...
  reviews: [Review!]!
}

type Review {
	...
	users: [User!]!
}

type User {
	id: ID!
	...
}
다행히 N+1 문제의 해결 방법은 명확합니다.
  1. 많은 수의 object type을 반환할 가능성이 있는 field에는 pagination을 적용하고,
  1. DataLoader 패턴을 적용하여 동일한 여러개의 요청을 한 번에 처리하도록 해야 합니다.
공개되지 않은 내부 GraphQL API의 경우 pagination을 잘 사용하여 schema를 정의하고, 클라이언트에서도 불필요하게 큰 쿼리를 작성하지 않도록 주의하면 N+1 문제를 충분히 해결할 수 있습니다.

마치며

이렇게 GraphQL Federation의 정의와 목적, Apollo Federation을 활용한 구현 방법, 그리고 GraphQL Federation의 한계까지 알아 보았습니다.
오토피디아에서는 기술을 통해 차량 정비 시장을 혁신하기 위해 노력하고 있고 이를 위해 새로운 기술에 대한 학습과 적용을 이어가고 있습니다. 오토피디아는 함께 차량 정비 시장을 혁신하기 위해 도전하실 메이커 분들을 찾고 있습니다. 더 나은 방법을 찾기 위해 치열하게 고민하는 오토피디아가 궁금하시다면 공식 채용 페이지를 통해 다양한 정보를 확인해보세요!
더 궁금하신 점은 댓글로 남겨 주시면 최대한 빠르게 답변 해드리도록 하겠습니다. 다음에 더 좋은 글로 찾아뵙겠습니다!

References

 

오토피디아 채용에 관한 모든 것을 준비했어요

첨단기술을 통한 모빌리티 혁신, 함께 하고 싶다면?

채용 둘러보기

글쓴이

김승수
김승수

백엔드 개발자 | 오토피디아 공동창업자

    0 comments