팀 하네스 설계기 1편: 에이전트에게 진실을 하나만 주기까지 고민한 것들
에이전트에게 Confluence 링크를 주고 "이거 구현해 줘"라고 하면 코드는 나온다. 문제는 그 코드가 기획 문서의 어느 해석을 구현한 것인지 아무도 모른다는 점이다. 3주 동안 하네스를 만들며 내가 가장 많이 한 일은 코드를 빨리 짜게 만드는 것이 아니라, 에이전트가 기댈 진실을 하나로 줄이는 것이었다.
kc-admin은 AI 상담 콘텐츠(지식 카드 라이브러리, 검수·배포 파이프라인, FAQ RAG)와 관리자 계정을 다루는 관리자 백엔드다. Spring Boot 4.1, Java 25, 단일 Gradle 모듈에 헥사고날 아키텍처를 패키지 경계로 나눴다. 요구사항은 전부 기획자가 쓴 Confluence 페이지와 Figma 화면으로 들어온다. 이 프로젝트에서 나는 처음부터 "사람이 코드를 쓰는" 하네스가 아니라 "담당자가 슬래시 커맨드 두 개로 기능 하나를 끝까지 보내는" 하네스를 목표로 잡았다.
8월 11일 첫 커밋에서 9월 3일까지, 하네스 파일(CLAUDE.md와 .claude/ 아래)만 건드린 커밋이 26개다. 그 결과물은 이렇다.
| 구성 | 개수 | 규모 |
|---|---|---|
CLAUDE.md | 1 | 30줄. 절대 규칙 4개와 "해당 작업을 할 때 읽어라" 목록뿐 |
.claude/rules/ | 9 | 아키텍처·컨벤션·보안·운영 안전·SSOT 워크플로우 등 |
.claude/agents/ | 7 | 구현·리뷰·수정·문서 분석·채점 노드 |
.claude/skills/ | 6 | 오케스트레이터 4종 + 온보딩 + 봉인된 자가 채점 |
.claude/hooks/ | 6 | Python 훅. bash-guard.py 하나가 765줄 |
이 3부작은 그 하네스를 해부한 기록이다. 1편은 왜 이렇게 만들었는가, 2편은 실제로 어떻게 도는가, 3편은 서브에이전트·스킬·훅 각각에 무엇이 들어 있는가를 다룬다.
1. 출발점: 에이전트가 문서를 해석하는 순간 진실이 둘이 된다
가장 쉬운 방법은 Confluence 링크를 주고 구현을 시키는 것이다. 처음 고민은 거기서 시작했다. 에이전트는 매 세션 원문을 다시 읽고 다시 해석한다. 원문은 기획자의 언어로 쓰여 있고, 정렬·페이징·권한·삭제 정책처럼 빈칸이 많고, 수시로 고쳐진다. 문서에 정렬 기준이 없으면 한 세션은 최신순을, 다른 세션은 이름순을 고를 수 있다. 둘 다 틀렸다고 말할 수 없다는 점이 더 위험하다. 그 위에서 매번 해석을 새로 하면 구현마다 진실이 하나씩 생긴다.
실제로 하네스의 SSOT 작성 스킬에는 이 빈칸들이 "단골 누락" 목록으로 박혀 있다. 정렬·페이징·검색 조건·권한·삭제 정책·동시 수정, 그리고 해당할 때는 비로그인 엔드포인트의 남용 방어, 개인정보 필드의 마스킹·보존 기간, 관리자 쓰기 행위의 감사 기록까지. 원문이 이 항목들에 침묵하면 에이전트가 채우는 것이 아니라 질문으로 올린다.
그래서 첫 번째 원칙을 이렇게 세웠다.
Confluence·Figma는 읽기 전용 입력이다. 구현의 근거는 그것을 정리한 SSOT 문서 하나뿐이다. Jira 티켓은 SSOT의 파생물이다.
docs/ssots/<슬러그>/ssot-<슬러그>.md가 그 SSOT 문서다. 요구사항 항목마다 (컨플: 섹션명), (피그마: 프레임명) 같은 출처가 붙고, 인수 조건(AC)은 전부 Given/When/Then 형식으로 번호가 매겨진다. 이 문서가 status: approved가 되기 전에는 구현 스킬이 시작 자체를 거부한다. 구현 중에 원문과 SSOT가 다르다는 걸 발견해도 에이전트는 원문을 따르지 않는다. 멈추고 보고한다.
방향이 한쪽뿐이라는 것은 생각보다 많은 것을 정리해 준다. "기획이 바뀌면?"은 SSOT 갱신 모드로 들어오고, "프론트가 API를 바꿔 달라면?"은 SSOT로 들어오고, "운영에서 느리면?"조차 별도의 기술 SSOT로 들어온다. 입구가 여러 개여도 진실이 저장되는 곳은 한 군데다.
2. 첫 번째 결정: 추측 대신 질문을 제도화한다
CLAUDE.md의 절대 규칙 4번은 한 줄이다. "문서에 없는 요구사항을 지어내지 않는다." 말로 하기는 쉽지만, LLM에게 "모르면 묻는다"를 실제로 지키게 하려면 물을 곳과 기다릴 곳이 있어야 한다.
그 장치가 SSOT 문서 8절의 [OPEN] 미확정 질문이다.
- 문서에 근거가 없는 결정은 본문에 단정하지 않고
[OPEN] Qn으로 올린다. - 각 질문에는 영향 AC가 붙는다.
[OPEN]이 남아 있는 AC는 구현이 금지된다. - 답변은 Jira 댓글로 받고, SSOT에
[ANSWERED: 댓글 링크]로 기록돼야 해소된다.
여기서 한 번 크게 고친 부분이 있다. 초기 [OPEN] 문안은 개발자가 개발자에게 묻는 말투였다.
- [OPEN] Q4: 목록 조회의 pageSize 상한을 둘 것인가? (영향 AC: AC-12, AC-17)
+ [OPEN] Q4: 목록에서 한 번에 불러올 수 있는 최대 건수를 제한할까요?
+ (배경: 제한이 없으면 한 번에 수천 건까지 요청할 수 있어 화면이 느려질 수 있습니다.
+ 기술 항목: pageSize 상한. 영향 AC: AC-12, AC-17)질문을 읽고 답하는 사람은 기획자다. pageSize, enum, 엔드포인트 같은 단어가 질문 줄에 있으면 답이 늦게 오거나, 오해한 답이 온다. 그래서 질문 줄에는 업무 용어만, 기술 표현은 배경 줄의 기술 항목:으로 내린다는 규칙을 정본으로 만들고, SSOT 채점 rubric에 이 항목을 넣었다. 단, 쉽게 쓰다가 무엇을 정해 달라는 것인지가 흐려지면 그쪽이 더 무겁게 감점된다. 쉬움보다 결정 대상이 우선이다.
3. 두 번째 결정: 사람 게이트는 어디에 둘 것인가
자동화를 설계하면 결국 "어디서 사람을 부를 것인가"로 귀결된다. 사람을 너무 자주 부르면 하네스가 아니라 귀찮은 체크리스트가 되고, 너무 드물게 부르면 사고가 사람 모르게 일어난다. 나는 이 기준 하나로 정리했다.
기획자에게 나가는 질문은 사람이 승인한다. 규격이 고정된 사실 통보는 자동으로 나간다.
| 구분 | 예시 | 처리 |
|---|---|---|
| 질문 (외부로 나가는 판단) | [OPEN] 질문 댓글, 답변 반려 안내 ↩️ | 문안 미리보기 → 담당자 승인 또는 추가·수정·삭제 피드백 → 승인된 문안 그대로 게시 |
| 사실 통보 (규격 고정) | 📐 SSOT 갱신 전파, ✅ 답변 반영, 📥 문서변경 반영, 작업 이력 댓글 | 자동 게시 |
| 되돌리기 어려운 동작 | 폐기된 AC의 코드 삭제, 병합 충돌 해소 | 후보만 보고하고 정지 |
| 승격 | parent → dev, parent → live, live → master | 100% 사람 |
Jira 댓글 승인 루프는 라운드 제한이 없다. 담당자가 "Q3은 빼고 Q5 문안을 이렇게 바꿔"라고 하면, 스킬은 댓글이 아니라 SSOT 8절을 먼저 고치고 그로부터 댓글을 다시 만든다. 댓글은 SSOT의 사본이어야 하기 때문이다. 댓글만 고치면 다시 진실이 둘이 된다.
또 하나의 게이트는 스토리 단위 구현을 시작하기 직전의 투입 승인이다. 어떤 작업 티켓을 몇 개의 워크트리로 띄우고, 어떤 티켓은 왜 재개하며, 완료되면 parent까지 자동 병합된다는 사실을 보여 주고 멈춘다. 이 게이트는 실행당 한 번이다. 티켓마다 멈추면 병렬의 이점이 사라진다.
4. 세 번째 결정: 병렬화의 단위는 티켓, 격리의 단위는 워크트리
스토리 하나는 보통 작업 티켓 3~6개로 쪼개진다. 이걸 순차로 돌리면 하루가 걸리고, 같은 디렉토리에서 병렬로 돌리면 서로의 파일을 밟는다. 그래서 식별자 체계를 이렇게 고정했다.
스토리 티켓 1개 = SSOT 문서 1개 = 에픽 parent 브랜치 1개
작업 티켓 1개 = git worktree 1개 = parent 병합 1개 = 에이전트 한 세션 완료 단위
워크트리는 반드시 하네스 루트의 .claude/worktrees/ 안에만 만든다. 티켓 브랜치는 항상 에픽 parent에서 파생하고, 완료되면 parent로 merge --no-ff 된 뒤 워크트리와 브랜치가 지워진다.
여기서 의외의 함정이 하나 있었다. 세션을 티켓 워크트리 안에서 열면 안 된다. Claude Code는 세션을 시작한 디렉토리의 .claude/ 사본을 읽는다. 워크트리는 parent를 파생한 시점의 스냅샷이라, 그 안에서 세션을 열면 며칠 전 버전의 훅과 스킬로 돈다. 그래서 구현 스킬의 첫 사전 점검이 git rev-parse --show-toplevel과 git worktree list의 첫 항목을 비교해, 루트가 아니면 진행하지 않고 안내만 한다.
5. 사고에서 배운 것: 8월 31일의 세 가지 실측
하네스 커밋 메시지를 다시 읽어 보면, 큰 구조 변경은 거의 전부 실제 사고 직후에 있었다. 특히 8월 31일 하루에 세 건이 겹쳤다.
① dev에 직접 커밋하던 산출물이 동시 세션에 되돌려졌다
처음에는 SSOT 문서와 실행 로그를 하네스 루트(dev 체크아웃)에 로컬 커밋했다. 그런데 동시에 돌던 다른 세션이 dev를 되돌리자, 거기에만 있던 산출물이 함께 사라질 수 있다는 것이 실측으로 드러났다. 산출물이 여러 세션이 공유하는 브랜치에 사는 한 이건 다시 일어난다.
처방은 구조 변경이었다. SSOT 문서, 실행 로그, 다이어그램 레지스트리, 리뷰 로그까지 스토리의 모든 산출물을 에픽 parent 브랜치 하나로 모았다. parent는 .claude/worktrees/parent-<에픽번호> 상주 워크트리에 스토리 기간 내내 체크아웃돼 있고, 스킬은 루트의 docs/ssots/를 읽지도 쓰지도 않는다. 이제 스킬이 dev에 직접 커밋하는 경로 자체가 없다. 코드와 문서가 한 브랜치에 있으니, 담당자의 parent → dev MR 하나로 전부 함께 올라간다.
② 기획자는 질문을 받은 적이 없었다
Jira 댓글을 markdown으로 게시하면 본문의 [~accountId:...] 멘션이 이스케이프돼 렌더링되지 않는다. 알림도 가지 않는다. 담당자는 "답변 대기 중"이라고 믿고, 기획자는 질문의 존재조차 모르는 표류가 실제로 생겼다. 이후 멘션이 들어가는 모든 댓글은 contentFormat: "adf"의 mention 노드로만 게시하고, 게시 후 반환 본문에 이스케이프 흔적이 보이면 같은 댓글을 재갱신하도록 규칙을 박았다.
③ stale한 origin/live에서 parent를 팠다
parent는 항상 live에서 판다. 처음에는 "원격이 기준이니까" origin/live를 썼는데, 이 저장소에서는 하네스 변경이 로컬 live에 먼저 들어가는 일이 많았다. 결과적으로 빌드 게이트(체크스타일 룰)가 빠진 구버전 워크트리가 만들어졌다. 지금은 merge-base --is-ancestor로 로컬과 원격 중 최신 쪽을 고르고, 분기 상태면 중단한다. 워크트리를 만든 직후에는 cmp -s로 checkstyle.xml과 build.gradle을 루트와 비교해, 다르면 "빌드 게이트 구버전"을 보고에 박는다. 파일이 존재하는지만 보면 안 된다. 존재해도 룰이 빠져 있을 수 있다.
같은 날 진행한 하네스 감사에서는 더 불편한 경로도 발견했다. fixer 서브에이전트가 코드 대신 규칙을 고쳐서 리뷰 지적을 "해소"할 수 있었다. 커버리지 기준을 낮추거나, 체크스타일 룰을 지우거나, 리뷰 규칙 문서 자체를 편집하는 식이다. 그래서 CLAUDE.md, .claude/rules|skills|agents, build.gradle, config/checkstyle/** 수정은 전부 사람 확인을 거치도록 훅을 강화했다. 이 이야기는 3편에서 자세히 한다.
6. 하네스를 채점하다: 해당 없는 축을 명시적으로 제외하기
하네스가 좋아지고 있는지 감으로 판단하고 싶지 않았다. 그래서 공개된 loop-engineering의 Loop Design Checklist(10섹션 43항목)를 kc-admin 용어로 번역해 자가 채점 시트를 만들었다. 척도는 SSOT 채점과 같은 2·1·0이다.
여기서 가장 중요했던 결정은 점수가 아니라 N/A 선언이었다. 원본 rubric은 cron으로 도는 무인 루프를 전제한다. 이 하네스는 사람이 티켓 단위로 호출하는 파이프라인이다. 스케줄링 5항목, 운영자 알림 1항목, 토큰 예산·자동 PR 5항목, 총 11항목을 "점수가 낮은 게 아니라 애초에 대상이 아니다"로 제외했다. 이걸 만족시키겠다고 cron과 예산 파일을 붙이면 설계 목적과 어긋난 개조가 된다.
| 일자 | 점수 | 무엇이 바뀌었나 |
|---|---|---|
| 08-13 | 55/64 (86점) | 최초 채점 |
| 08-24 | 54/64 (84점) | 티켓 → parent 자동 병합 도입으로 "병합 100% 사람" 근거가 사라져 오히려 감점 |
| 08-24 (2차) | 63/64 (98점) | 투입 승인 게이트, 리뷰 로그 반영 기준 헤더, 실행 로그, 병합 allowlist 훅, flaky 프로토콜, MCP 쓰기 가드 등 7건 반영 |
8월 24일의 감점이 기억에 남는다. 자동 병합을 넣는 순간 점수가 떨어졌고, 그걸 되돌리는 대신 훅이 병합 대상을 기계적으로 검증하는 allowlist를 만들어서 다시 올렸다. 채점 시트가 없었다면 "자동화가 늘었으니 좋아졌다"고 넘어갔을 것이다.
남은 1점은 봇 아이덴티티다. 하네스가 담당자 계정으로 댓글을 쓰기 때문에 사람이 쓴 글과 구분이 어렵다. 봇 계정을 따로 만들지 않기로 했고, 대신 기계 생성물에는 📐, ↩️, 📥 같은 고정 마커를 붙여 식별한다. 원본 rubric의 최고 단계(L3, 무인)는 목표로 삼지 않는다. 무인 병합을 전제하기 때문에 "승격은 전부 사람"이라는 원칙과 정면으로 충돌한다.
7. 이름 하나를 바꾸는 데 하루를 쓴 이유
9월 2일에 "스펙"이라는 용어를 "SSOT 문서"로 전부 바꿨다. 디렉토리(docs/specs → docs/ssots), 파일명, 스킬명(spec-* → ssot-*), 에이전트명, Jira 마커(📐 스펙 갱신 전파 → 📐 SSOT 갱신 전파)까지다.
사소해 보이지만 이유가 있었다. 이 프로젝트에는 REST Docs가 만드는 OpenAPI "스펙"도 있고, 프론트와 공유하는 API 계약도 있다. 같은 단어가 여러 산출물을 가리키면 규칙 문서를 읽는 사람도, 에이전트도 헷갈린다. 반면 SSOT는 "단일 진실 원천"이라는 역할 자체가 이름에 들어 있고, 이미 ssot- 접두를 쓰던 파일·에이전트·규칙 이름과 용어가 맞아떨어진다. 이름이 역할을 말하면 규칙을 덜 써도 된다. 대신 하위 호환에 신경을 썼다. 이미 파생된 parent 브랜치에는 구 이름 spec-*.md가 남아 있으므로, 스킬의 SSOT 탐색 글롭이 두 이름을 모두 보고, 구 마커 📐 스펙 갱신 전파도 같은 변경 신호로 읽는다.
같은 날 /ssot-followup도 만들었다. 개발이 끝난 뒤 발견된 이슈 중에는 SSOT가 바뀌지 않는 것(구현이 AC를 위반한 결함)과 기획 문서로는 표현할 수 없는 것(성능·운영·보안)이 있다. 이 둘은 그전까지 SSOT 게이트를 우회해서 들어올 수밖에 없었다. /ssot-followup은 이슈를 분류해 기존 경로로 돌려보내기만 하고, 코드는 고치지 않는다. 그 분류의 방어선은 한 문장이다. "사용자에게 보이는 동작이 바뀌면 어떤 경우에도 기술 이슈가 아니다." 기술 SSOT는 담당자 단독으로 승인되기 때문에, 기획 확인이 필요한 사안이 여기로 새면 게이트가 무력화된다.
8. 시니어의 시선으로 정리하는 인사이트
① 하네스의 첫 번째 일은 진실의 개수를 세는 것이다
에이전트의 품질 문제 대부분은 모델 성능이 아니라 근거가 여러 개인 상태에서 나왔다. 원문, 이전 세션의 해석, 티켓 본문, 담당자의 말. 이 중 무엇을 따를지 정해 주지 않으면 에이전트는 매번 그럴듯한 쪽을 고른다. SSOT 한 방향 원칙은 기술 장치라기보다 조직 원칙이다.
② "모르면 물어라"는 지시가 아니라 인프라다
질문을 저장할 위치(SSOT 8절), 질문이 막는 범위(영향 AC), 질문을 보낼 경로(승인 루프), 답을 회수할 경로(갱신 모드)가 있어야 비로소 "묻는다"가 실행된다. 프롬프트에 "추측하지 마"라고 쓰는 것만으로는 아무것도 바뀌지 않았다.
③ 사람 게이트는 판단의 성격으로 나눈다
"중요한 것은 사람이 본다"는 기준은 모호하다. 밖으로 나가는 판단과 되돌리기 어려운 동작만 사람이 승인하고, 규격이 고정된 사실 통보는 자동으로 둔다. 이 기준을 세운 뒤로 "이것도 승인받아야 하나"라는 논쟁이 거의 사라졌다.
④ 사고는 구조로 갚는다
8월 31일의 세 사고 모두 "다음부터 조심하자"로 끝낼 수 있었다. 하지만 동시 세션 사고는 산출물의 주소를 바꿔서, 멘션 사고는 게시 형식을 강제해서, stale 브랜치 사고는 판정 절차와 내용 비교로 막았다. 규칙 문서에 한 줄 추가하는 것과 사고가 다시 일어날 수 없는 구조를 만드는 것은 다르다.
⑤ 자기 채점 시트는 점수보다 N/A가 중요하다
남의 rubric을 가져오면 모든 항목을 만족시키고 싶어진다. 그 유혹을 끊은 것이 N/A 선언이었다. 설계 목적을 명시하고 해당 없는 축을 문서로 제외해야, 점수가 하네스를 엉뚱한 방향으로 끌고 가지 않는다.
마무리: 빠른 에이전트보다 흔들리지 않는 근거
한 문장으로 줄이면 이렇다. 에이전트에게 필요한 건 더 많은 맥락이 아니라 단 하나의 근거와, 그 근거가 비어 있을 때 멈출 자리다.
다음 편에서는 이 원칙들이 실제로 어떻게 도는지 따라간다. /ssot-creator가 문서 여러 개를 병렬 분석해 SSOT를 만들고 rubric으로 채점하는 그래프, /ssot-driven-dev가 작업 티켓을 의존성 DAG로 스케줄링해 워크트리 병렬로 구현하고 리뷰하고 병합하는 과정, 그리고 상태 파일 하나 없이 중단과 재개를 버티는 기법들이다.
- 2편: 티켓 하나가 parent에 병합되기까지
- 3편: 서브에이전트·스킬·훅 해부