- Published on
Next.js App Router TTFB 느림 - RSC 캐시·fetch 설정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, RSC) 기반의 Next.js App Router로 전환한 뒤, “페이지는 가벼운데도 TTFB(Time To First Byte)가 유독 느리다”는 체감이 자주 발생합니다. 특히 개발 환경에서는 빠른데 프로덕션에서만 느리거나, 특정 페이지에서만 TTFB가 튀는 형태로 나타나서 원인 파악이 어렵습니다.
이 글은 App Router에서 TTFB가 느려지는 전형적인 패턴을 RSC 캐시와 fetch 캐싱/재검증 관점에서 정리하고, 동적 렌더링 전파를 끊는 방법, RSC/데이터 캐시를 의도대로 설계하는 방법을 코드와 함께 설명합니다.
> 프론트 지표(INP/LCP 등) 최적화는 별도의 레이어입니다. TTFB가 느리면 그 위의 모든 지표가 악화되기 쉬우므로, 먼저 서버 렌더링 경로를 안정화하는 것이 우선입니다. 관련해서는 React/Next.js 프론트 최적화로 INP 200ms 달성도 함께 보면 전체 성능 그림을 잡는 데 도움이 됩니다.
TTFB가 느려지는 구조: App Router의 렌더링/캐시 레이어
App Router에서 한 요청의 TTFB는 대략 아래 합으로 결정됩니다.
- Route 결정 및 미들웨어(middleware, i18n, auth)
- RSC 렌더링 비용(Server Component tree 실행)
- 데이터 패칭 비용(서버에서 실행되는
fetch, DB, 외부 API) - 캐시 적중 여부
- RSC 페이로드 캐시(정적/ISR로 생성된 결과)
fetch데이터 캐시(Next 확장 fetch의 캐시)
- 스트리밍 여부(초기 바이트를 빨리 밀어낼 수 있는지)
핵심은 **“동적 렌더링이 전파되면 캐시가 깨지고, 매 요청마다 2~3번이 반복 실행된다”**는 점입니다. App Router는 기본적으로 “가능하면 정적/캐시”를 활용하지만, 아래 조건 중 하나만 걸려도 전체가 dynamic으로 기울 수 있습니다.
cookies(),headers(),draftMode()사용fetch(..., { cache: 'no-store' })또는next: { revalidate: 0 }export const dynamic = 'force-dynamic'searchParams에 따라 매번 달라지는 렌더링(구현 방식에 따라)
1) 가장 흔한 원인: 동적 렌더링 전파(dynamic waterfall)
증상
- 같은 페이지를 새로고침할 때마다 TTFB가 일정하게 높음
- 서버 로그/트레이스에서 외부 API 호출이 매번 발생
- CDN/Edge 캐시를 붙여도 효과가 미미
원리
App Router는 한 컴포넌트에서 dynamic 신호가 발생하면 상위/인접 렌더링이 정적으로 고정되기 어렵습니다. 특히 layout.tsx에서 cookies()를 읽거나, 루트에서 no-store fetch를 호출하면 해당 segment 전체가 동적으로 굳어버립니다.
해결 전략
- dynamic이 필요한 부분을 작은 segment로 격리
- 루트(layout/page)에서 불필요한
cookies()/headers()호출 제거 - 데이터 패칭은 가능한 캐시 가능한 fetch로 바꾸고, 필요한 곳만 no-store
예시: layout에서 쿠키를 읽어 전체가 dynamic 되는 경우
// app/layout.tsx
import { cookies } from 'next/headers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies(); // 이것만으로도 dynamic 신호가 될 수 있음
const theme = cookieStore.get('theme')?.value;
return (
<html data-theme={theme}>
<body>{children}</body>
</html>
);
}
개선 방향
- 테마처럼 “초기 HTML에 꼭 필요”가 아니라면 클라이언트에서 처리
- 또는 테마 적용이 필요한 페이지/segment에만 분리
// app/(theme)/layout.tsx 처럼 segment로 분리
import { cookies } from 'next/headers';
export default function ThemeLayout({ children }: { children: React.ReactNode }) {
const theme = cookies().get('theme')?.value ?? 'light';
return (
<div data-theme={theme}>
{children}
</div>
);
}
2) Next 확장 fetch의 기본 동작을 오해한 경우
App Router에서 서버 컴포넌트의 fetch는 브라우저 fetch가 아니라 Next가 캐시/재검증을 덧씌운 확장 fetch입니다. 여기서 TTFB를 좌우하는 포인트는 다음입니다.
cache: 'force-cache' | 'no-store'next: { revalidate: number }next: { tags: string[] }+revalidateTag()
(1) 매 요청마다 외부 API를 호출하고 있다면
아래처럼 no-store가 들어가면 항상 네트워크 왕복이 발생합니다.
// app/products/page.tsx
export default async function Page() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store',
});
const products = await res.json();
return <pre>{JSON.stringify(products, null, 2)}</pre>;
}
개선: ISR 스타일로 재검증 주기 부여
export default async function Page() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }, // 60초 동안 캐시 활용
});
if (!res.ok) throw new Error('API error');
const products = await res.json();
return <pre>{JSON.stringify(products, null, 2)}</pre>;
}
이렇게 하면 TTFB가 ‘외부 API 응답시간’에 고정되지 않고, 캐시 적중 시에는 서버가 빠르게 RSC를 만들 수 있습니다.
(2) "정적인데 왜 매번 느리지?" — fetch가 캐시되지 않는 조합
다음 케이스가 흔합니다.
- URL에 매번 바뀌는 쿼리(예:
?t=${Date.now()})가 붙음 → 캐시 키가 매번 달라짐 - 인증 헤더가 매번 달라지고, 서버가 이를 dynamic으로 취급
cookies()로 토큰을 읽고 그 토큰으로 fetch → 사용자별로 dynamic
사용자별 데이터는 캐시를 기대하지 말고, 캐시 가능한 데이터와 분리하는 게 정석입니다.
3) Route Segment Config로 렌더링 전략을 “명시”하기
App Router는 자동 최적화를 시도하지만, 애매한 경우(특히 배포 환경/런타임 차이)에는 명시가 더 안전합니다.
정적(가능하면 캐시)로 고정
// app/blog/page.tsx
export const dynamic = 'force-static';
export const revalidate = 300; // 5분 ISR
export default async function Page() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 300 },
});
const posts = await res.json();
return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}
무조건 동적으로(캐시 기대 X)
// app/me/page.tsx
export const dynamic = 'force-dynamic';
export default async function Page() {
const res = await fetch('https://api.example.com/me', { cache: 'no-store' });
const me = await res.json();
return <pre>{JSON.stringify(me, null, 2)}</pre>;
}
팁: “전체가 dynamic일 수밖에 없는 페이지”는 차라리 force-dynamic으로 명시하고, 나머지 페이지는 force-static/revalidate로 분리하면 TTFB 변동폭이 줄어듭니다.
4) RSC 캐시를 ‘데이터 캐시’와 혼동하지 않기
Next.js에는 캐시가 여러 겹 있습니다.
- Data Cache:
fetch결과를 캐싱(재검증 포함) - Full Route Cache / RSC Payload Cache: 정적/ISR일 때 라우트 결과(RSC 페이로드)를 캐싱
즉, fetch를 캐시해도 페이지 자체가 dynamic이면 RSC는 매번 렌더링될 수 있습니다. 반대로 페이지가 정적이어도, 내부 fetch가 no-store면 정적 생성이 불가능해집니다.
체크리스트
- 이 페이지는 사용자별인가? → dynamic 분리
- 이 데이터는 사용자별인가? →
no-store또는 별도 API 호출(클라이언트/서버 액션) - 공통 데이터인가? →
revalidate또는force-cache+ 태그
5) 태그 기반 재검증(revalidateTag)로 “빠른 TTFB + 즉시 갱신”
ISR의 약점은 “주기 기반”이라 갱신 타이밍이 애매할 수 있다는 점입니다. App Router에서는 태그 기반 캐시 무효화로 이를 보완할 수 있습니다.
데이터 패칭에 tags 부여
// app/products/page.tsx
export default async function Page() {
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'] },
});
const products = await res.json();
return <pre>{JSON.stringify(products, null, 2)}</pre>;
}
업데이트 후 태그 무효화
// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(id: string, payload: unknown) {
const res = await fetch(`https://api.example.com/products/${id}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Update failed');
// products 태그를 쓰는 fetch 캐시를 무효화
revalidateTag('products');
}
이 방식은 평소에는 캐시 적중으로 TTFB를 낮게 유지하면서, 변경 이벤트가 발생하면 즉시 최신화할 수 있습니다.
6) 스트리밍이 TTFB를 “가리는” 경우도 있다
App Router는 스트리밍을 지원하므로, 이론상 느린 데이터가 있어도 초기 바이트를 빨리 보낼 수 있습니다. 하지만 다음이면 스트리밍 효과가 사라집니다.
- 상단에서 무거운 데이터를 모두 await
loading.tsx/Suspense 경계가 적절히 없어서 상위가 막힘
Suspense로 느린 블록 격리
// app/dashboard/page.tsx
import { Suspense } from 'react';
import SlowPanel from './slow-panel';
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading analytics...</p>}>
<SlowPanel />
</Suspense>
</div>
);
}
// app/dashboard/slow-panel.tsx
export default async function SlowPanel() {
const res = await fetch('https://api.example.com/analytics', {
next: { revalidate: 120 },
});
const data = await res.json();
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
이렇게 하면 전체 TTFB를 줄이기보다는 사용자가 체감하는 최초 응답을 앞당길 수 있습니다. 다만 “진짜 TTFB” 자체가 SLA라면, 결국 캐시/동적 전파를 해결해야 합니다.
7) 운영에서의 TTFB 급증: DNS/클러스터 이슈도 함께 의심
App Router 최적화를 다 했는데도 특정 시간대에 TTFB가 급증한다면, 애플리케이션 코드보다 인프라 레벨(특히 DNS, egress, 노드 상태) 문제가 원인일 수 있습니다. 예를 들어 서버에서 외부 API를 호출할 때 DNS가 흔들리면 fetch가 전부 느려지고, 그게 곧 TTFB로 반영됩니다.
- EKS에서 DNS 문제로 upstream timeout이 나면 외부 API 호출이 전부 지연될 수 있습니다: EKS CoreDNS CrashLoopBackOff - upstream 타임아웃 해결
- 노드 리소스 부족/스케줄링 실패가 간헐적으로 발생하면 특정 파드만 느려지거나 콜드 스타트가 잦아질 수 있습니다: EKS Pod Pending 0/XX nodes available 원인별 해결
App Router의 캐시는 “정상적인 네트워크/런타임”을 전제로 성능을 끌어올립니다. 운영에서 TTFB가 튄다면 애플리케이션 캐시 설계 + 인프라 네트워크 안정성을 같이 봐야 합니다.
8) 실전 점검 순서(빠르게 원인 좁히기)
- 느린 페이지가 사용자별인가?
- 사용자별이면
force-dynamic으로 명시하고, 공통 영역을 별도 정적 페이지로 분리
- 사용자별이면
- 루트(layout/page)에서 dynamic 신호가 있나?
cookies()/headers()호출 위치 확인
- fetch가 매번 나가나?
cache: 'no-store'/revalidate: 0여부 확인- URL 쿼리/헤더로 캐시 키가 계속 바뀌는지 확인
- 캐시 전략을 명시했나?
- 정적/ISR:
dynamic = 'force-static',revalidate - 동적:
dynamic = 'force-dynamic'
- 정적/ISR:
- 갱신 요구가 있나?
- 주기 기반 ISR이면 충분한지, 아니면
tags + revalidateTag가 필요한지
- 주기 기반 ISR이면 충분한지, 아니면
마무리
Next.js App Router에서 TTFB가 느린 문제는 대개 “서버가 느리다”기보다 캐시가 깨져서 매 요청마다 RSC 렌더링 + 데이터 패칭이 반복되는 구조에서 발생합니다. 해결의 핵심은 다음 두 가지입니다.
- 동적 렌더링 전파를 최소화해 정적/ISR 캐시를 살린다.
fetch의 캐시/재검증 옵션을 의도적으로 설계하고, 필요하면tags로 즉시 무효화한다.
이 두 축을 잡으면 TTFB는 보통 “외부 API 평균 응답시간”이 아니라 캐시 적중률과 재검증 정책에 의해 안정적으로 관리되는 지표가 됩니다.