시리즈의 모든 개념을 종합하여 Apollo Server, Federation, DataLoader 기반의 프로덕션 수준 GraphQL API를 구축합니다. 스키마 설계부터 Docker 배포, 성능 테스트까지 전 과정을 다룹니다.
이번 장에서는 기술 블로그 플랫폼의 GraphQL API를 구축합니다. 시리즈에서 학습한 모든 개념을 실제 코드로 통합합니다.
| 영역 | 기술 |
|---|---|
| 게이트웨이 | Apollo Router |
| 서브그래프 | Apollo Server 4 + TypeScript |
| ORM | Prisma |
| 인증 | JWT (jsonwebtoken) |
| 데이터 로딩 | DataLoader |
| 실시간 | graphql-ws + Redis PubSub |
| 코드 생성 | GraphQL Code Generator |
| 컨테이너 | Docker Compose |
| DB | PostgreSQL |
| 캐시/PubSub | Redis |
세 개의 서브그래프와 하나의 구독 전용 서버로 구성합니다. 5장에서 배웠듯이 Apollo Federation에서는 Subscription이 지원되지 않으므로 별도 서버로 분리합니다.
graphql-blog-api/
docker-compose.yml
router/
router.yaml
supergraph.yaml
subgraphs/
users/
schema.graphql
src/
index.ts
resolvers.ts
data-sources.ts
loaders.ts
context.ts
posts/
schema.graphql
src/
index.ts
resolvers.ts
data-sources.ts
loaders.ts
comments/
schema.graphql
src/
index.ts
resolvers.ts
data-sources.ts
loaders.ts
subscriptions/
src/
index.ts
prisma/
schema.prisma
codegen.ts
package.jsondatasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role Role @default(USER)
avatar String?
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Role {
ADMIN
EDITOR
USER
}
model Post {
id String @id @default(cuid())
title String
content String
summary String?
status PostStatus @default(DRAFT)
author User @relation(fields: [authorId], references: [id])
authorId String
tags Tag[]
comments Comment[]
viewCount Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([status, publishedAt])
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
}
model Comment {
id String @id @default(cuid())
body String
author User @relation(fields: [authorId], references: [id])
authorId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([postId])
@@index([authorId])
}extend schema
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type Query {
me: User
user(id: ID!): User
}
type Mutation {
register(input: RegisterInput!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
updateProfile(input: UpdateProfileInput!): User!
}
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
avatar: String
role: Role!
createdAt: DateTime!
}
type AuthPayload {
token: String!
user: User!
}
input RegisterInput {
name: String!
email: String!
password: String!
}
input UpdateProfileInput {
name: String
avatar: String
}
enum Role {
ADMIN
EDITOR
USER
}
scalar DateTimeextend schema
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external"])
type Query {
post(id: ID!): Post
posts(
first: Int! = 10
after: String
status: PostStatus
tag: String
): PostConnection!
popularPosts(limit: Int! = 5): [Post!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
}
type Post @key(fields: "id") {
id: ID!
title: String!
content: String!
summary: String
status: PostStatus!
author: User!
tags: [Tag!]!
viewCount: Int!
publishedAt: DateTime
createdAt: DateTime!
}
type User @key(fields: "id") {
id: ID!
posts: [Post!]!
}
type Tag {
id: ID!
name: String!
postCount: Int!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
input CreatePostInput {
title: String!
content: String!
summary: String
tags: [String!]
}
input UpdatePostInput {
title: String
content: String
summary: String
tags: [String!]
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
scalar DateTimeextend schema
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type Query {
comments(postId: ID!, first: Int! = 20, after: String): CommentConnection!
}
type Mutation {
addComment(postId: ID!, body: String!): Comment!
updateComment(id: ID!, body: String!): Comment!
deleteComment(id: ID!): Boolean!
}
type Post @key(fields: "id") {
id: ID!
comments(first: Int! = 20, after: String): CommentConnection!
commentCount: Int!
}
type Comment @key(fields: "id") {
id: ID!
body: String!
author: User!
post: Post!
createdAt: DateTime!
}
type User @key(fields: "id") {
id: ID!
}
type CommentConnection {
edges: [CommentEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CommentEdge {
cursor: String!
node: Comment!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
scalar DateTimeimport { GraphQLError } from 'graphql';
import type { Context } from './context';
interface CreatePostInput {
title: string;
content: string;
summary?: string;
tags?: string[];
}
interface UpdatePostInput {
title?: string;
content?: string;
summary?: string;
tags?: string[];
}
interface PaginationArgs {
first: number;
after?: string;
status?: string;
tag?: string;
}
export const resolvers = {
Query: {
post: async (_parent: unknown, args: { id: string }, context: Context) => {
const post = await context.dataSources.posts.findById(args.id);
if (!post) return null;
// 공개된 포스트이거나 작성자 본인이면 조회 가능
if (post.status !== 'PUBLISHED') {
if (!context.currentUser || context.currentUser.id !== post.authorId) {
return null;
}
}
// 조회수 증가 (비동기, 에러 무시)
context.dataSources.posts.incrementViewCount(post.id).catch(() => {});
return post;
},
posts: async (_parent: unknown, args: PaginationArgs, context: Context) => {
return context.dataSources.posts.findPaginated(args);
},
popularPosts: async (_parent: unknown, args: { limit: number }, context: Context) => {
return context.dataSources.posts.findPopular(args.limit);
},
},
Mutation: {
createPost: async (
_parent: unknown,
args: { input: CreatePostInput },
context: Context,
) => {
if (!context.currentUser) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return context.dataSources.posts.create(
args.input,
context.currentUser.id,
);
},
updatePost: async (
_parent: unknown,
args: { id: string; input: UpdatePostInput },
context: Context,
) => {
if (!context.currentUser) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const post = await context.dataSources.posts.findById(args.id);
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' },
});
}
if (
post.authorId !== context.currentUser.id &&
context.currentUser.role !== 'ADMIN'
) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return context.dataSources.posts.update(args.id, args.input);
},
deletePost: async (
_parent: unknown,
args: { id: string },
context: Context,
) => {
if (!context.currentUser) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const post = await context.dataSources.posts.findById(args.id);
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' },
});
}
if (
post.authorId !== context.currentUser.id &&
context.currentUser.role !== 'ADMIN'
) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
await context.dataSources.posts.delete(args.id);
return true;
},
publishPost: async (
_parent: unknown,
args: { id: string },
context: Context,
) => {
if (!context.currentUser) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const post = await context.dataSources.posts.findById(args.id);
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' },
});
}
if (
post.authorId !== context.currentUser.id &&
!['ADMIN', 'EDITOR'].includes(context.currentUser.role)
) {
throw new GraphQLError('Not authorized to publish', {
extensions: { code: 'FORBIDDEN' },
});
}
return context.dataSources.posts.publish(args.id);
},
},
Post: {
author: (parent: { authorId: string }) => {
// Federation: 참조만 반환, Router가 Users 서브그래프에 요청
return { __typename: 'User' as const, id: parent.authorId };
},
tags: async (parent: { id: string }, _args: unknown, context: Context) => {
return context.loaders.tagsByPostId.load(parent.id);
},
},
User: {
// 참조 리졸버: Router가 User의 posts 필드 요청 시 호출
__resolveReference: async (
ref: { __typename: 'User'; id: string },
context: Context,
) => {
return { id: ref.id };
},
posts: async (parent: { id: string }, _args: unknown, context: Context) => {
return context.loaders.postsByAuthorId.load(parent.id);
},
},
};import DataLoader from 'dataloader';
import type { PrismaClient, Post, Tag } from '@prisma/client';
export function createLoaders(prisma: PrismaClient) {
return {
postById: new DataLoader<string, Post | null>(async (ids) => {
const posts = await prisma.post.findMany({
where: { id: { in: [...ids] } },
});
const map = new Map(posts.map((p) => [p.id, p]));
return ids.map((id) => map.get(id) ?? null);
}),
postsByAuthorId: new DataLoader<string, Post[]>(async (authorIds) => {
const posts = await prisma.post.findMany({
where: {
authorId: { in: [...authorIds] },
status: 'PUBLISHED',
},
orderBy: { createdAt: 'desc' },
});
const grouped = new Map<string, Post[]>();
for (const post of posts) {
const list = grouped.get(post.authorId) ?? [];
list.push(post);
grouped.set(post.authorId, list);
}
return authorIds.map((id) => grouped.get(id) ?? []);
}),
tagsByPostId: new DataLoader<string, Tag[]>(async (postIds) => {
const posts = await prisma.post.findMany({
where: { id: { in: [...postIds] } },
include: { tags: true },
});
const map = new Map(posts.map((p) => [p.id, p.tags]));
return postIds.map((id) => map.get(id) ?? []);
}),
};
}DataLoader는 모든 관계 필드에서 사용합니다. 4장에서 학습했듯이 이것은 선택이 아닌 필수입니다. Federation 환경에서 DataLoader가 없으면 서브그래프 간 N+1 문제가 네트워크 수준으로 증폭됩니다.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: graphql_blog
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
users-subgraph:
build:
context: .
dockerfile: subgraphs/users/Dockerfile
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/graphql_blog
JWT_SECRET: your-secret-key-change-in-production
PORT: "4001"
ports:
- "4001:4001"
depends_on:
- postgres
posts-subgraph:
build:
context: .
dockerfile: subgraphs/posts/Dockerfile
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/graphql_blog
PORT: "4002"
ports:
- "4002:4002"
depends_on:
- postgres
comments-subgraph:
build:
context: .
dockerfile: subgraphs/comments/Dockerfile
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/graphql_blog
REDIS_URL: redis://redis:6379
PORT: "4003"
ports:
- "4003:4003"
depends_on:
- postgres
- redis
subscriptions:
build:
context: .
dockerfile: subscriptions/Dockerfile
environment:
REDIS_URL: redis://redis:6379
PORT: "4004"
ports:
- "4004:4004"
depends_on:
- redis
router:
image: ghcr.io/apollographql/router:v1.43.0
volumes:
- ./router/router.yaml:/dist/config/router.yaml
- ./router/supergraph.graphql:/dist/config/supergraph.graphql
ports:
- "4000:4000"
command:
- --config
- /dist/config/router.yaml
- --supergraph
- /dist/config/supergraph.graphql
depends_on:
- users-subgraph
- posts-subgraph
- comments-subgraph
volumes:
pgdata:# 1. 슈퍼그래프 스키마 생성
rover supergraph compose --config router/supergraph.yaml > router/supergraph.graphql
# 2. Docker Compose 실행
docker compose up -d
# 3. 데이터베이스 마이그레이션
docker compose exec users-subgraph npx prisma migrate deploy
# 4. 상태 확인
curl http://localhost:4000/healthimport http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 }, // 램프 업
{ duration: '1m', target: 50 }, // 유지
{ duration: '30s', target: 100 }, // 피크
{ duration: '1m', target: 100 }, // 유지
{ duration: '30s', target: 0 }, // 램프 다운
],
thresholds: {
http_req_duration: ['p(95)<500'], // p95 응답 시간 500ms 이하
http_req_failed: ['rate<0.01'], // 에러율 1% 이하
},
};
const GRAPHQL_URL = 'http://localhost:4000/graphql';
const POSTS_QUERY = JSON.stringify({
query: `
query ListPosts {
posts(first: 10) {
edges {
node {
id
title
summary
author { name avatar }
tags { name }
commentCount
createdAt
}
}
pageInfo { hasNextPage endCursor }
totalCount
}
}
`,
});
export default function () {
const response = http.post(GRAPHQL_URL, POSTS_QUERY, {
headers: { 'Content-Type': 'application/json' },
});
check(response, {
'status is 200': (r) => r.status === 200,
'no errors': (r) => {
const body = JSON.parse(r.body);
return !body.errors;
},
'has data': (r) => {
const body = JSON.parse(r.body);
return body.data?.posts?.edges?.length > 0;
},
});
sleep(1);
}k6 run load-test.js최종 배포 전 확인해야 할 항목을 정리합니다.
이 체크리스트는 시작점입니다. 프로젝트의 특성에 따라 항목을 추가하거나 조정해야 합니다. 중요한 것은 "한 번 확인하고 끝"이 아니라, 지속적으로 검토하고 개선하는 것입니다.
11장에 걸친 "GraphQL 실전 아키텍처" 시리즈를 마무리합니다.
1장에서 REST의 한계와 GraphQL의 핵심 가치를 이해하는 것에서 시작하여, 스키마 설계(2장), 리졸버 구현(3장), N+1 문제 해결(4장)이라는 기반을 다졌습니다. 그 위에 Federation(5장), Subscription(6장), 인증/인가(7장), 캐싱(8장)이라는 프로덕션 필수 요소를 쌓았고, 코드 생성(9장)과 운영 모니터링(10장)으로 개발 워크플로와 안정성을 확보했습니다. 마지막으로 이 모든 것을 하나의 실전 프로젝트(11장)로 통합했습니다.
GraphQL은 도구입니다. 도구는 잘 사용될 때 가치를 발합니다. 이 시리즈에서 다룬 패턴과 원칙이 여러분의 프로젝트에서 실제 문제를 해결하는 데 도움이 되기를 바랍니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
GraphQL API의 프로덕션 운영 전략을 다룹니다. 스키마 변경 관리, Breaking Change 감지, 스키마 레지스트리, 쿼리 제한, 관측 가능성, 에러 처리, 성능 모니터링을 학습합니다.
GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴을 다룹니다. 코드 퍼스트 스키마 빌더와의 비교, CI 통합 전략도 학습합니다.
GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.