06 — Async와 Promise (Async & Promises)
질문: 리졸버가 비동기(Promise)를 반환할 때, executor는 어떻게 그것을 처리하는가? 한 줄 답: executor는 Promise를 자동으로 await한다. 형제 리졸버는 Promise.all로 동시 진행되며, Node.js event loop의 single tick 안에 모인 호출들이 DataLoader의 batch window가 된다.
Why — 비동기가 필수인 이유
리졸버의 대부분은 비동기다.
User: {
posts: async (user, _, ctx) => {
return await ctx.db.posts.findMany({ where: { authorId: user.id } });
}
}- DB 조회
- 외부 API 호출
- Redis lookup
- 파일 시스템 read
이 모두 비동기 I/O다. Node.js 같은 single-threaded 환경에서 동기 리졸버만 허용했다면 GraphQL은 처음부터 죽었다.
GraphQL spec은 §3 Execution에서 *“리졸버가 Promise(또는 Future, async iterator)를 반환할 수 있다”*고 명시한다. 다음 두 가지가 자연스러운 결과다:
- executor는 Promise를 자동으로 await — 리졸버 작성자가 신경 쓸 일이 없다
- 형제는 Promise.all로 동시 진행 — single-threaded여도 concurrent
How — graphql-js의 실제 동작
1) Promise 자동 unwrap
graphql-js의 completeValue에서 일어나는 일 (단순화):
function completeValue(returnType, fieldNodes, info, path, result) {
// 핵심: result가 Promise면 then으로 풀고, 결과를 다시 completeValue로
if (isPromise(result)) {
return result.then(
(resolved) => completeValue(returnType, fieldNodes, info, path, resolved),
(rawError) => { /* error handling */ throw locatedError; }
);
}
// 동기 값이면 그대로 처리
// ... NonNull / List / Object 분기
}→ 리졸버가 동기 값을 반환하든 Promise를 반환하든 똑같이 다뤄진다. 호출 측이 await을 적을 필요가 없다.
2) 형제 필드 Promise.all
executeFields (단순화):
function executeFields(...) {
const results = {};
let containsPromise = false;
for (const [responseName, fieldNodes] of groupedFields) {
const result = executeField(...);
results[responseName] = result;
if (isPromise(result)) containsPromise = true;
}
if (!containsPromise) return results; // 전부 동기면 그대로
return promiseForObject(results); // 하나라도 Promise면 Promise.all
}promiseForObject는 모든 키의 값을 Promise.all로 기다린 후 객체로 묶는다. 효과:
User: {
posts: async (u, _, ctx) => /* 50ms */,
comments: async (u, _, ctx) => /* 80ms */,
profile: async (u, _, ctx) => /* 30ms */,
}
// → 합쳐서 max(50, 80, 30) = 80ms (concurrent)세 리졸버가 동시에 시작되고, 가장 느린 것의 시간이 사용자 경험 시간.
3) mutation은 순차 await
async function executeFieldsSerially(...) {
const results = {};
for (const [responseName, fieldNodes] of groupedFields) {
const result = await executeField(...); // ★ await — 하나씩
results[responseName] = result;
}
return results;
}mutation에서는 각 필드를 await해서 완료 후 다음. spec이 정한 serial이 실제 코드 한 줄로 드러난다.
How — event loop와 DataLoader
single tick이라는 마법
Node.js event loop의 한 tick 안에서 동기적으로 등록된 모든 Promise는 같은 microtask queue에 들어간다.
// 모두 같은 tick에서 호출됨
const p1 = ctx.loaders.user.load(1);
const p2 = ctx.loaders.user.load(2);
const p3 = ctx.loaders.user.load(3);
// → DataLoader가 microtask 끝에서 한 번에 batch fetch
await Promise.all([p1, p2, p3]);GraphQL executor가 형제 리졸버를 동기적으로 모두 등록하고 Promise.all로 기다리는 덕분에, DataLoader가 한 batch로 묶을 수 있다.
query {
users { ... } # 100명
# 각 user별 posts 리졸버 100개가 같은 tick에 ctx.loaders.posts.load(u.id) 호출
# → DataLoader가 [u1.id, ..., u100.id] 한 번의 SQL로 batch
}이게 03 — N+1 & DataLoader가 동작하는 근본 메커니즘이다. executor의 동시성 모델이 DataLoader의 batch window를 만든다.
microtask vs macrotask
DataLoader는 microtask에 batch를 예약한다 (process.nextTick 또는 queueMicrotask). 이게 중요한 이유:
- 같은 tick 안에서 등록된 load() 호출들이 그 tick의 끝에서 한 번에 처리됨
setTimeout(..., 0)은 macrotask라서 다음 tick — 못 모인다
→ DataLoader 구현이 process.nextTick을 쓰는 이유. 사용자 코드에서 setTimeout으로 batching을 흉내내면 한 tick씩 늦어진다.
What — 실전 패턴
1) async/await로 작성
User: {
posts: async (user, args, ctx) => {
const posts = await ctx.db.posts.findMany({ where: { authorId: user.id } });
return posts.filter(p => p.published);
}
}→ 표준 async 함수. executor가 자동으로 await.
2) Promise 체이닝
User: {
posts: (user, args, ctx) =>
ctx.db.posts
.findMany({ where: { authorId: user.id } })
.then(posts => posts.filter(p => p.published))
}→ async 키워드 없이도 동작. 호환성 차이 없음.
3) 동기 값 — Promise로 감싸지 않아도 OK
User: {
fullName: (user) => `${user.first} ${user.last}`, // 동기 string
}→ executor가 Promise인지 아닌지 분기해서 처리. 동기 리졸버는 오버헤드가 없다.
4) 부분 동기 / 부분 비동기
User: {
isAdmin: (user) => user.role === 'ADMIN', // 동기
posts: async (user, _, ctx) => ctx.loaders.posts.load(user.id), // 비동기
}→ executor가 알아서 섞어 처리.
5) Promise.all 직접 쓰기
User: {
bestFriend: async (user, _, ctx) => {
const [friends, profiles] = await Promise.all([
ctx.db.friends.byUser(user.id),
ctx.db.profiles.byUser(user.id),
]);
return pickBest(friends, profiles);
}
}→ 한 리졸버 안에서 여러 비동기를 직렬화하지 않으려면 직접 Promise.all.
What-if — 잘못 이해하면
1) “await을 직렬로”
// ❌ 한 리졸버 안
const posts = await ctx.db.posts.byUser(user.id); // 50ms
const comments = await ctx.db.comments.byUser(user.id); // 80ms
// 총 130ms — 그러나 둘이 독립적이라면 80ms로 줄일 수 있었음// ✅
const [posts, comments] = await Promise.all([
ctx.db.posts.byUser(user.id),
ctx.db.comments.byUser(user.id),
]);
// 총 80ms→ 리졸버 간은 executor가 알아서 동시 진행하지만, 리졸버 내부의 여러 호출은 작성자가 Promise.all을 적어야 한다.
2) “DataLoader가 캐시면 setTimeout으로도 batch되겠지”
// ❌
setTimeout(() => loader.load(id), 0); // 다음 tick으로 밀림
// → 다른 load()들과 다른 batch에 들어감DataLoader는 current tick 안의 호출만 모은다. 비동기 boundary를 넘으면 batch가 갈라진다. 이게 async/await의 보이지 않는 곳에서 자주 일어남:
// ❌ 위험
User: {
posts: async (user, _, ctx) => {
await sleep(0); // ← 여기서 다음 tick으로 이동
return ctx.loaders.posts.load(user.id); // batch가 갈라짐
}
}→ DataLoader load() 호출은 await 직전 또는 async 함수 시작 즉시에.
3) “blocking 동기 코드”
User: {
hash: (user) => crypto.createHash('sha256').update(largeBuffer).digest('hex'),
}100MB buffer를 sha256? event loop가 몇 초 멈춘다 — 모든 다른 요청이 멈춤. CPU-heavy 작업은:
- Worker thread로 옮기거나
- Native non-blocking API 쓰거나
- 백그라운드 큐에 던지고 응답은 placeholder
4) “에러를 throw 안 하고 Promise.reject”
User: {
posts: () => Promise.reject(new Error('failed')),
}동작은 같다 — executor가 둘 다 잡는다. async/await에서는 throw가 자연스러움.
5) “subscription도 같은 모델”
subscription은 AsyncIterator를 반환한다. executor는 이걸 for-await으로 소비하며 각 이벤트마다 selection set을 재실행한다. 같은 4-인자 리졸버 모델이지만 호출 빈도가 N번이라는 점이 다르다 (04-transport).
Subscription: {
newComment: {
subscribe: async function* (_, args, ctx) {
for await (const event of ctx.pubsub.subscribe('NEW_COMMENT')) {
yield event;
}
}
}
}Insight — concurrency 모델의 깊이
”single-threaded인데 어떻게 fast한가”
GraphQL 서버가 Node.js에서 효율적인 이유는 대부분의 시간이 I/O wait이기 때문이다.
Request lifecycle:
parse (0.5ms, CPU)
validate (1ms, CPU)
execute:
├─ users 리졸버 (1ms CPU + 30ms DB I/O wait)
├─ posts × 100 (1ms CPU + 50ms DB I/O wait, batched)
└─ author × 100 (1ms CPU + 50ms DB I/O wait, batched)
serialize (1ms, CPU)CPU 시간은 몇 ms, I/O wait이 수십~수백 ms. event loop가 I/O wait 동안 다른 요청을 처리 → 한 코어로도 수천 RPS 가능.
→ 이게 깨지는 순간은 CPU 작업이 끼어드는 순간. 비싼 JSON parse, 큰 hash, 동기 sort — 한 군데서 100ms 막히면 모든 요청이 멈춘다.
”Promise는 GraphQL의 lingua franca”
graphql-js 외에도 graphql-java(Future), graphql-ruby(Lazy), juniper(Future) 모두 비동기 추상화를 채택한다. spec은 Promise라는 단어를 쓰지 않지만 *“비동기 결과를 반환할 수 있다”*고만 적어, 각 언어의 비동기 모델로 매핑되도록 했다.
→ JS의 Promise.all이 깔끔해서 GraphQL의 동시성 모델이 가장 직관적인 게 Node.js다. 그래서 대부분의 GraphQL 서버가 Node.js인 사실이 우연이 아니다.
”async iterator로 streaming의 길이 열렸다”
@defer와 @stream directive (현재 RFC)는 응답을 여러 청크로 나눠 보낸다.
query {
fastField # 즉시
... @defer { slowField } # 나중에
}응답:
chunk 1: { data: { fastField: "..." }, hasNext: true }
chunk 2: { incremental: [{ path: [], data: { slowField: "..." } }], hasNext: false }이게 가능한 이유는 executor가 이미 async iterator를 처리할 수 있기 때문이다. subscription의 인프라가 incremental delivery로 일반화된다.
”ParallelExecutor라는 미래”
graphql-java에는 DataLoaderDispatcherInstrumentation이 각 실행 레벨이 끝나는 시점을 hook해서 DataLoader dispatch를 명시적으로 호출한다. JS는 event loop의 microtask로 자동이지만, JVM은 명시적 dispatch가 필요하다. 같은 GraphQL spec이 언어마다 다른 동시성 메커니즘에 맞춰진다.
요약
executor는 Promise를 자동 await — 리졸버 작성자는 동기/비동기를 자유롭게 섞어 쓸 수 있다. 형제 리졸버는 Promise.all로 동시 진행. mutation은 순차 await. single event loop tick에 모인 호출들이 DataLoader의 batch window를 만든다 — async boundary 하나가 batch를 갈라놓을 수 있다. CPU-heavy 동기 작업이 끼어들면 event loop 전체가 멈춘다. 비동기 I/O 덕분에 GraphQL이 single-threaded에서 빠르다.
챕터 끝. 다음 챕터 03 — N+1 & DataLoader에서 이 동시성 모델 위에 어떻게 DataLoader가 올라가는지 자세히 본다.