콘텐츠 유형 (Collections)
하나의 글 목록을 **여러 섹션(stream)**으로 나눠 서로 다른 URL·레이아웃으로 보여주는
기능입니다. 가장 흔한 형태는 공지 게시판(/notice) + 블로그(/blog) 분리입니다.
핵심 모델 (2024 ADR-0060 Amendment 1): 글은 하나의 섹션에 속하고(
collection_key), 그 안에서 여러 주제(category)를 가질 수 있습니다. 섹션은 글쓰기 화면의 **"어디에 올릴까요?"**에서 고르고, 주제는 섹션 안의 세부 분류(예: 블로그의 칼럼·소식)입니다.
URL이 어떻게 정해지나
글의 URL = 그 글이 속한 섹션의
basePath+/{slug}— 섹션은 글의collection_key로 정해집니다.
글의 섹션 (collection_key) | basePath | URL |
|---|---|---|
notice (공지) | /notice | /notice/{slug} |
blog (블로그) | /blog | /blog/{slug} |
예: 섹션이 notice인 글 "개강안내" → https://내사이트/notice/개강안내
섹션이 blog인 글 "비문학공부법" → https://내사이트/blog/비문학공부법
섹션별로 함께 만들어지는 경로:
| 경로 | 설명 |
|---|---|
{basePath} | 섹션 목록 (예: /notice, /blog) |
{basePath}/{slug} | 글 상세 |
{basePath}/categories/{slug} | 주제 아카이브 (archives 켠 섹션만) |
/feed.xml | RSS — feed 켠 섹션들의 통합 피드 |
/sitemap.xml | 글마다 소속 섹션 basePath로 정확히 매핑 |
{basePath}/{slug}/opengraph-image | 글별 동적 OG 카드 (배선 시) |
규칙:
- slug은 한글 그대로 됩니다(예:
/blog/비문학독해). 내부적으로 percent-encoding. - 같은 글이 두 섹션에 안 뜸: 공지 글을
/blog/개강안내로 열면 404, 반대도 404(가드). - 섹션(
collection_key)이 없는 글은 sitemap·상세 라우트에서 제외됩니다.
섹션 vs 주제 (자주 헷갈리는 부분)
| 섹션 (collection) | 주제 (category) | |
|---|---|---|
| 무엇 | 글이 사는 곳 (공지 / 블로그) | 섹션 안의 세부 분류 (칼럼 / 소식) |
| 글당 | 딱 하나 (배타적) | 0개 이상 (선택·복수) |
| 정하는 곳 | 글쓰기 "어디에 올릴까요?" | 글쓰기 주제 칩 / 설정 > 분류 |
| 저장 | post.collection_key | 글의 category terms |
| 라우팅 | basePath 결정 (/notice) | 아카이브만 (/blog/categories/칼럼) |
이전 버전은 카테고리로 섹션을 추론했지만, 지금은 섹션이 글에 명시됩니다. 카테고리는 더 이상 어느 섹션에 속하는지를 결정하지 않습니다(주제 아카이브 전용).
어드민에서 설정 (mysite.roottale.com)
설정 > 콘텐츠 유형 에서 섹션을 정의합니다. 빈 상태의 "공지 + 블로그 한 번에 만들기" 버튼으로 표준 두 섹션을 한 번에 만들 수 있습니다. 각 섹션은:
| 항목 | 의미 |
|---|---|
| key | 안정 식별자 (notice, blog) — 사이트 코드와 맞물리므로 보통 고정 |
| 라벨 | 메뉴·작성 화면 표시 이름 (공지/블로그) |
| basePath | URL 앞부분 (/notice, /blog) |
| 주제 | 이 섹션이 제공하는 카테고리(아카이브 범위). 비우면 주제 없음 |
| feed / archives / og | RSS 포함 / 주제 아카이브 / 동적 OG |
| 순서 | 메뉴·표시 순서 |
섹션을 하나도 안 만들면 단일 블로그(/blog) 로 동작합니다(설정 전 기본값).
⚠️ basePath는 "라우트가 있어야" 동작합니다 (데이터=DB, 라우트=코드)
basePath·라벨·주제·플래그는 어드민에서 바꾸면 sitemap·feed·라우팅이 즉시 따라갑니다. 단 basePath에 해당하는 페이지 파일이 사이트에 있어야 실제로 열립니다:
/notice·/blog처럼 이미 라우트가 있는 경로는 어드민만으로 자유롭게 편집 → 동작.- basePath를 완전히 새 경로(예:
/news)로 바꾸면 sitemap엔/news/{slug}가 나가지만 사이트에app/news/[slug]라우트가 없으면 404. 개발자가 라우트를 먼저 추가해야 합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적 라우트 — 아래.)
글을 섹션에 올리기
글쓰기 화면 맨 위 **"어디에 올릴까요?"**에서 공지/블로그 카드를 고릅니다 — 이게 글의
섹션(collection_key)이 됩니다. 그 아래 주제는 고른 섹션의 카테고리만 보입니다
(블로그면 칼럼·소식 등). 주제는 선택이며, 설정 > 분류(taxonomy)에서 미리 만들어 둡니다.
사이트 연동 코드
섹션 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지 방식이 있습니다.
방식 A — 코드 상수 (간단, 고정)
import type { RouteCollection } from "@roottale/cms-renderer-next/routes";
export const COLLECTIONS: RouteCollection[] = [
{ key: "notice", basePath: "/notice", categories: [] }, // 주제 없는 게시판
{
key: "blog",
basePath: "/blog",
categories: ["column", "news"], // 이 섹션의 주제(아카이브 범위)
feed: true,
archives: true,
},
];
categories는 이제 그 섹션이 제공하는 주제 목록(아카이브/blog/categories/{slug}생성 범위)입니다. 섹션 소속은 글의collection_key로 정해지므로, 이 배열은 라우팅 소유권이 아닙니다.
// app/sitemap.ts
import { createSitemapIndex } from "@roottale/cms-renderer-next/routes";
const { generateSitemaps, sitemap } = createSitemapIndex(
{ apiKey, siteUrl, title, collections: COLLECTIONS },
[ /* 정적 경로 */ ],
);
export { generateSitemaps };
export default sitemap;
// app/feed.xml/route.ts
export const dynamic = "force-dynamic";
export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLECTIONS });
방식 B — 어드민에서 동적 로드 (운영자가 직접 편집)
상수 대신 어드민 "콘텐츠 유형" 값을 fetchCollections()로 가져와 팩토리에 resolver
함수로 넘깁니다. 운영자가 어드민에서 섹션을 바꾸면 사이트가 따라갑니다.
import { fetchCollections } from "@roottale/cms-client/server";
const DEFAULT: RouteCollection[] = [ /* 위와 동일 — fail-soft 기본값 */ ];
async function getCollections(): Promise<RouteCollection[]> {
try {
const c = await fetchCollections({ apiKey: process.env.ROOTTALE_API_KEY! });
return c.length ? c : DEFAULT;
} catch {
return DEFAULT; // API 미설정/실패 시 기본값
}
}
const { generateSitemaps, sitemap } = createSitemapIndex(
{ apiKey, siteUrl, title, collections: getCollections },
[ ],
);
export { generateSitemaps };
export default sitemap;
export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCollections });
공개 엔드포인트: GET /v1/cms/public/collections (블로그 조회와 같은 API 키).
응답은 RouteCollection과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
사이트 경계에서 캐시하세요(예: Next fetch(url, { next: { revalidate: 300 } })).
섹션 목록 페이지 (공지/블로그 분리 렌더)
각 섹션 목록은 RootTaleBlogList 에 collection+collections 를 넘겨 그 섹션
글만 보이게 합니다. 이걸 안 주면 컴포넌트가 전체 글을 렌더하므로 공지 글이
/blog 목록에 섞여 들어옵니다(섹션을 나눈 사이트의 가장 흔한 버그). collections
를 주면 글 링크도 소속 섹션 basePath 로 자동 라우팅됩니다(공지→/notice/{slug}).
import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
import { COLLECTIONS } from "@/lib/collections"; // 위 "방식 A" 의 상수
// app/notice/page.tsx — 공지 게시판
export default function NoticePage() {
return (
<RootTaleBlogList
apiKey={process.env.ROOTTALE_API_KEY!}
collection="notice"
collections={COLLECTIONS}
/>
);
}
// app/blog/page.tsx — 블로그
export default function BlogPage() {
return (
<RootTaleBlogList
apiKey={process.env.ROOTTALE_API_KEY!}
collection="blog"
collections={COLLECTIONS}
showCategoryFilter
/>
);
}
RootTaleBlogCategories 도 같은 collection+collections 를 받아 그 섹션의 주제만
집계합니다. 카테고리 칩/사이드바를 섹션별로 나눌 때 쓰세요.
동적 basePath(어드민에서 자유 편집)나 catch-all 라우트를 쓰면
collection값을 요청 경로에서 판정해 넘기세요(아래 "동적 basePath" 참고).
상세 페이지 가드 (섹션 누출 차단)
상세 라우트는 글이 그 섹션 소속인지 확인해 다른 섹션 글이 새는 것을 막습니다.
resolvePostCollection은 글의 collectionKey로 섹션을 해석합니다(공개 API가 글마다
collection_key를 내려줍니다 → cms-client의 CmsPostContent.collectionKey).
import { resolvePostCollection } from "@roottale/cms-renderer-next/routes";
// app/blog/[slug]/page.tsx
const post = await getPost(slug);
if (!post || resolvePostCollection(post, COLLECTIONS)?.key !== "blog") notFound();
상세 페이지 메타데이터 (canonical 공지/블로그 구분)
상세 라우트의 generateMetadata 도 collections 를 넘겨야 canonical 이 글의
섹션에 맞게 나옵니다. path 를 /blog/... 로 하드코딩하면 공지 글이 잘못된
블로그 canonical 을 갖게 됩니다.
import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
// app/blog/[slug]/page.tsx (공지면 app/notice/[slug])
return buildPostMetadata(post, {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
collections: COLLECTIONS, // 글의 collectionKey 로 /notice·/blog canonical 자동 해석
});
자세한 동작은 seo.md 의 "글 메타데이터 → 공지·블로그 다중 스트림" 참고.
revalidate
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
export const POST = createRevalidateRoute({ apiKey, revalidate, collections: COLLECTIONS });
각 섹션 basePath(+ archives면 /categories)와 /feed.xml·/sitemap.xml·/llms.txt를
자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
응답에 warning을 노출합니다.
동적 OG 이미지
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import {
createPostOgImage, OG_IMAGE_SIZE, OG_IMAGE_CONTENT_TYPE,
type ImageResponseLike,
} from "@roottale/cms-renderer-next/routes";
export const size = OG_IMAGE_SIZE;
export const contentType = OG_IMAGE_CONTENT_TYPE;
export default createPostOgImage(
{ apiKey, siteUrl, title, brandLabel: "내 사이트" },
{ ImageResponse: ImageResponse as unknown as ImageResponseLike }, // Next 16 타입 cast
);
Astro 사이트는
@roottale/cms-renderer-astro에서 동일한resolvePostCollection/resolvePostPath/RouteCollection을 import 하고renderBlogList({ collections })로 링크를 섹션별로 라우팅합니다(동등 surface).
slug 변경과 301
글의 slug(/{slug} 부분)는 글 편집 화면에서 바꿉니다. 바꾼 뒤 옛 slug로 들어오면 postRedirectPath로
301 리다이렉트되어 새 slug로 넘어갑니다(검색 순위 보존).
동적 basePath (advanced)
basePath를 코드 수정 없이 어드민에서 자유롭게 바꾸고 싶으면, 정적 app/notice/[slug]
대신 catch-all 동적 라우트 app/[stream]/[slug]/page.tsx(또는 app/[...path])를 두고,
그 안에서 collections를 읽어 요청 경로가 어떤 섹션 basePath인지 판정해 렌더합니다. 그러면
어드민에서 basePath를 /news로 바꿔도 동작합니다. 트레이드오프: 정적 라우트보다 캐시·타입
안전성이 떨어지므로, 섹션 구조가 자주 바뀌는 사이트에만 권장합니다.
자주 막히는 곳
- 글이 안 보여요 → 글에 **섹션(
collection_key)**이 지정됐는지 확인. 글쓰기 "어디에 올릴까요?"에서 섹션을 골라야 합니다. (섹션 없는 글은 sitemap·상세에서 제외.) - 404가 떠요 → 공지 글을
/blog/...로(또는 그 반대로) 열면 가드가 막습니다. 올바른 섹션 basePath로 접근하세요. basePath를 바꿨다면 사이트에 그 라우트 파일이 있는지 확인. - 주제 아카이브가 비어요 → 그 주제(카테고리)가 섹션의
categories(주제 목록)에 있고archives가 켜져 있는지 확인. - sitemap에 글이 빠졌어요 → 그 글에 섹션이 없으면 의도적으로 제외됩니다.