가상스크롤링에대해서

한상우·2025년 6월 11일

리액트

목록 보기
24/24
post-thumbnail

React 가상 스크롤링으로 목차 성능 최적화하기

📚 목차

🚀 들어가며

EPUB 리더를 개발하면서 마주한 문제가 있었습니다. 수백 개의 목차 항목이 있는 책에서 스크롤이 버벅거리고 메모리 사용량이 급증하는 현상이었죠. 이 문제를 해결하기 위해 가상 스크롤링(Virtual Scrolling)을 도입한 과정과 성능 개선 결과를 공유해보겠습니다.

🔍 문제 상황

기존 코드의 한계

// ❌ 기존: 모든 목차 항목을 DOM에 렌더링
function SimpleToc({ toc }: { toc: NavItem[] }) {
  const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());

  const renderTocItems = (items: NavItem[], level = 0): JSX.Element[] => {
    return items.map((item, index) => (
      <div key={item.href} style={{ paddingLeft: `${level * 20}px` }}>
        <div className="toc-item" onClick={() => navigate(item.href)}>
          {item.hasChildren && (
            <button onClick={() => toggleExpand(index)}>
              {expandedItems.has(index) ? '▼' : '▶'}
            </button>
          )}
          <span>{item.label}</span>
        </div>
        
        {/* 🔥 문제: 모든 자식 요소를 재귀적으로 렌더링 */}
        {item.hasChildren && expandedItems.has(index) && (
          <div>
            {renderTocItems(item.subitems || [], level + 1)}
          </div>
        )}
      </div>
    ));
  };

  return (
    <div className="toc-container" style={{ height: '400px', overflow: 'auto' }}>
      {renderTocItems(toc)}
    </div>
  );
}

성능 문제점

  1. DOM 오버헤드: 500개 목차 → 500개 DOM 노드 생성
  2. 메모리 증가: 모든 요소가 메모리에 상주
  3. 렌더링 지연: 초기 로딩 시 모든 요소를 한 번에 렌더링
  4. 스크롤 성능 저하: 브라우저가 수백 개의 요소를 계속 추적

💡 가상 스크롤링 원리

💡 가상 스크롤링이란?

가상 스크롤링(Virtual Scrolling)은 대용량 리스트의 성능 문제를 해결하는 핵심 기법입니다.

🎯 핵심 개념

일반 스크롤링에서는 1000개의 목차가 있으면 1000개의 DOM 요소를 모두 생성하고 메모리에 유지합니다. 이는 다음과 같은 문제를 야기합니다:

📱 화면 (400px 높이)
┌─────────────────┐
│ ✅ Item 1       │ ← DOM 생성됨
│ ✅ Item 2       │ ← DOM 생성됨  
│ ✅ Item 3       │ ← DOM 생성됨
│ ✅ Item 4       │ ← DOM 생성됨
└─────────────────┘
  ✅ Item 5         ← 안 보이지만 DOM 생성됨
  ✅ Item 6         ← 안 보이지만 DOM 생성됨
  ✅ Item 7         ← 안 보이지만 DOM 생성됨
  ✅ ...            ← 996개 더 생성됨!
  ✅ Item 1000      ← 메모리 낭비!

반면 가상 스크롤링에서는 화면에 보이는 영역 + 버퍼만큼의 DOM 요소만 생성합니다:

📱 화면 영역 (400px)
┌─────────────────┐
│ ✅ Item 15      │ ← 실제 DOM 렌더링
│ ✅ Item 16      │ ← 실제 DOM 렌더링  
│ ✅ Item 17      │ ← 실제 DOM 렌더링
│ ✅ Item 18      │ ← 실제 DOM 렌더링
└─────────────────┘

⬆️ 가상 영역 (560px) - DOM 없음, 높이만 계산
⬇️ 가상 영역 (3440px) - DOM 없음, 높이만 계산

총 높이: 4000px (1000개 × 40px)
실제 렌더링: 4개 요소만!
메모리 사용량: 99.6% 절약!

🔧 동작 원리

  1. 전체 데이터 분석: 1000개 항목 × 40px = 4000px 총 높이
  2. 보이는 영역 계산: 화면 높이 400px ÷ 40px = 10개 항목
  3. 버퍼 추가: 10개 + 4개 버퍼 = 14개만 실제 렌더링
  4. 스크롤 감지: 스크롤 위치에 따라 렌더링할 항목 동적 변경
  5. 위치 조정: transform: translateY()로 올바른 위치에 배치

🎨 시각적 비교

🔴 일반 스크롤링:

메모리: [DOM][DOM][DOM]...[DOM] (1000개)
렌더링: 전체 한 번에 처리 → 느림
스크롤: 모든 요소 추적 → 버벅거림

🟢 가상 스크롤링:

메모리: [DOM][DOM][DOM][DOM] (10개만)
렌더링: 필요한 부분만 → 빠름  
스크롤: 계산된 위치로 이동 → 부드러움

이제 구체적인 구현 방법을 살펴보겠습니다!

🛠️ 가상 스크롤 구현하기

1단계: 데이터 평면화

// 트리 구조를 평면 배열로 변환
const flattenToc = useMemo(() => {
  const flattenItems = (
    items: NavItem[], 
    level = 0, 
    parentNumbering = ""
  ): FlattenedTocItem[] => {
    const result: FlattenedTocItem[] = [];

    items.forEach((item, index) => {
      const numbering = parentNumbering 
        ? `${parentNumbering}.${index + 1}` 
        : `${index + 1}`;

      const flatItem: FlattenedTocItem = {
        ...item,
        level,
        index: result.length,
        hasChildren: Boolean(item.subitems?.length),
        numbering,
        isExpanded: expandedItems.has(result.length),
      };

      result.push(flatItem);

      // ✅ 확장된 경우에만 자식 추가
      if (flatItem.hasChildren && flatItem.isExpanded) {
        const children = flattenItems(
          item.subitems!, 
          level + 1, 
          numbering
        );
        result.push(...children);
      }
    });

    return result;
  };

  return flattenItems(toc);
}, [toc, expandedItems]);

2단계: 가상 스크롤 계산

const virtualItems = useMemo(() => {
  const itemHeight = 40;
  const containerHeight = 400;
  
  // 화면에 보이는 아이템 수 계산 (+ 버퍼)
  const visibleItemCount = Math.ceil(containerHeight / itemHeight) + 2;
  
  // 현재 스크롤 위치에서 시작 인덱스 계산
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(startIndex + visibleItemCount, flattenToc.length);

  return {
    items: flattenToc.slice(startIndex, endIndex), // 실제 렌더링할 데이터
    startIndex,
    endIndex,
    totalHeight: flattenToc.length * itemHeight,   // 전체 스크롤 높이
    offsetY: startIndex * itemHeight,              // 상단 오프셋
  };
}, [flattenToc, scrollTop, itemHeight, containerHeight]);

3단계: 가상 컨테이너 렌더링

function VirtualizedToc() {
  const scrollContainerRef = useRef<HTMLDivElement>(null);

  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    const target = e.target as HTMLDivElement;
    setScrollTop(target.scrollTop); // 스크롤 위치 업데이트
  }, []);

  return (
    <div className={className}>
      <div
        ref={scrollContainerRef}
        className="relative overflow-auto"
        style={{ height: finalHeight }}
        onScroll={handleScroll}
      >
        {/* 🎯 전체 높이를 가진 가상 컨테이너 */}
        <div style={{ height: virtualItems.totalHeight, position: "relative" }}>
          {/* 🎯 보이는 영역만 실제 렌더링 */}
          <div
            style={{
              transform: `translateY(${virtualItems.offsetY}px)`,
              position: "absolute",
              top: 0,
              left: 0,
              right: 0,
            }}
          >
            {virtualItems.items.map((item) => (
              <VirtualizedTocItem
                key={`${item.href}-${item.index}`}
                item={item}
                onNavigate={handleNavClick}
                onToggleExpand={toggleExpand}
                isActive={currentSection?.href === item.href}
                style={{ height: itemHeight }}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

📊 성능 비교 결과

실제 Chrome DevTools Performance 탭에서 측정한 결과입니다.

렌더링 성능 (Chrome DevTools 측정)

🔴 변경 전 (모든 목차 렌더링)

  • 렌더링: 714ms
  • 페인팅: 590ms
  • 총 합계: 14,076ms

🟢 변경 후 (가상 스크롤)

  • 렌더링: 549ms (23%↓)
  • 페인팅: 373ms (37%↓)
  • 총 합계: 13,321ms (5%↓)

핵심 성능 지표

지표변경 전변경 후변화
총 실행 시간14,076ms13,321ms5%↓
렌더링 시간714ms549ms23%↓
페인팅 시간590ms373ms37%↓
DOM 노드 수500+10~15개97%↓

🎯 실제 성능 개선 포인트

가상 스크롤링의 진짜 장점은 런타임 성능입니다:

  1. 스크롤 반응성: 무거운 DOM 조작 없이 translateY만 변경
  2. 메모리 효율성: 화면에 보이는 요소만 유지
  3. 장기 사용 안정성: 시간이 지나도 성능 저하 없음
  4. 대용량 데이터 처리: 1만 개 항목도 동일한 성능

🔍 측정 결과 분석

초기 측정에서는 스크립팅 시간이 증가했는데, 이는 다음 이유 때문입니다:

  • 가상화 로직: 초기 계산과 설정 비용
  • DevTools 오버헤드: 측정 환경의 영향
  • 메모이제이션 준비: 최적화를 위한 초기 투자

하지만 실제 사용자 경험에서는 다음과 같은 개선을 확인할 수 있습니다:

  • 스크롤 부드러움: 끊김 현상 완전 해결
  • 반응성 향상: 클릭/터치 즉시 반응
  • 메모리 안정성: 장시간 사용해도 브라우저 안정
  • 배터리 효율: 모바일에서 발열 및 배터리 소모 감소

🔧 실제 구현에서의 고려사항

1. 동적 높이 처리

// 각 아이템의 높이가 다를 수 있는 경우
const [itemHeights, setItemHeights] = useState<Map<number, number>>(new Map());

const measureItemHeight = useCallback((index: number, height: number) => {
  setItemHeights(prev => new Map(prev).set(index, height));
}, []);

// 누적 높이 계산
const cumulativeHeights = useMemo(() => {
  const heights: number[] = [];
  let totalHeight = 0;
  
  flattenToc.forEach((_, index) => {
    heights[index] = totalHeight;
    totalHeight += itemHeights.get(index) || defaultItemHeight;
  });
  
  return heights;
}, [flattenToc, itemHeights]);

2. 스크롤 위치 복원

// 현재 활성 섹션으로 자동 스크롤
useEffect(() => {
  if (currentSection && scrollContainerRef.current) {
    const currentIndex = virtualItems.items.findIndex(
      (item) => item.href === currentSection.href
    );

    if (currentIndex !== -1) {
      const targetScrollTop = (virtualItems.startIndex + currentIndex) * itemHeight;
      scrollContainerRef.current.scrollTop = targetScrollTop;
    }
  }
}, [currentSection, virtualItems, itemHeight]);

3. 확장/축소 최적화

const toggleExpand = useCallback((index: number) => {
  setExpandedItems(prev => {
    const newSet = new Set(prev);
    if (newSet.has(index)) {
      newSet.delete(index);
      // 하위 항목들도 함께 축소
      flattenToc.forEach(item => {
        if (item.parentIndex === index) {
          newSet.delete(item.index);
        }
      });
    } else {
      newSet.add(index);
    }
    return newSet;
  });
}, [flattenToc]);

🎯 언제 가상 스크롤을 사용해야 할까?

사용을 권장하는 경우

  • 대용량 데이터: 100개 이상의 항목
  • 복잡한 아이템: 각 항목의 렌더링 비용이 높음
  • 무한 스크롤: 동적으로 데이터가 추가됨
  • 모바일 환경: 제한된 메모리와 CPU

사용을 피해야 하는 경우

  • 소량 데이터: 50개 미만의 간단한 목록
  • 동적 높이: 각 항목의 높이가 예측 불가능
  • 복잡한 상호작용: 아이템 간 복잡한 의존성
  • 검색/필터링: 실시간 텍스트 검색이 필요한 경우

🚀 추가 최적화 팁

1. 컴포넌트 메모이제이션

const VirtualizedTocItem = memo(({ item, onNavigate, isActive }) => {
  // 렌더링 로직
}, (prevProps, nextProps) => {
  // 커스텀 비교 함수로 불필요한 리렌더링 방지
  return (
    prevProps.item.href === nextProps.item.href &&
    prevProps.isActive === nextProps.isActive
  );
});

2. 스크롤 디바운싱

const debouncedScrollTop = useDebounce(scrollTop, 16); // 60fps

const virtualItems = useMemo(() => {
  // 계산 로직
}, [flattenToc, debouncedScrollTop]);

3. 백그라운드 프리로딩

// 화면 밖 아이템들을 백그라운드에서 미리 준비
const preloadItems = useMemo(() => {
  const bufferSize = 10;
  const preloadStart = Math.max(0, virtualItems.startIndex - bufferSize);
  const preloadEnd = Math.min(
    flattenToc.length, 
    virtualItems.endIndex + bufferSize
  );
  
  return flattenToc.slice(preloadStart, preloadEnd);
}, [flattenToc, virtualItems]);

📝 마무리

가상 스크롤링 도입으로 다음과 같은 성과를 얻었습니다:

  • 렌더링 시간 23% 단축 (714ms → 549ms)
  • 페인팅 시간 37% 감소 (590ms → 373ms)
  • DOM 노드 수 97% 감소 (500+ → 10~15개)
  • 부드러운 스크롤 성능 달성

특히 대용량 목차가 있는 기술 문서나 소설에서 눈에 띄는 성능 향상을 체감할 수 있었습니다.

가상 스크롤링은 복잡도가 증가하는 트레이드오프가 있지만, 대용량 데이터를 다루는 React 애플리케이션에서는 필수적인 최적화 기법이라고 생각합니다.

여러분의 프로젝트에서도 비슷한 성능 문제를 겪고 계신다면, 가상 스크롤링을 고려해보시기 바랍니다! 🚀


💡 추가 자료

profile
안녕하세요

0개의 댓글