Apollo Client - Refetching

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

Refetching queries in Apollo Client

아폴로 클라이언트는 캐시를 업데이트해서 GraphQL 데이터를 로컬로 수정할 수 있도록 해준다. 하지만 가끔은 서버에서 쿼리를 리페칭함으로써 클라이언트사이드의 GraphQL 데이터를 업데이트하는 것이 더 간단할 수 있다.

이론적으로는 클라이언트에서 업데이트한 뒤에 모든 액티브 쿼리를 리패칭해야하지만, 쿼리를 선택적으로 리페칭하게되면 시간과 네트쿼으 대역폭을 절약할 수가 있다. InMemoryCache를 사용하면 최근 캐시 업데이트로 인해 무효화되었을 수 있는 액티브 쿼리를 확인할 수 있다.

로컬 캐시 업데이트와 리패칭을 함께 사용하면 더욱 좋은 조합이 된다: 어플리케이션에서 로컬 캐시 수정 결과를 즉시 표시하는 동시에, 백그라운드에서 리페칭하여 서버에서 최신 데이터를 가져올 수 있다. UI는 로컬 데이터와 리패치된 데이터의 차이점을 비교하여 리렌더링한다.

리패칭은 특히 mutation 이후에 일반적으로 실행해서, mutate 함수는 리패치되어야하는 쿼리를 특정하고 어떻게 리패치할 것인지와 같은 refetchQueriesonQueryUpdated 옵션을 받을 수 있다.

뮤테이션 밖에서 선택적으로 쿼리를 리패치하려면, ApolloClient의 메소드인 refetchQueries를 대신 사용할 수 있다.

client.refetchQueries

Refetch options

client.refetchQueries 메소드는 다음과 같은 타입스크립트 인터페이스의 형태인 options 객체를 가진다.

interface RefetchQueriesOptions<
  TCache extends ApolloCache<any>,
  TResult = Promise<ApolloQueryResult<any>>
> {
  updateCache?: (cache: TCache) => void;
  include?: Array<string | DocumentNode> | "all" | "active";
  onQueryUpdated?: (
    observableQuery: ObservableQuery<any>,
    diff: Cache.DiffResult<any>,
    lastDiff: Cache.DiffResult<any> | undefined
  ) => boolean | TResult;
  optimistic?: boolean;
}

updateCache

캐시된 필드를 리패치가 트리거 됨으로써 해당하는 필드들을 업데이트할 수 있는 함수

include

리패치할 특정 쿼리들이 들어갈 배열. 각 요소는 쿼리의 문자열 이름 또는 DocumentNode 객체가 들어갈 수 있다. 유사한 예로 뮤테이션에 있는 options.refetchQueries가 있다. "active" 또는 "all"을 넘겨주면 모든 액티브 쿼리들을 리패칭한다.

onQueryUpdated

options.updateCache의 영향을 받거나 options.include에 리스트되어있는 ObservableQuery가 한번씩 호출되는 콜백함수이다. 만약 onQueryUpdated가 제공되지 않으면 기본적으로 observableQuery.refetch()(ObservableQuery가 리패칭된)의 결과를 리턴한다. onQueryUpdated가 제공되면, 동적으로 각 쿼리가(observableQuery 또는 include, updateCache에 명시된 쿼리) 리페치되어야하는지를 여부를 (또는 어떻게) 결정할 수 있다. false를 리턴하면 연관된 쿼리가 리패치되는 것을 막는다.

optimistic

true인 경우, InMemoryCache의 임시적인 optimistic 레이어에서 options.updateCache 함수가 실행되고, 어떤 필드가 무효화되었는지 관찰한 후에 수정 사항을 캐시에서 삭제할 수 있다. 기본적으로는 false이다. options.updateCache가 캐시를 꾸준히 업데이트한다는 뜻.

Refetch results

client.refetchQueries 메소드는 onQueryUpdated가 리턴한 TResult 결과를 수집하고, onQueryUpdated가 제공되지 않으면 기본적으로는 TResult = Promise<ApolloQueryResult<any>>로 설정된다. Promise.all(results)를 사용하여 이러한 결과를 단일 Promise<TResolved[]>로 결합한다.

Promise.all의 프로미스 언래핑 동작 덕분에, 이 TResolved 타입은 TResultPromiseLike<TResolved> 또는 boolean인 경우를 제외하고는 TResult과 종종 동일한 타입이다.

리턴된 Promise 객체는 두 가지 유용한 프로퍼티를 가지고 있다.

queries

리패치된 ObservableQuery 객체의 배열

results

pending 상태의 프로미스를 포함해서 onQueryUpdated에서 리턴된 결과의 배열 또는 onQueryUpdated가 없을 때 기본적으로 제공되는 결과 배열이다. onQueryUpadted가 특정 쿼리에 대해 false를 리턴하는 경우, 해당 쿼리에 대한 결과가 제공되지 않는다. true인 경우, results를 포함한 Promise<ApolloQueryResult<any>>의 결과가 리턴된다.

이 두 가지 배열들은 각자가 패러랠하다: 같은 length를 가지고 있고, results[i]ObservableQuery에서 호출된 queries[i]에서 i에 해당하는 onQueryUpdated에서 생산된 result이다.

Refetch recipes

Refetching a specific query

특정 쿼리 이름으로 리패치를 하려면, include 옵션을 사용한다. include 옵션은 DocumentNode로도 해당하는 쿼리를 리패치할 수 있다.

await client.refetchQueries({
  include: ["SomeQueryName"],
});

await client.refetchQueries({
  include: [SOME_QUERY],
});

Refetching all queries

액티브 쿼리를 모두 리패치하려면 'active'를 전달한다. 아폴로클라이언트에서 관리하는 모든 쿼리를 리패치하려면 (심지어 observers도 없고, 어떤 언마운트된 컴포넌트의 쿼리여도) 'all'을 전달한다.

await client.refetchQueries({
  include: "active",
});

await client.refetchQueries({
  include: "all", // Consider using "active" instead!
});

Refetching queries affected by cache updates

updateCache 콜백에서 수행된 캐시 업데이트의 영향을 받는 쿼리를 리패치할 수 있다.

await client.refetchQueries({
  updateCache(cache) {
    cache.evict({ fieldName: "someRootField" });
  },
});

이렇게 하게되면, Query.someRootField에 종속되어있는 모든 쿼리를 리패치한다. 어떤 쿼리가 포함되어있는지 미리 알 필요가 없다. updateCache 안에서는 모든 캐시 operations의 조합이 허용된다. (writeQuery, writeFragment, modify, evict…)

updateCache에 의해 수행된 업데이트에 대한 변경사항은 기본적으로 캐시에 반영된다. client.refetchQueries가 관찰을 완료한 후 캐시를 변경하지 않고 즉시 삭제하는 기능을 사용하고 싶다면 temporary optimistic layer에서 수행할 수 있다.

await client.refetchQueries({
  updateCache(cache) {
    // 관련된 쿼리들을 리패치하게 해주는 함수
    // 여기서 리패치되어 결과를 받지만...
    cache.evict({ fieldName: "someRootField" });
  },

  // 아래처럼 설정함으로써 Query.someRootField가 temporary optimistic layer에서만 evict한다.
  // 리패치해서 받는 결과가 실제 캐시에는 반영되지 않음 (기존 캐시로 반영..?)
  optimistic: true,
});

캐시 데이터를 실제로 변경하지 않고 캐시를 업데이트하는 또 다른 방법은 cache.modifyINVALIDATE 센티널 객체를 사용하는 것이다.

await client.refetchQueries({
  updateCache(cache) {
    cache.modify({
      fields: {
        someRootField(value, { INVALIDATE }) {
          // Query.someRootField를 포함하는 쿼리를 업데이트한다.
          // 실제로 캐시에서 해당 값을 변경하지 않고 업데이트한다.
          return INVALIDATE;
        },
      },
    });
  },
});

client.refetchQueries가 소개되기 전에는, INVALIDATE 센티넬이 그렇게 유용하지 않았다. 왜냐하면 fetchPolicy: "cache-first"를 가지고 있는(캐시 우선인) 무효화된(유효하지 않은?) 쿼리들은 일반적으로 변경되지 않은 결과를 다시 읽어와서 네트워크 요청을 수행하지 않도록한다. client.refetchQueries 메소드는 애플리케이션 코드에서 이 무효화 시스템을 더욱 쉽게 접근할 수 있어 무효화된 쿼리의 리패치 동작을 제어할 수 있다…(INVALIDATE된 애들로 인해서 쉽게 리패치 동작을 제어할 수 있게 되어 유용해졌다고 말하는 듯. cache-first일 때 무효화된 데이터들이 있으니 네트워크 요청은 날리지 않을 것.. 계속 무효화된 캐시를 바라볼 것이라ㅡ 결국 리패치를 활용해서 업데이트된 데이터를 바라보게 되기 때문에)

위 모든 예제에서 include나, updateCache를 사용하는 것과는 상관없이 client.refetchQueries는 네트워크에서 영향을 받는 쿼리를 리패치하고, 그 결과인 Promise<ApolloQueryResult<any>>Promise<TResolved[]>에 포함시켜 리턴한다.

특정 쿼리가 includeupdateCache에 둘다 포함되어 있다면, 그 쿼리는 한번 리패치 된다. 다시 이야기하면, include 옵션을 사용하면 updateCache에 포함된 쿼리에 상관없이 특정 쿼리가 항상 포함되도록 하는 좋은 방법이다.(두번 호출할까봐 신경쓰지 않아도됨!)

Refetching selectively

개발환경에서는 아마도 적절한 쿼리가 리패치되는 것을 정확히 확인하고 싶을 것이다. 각 쿼리가 리패칭되기 전에 인터셉트하려면, onQueryUpdated 함수를 명시하는 방법을 쓸 수 있다.

const results = await client.refetchQueries({
  updateCache(cache) {
    cache.evict({ fieldName: "someRootField" });
  },

  onQueryUpdated(observableQuery) {
    // Logging 이나 debugger breakpoints를 개발환경에서 사용하면
    // client.refetchQueries가 어떤 작업을 하는지 이해하기에 유용하다.
    console.log(`Examining ObservableQuery ${observableQuery.queryName}`);
    debugger;

    // onQueryUpdated가 제공되지 않은 경우, 기본적인 리패칭 동작을 이행하도록 한다.
    return true;
  },
});

results.forEach((result) => {
  // 이 리턴값들은 ApolloQueryResult<any> 객체들이고, 네트워크에서 리페칭하여 갖고온 값들이다.
});

이 예제에서 어떻게 client.refetchQueries의 리패칭 동작을 바꾸지 않고 onQueryUpdated 함수를 추가하는지 보여준다. 이런 방식으로 순수하게 진단 또는 디버깅 목적으로만 onQueryUpdated를 사용할 수 있다.

만약 포함될 수 있는 특정 쿼리들을 건너뛰려면 onQueryUpdated에서 false를 리턴한다.

await client.refetchQueries({
  updateCache(cache) {
    cache.evict({ fieldName: "someRootField" });
  },

  onQueryUpdated(observableQuery, { complete, result, missing }) {
    console.log(
      `Examining ObservableQuery ${
        observableQuery.queryName
      } whose latest result is ${JSON.stringify(result)} which is ${
        complete ? "complete" : "incomplete"
      }`
    );

    if (shouldIgnoreQuery(observableQuery)) {
      return false;
    }

    // 네트워크에서는 쿼리를 조건없이 리패칭한다.
    return true;
  },
});

ObservableQuery가 충분한 정보로 제공되지 않는 경우에는, ObservableQuery의 두번째 인자로 전달된 Cache.DiffResult 객체를 사용해서 쿼리의 최신 결과와 쿼리의 완성 및 누락된 필드를 테스트할 수 있다.

await client.refetchQueries({
  updateCache(cache) {
    cache.evict({ fieldName: "someRootField" });
  },

  onQueryUpdated(observableQuery, { complete, result, missing }) {
    if (shouldIgnoreQuery(observableQuery)) {
      return false;
    }

    if (complete) {
      // 네트워크에서 무조건 리패치하는 것이 아닌 선택한 fetchPolicy에 따라 쿼리를 업데이트한다.
      return observableQuery.reobserve();
    }

    // 네트워크에서 무조건 리패치한다.
    return true;
  },
});

onQueryUpdated는 쿼리를 동적으로 필터할 수 있기 때문에, 위에서 언급했듯이 include 옵션을 사용해서 벌크로 사용하는 것도 잘 어울린다.

await client.refetchQueries({
  // 모든 액티브 쿼리를 디폴트로 포함시키는 옵션이다.
  // onQueryUpdated를 사용하여 해당 쿼리를 필터링하는 것이 아니면 권장하지 않는다.
  include: "active";

  // 동적 필터링을 허용하여 모든 액티브 쿼리를 한번씩 호출한다.
  onQueryUpdated(observableQuery) {
    return !shouldIngoreQuery(observableQuery);
  },
});

Handling refetch errors

위 예제에서 우리는 await client.refetchQueries(...)를 통해 모든 리패칭된 쿼리를 최종 ApolloQueryResult<any> 결과들로 알아낼 수 있다. 이 조합된 프로미스는 Promise.all로 만들어져서, 하나의 실패가 일어나면 모든 Promise<TResolved[]>를 rejects하여 다른 성공한 결과들을 숨길 수 있다. 이것이 문제가 된다면 client.refetchQueries에서 리턴해주는 queriesresults 배열을 사용할 수 있다.

const { queries, results } = client.refetchQueries({
  // ...
});

const finalResults = await Promise.all(
  results.map((result, i) => {
    return Promise.resolve(result).catch(error => {
      console.error(`Error refetching query ${queries[i].queryName}: ${error}`);
      return null; // 프로미스 reject가 일어난 경우 무시하도록 한다.
    });
  })
});

미래에는, client.refetchQueries 메소드에 추가 입력 옵션이 추가될 수 있다. 결과 객체에 프로퍼티를 추가하여 프로미스 관련 프로퍼티와 queriesresults 배열을 보완할 수 있을 것이다.

만약 어떤 새로운 client.refetchQueries 입력 옵션 또는 결과 프로퍼티들이 유용할 것 같으면 언제든지 이슈를 개설하거나 사용 사례를 설명하는 토론을 시작해주세요.

Corresponding client.mutate options

뮤테이션 이후 리패칭을 하려면, client.refetchQueries를 사용하는 것 대신 client.mutate에서 client.refetchQueries와 비슷한 옵션을 제공한다. 왜냐하면 뮤테이션이 진행하는 중 어떤 특정 시간에 리패칭이 실행되는 것이 중요하기 때문이다.

역사적인 이유로, client.mutate 옵션은 새로운 client.refetchQueries 옵션과 조금 다르지만, 내부적인 구현은 충분히 동일하다. 다음과 같은 옵션을 보고 해석하여 사용할 수 있다.

options.refetchQueries

client.refetchQueriesoptions.include와 같이 구현

options.update

client.refetchQueriesoptions.updateCache와 같이 구현

options.onQueryUpdated

client.refetchQueriesoptions.onQueryUpdated와 같이 구현

options.awaitRefetchQueries

client.refetchQueriesoptions.onQueryUpdated가 프로미스로 리턴됨