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.keyFields | typename + 어떤 필드를 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에서 반드시 정의해야 함.keyArgs— field 자체의 캐시 분기 인자.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()이 자동으로 merge와 read를 짜준다. 이걸 손으로 짜는 것과 결과는 같지만, 프리셋이 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의useFragmentGA까지 — 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·writeFragment5가지 도구가 외과 수술식 갱신을 가능하게 한다. typePolicies 없이 운영하면 console warning이 production 사고의 사전 경보가 된다. 다음 문서(04-relay-store)는 같은 모델을 spec으로 강제하는 Relay store를 다룬다 — Apollo가 유연한 ORM이라면 Relay는 엄격한 정규형 강제다.