블로그 글에 일정표를 넣었는데, 블로그는 캘린더를 모릅니다
블로그 글에 캘린더를 꽂고 싶었어요. 블로그가 캘린더 API를 부르면 반나절이면 끝나는데, 그러면 제품이 늘 때마다 배선이 제곱으로 늘어납니다. 그래서 저장소 대신 경계를 통일한 계약 이야기예요.

블로그 글 안에 캘린더 일정표를 넣고 싶어졌어요.
행사 안내 글이라면 날짜가 글 안에 살아 있어야 하잖아요. 일정이 바뀌면 글도 같이 바뀌고요.
구현은 쉬웠습니다. 블로그 에디터에 캘린더 블록을 만들고, 렌더링할 때 캘린더 API를 부르면 끝이에요. 반나절이면 됐을 겁니다.
안 했습니다. 오늘은 그 반나절을 안 쓰고 며칠을 쓴 이유예요.
반나절짜리 해법의 청구서
그 순간 블로그는 캘린더를 아는 제품이 됩니다. 캘린더의 응답 모양이 바뀌면 블로그가 깨져요.
그리고 이 요구는 한 번으로 안 끝납니다. 지금 줄 서 있는 제품이 이만큼이거든요.
블로그 ← 캘린더 일정표
다이어리 ← 캘린더 일정표
프로필 ← 캘린더 일정표
블로그 ← 투표 결과 (언젠가)
다이어리 ← 사진 모음 (언젠가)
...제품이 N개면 연결이 N×N으로 늘어요. 짝지어 배선을 깔면, 제품을 추가할 때마다 이미 있는 제품을 전부 고쳐야 합니다.
(이 플랫폼을 만든 이유가 딱 그 반복을 없애려던 거였는데, 여기서 다시 만들 뻔했어요.)
그럼 다 한 테이블에 넣으면 되지 않나
반대 극단도 검토했어요. 공개할 수 있는 모든 것을 하나의 범용 저장소로 모으는 방법이요. 실제로 이게 흔한 답이기도 합니다.
그런데 각 제품의 성격을 뜯어보니 안 맞았어요.
| 블로그 글 | 캘린더 일정 | |
|---|---|---|
| 공개 시점 | 발행할 때 굳음 | 지금 이 순간 바뀜 |
| 조회 방식 | id·slug로 하나씩 | 날짜 범위로 여러 개 |
| 버전 | 릴리스마다 스냅샷 | 버전 개념 없음 |
| 권한 | 사이트 단위 | 캘린더 단위 멤버십 |
두 번째 줄이 특히 안 맞아요. 블로그 글은 발행하면 굳는데, 캘린더 일정은 지금 이 순간 바뀌는 살아있는 데이터예요. 발행이라는 개념 자체가 없습니다.
둘을 같은 저장소에 넣으면 둘 다 어색해져요. 일정에 발행 절차가 생기거나, 글이 발행 없이 바뀌거나요. 나중에 투표나 점수판이 오면 각자의 갱신 주기와 집계 규칙을 또 욱여넣어야 하고요.
질문을 바꿨습니다
그래서 질문을 이렇게 바꿨어요. 합쳐야 하는 게 저장소가 아니라 경계가 아닐까?
제품마다 데이터는 각자 두고, 서로 주고받는 부분만 공통 규격으로 정하는 거예요. 그래서 개념 여섯 개를 계약에 추가했습니다.
PublishedResourceReference // 무엇을 가리키는가
PublishedResourceQuery // 어떻게 조회하는가
PublishedResourceProjection // 공개되는 모양은 무엇인가
PublishedResourceProviderDescriptor // 누가 제공하는가
PublishedResourceRendererDescriptor // 누가 그리는가
EmbeddedResourceSpec // 문서에 저장되는 명세여기서 제일 신경 쓴 규칙 하나. 이 계약은 JSON으로 직렬화 가능한 것만 담습니다.
도메인 객체도, React 컴포넌트도, DB 식별자도, 비밀값도, 구현 함수도 안 들어가요. 이 계약은 문서에 저장되고 네트워크를 건너다니거든요.
소유권을 이렇게 갈랐어요
| 누가 | 무엇을 소유하나 |
|---|---|
| 원본 제품(캘린더) | 원본 데이터, 접근 권한, 공개 여부, 공개할 필드 목록 |
| 넣는 제품(블로그) | "이걸 여기에 넣는다"는 명세 한 줄 |
| 플랫폼 | 명세가 유효한지, 볼 권한이 있는지 검사 |
블로그 문서에 저장되는 건 딱 이 정도예요.
{
type: "embeddedResource",
spec: {
providerId: "...", // 어느 제공자
publicationId: "...", // 그 제공자의 어떤 공개본
projectionId: "...", // 어떤 모양으로
query: {...}, // 이름 붙은 조회
rendererId: "...", // 무엇으로 그릴지
preset: {...}
}
}일정 내용이 글에 복사되지 않습니다. 참조만 있어요. 그래서 일정이 바뀌면 글에서도 바뀝니다.
이 구조가 자동으로 지켜주는 것들
1. 발행이 남의 데이터를 잠그지 않아요
블로그 글을 발행하면 그 시점의 콘텐츠가 릴리스에 굳습니다. 그런데 캘린더 일정은 안 굳어요.
글은 참조만 갖고 있으니까요. 릴리스가 잠그는 건 "이 자리에 저 일정표가 있다"는 사실이지 일정 내용이 아닙니다.
만약 복사하는 구조였다면 여기서 이상한 일이 생겼을 거예요. 발행 후에 일정을 바꿨는데 글에는 옛날 일정이 남는 거죠.
2. 꽂는다고 공개되지 않아요
중요한 안전장치예요. 비공개 캘린더를 글에 꽂아도 그게 공개로 바뀌지 않습니다.
공개할지는 캘린더가 결정합니다. 블로그는 참조만 갖고, 실제로 보여줄지는 원본 제품의 정책이 정해요.
그래서 "임베드하면 공개된다" 같은 사고가 구조적으로 불가능합니다. 권한을 확대하는 경로 자체가 없어요.
3. 권한이 대체되지 않아요
원본 제품이 자기 접근 권한을 계속 소유합니다. 플랫폼이 그걸 사이트 권한으로 갈아치우지 않아요.
그래서 캘린더 멤버십이 없는 사람은 글에 꽂힌 일정표도 못 봅니다. 꽂혔다는 이유로 권한이 생기지 않아요.
그럼 실제로 그리는 건 누구인가
계약만 있으면 화면이 안 그려지죠. 실제 렌더링은 누가 하느냐가 남습니다.
여기서 갈림길이 있었어요. 블로그가 그릴 것인가, 캘린더가 그릴 것인가.
| 소비자가 그린다 | 제공자가 그린다 | |
|---|---|---|
| 장점 | 소비자 디자인과 일관됨 | 제공자가 자기 데이터를 제일 잘 앎 |
| 단점 | 소비자가 데이터 모양을 알아야 함 | 소비자마다 다르게 보여주기 어려움 |
| 새 소비자가 생기면 | 또 그려야 함 | 그대로 재사용 |
제공자 쪽을 골랐어요. 캘린더가 자기 렌더러를 기여로 등록하고, 블로그는 그걸 불러다 씁니다.
그래서 블로그는 일정 데이터의 모양을 몰라도 돼요. "이 자리에 저 렌더러를 놓는다"만 알면 됩니다.
그런데 디자인은 어떻게 맞추나
제공자가 그리면 소비자 사이트의 디자인과 안 맞을 수 있죠. 여기서 preset을 뒀어요.
spec: {
rendererId: "...",
preset: { density: "compact", showLocation: false }
}소비자가 "이 자리에는 촘촘하게, 장소는 빼고"를 말할 수 있습니다. 다만 임의의 스타일을 넘기지는 못해요. 제공자가 미리 정의한 조절 항목만 쓸 수 있습니다.
임의 CSS를 허용하면 제공자가 나중에 렌더러를 고칠 때마다 소비자 쪽이 깨지거든요. 조절 가능한 축을 제공자가 선언하고, 소비자는 그 안에서 고릅니다.
검증은 언제 하나
명세는 문서에 저장돼요. 그런데 저장된 뒤에 세상이 바뀔 수 있습니다.
| 저장 후 생길 수 있는 일 | 렌더링 시점 처리 |
|---|---|
| 가리키던 공개본이 비공개로 바뀜 | 안 보여줌 — 자리도 안 만듦 |
| 제공자 플러그인이 제거됨 | 안 보여줌 — 글은 정상 |
| 렌더러 버전이 안 맞음 | 안 보여줌 |
| 볼 권한이 없는 방문자 | 안 보여줌 |
네 경우 다 조용히 사라집니다. 에러를 띄우지 않아요.
이유는 내부 링크 때와 같습니다. 임베드 하나가 깨졌다고 글이 안 뜨면 안 되거든요. 부가 요소는 실패해도 본문을 인질로 잡지 않습니다.
다만 편집 화면에서는 다르게 보여줘요. 글쓴이는 왜 안 나오는지 알아야 하니까, 거기서는 이유를 표시합니다.
같은 실패라도 독자에게는 조용히, 작성자에게는 명확하게. 두 사람이 필요한 정보가 다르거든요.
예산이라는 개념도 넣었어요
마지막으로 하나 더. 글 하나에 임베드를 백 개 꽂으면 어떻게 될까요.
렌더링할 때마다 제공자를 백 번 조회하게 됩니다. 그러면 그 글은 못 여는 글이 돼요.
그래서 호스트가 검사하는 항목에 예산을 넣었습니다. 문서당 임베드 개수와 조회 비용에 상한을 두는 거죠.
이건 악의적인 사용을 막으려는 게 아니에요. 글을 쓰다 보면 자연스럽게 늘어날 수 있고, 그때 서서히 느려지는 것보다 선을 넘을 때 명확히 막히는 게 낫습니다.
플랫폼은 여전히 아무것도 모릅니다
제일 공을 들인 부분이 이거예요. 이 계약에 "캘린더"라는 단어가 안 나와야 한다는 것.
그래서 확장 지점 이름도 도메인 중립으로 지었어요.
platform.published-resource.provider-descriptor/v1
platform.published-resource.projection-descriptor/v1
platform.published-resource.renderer-descriptor/v1
platform.published-resource.publication-inspector/v1
platform.authoring.embedded-resource/v1조립기가 하는 일은 이것뿐이에요. "이 제공자가 선언한 규격과 저 렌더러가 요구하는 규격이 맞는가, 그리고 실제 구현물이 존재하는가."
캘린더인지 투표인지 점수판인지는 몰라요. 알 필요도 없고요.
도메인 이름이 계약에 한 번 들어가면, 그건 계약이 아니라 그 제품용 배선이에요.
안 만든 것도 적어둘게요
설계하면서 "이것도 만들까" 싶었던 걸 일부러 안 만들었습니다.
| 안 만든 것 | 이유 |
|---|---|
| 모든 공개본을 담는 범용 테이블 | 지금 필요한 건 계약이지 저장소가 아님 |
| 제품 간 자동 동기화 | 참조로 충분 — 복사는 문제를 만듦 |
| 임베드 전용 권한 체계 | 원본 제품의 권한을 그대로 쓰면 됨 |
첫 줄이 제일 유혹적이었어요. 만들어두면 나중에 편할 것 같잖아요.
그런데 미리 만들면 그걸 쓰려고 각 제품의 성격을 거기에 맞추기 시작합니다. 저장소가 먼저 있으면 도메인이 저장소를 닮아가요. 순서가 거꾸로죠.
(필요해지면 그때 만들면 됩니다. 그때는 이미 무엇이 필요한지 알고 있을 거고요.)
시험은 세 번째 제품이 봅니다
솔직히 말하면 이 설계의 값어치는 아직 안 나왔어요. 지금 이걸 쓰는 조합은 블로그와 캘린더 하나뿐이거든요.
진짜 채점은 다이어리가 올라올 때입니다. 다이어리가 일정표를 넣고 싶다고 할 때, 새 배선 없이 되면 성공이고 뭔가 또 만들어야 하면 실패예요.
| 확인할 것 | 성공 기준 |
|---|---|
| 플랫폼 코어 변경 | 0줄 |
| 캘린더 쪽 변경 | 0줄 — 이미 제공자로 등록돼 있음 |
| 다이어리 쪽 작업 | 명세를 저장하는 것뿐 |
두 번째 줄이 핵심이에요. 새 소비자가 생겼다고 제공자가 바뀌면, 그건 계약이 제 역할을 못 한 겁니다.
그때까지 이건 가설입니다. 다만 반나절짜리 해법을 골랐다면 결과를 알아볼 기회조차 없었겠죠.
캘린더 이야기는 여기까지예요. 가입 없이 시작하게 만들고, 다른 제품이 꺼내 쓸 수 있게 열어뒀습니다. 이 제품이 플랫폼 가설에 어떤 성적을 줬는지는 두 번째 화분을 올렸습니다에 적어뒀어요.


