Apollo Client - Customizing the behavior of cached fields

이 글은 아폴로 클라이언트 공식문서를 읽고 정리한 글이다.

캐시된 필드의 동작 커스터마이징하기

아폴로 클라이언트 캐시에서 특정 필드를 읽고 쓰는 동작을 커스터마이징할 수 있다. 그렇게 하기 위해서는, 해당 필드에 대한 field policy(필드 정책)를 정의해주어야 한다. 필드 정책은 다음을 포함할 수 있다:

  • 해당 필드의 캐시된 값을 읽을때 어떤 일이 발생하는지 지정하는 read 함수
  • 해당 필드의 캐시된 값을 쓸 때 어떤 일이 발생하는지 지정하는 merge 함수
  • 캐시가 불필요한 중복된 데이터 저장하는 것을 피하도록 도와주는 key arguments의 배열

필드 정책들을 InMemoryCache 생성자에 넘겨준다. 각 필드 정책은 필드의 상위 타입에 해당하는 TypePolicy 객체 내에 정의된다.

다음 예제는 Person 타입의 name 필드의 필드정책을 정의한다:

const cache = new InMemoryCache({
  typePolicies: {
    Person: {
      fields: {
        name: {
          read(name) {
            // 캐시된 name 필드를 upper case로 바꿔서 리턴
            return name.toUpperCase();
          },
        },
      },
    },
  },
});

이 필드 정책은 Person.name이 쿼리될 때 리턴되는 캐시 값을 리턴하는 내용을 지정하는 read 함수를 정의했다.

read 함수

필드의 read 함수를 정의하면, 클라이언트에서 해당 필드를 쿼리할 때 캐시는 그 함수를 호출한다. 쿼리 응답으로 필드는 캐시된 값 대신 read 함수의 리턴 값을 나타낸다.

모든 read 함수는 두 개의 파라미터를 전달한다:

  • 첫번째 인자로는 필드의 현재 캐시된 값이다(존재했을때). 이 값을 통해 좀 더 유용하게 리턴값을 계산할 수 있다.
  • 두번째 인자로는 필드에 전달되는 인수를 포함해서 여러 프로퍼티와 헬퍼함수에 대한 접근을 제공하는 객체이다.
    • FieldFunctionOptions 타입의 필드들을 확인하려면 FieldPolicyAPI 문서를 참고하기

다음 read 함수는 Person 타입의 name 필드가 캐시를 가지고 있지 않을 때, 기본 값으로 UNKNOWN NAME을 리턴한다. 캐시된 값이 있으면 수정되지 않은 상태로 리턴된다.

const cache = new InMemoryCache({
  typePolicies: {
    Person: {
      fields: {
        name: {
          read(name = "UNKNOWN NAME") {
            return name;
          },
        },
      },
    },
  },
});

필드 인자들 핸들링하기

필드에서 인수를 허용하는 경우, read 함수의 두번째 파라미터로는 해당 인수에 제공된 값이 들어있는 args 객체가 포함된다.

예를 들면, 다음 read 함수는 name 필드에 maxLength 인자가 제공되었는지 유무를 체크한다. 만약 있다면, 함수는 maxLength의 첫번째 문자를 Personname으로 리턴한다. 그렇지 않으면 Personname이 리턴된다.

const cache = new InMemoryCache({
  typePolicies: {
    Person: {
      fields: {
        // 필드의 TypePolicy가 read 함수만 포함하면,
        // 이전 예제에서 보았듯이 객체 안에 네스팅하는 대신
        // 함수를 옵셔널하게 정의할 수 있다.
        name(name: string, { args }) {
          if (args && typeof args.maxLength === "number") {
            return name.substring(0, args.maxLength);
          }
          return name;
        },
      },
    },
  },
});

필드에 여러 매개변수가 필요한 경우, 각 매개변수를 변수로 래핑한 다음 디스트럭쳐링되어 리턴되어야한다. 각 파라미터는 각각의 서브필드로 사용가능하다.

다음 read 함수는 fullName의 하위필드인 firstName의 기본 값으로 UNKNOWN FIRST NAME을 할당하고, fullName의 하위필드인 lastName의 기본 값으로 UNKNOWN LAST NAME를 할당한다.

const cache = new InMemoryCache({
  typePolicies: {
    Person: {
      fields: {
        fullName: {
          read(
            fullName = {
              firstName: "UNKNOWN FIRST NAME",
              lastName: "UNKNOWN LAST NAME",
            }
          ) {
            return { ...fullName };
          },
        },
      },
    },
  },
});

다음 쿼리는 fullName 필드의 하위필드인 firstNamelastName을 리턴한다:

query personWithFullName {
  fullName {
    firstName
    lastName
  }
}

스키마에 정의되지않은 필드의 read 함수를 정의할 수도 있다. 예를 들면, 다음 read 함수를 통해 항상 로컬에 저장된 데이터로 채워지는 userId 필드를 쿼리할 수 있게 한다:

const cache = new InMemoryCache({
  typePolicies: {
    Person: {
      fields: {
        userId() {
          return localStorage.getItem("loggedInUserId");
        },
      },
    },
  },
});

로컬에 정의된 값으로만 필드를 쿼리하는 경우, @client 디렉티브를 쿼리에 포함해서 서버에서 가져오는 값이 아니라는 것을 아폴로 클라이언트에게 표시하여야 한다.

read 함수는 다른 사용 케이스도 포함한다:

  • 부동소수점 값을 가장 가까운 정수로 반올림하는 등의 클라이언트 요구에 맞게 캐시된 데이터를 변환한다.
  • 동일한 객체에 있는 하나 이상의 스키마 필드에서 local-only 필드를 파생한다.(예. birthDate 필드에서 연령 필드를 파생하기)
  • 여러 객체에서 하나 이상의 스키마 필드를 통해 local-only 필드를 파생한다.

read 함수의 모든 옵션 리스트를 보고 싶으면 여기를 참고하기. 이 옵션들이 거의다 필요하지 않을 것 같지만, 캐시에서 필드를 읽어올때 각각은 중요한 역할을 한다.

merge 함수

필드의 merge 함수를 정의하면, 캐시에서 서버에서 들어오는 값이 기록될 때마다 해당 함수를 호출한다. 기록될 때, 필드는 새로운 값을 들어온 값 대신 merge 함수의 리턴된 값을 통해 기록된다.

배열 병합하기

merge 함수의 일반적인 사용사례는 배열을 가지고 있는 필드를 어떻게 쓸 것인지를 정의할 때이다. 기본적으로, 해당 필드의 기존 배열은 들어온 필드로 완전히 대체된다. 대부분의 경우 다음과 같이 두 배열을 연결해서 사용하는 것이 좋다:

const cache = new InMemoryCache({
  typePolicies: {
    Agenda: {
      fields: {
        tasks: {
          merge(existing = [], incoming: any[]) {
            return [...existing, ...incoming];
          },
        },
      },
    },
  },
});

이 패턴은 특히 페이징된 리스트일 떄 일반적으로 사용된다

캐시에 아직 필드에 대한 데이터가 포함되어있지 않기 때문에, 필드의 특정 인스턴스에 대해 이 함수를 처음 호출할 때는 existing이 undefined라는 것을 기억하자. existing = []을 지정함으로써 기본 파라미터 값을 더 편하게 핸들링할 수 있다.

merge 함수는 incoming 배열을 existing 배열에 곧바로 바꿀 수는 없다. 잠재적인 에러를 피하기 위해 새로운 배열을 대신 리턴하여야한다. 개발모드에서는 아폴로 클라이언트는 Object.freeze를 사용하여 기존 데이터의 의도치않은 수정을 피한다.

정규화되지 않은 객체들을 병합하기

merge 함수를 사용하면, 캐시에서 정규화되지 않은 네스팅된 객체가 동일한 정규화된 상위 객체 내에 중첩되어 있다고 가정하여 지능적으로 중첩된 객체를 결합할 수 있다.

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        author: {
          // 정규화되지 않은 Book 안의 Author 객체
          merge(existing, incoming, { mergeObjects }) {
            return mergeObjects(existing, incoming);
          },
        },
      },
    },
  },
});

예제

그래프큐엘 스키마로 다음 타입들을 가지고 있다고하자:

type Book {
  id: ID!
  title: String!
  author: Author!
}

type Author { # Has no key fields
  name: String!
  dateOfBirth: String!
}

type Query {
  favoriteBook: Book!
}

이 스키마에서 Book 객체는 id 필드를 가지고 있으므로 캐시를 정규화할 수 있다. 그러나, Author 객체는 id 필드를 가지고 있지 않고, 특정 인스턴스를 고유하게 식별할 수 있는 다른 필드도 없다. 그러므로, 캐시는 Author 객체의 캐시는 정규화될 수 없고, 두 개의 다른 Author 객체는 실제로 동일한 Author를 나타내는 경우를 구분할 수 없다.

이제, 클라이언트에서 두 개의 쿼리를 실행시켜보자:

query BookWithAuthorName {
  favoriteBook {
    id
    author {
      name
    }
  }
}

query BookWithAuthorBirthdate {
  favoriteBook {
    id
    author {
      dateOfBirth
    }
  }
}

첫번째 쿼리가 리턴될 때, 아폴로 클라이언트는 캐시에 Book 객체를 다음과 같이 기록한다:

{
  "__typename": "Book",
  "id": "abc123",
  "author": {
    "__typename": "Author",
    "name": "George Eliot"
  }
}

Author 객체가 정규화되지 못하는 것을 기억하자. 그들은 상위 객체에서 직접 네스팅된다.

이제, 두번째 쿼리가 리턴될 때, 캐시된 Book 객체는 다음과 같이 업데이트된다:

{
  "__typename": "Book",
  "id": "abc123",
  "author": {
    "__typename": "Author",
    "dateOfBirth": "1819-11-22"
  }
}

Authorname 필드가 삭제되었다..! 이는 두 쿼리에서 리턴된 Author 객체가 실제로 동일한 Author를 참조하는지 아폴로 클라이언트에서 확인할 수 없기 때문이다. 그래서 두 객체의 필드를 병합하는 대신, 아폴로 클라이언트는 객체를 완전히 덮어씌우게 된다.(그리고 워닝을 기록)

그러나, 우리는 이런 두개의 객체를 같은 author로 나타낼 수 있다. 왜냐면 book의 author는 사실상 바뀌지 않기 때문이다. 그러므로, 우리는 동일한 Book에 속하는 한 Book.author 객체를 동일한 객체로 취급하도록 캐시에게 얘기해줄 수 있다. 이것은 두 개의 다른 것을 리턴하는 쿼리로도 namedateOfBirth 필드를 병합하는 것을 가능케한다.

이걸 할려면, 우리는 Book의 타입 정책 안의 author 필드의 merge 함수를 커스텀하여 정의해야한다:

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        author: {
          merge(existing, incoming, { mergeObjects }) {
            return mergeObjects(existing, incoming);
          },
        },
      },
    },
  },
});

우리는 mergeObjects라는 헬퍼 함수를 사용하여 Author 객체의 existingincoming을 받아서 값들을 머지한다. 스프레드 연산자를 사용해서 객체들을 병합하는 것 대신, mergeObjects를 사용하는 것은 중요한데, 왜냐하면 mergeObjectsBook.author의 하위 필드들에 대해 정의된 모든 merge 함수를 호출하기 때문이다.

merge 함수는 Book 또는 Author 관련 로직이 전혀 포함되어있지 않다는 것을 기억하자. 이것은 비정규화 객체 필드에 얼마든지 재사용할 수 있다는 것을 의미한다. 이 merge 함수의 정의는 매우 일반적이므로, 다음과 같은 약어로도 정의할 수 있다:

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        author: {
          // options.mergeObjects(existing, incoming)와 같은 의미
          merge: true,
        },
      },
    },
  },
});

요약하면, 위의 Book.author 정책을 사용하면 캐시가 특정 정규화된 Book 객체와 연결된 모든 Author 객체를 지능적으로 병합할 수 있다.

정규화되지 않은 두 객체를 병합하려면 merge: true의 경우 다음 조건이 모두 true여야 한다:

  • 두 객체는 캐시에서 정확히 동일한 정규화된 객체의 정확히 동일한 필드를 차지해야한다.
  • 두 객체는 같은 __typename을 가지고 있어야한다.
    • 이는 여러 객체 타입들 중 하나를 리턴할 수 있는 인터페이스 또는 유니온 리턴타입이 있는 필드가 있을때 중요하다.

이런 규칙을 위반하는 동작이 필요한 경우, merge: true를 사용하는 대신 커스터마이징한 merge 함수를 작성해야한다.

비정규화된 객체들에 대한 병합된 배열들

Book이 여러 authors를 가질 수 있는 상황을 생각해보자:

query BookWithAuthorNames {
  favoriteBook {
    isbn
    title
    authors {
      name
    }
  }
}

query BookWithAuthorLanguages {
  favoriteBook {
    isbn
    title
    authors {
      language
    }
  }
}

favoriteBook.authors 필드에는 정규화되지 않은 Author 객체 목록이 포함되어 있다. 이 경우에는 위의 두 쿼리에서 반환된 namelanguage 필드가 서로 올바르게 연결되도록 보다 정교한 merge 함수를 정의해야한다.

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        authors: {
          merge(existing: any[], incoming: any[], { readField, mergeObjects }) {
            const merged: any[] = existing ? existing.slice(0) : [];
            const authorNameToIndex: Record<string, number> =
              Object.create(null);
            if (existing) {
              existing.forEach((author, index) => {
                authorNameToIndex[readField<string>("name", author)] = index;
              });
            }
            incoming.forEach((author) => {
              const name = readField<string>("name", author);
              const index = authorNameToIndex[name];
              if (typeof index === "number") {
                // 존재하는 author 데이터와 새로운 author 데이터와 병합하기
                merged[index] = mergeObjects(merged[index], author);
              } else {
                // 이 배열에서 이 author를 처음 볼 때
                authorNameToIndex[name] = merged.length;
                merged.push(author);
              }
            });
            return merged;
          },
        },
      },
    },
  },
});

기존 authors 배열을 새로운 배열로 대체하는 대신, 이 코드는 배열을 서로 연결하면서 중복된 author의 name도 확인한다. 중복된 name이 발견될 때마다 Author 객체의 필드가 병합된다.

readField 헬퍼함수는 author가 캐시의 다른 곳에 있는 데이터를 참조하는 참조 객체일 가능성을 허용하므로 author.name을 직접 사용하는 것보다 더 강력하다. Author 타입이 결국 keyFields를 정의하여 정규화되는 경우에 중요하다.

이 예제에서 알 수 있듯이 merge 함수는 매우 정교해질 수 있다. 이런 경우, 일반 로직을 재사용 가능한 헬퍼함수로 추출할 수 있다:

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        authors: {
          merge: mergeArrayByField<AuthorType>("name"),
        },
      },
    },
  },
});

이제 재사용 가능한 추상화한 뒤에 세부 사항을 숨겼으므로, 구현이 얼마나 복잡해지는지는 더이상 중요하지 않다. 이렇게하면 클라이언트측 비지니스 로직을 개선하는 동시에 전체 애플리케이션에서 관련 로직을 일관되게 유지할 수 있으므로 자유롭다.

타입 레벨에서 merge 함수 정의하기

아폴로 클라이언트 3.3 이상부터는 정규화되지 않은 객체 타입에 대한 기본 merge 함수를 정의할 수 있다. 이렇게 하면, 필드별로 재정의하지 않는 한 해당 타입을 반환하는 모든 필드에서 기본 merge 함수를 사용하게 된다.

타입 정책상에서 비정규화 타입에 대한 기본 merge 함수를 선언한다. 비정규화된 객체 병합하기에 따른 비정규화된 Author 타입에 대한 결과는 다음과 같다:

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        // 더이상 필수가 아니다.
        // author: {
        //   merge: true,
        // },
      },
    },

    Author: {
      merge: true,
    },
  },
});

위에서 보여주듯이, Book.author을 위한 필드레벨의 merge 함수는 더이상 요구되지 않는다. 이 기본 예제의 최종 결과는 동일하지만, 이 경우 나중에 추가할 수 있는 다른 Author 리턴 필드에 기본 merge 함수를 자동으로 적용하게끔 한다.

페이지네이션 핸들링

필드가 배열을 가질 때, 배열의 결과를 페이지네이팅하는 것이 유용하다. 왜냐하면 모든 결과는 임의로 커질 수 있기 때문이다.

전형적으로, 쿼리는 명시된 페이지네이션 인자들을 포함한다:

  • 숫자 오프셋 또는 시작 ID를 사용하여 배열에서 시작할 위치
  • 단일 페이지에 리턴할 최대 요소 수

필드에 페이지네이션을 구현한 경우, 필드에 대한 readmerge 함수를 구현할 때 페이지 관련 인수를 염두에 두는 것이 중요하다:

const cache = new InMemoryCache({
  typePolicies: {
    Agenda: {
      fields: {
        tasks: {
          merge(existing: any[], incoming: any[], { args }) {
            const merged = existing ? existing.slice(0) : [];
            // 인수에 따라 들어오는 요소를 올바른 위치에 삽입
            const end = args.offset + Math.min(args.limit, incoming.length);
            for (let i = args.offset; i < end; ++i) {
              merged[i] = incoming[i - args.offset];
            }
            return merged;
          },

          read(existing: any[], { args }) {
            // 만약 캐시를 기록하기 전에 필드를 읽을 때, 함수는 undefined를 리턴할 것이고,
            // 이는 올바르게 필드가 누락되었음을 나타낸다.
            const page =
              existing && existing.slice(args.offset, args.offset + args.limit);
            // 기존 배열의 범위를 벗어난 페이지를 요청하는 경우, page.length는 0이 될 것이고,
            // 빈 배열 대신 undefined를 리턴해야한다.
            if (page && page.length > 0) {
              return page;
            }
          },
        },
      },
    },
  },
});

위 예제에서 보여주듯이, read 함수는 종종 동일한 인수를 역방향으로 처리하여 merge 함수와 협력해야한다.

주어진 Page가 args.offset에서 시작하는 대신 특정 엔티티 ID 다음에 시작되도록 하려면, 다음과 같이 mergeread 함수를 구현하고 readField 헬퍼 함수를 사용하여 기존 Task의 ID를 검사할 수 있다:

const cache = new InMemoryCache({
  typePolicies: {
    Agenda: {
      fields: {
        tasks: {
          merge(existing: any[], incoming: any[], { args, readField }) {
            const merged = existing ? existing.slice(0) : [];
            // 모든 기존 task ID들을 Set으로 가져오기
            const existingIdSet = new Set(
              merged.map((task) => readField("id", task))
            );
            // 기존 데이터에 이미 존재하는 들어오는 task들은 지운다.
            incoming = incoming.filter(
              (task) => !existingIdSet.has(readField("id", task))
            );
            // 들어오는 task 페이지 바로 앞에 있는 task의 인덱스를 찾기
            const afterIndex = merged.findIndex(
              (task) => args.afterId === readField("id", task)
            );
            if (afterIndex >= 0) {
              // afterIndex를 찾으면 해당 인덱스 뒤에 들어온 값들을 삽입
              merged.splice(afterIndex + 1, 0, ...incoming);
            } else {
              // 그렇지 않으면 기존 데이터 끝에 들어온 데이터를 삽입
              merged.push(...incoming);
            }
            return merged;
          },

          read(existing: any[], { args, readField }) {
            if (existing) {
              const afterIndex = existing.findIndex(
                (task) => args.afterId === readField("id", task)
              );
              if (afterIndex >= 0) {
                const page = existing.slice(
                  afterIndex + 1,
                  afterIndex + 1 + args.limit
                );
                if (page && page.length > 0) {
                  return page;
                }
              }
            }
          },
        },
      },
    },
  },
});

readField(fieldName)를 호출하면 현재 객체에서 지정된 필드의 값을 리턴하는 것을 기억하자. 객체를 readField에 두 번째 인수로 전달하면(readField('id', task)), readField는 대신 지정된 객체에서 지정된 필드를 읽는다. 위 예제에서 id 필드를 기존 Task 객체에서 읽을 때 들어오는 task 데이터의 중복을 제거할 수 있다.

위 페이지네이션 코드는 복잡하지만, 페이지네이션 전략을 구현한 후에는 필드 타입에 관계없이 해당 전략을 사용하는 모든 필드에 재사용할 수 있다. 예를 들어:

function afterIdLimitPaginatedFieldPolicy<T>() {
  return {
    merge(existing: T[], incoming: T[], { args, readField }): T[] {
      ...
    },
    read(existing: T[], { args, readField }): T[] {
      ...
    },
  };
}

const cache = new InMemoryCache({
  typePolicies: {
    Agenda: {
      fields: {
        tasks: afterIdLimitPaginatedFieldPolicy<Reference>(),
      },
    },
  },
});

merge 함수 비활성화하기

어떤 경우에는, 특정 필드에 대한 merge 함수를 완전히 비활성화해야할 수도 있다. 그렇게 하기 위해서는 merge: false를 다음과 같이 전달한다:

const cache = new InMemoryCache({
  typePolicies: {
    Book: {
      fields: {
        // 더이상 불필요하다!
        // author: {
        //   merge: true,
        // },
      },
    },

    Author: {
      merge: false,
    },
  },
});

key 인수 지정

필드가 인수를 허용하는 경우, 필드의 FieldPolicykeyArgs 배열을 지정할 수 있다. 이 배열은 필드의 리턴 값에 영향을 주는 key arguments가 어떤 것인지 나타낸다. 이 배열을 지정하면 캐시의 중복 데이터 양을 줄이는데 도움이 될 수 있다.

예제

스키마 Query 타입에 monthForNumber 필드가 포함되어 있다고 해보자. 이 필드는 제공된 number 인수가 주어지면 특정 month의 세부 정보를 리턴한다. number 인자는 이 필드의 핵심 인수이다:

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        monthForNumber: {
          keyArgs: ["number"],
        },
      },
    },
  },
});

키가 아닌 인수의 예로는 쿼리를 승인하는 데는 사용되지만 결과를 계산하는데는 사용되지 않는 access token이 있다.monthForNumberaccessToken 인수도 허용하는 경우, 해당 인수의 값은 어떤 월의 세부 정보가 반환되는지 영향을 주지 않는다.

기본적으로 필드의 모든 인수는 key arguments가 된다. 즉, 캐시는 특정 필드를 쿼리할 때 사용자가 제공하는 모든 고유한 인수의 값 조합에 대해 별도의 값을 지정한다.

필드의 key arguments를 지정하면, 캐시는 해당 필드의 나머지 인수가 key arguments가 아닌 것으로 이해한다. 이것은 key arguments가 아닌 인수가 변경될 때 캐시가 완전히 별도의 값을 지정할 필요가 없다는 것을 의미한다.

예를 들면, number 인수는 같지만 accessToken 인수는 다른 monthForNumber 필드를 사용하여 서로 다른 두 개의 쿼리를 실행한다고 하자. 이 경우 두 호출 모두 유일한 key argument에 동일한 값을 사용하기 때문에 두 번째 쿼리 응답이 첫 번째 쿼리 응답을 덮어씌우게 된다.

keyArgs 함수 제공하기

만약 특정 필드의 keyArgs를 더 세밀하게 제어해야하는 경우 인수 이름 배열 대신 함수를 전달할 수 있다. 이 keyArgs 함수는 두 개의 매개변수를 받는다:

  • 필드에 대한 모든 argument 값을 포함하는 args 객체
  • 기타 세부 정보를 제공하는 context 객체

자세한건 KeyArgsFunction문서 참고~

FieldPolicy API reference