
SPEC.md는 실제로 어떻게 운용하고 있는가? 스프린트 입도·파일 구성·모호함의 대가
요약
에이전트 기반 개발 체제에서 SPEC.md를 활용한 스프린트 관리 및 운용 실무를 다룹니다. 시간 단위가 아닌 '검증 가능한 동작 단위'로 스프린트를 나누는 기준과 단일 파일로 SPEC.md를 유지해야 하는 이유를 설명합니다.
핵심 포인트
- 스프린트 기준은 시간이 아닌 '검증 가능한 동작 단위'로 설정
- 기능이 섞이지 않아야 완료 정의(DoD)와 리뷰 해상도가 유지됨
- SPEC.md는 기술 스택 등 공통 전제 유지를 위해 단일 파일로 관리
- 기술적 독립성이 보장되는 경계선으로 스프린트를 분할
이전에 쓴 기사에서 Planner·Generator·Evaluator라는 3 에이전트 체제와, 그 기점이 되는 SPEC.md에 대해 썼다.
그것은 "왜 SPEC.md가 필요한가"에 대한 이야기였다. 이번에는 그 후속편으로, "실제로 어떻게 운용하고 있는가"를 쓰고자 한다.
스프린트는 어떤 입도(Granularity)로 나누고 있는가. SPEC.md는 1개 파일인가, 아니면 스프린트마다 나누고 있는가. 그리고 SPEC.md를 제대로 작성했다고 생각했음에도 모호함이 남아 있어, 실제로 실전에서 실패했던 이야기.
Sprint 0부터 Sprint 14까지, 이 블로그를 만들어 온 실제 SPEC.md와 커밋(Commit) 이력을 보면서 작성한다.
스프린트의 입도: 시간이 아니라 "검증 가능한 단위"로 나눈다
먼저 결론을 쓰겠다.
1스프린트 = 1일 분량, 과 같이 시간으로 구분한 적은 단 한 번도 없다. 구분하는 기준은 "사용자에게 보이는 동작으로서, 이것이 움직이면 완료라고 단언할 수 있는 단위"다.
이 블로그의 SPEC.md에는 현재 Sprint 0부터 Sprint 14까지 15개의 스프린트가 나열되어 있다.
| Sprint | 내용 | 입도의 실체 |
|---|---|---|
| 0 | 프로젝트 기반과 MDX 표시 파이프라인 | 인프라적인 토대. 1개 기능이 아닌 "움직이는 최소 골격" |
| ... |
Sprint 4~10을 보면 알 수 있듯이, 기본적으로는 "1기능 = 1스프린트"로 돌리고 있다. 태그 기능과 디자인 개선을 같은 스프린트에 몰아넣지는 않는다. 이유는 단순하다. 1스프린트에 기능이 2개 이상 섞이면, 완료 정의 (Definition of Done, DoD) 체크 항목도 어느 기능에 대한 이야기인지 모호해지고, Evaluator의 리뷰 해상도가 떨어진다는 점 때문이다.
반면 Sprint 11~14는 성격이 다르다. "Qiita·Zenn로의 자동 크로스 포스팅"이라는 하나의 기능을, 일부러 4개의 스프린트로 나누었다.
이것은 "큰 기능은 시간으로 구분하여 분할한다"가 아니라, 기술적으로 독립하여 검증할 수 있는 경계선으로 나누었다는 결과다. 공통 기반(변환 로직)이 먼저 확정되지 않으면 Qiita 측도 Zenn 측도 테스트할 수 없다. Qiita와 Zenn은 메커니즘이 근본적으로 다르기 때문에(전자는 REST API, 후자는 GitHub 리포지토리 연동), 같은 스프린트에서 양쪽을 모두 구현하면 한쪽의 결함이 다른 쪽의 리뷰에 섞여 들어간다. 그래서 토대 → 플랫폼 A → 플랫폼 B → 마지막으로 신뢰성 강화, 순으로 나누었다.
입도를 결정할 때 스스로에게 묻는 것은 결국 이 한 가지뿐이다.
이 스프린트의 DoD에 체크가 모두 완료되면, "동작한다"라고 단언할 수 있는가?
체크가 완료되어도 "아마 동작할 것이다"라고밖에 말할 수 없다면, 스프린트가 너무 크거나 경계 설정이 잘못된 것이다.
SPEC.md는 1개 파일로 키워나간다
SPEC.md는 스프린트마다 파일을 나누지 않는다. SPEC.md라는 하나의 파일에, Sprint 0부터 Sprint 14까지를 --- 구분자로 쌓아 올리고 있다. 현재는 600줄 가까이 되었다.
나누지 않는 이유는 두 가지가 있다.
첫 번째는, 스프린트 공통의 전제를 한 곳에 모으고 싶기 때문이다. SPEC.md의 서두에는 다음과 같은 섹션이 있다.
## 확정된 기술 스택 (변경 불가)
- Next.js (`output: 'export'`에 의한 정적 내보내기)
- 콘텐츠 관리: Markdown/MDX + Velite
...
이것을 스프린트별 파일로 나누면, Sprint 4를 작성할 때도, Sprint 12를 작성할 때도 이 전제를 매번 복사해서 붙여넣거나, Planner에게 "과거의 파일을 전부 읽어서 전제를 가져와줘"라고 부탁해야 한다. 복사해서 붙여넣으면 내용이 어긋나 불일치할 리스크가 늘어나고, 전부 읽게 한다면 파일을 나누는 의미가 없다. 1개 파일이라면 Planner는 항상 이 파일의 앞부분을 읽는 것만으로 확정 사항을 빠짐없이 이어받을 수 있다.
두 번째는, 과거의 스프린트가 이후 스프린트의 전제가 되는 경우가 흔하기 때문이다. 예를 들어 Sprint 7에서 추가한 "기사 썸네일" 필드는 Sprint 9의 OGP 이미지 생성의 전제가 된다. SPEC.md의 Sprint 9에는 "본 스프린트는 Sprint 7의 완료를 전제로 한다"라고 명기되어 있다. 1개 파일로 시계열로 나열되어 있으면 Planner도 나도 Evaluator도 이 의존 관계를 스크롤하는 것만으로 추적할 수 있다.
모호함을 "보류"로서 명시하는 작성법
SPEC.md를 하나의 파일로 키워나가는 과정에서 배운 테크닉이 하나 더 있다. 결정할 수 없는 것은 결정하지 않은 채로 보류(Pending) 상태임을 명시하는 것이다.
Sprint 11~13에는 ### 要決定事項 (결정 필요 사항)라는 헤더가 있다.
### 要決定事項 (결정 필요 사항)
- `SITE_BASE_URL`: 플레이스홀더(Placeholder) `https://YOUR_CLOUDFRONT_DOMAIN`
- Zenn frontmatter의 `emoji` 기본값 (예: `"📝"`)
...
실제 값이 아직 결정되지 않은 항목을 억지로 채워 넣어 사양(Specification)을 "완성한 척" 하는 것이 아니라, 플레이스홀더를 두고 먼저 진행한다. 그리고 구현이 끝나는 시점에 SPEC.md의 해당 부분을 실제 값으로 다시 써넣는다.
이를 하지 않으면, 결정되지 않은 단 한 가지 항목 때문에 스프린트 전체의 착수가 중단된다. 반대로 아무것도 적지 않고 진행하면, Generator가 그 자리에서 임의로 판단하여 적당한 값을 채워버리게 되고, 나중에 "왜 이 값이었지?"라며 이유를 알 수 없게 된다. 보류는 보류라고 적는다. 이것만으로도 모호함(Ambiguity)을 다루는 방식이 안정된다.
그럼에도 모호함은 남는다: 실전에서 실제로 발생한 두 가지 실패
입도(Granularity)를 의식하여 나누고, 전제 조건도 한 파일에 모았으며, 보류 사항도 명시했다. 그럼에도 SPEC.md의 모호함이 원인이 되어, Sprint를 "완료"한 후에 운영 환경에서 결함이 발생한 적이 두 번 있다. 둘 다 크로스 포스트(Cross-post) 기능(Sprint 11~14)에 관한 이야기다.
실패 1: "절대 URL로 변환한다"의 내용을 구체화하지 않았다
Sprint 11의 SPEC.md에는 다음과 같이 적혀 있었다.
기사 내의 이미지 경로 (
./image.png와 같은 상대 경로)가 CloudFront 절대 URL로 변환된다.
언뜻 보기에는 이것으로 충분해 보인다. 실제로 첫 번째 구현은 이대로 동작했다.
// 첫 번째 구현: 슬러그(Slug)로부터 기계적으로 URL을 조립함
return body.replace(
/!\[([^\]]*)\]\(\.\/([^)]+)\)/g,
...
DoD(Definition of Done) 체크도 통과했다. 로컬에서 변환 스크립트를 실행하여 이 https://.../blog/{slug}/image.png로 바뀌는 것을 확인하고 Sprint 11은 완료되었다.
문제는, 이 블로그의 이미지 배포 실태가 SPEC.md에 적은 전제와 달랐다는 점이다. 실제로는 Velite가 빌드 시점에 이미지를 해시가 포함된 파일명(thumbnail-a61d6e.png와 같은 형태)으로 변환하여 /static/ 하위에 다시 배치한다. SPEC.md의 "상대 경로를 CloudFront 절대 URL로"라는 문장은 변환의 목적은 올바르게 적었지만, 거기에 이르는 실제 경로(Velite가 배후에서 무엇을 하고 있는지)를 검증하지 않은 채 작성되었다.
이것이 표면화된 것은 실제로 Qiita와 Zenn에 게시 테스트를 진행했을 때였다. 변환된 Markdown을 Qiita에 게시하면 이미지가 표시되지 않는다. SPEC.md의 DoD는 "로컬에서 변환 스크립트를 실행하여 육안으로 확인한다"까지만 요구했기 때문에, Sprint 11 완료 시점에서는 이 차이를 알아차릴 수 없었다.
수정은 .velite/posts.json의 빌드 결과를 읽고, 거기에 적힌 실제 /static/ 경로를 다시 매핑하는 방식으로 진행되었다.
실패 2: DoD의 시나리오가 "1번의 push = 1개의 커밋"을 전제로 하고 있었다
Sprint 14의 DoD에는 4가지 E2E(End-to-End) 시나리오가 명시되어 있었다.
- 신규 기사 push
- 기존 기사 업데이트 push
published: false로 변경하는 push- 대상 외 파일만 포함된 push
언뜻 보기에는 망라적인 것처럼 보인다. 실제로 이 4가지 시나리오는 모두 통과했다.
하지만 이 4가지 시나리오에는 공통된 암묵적 전제가 있었다. **"1번의 push에 커밋이 1개"**라는 전제다. diff 검출 구현은 GitHub Actions가 전달하는 GITHUB_BEFORE(push 전의 SHA)와 GITHUB_SHA(push 후의 SHA)의 차이를 보는데, 리포지토리의 체크아웃 설정은 fetch-depth: 2로 되어 있었다. "HEAD~1을 가져올 수 있으면 충분하다"는 생각이었고, 이는 1개의 커밋을 push할 때는 올바르게 동작한다.
문제는 여러 기사를 묶어서 한 번의 push로 반영할 때(예를 들어 여러 기사의 frontmatter를 일괄 수정하여 push하는 경우)이다. 이 경우 GITHUB_BEFORE는 HEAD~1...
훨씬 더 이전의 커밋을 가리킬 수 있으며, fetch-depth: 2로는 그 오래된 커밋의 상태를 가져올 수 없어 diff 검출이 에러와 함께 중단되었다.
# 수정 전: HEAD~1만 가져올 수 있으면 충분하다는 전제
fetch-depth: 2 # git diff를 위해 HEAD~1을 사용할 수 있도록 필요함
# 수정 후: push에 묶인 커밋 수를 가정하지 않음
...
이것 역시 SPEC.md의 작성 방식이 미흡했다기보다, DoD (Definition of Done, 완료 정의)의 시나리오가 '대표적인 정상계·이상계'는 망라하고 있었지만, 'push의 입도(granularity)'라는 축이 통째로 빠져 있었던 케이스다. 4개의 시나리오는 모두 '1커밋 1기사'를 전제로 하고 있었으며, 그 외의 양적인 변동성에 대해서는 아무도 의심하지 않았다.
2가지 실패로부터 배운 것
이 두 가지 실패의 공통점은, 둘 다 SPEC.md의 문장 자체는 '모호하게' 보이지 않았다는 점이다. '절대 URL로 변환한다'도 '4개의 E2E (End-to-End) 시나리오'도, 읽는 순간 '이것은 위험하다'라고 생각하지 않는다. 위험한 것은 그 문장이 옳다고 믿고 의심하지 않았던 이면의 전제였다.
그로부터 SPEC.md의 작성 방식과 운용 방식을 두 가지 변경했다.
첫 번째: DoD는 '로컬에서 재현 가능한 확인'뿐만 아니라, 가능하다면 '실물에 대한 확인'까지 작성한다. 이미지 경로 실패는 로컬 변환 스크립트의 출력을 육안으로 확인하는 것만으로는 알아챌 수 없었다. DoD에 '실제로 Qiita/Zenn에 테스트 게시를 하고, 이미지가 표시되는지 확인한다'까지 적어 두었다면, Sprint 11 시점에서 알아차릴 수 있었을 것이다. 실제로 Sprint 14는 이 반성을 바탕으로, 처음부터 E2E 시나리오를 운영 API에 대해 실행한다는 전제로 DoD를 작성하고 있다.
두 번째: DoD 시나리오를 열거할 때, '정상계·이상계'의 축뿐만 아니라 '양·타이밍'의 축도 의식하여 도출한다. 1건이 아니라 여러 건, 1회가 아니라 연속, 동시 등의 축이다. fetch-depth의 실패는 이 축이 SPEC.md 검토 단계에서 통째로 빠져 있었기 때문에 발생했다. 지금은 새로운 스프린트의 DoD를 작성할 때, '이것이 1건만을 전제로 하고 있지는 않은가'를 스스로에게 다시 한번 되묻고 있다.
모호함을 완전히 제로로 만드는 것은 아마 불가능할 것이다. SPEC.md를 아무리 상세히 적더라도, 작성자 본인이 깨닫지 못한 전제는 반드시 남는다. 그래서 목표로 하는 것은 '모호함을 제로로 만드는 것'이 아니라, 모호함이 운영 환경에서 이빨을 드러내기 전에, DoD 안에서 한 번은 이빨을 드러내게 만드는 것이다.
요약
- 스프린트의 입도는 시간이 아니라 'DoD의 체크가 전부 완료되면 동작한다고 단언할 수 있는 단위'로 나눈다. 1기능=1스프린트가 기본이며, 큰 기능은 기술적인 경계(공통 기반 → A → B → 신뢰성 강화)로 나눈다.
- SPEC.md는 하나의 파일에서 키워 나간다. 스프린트 공통의 전제를 한곳에 모을 수 있고, 과거 스프린트에 대한 의존 관계도 동일한 파일 내에서 추적할 수 있다. 결정할 수 없는 사항은 '결정 필요 사항'으로 유보임을 명시하고, 플레이스홀더(placeholder)를 사용하여 진행한다.
- 그럼에도 모호함은 남는다. 이 블로그에서는 '절대 URL로의 변환'이라는 한 문장 이면에 있었던 Velite의 실제 이미지 전송 사양과, '4개의 E2E 시나리오' 이면에 있었던 '1push=1커밋'이라는 암묵적인 전제, 이 두 가지가 운영 환경에서의 실패를 초래했다.
- 대책은 DoD에 로컬 확인뿐만 아니라 실물에 대한 확인을 포함하는 것, 그리고 시나리오를 도출할 때 '양·타이밍'의 축을 명시적으로 검토하는 것이다.
SPEC.md 기반의 Planner/Generator/Evaluator 체제는 사상적으로는 지난번에 쓴 대로 잘 기능하고 있다. 다만, 잘 기능하는 것은 '좋은 SPEC.md를 작성했을 때'에 한한다. 좋은 SPEC.md는 한 번에 작성할 수 없다. 실제로 동작시키고, 실패하고, 다시 작성하는 그 루프의 횟수가 그대로 사양의 품질이 된다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기