이 글은 2026-08-04 하루의 기록이다. 오전엔 발행 파이프라인 3탭(생성·개정·배포)을 좌우 레이아웃으로 전면 개편했고, 낮엔 다중 세션 충돌에 잠금을 걸었다. 저녁엔 자잘한 버그를 사냥하다가 “변경이 없는데 개정 기록이 생기는” 오탐의 뿌리를 뽑았다.
배경 — “너무 위아래로만 복잡하고 옆에는 비어 있어”
아침은 산출물 생성 패널에 대한 한 줄 불만으로 시작했다.
“산출물 생성 패널 UX 개선해줘. 너무 위아래로만 복잡하고 옆에는 비어 있어.”
하단 도크로 기능을 모으는 작업이 이어지면서 패널들이 세로로만 자라 있었다. 대상 선택, 옵션, 파일 목록, 로그가 전부 위아래로 쌓여 스크롤은 길어지는데 가로 공간은 놀고 있었다. 이 불균형을 바로잡는 것이 오전의 주제가 됐고, 생성에서 시작한 좌우 재배치가 개정·배포 패널까지 번지면서 발행 파이프라인 3탭 전체의 전면 개편이 됐다.
문제
- 생성·개정·배포 패널을 어떤 공통 골격으로 다시 눕히나.
- 개정 기록의 “변경 내용 설명”을 사람이 매번 써야 하나 — AI 초안은 어디까지 안전한가.
- 여러 세션이 같은 프로젝트를 동시에 고치면 무엇이 깨지나.
- 배포는 매번 전체를 밀어야 하나 — “배포가 필요한 것”을 어떻게 판별하나.
- 변경이 없는데 개정 기록이 생기는 건 어디서 새는 것인가.
결정 1 — 3탭을 같은 좌우 골격으로 통일
생성 패널부터 배포 패널의 구조를 참고해 재편했다. 좌측엔 생성 대상과 옵션, 우측엔 생성될 파일 목록. 파일 목록엔 버전과 최근 생성일을 붙이고, 가독성 정렬을 문서 그룹과 Markdown 그룹에 똑같이 적용했다. 이어서 개정 패널과 배포 패널도 같은 좌우 골격으로 눕혔다 — 세 탭이 같은 문법을 쓰게 된 것이 이날 오전의 결과다.
선택 상태의 일관성 문제도 이때 정리했다. “재생성 필요만” 필터를 켜면 체크해 둔 파일이 목록에서 사라지는데, 체크는 남아 있었다.
보이지 않는 것이 선택되어 있으면 안 된다. 필터로 목록에서 사라진 파일은 체크를 자동 해제하고, 목록 상단의 선택 요약 정보도 함께 지웠다.
결정 2 — 개정 기록에 AI 초안을 들이다
개정 패널은 레이아웃만 바꾸고 끝나지 않았다. 개정 기록 대상이 파일명 그대로 노출되던 것을 사용자 관점 표기로 바꿨고, 그동안 확인할 방법이 없던 개정 기록 대상·변경 리프 정보를 패널에서 직접 볼 수 있게 했다. 변경 리프는 리프별 상세(경로·이전값·새값)까지 파고들 수 있다.
그 위에 개정 기록·커밋의 “변경 내용 설명”에 AI 입력 기능을 얹었다. 백엔드(POST /revise/describe)가 개정 대상 파일들의 리프 diff를 근거로 한 줄 설명을 초안하고, 프론트엔드 입력창 옆에 ✨ AI로 채우기 버튼이 뜬다(AI 백엔드가 설정된 경우만). 프롬프트에는 경계를 명시했다.
파일명이나 JSON 경로를 그대로 나열하지 않고 내용 관점으로 재서술한다. 근거 없는 이유나 이슈 키를 지어내지 않는다 — 이슈 키는 사용자가 직접 붙인다.
초안은 입력창을 채울 뿐이고, 확인·수정·기록은 사람이 한다. AI 미설정(503)·정상 초안(200) 경로 테스트를 추가해 검증했다.
결정 3 — 다중 세션 충돌, 세 곳에 잠금을 걸다
“여러 세션에서 수정했을 때 충돌 날 수 있는 부분을 검토해달라”는 요청으로 낮 작업이 시작됐다. 검토 결과 세 곳이 나왔다.
| 지점 | 위험 | 조치 |
|---|---|---|
| 데이터 변경 잡 ↔ 웹 저장 | index.lock 충돌, 저장분 유실 | 잡 실행 동안 프로젝트 락 |
| 개정 미리보기 ↔ 실행 | 남의 변경까지 싸잡아 기록 | stateToken 낙관적 잠금 |
| 저장소 객체 생성 | 초기화 경합 | _store() 생성 직렬화 |
가장 위험한 것은 첫 번째였다. revise.py·parse.py 잡이 서브프로세스로 데이터 JSON을 고쳐 커밋하는 동안 프로젝트 락을 잡지 않아서, 다른 세션이 웹에서 문서를 저장하면 인터리빙될 수 있었다 — git index.lock 충돌은 양반이고, 잡이 파일을 읽은 뒤 끼어든 저장을 옛 내용으로 되덮는 유실까지 가능했다.
두 번째는 낙관적 잠금의 공백이었다. 문서 저장에는 ETag(If-Match)가 있는데 개정 기록에는 상응 장치가 없었다. A 세션이 변경분을 미리보고 설명을 쓰는 사이 B 세션이 다른 변경을 저장하면, A의 설명이 B의 변경까지 싸잡아 이력에 실린다. 파일별 워크트리 내용 해시로 변경분 지문 stateToken을 만들어 status에 포함시키고, apply가 --expect-state로 검증하게 했다.
같은 커밋 묶음에 재생성 필요 오탐 수정도 실렸다. 산출물 신선도 판정이 파일 mtime 기준이라 pull/rebase만 해도 전부 “재생성 필요”로 물들던 것을, git 커밋 일시 기준으로 바꿔 해소했다.
결정 4 — 배포도 “필요한 것만”
산출물 생성에 “재생성 필요만” 흐름이 생겼으니 배포에도 같은 관점이 필요했다. 배포된 버전(Drive 시트·Confluence 버전 마커·Notion 콜아웃)과 로컬 개정 버전을 읽기 전용으로 비교하는 배포 필요 확인 기능을 먼저 만들고, 그 결과에서 needsDeploy로 판정된 산출물만 골라 배포하는 버튼을 붙였다.
여기서 대상별 셀렉터 의미 차이가 함정이었다. Drive는 코드 정확 일치라 AN11-1 그대로 쓰면 되지만, Confluence/Notion은 파일명 부분 일치라 AN11-1이 AN11-12까지 물어 온다. 마침표를 포함한 AN11-1.로 좁혀 오매칭을 막았다.
UX는 한 번의 재구성을 거쳤다. 처음엔 [필요 N건만 배포] 버튼이 확인 결과 속에 떨어져 있어 흐름이 안 읽혔다.
읽는 순서가 곧 권장 흐름이 되도록 — ① 배포 필요 확인이 실행 영역 첫 줄, ② 배포 버튼은 확인 결과에 따라 [필요 N건만 배포]가 기본(primary)이 되고 [전체 배포]는 보조(ghost)로 물러난다.
전체 강제·다이어그램 같은 옵션은 전체 배포에만 적용되도록 분리했다. 오후엔 이 작업들을 논리 단위 5커밋으로 나눠 커밋하고 push까지 마쳤다. 커밋할 때 같은 워킹트리에서 병렬로 돌던 다른 세션의 파일과 scratch 임시 파일은 스테이징에서 제외했다 — #30의 git add -A 교훈이 이번엔 처음부터 지켜졌다.
결정 5 — 저녁의 버그 사냥
저녁 세션은 자잘하지만 거슬리던 것들을 걷어냈다.
Project Files가 접히지 않던 버그. 단계 헤더를 클릭하면 접기와 동시에 at:focus-phase 이벤트를 쏘는데, 이 이벤트의 유일한 리스너가 자기 자신(PhaseNav)이고 그 리스너는 해당 단계를 무조건 다시 펼쳤다. 접자마자 자기 이벤트에 의해 도로 펼쳐지는 구조였다. 펼칠 때만 이벤트를 쏘도록 고쳤다.
탭 휠 클릭 닫기. 편집기 탭에 onAuxClick 핸들러로 가운데 버튼 닫기를 달았다. 기존 × 버튼과 같은 경로를 타서 미저장 확인도 그대로 묻고, onMouseDown에서 가운데 버튼의 브라우저 자동 스크롤을 억제했다.
Jira 조회에서 Backlog 제외. JQL에 status != Backlog를 넣는 대신 조회 후 상태명으로 후처리 필터를 넣었다 — 인스턴스에 “Backlog” 상태가 아예 없으면 JQL은 질의 자체가 깨지지만, 후처리는 어떤 프로젝트에서도 안전하다.
작업 AI 처리의 범위 제약 완화. 문서 편집 AI의 시스템 프롬프트가 “현재 문서 밖 요청은 거절”이라 작업(Task) 처리까지 막고 있었다. 프롬프트를 엄수/완화 두 모드로 분리해, 작업 AI 처리 경로만 완화 모드를 쓰게 했다 — 문서 밖 일이 섞여 있어도 거절하지 않고 현재 문서에 반영 가능한 부분을 최대한 반영하고, 못 한 부분은 설명으로 안내한다. 편집기의 AI 지시와 리뷰 제안 적용은 기존 엄수 모드를 유지한다.
산출물 패널의 선택 축 정리. 행 클릭 단일 선택과 체크박스 다중 선택이 공존하던 것을 체크 하나로 통일했다. 다운로드도 단일 선택에 붙어 있던 것을 체크 기반으로 옮기고, 문서·Markdown 그룹 헤더에 전체 선택 체크박스(부분 선택 시 indeterminate 표시)를 달았다.
결정 6 — “변경이 없는데 개정 기록이 있다”
하루의 마지막 조사가 가장 깔끔한 원인 규명이었다.
“개정 시, 변경이 없는데 개정 기록이 있는 경우가 있어.”
원인은 revise.py의 변경 수집이 git이 “수정됨”이라고 하는 모든 파일을 개정 대상으로 본 데 있었다. 그런데 리프 diff 계산에서는 개정 이력 자체(document.revisions)를 제외하므로, 실제 내용 변경이 0건인 파일 — 재직렬화(들여쓰기·키 순서), 이력만 손댄 경우, 메타 정리 — 도 “버전 업 + 개정 이력 추가” 경로로 떨어졌다. git의 수정 판정과 내용 변경 판정이 다른 기준을 쓰는데 한쪽만 보고 있었던 것이다.
수정은 상태 하나를 새로 만드는 것으로 정리했다. 내용 리프 변경이 0건인 수정 파일은 **no-change**로 분류해 버전을 유지하고 개정 이력을 추가하지 않으며, apply 시 커밋에만 내용 변경 없음 — 형식·메타 정리로 실린다. 마지막 이력 설명을 재사용하지 않는 것도 의도적이다 — 재사용하면 옛 설명에 든 Jira 이슈 키로 알림이 다시 붙는 부작용이 있다.
의도치 않은 함정
MouseEvent가 셀렉터가 되는 순간. 배포 함수들이 셀렉터 오버라이드 인자를 받게 되면서, 기존 onClick={uploadDrive} 식 직접 참조가 클릭 이벤트 객체를 셀렉터로 넘기게 됐다. 호출부 5곳을 () => run()으로 래핑해 해결했는데, 절반은 타입 에러로 빌드가 잡아준 덕이었다. 함수 시그니처에 선택 인자를 추가할 때 이벤트 핸들러 직접 참조는 항상 의심 대상이다.
마무리
이날의 흐름은 하나로 요약된다 — “전부”에서 “필요한 것만”으로. 재생성 필요만 생성하고, 배포 필요만 배포하고, 내용이 바뀐 것만 개정 기록에 싣는다. 그 판별 기준(git 커밋 일시, 배포 버전 비교, 내용 리프 diff)을 세우는 것이 하루 작업의 대부분이었다.
각각의 교훈:
- 판정 기준의 근거를 의심하라 — mtime은 pull만 해도 바뀌고, git의 “수정됨”은 내용이 안 바뀌어도 뜬다. 신선도든 개정이든, 무엇을 근거로 판정하는지가 오탐의 전부였다.
- 낙관적 잠금은 문서 저장에만 필요한 게 아니다 — 미리보기와 실행 사이가 벌어져 있는 모든 흐름에 상태 지문이 필요하다.
- 이벤트를 쏘는 쪽과 받는 쪽이 같은 컴포넌트면 루프를 의심하라 — 접자마자 도로 펼쳐지는 버그는 자기 이벤트를 자기가 받는 구조에서 나왔다.
- JQL보다 후처리가 안전할 때가 있다 — 존재하지 않는 상태명을 JQL에 넣으면 질의가 깨진다. 필터의 견고함이 우선이면 조회 후 거르는 게 맞다.
다음에 남는 것:
no-change분류가 실사용에서 형식 정리 커밋을 얼마나 걸러내는지 관찰.stateToken낙관적 잠금이 실제 다중 세션 상황에서 충돌을 잡아내는지 확인.- 완화 모드 AI 처리가 “반영할 내용이 전혀 없는 요청”을 보류 댓글로 남기는 빈도 — 프롬프트 경계가 적절한지 지켜보기.