1. GraphQL Federation 서브그래프 간 엔티티 데이터 참조 트러블 슈팅
1.1. 들어가기 앞서
포스팅 내용에 들어가기 앞서, 현재 재직중인 회사의 백엔드 서버의 GraphQL 구성을 먼저 말씀드리고자 합니다. 회사에서는 대부분의 API로 GraphQL를 사용하고 있습니다. Apollo Federation 아래에 몇 개의 마이크로 서비스로 구성되어있으며, 이 중 대부분은 단일 Repo에 여러 서비스가 띄워있는 MonoRepo 기반으로 동작하고 있습니다. 따라서, 동일한 코드 베이스라는 것을 의미합니다.
GraphQL 스키마는 Code First 로 생성됩니다. 개발자들이 작성한 Class와 Resolve 코드 기반으로 각 서브 그래프 마다 스키마가 생성됩니다. Apollo Federation 서버는 각 서브그래프간의 스키마를 조합하여 슈퍼 그래프를 만듭니다.
1.2. 이슈 원인 파악
최근에 한 에픽을 진행하면서 여러 GraphQL Apollo Federation 서브 그래프간의 엔티티 데이터를 참조하는 과정에서 흥미로운 이슈를 경험한 적이 있습니다. QA 단계에서 특정 API에서 버그가 발생한다는 제보를 받게 되었습니다. 외부에 공유 가능한 형태로 이슈를 조금 다듬어보자면 아래와 같습니다.
에러 메세지를 확인해보니designOption 의 데이터를 찾지 못한다는 것을 확인했습니다.
query UnitTable(
$input: FindAllUnitsInput!
$moduleId: ID!
) {
unitsOnModule(input: $input, moduleId: $moduleId) {
nodes {
unitItems {
id
name
item {
id
name
status
designOption {
pageWidth
}
}
}
}
}
}
GraphQL
복사
문제가된 API
왜 데이터를 찾지 못하는지 살펴보기위해 먼저 Apollo Federation의 쿼리 플랜을 확인해봤습니다. item 엔티티는 ContentSubgraph 에서 데이터를 조회하고, designOption 엔티티의 데이터는 MetadataSubgraph 에서 데이터를 조회한다는 것을 확인했습니다. 이때, item 엔티티는 위 두 서브그래프에서 양쪽다 공유되고 있는 엔티티입니다.
QueryPlan {
Sequence {
Fetch(service: "ContentSubgraph") {
{
unitsOnModule(input: $input, moduleId: $moduleId) {
nodes {
unitItems {
id
name
item {
id
name
status
}
}
}
}
}
},
Flatten(path: "unitsOnModule.nodes.@.unitItems.@.item") {
Fetch(service: "MetadataSubgraph") {
{
... on Item {
id
}
} =>
{
... on Item {
designOption {
pageWidth
}
}
}
},
},
},
}
GraphQL
복사
쿼리 플랜 결과. item 엔티티 하위의 designOption 엔티티는 다른 서비스에서 호출되고 있다.
문제는 designOption 필드가 item resolver의 ResolveField로 정의되어 있는데, designOption 를 조회하기 위한 리졸버의 @Parent() 인자로 전달되는 item 객체에 기대하는 추가 정보(예: 내부적으로 필요한 snapshotId 같은 필드)가 포함되어 있지 않다는 것이었습니다. ContentSubgraph에서 반환한 item 객체는 @key로 지정된 최소한의 정보만 포함하고 있었기 때문입니다.
앞서 말씀드린 것처럼 쿼리 플랜 상에서 item 엔티티는 ContentSubgraph에서 먼저 부분적으로 조회되고, 이후 MetadataSubgraph에서 추가 필드를 채워 넣으려는 구조임을 확인할 수 있었습니다. 이때, MetadataSubgraph에서 추가 데이터를 조회하기 위해 필요한 정보가 누락되어 있었던 것입니다.
1.3. 문제 해결
Apollo Federation 에 대해 조금 더 조사해보니, 한 서브 그래프에서 부분적인 데이터만을 전달 받았을때, 전체 데이터가 필요한 경우 resolveReference 를 사용한다는 것을 알게 되었습니다. 이에 따라 엔티티를 담당하는 리졸버(예를 들어, ItemResolver)에 __resolveReference 함수를 추가했습니다. 이 함수는 Federation의 엔티티 해석 과정에서, 단순히 id만 있는 참조 객체를 받아서 전체 엔티티(내부적으로 필요한 추가 필드 포함)를 다시 조회해 반환하는 역할을 합니다. 그 결과, ResolveField에서 designOption 필드를 올바르게 처리할 수 있게 되었습니다.
@Resolver(() => Item)
export class ItemResolver {
constructor(private readonly itemService: ItemService) {}
/**
* Federation의 엔티티 해석 과정에서 호출되는 메서드입니다.
* 단순히 id만 있는 참조 객체를 받아, 전체 엔티티 데이터를 조회하여 반환합니다.
*/
@ResolveReference()
async resolveReference(reference: { __typename: string; id: string }): Promise<Item> {
// 예를 들어, 데이터베이스나 외부 API를 통해 전체 Item 데이터를 조회합니다.
const fullItem = await this.itemService.getItemById(reference.id);
return fullItem;
}
TypeScript
복사
이 경험을 통해 Apollo Federation 환경에서 여러 서브그래프 간의 데이터 조각들이 어떻게 결합되는지, 그리고 __resolveReference 함수가 어떤 역할을 하는지 깨닫게 되었습니다. 이를 통해, 단일 엔드포인트를 통해 다양한 API를 효율적으로 통합하는 시스템의 데이터 일관성을 보장할 수 있었습니다.
위 사례를 통해 Apollo Federation, subgraph, resolveReference 등의 내용을 엿볼 수 있었습니다. 이제부터 이 개념들을 이론적으로 조금더 살펴보도록 합시다.
2. Apollo Federation 톺아보기
2.1. Apollo Federation 개요
Apollo Federation은 여러 개의 GraphQL API를 하나의 Supergraph로 결합하는 아키텍처입니다. 이 시스템은 각 API를 subgraph로 구성하여, 클라이언트가 단일 엔드포인트를 통해 통합된 그래프에 접근할 수 있도록 합니다. 이를 통해 다양한 데이터 소스를 하나의 통합된 인터페이스로 제공할 수 있으며, 데이터 접근성이 크게 향상됩니다.
또한 Apollo Federation은 마이크로서비스 아키텍처를 지원합니다. 각 팀은 자신이 담당하는 API를 독립적으로 개발하고 배포할 수 있어, 전체 시스템의 확장성과 유지보수성이 대폭 개선됩니다. 클라이언트는 단일 엔드포인트를 통해 여러 API에 접근할 수 있으므로, 복잡한 API 호출을 단순화하고 성능을 높이는 데 기여합니다.
2.2. @key Directive의 역할
@key directive는 Federation에서 각 subgraph가 고유한 엔티티를 식별하고 참조할 수 있도록 특정 필드를 정의합니다. 이는 각 subgraph가 다른 subgraph와의 상호작용에서 엔티티를 정확하게 식별할 수 있도록 하며, 데이터의 일관성을 유지하는 데 중요한 역할을 합니다.
엔티티는 여러 subgraph에서 공유될 수 있으며, @key directive는 이러한 엔티티의 고유성을 보장합니다. 이는 각 subgraph가 동일한 엔티티를 참조할 때, 데이터의 일관성을 유지하고 충돌을 방지하는 데 필수적입니다.
위 예시에서 보자면, item 엔티티는 ContentSubgraph 와 MetadataSubgraph 양쪽에서 참조되고 있습니다. 이와 같은 경우, Apollo Federation 에서 엔티티를 올바르게 식별하기 위해 @key directive를 사용해야합니다. 이때 고유한 값을 식별 할 수 있는 필드를 @key로 지정해야합니다. 따라서 여기서는 id 필드를 고유 식별자로 지정할 수 있습니다.
type Item @key(fields: "id") {
id: ID!
name: String
...
}
GraphQL
복사
각 subgraph는 @key directive를 통해 엔티티의 고유 식별자를 정의하고, 이를 기반으로 다른 subgraph와 상호작용할 수 있습니다. 이는 Federation의 핵심 기능 중 하나로, 각 subgraph가 독립적으로 작동하면서도 통합된 그래프를 제공할 수 있게 합니다.
2.3. resolveReference의 필요성
Apollo Federation 환경에서 한 subgraph가 엔티티를 조회할 때, 보통 @key에 정의된 필드만 제공됩니다. 그러나 다른 subgraph에서는 추가 데이터(예: snapshotId나 designOption 등)가 필요할 수 있습니다. 이때 __resolveReference 함수가 중요한 역할을 합니다.
[엔티티 참조 해결]
Federation의 쿼리 플래너가 다른 subgraph에서 엔티티를 참조할 때, 해당 엔티티는 부분적인 정보만 포함한 상태로 전달됩니다. __resolveReference 함수는 클라이언트 혹은 다른 subgraph에서 전달받은 엔티티의 고유 식별자를 기반으로, 해당 엔티티의 전체 데이터를 조회해 반환합니다.
[데이터 일관성 및 완전성 보장]
이 함수 덕분에 각 subgraph는 필요한 추가 필드(예: snapshotId 등)를 보충하여 클라이언트가 요청한 데이터를 완전하게 제공할 수 있습니다.
예를 들어, 아래와 같이 ItemResolver에서 __resolveReference를 구현하면, 단순한 참조 정보(예: { __typename: "Item", id: "..." })를 받아서 전체 엔티티 객체로 보완할 수 있습니다.
@ResolveReference()
async resolveReference(reference: { __typename: string; id: string }) {
const fullItem = await this.itemService.getItemById(reference.id);
return fullItem;
}
TypeScript
복사
이러한 매커니즘 덕분에, Apollo Gateway가 각 subgraph에서 가져온 데이터를 올바르게 통합하여 클라이언트에게 완전한 데이터를 제공할 수 있게 됩니다.
2.4. resolveReference와 N+1 문제
__resolveReference 함수 자체는 N+1 문제를 야기할 수 있는 가능성이 있으므로 주의해야합니다. 특히, 다수의 엔티티 참조를 처리할 때, 각 참조마다 별도의 데이터베이스 호출이나 API 요청이 발생하면, N+1 쿼리 문제가 발생할 수 있습니다.
예를 들어, 클라이언트 요청에서 100개의 부분 참조(예: { __typename: "Item", id: "..." })가 있다면, 이를 각각 __resolveReference에서 개별적으로 처리하게 되면 100번의 데이터 조회가 일어날 수 있습니다. 이는 성능 저하로 이어질 수 있습니다.
이러한 N+1 문제를 방지하기 위해 다양한 방법이 있겠지만, 그중 가장 보편적인 DataLoader 를 사용하여 문제를 해결할 수 있습니다. Facebook의 DataLoader와 같은 배칭(batch) 도구를 사용하면, 여러 개의 개별 호출을 하나의 배치 요청으로 묶어서 처리할 수 있습니다. 이를 통해 데이터베이스나 API 호출 횟수를 줄일 수 있습니다.
private ItemByIdLoader = new DataLoader<string, Item>(
async ids => {
const itemArr = await this.dataSource.manager.getRepository(Item).find({
where: {
id: In(ids),
},
});
const itemMap = reduce(
(acc, item) => {
if (isEmpty(item)) {
return acc;
}
return acc.set(item.id, item);
},
new Map<string, Item>(),
itemArr,
);
return pipe(
ids,
map((id: string) => itemMap.get(id)),
toArray,
);
},
{
cache: false,
},
);
async loadItemById(id: string): Promise<Item> {
return await this.ItemByIdLoader.load(id);
}
TypeScript
복사
DataLoader 예시 코드
2.5. @key와 resolveReference의 상호작용
@key directive와 __resolveReference 함수는 Federation에서 엔티티를 식별하고 참조를 해결하는 데 있어 상호 보완적인 역할을 합니다.
[엔티티 식별]
@key directive는 엔티티의 고유 식별자를 정의합니다. 이 식별자는 다른 subgraph에서 동일한 엔티티를 참조할 때 사용됩니다.
[엔티티 해석(Resolution)]
클라이언트가 요청한 데이터에서 일부 subgraph가 제공하는 엔티티는 @key 필드만 포함할 수 있습니다. 이때, Apollo Gateway는 _entities 쿼리를 사용하여 해당 엔티티의 참조 정보를 기반으로 __resolveReference 함수를 호출합니다.
[완전한 데이터 반환]
__resolveReference 함수는 전달받은 고유 식별자를 사용해 데이터베이스나 외부 API 호출을 통해 완전한 엔티티 객체를 조회한 후 반환합니다. 이렇게 하면 다른 subgraph에서 추가 필드(예: snapshotId 등)에 정상적으로 접근할 수 있게 됩니다.
이 두 요소의 조합 덕분에, 각 subgraph는 독립적으로 운영되면서도 전체 시스템에서는 데이터의 일관성과 완전성을 보장할 수 있습니다.
2.6. 결론 및 요약
Apollo Federation은 여러 GraphQL API를 하나의 Supergraph로 통합하는 아키텍처입니다. 이 과정에서 @key directive는 각 subgraph가 엔티티를 고유하게 식별할 수 있도록 돕고, __resolveReference 함수는 다른 subgraph에서 참조된 엔티티의 추가 데이터를 보충하여 반환합니다.
이 두 요소의 상호작용은 데이터의 일관성과 완전성을 보장하며, 클라이언트가 단일 엔드포인트를 통해 여러 API에 접근할 수 있도록 지원합니다.
개발자는 이 메커니즘을 이해하고 올바르게 구현함으로써, API의 확장성과 유지보수성을 크게 향상시킬 수 있습니다. 이를 통해 복잡한 데이터 소스 간의 참조 문제를 해결하고, 보다 효율적이고 일관된 시스템을 구축할 수 있습니다.