모달에서 좋아요를 눌렀는데 뒤 피드는 그대로 — 캐시 불일치 문제
초기 : 왜 TanStack Query를 쓰지 않고 직접 만들었나
1. 호출부 보일러플레이트
TanStack은 queryFn이 필수라 race condition 방지를 위한 signal 배선이 매 호출부에 반복된다.
const zip = createQuery(() => ({
queryKey: ['example', id],
queryFn: ({ signal }) => getApi<ExampleType>(`/api/example/${id}`, { signal })
}));
자체 구현은 url을 받는 시그니처라 signal 연결이 레이어 내부에 숨는다.
const zip = createQuery<ExampleType>(
() => `example:${id}`,
() => `/api/example/${id}`
);
호출부마다 반복되는 배선은 빠뜨리기 쉽고, 빠뜨려도 조용히 동작한다.
2. 러닝커브
React에서 써본 적은 있다. 다만 기존에 동작하던 함수들을 연결하거나 간단한 사이드 프로젝트에서 사용하던 수준이라, 현재 개발하고 있는 서비스에서 새 요구가 생길 때마다 TanStack이 그걸 어떤 메서드로 제공하는지, 그 메서드의 기본값과 예외가 뭔지를 매번 확인해야 한다.
당장 필요한 건 두 가지뿐이었다. 같은 요청 간의 Race Condition을 막기, 그리고 일정 시간 안에는 캐시를 쓰기. 이걸 위해 라이브러리 전체의 API 표면을 익혀야 하는 상태로 들어가는 게 비용으로 느껴졌다.
3. 동작이 한 파일에서 읽힌다
abort 시점, GC 타이밍, stale 판정이 한 파일 안에 있다. "왜 재요청이 안 되지"를 라이브러리 내부를 뒤지지 않고 답할 수 있다.
그래서 캐싱 원리만 가져왔다
TanStack에서 실제로 필요했던 건 개념 두 개였다.
-
staleTime — 이 시간 안에 받은 데이터는 신선한 것으로 보고 재요청하지 않는다
-
gcTime — 구독자가 모두 떠난 뒤 캐시를 얼마나 더 들고 있을 것인가
이 둘만 이해하고 SvelteMap 하나로 구현했다. 초기 버전은 179줄이다.
const cache = new SvelteMap<string, EntryType<unknown>>();
const meta = new Map<string, { subscribers: number; gcTimer?: ReturnType<typeof setTimeout> }>();
function subscribe(key: string, gcTime: number): () => void {
const m = meta.get(key) ?? { subscribers: 0 };
m.subscribers++;
if (m.gcTimer) clearTimeout(m.gcTimer);
meta.set(key, m);
return () => {
const cur = meta.get(key)!;
if (--cur.subscribers > 0) return;
cur.gcTimer = setTimeout(() => {
cache.delete(key);
meta.delete(key);
}, gcTime);
};
}
function run<T>(key: string, fetcher: () => Promise<T>, staleTime: number, force: boolean) {
const entry = cache.get(key) as EntryType<T> | undefined;
if (entry?.promise) return entry.promise;
if (!force && entry?.status === 'success' && Date.now() - entry.updatedAt < staleTime) {
return Promise.resolve(entry.data);
}
const promise = fetcher().then((data) => {
cache.set(key, { data, status: 'success', updatedAt: Date.now() });
return data;
});
cache.set(key, { data: entry?.data, status: 'pending', promise, updatedAt: entry?.updatedAt ?? 0 });
return promise;
}
구조는 세 부분이다.
| 부분 | 역할 |
|---|---|
| cache | 키 → 엔트리. SvelteMap이라 get/set이 반응형으로 추적된다 |
| subscribe() | $effect 안에서 구독자 수를 세다가 0이 되면 gcTime 뒤 캐시 삭제. 그 전에 다시 구독되면 타이머를 취소한다 |
| run() | 진행 중인 promise가 있으면 공유(중복 제거), staleTime 안이면 재요청 생략, 그 외에는 이전 data를 유지한 채 pending 표시(stale-while-revalidate) |
TanStack이 하는 일의 아주 얇은 단면이지만, 당시 요구사항은 이걸로 전부 충족됐다.
대신 포기한 것
자체 구현에 없는 기능은 명확하다.
| 없는 것 | 아쉬운 상황 |
|---|---|
| mutation 계층 | onMutate / rollback / cancelQueries 없음 |
| SSR prefetch → hydrate | load 결과를 캐시에 심는 표준 경로 없음 |
| devtools | 직접 만듦 |
이 중 mutation 계층의 부재가 이번 문제의 배경이다. GET 캐싱만 놓고 보면 자체 구현이 합리적이었지만, 상호작용(좋아요·핀·팔로우·댓글)이 붙기 시작하면서 "그 변경을 어디에 반영할 것인가"라는 질문이 남았다.
증상
/feed 피드에서 재현되는 시나리오다.
-
피드를 30개까지 무한스크롤한다
-
포스트를 클릭하면 상세 모달이 뜬다
-
모달에서 좋아요를 누른다 (모달 안 숫자는 올라간다)
-
모달을 닫는다
-
뒤 피드의 그 포스트는 원래 숫자 그대로다
같은 포스트가 화면 여러 곳에 동시에 그려지기 때문에 어긋남이 눈에 띈다. /feed 한 페이지 안에서도 메인 피드와 사이드바 SidebarList가 상위 3개를 공유하고, 모달 내부에도 같은 ListCard가 렌더된다.
데이터가 사는 곳을 세어보면
① page.data > data.posts (초기에 사용자에게 바로 보여질 SSR로 받아온 1페이지 10개)
② 컴포넌트 로컬 appended ($state<PostType[]>, 무한스크롤 2페이지부터)
③ page.state openModal('detail', { post }) → $state.snapshot()으로 동결
④ 모달 로컬 DetailModal의 let post = $state(initialPost) 로 처음에 바로 나타나게 한 뒤, 백그라운드에서 get_post로 받아온 최신 데이터 응답으로 병합
⑤ 쿼리 캐시 example:{id} 안의 detail.places[].post
$state.snapshot()으로 얼린 ③은 history state에 들어가므로 어떤 무효화도 닿지 않는다. ④는 서버에서 받은 최신 데이터를 병합해 놓고도 모달이 닫히면 그대로 버린다.
왜 어려운가
모달 진입점이 5곳이고, 배경에 뭐가 있을지 모른다.
openModal('detail') 호출부는 ListCard, CollectionModal, featured/SummaryCard, home/FeaturedPost, HistoryTab이다. ListCard는 거의 모든 화면에서 쓰이므로 실질 진입점은 더 많다.
배경이 언마운트되지 않는다.
ModalRouter.svelte가 모달 스택을 {#each}로 쌓기만 하고 배경 라우트는 마운트된 채로 남는다. 언마운트 후 재마운트라면 재요청으로 해결될 문제인데, 살아 있어서 누군가 명시적으로 갱신해줘야 한다.
환경
-
SvelteKit 2 / Svelte 5 (runes)
-
자체 쿼리 레이어
lib/api/query.svelte.ts(318줄,createQuery/createInfiniteQuery/invalidateQuery) -
백엔드는 GraphQL 문법만 차용한 자체 실행 엔진이다. 표준 서버가 아니다. 스키마 DSL로 선언한 필드만 존재하고, 실행기는 쿼리에 적힌 필드 이름을 그 선언 테이블에서 찾는다
-
그래서
__typename이 없다. 이건 개발자가 선언하는 필드가 아니라 스펙상 실행 엔진이 모든 오브젝트 타입에 자동으로 제공하는 메타 필드인데, 그 계층이 구현되어 있지 않다 →Type "Category" does not have the field "__typename" -
introspection도 없다. 스키마를 읽어 타입을 생성하거나 쿼리를 검증하는 도구를 붙일 수 없다
-
스키마에 union/interface가 없다. 다형성이 필요한 자리는
type: 'place' | 'item'같은 명시적 문자열 필드로 판별하는 컨벤션을 쓴다 -
eid는 전역 고유하지 않음. Post의eid와 Place의eid가 겹칠 수 있음 -
좋아요·댓글 작성·삭제 API는 미연결 상태. UI만 존재하고 핸들러가 비어 있음
가설 (탐색 순서대로)
-
가설 1: mutation 성공 후
invalidateQuery로 모달 외부에서 해당 모달이 나타내던 객체를 호출한 API키를 무효화한다 → 오답 (모달이 호출되는 곳이 여러 군데라 특정할 수 없고 Prop으로 넘기기에는 이러한 모달 및 상황이 더 발생할 것이라 추측되어 비효율적 ) -
가설 2: Svelte 자체의
invalidateAll()로load를 재실행한다 → 오답 (SSR로 오는 건 1페이지뿐이고 2페이지부터는 클라이언트 로컬 배열이다. load가 다시 돌면 그 배열을 비우는$effect가 같이 돌아 스크롤한 결과가 전부 날아가고, 배열을 살려둬도 커서 페이지네이션이라 그 사이 올라온 포스트만큼 목록에 구멍이 생긴다) -
가설 3: TanStack Query로 갈아타면 표준 도구로 해결된다 → 오답 (
queryKey단위 캐시라 "무효화할 키를 특정해야 한다"는 문제가 그대로 남고, 관리 대상도 쿼리 캐시뿐이라 모달 밖의 컴포넌트 로컬 배열에는 닿지 못한다. -
가설 4: 저장소를 쿼리 캐시 하나로 합치고, mutation 때 그 하나를
__typename:eid로 전수 순회해 고친다 → 부분 정답 (저장소가 하나면 병합 지점도 하나다. 배경에 뭐가 있는지 몰라도 된다. 다만 비용이 "바뀐 것"이 아니라 "캐시 전체 크기"에 비례한다) -
가설 5: 캐시에는 참조만 두고 엔티티는
__typename:eid맵에 한 벌만 둔다 → 정답 (4차에서 백엔드 제약으로 접었던 Apollo의 캐시 설계다.__typename을 우리가 붙이게 되면서 제약이 사라졌다)
시도한 것들
1차 — TanStack Query 실제 설치
이 문제에 바로 도움이 되지 않았다. TanStack은 캐시를 queryKey 단위로 관리한다. mutation 후 invalidateQueries를 부르려면 어떤 키가 영향받는지 알아야 하는데, 배경을 모른다는 게 문제였다. 그리고 관리 대상은 쿼리 캐시뿐이라 page.data와 같이 캐시가 아닌 SSR로 받아왔던 값, 스냅샷으로 전달하는 값, 컴포넌트 로컬 배열은 그대로 남는다.
패키지를 제거하고 자체 레이어로 되돌렸다.
2차 — 무효화 경로 검토
invalidateAll()을 쓸 수 없다는 것을 확인했다.
먼저 짚어둘 것 — invalidateAll() 자체가 무한스크롤을 초기화하지는 않는다. 이건 내비게이션이 아니라서 페이지 컴포넌트가 언마운트되지 않고, let appended = $state([])도 그대로 살아 있다. 초기화한 건 우리 코드에 있던 이 $effect다.
// routes/feed/+page.svelte
const reviews = $derived([...data.posts, ...appended]);
$effect(() => {
appended = [];
hasMore = data.posts.length >= PAGE_SIZE;
});
data.posts.length를 읽으므로 이 effect는 data.posts에 의존한다. invalidateAll() → load 재실행 → 새 배열 도착 → effect 재실행 → appended = []. 30개까지 스크롤했든 상관없이 10개로 돌아간다. 목록이 짧아지니 브라우저가 스크롤 위치도 같이 끌어올린다.
그리고 이 $effect는 버그가 아니다. 필터를 바꾸면 이전 필터로 받아둔 appended는 반드시 지워야 한다. 문제는 이 하나의 effect가 "필터가 바뀐 것"과 "같은 필터인데 데이터만 갱신된 것"을 구분하지 못한다는 점이다.
effect를 걷어내도 문제는 남는다. 커서 페이지네이션이기 때문이다. after 커서는 마지막 포스트의 createdAt이고, appended는 그때의 1페이지를 기준으로 이어붙인 것이다. 갱신 사이에 새 포스트 3개가 올라오면 이렇게 된다.
갱신 전 data.posts = [1..10] appended = [11..30]
갱신 후 data.posts = [신1,신2,신3,1..7] appended = [11..30]
↑ 8,9,10이 어디에도 없다
appended를 살려두면 이번엔 목록에 구멍이 생긴다. 무효화가 정답이 되려면 로드된 모든 페이지를 순차 재요청해 다시 채워야 했다.
— 이건 무한스크롤의 본질적 한계가 아니라 이 코드의 구조 문제다. TanStack의 createInfiniteQuery는 무효화 시 로드된 모든 페이지를 순차 재요청해 다시 채운다. 우리가 막힌 이유는 리스트가 "1페이지는 SSR data, 2페이지부터는 컴포넌트 로컬 배열"인 반반 구조이기 때문이다. 따라서 data의 제일 마지막 포스트가 바뀌면 해당 포스트를 기준으로 다시 after 커서를 지정한 채 요청을 해야했고 이것을 구현하려면 사실 기존에 기능이 잘 돼있는 Tanstack 라이브러리를 사용하는 것이 낫다는 생각이 들기 시작했다.
3차 — 엔티티 식별자 만들기
eid만으로 식별하려 했으나 eid는 전역으로 고유하지 않고 포스트, 장소 등등 타입별로 다르다는 이유로 각 엔티티별 식별자가 필요했다.
서버 GraphQL에서 __typename을 지원하면 해당 노드의 타입이름을 자동으로 가져와 편하게 매핑 트리를 만들 수 있지 않을까 라는 생각이 처음에 들었다. 하지만 백엔드에서 GraphQL 문법을 쓰긴 하지만 __typename을 지원하지는 않아, 쿼리 문자열에 자체 지시어를 넣고 서버 레이어에서 응답에 태그를 붙이는 방식을 만들었다.
// routes/api/categories/+server.ts
`categories { topCategories @type(Category) { eid name subCategories(sortKey: ?) @type(Category) { eid name } } }`;
compileQuery라는 함수를 만들어 서버로 요청을 하기 전 @type(X)를 떼어낸 쿼리로 요청을 한 뒤, tagEntities라는 함수로 응답을 순회하며 @type(X)에서 정의한 대로 __typename을 붙인다. 하지만 매번 요청 쿼리문마다 이렇게 @type(X)를 넣어야한다는 단점때문에 반드시 수정되어야할 부분이라고 생각했다.
4차 — Apollo를 검토하고 접었다
3차에서 식별자를 만들고 나니 다음 질문은 자연스러웠다. 이걸 이미 하고 있는 라이브러리가 있지 않나? Apollo Client다.
Apollo의 InMemoryCache는 응답을 통째로 저장하지 않는다. 엔티티 단위로 쪼개 평평한 맵에 넣고, 중첩 객체 자리에는 참조만 남긴다.
// 응답
{ posts: [ { __typename:'Post', id:'p1', likesCount:3, author:{__typename:'User', id:'u1'} } ] }
// Apollo 캐시
ROOT_QUERY { 'posts': [ {__ref:'Post:p1'} ] }
Post:p1 { id:'p1', likesCount:3, author:{__ref:'User:u1'} }
User:u1 { id:'u1', name:'김' }
엔티티가 한 벌만 존재하므로 cache.modify({ id: 'Post:p1', … }) 한 번이면 그 포스트를 참조하는 모든 화면이 갱신된다. 우리 문제를 정면으로 푸는 구조다.
그런데 도입할 수가 없었다.
Apollo의 정규화는 모든 selection set에 __typename을 자동 주입하고 그 값으로 캐시 ID를 만드는 것에 의존한다. 그런데 우리 백엔드에는 그 필드가 존재하지 않는다. 주입하는 순간 쿼리 전체가 실패한다.
addTypename을 끄고 타입마다 캐시 키를 손으로 지정하면 우회는 된다. 하지만 그건 라이브러리를 통째로 들여오면서 그 라이브러리의 핵심 자동화는 못 쓰는 상태다. introspection도 없어 스키마 기반 코드 생성·검증 도구도 함께 죽는다. 남는 건 무거운 의존성뿐이다.
여기서 Apollo는 후보에서 뺐다.
5차 — 방향 전환: 저장소를 줄인다
다시 처음 문제로 돌아왔다. 데이터가 다섯 곳에 사는 게 문제라면, 병합 지점도 다섯 곳이 된다. 그 문장을 뒤집으면 답이 나온다.
병합 지점이 저장소 수만큼 생긴다면, 저장소를 하나로 만들면 병합 지점도 하나다.
그리고 저장소가 하나면 병합이라는 개념 자체가 필요 없다. 그 하나를 고치면 끝이다.
1차에서 TanStack을 버린 근거는 "invalidateQueries를 부르려면 어떤 키가 영향받는지 알아야 한다"였다. 다시 보니 이건 TanStack이 권하는 무효화 API를 쓸 때의 제약이지 라이브러리의 제약이 아니었다. queryClient.getQueryCache().findAll()은 키를 몰라도 캐시 전체를 준다. 라이브러리를 무효화 도구가 아니라 모든 데이터가 모이는 단일 저장소로 쓰면 된다.
1차에 적었던 반대 이유의 무게도 달라졌다. 그때 계산은 "필요한 건 stale과 gc뿐인데 라이브러리 전체 API를 익힐 이유가 없다"였는데, 그 전제는 요구사항이 더 늘지 않는다는 것이었다. 실제로는 계속 늘었다.
| 필요해진 것 | 직접 만들면 |
|---|---|
| mutation 계층 | 낙관적 갱신, 실패 시 rollback 컨텍스트, in-flight 요청 취소 |
| 무한스크롤 | pages/pageParams 관리, fetchNextPage, 커서 계산, 무효화 시 전 페이지 순차 재요청 |
| SSR 결과를 캐시에 심기 | initialData와 "언제 신선한 것으로 칠지" 규칙 |
| 캐시 전체 접근 | 키를 모른 채 모든 엔트리를 순회하는 경로 |
| 알림 묶기 | 여러 엔트리를 바꿀 때 리렌더를 1회로 합치는 배치 |
| 실패 재시도 | 지수 백오프와 재시도 조건 |
| devtools | 캐시 상태를 눈으로 보는 패널 (실제로 직접 만들었다) |
이것들을 하나씩 직접 만들어 붙이는 비용이, TanStack이 제공하는 메서드를 공부하는 러닝커브보다 확실히 크다. 1차에서 러닝커브를 이유로 자체 구현을 골랐는데, 요구사항이 늘고 나니 같은 러닝커브가 오히려 갈아탈 이유가 됐다.
서버 라우트에서 __typename을 붙인다
저장소를 합치기 전에 식별자부터 정리해야 했다. 3차에서 만든 @type(Category) 지시어 파서는 버렸다. 쿼리 문자열을 파싱하는 방식은 ${POST_FIELDS} 같은 변수 보간에 취약했고, 백엔드로 보내기 전에 지시어를 떼어내는 단계도 필요했다.
다시 보니 붙일 자리가 이미 있었다. 클라이언트는 백엔드 GraphQL을 직접 부르지 않는다. +page.server.ts와 /api/… 라우트가 대신 호출해서 화면에 필요한 모양으로 가공한 뒤 내려보낸다. 프런트엔드 전용 서버 계층을 두는 이 구조를 BFF(Backend For Frontend)라고 부른다.
그렇다면 응답을 받아 클라이언트로 넘기기 직전에 태그를 붙이면 된다. 백엔드는 손댈 필요가 없고, 클라이언트는 __typename이 붙은 응답만 보게 된다.
브라우저 ──▶ +page.server.ts / api 라우트 ──▶ 백엔드 GraphQL
│
└─ 응답을 순회하며 __typename 주입
판단 근거는 필드명이다. 같은 이름이 다른 타입을 뜻하는 경우가 하나도 없었다. 그래서 post: 'PostType', likes: 'UserType[]' 같은 필드명 → 타입 규칙을 파일 하나에 모아두고, 응답이 오면 트리를 순회하며 붙인다. 노드는 자기가 뭔지 모르고 부모가 필드명으로 자식의 타입을 알려주는 구조이고, eid가 없는 객체는 태깅하지 않는다.
호출부에는 아무 표기도 남지 않는다. 쿼리는 원래 쓰던 그대로다.
전수 순회 패치
저장소를 쿼리 캐시로 합치고 나서, mutation은 이렇게 처리했다. 어떤 키가 영향받는지 묻지 않고 전부 뒤진다.
for (const query of client.getQueryCache().findAll()) {
const next = walk(query.state.data, key, patch); // __typename:eid가 일치하는 노드만 갈아끼움
if (next !== query.state.data) client.setQueryData(query.queryKey, next); // copy-on-write — 안 바뀐 쿼리는 스킵
}
findAll()이 비활성 쿼리까지 주므로 닫혀 있는 모달의 쿼리도 갱신된다. A 포스트의 좋아요 모달을 열었다 닫고, B 포스트에서 같은 사람을 팔로우한 뒤 A를 다시 열면 팔로우 상태로 보인다. 원래 문제가 여기서 풀렸다.
6차 — Apollo의 원리를 다시 꺼냈다
전수 순회는 동작했다. 그런데 비용의 모양이 계속 걸렸다. 비용이 "무엇이 바뀌었나"가 아니라 "캐시가 얼마나 크나"에 정비례한다. 팔로우 필드 하나를 토글하는데 지도 장소 목록까지 훑는다. 화면이 늘어날수록 관계없는 데이터를 훑는 시간이 선형으로 늘어난다.
줄이려면 "이 엔티티가 어디 있는지"를 알아야 한다. 그건 곧 엔티티를 트리 안에 묻어두지 말고 따로 꺼내 두라는 뜻이다. 4차에서 접었던 Apollo의 캐시 설계가 정확히 그것이었다.
4차에서 Apollo를 접은 이유는 그 라이브러리가 __typename을 자동 주입한다는 것이었다. 설계가 아니라 주입 방식이 백엔드와 안 맞았을 뿐이다. 그런데 5차에서 서버 라우트가 이미 그 일을 하고 있었다. 식별자는 손에 있고, 없는 건 그걸 활용하는 캐시 구조뿐이었다.
그래서 설계만 직접 만들었다.
응답을 쪼개 엔티티를 맵에 한 벌만 두고, 쿼리 캐시에는 참조만 남긴다.
쿼리 캐시 ['posts','feed'] → { pages: [[ {__ref:'Post:p1'}, {__ref:'Post:p2'} ]] }
['likes','p1'] → [ {__ref:'User:u9'} ]
엔티티 맵 Post:p1 { likesCount:3, user:{__ref:'User:u9'}, photos:[{__ref:'Photo:ph1'}] }
User:u9 { followed:false, followersCount:12 }
연산은 셋이다.
| | 하는 일 | 언제 |
|---|---|---|
| absorb | 응답 트리에서 엔티티를 뽑아 맵에 넣고 그 자리에 __ref를 남긴다 | 응답 수신 시 |
| hydrate | __ref를 맵의 실제 객체로 복원한다 | 렌더 시 |
| patch | 맵의 엔티티 하나만 갱신한다 | mutation 시 |
patch가 O(1) 이 됐다. 그리고 그 엔티티를 참조하는 모든 화면이 SvelteMap 구독을 통해 자동으로 갱신된다.
patch(key, partial) {
const previous = entities.get(key);
if (!previous) return;
const next = replaceEqualDeep(previous, { ...previous, ...partial });
if (next === previous) return; // 값이 같으면 아무도 깨우지 않는다
entities.set(key, next);
}
replaceEqualDeep은 TanStack이 setQueryData의 structural sharing에 쓰는 함수다. 그대로 가져다 썼다. 서버 응답을 그대로 반영하는 구조라 "이미 팔로우 중인 사람을 다시 누르는" 중복 갱신이 실제로 생기는데, 이 한 줄이 그때 화면 전체 재조립을 막는다.
읽기 — 순환 방어가 필요하다
엔티티끼리 서로를 참조할 수 있으므로(User → Post → User) 복원에 경로 기반 방어를 둔다. 지금 내려가는 중인 ref를 경로 Set에 기록해두고, 같은 ref를 다시 만나면 얕은 복사로 끊고 올라온다.
select 옵션으로 복원을 구현하면 안 된다. select는 쿼리 데이터가 바뀔 때만 재실행되는데 우리가 바꾸는 건 엔티티 맵이다. 스켈레톤은 그대로이므로 영원히 다시 돌지 않는다. $derived여야 한다.
호출부
createQuery를 직접 쓰지 않고 얇게 감쌌다. queryFn 결과에 absorb를 적용하고 data는 $derived(hydrate(...))로 노출한다.
const follow = gqlMutation(() => ({
mutationFn: ({ user, followed }) => postApi(`/api/users/${user.eid}/follow`, { followed }),
update: (result, { user }) => ({
typename: 'UserType',
eid: user.eid,
fields: { followed: result.followed, followersCount: result.followersCount }
})
}));
update가 result를 받는다는 점이 중요하다. 서버 응답이 정답이므로 낙관적 갱신도 롤백도 없다. 연타해도 매번 서버가 준 값을 그대로 쓰니 누적 오차가 생길 수 없고, 실패하면 애초에 아무것도 안 바꿨으니 되돌릴 것도 없다.
SSR load 결과는 gqlSave로 맵에 넣는다. 네트워크를 타지 않고 저장·복원만 하므로 프로필 헤더처럼 쿼리가 없는 화면도 mutation에 반응한다.
엔티티 맵은 요청마다 새로 만들어 setContext로 주입한다. 모듈 전역에 두면 SvelteKit 서버에서 요청 간 공유되어 다른 사용자의 데이터가 샌다. 서버에서는 gcTime 기본값이 Infinity라 정리 타이머도 안 돌아서, 유출과 메모리 누수가 한 실수에서 같이 나온다.
무엇을 엔티티로 둘 것인가
여기까지 만들어 놓고 실제 피드에서 무한스크롤로 포스트를 1,500개까지 불러본 뒤 맵을 열어봤다.
TODO: 스냅샷 이미지
엔티티 TODO개 / TODO MB. 그중 Photo가 TODO개로 TODO MB를 차지하고 있었다.
숫자를 보고 나서야 앞뒤가 안 맞는다는 걸 알았다. 엔티티 맵을 만든 이유는 mutation 때문이다. 한 곳만 고치면 그걸 참조하는 모든 화면이 따라오게 하려는 것이다. 그런데 사진은 mutation으로 바뀌는 필드가 없다. 좋아요를 누르든 팔로우를 하든 Photo의 url도 width도 그대로다. 바뀌지 않는 데이터를 "한 곳에서 고치기 쉽게" 만들어 둔 셈이었다.
그래서 태깅 기준을 바꿨다. eid가 있으면 다 태깅하는 게 아니라 mutation으로 바뀔 수 있는 타입만 필드 규칙에 선언하고, 서버 라우트는 그것들에만 __typename을 붙여 내려보낸다. photos, rankings는 선언하지 않으므로 태깅되지 않고, absorb가 키를 만들지 못하니 트리에 그대로 남는다. 사진 하나마다 맵 엔트리 · 키 문자열 · {__ref} 껍데기 셋이 함께 없어진다.
TODO: 적용 후 엔티티 TODO개 / TODO MB
정규화의 대가는 "읽을 때마다 다시 조립하기"다. 조립해서 얻는 게 없는 데이터라면 대가만 남는다.
이 방식의 문제
읽기가 공짜가 아니게 됐다
전수 순회에서는 캐시 데이터가 이미 최종 모양이라 그냥 반환하면 됐다. 지금은 렌더할 때마다 스켈레톤을 훑으며 트리를 다시 조립한다.
문제는 조립이 매번 전부 다시 일어난다는 것이다. 이유는 둘이다.
하나 — $derived 하나가 화면이 읽은 엔티티 전부에 의존한다.
hydrate는 트리를 내려가며 만나는 __ref마다 entities.get(ref)를 부른다. SvelteMap은 키마다 별도의 반응형 소스를 두므로 그 get 하나하나가 해당 키에 대한 의존성을 만든다. 그런데 그 의존성이 전부 하나의 계산 안에 모인다.
const data = $derived(store.hydrate(query.data));
// 이 계산 하나가 Post:p1 … Post:p200, User:u1 … 전부에 의존한다
User:u9 하나가 바뀌면 이 $derived는 통째로 무효화되고 처음부터 다시 실행된다. 계산의 단위가 엔티티가 아니라 화면 전체라서, 200개 중 하나가 바뀌어도 200개를 다시 조립한다.
둘 — 다시 실행될 때 이전 결과를 재사용하지 않는다.
hydrateNode는 __ref가 아닌 평범한 객체를 만나도 새 객체를 만들어 필드를 옮겨 담는다. 값이 하나도 안 바뀌었어도 그렇다.
const next: EntityRecordType = {};
for (const field of Object.keys(record)) next[field] = hydrateNode(record[field], visited);
return next; // 내용이 같아도 참조는 다르다
그래서 결과 트리의 모든 노드가 새 참조가 된다. Svelte는 참조가 바뀐 항목을 다시 그리므로, 피드에 포스트 200개가 있고 그중 28개에만 그 유저가 들어 있어도 안 바뀐 카드 172개까지 재렌더된다.
전수 순회는 copy-on-write였다. 바뀐 가지만 새 객체가 되고 나머지는 원래 참조를 그대로 돌려줬다. 이 축에서는 오히려 후퇴했다.
스켈레톤이 새는 지점이 있다
저장 형태와 사용 형태가 다르면 경계마다 변환을 기억해야 한다. 무한스크롤 커서가 대표적이다.
getNextPageParam: (posts) => posts.at(-1).createdAt;
// 스켈레톤에는 createdAt이 없다 → null → 2페이지에서 멈춘다
래퍼에서 복원한 뒤 넘기도록 처리했지만, 이런 지점이 또 있을 수 있다는 게 이 설계의 성격이다. initialData도 같은 부류다 — 옵션 함수가 반응형이라 여러 번 도는데 매번 흡수하면 이미 갱신된 엔티티를 낡은 SSR 값으로 되돌린다. 참조가 바뀔 때만 흡수하도록 메모이제이션을 넣어야 했다.
엔티티 맵이 정리되지 않는다
absorb와 patch는 set만 한다. 제거 경로가 없다. 쿼리 캐시는 gcTime으로 엔트리를 버리지만 그때 사라지는 건 스켈레톤이고 엔티티는 맵에 남는다. 페이지를 새로고침해야 비워진다.
정리 방식으로 LRU와 mark & sweep을 검토했는데 둘 다 "조용한 데이터 소실" 이라는 새 실패 모드를 들여온다. LRU는 "오래 안 쓴 것"과 "지금 화면에 있는 것"이 달라서 보이는 엔티티를 버릴 수 있고, mark & sweep은 루트 등록을 빠뜨리면 같은 사고가 난다.
그 밖에
-
낙관적 갱신이 없다.
update가result를 받는 구조라 서버 왕복이 끝나야 화면이 바뀐다. 정확성을 얻고 즉각성을 잃었다 -
update가 엔티티 하나만 받는다. 팔로우하면 상대의 팔로워 수는 오르지만 내 팔로잉 수는 그대로다 -
태깅이 빠지면 조용히 실패한다. 쿼리에서
eid를 선택하지 않으면 그 객체는 흡수되지 않고 트리에 남는다. 에러가 아니라 "갱신이 안 됨"으로 나타난다
후보 — 조립 결과를 메모하기
"읽기가 공짜가 아니다"의 답은 바뀐 가지만 다시 조립하는 것이다. 엔티티별로 조립 결과를 캐시해두고 그 엔티티가 그대로면 재사용하면 된다.
그런데 캐시만 얹으면 화면이 조용히 낡는다. Svelte의 의존성 추적이 pull 방식이기 때문이다.
hydrate가 메모 적중으로 하위 트리를 건너뜀
→ 그 안의 entities.get() 이 호출되지 않음
→ 의존성이 사라짐
→ 그 엔티티가 나중에 바뀌어도 화면이 다시 계산되지 않음
그래서 push 방식 무효화를 얹어야 한다. 자식 → 부모 관계를 기록해두고, patch 때 조상 사슬을 타고 올라가며 메모를 지우고 조상 엔티티의 참조도 새로 만들어 구독자를 깨운다.
patch(key, partial) {
const affected = collectAncestors(key); // key + 조상 전부
for (const t of affected) memo.delete(t);
entities.set(key, next);
for (const t of affected) entities.set(t, { ...entities.get(t)! }); // 참조만 갈아 알림을 올려보낸다
}
Apollo의 optimism이 하는 일과 같은 구조다 — 의존성 그래프를 따라 dirty를 위로 전파한다.
대가가 만만치 않다.
-
메모리가 대략 두 배. 엔티티마다 조립 결과를 하나 더 들고 있어야 한다. 안 그래도 정리가 안 되는 맵이라 부담이 겹친다
-
부모 관계 기록이 줄지 않는다. 복원할 때마다 추가만 하고 지우지 않아서 캐시에서 사라진 관계가 남는다. 과잉 무효화 방향이라 안전하지만 낭비다
-
한 군데만 새도 조용히 틀린다. 부모 기록이 배열 안쪽까지 닿지 않으면 그 경로의 변경이 화면에 나타나지 않는다. 에러가 아니라 "안 바뀜"으로 나타나는 종류다
세 방식 비교
| | 전체 훑기 | 정규화 스토어 | 스토어 + 메모 |
|---|---|---|---|
| mutation 비용 | O(캐시 전체) | O(1) | O(1) + O(조상 수) |
| 관계없는 화면 | 매번 훑는다 | 건드리지 않는다 | 건드리지 않는다 |
| 읽기 비용 | 없음 (최종 모양 그대로) | 트리 전체 재조립 | 바뀐 가지만 |
| 참조 안정성 | 좋음 (copy-on-write) | 나쁨 — 전부 새 참조 | 좋음 |
| 추가 메모리 | 없음 | 엔티티 1벌 | 엔티티 1벌 + 조립결과 1벌 + 관계 그래프 |
| 구현 복잡도 | 낮음 | 중간 | 높음 |
| 틀렸을 때 | 갱신 안 됨 (눈에 보임) | 갱신 안 됨 (눈에 보임) | 조용히 낡은 화면 |
| SSR 데이터 통합 | 불가 (캐시 밖) | 가능 (gqlSave) | 가능 |
정리하면 이렇다.
전체 훑기는 단순하고 참조 관리가 깔끔하지만, 비용이 캐시 크기에 묶여 있다. 화면이 늘수록 관계없는 데이터를 훑는 시간이 선형으로 늘어난다.
정규화 스토어는 그 결합을 끊었다. 대신 비용을 읽기 쪽으로 옮겼고, 안 바뀐 항목까지 새 참조를 만들어 재렌더를 유발한다.
메모는 읽기 비용까지 잡지만, 메모리 두 배와 "조용히 틀리는" 실패 모드를 받아들여야 한다.
어느 쪽이 맞는지는 실제 사용 패턴에 달렸다. 한 화면에 같은 엔티티가 얼마나 자주 반복되는지, 캐시에 관계없는 데이터가 얼마나 쌓이는지, mutation과 렌더의 빈도 비가 어떤지. 여기까지 정리하고 실측으로 판단하려 한다.
7차 — 조립하면서 비교한다
비교 표의 '스토어 + 메모'는 조립을 건너뛰는 방식이었다. 건너뛰기 때문에 빠르지만, 건너뛰기 때문에 의존성이 끊기고, 그걸 복구하려고 부모 그래프를 들고 다니며, 그래프가 한 군데라도 새면 조용히 낡는다. 대가의 뿌리가 전부 "건너뛴다"에 있다.
그래서 거꾸로 했다. 조립은 매번 전부 하되, 만들려는 객체가 이전 결과와 같으면 이전 객체를 그대로 돌려준다.
- 스켈레톤 노드마다 "지난 조립 결과"를
WeakMap에 기억해 둔다 - 노드를 조립할 때 자식들을 먼저 복원하고, 필드 하나하나를 이전 결과와 참조 비교한다. 끝까지 전부 같으면 새 객체를 만들지 않고 이전 결과를 반환한다
- 자식이 이전 참조를 돌려주면 부모의 비교도 통과한다. 참조 유지가 잎에서 뿌리로 전파되어, 바뀐 엔티티가 든 가지만 새 참조가 된다
function buildFields(source, visited, previous) {
let next = null;
for (const field of Object.keys(source)) {
const value = hydrateNode(source[field], visited);
if (!next && previous?.[field] === value) continue; // 아직 이전과 전부 같다
next ??= copyUpTo(previous, field); // 처음 달라진 순간에만 새 객체
next[field] = value;
}
return next ?? previous ?? {};
}
"조립을 다 끝낸 뒤 replaceEqualDeep로 비교"하는 것과는 다르다. 그건 트리를 전부 만들고 나서 전부 비교하고 대부분을 버리는, 일을 두 번 하는 구조다. 이 방식은 조립 루프 안에서 비교하므로 순회가 한 번이고, 버려지는 중간 객체도 바뀐 가지 분량뿐이다.
후보의 대가 세 가지가 여기서 어떻게 되는지가 핵심이다.
| 후보(push 무효화)의 대가 | 이 방식에서는 |
|---|---|
| 의존성 끊김 → 부모 그래프 필요 | 매번 전부 순회하므로 entities.get() 의존성이 유지된다. 그래프가 필요 없다 |
| 메모리 두 배 | 기억하는 건 조립 결과를 가리키는 포인터뿐이다. WeakMap이라 스켈레톤이 버려지면 같이 사라진다 |
| 한 군데만 새도 조용히 낡음 | 비교가 실패하면 최악이 "새 객체 하나 더"다. 화면이 낡는 실패 모드 자체가 없다 |
주의할 점은 하나다. 이전 결과를 재사용하려면 필드 구성이 같을 때만 비교해야 한다. 필드가 추가·삭제된 응답에 지난 결과를 돌려주면 낡은 모양이 남으므로, 필드 개수와 이름이 일치할 때만 비교 대상으로 삼는다.
그리고 이 메모는 absorb가 깨뜨릴 수 있다. refetch가 같은 데이터를 다시 가져와도 absorb가 매번 새 엔티티 객체를 set하면 모든 메모가 무효가 된다. 그래서 absorb에도 patch와 같은 replaceEqualDeep을 넣었다 — 내용이 같으면 set 자체를 생략한다.
낙관적 갱신 — 이제는 넣을 수 있다
6차에서는 "서버 응답이 정답, 낙관적 갱신 없음"이었다. 그 계산이 바뀌었다. patch가 O(1)이고, patch가 직전 값으로 되돌리는 클로저를 반환하게 만들면 롤백도 필드 단위로 정확해진다.
onMutate: (variables) => applyUpdates(options.optimistic(variables)), // undo 묶음을 반환
onError: (_error, _variables, rollback) => rollback?.(),
onSuccess: (result, variables) => applyUpdates(options.update(result, variables))
optimistic과 update는 배열을 반환한다. 팔로우 하나가 상대의 followersCount와 내 followeesCount를 같이 고치는 다중 엔티티 갱신이 선언 한 번에 되고, 롤백은 받은 undo들을 역순으로 실행한다.
"서버 응답이 정답"이라는 원칙은 그대로다. 성공하면 update가 서버 값으로 다시 patch하는데, 낙관값과 같으면 replaceEqualDeep이 걸러내 아무 일도 일어나지 않고, 다르면 서버 값으로 덮인다. 낙관적 갱신은 정확성 구조 위에 즉각성만 얹은 것이고, 값이 맞았다면 응답 시점 비용은 0에 수렴한다. 이게 실측에서 그대로 나온다.
엔티티 맵 정리 — mark & sweep
미뤄뒀던 이유는 "루트 등록을 빠뜨리면 조용히 소실된다"였다. 등록을 사람이 화면마다 하지 않게 만들면 그 위험의 대부분이 사라진다.
루트를 값이 아니라 함수로 등록한다. retain(read)는 "살아있는 트리를 반환하는 getter"를 Set에 넣을 뿐이고, sweep 시점에 그 함수들을 호출해 도달 가능한 __ref를 표시한 뒤 표시되지 않은 엔티티를 지운다.
retain(read) {
sweepRoots.add(read);
return () => sweepRoots.delete(read);
}
sweep() {
const reachable = new Set<string>();
for (const read of sweepRoots) markNode(read(), reachable);
for (const key of [...entities.keys()]) if (!reachable.has(key)) entities.delete(key);
}
쿼리 캐시 전체가 getQueryCache().findAll()을 읽는 getter 하나로 커버된다. 새 쿼리를 만들 때 등록할 것이 없으니 구조적으로 누락이 나지 않는다. 캐시 밖에서 엔티티를 참조하는 gqlSave만 $effect로 각자 등록한다.
sweep은 상시로 돌지 않는다. 쿼리 캐시의 removed 이벤트 — gcTime 만료로 스켈레톤이 버려질 때 — 에 1초 디바운스로 건다. 엔티티가 고아가 되는 건 스켈레톤이 버려질 때뿐이니, 그때만 도는 게 맞다.
태깅 선언 — 목록에서 규칙으로
5차에서 만든 태깅은 필드명 → 타입을 하나씩 적어둔 목록이었다. post, posts, place, places, nearbyPlaces, recommendedUsers… 새 API가 새 필드명을 들고 올 때마다 목록도 같이 늘어야 했고, 빠뜨리면 에러 없이 "갱신 안 됨"으로만 드러났다.
처음엔 검증을 붙이는 쪽으로 갔다. API마다 태깅 대상 필드를 선언해두고 응답과 대조하는 방식이다. 누락은 잡히지만 선언이 API 수만큼 늘어난다. 목록 관리 비용을 없애려다 목록을 하나 더 만드는 꼴이라 되돌렸다.
되돌리고 나서 목록을 다시 보니 그건 애초에 규칙이었다. 필드명이 예외 없이 타입 이름의 단수/복수였다. 그래서 선언을 지우고 ENTITY_TYPE_NAMES에서 접미사 표를 만들었다.
for (const typeName of ENTITY_TYPE_NAMES) {
const suffix = toTypename(typeName).toLowerCase();
TYPE_BY_SUFFIX[suffix] = typeName; // place → PlaceType
TYPE_BY_SUFFIX[`${suffix}s`] = `${typeName}[]`; // places → PlaceType[]
}
일치 판정은 camelCase 경계까지 본다. 필드명이 접미사와 같거나, 접미사를 대문자로 시작하는 형태로 끝날 때만 걸린다. nearbyPlaces·likedPlaces는 places에 걸리고 replaces는 걸리지 않는다.
남은 선언은 규칙에서 벗어나는 네 개뿐이다 — likes, followers, followees, checkStatusWeb. 필드명이 컨벤션을 따르는 한 API가 늘어도 손댈 것이 없고, "선언을 빠뜨려서 태깅이 안 됐다"는 실패 모드가 구조적으로 사라진다.
규칙의 출처가 ENTITY_TYPE_NAMES 하나로 모이면서, 6차의 "무엇을 엔티티로 둘 것인가"도 이제 그 배열에 이름을 넣느냐 빼느냐로 표현된다. mutation으로 바뀌지 않는 Photo·Ranking은 배열에서 빼면 규칙에서도 같이 빠진다.
왜 테스트 코드가 아니라 브라우저에서 쟀나
방식을 고르는 판단은 숫자로 하고 싶었고, 처음엔 Node에서 정량 측정할 생각이었다. 결론부터 말하면 이 문제에서 의미 있는 숫자는 브라우저에만 있다.
첫째, JS 시간만 보면 이 방식은 손해다. 조립에 비교가 얹히니 순수 계산 시간은 그냥 재조립보다 오히려 길다. 이득은 전부 하류에서 온다 — 참조가 안 바뀐 카드는 Svelte가 다시 그리지 않고, DOM diff·스타일 계산·레이아웃·페인트가 통째로 생략된다. "참조변화 28개 vs 200개"까지는 어디서든 셀 수 있지만, 그 172개 차이가 몇 ms인지는 브라우저만 안다. 하류가 없는 환경에서 재면 이 방식을 기각하게 된다.
둘째, 실제 병목이 JS 밖에 있었다. Performance 탭에서 응답 처리 Task를 열어보니 상당 부분이 보라색 Layout이었다. 카드마다 '더보기' 판정을 위해 scrollHeight를 읽는데, 카드가 재렌더되면 "스타일 무효화 → scrollHeight 읽기"가 카드 수만큼 반복되며 매번 강제 리플로우를 일으킨다. 어떤 Node 측정에도 존재하지 않는 비용이고, 측정 전에는 존재하는지도 몰랐다. 본문이 실제로 바뀔 때만 다시 재도록 고쳤다.
셋째, GC 압력도 화면 밖의 비용이다. 트리 전체 재조립이 만드는 일회용 객체들이 실제로 응답 Task 안에서 Minor GC(~1ms)로 잡혔다. 참조를 유지하니 그 할당 자체가 사라졌다.
넷째, 낙관적 갱신의 가치는 타임라인 위에서만 보인다. "클릭 Task가 5ms 커졌다"만 보면 후퇴다. 네트워크 왕복과 나란히 놓고 봐야 "화면 반영이 왕복에서 분리됐다"가 보인다.
실측 — 6차 vs 7차
두 버전을 각각 프로덕션 빌드로 동시에 띄워 놓고(6차 버전은 git worktree로 분리) 같은 시나리오를 쟀다.
- 피드 200개 로딩 → 좋아요 모달 → 팔로우 클릭 1회
- 라이브 서버라 측정 사이에 새 포스트가 올라올 수 있다. 6차→7차를 몇 분 안에 연속으로 한 쌍으로 묶고, 회차마다 DOM Nodes 수로 조건을 검증했다 (쌍 안 차이 0.02%)
- 3쌍 측정의 중앙값. Task 시간은 Performance 탭의 트레이스 파일을 파싱해 추출했다
| 지표 (중앙값) | 6차 재조립 | 7차 참조 유지 + 낙관 |
|---|---|---|
| 클릭 Task | 4.3ms | 9.3ms |
| 응답 처리 Task | 17.7ms | 0.66ms |
| JS 합계 | 22.0ms | 10.3ms |
| 클릭 → 화면 반영 | ~26ms | ~11ms |
| 응답 Task 안의 Layout (강제 리플로우) | 2~4ms | 0 |
| 응답 Task 안의 Minor GC | ~1ms | 없음 |
클릭 Task 4.3 → 9.3ms. 6차의 클릭은 fetch 등록뿐이었다. 7차는 그 안에서 낙관 patch → 조립 → flush → Layout까지 끝낸다. +5ms를 내고 즉시 반응을 샀다.
응답 처리 17.7 → 0.66ms. 낙관값과 서버값이 같으니 replaceEqualDeep이 patch를 통째로 걸러내고, 반응성이 아예 트리거되지 않는다. 트레이스에서 응답 도착 이후 렌더 계열 작업이 하나도 없는 것으로 교차 확인했다.
클릭 → 화면 반영 ~26 → ~11ms. 숫자보다 성질이 중요하다. 26ms는 서버 왕복이 4ms인 로컬 기준이다. 6차의 반영 시간은 왕복에 비례하므로 실환경 왕복이 100ms면 ~120ms가 된다. 7차의 ~11ms는 네트워크와 무관한 상수다.
어느 개선이 어느 숫자를 만들었나
이 표는 두 개선이 합산된 결과라 귀속을 나눠둘 필요가 있다. 6차의 patch에도 같은 값 스킵은 있었으므로, 6차에 낙관 갱신만 붙여도 "응답 처리 ≈ 0"과 "즉시 반응"은 얻는다. 응답 처리 17.7 → 0.66은 낙관 갱신의 몫이다.
그 경우 재조립 비용은 사라지는 게 아니라 클릭 Task로 이사한다 — 6차 + 낙관의 클릭은 4.3 + 17.7 ≈ 21ms가 됐을 것이고, 7차의 클릭은 9.3ms다. 이 차이가 참조 유지의 몫이다. 클릭 직후는 사용자가 가장 민감한 구간(INP가 재는 바로 그 지점)이고, CPU를 4배 스로틀하면 21ms짜리 클릭은 80ms를 넘어가며 체감된다.
참조 유지의 몫은 이 표 밖에도 있다. hydrate는 mutation 때만 도는 게 아니라 쿼리 데이터가 갈릴 때마다 돈다. 6차식이면 무한스크롤로 다음 페이지를 붙일 때마다 기존 200개 카드까지 전부 새 참조가 되어 스크롤할수록 페이지 추가가 무거워지고, staleTime 만료 후 refetch가 같은 데이터를 돌려줘도 전체가 재렌더된다. 팔로우 클릭은 가끔이지만 스크롤과 refetch는 상시다. 낙관 갱신은 비용을 언제 낼지를 옮기고, 참조 유지는 얼마나 낼지를 줄인다 — 둘은 대체재가 아니라 직교하는 개선이다.
메모리도 확인했다. 참조 유지 인프라의 실제 비용 — 조립 결과를 기억하는 WeakMap(엔트리 736개)과 sweep 루트 Set — 은 힙스냅샷 도달성 분석 기준 retained 약 33kB였다. 조립 결과 자체는 컴포넌트가 같이 참조하는 객체라 WeakMap의 소유분이 아니다. 스냅샷 총량은 +1.7MB(+3.6%)였는데 constructor별로 뜯어보면 늘어난 클로저 약 4천 개와 그 JIT 컴파일 코드, 참조 유지로 살아있는 배열들로 분산돼 있다 — 자료구조의 비용이 아니라 코드가 늘어난 비용이다. 클릭 한 번의 힙 증가폭은 0.5MB → 0.3MB로 줄었다.
아직 부족한 것
조립 단위가 여전히 화면 전체다. 참조는 유지하지만 순회와 비교는 카드 수에 선형이다. 200개에서는 클릭 Task 안에 묻히는 수준이지만, 수천 개 규모가 되면 후보로 검토했던 push 무효화 — 바뀐 가지만 재방문 — 를 다시 꺼내야 한다.
retain 등록이 빠지면 조용히 소실된다. 쿼리 캐시는 getter 하나로 자동이지만, gqlSave처럼 캐시 밖에서 엔티티를 참조하는 경로는 각자 등록해야 한다. 그런 경로를 새로 만들면서 등록을 빠뜨리면 sweep이 화면에 살아있는 엔티티를 지운다. mark & sweep을 미루게 했던 실패 모드가 범위만 줄어든 채 남아 있는 셈이다.
쿼리에서 eid를 빠뜨리면 조용히 실패한다. 필드명 규칙이 타입은 붙여주지만 eid가 없으면 엔티티 키를 만들 수 없어 흡수되지 않고, 에러가 아니라 "갱신 안 됨"으로만 나타난다. 규칙이 "이 필드는 Place다"까지 알고 있으니 eid가 없을 때 개발 모드 경고를 띄우는 건 가능한데, 아직 붙이지 않았다.
항목 추가·삭제 경로가 없다. patch는 존재하는 엔티티의 필드만 고친다. 새 포스트가 어느 피드 어느 위치에 들어가는지는 서버만 아니까, 댓글 작성·삭제처럼 목록 구조가 바뀌는 API가 붙는 시점에 해당 쿼리를 무효화하는 경로를 설계해야 한다.
같은 필드를 연타하는 낙관 갱신이 겹칠 수 있다. undo가 "patch 직전 값"의 필드 스냅샷이라, 같은 필드에 낙관 갱신 두 개가 겹친 상태에서 앞 요청이 실패하면 뒤 요청의 낙관값을 밟고 되돌린다. 성공한 요청의 서버 응답이 최종 값을 확정해 주므로 영구히 틀어지진 않지만, 그 사이 잠깐 낡은 값이 보일 수 있다.
처음의 문제로 돌아가면 — "모달에서 좋아요를 눌렀는데 뒤 피드는 그대로"는 이제 일어나지 않는다. 한 곳을 고치면 그 엔티티를 참조하는 모든 화면이 따라오고, 그 비용은 캐시 크기가 아니라 바뀐 것의 크기에 비례하며, 화면 반영은 네트워크에서 분리됐다. 남은 것들은 서비스가 그 규모에 도달할 때 꺼낼 항목들이다.