🔷 GraphQL3. N+1 & DataLoader04-event-loop-and-tick

04 · Event Loop & Tick

이 문서가 답하는 질문: DataLoader가 “같은 tick에 들어온 요청들을 모은다”는데, 그 tick이 정확히 뭐고, dispatch는 언제 일어나는가? 한 줄 답: “DataLoader는 .load()로 들어온 key를 queue에 쌓고, Node.js의 microtask queue가 비워지기 직전 (= 현재 동기 코드가 끝난 직후) 한 번에 batchFn을 호출한다.”


Why — 왜 이걸 알아야 하나

DataLoader를 그냥 쓰면 대부분 작동한다. 그런데 어느 순간 — batch가 안 되고 매번 단건 호출이 나가는 이상한 자리가 생긴다. 디버깅하면 십중팔구 await의 위치 문제다.

// ❌ batch 안 됨
for (const user of users) {
  await userLoader.load(user.id);
}
 
// ✅ batch 됨
await Promise.all(users.map((u) => userLoader.load(u.id)));

둘의 차이는 event loop의 tick 경계에 있다. 이걸 이해하면 — 어디서 batch가 깨지는지말로 설명할 수 있게 된다.


How — Node.js event loop 한 페이지 요약

큰 그림

Node.js의 한 tick은 대략 이 순서다.

핵심 사실들:

  • 동기 코드가 돌아가는 동안 queue들은 비워지지 않는다.
  • 동기 코드가 return하면 — Node는 queue를 비우기 시작한다.
  • process.nextTick queue가 가장 먼저, 그 다음 microtask(Promise), 그 다음 macrotask(setImmediate, setTimeout, I/O).
  • microtask queue가 비워지는 중에 더 추가되면 — 그것도 다 처리한 뒤에야 macrotask로 간다.

DataLoader는 어디에 끼는가

DataLoader의 기본 동작은 — .load(key)가 호출되면:

  1. 내부 queue에 (key, resolve, reject) 추가
  2. 첫 호출이면 dispatch를 schedule — 기본은 process.nextTick 또는 Promise.resolve().then(...) (microtask)
  3. Promise 반환 (아직 resolve 안 됨)

같은 동기 흐름 안에서 .load()가 100번 더 호출되면 — 모두 같은 queue에 누적된다. 동기 코드가 끝나고 처음 schedule된 dispatch가 실행될 때 — queue 전체가 한 번에 batchFn(keys)로 dispatch된다.

// 의사 코드
class DataLoader {
  load(key) {
    if (this._queue.length === 0) {
      process.nextTick(() => this._dispatch());
      // ↑ 첫 호출에 dispatch를 예약
    }
    return new Promise((resolve, reject) => {
      this._queue.push({ key, resolve, reject });
    });
  }
  _dispatch() {
    const queue = this._queue;
    this._queue = [];
    this._batchFn(queue.map((q) => q.key)).then((values) => {
      queue.forEach((q, i) => q.resolve(values[i]));
    });
  }
}

(실제 DataLoader 구현은 batchScheduleFn 설정도 있고 더 정교하지만, 본질은 위와 같다.)


What — 같은 tick의 의미

케이스 A — 같은 tick (batch 됨)

userLoader.load("1");
userLoader.load("2");
userLoader.load("3");
// ↑ 세 줄 모두 동기적으로 실행 → queue에 3개 누적
// 동기 코드가 끝나면 → batchFn(['1','2','3']) 한 번 호출

케이스 B — Promise.all로 묶음 (batch 됨)

await Promise.all([
  userLoader.load("1"),
  userLoader.load("2"),
  userLoader.load("3"),
]);
// ↑ 세 .load()는 동기적으로 차례로 호출됨 → 같은 tick

케이스 C — for await 직렬 (batch 깨짐)

for (const id of ["1", "2", "3"]) {
  await userLoader.load(id);
}
// ↑ 첫 .load("1")이 dispatch될 때까지 await → batchFn(['1']) 호출
//    그 다음 .load("2") → batchFn(['2'])
//    그 다음 .load("3") → batchFn(['3'])
// 결과: batch 3개, 각각 1개씩 → N+1과 같음

이걸 시간축으로 그리면:

위쪽 그림이 Promise.all, 아래쪽이 직렬 await. 같은 코드가 시간을 어떻게 쓰는가가 batch 여부를 가른다.


What — GraphQL 실행기와 DataLoader의 우연한 호흡

GraphQL 실행기(graphql-js)는 형제 필드를 병렬로 평가한다. 즉:

{
  users {
    posts {
      title
    }
  }
}
  • Query.users resolver가 100명의 user를 반환.
  • User.posts resolver가 100개의 user 각각에 대해 호출된다 — 그런데 직렬이 아니라 거의 동시에.
  • 각 호출은 postsLoader.load(user.id)동기적으로 한 번씩 부르고, Promise를 반환한다.
  • 100개 Promise가 모이고, GraphQL 실행기는 Promise.all로 기다린다.
  • 그 사이에 microtask queue가 dispatch되어 batchFn(['1','2',...,'100']) 한 번 호출.

즉 — GraphQL의 형제 평가 모델과 DataLoader의 microtask dispatch가 우연히 잘 맞물려서 batch가 기본값으로 일어난다. 이게 DataLoader가 GraphQL 자리에서 빛나는 진짜 이유다.


What — batchScheduleFn — 스케줄링을 바꾸기

DataLoader는 언제 dispatch할지를 옵션으로 받는다.

new DataLoader(batchFn, {
  batchScheduleFn: (callback) => {
    process.nextTick(callback);
    // 또는 setImmediate(callback)
    // 또는 setTimeout(callback, 10)  // 10ms 동안 모으기
  },
});
스케줄러시점언제 쓰나
process.nextTick (기본)다음 microtask 사이클거의 모든 경우
setImmediate다음 macrotaskI/O 사이에 yield하고 싶을 때
setTimeout(fn, ms)ms시간 기반 batching — 외부 API 호출을 더 모으고 싶을 때

setTimeout 기반 batching은 latency를 대가로 더 큰 batch를 만든다. 외부 API의 requests per second 한계를 피해야 할 때 흔히 쓴다.


What-if — 트레이스로 batch가 안 되는 자리 잡기

진단 1 — batchFn에 로그 박기

const userLoader = new DataLoader(async (keys) => {
  console.log("[batch] users:", keys.length, keys);
  return db.users.findMany({ where: { id: { in: [...keys] } } });
});

운영에서 batch size가 1이 자주 나오면 — 어딘가에서 직렬 awaittick을 끊는 코드가 있다는 신호.

진단 2 — Apollo Tracing / GraphQL Yoga의 inspector

GraphQL execution time을 필드별로 측정. User.postsN개 호출 × 같은 시간이면 batch 됨, N개 호출이 직렬 시간이면 batch 깨짐.

흔한 batch-깨는 코드

// ❌ for-await 패턴
for (const user of users) {
  user.posts = await postsLoader.load(user.id);
}
 
// ❌ async resolver 안에서 await가 .load 직전에 끼어든 경우
const resolver = async (user) => {
  await sleep(10); // 어떤 사전 작업 — 이게 tick을 끊는다
  return postsLoader.load(user.id);
  // ↑ 이 자리에 도달했을 때 다른 user들의 같은 자리는 다른 tick에 있음
};
 
// ✅ tick을 끊지 않게 — 사전 작업도 모두 Promise.all 안에서
const resolver = (user) => {
  return Promise.resolve()
    .then(() => preWork(user))
    .then(() => postsLoader.load(user.id));
};

미묘한 케이스 — Array.forEachasync를 못 기다린다

// ❌ 작동하지만 의도와 다름
users.forEach(async (user) => {
  user.posts = await postsLoader.load(user.id);
});
// forEach는 async callback을 *기다리지 않는다*
// 각 호출이 *같은 tick에 .load만 호출*하므로 — *우연히* batch가 일어남
// 그러나 forEach가 *return하는 순간 user.posts는 아직 미정*

이건 batch 측면에서는 작동하지만 논리적으로는 버그다. Promise.all(users.map(...))이 정답.


Insight — batch는 사실 Node.js의 자연스러운 부산물이다

DataLoader가 천재적인 라이브러리처럼 느껴지지만, 사실 그 batch 메커니즘Node.js event loop가 이미 가진 성질적절한 시점에 캡쳐한 것뿐이다.

  • Node.js는 동기 코드가 끝나기 전에 macrotask/microtask를 실행하지 않는다.
  • 따라서 동기 코드 안에서 .load()를 100번 호출하면 — 100번 모두 누적된 뒤에 처음으로 dispatch가 일어난다.
  • 별도 스케줄링 없이 그 성질만 활용한 게 DataLoader다.

이걸 알면 — DataLoader가 왜 다른 언어에서도 거의 그대로 작동하는지 보인다. Python의 asyncio도 같은 event loop 모델, Java의 CompletableFuture도 비슷. 언어의 비동기 모델만 같으면 — DataLoader가 번역된다.

반대로, 동기 언어 (PHP, Ruby의 main thread 등)에서는 DataLoader 그대로는 안 된다. 대신 deferred / lazy 패턴(Webonyx GraphQL의 Deferred 또는 Promise polyfill)을 명시적으로 깔아야 한다.

한 줄로 — DataLoader는 발명이 아니라 Node.js event loop 위에 만들어진 얇은 syntax다.


요약 + Mermaid

개념
Node.js tick 순서sync → nextTick → microtask → macrotask
DataLoader 기본 스케줄러process.nextTick (Promise.resolve().then)
batch가 깨지는 가장 흔한 이유직렬 await
batch 늘리는 방법Promise.all, setTimeout batchScheduleFn
GraphQL과의 호흡형제 필드 병렬 평가 + microtask dispatch

한 줄 결론batch는 마법이 아니라 event loop의 자연스러운 결과다. await 위치를 통제하면 batch가 살아남는다. 다음 문서(05)는 batch만으로 못 잡는 깊은 트리의 round-trip을 — lookahead로 잡는 이야기.