Navigation

Virtualized Scroll Restore

2023년 말부터 2024년 초까지 커뮤니티 피드와 커머스 상품 목록에서 react-virtualized 9 계열의 WindowScroller로 만든 목록에 스크롤 복원을 붙였습니다. 상세를 보고 돌아왔을 때 보던 항목이 그대로 보이려면 스크롤 좌표 말고도 데이터와 측정한 행 높이가 있어야 했고 지금 복원해도 되는지도 판단해야 했습니다. 당시 사내 위키에 문제를 만날 때마다 덧붙여 쓴 기록과 페이지 상태 복원 메모를 이 글 하나로 합쳤습니다.

글에서는 당시 방법과 실패한 시도, 지금 다시 확인한 기준을 나눠 적었습니다. 라이브러리 내부 캐시를 고친 우회책과 짧은 interval 보정은 당시 환경(react-virtualized 9.x, React Query v4, Zustand)에서 한 대응입니다. 새로 구현할 때 권하는 API 사용법으로 읽지 않았으면 합니다. 라이브러리 동작은 react-virtualized 9.22.3의 문서와 소스, 2026년 10월에 나온 10.0.0의 변경 기록으로 다시 확인했습니다.

그림은 당시 문서에 직접 그린 것입니다. 회사 화면이 담긴 화면 녹화와 캡처는 글로 풀어 썼습니다. 코드 예시는 대외비 코드를 그대로 옮기지 않고 판단에 필요한 구조만 남겨 일반화했습니다. 목록 래퍼와 측정값 주입은 프로젝트마다 동료와 나눠 만든 부분이 있어 제가 맡은 범위는 피드 사례와 커머스 목록 사례에 따로 적었습니다.

가상 스크롤 목록은 좌표만으로 돌아갈 수 없었습니다

여러 문서로 이루어진 사이트에서는 뒤로가기로 이전 문서를 열면 브라우저가 스크롤 위치를 되돌립니다. history.scrollRestoration의 기본값 auto가 하는 일입니다. SPA에서는 라우트가 바뀔 때 목록 컴포넌트가 새로 마운트되고 데이터도 다시 준비됩니다. 브라우저가 위치를 되돌리려는 순간 목록이 아직 그려지지 않았다면 그 위치까지 내려갈 문서 높이가 없습니다.

가상 스크롤은 여기에 조건을 하나 더 붙입니다. 화면에 보이는 행과 위아래 여유분만 DOM에 두고 나머지 행은 높이로만 계산합니다. 전체 높이와 각 행의 위치는 측정한 높이로 정하고 아직 측정하지 않은 행에는 기본 높이를 씁니다. 발표 자료에서는 이 차이를 고정 높이 목록과 가변 높이 목록으로 나눠 설명했습니다.

목록돌아왔을 때 필요한 값
행 높이가 고정항목 데이터, 마지막 스크롤 위치
행 높이가 가변항목 데이터, 행마다 측정한 높이, 마지막 스크롤 위치

피드 카드는 이미지 유무와 본문 길이에 따라 높이가 달랐습니다. 측정 전 기본 높이로 그린 목록과 측정을 마친 목록은 위쪽 행의 높이 합이 달라서 같은 scrollTop을 넣어도 다른 카드가 보였습니다.

“이전 상태”를 한 덩어리로 다루면 어디에 저장하고 언제 다시 써야 할지 흐려집니다. 상태를 종류별로 나누고 저장 위치와 재사용 조건을 따로 정했습니다.

상태예저장 위치다시 쓸 조건
공유할 조회 조건검색어, 필터, 정렬URL query같은 URL 조건
목록 구성항목 id와 순서, 불러온 페이지쿼리 캐시같은 조회의 데이터
화면 안의 위치window 위치, 목록 위치메모리 스토어대상 항목과 레이아웃이 유효함
행 측정값폭에 따른 행 높이메모리 스토어폭과 콘텐츠의 측정 조건이 같음

다른 사람과 링크로 나눌 조건만 URL에 둡니다. 행 높이처럼 화면 내부에서만 쓰는 값은 Web Storage나 메모리 스토어에 둡니다. MDN 기준으로 Web Storage는 origin마다 localStorage와 sessionStorage를 각각 5MiB까지 저장할 수 있습니다. 행 높이와 위치를 화면마다 계속 쌓기에는 이 한도가 부담이었고 결국 Zustand 메모리 스토어를 골랐습니다. 메모리 스토어는 새로고침하면 사라집니다. 스토어 라이브러리를 쓴다고 영속성이 따라오지는 않습니다.

window 좌표와 목록 좌표는 다릅니다

피드는 목록 위에 채널 소개 같은 영역이 있고 페이지 전체가 함께 내려가는 화면이었습니다. 목록이 자기 스크롤 영역을 따로 가지면 화면이 두 겹으로 스크롤됩니다. 그래서 WindowScroller로 window 스크롤과 목록을 연결했습니다.

왼쪽은 문서 전체 스크롤을 쓰는 window 가상 스크롤, 오른쪽은 컴포넌트 안에서만 스크롤하는 가상 스크롤을 비교한 손그림

왼쪽처럼 window 스크롤을 쓰면 목록이 페이지 전체 스크롤을 따라갑니다. 오른쪽은 목록이 컴포넌트 안에 스크롤 영역을 따로 갖는 방식입니다.

9.22.3 소스에서 WindowScroller는 목록이 페이지 위쪽에서 떨어진 거리를 updatePosition()으로 계산해 둡니다. 스크롤 이벤트가 오면 Math.max(0, window 스크롤 위치 - 목록 시작 위치)를 목록의 scrollTop으로 넘깁니다. 목록 위 영역을 지나기 전까지는 window가 아무리 내려가도 목록 scrollTop이 0이라는 뜻입니다.

목록 위 콘텐츠 영역 안에 현재 스크롤이 있을 때 window는 300이고 목록 scrollTop은 0인 상태를 그린 손그림

처음에는 목록 scrollTop 하나만 저장했습니다. 그런데 위 영역 안에서 상세로 갔다가 돌아오면 저장값이 0이라 맨 위에서 다시 시작했습니다. 그 뒤로는 두 값을 함께 저장하고 복원할 때 목록 위치가 있으면 목록에, 없으면 window에 적용했습니다.

왼쪽은 WindowScroller 안의 콘텐츠 영역과 가상 목록 영역 구조, 오른쪽은 목록 영역이 뷰포트를 벗어났는지에 따라 window scrollTop과 목록 scrollTop 중 하나로 복원하는 흐름도

목록 위치는 당시 WindowScroller 인스턴스에 ref를 걸어 state.scrollTop을 읽었습니다. 클래스 컴포넌트의 state는 공개 계약이 아닙니다. 같은 값은 WindowScroller의 onScroll 콜백이 { scrollTop }으로 넘겨 주므로 지금이라면 콜백으로 기록하겠습니다.

두 좌표를 함께 기록하고 나눠 적용tsx
const positionRef = useRef({ windowY: 0, listTop: 0 })

const onWindowScroll = ({ scrollTop }: { scrollTop: number }) => {
  // scrollTop은 목록 시작점을 기준으로 계산된 값이다
  positionRef.current = { windowY: window.scrollY, listTop: scrollTop }
}

function applySavedPosition(saved: { windowY: number; listTop: number }) {
  if (saved.listTop > 0) {
    setForcedTop(saved.listTop) // 목록 시작점을 지난 뒤에 떠났다
  } else if (saved.windowY > 0) {
    // 목록 위 영역 안에서 떠났다. 다음 렌더링 뒤에 window를 옮긴다
    setTimeout(() => window.scrollTo({ top: Math.floor(saved.windowY) }), 10)
  }
}

목록 시작 위치는 마운트와 창 크기 변경 때 다시 계산됩니다. 공식 문서도 WindowScroller 위쪽 콘텐츠의 크기가 바뀌면 updatePosition()을 직접 호출하라고 안내합니다. 소개 영역이 접히거나 펼쳐졌는데 이 호출을 빠뜨리면 목록이 엉뚱한 범위를 그리다가 영역 전체가 비어 보였습니다.

List에는 scrollToRow와 scrollToPosition 메서드도 있습니다. 하지만 react-virtualized 9.21.2와 React 16.8 조합에서 WindowScroller와 함께 쓰면 두 메서드가 오류 없이 아무 동작도 하지 않는다는 보고(#1507)가 있었고 저희 화면에서도 제대로 동작하지 않았습니다. 결국 List의 scrollTop prop에 복원값을 직접 넣는 쪽을 택했습니다. 이 선택이 아래에서 다룰 강제 위치 문제로 이어졌습니다.

측정한 행 높이를 저장하고 다시 넣습니다

CellMeasurer는 행을 실제로 그려 높이를 재고 CellMeasurerCache가 그 값을 보관합니다. List는 rowHeight={cache.rowHeight}로 캐시에서 높이를 읽고 측정 전인 행에는 defaultHeight를 씁니다. 9.22.3의 기본 keyMapper는 ${rowIndex}-${columnIndex} 모양의 키를 만듭니다. 그래서 당시 저장한 높이 객체의 키는 0-0, 1-0처럼 행 순서를 따랐습니다.

저장해 둔 행 높이를 새로 그릴 행에 주입해 처음부터 이전 높이로 그리는 과정을 세 칸으로 그린 손그림

돌아온 화면에서 캐시를 새로 만들면 모든 행이 기본 높이에서 시작합니다. 저장한 높이를 먼저 캐시에 넣은 뒤 목록을 그려야 첫 렌더링부터 떠날 때와 같은 높이 합이 나오고 저장한 scrollTop이 같은 행을 가리킵니다.

높이는 행을 그릴 때마다 기록했습니다. 측정 전 행까지 기록하면 기본 높이가 섞이므로 측정을 마친 행만 남기는 편이 맞습니다. 저장은 화면을 떠나는 순간에 했고 위치와 높이를 읽는 부분은 변수가 아닌 함수로 두었습니다. 렌더링 중에 변수로 담아 두면 그 렌더링 시점의 값에 고정되고 함수로 두면 저장 직전에 ref의 최신 값을 읽기 때문입니다.

측정을 마친 행의 높이만 기록tsx
const heightsRef = useRef<Record<string, number>>({})

const rowRenderer = ({ index, key, parent, style }: ListRowProps) => {
  const item = itemsRef.current[index]
  if (cache.has(index)) heightsRef.current[item.id] = cache.rowHeight({ index })

  return (
    <CellMeasurer key={key} cache={cache} parent={parent} rowIndex={index} columnIndex={0}>
      {({ measure, registerChild }) => (
        <div ref={registerChild} style={style}>
          {/* 이미지가 늦게 들어오거나 펼침으로 높이가 바뀌면 이 행만 다시 잰다 */}
          <Card item={item} onImageLoad={measure} onToggle={measure} />
        </div>
      )}
    </CellMeasurer>
  )
}

// 떠나는 시점에 호출한다
const readSnapshot = () => ({ ...positionRef.current, heights: { ...heightsRef.current } })

다시 넣는 쪽이 문제였습니다. 당시 코드는 _rowHeightCache와 _cellHeightCache에 저장한 객체를 직접 대입했습니다. 9.22.3 문서가 공개 메서드로 적은 것은 clear와 clearAll뿐이고 밑줄로 시작하는 두 필드는 내부 구현입니다. 버전이 바뀌면 이름이나 계산 방식이 달라질 수 있습니다. 10.0.0에서는 실제로 행 높이를 파생하는 방식이 바뀌었습니다.

같은 클래스에는 set(rowIndex, columnIndex, width, height)도 있습니다. CellMeasurer가 측정값을 넣을 때 쓰는 메서드로 9.x 문서에는 빠져 있지만 10.0.0의 공개 타입 CellMeasurerCacheAPI에는 “보통 직접 부를 필요는 없다”는 설명과 함께 들어 있습니다. 내부 필드 대입과 달리 행 높이 같은 파생값도 함께 계산하므로 다시 만든다면 set으로 채우겠습니다.

키도 인덱스가 아닌 항목 id로 바꿔야 합니다. 문서는 keyMapper를 행과 열 인덱스를 항목 id로 바꿔 목록이 바뀌어도 측정값이 무효가 되지 않게 하는 옵션으로 소개합니다. 인덱스 키는 목록 앞에 새 글이 끼어들면 높이가 한 칸씩 다른 항목에 붙습니다. 아래에서 다룰 “길이가 같아도 다른 항목일 수 있다”는 문제와도 이어집니다.

저장한 높이로 측정 캐시 채우기ts
type SavedHeights = Record<string, number> // 항목 id별 측정 높이

function createPrimedCache(
  idAt: (rowIndex: number) => string, // 최신 목록을 읽는 함수
  rowCount: number,
  saved: SavedHeights | undefined,
  defaultHeight: number,
) {
  const cache = new CellMeasurerCache({
    fixedWidth: true,
    defaultHeight,
    keyMapper: (rowIndex) => idAt(rowIndex), // 앞에 항목이 끼어도 높이가 항목을 따라간다
  })
  if (!saved) return cache

  for (let index = 0; index < rowCount; index++) {
    const height = saved[idAt(index)]
    // fixedWidth라 너비 값은 쓰이지 않는다
    if (height !== undefined) cache.set(index, 0, 0, height)
  }
  return cache
}

반대로 캐시를 너무 자주 비우면 다른 문제가 생겼습니다. 초기 구현은 받아 온 데이터가 바뀌거나 사용자가 무언가를 누를 때마다 캐시를 초기화했습니다. 측정을 마친 행이 모두 기본 높이로 돌아갔다가 다시 측정되면서 목록 전체가 줄었다 늘어났습니다.

측정된 높이를 가진 목록이 cache.clear 뒤 모든 행이 기본 높이로 바뀌고 measure로 다시 측정 높이로 돌아오는 과정을 그린 손그림

캐시는 useMemo로 만들고 정말 다시 재야 하는 조건만 의존성으로 두었습니다. 문서 기준으로 clearAll()은 반응형 레이아웃에서 너비가 바뀌어 전체를 다시 배치해야 할 때 씁니다. 한 행의 내용만 바뀌었다면 문서가 안내하는 대로 그 행만 clear(rowIndex, columnIndex)로 비우거나 CellMeasurer가 넘겨 주는 measure()로 다시 잽니다. 폭, 폰트, 이미지와 펼침 상태가 바뀌면 저장한 높이도 무효일 수 있으니 유지할 조건과 버릴 조건을 함께 정해야 했습니다.

가상 목록에서 클릭한 행만 measure로 다시 측정해 높이가 커지고 아래 행이 밀려나는 모습을 그린 손그림

펼치기처럼 한 행의 높이가 바뀌는 상호작용은 그 행의 measure만 부르면 됩니다. 이미지가 늦게 들어오는 행도 공식 예제처럼 onLoad에서 measure를 부릅니다. 목록 위 영역의 높이가 바뀔 때만 앞에서 본 updatePosition()이 필요합니다.

저장한 값을 언제 다시 쓸지 정합니다

떠날 때 저장하고 돌아올 때 꺼냅니다

복원의 기본 흐름은 단순합니다. 화면을 떠날 때 필요한 값을 스토어에 저장하고 돌아오면 꺼내 화면에 넣습니다.

현재 페이지에서 다음 페이지로 나갈 때 스토어에 저장하고 다시 들어올 때 스토어에서 불러오는 흐름을 그린 도식

저장 시점은 라우터에 따라 달랐습니다. Next.js Pages Router 프로젝트는 라우터의 routeChangeStart 이벤트에서 저장했고 React Router 프로젝트는 페이지 컴포넌트 effect의 정리 함수에서 저장했습니다. 이벤트에서 저장하든 언마운트 때 저장하든 결과는 크게 다르지 않았습니다.

정리 함수에서 저장할 때는 개발 모드의 StrictMode를 함께 따져야 합니다. React는 개발 모드에서 모든 effect를 설정, 정리, 설정 순서로 한 번 더 실행합니다. 마운트 직후 정리 함수가 한 번 불리므로 아직 측정이 끝나지 않은 빈 값이 먼저 저장됩니다. 저장 함수가 기존 값을 합치는 구조라면 개발 모드에서만 복원이 어긋나 보일 수 있습니다. 운영 빌드에는 없는 동작이라 원인을 찾느라 시간을 쓰기 쉽습니다. 저장할 값이 비어 있으면 건너뛰도록 해 두면 개발 모드와 운영 모드의 결과가 같아집니다.

저장 키도 정해야 했습니다. 같은 URL이라도 새로 들어온 목록은 처음부터 보여야 하고 뒤로가기로 돌아온 목록은 보던 자리에서 시작해야 합니다. Next.js는 history 항목마다 key를 넣습니다. 새로 진입하면 새 key가 생기고 뒤로가기로 돌아오면 이전 key가 돌아옵니다. 이 key로 캐시 항목을 고르고 오래된 항목은 30분 뒤 정리했습니다.

history 항목별 복원 캐시ts
type RestoreEntry = { url: string; savedAt: number; data?: unknown }
const TTL_MS = 30 * 60 * 1000

export const useRestoreStore = create<{
  entries: Record<string, RestoreEntry>
  save: (entryKey: string, url: string, data: unknown) => void
  load: <T>(entryKey: string) => T | undefined
  prune: () => void
}>((set, get) => ({
  entries: {},
  save: (entryKey, url, data) =>
    set((s) => ({ entries: { ...s.entries, [entryKey]: { url, data, savedAt: Date.now() } } })),
  load: <T,>(entryKey: string) => get().entries[entryKey]?.data as T | undefined,
  prune: () =>
    set((s) => ({
      entries: Object.fromEntries(
        Object.entries(s.entries).filter(([, entry]) => Date.now() - entry.savedAt < TTL_MS),
      ),
    })),
}))

이 스토어의 수명과 React Query 캐시의 수명은 서로 다릅니다. 아래에서 다룰 문제 대부분이 이 차이에서 나왔습니다.

복원해도 되는지 먼저 판단합니다

복원값이 있다는 사실만으로 복원하면 안 됐습니다. 저장한 값과 지금 받은 데이터가 어떤 관계인지에 따라 세 경우로 나눴습니다.

새로 받은 데이터만 있고 저장값이 없는 경우, 캐시 데이터와 저장값이 모두 있는 경우, 둘 다 있지만 저장값을 쓰면 안 되는 경우를 가상 스크롤 목록으로 보내는 세 갈래 도식

먼저 데이터가 준비될 때까지 기다려야 했습니다. 무한 스크롤 쿼리는 캐시가 남아 있으면 불러 두었던 페이지를 한 번에 돌려줍니다. 저장한 항목 수만큼 데이터가 찰 때까지 목록 렌더링을 미룬 이유입니다. 응답은 응답 변환 계층을 거쳐 화면 모델이 된 뒤 목록에 들어갔습니다. 변환이 끝나기 전에 목록을 먼저 그리면 뒤늦게 항목이 붙거나 레이아웃이 바뀌면서 간헐적으로 엉뚱한 위치에 도착했습니다.

길이 비교만으로는 부족합니다. 길이가 같아도 다른 항목일 수 있고 순서가 바뀌었을 수도 있습니다. 조회 조건과 항목 id 순서까지 비교해야 이전 위치를 적용할 근거가 생깁니다. id가 같아도 내용이 바뀌었다면 높이는 다시 확인해야 합니다.

캐시 만료도 문제였습니다. 오래 자리를 비웠다 돌아오면 목록이 아예 그려지지 않는 문제가 있었습니다. React Query v4에서 cacheTime은 사용하지 않는(inactive) 캐시 데이터가 메모리에 남는 시간이고 기본값은 5분입니다. 목록에서 상세로 가면 목록 쿼리를 보는 컴포넌트가 사라지고 그때부터 5분이 흐릅니다. 스토어 값은 만료를 따로 정하지 않으면 계속 남습니다. 쿼리 캐시가 비워진 뒤 돌아오면 스토어는 여전히 이전 항목 수만큼 기다리라고 하는데 새로 받은 데이터는 첫 페이지뿐이라 목록이 그려질 조건이 오지 않았습니다.

원문에는 이 옵션을 gcTime이라고 적었는데 당시 쓰던 v4의 이름은 cacheTime입니다. v5에서 gcTime으로 바뀌었습니다. 이전 안내는 많은 사람이 이 옵션을 “데이터를 캐시하는 시간”으로 잘못 읽었고 쿼리가 사용되는 동안에는 아무 일도 하지 않기 때문에 garbage collect의 약자로 이름을 바꿨다고 설명합니다.

당시에는 이번 진입에서 isLoading을 한 번이라도 거쳤으면 캐시 없이 새로 받은 것으로 보고 복원하지 않았습니다. v4에서 loading 상태는 캐시 데이터가 없고 아직 끝난 요청도 없는 상태라서 캐시가 비었는지 알려 주는 신호로 쓸 수 있었습니다. v5에서는 이 상태가 pending으로 바뀌었고 isLoading은 isPending && isFetching을 뜻하는 새 값이 됐습니다. 같은 코드를 v5로 옮기면 뜻이 달라집니다. 버전에 덜 묶이려면 진입하는 순간 queryClient.getQueryData(queryKey)가 있었는지를 한 번 읽어 두는 편이 낫습니다. 이 메서드는 v4.36.1과 v5.62.0 타입에 같은 모양으로 있습니다.

이 기준을 정하기 전에는 데이터의 갱신 시각이 3분을 넘었는지 비교하는 방법을 먼저 썼습니다. 이 시계는 React Query가 실제로 캐시를 들고 있는지와 따로 움직였습니다. 하루 만에 로딩 상태를 기준으로 바꿔 복원 여부가 실제 캐시 상태를 따르게 했습니다.

사용자의 행동도 따져야 했습니다. 처음 구조에서는 복원한 뒤에도 저장값이 남아 있었습니다. 필터를 바꿔 다른 정보를 보여 주는 목록에서도 이전 스크롤 위치로 돌아갔습니다. 필터를 확정하면 복원 플래그를 끄고 맨 위로 보내도록 했고 탭이 있는 화면은 탭마다 저장 key를 따로 두었습니다.

복원 여부를 한곳에서 판단ts
type Decision = 'restore' | 'wait' | 'fresh'

function decideRestore(saved: SavedList | undefined, now: CurrentList): Decision {
  if (!saved || !now.hadCacheOnEntry) return 'fresh' // 이번 진입에서 캐시 없이 받았다
  if (saved.queryHash !== now.queryHash) return 'fresh' // 조회 조건이 다르다
  if (now.isError) return 'fresh' // 실패하면 기다리지 않는다

  if (now.ids.length < saved.ids.length) {
    return now.isFetching ? 'wait' : 'fresh' // 더 올 데이터가 없으면 대기를 끝낸다
  }
  const sameItems = saved.ids.every((id, i) => now.ids[i] === id)
  return sameItems ? 'restore' : 'fresh' // 길이가 같아도 다른 항목일 수 있다
}

isLoading 하나로 판단하지 않고 실패와 빈 결과에서도 대기를 끝내야 합니다. 그래야 화면이 로딩 상태에 머물지 않습니다. 판단 순서를 그림으로 정리하면 다음과 같습니다.

flowchart TD
    Back["목록으로 복귀"] --> Same{"같은 조회 조건인가?"}
    Same -->|아니요| Fresh["새 목록으로 표시"]
    Same -->|예| Cache{"진입할 때 쿼리 캐시가 있었나?"}
    Cache -->|아니요| Fresh
    Cache -->|예| Data{"저장한 항목과 같은 데이터가 준비됐나?"}
    Data -->|아직| Wait["기다리되 실패와 빈 결과에서 해제"]
    Data -->|다름| Fresh
    Data -->|예| Layout["행 높이 주입, 목록 위치 확인"]
    Layout --> Restore["저장 위치를 적용하고 짧게 확인"]
    Restore --> Release["강제 위치 해제, 사용자 스크롤 허용"]

강제 위치는 적용하고 바로 놓아야 합니다

9.22.3 문서는 List의 scrollTop을 “강제 세로 스크롤 오프셋”으로 설명합니다. 원래 이 자리에는 WindowScroller가 넘겨 주는 scrollTop이 연결되어 window와 목록이 함께 움직입니다. 복원값을 여기에 끼워 넣으면 목록이 그 위치로 갑니다.

컨테이너에서 복원값을 useState로 두고 List의 scrollTop에 복원값 또는 WindowScroller의 scrollTop을 넣어 복원된 위치로 가는 모습을 그린 도식

복원값을 지우지 않으면 사용자가 아무리 스크롤해도 다시 그 자리로 끌려갑니다. 복원값은 useState로 두고 위치에 도착하면 undefined로 지웠습니다. 값을 넣는 쪽과 지우는 쪽이 서로 다른 이벤트에서 움직인다는 점이 까다로웠습니다.

개발자가 스크롤을 2000으로 넣으면 window 스크롤 이벤트로 가상 목록이 움직이고 스크롤 변경을 계기로 값을 undefined로 지우는 흐름을 그린 손그림

그림 속 initScrollY || scrollTop에는 버그가 하나 있습니다. ||는 0을 값이 없는 것으로 취급합니다. 맨 위로 보내려고 0을 넣어도 무시되고 window 쪽 값이 쓰입니다. 값이 없다는 뜻은 undefined로만 나타내고 ??로 고르면 0도 유효한 위치가 됩니다.

복원값의 존재 여부와 수명tsx
const [forcedTop, setForcedTop] = useState<number | undefined>(savedTop)
const controlledTop = forcedTop ?? liveScrollTop // 0도 유효한 위치다

<List scrollTop={controlledTop} {...listProps} />

처음에는 복원값을 의존성으로 둔 effect가 위치를 확인했습니다. 값을 지운 뒤 목표와 차이가 크면 다시 넣었고 값이 바뀌면 effect가 또 도는 식이었습니다. 대부분의 브라우저에서는 몇 번 돌다 목표에 도착했습니다. 하지만 Safari에서는 스크롤 이벤트와 effect의 실행 순서가 달랐고 특히 아이폰 13 미니에서는 몇 번을 다시 계산해도 엉뚱한 곳에 멈췄습니다. 짧은 시간에 state가 여러 번 바뀌는 동안 effect와 라이브러리의 스크롤 처리 중 무엇이 먼저 실행될지 보장할 수 없었습니다.

50, 100, 150, 200ms 눈금마다 스크롤을 확인하고 현재 위치가 0이면 2000으로 다시 보내 도착을 확인하며 200ms 이후는 보장하지 않는다는 내용을 그린 손그림

그래서 effect의 의존성을 없애고 interval로 바꿨습니다. 50ms마다 목록 위치가 목표와 5px 넘게 어긋났는지 보고 어긋났으면 다시 넣었습니다. 200ms가 지나면 강제 위치를 풀었습니다. 200ms는 사용자가 돌아와 첫 동작을 하기 전에 끝내려고 당시 정한 범위입니다. 사용자 행동을 측정한 값은 아닙니다. 확인된 사례는 없었지만 200ms 안에 도착하지 못하면 복원을 포기합니다.

짧게 다시 확인하고 제어권 돌려주기tsx
const CHECK_MS = 50
const LIMIT_MS = 200
const TOLERANCE_PX = 5

useEffect(() => {
  if (target === undefined) return
  let elapsed = 0

  const cleanup = () => {
    clearInterval(timer)
    window.removeEventListener('wheel', release)
    window.removeEventListener('touchstart', release)
  }
  const release = () => {
    cleanup()
    setForcedTop(undefined) // 사용자 스크롤에 넘긴다
  }
  const timer = setInterval(() => {
    if (elapsed > LIMIT_MS) return release()
    if (Math.abs(readListTop() - target) > TOLERANCE_PX) setForcedTop(target)
    elapsed += CHECK_MS
  }, CHECK_MS)

  window.addEventListener('wheel', release, { passive: true })
  window.addEventListener('touchstart', release, { passive: true })
  return cleanup // 화면 이탈과 StrictMode의 추가 정리에서도 타이머를 남기지 않는다
}, [target])

당시 코드는 시간 초과로만 끝났습니다. 위 예제는 사용자가 먼저 움직이면 바로 놓아주는 조건을 더했습니다. interval은 정답이 아닌 우회책입니다. 시간에 기대는 보정이므로 성공, 시간 초과, 화면 이탈과 사용자 개입 네 경우 모두에서 정리되는지 확인해야 합니다.

목록 끝에서도 같은 보정이 말썽이었습니다. 피드 맨 아래에는 모든 게시물을 확인했다는 안내 영역이 있었습니다. 마지막 근처로 복원하면 이 영역 높이까지 오차로 잡혀 목표에 영영 도달하지 못했다고 판단했고 위치를 계속 다시 넣었습니다. 안내 영역에 IntersectionObserver를 붙여 화면에 절반 이상 보이면 그 높이만큼 허용 오차를 늘렸습니다. 지금이라면 목표 위치를 문서가 실제로 스크롤할 수 있는 최댓값으로 먼저 자른 뒤 비교하겠습니다.

레이아웃을 흔든 두 가지 사례

영상을 전체 화면으로 열면 목록이 사라졌습니다

피드의 영상을 전체 화면으로 열면 바로 꺼지는 문제가 있었습니다. 당시 확인한 브라우저에서는 전체 화면으로 바뀌는 순간 window 스크롤이 0이 됐습니다. WindowScroller는 window를 따라 목록 scrollTop도 0으로 바꿨습니다. 목록이 맨 위를 그리면서 영상이 있던 행이 DOM에서 빠졌고 전체 화면 대상이 사라지자 전체 화면도 함께 끝났습니다.

가상 목록 안 8600 위치에 있던 영상을 전체 화면으로 열면 현재 스크롤이 0이 되는 상황을 그린 손그림

fullscreenchange 이벤트로 전체 화면 여부를 기록하고 그동안은 window와 목록의 연결을 잠시 끊었습니다. 목록의 onScroll에서 전체 화면 중이면 새 scrollTop 대신 마지막 값을 onChildScroll에 넘겼습니다. 전체 화면이 끝나면 저장해 둔 window 위치로 되돌리고 다시 연결했습니다.

전체 화면 동안 목록 위치 고정tsx
const inFullscreen = useRef(false)
const lastListTop = useRef(0)
const lastWindowY = useRef(0)

useEffect(() => {
  const onChange = () => {
    inFullscreen.current = document.fullscreenElement !== null
    if (!inFullscreen.current) window.scrollTo(0, Math.floor(lastWindowY.current)) // 원래 자리로
  }
  document.addEventListener('fullscreenchange', onChange)
  return () => document.removeEventListener('fullscreenchange', onChange)
}, [])

const onListScroll = ({ scrollTop }: { scrollTop: number }) => {
  const next = inFullscreen.current ? lastListTop.current : scrollTop // 전체 화면 중에는 이전 값
  lastListTop.current = next
  if (!inFullscreen.current) lastWindowY.current = window.scrollY
  onChildScroll({ scrollTop: next })
}

전체 화면 전환과 스크롤 이벤트의 순서는 브라우저마다 다를 수 있습니다. 이 보정을 다시 쓴다면 지원하는 브라우저마다 진입과 종료를 직접 재현해 봐야 합니다.

행이 붙을 때 스크롤이 끊겼습니다

가상 목록은 화면 위아래로 정해 둔 개수만큼 행을 미리 그리고 스크롤하면 그 범위의 행을 붙이고 뗍니다. 특정 행이 붙는 순간 스크롤이 툭 끊겼습니다. Performance 패널로 기록해 보니 그 순간 Layout Shift가 잡혔습니다.

Chrome DevTools Performance 패널의 Layout Shift 요약으로 Score 0.03272, Cumulative Score 0.1767이 표시된 캡처

캡처의 점수는 그 기록 한 번의 값이라 개선 폭을 비교하는 근거로는 쓰지 않았습니다. 원인을 찾을 때는 DOM을 비교했습니다. 매끄러운 경우에는 새 행만 붙고 기존 행의 top과 height는 그대로였습니다. 끊기는 경우에는 보이는 행 전체의 top과 height가 다시 계산되어 있었습니다.

새 행이 붙을 때 다시 배치를 일으킬 만한 스타일을 찾다가 썸네일 영역의 padding-bottom: 56.16% 비율 박스로 범위를 좁혔습니다. 화면 너비 구간마다 고정 높이를 주도록 바꾸자 끊김이 사라졌습니다. 당시 메모에는 이 백분율이 요소 높이를 기준으로 계산된다고 적었는데 잘못된 설명입니다. MDN 기준으로 padding의 백분율은 컨테이너의 inline size, 가로 쓰기에서는 너비를 기준으로 합니다. 비율 박스가 왜 기존 행의 위치까지 다시 계산하게 만들었는지는 확인하지 못했습니다. aspect-ratio로 바꾼 경우도 같은 조건에서 Performance 기록을 남겨 비교해 볼 만합니다.

해 보고 버린 방법과 지금의 기준

실패한 시도

시도결과바꾼 방법
scrollToRow, scrollToPosition으로 이동WindowScroller와 함께 쓰면 조용히 동작하지 않음List의 scrollTop에 복원값 주입
목록 scrollTop 하나만 저장목록 위 영역 안에서 떠나면 맨 위로 돌아감window 위치와 목록 위치를 함께 저장
데이터 변경과 상호작용마다 캐시 초기화목록이 기본 높이로 줄었다 다시 늘어남캐시 재생성을 정해진 조건으로 제한
응답 변환이 끝나기 전에 목록 렌더링간헐적으로 다른 위치에 도착저장한 항목 수만큼 찬 뒤 렌더링
복원한 뒤에도 저장값 유지조건이 다른 목록에도 이전 위치 적용조건 변경 플래그, 탭별 저장 key
스토어 값을 만료 없이 보관쿼리 캐시가 사라진 뒤 목록이 그려지지 않음캐시 없이 받은 진입에서는 복원 생략
갱신 시각이 3분을 넘었는지 비교실제 쿼리 캐시와 따로 움직임하루 만에 로딩 상태 기준으로 교체
initScrollY || scrollTop0을 값 없음으로 처리undefined와 ??로 구분
복원값을 의존성으로 둔 effect 반복Safari에서 순서가 달라 엉뚱한 위치50ms 간격, 200ms 상한의 재확인
고정된 허용 오차목록 끝 안내 영역 근처에서 반복 보정안내 영역이 보이면 그 높이만큼 허용

지금 다시 만든다면

react-virtualized 10.0.0이 2026년 10월에 나왔습니다. 변경 기록을 보면 이 글의 우회책 상당수가 기대던 API가 바뀌었습니다.

강제 위치를 넣었다 빼는 대신 다음 순서가 자연스럽습니다.

  1. 저장한 높이를 set으로 채운 캐시를 만듭니다.
  2. 복원 판단을 통과한 뒤에 목록을 그립니다.
  3. 행이 그려진 다음 scrollToPosition을 한 번 부릅니다.
  4. 도착했는지 확인하고 끝냅니다.

한 번만 이동하므로 높이를 먼저 채우는 단계가 더 중요해집니다. 10.0.0은 이 글을 정리하는 시점에 나온 지 일주일 된 버전이라 이 흐름을 직접 검증하지는 않았습니다. 판단 기준만 적어 둡니다.

복원은 예전 좌표를 다시 넣는 일보다 같은 목록인지 확인하고 데이터와 레이아웃이 준비되기를 기다린 뒤 제어권을 사용자에게 돌려주는 흐름에 가까웠습니다. 새 구현을 고를 때도 이 흐름이 라이브러리 밖에서 설명되는지부터 보겠습니다.

참고 자료