모달에서 좋아요를 눌렀는데 뒤 피드는 그대로
한 데이터가 여러 곳에 사는 문제
- 01 · 문제
- 모달에서 좋아요를 눌렀는데 닫고 나면 뒤 피드의 숫자는 그대로다. 같은 글이 화면마다 다른 상태로 보인다
- 02 · 분석
- 서버 상태의 복제와 캐시 일관성무효화(invalidate) 전략의 전제 조건정규화와 갱신 이상갱신 비용 O(캐시)와 O(1)참조 동일성과 렌더링
- 03 · 04 · 해결 방안과 결론
- 라이브러리(Apollo)를 들이는 대신 정규화 캐시라는 설계만 가져와 직접 만들었다. 전수 순회로 먼저 문제를 닫고, 비용 모양이 문제가 되자 엔티티 스토어로 옮겼다
- 결과
- 응답 처리 17.7ms → 0.66ms클릭 → 화면 반영 ~26ms → ~11ms(네트워크와 무관한 상수)갱신 비용 O(캐시 크기) → O(1)
사내 프로젝트라 이름은 A(사용자용 웹), B(어드민)로 표기한다. 이 글은 A에서 있었던 일이다.
문제
사용자용 웹 A의 피드에서 이런 일이 생긴다.
- 포스트를 30개쯤 스크롤해 내려간다.
- 하나를 눌러 상세 모달을 연다.
- 모달에서 좋아요를 누른다. 모달 안의 숫자는 올라간다.
- 모달을 닫는다.
- 뒤 피드의 그 포스트 좋아요 숫자는 그대로다.
같은 글이 두 화면에서 다른 상태로 보인다. 사용자는 "좋아요가 안 된 줄 알고" 피드에서 다시 누른다. 그러면 이번엔 취소가 된다. 의도와 반대의 결과가 데이터에 남는다.
좋아요만이 아니다. 팔로우도 같다. 좋아요 누른 사람 목록에서 누군가를 팔로우하고 그 사람의 프로필로 가면 아직 "팔로우" 버튼이다. 다시 누르면 언팔로우가 된다. 사용자 입장에서는 이 서비스의 숫자를 믿을 수 없다는 인상이 남는다. 좋아요·팔로우가 핵심 상호작용인 서비스에서 이건 기능 버그가 아니라 신뢰 문제였다.
이 서비스는 이제 막 사용자를 받기 시작하는 단계였고, 화면은 계속 늘어나는 중이었다. 즉 "지금 화면 몇 개만 고치면 되는" 문제가 아니라 앞으로 생길 모든 화면에서 반복될 문제였다.
분석
데이터가 사는 곳을 센다
서버에서 온 데이터는 TanStack Query 캐시에 queryKey 단위로 들어간다. 피드 목록은 ['posts', 'feed']에, 모달의 상세는 ['post', eid]에. 같은 포스트 하나가 두 키 아래 복제본으로 존재한다. 장소 페이지, 홈, 유저 페이지까지 세면 한 포스트가 다섯 곳에 산다.
모달에서 좋아요를 누르면 mutation은 ['post', eid]의 데이터만 갱신한다. 나머지 복제본은 모른다. 한 진실이 여러 복제본으로 나뉘어 있고, 쓰기가 한 곳에만 반영되는 상황이다.
이건 프론트엔드만의 문제가 아니라 캐시가 있는 모든 시스템의 문제다. CPU 캐시에서는 코어마다 같은 주소의 사본을 갖고 있다가 한 코어가 쓰면 나머지 사본을 무효화하거나(write-invalidate) 갱신한다(write-update). DB 복제에서는 마스터에 쓴 것이 리플리카에 전파될 때까지 옛 값이 읽힌다. 우리 상황은 이 문제의 브라우저판이고, 선택지도 같다. 바뀐 것을 무효화해서 다시 받거나, 바뀐 것을 모든 복제본에 전파하거나.
무효화가 안 되는 이유 — 어디에 복제됐는지 모른다
TanStack Query의 표준 답은 invalidateQueries다. mutation이 끝나면 영향받는 키를 무효화해서 다시 받는다. 하지만 이 방법은 어떤 키가 영향받는지 아는 것을 전제로 한다.
모달은 다섯 곳에서 열린다. 어디서 열렸는지 prop으로 넘겨받아도, 그 포스트가 그 화면 말고 또 어느 키에 복제돼 있는지는 모른다. 홈에서 열린 모달의 포스트가 방금 지나온 장소 페이지 캐시에도 있을 수 있다. 진입점마다 키 목록을 관리하면 화면이 늘 때마다 목록이 늘고, 하나라도 빠지면 에러 없이 조용히 옛 값이 남는다.
전체 무효화가 안 되는 이유 — 무한스크롤이 무너진다
그럼 다 무효화하면 되지 않을까. SvelteKit의 invalidateAll()이나 캐시 전체 refetch다. 여기서 무한스크롤이 걸린다.
피드는 커서 페이지네이션이다. 첫 페이지는 SSR로 오고, 2페이지부터는 클라이언트가 커서를 들고 이어 받아 배열에 붙인다. 이 상태에서 첫 페이지를 다시 받으면 두 가지 중 하나가 일어난다.
- 붙여 둔 배열을 비우면 → 30개 스크롤한 것이 10개로 돌아가고 스크롤 위치도 딸려 올라간다.
- 배열을 살려 두면 → 그 사이 새 글 3개가 올라왔을 때 목록에 구멍이 생긴다.
갱신 전 1페이지 = [1..10] 이어 붙인 것 = [11..30]
갱신 후 1페이지 = [신1,신2,신3,1..7] 이어 붙인 것 = [11..30]
↑ 8, 9, 10이 어디에도 없다
커서 페이지네이션은 "특정 시점의 일관된 스냅샷"을 보장하지 않는다. 페이지마다 조회 시점이 다르고, 첫 페이지만 다시 받으면 스냅샷이 섞인다. 로드된 페이지를 전부 순서대로 다시 받으면 구멍은 없지만, 보고 있던 포스트가 다음 페이지로 밀려 화면이 바뀐다. 무효화는 이 피드에서 정답이 될 수 없었다. 남은 길은 전파다.
전파하려면 식별자가 필요하다 — 그런데 없다
바뀐 것을 모든 복제본에 전파하려면 "이 객체와 저 객체가 같은 포스트다"를 판정할 수 있어야 한다. 서버는 각 객체에 eid를 준다. 그런데 eid는 타입 안에서만 유일하다. 포스트의 eid와 장소의 eid가 겹칠 수 있다. 전역 식별자는 타입 + eid여야 한다.
GraphQL에는 이걸 위한 메타 필드 __typename이 있다. 스펙상 실행 엔진이 모든 객체 타입에 자동으로 붙여 준다. 그런데 우리 백엔드는 GraphQL 문법만 차용한 자체 실행 엔진이라 이 계층이 없었다. __typename을 요청하면 "그런 필드 없음" 에러가 났다.
비용의 모양 — 어떻게 전파하느냐에 따라 다르다
식별자가 있다고 치고, 전파 방법에 따라 비용이 어떻게 다른지 봤다.
방법 1. 캐시를 전부 뒤진다. 모든 쿼리 데이터를 순회하며 타입:eid가 일치하는 노드를 갈아 끼운다. 어디에 복제됐는지 몰라도 된다. 비용은 캐시 전체 크기에 비례한다. 팔로우 하나를 바꾸는데 지도의 장소 목록까지 훑는다. 화면이 늘수록 관계없는 데이터를 훑는 시간이 선형으로 는다.
방법 2. 복제를 없앤다. 응답을 저장할 때 엔티티를 뽑아 한 벌만 맵에 두고, 쿼리 캐시에는 참조({__ref: 'Post:p1'})만 남긴다. 갱신은 맵의 한 항목만 고치면 되니 **O(1)**이다. 대신 읽을 때마다 참조를 실제 객체로 되돌리는 조립 비용이 생긴다.
이건 DB의 정규화와 같은 얘기다. 같은 값을 여러 행에 중복 저장하면 갱신할 때 전부 고쳐야 하고 하나라도 놓치면 불일치가 생긴다(갱신 이상). 정규화는 중복을 한 곳으로 모아 갱신 이상을 없애는 대신, 읽을 때 조인 비용을 낸다. 우리 문제의 "화면마다 다른 숫자"가 정확히 갱신 이상이었다.
읽기 비용과 렌더링 — 참조가 바뀌면 다시 그린다
방법 2에는 함정이 하나 더 있다. 조립할 때마다 새 객체를 만들면, 값이 하나도 안 바뀐 노드까지 참조가 달라진다. Svelte는 참조가 바뀐 항목을 다시 그린다. 피드 200장 중 28장에만 그 유저가 들어 있어도, 조립이 전부 새 객체를 돌려주면 안 바뀐 172장까지 재렌더된다. 조립이 O(1) 갱신의 이득을 렌더링에서 도로 내놓는 구조다.
그래서 요구사항이 하나 더 붙었다. 내용이 같으면 이전 객체를 그대로 돌려줘야 한다. 바뀐 가지만 새 참조가 되고 나머지는 원래 참조를 유지하는 것, TanStack이 setQueryData에서 하는 structural sharing이 바로 그것이다.
해결 방안
| 선택지 | 방법 | 장점 | 단점 | 이 서비스에 |
|---|---|---|---|---|
| A. 관련 키 무효화 | mutation 후 invalidateQueries. 진입점마다 키를 넘긴다 | 표준 방식. 코드가 익숙하다 | 복제 위치를 알아야 한다. 화면 늘수록 키 목록 관리. 누락 시 조용히 실패 | 진입점 1~2곳이면 적합. 5곳+에서는 부적합 |
| B. 전체 무효화 | invalidateAll() / 캐시 refetch | 누락 없음 | 무한스크롤이 초기화되거나 구멍이 생긴다 | 부적합 |
| C. 전수 순회 패치 | 모든 쿼리 데이터를 걸어 타입:eid 일치 노드 교체 | 복제 위치를 몰라도 됨. 구현 단순. 닫힌 모달 캐시까지 갱신 | 비용이 캐시 크기에 비례. 화면이 늘면 선형 증가 | 1차 채택 — 화면 수 적은 지금은 적합 |
| D. Apollo Client 도입 | 정규화 캐시 내장 | 검증된 구현 | 백엔드에 __typename이 없어 핵심 자동화 사용 불가. 기존 TanStack 코드 전면 교체. 번들 증가 | 부적합 |
| E. 자체 정규화 스토어 | 엔티티 맵 + 참조. absorb/hydrate/patch | 갱신 O(1). 화면 수와 무관 | 조립 비용, 참조 유지 로직, 엔티티 GC를 직접 만들어야 함 | 2차 채택 — 화면이 늘어난 뒤 |
| F. 서버 푸시(SSE/WebSocket) | 변경을 서버가 브로드캐스트 | 다른 사용자의 변경까지 반영 | 인프라 추가. "내가 누른 걸 내 화면에 반영"에는 과하다 | 기각 |
D를 접은 이유를 정확히 적어 두면, 설계가 아니라 주입 방식이 맞지 않았다. Apollo의 정규화는 모든 selection set에 __typename을 자동 주입하고 그 값으로 캐시 ID를 만드는 것에 의존한다. 이걸 끄고 타입마다 캐시 키를 손으로 지정하면 우회는 되지만, 라이브러리를 통째로 들여오면서 핵심 자동화는 못 쓰는 상태가 된다. 반대로 정규화 캐시라는 설계 자체는 이 문제를 정면으로 푸는 구조였다. 그래서 라이브러리 대신 설계를 가져오기로 했다.
결론
두 단계로 갔다. C로 먼저 문제를 닫고, 비용 모양이 문제가 되자 E로 옮겼다.
공통 기반 — 식별자를 어디서 붙일 것인가
백엔드가 __typename을 주지 않으니 우리가 붙여야 했다. 처음엔 쿼리 문자열에 @type(Category) 같은 자체 지시어를 넣고 파싱하는 방식을 만들었는데, 쿼리마다 표기를 넣어야 하고 문자열 보간에 취약해 버렸다.
다시 보니 붙일 자리가 이미 있었다. 클라이언트는 백엔드를 직접 부르지 않고 +page.server.ts와 /api/… 라우트가 대신 호출해 가공한 뒤 내려보낸다. 이 BFF(Backend For Frontend) 계층에서 응답을 클라이언트로 넘기기 직전에 필드명 규칙(post → 'Post', likes → 'User[]')으로 __typename을 붙였다. 백엔드는 손대지 않고, 호출부에도 아무 표기가 남지 않는다.
1차 — 전수 순회 (C)
for (const query of client.getQueryCache().findAll()) {
const next = walk(query.state.data, key, patch); // 타입:eid가 일치하는 노드만 갈아 끼운다
if (next !== query.state.data) client.setQueryData(query.queryKey, next); // copy-on-write
}
findAll()이 비활성 쿼리까지 주므로 닫혀 있는 모달의 캐시도 갱신된다. 원래 문제는 여기서 풀렸다. A 포스트의 좋아요 모달을 닫고 B 포스트에서 같은 사람을 팔로우한 뒤 A를 다시 열면 팔로우 상태로 보인다.
2차 — 정규화 엔티티 스토어 (E)
화면이 늘면서 전수 순회 비용이 눈에 들어오기 시작했다. 팔로우 필드 하나를 바꾸는데 지도 장소 목록까지 훑는다. 그래서 저장 구조를 바꿨다.
쿼리 캐시 ['posts','feed'] → { pages: [[ {__ref:'Post:p1'}, {__ref:'Post:p2'} ]] }
['likes','p1'] → [ {__ref:'User:u9'} ]
엔티티 맵 Post:p1 { likesCount:3, user:{__ref:'User:u9'} }
User:u9 { followed:false, followersCount:12 }
연산은 셋이다.
absorb— 응답 트리에서 엔티티를 뽑아 맵에 넣고 그 자리에__ref를 남긴다 (응답 수신 시)hydrate—__ref를 맵의 실제 객체로 복원한다 (렌더 시)patch— 맵의 엔티티 하나만 갱신한다 (mutation 시)
patch가 O(1)이 됐고, 그 엔티티를 참조하는 모든 화면이 SvelteMap 구독으로 함께 갱신된다.
참조 유지. 조립 결과를 스켈레톤 노드별로 WeakMap에 기억해 두고, 자식부터 복원한 뒤 필드를 ===로 비교해 전부 같으면 지난 객체를 그대로 돌려준다. 바뀐 엔티티가 든 가지만 새 참조가 된다. 피드 200장 중 유저 한 명이 바뀌면 28장만 새 객체고 172장은 원래 객체라 재렌더되지 않는다. WeakMap을 쓴 이유는 정리 때문이다. 스켈레톤이 캐시에서 버려지면 GC가 지난 결과도 같이 거둬 가서 따로 지울 코드가 없다.
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 ?? {}; // 새 객체가 없으면 지난 객체 그대로
}
낙관적 갱신. patch가 O(1)이고 참조도 유지되니 클릭 즉시 반영할 수 있게 됐다. patch가 되돌리기 함수를 반환하고, 실패하면 역순으로 실행한다. 성공하면 서버 값으로 다시 patch하는데, 낙관값과 같으면 replaceEqualDeep이 걸러내 아무 일도 일어나지 않는다.
const follow = gqlMutation(() => ({
mutationFn: ({ user, followed }) => postApi(`/api/user/${user.eid}/follow`, { followed }),
optimistic: ({ user, followed }) => [
{ typename: 'User', eid: user.eid, fields: { followed, followersCount: user.followersCount + (followed ? 1 : -1) } }
],
update: (result) => [
{ typename: 'User', eid: result.eid, fields: { followed: result.followed, followersCount: result.followersCount } }
]
}));
엔티티 정리. absorb와 patch는 set만 하니 제거 경로가 없다. LRU는 "오래 안 쓴 것"과 "지금 화면에 있는 것"이 달라 보이는 엔티티를 버릴 수 있어서, mark & sweep으로 갔다. 살아 있는 쿼리 데이터를 루트로 __ref를 수집하고 도달하지 않는 엔티티를 지운다. 상시로 돌지 않고, 엔티티가 고아가 되는 유일한 순간인 쿼리 캐시의 removed 이벤트(gcTime 만료)에 1초 디바운스로 건다.
실측 — 1차(재조립) vs 2차(참조 유지 + 낙관)
두 버전을 각각 프로덕션 빌드로 동시에 띄워 놓고(1차는 git worktree로 분리) 같은 시나리오를 쟀다. 피드 200개 로딩 → 좋아요 모달 → 팔로우 클릭 1회. 라이브 서버라 새 포스트가 올라올 수 있어 두 버전을 몇 분 안에 연속으로 한 쌍으로 묶고, 회차마다 DOM 노드 수로 조건을 검증했다(쌍 안 차이 0.02%). 3쌍의 중앙값이다.
| 지표 (중앙값) | 1차 재조립 | 2차 참조 유지 + 낙관 |
|---|---|---|
| 클릭 Task | 4.3ms | 9.3ms |
| 응답 처리 Task | 17.7ms | 0.66ms |
| JS 합계 | 22.0ms | 10.3ms |
| 클릭 → 화면 반영 | ~26ms | ~11ms |
| 응답 Task 안의 강제 리플로우 | 2~4ms | 0 |
클릭 → 화면 반영 ~26 → ~11ms. 숫자보다 성질이 중요하다. 26ms는 서버 왕복이 4ms인 로컬 기준이라 실환경 왕복이 100ms면 ~120ms가 된다. 11ms는 네트워크와 무관한 상수다.
두 개선의 몫을 나눠 두면, 응답 처리 17.7 → 0.66은 낙관적 갱신의 몫이다. 1차에 낙관 갱신만 붙여도 이 숫자는 얻는다. 대신 그 경우 재조립 비용이 클릭 Task로 이사해서 4.3 + 17.7 ≈ 21ms가 됐을 것이고, 2차의 클릭은 9.3ms다. 이 차이가 참조 유지의 몫이다. 그리고 참조 유지의 몫은 표 밖에도 있다. hydrate는 mutation 때만 도는 게 아니라 무한스크롤로 다음 페이지를 붙일 때, staleTime 만료 후 refetch할 때마다 돈다. 낙관 갱신은 비용을 언제 낼지를 옮기고, 참조 유지는 얼마나 낼지를 줄인다.
감수한 부작용
- 코드가 복잡해졌다. absorb/hydrate/patch, 참조 유지, 순환 참조 방어, mark & sweep. 전수 순회는 함수 하나였다. 이 복잡도는 화면 수가 늘어난 뒤에야 정당화된다. 처음부터 E로 갔다면 과한 설계였을 것이다.
- 태깅이 빠지면 조용히 실패한다. 쿼리에서
eid를 선택하지 않으면 그 객체는 흡수되지 않고 트리에 남는다. 에러가 아니라 "갱신이 안 됨"으로 나타난다. 아래 모니터링 1번이 이걸 잡는다. - 낙관값이 틀리면 화면이 한 번 되돌아간다.
followersCount를 클라이언트가 ±1 계산하는데, 그 사이 다른 사용자가 팔로우했으면 서버 값과 다르다. 성공 시 서버 값으로 덮으니 결과는 맞지만 숫자가 한 번 튄다. 정확성보다 즉시성을 택한 지점이다.
운영에서 보는 것
- 개발 모드 경고. absorb가
eid는 있는데__typename이 없는 객체를 만나면 콘솔에 경로와 함께 경고한다. 새 쿼리를 추가하다 태깅을 빠뜨리면 첫 렌더에서 바로 보인다. - hydrate 시간 측정.
performance.mark로 hydrate 구간을 찍어 두고, 개발 모드에서 한 번에 8ms를 넘으면 경고한다. 화면 하나가 과하게 큰 트리를 구독하고 있다는 신호다. - 엔티티 맵 크기. sweep 전후의 항목 수를 로그로 남긴다. sweep 뒤에도 계속 늘기만 하면 루트 등록이 빠진 곳이 있다는 뜻이다.
그 뒤에 일어난 일
이 문제의 출발점이었던 제약이 사라졌다. 백엔드가 자체 실행 엔진에서 표준 GraphQL 서버로 교체되면서 __typename을 스펙대로 지원하게 됐다. 그래서 BFF의 필드명 → 타입 규칙과 태깅 코드를 전부 지웠다. 쿼리 문서는 스키마에서 코드 생성으로 만들어지고 그때 __typename이 들어가며, 값은 서버가 준다.
엔티티 스토어는 그대로 남았다. 식별자를 어디서 얻느냐만 바뀌었고 그 위의 설계는 손댈 것이 없었다. 백엔드 변화에 견디는 경계를 그어 둔 것이 이 설계에서 가장 잘한 부분이었다고 생각한다.
남긴 것
라이브러리를 들이지 않아도 설계는 가져올 수 있다. Apollo는 주입 방식이 맞지 않아 접었지만 그 안의 정규화 캐시는 이 문제를 정확히 푸는 구조였다. 안 맞는 건 세부 구현이었지 설계가 아니었다.
얻는 것만 있는 변경은 없다. 정규화는 갱신을 O(1)로 만드는 대신 읽기 비용을 만들었고, 참조 유지는 그 비용을 줄이는 대신 코드를 더했으며, 낙관적 갱신은 응답 비용을 없앤 게 아니라 클릭 시점으로 옮겼다. 매 단계에서 무엇이 좋아지고 무엇이 나빠지는지를 나란히 놓고 나서야 다음 방향이 보였다.