SPA에서 목록을 아래까지 읽다가 상세 화면으로 이동한 뒤, 다시 목록으로 돌아왔을 때 스크롤 위치가 맨 위로 돌아가면 꽤 불편합니다.

처음에는 window.scrollTo 한 줄이면 해결될 것처럼 보입니다. 하지만 목록이 API로 렌더링되고 페이지 이동이 클라이언트 라우팅으로 처리되면 생각보다 고려할 지점이 많습니다.

기본적인 구현

스크롤 복원에 필요한 값은 단순합니다.

const key = 'list-scroll-y';

// 목록을 떠날 때
sessionStorage.setItem(key, String(window.scrollY));

// 목록에 돌아왔을 때
const saved = sessionStorage.getItem(key);
if (saved !== null) {
  window.scrollTo(0, Number(saved));
}

스크롤 위치는 탭을 닫으면 사라져도 되는 임시 UI 상태인 경우가 많아서 localStorage보다 sessionStorage가 자연스럽습니다.

requestAnimationFrame만으로는 부족하다

다음과 같은 코드를 자주 볼 수 있습니다.

requestAnimationFrame(() => {
  window.scrollTo(0, Number(savedScrollY));
});

requestAnimationFrame은 다음 repaint 전에 콜백을 실행하도록 예약하는 API입니다. 네트워크 응답이나 이미지 로딩이 끝날 때까지 기다리는 API는 아닙니다.

따라서 목록 컴포넌트가 마운트된 직후에는 문서 높이가 아직 짧을 수 있습니다.

목록 진입
 스켈레톤만 렌더링
 scrollTo(3000) 실행
 현재 문서 높이가 부족해 이동 가능한 곳까지만 이동
 API 응답  목록이 길어짐
 이미 복원 작업은 끝났으므로 3000으로 다시 이동하지 않음

복원 시점을 데이터 준비 이후로 미뤄야 합니다.

function ListPage() {
  const { data, isPending } = useListQuery();

  useScrollRestoration('list-scroll-y', {
    enabled: !isPending && data !== undefined,
  });

  return <List data={data} />;
}

여기서 data?.length > 0을 준비 조건으로 사용하면 안 됩니다. 정상적인 빈 결과와 아직 로딩 중인 상태를 구분할 수 없기 때문입니다. data !== undefined처럼 쿼리 결과가 도착했는지를 기준으로 삼는 편이 안전합니다.

브라우저 기본 복원과 직접 구현한 복원

브라우저도 히스토리 이동 시 스크롤 위치를 복원하려고 합니다. 직접 복원할 때는 두 주체가 경쟁하지 않도록 앱 시작 시 한 번만 수동 모드로 설정하는 것이 좋습니다.

useEffect(() => {
  window.history.scrollRestoration = 'manual';
}, []);

목록 훅이 마운트될 때마다 manual로 바꾸고 언마운트 시 auto로 되돌리면, 브라우저 뒤로가기와 애플리케이션의 뒤로가기 버튼이 서로 다른 결과를 만들 수 있습니다.

스크롤 위치 저장은 페이지 이동 시점에 처리하거나 scroll 이벤트에서 최신 좌표를 저장하는 방식으로 구현할 수 있습니다. 스크롤 이벤트가 너무 자주 발생하는 것이 걱정되면 requestAnimationFrame으로 저장 작업만 한 프레임에 한 번으로 제한할 수 있습니다. 이때도 중요한 것은 requestAnimationFrame을 데이터 준비 신호로 사용하지 않는 것입니다.

무한 목록에서의 한계

스크롤 좌표를 복원하는 것과 해당 좌표까지 문서를 다시 만드는 것은 별개의 문제입니다.

사용자가 여러 페이지를 불러온 뒤 상세로 이동했는데 목록 데이터 캐시가 사라졌다면, 돌아왔을 때 첫 페이지만 렌더링될 수 있습니다. 이 상태에서 저장된 좌표가 아무리 정확해도 문서가 그만큼 길지 않습니다.

완전한 복원이 필요하다면 스크롤 좌표와 함께 당시 불러온 페이지 수를 저장하고, 필요한 페이지를 먼저 다시 가져온 뒤 스크롤을 이동해야 합니다.

저장: scrollY + loadedPageCount
복귀: loadedPageCount까지 fetchNextPage
       목록 렌더링 완료
       scrollTo(scrollY)

정리

결국 스크롤 복원은 scrollTo 호출 자체보다 언제 저장하고, 언제 목록을 충분히 만들었는가를 설계하는 문제에 가깝습니다.