본문으로 건너뛰기
yutils

GraphQL 은 어떻게 동작할까?

GraphQL 이 REST 와 다른 점 — schema-first 설계, resolver tree, N+1 문제와 DataLoader 의 해결, mutation·subscription·federation, 그리고 GraphQL 이 과한 케이스.

약 9분 읽기

REST API 는 "한 endpoint = 하나의 응답". 화면에 "user 이름 + post 목록 + 친구 5 명" 을 띄우려면 3-4 round trip 또는 over-fetch. GraphQL 은 "client 가 정확히 원하는 모양을 한 번에 요청" — 한 endpoint, 한 query, 정확한 응답. 어떻게? 이 가이드는 GraphQL 의 schema, resolver tree, 가장 흔한 N+1 문제와 DataLoader 의 해결, mutation / subscription / federation, 그리고 GraphQL 이 과한 시나리오를 정리한다.

REST vs GraphQL — 한 줄 예시

# REST — 여러 endpoint
GET /users/42                 → 사용자 정보
GET /users/42/posts            → 사용자 post 들
GET /users/42/friends?limit=5 → 친구 5 명

3 round trips + 각 응답에 안 쓰는 필드 다수

# GraphQL — 한 query
POST /graphql
{
  user(id: 42) {
    name
    posts {
      title
      createdAt
    }
    friends(limit: 5) {
      name
      avatar
    }
  }
}

1 round trip. 응답에 정확히 요청한 필드만.

핵심 — Schema First

GraphQL 의 출발점은 schema. SDL (Schema Definition Language):

type User {
  id: ID!
  name: String!
  email: String
  posts: [Post!]!
  friends(limit: Int): [User!]!
}

type Post {
  id: ID!
  title: String!
  content: String
  author: User!
  createdAt: DateTime!
}

type Query {
  user(id: ID!): User
  posts(limit: Int = 10): [Post!]!
}

type Mutation {
  createPost(title: String!, content: String): Post!
}

Schema 가 contract. Client / server 둘 다 schema 기준으로 작업. ! 는 non-nullable. [Post!]! 는 빈 array 는 가능, null array X.

Resolver — 각 field 의 값 채우는 함수

const resolvers = {
  Query: {
    user: async (_, {id}) => {
      return db.users.findById(id);
    },
  },
  User: {
    name: (user) => user.name,        // 직접 매핑 (default behavior)
    posts: async (user) => {
      return db.posts.findByUserId(user.id);
    },
    friends: async (user, {limit}) => {
      return db.friendships.findFriends(user.id, limit);
    },
  },
  Post: {
    author: async (post) => {
      return db.users.findById(post.userId);
    },
  },
};

각 field 마다 resolver. field 단위로 lazy 실행 — client 가 요청한 field 만 resolver 호출.

Query 의 실행 — resolver tree

Query:
{
  user(id: 1) {
    name
    posts {
      title
      author {
        name
      }
    }
  }
}

Server 실행:
1. Query.user(id: 1) resolver → User{id:1, name:"Alice", ...}
2. User.name → "Alice"  (default field resolver, 직접 매핑)
3. User.posts resolver → [Post1, Post2, Post3]
4. 각 Post 마다:
   - Post.title → 직접 매핑
   - Post.author resolver → User{id:1, name:"Alice"}  ← 같은 사용자
   - User.name → "Alice"

결과 JSON:

{
  "data": {
    "user": {
      "name": "Alice",
      "posts": [
        {"title": "...", "author": {"name": "Alice"}},
        {"title": "...", "author": {"name": "Alice"}},
        {"title": "...", "author": {"name": "Alice"}}
      ]
    }
  }
}

N+1 문제 — GraphQL 의 가장 흔한 함정

Query:
{
  posts {              ← 1 query
    title
    author { name }    ← 각 post 마다 1 query → 10 posts = 10 queries
  }
}

순진한 실행:
1. db.posts.findAll()      → 10 posts
2. for each post:
   db.users.findById(p.userId) → 10 queries
Total: 11 queries (1 + N)

수천 post 면 수천 queries → DB 폭주

DataLoader — batching + caching

import DataLoader from 'dataloader';

const userLoader = new DataLoader(async (userIds) => {
  // 한 번에 fetch
  const users = await db.users.findByIds(userIds);
  // userIds 순서대로 정렬
  return userIds.map(id => users.find(u => u.id === id));
});

// resolver 에서
Post: {
  author: async (post) => {
    return userLoader.load(post.userId);
    // 같은 tick 의 모든 load 호출이 한 번에 batch
  },
},

결과:
1. db.posts.findAll()         → 10 posts
2. userLoader.load 호출 10 번 → DataLoader 가 batch → 1 query
   db.users.findByIds([1,2,3,...]) → 10 users
Total: 2 queries

DataLoader = micro-task 안 모든 load(id) 호출 모아 한 번에 batch. + memoization (같은 id 의 두 번째 load 는 cache).

Mutation — write 작업

# Schema
type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

input CreatePostInput {
  title: String!
  content: String
  tags: [String!]
}

# Client query
mutation {
  createPost(input: {title: "Hello", content: "..."}) {
    id
    title
    createdAt
  }
}

# Server response
{
  "data": {
    "createPost": {
      "id": "abc123",
      "title": "Hello",
      "createdAt": "2026-05-23T..."
    }
  }
}

Mutation 도 query 와 같은 endpoint. Query 와 다른 점 — server 가 sequential 실행 보장 (Query 는 parallel 가능).

Subscription — 실시간 push

# Schema
type Subscription {
  postCreated: Post!
}

# Client
subscription {
  postCreated {
    id
    title
    author { name }
  }
}

# Server
pubsub.publish('POST_CREATED', {postCreated: newPost});

→ subscribe 중인 모든 client 에 push

Subscription 은 보통 WebSocket 위. graphql-ws 표준 protocol. SSE 도 가능.

Schema 의 강력함 — introspection

# Special query
{
  __schema {
    types {
      name
      fields {
        name
        type { name }
      }
    }
  }
}

→ 전체 schema 의 metadata 반환

효과:

  • 자동 docs — GraphiQL / GraphQL Playground 가 schema 보고 docs 생성
  • 타입 안전 client — GraphQL Code Generator 가 TypeScript 타입 자동 생성
  • tooling 풍부 — Apollo / Relay / urql 의 cache /optimization

Trade-off — production 에서 introspection 비활성화 권장. attacker 가 schema 보고 internal 구조 파악 막음.

Federation — 분산 GraphQL

Microservice 환경에서 여러 GraphQL service 를 한 endpoint 로:

Service A (users):
type User @key(fields: "id") {
  id: ID!
  name: String!
}

Service B (posts):
type Post {
  id: ID!
  title: String!
  author: User!    ← User 정의는 Service A 에 있음
}

extend type User @key(fields: "id") {
  id: ID! @external
  posts: [Post!]!  ← Service B 가 User 에 posts field 추가
}

Apollo Gateway / Mesh:
- 두 service 의 schema 결합
- query 분석 후 적절한 service 로 라우팅
- 결과 합쳐서 client 에 응답

대형 조직 (Netflix, Walmart) 의 표준. 작은 팀은 monolithic GraphQL 충분.

REST vs GraphQL — 매트릭스

RESTGraphQL
endpointresource 마다 (수십~수백)1 개 (/graphql)
over-fetching흔함없음 (정확히 요청한 것만)
under-fetching흔함 (여러 호출 필요)없음 (한 query 에)
versioning/v1, /v2schema 진화 + @deprecated
cachingHTTP cache 자동 (GET URL 기반)POST 라 어려움. Apollo 의 client cache 별도
toolingOpenAPI / Postman 등GraphiQL / Apollo Studio
learning curveHTTP 만 알면schema 설계 + resolver + N+1 등 필요
file upload자연 (multipart)graphql-upload spec 별도
tooling 성숙30 년10 년

언제 GraphQL X — over-engineering

  • 단순 CRUD app — REST 가 더 빠르고 익숙
  • public API — caching / CDN 활용에 REST 가 유리
  • 파일 upload / streaming 위주 — multipart REST 가 자연
  • 작은 팀 + 단일 client — schema 설계 부담만

GraphQL 적합:

  • 모바일 + web client 둘 다 — 화면마다 다른 데이터 모양
  • 관계 데이터 깊은 nested fetch — REST 의 round-trip 부담
  • 여러 micro service 의 unified API — federation
  • BFF (Backend for Frontend) — client 의 필요에 맞춰 정확히 응답

흔한 함정

1. N+1 무시

DataLoader 안 쓰면 production 에서 DB 폭주. 모든 nested resolver 에 DataLoader 권장.

2. complex query 의 DoS

# 공격자의 query
{
  users {
    posts {
      comments {
        author {
          friends {
            posts {
              comments {
                ...
              }
            }
          }
        }
      }
    }
  }
}

→ 백만 row fetch 가능. server down.

방어:
- query depth limit (graphql-depth-limit)
- query complexity (graphql-query-complexity)
- persisted queries (pre-approved query 만 허용)

3. circular fragment

fragment A on User { friends { ...A } }
                              ↑ 자기 자신 참조
→ 무한 loop. spec 이 막지만 일부 구현 누락.

4. 모든 field 가 nullable

Schema 에 ! 안 박으면 모두 nullable. client 가 매번 null check. name: String! 명시.

5. Error handling 의 모호함

# REST
GET /users/999  →  404 Not Found

# GraphQL
{
  "data": {"user": null},
  "errors": [{"message": "User not found"}]
}

→ HTTP 200 OK. errors array 가 응답에 박힘. client 가 errors 도 체크.

참고 자료

요약

  • GraphQL = client 가 정확히 원하는 모양 요청. 1 endpoint, 1 query, 정확한 응답.
  • Schema-first — SDL 로 타입과 query 정의. Client/server 의 contract.
  • Resolver = field 별 함수. lazy 실행 — 요청된 field 만.
  • N+1 문제는 GraphQL 의 가장 큰 함정 — DataLoader 가 batching + memoization 으로 해결.
  • Mutation = write 작업 (sequential). Subscription = WebSocket 위 실시간 push.
  • Introspection 으로 자동 docs / type-safe client / tooling. production 비활성화 권장.
  • Federation = 여러 GraphQL service 의 unified gateway. microservice 환경 표준.
  • REST vs GraphQL — 화면마다 다른 데이터 / 깊은 nested / 여러 client 면 GraphQL. 단순 CRUD / public API / file upload 는 REST.
가이드 목록으로