Published on

Next.js 14 RSC 캐시·라우터 성능 트러블슈팅

Authors

서버 컴포넌트(React Server Components, RSC)와 App Router는 Next.js 14의 핵심이지만, 성능 문제는 대개 “캐시가 기대와 다르게 동작한다” 또는 “라우팅 전환이 불필요하게 느리다”로 나타납니다. 특히 아래 증상은 운영에서 자주 만납니다.

  • 페이지가 간헐적으로 오래 걸리고, 새로고침하면 빨라졌다가 다시 느려짐
  • 특정 사용자/쿼리에서만 데이터가 섞이거나 오래된 데이터가 노출됨
  • Link로 이동해도 풀 리로드처럼 느리고, 로딩 스피너가 길게 뜸
  • 서버 비용이 튀고(SSR 과다), DB 커넥션 풀이 빨리 고갈됨

이 글은 “RSC 캐시 계층”과 “라우터 전환 비용”을 분리해서 진단하고, 재현 가능한 방법으로 고치는 흐름을 제공합니다.

1) Next.js 14에서 캐시가 생기는 지점 3가지

App Router에서 성능을 좌우하는 캐시는 크게 3층입니다.

1-1. Data Cache (fetch 캐시)

서버에서 실행되는 fetch는 기본적으로 캐시가 개입할 수 있습니다. 대표 옵션은 다음과 같습니다.

  • cache: 'force-cache' / cache: 'no-store'
  • next: { revalidate: number }
  • next: { tags: ['...'] } + revalidateTag()

핵심은 “같은 요청으로 간주되는 키가 무엇인지”입니다. URL과 옵션, 일부 헤더 조건에 따라 동일 요청으로 묶이면 캐시 히트가 나고, 아니면 매번 새로 요청합니다.

1-2. Full Route Cache (정적 라우트 캐시)

정적으로 렌더 가능한 라우트는 빌드 시점 또는 ISR 형태로 결과가 캐시됩니다. 라우트 단위로 캐시가 걸리면 서버 부하가 급감하지만, 동적 요소가 섞이면 의도치 않게 캐시가 깨지거나 반대로 과하게 고정될 수 있습니다.

1-3. Router Cache (클라이언트 라우터 캐시)

클라이언트 측에서 App Router가 이전에 받아온 RSC payload를 보관해 전환을 빠르게 합니다. 하지만 전환 시마다 새로 받아야 하는 상황(동적 세그먼트, searchParams 변화, 캐시 무효화 등)이 많으면 체감 성능이 급락합니다.

2) 흔한 오해: “RSC가 느리다”가 아니라 “동적이 과하게 전파된다”

성능 이슈의 상당수는 작은 동적 요소가 상위 레이아웃까지 “연쇄적으로 동적”으로 만들어 캐시를 깨는 패턴입니다.

  • cookies() / headers()를 서버 컴포넌트 상단(레이아웃 포함)에서 호출
  • 인증 사용자에 따라 다른 결과를 서버에서 렌더링
  • searchParams를 상위에서 읽어 전체 트리를 재렌더

이 경우 라우트가 사실상 매 요청 SSR이 되어, RSC payload 생성 비용과 데이터 요청 비용이 매번 발생합니다.

2-1. 동적 전파를 막는 기본 전략

  • 사용자별 데이터는 가능한 한 하위 트리로 격리
  • 상위 레이아웃은 최대한 정적으로 유지
  • 꼭 필요한 곳에서만 cookies() / headers() 사용

3) 증상별 빠른 분류 체크리스트

3-1. “페이지가 랜덤하게 느리다”

  1. 서버 로그에서 해당 라우트가 매번 렌더링되는지 확인
  2. 동일 URL에 대해 fetch가 매번 발생하는지 확인
  3. cache: 'no-store'가 광범위하게 퍼져 있는지 확인

3-2. “데이터가 섞이거나 오래됨”

  1. 사용자별 응답이 Data Cache에 섞이지 않았는지 확인
  2. revalidate 값이 과하게 길거나 무효화가 누락되었는지 확인
  3. tags 기반 무효화가 실제로 호출되는지 확인

3-3. “링크 이동이 느리고 로딩이 길다”

  1. 전환 시 서버에 RSC 요청이 과도하게 발생하는지 확인
  2. loading.tsx가 너무 상위에 있어 매 전환마다 큰 스켈레톤이 뜨는지 확인
  3. 동적 세그먼트/검색 파라미터 변화가 상위 트리에 영향을 주는지 확인

4) 재현 가능한 계측: 어디가 병목인지 숫자로 잡기

4-1. 서버 렌더링과 데이터 요청 시간 분리

서버 컴포넌트에서 단순 타이밍 로그를 찍어도 병목이 선명해집니다.

// app/products/page.tsx
import { unstable_noStore as noStore } from 'next/cache'

async function getProducts() {
  const t0 = Date.now()
  const res = await fetch('https://example.com/api/products', {
    // 문제 재현용: 일단 캐시 꺼서 상한선을 본다
    cache: 'no-store',
  })
  const data = await res.json()
  console.log('getProducts ms:', Date.now() - t0)
  return data
}

export default async function Page() {
  // noStore()로 라우트 캐시까지 끄고, 순수 SSR 성능을 측정할 수도 있음
  // noStore()
  const t0 = Date.now()
  const products = await getProducts()
  console.log('Page total ms:', Date.now() - t0)

  return (
    <main>
      <h1>Products</h1>
      <pre>{JSON.stringify(products, null, 2)}</pre>
    </main>
  )
}
  • getProducts ms가 크면 네트워크/백엔드/DB 문제
  • Page total ms만 크면 렌더 트리 크기, 직렬화 비용, 불필요한 병렬성 부족 등을 의심

백엔드나 DB가 병목이라면 풀 고갈로 503이 나기도 합니다. 이때는 애플리케이션 레벨 최적화와 함께 커넥션 풀 진단이 필요합니다. 관련해서는 Spring Boot 3 503? HikariCP 풀 고갈 원인·해결 같은 접근(대기열, 타임아웃, 동시성 제한)이 Next.js의 서버 액션/SSR 부하에도 그대로 응용됩니다.

4-2. 라우터 전환 시 실제로 무엇을 요청하는지 확인

브라우저 네트워크 탭에서 전환 시 ?_rsc= 같은 요청이 반복되는지, 그리고 응답 크기가 큰지 확인합니다.

  • 응답 크기가 크면: 상위 레이아웃까지 큰 데이터를 포함하거나, 불필요한 데이터 직렬화가 있는지
  • 요청이 너무 잦으면: searchParams 변화가 상위 트리를 무효화하는지

5) RSC 캐시 트러블슈팅: 안전한 기본값 세팅

5-1. 사용자별 페이지는 기본적으로 캐시 금지

인증 사용자에 따라 결과가 달라지는 페이지는 cache: 'no-store' 또는 noStore()를 명시적으로 적용하는 편이 안전합니다.

// app/account/page.tsx
import { unstable_noStore as noStore } from 'next/cache'

export default async function AccountPage() {
  // 사용자별 페이지: 라우트 캐시 금지
  noStore()

  const res = await fetch('https://example.com/api/me', {
    cache: 'no-store',
    headers: {
      // 쿠키 기반이면 서버에서 cookies()를 꺼내 헤더로 전달하는 패턴도 흔함
    },
  })

  const me = await res.json()
  return <pre>{JSON.stringify(me, null, 2)}</pre>
}

이렇게 상한선을 잡아두고, 이후 “어떤 부분만 캐시해도 되는지”를 좁혀가는 방식이 데이터 섞임 사고를 줄입니다.

5-2. 공용 데이터는 revalidate + 태그 무효화로 운영 친화적으로

상품 목록, 카테고리, 공지 같은 공용 데이터는 ISR과 태그 무효화가 잘 맞습니다.

// lib/catalog.ts
export async function getCatalog() {
  const res = await fetch('https://example.com/api/catalog', {
    next: {
      revalidate: 60,
      tags: ['catalog'],
    },
  })
  return res.json()
}
// app/admin/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function publishCatalog() {
  // ...관리자 업데이트 로직...
  revalidateTag('catalog')
}
  • revalidate는 “최대 오래될 수 있는 시간”
  • revalidateTag는 “이벤트 기반 즉시 무효화”

둘을 같이 쓰면 트래픽이 몰려도 안정적으로 동작합니다.

5-3. 캐시 키에 사용자 컨텍스트를 섞지 말기

가장 위험한 패턴은 사용자별 토큰/쿠키를 헤더로 넣어 호출하면서 force-cache 또는 revalidate를 켜는 것입니다. 그러면 캐시가 사용자별로 분리되지 않거나(혹은 반대로 과도하게 분리되어 히트율이 0에 수렴) 예상치 못한 결과가 나옵니다.

원칙:

  • 사용자별 호출은 no-store
  • 공용 호출은 캐시 허용
  • 둘을 한 함수에서 섞지 말고 경로를 분리

6) 라우터 성능 트러블슈팅: “전환 비용”을 줄이는 실전 팁

6-1. loading.tsx의 위치가 체감 성능을 결정한다

app/loading.tsx처럼 너무 상위에 loading을 두면, 아주 작은 전환에도 전체 페이지가 로딩 상태로 떨어져 “느리다”로 인지됩니다.

권장:

  • 느린 데이터가 있는 세그먼트에만 loading.tsx를 둔다
  • 가능한 한 작은 영역만 스켈레톤 처리

6-2. searchParams는 상위에서 읽지 말고, 필요한 곳으로 내리기

정렬/필터를 위해 searchParams를 페이지 최상단에서 읽으면, 쿼리 변경마다 전체 RSC 트리가 새로 만들어집니다.

// app/products/page.tsx
export default async function ProductsPage(props: {
  searchParams: Promise<{ sort?: string }>
}) {
  const { sort } = await props.searchParams

  // sort에 따라 데이터가 달라지면, 이 페이지는 사실상 매번 동적이 됨
  // 해결: 상위는 고정하고, sort가 필요한 리스트 컴포넌트로 격리
  return (
    <main>
      <h1>Products</h1>
      <ProductsList sort={sort ?? 'popular'} />
    </main>
  )
}

async function ProductsList({ sort }: { sort: string }) {
  const res = await fetch(`https://example.com/api/products?sort=${sort}`, {
    cache: 'no-store',
  })
  const data = await res.json()
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

여기서도 더 나아가 “정렬 옵션 자체는 클라이언트에서 처리 가능한지”, “서버 정렬이 필요하다면 캐시 전략을 어떻게 가져갈지”를 분리하면 전환이 빨라집니다.

6-3. 프리패치가 꺼져 있거나 무력화되는지 확인

Link는 기본적으로 뷰포트 진입 시 프리패치를 시도하지만, 환경/설정/페이지 특성에 따라 기대만큼 동작하지 않을 수 있습니다.

  • 전환 대상이 강하게 동적이면 프리패치 이득이 제한적
  • 네트워크가 느리면 프리패치가 오히려 경쟁을 유발

필요하면 구간별로 프리패치를 제어합니다.

import Link from 'next/link'

export function Nav() {
  return (
    <nav>
      <Link href="/products" prefetch={true}>Products</Link>
      <Link href="/account" prefetch={false}>Account</Link>
    </nav>
  )
}

7) 운영에서 자주 터지는 케이스: 서버 비용 폭증과 장애 징후

RSC/SSR이 과해지면 Node 런타임뿐 아니라 외부 의존성(DB, Redis, 내부 API)까지 연쇄로 압박합니다. 징후는 다음과 같습니다.

  • 응답 지연과 함께 5xx 증가
  • DB 커넥션 대기 증가, 타임아웃
  • 컨테이너 재시작/헬스체크 실패

이때 플랫폼이 Kubernetes라면 재시작 루프 진단을 병행해야 합니다. 증상이 컨테이너 레벨로 번지면 K8s CrashLoopBackOff 원인 10가지·즉시 진단법 체크리스트처럼 “애플리케이션 로그 이전의 인프라 신호”를 먼저 잡는 게 시간을 아낍니다.

또한 Cloud Run 같은 서버리스에서는 콜드스타트와 동적 SSR이 합쳐져 라우팅 체감이 급격히 나빠질 수 있습니다. 이 경우는 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드의 접근처럼 “초기화 비용 최소화 + 캐시 가능한 경로 확대”가 핵심입니다.

8) 최종 점검: 문제를 ‘원인-해결’로 매핑하는 표

증상가장 흔한 원인1차 처방
매 요청 느림상위 레이아웃 동적화, no-store 남발동적 요소 하위로 격리, 공용 데이터는 revalidate 도입
데이터 오래됨revalidate 과대, 무효화 누락tags + revalidateTag() 적용
사용자 데이터 섞임사용자별 요청이 캐시됨사용자별은 no-store, 공용/개인 경로 분리
링크 전환이 무겁다상위 loading.tsx, searchParams 전파loading 하향 배치, 리스트/필터 컴포넌트 격리
백엔드/DB가 먼저 죽음SSR 동시성 증가로 의존성 포화동시성 제한, 캐시 확대, 풀/타임아웃 재조정

9) 결론: RSC 성능은 “캐시 설계”와 “동적 범위”의 문제다

Next.js 14에서 RSC 자체가 느린 경우는 드뭅니다. 대부분은 다음 두 가지가 성능을 결정합니다.

  1. 무엇을 공용으로 캐시하고, 무엇을 사용자별로 분리할지(데이터 캐시 설계)
  2. 동적 요소가 상위 트리로 전파되지 않도록 경계를 어디에 둘지(라우터 전환 비용)

진단은 “캐시를 과감히 끄고 상한선을 측정한 뒤”, 다시 캐시를 “안전한 곳부터” 켜는 방식이 가장 빠릅니다. 그 과정에서 revalidatetags를 운영 이벤트에 맞춰 설계하면, 성능과 정합성을 동시에 잡을 수 있습니다.