Published on

Next.js 14 RSC 느림? TTFB 급증 7가지 해결

Authors

서버 컴포넌트(React Server Components, RSC)로 전환했는데 갑자기 TTFB(Time To First Byte)가 치솟는 경우가 있습니다. 체감은 “페이지가 늦게 뜬다”지만, 실제로는 서버가 첫 바이트를 보내기 전까지 무엇인가가 길게 막히는 상황입니다. Next.js 14(App Router)는 RSC/스트리밍/캐시가 강력한 대신, 작은 실수 하나로 요청당 서버 작업량이 폭증하거나 캐시가 무력화되어 TTFB가 급격히 늘어날 수 있습니다.

이 글은 “RSC가 느리다”라는 막연한 결론 대신, TTFB를 늘리는 병목을 7가지로 분해하고 각각을 측정 → 원인 확정 → 해결 순서로 정리합니다.

먼저: TTFB가 늘어나는 지점부터 분리하자

TTFB는 대체로 아래 중 하나에서 증가합니다.

  1. 서버가 렌더링을 시작하기 전에(미들웨어, 인증, 로케일, 리다이렉트)
  2. 서버 렌더링 중(RSC 데이터 패칭, DB, 외부 API, 직렬화, CPU/메모리)
  3. 서버는 준비됐는데 네트워크/인프라에서 지연(콜드스타트, 노드 스케일링, TLS, 프록시)

가장 빠른 1차 확인은 curl서버 응답 헤더가 언제 오는지 보는 것입니다.

curl -s -o /dev/null -w "DNS:%{time_namelookup} TCP:%{time_connect} TLS:%{time_appconnect} TTFB:%{time_starttransfer} TOTAL:%{time_total}\n" https://example.com

여기서 TTFB만 유독 크면 서버/미들웨어/렌더링/인프라 문제일 가능성이 큽니다.

또한 Next.js App Router에서는 RSC payload가 먼저 오고, 이후 클라이언트 JS가 붙습니다. 즉, TTFB가 크면 “클라이언트 번들이 커서”가 아니라 서버가 첫 바이트를 못 보내는 상태일 확률이 높습니다.

해결 1) RSC에서 fetch 캐시가 깨져 매 요청마다 재패칭

가장 흔한 원인입니다. App Router에서 fetch는 기본적으로 캐시가 걸리지만, 아래 패턴이 들어가면 캐시가 깨져 모든 요청이 동기식 외부 호출/DB 호출로 이어집니다.

  • cache: 'no-store'
  • revalidate: 0
  • cookies()/headers() 사용으로 route가 dynamic으로 승격
  • searchParams를 그대로 사용하며 사실상 무한 변형 URL 생성

증상

  • 로컬에서는 괜찮은데 프로덕션에서 트래픽 증가 시 TTFB가 선형으로 증가
  • 외부 API/DB 호출 시간이 그대로 TTFB에 반영

처방

  1. 가능하면 RSC fetch의도적으로 캐시합니다.
// app/products/page.tsx
export const revalidate = 60; // 페이지 단위 ISR

async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error('API failed');
  return res.json();
}

export default async function Page() {
  const products = await getProducts();
  return <pre>{JSON.stringify(products, null, 2)}</pre>;
}
  1. 사용자별 데이터 때문에 dynamic이 필요하다면, “전체 페이지를 dynamic”으로 만들지 말고 사용자별 영역만 분리합니다.
  • 공용 데이터: RSC + 캐시
  • 사용자별 데이터: Client Component에서 호출하거나, 별도 route handler로 분리
// app/page.tsx (RSC)
import UserWidget from './user-widget';

export const revalidate = 300;

export default async function Page() {
  const res = await fetch('https://api.example.com/public', {
    next: { revalidate: 300 },
  });
  const data = await res.json();

  return (
    <>
      <main>{data.title}</main>
      <UserWidget />
    </>
  );
}
// app/user-widget.tsx (Client)
'use client';
import { useEffect, useState } from 'react';

export default function UserWidget() {
  const [me, setMe] = useState<any>(null);
  useEffect(() => {
    fetch('/api/me').then(r => r.json()).then(setMe);
  }, []);
  return <div>{me ? me.name : '...'}</div>;
}

해결 2) cookies()/headers()가 전체 라우트를 dynamic으로 만들어 TTFB 증가

RSC에서 cookies()headers()를 읽는 순간, Next는 해당 경로를 **정적 최적화 불가(dynamic)**로 판단하는 경우가 많습니다. 그 결과:

  • CDN/서버 캐시 히트율 급락
  • 매 요청마다 서버 렌더링 + 데이터 패칭

처방

  • 꼭 필요한 컴포넌트에서만 읽고, 가능한 한 Route Handler로 옮기기
  • 공용 페이지는 cookies() 접근을 피하고, 사용자별 처리는 /api/*로 분리
// app/api/me/route.ts
import { cookies } from 'next/headers';

export async function GET() {
  const token = cookies().get('token')?.value;
  if (!token) return Response.json({ ok: false }, { status: 401 });
  // ...token 검증 및 사용자 조회
  return Response.json({ ok: true, name: 'Alice' });
}

이 패턴은 RSC 캐시를 지키면서 개인화도 유지합니다.

해결 3) Waterfall 데이터 패칭(직렬 await)로 서버 렌더링이 길어짐

RSC는 서버에서 실행되므로 “그냥 await 몇 번”이 곧 TTFB 지연으로 이어집니다. 특히 아래처럼 직렬로 호출하면 최악입니다.

// 나쁜 예: 순차 대기
const a = await fetchA();
const b = await fetchB(a.id);
const c = await fetchC(b.id);

처방 1: 가능한 호출은 병렬화

const [a, b] = await Promise.all([fetchA(), fetchB()]);

처방 2: Suspense로 스트리밍(첫 바이트를 빨리)

RSC의 장점은 스트리밍으로 TTFB를 낮추고 나머지를 뒤에서 채우는 것입니다. 느린 영역을 Suspense 경계 뒤로 보내면, 서버가 더 빨리 “첫 바이트”를 보낼 수 있습니다.

// app/page.tsx
import { Suspense } from 'react';
import SlowPanel from './slow-panel';

export default function Page() {
  return (
    <>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading analytics...</div>}>
        <SlowPanel />
      </Suspense>
    </>
  );
}
// app/slow-panel.tsx (RSC)
export default async function SlowPanel() {
  const res = await fetch('https://api.example.com/analytics', {
    next: { revalidate: 60 },
  });
  const data = await res.json();
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

이렇게 하면 analytics가 느려도 페이지 골격은 먼저 전송되어 TTFB가 줄어듭니다.

해결 4) generateMetadata/서버 액션/미들웨어가 렌더링을 선행 차단

Next.js 14에서 종종 놓치는 함정이 generateMetadata()입니다. 여기서 외부 API를 호출하면 메타데이터 생성이 렌더링 앞단을 막아 TTFB가 증가합니다.

처방

  • generateMetadata에서는 가능한 한 네트워크 호출을 피하고, 해야 한다면 캐시/리밸리데이트를 강제
// app/[slug]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({ params }: any): Promise<Metadata> {
  const res = await fetch(`https://api.example.com/post/${params.slug}`, {
    next: { revalidate: 300 },
  });
  const post = await res.json();
  return { title: post.title, description: post.excerpt };
}

또한 middleware에서 인증/리다이렉트를 과도하게 수행하면 모든 요청이 선행 차단됩니다. 미들웨어는 최소한의 분기만 두고, 무거운 검증은 백엔드나 route handler로 넘기는 게 안전합니다.

해결 5) Edge Runtime/Node.js Runtime 선택 실수로 I/O가 느려짐

App Router는 라우트별로 runtime을 선택할 수 있습니다. Edge는 빠른 지리적 분산이 장점이지만:

  • 일부 Node API 미지원
  • DB 드라이버/네이티브 모듈 부적합
  • 외부 네트워크 호출이 오히려 불리한 환경도 존재

처방

  • DB 접근/무거운 서버 로직이 있으면 Node runtime을 명시
// app/api/report/route.ts
export const runtime = 'nodejs';

export async function GET() {
  // DB/SDK 호출 등
  return Response.json({ ok: true });
}

반대로 “정적에 가깝고 캐시 가능한 API”는 Edge가 유리할 수 있으니, 측정 기반으로 분리합니다.

해결 6) 프로덕션 인프라: 콜드스타트/스케일링/리소스 부족

RSC는 서버에서 더 많은 일을 하므로, 인프라가 받쳐주지 않으면 TTFB가 튀기 쉽습니다.

  • 컨테이너/서버리스 콜드스타트
  • CPU 부족으로 이벤트 루프 지연
  • 메모리 부족으로 GC/스왑
  • 오토스케일링 지연

EKS 같은 쿠버네티스 환경이라면 특히 Pod 리소스 부족이 TTFB로 바로 드러납니다. 아래 글들은 “느려졌다”가 아니라 “왜 느려졌는지”를 10분 내로 갈라내는 데 도움이 됩니다.

처방 체크리스트

  • Next 서버 프로세스의 CPU throttling 여부 확인
  • Pod requests/limits가 실제 트래픽 대비 적절한지 재산정
  • 오토스케일러(HPA/Karpenter)가 스파이크를 따라오는지 관찰
  • 콜드스타트가 심하면 최소 레플리카 유지 또는 프리웜 전략 고려

해결 7) 관측(Observability) 부재: 어디서 막히는지 모르면 영원히 느리다

RSC는 “서버에서 실행되는 프론트엔드”라서, 전통적인 APM 없이도 최소한의 타이밍 로그는 직접 심어야 합니다. 핵심은 요청 단위로 fetch/DB 호출 시간을 분해하는 것입니다.

처방: 서버에서 간단한 타이밍 계측

// lib/timing.ts
export async function time<T>(label: string, fn: () => Promise<T>): Promise<T> {
  const start = performance.now();
  try {
    return await fn();
  } finally {
    const ms = Math.round(performance.now() - start);
    console.log(`[timing] ${label}: ${ms}ms`);
  }
}
// app/page.tsx
import { time } from '@/lib/timing';

export default async function Page() {
  const data = await time('fetch public feed', async () => {
    const res = await fetch('https://api.example.com/feed', {
      next: { revalidate: 60 },
    });
    return res.json();
  });

  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

이 정도만 해도 “TTFB 2.5초”가 사실은 “외부 API 2.3초”인지, “서버 CPU 2.3초”인지 갈라집니다. 이후에는 OpenTelemetry/APM으로 확장하면 됩니다.

실전 진단 순서(권장)

  1. curl로 TTFB가 진짜 서버에서 늦는지 확인
  2. 해당 라우트가 dynamic인지 확인(cookies/headers, no-store, revalidate=0)
  3. RSC 내부 데이터 패칭이 waterfall인지 확인(직렬 await)
  4. generateMetadata/middleware/서버 액션이 선행 차단하는지 확인
  5. Suspense 스트리밍으로 첫 바이트를 빨리 보내도록 분리
  6. runtime(edge/node) 선택이 맞는지 확인
  7. 인프라 리소스/스케일링/콜드스타트 점검 + 타이밍 계측 도입

마무리: “RSC가 느린 게 아니라, 캐시/스트리밍/동적화가 꼬인 것”

Next.js 14의 RSC는 잘 쓰면 TTFB를 낮추고 서버 부하를 줄일 수 있지만, 반대로 작은 dynamic 요소 하나가 전체 페이지를 매 요청 렌더링으로 바꿔버릴 수 있습니다. 위 7가지를 체크하면서 특히

  • 캐시가 의도대로 동작하는지
  • 느린 작업을 Suspense 뒤로 보내 스트리밍이 되는지
  • 인프라가 서버 렌더링 비용을 감당하는지

이 세 축을 먼저 정리하면, “RSC 느림”이 아니라 “TTFB 병목이 어디인지”가 보이기 시작합니다.