🔷 GraphQL5. 캐시 & 성능02 · Normalized client cache

02 · Normalized client cache — 클라이언트가 데이터베이스가 된다

질문: HTTP 캐시가 못 풀면 클라이언트 안에서 캐시는 어떻게 만드나? 한 화면의 like 카운트가 바뀌면 다른 화면은 어떻게 자동 갱신되나? 한 줄 답: 응답 트리를 id 기반 entity 그래프로 분해해 저장하면, 한 entity의 갱신이 그것을 참조하는 모든 위치에 자동 전파된다.


Pyramid Top

이전 문서(01)가 HTTP 캐시는 GraphQL에서 무용하다는 사실을 말했다면, 이번 문서는 그 빈자리에 들어선 클라이언트 정규화 캐시의 원리를 다룬다. Apollo Client·Relay·urql Graphcache가 서로 다른 구현이지만 같은 invariant 위에 동작한다 — 같은 id를 가진 entity는 어디서 왔든 같은 entity.

이 한 줄이 GraphQL 클라이언트 캐시 전체를 설명한다. 이번 문서는 그 invariant가 어떻게 매끄럽게 깨지고 다시 강제되는지를 따라간다.


사고 흐름


Why — 왜 정규화인가

같은 화면 안에서도, 서로 다른 화면 사이에서도, 같은 entity를 여러 번 받게 된다.

# 화면 A: 피드
query Feed {
  posts {
    id
    title
    author { id name avatar }
  }
}
 
# 화면 B: 프로필
query Profile($id: ID!) {
  user(id: $id) { id name avatar bio }
}

User#42피드의 작성자로 등장하고 프로필의 본인으로 등장한다. 트리 그대로 저장하면?

cache.feed = { posts: [{ id: 1, author: { id: 42, name: "Alice", avatar: "..." } }] }
cache.profile = { user: { id: 42, name: "Alice", avatar: "...", bio: "..." } }

같은 Alice두 번 저장되어 있다. 프로필에서 Alice가 이름을 “Alicia”로 바꾸면 — 피드 화면은 여전히 “Alice”를 보여준다. 데이터 일관성이 깨진다.

정규화의 답: tree를 id 기준 entity map으로 평탄화한다.

cache.entities = {
  "User:42":  { id: 42, name: "Alice", avatar: "...", bio: "..." },
  "Post:1":   { id: 1, title: "...", author: { __ref: "User:42" } },  // ← ref만
}
cache.roots = {
  ROOT_QUERY: {
    "posts":      [{ __ref: "Post:1" }],
    "user({\"id\":42})": { __ref: "User:42" },
  }
}

Alice는 한 군데만. 누가 갱신해도 모든 참조 위치가 자동으로 새 값을 본다. 이게 cache가 데이터베이스가 된다는 말의 의미다.


How — 4단계 알고리즘

① Normalize (응답 도착 시)

응답 tree → 각 object 노드를 entity map에 dump → 자식 object는 __ref로 치환

엔티티 식별 기본 규칙(Apollo 기준):

  • dataIdFromObject(obj) = __typename:id“
  • id 필드가 없거나 __typename이 없으면 → 부모 안에 inline 저장 (정규화 안 됨)
// 입력
{ data: { post: { id: 1, __typename: "Post", title: "Hi",
                   author: { id: 42, __typename: "User", name: "Alice" } } } }
 
// 정규화 결과
{
  "Post:1": { id: 1, __typename: "Post", title: "Hi", author: { __ref: "User:42" } },
  "User:42": { id: 42, __typename: "User", name: "Alice" }
}

② Store

flat dictionary로 보관. ref는 문자열 포인터다 — 순환 참조도 자연스럽게 처리된다 (Userposts: [Post]author: User …).

③ Reference

자식 object들이 값을 복제하지 않고 ref만 둔다. 한 entity가 N개 위치에서 참조되어도 메모리는 한 카피.

④ Denormalize (read 시)

쿼리가 들어오면 ref를 재귀적으로 풀어서 tree를 다시 만든다. 이때 부족한 필드가 있으면 (cache miss):

  • 전체 query 재요청 (기본),
  • 또는 부분 보충(field policy) 이 둘 중 정책에 따라 동작.

What — 핵심 invariant 3개

invariant의미깨지면
같은 id = 같은 entity(typename, id) 동일 → 메모리상 한 객체데이터 분기·갱신 누락
id 없는 type은 부모에 inlinedataIdFromObject가 null → 정규화 안 됨동일 데이터 중복·잘못된 덮어쓰기
field args가 key의 일부posts(filter:"active")posts(filter:"all")다른 캐시 엔트리필터 바꿔도 같은 결과 보임

dataIdFromObject & typePolicies — 정규화를 명시적으로 정의

Apollo InMemoryCache의 핵심 설정:

new InMemoryCache({
  dataIdFromObject(obj) {
    // 기본: `${obj.__typename}:${obj.id}`
    return obj.id ? `${obj.__typename}:${obj.id}` : null;
  },
  typePolicies: {
    Book: { keyFields: ["isbn"] },           // id가 아니라 isbn으로 식별
    AllProducts: { keyFields: false },        // singleton — 정규화 안 함
    Order: { keyFields: ["id", "region"] },   // 복합 키
  }
});
  • keyFields: ["isbn"] — Book은 id 대신 isbn으로.
  • keyFields: false — singleton(루트 객체)은 정규화 안 함. 무조건 부모 안에.
  • keyFields: ["id", "region"] — 복합 키. cache key = Order:{"id":1,"region":"KR"}.

typePolicy 없이 복합 키id 없는 type은 캐시가 깨진다. Apollo는 console에 warning을 띄우지만 그것을 보고도 무시하면 데이터 손실로 이어진다. 같은 typename이 다른 ID 스킴으로 들어오는 순간 캐시는 덮어쓰기를 시작한다.


”cache가 데이터베이스가 된다”

이게 비유가 아니다. 정규화 캐시의 행동 모델은 관계형 DB와 정확히 동형이다.

관계형 DBNormalized cache
테이블typename
primary keyid (또는 keyFields)
foreign key__ref
joindenormalize 시의 ref 따라가기
trigger / subscriptionobserver pattern (한 entity 변화 → 구독 화면 알림)
transactionoptimistic update + rollback

Apollo Client·Relay·urql은 서로 다른 ORM이고, 같은 DB 모델을 공유한다. 그래서 세 라이브러리 모두 cache.modify·updater·Graphcache.updates 같은 직접 갱신 API를 갖는다.


What-if — 정규화가 깨지면

# bad: id 없음
query { settings { theme language } }

응답:

{ "data": { "settings": { "theme": "dark", "language": "ko" } } }

settings에 id가 없으니 → ROOT_QUERY.settings = { theme, language }inline 저장. 다른 화면에서 같은 settings를 다시 받으면 — 완전히 덮어쓴다. partial 응답이라면 기존 필드를 잃는다.

# bad: __typename 누락 (서버가 안 보내거나 클라이언트가 안 요청)
query { post(id: 1) { id title } }

__typename이 없으면 cache key를 만들 수 없다. → 모든 응답이 root에 inline. 정규화 자체가 동작 안 함.

그래서 Apollo Client는 모든 selection에 __typename을 자동 삽입한다 — 이 마법은 사실 정규화 invariant를 지키기 위한 강제다.


흥미로운 이야기

Relay가 먼저였고, Apollo가 그 모델을 일반화했다

2015년 GraphQL 첫 공개 직후, Facebook이 같이 공개한 Relay는 이미 global object identification + normalized store를 강제하고 있었다. Relay의 철학은 “그래프클라이언트 측에서도 그래프여야 한다”였다 — 그래서 모든 type이 Node interface를 구현해야 하고, 모든 객체에 전역 고유 id가 있어야 했다. 이 강제는 너무 엄격해서 진입장벽이 됐다. 2016년 Apollo Client가 등장해 Relay의 정규화 아이디어덜 강제적으로 옮겼다 — id 없어도 동작하고(단, inline), __typename도 자동 삽입. 철학은 같았고, 강제력만 풀었다. 이 한 결정이 Apollo가 생태계 점유율을 잡은 이유다. Relay는 정답을 알고 있었고, Apollo는 정답을 팔리는 형태로 만들었다.


Insight — 정규화는 클라이언트 측 ORM이 발명된 사건이다

fetch().then(setState)로 충분하던 시대에서 — id-graph 정규화 + ref + denormalize 시대로의 전환. 이게 GraphQL 클라이언트 도구가 Redux보다 복잡해진 이유고, 동시에 Redux를 대체한 이유다.


한 단락 요약

정규화 캐시는 응답 트리를 id 기반 entity 그래프로 분해해 저장한다. 그 결과 (a) 같은 entity가 메모리에 한 번만 있고, (b) 한 갱신이 모든 참조에 자동 전파되고, (c) cache는 작은 데이터베이스가 된다. 핵심 invariant는 “같은 id를 가진 entity는 어디서 왔든 같은 entity”이고, 그것을 강제하는 도구가 typePolicies.keyFields다. 다음 두 문서가 이 모델의 두 구체 구현 — 03-apollo-cache는 가장 널리 쓰이는 InMemoryCache, 04-relay-storeinvariant를 spec으로 강제하는 Relay store를 다룬다.