Skip to main content
Web
deep-diveweb

가변 높이 가상 스크롤: window 계산에서 스크롤 보정까지

고정 높이 목록을 손으로 계산하는 데서 시작해, 실제 높이를 측정하고 누적 위치와 스크롤을 보정하는 전체 순환을 단계별로 살펴본다.

마지막 검증 재검증 정책: 브라우저 호환성 의존: 90일마다 재검증
표준·구현·측정·해석 표시는 무엇인가요?
  • 표준웹 표준이나 언어 명세가 정한 동작
  • 구현특정 기술이나 브라우저가 실제로 구현한 동작
  • 측정명시한 환경에서 직접 실행해 관찰한 결과
  • 해석앞선 근거에서 도출한 설계 판단
  • 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용

30초 요약

가상 스크롤의 핵심은 전체 목록을 작은 목록으로 바꾸는 것이 아니다. 데이터는 그대로 두되, 현재 화면과 그 주변에 필요한 일부 항목만 DOM에 만드는 것이다.

가변 높이 목록은 다음 순환으로 이해하면 된다.

  1. 아직 모르는 높이는 추정한다.
  2. 스크롤 좌표와 누적 높이로 렌더링할 범위를 계산한다.
  3. 렌더링된 행의 실제 높이를 측정한다.
  4. 누적 위치를 갱신한다.
  5. 사용자가 보던 기준 항목이 움직이지 않도록 스크롤을 보정한다.

읽기 전에 알아둘 최소 개념

이 글은 별도 선수 문서 없이 읽을 수 있도록 필요한 표기를 먼저 고정한다.

  • 배열과 인덱스: 배열은 값을 순서대로 보관한다. 인덱스는 0부터 시작하는 위치 번호이며 height[i]i번째 항목의 높이라는 뜻이다.
  • 스크롤 컨테이너: 내용이 정해진 영역보다 커서 내부에 스크롤바가 생기는 요소다.
  • React 렌더링: React가 데이터로부터 화면 요소를 계산해 DOM에 만들거나 갱신하는 과정이다. 여기서는 상태 관리법보다 어떤 행을 만들지 계산하는 데 집중한다.
  • 반개방 구간: 시작값은 포함하고 끝값은 포함하지 않는 범위다. [70, 150)은 70 이상 150 미만을 뜻한다.
  • 코드 표기: const name = value는 이름에 값을 연결한다. { key: value }는 이름표가 붙은 값을 묶는 객체다. calculateWindow(...)처럼 괄호를 붙이면 함수를 호출하며, Math.floor(2.8)은 소수점 아래를 내려 2를 만든다.

왜 이런 기법이 필요한가

목록이 커질수록 React가 계산하고 브라우저가 배치·그리기·관리해야 하는 DOM도 늘어난다. 여기서 문제는 다음 순서로 발전한다.

  1. 전체 항목을 모두 렌더링하면 구현은 단순하지만 DOM 비용이 데이터 수와 함께 커진다.
  2. 고정 높이 windowing은 스크롤 위치를 행 높이로 나누어 필요한 인덱스를 빠르게 찾는다.
  3. 실제 UI에서는 문장 길이, 이미지, 사용자 설정에 따라 높이가 달라진다.
  4. 가변 높이를 지원하려면 추정, 측정, 누적 위치 갱신, 스크롤 보정이 추가된다.

특정 라이브러리보다 이 문제의 발전 순서를 기억하면 다른 구현의 코드도 같은 기준으로 읽을 수 있다.

페이지네이션·무한 스크롤·windowing은 무엇이 다른가

  1. 페이지네이션은 데이터를 페이지 단위로 나누고 사용자가 다음 페이지로 이동하게 한다.
  2. 무한 스크롤은 목록 끝에 가까워질 때 다음 데이터 묶음을 이어서 가져온다.
  3. windowing은 현재 화면에서 멀어진 DOM 항목을 제거하고 필요한 범위만 유지한다.

무한 스크롤만 적용하면 네트워크 요청은 나뉘지만, 이전 항목을 계속 DOM에 남겨 두는 구현에서는 DOM 수가 계속 증가할 수 있다. 반대로 모든 데이터를 한 번에 받아도 windowing으로 DOM 수만 제한할 수 있다.

고정 높이부터 손으로 계산한다

높이가 모두 50px인 네 항목을 생각해 보자.

index        0         1         2         3
item      [0,50)   [50,100) [100,150) [150,200)
viewport             [70,150)

scrollTop70, viewport 높이가 80이면 보이는 구간은 [70, 150)이다.

  • 인덱스 0의 구간 [0, 50)은 화면 위에 있으므로 보이지 않는다.
  • 인덱스 1의 구간 [50, 100)은 화면과 겹친다.
  • 인덱스 2의 구간 [100, 150)도 화면과 겹친다.
  • 인덱스 3은 화면 끝인 150에서 시작하므로 겹치는 픽셀이 없다.

따라서 visible 인덱스는 12다. 고정 높이라면 다음처럼 시작 위치를 계산할 수 있다.

const startIndex = Math.floor(scrollTop / itemHeight)
const offsetTop = startIndex * itemHeight

visible 범위만 렌더링하면 빠르게 스크롤할 때 React가 다음 DOM을 붙이기 전에 빈 공간이 보일 수 있다. 그래서 시작과 끝 바깥에 소수의 항목을 더 렌더링한다.

  • 너무 작으면 빠른 스크롤에서 빈 공간이 보일 가능성이 커진다.
  • 너무 크면 DOM 수와 React 작업량이 다시 늘어난다.
  • 항목 개수와 픽셀 거리 중 어떤 단위를 쓸지는 높이 분포와 스크롤 특성에 따라 정한다.

가변 높이에서는 고정식이 깨진다

문장 길이와 이미지 크기처럼 콘텐츠에 따라 높이가 달라지고 그 값을 미리 받지 못했다면 scrollTop / itemHeight를 사용할 수 없다. 각 항목의 높이가 다르기 때문이다.

항목 i의 시작 위치를 offset[i], 높이를 height[i]라고 하자.

offset[0] = 0
offset[i] = height[0] + height[1] + ... + height[i - 1]

첫 visible 항목은 끝 위치가 화면 시작보다 큰 첫 항목이다.

offset[i] + height[i] > scrollTop

마지막 visible 범위는 시작 위치가 화면 끝보다 작은 항목까지다.

offset[i] < scrollTop + viewportHeight

누적 위치를 cache(cache, 다시 사용하기 위해 보관한 계산값)에 저장하면 매번 처음부터 높이를 합하지 않아도 된다. 위치가 정렬되어 있으므로 이진 탐색(binary search, 탐색 범위를 절반씩 줄이는 방법)으로 첫 인덱스를 찾을 수 있다.

const nextWindow = calculateWindow({ heights: measuredHeights, scrollTop: container.scrollTop, viewportHeight: container.clientHeight, overscan: 2 })

추정·렌더링·측정·보정의 순환

전체 과정은 다음과 같다.

  1. 아직 측정하지 않은 항목에는 추정 높이를 넣는다.
  2. scrollTop, viewport 높이, 현재 누적 위치로 window를 계산한다.
  3. 해당 범위의 행만 DOM에 렌더링한다.
  4. ResizeObserver로 렌더링된 행의 실제 border box 높이를 받는다.
  5. 추정값과 실제값이 다르면 뒤 항목의 누적 위치를 갱신한다.
  6. 변경된 행이 현재 기준 항목보다 위에 있다면 scroll offset을 함께 보정한다.
  7. 새 위치 자료로 window를 다시 계산한다.

단순 배열은 변경 지점 뒤의 누적 위치를 다시 계산한다. 실제로 이 계산이 병목이 될 만큼 데이터와 갱신이 많을 때만 Fenwick tree(각 항목의 높이 변경과 앞에서부터의 누적 합 조회를 모두 빠르게 처리하는 트리형 배열)나 segment tree(구간별 값을 트리로 나눠 저장하는 자료구조) 같은 더 복잡한 구조를 검토한다.

두 관찰 API는 역할이 다르다

IntersectionObserver가 가상 목록에 쓸모없다는 뜻은 아니다.

  • 목록 끝의 sentinel(sentinel, 경계를 관찰하기 위해 둔 작은 요소)을 보고 다음 데이터를 가져온다.
  • 가까워진 이미지나 부가 콘텐츠의 준비를 시작한다.
  • 이미 렌더링된 항목의 노출 여부를 관찰한다.

ResizeObserver는 렌더링된 행의 크기를 측정한다. IntersectionObserver는 교차 상태, ResizeObserver는 크기 변화, scrollTop + 누적 높이는 window 인덱스 계산이라는 역할 구분이 중요하다.

heighttranslateY는 서로 대체하지 않는다

가상 목록에는 서로 다른 두 정보가 필요하다.

  1. 전체 스크롤 범위를 브라우저에 알려 줄 scroll extent(스크롤 가능한 전체 크기)
  2. 현재 window를 누적 위치에 배치할 좌표

spacer 방식에서는 빈 요소의 height로 전체 scroll extent를 표현한다. 다른 방식도 가능하지만 전체 크기를 나타내는 동등한 신호는 필요하다.

행은 absolute positioning(다른 요소의 기본 배치 흐름과 분리해 좌표로 놓는 방식)과 transform: translateY(...)를 조합해 각 누적 위치에 놓을 수 있다.

새 DOM을 추가하거나 행의 내용과 폭이 바뀌면 style·layout·paint가 다시 발생할 수 있다. 따라서 “translateY를 사용하면 layout과 paint를 건너뛴다”는 문장은 조건 없이 일반화하면 틀린 설명이 된다.

높이 보정과 스크롤 안정성

현재 기준 항목보다 위에 있는 행의 실제 높이가 추정보다 40px 크다고 하자.

높이 cache만 갱신: 기준 항목의 누적 위치 +40px → 화면에서도 40px 아래로 이동
scrollTop도 보정:   기준 항목의 누적 위치 +40px, scrollTop +40px → 화면상 이동 0px

일반적인 수동 보정 순서는 다음과 같다.

  1. 측정 직전 기준 항목과 항목 안의 상대 위치를 기록한다.
  2. 실제 높이를 cache에 반영하고 누적 위치를 다시 계산한다.
  3. 기준 항목의 이전 위치와 새 위치 차이만큼 scroll offset을 보정한다.
  4. 실제 브라우저에서 기준 항목의 화면 좌표가 유지되는지 확인한다.

브라우저 기본 anchoring과 수동 보정이 중복될 수 있으므로 무조건 overflow-anchor: none을 적용하거나 반대로 브라우저에 모두 맡기면 안 된다. 제품의 스크롤 구조에서 재현한 뒤 한쪽의 책임을 명확히 정한다.

경계 조건을 뒤에서 따로 처리한다

기본 모형을 먼저 구현한 뒤 다음 조건을 별도 테스트한다.

  • 역방향 flex 또는 writing mode
  • Safari overscroll
  • 0 높이 항목
  • 목록 중간 삽입·삭제
  • 폭 변경, 폰트 로딩, 이미지 로딩으로 인한 높이 변화
  • 빠른 연속 측정에서 같은 항목의 높이가 여러 번 바뀌는 경우

검색·focus·접근성도 window의 일부다

가상화는 성능 문제를 줄이는 대신 다음 실패 가능성을 만든다.

  • DOM에 없는 텍스트는 브라우저의 페이지 내 찾기에서 발견되지 않을 수 있다.
  • focus가 있는 행을 window 밖으로 제거하면 키보드 사용자의 위치가 사라질 수 있다.
  • 스크린 리더에는 현재 항목이 전체 중 어디에 있는지 별도 정보가 필요할 수 있다.
  • 텍스트 선택과 복사는 아직 DOM에 없는 항목을 가로지를 수 없다.

따라서 가상화 여부는 DOM 수만으로 결정하지 않는다. 제품에서 필요한 검색, 키보드 이동, 보조 기술, 선택과 복사 동작을 완료 조건에 포함한다.

권장 구현 순서

  1. 작은 고정 높이 데이터로 visible 범위의 경계 조건을 단위 테스트한다.
  2. spacer와 window 배치를 추가하고 실제 DOM 수를 관찰한다.
  3. overscan을 조절하며 빈 영역과 DOM 수의 변화를 비교한다.
  4. 추정 높이와 ResizeObserver 측정 cache를 추가한다.
  5. 위쪽 높이가 바뀌는 사례로 기준 항목 보정을 검증한다.
  6. 검색·focus·키보드·보조 기술 요구를 별도 시나리오로 검증한다.
  7. 실제 기기에서 Performance trace와 메모리 사용량을 측정한다.

적용하지 않는 편이 나은 경우

  • 항목이 수십 개뿐이고 실제 측정에서 렌더링 비용이 작다.
  • 브라우저 페이지 내 찾기와 전체 텍스트 선택이 핵심 기능이다.
  • 각 행의 편집 상태와 focus 복원이 DOM 절감보다 더 복잡해진다.
  • 높이 변화가 매우 잦아 측정과 보정 비용이 절감되는 DOM 비용보다 크다.
  • 페이지네이션만으로도 제품 요구와 성능 목표를 만족한다.

가상화 자체도 복잡성과 오류 가능성을 만든다. 먼저 실제 목록이 병목인지 측정하고, 필요할 때 가장 단순한 고정 높이 모델부터 시작한다.

실험으로 확인한 범위

이 결과는 “모든 데이터 크기에서 같은 성능이 나온다”는 뜻이 아니다. 다음 범위는 이번 실험으로 확인하지 않았다.

  • Firefox와 WebKit의 같은 상호작용
  • 100개·10,000개·100,000개 사이의 규모별 실행 시간
  • 실제 저사양 모바일 기기의 프레임 시간과 메모리
  • 이미지와 웹 폰트가 늦게 로드되는 제품 데이터

1,000개 항목으로 실험 과정과 결과 확인하기

오늘 기억할 세 문장

  1. windowing은 데이터 수가 아니라 동시에 존재하는 DOM 범위를 줄인다.
  2. 가변 높이는 추정 → 렌더링 → 측정 → 누적 위치 갱신 → 스크롤 보정 순환이다.
  3. IntersectionObserver는 노출 관찰, ResizeObserver는 크기 측정, scrollTop + 누적 높이는 window 계산에 사용한다.

Active recall

기억에서 꺼내 보기

답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.

  1. 01무한 스크롤과 windowing은 각각 무엇의 양을 조절할까?

    정답

    무한 스크롤은 한 번에 가져오는 데이터의 양을 나누고, windowing은 동시에 DOM에 존재하는 항목의 수를 제한한다.

    왜 그런가

    데이터 로딩과 화면 렌더링은 다른 단계이므로 둘을 함께 사용할 수도 있고 하나만 사용할 수도 있다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  2. 02화면 끝에서 정확히 시작하는 항목은 visible 범위에 포함될까?

    정답

    포함되지 않는다. 보이는 범위를 시작 포함·끝 제외인 반개방 구간으로 계산하면 겹치는 픽셀이 없기 때문이다.

    왜 그런가

    화면 끝에서 시작하는 항목은 필요하다면 visible 항목이 아니라 overscan 항목으로 미리 렌더링한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  3. 03IntersectionObserver와 ResizeObserver는 가상 목록에서 각각 어떤 문제에 적합할까?

    정답

    IntersectionObserver는 이미 존재하는 sentinel 등의 노출을 관찰하고, ResizeObserver는 렌더링된 행의 실제 크기 변화를 측정하는 데 적합하다.

    왜 그런가

    아직 만들지 않은 항목의 인덱스는 scrollTop과 높이 자료로 먼저 계산해야 한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  4. 04행을 translateY로 옮기면 layout과 paint가 항상 사라질까?

    정답

    아니다. transform은 주변 요소의 기본 배치 흐름을 다시 옮기지 않지만 새 DOM, 내용, 폭, layer 구성에 따라 style·layout·paint가 발생할 수 있다.

    왜 그런가

    실행된 렌더링 단계는 코드 모양만으로 단정하지 말고 Performance trace에서 확인해야 한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  5. 05현재 화면 위의 행이 40px 커지면 기준 행을 유지하기 위해 무엇을 보정해야 할까?

    정답

    높이 변화로 늘어난 누적 위치만큼 scroll offset도 40px 보정해야 기준 행의 화면상 위치가 유지된다.

    왜 그런가

    높이 cache만 바꾸면 기준 행의 문서상 위치도 함께 밀리므로 사용자가 보던 내용이 움직인다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

출처와 검증 범위

아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.

  1. Virtualize large lists with react-windowweb.dev · 공식 문서 · 확인 2026-07-26
  2. CSSOM View Module Level 1CSS Working Group · 표준 명세 · 확인 2026-07-26
  3. Element.scrollTopMDN Web Docs · 공식 문서 · 확인 2026-07-26
  4. Intersection ObserverW3C Web Applications Working Group · 표준 명세 · 확인 2026-07-26
  5. Resize Observer Module Level 1CSS Working Group · 표준 명세 · 확인 2026-07-26
  6. CSS Transforms Module Level 1CSS Working Group · 표준 명세 · 확인 2026-07-26
  7. Rendering performanceweb.dev · 공식 문서 · 확인 2026-07-26
  8. CSS Scroll Anchoring Module Level 1CSS Working Group · 표준 명세 · 확인 2026-07-26
  9. overflow-anchorMDN Web Docs · 공식 문서 · 확인 2026-07-26
  10. Feed PatternW3C Web Accessibility Initiative · 공식 문서 · 확인 2026-07-26
이 문서의 마지막까지 읽었습니다.