사용자가 페이지 간 탐색 후 뒤로가기 버튼을 눌렀을 때 이전 스크롤 위치를 복원하는 다양한 구현 방법을 정리한 문서이다.
사용자 경험 향상
가장 간단한 방법으로 브라우저의 기본 scrollRestoration 기능을 사용한다.
// 브라우저 자동 처리 (기본값)
history.scrollRestoration = 'auto';
// 개발자 직접 제어
history.scrollRestoration = 'manual';
브라우저 호환성을 고려하거나 더 세밀한 제어가 필요할 때 사용한다.
// 스크롤 이벤트 리스너로 위치 저장
let scrollTimer;
window.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
sessionStorage.setItem('scrollPosition', window.scrollY.toString());
}, 100); // 100ms 디바운스
}, { passive: true });
// 페이지 로드 시 스크롤 위치 복원
window.addEventListener('load', () => {
const savedPosition = sessionStorage.getItem('scrollPosition');
if (savedPosition) {
window.scrollTo(0, parseInt(savedPosition, 10));
}
});
// 모바일 환경에서 beforeunload가 작동하지 않을 수 있으므로 추가
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
sessionStorage.setItem('scrollPosition', window.scrollY.toString());
}
});
SPA에서 페이지 전환 시 더 정교한 스크롤 복원이 필요할 때 사용한다.
interface ScrollState {
scrollX: number;
scrollY: number;
timestamp: number;
}
// 페이지 이동 시 현재 스크롤 위치를 history state에 저장
const navigateWithScrollSave = (url: string): void => {
const currentState: ScrollState = {
scrollX: window.scrollX,
scrollY: window.scrollY,
timestamp: Date.now()
};
// 현재 페이지 상태에 스크롤 위치 저장
history.replaceState(
{ ...history.state, ...currentState },
'',
location.href
);
// 새 페이지로 이동
history.pushState({}, '', url);
loadPageContent(url);
};
// popstate 이벤트로 뒤로가기 시 스크롤 복원
window.addEventListener('popstate', (event: PopStateEvent) => {
const state = event.state as ScrollState;
if (state && typeof state.scrollY === 'number') {
// DOM 렌더링 완료 후 스크롤 복원
requestAnimationFrame(() => {
window.scrollTo(state.scrollX, state.scrollY);
});
}
});
전체 페이지가 아닌 특정 영역의 스크롤을 복원해야 할 때 사용한다.
const createScrollRestoration = (containerId: string) => {
const container = document.getElementById(containerId);
if (!container) return;
const storageKey = `scroll-${containerId}`;
// 이전 스크롤 위치 복원
const savedPosition = sessionStorage.getItem(storageKey);
if (savedPosition) {
container.scrollTop = parseInt(savedPosition, 10);
}
// 스크롤 변경 시 위치 저장 (디바운스 적용)
let timer: number;
container.addEventListener('scroll', () => {
clearTimeout(timer);
timer = setTimeout(() => {
sessionStorage.setItem(storageKey, container.scrollTop.toString());
}, 100);
}, { passive: true });
// 정리 함수 반환
return () => {
sessionStorage.setItem(storageKey, container.scrollTop.toString());
clearTimeout(timer);
};
};
// 사용 예시
const cleanupSidebar = createScrollRestoration('sidebar');
const cleanupMainContent = createScrollRestoration('main-content');
React Router v6.4+에서 제공하는 가장 간단한 방법이다.
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ScrollRestoration } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<ProductList />} />
<Route path="/products/:id" element={<ProductDetail />} />
</Routes>
{/* 모든 라우트에 대해 자동 스크롤 복원 */}
<ScrollRestoration />
</BrowserRouter>
);
}
특정 경로에서만 스크롤 위치를 기억하도록 제어할 수 있다.
<ScrollRestoration
getKey={(location, matches) => {
// 목록 페이지에서만 스크롤 위치 기억
const scrollRestorationPaths = ['/products', '/search', '/users'];
if (scrollRestorationPaths.includes(location.pathname)) {
// 쿼리 파라미터까지 포함해서 키 생성
return location.pathname + location.search;
}
// null 반환 시 스크롤 복원 안 함 (페이지 상단으로)
return null;
}}
/>
더 세밀한 제어가 필요할 때 직접 구현하는 훅이다.
import { useEffect, useCallback, useRef } from 'react';
import { useLocation, useNavigationType } from 'react-router-dom';
interface ScrollData {
x: number;
y: number;
timestamp: number;
}
export const useScrollRestoration = () => {
const location = useLocation();
const navigationType = useNavigationType();
const scrollTimerRef = useRef<number>();
// 브라우저 자동 복원 비활성화
useEffect(() => {
if ('scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
}, []);
const getStorageKey = useCallback(() => {
return `scroll-${location.pathname}${location.search}`;
}, [location.pathname, location.search]);
// 스크롤 위치 저장
const saveScrollPosition = useCallback(() => {
const scrollData: ScrollData = {
x: window.scrollX,
y: window.scrollY,
timestamp: Date.now()
};
sessionStorage.setItem(
getStorageKey(),
JSON.stringify(scrollData)
);
}, [getStorageKey]);
// 스크롤 위치 복원
const restoreScrollPosition = useCallback(() => {
const savedData = sessionStorage.getItem(getStorageKey());
if (savedData && navigationType === 'POP') {
try {
const { x, y, timestamp }: ScrollData = JSON.parse(savedData);
// 5분 이내 데이터만 유효하다고 판단
if (Date.now() - timestamp < 300000) {
requestAnimationFrame(() => {
window.scrollTo(x, y);
});
}
} catch (error) {
console.warn('스크롤 복원 실패:', error);
}
} else {
// 새로운 페이지는 맨 위로
window.scrollTo(0, 0);
}
}, [getStorageKey, navigationType]);
// 이벤트 리스너 등록
useEffect(() => {
restoreScrollPosition();
const handleScroll = () => {
clearTimeout(scrollTimerRef.current);
scrollTimerRef.current = setTimeout(saveScrollPosition, 100);
};
const handleBeforeUnload = () => saveScrollPosition();
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
saveScrollPosition();
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
saveScrollPosition();
clearTimeout(scrollTimerRef.current);
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [saveScrollPosition, restoreScrollPosition]);
return { saveScrollPosition };
};
무한 스크롤 리스트에서 뒤로가기 시 로드된 데이터와 스크롤 위치를 모두 복원한다.
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
interface Product {
id: number;
name: string;
price: number;
}
interface InfiniteScrollState {
products: Product[];
page: number;
scrollY: number;
timestamp: number;
}
export const InfiniteScrollWithRestoration = () => {
const [products, setProducts] = useState<Product[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const location = useLocation();
const storageKey = `infinite-scroll-${location.pathname}`;
// 페이지 로드 시 상태 복원
useEffect(() => {
const savedData = sessionStorage.getItem(storageKey);
if (savedData) {
try {
const {
products: savedProducts,
page: savedPage,
scrollY,
timestamp
}: InfiniteScrollState = JSON.parse(savedData);
// 5분 이내 데이터만 복원
if (Date.now() - timestamp < 300000) {
setProducts(savedProducts);
setCurrentPage(savedPage);
// 데이터 복원 후 스크롤 위치 복원
setTimeout(() => {
window.scrollTo(0, scrollY);
}, 100);
return;
}
} catch (error) {
console.warn('무한 스크롤 상태 복원 실패:', error);
}
}
// 복원할 데이터가 없으면 첫 페이지 로드
loadProducts(1);
}, []);
// 상태 저장
useEffect(() => {
const saveState = () => {
if (products.length === 0) return;
const stateData: InfiniteScrollState = {
products,
page: currentPage,
scrollY: window.scrollY,
timestamp: Date.now()
};
sessionStorage.setItem(storageKey, JSON.stringify(stateData));
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
saveState();
}
};
window.addEventListener('beforeunload', saveState);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
saveState();
window.removeEventListener('beforeunload', saveState);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [products, currentPage, storageKey]);
const loadProducts = async (page: number) => {
setLoading(true);
try {
const response = await fetch(`/api/products?page=${page}`);
const newProducts: Product[] = await response.json();
if (page === 1) {
setProducts(newProducts);
} else {
setProducts(prev => [...prev, ...newProducts]);
}
setCurrentPage(page);
} catch (error) {
console.error('상품 로드 실패:', error);
} finally {
setLoading(false);
}
};
const loadMore = () => {
if (!loading) {
loadProducts(currentPage + 1);
}
};
return (
<main>
<h1>상품 목록</h1>
<section>
{products.map(product => (
<article key={product.id}>
<h2>{product.name}</h2>
<p>가격: {product.price}원</p>
</article>
))}
</section>
<button
onClick={loadMore}
disabled={loading}
aria-label={loading ? '상품 로딩 중' : '더 많은 상품 보기'}
>
{loading ? '로딩 중...' : '더 보기'}
</button>
</main>
);
};
Next.js의 Link 컴포넌트는 기본적으로 스마트한 스크롤 동작을 제공한다.
import Link from 'next/link';
export default function Navigation() {
return (
<nav>
{/* 기본 동작: Next.js가 자동으로 처리 */}
<Link href="/products">
상품 목록
</Link>
{/* 스크롤 위치 유지 (필터링, 정렬에 유용) */}
<Link href="/products?category=electronics" scroll={false}>
전자제품 필터
</Link>
{/* 명시적으로 페이지 상단으로 */}
<Link href="/new-page" scroll={true}>
새 페이지
</Link>
</nav>
);
}
'use client';
import { useRouter } from 'next/navigation';
export const ProductFilter = () => {
const router = useRouter();
const handleFilterChange = (filterValue: string) => {
// 필터 변경 시 스크롤 위치 유지
router.push(`/products?filter=${filterValue}`, { scroll: false });
};
const handleCategoryChange = (category: string) => {
// 카테고리 변경 시에는 페이지 상단으로
router.push(`/products/${category}`, { scroll: true });
};
return (
<section>
<h2>상품 필터</h2>
<select
onChange={(e) => handleFilterChange(e.target.value)}
aria-label="정렬 옵션 선택"
>
<option value="">선택해주세요</option>
<option value="price">가격순</option>
<option value="name">이름순</option>
<option value="rating">평점순</option>
</select>
<select
onChange={(e) => handleCategoryChange(e.target.value)}
aria-label="카테고리 선택"
>
<option value="">전체 카테고리</option>
<option value="electronics">전자제품</option>
<option value="clothing">의류</option>
<option value="books">도서</option>
</select>
</section>
);
};