실시간 대용량 자산 트레이딩 대시보드 도메인을 통해 React 성능 최적화를 Before → Profile → After 방식으로 직접 체감하는 실험 프로젝트.
| 범주 | 핵심 질문 | 지표 | 이 프로젝트 |
|---|---|---|---|
| 최초 로딩 | 첫 화면이 얼마나 빨리 뜨냐 | LCP, TTI | Phase 3 |
| 런타임 | 뜬 후 얼마나 부드럽게 동작하냐 | FPS, INP | Phase 1, 2, 5, 6 |
| 캐싱 | 반복 요청을 얼마나 줄이냐 | — | Phase 4 |
| 레이아웃 안정성 | 화면이 얼마나 안 튀느냐 | CLS | — |
| 체감 성능 | 빠르게 느껴지게 하는 UX 보완 | — | — |
레이아웃 안정성과 체감 성능은 별도 Phase로 추가 예정
npm install
npm run dev # http://localhost:5173src/
├── entities/asset/
│ └── types.ts # Asset 인터페이스 (updatedAt 포함)
├── shared/lib/
│ └── mockGenerator.ts # 1000개 자산 생성 + 이미지 URL 분기
├── store/
│ └── assetStore.ts # Zustand — 스트리밍 시뮬레이터 + 실험 파라미터
├── components/
│ ├── before/AssetRow.tsx # ❌ Phase 1 Before (memo 없음)
│ ├── after/AssetRow.tsx # ✅ Phase 1 After (React.memo)
│ ├── p2before/AssetRow.tsx # ❌ Phase 2 Before (width + background-color)
│ ├── p2after/AssetRow.tsx # ✅ Phase 2 After (transform + opacity)
│ └── StreamingControls.tsx # 주기 / 비율 / 참조 무결성 / 이미지 컨트롤
└── pages/
├── DashboardBefore.tsx # Phase 1 ❌
├── DashboardAfter.tsx # Phase 1 ✅
├── Phase2Before.tsx # Phase 2 ❌
├── Phase2After.tsx # Phase 2 ✅
├── Phase3Page.tsx # Phase 3 번들 최적화 실험
├── Phase4Page.tsx # Phase 4 캐싱 최적화 실험
├── Phase5Before.tsx # Phase 5 ❌ (1000행 전부 DOM 렌더)
├── Phase5After.tsx # Phase 5 ✅ (useVirtualizer — ~25행만 DOM 렌더)
├── Phase6Before.tsx # Phase 6 ❌ (inputValue state가 부모에 있음)
└── Phase6After.tsx # Phase 6 ✅ (SearchInput이 state 소유 + Enter-to-search)
| 파라미터 | 설명 | 실험 목적 |
|---|---|---|
| 업데이트 주기 | 50ms = 초당 20회 상태 변경 | 주기를 줄일수록 Before/After 차이가 극명해짐 |
| 업데이트 비율 | 1% = 1000개 중 약 10개만 실제 변경 | 나머지 990개는 값이 그대로인데도 리렌더되는지 확인 |
| 참조 무결성 깨기 | 값이 같아도 { ...asset } 으로 새 객체 반환 |
React.memo shallow compare 실패 원인 체험 |
"0.1초마다 10개 행만 바뀌는데, 왜 200개가 전부 리렌더될까?"
memo없음 → 부모가 리렌더되면 자식 200개 전체 리렌더onSelect가 렌더마다 새 함수 생성 → 설령 memo가 있어도 props가 바뀐 것으로 판단filteredAssets,totalVolume이 렌더마다 재계산
React.memo(AssetRow)→ props가 바뀐 행만 리렌더useCallback(onSelect)→ 함수 레퍼런스 안정, memo 효과 보존useMemo(filteredAssets, totalVolume)→ 의존값이 실제로 바뀔 때만 재계산
준비
- Chrome 확장 React Developer Tools 설치
- DevTools → Profiler 탭
- ⚙️ → "Record why each component rendered" 체크
실험 A: Before vs After
- Before 탭 선택 → Profiler ⏺ 녹화 시작
- 스트리밍 시작 → 3~5초 후 중단 → Profiler ⏹ 중지
- 불꽃 그래프에서
AssetRow200개가 전부 리렌더된 것 확인 - After 탭으로 전환해 동일 반복 → 변경된 행 몇 개만 리렌더 확인
각 컴포넌트에 마우스 올리면 "The parent component rendered" / "Props changed" 이유 표시
실험 B: memo가 있어도 실패하는 경우
- After 탭 유지 → "참조 무결성 깨기" 체크 ON
- 동일하게 Profiler 측정
- memo가 있어도 200개 전체 리렌더되는 것 확인
Before : memo ❌ → 전체 리렌더 (이유: 부모 리렌더)
After : memo ✅ → 변경 행만 리렌더
After + broken : memo ✅ + 새 객체 참조 → 전체 리렌더 (이유: shallow compare 실패)
콘솔로 수치 확인하기
after/AssetRow.tsx에 임시로 추가:
const AssetRow = memo(function AssetRow({ asset, onSelect }: Props) {
console.count(`render:${asset.id}`); // 추가
...Before는 스트리밍 1틱마다 200개 카운트가 올라가고, After는 실제 변경 항목만 올라간다.
"애니메이션 CSS 속성을 잘못 고르면 왜 브라우저가 버벅거릴까?"
브라우저가 화면을 그리는 단계: Layout → Paint → Composite
width,height,margin변경 → Layout부터 전부 다시 수행 (비쌈)background-color변경 → Paint부터 다시 수행transform,opacity변경 → Composite만 수행 (GPU 처리, 가장 저렴)
| CSS 속성 | 트리거 단계 | |
|---|---|---|
| ❌ 모멘텀 바 | width + transition: width |
Layout → Paint → Composite |
| ✅ 모멘텀 바 | transform: scaleX() + will-change: transform |
Composite only |
| ❌ 행 하이라이트 | background-color transition (tr 배경) |
Paint → Composite |
| ✅ 행 하이라이트 | opacity transition (별도 레이어) + will-change: opacity |
Composite only |
- DevTools → 오른쪽 상단
⋮→ More tools → Rendering - Paint flashing ON (Repaint 영역이 초록색으로 표시됨)
- 스트리밍 시작 → P2 Before 탭: 모멘텀 바마다 초록 영역이 깜빡임
- P2 After 탭으로 전환: 깜빡임 없음 (GPU 처리, 브라우저 Paint 없음)
"앱이 커질수록 첫 로딩이 느려지는데, 어떻게 줄일까?"
// 탭을 클릭하는 시점에 해당 청크만 다운로드
const Phase2Before = lazy(() => import("./pages/Phase2Before"))
<Suspense fallback={<div>로딩 중...</div>}>
{tab === "p2-before" && <Phase2Before />}
</Suspense>확인: DevTools → Network → JS 필터 → Phase 탭 처음 클릭 시 Phase2Before-[hash].js 파일 요청 확인
빌드 결과 (실제 청크 크기):
DashboardBefore-xxx.js 1.60 kB ← 탭 클릭 전까지 다운로드 안 됨
DashboardAfter-xxx.js 1.68 kB
Phase2Before-xxx.js 2.33 kB
Phase2After-xxx.js 2.85 kB
Phase3Page-xxx.js 3.60 kB
index-xxx.js 200.17 kB ← react-dom 포함 메인 번들
npm run build # dist/stats.html 자동 생성
open dist/stats.html트리맵으로 어떤 패키지가 번들을 얼마나 차지하는지 시각적으로 확인 가능. 청크가 비정상적으로 크면 추가 lazy 분리 대상.
빌드 시 실제로 사용된 코드만 번들에 포함. ES Module 방식이어야 작동함.
// ❌ Before — 전체 라이브러리 번들에 포함 (lodash ~70KB)
import _ from 'lodash'
const sorted = _.sortBy(assets, 'price')
// ✅ After — sortBy 함수만 포함
import { sortBy } from 'lodash-es'
const sorted = sortBy(assets, 'price')stats.html에서 lodash 전체 import 시 청크 크기가 급격히 커지는 것을 확인 가능.
Phase 3 탭의 토글로 직접 비교 가능.
| URL 쿼리스트링 | 이미지 1장 크기 | |
|---|---|---|
| ❌ Before | ?fit=crop&w=800&q=100 (JPEG 원본) |
~100–200 KB |
| ✅ After | ?auto=format&fm=webp&w=40&h=40&q=60 |
~1–3 KB |
확인: DevTools → Network → Img 필터 → Size 컬럼 비교
"같은 데이터를 반복 요청할 때마다 네트워크를 타야 할까?"
모달을 열면 자산 상세 API를 호출한다. 닫고 5초 안에 같은 자산을 다시 열면?
| 구현 | 두 번째 열기 | |
|---|---|---|
| ❌ Before | useEffect + fetch |
항상 500ms 로딩 |
| ✅ After | useQuery({ staleTime: 5000 }) |
즉시 표시 (캐시 히트) |
확인: 콘솔에서 "API 누적 호출 수" 숫자 비교. Before는 열 때마다 증가, After는 5초 내 재열기 시 증가 안 함.
"1000개 행을 DOM에 전부 그리면 뭐가 문제일까?"
브라우저는 DOM에 존재하는 모든 요소를 Layout/Paint 단계에서 처리한다. 화면에 보이는 건 15개뿐이어도 DOM에 1000개 <tr>이 있으면 그 비용을 전부 치른다.
가상화(Virtualization): 스크롤 위치를 기준으로 화면에 보이는 행 + 약간의 여유분만 DOM에 렌더하고, 나머지는 절대 좌표로 공간만 잡아둔다.
| DOM 행 수 | 스크롤 시 | |
|---|---|---|
| ❌ P5 Before | 1000개 항상 존재 | 전체 Layout 비용 |
| ✅ P5 After | ~15개 (overscan 포함 ~25개) | 보이는 것만 처리 |
const virtualizer = useVirtualizer({
count: assets.length, // 전체 항목 수
getScrollElement: () => parentRef.current,
estimateSize: () => 44, // 행 높이(px) 추정값
overscan: 5, // 화면 밖 위아래 5행 미리 렌더
});
// 전체 스크롤 공간 확보
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((row) => (
<div style={{ position: "absolute", top: row.start, height: row.size }}>
{/* 실제 데이터 렌더 */}
</div>
))}
</div>- Elements 탭: P5 Before → 스크롤 컨테이너 안 행 1000개 확인 / P5 After → ~25개만 존재 확인
- Performance 탭: 스크롤 시 Before는 Layout 이벤트 빈번 / After는 현저히 감소
- 화면 상단 DOM 카운터: P5 After 탭에서 스크롤해도 "DOM에 렌더된 행" 숫자가 일정하게 유지되는 것 확인
"Enter 눌러서 검색하는데 왜 타이핑할 때 인풋이 버벅일까?"
Enter-to-search 구조에서도 인풋 value state가 어디에 있느냐에 따라 타이핑 성능이 달라진다.
[Before] 부모가 inputValue state 소유
타이핑 → setInputValue → 부모 리렌더
→ 10,000개 행 reconciliation (데이터 안 바뀌어도)
→ 인풋에 글자 반영
리스트 데이터(filterQuery)는 Enter 전까지 변하지 않는다. 그런데도 부모가 리렌더되면 자식인 리스트 전체가 reconciliation을 거친다 — Phase 1에서 봤던 것과 같은 원리.
| 타이핑 시 리렌더 범위 | Enter 시 | |
|---|---|---|
| ❌ P6 Before | 부모 + 10,000행 reconciliation | 리스트 갱신 |
| ✅ P6 After | SearchInput 컴포넌트만 | 리스트 갱신 |
// ✅ SearchInput이 inputValue를 직접 소유
const SearchInput = memo(function SearchInput({ onSearch }) {
const [value, setValue] = useState(""); // 타이핑 시 이 컴포넌트만 리렌더
function handleKeyDown(e) {
if (e.key === "Enter") onSearch(value); // Enter 시에만 부모에 전달
}
return <input value={value} onChange={e => setValue(e.target.value)} onKeyDown={handleKeyDown} />;
});
// 부모는 filterQuery만 관리 — 타이핑 시 리렌더 없음
function Page() {
const [filterQuery, setFilterQuery] = useState("");
const filtered = useMemo(() => assets.filter(...), [filterQuery]);
return (
<>
<SearchInput onSearch={setFilterQuery} />
<BigList items={filtered} /> {/* Enter 시에만 리렌더 */}
</>
);
}- P6 Before 탭 → 검색창에 빠르게 타이핑 → 헤더의 "페이지 렌더: N회"가 타이핑마다 증가, "입력 지연 Xms" 표시
- P6 After 탭 → 같은 속도로 타이핑 → "페이지 렌더" 숫자 그대로, 입력 지연 1~2ms
- Enter 또는 검색 버튼 → 양쪽 모두 그 시점에만 리스트 갱신
차이가 미미하면 DevTools → Performance 탭 → CPU: 4x slowdown 적용 후 비교