테마 전환

React Server Components 성능 최적화: 데이터 가져오기와 캐시 실전

Easton editorial illustration: React Server Component tree on a performance bench transforming a request waterfall into parallel streaming slabs

RSC 페이지의 TTFB가 아직 300-500ms에 머물러 있다면, 성능 잠재력의 30% 정도만 쓰고 있을 가능성이 큽니다. 실제 측정 데이터에서는 올바른 스트리밍 아키텍처만으로 TTFB를 45ms까지 낮출 수 있었습니다. 마법이 아니라 React Server Components의 스트리밍 렌더링이 제대로 작동했을 때 나오는 결과입니다.

작년에 한 이커머스 팀의 상품 상세 페이지를 최적화할 때 저도 이 문제를 겪었습니다. 그 팀은 Next.js App Router를 쓰고 있었지만 TTFB가 꾸준히 380ms 안팎이었습니다. 확인해 보니 중첩 컴포넌트가 각자 데이터를 가져오면서 전형적인 “워터폴”이 생겼습니다. 상품 정보가 리뷰를 기다리고, 리뷰가 가격 데이터를 기다리고, 가격이 다시 재고 검증을 기다리는 식이었습니다. 9초 동안 빈 화면이 이어졌습니다.

이 글에서는 바로 이 문제를 어떻게 해결할지 다룹니다. 4가지 워터폴 처리 방식을 비교하고, 5가지 캐시 API의 사용 시나리오를 정리하며, 바로 복사해 쓸 수 있는 설정 템플릿도 제공합니다. TTFB 450ms와 45ms 사이의 차이는 몇 개의 Suspense 경계를 어디에 두느냐에서 갈릴 수 있습니다.


워터폴 문제: RSC 성능의 가장 큰 병목

먼저 실제 상황을 떠올려 보겠습니다. 이커머스 사이트에서 상품 상세 페이지를 열었는데, 상품명만 먼저 뜨고 3초 뒤 가격이 나타나며, 다시 5초 뒤에야 리뷰 영역이 보입니다. 사용자 경험은 좋을 수 없습니다.

이것이 워터폴 문제입니다. 중첩 컴포넌트가 각자 데이터를 가져오고, 병렬이 아니라 순서대로 실행됩니다. React Server Components의 데이터 가져오기는 기본적으로 동기 블로킹에 가깝습니다. await가 붙은 요청은 Suspense로 감싸지 않는 한 렌더링을 막습니다.

워터폴의 두 가지 형태

첫 번째: Server 내부 워터폴입니다. 같은 페이지 안에서 부모 컴포넌트가 데이터를 가져와야 자식 컴포넌트를 렌더링하고, 자식 컴포넌트가 다시 자기 데이터를 가져옵니다. 전형적인 코드는 다음과 같습니다.

// 워터폴 예시. 문제가 있는 코드입니다
async function ProductPage({ id: string }) {
  // 첫 번째 요청: 1초
  const product = await db.getProduct(id);

  // 자식 컴포넌트가 렌더링된 뒤에야 아래 요청들이 시작됩니다
  return (
    <div>
      <ProductDetails product={product} />
      <ProductPrice id={id} />      {/* 내부에서 await getPrice(id), 3초 */}
      <ProductReviews id={id} />    {/* 내부에서 await getReviews(id), 5초 */}
    </div>
  );
}

// ProductPrice.tsx
async function ProductPrice({ id }) {
  const price = await getPrice(id);  // 부모 컴포넌트 렌더링 뒤에 실행됩니다
  return <span>{price}</span>;
}

총 소요 시간은 9초입니다. 사용자는 9초 동안 빈 화면을 보게 됩니다.

두 번째: Client-Server 워터폴입니다. 클라이언트 컴포넌트가 서버를 요청하고, 서버가 다시 데이터베이스를 요청합니다. 이 형태는 더 숨어 있어서 React DevTools Profiler로 봐야 잡히는 경우가 많습니다. N+1 문제의 변형이라고 볼 수 있습니다.

워터폴을 식별하는 방법

React DevTools Profiler를 열고 페이지 로드를 한 번 기록합니다. 타임라인에 뚜렷한 계단식 요청 분포가 보이면, 즉 각 요청이 이전 요청 완료를 기다린다면 워터폴입니다.

더 직관적인 방법도 있습니다. 브라우저 Network 패널을 열고 요청 시작 시점을 봅니다. 데이터 요청이 한 번에 몰려 시작되지 않고 흩어져 있다면 문제는 거의 확정입니다.

흥미로운 점은 많은 개발자가 RSC를 쓰기만 하면 자동으로 성능 이득이 생긴다고 생각한다는 것입니다. 실제로는 그렇지 않습니다. SitePoint의 2026년 보고서에 따르면 많은 팀이 RSC 성능 잠재력의 약 30%만 활용하고 있습니다. 이유는 워터폴을 처리하지 않았기 때문입니다.


네 가지 해결책 비교: 단순한 방식부터 우아한 방식까지

워터폴 문제에는 네 가지 대표적인 해결책이 있습니다. 단순한 방식부터 복잡한 방식까지, 그리고 거친 방식부터 우아한 방식까지 차이가 있습니다.

해결책 1: Promise.all 병렬 가져오기

가장 직접적인 접근입니다. 모든 요청을 함께 시작하고 Promise.all로 모두 끝나기를 기다립니다.

// 해결책 1: Promise.all 병렬 가져오기
async function ProductPage({ id: string }) {
  // 모든 요청을 동시에 시작합니다
  const [product, price, reviews] = await Promise.all([
    getProduct(id),      // 1초
    getPrice(id),        // 3초
    getReviews(id),      // 5초
  ]);

  return (
    <div>
      <ProductDetails product={product} />
      <ProductPriceDisplay price={price} />
      <ProductReviewsList reviews={reviews} />
    </div>
  );
}

총 소요 시간은 5초입니다. 가장 느린 요청이 전체 시간을 결정합니다.

장점: 단순하고 변경 범위가 작습니다.

단점: 가장 느린 요청이 끝나기 전까지 사용자는 아무 콘텐츠도 볼 수 없습니다. 또 하나의 문제는 데이터 결합입니다. 부모 컴포넌트가 자식 컴포넌트에 필요한 데이터를 모두 알아야 하므로 컴포넌트 독립성 원칙을 깨뜨립니다.

해결책 2: Suspense 경계 분리

데이터 의존 영역에 Suspense를 추가해 핵심 콘텐츠를 먼저 보여줍니다.

// 해결책 2: Suspense 경계 분리
async function ProductPage({ id: string }) {
  const product = await getProduct(id);  // 핵심 데이터만 먼저 기다립니다

  return (
    <div>
      <ProductDetails product={product} />  {/* 1초 뒤 표시 */}
      
      {/* 중요도가 낮은 부분은 Suspense로 감쌉니다 */}
      <Suspense fallback={<PriceSkeleton />}>
        <ProductPrice id={id} />
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews id={id} />
      </Suspense>
    </div>
  );
}

사용자 경험은 달라집니다. 1초 뒤 상품 정보가 보이고, 3초 뒤 가격이 나타나며, 5초 뒤 리뷰 로드가 끝납니다.

장점: 핵심 콘텐츠를 먼저 보여주므로 사용자가 느끼는 속도가 좋아집니다.

단점: 데이터 요청은 여전히 순서대로 시작됩니다. ProductPrice와 ProductReviews의 요청은 부모 컴포넌트가 렌더링된 뒤에야 시작되므로 진짜 병렬은 아닙니다.

해결책 3: Promise를 Props로 전달하기

부모 컴포넌트가 모든 요청을 시작하고, Promise를 props로 자식 컴포넌트에 전달합니다. 자식 컴포넌트가 직접 await합니다.

// 해결책 3: Promise 전달 패턴
async function ProductPage({ id: string }) {
  // 모든 요청을 즉시 시작하고 await하지 않습니다
  const productPromise = getProduct(id);
  const pricePromise = getPrice(id);
  const reviewsPromise = getReviews(id);

  // 핵심 데이터만 await합니다
  const product = await productPromise;

  return (
    <div>
      <ProductDetails product={product} />
      
      <Suspense fallback={<PriceSkeleton />}>
        <ProductPrice pricePromise={pricePromise} />
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews reviewsPromise={reviewsPromise} />
      </Suspense>
    </div>
  );
}

// ProductPrice.tsx. Promise를 받습니다
async function ProductPrice({ pricePromise }) {
  const price = await pricePromise;  // 부모 컴포넌트가 시작한 Promise를 재사용합니다
  return <span>{price}</span>;
}

세 요청은 부모 컴포넌트에서 동시에 시작됩니다. 핵심 데이터는 1초 뒤, 가격은 3초 뒤, 리뷰는 5초 뒤에 보입니다.

장점: 모든 요청이 병렬로 시작되고, 핵심 콘텐츠가 먼저 보이며, 데이터도 분리됩니다. 자식 컴포넌트는 Promise만 받습니다.

단점: 컴포넌트 인터페이스를 바꿔야 합니다. 자식 컴포넌트가 id를 받던 구조에서 Promise를 받는 구조로 바뀝니다.

해결책 4: React cache() + preload(추천)

React 19에는 cache() API가 도입되었습니다. preload 패턴과 함께 쓰면 가장 우아한 해결책이 됩니다.

// 해결책 4: React cache() + preload
import { cache } from 'react';

// 데이터 가져오기 함수를 cache로 감쌉니다
const getComments = cache(async (postId: string) => {
  return db.getComments(postId);
});

// preload 함수를 export해 의도를 명확히 표시합니다
export const preloadComments = (id: string) => {
  void getComments(id);  // await하지 않고 시작만 하며 블로킹하지 않습니다
};

// 부모 컴포넌트
async function PostPage({ postId: string }) {
  preloadComments(postId);  // 댓글을 미리 로드합니다
  
  const post = await getPost(postId);  // 핵심 데이터만 await합니다
  
  return (
    <div>
      <PostContent post={post} />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={postId} />  {/* id를 그대로 쓰고 cache가 자동 재사용합니다 */}
      </Suspense>
    </div>
  );
}

// Comments.tsx. 컴포넌트 인터페이스는 그대로입니다
async function Comments({ postId }) {
  const comments = await getComments(postId);  // preload의 Promise를 재사용합니다
  return <CommentList comments={comments} />;
}

원리: cache() 함수는 같은 렌더링 주기 안에서 자동으로 memoized됩니다. preload를 호출하면 요청은 시작되지만 기다리지 않습니다. 자식 컴포넌트가 await할 때 같은 Promise를 재사용합니다.

장점:

  • 컴포넌트 인터페이스가 바뀌지 않습니다. 여전히 id를 전달합니다
  • 요청이 자동 memoized되어 데이터 결합이 없습니다
  • 자식 컴포넌트를 삭제했을 때 preload가 불필요한 코드가 되므로 발견하기 쉽습니다

단점: cache() 메커니즘을 이해해야 하며, 숨은 결합에 주의해야 합니다. Comments 컴포넌트를 삭제할 때 preload도 함께 삭제해야 합니다.

네 가지 해결책 비교

해결책총 소요 시간핵심 콘텐츠 표시 시간데이터 결합변경 비용
순차 가져오기9s9s없음없음
Promise.all5s5s있음낮음
Suspense5s1s없음낮음
Promise 전달5s1s분리됨중간
cache() + preload1s1s없음중간

실제 선택은 팀 상황에 달려 있습니다. 빠른 마이그레이션에는 해결책 2를, 새 프로젝트에는 해결책 4를 추천합니다.


스트리밍 렌더링 아키텍처: TTFB 45ms의 비밀

전통적인 SSR의 작업 흐름은 이렇습니다. 모든 데이터 가져오기가 끝날 때까지 기다리고, 완성된 HTML을 렌더링한 뒤, 한 번에 브라우저로 보냅니다. TTFB(Time to First Byte)는 데이터 가져오기 시간과 렌더링 시간을 더한 값이 됩니다.

구체적인 숫자로 보면 데이터베이스 쿼리 400ms, 렌더링 50ms라면 TTFB는 약 450ms입니다. 사용자는 거의 0.5초 동안 빈 화면을 보고 기다립니다.

450ms → 45ms
TTFB 최적화 효과

RSC Streaming은 이 흐름을 어떻게 바꾸는가

스트리밍 렌더링의 핵심은 무언가를 줄이는 것이 아니라, 콘텐츠가 사용자에게 도착하는 순서를 바꾸는 것입니다. 정적 부분은 즉시 보내고, 동적 부분은 스트리밍으로 보충합니다.

// 스트리밍 아키텍처 예시
export default async function Dashboard() {
  return (
    <Layout>                          {/* 정적 shell, Suspense로 감싸지 않음 */}
      <Nav />                         {/* 즉시 렌더링 */}
      <Sidebar />                     {/* 즉시 렌더링 */}
      
      <Suspense fallback={<ChartSkeleton />}>
        <DynamicChart />               {/* 동적 데이터, 스트리밍 로드 */}
      </Suspense>
      
      <Suspense fallback={<TableSkeleton />}>
        <DataTable />                  {/* 동적 데이터, 스트리밍 로드 */}
      </Suspense>
    </Layout>
  );
}

작업 흐름을 나누면 다음과 같습니다.

  1. T=0ms: 정적 shell(Layout, Nav, Sidebar)이 CDN 엣지 캐시에서 즉시 전송됩니다
  2. T=30-50ms: 브라우저가 정적 shell 렌더링을 시작하고 스켈레톤을 표시합니다
  3. T=200ms: DynamicChart 데이터 가져오기가 끝나고 해당 Suspense 경계의 콘텐츠가 스트리밍으로 전송됩니다
  4. T=400ms: DataTable 데이터 가져오기가 끝나고 해당 콘텐츠가 스트리밍으로 전송됩니다

TTFB는 약 45ms입니다. 정적 shell을 보내는 데 걸리는 시간입니다.

PPR(Partial Prerendering)의 역할

PPR은 Next.js 15에서 도입된 기능이며 Next.js 16에서는 기본 활성화될 예정입니다. 정적 부분은 CDN에 미리 렌더링하고, 동적 부분은 스트리밍으로 유지합니다.

설정 방법은 다음과 같습니다.

// next.config.js. Next.js 15
module.exports = {
  experimental: {
    ppr: true,  // PPR 활성화
  },
};

// next.config.js. Next.js 16(프리뷰)
module.exports = {
  experimental: {
    ppr: 'incremental',  // 점진적으로 활성화
    cacheComponents: true,  // 새 캐시 모델
  },
};

PPR을 활성화하면 정적 shell(내비게이션, 레이아웃, 스켈레톤)이 미리 렌더링되어 CDN에 저장됩니다. 사용자가 방문하면 CDN이 정적 HTML을 즉시 반환하고, 동적 부분은 서버가 스트리밍으로 보충합니다.

Suspense 경계 설계 원칙

핵심 원칙: Suspense로 스트리밍 블록을 표시하는 것을 잊으면 React는 전체 애플리케이션을 하나의 거대한 블록으로 취급합니다.

올바른 방식은 다음과 같습니다.

  • 정적 부분은 Suspense로 감싸지 않습니다: 내비게이션, Layout, 데이터에 의존하지 않는 스켈레톤
  • 동적 부분은 Suspense로 감쌉니다: 데이터베이스/API에 의존하는 컴포넌트
// 올바른 예시
export default async function Page() {
  return (
    <>
      <Header />                     {/* 정적, 감싸지 않음 */}
      <main>
        <Suspense fallback={<HeroSkeleton />}>
          <HeroSection />             {/* 동적, 감쌈 */}
        </Suspense>
        
        <Suspense fallback={<ContentSkeleton />}>
          <MainContent />             {/* 동적, 감쌈 */}
        </Suspense>
      </main>
      <Footer />                      {/* 정적, 감싸지 않음 */}
    </>
  );
}

잘못된 예시(전체 페이지가 블로킹됨):

// 잘못된 예시. Suspense를 잊었습니다
export default async function Page() {
  const data = await fetchDashboard();  // await가 전체 페이지를 블로킹합니다
  return (
    <>
      <Header />
      <Dashboard data={data} />
      <Footer />
    </>
  );
}

Suspense 경계가 없으면 전체 페이지가 하나의 스트리밍 블록으로 취급됩니다. TTFB는 여전히 450ms입니다.

성능 데이터 비교

렌더링 모드TTFBLCP설명
전통적 SSR~450ms~500ms모든 데이터를 기다림
RSC(Suspense 없음)~450ms~500ms전통적 SSR과 동일
RSC Streaming~45ms~200ms정적 shell을 즉시 전송
RSC + PPR~30ms~150msCDN이 정적 shell을 캐시

데이터 출처는 SitePoint 2026년 보고서입니다. 실제 측정값은 데이터 소스와 CDN 구성에 따라 달라질 수 있습니다.


5가지 캐시 API 사용 가이드

Next.js와 React는 5가지 캐시 메커니즘을 제공합니다. 제대로 고르면 절반의 노력으로 두 배의 효과를 얻지만, 잘못 고르면 중복 요청이 생길 수 있습니다.

1. fetch cache(가장 자주 사용)

Server Components 안의 fetch 요청은 자동으로 memoized됩니다. 같은 렌더링 주기 안에서 같은 URL과 파라미터의 요청은 한 번만 발생합니다.

// fetch cache 예시
async function ProductCard({ id }) {
  // 자동 캐시. 같은 URL은 중복 요청하지 않습니다
  const res = await fetch(`https://api.example.com/products/${id}`, {
    cache: 'force-cache',      // 강제 캐시(기본값)
    next: {
      revalidate: 3600,         // 1시간 뒤 재검증
      tags: ['products'],       // revalidateTag에 사용할 태그
    },
  });
  return <Card data={res.json()} />;
}

async function ProductList() {
  // 여기의 요청은 위 캐시를 재사용합니다
  const res = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
  });
  return <List data={res.json()} />;
}

설정 옵션:

  • cache: 'force-cache': 캐시를 우선 사용합니다(기본값)
  • cache: 'no-store': 매번 다시 요청합니다
  • next.revalidate: 주기적으로 재검증합니다(초 단위)
  • next.tags: 태그입니다. revalidateTag와 함께 수동 갱신에 사용합니다

2. React cache()(React 19 신규)

함수 호출 결과를 캐시하는 데 사용합니다. 데이터베이스 쿼리나 커스텀 데이터 가져오기 함수에 적합합니다.

import { cache } from 'react';

// 데이터베이스 쿼리를 cache로 감쌉니다
export const getUser = cache(async (id: string) => {
  const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
  return user;
});

// 여러 컴포넌트에서 사용하면 자동으로 memoized됩니다
async function UserProfile({ id }) {
  const user = await getUser(id);
  return <Profile user={user} />;
}

async function UserStats({ id }) {
  const user = await getUser(id);  // 위 결과를 재사용합니다
  return <Stats user={user} />;
}

주의: cache()는 같은 렌더링 주기 안에서만 유효합니다. 요청 간 캐시가 필요하면 unstable_cache를 사용해야 합니다.

3. unstable_cache(Next.js 14-15)

요청 간에 유지되는 지속 캐시입니다. 비싼 계산이나 여러 페이지가 공유하는 데이터에 적합합니다.

import { unstable_cache } from 'next/cache';

// 함수를 감싸고 지속 캐시를 추가합니다
export const getPopularProducts = unstable_cache(
  async () => {
    const products = await db.getPopularProducts();
    return products;
  },
  ['popular-products'],           // 캐시 키
  {
    revalidate: 3600,              // 1시간 뒤 재검증
    tags: ['products', 'popular'], // 여러 태그
  }
);

// 사용
async function HomePage() {
  const products = await getPopularProducts();
  return <ProductGrid products={products} />;
}

수동 갱신:

import { revalidateTag } from 'next/cache';

// Server Action 또는 API Route 안에서
async function updateProduct() {
  await db.updateProduct();
  await revalidateTag('products');  // products 태그의 모든 캐시를 갱신합니다
}

4. use cache(Next.js 16 신규)

컴포넌트 수준 캐시 지시어입니다. 함수나 컴포넌트 상단에 'use cache'를 추가하면 출력이 자동으로 캐시됩니다.

// 함수 수준 'use cache'
'use cache';
export async function getRecommendations(userId: string) {
  return db.getRecommendations(userId);
}

// 컴포넌트 수준 'use cache'
'use cache';
export async function CachedFooter() {
  const links = await getFooterLinks();
  return <Footer links={links} />;
}

적용 시나리오: 자주 접근하는 컴포넌트와 정적 콘텐츠입니다. 실험적 기능이며 Next.js 16에서 정식 지원됩니다.

5. revalidatePath / revalidateTag

캐시를 수동으로 갱신하는 방법입니다.

import { revalidatePath, revalidateTag } from 'next/cache';

// 경로 기준 갱신
await revalidatePath('/products');      // 해당 경로의 모든 캐시를 갱신합니다
await revalidatePath('/products/[id]', 'page');  // 특정 페이지를 갱신합니다

// 태그 기준 갱신
await revalidateTag('products');        // products 태그의 모든 캐시를 갱신합니다

선택 기준:

  • 정밀 제어에는 revalidateTag를 사용합니다(추천)
  • 일괄 갱신에는 revalidatePath를 사용합니다

캐시 API 비교

API캐시 범위지속 여부적용 시나리오버전
fetch cache단일 요청구성 가능API 요청Next.js 13+
React cache()단일 렌더링 주기아니요데이터베이스 쿼리, 커스텀 함수React 19
unstable_cache요청 간예비싼 계산, 공유 데이터Next.js 14-15
use cache함수/컴포넌트 수준예자주 접근하는 컴포넌트Next.js 16
Cache Components컴포넌트 출력예PPR과 함께 사용Next.js 16

실전 설정 템플릿과 함정

이론은 여기까지입니다. 바로 복사해 쓸 수 있는 설정을 보겠습니다.

next.config.js 전체 설정

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    // Next.js 15: PPR 활성화
    ppr: true,
    
    // Next.js 16: 새 캐시 모델
    // cacheComponents: true,  // 정식 버전에서 활성화
  },
  
  // 성능 관련
  images: {
    formats: ['image/avif', 'image/webp'],
  },
  
  // 출력 최적화
  output: 'standalone',  // Docker 배포용
};

export default nextConfig;

preload 함수 export 규칙

preload 함수는 숨은 결합을 만들기 쉽습니다. 깊은 자식 컴포넌트를 삭제했을 때 preload가 불필요한 코드로 남을 수 있습니다.

추천 방식은 preload 함수 위에 주석을 달아 용도를 표시하는 것입니다.

// comments.ts
import { cache } from 'react';

const getComments = cache(async (postId: string) => {
  return db.getComments(postId);
});

/**
 * preloadComments: Comments 컴포넌트를 위해 댓글 데이터를 미리 로드합니다
 * 주의: Comments 컴포넌트를 삭제할 때 이 preload 함수도 함께 삭제해야 합니다
 */
export const preloadComments = (id: string) => {
  void getComments(id);
};

export async function Comments({ postId }) {
  const comments = await getComments(postId);
  return <CommentList comments={comments} />;
}

자주 밟는 함정

함정 1: Suspense 경계를 잊음

증상: 전체 페이지 TTFB가 여전히 450ms이고 스트리밍 효과가 없습니다.

원인: Suspense 경계가 없어서 React가 전체 페이지를 하나의 스트리밍 블록으로 취급합니다.

해결: 데이터 의존 컴포넌트에 Suspense를 추가합니다.

// 수정 전
async function Page() {
  const data = await getData();  // 전체 페이지를 블로킹합니다
  return <Dashboard data={data} />;
}

// 수정 후
async function Page() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <Dashboard />
    </Suspense>
  );
}

함정 2: preload했지만 사용하지 않음

증상: 요청은 시작되지만 데이터가 사용되지 않아 리소스를 낭비합니다.

원인: 자식 컴포넌트를 삭제했지만 preload 함수가 남아 있습니다.

해결: 컴포넌트를 삭제할 때 preload도 함께 삭제하거나, 주석으로 연결 관계를 표시합니다.

함정 3: 캐시 tags 충돌

증상: revalidateTag의 갱신 범위가 너무 커서 갱신할 필요가 없는 데이터까지 함께 갱신됩니다.

원인: 서로 관련 없는 여러 캐시가 같은 tag를 사용했습니다.

해결: 서로 다른 업무 데이터에는 서로 다른 tag를 사용합니다.

// 잘못된 예시
await fetch(url, { next: { tags: ['data'] } });  // 모든 데이터에 'data' tag를 사용합니다
await revalidateTag('data');  // 모든 데이터 캐시를 갱신합니다

// 올바른 예시
await fetch(productsUrl, { next: { tags: ['products'] } });
await fetch(usersUrl, { next: { tags: ['users'] } });
await revalidateTag('products');  // products만 갱신합니다

함정 4: fetch cache와 React cache를 섞어 씀

증상: 같은 데이터에 대해 요청이 두 번 발생합니다.

원인: fetch에 'no-store'를 사용해서 React cache()가 재사용할 수 없습니다.

해결: fetch에 force-cache 또는 기본 설정을 사용해 React cache가 재사용할 수 있게 합니다.

// 잘못된 예시
const data1 = await fetch(url, { cache: 'no-store' });  // 캐시하지 않습니다
const data2 = await getData();  // React cache로 감쌌지만 재사용할 수 없습니다

// 올바른 예시
const data1 = await fetch(url);  // 기본값은 force-cache입니다
const data2 = await getData();  // 재사용할 수 있습니다

디버깅 도구

  1. React DevTools Profiler: 렌더링 과정을 기록하고 워터폴 분포를 확인합니다
  2. Next.js 분석 도구: next build --experimental-debug로 빌드 분석을 출력합니다
  3. Chrome DevTools: Network 패널에서 요청 타이밍을 보고, Performance 패널에서 렌더링 시점을 확인합니다

핵심 지표:

  • TTFB: 첫 바이트 시간, 목표 < 100ms
  • LCP: 최대 콘텐츠 렌더링 시간, 목표 < 2.5s
  • CLS: 누적 레이아웃 이동, 목표 < 0.1

마이그레이션 제안

기존 SSR 페이지를 RSC 스트리밍으로 옮길 때는 다음 순서가 좋습니다.

  1. 먼저 워터폴을 식별합니다: Profiler로 기록하고 순차 데이터 가져오기를 찾습니다
  2. Suspense 경계를 추가합니다: 데이터 의존 컴포넌트에 Suspense를 추가하고 핵심 경로부터 처리합니다
  3. preload를 추가합니다: 깊은 컴포넌트에는 cache() + preload를 사용합니다
  4. 캐시를 구성합니다: fetch에 tags를 추가해 정밀한 갱신 제어를 구현합니다

단계적으로 마이그레이션하세요. 한 번에 전부 리팩터링하지 말고, 가장 느린 페이지부터 처리한 뒤 효과를 측정하고 확장하는 편이 좋습니다.


정리

핵심은 세 단계입니다. 워터폴을 식별하고, 해결책을 선택하고, 스트리밍 아키텍처를 구성하는 것입니다.

워터폴 문제는 비교적 쉽게 식별할 수 있습니다. 중첩 컴포넌트가 각자 데이터를 await하고, 타임라인이 계단식으로 보입니다. 네 가지 해결책은 각각 맞는 상황이 다릅니다. 빠른 마이그레이션에는 Suspense 경계를, 새 프로젝트에는 React cache() + preload를 사용하세요.

스트리밍 아키텍처의 핵심은 Suspense 경계의 위치입니다. 정적 부분(내비게이션, Layout)은 감싸지 않고, 동적 부분(데이터 의존 컴포넌트)은 반드시 감쌉니다. 이 경계를 잊으면 전체 페이지는 여전히 블로킹 렌더링입니다.

성능 효과는 숫자로 확인할 수 있습니다. TTFB가 450ms에서 45ms로 내려가며, 10배 차이가 납니다. 그 사이를 가르는 것은 몇 개의 Suspense 경계와 하나의 preload 함수일 수 있습니다.

이제 Next.js 프로젝트를 열고 중첩 컴포넌트가 각자 데이터를 가져오는 부분이 있는지 확인해 보세요. TTFB가 아직 300ms 이상이라면 핵심 콘텐츠를 Suspense로 감싸 보세요. 사용자는 차이를 바로 느낄 것입니다.


참고 자료

React Server Components 성능 최적화 절차

워터폴 식별부터 스트리밍 아키텍처 구성까지 이어지는 전체 최적화 단계입니다

⏱️ Estimated time: 60 min

  1. 1

    Step 1: 워터폴 문제 식별하기

    React DevTools Profiler로 페이지 로드를 기록합니다:

    • Chrome DevTools를 열고 Profiler 탭으로 전환합니다
    • 녹화를 시작한 뒤 페이지를 새로고침하고 로드가 끝날 때까지 기다립니다
    • 타임라인에서 요청 분포를 확인합니다
    • 계단식 분포가 보이면 워터폴 문제입니다
    • TTFB가 300ms를 넘는지 확인합니다
  2. 2

    Step 2: 해결책 선택하기

    팀 상황에 맞춰 방식을 고릅니다:

    • 빠른 마이그레이션: 해결책 2(Suspense 경계 분리)
    • 새 프로젝트: 해결책 4(React cache() + preload)
    • 데이터 결합을 감수할 수 있음: 해결책 1(Promise.all)
    • 컴포넌트 인터페이스 유지: 해결책 4(추천)
  3. 3

    Step 3: Suspense 경계 추가하기

    데이터 의존 컴포넌트에 Suspense를 추가합니다:

    • 정적 부분은 감싸지 않습니다(내비게이션, Layout)
    • 동적 부분은 반드시 감쌉니다(데이터 의존 컴포넌트)
    • 적절한 fallback 스켈레톤을 제공합니다
    • 핵심 경로부터 우선 처리합니다
  4. 4

    Step 4: React cache() + preload 구성하기

    React 19 cache() API를 사용합니다:

    • 데이터 가져오기 함수를 cache로 감쌉니다
    • preload 함수를 export하되 await하지 않습니다
    • 주석으로 연결 관계를 표시합니다
    • 컴포넌트를 삭제할 때 preload도 함께 삭제합니다
  5. 5

    Step 5: 캐시 전략 구성하기

    적절한 캐시 API를 선택합니다:

    • fetch cache: API 요청(가장 자주 사용)
    • React cache(): 데이터베이스 쿼리
    • unstable_cache: 요청 간 공유
    • 캐시에 tags를 추가해 정밀하게 갱신합니다
  6. 6

    Step 6: 성능 효과 측정하기

    최적화 결과를 검증합니다:

    • TTFB 목표: &lt; 100ms
    • LCP 목표: &lt; 2.5s
    • CLS 목표: &lt; 0.1
    • 최적화 전후 성능 데이터를 비교합니다

FAQ

React Server Components의 워터폴 문제는 무엇인가요?
워터폴 문제는 중첩 컴포넌트가 데이터를 순서대로 가져오면서 페이지 로드 시간이 누적되는 현상입니다. 예를 들어 부모 컴포넌트가 데이터를 가져온 뒤 자식 컴포넌트를 렌더링하고, 자식 컴포넌트가 다시 데이터를 가져오면 총 시간은 모든 요청 시간의 합이 됩니다. React DevTools Profiler로 계단식 요청 분포를 확인할 수 있습니다.
4가지 워터폴 해결책은 어떻게 선택해야 하나요?
팀 상황에 맞춰 선택합니다:

• Promise.all: 단순하고 직접적이며 빠른 수정에 적합하지만 데이터 결합이 남습니다
• Suspense 경계: 핵심 콘텐츠를 먼저 보여주므로 빠른 마이그레이션에 적합합니다
• Promise 전달: 모든 요청을 병렬로 실행하며 컴포넌트 인터페이스를 바꿀 수 있을 때 적합합니다
• React cache() + preload: 가장 우아한 방식이며 새 프로젝트에 적합합니다(추천)
Suspense 경계는 어디에 두어야 하나요?
핵심 원칙은 정적 부분은 Suspense로 감싸지 않고, 동적 부분은 반드시 감싸는 것입니다. 정적 부분에는 내비게이션, Layout, 데이터에 의존하지 않는 스켈레톤이 포함됩니다. 동적 부분에는 데이터베이스나 API에 의존하는 컴포넌트가 포함됩니다. 감싸는 것을 잊으면 전체 페이지가 블로킹됩니다.
5가지 캐시 API는 무엇이 다른가요?
적용 시나리오가 다릅니다:

• fetch cache: API 요청, 자동 memoized(Next.js 13+)
• React cache(): 데이터베이스 쿼리, 단일 렌더링 주기 캐시(React 19)
• unstable_cache: 요청 간 지속 캐시, 비싼 계산(Next.js 14-15)
• use cache: 함수/컴포넌트 수준 캐시(Next.js 16)
• revalidatePath/Tag: 수동 캐시 갱신
RSC 성능 최적화 효과는 어떻게 측정하나요?
React DevTools Profiler로 렌더링 과정을 기록하고 워터폴 분포를 확인합니다. 핵심 지표는 TTFB 100ms 미만, LCP 2.5s 미만, CLS 0.1 미만입니다. 최적화 후 TTFB를 450ms에서 45ms까지 낮출 수 있습니다.
PPR(Partial Prerendering)이란 무엇인가요?
PPR은 Next.js 15에서 도입된 기능이며 Next.js 16에서는 기본 활성화될 예정입니다. 정적 부분은 CDN에 미리 렌더링하고 동적 부분은 스트리밍으로 유지합니다. 활성화하면 정적 shell(내비게이션, 레이아웃)이 CDN에서 즉시 반환되어 TTFB를 30ms까지 낮출 수 있습니다. 설정은 experimental.ppr = true입니다.
자주 밟는 캐시 설정 함정은 무엇인가요?
흔한 문제는 네 가지입니다:

• Suspense 경계를 잊음: 전체 페이지가 블로킹되어 TTFB가 개선되지 않습니다
• preload했지만 사용하지 않음: 컴포넌트를 삭제했는데 preload가 남아 요청을 낭비합니다
• 캐시 tags 충돌: revalidateTag의 갱신 범위가 지나치게 커집니다
• fetch cache와 React cache 혼용: no-store 때문에 재사용할 수 없습니다

1분 읽기 · 게시일: 2026년 5월 13일 · 수정일: 2026년 10월 21일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog