학습 자료

"use cache" — 한 페이지 안에서 정적과 동적을 섞는다


Article

12강의 마지막 문제로 시작한다. 본문은 모두에게 같고, 사용자 이름만 다르다. 그런데 이름 한 줄 때문에 페이지 전체가 동적이 됐다.

Next.js 16은 이걸 풀려고 캐싱 모델 전체를 갈아엎었다. 이름이 Cache Components다.

// next.config.js
const nextConfig = {
  cacheComponents: true,
};

module.exports = nextConfig;

켜는 순간 기본값이 바뀐다.

무엇이 뒤집히나

끄면 (12강)

라우트 단위

  • 페이지는 기본이 정적
  • 동적 API 하나가 전체를 물들인다
  • revalidate 로 주기를 정한다

켜면

컴포넌트 단위

  • 데이터는 기본이 동적
  • 캐시할 것만 "use cache" 로 고른다
  • 나머지는 <Suspense> 로 감싼다

캐싱이 완전한 opt-in이 됐다. 그리고 그 단위가 라우트가 아니라 컴포넌트다.

껍데기를 미리 만들고 구멍만 채운다

켜면 페이지가 두 부분으로 갈린다.

빌드할 때 만들어지는 정적 껍데기
  • /posts/1 의 HTML
    • <header> 로고 · 메뉴고정 — 껍데기에 포함
    • <article> 본문"use cache" — 껍데기에 포함
    • <aside> 사용자 이름쿠키가 필요하다
      • fallback: "불러오는 중"껍데기에는 이것만

요청이 오면 껍데기를 즉시 보낸다. 사용자는 본문을 바로 읽는다. 그동안 서버는 쿠키를 읽어 이름을 만들고, 준비되면 그 부분만 흘려보낸다.

이걸 부분 프리렌더링(Partial Prerendering)이라 부른다. 예전에는 실험 기능이었는데, cacheComponents를 켜면 이게 기본 동작이다.

12강의 페이지와 무엇이 다른가
동적 (12강)
쿠키 읽고 DB 조회하고 전부 렌더링한꺼번에 도착
Cache Components
껍데기 — 본문이 보인다이름이 흘러들어온다
요청
본문은 이미 만들어져 있다. 기다릴 이유가 없다

세 자리에 쓸 수 있다

"use cache""use client"처럼 지시자다. 쓰는 자리가 세 곳이다.

// 1. 파일 맨 위 — 모든 export 가 캐시된다
"use cache";

export default async function Page() { … }
// 2. 컴포넌트 안 — 이 컴포넌트의 결과가 캐시된다
export async function PostBody({ id }) {
  "use cache";
  const post = await getPost(id);
  return <article>{post.body}</article>;
}
// 3. 함수 안 — 이 함수의 반환값이 캐시된다
export async function getPosts() {
  "use cache";
  const res = await fetch("https://api.example.com/posts");
  return res.json();
}

파일 맨 위에 쓰면 모든 export가 async 함수여야 한다.

어디에무엇이 캐시되나언제 쓰나
파일 맨 위그 파일의 모든 export데이터 모듈 전체
컴포넌트 안그 컴포넌트의 JSX 결과화면 조각을 통째로
함수 안그 함수의 반환값여러 곳에서 쓰는 데이터

"use client"가 경계 선언, "use server"가 엔드포인트 선언이었다. "use cache"저장 선언이다. 셋 다 이름이 비슷할 뿐 하는 일이 다르다.

캐시 키가 무엇으로 만들어지나

같은 결과를 다시 쓰려면 "같은 호출"인지 판단해야 한다. 10강의 요청 메모이제이션과 같은 문제다. 다만 여기는 재료가 더 많다.

캐시 키를 이루는 것들
빌드 ID
배포할 때마다 바뀐다
새 배포 = 캐시 전부 무효
함수 ID
코드 위치와 시그니처의 해시
인자
getPost("1") 의 "1"
직렬화된다
클로저로 잡은 값
바깥 스코프에서 참조한 변수
자동으로 인자가 된다
id 가 다르면 다른 캐시 항목이 된다

네 번째가 놀랍다. 바깥 변수를 참조하면 그것도 캐시 키에 들어간다.

async function Component({ userId }) {
  const getData = async (filter) => {
    "use cache";
    return fetch(`/api/users/${userId}/data?filter=${filter}`);
  };

  return getData("active");
}

userId는 인자가 아니라 클로저다. 자바스크립트 편 14강에서 배운 그것이다. 그런데 Next.js가 이걸 잡아내 캐시 키에 넣는다. 사용자마다 다른 캐시 항목이 생긴다.

인자와 반환값은 직렬화되어야 한다. 8강의 그 규칙이다. 다만 방향이 조금 다르다.

인자로 받을 수 있나반환할 수 있나
문자열 · 숫자 · 객체 · 배열된다된다
Date · Map · Set된다된다
JSX통과만 시킨다된다
함수 · 클래스 인스턴스안 된다안 된다

요청 시점의 값은 안에서 못 읽는다

캐시된 함수 안에서 cookies()를 부르면 터진다. 당연하다. 저장해두고 여러 사람에게 나눠줄 결과인데, 특정 사용자의 쿠키가 섞이면 안 된다.

콘솔
  • Error: Route used `cookies()` inside "use cache".
  • Accessing Dynamic data sources inside a cache scope is not supported.

headers(), searchParams도 마찬가지다. 바깥에서 읽어 인자로 넘긴다.

export default function Page() {
  return (
    <Suspense fallback={<p>불러오는 중…</p>}>
      <Profile />
    </Suspense>
  );
}

// 캐시하지 않는다. 쿠키를 읽는다
async function Profile() {
  const session = (await cookies()).get("session")?.value;
  return <CachedProfile sessionId={session} />;
}

// 캐시한다. 값을 인자로 받는다
async function CachedProfile({ sessionId }) {
  "use cache";
  const data = await fetchUserData(sessionId);
  return <p>{data.name}</p>;
}

sessionId가 캐시 키가 된다. 같은 세션이면 저장된 결과를 준다.

캐시하지 않을 것은 Suspense 로 감싼다

이제 규칙이 하나로 정리된다. cacheComponents를 켜면 Next.js는 모든 비동기 데이터에 대해 둘 중 하나를 요구한다.

빌드할 때 Next.js 가 묻는 것
  1. 이 데이터, 미리 만들 수 있나
  2. "use cache" 가 있다→ 껍데기에 넣는다
  3. <Suspense> 안에 있다→ fallback 을 껍데기에 넣고 나중에 흘린다
  4. 둘 다 없다→ 빌드를 멈추고 에러
콘솔
  • Error: Uncached data was accessed outside of <Suspense>.

이 에러는 잔소리가 아니라 설계다. "이 데이터를 기다리는 동안 사용자에게 무엇을 보여줄지 네가 정해라"는 요구다.

4강에서 loading.js를 만들면 로딩 화면이 공짜로 생긴다고 했다. 그건 페이지 전체를 감싸는 <Suspense>였다. 이제는 페이지 안의 조각마다 경계를 긋는다. 15강에서 이 경계를 자세히 다룬다.

얼마나 오래 저장하나

아무 설정도 안 하면 기본 프로파일을 쓴다.

기본값
stale5분브라우저가 다시 안 묻고 쓰는 시간
revalidate15분서버가 뒤에서 새로 만드는 주기
expire없음시간으로는 만료되지 않는다

바꾸려면 cacheLife를 부른다.

import { cacheLife } from "next/cache";

export async function getPosts() {
  "use cache";
  cacheLife("hours");

  return fetch("https://api.example.com/posts").then(r => r.json());
}

"hours", "days" 같은 이름 붙은 프로파일이 있고, next.config.js에서 직접 정의할 수도 있다.

children 은 캐시를 오염시키지 않는다

캐시된 컴포넌트 안에 동적인 것을 넣고 싶다면, 9강의 합성 패턴을 그대로 쓴다.

export default async function Page() {
  return (
    <CachedLayout header={<h1>블로그</h1>}>
      <LiveComments />
    </CachedLayout>
  );
}

async function CachedLayout({ header, children }) {
  "use cache";
  const data = await fetch("/api/cached");

  return (
    <div>
      {header}
      <Article data={data} />
      {children}
    </div>
  );
}

children들여다보지 않고 자리에만 놓으면 캐시 키에 영향을 주지 않는다. CachedLayout의 캐시는 그대로 재사용되고, LiveComments는 매 요청 새로 그려진다.

9강에서 import 하지 말고 children으로 받으라고 했던 그 원칙이 여기서 또 통한다. 경계를 넘는 통로는 언제나 합성이다.

12강의 문제를 푼다

돌아가서 그 페이지를 고친다.

import { Suspense } from "react";
import { cookies } from "next/headers";

export default async function Post({ params }) {
  const { id } = await params;

  return (
    <>
      <Suspense fallback={<span>로그인</span>}>
        <UserName />
      </Suspense>
      <PostBody id={id} />
    </>
  );
}

async function PostBody({ id }) {
  "use cache";
  const post = await getPost(id);
  return <article>{post.body}</article>;
}

async function UserName() {
  const session = (await cookies()).get("session")?.value;
  const user = await getUser(session);
  return <span>{user?.name ?? "로그인"}</span>;
}

본문은 빌드할 때 굳는다. 이름 자리에는 "로그인"이 먼저 들어가고, 쿠키를 읽은 결과가 도착하면 바뀐다.

한 줄이 전체를 물들이지 않는다. 이것이 Cache Components가 산 것이다.

정리하면

  • cacheComponents: true를 켜면 데이터는 기본이 동적이고, 캐시할 것을 고른다.
  • 부분 프리렌더링이 기본이 된다. 정적 껍데기를 먼저 보내고 동적인 구멍을 흘려보낸다.
  • "use cache"파일 · 컴포넌트 · 함수 세 자리에 쓴다.
  • 캐시 키는 빌드 ID + 함수 ID + 인자 + 클로저로 잡은 값이다. 바깥 변수도 키가 된다.
  • 캐시된 함수 안에서는 cookies(), headers(), searchParams읽을 수 없다. 밖에서 읽어 값으로 넘긴다.
  • Promise를 넘기면 빌드가 타임아웃된다. 값을 꺼내서 넘긴다.
  • 캐시하지 않는 비동기 데이터는 <Suspense>로 감싸야 한다. 아니면 빌드가 실패한다.
  • cacheLife로 저장 기간을 정한다. 기본은 서버 15분마다 갱신이다.
  • children들여다보지 않으면 캐시 키를 오염시키지 않는다. 9강의 합성 패턴이 그대로 통한다.

이제 시간이 아니라 사건으로 캐시를 버리는 법이 남았다. 글을 수정했으면 15분을 기다릴 게 아니라 그 즉시 바뀌어야 한다.

11강에서 revalidatePath를 잠깐 썼다. 태그를 붙여 정확히 필요한 것만 버리는 방법이 있고, Next.js 16에서 그 API가 셋으로 갈라졌다. 다음 강의에서 revalidateTag, updateTag, refresh를 구분한다.

"use cache" — 한 페이지 안에서 정적과 동적을 섞는다 — 디코드랩(DCODELAB)