에이전트가 자기 코드를 채점하지 못하게 만들기: sparring 🥊
AI 코딩 에이전트가 자기 결과물을 스스로 리뷰할 때 생기는 맹점을 Stop 훅 기반 리뷰 루프로 막는 방법을 정리한다. sparring은 작성자와 리뷰어를 분리하고, 리뷰를 부탁이 아니라 종료 조건으로 만들어 수렴할 때까지 세션을 계속 진행시킨다.
· 9 min read
무엇을 틀렸는지 모르는 에이전트 #
IDE를 가지고 직접 코딩한게 언제적인지 모를만큼, 요즘은 AI에게 코딩을 시키는 경우가 훨씬 많아졌습니다. 솔직히 말하면 직접 코딩하지 않고있는게 맞습니다.
그런데 AI 코딩 에이전트를 며칠만 써보면 코드를 쓰는 일은 잘하는데, 자기가 무엇을 틀렸는지 알아채는 일은 못한다는 것을 알 수 있게 됩니다.
사실 가장 빠르고 자연스러운 해결책은 리뷰를 다시 붙여서 에이전트에게 물어보는 것 입니다. 그런데 같은 세션에게 “방금 쓴 코드를 리뷰해봐"라고 하면 둘 중 하나의 상황이 발생하게 됩니다.
- 자기 결과물에 관대한 경우
- 버그를 만들어낸 그 맹점을 리뷰할 때도 그대로 갖고, 못 본 것을 다시 봐도 여전히 못 보는 경우
sparring 은 이 문제를 프롬프트가 아니라 하네스 층에서 다루도록 설계했습니다.
- 작성자 모델과 리뷰어 모델을 다른 벤더로 분리
- 리뷰를 부탁이 아니라 종료 조건으로 설정
그래서 에이전트가 “다 했습니다"라고 말하며 세션을 끝내려는 순간, 훅이 그 종료를 바로 막아버립니다.
어디에서 강제 종료가 발생하는가 #
Claude Code에는 Stop 훅이 있습니다. 에이전트가 턴을 끝내려는 시점에 호출되는 스크립트인데, 훅이 출력하는 JSON에 따라 세션이 실제로 끝나거나, 끝나지 못하고 이어지도록 합니다.
approve() { printf '{}\n'; exit 0; }
block() { # $1=이유(모델에게 그대로 전달) $2=상태 표시줄 메시지
jq -nc --arg r "$1" --arg s "${2:-sparring}" \
'{decision:"block", reason:$r, systemMessage:$s}'
exit 0
}위의 코드를 순서대로 정리하면 아래와 같습니다.
block을 내보내면reason문자열이 모델에게 새로운 지시로 실행됩니다.- 리뷰어가 수렴을 선언할 때까지 계속
block을 냅니다.
작성자에게는 이 루프를 빠져나갈 수단이 없습니다. 프롬프트에 “리뷰를 꼭 받으세요"라고 적어두는 것과는 다른, 지킬지 말지가 모델의 판단에 달려 있지 않게됩니다.
빈 객체 {}는 승인을 의미하는데요, decision: "approve"를 명시하면 Codex 쪽 훅 배선이 이를 거부하기 때문입니다. 즉, 두 하네스가 모두 “계속 진행"으로 읽는 모양은 빈 객체 하나뿐이라서 이를 승인을 판단하는 경우로 정의했습니다.
Codex 0.144.1에서 “invalid stop hook JSON output"이 뜨고 수렴한 런의 세션이 풀리지 않았던 것을 확인했습니다
반대로 훅이 자기 버그로 죽으면 그때는 무조건 승인하도록 했습니다. 리뷰를 한 번 놓치는 상황보다 훅이 고장 나서 사용자가 세션에 갇히는 상황이 훨씬 심각하다고 생각했습니다.
라운드 별 동작 방식 #
/spar:fight <할 일> 명령은 정해진 계획에 따라 실제로 스파링 형태로 코드를 수정하는 것을 의미합니다. 이 명령은 상태 파일 하나를 먼저 작성합니다.
---
active: true
phase: task
round: 0
review_id: 20260730-104512-9ac31f
base_sha: 4f2a1c... # 루프가 시작된 시점. 이 기준선은 끝까지 움직이지 않는다
reviewer: codex
max_rounds: 5
---
스펙에 있는 페이지네이션 오프셋 버그를 고쳐라그 다음 에이전트가 평소대로 코드를 작성합니다. 다 썼다고 판단해서 멈추려 하면 훅이 발생합니다.
[구현] 작성자가 코드를 쓰고, 멈추려 한다
|
v
Stop 훅 -- 10줄·2경로 이하 + 위험 경로 없음? --> skipped 로 종료(기록됨)
|
v
[라운드 N] 리뷰어(읽기 전용)가 변경면과 요구사항을 함께 본다
|- STATUS: FINDINGS
| |- [MECHANICAL] -> 즉시 수정. 사용자에게 묻지 않는다
| |- [DESIGN] -> 먼저 논쟁, 안 풀리면 보류 후 게이트에서 일괄 질문
| |- 교착(같은 발견을 2라운드 연속 제기·거부)
| | |- 사실 문제 -> 블라인드 심판
| | `- 설계 문제 -> 사용자 게이트 + 결정 원장
| `- 발견별 응답 파일 작성 -> 라운드 N+1
`- STATUS: CONVERGED -> (필요하면 최종 스윕) -> 종료훅은 각 라운드를 준비할 때 리뷰어 runner script가 그 자리에서 생성됩니다. 이때 스크립트 내부는 리뷰어 계열에 따라 다르게 작성되는데요, 현재는 claude/codex를 지원하므로 2가지 종류로 구분됩니다.
# codex 계열
codex exec --sandbox read-only --skip-git-repo-check \
--output-last-message "$tmp" < "$prompt"
# claude 계열(단일 벤더 모드)
{ cat "$prompt"; echo '--- Changes under review ---'; cat "$diff"; } \
| claude -p --safe-mode --tools Read Grep Glob > "$tmp"두 경우 모두 리뷰어에게 쓰기 권한이 없도록 되어있습니다. Codex 리뷰어는 읽기 전용 샌드박스 안에서 직접 git diff를 돌리고, Claude 리뷰어는 Read/Grep/Glob만 들고 변경면을 프롬프트로 전달받습니다. 언제나 작성자만 코드를 작업할 수 있도록 했습니다.
리뷰어의 경우 엄격한 형식을 바탕으로 결과를 출력하도록 했습니다. 왜냐하면 훅이 이 텍스트를 파싱해서 다음 행동을 정하기 때문입니다. 그러므로 엄격한 형식은 필수적인 부분이었습니다.
- 첫 줄은
STATUS: CONVERGED또는STATUS: FINDINGS여야 한다. - 발견 하나하나가
### F3-2 [MECHANICAL] 오프셋이 0-기준으로 계산됨같은 머리글에 파일·문제·제안을 포함해야 한다.
발견이 나왔으면 작성자는 모든 질문에 대해 응답해야합니다. [MECHANICAL] 으로 표시된 항목들은 모두 고쳐야합니다. 거부하려면 코드나 요구사항에 근거를 대야 하고, “불편해서” 와 같은 이유는 통하지 않습니다. 그리고 발견 ID별로 응답 파일을 작성하도록 했습니다.
### F3-1: FIXED — offset을 (page-1)*size로 바꾸고 경계 테스트 두 개를 추가
### F3-2: REJECTED — 이 경로는 스펙 4절이 1-기준을 명시한다응답 파일이 없거나 답하지 않은 발견이 남아 있는경우, block 으로 처리합니다. 다음 라운드는 그 파일이 채워진 뒤 준비되도록 해서 무심코 넘어가는 구간이 없도록 설정했습니다.
설득하기 어렵도록 만드는 장치들 #
여러 라운드를 돌았음에도 실패하는 리뷰의 경우, 버그를 놓쳐서이기 때문만은 아닙니다. 바로 작성자가 리뷰어를 설득해서 끝내기 때문입니다. 한쪽이 자신 있게 말해서 논쟁을 이기게되면, 루프는 논쟁을 종료하고 ‘입김이 센 쪽으로’ 붙게 됩니다.
그래서 구조적으로 몇 가지 부분을 막아두었습니다.
- 리뷰어에게 무엇을 고쳤는지 말하지 않습니다 : 매 라운드는 고정된 기준선을 중심으로 완전히 새로운 리뷰를 수행합니다. “지난 라운드 지적은 반영했습니다” 같은 문장이 채널을 타고 넘어가지 않도록 해서, 리뷰어는 매번 이 문제를 처음 보고 행동합니다.
- 같은 발견을 두 라운드 연속 제기하고 거부하면 교착지점으로 봅니다 : 문제(
[MECHANICAL])는 블라인드 상태인(아무것도 미리 알고있지 않은) 심판에게 갑니다. 심판은 코드와 그 발견만 보고, 지금까지의 논쟁은 보지 못하기 때문에, 평가가 객관적이게 됩니다. 그래서RULING: UPHELD면 작성자는 고쳐야 하고,DISMISSED면 발견한 내용이 무시됩니다. 결국 심판이 논쟁의 히스토리를 알면 알수록 더 잘 쓴 쪽으로 기울것이고, 결국 편향된 결과를 제공할 것이기 때문입니다. - 설계 문제는 사람에게 : 설계 과정에서의 결정권은 심판이 아닌 사용자(사람)가 갖도록 했습니다. 나중에 보류된 문젲들만 남았을 때 훅이 게이트를 한 번 열어서 남아있던 질문들을 하나로 모아 사용자에게 넘깁니다. 답은
.claude/spar-ledger.md에 기록되고, 이후 라운드의 리뷰어 프롬프트에 “이미 정해진 설계 결정"으로 주입됩니다. 같은 논점이 되살아나지 않는 것입니다. - 표현만 바꾼 반복은 같은 발견으로 묶는다 : 발견된 내용은 파일 이름과 정규화한 제목으로 구분합니다. 지문은 새로운데 같은 파일에 이미 추적 중인 발견이 있으면, 블라인드 상태의 모듈이 라운드당 한 번 돌면서 둘이 같은 결함인지 판정합니다. 만약 같다고 나오면 별칭으로 묶여 동일한 문제를 중복으로 확인하지 않도록 방지합니다.
5회 이상 논쟁해도 해결되지 않는 경우 #
여러번의 논쟁을 진행했음에도 해결되지 않는 문제가 생길 수 있습니다. 이 경우 언제까지 해결될지 모를 수 있고, 심하면 계속해서 서로 논쟁만 할수도 있게 됩니다.
이를 방지하기 위해 캡(cap)을 두기로 했습니다. 처음에는 단순히 5회로 최대 논쟁 가능 라운드를 지정했었는데요, 나중에 확인해보니 경과한 라운드 수마다 생성된 리뷰들이 “실제로 해결책을 만들어내기 위한 리뷰"로 판단하기가 어려웠습니다.
그래서 캡을 2단계로 나눴습니다.
- 소프트 캡(기본 5): 만약 진행했던 라운드가 생산적이었으나 해결만 완료되지 않은거라면, 하드 캡(그 두 배)까지 추가로 진행합니다. 이때 생산적이라는 조건은 좀 까다로운데요, 모든 발견이 명확한
FIXED로 답해졌고, 거부·미답·모호한 항목이 존재하지 않고, 심판 대기나 보류된 설계 발견이 없고, 앞선 라운드의 발견을 되풀이하는 항목이 없는 경우를 의미합니다. - 같은 지적이 다시 나온다는 건 제대로 코드 수정이 되지 않고있다는 것이고, 그건 현재 라운드가 진전되지 않고있다는 것을 의미한다고 판단했습니다. 그래서 한번만 되풀이가 발생하더라도 연장을 막도록 했습니다. 느슨한 규칙을 두고 계속 비용을 낭비하는 것보다, 엄격한 규칙을 먼저 지정하고 점점 모델의 성능이 좋아지면서 이 제한을 느슨하게 하는 것이 더 좋은 방향이라고 생각했습니다.
그리고 수렴 선언 뒤에도 추가로 한단계의 검증을 더 진행하도록 했습니다.
- 위험한 경로를 건드렸거나, 세 라운드 이상 돌았거나, 설계 발견이 하나라도 있었으면 최종 단계로 이동합니다.
- 작성자 역할을 가진 새로운 인스턴스가 루프 이력을 전혀 모르는 상태로 스냅샷을 읽고 마감을 위한 최종 점검을 합니다. 이때, 최종 점검 기준으로는
SWEEP: CLEAN|FINDINGS를 사용하며, 리뷰어가 남긴STATUS: CONVERGED는 사용하지 않습니다. 루프 밖에서 만들어진 무엇도 수렴으로 오인될 수 없어야 한다.
Codex에서도 사용할 수 있도록 만들기 #
Codex CLI에도 Stop 훅이 있고 decision: block 상태를 가집니다. 그래서 claude에서 실행하던 방식을 그대로 codex에서 실행할 수 있도록 할 수 있습니다. 즉 Codex가 코드를 쓰고, claude -p가 리뷰하고, 종료를 막는 게이트키퍼 스크립트를 만들 수 있는것이죠. 명확하게 작성한 정책 문서 하나면 사용자가 취향에 따라 두 에이전트 중 하나를 선택해서 작성자로 동작하게 할 수 있습니다.
중요한점은 작성자만 코드를 쓴다는 것입니다. 작성자는 논쟁이 수렴되었는지 여부를 판단하지 않습니다. 수렴이 되었는지를 확인하고 결정하는 것은 훅이 하고, 프롬프트 자체를 안전장치로 취급하지 않습니다. 왜냐하면 심판과 스윕은 논쟁을 확인할 수 없도록 되어있기 때문입니다.
시작 전에 계획하기 #
당연히 작업이 한번에 끝나지 못하는 경우가 많습니다. 그래서 코드 작업 루프 위에 계획 단계를 만들었습니다.
/spar:ready <스펙>은 스펙으로 작성된 문서를 체크박스 계획으로 바꾸고 전용 브랜치를 잘라두는 준비 작업을 합니다. /spar:fight는 그 계획을 과제 하나씩 루프에 통과시키고, 과제가 수렴할 때마다 체크박스를 체크하고 커밋합니다. 다음 과제로 넘길지 말지는 모델이 아니라, 앞 과제가 남긴 결과 파일로 결정합니다. 수렴하지 않은 과제는 바로 멈춰버립니다. 확실하지 않은 상황에서 다음 단계로 넘어가지 않습니다.
작성된 계획이 있으면, 코드 논쟁을 시작하기 전에 꼭 한번 확인하도록 했습니다. /spar:ready가 쓴 계획을 리뷰어가 읽기 전용으로 한 번 훑고, 코드에 대한 주장이 맞는지, 각 단계가 실행 가능한지, 스펙을 다 포함하고 있는지 확인합니다. 결과는 PLAN-REVIEW: CLEAN|FINDINGS로 표현됩니다. STATUS:를 쓰지 않는 이유는 스윕과 같은데요, 수렴은 루프 안에서 리뷰어만 쓸 수 있는 단어이기 때문입니다.
계획 문서를 리뷰한 결과 모든 항목에 확인 표시가 적히기 전에는 /spar:fight가 계획을 시작하지 않습니다. 이때 확인 결과는 계획·스펙·코드에 대한 수용 또는 거부 중 하나로 나옵니다. 이때 근거가 있다면 거부한 항목도 똑같이 확인했다고 치고 통과시킵니다. 앞에서 말했듯이 계획문서는 한 번만 읽습니다. 계획에는 수렴할 대상이 되는 테스트가 없기 때문입니다.
효과는 어느 정도인가 #
총 3개의 예시 코드를 작성하고, 각 코드마다 그럴듯한 버그를 하나씩 심었습니다. 그리고 리뷰 없이 채점한 결과와 루프를 한 번 돌린 결과를 숨겨둔 오라클로 비교했습니다.
- 리뷰가 없었을 경우: 24점 중 17점(71%)
- 루프를 돌린 뒤: 24점 만점
Claude/Codex 간 교차 벤더든, Claude/Claude 또는 Codex/Codex와 같이 동일 벤더든 결과가 같았습니다. 두 리뷰어 모두 심어둔 결함을 정확히 이름 불러 지적했다.
중요한점은 이러한 테스트 결과는 통계가 아니라 예시라는 점입니다. 심었던 버그는 스펙이 명확하고 작은 과제 수준이었습니다.
훅으로 만들면서 알게 된 것 #
이 기능을 만들면서 가장 크게 느낀 것은, “모델에게 잘 지켜달라고 부탁하는 것"과 “시스템이 실제로 못 하게 막는 것"은 완전히 다르다는 점입니다.
프롬프트에 “리뷰 없이 끝내지 마"라고 적어두면, 결국 그 규칙을 지킬 책임은 모델에게 있게됩니다. 반면 Stop 훅으로 막으면, 모델이 끝났다고 말해도 리뷰가 끝나기 전에는 세션이 종료되지 않고 규칙이 말에서 구조로 내려오게 됩니다.
그 대신 챙겨야 할 것도 늘어납니다.
- 리뷰어가 만든 결과 파일이나 심판 판정 파일은 작성자가 마음대로 고치거나 지울 수 없어야 합니다. 자기를 평가한 시험지를 직접 수정할 수 있다면 평가가 아니죠. 그래서 리뷰 결과는 안전한 방식으로 저장하고, 동시에 두 프로세스가 같은 파일을 건드리지 않도록 잠금도 걸어야 합니다.
- 리뷰어에게 보여줄 정보도 조심해서 다뤄야 합니다. 작성자가 “이건 이렇게 고쳤다"거나 “리뷰어 지적은 틀렸다"고 주장한 내용까지 리뷰어가 계속 보게 되면, 다음 리뷰가 코드 자체보다 논쟁에 영향을 받을 수 있을 것입니다. 그래서 리뷰어는 매번 코드와 요구사항을 중심으로 다시 보게 만들고, 작성자의 응답 파일은 리뷰어에게 숨겨야 합니다.
- 물론 한계도 있습니다. 작성자가 “고쳤다"고 적었는지는 확인할 수 있지만, 정말 제대로 고쳤는지는 알 수 없습니다. 그건 다음 리뷰 라운드가 다시 확인해야 합니다.
sparring에서 구성하는 경계는 아래와 같이 정리할 수 있을 것 같습니다.
시스템이 강제로 막을 수 있는 것
-> 리뷰 없이 종료하기
-> 응답 없이 다음 라운드로 넘어가기
-> 리뷰어가 쓰기 권한 갖기
반복 리뷰로 확인해야 하는 것
-> 수정이 정말 맞는지
-> 새 버그가 생기지 않았는지
-> 설계 판단이 타당한지이 둘을 구분하는 것이 훅으로 리뷰 루프를 만들 때 가장 중요한 점이었다고 생각합니다.
각 에이전트별로 플러그인으로 설치하면 사용할 수 있습니다. jq가 필요하고, Codex CLI가 있으면 Claude와 Codex를 나눠 교차 리뷰로 돌릴 수 있습니다. Codex CLI가 없으면 Claude가 혼자 리뷰어 역할을 맡습니다. 물론 반대도 가능합니다.
claude plugin marketplace add wnjoon/sparring
claude plugin install spar@sparring