본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 11장: 실전 프로젝트 -- GraphQL API 구축
2026년 2월 26일·웹 개발·

11장: 실전 프로젝트 -- GraphQL API 구축

시리즈의 모든 개념을 종합하여 Apollo Server, Federation, DataLoader 기반의 프로덕션 수준 GraphQL API를 구축합니다. 스키마 설계부터 Docker 배포, 성능 테스트까지 전 과정을 다룹니다.

17분1,550자12개 섹션
graphqlapi-designfrontend
공유
graphql-architecture11 / 11
1234567891011
이전10장: 프로덕션 운영과 모니터링

학습 목표

  • 실전 프로젝트의 전체 아키텍처를 설계한다
  • Federation 기반 서브그래프 스키마를 설계하고 구현한다
  • CRUD 리졸버, 인증/인가, DataLoader를 통합한다
  • 코드 생성과 타입 안전성을 적용한다
  • Docker Compose로 전체 시스템을 배포하고 성능을 검증한다

프로젝트 개요

이번 장에서는 기술 블로그 플랫폼의 GraphQL API를 구축합니다. 시리즈에서 학습한 모든 개념을 실제 코드로 통합합니다.

기능 요구사항

  • 사용자 인증 (회원가입, 로그인, JWT)
  • 포스트 CRUD (작성, 조회, 수정, 삭제)
  • 댓글 시스템
  • 태그 기반 분류
  • 실시간 알림 (새 댓글)

기술 스택

영역기술
게이트웨이Apollo Router
서브그래프Apollo Server 4 + TypeScript
ORMPrisma
인증JWT (jsonwebtoken)
데이터 로딩DataLoader
실시간graphql-ws + Redis PubSub
코드 생성GraphQL Code Generator
컨테이너Docker Compose
DBPostgreSQL
캐시/PubSubRedis

아키텍처 설계

세 개의 서브그래프와 하나의 구독 전용 서버로 구성합니다. 5장에서 배웠듯이 Apollo Federation에서는 Subscription이 지원되지 않으므로 별도 서버로 분리합니다.


프로젝트 구조

text
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.json

데이터 모델

prisma/schema.prisma
prisma
datasource 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])
}

서브그래프 스키마

Users 서브그래프

subgraphs/users/schema.graphql
graphql
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 DateTime

Posts 서브그래프

subgraphs/posts/schema.graphql
graphql
extend 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 DateTime

Comments 서브그래프

subgraphs/comments/schema.graphql
graphql
extend 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 DateTime

리졸버 구현 (Posts 서브그래프)

subgraphs/posts/src/resolvers.ts
typescript
import { 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);
    },
  },
};

DataLoader 구현

subgraphs/posts/src/loaders.ts
typescript
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) ?? []);
    }),
  };
}
Info

DataLoader는 모든 관계 필드에서 사용합니다. 4장에서 학습했듯이 이것은 선택이 아닌 필수입니다. Federation 환경에서 DataLoader가 없으면 서브그래프 간 N+1 문제가 네트워크 수준으로 증폭됩니다.


Docker Compose 배포

docker-compose.yml
yaml
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:

실행

deploy.sh
bash
# 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/health

성능 테스트

k6를 활용한 부하 테스트

load-test.js
javascript
import 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);
}
run-test.sh
bash
k6 run load-test.js

프로덕션 배포 체크리스트

최종 배포 전 확인해야 할 항목을 정리합니다.

보안

  • JWT 비밀 키가 환경 변수에서 주입되는가
  • 프로덕션에서 Persisted Queries 전용 모드가 활성화되었는가
  • 에러 응답에 내부 정보(스택 트레이스, SQL 등)가 노출되지 않는가
  • CORS 설정이 허용된 도메인만 포함하는가
  • 쿼리 깊이/복잡도 제한이 설정되었는가
  • Rate Limiting이 적용되었는가

성능

  • 모든 관계 필드에 DataLoader가 적용되었는가
  • 데이터베이스 인덱스가 적절히 설정되었는가
  • 빈번한 쿼리에 캐싱이 적용되었는가
  • Connection 패턴에 최대 페이지 크기 제한이 있는가

운영

  • 로깅과 트레이싱이 설정되었는가
  • 헬스 체크 엔드포인트가 동작하는가
  • 스키마 레지스트리에 등록되었는가
  • CI에서 스키마 변경 검증이 실행되는가
  • 코드 생성이 CI에 통합되었는가

인프라

  • 데이터베이스 연결 풀이 적절히 설정되었는가
  • Redis 연결에 재시도 전략이 있는가
  • 컨테이너에 리소스 제한(CPU, 메모리)이 설정되었는가
  • 서브그래프가 독립적으로 스케일 아웃 가능한가
Tip

이 체크리스트는 시작점입니다. 프로젝트의 특성에 따라 항목을 추가하거나 조정해야 합니다. 중요한 것은 "한 번 확인하고 끝"이 아니라, 지속적으로 검토하고 개선하는 것입니다.


시리즈 정리

11장에 걸친 "GraphQL 실전 아키텍처" 시리즈를 마무리합니다.

1장에서 REST의 한계와 GraphQL의 핵심 가치를 이해하는 것에서 시작하여, 스키마 설계(2장), 리졸버 구현(3장), N+1 문제 해결(4장)이라는 기반을 다졌습니다. 그 위에 Federation(5장), Subscription(6장), 인증/인가(7장), 캐싱(8장)이라는 프로덕션 필수 요소를 쌓았고, 코드 생성(9장)과 운영 모니터링(10장)으로 개발 워크플로와 안정성을 확보했습니다. 마지막으로 이 모든 것을 하나의 실전 프로젝트(11장)로 통합했습니다.

GraphQL은 도구입니다. 도구는 잘 사용될 때 가치를 발합니다. 이 시리즈에서 다룬 패턴과 원칙이 여러분의 프로젝트에서 실제 문제를 해결하는 데 도움이 되기를 바랍니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#graphql#api-design#frontend

관련 글

웹 개발

10장: 프로덕션 운영과 모니터링

GraphQL API의 프로덕션 운영 전략을 다룹니다. 스키마 변경 관리, Breaking Change 감지, 스키마 레지스트리, 쿼리 제한, 관측 가능성, 에러 처리, 성능 모니터링을 학습합니다.

2026년 2월 24일·13분
웹 개발

9장: 코드 생성과 타입 안전성

GraphQL Code Generator를 활용한 TypeScript 타입 생성, React 훅 자동 생성, Fragment Colocation 패턴을 다룹니다. 코드 퍼스트 스키마 빌더와의 비교, CI 통합 전략도 학습합니다.

2026년 2월 22일·13분
웹 개발

8장: 캐싱 전략

GraphQL API의 다양한 캐싱 전략을 다룹니다. HTTP 캐싱의 한계, Persisted Queries를 통한 CDN 캐싱, @cacheControl 디렉티브, Redis 기반 리졸버 캐싱, 캐시 무효화 패턴을 학습합니다.

2026년 2월 20일·13분
이전 글10장: 프로덕션 운영과 모니터링

댓글

목차

약 17분 남음
  • 학습 목표
  • 프로젝트 개요
    • 기능 요구사항
    • 기술 스택
  • 아키텍처 설계
  • 프로젝트 구조
  • 데이터 모델
  • 서브그래프 스키마
    • Users 서브그래프
    • Posts 서브그래프
    • Comments 서브그래프
  • 리졸버 구현 (Posts 서브그래프)
  • DataLoader 구현
  • Docker Compose 배포
    • 실행
  • 성능 테스트
    • k6를 활용한 부하 테스트
  • 프로덕션 배포 체크리스트
    • 보안
    • 성능
    • 운영
    • 인프라
  • 시리즈 정리