무한스크롤 피드 — 뒤로가기 스크롤 복원
TB2(페이지 단위 가상 스크롤)의 후속 문서다. 뒤로가기 복원의 원래 증상과 원인, 적용된 해결책, 구현 후 폐기한 두 가지 프로토콜 설계와 폐기 이유, 남은 열린 항목을 기록한다.
상태: 2026-08-13 기준 문제 1~4 전부 해결 적용. 문제 3은 두 차례 설계(앵커 스냅샷 → ΔH 애니메이션)를 구현까지 갔다가 복잡도를 이유로 롤백한 뒤, "새 글 N개" pill(5장)로 확정했다.
1. 원래 증상 — 왕복마다 스크롤이 아래로 밀린다
목표: 다른 페이지 갔다 뒤로가기 하면 보던 그 위치로 온다. 카드 시작점이 아니라 보던 픽셀. 왕복을 몇 번 반복해도 드리프트 0.
가상 스크롤 도입 전, 뒤로가기 → 앞으로가기 → 뒤로가기를 반복하면 저장값이 매 사이클 458~800px씩 커졌다 (5755.5 → 6213.5 → 6671.5 → …). 원인은 content-visibility의 700px 플레이스홀더:
- 이탈 시 — 지나온 카드들이 실제 높이를 가진 좌표계에서
scrollY저장. - 뒤로가기 — 컴포넌트가 새로 만들어지며 실측이 사라지고 화면 밖 카드는 다시 700px 추정치가 된다.
- 압축된 좌표계에
scrollTo(저장값)→ 원래보다 아래 카드에 착지. - 착지 후 주변 카드가 실제 높이로 부풀고, scroll anchoring이
scrollY를 밀어올린다. - 부푼 값이 다음 이탈 때 저장된다 → 누적.
핵심은 저장된 숫자가 틀린 게 아니라, 저장할 때의 좌표계와 복원할 때의 좌표계가 다르다는 것. @tanstack/svelte-virtual 같은 아이템 단위 라이브러리도 estimateSize가 같은 자리의 같은 추정치라 동일하게 실패한다(measurementsCache가 컴포넌트와 함께 죽는다). TB2가 실측 spacer 방식으로 간 이유다.
2. 문제 4개와 현재 상태
| # | 문제 | 상태 |
|---|---|---|
| 1 | 전량 마운트 스파이크 — 재마운트 시 실측이 없어 캐시된 전 페이지가 마운트 | ✅ 해결 (3.1) |
| 2 | 펼침 상태 유실 — expandedPostIds가 컴포넌트와 함께 죽음 | ✅ 해결 (3.1) |
| 3 | staleTime refetch — 새 포스트 유입/삭제 시 착지 어긋남·복원 후 밀림 | ✅ 해결 (5장) |
| 4 | gcTime 경과 — 캐시 소멸로 저장값이 가리키던 콘텐츠 자체가 없음 | ✅ 해결 (3.2) |
| + | 복원 직후 미세 밀림 — 더보기 버튼 2단계 렌더 | ✅ 해결 (3.3, 작업 중 발견한 파생 문제) |
3. 적용된 해결책
3.1 상태 수명 정렬 — 모듈 스코프 캐시 (문제 1·2)
초기 설계(폐기 전)는 sessionStorage 스냅샷에 pageHeights·expandedPostIds를 넣고 지문(fingerprint)으로 유효성을 검증하려 했다. 그 복잡도의 근원은 수명 불일치였다: sessionStorage는 TanStack 캐시보다 오래 살아남으므로 "돌아왔을 때 데이터가 그대로인가"를 검증해야 했고, 그 검증이 지문·3분기 테이블·init 사전 주입 함정을 만들었다.
해결은 저장 위치를 바꾸는 것이다. 데이터별 상태를 모듈 스코프에 두면 SPA 내비게이션에서 살아남고, 하드 리로드 시 TanStack 캐시와 함께 죽는다 — 수명이 정확히 일치하므로 검증 자체가 필요 없다.
VirtualPageList.svelte—<script module>의pageHeightsByCacheKey: Map<string, number[]>+cacheKeyprop. init에서 캐시를 읽어pageHeights초기값으로 쓰고onDestroy에서$state.snapshot으로 저장. 길이가 현재pages와 다르면 캐시 폐기 (gcTime 케이스 검출). 읽기·쓰기 모두browser가드 — SSR에서 onDestroy도 실행되므로 쓰기 가드가 실제로 필요하다.isPageMounted의mountedRange === null분기를true → false로 변경. IO가 첫 보고를 하기 전의 공백을 "전부 마운트"가 아니라 "실측 높이 spacer"로 때운다. 높이를 모르는 페이지는 그 위 분기에서 여전히 마운트되므로 첫 방문은 무변경. 이 한 줄이 없으면 캐시를 복원해도 첫 렌더에 전량 마운트돼 캐시가 무의미하다.posts/+page.svelte—<script module>의expandedPostIdsByZone: Map<string, SvelteSet<string>>. 서버에서는 매번 새 Set을 반환해 공유 Map에 쓰지 않는다 (zone이 URL 파라미터라 서버 Map에 쌓이면 임의 키로 증식 가능).
부수 효과: 좌표계가 첫 렌더부터 재현되므로 default 복원(scrollTo(scrollY))만으로 착지가 정확해졌다. 별도 스냅샷 프로토콜 없이.
3.2 gcTime 경과 — 도달 불가 판정 (문제 4)
scroll_restore.ts 복원 경로에서 저장값 > scrollHeight − innerHeight + 1이면 복원을 포기하고 scrollTo(0, 0). gcTime 경과 시 높이 캐시가 길이 가드로 폐기돼 문서가 SSR 1페이지로 짧아지므로 이 판정에 걸린다. 명시적 scrollTo(0, 0)인 이유: SvelteKit이 popstate 때 자체 복원을 시도하므로 가만히 있으면 clamp된 바닥 위치가 남는다.
3.3 복원 직후 미세 밀림 — anchoring 억제
증상: 복원 착지가 보던 위치보다 십수 px 아래. 실측 로그로 확정한 원인:
착지 시점: 카드들의 clamped가 false로 초기화 → 더보기 버튼 없는(짧은) 좌표계에 옛 scrollY 적용
프레임 2~4: ResizeObserver가 clamped 판정 → 버튼 삽입 → 위쪽이 자람
→ Chrome scroll anchoring이 이미 밀린 화면을 붙잡고 scrollY를 +16 보정 → 밀림 고정
Safari(anchoring 없음)에서는 발생하지 않는다 — anchoring이 만든 버그다. 버튼이 없었다면 scrollY가 저장값 그대로 유지되고, 문서가 완성되는 순간 그 값이 저절로 정확해진다.
해결(scroll_restore.ts): 복원 scrollTo 직전 document.documentElement에 overflow-anchor: none을 걸고, scrollHeight가 2프레임 연속 무변화(상한 10프레임) 하면 원복.
const releaseAnchoring = suppressScrollAnchoring(); // 끄고 복구 함수 반환
try {
window.scrollTo(0, savedScrollY);
await waitForStableScrollHeight(signal); // 정착 대기, abort 시 즉시 resolve
} finally {
releaseAnchoring();
}
상수의 근거 — 단위가 프레임인 이유: 기다리는 대상(IO 마운트 → RO 실측 → DOM flush)이 전부 프레임 단위로 양자화된 렌더링 파이프라인 이벤트다. stable 2는 실측 로그에서 관찰된 최대 조용 구간(1프레임) + 1. max 10은 anchoring을 꺼두는 시간의 상한(~160ms)으로, 그 뒤의 변화는 네트워크성이라 이 메커니즘의 대상이 아니다.
3.4 파생 수정 — ReviewCard clamped 측정의 reflow thrashing
기존 $effect는 카드 단위로 "scrollHeight 읽기(강제 레이아웃) → clamped 쓰기(버튼 삽입, dirty)"가 교차해 카드 N장 마운트에 최악 N회 강제 동기 레이아웃을 유발했다. use:measureClamped(ResizeObserver)로 교체 — RO 콜백은 레이아웃 계산 후·페인트 전에 일괄 실행되므로 읽기 전부 → 쓰기 전부 순서가 강제되어 강제 레이아웃 0회. 리사이즈 시 clamped 재계산도 공짜로 얻었다(기존엔 버그).
4. 폐기된 설계 2개
4.1 앵커 스냅샷 (구현 → 롤백)
스냅샷 { scrollY, anchor: { id, delta } }. 이탈 시 뷰포트 상단에 걸친 카드(rect.bottom > 0인 첫 카드)를 data-post-eid로 캡처, 복원 시 scrollTo(scrollY) 착지 후 rAF 루프로 앵커 카드를 rect.top + delta 보정. refetch 대응을 위해 루프 수명을 settled()(= !feed.isFetching) 이후 3프레임까지 연장하고, 사용자 입력(wheel/touchstart/keydown/mousedown) 감지 시 취소.
동작은 했다. 폐기 이유는 복잡도: 앵커 카드 삭제 폴백, 카드가 페이지 경계를 넘는 경우, delta clamp, scroll 이벤트를 취소 신호로 못 쓰는 문제(자기 자신의 scrollBy도 발생시킴), settled 판정과 DOM 반영 사이의 프레임 갭 등 엣지 처리가 본체보다 커졌다.
4.2 ΔH 애니메이션 (구현 → 롤백)
발상 전환: 앵커는 "보던 픽셀 사수"(스크롤이 콘텐츠를 쫓아감), 이건 "변화를 보이게"(스크롤은 두고 밀림을 애니메이션으로 설명). 커서 구조상 refetch로 변하는 건 page 0뿐이고 page 0은 항상 실측되므로, 추적 대상이 포스트 정체성이 아니라 page 0 높이 변화량(ΔH) 숫자 하나가 된다. 앵커의 엣지케이스(삭제·페이지 경계 이동)가 전부 무의미해진다.
구현: VirtualPageList에 onPageHeightChange 콜백 prop → 피드에서 page 0의 eid 시그니처가 바뀐 경우(데이터 변경)에만, page 0이 뷰포트 위로 완전히 지나간 상태일 때 translateY(-ΔH) → 0을 300ms 애니메이션. Chrome 네이티브 anchoring과의 이중 보정을 막기 위해 피드 섹션에 [overflow-anchor:none] 상시 적용이 전제.
폐기 이유: 시그니처 가드·뷰포트 가드·anchoring 전담 전환까지 얹으니 이것도 결국 무거워졌다. 단, 네이티브 anchoring을 끄는 순간 그 보호(refetch 밀림 가림, 위로 스크롤 재마운트 보정)를 우리가 전담해야 한다는 제약은 어떤 재설계에서도 유효하다.
4.3 초기 설계에서 무효화된 것들
- 지문(fingerprint)·복원 3분기·
readScrollSnapshotinit 사전 주입 — 전부 수명 불일치(3.1)의 산물. 모듈 캐시로 근원이 사라졌다. useScrollRestorecustom 모드 / 2함수 분리(useScrollSnapshotRestore) — 시도했으나 오버로드·union·모드 분기가 단일 책임을 해쳐 롤백. 현재는 단일 함수 + 도달 불가 판정만 남았다.
5. 문제 3 해결 — "새 글 N개" pill (적용됨)
두 차례 폐기의 교훈은 "복원 시점에 따라잡기"가 본질적으로 엣지가 많다는 것. 확정한 답은 문제를 뒤집는다: 캐시 = 렌더링 좌표계이므로, 사용자가 명시적으로 적용하기 전까지 캐시를 불변으로 만든다. 밀림을 보정하는 게 아니라 밀림이 발생할 수 없는 구조라 좌표 보정 코드가 0줄이다.
- 피드 쿼리
staleTime: Infinity— 자동 refetch 차단. 뒤로가기 복원이 항상 저장 시점과 같은 데이터 위에서 일어난다. - head 쿼리 — 같은 엔드포인트로 첫 페이지만 받는 감시용
gqlQuery(staleTime: 30초,enabled: browser,refetchOnWindowFocus: true— TanStack 기본값이라 "포커스 시 stale이면 재확인"을 명시한 것. 매 포커스마다 원하면'always'). 렌더링에는 쓰지 않고 캐시 전체 eid에 없는 글 수만 센다. page 0만 비교하면 서버 측 삭제 시 head 꼬리에 딸려온 page 1 글을 새 글로 오판한다 — 전체 비교의 비용은$derived안의 일시적 Set(수십 KB, 캐시된 PostType 본체의 1% 미만)이라 정확성 쪽이 남는 장사다. head가 첫 페이지(10개)만 보므로 count가PAGE_SIZE이상이면"10개+"로 표기한다. - pill UI — count > 0이면 피드 상단 sticky pill 노출(
in:fly, primary 그라데이션). 클릭 시feed.reset()(queryClient.resetQueries— 캐시를 SSR 초기 상태로 되돌려 page 0 하나만 재요청) +scrollTo(0, 0). 적용되면 head와 캐시가 일치해 count가 0이 되고 pill이 사라진다. gqlInfiniteQuery에reset()을 추가했다. 기존refetch()는 캐시된 전 페이지를 순차 재요청(N페이지 = N요청)이라 pill 용도에 부적합.- 트레이드오프: 피드의 새 글 반영 경로가 pill 하나로 일원화된다(자동 갱신 없음). 무한스크롤·mutation 반영은 무변경.
- pill 높이 함정: sticky 래퍼를
flex h-0으로 만들면 기본align-items: stretch가 버튼을 높이 0으로 누른다 →items-start필수.
6. 부수 정비 — gql 래퍼 옵션 타입을 TanStack 확장으로
pill 작업 중 refetchOnWindowFocus를 쓰려다 보니, 래퍼가 enabled·staleTime·gcTime을 손으로 미러링하고 있어 옵션 하나마다 래퍼를 고쳐야 하는 구조였다. CreateQueryOptions/CreateInfiniteQueryOptions를 Omit 후 확장하는 형태로 교체 — 나머지 통과 옵션(refetchInterval, retry, maxPages 등)은 자동으로 열린다.
Omit 목록은 두 부류로 읽는다:
- 교체용 (
queryFn,initialData,getNextPageParam) — 래퍼가 entity store absorb/hydrate로 감싸므로 외부 시그니처가 TanStack 원본과 달라야 한다.&교차로는 교체가 안 된다(같은 키는 교집합이 됨) — 예컨대 TanStack의 함수형initialData: () => data가 살아남으면seed()가 함수 자체를 absorb하는 런타임 버그가 된다. - 차단용 (
select,placeholderData,getPreviousPageParam,behavior) — 캐시 내부의 absorb된 skeleton({ __ref })에서 실행되는 훅이라 hydrate 계층을 우회한다. 열어두면 타입은TData로 통과하는데 런타임엔 skeleton이 들어오는, 타입체커가 못 잡는 함정이 된다. 나중에 필요해지면 Omit에서 빼는 게 아니라 hydrate를 끼워 감싼 버전을 교체용으로 추가한다.
내부 createQuery/createInfiniteQuery 호출에는 경계 캐스트가 생겼다 — 공개 API는 TData, 내부 캐시는 skeleton(unknown)인 이중 구조의 경계 표시로, hydrate(...) as TPage[]와 같은 성격이다.
gqlInfiniteQuery에는 reset()도 추가했다(queryClient.resetQueries): 캐시를 SSR 초기 상태로 되돌리고 활성 쿼리라 page 0 하나만 자동 재요청된다. refetch()는 캐시된 전 페이지를 순차 재요청(N페이지 = N요청)이라 "최상단에서 새 출발"인 pill 용도에는 reset이 맞다.
7. 열린 항목
- 위로 스크롤 시 spacer 재마운트 과도기 — 재마운트 카드의 더보기 버튼이 1~2프레임 늦게 붙으며 높이가 출렁인다(실측 로그에서 −64/+64 관찰). 현재는 네이티브 anchoring이 가리는 영역. 모바일 실기기에서 체감되면 TB2 6장식 rAF 보정 검토.
- 리사이즈/회전 시
pageHeights무효화 — spacer 높이가 옛 폭 기준으로 굳는 문제. 미해결.
8. 검증 시나리오
- 기본 왕복 — 깊이 스크롤 → 상세 → 뒤로가기 반복. 보던 픽셀 복원 + 드리프트 0 + 복원 직후 마운트가 윈도우 수준(Elements 패널).
- 펼침 복원 — 카드 펼치고 왕복 → 펼침 유지.
- 미세 밀림 — 복원 직후 scrollY가 저장값에서 안 움직이는지 (3.3의 suppress 동작 확인).
- 케이스 gcTime —
gcTime을 임시 10초로 낮춰 "복원 포기 → 최상단" 확인. - pill 동작 — 다른 탭/기기에서 새 포스트 작성 → 피드 탭 포커스(head staleTime 30초 경과 후) → "새 글 N개" pill 등장 → 클릭 시 최상단 이동 + page 0 새로 로드 + pill 소멸. 깊은 위치에서 refetch로 화면이 밀리는 일은 구조적으로 없어야 한다.
9. 최종 파일 구성
| 파일 | 역할 |
|---|---|
src/lib/components/interface/common/VirtualPageList.svelte | 페이지 단위 가상화 + <script module> 높이 캐시(cacheKey prop). 길이 가드로 gcTime 케이스 자체 검출 |
src/routes/posts/+page.svelte | zone별 펼침 캐시(<script module>), 피드 쿼리(staleTime: Infinity), head 쿼리 + pill UI, useScrollRestore 연결 |
src/lib/utils/helpers/scroll_restore.ts | 단일 useScrollRestore: 이탈 시 저장 → popstate 복원, 도달 불가 시 포기(최상단), 정착 시까지 anchoring 억제 |
src/lib/components/interface/review/ReviewCard.svelte | use:measureClamped(ResizeObserver) — clamped 측정, reflow thrashing 없음 |
src/lib/api/gql.svelte.ts | 옵션 타입 = TanStack Omit 확장(교체용/차단용), gqlInfiniteQuery.reset() |