🔷 GraphQL2. 실행 & 리졸버06 — Async와 Promise (Async & Promises)

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)를 반환할 수 있다”*고 명시한다. 다음 두 가지가 자연스러운 결과다:

  1. executor는 Promise를 자동으로 await — 리졸버 작성자가 신경 쓸 일이 없다
  2. 형제는 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가 올라가는지 자세히 본다.