GraphQLとRESTの違いは、RESTが「URLごとに決まった形のデータを返す」のに対し、GraphQLは「1つのエンドポイントで、クライアントが必要な項目をクエリで指定して受け取る」点です。 どちらもHTTPの上で動くAPIの作り方で、優劣ではなく、得意なことが異なります。

この記事では、ユーザー・投稿・コメントという同じデータを、RESTとGraphQLの両方でNode.jsに実装し、「記事一覧画面」1つを表示するのに必要なリクエスト数・転送バイト数・SQLの実行回数を実測しました。 GraphQLで起きやすいN+1問題がDataLoaderでどう変わるか、エラーがHTTP 200で返る挙動、クエリの深さ制限の効き方も、すべて実際の出力で示します。最後に、選定チェックリストとBFF・tRPC・gRPCの位置づけもまとめます(検証環境:Node.js 22、graphql 17.0.2、graphql-yoga 5.24.1、dataloader 2.2.3、Express 5.2.1、SQLite〈better-sqlite3〉)。

GraphQLとRESTの違い早見表

観点 REST GraphQL
エンドポイント リソースごとに複数(/users/1、/posts) 原則1つ(/graphql)
取得する項目 サーバーが決める クライアントがクエリで選ぶ
関連データの取得 複数回のリクエストか、専用エンドポイント 1回のクエリでネストして取得
型・仕様書 OpenAPIなどで別途定義 スキーマ(SDL)が必須で、仕様書を兼ねる
HTTPキャッシュ GETとURL単位で効きやすい(ETag、CDN) 主にPOSTのため、クライアント側キャッシュが中心
エラーの表し方 HTTPステータスコード 200で errors 配列を返すことが多い
バージョニング /v1/、/v2/ など フィールド追加と @deprecated で1本を育てる
負荷の予測 エンドポイント単位で見積もりやすい クエリ次第で重くなるため、深さ・複雑度の制限が必要
向いている場面 公開API、ファイル配信、シンプルなCRUD 画面ごとに必要な項目が違う、関連データが多い

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「GraphQLとRESTの違い|同じ画面を両方で作ってリクエスト数を実測」

検証の準備:同じデータをRESTとGraphQLで公開する

同じSQLiteのデータ(ユーザー20人・投稿100件・コメント300件)を、1つのNode.jsサーバーからREST版とGraphQL版で公開しました。 SQLを実行するたびにカウンタを1増やし、何回データベースに問い合わせたかを数えます。

表示したい画面は「新しい投稿10件の一覧。各投稿にタイトル・著者名・先頭3件のコメント(本文とコメント者名)を表示」です。

REST版のエンドポイント

// SQL実行回数を数える
const stats = { sql: 0 };
const q  = (sql, ...a) => { stats.sql++; return db.prepare(sql).all(...a); };
const q1 = (sql, ...a) => { stats.sql++; return db.prepare(sql).get(...a); };

app.get("/api/posts", (req, res) =>
  res.json(q("SELECT * FROM posts ORDER BY id DESC LIMIT ?", Number(req.query.limit ?? 10))));
app.get("/api/users/:id", (req, res) =>
  res.json(q1("SELECT * FROM users WHERE id=?", req.params.id)));
app.get("/api/posts/:id/comments", (req, res) =>
  res.json(q("SELECT * FROM comments WHERE post_id=? ORDER BY id LIMIT ?",
             req.params.id, Number(req.query.limit ?? 3))));

クライアントは「一覧 → 各投稿の著者とコメント → 各コメントの著者」の順に取得します。同じユーザーIDは1回だけ取得するようにキャッシュし、並列にできるところは並列化した、なるべく効率のよい書き方にしています。

const posts = await get("/api/posts?limit=10");
const cache = new Map();
const user = (id) => {
  if (!cache.has(id)) cache.set(id, get(`/api/users/${id}`));
  return cache.get(id);
};
const view = await Promise.all(posts.map(async (p) => {
  const [author, comments] = await Promise.all([
    user(p.user_id), get(`/api/posts/${p.id}/comments?limit=3`)]);
  const cs = await Promise.all(comments.map(async (c) =>
    ({ body: c.body, author: { name: (await user(c.user_id)).name } })));
  return { title: p.title, author: { name: author.name }, comments: cs };
}));

GraphQL版のスキーマとリゾルバ

import { createYoga, createSchema } from "graphql-yoga";
import DataLoader from "dataloader";

const typeDefs = /* GraphQL */ `
  type User { id: ID!, name: String!, email: String!, bio: String, posts: [Post!]! }
  type Post { id: ID!, title: String!, body: String!, author: User!, comments(first: Int = 3): [Comment!]! }
  type Comment { id: ID!, body: String!, author: User!, post: Post! }
  type Query { posts(first: Int = 10): [Post!]!, user(id: ID!): User }
`;

// DataLoader: 同じタイミングで要求されたIDをまとめて1回のSQLにする
const makeLoaders = () => ({
  user: new DataLoader(async (ids) => {
    const rows = q(`SELECT * FROM users WHERE id IN (${ids.map(() => "?").join(",")})`, ...ids);
    const m = new Map(rows.map((r) => [String(r.id), r]));
    return ids.map((id) => m.get(String(id)));   // 入力と同じ順番で返す
  }),
  comments: new DataLoader(async (postIds) => {
    const rows = q(`SELECT * FROM comments WHERE post_id IN (${postIds.map(() => "?").join(",")}) ORDER BY id`, ...postIds);
    return postIds.map((pid) => rows.filter((r) => r.post_id === Number(pid)));
  }),
});

const resolvers = {
  Query: { posts: (_, { first }) => q("SELECT * FROM posts ORDER BY id DESC LIMIT ?", first) },
  Post: {
    author: (p, _, ctx) => USE_LOADER
      ? ctx.loaders.user.load(p.user_id)
      : q1("SELECT * FROM users WHERE id=?", p.user_id),
    comments: async (p, { first }, ctx) => USE_LOADER
      ? (await ctx.loaders.comments.load(p.id)).slice(0, first)
      : q("SELECT * FROM comments WHERE post_id=? ORDER BY id LIMIT ?", p.id, first),
  },
  Comment: {
    author: (c, _, ctx) => USE_LOADER
      ? ctx.loaders.user.load(c.user_id)
      : q1("SELECT * FROM users WHERE id=?", c.user_id),
  },
};

const yoga = createYoga({
  schema: createSchema({ typeDefs, resolvers }),
  context: () => ({ loaders: makeLoaders() }),   // リクエストごとに作り直す
});
app.use(yoga.graphqlEndpoint, yoga);

クライアントが送るクエリは1つです。

{
  posts(first: 10) {
    title
    author { name }
    comments(first: 3) { body author { name } }
  }
}

実測:1画面に必要なリクエスト数・転送量・SQL回数

同じ画面を作るのに、汎用RESTは25リクエスト、GraphQLは1リクエストでした。 さらに比較用に、この画面専用にまとめたREST(/api/feed、いわゆるBFF的なエンドポイント)も用意しました。転送バイト数はレスポンス本文の合計です。

方式 HTTPリクエスト数 直列の往復段数 応答本文の合計 SQL実行回数
汎用REST(/posts→/users/:id・/comments) 25 3 17,944 B 25
画面専用REST(/api/feed) 1 1 2,682 B 3
GraphQL(DataLoaderなし) 1 1 2,620 B 51
GraphQL(DataLoaderあり) 1 1 2,620 B 4

(表示される内容は4方式とも同一であることを出力で確認しています)

この表から、次の3つがわかります。

  • 汎用RESTは「取りすぎ(オーバーフェッチ)」と「足りない分の追加取得(アンダーフェッチ)」が同時に起きます。 投稿の本文(body)やユーザーの自己紹介など、画面に使わない項目まで返るため、転送量は約6.8倍になりました。さらに「一覧→著者→コメント者」と3段階の往復が必要です。
  • GraphQLは通信は1回で済みますが、素直にリゾルバを書くとSQLが51回(1+投稿10件の著者10+コメント10+コメント者30)に増えます。 これがGraphQLのN+1問題です。通信の往復がサーバー内部のSQLの往復に移った、と考えるとわかりやすいです。
  • DataLoaderで同じタイミングのID要求をまとめると、SQLは4回に減りました。 GraphQLを採用するなら、DataLoader(またはJOINで一括取得する仕組み)は最初から入れておくのがおすすめです。

また、画面専用REST(BFF的な設計)でも、GraphQLとほぼ同じ転送量・1往復を実現できます。 「画面の数が少なく、変更も少ない」ならRESTのままで十分な場合も多い、という判断材料になります。

sequenceDiagram
    participant C as クライアント
    participant R as REST API
    participant G as GraphQL API
    Note over C,R: 汎用REST: 3段・25リクエスト
    C->>R: GET /api/posts?limit=10
    C->>R: GET /api/users/:id と /api/posts/:id/comments(並列)
    C->>R: GET /api/users/:id(コメント者、並列)
    Note over C,G: GraphQL: 1段・1リクエスト
    C->>G: POST /graphql { posts { title author {name} comments {...} } }
    G-->>C: 必要な項目だけのJSON

キャッシュの違い:HTTPキャッシュとクライアントキャッシュ

RESTはURL単位でHTTPキャッシュ(ブラウザ・CDN)が効きやすく、GraphQLは正規化キャッシュ(クライアントライブラリ側のキャッシュ)が中心です。 実際に確かめました。

# REST: ExpressはGETの応答に弱いETagを自動で付ける
$ curl -s -D - -o /dev/null http://127.0.0.1:7065/api/users/1 | grep -i etag
ETag: W/"151-eIRfJvHNOitClGK7Bp+ZDNiLXAY"

# 同じETagを付けて再取得すると 304 Not Modified(本文0バイト)
$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \
    -H 'If-None-Match: W/"151-eIRfJvHNOitClGK7Bp+ZDNiLXAY"' http://127.0.0.1:7065/api/users/1
304 0

同じサーバーのGraphQL(POST)には、ETagもCache-Controlも付きませんでした。

キャッシュの層 REST GraphQL
ブラウザ・CDN(HTTPキャッシュ) GET+Cache-Control/ETagでそのまま使える POSTは対象外。GETでのクエリ送信や、ハッシュで送る「永続化クエリ」を組み合わせて対応
クライアントライブラリ 取得したJSONを自前で保持 Apollo Client、urql、Relayなどが型名+IDで正規化して保持し、画面間で共有
サーバー側 エンドポイント単位で保存しやすい フィールド単位・DataLoader単位で保存

公開データを大量に配信し、CDNで捌きたいならRESTが素直です。 GraphQLでCDNを効かせたい場合は、永続化クエリ(クエリ本文の代わりにハッシュをGETで送る方式)を検討するとよいでしょう。

エラーの違い:GraphQLは200で errors を返す

RESTはHTTPステータスコードで成否を表しますが、GraphQLはHTTP 200のまま、本文の errors 配列で失敗を伝えることが多いです。 実際の応答を見てみます。

一部のフィールドだけ失敗した場合(部分的な成功)

User.secretNote というフィールドのリゾルバで、権限エラーを投げるようにしました。

import { GraphQLError } from "graphql";
// ...
User: {
  secretNote: () => {
    throw new GraphQLError("この項目を見る権限がありません",
      { extensions: { code: "FORBIDDEN" } });
  },
},
クエリ: { posts(first: 2) { title author { name secretNote } } }
HTTPステータス: 200
{"errors":[{"message":"この項目を見る権限がありません","locations":[{"line":1,"column":41}],
  "path":["posts",0,"author","secretNote"],"extensions":{"code":"FORBIDDEN"}}, ...],
 "data":{"posts":[{"title":"投稿タイトル100","author":{"name":"user20","secretNote":null}},
                  {"title":"投稿タイトル99","author":{"name":"user19","secretNote":null}}]}}

data には取れた分が入り、失敗したフィールドだけが null になり、errors の path でどこが失敗したかがわかります。 RESTでは表現しにくい「部分的な成功」を返せるのがGraphQLの特徴です。

なお、GraphQLError ではなく通常の Error を投げた場合、graphql-yogaは既定で内部情報を隠し、"message":"Unexpected error."、"code":"INTERNAL_SERVER_ERROR" と返しました。利用者に見せたいエラーは GraphQLError で投げ、それ以外は隠すという使い分けができます。

スキーマにないフィールドを要求した場合

クエリ: { posts(first: 1) { title nickname } }
Accept 指定なし(application/json)          → HTTP 200
Accept: application/graphql-response+json    → HTTP 400
{"errors":[{"message":"Cannot query field \"nickname\" on type \"Post\".", ...
  "extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}

同じ検証エラーでも、クライアントが Accept: application/graphql-response+json を送ると400、従来の application/json だと200が返りました。 これは策定中の「GraphQL over HTTP」仕様に沿った挙動です。監視やリトライ判定を「HTTP 200なら成功」で組むと、GraphQLのエラーを見落とします。 本文の errors の有無も確認するようにしておきましょう。

状況 REST(一般的な設計) GraphQL(今回の実測)
存在しないID 404 200、"user": null(nullable の場合)
権限がない 403 200、errors に FORBIDDEN、該当フィールドは null
リクエストの形が不正 400 200(application/json)または 400(graphql-response+json)
サーバー内部の失敗 500 200、errors に INTERNAL_SERVER_ERROR(メッセージは隠される)

バージョニングの違い

RESTはURLやヘッダーでバージョンを分けるのが一般的で、GraphQLはフィールドを追加しながら1本のスキーマを育てるのが一般的です。

type User {
  name: String!
  fullName: String! @deprecated(reason: "name を使ってください")
}
変更内容 REST GraphQL
項目の追加 互換性あり。そのまま追加 互換性あり。要求したクライアントだけが受け取る
項目の廃止 新バージョン(/v2)を作ることが多い @deprecated で告知し、利用状況を見て削除
利用状況の把握 エンドポイント単位のアクセスログ フィールド単位で「誰が使っているか」を計測できる

セキュリティ:クエリの深さ・複雑度を制限する

GraphQLはクライアントが自由にクエリを組めるため、深くネストしたクエリ1つでサーバーに大きな負荷がかかります。 投稿→コメント→投稿→… と循環する関係を何段もたどるクエリを送ってみました。

{ posts(first: 10) { comments { post { comments { post { comments { post { comments { body } } } } } } } } }
設定 HTTP SQL実行回数 応答本文
制限なし(DataLoaderなし) 200 791 38,447 B
制限なし(DataLoaderあり) 200 392 38,447 B
深さ制限4(graphql-depth-limit) 200 0 116 B

深さ制限を入れると、実行前の検証段階で拒否され、SQLは1回も実行されませんでした。

import depthLimit from "graphql-depth-limit";
import { useValidationRule } from "@envelop/core";

const yoga = createYoga({
  schema,
  plugins: [useValidationRule(depthLimit(4))],
});
{"errors":[{"message":"'' exceeds maximum operation depth of 4",
  "extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}

検証の過程で気づいた点として、最初に createYoga へ validationRules: [depthLimit(4)] と書いたところ、エラーにならないまま制限が効きませんでした(深いクエリがそのまま実行された)。 envelopの useValidationRule プラグインで渡すと効きました。制限を入れたら、深いクエリを実際に送って拒否されることを確認するのがおすすめです。

GraphQLで押さえておきたい対策をまとめます。

対策 内容
深さ制限 ネストの段数に上限を設ける(今回は4)
複雑度(コスト)制限 フィールドごとにコストを付け、合計に上限。first: 1000 のような大量取得も抑えられる
件数引数の上限 first の最大値をリゾルバで検証する
永続化クエリ・許可リスト 事前登録したクエリだけを実行する。公開しない社内APIで特に有効
イントロスペクションの扱い 本番で外部に公開しない場合は無効化も検討
フィールド単位の認可 リゾルバごとに権限チェック(上のsecretNoteの例)
レート制限 リクエスト数ではなくクエリのコストで数えると実態に合う

GraphQLとRESTの選定チェックリスト

次の項目で「GraphQL寄り」が多ければGraphQL、「REST寄り」が多ければRESTを選ぶと、判断がぶれにくくなります。

質問 はい → いいえ →
画面やクライアント(Web・スマホ等)ごとに必要な項目が大きく違うか GraphQL寄り REST寄り
1画面で関連データを3種類以上たどるか GraphQL寄り REST寄り
不特定多数に公開し、CDNで大量に配信したいか REST寄り GraphQL寄り
ファイルのアップロード・ダウンロードが中心か REST寄り どちらでも
チームにDataLoader・深さ制限・コスト計測を運用する体制があるか GraphQL寄り REST寄り
型定義から仕様書とクライアントコードを自動生成したいか どちらでも(RESTはOpenAPI、GraphQLはスキーマ) -
画面の変更が頻繁で、その都度API変更を待ちたくないか GraphQL寄り REST寄り

BFF・tRPC・gRPCの位置づけ

RESTとGraphQLの二択ではなく、「誰と誰が通信するか」で選ぶと整理しやすくなります。

方式 ひとことで 向いている通信 今回の実測との関係
REST リソースをURLで表す 公開API、外部連携 汎用設計だと25リクエスト
BFF(Backend for Frontend) 画面ごとに専用APIを用意する設計 フロントと、その専用サーバー /api/feed で1リクエスト・2,682 B
GraphQL クライアントが項目を選ぶクエリ言語 多様な画面・クライアントからの取得 1リクエスト・2,620 B、DataLoader必須
tRPC TypeScriptの型をそのまま共有するRPC フロントとサーバーが同じTypeScriptリポジトリ スキーマ定義なしで型安全に呼べる
gRPC Protocol BuffersとHTTP/2によるRPC サービス間(マイクロサービス)通信 バイナリで軽量。ブラウザからは gRPC-Web 等が必要
flowchart LR
    B[ブラウザ / スマホ] -->|GraphQL または BFFのREST| F[BFF / GraphQLサーバー]
    X[外部の利用者] -->|REST 公開API| P[公開APIゲートウェイ]
    F -->|gRPC| S1[ユーザーサービス]
    F -->|gRPC| S2[投稿サービス]
    P -->|gRPC| S1

外部向けはREST、画面向けはGraphQLかBFF、サービス間はgRPC、TypeScriptで閉じた小〜中規模アプリはtRPCという組み合わせは、よく採られる構成です。

よくある質問

Q. GraphQLはRESTより速いですか? A. 通信回数と転送量は減らしやすいですが、サーバー内部の処理は書き方次第です。 今回の実測では、GraphQLは1リクエスト・2,620 Bで済んだ一方、DataLoaderなしではSQLが51回と、汎用RESTの25回より増えました。DataLoaderを入れると4回になりました。

Q. RESTでもオーバーフェッチは解決できますか? A. できます。 画面専用のエンドポイント(BFF)や、?fields=title,author のような項目指定パラメータで対応できます。実測でも画面専用RESTは1リクエスト・2,682 BとGraphQLとほぼ同じでした。画面の種類が増えるほど、専用エンドポイントの保守が増える点が比較のポイントです。

Q. GraphQLのエラーはなぜ200で返るのですか? A. 1つのリクエストで複数のデータを取り、一部だけ失敗することがあるため、成功した分と失敗の情報を同時に返す設計になっています。策定中のGraphQL over HTTP仕様では、application/graphql-response+json を使うとリクエスト自体の不正は4xxで返るよう整理されており、今回も400が返ることを確認しました。

Q. 既存のREST APIをGraphQLに置き換えるべきですか? A. 置き換えより「前段に足す」方法が現実的です。 既存RESTをそのまま残し、GraphQLサーバー(またはBFF)が裏でRESTを呼んでまとめる構成にすれば、段階的に移行できます。

まとめ

  • RESTは「URLごとに決まった形」、GraphQLは「クライアントが項目を選ぶ」のが根本的な違い
  • 同じ画面で、汎用RESTは25リクエスト・17,944 B、GraphQLは1リクエスト・2,620 Bだった
  • GraphQLはDataLoaderなしだとSQLが51回(N+1)、ありで4回。最初から入れておく
  • 画面専用REST(BFF)でもGraphQLに近い効率が出せる。画面数と変更頻度で選ぶ
  • RESTはHTTPキャッシュ(ETag・CDN)、GraphQLはクライアントの正規化キャッシュが中心
  • GraphQLのエラーは200+errorsで返ることが多い。監視は本文の errors も確認する
  • 深さ・複雑度の制限は実際に深いクエリを送って効いているか確認する(深さ4でSQL 0回)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「GraphQLとRESTの違い|同じ画面を両方で作ってリクエスト数を実測」

株式会社RJCでは、API設計を実装と計測で確かめながら、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?