Next.js 링크가 느린 이유: cookies() 하나가 prefetch를 바꾼다 (16.3 기준)

Next.js 링크가 느린 이유: cookies() 하나가 prefetch를 바꾼다 (16.3 기준)

Next.js App Router의 기본 설정에서는 레이아웃이나 페이지 어딘가에서 cookies(), headers(), searchParams 같은 요청 시점 API를 하나만 읽어도 그 라우트 전체가 동적 렌더링으로 바뀝니다. 동적 라우트는 <Link>가 미리 가져오지(prefetch) 않거나 loading.js 경계까지만 가져옵니다. 그래서 loading.js가 없다면 링크를 누른 뒤 서버가 응답할 때까지 화면이 멈춘 것처럼 보입니다. 이 문제를 prefetch={true}로 덮으면 이번에는 화면에 보이는 링크마다 서버 렌더링 비용이 붙습니다.

Next.js 팀도 이 문제를 인정했습니다. 2026년 8월 3일 공개된 Next.js 16.3 릴리스 글은 "Prefetching was too aggressive and costly(prefetch가 지나치게 공격적이고 비쌌다)"라고 적었습니다. 함께 실린 예시도 이 글의 주제 그대로입니다. cookies()를 읽는 컴포넌트 하나가 공통 헤더에 추가되면 라우트가 요청 시점 렌더링으로 밀려날 수 있다는 것입니다(Next.js 16.3, 2026-10-03 확인).

이 글은 2026년 10월 3일 기준 npm latest인 Next.js 16.3.8(Active LTS) 공식 문서를 근거로 씁니다. 동작은 두 경우로 나눠 설명합니다. 하나는 기본 설정이고, 다른 하나는 Cache Components를 켠 경우입니다. 두 경우는 결론이 정반대이기 때문에, 이 구분 없이 쓰인 팁은 절반만 맞습니다.

핵심 요약

  • 기본 설정(cacheComponents 꺼짐)에서는 레이아웃이나 페이지가 cookies(), headers(), searchParams, draftMode() 가운데 하나만 써도 라우트 전체가 동적 렌더링이 됩니다.
  • 동적 라우트는 prefetch를 건너뛰거나 loading.js까지만 가져옵니다. 레이아웃이 cookies()를 읽으면 같은 폴더나 그 아래에 둔 loading.js로는 막힌 이동을 풀 수 없습니다.
  • prefetch={true}는 동적 라우트를 통째로 미리 렌더링하므로 보이는 링크 수만큼 서버 비용이 생깁니다. prefetch={false}는 비용 대신 체감 속도를 포기하는 선택입니다.
  • 근본 처방은 요청 시점 데이터를 공통 레이아웃에서 떼어 내는 것입니다. 기본 설정에서는 그 값이 필요한 페이지나 클라이언트 컴포넌트로 옮기고, Cache Components를 켰다면 그 부분만 <Suspense>로 감쌉니다. 16.3의 Partial Prefetching까지 켜면 링크별이 아니라 라우트별로 셸 하나만 prefetch합니다.

목차

  • 왜 cookies() 하나가 라우트 전체를 동적으로 만드나요?
  • 동적 라우트에서 prefetch는 실제로 무엇을 가져오나요?
  • 오해 1: "prefetch는 공짜다"
  • 오해 2: "prefetch={true}를 붙이면 빨라진다"
  • 오해 3: "prefetch={false}로 끄면 해결된다"
  • 근본 처방: 요청 시점 데이터를 공통 레이아웃에서 떼어 내기
  • 내 앱을 점검하는 순서
  • 이 글의 자료를 고른 방법
  • 자주 묻는 질문
  • 마무리: 하나만 고친다면
  • 함께 보면 좋은 영상

왜 cookies() 하나가 라우트 전체를 동적으로 만드나요?

기본 설정의 렌더링 단위가 컴포넌트가 아니라 라우트이기 때문입니다. cookies() 공식 문서에는 "cookies는 미리 값을 알 수 없는 요청 시점 API이며, 레이아웃이나 페이지에서 사용하면 라우트가 동적 렌더링으로 전환된다"라고 적혀 있습니다(cookies). 공식 용어집은 같은 효과를 내는 요청 시점 API로 cookies(), headers(), searchParams, draftMode() 네 가지를 듭니다(Next.js Glossary). 여기에 fetch의 cache: 'no-store'나 revalidate: 0도 라우트를 동적으로 렌더링하게 만든다고 기존 캐싱 가이드는 설명합니다(Caching and Revalidating (Previous Model)).

실무에서 가장 흔한 경로는 공통 레이아웃입니다. 예를 들어 쇼핑몰의 모든 페이지가 공유하는 헤더에 장바구니 개수를 띄운다고 해 보겠습니다.

// app/(shop)/layout.tsx
import { cookies } from 'next/headers'

export default async function ShopLayout({ children }: { children: React.ReactNode }) {
  const cartId = (await cookies()).get('cart')?.value
  const count = await getCartCount(cartId)

  return (
    <>
      <Header cartCount={count} />
      {children}
    </>
  )
}

이 레이아웃 아래의 /products/[id]가 generateStaticParams로 빌드할 때 미리 만들어 두던 상품 상세 페이지라고 해 보겠습니다. 그런데 헤더 한 줄 때문에 (shop) 그룹의 모든 라우트가 요청이 올 때마다 서버에서 렌더링됩니다. 상품 설명이나 이미지처럼 모든 사용자에게 똑같은 내용도 함께 다시 그립니다.

내 라우트가 어느 쪽인지는 next build 출력에서 바로 확인할 수 있습니다. 라우트 목록 옆에 정적(Static)은 ○, generateStaticParams로 미리 만든 페이지(SSG)는 ●, 동적(Dynamic)은 ƒ로 표시됩니다. 어제까지 ○나 ●였던 라우트가 ƒ로 바뀌었다면 최근에 추가된 요청 시점 API부터 찾아보세요. 개발 모드에서는 Next.js Devtools로도 라우트가 정적인지 동적인지 확인할 수 있습니다(Linking and Navigating).

동적 라우트에서 prefetch는 실제로 무엇을 가져오나요?

기본 설정에서 동적 라우트는 prefetch 대상에서 빠지거나, loading.js 경계까지만 미리 받습니다. Prefetching 가이드는 Cache Components를 쓰지 않을 때의 동작을 아래처럼 정리합니다(Prefetching).

구분 정적 페이지 동적 페이지
prefetch 여부 라우트 전체 안 함 (loading.js가 있으면 그 경계까지)
클라이언트 캐시 유지 시간 기본 5분 꺼짐 (staleTimes로 켜야 함)
클릭 시 서버 왕복 없음 있음

Linking 문서는 이 설계의 이유와 대가를 함께 적어 둡니다. 동적 라우트를 건너뛰는 이유는 사용자가 방문하지 않을 수도 있는 라우트에 서버가 쓸데없이 일하지 않게 하려는 것입니다. 하지만 "이동 전에 서버 응답을 기다리면 사용자는 앱이 반응하지 않는다고 느낄 수 있다"고 인정합니다. 그래서 동적 라우트에는 loading.tsx를 추가하라고 권합니다(Linking and Navigating). 16.3 Instant Navigations 글은 이 체감을 세 줄로 요약합니다. 링크를 누른다, 아무 일도 일어나지 않는다, 그다음에 서버가 응답한다(Next.js 16.3: Instant Navigations, 2026-06-25).

loading.js를 넣었는데도 느리다면 cookies()를 어디서 읽는지 보세요. loading.js는 같은 폴더의 page.js와 하위 레이아웃은 감싸지만, 같은 폴더의 layout.js는 감싸지 않습니다. 공식 문서도 레이아웃이 cookies(), headers(), 캐시되지 않은 fetch를 쓰면 loading.js 대체 화면(fallback)이 그 부분에는 보이지 않는다고 적었습니다. Cache Components를 쓰지 않는다면 "레이아웃 렌더링이 끝날 때까지 이동이 막힌다"는 것입니다(loading.js). 앞의 ShopLayout처럼 장바구니 쿠키를 레이아웃에서 읽고 있다면, (shop) 폴더와 그 아래에 loading.tsx를 몇 개 추가해도 (shop) 그룹으로 들어가는 이동은 레이아웃 렌더링이 끝날 때까지 막힙니다.

문서상 부모 세그먼트의 loading.js(예: app/loading.tsx)는 하위 레이아웃까지 감싸므로 대체 화면을 보여 줄 여지는 있습니다. 다만 그 아래 모든 라우트에 같은 로딩 화면이 걸리므로, next build && next start로 직접 확인하고 쓰는 편이 안전합니다.

실무 포인트: "RSC가 하나라도 있으면 느리다"는 말은 정확하지 않습니다. App Router에서는 컴포넌트가 기본적으로 모두 RSC이기 때문입니다. 정확히는 "요청 시점 데이터를 읽는 서버 컴포넌트가 공통 레이아웃처럼 위쪽에 있으면 피해가 넓다"입니다. 기본 설정에서는 그 호출을 <Suspense>로 감싸도 라우트는 여전히 동적입니다. 피해를 줄이려면 호출 위치를 레이아웃 밖으로 옮겨야 합니다. <Suspense>가 피해 범위를 그 컴포넌트로 좁혀 주는 것은 Cache Components를 켰을 때입니다(Caching).

오해 1: "prefetch는 공짜다"

prefetch는 공짜가 아닙니다. 동적 라우트를 prefetch한다는 것은 그 자리에서 서버 렌더링을 돌린다는 뜻입니다. 앞에서 본 것처럼 Linking 문서가 기본 설정에서 동적 라우트 prefetch를 건너뛰는 이유로 서버의 불필요한 작업을 든 것도 이 때문입니다. prefetch={true}로 동적 라우트 전체를 받으면 렌더링 범위도 그만큼 커집니다(Link). Cache Components 전용인 prefetch 세그먼트 설정 문서도 쿠키나 헤더를 읽는 페이지는 "런타임에 새 서버 렌더링으로 prefetch되며, 이는 페이지 뷰마다 서버 CPU 비용이 든다"고 적었습니다(prefetch). 렌더링 모델이 달라도 요청 시점 데이터를 읽는 prefetch가 서버를 깨운다는 점은 같습니다.

CDN도 이 비용을 흡수하지 못합니다. prefetch는 rsc, next-router-prefetch 같은 헤더와 _rsc 쿼리 파라미터가 붙은 RSC 페이로드 요청입니다. 그런데 동적 페이지 응답에는 private, no-cache, no-store, max-age=0, must-revalidate가 붙습니다. CDN 가이드는 prefetch 응답 가운데 CDN 캐시가 가능한 경우로 Partial Prerendering이 켜진 라우트의 정적 prefetch를 듭니다(Using a CDN with Next.js). 브라우저 개발자 도구의 Network 탭에서 _rsc로 필터링하면 이 요청들을 직접 볼 수 있습니다.

서버리스 환경에서는 요청 하나하나가 과금 단위입니다. Vercel의 Fluid compute 가격 문서는 Invocations를 "함수로 들어오는 요청마다" 세고, 요청이 성공했든 실패했든 센다고 설명합니다. Pro 플랜의 Invocations는 100만 건당 0.60달러이고, Active CPU와 Provisioned Memory는 따로 청구됩니다(Fluid compute pricing, 2026-06-16 갱신). 호출 단가 자체는 싸지만, 사용자가 누르지도 않은 링크에 대해 렌더링 비용까지 붙는다는 점이 문제입니다.

링크 20개가 보일 때 prefetch 요청 수 (Next.js 공식 블로그 예시)
공식 블로그가 든 예시를 그대로 옮긴 개념도이며, 실측 벤치마크가 아닙니다. 16.3 값은 cacheComponents와 partialPrefetching을 모두 켰을 때입니다. 출처: Next.js 16.3: Instant Navigations, 2026-06-25

Next.js 팀도 이 비용을 직접 언급했습니다. 16.2까지는 화면에 보이는 링크마다 서버로 prefetch 요청을 보냈고, 스크롤할 때 Network 탭에 요청이 쏟아졌습니다. 팀은 "많은 분이 이게 터무니없어 보인다고 했고, 솔직히 우리도 동의한다"고 썼습니다(Next.js 16.3: Instant Navigations). 버전을 올릴 때도 이 비용을 지켜봐야 합니다. 16.0.1에서는 prefetch가 중복 실행되어 Vercel 함수 호출이 두 배 넘게 늘었다는 이슈가 보고됐고, 지금은 닫혀 있습니다(vercel/next.js #85489, 2025-10-29).

오해 2: "prefetch={true}를 붙이면 빨라진다"

빨라지기는 합니다. 대신 누르지 않은 링크에도 비용을 냅니다. prefetch={true}는 정적·동적 라우트 모두 전체를 prefetch합니다(Link). 이렇게 받은 페이지에는 클라이언트 캐시의 static 유지 시간(기본 5분)이 적용됩니다. 그러니 클릭했을 때 즉시 뜨는 것은 맞습니다. 문제는 상품 카드 50개가 있는 목록 페이지라면 사용자가 하나도 누르지 않아도 동적 렌더링이 최대 50번 일어난다는 점입니다. Cache Components 기준의 최적화 가이드도 링크별 prefetch를 두고 "보이는 링크마다 비용을 낸다, 클릭 여부와 상관없이"라고 경고합니다(Optimizing prefetching).

prefetch={true} 없이 받은 동적 페이지는 클라이언트에 오래 남지 않습니다. Next.js 15.0부터 staleTimes.dynamic 기본값이 30초에서 0초로 바뀌었기 때문입니다.

staleTimes 기본값 변화 (v14.2와 v15.0 이후)
v15.0 이후 값은 2026년 10월 기준 latest인 16.3.8까지 같습니다. 출처: staleTimes, 문서 버전 16.3.8, 2026-10-03 확인

예외는 브라우저의 뒤로 가기와 앞으로 가기입니다. 이때는 캐시된 페이지를 재사용합니다(Next.js Glossary). 그렇다고 staleTimes.dynamic 값을 늘리는 것을 정답으로 보기는 어렵습니다. 공식 문서는 staleTimes를 아직 실험 기능으로 분류하고 "프로덕션 사용을 권장하지 않는다"고 적었습니다(staleTimes). 16.3 릴리스 글도 16.3 이전의 선택지가 둘뿐이었다고 정리합니다. loading.tsx로 재사용 가능한 로딩 셸을 만들거나, <Link prefetch={true}>로 페이지 전체를 공격적으로 prefetch하는 것입니다. 그리고 많은 앱이 결국 링크 클릭 때마다 이동이 막히는 상태로 남았다고 썼습니다(Next.js 16.3).

오해 3: "prefetch={false}로 끄면 해결된다"

prefetch={false}는 비용 문제를 체감 속도 문제로 바꿀 뿐입니다. 공식 문서는 무한 스크롤 표처럼 링크가 많은 곳에서 리소스를 아끼려고 끄는 것을 인정합니다. 하지만 대가도 분명히 적었습니다. 정적 라우트는 클릭해야 가져오고, 동적 라우트는 서버 렌더링이 끝나야 이동할 수 있습니다(Linking and Navigating). 이미 동적이던 라우트라면 prefetch를 꺼도 체감은 거의 그대로이거나 더 나빠집니다.

비용과 체감 사이의 현실적인 타협은 hover할 때만 prefetch하는 것입니다. 공식 문서가 직접 제시하는 패턴은 다음과 같습니다.

'use client'

import Link from 'next/link'
import { useState } from 'react'

export function HoverPrefetchLink({ href, children }: { href: string; children: React.ReactNode }) {
  const [active, setActive] = useState(false)

  return (
    <Link href={href} prefetch={active ? null : false} onMouseEnter={() => setActive(true)}>
      {children}
    </Link>
  )
}

처음에는 prefetch={false}로 시작하고, 마우스를 올리면 null(기본 동작)로 돌아가 prefetch합니다(Prefetching). 터치 기기에서는 hover 시점이 사실상 탭 직전이라, 모바일 비중이 높은 서비스라면 효과가 줄어든다는 점도 감안하세요. 기다리는 동안 아무 반응이 없는 것이 문제라면 useLinkStatus(v15.3.0 도입)로 링크 옆에 대기 표시를 띄울 수 있습니다. 공식 문서는 이 훅이 prefetch가 꺼져 있거나 목적지가 loading.js 없는 동적 라우트일 때 유용하다고 하면서도, 느린 이동을 발견했을 때 쓰는 "빠른 임시 처방(quick patch)"이고 원인은 prefetch나 loading.js로 고치라고 덧붙입니다(useLinkStatus).

참고로 15.3에는 hover할 때 전체 prefetch로 바꾸는 unstable_dynamicOnHover prop도 들어왔습니다(PR #77866). 하지만 공식 문서에 없는 불안정 API이므로 프로덕션 코드의 기본 해법으로 삼기는 어렵습니다.

근본 처방: 요청 시점 데이터를 공통 레이아웃에서 떼어 내기

근본 처방은 요청 시점 데이터를 읽는 부분을 최대한 작게 떼어 내는 것입니다. 버전과 설정에 따라 방법이 둘로 나뉩니다.

기본 설정을 유지할 때

기본 설정에서는 <Suspense>로 감싸는 것만으로 라우트가 정적으로 돌아오지 않습니다. 요청 시점 API를 부르는 위치 자체를 공통 레이아웃 밖으로 옮겨야 합니다. 앞의 장바구니 예시라면 쿠키를 읽는 일을 Route Handler에 맡기고, 헤더에서는 클라이언트 컴포넌트가 숫자만 받아 오게 바꿀 수 있습니다.

// app/api/cart/count/route.ts
import { cookies } from 'next/headers'

export async function GET() {
  const cartId = (await cookies()).get('cart')?.value
  return Response.json({ count: await getCartCount(cartId) })
}
// app/(shop)/cart-badge.tsx
'use client'

import { useEffect, useState } from 'react'

export function CartBadge() {
  const [count, setCount] = useState<number | null>(null)

  useEffect(() => {
    fetch('/api/cart/count')
      .then((res) => res.json())
      .then((data) => setCount(data.count))
  }, [])

  return <span aria-label="장바구니 상품 수">{count ?? ''}</span>
}

이제 ShopLayout은 cookies()를 읽지 않고 <Header><CartBadge /></Header>만 렌더링합니다. 아래 페이지들이 다른 이유로 동적이지 않다면 next build에서 다시 ○(generateStaticParams로 만든 페이지는 ●)로 표시됩니다. 대가도 있습니다. 장바구니 숫자는 첫 화면보다 조금 늦게 뜹니다. 또 레이아웃은 (shop) 그룹 안에서 이동해도 다시 마운트되지 않으므로, 상품을 담은 뒤에는 숫자를 다시 불러오는 처리가 따로 필요합니다. 담기 동작이 끝난 뒤 같은 API를 한 번 더 호출하는 정도면 충분합니다. 그래도 링크마다 동적 렌더링이 일어나는 것보다는 비용을 예측하기 쉽습니다. 정리하면 순서는 이렇습니다.

  1. 공통 layout.tsx에서 cookies()와 headers()를 빼서, 그 값이 정말 필요한 페이지나 위 예시 같은 클라이언트 컴포넌트로 옮깁니다.
  2. 동적 라우트에는 loading.tsx를 둡니다. 그래야 부분 prefetch가 가능하고 클릭 즉시 로딩 화면을 보여 줄 수 있습니다.
  3. [slug] 같은 동적 세그먼트가 미리 만들 수 있는 페이지라면 generateStaticParams를 추가합니다. 공식 문서는 이 함수가 빠지면 라우트가 요청 시점의 동적 렌더링으로 돌아간다고 설명합니다(Linking and Navigating).
  4. 링크가 많은 목록은 hover prefetch로 바꿉니다.

Cache Components와 Partial Prefetching을 켤 때 (16.3)

Cache Components(cacheComponents: true)를 켜면 이 글의 전제가 뒤집힙니다. Caching 문서는 <Suspense> 안에서 cookies()를 읽는 예시를 보여 주고 이렇게 설명합니다. "이전 렌더링 모델처럼 라우트 전체가 동적 렌더링이 되지 않는다. 정적이거나 캐시된 콘텐츠는 그대로 초기 HTML에 실린다"(Caching). 이 방식이 Partial Prerendering(PPR)입니다. 정적 셸을 먼저 보내고, 요청 시점 데이터가 필요한 구멍만 나중에 채웁니다.

앞의 쇼핑몰 레이아웃은 이렇게 바꿀 수 있습니다.

// app/(shop)/layout.tsx  (cacheComponents: true 기준)
import { Suspense } from 'react'
import { cookies } from 'next/headers'

export default function ShopLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Header>
        <Suspense fallback={<CartBadgeSkeleton />}>
          <CartBadge />
        </Suspense>
      </Header>
      {children}
    </>
  )
}

async function CartBadge() {
  const cartId = (await cookies()).get('cart')?.value
  return <span>{await getCartCount(cartId)}</span>
}

Cache Components에서 <Suspense> 밖에서 cookies()를 부르면 라우트를 미리 렌더링할 수 없습니다. 이 경우 개발 오버레이가 막히는 라우트로 표시하고, 레이아웃에서 감싸지 않은 요청 시점 접근은 빌드 오류로 안내됩니다(cookies, Caching, loading.js). 기본 설정에서는 조용히 동적으로 바뀌던 문제를 프레임워크가 먼저 지적해 주는 셈입니다.

여기에 partialPrefetching: true를 더하면 prefetch 단위가 바뀝니다. 16.3부터 <Link>는 링크마다 페이지를 받지 않고, 라우트마다 재사용 가능한 App Shell 하나를 받아 세션 동안 캐시합니다. 공식 블로그의 예시대로라면 사이드바에 채팅 링크가 20개 있어도 /chat/[id] 셸 한 번이면 됩니다(Next.js 16.3: Instant Navigations).

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig

두 플래그는 16.3.8에서도 기본값이 꺼져 있는 선택 기능입니다. Next.js 팀은 Instant Navigations 글에서 cacheComponents 플래그가 "향후 메이저 버전에서 기본값이 된다"고 했고, partialPrefetching도 같은 방식으로 기본 동작으로 만들 계획이라고 밝혔습니다(Next.js 16.3: Instant Navigations).

플래그를 켜지 않아도 받는 개선도 있습니다. 16.0부터는 같은 레이아웃을 공유하는 링크 50개를 prefetch할 때 레이아웃을 50번이 아니라 한 번만 내려받습니다. 다만 공식 블로그는 "개별 prefetch 요청 수는 늘 수 있지만 총 전송량은 훨씬 줄어든다"는 트레이드오프도 함께 밝혔습니다(Next.js 16, 2025-10-21). 16.3 릴리스 글은 작은 prefetch 응답을 자동으로 묶어 요청 수를 줄이는 기능(prefetch inlining)을 기존 앱도 코드 변경 없이 받는 개선 항목에 넣었습니다(Next.js 16.3).

내 앱을 점검하는 순서

아래 순서대로 보면 원인을 대부분 좁힐 수 있습니다. prefetch는 프로덕션에서만 동작하므로, 측정은 반드시 next build && next start로 띄운 앱에서 해야 합니다.

순서 확인할 것 확인 방법 처방
1 동적으로 바뀐 라우트 next build 출력에서 ƒ 표시 원인이 된 요청 시점 API 찾기
2 레이아웃의 요청 시점 API 아래 검색 명령 두 개 실행 기본 설정: 레이아웃 밖(페이지, 클라이언트 컴포넌트)으로 옮기기. Cache Components: <Suspense>로 감싸기
3 loading.tsx 없는 동적 라우트 ƒ 라우트 폴더에 loading.tsx가 있는지 확인 loading.tsx 추가
4 prefetch 요청 폭주 Network 탭에서 _rsc 필터 후 스크롤 목록은 hover prefetch, prefetch={true}는 꼭 필요한 링크만
5 업그레이드 후 비용 변화 호스팅 대시보드의 함수 호출 수를 배포 전후로 비교 이상하게 늘었다면 이슈 트래커 확인

레이아웃에서 직접 부르는 경우는 첫 번째 명령으로 찾을 수 있습니다. 하지만 16.3 블로그 예시처럼 레이아웃이 불러오는 헤더 컴포넌트 안에서 cookies()를 읽는 경우는 여기에 걸리지 않습니다. 그래서 컴포넌트 폴더까지 두 번째 명령으로 한 번 더 찾고, 최종 판단은 next build의 ƒ 표시로 하세요.

# 1) 레이아웃에서 직접 부르는 경우
grep -rnE "cookies\(|headers\(|draftMode\(|connection\(|no-store|force-dynamic|revalidate[[:space:]]*[:=][[:space:]]*0" app src/app --include="layout.*" 2>/dev/null

# 2) 레이아웃이 불러오는 컴포넌트 안에서 부르는 경우
grep -rlE "cookies\(|headers\(" app components src --include="*.tsx" --include="*.ts" 2>/dev/null

이 글의 자료를 고른 방법

이 글의 동작 설명은 2026년 10월 3일 기준 npm latest인 Next.js 16.3.8의 공식 문서와 nextjs.org 블로그를 직접 읽고 확인했습니다. 기본 설정과 Cache Components 설정에서 동작이 다른 부분은 문서가 어느 쪽을 전제로 하는지 구분해 옮겼습니다. 과금 설명은 Vercel 공식 가격 문서를 따랐고, GitHub 이슈는 실무에서 겪을 수 있는 사례로만 인용했습니다. 측정 수치를 새로 만들지 않았고, 요청 수 차트는 공식 블로그 예시를 시각화한 개념도입니다.

자주 묻는 질문

개발 모드에서는 왜 이 문제가 재현되지 않나요?

prefetch가 프로덕션에서만 동작하기 때문입니다(Link). next dev로는 prefetch 요청도, 그 비용도 볼 수 없습니다. next build && next start로 띄운 뒤 Network 탭을 보세요. 16.3부터는 개발 모드의 Navigation Inspector로 이동이 어느 셸에서 멈추는지 미리 볼 수 있습니다(Next.js 16.3).

Cache Components를 켜면 무조건 빨라지나요?

아닙니다. 요청 시점 데이터를 <Suspense>로 감싸거나 'use cache'로 캐시해야 효과가 납니다. 감싸지 않은 부분은 개발 오버레이가 막히는 라우트로 지적합니다. prefetch={true}로 캐시된 결과를 미리 받는 경우에도 캐시가 비어 있는 첫 방문에는 서버가 결과를 계산해야 하므로 로딩 화면이 보일 수 있다고 공식 문서는 설명합니다(Optimizing prefetching).

prefetch 때문에 분석 이벤트가 두 번 찍히는데 관련이 있나요?

관련이 있습니다. 레이아웃이나 페이지가 렌더링 중에 분석 이벤트 같은 부수 효과를 실행하면, 사용자가 방문할 때가 아니라 prefetch할 때 실행될 수 있습니다. 공식 문서는 이런 코드를 useEffect나 Server Action으로 옮기라고 권합니다(Prefetching).

마무리: 하나만 고친다면

딱 하나만 고칠 수 있다면 공통 레이아웃에서 cookies()와 headers()를 빼세요. 레이아웃은 그 아래의 모든 라우트가 공유하기 때문에, 여기 있는 요청 시점 API 하나가 그 아래 모든 라우트의 prefetch 동작과 서버 비용을 결정합니다. 루트 레이아웃이라면 앱 전체가 영향을 받습니다. 그다음 할 일은 순서대로입니다.

  • 동적 라우트에 loading.tsx를 두고, 목록 링크는 hover prefetch로 바꿉니다.
  • prefetch={true}는 클릭 가능성이 높은 소수의 링크에만 씁니다.
  • 16.3을 쓰고 있다면 Cache Components와 Partial Prefetching 도입을 검토합니다. 다만 cacheComponents는 앱 전체의 캐싱 방식을 바꾸는 플래그라, 켜는 순간 <Suspense>나 'use cache'로 처리하지 않은 요청 시점 접근이 여러 라우트에서 한꺼번에 드러날 수 있습니다. 공식 Cache Components 마이그레이션 가이드를 따라 단계적으로 옮기세요.

함께 보면 좋은 영상

글쓴이

주홍철은 네이버 출신 개발자이자 AI 핀테크 스타트업 어비스(AVISS)의 대표입니다. 경제·증시 분석 AI, AI 에이전트, 데이터 파이프라인을 직접 설계하고 개발하며, 『면접을 위한 CS 전공지식 노트』와 『클로드 코드 제대로 시작하기』(길벗)를 썼습니다. 회사 소개는 어비스 홈페이지에, 다른 글은 어비스 블로그에 있으며, 글에 대한 정정 요청이나 문의는 [email protected]으로 보내 주세요.