
보관함 페이지에는 플레이리스트별 트랙 목록이 있습니다.
트랙이 10~20개면 문제없지만, 좋아하는 곡을 계속 추가하다 보면 100곡, 200곡이 될 수 있습니다.
처음엔 단순하게 map()으로 전체를 렌더링했습니다.
// 단순 구현
export default function TrackList({ tracks }: { tracks: PlaylistItem[] }) {
return (
<ul>
{tracks.map(track => (
<PlayerQueueItem key={track.videoId} item={track} />
))}
</ul>
);
}
직관적이고 동작도 합니다.
하지만 "트랙이 많아지면 어떻게 될까?" 라는 의문이 생겼습니다!
화면에는 한 번에 약 10개의 트랙만 보입니다.
하지만 map()으로 렌더링하면 200곡이면 200개의 DOM 노드가 전부 존재합니다.
화면에 보이는 트랙: 10개
실제 DOM에 존재하는 트랙: 200개 💥
DOM 노드가 많을수록:
트랙 하나가 단순한 텍스트라면 괜찮을 수 있습니다.
그런데 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이 이 로직을 제공합니다.
npm install @tanstack/react-virtual
'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>
);
}
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
// 200개 × 56px = 11,200px
실제로 DOM에는 10개만 있지만, 스크롤바는 200개짜리처럼 동작해야 합니다.
getTotalSize()가 반환한 전체 높이를 컨테이너에 지정해서 스크롤 범위를 확보합니다.
style={{ transform: `translateY(${virtualRow.start}px)` }}
// 3번째 트랙이라면: translateY(112px) = 56px × 2
모든 아이템이 position: absolute로 컨테이너 좌상단에 쌓입니다.
virtualRow.start를 translateY에 적용해 각 아이템을 정확한 위치에 배치합니다.
width 대신 transform을 사용하므로 Layout 재계산 없이 GPU가 처리합니다.
overscan: 2
화면에 보이는 영역 위아래로 2개씩 미리 렌더링합니다.
스크롤할 때 새 항목이 즉시 나타나 버벅임이 없습니다.
너무 크면 렌더링할 DOM이 많아져 오히려 역효과가 납니다.
200개 트랙 기준으로 예상되는 차이입니다.
페이지 진입
↓
200개 트랙 전부 DOM 생성
↓
초기 렌더링: 200개 × (이미지 + 텍스트 + 버튼) 처리
↓
메모리: 200개 DOM 노드 상주
↓
스크롤 시: 200개 노드를 기준으로 레이아웃 계산
페이지 진입
↓
화면에 보이는 트랙 10개 + overscan 4개 = 14개만 DOM 생성
↓
초기 렌더링: 14개만 처리 ✅
↓
메모리: 14개 DOM 노드만 상주 ✅
↓
스크롤 시: 진입/이탈 항목만 추가/제거 ✅

트랙 수가 늘어도 렌더링 비용이 거의 변하지 않는다는 게 핵심입니다.
지금 당장 200곡을 가진 사용자가 많지 않을 수 있습니다.
하지만 가상 리스트는 적용 비용이 낮고, 데이터가 쌓일수록 효과가 커집니다.
"느려지고 나서 고치기"보다 "예방하기"가 훨씬 쉽다는 걸 다시 확인했습니다.
React 성능 최적화라고 하면 흔히 memo, useCallback을 떠올립니다.
하지만 DOM 노드 수 자체를 줄이는 것도 중요한 최적화입니다.
브라우저가 관리해야 할 노드가 적을수록 레이아웃 계산, 이벤트 버블링, 메모리 모두 고려해보는 습관을 가져보아요.
다음 글에는 IndexedDB 스키마 설계 및 데이터 무결성 관리에 대해 다뤄보려고 합니다!
지금까지 긴 글 읽어주셔서 감사합니다 :)
💬 비슷한 문제를 겪으셨거나, 더 좋은 해결 방법이 있다면 댓글로 공유해주세요!