읽기는 정적으로, 발행은 즉시
SSG + ISR + 태그 기반 캐싱을 실제 코드로 뜯어봅니다. "전부 굽고, 바뀐 곳만 도려내기"가 어떻게 구현되는지 살펴봅니다. 다시, 출발점 1편에서 이야기했듯 블로그는 읽기가 압도적으로 많은 서비스입니다.
SSG + ISR + 태그 기반 캐싱을 실제 코드로 뜯어봅니다. "전부 굽고, 바뀐 곳만 도려내기"가 어떻게 구현되는지 살펴봅니다.
다시, 출발점
1편에서 이야기했듯 블로그는 읽기가 압도적으로 많은 서비스입니다. 그래서 masonlab의 목표는 명확합니다.
-
읽기는 가능한 한 정적 HTML로 내보낸다 (DB·렌더링 비용 0).
-
그럼에도 글을 발행하면 즉시 반영된다.
-
그 반영은 사이트 전체가 아니라 바뀐 곳만 일어난다.
언뜻 1번과 2번은 충돌하는 것처럼 보입니다. 전부 정적으로 구워 두면 새 글은 어떻게 보여줄까요? 이 긴장을 푸는 것이 이번 편의 주제입니다.
빌드 타임: 발행된 모든 글을 굽는다
먼저 빌드 시점에 발행된 모든 글의 페이지를 미리 만들어 둡니다. App Router에서는 generateStaticParams가 그 역할을 합니다.
// src/app/(site)/posts/[slug]/page.tsx
export const revalidate = 3600; // ISR: 1시간마다 자동 재검증
export const dynamicParams = true; // 빌드 후 생긴 slug도 첫 요청 때 렌더 후 캐시
export async function generateStaticParams() {
const posts = await getAllPublishedSlugs();
return [posts.map](https://posts.map)((p) => ({ slug: p.slug }));
} 세 줄이 각자 역할을 합니다.
-
generateStaticParams— 빌드 때 발행된 글을 전부 정적 페이지로 굽습니다. -
dynamicParams = true— 빌드 이후에 생긴 글도 첫 요청 때 렌더링하고 그 결과를 캐시합니다. 그래서 "빌드 안 된 새 글"도 404가 아니라 정상적으로 보입니다. -
revalidate = 3600— 1시간이 지나면 백그라운드에서 다시 검증합니다. 웹훅이 없어도 결국엔 최신화되는 안전장치입니다.
데이터 레이어: unstable_cache + 태그
DB 조회 함수는 전부 unstable_cache로 감쌉니다. 핵심은 각 캐시에 태그를 붙이는 것입니다. 이 태그가 나중에 "외과적 갱신"의 손잡이가 됩니다.
// src/lib/posts.ts (발췌)
export const CACHE_TAGS = {
posts: "posts",
categories: "categories",
tags: "tags",
} as const;
const DEFAULT_REVALIDATE = 3600;
const cachedBySlug = unstable_cache(*getPostBySlug, ["post-by-slug"], {
tags: [CACHE*TAGS.posts],
revalidate: DEFAULT_REVALIDATE,
});
const cachedList = unstable_cache(*listPublishedPosts, ["published-posts"], {
tags: [CACHE*TAGS.posts],
revalidate: DEFAULT_REVALIDATE,
}); 글 상세, 글 목록, 인기 글, 관련 글 — 글과 관련된 캐시에는 전부 posts 태그를 답니다. 카테고리와 태그 목록은 각각 categories, tags 태그를 갖습니다. 이렇게 해두면 "글이 하나라도 바뀌면 posts 태그만 무효화" 하는 식으로, 한 번의 호출로 영향받는 모든 페이지를 한꺼번에 새로 굽게 만들 수 있습니다.
예약 발행을 다루는 작은 트릭
글에는 "미래 시각으로 예약 발행"이 있습니다. 정적으로 구워 두는 구조에서 예약 글을 어떻게 제때 공개할까요?
비결은 "지금 시각"을 캐시 키에 넣지 않는 것입니다.
// src/lib/posts.ts (발췌)
function nowIso(): string {
return new Date().toISOString();
}
async function *getPostBySlug(slug: string) {
const supabase = createPublicClient();
const { data } = await supabase
.from("posts")
.select(DETAIL*COLUMNS)
.eq("slug", decodeSlug(slug))
.eq("status", "published")
.lte("published_at", nowIso()) // 아직 도래하지 않은 예약 글은 제외
.maybeSingle();
return data ? mapPost(data) : null;
} nowIso()는 캐시 키 인자가 아니라 쿼리 내부에서 계산됩니다. 따라서 예약 글은 발행 시각이 지난 뒤, 다음 재검증(최대 1시간) 때 자연스럽게 공개됩니다. 별도의 크론 작업이 필요 없습니다.
발행 순간: 웹훅이 캐시를 도려낸다
이제 즉시 반영 차례입니다. Supabase의 Database Webhook을 posts 테이블의 insert/update/delete에 연결해 둡니다. 글이 바뀌면 Supabase가 /api/revalidate를 호출합니다.
// src/app/api/revalidate/route.ts
export async function POST(request: NextRequest) {
// 1) 공유 시크릿으로 인증 — 아무나 캐시를 날리지 못하게
const secret =
request.headers.get("x-revalidate-secret") ??
request.nextUrl.searchParams.get("secret");
if (!process.env.REVALIDATE_SECRET || secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ revalidated: false, message: "Unauthorized" }, { status: 401 });
}
// 2) 어떤 글이 바뀌었는지 페이로드에서 slug 추출 (없으면 전체 갱신)
let slug: string | undefined;
try {
const body = await request.json();
slug = body?.record?.slug ?? body?.old_record?.slug;
} catch {
// 수동 트리거 등 JSON 본문이 없으면 전체 갱신으로 폴백
}
// 3) 태그 기반으로 영향받은 캐시를 한 번에 무효화
revalidateTag(CACHE_TAGS.posts, "max");
revalidateTag(CACHE_TAGS.categories, "max");
revalidateTag(CACHE_TAGS.tags, "max");
// 4) 핵심 경로도 콕 집어 갱신
revalidatePath("/");
revalidatePath("/posts");
revalidatePath("/sitemap.xml");
revalidatePath("/rss.xml");
if (slug) revalidatePath(`/posts/${slug}`);
return Response.json({ revalidated: true, slug: slug ?? null });
} 흐름을 정리하면 이렇습니다.
글 발행/수정 → Supabase Webhook → POST /api/revalidate
→ 시크릿 검증
→ revalidateTag("posts") 등으로 관련 캐시 일괄 무효화
→ 다음 방문 때 해당 페이지만 새로 렌더 → 다시 캐시
Next.js 16에서
revalidateTag는 두 번째 인자로 cacheLife를 요구합니다. 여기서는"max"를 써서 다음 재검증까지 최대한 캐시를 유지하도록 했습니다.
두 겹의 안전망
정리하면 갱신은 두 가지 경로로 일어납니다.
-
즉시 경로(웹훅) — 발행하자마자 영향받은 캐시를 무효화. 보통 몇 초 안에 반영됩니다.
-
시간 경로(ISR
revalidate = 3600) — 웹훅이 실패하거나 누락돼도 최대 1시간 뒤에는 자동으로 최신화됩니다.
이 두 겹 덕분에 "정적이라 빠른데, 그렇다고 낡지도 않은" 상태가 유지됩니다. 평상시에는 정적 HTML의 속도를, 발행 순간에는 동적 페이지의 즉시성을 가져가는 셈입니다.
정리
-
빌드 타임에 발행된 글을 전부 굽고(
generateStaticParams), 이후 글은dynamicParams로 흡수합니다. -
모든 DB 조회는
unstable_cache+ 태그로 감싸, 갱신의 손잡이를 만들어 둡니다. -
예약 발행은 "지금"을 캐시 키에서 빼는 트릭으로 크론 없이 처리합니다.
-
발행 순간에는 웹훅이 태그를 무효화해 바뀐 곳만 다시 굽습니다.
-
시간 기반 ISR이 마지막 안전망 역할을 합니다.
다음 편: Markdown 한 줄이 화면이 되기까지, unified 파이프라인을 해부합니다.
코드와 비즈니스, 그 사이에서 배운 것들을 기록하고 공유합니다.