GraphQL 스키마 설계의 두 가지 접근법과 실전 타입 설계 패턴을 다룹니다. Node 인터페이스, Relay 커서 페이지네이션, 유니온/인터페이스 등 프로덕션 수준의 스키마 설계 원칙을 학습합니다.
GraphQL 스키마를 정의하는 방법은 크게 두 가지로 나뉩니다.
.graphql 파일에 SDL(Schema Definition Language)로 스키마를 먼저 작성한 뒤, 리졸버를 구현합니다.
type Query {
user(id: ID!): User
posts(first: Int!, after: String): PostConnection!
}
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}import type { Resolvers } from './generated/types';
const resolvers: Resolvers = {
Query: {
user: (_parent, args, context) => {
return context.dataSources.users.findById(args.id);
},
},
};장점: 스키마가 API 계약서 역할을 하며, 프런트엔드/백엔드 팀 간 소통이 명확합니다. SDL은 비개발자도 읽기 쉽습니다.
단점: 스키마와 리졸버 사이의 타입 동기화가 수동으로 이루어져야 합니다. GraphQL Code Generator로 이 문제를 완화할 수 있지만 추가 빌드 단계가 필요합니다.
TypeScript 코드에서 스키마를 프로그래밍 방식으로 정의합니다. Pothos나 Nexus 같은 빌더 라이브러리를 사용합니다.
import SchemaBuilder from '@pothos/core';
const builder = new SchemaBuilder({});
builder.queryType({
fields: (t) => ({
user: t.field({
type: UserType,
args: {
id: t.arg.id({ required: true }),
},
resolve: (_parent, args, context) => {
return context.dataSources.users.findById(String(args.id));
},
}),
}),
});
const UserType = builder.objectType('User', {
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
email: t.exposeString('email'),
posts: t.field({
type: [PostType],
resolve: (user, _args, context) => {
return context.dataSources.posts.findByAuthorId(user.id);
},
}),
}),
});장점: 타입 안전성이 컴파일 타임에 보장됩니다. 리졸버와 스키마가 한 곳에 있어 동기화 문제가 없습니다.
단점: SDL보다 코드가 장황합니다. 스키마의 전체 모습을 한눈에 파악하기 어려울 수 있습니다.
2026년 현재 트렌드는 코드 퍼스트로 기울고 있습니다. 특히 Pothos는 TypeScript의 타입 추론을 극대화하여 별도 코드 생성 없이도 완전한 타입 안전성을 제공합니다. 다만 팀의 기존 워크플로와 선호도를 고려하여 결정해야 합니다.
대규모 GraphQL API에서는 Node 인터페이스를 통해 모든 엔티티를 일관되게 조회할 수 있는 구조를 갖추는 것이 좋습니다. 이 패턴은 Relay 사양에서 유래했지만, Relay를 사용하지 않더라도 유용합니다.
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
nodes(ids: [ID!]!): [Node]!
}
type User implements Node {
id: ID!
name: String!
email: String!
}
type Post implements Node {
id: ID!
title: String!
content: String!
}글로벌 ID는 타입 정보와 데이터베이스 ID를 결합하여 시스템 전체에서 고유한 식별자를 만듭니다.
function toGlobalId(typeName: string, id: string): string {
return Buffer.from(`${typeName}:${id}`).toString('base64');
}
function fromGlobalId(globalId: string): { typeName: string; id: string } {
const decoded = Buffer.from(globalId, 'base64').toString('utf8');
const [typeName, id] = decoded.split(':');
return { typeName, id };
}
// 사용 예시
toGlobalId('User', '42'); // "VXNlcjo0Mg=="
fromGlobalId('VXNlcjo0Mg=='); // { typeName: 'User', id: '42' }const resolvers = {
Query: {
node: async (_parent: unknown, args: { id: string }, context: Context) => {
const { typeName, id } = fromGlobalId(args.id);
switch (typeName) {
case 'User':
return context.dataSources.users.findById(id);
case 'Post':
return context.dataSources.posts.findById(id);
default:
return null;
}
},
},
Node: {
__resolveType(obj: { __typename?: string }) {
return obj.__typename ?? null;
},
},
};글로벌 ID의 핵심 이점은 클라이언트 캐싱입니다. Apollo Client나 Relay는 글로벌 ID를 캐시 키로 사용하여 동일 엔티티의 중복 요청을 방지합니다.
목록 데이터를 다루는 GraphQL API에서 가장 널리 채택된 페이지네이션 패턴은 Relay 스타일 Connection 패턴입니다.
오프셋 기반 페이지네이션(page, limit)은 구현이 단순하지만 몇 가지 문제가 있습니다.
OFFSET이 성능 저하를 유발커서 기반 페이지네이션은 이 문제를 해결합니다.
type Query {
posts(
first: Int
after: String
last: Int
before: String
): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}interface PaginationArgs {
first?: number;
after?: string;
last?: number;
before?: string;
}
async function resolvePostConnection(
args: PaginationArgs,
context: Context,
): Promise<PostConnection> {
const limit = args.first ?? args.last ?? 20;
const cursor = args.after ?? args.before;
const decodedCursor = cursor
? Buffer.from(cursor, 'base64').toString('utf8')
: undefined;
const posts = await context.dataSources.posts.findPaginated({
limit: limit + 1, // 다음 페이지 존재 여부 확인용
cursor: decodedCursor,
direction: args.last ? 'backward' : 'forward',
});
const hasMore = posts.length > limit;
const slicedPosts = hasMore ? posts.slice(0, limit) : posts;
const edges = slicedPosts.map((post) => ({
cursor: Buffer.from(post.id).toString('base64'),
node: post,
}));
return {
edges,
pageInfo: {
hasNextPage: args.first ? hasMore : false,
hasPreviousPage: args.last ? hasMore : false,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
totalCount: await context.dataSources.posts.count(),
};
}커서는 불투명(opaque) 문자열이어야 합니다. 클라이언트가 커서의 내부 구조에 의존하면 안 됩니다. Base64 인코딩은 이 불투명성을 보장하는 일반적인 방법입니다.
Mutation에서 복잡한 입력 데이터를 받을 때는 Input 타입을 사용합니다.
input CreatePostInput {
title: String!
content: String!
categoryId: ID!
tags: [String!]
publishAt: DateTime
}
input UpdatePostInput {
title: String
content: String
categoryId: ID
tags: [String!]
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
}
type CreatePostPayload {
post: Post
errors: [UserError!]!
}
type UserError {
field: String!
message: String!
}Mutation 응답에 Payload 타입을 사용하는 것이 모범 사례입니다. 성공 시 결과 데이터를, 실패 시 구조화된 에러 목록을 반환할 수 있습니다.
유한한 선택지를 표현할 때 사용합니다.
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum SortOrder {
ASC
DESC
}
input PostFilter {
status: PostStatus
sortBy: PostSortField
sortOrder: SortOrder
}
enum PostSortField {
CREATED_AT
UPDATED_AT
TITLE
VIEW_COUNT
}서로 다른 타입을 하나의 필드에서 반환해야 할 때 사용합니다.
union SearchResult = Post | User | Comment
type Query {
search(query: String!): [SearchResult!]!
}const resolvers = {
SearchResult: {
__resolveType(obj: { __typename: string }) {
return obj.__typename;
},
},
Query: {
search: async (_parent: unknown, args: { query: string }, context: Context) => {
const [posts, users, comments] = await Promise.all([
context.dataSources.posts.search(args.query),
context.dataSources.users.search(args.query),
context.dataSources.comments.search(args.query),
]);
return [
...posts.map((p) => ({ ...p, __typename: 'Post' as const })),
...users.map((u) => ({ ...u, __typename: 'User' as const })),
...comments.map((c) => ({ ...c, __typename: 'Comment' as const })),
];
},
},
};공통 필드를 공유하는 타입들을 추상화합니다.
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type Post implements Node & Timestamped {
id: ID!
title: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment implements Node & Timestamped {
id: ID!
body: String!
createdAt: DateTime!
updatedAt: DateTime!
}일관된 네이밍은 스키마의 가독성과 유지보수성을 크게 향상시킵니다.
| 대상 | 규칙 | 예시 |
|---|---|---|
| 타입 | PascalCase | UserProfile, PostConnection |
| 필드 | camelCase | firstName, createdAt |
| 열거형 값 | SCREAMING_SNAKE_CASE | PUBLISHED, IN_PROGRESS |
| Input 타입 | 동사 + 명사 + Input | CreatePostInput, UpdateUserInput |
| Payload 타입 | 동사 + 명사 + Payload | CreatePostPayload |
| 인자 | camelCase | first, after, sortBy |
필드명은 약어를 피하고 의미를 명확히 드러내야 합니다. cnt보다 count, desc보다 description이 좋습니다. 스키마는 API의 인터페이스이자 문서입니다.
이번 장에서는 GraphQL 스키마 설계의 핵심 패턴을 살펴보았습니다. 스키마 퍼스트와 코드 퍼스트 접근법 각각의 장단점을 비교했고, Node 인터페이스를 통한 일관된 엔티티 조회, Connection 패턴을 통한 커서 기반 페이지네이션, 그리고 입력 타입과 다형성 타입의 활용법을 학습했습니다.
좋은 스키마 설계는 GraphQL API의 성공을 좌우합니다. 클라이언트 관점에서 직관적이고, 확장에 열려 있으며, 일관된 규칙을 따르는 스키마를 설계하는 것이 핵심입니다.
3장에서는 스키마를 실제로 동작하게 만드는 리졸버 구현을 다룹니다. 리졸버 체인의 실행 흐름, 컨텍스트 설계, 데이터 소스 추상화 패턴, 그리고 Apollo Server와 GraphQL Yoga에서의 구체적인 구현 방법을 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
GraphQL 리졸버 체인의 실행 흐름과 컨텍스트 설계를 이해합니다. 데이터 소스 추상화, 부모-자식 리졸버 관계, Apollo Server와 GraphQL Yoga에서의 실전 구현 패턴을 다룹니다.
REST API의 한계인 오버페칭과 언더페칭 문제를 분석하고, GraphQL의 타입 시스템, 스키마, 리졸버 등 핵심 개념을 소개합니다. 2026년 GraphQL 생태계 현황과 도입 판단 기준을 다룹니다.
GraphQL에서 가장 흔한 성능 문제인 N+1 문제의 원인을 분석하고, DataLoader를 통한 배칭과 캐싱 최적화를 구현합니다. 쿼리 복잡도 분석과 깊이 제한 전략도 다룹니다.