← Cases
Case 01사용자 웹 A2026.08severity medium

뒤로가기를 하면 보던 자리가 아니다

스크롤 위치가 왕복마다 밀리는 문제

01 · 문제
포스트를 보고 뒤로가기를 누르면 보던 자리보다 아래에 떨어진다. 왕복할수록 더 밀리고, 펼쳐 둔 글은 접혀 있다
02 · 분석
스크롤 위치는 좌표계에 종속된 숫자다추정 높이와 실측 높이의 좌표계 불일치브라우저 scroll anchoring의 개입상태의 수명 불일치갱신 중 스냅샷이 바뀌는 문제
03 · 04 · 해결 방안과 결론
좌표를 보정하는 대신 좌표계가 바뀌지 않게 만들었다. 상태는 데이터와 수명을 맞추고, 자동 갱신은 끄고 새 글은 사용자가 적용하게 했다
결과
왕복 반복 시 착지 오차 0px좌표 보정 코드 0줄뒤로가기 시 전 페이지 일괄 마운트 스파이크 제거
SvelteKitTanStack QueryCSS Scroll Anchoring

사내 프로젝트라 이름은 A(사용자용 웹), B(어드민)로 표기한다. 이 글은 A에서 있었던 일이고, 무한스크롤 피드의 메모리 상한의 후속이다.

문제

피드에서 포스트를 눌러 상세 페이지로 간다. 다 보고 뒤로가기를 누른다. 사용자가 기대하는 것은 하나다. 방금 보던 그 자리.

실제로는 이랬다.

  • 보던 자리보다 아래에 떨어진다. 카드 한두 장 아래다. 사용자는 위로 스크롤해서 보던 곳을 다시 찾는다.
  • 왕복할수록 더 밀린다. 뒤로가기 → 앞으로가기 → 뒤로가기를 반복하면 매번 카드 하나씩 더 아래로 간다.
  • 펼쳐 둔 글이 접혀 있다. 긴 본문을 "더보기"로 펼친 채 상세로 갔다가 돌아오면 다시 접혀 있다. 그래서 위치도 또 달라진다.
  • 오래 있다가 돌아오면 맨 위다. 상세에서 몇 분 머물다 뒤로가면 피드가 처음부터 다시 시작된다.

사용자 입장에서 뒤로가기는 "취소"다. 취소했는데 원래 상태가 아니면 신뢰가 깨진다. 피드를 탐색하다 상세를 들락날락하는 것이 이 서비스의 기본 사용 패턴이라, 가장 잦은 동작이 매번 조금씩 틀리는 상황이었다.

분석

스크롤 위치는 숫자 하나이고, 그 숫자는 좌표계에 종속된다

브라우저와 SvelteKit의 스크롤 복원은 단순하다. 떠날 때 scrollY를 저장하고, 돌아오면 scrollTo(저장값)한다. 이 방식이 맞으려면 조건이 하나 있다. 저장할 때의 문서와 복원할 때의 문서가 같은 높이 구조여야 한다. scrollY = 5,755는 "문서 위에서 5,755px 아래"라는 뜻일 뿐이다. 그 자리에 무엇이 있는지는 문서가 정한다. 문서 높이가 달라지면 같은 숫자가 다른 콘텐츠를 가리킨다.

즉 문제는 저장된 숫자가 틀린 게 아니라, 저장할 때의 좌표계와 복원할 때의 좌표계가 다르다는 것이었다.

좌표계가 달라지는 이유 — 추정 높이

가상 스크롤 도입 전, 피드 카드는 content-visibility: auto에 contain-intrinsic-size: auto 700px로 화면 밖에서 700px 자리를 잡았다. 실제 높이는 400~1,200px이다. 이게 왕복마다 저장값을 부풀렸다. 실측 로그로 확인한 연쇄다.

① 이탈     지나온 카드들은 실제 높이를 가진 좌표계 → scrollY = 5,755 저장
② 뒤로가기  컴포넌트가 새로 만들어지고 실측이 사라짐 → 화면 밖 카드는 다시 700px 추정치
           → 문서가 압축된 좌표계
③ 복원     압축된 좌표계에 scrollTo(5,755) → 원래보다 아래 카드에 착지
④ 착지 후  주변 카드가 뷰포트에 들어오며 실제 높이로 부풂
           → 브라우저 scroll anchoring이 "보던 것을 유지"하려고 scrollY를 밀어 올림
⑤ 다음 이탈 부풀어 오른 scrollY가 저장됨 → 5,755 → 6,213 → 6,671 → … 매 사이클 +458~800px

@tanstack/svelte-virtual 같은 아이템 단위 라이브러리도 같은 자리에 같은 추정치(estimateSize)를 갖고 있어서 동일하게 실패한다. 측정 캐시가 컴포넌트와 함께 죽기 때문이다. 앞 글에서 실측 spacer 방식으로 간 이유이기도 하다.

가상 스크롤을 넣었더니 문제의 모양이 바뀌었다

앞 글의 가상 스크롤은 언마운트 직전 실측 높이를 spacer에 넣어 좌표계를 고정한다. 그런데 뒤로가기에서는 새 문제가 나왔다. 실측 높이(pageHeights)와 펼침 상태(expandedPostIds)가 컴포넌트 안에 살고 있어서, 페이지를 떠나면 컴포넌트와 함께 사라진다. 돌아오면 실측이 하나도 없다.

#문제원인
1뒤로가기 순간 전 페이지가 한꺼번에 마운트되는 스파이크실측이 없으니 spacer를 만들 수 없어 전부 마운트
2펼침 상태 유실 → 높이가 바뀜expandedPostIds가 컴포넌트와 함께 죽음
3돌아오는 사이 새 글이 들어오면 착지가 어긋남staleTime 만료 후 refetch로 데이터 스냅샷이 바뀜
4오래 있다 돌아오면 맨 위gcTime 경과로 캐시 소멸 → 문서가 SSR 1페이지로 짧아져 저장값이 가리키던 콘텐츠 자체가 없음

상태의 수명이 서로 다르다

1·2번을 풀려면 높이와 펼침 상태를 어딘가에 보관해야 한다. 처음 설계는 sessionStorage에 스냅샷을 넣는 것이었다. 그런데 설계하다 보니 검증 코드가 자꾸 늘었다. 이유를 따져 보니 수명 불일치였다.

sessionStorage        탭이 살아 있는 동안   ← 가장 길다
TanStack 쿼리 캐시    gcTime까지, 새로고침이면 소멸
컴포넌트 로컬 상태     마운트 ~ 언마운트     ← 가장 짧다

높이는 데이터(쿼리 캐시)에 종속된 값이다. 캐시가 사라졌으면 높이도 의미가 없고, 캐시가 살아 있으면 높이도 살아 있어야 한다. 그런데 sessionStorage는 캐시보다 오래 산다. 그러니 돌아왔을 때 "이 높이가 지금 데이터의 높이가 맞는가"를 지문(fingerprint)으로 검증해야 했고, 검증이 분기를 만들고, 분기가 버그 표면을 만들었다. 복잡도의 근원은 저장 방식이 아니라 저장 위치의 수명이 데이터의 수명과 다른 것이었다.

브라우저의 scroll anchoring이 개입한다

④번에서 나온 scroll anchoring은 브라우저 기능이다. 뷰포트 위쪽의 콘텐츠 높이가 바뀌면(이미지 로드, 광고 삽입 등) 보던 내용이 밀리지 않도록 브라우저가 scrollY를 자동으로 보정한다. 평소에는 고마운 기능인데, 복원 직후에 문서가 아직 완성되지 않은 상태에서는 우리가 방금 넣은 scrollY를 브라우저가 고쳐 버린다.

가상 스크롤 도입 후에도 십수 px이 밀리는 잔여 증상이 있었는데, 실측 로그로 원인을 잡았다.

착지 시점   카드들의 clamped 판정이 아직 false → 더보기 버튼이 없는(짧은) 좌표계에 옛 scrollY 적용
프레임 2~4  ResizeObserver가 clamped 판정 → 더보기 버튼 삽입 → 뷰포트 위쪽이 자람
            → Chrome scroll anchoring이 화면을 붙잡고 scrollY를 +16 보정 → 밀림이 고정됨

Safari에는 scroll anchoring이 없어서 이 증상이 없었다. anchoring이 만든 버그다. 버튼이 없었다면 scrollY는 저장값 그대로 유지되고, 문서가 완성되는 순간 그 값이 저절로 정확해진다.

갱신 중에 데이터가 바뀌면 좌표계도 바뀐다

3번은 성격이 다르다. 높이를 완벽히 복원해도, 돌아오는 사이 staleTime이 지나 피드가 refetch되면 첫 페이지에 새 글이 들어오고 모든 카드가 아래로 밀린다. 좌표계가 바뀐 것이다. DB에서 트랜잭션 중에 다른 트랜잭션의 커밋이 보이면 같은 쿼리가 다른 결과를 주는 것과 같은 모양이다. 복원이 정확하려면 "떠날 때 본 데이터"와 "돌아와서 보는 데이터"가 같은 스냅샷이어야 한다.

해결 방안

높이·펼침 상태를 어디에 둘 것인가 (문제 1·2)

선택지수명장점단점판단
A. sessionStorage 스냅샷 + 지문 검증탭새로고침에도 살아남음캐시보다 오래 살아 검증 필요. 지문·분기 코드 증가폐기
B. 모듈 스코프 변수SPA 세션 (새로고침이면 소멸)쿼리 캐시와 수명이 같다. 검증 불필요, 코드 단순새로고침 후에는 없음 — 그때는 캐시도 없으니 문제가 안 됨채택
C. 쿼리 캐시 안에 높이를 함께 저장캐시수명 완벽 일치서버 데이터에 UI 상태를 섞음. refetch 시 덮어씀부적합

복원 위치를 어떻게 정할 것인가

선택지방법장점단점판단
D. scrollY 그대로 (기본 복원)좌표계만 같으면 정확코드 0줄좌표계가 바뀌면 틀림 → 좌표계를 고정하는 것이 전제채택 (B와 함께)
E. 앵커 기반 복원"보던 카드 id + 카드 안 오프셋" 저장 후 그 카드를 찾아 스크롤좌표계가 바뀌어도 정확. 새 글이 들어와도 맞음카드가 삭제됐을 때 fallback. 카드까지 마운트해야 위치를 알 수 있어 가상 스크롤과 얽힘. 구현량승격 후보로 보류

갱신을 어떻게 다룰 것인가 (문제 3)

선택지방법장점단점판단
F. 자동 refetch 유지 + 좌표 보정새 글 수만큼 높이를 계산해 scrollY에 더함피드가 스스로 최신새 글의 높이는 렌더 전엔 모른다 → 다시 추정. 삭제된 글은 음수 보정. 보정 코드가 계속 자란다부적합
G. 피드를 불변으로 + "새 글 N개" 버튼staleTime: Infinity. 새 글은 별도 head 쿼리로 감시하고 사용자가 눌러 적용좌표계가 바뀔 일이 없다. 보정 코드 0줄피드가 스스로 갱신되지 않는다. 새 글을 보는 경로가 버튼 하나채택
H. 서버가 스냅샷 커서를 제공조회 시점을 고정한 커서서버 수준의 일관성백엔드 변경. 이 문제에 과함기각

결론

B + D + G를 적용하고, 4번(gcTime)과 anchoring 잔여 증상을 각각 처리했다.

상태 수명 정렬 — 모듈 스코프 캐시

VirtualPageList가 <script module>의 Map에 cacheKey별로 pageHeights를 보관한다. 컴포넌트가 사라질 때 저장하고 다시 만들어질 때 읽어 초기값으로 쓴다. 저장된 배열 길이가 지금 pages 길이와 다르면 낡은 것이니 버린다. expandedPostIds도 같은 방식이다.

첫 렌더의 빈틈도 막았다. IntersectionObserver가 첫 보고를 하기 전에는 어느 페이지가 보이는지 모른다. 원래는 이 순간 전부 마운트했는데, 높이를 아는 페이지는 spacer로 두도록 바꿨다. 이게 없으면 높이를 복원해도 첫 렌더에 전량 마운트돼 캐시가 의미 없다. 높이를 모르는 페이지는 여전히 마운트되니 첫 방문 동작은 그대로다.

결과적으로 뒤로가기 첫 렌더부터 좌표계가 이탈 시점과 같아져서, 기본 복원 scrollTo(scrollY)만으로 정확한 위치에 착지한다. 별도 스냅샷도, 좌표 보정도 없다.

gcTime 경과 — 도달 불가 판정

복원 경로에서 저장값 > scrollHeight − innerHeight + 1이면 복원을 포기하고 scrollTo(0, 0)한다. gcTime이 지나 캐시가 사라지면 문서가 SSR 1페이지로 짧아지므로 이 판정에 걸린다. 명시적으로 맨 위로 보내는 이유는, SvelteKit이 popstate 때 자체 복원을 시도하므로 가만히 두면 clamp된 바닥 위치가 남기 때문이다. "보던 자리로 못 돌아간다"는 피할 수 없지만, 적어도 **예측 가능한 자리(맨 위)**로 간다.

anchoring 억제 — 문서가 안정될 때까지만

복원 scrollTo 직전에 document.documentElement에 overflow-anchor: none을 걸고, scrollHeight가 2프레임 연속 변하지 않으면(상한 10프레임) 원복한다. anchoring을 영구히 끄는 것이 아니다. 문서가 완성되는 짧은 구간에만 브라우저의 보정을 멈추고, 그 뒤에는 평소대로 돌려준다.

const releaseAnchoring = suppressScrollAnchoring(); // 끄고 복구 함수 반환
try {
	window.scrollTo(0, savedScrollY);
	await waitForStableScrollHeight(signal);          // 정착 대기, abort 시 즉시 resolve
} finally {
	releaseAnchoring();
}

파생 수정 — clamped 판정을 ResizeObserver로

anchoring 문제를 쫓다가 카드의 clamped 판정 코드가 reflow thrashing을 일으키고 있는 것을 발견했다. 기존 $effect는 카드마다 "scrollHeight 읽기(강제 레이아웃) → clamped 쓰기(버튼 삽입, dirty)"가 교차해서 카드 N장 마운트에 최악 N회 강제 동기 레이아웃을 냈다. ResizeObserver로 바꿨다. RO 콜백은 레이아웃 계산 후·페인트 전에 일괄 실행되므로 읽기 전부 → 쓰기 전부 순서가 강제되어 강제 레이아웃이 0회가 된다. 리사이즈 시 clamped 재계산도 공짜로 얻었다(기존엔 버그였다).

"새 글 N개" — 갱신을 사용자에게 넘긴다

  • 피드 쿼리는 다시 받지 않는다. staleTime: Infinity. 뒤로가기 복원은 항상 이탈 시점과 같은 데이터 위에서 일어난다.
  • 새 글은 별도 쿼리로 감시한다. 같은 API로 첫 페이지 10개만 받는 head 쿼리를 하나 더 둔다. 30초 stale에 창 포커스마다 재확인하고, 화면에는 그리지 않는다. head에 있는데 피드 캐시 전체에 없는 글의 수만 센다. 첫 페이지만이 아니라 캐시 전체와 비교하는 이유는, 서버에서 글이 지워지면 head 꼬리에 원래 2페이지였던 글이 딸려 올라와 새 글로 오판되기 때문이다.
  • 적용은 사용자가 한다. 셈이 0보다 크면 피드 상단에 "새 글 N개" 버튼을 띄운다. 클릭하면 head가 이미 받아 둔 첫 페이지로 피드 캐시를 통째로 갈아 끼우고 맨 위로 올린다. 재요청은 없다.

감수한 부작용

  • 피드가 스스로 최신이 되지 않는다. 사용자가 버튼을 눌러야 새 글이 보인다. 자동 갱신을 포기한 대가로 좌표 보정 코드 0줄을 얻었다. 이 서비스의 피드는 실시간성이 핵심이 아니라 감당할 수 있는 트레이드오프라고 판단했다. 실시간 피드였다면 E(앵커 복원)로 갔을 것이다.
  • gcTime이 지나면 맨 위로 간다. 보던 자리 복원은 포기했다. 캐시가 없는데 위치만 있는 것은 의미가 없다.
  • anchoring을 잠깐 끄는 동안 이미지 로드로 화면이 밀릴 수 있다. 2프레임 안정 판정으로 구간을 최소화했지만 0은 아니다.

운영에서 보는 것

  1. 복원 오차 로그. 개발 모드에서 복원 완료 후 |scrollY − 저장값|을 콘솔에 찍는다. 정상이면 0이다. 0이 아니면 어딘가에서 좌표계가 바뀐 것이고, 그 값이 곧 "얼마나 바뀌었는가"다.
  2. 실기기 왕복 테스트. Chrome(anchoring 있음)과 Safari(없음) 양쪽에서 뒤로가기 → 앞으로가기 → 뒤로가기를 5회 반복해 착지가 같은지 본다. 피드나 카드 레이아웃을 건드리는 PR의 체크 항목이다.
  3. head 쿼리 오판. "새 글 N개"의 N이 실제와 다르면 사용자가 눌러도 아무것도 안 바뀌는 경험이 된다. 버튼 클릭 후 실제로 캐시에 추가된 글 수를 N과 비교해 다르면 개발 모드에서 경고한다.
  4. E로 승격할 조건. 글 삭제·정렬 변경이 잦아져 "같은 스냅샷" 전제가 자주 깨지거나, 피드에 실시간성이 요구되면 앵커 기반 복원으로 옮긴다. 그때는 이 문서의 F를 하지 않고 E로 간다.

남긴 것

상태는 데이터와 수명을 맞춘다. sessionStorage에 넣으면 캐시보다 오래 살아 검증이 필요했고, 검증이 분기를 만들었다. 모듈 스코프로 옮기자 SPA 이동에서는 살고 새로고침에서는 캐시와 함께 죽어 검증 자체가 사라졌다. 어디에 저장할지가 얼마나 복잡해질지를 정했다.

갱신을 막는 것도 설계다. 좌표를 보정하는 방향으로 갔으면 새 글 높이 추정, 삭제 보정, 브라우저별 anchoring 차이까지 끝없이 대응해야 했다. 좌표계가 바뀌지 않게 만들자 보정할 것이 없어졌다. 문제를 잘 푸는 것보다 문제가 생길 수 없는 구조를 고르는 게 나은 경우가 있다는 것을 배웠다.

사례 전체와 이력 보기