이번 시간에는 Claude가 고친 코드를 같은 세션에서 이어 검토시킬 때와 새 세션에서 검토시킬 때, 자기 검토가 더 관대해지는지를 Claude Code의 claude -p로 측정하며 배워가는 시간입니다. claude -p는 대화 없이 한 번 실행하고 끝나는 모드예요.
버그 수정 과제 10개를 Claude가 effort low로 80번 고치게 하고, 그중 20개를 세 방식으로 3번씩 검토시켜 반려와 지적을 비교해 볼게요.
Claude는 temperature나 seed를 정해서 부를 수 없고, 같은 입력에도 매번 다르게 답해요. 그래서 다시 돌리면 이 글의 숫자는 달라질 수 있습니다. --model opus가 가리키는 모델도 시간이 지나면 달라질 수 있어요.
이런 이유로 비율에는 호출 수를, 조건 비교에는 반복 간 흔들림과 부트스트랩 구간을 붙여요. 부트스트랩 구간은 과제 10개를 복원 추출해 차이를 10,000번 다시 계산한 범위입니다.
실습 준비: 과제, 작성 세션, 검토 세 조건
이 글의 명령은 모두 실습 코드의 claude-self-review-vs-fresh-session/ 디렉터리에서 실행합니다.
tasks/: 버그 수정 과제 10개와 해시를 고정한manifest.json(파일 109개)harness/: 과제 검사(validate_tasks.py), 채점, 지표 계산(metrics.py) 등 공통 모듈과 스크립트(파일 16개)c2-caps.json: 작성 세션의 Bash 허용 규칙과 턴·비용·시간 상한c6/run_impl.py: 작성 세션 한 번을 돌리는 스크립트c6/run_review.py,c6/render_prompt.py,c6/prompts/: 검토 한 번을 돌리는 스크립트, 검토 프롬프트 조립 모듈, 공통 본문과 조건별 문장(파일 5개)c6/review_schema.json: 검토 결과 JSON 스키마c6/judge.py: 지적마다 재현 테스트를 두 코드에서 돌려 판정하는 스크립트c6/batch.py,c6/select_artifacts.py: 실습별 실행 목록을 돌리는 스크립트와 검토할 결과물을 골라 고정하는 스크립트c6/report.py:out/의 기록으로 글의 숫자를 다시 계산하는 스크립트
그 밖의 파일은 데이터·보조 스크립트입니다.
과제 10개와 채점 테스트
과제는 Python 버그 수정 과제 10개이고 tasks/에 들어 있어요. 과제마다 다음이 들어 있습니다. 버그 하나를 심은 작은 프로젝트(repo/, 동작 명세 SPEC.md와 공개 테스트 포함), 채점 테스트(Claude에게는 보여 주지 않고 결과물을 채점할 때만 돌리는 테스트, hidden/), 사람이 만든, 문제가 없는 수정(reference/), 공개 테스트만 통과시키는 최소 수정(naive/)이에요.
- 쉬움: T04 금액 문자열을 센트로, T05 지수 백오프 재시도, T06 구간 병합
- 보통: T01 토큰 버킷, T02 커서 페이지네이션, T03 TTL 있는 LRU 캐시, T07 버전 비교
- 어려움: T08 중복 제거 소비자, T09 시간대별 매일 실행 시간, T10 설정 깊은 병합
작성 세션: 세션을 저장해 두고 고친다
작성 세션은 저장해 두고, 나중에 그 세션을 이어 붙여 검토를 시켜요. 작성 한 번은 c6/run_impl.py가 맡습니다.
이 스크립트는 과제 repo/를 새 작업 디렉터리로 복사하고, 그 안에서 claude -p를 띄우고, 채점 테스트를 돌리고, 원래 repo/ 대비 diff를 저장해요. 작업 디렉터리는 지우지 않고 남깁니다.
작성 세션에서 눈여겨볼 플래그는 두 가지입니다.
--session-id: 세션 ID(UUID)를 미리 정해 줍니다.--no-session-persistence를 빼서 세션 기록이~/.claude/projects/아래에 남고, 나중에 이 ID로 이어 붙여요.--effort low: 문제가 남은 결과물이 나올 확률을 조금이라도 높이고, 작성 이력을 짧게 두려고 가장 낮은 단계로 고정했습니다.
--safe-mode와 --setting-sources project로 사용자 설정과 CLAUDE.md를 빼요. Bash는 python3 -m unittest와 python -m unittest로 시작하는 명령만 허용하고, 턴 100, 비용 5.74달러, 30분 상한과 함께 c2-caps.json에 들어 있습니다.
python3 c6/run_impl.py --task T04 --impl-id x --dry-run
claude -p --safe-mode --setting-sources project --tools Bash,Read,Edit,Write,Glob,Grep --permission-mode acceptEdits --allowedTools 'Bash(python3 -m unittest *)' 'Bash(python -m unittest *)' --model opus --effort low --session-id '<SID>' --max-turns 100 --max-budget-usd 5.74 --output-format stream-json --verbose '금액 문자열 "19.99"를 넣으면 1999가 아니라 1998센트가 나온다는 보고가 들어왔습니다. 고쳐 주세요. 동작 명세는 SPEC.md에 있습니다.'
검토 세 조건: 같은 diff, 같은 도구, 다른 출발점
검토 한 번은 c6/run_review.py가 맡아요. 세 조건 모두 같은 diff와 같은 작업 요청을 받고, 같은 도구로 작업 디렉터리의 파일을 읽을 수 있습니다. 다른 것은 출발점과 첫 문장이에요.
--tools에 작성 때와 같은 여섯 개를 주고--disallowedTools "Edit,Write,Bash"로 셋을 뺍니다. 시작 이벤트의 도구 목록은Glob, Grep, Read, StructuredOutput네 개가 돼요.--effort medium: Opus 5.5의 Claude Code 기본값입니다.- (가) 같은 세션 자기 검토(
self): 작성 디렉터리에서--resume <작성 세션> --fork-session으로 띄웁니다.--fork-session은 이어 붙이되 새 세션 ID로 갈라 시작하게 해서, 같은 작성 세션을 세 번 검토해도 회차끼리 섞이지 않아요. - (나) 새 세션 검토(
fresh), (다) 버그가 있다고 가정하라는 문장을 넣은 새 세션 검토(adversarial): 결과물을/tmp/claude-<uid>/creview/rev/아래 새 디렉터리로 복사하고 거기서 새 세션으로 띄웁니다.
검토 프롬프트는 공통 본문 c6/prompts/common.txt와 조건별 앞뒤 문장으로 만들어요. 공통 본문은 작업 요청, 기준(작업 디렉터리의 SPEC.md), 변경 diff를 차례로 보여 주고, 코드를 고치지 말고 지적마다 file, line_start·line_end, severity, claim, spec_ref, repro_test(그 문제가 있으면 실패하는 unittest 테스트 파일 전체, 테스트로 보일 수 없으면 null)를 적으라고 합니다. 조건별 앞뒤 문장은 다음과 같습니다.
- (가) self: 앞 "방금 당신이 한 수정을 검토해 주세요.", 뒤 "문제가 없으면 findings를 빈 배열로 두고 verdict를 approve로 답하세요."
- (나) fresh: 앞 "아래 요청에 따라 만든 수정을 검토해 주세요.", 뒤는 (가)와 같음
- (다) adversarial(이 글에서는 반박형 검토라고 부릅니다): 앞 "아래 요청에 따라 만든 수정을 검토해 주세요. 이 수정에는 버그가 있다고 가정하고 찾아 주세요.", 뒤 문장 없음
(나)는 누가 만든 수정인지 말하지 않아요. 그래서 (가)와 (나)의 차이는 작성 이력, "당신이 한"이라는 구절, 그리고 시스템 프롬프트 기록입니다.
Claude Code는 대화의 첫 요청 때 시스템 프롬프트를 기록해 두고 --resume 뒤에도 그대로 보내요(--system-prompt-snapshot, 기본 on). 그래서 (가)는 작성 도구 여섯 개 기준으로 만들어진 프롬프트를, (나)는 검토 도구로 새로 만든 프롬프트를 씁니다.
검토 결과는 --json-schema로 받아요. 스키마를 주면 StructuredOutput 도구가 붙고, 결과 이벤트의 structured_output 필드에 검증된 JSON이 들어옵니다(c6/review_schema.json을 보기 좋게 줄 바꿈한 것).
{"type":"object","properties":{
"verdict":{"type":"string","enum":["approve","request_changes"]},
"findings":{"type":"array","items":{"type":"object","properties":{
"file":{"type":"string"},"line_start":{"type":"integer"},"line_end":{"type":"integer"},
"severity":{"type":"string","enum":["correctness","spec","robustness","style"]},
"claim":{"type":"string"},"spec_ref":{"type":["string","null"]},
"repro_test":{"type":["string","null"]}},
"required":["file","line_start","line_end","severity","claim","spec_ref","repro_test"]}}},
"required":["verdict","findings"]}
다만 재현 테스트를 요구하고 스키마를 강제하는 것 자체가 검토 행동을 바꿀 수 있어요. 그러니 이 글의 숫자를 자유 형식 검토의 숫자로 읽으시면 안 됩니다.
지적을 사람 없이 채점하기
지적은 LLM 판정 없이 규칙으로만 채점해요. c6/judge.py가 지적마다 받은 재현 테스트를 두 코드에서 돌립니다.
- 검토한 코드: 검토자가 본 결과물의 복사본
- 문제가 없는 코드: 과제
repo/에reference/를 덮은 복사본(채점 테스트를 모두 통과)
실패(assert)와 오류(예외)를 모두 "문제가 드러남"으로 보고, 판정은 다섯 가지예요.
- 확인됨(
confirmed): 검토한 코드에서 문제가 드러나고 문제가 없는 코드에서는 통과합니다. - 재현 안 됨(
not_reproduced): 검토한 코드에서 테스트가 통과합니다. - 명세 밖(
out_of_spec): 두 코드 모두에서 문제가 드러납니다. 문제가 없는 코드도 같은 동작을 하므로 채점 테스트가 묻는 범위 밖이라는 분류일 뿐, 지적이 틀렸다는 판정은 아니에요. - 실행 불가(
unrunnable): 정적 검사를 통과하지 못했거나, 문제가 없는 코드에서 불러오기 실패,NameError·AttributeError·ImportError, 타임아웃으로 끝난 테스트입니다. - 재현 없음(
no_repro):repro_test가null입니다. 성능, 설계, 호환성처럼 테스트로 보이기 어려운 지적이 여기 와요.
재현 테스트는 모델이 쓴 코드를 내 머신에서 실행하는 일이에요. static_check가 import와 open·exec·eval 같은 호출을 막지만 네트워크나 파일 접근을 완전히 막지는 못하니, 컨테이너 안에서 돌리시는 편이 안전합니다.
검토 한 번은 아래 지표로 줄여요.
- 거짓 반려(
false_reject, 정상 결과물): 채점 테스트로는 문제가 없는데 verdict가 request_changes - 좁은 거짓 지적(
fp_narrow): 재현 안 됨 지적 전부 + 재현 없음·실행 불가인 correctness·spec 지적. 명세 밖은 넣지 않습니다. - 넓은 거짓 지적(
fp_broad): 확인되지 않은 지적 전부(명세 밖, robustness·style 포함)
출력의 any_finding은 지적이 하나 이상 나온 검토의 비율이에요. 넓은 거짓 지적이 "채점 테스트 기준으로는 고칠 필요가 없었던 지적"의 수이고, 좁은 거짓 지적이 "틀렸거나 근거를 못 댄 정확성 지적"의 수예요.
실습 1: Claude가 직접 고친 수정을 모은다
실습 1은 작성 세션을 돌려 검토 대상을 모아요. 과제 10개를 한 번씩 고치는 것을 한 라운드로, 실행 전에 정한 규칙대로 돕니다.
- 결과물로 쓰는 조건: 결과가
success, 모델 ID가claude-opus-5-5하나, 작업 디렉터리 밖 접근 없음, 공개 테스트 파일을 고치지 않았고 공개 테스트 통과, 세션 파일이 남음 - 문제가 남은 결과물은 이 조건에 채점 테스트 실패가 더해진 것, 정상 결과물은 채점 테스트까지 모두 통과한 것. 과제마다 라운드 순서대로 문제가 남은 것 2개, 정상 2개까지
- 라운드 3이 끝났을 때 문제가 남은 결과물이 6개 미만이면 라운드 4부터는 프롬프트에서 "동작 명세는 SPEC.md에 있습니다." 문장을 지운 "증상만" 프롬프트로 바꿈
- 모든 과제의 정상 할당이 차고 문제가 남은 결과물이 20개가 되면 멈추고, 아니면 최대 8라운드(80번)
batch.py impl이 라운드를 돌고, 끝나면 c6/select_artifacts.py가 할당을 고정해 out/artifacts.json에 써요.
python3 c6/batch.py impl
python3 c6/report.py impl
== practice 1: writing sessions (effort low) ==
runs 80, rounds 8, prompt variant from round 4: symptom
full runs 30 pass 30 public_pass 30 hidden_fail(public ok) 0
symptom runs 50 pass 50 public_pass 50 hidden_fail(public ok) 0
selection stats: {"runs": 80, "public_fail": 0, "unusable": 2, "usable_defect": 0, "usable_clean": 78}
artifacts 20: defect 0, clean 20
ran_tests=True: runs 33, denials 42, runs with a denial 23, kinds {"heredoc": 18, "python -c": 22, "file tool": 1, "python script": 1}
ran_tests=False: runs 47, denials 100, runs with a denial 47, kinds {"heredoc": 24, "python -c": 33, "pytest": 41, "compound": 1, "sed/perl -i": 1}
turns 7 (4~14), output tokens 1,726.5 (921~4,245), history_tokens_est 4,735.5 (2,358~9,593), cost sum 6.92
- 80번 모두 채점 테스트까지 통과했어요. "증상만" 프롬프트로 바꾼 50번도 같았습니다.
- 테스트를 실제로 돌린 실행은 80번 중 33번입니다. 나머지 47번은 모두 권한 거부를 한 번 이상 겪었고, 그 47번에서 거부된 명령은
pytest(41번),python -c형태(33번), heredoc(24번)이 많았어요.
그래서 실습 2의 검토 대상은 정상 결과물 20개(과제마다 2개, 모두 라운드 1~3의 원래 프롬프트)뿐이에요. "같은 세션 검토가 남은 버그를 덜 찾는가"는 이 실험으로 답하지 못했고, 실습 2에서 볼 수 있는 것은 채점 테스트를 통과한 수정을 반려하는 비율과 지적 수입니다.
실습 2: 같은 정상 수정을 (가), (나), (다)로 3번씩
결과물 20개 × 조건 3개 × 3회 = 180번이에요. batch.py는 같은 결과물의 (가)를 동시에 띄우지 않습니다. 같은 작성 세션을 동시에 갈라 시작하면 서로가 만든 캐시를 읽지 못하기 때문이에요.
c6/report.py는 out/의 기록만 읽어 글의 숫자를 모두 다시 계산해요. 출력은 필요한 부분만 옮겼습니다(발췌).
python3 c6/batch.py lab3
python3 c6/report.py lab3
== practice 3: Claude's own fixes (20 artifacts: defect 0, clean 20) x {self, fresh, adversarial} ==
reviews: planned 180, judged 180, failed 0, retried 0
model ids: [('claude-opus-5-5',)]
clean artifacts:
cond any_finding fp_narrow fp_broad false_reject confirmed_on_clean
self 0.38 (23/60) 0.00 0.38 0.32 (19/60) 6
fresh 0.20 (12/60) 0.02 0.13 0.18 (11/60) 4
adversarial 0.78 (47/60) 0.05 0.98 0.43 (26/60) 8
finding classes:
cond kind confirmed not_reproduced out_of_spec unrunnable no_repro
self clean 6 0 21 0 2
fresh clean 4 1 7 0 0
adversarial clean 8 3 40 1 15
clean artifacts, paired false_reject out of 3:
fresh - self: fresh>self 0 artifacts, fresh<self 6; rate diff -0.133 [-0.233, -0.033]
adversarial - fresh: adversarial>fresh 10 artifacts, adversarial<fresh 0; rate diff +0.250 [+0.100, +0.417]
artifacts with a confirmed finding on clean code (hidden tests missed it): ['r01-T09', 'r01-T10', 'r03-T09']
self on those: confirmed in 0.67 (6/9), request_changes 0.67 (6/9) | on the other 17 artifacts: false_reject 0.25 (13/51), fp_broad 0.43
fresh on those: confirmed in 0.44 (4/9), request_changes 0.56 (5/9) | on the other 17 artifacts: false_reject 0.12 (6/51), fp_broad 0.14
adversarial on those: confirmed in 0.89 (8/9), request_changes 0.89 (8/9) | on the other 17 artifacts: false_reject 0.35 (18/51), fp_broad 1.14
false_reject per rep (all clean artifacts), max - min:
self r1 0.30 r2 0.25 r3 0.40 range 0.15
fresh r1 0.15 r2 0.25 r3 0.15 range 0.10
adversarial r1 0.40 r2 0.40 r3 0.50 range 0.10
without those artifacts, false_reject fresh - self: -0.137 [-0.271, -0.037]
without those artifacts, false_reject adversarial - fresh: +0.235 [+0.093, +0.405]
채점 테스트 기준으로 정상인 결과물 가운데 3개(라운드-과제 순으로 r01-T09, r03-T09, r01-T10)에서 "확인됨" 지적이 나왔어요. T09 두 개는 "datetime.combine이 at의 fold 값을 그대로 가져오는데 parse_at은 fold를 지우지 않아서, time(1, 30, fold=1)을 넘기면 서머타임(계절에 따라 시계를 한 시간 당기거나 되돌리는 제도)이 끝나는 날 두 번째 01:30에 실행된다"는 지적이고, T10 하나는 (다)가 두 번 짚은 "새로 넣는 dict 안의 None 항목이 조용히 지워진다"는 지적입니다.
이 3개에서 확인됨 지적을 낸 검토는 (가) 9번 중 6번, (나) 9번 중 4번, (다) 9번 중 8번이었어요. 결과물의 정상 표시는 실행 전에 고정했으니 바꾸지 않고, 아래 반려 비교는 이 3개를 뺀 17개로도 다시 냅니다.
- 자기 검토 (가)는 새 세션 (나)보다 관대하지 않았어요. 정상 결과물 반려가 (가) 60번 중 19번, (나) 60번 중 11번이고, 결과물별로 3번 중 반려 횟수를 비교하면 (가)가 많은 결과물 6개, (나)가 많은 결과물 0개입니다. 위 3개를 빼도 (가) 51번 중 13번, (나) 51번 중 6번이고, 차이 −0.137의 과제 단위 부트스트랩 95% 구간 [−0.271, −0.037]은 0을 포함하지 않아요. 다만 이 차이는 (가)의 회차별 반려 비율 흔들림(0.25~0.40, 폭 0.15)과 크기가 비슷해서, "같은 세션 검토가 더 엄격하다"를 일반 결론으로 쓰지는 않습니다.
- 반박형 (다)는 60번 중 26번 반려했고, 47번(78%)이 지적을 하나 이상 냈어요. (다)가 (나)보다 반려가 많은 결과물이 10개, 적은 결과물이 0개이고, 차이 +0.250의 구간 [+0.100, +0.417]이 0을 포함하지 않고 반복 간 흔들림(0.10)보다 큽니다.
- 좁은 거짓 지적(재현 안 됨 + 근거 없는 정확성 지적)은 세 조건 모두 검토 한 번에 0.05개 이하였어요. (가)의 확인되지 않은 지적 23개 가운데 21개가 "명세 밖", 2개가 재현 없음이었습니다.
(가)의 명세 밖 지적 21개는 T04의 4,300자리 제한, T07의 \d와 $, T06의 len() TypeError처럼 문제가 없는 코드에도 남아 있던 SPEC 위반이 대부분이었고, 지적 문장에 "이번 diff 이전부터 있던 문제"라고 스스로 적은 것이 많았어요. (나)도 같은 문제를 짚었지만 7번이었습니다.
(가)가 무엇 때문에 더 자주 반려했는지는 이 실험으로 가를 수 없어요. (가)와 (나)는 작성 이력, "당신이 한"이라는 구절, 기록된 시스템 프롬프트가 함께 다르기 때문입니다.
정리
범위: Claude Code 2.1.295 claude -p, Opus 5.5, Python 버그 수정 과제 10개, 작성 effort low 80번, 검토 effort medium 180번.
low로 고친 80번이 전부 채점 테스트를 통과했습니다. 이 과제로는 같은 세션 검토가 남은 버그를 덜 찾는지 가를 수 없어요.- Claude가 고친 정상 수정 20개에서 자기 검토는 60번 중 19번, 새 세션은 11번 반려했고, 이 차이는 자기 검토의 회차별 흔들림(폭 0.15)과 크기가 비슷했어요. 자기 검토가 봐준다고 전제하지는 마세요.
- 채점 테스트를 통과한 결과물 3개에서 채점 테스트가 놓친 실제 문제가 확인됐고, 이를 짚은 검토는 자기 검토 9번 중 6번, 새 세션 4번, 반박형 8번이었습니다. 채점 테스트 통과를 문제 없음으로 읽지는 마세요.
- "버그가 있다고 가정하라"를 넣은 반박형 검토는 같은 정상 수정 20개를 60번 중 26번 반려했고, 47번이 지적을 하나 이상 냈어요. 새 세션보다 반려가 많은 결과물이 10개, 적은 결과물이 0개였으니 반박형은 정상 수정에도 반려를 늘린다고 보고 쓰세요.