발행 웹훅 (캐시 자동 갱신)
글 발행/수정 시 사이트 캐시를 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) 를 붙이고, 웹훅 수신 시 그 이름표를 지웁니다.
두 곳을 함께 해 주세요.
- 수신 라우트에
revalidateTag를 주입한다 (위 §1 예시) - 설정 조회마다 이름표를 붙인다
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 등록
- 어드민 내 사이트 > (사이트 선택) 페이지로 이동
- "글 발행 후 사이트 자동 갱신" 카드에서:
- 자동 갱신 URL:
https://<사이트 도메인>/api/revalidate - 활성화 체크
- 자동 갱신 URL:
- 저장 — 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권한("읽기 + 쓰기 + 설정 변경", scopesettings: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가 비어 있으면 주입이 빠진 것 |