🔷 GraphQL3. N+1 & DataLoader05-lookahead-and-projection

05 · Lookahead & Projection

이 문서가 답하는 질문: DataLoader로도 못 잡는 자리가 있다 — 트리가 깊으면 매 depth마다 round-trip이 생긴다. 이걸 어떻게 처음부터 줄이나? 한 줄 답: “resolver의 info 인자에서 클라이언트가 어떤 필드를 selection했는지를 미리 읽고, 그에 맞춰 SELECT 컬럼/JOIN을 결정하는 패턴 — DataLoader가 cache라면 lookahead는 JOIN이다.”


Why — DataLoader가 못 잡는 자리

02~04에서 본 DataLoader는 형제 필드의 같은 tick batch를 잡는다. 그런데 깊은 트리에서는 각 depth가 별도의 batch가 된다.

{
  users(limit: 100) {       # tick 1
    posts {                  # tick 2 — batch (postsLoader)
      comments {             # tick 3 — batch (commentsLoader)
        author { name }      # tick 4 — batch (userLoader)
      }
    }
  }
}

DataLoader가 각 tick에서 잘 동작해도4번의 round-trip은 그대로다. 5단계면 5번, 6단계면 6번. depth가 깊을수록 latency가 선형으로 늘어난다.

다른 접근: resolver 시작 시점에 “클라이언트가 어디까지 selection했는지”를 먼저 읽고, 한 번의 SQL JOIN으로 전체 트리를 통째로 가져오면? 그게 lookahead다.

둘은 대립이 아니라 다른 도구다.


How — info 인자로 selection 읽기

GraphQL resolver는 4번째 인자로 info: GraphQLResolveInfo를 받는다.

const resolvers = {
  Query: {
    users: (parent, args, context, info) => {
      // info.fieldNodes — 클라이언트가 보낸 selection의 AST
      // info.fieldName  — 현재 필드 이름 ('users')
      // info.path        — 응답 트리에서의 위치
      // info.schema      — 전체 스키마
      // ...
      console.log(info.fieldNodes[0].selectionSet);
      return db.users.findMany(/* ... */);
    },
  },
};

info.fieldNodesAST다. 사람이 직접 쓸 수도 있지만, 보통은 라이브러리를 쓴다.

graphql-parse-resolve-info — 사실상 표준 헬퍼

import { parseResolveInfo } from "graphql-parse-resolve-info";
 
const resolvers = {
  Query: {
    users: (parent, args, context, info) => {
      const parsed = parseResolveInfo(info);
      // parsed = {
      //   name: 'users',
      //   alias: 'users',
      //   args: { limit: 100 },
      //   fieldsByTypeName: {
      //     User: {
      //       id: { name: 'id', ... },
      //       posts: {
      //         name: 'posts',
      //         fieldsByTypeName: {
      //           Post: { title: {...}, comments: {...} }
      //         }
      //       }
      //     }
      //   }
      // }
      const fields = parsed.fieldsByTypeName.User;
      const includePosts = "posts" in fields;
      const includeAuthor =
        includePosts && "author" in fields.posts.fieldsByTypeName.Post;
      // ↑ 이걸로 SELECT/JOIN 전략을 결정
    },
  },
};

실전 — Prisma의 include 동적 구성

const resolvers = {
  Query: {
    users: (_, { limit }, { prisma }, info) => {
      const parsed = parseResolveInfo(info);
      const userFields = parsed.fieldsByTypeName.User;
 
      const include: any = {};
      if ("posts" in userFields) {
        include.posts = true;
        const postFields = userFields.posts.fieldsByTypeName.Post;
        if ("comments" in postFields) {
          include.posts = { include: { comments: true } };
          // 깊이 더 들어가면 재귀로 짠다 — 보통 헬퍼 함수로 분리
        }
      }
 
      return prisma.user.findMany({ take: limit, include });
      // → Prisma가 알아서 LEFT JOIN으로 SQL 컴파일
    },
  },
};

요청된 깊이 전체한 번의 SQL로 내려간다.

graphql-fields — 더 단순한 헬퍼

import graphqlFields from "graphql-fields";
 
const resolvers = {
  Query: {
    users: (_, args, ctx, info) => {
      const fields = graphqlFields(info);
      // fields = { id: {}, posts: { title: {}, comments: { ... } } }
    },
  },
};

parseResolveInfo보다 얇은 wrapper. 타입 정보 없이 이름만 필요할 때.


What — DataLoader vs Lookahead 결정 매트릭스

상황DataLoaderLookahead
selection이 얕음 (1~2단)충분과한 설계
selection이 깊음 (4단+)round-trip 누적JOIN 한 번
selection이 동적, 거의 매번 다름잘 맞음JOIN 규칙이 복잡해짐
selection이 비교적 고정된 패턴OKJOIN이 예측 가능 → 더 효율
같은 user가 여러 자리에서 등장cache로 dedupeJOIN은 row 중복 가능
백엔드가 관계형 DBOK강점
백엔드가 여러 microservice강점어려움 (cross-service JOIN 불가)
ORM이 include/select 지원 (Prisma)OK강점
재귀 관계 (tree)depth마다 batchrecursive CTE 등으로 한 번

함께 쓰는 패턴

실전에서는 둘 다 동시에 쓴다.

// 1단 lookahead로 큰 트리는 한 번에 가져온다
const users = await prisma.user.findMany({
  include: buildInclude(info),
});
 
// 2단 — 그래도 *cross-aggregate*나 *외부 service*가 필요한 자리에는 DataLoader
return users.map((user) => ({
  ...user,
  externalProfile: () => profileLoader.load(user.externalId),
  //                    ↑ 외부 마이크로서비스 — JOIN 불가, batch로
}));

같은 DB 안은 lookahead로 JOIN, 경계를 넘는 자리는 DataLoader로 batch — 이게 사실상 표준 조합이다.


What — info기록 안 함 함정

resolver에서 info를 활용하기 시작하면 — 간접 효과들이 따라온다.

함정 1 — selection set이 fragment를 포함하면 분기가 많아짐

fragment UserDetail on User {
  id
  name
  email
}
 
query {
  users {
    ...UserDetail
    posts { title }
  }
}

info.fieldNodesfragment를 펼쳐주지 않는다. parseResolveInfo펼쳐준다. 직접 AST를 다룰 때는 fragment 처리 코드를 수동으로 짜야 함. 항상 라이브러리 쓰는 게 안전.

함정 2 — @skip / @include 디렉티브

query ($wantsPosts: Boolean!) {
  users {
    id
    posts @include(if: $wantsPosts) { title }
  }
}

info로만 selection을 읽으면 — postsAST에는 있지만 실제로는 skip될 수 있음. parseResolveInfovariables를 받아 디렉티브 평가까지 해준다.

const parsed = parseResolveInfo(info, { keepRoot: true });
// variables는 info.variableValues에서 자동으로 읽음

함정 3 — 너무 깊으면 공격 벡터

복잡한 selection에 자동으로 JOIN을 생성하면 — 악의적 클라이언트5단 깊이의 self-join을 요청해서 DB를 죽일 수 있다. depth limitcomplexity analysis를 함께 적용해야 한다. 이건 07-security-governance 챕터에서 본다.


What-if — Prisma의 selection 분석 (자동 lookahead)

Prisma의 GraphQL 통합 라이브러리들(@pothos/plugin-prisma, nexus-prisma 등)은 info를 직접 읽어 include/select자동 생성한다.

// @pothos/plugin-prisma 예시
builder.prismaObject("User", {
  fields: (t) => ({
    id: t.exposeID("id"),
    posts: t.relation("posts"),
    //      ↑ 이 한 줄로 — info를 읽고 prisma.findMany의 include까지 자동 구성
  }),
});
  • 사람이 parseResolveInfo를 쓸 필요 없음.
  • 컴파일 시점에 schema와 ORM이 매칭되어 — info 분석이 런타임에 자동.
  • 대신 — Prisma + GraphQL이라는 짝꿍에 묶이는 비용.

Hasura / PostGraphile — 서버 자체가 lookahead

Hasura나 PostGraphile은 GraphQL을 SQL로 컴파일한다 — 즉, 전체 selection이 한 줄의 SQL JOIN이 된다. 이건 06에서 본다.


Insight — DataLoader와 Lookahead는 다른 시간을 산다

DataLoader는 실행 중에 일어난다 — resolver가 호출된 모인 호출들을 묶는다. 그래서 동적이고, 코드 변경 없이 효과가 난다.

Lookahead는 실행 직전에 일어난다 — resolver가 호출되기 앞으로 일어날 호출들을 예측해서 미리 fetch한다. 그래서 정적이고, 분석/계획이 필요하다.

차원DataLoaderLookahead
시간실행 중 (reactive)실행 전 (proactive)
정보원같은 tick에 모인 keyinfo.fieldNodes (selection)
단위keyfield tree
효과depth마다 1 round-trip전체 트리 1 round-trip
한계depth 누적cross-service 불가, fragment/directive 복잡
적용 비용resolver 안에서 .load() 호출info 분석 코드 + ORM 매핑

다른 비유: DataLoader는 react-style(이미 일어난 일을 묶는다), lookahead는 plan-style(앞으로 일어날 일을 미리 짠다). 둘은 서로의 약점을 메운다.


요약 + Mermaid

개념
info의 핵심 필드fieldNodes, path, variableValues
사실상 표준 헬퍼graphql-parse-resolve-info, graphql-fields
Prisma 통합@pothos/plugin-prisma, nexus-prisma (자동)
강점depth 누적 round-trip 1번으로
약점cross-service에는 안 통함, 보안 고려 필요
DataLoader와 관계경쟁이 아니라 보완

한 줄 결론 — Lookahead는 예측, DataLoader는 추수다. 같은 DB 안에서는 JOIN으로, 서비스 경계 너머는 batch로. 다음 문서(06)는 lookahead를 서버 차원에서 자동화한 join-monger/Prisma/Hasura의 비교.