SPA에서 사용하는 Scroll Restoration 구현하기

SPA에서 스크롤 복원(Scroll Restoration)은 사용자 경험을 구성하는 핵심 요소 중 하나입니다. 예를 들어, 이커머스 앱에서 상품 리스트를 보다가 상세 페이지로 이동한 후, 다시 ‘뒤로 가기’를 누르면 원래 보던 위치로 돌아오는 것은 매우 자연스러운 기대입니다.

제가 담당하는 서비스에도 아이템 목록과 상세 페이지 구조가 있었지만, 스크롤 복원 기능이 없어 사용성이 다소 떨어졌습니다. 처음에는 무한 스크롤이 적용된 강의 목록 페이지에 한해 기능을 구현하려 했었지만 여러 서비스의 사용자 경험을 돌이켜 보면 사용자가 앱을 탐색하면서 언제든 자연스럽게 이전 스크롤 위치로 돌아갈 수 있어야하는 경험은 앱 전체적으로 사용자의 흐름을 끊지 않고 이어주는 중요한 요소였습니다. 따라서 고민 끝에 특정 도메인에 종속되지 않는, 앱 전반에 적용 가능한 범용 컴포넌트 형태로 구현하기로 결정했습니다.


왜 React Router의 ScrollRestoration은 사용할 수 없었을까?

처음에는 React Router 에서 제공하는 ScrollRestoration 컴포넌트를 사용하면 간단하게 구현될 것이라 생각했습니다. 그러나 이 컴포넌트는 Data Router 환경에서만 동작합니다. 일반 라우터에서는 런타임 에러를 발생시키죠.

Data Router는 React Router v6.4 이상에서 도입된 SSR 지원 구조로, 각 라우트에서 필요한 데이터를 먼저 가져온 후 렌더링하는 방식입니다.

하지만 저희 서비스는 B2B이며, B2B 서비스 특성상 SSR은 필수가 아니었습니다. 때문에, 단지 스크롤 복원 기능을 위해 라우팅 구조 전체를 바꾸는 것은 크게 비효율적입니다. 그래서, 직접 구현하기로 결심했습니다.


스크롤 복원 기능을 설계하며 고려한 4가지 질문

처음에는 "스크롤 위치만 저장하고 복구하면 되겠지" 싶었습니다. 하지만 막상 구현에 들어가 보니 생각보다 신경 써야 할 부분이 많았고, 특히 앱 전반에 적용할 범용 컴포넌트로 만들려다 보니 더 신중한 접근이 필요했습니다.

페이지 전환의 종류도 다양하고, 저장해야 할 상태 역시 상황에 따라 달라지며, 스크롤 복원 타이밍이 조금만 어긋나도 UX에 영향을 줄 수 있었기 때문이죠.

이런 문제들을 정리하면서 다음의 네 가지 질문을 던지게 되었고, 그에 대한 고민을 바탕으로 스크롤 복원 기능을 설계하게 되었습니다.


1. 스크롤 복원에 필요한 상태는 무엇인가?

기본적으로는 스크롤 위치 (scrollX, scrollY) 값만 저장하면 충분합니다. 하지만 무한 스크롤처럼 렌더링 데이터에 따라 스크롤 위치가 달라지는 경우에는 페이지 번호, 필터 조건 등의 부가 상태도 함께 저장해야 자연스러운 복원이 가능합니다. 다만 이처럼 페이지에 따라 복원이 필요한 상태는 달라질 수 있기 때문에, 스크롤 위치 외의 특수한 상태는 별도 처리로 분리하고 스크롤 복구 컴포넌트 자체는 스크롤 위치 복원에만 집중하는 방향으로 설계했습니다.

2. 스크롤 복원에 필요한 상태는 어디에 저장해야 할까?

복원 대상은 사용자의 브라우저 히스토리 기반 이동이므로, 상태 저장도 페이지 이동 간 유지되어야 합니다. 또한 새로고침 시에도 복원에 필요한 상태들은 유지되어야합니다. 이를 고려해 전역 상태나 메모리 대신 Session Storage를 사용했습니다.

3. 어떤 기준으로 복원 여부를 판단할까?

스크롤 복원은 브라우저 히스토리 이동으로 페이지에 다시 접근했을 때만 동작해야 합니다. 그렇다면, 사용자가 '뒤로 가기' 또는 '앞으로 가기'로 페이지에 재접근했는지, 아니면 완전히 새 페이지로 이동한 건지를 어떻게 구분할 수 있을까요?


🔑 React Router의 location.key의 활용

react-router-dom에서 제공하는 useLocation() 훅을 사용하면 pathname, search, hash 외에 key라는 값이 함께 제공됩니다. 이 key는 브라우저 히스토리의 각 엔트리에 고유하게 부여되는 식별자입니다. 사용자가 페이지를 방문하거나 링크를 클릭해서 새로운 히스토리 항목이 추가될 때마다 새로운 key가 생성됩니다.

import { useLocation } from "react-router-dom";
 
const MyComponent = () => {
  const location = useLocation();
  console.log(location.key); // 예: "ac3df4", "b6a7f9" 등
};

React Router는 내부적으로 window.history.state.key와 연결하여 이 키를 추적합니다. 브라우저 히스토리 web API와 연결해 각 페이지 상태를 추적하고 구분할 수 있게 만들어둔 거죠. 따라서 이걸 활용하면 사용자가 뒤로/앞으로 가기로 페이지에 재접근했는지, 아니면 새로운 페이지로 이동한 건지를 구분할 수 있습니다.

이를 통해 아래와 같은 로직이 가능합니다:

  • 이전에 저장해둔 location.key와 현재의 location.key를 비교해
    • 같으면 → 히스토리 이동(뒤로/앞으로) → 복원 필요
    • 다르면 → 새로운 페이지 이동 → 복원 불필요

따라서 이 키를 기반으로 Session Storage에 히스토리 엔트리별 상태를 저장하고, 페이지 진입 시 해당 키에 대한 상태가 있다면 복원을 시도하는 구조로 구현했습니다.


4. 스크롤 복원 타이밍을 어떻게 맞출까?

단순히 히스토리 이동으로 페이지에 접근했다고 해서 바로 스크롤을 복원할 수 있는 건 아닙니다. 스크롤을 복원하려는 위치에 해당하는 콘텐츠가 아직 로드되지 않았다면, 스크롤이 생기지 않아 복원이 실패하거나 어색하게 동작할 수 있습니다.

사실 React Router의 ScrollRestoration 컴포넌트가 Data Router 환경에서만 작동하는 이유도 여기에 있습니다. 데이터가 미리 로딩되지 않으면 복원이 실패할 수 있기 때문이죠.

초기에는 API 요청 완료 시점을 기준으로 복원 타이밍을 잡으려 했지만,

  • API 호출이 여러 번 있을 수 있고
  • 로딩 순서가 예측 불가하며
  • 하위 컴포넌트 내부에서 데이터를 불러오는 경우도 많아 결국 정확한 타이밍을 잡는 게 매우 어렵다는 걸 깨달았습니다.

그래서 접근법을 전환했습니다.

스크롤 가능한 상태인지만 판단하자.

ScrollRestoration 입장에서는 데이터가 어떤 순서로 도착하든 신경 쓸 필요가 없습니다. 복원의 핵심은, 복원하려는 스크롤 위치까지 콘텐츠가 충분히 렌더링되었느냐입니다.

즉, 복원 시점은 데이터 로딩 상태와 무관하게 스크롤이 가능한지 여부만으로 판단해도 충분했습니다. 스크롤 가능 여부는 단순하게 판단할 수 있습니다.

document.body.scrollHeight >= savedScrollY

이 조건을 만족하면 복원이 가능한 상태라고 판단할 수 있습니다.

📏 ResizeObserver를 통한 복원 타이밍 제어

그렇다면 이 타이밍을 어떻게 감지할까요? 제가 채택한 방식은 ResizeObserver를 이용한 스크롤 가능 상태 감지입니다. body 태그의 크기를 관찰하다가, 저장된 스크롤 위치 이상으로 커졌을 때 스크롤 복원을 실행하는 방식입니다.

const observer = new ResizeObserver(() => {
  const scrollable = document.body.scrollHeight > savedScrollY;
  if (scrollable) {
    window.scrollTo(0, savedScrollY);
    observer.disconnect();
  }
});
observer.observe(document.body);
  • 페이지가 스크롤 가능한 상태가 되면 바로 복원
  • 조건이 충족되지 않으면 계속 감지
  • 복원이 완료되면 disconnect()로 옵저버 정리

덕분에 데이터 로딩 순서나 서버 데이터의 렌더링 타이밍과 상관없이, 보다 안정적인 복원 타이밍을 확보할 수 있었습니다.

스크롤 복구 플로우

위 고민을 바탕으로 다음과 같이 스크롤 복구 플로우를 정리하였습니다.

스크롤 복구 플로우

핵심 구현

이제, 이 고민들을 실제 코드로 어떻게 풀었는지 구체적으로 보여드리겠습니다.

스크롤 복원 기능은 크게 두 부분으로 나뉘어 있습니다.

1. 히스토리 스택 별로 상태를 저장/복원할 수 있게 해주는 커스텀 훅
2. 페이지 이동 시 스크롤 위치를 저장하고, 조건이 맞으면 복원해주는 ScrollRestoration 컴포넌트

이 두 가지를 결합하면, 어떤 페이지든 쉽게 스크롤 복원 기능을 붙일 수 있는 범용 컴포넌트 형태로 사용할 수 있습니다.


1. useSessionStorageByLocationKey - 히스토리별로 상태 저장/복원 커스텀 훅

React Router의 location.key를 기반으로 세션 스토리지에 상태를 저장하고, 해당 key에 따라 구분된 상태를 복원할 수 있게 해주는 커스텀 훅입니다.

const useSessionStorageByLocationKey = <T>(stateRestorationKey: string) => {
  const { key: locationKey } = useLocation();
 
  const getRestorationState: () => T = () => {
    const restorationState = JSON.parse(
      sessionStorage.getItem(locationKey) ?? "{}"
    );
    return restorationState[stateRestorationKey];
  };
 
  const setRestorationState = (state: T) => {
    const prevState = JSON.parse(sessionStorage.getItem(locationKey) ?? "{}");
    sessionStorage.setItem(
      locationKey,
      JSON.stringify({ ...prevState, [stateRestorationKey]: state })
    );
  };
 
  return { locationKey, getRestorationState, setRestorationState };
};

이 훅은 단순한 스크롤 위치뿐만 아니라, 무한 스크롤 페이징 정보 같은 부가 상태도 히스토리 항목별로 저장/복원할 수 있게 해줍니다.

위 커스텀훅을 활용해 아래와 같이 구체적인 상태 목적에 맞게 래핑해두면 컴포넌트에서는 더 간결하고 명확하게 사용할 수 있습니다.

export const useScrollRestorationState = () => {
  return useSessionStorageByLocationKey<{ x?: number; y: number }>(
    SESSION_STORAGE_KEYS.SCROLL_RESTORATION
  );
};
 
export const usePageRestorationState = () => {
  return useSessionStorageByLocationKey<number>(
    SESSION_STORAGE_KEYS.PAGE_RESTORATION
  );
};

또한, 제네릭 타입을 명시적으로 지정하기 때문에 세션 스토리지에 저장된 값의 타입 안정성을 컴파일 타임에 보장할 수 있습니다.

ScrollRestoration – 페이지 이동 시 스크롤 위치를 저장하고 복원

실제로 스크롤 위치를 기록하고 복원해주는 컴포넌트입니다. 페이지 이동 및 스크롤 이벤트 발생 시 현재 스크롤 위치를 저장해두고, 사용자가 히스토리 이동(뒤로 가기 / 앞으로 가기)을 했을 때 스크롤이 가능한 상태인지 확인한 뒤 복원합니다.

// 현재 페이지가 스크롤 가능한 상태인지 판단하는 함수
// scrollHeight/Width가 현재 뷰포트보다 커야 스크롤이 가능하다고 판단
const getHasScrollableArea = () => {
  const scrollableHeight = document.documentElement.scrollHeight;
  const scrollableWidth = document.documentElement.scrollWidth;
  return (
    scrollableHeight > window.innerHeight || scrollableWidth > window.innerWidth
  );
};
 
export const ScrollRestoration = () => {
  // 현재 location.key 기준으로 스크롤 위치를 저장/복원하는 훅
  const { getRestorationState, setRestorationState, locationKey } =
    useScrollRestorationState();
 
  // 현재 스크롤 위치를 세션 스토리지에 저장
  const setScrollPosition = () => {
    // XXX: 세션 스토리지에 저장할 때 소수점 버림 처리
    setRestorationState({
      x: Math.floor(window.scrollX),
      y: Math.floor(window.scrollY),
    });
  };
 
  // 저장된 위치로 스크롤을 이동시키는 함수
  const scrollToRestorationState = ({
    restorationScrollPosition,
    onRestorationScrollEnd,
  }: {
    restorationScrollPosition: { x?: number; y?: number };
    onRestorationScrollEnd: () => void;
  }) => {
    const documentWidth = document.documentElement.scrollWidth;
    const documentHeight = document.documentElement.scrollHeight;
    const windowWidth = window.innerWidth;
    const windowHeight = window.innerHeight;
 
    // XXX: 화면이 큰 경우 scrollable 사이즈가 음수가 될 수 있어 0으로 처리
    const scrollableWidth =
      documentWidth - windowWidth < 0 ? 0 : documentWidth - windowWidth;
    const scrollableHeight =
      documentHeight - windowHeight < 0 ? 0 : documentHeight - windowHeight;
 
    // 저장된 위치가 현재 스크롤 가능한 범위를 초과하면 복원하지 않음
    if (
      scrollableWidth < (restorationScrollPosition.x ?? 0) ||
      scrollableHeight < (restorationScrollPosition.y ?? 0)
    )
      return;
 
    // 스크롤 위치 복원
    window.scrollTo(
      restorationScrollPosition.x ?? 0,
      restorationScrollPosition.y ?? 0
    );
 
    onRestorationScrollEnd();
  };
 
  useEffect(() => {
    // 기본 브라우저 스크롤 복원 비활성화
    history.scrollRestoration = "manual";
 
    // 스크롤 이벤트로 현재 위치 저장
    window.addEventListener("scroll", setScrollPosition, { passive: true });
 
    const restorationScrollPosition = getRestorationState();
 
    // 복원할 위치가 있을 경우, ResizeObserver로 콘텐츠 로드/변경 감지
    const resizeObserver = restorationScrollPosition
      ? new ResizeObserver(() => {
          // 스크롤 가능한 상태가 되면 복원 시도
          if (getHasScrollableArea()) {
            scrollToRestorationState({
              restorationScrollPosition,
              onRestorationScrollEnd: () => {
                // 복원 완료 시 옵저버 해제
                resizeObserver?.disconnect();
              },
            });
          }
        })
      : null;
 
    // 이미 페이지가 스크롤 가능한 상태면 바로 복원
    if (restorationScrollPosition && getHasScrollableArea()) {
      scrollToRestorationState({
        restorationScrollPosition,
        onRestorationScrollEnd: () => resizeObserver?.disconnect(),
      });
    }
 
    // 그렇지 않으면 body 요소에 변화가 생길 때까지 감시
    if (restorationScrollPosition) {
      resizeObserver?.observe(document.body);
    }
 
    // 클린업: 스크롤 이벤트 및 옵저버 정리
    return () => {
      window.removeEventListener("scroll", setScrollPosition);
      resizeObserver?.disconnect();
    };
  }, [locationKey]);
 
  return null;
};

핵심 포인트:

  • 스크롤 가능한지 판단해서 복원
  • scrollPosition 초과 여부로 스크롤 가능성 체크
  • 복원 완료 후 옵저버 정리

이 두 가지를 조합하면, 특정 도메인에 얽매이지 않고 앱 전체에서 재사용할 수 있는 범용 스크롤 복원 컴포넌트로 사용할 수 있습니다. 사용자는 별도의 조건 분기 없이 단순히 ScrollRestoration를 루트에 추가해주기만 하면 됩니다.

const App = () => {
  return (
    <Provider store={store}>
      <QueryConfig>
        <BrowserRouter>
          <ScrollRestoration />
          <ScrollToTop />
          <CustomRoutes />
          ...
        </BrowserRouter>
      </QueryConfig>
    </Provider>
  );
};

무한스크롤 기능이 구현된 페이지에서는 아래와 같이 데이터 렌더링에 필요한 상태를 추가로 저장하고 복원해주면 됩니다.

const RegistrationCourseContent = () => {
  const [page, setPage] = useState(1);
 
  const {
    getRestorationState: getPageRestorationState,
    setRestorationState: setPageRestorationState,
  } = usePageRestorationState();
 
  // 뒤로가기로 복귀 시 이전에 보고 있던 페이지 번호 복원
  useEffect(() => {
    const restorationPage = getPageRestorationState();
    if (restorationPage) {
      setPage(restorationPage);
    } else {
      // 복구할 페이지가 없다면 새로 진입했다는 뜻이므로 초기화
      setPage(1);
    }
  }, [searchParams.toString()]);
 
  // 스크롤할 때마다 현재 페이지 번호 저장
  // ✅ 처음엔 `page` 상태가 바뀔 때마다 저장하도록 했지만,
  // 복원 직후 상태가 다시 덮이는 버그가 발생해 스크롤 이벤트를 기준으로 저장하도록 바꿨습니다.
  // 무한스크롤의 특성상 스크롤이 곧 사용자 상호작용이기 때문에,
  // 실제 유저가 콘텐츠를 소비한 시점을 기준으로 복원 상태를 저장하는 것이 더 안정적입니다.
  useEffect(() => {
    const handleScroll = () => {
      setPageRestorationState(page);
    };
 
    window.addEventListener("scroll", handleScroll, { passive: true });
 
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, [page]);
 
 
 
  // 페이지 번호에 따라 데이터를 불러오는 쿼리
  const { courseList, totalPage } = useRegistrationCoursesByParams({
    categoryId: selectedCategoryId,
    infinitePage: page,
    ...
  });
 
  const viewMoreTriggerRef = useInfiniteScroll(() => {
    if (page >= totalPage) return;
    setPage(page + 1);
  });
 
  return (
    <>
      {/* ...기타 UI 생략 */}
      <RegistrationCourseList
        courseList={courseList}
        ref={viewMoreTriggerRef}
      />
      {/* ... */}
    </>
  );
};
 

마무리하며

Scroll Restoration은 단순히 위치를 기억하는 기능처럼 보이지만, 사용자의 흐름을 끊지 않고 이어주는 중요한 UX 요소입니다. 이번 구현을 통해 도메인에 종속되지 않고, 어떤 페이지에서든 적용 가능한 범용적인 스크롤 복원 컴포넌트를 만들 수 있었고, 이를 통해 앱의 사용자 경험도 한층 더 매끄러워졌습니다.

앞으로도 이런 유틸리티성 로직들을 잘 추상화해서 패키지화해보는 것도 고려 중입니다. 비슷한 고민을 하고 계신 분들께 이 글이 도움이 되었길 바라며 부족한 점이 있다면 의견 부탁드리겠습니다.🙏