시간이 아니라 사건으로 캐시를 버린다
Article
13강에서 cacheLife("hours")로 한 시간마다 갱신하게 했다. 그런데 관리자가 오타를 고쳤다면 한 시간을 기다릴 수 없다.
시간으로 버리는 대신 사건으로 버린다. 글이 수정됐다는 사건이 일어나면 그 글의 캐시를 버린다.
먼저 이름표를 붙인다
무엇을 버릴지 지목하려면 캐시 항목에 이름이 있어야 한다. cacheTag가 그 이름표다.
import { cacheTag } from "next/cache";
export async function getPosts() {
"use cache";
cacheTag("posts");
return fetch("https://api.example.com/posts").then(r => r.json());
}
export async function getPost(id) {
"use cache";
cacheTag("posts", `post-${id}`);
return fetch(`https://api.example.com/posts/${id}`).then(r => r.json());
}
태그는 여러 개 붙일 수 있다. getPost(1)은 posts이기도 하고 post-1이기도 하다.
fetch를 직접 쓴다면 옵션으로도 붙는다.
fetch(url, { next: { tags: ["posts"] } });
태그 → 캐시 항목
- posts
- getPosts()
- 목록 페이지
- posts
- getPost("1")
- 1번 글
- posts
- getPost("2")
- 2번 글
- post-1
- getPost("1")
- 1번 글만
이름을 어떻게 설계하느냐가 곧 얼마나 정밀하게 버릴 수 있느냐다. 넓은 태그와 좁은 태그를 함께 붙여둔다.
버리는 함수가 셋이다
Next.js 16에서 여기가 갈라졌다. 셋 다 next/cache에서 가져오지만 쓰는 자리와 결과가 다르다.
| 어디서 부르나 | 다음 방문자는 | |
|---|---|---|
| updateTag(tag) | Server Action 에서만 | 새 데이터를 기다린다 |
| revalidateTag(tag, "max") | Server Action · 라우트 핸들러 | 옛것을 받고, 뒤에서 갱신된다 |
| refresh() | Server Action 에서만 | 캐시는 그대로, 화면만 다시 받는다 |
무엇을 고를지는 질문 하나로 갈린다. 바꾼 사람이 그 결과를 즉시 봐야 하는가.
내가 쓴 글은 내가 바로 봐야 한다
댓글을 달았는데 목록에 안 보이면 사용자는 버튼이 안 먹었다고 생각한다. 다시 누른다.
updateTag는 이 문제를 푼다. 태그를 즉시 만료시키고, 다음 요청은 새 데이터가 올 때까지 기다린다.
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData) {
const post = await db.post.create({
data: { title: formData.get("title") },
});
updateTag("posts");
updateTag(`post-${post.id}`);
redirect(`/posts/${post.id}`);
}
redirect로 옮겨간 그 페이지에서 방금 쓴 글이 보인다. 캐시된 옛 데이터가 아니다.
이걸 read-your-own-writes라 부른다. 자기가 쓴 것을 자기가 읽는다.
남들에게는 조금 늦어도 된다
블로그 글 목록을 웹훅으로 갱신한다고 하자. CMS에서 글이 발행되면 우리 서버에 알림이 온다.
이때는 아무도 기다리고 있지 않다. 다음 방문자를 붙잡아 세워 새 데이터를 기다리게 할 이유가 없다.
revalidateTag는 태그를 오래된 것으로 표시만 한다.
"use server";
import { revalidateTag } from "next/cache";
export async function publishPost() {
await cms.publish();
revalidateTag("posts", "max");
}
다음 방문자는 저장된 옛 HTML을 즉시 받는다. 그동안 서버는 뒤에서 새것을 만든다. 그다음 방문자가 새것을 받는다.
12강에서 ISR을 설명하며 나온 그 이름이다. stale-while-revalidate. 오래된 것을 주면서 갱신한다.
updateTag
기다리게 한다
- 사용자가 방금 바꿨다
- 폼 제출 · 댓글 · 좋아요
- Server Action 안에서만
revalidateTag
옛것을 준다
- 다른 곳에서 바뀌었다
- 웹훅 · 크론 · 관리자 도구
- 라우트 핸들러에서도 된다
두 번째 인자를 빼먹지 않는다
revalidateTag의 시그니처가 16에서 바뀌었다.
revalidateTag(tag, profile);
두 번째 인자가 갱신 방식을 정한다. 권장값은 "max"이고, 이게 stale-while-revalidate다.
웹훅처럼 외부 시스템이 부르는데 즉시 만료가 필요한 드문 경우에는 객체를 넘긴다.
revalidateTag("posts", { expire: 0 });
부르는 자리를 지킨다
updateTag와 refresh는 Server Action 안에서만 부를 수 있다. 라우트 핸들러에서 부르면 터진다.
// app/api/webhook/route.js
import { updateTag } from "next/cache";
export async function POST() {
updateTag("posts"); // 터진다
}
- Error: updateTag can only be called from within a Server Action
라우트 핸들러에서는 revalidateTag를 쓴다. 클라이언트 컴포넌트에서는 셋 다 못 부른다. 서버에서만 동작하는 함수들이다.
- Server Action 인가아니면 revalidateTag 뿐이다
- 사용자가 방금 바꿨나예 → updateTag
- 다른 데서 바뀌었나예 → revalidateTag(tag, "max")
- 캐시는 멀쩡한가예 → refresh()
refresh — 캐시는 그대로 두고 화면만
마지막 함수는 성격이 다르다. refresh()는 캐시를 버리지 않는다. 클라이언트 라우터를 새로 고친다.
"use server";
import { refresh } from "next/cache";
export async function markAsRead(id) {
await db.notification.update({ where: { id }, data: { read: true } });
refresh();
}
캐시하지 않은 부분만 다시 받아온다. 6강에서 router.refresh()로 하던 일이다. 이제 서버 쪽에서 부른다. 클라이언트 컴포넌트를 만들 필요가 없다.
| 무엇이 바뀌었나 | 쓸 함수 |
|---|---|
| 캐시된 데이터가 바뀌었다 (내가 바꿨다) | updateTag |
| 캐시된 데이터가 바뀌었다 (딴 데서) | revalidateTag(tag, "max") |
| 캐시 안 한 데이터만 바뀌었다 | refresh() |
| 한 페이지의 모든 것 | revalidatePath("/posts") |
revalidatePath 는 큰 망치다
11강에서 쓴 것이다. 경로 하나에 딸린 캐시를 통째로 버린다.
revalidatePath("/posts");
편하지만 거칠다. 그 페이지가 쓰는 모든 데이터를 버린다. 바뀐 것이 글 제목 하나뿐이어도 사이드바의 인기 태그까지 다시 만든다.
태그를 붙여두면 필요한 것만 버릴 수 있다. 처음에는 revalidatePath로 시작하고, 느려지면 태그로 좁혀 나간다.
정리하면
- 시간이 아니라 사건으로 캐시를 버린다. 먼저
cacheTag로 이름표를 붙인다. - 태그는 여러 개 붙인다. 넓은 태그(
posts)와 좁은 태그(post-1)를 함께 둔다. updateTag(tag)— 사용자가 방금 바꿨다. 다음 요청은 새 데이터를 기다린다. Server Action 전용.revalidateTag(tag, "max")— 딴 데서 바뀌었다. 옛것을 즉시 주고 뒤에서 갱신한다. 라우트 핸들러에서도 된다.- 두 번째 인자를 빼먹지 않는다. 한 인자짜리는 deprecated이고 동작도 다르다.
refresh()— 캐시는 그대로 두고 화면만 다시 받는다.router.refresh()의 서버 버전이다.revalidatePath는 큰 망치다. 그 경로의 모든 캐시를 버린다.- 배포하면 빌드 ID가 바뀌어 캐시가 전부 무효화된다.
Part 4의 마지막 조각이 남았다. 13강에서 <Suspense>를 여러 번 썼지만, 그게 정확히 무엇을 하는지는 미뤄뒀다.
4강에서 loading.js를 만들면 로딩 화면이 공짜로 생긴다고 했고, 6강에서 useSearchParams를 감싸라고 했고, 13강에서는 캐시하지 않은 데이터를 감싸라고 했다. 셋 다 같은 장치다. 다음 강의에서 그 경계를 직접 긋고, HTML이 조각조각 흘러오는 것을 본다.