- Published on
Next.js 14 RSC fetch waterfall 끊는 캐시·prefetch 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, 이하 RSC)는 클라이언트 번들 감소와 서버에서의 데이터 패칭을 자연스럽게 만들어 주지만, 잘못 구성하면 페이지 렌더링 경로가 fetch -> render -> fetch -> render ...로 직렬화되며 이른바 fetch waterfall이 발생합니다. 특히 Next.js 14의 App Router에서 async 서버 컴포넌트를 여러 단계로 중첩하거나, 레이아웃/페이지/서브컴포넌트가 제각각 fetch()를 호출하면 “한 요청이 끝나야 다음 요청을 시작”하는 구조가 쉽게 만들어집니다.
이 글에서는 Next.js 14 RSC에서 waterfall이 생기는 대표 패턴을 분해하고, 이를 캐시 설계, prefetch(사전 로딩), 병렬화(동시 패칭), **렌더링 경계(Suspense)**로 끊어내는 방법을 코드와 함께 정리합니다.
> 부하가 큰 API를 동시에 당기다 보면 백엔드가 429를 반환하기도 합니다. 재시도/백오프 설계는 OpenAI API 429 Rate Limit 재시도·백오프 설계처럼 “클라이언트/서버 모두에서 안전한 재시도” 관점으로 같이 점검하는 것을 권장합니다.
fetch waterfall이 생기는 구조적 원인
1) 중첩된 서버 컴포넌트가 각각 await fetch
가장 흔한 형태입니다.
Page가await fetchUser()Profile이await fetchProfile(user.id)Orders가await fetchOrders(user.id)
겉보기에는 “컴포넌트 분리”지만, 서버 렌더링 경로에서는 상위 컴포넌트가 resolve되어야 하위가 실행되며, 결과적으로 직렬화됩니다.
2) 레이아웃/페이지/서브컴포넌트에 데이터 패칭이 분산
layout.tsx에서 세션, page.tsx에서 목록, 카드 컴포넌트에서 상세를 각각 가져오면, Next는 트리 전체를 조립하며 각 단계의 await 지점에서 정지합니다.
3) 동적 데이터로 인해 캐시가 꺼져 매번 원격 호출
fetch(url, { cache: 'no-store' }) 또는 cookies()/headers() 사용으로 라우트가 동적으로 판단되면, 같은 요청이라도 매번 원격 API를 호출하게 됩니다. waterfall이 “더 느리게” 체감되는 이유입니다.
진단: 지금이 waterfall인지 확인하는 법
1) 브라우저/서버 타이밍이 아니라 “서버 렌더 경로”를 보라
- DevTools Network는 클라이언트 요청 중심이라 RSC 내부의 직렬화가 잘 안 보일 수 있습니다.
- 서버 로그에 fetch 시작/종료 타임스탬프를 찍어 순서를 확인하는 게 가장 확실합니다.
// lib/logFetch.ts
export async function logFetch<T>(label: string, fn: () => Promise<T>) {
const start = Date.now();
console.log(`[fetch:start] ${label} t=${start}`);
const result = await fn();
const end = Date.now();
console.log(`[fetch:end] ${label} dt=${end - start}ms`);
return result;
}
2) Next.js 캐시 힌트로 “예상한 대로 캐시되는지” 확인
next: { revalidate: n }를 줬는데도 매번 호출된다면, 라우트가 동적이거나no-store가 섞였을 가능성이 큽니다.
핵심 전략 1: “병렬화”로 waterfall 자체를 구조적으로 제거
패턴 A) 상위에서 Promise를 먼저 만들고 한 번에 await
RSC에서는 Promise를 미리 만들어두면 하위에서 기다리도록 설계할 수 있습니다.
// app/dashboard/page.tsx (Server Component)
import Profile from './Profile';
import Orders from './Orders';
async function fetchUser() {
const res = await fetch('https://api.example.com/me', {
// 캐시 전략은 뒤에서 다룸
next: { revalidate: 60 },
});
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}
async function fetchOrders(userId: string) {
const res = await fetch(`https://api.example.com/users/${userId}/orders`, {
next: { revalidate: 30 },
});
if (!res.ok) throw new Error('Failed to fetch orders');
return res.json();
}
export default async function Page() {
// 1) user는 필요하니 먼저
const user = await fetchUser();
// 2) userId가 생긴 순간부터는 병렬화 가능
const ordersPromise = fetchOrders(user.id);
return (
<div>
<Profile user={user} />
<Orders ordersPromise={ordersPromise} />
</div>
);
}
// app/dashboard/Orders.tsx (Server Component)
export default async function Orders({ ordersPromise }: { ordersPromise: Promise<any> }) {
const orders = await ordersPromise;
return (
<section>
<h2>Orders</h2>
<pre>{JSON.stringify(orders, null, 2)}</pre>
</section>
);
}
이 방식의 요점은 의존성이 없는 fetch들을 최대한 빨리 시작시키는 것입니다. “컴포넌트가 렌더될 때 fetch 시작”이 아니라, “데이터 의존 그래프가 결정되는 순간 fetch 시작”으로 바꿉니다.
패턴 B) 의존성이 없는 fetch는 Promise.all
export default async function Page() {
const [stats, notices, feed] = await Promise.all([
fetch('https://api.example.com/stats', { next: { revalidate: 60 } }).then(r => r.json()),
fetch('https://api.example.com/notices', { next: { revalidate: 300 } }).then(r => r.json()),
fetch('https://api.example.com/feed', { next: { revalidate: 10 } }).then(r => r.json()),
]);
return (
<main>
<pre>{JSON.stringify({ stats, notices, feed }, null, 2)}</pre>
</main>
);
}
핵심 전략 2: 캐시를 “데이터 성격별로” 분리해 waterfall 비용을 낮춘다
waterfall을 완전히 제거하지 못하더라도, 각 fetch가 캐시 히트라면 직렬화 비용은 급격히 줄어듭니다. Next.js에서 캐시는 크게 다음 축으로 설계합니다.
1) next.revalidate로 ISR 성격의 데이터 캐시
- 자주 바뀌지 않는 목록/설정/콘텐츠:
revalidate: 60~600 - 실시간성이 중요하지만 0초는 아닌 경우:
revalidate: 5~30
await fetch('https://api.example.com/catalog', {
next: { revalidate: 300 },
});
2) 사용자별/권한별 데이터는 “공유 캐시”를 조심
cookies()/headers()를 읽는 순간 해당 RSC 경로는 동적이 될 수 있고, 캐시 공유가 깨질 수 있습니다. 사용자별 데이터는 보통:
- 서버에서 세션 확인 후, 백엔드에서 사용자 토큰 기반 캐시
- 혹은 Next 측에서는
no-store로 명시하고, 대신 waterfall을 병렬화로 줄임
import { cookies } from 'next/headers';
export async function fetchMe() {
const token = cookies().get('token')?.value;
return fetch('https://api.example.com/me', {
cache: 'no-store',
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());
}
여기서 중요한 건 no-store 자체가 나쁜 게 아니라, no-store가 필요한 데이터는 구조적으로 병렬화/경계 분리로 비용을 상쇄해야 한다는 점입니다.
3) unstable_cache(또는 cache 래핑)로 함수 단위 캐시
동일한 인자로 반복 호출되는 함수를 캐시해 waterfall의 “중복 호출”을 제거할 수 있습니다.
// lib/cached.ts
import { unstable_cache } from 'next/cache';
export const getCatalog = unstable_cache(
async () => {
const res = await fetch('https://api.example.com/catalog', {
next: { revalidate: 300 },
});
if (!res.ok) throw new Error('catalog fetch failed');
return res.json();
},
['catalog'],
{ revalidate: 300 }
);
// app/page.tsx
import { getCatalog } from '@/lib/cached';
export default async function Page() {
const catalog = await getCatalog();
return <pre>{JSON.stringify(catalog, null, 2)}</pre>;
}
핵심 전략 3: Suspense로 “렌더링 경계”를 만들어 체감 waterfall을 줄인다
RSC에서 모든 데이터가 준비될 때까지 HTML이 늦게 내려오면 TTFB가 커집니다. 이때 중요한 영역만 먼저 렌더하고, 나머지는 Suspense로 늦춰 체감 성능을 개선할 수 있습니다.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import CriticalHeader from './CriticalHeader';
import SlowWidget from './SlowWidget';
export default async function Page() {
return (
<main>
<CriticalHeader />
<Suspense fallback={<div>Loading widget...</div>}>
<SlowWidget />
</Suspense>
</main>
);
}
// app/dashboard/SlowWidget.tsx (Server Component)
export default async function SlowWidget() {
const data = await fetch('https://api.example.com/slow', {
next: { revalidate: 60 },
}).then(r => r.json());
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
이 방식은 waterfall 자체를 없애진 않지만, 사용자가 “먼저 보게 되는 것”을 앞당겨 UX를 크게 개선합니다.
핵심 전략 4: prefetch를 “정확한 타이밍”에 건다 (라우팅 + 데이터)
prefetch는 두 층위가 있습니다.
- 라우트 프리페치: Next
<Link>가 뷰포트에 들어오면 해당 라우트의 RSC 페이로드를 미리 가져옴 - 데이터 프리페치: 다음 화면에서 필요할 데이터를 미리 warming
1) 라우트 prefetch: Link 기본 동작을 이해
App Router에서 <Link>는 기본적으로 prefetch가 동작합니다(환경/상황에 따라 다를 수 있음). 중요한 건:
- “사용자가 클릭하기 직전”에 뷰포트에 들어오는 링크라면 효과가 큼
- 목록이 길어 링크가 많으면 네트워크/서버 부하가 늘 수 있어 선택적으로 끄기도 함
import Link from 'next/link';
export function ItemLink({ id }: { id: string }) {
return (
<Link href={`/items/${id}`} prefetch={true}>
Open item
</Link>
);
}
2) 데이터 prefetch: 서버에서 “다음 화면 후보”를 미리 캐시 워밍
예를 들어 목록 페이지에서 상위 N개의 상세 데이터를 미리 당겨두면, 상세 진입 시 waterfall이 크게 줄어듭니다. 단, 무턱대고 하면 백엔드가 버티지 못합니다(429/부하). “상위 N개”, “사용자 행동 기반”처럼 제한을 둡니다.
// app/items/page.tsx (Server Component)
import Link from 'next/link';
async function getItems() {
return fetch('https://api.example.com/items', { next: { revalidate: 30 } }).then(r => r.json());
}
async function getItemDetail(id: string) {
return fetch(`https://api.example.com/items/${id}`, { next: { revalidate: 30 } }).then(r => r.json());
}
export default async function ItemsPage() {
const items: Array<{ id: string; name: string }> = await getItems();
// 상위 5개만 워밍 (주의: 과도한 프리페치는 역효과)
await Promise.all(items.slice(0, 5).map(i => getItemDetail(i.id)));
return (
<ul>
{items.map(i => (
<li key={i.id}>
<Link href={`/items/${i.id}`}>{i.name}</Link>
</li>
))}
</ul>
);
}
이 예시는 단순화를 위해 같은 요청에서 워밍했지만, 실제로는:
- 워밍 대상 제한(개수/조건)
- 워밍 실패 시 무시
- 백엔드 rate limit 고려(동시성 제한)
이 필수입니다. 동시성 제한이 필요하면 p-limit 같은 패턴을 직접 구현하거나, 서버에서 큐잉/배치 API를 고려하세요.
실전 패턴: “BFF(Route Handler)로 합쳐서 한 번에 가져오기”
RSC에서의 waterfall을 끊는 가장 강력한 방법 중 하나는 백엔드 호출을 여러 번 하지 않게 만드는 것입니다. 즉, Next.js를 BFF(Backend For Frontend)로 두고 Route Handler에서 필요한 데이터를 모아 반환합니다.
// app/api/dashboard/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const [stats, notices] = await Promise.all([
fetch('https://api.example.com/stats', { next: { revalidate: 60 } }).then(r => r.json()),
fetch('https://api.example.com/notices', { next: { revalidate: 300 } }).then(r => r.json()),
]);
return NextResponse.json({ stats, notices });
}
// app/dashboard/page.tsx
export default async function Page() {
const data = await fetch('http://localhost:3000/api/dashboard', {
next: { revalidate: 60 },
}).then(r => r.json());
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
장점:
- RSC 트리 여기저기 흩어진 fetch를 단일 fetch로 축소
- 백엔드 호출 동시성/재시도/타임아웃을 한 곳에서 통제
주의점:
- BFF 자체가 병목이 되지 않도록 캐시/타임아웃을 명확히
- 배포 환경에서 API 호출이 내부 네트워크를 타는지(레이턴시) 확인
캐시 무효화(Invalidate): 태그 기반 설계를 습관화
목록/상세/위젯이 서로 연결되어 있을 때, 단순 revalidate: 60만으로는 “업데이트 직후 반영” 요구를 만족시키기 어렵습니다. 이때 태그 기반 revalidate를 쓰면 운영이 쉬워집니다.
// 예: fetch에 태그 부여
await fetch('https://api.example.com/catalog', {
next: { tags: ['catalog'], revalidate: 300 },
});
// 예: 어떤 액션 이후 태그 무효화
import { revalidateTag } from 'next/cache';
export async function updateCatalogItem() {
// ... 업데이트 호출
revalidateTag('catalog');
}
이렇게 해두면 waterfall 최적화의 전제인 “캐시 히트율”을 안정적으로 유지할 수 있습니다.
운영 관점 체크리스트: 성능 최적화가 장애로 번지지 않게
prefetch/병렬화/캐시를 공격적으로 적용하면, 트래픽 패턴이 바뀌며 인프라 이슈가 드러나기도 합니다.
- 프리페치로 동시 요청이 늘어 Pod 메모리 사용량이 튀는지 확인
- 리눅스/컨테이너 메모리 압박 분석은 Linux OOM Killer 원인추적 - dmesg·cgroup·로그 체크리스트가 그대로 도움이 됩니다.
- 쿠버네티스 환경에서 특정 배포 이후 갑자기 재시작이 반복되면
- K8s CrashLoopBackOff·OOMKilled 원인과 해결처럼 “로그/리소스/프로브”를 함께 점검하세요.
정리: waterfall을 끊는 우선순위
- 의존성 없는 fetch를 병렬화(
Promise.all, Promise 선생성)해서 구조적 waterfall 제거 - 데이터 성격에 맞게 캐시를 켜서(revalidate/unstable_cache/태그) 반복 비용을 낮춤
- Suspense 경계로 TTFB/체감 성능을 개선
- 라우트/데이터 prefetch는 제한적으로(상위 N개, 조건 기반) 적용
- 필요하면 BFF(Route Handler)로 호출 수 자체를 감소
RSC의 성능은 “서버에서 데이터를 가져온다”는 사실보다, 데이터 의존 그래프를 어떻게 설계하느냐에 더 크게 좌우됩니다. fetch를 어디서 호출할지(컴포넌트 분리)보다, 어떤 것은 병렬화할지/캐시할지/미리 당길지를 먼저 결정하면 Next.js 14에서도 waterfall 없는 RSC를 안정적으로 운영할 수 있습니다.