스크롤 위치 복원 구현

contability·2025년 9월 8일

개요

사용자가 페이지 간 탐색 후 뒤로가기 버튼을 눌렀을 때 이전 스크롤 위치를 복원하는 다양한 구현 방법을 정리한 문서이다.

스크롤 위치 복원이 중요한 이유

사용자 경험 향상

  • 긴 목록에서 특정 아이템을 확인한 후 뒤로가기 시, 다시 처음부터 스크롤할 필요가 없다
  • 검색 결과나 피드에서 콘텐츠를 탐색할 때 이전 위치를 기억해 연속성 있는 경험을 제공한다
  • 사용자의 탐색 맥락(context)을 유지해 인지 부하를 줄인다

1. Vanilla JavaScript 구현

1.1 브라우저 네이티브 기능 활용

가장 간단한 방법으로 브라우저의 기본 scrollRestoration 기능을 사용한다.

// 브라우저 자동 처리 (기본값)
history.scrollRestoration = 'auto';

// 개발자 직접 제어
history.scrollRestoration = 'manual';

1.2 sessionStorage를 활용한 수동 구현

브라우저 호환성을 고려하거나 더 세밀한 제어가 필요할 때 사용한다.

// 스크롤 이벤트 리스너로 위치 저장
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());
  }
});

1.3 History API State를 활용한 고급 구현

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);
    });
  }
});

1.4 특정 컨테이너 스크롤 복원

전체 페이지가 아닌 특정 영역의 스크롤을 복원해야 할 때 사용한다.

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');

2. React 구현

2.1 React Router ScrollRestoration 컴포넌트

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>
  );
}

2.2 조건부 스크롤 복원

특정 경로에서만 스크롤 위치를 기억하도록 제어할 수 있다.

<ScrollRestoration 
  getKey={(location, matches) => {
    // 목록 페이지에서만 스크롤 위치 기억
    const scrollRestorationPaths = ['/products', '/search', '/users'];
    
    if (scrollRestorationPaths.includes(location.pathname)) {
      // 쿼리 파라미터까지 포함해서 키 생성
      return location.pathname + location.search;
    }
    
    // null 반환 시 스크롤 복원 안 함 (페이지 상단으로)
    return null;
  }}
/>

2.3 커스텀 스크롤 복원 훅

더 세밀한 제어가 필요할 때 직접 구현하는 훅이다.

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 };
};

2.4 무한 스크롤과 스크롤 복원

무한 스크롤 리스트에서 뒤로가기 시 로드된 데이터와 스크롤 위치를 모두 복원한다.

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>
  );
};

3. Next.js 구현

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>
  );
}

3.2 useRouter를 이용한 프로그래밍 방식 제어

'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>
  );
};

0개의 댓글