ADR-23: 윈도우 레벨 가상화 패턴
| 날짜 | 작성자 | 레포 |
|---|---|---|
| 2026-01-19 | @specvital | web |
컨텍스트
spec-view 모드에서 1000개 이상의 behavior 로딩 시 심각한 성능 저하 발생:
| 이슈 | 설명 |
|---|---|
| 계산 병목 | computeFilteredDocument()가 O(n^3) 복잡도로 매 렌더 사이클마다 재계산 |
| 불충분한 가상화 범위 | 100+ 임계값에서 behavior 레벨만 가상화; Domain/Feature 레벨 가상화 부재 |
| 중첩 구조 문제 | 깊은 4단계 계층 (Document -> Domain -> Feature -> Behavior)이 전통적 가상화 접근 복잡화 |
이전 접근법 문제
useVirtualizer를 사용한 컨테이너 기반 가상화의 치명적 UX 이슈:
- 불일치하는 스크롤 경험: 각 가상화 섹션별 별도 스크롤 컨테이너가 통합 페이지 스크롤 패턴 파괴
- CSS 레이아웃 붕괴: 컨테이너 가상화에 필요한
position: absolute레이아웃이 일반 CSS 간격, 카드 구조, 시각적 그룹핑 훼손
제약 조건
| 제약 | 설명 |
|---|---|
| React Compiler | 자동 메모이제이션을 깨는 패턴 없이 React Compiler와 호환 필수 |
| UX 일관성 | 단일 페이지 스크롤; 중첩 스크롤 컨테이너 금지 |
| 시각적 그룹핑 | Domain/Feature 카드 구조 유지 필수 |
| 임계값 민감도 | 소규모 (< 30개)와 대규모 (1000+) 데이터셋 모두 처리 |
결정
TanStack Virtual의 useWindowVirtualizer를 사용한 윈도우 레벨 가상화와 플랫 배열 변환 및 지연 렌더링 채택.
핵심 구현
- 플랫 배열 변환:
flattenSpecDocument()가 중첩 계층을 타입 구분자가 있는 단일 배열로 변환 - 윈도우 레벨 가상화:
useWindowVirtualizer가 브라우저 윈도우를 스크롤 컨테이너로 사용하여 중첩 스크롤 이슈 제거 - 지연 렌더링: React 18의
useDeferredValue가 필터/검색 작업 중 UI 블로킹 방지 - 낮춘 임계값: 100에서 30으로 감소하여 조기 최적화 활성화
- CSS 기반 그룹핑:
isLastInDomain플래그가 레이아웃 파괴 없이 시각적 분리 가능하게 함
라이브러리 선택
@tanstack/react-virtual 3.13.6
대안 대비 선택 이유:
- 페이지 레벨 스크롤을 위한 네이티브
useWindowVirtualizer훅 - 이미 의존성 트리에 포함 (다른 컴포넌트에서 사용 중)
- React 19 호환 (
useSyncExternalStore사용) - 알려진 React Compiler 호환성 이슈를
"use no memo"로 문서화 및 완화 (ADR-22)
고려한 옵션
옵션 A: 플랫 배열을 사용한 윈도우 레벨 가상화 (채택)
flattenSpecDocument()가 Document -> Domain[] -> Feature[] -> Behavior[]를 FlatItem[]으로 변환. 각 FlatItem은 타입 구분자 (domain-header, feature-header, behavior-row) 포함. useWindowVirtualizer가 윈도우 스크롤 위치를 사용하여 플랫 배열 가상화.
type FlatSpecItem = FlatSpecDomainItem | FlatSpecFeatureItem | FlatSpecBehaviorItem;
const flatItems = useMemo(() => flattenSpecDocument(document), [document]);
const virtualizer = useWindowVirtualizer({
count: flatItems.length,
estimateSize: (index) => getItemHeight(flatItems[index].type),
overscan: 5,
});
const deferredItems = useDeferredValue(virtualizer.getVirtualItems());| 장점 | 단점 |
|---|---|
| 단일 스크롤 컨테이너 (윈도우) | 플랫 배열 변환이 복잡도 추가 |
| 일반 CSS 레이아웃 유지 | 렌더 함수에서 타입 체킹 오버헤드 |
| React Compiler 호환 (escape hatch 사용) | estimateSize 캘리브레이션 필요 |
useDeferredValue가 UI 응답성 유지 | 플랫 배열의 메모리 오버헤드 |
옵션 B: 컨테이너 레벨 가상화
Domain/Feature 섹션별 복수의 useVirtualizer 인스턴스. 각 섹션이 자체 스크롤 컨테이너 보유.
| 장점 | 단점 |
|---|---|
| 섹션별 로직이 더 단순 | 복수 스크롤 컨테이너 (UX 분열) |
| 플랫 배열 변환 불필요 | position: absolute가 CSS 간격 파괴 |
| 카드 구조 붕괴 |
기각: UX 퇴행이 구현 단순성을 상회. 커밋 2c45796에서 명시적으로 이 패턴에서 마이그레이션.
옵션 C: 가상화 없이 페이지네이션
Domain/Feature를 페이지네이션 (예: 페이지당 10개 도메인). 현재 페이지 내 모든 항목을 가상화 없이 렌더링.
| 장점 | 단점 |
|---|---|
| 가장 단순한 구현 | 연속적 탐색 UX 파괴 |
| 가상화 오버헤드 없음 | 페이지 네비게이션 마찰 |
| 예측 가능한 메모리 사용 | O(n^3) 계산 이슈 미해결 |
기각: 스펙 문서는 연속적 계층으로 탐색됨; 페이지네이션이 사용자 경험 분절.
옵션 D: 스트리밍을 사용한 서버 사이드 렌더링
서버가 초기에 보이는 부분만 렌더링. 사용자 스크롤 시 추가 콘텐츠 스트리밍 (React Server Components + Suspense).
| 장점 | 단점 |
|---|---|
| 클라이언트 계산 제거 | 서버 측 스크롤 상태 필요 |
| 더 빠른 초기 페인트 | 스크롤 시 네트워크 지연 |
| 복잡한 SSR/클라이언트 하이드레이션 |
기각: 문제 대비 과도한 아키텍처; 클라이언트 측 가상화로 충분.
결과
긍정적
| 영역 | 이점 |
|---|---|
| 성능 | 1000+ behavior가 부드럽게 렌더링; 보이는 항목만 DOM에 존재 |
| UX 일관성 | 단일 페이지 스크롤; 통합된 스크롤 위치 |
| CSS 무결성 | 일반 플로우 레이아웃; 카드 구조 유지 |
| 응답성 | useDeferredValue가 필터링 중 입력 지연 방지 |
| 임계값 커버리지 | 30개 항목 임계값이 더 많은 실제 케이스 포착 |
부정적
| 영역 | 트레이드오프 | 완화 |
|---|---|---|
| 복잡도 | 플랫 배열 변환 로직 | flattenSpecDocument() 유틸리티에 격리 |
| React Compiler | "use no memo" 지시어 필요 | ADR-22에 문서화 |
| 타입 안전성 | 렌더에서 런타임 타입 구분 | TypeScript 축소를 사용한 완전 switch |
| 추정 정확도 | estimateSize 불일치로 지터 발생 | 실제 높이 측정; 패딩 버퍼 추가 |
기술적 함의
- 메모리 프로파일: 플랫 배열이 구조적 메타데이터를 복제하지만 DOM 노드를 n에서 ~20개 (뷰포트 크기)로 감소
- 스크롤 복원: 윈도우 스크롤 위치가 네비게이션 간 자동 유지 (브라우저 네이티브 동작)
- 검색/필터 통합: 필터 작업이 플랫 배열 업데이트;
useDeferredValue가 재가상화 지연
구현 상세
영향받는 파일
| 파일 | 목적 |
|---|---|
virtualized-document-view.tsx | 메인 윈도우 레벨 가상화 뷰 |
virtualized-behavior-list.tsx | 컨테이너 레벨 가상화 behavior 리스트 (이전 접근법, 임계값 30) |
flatten-spec-document.ts | 계층에서 플랫 배열로 변환 |
flat-spec-item.ts | 플랫 항목을 위한 discriminated union 타입 |
use-document-filter.ts | useDeferredValue를 사용한 문서 필터링 |
높이 추정 패턴
const getItemHeight = (item: FlatSpecItem): number => {
const baseHeight = item.type === "domain-header" ? 80 : item.type === "feature-header" ? 56 : 72;
return baseHeight + (item.isLastInDomain ? DOMAIN_GAP : 0);
};ScrollMargin 처리
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
setScrollMargin(listRef.current?.offsetTop ?? 0);
}, []);참조
- 71fce34: 메인 윈도우 가상화 구현
- a155369: 카드 간 누락된 간격 수정
- 9c41475: 도메인 카드 구조 복원
- 2c45796: 컨테이너에서 윈도우 스크롤로 마이그레이션
- ADR-22: React Compiler 도입 -
"use no memo"escape hatch 패턴 - ADR-04: TanStack Query 선택 - TanStack 에코시스템
- TanStack Virtual 문서
- React useDeferredValue
