Salesforce GraphQL APIで「まさか、SOQLより遅いなんて…」と後悔したあの時の判断

graphql api で実際にやらかした判断。それは、リリースされたばかりのGraphQL APIに飛びつき、「これでSOQLの限界もRESTのオーバーフェッチも解決できる!」と過信したことだった。

当時の私は、とにかく複雑なデータ構造を一度のAPIコールで取得したいと考えていた。例えば、あるカスタムオブジェクトProject__cに関連する複数のTask__cレコードがあり、さらに各Task__cにはAssignee__c(これもカスタムオブジェクトでUser__cへのLookup)がいて、そのAssignee__cの特定のフィールド(例えばメールアドレスや部署)も欲しい、といったシナリオだ。

従来のSOQLだと、これは複数のクエリを発行するか、非常に複雑なSOQLクエリ(親子孫関係が絡むような)を書くか、あるいはApexでforループを回して関連データを収集する、という形になっていた。REST APIでも、複合APIを使えば多少は楽になるが、欲しいフィールドを厳選する点でGraphQLの柔軟性は魅力的に見えた。

当時は「これぞ未来のAPIだ!」と信じていた

GraphQL APIが発表された時、特に@connectionディレクティブを使ったリレーションシップのトラバース機能に興奮した。「これで、必要なデータだけを、1回の往復で、しかも宣言的に取得できる!」と、かなり前のめりだった。

実際の開発では、LWCからSalesforceデータを取得する部分でGraphQLを積極的に採用した。以下のようなクエリを書けば、プロジェクトとそのタスク、そして担当者情報を一度に取れると判断したのだ。

query getProjectDetails($projectId: ID) {
  uiapi {
    query {
      Project__c(where: { Id: { eq: $projectId } }) {
        edges {
          node {
            Id
            Name { value }
            Status__c { value }
            Tasks__r @connection(first: 100) { # 最大100件のタスクを取得
              edges {
                node {
                  Id
                  Name { value }
                  DueDate__c { value }
                  Assignee__r { # Assignee__cオブジェクトへのリレーション
                    Id
                    Name { value }
                    Email__c { value } # Assignee__cのカスタムフィールド
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

このクエリは一見すると非常にスマートに見えた。SOQLで同じことをしようとすると、複数の子オブジェクトを持つ親オブジェクトからさらに孫オブジェクトのフィールドを取得する際に、複数のクエリやApexでのループ処理が必要になりがちだったからだ。

しかし、やらかしはパフォーマンス問題という形で現れた

開発環境で少量のデータで動かしている間は、問題なく動作した。むしろ、SOQLを複数回叩くよりも速いとさえ感じた。しかし、本番環境に近いデータ量(例えば、1つのプロジェクトに数千のタスクがあるケースや、各タスクに過去のアクティビティ履歴オブジェクトが多数ぶら下がっているケース)でテストを始めると、途端にパフォーマンスが劣化したのだ。

まず、クエリのレスポンスタイムが異常に長くなった。これはLWCからGraphQL APIをコールしていたので、クライアント側のUXに直結する問題だった。開発者コンソールでNetworkタブを見ると、GraphQLのAPIコールの完了までに数秒を要することも珍しくなかった。

次に、Apexでコールバック処理(外部システム連携など)を行っている際に、CPU Time Limit に抵触するケースも発生した。これはGraphQL APIの特性上、内部で複雑なデータ結合や処理が行われるため、Governor Limitsを消費しやすいという事実に、当時はあまり意識が向いていなかったためだ。

特に私が甘く見ていたのは以下の点だった。

  • @connectionディレクティブの罠: @connection(first: 100)のように件数を指定しても、そのリレーションシップを辿るための内部処理コストは決して小さくない。特に、リレーション先のオブジェクトが非常に多い場合、その処理は重くなる。

  • 多すぎる階層: 上記の例では3階層程度だが、さらに孫の孫…とリレーションを深く辿ろうとすると、指数関数的にクエリの処理負荷が増大する感覚があった。取得するフィールドを厳選しているにもかかわらず、だ。

  • 内部的なクエリ変換コスト: SalesforceのGraphQL APIは、内部的にSOQLや他のデータ取得メカニズムに変換しているはずだ。その変換と実行のオーバーヘッドが、単純なSOQLを複数回叩くよりも高くなるケースがあるということを、当時は全く考慮していなかった。

  • クライアントからのクエリ構築の自由度: 当初は「クライアントが欲しいデータだけを自由に取得できる」という点に魅力を感じたが、これは同時に「無駄に複雑なクエリが発行されやすい」というリスクもはらんでいた。結局、パフォーマンス問題が顕在化してから、サーバーサイドで厳密にクエリを定義し、クライアントからは変数だけを渡す形に制限せざるを得なくなった。

後から「やらなければよかった」と思った設計

あの時、もし別の選択肢があったなら…。今なら、まず以下の選択肢を検討するだろう。

  1. シンプルなSOQLの複数回実行: 多くの場合、SOQLで親と子のデータをそれぞれ取得し、LWCのJavaScript側で結合した方がパフォーマンスが良いことが多かった。特に子レコードが少ない場合や、親のデータと子のデータを使うタイミングが異なる場合など。

  2. 複合API (Composite API): 複数のREST APIコールを1つのリクエストにまとめる複合APIは、GraphQLのような柔軟性はないが、ネットワークオーバーヘッドを削減しつつ、異なるオブジェクトのデータを効率的に取得できる。特に、データの更新も伴う場合はこちらの方がシンプル。

  3. Apexによるデータ加工: LWCからApexメソッドをコールし、そのApex内で複数のSOQLクエリを実行して必要なデータを取得・加工してからLWCに返す。最も柔軟性が高く、Governor Limitsを意識した最適なデータ取得ロジックを実装できる。これは最終的に多く採用したパターンだ。

GraphQL APIは、特定のユースケース、例えば「外部サービスがSalesforceのデータを動的に、かつピンポイントで取得したい」というようなシナリオでは強力なツールだ。しかし、Salesforce LWCのような「内部」での利用において、安易にSOQLの代替として使うと、思わぬ落とし穴にはまることがある。特に、リレーションシップを深く辿るクエリは要注意だ。

あの時の私は、新しい技術の可能性に目を奪われ、その裏に潜む複雑さやパフォーマンス特性を十分に理解していなかった。今なら、GraphQL APIの導入は、そのユースケースが本当にGraphQLのメリット(特に柔軟なデータシェイピング)を最大限に活かせるかどうかを、より慎重に評価してから判断する。

結局のところ、SalesforceのGraphQL APIは万能薬ではなかった。今では、SOQL、REST API、複合API、Apexといった既存のツール群とGraphQL APIを、それぞれの特性とGovernor Limitsを理解した上で使い分けることが、Salesforce開発者にとって最も重要だと痛感している。


これは当時の自分向けのメモだ。

コメント