Published on

Next.js App Router 로딩 느림? RSC 캐시·prefetch 최적화

Authors

서버 컴포넌트 기반의 Next.js App Router는 잘 튜닝하면 초기 로딩과 라우팅 전환이 매우 빨라집니다. 그런데 실무에서는 오히려 "페이지 이동이 굼뜨다", "로딩 스피너가 자주 돈다", "같은 데이터를 계속 다시 받아온다" 같은 불만이 나오는 경우가 많습니다.

대부분은 App Router 자체가 느린 게 아니라, 다음 두 축이 어긋나서 생깁니다.

  • RSC 캐시 전략이 의도와 다르게 동작해서 매 요청마다 서버 렌더링과 데이터 패칭이 반복됨
  • prefetch가 기대만큼 미리 안 당겨지거나, 반대로 과도하게 당겨서 네트워크와 CPU를 잡아먹음

이 글에서는 "왜 느려졌는지"를 RSC 요청 흐름으로 쪼개서 진단하고, 캐시와 prefetch를 실전적으로 최적화하는 방법을 코드와 함께 정리합니다.

관련해서 RSC 캐시가 꼬여 갱신이 안 되는 케이스는 별도 글에 더 깊게 정리해 두었습니다.


1) App Router에서 "로딩"이 느려지는 지점부터 분해하기

App Router의 한 번의 페이지 전환은 대략 이런 일들이 섞여 일어납니다.

  1. 클라이언트가 라우트 전환을 시작
  2. (가능하면) Next가 해당 라우트의 RSC 페이로드를 미리 가져오거나, 전환 시점에 가져옴
  3. 서버에서 서버 컴포넌트 트리 렌더링 및 데이터 패칭
  4. 스트리밍으로 RSC 페이로드 전송
  5. 클라이언트에서 서버 컴포넌트 결과를 합성하고, 필요한 클라이언트 컴포넌트 하이드레이션

여기서 체감이 느려지는 대표 원인은 다음과 같습니다.

  • 서버에서 매번 동일한 데이터 패칭이 반복됨(캐시 미적용)
  • 동적 렌더링으로 강제되어 CDN 캐시나 Next 캐시를 못 탐
  • loading.tsx가 자주 보인다는 것은 스트리밍이 늦거나, suspense 경계가 과하게 쪼개져 있거나, prefetch가 안 먹고 있다는 신호
  • prefetch가 너무 공격적이라서 오히려 현재 페이지의 LCP, INP를 깎아먹음

프론트 성능 지표 측면에서 원인 추적은 LCP와 INP 관점이 특히 유효합니다.


2) RSC 캐시의 핵심: "무엇을", "어디에", "얼마나" 캐시할 것인가

App Router에서 흔히 혼동하는 포인트는 "페이지가 캐시되는 것"과 "데이터 패칭이 캐시되는 것"이 다르다는 점입니다.

  • 페이지 전체를 정적으로 만들면(정적 렌더링) 서버 렌더 비용이 크게 줄어듭니다.
  • 동적 페이지라도 fetch 레벨에서 캐시를 잘 쓰면, 서버 렌더는 발생해도 데이터 패칭 비용은 줄일 수 있습니다.

2-1) fetch 캐시 옵션을 먼저 표준화하기

가장 많이 보는 실수는 아무 옵션 없이 fetch를 호출해서, 환경에 따라 의도치 않게 동적이 되거나 캐시가 비어 있는 상태로 매번 호출되는 것입니다.

아래는 데이터 성격별로 자주 쓰는 패턴입니다.

// app/products/page.tsx (Server Component)
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    // 일정 시간 동안 캐시하고 재검증
    next: { revalidate: 60 },
  })

  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

export default async function Page() {
  const products = await getProducts()
  return (
    <main>
      <h1>Products</h1>
      <pre>{JSON.stringify(products, null, 2)}</pre>
    </main>
  )
}
  • 자주 바뀌지 않는 목록: next: { revalidate: 60 } 같이 ISR 성격으로
  • 사용자별 데이터, 실시간성 강함: cache: 'no-store' 또는 revalidate: 0
async function getMe() {
  const res = await fetch('https://api.example.com/me', {
    cache: 'no-store',
    headers: {
      // 인증 기반이면 보통 쿠키/헤더에 의해 응답이 달라짐
      Authorization: `Bearer ${process.env.API_TOKEN}`,
    },
  })

  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

중요한 점은 cache: 'no-store'가 섞인 순간, 그 데이터에 의존하는 RSC 트리는 매 요청마다 새로 렌더링될 가능성이 커지고, 결과적으로 페이지 전환 체감이 느려질 수 있다는 것입니다.

2-2) 동적 렌더링을 유발하는 트리거를 제거하기

다음 요소가 있으면 해당 라우트가 동적으로 평가되기 쉽습니다.

  • cookies() 또는 headers() 사용
  • cache: 'no-store'
  • revalidate: 0
  • 사용자별 데이터를 서버 컴포넌트 최상단에서 바로 읽기

해결 전략은 "동적이 꼭 필요한 부분"을 아래로 내리는 것입니다.

  • 상단은 캐시 가능한 공용 데이터로 구성
  • 사용자별 데이터는 별도 Suspense 경계로 분리하거나 클라이언트에서 가져오기
// app/dashboard/page.tsx
import { Suspense } from 'react'

export default async function Page() {
  // 공용 데이터는 캐시 가능하게
  const res = await fetch('https://api.example.com/announcements', {
    next: { revalidate: 300 },
  })
  const announcements = await res.json()

  return (
    <main>
      <h1>Dashboard</h1>
      <section>
        <h2>Announcements</h2>
        <pre>{JSON.stringify(announcements, null, 2)}</pre>
      </section>

      <Suspense fallback={<p>Loading my widgets...</p>}>
        <MyWidgets />
      </Suspense>
    </main>
  )
}

async function MyWidgets() {
  // 여기서만 사용자별/동적 데이터 사용
  const res = await fetch('https://api.example.com/my-widgets', {
    cache: 'no-store',
  })
  const widgets = await res.json()

  return (
    <section>
      <h2>My Widgets</h2>
      <pre>{JSON.stringify(widgets, null, 2)}</pre>
    </section>
  )
}

이렇게 하면 공용 영역은 캐시를 타고 빨리 뜨고, 사용자별 영역만 늦게 스트리밍되어도 체감이 개선됩니다.


3) unstable_cache와 태그 기반 재검증으로 "서버 계산"까지 캐시하기

fetch 캐시는 HTTP 요청에만 적용됩니다. 그런데 느린 원인이 다음과 같은 경우도 많습니다.

  • 여러 API 응답을 합치는 조합 로직
  • DB 쿼리 후 가공, 정렬, 그룹핑
  • 외부 API 호출 없이도 무거운 계산

이때는 Next의 데이터 캐시 유틸을 사용해 서버 함수 결과 자체를 캐시하는 것이 효과적입니다.

// app/lib/getCatalog.ts
import { unstable_cache } from 'next/cache'

type Catalog = {
  categories: string[]
  featured: { id: string; name: string }[]
}

async function buildCatalog(): Promise<Catalog> {
  // 예: 여러 소스를 조합
  const [a, b] = await Promise.all([
    fetch('https://api.example.com/categories', { next: { revalidate: 600 } }).then(r => r.json()),
    fetch('https://api.example.com/featured', { next: { revalidate: 60 } }).then(r => r.json()),
  ])

  return { categories: a, featured: b }
}

export const getCatalog = unstable_cache(
  async () => buildCatalog(),
  ['catalog-v1'],
  { revalidate: 60 }
)

페이지에서는 이렇게 사용합니다.

// app/(shop)/page.tsx
import { getCatalog } from '../lib/getCatalog'

export default async function Page() {
  const catalog = await getCatalog()
  return (
    <main>
      <h1>Shop</h1>
      <pre>{JSON.stringify(catalog, null, 2)}</pre>
    </main>
  )
}

핵심은 "RSC 렌더링이 느린 이유가 네트워크가 아니라 서버 계산"인 경우, unstable_cache가 체감 성능을 크게 바꾼다는 점입니다.

추가로 운영에서는 태그 기반 무효화도 자주 씁니다.

// app/actions/revalidateCatalog.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function revalidateCatalog() {
  revalidateTag('catalog')
}

위 예시는 개념 예시이고, 실제로는 fetchnext: { tags: ['catalog'] }를 부여해 태그를 연결한 뒤, 배치나 CMS 업데이트 시점에 무효화하는 식으로 구성합니다.


4) prefetch 최적화: "안 되는 이유"와 "과한 이유"를 둘 다 잡기

App Router에서 페이지 이동이 느릴 때, 많은 팀이 Linkprefetch만 켜면 해결될 거라 기대합니다. 하지만 prefetch는 다음 전제들이 맞아야 효과가 큽니다.

  • 링크가 뷰포트에 들어와야 함(관찰 기반)
  • 네트워크가 바쁘지 않아야 함
  • 해당 라우트가 과도하게 동적이면 prefetch 비용 대비 이득이 낮아짐

4-1) 중요한 링크만 선택적으로 prefetch 켜기

모든 링크를 prefetch하면, 특히 목록 페이지에서 수십 개 라우트를 동시에 당기면서 현재 페이지의 리소스 경쟁이 심해질 수 있습니다.

import Link from 'next/link'

export function ProductCard({ id, name }: { id: string; name: string }) {
  return (
    <Link
      href={`/products/${id}`}
      // 전환 빈도가 높고 사용자가 다음에 클릭할 확률이 높은 링크만
      prefetch
    >
      {name}
    </Link>
  )
}

반대로 "목록에서 상세로 많이 들어가긴 하는데, 카드가 100개"라면 prefetch를 끄고, 상단 몇 개만 켜거나 사용자 상호작용 시점에만 당기는 전략이 안전합니다.

<Link href={`/products/${id}`} prefetch={false}>
  {name}
</Link>

4-2) 사용자 의도 기반으로 미리 당기기

정말로 체감 개선이 필요한 핵심 플로우에서는, "hover"나 "touchstart"에 맞춰 prefetch를 트리거하는 방식을 고려할 수 있습니다.

Next 내부 prefetch API는 버전별로 변경 여지가 있어, 여기서는 안정적인 대안으로 router.prefetch를 사용합니다.

'use client'

import { useRouter } from 'next/navigation'

export function IntentPrefetchLink({ href, children }: { href: string; children: React.ReactNode }) {
  const router = useRouter()

  return (
    <a
      href={href}
      onMouseEnter={() => router.prefetch(href)}
      onTouchStart={() => router.prefetch(href)}
    >
      {children}
    </a>
  )
}

주의할 점

  • 모바일에서는 touchstart가 과도한 prefetch를 유발할 수 있어, 핵심 CTA에만 제한하는 것이 좋습니다.
  • prefetch는 "데이터"뿐 아니라 라우트 트리와 RSC 페이로드를 건드리므로, 동적 페이지에서 비용이 커질 수 있습니다.

5) loading.tsx가 자주 보인다면: prefetch 문제가 아니라 "경계" 문제일 수 있음

loading.tsx는 App Router의 강력한 UX 도구지만, 너무 자주 보이면 사용자에게 "항상 느리다"는 인상을 줍니다.

체크리스트

  • 상단 레이아웃까지 매번 다시 로딩되는가
  • Suspense 경계를 너무 상위에 둬서, 작은 데이터 지연에도 전체가 fallback으로 떨어지는가
  • 동적 데이터가 최상단에 있어 페이지 전체가 매번 늦어지는가

권장 패턴

  • 레이아웃은 최대한 안정적으로 유지
  • 느린 데이터는 하위 섹션으로 격리
  • 가능한 부분은 스트리밍으로 먼저 보여주기
// app/reports/page.tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <main>
      <h1>Reports</h1>

      <Suspense fallback={<p>Loading summary...</p>}>
        <Summary />
      </Suspense>

      <Suspense fallback={<p>Loading chart...</p>}>
        <Chart />
      </Suspense>
    </main>
  )
}

async function Summary() {
  const res = await fetch('https://api.example.com/reports/summary', {
    next: { revalidate: 120 },
  })
  const data = await res.json()
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

async function Chart() {
  const res = await fetch('https://api.example.com/reports/chart', {
    cache: 'no-store',
  })
  const data = await res.json()
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

요지는 "정적 또는 캐시 가능한 것부터 먼저" 보여주고, 동적인 것은 늦게 와도 UX가 무너지지 않게 만드는 것입니다.


6) 운영에서 바로 쓰는 점검 루틴

App Router 성능 이슈는 감으로 고치면 재발합니다. 아래 순서로 로그와 설정을 점검하면 원인을 빠르게 좁힐 수 있습니다.

6-1) 라우트 단위로 동적 여부 확인

  • 해당 라우트가 정적이어야 하는데 동적으로 평가되고 있지 않은지
  • cookies()headers()가 불필요하게 상단에서 호출되고 있지 않은지

6-2) 데이터 호출별로 캐시 의도를 명시

  • 모든 fetchnext: { revalidate: n } 또는 cache: 'no-store'를 명시
  • "왜 이 데이터는 no-store인가"를 코드 코멘트로 남기기

6-3) prefetch는 핵심 전환에만 제한

  • 목록 페이지에서 대량 prefetch가 발생하면 오히려 LCP, INP가 악화
  • 상단 내비게이션, 핵심 CTA 등 전환 확률이 높은 링크만 prefetch

7) 결론: 느린 App Router의 대부분은 "캐시 설계"와 "prefetch 남용" 문제

정리하면, App Router에서 로딩이 느릴 때의 우선순위는 다음이 가장 효율적입니다.

  1. fetch 캐시 정책을 데이터 성격에 맞게 표준화해서 불필요한 재요청 제거
  2. 동적 트리거를 상단에서 제거하고, 동적 데이터는 하위 Suspense 경계로 격리
  3. 서버 계산이 무겁다면 unstable_cache로 함수 결과까지 캐시
  4. prefetch는 "많이"가 아니라 "정확히"가 정답, 핵심 링크만 의도 기반으로 적용

이 네 가지를 잡으면, 같은 코드베이스에서도 라우팅 전환 체감이 확연히 달라집니다. 특히 RSC 캐시는 한 번 꼬이면 "느림"과 "갱신 안 됨"이 같이 나타날 수 있으니, 증상이 비슷하다면 앞서 공유한 RSC 캐시 트러블슈팅 글도 함께 확인해 보세요.