AppSyncを1年本番運用して後悔したスキーマ設計の話【2026年版】
「GraphQLならLambda管理が減っていいじゃん」と軽い気持ちで始めたAppSync運用。1年経って見えてきた設計の後悔とリゾルバー最適化の現実解をまとめました。
AppSync、正直なめてた
うちのチームがAppSyncをフル採用したのは2025年初頭のことで、当時は「GraphQLをサーバーレスで動かせるならLambdaの管理が減っていいじゃん」という軽い気持ちで始めた。でも実際に1年以上本番で運用してみると、スキーマ設計のツメの甘さやリゾルバーの書き方一つで体験が大きく変わることを痛感した。
以前書いた「AppSync GraphQL、チーム本番運用で1年しんどかった話と今の現実解」でも愚痴を書いたけど、あの記事から半年経ってさらに知見が積み上がった。2026年時点での「こう設計すればよかった」を、後悔も含めて整理しておこうと思う。
全体アーキテクチャと構成図
まずうちが今使っている構成を見てほしい。フロントエンドはNext.js、バックエンドはAppSync + DynamoDB + Lambda(一部)、認証はCognito、というシンプルなサーバーレス構成だ。
graph TB
subgraph Client["クライアント層"]
FE["Next.js 15\nフロントエンド"]
Mobile["モバイルアプリ"]
end
subgraph Auth["認証層"]
Cognito["Amazon Cognito\nUser Pool"]
end
subgraph AppSyncLayer["AppSync API層"]
AS["AWS AppSync\nGraphQL API"]
subgraph Resolvers["リゾルバー"]
JSR["JavaScript Resolver\n(Pipeline)"]
VTLR["VTL Resolver\n(レガシー)"]
end
end
subgraph DataSources["データソース層"]
subgraph VPC_Group["VPC (ap-northeast-1)"]
subgraph AZ_A["AZ: ap-northeast-1a"]
Lambda1["Lambda\n(複雑ロジック)"]
Aurora["Aurora PostgreSQL\nCluster"]
end
subgraph AZ_B["AZ: ap-northeast-1c"]
Lambda2["Lambda\n(外部API連携)"]
AuroraR["Aurora Replica"]
end
end
DDB["DynamoDB\n(メインストア)"]
S3["S3\n(ファイル管理)"]
EB["EventBridge\n(イベント配信)"]
end
subgraph Observability["可観測性"]
CW["CloudWatch\nLogs / Metrics"]
XRAY["X-Ray\nトレーシング"]
end
FE -->|GraphQL over HTTPS| AS
Mobile -->|GraphQL over HTTPS| AS
AS -->|JWT検証| Cognito
AS --> JSR
AS --> VTLR
JSR --> DDB
JSR --> Lambda1
JSR --> Lambda2
VTLR --> DDB
Lambda1 --> Aurora
Lambda2 --> AuroraR
AS --> S3
AS --> EB
AS --> CW
AS --> XRAY
最初の設計からの大きな変更点は2つ。VTLリゾルバーをほぼJavaScriptリゾルバーに置き換えたことと、DynamoDBへの直接アクセスはJavaScriptリゾルバーで賄い、複雑なビジネスロジックはLambdaに任せるという境界線を明確にしたことだ。
VTLは正直しんどかった。デバッグが難しいし、チームメンバーが慣れるまでに3ヶ月かかった。2025年後半にJavaScriptリゾルバーが安定してきてからは、移行して本当によかったと思っている。
スキーマ設計:1年目の失敗から学んだこと
型設計は最初が肝心
GraphQLの型設計で最初にやらかしたのは、「とりあえず全部Stringで受け取ってバリデーションはアプリ側で」という判断だった。AWSのドキュメントを読むとAWSDateTime、AWSJSON、AWSEmailといったスカラー型が用意されているのだが、最初はそれを使わずに自前Stringで実装してしまった。
# ❌ 最初の設計(後悔した)
type User {
id: ID!
email: String!
createdAt: String! # これが地獄の始まり
metadata: String! # JSONをStringに詰め込むのは本当にやめたほうがいい
}
# ✅ 今の設計
type User {
id: ID!
email: AWSEmail!
createdAt: AWSDateTime!
metadata: AWSJSON
}
AWSDateTimeはISO 8601形式の検証をAppSync側でやってくれるし、AWSJSONはJSONのシリアライズ・デシリアライズを自動でハンドリングしてくれる。フロントのTypeScript型生成(GraphQL Code Generator)とも相性がいい。地味だけど、これを最初からやっていればフロントとバックで日付フォーマットのすり合わせをする時間が丸々消えていたはずだ。
ページネーション設計
もう一つの失敗はページネーション。最初にオフセットベース(offset/limit)で設計してしまった。DynamoDBとの相性が最悪で、後からカーソルベースに移行するのに2週間かかった。
# ✅ カーソルベースのページネーション(Relay仕様に合わせた)
type UserConnection {
items: [User!]!
nextToken: String
totalCount: Int
}
type Query {
listUsers(
filter: UserFilterInput
limit: Int
nextToken: String
): UserConnection!
}
input UserFilterInput {
status: UserStatus
createdAfter: AWSDateTime
createdBefore: AWSDateTime
}
DynamoDBのLastEvaluatedKeyをそのままBase64エンコードしてnextTokenとして返す設計にしたら、かなりすっきりした。個人的にはDynamoDBをメインストアにするならカーソルベース一択だと思っている。オフセットでやりたい気持ちはわかるけど、DynamoDBにオフセットは合わない。
ネストの深さ問題
GraphQLの柔軟性は諸刃の剣で、クライアントが深いネストのクエリを投げてくると一気にDynamoDBの読み取りコストが跳ね上がる。うちではDepth Limitを設けており、CDKでその設定も管理している。
// AppSync設定(CDKで管理)
const api = new appsync.GraphqlApi(this, 'Api', {
name: 'MyAppSyncApi',
definition: appsync.Definition.fromFile('schema.graphql'),
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.USER_POOL,
userPoolConfig: {
userPool: userPool,
},
},
additionalAuthorizationModes: [
{
authorizationType: appsync.AuthorizationType.API_KEY,
apiKeyConfig: {
expires: cdk.Expiration.after(cdk.Duration.days(365)),
},
},
],
},
xrayEnabled: true,
logConfig: {
fieldLogLevel: appsync.FieldLogLevel.ERROR,
excludeVerboseContent: true,
},
});
X-Rayは絶対有効化すべき。リゾルバーのどのステップで時間がかかっているかがトレースで見えるようになってから、ボトルネック特定が劇的に楽になった。これを後から有効化しようとすると再デプロイが必要になるので、最初から入れておいて損はない。
JavaScriptリゾルバーへの移行:VTLからの脱却
2025年後半から本格的にJavaScriptリゾルバー(AppSync Functions)への移行を進めた。結論から言うと、開発体験が別次元になった。
VTLとJavaScriptリゾルバーの比較
| 観点 | VTLリゾルバー | JavaScriptリゾルバー |
|---|---|---|
| 学習コスト | 高(VTL独自構文) | 低(ES2023ライク) |
| デバッグ | 困難(ログが見づらい) | 比較的容易 |
| 型安全性 | なし | TypeScript定義あり |
| パフォーマンス | 高速 | VTLと同等(改善済み) |
| テスト | 難しい | Jest等でユニットテスト可 |
| Pipeline対応 | ○ | ○ |
| 複雑なロジック | 書きにくい | 書きやすい |
パフォーマンスはほぼ同等になった。2025年秋ごろのアップデートでJavaScriptリゾルバーのレイテンシが改善されて、VTLと遜色ない数値が出るようになっている。正直まだVTLのほうがほんの少し速いケースもあるけど、開発効率を考えたらJavaScriptリゾルバー一択だと思う。新規開発でVTLを選ぶ理由は、2026年現在ほぼないんじゃないかな。
Pipelineリゾルバーの実装例
うちのチームで実際に使っているパターンを見せる。ユーザー取得 → 権限チェック → データ加工という3ステップのパイプラインだ。
// functions/getUserById.ts(AppSync Function)
import { Context, DynamoDBGetItemRequest } from '@aws-appsync/utils';
export function request(ctx: Context): DynamoDBGetItemRequest {
return {
operation: 'GetItem',
key: {
pk: { S: `USER#${ctx.args.id}` },
sk: { S: 'PROFILE' },
},
};
}
export function response(ctx: Context) {
if (ctx.error) {
util.error(ctx.error.message, ctx.error.type);
}
if (!ctx.result) {
util.error('User not found', 'NotFoundError');
}
return ctx.result;
}
// functions/checkPermission.ts(権限チェック Function)
import { Context } from '@aws-appsync/utils';
export function request(ctx: Context) {
const { sub } = ctx.identity as { sub: string };
const user = ctx.prev.result;
// 自分のデータか管理者かチェック
if (user.pk !== `USER#${sub}` && !ctx.identity.groups?.includes('admin')) {
util.unauthorized();
}
// 次のFunctionにそのまま渡す
return { payload: ctx.prev.result };
}
export function response(ctx: Context) {
return ctx.prev.result;
}
このパイプライン設計はかなり気に入っている。各Functionが単一責任になるし、テストもしやすい。ctx.prev.resultで前のFunctionの結果を受け取れるので、データの受け渡しも直感的だ。
ローカルテストの設定
@aws-appsync/utilsパッケージを使えばJavaScriptリゾルバーをローカルでテストできる。これが地味に便利で、CI/CDに組み込んでいる。
// __tests__/getUserById.test.ts
import { request, response } from '../functions/getUserById';
describe('getUserById', () => {
test('正常なリクエストを生成できる', () => {
const ctx = {
args: { id: 'user-123' },
} as any;
const req = request(ctx);
expect(req.operation).toBe('GetItem');
expect(req.key.pk.S).toBe('USER#user-123');
});
test('ユーザーが存在しない場合にエラーを返す', () => {
const ctx = {
result: null,
error: null,
} as any;
expect(() => response(ctx)).toThrow();
});
});
VTL時代はリゾルバーのテストが書けなくて、デプロイして実機確認という原始的な方法しかなかった。ユニットテストが書けるようになってから開発速度が体感で1.5倍くらい上がった感じがする。これは大げさじゃなくて、「デプロイ→動作確認→修正→再デプロイ」のサイクルがなくなるだけで全然違う。
認証とセキュリティ:Cognitoとカスタム認証の使い分け
AppSyncの認証は複数のモードを同時に使えるのだが、どれをどう使い分けるかで最初にかなり悩んだ。
認証モードの整理
flowchart LR
subgraph Clients["クライアント"]
Web["Webアプリ"]
Mobile["モバイル"]
Internal["内部サービス"]
Public["パブリックAPI"]
end
subgraph AuthModes["認証モード"]
Cognito["Cognito User Pool\n(一般ユーザー)"]
IAM["IAM認証\n(サービス間)"]
Lambda_Auth["Lambda Authorizer\n(カスタムロジック)"]
APIKey["API Key\n(パブリック読み取り)"]
end
Web --> Cognito
Mobile --> Cognito
Internal --> IAM
Public --> APIKey
Cognito -.-> Lambda_Auth
うちの構成はこんな感じになっている。
- Cognito User Pool:エンドユーザー向けのメインの認証。JWTのグループ情報でRBACを実装している
- IAM認証:他のAWSサービス(Lambda等)からAppSyncを叩くとき。SigV4署名なので安全
- Lambda Authorizer:外部IdPとの連携が必要な一部のエンドポイントで使用
- API Key:パブリックに読み取り可能なデータのみ(ブログ投稿の閲覧など)
セキュリティについてはOWASP Top 10 2024対策も参考にしたが、GraphQL特有の脅威(N+1問題、過剰なデータ取得、インジェクション)を意識した設計が重要だ。
フィールドレベル認可
最初は「スキーマレベルで制御できればいいや」と思っていたのだけど、実務では「このフィールドは管理者だけが見れる」というケースが頻発する。
type User {
id: ID!
name: String!
email: AWSEmail! @auth(rules: [{ allow: owner }, { allow: groups, groups: ["admin"] }])
# sensitiveDataは管理者グループのみ
internalNote: String @auth(rules: [{ allow: groups, groups: ["admin"] }])
createdAt: AWSDateTime!
}
@authディレクティブを使ったフィールドレベル認可はAmplify Gen 2の機能だが、AppSync単体でも@aws_authディレクティブで実現できる。ただし、フィールドごとにリゾルバーを書く必要があるので、設計初期に決めておかないとあとで痛い目を見る。これは本当に最初から決めておいてほしい。
パフォーマンス最適化:N+1問題との戦い
GraphQLの宿命であるN+1問題は、AppSyncでも当然発生する。うちが1年かけて見つけた対策を整理しておく。
BatchGetItemの活用
最初はフィールドリゾルバーを素朴に実装していたので、ユーザーのリストを取得したあと各ユーザーのプロフィールを個別にDynamoDBで取得するという最悪のパターンに陥っていた。
// ❌ N+1が発生するパターン
export function request(ctx: Context) {
// users[0].profileId, users[1].profileId ... 全部個別リクエスト
return {
operation: 'GetItem',
key: { pk: { S: `PROFILE#${ctx.source.profileId}` }, sk: { S: 'DETAIL' } },
};
}
// ✅ BatchGetItemで解決
export function request(ctx: Context) {
// Pipelineの前段でIDリストを収集しておき、バッチ取得
const keys = ctx.args.userIds.map((id: string) => ({
pk: { S: `USER#${id}` },
sk: { S: 'PROFILE' },
}));
return {
operation: 'BatchGetItem',
tables: {
UsersTable: {
keys,
consistentRead: false,
},
},
};
}
キャッシュ戦略
AppSyncのサーバーサイドキャッシュを有効にしてから、読み取り系クエリのレスポンスタイムが劇的に改善した。数字で見るとこんな感じだ。
xychart-beta
title "AppSyncキャッシュ有無のレスポンスタイム比較(ms)"
x-axis ["キャッシュなし", "TTL 60s", "TTL 300s", "TTL 3600s"]
y-axis "レスポンスタイム (ms)" 0 --> 250
bar [220, 45, 38, 35]
TTL 60秒でも220msから45msまで落ちた。コスト感としてはキャッシュ自体にも料金がかかるが、DynamoDBの読み取りコストと比べると十分ペイする。
注意点として、キャッシュはデフォルトでGraphQL操作単位ではなくリゾルバー単位でかかるので、ユーザーごとに異なるデータを返すリゾルバーには$context.identity.subをキャッシュキーに含める設定が必要だ。これを忘れて別ユーザーのデータが返ってくるというインシデントが発生した(インシデント対応の最新ベストプラクティスで学んだことが生きた場面でもあった)。
// CDKでのキャッシュ設定
const api = new appsync.GraphqlApi(this, 'Api', {
// ... 他の設定
});
// キャッシュ設定はCFnで直接設定
const cfnApiCache = new appsync.CfnApiCache(this, 'ApiCache', {
apiId: api.apiId,
type: 'SMALL', // 開発環境。本番はLARGEかXLARGEを検討
ttl: 300,
apiCachingBehavior: 'PER_RESOLVER_CACHING',
atRestEncryptionEnabled: true,
transitEncryptionEnabled: true,
});
Subscriptionの設計
リアルタイム機能の実装でSubscriptionを使っているチームも多いと思うけど、これが意外とコストに直結する。接続を長時間維持するクライアントが多いと、AppSyncの接続料金($0.08/百万接続分)がじわじわ増えていく。
うちでは以下のようにして不要な接続を切る設計にした。
type Subscription {
# フィルタを必須にして不要な更新を受け取らせない
onOrderUpdated(orderId: ID!): Order
@aws_subscribe(mutations: ["updateOrder"])
@aws_auth(cognito_groups: ["users", "admin"])
}
フィルタをorderIdで絞ることで、全Subscriptionクライアントに更新が飛ぶのを防いでいる。AppSync側でフィルタリングしてくれるので、クライアントへの無駄な転送が減る。皆さんのチームはSubscriptionのコスト管理どうやってます?
GraphQL Federation:今後の設計方針
うちのシステムがまだ単一のAppSyncで全部賄えているのは規模が比較的小さいからで、これが複数のサービスに分かれてくると話が変わってくる。
GraphQL FederationについてはGraphQL 2026年最新実装ガイドで詳しく書いたが、AppSyncでのFederation対応は2026年現在も公式サポートは限定的な状態だ。Apollo RouterをLambdaやECSで動かしてAppSyncのエンドポイントをサブグラフとして束ねるパターンが現実的な選択肢になっている。正直まだ検証中だけど、マイクロサービス化を検討しているチームは早めに設計を考えておいたほうがいいと思う。
また、イベント駆動アーキテクチャ実装ガイドでも触れているように、AppSync + EventBridgeの組み合わせは非同期処理との相性がいい。Mutationで更新を受け取ったあと、EventBridgeを通じてバックグラウンド処理を走らせるパターンはかなり使えるので試してほしい。
まとめ
1年以上AppSyncをチームで本番運用して見えてきた知見を整理すると、こんな感じになる。
| # | ポイント | 一言メモ |
|---|---|---|
| 1 | スキーマ設計は最初が9割 | AWSDateTime/AWSEmailを使い、ページネーションはカーソルベースで |
| 2 | VTL → JavaScriptリゾルバーへの移行は正解 | 開発速度・テスタビリティ・チーム理解度が全部上がった |
| 3 | Pipelineリゾルバーで単一責任を守る | 認証チェック・取得・変換を分離すると保守性が格段に上がる |
| 4 | キャッシュは早めに設計に組み込む | 後付けだとキャッシュキーの設計ミスがセキュリティインシデントになりうる |
| 5 | N+1はBatchGetItem+キャッシュで対処 | 設計時点で意識すれば大抵のケースは防げる |
次にやること:
- 既存プロジェクトにVTLリゾルバーが残っているなら、JavaScriptリゾルバーへの移行計画を立てる
@aws-appsync/utilsでユニットテストをCI/CDに組み込む- AppSyncのサーバーサイドキャッシュを有効化してレスポンスタイムを計測する
AppSyncは設計の自由度が高い分、最初の選択が後の運用コストに大きく影響する。この記事が同じようなところで詰まっているエンジニアの助けになれば嬉しい。何か試してみた結果や別のアプローチがあれば、ぜひ教えてほしい。