Claude Code 하네스를 직접 엔지니어링한 이야기 (2) — 무슨 고민을 했나
1편에서 제 하네스의 부품을 펼쳐 봤습니다. 그런데 부품을 나열하는 건 절반밖에 안 됩니다. 진짜 이야기는 "왜 하필 그 모양인가" 에 있습니다. 이 글은 각 부품 앞에서 제가 망설였던 트레이드오프를 되짚은 회고입니다. 거듭 적어 두지만 이건 회사 표준이 아니라 제가 혼자 꾸린 개인 프로젝트라, 정답을 안내하는 글이 아니라 한 사람이 자기 개인 환경에서 어느 쪽을 골랐고 무엇을 포기했는지를 적은 기록에 가깝습니다. 3부작 중 2편입니다.
1. 출발점은 "LLM이 흔히 하는 실수" 였습니다
하네스를 처음 손볼 때 저는 "어떻게 하면 더 똑똑하게 시킬까" 를 고민하지 않았습니다. 대신 AI가 코드를 짤 때 반복적으로 저지르는 실수가 뭐였지? 를 먼저 적어 봤습니다. 제가 겪은 건 대개 이 네 가지였습니다.
- 안 물어보고 추측으로 빈칸을 메운다.
- 시키지도 않은 추상화·설정·예외 처리를 얹는다.
- 고치라는 곳 옆의 멀쩡한 코드까지 건드린다.
- "동작하게 했다" 고 말하는데 검증 기준이 없다.
CLAUDE.md 의 행동 원칙 네 줄(Think Before / Simplicity / Surgical / Goal-Driven)은 사실 새로운 규칙이 아니라 이 네 가지 실수의 거울상입니다. 추측하지 마라 / 최소한만 짜라 / 외과적으로 바꿔라 / 검증 가능한 목표로 바꿔라.
여기서 첫 번째 판단이 있었습니다. 원칙을 더 길게, 더 많이 적을 수도 있었습니다. 하지만 항상 주입되는 문서가 길어지면 그 문서 자체가 노이즈가 됩니다. 그래서 "흔한 실수 1개 = 원칙 1줄" 로 묶고, 더 늘리지 않았습니다. 원칙은 헌법이고, 헌법은 짧아야 매번 읽힌다고 봤습니다.
2. 가장 큰 고민은 "추론을 어디까지 허용할까" 였습니다
LLM은 빈칸을 만나면 멈추지 않고 그럴듯하게 메웁니다. 그게 강점이자 사고의 진원지였습니다. 그래서 작업 전 플랜 스킬을 만들 때 핵심 원칙을 하나로 잡았습니다.
추론을 최소화하기 위해, 플랜의 모든 항목에 구체 예시를 박는다.
말로는 쉬운데, 이걸 강제하려니 "모호한 표현" 의 목록을 직접 만들어야 했습니다. 플랜에 이런 말이 남아 있으면 그 플랜은 미완성으로 칩니다.
| 모호한 표현 | 구체화한 표현 |
|---|---|
| "유효성 검사 추가" | "email 에 @ 없으면 400. 예: 'foo'→400, 'a@b'→200" |
| "예외 처리" | "DB 락 충돌 시 3회 재시도(100ms backoff), 실패하면 특정 예외" |
| "기존 패턴대로" | "UserService.create() 패턴 참고 — 검증은 Service, Repository는 순수 영속화" |
| "리팩터링" | "변경 전후 같은 테스트 통과. 외부 동작 동일성이 기준" |
TBD, TODO, "엣지 케이스 처리", "Task N 과 동일" 같은 회피성 표현도 금지 목록에 넣었습니다. 여기서 트레이드오프가 분명했습니다. 플랜을 쓰는 데 시간이 더 듭니다. 한 줄로 끝낼 걸 예시까지 박아야 하니까요. 하지만 저는 "플랜에 모호한 한 줄이 남으면, 구현 단계에서 모델이 그 한 줄을 추측으로 메운다" 는 쪽에 더 무게를 뒀습니다. 앞에서 1분 쓰는 게, 뒤에서 잘못 짠 코드를 되돌리는 것보다 싸다 — 이게 제가 고른 답이었습니다.
3. 스킬을 하나로 둘까, 쪼갤까
처음엔 "작업 워크플로" 라는 큰 스킬 하나에 계획부터 검수까지 다 넣을 생각이었습니다. 그게 편해 보였습니다. 그런데 막상 적다 보니 한 스킬이 "작업 전에 계획하고" + "작업 후에 테스트를 검수하고" 두 가지를 책임지고 있었습니다.
여기서 제 스킬 작성 규칙이 생겼습니다.
스킬 하나의 책임을 한 문장으로 적었을 때, 그 문장에 "그리고(and)" 가 들어가면 스킬을 둘로 쪼갠다.
그래서 planning-before-work 는 작업 전 계획만, verifying-test-coverage 는 구현 후 검수만 책임지도록 분리했습니다. 호출 시점이 다르고(작업 전 vs 작업 후), 한쪽이 실패해도 다른 쪽은 멀쩡해야 하니까요.
쪼개니까 비용도 생겼습니다. 스킬 사이의 경계와 호출 순서를 따로 적어 둬야 합니다. 한 덩어리였으면 필요 없었을 일입니다. 하지만 한 스킬이 두 가지를 하면, 한 가지를 고칠 때 다른 한 가지가 같이 흔들립니다. 단일 책임이 길게 보면 유지비가 싸다고 봤고, 이건 평소 코드를 짤 때의 감각을 그대로 하네스에 옮긴 것이기도 했습니다.
4. 게이트는 늘리고 싶고, 사용자 허들은 줄이고 싶었습니다
이게 2편에서 제일 오래 망설인 부분입니다. 저는 검증 단계를 많이 두고 싶었습니다. 계획도 검토받고, 코드도 검토받고 싶었습니다. 그런데 검증 단계마다 저한테 "이대로 진행할까요?" 를 물으면, 작업 한 건에 확인 버튼이 대여섯 번 뜹니다. 그러면 저는 제가 만든 하네스를 귀찮아서 안 쓰게 됩니다.
그래서 검증(verification)과 사용자 허들(approval)을 분리했습니다.
검증 단계 : 많아도 된다 (플랜 셀프체크, 외부 모델 핑퐁, 테스트 검수 …)
사용자 허들 : 한 국면에 정확히 한 번구체적으로 작업 전 국면에서는 "다듬어진 최종 플랜을 보여 주고 승인받는" 딱 한 곳만 사람이 멈춥니다. 그 앞의 외부 모델 교차검토는 라운드를 돌며 플랜을 자동으로 다듬되, 라운드 사이에 저한테 일일이 안 묻습니다.
여기엔 솔직히 제 원칙과의 충돌이 있었습니다. CLAUDE.md 는 "코드/설정을 바꾸기 전엔 승인받아라" 라고 말합니다. 그런데 핑퐁 게이트는 라운드 사이 수정을 승인 없이 자동 적용합니다. 저는 이걸 숨기지 않고 "승인 게이트의 명시적 예외" 라고 못 박아 뒀습니다. 예외를 인정하되, 어디까지가 예외인지 표로 적어 두고 그 표 밖으로는 자동화를 못 넘어가게 한 겁니다.
자동화의 위험은 자동화 자체가 아니라, 예외가 슬그머니 넓어지는 것이라고 봤습니다. 그래서 예외를 없애는 대신, 예외를 명시하고 가뒀습니다.
5. 왜 외부 모델한테 교차검증을 시켰나
제가 짠 변경을 제가 검토하면, 같은 머리가 같은 맹점을 두 번 지나갑니다. 코드 리뷰를 남한테 받는 이유와 똑같습니다. 그래서 게이트의 책임을 딱 하나로 못 박았습니다 — "외부 모델 한 명과의 독립 교차검토".
설계할 때 신경 쓴 건 세 가지였습니다.
- 편향 줄이기: 제 쪽(모델 자체 평가)은 외부 모델의 응답을 보기 전에 먼저 작성합니다. 남의 답을 보고 나서 내 의견을 쓰면 휩쓸리니까요.
- 출력 강제: 외부 모델한테 자유 서술 대신
[위험] / [경고] / [안전]세 라벨의 한 줄 포맷만 쓰게 했습니다. 총평·인사말 금지. 그래야 라운드를 기계적으로 돌릴 수 있습니다. - 종료 조건: 무한 핑퐁은 막아야 하니 최대 3라운드, 그 안에 합의가 안 되면 사람한테 결정을 넘깁니다.
외부 모델은 --sandbox read-only 로 띄웁니다. 검토자가 파일을 고치면 "독립 검토" 라는 전제가 깨지기 때문입니다. 검토자는 검토만, 수정은 제 쪽이 — 이 경계는 타협하지 않았습니다.
6. 무엇을 막고, 무엇을 그냥 자동화할까
권한을 deny / ask / allow 세 단계로 나눌 때 기준은 단순했습니다.
되돌리기 어려운가? → YES → 막거나(deny) 매번 물어본다(ask)
→ NO → 자동으로 통과시킨다(allow)git commit, git reset --hard, git push --force, rm -rf, DB 콘솔 직접 접속 — 전부 한 번 하면 주워 담기 힘듭니다. 그래서 deny. 반면 라운드 사이 코드 수정처럼 git으로 언제든 되돌릴 수 있는 건 자동화했습니다. 비가역이면 막고, 가역이면 자동화한다. 4절의 자동 적용 예외도 결국 이 한 줄에서 나왔습니다.
force-push는 한 겹 더 신경 썼습니다. deny 규칙은 명령어 문자열을 매칭하는데, refspec 없이 git push -f 만 친 경우엔 보호 브랜치 이름이 명령어에 안 보입니다. 그래서 PreToolUse 훅을 한 겹 더 깔아서, 명령에 브랜치명이 없으면 현재 브랜치를 직접 확인해 보호 브랜치면 막게 했습니다.
한 가지 사고를 한 겹으로만 막으면, 그 한 겹의 빈틈이 곧 사고입니다. 비가역 사고일수록 방어를 겹쳐 두는 게 과하지 않다고 봤습니다.
7. 컨텍스트는 사라진다는 전제
LLM 세션은 영원하지 않습니다. 길어지면 컨텍스트가 압축되고, 종료되면 그 안의 기억이 흩어집니다. 이걸 "어쩔 수 없는 일" 로 두지 않고 하네스가 책임질 일로 옮긴 게 핸드오프입니다.
고민은 "언제 남길까" 였습니다. 한때는 SessionEnd 와 PreCompact(압축 직전) 두 사건에 훅을 걸어 자동으로 남기게 했습니다. 지금은 그 훅은 빼고, 세션을 정리할 때 writing-handoff-doc 스킬을 불러 남기는 쪽으로 옮겼습니다. 훅이든 스킬이든 원칙은 하나였습니다 — 다음 세션이 "왜 이걸 하고 있었지?" 를 묻지 않게, 목표·결정·진행·다음 액션·관련 파일을 5섹션 요약으로 남긴다.
대신 비용을 줄이려는 감각은 유지했습니다. 메시지 몇 개 안 오간 사소한 세션까지 매번 장문 요약을 만들면 낭비라, 남길 값어치가 있을 때만 남깁니다. 자동화든 스킬이든, 매번 비싸지 않게 거는 게 이 절의 핵심이었습니다.
8. 고민은 한 번에 끝나지 않았습니다
마지막으로, 이 글이 "처음부터 잘 설계했다" 처럼 읽히면 정직하지 않습니다. 부품 대부분은 한 번 만들고 끝난 게 아니라, 실제로 부딪힌 뒤 고친 것들입니다. 흔적 세 가지만 적어 둡니다.
- 외부 모델 추론 강도를 한 단계 낮췄습니다. 처음엔 가장 높은 추론 강도로 핑퐁을 돌렸는데, 빠른 처리 큐와 같이 쓰니 응답이 멈추는 경우가 있었습니다. 실측해 보고 한 단계 아래로 내렸습니다. "제일 센 설정 = 제일 좋은 설정" 이 아니었습니다.
- force-push 차단 훅에 케이스를 하나 더 붙였습니다. 처음 버전은 명령어에 브랜치명이 적힌 경우만 막았습니다. refspec 없이
-f만 친 사각지대를 뒤늦게 발견하고 6절의 두 번째 케이스를 추가했습니다. - 핸드오프를 훅으로 돌리던 시절엔 재진입 가드가 필요했습니다. 핸드오프 요약을 만들려고 훅이 내부에서 또 다른 세션을 띄우는데, 그 세션이 끝나면서
SessionEnd가 다시 울려 훅이 자기 자신을 무한 호출하는 버그가 있었습니다. 환경 변수 플래그 한 줄로 막았고, 지금은 아예 훅 대신 스킬로 옮겨 이 문제 자체가 사라졌습니다.
세 가지 다, 만들 때는 안 보였고 써 보고 나서야 보였습니다. 하네스 엔지니어링이 코드 엔지니어링과 똑같은 점이 여기 있었습니다 — 한 번에 맞추는 게 아니라, 부딪힌 자리를 한 줄씩 고쳐 가는 일이었습니다.
마무리
2편을 쓰면서 정리된 한 줄은 이것이었습니다.
좋은 하네스는 "무엇을 넣었나" 가 아니라 "무엇을 일부러 안 넣었나, 어디서 멈췄나" 로 결정된다.
원칙을 더 길게 쓸 수도 있었지만 네 줄에서 멈췄고, 검증은 늘리되 사용자 허들은 한 곳으로 줄였고, 자동화는 하되 비가역 작업 앞에서는 멈췄습니다. 매번 "더 넣을까" 와 "여기서 멈출까" 사이에서 후자를 고른 자리들이, 지금 하네스의 실제 모양을 만들었습니다.
다음 편에서는 이 원칙과 부품들이 실제 작업 한 건에서 어떤 순서로 작동하는지, 절차를 처음부터 끝까지 따라가 보겠습니다.