플러그인은 DB를 모른다 — blueprint와 ui의 경계
렌더러가 DB를 직접 읽으면 편해요. 그리고 그 순간 결합이 시작됩니다. 선언만 하는 blueprint와 그리기만 하는 ui — 파일 4개와 95개로 갈라진 경계를 실제 코드로 뜯어봤어요.

플러그인이 데이터베이스를 읽게 하면 개발이 정말 빨라집니다. 한 시간이면 될 일이거든요.
그리고 그 한 시간이 다음 제품을 못 만들게 만듭니다.
이 글은 arcaC에서 플러그인이 DB를 모르게 만든 이야기예요. 결과부터 말하면 블로그 플러그인은 두 조각으로 갈라졌습니다.
| 패키지 | 파일 수 | 의존성 | 하는 일 |
|---|---|---|---|
| blog-blueprint | 4개 | 계약 2개 | 무엇이 존재하는지 선언 |
| blog-ui | 95개 | UI·런타임 (DB 없음) | 받은 데이터를 그리기 |
선언은 4개 파일이고 그리기는 95개 파일이에요. 그런데 DB에 닿는 코드는 양쪽 다 0줄입니다.
먼저, 왜 이게 문제인가
블로그 목록 화면을 만든다고 해볼게요. 제일 빠른 길은 이겁니다.
// 렌더러 안에서
const posts = await db.query(
"SELECT * FROM content WHERE type = 'blog.post' ORDER BY published_at DESC LIMIT 10"
);동작합니다. 오늘은요. 문제는 이 한 줄이 만드는 약속이에요.
| 이 코드가 조용히 약속하는 것 | 나중에 깨지는 순간 |
|---|---|
| 테이블 이름이 content다 | 스키마를 바꾸면 플러그인이 깨짐 |
| 플러그인이 DB에 접속할 수 있다 | 권한 경계가 사라짐 — 남의 사이트도 읽힘 |
| 공개/초안 구분을 플러그인이 안다 | 발행 전 글이 새어 나갈 경로가 생김 |
| 정렬·개수를 코드가 정한다 | 같은 목록을 다른 페이지에서 다르게 못 씀 |
세 번째 줄이 특히 위험해요. 앞 편에서 읽기 모델을 둘로 가른 이유가 여기서 무너지거든요. 플러그인이 직접 쿼리하면 어느 timeline을 읽을지도 플러그인이 정하게 됩니다.
그래서 플러그인은 선언만 합니다
blueprint가 하는 일은 "무엇이 존재하는가"를 적는 것뿐이에요. 글이라는 게 어떤 필드를 갖는지부터요.
const blogPostContentType = {
typeId: "blog.post",
version: 3,
displayName: "블로그 글",
fields: [
{ fieldId: "title", kind: "string", required: true, indexed: true },
{ fieldId: "slug", kind: "string", required: true, indexed: true },
{ fieldId: "body", kind: "richText", required: true, indexed: false },
{ fieldId: "publishedAt", kind: "datetime", required: true, indexed: true },
{ fieldId: "folder", kind: "reference", required: false, indexed: true,
reference: { targetTypeId: "taxonomy.term", scope: "site" } },
// ...
],
};여기 어디에도 CREATE TABLE이 없죠. 어떤 DB를 쓸지, 컬럼을 어떻게 만들지는 플랫폼이 정합니다. 플러그인은 의미만 말해요.
데이터를 가져오는 것도 마찬가지로 선언입니다.
{
bindingId: "featuredPost",
kind: "content.list",
typeId: "blog.post",
limit: 1,
sort: { fieldId: "publishedAt", direction: "desc" },
filter: { equals: { featured: true } },
visibility: { audience: "public" },
}"공개된 글 중에 featured인 것 하나를, 최신순으로." 쿼리가 아니라 주문서예요. 이걸 읽고 실제 SQL을 만드는 건 플랫폼입니다.
(그래서 나중에 목록 전용 경량 읽기 모델을 도입할 때도 플러그인은 한 줄도 안 고쳤어요. 주문서는 그대로고 주방만 바뀐 거죠.)
여기서 규율이 하나 필요했습니다
계약 이야기를 할 때 흔한 함정이 있어요. 모든 걸 문자열 키로 바꾸는 겁니다. 그러면 결합은 사라지는데 타입도 같이 사라져요. 오타가 런타임까지 살아남고요.
그래서 선을 이렇게 그었습니다.
| 허용 | 금지 |
|---|---|
| 타입·확장 지점 계약은 공개 계약 패키지에서 직접 import | 다른 플러그인의 구현을 import |
| 선택적·런타임 조합은 Manifest의 ID로 선언 | 전부를 문자열 Service Locator로 |
| Host 기능은 권한 범위가 붙은 API로 받기 | 플러그인 진입점에 Registry·DB·파일시스템 통째로 넘기기 |
마지막 줄이 핵심이에요. 플러그인에게 "DB 객체"를 주지 않습니다. 주면 언젠가 쓰거든요. 급할 때는 반드시 씁니다.

선언을 누가 조립하나
플러그인이 선언만 한다면, 그 선언들을 모아 실제 사이트로 만드는 건 누구일까요. 조립기예요.
여기서 이 플랫폼이 스스로에게 건 제일 강한 규율이 나옵니다. 조립기는 blog라는 단어를 모릅니다.
플랫폼 패키지 어디에도 이런 코드가 없어요.
// 이 플랫폼에 없는 코드
if (siteType === "blog") {
mountBlogRoutes();
} else if (siteType === "calendar") {
mountCalendarRoutes();
}대신 조립기는 이렇게만 봅니다. "이 기여가 선언한 확장 지점이 존재하는가, 계약 버전이 맞는가, 필요한 권한이 선언돼 있는가."
확장 지점 이름에도 도메인 이름을 금지했어요. platform.blog.post/v1 같은 건 못 만듭니다. 도메인 이름은 기여 id나 콘텐츠 타입 id(blog.post)에만 허용돼요.
(판정 기준도 문서에 못 박아 뒀어요. "두 번째 제품을 만들 때 플랫폼 패키지가 많이 바뀌면 설계 실패다.")
Registry와 Repository는 다릅니다
여기서 자주 헷갈리는 구분이 하나 있어요.
| Registry | Repository | |
|---|---|---|
| 질문 | 무엇을 만들 수 있는가 | 사용자가 실제로 무엇을 만들었는가 |
| 담기는 것 | 콘텐츠 타입·렌더러·테마의 정의 | 실제 글, 초안, 일정 |
| 누가 채우나 | 플러그인 선언 | 사용자의 저작 행위 |
| 바뀌는 시점 | 플러그인 조합이 바뀔 때 | 사용자가 저장할 때 |
이 구분 덕에 "블로그 글 타입을 안다"와 "블로그 글을 저장한다"가 다른 계층이라는 게 코드에서 강제됩니다. 플러그인은 앞쪽만 알고, 뒤쪽은 손도 못 대요.
게시할 때는 Registry에 그 시점의 설정을 얹어 굳힌 불변 스냅샷을 만듭니다. 그래서 실행 중인 사이트가 다른 사이트의 플러그인 변경에 오염되지 않아요.
실제로 격리됐는지 확인해봤어요
설계는 말로 하면 다 됩니다. 그래서 실제로 검사해봤어요. blog-ui의 의존성 목록이 이겁니다.
@arcac/admin-runtime
@arcac/content-ui
@arcac/contracts
@arcac/feature-runtime
@arcac/platform-access
@arcac/rich-text
@arcac/theme
@arcac/ui
lucide-react
lowlightDB 드라이버도, ORM도, 저장소 패키지도 없어요. 95개 파일짜리 UI가 데이터 계층에 손이 안 닿습니다.
그리고 blueprint 쪽은 의존성이 딱 두 개예요. 계약과 리치텍스트. 선언만 하는 패키지니까요.
그래서 얻은 것
1. 렌더러를 바꿔도 데이터가 안 흔들려요
블로그 UI를 수십 번 갈아엎는 동안 blueprint는 거의 그대로였어요. 화면을 다시 그리는 일과 무엇이 존재하는지 정하는 일이 분리돼 있으니까요.
2. 같은 데이터를 다른 화면이 다르게 씁니다
바인딩이 선언이라서 페이지마다 자기 주문서를 갖습니다. 홈은 featured 하나, 목록은 20개, 내비게이션은 앞뒤 글만. 같은 타입을 세 페이지가 각자 다르게 주문해요.
3. 권한을 플랫폼이 강제합니다
바인딩에 visibility가 적혀 있죠. 이걸 플러그인이 아니라 플랫폼이 해석해요. 그래서 플러그인 코드에 버그가 있어도 비공개 글이 새어 나갈 경로 자체가 없습니다.
플러그인을 신뢰하지 않는 게 아니라, 신뢰할 필요가 없게 만든 거예요. 나중에 남이 만든 플러그인을 올리게 되면 이 차이가 전부가 됩니다.
치른 값도 있어요
정직하게 적을게요. 이 경계는 공짜가 아니었습니다.
| 불편해진 것 | 실제로 겪은 일 |
|---|---|
| 새 조회 방식이 필요하면 계약부터 고쳐야 함 | 필터 하나 추가하는 데 계약 → 스키마 → 런타임 세 곳을 거침 |
| 선언으로 표현 못 하는 요구가 있음 | 복잡한 집계는 아직 바인딩으로 못 씀 |
| 디버깅 경로가 길어짐 | 화면에 데이터가 없을 때 볼 곳이 늘어남 |
특히 첫 줄이 자주 아팠어요. 급할 때 "그냥 여기서 읽으면 5분인데"라는 생각이 매번 들거든요.
그때마다 이 질문으로 버텼습니다. 두 번째 제품을 만들 때 이 코드가 방해가 되나?
만약 그때 DB를 읽었다면
반대 세계를 잠깐 상상해볼게요. 블로그 UI가 DB를 직접 읽는 채로 캘린더를 붙이려 했다면 무슨 일이 생겼을까요.
| 상황 | 벌어졌을 일 |
|---|---|
| 캘린더는 기간으로 조회해야 함 | 블로그가 쓰던 쿼리 헬퍼를 캘린더용으로 확장 → 두 도메인이 한 헬퍼를 공유 |
| 일정은 발행 없이 즉시 바뀜 | 블로그의 draft/release 가정이 캘린더에 안 맞음 → 조건문 분기 시작 |
| 캘린더 공유 권한은 사이트 권한과 다름 | 권한 검사 코드가 두 벌이 되고, 둘 중 하나는 반드시 뒤처짐 |
| 블로그 글에 일정표를 넣고 싶어짐 | 블로그가 캘린더 테이블을 알게 됨 → 제품 두 개가 한 몸 |
마지막 줄이 실제로 왔어요. 블로그 글 안에 캘린더 일정표를 꽂는 요구가요.
경계가 있었기 때문에 그때 만든 건 배선이 아니라 계약이었습니다. 블로그는 지금도 캘린더가 뭔지 몰라요 — 저장하는 건 "어느 제공자의 어떤 공개본을 어떻게 그린다"는 명세 한 줄뿐이거든요.
(그 이야기는 캘린더 시리즈에 따로 썼어요.)
그리고 실제로 시험을 봤어요
한참 뒤에 캘린더를 만들면서 이 경계가 채점을 받았습니다. 캘린더 화면도 DB를 모르고, 플랫폼 코어도 캘린더를 모르는 상태로 붙었어요.
그때 코어에서 도메인 이름으로 갈라지는 분기를 검사해봤는데 0건이었습니다. 이 경계가 그 결과를 만든 거예요.
경계는 만들 때가 아니라 두 번째 것을 붙일 때 값을 합니다. 그전까지는 그냥 번거로운 규칙처럼 보이고요.
다음 편은 이 구조를 그림으로 봅니다. 설계 문서에만 있던 arcaC의 구조를 다이어그램 3장으로 꺼냈어요.


