SPA에서 스크롤 위치 복원이 어려운 이유
브라우저는 원래 페이지를 떠났다가 뒤로가기로 돌아오면 스크롤 위치를 그대로 기억해서 되돌려준다.
history.scrollRestoration이라는 API 하나로 설명되는 동작이다. 값은 auto(기본값, 브라우저가 알아서 복원)와 manual(개발자가 직접 처리) 둘 뿐이다. 그리고 이 복원은 popstate 이벤트, 그러니까 뒤로가기/앞으로가기에만 붙는다.
문제는 SPA에서 <Link>로 페이지를 이동하는 건 뒤로가기가 아니라는 점이다. pushState는 히스토리에 새 엔트리를 추가하기만 할 뿐, 브라우저 입장에서는 스크롤 복원을 걸 지점 자체가 없다.
그래서 Home에서 Blog로, 다시 Home으로 돌아왔을 때 스크롤이 위로 튕기는 걸 그냥 두면 계속 튕긴다. 뒤로가기로 돌아왔을 때만 브라우저가 알아서 챙겨주고, 링크 클릭으로 이동하면 아무도 안 챙겨준다.
프레임워크마다 다르게 때운다
각자 다른 지점에서, 다른 방식으로 이 빈틈을 메운다.
| 언제 동작하나 | 방식 | 한계 | |
|---|---|---|---|
| 브라우저 네이티브 | popstate(뒤로/앞으로가기)만 | 세션 히스토리 엔트리마다 자동 저장 | 클라이언트 라우팅 네비게이션엔 안 붙음 |
React Router <ScrollRestoration> | 모든 라우트 전환 | location.key 기준 메모리에 저장, 로더 완료 후 복원 | SSR 프레임워크 없이 쓰면 초기 로드 시 깜빡임 |
| Next.js App Router | 모든 라우트 전환 | 새 라우트는 top, 뒤/앞은 유지 (앵커 링크 흉내) | scroll: false로 끌 수는 있지만 커스텀 위치 복원은 직접 구현해야 함 |
React Router는 데이터 로더가 다 끝난 뒤에 복원한다. JS 번들 로딩, 데이터 로딩, 렌더링이 다 끝나야 실제 문서 높이를 알 수 있으니, 그전에 복원을 시도하면 당연히 깨진다는 걸 공식 문서도 인정한다.
Next.js는 왜 이렇게 설계했는지 더 깊은 이유는 문서에 없다. 스트리밍이나 prefetch 같은 내부 구현과 얽혀 있는지는 확인할 수 없었다.
커스텀 스크롤 라이브러리를 쓰면 문제가 두 배가 된다
여기까지는 프레임워크 대 브라우저 얘기다.
Lenis 같은 부드러운 스크롤 라이브러리를 얹으면 문제가 하나 더 생긴다. 이 라이브러리들은 실제 페이지를 스크롤하는 대신 transform으로 콘텐츠를 움직이는 가상 스크롤 방식을 쓴다. window.scrollY가 더 이상 사용자가 실제로 보고 있는 위치를 그대로 반영하지 않고, 라이브러리의 내부 상태를 거쳐야 한다.
게다가 이런 라이브러리는 보통 마우스 휠 이벤트를 window 레벨에서 통째로 가로챈다. 페이지 안에 중첩된 스크롤 영역(모달, 사이드 패널 같은)이 있으면 그쪽 스크롤까지 먹통이 되기도 한다.
프레임워크가 하나 풀어둔 문제를, 라이브러리가 다시 꼬아놓는 셈이다.
실제로 구현하려면 부딪히는 두 가지 문제
이 블로그에도 Home → Blog → Home 이동 시 스크롤을 복원하는 기능을 직접 만들면서 겪은 문제는 결국 두 가지로 좁혀졌다.
언제 저장할까
가장 먼저 떠오르는 방법은 "페이지를 떠나는 시점에 스크롤 위치를 한 번 저장한다"이다. React라면 컴포넌트의 cleanup 함수에서 저장하면 될 것 같다.
그런데 실제로 해보면 저장되는 값이 항상 0이다.
원인은 Next.js가 네비게이션을 시작하면서 화면을 스크롤 맨 위로 되돌리는 처리를, React가 이 컴포넌트의 cleanup을 실행하기도 전에 먼저 해버리기 때문이다. cleanup이 실행될 때는 이미 스크롤이 0으로 초기화된 뒤라, "떠나는 시점에 캡처"라는 접근 자체가 성립하지 않는다.
그래서 방향을 바꿨다. 스크롤이 일어날 때마다 최신 위치를 계속 sessionStorage에 흘려 저장한다. 떠나는 시점을 정확히 잡으려 하지 않고, 그 직전까지의 값이 항상 최신 상태로 남아 있게 만드는 것이다.
언제 복원할까
저장은 됐는데, 이번엔 복원 타이밍이 문제였다.
페이지가 다시 마운트된 직후 저장해둔 위치로 스크롤을 옮겨봤더니, 목표 지점보다 훨씬 짧은 곳에서 멈췄다. 원인은 문서의 실제 높이가 마운트 직후 한 번에 갖춰지지 않고 몇 프레임에 걸쳐 자란다는 것. 스크롤 트리거 기반 애니메이션이 있는 페이지라면 특히 그렇다.
문서가 아직 짧을 때 스크롤을 시도하면, 스크롤 가능한 최대 범위에 걸려 중간에서 멈춰버린다.
한 번만 시도하지 말고, 문서 높이가 다 자랄 때까지 여러 프레임에 걸쳐 다시 시도하는 쪽으로 바꿨다. 그런데 이번엔 "맨 위 → 목표 위치"로 튀어 오르는 게 눈에 보이는 문제가 생겼다.
최종적으로는 첫 페인트 전에 문서를 잠깐 숨겨두고, 목표 위치에 도달했는지 매 프레임 확인하다가 도달하는 순간에만 다시 보여주는 방식으로 정리했다. 사용자 입장에서는 페이지가 이동한 순간 이미 원래 있던 위치에 가 있는 것처럼 보인다.
정리
"스크롤 위치 하나 저장하면 되는 거 아니야?"라고 생각했다가, 저장 시점과 복원 시점 둘 다 프레임워크의 내부 타이밍과 부딪히는 문제라는 걸 알게 됐다.
브라우저 네이티브 기능은 뒤로가기에만 붙어 있고, 프레임워크는 저마다 다른 지점에서 스크롤을 건드리고, 그 위에 커스텀 스크롤 라이브러리까지 얹으면 손댈 지점이 하나 더 늘어난다. 결국 "언제 저장하고 언제 복원할지"를 명확히 하는 게 전부였다.
참고
댓글
불러오는 중...