이 글은 Vercel에서 작성한 React Best Practice와 소스 코드를 보며 톺아보는 글입니다.
|AI에게 "이 코드를 React Best Practice에 맞게 리팩토링해줘"라고 요청할 수도 있지만,
왜 그 코드가 더 나은지, 어떤 원리로 성능이 개선되는지 이해하는 것이 중요합니다.
이 시리즈에서는 Vercel이 정의한 8가지 카테고리를 하나씩 깊이 있게 살펴봅니다.
각 Best Practice의 이론적 배경, 실제 성능 차이, 적용 시점을 중심으로 분석합니다.
이 글은 sections에서 소개하는 8가지 섹션 중 7번째 섹션인 JavaScript 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)
7. JavaScript Performance: 핫 패스 마이크로 최적화
JavaScript 성능이 중요한 이유
React 앱의 대부분 시간은 JavaScript 실행에 사용됩니다. 개별 최적화는 작아 보이지만, 핫 패스(hot path)에 적용하면 누적 효과가 큽니다.
핫 패스란?
- 자주 실행되는 코드 경로
- 렌더링마다 실행되는 함수
- 큰 배열을 처리하는 루프
- 이벤트 핸들러
JavaScript 성능 최적화의 핵심:
- 알고리즘 복잡도 개선: O(n²) → O(n)
- 불필요한 작업 제거: 캐싱, 조기 반환
- 적절한 자료구조: Array → Set/Map
7-1. 불변성을 위한 toSorted() 사용 (MEDIUM-HIGH)
Impact: MEDIUM-HIGH (React 상태 변경 버그 방지)
.sort()는 배열을 제자리에서 변경하여 React 상태 및 props와 함께 사용할 때 버그를 발생시킬 수 있습니다. 변경 없이 새 정렬된 배열을 생성하는 .toSorted()를 사용하세요.
문제: 원본 배열 변경
function UserList({ users }: { users: User[] }) {
// users prop 배열을 변경!
const sorted = useMemo(
() => users.sort((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
문제점:
const users = [{ name: 'Bob' }, { name: 'Alice' }]
// users.sort()가 users를 변경
const sorted = users.sort((a, b) => a.name.localeCompare(b.name))
console.log(users === sorted) // true (같은 배열!)
console.log(users) // [{ name: 'Alice' }, { name: 'Bob' }] (변경됨!)
React에서 왜 문제인가?
1. Props/State 불변성 위반:
function Parent() {
const [users, setUsers] = useState([...])
return <UserList users={users} />
}
function UserList({ users }) {
// 부모의 users 상태를 직접 변경!
users.sort(...)
// React는 변경을 감지 못함
}
2. Stale Closure 버그:
function Component({ items }) {
useEffect(() => {
const sorted = items.sort(...)
// 나중에 items를 참조하면 이미 변경된 상태
}, [items])
}
개선: 새 배열 생성
function UserList({ users }: { users: User[] }) {
// 새 정렬된 배열 생성, 원본 변경 안 됨
const sorted = useMemo(
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
toSorted()의 작동 원리
const original = [3, 1, 2]
// sort() - 제자리 변경
const sorted1 = original.sort()
console.log(original) // [1, 2, 3] (변경됨!)
console.log(sorted1 === original) // true
// toSorted() - 새 배열 반환
const original2 = [3, 1, 2]
const sorted2 = original2.toSorted()
console.log(original2) // [3, 1, 2] (변경 안 됨!)
console.log(sorted2) // [1, 2, 3]
console.log(sorted2 === original2) // false
브라우저 지원 (Fallback)
.toSorted()는 최신 브라우저에서 사용 가능합니다 (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). 구형 환경의 경우:
// Fallback
const sorted = [...items].sort((a, b) => a.value - b.value)
다른 불변 배열 메서드
// toReversed() - 불변 reverse
const reversed = arr.toReversed()
// toSpliced() - 불변 splice
const spliced = arr.toSpliced(1, 2, 'new')
// with() - 불변 요소 교체
const updated = arr.with(0, 'new value')
7-2. 배열 비교 시 길이 먼저 확인 (MEDIUM-HIGH)
Impact: MEDIUM-HIGH (비용 큰 연산 회피)
비용이 큰 연산(정렬, 깊은 동등성, 직렬화)으로 배열을 비교할 때는 길이를 먼저 확인하세요. 길이가 다르면 배열은 같을 수 없습니다.
문제: 항상 비용 큰 비교 실행
function hasChanges(current: string[], original: string[]) {
// 길이가 달라도 항상 정렬 및 join
return current.sort().join() !== original.sort().join()
}
복잡도 분석:
current.length = 5, original.length = 100
1. current.sort() → O(5 log 5)
2. original.sort() → O(100 log 100)
3. join() → O(5 + 100)
4. 문자열 비교 → O(5 + 100)
총: O(n log n) + O(m log m) + O(n + m)
길이가 다르면 절대 같을 수 없는데, 정렬과 join을 모두 실행합니다!
개선: O(1) 길이 체크 먼저
function hasChanges(current: string[], original: string[]) {
// 길이가 다르면 조기 반환
if (current.length !== original.length) {
return true // O(1)
}
// 길이가 같을 때만 정렬
const currentSorted = current.toSorted()
const originalSorted = original.toSorted()
for (let i = 0; i < currentSorted.length; i++) {
if (currentSorted[i] !== originalSorted[i]) {
return true
}
}
return false
}
최적화 효과:
current.length = 5, original.length = 100
1. 길이 비교: O(1)
2. 다르므로 즉시 반환 ✅
총: O(1) (대신 O(n log n + m log m))
실전 예시: 태그 비교
// ❌ 나쁨
function tagsChanged(current: string[], saved: string[]) {
return JSON.stringify(current.sort()) !== JSON.stringify(saved.sort())
}
// ✅ 좋음
function tagsChanged(current: string[], saved: string[]) {
if (current.length !== saved.length) return true
const sortedCurrent = current.toSorted()
const sortedSaved = saved.toSorted()
return sortedCurrent.some((tag, i) => tag !== sortedSaved[i])
}
다른 활용
// 깊은 동등성 비교
function deepEqual(a: any[], b: any[]) {
if (a.length !== b.length) return false // O(1) 조기 반환
return a.every((item, i) => deepCompare(item, b[i]))
}
// Set 비교
function setsEqual(a: Set<string>, b: Set<string>) {
if (a.size !== b.size) return false // O(1)
return [...a].every(item => b.has(item))
}
7-3. 반복되는 함수 호출 캐싱 (MEDIUM)
Impact: MEDIUM (중복 연산 방지)
렌더링 중 같은 입력으로 반복 호출되는 함수는 모듈 레벨 Map을 사용하여 결과를 캐싱하세요.
문제: 중복 연산
function ProjectList({ projects }: { projects: Project[] }) {
return (
<div>
{projects.map(project => {
// slugify()가 같은 프로젝트 이름에 100+ 번 호출
const slug = slugify(project.name)
return <ProjectCard key={project.id} slug={slug} />
})}
</div>
)
}
타임라인:
projects = [
{ name: 'React' }, // slugify('React')
{ name: 'Vue' }, // slugify('Vue')
{ name: 'React' }, // slugify('React') 다시!
{ name: 'React' }, // slugify('React') 또!
...
]
총 slugify() 호출: 1000번
고유 이름 수: 10개
중복 연산: 990번
개선: 모듈 레벨 캐시
// 모듈 레벨 캐시
const slugifyCache = new Map<string, string>()
function cachedSlugify(text: string): string {
if (slugifyCache.has(text)) {
return slugifyCache.get(text)!
}
const result = slugify(text)
slugifyCache.set(text, result)
return result
}
function ProjectList({ projects }: { projects: Project[] }) {
return (
<div>
{projects.map(project => {
// 고유 프로젝트 이름당 한 번만 계산
const slug = cachedSlugify(project.name)
return <ProjectCard key={project.id} slug={slug} />
})}
</div>
)
}
타임라인:
첫 'React': slugify() 실행 → 캐시 저장
두 번째 'React': 캐시 히트 ✅
세 번째 'React': 캐시 히트 ✅
...
총 slugify() 호출: 10번 (고유 이름만)
캐시 히트: 990번
왜 모듈 레벨인가?
// ❌ 나쁨: 컴포넌트 내부 캐시
function Component() {
const cache = useMemo(() => new Map(), [])
// React 컴포넌트에서만 작동
}
// ✅ 좋음: 모듈 레벨 캐시
const cache = new Map()
// 어디서든 작동: 유틸리티, 이벤트 핸들러, 컴포넌트
단순 값 캐싱
let isLoggedInCache: boolean | null = null
function isLoggedIn(): boolean {
if (isLoggedInCache !== null) {
return isLoggedInCache
}
isLoggedInCache = document.cookie.includes('auth=')
return isLoggedInCache
}
// 인증 변경 시 캐시 무효화
function onAuthChange() {
isLoggedInCache = null
}
캐시 크기 관리
const cache = new Map<string, string>()
const MAX_SIZE = 1000
function cachedFunction(key: string): string {
if (cache.has(key)) return cache.get(key)!
const result = expensiveOperation(key)
// LRU 캐시: 크기 초과 시 가장 오래된 항목 제거
if (cache.size >= MAX_SIZE) {
const firstKey = cache.keys().next().value
cache.delete(firstKey)
}
cache.set(key, result)
return result
}
참고: How we made the Vercel Dashboard twice as fast
7-4. Layout Thrashing 방지 (MEDIUM)
Impact: MEDIUM (강제 동기 레이아웃 방지)
스타일 쓰기와 레이아웃 읽기를 섞지 마세요. 스타일 변경 사이에 레이아웃 속성을 읽으면 브라우저가 강제로 동기 리플로우를 트리거합니다.
문제: 읽기와 쓰기 섞임
function layoutThrashing(element: HTMLElement) {
element.style.width = '100px'
const width = element.offsetWidth // 강제 리플로우!
element.style.height = '200px'
const height = element.offsetHeight // 또 강제 리플로우!
}
브라우저 타임라인:
1. style.width = '100px'
→ 스타일 무효화 (Layout 필요)
2. offsetWidth 읽기
→ 브라우저: "Layout이 무효화됐네, 지금 계산해야 해!"
→ 강제 동기 Layout 실행 (비쌈!)
3. style.height = '200px'
→ 스타일 무효화
4. offsetHeight 읽기
→ 또 강제 동기 Layout! (비쌈!)
정상 동작: 쓰기만
function normalUpdate(element: HTMLElement) {
element.style.width = '100px'
element.style.height = '200px'
element.style.backgroundColor = 'blue'
// 브라우저가 배치 처리 ✅
}
브라우저 타임라인:
1. style.width = '100px' → 무효화
2. style.height = '200px' → 무효화
3. style.backgroundColor = 'blue' → 무효화
4. 프레임 끝에 한 번만 Layout 실행 ✅
개선 패턴 1: 읽기 먼저, 쓰기 나중에
function avoidThrashing(element: HTMLElement) {
// 읽기 단계 - 모든 레이아웃 쿼리 먼저
const rect = element.getBoundingClientRect()
const offsetWidth = element.offsetWidth
const offsetHeight = element.offsetHeight
// 쓰기 단계 - 모든 스타일 변경 나중에
element.style.width = `${offsetWidth * 2}px`
element.style.height = `${offsetHeight * 2}px`
}
개선 패턴 2: CSS 클래스 사용 (더 좋음)
.highlighted-box {
width: 100px;
height: 200px;
background-color: blue;
border: 1px solid black;
}
function updateElementStyles(element: HTMLElement) {
// 하나의 클래스 추가
element.classList.add('highlighted-box')
// 레이아웃 읽기 (한 번만 리플로우)
const { width, height } = element.getBoundingClientRect()
}
React 예시
// ❌ 나쁨: 스타일 변경과 레이아웃 쿼리 섞임
function Box({ isHighlighted }: { isHighlighted: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current && isHighlighted) {
ref.current.style.width = '100px'
const width = ref.current.offsetWidth // 강제 레이아웃!
ref.current.style.height = '200px'
}
}, [isHighlighted])
return <div ref={ref}>Content</div>
}
// ✅ 좋음: 클래스 토글
function Box({ isHighlighted }: { isHighlighted: boolean }) {
return (
<div className={isHighlighted ? 'highlighted-box' : ''}>
Content
</div>
)
}
Layout 강제하는 속성들
읽기 (Layout 트리거):
offsetWidth,offsetHeightoffsetTop,offsetLeftclientWidth,clientHeightgetBoundingClientRect()getComputedStyle()scrollTop,scrollHeight
쓰기 (Layout 무효화):
element.style.*변경classList조작- DOM 추가/제거
참고: CSS Triggers - 어떤 CSS 속성이 Layout/Paint를 트리거하는지 확인
7-5. O(1) 조회를 위한 Set/Map 사용 (LOW-MEDIUM)
Impact: LOW-MEDIUM (O(n) → O(1))
반복된 멤버십 검사를 위해 배열을 Set/Map으로 변환하세요.
문제: O(n) 조회
const allowedIds = ['a', 'b', 'c', 'd', 'e', ...] // 1000개
items.filter(item => allowedIds.includes(item.id))
복잡도:
items: 10,000개
allowedIds: 1,000개
각 item마다:
allowedIds.includes() → O(1000) 평균
총: O(10,000 × 1,000) = O(10,000,000) 연산
개선: O(1) 조회
const allowedIds = new Set(['a', 'b', 'c', 'd', 'e', ...])
items.filter(item => allowedIds.has(item.id))
복잡도:
Set 생성: O(1,000)
각 item마다:
allowedIds.has() → O(1)
총: O(1,000) + O(10,000) = O(11,000) 연산
약 900배 빠름!
Array vs Set 성능
const arr = Array.from({ length: 1000 }, (_, i) => i)
const set = new Set(arr)
console.time('array')
for (let i = 0; i < 1000; i++) {
arr.includes(500) // 평균 500번 반복
}
console.timeEnd('array') // ~5ms
console.time('set')
for (let i = 0; i < 1000; i++) {
set.has(500) // 해시 조회
}
console.timeEnd('set') // ~0.1ms
Map으로 객체 조회
// ❌ O(n) - find() 사용
function processOrders(orders: Order[], users: User[]) {
return orders.map(order => ({
...order,
user: users.find(u => u.id === order.userId)
}))
}
// ✅ O(1) - Map 사용
function processOrders(orders: Order[], users: User[]) {
const userById = new Map(users.map(u => [u.id, u]))
return orders.map(order => ({
...order,
user: userById.get(order.userId)
}))
}
성능 차이:
1000 orders × 1000 users:
find(): 1,000,000번 비교
Map: 1,000번 (Map 생성) + 1,000번 (조회) = 2,000번
500배 빠름!
7-6. 반복 조회를 위한 인덱스 Map 구축 (LOW-MEDIUM)
Impact: LOW-MEDIUM (1M 연산 → 2K 연산)
같은 키로 여러 번 .find() 호출하는 경우 Map을 사용하세요.
문제: 반복된 find()
function enrichOrders(orders: Order[], products: Product[]) {
return orders.map(order => {
const product = products.find(p => p.id === order.productId)
return { ...order, productName: product?.name }
})
}
복잡도:
100 orders × 1,000 products
각 order마다 products.find():
평균 500개 항목 확인
총: 100 × 500 = 50,000번 비교
개선: 한 번 인덱싱, 여러 번 조회
function enrichOrders(orders: Order[], products: Product[]) {
// 한 번 인덱싱: O(n)
const productById = new Map(
products.map(p => [p.id, p])
)
// 각 조회: O(1)
return orders.map(order => ({
...order,
productName: productById.get(order.productId)?.name
}))
}
복잡도:
Map 생성: 1,000번 (products 수)
조회: 100번 (orders 수)
총: 1,100번
45배 빠름!
실전 예시: 중첩 조회
// ❌ O(n²) - 중첩 find
function buildThreads(messages: Message[], users: User[]) {
return messages.map(msg => ({
...msg,
author: users.find(u => u.id === msg.authorId),
replyTo: msg.replyToId
? messages.find(m => m.id === msg.replyToId)
: null
}))
}
// ✅ O(n) - 인덱스 Map
function buildThreads(messages: Message[], users: User[]) {
const userById = new Map(users.map(u => [u.id, u]))
const messageById = new Map(messages.map(m => [m.id, m]))
return messages.map(msg => ({
...msg,
author: userById.get(msg.authorId),
replyTo: msg.replyToId
? messageById.get(msg.replyToId)
: null
}))
}
7-7. 여러 배열 반복 결합 (LOW-MEDIUM)
Impact: LOW-MEDIUM (반복 횟수 감소)
여러 .filter() 또는 .map() 호출은 배열을 여러 번 반복합니다. 하나의 루프로 결합하세요.
문제: 3번 반복
const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)
실행:
반복 1: 1,000명 전체 확인 (isAdmin)
반복 2: 1,000명 전체 확인 (isTester)
반복 3: 1,000명 전체 확인 (isActive)
총: 3,000번 확인
개선: 1번 반복
const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []
for (const user of users) {
if (user.isAdmin) admins.push(user)
if (user.isTester) testers.push(user)
if (!user.isActive) inactive.push(user)
}
실행:
반복 1: 1,000명 확인 (모든 조건)
총: 1,000번 확인
3배 빠름!
언제 효과적인가?
✅ 결합하기 좋음:
- 같은 배열에 대한 여러 filter
- 배열이 큼 (1000+ 항목)
- 핫 패스 (자주 실행)
❌ 결합 불필요:
- 배열이 작음 (< 100)
- 한 번만 실행
- 가독성이 중요
reduce로 결합
const { admins, testers, inactive } = users.reduce((acc, user) => {
if (user.isAdmin) acc.admins.push(user)
if (user.isTester) acc.testers.push(user)
if (!user.isActive) acc.inactive.push(user)
return acc
}, { admins: [], testers: [], inactive: [] })
7-8. 함수에서 조기 반환 (LOW-MEDIUM)
Impact: LOW-MEDIUM (불필요한 연산 방지)
결과가 결정되면 조기 반환하여 불필요한 처리를 건너뛰세요.
문제: 답을 찾은 후에도 계속 처리
function validateUsers(users: User[]) {
let hasError = false
let errorMessage = ''
for (const user of users) {
if (!user.email) {
hasError = true
errorMessage = 'Email required'
}
if (!user.name) {
hasError = true
errorMessage = 'Name required'
}
// 에러를 찾아도 모든 사용자 계속 확인
}
return hasError ? { valid: false, error: errorMessage } : { valid: true }
}
실행:
users: 10,000명
첫 번째 사용자에 에러 발견
하지만:
- 나머지 9,999명 모두 확인
- 불필요한 9,999번 반복
개선: 첫 에러에서 즉시 반환
function validateUsers(users: User[]) {
for (const user of users) {
if (!user.email) {
return { valid: false, error: 'Email required' }
}
if (!user.name) {
return { valid: false, error: 'Name required' }
}
}
return { valid: true }
}
실행:
users: 10,000명
첫 번째 사용자에 에러 발견
→ 즉시 반환! ✅
→ 1번 확인만 수행
→ 9,999배 빠름!
실전 예시
// 권한 확인
function checkPermission(user: User, resource: Resource) {
if (!user) return false
if (user.role === 'admin') return true // 조기 반환
if (resource.ownerId === user.id) return true
if (user.groups.includes(resource.groupId)) return true
return false
}
// 배열 검색
function findFirst(items: Item[], predicate: (item: Item) => boolean) {
for (const item of items) {
if (predicate(item)) {
return item // 찾으면 즉시 반환
}
}
return null
}
섹션 7 정리: JavaScript Performance의 핵심
- toSorted() 사용: 배열 변경 방지로 React 버그 예방
- 길이 먼저 확인: 비용 큰 배열 비교 전 조기 반환
- 함수 캐싱: 반복 호출 시 모듈 레벨 Map 캐시
- Layout Thrashing 방지: 읽기/쓰기 분리 또는 CSS 클래스 사용
- Set/Map 사용: O(n) 조회를 O(1)로 개선
- 인덱스 Map: 반복 find()를 Map 조회로 대체
- 반복 결합: 여러 filter를 하나의 루프로
- 조기 반환: 결과 결정 즉시 반환
Vercel이 이 섹션을 LOW-MEDIUM으로 분류한 이유는 개별 최적화의 영향이 작지만, 핫 패스에 적용하면 누적 효과가 크기 때문입니다. 특히 큰 배열을 다루거나 자주 실행되는 코드에서는 극적인 성능 개선이 가능합니다.
JavaScript 최적화 체크리스트
- 배열 정렬/역순에
.toSorted(),.toReversed()사용 - 배열 비교 전 길이 확인
- 반복 호출되는 비용 큰 함수 캐싱
- DOM 스타일 변경을 CSS 클래스로 대체
- 멤버십 검사에 Set 사용
- 반복 조회에 인덱스 Map 구축
- 여러 filter를 단일 루프로 결합
- 검증/검색 함수에 조기 반환 추가
- Chrome DevTools Profiler로 핫 패스 식별
측정하기:
console.time('operation')
// 최적화 대상 코드
console.timeEnd('operation')
작은 최적화도 핫 패스에서는 큰 차이를 만듭니다!
다음 편 예고
다음 글에서는 Advanced Patterns (LOW)를 다룹니다. 특정 케이스에서 신중한 구현이 필요한 고급 패턴을 살펴봅니다.