이 글은 Vercel에서 작성한 React Best Practice와 소스 코드를 보며 톺아보는 글입니다.
AI에게 "이 코드를 React Best Practice에 맞게 리팩토링해줘"라고 요청할 수도 있지만,
왜 그 코드가 더 나은지, 어떤 원리로 성능이 개선되는지 이해하는 것이 중요합니다.
이 시리즈에서는 Vercel이 정의한 8가지 카테고리를 하나씩 깊이 있게 살펴봅니다.
각 Best Practice의 이론적 배경, 실제 성능 차이, 적용 시점을 중심으로 분석합니다.
이 글은 sections에서 소개하는 8가지 섹션 중 4번째 섹션인 Client-Side Data Fetching을 다룹니다.
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)
4. Client-Side Data Fetching: 효율적인 클라이언트 데이터 관리
클라이언트 사이드 데이터 페칭이 중요한 이유
React Server Components의 등장으로 많은 데이터 페칭이 서버로 이동했지만, 클라이언트 사이드 페칭은 여전히 중요합니다:
- 사용자 인터랙션 기반 데이터: 검색, 필터링, 실시간 업데이트
- 인증 후 데이터: 로그인 후 fetch되는 개인화된 정보
- 브라우저 API 의존 데이터: localStorage, 위치 정보 등
클라이언트 데이터 페칭의 핵심은:
- 자동 중복 제거: 같은 요청이 여러 컴포넌트에서 발생해도 한 번만 실행
- 효율적인 캐싱: 불필요한 네트워크 요청 감소
- 성능 최적화: 이벤트 리스너와 저장소 관리
4-1. SWR로 자동 중복 제거 (MEDIUM-HIGH)
Impact: MEDIUM-HIGH (자동 중복 제거)
SWR은 요청 중복 제거, 캐싱, 재검증을 컴포넌트 인스턴스 간에 자동으로 처리합니다.
문제 코드: 중복 제거 없음, 각 인스턴스가 개별 fetch
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
}, [])
return <div>{users.map(u => u.name)}</div>
}
function App() {
return (
<>
<UserList /> {/* fetch 1 */}
<UserList /> {/* fetch 2 */}
<UserList /> {/* fetch 3 */}
</>
)
}
// 3개의 동일한 네트워크 요청
문제점:
- 같은 데이터를 3번 요청
- 네트워크 대역폭 낭비
- 서버 부하 증가
- 일관성 문제 (요청 간 데이터가 달라질 수 있음)
개선 코드: 여러 인스턴스가 하나의 요청 공유
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(r => r.json())
function UserList() {
const { data: users, error, isLoading } = useSWR('/api/users', fetcher)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <div>{users.map(u => u.name)}</div>
}
function App() {
return (
<>
<UserList /> {/* fetch 실행 */}
<UserList /> {/* 캐시 히트 */}
<UserList /> {/* 캐시 히트 */}
</>
)
}
// 1개의 네트워크 요청, 3개 컴포넌트 모두 공유
SWR의 핵심 기능
1. 자동 중복 제거
// 같은 키를 사용하는 모든 useSWR이 하나의 요청 공유
useSWR('/api/users', fetcher) // 요청 1
useSWR('/api/users', fetcher) // 요청 1 재사용
2. 자동 재검증
// 포커스 시 자동으로 데이터 갱신
const { data } = useSWR('/api/users', fetcher, {
revalidateOnFocus: true, // 탭 전환 후 돌아올 때
revalidateOnReconnect: true, // 네트워크 재연결 시
refreshInterval: 30000 // 30초마다 폴링
})
3. Optimistic UI 업데이트
const { data, mutate } = useSWR('/api/users', fetcher)
const addUser = async (newUser) => {
// 즉시 UI 업데이트 (낙관적)
mutate([...data, newUser], false)
// 실제 API 호출
await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser)
})
// 서버 데이터로 재검증
mutate()
}
불변 데이터용: useImmutableSWR
설정 파일처럼 변하지 않는 데이터는 재검증을 비활성화하세요:
import useSWR from 'swr'
function useImmutableSWR(key: string, fetcher: Fetcher) {
return useSWR(key, fetcher, {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false
})
}
function StaticContent() {
const { data } = useImmutableSWR('/api/config', fetcher)
return <div>{data.siteName}</div>
}
Mutation용: useSWRMutation
POST, PUT, DELETE 같은 변경 작업은 useSWRMutation을 사용하세요:
import { useSWRMutation } from 'swr/mutation'
async function updateUser(url: string, { arg }: { arg: User }) {
return fetch(url, {
method: 'PUT',
body: JSON.stringify(arg)
}).then(r => r.json())
}
function UpdateButton() {
const { trigger, isMutating } = useSWRMutation('/api/user', updateUser)
return (
<button
onClick={() => trigger({ name: 'John' })}
disabled={isMutating}
>
{isMutating ? 'Updating...' : 'Update'}
</button>
)
}
SWR vs React Query 비교
| 특징 | SWR | React Query |
|---|---|---|
| 번들 크기 | ~5KB | ~13KB |
| 학습 곡선 | 낮음 | 중간 |
| 기본 기능 | 충분 | 풍부 |
| Devtools | 없음 | 있음 |
| 적합한 경우 | 단순한 앱 | 복잡한 앱 |
참고: SWR 공식 문서
4-2. localStorage 데이터 버전 관리 및 최소화 (MEDIUM)
Impact: MEDIUM (스키마 충돌 방지, 저장소 크기 감소)
키에 버전 접두사를 추가하고 필요한 필드만 저장하세요. 스키마 충돌을 방지하고 민감한 데이터의 실수로 인한 저장을 막습니다.
문제 코드: 버전 없음, 모든 것 저장, 에러 처리 없음
// 위험: 버전 없음, 전체 객체 저장
localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
const data = localStorage.getItem('userConfig')
const config = JSON.parse(data) // Safari 프라이빗 모드에서 에러
문제점:
- 스키마 변경 시 충돌: 앱 업데이트 후 이전 데이터 형식과 충돌
- 민감 데이터 저장: 토큰, PII가 실수로 저장될 수 있음
- 에러 처리 없음: Safari/Firefox 프라이빗 모드, 할당량 초과 시 에러
- 불필요한 저장: 20개 필드 중 2개만 필요한데 전부 저장
개선 코드: 버전 관리 및 최소 필드
const VERSION = 'v2'
function saveConfig(config: { theme: string; language: string }) {
try {
localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
} catch {
// 프라이빗 브라우징, 할당량 초과, 또는 비활성화 시 throw
}
}
function loadConfig() {
try {
const data = localStorage.getItem(`userConfig:${VERSION}`)
return data ? JSON.parse(data) : null
} catch {
return null
}
}
버전 마이그레이션
// v1에서 v2로 마이그레이션
function migrate() {
try {
const v1 = localStorage.getItem('userConfig:v1')
if (v1) {
const old = JSON.parse(v1)
saveConfig({
theme: old.darkMode ? 'dark' : 'light',
language: old.lang
})
localStorage.removeItem('userConfig:v1')
}
} catch {}
}
// 앱 초기화 시 실행
useEffect(() => {
migrate()
}, [])
서버 응답에서 최소 필드만 저장
// User 객체에 20+ 필드가 있지만, UI에 필요한 것만 저장
function cachePrefs(user: FullUser) {
try {
localStorage.setItem('prefs:v1', JSON.stringify({
theme: user.preferences.theme,
notifications: user.preferences.notifications
}))
} catch {}
}
// ❌ 나쁨: 전체 user 객체 저장 (토큰, 이메일 등 포함)
localStorage.setItem('user', JSON.stringify(fullUser))
// ✅ 좋음: 필요한 것만
localStorage.setItem('prefs:v1', JSON.stringify({
theme: fullUser.theme,
lang: fullUser.language
}))
localStorage 에러 처리가 중요한 이유
// Safari/Firefox 프라이빗 모드에서 setItem()은 throw
try {
localStorage.setItem('key', 'value')
} catch (e) {
// SecurityError: The operation is insecure
console.warn('localStorage unavailable')
}
// getItem()도 일부 상황에서 throw 가능
try {
const data = localStorage.getItem('key')
} catch (e) {
console.warn('localStorage read failed')
}
실전 패턴: Type-safe localStorage wrapper
type StorageKey = 'theme' | 'language' | 'notifications'
interface StorageSchema {
theme: { mode: 'light' | 'dark' }
language: { code: string }
notifications: { enabled: boolean }
}
function getItem<K extends StorageKey>(
key: K,
version: string = 'v1'
): StorageSchema[K] | null {
try {
const data = localStorage.getItem(`${key}:${version}`)
return data ? JSON.parse(data) : null
} catch {
return null
}
}
function setItem<K extends StorageKey>(
key: K,
value: StorageSchema[K],
version: string = 'v1'
): boolean {
try {
localStorage.setItem(`${key}:${version}`, JSON.stringify(value))
return true
} catch {
return false
}
}
// 사용
const theme = getItem('theme') // 타입 안전
setItem('theme', { mode: 'dark' })
이점
- 스키마 진화: 버전 관리를 통한 안전한 업데이트
- 저장소 크기 감소: 필요한 것만 저장
- 보안: 토큰/PII/내부 플래그 저장 방지
- 안정성: 에러 처리로 앱 크래시 방지
4-3. 스크롤 성능을 위한 Passive 이벤트 리스너 (MEDIUM)
Impact: MEDIUM (이벤트 리스너로 인한 스크롤 지연 제거)
터치 및 휠 이벤트 리스너에 { passive: true }를 추가하여 즉각적인 스크롤을 활성화하세요.
문제 코드: 스크롤 지연 발생
useEffect(() => {
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
document.addEventListener('touchstart', handleTouch)
document.addEventListener('wheel', handleWheel)
return () => {
document.removeEventListener('touchstart', handleTouch)
document.removeEventListener('wheel', handleWheel)
}
}, [])
문제점:
- 브라우저는 리스너가 완료될 때까지 대기
preventDefault()호출 여부 확인 필요- 스크롤/터치가 수십~수백 ms 지연됨
- 사용자 경험 저하
왜 브라우저가 기다리나?
// 브라우저의 고민:
// "이 리스너가 preventDefault()를 호출할까?
// 호출하면 스크롤을 취소해야 하는데..."
element.addEventListener('touchstart', (e) => {
// 브라우저: "이 함수가 끝날 때까지 스크롤 대기"
doSomething()
// e.preventDefault() 호출 가능성이 있음
})
브라우저는 리스너가 preventDefault()를 호출할지 알 수 없으므로, 모든 리스너가 완료될 때까지 스크롤을 차단합니다.
개선 코드: Passive 옵션으로 즉시 스크롤
useEffect(() => {
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
document.addEventListener('touchstart', handleTouch, { passive: true })
document.addEventListener('wheel', handleWheel, { passive: true })
return () => {
document.removeEventListener('touchstart', handleTouch)
document.removeEventListener('wheel', handleWheel)
}
}, [])
passive: true의 의미:
"이 리스너는 절대 preventDefault()를 호출하지 않아요. 스크롤을 바로 시작하세요!"
실전 예시: 분석 추적
// 스크롤 깊이 추적 (분석용)
useEffect(() => {
const trackScroll = () => {
const scrollPercent = (window.scrollY / document.body.scrollHeight) * 100
analytics.track('scroll_depth', { percent: scrollPercent })
}
// passive: true - 분석은 스크롤을 차단하면 안 됨
window.addEventListener('scroll', trackScroll, { passive: true })
return () => window.removeEventListener('scroll', trackScroll)
}, [])
언제 Passive를 사용하나?
✅ 사용해야 할 때:
- 추적/분석
- 로깅
- 애니메이션 동기화
preventDefault()를 호출하지 않는 모든 리스너
❌ 사용하지 말아야 할 때:
- 커스텀 스와이프 제스처 구현
- 커스텀 줌 컨트롤
preventDefault()가 필요한 모든 리스너
잘못 사용한 예
// ❌ 잘못됨: passive인데 preventDefault() 호출
element.addEventListener('touchstart', (e) => {
e.preventDefault() // 무시됨! 경고만 출력
// Custom gesture implementation
}, { passive: true })
// ✅ 올바름: passive 없이 사용
element.addEventListener('touchstart', (e) => {
e.preventDefault()
// Custom gesture implementation
})
성능 영향
Chrome DevTools의 Performance 탭에서 확인 가능:
passive: false (기본값)
Touchstart event → Wait for handler → Check preventDefault → Start scroll
|--- 16ms ---|--- handler time ---|--- 10ms ---|
Total: 26ms+ 지연
passive: true
Touchstart event → Start scroll immediately
|--- 0ms delay ---|
Handler는 백그라운드에서 실행
적용 대상 이벤트
touchstarttouchmovewheelmousewheel
이 이벤트들은 스크롤과 직접 연관되므로 passive 옵션의 영향이 큽니다.
4-4. 전역 이벤트 리스너 중복 제거 (LOW)
Impact: LOW (N개 컴포넌트에 1개 리스너)
useSWRSubscription()을 사용하여 컴포넌트 인스턴스 간에 전역 이벤트 리스너를 공유하세요.
문제 코드: N개 인스턴스 = N개 리스너
function useKeyboardShortcut(key: string, callback: () => void) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.key === key) {
callback()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [key, callback])
}
function App() {
return (
<>
<Component1 /> {/* keydown 리스너 1 */}
<Component2 /> {/* keydown 리스너 2 */}
<Component3 /> {/* keydown 리스너 3 */}
</>
)
}
// window에 3개의 keydown 리스너 등록
문제점:
- 컴포넌트 인스턴스만큼 리스너 등록
- 메모리 사용 증가
- 이벤트 처리 오버헤드
- 10개 컴포넌트 = 10개 리스너
개선 코드: N개 인스턴스 = 1개 리스너
import useSWRSubscription from 'swr/subscription'
// 모듈 레벨 Map으로 키별 콜백 추적
const keyCallbacks = new Map<string, Set<() => void>>()
function useKeyboardShortcut(key: string, callback: () => void) {
// 이 콜백을 Map에 등록
useEffect(() => {
if (!keyCallbacks.has(key)) {
keyCallbacks.set(key, new Set())
}
keyCallbacks.get(key)!.add(callback)
return () => {
const set = keyCallbacks.get(key)
if (set) {
set.delete(callback)
if (set.size === 0) {
keyCallbacks.delete(key)
}
}
}
}, [key, callback])
// 전역 리스너는 한 번만 등록
useSWRSubscription('global-keydown', () => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && keyCallbacks.has(e.key)) {
keyCallbacks.get(e.key)!.forEach(cb => cb())
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
})
}
function Profile() {
// 여러 단축키가 같은 리스너 공유
useKeyboardShortcut('p', () => console.log('Profile'))
useKeyboardShortcut('k', () => console.log('Search'))
useKeyboardShortcut('s', () => console.log('Settings'))
}
// window에 1개의 keydown 리스너만 등록
동작 원리
- 첫 번째 컴포넌트 마운트:
useSWRSubscription이window.addEventListener실행- 전역 리스너 1개 등록
- 추가 컴포넌트 마운트:
useSWRSubscription이 기존 리스너 재사용- 새 리스너 등록 안 함
- 콜백 실행:
- 키 이벤트 발생 시 전역 리스너 실행
- Map에서 해당 키의 모든 콜백 찾아 실행
실전 예시: 전역 온라인/오프라인 상태
import useSWRSubscription from 'swr/subscription'
function useOnlineStatus() {
const { data: isOnline } = useSWRSubscription('online-status', (key, { next }) => {
const handleOnline = () => next(null, true)
const handleOffline = () => next(null, false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
// 초기값
next(null, navigator.onLine)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
})
return isOnline ?? true
}
// 10개 컴포넌트에서 사용해도 리스너는 1개
function Component1() {
const isOnline = useOnlineStatus()
return <div>{isOnline ? 'Online' : 'Offline'}</div>
}
다른 전역 이벤트 적용
Window resize:
const resizeCallbacks = new Set<() => void>()
function useWindowResize(callback: () => void) {
useEffect(() => {
resizeCallbacks.add(callback)
return () => resizeCallbacks.delete(callback)
}, [callback])
useSWRSubscription('window-resize', () => {
const handler = () => resizeCallbacks.forEach(cb => cb())
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
})
}
Visibility change:
function usePageVisibility() {
const { data: isVisible } = useSWRSubscription('page-visibility', (key, { next }) => {
const handler = () => next(null, !document.hidden)
document.addEventListener('visibilitychange', handler)
next(null, !document.hidden)
return () => document.removeEventListener('visibilitychange', handler)
})
return isVisible ?? true
}
언제 사용하나?
- 많은 컴포넌트가 같은 전역 이벤트를 구독할 때
- 키보드 단축키, resize, scroll, visibility 같은 전역 이벤트
- 성능이 중요한 경우
언제 사용하지 않나?
- 컴포넌트 인스턴스가 1-2개뿐일 때
- 이벤트가 특정 요소에만 해당할 때
- 간단한 프로토타입
섹션 4 정리: Client-Side Data Fetching의 핵심
- SWR 사용: 자동 중복 제거와 캐싱으로 네트워크 요청 최적화
- localStorage 버전 관리: 스키마 충돌 방지 및 최소 필드만 저장
- Passive 이벤트 리스너: 스크롤 성능을 위해 passive 옵션 사용
- 전역 리스너 공유: 여러 컴포넌트가 하나의 리스너 공유
Vercel이 이 섹션을 MEDIUM-HIGH로 분류한 이유는 클라이언트 데이터 페칭이 사용자 경험과 네트워크 효율성에 직접적인 영향을 미치기 때문입니다. 특히 SWR을 사용한 자동 중복 제거는 즉각적인 성능 개선을 제공합니다.
클라이언트 최적화 체크리스트
- useState + useEffect를 SWR로 마이그레이션
- localStorage에 버전 접두사 추가
- localStorage 읽기/쓰기에 try-catch 추가
- 스크롤 관련 이벤트에 passive 옵션 추가
- 전역 이벤트 리스너 중복 확인
- SWR 캐시 전략 설정 (불변 데이터 vs 동적 데이터)
- localStorage에서 민감 데이터 제거
다음 편 예고
다음 글에서는 Re-render Optimization (MEDIUM)을 다룹니다. 불필요한 리렌더링을 줄여 UI 반응성을 개선하는 전략을 살펴봅니다.