🔷 GraphQL2. 실행 & 리졸버03 — Context와 Info

03 — Context와 Info

질문: 같은 요청의 모든 리졸버가 무엇을 공유하며, 한 노드는 자기가 어디 있는지 어떻게 아는가? 한 줄 답: context는 요청 1회 동안만 살아있는 컨테이너 — 인증 결과·DataLoader·DB 핸들이 여기 산다. info는 현재 평가 중인 노드의 자기 인식 — fieldName, path, returnType, schema를 담는다.


Why — 왜 두 개가 따로 있나

리졸버가 호출될 때, 두 종류의 정보가 필요하다.

종류라이프타임어디에
요청 단위 공유인증된 user, DB 연결, DataLoader 캐시HTTP 요청 1회context
현재 노드 메타내가 어떤 필드인지, 어디 위치한 노드인지노드 평가 한 번info

이 둘을 분리하지 않으면 어디에 무엇이 들어가는지 혼동이 생긴다. 분리되어 있어서 요청 단위 캐싱(DataLoader)이 깔끔히 성립한다.


How — context

생성 시점 — 요청마다 새로

// Apollo Server / graphql-yoga 공통 패턴
const server = new ApolloServer({
  schema,
  context: async ({ req }) => {
    // ★ 이 함수가 매 HTTP 요청마다 한 번 호출된다
    const token = req.headers.authorization;
    const user  = token ? await verifyJWT(token) : null;
 
    return {
      user,                                           // 인증 정보
      db: req.app.get('db'),                          // 공유 DB 풀
      loaders: {                                      // ★ DataLoader는 요청마다 새로
        userById: new DataLoader(ids => db.users.findMany({ id: { in: ids } })),
        postsByAuthor: new DataLoader(ids => batchPostsByAuthor(ids)),
      },
      requestId: crypto.randomUUID(),
    };
  },
});

핵심 invariant: context 객체는 같은 요청 안의 모든 리졸버가 공유하지만, 요청 간엔 절대 공유되지 않는다.

이게 왜 중요한가?

// ❌ 위험 — 전역 DataLoader
const globalLoader = new DataLoader(/* ... */);
const resolvers = {
  User: { posts: (u, _, ctx) => globalLoader.load(u.id) }
}
// → 캐시가 *요청 간에 살아남는다*. user-A의 posts를 user-B가 본다.
// ✅ 안전 — 요청별 DataLoader
context: async ({ req }) => ({
  loaders: { posts: new DataLoader(/* ... */) },   // 요청마다 새 인스턴스
})

N+1 챕터에서 DataLoader의 batch window가 한 요청 안의 single tick으로 잡히는 이유가 여기서 나온다.

context에 무엇을 넣나

종류
인증 / 권한ctx.user, ctx.user.roles, ctx.tenantId
요청 단위 캐시ctx.loaders.* (DataLoader 모음)
인프라 핸들ctx.db, ctx.redis, ctx.s3
외부 API 클라이언트ctx.services.payment, ctx.services.email
관측ctx.requestId, ctx.tracer, ctx.logger.child({ requestId })

context에 넣으면 안 되는 것

  • 변경 가능한 상태: 리졸버끼리 통신하려고 ctx.foo = ...로 쓰면 순서 의존이 생긴다. 형제 리졸버 순서는 보장 안 됨.
  • 전역 캐시: 요청 사이 살아남는 캐시. 위 예시 참고.
  • 응답 데이터: context는 입력이지 출력이 아니다. 결과는 리졸버 반환값으로.

How — info

모양

interface GraphQLResolveInfo {
  fieldName:    string;                    // "posts"
  fieldNodes:   FieldNode[];               // AST 노드들 (fragment 합쳐진)
  returnType:   GraphQLOutputType;         // [Post!]!
  parentType:   GraphQLObjectType;         // User
  path:         Path;                      // { key: 'posts', prev: { key: 0, prev: { key: 'users', prev: undefined } } }
  schema:       GraphQLSchema;             // 전체 스키마
  fragments:    { [name]: FragmentDefinitionNode };
  rootValue:    any;
  operation:    OperationDefinitionNode;   // 전체 쿼리 AST
  variableValues: { [name]: any };
}

path가 흥미롭다

응답 트리에서 현재 노드의 위치다 — 응답 JSON의 좌표.

query {
  users {
    posts {
      title    # info.path = users → 2 → posts → 0 → title
    }
  }
}
// linked list 형태
{ key: 'title', prev: { key: 0, prev: { key: 'posts', prev: { key: 2, prev: { key: 'users', prev: undefined } } } } }

graphql-js의 responsePathAsArray(info.path) 헬퍼로 ["users", 2, "posts", 0, "title"] 배열로 변환 가능.

에러 객체의 path 필드와 같다 (05-errors-and-partial-response).

info를 쓰는 진짜 사례

1) prefetch — 한 번에 select

User: {
  // 클라이언트가 어떤 하위 필드를 요청했는지 *미리* 알 수 있다
  fullData: (user, _, ctx, info) => {
    const requested = graphqlFields(info);  // { posts: { title: {}, body: {} } }
    return ctx.db.users.findFull({
      id: user.id,
      includePosts:    'posts' in requested,
      includeComments: 'comments' in requested,
    });
  }
}

이게 N+1 챕터대안 중 하나다 — DataLoader가 batch한다면, prefetch는 애초에 한 번에 가져온다.

2) directive 처리

type Query {
  secret: String @auth(role: "ADMIN")
}
Query: {
  secret: (_, __, ctx, info) => {
    const authDirective = info.fieldNodes[0].directives?.find(d => d.name.value === 'auth');
    if (authDirective && ctx.user.role !== 'ADMIN') throw new ForbiddenError();
    return DB.secret;
  }
}

보통은 schema directive transformer로 자동화한다.

3) tracing

const traceWrapper = (resolver) => async (parent, args, ctx, info) => {
  const path = responsePathAsArray(info.path).join('.');
  const span = ctx.tracer.start(`gql.${path}`);
  try { return await resolver(parent, args, ctx, info); }
  finally { span.end(); }
};

What — 정리표

context vs info — 한눈에

차원contextinfo
라이프타임요청 1회노드 평가 1회
작성 주체서버 부트스트랩 (Apollo Server 옵션 등)graphql-js executor
변경 가능?의도적으로 immutable로 — 사실상 read-onlyread-only
주된 용도인증·DataLoader·인프라 핸들메타·prefetch·directive
같은 요청 내 공유?yesno (노드마다 다름)
요청 사이 공유?절대 no의미 없음

context 생성 단계 (시간 순)

[1] HTTP 요청 도착

[2] 미들웨어 (parse body, cors, ...)

[3] context 팩토리 호출 — 1회
     · req에서 JWT 읽기 → verifyJWT
     · DataLoader 새 인스턴스 만들기
     · requestId 부여

[4] graphql-js execute(schema, document, root, **context**, variables)

[5] 모든 리졸버가 같은 context 참조

[6] 응답 직렬화 → HTTP response

[7] context는 GC 대상 (DataLoader 캐시도 함께 소멸)

What-if — 잘못 이해하면

1) 인증을 리졸버마다 다시 검사

// ❌
User: {
  email: (_, __, ctx) => verifyJWT(ctx.req.headers.authorization) /* ... */
}

JWT 검증은 요청 1회면 충분하다. context 팩토리에서 한 번 해서 ctx.user에 저장.

2) DataLoader를 module-level에 두기

// ❌ 절대 금지
const loader = new DataLoader(...);
export const resolvers = {
  User: { posts: (u) => loader.load(u.id) }
};

→ 두 사용자가 동시에 요청을 보내면 캐시 키가 충돌해서 사용자 A의 데이터를 B가 본다. 데이터 leak.

✅ 반드시 context 팩토리 안에서 요청마다 새로:

context: () => ({ loaders: { posts: new DataLoader(...) } })

3) context를 mutable 통신 채널로 쓰기

// ❌
Query: {
  user: (_, __, ctx) => { ctx.currentUser = userObj; return userObj; },
}
User: {
  posts: (_, __, ctx) => ctx.currentUser.posts,  // ctx.currentUser가 user 리졸버보다 먼저 호출되면?
}

→ 형제 리졸버는 parallel이고, Query.userUser.posts는 부모-자식이긴 하지만 위처럼 ctx를 거치면 부모 객체 전달이 망가진다. parent 인자 쓰자.

4) info를 캐싱하기

info.path, info.fieldNodes호출마다 다르다. info 객체나 그 부분을 외부에 보관하면 메모리 누수 또는 오해된 노드 정보가 된다. 항상 호출 안에서만 쓴다.

5) info에서 비밀을 끌어다 쓰기

info.schema, info.fragments서버 메타다. 이걸 응답 데이터에 섞으면 introspection을 끈 의미가 사라진다 (07-security-governance).


Insight — 두 인자의 디자인 철학

”context는 의존성 주입(DI) 컨테이너다”

GraphQL 커뮤니티가 명시적으로 말하진 않지만, context는 DI 컨테이너의 GraphQL 버전이다. 리졸버는 순수 함수처럼 의존성을 인자로 받는다 → 테스트에서 mock context 한 번 만들면 끝.

test('User.posts returns by author', async () => {
  const mockCtx = { db: { posts: { byAuthor: () => [{ id: 1 }] } } };
  expect(await resolvers.User.posts({ id: 'u1' }, {}, mockCtx)).toEqual([{ id: 1 }]);
});

REST 컨트롤러는 req/res에 직결되어 Express 모킹이 필요한데, GraphQL 리졸버는 context 객체 하나면 끝난다. 이 테스트 용이성이 종종 GraphQL 도입의 부수적 이득으로 언급된다.

”info가 거의 안 쓰이는 게 좋은 신호”

대부분의 리졸버는 parent + args + context로 끝난다. info를 자주 만지면 마법적 동작이 된다 — 클라이언트가 어떤 필드를 요청했는지에 따라 서버 동작이 달라지는 암묵적 조건. info는 강력하지만 마지막 수단으로.

”context 팩토리는 가벼워야 한다”

context: async ({ req }) => {
  await syncFromExternalAPI();  // ❌ 매 요청마다 100ms 추가
}

context 생성은 모든 요청에 비용을 더한다. JWT 검증·DataLoader 인스턴스화 정도가 한계. 무거운 작업은 그 작업이 필요한 리졸버 안에서 lazy하게.

”subscription에서 context는 연결 단위가 될 수 있다”

WebSocket subscription에서는 context가 connection 시작 시 한 번 만들어지고 이벤트마다 재사용되는 경우가 많다 (04-transport). 이 경우 DataLoader는 각 이벤트마다 새로 만들어야 한다 — Apollo Server의 subscribe hook이 이걸 처리.


요약

context는 요청 1회짜리 컨테이너 — DI 컨테이너처럼 의존성을 모은다. 요청마다 새로 생성되는 게 핵심 invariant고, 그래서 DataLoader가 안전하게 동작한다. info는 노드의 자기 인식 — fieldName/path/returnType/schema. 95%의 리졸버는 안 만진다. 만지는 5%는 prefetch나 directive 같은 고급 패턴.

다음: 04 — 실행 트리 순회 — context와 info가 트리의 어디에 적용되는지, 그 트리의 모양.