이 글은 Vercel에서 작성한 React Best Practice와 소스 코드를 보며 톺아보는 글입니다.
AI에게 "이 코드를 React Best Practice에 맞게 리팩토링해줘"라고 요청할 수도 있지만,
왜 그 코드가 더 나은지, 어떤 원리로 성능이 개선되는지 이해하는 것이 중요합니다.
이 시리즈에서는 Vercel이 정의한 8가지 카테고리를 하나씩 깊이 있게 살펴봅니다.
각 Best Practice의 이론적 배경, 실제 성능 차이, 적용 시점을 중심으로 분석합니다.
이 글은 sections에서 소개하는 8가지 섹션 중 6번째 섹션인 Rendering Performance를 다룹니다.
Vercel React Best Practices 구조
Vercel은 React 성능 최적화를 Impact 수준에 따라 8개 섹션으로 분류했습니다:
- Eliminating Waterfalls (CRITICAL)
- Bundle Size Optimization (CRITICAL)
- Server-Side Performance (HIGH)
- Client-Side Data Fetching (MEDIUM-HIGH)
- Re-render Optimization (MEDIUM)
- Rendering Performance (MEDIUM)
- JavaScript Performance (LOW-MEDIUM)
- Advanced Patterns (LOW)
6. Rendering Performance: 브라우저 렌더링 최적화
렌더링 성능이 중요한 이유
React가 Virtual DOM을 업데이트한 후, 브라우저는 실제 DOM을 렌더링해야 합니다. 이 과정에는:
- Layout 계산: 요소의 크기와 위치 결정
- Paint: 픽셀을 화면에 그리기
- Composite: 레이어를 합성
렌더링 성능 최적화의 핵심은:
- 브라우저 작업 최소화: 불필요한 Layout/Paint 방지
- GPU 활용: 하드웨어 가속 활성화
- 렌더링 연기: 화면 밖 컨텐츠는 나중에 처리
6-1. 긴 목록에 CSS content-visibility 사용 (HIGH)
Impact: HIGH (더 빠른 초기 렌더링)
content-visibility: auto를 적용하여 화면 밖 렌더링을 연기하세요.
문제: 1000개 메시지 모두 렌더링
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
<div key={msg.id} className="p-4 border-b">
<Avatar user={msg.author} />
<div>{msg.content}</div>
<Timestamp date={msg.createdAt} />
</div>
))}
</div>
)
}
// 1000개 메시지 모두 Layout + Paint
브라우저 작업:
메시지 1: Layout 계산 + Paint
메시지 2: Layout 계산 + Paint
메시지 3: Layout 계산 + Paint
...
메시지 1000: Layout 계산 + Paint
총 시간: ~3000ms
화면에 보이는 건 10개뿐인데, 990개의 화면 밖 요소도 모두 렌더링합니다.
브라우저 렌더링 파이프라인
DOM 변경
↓
Recalculate Style (CSS 계산)
↓
Layout (크기/위치 계산) ← 비용 큼!
↓
Paint (픽셀 그리기) ← 비용 큼!
↓
Composite (레이어 합성)
↓
화면 표시
content-visibility는 Layout과 Paint를 건너뛰게 합니다.
개선: CSS로 화면 밖 렌더링 연기
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
<div key={msg.id} className="message-item p-4 border-b">
<Avatar user={msg.author} />
<div>{msg.content}</div>
<Timestamp date={msg.createdAt} />
</div>
))}
</div>
)
}
브라우저 작업:
화면 안 메시지 (10개):
메시지 1-10: Layout + Paint
화면 밖 메시지 (990개):
메시지 11-1000: 건너뜀! ✅
총 시간: ~300ms (10배 빠름)
content-visibility의 작동 원리
content-visibility: auto:
- 브라우저에게 "이 요소가 화면 밖에 있으면 렌더링 건너뛰어도 돼"라고 알림
- 요소가 뷰포트에 들어오면 자동으로 렌더링
- JavaScript 없이 순수 CSS로 가상 스크롤 효과
contain-intrinsic-size:
- 렌더링되지 않은 요소의 예상 크기 지정
- 스크롤바 위치 계산에 필요
0 80px= 너비는 자동, 높이는 80px
실전 예시: 다양한 높이
/* 고정 높이 */
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 100px;
}
/* 가변 높이 (평균값 사용) */
.comment-item {
content-visibility: auto;
contain-intrinsic-size: 0 150px; /* 평균 높이 */
}
/* 이미지 카드 */
.image-card {
content-visibility: auto;
contain-intrinsic-size: 0 400px; /* 이미지 높이 */
}
성능 측정
// Before: content-visibility 없음
// Chrome DevTools Performance:
// - Total Rendering: 3200ms
// - Layout: 2800ms
// - Paint: 400ms
// After: content-visibility 적용
// Chrome DevTools Performance:
// - Total Rendering: 350ms
// - Layout: 280ms (10개만)
// - Paint: 70ms (10개만)
언제 사용하나?
✅ 효과적:
- 긴 피드/목록 (100+ 항목)
- 채팅 메시지
- 댓글 섹션
- 테이블 행
- 검색 결과
❌ 비효율적:
- 짧은 목록 (< 50 항목)
- 모든 항목이 화면 안
- 복잡한 레이아웃 (Grid, Flexbox with wrap)
- 고정 높이가 아닌 경우 (스크롤 점프 발생 가능)
주의사항
// ❌ 나쁨: 부모에 적용
<div style={{ contentVisibility: 'auto' }}>
{messages.map(m => <Message />)}
</div>
// ✅ 좋음: 각 항목에 적용
{messages.map(m => (
<div style={{ contentVisibility: 'auto' }}>
<Message />
</div>
))}
6-2. 깜빡임 없이 하이드레이션 불일치 방지 (MEDIUM)
Impact: MEDIUM (시각적 깜빡임과 하이드레이션 에러 방지)
클라이언트 사이드 저장소(localStorage, 쿠키)에 의존하는 컨텐츠를 렌더링할 때, React가 하이드레이션하기 전에 DOM을 업데이트하는 동기 스크립트를 주입하세요.
문제 1: SSR 깨짐
function ThemeWrapper({ children }: { children: ReactNode }) {
// localStorage는 서버에서 사용 불가 - 에러 발생
const theme = localStorage.getItem('theme') || 'light'
return (
<div className={theme}>
{children}
</div>
)
}
에러:
ReferenceError: localStorage is not defined
서버에서는 localStorage가 없습니다.
문제 2: 시각적 깜빡임
function ThemeWrapper({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light')
useEffect(() => {
// 하이드레이션 후 실행 - 깜빡임 발생
const stored = localStorage.getItem('theme')
if (stored) {
setTheme(stored)
}
}, [])
return (
<div className={theme}>
{children}
</div>
)
}
사용자 경험:
1. 서버 HTML: <div class="light"> (기본값)
2. 화면 표시: 흰색 배경 (0.1초)
3. useEffect 실행: theme = 'dark'
4. 리렌더링: <div class="dark">
5. 화면 깜빡: 흰색 → 검은색 (깜빡임!)
하이드레이션 타임라인
서버:
1. React 렌더링 → HTML
2. HTML 전송
브라우저:
3. HTML 파싱 → 화면 표시
4. JavaScript 다운로드
5. React 하이드레이션 시작
6. useEffect 실행 ← 여기서 localStorage 읽음 (늦음!)
7. 상태 업데이트 → 리렌더링
문제: 6번 시점에 이미 화면이 표시되어 사용자가 깜빡임을 봄.
개선: 깜빡임 없음, 하이드레이션 불일치 없음
function ThemeWrapper({ children }: { children: ReactNode }) {
return (
<>
<div id="theme-wrapper">
{children}
</div>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme') || 'light';
var el = document.getElementById('theme-wrapper');
if (el) el.className = theme;
} catch (e) {}
})();
`,
}}
/>
</>
)
}
타임라인:
브라우저:
1. HTML 파싱
2. <div id="theme-wrapper"> 생성
3. <script> 실행 ← 즉시! (하이드레이션 전)
- localStorage 읽기
- className 설정
4. 화면 표시 (이미 올바른 테마!)
5. React 하이드레이션 (변경 없음)
깜빡임 없음!
동작 원리
인라인 스크립트의 특징:
- HTML 파싱 중 동기적으로 실행
- React 하이드레이션 전에 실행
- DOM을 직접 조작 가능
- 화면 표시 전에 적용됨
<div id="theme-wrapper">
<h1>Hello</h1>
</div>
<script>
// 이 시점에 이미 DOM 존재
// 하지만 화면에는 아직 표시 안 됨
document.getElementById('theme-wrapper').className = 'dark';
// 이제 화면에 표시 → 처음부터 dark
</script>
실전 예시: 인증 상태
function AuthWrapper({ children }: { children: ReactNode }) {
return (
<>
<div id="auth-wrapper" data-authenticated="false">
{children}
</div>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var token = localStorage.getItem('auth_token');
var el = document.getElementById('auth-wrapper');
if (el && token) {
el.setAttribute('data-authenticated', 'true');
}
} catch (e) {}
})();
`,
}}
/>
</>
)
}
언제 사용하나?
✅ 적합:
- 테마 토글
- 사용자 설정
- 인증 상태
- 언어 설정
- 클라이언트 전용 데이터
❌ 부적합:
- 서버 데이터 (API 응답)
- SEO 중요 컨텐츠
- 동적으로 자주 변경되는 값
주의사항
- try-catch로 감싸기 (localStorage 접근 실패 가능)
- XSS 방지: 사용자 입력 삽입 금지
- 코드 최소화: 스크립트 크기 작게 유지
6-3. 표시/숨김에 Activity 컴포넌트 사용 (MEDIUM)
Impact: MEDIUM (상태/DOM 보존)
React의 <Activity>를 사용하여 자주 토글되는 비용 큰 컴포넌트의 상태/DOM을 보존하세요.
문제: 매번 재생성
function Dropdown({ isOpen }: Props) {
return (
<div>
{isOpen && <ExpensiveMenu />}
</div>
)
}
실행 흐름:
isOpen: false → true
→ ExpensiveMenu 마운트
→ 초기화 (비용 큼)
→ 렌더링
isOpen: true → false
→ ExpensiveMenu 언마운트
→ 상태 손실
→ DOM 제거
isOpen: false → true
→ ExpensiveMenu 다시 마운트 (처음부터!)
→ 초기화 재실행 (낭비!)
개선: DOM과 상태 보존
import { Activity } from 'react'
function Dropdown({ isOpen }: Props) {
return (
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<ExpensiveMenu />
</Activity>
)
}
실행 흐름:
isOpen: false → true
→ ExpensiveMenu 마운트
→ 초기화
→ mode='visible' (표시)
isOpen: true → false
→ mode='hidden' (숨김)
→ DOM 유지! ✅
→ 상태 유지! ✅
isOpen: false → true
→ mode='visible' (표시)
→ 초기화 재실행 없음! ✅
Activity vs 조건부 렌더링
// 조건부 렌더링 (언마운트/마운트)
{isOpen && <Component />}
// Activity (숨김/표시)
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<Component />
</Activity>
차이점:
| 특징 | 조건부 렌더링 | Activity |
|---|---|---|
| DOM | 제거/재생성 | 유지 |
| 상태 | 손실 | 보존 |
| 초기화 | 매번 | 한 번만 |
| 메모리 | 적음 | 많음 (DOM 유지) |
언제 Activity를 사용하나?
✅ 사용:
- 드롭다운 메뉴 (복잡한 내부 상태)
- 모달 (폼 입력 보존)
- 탭 패널 (스크롤 위치 유지)
- 토글이 빈번한 컴포넌트
- 초기화 비용이 큰 컴포넌트
❌ 사용 안 함:
- 단순 컴포넌트
- 상태 없는 컴포넌트
- 초기화가 저렴한 경우
- 메모리 제약이 있을 때
실전 예시: 탭
function TabPanel({ activeTab }: { activeTab: string }) {
return (
<div>
<Activity mode={activeTab === 'profile' ? 'visible' : 'hidden'}>
<ProfileTab />
</Activity>
<Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
<SettingsTab />
</Activity>
<Activity mode={activeTab === 'billing' ? 'visible' : 'hidden'}>
<BillingTab />
</Activity>
</div>
)
}
탭 전환 시:
- 이전 탭:
mode='hidden'(DOM 유지, 보이지 않음) - 새 탭:
mode='visible'(즉시 표시) - 스크롤 위치, 폼 입력 모두 보존
6-4. 명시적 조건부 렌더링 (LOW)
Impact: LOW ("0" 또는 NaN 렌더링 방지)
조건이 0, NaN, 또는 렌더링되는 다른 falsy 값일 수 있을 때는 && 대신 명시적 삼항 연산자(? :)를 사용하세요.
문제: 0이 화면에 표시됨
function Badge({ count }: { count: number }) {
return (
<div>
{count && <span className="badge">{count}</span>}
</div>
)
}
// count = 0일 때 렌더링: <div>0</div>
// count = 5일 때 렌더링: <div><span class="badge">5</span></div>
왜 0이 렌더링되나?
JavaScript의 && 연산자는 첫 번째 falsy 값을 반환합니다:
0 && <Component /> // → 0 (falsy)
5 && <Component /> // → <Component /> (truthy)
// React는 0을 렌더링함!
// false, null, undefined만 렌더링 안 함
React의 렌더링 규칙
{false} // 렌더링 안 됨
{null} // 렌더링 안 됨
{undefined} // 렌더링 안 됨
{true} // 렌더링 안 됨
{0} // "0" 렌더링됨! ❌
{NaN} // "NaN" 렌더링됨! ❌
{""} // 빈 문자열 (렌더링 안 됨)
개선: 명시적 조건
function Badge({ count }: { count: number }) {
return (
<div>
{count > 0 ? <span className="badge">{count}</span> : null}
</div>
)
}
// count = 0일 때 렌더링: <div></div>
// count = 5일 때 렌더링: <div><span class="badge">5</span></div>
다른 예시
// ❌ 나쁨: 빈 배열 길이 0 표시
{items.length && <List items={items} />}
// items = [] → "0" 표시
// ✅ 좋음
{items.length > 0 ? <List items={items} /> : null}
// ❌ 나쁨: NaN 표시 가능
{value && <Display value={value} />}
// value = NaN → "NaN" 표시
// ✅ 좋음
{!isNaN(value) && value !== 0 ? <Display value={value} /> : null}
안전한 패턴
// Boolean 변환
{Boolean(count) && <Badge count={count} />}
// 명시적 비교
{count > 0 && <Badge count={count} />}
// Double NOT
{!!count && <Badge count={count} />}
// 삼항 연산자 (가장 명확)
{count > 0 ? <Badge count={count} /> : null}
6-5. SVG 래퍼 애니메이션 (LOW)
Impact: LOW (하드웨어 가속 활성화)
많은 브라우저가 SVG 요소의 CSS3 애니메이션에 하드웨어 가속을 지원하지 않습니다. SVG를 <div>로 감싸고 래퍼를 애니메이션하세요.
문제: SVG 직접 애니메이션 - 하드웨어 가속 없음
function LoadingSpinner() {
return (
<svg
className="animate-spin"
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
)
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
브라우저 렌더링:
매 프레임 (60fps):
1. JavaScript 타이머
2. CSS 계산
3. Layout (SVG 재계산) ← CPU
4. Paint ← CPU
5. Composite
CPU에서 모든 작업 수행 → 느림
개선: 래퍼 div 애니메이션 - 하드웨어 가속
function LoadingSpinner() {
return (
<div className="animate-spin">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
</div>
)
}
브라우저 렌더링:
매 프레임:
1. GPU에서 transform 계산 ← 빠름!
2. Composite만 실행
(Layout, Paint 건너뜀!)
하드웨어 가속 작동 원리
CPU 렌더링 (SVG 직접):
- 모든 프레임마다 SVG 재계산
- Layout, Paint 매번 실행
- 메인 스레드 차단
GPU 렌더링 (div 래퍼):
- SVG는 한 번만 렌더링 → 텍스처로 저장
- 이후 프레임은 텍스처만 회전 (GPU)
- 메인 스레드 자유로움
적용 대상
모든 CSS transform과 transition:
transformopacitytranslatescalerotate
// ❌ SVG 직접
<svg style={{ transform: 'scale(1.5)' }} />
// ✅ div 래퍼
<div style={{ transform: 'scale(1.5)' }}>
<svg />
</div>
6-6. 정적 JSX 요소 호이스팅 (LOW)
Impact: LOW (재생성 방지)
정적 JSX를 컴포넌트 외부로 추출하여 재생성을 방지하세요.
문제: 매 렌더링마다 재생성
function LoadingSkeleton() {
return <div className="animate-pulse h-20 bg-gray-200" />
}
function Container() {
const [loading, setLoading] = useState(true)
return (
<div>
{loading && <LoadingSkeleton />}
</div>
)
}
매 렌더링마다:
Container 렌더링
→ JSX 생성: {loading && <LoadingSkeleton />}
→ LoadingSkeleton 참조 생성 (새 객체)
→ React는 이게 새 요소인지 확인
개선: 한 번만 생성
const loadingSkeleton = (
<div className="animate-pulse h-20 bg-gray-200" />
)
function Container() {
const [loading, setLoading] = useState(true)
return (
<div>
{loading && loadingSkeleton}
</div>
)
}
매 렌더링마다:
Container 렌더링
→ loadingSkeleton 참조 (같은 객체)
→ React: "같은 요소네, 재사용!"
언제 효과적인가?
✅ 큰 효과:
// 복잡한 SVG 아이콘
const icon = (
<svg>
<path d="..." /> {/* 수십 개 경로 */}
</svg>
)
// 복잡한 정적 구조
const skeleton = (
<div>
<div className="skeleton-header" />
<div className="skeleton-body">
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
</div>
)
❌ 작은 효과:
// 단순 요소
const button = <button>Click</button>
// 동적 props (호이스팅 불가)
<Icon name={iconName} /> // iconName은 props
React Compiler 참고: React Compiler가 활성화된 프로젝트에서는 정적 JSX 요소 호이스팅이 자동으로 처리됩니다.
6-7. SVG 정밀도 최적화 (LOW)
Impact: LOW (파일 크기 감소)
SVG 좌표 정밀도를 줄여 파일 크기를 감소시키세요.
문제: 과도한 정밀도
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
6자리 소수점은 0.0001픽셀 정밀도 → 육안으로 구별 불가!
개선: 1자리 소수점
<path d="M 10.3 20.8 L 30.9 40.2" />
SVGO로 자동화
npx svgo --precision=1 --multipass icon.svg
결과:
Before: 5.2KB
After: 2.8KB (46% 감소)
적절한 정밀도
| viewBox 크기 | 권장 정밀도 | 이유 |
|---|---|---|
| 24×24 | 1 | 0.1px 차이는 보이지 않음 |
| 100×100 | 1 | 0.1px 차이는 보이지 않음 |
| 1000×1000 | 2 | 0.01px 정도면 충분 |
| 지도 | 3-4 | 정밀한 경계 필요 |
섹션 6 정리: Rendering Performance의 핵심
- content-visibility: 긴 목록의 화면 밖 렌더링 연기
- 하이드레이션 최적화: 동기 스크립트로 깜빡임 방지
- Activity 컴포넌트: 토글 시 DOM/상태 보존
- 명시적 조건:
0렌더링 방지 - SVG 래퍼: 하드웨어 가속 활성화
- JSX 호이스팅: 정적 요소 재생성 방지
- SVG 정밀도: 파일 크기 최적화
Vercel이 이 섹션을 MEDIUM으로 분류한 이유는 렌더링 성능이 브라우저 작업량에 직접적인 영향을 미치기 때문입니다. 특히 content-visibility는 긴 목록에서 극적인 성능 개선을 제공합니다.
렌더링 최적화 체크리스트
- 긴 목록에 content-visibility 적용
- 테마 토글에 동기 스크립트 사용
- 빈번한 토글에 Activity 사용
- 숫자 조건에서
count > 0사용 - SVG 애니메이션을 div로 감싸기
- 복잡한 정적 JSX 호이스팅
- SVGO로 SVG 최적화
- Chrome DevTools Performance로 렌더링 측정
다음 편 예고
다음 글에서는 JavaScript Performance (LOW-MEDIUM)를 다룹니다. 핫 패스(hot path)를 위한 마이크로 최적화로 의미 있는 개선을 만드는 전략을 살펴봅니다.