pubuilder 14. 썸네일 무효화 단일화와 퍼블리싱 패널 이미지 첨부
pubuilder의 IA 맵에는 확인필요 상태가 있다. 퍼블리싱이 끝난 페이지를 사람이 눈으로 한 번 보고 넘기라는 표시인데, 확인의 근거가 되는 그림이 썸네일이다. 그 그림이 틀리면 상태 자체가 의미를 잃는다. 지금까지 강제로 다시 찍게 해둔 건 퍼블리싱이 끝난 페이지뿐이었다. 사람이 에디터에서 직접 고친 변경, 새로 만든 라우트, 되돌리기로 원복한 페이지에는 여전히 옛 화면이 걸려 있었다. 썸네일 캐시 키가 [CAPTURE_VERSION, url, width, height, authFingerprint]라서 페이지 내용이 아예 들어 있지 않다는 게 근본 원인이었다.
두 번째 축은 입력이다. 퍼블리싱 패널은 Figma 링크와 자연어 지시 두 가지만 받는데, "이 시안처럼", "이 부분 간격" 같은 요청은 말로 옮기는 비용이 실제 수정보다 크다.
앞쪽은 v0.9.18(8/24)로, 뒤쪽은 v0.9.19(8/25)로 나갔다.
썸네일 무효화는 서버 한 곳에서만 판정하게 했다
무효화(invalidation, 캐시에 든 값을 더 이상 못 믿는다고 표시하는 일)의 단일 출처를 서버에 뒀다. 업데이트 버튼을 누르면 서버가 프로젝트 소스를 훑어 파일별 sha256 지문(파일 내용을 통째로 해시한 값. 내용이 1바이트라도 다르면 값이 달라진다)을 남기고, 지문이 달라진 파일에서 영향받는 페이지만 dirty로 표시한다. 어떤 페이지가 영향을 받는지 안전하게 한정할 수 없는 변경, 예를 들어 공용 스타일이면 allPagesDirty로 올린다. 같은 요청에서 라우트도 다시 훑어 새로 생긴 경로를 IA에 병합하고, 패널에 변경 페이지 N개 · 신규 M개로 결과를 보여준다.
mtime과 size를 보는 대신 내용 해시를 쓴 건, 같은 길이로 빠르게 저장한 변경을 놓치기 때문이다. 업데이트는 유저가 명시적으로 누르는 저빈도 작업이라 정확성 쪽에 비용을 더 쓸 수 있다.
판정을 서버에 둔 이유가 있다. React 카드는 유저가 맵을 열 때 마운트되므로, 클라이언트가 기억하는 "다시 찍어야 함"은 마운트 순서에 묻혀 사라진다. 서버가 기억하면 화면 밖 카드도 처음 나타나는 순간 반드시 강제 재캡처된다.
늦게 끝난 캡처가 dirty 플래그까지 지우고 있었다
되돌리기가 파일을 baseline으로 되돌려도 그 페이지는 dirty로 표시되지 않아서, 카드에는 되돌리기 전 화면이 계속 걸렸다. 되돌린 경로를 dirty로 기록하는 것까지는 자명한데, 여기에 경합이 하나 숨어 있다. 되돌리기보다 먼저 시작된 캡처가 늦게 끝나면 그 결과가 새 상태를 덮고, 게다가 dirty 플래그까지 지워버려 옛 썸네일이 영구히 박제된다.
그래서 dirty에 페이지별 세대(generation, 값이 바뀔 때마다 하나씩 올라가는 정수. 비동기 작업이 시작 시점의 세계가 아직 유효한지 확인하는 데 쓴다)를 뒀다. 캡처는 네비게이션 전에 세대를 읽고, 끝난 뒤 그 세대일 때만 dirty를 지운다. 도는 사이에 새 변경이 들어왔다면 그 캡처는 이미 옛 화면이라 dirty를 지우면 안 된다.
썸네일 한 장이 로드될 때마다 캔버스 전체가 다시 계산되고 있었다
썸네일 캐시가 캔버스 컴포넌트의 React state였다. 한 장이 로드될 때마다 노드 배열 전체가 다시 조립되고 레이아웃·연결선·미니맵이 재계산됐으며, 카드마다 data 객체가 새로 생겨 memo도 듣지 않았다. IntersectionObserver도 카드 수만큼 생성됐다. 캐시를 별도 스토어로 빼서 카드가 자기 페이지의 캐시 여부와 재캡처 키만 구독하게 하고, observer는 캔버스당 하나를 공유하게 했다. playground 12노드 기준으로 observer가 11개에서 1개로 줄었다.
서버 쪽에서는 캐시 히트마다 webp를 통째로 읽어 sha256을 다시 돌리고 있었다. 그 응답을 받은 브라우저가 곧바로 blob을 받아가므로 같은 파일을 두 번 읽는다. 크기와 mtime으로 같은 바이트인지 확인하고 해시를 재사용하도록 바꿨다. 이때 LRU 터치가 mtime을 올리면 "내용이 바뀌었다"는 신호가 오염되므로, 터치는 atime만 올린다. 정리 기준이 max(atime, mtime)이라 캐시 정책과 삭제 순서는 그대로다.
최적화라고 넣은 게 체감상 손해였다
반대로 걷어낸 것도 있다. 축소하면 썸네일을 도형으로 바꾸는 LOD(Level of Detail, 화면상 크기에 따라 표현 밀도를 낮추는 방식)와 viewport 가상화를 제거하고 카드를 상시 마운트하게 했다. 팬이나 줌을 멈출 때마다 재캡처와 재마운트가 일어나 오히려 끊겼다. 관련 테스트 91줄도 같이 지웠다.
확인필요 배지는 페이지 카드와 FAB에 같은 우선순위로 달았다. 맵을 닫아 둔 사이에도 확인할 게 남았다는 사실이 보여야 한다. 완료 표기는 완료에서 작업완료로 바꿨다. 퍼블리싱이 끝난 것과 사람이 확인을 마친 것은 다르다. 패널 로그 줄바꿈도 어절 단위로 고정했다. 단어 중간에서 잘렸다.
드롭·붙여넣기·파일 선택 세 경로를 한 함수로 모았다
퍼블리싱 패널에 참고 이미지를 첨부할 수 있게 했다. 드래그앤드롭, 붙여넣기(⌘V), 파일 선택 세 경로를 한 함수로 모았다. LLM 입력창에 이미지를 붙여넣는 감각을 그대로 재현하는 게 목표라, 세 경로의 결과가 사용자 입장에서 구별되면 안 된다. png/jpeg/webp, 최대 4장, 장당 10MB.
전송 방식에서 결정이 하나 갈렸다. readBody의 MAX_BODY_BYTES가 1MB이고 초과하면 413으로 스트림을 끊으므로 publish 본문에 base64를 싣는 길은 애초에 없다. 드롭하는 즉시 별도 엔드포인트로 올리고 assetId만 들고 있다가, 실행할 때 id 배열로 싣는다. 기존 JSON 계약이 그대로 유지되고, 용량이나 포맷 위반을 실행 버튼이 아니라 드롭 시점에 알려줄 수 있다. 기각한 대안은 두 가지다. 실행 시점 일괄 업로드는 검증 피드백이 실행까지 밀린다. multipart 교체는 isLegacyBody 분기를 포함해 계약 전부를 건드린다.
여러 장은 여러 요청이다. 3장이 한 번에 들어오면 Promise.all로 3개 요청을 띄운다. 하나가 검증에 걸려도 나머지는 올라가고 실패한 칩에만 사유가 남는다. multipart 한 덩어리로 묶으면 얻기 어려운 성질이다. 파일명은 쿼리 파라미터로 받는다. 커스텀 헤더를 쓰면 Access-Control-Allow-Headers 프리플라이트 설정을 건드려야 하는데 그럴 이유가 없다.
검증은 3단이다. content-type 화이트리스트, 스트리밍 중 바이트 상한, 그리고 매직바이트 스니핑(파일 앞부분 몇 바이트의 고정 시그니처를 직접 읽어 실제 포맷을 확인하는 것. \x89PNG / \xFF\xD8\xFF / RIFF....WEBP). 확장자와 MIME 헤더만 믿지 않는다. assetId는 uuid만 오가고 경로는 서버만 안다. 클라이언트가 경로를 보내는 구조면 경로 주입이 열린다.
에이전트 전달은 Figma 모드가 이미 쓰던 경로를 그대로 쓴다. 프롬프트에 파일 경로를 문자열로 박아 에이전트가 Read로 열게 한다. LLM API에 base64를 직접 싣는 건 이 시스템의 구조가 아니다.
린트 규칙은 발명이 아니라 이미 지키고 있는 것의 고정이다
eslint를 도입하면서 규칙을 고르는 기준을 하나로 뒀다. 이 저장소가 이미 지키고 있는 것만 규칙으로 만든다. 후보 규칙마다 현재 코드의 위반 수를 실제로 세어 docs/lint-plan.md에 적었고, 0이거나 그 자리에서 정리할 수 있을 때만 채택했다. 그렇게 넣은 결과 pnpm lint는 위반 0으로 통과한다.
가장 도움이 되는 건 레이어 경계다. server/는 React를 모르고, components/는 node:를 모르고, styles/는 아무것도 의존하지 않는다. 이미 위반 0건이라 지금이 고정하기에 최적의 타이밍이었다. import-x/no-restricted-paths로 고정했다. 이 규칙은 어떤 디렉터리에서 어떤 디렉터리를 import하면 안 되는지를 경로 단위로 막는다.
두 가지를 실측으로 배웠다. 하나, no-restricted-paths는 TS 리졸버를 붙이기 전까지 아무 위반도 잡지 못한다. 확장자 없는 상대경로를 해석하지 못해서다. 그래서 위반 0으로 나온 규칙은 전부 일부러 위반 파일을 만들어 발동을 확인했다. 둘, src/components/Fab.tsx가 git에는 대문자, 디스크와 import에는 소문자로 갈려 있었다. macOS에서는 문제가 안 보이지만 Linux CI에서는 깨진다. git mv로 정리하고 unicorn/filename-case: kebabCase로 재발을 막았다.
이날 고친 것들
| 문제 | 원인 | 해결 |
|---|---|---|
| 되돌린 뒤 카드에 옛 화면이 영구히 남음 | 되돌리기가 dirty를 남기지 않고, 먼저 시작된 캡처가 끝나며 dirty를 지움 | 되돌린 경로를 dirty로 기록 + 페이지별 dirty 세대로 늦은 캡처의 clear 차단 |
| 썸네일 한 장 로드에 캔버스 전체 재계산 | 캐시가 캔버스 컴포넌트 state, 카드마다 새 data 객체 | 캐시를 별도 스토어로 분리, 카드가 자기 페이지 키만 구독 |
| 캐시 히트마다 webp를 두 번 읽음 | 히트 응답에서 sha256 재계산 후 브라우저가 blob을 또 요청 | 크기·mtime으로 동일 확인 후 해시 재사용, LRU 터치는 atime만 |
| 축소·팬 시 끊김 | LOD·viewport 가상화가 재캡처와 재마운트를 유발 | 최적화 제거, 카드 상시 마운트 |
| 블록을 바꿔도 이전 첨부 안내 문구가 남음 | 첨부 컨텍스트는 초기화되는데 문구만 잔류 | 블록 변경 시 안내 문구도 함께 초기화 |
| 첨부가 후속 대화에서 사라짐 | job 종료마다 도는 cleanupPaths가 확인필요로 멈춘 실행의 첨부까지 삭제 | 첨부는 cleanupPaths에 싣지 않는다 (후속 답변이 같은 assetId를 다시 싣는다) |
| 레이어 경계 규칙이 위반을 못 잡음 | TS 리졸버 없이 확장자 없는 상대경로 해석 실패 | eslint-import-resolver-typescript 연결 + 위반 파일 probe로 발동 확인 |
무효화는 한 곳에서만 판정해야 한다. 클라이언트가 "다시 찍어야 함"을 기억하면 마운트 순서에 묻혀 사라진다. 그래서 처음부터 서버에 뒀다. 그리고 비동기 무효화에는 세대가 필요하다. "작업이 끝났으니 플래그를 지운다"는 그 작업이 시작된 시점의 세계가 아직 유효할 때만 옳다.
되돌리기 후 썸네일이 옛 화면으로 남는 건 API 테스트로는 절대 안 나온다. 되돌린 다음 맵을 직접 열어봐야 보인다. 대신 세대 경합 쪽은 실측 전에 설계로 잡을 수 있었다.
최적화는 되돌릴 수 있어야 한다. LOD와 가상화는 이론적으로 맞았지만 체감은 나빴다. 넣은 것보다 걷어낸 판단이 더 어려웠다.
eslint를 지금 넣은 것도 타이밍 판단이었다. 경계가 이미 깨끗할 때 규칙을 걸면 비용이 0이고, 한 번 무너진 뒤에 걸면 대규모 정리가 먼저 필요하다. Fab.tsx 한 건이 그 사이에 이미 새고 있었다는 게 근거가 됐다. 위반 수를 세지 않고 넣은 규칙은 부채가 되고, 발동 확인을 안 한 규칙은 있으나 마나다. 실제로 규칙 하나가 위반을 못 잡고 있었다.
pnpm type-check는 editor-core · pubuilder · playground 전부 클린이었고, pnpm test는 50파일 542케이스가 전부 통과했다.
다음 할 일
- allPagesDirty 판정이 지금은 보수적이다. 공용 모듈 변경의 영향 페이지를 import 그래프로 좁힐 여지가 있다
- 첨부 이미지 고아 파일 — 드롭만 하고 실행하지 않은 세션의 업로드가 남는다. TTL 정리 필요
- PublishPanel이 publishJobs 객체 전체를 구독하는 문제 — primitive 구독으로 좁히기
- 린트 2단계:
docs/lint-plan.md에서 커스텀 규칙으로 분류한 항목들