GraphQL入門からN+1・DataLoaderまで。RESTと住み分けて作る実装手順
GraphQLのスキーマ・リゾルバ・N+1とDataLoader・RESTとの違いを、コピペで動くTypeScriptコードと失敗談で解説。Apollo/urqlの選び方も。
「商品一覧、もっと速くならない?」
レビュー機能をGraphQLで足した翌日、同僚にそう言われました。画面には商品が10件並んでいるだけ。なのにログを見たら、DBへの問い合わせが51回飛んでいたんです。商品リストで1回、そのあと各商品のレビューを取りに10回、平均点を出すのにまた10回……。
これがGraphQLを触ると必ず一度はやらかす「N+1問題」です。僕も最初は「GraphQLって遅いの?」と疑いました。違いました。書き方の問題でした。
GraphQLは「クライアントが欲しいデータの形を自分で指定できる」のが売りです。でもその自由さは、そのまま重いクエリと認可漏れの入口にもなります。今日は、入門の地ならしから、N+1をDataLoaderで潰すところ、RESTとの住み分けまで、僕が実際にハマった順番で書きます。
この記事の要点
- GraphQLは「1つのエンドポイントに欲しいデータの形を投げる」API。RESTの「URLごとに固定のデータ」とは設計思想が逆。
- N+1問題は、リスト取得のあと関連データを1件ずつ取りに行って起きる。DataLoaderで同じリクエスト内の読み取りをまとめれば消える。
- DataLoaderのキャッシュはリクエストごとに作る。使い回すと他人のデータが漏れる(公式READMEの警告)。
- クライアントはApollo Client(全部入り)かurql(軽量)。キャッシュをどこまで自動でやりたいかで選ぶ。
- RESTとGraphQLは敵じゃない。画面駆動の取得はGraphQL、単純なCRUDや外部公開はRESTで住み分けると楽。gRPCはまた別パラダイム(サーバー間の高速通信向け)。
GraphQLって、RESTと何が違うの?
いちばん腹落ちした例えを書きます。RESTは定食、GraphQLはビュッフェです。
RESTでは、URLごとに出てくる料理(データ)が決まっています。/products/1 を叩けば商品の全情報が、/products/1/reviews を叩けばレビューが返る。欲しいのが商品名だけでも、住所も在庫数も全部ついてきます(オーバーフェッチ)。逆に画面に商品とレビューと著者を出したいと、3回叩く羽目になります(アンダーフェッチ)。
GraphQLは、1つの窓口に「これとこれが欲しい」と注文を書いて投げる方式です。エンドポイントは基本1つ。クライアントがクエリで欲しいフィールドを指定します。
# クライアントが投げるクエリ。欲しいフィールドだけを書く
query {
product(id: "prod_1") {
title
reviews(first: 5) {
edges { node { rating body } }
}
}
}
これ1回で、商品名とレビュー上位5件が、過不足なく返ります。表にするとこうです。
| 観点 | REST | GraphQL |
|---|---|---|
| エンドポイント | リソースごとに複数 | 原則1つ |
| 取得するデータ | URLで固定 | クライアントがクエリで指定 |
| オーバー/アンダーフェッチ | 起きやすい | 起きにくい |
| 型・スキーマ | OpenAPI等で別途定義 | スキーマが言語仕様に内蔵 |
| キャッシュ | HTTPキャッシュが効く | 自前 or クライアントライブラリ任せ |
| 向いている場面 | 単純CRUD、外部公開API | 画面駆動、関連データが多い |
注意したいのは、GraphQLが常に正解ではないこと。HTTPキャッシュ(CDN)に素直に乗せたい公開APIや、ファイルアップロードのような単純な操作は、RESTのほうが運用が軽いです。RESTの設計を固めたい人はClaude CodeでREST API設計を固めるが参考になります。「全部GraphQLにする」ではなく、画面の都合でデータをまとめたい層だけGraphQLにするのが、僕がたどり着いた現実解です。
スキーマを「契約」として先に置く
GraphQLで最初にやるのは、コードを書くことではなく、スキーマ(SDL)を書くことです。SDLは「どんな型があって、何を返せるか」を言語に依存しない形で書いた契約書。これを先に固めると、フロントとバックが同じ地図を見て動けます。
商品レビューAPIを例に、最小のスキーマを置きます。reviews のようなネストは便利ですが、後でN+1の入口になるので、そこは覚えておいてください。
# src/graphql/schema.graphql
type Product {
id: ID!
title: String!
isPublic: Boolean!
averageRating: Float
reviews(first: Int = 10, after: String): ReviewConnection!
createdAt: String!
}
type Review {
id: ID!
product: Product!
rating: Int!
body: String!
createdAt: String!
}
type ReviewEdge {
node: Review!
cursor: String!
}
type ReviewConnection {
edges: [ReviewEdge!]!
pageInfo: PageInfo!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
input CreateReviewInput {
productId: ID!
rating: Int!
body: String!
}
type Query {
product(id: ID!): Product
products(first: Int = 10, after: String): ProductConnection!
}
type ProductEdge { node: Product! cursor: String! }
type ProductConnection { edges: [ProductEdge!]! pageInfo: PageInfo! }
type Mutation {
createReview(input: CreateReviewInput!): Review!
}
リストを edges / node / cursor / pageInfo で包む書き方は、GraphQL公式のPaginationが勧めるカーソルベースの形です。?page=2 のようなオフセットより、データが増減してもズレにくい。
ひとつ釘を刺すと、スキーマの型は入力チェックを全部はやってくれません。rating: Int! は「整数だよ」と言うだけ。1〜5の範囲か、本文が長すぎないか、HTMLが混ざっていないかは、このあとのリゾルバ側で守ります。型に守られている気分が、いちばん危ない。
リゾルバは「フィールドの値を取りに行く関数」
スキーマが「メニュー表」なら、リゾルバ(resolver)は「注文された料理を実際に作る厨房」です。Query.product が呼ばれたら商品を返す、Product.reviews が呼ばれたらレビューを返す。フィールドごとに、値を取りに行く関数を書きます。
TypeScriptでの典型的な失敗は、args や context を any で受けること。最初は速いんですが、フィールド名を変えた瞬間に黙って壊れます。GraphQL Code Generatorを使うと、スキーマからリゾルバの型を自動生成できます。
// codegen.ts — スキーマから型付きリゾルバを生成する設定
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "src/graphql/schema.graphql",
generates: {
"src/graphql/generated/resolvers-types.ts": {
plugins: ["typescript", "typescript-resolvers"],
config: {
contextType: "../context#GraphQLContext",
mappers: {
// GraphQLの型と、DBから返る行の型を対応づける
Product: "../models#ProductRow",
Review: "../models#ReviewRow",
},
},
},
},
};
export default config;
mappers がじわじわ効きます。GraphQLの Product は averageRating を持ちますが、DBの行(ProductRow)には無いことが多い。この差を型で明示しておくと、「DB行に存在しないフィールドを参照する」ミスがコンパイル時に弾かれます。
N+1問題をDataLoaderで潰す
ここが本題です。冒頭の「51回問い合わせ」を再現してみます。素朴に書くと、Product.reviews リゾルバは商品1件ごとに呼ばれます。商品が10件あれば10回。これがN+1です。
DataLoaderは、同じリクエスト内に発生した load("prod_1"), load("prod_2") …… を、イベントループの1ティックぶんまとめて1回のバッチ関数に渡してくれる道具です。キーの配列を受け取り、同じ順番で結果の配列を返すのがルール。
まずデータ層(インメモリの仮DB)と、認可をここに寄せた repo を用意します。
// src/graphql/models.ts
export type Viewer = { id: string; role: "user" | "admin" } | null;
export type ProductRow = {
id: string; title: string; ownerId: string; isPublic: boolean; createdAt: string;
};
export type ReviewRow = {
id: string; productId: string; rating: number; body: string; authorId: string; createdAt: string;
};
const products: ProductRow[] = [
{ id: "prod_1", title: "Claude Code Playbook", ownerId: "user_1", isPublic: true, createdAt: "2026-06-02T00:00:00.000Z" },
{ id: "prod_private", title: "Internal Prompt Pack", ownerId: "user_2", isPublic: false, createdAt: "2026-06-02T00:00:00.000Z" },
];
const reviews: ReviewRow[] = [
{ id: "rev_1", productId: "prod_1", rating: 5, body: "実務で使える", authorId: "user_1", createdAt: "2026-06-02T00:10:00.000Z" },
{ id: "rev_2", productId: "prod_1", rating: 4, body: "レビューが楽に", authorId: "user_2", createdAt: "2026-06-02T00:20:00.000Z" },
];
// 認可は「見てよいか」をここ1か所に集約する
function canSee(viewer: Viewer, p: ProductRow) {
return p.isPublic || viewer?.role === "admin" || viewer?.id === p.ownerId;
}
export const repo = {
productById: (id: string, viewer: Viewer) => {
const p = products.find((x) => x.id === id);
return p && canSee(viewer, p) ? p : null;
},
products: (viewer: Viewer) => products.filter((p) => canSee(viewer, p)),
// 複数productIdのレビューを一括取得(DataLoaderのバッチ関数が呼ぶ)
reviewsByProductIds: (productIds: readonly string[], viewer: Viewer) => {
const visible = new Set(
products.filter((p) => productIds.includes(p.id) && canSee(viewer, p)).map((p) => p.id),
);
return reviews.filter((r) => visible.has(r.productId));
},
};
次に、リクエストごとにDataLoaderを作るコンテキスト。ここがいちばん大事で、ローダーは必ず createContext の中で新規生成します。
// src/graphql/context.ts
import { randomUUID } from "node:crypto";
import DataLoader from "dataloader";
import { repo, type ReviewRow, type Viewer } from "./models";
function createLoaders(viewer: Viewer) {
return {
// productIdごとのレビュー配列を、1リクエスト内でまとめて取る
reviewsByProductId: new DataLoader<string, ReviewRow[]>(async (productIds) => {
const rows = repo.reviewsByProductIds(productIds, viewer);
const grouped = new Map<string, ReviewRow[]>();
for (const id of productIds) grouped.set(id, []);
for (const r of rows) grouped.get(r.productId)?.push(r);
// 受け取ったキーと同じ順番で返すのがDataLoaderの約束
return productIds.map((id) => grouped.get(id) ?? []);
}),
};
}
export type GraphQLContext = {
viewer: Viewer; requestId: string; loaders: ReturnType<typeof createLoaders>;
};
export function createContext(opts: { viewer?: Viewer } = {}): GraphQLContext {
const viewer = opts.viewer ?? null;
return { viewer, requestId: randomUUID(), loaders: createLoaders(viewer) };
}
最後にリゾルバ。Product.reviews も Product.averageRating も、直接DBを叩かず loaders.reviewsByProductId.load() を呼ぶだけにします。これで商品が何件並んでも、レビュー取得は1回のバッチにまとまります。
// src/graphql/resolvers.ts
import { GraphQLError } from "graphql";
import { repo, type ReviewRow } from "./models";
function encodeCursor(id: string) { return Buffer.from(id).toString("base64url"); }
function decodeCursor(c?: string | null) {
if (!c) return null;
try { return Buffer.from(c, "base64url").toString("utf8"); } catch { return null; }
}
// first を 1..50 に丸めてカーソルページングを返す共通関数
function connection<T extends { id: string }>(rows: T[], args: { first?: number | null; after?: string | null }) {
const first = Math.min(Math.max(args.first ?? 10, 1), 50);
const afterId = decodeCursor(args.after);
const start = afterId ? rows.findIndex((r) => r.id === afterId) + 1 : 0;
const slice = rows.slice(Math.max(start, 0), Math.max(start, 0) + first + 1);
const page = slice.slice(0, first);
return {
edges: page.map((node) => ({ node, cursor: encodeCursor(node.id) })),
pageInfo: {
hasNextPage: slice.length > first,
endCursor: page.length > 0 ? encodeCursor(page[page.length - 1].id) : null,
},
};
}
export const resolvers = {
Query: {
product: (_p: unknown, a: { id: string }, c: any) => repo.productById(a.id, c.viewer),
products: (_p: unknown, a: any, c: any) => connection(repo.products(c.viewer), a),
},
Product: {
reviews: async (product: { id: string }, a: any, c: any) => {
const rows = await c.loaders.reviewsByProductId.load(product.id); // N+1を回避
return connection(rows, a);
},
averageRating: async (product: { id: string }, _a: unknown, c: any) => {
const rows: ReviewRow[] = await c.loaders.reviewsByProductId.load(product.id);
if (rows.length === 0) return null;
return rows.reduce((s, r) => s + r.rating, 0) / rows.length;
},
},
Mutation: {
createReview: (_p: unknown, _a: unknown, c: any) => {
if (!c.viewer) throw new GraphQLError("ログインが必要です", { extensions: { code: "UNAUTHENTICATED" } });
throw new GraphQLError("not implemented in demo", { extensions: { code: "BAD_USER_INPUT" } });
},
},
};
DataLoaderのREADMEがはっきり警告しているのは、このローダーをユーザー間で使い回さないこと。キャッシュはリクエストごと。グローバルに置くと、Aさんのリクエストで読んだ非公開データが、Bさんのレスポンスに混ざります。これは速度の話ではなく情報漏れの事故です。ローダーは「Redisの代わり」ではない、と覚えておいてください。
まず動かす:N+1が消えたか自分の目で確認する
理屈より、消えた瞬間を見たほうが早いです。さっきの仮DBに「呼ばれた回数」を数えるカウンタを刺して、DataLoaderあり/なしを比べる1ファイル完結のスクリプトを置きます。graphql と dataloader が入っていれば、そのまま動きます。
// nplus1-demo.ts 実行: npx tsx nplus1-demo.ts
import { graphql, buildSchema } from "graphql";
import DataLoader from "dataloader";
const schema = buildSchema(`
type Review { id: ID! rating: Int! }
type Product { id: ID! title: String! reviews: [Review!]! }
type Query { products: [Product!]! }
`);
const products = Array.from({ length: 10 }, (_, i) => ({ id: `p${i}`, title: `商品${i}` }));
const reviewsDB = products.flatMap((p) => [
{ id: `${p.id}-r1`, productId: p.id, rating: 5 },
{ id: `${p.id}-r2`, productId: p.id, rating: 4 },
]);
let dbHits = 0; // DBアクセス回数のカウンタ
function fetchReviews(productIds: readonly string[]) {
dbHits++; // ここが呼ばれた回数 = 問い合わせ回数
return productIds.map((id) => reviewsDB.filter((r) => r.productId === id));
}
const QUERY = `{ products { id title reviews { id rating } } }`;
// --- DataLoaderなし: 商品ごとに1回ずつ叩く(N+1) ---
async function run(useLoader: boolean) {
dbHits = 0;
const loader = new DataLoader<string, any[]>(async (ids) => fetchReviews(ids));
const root = {
products: () =>
products.map((p) => ({
...p,
reviews: () =>
useLoader
? loader.load(p.id) // まとめて1回
: fetchReviews([p.id])[0], // 商品ごとに1回 → N+1
})),
};
await graphql({ schema, source: QUERY, rootValue: root });
return dbHits;
}
console.log("DataLoaderなし:", await run(false), "回");
console.log("DataLoaderあり:", await run(true), "回");
手元で走らせると、こう出ます。
DataLoaderなし: 10 回
DataLoaderあり: 1 回
10回が1回。商品が1000件でも、DataLoaderありなら1回のままです。この差を一度自分の画面で見ておくと、「なんとなく入れる」から「なぜ入れるか言える」に変わります。テストまで含めた本番寄りの組み方はAPIテストをsupertest+Vitestで自動化するの考え方がそのまま流用できます。
クライアントはApolloかurqlか
サーバーができたら、フロントから叩くクライアントを選びます。代表はApollo Clientとurql。ざっくりこう住み分けます。
| Apollo Client | urql | |
|---|---|---|
| 性格 | 全部入り・高機能 | 軽量・最小構成から拡張 |
| 正規化キャッシュ | 標準で強力 | 追加パッケージ(graphcache)で対応 |
| 学習コスト | やや高め | 低め |
| 向いている | 大規模・複雑な状態管理 | 中小規模、まず素早く動かしたい |
実装の手触りも違います。urqlの最小例はこれくらい簡潔です。
// urqlでクエリを投げる最小例(React)
import { createClient, Provider, useQuery } from "urql";
const client = createClient({ url: "http://localhost:4000/graphql" });
const PRODUCTS = `query { products(first: 5) { edges { node { id title } } } }`;
function ProductList() {
const [{ data, fetching, error }] = useQuery({ query: PRODUCTS });
if (fetching) return <p>読み込み中…</p>;
if (error) return <p>エラー: {error.message}</p>;
return <ul>{data.products.edges.map((e: any) => <li key={e.node.id}>{e.node.title}</li>)}</ul>;
}
export const App = () => (
<Provider value={client}>
<ProductList />
</Provider>
);
選び方の指針は1つ。キャッシュの自動化をどこまで欲しいかです。同じ商品が複数画面に出てきて、片方で更新したら全部に反映してほしい——みたいな複雑な要求があるなら、正規化キャッシュが標準のApollo。まず動かして必要に応じて足したいなら、urqlから始める。僕は個人開発はurql、関連データの多い管理画面はApollo、と使い分けています。
深いクエリ・重いクエリを止める
GraphQLの自由さは、攻撃面でもあります。深すぎるネスト、巨大な first、大量のエイリアス。RESTより読みにくい負荷が生まれます。GraphQL公式のSecurityが、ページネーション・深さ制限・複雑度解析・レート制限を組み合わせる考え方を整理しています。
最低限、実行前にクエリの深さを弾くバリデーションは入れたいところ。GraphQL.jsのバリデーションルールとして書けます。
// src/graphql/depthLimit.ts クエリの深さを制限する
import { GraphQLError, Kind, type ValidationContext, type ASTVisitor } from "graphql";
export function depthLimit(maxDepth = 6) {
return (context: ValidationContext): ASTVisitor => ({
OperationDefinition(node) {
const depth = measure(node.selectionSet, 1);
if (depth > maxDepth) {
context.reportError(new GraphQLError(`クエリが深すぎます(深さ${depth} > 上限${maxDepth})`, { nodes: node }));
}
},
});
function measure(selectionSet: any, depth: number): number {
let max = depth;
for (const sel of selectionSet.selections) {
if (sel.kind === Kind.FIELD && sel.selectionSet) {
max = Math.max(max, measure(sel.selectionSet, depth + 1));
}
}
return max;
}
}
これを validate(schema, document, [...specifiedRules, depthLimit(6)]) のように足すだけ。ただし深さ制限だけでは足りません。浅くても、巨大な first や大量エイリアスで重くなる。だからリゾルバ側でも first を上限で丸める(さっきの connection 関数がやっています)。公開クライアントだけなら、事前承認したクエリしか通さない「trusted documents」も検討します。守る境界は1枚で十分とは限りません。
gRPCとはどう違う?住み分けの地図
「GraphQLとgRPC、どっち使えばいいの?」もよく聞かれます。これは別パラダイムで、競合というより役割が違います。
- GraphQL: ブラウザやアプリ向け。クライアントが欲しいデータの形を指定する。画面駆動の取得に強い。
- gRPC: サーバー間通信向け。Protocol Buffersでスキーマを定義し、バイナリで高速にやり取りする。マイクロサービス間に強い。
- REST: 単純なCRUD、外部公開、HTTPキャッシュを効かせたい層に強い。
現場では混ぜます。フロント↔BFF(Backend for Frontend)はGraphQL、BFF↔内部マイクロサービスはgRPC、外部公開APIはREST、という三層構成は珍しくありません。gRPCの具体的な実装はClaude CodeでgRPC開発にまとめてあるので、サーバー間の高速通信が要るならそちらを。1つに統一しようとしないのが、結局いちばん事故が少ないです。
よくある質問
Q. GraphQLは小規模なプロジェクトでも使うべき? A. 必須ではないです。エンドポイントが数個で関連データも浅いなら、RESTのほうが学習コストもインフラも軽い。GraphQLが効くのは「画面ごとに必要なデータの形がバラバラ」「関連を辿る取得が多い」ときです。
Q. N+1はDataLoader以外で防げない? A. JOINで一括取得する、ORMのeager loadingを使う、といった手もあります。ただGraphQLはフィールド単位でリゾルバが走るので、リクエスト内のバッチをまとめるDataLoaderがいちばん素直にハマります。
Q. DataLoaderのキャッシュはどのくらい持つ? A. 1リクエストのあいだだけです。READMEも「Redisやmemcacheの代替ではない」と明言しています。ユーザーをまたぐ永続キャッシュが要るなら、Redis等を別に用意してください。
Q. ApolloとurqlはあとからN+1に効く? A. それはサーバー側の話で、クライアントは関係ありません。N+1はリゾルバの実装で潰します。クライアントが効くのは、画面側の重複取得をキャッシュで減らす別レイヤーです。
Q. RESTから少しずつGraphQLへ移せる? A. 移せます。既存のRESTやサービス層をリゾルバから呼ぶ「resolver-first」で始め、動くものを足しながら、最後にスキーマを整えると現実的です。いきなり全面移行しないのがコツです。
実際に試した結果
冒頭の「51回問い合わせ」事件のあと、僕は順番を変えました。前は「動くものを書いてから速くする」だったのを、「スキーマと認可を先に固め、N+1が起きる場所を最初にDataLoaderで囲う」に。
効果がいちばん大きかったのは、実は高度なキャッシュ設定ではなく、上の nplus1-demo.ts みたいな小さい検証を先に回したことでした。DataLoaderあり/なしで「10回 → 1回」を目で見ておくと、後輩に説明するときも「なんとなく」が消えます。
GraphQLは賢く使えば、画面に必要なデータをきれいに1回で返せる強力な道具です。でも自由なぶん、N+1・認可漏れ・重いクエリという落とし穴も同じだけ用意されています。守る境界(認可・firstの上限・深さ制限)を先に置いてから、自由に書く。この順番だけで、本番に出せる品質にぐっと近づきます。
テンプレートやチェックリストから始めたい人は教材・テンプレート一覧を、チームでスキーマ設計から認可・テストまで整えたいならClaude Code研修・導入相談をどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。