Published on

Next.js 14 App Router TTFB 폭증 잡는 RSC 튜닝

Authors

서버 컴포넌트(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에서 호출
  • fetchcache: '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.tsxSuspense 를 통해 스트리밍을 할 수 있지만, “첫 바이트를 보내기 전”에 상단에서 무거운 작업을 끝까지 기다리면 효과가 없습니다.

해결: 무거운 데이터는 하위 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 fetchnext.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) 디버깅 체크리스트(운영에서 바로 쓰는 순서)

  1. TTFB가 높은 요청의 서버 로그에서 “가장 오래 걸린 await”를 찾는다
  2. 해당 await가 외부 API라면
    • 동시화 가능한지(Promise.all)
    • 스트리밍 경계로 내릴 수 있는지(Suspense)
    • 캐시 가능한지(next.revalidate)
  3. 페이지/레이아웃에서 cookies() / headers() 호출이 있는지 확인한다
  4. fetch 옵션에 cache: 'no-store' 가 섞여 있는지 확인한다
  5. 배포 환경에서만 느리면
    • 런타임(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 폭증은 대부분 안정적으로 잡힙니다.