이 글은 Vercel에서 작성한 React Best Practice와 소스 코드를 보며 톺아보는 글입니다.
AI에게 "이 코드를 React Best Practice에 맞게 리팩토링해줘"라고 요청할 수도 있지만,
왜 그 코드가 더 나은지, 어떤 원리로 성능이 개선되는지 이해하는 것이 중요합니다.
이 시리즈에서는 Vercel이 정의한 8가지 카테고리를 하나씩 깊이 있게 살펴봅니다.
각 Best Practice의 이론적 배경, 실제 성능 차이, 적용 시점을 중심으로 분석합니다.
이 글은 sections에서 소개하는 8가지 섹션 중 5번째 섹션인 Re-render Optimization을 다룹니다.
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)
5. Re-render Optimization: 불필요한 렌더링 제거
리렌더링 최적화가 중요한 이유
React는 상태가 변경될 때마다 컴포넌트를 다시 렌더링합니다. 불필요한 리렌더링은:
- CPU 사용량 증가: 수백~수천 개의 컴포넌트가 불필요하게 실행
- UI 반응성 저하: 사용자 입력이 지연되는 느낌
- 배터리 소모: 특히 모바일 기기에서 문제
리렌더링 최적화의 핵심은:
- 낭비된 연산 최소화: 결과가 같다면 재계산하지 않기
- 안정적인 참조 유지: 불필요한 의존성 변경 방지
- 계산 연기: 비긴급 업데이트는 나중으로 미루기
중요: React Compiler가 활성화된 프로젝트에서는 많은 최적화가 자동으로 처리됩니다. 하지만 컴파일러가 없는 환경이나 특정 패턴에서는 여전히 수동 최적화가 필요합니다.
5-1. 메모이제이션된 컴포넌트로 추출 (MEDIUM)
Impact: MEDIUM (조기 반환 활성화)
비용이 많이 드는 작업을 메모이제이션된 컴포넌트로 추출하여 계산 전 조기 반환을 활성화하세요.
문제 코드: 로딩 중에도 아바타 계산
function Profile({ user, loading }: Props) {
const avatar = useMemo(() => {
const id = computeAvatarId(user) // 비용이 큰 계산
return <Avatar id={id} />
}, [user])
if (loading) return <Skeleton />
return <div>{avatar}</div>
}
문제점:
useMemo는 조건문 전에 실행됨loading=true일 때도computeAvatarId()실행- 결과를 사용하지 않는데도 CPU 낭비
React Hooks 실행 순서
function Component() {
// 1. useState 실행
const [state, setState] = useState()
// 2. useMemo 실행 (조건문 전!)
const value = useMemo(() => expensiveCalc(), [])
// 3. 조건문 평가
if (condition) return <div />
// 4. JSX 반환
return <div>{value}</div>
}
Hooks는 항상 컴포넌트 최상위에서 실행되므로, 조건문으로 건너뛸 수 없습니다.
개선 코드: 로딩 시 계산 건너뛰기
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user])
return <Avatar id={id} />
})
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />
return (
<div>
<UserAvatar user={user} />
</div>
)
}
실행 흐름:
loading=true일 때:
Profile 렌더링 시작
→ if (loading) 체크
→ <Skeleton /> 즉시 반환
→ UserAvatar 렌더링 안 함
→ computeAvatarId() 실행 안 됨 ✅
loading=false일 때:
Profile 렌더링 시작
→ if (loading) 통과
→ <UserAvatar> 렌더링
→ computeAvatarId() 실행
실전 예시: 복잡한 필터링
// ❌ 나쁨: 항상 필터링 수행
function ProductList({ products, showOnlyInStock }: Props) {
const filtered = useMemo(
() => products.filter(p => p.inStock),
[products]
)
if (!showOnlyInStock) return <AllProducts products={products} />
return <FilteredView products={filtered} />
}
// ✅ 좋음: 필요할 때만 필터링
const FilteredProducts = memo(function FilteredProducts({
products
}: { products: Product[] }) {
const filtered = useMemo(
() => products.filter(p => p.inStock),
[products]
)
return <FilteredView products={filtered} />
})
function ProductList({ products, showOnlyInStock }: Props) {
if (!showOnlyInStock) return <AllProducts products={products} />
return <FilteredProducts products={products} />
}
memo()의 작동 원리
const MyComponent = memo(function MyComponent({ prop1, prop2 }) {
// React가 이전 props와 비교:
// - prop1이 같고 prop2가 같으면 → 이전 결과 재사용
// - 하나라도 다르면 → 재렌더링
return <div>{prop1} {prop2}</div>
})
5-2. 함수형 setState 업데이트 (MEDIUM)
Impact: MEDIUM (stale closure 방지, 불필요한 콜백 재생성 방지)
현재 상태 값을 기반으로 상태를 업데이트할 때, 함수형 업데이트 형태의 setState를 사용하세요.
문제 코드: 의존성에 state 필요
function TodoList() {
const [items, setItems] = useState(initialItems)
// 콜백은 items에 의존, items 변경마다 재생성됨
const addItems = useCallback((newItems: Item[]) => {
setItems([...items, ...newItems])
}, [items]) // ❌ items 의존성으로 인한 재생성
// Stale closure 위험: 의존성 누락
const removeItem = useCallback((id: string) => {
setItems(items.filter(item => item.id !== id))
}, []) // ❌ items 의존성 누락 - 항상 초기값 참조!
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}
문제점:
첫 번째 콜백:
items가 변경될 때마다addItems재생성ItemsEditor가 메모이제이션되어 있어도 재렌더링- 성능 저하
두 번째 콜백:
- 의존성 배열이 비어있어
items는 항상 초기값 - Stale closure 버그 - 가장 흔한 React 버그 유형!
Stale Closure 예시
function Counter() {
const [count, setCount] = useState(0)
const increment = useCallback(() => {
setCount(count + 1) // count는 항상 0
}, []) // 의존성 누락
// 클릭 후 count=1이 되고, 다시 increment 호출 시
// count + 1 = 0 + 1 = 1 (여전히!)
// 2, 3, 4로 증가하지 않음
}
개선 코드: 안정적인 콜백, stale closure 없음
function TodoList() {
const [items, setItems] = useState(initialItems)
// 안정적인 콜백, 절대 재생성 안 됨
const addItems = useCallback((newItems: Item[]) => {
setItems(curr => [...curr, ...newItems])
}, []) // ✅ 의존성 불필요
// 항상 최신 상태 사용, stale closure 위험 없음
const removeItem = useCallback((id: string) => {
setItems(curr => curr.filter(item => item.id !== id))
}, []) // ✅ 안전하고 안정적
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}
함수형 업데이트의 이점
1. 안정적인 콜백 참조
// 의존성 없음 → 절대 재생성 안 됨
const increment = useCallback(() => {
setCount(c => c + 1)
}, [])
2. Stale closure 방지
// 항상 최신 값 참조
setCount(c => c + 1) // c는 항상 현재 count
3. 더 적은 의존성
// ❌ 의존성 많음
useEffect(() => {
if (items.length > 10) {
alert('Too many!')
}
}, [items])
// ✅ 의존성 없음
useEffect(() => {
const check = (currentItems) => {
if (currentItems.length > 10) alert('Too many!')
}
// 실제 사용 시 함수형 업데이트 패턴 활용
}, [])
언제 함수형 업데이트를 사용하나?
✅ 사용해야 할 때:
- setState가 현재 상태 값에 의존
- useCallback/useMemo 내부에서 상태 필요
- 이벤트 핸들러가 상태 참조
- 비동기 작업에서 상태 업데이트
❌ 직접 업데이트가 괜찮을 때:
- 정적 값 설정:
setCount(0) - props/인자만 사용:
setName(newName) - 상태가 이전 값에 의존하지 않음
5-3. 지연 상태 초기화 (MEDIUM)
Impact: MEDIUM (매 렌더링마다 낭비되는 연산)
비용이 큰 초기값에는 useState에 함수를 전달하세요. 함수 형태가 없으면 초기화 함수가 매 렌더링마다 실행됩니다.
문제 코드: 매 렌더링마다 실행
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex()가 매 렌더링마다 실행됨
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
const [query, setQuery] = useState('')
// query 변경 시마다 buildSearchIndex 재실행 (불필요!)
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse가 매 렌더링마다 실행됨
const [settings, setSettings] = useState(
JSON.parse(localStorage.getItem('settings') || '{}')
)
return <SettingsForm settings={settings} onChange={setSettings} />
}
실행 타임라인:
렌더링 1: buildSearchIndex(items) 실행 → 상태 초기화 (사용됨)
렌더링 2: buildSearchIndex(items) 실행 → 결과 버림! (낭비)
렌더링 3: buildSearchIndex(items) 실행 → 결과 버림! (낭비)
...
React의 useState 동작
// 직접 값 전달
const [state] = useState(expensiveFunc())
// → expensiveFunc()는 매 렌더링마다 실행
// → React: "혹시 몰라서 실행해봤는데, 이미 state 있네? 버림"
// 함수 전달
const [state] = useState(() => expensiveFunc())
// → 첫 렌더링: expensiveFunc() 실행
// → 이후 렌더링: 함수 자체를 건너뜀
개선 코드: 한 번만 실행
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex()는 첫 렌더링에만 실행
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
const [query, setQuery] = useState('')
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse는 첫 렌더링에만 실행
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings')
return stored ? JSON.parse(stored) : {}
})
return <SettingsForm settings={settings} onChange={setSettings} />
}
실전 예시: DOM 읽기
// ❌ 나쁨: 매 렌더링마다 DOM 읽기
function ScrollableList() {
const [initialScrollTop] = useState(
document.getElementById('main')?.scrollTop || 0
)
// ...
}
// ✅ 좋음: 한 번만 DOM 읽기
function ScrollableList() {
const [initialScrollTop] = useState(() =>
document.getElementById('main')?.scrollTop || 0
)
// ...
}
언제 지연 초기화를 사용하나?
✅ 사용해야 할 때:
- localStorage/sessionStorage 읽기
- 데이터 구조 구축 (인덱스, Map)
- DOM 읽기
- 무거운 변환 작업
- 비용이 큰 계산
❌ 불필요할 때:
- 단순 원시값:
useState(0) - 직접 참조:
useState(props.value) - 저렴한 리터럴:
useState({})
성능 차이
// 10,000개 아이템 인덱싱
function Component({ items }) {
// 나쁨: 렌더링 100번 → 1,000,000개 아이템 처리 (낭비!)
const [index] = useState(buildIndex(items)) // 매번 실행
// 좋음: 렌더링 100번 → 10,000개 아이템 처리 (1번만!)
const [index] = useState(() => buildIndex(items)) // 한 번만
}
5-4. 파생 상태 구독 (MEDIUM)
Impact: MEDIUM (리렌더링 빈도 감소)
연속적인 값 대신 파생된 불리언 상태를 구독하여 리렌더링 빈도를 줄이세요.
문제 코드: 모든 픽셀 변경마다 리렌더링
function Sidebar() {
const width = useWindowWidth() // 767, 766, 765, 764...
const isMobile = width < 768
return <nav className={isMobile ? 'mobile' : 'desktop'} />
}
문제점:
- 창 크기 조절 시 매 픽셀마다 리렌더링
width가 변경되면Sidebar리렌더링- 하지만 실제로 UI가 바뀌는 건 768px 경계를 넘을 때만!
렌더링 타임라인:
width: 800 → isMobile: false → 렌더링
width: 799 → isMobile: false → 렌더링 (불필요!)
width: 798 → isMobile: false → 렌더링 (불필요!)
...
width: 768 → isMobile: false → 렌더링 (불필요!)
width: 767 → isMobile: true → 렌더링 (필요!)
width: 766 → isMobile: true → 렌더링 (불필요!)
개선 코드: 불리언 변경 시에만 리렌더링
function Sidebar() {
const isMobile = useMediaQuery('(max-width: 767px)')
return <nav className={isMobile ? 'mobile' : 'desktop'} />
}
useMediaQuery 구현 예시:
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
window.matchMedia(query).matches
)
useEffect(() => {
const mediaQuery = window.matchMedia(query)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}, [query])
return matches
}
렌더링 타임라인:
width: 800 → isMobile: false → 렌더링
width: 799 → isMobile: false → (렌더링 없음!)
width: 798 → isMobile: false → (렌더링 없음!)
...
width: 768 → isMobile: false → (렌더링 없음!)
width: 767 → isMobile: true → 렌더링 (필요!)
width: 766 → isMobile: true → (렌더링 없음!)
다른 예시: 스크롤 방향
// ❌ 나쁨: 매 픽셀마다 리렌더링
function Header() {
const scrollY = useScrollY() // 0, 1, 2, 3, 4...
return <header style={{ transform: `translateY(${scrollY}px)` }} />
}
// ✅ 좋음: 방향 변경 시에만 리렌더링
function Header() {
const isScrollingDown = useScrollDirection() // true/false
return <header className={isScrollingDown ? 'hidden' : 'visible'} />
}
useScrollDirection 구현:
function useScrollDirection(): boolean {
const [isScrollingDown, setIsScrollingDown] = useState(false)
const [lastY, setLastY] = useState(0)
useEffect(() => {
const handler = () => {
const currentY = window.scrollY
const scrollingDown = currentY > lastY
// 방향이 바뀔 때만 상태 업데이트
if (scrollingDown !== isScrollingDown) {
setIsScrollingDown(scrollingDown)
}
setLastY(currentY)
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [lastY, isScrollingDown])
return isScrollingDown
}
핵심 원리
연속 값 → 이산 값 변환:
// 연속: 무한한 가능성
width: number // 1, 2, 3, ..., 1920, 1921, ...
// 이산: 제한된 경우의 수
isMobile: boolean // true, false (2가지만)
리렌더링 횟수 = 값 변경 횟수이므로, 값의 경우의 수를 줄이면 리렌더링도 줄어듭니다.
5-5. 비긴급 업데이트에 Transition 사용 (MEDIUM)
Impact: MEDIUM (UI 반응성 유지)
빈번하고 비긴급한 상태 업데이트를 transition으로 표시하여 UI 반응성을 유지하세요.
문제 코드: 매 스크롤마다 UI 차단
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => setScrollY(window.scrollY)
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
return <div>Scroll position: {scrollY}</div>
}
문제점:
- 스크롤 이벤트는 초당 60번 이상 발생
- 매번
setScrollY호출 → 컴포넌트 리렌더링 - 다른 긴급한 업데이트(버튼 클릭)가 차단됨
실행 흐름:
사용자 스크롤 → setScrollY(100)
→ ScrollTracker 리렌더링 시작
→ 사용자 버튼 클릭 (대기...)
→ ScrollTracker 리렌더링 완료
→ setScrollY(101)
→ ScrollTracker 리렌더링 시작
→ 버튼 클릭 여전히 대기...
개선 코드: 논블로킹 업데이트
import { startTransition } from 'react'
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => {
startTransition(() => {
setScrollY(window.scrollY)
})
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
return <div>Scroll position: {scrollY}</div>
}
실행 흐름:
사용자 스크롤 → startTransition(() => setScrollY(100))
→ 업데이트 큐에 추가 (낮은 우선순위)
→ 사용자 버튼 클릭 (즉시 처리!)
→ 버튼 클릭 완료 후 스크롤 업데이트 처리
startTransition의 작동 원리
React는 업데이트를 두 가지 우선순위로 분류합니다:
긴급 업데이트 (Urgent):
- 사용자 입력 (타이핑, 클릭, 드래그)
- 즉시 처리되어야 함
- 지연 시 UX 저하
Transition 업데이트 (Non-urgent):
- 검색 결과, 애니메이션, 통계
- 약간 지연되어도 괜찮음
- 긴급 업데이트가 먼저 처리됨
// 긴급
setInputValue(e.target.value) // 즉시 처리
// 비긴급
startTransition(() => {
setSearchResults(filtered) // 나중에 처리
})
실전 예시: 검색 입력
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const handleChange = (e) => {
// 입력값은 즉시 업데이트 (긴급)
setQuery(e.target.value)
// 검색 결과는 나중에 업데이트 (비긴급)
startTransition(() => {
const filtered = items.filter(item =>
item.name.includes(e.target.value)
)
setResults(filtered)
})
}
return (
<div>
<input value={query} onChange={handleChange} />
<SearchResults results={results} />
</div>
)
}
사용자 경험:
- 입력 필드는 항상 즉각 반응 (타이핑 지연 없음)
- 검색 결과는 약간 늦게 나타남 (괜찮음)
useTransition Hook
useTransition은 로딩 상태도 제공합니다:
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
const handleChange = (e) => {
setQuery(e.target.value)
startTransition(() => {
const filtered = items.filter(item =>
item.name.includes(e.target.value)
)
setResults(filtered)
})
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<SearchResults results={results} />
</div>
)
}
언제 Transition을 사용하나?
✅ 사용해야 할 때:
- 검색 필터링
- 탭 전환
- 정렬/필터 변경
- 스크롤 추적
- 차트 업데이트
❌ 사용하지 말아야 할 때:
- 폼 입력 제어
- 버튼 클릭 핸들러
- 애니메이션 프레임
- 즉각적인 피드백이 필요한 경우
5-6. 사용 시점으로 상태 읽기 연기 (MEDIUM)
Impact: MEDIUM (불필요한 구독 회피)
콜백 내부에서만 읽는다면 동적 상태(searchParams, localStorage)를 구독하지 마세요.
문제 코드: 모든 searchParams 변경에 구독
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
const handleShare = () => {
const ref = searchParams.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
}
문제점:
useSearchParams()는 URL 변경마다 리렌더링 유발?tab=home→?tab=profile:ShareButton리렌더링- 하지만
ref파라미터만 필요한데 다른 파라미터 변경에도 반응 - 불필요한 리렌더링
렌더링 타임라인:
URL: ?ref=twitter&tab=home
→ ShareButton 렌더링
URL: ?ref=twitter&tab=profile (tab 변경)
→ ShareButton 리렌더링 (불필요!)
URL: ?ref=twitter&tab=settings (tab 변경)
→ ShareButton 리렌더링 (불필요!)
개선 코드: 온디맨드 읽기, 구독 없음
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
const ref = params.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
}
렌더링 타임라인:
URL: ?ref=twitter&tab=home
→ ShareButton 렌더링
URL: ?ref=twitter&tab=profile (tab 변경)
→ ShareButton 리렌더링 없음! ✅
URL: ?ref=twitter&tab=settings (tab 변경)
→ ShareButton 리렌더링 없음! ✅
버튼 클릭 시에만 URL 읽기
언제 구독하고, 언제 연기하나?
구독 (useSearchParams 사용):
// UI에 반영되어야 하는 경우
function FilterView() {
const searchParams = useSearchParams()
const filter = searchParams.get('filter') || 'all'
// filter 값에 따라 UI가 달라짐 → 구독 필요
return <div>Current filter: {filter}</div>
}
연기 (직접 읽기):
// 콜백에서만 사용하는 경우
function AnalyticsTracker() {
const trackEvent = () => {
const params = new URLSearchParams(window.location.search)
const campaign = params.get('utm_campaign')
// campaign 값이 UI에 영향 없음 → 구독 불필요
analytics.track('event', { campaign })
}
return <button onClick={trackEvent}>Track</button>
}
localStorage 예시
// ❌ 나쁨: 다른 탭에서 변경 시마다 리렌더링
function SaveButton() {
const [settings] = useLocalStorage('settings', {})
const handleSave = () => {
saveToServer(settings)
}
return <button onClick={handleSave}>Save</button>
}
// ✅ 좋음: 클릭 시에만 읽기
function SaveButton() {
const handleSave = () => {
const settings = JSON.parse(
localStorage.getItem('settings') || '{}'
)
saveToServer(settings)
}
return <button onClick={handleSave}>Save</button>
}
핵심 원칙
렌더링에 필요 → 구독
const value = useReactiveHook()
return <div>{value}</div> // JSX에 사용
콜백에서만 필요 → 연기
const onClick = () => {
const value = readDirectly() // 필요할 때만
doSomething(value)
}
5-7. 단순 표현식을 useMemo로 감싸지 않기 (LOW-MEDIUM)
Impact: LOW-MEDIUM (불필요한 오버헤드)
표현식이 단순하고(논리 또는 산술 연산자 몇 개) 결과 타입이 원시값(boolean, number, string)일 때는 useMemo로 감싸지 마세요.
문제 코드: useMemo 오버헤드가 더 큼
function Header({ user, notifications }: Props) {
const isLoading = useMemo(() => {
return user.isLoading || notifications.isLoading
}, [user.isLoading, notifications.isLoading])
if (isLoading) return <Skeleton />
return <div>Header content</div>
}
문제점:
useMemo호출 비용: 함수 호출 + 의존성 비교- 실제 연산 비용: OR 연산 (
||) useMemo비용 > 실제 연산 비용!
실행 흐름:
useMemo 실행:
1. 이전 의존성 배열 가져오기
2. user.isLoading 비교 (이전 vs 현재)
3. notifications.isLoading 비교 (이전 vs 현재)
4. 변경 감지 시 콜백 실행: user.isLoading || notifications.isLoading
5. 결과 저장
실제 연산:
1. user.isLoading || notifications.isLoading
→ useMemo가 5배 더 비쌈!
개선 코드: 직접 계산
function Header({ user, notifications }: Props) {
const isLoading = user.isLoading || notifications.isLoading
if (isLoading) return <Skeleton />
return <div>Header content</div>
}
언제 useMemo를 사용하나?
❌ 사용하지 마세요:
// 단순 산술
const total = useMemo(() => price * quantity, [price, quantity])
// → const total = price * quantity
// 단순 논리
const isValid = useMemo(() => name && email, [name, email])
// → const isValid = name && email
// 문자열 연결
const fullName = useMemo(() => `${first} ${last}`, [first, last])
// → const fullName = `${first} ${last}`
// 삼항 연산자
const label = useMemo(() => count > 0 ? 'Items' : 'Empty', [count])
// → const label = count > 0 ? 'Items' : 'Empty'
✅ 사용하세요:
// 배열 필터링 (비용이 큼)
const filtered = useMemo(
() => items.filter(i => i.active),
[items]
)
// 배열 변환 (비용이 큼)
const mapped = useMemo(
() => items.map(i => ({ ...i, extra: compute(i) })),
[items]
)
// 객체/배열 생성 (참조 안정성 필요)
const config = useMemo(
() => ({ theme, locale }),
[theme, locale]
)
// 복잡한 계산
const result = useMemo(
() => {
let sum = 0
for (let i = 0; i < 1000; i++) {
sum += complexCalc(data[i])
}
return sum
},
[data]
)
기준
연산 비용 < 100μs → useMemo 불필요
// 원시값 연산: ~1μs
a + b
a || b
a > b
// 문자열 연결: ~5μs
`${first} ${last}`
// 짧은 배열 접근: ~10μs
arr[0]
arr.length
연산 비용 > 1ms → useMemo 고려
// 큰 배열 필터링: ~10ms
largeArray.filter(...)
// 복잡한 변환: ~50ms
data.map(item => heavyTransform(item))
// 재귀 계산: 수 초
fibonacci(40)
측정하기
console.time('calc')
const result = expensiveOperation()
console.timeEnd('calc')
// calc: 0.5ms → useMemo 불필요
// calc: 50ms → useMemo 사용
섹션 5 정리: Re-render Optimization의 핵심
- 메모이제이션 컴포넌트: 조기 반환으로 비용 큰 계산 건너뛰기
- 함수형 setState: Stale closure 방지 및 안정적인 콜백
- 지연 상태 초기화: 비용 큰 초기화는 한 번만
- 파생 상태 구독: 연속 값 대신 불리언으로 리렌더링 감소
- Transition 사용: 비긴급 업데이트로 UI 반응성 유지
- 상태 읽기 연기: 콜백에서만 사용 시 구독 회피
- useMemo 신중히 사용: 단순 표현식은 직접 계산
Vercel이 이 섹션을 MEDIUM으로 분류한 이유는 리렌더링 최적화가 UI 반응성에 직접적인 영향을 미치기 때문입니다. 특히 함수형 setState와 지연 초기화는 즉각적인 성능 개선과 버그 방지를 제공합니다.
리렌더링 최적화 체크리스트
- 비용 큰 계산을 메모이제이션된 컴포넌트로 추출
- 상태 기반 setState를 함수형 업데이트로 변경
- localStorage/계산 비용 큰 초기화에 지연 초기화 적용
- 연속 값(width, scrollY)을 불리언으로 변환
- 검색/필터에 startTransition 적용
- 콜백에서만 사용하는 상태 구독 제거
- 불필요한 useMemo 제거 (단순 표현식)
- React DevTools Profiler로 리렌더링 측정
React Compiler 참고: React Compiler가 활성화된 프로젝트에서는 memo(), useMemo(), useCallback() 같은 수동 메모이제이션이 자동으로 처리됩니다. 하지만 함수형 setState, 지연 초기화, Transition 같은 패턴은 여전히 수동으로 적용해야 합니다.
다음 편 예고
다음 글에서는 Rendering Performance (MEDIUM)을 다룹니다. 렌더링 프로세스를 최적화하여 브라우저가 수행해야 하는 작업을 줄이는 전략을 살펴봅니다.