고정 높이의 가상 스크롤 구현하기

2026. 6. 23. 22:20·JavaScript

Intro

최근 서비스에 무한스크롤을 통해 리스트를 무한적으로 보여주는 기술을 많이 사용하게 되었습니다. 이렇듯 무한 스크롤로 목록을 계속 불러오다보면, 10,000개까지 데이터가 쌓이는 경우가 발생하게 됩니다. 

실제로 리스트에 10,000개를 그대로 넣어봤더니 아래 영상처럼 2번 Fixed height 에서 1번 Naive 화면이동이 느려지고 상호작용할때는 전반적으로 끊김이 발생했습니다.

10,000개를 모두 브라우저 DOM에 띄워두었기 때문입니다. 브라우저가 아이템을 모두 가지고 있어야하기 때문에 무거워집니다.

 

수치상으로도 확인해볼까요?

 

순수하게 DOM에 10,000개를 모두 렌더링 했을때와 가상 스크롤을 적용했을때를 비교해보았습니다.

첫번째 화면(Naive rendering)은 영상에서 1번 Naive 공간인 DOM에 10,000개를 모두 렌더링한 상태이고, 두번째 화면(react window)는 2번인 Fixed height 공간인 가상스크롤을 적용한 상태입니다.

 

JS heap size와 DOM node 수를 비교해보면, 10,000개를 모두 DOM에 그린 페이지는 메모리와 노드 수를 엄청나게 차지하는 반면, react-window 페이지는 DOM에 올라와 있는 노드가 현저히 적었습니다.

 

이렇듯, 아이템이 한꺼번에 많이 들어오는 최악의 상황을 어떻게 대비할 수 있을까요?

우리가 보고 있지 않는 아이템이 함께 dom에 그려져있는 것은 불필요하다고 생각합니다.

현재 보고 있는 화면 안에서, 필요한 만큼만 아이템을 렌더링하면 어떨까요?

바로 이 방법을 사용한 기술이 가상 스크롤 입니다

 

가상 스크롤이란?

가상 스크롤은 보이는 부분만 렌더링하는, 즉 윈도잉(windowing) 기법을 활용한 기술입니다.

윈도잉(windowing)이란?
전체 데이터 집합을 한꺼번에 처리하지 않고, 현재 관심 있는 일부 구간(window)만 선택해 다루는 기법입니다.

실제 창문을 봐도 바깥의 모든 배경이 보이는 게 아니라, 창틀에 걸친 구간만 보입니다.
이렇듯 불필요한 부분은 가리고 필요한 부분만 보이도록 하는 동작을 이용한 기술입니다.

처음 렌더링되는 요소의 수는 전체의 아주 작은 일부분이며, 이 렌더링 구간을 이동시키면서 보이지 않게 된 구간은 새로 보이는 구간과 교체·재활용합니다.

 

리스트에 윈도잉 기법을 활용하면 가상 스크롤을 구현할 수 있습니다.

우리가 보고 있는 화면이 창문이라면, 보이지 않는 부분은 가리고 보이는 부분만 창문을 통해 보이도록 구현하는 것입니다.

 

가상 스크롤 API

react-window의 FixedSizeList는 height, width, itemSize, itemCount 네 가지 props를 받습니다.

import React from 'react';
import { FixedSizeList } from 'react-window';

const items = Array.from({ length: 10000 }, (_, i) => `Item ${i}`);

// 각 행을 그리는 컴포넌트
const Row = ({ index, style }) => (
  <article style={style}>
    {items[index]}
  </article>
);

const ListComponent = () => (
  <FixedSizeList
    height={500}
    width={500}
    itemSize={120}
    itemCount={items.length}
  >
    {Row}
  </FixedSizeList>
);

export default ListComponent;

 

  • height : 보이는 영역(viewport)의 높이 (px)
  • width : 너비 (px)
  • itemSize : 각 행의 고정 높이 (px)
  • itemCount : 전체 아이템 개수
    • 추가로 itemData로 item 전체 데이터를 넘겨주거나, overscanCount로 앞뒤에 여분 렌더링을 둘 수 있습니다.

여기서 눈 여겨볼 부분은 Row 함수입니다. Row 함수는 안에 들어올 각 행의 컴포넌트를 그리는 함수입니다.

Row에 넘어오는 style은 react-window가 각 행에 position: absolute와 top 값을 계산해서 인라인으로 넣어줍니다.

실제로 렌더링된 결과를 보면 각 행에 position과 top이 계산되어 적용된 것을 확인할 수 있습니다.

 

 

React-window FixedSizeList의 동작과정

1단계 스크롤 이벤트 받기

 

FixedSizeList는 내부에 스크롤 가능한 컨테이너(scroll-viewport)를 만들고 onScroll을 붙입니다.

브라우저는 스크롤이 발생하면 맨 위에서 얼마나 내려갔는지(scrollTop)를 알려줍니다.

onScroll에 scrollTop을 알 수 있는 함수를 넘겨줍니다.

 

2단계 창문의 가장 첫 아이템 인덱스 구하기

 

화면이 위에서 1000px 내려갔고 아이템 높이가 72px로 일정하다면, 1000 ÷ 72로 13번 행부터 보일 것입니다.

visibleStartIndex = Math.floor(scrollTop / itemSize)
                  = Math.floor(1000 / 72)
                  = Math.floor(13.88...)
                  = 13

 

3단계 창문의 마지막 아이템 인덱스 구하기

 

마지막으로 보이는 아이템은 단순히 첫 인덱스 + 보이는 개수로만 계산하면 부족할 수 있습니다.

스크롤은 항상 아이템의 시작 지점에서 멈추는 게 아니라 아이템의 중간에서 멈출 수 있기 때문입니다.

예를 들어 scrollTop이 어떤 아이템의 중간을 가리키고 있다면, 화면 아래쪽에 예상보다 한 개 더 걸칠 수 있습니다.

그래서 실제 구현에서는 뷰포트의 끝 위치(viewportHeight)를 기준으로 마지막 인덱스를 계산합니다.

visibleStopIndex = Math.ceil((scrollTop + viewportHeight) / itemSize)
scrollTop      = 970
viewportHeight = 560
itemSize       = 72

visibleStopIndex = Math.ceil((970 + 560) / 72)
                 = Math.ceil(21.25)
                 = 22

 

정리

  • 화면의 시작 위치는 scrollTop / itemSize로 구하고,
  • 화면의 끝 위치는 (scrollTop + viewportHeight) / itemSize로 구한다.
4단계 행의 위치(top) 계산

 

보이는 인덱스 범위를 알았으니, 각 행에 입힐 top을 계산합니다.

고정 높이에서는 인덱스에 아이템 높이를 곱하기만 하면 해당 행의 절대 위치가 바로 나옵니다.

15번 행의 top = index × itemSize = 15 × 72 = 1080px
→ style = { position: "absolute", top: 1080, height: 72, ... }

 

이 style을 각 Row에 인라인으로 붙입니다.

모든 행 높이가 같다는 전제 덕분에 곱셈 한 번이면 위치가 나온다는 점을 이용한 것이 포인트입니다.

 

고정 높이 가상 스크롤 직접 구현해보기

가상 스크롤에 필요한 값은 itemCount, itemSize, height, width 네 가지입니다.

앞에서 부모 컴포넌트 역할을 했던 FixedSizeList와 Row를 직접 만들어봅니다.

// 정적인 높이 리스트
export function List() {
  return (
    <section className="demo-panel">
      <FixedSizedList
        className="scroll-viewport"
        height={VIEWPORT_HEIGHT}
        width="100%"
        itemCount={items.length}
        itemSize={ITEM_SIZE}
        itemData={items}
        overscanCount={5}
      >
        {Row}
      </FixedSizedList>
    </section>
  );
}

 

FixedSizedList는 최상위 컴포넌트이므로 scroll-viewport 스타일을 적용할 수 있도록 className과 item에 대한 값들을 props로 받도록하고, Array.from으로 실제 Row 행들을 만들 것 입니다.

 

Row는 각 행을 의미하는 부분이기 때문에, Index를 각 행에 반영이 필요하고, top의 값은 위에서 언급했던 것처럼 itemIndex는 오프셋을 더해서 구합니다.  handleScroll은 scorllTop을 구하기 위한 함수로 사용될 예정입니다.

 

visibleOffset을 구하기 위해 2단계 3단계에서 본 계산으로 보이는 인덱스 범위(visibleStartIndex, visibleStopIndex)를 구해야합니다.

인덱스가 범위를 벗어나는 경우를 막기 위해 max/min으로 가드를 두었습니다.

renderIndex는, 추후에 보이는 아이템 외에 앞뒤로 미리 렌더링할 아이템(overScan)을 추가한 실제 렌더 범위를 계산합니다.

 

 

renderStartIndex부터 renderEndIndex까지의 행만 렌더링하기 때문에, 그릴 개수는 renderEndIndex - renderStartIndex이고,

각 행의 실제 인덱스는 renderStartIndex + offset입니다.

각 행의 top은 4단계의 계산처럼 인덱스에 아이템 높이를 곱해서 계산할 수 있도록 합니다. 

더보기

오프셋이란, 렌더 범위 안에서의 순번을 뜻합니다.

visibleOffset은 보이는 아이템(visible) 중에서 가리킬 아이템 바로 이전까지의 인덱스(Offset)를 말합니다.

  const [scrollTop, setScrollTop] = useState(0);
  const visibleStartIndex = Math.max(0, Math.floor(scrollTop / itemSize));
  const visibleStopIndex = Math.min(
    itemCount - 1,
    Math.ceil((scrollTop + viewportHeight) / itemSize) - 1
  );
  const renderStartIndex = Math.max(0, visibleStartIndex - overscan);
  const renderEndIndex = Math.min(itemCount, visibleStopIndex + 1 + overscan);

  const handleScroll = (event: UIEvent<HTMLDivElement>) => {
    setScrollTop(event.currentTarget.scrollTop);
  };

 

가상 스크롤에서 scrolltop은 보여지는 창문(범위)계산하는 기준이기 때문이므로 state로 관리할 수 있어야하지만, 스크롤 이벤트는 유저가 가장 많이 실행하는 이벤트입니다.

스크롤 이벤트가 고빈도로 발생하기때문에 매 이벤트마다 setState를 호출하게 되면, 렌더링이 과도해질수있습니다.

이 부분의 문제 개선은 호출빈도를 줄일수있도록 쓰로틀링을 사용하는 방식을 이용해볼수있습니다.

예를 들어 requestAnimationFrame을 이요하면 한 프레임안에 한번만 state를 갱신하도록 묶을 수 있습니다.

export function FixedSizedList({
  className,
  height,
  width,
  itemCount,
  itemSize,
  itemData,
  overscanCount,
}: FixedSizedListProps) {
  return (
    <div
      className={className}
      style={{ height, width }}
      onScroll={handleScroll}
    >
      <div className="virtual-space">
        {Array.from(
          { length: renderEndIndex - renderStartIndex },
          (_, offset) => {
            const itemIndex = renderStartIndex + offset;

            return (
              <RowComponent
                key={itemData[itemIndex].id}
                index={itemIndex}
                data={itemData}
                style={{
                  position: "absolute",
                  top: itemIndex * itemSize,
                  left: 0,
                  right: 0,
                  height: itemSize,
                }}
              />
            );
          }
        )}
      </div>
    </div>
  );
}

 

왜 style에 absolute와 top으로 하는걸까?

가상 스크롤은 전체 1만 개 중 보이는 15개 정도만 DOM에 그립니다. 예를 들어 13번 아이템부터 그려야 한다면, 1~12번 아이템은 DOM에 존재하지 않습니다.

absolute는 상위 요소를 기준으로 dom에 절대좌표 (top)에 배치합니다. 덕분에 dom 순서와 화면상의 위치를 분리할 수 있습니다.

dom에선 첫 자식이지만 화면에서는 900xp지점 배치가 viewport 밖의 좌표라도 상관없이 둘 수 있습니다.

 

이 absolute의 이 특성을 윈도잉에서 활용합니다.

 

마치 창문 처럼 렌더링 전체 캔버스에서 절대적인 top 위치에 아이템을 그립니다.

 

어떤 아이템의 top의 위치가 100인데 scorlltop이 120으로 그 아이템은 화면 위쪽으로 20px 밀려나 viewport에서 벗어난 상태입니다.

이 아이템은 어차피 우리 눈에 보이지 않기 때문에 윈도잉 기법대로 굳이 그리지 않아도 됩니다.

아이템 위치가 viewport 범위 안에 들어온 경우에만 재빠르게 그리면 됩니다.

 

아래 영상은 마우스의 위치를 스크롤로 제어중이라고 가정했을 때, 실제 가상 스크롤에서 absolute top방식이 영향을 끼치는 것을 눈으로 확인할 수 있는 영상입니다. 만약 저 격자를 전체 viewoport라고 했을때, 스크롤이 된다고 느껴지게끔 하는 것입니다.

absolute 말고 다른 방법은 없을까요?
top을 바꾸면 브라우저는 해당 요소의 위치를 레이아웃 단계에서 다시 계산합니다.
absolute 요소라 다른 형제 요소까지 밀리는 전체 리플로우를 일으키지 않지만, 레이아웃단계에 관여하게 됩니다.
레이아웃, 페인팅 단계를 최적화를 위해 transform : translateY(top)을 사용해볼수있습니다.

transform은 레이아웃과 페인트 단계를 건너뛰고 마지막 합성(composite) 단계에서만 처리됩니다.
또한 transform이 걸린 요소는 별도 레이어로 승격되기 쉽고, 그 레이어의 합성은 CPU가 아닌 GPU가 담당할 수 있어 성능에 유리합니다.

실제로 TanStack Virtual 공식 문서의 기본 예제도 보이는 아이템을
position: absolute로 두고 transform: translateY(virtualItem.start)로 위치를 잡습니다.

핵심은, absolute + top이든 absolute + transform이든 근본 동작 원리는 같다는 점입니다.
둘 다 요소를 좌표로 배치하는 것이고, transform은 그 중 더 저렴한 단계에서 좌표를 적용하는 최적화일 뿐입니다.

고정 높이의 한계 

고정 높이의 계산은 간단한 수학 연산으로 가능할 정도로 단순했습니다.

startIndex = Math.floor(scrollTop / itemSize)   // 스크롤 위치 → index
top        = index × itemSize                    // index → 위치
전체 높이   = itemCount × itemSize                // 스크롤바 높이

 

 

가능했던 이유는 모든 행의 itemSize가 동일하다는 전제가 있었기 때문입니다.

하지만 요즘 리스트는 아이템 높이를 고정으로 둘 수 없는 경우가 많습니다. 아이템 높이를 고정이 아니라면 어떤 문제가 발생할까요?

 

1. 특정 행의 위치(top)를 즉시 알 수 없다.

7번 행의 위치 (top)를 구하려면 기존방식으로는 7*indexSize(높이)를 계산하면 충분했었습니다.

하지만 높이가 다 다르면, 0 ~ 6번 행의 실제 높이를 전부 알아야 합니다.

 

2. 스크롤 위치로 시작 인덱스를 즉시 알 수 없다.

아이템 사이즈가 일관되지 않으면 scrollTop/itemSize로 startindex를 구할 수 없습니다.

높이를 하나씩 누적해가면서 scrollTop을 넘는 지점을 찾아야하며, 선형탐색이 필요해집니다.

1만개의 아이템이라면 최악의 경우 1만번 순회하게 됩니다.

sum = 0
for (i = 0; i < itemCount; i++) {
  sum += h(i)
  if (sum > scrollTop) { 
  		startIndex = i; 
        break 
  }
}

 

3. 전체 높이(스크롤바)를 미리 알 수 없다.

itemCount * itemSize로 구했던 전체 높이도, itemSize가 일관되지 않게 되면, 모든 행의 높이를 알아야합니다.

 

가장 큰 문제는 그리기 전엔 높이를 알 수 없다는 점입니다.

 

높이를 안다면 누적합으로 가능합니다.

하지만 동적 높이는 각 행의 실제 높이를 DOM에 그려보기 전까진 알 수 없습니다.

내용이 길어지면 줄바꿈이 되어 길어지고, 이미지가 늦게 로드되면 처음의 아이템 높이에서 변경됩니다.

 

이것이 고정 높이 문제입니다.

 

고정 높이 가상 스크롤의 개선 방법

문제의 개선 방법은 크게 두 가지 방법이 있습니다.

 

1. 높이를 미리 알려주기

개발자가 itemSize를 index 높이들을 모두 제공해줍니다.

주어진 높이들을 통해 계산하여 동적 가상 스크롤 구현합니다.

 

2. 추정 후 측정 및 보정

앞에서 문제였던 부분은 그리기 전엔 높이를 알 수 없다는 점이었습니다.

그렇다면 그리고 난 후 실제 높이를 측정해볼 수 있을까요?

 

 

정리

  • 가상 스크롤은 윈도잉을 UI에 적용해, 보이는 것만 그리고 전체 높이는 빈 공간으로 유지하는 기법이다.
  • 고정 높이에서는 scrollTop ÷ itemSize, index × itemSize처럼 위치 계산이 곱셈·나눗셈 한 번으로 끝나 빠르다.
  • viewport에 보이는 행은 position : absolute로 배치해 DOM 순서와 화면 위치를 분리하고, transform을 쓰면 한단계 더 최적화 할수있다.

Reference

  • https://d2.naver.com/helloworld/1450243
  • https://web.dev/articles/virtualize-long-lists-react-window?hl=ko
  • https://tanstack.com/virtual/latest/docs/introduction

'JavaScript' 카테고리의 다른 글

JSX란 무엇이고, 화면은 언제 그려질까?  (0) 2025.11.29
렉시컬 환경과 클로저  (0) 2025.11.28
실행 컨텍스트로 이해하는 호이스팅과 TDZ  (0) 2025.11.26
'JavaScript' 카테고리의 다른 글
  • JSX란 무엇이고, 화면은 언제 그려질까?
  • 렉시컬 환경과 클로저
  • 실행 컨텍스트로 이해하는 호이스팅과 TDZ
happy7yong
happy7yong
뭇별에 닿기를
  • happy7yong
    yongyong
    happy7yong
  • 전체
    오늘
    어제
    • 분류 전체보기 (8)
      • JavaScript (4)
      • Coding Test (0)
        • JavaScript (0)
      • Trouble Shooting (2)
        • JAVA (2)
      • 회고 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
happy7yong
고정 높이의 가상 스크롤 구현하기
상단으로

티스토리툴바