다양한 GraphQL 서버에서 동작하는 애플리케이션 설계
「인터페이스에 대해 코딩하고, 구현에 대해 코딩하지 않는다」는 것은 기능을 직접 호출하는 것이 아니라, 필요한 입력과 기대 출력을 열거한 계약을 통해 호출하는 방식입니다. 구현의 세부 사항은 숨겨집니다. 이 전략은 애플리케이션을 특정 구현·프로바이더·스택으로부터 분리하여, 애플리케이션 코드를 변경하지 않고도 전환할 수 있게 해줍니다.
이 전략은 GraphQL에도 적용할 수 있습니다. GraphQL은 애플리케이션과 서버 사이의 중간자 역할을 하여, 필요한 모든 변경을 GraphQL 쿼리에만 집중시키고 비즈니스 로직에는 손을 대지 않아도 됩니다.
GraphQL 쿼리는 클라이언트와 서버 사이의 인터페이스 역할을 합니다. 쿼리를 실행하면 GraphQL 서버가 이를 처리하여 클라이언트에 필요한 데이터를 반환합니다. 데이터는 어디에서 오는 걸까요? 어떻게 가져온 걸까요? 클라이언트는 그것을 알지 못하며, 알 필요도 없습니다.

쿼리에 대한 응답은 쿼리와 동일한 형태를 가집니다. 이 GraphQL 쿼리에 대해:
{
post(by: { id: 1 }) {
id
title
}
}...응답은 다음과 같습니다:
{
"data": {
"post": {
"id": 1,
"title": "Hello world!"
}
}
}동일한 쿼리라도 매개변수가 다르면 반환되는 데이터는 달라지지만, 형태는 일정합니다. 즉, 쿼리가 변하지 않는 한, 애플리케이션은 데이터를 읽고 처리하는 로직을 변경할 필요가 없으며, 어떤 GraphQL 서버가 쿼리를 실행하고 있는지도 문제가 되지 않습니다.
이렇게 하여 한 GraphQL 서버에서 다른 서버로 원활하게 전환할 수 있습니다.
쿼리는 GraphQL 스키마에 의존합니다
다만, 마지막 단락은 다소 낙관적입니다. GraphQL 쿼리는 GraphQL 서버에 따라 변경이 필요할 수 있기 때문입니다. 더 정확히 말하면, 쿼리는 GraphQL 스키마를 기반으로 하며, 서버마다 다른 스키마를 노출하는 경우 쿼리도 달라집니다.
예를 들어, Cursor Connections 사양을 사용하는 GraphQL 서버에서는 다음과 같은 쿼리를 실행합니다:
{
categories(first: 10000) {
edges {
node {
categoryId
description
id
name
slug
}
}
}
}한편, WordPress 방식의 페이지네이션을 사용하는 다른 서버(Gato GraphQL 등)에서는 동일한 쿼리를 다음과 같이 실행합니다:
{
postCategories(pagination: { limit: 10000 }) {
id
description
globalID
name
slug
}
}두 쿼리의 차이를 확인할 수 있습니다:
| 기능 | 서버 #1 | 서버 #2 |
|---|---|---|
| 포스트 카테고리 필드 | categories | postCategories |
| 결과 수를 제한하는 필드 인수 | first | pagination.limit |
오브젝트의 필드 id가 나타내는 것 | 전역 고유 ID | 해당 타입의 고유 ID |
| 쿼리 형태 | edges.node로 인해 더 깊은 구조 | 더 평탄한 구조 |
첫 번째 서버의 쿼리를 두 번째 서버의 동등한 쿼리로 교체하는 것만으로는 동작하지 않습니다. 로직이 여전히 원래 쿼리의 형태와 필드에 따라 응답 데이터에 접근하려 하기 때문입니다.
한 가지 해결책은 클라이언트 측의 데이터 조회 로직도 교체하는 것입니다. 예를 들어, 다음 로직을:
const categories = data?.data.categories.edges.map(({ node = {} }) => node);...다음과 같이 교체합니다:
const categories = data?.data.postCategories;그러나 그것이 바로 피하고 싶은 것입니다. 변경을 최소한으로 유지하고, 인터페이스(GraphQL 쿼리)만 변경하며 비즈니스 로직은 그대로 유지하고 싶은 것입니다.
다행히도, 다음 단계를 따라 GraphQL 쿼리만 변경함으로써 이 차이를 메울 수 있습니다:
- GraphQL 쿼리를 애플리케이션으로부터 분리된 상태로 유지하기
- 별칭을 사용하여 필드 이름을 적합하게 만들기
self필드를 사용하여 응답 형태를 적합하게 만들기
이 3단계를 통해 애플리케이션을 다른 GraphQL 서버에 맞게 적합시키는 방법을 알아보겠습니다.
GraphQL 쿼리를 애플리케이션으로부터 분리된 상태로 유지하기
GraphQL 쿼리를 애플리케이션 로직으로부터 분리하려면 다음이 필요합니다:
- 각 GraphQL 쿼리(또는 묶음)를 별도의 파일에 저장하고, 모두 특정 폴더에 배치하기
- 쿼리를 내보내고 애플리케이션에서 가져오기
예를 들어, 모든 GraphQL 쿼리를 src/data 아래의 별도 파일에 배치하고 내보낼 수 있습니다:
// file `src/data/categories.js`
export const QUERY_ALL_CATEGORIES = gql`
{
categories(first: 10000) {
edges {
node {
databaseId
description
id
name
slug
}
}
}
}
`;애플리케이션은 GraphQL 쿼리를 가져와서 사용할 수 있습니다:
import { QUERY_ALL_CATEGORIES } from 'data/categories';
export async function getAllCategories() {
const apolloClient = getApolloClient();
const data = await apolloClient.query({
query: QUERY_ALL_CATEGORIES,
});
const categories = data?.data.categories.edges.map(({ node = {} }) => node);
return {
categories,
};
}이 설정 덕분에 모든 변경은 src/data 아래의 파일에 대해서만 수행하면 됩니다.
별칭을 사용하여 필드 이름을 적합하게 만들기
필드 별칭을 사용하여 두 번째 GraphQL 서버의 응답 내 필드 이름을 첫 번째 서버의 필드 이름으로 변경할 수 있습니다.
이렇게 하면 필드 postCategories·id·globalID를 애플리케이션이 기대하는 이름인 categories·categoryId·id로 각각 가져올 수 있습니다:
{
categories: postCategories(pagination: { limit: 10000 }) {
categoryId: id
description
id: globalID
name
slug
}
}필드 categories에는 인수 first가 있고, 대응하는 필드 postCategories는 인수 pagination.limit을 사용한다는 점에 유의하시기 바랍니다. 그러나 필드 인수는 응답 내 필드 이름에 반영되지 않으므로 신경 쓸 필요가 없습니다.
self 필드를 사용하여 응답 형태를 적합하게 만들기
마지막 과제는 조금 더 까다롭습니다. Cursor Connections 사양에서 오는 edges와 node의 추가 레벨을 덧붙여 응답 형태를 변경해야 합니다.
이를 위해 GraphQL 스키마의 모든 타입에 self 필드를 추가합니다. 이 필드는 적용된 오브젝트 자체를 그대로 반환합니다:
type QueryRoot {
self: QueryRoot!
}
type Post {
self: Post!
}
type User {
self: User!
}self 필드를 사용하면 쿼리 대상 오브젝트를 벗어나지 않고 쿼리에 추가 레벨을 덧붙일 수 있습니다. 이 쿼리를 실행하면:
{
__typename
self {
__typename
}
post(by: { id: 1 }) {
self {
id
__typename
}
}
user(by: { id: 1 }) {
self {
id
__typename
}
}
}...다음 응답이 반환됩니다:
{
"data": {
"__typename": "QueryRoot",
"self": {
"__typename": "QueryRoot"
},
"post": {
"self": {
"id": 1,
"__typename": "Post"
}
},
"user": {
"self": {
"id": 1,
"__typename": "User"
}
}
}
}이제 self를 사용하여 nodes와 edge 레벨을 인위적으로 덧붙일 수 있습니다:
{
categories: self {
edges: postCategories(pagination: { limit: 10000 }) {
node: self {
categoryId: id
description
id: globalID
name
slug
}
}
}
}GraphQL 스키마에서 edges와 self의 오브젝트 타입은 명백히 다릅니다. 그러나 애플리케이션은 GraphQL 서버에서 모델링된 실제 오브젝트와 직접 상호 작용하지 않으므로 문제가 되지 않습니다. 대신 JSON 오브젝트로 데이터를 수신하기 때문에, PostConnection 오브젝트에서 오는 필드든 Post 오브젝트에서 오는 필드든 해당 데이터는 동일합니다.
categories 필드는 self를 통해 해결되고, edges는 postCategories를 통해 해결되며, 반대 순서가 아니라는 점에 유의하시기 바랍니다. 이는 반환되는 요소의 카디널리티가 Cursor Connections 사양을 사용하는 필드에서 정의된 것과 일치하도록 하기 위함입니다:
type RootQuery {
categories: RootQueryToCategoryConnection
}
type RootQueryToCategoryConnection {
edges: [RootQueryToCategoryConnectionEdge]
}
type RootQueryToCategoryConnectionEdge {
node: Category
}적합화된 GraphQL 쿼리가 반대 순서(즉, categories: postCategories와 edges: self를 쿼리하는 형태)였다면, 데이터에 대한 접근은 실패합니다. data.categories가 배열이 되어 다음을 실행할 때 data.categories.edges가 오류를 발생시키기 때문입니다:
const categories = data?.data.categories.edges.map(({ node = {} }) => node);모든 쿼리 적합화하기
src/data 내의 모든 GraphQL 쿼리에 동일한 전략을 적용하면, 애플리케이션은 한 GraphQL 서버에서 다른 서버로 손쉽게 전환할 수 있게 됩니다.