본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 3장: Resolver 구현과 데이터 로딩
2026년 2월 10일·웹 개발·

3장: Resolver 구현과 데이터 로딩

GraphQL 리졸버 체인의 실행 흐름과 컨텍스트 설계를 이해합니다. 데이터 소스 추상화, 부모-자식 리졸버 관계, Apollo Server와 GraphQL Yoga에서의 실전 구현 패턴을 다룹니다.

14분774자10개 섹션
graphqlapi-designfrontend
공유
graphql-architecture3 / 11
1234567891011
이전2장: 스키마 설계 패턴다음4장: N+1 문제와 DataLoader 최적화

학습 목표

  • 리졸버 체인의 실행 흐름과 부모-자식 리졸버 관계를 이해한다
  • 컨텍스트 객체를 설계하고 요청별 의존성을 주입하는 방법을 익힌다
  • 데이터 소스 추상화 패턴을 적용할 수 있다
  • Apollo Server와 GraphQL Yoga에서 리졸버를 구현한다

리졸버의 기본 구조

모든 GraphQL 리졸버는 네 개의 인자를 받습니다.

resolver-signature.ts
typescript
type ResolverFn<TParent, TArgs, TContext, TResult> = (
  parent: TParent,    // 부모 리졸버의 반환값
  args: TArgs,        // 클라이언트가 전달한 인자
  context: TContext,   // 요청 전체에 공유되는 객체
  info: GraphQLResolveInfo, // 쿼리 실행 정보 (AST, 필드 이름 등)
) => TResult | Promise<TResult>;
인자설명주요 용도
parent부모 타입 리졸버의 반환값관계 필드 리졸빙
args해당 필드에 전달된 인자필터링, 페이지네이션
context요청 범위 공유 객체인증 정보, DataLoader, DB 연결
info쿼리 AST 및 메타데이터필드 선택 최적화, 복잡도 분석

리졸버 체인

GraphQL의 핵심 메커니즘 중 하나는 리졸버 체인(Resolver Chain) 입니다. 쿼리의 각 필드는 해당 타입의 리졸버를 통해 순차적으로 해결됩니다.

다음 쿼리를 실행할 때 리졸버 체인이 어떻게 동작하는지 살펴보겠습니다.

query-chain.graphql
graphql
query {
  post(id: "1") {
    title
    author {
      name
      avatar
    }
    comments(first: 5) {
      edges {
        node {
          body
          author {
            name
          }
        }
      }
    }
  }
}

기본 리졸버(Default Resolver)

GraphQL 런타임은 명시적 리졸버가 없는 필드에 대해 기본 리졸버(Default Resolver) 를 사용합니다. 기본 리졸버는 parent[fieldName]을 반환합니다.

default-resolver.ts
typescript
// GraphQL 런타임의 기본 리졸버 동작
function defaultFieldResolver(parent: Record<string, unknown>, _args: unknown, _context: unknown, info: GraphQLResolveInfo) {
  return parent[info.fieldName];
}

따라서 Post.title처럼 부모 객체에 이미 데이터가 있는 필드는 별도 리졸버를 작성할 필요가 없습니다.

관계 필드 리졸버

관계 필드(Post.author, Post.comments)는 추가 데이터 조회가 필요하므로 명시적 리졸버를 작성해야 합니다.

relation-resolvers.ts
typescript
const resolvers = {
  Query: {
    post: async (_parent: unknown, args: { id: string }, context: Context) => {
      return context.dataSources.posts.findById(args.id);
    },
  },
  Post: {
    author: async (parent: Post, _args: unknown, context: Context) => {
      // parent에는 Query.post 리졸버가 반환한 Post 객체가 들어옴
      return context.dataSources.users.findById(parent.authorId);
    },
    comments: async (parent: Post, args: PaginationArgs, context: Context) => {
      return context.dataSources.comments.findByPostId(parent.id, args);
    },
  },
  Comment: {
    author: async (parent: Comment, _args: unknown, context: Context) => {
      return context.dataSources.users.findById(parent.authorId);
    },
  },
};
Warning

위 코드에서 Post.author와 Comment.author 리졸버가 각각 독립적으로 User를 조회합니다. 댓글이 5개이고 같은 작성자가 여러 번 등장하면, 동일한 User를 중복 조회하게 됩니다. 이것이 바로 N+1 문제이며, 4장에서 DataLoader를 통해 해결합니다.


컨텍스트 설계

컨텍스트(Context) 는 하나의 요청 내에서 모든 리졸버가 공유하는 객체입니다. 인증 정보, 데이터 소스, DataLoader 인스턴스 등을 담습니다.

컨텍스트 타입 정의

context.ts
typescript
import type { PrismaClient } from '@prisma/client';
import type DataLoader from 'dataloader';
 
interface AuthenticatedUser {
  id: string;
  role: 'ADMIN' | 'USER' | 'EDITOR';
  permissions: string[];
}
 
interface DataSources {
  users: UserDataSource;
  posts: PostDataSource;
  comments: CommentDataSource;
}
 
interface Loaders {
  userById: DataLoader<string, User>;
  postsByAuthorId: DataLoader<string, Post[]>;
}
 
interface Context {
  currentUser: AuthenticatedUser | null;
  dataSources: DataSources;
  loaders: Loaders;
  prisma: PrismaClient;
  requestId: string;
}

컨텍스트 팩토리

컨텍스트는 매 요청마다 새로 생성되어야 합니다. 특히 DataLoader는 요청 단위 캐시를 사용하므로 요청 간 공유하면 안 됩니다.

context-factory.ts
typescript
import { v4 as uuid } from 'uuid';
 
async function createContext({ req }: { req: Request }): Promise<Context> {
  const token = req.headers.get('authorization')?.replace('Bearer ', '');
  const currentUser = token ? await verifyToken(token) : null;
 
  const requestId = uuid();
 
  return {
    currentUser,
    dataSources: {
      users: new UserDataSource(prisma),
      posts: new PostDataSource(prisma),
      comments: new CommentDataSource(prisma),
    },
    loaders: createLoaders(prisma),
    prisma,
    requestId,
  };
}

데이터 소스 추상화

리졸버에서 직접 데이터베이스 쿼리를 작성하면 코드가 비대해지고 테스트가 어려워집니다. 데이터 소스(DataSource) 레이어를 통해 데이터 접근을 추상화합니다.

data-source.ts
typescript
abstract class BaseDataSource<T> {
  constructor(protected prisma: PrismaClient) {}
 
  abstract findById(id: string): Promise<T | null>;
  abstract findAll(args: PaginationArgs): Promise<T[]>;
  abstract count(filter?: Record<string, unknown>): Promise<number>;
}
 
class PostDataSource extends BaseDataSource<Post> {
  async findById(id: string): Promise<Post | null> {
    return this.prisma.post.findUnique({ where: { id } });
  }
 
  async findAll(args: PaginationArgs): Promise<Post[]> {
    return this.prisma.post.findMany({
      take: args.first ?? 20,
      cursor: args.after ? { id: args.after } : undefined,
      skip: args.after ? 1 : 0,
      orderBy: { createdAt: 'desc' },
    });
  }
 
  async count(): Promise<number> {
    return this.prisma.post.count();
  }
 
  async findByAuthorId(authorId: string): Promise<Post[]> {
    return this.prisma.post.findMany({
      where: { authorId },
      orderBy: { createdAt: 'desc' },
    });
  }
 
  async create(input: CreatePostInput, authorId: string): Promise<Post> {
    return this.prisma.post.create({
      data: { ...input, authorId },
    });
  }
}

이 추상화의 이점은 다음과 같습니다.

  • 리졸버가 데이터베이스 구현에 의존하지 않음
  • 테스트 시 DataSource를 모킹하여 리졸버를 독립적으로 테스트 가능
  • 데이터베이스 변경(예: PostgreSQL에서 MongoDB로) 시 DataSource만 수정
  • 공통 로직(로깅, 캐싱)을 BaseDataSource에서 처리 가능

필드 리졸버 vs 타입 리졸버

필드 리졸버(Field Resolver)

특정 타입의 특정 필드에 대한 해결 로직을 정의합니다. 지금까지 살펴본 대부분의 리졸버가 필드 리졸버입니다.

field-resolver.ts
typescript
const resolvers = {
  Post: {
    // 계산된 필드: DB에 없지만 리졸버에서 생성
    readingTime: (parent: Post) => {
      const wordsPerMinute = 200;
      const wordCount = parent.content.split(/\s+/).length;
      return Math.ceil(wordCount / wordsPerMinute);
    },
    // 포맷된 필드
    formattedDate: (parent: Post) => {
      return new Intl.DateTimeFormat('ko-KR', {
        year: 'numeric',
        month: 'long',
        day: 'numeric',
      }).format(new Date(parent.createdAt));
    },
  },
};

타입 리졸버(__resolveType)

유니온이나 인터페이스에서 실제 구체 타입을 판별하는 특수 리졸버입니다.

type-resolver.ts
typescript
const resolvers = {
  // 유니온 타입 리졸버
  SearchResult: {
    __resolveType(obj: { __typename?: string; title?: string; body?: string }) {
      if (obj.__typename) return obj.__typename;
      if ('title' in obj) return 'Post';
      if ('body' in obj) return 'Comment';
      return 'User';
    },
  },
  // 인터페이스 타입 리졸버
  Node: {
    __resolveType(obj: { __typename: string }) {
      return obj.__typename;
    },
  },
};

Apollo Server 구현

Apollo Server 4에서의 전체 서버 설정 예시입니다.

apollo-server.ts
typescript
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { readFileSync } from 'fs';
 
const typeDefs = readFileSync('./schema.graphql', 'utf8');
 
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),
    {
      async requestDidStart() {
        return {
          async didResolveOperation(requestContext) {
            console.log(`Operation: ${requestContext.operationName}`);
          },
          async didEncounterErrors(requestContext) {
            for (const error of requestContext.errors) {
              console.error('GraphQL Error:', error);
            }
          },
        };
      },
    },
  ],
});
 
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: createContext,
});
 
console.log(`Server ready at ${url}`);

GraphQL Yoga 구현

GraphQL Yoga는 경량이면서도 표준을 충실히 따르는 대안입니다.

yoga-server.ts
typescript
import { createSchema, createYoga } from 'graphql-yoga';
import { createServer } from 'node:http';
 
const yoga = createYoga({
  schema: createSchema({
    typeDefs: /* GraphQL */ `
      type Query {
        post(id: ID!): Post
      }
      type Post {
        id: ID!
        title: String!
        author: User!
      }
      type User {
        id: ID!
        name: String!
      }
    `,
    resolvers,
  }),
  context: createContext,
  maskedErrors: process.env.NODE_ENV === 'production',
  logging: true,
});
 
const server = createServer(yoga);
server.listen(4000, () => {
  console.log('Yoga server is running on http://localhost:4000/graphql');
});
Tip

Apollo Server와 GraphQL Yoga 모두 GraphQL 사양을 준수하므로, 리졸버 코드는 프레임워크에 관계없이 동일하게 작성됩니다. 차이는 서버 설정, 미들웨어, 플러그인 생태계에 있습니다. 프로젝트 요구사항에 맞는 것을 선택하면 됩니다.


에러 처리 패턴

리졸버에서 에러를 처리하는 방법은 크게 두 가지입니다.

GraphQLError 던지기

error-handling.ts
typescript
import { GraphQLError } from 'graphql';
 
const resolvers = {
  Query: {
    post: async (_parent: unknown, args: { id: string }, context: Context) => {
      const post = await context.dataSources.posts.findById(args.id);
      
      if (!post) {
        throw new GraphQLError('Post not found', {
          extensions: {
            code: 'NOT_FOUND',
            argumentName: 'id',
          },
        });
      }
 
      if (post.status === 'DRAFT' && context.currentUser?.id !== post.authorId) {
        throw new GraphQLError('Not authorized to view this draft', {
          extensions: { code: 'FORBIDDEN' },
        });
      }
 
      return post;
    },
  },
};

Result Union 패턴

Mutation의 경우 예상 가능한 에러를 타입 시스템으로 표현하는 것이 더 바람직합니다.

result-union.graphql
graphql
type Mutation {
  createPost(input: CreatePostInput!): CreatePostResult!
}
 
union CreatePostResult = CreatePostSuccess | ValidationError | AuthenticationError
 
type CreatePostSuccess {
  post: Post!
}
 
type ValidationError {
  field: String!
  message: String!
}
 
type AuthenticationError {
  message: String!
}

이 패턴을 사용하면 클라이언트가 __typename을 통해 결과 타입을 명확히 구분할 수 있고, 타입 안전한 에러 처리가 가능합니다.


정리

이번 장에서는 GraphQL 리졸버의 구현 방법을 상세히 다루었습니다. 리졸버 체인의 실행 흐름, 네 가지 인자의 역할, 컨텍스트 설계, 데이터 소스 추상화 패턴을 학습했습니다. Apollo Server와 GraphQL Yoga에서의 서버 설정과 에러 처리 방법도 살펴보았습니다.

리졸버는 GraphQL API의 "두뇌"입니다. 잘 설계된 리졸버는 데이터 소스와의 경계를 명확히 하고, 테스트 가능하며, 확장에 열려 있습니다.

다음 장 미리보기

3장에서 보았듯이 관계 필드 리졸버는 N+1 문제를 일으킵니다. 4장에서는 이 문제의 원인을 깊이 분석하고, DataLoader를 활용한 배칭과 캐싱 최적화를 구현합니다. 쿼리 복잡도 분석과 깊이 제한을 통한 방어적 프로그래밍도 함께 다룹니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#graphql#api-design#frontend

관련 글

웹 개발

4장: N+1 문제와 DataLoader 최적화

GraphQL에서 가장 흔한 성능 문제인 N+1 문제의 원인을 분석하고, DataLoader를 통한 배칭과 캐싱 최적화를 구현합니다. 쿼리 복잡도 분석과 깊이 제한 전략도 다룹니다.

2026년 2월 12일·13분
웹 개발

2장: 스키마 설계 패턴

GraphQL 스키마 설계의 두 가지 접근법과 실전 타입 설계 패턴을 다룹니다. Node 인터페이스, Relay 커서 페이지네이션, 유니온/인터페이스 등 프로덕션 수준의 스키마 설계 원칙을 학습합니다.

2026년 2월 8일·13분
웹 개발

5장: Federation과 Supergraph

Apollo Federation 2.0을 활용한 마이크로서비스 GraphQL 아키텍처를 다룹니다. 서브그래프와 슈퍼그래프 개념, 엔티티 참조 리졸버, 주요 디렉티브, 스키마 컴포지션 과정을 학습합니다.

2026년 2월 14일·15분
이전 글2장: 스키마 설계 패턴
다음 글4장: N+1 문제와 DataLoader 최적화

댓글

목차

약 14분 남음
  • 학습 목표
  • 리졸버의 기본 구조
  • 리졸버 체인
    • 기본 리졸버(Default Resolver)
    • 관계 필드 리졸버
  • 컨텍스트 설계
    • 컨텍스트 타입 정의
    • 컨텍스트 팩토리
  • 데이터 소스 추상화
  • 필드 리졸버 vs 타입 리졸버
    • 필드 리졸버(Field Resolver)
    • 타입 리졸버(__resolveType)
  • Apollo Server 구현
  • GraphQL Yoga 구현
  • 에러 처리 패턴
    • GraphQLError 던지기
    • Result Union 패턴
  • 정리
    • 다음 장 미리보기