putree 15. 앵커 없는 selector 버그와 블록 소스 마커 도입
시작은 "바운더리가 스크롤을 안 따라온다"
작업완료 경계가 엉뚱한 자리에 뜨고, 스크롤해도 화면에 붙박이로 남는다는 이야기가 들어왔다. 퍼블리싱 중에 뜨는 오버레이도 증상이 같았다. 항상 그런 것도 아니어서 처음에는 "처음 작업만 그런가" 정도로 짐작하고 있었다.
원인은 하나였다 — 앵커 없는 selector
요소의 CSS 경로를 만들어주는 cssPath가 돌려주는 selector는 body에 앵커되지 않은 상대 경로였다. 오버레이는 doc.querySelector로 한 번만 조회하니, 문서에서 제일 먼저 나오는 같은 구조의 요소가 걸린다.
playwright로 playground 홈의 DOM을 전수 대조해봤다.
selector: "div:nth-of-type(2)"
의도한 요소: top 2506 (페이지 하단)
실제 매칭: div.gnb-right — top 15, sticky 헤더 안내가 고른 건 페이지 한참 아래 블록인데, 실제로 잡힌 건 sticky 헤더 안의 요소였다. sticky 헤더가 고정돼 있는 동안에는 그 안의 요소도 스크롤에 따라 rect(요소의 화면상 위치·크기)가 변하지 않는다. 정확히는 containing block(위치 계산의 기준이 되는 상위 박스) 끝에 닿으면 그때부터 다시 같이 흘러간다. 그래서 박스가 화면에 붙박이로 보였다.
오버레이 동기화 로직 자체는 멀쩡했다. 선택 박스가 요소를 픽셀 단위로 따라오는 것을 실측으로 확인했다. 잘못된 요소를 정확하게 따라가고 있었을 뿐이다. 간헐적이었던 이유도 여기서 풀렸다. 선택한 블록의 경로에 고유한 태그가 섞여서 우연히 유일해지면 제대로 잡힌다.
selector를 body > ...로 앵커되게 바꿨고, 경로를 8단계에서 잘라내던 처리를 없앴다. 그리고 cssPath(body)가 빈 문자열을 돌려주던 걸 'body'로 바꿨다. querySelector('')는 예외를 던지기 때문에, 전체 모드 오버레이가 아무 메시지 없이 안 뜨고 있었다.
캡처 이후 DOM이 바뀐다는 문제는 남았다
앵커를 고쳐도 위치 기반 selector의 한계는 그대로다. 그리고 퍼블리싱이 하는 일이 정확히 그 위치를 바꾸는 일이다. 그래서 소스 마커를 도입했다. 위치로 요소를 찾는 대신, 그 요소의 소스 코드에 고유한 표식을 직접 심어두고 그 표식으로 찾는 방식이다.
실행할 때 클라이언트가 blk_ + 8자리 hex로 블록 아이디를 발급한다. 그리고 에이전트에게 주는 프롬프트에 대상 JSX 루트에 data-putree-block 속성을 다른 수정보다 먼저 넣으라는 규칙을 박아뒀다. 표식이 먼저 심겨야 퍼블리싱이 코드를 헤집는 동안에도 그 블록을 계속 따라갈 수 있기 때문이다. 작업이 끝나면 서버가 변경된 파일에서 그 삽입을 검증한다. 안 심겼으면 blockId를 null로 두고 selector 폴백으로 돌아간다. 마커를 믿고 있다가 조용히 깨지는 상황을 막으려는 처리다.
flowchart TD
A["클라이언트가 blk_ + 8 hex 발급"] --> B["에이전트가 JSX 루트에 data-putree-block 삽입"]
B --> C{"변경 파일에 마커가 심겼나"}
C -->|예| D["마커로 요소를 찾는다"]
C -->|아니오| E["blockId는 null, selector 폴백"]
E --> F["동기화마다 마커 후보를 다시 시도"]
F --> D
D --> G["승인·되돌리기·재퍼블리싱 시 서버가 속성 제거"]
오버레이는 마커와 selector를 후보 목록으로 두고 순서대로 시도한다. 폴백으로 버티는 중이면 상위 후보를 매 동기화마다 다시 노린다. 퍼블리싱 도중에 마커가 파일에 들어오는 순간 그쪽으로 갈아타게 하려는 것이다.
기동 스윕이 이 저장소의 테스트 파일을 고쳤다
"세션이 죽어도 찌꺼기가 남지 않게" 하려고, 기동할 때 프로젝트를 전수 스캔해서 고아 마커를 지우도록 설계했다. 전체 테스트를 돌리자 이 저장소의 테스트 파일이 실제로 손상됐다.
- expect(prompt).toContain('data-putree-block="blk_1a2b3c4d"')
+ expect(prompt).toContain('')마커 문자열이 정당하게 들어 있는 파일, 그러니까 테스트 코드나 도구 코드나 스냅샷을 스윕이 구분할 방법이 없다. 유저 저장소에서도 똑같이 날 일이었다.
그래서 스윕 범위를 리뷰 기록(reviews.json)이 아는 파일로 좁혔다. 도구가 자기 기록 밖의 유저 파일을 건드리는 건 허용하지 않기로 했다. 대가로 reviews.json이 사라진 경우의 마커는 파일에 남는다. 렌더에 영향 없는 data 속성 하나이고 grep으로 찾을 수 있어서 감수하기로 했다.
파일을 고치는 기능은 고쳐도 되는 근거가 있는 범위 안에서만 움직여야 했다. 패턴이 우리 것이라는 사실만으로는 근거가 안 된다.
마커와 폴백이 실제로 갈아타는지 브라우저에서 확인했다
마커를 아래쪽 섹션(top 1613)에 심고, selector 후보는 헤더 안의 미끼(top 15)를 가리키게 둔 상태에서 playwright로 단계별 위치를 재봤다.
| 단계 | 요소 top | 박스 top |
|---|---|---|
| 1. 마커 우선 | 1613 | 1613 |
| 2. 스크롤 +400 | 1213 | 1213 |
| 3. 마커 제거 후 폴백 | 15 | 15 |
| 4. 마커 복귀 후 다시 마커 우선 | 1213 | 1213 |
3번은 처음에 실패했다. 마커가 지워졌는데도 옛 요소를 계속 물고 있었다. 물고 있는 요소가 자기를 찾아낸 후보에 여전히 맞는지 el.matches(요소가 특정 selector에 해당하는지 확인하는 DOM 메서드)로 확인하도록 고쳤다. 유닛 테스트로는 안 잡히고 실제로 브라우저를 돌려봐야 나오는 케이스였다.
putree 594개, editor-core 5개 테스트가 통과했고 type-check와 lint도 깨끗하다. 리뷰 저장 포맷은 v1에서 v2로 옮겼는데, 진행 중인 확인 대기 항목은 버리지 않고 그대로 넘겼다. 이미 화면에 떠 있는 작업완료 항목은 옛 selector가 job 키에 박혀 있어서 그대로 남는다. 승인하거나 다시 퍼블리싱하면 새 앵커로 바뀐다.