Web/Apollo Client

Apollo Client Cache 톺아보기

Apollo Client를 사용하기 시작할 때 가장 처음으로 어려움을 겪는 부분이 cache 관련한 부분 아닐까 생각한다. (적어도 나는 그랬다.) 그래서 오늘은 개인적으로 Apollo Client의 caching(이하 캐싱)에 대해서 정리도 해볼 겸 추가적으로 공부하고자 포스팅을 하게 되었다.

 

개요

Apollo Client는 기본적으로 GraphQL의 모든 쿼리를 알아서 자동으로 캐싱한다. 기본 설정을 아무것도 건드리지 않았다면, 모든 쿼리 요청에 대해 캐싱된 데이터를 우선으로 응답한다. 요청이 발생했을 때 Apollo Client가 이 요청을 가로채서 실제로 서버로 요청을 보내지 않고 캐싱된 데이터를 응답으로 반환한다. 이 때문에 서버에서 실시간으로 바뀌는 값을 가져오고자 할 때는 난감한 경우_(이런 경우 해결할 수 있는 방법을 글 후반에서 설명하도록 하겠다.)_가 많지만, 이런 경우를 제외하면 서버에 요청을 보내지도 않고 즉시 응답을 받는다는 것은 보편적인 상황에서는 이득인 경우가 많다. 네트워크 요청 자체도 비용이기 때문에 아낄 수 있다면 좋고, 이에 따라 퍼포먼스 관련으로도 긍정적 효과를 보이기 때문이다.

 

그렇다면 이제 Apollo Client가 이런 캐싱을 내부적으로 어떻게 처리하고 있는지 알아보자. 캐싱을 잘 다루려면 내부 구조부터 파악하고 가는 게 좋다.

 

이미지 출처: Apollo Docs

 

Apollo Client는 내부적으로 InMemoryCache라고 불리는 cache 저장 공간을 두고 있다. 이 공간이 캐싱된 데이터를 저장해두는 공간이다. 쿼리를 통해서 응답을 받을 때마다 해당 응답의 정보를 이 공간에 저장하게 되는데, 각 응답을 객체로 변환하여 flat한 lookup table 구조로 저장한다. 왜 하필 flat한 lookup table 구조냐면, InMemoryCache 내부에 저장된 객체는 서로가 서로를 참조할 수 있어야 하기 때문에 원활하게 서로 참조할 수 있도록 하기 위해 이런 구조를 택했다. 문제는 쿼리를 통해서 받은 응답이 deeply nested한 구조를 가지고 있어서 항상 일관되게 flat한 구조로 변환될 수 없는 경우가 있다는 것이다. 이런 경우를 대비하기 위해서 Apollo Client는 응답을 객체로 변환하는 과정에서 normalization(이하 정규화) 과정을 거친다.

 

정규화

{
  "data": {
    "person": {
      "__typename": "Person",
      "id": "cGVvcGxlOjE=",
      "name": "Luke Skywalker",
      "homeworld": {
        "__typename": "Planet",
        "id": "cGxhbmV0czox",
        "name": "Tatooine"
      }
    }
  }
}

 

위와 같은 응답 객체를 Apollo Client가 정규화하는 순서를 살펴보면 다음과 같다.

 

  • 1. 객체 식별
    쿼리 응답을 통해 받은 모든 응답 객체를 식별한다.
    식별 과정에서 __typename 필드와 id 필드(또는 _id 필드)를 사용한다. 이에 따라 위 예제에서는 id가 cGVvcGxlOjE=인 Person 객체와 id가 cGxhbmV0czox인 Planet 객체, 이렇게 두 객체가 식별된다.
  • 2. Cache ID 생성
    이렇게 식별된 모든 객체에 대해 Cache ID를 생성한다.
    Cache ID는 InMemoryCache 내부에서 각각의 객체를 식별하는 용도로 사용된다. 기본적으로 식별에 사용했던 두 필드(보통 __typename필드와 id 필드)가 콜론으로 구분된 형태다. 즉, 위 예제에서는 각 객체의 Cache ID가 Person:cGVvcGxlOjE=와 Planet:cGxhbmV0czox로 생성된다.
  • 3. 객체 필드를 참조값으로 치환
    참조값으로 치환 가능한 모든 객체 필드에 대해 치환 과정을 거친다. (단, Cache ID를 생성하지 못하는 객체에 대해서는 치환 과정을 거치지 않는다. 참조값으로 치환할 수 없기 때문.) 따라서 위 예제 객체의 경우 아래와 같이 변한다.
{
  "__typename": "Person",
  "id": "cGVvcGxlOjE=",
  "name": "Luke Skywalker",
  "homeworld": {
    "__ref": "Planet:cGxhbmV0czox"
  }
}
  • 4. 정규화된 객체를 저장
    이 과정을 거친 모든 정규화된 객체들이 전부 InMemoryCache에 저장되게 된다. 이 모든 과정은 InMemoryCache에 저장되기 전에 진행되므로, 대부분의 경우 InMemoryCache에는 정규화된 객체만 존재하게 된다.

 

InMemoryCache에 객체를 저장하기 전에 정규화 과정을 거치게 되면 같은 ID를 가진 객체(같은 응답 객체)를 여러 번 저장하는 일 없이 InMemoryCache 내부에 unique하게 존재하도록 유지하고 이를 보장한다. 만약 새로운 응답 객체가 들어왔을 때 이미 캐싱된 데이터를 참조하고 있을 경우, InMemoryCache에 이미 저장된 참조값은 덮어쓰기되는 방식으로 캐시 업데이트가 진행된다.

 

이렇게 InMemoryCache에 정규화되어 저장된 캐시의 경우 Apollo Client DevTools를 통해 시각적으로 확인할 수 있다.

 

 

그런데 위 과정에서 대부분의 경우 InMemoryCache에는 정규화된 객체만 존재한다고 했었다. 이 대부분의 경우라는 건 결국 일부 경우에는 정규화되지 못한 객체가 InMemoryCache에 저장된다는 뜻이다. 앞서 말한대로 Cache ID를 생성할 수 없는 객체(id 필드나 _id 필드가 없는 객체)의 경우 참조값으로 치환할 수 없기 때문에 정규화를 할 수가 없다. 이렇게 정규화되지 못한 객체는 어쩔 수 없이 정규화되지 않은 상태로 InMemoryCache에 저장되고, 여러 번 저장되어 제대로 된 캐싱 기능을 제공하지 못하고 비효율적인 캐싱 경험을 제공한다. 따라서 이런 상황을 방지하고자 GraphQL 서버 개발자는 API를 만들 때 항상 id 필드를 제공하도록 해야 한다. 이건 서버 이야기니까 잠시 제쳐두고, 만약 당신이 API를 직접 손볼 수 없는 상황이라고 하더라도 걱정하지 말자. 이러한 특별한 유즈 케이스에 대응할 수 있도록 Apollo Client에는 개발자가 직접 Cache ID를 생성할 수 있도록 Cache ID 커스터마이징 기능을 제공하고 있다.

 

Cache ID 커스터마이징

Apollo 3.0 버전부터 @apollo/client 패키지에서 InMemoryCache를 import해 사용할 수 있도록 클래스를 제공하고 있다. InMemoryCache를 통해서 특별한 유즈 케이스에 대해 개발자가 여러 세부 설정들을 손볼 수 있는데, 우리는 Cache ID 커스터마이징을 위해 InMemoryCache에 TypePolicy를 새로 정의하도록 해보겠다.

 

본격적으로 시작하기 전에, Cache ID를 커스터마이징하겠다는 것 자체가 결국 id 필드 또는 _id 필드가 없거나 캐싱용으로 적절히 사용할 수 없는 상황이라는 뜻이기 때문에 앞으로 id 필드처럼 활용하기 위해 지정한 필드를 키필드(keyField)라고 할 것이다.

 

아래와 같은 응답이 들어왔다고 가정하자.

 

{
  __typename: "Product",
  upc: "020357122682",
  // ... (기타 정보)
}

 

upc 필드를 키필드로 지정하고 싶으면 아래처럼 InMemoryCache의 TypePolicy를 정의하면 된다.

 

const cache = new InMemoryCache({
  typePolicies: {
    Product: { // 정규화할 __typename 필드
      keyFields: ["upc"], // id로 활용할 필드
    },
  }
}

 

만약 키필드로 지정하고자 하는 필드가 여러 개 있을 경우 해당하는 필드 전체를 조합하여 유니크하게 사용할 수 있다.

 

// 응답
{
  __typename: "Person",
  name: "raejoonee",
  email: "crj0901@gmail.com",
  // ... (기타 정보)
}
const cache = new InMemoryCache({
  typePolicies: {
    Person: {
      keyFields: ["name", "email"], // name 필드와 email 필드의 조합으로 키필드를 사용한다.
    },
  }
}

 

nested한 필드를 키필드로 지정하고 싶으면 typePolicy를 아래처럼 정의할 수도 있다.

 

// 응답
{
  __typename: "Person",
  title: "개발자로 살아남기",
  author: {
    name: "박종천",
    email: "soubau@hotmail.com",
  },
  // ... (기타 정보)
}
const cache = new InMemoryCache({
  typePolicies: {
    Person: {
      keyFields: ["title", "author", ["name"]], // title 필드와 name 필드의 조합으로 키필드를 사용한다. (name 필드는 author 객체 내부에 존재한다.)
    },
  }
}

 

이렇게 정의한 키필드는 앞으로 정규화 과정에서 Cache ID를 생성하기 위한 ID로 사용된다.

 

활용

글 서두에서 서버에서 실시간으로 바뀌는 값을 가져오고자 할 때는 Apollo Client의 기본 캐싱 기능이 난감할 수 있다고 언급했었다. 이는 Apollo Client의 fetch policy를 별도로 손보지 않는 이상 이전에 요청된 내용과 완전히 같은 내용의 요청에 대해서는 캐싱된 응답을 우선 사용하도록 설정되어 있기 때문이다. 앞서 말한 문제를 해결하기 위해서는 fetch policy를 바꿔서 특정 유즈 케이스에서 캐싱된 응답을 사용하지 않도록 설정하는 방법도 있고, 아니면 캐싱된 (각 쿼리에 대한) 응답 결과를 업데이트하는 방법도 있는데 둘 다 소개해보도록 하겠다.

 

캐싱된 응답 업데이트

위에서 정규화 과정을 설명할 때, 새로운 응답 객체가 들어왔을 때 이미 캐싱된 데이터를 참조하고 있을 경우 InMemoryCache에 이미 저장된 참조값은 덮어쓰기된다는 점을 이용하는 방법이다. 즉, 새로운 응답 객체를 받아서 현재 캐싱되어 있는 쿼리 응답을 업데이트 하는 것이다. 단, 이 방법을 사용하려면 새로 응답받을 객체의 키필드와 캐싱된 응답 결과를 받을 때 요청했던 객체의 키필드가 같아야 하고 그 키필드의 값 또한 같다는 보장이 있어야 한다. 이 방법은 크게 polling 방식과 refetch 방식으로 구분된다. 두 방식 중 어떤 것을 사용해도 좋다. 상황마다 더 적절한 방식을 채택하면 된다.

 

Polling 방식

쿼리에 interval을 두어 해당 interval마다 캐싱된 응답 객체를 업데이트하는 방식이다. 더 자세히 말하면 서버로부터 데이터를 interval마다 요청하는 방식이다. 이 방식을 사용하면 클라이언트와 서버의 데이터가 서로 거의 실시간으로 동기화된다. React를 사용하고 있을 경우 useQuery 훅의 options 객체에 polling interval 주기를 설정하여 제공할 수 있다.

 

const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
    variables: { breed },
    pollInterval: 500,
  });

 

단위는 ms 단위이기 때문에 위 쿼리는 0.5초마다 서버로부터 데이터를 받아오게 된다. 참고로, pollInterval을 0으로 설정할 경우 정상적으로 작동하지 않는다. useQuery 훅의 result 객체에 startPolling 함수와 stopPolling 함수를 새로 정의하면 polling interval을 동적으로 설정할 수도 있다.

 

Refetch 방식

특정 상황에만 서버로부터 캐싱된 응답 객체를 업데이트하는 방식이다. 서버로부터 데이터를 지속적으로 요청하기보다는 특정 상황에서만 요청하고자 할 때 사용하면 된다. useQuery 훅에서 refetch 함수를 destructuring해서 사용하면 된다.

 

function DogPhoto({ breed }) {
  const { loading, error, data, refetch } = useQuery(GET_DOG_PHOTO, {
    variables: { breed }
  });

  if (loading) return null;
  if (error) return `Error! ${error}`;

  return (
    <div>
      <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
      <button onClick={() => refetch()}>Refetch!</button>
    </div>
  );
}

 

다음과 같이 refetch 함수에 인자로 새로운 변수를 제공할 수도 있다.

 

<button onClick={() => refetch({
  breed: 'dalmatian'
})}>Refetch!</button>

 

Fetch Policy

useQuery 훅의 options 객체에 fetch policy를 제공하면 Apollo Client가 요청할 때 사용하는 쿼리를 어떻게 fetch할지 (fetch할지 말지) 설정할 수 있다. 기본값은 cache-first다. 이 policy는 쿼리가 가장 처음 fetch될 때는 서버로 요청을 보내되, 이후부터는 항상 캐싱된 응답을 재사용하도록 한다. 이 policy가 마음에 들지 않는다면 다른 policy로 대체할 수 있다. Apollo Client에서 제공하는 policy는 다음과 같다.

 

이름설명

cache-first 쿼리가 가장 처음 fetch될 때는 서버로 요청을 보내되, 이후부터는 항상 캐싱된 응답을 재사용한다. 실시간 데이터를 받아오는 경우 직접 최적화해야 한다는 단점이 있지만, 보편적인 경우 최적의 성능을 보여준다. Apollo Client fetch policy의 기본값이다.
cache-only 절대 서버로 요청을 보내지 않고, 캐싱된 응답만 재사용한다. 캐싱된 응답이 없을 경우 에러가 발생한다.
cache-and-network 쿼리를 서버로 fetch함과 동시에 캐싱된 응답이 있는지 찾는다. 만약 캐싱된 응답의 키필드 값이 서버로부터 받아온 값과 다를 경우 캐시를 업데이트한다.
network-only 캐싱된 응답이 있는지 확인하지 않고, 일단 서버로 요청을 보낸다. 응답이 캐싱되지 않는다는 것은 아니다. 캐싱도 정상적으로 된다. 서버 데이터와의 일관성을 가장 중요하게 생각하는 경우 이 policy를 사용한다. 단, 다른 policy와 비교했을 때 성능이 좋지 않다.
no-cache 캐싱된 응답이 있는지 확인하지 않고, 일단 서버로 요청을 보낸다. 응답을 캐싱하지도 않는다.
standby cache-first와 비슷하지만, 캐싱된 값이 절대 업데이트되지 않고 개발자가 직접 업데이트해줘야 한다.