- Published on
Next.js 14 App Router TTFB 폭증 잡는 RSC 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, 이하 RSC) 기반의 Next.js 14 App Router로 마이그레이션한 뒤, 특정 페이지의 TTFB(Time To First Byte)가 갑자기 수 초대로 튀는 경우가 있습니다. 문제는 “서버에서 렌더링하니 빨라질 것”이라는 기대와 달리, RSC가 데이터 패칭, 캐시, 스트리밍, 동적 함수 사용 여부에 따라 매우 쉽게 “모든 요청이 풀 렌더 + 풀 데이터 재조회”로 굳어버린다는 점입니다.
이 글은 App Router에서 TTFB 폭증을 만드는 전형적인 패턴을 짚고, RSC 관점에서 원인 분리 → 측정 → 구조 변경 → 재검증 순서로 튜닝하는 방법을 다룹니다.
관련해서 이미지가 병목인 케이스는 아래 글이 더 직접적입니다.
또, RSC/CSR 경계에서 발생하는 불필요한 재렌더/불안정 UI는 하이드레이션 이슈와도 이어지니 함께 참고하면 좋습니다.
TTFB가 “폭증”하는 전형적인 증상
다음 중 하나라도 해당되면 RSC 튜닝 대상입니다.
- 첫 요청뿐 아니라 매 요청마다 TTFB가 높다(캐시가 전혀 안 먹는다)
- 로컬에서는 괜찮은데 배포 환경에서만 느리다(Edge/Node 런타임, 외부 API 지연, 커넥션 이슈)
- 페이지에
fetch가 여러 번 있고, waterfall(순차 대기) 형태로 누적된다 cookies()/headers()/searchParams의존으로 인해 페이지 전체가 동적이 된다loading.tsx를 만들었는데도 “첫 바이트”가 늦다(스트리밍이 막혀 있다)
핵심은 “서버에서 뭘 기다리느라 첫 바이트를 못 보내는가”입니다.
1) 먼저 원인을 분리: 네트워크 vs 렌더링 vs 캐시
서버에서 측정 로그를 심기
RSC는 브라우저가 아니라 서버에서 실행되므로, 가장 빠른 진단은 서버 로그에 타이밍을 찍는 것입니다.
// app/(shop)/product/[id]/page.tsx
import { unstable_noStore as noStore } from 'next/cache'
function ms(start: bigint) {
return Number((process.hrtime.bigint() - start) / 1000000n)
}
export default async function Page({ params }: { params: { id: string } }) {
const t0 = process.hrtime.bigint()
// 디버깅 중에는 캐시를 끄고(=항상 재조회) 병목을 확실히 드러내기도 합니다.
// 단, 운영에 그대로 두면 TTFB는 더 나빠집니다.
// noStore()
const t1 = process.hrtime.bigint()
const product = await fetch(`https://api.example.com/products/${params.id}`)
.then(r => r.json())
const t2 = process.hrtime.bigint()
console.log('[TTFB-debug] fetch(ms)=', Number((t2 - t1) / 1000000n), 'total(ms)=', ms(t0))
return (
<main>
<h1>{product.name}</h1>
</main>
)
}
이렇게만 해도 TTFB 폭증의 70%는 “외부 API가 느린데, 그걸 페이지 최상단에서 기다린다”로 정리됩니다.
Next.js가 동적으로 굳었는지 확인
다음 조건을 밟으면 캐시/정적화가 깨지면서 매 요청 서버 렌더가 됩니다.
cookies()/headers()를 RSC에서 호출fetch를cache: 'no-store'로 호출export const dynamic = 'force-dynamic'revalidate = 0또는 사실상 그에 준하는 구성
문제는 이것이 “의도치 않게” 발생하기 쉽다는 겁니다. 예를 들어 인증 토큰 때문에 cookies() 를 상단에서 읽고, 그 결과 페이지 전체가 동적으로 변해 TTFB가 급증하는 식입니다.
2) RSC에서 가장 흔한 TTFB 폭증 원인 5가지
2.1 waterfall 데이터 패칭(순차 대기)
아래처럼 작성하면 A가 끝나야 B, B가 끝나야 C가 시작됩니다.
const a = await fetch('https://api.example.com/a').then(r => r.json())
const b = await fetch(`https://api.example.com/b?x=${a.id}`).then(r => r.json())
const c = await fetch(`https://api.example.com/c?y=${b.id}`).then(r => r.json())
해결 전략은 두 가지입니다.
- 가능한 것은
Promise.all로 동시화 - 의존성이 있는 것은 “상단에서 전부 기다리지 말고” 하위 컴포넌트로 내려 스트리밍
동시화 예시는 다음과 같습니다.
const [a, b] = await Promise.all([
fetch('https://api.example.com/a').then(r => r.json()),
fetch('https://api.example.com/b').then(r => r.json()),
])
2.2 스트리밍을 막는 상단의 무거운 작업
App Router는 loading.tsx 와 Suspense 를 통해 스트리밍을 할 수 있지만, “첫 바이트를 보내기 전”에 상단에서 무거운 작업을 끝까지 기다리면 효과가 없습니다.
해결: 무거운 데이터는 하위 RSC로 분리 + Suspense
// app/(shop)/product/[id]/page.tsx
import { Suspense } from 'react'
import ProductMain from './ProductMain'
import ProductRecs from './ProductRecs'
export default function Page({ params }: { params: { id: string } }) {
return (
<main>
<Suspense fallback={<p>상품 정보를 불러오는 중...</p>}>
<ProductMain id={params.id} />
</Suspense>
<Suspense fallback={<p>추천 상품 로딩 중...</p>}>
<ProductRecs id={params.id} />
</Suspense>
</main>
)
}
// app/(shop)/product/[id]/ProductMain.tsx
export default async function ProductMain({ id }: { id: string }) {
const product = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60 },
}).then(r => r.json())
return <h1>{product.name}</h1>
}
이 구조의 포인트는 “페이지 최상단”이 무거운 await 를 들고 있지 않게 만드는 것입니다. 그러면 HTML 스트림을 더 빨리 열 수 있어 TTFB가 눈에 띄게 개선됩니다.
2.3 의도치 않은 no-store 와 동적 함수 사용
다음은 캐시를 사실상 포기하는 스위치들입니다.
fetch(url, { cache: 'no-store' })unstable_noStore()cookies()/headers()호출
특히 인증이 필요한 페이지에서 이런 일이 자주 벌어집니다.
해결: “페이지 전체 동적”을 피하고, 필요한 조각만 동적으로
- 레이아웃/상단은 캐시 가능하게 유지
- 개인화 영역만 클라이언트에서 호출하거나, 별도 라우트 핸들러로 분리
예를 들어, 페이지 본문은 revalidate 로 캐시하고, 사용자별 위시리스트 여부만 클라이언트에서 가져오게 할 수 있습니다.
// app/(shop)/product/[id]/page.tsx
import WishButton from './WishButton'
export default async function Page({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: { revalidate: 300 },
}).then(r => r.json())
return (
<main>
<h1>{product.name}</h1>
<WishButton productId={params.id} />
</main>
)
}
// app/(shop)/product/[id]/WishButton.tsx
'use client'
import { useEffect, useState } from 'react'
export default function WishButton({ productId }: { productId: string }) {
const [wished, setWished] = useState(false)
useEffect(() => {
fetch(`/api/wish-state?productId=${encodeURIComponent(productId)}`)
.then(r => r.json())
.then(d => setWished(Boolean(d.wished)))
}, [productId])
return <button>{wished ? '위시 해제' : '위시하기'}</button>
}
이렇게 하면 “개인화 때문에 전체 페이지가 매 요청 동적 렌더”가 되는 최악의 패턴을 피할 수 있습니다.
2.4 fetch 캐시 키 폭발(쿼리스트링/헤더 변동)
fetch 캐시는 URL과 옵션을 기준으로 키가 잡히는데, 다음이 섞이면 캐시 적중률이 급락합니다.
- 매번 바뀌는 쿼리 파라미터(예:
ts,nonce) - 사용자별로 다른 헤더를 그대로 전달
해결: 캐시 가능한 요청과 사용자별 요청을 분리
- 공통 데이터는 사용자 무관한 엔드포인트로
- 사용자별 데이터는 별도 엔드포인트로 분리하고 캐시 기대를 버리거나, 서버 내부 캐시를 설계
2.5 서버 런타임/리전/커넥션 문제
RSC는 서버에서 외부 API를 호출합니다. 배포 환경에서만 TTFB가 튄다면 다음을 점검합니다.
- Node 런타임 vs Edge 런타임 차이(지원 API, TLS, 콜드스타트 특성)
- 외부 API가 같은 리전에 있는지(왕복 지연)
- 커넥션 재사용이 되는지(프록시/ALB/서버리스 환경에서 keep-alive 문제)
이 영역은 Next.js 코드만으로 해결이 안 되는 경우도 많아서, APM/인프라 로그와 함께 봐야 합니다.
3) 실전 튜닝 레시피: 캐시·동시성·스트리밍을 조합하라
3.1 fetch에 next.revalidate 를 명시해 “의도”를 고정
RSC에서 fetch는 기본적으로 캐시될 수 있지만, 페이지 구성과 동적 함수 사용 여부에 따라 기대가 깨질 수 있습니다. 중요한 요청에는 next: { revalidate } 를 명시해 팀의 의도를 코드로 남기는 편이 안전합니다.
const product = await fetch(url, {
next: { revalidate: 120 },
}).then(r => r.json())
- 데이터가 자주 바뀌면 짧게
- 거의 안 바뀌면 길게
- 실시간성이 필요하면 해당 조각만 동적으로 분리
3.2 React cache 로 중복 호출 제거(서버 내 메모이제이션)
동일 요청이 한 렌더 트리에서 여러 번 호출되는 경우가 있습니다(여러 컴포넌트가 같은 데이터를 필요로 함). 이때 React의 cache 를 사용하면 “한 요청 내 중복”을 줄일 수 있습니다.
// lib/getProduct.ts
import { cache } from 'react'
export const getProduct = cache(async (id: string) => {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 300 },
})
if (!res.ok) throw new Error('failed to fetch')
return res.json()
})
// 여러 RSC에서 공유
import { getProduct } from '@/lib/getProduct'
export default async function ProductTitle({ id }: { id: string }) {
const p = await getProduct(id)
return <h1>{p.name}</h1>
}
이는 TTFB를 “드라마틱”하게 줄이기보다, 불필요한 중복 I/O를 제거해 분산을 줄이는 데 효과적입니다.
3.3 Suspense 경계 재설계: “첫 화면”만 먼저 보내기
TTFB는 첫 바이트지만, 사용자가 체감하는 것은 “첫 의미 있는 UI”입니다. RSC에서 가장 좋은 최적화는 중요한 조각을 먼저 보내고 나머지는 스트리밍으로 뒤늦게 합류시키는 것입니다.
추천 구조:
- 상단 헤더/타이틀/가격 등 핵심: 빠르게
- 리뷰/추천/연관상품/로그성 데이터: 늦게
loading.tsx 는 라우트 전환 UX에 좋지만, 같은 페이지 내에서도 Suspense 를 적극적으로 써야 합니다.
3.4 동적 함수는 “최상단”에서 피하라
cookies() 를 페이지 상단에서 호출하면, 그 순간 페이지 전체가 동적이 되고 캐시가 무력화될 수 있습니다. 가능한 패턴은 다음입니다.
- 인증이 꼭 필요한 API만 Route Handler로 분리
- 페이지는 캐시 가능한 데이터로 구성
- 사용자별 데이터는 클라이언트에서 후처리 또는 별도 스트리밍 경계로 격리
4) 디버깅 체크리스트(운영에서 바로 쓰는 순서)
- TTFB가 높은 요청의 서버 로그에서 “가장 오래 걸린 await”를 찾는다
- 해당 await가 외부 API라면
- 동시화 가능한지(
Promise.all) - 스트리밍 경계로 내릴 수 있는지(
Suspense) - 캐시 가능한지(
next.revalidate)
- 동시화 가능한지(
- 페이지/레이아웃에서
cookies()/headers()호출이 있는지 확인한다 fetch옵션에cache: 'no-store'가 섞여 있는지 확인한다- 배포 환경에서만 느리면
- 런타임(Node/Edge)
- 리전
- 커넥션 재사용/프록시 설정
- 외부 API 자체 지연 을 분리한다
5) 자주 하는 실수와 안티패턴
안티패턴 1: “일단 전부 no-store”
문제가 생기면 no-store 로 통일하는 경우가 있는데, 이는 TTFB를 구조적으로 악화시킵니다. 실시간이 필요한 조각만 동적으로 분리해야 합니다.
안티패턴 2: 개인화 때문에 전체 페이지를 동적으로
개인화는 보통 페이지의 일부입니다. 전체를 동적으로 만들면 캐시/스트리밍 이점이 사라지고, 트래픽이 늘수록 서버 비용과 지연이 함께 증가합니다.
안티패턴 3: loading.tsx 만으로 해결하려고 함
loading.tsx 는 라우트 세그먼트 단위의 로딩 UX입니다. 페이지 내부의 무거운 컴포넌트는 Suspense 로 쪼개야 스트리밍이 살아납니다.
6) 결론: TTFB 폭증은 “RSC 설계 신호”다
Next.js 14 App Router에서 TTFB가 폭증했다는 것은 대체로 다음 중 하나입니다.
- 데이터 패칭이 순차적으로 누적되고 있다
- 캐시가 의도치 않게 깨져 매 요청 풀 렌더가 된다
- 스트리밍 경계가 없어 첫 바이트를 늦게 보낸다
- 배포 환경의 네트워크/런타임 특성이 병목이다
해결은 한 가지 만능 키가 아니라, 캐시(revalidate) + 동시성(Promise.all) + 스트리밍(Suspense) + 동적 영역 격리의 조합입니다. 이 네 가지를 기준으로 페이지를 다시 쪼개고, 서버에서 타이밍 로그로 재검증하면 TTFB 폭증은 대부분 안정적으로 잡힙니다.