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のドキュメントを読むとAWSDateTimeAWSJSONAWSEmailといったスカラー型が用意されているのだが、最初はそれを使わずに自前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を使い、ページネーションはカーソルベースで
2VTL → JavaScriptリゾルバーへの移行は正解開発速度・テスタビリティ・チーム理解度が全部上がった
3Pipelineリゾルバーで単一責任を守る認証チェック・取得・変換を分離すると保守性が格段に上がる
4キャッシュは早めに設計に組み込む後付けだとキャッシュキーの設計ミスがセキュリティインシデントになりうる
5N+1はBatchGetItem+キャッシュで対処設計時点で意識すれば大抵のケースは防げる

次にやること:

  • 既存プロジェクトにVTLリゾルバーが残っているなら、JavaScriptリゾルバーへの移行計画を立てる
  • @aws-appsync/utilsでユニットテストをCI/CDに組み込む
  • AppSyncのサーバーサイドキャッシュを有効化してレスポンスタイムを計測する

AppSyncは設計の自由度が高い分、最初の選択が後の運用コストに大きく影響する。この記事が同じようなところで詰まっているエンジニアの助けになれば嬉しい。何か試してみた結果や別のアプローチがあれば、ぜひ教えてほしい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事