02 — 리졸버 함수 (Resolver Function)
질문: GraphQL 서버에서 내가 작성하는 코드는 정확히 무엇이고, 어떻게 호출되는가? 한 줄 답: 각 필드마다 하나의 함수 —
(parent, args, context, info) => value. 네 인자는 각각 위치·요청·요청-스코프·메타를 분리하고, 반환값은 CompleteValue가 스키마 타입에 맞춰 정형화한다.
Why — 왜 필드 단위 함수인가
REST 컨트롤러는 엔드포인트 단위다 — 한 핸들러가 한 요청을 통째로 처리한다.
// REST
app.get('/users/:id/posts', async (req, res) => {
const user = await db.users.find(req.params.id);
const posts = await db.posts.where({ authorId: user.id });
res.json({ ...user, posts });
});GraphQL은 다르다. 클라이언트가 어떤 필드의 조합을 요청할지 미리 알 수 없으니, 각 필드가 자기 평가 책임을 가져야 한다.
// GraphQL — 필드마다 별도 함수
const resolvers = {
Query: {
user: (_, { id }, ctx) => ctx.db.users.find(id),
},
User: {
posts: (user, _, ctx) => ctx.db.posts.where({ authorId: user.id }),
},
};→ 클라이언트가 user { name }만 요청하면 User.posts는 호출되지 않는다. user { posts { title } }면 호출된다. 요청한 만큼만 실행되는 GraphQL의 핵심이 여기 있다.
리졸버는 지연 평가된 그래프 노드다 — 클라이언트가 그 노드를 깊이 파고들 때만 평가된다.
How — 4개 인자의 역할
§6.2.3 ResolveFieldValue — spec은 인자 형식을 자세히 정하지 않지만, 모든 주류 구현(graphql-js, graphql-java, Apollo)이 4-인자 형식을 채택했다.
type Resolver<Parent, Args, Context, Result> = (
parent: Parent, // 1. 위치 — 부모 객체
args: Args, // 2. 요청 — 이 필드의 인자 (coerced)
context: Context, // 3. 요청-스코프 — 인증·DataLoader·db 핸들
info: GraphQLResolveInfo, // 4. 메타 — 현재 노드의 자기 인식
) => Result | Promise<Result>;1) parent (a.k.a. source, root, obj)
부모 필드의 리졸버가 반환한 값이다.
query {
user(id: "1") { # Query.user 리졸버 → returns { id: "1", name: "Ada" }
name # User.name 리졸버의 parent = { id: "1", name: "Ada" }
posts { # User.posts 리졸버의 parent = { id: "1", name: "Ada" }
title # Post.title 리졸버의 parent = { id, title, authorId }
}
}
}루트 필드(Query.user)의 parent는 initial root value — 보통 undefined거나 서버가 주입한 컨텍스트 스타터다.
2) args
쿼리에서 받은 인자가 이미 coerce된 상태로 전달된다.
type Query {
user(id: ID!, includeDeleted: Boolean = false): User
}Query: {
user: (_, args, ctx) => {
// args = { id: "1", includeDeleted: false }
// 1. variables 치환 끝남
// 2. default value 적용 끝남
// 3. scalar coerce 끝남 (ID → string, Int → number)
// 4. Input Object 검증 끝남
return ctx.db.users.find(args.id);
}
}→ 리졸버는 type-safe 인자를 받는다. 검증은 실행 모델의 CoerceArgumentValues 단계에서 이미 끝났다.
3) context
같은 HTTP 요청의 모든 리졸버가 공유하는 컨테이너. 다음 문서 03-context-and-info에서 자세히. 보통:
- 인증된 user 객체
- DataLoader 인스턴스 (요청마다 새로)
- DB 연결, 외부 API 클라이언트
- 요청 헤더, IP, 로깅 트레이스 ID
4) info
현재 평가 중인 노드의 자기 인식. 잘 안 쓰지만 결정적인 순간이 있다.
interface GraphQLResolveInfo {
fieldName: string; // "posts"
fieldNodes: FieldNode[]; // 같은 이름의 AST 노드들 (fragment merge 결과)
returnType: GraphQLOutputType; // [Post!]!
parentType: GraphQLObjectType; // User
path: { key, prev }; // user → posts (응답에서의 위치)
schema: GraphQLSchema;
fragments: { [name]: FragmentDefinitionNode };
rootValue: any;
operation: OperationDefinitionNode;
variableValues: { ... };
}언제 쓰나?
- prefetch optimization: info에서 클라이언트가 어떤 하위 필드를 요청했는지 미리 읽고 한 번에 SQL select로 가져온다 (
graphql-fields같은 라이브러리) - path-aware logging:
info.path로 응답 트리상 위치 추적 - directive 처리:
info.fieldNodes[0].directives로 필드에 붙은@auth,@deprecated확인
What — 모양과 동작
default resolver
리졸버를 명시적으로 등록하지 않으면 graphql-js가 기본 구현을 쓴다 — §6.2.3:
const defaultFieldResolver = (parent, args, ctx, info) => {
if (parent == null) return null;
const value = parent[info.fieldName];
return typeof value === 'function' ? value.call(parent, args, ctx, info) : value;
};한 줄 요약: parent[fieldName]. parent가 이미 그 모양이면 리졸버를 쓸 필요가 없다.
// User.id, User.name 리졸버는 명시하지 않아도 동작 — db row가 이미 그 모양
Query: {
user: (_, { id }, ctx) => ctx.db.users.find(id), // { id, name, email } 반환
},
// User: { id, name, email은 default resolver가 처리 }이 단순함이 GraphQL의 신비를 부분적으로 푼다 — 마법이 아니라 property access다.
반환 타입 매칭
리졸버 반환값은 CompleteValue가 스키마 타입에 맞춰 정형화한다.
| 스키마 | 리졸버가 반환해야 할 것 | CompleteValue가 하는 일 |
|---|---|---|
String | string | null | Promise<…> | scalar serialize |
Int | number | null | 정수 검증 + serialize |
User | object | null | User selection set 재귀 |
[Post] | array | null | 각 요소에 CompleteValue 재귀 |
[Post!]! | non-null array of non-null Post | 하나라도 null이면 부모로 에러 전파 |
interface Node | object + __typename | __resolveType 호출 |
scalar coercion
const DateScalar = new GraphQLScalarType({
name: 'Date',
serialize: (value) => value.toISOString(), // resolver 반환 → JSON
parseValue: (value) => new Date(value), // variables → resolver args
parseLiteral: (ast) => new Date(ast.value), // 인라인 리터럴 → args
});리졸버는 내부 표현(Date 객체)을 반환하고, serialize가 전송 표현(ISO 문자열)으로 바꾼다. 입력 방향은 거꾸로 — 클라이언트의 문자열이 parseValue로 Date가 된다.
What-if — 잘못 이해하면
1) “리졸버를 비동기로 만들면 형제가 막힌다”
아니다. 형제 리졸버는 Promise.all로 동시 진행된다 (06-async-and-promises).
User: {
posts: async (user, _, ctx) => ctx.db.posts.byAuthor(user.id), // 50ms
comments: async (user, _, ctx) => ctx.db.comments.byAuthor(user.id), // 80ms
}
// → 합쳐서 80ms (parallel), 130ms 아님2) “default resolver를 신뢰하지 못한다”
종종 db row 키 이름과 스키마 필드 이름이 다르다.
type User { fullName: String }// row: { full_name: "Ada Lovelace" }
// fullName 리졸버 없으면 → null
User: {
fullName: (user) => user.full_name, // 명시 필요
}→ ORM이 자동 camelCase를 안 해주면 모든 필드에 리졸버를 써야 한다. 보통 mapper 한 층을 두거나 Prisma 같은 자동 변환 도구를 쓴다.
3) “parent 대신 context에서 부모를 꺼내자”
// 안티패턴
User: {
posts: (user, _, ctx) => ctx.db.posts.byAuthor(ctx.currentUserId), // ❌ ctx.currentUserId?
}User.posts의 parent는 그 사용자다 — ctx.currentUserId(인증된 본인)와 다르다. 다른 사람 페이지를 볼 때 망가진다. 부모 객체는 반드시 parent 인자로.
4) “args를 검증하지 않아도 된다”
타입은 검증되지만 비즈니스 규칙은 별개다.
type Mutation {
transfer(from: ID!, to: ID!, amount: Int!): Receipt
}amount: Int!는 정수임만 보장한다. amount > 0, from != to는 리졸버 책임이다.
5) “info를 어디든 신뢰”
info.path는 응답 트리 위치다 — users.3.posts.0.title. 이걸 로그에 그대로 쓰면 PII가 path에 박힌 사용자 id가 노출될 수 있다. 로깅 전 sanitize.
Insight — 단순함이 풀어주는 신비
”정말로 함수 하나일 뿐인가 — 그게 끝인가”
거의 그렇다. graphql-js의 executeField는 결국:
const result = resolveFn(source, args, contextValue, info);이 한 줄이 전부다. 그 위에 try/catch와 Promise 처리만 붙는다. “GraphQL은 마법이다”라는 인상은 합성의 결과고, 각 단위는 평범한 함수다.
”왜 4개 인자인가 — 3개나 5개가 아니라”
각 인자는 서로 다른 라이프타임을 가진다.
| 인자 | 라이프타임 | 왜 분리되어야 하나 |
|---|---|---|
| parent | 노드별 | 트리 위치에 따라 달라짐 |
| args | 노드별 | 쿼리마다 다름 |
| context | 요청 단위 | 같은 요청의 모든 리졸버가 공유 |
| info | 노드별 | 메타정보, 부가적 |
만약 parent + args + context 셋을 한 객체로 묶으면 생명주기가 섞여 캐싱·DataLoader 설계가 어려워진다. 분리가 곧 설계다.
”Apollo의 ‘Anatomy of a Resolver’ 글이 강조하는 한 가지”
Apollo 공식 글이 반복해서 말하는 건 “리졸버는 thin해야 한다”. 비즈니스 로직은 서비스 레이어에 두고, 리졸버는 그 서비스를 호출만 하라. 그래야:
- 테스트가 단위 함수 테스트로 환원된다
- 같은 서비스를 REST/gRPC에서도 재사용 가능
- 리졸버는 GraphQL adapter가 된다
User: {
posts: (user, args, ctx) => ctx.services.posts.byAuthor(user.id, args), // ✅ thin
// 비즈니스 로직은 ctx.services.posts 안에
}요약
리졸버 =
(parent, args, context, info) => value. 4개 인자는 각각 위치·요청·요청-스코프·메타를 분리한다. default resolver는parent[fieldName]— 그래서 ORM row가 곧 스키마면 리졸버 대부분이 사라진다. 반환값은 CompleteValue가 스키마 타입에 맞춰 정형화한다.
다음: 03 — Context & Info — 4개 인자 중 가장 오해받는 두 개를 자세히.