- Published on
Next.js 14 RSC 느림? TTFB 급증 7가지 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, RSC)로 전환했는데 갑자기 TTFB(Time To First Byte)가 치솟는 경우가 있습니다. 체감은 “페이지가 늦게 뜬다”지만, 실제로는 서버가 첫 바이트를 보내기 전까지 무엇인가가 길게 막히는 상황입니다. Next.js 14(App Router)는 RSC/스트리밍/캐시가 강력한 대신, 작은 실수 하나로 요청당 서버 작업량이 폭증하거나 캐시가 무력화되어 TTFB가 급격히 늘어날 수 있습니다.
이 글은 “RSC가 느리다”라는 막연한 결론 대신, TTFB를 늘리는 병목을 7가지로 분해하고 각각을 측정 → 원인 확정 → 해결 순서로 정리합니다.
먼저: TTFB가 늘어나는 지점부터 분리하자
TTFB는 대체로 아래 중 하나에서 증가합니다.
- 서버가 렌더링을 시작하기 전에(미들웨어, 인증, 로케일, 리다이렉트)
- 서버 렌더링 중(RSC 데이터 패칭, DB, 외부 API, 직렬화, CPU/메모리)
- 서버는 준비됐는데 네트워크/인프라에서 지연(콜드스타트, 노드 스케일링, 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: 0cookies()/headers()사용으로 route가 dynamic으로 승격searchParams를 그대로 사용하며 사실상 무한 변형 URL 생성
증상
- 로컬에서는 괜찮은데 프로덕션에서 트래픽 증가 시 TTFB가 선형으로 증가
- 외부 API/DB 호출 시간이 그대로 TTFB에 반영
처방
- 가능하면 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>;
}
- 사용자별 데이터 때문에 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분 내로 갈라내는 데 도움이 됩니다.
- EKS Pod Pending(Insufficient cpu) 원인과 해결
- EKS Pod OOMKilled 반복 원인과 메모리·GC·Limit 튜닝
- EKS에서 Karpenter 노드가 안 늘 때 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으로 확장하면 됩니다.
실전 진단 순서(권장)
curl로 TTFB가 진짜 서버에서 늦는지 확인- 해당 라우트가 dynamic인지 확인(
cookies/headers, no-store, revalidate=0) - RSC 내부 데이터 패칭이 waterfall인지 확인(직렬 await)
generateMetadata/middleware/서버 액션이 선행 차단하는지 확인- Suspense 스트리밍으로 첫 바이트를 빨리 보내도록 분리
- runtime(edge/node) 선택이 맞는지 확인
- 인프라 리소스/스케일링/콜드스타트 점검 + 타이밍 계측 도입
마무리: “RSC가 느린 게 아니라, 캐시/스트리밍/동적화가 꼬인 것”
Next.js 14의 RSC는 잘 쓰면 TTFB를 낮추고 서버 부하를 줄일 수 있지만, 반대로 작은 dynamic 요소 하나가 전체 페이지를 매 요청 렌더링으로 바꿔버릴 수 있습니다. 위 7가지를 체크하면서 특히
- 캐시가 의도대로 동작하는지
- 느린 작업을 Suspense 뒤로 보내 스트리밍이 되는지
- 인프라가 서버 렌더링 비용을 감당하는지
이 세 축을 먼저 정리하면, “RSC 느림”이 아니라 “TTFB 병목이 어디인지”가 보이기 시작합니다.