EmDash는 데이터베이스 우선 콘텐츠 모델을 사용합니다. 스키마 정의는 코드가 아니라 DB에 있습니다. 런타임 스키마 변경과 비개발자 친화적 설정을 가능하게 하는 근본적인 설계 선택입니다.
스키마를 데이터로
Strapi나 Keystatic 같은 전통적 CMS는 코드에서 스키마를 정의합니다.
// 전통적 접근 — 코드 속 스키마
const posts = collection({
fields: {
title: text({ required: true }),
content: richText(),
},
});
EmDash는 동일한 정보를 테이블에 저장합니다.
-- _emdash_collections 테이블
INSERT INTO _emdash_collections (slug, label)
VALUES ('posts', 'Blog Posts');
-- _emdash_fields 테이블
INSERT INTO _emdash_fields (collection_id, slug, type, required)
VALUES
('coll_abc', 'title', 'string', true),
('coll_abc', 'content', 'portableText', false);
두 방식 모두 같은 콘텐츠 구조를 정의합니다. 차이는 그 구조가 어디에 있고 어떻게 바꿀 수 있는지입니다.
왜 데이터베이스 우선인가
런타임 변경
코드 변경이나 재빌드 없이 콘텐츠 타입을 만들고 편집합니다. 비개발자도 관리 UI에서 데이터 모델을 설계할 수 있습니다.
실제 SQL 열
WordPress의 EAV와 달리 필드마다 실제 열이 있습니다. 인덱스, 외래 키, 쿼리 최적화가 가능합니다.
자기 설명적
DB 도구로 스키마를 직접 검사할 수 있습니다. 코드를 파싱할 필요가 없습니다.
마이그레이션 경로
버전 관리를 위해 스키마를 JSON으로 보냅니다. 새 환경에서 가져옵니다.
스키마 테이블
콘텐츠 구조를 정의하는 시스템 테이블이 두 개입니다.
컬렉션 테이블
CREATE TABLE _emdash_collections (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
label_singular TEXT,
description TEXT,
icon TEXT,
supports JSON,
source TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
source 필드는 컬렉션이 어떻게 만들어졌는지 추적합니다.
| 값 | 설명 |
|---|---|
manual | 관리 UI로 생성 |
template:blog | 템플릿 시드 파일로 생성 |
import:wordpress | WordPress에서 가져옴 |
discovered | 기존 데이터에서 자동 발견 |
필드 테이블
CREATE TABLE _emdash_fields (
id TEXT PRIMARY KEY,
collection_id TEXT REFERENCES _emdash_collections(id),
slug TEXT NOT NULL,
label TEXT NOT NULL,
type TEXT NOT NULL,
column_type TEXT NOT NULL,
required INTEGER DEFAULT 0,
unique_field INTEGER DEFAULT 0,
default_value TEXT,
validation JSON,
widget TEXT,
options JSON,
sort_order INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, slug)
);
콘텐츠 테이블
각 컬렉션은 ec_ 접두사 테이블을 갖습니다.
CREATE TABLE ec_products (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE,
status TEXT DEFAULT 'draft',
author_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
published_at TEXT,
deleted_at TEXT,
version INTEGER DEFAULT 1,
title TEXT NOT NULL,
price REAL
);
런타임 스키마 변경
관리 UI에서 필드를 추가하면 EmDash는 다음을 수행합니다.
_emdash_fields에 레코드 삽입ALTER TABLE ec_<collection> ADD COLUMN ...실행- 검증용 Zod 스키마 재생성
SQLite에서 런타임에 지원되는 ALTER TABLE 작업:
| 작업 | 지원 |
|---|---|
| 열 추가 | 예 |
| 열 이름 변경 | 예 |
| 열 삭제 | 예 (SQLite 3.35+) |
| 열 타입 변경 | 아니오 (테이블 재구축 필요) |
타입 변경 시 EmDash는 테이블 재구축을 투명하게 처리합니다.
스키마와 콘텐츠 분리
| 관심사 | 위치 | 테이블 |
|---|---|---|
| 스키마 | 시스템 테이블 | _emdash_collections, _emdash_fields |
| 콘텐츠 | 컬렉션별 테이블 | ec_posts, ec_products 등 |
| 미디어 | 별도 테이블 + 스토리지 | media 테이블 + R2/S3 |
| 설정 | 옵션 테이블 | site: 접두사의 options |
이 분리의 의미:
- 콘텐츠 없이 스키마만 내보낼 수 있음
- 스키마 간에 콘텐츠를 마이그레이션할 수 있음
- 시스템 테이블에 사용자 데이터가 섞이지 않음
런타임 검증
EmDash는 시작 시 DB 필드 정의로부터 Zod 스키마를 만들고, 생성·갱신마다 검증합니다.
function buildSchema(fields: Field[]): ZodSchema {
const shape: Record<string, ZodType> = {};
for (const field of fields) {
let zodType = fieldTypeToZod(field.type);
if (field.required) zodType = zodType.required();
if (field.validation?.min !== undefined) zodType = zodType.min(field.validation.min);
shape[field.slug] = zodType;
}
return z.object(shape);
}
TypeScript 연동
데이터베이스 스키마에서 TypeScript 타입을 생성합니다:
# 데이터베이스에서 스키마를 가져와 타입 생성
npx emdash types
.emdash/types.ts가 생성됩니다:
// .emdash/types.ts (생성됨)
export interface Post {
title: string;
content: PortableTextBlock[];
excerpt?: string;
featuredImage?: string;
}
export interface Product {
title: string;
price: number;
quantity: number;
}
// 쿼리 함수의 타입 오버로드
declare module "emdash" {
export function getEmDashCollection(
type: "posts",
): Promise<{ entries: ContentEntry<Post>[]; error?: Error }>;
export function getEmDashEntry(
type: "products",
id: string,
): Promise<{ entry: ContentEntry<Product> | null; error?: Error; isPreview: boolean }>;
}
개발자와 비개발자 워크플로
개발자는 CLI를 사용합니다:
# 스키마 가져오기, 타입 생성
npx emdash types
# 스키마를 JSON으로 내보내기
npx emdash export-seed > seed.json
비개발자는 관리 UI만 사용합니다:
- 관리 패널에서 Content Types 열기
- Add Collection 클릭
- 시각적 빌더로 필드 정의
- 즉시 콘텐츠 작성 시작
두 경로 모두 동일한 DB 테이블을 수정합니다.
시드 파일
템플릿과 내보내기는 이식 가능한 스키마 정의를 위해 JSON 시드 파일을 사용합니다:
{
"version": "1",
"collections": [
{
"slug": "posts",
"label": "Blog Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "preview"],
"fields": [
{ "slug": "title", "type": "string", "required": true },
{ "slug": "content", "type": "portableText" },
{ "slug": "featuredImage", "type": "image" }
]
}
],
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
"menus": [{ "name": "primary", "label": "Primary Navigation" }]
}
프로그래밍 방식으로 시드 파일을 적용합니다:
import { applySeed, validateSeed } from "emdash/seed";
import seedData from "./.emdash/seed.json";
// 먼저 검증
const { valid, errors } = validateSeed(seedData);
// 적용 (멱등 — 재실행 안전)
await applySeed(db, seedData, {
includeContent: true,
onConflict: "skip", // 'skip' | 'update' | 'error'
});
다른 접근과 비교
| 접근 | 스키마 위치 | 런타임 변경 | 타입 |
|---|---|---|---|
| EmDash | DB | 예(전체) | DB에서 생성 |
| WordPress | PHP + EAV | 제한적(메타) | 없음 |
| Strapi | 코드 | 아니오(재빌드) | 빌드 시 생성 |
| Sanity | 코드 | 아니오(배포 필요) | 내장 |
| Directus | DB | 예(전체) | DB에서 생성 |
EmDash는 Directus 모델을 따릅니다: 데이터베이스 우선에 선택적 타입 생성. 타입 안전 개발을 지원하면서도 최대한의 유연성을 제공합니다.