notes
Tanstack Virtual
TanStack Virtual 가상화 목록 구성·사용법과 성능 최적화 메모.
한 줄 요약
TanStack Virtual = “10,000개 리스트인데, 화면에 보이는 10~20개만 DOM에 그려 주는 도구”
현재 땡큐 캠핑에 listCard에 도입
InfinityQueryVisualScrollList.tsx가 바로 그것입니다.
왜 필요한가? — 엘리베이터 비유
10층 건물에 10,000호가 있다고 상상해 보세요.
❌ 일반 렌더링
- 10,000호 문을 전부 실제로 건설
- 메모리·시간 폭발, 스크롤할 때마다 버벅임
✅ 가상화
- 지금 보이는 층 몇 개만 임시 세트로 설치
- 스크롤하면 세트를 옮겨 다시 칠함
- 사용자 눈에는 “10,000개 있는 것처럼” 보임
진짜 데이터는 10,000개지만, DOM(화면에 그린 것)은 10~20개만 유지합니다.
TanStack Virtual이 뭔가?
앞서 말씀드린 프레임워크 vs 라이브러리 관점이면:
- 내가
useVirtualizer()호출 → 라이브러리 - 다만 스크롤할 때마다 **virtualizer가 “이거 그려”**라고 범위를 바꿔 줌
동작 원리 (3단계만 기억)
① 스크롤 컨테이너 ref 잡기
② useVirtualizer({ count, estimateSize, getScrollElement })
③ getVirtualItems()만 map해서 그리기
그림으로
[ 안 보임 ] 항목 0, 1 ← DOM 없음
────────── 화면 상단 ──────────
[ 보임 ✓ ] 항목 2, 3, 4, 5 ← 실제로 그림
────────── 화면 하단 ──────────
[ 안 보임 ] 항목 6, 7... ← DOM 없음
스크롤 내리면 → virtualizer가 2,3,4,5 대신 5,6,7,8 같은 식으로 범위만 바꿉니다.
꼭 알아야 할 단어 4개
고정 높이 카드(높이 200px 딱 맞음) → estimateSize만으로 충분
댓글·리뷰처럼 길이 들쭉날쭉 → measureRef 필요
코드 뼈대 (가이드 100줄 → 이 20줄)
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length, // 전체 개수
getScrollElement: () => parentRef.current, // 스크롤 되는 div
estimateSize: () => 200, // 한 줄 예상 높이
overscan: 5, // 위아래 여분
});
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
{/* 스크롤바 길이 = 전체 리스트 길이인 척 */}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
ref={row.measureRef} // 동적 높이면 필요
style={{
position: 'absolute',
transform: `translateY(${row.start}px)`, // 위치만 밀어 넣기
width: '100%',
}}
>
{items[row.index].name}
</div>
))}
</div>
</div>
);
핵심 패턴 3가지:
- 바깥 div →
overflow: auto(스크롤 상자) - 안쪽 div →
height: getTotalSize()(스크롤바 길이 맞추기) - 각 항목 →
position: absolute+translateY(start)(보이는 것만 배치)
신 프로젝트에서 이미 쓰는 방식
InfinityQueryVisualScrollList.tsx는 가상화 + 무한 스크롤을 한 번에 묶었습니다.
// 실제 프로젝트 패턴 (요약)
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? items.length + 1 : items.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => estimateSize, // 기본 200px
overscan: 5,
});
→ 캠핑장 목록처럼 많은 카드 + 스크롤 더 불러오기에 딱 맞는 조합입니다.
언제 쓰고, 언제 안 쓰나?
✅ 쓰면 좋음
- 항목 수백~수만 개
- 무한 스크롤 목록
- 테이블/그리드가 길 때
- 스크롤이 버벅일 때
❌ 굳이 안 써도 됨
- 항목 20~30개 이하
- 높이·레이아웃이 너무 복잡해서 오히려 버그 나기 쉬울 때
- SEO가 중요한 전체 HTML이 한 번에 있어야 할 때 (가상화는 DOM에 일부만 있음)
자주 터지는 문제 (가이드 8장을 말로)
react-window / MUI List vs TanStack Virtual
땡큐처럼 디자인 시스템 카드를 그대로 쓰려면 Headless인 TanStack이 잘 맞습니다.
무한 스크롤 + 가상화 (머릿속 그림)
사용자 스크롤 ↓
→ virtualizer: "지금 45~52번 보여"
→ 52번이 거의 끝이면 → fetchNextPage()
→ 데이터 늘어남 → count 증가
→ virtualizer가 범위만 다시 계산
데이터 로딩은 React Query, 그릴 범위는 Virtual — 역할 분리입니다.
3초 체크리스트
- 스크롤 되는 div ref 잡았나?
count= 실제 배열 길이 맞나?getTotalSize()로 안쪽 높이 맞췄나?translateY(start)로 위치 잡았나?- 높이 가변이면 measureRef 붙였나?
왜 사진이 “다시 렌더”되나?
가상화의 본질은 이겁니다.
화면 밖으로 나가면 DOM에서 지우고, 다시 들어오면 새로 그린다.
스크롤 ↓
항목 3,4,5 보임 → <img> 3개 DOM에 존재
스크롤 더 ↓
항목 3은 화면 밖 → React가 **언마운트** (img 태그 삭제)
다시 위로 ↑
항목 3 다시 보임 → React가 **새로 마운트** (img 태그 새로 생성)
그래서 신이 보시는 현상은 보통 이렇게 나뉩니다.
데이터(이미지 URL)는 React Query 캐시에 남아 있어도,
DOM의 <img> / OptimizedImage는 새로 만들어집니다.
그래서 “다시 그리는 것처럼” 보입니다.
비유
엘리베이터 앞 전광판 10칸만 켜 두는 것과 같습니다.
- 11번째 항목으로 스크롤하면 → 1번 전광판 끔
- 다시 올라오면 → 1번 전광판 다시 켬 (내용은 같아도 전원 다시 넣는 느낌)
성능은 좋아지지만, 한 번 그렸던 화면을 계속 붙여 두지는 않습니다.
신 프로젝트 코드 기준
InfinityQueryVisualScrollList는 이렇게 동작합니다.
{virtualItems.map((virtualRow) => {
// ...
) : (
// 실제 아이템 렌더링
item ? items(item, virtualRow.index) : null
)}
스크롤할 때마다 virtualItems가 바뀌고 → 리스트 전체가 리렌더됩니다.
items() 안에서 카드·이미지를 memo 없이 만들면, 보이는 행도 매번 새로 그려질 수 있습니다.
줄이는 방법 (실무 순서)
1. 카드 컴포넌트 memo (가장 효과 큼)
const CampCard = memo(function CampCard({ item }: { item: Camp }) {
return (
<OptimizedImage src={item.thumbUrl} alt={item.name} />
);
});
// items prop
items={(item) => <CampCard item={item} />}
같은 item이면 리렌더 자체를 막습니다.
(언마운트 후 재진입 시에는 여전히 한 번은 그려짐)
2. key를 index 말고 고유 ID
// ✅ 좋음
key={item.campSeq}
// ❌ 나쁨 (순서 바뀌면 이미지 섞임 + 재마운트)
key={virtualRow.index}
지금은 virtualRow.key를 쓰고 있어서 대체로 괜찮지만, 카드 내부 Image도 key={item.campSeq} 같이 안정적으로 두는 게 좋습니다.
3. overscan 늘리기 (간단한 완화)
overscan={10} // 기본 5 → 위아래 더 미리 그려 둠
화면 밖이어도 조금 더 오래 DOM에 남습니다.
스크롤 되돌릴 때 재마운트가 줄어듦. 대신 DOM 수는 조금 늘어납니다.
4. 이미지 캐시 확인
네트워크에서 진짜 재다운로드인지, 디코딩만 다시인지 구분하세요.
OptimizedImage / CDN에 긴 cache가 있으면 재다운로드는 줄고, 깜빡임만 남는 경우가 많습니다.
5. 로딩 UI 완화
OptimizedImage가 로딩마다 스켈레톤을 보여 주면 **“또 로딩”**처럼 느껴집니다.
- 이미 본 URL이면 placeholder 스킵
blurDataURL/ 저해상도 placeholder 고정- 또는
loading="lazy"+decoding="async"
6. 가상화가 필요 없으면 빼기
photo.form.tsx처럼 Swiper·모달 구조는 가상화와 별개입니다.
목록 카드 썸네일에서 깜빡이는 거라면 위 1~3번이 해당됩니다.
기대치 정리 (중요)
- *“한 번 본 사진은 절대 안 깜빡이게”**와 **“만 개도 부드럽게”**는 **동시에 100%**는 어렵습니다.
RecyclerView(안드로이드)도 뷰를 재활용하지, 계속 붙여 두진 않습니다.
요약
TanStack Virtual은 “긴 리스트를 속이는 기술”이 아니라, “보이는 부분만 진짜로 그리는 기술”입니다.
- 데이터 10,000개 → 메모리/배열에는 있어도 됨
- DOM은 화면에 보이는 10~20개만
- 신은 이미
InfinityQueryVisualScrollList로 가상화 + 무한스크롤 패턴을 갖춰 두셨습니다
새 긴 목록 만들 때 그 컴포넌트를 감싸 쓰면 가이드 9장을 처음부터 짤 필요가 없습니다.
가상화 = 보이는 것만 DOM에 두기 → 화면 밖으로 나가면 img도 지워짐 → 돌아오면 다시 그림. 데이터는 캐시에 있어도, 화면 컴포넌트는 새로 태어남.
실무 처방: memo + 안정적인 key + overscan 조정 + 이미지 CDN 캐시.
그래도 살짝 깜빡이면 가상화 트레이드오프로 받아들이거나, 해당 화면만 가상화 범위를 줄이면 됩니다.