본문으로 건너뛰기

putree 16. 동적 라우트 선언 전달과 커밋 히스토리 재작성

·23 min read·16 / 20

[id] 같은 동적 경로로 만들어진 페이지가 캔버스에서 전부 "로그인이 필요한 페이지"로 나온다는 이야기가 들어왔다. 자물쇠 아이콘이 붙은 카드였다. 인증과는 아무 상관이 없는 페이지들이었다.

자물쇠는 인증이 아니라 리다이렉트를 본 결과였다

세 가지 증상이 한꺼번에 올라왔는데 뿌리가 하나였다.

첫 번째가 자물쇠다. ia.config.ts/admin/notices/[id]가 문자열 그대로 남아 있으니 썸네일 캡처가 그 문자열을 주소로 열었다. Next는 브래킷 부분을 파라미터 값으로 받고, 그 값으로 데이터를 못 찾은 앱이 목록으로 되돌린다.

flowchart LR
    A["브래킷 경로가 config에 남음"] --> B["그 문자열 그대로 주소 요청"]
    B --> C["파라미터 값으로 해석됨"]
    C --> D["해당 데이터 없음"]
    D --> E["앱이 목록으로 리다이렉트"]
    E --> F["REDIRECTED 판정"]
    F --> G["자물쇠 카드"]

두 번째는 서브도메인 페이지가 "페이지를 찾을 수 없다"로 뜨는 것이었다. A사의 proxy.ts가 서비스 도메인에서 /admin을 본문 없는 404 응답으로 막는다. 본문이 비어 있으니 렌더 여부 검사에 걸려 일반 캡처 실패로 분류됐다. 의도된 방어인데 오류라고 말하고 있었다.

세 번째는 재스캔하면 트리가 무너지는 것이었다. /[campus]/boys로 바꿔 적어 둔 config에 새 라우트 /[campus]/plan을 병합하면 노드가 루트로 튀었다. 원인은 merge.ts의 비대칭이었다. 커버리지 판정(isCovered)은 matchesPattern을 쓰는데 부모 찾기는 문자열 완전일치였다. 'boys''[campus]'는 같지 않으니 부모를 못 찾는다. buildTreemergeConfig를 직접 불러 재현해보니 최초 스캔은 멀쩡하고 병합만 깨졌다.

프레임워크 문법을 아는 건 스캐너뿐이어야 했다

처음엔 캔버스에서 path.includes('[')로 동적 여부를 판정할 생각이었다. 그런데 퍼빌더는 React, HTML, Next를 다 상대하는 도구다. 브래킷은 Next와 SvelteKit 문법이고 React Router는 :id를 쓰며 손으로 쓴 HTML 프로젝트엔 그런 개념 자체가 없다. 캔버스가 문법을 판정하는 순간 RouteScanner 어댑터 구조가 무의미해진다.

그래서 계층별로 무엇을 알아도 되는지부터 정했다.

계층아는 것모르는 것
스캐너 어댑터프레임워크 문법캡처·UI
ia.config.ts선언(dynamic·samples·origin)문법
캔버스선언 + 런타임 관찰(HTTP 상태·최종 URL)프레임워크

정하고 나니 필요한 정보는 이미 있는데 버리고 있었다는 게 보였다. 스캐너 타입에는 isDynamic이 처음부터 있었는데, buildTree가 "동적 여부는 emit이 경로의 브래킷으로 다시 판별한다"며 명시적으로 버리고 있었다. 그 재판별이 문법 유출의 시작점이었다. 버리지 말고 dynamic: true로 끝까지 흘려보내면 캔버스 규칙은 한 줄로 끝난다. 동적이면 스스로는 못 찍는다.

새 필드를 만들기 전에 파이프라인에서 무엇이 버려지고 있는지부터 봤어야 했다.

path를 덮어쓰는 대신 samples를 옆에 뒀다

/notice/[id]/notice/1로 덮어쓰는 게 그동안의 관례였는데, 이번에 그게 문제의 원인이라는 게 드러났다. pathIANode의 id이자 .putree/dirty-pages.json의 키다. 덮어쓰면 재스캔이 부모를 잃고 dirty 상태가 고아가 된다. 그래서 path는 그대로 두고 실제 주소를 옆에 붙이는 형태로 갔다.

{
  path: "/[campus]",
  title: "캠퍼스",
  dynamic: true,
  samples: [
    { title: "남캠", path: "/boys" },
    { title: "여캠", path: "/girls" },
  ],
}

배열인 이유는 관계가 1:N이기 때문이다. 캠퍼스가 셋이면 노드도 셋이어야 한다. [id]는 길이가 1일 뿐 별개 개념이 아니다. 렌더링은 기존 variants와 같은 메커니즘을 탄다. normalizeIA가 샘플 하나당 자식 IANode를 만든다.

접두사 자르기에 함정이 하나 있었다. stripPrefix: '/admin'을 단순 startsWith로 처리하면 /admin/administratorsistrators가 된다. 세그먼트 경계를 검사하도록 고치고 테스트로 고정했다.

캔버스가 파일을 고치게 하면서 주석을 포기했다

404와 410은 렌더 여부 검사보다 먼저 판정하도록 순서를 바꿨다. 미들웨어가 막아 본문이 빈 404와 본문이 있는 404가 같은 상태로 보고돼야 하기 때문이다.

그리고 PATCH /api/iaserver/ia-write.ts를 붙였다. 비개발자가 ia.config.ts를 직접 열어 고치기는 어려우니, 사람만 아는 답(실제 주소와 제목)을 썸네일 카드에서 받아 캔버스가 파일에 적게 했다.

이 모듈의 핵심은 왕복 검사다. 로드한 config를 다시 emit한 텍스트가 원본 텍스트와 정확히 같을 때만 파일을 고친다. jiti는 pages: build() 같은 표현식도 평가해 평범한 객체를 돌려주므로, 객체 비교로는 "이 파일이 emit이 표현할 수 있는 형태인지"를 가릴 수 없다. 텍스트가 유일한 증거였다.

대가는 emit이 주석을 못 남기게 된 것이다. TODO 주석을 넣으면 자기 출력이 자기 검사를 통과하지 못한다. 그래서 sampleFor, TODO 주석, EmitOptions.newPaths를 전부 걷어내고 고정 헤더 한 줄만 남겼다. 추가된 경로는 어차피 스캔이 stdout으로 보고한다.

애초에 캔버스가 파일을 쓰는 데 반대했던 근거가 "다시 emit하면 주석이 날아간다"였는데, 확인해보니 그건 스캔이 이미 매번 하고 있는 일이었다.

판정 근거를 감췄더니 휴리스틱이 거짓말을 했다

카드가 관찰한 것만 말하도록 상태를 다시 갈랐다.

상태판정카드
needs-url동적이고 샘플 없음 (캡처 전, 서버 왕복 없음)주소 입력
패턴동적이고 샘플 있음"화면 N개" + 주소 추가
not-foundHTTP 404·410상태 코드 + 주소 입력
redirected최종 경로가 요청 경로와 다름요청 → 도착 항상 표시

리다이렉트 카드가 도착 경로를 항상 보여주도록 바꾼 게 이날 가장 크게 손댄 부분이다. 자물쇠는 "도착 경로가 /login 류로 끝난다"는 휴리스틱일 뿐인데, 예전에 도착 경로를 "좁은 카드에서 노이즈"라며 감춰뒀다. 근거가 화면에 없으니 틀린 추측을 알아챌 방법도 없었고, 그래서 동적 라우트가 전부 인증이 필요한 페이지로 보였다. 판정 근거를 감추면 휴리스틱은 거짓말이 된다. 자물쇠 아이콘 하나 때문에 몇 개월을 인증 문제로 오해했다.

입력은 "id 값"이 아니라 완전한 주소를 받는다. 브래킷 자리에 값을 끼워 넣는 방식은 문법을 캔버스로 다시 들여오는 짓이다. resolveIAInput이 붙여넣은 주소를 노드 경로와 대조해 샘플인지 origin인지 판정하고, origin이면 적용 범위(/admin 아래 몇 개인지)를 되묻는다.

화면을 띄우고 나서 세 가지를 더 잡았다

단위 테스트가 전부 통과한 뒤 playground를 실제로 띄웠다. 테스트로는 안 보이던 것들이 나왔다.

스캔이 파일을 고쳐놓고 "변경 없음"이라고 보고했다. 백필은 추가·삭제 집계에 안 잡히기 때문이다. mergeConfig가 백필 결과를 돌려주고 스캔이 경로별로 출력하게 고쳤다. A사 프로젝트에서 동적 라우트로 표시한 8줄이 정확히 나온다.

샘플이 이미 있는데도 "주소를 알려주세요"가 떴다. 이미 답한 사람에게 답을 또 요구하는 꼴이었다.

ia.config.ts를 프로젝트 루트에서만 찾고 있었다. playground는 src/ia.config.ts라서 raw ENOENT 오류가 카드에 그대로 샜다.

end-to-end 확인은 playground에서 했다. 입력칸에 http://localhost:4500/models/1을 넣으면 ia.config.ts가 갱신되고, HMR로 노드가 14개에서 15개로 늘면서 새 자식이 썸네일과 함께 등장한다. 낙관적 갱신 없이 파일에서 HMR로 이어지는 경로만으로 돈다. A사 프로젝트에서는 scan --dry-run으로 백필 8개를 확인한 뒤 파일을 원복했다.

아무것도 고르지 않아도 페이지 상황이 보여야 했다

같은 날 뷰어 좌측 하단에 상시 목록을 붙이는 작업도 다른 세션에 맡겨 나란히 돌렸다. 퍼블리싱 독이다.

"지금 이 페이지에 뭐가 걸려 있나"를 알려면 뭔가를 먼저 해야 한다는 게 문제였다. 블록 오버레이는 그 블록이 화면에 들어와야 보이고, 우측 패널은 블록을 선택해야 열린다. 아무것도 선택하지 않은 상태에서 페이지 전체 상황을 볼 방법이 없었다.

dockRowsForPage는 새 데이터를 만들지 않는다. 이미 페이지 경로와 selector로 키가 잡혀 있는 퍼블리싱 잡에서 실행 중·확인 대기·사인 대기만 뽑아 상태 우선순위로 정렬한다. 에러는 넣지 않았다. 에러는 우측 패널이 말한다.

독 자체는 표시 전용으로 뒀다. 좋아요·되돌리기·중단은 전부 우측 패널이 계속 맡는다. 행마다 버튼을 다는 안을 검토했다가 접었는데, "selector가 깨져도 blockKey로 언제나 처리할 수 있다"는 보장을 우측 목록이 이미 들고 있어서 같은 기능의 두 번째 사본이 되기 때문이다.

행 제목이 selector가 아니라 블록 이름("프로젝트 사례")이려면 실행 시점 이름을 어딘가 보관해야 한다. PublishJobState에서 ReviewSink, ReviewRecord, DTO, hydrateReviews까지 배관을 뚫었다. ReviewRecord는 이미 v2였는데 VERSION은 올리지 않았다. 없으면 null로 읽으면 그만이라 마이그레이션이 필요 없고, 덕분에 같은 파일을 만지던 앵커 마커 작업과도 충돌하지 않았다.

ReviewSink.putlabel은 일부러 필수로 뒀다. optional로 두면 호출부가 전달을 빠뜨려도 타입 체크가 통과해 label이 조용히 사라진다. 실제로 한 번 그렇게 빠뜨렸고, 필수로 바꿔 타입에서 걸리게 했다.

우측 패널도 재배치했다. 사인 대기 목록과 결과 카드를 최상단으로 올리고 구분선으로 입력란과 갈랐다. 완료 카드가 입력란 아래로 밀려 스크롤해야 보이던 문제였다.

playwright로 실기동도 확인했다. 독이 좌측 16px, 하단 16px에 뜨고, label 경로와 selector 폴백이 한 화면에 동시에 나온다. 이 저장소에 남아 있던 실제 레코드 7건이 전부 label이 없는 상태라 폴백이 실데이터로 검증됐다. 좋아요를 누르면 그 행만 빠지고, 퍼블리싱 패널의 자식 순서도 사인 대기, 구분선, Figma 입력, 실행 버튼 순으로 맞다. 퍼블리싱 중 상태만 실제 잡이 필요해 단위 테스트로만 덮여 있다.

같은 워킹트리에서 두 작업을 돌리다 커밋이 서로를 삼켰다

IA 캔버스와 퍼블리싱 독을 각각 다른 에이전트 세션에 맡겨 같은 워킹트리, 같은 main에서 나란히 돌리고 있었다. 몇 분 간격으로 커밋이 번갈아 쌓였다.

그런데 캔버스 쪽 초반 커밋에서 git add -A를 썼다. 퍼블리싱 독 쪽의 server/reviews.ts, agent.ts 작업과 테스트 두 개가 캔버스 커밋에 통째로 삼켜졌고, 그쪽 커밋에는 구현 없이 스펙 문서만 남았다. 그리고 아직 커밋하지 않은 playground 작업(케밥 메뉴, 4열 그리드, 히어로 카피, new 배지) 4파일도 함께 딸려 들어갔다.

두 세션 사이에 사실관계를 맞추다가 "캔버스 커밋이 api.tsReviewRecordDto.label을 지웠다"는 주장이 나왔는데, git log -S와 diff로 확인해보니 아니었다. 그 커밋은 19줄을 더하고 1줄을 지웠고 지운 건 import 한 줄이었다. 원인은 다른 데 있었다. 파이썬으로 앵커 문자열을 치환하면서 앵커가 이미 바뀐 뒤였고, 치환이 조용히 아무 일도 안 하고 끝났다. 그 필드를 읽는 코드가 없어서 타입 체크도 통과했다.

앵커 치환은 이렇게 조용히 실패한다. 이번에 merge.ts를 파이썬으로 고칠 때 assert old in s를 넣어둔 게 우연히 옳았다.

빈 diff가 나와야 재작성이 검증된다

히스토리를 정리하기로 했다. git add -A로 뭉뚱그려진 커밋들을 각자의 몫으로 잘랐다.

  • 첫 커밋은 IA 스캐너 몫만 남기고, 딸려 들어간 playground 4파일은 새 커밋으로 떼어냈다. 커밋 하나가 둘로 갈라지면서 전체 개수가 하나 늘었다.
  • 다른 커밋은 404·ia.config 몫만 남겼고, 거기서 빠진 서버 label 구현은 스펙 문서만 남아 있던 퍼블리싱 독 쪽 커밋에 합쳤다.
  • 커밋 메시지에서 "복구"라고 잘못 적혀 있던 것을 "추가"로 고쳤다.
  • 나머지는 내용도 메시지도 그대로 뒀다.

검증 방법이 이 재작성의 핵심이었다. "귀속만 바꾸고 내용은 그대로"가 성립하려면 git diff backup/pre-split main이 완전히 비어야 한다. 독립적으로 확인했고 실제로 비었다. 문제 됐던 커밋들은 메시지와 패치를 파일로 떠서 바이트 단위로 비교까지 했고 동일했다.

한때 "playground 4파일은 커밋에서 빼고 워킹트리로 되돌리자"고 제안했다가 접었고, 접은 게 옳았다. 히스토리에서 빼면 빈 diff 불변식이 깨져 검증 자체를 못 쓰게 된다. 저자 표기 정확도보다 검증 가능한 재작성이 먼저다. 대신 커밋 메시지에서 저자 단정만 뺐다.

backup/pre-split 브랜치는 남겨뒀다. 검증이 끝났고 reflog에도 옛 HEAD가 남지만, 재작성 당일에 유일한 안전망을 지울 이유가 없다. 며칠 뒤 git branch -D backup/pre-split으로 지우면 된다.

동시에 두 작업이 도는 저장소에서 git add -A는 사고다. 이 저장소는 이제 그 전제로 다뤄야 한다.

테스트가 깨진 채로 남은 앞선 커밋은 손대지 않았다

블록 앵커 id를 발급·전달하는 앞선 커밋은 테스트가 깨진 상태로 올라가 있었다. 거기 섞여 들어간 tests/heuristics.test.ts의 "구조가 같은 앞쪽 요소가 있어도 원래 요소로 되돌아간다"는 cssPath 유일성 테스트인데, 그 유일성을 만드는 수정은 21시간 뒤 커밋에 들어갔다. 그 시점 구현으로 실제 돌려봤다.

selector: div:nth-of-type(2) > section
원래 요소로 돌아오나: false   유일한가: false

앞서 cssPath를 파고들 때 쓴 테스트가 앞선 커밋에 쓸려 들어간 것으로 보인다. 고치지 않았다. HEAD는 초록이고, 23시간 전 중간 커밋 하나를 위해 방금 정리한 히스토리를 다시 재작성하는 건 위험 대비 이득이 없다. editor-core/src/selection.ts가 뒤쪽 커밋에 있는 건 정상이다. 그 커밋의 주제가 바로 그 파일이다.

어드민 origin은 아직 실기동을 못 봤다

어드민 origin 흐름은 로그인 세션이 필요해서 실제로 돌려보질 못했다. 캔버스가 보내는 localhost 쿠키가 admin.localhost에서 유효한지가 미지수다. 캡처 쪽에서 대상 origin에 쿠키를 붙여주긴 하는데, 안 되면 putree auth http://admin.localhost:3000/login으로 우회할 수 있다.

자동 제안은 범위 밖으로 미뤄뒀다. /sitemap.xml 크롤, 부모 목록에서 a[href] 수확, 조상 샘플의 위치 기반 치환 셋 다 결국 입력 칸을 미리 채워주는 형태라 통로가 같다. IA 구조 편집(트리 재배치, 노드 추가·삭제, 레거시 구체 노드를 samples로 통합)도 남았다. 퍼블리싱 중 상태는 실화면에서 못 본 유일한 상태라, 다음 실제 잡 때 좌측 하단 파란 점을 눈으로 확인해야 한다.

세션이 먼저 낸 안을 이날 두 번 뒤집었다. 브래킷 검사로 캔버스에서 판정하려던 것과, 안내만 하고 파일 쓰기는 안 하려던 것. 둘 다 뒤집은 쪽이 맞았고, 두 번째는 반대의 근거로 들었던 비용이 이미 지불되고 있던 비용이었다.