긴 플레이리스트, 전부 그려야 할까? (feat. React Virtual) -4

yoon·2026년 4월 7일

yoon-play2

목록 보기
4/5
post-thumbnail

🤔 왜 필요한가?

보관함 페이지에는 플레이리스트별 트랙 목록이 있습니다.
트랙이 10~20개면 문제없지만, 좋아하는 곡을 계속 추가하다 보면 100곡, 200곡이 될 수 있습니다.

처음엔 단순하게 map()으로 전체를 렌더링했습니다.

// 단순 구현
export default function TrackList({ tracks }: { tracks: PlaylistItem[] }) {
  return (
    <ul>
      {tracks.map(track => (
        <PlayerQueueItem key={track.videoId} item={track} />
      ))}
    </ul>
  );
}

직관적이고 동작도 합니다.
하지만 "트랙이 많아지면 어떻게 될까?" 라는 의문이 생겼습니다!

문제 예상(원인)

DOM에 모든 요소가 존재한다는 것

화면에는 한 번에 약 10개의 트랙만 보입니다.
하지만 map()으로 렌더링하면 200곡이면 200개의 DOM 노드가 전부 존재합니다.

화면에 보이는 트랙: 10개
실제 DOM에 존재하는 트랙: 200개 💥

DOM 노드가 많을수록:

  • 초기 렌더링 비용 증가 (200개를 한 번에 그림)
  • 메모리 사용량 증가 (보이지 않는 요소도 메모리 차지)
  • 스크롤 성능 저하 (브라우저가 관리할 노드가 많아짐)

🤔 실제로 문제가 될까?

트랙 하나가 단순한 텍스트라면 괜찮을 수 있습니다.
그런데 PlayerQueueItem은 썸네일 이미지, 버튼, 아이콘 등을 포함한 복합 컴포넌트입니다.

200개 × (이미지 + 텍스트 + 버튼 + 아이콘) = 상당한 DOM 크기

지금 당장 느리지 않더라도, 데이터가 많아질수록 문제가 될 가능성이 충분합니다.
"느려지고 나서 고치기"보다 "예방하기" 를 선택했습니다.




💡 아이디어 + 해결

핵심 아이디어: 보이는 것만 그리자

스크롤 위치를 기준으로 현재 화면에 보이는 트랙만 DOM에 렌더링하면 됩니다.
스크롤을 내리면 새 항목이 그려지고, 화면을 벗어난 항목은 DOM에서 제거합니다.

[트랙 1]  ← DOM에 없음 (화면 위로 벗어남)
[트랙 2]  ← DOM에 없음
──────────────────── 화면 시작
[트랙 3]  ← DOM에 있음 ✅
[트랙 4]  ← DOM에 있음 ✅
[트랙 5]  ← DOM에 있음 ✅
[트랙 6]  ← DOM에 있음 ✅
[트랙 7]  ← DOM에 있음 ✅
──────────────────── 화면 끝
[트랙 8]  ← DOM에 없음 (화면 아래)
[트랙 9]  ← DOM에 없음
...

이 개념을 가상 리스트(Virtual List) 라고 합니다.
직접 구현할 수도 있지만, @tanstack/react-virtual이 이 로직을 제공합니다.

⚙️ 가상 리스트 적용

1. 설치

npm install @tanstack/react-virtual

2. 구현

'use client';

import { useCallback, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';

export default function TrackList({ tracks, context }: TrackListProps) {
  const { currentVideoId, setPlayerListFromContext } = usePlayerCore();
  
  // 1. 스크롤 컨테이너 ref
  const parentRef = useRef<HTMLDivElement | null>(null);

  // 2. 가상화 설정
  const rowVirtualizer = useVirtualizer({
    count: tracks.length,        // 전체 트랙 수
    getScrollElement: () => parentRef.current, // 스크롤 컨테이너
    estimateSize: () => 56,      // 트랙 하나의 높이 (px)
    overscan: 2,                 // 화면 밖으로 미리 렌더링할 개수
  });

  const handlePlay = useCallback(
    (videoId: string) => {
      const track = tracks.find(t => t.videoId === videoId);
      if (track) setPlayerListFromContext(tracks, track);
    },
    [tracks, setPlayerListFromContext],
  );

  // 3. 실제 렌더링되는 가상 아이템 목록
  const virtualItems = rowVirtualizer.getVirtualItems();

  return (
    // 4. 스크롤 컨테이너
    <div
      ref={parentRef}
      className='overflow-auto mt-8'
      style={{ height: 'calc(100vh - 380px - 80px)' }}
    >
      {/* 5. 전체 높이를 확보하는 컨테이너 (스크롤바가 올바르게 표시되도록) */}
      <ul
        className='relative w-full'
        style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
      >
        {/* 6. 화면에 보이는 항목만 렌더링 */}
        {virtualItems.map(virtualRow => {
          const track = tracks[virtualRow.index];
          return (
            <li
              key={track.videoId}
              className='absolute left-0 top-0 w-full'
              style={{
                // 7. translateY로 정확한 위치에 배치
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              <PlayerQueueItem
                item={track}
                isActive={track.videoId === currentVideoId}
                onClick={handlePlay}
                context={context}
                showLikeButton={false}
              />
            </li>
          );
        })}
      </ul>
    </div>
  );
}

핵심 개념 뜯어보기

getTotalSize() — 전체 높이 확보

style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
// 200개 × 56px = 11,200px

실제로 DOM에는 10개만 있지만, 스크롤바는 200개짜리처럼 동작해야 합니다.
getTotalSize()가 반환한 전체 높이를 컨테이너에 지정해서 스크롤 범위를 확보합니다.

translateY — 올바른 위치에 배치

style={{ transform: `translateY(${virtualRow.start}px)` }}
// 3번째 트랙이라면: translateY(112px) = 56px × 2

모든 아이템이 position: absolute로 컨테이너 좌상단에 쌓입니다.
virtualRow.starttranslateY에 적용해 각 아이템을 정확한 위치에 배치합니다.
width 대신 transform을 사용하므로 Layout 재계산 없이 GPU가 처리합니다.

overscan: 2 — 스크롤 버벅임 방지

overscan: 2

화면에 보이는 영역 위아래로 2개씩 미리 렌더링합니다.
스크롤할 때 새 항목이 즉시 나타나 버벅임이 없습니다.
너무 크면 렌더링할 DOM이 많아져 오히려 역효과가 납니다.




📊 Before / After (가상 시나리오)

200개 트랙 기준으로 예상되는 차이입니다.

Before: 전체 렌더링

페이지 진입
  ↓
200개 트랙 전부 DOM 생성
  ↓
초기 렌더링: 200개 × (이미지 + 텍스트 + 버튼) 처리
  ↓
메모리: 200개 DOM 노드 상주
  ↓
스크롤 시: 200개 노드를 기준으로 레이아웃 계산

After: 가상 리스트

페이지 진입
  ↓
화면에 보이는 트랙 10개 + overscan 4개 = 14개만 DOM 생성
  ↓
초기 렌더링: 14개만 처리 ✅
  ↓
메모리: 14개 DOM 노드만 상주 ✅
  ↓
스크롤 시: 진입/이탈 항목만 추가/제거 ✅

트랙 수가 늘어도 렌더링 비용이 거의 변하지 않는다는 게 핵심입니다.




🎓 회고

1. 느려지기 전에 설계하자

지금 당장 200곡을 가진 사용자가 많지 않을 수 있습니다.
하지만 가상 리스트는 적용 비용이 낮고, 데이터가 쌓일수록 효과가 커집니다.
"느려지고 나서 고치기"보다 "예방하기"가 훨씬 쉽다는 걸 다시 확인했습니다.

2. DOM 크기도 성능이다

React 성능 최적화라고 하면 흔히 memo, useCallback을 떠올립니다.
하지만 DOM 노드 수 자체를 줄이는 것도 중요한 최적화입니다.
브라우저가 관리해야 할 노드가 적을수록 레이아웃 계산, 이벤트 버블링, 메모리 모두 고려해보는 습관을 가져보아요.




다음 글에는 IndexedDB 스키마 설계 및 데이터 무결성 관리에 대해 다뤄보려고 합니다!

지금까지 긴 글 읽어주셔서 감사합니다 :)

💬 비슷한 문제를 겪으셨거나, 더 좋은 해결 방법이 있다면 댓글로 공유해주세요!

profile
Frontend Developer 😆 | PM

0개의 댓글