🔷 GraphQL3. N+1 & DataLoader02-dataloader-pattern

02 · DataLoader Pattern

이 문서가 답하는 질문: DataLoader는 정확히 무엇이고, 어떻게 생겼으며, 왜 사실상 표준이 되었나? 한 줄 답: “DataLoader는 batchFn(keys[]) → Promise<values[]> 한 시그니처 위에, key 순서 invariantper-request scope 두 약속을 얹은 작은 라이브러리다.”


Why — 왜 라이브러리화가 필요했나

이전 문서(01)에서 본 N+1 문제. 누구나 처음에는 손으로 batching을 짠다.

// 손으로 batching 짜기 — 작동은 한다
const userIds = users.map((u) => u.id);
const posts = await db.posts.findMany({ where: { user_id: { in: userIds } } });
const postsByUser = groupBy(posts, "user_id");
return users.map((u) => ({ ...u, posts: postsByUser[u.id] ?? [] }));

문제는 — resolver 모델이 부모-자식을 분리하기 때문에, 이 코드를 어느 자리에 두느냐가 어색하다. Query.users에서 짜면 posts를 미리 fetch하지만, 클라이언트가 posts를 요청 안 했을 때는 낭비다. User.posts에서 짜면 각 user마다 한 번씩 호출되어 N+1이 다시 나온다.

GraphQL 실행기는 깊이 우선 평가를 하지만 형제 필드는 병렬로 평가한다 — 즉, 같은 부모의 자식 필드들같은 tick에 User.posts(user₁), User.posts(user₂), … User.posts(userₙ)호출만 된다. 그것들이 return하기 전에 모이면 batch가 가능하다.

핵심 통찰: resolver 호출 자체는 이미 batch되어 있다. 모자란 건 그 호출들을 모아서 한 번에 fetch하는 어댑터뿐이다.

DataLoader가 한 일은 그 어댑터이름시그니처를 준 것이다.


How — DataLoader의 모양

핵심 시그니처

import DataLoader from "dataloader";
 
const userLoader = new DataLoader<string, User>(async (keys) => {
  // keys: readonly string[]    — 같은 tick에 들어온 모든 key
  // return: User[]              — keys와 같은 순서, 같은 길이
  const users = await db.users.findMany({ where: { id: { in: [...keys] } } });
  const byId = new Map(users.map((u) => [u.id, u]));
  return keys.map((k) => byId.get(k) ?? new Error(`user ${k} not found`));
});
 
// 사용
const user = await userLoader.load("user-42");
const users = await userLoader.loadMany(["user-1", "user-2", "user-3"]);

이게 거의 전부다. 30줄짜리 라이브러리가 사실상 표준이 된 이유는, 시그니처가 그만큼 미니멀해서다.

동작 흐름

세 가지 일이 순서대로 일어난다.

  1. load(key)가 호출되면 — DataLoader는 내부 queue에 key를 추가하고, 그 key를 해소할 Promise를 즉시 반환한다 (아직 fetch는 안 일어났다).
  2. 같은 event loop tick 동안 모인 모든 key들이 — 다음 tick의 시작batchFn(keys) 한 번으로 dispatch된다.
  3. batchFn이 반환한 values[] 배열이 — 각 Promise에 매칭되어 resolve된다.

이 동작이 어떻게 시간 위에서 펼쳐지는지는 다음 문서(04)에서 본다.


What — 두 가지 invariant

Invariant 1 — keys.length === values.length

batchFn이 받은 keys 배열의 길이와 반환한 values 배열의 길이가 반드시 같아야 한다.

// ❌ 위험: DB가 일부 key를 못 찾으면 길이가 안 맞는다
new DataLoader(async (keys) => {
  return await db.users.findMany({ where: { id: { in: [...keys] } } });
  // 만약 keys = ['1','2','3']인데 user-2가 삭제됐다면 → 길이 2가 반환
  // DataLoader는 어느 Promise를 resolve할지 모른다
});
// ✅ 안전: 못 찾은 자리는 null 또는 Error로 채운다
new DataLoader(async (keys) => {
  const found = await db.users.findMany({ where: { id: { in: [...keys] } } });
  const byId = new Map(found.map((u) => [u.id, u]));
  return keys.map((k) => byId.get(k) ?? null);
});

Invariant 2 — values[i]keys[i]에 대응

순서가 반드시 같아야 한다. DataLoader는 index로 매칭하지 key로 매칭하지 않는다.

이건 성능 결정이다 — key가 객체일 수도 있고 직렬화 비용이 있다. 그래서 호출자가 순서를 보장하는 책임을 진다.

// ❌ 데이터베이스가 임의 순서로 반환 → key 순서와 다름
new DataLoader(async (keys) => {
  return await db.users.findMany({ where: { id: { in: [...keys] } } });
  // SQL의 IN 절은 순서를 보장하지 않는다!
});
// ✅ Map으로 재정렬
new DataLoader(async (keys) => {
  const rows = await db.users.findMany({ where: { id: { in: [...keys] } } });
  const byId = new Map(rows.map((u) => [u.id, u]));
  return keys.map((k) => byId.get(k) ?? null);
});

재정렬이 거의 모든 DataLoader 구현의 공통 패턴이다.

부분 에러 — Error 객체를 값으로 반환하면 그 Promise만 reject

new DataLoader(async (keys) => {
  return keys.map((k) => {
    if (k === "blocked") return new Error("forbidden");
    return findUser(k);
  });
});
 
await userLoader.load("user-1"); // → User₁ (성공)
await userLoader.load("blocked"); // → throws Error("forbidden")

이게 GraphQL의 partial response 모델과 잘 맞는다 — 한 필드의 에러가 전체 응답을 죽이지 않는다.


What — GraphQL context와 합쳐 쓰는 표준 패턴

DataLoader 인스턴스는 요청마다 새로 만든다. 그래서 거의 모든 GraphQL 서버 예시는 context factory에 loaders를 둔다.

// Apollo Server / Yoga / Mercurius 모두 동일한 모양
const server = new ApolloServer({
  schema,
  context: ({ req }) => ({
    user: req.user,
    loaders: {
      userById: new DataLoader((ids: string[]) =>
        batchLoadUsers(ids),
      ),
      postsByUserId: new DataLoader((ids: string[]) =>
        batchLoadPostsByUserIds(ids),
      ),
    },
  }),
});
 
// resolver에서:
const resolvers = {
  User: {
    posts: (user, _, { loaders }) => loaders.postsByUserId.load(user.id),
    //                              ↑ 같은 tick에 모든 user가 모이고 한 번에 batch
  },
};

요청 끝나면 loaders도 GC — 캐시도, queue도, 모두 한 요청 수명에 묶인다. 왜 그래야 하는지는 다음 문서(03)에서.


What-if — 잘못 쓰는 사례 카탈로그

함정 1 — 전역 DataLoader

// ❌ 절대 하지 말 것
const userLoader = new DataLoader(batchFn);
// 서버 부팅 시 한 번만 만든다 → 모든 요청이 같은 캐시 공유 → 보안 사고

다른 user의 데이터가 cache hit으로 새어 나간다. 반드시 요청마다 새 인스턴스.

함정 2 — await를 잘못 끼움

// ❌ 한 번에 한 user씩 직렬 처리 — batch 안 됨
for (const user of users) {
  const posts = await postsLoader.load(user.id);
  //            ↑ 이걸 기다린 뒤 다음 load를 호출 → 매번 tick이 분리됨
}
 
// ✅ Promise를 먼저 다 만들고 한 번에 await — 한 tick에 batch
const results = await Promise.all(
  users.map((u) => postsLoader.load(u.id)),
);

함정 3 — key가 객체

// ❌ JS 객체는 reference 비교 → 매번 다른 key로 인식
loader.load({ userId: "1", tenantId: "a" });
loader.load({ userId: "1", tenantId: "a" });
// 위 둘은 *다른* key로 취급되어 batch도 dedupe도 안 됨
 
// ✅ cacheKeyFn으로 직렬화
new DataLoader(batchFn, {
  cacheKeyFn: (k) => `${k.tenantId}:${k.userId}`,
});

함정 4 — batch에 maxBatchSize 미설정

DB나 외부 API에 따라 IN 절 최대 길이가 있다 (Postgres ~65k, Redis MGET 한계 등). batch가 그 한계를 넘으면 DB가 에러를 낸다.

new DataLoader(batchFn, {
  maxBatchSize: 1000, // 1000개씩 쪼개서 호출
});

함정 5 — batch가 일어났는데 같지 않은 종류를 섞음

DataLoader는 인스턴스 단위로 batch한다. userLoaderpostLoader각자 batch한다. 한 인스턴스에서 서로 다른 SQL이 필요한 key를 받으면 안 된다.

// ❌ "엔티티 한 개로 다 처리하자"
const entityLoader = new DataLoader(async (keys: string[]) => {
  // keys = ['user:1', 'post:2', 'comment:3']
  // 각각 다른 테이블 → batch 의미 없음
});
 
// ✅ 엔티티별로 분리
const userLoader = new DataLoader(batchUsers);
const postLoader = new DataLoader(batchPosts);

Insight — DataLoader가 사실상 표준이 된 이유

2015년 Facebook engineering 블로그에서 Lee Byron이 DataLoader: A modern batch-loading utility를 공개했을 때 강조한 세 가지가 있다.

  1. “이건 GraphQL 라이브러리가 아니다” — Node.js의 Promise + event loop만 가정한다. Express에도, REST 클라이언트에도, gRPC client wrapper에도 쓸 수 있다.
  2. “이건 100줄짜리 라이브러리다” — 실제 공식 구현이 ~250줄. 어떤 언어로든 한 시간이면 포팅된다.
  3. “이건 batch + cache지, ORM이 아니다” — eager loading 전략을 결정하지 않는다. 그저 같은 tick의 요청들을 모은다.

미니멀함이 사실상 표준화의 이유다. 다른 언어들도 같은 시그니처를 그대로 따라가서 — graphql-php에는 webonyx/graphql-php deferred resolver, Java에는 java-dataloader, Python에는 aiodataloader, Go에는 graph-gophers/dataloader가 모두 같은 batchFn 시그니처다.

한 줄로: DataLoader는 해결책이라기보다 공통 어휘다. “여기에 batch loader를 끼우자”가 말로 통하는 자리가 된 것이 라이브러리의 진짜 기여다.


요약 + Mermaid

핵심 키
시그니처batchFn(keys: K[]) => Promise<V[]>
invariant 1keys.length === values.length
invariant 2values[i]keys[i]에 대응
scopeper-request (context 안에)
부분 에러Error 객체를 값으로 반환
표준화 이유미니멀 + 언어 독립 + GraphQL 비종속

한 줄 결론 — DataLoader는 batching의 공통 어휘다. 시그니처가 작고 규칙이 둘뿐이라서 어떤 백엔드에도 맞춰 적용된다. 다음 문서(03)는 그 안의 cache 부분을 본다.