Draft와 Release를 가르는 선 — CQRS식 콘텐츠 프로젝션
초안을 저장했을 뿐인데 공개 화면이 바뀌면, 사용자는 도구를 불신하게 돼요. 그게 정확히 어디서 깨지는지 실제 스키마와 쿼리로 뜯어보고, 잠금을 if문이 아니라 SQL 제약에 맡긴 이야기입니다.

초안을 저장했을 뿐인데 공개 화면이 바뀌어 있다면, 개발자는 원인을 찾겠죠. 가족은 그냥 무서워합니다. 그리고 다시는 저장을 안 눌러요.
이 플랫폼에서 제일 지키고 싶었던 약속은 한 문장이었어요. 발행 전에는 공개 화면이 절대 바뀌지 않는다.
당연한 말 같죠? 그런데 흔한 테이블 설계에서는 이게 은근히 잘 깨집니다. 오늘은 그 "은근히"가 정확히 어디서 깨지는지, 그리고 그걸 막으려고 스키마를 어떻게 갈랐는지 이야기할게요.
(이 글에는 실제 스키마와 쿼리가 나와요. 제 개인 프로젝트 코드라 그대로 옮깁니다.)
먼저, 가장 짧은 길
콘텐츠 저장을 처음 설계하면 대개 이 모양이 나와요. 리소스 한 행에 현재 상태를 다 얹는 겁니다.
content_resource
resource_id
slug
status -- draft / published / archived
visibility
current_revision -- 지금 보여줄 revision
...그리고 Studio도, 공개 화면도 이 행을 읽습니다. 짧고, 빠르고, 이해하기 쉬워요.
이제 여기서 무슨 일이 벌어지는지 보죠.
| 사용자가 한 일 | 바뀌는 값 | 공개 화면에서 벌어지는 일 |
|---|---|---|
| 글을 보관함으로 옮김 | status = archived | 발행도 안 했는데 공개 목록에서 글이 사라짐 |
| 초안을 저장함 | current_revision +1 | 발행 안 한 문장이 공개 상세에 뜸 |
| 주소를 고침 | slug 변경 | 기존 공개 URL이 즉시 404 |
| 공개 범위를 바꿔봄 | visibility 변경 | 비공개 전환이 곧바로 반영 |
네 줄 다 사용자는 발행 버튼을 누른 적이 없습니다.
이게 무서운 이유는 버그가 아니라는 거예요. 설계대로 동작한 거죠. 한 행을 두 사람이 읽고 있으니까요.
(archive 한 번 눌렀는데 공개 목록에서 글이 사라진다 — 그게 이 지름길의 미래예요.)
그럼 어떻게 막을까 — 후보 세 개
후보 1. 코드에서 조건을 건다
공개 쿼리에 status = published AND published_revision 같은 조건을 붙이는 방법이에요. 컬럼 하나 더 두고요.
문제는 이 조건을 모든 쿼리가 기억해야 한다는 겁니다. 목록, 상세, 사이트맵, 검색 색인, RSS… 한 군데만 빠뜨리면 그 화면만 조용히 미래를 보여줘요.
그리고 이런 실수는 리뷰에서 잘 안 잡혀요. 빠진 조건은 눈에 안 띄거든요.
후보 2. 공개용 테이블에 발행 시점 스냅샷을 복사한다
발행할 때 공개용 행을 만들어 두는 방식이에요. 방향은 맞습니다. 실제로 제가 고른 것도 이 계열이고요.
다만 순진하게 만들면 롤백이 어려워요. 공개용 행을 덮어쓰면 이전 발행본이 사라지니까요.
후보 3. 전체 스냅샷을 릴리스마다 통째로 남긴다
제가 고른 방식입니다. 릴리스마다 그 시점의 공개본 전체가 한 벌씩 남아요. 저장 공간은 더 쓰지만, 롤백이 포인터 되돌리기가 되고 "그때 그 화면"이 재현됩니다.
이 플랫폼은 이미 런타임 스냅샷을 불변으로 굳히는 습관이 있었어요. 콘텐츠도 같은 규칙을 따르게 한 거죠.
그래서 읽기 모델을 둘로 갈랐습니다

실제 스키마는 이렇게 생겼어요. 먼저 편집 쪽입니다.
CREATE TABLE content_draft_projection (
workspace_id text NOT NULL,
site_id text NOT NULL,
resource_id text NOT NULL,
type_id text NOT NULL,
current_revision integer NOT NULL,
status text NOT NULL,
slug text,
visibility text NOT NULL,
index_json jsonb NOT NULL,
version integer NOT NULL,
updated_at timestamptz NOT NULL,
PRIMARY KEY (workspace_id, site_id, resource_id)
);한 리소스당 딱 한 행이에요. 지금 이 순간의 상태죠. Studio와 미리보기와 편집 모드가 이걸 읽습니다.
그리고 공개 쪽. 딱 한 컬럼이 다릅니다.
CREATE TABLE release_content_projection (
workspace_id text NOT NULL,
site_id text NOT NULL,
release_id text NOT NULL, -- ← 이 한 줄이 전부를 바꿉니다
resource_id text NOT NULL,
type_id text NOT NULL,
revision integer NOT NULL,
status text NOT NULL,
slug text,
visibility text NOT NULL,
index_json jsonb NOT NULL,
content_hash text NOT NULL,
PRIMARY KEY (workspace_id, site_id, release_id, resource_id)
);기본 키에 release_id가 들어가 있죠. 그래서 같은 글이 릴리스마다 각자의 행을 가집니다. 덮어쓰기가 아니라 쌓기예요.
공개 런타임은 활성 릴리스의 행만 읽습니다. 편집 쪽에서 무슨 일이 일어나든 이 행은 안 움직여요.
발행은 계산이 아니라 복사입니다
여기서 재미있는 결정을 하나 했어요. 발행할 때 프로젝션을 다시 계산하지 않습니다.
글이 300편이면 300편을 다시 만드는 게 아니라, 이전 릴리스 것을 통째로 복제하고 바뀐 것만 빼요. 실제 쿼리가 이겁니다.
INSERT INTO release_content_projection (
workspace_id, site_id, release_id, resource_id, type_id,
revision, status, slug, visibility, index_json, content_hash, updated_at
)
SELECT
workspace_id, site_id,
$4, -- 새 릴리스 id
resource_id, type_id,
revision, status, slug, visibility, index_json, content_hash, updated_at
FROM release_content_projection
WHERE workspace_id = $1
AND site_id = $2
AND release_id = $3 -- 이전 릴리스
AND NOT (
(resource_id = $5 AND type_id = $6) -- 이번에 바뀐 것만 제외
);제외된 자리에는 draft 스냅샷을 새로 넣습니다. 왕복 두 번이면 새 릴리스 한 벌이 완성돼요.
그리고 이전 릴리스 행은 그대로 남아 있습니다. 그래서 롤백은 활성 릴리스 포인터를 되돌리는 일이에요. 데이터를 복구하는 게 아니라요.
잠금은 if문이 아니라 SQL 조건이에요
여기에 구멍이 하나 있었어요.
저장 정책상 아직 잠기지 않은 마지막 revision은 UPDATE로 교체될 수 있습니다. 타이핑할 때마다 행이 쌓이면 이력이 스팸이 되니까, 짧은 시간 안의 편집은 같은 revision을 덮어쓰거든요.
그런데 그 revision이 이미 릴리스에 담긴 것이라면? "불변 릴리스"가 거짓말이 됩니다. 공개된 글의 내용이 소리 없이 바뀌는 거예요.
이걸 애플리케이션 코드에서 검사할 수도 있어요. "잠겼는지 확인하고 아니면 UPDATE." 그런데 그 확인과 UPDATE 사이의 틈에서 발행이 일어나면요?
그래서 검사를 UPDATE 문 안으로 집어넣었습니다.
UPDATE content_revision
SET payload = $6, content_hash = $7, coalesce_key = $9
WHERE workspace_id = $1
AND site_id = $2
AND resource_id = $3
AND type_id = $4
AND revision = $5
AND NOT EXISTS ( -- ← 잠금이 여기 있습니다
SELECT 1
FROM release_content_projection
WHERE release_content_projection.workspace_id = content_revision.workspace_id
AND release_content_projection.site_id = content_revision.site_id
AND release_content_projection.resource_id = content_revision.resource_id
AND release_content_projection.type_id = content_revision.type_id
AND release_content_projection.revision = content_revision.revision
);참조되는 순간 이 UPDATE는 0행을 고칩니다. 그리고 0행이면 호출한 쪽이 "새 revision을 만들어야 하는구나"로 갑니다.
규칙을 기억력이 아니라 스키마에 넣으면, 잊어버릴 수가 없어요.
슬러그 유일성도 두 세계가 다릅니다
디테일 하나 더. 주소 중복 방지도 두 테이블에서 조건이 달라요.
-- 편집 쪽: 보관한 글은 주소를 비켜준다
CREATE UNIQUE INDEX content_draft_projection_scope_slug_unique
ON content_draft_projection (workspace_id, site_id, type_id, slug)
WHERE slug IS NOT NULL AND status <> 'archived';
-- 공개 쪽: 같은 릴리스 안에서, 공개된 것끼리만
CREATE UNIQUE INDEX release_content_projection_scope_slug_unique
ON release_content_projection (workspace_id, site_id, release_id, type_id, slug)
WHERE slug IS NOT NULL AND status = 'published';편집 쪽은 보관한 글이 주소를 붙잡고 있으면 안 되니 archived를 빼고, 공개 쪽은 릴리스 단위로 published끼리만 봅니다.
부분 인덱스 조건이 다르다는 건 두 세계의 규칙이 실제로 다르다는 뜻이에요. 한 테이블로 합쳤다면 이 둘 중 하나는 포기했어야 했을 거예요.
삭제도 여기서 막힙니다
릴리스가 참조 중인 콘텐츠를 물리 삭제하려고 하면 이렇게 거절당해요.
Cannot delete content locked by a release projection: <resource>그래서 삭제의 기본값을 보관(archive)으로 둘 수 있게 됐어요. 공개된 적 있는 글은 지워지지 않고, 지워진 글은 공개된 적이 없습니다.
예외를 하나 열었어요, 그리고 문을 좁혔습니다
정직하게 적을 게 있어요. 이 규칙에 예외를 하나 열었습니다.
사이트 소개 문구나 폴더·태그 구조 같은 "사이트 정체성"은 공개 화면도 draft를 읽어요. 소개 한 줄 고치는 데 사이트 전체 발행을 요구하는 건 과했거든요.
대신 예외의 문을 아주 좁게 냈습니다. 이 예외는 플러그인이나 페이지 문서가 선언할 수 없고, 공개 호스트만 주입할 수 있어요. 그것도 바인딩 id와 타입 id가 둘 다 일치할 때만요.
예외를 열 때 신뢰 경계를 같이 긋지 않으면, 그건 "일반 글도 draft로 읽히는" 뒷문이 됩니다.
치른 값
공짜는 아니었어요. 정리하면 이렇습니다.
| 치른 것 | 얻은 것 |
|---|---|
| 리소스 하나가 릴리스 수만큼 행을 가짐 | "그때 그 화면"이 그대로 재현됨 |
| 모든 콘텐츠 쿼리가 어느 timeline을 읽을지 명시해야 함 | 실수로 draft를 공개하는 경로가 사라짐 |
| 발행 로직이 복제+교체로 복잡해짐 | 롤백이 포인터 되돌리기가 됨 |
| 테이블이 하나 늘어남 | 저작 안전성이 리뷰가 아니라 제약 위반으로 걸림 |
금지 사항도 하나 박았어요. visibility를 draft/release 선택자로 겸용하지 않는다. visibility는 "누가 보는가"의 문제고 읽기 모드는 "어느 시점을 보는가"의 문제라서, 둘을 한 컬럼에 얹으면 언젠가 반드시 섞입니다.
나중에 받은 배당금
이 분리가 한참 뒤에 값을 한 번 더 했어요.
"글 하나만 공개"가 사이트 전체 발행과 다른 연산이 될 수 있게 된 겁니다. 릴리스 안에서 그 리소스의 행만 바꾸면 되니까요.
글 하나 공개하려다 검토 안 된 다른 초안까지 딸려 나가는 사고 — 그 가능성 자체가 모델에서 사라졌어요. 조심해서 막은 게 아니라, 그렇게 될 수 있는 경로가 없는 거예요.
설계가 값을 하는 순간은 대개 이렇더라고요. 만들 때 상상하지 않았던 요구가 왔는데 이미 가능한 상태인 것.
코어가 지키는 선이 하나 더 있어요. 플러그인은 DB를 모른다 — 다음 편이에요.


