AppSync GraphQL、チーム本番運用で1年しんどかった話と今の現実解
「マネージドだから楽だろう」と甘く見て痛い目を見た1年間。N+1爆死・認証設計ミス・サブスクリプションの罠など、実際に踏んだ地雷と今の安定構成を包み隠さず書きました。
AppSyncをチームに導入してから1年、正直しんどかった話
うちのチームがAppSync + GraphQLを本番導入したのは去年の春のことだった。モバイルアプリのバックエンドAPIをRESTからGraphQLに移行するプロジェクトで、「マネージドだから楽だろう」という甘い見通しで入ったのが正直なところ。結果として1年間でかなりの痛い目を見たけど、今は設計も運用も安定してきた。その過程で気づいた設計の勘どころを、失敗談込みで書いていく。
AppSync自体は2026年現在、かなり成熟したサービスになっている。特にJavaScriptリゾルバ(APPSYNC_JS)がGA以降どんどんアップデートされていて、2025年末にはパイプラインリゾルバのデバッグ機能も強化された。VTLを書かなくていいだけでチームの生産性が全然違う。VTLで書いてた頃のコードを見返すと、今は「これは人間が書くコードじゃない」と本気で思う。
GraphQL全般の設計パターンについてはGraphQL 2026年最新実装ガイド|Federation・キャッシング・セキュリティでまとめているので、ここではAppSync固有の話に絞っていく。
最初に失敗したリゾルバ設計と、今の構成
最初に大きくやらかしたのがリゾルバのN+1問題だった。GraphQLの典型的な罠なんだけど、AppSyncで踏むとDynamoDBのReadキャパシティが爆発する。
// ❌ やらかした設計:親クエリのリゾルバで子を都度取得
// Query.posts リゾルバ
export function request(ctx) {
return {
operation: 'Scan',
table: 'Posts',
};
}
// Post.author リゾルバ(N+1が発生)
export function request(ctx) {
return {
operation: 'GetItem',
table: 'Users',
key: util.dynamodb.toMapValues({ userId: ctx.source.authorId }),
};
}
これ、記事が50件返ってきたら50回Usersテーブルへの読み取りが走る。最初の1週間でDynamoDBのConsumedReadCapacityUnitsが想定の8倍になって「なんか費用おかしくない?」ってなった。
解決策はBatchGetItemとパイプラインリゾルバの組み合わせ。AppSyncのパイプラインリゾルバで、まず親リソースをまとめて取得してauthorIdを収集し、次のファンクションでBatchGetItemする。
// ✅ 修正後:BatchGetItemで一括取得
// パイプラインの第2ファンクション
export function request(ctx) {
const authorIds = [...new Set(ctx.prev.result.items.map(p => p.authorId))];
const keys = authorIds.map(id =>
util.dynamodb.toMapValues({ userId: id })
);
return {
operation: 'BatchGetItem',
tables: {
Users: { keys },
},
};
}
export function response(ctx) {
const users = ctx.result.data.Users;
const userMap = {};
users.forEach(u => { userMap[u.userId] = u; });
// 前のファンクションの結果にauthor情報をマージ
return ctx.prev.result.items.map(post => ({
...post,
author: userMap[post.authorId],
}));
}
これだけでDynamoDBの読み取りが1/10以下になった。地味だけど、この設計変更が一番コスト効果があったと思う。たった数十行の変更でインフラコストが激変するのがAppSyncの面白いところでもあり、怖いところでもある。
リゾルバのデータソース選択
2026年現在、AppSyncが対応しているデータソースはかなり多い。うちのプロジェクトでは用途に応じて使い分けている。
| データソース | 主な用途 | 特徴 |
|---|---|---|
| DynamoDB | メインのCRUD | レイテンシ低、単一テーブル設計必須 |
| Lambda | 複雑なビジネスロジック | 柔軟だがコールドスタートに注意 |
| Aurora Serverless v2 | 複雑なクエリ、JOIN | SQL書けるが接続プール管理が必要 |
| HTTP | 外部API連携 | 単純なプロキシに便利 |
| OpenSearch | 全文検索 | DynamoDBストリームと組み合わせ |
| EventBridge | イベント発火 | 非同期処理のトリガーに |
Lambdaリゾルバは「全部Lambdaに投げればいい」という誘惑があるけど、正直やりすぎると辛くなる。単純なGetItem/PutItemはDynamoDBダイレクトリゾルバの方がレイテンシが30〜50ms良いし、コスト的にも優位。Lambdaは認可チェックや複数サービスを跨ぐ処理に絞るのが、うちのチームで定着しているルールだ。
Lambdaのコールドスタートが気になる場合の対策はLambda Cold Start地獄から脱出した|本番で効いた5つの対策が参考になる。
AWS構成図:本番AppSyncアーキテクチャ
実際の本番環境の構成を図にするとこんな感じ。モバイルアプリ・Webアプリ両対応のマルチクライアント構成になっている。
graph TB
subgraph Clients["クライアント"]
Mobile["📱 Mobile App\n(iOS/Android)"]
Web["🌐 Web App\n(Next.js)"]
end
subgraph Edge["エッジ・認証層"]
CF["CloudFront\n(キャッシュ)"]
Cognito["Cognito\nUser Pool"]
end
subgraph AppSyncLayer["AppSync Layer"]
AppSync["AppSync\nGraphQL API"]
subgraph Resolvers["リゾルバ"]
DDBResolver["DynamoDB\nDirectリゾルバ"]
LambdaResolver["Lambda\nリゾルバ"]
HTTPResolver["HTTP\nリゾルバ"]
end
end
subgraph DataLayer["データ層"]
subgraph VPC["VPC (ap-northeast-1)"]
subgraph AZ1["AZ-1a"]
Aurora1["Aurora Serverless v2\n(Primary)"]
end
subgraph AZ2["AZ-1c"]
Aurora2["Aurora Serverless v2\n(Replica)"]
end
ProxyDB["RDS Proxy"]
end
DynamoDB["DynamoDB\n(SingleTable)"]
OpenSearch["OpenSearch\nServerless"]
S3["S3\n(メディア)"]
end
subgraph EventLayer["イベント・非同期"]
EventBridge["EventBridge"]
SQS["SQS"]
Lambda["Lambda\n(ビジネスロジック)"]
end
subgraph Observability["監視"]
CW["CloudWatch"]
XRay["X-Ray"]
end
Mobile --> CF
Web --> CF
CF --> AppSync
Cognito --> AppSync
AppSync --> DDBResolver
AppSync --> LambdaResolver
AppSync --> HTTPResolver
DDBResolver --> DynamoDB
LambdaResolver --> Lambda
Lambda --> ProxyDB
Lambda --> S3
Lambda --> EventBridge
ProxyDB --> Aurora1
ProxyDB --> Aurora2
EventBridge --> SQS
SQS --> Lambda
HTTPResolver --> OpenSearch
AppSync --> CW
AppSync --> XRay
Lambda --> CW
CloudFrontをAppSyncの前段に置いているのがポイントだ。AppSyncのQuery系は一部キャッシュ可能なものがあって、CloudFrontのキャッシュと組み合わせると読み取りコストを大幅に削減できる。うちのケースだと公開データの読み取りでAppSync APIコール数が月40%ほど削減できた。
ただし注意が必要なのは、GraphQLはPOSTリクエストがデフォルトなのでCloudFrontのキャッシュ設定を工夫する必要があること。GETメソッドでのクエリ送信(クエリパラメータ利用)か、Cache Control Headerを適切に設定するかのどちらかが必要になる。この設定、最初は「なんでキャッシュ効かないんだろう」とかなり悩んだ。
認証・認可設計でハマった話
AppSyncの認証は複数の認証モードを組み合わせられるのが便利なんだけど、設計を間違えると後から修正が大変になる。
最初うちは「Cognitoだけで全部賄えばいい」と思っていたけど、実際にはAPIキー・Cognito・IAM・Lambdaオーソライザーの4つを使い分けることになった。
# スキーマでの認証モード指定
type Query {
# 未認証ユーザーも見れる公開コンテンツ
publicPosts: [Post] @aws_api_key @aws_cognito_user_pools
# ログイン済みユーザーのみ
myProfile: User @aws_cognito_user_pools
# 管理者のみ(Lambdaオーソライザーで権限チェック)
adminDashboard: AdminStats @aws_lambda
}
type Mutation {
# IAM認証(バックエンドサービス間通信)
internalSync(input: SyncInput!): SyncResult @aws_iam
# Cognito認証(フロントエンドから)
createPost(input: CreatePostInput!): Post @aws_cognito_user_pools
}
LambdaオーソライザーはRBAC(ロールベースアクセス制御)の実装に便利。Cognitoのカスタム属性だけでは表現しきれない複雑な権限ロジックをLambdaで処理できる。
// Lambdaオーソライザー実装例
exports.handler = async (event) => {
const token = event.authorizationToken;
try {
// JWTの検証(Cognito以外の独自トークンなど)
const decoded = await verifyToken(token);
// カスタム権限チェック
const permissions = await getPermissions(decoded.sub);
return {
isAuthorized: true,
resolverContext: {
userId: decoded.sub,
role: decoded.role,
permissions: JSON.stringify(permissions),
},
// TTLを設定してLambda起動回数を減らす(重要!)
ttlOverride: 300,
};
} catch (err) {
return { isAuthorized: false };
}
};
ttlOverrideの設定を忘れてLambdaオーソライザーがリクエストごとに呼ばれていた時期があって、Lambda費用が予想の5倍になったことがある。AppSyncはデフォルトだとオーソライザーの結果をキャッシュしないので注意。300秒(5分)のTTLを設定してからかなり改善された。これ、公式ドキュメントに書いてあるんだけどサラッとしか触れられていないので見落としやすい。
セキュリティ設計全般についてはOWASP Top 10 2024対策|脆弱性10項目の実装方法と企業の守り方も参考になる。GraphQLはインジェクション攻撃の対象になりやすいので、インプットバリデーションは必須だ。
サブスクリプション設計の地雷
AppSyncのサブスクリプション(WebSocket)は、リアルタイム機能を簡単に実装できる反面、設計をミスると接続数が爆発してコストに直撃する。うちのプロジェクトはチャット機能を持っていたので、これが一番手を焼いた部分だった。
type Subscription {
# ❌ 最初にやらかした設計:全ユーザーへのブロードキャスト
# onMessageCreated: Message @aws_subscribe(mutations: ["createMessage"])
# ✅ 修正後:チャンネルIDでフィルタリング
onMessageCreated(channelId: ID!): Message
@aws_subscribe(mutations: ["createMessage"])
@aws_cognito_user_pools
}
最初の設計ではフィルタリングなしでサブスクリプションを設定していたため、メッセージが1件作成されるたびに全接続ユーザーへWebSocketメッセージが飛んでいた。ユーザーが100人いたら100回のメッセージ配信になる。これに気づいたのが請求書を見たときだったのは、今となっては笑えない思い出だ。
AppSyncのサブスクリプションコストは接続時間(1分単位)とメッセージ配信数で課金されるので、フィルタリングは必須。2025年末に追加されたサブスクリプションフィルタリング機能(@aws_subscribeのエンハンスメント)を使うとリゾルバ側でさらに細かいフィルタリングができるようになった。
// Mutationリゾルバでのサブスクリプションフィルター設定
export function response(ctx) {
const message = ctx.result;
// extensions.subscriptionsFilter でフィルター条件を指定
extensions.setSubscriptionsFilter({
filterGroup: [
{
filters: [
{
fieldName: 'channelId',
operator: 'eq',
value: message.channelId,
},
// 購読者がそのチャンネルのメンバーかどうかもチェック
{
fieldName: 'subscriberIds',
operator: 'contains',
value: message.channelId,
},
],
},
],
});
return message;
}
公式ドキュメントに書いてあるんだけど事例が少なくて最初気づかなかった、という典型パターンがまたここでも出てきた。導入してからサブスクリプションのメッセージ配信コストが約60%削減できた。
サブスクリプション最適化前後のコスト推移
フィルタリングとTTL設定の効果がどれほど大きかったか、グラフで見るとよく分かる。
xychart-beta
title "サブスクリプション最適化前後の月次コスト比較(USD)"
x-axis ["1月", "2月", "3月(最適化)", "4月", "5月", "6月"]
y-axis "月次コスト (USD)" 0 --> 600
bar [420, 480, 310, 180, 175, 170]
3月に接続フィルタリングとTTL設定を入れてから一気に下がった。この最適化だけで月250ドル程度削減できていて、チームで「やってよかった」となったポイントだった。個人的には、これを最初から知っていればと思うと少し悔しい。
スキーマ設計で後悔したことと、2026年のベストプラクティス
スキーマ設計は後から変えるのが大変なので、最初にちゃんと考えておくべきだった。1年運用してみて後悔したポイントをいくつか共有する。
Connectionパターンは最初から使う
# ❌ 最初の設計:シンプルなリスト
type Query {
posts: [Post!]!
}
# ✅ 後から変えた設計:ページネーション対応
type Query {
posts(first: Int, after: String, filter: PostFilter): PostConnection!
}
type PostConnection {
items: [Post!]!
nextToken: String
totalCount: Int
}
リリース後にページネーションが必要になって後からスキーマを変更したら、既存クライアントとの互換性で地獄を見た。最初からConnectionパターンを使っておけばよかった、と今でも思う。
Input型の粒度
# ❌ なんでも1つのInputにまとめない
type Mutation {
updatePost(id: ID!, title: String, content: String,
tags: [String], status: PostStatus,
thumbnailUrl: String): Post
}
# ✅ 操作ごとにInputを分ける
type Mutation {
updatePostContent(id: ID!, input: UpdatePostContentInput!): Post
updatePostMetadata(id: ID!, input: UpdatePostMetadataInput!): Post
publishPost(id: ID!): Post
archivePost(id: ID!): Post
}
input UpdatePostContentInput {
title: String!
content: String!
}
input UpdatePostMetadataInput {
tags: [String!]
thumbnailUrl: String
}
操作ごとにMutationを分けると認可の制御がしやすいし、フロントエンドのコードも意図が明確になる。最初は「GraphQLなんだから1つのMutationで全部更新できれば便利」と思っていたけど、認可ロジックが複雑になった時点で後悔した。これも典型的な「後から痛い目を見る」パターンだった。
エラーハンドリング
AppSync特有の話として、GraphQLエラーとHTTPエラーの扱い方がある。AppSyncはHTTP 200で返してGraphQL標準のerrors配列にエラー情報を入れるのが基本なんだけど、errorTypeカスタムフィールドをちゃんと使うとクライアント側の処理が楽になる。
// Lambdaリゾルバからのエラー返却
exports.handler = async (event) => {
try {
// ビジネスロジック
} catch (err) {
if (err instanceof NotFoundError) {
// AppSyncで利用可能なカスタムエラー
util.error(err.message, 'NotFound', null, {
resourceId: event.arguments.id,
});
} else if (err instanceof UnauthorizedError) {
util.error(err.message, 'Unauthorized');
} else {
util.error('Internal server error', 'InternalError');
}
}
};
クライアント側でこのerrorTypeを見てUIのエラーハンドリングを切り替えられる。最初はエラーのコンテキストを全然返していなくて、「エラーになった」以上の情報がフロントに伝わらずデバッグが大変だった。フロントエンド担当者からのSlackが来るたびに「ログ見ます」しか言えなかった時期があって、正直かなり申し訳なかった。
インシデント対応の観点では、エラーログをCloudWatchに適切に流しておくことも重要。インシデント対応の最新ベストプラクティス2026|DevOps・SRE必読でも触れているけど、エラーの可観測性は後から整えるより最初から設計に組み込む方が断然楽だ。
まとめ
1年間AppSyncを本番運用して学んだことを整理するとこうなる。
- N+1問題は早めに潰す:DynamoDBダイレクトリゾルバのBatchGetItemとパイプラインリゾルバを組み合わせると、ReadキャパシティとAPIコール数の両方を大幅に削減できる。
- LambdaオーソライザーのTTLは必ず設定する:デフォルトはキャッシュなしなので、
ttlOverrideを300秒程度に設定しておかないとコストが爆発する。 - サブスクリプションにはフィルタリングが必須:ブロードキャストで全接続ユーザーに送ると接続数が増えるほどコストが跳ね上がる。
extensions.setSubscriptionsFilter()を活用する。 - スキーマはConnectionパターンで設計する:後からページネーションを追加するとクライアントとの互換性で苦労する。最初からConnectionパターンを採用しておく。
- データソースは用途で使い分ける:「全部Lambdaで」はアンチパターン。単純なCRUDはDynamoDBダイレクトリゾルバの方がレイテンシ・コストとも有利。
振り返ってみると、ほとんどの失敗は「後から気づいた」ものだった。ドキュメントに書いてあるけど事例が少ない、動いてはいるけどコストが見えていない、といった類の問題が多かった印象だ。AppSyncは確かに便利なサービスだけど、「マネージドだから大丈夫」という思い込みが一番の敵だったかもしれない。
まだVTLで書いたリゾルバが残っているプロジェクトがある人は、JavaScriptリゾルバへの移行を検討してみてほしい。VTLは書けると強力だけど、チームへの知識移転がしんどい。2026年現在ではJavaScriptリゾルバで大抵のことができるので、新規実装はJSで書く方針にするだけで開発体験がかなり変わる。
皆さんのチームではAppSyncの設計でどんなところで詰まっていますか?特にサブスクリプションのコスト管理は悩ましいポイントだと思うので、別の方法を採用している事例があれば聞いてみたい。