React 뒤로가기 시 이전 페이지 스크롤로 이동하기

웹앱을 개발할 때, 항상 존재하는 메인 페이지를 두고, 그 위에 서브 페이지를 보이게 하라는 요청이 있었습니다. 하지만 이 경우에는 뒤로가기시 스크롤의 위치가 정확하게 동작하지 않았습니다. 이에 대한 해결 방법을 찾아보면서 알아낸 방법을 정리합니다.

전체 코드

main.jsx

import { createRoot } from 'react-dom/client'

import { createBrowserRouter, RouterProvider } from 'react-router'

import './index.css'
import MainPage from './main-page.jsx'
import SubPage from './pages/sub-page.jsx'
import ScrollRestore from './scroll-restore.jsx'

export const router = createBrowserRouter([
  {
    path: "/",
    Component: MainPage,
    children: [
      {
        Component: ScrollRestore,
        children: [
          {
            path: "/sub/:id",
            Component: SubPage
          }
        ]
      }
    ]
  },
]);


const root = document.getElementById('root')

createRoot(root).render(
  <RouterProvider router={router} />
)

app.jsx

import { Outlet, useNavigate } from 'react-router'

function App() {
  const navigate = useNavigate()

  const openSubPage = (id) => {
    navigate(`/sub/${id}`);
  };


  return (
    <div>
      <h1>메인 페이지</h1>
      <ul>
        {Array.from({ length: 1000 }, (_, i) => (
          <li key={i} style={{ padding: "20px", border: "1px solid gray" }}>
            항목 {i + 1} <button onClick={() => openSubPage(i + 1)}>서브 페이지 열기</button>
          </li>
        ))}
      </ul>
      <Outlet />
    </div>
  )
}

export default App

sub-page.jsx

import { useContext } from "react";
import { useNavigate, useParams } from "react-router";
import { ScrollRestore } from "../scroll-restore";

const SubPage = () => {
  const { page } = useContext(ScrollRestore)
  const navigate = useNavigate();
  const { id } = useParams();

  const closeSubPage = () => {
    navigate(-1);
  };

  const openSubPage = (id) => {
    navigate(`/sub/${id}`);
  };

  return (
    <div style={{
      position: "fixed",
      top: 0,
      left: 0,
      width: "100%",
      height: "100%",
      background: "white",
      display: "flex",
      justifyContent: "center",
      alignItems: "center"
    }}>
      <div ref={page} style={{ height: '100%', padding: "20px", borderRadius: "10px", overflow: 'auto' }}>
        <h2>서브 페이지 {id}</h2>
        <button onClick={closeSubPage}>닫기</button>
        <ul>
        {Array.from({ length: 1000 }, (_, i) => (
          <li key={i} style={{ padding: "20px", border: "1px solid gray" }}>
            항목 {i + 1} <button onClick={() => openSubPage(i + 1)}>서브 페이지 열기</button>
          </li>
        ))}
      </ul>
      </div>
    </div>
  );
};

export default SubPage

scroll-restore.jsx

import { createContext, useLayoutEffect, useRef } from "react";
import { Outlet, useLocation } from "react-router";
import { router } from "./main";

/**
 * useScrollRestore 훅
 * - 페이지 이동 시 현재 페이지의 스크롤 위치를 저장하고, 다시 방문하면 해당 위치로 복원
 * - `useRef`를 사용하여 스크롤 위치 데이터를 저장하고, `useLayoutEffect`를 활용하여 스크롤 동작을 제어
 */
function useScrollRestore() {
  const location = useLocation(); // 현재 라우트의 위치 정보를 가져옴
  const page = useRef(); // 페이지 컨테이너의 DOM 요소를 저장할 ref
  const historyData = useRef([]); // 방문한 페이지의 스크롤 기록을 저장하는 배열
  const scrollTopData = useRef({}); // 페이지별 스크롤 위치를 저장하는 객체

  /**
   * 첫 번째 useLayoutEffect
   * - 페이지 이동(PUSH) 시 현재 페이지의 스크롤 위치를 저장
   */
  useLayoutEffect(() => {
    const { key, pathname, search } = location; // 현재 라우트의 키(key), 경로(pathname), 검색쿼리(search) 가져오기
    const element = page.current; // 현재 페이지의 DOM 요소
    const historyArray = historyData.current; // 방문 기록 배열
    const scrollTop = scrollTopData.current; // 스크롤 위치 저장 객체

    let isAction = false; // 중복 실행을 방지하기 위한 플래그

    if (!element) return; // 페이지 요소가 없으면 실행하지 않음

    // 라우터의 상태 변경을 감지하는 구독(subscription)
    const unsubscribe = router.subscribe((history) => {
      // PUSH 액션이 아니거나 이미 실행된 경우 실행하지 않음
      if (!element || history.historyAction !== "PUSH" || isAction) return;
      isAction = true; // 실행됨을 표시

      // 현재 페이지의 스크롤 위치 저장
      historyArray.push({
        key,
        pathname,
        search,
        top: element.scrollTop, // 현재 스크롤 위치 저장
      });

      // 현재 페이지의 키를 기준으로 스크롤 위치 저장
      scrollTop[key] = element.scrollTop;
    });

    return unsubscribe; // 구독 해제 함수 반환
  }, [location]);

  /**
   * 두 번째 useLayoutEffect
   * - 페이지가 변경될 때 저장된 스크롤 위치로 복원
   */
  useLayoutEffect(() => {
    if (!page.current) return;
    const element = page.current; // 현재 페이지의 DOM 요소

    // 이전에 저장된 스크롤 위치 가져오기
    const savedScrollTop =
      scrollTopData.current[location.key] || (location.state && location.state.top);

    // 저장된 스크롤 위치가 없으면 최상단으로 이동
    if (!savedScrollTop) {
      element.scroll({ top: 0, behavior: "instant" });
      return;
    } else {
      // 저장된 스크롤 위치로 이동
      element.scroll({ top: savedScrollTop, behavior: "instant" });
      // 스크롤이 이미 설정된 경우 추가 작업 불필요
      if (element.scrollTop === savedScrollTop) return;
    }

    /**
     * restoreScroll 함수
     * - 페이지 콘텐츠가 완전히 렌더링되지 않아 스크롤 복원이 실패한 경우 재시도
     */
    const restoreScroll = () => {
      if (element.scrollHeight > savedScrollTop) {
        element.scroll({ top: savedScrollTop, behavior: "instant" });
        observer.disconnect(); // MutationObserver 해제
      }
    };

    // 페이지 요소 변경을 감지하는 MutationObserver 생성
    const observer = new MutationObserver(() => {
      restoreScroll(); // 변경이 감지되면 스크롤 복원 시도
    });

    // MutationObserver를 사용하여 DOM 변경 감지
    observer.observe(element, { childList: true, subtree: true });

    return () => {
      observer.disconnect(); // 컴포넌트 언마운트 시 MutationObserver 해제
    };
  }, [location.key]);

  // page와 historyData를 반환하여 컨텍스트에서 활용 가능하게 함
  return {
    page,
    historyData,
  };
}

// 스크롤 복원 기능을 제공하는 Context 생성
export const ScrollRestore = createContext();

/**
 * Observer 컴포넌트
 * - useScrollRestore를 호출하여 스크롤 상태를 관리하고, 하위 컴포넌트에 제공
 */
export default function Observer() {
  const { page, historyData } = useScrollRestore(); // useScrollRestore 훅 실행

  return (
    <ScrollRestore.Provider value={{ page, historyData }}>
      <Outlet /> {/* 하위 라우트 컴포넌트들을 렌더링 */}
    </ScrollRestore.Provider>
  );
}

문제점

로딩이 오래걸리면 스크롤은 콘텐츠가 다 생성되지 않아서 기억하고 있는 스크롤 값이 height값을 넘어가는 경우가 있다. 당연히 이 경우에 제대로 동작하지 않는다.

해결 방법

  1. restoreScroll 함수는 위 문제를 해결하기 위해서 만든 함수이지만, 이것으로는 해결이 안되었음
  2. 로드가 된 다음에 동작하게 하는 방법 (이렇게 하지는 않았지만 이렇게 하면 해결이 될 것이라고 생각)
  3. 보통 이미지가 늦게 로드 되는 경우가 많았다. 이 경우에 이미지에 미리 고정적인 크기를 주었더니 해결이 됨

Leave a Comment