Next.js

Next.js Cache Components와 cacheLife 이해하기

황인선Frontend Developer
2026년 06월 29일 발행
next.jsfront-endarchitecture

Next.js 16에서 cacheComponents를 켜면 App Router의 데이터 패칭 방식이 조금 더 명시적으로 바뀐다. 기본 방향은 간단하다. 런타임에 새로 가져와야 하는 데이터는 프리렌더에 억지로 포함하지 않고, 재사용해도 되는 데이터는 개발자가 use cache로 명확히 표시한다.

즉, "무엇을 캐시할 것인가"를 컴포넌트나 함수 단위로 선언하는 방식에 가깝다.

Cache Components란?

typescript
import type { NextConfig } from "next";

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

export default nextConfig;

이 옵션을 켜면 use cache, cacheLife, cacheTag 같은 캐시 API를 사용할 수 있다. 핵심은 서버 컴포넌트에서 발생하는 데이터 패칭을 전부 같은 방식으로 취급하지 않고, 캐시 가능한 부분과 런타임에 처리해야 하는 부분을 나눌 수 있다는 점이다.

예를 들어 블로그 글 목록, 카테고리 목록, 마크다운 변환 결과처럼 자주 바뀌지 않는 데이터는 캐시해도 괜찮다. 반대로 사용자별 데이터, 쿠키나 헤더에 의존하는 데이터, 검색 조건이 매 요청마다 달라지는 데이터는 캐시 스코프를 조심해서 잡아야 한다.

use cache는 어디에 붙일 수 있을까?

use cache는 파일, 컴포넌트, 함수 단위로 사용할 수 있다.

typescript
// 함수 단위
export async function getPosts() {
  "use cache";

  return await fetchPosts();
}
typescript
// 컴포넌트 단위
export async function PostList() {
  "use cache";

  const posts = await getPosts();
  return <div>{posts.map((post) => post.title)}</div>;
}

이렇게 선언하면 Next.js는 해당 함수나 컴포넌트의 입력값을 기준으로 캐시 키를 만든다. 캐시 키에는 빌드 ID, 함수 위치와 시그니처, 직렬화 가능한 인자, 개발 환경의 HMR 정보 등이 포함된다.

그래서 같은 함수라도 인자가 다르면 별도 캐시로 관리된다.

typescript
await getPosts({ type: "post" });
await getPosts({ type: "short" });

위 두 호출은 같은 함수지만 인자가 다르기 때문에 서로 다른 캐시 엔트리로 볼 수 있다.

cacheLife는 캐시의 수명을 정한다

use cache가 "이 결과를 캐시하겠다"는 선언이라면, cacheLife는 "얼마나 오래 어떤 방식으로 재사용할지"를 정하는 함수다.

typescript
import { cacheLife } from "next/cache";

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

  return await fetchPosts();
}

cacheLife는 반드시 use cache 스코프 안에서 실행되어야 한다. use cache 없이 cacheLife만 호출하는 식으로는 사용할 수 없다.

Next.js는 몇 가지 기본 프로필을 제공한다.

Profile적합한 경우
`seconds`실시간에 가까운 데이터
`minutes`자주 바뀌는 피드나 뉴스
`hours`하루에 여러 번 바뀌는 콘텐츠
`days`하루 단위로 바뀌는 글이나 문서
`weeks`주 단위로 바뀌는 콘텐츠
`max`거의 바뀌지 않는 정적 콘텐츠

블로그 글 목록은 보통 hoursdays 정도가 적당하다. 글을 자주 발행한다면 hours, 거의 수정하지 않는 정적 문서라면 daysmax를 고려할 수 있다.

stale, revalidate, expire

cacheLife의 프로필은 내부적으로 세 가지 값을 다룬다.

  • stale: 클라이언트 라우터가 서버 확인 없이 캐시를 사용할 수 있는 시간
  • revalidate: 서버에서 백그라운드 갱신을 시도하는 주기
  • expire: 캐시가 완전히 만료되어 다음 요청이 새 데이터를 기다려야 하는 시간 중요한 점은 stale이 브라우저의 Cache-Control 헤더와 같은 개념이 아니라는 것이다. Next.js 클라이언트 라우터가 얼마나 오래 해당 데이터를 즉시 재사용할지에 가깝다.

필요하면 기본 프로필 대신 직접 값을 줄 수도 있다.

typescript
cacheLife({
  stale: 300,
  revalidate: 3600,
  expire: 86400,
});

다만 대부분의 경우에는 hours, days 같은 이름 있는 프로필을 먼저 쓰는 편이 읽기 쉽다. 코드만 봐도 데이터의 성격이 드러나기 때문이다.

cacheTag와 함께 쓰기

캐시는 오래 보관하는 것보다 잘 무효화하는 것이 더 중요할 때가 많다. 이때 cacheTag를 함께 사용한다.

typescript
import { cacheLife, cacheTag } from "next/cache";

export async function getPosts({ type = "post" }) {
  "use cache";
  cacheLife("hours");
  cacheTag("posts", `posts:${type}`);

  return await fetchPosts({ type });
}

이렇게 태그를 붙여두면 나중에 글을 발행하거나 수정했을 때 revalidateTag("posts") 또는 revalidateTag("posts:post")처럼 필요한 범위만 갱신할 수 있다.

태그는 여러 개 붙일 수 있고, 같은 태그를 여러 번 붙여도 추가 효과는 없다. 그래서 넓은 범위의 태그와 좁은 범위의 태그를 함께 붙이는 식이 실용적이다.

주의할 점

요청 시점 API는 캐시 안에서 직접 읽지 않는다

cookies(), headers(), searchParams 같은 요청 시점 값은 캐시 스코프 안에서 직접 읽으면 안 된다. 먼저 바깥에서 읽고, 캐시 함수나 컴포넌트에 직렬화 가능한 인자로 넘기는 방식이 좋다.

typescript
async function Page({ searchParams }) {
  const params = await searchParams;
  const tags = normalizeTags(params.tags);

  return <CachedPostList tags={tags} />;
}

async function CachedPostList({ tags }: { tags: string[] }) {
  "use cache";
  cacheLife("hours");

  return await renderPosts(tags);
}

이렇게 하면 요청별 값은 캐시 밖에서 처리하고, 캐시 키에는 정규화된 인자만 포함된다.

인자는 직렬화 가능해야 한다

캐시 키를 만들려면 인자를 직렬화할 수 있어야 한다. 문자열, 숫자, 배열, plain object는 괜찮지만 클래스 인스턴스나 함수 같은 값은 피하는 것이 좋다.

또 배열을 캐시 키로 쓸 때는 순서도 신경 써야 한다. 예를 들어 react,front-endfront-end,react가 같은 의미라면 .sort()로 정규화한 뒤 넘기는 편이 낫다.

짧은 캐시는 Suspense 경계를 고려한다

seconds처럼 아주 짧은 캐시는 프리렌더에 포함되지 않고 동적 영역처럼 다뤄질 수 있다. 이런 데이터는 <Suspense> 경계 안에 두고 fallback을 준비하는 편이 안전하다.

typescript
<Suspense fallback={<div>Loading...</div>}>
  <RealtimeWidget />
</Suspense>

정적 페이지 안에 일부만 짧게 갱신되는 영역을 섞고 싶을 때 이 패턴이 유용하다.

실제로 어떻게 쓰면 좋을까?

내 기준으로는 다음 순서로 판단한다.

  1. 데이터가 사용자별인가?
  2. 쿠키, 헤더, 검색 파라미터에 직접 의존하는가?
  3. 얼마나 자주 바뀌는가?
  4. 변경됐을 때 어떤 단위로 무효화해야 하는가? 블로그 글 목록이라면 보통 사용자별 데이터가 아니고, 공개 글만 가져오며, 자주 바뀌지 않는다. 그래서 서비스 함수에 use cache, cacheLife("hours"), cacheTag("posts")를 붙이는 방식이 잘 맞는다.
typescript
export async function getPosts({ type = "post" }) {
  "use cache";
  cacheLife("hours");
  cacheTag("posts", `posts:${type}`);

  return await queryPosts({ type });
}

태그 필터처럼 조건이 있는 목록은 조건을 정규화해서 캐시 컴포넌트에 넘긴다.

typescript
async function CachedShortsList({ activeTags }: { activeTags: string[] }) {
  "use cache";
  cacheLife("hours");
  cacheTag(
    "posts",
    "posts:short",
    activeTags.length > 0 ? `posts:short:tags:${activeTags.join(",")}` : "posts:short:all"
  );

  return await renderShorts(activeTags);
}

이렇게 하면 태그 조합별 결과를 재사용하면서도, 필요한 순간 태그 기반으로 갱신할 수 있다.

정리

Cache Components는 Next.js에서 서버 컴포넌트의 데이터 패칭을 더 명시적으로 다루게 해준다. use cache는 캐시할 영역을 정하고, cacheLife는 수명을 정하고, cacheTag는 나중에 무효화할 이름표를 붙인다.

중요한 건 무조건 캐시하는 것이 아니다. 바뀌는 속도와 요청 의존성을 기준으로 캐시 경계를 작게 잡는 것이다. 공개 콘텐츠 목록이나 마크다운 변환 결과처럼 재사용 가치가 높은 데이터부터 적용하면 효과를 보기 쉽다.