🔷 GraphQL2. 실행 & 리졸버02 — 리졸버 함수 (Resolver Function)

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가 하는 일
Stringstring | null | Promise<…>scalar serialize
Intnumber | null정수 검증 + serialize
Userobject | nullUser selection set 재귀
[Post]array | null각 요소에 CompleteValue 재귀
[Post!]!non-null array of non-null Post하나라도 null이면 부모로 에러 전파
interface Nodeobject + __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개 인자 중 가장 오해받는 두 개를 자세히.