Table of contents
Open Table of contents
revalidatePath가 하는 일
revalidatePath는 next/cache에서 제공하는 온디맨드 캐시 무효화 함수다. 특정 URL 경로를 지정하면 해당 경로에 묶인 서버 캐시(Data Cache + Full Route Cache)를 무효화한다. 다음 요청이 들어오면 캐시를 재생성하면서 최신 데이터를 반영한다.
import { revalidatePath } from "next/cache";
revalidatePath("/notes/42");
이 한 줄이 실행되는 순간, /notes/42 경로에 결빙되어 있던 캐시가 사라진다. 이후 해당 경로에 접근하면 서버는 캐시 없이 새로 렌더링하고, 그 결과를 다시 캐싱한다.
세 가지 호출 형태
revalidatePath는 두 번째 인자로 type을 받는다. 이 값에 따라 무효화 범위가 달라진다.
경로 직접 지정 (기본형)
revalidatePath("/notes/42");
정확히 해당 경로 하나만 무효화한다. 가장 단순하고 부작용이 적다. 정적 경로이거나 이미 구체적인 URL을 알고 있을 때 사용한다.
page 타입
revalidatePath("/notes/[id]", "page");
동적 세그먼트를 포함하는 페이지 파일 단위로 무효화한다. /notes/42, /notes/99 등 해당 동적 라우트에 매칭되는 모든 경로가 대상이다. 단, 하위 레이아웃이나 중첩 경로에는 영향을 주지 않는다.
layout 타입
revalidatePath("/notes", "layout");
해당 경로의 레이아웃을 공유하는 모든 하위 페이지를 연쇄적으로 무효화한다. /notes, /notes/42, /notes/42/comments 등 레이아웃 트리 아래의 모든 경로가 대상이 된다.
비교 정리
| 호출 형태 | 무효화 범위 | 적합한 상황 |
|---|---|---|
revalidatePath("/notes/42") | 해당 경로 1개 | 단일 리소스 수정 |
revalidatePath("/notes/[id]", "page") | 동적 라우트 전체 | 목록 데이터 일괄 변경 |
revalidatePath("/notes", "layout") | 레이아웃 하위 전체 | 공통 레이아웃에 영향을 주는 변경 |
무효화 범위가 넓을수록 불필요한 캐시까지 날아가므로, 가능한 한 좁은 범위를 선택하는 것이 원칙이다.
실전 구현: 메모 앱의 캐시 무효화
revalidatePath가 Server Action, use cache, Suspense와 어떻게 맞물리는지 메모 앱을 예시로 설계한다. 외부 의존성 없이 핵심 구조에 집중하기 위해 인메모리 데이터 저장소를 사용한다.
데이터 계층: Server Action과 무효화 로직
// src/app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
interface Memo {
id: string;
teamId: string;
content: string;
author: string;
pinned: boolean;
}
let memos: Memo[] = [
{
id: "1",
teamId: "alpha",
content: "배포 전 스테이징 환경에서 회귀 테스트 필수",
author: "박엔지니어",
pinned: true,
},
{
id: "2",
teamId: "alpha",
content: "금요일 오후 배포 금지",
author: "김리드",
pinned: false,
},
{
id: "3",
teamId: "alpha",
content: "API 응답 스키마 변경 예정 - 클라이언트 팀 공유 완료",
author: "이백엔드",
pinned: false,
},
];
export async function getMemos(teamId: string) {
// 실제 환경에서는 DB 쿼리
await new Promise(resolve => setTimeout(resolve, 300));
return memos.filter(m => m.teamId === teamId);
}
export async function deleteMemo(memoId: string, teamId: string) {
memos = memos.filter(m => m.id !== memoId);
// 해당 팀 페이지의 캐시만 정밀하게 무효화한다
revalidatePath(`/team/${teamId}`);
}
export async function togglePin(memoId: string, teamId: string) {
memos = memos.map(m =>
m.id === memoId ? { ...m, pinned: !m.pinned } : m
);
revalidatePath(`/team/${teamId}`);
}
'use server' 지시어가 파일 최상단에 위치한다. 이 파일의 모든 export 함수는 서버에서만 실행되며, 클라이언트 번들에 포함되지 않는다.
deleteMemo와 togglePin은 데이터 변경 후 revalidatePath를 호출한다. 변경이 발생한 팀 페이지의 캐시만 무효화하므로, 다른 팀의 캐시에는 영향이 없다.
반환값이 없다는 점도 중요하다. Server Action이 void를 반환하면 <form>의 action 속성에 직접 바인딩할 때 TypeScript 타입 제약을 자연스럽게 충족한다.
페이지 컴포넌트: 정적 셸과 Suspense 경계
// src/app/team/[id]/page.tsx
import { Suspense } from "react";
import MemoBoard from "./MemoBoard";
export default function TeamPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div style={{ padding: "24px", maxWidth: "640px", margin: "0 auto" }}>
<h1>팀 메모 보드</h1>
<p>중요한 공지와 팀 규칙을 관리합니다.</p>
<hr style={{ margin: "20px 0" }} />
<Suspense
fallback={
<p style={{ color: "#6b7280" }}>메모를 불러오는 중...</p>
}
>
<MemoBoard paramsPromise={params} />
</Suspense>
</div>
);
}
이 컴포넌트에서 주목할 점은 두 가지다.
첫째, params를 await 하지 않는다. Next.js의 동적 라우트에서 params는 Promise로 전달된다. 부모 컴포넌트가 이 Promise를 해제하면 해당 컴포넌트 자체가 동적(dynamic)으로 전환되어 정적 셸의 이점을 잃는다. Promise를 그대로 자식에게 전달하고, Suspense 경계 안에서 해제하는 것이 PPR(Partial Prerendering) 패턴의 핵심이다.
둘째, <Suspense>의 fallback이 로딩 상태를 담당한다. revalidatePath로 캐시가 무효화된 직후 페이지에 접근하면, 서버가 새로운 데이터를 가져오는 동안 이 fallback이 표시된다. 캐시가 유효한 상태에서는 fallback 없이 즉시 캐싱된 결과가 렌더링된다.
캐싱 컴포넌트: use cache와 Server Action 바인딩
// src/app/team/[id]/MemoBoard.tsx
"use cache";
import { getMemos, deleteMemo, togglePin } from "@/app/actions";
export default async function MemoBoard({
paramsPromise,
}: {
paramsPromise: Promise<{ id: string }>;
}) {
const { id: teamId } = await paramsPromise;
const memos = await getMemos(teamId);
const pinned = memos.filter(m => m.pinned);
const general = memos.filter(m => !m.pinned);
return (
<div>
{pinned.length > 0 && (
<section>
<h3>고정 메모</h3>
{pinned.map(memo => (
<MemoCard key={memo.id} memo={memo} teamId={teamId} />
))}
</section>
)}
<section style={{ marginTop: "16px" }}>
<h3>일반 메모</h3>
{general.map(memo => (
<MemoCard key={memo.id} memo={memo} teamId={teamId} />
))}
{general.length === 0 && (
<p style={{ color: "#9ca3af" }}>등록된 메모가 없습니다.</p>
)}
</section>
</div>
);
}
function MemoCard({ memo, teamId }: { memo: any; teamId: string }) {
return (
<div
style={{
padding: "12px",
marginBottom: "8px",
background: memo.pinned ? "#fef9c3" : "#f9fafb",
borderRadius: "8px",
border: "1px solid #e5e7eb",
}}
>
<strong>{memo.author}</strong>: {memo.content}
<div style={{ marginTop: "8px", display: "flex", gap: "8px" }}>
<form action={togglePin.bind(null, memo.id, teamId)}>
<button
type="submit"
style={{
padding: "4px 8px",
fontSize: "12px",
borderRadius: "4px",
border: "1px solid #d1d5db",
cursor: "pointer",
}}
>
{memo.pinned ? "고정 해제" : "고정"}
</button>
</form>
<form action={deleteMemo.bind(null, memo.id, teamId)}>
<button
type="submit"
style={{
padding: "4px 8px",
fontSize: "12px",
color: "white",
backgroundColor: "#ef4444",
borderRadius: "4px",
border: "none",
cursor: "pointer",
}}
>
삭제
</button>
</form>
</div>
</div>
);
}
'use cache' 지시어는 파일 최상단에 위치해야 한다. 이 지시어가 있으면 컴포넌트의 렌더링 결과가 서버에 캐싱된다. 최초 요청에서 getMemos를 호출하여 데이터를 가져오고 렌더링한 결과를 저장해둔다. 이후 동일한 요청이 들어오면 getMemos를 다시 호출하지 않고 캐싱된 결과를 즉시 반환한다.
form의 action에 Server Action을 .bind()로 바인딩하는 패턴은 클라이언트 JavaScript에 의존하지 않는다. JavaScript가 로드되지 않은 환경에서도 HTML <form>의 기본 동작으로 서버에 요청을 전달할 수 있다. .bind()는 Server Action에 전달할 인자(메모 ID, 팀 ID)를 미리 고정하는 역할을 한다.
데이터 흐름 전체 그림
이 구조에서 캐시 생성과 무효화는 다음과 같이 순환한다.
1단계 - 최초 접근과 캐시 생성
브라우저 → /team/alpha 요청
→ 정적 셸 즉시 반환 (h1, p, hr)
→ Suspense 경계 안: MemoBoard 렌더링 시작
→ getMemos("alpha") 호출 (300ms 소요)
→ 렌더링 결과를 캐시에 저장
→ 스트리밍으로 클라이언트에 전달
2단계 - 캐시 히트
브라우저 → /team/alpha 재접근
→ 정적 셸 즉시 반환
→ MemoBoard: 캐시 히트, getMemos 호출 없이 즉시 반환
→ 300ms 지연 없이 전체 페이지가 한 번에 렌더링
3단계 - 무효화와 재생성
사용자 → "삭제" 버튼 클릭
→ deleteMemo Server Action 실행
→ 데이터 변경
→ revalidatePath("/team/alpha") 호출
→ /team/alpha 캐시 삭제
→ 서버가 MemoBoard를 새로 렌더링
→ getMemos("alpha") 다시 호출
→ 갱신된 결과를 캐시에 저장
→ 클라이언트 UI 갱신
핵심은 revalidatePath가 캐시 삭제만 담당한다는 점이다. 새로운 캐시의 생성은 use cache가 다음 요청에서 자동으로 처리한다. 두 메커니즘이 분리되어 있기 때문에 각각의 책임이 명확하다.
타입 인자를 활용한 범위 확장
팀 메모 앱이 성장하여 /team/alpha 아래에 여러 하위 경로가 생겼다고 가정하자.
/team/alpha → 메모 보드
/team/alpha/members → 팀원 목록
/team/alpha/settings → 팀 설정
이들이 공통 레이아웃을 공유하고, 레이아웃에 팀 이름이나 멤버 수 같은 공유 데이터가 캐싱되어 있다면, 단일 경로 무효화로는 부족하다.
// 팀 이름 변경 시: 레이아웃 하위 전체 무효화
export async function renameTeam(teamId: string, newName: string) {
await updateTeamName(teamId, newName);
// 레이아웃을 공유하는 모든 하위 경로의 캐시를 무효화
revalidatePath(`/team/${teamId}`, "layout");
}
반대로 멤버 목록만 변경되었다면 범위를 좁힐 수 있다.
// 멤버 추가 시: 멤버 페이지만 무효화
export async function addMember(teamId: string, userId: string) {
await insertMember(teamId, userId);
// /team/alpha/members 경로만 무효화
revalidatePath(`/team/${teamId}/members`);
}
revalidatePath의 제약과 대안
revalidatePath는 직관적이지만 한계가 있다.
경로를 알아야 한다. 무효화할 URL을 호출 시점에 명시해야 한다. 하나의 데이터가 여러 페이지에 캐싱되어 있으면 모든 경로를 나열해야 하고, 경로가 추가될 때마다 무효화 코드도 함께 수정해야 한다.
// 같은 데이터가 세 곳에 캐싱되어 있으면 세 번 호출해야 한다
export async function updateMemo(memoId: string, teamId: string) {
await saveMemo(memoId);
revalidatePath(`/team/${teamId}`);
revalidatePath("/dashboard");
revalidatePath("/recent");
}
이런 상황에서는 revalidateTag가 더 적합하다. cacheTag로 데이터에 이름을 붙여두면 경로와 무관하게 태그 하나로 관련 캐시를 일괄 무효화할 수 있다.
경로 단위의 거친 입도. revalidatePath는 경로에 연결된 모든 캐시를 무효화한다. 한 페이지에 여러 데이터가 캐싱되어 있을 때, 특정 데이터만 선택적으로 무효화할 수 없다. 메모 보드와 팀 통계가 같은 페이지에 있다면, 메모 하나를 삭제해도 팀 통계의 캐시까지 함께 날아간다.
선택 기준
| 상황 | 권장 API |
|---|---|
| 단일 페이지의 단일 데이터 변경 | revalidatePath |
| 여러 페이지에 걸친 동일 데이터 변경 | revalidateTag |
| 사용자 액션의 결과를 즉시 UI에 반영 | updateTag |
revalidatePath는 경로와 데이터가 1:1로 대응하는 단순한 구조에서 가장 효과적이다. 구조가 복잡해질수록 태그 기반 무효화로 전환하는 것이 유지보수에 유리하다.
정리
revalidatePath는 캐시 무효화의 가장 기본적인 도구다. 경로를 지정하면 해당 캐시가 사라지고, 다음 요청에서 use cache가 새로운 캐시를 생성한다. Server Action에서 데이터를 변경한 직후 호출하면, Suspense 경계를 통해 로딩 상태를 거쳐 갱신된 UI가 렌더링되는 하나의 흐름이 완성된다.
세 가지 호출 형태(기본형, page, layout)는 무효화 범위를 조절하는 도구다. 범위는 항상 최소한으로 유지하되, 레이아웃 전체를 갱신해야 하는 상황에서는 layout 타입을 활용한다.
구조가 단순할 때 revalidatePath로 시작하고, 데이터와 경로의 관계가 복잡해지면 revalidateTag로 전환하는 것이 실용적인 접근이다.