학습 자료

시간이 아니라 사건으로 캐시를 버린다


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번 글만
posts 를 버리면 셋 다 버려진다. post-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
새 데이터를 기다린다최신 화면
revalidateTag "max"
옛 화면 — 즉시뒤에서 갱신 (다음 사람이 새것을 본다)
다음 방문
기다리게 할 것인가, 옛것을 보여줄 것인가
무엇을 고르나

updateTag

기다리게 한다

  • 사용자가 방금 바꿨다
  • 폼 제출 · 댓글 · 좋아요
  • Server Action 안에서만

revalidateTag

옛것을 준다

  • 다른 곳에서 바뀌었다
  • 웹훅 · 크론 · 관리자 도구
  • 라우트 핸들러에서도 된다

두 번째 인자를 빼먹지 않는다

revalidateTag의 시그니처가 16에서 바뀌었다.

revalidateTag(tag, profile);

두 번째 인자가 갱신 방식을 정한다. 권장값은 "max"이고, 이게 stale-while-revalidate다.

웹훅처럼 외부 시스템이 부르는데 즉시 만료가 필요한 드문 경우에는 객체를 넘긴다.

revalidateTag("posts", { expire: 0 });

부르는 자리를 지킨다

updateTagrefreshServer 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를 쓴다. 클라이언트 컴포넌트에서는 셋 다 못 부른다. 서버에서만 동작하는 함수들이다.

고르는 순서
  1. Server Action 인가아니면 revalidateTag 뿐이다
  2. 사용자가 방금 바꿨나예 → updateTag
  3. 다른 데서 바뀌었나예 → revalidateTag(tag, "max")
  4. 캐시는 멀쩡한가예 → 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이 조각조각 흘러오는 것을 본다.

시간이 아니라 사건으로 캐시를 버린다 — 디코드랩(DCODELAB)