tanstack virtual이라고 tanstack에서 제공해주는 가상스크롤 라이브러리가 있다. 헤드리스다 보니 디펜던시가 많이 걸려있지 않지만,쓰기 편하려면 컴포넌트를 직접 개발해야 하긴 한다.
여기서는 tanstack virtual을 컴포넌트 형태로 개발하면서 만난 여러 문제 중 하나를 기록하고자 한다.
가상스크롤 개발 시 SSR을 사용하는 플랫폼이라면 고민되는 포인트가 하나 있을 것이다. 서버사이드에서 데이터를 불러올 때 HTML에 프로덕트 정보가 담겨 있어야 하는데, 이걸 어떻게 처리할 것이냐다. TanStack Virtual은 SSR 렌더링을 위한 완결된 가이드를 공식적으로 제공하지는 않는다. 다만 SSR 환경에서 사용할 수 있도록 만들어진 옵션이 세 가지 있다.
- initialRect — 스크롤 요소의 초기 크기를 서버에 미리 알려준다
- initialOffset — 초기 스크롤 위치를 지정한다
- initialMeasurementsCache — 이전에 측정해둔 아이템 크기 스냅샷을 시드로 넣는다
virtualizer가 "지금 어떤 아이템을 그릴지" 계산하려면 두 가지가 필요하다. 뷰포트(스크롤 요소) 크기와 각 아이템의 위치/크기 측정값이다.
문제는 서버에는 DOM이 없다는 것. 그래서 기본 동작대로라면 뷰포트 크기는 0, 측정값은 빈 배열이 되고, getVirtualItems()는 빈 배열을 반환한다. 결과적으로 SSR HTML에는 height: 0짜리 spacer div만 담겨서 내려온다.
이때 initialRect를 넘겨주면 흐름이 달라진다.
initialRect: { width: 1280, height: 720 }
→ 서버에서 getSize()가 720을 반환
→ 측정값은 estimateSize 기반으로 생성 (서버에선 실측 불가)
→ "0 ~ 720px 범위에 걸치는 아이템"이 계산됨
→ getVirtualItems()가 해당 슬라이스를 반환
→ SSR HTML에 실제 아이템 마크업이 담김
즉 initialRect가 가짜 뷰포트 역할을 하고, 실측이 불가능한 아이템 높이는 estimateSize가 대신한다. 여기에 initialOffset을 주면 "스크롤이 이만큼 내려간 상태"를 가정하고 그 위치의 슬라이스를 렌더하고, initialMeasurementsCache를 주면 estimateSize 대신 이전에 실측해둔 값으로 계산한다(뒤로가기 시 스크롤 복원 등에 유용하다).
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 120, // 아이템 예상 높이
// --- SSR 관련 옵션 ---
// 서버에서 사용할 가짜 뷰포트 크기
initialRect: { width: 1280, height: 720 },
// 초기 스크롤 위치 (스크롤 복원 시 저장해둔 offset)
initialOffset: 0,
// 이전에 실측한 아이템 크기 스냅샷 (virtualizer.measurementsCache 저장분)
initialMeasurementsCache: savedCache,
})
이렇게 하면 클라이언트 첫 렌더(하이드레이션)도 동일한 initialRect 기준으로 계산되기 때문에 서버 HTML과 일치하고, 마운트 이후 ResizeObserver가 실제 크기로 보정한다.
이 방식의 본질은 결국 전부 추정값이라는 데 있다.
서버는 클라이언트 뷰포트를 모른다. initialRect에 넣는 값은 결국 개발자가 찍은 추측이다. 1280×720으로 렌더했는데 실제 접속 기기가 모바일이면, 서버가 그린 아이템 개수와 실제 필요한 개수가 어긋난다.
아이템 높이도 추정이다. 서버에서는 measureElement가 동작하지 않으니 모든 계산이 estimateSize 기준이다. 실제 높이가 가변적이라면 마운트 후 실측되면서 아이템 위치가 재배치되고, 그 과정에서 레이아웃 시프트가 생길 수 있다.
SSR HTML에는 뷰포트 슬라이스만 담긴다. 전체 리스트가 아니라 "추정 뷰포트에 걸치는 일부"만 렌더되므로, SEO 관점에서 전체 프로덕트 정보를 HTML에 담고 싶다면 이 방식만으로는 부족하다.
window virtualizer는 더 애매하다. useWindowVirtualizer는 window 자체가 스크롤 요소인데, 서버에는 window가 없다. element 기반보다 추정이 한 단계 더 불안해진다.
정리하면 이 옵션들은 "SSR에서 빈 HTML이 내려오는 것"은 막아주지만, 정확한 렌더링을 보장하는 게 아니라 그럴듯한 추정 렌더링을 만들어주는 장치다. 그래서 전체 데이터가 HTML에 담겨야 하는 요구사항이 있다면, 별도의 접근(마운트 전에는 실제 데이터를 직접 렌더하고 마운트 후 가상스크롤로 전환하는 방식)이 필요해진다.
그래서 옵션에 기대는 대신, enabled: true로 고정하고 렌더 소스를 마운트 시점 기준으로 분기했다.
[SSR / 마운트 전] 실제 데이터 직접 렌더 → SSR HTML에 콘텐츠 포함
│
▼ (onMounted)
[마운트 후] getVirtualItems() 기반 가상스크롤 렌더
const nuxtApp = useNuxtApp()
const isStaticPhase = ref(import.meta.server || nuxtApp.isHydrating)
onMounted(() => {
isStaticPhase.value = false
})
const virtualizer = useWindowVirtualizer(
computed(() => ({
enabled: true,
getItemKey: getRowKey,
...나머지 옵션들
}))
)
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
const displayRows = computed<DisplayRow[]>(() =>
isStaticPhase.value
? Array.from({ length: rowCount.value }, (_, index) => ({
index,
key: getRowKey(index),
}))
: virtualItems.value
> )
'use client'
// 초기값 true = 서버 렌더 + 하이드레이션 첫 렌더까지 "정적 단계"
// useEffect는 하이드레이션 이후에 실행되므로 미스매치 없이 전환된다
const [isStaticPhase, setIsStaticPhase] = useState(true)
useEffect(() => {
setIsStaticPhase(false)
}, [])
const virtualizer = useWindowVirtualizer({
enabled: true,
getItemKey: getRowKey,
// ...나머지 옵션들
})
const virtualItems = virtualizer.getVirtualItems()
const displayRows: DisplayRow[] = isStaticPhase
? Array.from({ length: rowCount }, (_, index) => ({
index,
key: getRowKey(index),
}))
: virtualItems
// 정적 단계와 virtualizer가 같은 key를 쓰도록 통일
// 없으면 서버/클라이언트 row 키가 달라져 하이드레이션 미스매치 발생
const getRowKey = (index: number) => {
if (columns.value <= 1) return index
const start = index * columns.value
const first = props.items[start]
return first ? `${start}::row` : `row-${index}`
}
정적 단계 판정에 하이드레이션 첫 렌더가 서버 HTML과 일치하도록 했고, getRowKey를 양쪽에 동일하게 사용해 전환 시 미스매치를 방지했다. 추정값에 기대지 않으니 SSR HTML에 실제 데이터가 온전히 담기고, 가상화는 마운트 후에만 동작한다.
물론 다른 가상스크롤 라이브러리는 써보지 않아서 잘 모르겠다.
이런 헤드리스 방식이 아닌 라이브러리도 리액트 진영에는 여럿 있다. react-window나 react-virtuoso 같은.
하지만 현재 플랫폼은 vue 진영이라 nuxt를 사용하고 있고, 여기에 맞는 라이브러리를 찾다 보니 업데이트가 활발해 믿음직스러운 tanstack 쪽을 선택했기에 이런 처리를 해준 것이다. 다른 라이브러리는 SSR을 더 잘 지원해줄 수도 있다.
나처럼 이런 고민을 하는 경우에 참고하면 좋을 것 같다.