본문 바로가기

React

React에서 스크롤 위치 저장 및 복원 기능 구현하기

리스트·기사·문서 페이지에서 뒤로가기나 라우팅 후 원래 읽던 위치로 돌아가게 하는 스크롤 저장/복원은 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를 크게 올려주는 개선입니다. 위 훅을 베이스로 라우팅/컨테이너/이미지 로딩 등 환경에 맞게 조정하면 대부분의 케이스에서 안정적으로 동작합니다. 프로젝트에 적용해 사용자들이 “방금 보던 자리”로 자연스럽게 돌아오도록 만들어보세요.