본문 바로가기

콘텐츠 모델과 노출 관리

RootTale의 콘텐츠 관리 단위는 다음과 같습니다.

리소스뜻안정 식별자
콘텐츠 모델페이지·글·정보의 구조와 화면 연결model_key
필드 그룹모델에 붙는 구조화된 추가 필드model_key + group_key
항목모델에 속한 실제 페이지·글·정보post_id
노출 슬롯FRONT가 선언한 배너·팝업 위치 계약slot_key
노출 캠페인슬롯에 표시할 내용·기간·경로exposure_id

source: "code"인 모델·필드 그룹은 개발자 계약입니다. 구조를 API로 바꾸거나 삭제할 수 없습니다. 모델 상태만 활성화·비활성화할 수 있습니다. source: "site"인 리소스는 관리 API로 만들고 수정할 수 있습니다.

권한

전체 자동화에는 API 키 프로필 full_management를 권장합니다. 세부 권한은 다음과 같이 분리됩니다.

권한허용 작업
cms:read모델·필드·항목·노출 슬롯·캠페인 조회
cms:write항목 작성·수정·삭제
cms:publish항목 발행·발행 취소
content-models:writesite 모델·필드 그룹 관리, code 모델 상태 변경
exposures:write노출 캠페인 초안 작성·수정
exposures:publish노출 발행·보관, 발행 중 캠페인 수정

이전 키 프로필에는 새 쓰기 권한이 자동으로 추가되지 않습니다. 자동화에 필요한 권한을 명시해 새 키를 발급하세요.

콘텐츠 모델과 필드 그룹

모델 API:

  • GET|POST /v1/cms/content-models
  • GET|PATCH|DELETE /v1/cms/content-models/{model_key}
  • GET|POST /v1/cms/content-models/{model_key}/field-groups
  • GET|PATCH|DELETE /v1/cms/content-models/{model_key}/field-groups/{group_key}

수정·삭제는 응답의 updated_at을 expected_updated_at으로 다시 보내야 합니다. 다른 사용자가 먼저 바꿨다면 409 version_conflict가 납니다. 사용 중인 모델과 필드 그룹은 삭제할 수 없습니다.

{
  "key": "team-member",
  "label": "구성원",
  "cardinality": "collection",
  "preset": "entity",
  "presentation": {
    "kind": "detail",
    "detailPath": "/team/:slug",
    "templateKey": "team-member"
  },
  "publication": null,
  "definition": {}
}

필드 그룹의 definition은 필드 배열과 검증 규칙을 담습니다. 정의되지 않은 field_values 키, 형식이 틀린 값, 다른 사이트 항목을 가리키는 관계 값은 저장되지 않습니다.

계층형 분류 모델

FAQ·도움말·문서처럼 목록 아래에 여러 단계의 분류 허브가 필요한 모델은 presentation.kind: "category_tree"를 사용합니다. 이 계약은 FAQ 전용 기능이 아니며 1~3단계 분류를 지원합니다.

{
  "key": "faq",
  "cardinality": "collection",
  "preset": "article",
  "presentation": {
    "kind": "category_tree",
    "basePath": "/faq",
    "categoryDepth": 2,
    "templateKey": "faq"
  }
}

기본은 categoryDepth 단계의 말단 분류에만 글을 붙입니다(위 예는 /faq/{영역}/{질환}/{slug}). 하위 분류가 필요한 영역만 나누고 나머지 영역에는 바로 글을 쓰려면 "entryCategory": "leaf"를 둡니다. 그러면 categoryDepth는 최대 단계가 되고, 하위 분류가 없는 분류면 어느 단계든 글을 붙일 수 있습니다.

entryCategory글을 붙일 수 있는 분류FAQ 주소 예
생략·"deepest"categoryDepth 단계 말단만/faq/{영역}/{질환}/{slug}
"leaf"하위가 없는 분류(최대 categoryDepth 단계)/faq/{영역}/{slug}, /faq/{영역}/{질환}/{slug}

leaf 모델에서 글이 붙은 분류에 나중에 하위 분류가 생겨도 이미 붙은 글의 주소는 유지됩니다. 새로 고르거나 다시 발행할 때는 하위가 없는 분류를 골라야 합니다. 같은 상위 아래에서 하위 분류 slug와 글 slug는 겹칠 수 없습니다. FRONT는 공개 글의 path를 그대로 쓰고, 분류 허브 화면에서 하위 분류와 그 분류에 바로 붙은 글을 함께 보여 주면 됩니다.

목록·피드 규칙도 모델이 소유합니다

글 목록의 카테고리 모음, RSS 피드, 허브 주소 형식처럼 예전 collections 설정에만 있던 규칙은 이제 detail·category_tree presentation의 선택 필드로 선언합니다. 모두 생략 가능하며 기본값은 컬렉션이 없을 때의 동작과 같습니다.

필드의미기본값
archives카테고리 모음 주소({목록}/categories/{slug} 또는 direct)를 발행·갱신false
feedRSS 피드에 포함false
categoryPath카테고리 허브 주소 형식 — namespaced = {목록}/categories/{slug}, direct = {목록}/{slug}namespaced
layout목록 레이아웃 힌트(FRONT가 해석, 라우팅과 무관)없음

detail의 목록 주소는 detailPath에서 /:slug를 뗀 부모 경로이고, category_tree는 basePath가 목록입니다. 예전 collections 응답은 호환을 위해 유지되지만 새 사이트는 모델 presentation만 읽으면 됩니다.

목록 화면(listPaths)

글을 목록으로 보여 주는 고정 화면이 따로 있으면 listPaths에 적습니다. 홈의 최근 사례나 회사소개의 인증서 갤러리처럼 모델 주소 밖에 있는 화면이 여기에 해당합니다. data_only·detail·category_tree에서 쓸 수 있습니다.

  • 글을 바꾸면 웹훅 paths에 이 화면들이 함께 실립니다. 상세 주소의 부모 목록은 자동으로 들어가므로 적지 않습니다.
  • 상세 주소가 없는 data_only 모델은 이 화면들만 갱신 대상이 되며, 글을 여러 개 담는 활성 모델(page 프리셋 제외)은 listPaths를 1개 이상 적어야 저장됩니다(없으면 400 bad_request, details.reason: "list_paths_required").
  • 첫 화면이 기준 화면입니다. 그 모델의 모든 글이 보이는 화면(예: /experts)을 먼저 적으세요. ROOT-ADMIN은 기준 화면의 소스(서버가 보낸 화면 데이터 포함)에 현재 제목이 있는지로 공개 반영을 확인하고, 나머지 화면(홈 등 일부만 보이는 화면)은 제목이 없으면 판정을 보류합니다.
  • 상세 모델의 추가 화면은 글 링크 기준으로 확인하고, 링크가 없으면 판정을 보류합니다.
  • 고정 경로만 쓸 수 있습니다(:slug 같은 변수 불가, 최대 20개).
{
  "key": "certificate",
  "preset": "entity",
  "presentation": {
    "kind": "data_only",
    "listPaths": ["/company/credentials", "/company"]
  }
}

RootTale 표준 블로그 주소 — RootTale이 만드는 사이트(스타터·roottale init 시드)의 blog 모델은 아래 규칙 하나를 씁니다: 글 /blog/{category}/{slug}, 카테고리 허브 /blog/{category}, 목록 /blog. 1단계 category_tree이고 글마다 카테고리를 정확히 하나 고릅니다(categoryCardinality: "exactly-one"). 옛 평면 주소 /blog/{slug}와 /blog/categories/{slug}는 FRONT가 정본으로 301 합니다.

{
  "kind": "category_tree",
  "basePath": "/blog",
  "categoryDepth": 1,
  "templateKey": "blog",
  "categoryPath": "direct",
  "categoryCardinality": "exactly-one",
  "archives": true,
  "feed": true
}

직접 만드는 사이트가 평면 상세 주소를 유지해도 됩니다 — 그때는 detail 규칙을 선언하고 FRONT 라우트를 그 규칙에 맞추면 됩니다.

{
  "kind": "detail",
  "detailPath": "/blog/:slug",
  "templateKey": "blog",
  "archives": true,
  "feed": true
}

공개 사이트는 fetchCategories({ collectionKey: "faq" })의 id와 parentId로 루트부터 말단까지의 분류 사슬을 만들고, fetchPosts({ modelKey: "faq" })의 글마다 말단 카테고리를 정확히 하나 연결합니다. 위 예시는 다음 주소를 표현합니다.

  • /faq
  • /faq/headache
  • /faq/headache/migraine
  • /faq/headache/migraine/{slug}

ROOT-ADMIN은 발행·예약 시 선언한 깊이의 말단 분류인지 다시 검사합니다. 부모만 고르거나 복수 분류를 연결한 항목은 발행하지 않습니다. 분류 slug나 부모 관계를 바꾸는 일은 공개 주소 변경이므로 기존 발행 글이 있으면 일반 라우팅 변경 보호를 따릅니다.

코드로 배포하는 모델은 editor에서 저장 구조를 바꾸지 않고 ROOT-ADMIN의 기본 필드 이름과 분류 단계 이름만 바꿀 수 있습니다.

{
  "editor": {
    "labels": {
      "title": "질문",
      "excerpt": "짧은 답변",
      "body": "상세 답변",
      "categories": "질환 분류",
      "tags": "검색 태그"
    },
    "categoryLevels": ["진료 영역", "세부 질환"]
  }
}

categoryLevels의 개수는 categoryDepth와 같아야 합니다. 최종 선택에서는 말단 분류 ID 하나만 기존 카테고리 관계로 저장됩니다.

FAQ처럼 요약이 본문보다 앞서고 분류가 주소를 정하는 모델은 editor.layout을 "guided"로 두면 ROOT-ADMIN 편집 화면이 작성 순서 한 열로 바뀝니다 — 분류 → 제목 → 요약 → 본문 → 커스텀 필드. 주소·검색결과·공유 이미지·표시 옵션은 "공개 설정" 한 곳으로 접히고, 오른쪽 발행 설정에는 작성자·태그만 남습니다. 생략하거나 "default"면 일반 글 배치(제목 → 본문 → 발행 설정 → 검색·공유 설정)입니다. editor.requiredFields에 "excerpt"를 적으면 요약이 비어 있을 때 발행·예약을 막고(초안 저장은 됩니다) 편집기에 필수 표시(*)를 붙입니다. 제목은 항상 필수라 적지 않습니다. 저장 구조·공개 API 응답은 두 옵션 모두 바꾸지 않습니다.

{
  "editor": {
    "layout": "guided",
    "requiredFields": ["excerpt"],
    "labels": { "title": "질문", "excerpt": "요약 답변", "body": "상세 답변" },
    "categoryLevels": ["영역", "질환"]
  }
}

관계형 커스텀 필드는 postType과 함께 modelKey를 지정하면 선택 대상을 같은 콘텐츠 모델로 좁힐 수 있습니다. 예를 들어 relationship 필드에 "postType": "post", "modelKey": "faq"를 선언하면 관련 FAQ만 표시됩니다.

아직 CMS에 글이나 초안이 없는 미래 콘텐츠는 textarea에 내부 콘텐츠 키 형식을 선언해 예약할 수 있습니다. ROOT-ADMIN은 전용 입력기에서 접두사·중복·최대 개수를 검사하며, 저장 API도 같은 규칙을 적용합니다. 값은 공개 fields에 줄바꿈 문자열로 그대로 제공되므로 고객 FRONT가 현재 발행 원장과 대조해 링크 노출을 결정합니다.

{
  "key": "field_related_content_keys",
  "name": "related_content_keys",
  "label": "미발행 FAQ 예약 키",
  "type": "textarea",
  "format": "internal_content_keys",
  "keyPrefix": "faq",
  "maxItems": 5
}

키는 faq.headache.migraine.aura-symptoms처럼 영문 소문자·숫자·한글과 점· 하이픈으로 구성합니다. keyPrefix를 주면 해당 접두사로 시작하는 키만 저장할 수 있습니다. 이 필드 자체는 미발행 URL을 만들지 않습니다.

본문 안의 예약 내부 링크

글 본문에서는 다른 글을 텍스트 표기로 연결할 수 있고, 아직 발행되지 않은 글도 미리 연결해 둘 수 있습니다.

[[internal:{키}|표시 문구]]

키는 대상 글의 정규 공개 경로 조각을 점으로 이은 것입니다. 경로는 사이트맵· 단축링크와 같은 규칙으로 정해지므로 콘텐츠 유형에 관계없이 한 규칙입니다.

공개 경로키
/faq/headache/migraine/aura-symptoms (계층형 분류 모델)faq.headache.migraine.aura-symptoms
/column/headache/my-post (카테고리 주소 컬렉션)column.headache.my-post
/blog/my-post (일반 컬렉션)blog.my-post
/team/hong (상세 프리셋 모델)team.hong

고정 페이지·data_only 모델처럼 글별 상세 주소가 없는 콘텐츠는 대상이 되지 않습니다.

ROOT-ADMIN 편집기 도구 모음의 내부 링크 삽입 버튼이 이 표기를 대신 만들어 줍니다. 작성자는 콘텐츠 유형과 분류를 이름으로 고르고 글(초안 포함)을 목록에서 고르거나, 아직 없는 글의 slug만 적습니다. 각 글의 게시 주소 영역에는 다른 글에서 이 글을 연결할 때 쓰는 키가 복사 버튼과 함께 표시됩니다.

이 표기는 본문 body_json의 일반 텍스트로 저장되며 공개 API도 그대로 내보냅니다. 편집기 안에서는 칩으로 보여 주며(대상 글이 있으면 제목·발행 상태, 없으면 "아직 없는 글"), 저장 형식은 바뀌지 않습니다.

@roottale/cms-renderer-next 를 쓰면 따로 구현할 것이 없습니다. RootTaleBlogPost 는 본문에 표기가 있을 때만 발행 글 경로 색인을 만들어(fetchInternalContentPathIndex, 옛 slug 포함) 링크로 렌더합니다. 대상이 없는 표기는 문구만 남기고 <span class="rt-internal-link rt-internal-link--pending" data-rt-internal-link-pending="키"> 로 표시하므로 배포 QA 에서 찾을 수 있습니다. 링크는 <a class="rt-internal-link"> 입니다. code·pre·이미 링크된 구간과 속성값은 변환하지 않습니다.

// 기본값 internalLinks="auto" — 필요할 때만 색인을 만든다.
<RootTaleBlogPost apiKey={apiKey} slugOrId={slug} />

// 여러 글을 한 페이지에서 그리는 등 색인을 직접 관리하려면:
import { fetchInternalContentPathIndex, RenderTiptap } from "@roottale/cms-renderer-next/server";
const internalLinks = await fetchInternalContentPathIndex({ apiKey, revalidate: 300 });
<RenderTiptap doc={post.bodyJson} internalLinks={internalLinks} />

// 끄려면 internalLinks={false} — 표기가 텍스트 그대로 나옵니다.

렌더러를 쓰지 않는 FRONT 는 직접 해석합니다 — 텍스트 구간에서 표기를 찾아, 현재 발행 원장의 글 경로를 같은 규칙으로 키로 바꿔 대조한 뒤 있으면 링크로, 없으면 표시 문구만 렌더링하세요(@roottale/cms-core 의 findInternalContentLinkTokens· internalContentPathIndex·renderInternalContentLinksInHtml 을 그대로 쓸 수 있습니다).

글의 주소는 계산하지 말고 읽으세요. 공개 글 응답의 path(@roottale/cms-client 에서는 post.path)가 플랫폼이 저장한 정규 공개 경로입니다(예 /column/my-post, 상세 주소가 없는 글은 null). 링크·사이트맵·내부 링크 키에 이 값을 그대로 쓰면 모델 규칙이 바뀌어도 FRONT 코드를 고칠 필요가 없습니다. 내부 링크 키는 이 경로의 조각을 점으로 이은 값과 같습니다.

주소가 바뀐 글도 자동으로 따라가게 하려면 공개 글 응답의 previous_slugs(옛 slug 목록, 최신순 — @roottale/cms-client에서는 previousSlugs)를 함께 쓰세요. 현재 경로의 마지막 조각을 옛 slug로 바꾼 경로도 같은 글의 키로 등록하면, [[internal:blog.old-slug|…]] 처럼 옛 키로 남아 있는 본문도 현재 주소로 렌더됩니다(분류 이동은 이력이 없어 대상 밖).

페이지·글·정보 항목

항목 API는 기존 /v1/cms/posts를 그대로 쓰며 모든 응답에 model_key와 field_values가 포함됩니다.

{
  "model_key": "team-member",
  "title": "홍길동 세무사",
  "slug": "hong-gildong",
  "body_json": {"type":"doc","content":[]},
  "field_values": {
    "position": "대표 세무사",
    "specialties": ["법인세", "상속세"]
  }
}
  • page 모델은 type: "page", article·entity 모델은 type: "post"로 저장됩니다. model_key와 type이 다르면 요청을 거부합니다.
  • entity 항목에는 작성자·카테고리·태그를 붙일 수 없습니다.
  • 작성자를 사용하는 article 모델은 발행·예약 전에 활성 공개 작성자가 필요합니다.
  • 기존 자동화가 model_key를 생략하면 활성 page/article 모델이 정확히 하나일 때만 호환 처리됩니다. 후보가 여러 개면 400 model_key_required입니다.
  • 항목의 모델은 생성 뒤 바꿀 수 없습니다. 다른 모델로 옮기려면 새 항목을 만드세요.

공개 FRONT는 검증된 표시 값 fields를 읽습니다. 관리 자동화는 편집 가능한 원문 field_values를 사용합니다.

노출 슬롯과 캠페인

전용 관리 화면과 예약 시작·종료, 수정안 저장 및 공통 Next.js runtime 연결은 팝업·배너와 예약 표시를 따릅니다. 아래 단일 슬롯 조회는 이전 연동과의 호환용입니다.

노출 슬롯은 FRONT의 사이트 콘텐츠 계약에서 옵니다. 관리 API로 슬롯을 만들거나 수정할 수 없습니다.

  • GET /v1/cms/exposure-slots
  • GET|POST /v1/cms/exposures
  • GET|PATCH /v1/cms/exposures/{exposure_id}
  • POST /v1/cms/exposures/{exposure_id}/publish
  • POST /v1/cms/exposures/{exposure_id}/archive

생성은 항상 초안입니다. 수정·발행·보관은 version을 확인합니다. 발행 응답의 overlap은 같은 슬롯·경로·기간에 겹치는 캠페인 수와 ID를 알려 줍니다. 겹침은 오류가 아닙니다. 새 공통 runtime은 같은 위치의 유효한 항목을 순서대로 캐러셀로 표시합니다. 아래 호환용 단일 조회는 우선순위와 최신순으로 한 건을 선택합니다.

{
  "slot_key": "global-popup",
  "kind": "popup",
  "content": {"variant":"notice","title":"여름 휴무 안내"},
  "starts_at": "2026-08-20T00:00:00.000Z",
  "ends_at": "2026-08-25T00:00:00.000Z",
  "target_paths": ["/"],
  "priority": 10,
  "repeat": "session"
}

MCP와 CLI

MCP는 각 HTTP 작업을 같은 이름의 도구로 제공합니다.

  • 모델: listCmsContentModels, getCmsContentModel, createCmsContentModel, updateCmsContentModel, activateCmsContentModel, deactivateCmsContentModel, deleteCmsContentModel
  • 필드: listCmsFieldGroups, getCmsFieldGroup, createCmsFieldGroup, updateCmsFieldGroup, activateCmsFieldGroup, deactivateCmsFieldGroup, deleteCmsFieldGroup
  • 항목: listManagedCmsPosts, getManagedCmsPost, createCmsPost, updateCmsPost, publishCmsPost, unpublishCmsPost, deleteCmsPost
  • 노출: listCmsExposureSlots, listCmsExposures, getCmsExposure, createCmsExposure, updateCmsExposure, publishCmsExposure, archiveCmsExposure

CLI의 큰 JSON 입력은 camelCase 키를 쓰는 --input-file로 전달합니다.

npx -y @roottale/cms-mcp cli models list
npx -y @roottale/cms-mcp cli models create --input-file ./model.json
npx -y @roottale/cms-mcp cli fields create team-member --input-file ./fields.json
npx -y @roottale/cms-mcp cli entries create \
  --model-key team-member --title "홍길동" --slug hong-gildong \
  --body-file ./body.json --field-values-file ./values.json
npx -y @roottale/cms-mcp cli exposure-slots list
npx -y @roottale/cms-mcp cli exposures create --input-file ./exposure.json
npx -y @roottale/cms-mcp cli exposures publish "<exposure-id>"

posts는 기존 스크립트 호환 별칭입니다. 새 자동화는 entries를 권장합니다.

공개 FRONT 연결

고객 FRONT는 fetchContentModels()와 fetchPosts({ modelKey })로 활성 모델과 항목을 읽습니다. 노출은 개발자가 RootTaleExposureSlot을 배치한 위치에만 표시됩니다.

import { RootTaleExposureSlot } from "@roottale/cms-renderer-next/server";

export default async function Layout({ children }: { children: React.ReactNode }) {
  return <>
    {children}
    <RootTaleExposureSlot
      apiKey={process.env.ROOTTALE_API_KEY!}
      slotKey="global-popup"
      path="/"
      allowedVariants={["notice"]}
      revalidate={60}
    />
  </>;
}

공개 raw API는 다음 세 경로입니다.

  • GET /v1/cms/public/content-models
  • GET /v1/cms/public/posts?model_key=team-member
  • GET /v1/cms/public/categories?collection_key=faq (id, parent_id 포함)
  • GET /v1/cms/public/exposures?slot_key=global-popup&path=%2Fabout