Cache Components

김동현·2026년 3월 4일

알아두면 좋은 점: 캐시 컴포넌트는 여러분이 직접 선택해서 켜야 하는(opt-in) 기능이에요. Next.js 설정 파일(config file)에서 cacheComponents 플래그를 true로 설정해야 활성화된답니다. 더 자세한 내용은 문서 하단의 캐시 컴포넌트 활성화하기 부분을 참고해 주세요.

캐시 컴포넌트를 사용하면 하나의 라우트(페이지 경로) 안에서 정적(static), 캐시된(cached), 그리고 동적(dynamic) 콘텐츠를 자유롭게 섞어서 쓸 수 있어요. 즉, 정적 사이트가 주는 엄청난 속도동적 렌더링이 주는 유연성을 동시에 얻을 수 있다는 뜻이죠.

서버에서 렌더링하는 애플리케이션들은 보통 둘 중 하나를 강제로 선택해야 했어요. 빠르지만 데이터가 오래된(stale) 정적 페이지를 만들거나, 항상 최신 데이터를 보여주지만 렌더링이 느린 동적 페이지를 만들거나요. 이 렌더링 작업을 클라이언트(브라우저) 쪽으로 옮기게 되면 서버의 부하는 줄어들지만, 대신 다운로드해야 할 자바스크립트 번들 크기가 커지고 초기 렌더링 속도가 느려지는 트레이드오프(trade-offs)가 발생했습니다.

하지만 캐시 컴포넌트는 이런 트레이드오프를 완전히 없애버려요! 라우트를 미리 렌더링(Prerendering)해서 정적 HTML 쉘(static HTML shell) 로 만든 다음 브라우저로 즉시 보내고, 동적인 콘텐츠는 준비가 완료되는 대로 UI를 업데이트하는 방식을 사용하거든요.

💡 강사의 부연 설명: 여기서 말하는 '정적 HTML 쉘(Shell)'이라는 건, 페이지의 큰 뼈대(헤더, 푸터, 레이아웃 등 변하지 않는 부분)를 먼저 그려서 사용자에게 보여주는 걸 말해요. 사용자는 빈 화면 대신 빠르게 뼈대를 볼 수 있어서 페이지가 엄청 빠르다고 느끼게 됩니다. 빈껍데기를 먼저 보여주고, 알맹이는 나중에 채워 넣는 거라고 이해하시면 쉬워요!

부분적으로 다시 렌더링되는 제품 페이지. 정적인 내비게이션과 제품 정보를 보여주고, 동적인 장바구니와 추천 제품을 보여줍니다.

캐시 컴포넌트에서 렌더링이 작동하는 방식 (How rendering works with Cache Components)

빌드 타임(Build time)에 Next.js는 여러분이 작성한 라우트의 컴포넌트 트리를 렌더링합니다. 컴포넌트들이 네트워크 리소스에 접근하지 않거나, 특정 시스템 API를 사용하지 않거나, 렌더링하기 위해 들어오는 요청(request) 데이터를 필요로 하지 않는다면, 그 컴포넌트들의 결과물은 자동으로 정적 쉘(static shell)에 추가됩니다. 만약 그렇지 않은 경우라면, 여러분이 직접 어떻게 처리할지 선택해야 해요:

  • 컴포넌트를 React의 <Suspense>로 감싸서 콘텐츠가 준비될 때까지 대체 UI(fallback UI)를 보여주며 렌더링을 요청 시간(request time)으로 미루거나 (지연시키거나)
  • use cache 지시어(directive)를 사용해 결과를 캐시해서 정적 쉘에 포함시킬 수 있어요. (단, 이때는 요청 데이터가 필요 없는 경우여야 합니다)

이러한 과정이 사용자의 요청이 도착하기 전인 빌드 단계에서 미리 일어나기 때문에, 우리는 이를 프리렌더링(Prerendering) 이라고 부릅니다. 이 과정은 초기 페이지 로드를 위한 HTML과 클라이언트 측 네비게이션을 위한 직렬화된 RSC Payload(React Server Components Payload)로 구성된 정적 쉘을 생성해요. 덕분에 사용자가 URL을 직접 입력하고 들어오든, 다른 페이지에서 링크를 클릭해서 넘어오든 브라우저는 즉각적으로 완전히 렌더링된 콘텐츠를 받을 수 있게 되죠.

Next.js에서는 프리렌더링 중에 완료할 수 없는 컴포넌트들을 여러분이 명시적으로 처리하도록 강제하고 있어요. 만약 이런 컴포넌트들을 <Suspense>로 감싸지 않거나 use cache로 표시하지 않으면, 개발 및 빌드 과정에서 Uncached data was accessed outside of <Suspense> 라는 에러를 만나게 되실 겁니다.

알아두면 좋은 점: 캐싱은 컴포넌트나 함수 수준에서 적용할 수 있는 반면, 대체 UI(fallback UI)는 어떤 서브트리(컴포넌트 구조의 일부분) 주변에든 자유롭게 정의할 수 있어요. 즉, 단일 라우트 안에서 정적, 캐시된, 동적 콘텐츠를 여러분 입맛대로 조립해서 구성할 수 있다는 뜻이에요.

클라이언트에서 부분적으로 렌더링된 페이지를 보여주는 다이어그램. 스트리밍되는 청크(조각)들에 대한 로딩 UI가 표시되어 있습니다.

이러한 렌더링 접근 방식을 부분 프리렌더링(Partial Prerendering, PPR) 이라고 부르며, 캐시 컴포넌트가 켜져 있을 때의 기본 동작입니다. 이 문서의 나머지 부분에서는 이를 단순히 "프리렌더링"이라고 부를게요. 이 프리렌더링은 상황에 따라 부분적인 결과물을 만들 수도 있고, 완전한 결과물을 만들어낼 수도 있답니다.

🎥 영상 보기: 왜 부분 프리렌더링(PPR)이 필요하고 어떻게 작동하는지 영상으로 확인해보세요 → YouTube (10분).

자동으로 프리렌더링되는 콘텐츠 (Automatically prerendered content)

동기식 I/O(Synchronous I/O), 모듈 임포트(module imports), 그리고 순수 계산(pure computations) 같은 작업들은 프리렌더링 중에 끝낼 수 있어요. 오직 이런 작업들만 사용하는 컴포넌트들은 렌더링 결과물이 정적 HTML 쉘에 자동으로 포함됩니다.

아래 코드의 Page 컴포넌트를 보면 렌더링 중에 일어나는 모든 작업이 무사히 완료될 수 있기 때문에, 이 결과물은 자동으로 정적 쉘에 들어갑니다. 레이아웃과 페이지가 모두 성공적으로 프리렌더링되면, 라우트 전체가 하나의 정적 쉘이 되는 거죠.

import fs from 'node:fs'

export default async function Page() {
  // 동기식 파일 시스템 읽기 (Synchronous file system read)
  const content = fs.readFileSync('./config.json', 'utf-8')

  // 모듈 임포트 (Module imports)
  const constants = await import('./constants.json')

  // 순수 연산 (Pure computations)
  const processed = JSON.parse(content).items.map((item) => item.value * 2)

  return (
    <div>
      <h1>{constants.appName}</h1>
      <ul>
        {processed.map((value, i) => (
          <li key={i}>{value}</li>
        ))}
      </ul>
    </div>
  )
}

알아두면 좋은 점: 빌드 결과물 요약(build output summary)을 확인해서 라우트가 완전히 프리렌더링되었는지 검증해 볼 수 있어요. 아니면 브라우저에서 '페이지 소스 보기'를 해서 어떤 콘텐츠가 정적 쉘에 추가되었는지 직접 두 눈으로 확인해 보는 것도 좋은 방법입니다.

렌더링을 요청 시간으로 미루기 (Defer rendering to request time)

Next.js가 프리렌더링을 하다가 스스로 완료할 수 없는 작업(예: 네트워크 요청, 들어오는 요청 데이터 접근, 비동기 작업 등)을 마주치면, 개발자인 여러분이 이를 직접 명시적으로 처리해 주길 요구합니다. 렌더링을 사용자의 요청 시간(request time)으로 미루려면, 부모 컴포넌트에서 Suspense boundary를 제공하여 대체 UI(fallback UI)를 렌더링해야 해요. 이렇게 하면 대체 UI는 정적 쉘의 일부가 되고, 실제 알맹이 콘텐츠는 사용자가 요청하는 시점에 해결되어 화면에 나타납니다.

Suspense boundary는 그것을 필요로 하는 컴포넌트에 최대한 가깝게 배치하는 것이 좋습니다. 왜냐하면 바운더리 바깥에 있는 모든 것들은 여전히 평소처럼 프리렌더링될 수 있기 때문에, 정적 쉘에 들어가는 콘텐츠의 양을 극대화할 수 있거든요.

알아두면 좋은 점: Suspense boundary를 사용하면, 여러 개의 동적인 섹션들이 서로를 막지 않고(blocking) 병렬로 렌더링될 수 있어요. 덕분에 전체적인 페이지 로드 시간을 크게 줄일 수 있답니다!

💡 강사의 팁: 면접에서 프론트엔드 성능 최적화에 대한 질문을 받을 때, 이 Suspense를 활용한 병렬 렌더링과 부분 프리렌더링(PPR) 개념을 설명하시면 면접관에게 아주 좋은 인상을 남길 수 있을 거예요!

동적 콘텐츠 (Dynamic content)

외부 시스템은 콘텐츠를 비동기적으로 제공하기 때문에, 응답을 받는 데 시간이 얼마나 걸릴지 예측하기 어렵고 심지어 실패할 수도 있어요. 이것이 바로 프리렌더링이 외부 요청을 자동으로 실행하지 않는 이유입니다.

일반적으로 매 요청마다 데이터 소스에서 가장 최신 데이터를 가져와야 할 때(예: 실시간 피드나 개인화된 콘텐츠)는, Suspense boundary를 사용해 대체 UI를 제공함으로써 렌더링을 지연시켜야 합니다.

예를 들어, 아래의 DynamicContent 컴포넌트는 자동으로 프리렌더링할 수 없는 여러 작업들을 수행하고 있어요.

import { Suspense } from 'react'
import fs from 'node:fs/promises'

async function DynamicContent() {
  // 네트워크 요청 (Network request)
  const data = await fetch('https://api.example.com/data')

  // 데이터베이스 쿼리 (Database query)
  const users = await db.query('SELECT * FROM users')

  // 비동기 파일 시스템 작업 (Async file system operation)
  const file = await fs.readFile('..', 'utf-8')

  // 외부 시스템의 지연을 시뮬레이션 (Simulating external system delay)
  await new Promise((resolve) => setTimeout(resolve, 100))

  return <div>Not in the static shell</div>
}

DynamicContent를 페이지 안에서 사용하려면, <Suspense>로 감싸서 대체 UI를 정의해 주어야 해요:

export default async function Page(props) {
  return (
    <>
      <h1>Part of the static shell</h1>
      {/* <p>Loading..</p> 는 정적 쉘의 일부가 됩니다 */}
      <Suspense fallback={<p>Loading..</p>}>
        <DynamicContent />
        <div>Sibling excluded from static shell</div>
      </Suspense>
    </>
  )
}

프리렌더링은 fetch 요청을 만나는 순간 멈춥니다. 요청 자체는 시작되지 않고, 그 아래에 있는 코드들도 실행되지 않아요.

대신 대체 UI인 <p>Loading...</p>가 정적 쉘에 포함되어 바로 사용자에게 보여지고, 컴포넌트의 실제 콘텐츠는 요청 시간에 스트리밍되어 나중에 도착하게 됩니다.

위 예제에서는 네트워크 요청, DB 쿼리, 파일 읽기, 타임아웃 같은 모든 작업이 하나의 컴포넌트 안에서 순차적으로 실행되기 때문에, 이 모든 작업이 끝나야만 콘텐츠가 화면에 나타납니다.

알아두면 좋은 점: 자주 변하지 않는 동적 콘텐츠의 경우에는, 스트리밍하는 대신 use cache를 사용해서 동적 데이터를 정적 쉘 안에 아예 포함시켜 버릴 수도 있어요. 아래의 프리렌더링 도중(During prerendering) 섹션에 있는 예제를 확인해 보세요.

런타임 데이터 (Runtime data)

사용자가 요청을 보낼 때만 알 수 있는 요청 컨텍스트(request context)가 필요한 특수한 형태의 동적 데이터들을 말합니다.

import { cookies, headers } from 'next/headers'
import { Suspense } from 'react'

async function RuntimeData({ searchParams }) {
  // 요청 데이터에 접근하기 (Accessing request data)
  const cookieStore = await cookies()
  const headerStore = await headers()
  const search = await searchParams

  return <div>Not in the static shell</div>
}

RuntimeData 컴포넌트를 사용하려면, 마찬가지로 <Suspense> 바운더리로 감싸주어야 합니다:

export default async function Page(props) {
  return (
    <>
      <h1>Part of the static shell</h1>
      {/* <p>Loading..</p> 는 정적 쉘의 일부가 됩니다 */}
      <Suspense fallback={<p>Loading..</p>}>
        <RuntimeData searchParams={props.searchParams} />
        <div>Sibling excluded from static shell</div>
      </Suspense>
    </>
  )
}

위의 런타임 API들에 직접 접근하지 않으면서 렌더링을 요청 시간으로 미루고 싶다면 connection() 함수를 사용하세요.

알아두면 좋은 점: 런타임 데이터는 요청 컨텍스트를 필요로 하기 때문에 use cache로 캐시할 수 없어요! 런타임 API에 접근하는 컴포넌트들은 반드시 <Suspense>로 감싸야 합니다. 하지만, 런타임 데이터에서 특정 값을 추출해서 캐시된 함수의 인자(argument)로 넘겨주는 건 가능해요. 아래의 런타임 데이터와 함께 사용하기 섹션에서 예제를 살펴보세요.

💡 강사의 팁: 쿠키 같은 런타임 데이터를 읽으면서도 정적 쉘 생성을 막지 않는(blocking하지 않는) 방법 중 하나는, Promise를 클라이언트 컨텍스트 프로바이더로 넘기는 방식입니다. 이 부분은 Context와 React.cache로 데이터 공유하기 예제를 보시면 이해가 쉬울 거예요.

알아두면 좋은 점: React.cacheuse cache 바운더리 안에서 완전히 격리된 스코프로 동작합니다. 자세한 내용은 React.cache 격리(isolation) 문서를 참조해 주세요.

비결정적 연산 (Non-deterministic operations)

Math.random(), Date.now(), 또는 crypto.randomUUID() 같은 연산들은 실행될 때마다 매번 다른 값을 만들어냅니다. 이러한 연산들이 꼭 요청 시간에 실행되어 매 요청마다 고유한 값을 생성하도록 보장하려면, 동적 데이터나 런타임 데이터 접근 뒤에 이러한 연산을 호출함으로써 렌더링을 미루겠다는 의도를 명시적으로 표시해야 합니다.

import { connection } from 'next/server'
import { Suspense } from 'react'

async function UniqueContent() {
  // 명시적으로 렌더링을 요청 시간으로 미룸 (Explicitly defer to request time)
  await connection()

  // 비결정적 연산들 (Non-deterministic operations)
  const random = Math.random()
  const now = Date.now()
  const date = new Date()
  const uuid = crypto.randomUUID()
  const bytes = crypto.getRandomValues(new Uint8Array(16))

  return (
    <div>
      <p>{random}</p>
      <p>{now}</p>
      <p>{date.getTime()}</p>
      <p>{uuid}</p>
      <p>{bytes}</p>
    </div>
  )
}

UniqueContent 컴포넌트는 요청 시간으로 렌더링이 지연되므로, 라우트 안에서 사용하려면 당연히 <Suspense>로 감싸야겠죠?

export default async function Page() {
  return (
    // <p>Loading..</p> 는 정적 쉘에 포함됩니다.
    <Suspense fallback={<p>Loading..</p>}>
      <UniqueContent />
    </Suspense>
  )
}

이렇게 하면 모든 사용자의 요청마다 서로 다른 무작위 숫자, 날짜 등을 볼 수 있게 됩니다.

알아두면 좋은 점: 비결정적 연산의 결과도 use cache를 사용하면 캐시할 수 있어요. 비결정적 연산과 함께 사용하기 섹션에서 예제를 확인해 보세요.

use cache 사용하기 (Using use cache)

use cache 지시어(directive)는 비동기 함수나 컴포넌트의 반환 값을 캐시해 줍니다. 이 지시어는 함수, 컴포넌트, 또는 파일 전체 수준에서 모두 적용할 수 있어요.

함수의 인자(arguments)나 부모 스코프에서 가져와 사용하는 값들(클로저 값들)은 자동으로 캐시 키(cache key)의 일부가 됩니다. 즉, 입력값이 달라지면 서로 다른 캐시 항목이 생성된다는 뜻이에요. 이를 통해 사용자별로 개인화되거나 파라미터화된 캐시 콘텐츠를 구성할 수 있습니다.

동적 콘텐츠가 매 요청마다 소스에서 새롭게 데이터를 가져올 필요가 없을 때, 이를 캐시하면 프리렌더링 중에 해당 콘텐츠를 정적 쉘에 포함시키거나 런타임에 여러 요청에 걸쳐 동일한 결과를 재사용할 수 있게 해줍니다.

캐시된 콘텐츠는 두 가지 방식으로 재검증(revalidate, 최신화)될 수 있어요: 캐시 수명(lifetime)에 따라 자동으로 갱신되거나, revalidateTag 또는 updateTag 함수와 태그(tags)를 사용해서 원할 때(on-demand) 수동으로 갱신할 수 있습니다.

알아두면 좋은 점: 무엇을 캐시할 수 있고 인자가 어떻게 동작하는지에 대한 제약 사항은 직렬화 요구사항 및 제약조건(serialization requirements and constraints) 문서를 꼼꼼히 읽어보시길 권해드립니다.

프리렌더링 도중 (During prerendering)

동적 콘텐츠를 외부에서 가져오긴 하지만, 그 데이터가 자주 바뀌지 않을 때가 많습니다. 예를 들어, 재고가 변해야만 업데이트되는 상품 카탈로그 데이터, 한 번 발행되면 수정될 일이 거의 없는 블로그 포스트 내용, 또는 과거 날짜에 대한 분석 리포트 같은 것들이죠.

이러한 데이터가 런타임 데이터에 의존하지 않는다면, use cache 지시어를 사용하여 데이터를 정적 HTML 쉘에 포함시켜 버릴 수 있습니다. 캐시된 데이터를 얼마나 오랫동안 유지할지 정의하려면 cacheLife를 사용하세요.

💡 강사의 실무 팁: 여러분이 포트폴리오를 준비하고 계신다면 이 캐싱 전략을 꼭 활용해 보세요! 블로그 글은 한 번 만들어지면 매 요청마다 매번 다시 그려낼 필요가 없잖아요? use cache로 정적으로 구워내서 속도를 미친 듯이 끌어올리고, 좋아요 버튼이나 다크 모드 같은 부분만 클라이언트나 Suspense로 동적 처리하면, 면접관에게 성능 최적화에 대해 깊이 고민했다는 걸 확실히 어필할 수 있습니다!

재검증이 발생하면, 정적 쉘은 새로운 콘텐츠로 업데이트됩니다. 필요할 때 즉시 재검증하는 방법에 대해서는 태그 달기 및 재검증(Tagging and revalidating) 섹션을 참조하세요.

import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife('hours')

  const users = await db.query('SELECT * FROM users')

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

cacheLife 함수는 'hours', 'days', 'weeks' 같은 캐시 프로필 이름을 문자열로 받거나, 캐시 동작을 상세히 제어하기 위해 직접 커스텀 설정 객체를 전달받을 수 있습니다:

import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife({
    stale: 3600, // 1시간 지나면 오래된(stale) 것으로 간주
    revalidate: 7200, // 2시간 지나면 재검증
    expire: 86400, // 1일 지나면 만료됨
  })

  const users = await db.query('SELECT * FROM users')

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

사용 가능한 프로필 목록과 커스텀 설정 옵션에 대한 자세한 내용은 cacheLife API 레퍼런스를 확인해 보세요.

런타임 데이터와 함께 사용하기 (With runtime data)

런타임 데이터와 use cache는 같은 스코프(영역) 안에서 함께 사용할 수 없어요. 하지만, 런타임 API에서 값을 추출해 내어 캐시된 함수에 인자(argument)로 전달하는 건 가능합니다!

import { cookies } from 'next/headers'
import { Suspense } from 'react'

export default function Page() {
  // 페이지 자체가 동적 바운더리를 생성합니다.
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ProfileContent />
    </Suspense>
  )
}

// 컴포넌트(캐시되지 않음)가 런타임 데이터를 읽어옵니다.
async function ProfileContent() {
  const session = (await cookies()).get('session')?.value

  return <CachedContent sessionId={session} />
}

// 캐시된 컴포넌트/함수가 데이터를 props로 전달받습니다.
async function CachedContent({ sessionId }: { sessionId: string }) {
  'use cache'
  // sessionId가 캐시 키의 일부가 됩니다!
  const data = await fetchUserData(sessionId)
  return <div>{data}</div>
}

요청 시간에 CachedContent가 실행될 때, 캐시에 일치하는 항목이 없으면 함수가 실행되고 그 결과는 다음 요청들을 위해 저장됩니다. 아주 스마트하죠?

비결정적 연산과 함께 사용하기 (With non-deterministic operations)

use cache 스코프 안에서 비결정적 연산을 사용하면, 이 연산들은 프리렌더링 중에 딱 한 번만 실행됩니다. 이 방식은 모든 사용자에게 렌더링된 동일한 결과물을 보여주고 싶을 때 아주 유용해요:

export default async function Page() {
  'use cache'

  // 한 번만 실행된 후, 모든 요청에 대해 결과가 캐시됩니다.
  const random = Math.random()
  const random2 = Math.random()
  const now = Date.now()
  const date = new Date()
  const uuid = crypto.randomUUID()
  const bytes = crypto.getRandomValues(new Uint8Array(16))

  return (
    <div>
      <p>
        {random} and {random2}
      </p>
      <p>{now}</p>
      <p>{date.getTime()}</p>
      <p>{uuid}</p>
      <p>{bytes}</p>
    </div>
  )
}

캐시가 재검증되기 전까지는, 이 라우트로 들어오는 모든 요청은 처음에 생성된 동일한 무작위 숫자, 타임스탬프, UUID를 보게 됩니다.

태그 달기 및 재검증 (Tagging and revalidating)

캐시된 데이터에 cacheTag로 꼬리표(태그)를 달아두고, 데이터가 변경(mutation)되었을 때 이를 갱신할 수 있어요. 즉각적인 업데이트가 필요하다면 Server Actions 내에서 updateTag를 사용하고, 업데이트가 조금 지연되어도 괜찮다면 revalidateTag를 사용하세요.

updateTag 사용하기

같은 요청 내에서 캐시된 데이터를 만료시키고 즉각적으로 새로고침해야 할 때는 updateTag를 사용합니다:

import { cacheTag, updateTag } from 'next/cache'

export async function getCart() {
  'use cache'
  cacheTag('cart')
  // 데이터 페칭 (fetch data)
}

export async function updateCart(itemId: string) {
  'use server'
  // itemId를 사용해 데이터를 기록합니다.
  // 사용자의 장바구니를 업데이트합니다.
  updateTag('cart')
}

revalidateTag 사용하기

제대로 태그가 지정된 캐시 항목들만 무효화(invalidate)하고, 'stale-while-revalidate(오래된 데이터를 먼저 보여주면서 백그라운드에서 검증)' 동작을 원할 때는 revalidateTag를 사용하세요. 최종적 일관성(eventual consistency)을 허용할 수 있는 정적 콘텐츠에 이상적인 방법입니다.

import { cacheTag, revalidateTag } from 'next/cache'

export async function getPosts() {
  'use cache'
  cacheTag('posts')
  // 데이터 페칭 (fetch data)
}

export async function createPost(post: FormData) {
  'use server'
  // FormData를 사용해 데이터를 기록합니다.
  revalidateTag('posts', 'max')
}

더 자세한 설명과 다양한 사용 예제를 보고 싶으시다면 use cache API 레퍼런스를 참고해 주세요.

무엇을 캐시해야 할까요? (What should I cache?)

무엇을 캐시할지는, 여러분이 UI 로딩 상태를 어떻게 보여주고 싶은지에 따라 결정해야 합니다. 만약 데이터가 런타임 데이터에 의존하지 않고, 일정 기간 동안 동일한 캐시 값이 여러 요청에 제공되어도 괜찮은 성격의 데이터라면, use cachecacheLife를 사용해서 그 동작을 명시해 주세요.

업데이트 메커니즘을 갖춘 콘텐츠 관리 시스템(CMS)의 경우, 태그를 사용해 캐시 유지 기간을 길게 가져가면서, 내용이 변경되었을 때 revalidateTag를 호출하여 정적인 초기 UI를 재검증 상태로 마킹하는 방식을 고려해 보세요. 이 패턴을 사용하면, 캐시를 지레짐작으로 일찍 만료시키는 대신 캐시된 초고속 응답을 제공하면서도 데이터가 '실제로' 변경되었을 때만 콘텐츠를 최신화할 수 있습니다.

💡 강사의 팁: 이 부분이 현업에서 제일 중요한 노하우입니다! 무조건 캐시 시간을 짧게 둬서 서버를 괴롭히지 마시고, 캐시는 길게 가져가되 사용자가 데이터를 수정했을 때 태그로 콕 집어 캐시를 날려버리는 방식(On-demand Revalidation)이 성능과 데이터 정확도를 모두 잡는 베스트 프랙티스랍니다.

모든 개념 합쳐보기 (Putting it all together)

자, 지금까지 배운 내용들이 하나의 페이지에서 어떻게 조화롭게 동작하는지 완벽한 예제를 통해 보여드릴게요. 정적 콘텐츠, 캐시된 동적 콘텐츠, 그리고 스트리밍되는 동적 콘텐츠가 모두 한 페이지에 담겨 있습니다:

import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
import Link from 'next/link'

export default function BlogPage() {
  return (
    <>
      {/* 정적 콘텐츠 - 자동으로 프리렌더링됨 */}
      <header>
        <h1>Our Blog</h1>
        <nav>
          <Link href="/">Home</Link> | <Link href="/about">About</Link>
        </nav>
      </header>

      {/* 캐시된 동적 콘텐츠 - 정적 쉘에 포함됨 */}
      <BlogPosts />

      {/* 런타임 동적 콘텐츠 - 요청 시간에 스트리밍됨 */}
      <Suspense fallback={<p>Loading your preferences...</p>}>
        <UserPreferences />
      </Suspense>
    </>
  )
}

// 모든 사용자가 동일한 블로그 포스트를 봅니다 (매 시간마다 재검증)
async function BlogPosts() {
  'use cache'
  cacheLife('hours')

  const res = await fetch('https://api.vercel.app/blog')
  const posts = await res.json()

  return (
    <section>
      <h2>Latest Posts</h2>
      <ul>
        {posts.slice(0, 5).map((post: any) => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>
              By {post.author} on {post.date}
            </p>
          </li>
        ))}
      </ul>
    </section>
  )
}

// 각 사용자의 쿠키를 기반으로 개인화됩니다
async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value || 'light'
  const favoriteCategory = (await cookies()).get('category')?.value

  return (
    <aside>
      <p>Your theme: {theme}</p>
      {favoriteCategory && <p>Favorite category: {favoriteCategory}</p>}
    </aside>
  )
}

프리렌더링이 진행되는 동안, 헤더(정적)와 API에서 가져온 블로그 포스트(use cache로 캐시됨)는 사용자의 취향(preferences)을 위한 대체 UI와 함께 정적 쉘의 일부가 됩니다.

사용자가 이 페이지에 방문하면, 헤더와 블로그 포스트가 포함된 이 프리렌더링된 쉘을 눈 깜짝할 새 보게 됩니다. 개인화된 환경설정 부분만 사용자의 쿠키에 의존하므로, 이 부분만 요청 시간에 스트리밍되어 들어오게 되죠. 이렇게 구성하면 정말 빠른 초기 로딩 속도를 보장하면서도 개인화된 맞춤 콘텐츠를 안전하게 제공할 수 있어요.

메타데이터와 뷰포트 (Metadata and Viewport)

generateMetadatagenerateViewport 함수는 페이지나 레이아웃을 렌더링하는 과정의 일부분입니다. 프리렌더링 중일 때, 이 함수들이 런타임 데이터나 캐시되지 않은 동적 데이터에 접근하는 것은 페이지의 다른 부분과는 별개로 추적됩니다.

만약 페이지나 레이아웃 자체는 프리렌더링이 가능한데, 메타데이터나 뷰포트 함수만 캐시되지 않은 동적 데이터나 런타임 데이터에 접근한다면, Next.js는 여러분에게 명시적인 선택을 요구할 거예요. 데이터를 가능한 한 캐시할 것인지, 아니면 지연 렌더링(deferred rendering)이 여러분의 의도적 선택이었음을 명시할 것인지 말이죠. 이 부분에 대한 해결 방법은 캐시 컴포넌트와 메타데이터캐시 컴포넌트와 뷰포트 문서를 확인해 주세요.

캐시 컴포넌트 활성화하기 (Enabling Cache Components)

Next config 파일에 cacheComponents 옵션을 추가하여 캐시 컴포넌트(PPR 포함)를 켤 수 있습니다:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
/** @type {import('next').NextConfig} */
const nextConfig = {
  cacheComponents: true,
}

module.exports = nextConfig

알아두면 좋은 점: 캐시 컴포넌트가 활성화되면, GET Route Handlers(API 라우트)도 일반 페이지와 동일한 프리렌더링 모델을 따르게 됩니다. 자세한 내용은 캐시 컴포넌트와 Route Handlers 문서를 참고하세요.

네비게이션은 Activity를 사용합니다 (Navigation uses Activity)

cacheComponents 플래그가 켜져 있을 때, Next.js는 클라이언트 사이드 네비게이션(페이지 이동) 중에 컴포넌트의 상태(state)를 보존하기 위해 React의 <Activity> 컴포넌트를 사용합니다.

여러분이 다른 페이지로 이동할 때 이전 라우트를 언마운트(unmount)시켜버리는 대신, Next.js는 Activity 모드를 "hidden"으로 설정합니다. 이것이 의미하는 바는 다음과 같습니다:

  • 라우트 사이를 이동할 때 컴포넌트의 상태(state)가 보존됩니다.
  • 뒤로 가기를 해서 다시 돌아왔을 때, 이전 라우트가 상태를 그대로 간직한 채 다시 나타납니다.
  • 라우트가 "hidden" 상태가 될 때 Effect(예: useEffect의 클린업 함수)들이 정리(clean up)되고, 다시 보이게 될 때 재생성됩니다.

이 동작 덕분에, 사용자가 앞뒤로 페이지를 이동할 때 UI 상태(입력 폼의 텍스트나 펼쳐둔 섹션 등)가 유지되어 훨씬 부드러운 네비게이션 경험을 선사할 수 있습니다.

알아두면 좋은 점: Next.js는 메모리 관리를 위해 최근에 방문한 몇 개의 라우트만 "hidden" 상태로 유지하는 자체적인 휴리스틱(heuristics) 알고리즘을 사용하며, 너무 오래된 라우트들은 DOM이 과도하게 커지는 것을 막기 위해 아예 제거합니다.

라우트 세그먼트 설정 마이그레이션하기 (Migrating route segment configs)

캐시 컴포넌트를 활성화하면, 기존에 사용하시던 여러 가지 라우트 세그먼트 설정(route segment config) 옵션들은 더 이상 필요하지 않거나 지원되지 않습니다:

dynamic = "force-dynamic"

더 이상 필요하지 않습니다. 이제 모든 페이지는 기본적으로 동적(dynamic)으로 동작합니다.

// 수정 전 (Before) - 더 이상 필요 없어요!
export const dynamic = 'force-dynamic'

export default function Page() {
  return <div>...</div>
}
// 수정 후 (After) - 그냥 지워버리시면 됩니다.
export default function Page() {
  return <div>...</div>
}

dynamic = "force-static"

이 옵션도 일단 삭제하는 것부터 시작하세요. 개발 및 빌드 타임에 처리되지 않은 동적 데이터나 런타임 데이터 접근이 감지되면 Next.js가 친절하게 에러를 띄워줄 겁니다. 문제가 없다면, 프리렌더링 단계에서 알아서 정적 HTML 쉘을 추출해 냅니다.

동적 데이터에 접근하는 경우, 캐시된 동작을 유지하려면 데이터 접근 위치와 최대한 가까운 곳에 use cache를 추가하고 cacheLife'max' 처럼 길게 설정해 주세요. 필요하다면 페이지나 레이아웃의 최상단에 추가해도 좋습니다.

런타임 데이터 접근(cookies(), headers() 등)의 경우, 에러 메시지가 이것들을 Suspense로 감싸라고 안내할 거예요. 원래 force-static을 쓰고 싶으셨던 상황이니, 요청 시간에 일어나는 작업을 막으려면 당연히 런타임 데이터 접근 코드를 제거하셔야 합니다.

// 수정 전 (Before)
export const dynamic = 'force-static'

export default async function Page() {
  const data = await fetch('https://api.example.com/data')
  return <div>...</div>
}
import { cacheLife } from 'next/cache'

// 수정 후 (After) - 대신 'use cache'를 사용하세요!
export default async function Page() {
  'use cache'
  cacheLife('max')
  const data = await fetch('https://api.example.com/data')
  return <div>...</div>
}

revalidate

cacheLife로 대체하세요. 기존의 라우트 세그먼트 설정 대신, 이제 cacheLife 함수를 사용해서 캐시 지속 시간을 명시합니다.

// 수정 전 (Before)
export const revalidate = 3600 // 1시간

export default async function Page() {
  return <div>...</div>
}
// 수정 후 (After) - cacheLife를 사용하세요
import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife('hours')
  return <div>...</div>
}

fetchCache

더 이상 필요하지 않습니다. use cache를 쓰면 캐시된 스코프 안의 모든 데이터 페칭(data fetching)이 자동으로 캐시되므로 fetchCache 옵션 자체가 무의미해집니다.

// 수정 전 (Before)
export const fetchCache = 'force-cache'
// 수정 후 (After) - 캐싱 동작을 제어하려면 'use cache'를 쓰세요
export default async function Page() {
  'use cache'
  // 여기에 있는 모든 fetch 요청은 캐시됩니다!
  return <div>...</div>
}

runtime = 'edge'

지원되지 않습니다. 캐시 컴포넌트는 Node.js 런타임을 필요로 하며, Edge Runtime(엣지 런타임)을 사용하면 에러가 발생합니다.

다음 단계로 넘어가기 (Next Steps)

캐시 컴포넌트의 설정 옵션들에 대해 더 깊이 학습해 보세요.

  • cacheComponents
    • Next.js에서 cacheComponents 플래그를 어떻게 활성화하는지 배웁니다.
  • use cache
    • Next.js 애플리케이션에서 데이터를 캐시하기 위해 "use cache" 지시어를 어떻게 쓰는지 배웁니다.
  • cacheLife
    • 캐시된 함수나 컴포넌트의 캐시 만료 시간을 설정하기 위해 cacheLife 함수를 사용하는 방법을 배웁니다.
  • cacheTag
    • Next.js 애플리케이션에서 캐시 무효화(invalidation)를 관리하기 위해 cacheTag 함수를 어떻게 쓰는지 배웁니다.
  • revalidateTag
    • revalidateTag 함수에 대한 API 레퍼런스입니다.
  • updateTag
    • updateTag 함수에 대한 API 레퍼런스입니다.

모든 공식 문서의 의미론적인 개요를 확인하려면 https://nextjs.org/docs/sitemap.md 를 참조하세요.

모든 사용 가능한 공식 문서의 인덱스를 확인하려면 https://nextjs.org/docs/llms.txt 를 참조하세요.


강의 수고 많으셨습니다! 이 멋진 캐시 컴포넌트와 PPR을 활용해서 여러분만의 훌륭한 프로젝트 포트폴리오를 완성하시길 진심으로 응원할게요!

profile
프론트에_가까운_풀스택_개발자

0개의 댓글