Skip to content

Chapter 13. 10,000개 댓글 렌더링 최적화와 가상 스크롤 & React 19.3 신기능 ​

"12장까지 다듬은 게시판에 10,000개의 댓글이 달리는 순간, 브라우저는 얼어붙었다. React Compiler와 React.memo로 불필요한 리렌더링을 차단하고, @tanstack/react-virtual 가상 스크롤과 React 19.3 <Activity>·View Transitions, 그리고 메모리 누수 방어로 극한의 60fps를 달성하자."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
Long Task, Jank, Reflow, Detached 노드50ms 넘는 메인 스레드 점유 / 프레임 끊김 / 레이아웃 재계산 / 미회수 DOM13.1
React Compiler & React.memo빌드 시점 자동 메모이제이션과 수동 Props 얕은 비교 최적화13.2 🆕 박스
계층형 트리 평탄화 (flattenCommentTree)재귀 댓글 트리를 depth가 포함된 1차원 배열로 펼치기13.3 [코드 13-1]
가상 스크롤(Windowing) 원리 (VirtualList.tsx)화면에 보이는 15개 행만 DOM에 그리고 나머지는 오프셋으로 대체13.3 [코드 13-2]
@tanstack/react-virtual (useVirtualizer)실무 표준 헤드리스 가상 스크롤 훅으로 10,000개 댓글 렌더링13.4 [코드 13-3]
React 19.3 <Activity mode="hidden"> & View Transitions언마운트 없이 백그라운드 상태로 숨기기 / 부드러운 화면 전환 애니메이션13.5 🆕 박스
SPA 3대 메모리 누수 방어 (useEffect 클린업)전역 리스너, 타이머, 외부 구독을 언마운트 시점에 해제하기13.6 [코드 13-5]

TIP

🏁 이 장의 시작 파일
frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch12.tsx와 같은 상태에서 시작합니다. 이 장에서 손대는 파일: frontend/src/components/common/VirtualList.tsx, frontend/src/practice/mockComments.ts, frontend/src/practice/VirtualCommentDemo.tsx, frontend/src/hooks/useBoardShortcuts.ts


13.1 10,000개 댓글과 브라우저의 한계 ​

인기 기술 글에 댓글이 10,000개 넘게 달렸습니다. 6장의 CommentItem.tsx를 일반적인 comments.map(...)으로 한꺼번에 렌더링하면:

  1. 브라우저 메인 스레드가 2~3초간 멈추고(Long Task), 스크롤할 때 프레임이 뚝뚝 끊깁니다(Jank).
  2. DOM 노드가 30,000개 이상 생기면서 레이아웃(Reflow)과 페인트 비용이 폭증합니다.

📖 용어 한 줄 정리

  • Long Task: 메인 스레드를 50ms 넘게 붙잡는 작업. 그동안 클릭·스크롤·타이핑이 전부 먹통이 됩니다.
  • Jank: 프레임이 제때(60fps 기준 16.7ms 이내) 그려지지 않아 화면이 뚝뚝 끊기는 현상.
  • Reflow(Layout): 요소의 크기와 위치를 다시 계산하는 브라우저 작업.
  • Detached DOM 노드: 화면(DOM 트리)에서는 떨어져 나갔는데 이벤트 리스너나 클로저가 참조를 놓아주지 않아 가비지 컬렉터(GC)가 회수하지 못한 유령 노드.

13.2 최적화 1: React Compiler 자동 메모이제이션과 React.memo ​

React는 기본적으로 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 함께 리렌더링합니다. 댓글이 10,000개일 때 부모의 검색어 상태 하나가 바뀌었다고 자식 10,000개가 다시 호출되면 재앙이 일어납니다.

🆕 처음 보는 문법: React 19 Compiler와 React.memo / useMemo의 관계

  • React.memo(Component): 이전 렌더링의 Props와 새 Props를 **얕은 비교(Shallow Compare)**하여 바뀌지 않았으면 자식 렌더링을 통째로 건너뜁니다.
  • React 19 Compiler (babel-plugin-react-compiler): Vite 빌드 파이프라인에 React Compiler 플러그인을 켜두면, 개발자가 일일이 useMemo, useCallback, React.memo를 손으로 감싸지 않아도 컴파일러가 빌드 시점에 의존성을 분석해 자동으로 메모이제이션 코드를 주입해 줍니다!
  • 그럼에도 useMemo와 가상 스크롤이 필요한 이유:
    1. 10,000개 트리를 평탄화(flattenCommentTree)하는 무거운 계산처럼 의도를 명시적으로 보장해야 할 때 useMemo를 씁니다.
    2. 메모이제이션은 리렌더링(두 번째 이후 렌더링) 비용만 줄여줄 뿐, 최초 마운트 시 10,000개의 실제 DOM 노드를 생성하는 브라우저 비용은 막아주지 못합니다. 이 문제를 근본적으로 해결하는 것이 바로 13.3~13.4의 **가상 스크롤(Windowing)**입니다.

13.3 최적화 2: 계층형 댓글 평탄화(Flattening)와 60줄짜리 VirtualList.tsx 원리 ​

사람의 눈은 한 화면에서 고작 10~15개 행만 볼 수 있습니다. **가상 스크롤(Virtual Scroll / Windowing)**의 핵심 아이디어는 단순하고 강력합니다:

"실제 DOM에는 지금 스크롤 위치에서 눈에 보이는 15개 행(+위아래 여유 버퍼)만 그리고, 나머지 9,985개 행은 전체 높이(totalHeight)와 translateY(offsetY) 위치 이동으로 눈속임한다!"

🎭 비유로 이해하는 가상 스크롤: 무대 위 배우 10명 vs 대기실의 10,000명 엑스트라
10,000명이 등장하는 초대형 뮤지컬을 상연한다고 상상해 봅시다. 무대(뷰포트)는 크기가 정해져 있어서 한 번에 배우 10명만 올라갈 수 있습니다. 그런데 무대 감독이 10,000명의 엑스트라를 좁은 무대 위에 억지로 다 올리면 무대 바닥이 폭삭 무너져 내립니다(브라우저 프리징).
가상 스크롤은 무대 위에는 지금 대사를 칠 10명의 배우만 올려두고, 관객이 시선을 돌릴(스크롤할) 때마다 무대 위 배우를 번개같이 바꿔치기합니다! 관객(사용자)은 10,000명이 다 서 있는 줄 알지만 실제 브라우저가 그리는 DOM은 깃털처럼 가볍습니다.

6장의 **계층형 댓글 트리(CommentNode[])**에 가상 스크롤을 적용하려면, 먼저 재귀 트리를 depth(들여쓰기 단계)가 포함된 1차원 배열로 평탄화(flattenCommentTree)해야 합니다.

[코드 13-1] 계층형 댓글 평탄화 및 10,000개 가짜 데이터 생성 (frontend/src/practice/mockComments.ts) ​

typescript
// frontend/src/practice/mockComments.ts
export interface CommentNode {
  id: number;
  parentId: number | null;
  author: string;
  content: string;
  createdAt: string;
  children?: CommentNode[];
}

export interface FlatComment extends CommentNode {
  depth: number;
}

// 1. 재귀 트리를 가상 스크롤용 1차원 배열로 평탄화하는 함수
export function flattenCommentTree(
  nodes: CommentNode[],
  depth = 0,
  result: FlatComment[] = []
): FlatComment[] {
  for (const node of nodes) {
    result.push({ ...node, depth });
    if (node.children && node.children.length > 0) {
      flattenCommentTree(node.children, depth + 1, result);
    }
  }
  return result;
}

// 2. 루트 댓글 2,000개 × (본인 1 + 대댓글 4단계) = 정확히 10,000개 트리 생성
export function createMockCommentTree(rootCount = 2000, depthPerRoot = 4): CommentNode[] {
  let nextId = 1;
  const roots: CommentNode[] = [];

  for (let r = 0; r < rootCount; r++) {
    const root: CommentNode = {
      id: nextId++,
      parentId: null,
      author: `사우${r + 1}`,
      content: `루트 기술 코멘트 #${r + 1} — 가상 스크롤 성능 실험 중입니다.`,
      createdAt: '2026-10-29 10:00',
      children: []
    };
    let parent = root;
    for (let d = 1; d <= depthPerRoot; d++) {
      const child: CommentNode = {
        id: nextId++,
        parentId: parent.id,
        author: `답글러${d}`,
        content: `${d}단계 대댓글 응답입니다.`,
        createdAt: '2026-10-29 10:05',
        children: []
      };
      parent.children!.push(child);
      parent = child;
    }
    roots.push(root);
  }
  return roots;
}

먼저 외부 라이브러리 없이 가상 스크롤의 수학적 원리를 100% 이해하기 위해 60줄짜리 VirtualList.tsx를 직접 작성해 봅니다:

[코드 13-2] 원리 이해용 60줄 자체 가상 스크롤 (frontend/src/components/common/VirtualList.tsx) ​

tsx
// frontend/src/components/common/VirtualList.tsx
import React, { useState, type UIEvent } from 'react';

export interface VirtualListProps<T extends { id: number | string }> {
  items: T[];
  itemHeight: number;      // 행당 고정 높이 (px)
  containerHeight: number; // 뷰포트 높이 (px)
  buffer?: number;         // 빠른 스크롤 시 흰 여백 방지를 위한 상하 여유 버퍼 개수
  renderItem: (item: T, index: number) => React.ReactNode;
}

export function VirtualList<T extends { id: number | string }>({
  items,
  itemHeight,
  containerHeight,
  buffer = 5,
  renderItem
}: VirtualListProps<T>) {
  const [scrollTop, setScrollTop] = useState(0);

  function handleScroll(e: UIEvent<HTMLDivElement>) {
    setScrollTop(e.currentTarget.scrollTop);
  }

  // 1. 현재 scrollTop 위치에서 렌더링을 시작할 인덱스와 끝낼 인덱스 계산
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const endIndex = Math.min(items.length, startIndex + visibleCount + buffer * 2);

  // 2. 실제로 DOM에 그릴 소수의 조각(약 19개)만 추출
  const visibleItems = items.slice(startIndex, endIndex);

  // 3. 전체 스크롤바 높이와 현재 조각이 위치할 Y축 오프셋 계산
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      className="virtual-scroll-container"
      style={{ height: `${containerHeight}px`, overflowY: 'auto', position: 'relative' }}
      onScroll={handleScroll}
    >
      <div style={{ height: `${totalHeight}px`, width: '100%', position: 'relative' }}>
        <div
          style={{
            transform: `translateY(${offsetY}px)`,
            willChange: 'transform'
          }}
        >
          {visibleItems.map((item, idx) => (
            <div
              key={item.id}
              className="virtual-item-wrapper"
              style={{ height: `${itemHeight}px`, boxSizing: 'border-box' }}
            >
              {renderItem(item, startIndex + idx)}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

13.4 [실습] 실무 표준 @tanstack/react-virtual (useVirtualizer)로 10,000개 댓글 렌더링 ​

[코드 13-2]로 원리를 파악했다면, 실무 프로덕션에서는 가변 높이 측정(ResizeObserver), 키보드 스크롤, 스크롤 점프(scrollToIndex)까지 완벽히 지원하는 업계 표준 라이브러리 **@tanstack/react-virtual**의 useVirtualizer 훅을 사용합니다.

bash
npm install @tanstack/react-virtual

[코드 13-3] @tanstack/react-virtual 기반 10,000개 댓글 데모 (frontend/src/practice/VirtualCommentDemo.tsx) ​

tsx
// frontend/src/practice/VirtualCommentDemo.tsx
import React, { useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { createMockCommentTree, flattenCommentTree } from './mockComments';

export default function VirtualCommentDemo() {
  // 1. 10,000개 계층형 댓글 트리를 생성하고 1차원 배열로 평탄화 (useMemo로 캐싱)
  const [commentTree] = useState(() => createMockCommentTree(2000, 4));
  const flatComments = useMemo(() => flattenCommentTree(commentTree), [commentTree]);

  // 2. 스크롤 컨테이너 DOM을 가리킬 ref
  const parentRef = useRef<HTMLDivElement | null>(null);

  // 🌟 3. TanStack Virtual의 useVirtualizer 설정
  const rowVirtualizer = useVirtualizer({
    count: flatComments.length,       // 전체 아이템 수 (10,000개)
    getScrollElement: () => parentRef.current,
    estimateSize: () => 56,           // 행당 기본 높이 56px
    overscan: 5                       // 상하 여유 버퍼 5개
  });

  const virtualItems = rowVirtualizer.getVirtualItems();

  return (
    <section className="virtual-demo-section">
      <header className="demo-header">
        <h2>🚀 댓글 {flatComments.length.toLocaleString()}개 가상 스크롤 실험 (@tanstack/react-virtual)</h2>
        <div className="jump-buttons">
          <button type="button" onClick={() => rowVirtualizer.scrollToIndex(0)}>
            맨 위로
          </button>
          <button type="button" onClick={() => rowVirtualizer.scrollToIndex(4999, { align: 'center' })}>
            5,000번째 댓글로 점프!
          </button>
          <button type="button" onClick={() => rowVirtualizer.scrollToIndex(9999)}>
            맨 끝(10,000번)으로
          </button>
        </div>
      </header>

      {/* 스크롤 뷰포트 컨테이너 */}
      <div
        ref={parentRef}
        className="virtual-scroll-viewport"
        style={{
          height: '480px',
          overflowY: 'auto',
          border: '1px solid #cbd5e1',
          borderRadius: '8px',
          position: 'relative'
        }}
      >
        {/* 전체 가상 높이를 잡아주는 내부 팬텀 영역 (10,000 * 56px = 560,000px) */}
        <div
          style={{
            height: `${rowVirtualizer.getTotalSize()}px`,
            width: '100%',
            position: 'relative'
          }}
        >
          {/* 실제로 화면에 보이는 약 19개의 가상 행만 절대 위치(translateY)로 렌더링! */}
          {virtualItems.map((virtualRow) => {
            const item = flatComments[virtualRow.index];
            return (
              <div
                key={item.id}
                data-index={virtualRow.index}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  height: `${virtualRow.size}px`,
                  transform: `translateY(${virtualRow.start}px)`,
                  paddingLeft: `${16 + item.depth * 20}px`,
                  display: 'flex',
                  alignItems: 'center',
                  boxSizing: 'border-box',
                  borderBottom: '1px solid #f1f5f9',
                  whiteSpace: 'nowrap',
                  overflow: 'hidden',
                  textOverflow: 'ellipsis'
                }}
              >
                {item.depth > 0 && <span style={{ color: '#94a3b8', marginRight: 6 }}>↳</span>}
                <strong>[{item.id}] {item.author}</strong>
                <span style={{ marginLeft: 8 }}>{item.content}</span>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

💡 [확인하기 - DOM 노드 개수와 렌더링 속도 측정]:

  1. src/router/index.tsx에 { path: '/perf', element: <VirtualCommentDemo /> }를 추가하고 http://localhost:3000/perf를 엽니다.
  2. 개발자 도구 Console에서 document.querySelectorAll('[data-index]').length를 실행해 보십시오. 전체 댓글은 10,000개인데 실제 DOM에 존재하는 댓글 행은 딱 19개뿐입니다!
  3. [5,000번째 댓글로 점프!] 버튼을 누르거나 스크롤바를 위아래로 격렬하게 흔들어 보십시오. 단 1ms의 버벅임도 없이 60fps로 매끄럽게 동작합니다.

13.5 최적화 3: React 19.3 <Activity mode="hidden">와 View Transitions ​

탭을 전환하거나 상세 패널을 닫았다가 다시 열 때, 이전 스크롤 위치와 입력 중이던 로컬 상태를 잃지 않으면서도 백그라운드 성능을 아끼고 싶다면 React 19.3의 <Activity> 컴포넌트와 브라우저 표준 View Transitions API를 결합합니다.

🆕 처음 보는 문법: React 19.3 <Activity>와 document.startViewTransition

  • <Activity mode={isVisible ? 'visible' : 'hidden'}>:
    • 기존 조건부 렌더링({isVisible && <CommentPanel />})은 false가 되는 순간 컴포넌트를 완전히 언마운트하여 로컬 상태와 DOM을 파괴합니다.
    • 반대로 <Activity mode="hidden">은 컴포넌트 상태와 DOM을 보존한 채 화면에서만 숨기고(display: none), 내부 useEffect(타이머·구독)는 일시 정지(클린업 실행)시키며 리렌더링 우선순위를 최하위로 낮춥니다! (Vue의 <KeepAlive>보다 한 단계 더 진화한 React 19.3 핵심 기능입니다.)
  • document.startViewTransition(() => { startTransition(() => setTab(...)) }):
    • 상태 변경 전후의 화면 스냅샷을 브라우저가 캡처하여 별도 애니메이션 라이브러리 없이도 부드러운 크로스페이드(Cross-fade) 화면 전환을 만들어 줍니다.

[코드 13-4] <Activity>와 View Transition 적용 예시 ​

tsx
// frontend/src/practice/ActivityTabDemo.tsx
import React, { Activity, useState, startTransition } from 'react';
import VirtualCommentDemo from './VirtualCommentDemo';

export function ActivityTabDemo() {
  const [activeTab, setActiveTab] = useState<'board' | 'comments'>('comments');

  function switchTabWithTransition(nextTab: 'board' | 'comments') {
    const update = () => {
      startTransition(() => {
        setActiveTab(nextTab);
      });
    };

    // 브라우저가 View Transitions API를 지원하면 부드러운 화면 전환 적용
    if (typeof document !== 'undefined' && 'startViewTransition' in document) {
      (document as any).startViewTransition(update);
    } else {
      update();
    }
  }

  return (
    <div>
      <div className="tab-buttons">
        <button type="button" onClick={() => switchTabWithTransition('comments')}>
          💬 대용량 댓글 탭 (스크롤 위치 보존)
        </button>
        <button type="button" onClick={() => switchTabWithTransition('board')}>
          📋 공지사항 안내 탭
        </button>
      </div>

      {/* 🌟 mode="hidden"일 때도 VirtualCommentDemo의 스크롤 위치와 상태가 파괴되지 않고 유지됨! */}
      <Activity mode={activeTab === 'comments' ? 'visible' : 'hidden'}>
        <VirtualCommentDemo />
      </Activity>

      <Activity mode={activeTab === 'board' ? 'visible' : 'hidden'}>
        <div className="notice-box">다른 탭에 다녀와도 댓글 탭의 스크롤 위치가 그대로 남아 있습니다!</div>
      </Activity>
    </div>
  );
}

13.6 최적화 4: SPA의 3대 메모리 누수(Memory Leak) 방어 ​

SPA는 화면을 이동해도 브라우저 탭의 메모리를 초기화하지 않습니다. 컴포넌트가 언마운트될 때(useEffect의 return () => ... 클린업 함수) 뒷정리를 빠뜨리면 몇 시간 뒤 브라우저 탭이 느려지다 죽어버립니다.

#3대 메모리 누수 패턴컴포넌트를 붙잡는 주체useEffect 클린업 해결책
1전역 이벤트 리스너 (window.addEventListener('keydown', ...))window 전역 객체return () => window.removeEventListener(...)
2타이머 (setInterval, setTimeout)브라우저 타이머 큐return () => clearInterval(timerId)
3외부 구독·인스턴스 (WebSocket, 차트/에디터 인스턴스, IntersectionObserver)소켓·옵저버 인스턴스return () => { socket.close(); observer.disconnect(); }

[코드 13-5] 전역 단축키(Alt+R) + 5분 자동 갱신 훅 (frontend/src/hooks/useBoardShortcuts.ts) ​

typescript
// frontend/src/hooks/useBoardShortcuts.ts
import { useEffect } from 'react';

export function useBoardShortcuts(onRefresh: () => void) {
  useEffect(() => {
    const handleKeydown = (e: KeyboardEvent) => {
      if (e.altKey && e.key.toLowerCase() === 'r') {
        e.preventDefault();
        onRefresh();
      }
    };

    // 1. 전역 키보드 리스너 등록 & 5분(300,000ms) 주기 자동 폴링 타이머 시작
    window.addEventListener('keydown', handleKeydown);
    const pollTimer = window.setInterval(onRefresh, 300_000);

    // 🌟 2. 클린업(Cleanup) 함수: 컴포넌트 언마운트(또는 <Activity mode="hidden"> 전환) 시 즉시 해제!
    return () => {
      window.removeEventListener('keydown', handleKeydown);
      window.clearInterval(pollTimer);
    };
  }, [onRefresh]);
}

13.7 [직접 해보기] 실습 미션 💪 ​

🎯 미션: useBoardShortcuts가 언마운트 시 리스너를 완벽히 제거하는지 테스트하기 ​

@testing-library/react의 renderHook을 사용하여, 훅이 마운트된 동안에는 Alt + R 키 입력 시 onRefresh가 1번 호출되고, unmount()된 이후에는 Alt + R을 아무리 눌러도 onRefresh가 추가로 호출되지 않는지 검증하는 단위 테스트를 작성해 보십시오.

💡 미션 정답 코드 확인하기
typescript
// frontend/tests/unit/useBoardShortcuts.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useBoardShortcuts } from '@/hooks/useBoardShortcuts';

describe('useBoardShortcuts 메모리 누수 방지 검증', () => {
  it('마운트 중에는 Alt+R에 반응하고, 언마운트 후에는 리스너가 제거되어 반응하지 않는다', () => {
    const onRefresh = vi.fn();
    const { unmount } = renderHook(() => useBoardShortcuts(onRefresh));

    // 1. 마운트 상태: Alt+R 이벤트 발생 시 호출됨
    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', altKey: true }));
    expect(onRefresh).toHaveBeenCalledTimes(1);

    // 2. 언마운트 실행 (클린업 동작)
    unmount();

    // 3. 언마운트 후 다시 Alt+R을 눌러도 호출 횟수가 늘어나지 않아야 함!
    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', altKey: true }));
    expect(onRefresh).toHaveBeenCalledTimes(1);
  });
});

13.8 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 3줄

  1. 10,000개의 계층형 댓글은 flattenCommentTree로 1차원 배열로 펼친 뒤, **@tanstack/react-virtual(useVirtualizer)**을 사용해 화면에 보이는 약 15~20개 행만 DOM에 렌더링합니다.
  2. 자주 오가는 탭이나 패널은 React 19.3의 **<Activity mode="hidden">**으로 감싸 로컬 상태와 스크롤 위치를 보존하면서 백그라운드 부하를 멈춥니다.
  3. window.addEventListener와 setInterval 같은 외부 자원은 반드시 useEffect의 **클린업 함수(return () => ...)**에서 해제하여 메모리 누수를 원천 차단합니다.

바뀐 파일

파일무엇이 바뀌었나
src/practice/mockComments.tsflattenCommentTree 평탄화 함수 및 10,000개 가짜 댓글 생성기
src/components/common/VirtualList.tsx가상 스크롤 수학 원리 학습용 60줄 제네릭 컴포넌트
src/practice/VirtualCommentDemo.tsx@tanstack/react-virtual useVirtualizer 기반 10,000개 댓글 뷰
src/hooks/useBoardShortcuts.ts단축키·타이머 등록 및 언마운트 클린업 Custom Hook

자주 하는 실수

증상원인해결
가상 스크롤 적용 후 스크롤할 때 행끼리 글자가 겹침긴 댓글이 2줄로 줄바꿈되면서 고정 높이(56px) 가정과 어긋남한 줄 말줄임(whiteSpace: 'nowrap', textOverflow: 'ellipsis')을 주거나 measureElement 동적 측정 사용
화면을 몇 번 왔다 갔다 했더니 Alt+R 한 번에 API 요청이 5번씩 나감useEffect에서 removeEventListener 클린업을 빼먹음반드시 return () => window.removeEventListener(...) 반환
useVirtualizer가 아무 행도 안 그림 (virtualItems가 빈 배열)스크롤 컨테이너에 고정 높이(height: '480px')나 ref={parentRef}를 빠뜨림뷰포트 div에 ref={parentRef}와 명시적인 height, overflowY: 'auto' 부여

셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.

  1. itemHeight = 50, containerHeight = 500, buffer = 5이고 scrollTop = 1000일 때 startIndex, endIndex, 실제로 그리는 행 수, offsetY는 각각 얼마인가요?
  2. 조건부 렌더링({isOpen && <Panel />}) 대신 React 19.3의 <Activity mode={isOpen ? 'visible' : 'hidden'}><Panel /></Activity>를 쓰면 어떤 점이 달라지나요?
  3. React.memo를 썼는데도 10,000개 댓글을 처음 열 때 브라우저가 2초간 멈추는 이유는 무엇인가요?
셀프 체크 정답
  1. startIndex = Math.floor(1000 / 50) - 5 = 15, 한 화면 행 수 500 / 50 = 10, endIndex = 15 + 10 + 5 * 2 = 35, 실제 그리는 행 수는 35 - 15 = 20개, offsetY = 15 * 50 = 750px입니다.
  2. {isOpen && <Panel />}는 닫힐 때 컴포넌트를 완전히 언마운트하여 입력 상태와 스크롤 위치가 초기화되지만, <Activity mode="hidden">은 상태와 DOM을 유지한 채 화면에서만 숨기고 내부 Effect만 일시 정지시킵니다.
  3. React.memo는 두 번째 이후의 리렌더링만 건너뛰게 해줄 뿐, 최초 마운트 시 10,000개의 컴포넌트를 실행하고 30,000개의 실제 DOM 노드를 브라우저에 생성하는 비용은 줄여주지 못하기 때문입니다(가상 스크롤이 필요한 이유).

다음 챕터로 이어지는 질문 ​

이제 게시판은 10,000개의 댓글이 달려도 60fps로 부드럽게 스크롤되고, 화면을 오가도 메모리 누수가 발생하지 않습니다.
하지만 운영 환경에 배포하기 전 마지막 관문이 남아 있습니다.
"5장의 usePagination, 10장의 낙관적 업데이트 롤백, 12장의 토큰 갱신은 이미 단위 테스트로 검증했습니다. 그렇다면 실제 사용자처럼 화면에서 글을 검색하고, 폼에 입력해 새 글을 등록하고, 에러 시 롤백되는 전체 화면 시나리오는 백엔드 서버 없이 어떻게 자동으로 검증할 수 있을까요?"
대망의 마지막 Chapter 14에서는 @testing-library/react + userEvent + MSW v2를 결합해 게시판 화면 전체를 검증하는 통합 테스트 3종 시나리오와 Context 격리 테스트를 완성해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub