본문 바로가기

발행 웹훅 (캐시 자동 갱신)

글 발행/수정 시 사이트 캐시를 near-real-time으로 갱신하는 웹훅 설정

발행 웹훅 — 캐시 자동 갱신

어드민에서 글을 발행/수정/삭제하면 RootTale이 고객 사이트의 revalidate 엔드포인트로 ES256 서명된 웹훅을 보냅니다. 사이트는 서명을 검증하고 revalidatePath 호출해 즉시 갱신합니다.

  • 별도 webhook secret을 보관할 필요가 없습니다 — 검증은 사이트 스코프 API 키로 JWKS 공개키를 가져와 수행합니다.
  • ISR revalidate = 1800 같은 시간 기반 설정은 fallback입니다. 정상 경로는 웹훅입니다.

1. revalidate 라우트 추가 (Next.js)

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";

export const POST = createRevalidateRoute({
  apiKey: process.env.ROOTTALE_API_KEY!,
  apiBase: process.env.ROOTTALE_API_BASE,
  revalidate: revalidatePath,
  // 설정(디자인 토큰·상단 메뉴·사업장 정보·블로그 표시 설정) 저장을 즉시
  // 반영하려면 **반드시 넣으세요**. `{ expire: 0 }` 이어야 즉시 만료입니다.
  // 아래 §설정 저장 참고.
  revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
  themeTag: THEME_CACHE_TAG,
  // 글 변경 시 함께 갱신할 추가 경로 (기본: /feed.xml, /sitemap.xml, /blog)
  alsoRevalidate: ["/feed.xml", "/sitemap.xml", "/blog", "/"],
});

export function GET(): Response {
  return new Response("Method Not Allowed", { status: 405 });
}

블로그가 /blog 아닌 경로면 blogBasePath: "/insights" 옵션을 추가하세요.

카테고리/태그 인덱스 같은 동적 경로가 있다면 revalidate 콜백을 확장합니다. 2번째 인자(type)를 반드시 그대로 넘기세요 — 설정 저장 시 팩토리가 revalidate("/", "layout")으로 루트 레이아웃 전역 무효화를 요청하는데, (path) 받는 콜백은 그 "layout" 버려서 정적 페이지가 안 바뀝니다.

function revalidateBlogPath(path: string, type?: "layout" | "page"): void {
  revalidatePath(path, type); // ← type 을 빠뜨리지 마세요
  if (path === "/blog/categories") revalidatePath("/blog/categories/[category]", "page");
  if (path === "/blog/tags") revalidatePath("/blog/tags/[tag]", "page");
}

설정 저장을 즉시 반영하려면 — 캐시 이름표(tags)

글은 경로에 담겨 있어서 revalidatePath 지워집니다. 반면 디자인 토큰· 상단 메뉴·사업장 정보 같은 설정은 경로가 아니라 fetch 응답 캐시(Next.js Data Cache)⁠에 들어 있어서, revalidatePath로는 지워지지 않습니다. 그래서 설정 조회에는 캐시 이름표(tags) 를 붙이고, 웹훅 수신 시 그 이름표를 지웁니다.

두 곳을 함께 해 주세요.

  1. 수신 라우트revalidateTag 주입한다 (위 §1 예시)
  2. 설정 조회마다 이름표를 붙인다
import {
  BUSINESS_CACHE_TAG,
  MENUS_CACHE_TAG,
  THEME_CACHE_TAG,
  fetchBusinessProfile,
  fetchMenu,
  fetchTheme,
} from "@roottale/cms-client/server";

const theme = await fetchTheme({ apiKey, tags: [THEME_CACHE_TAG] });
const business = await fetchBusinessProfile({ apiKey, tags: [BUSINESS_CACHE_TAG] });
const menu = await fetchMenu({ apiKey, slug: "primary", tags: [MENUS_CACHE_TAG] });

이름표 상수는 @roottale/cms-client/server 내보냅니다 — 문자열을 직접 쓰지 말고 상수를 쓰세요(양쪽 이름이 어긋나면 조용히 안 지워집니다).

두 번째 인자는 { expire: 0 } 이어야 합니다. Next는 expire 0이 아닌 프로파일을 stale-while-revalidate 업데이트로 취급해서, 이름표를 지워도 다음 요청이 여전히 옛 값을 받습니다 — "max" 프로파일의 expire 1년이라 "즉시 반영"이 되지 않습니다. updateTag 즉시 만료이지만 서버 액션 전용이라 웹훅 라우트(Route Handler)⁠에서 호출하면 오류가 납니다. 이 경로에서 쓸 수 있는 것은 revalidateTag(tag, { expire: 0 }) 하나뿐입니다.

조회 함수 이름표 상수 어드민 화면
fetchTheme (theme.siteNav 포함) THEME_CACHE_TAG 디자인 · 설정 > 사이트 > 상단 메뉴
fetchBusinessProfile BUSINESS_CACHE_TAG 운영 > 비즈니스 프로필
fetchMenu / fetchMenus MENUS_CACHE_TAG 디자인 > 메뉴
fetchBlogSettings BLOG_SETTINGS_CACHE_TAG 설정 > 블로그
fetchCollections COLLECTIONS_CACHE_TAG 설정 > 콘텐츠 유형

다섯 개를 한 벌로 묶은 SETTINGS_CACHE_TAGS 배열도 있습니다. 수신 라우트는 설정 저장 신호(theme.updated) 한 번에 이 목록 전체를 지웁니다 — 무엇이 바뀌었는지 웹훅 본문에 없기 때문이며, 쓰지 않는 이름표를 지우는 것은 아무 일도 일어나지 않으므로 해가 없습니다.

revalidateTag 주입하지 않으면 설정 변경이 그 조회의 revalidate 초만큼(설정에 따라 30분까지) 늦게 반영됩니다. 라우트는 정상 200을 돌려주고 어드민에도 오류가 안 뜨기 때문에, 이 누락은 "가끔 늦게 반영된다"로만 보입니다. 응답 본문의 revalidated.requestedTags 빈 배열이면 주입이 빠진 것입니다.

2. 어드민에 웹훅 URL 등록

  1. 어드민 내 사이트 > (사이트 선택) 페이지로 이동
  2. "글 발행 후 사이트 자동 갱신" 카드에서:
    • 자동 갱신 URL: https://<사이트 도메인>/api/revalidate
    • 활성화 체크
  3. 저장 — ES256 키페어가 자동 발급됩니다 (고객 측 보관 항목 없음)

URL을 비우고 저장하면 웹훅이 비활성화됩니다.

주소는 "실제로 서비스되는 주소" 여야 합니다

웹훅은 리다이렉트를 따라가지 않습니다(서명이 본문에 묶여 있어 따라가면 안 됩니다). 그래서 다른 주소로 넘어가는 주소를 등록하면 3xx 응답을 받고 그대로 실패합니다 — 화면에는 아무 오류가 안 뜨고, 발행한 글만 조용히 늦게 반영됩니다.

  • 사이트 도메인을 바꿨다면 이 URL도 같이 바꾸세요. 옛 주소가 새 주소로 넘어가도록 해 뒀더라도 웹훅에는 소용이 없습니다.
  • example.com www.example.com으로 넘어가는 구성이라면 넘어간 뒤의 주소(https://www.example.com/api/revalidate)⁠를 등록하세요.

Cloudflare를 쓴다면 SSL 모드가 Flexible이면 안 됩니다

도메인이 Cloudflare에 있고 SSL/TLS 모드가 Flexible이면, Cloudflare가 원본 서버에 평문 HTTP로 붙습니다. Vercel·Netlify 등 대부분의 호스팅은 HTTP 요청을 HTTPS로 되돌리므로 자기 자신으로 무한히 넘어가는 308이 되고, 웹훅은 매번 실패합니다.

브라우저로는 멀쩡해 보일 수 있습니다(DNS가 Cloudflare를 거치지 않는 설정이면 사람이 접속할 때는 이 경로를 안 타기 때문입니다). SSL/TLS 모드를 Full (strict) 로 두세요 — 보안상으로도 그쪽이 맞습니다.

확인 방법: 아래 응답이 308이고 이동 주소가 요청한 주소와 같으면 이 경우입니다.

curl -sI -X POST https://<사이트 도메인>/api/revalidate

웹훅 발송 트리거

  • 게시물 생성/발행/수정/삭제/발행 취소
  • 게시물의 카테고리·태그 변경 (블로그 카드의 카테고리 라벨이 바뀌므로)
  • 분류(taxonomy) 용어 생성/수정/순서 변경/삭제
  • 디자인 토큰 변경 → theme.updated
  • 상단 메뉴·헤더/푸터 메뉴·공지 배너·상담바·문의 게시판 설정 변경 → theme.updated
  • 사업장 정보(비즈니스 프로필) 변경 → theme.updated
  • 블로그 표시 설정 변경 (TOC, 작성자/발행일, 작성자 카드) → theme.updated
  • 콘텐츠 유형(스트림) 변경 → theme.updated
  • 설정 쓰기 API(PATCH /v1/cms/settings/*) 호출 → theme.updated
  • 수동 revalidation API 호출 (event 지정 — 아래 §"수동 revalidation API")

설정 저장은 전부 theme.updated 하나로 옵니다 — 무엇이 바뀌었는지는 본문에 없습니다. 그래서 수신 측은 이 이벤트에서 설정 이름표를 통째로 지웁니다.

캐시 무효화 규칙

수신 측은 다음을 보장해야 합니다 (createRevalidateRoute 기본 처리):

  • 모든 서명된 이벤트에서 블로그 목록(/blog) revalidate — 글 본문이 안 바뀌어도 카드 메타(카테고리 라벨 등)⁠가 바뀔 수 있음
  • 상세 페이지는 현재 slug + payload의 paths 힌트 경로 모두 revalidate
  • 홈에 최신 글 섹션이 있으면 alsoRevalidate / 포함
  • theme.updated(설정 저장)⁠는 캐시 이름표 전체 + revalidatePath("/", "layout") + 본문 paths 로 처리 — 설정은 모든 페이지에 깔리므로 경로만 열거하면 정적 페이지가 빠지고, 반대로 /sitemap.xml·/feed.xml 같은 route handler 는 레이아웃 무효화에 딸려 온다고 보장할 수 없어 paths 함께 씁니다

분류 변경은 taxonomy.updated 이벤트 한 번으로 전달됩니다. payload의 paths에는 영향을 받는 글 상세 경로와 카테고리 모음 경로가 중복 없이 들어갑니다. 연결된 글마다 웹훅을 따로 보내지 않으므로, 카테고리 하나를 바꿔도 수십 번 재검증되는 문제가 없습니다.

글 웹훅의 paths — 콘텐츠 유형 기준

글 관련 이벤트(post.published·post.updated·post.deleted)⁠의 paths 그 글이 속한 콘텐츠 유형(스트림)⁠의 주소를 담습니다. 공지 유형(/notice) 글이면 /notice /notice/{slug} 오고, /blog 오지 않습니다.

한 글의 paths 들어가는 것:

  • 유형의 목록 주소 (예: /notice)
  • 유형의 글 상세 주소 (예: /notice/my-post). 한글 주소는 인코딩한 값과 원문을 함께 보냅니다
  • 유형의 카테고리 모음 켬 설정이 켜져 있을 때만 {유형 주소}/categories {유형 주소}/categories/{카테고리}
  • 유형을 옮긴 글은 옮기기 전·후 주소가 함께 옵니다 — 옮기기 전 목록에서도 글이 빠져야 하기 때문입니다

주소가 안 나오는 경우도 있습니다.

  • 주소 없는 유형(페이지 안에서 불러 쓰는 강사·후기 같은 콘텐츠)이나 목록 전용 유형(글마다 상세 주소가 없는 유형)⁠은 상세 주소가 없으므로 그만큼 빠집니다. 유형 자체에 주소가 없으면 paths 비어 올 수 있습니다
  • 글에 유형이 없거나(미분류) 사이트가 아직 콘텐츠 유형을 안 쓰면 예전처럼 /blog·/blog/{slug}·/blog/categories* 옵니다

paths 비거나 유형 주소만 와도 걱정할 필요는 없습니다. 위 §캐시 무효화 규칙대로 수신 측은 모든 서명 이벤트에서 블로그 목록과 피드·사이트맵을 함께 갱신하고, collections 넘긴 사이트는 각 유형의 목록 주소도 자동으로 갱신합니다.

저수준 검증 — verifyRootTaleWebhook

createRevalidateRoute 못 쓰는 환경(다른 프레임워크 등)⁠은 @roottale/cms-client/webhook으로 직접 검증합니다:

import { SETTINGS_CACHE_TAGS } from "@roottale/cms-client/server";
import { verifyRootTaleWebhook } from "@roottale/cms-client/webhook";

export async function POST(request: Request) {
  const rawBody = await request.text(); // 반드시 파싱 전 raw로 검증
  const result = await verifyRootTaleWebhook({
    rawBody,
    headers: request.headers,
    apiKey: process.env.ROOTTALE_API_KEY!,
  });
  if (!result.ok) {
    return Response.json({ reason: result.reason }, { status: 401 });
  }
  const payload = JSON.parse(rawBody) as { paths?: unknown };
  const paths = Array.isArray(payload.paths)
    ? payload.paths.filter((path): path is string => typeof path === "string")
    : [];
  // result.event:
  //   "post.published" | "post.updated" | "post.deleted" | "taxonomy.updated"
  //   | "theme.updated"  ← 설정 저장(디자인 토큰·상단 메뉴·사업장 정보 등)
  //
  // "theme.updated"는 캐시 이름표 + 레이아웃 무효화가 본체이고, 본문 paths 는
  // 그 위에 더합니다 — 콘텐츠 유형 저장은 /sitemap.xml·/feed.xml·/llms.txt 를
  // 보내는데 이건 route handler 라 레이아웃 무효화로 덮인다고 볼 수 없습니다.
  // (위 §1 "설정 저장" 참고)
  if (result.event === "theme.updated") {
    // { expire: 0 } = 즉시 만료. "max" 등 다른 프로파일은 SWR 업데이트라 다음
    // 요청이 옛 값을 받습니다. updateTag 는 서버 액션 전용이라 여기서 throw.
    for (const tag of SETTINGS_CACHE_TAGS) revalidateTag(tag, { expire: 0 });
    revalidatePath("/", "layout"); // 정적 페이지까지 반영
    for (const path of paths) revalidatePath(path); // 콘텐츠 유형 저장의 sitemap·feed
    return Response.json({ ok: true });
  }
  for (const path of paths) revalidatePath(path);
  return Response.json({ ok: true });
}

실패 reason 값: missing_signature, invalid_signature, expired, body_hash_mismatch, timestamp_out_of_window, replay_seen 등. 옵션으로 expectedSiteId(멀티 사이트 하드닝), consumeJti(replay 방지 저장소), timestampWindowSec(기본 300초)⁠을 지정할 수 있습니다.

수동 revalidation API

배포 직후 등 강제 갱신이 필요할 때:

POST https://api.roottale.com/v1/cms/revalidate
Authorization: Bearer rtlk_cust_...
Content-Type: application/json

{
  "event": "post.updated",
  "paths": ["/blog", "/blog/my-post"],
  "slug": "my-post"
}

event 넣을 수 있는 값은 post.published · post.updated · post.deleted · theme.updated 넷입니다. 생략하면 post.updated입니다.

설정을 직접 바꿨을 때 — theme.updated

사업장 정보·상단 메뉴를 설정 쓰기 API로 바꾸면 이 신호는 저장과 함께 자동으로 나갑니다. 직접 보낼 일은 그 밖의 경우입니다 — 예를 들어 알림 주소를 나중에 등록해서 저장 시점의 신호를 놓쳤거나, 수신 라우트를 고친 뒤 캐시 이름표를 한 번 비우고 싶을 때입니다.

POST https://api.roottale.com/v1/cms/revalidate
Authorization: Bearer rtlk_cust_...
Content-Type: application/json

{ "event": "theme.updated" }

이 값만 한 단계 위 권한이 필요합니다. read_write_settings 권한("읽기 + 쓰기 + 설정 변경", scope settings:write)⁠으로 발급한 키여야 합니다. 글쓰기 키(read_write)로 보내면 403 insufficient_scope이고, 나머지 세 값은 지금까지 그대로 글쓰기 키로 보낼 수 있습니다.

권한을 나눈 이유는 비용입니다. theme.updated 경로 몇 개가 아니라 설정 이름표 전체 + 루트 레이아웃(그 아래 모든 페이지) 을 다시 만들게 합니다. 글을 쓰라고 내준 키가 사이트 전체 재생성을 반복해서 돌릴 수 있으면 안 됩니다.

paths 함께 보내면 이름표·레이아웃 무효화 위에 그 경로들이 더해집니다. 비워 두면 홈(/)이 기본으로 들어갑니다 — theme.updated 분기가 없는 옛 수신 라우트(@roottale/cms-renderer-next 0.41.0 미만)⁠를 위한 기본값입니다.

트러블슈팅

증상 확인
발행해도 사이트 미반영 어드민의 자동 갱신 URL·활성화 체크, 배포 도메인 일치 여부
전송 기록이 308·301 등록한 주소가 다른 주소로 넘어가고 있습니다. 넘어간 뒤의 주소를 등록하세요. 이동 주소가 요청 주소와 같으면 Cloudflare SSL 모드가 Flexible입니다(위 §2 참고)
401 invalid_signature ROOTTALE_API_KEY 해당 사이트 스코프 키인지
401 timestamp_out_of_window 서버 시계 동기화 (NTP)
일부 페이지만 갱신 alsoRevalidate·동적 경로 콜백 누락
글은 즉시인데 설정(전화·주소·메뉴·디자인)⁠만 늦게 반영 revalidateTag 주입과 조회의 tags 누락 (§1 "설정 저장") — 응답의 revalidated.requestedTags 비어 있으면 주입이 빠진 것