2026년 고속 JavaScript ListView: 윈도잉, 가상화 및 성능 최적화
TanStack Virtual, react-virtuoso, react-window를 활용한 JavaScript 리스트 가상화를 마스터합니다. 윈도잉 기법으로 100,000개 이상의 아이템을 60fps로 렌더링하는 코드 예제를 다룹니다.

가상화 없이 JavaScript ListView에서 수천 개의 아이템을 렌더링하면 브라우저가 수천 개의 DOM 노드를 생성합니다. 그 결과 초기 렌더링 지연, 스크롤 끊김, 메모리 소비 증가가 발생하며, 모바일 기기에서는 크래시가 발생할 수 있습니다. 2026년의 고속 JavaScript ListView 구현은 윈도잉을 통해 이 문제를 해결합니다. 뷰포트에 표시되는 10~20개의 아이템만 렌더링하면서 모든 아이템을 포함하는 스크롤 가능한 영역을 시뮬레이션하는 기법입니다.
윈도잉은 보이는 아이템과 작은 버퍼(오버스캔)만 렌더링하여 리스트 크기에 관계없이 DOM 노드를 10,000개에서 약 20개로 줄입니다.
대규모 리스트에서 브라우저 성능이 저하되는 이유
모든 DOM 노드는 메모리를 소비하고 레이아웃 계산 시 브라우저가 위치를 추적해야 합니다. 적당히 복잡한 마크업(아바타, 두 개의 텍스트 span, 버튼)을 가진 10,000개의 리스트는 40,000개 이상의 DOM 노드를 생성합니다. Chrome의 렌더링 엔진은 스크롤 이벤트마다 모든 노드의 스타일과 위치를 재계산합니다.
그 결과 초기 렌더링에 2~4초가 걸리고, 스크롤은 15fps까지 떨어지며, 메모리 사용량은 500MB 이상으로 증가합니다. 모바일 Safari에서는 탭이 완전히 크래시되는 경우도 있습니다.
가상화는 일정한 DOM 노드 수를 유지함으로써 이 문제를 해결합니다. 데이터 배열에 100개가 있든 100,000개가 있든 렌더링되는 DOM은 약 20~30개 노드에 머무르며, 메모리는 일정하고 스크롤 성능은 60fps를 유지합니다.
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
interface Item {
id: string
name: string
email: string
}
export function VirtualizedList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null)
// 예상 행 높이로 가상화 인스턴스 생성
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // 픽셀 단위 예상 높이
overscan: 5, // 뷰포트 위아래로 5개의 추가 아이템 렌더링
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index]
return (
<div
key={item.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="flex items-center gap-3 p-3 border-b">
<div className="w-10 h-10 rounded-full bg-gray-200" />
<div>
<p className="font-medium">{item.name}</p>
<p className="text-sm text-gray-500">{item.email}</p>
</div>
</div>
</div>
)
})}
</div>
</div>
)
}이 구현은 보이는 행과 오버스캔 버퍼만 렌더링합니다. 외부 컨테이너가 스크롤 가능한 총 높이를 설정하여 모든 아이템이 DOM에 존재하는 것처럼 스크롤바가 동작합니다.
TanStack Virtual: 2026년의 헤드리스 접근 방식
TanStack Virtual은 프레임워크에 구애받지 않는 가상화 프리미티브를 제공합니다. react-window나 react-virtuoso와 달리 미리 빌드된 컴포넌트를 제공하지 않고, 어떤 아이템을 렌더링하고 어디에 배치할지 계산하는 훅만 제공합니다. 이 헤드리스 아키텍처는 스타일링과 DOM 구조에 대한 완전한 제어를 가능하게 합니다.
현재 버전(3.13)은 React, Vue, Solid, Svelte 및 바닐라 JavaScript를 지원합니다. 주요 구성 옵션은 다음과 같습니다.
| 옵션 | 목적 | 기본값 |
|---|---|---|
count | 총 아이템 수 | 필수 |
estimateSize | 예상 아이템 높이/너비를 반환하는 함수 | 필수 |
overscan | 뷰포트 외부에 렌더링할 추가 아이템 수 | 1 |
getScrollElement | 스크롤 가능한 컨테이너 반환 | 필수 |
measureElement | 동적 크기를 위한 커스텀 측정 함수 | 자동 |
overscan 값은 스크롤 부드러움에 직접적인 영향을 미칩니다. 너무 낮은 값(02)으로 설정하면 빠른 스크롤 시 흰색 깜박임이 발생합니다. 너무 높은 값(20 이상)으로 설정하면 가상화의 이점이 사라집니다. 대부분의 애플리케이션에서 510이 최적의 균형을 제공합니다.
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useCallback } from 'react'
interface Message {
id: string
content: string
timestamp: number
}
export function ChatList({ messages }: { messages: Message[] }) {
const parentRef = useRef<HTMLDivElement>(null)
// measureElement로 동적 높이 측정 활성화
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // 대략적인 추정, 실제는 나중에 측정
overscan: 8,
})
return (
<div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const message = messages[virtualRow.index]
return (
<div
key={message.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement} // 자동 측정 활성화
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="p-4 border-b">
<p className="text-sm text-gray-500">
{new Date(message.timestamp).toLocaleTimeString()}
</p>
<p className="mt-1">{message.content}</p>
</div>
</div>
)
})}
</div>
</div>
)
}measureElement ref는 렌더링 후 자동 높이 측정을 활성화합니다. 이를 통해 수동으로 높이를 계산할 필요 없이 가변 콘텐츠 길이를 처리할 수 있습니다.
react-virtuoso: 기능이 풍부한 대안
react-virtuoso는 정반대 접근 방식을 취합니다. 배터리 포함형 컴포넌트 라이브러리입니다. TanStack Virtual이 스크롤 컨테이너와 배치 로직 구축을 요구하는 반면, react-virtuoso는 이를 내부에서 처리합니다.
트레이드오프는 번들 크기(TanStack Virtual의 12KB에 비해 37KB)이지만, 그룹화된 리스트, 스티키 헤더, 역방향 스크롤이 있는 채팅 인터페이스 등 일반적인 패턴에 대한 보일러플레이트가 크게 줄어듭니다.
import { Virtuoso } from 'react-virtuoso'
interface User {
id: string
name: string
department: string
}
export function UserDirectory({ users }: { users: User[] }) {
return (
<Virtuoso
style={{ height: '600px' }}
data={users}
itemContent={(index, user) => (
<div className="flex items-center gap-3 p-4 border-b hover:bg-gray-50">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center">
{user.name.charAt(0)}
</div>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.department}</p>
</div>
</div>
)}
/>
)
}더 적은 코드로 동일한 가상화 결과를 얻을 수 있습니다. react-virtuoso는 자동으로 아이템을 측정하고, 동적 높이를 처리하며, 스크롤 위치 유지를 제공합니다.
React 성능에 관한 면접 준비에서 각 라이브러리를 언제 선택해야 하는지 이해하는 것은 아키텍처에 대한 인식을 보여줄 수 있는 기회입니다. TanStack Virtual은 이미 TanStack Table을 사용하거나 프레임워크에 구애받지 않는 코드가 필요한 애플리케이션에 적합합니다. react-virtuoso는 번들 크기보다 개발 속도를 우선시하는 팀에 적합합니다.
Node.js / NestJS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
일반적인 함정과 해결책
이미지로 인한 스크롤 위치 점프
이미지는 비동기로 로드되어 초기 측정 후 행 높이가 변경됩니다. 가상화기는 이 변화를 예측할 수 없어 아래 콘텐츠가 밀려납니다.
해결책은 aspect-ratio 또는 고정 치수를 사용하여 이미지 공간을 예약하는 것입니다.
interface ImageItem {
id: string
src: string
title: string
}
export function ImageListItem({ item }: { item: ImageItem }) {
return (
<div className="flex gap-4 p-4 border-b">
{/* 로드 상태와 관계없이 80x80px 공간 예약 */}
<div className="w-20 h-20 flex-shrink-0 bg-gray-100">
<img
src={item.src}
alt={item.title}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
<p className="font-medium">{item.title}</p>
</div>
)
}스크롤 중 상태 손실
가상화된 아이템은 뷰에서 스크롤 아웃되면 언마운트됩니다. 로컬 상태(폼 입력, 펼쳐진 아코디언)가 사라집니다.
해결책은 아이템 ID로 키가 지정된 상태를 부모 컴포넌트나 상태 관리 라이브러리로 끌어올리는 것입니다.
import { useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
interface FAQ {
id: string
question: string
answer: string
}
export function FAQList({ faqs }: { faqs: FAQ[] }) {
// 상태가 부모에 존재하여 행 언마운트 후에도 유지됨
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const parentRef = useRef<HTMLDivElement>(null)
const toggleExpanded = useCallback((id: string) => {
setExpandedIds(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const virtualizer = useVirtualizer({
count: faqs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
overscan: 5,
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const faq = faqs[virtualRow.index]
const isExpanded = expandedIds.has(faq.id)
return (
<div
key={faq.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<button
onClick={() => toggleExpanded(faq.id)}
className="w-full text-left p-4 border-b"
>
<p className="font-medium">{faq.question}</p>
{isExpanded && (
<p className="mt-2 text-gray-600">{faq.answer}</p>
)}
</button>
</div>
)
})}
</div>
</div>
)
}불안정한 키로 인한 재렌더링
배열 인덱스를 키로 사용하면 리스트가 재정렬될 때까지는 작동합니다. 아이템이 잘못된 상태를 받아 시각적 결함과 불필요한 렌더링이 발생합니다.
항상 데이터 자체에서 안정적이고 고유한 식별자를 사용해야 합니다.
// 올바름: 데이터에서 안정적인 키
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index]
return <div key={item.id}>...</div> // 데이터베이스/API의 id
})}
// 잘못됨: 인덱스는 리스트 재정렬 시 변경됨
{virtualizer.getVirtualItems().map((virtualRow, index) => {
return <div key={index}>...</div> // 재정렬 시 버그 발생
})}2026년 프로젝트를 위한 라이브러리 비교
적절한 가상화 라이브러리 선택은 프로젝트 요구사항에 따라 달라집니다. 이 비교는 2026년 생태계를 반영합니다.
| 기준 | TanStack Virtual | react-virtuoso | react-window |
|---|---|---|---|
| 번들 크기 | 12KB | 37KB | 6KB |
| 동적 높이 | 수동 설정 | 자동 | 미지원 |
| TypeScript | 네이티브 | 네이티브 | @types 패키지 |
| 프레임워크 지원 | React, Vue, Solid, Svelte | React만 | React만 |
| 유지보수 | 활발함 (2026) | 활발함 (2026) | 유지보수 모드 |
| 학습 난이도 | 높음 | 낮음 | 가장 낮음 |
react-window는 번들 크기가 중요한 단순 고정 높이 리스트에는 여전히 유효합니다. 그러나 유지보수 모드 상태와 동적 높이 지원 부재로 인해 복잡한 요구사항을 가진 새 프로젝트에는 적합하지 않습니다.
고성능 React 애플리케이션 구축에서 가상화 트레이드오프를 이해하는 것은 면접관이 찾는 성능 인식을 보여줍니다.
성능 개선 측정
가상화의 이점을 정량화하려면 일관된 측정 방법론이 필요합니다. Chrome DevTools의 Performance 패널이 관련 메트릭을 제공합니다.
가상화 전후 주요 측정 항목은 다음과 같습니다.
- First Contentful Paint (FCP): 첫 번째 콘텐츠가 렌더링될 때까지의 시간. 가상화는 초기 DOM 생성을 제한하여 이를 단축합니다.
- Total Blocking Time (TBT): 로딩 중 메인 스레드 블로킹. DOM 노드가 적을수록 스타일/레이아웃 계산이 줄어듭니다.
- 메모리 사용량: Memory 패널에서 힙 크기 스냅샷. 리스트 길이와 관계없이 일정해야 합니다.
- 스크롤 fps: 스크롤하면서 Performance 트레이스 기록. 프레임 레이트는 60fps를 유지해야 합니다.
import { useEffect, useRef } from 'react'
export function useScrollFPS(containerRef: React.RefObject<HTMLElement>) {
const frameCountRef = useRef(0)
const lastTimeRef = useRef(performance.now())
useEffect(() => {
const container = containerRef.current
if (!container) return
let rafId: number
const measureFrame = () => {
frameCountRef.current++
const now = performance.now()
if (now - lastTimeRef.current >= 1000) {
console.log(`Scroll FPS: ${frameCountRef.current}`)
frameCountRef.current = 0
lastTimeRef.current = now
}
rafId = requestAnimationFrame(measureFrame)
}
const handleScroll = () => {
if (!rafId) {
rafId = requestAnimationFrame(measureFrame)
}
}
container.addEventListener('scroll', handleScroll, { passive: true })
return () => {
container.removeEventListener('scroll', handleScroll)
cancelAnimationFrame(rafId)
}
}, [containerRef])
}이 훅은 개발 중 스크롤 FPS를 로깅합니다. 프로덕션 애플리케이션에서는 Sentry Performance나 커스텀 텔레메트리 같은 모니터링 도구를 사용해야 합니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
JavaScript 리스트 성능의 핵심 포인트
- 가상화는 DOM 노드를 수천 개에서 약 20개로 줄여 데이터 크기와 관계없이 메모리와 스크롤 성능을 일정하게 유지합니다
- TanStack Virtual(12KB)은 React, Vue, Solid, Svelte에서 최대 제어를 위한 헤드리스 프리미티브를 제공합니다
- react-virtuoso(37KB)는 자동 동적 높이 처리가 포함된 배터리 포함형 컴포넌트를 제공합니다
- react-window는 2022년 이후 유지보수 모드이며 동적 높이 지원이 없습니다
- 오버스캔을 5~10으로 설정하면 가상화 이점을 해치지 않으면서 최적의 스크롤 부드러움을 얻습니다
- 이미지 크기를 예약하여 비동기 로딩 중 스크롤 위치 점프를 방지합니다
- 가상화된 행은 스크롤 중 언마운트되므로 확장 가능/편집 가능한 상태는 부모 컴포넌트로 끌어올립니다
- 재정렬 시 상태 불일치를 방지하기 위해 배열 인덱스가 아닌 데이터 ID의 안정적인 키를 사용합니다
- 구현 전후 FCP, TBT, 메모리, 스크롤 FPS를 측정하여 개선 사항을 정량화합니다
Node.js / NestJS 코드의 버그를 찾을 수 있나요
실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

작성자
Anthony Fillion-MailletSharpSkill 창업자
10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.
2026년 9월 15일 업데이트
태그
공유
관련 기사

2026년 고성능 JavaScript ListView 구현: 가상화 및 최적화 기법
JavaScript 리스트 가상화 기술을 심층 분석합니다. TanStack Virtual, react-virtuoso, react-window를 활용한 대규모 데이터의 효율적인 렌더링 방법과 면접 대비 내용을 다룹니다.

NestJS + Prisma: Node.js를 위한 모던 백엔드 스택
NestJS와 Prisma로 모던한 백엔드 API를 구축하기 위한 완전한 가이드입니다. 설정, 모델, 서비스, 트랜잭션 및 모범 사례를 설명합니다.

Node.js 백엔드 면접 질문: 완벽 가이드 2026
Node.js 백엔드 면접에서 가장 자주 나오는 25가지 질문. Event loop, async/await, streams, 클러스터링, 성능을 상세한 답변과 함께 설명합니다.