발행 웹훅 — 캐시 자동 갱신
팝업·배너의 예약 시작·종료는 별도 노출 runtime이 처리합니다. 아래 웹훅은 저장 변경에 따른 캐시 무효화 계약이며 시간 경과 자체가 웹훅을 발생시키지는 않습니다.
어드민에서 글을 발행/수정/삭제하면 RootTale이 고객 사이트의 revalidate
엔드포인트로 ES256 서명된 웹훅을 보냅니다. 사이트는 서명을 검증하고
revalidatePath를 호출해 즉시 갱신합니다.
- 별도 webhook secret을 보관할 필요가 없습니다 — 검증은 사이트 스코프 API 키로 JWKS 공개키를 가져와 수행합니다.
- ISR
revalidate = 1800같은 시간 기반 설정은 fallback입니다. 정상 경로는 웹훅입니다. 웹훅이 실패하면(타임아웃·5xx·연결 오류) 플랫폼이 변경을 outbox 에 남겨 1·2·4분… 간격(최대 6시간, 8회)으로 자동으로 다시 보내고, 어드민 "발행 알림 기록" 화면에서 즉시 다시 보낼 수도 있습니다 — 사이트가 잠시 내려가 있어도 변경은 유실되지 않습니다. 웹훅 배선이 끝난 사이트는 fallback 을 길게 잡아도 됩니다(sites/starter는 6시간,21600) — 짧게 잡으면 방문이 있는 페이지마다 그 주기로 재생성이 일어나 함수 실행·ISR 쓰기·API 호출이 늘어날 뿐, 정상 반영 속도는 웹훅이 정합니다.
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 | 설정 > 콘텐츠 유형 |
fetchSitePatterns | SITE_PATTERNS_CACHE_TAG | 콘텐츠 도구 > 공통 블록 |
이들을 한 벌로 묶은 SETTINGS_CACHE_TAGS 배열도 있습니다. 수신 라우트는
설정 저장 신호(theme.updated) 한 번에 이 목록 전체를 지웁니다 — 무엇이
바뀌었는지 웹훅 본문에 없기 때문이며, 쓰지 않는 이름표를 지우는 것은 아무 일도
일어나지 않으므로 해가 없습니다.
revalidateTag를 주입하지 않으면 설정 변경이 그 조회의revalidate초만큼(설정에 따라 30분까지) 늦게 반영됩니다. 라우트는 정상 200을 돌려주고 어드민에도 오류가 안 뜨기 때문에, 이 누락은 "가끔 늦게 반영된다"로만 보입니다. 응답 본문의revalidated.requestedTags가 빈 배열이면 주입이 빠진 것입니다.
2. 어드민에서 알림 보낼 주소 확인
웹훅 주소는 따로 적지 않습니다. 어드민 설정 > 사이트 정보의 공개 도메인과
스테이징 주소에서 https://<도메인>/api/revalidate 가 자동으로 정해지고, 사이트를
만들거나 도메인을 바꾸면 알림 주소도 같이 따라갑니다(스테이징 주소를 지우면 그 목적지도
사라집니다). 켜고 끄기는 설정 > 발행 알림 기록(Webhook) 또는 내 사이트 > (사이트
선택) 의 "글 발행 후 사이트 자동 갱신" 카드에서 운영·스테이징 스위치로 합니다.
처음 켤 때 ES256 키페어가 자동 발급됩니다(고객 측 보관 항목 없음).
/api/revalidate 가 아닌 경로나 임시 미리보기 배포처럼 규칙 밖 주소가 필요하면 같은
카드의 다른 주소 추가로 직접 등록할 수 있습니다.
주소는 "실제로 서비스되는 주소" 여야 합니다
웹훅은 리다이렉트를 따라가지 않습니다(서명이 본문에 묶여 있어 따라가면 안 됩니다). 그래서 다른 주소로 넘어가는 주소를 등록하면 3xx 응답을 받고 그대로 실패합니다 — 화면에는 아무 오류가 안 뜨고, 발행한 글만 조용히 늦게 반영됩니다.
- 사이트 도메인을 바꿨다면 사이트 정보의 공개 도메인을 실제 주소로 고치세요 — 알림 주소는 거기서 자동으로 따라갑니다. 옛 주소가 새 주소로 넘어가도록 해 뒀더라도 웹훅에는 소용이 없습니다.
example.com이www.example.com으로 넘어가는 구성이라면 공개 도메인을 넘어간 뒤의 주소(www.example.com)로 두세요.
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
웹훅 발송 트리거
어드민에서 저장하든 CMS 관리 API(/v1/cms/posts…, MCP 도구 포함)로 쓰든 글 변경은
같은 발송 대기열을 거칩니다. 그래서 이벤트·paths·자동 재시도·실패 기록·공개 화면
반영 확인이 같습니다. 관리 API는 대기열에 적고 곧바로 전달을 요청한 뒤 응답합니다.
그 전달이 실패하면 약 2분 안에 다시 보냅니다.
- 게시물 생성/발행/수정/삭제/발행 취소
- 게시물의 카테고리·태그 변경 (블로그 카드의 카테고리 라벨이 바뀌므로)
- 작성자 정보 변경 → 해당 작성자의 공개 글과 상위 목록 주소를 담은
post.updated. 주소는 저장된 공개 경로를 사용하므로/reviews·계층형/faq등도 포함됩니다. 공개 글이 없거나 조회·경로 상한을 넘으면theme.updated로 전체 캐시를 갱신합니다. - 분류(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는 빈 배열일 수
있습니다. 수신기는 경로 유무와 무관하게 분류 캐시와 루트 레이아웃을 갱신해야 합니다.
createRevalidateRoute는 설정 태그·루트 layout·사이트맵/피드 및 alsoRevalidate를
함께 갱신합니다. 직접 만든 수신기도 같은 규칙을 적용하세요.
연결된 글마다 웹훅을 따로 보내지 않으므로, 카테고리 하나를 바꿔도 수십 번 재검증되는
문제가 없습니다.
글 웹훅의 paths — 콘텐츠 유형 기준
글 관련 이벤트(post.published·post.updated·post.deleted)의 paths는 그 글이
속한 콘텐츠 유형(스트림)의 주소를 담습니다. 공지 유형(/notice) 글이면 /notice와
/notice/{slug}가 오고, /blog는 오지 않습니다.
한 글의 paths에 들어가는 것:
- 유형의 목록 주소 (예:
/notice) - 유형의 글 상세 주소 (예:
/notice/my-post). 한글 주소는 인코딩한 값과 원문을 함께 보냅니다 - 유형의 카테고리 모음 켬 설정이 켜져 있을 때만
{유형 주소}/categories와{유형 주소}/categories/{카테고리} - 유형을 옮긴 글은 옮기기 전·후 주소가 함께 옵니다 — 옮기기 전 목록에서도 글이 빠져야 하기 때문입니다
- 주소가 바뀐 글(slug 변경·카테고리 이동 등으로 상세 주소가 옮겨진 글)은 옛 상세 주소도 함께 옵니다 — 옛 주소의 캐시가 비워져 바로 새 주소로 이동하고, 옛 주소를 참조하던 페이지도 같이 갱신할 수 있게 하기 위해서입니다
category_tree표시 모델은 설정한basePath, 선택한 분류까지의 모든 허브, 분류를 포함한 상세 주소가 옵니다. 모델 preset이article이어도/blog로 바꾸지 않습니다
주소가 안 나오는 경우도 있습니다.
- 주소 없는 유형(페이지 안에서 불러 쓰는 강사·후기 같은 콘텐츠)이나 목록
전용 유형(글마다 상세 주소가 없는 유형)은 상세 주소가 없으므로 그만큼 빠집니다.
유형 자체에 주소가 없으면
paths가 비어 올 수 있습니다 - 글에 유형이 없거나(미분류) 사이트가 아직 콘텐츠 유형을 안 쓰면 예전처럼
/blog·/blog/{slug}·/blog/categories*로 옵니다
paths가 비거나 유형 주소만 와도 걱정할 필요는 없습니다. 위 §캐시 무효화 규칙대로
수신 측은 모든 서명 이벤트에서 블로그 목록과 피드·사이트맵을 함께 갱신하고,
collections를 넘긴 사이트는 각 유형의 목록 주소도 자동으로 갱신합니다.
직접 만든 수신 라우트가 modelKey별 주소를 엄격히 나누는 경우에는 글 이벤트의
paths가 지원하는 주소를 하나도 포함하지 않거나 modelKey의 주소 계열과 다르면
422 같은 non-2xx로 답하세요. 아무 캐시도 지우지 않았는데 200을 반환하면
RootTale은 성공으로 기록하므로 경로 계산 오류가 조용히 숨습니다. modelKey를
경로 대체값으로 써서 성공시키지 말고, 발신 경로와 설정의 불일치를 드러내는 검증값으로
사용해야 합니다.
저수준 검증 — 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 미만)를 위한 기본값입니다.
트러블슈팅
| 증상 | 확인 |
|---|---|
| 발행해도 사이트 미반영 | 어드민 발행 알림 화면의 운영·스테이징 스위치가 켜져 있는지, 사이트 정보의 도메인이 실제 배포 도메인과 같은지 |
전송 기록이 308·301 | 알림 주소가 다른 주소로 넘어가고 있습니다. 사이트 정보의 공개 도메인을 넘어간 뒤의 주소로 고치세요. 이동 주소가 요청 주소와 같으면 Cloudflare SSL 모드가 Flexible입니다(위 §2 참고) |
401 invalid_signature | ROOTTALE_API_KEY가 해당 사이트 스코프 키인지 |
401 timestamp_out_of_window | 서버 시계 동기화 (NTP) |
| 일부 페이지만 갱신 | alsoRevalidate·동적 경로 콜백 누락 |
| 글은 즉시인데 설정(전화·주소·메뉴·디자인)만 늦게 반영 | revalidateTag 주입과 조회의 tags 누락 (§1 "설정 저장") — 응답의 revalidated.requestedTags가 비어 있으면 주입이 빠진 것 |
실패한 갱신 요청 관리
ROOT-ADMIN의 사이트 설정 → 발행 알림 기록에서 미전달 건수, 가장 오래된 변경과
조치가 필요한 원인을 확인할 수 있습니다. 일시적인 네트워크 오류·408·429·5xx는
자동 재시도합니다. Retry-After가 있으면 지정한 대기 시간을 적용합니다(최대 24시간).
422·인증·주소 오류는 원인을 수정한 뒤 지금 다시 보내기를 실행하세요.
이미 성공한 목적지는 유지하고 실패한 목적지의 시도 횟수만 새로 시작합니다.
CMS 관리 API로 쓴 글 변경도 같은 기록에 남고 같은 방식으로 재시도됩니다.
수신측의 알려진 reason은 http_422:model_path_mismatch처럼 전송 기록에 남습니다.
응답 본문이나 고객 콘텐츠는 기록하지 않습니다. 전달 성공은 무효화 요청이 접수됐다는
뜻이므로, 실제 화면 반영이 의심되면 해당 공개 페이지도 확인해야 합니다. 수신 라우트는
같은 요청이 다시 와도 안전하게 캐시를 무효화해야 합니다.