pubuilder 12. 캔버스 자체 구현과 서버 사이드 썸네일 캡처 전환
pubuilder에는 프로젝트의 페이지들을 노드로 늘어놓고 연결 관계를 보여주는 IA 맵 화면이 있다. 각 노드는 그 페이지의 미리보기를 담고, 클릭하면 전체화면으로 열린다. 이날 손댄 건 그 맵을 떠받치던 두 개의 기반이었다. 캔버스 라이브러리와 미리보기 방식.
@xyflow/react에서 실제로 쓰던 건 뷰포트 변환·미니맵·엣지뿐이었다
노드 편집 기능은 전부 꺼 둔 상태였다. 드래그도, 연결도, 선택도 쓰지 않았다. 남은 사용 범위는 화면 변환과 미니맵, 그리고 엣지 렌더링 정도였고, 그 폭이면 직접 만드는 편이 낫다고 봤다. 의존성을 걷어내고 FlowCanvas로 교체했다.
커서 기준 휠 줌은 d3-zoom과 같은 2^delta 곡선을 따르게 하고 deltaMode도 계산에 같이 넣었다. 트랙패드 핀치가 브라우저 줌으로 새지 않도록 native 리스너에 passive: false로 붙였다. 여기에 드래그 팬, fitView, 직교 라운드 엣지, 드래그-투-팬 미니맵, 줌 컨트롤을 얹었다.
뷰포트 수학은 src/viewport.ts로 떼어내 유닛 테스트를 붙였다. "커서 아래 world 좌표가 줌 전후로 움직이지 않는다"는 불변식을 테스트로 고정했다. 줌 구현에서 어긋나기 가장 쉬운 지점이 그 부분이다.
팬과 줌이 도는 동안에는 transform을 DOM에 직접 쓰고 React state는 rAF로 뒤따라오게 했다. state 변경으로 매 프레임 리렌더를 돌리면 끊긴다. 드래그 팬은 4px 임계를 넘긴 경우에만 뒤따르는 click을 삼키게 했다. 노드 위에서 화면을 끌었을 뿐인데 뷰어가 열리는 걸 막기 위해서다.
자동 fitView가 노드 배열 identity에 반응하는 문제도 같이 잡았다. 썸네일이 하나씩 로드될 때마다 배열이 새로 만들어지고, 그때마다 화면이 리셋됐다. 비교 기준을 레이아웃 기하 값으로 만든 키로 바꿨다. 노드는 PageNodeCard로 정리하고 Handle은 제거했다.
캔버스를 직접 들고 있게 되니 성능 조정도 바로 넣을 수 있었다. 노드의 화면상 폭이 60px 미만이면 카드를 아예 마운트하지 않고 경량 도형(.pbu-flow-node-lite)만 그린다. 확대 배율에 따라 표현 밀도를 낮추는 LOD(Level of Detail) 방식이다. 이 상태에서는 썸네일 캡처도 요청하지 않는다. 확대해 들어가면 현재 viewport에서 300px 여유를 둔 범위 안의 카드만 마운트하고, 엣지도 같은 기준으로 걸러낸다. 노드 1,000개짜리 맵을 전체 보기로 열었을 때 노드 div는 1,000개지만 상세 카드는 하나도 마운트되지 않는 것을 테스트로 고정했다.
의존성을 뺄지 말지의 판단 기준은 패키지 크기가 아니라 실제로 쓰는 표면이었다. 쓰는 범위가 좁으면 직접 구현이 성능 조정과 버그 수정의 자유도를 같이 준다.
설치조차 안 되는 패키지를 배포하고 있었다
배포 타입 쪽에서는 성격이 다른 문제가 한꺼번에 걸렸다.
구형 프로젝트에서는 서브패스 타입이 아예 잡히지 않았다. 서브패스 타입을 exports 필드로만 노출해 뒀는데, moduleResolution: "node"(node10) 해석기는 exports를 읽지 않는다. 그래서 README가 안내하는 import { defineIA } from 'pubuilder/config'가 그런 프로젝트에서는 TS2307로 실패하고, defineIA의 인자·반환 타입 추론이 통째로 죽었다. node10 폴백용 typesVersions 매핑을 추가하고 ./standalone.js export에도 types 조건을 붙였다.
배포 타입 자체도 설치 불가능한 패키지를 가리키고 있었다. src/types.ts가 비공개 워크스페이스 패키지 @pointly/editor-core의 타입을 재수출하는데, vite는 JS를 인라인한 반면 tsc는 재수출 문을 그대로 흘려보냈다. 배포된 dist/types.d.ts의 첫 줄이 패키지를 설치한 쪽에는 없는 모듈을 가리켰다.
이게 에러로 드러나지도 않았다. skipLibCheck는 컴파일러 기본값이 꺼짐이지만 tsc --init이 권장 옵션으로 켜 주기 때문에 사실상 대부분의 프로젝트에서 켜져 있다. 그래서 해석 실패가 조용히 넘어가고 BlockSelection과 Granularity가 any로 무너졌다. 패키지를 설치해 쓰는 쪽에서는 타입이 있는 것처럼 보이는데 아무것도 안 잡히는 상태다.
더 단순한 것도 있었다. dependencies에 workspace:*가 남아 있어 npm i pubuilder 자체가 EUNSUPPORTEDPROTOCOL로 실패했다.
rollup-plugin-dts로 공개 진입점 3종(index · config · standalone) 선언을 각각 자기완결형으로 롤업하고(rollup.config.dts.mjs와 scripts/finish-dts.mjs), @pointly/editor-core를 dependencies에서 devDependencies로 옮겼다. 배포에 딸려 나가던 내부 선언 조각(dist/components/*.d.ts 등)도 지웠다.
같은 종류의 고장이 조용히 다시 생기는 걸 막으려고 pnpm check:exports를 추가했다. attw(@arethetypeswrong/cli)는 패키지를 실제로 설치했을 때 각 해석 모드에서 타입이 제대로 잡히는지를 검사해 주는 도구다. ESM 전용 패키지이므로 cjs-resolves-to-esm만 의도적으로 제외했다. 3개 진입점을 node10 · node16(CJS·ESM) · bundler 네 모드로 돌려 전부 통과하는 걸 확인했다. 검증은 그 외에도 npm install 성공, skipLibCheck: false로 둔 설치 쪽 프로젝트의 tsc 통과, IsAny<BlockSelection>이 false인지까지 봤다.
JS 번들링과 타입 생성이 서로 다른 기준을 쓰면 배포물이 어긋난다. 그리고 그 어긋남은 설치한 쪽 skipLibCheck에 가려져 아무 소리 없이 any가 된다. 눈으로 확인할 수 없는 종류의 고장은 단정과 자동 검사로 고정해 두는 수밖에 없었다.
노드마다 앱 하나를 띄우던 구조는 페이지 수만큼 비용이 곱해졌다
맵의 모든 노드가 각자 실제 페이지를 iframe으로 실행하고 있었다. 직관적인 구조이긴 한데, 페이지가 늘면 메모리와 CPU가 그대로 곱해진다. 컴패니언 서버가 Playwright chromium으로 페이지를 열어 WebP로 캡처하고, 맵에서는 이미지만 그리는 구조로 바꿨다. Playwright는 헤드리스 브라우저를 코드로 조종하는 자동화 도구다.
라이브 iframe은 페이지를 선택해 전체화면으로 열었을 때만 뜬다. 블록 인스펙터는 그 경로에서 동작하므로 그대로 살아 있다. 상호작용이 필요한 순간만 실제 앱을 띄우고, 나머지는 이미지로 낮췄다.
API는 POST /api/thumbnails/capture와 GET /api/thumbnails/:key(64 hex) 두 개다. 보안 쪽은 서버가 고정한 origin과 정확히 같은 페이지만 허용하고(외부 URL은 400), viewport 범위를 검증하고, 이미지 조회에도 세션 토큰을 요구하고, 클라이언트 연결이 끊기면 캡처를 취소한다. 성능 쪽은 하나의 BrowserContext를 공유해 최대 3개씩 동시 캡처하고, .pubuilder/thumbnails 디스크 캐시에 남겨 다음 실행에서도 재사용한다. 최근 사용 기준으로 최대 1,000개 또는 250MB까지다.
캡처 대상 페이지에서도 PageMap이 렌더되면 맵이 자기 자신을 다시 캡처한다. window.__PUBUILDER_CAPTURE__ 플래그로 그 경로에서는 오버레이가 그려지지 않게 막았다.
첫 캡처 때 고정 버전 chromium을 자동 설치하고, 실패하면 시스템 Chrome · Edge · Chromium 순으로 폴백한다. PUBUILDER_CHROME_PATH로 직접 지정하는 길도 열어 뒀다. playwright와 playwright-core는 CLI 번들에서 external로 뺐다. 이 전환으로 컴패니언 서버의 Node 요구사항이 18에서 20으로 올라간다.
여기까지가 v0.9.10이다. 캔버스 교체와 배포 타입 수정, 썸네일 전환, LOD·가상화를 한 버전에 담아 18시 31분에 배포했다.
캡처를 옮긴 직후 실패가 네 번 연달아 나왔다
배포하고 나서 캡처 실패가 줄줄이 올라왔다. 원인은 매번 달랐다.
타임아웃 500. 특정 페이지만 캡처가 500 INTERNAL로 실패했다. page.goto의 완료 조건이 waitUntil: 'load'였는데, 애널리틱스 비콘이나 SSE, long-polling처럼 끝나지 않는 요청을 물고 있는 페이지에서는 그 조건이 15초 안에 채워지지 않았다. 페이지마다 그런 요청이 있고 없고가 달라서 "일부 페이지만" 실패하는 것처럼 보였다. domcontentloaded까지만 기다리고 networkidle은 3초 best-effort로 시도하되 실패해도 캡처를 진행하게 했다. 네비게이션이 타임아웃해도 이미 그려진 화면은 찍는다. abort 신호는 그대로 예외로 전파해 취소 동작을 유지했다. 끝나지 않는 서브리소스를 가진 재현 페이지에서 500(15.9초)이 200(4.2초)으로 바뀌었다.
같은 버전에서 화질도 올렸다. deviceScaleFactor 없이 1x에 WebP quality 72로 찍고 있었는데, 캔버스가 최대 2배까지 확대되고 화면도 보통 DPR 2배라 확대하면 글자가 뭉갰다. 벡터로 다시 그려지던 예전 iframe과 비교하면 눈에 띄는 후퇴였다. 2x에 quality 88로 올려 축소 표시에서도 슈퍼샘플링 효과를 얻게 했다. 같은 페이지 기준으로 1440×900 / 22.8KB가 2880×1800 / 71.5KB가 됐고, 캐시 상한은 그대로라 가득 채워도 약 72MB다. 캡처 방식 자체가 바뀌었으니 CAPTURE_VERSION을 2로 올려 기존 캐시를 무효화했다. 이 두 건이 18시 51분 v0.9.11이다.
공유 취소 409. 일부 노드에만 "썸네일 캡처가 취소됐어요"가 남았다. 같은 키의 캡처 요청을 하나로 합치는 dedup이 맨 처음 요청자의 AbortSignal에만 묶여 있던 게 원인이었다. 캔버스를 팬하거나 줌해서 그 첫 노드가 언마운트되면(LOD, 가상화, LRU 어느 쪽이든) 공유 작업이 통째로 죽고, 아직 화면에 남아 있는 다른 노드까지 취소 응답을 받았다. React StrictMode의 이중 마운트도 같은 경로였다. mount, cleanup, 다시 mount로 이어지는 흐름에서 두 번째 마운트가 첫 번째가 방금 취소한 작업에 그대로 붙었다. 경합이라 재현이 들쭉날쭉했다. createSharedTasks가 붙어 있는 노드 수를 세게 해서 마지막 하나가 떠날 때만 실제 작업을 중단하고, 이미 취소된 항목에는 새로 붙지 않도록 했다. 취소는 500 INTERNAL이 아니라 409 CANCELLED로 내려가고, 화면에 남아 있는 노드는 에러 카드 대신 조용히 최대 2회까지 재시도한다.
회귀 테스트 6개를 붙이면서 예전 구현을 그대로 되살려 돌려 봤다. 그 테스트들이 실제로 실패하는 것까지(taskSignal.aborted가 false 기대인데 true) 확인했다. 회귀 테스트는 예전 구현에서 실패하는 걸 봐야 그 버그를 잡고 있다는 게 증명된다.
빈 화면 캐시. 타임아웃을 고치면서 page.goto 실패를 .catch()로 삼켰는데, 연결 거부나 DNS 실패처럼 아무것도 그려지지 않은 경우까지 스크린샷을 찍어 디스크 캐시에 저장했다. 빈 흰 화면이 유효한 썸네일처럼 계속 재사용된다. 에러를 보여주는 것보다 나빴다. 실패보다 나쁜 건 성공처럼 보이는 실패다. 이제 네비게이션이 실패하면 document.body에 실제로 그려진 내용이 있는지 확인해서, 있으면 그대로 캡처하고(dev 컴파일이 느린 경우 등) 없으면 캐시에 아무것도 남기지 않고 실패한다. 사용자가 다시 캡처로 재시도할 수 있다. ERR_CONNECTION_REFUSED 페이지에서 500이 나오고 해당 캐시 키 파일이 생성되지 않는 것을 확인했다. 이 두 건이 19시 5분 v0.9.12다.
리다이렉트 캐시. 캡처 브라우저는 쿠키도 로컬스토리지도 없는 새 컨텍스트다. 사용자 브라우저와는 다른 방문자라서, 로그인이나 온보딩 가드가 걸린 경로는 홈이나 로그인 페이지로 튕긴다. 그대로 찍으면 /service-feedback 카드에 / 화면이 박히고, 그 이미지가 캐시에까지 남아 계속 재사용됐다. 렌더가 안정된 뒤 최종 경로를 비교해(쿼리·해시·끝 슬래시 차이는 무시) 다르면 캡처하지 않고 422 REDIRECTED로 어느 경로로 튕겼는지 그대로 보여준다.
여기에 오탐 완화도 붙였다. i18n 로케일 프리픽스(/ko/foo)나 basePath 부착처럼 경로가 덧붙기만 한 리다이렉트는 같은 페이지로 간주한다. 캡처 컨텍스트에서 서비스 워커도 차단했다. 앱 셸을 캐시하는 서비스 워커가 붙으면 두 번째 캡처부터 네트워크 대신 셸이 응답해 엉뚱한 페이지가 찍힐 수 있다. 이 리다이렉트 관련 작업은 0.9.13으로 준비만 해 두고 배포하지는 않았다.
네 번을 거치면서 썸네일 요청 하나의 결과 갈래가 이렇게 정리됐다.
npm에 올라간 0.9.12의 dist는 0.9.11 빌드였다
캡처 수정을 연달아 내보내다 보니 배포 자체가 어긋났다. prepublishOnly도 prepack도 걸려 있지 않아서, 소스를 고친 뒤 빌드하지 않고 배포하면 그대로 나갔다. prepack은 패키지를 묶기 직전에 도는 npm 훅이다. 여기에 build를 걸어 버전과 dist가 어긋날 수 없게 했다.
배포 절차에 사람의 순서 준수가 필요하면 언젠가는 어긋난다. 실제로 이날 어긋났다. 도구가 강제하게 만드는 편이 확실하다.
문서도 같이 손봤다. README와 product-overview의 "라이브 iframe 썸네일" 서술을 서버 사이드 WebP 캡처로 고치고, 요구사항을 Node 18에서 20으로 올렸다. CHANGELOG 0.9.10에 썸네일 전환과 캔버스 LOD·가상화 항목을 넣었다. playground 데모 대화방 목록 데이터도 교체했다.
성능과 배포 정합성, 캡처 신뢰성이라는 서로 다른 층을 하루에 한꺼번에 손댄 날이었다. 캔버스 교체와 서버 사이드 캡처 전환은 맵이 커질 때 걸리던 두 병목을 같이 걷어냈고, 배포 타입 작업은 사용자가 알아차리기 어려운 종류의 조용한 고장을 자동 검사로 막았다. 다만 썸네일 전환 직후에 캡처 관련 버그가 네 번 연달아 나온 건 그냥 넘길 일이 아니다. Playwright라는 새 외부 프로세스를 들이면서 실패 경로를 먼저 정리하지 않고 시작한 대가였다. 하루에 세 번 배포한 것도, 그러다 dist와 버전이 어긋난 배포가 한 번 나간 것도 같은 원인에서 나왔다.
남은 것들을 적어 둔다. 캡처 실패 경로(타임아웃 · 연결 거부 · 취소 · 리다이렉트)마다 상태 코드와 캐시 정책을 테스트로 고정해야 한다. 인증이 필요한 경로의 썸네일은 세션을 주입할지 안내 카드로 대신할지 아직 정하지 못했다. 0.9.13은 prepack 게이트가 실제로 dist를 갱신하는지 확인하고 배포할 생각이다. dist가 어긋난 채 나간 0.9.12의 영향 범위도 봐야 하고, CHANGELOG 0.9.7의 누락 항목도 채워야 한다. 디스크 캐시 무효화 기준은 코드 변경 감지와 페이지 내용 변경 감지 중 어느 쪽으로 잡을지 검토가 필요하다. 노드 1,000개 규모에서 동시 3개짜리 캡처 큐의 대기 시간과 우선순위 정책도 아직 재 보지 않았다. pnpm check:exports는 CI에 넣어 배포 전 자동 게이트로 만들어야 하고, Node 20 상향이 README와 에러 메시지, 설치 안내에 빠짐없이 들어갔는지도 확인해야 한다.