Next.js Pages Router에서 Scroll Restoration 직접 구현하기

Next.js Pages Router에서 Scroll Restoration 직접 구현하기

Next.js Pages Router에서 Scroll Restoration 직접 구현하기

배경

기존에 Next.js 13 Pages Router로 운영 중이던 프로젝트를 Next.js 15로 업데이트한 후, 뒤로가기/앞으로가기 시 스크롤 위치가 전혀 복원되지 않는 문제가 발생했다.

next.config.jsscrollRestoration: true가 설정되어 있었고 Next.js 13에서는 정상 동작했지만, 15로 업데이트 후 동작하지 않았다.


원인 분석

scrollRestoration: true가 작동하지 않는 이유

Next.js의 scroll restoration은 next/dist/client/router.js 내부에서 처리된다. 그런데 _app.tsxApp.getInitialProps가 존재하면, 모든 페이지 이동이 서버 데이터를 새로 fetch하는 hard navigation으로 처리되어 스크롤 복원 로직이 스킵된다.

// _app.tsx App.getInitialProps = async ({ Component, ctx }: AppContext) => { // 이게 있으면 scrollRestoration: true 무력화 ... };

getInitialProps를 제거하면 해결되지만, 서버사이드 공통 데이터 fetch가 필요한 경우 제거할 수 없다. 직접 구현이 필요하다.

App Router를 사용하는 경우 _app.tsxgetInitialProps 개념 자체가 없고 layout.tsx 구조로 대체되므로 이 문제가 발생하지 않는다. 브라우저 native scroll restoration이 기본적으로 잘 동작한다.


1차 구현 — 수동 Scroll Restoration

// _app.tsx const shouldRestore = useRef(false); useEffect(() => { if ('scrollRestoration' in window.history) { window.history.scrollRestoration = 'manual'; } const saveScroll = () => { const pathWithoutHash = router.asPath.split('#')[0]; sessionStorage.setItem(`scroll:${pathWithoutHash}`, String(window.scrollY)); }; const onRouteChangeStart = () => { saveScroll(); shouldRestore.current = true; // ❌ 문제의 코드 }; const onRouteChangeComplete = (url: string) => { if (url.includes('#')) { shouldRestore.current = false; return; } if (!shouldRestore.current) return; shouldRestore.current = false; const path = url.split('#')[0]; const y = sessionStorage.getItem(`scroll:${path}`); if (y !== null) { requestAnimationFrame(() => window.scrollTo(0, Number(y))); } }; router.events.on('routeChangeStart', onRouteChangeStart); router.events.on('routeChangeComplete', onRouteChangeComplete); window.addEventListener('beforeunload', saveScroll); return () => { router.events.off('routeChangeStart', onRouteChangeStart); router.events.off('routeChangeComplete', onRouteChangeComplete); window.removeEventListener('beforeunload', saveScroll); }; }, [router]);

뒤로가기/앞으로가기 시 스크롤 복원은 됐지만 새로운 문제가 발생했다.


추가 이슈 — 탭/링크 클릭 이동도 스크롤을 복원해버림

예를 들어 /footwear/sandals-slides에서 바닥까지 스크롤한 뒤, 네비게이션 탭을 클릭해서 /footwear/boots로 이동했다가 다시 /footwear/sandals-slides 탭을 클릭하면, 이전에 저장된 바닥 위치로 복원되어버렸다.

원인

onRouteChangeStart에서 shouldRestore.current = true항상 켜버렸기 때문이다. 뒤로가기뿐 아니라 일반 링크 클릭도 routeChangeStart를 거치므로, 모든 이동에서 복원이 발동됐다.


최종 해결 — popstate로 뒤로가기/앞으로가기만 감지

핵심은 단순하다. popstate 이벤트는 뒤로가기/앞으로가기에서만 발생한다. 일반 링크 클릭(router.push)에서는 발생하지 않는다.

shouldRestore 플래그를 routeChangeStart가 아니라 popstate에서만 켜면 된다.

// _app.tsx const shouldRestore = useRef(false); useEffect(() => { if ('scrollRestoration' in window.history) { window.history.scrollRestoration = 'manual'; } const saveScroll = () => { const pathWithoutHash = router.asPath.split('#')[0]; sessionStorage.setItem(`scroll:${pathWithoutHash}`, String(window.scrollY)); }; const onRouteChangeStart = () => { saveScroll(); // 이동 전 현재 위치만 저장 }; const onRouteChangeComplete = (url: string) => { if (url.includes('#')) { shouldRestore.current = false; return; } if (!shouldRestore.current) return; shouldRestore.current = false; const path = url.split('#')[0]; const y = sessionStorage.getItem(`scroll:${path}`); if (y !== null) { requestAnimationFrame(() => window.scrollTo(0, Number(y))); } }; // ✅ 뒤로가기/앞으로가기일 때만 복원 플래그 ON const onPopState = () => { shouldRestore.current = true; }; router.events.on('routeChangeStart', onRouteChangeStart); router.events.on('routeChangeComplete', onRouteChangeComplete); window.addEventListener('popstate', onPopState); window.addEventListener('beforeunload', saveScroll); return () => { router.events.off('routeChangeStart', onRouteChangeStart); router.events.off('routeChangeComplete', onRouteChangeComplete); window.removeEventListener('popstate', onPopState); window.removeEventListener('beforeunload', saveScroll); }; }, [router]);

동작 흐름 정리

| 이동 방식 | popstate 발생 | shouldRestore | 결과 | | --- | --- | --- | --- | | 링크/탭 클릭 (router.push) | ❌ | false | 스크롤 탑 (복원 안 함) | | 뒤로가기 | ✅ | true | 저장된 위치 복원 | | 앞으로가기 | ✅ | true | 저장된 위치 복원 |


참고 — scroll: false와의 조합

특정 페이지에서 router.push(url, undefined, { scroll: false })를 사용하는 경우, Next.js가 스크롤을 건드리지 않고 popstate도 발생하지 않아 복원 로직이 개입하지 않는다. 이 케이스는 해당 컴포넌트에서 별도로 스크롤 위치를 직접 제어하면 된다.

JP
이중표Frontend Engineer

3년차 프론트엔드 개발자. Next.js, React, TypeScript 기반 웹 애플리케이션 개발 전문. 대규모 트래픽 환경에서 SSR·ISR 렌더링 전략 설계 경험.

이력서 보기