이 글은 2026-07-20 하루의 기록이다. #27이 좌측 메뉴를 단계별로 바꾼 날이었다면, 이날은 그 단계가 실제로 무언가를 막고 열게 만든 날이다. 게이트, 잠금, 복귀. 그리고 두 번의 왕복이 또 있었다.
배경 — 메뉴는 단계인데, 단계는 아무것도 안 했다
#27에서 좌측 메뉴를 분석 → 설계 → 구현 → 시험 → 인도로 바꿨다. 그런데 그건 메뉴만 단계였다. 프로젝트가 지금 어느 단계인지 시스템은 몰랐고, 따라서 아무 화면이나 열 수 있었고, 아무 산출물이나 고칠 수 있었다.
단계가 이름표에 그치면 아무것도 아니다. 단계가 의미를 가지려면 넘어가는 데 조건이 있어야 하고, 지나온 자리는 닫혀야 한다. 이날 한 일은 그 두 가지다.
그리고 그 축이 서자, 그동안 애매하게 떠 있던 것들 — Story를 어디서 만드나, Jira 연동은 언제 시작하나, 포인트는 누가 갖나 — 이 전부 “어느 단계의 일인가”로 답이 나왔다.
문제
- Story가 요구사항과 1:1이 아니라 1:N이면, 기존 코드 어디가 깨지나.
- 기능과 Story의 매핑 정보는 어디에 저장하나.
- 작업 리뷰 기능이 생겼는데 Jira 피드백 기능은 아직 필요한가.
- 단계를 넘어가는 조건은 무엇이고, 미달일 때 누가 결정하나.
- Story 포인트는 요구사항의 속성인가 기능의 속성인가.
- Jira 연동은 어느 단계에서 시작해야 하나.
- 지나온 단계의 산출물을 어떻게 잠그나.
결정 1 — Story는 요구사항이 아니라 백킹 이슈다
아침의 첫 문장이 이날의 나머지를 결정했다.
Jira에 Story를 요구사항이 아닌 이슈로 정의했어. 이제 Story는 요구사항이랑 1:1이 아니라 1:N 관계야.
#27에서 “요구사항의 정본은 산출물, Jira는 작업만”이라고 정했다. 이 문장은 그 결론을 관계 차수로 확정한 것이다. Story 하나가 요구사항 N건을 뒷받침한다. Story는 더 이상 요구사항 자체가 아니라 백킹 이슈다.
의미가 바뀌니 동기화 규칙도 바뀌어야 했다.
| 필드 | 이전 (1:1) | 이후 (1:N) |
|---|---|---|
summary · detail | Jira → 요구사항 덮어쓰기 | 요구사항이 소유. Story 최초 발견 시 초안 1건에만 초기값 |
status · storyPoints · category | Jira → 요구사항 | Jira → 같은 issueKey의 전 요구사항에 전파 |
issueKey | 1:1 키 | 그룹 키 |
Story가 요구사항이었을 땐 Jira의 문장을 그대로 가져오는 게 맞았다. 이제 요구사항 문장은 사람이 쓰는 필드다. provenance 규칙도 따라갔다 — summary·detail이 Jira 소유에서 풀려 AI 보완 대상이 됐고, issueKey는 계속 잠갔다. AI가 이슈 키를 지어내면 안 되니까.
결정 2 — dict 키 하나가 데이터를 먹고 있었다
1:N 전환에서 가장 위험했던 건 새로 만들 코드가 아니라 이미 있던 코드였다.
init_requirement의 병합 로직이 issueKey를 dict 키로 쓰고 있었다. 1:1일 땐 아무 문제가 없다. 키가 유일하니까. 그런데 1:N이 되는 순간, 같은 Story를 공유하는 요구사항 N건이 재도출 한 번에 1건으로 합쳐져 유실된다.
# 1:1 전제 — 같은 키가 오면 마지막 것만 남는다
merged = {r["issueKey"]: r for r in requirements}
# 1:N — 키는 그룹의 이름이지 항목의 이름이 아니다
merged = defaultdict(list)
for r in requirements:
merged[r["issueKey"]].append(r)
키→목록 그룹 병합으로 바꾸고, 재실행해도 결과가 안정적인지 테스트로 고정했다.
무서운 건 이 버그의 성질이다. 에러를 내지 않는다. 그냥 조용히 행이 줄어든다. 관계 차수를 바꾸면서 “새 기능이 잘 도나”만 보면 절대 안 보이는 종류다.
관계 차수를 1:1에서 1:N으로 바꿀 때 진짜 위험은 새 코드가 아니라, 유일성을 전제로 쓰인 옛 자료구조다.
그래서 전수 검토를 돌렸다. 결과는 나머지는 안전이었는데, 안전한 이유가 흥미로웠다 — 시스템 전반이 요구사항을 issueKey가 아니라 identifier로 식별하고 있었다. 역파싱의 행 매칭 dict도 identifier 기준이었다. 가장 걱정했던 “issueKey dict로 유실” 패턴이 애초에 없었던 것이다.
식별자 정책을 일찍 세워 둔 것이 여기서 값을 했다. issueKey는 표시·링크용, identifier는 정체성. 이 구분이 흐려져 있었다면 이날 고칠 곳이 열 군데는 됐을 것이다.
결정 3 — 매핑은 저장하지 않는다
기능과 Story도 1:N이다. 기능 항목 옆에 걸린 Story를 칩으로 보고 싶다는 요구였다.
1. SUOMS-FUN-COM-001 — 드론 상태 실시간 조회 [SUOMS-45] [SUOMS-46] [✨ Story +]
여기서 매핑을 어디에 저장할지가 문제였다. function.json에 storyKeys: [] 필드를 두는 게 자연스러워 보인다. 그렇게 하지 않았다.
매핑의 원천은 Jira 라벨로 했다. 구현 Story를 만들 때 기능 id를 라벨로 실어 보내고, 조회는 라벨 검색 한 번으로 그룹을 만든다. 별도 저장이 없다.
이유는 #27의 그 원칙이 또 나온 것이다. 매핑을 우리 쪽에 저장하면 Jira에서 이슈를 지우거나 옮겼을 때 우리 데이터가 거짓말을 시작한다. 두 시스템에 같은 사실을 두면 반드시 어긋난다. 라벨을 원천으로 두면 Jira가 곧 정답이고, 동기화할 것이 없다.
부수 효과도 좋았다 — Jira에서 직접 만든 Story도 기능 id 라벨만 달면 칩에 잡힌다. 우리 도구를 거치지 않은 작업이 자동으로 보인다.
결정 4 — 중복이면 지운다
낮에 짧지만 중요한 결정이 있었다.
Jira 피드백 기능은 작업에 대한 리뷰 기능으로 인해 중복인 것으로 보여. 검토 후 삭제.
#26·#27에서 작업 스레드(댓글·하위 작업 요청·AI 처리)와 리뷰 워크플로우(승인해야 반영, PR 연동)를 만들었다. 그러자 그전에 만든 Jira 피드백 기능의 세 동작이 전부 새 기능에 덮였다 — 검토 의견 등록은 작업 스레드가 더 풍부하게, 완료 전이는 작업 완료와 동일하게, “반영 후 검수”는 리뷰 워크플로우가 더 강한 형태로.
엔드포인트 4개, 프론트 패널 1개, CLI 서브커맨드 1개, 빌더 모듈, 스킬 디렉터리, 테스트 6건을 지웠다.
지우면서 남긴 것도 명시했다. revise --notify-issue는 개정 댓글이라 작업·Story 이슈에도 유효한 범용 규약이라 남겼고, JIRA_FEEDBACK_PROJECT 환경변수는 이름은 낡았지만 프로젝트 키 해소용으로 전역에서 쓰고 있어 배포 호환 때문에 남겼다. 워크플로우 문서의 해당 절은 역사적 설계로 보존하되 상단에 “제거·대체됨”을 적었다.
기능이 중복되면 둘 다 시들해진다. 어느 쪽으로 갈지 사용자가 매번 골라야 하기 때문이다. 새 기능이 옛 기능을 덮었다면, 옛 기능을 지우는 것까지가 그 기능의 완성이다.
결정 5 — 게이트: 판단은 사람, 기록은 서버
오후의 본체다.
프로젝트별 진행 단계 설정 기능 구현해줘. 분석 → 설계 → 구현 → 시험 → 인도 단계이고, 각 단계는 순차적으로 진행되도록 하고, 각 단계에서 다음 단계 넘어갈 때 점검 사항들 점검할 수 있게 해줘.
설계는 이렇게 잡았다.
- 현재 단계는 서버가 정본으로 들고(
build/phase.json), 전환은 바로 다음 단계로만 허용한다. - 건너뛰기·역행은 사유를 받아 이력에 남긴다 (누가·언제·왜).
- 게이트는 자동 점검(정합성 check ERROR 0, Jira 단계 라벨 미결 0)과 수동 점검(단계별 체크리스트)으로 나눈다.
- 미달이어도 넘어갈 수는 있다. 대신 사유가 필수고 이력에
[게이트 예외 전환]으로 박힌다. - 미도달 단계도 열람은 된다. 막으면 준비 작업을 못 하니까.
마지막 두 줄이 이 설계의 성격이다. 게이트를 통과 못 하면 못 넘어가게 만드는 건 쉽고, 그러면 사람들은 곧 우회로를 찾는다. 실제 프로젝트에서 “요구사항 하나가 아직 미정인데 설계는 시작해야 하는” 상황은 늘 있다. 그걸 막으면 도구를 안 쓰게 된다.
게이트의 목적은 막는 게 아니라 넘어갔다는 사실을 기록에 남기는 것이다. 판단은 사람이 하고, 기록은 서버가 한다.
Jira 미설정 프로젝트는 “판정 불가”로 표시하고 막지 않는다. 같은 이유다. 우리가 확인할 수 없는 것을 미달로 취급하면 안 된다.
결정 6 — 조건과 실측을 분리하라
게이트를 켜자마자 두 건이 들어왔다. 둘 다 UX 문제였고, 두 번째가 이날 배운 것 중 가장 값졌다.
첫 번째 — “SUOMS가 미달 상태로 전환 버튼이 활성화 뜨는데 맞어?”
표시 자체는 맞았다. SUOMS는 실제로 미달이었다. 문제는 버튼 설계였다. “자동 점검 미달”과 “수동 체크박스를 아직 안 눌렀음”이 같은 버튼(미달 상태로 전환)으로 나왔다. 그래서 멀쩡한 프로젝트도 모달을 열자마자 미달처럼 보였다. 체크박스를 아직 안 누른 건 당연한 초기 상태인데.
버튼을 둘로 갈랐다. 정상 전환은 primary로 두되 자동 전부 통과 + 수동 전부 확인일 때만 활성화하고, 비활성일 땐 툴팁이 이유를 말한다. 예외 전환은 별도 ghost 버튼으로 뺐다.
두 번째 — “phase-AN 라벨 미결 이슈가 Jira에 6건이 남아 있는데, 미결 이슈 0건으로 표출되고 있어.”
데이터는 처음부터 6건으로 맞게 오고 있었다. 원인은 문구 설계 실수였다.
전: [미달] phase-AN 미결 이슈 0건 6건
후: [미달] phase-AN 미결 이슈 없음 현재 6건
“0건”은 내가 충족해야 할 조건의 이름으로 쓴 말이었다. 그런데 조건 문구에 숫자를 넣으니 그게 측정 결과처럼 읽혔다. 실제 측정값 6건은 옆에 작고 흐린 글씨로 있었으니 더 그랬다.
조건 제목에서 숫자를 빼고 ”…없음”으로 통일했다. 정합성 항목도 “ERROR 없음 · 현재 스키마 0건 · 추적성 ERROR 0건” 형식으로 바꿨다. 그리고 미달 항목의 실측은 흐린 보조 텍스트가 아니라 굵은 빨강으로 올렸다.
조건 문구에 숫자를 넣지 마라. 사람은 화면의 숫자를 전부 측정값으로 읽는다.
이 버그는 코드가 한 줄도 틀리지 않았는데 사용자가 시스템을 불신하게 만들었다. 그런 종류가 제일 비싸다. 게이트처럼 “통과/미달”을 판정하는 화면은 특히 그렇다 — 판정을 못 믿으면 기능 전체가 없는 것과 같다.
결정 7 — 두 번의 왕복
#27이 Task → Story → Task 왕복이었는데, 이날도 두 번 왕복했다.
왕복 ①: storyPoints, 요구사항 → 기능 → 요구사항.
15:22에 “요구사항 정의의 Story Points를 기능 정의로 이전해줘”. 근거는 명확했다 — 1:N에서 요구사항마다 포인트가 복제되니 합산하면 이중 계산된다. 포인트는 구현 규모니까 기능에 한 번만 실리는 게 맞아 보였다.
15:55에 되돌렸다. 33분 만이다.
되돌린 이유는 구현 난이도가 아니라 소유권이었다. 포인트는 Jira의 Story가 갖는 값이고, Story의 백킹 대상은 요구사항이다. 기능으로 옮기면 Jira에서 오는 값을 기능이 받아야 하는데, 기능은 Jira와 직접 연결된 게 없다. 이중 계산 문제는 “합산하면 안 된다”는 규약으로 풀 문제였지, 필드를 옮겨서 풀 문제가 아니었다.
다행히 마이그레이션을 아직 실행하기 전이어서 데이터 쪽 되돌릴 작업은 없었다. 이전 커밋을 revert하는 것으로 끝났다. 이건 운이 아니라 순서 덕이다 — 이전 작업에서 마이그레이션을 “코드 먼저, 데이터는 사람이 명령으로”로 나눠 뒀기 때문에 코드만 되돌리면 됐다.
왕복 ②: Jira 연동 시작점.
같은 지시에 이게 붙어 있었다.
분석에서 설계로 넘어갈 때는 Jira 이슈 미해결 처리 검증하지 않음. Jira 연동은 설계 완료 시 구현으로 넘어갈 때 이슈 생성하고 연동 및 맵핑하는 흐름으로 가자.
앞서 게이트를 만들 때 모든 단계에 Jira 미결 검사를 걸었었다. 그런데 분석 단계에 Jira 이슈가 있다는 것 자체가 이상하다. 분석은 요구사항을 정리하는 단계지 일을 배분하는 단계가 아니다. Jira에 이슈가 생기는 건 구현할 것이 정해진 다음이다.
그래서 Jira 미결 검증을 구현 이후 단계(IMP/TE/TO)에서만 하도록 좁혔다. 분석·설계 게이트는 정합성 검사와 수동 체크리스트만 본다. 그리고 구현 진입 시점에 “기능 목록에서 기능별 구현 Task를 생성해 Jira와 연동·매핑하세요” 안내가 뜨고, 미연동 기능 건수 배너가 붙는다.
이 결정의 파급이 컸다. 기능 정의 편집기(분석 단계 문서)에 있던 [✨ Story] 버튼을 제거했다 — 분석 단계에서 Jira 이슈를 만들 수 있으면 안 되니까. 아침에 공들여 만든 화면인데 오후에 지웠다. 대신 매핑 칩은 유지했다. 만드는 건 구현 단계의 권한이고, 보는 건 어디서나 된다.
아침에 만든 걸 오후에 지우는 게 낭비처럼 보이지만, 그 기능을 만들어 봤기 때문에 “이건 분석 단계의 일이 아니다”를 알 수 있었다.
결정 8 — 잠금은 서버가 강제한다
저녁의 요청은 한 줄이었다.
이전 단계는 수정이 불가능하도록, 변경이 필요하면 이전 단계로 돌아가야 함.
완료된 단계의 산출물은 잠근다. 프로젝트가 설계 단계면 분석 산출물 8종과 분석 다이어그램이 잠기고, 구현 단계면 설계 산출물까지 잠긴다.
여기서 중요한 건 어디에 가드를 거느냐였다. 저장 버튼을 비활성화하는 건 잠금이 아니다. 그건 힌트다. 그래서 단계→소유 산출물 매핑을 서버(server/phase.py)에 두고, 문서를 쓰는 모든 경로에 409 가드를 걸었다.
- 직접 저장
- 리뷰 요청 생성
- 리뷰 승인·반영 — 요청 후에 단계가 넘어간 경우 포함
- 작업 AI 처리
- 다이어그램 소스 저장
세 번째가 놓치기 쉬운 자리였다. 리뷰를 요청할 땐 열려 있었는데 승인하는 시점엔 단계가 넘어가 있을 수 있다. 그래서 승인 시점에 다시 검사한다.
거부 메시지는 “안 됩니다”로 끝내지 않고 어느 단계로 복귀해야 하는지를 안내한다. 그리고 완료된 단계 화면에 [이 단계로 복귀 (재작업)] 버튼을 뒀다. 사유가 필수고 이력에 남는다. 복귀하면 잠금이 풀리고, 다시 올라올 땐 게이트를 다시 밟는다.
미래 단계 산출물은 잠그지 않았다. 분석 단계에서 설계 문서를 미리 쓰는 건 허용한다. 게이트를 열람 가능하게 둔 것과 같은 이유다.
부수 효과 — 비기능 섹션이 검출기가 됐다
구현 화면에 비기능 섹션을 붙였다. 판정 기준은 “요구사항 중 어느 기능의 relatedReq에도 잡히지 않은 항목”이다.
만들고 보니 이 섹션이 원래 목적 말고 다른 일을 하고 있었다. 매핑 구멍 검출기다.
비기능 요구가 여기 뜨는 건 정상이다. 그런데 명백히 기능 요구인데 여기 떠 있다면, 그건 비기능이어서가 아니라 relatedReq 매핑이 빠진 것이다. 안내문에 이 점을 명시했다.
“매핑 안 된 것들”이라는 여집합은 두 가지를 동시에 담는다 — 매핑될 이유가 없는 것과, 매핑돼야 하는데 안 된 것. 후자를 찾는 게 정합성 검사의 일인데, 화면 하나가 공짜로 그 일을 겸하게 됐다.
이후 비기능 항목에도 Task 생성 버튼을 달고, 생성 시 그 요구사항의 백킹 이슈와 ‘Relates’ 링크로 자동 연결하게 했다. 링크 생성이 실패해도 Task 생성 자체는 유지되고 토스트로 알린다 — 부수 동작의 실패가 주 동작을 되돌리면 안 된다.
의도치 않은 함정 — 상태를 공유한 스피너
AI 추가 제안이 끝났는데 버튼이 계속 “찾는 중…”이었다.
버그가 아니라 상태 공유 문제였다. AI 추가 제안이 끝나면 에픽 추천이 자동으로 이어서 도는데(#27의 그 결정이다), 두 동작이 같은 suggesting 상태를 쓰고 있었다. 제안 카드는 이미 떴는데 에픽 추천이 도는 동안 버튼은 계속 로딩이었고, 추천이 느리면 멈춘 것처럼 보였다.
에픽 추천을 별도 상태로 분리하고 try/finally로 스피너 해제를 보장했다. 이제 추천 진행은 [에픽 추천] 버튼 쪽에 따로 표시된다.
교훈은 간단하다. 두 동작을 연쇄시키면 진행 표시도 두 개여야 한다. 하나로 묶으면 사용자는 어디까지 됐는지 모른다.
마지막 점검이 찾아낸 유령
하루를 닫으며 리뷰·작업 워크플로우를 단계 체계와 교차 점검했다. 정상 확인이 대부분이었지만 갭 세 건이 나왔고, 앞의 둘이 같은 성격이었다.
A. 미결 리뷰가 게이트에 없다. 열린 리뷰를 둔 채 단계를 넘으면, 그 리뷰는 승인 시점에 409로 막혀 영영 승인 불가로 좌초된다. 이날 게이트에 “미완료 작업 검사”를 넣었으면서 같은 성격의 “미완료 리뷰”는 안 넣은 비대칭이다.
B. 작업 생성 엔드포인트가 서버에서 안 막힌다. 편집기 작업 탭은 읽기 전용으로 비활성화했지만, /stories 자체는 잠긴 단계의 산출물 작업 생성을 막지 않는다. 이미 지나온 단계에 미완료 작업이 새로 생기면 그 단계 게이트는 다시 돌지 않으므로 아무도 검사하지 않는 유령 작업이 된다.
둘 다 같은 모양이다 — 잠금과 게이트가 각각은 맞는데, 그 사이에 끼는 상태를 아무도 안 본다. 잠금은 “지나온 단계는 못 고친다”를 보장하고, 게이트는 “미완료가 있으면 못 넘어간다”를 보장한다. 그런데 “넘어간 뒤에 생긴 미완료”는 두 규칙 어디에도 안 걸린다.
B는 특히 UI 가드만 걸고 서버 가드를 안 건 것이라, 잠금 설계의 원칙(“서버가 강제한다”)을 스스로 어긴 자리다. 다만 라벨 명시 경로(기능 → 구현 Task)는 구현 단계 규약이라 막으면 안 되니, 기본 경로만 가드해야 한다.
규칙을 두 개 만들면 규칙 사이의 틈이 새로 생긴다. 각각을 검증하는 것으로는 그 틈이 안 보인다.
마무리
이날의 축은 하나다 — 단계에 힘을 줬다. 그리고 힘이 생기니 나머지가 따라 정렬됐다. Jira 연동은 구현 진입으로, Story 생성 권한은 구현 단계로, 산출물 수정 권한은 현재 단계로.
- 관계 차수를 바꾸면 옛 자료구조부터 의심한다 —
issueKey를 dict 키로 쓰던 병합이 1:N에서 조용히 행을 먹고 있었다. 에러가 안 나는 유실이 가장 위험하다. - 식별자와 외부 키를 일찍 갈라 둔 것이 값을 했다 — 시스템이
identifier로 식별하고 있었기 때문에 1:N 전환에서 고칠 곳이 세 군데뿐이었다. - 매핑은 저장하지 않고 라벨로 조회한다 — 저장하는 순간 동기화 부채가 생긴다. 부수 효과로 Jira에서 직접 만든 이슈도 잡힌다.
- 새 기능이 옛 기능을 덮었으면 옛 기능을 지운다 — 중복은 사용자에게 매번 선택을 강요한다. 지우는 것까지가 완성이다.
- 게이트는 막는 장치가 아니라 기록하는 장치다 — 미달 전환은 사유와 함께 허용한다. 막으면 도구를 안 쓴다.
- 조건 문구에 숫자를 넣지 마라 — “미결 0건”이 조건 이름이었는데 측정값으로 읽혔다. 코드는 옳았지만 사용자는 시스템을 불신했다.
- 잠금은 서버가 강제한다 — 저장 버튼 비활성화는 힌트지 잠금이 아니다. 쓰기 경로 5개 전부에 가드를 걸었고, 리뷰는 승인 시점에 재검사한다.
- 규칙 사이의 틈을 봐야 한다 — 잠금도 맞고 게이트도 맞는데, “넘어간 뒤 생긴 미완료”는 둘 다 안 본다.
다음에 남는 것:
- 갭 A·B 수정 — 게이트에 “미결 리뷰 0” 항목 추가,
/stories기본 경로 서버 가드. - 작업 내역 화면의
[시작]버튼이 잠긴 산출물에도 열려 있는 것 (갭 C, 경미). - 재생성 필요(stale) 산출물이 자동 게이트에 없고 인도 단계 수동 항목뿐인 것 — 의도인지 확인 필요.
- 다이어그램 변경이 리뷰 워크플로우 없이 직접 커밋되는 것 — 리뷰가 문서 전용인 게 맞는지.
- 시험·인도 단계 화면. 이날 실체가 생긴 건 분석·설계·구현 셋이다.
- 게이트 예외 전환의 실사용 빈도 관찰 — 예외가 기본값이 되면 게이트는 이미 죽은 것이다.