Table of contents
Open Table of contents
경로 중심 무효화의 구조적 결함
revalidatePath는 URL을 지정해 해당 경로의 캐시를 무효화한다. 단순하고 직관적이지만, 하나의 데이터가 여러 페이지에 걸쳐 렌더링되는 구조에서 근본적인 문제를 드러낸다.
온라인 강의 플랫폼을 예로 들어보자. 강사가 특정 강의의 제목을 수정하면, 그 변경은 다음 페이지 전부에 반영되어야 한다.
| 경로 | 노출 위치 |
|---|---|
/courses/react-advanced | 강의 상세 페이지 |
/ | 메인 추천 강의 섹션 |
/categories/frontend | 카테고리 목록 |
/instructors/kim | 강사 프로필의 강의 목록 |
/dashboard | 수강생 대시보드 |
경로 기반으로 무효화하면 코드는 이렇게 된다.
import { revalidatePath } from "next/cache";
async function updateCourseTitle(courseId: string, newTitle: string) {
await db.course.update({
where: { id: courseId },
data: { title: newTitle },
});
revalidatePath(`/courses/${courseId}`);
revalidatePath("/");
revalidatePath("/categories/frontend");
revalidatePath(`/instructors/kim`);
// /dashboard를 빠뜨렸다.
}
개발자가 경로 하나를 빠뜨리면, 해당 페이지의 사용자만 과거 데이터를 보게 된다. 문제는 이 실수를 컴파일 타임에 잡을 수 없다는 점이다. 타입 체크도, 린트 규칙도 “이 데이터가 어느 경로에서 렌더링되는지”를 추적하지 못한다.
경로가 추가될 때마다 무효화 코드도 함께 수정해야 하고, 그 의존 관계는 코드 어디에도 명시되지 않는다. 경로와 데이터 사이의 매핑이 개발자의 기억에만 존재하는 셈이다.
이 문제의 근본 원인은 무효화 대상을 **경로(어디에 보이는가)**로 지정하기 때문이다. 실제로 변경된 것은 경로가 아니라 **데이터(무엇이 바뀌었는가)**다.
서로게이트 키: CDN이 먼저 풀었던 문제
이 문제는 새로운 것이 아니다. 글로벌 CDN 업체들은 오래전부터 같은 문제를 다뤄왔다.
Fastly, Cloudflare 같은 CDN은 전 세계 수백 개 엣지 노드에 콘텐츠를 캐싱한다. 뉴스 사이트의 기사 제목이 수정되면, 그 기사가 포함된 모든 페이지의 캐시를 동시에 무효화해야 한다. 메인 페이지, 카테고리 페이지, 검색 결과, RSS 피드 등 노출 지점은 수십 곳에 달한다.
CDN이 채택한 해법이 **서로게이트 키(Surrogate Key)**다. 원리는 단순하다.
- 태깅: 서버가 응답을 생성할 때, HTTP 헤더에 해당 응답이 의존하는 데이터의 식별자를 태그로 첨부한다.
- 역인덱스 구축: CDN은
태그 → 캐시 엔트리역인덱스를 유지한다.article-789라는 태그가 붙은 캐시 엔트리가 메인 페이지, 카테고리 페이지, 검색 결과 세 곳에 존재한다면, 역인덱스에 세 항목이 기록된다. - 태그 기반 퍼지: 데이터가 변경되면 태그 하나로 퍼지 요청을 보낸다. CDN은 역인덱스를 조회해 해당 태그가 연결된 모든 캐시 엔트리를 한 번에 제거한다.
Surrogate-Key: article-789 category-tech author-42
이 헤더가 붙은 응답은 세 개의 태그로 추적된다. article-789를 퍼지하면 이 기사가 포함된 모든 페이지의 캐시가 무효화되고, category-tech를 퍼지하면 해당 카테고리에 속하는 기사가 포함된 모든 페이지가 무효화된다.
핵심은 **“이 캐시가 어떤 URL인가”가 아니라 “이 캐시가 어떤 데이터에 의존하는가”**를 기준으로 무효화한다는 점이다.
cacheTag: Next.js의 서로게이트 키
Next.js 16은 이 서로게이트 키 아키텍처를 프레임워크 레벨에서 구현했다. cacheTag 함수가 그 진입점이다.
import { cacheTag } from "next/cache";
async function getCourse(courseId: string) {
"use cache";
cacheTag(`course-${courseId}`, "courses-list");
const course = await db.course.findUnique({
where: { id: courseId },
});
return course;
}
cacheTag는 use cache 지시어가 선언된 함수 내부에서 호출한다. 이 함수의 반환값이 캐싱될 때, 지정한 태그들이 해당 캐시 엔트리에 연결된다.
getCourse("react-advanced")가 메인 페이지, 강의 상세, 강사 프로필 등 어디에서 호출되든, 생성되는 캐시 엔트리에는 course-react-advanced와 courses-list 태그가 동일하게 부착된다.
컴포넌트 단위 태깅
함수뿐 아니라 컴포넌트 자체에도 태그를 붙일 수 있다.
import { cacheTag } from "next/cache";
async function CourseCard({ courseId }: { courseId: string }) {
"use cache";
cacheTag(`course-${courseId}`);
const course = await db.course.findUnique({
where: { id: courseId },
});
return (
<article>
<h3>{course.title}</h3>
<p>{course.instructor.name}</p>
<span>{course.enrollCount}명 수강 중</span>
</article>
);
}
이 컴포넌트가 렌더링되는 모든 페이지에서 course-${courseId} 태그가 캐시에 연결된다. 데이터와 캐시의 관계가 호출 시점이 아닌 선언 시점에 고정되므로, 새로운 페이지가 추가되어도 무효화 코드를 수정할 필요가 없다.
fetch와의 조합
외부 API를 호출하는 fetch에도 태그를 지정할 수 있다.
const res = await fetch(`https://api.example.com/courses/${courseId}`, {
next: { tags: [`course-${courseId}`] },
});
cacheTag 함수와 fetch의 next.tags 옵션은 동일한 태그 시스템을 공유한다. 둘 다 같은 역인덱스에 기록되므로, 무효화 시 함께 처리된다.
updateTag와 revalidateTag: 두 가지 무효화 전략
태그가 부착된 캐시를 무효화하는 API는 두 가지다. 동작 방식이 다르므로 시나리오에 따라 선택해야 한다.
updateTag: 즉시 만료
import { updateTag } from "next/cache";
async function updateCourseAction(courseId: string, data: CourseUpdateInput) {
await db.course.update({
where: { id: courseId },
data,
});
updateTag(`course-${courseId}`);
}
updateTag는 해당 태그가 연결된 모든 캐시 엔트리를 즉시 만료시킨다. 다음 요청은 캐시를 건너뛰고 새로 데이터를 조회한다. Server Action 내부에서만 호출할 수 있다.
사용자가 자신의 행위 결과를 즉시 확인해야 하는 상황에 적합하다. 강의 제목을 수정한 뒤 상세 페이지로 돌아왔는데 이전 제목이 보인다면, 사용자는 수정이 실패했다고 판단한다.
revalidateTag: Stale-While-Revalidate
import { revalidateTag } from "next/cache";
export async function POST(request: Request) {
const { courseId } = await request.json();
revalidateTag(`course-${courseId}`, { revalidate: "max" });
return Response.json({ revalidated: true });
}
revalidateTag는 SWR(Stale-While-Revalidate) 방식으로 동작한다. 호출 시점에 캐시를 stale로 표시하되, 다음 요청에는 기존 캐시를 먼저 응답하고 백그라운드에서 새 데이터를 준비한다.
외부 웹훅이나 CMS에서 콘텐츠 변경을 통지받는 Route Handler에서 주로 사용한다. 사용자가 직접 트리거한 행위가 아니므로, 약간의 지연은 허용된다.
주의:
revalidateTag(tag)단일 인자 호출은 deprecated다. 두 번째 인자로 옵션 객체를 반드시 전달한다.
선택 기준 비교
| 기준 | updateTag | revalidateTag |
|---|---|---|
| 캐시 만료 시점 | 즉시 | stale 표시 후 백그라운드 갱신 |
| 다음 요청 응답 | 새 데이터 (대기) | 기존 캐시 (즉시 응답) |
| 호출 가능 위치 | Server Action | Server Action, Route Handler |
| 주요 시나리오 | 사용자 직접 조작 후 즉시 반영 | 외부 이벤트, 배치 갱신 |
앞선 문제를 태그로 재설계하기
경로 기반 무효화에서 발생했던 강의 플랫폼 문제를 태그 기반으로 다시 설계해 보자.
먼저 강의 데이터를 조회하는 함수에 태그를 선언한다.
import { cacheTag } from "next/cache";
async function getCourse(courseId: string) {
"use cache";
cacheTag(`course-${courseId}`);
return db.course.findUnique({ where: { id: courseId } });
}
async function getCoursesByCategory(category: string) {
"use cache";
cacheTag(`category-${category}`, "courses-list");
return db.course.findMany({ where: { category } });
}
async function getInstructorCourses(instructorId: string) {
"use cache";
cacheTag(`instructor-${instructorId}`, "courses-list");
return db.course.findMany({ where: { instructorId } });
}
이제 강의 제목을 수정하는 Server Action은 이렇게 된다.
import { updateTag } from "next/cache";
async function updateCourseAction(courseId: string, data: CourseUpdateInput) {
const course = await db.course.update({
where: { id: courseId },
data,
});
updateTag(`course-${courseId}`);
updateTag(`category-${course.category}`);
updateTag(`instructor-${course.instructorId}`);
}
경로를 하나도 언급하지 않았다. 무효화 대상은 데이터의 식별자다. 새로운 페이지가 추가되어 getCourse를 호출하더라도, 그 페이지의 캐시는 자동으로 course-${courseId} 태그에 연결된다. 무효화 코드는 수정할 필요가 없다.
경로 기반 무효화에서 빠뜨렸던 /dashboard도 getCourse를 사용하기만 하면 태그 체계에 자동 편입된다.
태그 명명 규칙 설계
태그는 문자열이다. 규칙 없이 자유롭게 붙이면 금세 혼란에 빠진다. 프로젝트 초기에 명명 규칙을 확립하는 것이 중요하다.
엔티티-ID 패턴
개별 엔티티를 식별하는 태그다.
course-{id} # 특정 강의
instructor-{id} # 특정 강사
enrollment-{id} # 특정 수강 등록
해당 엔티티의 데이터가 변경되면 이 태그로 무효화한다.
컬렉션 패턴
목록이나 집계를 포함하는 캐시에 사용한다.
courses-list # 강의 목록 전체
category-{slug} # 특정 카테고리의 강의 목록
instructor-{id}-courses # 특정 강사의 강의 목록
새 강의가 추가되거나 삭제되면 관련 컬렉션 태그를 무효화한다.
계층 구조를 반영한 복합 태그
하나의 캐시 엔트리에 여러 수준의 태그를 동시에 부착하면 세밀한 제어가 가능하다.
async function getCourseReviews(courseId: string) {
"use cache";
cacheTag(
`course-${courseId}-reviews`, // 이 강의의 리뷰 목록
`course-${courseId}`, // 이 강의와 관련된 모든 것
"reviews-global" // 전체 리뷰 시스템
);
return db.review.findMany({ where: { courseId } });
}
리뷰 하나가 삭제되면 course-${courseId}-reviews로 해당 강의의 리뷰 캐시만 무효화한다. 강의 자체가 삭제되면 course-${courseId}로 강의와 관련된 모든 캐시를 한 번에 제거한다. 관리자가 리뷰 시스템 전체를 점검한 뒤 reviews-global로 전역 갱신을 트리거할 수도 있다.
태그 무효화의 전파 범위
updateTag나 revalidateTag를 호출하면, Next.js 내부의 역인덱스에서 해당 태그가 연결된 모든 캐시 엔트리가 무효화된다. 이 범위는 함수 단위 캐시뿐 아니라 Full Route Cache에도 적용된다.
updateTag("course-react-advanced") 호출 시:
getCourse("react-advanced")의 캐시 → 무효화
CourseCard({ courseId: "react-advanced" })의 캐시 → 무효화
위 함수/컴포넌트를 포함하는 모든 페이지의 Route Cache → 무효화
하나의 태그 무효화가 해당 데이터에 의존하는 전체 캐시 트리를 관통한다. 경로를 나열할 필요가 없는 이유다.
다만 CDN 레벨의 캐시는 별도로 퍼지해야 한다. updateTag는 Next.js 서버의 캐시만 무효화한다. CDN이 s-maxage로 응답을 캐싱하고 있다면, CDN 퍼지 API를 함께 호출하는 패턴이 필요하다.
async function updateCourseAction(courseId: string, data: CourseUpdateInput) {
await db.course.update({ where: { id: courseId }, data });
// Next.js 서버 캐시 무효화
updateTag(`course-${courseId}`);
// CDN 캐시 퍼지 (Fastly 예시)
await fetch(`https://api.fastly.com/service/${SERVICE_ID}/purge/course-${courseId}`, {
method: "POST",
headers: { "Fastly-Key": FASTLY_API_TOKEN },
});
}
경로 기반과 태그 기반의 선택 기준
revalidatePath가 무조건 나쁜 것은 아니다. 두 방식은 적합한 시나리오가 다르다.
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 데이터가 단일 페이지에서만 사용 | revalidatePath | 경로가 곧 데이터의 범위와 일치 |
| 데이터가 여러 페이지에 걸쳐 사용 | cacheTag + updateTag | 경로 나열 없이 데이터 기준으로 무효화 |
| 레이아웃 변경 (GNB, 사이드바) | revalidatePath(path, "layout") | 레이아웃 단위 무효화가 의미적으로 정확 |
| 외부 시스템에서 변경 통지 수신 | cacheTag + revalidateTag | SWR 방식으로 부하를 분산 |
| 전체 페이지 구조 변경 (리디자인) | revalidatePath("/", "layout") | 태그로는 표현하기 어려운 전역 변경 |
실무에서는 두 방식을 혼용한다. 대부분의 데이터 변경은 태그 기반으로 처리하고, 레이아웃이나 네비게이션 같은 구조적 변경에만 경로 기반을 사용하는 것이 일반적인 패턴이다.
정리
revalidatePath의 경로 기반 무효화는 “이 데이터가 어디에 보이는가”를 개발자가 전부 파악하고 나열해야 한다. 페이지가 늘어날수록 빠뜨릴 가능성도 함께 높아진다.
cacheTag는 이 관계를 뒤집는다. 데이터를 조회하는 시점에 태그를 선언하면, 그 데이터가 어떤 페이지에서 사용되든 태그가 자동으로 따라간다. 무효화할 때는 태그 하나로 모든 관련 캐시를 한 번에 제거한다.
이 구조는 CDN 업계에서 서로게이트 키라는 이름으로 오래전부터 검증된 패턴이다. Next.js 16이 cacheTag와 updateTag라는 이름으로 프레임워크에 내장한 덕분에, 별도의 인프라 설정 없이 동일한 아키텍처를 애플리케이션 코드 레벨에서 적용할 수 있게 되었다.