Engineering

읽기는 정적으로, 발행은 즉시

SSG + ISR + 태그 기반 캐싱을 실제 코드로 뜯어봅니다. "전부 굽고, 바뀐 곳만 도려내기"가 어떻게 구현되는지 살펴봅니다. 다시, 출발점 1편에서 이야기했듯 블로그는 읽기가 압도적으로 많은 서비스입니다.

M
Mason
Software Engineer · · 7분

SSG + ISR + 태그 기반 캐싱을 실제 코드로 뜯어봅니다. "전부 굽고, 바뀐 곳만 도려내기"가 어떻게 구현되는지 살펴봅니다.

다시, 출발점

1편에서 이야기했듯 블로그는 읽기가 압도적으로 많은 서비스입니다. 그래서 masonlab의 목표는 명확합니다.

  1. 읽기는 가능한 한 정적 HTML로 내보낸다 (DB·렌더링 비용 0).

  2. 그럼에도 글을 발행하면 즉시 반영된다.

  3. 그 반영은 사이트 전체가 아니라 바뀐 곳만 일어난다.

언뜻 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 파이프라인을 해부합니다.

이 글이 어떠셨나요? 이모지로 반응을 남겨주세요
M
Mason
Software Engineer · masonlab

코드와 비즈니스, 그 사이에서 배운 것들을 기록하고 공유합니다.

댓글 0

아직 댓글이 없어요. 첫 댓글을 남겨보세요.

이런 글은 어때요?