
2026年 REST API vs GraphQL vs tRPC:どれを選ぶべきか?
📷 Stanislav Kondratiev / Pexels2026年 REST API vs GraphQL vs tRPC:どれを選ぶべきか?
モダンWeb開発のためのREST、GraphQL、tRPCの詳細比較です。各アプローチの長所、短所、パフォーマンス特性、最適なユースケースを解説します。
フロントエンドがバックエンドとどのように通信するかの選択は、Webプロジェクトにおいて最も重大なアーキテクチャの決定の一つです。2026年、開発者はREST、GraphQL、tRPCという3つの成熟し実戦で検証されたオプションを持っています。それぞれに明確な強み、トレードオフ、理想的なユースケースがあります。この記事では、次のプロジェクトに適した選択をするための徹底的な比較を提供します。
REST API:確立されたスタンダード
REST(Representational State Transfer)は20年以上にわたって支配的なAPIパラダイムです。HTTPメソッドをリソースに対するCRUD操作にマッピングし、直感的でほぼすべての開発者に理解されています。
RESTの仕組み
REST APIはリソースをURLとして公開します。クライアントは標準的なHTTPメソッドを使用してやり取りします:
// GET a list of users
const response = await fetch('https://api.example.com/users');
const users = await response.json();
// GET a single user
const user = await fetch('https://api.example.com/users/42');
// POST to create a new user
const newUser = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Alice Johnson',
email: 'alice@example.com',
}),
});
// PUT to update a user
await fetch('https://api.example.com/users/42', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice Smith' }),
});
// DELETE a user
await fetch('https://api.example.com/users/42', {
method: 'DELETE',
});
サーバーサイドRESTの例(Express)
import express from 'express';
const app = express();
app.use(express.json());
// GET /users
app.get('/users', async (req, res) => {
const { page = 1, limit = 20, sort = 'name' } = req.query;
const users = await db.users.findMany({
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
orderBy: { [sort as string]: 'asc' },
});
res.json({ data: users, page: Number(page), limit: Number(limit) });
});
// GET /users/:id
app.get('/users/:id', async (req, res) => {
const user = await db.users.findUnique({
where: { id: Number(req.params.id) },
include: { posts: true, profile: true },
});
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST /users
app.post('/users', async (req, res) => {
const user = await db.users.create({ data: req.body });
res.status(201).json(user);
});
RESTの強み
- 普遍的に理解されている:すべての開発者、すべての言語、すべてのプラットフォームがRESTをサポートしています。
- HTTPキャッシング:GETリクエストはブラウザ、CDN、プロキシによってそのままキャッシュできます。
- ステートレスでシンプル:各リクエストにサーバーが必要とするすべての情報が含まれています。
- 成熟したツール:Swagger/OpenAPI、Postman、そして数多くのライブラリ。
- ステータスコードが意味を持つ:200、201、404、422、500はすべて具体的な情報を伝えます。
RESTの弱み
- オーバーフェッチング:エンドポイントがクライアントが数フィールドしか必要としなくても全フィールドを返します。
- アンダーフェッチング:関連データの取得に複数のラウンドトリップが必要なことが多いです。
- ビルトインの型安全性がない:OpenAPIコード生成などの追加ツールなしでは、クライアントとサーバー間のコントラクトがありません。
- バージョニングの課題:破壊的変更には
/v2/エンドポイントやヘッダーベースのバージョニングが必要なことが多いです。
GraphQL:柔軟なクエリ
Facebookが作成し2015年にオープンソース化したGraphQLは、クライアントが単一のリクエストで必要なデータを正確に指定できます。型付きスキーマを使用してAPIサーフェスを定義します。
GraphQLの仕組み
// A GraphQL query
const query = `
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts(first: 5) {
title
createdAt
}
followers {
totalCount
}
}
}
`;
const response = await fetch('https://api.example.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
variables: { id: '42' },
}),
});
const { data } = await response.json();
// data.user.name, data.user.posts, data.user.followers.totalCount
サーバーサイドGraphQLスキーマ
// schema.ts (using a schema-first approach)
const typeDefs = `
type User {
id: ID!
name: String!
email: String!
posts(first: Int, after: String): PostConnection!
followers: FollowerCount!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
createdAt: DateTime!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type Query {
user(id: ID!): User
users(first: Int, after: String): UserConnection!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
}
`;
リゾルバ
const resolvers = {
Query: {
user: async (_: unknown, { id }: { id: string }, ctx: Context) => {
return ctx.db.users.findUnique({ where: { id } });
},
users: async (_: unknown, args: PaginationArgs, ctx: Context) => {
return paginateUsers(ctx.db, args);
},
},
User: {
posts: async (parent: User, args: PaginationArgs, ctx: Context) => {
return ctx.loaders.userPosts.load({ userId: parent.id, ...args });
},
followers: async (parent: User, _: unknown, ctx: Context) => {
const count = await ctx.db.followers.count({
where: { followeeId: parent.id },
});
return { totalCount: count };
},
},
Mutation: {
createUser: async (_: unknown, { input }: { input: CreateUserInput }, ctx: Context) => {
return ctx.db.users.create({ data: input });
},
},
};
GraphQLの強み
- オーバーフェッチングもアンダーフェッチングもない:クライアントが必要なフィールドを正確にリクエストします。
- 単一エンドポイント:すべての操作が1つのURLを通じて行われます。
- 強力な型付きスキーマ:スキーマが生きたドキュメントとして機能し、強力なツーリングを可能にします。
- イントロスペクション:クライアントがAPIサーフェス全体をプログラマティックに検出できます。
- サブスクリプション:WebSocketを通じたリアルタイムデータのビルトインサポート。
- 多様なクライアントに最適:モバイルアプリは最小限のフィールドをリクエストし、デスクトップアプリはすべてを取得できます。
GraphQLの弱み
- 複雑さ:リゾルバ、データローダー、永続化クエリ、スキーマ管理が認知的オーバーヘッドを追加します。
- キャッシングが難しい:すべてのリクエストが単一エンドポイントへのPOSTであるため、従来のHTTPキャッシングが機能しません。クライアントサイドキャッシング(Apollo、urql)や永続化クエリが必要です。
- N+1クエリ問題:データローダーなしでは、ネストされたリゾルバが過度なデータベースクエリを引き起こす可能性があります。
- ファイルアップロード:ネイティブにはサポートされておらず、マルチパートリクエストスペックや別途のアップロードエンドポイントが必要です。
- セキュリティサーフェス:深くネストされた高コストなクエリは、クエリ深度制限と複雑度分析を実装しないとサービス拒否を引き起こす可能性があります。
tRPC:エンドツーエンドの型安全性
tRPCはTypeScript専用のフレームワークで、完全な型安全性とゼロコード生成でクライアントからサーバー関数を直接呼び出せます。フロントエンドとバックエンドの両方がTypeScriptで書かれたモノレポプロジェクト向けに設計されています。
tRPCの仕組み
tRPCはAPIレイヤーを別の概念として排除します。サーバーでプロシージャを定義し、クライアントがローカル関数のように呼び出します。
サーバーサイドのルーター定義:
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.users.findUnique({
where: { id: input.id },
include: { posts: true, profile: true },
});
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user;
}),
list: publicProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
const { page, limit, search } = input;
const where = search
? { name: { contains: search, mode: 'insensitive' as const } }
: {};
const [users, total] = await Promise.all([
ctx.db.users.findMany({
where,
skip: (page - 1) * limit,
take: limit,
}),
ctx.db.users.count({ where }),
]);
return { users, total, page, limit };
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.users.create({ data: input });
}),
update: protectedProcedure
.input(z.object({
id: z.string(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
return ctx.db.users.update({ where: { id }, data });
}),
});
クライアントサイドの使用(React):
// Client: full type safety, no code generation needed
import { trpc } from '../utils/trpc';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = trpc.user.getById.useQuery({ id: userId });
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.getById.invalidate({ id: userId });
},
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error.message} />;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={() => updateUser.mutate({ id: userId, name: 'New Name' })}>
Update Name
</button>
</div>
);
}
すべての入力、出力、エラーが完全に型付けされています。サーバーでフィールド名を変更すると、クライアントに即座にTypeScriptエラーが表示されます。
tRPCの強み
- エンドツーエンドの型安全性:サーバーの変更がゼロコード生成でクライアントの型に即座に反映されます。
- 最小限のボイラープレート:スキーマファイル、APIルート定義、クライアント生成ステップがありません。
- 優れた開発者体験:IDEでのオートコンプリート、インラインドキュメント、リファクタリングサポート。
- ビルトインの入力バリデーション:Zodスキーマがランタイムで入力を検証し、コンパイル時に型を提供します。
- React Queryとの統合:キャッシング、楽観的アップデート、リアルタイムリフェッチのファーストクラスサポート。
- サブスクリプション:リアルタイム機能のためのWebSocketサポート。
tRPCの弱み
- TypeScriptのみ:クライアントとサーバーの両方がTypeScriptである必要があります。Python、Go、その他のバックエンド言語では動作しません。
- モノレポ中心:クライアントとサーバーのコードが同じ場所にあるときに最も機能します。チームが別々のリポジトリで作業する場合は実用性が低くなります。
- パブリックAPIには不向き:言語に依存しないスキーマがないため、サードパーティが利用するAPIには適していません。
- クライアントとサーバーの密結合:密結合がチームの成長に伴い保守の問題になる可能性があります。
- 小さなエコシステム:RESTやGraphQLと比較してチュートリアル、ツール、コミュニティリソースが少ないです。
比較表
| 機能 | REST | GraphQL | tRPC |
|---|---|---|---|
| 型安全性 | 手動(OpenAPIコード生成) | スキーマベース | 自動(TypeScript) |
| オーバーフェッチング | よくある | 解消 | 最小限(型付き戻り値) |
| アンダーフェッチング | よくある(N+1リクエスト) | 解消 | 最小限 |
| キャッシング | 優秀(HTTPネイティブ) | 複雑(クライアントサイド) | React Queryビルトイン |
| 学習コスト | 低い | 中〜高 | 低い(TSを知っていれば) |
| ツールの成熟度 | 優秀 | 非常に良い | 良い |
| 言語サポート | すべて | すべて | TypeScriptのみ |
| リアルタイム | WebSocket/SSE(別途) | サブスクリプション | サブスクリプション |
| ファイルアップロード | ネイティブ | 回避策が必要 | 回避策が必要 |
| パブリックAPI | 理想的 | 良い | 不向き |
| コード生成 | オプション(OpenAPI) | 多くの場合必要 | 不要 |
| パフォーマンス | 良い | 良い(バッチング込み) | 優秀(最小オーバーヘッド) |
| APIドキュメント | Swagger/OpenAPI | スキーマイントロスペクション | TypeScript型 |
各アプローチの選択基準
RESTを選ぶべきとき
- さまざまな言語のサードパーティが利用するパブリックAPIを構築する場合。
- CDNやブラウザレベルでのHTTPキャッシングが必要な場合(例:コンテンツが豊富なサイト)。
- チームに従来のHTTP APIに慣れた開発者がいる場合。
- 異なる言語ランタイム間で通信するマイクロサービスを構築する場合。
- 単純なCRUDアプリケーションのための最もシンプルなアーキテクチャが必要な場合。
GraphQLを選ぶべきとき
- 同じAPIから異なるデータ形状を必要とする複数のクライアント(Web、モバイル、TV、ウォッチ)がある場合。
- データが深くリレーショナルで、クライアントが単一リクエストでネストされたデータを頻繁に必要とする場合。
- クエリレイヤーと密接に統合されたリアルタイム機能(サブスクリプション)が必要な場合。
- 開発者ツールのためのイントロスペクションを持つ自己文書化APIが必要な場合。
- 複数のバックエンドサービスからデータを集約するゲートウェイを構築する場合。
tRPCを選ぶべきとき
- スタック全体がTypeScriptの場合(フロントエンドとバックエンドの両方)。
- クライアントとサーバーのコードが一緒にあるモノレポで作業する場合。
- 開発者の速度を重視し、最速のイテレーション速度が必要な場合。
- APIが公開されない内部ツールやSaaS製品を構築する場合。
- 型安全性が最優先で、コンパイル時にAPIの不一致を検出したい場合。
実際のアーキテクチャパターン
ハイブリッドアプローチ:tRPC + REST
多くのチームが内部のフロントエンド-バックエンド通信にはtRPCを使用し、サードパーティ統合用に別のREST APIを公開しています:
// Internal tRPC router for the dashboard
export const appRouter = router({
user: userRouter,
billing: billingRouter,
analytics: analyticsRouter,
});
// Public REST API for integrations
app.get('/api/v1/users/:id', async (req, res) => {
const user = await db.users.findUnique({
where: { id: req.params.id },
select: publicUserFields,
});
res.json(user);
});
ゲートウェイとしてのGraphQL
GraphQLは複数のマイクロサービスからデータをフェデレーションするAPIゲートウェイとしてうまく機能します:
// Gateway schema that combines multiple services
const gatewaySchema = stitchSchemas({
subschemas: [
{ schema: userServiceSchema, executor: userServiceExecutor },
{ schema: orderServiceSchema, executor: orderServiceExecutor },
{ schema: inventoryServiceSchema, executor: inventoryServiceExecutor },
],
});
型安全性のためのREST + OpenAPI
RESTを選択しつつ型安全性を求める場合、OpenAPIコード生成がギャップを埋めます:
# openapi.yaml
paths:
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
# Generate typed client
npx openapi-typescript-codegen --input openapi.yaml --output src/api
// Generated client with full type safety
import { UserService } from './api';
const user = await UserService.getUser({ id: '42' });
// user is fully typed
パフォーマンスの考慮事項
RESTのパフォーマンス
RESTはHTTP/2マルチプレキシングとブラウザキャッシングの恩恵を受けます。キャッシュ可能なコンテンツを提供する読み取り重視のAPIでは、RESTは非常に高速になり得ます。欠点は、複数のエンドポイントからデータが必要な場合に複数のラウンドトリップが必要なことです。
GraphQLのパフォーマンス
GraphQLは単一リクエストで複雑なクエリを許可することでラウンドトリップを削減します。ただし、サーバーは潜在的に複雑なクエリツリーを解決する必要があります。適切な最適化(データローダー、クエリ複雑度制限、永続化クエリ)なしでは、GraphQLは実際にRESTより遅くなる可能性があります。
tRPCのパフォーマンス
tRPCはHTTP上のシンプルなJSONで通信するため、シリアライゼーションオーバーヘッドが最小限です。リクエストバッチングが有効な場合、複数のプロシージャコールが単一のHTTPリクエストに結合され、ラウンドトリップが削減されます。React Queryの統合により、ネットワークリクエストをさらに削減するクライアントサイドキャッシングが提供されます。
まとめ
唯一の最善のAPIアプローチは存在しません。正しい選択はチーム、テクノロジースタック、プロジェクトの要件によって異なります。
RESTはパブリックAPI、マイクロサービス通信、複数の言語を使用するチームのデフォルトの選択肢として残っています。そのシンプルさと普遍的なサポートがほとんどのシナリオで安全な選択となります。
GraphQLは複雑なリレーショナルデータと、そのデータの異なるビューを必要とする複数のクライアントアプリケーションがある場合に優れています。複雑さを追加しますが、適切なユースケースではその柔軟性に価値があります。
tRPCは開発者の速度と型安全性が優先されるTypeScriptモノレポプロジェクトに最適なオプションです。APIコントラクトの不一致というバグのカテゴリ全体を排除し、3つの中で最速の開発体験を提供します。
多くの成功しているチームがこれらのアプローチの組み合わせを使用し、各アプローチが最も適した場所で活用しています。重要なのは、トレードオフを理解し、前のプロジェクトで使ったものをデフォルトにするのではなく、意図的な決定を行うことです。