Skip to content
iik89
Go back

Next.js 16 온디맨드 캐시 무효화: revalidatePath, revalidateTag, updateTag 실전 가이드

Table of contents

Open Table of contents

캐시는 양날의 검이다

use cache 지시어와 cacheLife로 서버 컴포넌트의 응답을 캐싱하면 데이터베이스 부하는 극적으로 줄어든다. 하지만 캐시된 데이터는 원본과 분리된 복사본이다. 원본이 변경되어도 TTL이 만료되기 전까지 캐시는 과거의 스냅샷을 그대로 내려준다.

협업 칸반 보드를 예로 들어보자. 팀원 A가 긴급 태스크의 상태를 “완료”로 변경했다. 데이터베이스에는 즉시 반영되지만, 보드 화면을 캐싱하고 있던 다른 팀원들은 여전히 “진행 중” 상태를 보게 된다.

// 문제: DB 변경과 캐시 사이의 불일치
async function completeTask(taskId: string) {
  await db.task.update({
    where: { id: taskId },
    data: { status: "done" },
  });
  // DB는 "done"이지만, 캐시된 보드 화면은 여전히 "in-progress"를 보여준다.
  // TTL이 만료될 때까지 이 불일치는 계속된다.
}

이것이 Stale Data 문제다. 캐시가 원본과의 동기화를 잃고 오래된 데이터를 서빙하는 상황을 말한다. TTL 만료를 수동적으로 기다리는 대신, 데이터 변경 시점에 능동적으로 캐시를 갱신하는 접근이 필요하다. 이를 **온디맨드 캐시 무효화(On-demand Cache Invalidation)**라 한다.

Next.js 16은 이 문제를 해결하기 위한 세 가지 API를 제공한다.


revalidatePath: 경로 단위 무효화

revalidatePath는 특정 URL 경로에 연결된 캐시를 통째로 무효화한다. 가장 단순하고 직관적인 방식이다.

import { revalidatePath } from "next/cache";

async function updateProjectDescription(
  projectId: string,
  description: string
) {
  await db.project.update({
    where: { id: projectId },
    data: { description },
  });

  // /projects/abc123 경로의 모든 캐시를 무효화한다
  revalidatePath(`/projects/${projectId}`);
}

동작 방식

호출 시점에 해당 경로의 서버 캐시를 즉시 무효화한다. 다음 요청이 들어오면 캐시를 재생성하면서 최신 데이터를 반영한다. Server Action과 Route Handler 모두에서 사용할 수 있다.

적합한 상황

한계

한 데이터가 여러 페이지에 걸쳐 캐싱되어 있다면 일일이 모든 경로를 나열해야 한다. 칸반 보드의 태스크 목록이 대시보드, 타임라인, 프로젝트 상세 페이지에 각각 캐싱되어 있다면 세 경로 모두에 revalidatePath를 호출해야 하는데, 이는 유지보수 부담이 된다.


revalidateTag: 태그 기반 백그라운드 갱신

revalidateTagcacheTag로 이름을 붙여둔 캐시 엔트리를 태그 단위로 무효화한다. 하나의 태그가 여러 페이지에 걸쳐 사용되더라도 한 번의 호출로 모두 처리할 수 있다.

먼저 캐싱 시점에 태그를 부여한다.

import { cacheTag } from "next/cache";

async function getTasksByProject(projectId: string) {
  "use cache";
  cacheTag(`tasks-${projectId}`);

  return db.task.findMany({
    where: { projectId },
    orderBy: { updatedAt: "desc" },
  });
}

데이터가 변경되면 태그를 기준으로 무효화한다.

import { revalidateTag } from "next/cache";

async function archiveTask(taskId: string, projectId: string) {
  await db.task.update({
    where: { id: taskId },
    data: { archived: true },
  });

  // 'tasks-abc123' 태그가 붙은 모든 캐시를 무효화한다
  revalidateTag(`tasks-${projectId}`);
}

동작 방식

호출 시점에 해당 태그의 캐시를 “낡음(stale)“으로 마킹한다. 즉시 삭제하는 것이 아니라, 다음 요청이 들어왔을 때 백그라운드에서 새 데이터를 가져온다. 그동안 기존 캐시를 먼저 보여주고 갱신이 완료되면 교체하는 Stale-While-Revalidate(SWR) 패턴으로 동작한다.

Next.js 16에서는 두 번째 인자로 cacheLife 프로필을 받아 SWR 동작의 유효 기간을 지정할 수 있다.

// 'max' 프로필 기준으로 stale 상태를 유지하면서 백그라운드 갱신
revalidateTag(`tasks-${projectId}`, "max");

적합한 상황

한계

SWR 방식이므로 무효화 직후의 첫 번째 요청은 여전히 이전 데이터를 보여준다. 사용자가 직접 트리거한 액션의 결과를 즉시 확인해야 하는 상황에는 부적합하다.


updateTag: 즉시 갱신

updateTag는 Next.js 16에서 새로 도입된 API다. 서버 캐시를 무효화하는 동시에, 현재 화면을 보고 있는 클라이언트의 UI까지 즉각적으로 다시 렌더링한다.

import { updateTag } from "next/cache";

async function moveTask(
  taskId: string,
  projectId: string,
  newStatus: string
) {
  await db.task.update({
    where: { id: taskId },
    data: { status: newStatus },
  });

  // 캐시 무효화 + 클라이언트 즉시 리렌더링
  updateTag(`tasks-${projectId}`);
}

동작 방식

호출 즉시 해당 태그의 서버 캐시를 만료시킨다. 동시에 클라이언트 측에서 해당 태그와 연관된 데이터를 다시 가져와 화면을 갱신한다. 페이지 전체를 새로고침하지 않고 RSC Payload만 교체하는 방식이므로 전환이 매끄럽다.

핵심 차이는 “read-your-own-writes” 보장이다. 사용자가 수행한 변경의 결과를 사용자 본인이 즉시 확인할 수 있다.

제약 사항

updateTagServer Action 내부에서만 호출할 수 있다. Route Handler, 미들웨어, 클라이언트 컴포넌트에서는 사용할 수 없다. 이 제약은 updateTag가 현재 요청-응답 사이클의 클라이언트와 직접 통신해야 하기 때문이다.

적합한 상황


세 API 비교

구분revalidatePathrevalidateTagupdateTag
무효화 단위URL 경로캐시 태그캐시 태그
갱신 타이밍다음 요청 시다음 요청 시 (SWR)즉시
클라이언트 UI 갱신다음 네비게이션다음 네비게이션현재 화면 즉시
사용 가능 컨텍스트Server Action, Route HandlerServer Action, Route HandlerServer Action만
사전 설정 필요없음cacheTag 부여 필요cacheTag 부여 필요

실전 조합 패턴

실무에서는 하나의 API만 단독으로 쓰기보다, 상황에 따라 조합하는 것이 일반적이다.

Server Action에서의 조합

사용자 인터랙션을 처리하는 Server Action에서는 updateTag로 즉시성을 확보하면서, 관련 페이지 경로도 함께 무효화하는 패턴이 효과적이다.

"use server";

import { revalidatePath } from "next/cache";
import { updateTag } from "next/cache";

export async function createTask(projectId: string, title: string) {
  const task = await db.task.create({
    data: { projectId, title, status: "todo" },
  });

  // 현재 보고 있는 보드 화면을 즉시 갱신
  updateTag(`tasks-${projectId}`);

  // 대시보드 등 다른 경로의 캐시도 정리
  revalidatePath("/dashboard");

  return task;
}

Webhook에서의 태그 기반 무효화

외부 서비스의 이벤트(CI/CD 파이프라인 완료 알림, 결제 상태 변경 등)에 반응하는 Route Handler에서는 updateTag를 쓸 수 없다. 이때는 revalidateTag를 사용한다.

// app/api/webhook/payment/route.ts
import { revalidateTag } from "next/cache";

export async function POST(request: Request) {
  const event = await request.json();

  if (event.type === "payment.completed") {
    await db.order.update({
      where: { id: event.orderId },
      data: { status: "paid" },
    });

    revalidateTag(`order-${event.orderId}`);
  }

  return Response.json({ received: true });
}

캐시 계층과 무효화 범위

Next.js의 캐시는 두 계층으로 나뉜다.

서버 측 Data Cache: use cache로 캐싱된 서버 컴포넌트 응답과 데이터 요청 결과를 보관한다. revalidatePath, revalidateTag, updateTag 모두 이 계층을 무효화한다.

클라이언트 측 Router Cache: 브라우저가 이전에 방문한 경로의 RSC Payload를 메모리에 보관한다. Server Action의 응답이 돌아오면 이 캐시도 함께 무효화되어 클라이언트가 최신 데이터를 렌더링한다.

즉, Server Action 내에서 무효화 API를 호출하면 서버와 클라이언트 양쪽의 캐시가 동시에 정리된다. 별도로 브라우저 캐시를 수동 제거할 필요가 없다.


디버깅 방법

캐시 무효화가 의도대로 동작하는지 확인하려면 두 곳을 관찰한다.

터미널 로그: 개발 서버(next dev)에서 무효화를 트리거한 뒤 데이터베이스 쿼리 로그가 새로 찍히는지 확인한다. 캐시가 유효하면 쿼리가 실행되지 않고, 무효화되었으면 새 쿼리가 발생한다.

브라우저 네트워크 탭: updateTag 호출 후 전체 페이지 리로드 없이 RSC Payload(text/x-component 타입)만 전송되는 것을 확인할 수 있다. 이 작은 데이터 조각이 변경된 부분의 UI만 교체한다.


선택 기준 정리

어떤 API를 쓸지 결정하는 흐름은 다음과 같다.

  1. 사용자가 직접 트리거한 액션이고, 결과를 즉시 보여줘야 하는가?updateTag를 쓴다. 단, Server Action 내부여야 한다.
  2. 여러 페이지에 걸친 데이터를 한 번에 무효화해야 하는가?revalidateTag를 쓴다. 태그 설계가 전제 조건이다.
  3. 특정 페이지 하나만 갱신하면 충분한가?revalidatePath가 가장 간단하다.
  4. Route Handler나 webhook에서 호출하는가?updateTag는 불가능하므로 revalidateTag 또는 revalidatePath를 쓴다.

캐시 전략의 핵심은 무효화 범위를 최소화하는 것이다. 변경된 데이터와 관련 없는 캐시까지 날려버리면 불필요한 재생성 비용이 발생하고, 순간적으로 원본 데이터베이스에 부하가 집중된다. cacheTag를 적절한 단위로 설계해두면, 필요한 부분만 정밀하게 무효화할 수 있다.


Share this post on:

Previous Post
revalidatePath로 설계하는 캐시 무효화 아키텍처
Next Post
PPR 도입 판단 기준: Suspense 경계와 cacheLife를 적용할 시점