React Router ScrollRestoration 디버깅 기록 — 왜 스크롤이 안 움직였을까?
React Router ScrollRestoration 디버깅 기록 — 왜 스크롤이 안 움직였을까?
Single-Page Application(이하 SPA)에서 뒤로·앞으로 네비게이션을 했을 때 유저가 읽던 위치로 스크롤을 복원해 주는 일은 UX 품질을 좌우합니다. React Router v6.4+는 이를 위해
이 글은
⸻
목차
1. 문제 상황 설명
2. <ScrollRestoration /> 한 줄 추가 → 실패
3. “루트 스크롤 잠금”이 가져오는 파국
4. 과연 overflow 가 진짜 범인인가? (실험 & 증명)
5. React Router v7 <ScrollRestoration /> 내부 동작 해부
6. 해결 전략 비교 — 루트 스크롤 해제 vs 우회 구현
7. 우회 구현: Zustand + data-id + scrollIntoView
8. 체크리스트 & 실전 팁
9. 회고 — 같은 삽질을 피하려면?
⸻
1. 문제 상황 설명
구조
• /qna : 무한 스크롤 Q&A 목록
• /answer/:id, /complaint/:id : 상세 뷰
• 요구 : 상세 → 뒤로/앞으로 시 목록에서 이전 스크롤 위치 복원
글로벌 CSS
html, body {
height: 100vh; /* (1) 문서 높이를 뷰포트와 완전히 동일하게 */
overflow: hidden; /* (2) 루트 스크롤바 완전 제거 */
}
.c-qna {
height: 100%;
overflow: auto; /* 실제 스크롤은 이 div가 담당 */
}
height: 100vh 는 모바일 WebView 이슈, overflow:hidden 은 패럴랙스 효과 때문에 반드시 유지해야 하는 조건이었다.
CSS 고수라면 이것만 보고도 문제의 원인을 짐작하실 수 있을 것이다.
⸻
2. ScrollRestoration 한 줄 추가 → 실패
<Outlet />
옆에 한 줄만 추가하면 된다는 React router의 호언장담과 달리 스크롤은 전혀 동작하지 않았다.
function AppLayout() {
return (
<>
<ScrollRestoration storageKey="home" />
<Outlet />
</>
);
}
• 기대 : 뒤로/앞으로 때 자동 복원
• 현실 : 스크롤은 늘 0 px, 경고·오류도 없음 → 디버깅 시작
브라우저 DevTools > Performance 패널 확인
• 뒤로 버튼 클릭했으나
• 정말로 스크롤 이동이 전혀 발생하지 않는다!
⸻
3. “루트 스크롤 잠금”이 가져오는 파국
관찰 결과
window.scrollTo(0, 400) 실행 했으나, 여전히 스크롤은 0px.
const root = document.scrollingElement; // <html>
console.log(root.scrollHeight, root.clientHeight); // 같음
window.scrollTo(0, 300); // 효과 없음
반면 내부 컨테이너의 scrollTop 은 값이 변하며 실제 스크롤바도 표시 → 실제 스크롤 주체가 div임 확인.
브라우저는 루트가 “움직일 수 없다”고 판단하므로 루트-타깃 API(window.scrollTo, ScrollRestoration)가 전부 무력화 된다.
⸻
4. 과연 overflow가 진짜 범인인가?
4-1 실험 ① — overflow:hidden 제거
html, body {
height: 100vh;
/* overflow: hidden; ⬅︎ 주석 */
}
• window.scrollTo 즉시 정상 동작
• <ScrollRestoration />도 바로 복원 성공
4-2 실험 ② — overflow 유지, height:100vh만 제거
html, body {
/* height: 100vh; ⬅︎ 주석 처리 */
overflow: hidden;
}
• 여전히 스크롤 불가 (루트가 overflow hidden 이면 내부 높이와 무관하게 스크롤 차단)
4-3 결론
• overflow:hidden 이 루트 스크롤 차단의 주범
• height:100vh 는 scrollHeight 계산을 1 : 1 로 만드는 보조 요인
• 두 속성이 동시에 있으면 “루트 스크롤 0px”이 확정 → ScrollRestoration 완전 무력화
StackOverflow · jQuery 이슈 등에서도 같은 현상 보고가 다수 있다.  
⸻
5. React Router v7 ScrollRestoration 내부 동작 해부
- route마다 Key를 지정
- 세션 스토리지에 각 Route Key의 현재 스크롤 위치 저장
- POP 이동(뒤로/앞으로)할 경우, 세션 스토리지를 검사하여 스크롤 위치가 존재할 경우, window.scrollTo 함수를 호출해 스크롤 복원
결국 루트 엘리먼트가 스크롤 가능한 구조가 아니면 설계상 작동이 불가능한 구조다.
⸻
6. 해결 전략 비교
- 루트 스크롤 구조로 전환 &
그대로: 레거시 CSS·WebView 대수정은 불가… - React-router의
포크 → 하위 element를 스크롤 하도록 기능 확장: react router의 활발한 버전 변경 때문에 유지 보수가 어려움… - Zustand + scrollIntoView: 레이아웃 변경 필요 없음 & 구현 단순
가장 단순한 Zustand + scrollIntoView를 선택했다
⸻
7. 우회 구현: Zustand + data-id + scrollIntoView
- Zustand 스토어를 만들어서
- 목록 아이템을 클릭할 때마다 zustand에 아이템의 id를 저장하고
- 목록 아이템 html element에 data-id를 부여하고
- useEffect에서 document.querySelector로 zustand의 그 id에 해당하는 element를 선택한 후, scrollIntoView로 스크롤을 복원한다
const navType = useNavigationType();
const targetId = useScrollStore(s => s.targetId);
useEffect(() => {
if (navType !== 'POP' || !targetId) return; // 뒤로가기 & 앞으로 가기 동작에서만 스크롤 복원
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(`[data-id="${targetId}"]`)
?.scrollIntoView({ behavior: 'instant' });
});
}, [navType, targetId]);
⸻
8. 체크리스트 & 실전 팁
1. CSS부터 확인 — html/body overflow, height
2. window.scrollTo 수동 호출 테스트
3. POP vs PUSH 구별(useNavigationType)
4. DOM 완성 타이밍 확보(requestAnimationFrame, Observer)
5. 세션 키/스토어 키 충돌 방지
⸻
9. 회고 — 같은 삽질을 피하려면?
• Root vs Inner 스크롤 분리 여부를 최우선으로 점검하자.
• <ScrollRestoration /> 는 (2025-04 현재) 루트 전용 컴포넌트다. 구조가 맞지 않으면 과감히 우회하거나 다른 라이브러리를 쓰는 편이 빠르다.
• 전역 스토어 + scrollIntoView 방식은 구현이 쉽고 레거시 레이아웃에도 잘 맞는다.
한 줄 요약
“window.scrollTo가 움직이지 못하면
EOD
20250425
Leave a comment