🔷 GraphQL5. 캐시 & 성능03 · Apollo InMemoryCache

03 · Apollo InMemoryCache — typePolicies가 곧 캐시 정책이다

질문: Apollo Client에서 캐시는 실제로 어떻게 동작하나? typePolicies·fieldPolicies·merge·read는 각각 무엇을 푸는가? 한 줄 답: InMemoryCache는 정규화 캐시의 생산용 구현체이고, typePolicies 한 객체가 cache key·병합 전략·필드 읽기·페이지네이션·cache redirect까지 5가지 결정을 한 곳에 모은다.


Pyramid Top

02가 정규화의 원리였다면, 이번 문서는 그 원리를 실제 코드에 어떻게 옮기는가다. Apollo Client는 npm 다운로드 기준 가장 널리 쓰이는 GraphQL 클라이언트이고, 그 핵심은 InMemoryCache 한 클래스다. 이 클래스가 잘못 설정되면 — 데이터 손실·무한 refetch·optimistic update 실패·페이지네이션 깨짐이 모두 한꺼번에 온다. 이 문서는 그 5가지 사고 자리에 각각 어떤 옵션이 대응하는지를 한 표로 정리한다.


사고 흐름


Why — InMemoryCache 한 객체가 풀어야 하는 5가지

풀어야 할 문제옵션 위치한 줄 답
같은 entity 식별typePolicies.X.keyFieldstypename + 어떤 필드를 key로 쓸까
부분 응답 병합fieldPolicies.X.f.merge같은 위치에 또 들어오면 어떻게 합칠까
읽기 시 가공fieldPolicies.X.f.read캐시에서 꺼낼 때 변환
페이지네이션fieldPolicies.X.feed.{keyArgs, merge, read}무한 스크롤·offset·cursor
참조 가져오기cache.readFragment / useFragment트리 전체가 아니라 노드만 구독

이 5개를 한 객체에 다 적는다.


How — typePolicies의 해부

import { InMemoryCache } from "@apollo/client";
 
const cache = new InMemoryCache({
  typePolicies: {
    // ① type 레벨 - cache key
    Book: {
      keyFields: ["isbn"],
    },
    User: {
      keyFields: ["id", "tenantId"], // 복합 키
    },
    SiteConfig: {
      keyFields: false, // singleton — 정규화 안 함
    },
 
    // ② field 레벨 - merge / read / pagination
    Query: {
      fields: {
        feed: {
          // keyArgs: 같은 필드를 *다른 캐시 엔트리*로 분리할 인자
          keyArgs: ["category"],
          // merge: 새 응답을 기존 캐시와 합치는 법
          merge(existing = [], incoming, { args }) {
            const { offset = 0 } = args ?? {};
            const merged = existing.slice();
            for (let i = 0; i < incoming.length; ++i) {
              merged[offset + i] = incoming[i];
            }
            return merged;
          },
          // read: 캐시에서 읽을 때 가공
          read(existing, { args }) {
            return existing?.slice(args?.offset ?? 0, (args?.offset ?? 0) + (args?.limit ?? 10));
          },
        },
      },
    },
 
    // ③ field-level read - computed field
    Person: {
      fields: {
        fullName: {
          read(_, { readField }) {
            return `${readField("firstName")} ${readField("lastName")}`;
          },
        },
      },
    },
  },
});

핵심 3가지 결정자를 한 줄씩:

  • keyFields — 정규화 키 결정자. 잘못 두면 동일 entity가 두 키로 분리되어 일관성 깨짐.
  • merge(existing, incoming) — 같은 위치에 또 들어왔을 때. 기본은 완전 덮어쓰기. 페이지네이션·partial fetch에서 반드시 정의해야 함.
  • keyArgsfield 자체의 캐시 분기 인자. feed(category:"news")feed(category:"music")다른 entry로.

주의: merge를 정의 안 한 채로 같은 필드부분 응답이 또 도착하면, Apollo는 경고를 출력하며 전체를 덮어쓴다. partial fetch 정책이 있는 모든 필드는 명시적 merge가 사실상 의무다.


What — 5가지 운영 도구

optimistic update — 응답 도착 전 미리 캐시에 쓰기

useMutation(LIKE_POST, {
  variables: { postId: 1 },
  optimisticResponse: {
    likePost: {
      __typename: "Post",
      id: 1,
      likeCount: post.likeCount + 1,
      isLiked: true,
    },
  },
});

서버 응답 전에 예측 응답을 캐시에 써둔다. → UI가 즉시 반응. 서버 응답이 도착하면 덮어쓰기. 실패하면 자동 rollback.

예측 응답이 entity id를 정확히 가져야 정규화가 일치한다. id가 잘못되면 새 entity로 인식되어 rollback이 안 일어남.

useFragment — 트리가 아니라 노드 단위 구독

const { data, complete } = useFragment({
  fragment: gql`fragment PostBody on Post { title content }`,
  from: { __typename: "Post", id: postId },
});

화면 컴포넌트가 전체 query를 다시 실행하지 않고, 특정 entity의 특정 필드만 구독. 그 entity가 갱신되면 이 컴포넌트만 리렌더. list virtualization + 정규화 캐시의 정점.

cache.modify — 외과 수술식 갱신

cache.modify({
  id: cache.identify({ __typename: "Post", id: 1 }),
  fields: {
    likeCount(existing) { return existing + 1; },
    comments(existing, { toReference }) {
      const newRef = toReference({ __typename: "Comment", id: 99 });
      return [...existing, newRef];
    },
  },
});

mutation 응답 없이도 캐시를 직접 갱신. 한 entity의 몇 필드만. 리스트 끝에 추가·삭제할 때 매우 유용.

cache redirect — id를 미리 알 때 캐시 hit 유도

typePolicies: {
  Query: {
    fields: {
      book(_, { args, toReference }) {
        return toReference({ __typename: "Book", id: args?.id });
      },
    },
  },
},

query { book(id: 1) {...} }가 들어왔을 때 — 캐시에 이미 그 Book이 있으면 그것을 돌려주고 네트워크 호출을 안 한다. 피드에서 Post를 본 뒤 상세 페이지로 들어가면 즉시 표시되는 마법이 이 옵션.

cache.writeFragment / readFragment — 트랜잭션식 접근

const post = cache.readFragment({
  id: "Post:1",
  fragment: gql`fragment X on Post { id title }`,
});
cache.writeFragment({
  id: "Post:1",
  fragment: gql`fragment X on Post { title }`,
  data: { title: "Updated" },
});

저장된 entity를 작은 selection으로 읽고 쓸 수 있다. cache.modify부분 갱신용이라면, writeFragment부분 + 새 필드 추가까지.


페이지네이션 — offsetLimitPagination & relayStylePagination

Apollo가 제공하는 프리셋:

import { offsetLimitPagination, relayStylePagination } from "@apollo/client/utilities";
 
typePolicies: {
  Query: {
    fields: {
      feed:      offsetLimitPagination(),      // offset/limit
      posts:     relayStylePagination(),        // edges/cursors/pageInfo
      tagPosts:  offsetLimitPagination(["tag"]), // tag별 분기
    },
  },
}

offsetLimitPagination()이 자동으로 mergeread를 짜준다. 이걸 손으로 짜는 것과 결과는 같지만, 프리셋이 5줄로 줄여준다. Relay style은 04-relay-store에서 다룬다.


What-if — typePolicies 안 정하고 운영하면

사례어떤 사고
복합 키 type에 keyFields 미설정Order:42로 캐시 → 다른 region의 Order:42가 덮어쓰기
merge 미설정 + partial 응답console warning + 기존 필드 잃음
페이지네이션 merge 미설정새 페이지가 기존 페이지를 통째로 덮어씀
keyArgs 미설정feed(category:"news") 응답이 feed(category:"music") 캐시를 덮음
singleton에 keyFields: false 미설정id 없는 root config가 root에 inline 되어 다른 응답이 덮어씀

Apollo의 console warning은 production 사고의 사전 경보다. “Cache data may be lost…” 메시지가 한 번이라도 보이면 그 type에 policy를 추가해야 한다. 무시하면 데이터 손실조용히 일어난다.


garbage collection & eviction

캐시는 무한히 자란다. 해결:

cache.evict({ id: cache.identify({ __typename: "Post", id: 1 }) });
cache.gc(); // 참조되지 않는 entity 제거

gc()root에서 도달 불가능한 entity를 모두 정리. 메모리 압박 시기에 주기적으로 호출한다. SSR/SSG에서 redux-persist 비슷한 apollo3-cache-persist 같은 라이브러리가 LRU eviction을 얹어준다.


흥미로운 이야기

Apollo Cache는 5번 다시 짜였다

Apollo Client의 캐시는 1.x의 Redux 기반 store에서 시작해, 2.x의 apollo-cache-inmemory(별도 패키지), 3.x의 통합 InMemoryCache, 3.3의 reactive variable + useFragment 도입, 그리고 3.7의 useFragment GA까지 — 5번 큰 재설계를 거쳤다. 각 재설계의 동기는 같은 한 가지였다 — 정규화는 옳은데, 그 위의 API가 쓰기 어렵다. typePolicies가 안정화된 3.x 이후로도 fieldPolicy를 어떻게 더 잘 표현할 것인가는 GitHub issue로 매년 새로 올라온다. 이 도구의 복잡도가 곧 정규화 캐시의 본질적 복잡도다 — 한 줄로 풀 수 없어서 5번 다시 짠 거다.


Insight — typePolicies클라이언트 측 schema

서버 스키마가 type system이라는 약속을 정한다면, typePolicies그 약속의 클라이언트 측 반사다. 둘이 어긋나면 캐시는 깨진다. 그래서 06-federation@key directive가 곧 클라이언트 keyFields동형 매핑되도록 디자인된 것.


한 단락 요약

Apollo InMemoryCache는 정규화 캐시의 생산용 구현체다. 핵심은 typePolicies 한 객체 — type 레벨에서 keyFields로 정규화 키를 정하고, field 레벨에서 merge·read·keyArgs로 병합·읽기·분기를 정한다. 그 위에 optimistic update·useFragment·cache.modify·cache redirect·writeFragment 5가지 도구가 외과 수술식 갱신을 가능하게 한다. typePolicies 없이 운영하면 console warning이 production 사고의 사전 경보가 된다. 다음 문서(04-relay-store)는 같은 모델을 spec으로 강제하는 Relay store를 다룬다 — Apollo가 유연한 ORM이라면 Relay는 엄격한 정규형 강제다.