위 글은 Vercel에서 작성한 React Best pracitce 글과 소스 코드를 보며 톺아보는 글입니다.
AI에게 "이 코드를 React Best Practice에 맞게 리팩토링해줘"라고 요청할 수도 있지만,
왜 그 코드가 더 나은지, 어떤 원리로 성능이 개선되는지 이해하는 것이 중요합니다.
이 시리즈에서는 Vercel이 정의한 8가지 카테고리를 하나씩 깊이 있게 살펴봅니다.
각 Best Practice의 이론적 배경, 실제 성능 차이, 적용 시점을 중심으로 분석합니다.
이 글은 sections에서 소개하는 8가지 섹션 중 1번째 섹션인 Waterfall 제거를 다룹니다.
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)
1. Eliminating Waterfalls: 성능의 최대 적
Waterfall이란?
Waterfall은 비동기 작업이 순차적으로 실행되면서 각각의 네트워크 지연이 누적되는 현상입니다.
// Waterfall 예시
async function fetchUserData(userId) {
const user = await fetch(`/api/users/${userId}`); // 100ms
const profile = await fetch(`/api/profile/${user.id}`); // 100ms
const posts = await fetch(`/api/posts/${user.id}`); // 100ms
// 총 300ms 소요
}
1-1. Promise.all()로 독립적인 작업 병렬화 (CRITICAL)
Impact: CRITICAL (2-10× 성능 향상)
가장 기본적이면서도 강력한 Waterfall 제거 패턴입니다. 서로 의존성이 없는 비동기 작업들은 순차적으로 기다릴 필요가 없습니다.
문제 코드: 순차 실행 (3번의 네트워크 왕복)
const user = await fetchUser() // 100ms 대기
const posts = await fetchPosts() // 100ms 대기
const comments = await fetchComments() // 100ms 대기
// 총 300ms 소요
각 await마다 전체 네트워크 지연(RTT)이 누적됩니다. 세 요청이 서로 영향을 주지 않는데도 순차적으로 실행되어 시간이 낭비됩니다.
개선 코드: 병렬 실행 (1번의 네트워크 왕복)
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
])
// 총 100ms 소요 (가장 긴 작업 기준)
세 요청이 동시에 시작되고, 가장 느린 작업이 완료되는 시점에 모두 완료됩니다.
왜 이게 CRITICAL인가?
- 즉각적인 효과: 코드 수정만으로 70% 이상 성능 개선
- 사용자 체감 성능: LCP, FCP 같은 Core Web Vitals 직접 개선
- 적용 범위: API 호출, DB 쿼리, 파일 읽기 등 모든 비동기 작업
주의사항
단, Promise.all()은 하나라도 실패하면 전체가 실패합니다. 부분 실패를 허용하려면 Promise.allSettled()를 사용하세요.
1-2. API Routes에서 Waterfall 체인 방지 (CRITICAL)
Impact: CRITICAL (2-10× 성능 향상)
API Routes나 Server Actions에서는 독립적인 작업을 즉시 시작하되, 나중에 await하는 패턴이 핵심입니다.
문제 코드: 불필요한 순차 실행
export async function GET(request: Request) {
const session = await auth() // 100ms
const config = await fetchConfig() // 100ms (auth와 무관)
const data = await fetchData(session.user.id) // 100ms
return Response.json({ data, config })
}
// 총 300ms
config는 auth와 독립적인데도 순차적으로 기다립니다.
개선 코드: 조기 Promise 생성
export async function GET(request: Request) {
const sessionPromise = auth() // 즉시 시작
const configPromise = fetchConfig() // 동시 시작
const session = await sessionPromise // 필요한 시점에 await
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id) // session 필요한 작업만 나중에
])
return Response.json({ data, config })
}
// 총 200ms (auth와 config 병렬, 이후 data)
핵심 원리: Promise는 생성 즉시 실행된다
// 이 순간 fetchData()가 시작됨 (await 없이도)
const promise = fetchData()
// 나중에 결과가 필요할 때 await
const result = await promise
많은 개발자가 놓치는 부분: await가 실행을 시작하는 게 아니라, Promise는 생성 즉시 실행됩니다. await는 단지 결과를 기다리는 것뿐입니다.
1-3. 의존성 기반 병렬화 (CRITICAL)
Impact: CRITICAL (2-10× 성능 향상)
부분적으로 의존성이 있는 작업들을 최대한 병렬화하는 고급 패턴입니다.
문제 코드: 불필요하게 기다리는 작업
const [user, config] = await Promise.all([
fetchUser(),
fetchConfig()
])
const profile = await fetchProfile(user.id)
// config와 profile은 독립적인데 순차 실행됨
profile은 user에 의존하지만, config와는 독립적입니다. 하지만 위 코드는 config가 끝날 때까지 profile을 시작하지 않습니다.
개선 방법 1: better-all 라이브러리
import { all } from 'better-all'
const { user, config, profile } = await all({
async user() { return fetchUser() },
async config() { return fetchConfig() },
async profile() {
return fetchProfile((await this.$.user).id) // user만 의존
}
})
better-all은 의존성 그래프를 자동 분석하여 최대한 병렬화합니다.
개선 방법 2: 수동 Promise 체이닝 (의존성 없는 방법)
const userPromise = fetchUser()
const profilePromise = userPromise.then(user => fetchProfile(user.id))
const [user, config, profile] = await Promise.all([
userPromise,
fetchConfig(), // user, profile과 병렬 실행
profilePromise
])
타임라인 비교
문제 코드:
0-100ms: user, config (병렬)
100-200ms: profile (대기)
총 200ms
개선 코드:
0-100ms: user, config (병렬)
100-200ms: profile, config은 이미 완료
총 200ms이지만, config는 100ms에 이미 사용 가능
실제로는 config가 더 빠르면 더 일찍 반환 가능합니다.
1-4. await를 필요한 시점까지 연기 (HIGH)
Impact: HIGH (불필요한 코드 경로 차단 방지)
모든 분기에서 데이터가 필요하지 않다면, await를 실제로 사용하는 분기 안으로 이동시키세요.
문제 코드: 모든 경로가 차단됨
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId) // 항상 실행
if (skipProcessing) {
return { skipped: true } // userData 안 쓰는데 기다렸음
}
return processUserData(userData)
}
skipProcessing=true인 경우(예: 50%의 요청)에도 불필요하게 fetchUserData를 기다립니다.
개선 코드: 조건부 await
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) {
return { skipped: true } // 즉시 반환
}
const userData = await fetchUserData(userId) // 필요할 때만
return processUserData(userData)
}
실전 예시: Early Return 최적화
// 개선 전
async function updateResource(resourceId: string, userId: string) {
const permissions = await fetchPermissions(userId) // 항상 실행
const resource = await getResource(resourceId)
if (!resource) return { error: 'Not found' }
if (!permissions.canEdit) return { error: 'Forbidden' }
return await updateResourceData(resource, permissions)
}
// 개선 후
async function updateResource(resourceId: string, userId: string) {
const resource = await getResource(resourceId)
if (!resource) return { error: 'Not found' } // 빠른 실패
const permissions = await fetchPermissions(userId) // 필요할 때만
if (!permissions.canEdit) return { error: 'Forbidden' }
return await updateResourceData(resource, permissions)
}
언제 효과적인가?
- Early return이 자주 발생하는 경우 (예: 404, 401 에러)
- 조건부 로직이 많은 경우
- 비싼 비동기 작업 (DB 쿼리, 외부 API)
1-5. 전략적 Suspense 경계 설정 (HIGH)
Impact: HIGH (더 빠른 초기 렌더링)
데이터를 기다려서 전체 JSX를 반환하는 대신, Suspense 경계를 사용해 레이아웃을 먼저 보여주세요.
문제 코드: 전체 페이지가 차단됨
async function Page() {
const data = await fetchData() // 전체 레이아웃 차단
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div><DataDisplay data={data} /></div>
<div>Footer</div>
</div>
)
}
데이터를 기다리는 동안 사용자는 빈 화면을 봅니다. Sidebar, Header, Footer는 데이터와 무관한데도 기다립니다.
개선 코드: Suspense로 부분 스트리밍
function Page() {
return (
Sidebar
Header
Footer
)
}
async function DataDisplay() {
const data = await fetchData() // 이 컴포넌트만 차단
return {data.content}
}
사용자 경험:
- 즉시: Sidebar, Header, Footer, Skeleton 표시
- 데이터 로드 후: Skeleton → DataDisplay 교체
고급 패턴: Promise 공유
function Page() {
const dataPromise = fetchData() // 즉시 시작, await 없음
return Header
}
function DataDisplay({ dataPromise }) {
const data = use(dataPromise) // React의 use() hook
return {data.content}
}
function DataSummary({ dataPromise }) {
const data = use(dataPromise) // 같은 Promise 재사용
return {data.summary}
}
두 컴포넌트가 같은 Promise를 공유하므로 중복 요청 없이 데이터를 기다립니다.
언제 사용하지 말아야 하나?
- 레이아웃 결정에 필요한 데이터: 높이/위치가 바뀌면 Layout Shift 발생
- SEO 중요 컨텐츠: Above the fold 영역은 서버에서 완전히 렌더링
- 빠른 쿼리: Suspense 오버헤드가 더 클 수 있음
- Layout Shift 회피: 로딩 → 컨텐츠 전환 시 UI 점프 원치 않을 때
Trade-off
- 장점: 더 빠른 초기 페인트 (FCP 개선)
- 단점: 잠재적 Layout Shift, 스켈레톤 UI 필요
UX 우선순위에 따라 선택하세요.
섹션 1 정리: Waterfall 제거의 핵심
- Promise.all(): 독립적 작업은 무조건 병렬화
- 조기 Promise 생성:
await는 나중에, 시작은 즉시 - 의존성 최소화: 부분 의존성도 병렬화 가능
- 조건부 await: 필요한 분기에서만 기다리기
- Suspense 경계: 레이아웃과 데이터 로딩 분리
Vercel이 이 섹션을 CRITICAL로 분류한 이유는 명확합니다. Waterfall 제거는 가장 적은 노력으로 가장 큰 성능 개선을 얻을 수 있는 최적화입니다.
다음 편 예고
다음 글에서는 Bundle Size Optimization (CRITICAL)을 다룹니다. 초기 로딩 성능을 결정하는 번들 최적화 전략을 살펴봅니다.