← 글 목록

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 캐시.

그래도 살짝 깜빡이면 가상화 트레이드오프로 받아들이거나, 해당 화면만 가상화 범위를 줄이면 됩니다.