안녕하세요! 올리브영 프론트엔드 개발자 hee 입니다
여러분은 혹시 사용자가 이전에 보던 화면 그대로 다시 보여주려다 예상치 못한 스크롤 위치 때문에 당황했던 경험이 있으신가요? SPA 환경에서 '스크롤 복구(Scroll Restoration)'는 단순해 보여도 개발자를 끊임없이 괴롭히는 난제 중 하나입니다. 이번 포스팅에서는 올리브영 프론트엔드 개발팀이 다양한 프로젝트에서 마주했던 스크롤 복구 이슈들을 어떻게 해결해왔는지, 그 생존 전략과 실전 팁들을 공유해드리고자 합니다. 이 글이 여러분의 '스크롤 지옥' 탈출에 조금이나마 도움이 되기를 바랍니다!
Scroll restoration이란?
Scroll restoration은 사용자가 페이지를 떠났다가 다시 돌아왔을 때, 이전에 보고 있던 스크롤 위치를 자동으로 복구하는 기능입니다. 전통적인 웹사이트에서는 브라우저가 자동으로 이 기능을 제공했지만, 현대의 SPA(Single Page Application) 환경에서는 여러 가지 기술적 제약으로 인해 이 기능이 제대로 작동하지 않는 경우가 많습니다.
이러한 스크롤 위치 복구 실패는 사용자 경험에 심각한 영향을 미칩니다. 특히 긴 목록을 스크롤하다가 상세 페이지로 이동한 후 다시 돌아왔을 때 맨 위로 되돌아가는 현상은 사용자들에게 큰 불편함을 안겨줍니다. 이는 단순히 불편함을 넘어서 사용자의 이탈률 증가와 전반적인 서비스 만족도 저하로 이어질 수 있어, 현대 웹 개발에서 반드시 해결해야 할 중요한 과제 중 하나입니다.
SPA 환경에서 스크롤 복구가 어려운 이유
SPA에서 스크롤 복구가 어려운 이유를 차근차근 살펴보겠습니다. 전통적인 멀티 페이지 웹사이트에서는 각 페이지가 독립적인 HTML 문서였습니다. 브라우저는 사용자가 페이지를 떠날 때 스크롤 위치를 기억해두었다가, 다시 그 페이지로 돌아올 때 자동으로 복구해주었습니다.
하지만 SPA의 동작은, 하나의 페이지 안에서 JavaScript가 콘텐츠를 동적으로 바꾸는 방식입니다. 이 때 브라우저 입장에서는 계속 같은 페이지에 머물러 있다고 인식하기 때문에, 기본적인 스크롤 복구 기능이 제대로 작동하지 않습니다.
더 복잡한 문제는 현대 웹 애플리케이션의 특성에서 나타납니다. 데이터를 비동기적으로 불러오고, 무한 스크롤을 구현하며, 콘텐츠의 높이가 동적으로 변하는 환경에서는 단순히 스크롤 위치만 저장하는 것으로는 해결되지 않습니다. 페이지를 다시 방문했을 때 데이터가 아직 로드되지 않았거나, 동적으로 생성되는 콘텐츠의 높이가 달라질 수 있기 때문입니다.
상황별 스크롤 복구 방법
실제 프로젝트에서 마주했던 다양한 상황들을 유형별로 분류해서 살펴보겠습니다. 각 상황마다 최적의 해결책이 다르다는 것을 이해하는 것이 중요합니다.
1) 정적인 데이터 상황
가장 간단한 경우부터 시작해보겠습니다. 페이지의 모든 데이터가 미리 로드되어 있고, 콘텐츠의 높이가 변하지 않는 상황입니다. 이런 경우에는 브라우저의 History API를 활용하여 비교적 쉽게 해결할 수 있습니다.
아래 예제에서는 단순하게 History API를 사용해서 scrollY값을 저장했지만 Web Storage를 이용하거나 URL 쿼리를 이용해서 저장할 수 있습니다.
// 기본적인 스크롤 위치 저장 및 복구
function setupBasicScrollRestoration() {
// 페이지를 떠날 때 현재 스크롤 위치를 저장
window.addEventListener('beforeunload', () => {
const scrollY = window.scrollY;
history.pushState({ scrollY }, null, '?page=1');
});
// 페이지로 돌아왔을 때 저장된 위치로 스크롤
window.addEventListener('popstate', (event) => {
if (event.state && event.state.scrollY) {
setTimeout(() => {
window.scrollTo(0, event.state.scrollY);
}, 100);
}
});
}
정적인 데이터 환경에서는 이 방법이 매우 효과적입니다. 하지만 실제 프로젝트에서는 대부분 동적인 데이터를 다루기 때문에 더 복잡한 접근이 필요합니다.
2) 동적인 데이터 상황
동적인 데이터를 다루는 상황에서는 단순히 스크롤 위치만 저장하는 것으로는 부족합니다. 데이터가 비동기적으로 로드되면서 페이지의 높이가 변하기 때문에, 저장된 스크롤 위치가 의미 없어질 수 있습니다.
이런 상황에서는 스크롤 위치와 함께 추가적인 정보를 저장해야 합니다. 현재 화면에 보이는 아이템들의 정보를 함께 저장하면, 데이터가 다시 로드되더라도 특정 아이템을 기준으로 스크롤 위치를 복구할 수 있습니다.
// 동적 데이터 환경에서의 스크롤 관리
class DynamicScrollManager {
saveScrollState(pageKey) {
const scrollY = window.scrollY;
const visibleItems = this.getVisibleItemsInfo();
// 스크롤 위치와 보이는 아이템 정보를 함께 저장
sessionStorage.setItem(pageKey, JSON.stringify({
scrollY,
visibleItems,
}));
}
async restoreScrollState(pageKey) {
const savedData = JSON.parse(sessionStorage.getItem(pageKey) || '{}');
if (!savedData.visibleItems) return;
// 데이터 로딩이 완료될 때까지 대기
await this.waitForDataLoad();
// 저장된 아이템을 찾아서 기준점으로 사용
const targetElement = this.findTargetElement(savedData.visibleItems);
if (targetElement) {
targetElement.scrollIntoView();
// 미세 조정을 위해 저장된 정확한 위치로 스크롤
window.scrollTo(0, savedData.scrollY);
}
}
getVisibleItemsInfo() {
// 현재 화면에 보이는 아이템들의 ID와 위치 정보 수집
const items = document.querySelectorAll('[data-item-id]');
return Array.from(items).map(item => ({
id: item.dataset.itemId,
offsetTop: item.offsetTop
}));
}
findTargetElement(savedItems) {
// 저장된 아이템 중 첫 번째 아이템을 기준점으로 사용
return document.querySelector(`[data-item-id="${savedItems[0].id}"]`);
}
}
이 방식의 핵심은 절대적인 스크롤 위치보다는 상대적인 기준점을 활용한다는 점입니다. 특정 아이템을 기준으로 위치를 복구하기 때문에, 데이터가 다시 로드되어 페이지 높이가 변하더라도 안정적으로 작동합니다. 중요한점은 데이터로드 이후에 스크롤을 복구해야된다는 점 입니다.
3) 동적인 데이터 상황 - 레이지 로딩
동적 데이터 상황 중에서도 무한 스크롤이나 레이지 로딩을 사용하는 경우가 가장 복잡합니다. 사용자가 스크롤을 내릴 때마다 새로운 데이터를 불러오는데, 페이지를 벗어나거나 새로고침으로 다시 방문할 때는 그 데이터가 없는 상태이기 때문입니다.
"스크롤이 해당 지점에 도달했을때 데이터를 불러온다면 어떻게 스크롤 처음 위치를 잡을 수 있을까?" 이 문제를 해결하기 위해서는 두 가지 전략을 조합해야 합니다. 첫째는 필요한 데이터를 미리 로드하는 것이고, 둘째는 스켈레톤 UI를 통해 레이아웃을 미리 확보하는 것입니다.
// 레이지 로딩 환경에서의 스크롤 복구
class LazyLoadScrollManager {
async restoreScrollWithPreload(pageKey) {
const savedState = this.getSavedState(pageKey);
if (!savedState) return;
// 1단계: 스켈레톤으로 공간 확보
this.createSkeletonPlaceholders(savedState.totalHeight);
// 2단계: 저장된 지점까지 필요한 데이터를 배치 단위로 미리 로드
await this.preloadRequiredData(savedState.requiredItemCount);
// 3단계: 실제 스크롤 위치 복구
window.scrollTo(0, savedState.scrollY);
}
async preloadRequiredData(itemCount) {
const batchSize = 20;
for (let i = 0; i < itemCount; i += batchSize) {
const batch = await this.loadDataBatch(i, batchSize);
this.renderBatch(batch);
await this.waitForRender(); // DOM 업데이트 대기
}
}
}
스켈레톤을 이용한 CLS 줄이기
Cumulative Layout Shift(CLS)를 줄이기 위해 스켈레톤 UI를 활용하는 것이 중요합니다. 스켈레톤은 실제 콘텐츠가 로드되기 전에 레이아웃을 미리 잡아주는 역할을 합니다.
스켈레톤으로 해당 데이터의 height 크기를 미리잡아놓기
스켈레톤의 효과를 극대화하려면 실제 콘텐츠와 비슷한 높이를 갖도록 해야 합니다. 이를 위해 과거 데이터를 기반으로 예상 높이를 계산하는 방법을 사용할 수 있습니다.
// 스켈레톤 높이 관리
class SkeletonHeightManager {
constructor() {
this.itemHeights = []; // 실제 아이템 높이들을 기록
this.averageHeight = 200; // 초기 평균 높이
}
// 높이 평균값 계산을 위해 각 엘리먼트 순회
recordItemHeight(element) {
const height = element.offsetHeight;
this.itemHeights.push(height);
}
// 최근 50개 아이템의 평균 높이 계산
getItemAverageHeight(element) {
const recentHeights = this.itemHeights.slice(-50);
this.averageHeight = recentHeights.reduce((sum, h) => sum + h, 0) / recentHeights.length;
}
// 스켈레톤 생성
createSkeletonWithEstimatedHeight(count) {
const container = document.createElement('div');
for (let i = 0; i < count; i++) {
const skeleton = document.createElement('div');
skeleton.className = 'skeleton-item';
skeleton.style.height = `${this.averageHeight}px`;
container.appendChild(skeleton);
}
return container;
}
}
4) react-query를 사용하는 상황
React Query를 활용하면 데이터 캐싱 기능 덕분에 스크롤 복원을 훨씬 수월하게 구현할 수 있습니다. 이미 캐시된 데이터를 즉시 화면에 표시할 수 있어 복잡한 로딩 상태 관리가 불필요해집니다.
이런 효과를 얻기 위해서는 React Query의 캐싱 메커니즘을 제대로 이해하는 것이 중요합니다. 데이터가 캐시에 저장되어 있으면 별도의 네트워크 요청 없이 UI를 바로 렌더링할 수 있습니다. 이러한 특성은 스크롤 복원 과정에서 끊김 없는, 매끄러운 사용자 경험을 제공합니다.
// React Query 환경에서의 스크롤 복구
function useScrollRestorationWithQuery(queryKey) {
const queryClient = useQueryClient();
const saveScrollState = () => {
const scrollData = {
scrollY: window.scrollY,
timestamp: Date.now()
};
// React Query 캐시에 스크롤 정보도 함께 저장
queryClient.setQueryData([...queryKey, 'scroll'], scrollData);
};
const restoreScrollState = async () => {
// 1단계: 캐싱된 데이터 가져옴
const scrollData = queryClient.getQueryData([...queryKey, 'scroll']);
const cachedData = queryClient.getQueryData(queryKey);
if (scrollData && cachedData) {
// 2단계: 캐싱된 데이터를 이용해서 컴포넌트를 화면에 렌더링
await componentsRendering(cachedData);
// 3단계: 캐싱된 스크롤값을 이용해서 좌표이동
window.scrollTo(0, scrollData.scrollY);
}
};
return { saveScrollState, restoreScrollState };
}
// 무한 스크롤과 조합한 경우
function useInfiniteScrollRestoration(queryKey) {
const infiniteQuery = useInfiniteQuery({
queryKey,
queryFn: fetchData,
staleTime: 5 * 60 * 1000, // 캐시 시간을 길게 설정
});
const restoreWithInfiniteData = async () => {
// 1단계: 저장된 pageCount와 스크롤 위치를 가져옴
const savedScrollData = getSavedScrollData();
if (savedScrollData) {
// 2단계: 저장된 페이지 수만큼 데이터 복구
while (infiniteQuery.data.pages.length < savedScrollData.pageCount) {
await infiniteQuery.fetchNextPage();
}
// 3단계: 모든 데이터 로딩 완료 후 스크롤 복구
window.scrollTo(0, savedScrollData.scrollY);
}
};
return { ...infiniteQuery, restoreWithInfiniteData };
}
React Query를 사용할 때의 핵심은 데이터와 스크롤 상태를 함께 관리한다는 점입니다. 캐시된 데이터가 있다면 로딩 시간 없이 즉시 스크롤을 복구할 수 있고, 없다면 데이터 로딩과 스크롤 복구를 순차적으로 처리할 수 있습니다.
5) 대량 리스트에서 가상화를 이용한 상황
가상화(Virtualization)를 사용하는 경우에는 특별한 접근 방식이 필요합니다. 가상화된 리스트는 실제로 화면에 보이는 아이템들만 DOM에 렌더링하기 때문에 기존의 스크롤 복원 방법으로는 한계가 있습니다.
먼저 가상화의 동작 원리를 살펴보겠습니다. 수천 개의 아이템이 포함된 리스트라고 해도 실제 DOM에 존재하는 것은 현재 화면에 표시되는 10~20개 정도에 불과합니다. 나머지 아이템은 가상으로 존재하며, 사용자의 스크롤에 따라 동적으로 생성되고 제거되는 방식으로 동작합니다.
그래서 가상화 환경에서 스크롤을 복원하려면 기존과는 다른 접근이 필요합니다. 사용자가 마지막으로 보고 있던 아이템 데이터를 먼저 로드하고, 이후 주변 데이터를 점진적으로 불러오는 방식을 사용해야 합니다. 이렇게 데이터가 준비되면 해당 위치로 스크롤을 이동시켜 자연스러운 복원 경험을 만들 수 있습니다.
// 가상화 환경에서의 스크롤 복구
class VirtualizedScrollManager {
saveVirtualizedScrollState(pageKey) {
const virtualList = getVirtualListInstance();
// 먼저 로드되는 상황을 고려해서 0부터 시작하는 sequence값을 계산해서 저장
const listSequence = virtualList.getSequence();
const pageNumber = virtualList.getPageNumber();
const pageSize = virtualList.getPageSize();
// 가상화된 환경에서는 보이는 아이템 순서값과 최신 페이징 데이터값을 저장
sessionStorage.setItem(pageKey, JSON.stringify({
listSequence,
pageNumber,
pageSize,
}));
}
async restoreVirtualizedScroll(pageKey) {
const savedState = JSON.parse(sessionStorage.getItem(pageKey) || '{}');
if (!savedState.listSequence) return;
// 1단계: 먼저 보이는 아이템 데이터 호출
await this.loadDataRange(savedState.pageNumber, savedState.pageSize);
// 2단계: 가상 리스트에 저장된 스크롤 오프셋 적용
virtualList.scrollTo(savedState.listSequence);
// 3단계: 보이던 아이템 주변 데이터 로드
await this.preloadAroundVisibleRange(
savedState.pageNumber,
savedState.pageSize
);
}
}
가상화 환경에서의 핵심은 필요한 부분만 우선적으로 로드하고, 나머지는 백그라운드에서 점진적으로 로드한다는 점입니다. 이렇게 하면 사용자는 즉시 원하는 위치를 볼 수 있고, 전체 데이터 로딩을 기다릴 필요가 없습니다.
위 코드는 pseudo 코드를 이용해서 간결하게 작성했습니다. 만약 리액트에서 사용한다면 React-Virtualized, Virtuoso 와 같은 라이브러리 사용을 추천합니다.
완벽보단 자연스럽게
지금까지 상황별 스크롤 복구 방법을 살펴봤습니다.
실제 프로젝트에서는 여러 방법을 조합해 사용하는 경우가 많습니다. 예를 들면 React Query로 데이터를 관리하면서 무한 스크롤을 구현하고, 스켈레톤 UI로 사용자 경험을 개선하는 형태가 있죠. 중요한 것은 각 프로젝트의 특성과 사용자의 사용 패턴을 이해하는 것, 그리고 그에 맞는 최적의 방법을 선택하는 것입니다.
스크롤 복구을 완벽하게 하기 보다는, 사용자가 불편함을 느끼지 않을 정도의 적절한 수준을 목표로 하는 것이 현실적인 접근입니다. 여러분의 환경에 적합한 꿀조합은 어떤 방법인가요?