Skip to content

ADR-23: 윈도우 레벨 가상화 패턴

🇺🇸 English Version

날짜작성자레포
2026-01-19@specvitalweb

컨텍스트

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를 사용한 윈도우 레벨 가상화와 플랫 배열 변환 및 지연 렌더링 채택.

핵심 구현

  1. 플랫 배열 변환: flattenSpecDocument()가 중첩 계층을 타입 구분자가 있는 단일 배열로 변환
  2. 윈도우 레벨 가상화: useWindowVirtualizer가 브라우저 윈도우를 스크롤 컨테이너로 사용하여 중첩 스크롤 이슈 제거
  3. 지연 렌더링: React 18의 useDeferredValue가 필터/검색 작업 중 UI 블로킹 방지
  4. 낮춘 임계값: 100에서 30으로 감소하여 조기 최적화 활성화
  5. 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가 윈도우 스크롤 위치를 사용하여 플랫 배열 가상화.

typescript
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.tsuseDeferredValue를 사용한 문서 필터링

높이 추정 패턴

typescript
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 처리

typescript
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
  setScrollMargin(listRef.current?.offsetTop ?? 0);
}, []);

참조

Open-source test coverage insights