본문으로 건너뛰기

putree 18. 카드 설정 모달과 구조 편집, 자동 갱신 되돌리기

·42 min read·18 / 20

putree에서 가장 큰 하루였다. 큰 줄기는 두 가지였다. 카드에서 설정을 고치는 모달, 그리고 맵에서 페이지를 추가·삭제·이동하는 구조 편집. 그 둘을 0.10.0으로 묶었고, 그 뒤에 큰 프로젝트에서 드러난 성능 회귀를 세 번에 걸쳐 되돌렸다.

콘솔에 쌓인 ERR_CONNECTION_REFUSED, 그리고 썸네일은 찍히는데 열면 빈 화면

콘솔에 ERR_CONNECTION_REFUSED가 무더기로 쌓이고 서버는 꺼져 있다고 나왔다. 원인은 세 가지였다.

포트 탐색이 열여섯 개 포트에 동시에 요청을 던지고 있었다. 성공 하나를 빨리 얻는 대신 실패 열다섯 개의 시끄러움을 샀다. 순차로 바꿨다. putree dev가 심어 준 NEXT_PUBLIC_PUTREE_SERVER_URL·TOKEN을 실제로 읽는 곳이 없어서 매번 포트를 처음부터 훑고 있던 것도 고쳤다. 그리고 localhost:3009cms.localhost:3009를 한 식구로 보게 했다. 허용 목록과 고정 origin 양쪽에서.

이어서 주소 정합 버그를 하나 잡았다. 뷰어가 node.path로 페이지를 열고 있었다. IANode.captureUrl의 정의는 "캡처와 뷰어가 실제로 열 대상"인데 PageViewerpath를 쓰고 있었다. cms.localhost 같은 origin 선언 서브트리에서는 맵이 떠 있는 origin에 path를 붙여 존재하지 않는 주소를 열었다. 썸네일은 captureUrl을 쓰니까 잘 찍히는데 클릭해 들어가면 빈 화면이었다. 증상만 보면 원인을 짚기 어려운 조합이다.

iframe src와 "해당 페이지로" 링크 둘 다 captureUrl로 바꿨고, 독(진행 중인 작업 목록을 보여주는 패널)의 pagePath와 화면 경로 표시는 path 그대로 뒀다. path는 노드 id이자 dirty-pages의 키다.

설정 진입점이 여섯 갈래였다

한 카드에 설정으로 들어가는 길이 여섯 개였다. not-found 노드는 "주소 알려주기", 동적 노드는 "값 알려주기", redirected는 읽고 끝나는 unlock 안내, 정상 카드는 아무것도 없다. 제목은 NodeTitle이 인라인으로 따로 저장하고, skill-slots는 window.confirm을 쓴다. 같은 일을 하는 코드가 여섯 군데에 조금씩 다른 모양으로 있으니 설정 하나를 늘리려면 여섯 곳을 고쳐야 한다.

경계를 다시 그었다. 카드는 진단만 한다(상태와 근거). 모달은 처방만 한다. 진입점은 설정 하나.

계획을 짜면서 구현 순서를 뒤집었다. 서버부터 짰더니 눈에 보이는 변화가 열세 번째 태스크에나 나왔다. 그때까지는 아무것도 검증하지 못한다. 입구를 먼저 붙여 두면 내용물을 갈아 끼우는 동안에도 설정에 닿지 못하는 구간이 생기지 않는다.

그래서 카드 헤더에 톱니를 달고 클릭은 stopPropagation했다. 카드 클릭은 뷰어 열기라 삼키지 않으면 설정을 누를 때마다 뷰어가 함께 열린다. 외부 링크 노드에는 달지 않았다. 처음엔 톱니를 hover에서만 드러냈는데 바로 되돌렸다. 찾을 수 없는 입구는 없는 입구와 같다. "설정에 닿을 방법이 없다"가 이 기능의 출발점이었다. 잡음을 걱정해 숨기는 대신 평소에는 흐린 색, hover와 포커스에서 또렷하게.

스펙 문구도 하나 고쳤다. "동적 노드면 샘플 섹션이 펼쳐진다"로 읽히던 문장인데, 그렇게 읽으면 구현자가 섹션을 조건부 렌더할 여지가 있다. 그건 지금 IAUrlModal의 문제를 그대로 옮기는 것이다. 모든 페이지의 모달은 같은 모양이다. 서브도메인도 동적 경로도 지금 없을 뿐 나중에 생기는데, 없을 때 필드가 안 보이면 새로 넣을 방법이 없다.

주소를 통째로 받지 않기로 했다

한 칸으로 받던 시절에는 낱말 하나의 뜻을 노드가 정했다. ia-input.ts의 주석 그대로 "동적 노드면 값, 정적 노드면 서브도메인". 그래서 어느 썸네일을 여느냐에 따라 다른 것을 묻는 화면이 나왔고, 무엇보다 /t/[tenant]가 서브도메인에 사는 경우 (값과 호스트가 둘 다 필요한 경우)를 아예 표현할 수 없었다.

칸을 나누고 뜻도 칸이 정하게 했다. resolveIAInputresolveOriginInput·resolveSampleInput으로 쪼갰고 둘 다 노드 상태를 보지 않는다. 저장은 호스트를 먼저 한다. 샘플의 미리보기 주소가 origin에 의존하니 반대 순서면 중간 상태에서 엉뚱한 호스트로 한 번 찍는다.

그 다음 더 큰 결정을 했다. 두 칸 모두 주소를 받지 않는다. 주소를 통째로 받으면 어디까지가 호스트고 어디부터가 경로인지 우리가 추측해야 하고, 그 추측이 틀리면 조용히 엉뚱한 접두사가 저장된다. 사람이 적을 것은 이름과 값뿐이고, 그것이 어떤 주소가 되는지는 미리보기가 책임진다.

동적 자리가 여러 개인 경로(/t/[tenant]/posts/[id])는 자리마다 칸을 하나씩 뒀다. 일부만 채우면 어느 자리가 비었는지 말해 주고 저장하지 않는다. 반쯤 채운 경로는 여전히 [id]를 품고 있어 열리지 않는다. 라우트 문법을 아는 자리에 fillDynamicSegments를 더했다. route-pattern.ts가 그 지식의 유일한 지점이라는 규약을 지킨다.

잃은 것도 있다. 주소를 받던 시절에는 붙여넣은 경로의 앞부분을 역산해 조상(/admin)에 origin을 적을 수 있었다. 이제 이름만 받으므로 경계를 알 수 없고, 지금 연 카드가 그 호스트의 루트가 된다.

Esc는 사실상 영영 안 먹고 있었다. 모달이 열리자마자 입력칸에 포커스를 주는데, 그 입력칸의 keydown 핸들러가 캔버스 단축키를 막으려고 stopPropagation하고, 그게 window에서 Esc를 듣는 CanvasOverlay의 리스너까지 막았다. "모달 > 뷰어 > 맵 순서로 하나만 닫는다"는 규약은 모달이 window까지 이벤트를 흘려보낼 때만 성립하는 것이었다. 삼키는 쪽이 닫는 책임도 지게 했다. 저장이 도는 중에는 Esc도 배경 클릭도 닫지 않는다.

값 칸 미리보기가 방금 친 호스트를 반영하지 못하던 것도 고쳤다. 위에 cms를 쳐 둔 채로 아래에 4를 치면 위는 cms.localhost, 아래는 localhost를 가리켜 화면이 따로 놀았다. 미리보기의 존재 이유가 "저장하면 무엇이 되는가"이므로 아직 저장되지 않은 입력도 함께 그려야 한다.

샘플 노드의 서브도메인은 부모에 적게 했다. /agents/2 카드에서 저장하면 "ia.config.ts에 없는 경로예요"로 실패했는데, 샘플은 config에 자기 항목이 없다. 부모 패턴 노드의 samples 배열 안에 사는 값이라 origin을 적을 자리 자체가 없다. NodeTitle이 제목에 대해 이미 하고 있던 판정과 같다.

저장했는데 화면이 그대로였다

저장 후 아무것도 하지 않고 "파일이 바뀌면 앱 HMR이 새 config를 내려보낸다"에 기대고 있었다. 그건 호스트 앱의 번들러가 그렇게 해 줄 때만 참이다. standalone 번들에는 HMR이 아예 없다. 그래서 이름을 고쳐도 서브도메인을 넣어도 모달만 닫히고 카드는 그대로였다.

patchIA는 이미 새 config를 응답으로 돌려주고 있었는데 양쪽 저장 경로가 그것을 버리고 있었다. 응답을 버리고 재조회에 기대는 코드는 대개 어딘가에서 조용히 실패한다. store에 configOverride를 두고 맵이 prop 대신 그것을 쓰게 했다. 낙관적 추측이 아니라 서버가 쓴 파일의 내용이므로, 실패했는데 화면만 바뀌는 상태가 되지 않는다.

그리고 "물려받은 서브도메인을 어떻게 보여줄 것인가"로 칸 표시를 네 번 바꿨다.

순서표시왜 바꿨나
처음에는상속값을 값으로 채움값이 그대로라 "변경 없음"이 되고 저장 버튼이 잠긴다. 부모에서 넣으면 자식이 다 다시 찍히는데 자식 카드에서는 아무 일도 안 일어났다
그 다음칸은 비우고 안내로만빈 칸이 "부모에 넣었는데 적용이 안 됐다"로 읽혔다. 실제로는 captureUrl까지 제대로 상속되고 있었고 화면만 그 사실을 감췄다
그 다음placeholder로 흐리게"안 들어간 건가"로 읽혔다
결국값으로 채우고 바로 아래 안내가 출처를 말한다자기 것이 아니라는 뜻은 맞았지만, 지금 이 페이지가 어디서 열리는지는 칸에 또렷하게 있어야 한다

이 과정에서 normalizeIAoriginDeclared를 함께 실어 보내게 했다. "이 노드가 직접 선언했는가"를 알아야 하는데 IANode.origin은 상속까지 반영한 값이라 그것만으로는 구분할 수 없다. 샘플과 variant는 config에 자기 항목이 없으므로 언제나 선언할 수 없다.

서브도메인을 지울 수도 있게 했다. 힌트는 "비워 두면 이 앱에서 엽니다"라고 약속하는데 실제로는 못 했다. 빈 칸이 입력 없음으로 취급돼 저장 버튼이 잠겼고, 한번 넣은 서브도메인은 ia.config.ts를 손으로 고쳐야만 지울 수 있었다.

미리보기가 "바뀔 값"만 그리던 것도 고쳤다. 이미 설정된 카드를 열면 변경 없음이라 상자가 빈 채로 남고, 사용자는 값이 먹은 건지 화면이 고장난 건지 구분할 수 없었다. 안 고쳤을 때는 "지금 이렇게 열려요"로 현재 주소를 보여준다. 바뀔 값이 아니므로 액센트는 쓰지 않는다.

토큰의 주인은 페이지가 아니라 호스트다

웹 저장소 토큰 보관소를 새로 뒀다(server/origin-auth.ts). 미들웨어가 빌려주는 쿠키(OriginSessionStore, 메모리)와 다른 물건이다. 사용자가 손으로 넣는 토큰이라 서버 재시작을 넘어 살아야 한다. 저장 정책은 ReviewStore와 같고, .putree/ 아래라 그 디렉터리가 스스로를 무시해 토큰이 저장소에 올라가지 않는다.

키를 노드 path가 아니라 origin으로 잡은 이유가 있다. localStorage는 브라우저에서 origin 단위로 격리된다. 토큰은 /admin/brand의 것이 아니라 그 호스트의 것이고, 같은 서브도메인 페이지 스무 개에 같은 값을 스무 번 적게 할 이유가 없다.

읽기 함수는 read()for()로 나눴다. 항목이 하나도 없어도 autoCollect: false는 살아 있는 설정이라, 캡처가 for()로만 판단하면 "자동 수집 끄기"가 통째로 무시된다. for()는 그 레코드를 undefined로 돌려주기 때문이다.

모달에 인증 섹션을 붙였다. 지금까지는 맵이 떠 있는 origin의 저장소를 통째로 긁어 보내기만 했다. 다른 호스트 노드에는 엉뚱한 값이 실렸고 사용자는 무엇이 실려갔는지 볼 수도 고칠 수도 없었다. 이제 캡처가 서버에서 고른다.

서브도메인이 경로를 떼는지도 고를 수 있게 했다. admin.localhost/notices처럼 짧아지는 것이 전형이라 기본은 떼기지만, cms.localhost/agents/[id]처럼 경로를 유지하는 배치도 흔한데 이름만 받기로 한 뒤로는 그걸 말할 방법이 없었다. 그리고 variant 삭제를 붙이면서 노드에 선언 당시의 query를 실어 보내게 했다. 경로에서 되짚으면 base path의 query와 섞여 어느 항목인지 모호해진다.

쿠키는 칸을 두지 않고 상태만 보여준다. 사용자가 넣을 필요가 없기 때문이다. 같은 호스트면 브라우저가 싣고, 다른 호스트면 putree/next 미들웨어가 넘겨준다.

문제는 두 번째다. 미들웨어가 설치돼 있고 누군가 그 호스트를 로그인 상태로 한 번 열었을 때만 전달되는데, 안 되어 있으면 캡처가 로그인 화면으로 튕기고 사용자는 이유를 알 방법이 없었다. REDIRECTED로 나타나던 그 자리다. 브라우저 것이 실리는지, 미들웨어가 넘겨주는지, 아무것도 없는지를 읽기 전용으로 보여주고 없을 때는 다음에 할 일까지 적는다. putree unlock을 설치하고 그 호스트를 로그인 상태로 한 번 열기.

구조 편집은 쌓아 두고 한 번에

op 모델을 먼저 뒀다(structure-ops.ts). 쌓기만 하고 파일은 건드리지 않는다. 이동 하나가 폴더를 옮기고 참조를 고치는 리팩터 잡이라, 손이 미끄러진 것까지 잡이 되면 되돌릴 것이 쌓인다. 연관된 이동 여러 건을 한 번의 리팩터로 묶으면 중간에 깨진 상태가 생기지 않는 이점도 있다.

자기 자신이나 자손 밑으로 옮기면 트리가 끊어지므로 거부하고 이유를 문구로 돌려준다. 같은 대상의 조작은 마지막 것만 남기고, 방금 추가한 것을 지우면 둘 다 사라진다. 파일에 아무 일도 일어나지 않아야 한다.

추가 버튼은 툴바에 뒀다. 카드마다 두면 "이 카드 아래 생기나"로 읽히는데 부모는 모달에서 고른다. 무엇보다 카드의 추가 버튼이 자식 추가로 고정되면 최상위 페이지를 만들 방법이 없다. 모든 카드가 누군가의 자식이기 때문이다. 삭제는 그 카드가 대상이라 카드에 뒀다.

부모 고르기는 네이티브 select에서 인라인 목록으로 바꿨다. select는 OS 크롬을 그대로 써서 이 오버레이와 따로 논다. 후보에서 샘플과 variant는 걸러낸다. 둘 다 config에 자기 항목이 없어 그 아래에 children을 적을 자리가 없다.

쌓아 둔 op를 캔버스에도 그렸다. 추가한 페이지가 어디에도 나타나지 않았기 때문이다. op만 쌓고 캔버스는 여전히 파일의 트리를 그렸으니 확인할 방법이 없었다. 추가 예정 카드는 점선 테두리에 "적용하면 만들어져요"를 얹고 캡처는 시도하지 않는다. 없는 페이지를 찍으면 404만 받는다.

이동은 자손 경로까지 함께 갈아 끼우고, 삭제는 트리에서 빼지 않는다. 빼면 그 아래 자식들이 함께 사라져 무엇이 지워지는지 볼 수 없다.

드래그로 부모를 바꾸는 것은 헤더의 손잡이에서만 시작하게 했다. 카드 전체를 잡게 하면 캔버스 팬·카드 클릭과 뜻이 겹친다. 놓을 자리는 elementFromPoint로 정한다. 카드는 캔버스 transform 안에 있어 좌표로 계산하면 줌·팬 배율을 다시 풀어야 하는데, 브라우저가 이미 푼 결과를 준다. 처음에는 "이동 예정"만 떴는데 어디로 가는지가 없으면 적용을 누를 판단이 서지 않아서, 무엇이 어디로 가는지를 드래그 중에 보이게 했다. 놓을 수 없는 카드는 물러나게 해서 무엇이 되고 무엇이 안 되는지를 시도해 보고 알 필요가 없게 했다.

샘플은 즉시 지운다. 옮길 수 없게 막고 나니 지울 방법도 함께 사라졌는데, 샘플 삭제는 구조 변경이 아니다. 부모의 samples 배열에서 한 줄 빼는 것뿐이고 라우트 파일이 따로 없어 코드는 그대로다. 리팩터 잡을 돌릴 이유가 없다.

config를 못 찾았을 때는 이유를 말해준다. "찾아본 곳: ..."만 적으면 "그런 파일이 없나"로 읽히는데, 흔한 원인은 파일이 없는 게 아니라 서버를 엉뚱한 디렉터리에서 띄운 것이다. 모노레포 루트에서 putree dev를 돌리면 앱의 config가 보이지 않는다.

홈(/) 아래 접두사가 //로 보이던 것도 잡았다. 화면이 부모 경로에 슬래시를 직접 붙여 만들고 있었다. joinPath는 최상위와 홈을 둘 다 슬래시 하나로 다루는데 그 규칙을 화면이 따로 쓴 탓이다. 같은 규칙을 두 곳에 두지 않으려고 parentPrefixOfjoinPath 옆에 두고 둘이 일치하는지 테스트로 묶었다. 그 밖에 상자 왼쪽 액센트 바 표현을 경고 문구·부모 고르기·확인 카드·스킬 항목에서 모두 걷어내 styles.ts 상단에 규칙으로 적고 테스트로 다시 들어오지 못하게 막았고, 삭제 버튼이 붙으면서 헤더의 space-between 때문에 제목·경로·아이콘이 흩어지던 것도 고쳤다.

파일은 putree가, 코드는 에이전트가

역할 경계가 이 기능의 핵심이다. ia.config.ts는 서버가 결정적으로 쓰고, 라우트 파일 이동·생성·삭제와 참조 갱신만 에이전트에게 맡긴다. 트리를 옮기는 규칙은 이미 applyOpsToConfig에 있고 캔버스가 같은 함수로 그리므로, 같은 일을 자연어로 다시 시키면 화면과 파일이 어긋난다.

에이전트를 쓰는 쪽은 참조 갱신이다. 경로 참조는 정규식으로 잡히는 것만 있지 않다. 템플릿 리터럴로 조립한 경로, 상수 파일의 경로표, 테스트 기대값까지 함께 고쳐야 라우트가 산다. 그리고 app/ 폴더 구조를 아는 codemod를 서버에 두면 "라우트 문법을 아는 곳은 route-pattern과 스캐너뿐"이라는 규약이 무너진다.

flowchart TD
    A["op 쌓기 (파일은 그대로)"] --> B["적용 누르기"]
    B --> C["서버가 op를 다시 만든다"]
    C --> D["putree가 ia.config.ts를 쓴다"]
    D --> E["맵이 목표 상태를 먼저 보여준다"]
    D --> F["에이전트가 라우트 파일과 참조를 고친다"]
    F --> G["잡 완료"]
    G --> H["영향받은 경로만 다시 캡처"]
    F --> I["실패"]
    I --> J["ia.config.ts만 바뀐 상태"]

순서는 config가 먼저다. 잡이 도는 동안에도 맵이 목표 상태를 보여줘야 하고, 실패해도 중단이 코드 쪽만 되돌리면 된다. 서버는 클라이언트가 보낸 op를 신뢰하지 않고 다시 만든다. 이 요청 하나가 파일을 옮기고 지우기 때문이다.

그리고 그 "config 먼저"가 곧바로 대가를 청구했다. 네 가지 증상이 같은 뿌리에서 나왔다.

증상원인해결
"Temp is not defined"로 앱이 죽음에이전트가 Route를 먼저 넣고 import를 나중에 넣는 사이 dev 서버 HMR이 반쪽 파일을 화면에 밀어넣었다프롬프트에 못박았다. 한 파일을 나눠 저장하지 말 것, 만들 때는 파일 먼저·가리키는 코드 나중, 지울 때는 반대
적용 직후 "중복 path"로 맵 전체가 사라짐config 싣기와 op 비우기를 따로 불러 그 사이에 한 번 렌더됐다. config에는 새 페이지가 있는데 op에도 같은 추가가 남아 캔버스가 같은 경로를 두 번 그렸고 normalizeIA가 던졌다settleStructure로 한 번의 갱신에 합쳤다. applyOpsToConfig도 방어한다(이미 있는 경로에 add 안 얹기, 찬 목적지로 move 건너뛰기)
화면은 제대로 만들어졌는데 카드에 404가 남음config가 먼저 바뀌니 카드가 그 순간 캡처하는데 라우트 파일은 아직 없다. SPA는 없는 경로에도 200으로 자기 404 화면을 준다잡의 done을 구독해 영향받은 경로를 다시 찍고, 진행 중인 페이지는 캡처를 아예 건너뛰고 스켈레톤을 보여준다
뷰어에 404가 만들어지는 내내 떠 있음카드는 스켈레톤으로 가렸는데 뷰어는 그대로였다"코드 반영 중 · 끝나면 이 자리에 화면이 들어와요"로 덮고, 잡이 끝나면 프레임을 다시 마운트한다. 그러지 않으면 덮개만 걷히고 그 아래 404가 남는다

SPA의 200 응답은 "그 페이지가 있다"는 뜻이 아니고, 서버의 상태 코드 판정에 걸리지 않으니 그 404 화면이 "정상 썸네일"로 캐시된다. 상태 코드로 캡처 유효성을 판정하는 모든 코드가 이 함정 위에 있다.

진행 표시는 툴바에서 카드 배지로 옮겼다. 상태를 툴바에 띄웠는데 그 자리는 맵 전체의 것이다. 카드에 일어나는 일은 퍼블리싱 중·작업완료·확인필요처럼 그 카드가 말해야 일관되고, 여러 페이지가 걸린 변경에서 어느 카드가 대상인지도 그래야 보인다.

상태 자체는 스토어에 뒀다. 적용 직후 쌓인 op가 비므로 그것을 그리던 컴포넌트가 사라지고, 거기 얹으면 표시도 함께 사라진다. 실패하면 "ia.config.ts만 바뀐 상태예요"로 지금 어떤 상태인지 말한다. 실제로 서버를 내리는 바람에 잡이 끊겨 config에만 페이지가 남은 일이 있었다.

페이지를 새로 만들 때는 퍼블리싱과 같은 슬롯 지침을 준다. 구조 잡은 자동 감지 프로필만 받고 있었는데, 페이지를 새로 만드는 것은 UI 코드를 짜는 일이라 디자인 시스템·스타일링·아이콘 지침이 퍼블리싱과 똑같이 필요하다. 같은 프로젝트에서 퍼블리싱한 화면과 새로 만든 화면이 서로 다른 규칙을 따르면 그게 곧 부채다. 추가가 섞인 작업에만 싣는다.

되돌리기도 손봤다. 파일마다 따로 판단하다 보니 참조 파일(app.tsx)은 충돌로 남기고 새로 만든 파일만 지우는 조합이 나왔다. 없는 파일을 import하는 코드가 남아 앱이 통째로 죽었다("Failed to load url /src/pages/temp.tsx"). 한 잡이 만든 파일과 그것을 가리키게 고친 파일은 한 묶음이다. suspendRunningrevertJob이 같은 판단을 각자 구현하던 것도 합쳤다.

playground 픽스처도 정리했다. 맵을 시험하며 넣은 cms.localhost:4500 선언이 저장소에 딸려 들어가 픽스처가 스스로를 깨뜨리는 상태였고, 적용 중단으로 어중간하게 남은 /temp도 함께 지웠다.

배지 우선순위도 바꿨다. 독은 "퍼블리싱 중 1"인데 카드 배지는 작업완료였고, 인스펙터에는 두 오버레이가 겹쳐 그려졌다. 작업완료가 퍼블리싱 중보다 앞에 서 있었기 때문이다. 작업완료는 사람 사인을 기다리며 멈춰 있는 상태라 현재를 가려서는 안 된다.

확인필요 > 퍼블리싱 중 > 작업완료 순으로 바꾸고 대가도 적어 뒀다. 한 블록이 사인을 기다리는 동안 다른 블록을 퍼블리싱하면 사인 대기 배지가 잠시 가려진다. 독에는 남아 있다.

그 호스트로 데려다주고 0.10.0으로 묶었다

서브도메인 페이지는 그 호스트에서 맵을 열어야 편집할 수 있다. 교차 출처 iframe은 DOM을 읽을 수 없어 블록 선택이 안 된다. 브라우저 보안이라 우회할 방법이 없고, 그래서 지금까지는 "거기서 맵을 열어야 해요"라고 말만 했다. 말만 하면 사용자는 주소를 손으로 치고, 맵을 다시 열고, 카드를 다시 찾아야 한다. 비개발자에게는 그 세 단계가 곧 막다른 길이다.

?putree=/경로로 들어오면 맵이 열리고 그 페이지 뷰어가 바로 뜨게 했다. 주소에서는 곧바로 지운다.

여기까지 두 덩어리를 합쳐 0.10.0으로 릴리즈했다. 호환성이 깨지는 변경이지만 minor로 올렸다(0.x 규칙). IANodeoriginDeclared가 필수로 생겨 그 타입을 직접 구성하던 코드가 깨진다. CHANGELOG에는 판단 근거를 남겼다. 특히 주소 대신 값만 받기로 하면서 잃은 것(붙여넣은 경로로 조상을 역산하던 편의)과 그 대신 둔 것(경로를 떼는지 고르는 체크박스)을 함께 적었다.

README에는 두 절을 더했다. 카드에서 설정 고치기, 페이지 추가·삭제·이동. 특히 적용이 파일을 어떻게 나눠 맡는지를 적었다. 그 경계가 이 기능을 믿고 쓸 수 있는 근거다. 테스트는 930개였다.

자동 갱신을 세 번에 걸쳐 되돌렸다

A프로젝트처럼 큰 곳에 물렸더니 카드가 뜨지 않을 만큼 느려지고, 서브도메인 카드는 캡처와 스켈레톤을 끝없이 오갔다. 둘 다 자동 갱신을 넣으면서 만든 회귀다.

1차로 해싱과 무한 재시도를 끊었다. dirty 판정이 프로젝트의 모든 소스 파일을 SHA-256으로 해싱하고 있었다. dirty-pages의 주석이 이미 그 전제를 적어 뒀었다. "업데이트는 유저가 명시적으로 누르는 저빈도 작업이므로 정확성을 위해 소스 내용 지문을 쓴다." 그것을 3초마다 돌린 것이다. 경로·크기·mtime만 보는 값싼 지문을 앞에 두고 달라졌을 때만 해싱하게 했다.

그리고 서버의 dirty는 캡처가 성공해야 내려간다. 로그인으로 튕기는 카드는 영원히 dirty로 남고 매번 다시 알리니 캡처 → 실패 → 다시 캡처가 끝없이 돌았다. 한 번 알린 경로는 목록에서 사라지기 전까지 다시 알리지 않게 했다.

2차로 주기를 잡 활동으로 갈랐다. 조회 한 번이 여전히 소스 트리를 훑기 때문이다. 퍼블리싱·구조 변경이 도는 동안은 2초, 그 외에는 30초. 폴링을 잡이 돌 때만 하자는 생각도 있었지만, 자동 갱신이 애초에 필요했던 장면은 에디터에서 직접 고칠 때다. 그때는 잡이 돌지 않는다. 끄는 대신 늦췄다.

3차로 걷어냈다. 문제는 조회 비용이 아니라 그 결과였다. 소스 파일 하나만 바뀌어도 서버는 전체 페이지를 dirty로 세운다. 어느 페이지가 영향받았는지 알 방법이 없으니 안전한 쪽으로 전부 표시하는 것이고, 사람이 업데이트를 누를 때는 그게 맞다. 그런데 자동으로 하면 파일 하나 저장할 때마다 맵 전체가 Playwright 캡처를 다시 돌린다. 그러니 주기를 줄이는 어떤 튜닝도 답이 될 수 없었다.

다시 찍는 시점을 둘로 고정했다. 구조 변경 잡이 끝났을 때(영향받은 경로를 우리가 알고 있다), 사용자가 업데이트를 눌렀을 때(전체를 훑는 비용을 감수하겠다는 뜻). 쓰이지 않게 된 엔드포인트와 헬퍼는 지우고 값싼 지문은 남겼다. 업데이트 버튼 자체가 큰 프로젝트에서 빨라진다. 그리고 "하나가 바뀌면 전부 dirty"라는 성질을 테스트로 박아 뒀다. 다음에 같은 생각이 들었을 때 근거가 코드에 있어야 한다.

2x 캡처가 들고 있던 800MB

카드는 260px 폭인데 캡처는 2x, 즉 2880×1800이었다. 그 크기 그대로 디코딩되면 픽셀당 4바이트라 한 장이 20MB짜리 비트맵이 되고, 캐시 상한이 40장이니 800MB 수준의 비트맵이 캔버스에 얹힌다. 그 메모리가 그대로 합성 비용이라 카드가 많은 프로젝트에서 줌·팬이 눈에 띄게 밀렸다.

필요한 해상도를 다시 셈했다. 카드 폭 260px에 기본 fit 최대 확대 2배, 거기에 DPR 2배를 곱하면 1040px이다. 1440px 뷰포트를 1x로 뜨면 여유가 남는다. 2x는 그 네 배를 들고 있었다. 대가는 2.7배를 넘겨 확대하면 뭉개지는 것인데, 그 배율에서 자세히 보는 일은 뷰어(라이브 iframe)가 맡는다. CAPTURE_VERSION을 올려 옛 캐시를 무효화했다.

캔버스를 움직이는 동안에는 카드 아이콘을 그리지 않게 했다. 어차피 누를 수 없고, 설정·삭제·드래그 세 개가 카드마다 SVG라 수십 장이면 매 프레임 비용이 된다.

성능 규약이 코드 주석에만 있던 것도 테스트로 박았다. transform은 DOM에 직접 쓰고 멎은 뒤 한 번만 상태를 맞춘다는 규약은 무심코 setState 하나를 더하면 조용히 깨진다. 휠을 다섯 번 굴려도 카드 렌더 수가 그대로인지, 그러면서도 world의 transform은 실제로 바뀌는지 함께 본다. 앞만 보면 "아무 일도 안 일어나는" 구현도 통과한다.

0.10.2에는 잔가지 몇 개를 실었다.

  • 독의 작업 목록이 하나뿐이면 헤더 클릭이 곧바로 그 카드를 연다. 목적지가 하나인데 목록을 한 번 더 펼치게 하고 있었다
  • 카드 origin은 dev 서버가 관측한 앱 주소로 고정한다. origin 없는 카드를 "현재 브라우저 origin"에 붙이면 서브도메인이나 다른 포트로 이동했을 때 엉뚱한 주소를 캡처한다. dev stdout에서 실제로 관측한 앱 origin을 /api/project-context로 내려준다
  • 뷰어 안에서 이동해도 작업은 현재 카드에 귀속한다. viewerPath는 iframe을 처음 연 카드라서 프레임 안 SPA 이동 뒤에도 그대로였는데, 실제 도달 경로를 activePagePath로 따로 추적한다
  • 현재 페이지를 바로 여는 편집 FAB. 맵을 열고 카드를 다시 찾지 않아도 지금 보고 있는 페이지가 IA에 있으면 한 번에 뷰어로 간다. IA 밖이면 비활성에 이유를 붙인다

남은 것은 네 가지다.

  • variant 삭제는 붙였지만 쿼리로 갈리는 화면을 캔버스에서 추가하는 입구는 없다. 지금은 config에 직접 쓰는 variants뿐이다
  • 독이 캔버스 안에 살아서 뷰어가 열리면 통째로 가려진다. A 페이지 작업을 걸어두고 B 페이지를 열면 돌고 있는 일을 볼 방법이 없다
  • 리로드하면 "퍼블리싱 중" 행이 사라지는 비대칭. 완료된 일은 reviews.json이 근거를 들고 있는데 실행 중인 일에는 그런 근거가 없다
  • putree unlock 실기동 확인 (A프로젝트 어드민)

이날 한 일의 절반 가까이가 만들고 나서 화면을 보고 고친 것이다. 상속 표시 하나를 네 번 왕복했고, 톱니 표시를 두 번, 진행 표시 자리를 두 번, 자동 갱신을 세 번 되돌렸다. 되돌린 자국만 늘어놓으면 방황처럼 보이지만, 매번 되돌린 근거가 "사용자가 그 화면을 어떻게 읽는가"였다는 점은 일관됐다. 계획서에 없던 것을 화면이 알려줬다.

특히 같은 실수가 하루에 세 번 나왔다. 정상 카드에는 설정 입구가 없었고, 서브도메인이 없으면 칸이 사라졌고, 톱니를 hover에서만 드러냈다. 지금 없을 뿐 나중에 생기는 것을 UI에서 숨기면 넣을 방법 자체가 사라진다. 조건부 렌더는 "지금 필요 없다"를 "영영 못 한다"로 만든다.