Apollo ServerとTypeScriptによる実践GraphQL API構築入門:Schema、ResolverからDataLoaderを用いたN+1問題の完全解決まで

Development tutorial - IT technology blog
Development tutorial - IT technology blog

クイックスタート(5分で動かす環境構築)

面倒な初期設定なしで、手軽にGraphQLサーバーを試してみたいと思いませんか? Node.js、TypeScript、Apollo Server v4の組み合わせなら、わずか数分で動くプロトタイプを立ち上げることができます。

まずはプロジェクトディレクトリを作成し、必要なパッケージをインストールしましょう。

mkdir graphql-ts-quickstart && cd graphql-ts-quickstart
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node tsx
npx tsc --init

続いて src/index.ts を作成し、以下の最小限のコードを貼り付けます。

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

// 1. スキーマの定義
const typeDefs = `#graphql
  type Book {
    id: ID!
    title: String!
    author: String!
  }

  type Query {
    books: [Book!]!
  }
`;

// 2. サンプルデータ
const books = [
  { id: '1', title: 'Clean Architecture', author: 'Robert C. Martin' },
  { id: '2', title: 'Refactoring', author: 'Martin Fowler' },
];

// 3. リゾルバーの実装
const resolvers = {
  Query: {
    books: () => books,
  },
};

// 4. サーバーの起動
async function bootstrap() {
  const server = new ApolloServer({ typeDefs, resolvers });
  const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
  console.log(`🚀 サーバーが起動しました: ${url}`);
}

bootstrap();

次のコマンドを実行してサーバーを起動します。

npx tsx src/index.ts

ブラウザで http://localhost:4000 にアクセスすると、Apollo SandboxのUIが表示されます。あとは { books { title author } } というクエリを入力してRunをクリックするだけで、実行結果を確認できます。

詳細解説:SchemaとResolver

課題:なぜ複雑なアプリケーションでREST APIは限界を迎えるのか?

モバイルアプリのフィード画面を開発している場面を想像してみてください。そのUIには、ユーザープロフィール、投稿一覧、最新のコメント、いいね数など、多岐にわたるデータを集約して表示する必要があります。従来のRESTアーキテクチャでは、フロントエンドチームは常に次の2つの壁に直面することになります。

  • オーバーフェッチ(Over-fetching): 友達リストを表示するために name と avatarUrl だけが必要だとします。しかし /api/users エンドポイントは、電話番号、生年月日、住所、取引履歴まで余計に返してきます。その結果、ペイロードが無駄に肥大化し、数KBで済むはずが数百KBに膨らんでしまいます。
  • アンダーフェッチ(Under-fetching): 単一のエンドポイントでは必要なデータを集めきれません。プロフィール画面を構築するために、クライアントは複数のリクエストを順次送信せざるを得ません。まず /users/1 を取得し、その完了を待ってから /users/1/posts を呼び出し、さらに各投稿のコメントを取得するためにループ処理を行います。通信環境が不安定なモバイル回線では、累積レイテンシが容易に800msを超えてしまいます。

GraphQLはこのパワーバランスを劇的に覆します。クライアント側が必要なデータ構造を厳密に指定できるため、リクエストしたフィールドだけをサーバーが過不足なく返してくれます。1バイトの無駄も生じません。

スキーマ設計:明確な仕様契約(Contract)

GraphQLにおいて、スキーマ(Schema)はフロントエンドとバックエンドの間で交わされる契約書の役割を果たします(スキーマ駆動開発 / Contract-Driven Development)。Apollo ServerではSDL(Schema Definition Language)記法を用いることで、型(Types)、クエリ(Queries)、ミューテーション(Mutations)を直感的かつ明瞭に定義できます。

ここでは、User と Post のリレーションを持つブログ機能を例に、スキーマ設計を見ていきましょう。

export const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    authorId: ID!
    author: User!
  }

  type Query {
    posts: [Post!]!
    user(id: ID!): User
  }

  input CreatePostInput {
    title: String!
    content: String!
    authorId: ID!
  }

  type Mutation {
    createPost(input: CreatePostInput!): Post!
  }
`;

TypeScriptによる型安全なリゾルバーの実装

スキーマが設計図なら、リゾルバー(Resolver)は実際に組み立てを行う職人です。各リゾルバーは、特定のフィールドに対応するデータを取得・解決する関数であり、通常 (parent, args, contextValue, info) という4つの引数を受け取ります。

大規模プロジェクトでは、GraphQL Code Generatorを導入してスキーマ定義から型を自動生成するのがベストプラクティスです。本記事ではシンプルに解説するため、インメモリモデルのインターフェースを手動で定義し、コンパイル時の型安全性を確保します。

export interface UserModel {
  id: string;
  name: string;
  email: string;
}

export interface PostModel {
  id: string;
  title: string;
  content: string;
  authorId: string;
}

// インメモリのモックデータベース
export const fakeUsers: UserModel[] = [
  { id: 'u1', name: '山田 太郎', email: '[email protected]' },
  { id: 'u2', name: '佐藤 花子', email: '[email protected]' },
];

export const fakePosts: PostModel[] = [
  { id: 'p1', title: 'TypeScript入門のロードマップ', content: '記事1の本文...', authorId: 'u1' },
  { id: 'p2', title: 'Node.jsバックエンド最適化', content: '記事2の本文...', authorId: 'u1' },
  { id: 'p3', title: 'エンジニア面接対策の知見', content: '記事3の本文...', authorId: 'u2' },
];

応用編:DataLoaderによるN+1問題の完全解決

N+1問題:GraphQLに潜む時限爆弾

GraphQLを学び始めたエンジニアの多くは、直感的なアプローチでリゾルバーを書きがちです。以下の Post と Author のデータマッピングの例を見てみましょう。

export const resolvers = {
  Query: {
    posts: (): PostModel[] => {
      console.log('Query: 投稿一覧を取得');
      return fakePosts;
    },
  },
  Post: {
    author: (parent: PostModel): UserModel | undefined => {
      console.log(`Field Resolver: ユーザーID ${parent.authorId} を検索`);
      return fakeUsers.find((u) => u.id === parent.authorId);
    },
  },
};

クライアントから、以下のような典型的なネストされたクエリが送信されたとします。

query GetPostsWithAuthors {
  posts {
    id
    title
    author {
      name
    }
  }
}

ターミナルを確認すると、次のようなログが出力されます。

Query: 投稿一覧を取得 (1回)
Field Resolver: ユーザーID u1 を検索
Field Resolver: ユーザーID u1 を検索
Field Resolver: ユーザーID u2 を検索

3件の投稿に対して、子リゾルバーが3回実行されています。では、取得する投稿が100件になったらどうなるでしょうか? Post.author リゾルバーは100回連続で呼び出されることになります。

PostgreSQLやMySQLと接続する本番環境では、サーバーは投稿一覧を取得する1回のクエリ + 著者情報を取得する個別のクエリ100回を発行することになります。仮に秒間100リクエストの負荷がかかれば、データベースは毎秒10,000回以上のクエリを処理しなければなりません。その結果、コネクションプールは枯渇し、CPU使用率は100%に達し、サーバーは瞬く間にダウンしてしまいます。

根本的な原因はどこにあるのか?

GraphQLの実行エンジンは、ツリー構造を深さ優先探索(depth-first execution)で解決していきます。各リゾルバーは完全に独立して実行されるため、親(parent)から渡されたデータしか把握できません。同階層にある他のリゾルバーが誰のデータを取得しようとしているかを知る術がなく、リクエストを1つにまとめることができないのです。

解決策:DataLoaderによるバッチ処理とキャッシュ

このボトルネックを打破するためにMeta社が開発したライブラリが dataloader です。その仕組みは非常に巧妙で、Node.jsのイベントループにおけるマイクロタスクキュー(microtask queue)を活用して処理を一括化(バッチ処理)します。

同一の実行ティック内で個別のクエリを即座に投げるのではなく、DataLoaderはわずか数マイクロ秒だけ待機します。同階層の全リゾルバーから要求されたIDが集まるのを待って単一の配列にまとめ上げるのです。これにより、100回に及ぶ個別の問い合わせが、たった1回の SELECT * FROM users WHERE id IN ('u1', 'u2', ...) というクエリへと劇的に集約されます。

プロジェクトにライブラリをインストールします。

npm install dataloader

DataLoaderをインスタンス化し、Apollo Serverの context に登録します。

import DataLoader from 'dataloader';
import { UserModel, fakeUsers, PostModel, fakePosts } from './data';

// バッチロード関数:IDの配列を受け取り、対応するUserの配列を返す
async function batchUsers(userIds: readonly string[]): Promise<(UserModel | null)[]> {
  console.log(`--> DataLoaderがIDのバッチクエリをまとめました: [${userIds.join(', ')}]`);
  
  // SQLクエリのシミュレーション: SELECT * FROM users WHERE id IN (...)
  const users = fakeUsers.filter((u) => userIds.includes(u.id));
  
  // 必須:返却する配列はuserIdsと同じ長さかつ同一のインデックス順でなければならない
  return userIds.map((id) => users.find((u) => u.id === id) || null);
}

export interface GraphQLContext {
  userLoader: DataLoader<string, UserModel | null>;
}

export function createContext(): GraphQLContext {
  return {
    userLoader: new DataLoader<string, UserModel | null>(batchUsers),
  };
}

リゾルバーを修正し、context経由でDataLoaderを利用するように変更します。

export const resolvers = {
  Query: {
    posts: (): PostModel[] => fakePosts,
  },
  Post: {
    author: async (parent: PostModel, _: unknown, context: GraphQLContext) => {
      // DBへ直接クエリを発行する代わりに、DataLoaderのキューに追加
      return context.userLoader.load(parent.authorId);
    },
  },
};

サーバー初期化コードをcontext factory対応に更新します。

const server = new ApolloServer<GraphQLContext>({
  typeDefs,
  resolvers,
});

const { url } = await startStandaloneServer(server, {
  context: async () => createContext(),
  listen: { port: 4000 },
});

先ほどと同じ3件の投稿クエリを再実行すると、ターミナルには次のように出力されます。

--> DataLoaderがIDのバッチクエリをまとめました: [u1, u2]

DataLoaderは「複数IDのバッチ集約」と「重複IDの排除(重複排除 / Deduplication)」を同時に実行してくれました。これにより、3回に分かれていたクエリがたった1回のデータ取得に抑えられます。

本番環境で運用するための実践的なTips

  • DataLoaderのライフサイクル(リクエストスコープの原則): DataLoaderをグローバルなシングルトン変数としてインスタンス化してはいけません。必ず各リクエストの createContext() 内で新しくインスタンスを生成してください。リクエスト間でインスタンスを共有すると、ユーザーAのキャッシュデータ(機密情報を含む可能性のあるデータ)をユーザーBが誤って読み取ってしまう脆弱性に繋がります。
  • バッチ関数における配列の整合性と順序の厳守: DataLoaderの仕様上、返却する配列の要素数は渡されたキー(keys)の配列長と完全に一致し、各インデックスの順序も対応している必要があります。データベースからの取得順序が崩れているにもかかわらずキー配列に合わせて再マッピングしなかった場合、別のユーザーの名前が著作者として誤紐付けされるバグが発生します。
  • JSONペイロードの整形と検証: ネストの深いリゾルバーをデバッグする際、巨大で複雑なJSONペイロードを扱うことになります。ブラウザ拡張機能を無暗に追加したくない場合は、toolcraft.appなどのオンラインツール(例:toolcraft.app/ja/tools/developer/json-formatter のJSON Formatter機能)を活用すれば、手軽にJSONの整形・構文チェック・フィルタリングが行えます。
  • Query Depth LimitingによるDoS攻撃の防御: 双方向リレーションを持つスキーマは、posts { author { posts { author { posts { ... } } } } } のような無限ネストクエリによるDoS攻撃の標的になりやすいです。リゾルバーが実行される前に graphql-depth-limit などのパッケージを導入し、クエリの深さを最大4〜5階層程度に制限しておくことが不可欠です。
Share: