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.fieldNodes는 AST다. 사람이 직접 쓸 수도 있지만, 보통은 라이브러리를 쓴다.
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 결정 매트릭스
| 상황 | DataLoader | Lookahead |
|---|---|---|
| selection이 얕음 (1~2단) | 충분 | 과한 설계 |
| selection이 깊음 (4단+) | round-trip 누적 | JOIN 한 번 |
| selection이 동적, 거의 매번 다름 | 잘 맞음 | JOIN 규칙이 복잡해짐 |
| selection이 비교적 고정된 패턴 | OK | JOIN이 예측 가능 → 더 효율 |
| 같은 user가 여러 자리에서 등장 | cache로 dedupe | JOIN은 row 중복 가능 |
| 백엔드가 관계형 DB | OK | 강점 |
| 백엔드가 여러 microservice | 강점 | 어려움 (cross-service JOIN 불가) |
| ORM이 include/select 지원 (Prisma) | OK | 강점 |
| 재귀 관계 (tree) | depth마다 batch | recursive 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.fieldNodes는 fragment를 펼쳐주지 않는다. parseResolveInfo는 펼쳐준다. 직접 AST를 다룰 때는 fragment 처리 코드를 수동으로 짜야 함. 항상 라이브러리 쓰는 게 안전.
함정 2 — @skip / @include 디렉티브
query ($wantsPosts: Boolean!) {
users {
id
posts @include(if: $wantsPosts) { title }
}
}info로만 selection을 읽으면 — posts가 AST에는 있지만 실제로는 skip될 수 있음. parseResolveInfo는 variables를 받아 디렉티브 평가까지 해준다.
const parsed = parseResolveInfo(info, { keepRoot: true });
// variables는 info.variableValues에서 자동으로 읽음함정 3 — 너무 깊으면 공격 벡터
복잡한 selection에 자동으로 JOIN을 생성하면 — 악의적 클라이언트가 5단 깊이의 self-join을 요청해서 DB를 죽일 수 있다. depth limit과 complexity 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한다. 그래서 정적이고, 분석/계획이 필요하다.
| 차원 | DataLoader | Lookahead |
|---|---|---|
| 시간 | 실행 중 (reactive) | 실행 전 (proactive) |
| 정보원 | 같은 tick에 모인 key | info.fieldNodes (selection) |
| 단위 | key | field 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의 비교.