본문 바로가기

Programming/React, Angular, GraphQL

GraphQL Apollo Client - 캐싱 전략 (1편)

해당 글은 GraphQL - Apollo Client의 캐싱 전략에 대한 글이다.

 

모든 ApolloClient 인스턴스에서 InMemoryCache를 사용한다. Apollo Client는 GraphQL query의 결과들을 로컬, 정규화된(normalized), in-memory cache에 저장한다. 네트워크 요청을 전송하지 않고도 이미 캐시된 데이터를 사용하여 즉각 응답한다.

 

InMemoryCache는 오브젝트들이 서로 참조가능하도록 flat lookup table형태로 데이터를 저장한다. 동일한 객체의 다른 필드를 가져오는 경우 하나의 캐시된 객체에는 다수의 쿼리 결과 필드를 포함할 수도 있다. 캐시는 flat한 구조지만, GraphQL query로부터 반환되는 객체는 flat하지 않다. InMemoryCache가 중첩된 데이터를 flat하게 만들기 위해서 정규화(normalize)를 진행하는데 조금 더 자세히 알아보자.

 

글의 전문은 여기서 볼 수 있다.

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

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

 

Data normalization

Apollo Client가 쿼리 응답 데이터를 받을때 아래와 같은 일을 한다.

  1. 객체 확인 캐시는 쿼리 응답에 포함된 객체를 확인한다. 위의 예제에 따르면
    1. Person with id cGVvcGxlOjE
    2. Planet with id cGxhbmV0czox
  2. 캐시 ID 생성 각각의 객체에 캐시 ID를 생성한다. InMemoryCache에 있는 동안 특정 객체를 고유하게 식별한다. 캐시ID의 형식은 보통 __typename:id 형태가 된다. 위의 예제에 따르면
    1. Person:cGVvcGxlOjE=
    2. Planet:cGxhbmV0czox
      캐시 ID 형식을 변경할 수도 있다. TypePolicy정의를 통해서 개별적으로 선언할 수 있다.
const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      // upc 필드 기준으로 식별
      keyFields: ["upc"],
    },
    Person: {
      // name+email을 고유하게 식별할 키로 선정
      keyFields: ["name", "email"],
    },
    Book: {
      // title + author.name
      keyFields: ["title", "author", ["name"]],
    },
    AllProducts: {
      // Singletone 타입인 경우 고유하게 식별할 필드가 없다면 비워둘 수도 있다.
      keyFields: [],
    },
  },
});

 

3. 객체 필드를 참조로 대체한다.

캐시의 객체를 포함하는 각 필드를 객체에 대한 참조로 값을 대체한다.

 

before

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

 

after

homeworld 필드에는 정규화된 Planet 객체에 대한 정보를 담고있다. 만약 캐시 ID 생성에 실패할 경우 기존 객체 정보를 그대로 담고있다.

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

 

만약, 동일한 homeworld를 가진 다른 사용자가 쿼리 요청을 보낸다면, 정규화된 Person 객체의 homeworld 필드에는 이미 캐시된 homeworld 객체를 정보를 갖게된다. 정규화 작업을 통해 데이터 중복을 줄일 수 있고, 로컬 데이터를 서버의 최신 상태로 유지할 수 있도록 도와준다.

 

4. 정규화된 객체 저장

정규화된 객체에 대한 정보는 캐시의 flat lookup 테이블에 저장된다.

캐시 데이터 읽고 쓰기

Apollo Client cache를 사용하면 GraphQL server와 통신하지않고도 데이터를 읽고 쓸 수 있다. 이전에 서버로부터 받아온 데이터와 로컬에서만 사용가능한 데이터끼리 상호 작용할 수 있다. Apollo Client는 다양한 전략을 통해 캐시된 데이터를 사용할 수 있도록 지원한다.

 

GraphQL Query 사용하기

remote, local 데이터 모두 관리할 때 사용한다.

  • readQuery
    캐시에서 GraphQL query를 바로 실행시킬 수 있다.
const READ_TODO = gql`
  query ReadTodo($id: ID!) {
    todo(id: $id) {
      id
      text
      completed
    }
  }
`;

// 캐시된 값 중에서 ID 5번값의 아이템 정보를 가져온다.
const { todo } = client.readQuery({
  query: READ_TODO,
  // 일치하지않는 타입인 경우 null을 반환한다.
  variables: {
    id: 5,
  },
});

이때 주의할 점은 당연하겠지만, id:5에 해당하는 값이 캐시되어있어야 한다.

{
  ROOT_QUERY: {
	  // id:5에 해당하는 값이 존재한다.
    'todo({"id":5})': {
      __ref: 'Todo:5'
    }
  },
  'Todo:5': {
    // ...
  }
}

만약, 캐시에 저장된 값이 없다면 readQuery는 null을 반환할 것이다. GraphQL server로부터 데이터를 가져오려고 시도하지 않는다.

반환된 객체를 바로 수정해서는 안된다. 캐시된 데이터를 수정하고싶다면, https://www.apollographql.com/docs/react/caching/cache-interaction/#combining-reads-and-writes 를 참고하자.

 

readQuery는 GraphQL server 스키마에 정의되지않은 필드도 포함될 수 있다. local-only fields라고 해서, 클라이언트 사이드에서만 사용할 수 있는 필드를 정의할 수 있다.

(ex) localStorage로부터 읽어온 데이터 https://www.apollographql.com/docs/react/local-state/managing-state-with-field-policies/

query ProductDetails($productId: ID!) {
  product(id: $productId) {
    name
    price
    isInCart @client # This is a local-only field
  }
}

const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      fields: {
        isInCart: {
	        // isInChart 필드에 대해서 read 함수 정의
	        // read 기능이 있는 필드를 쿼리할 때마다 캐시는 아래 함수를 호출해서 값을 채운다.
          read(_, { variables }) {
            return localStorage.getItem('CART').includes(
              variables.productId
            );
          }
        }
      }
    }
  }
});
  • writeQuery
    GraphQL query와 일치하는 형태의 데이터를 캐시에 작성할 수 있도록 한다.
client.writeQuery({
  query: gql`
    query WriteTodo($id: Int!) {
      todo(id: $id) {
        id
        text
        completed
      }
    }`,
  data: { // 작성할 데이터값을 추가한다.
    todo: {
      __typename: 'Todo',
      id: 5,
      text: 'Buy grapes 🍇',
      completed: false
    },
  },
  variables: {
    id: 5
  }
});

writeQuery를 통해 작성된 캐시 데이터는 GraphQL server에 전송, 반영되지 않는다. 만약 현재 환경을 다시 로딩한다면, 해당 데이터들이 사라진다. schema에 없는 필드도 추가할 수 있다.

// BEFORE
{
  'Todo:5': {
    __typename: 'Todo',
    id: 5,
    text: 'Buy oranges 🍊',
    completed: true,
    dueDate: '2022-07-02'
  }
}

// AFTER
{
  'Todo:5': {
    __typename: 'Todo',
    id: 5,
    text: 'Buy grapes 🍇',
    completed: false,
    dueDate: '2022-07-02'
  }
}

이처럼 ID:5 에 해당하는 객체에 대한 캐시 정보가 이미 존재한다면, writeQuery는 data에 명시된 값으로 덮어쓴다. (명시되지않은 값들은 유지된다.)

GraphQL fragments 사용하기

캐시된 객체에 있는 필드를 전체 쿼리를 구성하지않고 바로 접근할 때 사용한다. 정규화된 그 어떤 캐시 객체에 GraphQL fragments를 사용하여 캐시 데이터를 읽고 쓸 수 있다. 캐시된 데이터에 대한 “random access”가 read,writeQuery보다 더 많이 제공되고, 유효한 쿼리가 필요하다.

  • readFragment
const todo = client.readFragment({
  id: 'Todo:5', // 필수!
  // query 필드가 유효해야함
  fragment: gql`
    fragment MyTodo on Todo {
      id
      text
      completed
    }
  `,
});

readQuery와 동일하게 readFragment로도 캐시 데이터를 가져올 수 있다. readQuery와 다른 점은 id 필드가 필수인데, 이는 캐시 ID를 나타낸다. 위에서도 설명했듯이, 캐시 ID의 기본 형식은 __typename:id 인데, 커스터마이징도 가능하다.

ID 5를 가진 캐시된 Todo 객체가 없거나, ID 5를 가진 캐시된 Todo 객체는 있으나 text나 completed 필드중 어느 하나라도 빠졌다면, null을 반환한다.

  • writeFragment
    readFragment와 동일하게 writeFragment 메소드를 통해서 캐시에 데이터를 작성할 수 있다. writeQuery와 마찬가지로 수정하는 값이 GraphQL 서버로 전송되지는 않는다. 현재 환경을 다시 로딩하면 변경점들이 없어진다.
client.writeFragment({
  id: 'Todo:5',
  fragment: gql`
    fragment MyTodo on Todo {
      completed
    }
  `,
  data: {
    completed: true,
  },
});

현재 활성화된 쿼리를 포함해서 Apollo Client cache를 구독하는 것들은 이 변경사항을 확인하고, 어플리케이션 UI의 값을 업데이트한다.

  • watchFragment

GraphQL 쿼리에서 특정 fragment의 변경사항을 watch하는데 사용된다. GraphQL 쿼리에 의해 반환된 데이터가 변경될 때마다 업데이트를 수신한다. Observable을 반환하므로, 이를 구독해서 변경 사항을 업데이트 받을 수 있다.

import React from 'react';
import { useApolloClient, gql } from '@apollo/client';

const USER_DETAILS_FRAGMENT = gql`
  fragment UserDetails on User {
    name
    email
  }
`;

const UserDetailsComponent = () => {
  const client = useApolloClient();

  // 사용자 정보 fragment를 watch하여 업데이트를 수신
  const { data } = client.watchFragment({
    id: 'User:1', // 사용자의 고유 ID
    fragment: USER_DETAILS_FRAGMENT,
  });

  return (
    <div>
      <h2>User Details</h2>
      <p>Name: {data.name}</p>
      <p>Email: {data.email}</p>
    </div>
  );
};

export default UserDetailsComponent;

GPT가 작성한 예시인데, watchFragment를 통해서 User:1 ID에 해당하는 캐시를 지켜보고, 값이 변경될 때마다 자동으로 UI 업데이트가 진행된다. 실시간으로 업데이트되어야하는 reactive한 데이터에 한해서 적합하다.

  • useFragment
    특정 fragment만 추출하여 사용한다. 특정 데이터만 필요할 때 주로 사용한다.

cache.modify 사용하기

GraphQL을 사용하지않고, 캐시된 데이터 값을 바꾸거나, 필드 전체를 삭제할 때도 사용한다. 수정된 필드가 있다면, 현재 활성화된 쿼리들을 모두 새로고침한다. (만약 broadcast: false 옵션을 넘긴다면, 새로고침되지 않는다.)

writeQuery, writeFragment와 다른점이 있다. 무조건 사용자가 명시한 값으로 덮어쓰게 되며, 캐시에 존재하지 않는 필드는 수정할 수 없다.

 

정리해보자면

  • writeQuery: 전체 쿼리 결과를 캐시에 작성할 때 사용
  • writeFragment: 특정 fragment 데이터를 캐시에 작성할 때 사용
  • cache.modify: 캐시의 특정 부분을 수정할 때 사용

cache.modify는 몇가지 파라미터 값이 필요하다.

  1. id: 수정할 캐시 객체의 ID (cache.identify 함수를 사용하는 것을 권장)
  2. fields: 각 필드별로 값을 수정할 때 실행할 함수 map
  3. (선택) broadcast 여부, optimistic 여부
cache.modify({
  id: cache.identify(myObject),
  // 만약 개별 필드에 대해서 수정 함수를 작성하지 않는다면, 해당 필드는 수정되지않는다.
  fields: {
    name(cachedName) {
      return cachedName.toUpperCase();
    },
  },
  /* broadcast: false // Include this to prevent automatic query refresh */
});

만약, scalar, enum, 혹은 base type의 목록을 포함한 필드에 수정할 함수를 정의한다면 해당 필드의 기존 값(value)을 정확하게 전달받게 된다. quantity 필드에 대한 수정 함수를 정의하고 현재 필드의 값이 5라면, 수정 함수에는 5가 전달된다.

 

하지만 객체 타입이나 객체 목록을 포함한 필드의 수정 함수를 정의해야된다면, 객체는 참조(references)로 표현된다. 각 참조는 캐시 ID를 통해 해당 필드에 있는 객체 값을 가리킨다. 만약, 수정 함수에서 다른 참조를 반환하면, 해당 필드에 포함된 다른 캐시된 객체가 변경되며 원래 캐시된 객체의 데이터는 수정되지 않는다.

import { gql, useApolloClient } from '@apollo/client';

const MODIFY_CART_ITEM = gql`
  mutation ModifyCartItem($id: ID!, $quantity: Int!) {
    modifyCartItem(id: $id, quantity: $quantity) {
      id
      quantity
    }
  }
`;

const Cart = () => {
  const client = useApolloClient();

  const handleIncreaseQuantity = (itemId) => {
    // 아이템의 캐시 ID를 이용하여 해당 아이템의 참조를 가져옴
    const cartItemRef = client.cache.identify({ __typename: 'CartItem', id: itemId });

    // 캐시 수정: 해당 아이템의 수량을 증가시킴
    client.cache.modify({
      id: cartItemRef,
      fields: {
        quantity: (existingQuantity) => existingQuantity + 1, // 기존 수량에 1을 더함
      },
    });
  };
};

GPT가 만든 예제를 살펴보자. 캐시 ID를 통해 해당 아이템의 참조(reference)를 가져오고 해당 참조를 수정한다. 이렇게 하게되면 기존의 캐시된 객체를 직접 수정하는 것이 아닌, 새로운 참조를 반환하게 된다. 따라서, 다른 곳에서 해당 아이템을 참조하고 있더라도 영향을 받지않고 이벤트를 처리할 수 있다.

수정 함수(modifier function)

수정 함수는 두번째 파라미터로 유용한 유틸들을 포함하는 객체 형태며, 선택적이다.readField, DELETE와 같은 Modifier function API를 사용해서 추가 동작을 적용시킬 수 있다.

modifier function api: https://www.apollographql.com/docs/react/api/cache/InMemoryCache/#modifier-function-api

 

예시를 보면서 확인해보자.

 

  • 리스트에서 아이템 삭제하기
    예시를 들어보자. 블로그 애플리케이션을 개발하는데, 각 Post에는 Comments 배열이 있다고 가정해보자. paginated된 Post의 Comments중에서 특정 Comment를 삭제해보자.
const idToRemove = 'abc123';

cache.modify({
  id: cache.identify(myPost),
  fields: {
    comments(existingCommentRefs, { readField }) {
      return existingCommentRefs.filter(
        commentRef => idToRemove !== readField('id', commentRef)
      );
    },
  },
});

현재 존재하는 Comment List에서 삭제하고자하는 id와 일치하지 않으면 남기도록 구현되어있다.

  • 리스트에 아이템 추가하기
const newComment = {
  __typename: 'Comment',
  id: 'abc123',
  text: 'Great blog post!',
};

cache.modify({
  id: cache.identify(myPost),
  fields: {
    comments(existingCommentRefs = [], { readField }) {
      const newCommentRef = cache.writeFragment({
        data: newComment,
        fragment: gql`
          fragment NewComment on Comment {
            id
            text
          }
        `
      });

      // 만약 새 comment가 캐시에 있을 경우, 추가하지 않아도 됨
      if (existingCommentRefs.some(
        ref => readField('id', ref) === newComment.id
      )) {
        return existingCommentRefs;
      }

      return [...existingCommentRefs, newCommentRef];
    }
  }
});
  1. writeFragment를 통해서 newComment를 캐시에 저장하면서 반환된 참조(reference) 값을 newCommentRef 변수에 저장한다.
  2. 기존 캐시에 방금 생성된 참조값이 존재하는지 확인하고 있는 경우 바로 반환한다.
  • mutation 이후에 캐시 업데이트하기

options.data 값과 함께 writeFragment 함수를 호출하면 캐시 ID 필드없이 확인할 수 있다. options.id 값을 통해 특정 캐시를 확실하게 명시하거나, options.data를 통해서 파악하도록 제공해야된다.

const [addComment] = useMutation(ADD_COMMENT, {
  update(cache, { data: { addComment } }) {
    cache.modify({
      id: cache.identify(myPost),
      fields: {
        comments(existingCommentRefs = [], { readField }) {
          const newCommentRef = cache.writeFragment({
            data: addComment,
            fragment: gql`
              fragment NewComment on Comment {
                id
                text
              }
            `
          });
          return [...existingCommentRefs, newCommentRef];
        }
      }
    });
  }
});

useMutation 함수는 Comment를 생성하고, 캐시에 등록도 하지만, 어떤 Post의 목록에 추가해야되는지 알지 못한다. 따라서, update 콜백 함수를 통해서 call.modify를 호출한다. useMutation을 통해서 이미 캐시에 등록되어있으므로, cache.writeFragment는 현재 존재하는 캐시 정보를 그냥 반환한다.

  • 캐시된 오브젝트로부터 필드 삭제하기
cache.modify({
  id: cache.identify(myPost),
  fields: {
    comments(existingCommentRefs, { DELETE }) {
      return DELETE;
    },
  },
});

DELETE 옵션을 사용하여 특정 캐시된 객체를 삭제할 수 있다.

  • 캐시된 객체의 필드 무효화하기

일반적으로 필드의 값을 변경하거나 삭제하는 행동을 통해서 필드를 무효화할 수 있는데 이전에 필드를 소비했다면(consume) 지켜보고 있는 쿼리들을 다시 읽게 한다. cache.modify를 사용하면 값을 수정하거나 삭제하지않고도 필드를 무효화시킬 수 있따. INVALIDATE sentinel을 사용하면 된다.

cache.modify({
  id: cache.identify(myPost),
  fields: {
    comments(existingCommentRefs, { INVALIDATE }) {
      return INVALIDATE;
    },
  },
});

주어진 객체에서 모든 필드를 무효화시키고 싶다면, INVALIDATE 옵션을 modifier 함수에 전달하면 된다.

cache.modify({
  id: cache.identify(myPost),
  fields(fieldValue, details) {
    return details.INVALIDATE;
  },
});

객체의 캐시 ID 알아내기

만약 커스텀 캐시 ID를 사용하고 있다면, cache.identify 메소드를 통해서 알아내고자하는 타입 객체의 캐시 ID를 알아낼 수 있다.

const invisibleManBook = {
  __typename: 'Book',
  isbn: '9780679601395', // 캐시 ID로 사용하는 key 필드값이라고 가정
  title: 'Invisible Man',
  author: {
    __typename: 'Author',
    name: 'Ralph Ellison',
  },
};

 

id 필드가 없어서 어떤 필드가 key인지 알지 못하는 상황이다. isbn이 키 값이라는 것을 몰라도 cache.identify 메소드를 통해서 캐시를 찾아낼 수 있다.

const bookYearFragment = gql`
  fragment BookYear on Book {
    publicationYear
  }
`;

const fragmentResult = cache.writeFragment({
  id: cache.identify(invisibleManBook),
  fragment: bookYearFragment,
  data: {
    publicationYear: '1952'
  }
});

캐시는 Book 타입의 캐시 ID에 사용되는 필드가 isbn인 것을 알기 때문에 cache.identify 메소드로 알아낼 수 있다. 하지만 위 예제는 isbn 이라는 단일 필드를 사용하기때문에 간단하지만, 여러 필드의 조합으로 캐시 ID를 지정하는 경우가 많다. 예를 들면 isbn+title 의 조합이 될 수도 있다. 따라서 cache.identify 를 사용하지않고 원하는 객체의 캐시 ID를 알아내는것은 훨씬 어렵다. 즉, cache.modify를 적극 권장한다.

 

이번 편에서는 Apollo Client의 캐싱 전략에 대해 알아봤고, 어떻게 캐시에 접근하고, 업데이트할 수 있는지 확인해 보았다. 캐싱 전략 2편에서는 가비지 컬렉션, 메모리 관리 방법에 대해 알아보려고 한다.

반응형