- Published on
Next.js App Router 렌더링 폭주, RSC 캐시·revalidate로 TTFB 낮추기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 비용이 갑자기 치솟고, 같은 페이지인데도 요청마다 TTFB(Time To First Byte)가 들쭉날쭉해지는 상황은 App Router 도입 초기에 자주 겪습니다. 특히 RSC(React Server Components) 기반 렌더링은 “서버에서 다 해주니 편하다”는 장점만큼, 캐시 경계를 잘못 잡으면 모든 요청이 매번 서버 렌더링으로 몰리는 폭주를 만들기 쉽습니다.
이 글은 “왜 폭주가 생기는지”를 먼저 구조적으로 설명하고, 그 다음 RSC 캐시/라우트 캐시/데이터 캐시의 역할 분리, revalidate/tags/dynamic 설정, 그리고 TTFB를 낮추는 실전 패턴을 코드로 정리합니다.
> 운영 중 장애/성능 이슈는 앱 코드만 보지 말고 엣지·LB·WAF까지 같이 봐야 합니다. 520/521 같은 엣지 오류와 TTFB 상승이 함께 나타날 때는 인프라 로그를 먼저 30분만이라도 따라가 보세요: Cloudflare 520·521, Nginx·ALB 로그로 30분 진단
App Router에서 “렌더링 폭주”가 생기는 전형적 원인
1) fetch가 기본적으로 캐시되지 않는 상황을 만든다
App Router에서 fetch()는 기본적으로 캐시 가능한 방향이지만, 아래 중 하나라도 걸리면 사실상 매 요청마다 서버에서 새로 실행됩니다.
cookies()/headers()사용(요청별 값에 의존) → 라우트가 동적으로 기울기 쉬움fetch(..., { cache: 'no-store' })또는revalidate: 0export const dynamic = 'force-dynamic'- 인증/개인화 데이터를 페이지 루트에서 섞어버림
결과적으로 “전체 페이지가 동적”이 되어 라우트 단위 캐시가 깨지고, RSC 트리 전체가 매번 다시 계산됩니다.
2) “정적이어도 되는 것”과 “동적이어야 하는 것”을 같은 트리에서 렌더링
예를 들어 상품 상세 페이지에서:
- 상품 설명/이미지/가격: 분 단위로만 바뀌어도 됨(정적/ISR 가능)
- 사용자별 쿠폰/장바구니 상태: 요청마다 달라짐(동적)
이 둘을 같은 서버 컴포넌트 루트에서 함께 처리하면, 동적 요소 하나 때문에 전체가 동적으로 전락합니다. 이게 가장 흔한 폭주 패턴입니다.
3) 캐시 무효화 전략 부재(또는 과도한 무효화)
- 무효화를 안 해서 오래된 데이터가 남는 것도 문제지만
- 반대로
revalidate: 0을 남발하거나, 태그 무효화를 과하게 걸면 캐시 히트율이 0%에 가까워져 서버가 매번 렌더링합니다.
Next.js 캐시 계층을 “목적별로” 나눠 이해하기
App Router 성능 튜닝은 결국 어떤 캐시를 믿고, 어디서 깨지는지를 찾는 게임입니다.
1) Data Cache (fetch 캐시)
서버에서 실행되는 fetch 결과를 캐시합니다.
next: { revalidate: N }→ N초 동안 캐시next: { tags: ['product:123'] }→ 태그 기반 무효화
2) Full Route Cache(라우트 결과 캐시)
페이지/레이아웃 결과 자체가 캐시될 수 있습니다(정적/ISR에 가까운 개념). 하지만 cookies() 같은 동적 의존이 있으면 깨집니다.
3) RSC Payload 캐시(서버 컴포넌트 페이로드)
클라이언트가 받아서 화면을 구성하는 RSC 페이로드도 캐시 경로가 생기는데, 이것 역시 동적 경계에 크게 영향을 받습니다.
핵심은:
- 데이터는 Data Cache로 안정화하고
- 정적 페이지는 Route Cache로 밀어넣고
- 동적 조각은 최소 범위로 격리하는 것입니다.
TTFB를 낮추는 6가지 실전 패턴
1) 정적/동적 경계를 레이아웃에서 분리하기
개인화가 필요한 부분을 페이지 루트가 아니라 하위 컴포넌트로 격리합니다.
// app/products/[id]/page.tsx (Server Component)
import ProductDetail from './ProductDetail'
import UserPanel from './UserPanel'
export default async function Page({ params }: { params: { id: string } }) {
// 상품 정보는 캐시 가능
return (
<div>
<ProductDetail id={params.id} />
{/* 사용자 패널은 동적이지만, 전체를 동적으로 만들지 않도록 분리 */}
<UserPanel />
</div>
)
}
// app/products/[id]/ProductDetail.tsx (Server Component)
export const revalidate = 60 // 페이지 수준이 아니라, 해당 트리에서 캐시 유도
export default async function ProductDetail({ id }: { id: string }) {
const res = await fetch(`${process.env.API}/products/${id}`, {
next: { revalidate: 60, tags: [`product:${id}`] },
})
const product = await res.json()
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
</section>
)
}
// app/products/[id]/UserPanel.tsx
import { cookies } from 'next/headers'
export default async function UserPanel() {
const token = cookies().get('token')?.value
if (!token) return null
// 사용자별 요청은 캐시하지 않는 게 안전
const res = await fetch(`${process.env.API}/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
})
const me = await res.json()
return <aside>안녕하세요, {me.name}</aside>
}
이렇게 하면 상품 영역은 캐시로 TTFB가 안정되고, 사용자 패널만 동적으로 처리됩니다.
2) cache: 'no-store'를 “페이지 전체”에 퍼뜨리지 않기
가장 위험한 패턴은 아래처럼 루트에서 no-store를 쓰는 것입니다.
// 나쁜 예시: 페이지 루트에서 no-store로 전체를 동적으로 만듦
export default async function Page() {
const res = await fetch(`${process.env.API}/feed`, { cache: 'no-store' })
const feed = await res.json()
return <FeedList items={feed} />
}
대신 정말 실시간이 필요한 일부만 no-store로 두고, 나머지는 revalidate로 타협합니다.
3) ISR 감각으로 revalidate를 설계하기(초 단위 SLA)
revalidate는 “언제까지 최신이어야 하는가”를 초 단위로 코드에 박는 행위입니다.
- 가격/재고: 10~30초
- 상세 설명/이미지: 10~60분
- 블로그 글: 1~24시간
await fetch(`${API}/inventory/${id}`, {
next: { revalidate: 15, tags: [`inventory:${id}`] },
})
await fetch(`${API}/product/${id}`, {
next: { revalidate: 3600, tags: [`product:${id}`] },
})
TTFB 폭주는 보통 “모든 게 0초”라서 생깁니다. 업무적으로 허용 가능한 최신성을 숫자로 합의하는 게 성능의 80%입니다.
4) 태그 기반 무효화로 “필요할 때만” 갱신하기
관리자 페이지/백오피스에서 상품을 수정하면, 해당 상품 페이지만 갱신되면 됩니다.
// app/api/admin/products/[id]/route.ts
import { revalidateTag } from 'next/cache'
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
const body = await req.json()
// ...DB 업데이트...
revalidateTag(`product:${params.id}`)
revalidateTag(`inventory:${params.id}`)
return Response.json({ ok: true })
}
이 패턴을 쓰면 revalidate를 길게 가져가도 “수정 시 즉시 반영”이 가능합니다. 결과적으로 평상시에는 캐시 히트, 변경 시에만 갱신이 일어나 TTFB와 서버 비용이 모두 내려갑니다.
5) dynamic 설정은 최후의 수단으로만
다음 설정은 강력하지만, 잘못 쓰면 캐시를 전부 날립니다.
// 정말로 매 요청마다 서버 렌더가 필요할 때만
export const dynamic = 'force-dynamic'
대부분의 경우는 dynamic을 만지는 대신:
- 개인화 컴포넌트를 분리
fetch단위로no-store/revalidate를 조절
이게 더 안전합니다.
6) 스트리밍 + Suspense로 “TTFB 체감”을 낮추기
TTFB 자체를 줄이기 어렵다면(외부 API가 느림), 초기 바이트를 빨리 보내고 나머지를 스트리밍하는 방식으로 체감을 개선할 수 있습니다.
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import ProductDetail from './ProductDetail'
import Recommendations from './Recommendations'
export default function Page({ params }: { params: { id: string } }) {
return (
<div>
<Suspense fallback={<p>상품 정보를 불러오는 중...</p>}>
<ProductDetail id={params.id} />
</Suspense>
<Suspense fallback={<p>추천 로딩 중...</p>}>
<Recommendations id={params.id} />
</Suspense>
</div>
)
}
이때도 Recommendations 같은 부가 영역은 revalidate를 길게 주거나, 아예 클라이언트에서 지연 로딩하는 선택지가 있습니다.
운영에서 “폭주인지” 확인하는 체크리스트
1) 같은 URL에 대해 서버 로그/트레이스를 보면 매번 동일 쿼리가 반복되는가?
- 반복된다 → 캐시가 깨졌거나
no-store가 섞였을 확률이 큼
2) 인증 쿠키/헤더 접근이 상위 레이아웃에 있는가?
- 있다 → 해당 레이아웃 아래가 전부 동적으로 기울 수 있음
3) 외부 보호 장비(WAF/봇 차단)로 재시도/리다이렉트가 늘어나고 있나?
TTFB 상승이 단순 렌더링 문제가 아니라, 중간에서 403/챌린지로 왕복이 늘어난 것일 수도 있습니다. 봇 제어로 403이 지속되면 애플리케이션 캐시 튜닝만으로는 해결이 안 됩니다: AWS WAF Bot Control 막힘으로 403 지속될 때
자주 터지는 함정 3가지
1) cookies()를 “레이아웃”에서 읽고 전체를 동적으로 만든다
- 해결: 쿠키가 필요한 컴포넌트만 분리하거나, 클라이언트 컴포넌트로 옮겨 API를 호출
2) API 응답에 Set-Cookie가 섞여 캐시가 무력화된다
- CDN/프록시 레벨에서 캐시가 깨질 수 있음
- 개인화 응답과 공용 응답을 엔드포인트부터 분리하는 게 좋습니다.
3) 태그 무효화를 남발해 캐시 히트율이 바닥난다
product:*같은 광범위 태그를 마구 무효화하면 사실상no-store와 다를 바 없습니다.- “무효화 단위”를 리소스 ID 중심으로 좁히세요.
결론: RSC 캐시는 “기능”이 아니라 “설계”다
App Router에서 렌더링 폭주를 막고 TTFB를 안정화하려면, 다음 3가지만 일관되게 지키면 됩니다.
- 정적/동적 경계를 분리해 페이지 전체가 동적으로 기울지 않게 한다.
fetch에revalidate와tags를 기본값처럼 부여해 데이터 캐시 히트율을 만든다.- 변경이 있을 때만
revalidateTag로 선택적 무효화해 “평상시 빠름 + 변경 시 최신”을 동시에 잡는다.
이 3가지를 적용하면, 대부분의 “App Router 도입 후 TTFB 폭증” 케이스는 서버 스펙 증설 없이도 눈에 띄게 내려갑니다.