리스트·기사·문서 페이지에서 뒤로가기나 라우팅 후 원래 읽던 위치로 돌아가게 하는 스크롤 저장/복원은 UX 품질을 결정짓는 요소입니다. 실무에서는 라우터, 이미지 로딩, 컨테이너 스크롤 등 다양한 변수 때문에 일관된 동작을 만들기가 쉽지 않습니다. 이 글에서는 React에서 신뢰성 있게 스크롤 위치를 저장하고 복원하는 방법을 단계별로 정리합니다.
1. 핵심 아이디어
원리는 단순합니다. 스크롤 이벤트로 현재 위치를 저장하고, 화면 진입 시 저장된 위치로 스크롤합니다. 브라우저 기본 복원과 충돌하지 않도록 manual 모드로 바꾸고, 레이아웃/이미지 로딩 후 복원 타이밍을 조절합니다.
// 브라우저 기본 복원 끄기 (충돌 방지)
useEffect(() => {
try { window.history.scrollRestoration = 'manual'; } catch {}
}, []);
2. 가장 간단한 전역(window) 복원
페이지별 키를 정해 sessionStorage에 위치를 저장하고, 마운트 후 복원합니다.
import { useEffect } from 'react';
function useSimpleScrollRestore(key) {
useEffect(() => {
const onScroll = () => {
sessionStorage.setItem('scroll:' + key, String(window.scrollY));
};
window.addEventListener('scroll', onScroll, { passive: true });
// 마운트 시 복원 (첫 페인트 뒤 타이밍)
requestAnimationFrame(() => {
const saved = sessionStorage.getItem('scroll:' + key);
if (saved) {
window.scrollTo({ top: parseFloat(saved), behavior: 'auto' });
}
});
return () => {
window.removeEventListener('scroll', onScroll);
};
}, [key]);
}
실무에서는 이미지/동적 콘텐츠 로딩으로 높이가 바뀌어 복원이 틀어질 수 있습니다. 아래 훅은 그 문제들을 더 폭넓게 다룹니다.
3. 실전에 쓰는 커스텀 훅(useScrollRestore)
전역(window)과 특정 스크롤 컨테이너를 모두 지원하고, rAF로 스크롤 저장을 스로틀링하며, 이미지 로딩 이후 재복원합니다.
import { useEffect, useRef } from 'react';
/**
* key: 페이지/리스트별 고유 키 (예: pathname+search)
* containerRef: 특정 스크롤 컨테이너(ref). 없으면 window 기준
* enabled: 조건부 활성화
* waitImages: 이미지 로딩 후 재복원 여부
*/
export function useScrollRestore({ key, containerRef, enabled = true, waitImages = true }) {
const rafId = useRef(0);
useEffect(() => {
if (!enabled) return;
const container = containerRef?.current || window;
const isWindow = container === window;
const readAndRestore = () => {
const raw = sessionStorage.getItem('scroll:' + key);
const y = raw ? parseFloat(raw) : 0;
const max = isWindow
? document.documentElement.scrollHeight - window.innerHeight
: (containerRef.current?.scrollHeight || 0) - (containerRef.current?.clientHeight || 0);
const clamped = Math.max(0, Math.min(y, max));
if (isWindow) {
window.scrollTo({ top: clamped, behavior: 'auto' });
} else {
try { container.scrollTo({ top: clamped, behavior: 'auto' }); }
catch { container.scrollTop = clamped; }
}
};
const afterPaintRestore = () => {
cancelAnimationFrame(rafId.current);
rafId.current = requestAnimationFrame(readAndRestore);
};
const onScroll = () => {
cancelAnimationFrame(rafId.current);
rafId.current = requestAnimationFrame(() => {
const pos = isWindow ? window.scrollY : (containerRef.current?.scrollTop || 0);
sessionStorage.setItem('scroll:' + key, String(pos));
});
};
// 브라우저 기본 복원 비활성화
try { window.history.scrollRestoration = 'manual'; } catch {}
// 최초 복원
afterPaintRestore();
// 스크롤 저장
const target = isWindow ? window : containerRef.current;
target?.addEventListener('scroll', onScroll, { passive: true });
// 페이지 이탈 시 최종 위치 저장 (iOS Safari 대응)
const onHide = () => onScroll();
window.addEventListener('pagehide', onHide);
window.addEventListener('beforeunload', onHide);
// 이미지 로딩 후 레이아웃 확정, 재복원
let imgs = [];
if (waitImages && isWindow) {
imgs = Array.from(document.images).filter(img => !img.complete);
if (imgs.length) {
const onImgLoad = () => afterPaintRestore();
imgs.forEach(img => img.addEventListener('load', onImgLoad, { once: true }));
}
}
return () => {
target?.removeEventListener('scroll', onScroll);
window.removeEventListener('pagehide', onHide);
window.removeEventListener('beforeunload', onHide);
if (waitImages && imgs.length) {
// 이벤트 핸들러 정리
const onImgLoad = () => afterPaintRestore();
imgs.forEach(img => img.removeEventListener('load', onImgLoad));
}
};
}, [key, enabled, containerRef]);
}
4. React Router v6 통합
라우트 변경 시 pathname+search를 키로 사용하면 페이지별로 정확히 복원됩니다. 해시(#anchor)가 있다면 앵커 우선으로 처리하는 보완 코드를 함께 쓰면 좋습니다.
import { useLocation } from 'react-router-dom';
import { useScrollRestore } from './useScrollRestore';
export default function ArticlePage() {
const location = useLocation();
const key = location.pathname + location.search;
useScrollRestore({ key });
// 해시 앵커가 있을 때는 앵커 우선
useEffect(() => {
if (!location.hash) return;
requestAnimationFrame(() => {
const id = location.hash.slice(1);
const el = document.getElementById(id);
if (el) el.scrollIntoView({ block: 'start' });
});
}, [location.pathname, location.search, location.hash]);
return (
<main>{/* ... */}</main>
);
}
5. 특정 스크롤 컨테이너 복원
무한 스크롤 목록 같이 내부 스크롤을 쓰는 경우 ref를 넘겨주면 됩니다.
import { useRef } from 'react';
import { useScrollRestore } from './useScrollRestore';
export default function Feed() {
const listRef = useRef(null);
useScrollRestore({ key: 'feed:list', containerRef: listRef });
return (
<div ref={listRef} style={{ overflow: 'auto', maxHeight: 600 }}>
{/* 긴 리스트 렌더링 */}
</div>
);
}
6. 성능·안정성 팁
- 스크롤 핸들러는 passive 옵션으로 등록합니다. 브라우저 스크롤 성능이 좋아집니다.
- 저장은 requestAnimationFrame으로 스로틀링해 과도한 sessionStorage 접근을 줄입니다.
- 이미지/폰트 로딩으로 레이아웃이 바뀌면 재복원합니다. 필요하면 특정 skeleton 표시 후 복원 타이밍을 조절합니다.
- 저장 값이 현재 최대 스크롤 높이보다 크면 클램프해서 안전하게 처리합니다.
- 모바일 키보드/주소창 표시로 viewport 높이가 바뀌면 스크롤이 약간 달라질 수 있습니다. 중요 페이지는 입력 포커스 해제 후 복원하거나, 입력 영역을 피해서 콘텐츠 레이아웃을 구성합니다.
7. SSR/Next.js 주의사항
SSR 환경에서는 window가 없으므로 클라이언트에서만 훅을 실행합니다. Next.js에서는 "use client" 컴포넌트에서 훅을 사용하세요.
'use client';
import { useScrollRestore } from '@/lib/useScrollRestore';
export default function Page() {
useScrollRestore({ key: '/products' });
return <div>...</div>;
}
8. 테스트 체크리스트
- 뒤로가기/앞으로가기 시 정확히 이전 위치로 복원되는지
- 이미지가 늦게 로드되는 페이지에서도 최종적으로 올바른 위치인지
- 해시 앵커(#section) 진입 시 저장된 위치 대신 앵커로 이동하는지
- 내부 컨테이너 스크롤에서도 정상 저장/복원되는지
- 모바일 Safari에서 pagehide/beforeunload 저장이 잘 동작하는지
9. 마무리
스크롤 저장/복원은 작지만 체감 UX를 크게 올려주는 개선입니다. 위 훅을 베이스로 라우팅/컨테이너/이미지 로딩 등 환경에 맞게 조정하면 대부분의 케이스에서 안정적으로 동작합니다. 프로젝트에 적용해 사용자들이 “방금 보던 자리”로 자연스럽게 돌아오도록 만들어보세요.
'React' 카테고리의 다른 글
| React에서 HTML5 Canvas를 활용한 인터랙티브 그래픽 그리기 (0) | 2026.06.12 |
|---|---|
| React 앱에서 QR 코드 생성 및 스캐닝 기능 추가하기 (0) | 2026.06.11 |
| React 앱에 서버리스 함수(Function) 연동하기 (0) | 2026.06.05 |
| React에서 Chart.js를 사용한 동적 차트 생성 (0) | 2026.06.05 |
| React 컴포넌트에서 메모리 누수 디버깅하기 (0) | 2026.06.05 |