다크 모드
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 넘는 메인 스레드 점유 / 프레임 끊김 / 레이아웃 재계산 / 미회수 DOM | 13.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(...)으로 한꺼번에 렌더링하면:
- 브라우저 메인 스레드가 2~3초간 멈추고(Long Task), 스크롤할 때 프레임이 뚝뚝 끊깁니다(Jank).
- 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와 가상 스크롤이 필요한 이유:- 10,000개 트리를 평탄화(
flattenCommentTree)하는 무거운 계산처럼 의도를 명시적으로 보장해야 할 때useMemo를 씁니다. - 메모이제이션은 리렌더링(두 번째 이후 렌더링) 비용만 줄여줄 뿐, 최초 마운트 시 10,000개의 실제 DOM 노드를 생성하는 브라우저 비용은 막아주지 못합니다. 이 문제를 근본적으로 해결하는 것이 바로 13.3~13.4의 **가상 스크롤(Windowing)**입니다.
- 10,000개 트리를 평탄화(
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 노드 개수와 렌더링 속도 측정]:
src/router/index.tsx에{ path: '/perf', element: <VirtualCommentDemo /> }를 추가하고http://localhost:3000/perf를 엽니다.- 개발자 도구 Console에서
document.querySelectorAll('[data-index]').length를 실행해 보십시오. 전체 댓글은 10,000개인데 실제 DOM에 존재하는 댓글 행은 딱 19개뿐입니다![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줄
- 10,000개의 계층형 댓글은
flattenCommentTree로 1차원 배열로 펼친 뒤, **@tanstack/react-virtual(useVirtualizer)**을 사용해 화면에 보이는 약 15~20개 행만 DOM에 렌더링합니다. - 자주 오가는 탭이나 패널은 React 19.3의 **
<Activity mode="hidden">**으로 감싸 로컬 상태와 스크롤 위치를 보존하면서 백그라운드 부하를 멈춥니다. window.addEventListener와setInterval같은 외부 자원은 반드시useEffect의 **클린업 함수(return () => ...)**에서 해제하여 메모리 누수를 원천 차단합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/practice/mockComments.ts | flattenCommentTree 평탄화 함수 및 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' 부여 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
itemHeight = 50,containerHeight = 500,buffer = 5이고scrollTop = 1000일 때startIndex,endIndex, 실제로 그리는 행 수,offsetY는 각각 얼마인가요?- 조건부 렌더링(
{isOpen && <Panel />}) 대신 React 19.3의<Activity mode={isOpen ? 'visible' : 'hidden'}><Panel /></Activity>를 쓰면 어떤 점이 달라지나요? React.memo를 썼는데도 10,000개 댓글을 처음 열 때 브라우저가 2초간 멈추는 이유는 무엇인가요?
셀프 체크 정답
startIndex = Math.floor(1000 / 50) - 5 = 15, 한 화면 행 수500 / 50 = 10,endIndex = 15 + 10 + 5 * 2 = 35, 실제 그리는 행 수는35 - 15 = 20개,offsetY = 15 * 50 = 750px입니다.{isOpen && <Panel />}는 닫힐 때 컴포넌트를 완전히 언마운트하여 입력 상태와 스크롤 위치가 초기화되지만,<Activity mode="hidden">은 상태와 DOM을 유지한 채 화면에서만 숨기고 내부 Effect만 일시 정지시킵니다.React.memo는 두 번째 이후의 리렌더링만 건너뛰게 해줄 뿐, 최초 마운트 시 10,000개의 컴포넌트를 실행하고 30,000개의 실제 DOM 노드를 브라우저에 생성하는 비용은 줄여주지 못하기 때문입니다(가상 스크롤이 필요한 이유).
다음 챕터로 이어지는 질문
이제 게시판은 10,000개의 댓글이 달려도 60fps로 부드럽게 스크롤되고, 화면을 오가도 메모리 누수가 발생하지 않습니다.
하지만 운영 환경에 배포하기 전 마지막 관문이 남아 있습니다.
"5장의 usePagination, 10장의 낙관적 업데이트 롤백, 12장의 토큰 갱신은 이미 단위 테스트로 검증했습니다. 그렇다면 실제 사용자처럼 화면에서 글을 검색하고, 폼에 입력해 새 글을 등록하고, 에러 시 롤백되는 전체 화면 시나리오는 백엔드 서버 없이 어떻게 자동으로 검증할 수 있을까요?"
대망의 마지막 Chapter 14에서는 @testing-library/react + userEvent + MSW v2를 결합해 게시판 화면 전체를 검증하는 통합 테스트 3종 시나리오와 Context 격리 테스트를 완성해 보겠습니다.