이 글은 2026-07-21 하루의 기록이다. #28이 단계에 힘을 준 날이었다면, 이날은 그 위에 얹힌 화면들을 줄이는 날이었다. 탭이 화면이 되고, 화면이 도크가 됐다. 그리고 그 과정에서 잠금·상태·실행 정보가 각각 제자리를 찾아 옮겨 갔다.
배경 — 두 개의 압력
이날은 두 가지 힘이 하루를 밀고 갔다.
하나는 **“IDE를 지향한다”**였다. 편집기 탭, 상태 바, 문제 도크, 분할 뷰, 단축키 — VS Code나 IntelliJ에 있는 크롬을 하나씩 이 도구에 붙였다.
다른 하나는 통합의 압력이었다. #27에서 작업을 일급 축으로 올리고 #28에서 리뷰 워크플로우를 세운 뒤로, 화면이 자꾸 늘어나 있었다. 현황·산출물·작업·리뷰가 각자 별도 화면을 갖고 있었고, 하루 종일 그 사이를 왔다 갔다 했다.
둘은 결국 같은 방향을 가리켰다. IDE가 화면이 아니라 하나의 작업 표면에 도크를 붙이는 방식이라면, 흩어진 화면들도 그렇게 모여야 했다. 이날 한 일의 절반은 그 수렴이다.
문제
- #28이 남긴 정합 구멍 세 개(미결 리뷰·서버 가드·잠긴 [시작])를 어떻게 메우나.
- 헤더의 버튼 넉 줄이 왜 어색한가.
- “검색 이동”과 “전체 검색”은 실제로 무엇이 다른가.
- 기기에 갇힌 작업 이력을 DB로 옮겨야 하나.
- 작업과 데이터를 동시에 보려면 화면 구조가 어떻게 돼야 하나.
- 리뷰 중인 산출물을 어떻게 잠그나 — 누구에게까지.
- 요구사항에서 Story 포인트·상태 필드는 어디로 가야 하나.
결정 1 — 틈부터 메운다
아침의 첫 지시는 “A, B, C 모두 고쳐줘”였다. #28을 닫으며 적어 둔 세 개의 갭이다. 그날 배운 게 **“규칙 사이의 틈은 각 규칙을 검증해서는 안 보인다”**였는데, 그 틈을 그대로 두고 다음 일을 시작할 수는 없었다.
- A. 게이트에 미결 리뷰 검사. 열린 리뷰를 둔 채 단계를 넘으면 그 리뷰는 승인 시점에 409로 좌초된다. 게이트가 “미완료 작업”은 보면서 “미완료 리뷰”는 안 보던 비대칭을 채웠다.
- B.
/stories서버 가드. 편집기 탭은 읽기 전용으로 막았지만 엔드포인트 자체는 잠긴 단계의 작업 생성을 안 막았다. UI 가드만 걸고 서버 가드를 안 건 것 — 잠금 설계의 원칙을 스스로 어긴 자리였다. 다만 라벨 명시 경로(기능 → 구현 Task)는 구현 규약이라 열어 뒀다. - C. 작업 내역 [시작] 비활성. 이전 단계 작업은 착수해도 저장이 잠겨 결실이 없으니 비활성화하고 툴팁으로 이유를 안내했다. [완료]는 예외 전환의 잔여 정리를 위해 의도적으로 남겼다.
세 개를 한 커밋으로 닫고, 전체 400건 테스트를 확인했다. 하루의 시작을 어제의 빚 청산으로 여는 건 나쁘지 않은 습관이다.
결정 2 — 내비게이션은 내비게이션처럼 생겨야 한다
헤더가 어색하다는 지적이 들어왔다. 프로젝트 정보·현황 상세·작업 내역·리뷰가 성격 구분 없이 같은 모양의 액션 버튼으로 나열돼 있었다.
원인은 형태와 의미의 불일치였다.
“화면 전환(내비게이션)“인데 “동작(액션)“처럼 생긴 것이 어색함의 원인이었다.
그래서 성격대로 갈랐다. 프로젝트 정보는 셀렉터 옆 ⓘ 아이콘으로 — 별도 목적지가 아니라 선택된 프로젝트의 부속이라는 위계를 형태로 드러냈다. 현황·작업·리뷰는 테두리 있는 세그먼트 탭 그룹으로 묶어, 단계와 무관한 조망 화면임이 읽히게 했다. 리뷰 탭에는 반영 대기 건수 배지를 달았다.
곧이어 기본 작업 화면(단계별 산출물)으로 돌아오는 길이 로고 클릭뿐이라는 걸 깨닫고 [산출물] 탭을 앞에 세웠고, 조망 화면에서는 좌측 산출물 메뉴를 숨겨 본문이 전폭을 쓰게 했다. 단계와 무관한 화면에서 단계 네비게이션은 소음이니까.
탭의 위치는 취향이 아니다. 정보 구조가 곧 사용자의 사고 구조가 된다. — 이 문장을 #27에 적었는데, 이날 헤더에서 다시 확인했다.
결정 3 — 이름이 기능보다 큰 약속을 하고 있었다
IDE 크롬을 붙이며 단축키를 늘리다가 Ctrl+Shift+F를 “전체 검색”으로 걸어 뒀다. 그런데 사용자가 물었다 — “검색 이동 팔레트와 전체 검색의 차이가 뭐야?”
정직하게 답할 수밖에 없었다.
지금은 차이가 없다. 둘 다 같은 팔레트(Ctrl+K)를 연다. “전체 검색”이라는 이름이 실제 기능보다 큰 약속을 하고 있었다.
현재 팔레트는 이름 기반 이동(quick-open)이지 문서 내용을 뒤지지 않았다. 이름이 걸어 놓은 약속을 실제로 갚기로 했다 — 서버에 GET /search?q= 엔드포인트를 만들어 전 입력 JSON의 문자열 값을 재귀 탐색하고, 결과를 사람 문장 위치 + 강조 스니펫으로 보여주는 진짜 전문 검색.
부수 효과가 좋았다. 식별자로 검색하면 그게 곧 참조 추적이다 — SUOMS-REQ-PER-001을 넣으면 그 식별자를 참조하는 모든 문서·필드가 나온다. 두 검색이 이제 실제로 다른 축을 담당한다: Ctrl+K는 이름으로 이동, Ctrl+Shift+F는 내용을 뒤진다.
별칭에 이름을 붙이는 순간 그 이름은 약속이 된다. 약속을 못 지킬 거면 이름을 지우고, 지킬 거면 기능을 만들어라. 어중간하게 두면 사용자가 매번 속는다.
결정 4 — 버전은 이미 1.0이었다
시스템 자체에 시맨틱 버저닝을 도입했다. 단일 출처 VERSION 파일 + get_version(), bump CLI, CHANGELOG, 그리고 노출 네 곳(FastAPI 메타·/api/version·관리자 카드·헤더 로고 옆 vX.Y.Z).
규칙 정본은 코드에 박았다 — MAJOR는 스키마·CLI·API 비호환, MINOR는 기능 추가, PATCH는 수정. 산출물 문서 버전과는 완전히 별개다.
기준을 1.0.0으로 잡은 근거가 이 결정의 전부다. 이미 8개 프로젝트가 실사용 중이고 스키마·CLI·API가 규약으로 참조되고 있었다. 0.x(비호환 자유) 단계는 진작 지나 있었는데 버전만 안 붙어 있던 셈이다.
버전은 코드가 도달한 상태를 이름 붙이는 일이다. 이미 남들이 규약으로 의존하고 있으면, 그건 이미 1.0이다.
결정 5 — DB냐 파일이냐가 아니라, 로컬이냐 서버냐
“작업 이력을 어디에 저장하고 있어? DB로 전환해야 하나?”라는 검토 요청이 왔다. 전수 조사 결과가 방향을 뒤집었다.
RDB 도입은 과했다. 단일 uvicorn + 스레드 락이라 파일 저장의 동시성 문제가 실제로 없고, 데이터량도 수 KB~수 MB이며, 정본(Git·Jira)은 이미 견고한 외부 저장소였다.
진짜 문제는 다른 축이었다.
질문은 “DB냐 파일이냐”가 아니라 “클라이언트 로컬이냐 서버냐”였다.
작업 이력·활성 작업·알림 읽음 상태가 전부 브라우저 localStorage에 갇혀 있었다. 협업성 데이터인데 기기에 묶여 있으니, 다른 PC에서 이어서 일할 수 없고 팀원 간 “누가 뭘 잡고 있나”도 공유 안 됐다.
그래서 저장 매체가 아니라 위치를 옮겼다 — 이 상태들을 서버의 사용자별 저장으로 승격하되, 매체는 운영 부담 0인 SQLite 한 파일(build/state.db, WAL)로. active_task·task_history·noti_state 세 테이블이면 충분했다. 프론트는 서버 정본 + localStorage 캐시/폴백 구조라, 구버전 서버나 오프라인에서도 무중단으로 돈다.
핵심은 저장 계층에 경계를 그은 것이다. 다중 워커·서버로 확장할 때가 오면(수평 확장, 동시 수십 명, 파일 락 경합) 이 모듈 하나만 PostgreSQL 구현으로 교체하면 된다. API 시그니처는 그대로다. 지금 PostgreSQL을 들이는 대신 갈아 끼울 수 있는 자리를 만들어 둔 것이 이날의 실속이다.
결정 6 — 화면을 지웠다
이날의 척추다. 요청이 짧은 지시들로 조금씩 들어왔는데, 이어 보면 하나의 방향이었다 — 작업과 데이터를 한 화면에서 동시에 보기.
처음엔 작업 화면 상단에 “현재 작업” 상세 패널을 세웠다. 여기서 좋은 발견이 하나 있었다.
활성 작업 중 저장하면 커밋에 이슈 키가 붙는 규약이 이미 있었다.
git log --grep한 번이면 “이 작업으로 실제 무엇을 남겼나”가 나온다.
이미 지키고 있던 습관 하나가 새 기능의 데이터원이 됐다. 남긴 커밋 목록을 패널에 띄우고, [완료]의 “한 일” 초안을 커밋 요약으로 자동 채웠다. 키 정확 일치라 TP-1이 TP-11을 오탐하지 않는다.
그다음이 수렴의 연쇄였다.
- 작업 도크 — 기존 분할 보기(폭 드래그·localStorage) 인프라를 콘텐츠 스위처로 일반화했다. 편집기 오른쪽에 [다이어그램]/[작업]을 고르는 도크. 폼을 고치면서 완료 조건·스레드·커밋을 옆에서 본다.
- 상시화 — 활성 작업이 없으면 도크가 잠겨 “동시 보기”가 조건부였다. 활성 작업이 없을 땐 이 산출물의 작업 목록을 대신 띄워, 고르기 → 잡기 → 보며 고치기가 끊기지 않게 했다.
- 리뷰도 작업이다 — 반영 대기 리뷰를 도크의 수신함으로, 다시 작업 큐의 같은 행으로 끌어올렸다. “내 담당”을 켜면 수정할 작업과 검토할 리뷰가 한 목록에 선다.
- 화면 삭제 — 마지막 지시가 못을 박았다: “작업/리뷰를 위한 별도 화면은 중복이므로 제거하고 모두 공통 패널에서.”
TasksView와ReviewsView를 지웠다. 리뷰 화면에만 있던 기능(코멘트·철회·영향성 리포트)은 도크의 모달로 승계했다. 헤더 탭은 [현황·산출물] 둘만 남았다.
화면 하나를 지울 때마다 답해야 하는 질문은 “이 기능이 사라지나”가 아니었다.
화면을 지우는 것은 기능을 지우는 게 아니라, 그 기능이 있어야 할 자리를 정하는 것이다. 리뷰 화면을 지우려면 먼저 리뷰가 도크에서 완결되는지를 증명해야 했다.
도크가 유일한 작업 표면이 되자, 하루의 시작에 “왔다 갔다 하던” 흐름이 사라졌다. 저녁엔 반대 정리도 했다 — 도크가 상시가 되니 [🗂 작업] 토글 버튼과 다이어그램 분할 모드가 되레 군더더기여서 걷어냈다.
결정 7 — 잠금은 힌트가 아니라 규칙이다
오후 늦게 잠금이 세 겹으로 쌓였다. 요청은 한 문장씩이었지만, 매번 누구에게까지 막느냐가 관건이었다.
- 이전 단계 잠금(
phaseLocked, #28) — 지나온 단계 산출물. - 리뷰 중 잠금(
reviewLocked) — 내가 리뷰를 잡고 있으면 데이터가 읽기 전용. 처음엔 대상 문서만이었는데, 곧 “모든 산출물”로 넓혔다. 검토는 “이 저장본”을 판단하는 일이라, 도중에 어느 문서든 고치면 판단 기준이 흔들리기 때문이다. - 검토 요청 중 전 사용자 락온(
underReviewLocked) — 여기서 서버로 내려갔다. 리뷰가 열린 산출물은 결정(승인·반려·철회) 전까지 모든 사용자에게 쓰기가 막힌다. 산출물 충돌을 회피하기 위해서다.
세 번째가 이날 잠금 설계의 핵심이었다. 프론트에서 폼을 잠그는 것만으로는 “다른 사용자”를 막을 수 없다.
저장 버튼을 비활성화하는 건 잠금이 아니라 부탁이다. 잠금은 서버가 거절할 수 있어야 잠금이다.
그래서 write_document와 AI 처리 경로에 _assert_not_under_review 가드를 걸어 409로 거절하고, 같은 산출물에 2차 리뷰 요청도 막았다(경쟁 스냅샷 금지). 프론트는 /reviews가 실어 보내는 lockedDocs를 받아, 활성 리뷰가 없는 사용자에게도 잠금 배너를 띄운다.
여기서 하나 조심할 자리가 있었다.
쓰기에 가드를 거는 순간 “그럼 승인 반영은 어떻게 통과하지”를 물어야 한다.
승인이 gitstore.write를 직접 부르는 우회로였던 게 이번엔 다행이었다 — 막아야 할 문(사용자 저장)과 열려야 할 문(승인 반영)이 애초에 다른 문이었다. 이 구조가 아니었으면 잠금이 승인 자체를 막아 데드락이 났을 것이다. 마지막으로 편집기 탭에도 🔒 표시를 달아, 편집기에 들어가기 전에 잠금이 읽히게 했다.
결정 8 — 실행 정보는 산출물 밖으로
requirement.json에서 storyPoints·status 필드를 제거했다. 근거는 #27·#28에서 세운 원칙의 연장이다 — 포인트·상태는 Jira가 정본인 실행 관리 정보지 요구사항의 내용이 아니다. 요구사항 문서는 내용만 담아야 한다.
그런데 이 정리가 잔재의 두 얼굴을 드러냈다.
첫째, 스키마에서 지워도 화면에서 안 사라졌다. 폼은 스키마 키뿐 아니라 데이터 파일에 실존하는 키도 함께 그린다(스키마 밖 키를 몰래 숨기지 않는 게 기본 동작). 기존 파일에 남은 값이 계속 표출된 것이다.
스키마에서 필드를 지워도 데이터는 안 지워진다. 화면은 스키마가 아니라 데이터를 그린다.
문서별 “퇴역 키” 목록(RETIRED_KEYS)을 두어 폼이 걸러 내게 했다. 파일 자체의 잔재는 재도출(init_requirement)이 정규화로 걷어 낸다 — 재도출이 곧 마이그레이션이라는 이전 설계가 여기서 또 값을 했다.
둘째, 빈 섹션이 “수정됨”으로 오표시됐다. 참조 문서처럼 비어 있는 섹션이 아무것도 안 바꿨는데 노란 테두리로 떴다. 원인은 미묘했다 — 폼이 편집을 위해 결측 배열을 렌더 시점에 []로 실체화하는데, 비교 기준인 저장본 스냅샷은 그 전에 찍혀 “없음 vs 빈 배열”이 차이로 잡힌 것이다.
빈 값에는 두 얼굴이 있다. 없는 것과 비운 것. 변경 판정이 그 둘을 다르게 보면, 아무것도 안 한 사용자에게 “수정됨”이라고 거짓말을 한다.
변경 판정을 “빈 값 동치” 비교로 바꿨다 — ""·[]·{}·null은 결측과 같게 본다. false·0 같은 유효 값은 빈 값으로 오인하지 않도록 9개 케이스로 고정했다.
의도치 않은 함정
“항상 가능”의 왕복. “작업 만들기도 항상 가능해야 돼”를 “문서를 안 열어도 어디서나”로 읽고, 단계 화면에 대상 산출물 선택기까지 붙였다. 그런데 진짜 뜻은 “작업 중에도”였다. 확장을 다 만들고 나서야 방향이 반대였음을 알고 두 커밋을 revert했다. 되돌린 번들의 해시가 검증됐던 상태와 똑같이 나온 게 유일한 위안이었다 — 왕복은 했지만 흔적은 안 남았다.
지시가 짧을수록 뜻을 넓게 잡기 쉽다. “항상”이 “어디서나”인지 “언제나”인지를 먼저 물었어야 했다.
캐시된 번들. “반영이 안 됐다”는 보고가 하루에 몇 번 나왔는데, 대개 원인은 코드가 아니라 브라우저가 옛 JS 번들을 캐시한 것이었다. 프론트 전용 변경이 많은 날의 세금이다. “코드는 맞는데 안 보인다”를 만나면 서버 로그보다 번들 해시를 먼저 봐야 한다.
마무리
이날 손댄 것들은 겉으로 제각각이지만, 하나로 묶으면 경계를 옳은 자리로 옮기는 일이었다.
- 작업/리뷰는 흩어진 화면에서 도크 하나로.
- 상태는 브라우저 로컬에서 서버로.
- 잠금은 저장 버튼 힌트에서 서버 강제로.
- **실행 정보(포인트·상태)**는 산출물에서 Jira로.
- 검색은 이름과 내용, 두 축으로.
각각의 교훈:
- 틈부터 메운다 — 어제 남긴 정합 구멍 세 개를 첫 커밋으로 닫았다. 규칙 사이의 틈은 미루면 다음 규칙이 그 위에 쌓인다.
- 형태가 의미를 말한다 — 내비게이션을 액션 버튼처럼 그리면 어색하다. 성격대로 형태를 갈라야 읽힌다.
- 이름은 약속이다 — “전체 검색”이 quick-open을 열고 있었다. 이름을 지우거나 기능을 만들거나, 둘 중 하나다.
- DB냐 파일이냐가 질문이 아니다 — 진짜 축은 로컬이냐 서버냐였다. 매체는 SQLite로 충분했고, 갈아 끼울 경계만 그어 뒀다.
- 화면을 지우려면 자리를 먼저 정한다 — 리뷰 화면을 지우기 전에 리뷰가 도크에서 완결되는지를 증명했다.
- 잠금은 서버가 거절할 수 있어야 잠금이다 — 그리고 가드를 걸 때는 열려야 할 문(승인 반영)이 다른 경로인지 함께 확인해야 한다.
- 화면은 스키마가 아니라 데이터를 그린다 — 필드를 스키마에서 지워도 파일 잔재는 계속 보인다. 빈 값의 두 얼굴도 같은 뿌리다.
다음에 남는 것:
- SQLite 상태 서버화 이후 실제 다중 사용자 동시 작업에서 락온·이력이 어떻게 도는지 관찰.
- 도크가 유일한 작업 표면이 된 뒤, 좁은 화면·모바일에서의 답답함 점검.
underReviewLocked의 산출물당 단일 리뷰 규칙이 실사용에서 너무 빡빡하지 않은지.- storyPoints 재도출 마이그레이션을 기존 프로젝트에 실제로 한 번 돌리기.
- 검토 요청 중 전 사용자 락온이 걸린 문서를 다른 사람이 “왜 못 고치나” 물을 때, 안내가 충분히 친절한지.
- 이날 삭제한 화면들의 딥링크 리다이렉트가 오래된 북마크·알림에서 잘 도는지 추적.