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.nextTickqueue가 가장 먼저, 그 다음 microtask(Promise), 그 다음 macrotask(setImmediate,setTimeout, I/O).- microtask queue가 비워지는 중에 더 추가되면 — 그것도 다 처리한 뒤에야 macrotask로 간다.
DataLoader는 어디에 끼는가
DataLoader의 기본 동작은 — .load(key)가 호출되면:
- 내부 queue에 (key, resolve, reject) 추가
- 첫 호출이면 dispatch를 schedule — 기본은
process.nextTick또는Promise.resolve().then(...)(microtask) - 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.usersresolver가 100명의 user를 반환.User.postsresolver가 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 | 다음 macrotask | I/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이 자주 나오면 — 어딘가에서 직렬 await나 tick을 끊는 코드가 있다는 신호.
진단 2 — Apollo Tracing / GraphQL Yoga의 inspector
GraphQL execution time을 필드별로 측정. User.posts가 N개 호출 × 같은 시간이면 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.forEach는 async를 못 기다린다
// ❌ 작동하지만 의도와 다름
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로 잡는 이야기.