【GraphQL】そのApollo ClientのキャッシュIDは一意ですか？

# はじめに

こちらの記事は株式会社メドレーのAdvent Calendar(2024)の7日目の記事です！

https://qiita.com/advent-calendar/2024/medley 


今年度から株式会社メドレーにて新卒エンジニアとして働いている@shiromieです。
私が携わっているプロダクトでは
フロントエンド: TypeScript × React  ×  Apollo Client(GraphQL)
バックエンド　: TypeScript × NestJS × Apollo Server(GraphQL)  
という技術スタックを主に採用しています。

その中で、直近の業務においてApollo Clientの「キャッシュIDの一意性」を考慮しなければならないケースに何度か遭遇しました。

そこで本記事では、Apollo ClientにおけるキャッシュIDの一意性がなぜ重要かについて、具体例を挙げながら詳しく解説したいと思います。


# 本記事の構成
- Apollo Clientとは？
- Apollo Clientのキャッシュとは？
- キャッシュIDはどのように使われるのか？
- なぜ一意なキャッシュIDが重要か？
- 一意なキャッシュIDを担保するには？
    - 方法① キャッシュキーとして扱うフィールドをカスタムで設定する
    - 方法② サーバーサイドで生成した一意なIDをクライアントサイドで用いる
- 終わりに
- あとがき


# 対象読者
- 業務でApollo Clientを使用している方
- これからApollo Clientを使用する方
- Apollo Clientのキャッシュを活用したい方


# Apollo Clientとは？

Apollo ClientはGraphQL APIとフロントエンドを接続するためのライブラリで、以下のような特徴を持っています。

- TypeScriptとの親和性が高く、型安全なデータ取得が可能
- キャッシュ機能により、データの再利用を効率化しパフォーマンスを向上させる
- クエリ、ミューテーション、サブスクリプションを通じてリアクティブなデータ管理を実現する

またバックエンドがGraphQLサーバーである場合、Apollo Clientはクエリを送信し結果をキャッシュに保存することで、フロントエンドの負担軽減も可能にします。


# Apollo Clientのキャッシュとは？

Apollo ClientではクエリのレスポンスデータをInMemoryCacheに保存し、次回以降の同様のクエリに対してキャッシュデータを優先的に使用(※一部の[fetch-policy](https://www.apollographql.com/docs/react/data/queries#supported-fetch-policies:~:text=function%20is%20called.-,Supported%20fetch%20policies,-Name)を除く)します。

その仕組みは公式ドキュメントにあるイメージ図が非常に明快であるため、引用した以下の画像を参照下さい。

- Bookがcacheに存在しなかった場合
![スクリーンショット 2024-11-24 13.03.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717348/93c7abb7-2fbb-780b-96e2-58f537f3150c.png)


- Bookがcacheに存在した場合
![スクリーンショット 2024-11-24 13.02.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717348/f4152ede-76b1-301f-5ae5-c8f3deaeb875.png)


上記の画像は、以下の公式ドキュメントより引用しました。

https://www.apollographql.com/docs/react/caching/overview

# キャッシュIDはどのように使われるのか？

Apollo Clientではキャッシュデータを識別する際、デフォルトでは`__typename`と`id`を基に生成されたキャッシュキーを使用します。

例えば、以下のようなGraphQL Typeを定義する場合、



```graphql
"""
ある特定の生徒における特定の講義の受講状況
"""
type StudentLecturesAttendance {
  """
  講義のID
  """
  id: ID!

  """
  生徒のID
  """
  studentId: String!

  """
  講義のタイトル
  """
  title: String!
  
  """
  生徒がこの講義を完了したかどうか
  """
  isCompleted: Boolean!
}

type Student {
  id: ID!
  name: String!
}

type Query {  
  studentLecturesAttendance(input: StudentLecturesAttendanceInput!): [StudentLecturesAttendance!]!
}

input StudentLecturesAttendanceInput {
  """
  生徒のID
  """
  studentId: ID!

  """
  講義のID
  """
  lectureId: ID
}
```
`StudentLecturesAttendance`のキャッシュIDは`StudentLecturesAttendance:101`といったものになります。

※ この例では不適切なスキーマ定義を意図的に使用しています。本来、`StudentLecturesAttendance`は「生徒と講義の組み合わせ」によって一意に識別されるべきで、`id`に講義のID（`Lecture`の`id`）を使用して定義するべきではありません。故にこの設計は実際の運用には適していないため、あくまでサンプルとしてご覧ください。

# なぜ一意なキャッシュIDが重要か？
キャッシュIDの一意性が担保されない場合、**予期せぬキャッシュの上書き**が発生します。
例えば、

生徒A:
```json
{
  "studentLecturesAttendance": {
   "id": 101,
   "studentId": "01HKVP5MQ3ZARPW63J8Z6EJ84V",
   "title": "講義A",
   "isCompleted": true
  }
}
```

生徒B:
```json
{
  "studentLecturesAttendance": {
   "id": 101,
   "studentId": "0000003BW9BSS9VAY4KDND9BP7",
   "title": "講義A",
   "isCompleted": false
  }
}
```
とする場合、これらはどちらもキャッシュIDは`StudentLecturesAttendance:101`として保存されます。

Apollo Clientでは新しいクエリ結果をキャッシュに保存する際、既存のキャッシュキーに対するエントリが存在すればそのデータを上書きするため、このようなIDの定義では生徒AとBのキャッシュのデータに競合が生じてしまいます。

# 一意なキャッシュIDを担保するには？

- 生徒に対して受けるべき講義を割り当て、「各生徒が講義を受講完了しているか？」を先生が確認する
- 生徒の一覧を表示し、各生徒を示すコンポーネント内のボタンをクリックすると、その生徒が受講した講義の受講状況を取得する

上記の仕様を満たす画面の実装を考えます。

<details><summary>コード例</summary>

```tsx
type Student = {
  id: string;
  name: string;
};

type Lecture = {
  id: number;
  title: string;
  isCompleted: boolean;
};

function StudentsList({ lectureId }: { lectureId: string }) {
  // Apollo ClientのuseQueryにより、生徒一覧を取得
  const { data } = useQuery(ListLectureAssignedStudentsDocument, {
    fetchPolicy: 'cache-and-network',
    variables: {
      lectureId,
    },
  });

  const students = data?.listLectureAssignedStudents || [];

  return (
    <ul>
      {students.map((student) => (
        <ListElement key={student.id} student={student} lectureId={lectureId}/>
      ))}
    </ul>
  );
}

/**
 * 各生徒を表示するコンポーネント
 *
 * ボタンをクリックすると、その生徒に割り当てられた講義を取得する
 * 講義には、その生徒が講義を受講完了したか示す`isCompleted`が含まれる
 */
function ListElement({ student, lectureId }: { student: Student; lectureId: number }) {
  // Apollo ClientのuseLazyQueryにより、受講状況を取得
  const [fetchStudentLecturesAttendance, { data }] = useLazyQuery(
    StudentLecturesAttendanceDocument
  );

  const handleFetchAttendance = () => {
    fetchStudentLecturesAttendance({
      variables: {
        input: {
          lectureId,
          studentId: student.id,
        },
      },
    });
  };

  const lecture = data?.studentLecturesAttendance;

  return (
    <li>
      <div>生徒名: {student.name}</div>
      <button onClick={handleFetchAttendance}>受講状況を取得する</button>

      {lecture !== undefined && (
        <div> {lecture.title} - {lecture.isCompleted ? '完了' : '未完了'} </div>
      )}
    </li>
  );
}
```
</details>


また受講状況を取得する際に実行するクライアントサイドでのqueryである`GetStudentLecturesAttendance`は以下のように定義します。
```graphql
  query GetStudentLecturesAttendance($input: StudentLecturesAttendanceInput!) {
    studentLecturesAttendance(input: $input) { 
      id 
      title
      isCompleted
    }
  }
```

### 方法① キャッシュキーとして扱うフィールドをカスタムで設定する

`StudentLecturesAttendance`の`id`が講義のID(`lectureId`)のみにより決定される場合、この`id`は講義そのものを識別するもので属する生徒（`studentId`）の情報を含んでいないため不適切となります。画面上では、生徒Aの受講状況を取得後に生徒Bの受講状況を取得すると、既に取得し表示されていた生徒Aの情報が生徒Bの情報に書き換わってしまうという問題が発生します。

したがって、`StudentLecturesAttendance`の`id`は`lectureId`から生成するのではなく、`studentId`も考慮した一意性のあるものに変更する必要があります。

しかし既存のデータ構造の変更が難しい場合、短期的な解決策として`id`を変更するのではなく、**Apollo Clientのキャッシュキーとして扱うフィールドをカスタムで設定する**という方法があります。

Apollo Clientのキャッシュキーはデフォルトで`id`のみを参照するようになっていますが、これを`StudentLecturesAttendance`のキャッシュキーのみ、`id`(lectureId)、`studentId`の2つがキーとなるように設定します。

```ts
export const apolloClient = new ApolloClient({
  cache: new InMemoryCache({
    possibleTypes: generatedIntrospection.possibleTypes,
    typePolicies: {
      StudentLecturesAttendance: {
        keyFields: ['id', 'studentId'], // カスタムキーを定義
      },
    },
  }),
});
```

この設定により、キャッシュIDが以下のように変更され一意性が担保されます。
```json
StudentLecturesAttendance:{"id":101,"studentId":"01HKVP5MQ3ZARPW63J8Z6EJ84V"}
StudentLecturesAttendance:{"id":101,"studentId":"0000003BW9BSS9VAY4KDND9BP7"}
```

またカスタムキーが有効に機能するよう、`GetStudentLecturesAttendance`をリクエストする場合は必ず`studentId`を呼ぶように変更します。

```graphql
  query GetStudentLecturesAttendance($input: StudentLecturesAttendanceInput!) {
    studentLecturesAttendance(input: $input) {
      id 
      studentId # 追加
      title
      isCompleted
    }
  }
```

これにより異なる`studentId`に基づくデータが個別にキャッシュされ、上書き問題が解消されます。



しかしこの解決策は長期視点で考えた場合、あまり良い方法とは呼べません。なぜなら、今後`GetStudentLecturesAttendance`をリクエストする際は`id`と`studentId`の2つを常に必ず呼び出す必要があることを開発者に周知する必要があり、意図しない実装ミスを生み出す要因になるためです。

### 方法② サーバーサイドで生成した一意なIDをクライアントサイドで用いる

より良い解決策として、サーバーサイドで生成した一意なIDをクライアントサイドで用いる方法があります。以下にその実装例を示します。

```ts
export async function listLectures(studentId: string): ListLecturesPayload {
  const lectures = await getLectures(studentId)

  return lectures.map((lecture) => ({ id: `student_${stuedentId}_lecture_${lecture.id}`}))
}
```
上記のように`studentId`と組み合わせたIDを`StudentLecturesAttendance`のキャッシュIDとして使用することで、同じIDを持つ講義でも別々のキャッシュとして保存することが可能です。これは方法①のようにカスタムキーを全て指定して呼び出す必要がない(※`id`は常に指定するべき)ため、保守性の高い解決策と言えます。

また、重要なのはキャッシュオブジェクトの同一性がIDによって担保されることであり、クライアントサイドからの入力でフィールドの値が変わる場合はその入力に起因したIDを生成する(例: 検索条件をハッシュ化する)といったことも必要になるかもしれません。

# 終わりに
以上になります。
この記事では、Apollo ClientのキャッシュIDの一意性の重要性と担保する方法について、具体例を元に解説しました。データ競合やキャッシュの上書き問題に直面した際の参考になれば幸いです。

Medley Advent Calendar 2024、明日は @Layzie さんです！
ぜひお楽しみに！


# あとがき
株式会社メドレーではエンジニアを絶賛大募集中です。
興味のある方は、ぜひ以下のリンクから詳細をご覧ください。

https://open.talentio.com/r/1/c/medley/pages/54855 

皆さまのご応募を心よりお待ちしております！