← 미리디 아카이브 / 재배치 알고리즘 파이프라인

내용시각화 post-replace-relayout 작업트리 2026-08-27

엔진 내장 렌더 호스트

재배치 뷰어의 그림 네 장은 남의 서버가 그려 주던 것이었다. 그 배포가 삭제되면서 화면이 통째로 죽었다. 미리캔버스 엔진 렌더러를 저장소 안으로 들여 같은 화면을 스스로 그리게 만든 과정 — 무엇이 어디에 있었고, 무엇에 걸렸고, 무엇이 아직 남았는지.

miricanvas-iui · src/post-replace-relayout/doc-render 엔진 0.0.845 새 파일 5 · 고친 파일 4
§0

30초 요약

  • 문제뷰어의 그림은 unicorn-73380 preview 서버의 iframe 이 그렸다. 그 배포가 삭제됐고, 살아 있을 때도 매일 20:00 에 잠들어 사람이 깨워야 했다.
  • 왜 그냥 못 그렸나이미 쓰던 엔진 세 패키지는 재는 부품이다. 그리는 뷰와 그 등록 코드는 상위 패키지 miricanvas-editor-2 에 있다.
  • 한 일그 패키지를 같은 버전으로 받아, 공개 엔트리 PageRootView 로 페이지 한 장을 그리는 iframe 전용 페이지를 만들었다. 프로토콜은 예전 그대로라 뷰어는 주소만 바뀌었다.
  • 왜 여전히 iframe 인가엔진은 전역 싱글턴(NodeManager · paper · jotai)을 쓴다. 한 문서 = 한 iframe 이어야 서로 안 섞인다. 덤으로 엔진이 앱 번들에 안 들어간다.
  • 걸린 곳 셋paper-esm 의 CJS 사본 · Page 객체를 되찾는 길 · 렌더가 텍스트 박스를 안 고친다는 사실(재검증이 조용히 무력해질 뻔했다).
  • 남은 것내용 대치(nodeTextMap)만은 못 옮겼다 — editor 패키지가 공개 엔트리로 안 내보낸다. 케이스를 굽는 배치는 그래서 아직 preview 를 쓴다.
§1

남의 서버에 매달린 화면

/post-replace-relayout 은 케이스 하나를 네 장(원안 · 대치 직후 · 재배치 결과 · 정답) 나란히 놓고 견주는 화면이다. 그 네 장은 합성 스냅샷(*.webp)이 아니라 엔진이 그 자리에서 그린 화면이다 — 재배치 결과(After)는 커밋된 스냅샷이 아예 없어서, 라이브로 그리지 않으면 볼 방법이 없다.

문제는 그리는 쪽이 우리 것이 아니었다는 데 있다. design-2 의 preview 배포 한 곳에 iframe 을 걸고 sheetJson 을 postMessage 로 던지면, 그쪽이 그려서 돌려주는 구조였다. 그 배포가 삭제되자 화면 전체가 「preview 서버 응답 없음」이 됐다.

예전 — 2026-08 이전 뷰어 local.miricanvas.com:3000 /post-replace-relayout DocIframe · useRenderProbe postMessage cross-origin unicorn-73380.preview… /v2/design2/component-thumbnail design-2 배포 · 엔진이 여기 있었다 ✕ 삭제됨 매일 20:00 sleep = 매번 깨워야 했다 지금 뷰어 local.miricanvas.com:3000 /post-replace-relayout DocIframe · useRenderProbe postMessage same-origin doc-render (같은 dev 서버) /src/post-replace-relayout/doc-render/ 엔진을 이 안에서 부팅한다 ✓ 남의 서버 없음 dev 서버만 떠 있으면 된다 프로토콜은 그대로
§2

엔진은 한 덩어리가 아니다

여기서 가장 헷갈리는 대목. iui 저장소는 이미 엔진을 쓰고 있었다. src/text-tight-box/compute.ts 가 글자 영역(tight bounding box)을 재려고 엔진 세 패키지를 import 한다. 그런데도 그림은 남의 서버가 그렸다. 왜?

엔진이 층으로 갈려 있기 때문이다. 있던 것은 「재는 부품」이고, 「그리는 뷰」는 한 층 위에 있다.

아래에서 위로 쌓인다 engine-framework 문서 모델 — Page · Node · PageBuilder · 좌표 · 텍스트 자료구조 이미 있었다 engine-view-framework 렌더 인프라 — NodeViewFactory · 폰트 로더 · TextBoundingBoxCalculator 이미 있었다 miricanvas-editor-2 실제 뷰 — TextItem · PhotoItem · ShapeItem … 과 그 등록 코드(root-setup.env) ← 이번에 받았다 「노드 타입 → 뷰」 등록표가 비어 있으면 NodeViewFactory 는 아무것도 안 그린다 — 화면이 하얗게 뜬다. 그 등록표를 채우는 코드가 세 번째 층에 있다. 그래서 「재기」는 되는데 「그리기」는 안 됐다.
이미 있던 세 패키지로 되는 일
글자 영역 재기(compute.ts) · 문서 만들기(PageBuilder) · 직렬화(makePageCreateCommandChunk). DOM 은 필요하지만 뷰는 필요 없다.
한 층 더 있어야 되는 일
화면에 그리기. 노드 타입마다 뷰 컴포넌트가 있어야 하고, 그 등록·paper 초기화· 리소스 선로딩까지가 한 세트다.

PageRootView 인가

editor-2 가 공개 엔트리로 내보내는 컴포넌트 중 「pageJson 한 장을 그리는 것이 존재 이유」인 것이 이것뿐이다. web-2 원본 주석에 "합성기에서 사용하는 페이지루트" 라고 적혀 있다 — 합성 서버가 썸네일을 구울 때 쓰던 바로 그 컴포넌트다. 뷰 등록 · paper 초기화 · API 클라이언트 초기화 · 폰트를 포함한 리소스 선로딩을 전부 안에서 마친 뒤에 그린다. 우리가 줄 것은 pageDataapiClientConfig 둘뿐이다.

§3

무엇을 만들었나

iframe 전용 페이지 하나다. vite 의 두 번째 엔트리로 잡아 두어서 앱 번들과 섞이지 않는다 — 앱 라우트는 이 코드를 import 하지 않고, 브라우저가 iframe 을 열 때만 엔진이 로드된다.

엔트리 둘 — 섞이지 않는다
index.html
src/main.tsx · 앱 전체(라우터·모든 화면)
엔진 없음
/post-replace-relayout
DocIframe · useRenderProbe
iframe 을 띄우고 postMessage 만 한다
두 번째 엔트리 — iframe 만 연다
doc-render/index.html
main.tsx → DocRenderHost
여기서만 엔진을 import 한다
PageRootView
@miri-unicorn/miricanvas-editor-2/page-root
153 청크 · 6.8MB (dev 기준)

파일 다섯

파일하는 일
doc-render/index.html엔트리 HTML. iframe 의 src 가 이 파일이다
doc-render/main.tsxcreateRoot. StrictMode 를 안 쓴다 — 엔진이 전역 싱글턴이라 effect 이중 실행이 초기화를 두 번 태운다
doc-render/DocRenderHost.tsx프로토콜(PING/READY/LOADED) + PageRootView 로 렌더 + 글자 영역 실측 회신
doc-render/theme.tsstyled-components 테마. 엔진 안쪽이 theme.token.* 을 읽어서 없으면 그 자리에서 터진다
doc-render/paperEsmShim.tspaper-esm 을 진짜 ESM 으로 감싸는 겉옷 — 없으면 아무것도 안 그려진다(§6)

고친 곳 넷

DocIframe.tsx
주소를 로컬로 바꾸고(IFRAME_URL), 「서버 실행」 링크를 걷어내고, 부팅 대기 예산을 늘렸다(엔진을 iframe 마다 새로 띄우므로).
useRenderProbe.tsx
실측을 pageJson 대신 tightBoxes 에서 읽는다(§6).
PostReplaceRelayout.tsx
preview 매니저 링크 제거.
vite.config.ts
엔트리 둘 + paper-esm alias 두 줄 + optimizeDeps.include.
§4

핸드셰이크 — 오간 메시지

프로토콜은 예전 것을 그대로 뒀다. 삭제된 preview 페이지의 원본 (web-2 브랜치 feature/UNICORN-73380_)을 읽어 규격을 그대로 옮겼기 때문에, 뷰어 쪽은 상수 두 줄(주소·오리진) 말고는 손댈 것이 없었다.

부모 — 뷰어 자식 — doc-render iframe 엔진 부팅 COMPONENT_THUMBNAIL_PING · 200ms 마다 COMPONENT_THUMBNAIL_READY COMPONENT_THUMBNAIL · { sheetJson, nodeTextMap: {} } PageBuilder → 리소스 선로딩 → 렌더 → 글자 영역 재기 COMPONENT_THUMBNAIL_LOADED · { pageJson, tightBoxes } tightBoxes 는 이번에 더한 것이다 — 이유는 §6

nodeTextMap비워 보낸다는 것이 중요하다. 뷰어가 보내는 문서는 이미 대치가 끝난 것이라 넣을 글이 없다. 그리고 이 호스트는 애초에 내용 대치를 하지 않는다(§8).

§5

코드 흐름

렌더 — 우리가 쓴 것은 이만큼이다 doc-render/DocRenderHost.tsx
// 1) 메시지를 받아 문서를 state 에 담는다
if (data.type === 'COMPONENT_THUMBNAIL_PING') postToParent({ type: 'COMPONENT_THUMBNAIL_READY' });
if (data.type === 'COMPONENT_THUMBNAIL')      setDoc(load.sheetJson);

// 2) 그리는 것은 이 한 덩어리가 전부다 — 뷰 등록·paper·리소스 선로딩은 안에서 끝난다
<ThemeProvider theme={DS_THEME}>
  <PageRootView
    pageData={doc}
    apiClientConfig={{ baseURL: 'https://api-staging.miricanvas.com', domain: '',
                       language: 'ko', withCredentials: true, isApp: false }}
    zoomScale={Math.min(size.width / doc.width, size.height / doc.height)}
    watermarkPolicy='NONE'
    isViewModelEnabledArea            // ← Page 객체를 되찾으려고 켠다 (§6-02)
    onViewLoaded={onViewLoaded}
  />
</ThemeProvider>

// 3) 다 그리면 두 가지를 돌려준다
postToParent({
  type: 'COMPONENT_THUMBNAIL_LOADED',
  pageJson:   makePageCreateCommandChunk(page),   // 엔진이 확정한 문서
  tightBoxes: tightBoxesOf(doc),                  // 글자 영역 실측 (§6-03)
});

tightBoxesOf 는 새로 만든 계산기가 아니다. 이 저장소가 이미 iui.text_tight_boxes 를 구울 때 쓰는 그 함수 (src/text-tight-box/compute.tscomputeTightBoxesForDoc)를 그대로 부른다 — 좌표 규약(프레임 원점 기준)까지 같아서 변환이 없다.

엔트리 둘 · alias 둘 vite.config.ts
build: { rollupOptions: { input: {
  main:      'index.html',
  docRender: 'src/post-replace-relayout/doc-render/index.html',   // 출력 경로 = 소스 경로 → dev/build URL 이 같다
}}},

resolve: { alias: [
  { find: /^@miri-unicorn\/paper-esm$/, replacement: '…/doc-render/paperEsmShim.ts' },  // §6-01
  { find: /^paper-esm-cjs$/,           replacement: '…/paper-esm/dist/paper-esm.js' },
]},

optimizeDeps: { include: ['@miri-unicorn/miricanvas-editor-2/page-root'] }  // 재최적화로 iframe 이 리로드되지 않게
§6

걸린 곳 셋

「패키지 받아서 컴포넌트 하나 붙이면 끝」이 아니었다. 실제로 막힌 곳은 셋이고, 셋 다 증상만 보고는 원인을 알 수 없는 종류였다.

01

화면이 하얗다 — paper-esm 은 이름과 달리 CJS 다

dist/paper-esm.js 에는 export 가 하나도 없다 (module.exports = paper). 그래서 번들러가 CJS→ESM 변환을 끼우는데, 그 변환은 원본의 속성을 getter 로만 복사한 사본을 만든다.

하필 paper.js 의 PaperScope 는 프로토타입에 자기 자신을 가리키는 paper 를 달고 있다. 그래서 엔진이 하는 const { paper } = await import('@miri-unicorn/paper-esm')원본이 아니라 사본을 집어 온다. 그 사본은 project 가 getter 뿐이라 pathfinder 초기화의 paperInstance.setup(...) 이 이렇게 터진다.

[pathfinder >>hahaha>>] SVG 처리 최적화 시스템 초기화 실패:
  Cannot set property project of [object Object] which has only a getter

그러면 initializePaperManager() 가 거절되고 isPaperInitialized 가 영영 false 로 남는다 — 화면은 하얗고 LOADED 도 안 온다. web-2 는 번들러가 달라서 드러나지 않던 문제다.

처방default 임포트는 사본이 아니라 원본을 준다. 그것을 paper 라는 이름으로 다시 내보내는 겉옷을 두고 (paperEsmShim.ts), resolve.alias 로 갈아끼운다.

02

Page 객체를 되찾을 수 없다

회신할 pageJson 을 만들려면 렌더가 끝난 Page 객체가 필요한데, PageRootView 는 그것을 자기 안에서 만든다. 되찾는 길을 둘 재 봤다.

결과
resolveNodeManager().getNodeById(id)페이지도 자식 노드도 전부 undefined — 이 등록부는 디자인(문서 세션)을 거쳐 만든 노드만 담는다
resolveViewModelManager()
.getMountedPageViewModels()
페이지 뷰모델 1개, node 가 우리가 보낸 doc 의 id 를 그대로 갖고 있다 채택

처방isViewModelEnabledArea 를 켠다(기본 꺼짐). 그 스위치가 뷰모델 등록을 켜고, 등록된 뷰모델이 노드를 들고 있다. 그림 자체는 달라지지 않는다.

03

가장 위험했던 것 — 렌더는 텍스트 박스를 고치지 않는다

재검증(「렌더 재검증」 토글)은 「폭을 열었더니 세 줄이 두 줄이 됐나」를 묻는다. 예전 설계는 그 답을 LOADED.pageJson박스 높이에서 읽었다. 그런데 실제로 재 보니 렌더를 거쳐도 텍스트 노드의 boundingBox 는 문서에 적힌 그대로 돌아온다.

확인 방법은 단순하다 — 텍스트 하나의 폭을 60% 로 줄여 넣고 돌려받은 문서를 비교했다.

보낸 문서박스 폭pageJson 박스 높이글자 영역(tight) 높이
원래대로548.4100.7780.17
폭만 60% 로329.0100.77 ← 그대로122.16 ← +41.99

오른쪽 +41.99pxfontSize 29.33 × fontSizeScale 1.4314 ≈ 42.0정확히 한 줄이다. 왼쪽은 한 자리도 안 움직였다.

pageJson 으로 재검증하면 언제나 「예측대로」라고 답한다. 틀렸다고 말해 주는 일이 영영 없으므로 조용히 무력해지는 종류의 고장이다.

처방 — 글자 영역을 직접 잰다. 이 저장소가 이미 tight box 를 구울 때 쓰는 엔진 계산기를 그 자리에서 부르고, 결과를 LOADED.tightBoxes 로 실어 준다. 덤으로 CLI 포트와 같은 자가 됐다 — 예전에는 뷰어가 박스 높이를, CLI 가 glyph extent 를 써서 둘이 갈렸다.

§7

실제로 도는 그림

아래는 /post-replace-relayout 이 케이스 하나를 그리는 모양이다(케이스 4223631). 네 장 전부 로컬 렌더 호스트가 그린 것이고, 점선은 뷰어가 그 위에 얹는 판정 박스다.

ORIGINAL — 대치 이전 (채점 기준)
사진
BEFORE — 대치 직후 (rendered.json)
사진 겹침
AFTER — 재배치 결과 (브라우저에서 즉시 계산)
사진 해소
EXPECTED — 정답 (사람이 만든 것)
사진
판정 박스(글자 + 글머리 기호) 겹침·넘침이 잡힌 자리 모형이다 — 실제 화면은 엔진이 그린 진짜 디자인이다

새 렌더러가 예전 값과 같은 답을 내나

같은 케이스를 구워 둔 값(헤드리스 크롬 배치)과 브라우저에서 실시간으로 잰 값으로 비교했다. 텍스트 세 노드 전부 폭은 소수점까지 일치하고 높이만 1px 미만 차이다(같은 문서 안 전역 스타일 차이로 보인다). 재검증이 묻는 것은 줄 하나 몫(≈42px)의 차분이라 이 정도 차이는 판정을 못 바꾼다.

노드구워 둔 값 (tight/rendered.json)렌더 호스트 실측차이
a725d14c507.293 × 79.512507.2 × 80.17+0.66
36d62677273.040 × 37.324273.0 × 37.99+0.67
9c29a74f446.120 × 121.446446.1 × 122.46+1.01

네트워크 — 로그인 없이 뜬다

리소스(폰트·이미지·도형)는 api-staging.miricanvas.com 에서 받는다. 브라우저에서 직접 부를 수 있는지가 관건이었는데, local.miricanvas.com:3000 오리진에 대해 비로그인 200 + CORS 허용인 것을 확인했다.

엔드포인트무엇결과
/process/api/p/font폰트 메타 → 파일 URL200 · ACAO 허용
/designresource/api/p/designresources/resources이미지·도형 리소스200 · ACAO 허용
file-staging.miricanvas.com리소스 파일 본체ACAO: *

localhost 로는 안 된다 — 오리진이 허용 목록에 없다. 반드시 local.miricanvas.com:3000 으로 연다.

§8

경계와 실행법

이 호스트가 하는 것
그리기 — 페이지 한 장을 엔진 뷰로 렌더.
재기 — 글자 영역(tight bounding box) 실측.
돌려주기 — 엔진이 확정한 문서 + 실측.
이 호스트가 안 하는 것
내용 대치(nodeTextMap) — 마크다운을 텍스트 노드에 흘려 넣는 코드는 editor 패키지의 비공개 모듈이라 못 부른다.
글자 축소(오토핏) — 그 코드가 아예 없다. 못 담을 글은 줄어드는 대신 넘침으로 남는다.

그래서 아직 남은 의존 하나

케이스를 굽는 배치(batch/addTestCase.ts · batch/importCase.ts)는 「원본 sheetJson + 대치할 글」을 렌더러에 태워 rendered.json 을 만든다. 그 대치가 이 호스트에 없어서 배치는 아직 preview 페이지를 몬다. 뷰어는 완전히 독립했고, 코퍼스는 이미 구워져 있으니 보는 데는 지장이 없다.

여는 법

  1. pnpm dev — dev 서버 하나면 된다. 깨울 서버도, 기다릴 서버도 없다.
  2. https://local.miricanvas.com:3000/post-replace-relayout 을 연다. (localhost 는 리소스 API 가 막는다)
  3. 첫 iframe 은 엔진을 부팅하느라 몇 초 걸린다. 그다음부터는 브라우저 캐시가 받쳐 준다.
  4. 렌더 호스트만 따로 보려면 /src/post-replace-relayout/doc-render/index.html 을 연다 — waiting: sheetJson 대기 가 뜨면 정상이다.