리스트 가상화(Virtualization) - 성능 최적화를 위한 필수 기술 (많은 데이터 표현 시 최적화)

🎯 리스트 가상화란?

많은 데이터를 렌더링할 때 성능을 최적화하는 기법으로, 실제로 보이는 항목만 렌더링하여 불필요한 리소스 사용을 줄이는 방식이다.

수천, 수만 개의 리스트를 한꺼번에 렌더링하면 성능 문제가 발생하는데, 가상화를 적용하면 이를 해결할 수 있다.

🛠 구현 방식

리스트 가상화를 구현하는 방법에는 여러 가지가 있지만, 여기서는 두 가지 방식을 소개한다.

  1. absolute 포지셔닝 방식
    • 리스트 전체 높이를 유지하면서 보이는 아이템만 absolute 위치 조정하여 렌더링.
    • 빠른 성능을 보장하지만, 일부 스타일이 변경될 경우 위치 계산이 틀어질 가능성이 있음.
  2. 패딩(Padding) 방식
    • 위/아래에 div로 패딩을 추가하여 스크롤 위치를 조정.
    • 스타일 변경에 강하고, absolute 없이 구현 가능하여 유지보수성이 좋음.
 

📌 absolute 포지셔닝 방식 구현 예제

일단, absolute 방식을 먼저 구현해보자

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Virtualized List</title>
  <style>
    #container {
      height: 100vh;
      overflow-y: auto;
      position: relative;
      border: 1px solid #ddd;
    }
    #spacer {
      position: relative;
      width: 100%;
    }
    .item {
      position: absolute;
      height: 50px;
      width: 100%;
      border-bottom: 1px solid #ddd;
      display: flex;
      align-items: center;
      padding-left: 10px;
      background: white;
    }
  </style>
</head>
<body>
  <div id="container">
    <div id="spacer"></div>
  </div>
  <script>
    const container = document.getElementById("container");
    const spacer = document.getElementById("spacer");
    const itemHeight = 50;
    const bufferCount = 5;
    const totalItems = 10000;
    const visibleCount = Math.ceil(window.innerHeight / itemHeight) + bufferCount * 2;

    spacer.style.height = `${totalItems * itemHeight}px`;

    let startIdx = 0;
    
    function renderItems() {
      const scrollTop = container.scrollTop;
      startIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferCount);
      const endIdx = Math.min(totalItems, startIdx + visibleCount);

      container.innerHTML = "";
      container.appendChild(spacer);

      for (let i = startIdx; i < endIdx; i++) {
        const item = document.createElement("div");
        item.className = "item";
        item.textContent = `Item ${i + 1}`;
        itehttp://m.style.top = `${i * itemHeight}px`;
        container.appendChild(item);
      }
    }

    container.addEventListener("scroll", renderItems);
    renderItems();
  </script>
</body>
</html>


📌 설명

spacer(div)를 전체 리스트 크기에 맞춰 설정해 스크롤바를 유지할 수 있다.
scroll 이벤트가 발생하면 innerHTML을 갱신해 필요한 요소만 DOM에 추가한다.
absolute로 아이템 위치를 조정해 원래 리스트처럼 보이게 한.

 

이 absolute 코드를 React로 바꿔보면 다음과 같이 구현할 수 있다.

import { useEffect, useRef, useState } from 'react';

const itemHeight = 50; // 각 아이템 높이
const bufferCount = 5; // 추가적으로 렌더링할 여유 아이템 수

const VirtualizedList = ({ items }: { items: string[] }) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [startIdx, setStartIdx] = useState(0);
  const visibleCount = Math.ceil(window.innerHeight / itemHeight) + bufferCount * 2;

  const handleScroll = () => {
    if (!containerRef.current) return;
    const scrollTop = containerRef.current.scrollTop;
    const newStartIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferCount);
    setStartIdx(newStartIdx);
  };

  useEffect(() => {
    const container = containerRef.current;
    if (container) {
      container.addEventListener('scroll', handleScroll);
    }
    return () => {
      if (container) container.removeEventListener('scroll', handleScroll);
    };
  }, []);

  const endIdx = Math.min(items.length, startIdx + visibleCount);
  const visibleItems = items.slice(startIdx, endIdx);

  return (
    <div
      ref={containerRef}
      style={{
        height: '100vh',
        overflowY: 'auto',
        position: 'relative',
      }}
    >
      <div style={{ height: items.length * itemHeight, position: 'relative' }}>
        {visibleItems.map((item, index) => (
          <div
            key={startIdx + index}
            style={{
              position: 'absolute',
              top: (startIdx + index) * itemHeight,
              height: itemHeight,
              width: '100%',
              borderBottom: '1px solid #ddd',
              display: 'flex',
              alignItems: 'center',
              paddingLeft: '10px',
            }}
          >
            {item}
          </div>
        ))}
      </div>
    </div>
  );
};

export default function App() {
  const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
  return <VirtualizedList items={items} />;
}

 

📌 설명

전체 리스트 높이(height: items.length * itemHeight)를 유지 한다.
스크롤 위치에 따라 현재 필요한 데이터만 slice로 잘라서 렌더링한다
absolute 위치를 이용해 아이템이 원래 위치에 있는 것처럼 보이게 처리해준다.

 

📌 패딩(Padding) 방식 구현 예제

 

이제 padding 방식으로 다시 구현해보자.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Virtualized List</title>
  <style>
    #container {
      height: 100vh;
      overflow-y: auto;
      border: 1px solid #ddd;
      position: relative;
    }
    .item {
      height: 50px;
      border-bottom: 1px solid #ddd;
      display: flex;
      align-items: center;
      padding-left: 10px;
      background: white;
    }
  </style>
</head>
<body>
  <div id="container">
    <div id="padding-top"></div>
    <div id="list"></div>
    <div id="padding-bottom"></div>
  </div>
  <script>
    const container = document.getElementById("container");
    const list = document.getElementById("list");
    const paddingTop = document.getElementById("padding-top");
    const paddingBottom = document.getElementById("padding-bottom");

    const itemHeight = 50;
    const bufferCount = 5;
    const totalItems = 10000;
    const visibleCount = Math.ceil(window.innerHeight / itemHeight) + bufferCount * 2;

    let startIdx = 0;

    function renderItems() {
      const scrollTop = container.scrollTop;
      startIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferCount);
      const endIdx = Math.min(totalItems, startIdx + visibleCount);

      paddingTop.style.height = `${startIdx * itemHeight}px`;
      paddingBottom.style.height = `${(totalItems - endIdx) * itemHeight}px`;

      list.innerHTML = "";
      for (let i = startIdx; i < endIdx; i++) {
        const item = document.createElement("div");
        item.className = "item";
        item.textContent = `Item ${i + 1}`;
        list.appendChild(item);
      }
    }

    container.addEventListener("scroll", renderItems);
    renderItems();
  </script>
</body>
</html>

 

📌 설명

 

 

스크롤 위치에 따라 화면에 보여줄 아이템만 동적으로 생성하고, 나머지는 padding으로 공간만 확보할 수 있다.

scroll 이벤트가 발생할 때마다 renderItems() 함수가 호출되어 보여줄 범위를 갱신해준다.

 

 

이제, React로 구현해보자.

import { useEffect, useRef, useState } from 'react';

const itemHeight = 50;
const bufferCount = 5;

const VirtualizedList = ({ items }: { items: string[] }) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [startIdx, setStartIdx] = useState(0);
  const visibleCount = Math.ceil(window.innerHeight / itemHeight) + bufferCount * 2;

  const handleScroll = () => {
    if (!containerRef.current) return;
    const scrollTop = containerRef.current.scrollTop;
    const newStartIdx = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferCount);
    setStartIdx(newStartIdx);
  };

  useEffect(() => {
    const container = containerRef.current;
    if (container) {
      container.addEventListener('scroll', handleScroll);
    }
    return () => {
      if (container) container.removeEventListener('scroll', handleScroll);
    };
  }, []);

  const endIdx = Math.min(items.length, startIdx + visibleCount);
  const visibleItems = items.slice(startIdx, endIdx);

  return (
    <div
      ref={containerRef}
      style={{
        height: '100vh',
        overflowY: 'auto',
      }}
    >
      <div style={{ height: startIdx * itemHeight }} />
      {visibleItems.map((item, index) => (
        <div
          key={startIdx + index}
          style={{
            height: itemHeight,
            borderBottom: '1px solid #ddd',
            display: 'flex',
            alignItems: 'center',
            paddingLeft: '10px',
          }}
        >
          {item}
        </div>
      ))}
      <div style={{ height: (items.length - endIdx) * itemHeight }} />
    </div>
  );
};

export default function App() {
  const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
  return <VirtualizedList items={items} />;
}
📌 설명

상단과 하단에 빈 div로 공간을 확보해 전체 리스트처럼 보이게 하며, 실제 DOM에 그려지는 항목 수를 최소화한다.

scroll 이벤트를 감지해 startIdx를 갱신하고, 그에 따라 items.slice()로 보여줄 항목 범위를 조절한다.

 

🚀 요약하자면....

리스트 가상화를 적용하면 다음과 같은 장점이 있다.

 

렌더링 최적화 - 보이는 아이템만 렌더링하여 DOM 부하를 줄임

빠른 스크롤 성능 - 수천 개의 아이템을 다뤄도 성능 저하가 없음

유지보수 용이 - absolute 방식과 padding 방식 중 선택하여 적용 가능

 

패딩 방식은 스타일 변경에도 강하고, absolute 방식보다 자연스럽게 동작하기 때문에 유연한 UI를 만들 때 유리하다.

 

많은 데이터를 리스트로 표현할 때 필연적으로 최적화를 고민할 수 있는데, 라이브러리를 사용할 수도 있지만 때로는 직접 코드를 작성해 사용하는 것도 좋은 방법이 될 수 있다 🌽

 

+ 라이브러리로는 TanStack Virtual, react-window, react-virtualized 등을 많이 사용한다 🥙

 

🤗 실제 작성한 코드

https://github.com/citron03/practice-next-15/commit/1a955ec260c475523a5acf3cfc19fd507a755249

 

feat(list): make virtual list two version (padding, absolute) · citron03/practice-next-15@1a955ec

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

github.com