이 글은 Vercel에서 작성한 React Best Practice와 소스 코드를 보며 톺아보는 글입니다.
AI에게 "이 코드를 React Best Practice에 맞게 리팩토링해줘"라고 요청할 수도 있지만,
왜 그 코드가 더 나은지, 어떤 원리로 성능이 개선되는지 이해하는 것이 중요합니다.
이 시리즈에서는 Vercel이 정의한 8가지 카테고리를 하나씩 깊이 있게 살펴봅니다.
각 Best Practice의 이론적 배경, 실제 성능 차이, 적용 시점을 중심으로 분석합니다.
이 글은 sections에서 소개하는 8가지 섹션 중 마지막 8번째 섹션인 Advanced Patterns를 다룹니다.
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)
8. Advanced Patterns: 신중한 구현이 필요한 고급 패턴
고급 패턴이 중요한 이유
이 섹션의 패턴들은 영향력이 낮지만(LOW), 올바르게 사용하면 특정 문제를 우아하게 해결합니다. 하지만 잘못 사용하면 오히려 복잡도만 증가시킬 수 있습니다.
고급 패턴의 특징:
- 특정 케이스에만 유용: 모든 곳에 적용할 필요 없음
- 신중한 구현 필요: 제대로 이해하고 사용해야 함
- 대안 존재: 최신 React API가 더 나은 해결책 제공
8-1. Refs에 이벤트 핸들러 저장 (LOW)
Impact: LOW (안정적인 구독)
콜백 변경 시 재구독하지 말아야 하는 Effect에서 사용할 때는 콜백을 refs에 저장하세요.
문제: 매 렌더링마다 재구독
function useWindowEvent(event: string, handler: (e: Event) => void) {
useEffect(() => {
window.addEventListener(event, handler)
return () => window.removeEventListener(event, handler)
}, [event, handler])
}
function Component() {
const [count, setCount] = useState(0)
// 매 렌더링마다 새 함수
useWindowEvent('resize', () => {
console.log('Window resized, count:', count)
})
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
실행 흐름:
렌더링 1:
handler = () => console.log(0)
addEventListener(handler1)
클릭 → count = 1 → 렌더링 2:
handler = () => console.log(1) // 새 함수!
removeEventListener(handler1) // 이전 제거
addEventListener(handler2) // 새로 추가
클릭 → count = 2 → 렌더링 3:
handler = () => console.log(2) // 또 새 함수!
removeEventListener(handler2)
addEventListener(handler3)
매 렌더링마다 이벤트 리스너를 제거하고 다시 추가합니다!
왜 문제인가?
1. 성능 비용:
// 리스너 제거/추가는 비용이 있음
window.removeEventListener(...) // 내부 리스너 배열 탐색
window.addEventListener(...) // 배열에 추가
2. 이벤트 손실 위험:
// Effect cleanup과 새 Effect 사이에 이벤트 발생 가능
removeEventListener() // 리스너 제거
// 이 시점에 resize 이벤트 발생 → 놓침!
addEventListener() // 리스너 추가
3. 메모리 누수 위험:
// cleanup이 올바르게 실행 안 되면 리스너 누적
addEventListener(handler1) // 등록
addEventListener(handler2) // 중복 등록!
개선: Ref로 안정적인 구독
function useWindowEvent(event: string, handler: (e: Event) => void) {
const handlerRef = useRef(handler)
// 항상 최신 handler 유지
useEffect(() => {
handlerRef.current = handler
}, [handler])
useEffect(() => {
// Ref를 통해 호출하는 래퍼
const listener = (e: Event) => handlerRef.current(e)
window.addEventListener(event, listener)
return () => window.removeEventListener(event, listener)
}, [event]) // handler 의존성 없음!
}
실행 흐름:
렌더링 1:
handlerRef.current = () => console.log(0)
addEventListener(listener) // 한 번만!
클릭 → count = 1 → 렌더링 2:
handlerRef.current = () => console.log(1) // Ref만 업데이트
// listener는 그대로! 재구독 없음 ✅
클릭 → count = 2 → 렌더링 3:
handlerRef.current = () => console.log(2)
// 여전히 재구독 없음 ✅
작동 원리
Ref의 특징:
ref.current변경은 리렌더링 유발 안 함- Effect 의존성 배열에 포함할 필요 없음
- 항상 최신 값 참조 가능
래퍼 함수:
const listener = (e) => handlerRef.current(e)
// ↑ 호출 시점에 최신 handler 실행
이벤트 발생 시마다 handlerRef.current를 읽으므로 항상 최신 handler 실행됩니다.
useEffectEvent (React 19+)
React 19부터는 useEffectEvent가 이 패턴을 대체합니다:
import { useEffectEvent } from 'react'
function useWindowEvent(event: string, handler: (e: Event) => void) {
const onEvent = useEffectEvent(handler)
useEffect(() => {
window.addEventListener(event, onEvent)
return () => window.removeEventListener(event, onEvent)
}, [event])
}
useEffectEvent의 작동 원리:
- 안정적인 함수 참조 제공 (재생성 안 됨)
- 항상 최신 handler 호출
- 내부적으로 Ref 패턴 사용
언제 사용하나?
✅ 사용:
- 전역 이벤트 리스너 (window, document)
- WebSocket 연결
- 인터벌/타이머
- 외부 라이브러리 구독
❌ 불필요:
- 단순 Effect (한 번만 실행)
- handler가 안정적인 경우 (useCallback)
- React 19+ (useEffectEvent 사용)
실전 예시: WebSocket
function useWebSocket(url: string, onMessage: (data: any) => void) {
const onMessageRef = useRef(onMessage)
useEffect(() => {
onMessageRef.current = onMessage
}, [onMessage])
useEffect(() => {
const ws = new WebSocket(url)
ws.onmessage = (e) => {
onMessageRef.current(JSON.parse(e.data))
}
return () => ws.close()
}, [url]) // onMessage 변경 시 재연결 안 함!
}
8-2. 안정적인 콜백 참조를 위한 useEffectEvent (LOW)
Impact: LOW (Effect 재실행 방지)
의존성 배열에 추가하지 않고 콜백에서 최신 값에 접근하세요. Stale closure를 피하면서 Effect 재실행을 방지합니다.
문제: 콜백 변경마다 Effect 재실행
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
useEffect(() => {
const timeout = setTimeout(() => onSearch(query), 300)
return () => clearTimeout(timeout)
}, [query, onSearch]) // onSearch 변경 시마다 재실행
}
부모 컴포넌트:
function Parent() {
const [filter, setFilter] = useState('')
return (
<SearchInput
onSearch={(q) => {
// 매 렌더링마다 새 함수
console.log('Search:', q, 'Filter:', filter)
}}
/>
)
}
실행 흐름:
부모 렌더링 1:
onSearch1 = (q) => console.log(q, filter1)
부모 렌더링 2 (filter 변경):
onSearch2 = (q) => console.log(q, filter2) // 새 함수!
→ SearchInput의 Effect 재실행
→ clearTimeout + setTimeout 재시작
→ 사용자가 타이핑 중이어도 타이머 리셋!
왜 문제인가?
1. 디바운스 깨짐:
사용자 타이핑: "h" "e" "l"
300ms 기다림...
→ 부모 리렌더링 (다른 이유)
→ onSearch 새 함수
→ Effect 재실행
→ 타이머 리셋!
→ 300ms 다시 기다림...
2. 불필요한 연산:
// Effect가 매번 재실행
clearTimeout() // 기존 타이머 취소
setTimeout() // 새 타이머 생성
개선: useEffectEvent 사용
import { useEffectEvent } from 'react'
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const onSearchEvent = useEffectEvent(onSearch)
useEffect(() => {
const timeout = setTimeout(() => onSearchEvent(query), 300)
return () => clearTimeout(timeout)
}, [query]) // onSearch 의존성 제거!
}
실행 흐름:
부모 렌더링 1:
onSearch1 = (q) => console.log(q, filter1)
onSearchEvent.current = onSearch1
부모 렌더링 2 (filter 변경):
onSearch2 = (q) => console.log(q, filter2)
onSearchEvent.current = onSearch2 // 참조만 업데이트
→ Effect 재실행 안 함! ✅
→ 타이머 유지 ✅
300ms 후:
onSearchEvent(query) 호출
→ onSearch2 실행 (최신 filter 사용) ✅
useEffectEvent의 작동 원리
내부 구현 (개념적):
function useEffectEvent(handler) {
const ref = useRef(handler)
useLayoutEffect(() => {
ref.current = handler
}, [handler])
return useCallback((...args) => {
return ref.current(...args)
}, []) // 빈 의존성 → 안정적인 함수
}
핵심:
- 반환된 함수는 절대 변경되지 않음 (안정적)
- 호출 시 최신 handler 실행 (Ref 통해)
- Effect 의존성 배열에 추가 불필요
React 19 이전 버전 (Polyfill)
function useEvent<T extends (...args: any[]) => any>(handler: T): T {
const handlerRef = useRef(handler)
useLayoutEffect(() => {
handlerRef.current = handler
})
return useCallback((...args: any[]) => {
return handlerRef.current(...args)
}, []) as T
}
실전 예시: 채팅 메시지
function ChatRoom({ roomId, onMessage }: Props) {
const onMessageEvent = useEffectEvent(onMessage)
useEffect(() => {
const connection = createConnection(roomId)
connection.on('message', (msg) => {
onMessageEvent(msg) // 최신 onMessage 호출
})
connection.connect()
return () => connection.disconnect()
}, [roomId]) // onMessage 변경 시 재연결 안 함!
}
useEffectEvent vs useCallback 비교
| 특징 | useEffectEvent | useCallback |
|---|---|---|
| 함수 재생성 | 절대 안 함 | 의존성 변경 시 |
| 최신 값 접근 | 항상 | 의존성에 포함 필요 |
| 주 용도 | Effect 내부 | Props/자식 전달 |
| 의존성 배열 | 불필요 | 필요 |
언제 사용하나?
✅ useEffectEvent:
- Effect 내부에서만 사용하는 콜백
- 최신 props/state 필요하지만 Effect 재실행 원치 않음
- 디바운스, 인터벌, 이벤트 리스너
✅ useCallback:
- 자식 컴포넌트에 props로 전달
- 다른 훅의 의존성
- 메모이제이션 필요
주의사항
// ❌ 나쁨: Effect 외부에서 호출
const onEvent = useEffectEvent(handler)
onEvent() // 렌더링 중 호출 - 금지!
// ✅ 좋음: Effect 내부에서만
useEffect(() => {
onEvent() // 안전
}, [])
useEffectEvent로 생성된 함수는 렌더링 중이 아닌 Effect나 이벤트 핸들러에서만 호출해야 합니다.
시리즈 전체 정리: React Best Practices 여정
8개 섹션을 모두 살펴봤습니다. 각 섹션의 핵심을 다시 한번 정리합니다:
1. Eliminating Waterfalls (CRITICAL)
핵심: 순차 실행을 병렬 실행으로 변환
// Before: 300ms
const user = await fetchUser()
const posts = await fetchPosts()
// After: 100ms
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()])
영향: 2-10× 성능 향상, 가장 큰 효과
2. Bundle Size Optimization (CRITICAL)
핵심: 필요한 코드만 로드
// Before: 전체 라이브러리
import { Icon } from 'lucide-react'
// After: 필요한 아이콘만
import Icon from 'lucide-react/dist/esm/icons/icon'
영향: TTI/LCP 직접 개선, 200-800ms 절약
3. Server-Side Performance (HIGH)
핵심: 서버에서 병렬 페칭 및 인증 강화
// 컴포넌트 구성으로 병렬 페칭
function Page() {
return (
<>
<Header /> {/* 동시 fetch */}
<Sidebar /> {/* 동시 fetch */}
</>
)
}
영향: 응답 시간 단축, 보안 강화
4. Client-Side Data Fetching (MEDIUM-HIGH)
핵심: SWR로 자동 중복 제거
// 여러 컴포넌트가 같은 데이터 공유
const { data } = useSWR('/api/users', fetcher)
영향: 네트워크 요청 감소, 캐싱 자동화
5. Re-render Optimization (MEDIUM)
핵심: 불필요한 리렌더링 제거
// 함수형 setState로 stale closure 방지
setItems(curr => [...curr, newItem])
// 지연 초기화로 비용 큰 연산 한 번만
const [state] = useState(() => expensiveInit())
영향: UI 반응성 개선, CPU 사용량 감소
6. Rendering Performance (MEDIUM)
핵심: 브라우저 작업 최소화
/* content-visibility로 화면 밖 렌더링 연기 */
.list-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}
영향: 10× 빠른 초기 렌더링 (긴 목록)
7. JavaScript Performance (LOW-MEDIUM)
핵심: 알고리즘 복잡도 개선
// O(n) → O(1)
const allowedIds = new Set(['a', 'b', 'c'])
items.filter(item => allowedIds.has(item.id))
영향: 핫 패스에서 누적 효과
8. Advanced Patterns (LOW)
핵심: 특정 케이스를 위한 고급 패턴
// useEffectEvent로 안정적인 콜백
const onEvent = useEffectEvent(handler)
영향: 특정 문제의 우아한 해결
우선순위 가이드
모든 최적화를 한 번에 적용할 필요는 없습니다. 영향도 순으로 접근하세요:
Phase 1: 즉시 적용 (CRITICAL)
- Waterfall 제거: Promise.all()로 병렬화
- Barrel 임포트 제거: 직접 임포트
- 동적 임포트: 대형 컴포넌트 lazy load
- Server Actions 인증: 모든 action에 인증 체크
예상 효과: 50-70% 성능 개선
Phase 2: 핫 패스 최적화 (HIGH)
- content-visibility: 긴 목록
- React.cache(): 반복 쿼리
- toSorted(): 배열 변경 버그 방지
- Set/Map: O(n) → O(1)
예상 효과: 추가 20-30% 개선
Phase 3: 세밀한 튜닝 (MEDIUM)
- 함수형 setState: Stale closure 방지
- 지연 초기화: 비용 큰 연산
- Suspense 경계: 레이아웃 우선 표시
- SWR: 자동 중복 제거
예상 효과: UI 반응성 개선
Phase 4: 마이크로 최적화 (LOW)
- 조기 반환: 불필요한 연산 제거
- 반복 결합: 여러 filter → 단일 loop
- useEffectEvent: Effect 재실행 방지
예상 효과: 세부적인 개선
측정하기
최적화 전후를 반드시 측정하세요:
Chrome DevTools
// Performance 탭
1. 녹화 시작
2. 사용자 시나리오 수행
3. 녹화 중지
4. Main 스레드 분석
React DevTools Profiler
1. Profiler 탭 열기
2. 녹화 시작
3. 액션 수행
4. Flamegraph 분석
Web Vitals
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
getCLS(console.log)
getFID(console.log)
getFCP(console.log)
getLCP(console.log)
getTTFB(console.log)
마치며
React Best Practices는 은탄환이 아닙니다. 상황에 맞게 적용해야 합니다:
언제 최적화하나?
✅ 최적화하세요:
- 사용자가 느린 것을 느낄 때
- 측정 결과가 문제를 보여줄 때
- 핫 패스 (자주 실행되는 코드)
❌ 최적화하지 마세요:
- 측정하지 않았을 때
- 한 번만 실행되는 코드
- 과도한 복잡도 증가
기억할 점
- 측정 먼저: 추측하지 말고 측정하기
- 큰 것부터: CRITICAL → HIGH → MEDIUM → LOW
- 가독성 유지: 성능과 유지보수성의 균형
- React Compiler: 수동 메모이제이션 대부분 자동화
더 알아보기
시리즈를 마치며
8편에 걸쳐 Vercel의 React Best Practices를 깊이 있게 살펴봤습니다. 각 패턴의 원리를 이해하고 언제 적용할지 아는 것이 중요합니다.
최적화는 여정입니다. 한 번에 모든 것을 적용하려 하지 말고, 측정하고, 개선하고, 다시 측정하세요.
여러분의 React 앱이 더 빠르고 반응적이 되기를 바랍니다! 🚀
시리즈 전체 목록:
- Eliminating Waterfalls - 성능의 최대 적
- Bundle Size Optimization - 초기 로딩의 핵심
- Server-Side Performance - 서버에서의 최적화
- Client-Side Data Fetching - 효율적인 클라이언트 데이터 관리
- Re-render Optimization - 불필요한 렌더링 제거
- Rendering Performance - 브라우저 렌더링 최적화
- JavaScript Performance - 핫 패스 마이크로 최적화
- Advanced Patterns - 신중한 구현이 필요한 고급 패턴 ← 현재 글