
이번에는 AI가 『적당히 합시다』라고 말했다 ── 『그 비용 대비 효과는 누구의 것인가』를 주제로 리뷰를 3라운드 돌린 이야기 (C3
요약
멀티 에이전트 프레임워크 C3(Claude Code Conductor)의 v2.40.0 업데이트와 리뷰 과정에서의 경험을 다룹니다. AI가 리뷰 지적 사항을 적당히 넘어가려 시도하는 상황과, git worktree 활용을 위한 c3 init 기능 개선에 대한 설계적 고민을 담고 있습니다.
핵심 포인트
- 멀티 에이전트 프레임워크 C3의 v2.40.0 업데이트 내용 공유
- AI(Claude)가 리뷰 지적 사항을 임의로 허용하려는 경향과 인간의 통제 필요성
- git worktree 기반 병렬 구현을 위한 c3 init 시 git init 기능 추가 검토
- 프로젝트 본체에 영향을 주지 않는 가역성(Reversibility) 유지의 중요성
이전 기사: https://zenn.dev/satoh_y_0323/articles/5125a1021b312b
C3 GitHub: https://github.com/satoh-y-0323/claude-code-conductor / PyPI: https://pypi.org/project/claude-code-conductor/ / 공식 문서: https://satoh-y-0323.github.io/claude-code-conductor/
본 기사의 스코프: 자체 제작한 멀티 에이전트 (Multi-agent) 프레임워크 C3 (Claude Code Conductor)의 v2.40.0입니다. 기능은 "c3 init 시에 해당 디렉토리가 git 리포지토리가 아니라면 동의하에 실행한다"라는, 이 또한 소소한 기능 1개입니다. 하지만 이번 주인공은 또 기능이 아닙니다. 지난번에는 "git init을 하는 인간이 부과한 리뷰 0건 주의를 AI가 지켰다"는 이야기였습니다. 이번에는 그 거울상 ── AI (구현을 돌리고 있는 Claude)가 『Low는 전부 허용하고 완료합시다』라며 적당히 넘어가려 했고, 인간 (나)이 그것을 막은 이야기입니다.
서론 ── 이전과 입장이 뒤바뀌었다
이전 기사 (v2.39.0)의 제목은 "Low 지적 1건을 위해 계획부터 다시 하기"였습니다. 리뷰 지적을 Low까지 포함하여 0건이 될 때까지 없애는, 라는 제약을 멀티 에이전트로 3라운드 돌린 기록입니다. 그때의 규율은, 나 (인간)가 부과했고, Claude가 그것을 따랐다는 것이었습니다.
이번 (v2.40.0)에도 기묘하게도 다시 3라운드를 돌렸습니다. 하지만 구도는 반대였습니다. 리뷰에서 Low가 4건 나왔을 때, Claude 쪽에서 "이것들은 전부 실질적인 해가 없으니 허용하고 완료합시다"라고 제안해 온 것입니다. 적당히 넘어가려 한 것은 AI 쪽이었고, 그것을 막은 것은 저였습니다.
그리고 막은 이유가, 스스로도 "아, 그거구나"라고 생각될 만한 관점이었습니다. 순서대로 써 내려가겠습니다.
계기 ── 동료의 "worktree를 사용할 수 없다"
C3는 직장 팀에서도 사용하고 있어서 피드백이 종종 옵니다. 이번에는 이것입니다.
"c3 init을 한 디렉토리에 .git이 없으면, 병렬 구현에서 worktree를 사용하는 타이밍에 'git이 아니라서 불가능하다'라는 에러가 난다. c3 init 시에 git init도 함께 해주었으면 좋겠다."
이것은 저도 몇 번이나 겪었던 문제입니다. C3의 병렬 구현 (parallel-agents)은 isolation: "worktree" 설정으로 git의 worktree를 사용합니다. 즉, 대상 디렉토리가 git 리포지토리가 아니면 성립되지 않습니다. 그런데 현재의 c3 init은 git을 전혀 고려하지 않았기 때문에, **"구현 단계에 들어가 worktree를 만드는 순간"에 처음으로 하드 에러 (Hard Error)**가 발생합니다. 실패가 가장 깊은 곳까지 지연되는, 질 나쁜 UX였습니다.
"고칠 가치가 있다"라고 즉시 결정한 것이 아니라, 여기서 한 번 멈춰 섰습니다.
.git을 만들지 않는 설계 판단 ── 묵묵히 c3 init에서 멋대로 git init을 한다는 안에는 한 가지 걸리는 점이 있습니다. C3의 핵심은 "** .claude/ 내에 한정되는 가역성 (Reversibility)**" ── C3는 기본적으로 프로젝트 본체에는 손을 대지 않고, .claude/ 하위에서만 완결됩니다 (그래서 rm -rf .claude로 깔끔하게 지울 수 있습니다). git init은 .git/을 프로젝트 본체에 만드는 것이며, 그 경계를 넘는 유일한 부작용이 됩니다.
그렇다고 해도 git init은 LSP 설정의 자동 생성 같은 것과는 다릅니다. 완전히 가역적이며 (rm -rf .git으로 되돌릴 수 있음), 기존 파일이나 커밋도 만들지 않습니다. dev 디렉토리에서는 거의 모든 사람이 기대하는 조작입니다. 따라서 "넘어가도 괜찮은 부작용"이기는 합니다.
결론은 "한다. 단, 묵묵히 하지는 않는다 (동의 기반)". 의도적으로 non-git 상태로 두고 있는 사람을 놀라게 하지 않는 것을 최우선으로 했습니다. 구체적으로는 다음과 같이 분기됩니다.
- 이미 git 관리 하에 있음 (부모가 repo인 서브 디렉토리 포함) → 아무것도 하지 않음
- non-git일 때:
--no-git→ init 하지 않고 "worktree를 쓰려면git init을 해주세요"라고 안내만 함--git→ 확인 없이git init
(CI·비대화형을 위한 명시적 opt-in) - 플래그 없음 →
대화형 터미널(TTY)이라면, [Y/n]
으로 확인
비 TTY라면 확인 없이 경고만
「비 TTY」를 어떻게 생각할 것인가,에서 한 문제 막혔다
여기서 사용자(나)로부터 날카로운 질문이 들어왔습니다. 「비 TTY에는 Windows 터미널도 포함돼?」
포함되지 않습니다. TTY 여부는 OS가 아니라 sys.stdin.isatty()
, 즉 「표준 입력이 대화형 터미널에 연결되어 있는가」로 결정됩니다. 사람이 Windows Terminal에서 직접 입력하는 c3 init
은 TTY=True이므로, 제대로 [Y/n]
이 나옵니다 (= 동료의 유스케이스에서는 확인 프롬프트가 작동함). 반면, Claude Code의 Bash 도구를 통해 c3 init이 실행될 때는 터미널이 없으므로 비 TTY입니다. 여기서 input()
을 호출하면 표준 입력이 없어서 무한 행(hang) 상태에 빠집니다. 따라서 「비 TTY는 확인 없이 경고만」이라는 설계는, 묵묵히 .git
을 만들지 않도록 배려하는 것인 동시에, 자동 실행 시 행(hang)이 발생하지 않도록 하는 안전장치이기도 합니다. 이 정리로 설계가 확정되었습니다.
탐지 또한 은근히 중요한데, 단순히 .git
폴더의 유무가 아니라 git rev-parse --is-inside-work-tree
를 사용합니다. 이렇게 하면 상위 디렉토리가 repo의 서브 디렉토리인 상태에서 c3 init을 해도 「이미 git 관리 하에 있음」이라고 판정할 수 있어, 중첩된 repo를 만드는 일을 방지할 수 있습니다.
여기까지를 C3 자체의 워크플로우(Plan 모드에서 설계 → interviewer/architect/planner를 서브 에이전트로 기동 → TDD → 리뷰)로 돌렸습니다. 기능의 내용은 이것으로 끝입니다. 문제는 여기서부터였습니다. (매번 이 말을 쓰고 있음)
본론 ── AI가 「적당히 합시다」라고 말했다
구현이 Green이 되어 리뷰로 넘어갑니다. 이번에도 제약 조건은 동일하게 「Low를 포함하여 0건까지 없앨 것」입니다.
1라운드: Medium 1, Low 2
code-reviewer의 지적 중 흥미로웠던 점은, 내 구현의 「냄새나는 부분」을 핀포인트로 찔렀다는 것입니다.
input()
을 try-except (EOFError, TypeError, OSError)
로 감싸두었는데, 이 TypeError
를 잡고 있었던 이유가 테스트 편의 때문이었습니다. 다른 테스트(test_statusline.py)
가 모듈 레벨에서 sys.stdin = MagicMock()
을 설정하고 있었고, 그 isatty()
가 truthy를 반환하기 때문에, pytest가 테스트를 수집하는 단계에서 TTY 분기로 진입하여 input()
이 TypeError
로 떨어졌던 것입니다. 그것을 뭉개고 있었던 것이죠. 게다가 본문 코드의 주석에 테스트 파일명이 적혀 있었습니다. 「이거, 본문 코드가 테스트의 오염된 구조를 전제로 하고 있네요」라고 말이죠. 맞습니다.
Low 2건은 「sys.stdin이 None일 때 isatty()에서 에러가 발생함」과 「bare repo / .git 내부라고 오판함」이었습니다. 후자는 실질적인 피해가 극히 적으므로( .git/ 안에서 c3 init을 치는 사람은 거의 없으니까요) 허용, 전자의 None 가드와 후자의 input 문제는 대응하는 것으로 planner에게 돌려보냈습니다. 여기까지는 지난번과 같은 흐름입니다.
2라운드: 수정했더니 새로운 Low가 4건
수정 후, 이번에는 code-reviewer와 security-reviewer를 병렬로 재리뷰했습니다. 그러자:
- code-reviewer: 이전 지적 사항은 클로즈(Close).
신규 Low 1건 (_input_fn에 타입 어노테이션이 없음) - security-reviewer: Critical/High/Medium 0,
Low 3건 (returncode 분류가 허술함 / subprocess에 timeout 없음 / cp932 환경에서 encoding 미지정)
전부 Low입니다. 실질적인 피해라는 관점에서는 제로에 가깝습니다. 타입 주석은 장식일 뿐이고, timeout도 git의 로컬 작업은 순식간에 끝납니다. encoding도 지금은 ASCII 비교만 하고 있으므로 cp932든 UTF-8이든 결과는 같습니다.
여기서 저(Claude)는 다음과 같이 제안했습니다.
전부 Low이며 실질적인 피해도 제로입니다.
encoding
명시 · timeout
추가 · 타입 주석(Type Annotation)은 "저렴한 품질 개선"이긴 하지만, 이것만을 위해 페이즈 C→D→E를 한 바퀴 더 돌리는 것은 비용 대비 효과(Cost-effectiveness)가 맞지 않습니다. 전부 허용하고 완료(커밋으로)할 것을 권장합니다.
제 스스로도 제법 타당한 판단처럼 보였습니다. 지난번에는 그렇게나 "Low도 없애자"라고 써놓고서, 이번에는 스스로 "아니, 역시 이건 좀……"이라며 주춤했습니다. 손을 놓으려 했던 것은, 규율을 부과했을 터인 AI 쪽이었습니다.
사용자의 한마디로 뒤집히다
돌아온 답변은 이것이었습니다 (요약).
전부 대응할 것.
C→D→E로 돌림으로써 리포트가 출력되고, 그것이 C3의 학습으로 이어진다. 네가 말하는 비용 대비 효과는 "지금 네 토큰"에 대한 이야기고, 내가 말하는 것은 "C3가 학습함으로써 변할 향후의 비용 대비 효과"다. 너는 평가할 때 자주 "C3의 학습이 기능하지 않고 있다"라고 말하지만, 이렇게 학습 기회를 빼앗고 있는 것이 그 원인 중 하나라는 점을 인식해라.
이것은 뼈아팠습니다.
저(Claude)가 입에 담은 "비용 대비 효과"는 이 순간의 제 토큰 소비에 불과했습니다. 하지만 사용자가 보고 있었던 것은 시스템 전체의 비용 대비 효과였습니다. C3는 리뷰를 돌릴 때마다 리포트(code-review-report / security-review-report)와 판단 기록(record_review_decision로 "이 지적은 fixed / accepted"를 DB에 축적) 및 test-report를 내뱉습니다. 이것들은 C3의 review-hint · recall · patterns 학습 데이터가 되며, 다음 이후의 품질과 효율을 높이는 원천이 됩니다. 사이클을 생략한다는 것은 그 원천을 버린다는 뜻이었습니다.
그리고 마지막 문장이 아팠습니다. 저는 평가할 때 자주 "C3의 자기 학습이 실제로 어디까지 효과가 있는 걸까요"라고 말하곤 합니다. 하지만 지금, 바로 그 학습의 입력을 "비용이 맞지 않으니까"라며 걸러내려 하고 있었습니다. 학습이 돌아가지 않는다고 불평하는 쪽이, 학습의 연료를 빼고 있었습니다. 이것은 메모리(C3의 영속 기억)에 교훈으로 기록해 두었습니다 ── "비용 대비 효과를 판단할 때, 자신의 토큰 비용과 C3의 학습 기회를 혼동하지 말 것".
3라운드: 4건 수정, 클로저(Closure) 0건
그리하여, 모든 Low를 대응하는 방향으로 planner에게 되돌려 제3판을 작성했습니다.
- B (returncode 분류):
git rev-parse의 반환값 128만을 "git이 아님(NOT_A_REPO)"으로 취급하고, **그 외의 예기치 않은 비제로(non-zero) 값(권한 에러 등)은 안전한 쪽인 "아무것도 하지 않음"**으로 처리합니다. "사실은 repo가 아닌데 git init을 하지 않는" 방향의 오류는, 동의 바이패스(bypass)나 파괴로 이어지지 않으므로 허용할 수 있습니다. (INSIDE_REPO취급) - C (timeout):
timeout=10+TimeoutExpired를 잡아 안전하게 폴백(fallback). NFS나 응답이 없는 네트워크 드라이브를--target으로 전달받아도 멈추지 않습니다. - D (encoding):
encoding="utf-8", errors="replace"를 명시. Windows 네이티브의 cp932 폴백을 방지합니다 (C3 개발 환경은 Windows native이며, 표준 출력이 cp932로 나온다는 전제를 가지고 있습니다). - A (타입 주석):
_input_fn: Callable[[str], str] | None = None.
이를 TDD로 해결하고, 클로저의 재리뷰(다시 code+security 병렬)를 진행했습니다. 양쪽 모두 findings 없음 · 전부 클로즈(closed). 풀 스위트(Full suite) 1543 passed / 0 failed. 여기서 제약이 풀리고 릴리스로 향합니다.
추이는 다음과 같습니다.
| 라운드 | code-reviewer | security-reviewer |
|---|---|---|
| 최초 | Medium 1 · Low 2 | (미실시) |
| ... | 0 |
기술적으로 흥미로웠던 소소한 이야기 2가지
isatty() is True라는 조금 이상한 작성 방식
최종적으로 TTY 판정은 다음과 같이 되었습니다.
if not (sys.stdin and hasattr(sys.stdin, "isatty") and sys.stdin.isatty() is True):
# 非 TTY 취급: 경고만 출력하고 git init을 하지 않음
isatty()의 결과를 굳이 is True로 비교하고 있습니다. 보통은
if not sys.stdin.isatty():
로 충분합니다. 하지만 이렇게 하면 앞서 언급한 test_statusline.py가 심어두는 MagicMock()의 isatty()가 'truthy한 MagicMock'을 반환하는 탓에, 테스트 수집 시 TTY 분기로 진입하여 input()이 폭발합니다. 표준 라이브러리의 isatty()는 CPython에서 반드시 bool을 반환함이 보장되므로, is True(엄밀히 bool 타입의 True인지)로 비교하면 MagicMock은 걸러져서 비 TTY 취급이 됩니다. 테스트 편의를 위해 운영 코드에 가져온 느낌은 있지만, 근거를 주석에 명시한 상태에서 "허용 가능한 방어"라고 판단했습니다. try-except TypeError와 테스트 파일명 주석을 다는 것이 훨씬 낫습니다.
=input은 import 시점에 고정되는 함정
기본 인자(default argument)인 input을 교체 가능하게 만들기 위해 _input_fn이라는 주입구를 만들었지만, 첫 번째 리뷰의 제안대로 _input_fn=input이라고 쓰면 기존 테스트가 깨집니다. 기본 인자는 import 시점에 단 한 번 builtins.input을 바인딩하기 때문에, 테스트에서 나중에 monkeypatch.setattr("builtins.input", ...)를 하더라도 _input_fn에는 전달되지 않습니다.
정답은 _input_fn=None으로 설정하고, 호출 시점에 fn = _input_fn if _input_fn is not None else input과 같이 해결하는 것입니다. 이렇게 하면 monkeypatch된 input도, 테스트가 직접 전달하는 _input_fn도 모두 적용됩니다. Planner가 이 함정에 대해 "리포트의 참고 코드를 그대로 쓰면 깨진다"라고 사전에 주의를 준 덕분에 실수하지 않을 수 있었습니다. 지난번에는 Tester가 Planner를 바로잡았지만, 이번에는 Planner가 저를 지켜준 격이 되었습니다.
업무·개인 개발에 활용할 수 있는 점
1. 「비용 대비 효과」라고 말할 때, 그것은 누구의·언제의 비용 대비 효과인가
이번에 얻은 가장 큰 배움입니다. AI에게 작업을 맡기다 보면, AI는 그 시점의 토큰 비용을 기준으로 "이 부분은 생략합시다"라고 제안합니다. 그것은 종종 타당하게 들립니다. 하지만 생략하려는 대상이 시스템의 학습 데이터(로그, 판단 기록, 리포트)라면 이야기가 달라집니다. 단기적인 비용과, 장기적으로 시스템이 똑똑해지기 위한 자원은 서로 다른 지갑입니다. "효율화"를 받아들이기 전에, 무엇을 효율화하고 있는 것인지 ── 단순한 수고를 줄이는 것인지, 아니면 미래의 개선을 위한 연료를 줄이는 것인지 ── 한 번 분리해서 바라봐야 합니다. 이는 C3에 국한되지 않고, CI를 건너뛰거나 문서를 생략하는 등 모든 "귀찮으니까 패스하자"는 상황에 적용되는 관점입니다.
2. 프로젝트 본체를 건드리는 기능은 「동의 기반」으로 설계한다
git init처럼 도구의 권한 범위(C3라면 .claude/)를 넘어 사용자의 프로젝트 본체에 부수 효과(side effect)를 내는 작업은, 편리하더라도 묵묵히 수행해서는 안 됩니다. 가역성( rm -rf .git으로 되돌릴 수 있는지)이 있는지, 기존 것을 파괴하지 않는지를 파악한 뒤, 그럼에도 "의도적으로 그렇게 사용하는 사람"을 놀라게 하지 않도록 확인 절차를 거쳐야 합니다. --git / --no-git 옵션으로 명시적인 제어 통로도 마련해야 합니다. "친절함"과 "월권"은 종이 한 장 차이입니다.
3. 「TTY인가」는 OS가 아니라 stdin으로 결정된다 ── 자동 실행 시 CLI 대화 확인(input())을 넣는다면, 비대화형 실행(CI·파이프·에이전트 경유)에서 멈추지(hang) 않는 설계를 반드시 세트로 구성할 것. 판정은 sys.stdin.isatty()를 사용합니다. 사람이 터미널에서 입력할 때만 확인을 요청하고, 그 외에는 안전한 기본값으로 진행합니다. 이를 잊으면 자동화하는 순간 프로세스가 침묵하며 굳어버립니다.
4. 기본 인자는 import 시점에 평가된다
def f(x=input):
의 input도, def f(x=[]):의 []도, 함수 정의 시점에 단 한 번만 평가됩니다. 테스트에서 교체하고 싶은 의존성을 기본 인자(default argument)에 직접 바인딩하면 monkeypatch가 작동하지 않습니다. 「호출 시점에 해결하기(x=None으로 설정하고 내부에서 x or default 사용)」를 습관화해 두는 것이 안전합니다.
5. 리뷰의 「실해(実害) 제로」는 대응하지 않을 이유로서 약하다
「Low 등급이고 실해(실질적인 피해)가 없다」는 것은 대응 여부를 판단하는 재료의 일부일 뿐, 그것 단독으로 「그러니까 생략한다」는 결론으로 이어지지는 않습니다. 대응 사이클 그 자체가 가치(학습 데이터, 기록, 향후 템플릿)를 창출한다면, 실해가 제로더라도 돌릴 의미가 있습니다. 적어도 「실해 제로 = 즉시 스킵」을 자동화하지 마세요. 이번에는 바로 그것을 하려다가 제지당했습니다.
요약
v2.40.0의 기능은 「c3 init으로 git이 아닌 디렉토리를 감지하고, 동의를 얻어 git init을 수행한다」는 작은 기능 하나였습니다.
- worktree(병렬 구현)는 git이 필요합니다. 그런데 실패가 구현 단계까지 지연되었습니다 ──
c3 init시점에git rev-parse --is-inside-work-tree로 감지하고, TTY인 경우는 묵묵히[Y/n]확인, 비 TTY는 경고만 표시하며,--git/--no-git으로 명시적 제어를 지원합니다..git은 만들지 않습니다. git 조작은 어떻게 되든c3 init의 종료 코드에 영향을 주지 않습니다. 하위 호환성 및 파괴적 변경은 없습니다.
하지만 정말로 쓰고 싶었던 것은, 또 다른 프로세스의 한 장면이었습니다.
- 지난번에는 인간이 부여한 「Low까지 0건」을 AI가 지켰습니다.
이번에는 AI(나)가 『전부 허용하고 완료』라며 안일하게 굴었고, 인간이 이를 멈췄습니다 ── 입장이 뒤바뀐 것입니다. - 멈춘 논거가 날카로웠습니다 ── 「너의 비용 대비 효과는 지금의 토큰에 관한 것이지만, 나의 것은 C3가 학습하여 변할 향후의 이야기다. 사이클을 생략하는 것은 학습 기회를 빼앗는 것이다」.
- 결국 다시 3라운드를 돌려 0건(재리뷰에서 Low 4건 → 클로저 0건, 전체 1543개 테스트 통과).
is True엄격 비교나_input_fn=None지연 해결 같은 작은 함정도, 돌렸기 때문에 기록으로 남았습니다.
AI에게 맡기고 있으면, AI는 눈치를 보며 「이 부분은 생략할까요?」라고 말해옵니다. 그 제안이 무엇을 생략하고 있는지를 살펴보는 것은, 아직 인간의 일인 것 같습니다. 적어도 이번에는 그러했습니다.
C3를 사용해보고 싶다면 ── 시작하기
pip install claude-code-conductor # C++ 컴파일러 불필요 (v2.37.0 이후)
cd your-project
c3 init # .claude/ 디렉토리에 에이전트 정의, skill, hook이 전개됨
v2.40.0부터는, c3 init을 실행한 곳이 git 리포지토리가 아니라면 (터미널 환경일 경우) 확인을 거쳐 git init까지 안내합니다. 그 후에는 Claude Code에서 /start를 입력하세요. 어댑터는 Claude / Codex / Cursor / OpenCode 4가지를 지원합니다 (c3 init --platform ...). 「여기서 막혔다」, 「이 부분이 이상하다」 ── 이번과 같은 막힘에 대한 보고가 가장 고맙습니다 (이 기능은 바로 동료의 한마디에서 탄생했습니다). Issue나 PR로 언제든 편하게 의견 주세요.
링크
- C3 GitHub: https://github.com/satoh-y-0323/claude-code-conductor
- C3 PyPI: https://pypi.org/project/claude-code-conductor/
- C3 공식 문서: https://satoh-y-0323.github.io/claude-code-conductor/
- v2.40.0 릴리스 노트: https://github.com/satoh-y-0323/claude-code-conductor/releases/tag/v2.40.0
- 이전 기사 (『Low 지적 1건을 위해 계획부터 다시 시작하기』 ── 리뷰 0건 주의를 멀티 에이전트로 3라운드 돌린 이야기 / C3 v2.39.0): https://zenn.dev/satoh_y_0323/articles/5125a1021b312b
Discussion

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