대용량 트래픽 성능 최적화(3)-@tanstack/react-virtual의 가상화

hannah·2026년 2월 12일

TroubleShooting

목록 보기
8/9
post-thumbnail

지난 두 편의 글을 통해 Zustand의 useShallowReact의 메모이제이션(React.memo, useCallback, useMemo) 기법을 적용하여 불필요한 컴포넌트 리렌더링을 제어했다. 상태가 변경될 때마다 전체가 렌더링되던 문제는 해결했지만, 1,000개 이상의 카드를 렌더링하는 환경에서는 여전히 스크롤 지연 현상이 남아있었다.

리렌더링을 막았더라도 초기에 화면에 보이지 않는 1,000개의 카드 컴포넌트가 모두 DOM 트리에 마운트되어 브라우저가 처리해야 할 노드가 너무 많았기 때문이다. 이번 글에서는 이러한 물리적인 한계를 극복하기 위해 @tanstack/react-virtual을 도입하여 화면에 보이는 카드만 동적으로 렌더링하는 가상화 적용 과정을 정리한다.


1. 문제: DOM 노드 과부하로 인한 성능 저하

초기 칸반 보드의 카드 렌더링 로직은 일반적인 배열 매핑 방식이었다.

// 초기 구현
{cards.map((card) => (
  <SortableCard key={card.id} card={card} ... />
))}

보통 사용자의 화면에 한 번에 노출되는 카드는 5~10개 정도다. 하지만 위 방식대로라면 화면 밖에 있는 990개의 카드까지 모두 DOM에 그려진다. 결과적으로 수천 개의 DOM 노드가 한 번에 생성되며 메모리 사용량이 증가하고, 스크롤을 내릴 때마다 브라우저가 방대한 노드들의 위치를 재계산하느라 렌더링 지연이 발생하게 된다.

2. 해결책: 렌더링 가상화 도입

이 문제를 해결하기 위해 가상화를 도입했다. 가상화는 방대한 리스트 전체의 높이는 가상으로 유지하여 스크롤바의 동작은 정상적으로 보장하면서도 실제 DOM에는 현재 사용자의 시야에 들어온 항목들만 동적으로 렌더링하는 최적화 기법이다.

이를 직접 구현하기에는 스크롤 이벤트 최적화 등 고려할 사항이 많기 때문에 검증된 라이브러리인 @tanstack/react-virtualuseVirtualizer 훅을 활용하기로 했다.

3. useVirtualizer 적용 과정

가장 먼저 스크롤이 발생하는 컨테이너의 참조를 연결하고 가상화 환경을 설정했다.

기본 설정

// Column.tsx
import { useVirtualizer } from "@tanstack/react-virtual";

const CARD_ESTIMATE_HEIGHT = 100;
const CARD_GAP = 8;

const scrollRef = useRef<HTMLDivElement>(null);

const virtualizer = useVirtualizer({
  count: cards.length,
  getScrollElement: () => scrollRef.current,
  estimateSize: () => CARD_ESTIMATE_HEIGHT,
  overscan: 5,
  gap: CARD_GAP,
});

const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();

여기서 count는 전체 항목의 개수, estimateSize는 렌더링 전 카드의 예상 높이를 의미한다. overscan을 5로 설정하여 화면에 보이는 영역 위아래로 5개의 카드를 미리 렌더링해 두어 스크롤 시 빈 화면이 노출되는 깜빡임 현상을 방지했다.

렌더링 로직 교체

기존의 map 함수를 virtualItems.map으로 교체하고, 라이브러리가 계산해 준 위치값(virtualRow.start)을 사용해 카드를 절대 위치로 배치했다.

<div
  style={{
    height: totalSize,
    position: "relative",
    width: "100%",
  }}
>
  {virtualItems.map((virtualRow) => {
    const card = cards[virtualRow.index];
    if (!card) return null;
    return (
      <div
        key={card.id}
        data-index={virtualRow.index}
        ref={(el) => virtualizer.measureElement(el ?? null)}
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          width: "100%",
          transform: `translateY(${virtualRow.start}px)`,
        }}
      >
        <SortableCard card={card} ... />
      </div>
    );
  })}
</div>

measureElement를 ref로 넘겨주는 것이 매우 중요하다. 카드의 내용이 길어져 높이가 예상치와 달라지더라도, 실제 렌더링된 요소의 높이를 동적으로 측정하여 위치를 자동으로 보정해 준다. 또한 data-index 속성을 추가하여 디버깅 시 각 카드의 인덱스를 쉽게 확인할 수 있도록 했다.

4. DnD 기능과의 충돌 해결

가상화를 적용한 후, 드래그 앤 드롭으로 카드를 멀리 이동시킬 때 위치 계산이 정상적으로 이루어지지 않는 현상을 발견했다. 화면에서 벗어난 카드들이 DOM에서 제거되면서 DnD 라이브러리가 카드의 위치를 추적하지 못해 발생하는 문제였다.

이를 해결하기 위해 드래그 이벤트가 진행 중일 때만 조건부로 가상화를 해제하는 하이브리드 방식을 적용했다.

// Board.tsx
const FORCE_RENDER_ALL_THRESHOLD = 100;

<Column
  forceRenderAll={
    activeId != null &&
    activeCard?.columnId === column.id &&
    columnCards.length <= FORCE_RENDER_ALL_THRESHOLD
  }
  // ...
/>

드래그 중인 카드가 속한 컬럼이고, 해당 컬럼의 카드 개수가 100개 이하일 때만 forceRenderAlltrue로 넘겨 전체 카드를 렌더링하게 만들었다.

// Column.tsx
{forceRenderAll ? (
  // 드래그 중: 모든 카드 렌더링 (가상화 해제)
  <AnimatePresence mode="popLayout">
    <div className="flex flex-col gap-2">
      {cards.map((card) => (
        <SortableCard key={card.id} card={card} ... />
      ))}
    </div>
  </AnimatePresence>
) : (
  // 평상시: 가상화 적용
  <div style={{ height: totalSize, position: "relative" }}>
    {virtualItems.map(...)}
  </div>
)}

드래그 중일 때는 AnimatePresence를 함께 사용하여 카드 이동 시 자연스러운 애니메이션 효과를 제공하면서도, DnD 라이브러리가 모든 카드의 위치를 정확히 추적할 수 있도록 했다. 이 조치를 통해 대량의 카드가 있을 때의 스크롤 성능은 유지하면서, DnD 조작 시의 정확도까지 확보할 수 있었다.

5. 성능 개선 효과

가상화 적용 전후의 차이는 뚜렷했다.

가상화 적용 전

  • 가상화 적용 전 (Before):
    • 전체 카드: 1,000개
    • 전체 DOM 카드 노드: 1,000개 (모든 카드가 DOM에 렌더링됨)
    • 초기 렌더링 소요 시간: 약 500ms
    • 스크롤 프레임 레이트: 20~30fps (스크롤 끊김 발생)

가상화 적용 후

  • 가상화 적용 후 (After):
    • 전체 카드: 1,000개
    • 전체 DOM 카드 노드: 39개 (화면에 보이는 카드 + overscan만 렌더링)
    • 초기 렌더링 소요 시간: 약 50ms
    • 스크롤 프레임 레이트: 60fps (부드러운 스크롤 유지)

6. 주의사항 및 한계점

가상화가 모든 상황에 적합한 것은 아니다. 프로젝트에 적용하며 확인한 주의사항은 다음과 같다.

  1. 초기 렌더링 비용: 가상화 자체도 연산 비용이 존재한다. 항목이 50개 미만으로 적은 리스트에 적용하면 오히려 오버헤드가 발생할 수 있다.
  2. 동적 높이 측정의 중요성: 카드 높이가 고정되어 있지 않다면 반드시 measureElement를 활용해 실제 DOM 높이를 재측정해야 스크롤 위치가 어긋나지 않는다.
  3. 접근성(a11y) 고려: 화면에 보이지 않는 요소가 DOM에서 완전히 제거되므로 스크린 리더 등 보조 기기를 사용하는 환경에 영향을 줄 수 있다. 적절한 접근성 속성 부여가 필요하다.

지금까지 총 세 편의 글에 걸쳐 대용량 데이터를 다루는 칸반 보드의 성능 최적화 과정을 정리해 보았다. 이제 마무리로 한계점에서 언급한 접근성 고려를 어떻게 했는지까지 정리하며 대규모 트래픽 성능 최적화 글을 마치려고 한다.

0개의 댓글