Claude Code 자동화를 어디까지 믿을 수 있을까 — PR 기록 훅을 만들며 적용한 Harness Engineering
Claude Code로 작업하면서 /obsidian-save라는 스킬을 사용하고 있다.
한 세션에서 어떤 작업을 했는지, 왜 그런 결정을 했는지, 어떤 문제가 있었는지를 Obsidian에 정리하는 스킬이다. 이렇게 저장한 기록은 나중에 다른 Claude Code 세션에서 다시 참고할 수 있다.
문제는 내가 이 스킬을 직접 실행해야 한다는 것이었다.
하나의 작업만 천천히 진행할 때는 별문제가 없었다. 그런데 여러 에이전트를 병렬로 사용하면서 에이전틱 코딩을 하다 보니 구현이 끝난 뒤 기록을 빼먹는 일이 종종 생겼다.
작업은 끝났고 PR도 만들었는데, 세션을 닫고 나서야 /obsidian-save를 실행하지 않았다는 걸 알아차리는 식이었다.
처음에는 단순히 잘 기억해서 실행하면 된다고 생각했다. 하지만 반복해서 놓친다면 기억의 문제가 아니라 구조의 문제에 가깝다고 봤다.
그래서 아예 사람이 기억하지 않아도 기록되는 시점을 만들기로 했다.
내가 선택한 시점은 gh pr create가 성공한 순간이었다.
왜 PR 생성을 기준으로 잡았나
자동 저장을 만들기 전에 먼저 언제 저장해야 하는지를 정해야 했다.
처음 떠올릴 수 있는 후보는 commit, push, PR 정도였다.
하지만 commit은 한 작업에서도 여러 번 만든다. 구현 중간에 작은 단위로 commit을 쌓기도 하고, 리뷰 반영 과정에서 추가 commit이 생기기도 한다.
push도 마찬가지였다. 원격에 백업하기 위해 중간 상태를 올릴 수도 있어서 작업이 끝났다는 신호로 사용하기에는 애매했다.
반면 내 작업 방식에서 PR은 대부분 하나의 작업 단위에 한 번 생성한다.
commit
commit
push
commit
push
↓
gh pr create
↓
하나의 작업이 어느 정도 정리된 시점
그래서 다음 규칙을 만들었다.
gh pr create가 성공했다면 이번 작업을 Obsidian에 기록한다.
PR 생성 자체가 작업 완료를 완벽하게 의미하는 것은 아니다. PR 이후에도 리뷰를 받고 수정할 수 있다.
그래도 내 개발 흐름에서는 commit이나 push보다 훨씬 강한 체크포인트였다.
블로그까지 자동으로 저장하지는 않았다
기존에는 /obsidian-save 외에 /blog-post라는 스킬도 사용하고 있었다.
처음에는 둘 다 PR 생성 이후 자동으로 실행할 수도 있겠다고 생각했다. 하지만 둘은 목적이 다르다고 판단했다.
Obsidian은 개인 기록이다.
작은 시행착오든, 나중에 쓸 일이 없을 것 같은 작업이든 기록이 남아 있는 편이 낫다. 여기에서는 선별보다 누락 방지가 중요했다.
반면 블로그는 외부 공개용이다.
무엇을 공개할지, 이 경험이 글로 남길 만한지, 어떤 관점으로 이야기할지는 내 주관적인 판단이 들어가야 한다.
그래서 역할을 나눴다.
Obsidian → PR 생성 후 자동 저장 → 누락하지 않는 것이 우선 Blog → 자동 실행하지 않음 → 블로그로 쓸 만한 세션이면 제안만 함 → 최종 판단은 사람이 함
이렇게 자동화할 범위부터 제한한 뒤 Claude Code Hook을 살펴보기 시작했다.
PostToolUse와 Stop을 연결했다
Claude Code의 Hook은 특정 생명주기 이벤트가 발생했을 때 미리 등록한 명령을 실행할 수 있는 기능이다.
이번 자동화에서 필요했던 이벤트는 두 개였다.
PostToolUse는 도구 호출이 성공한 뒤 발생하고, Stop은 Claude가 한 턴의 응답을 마치려고 할 때 발생한다. 현재 Claude Code 공식 Hooks Reference에서도 각각 이 시점으로 정의하고 있다.
처음 잡은 구조는 다음과 같았다.
flowchart TD
A["Claude가 Bash 실행"] --> B["gh pr create 성공"]
B --> C["PostToolUse"]
C --> D["PR 정보를 마커 파일에 기록"]
D --> E["Claude가 현재 턴을 마침"]
E --> F["Stop"]
F --> G{"처리할 PR 마커가 있는가?"}
G -- "없음" --> H["정상 종료"]
G -- "있음" --> I["Claude에게 /obsidian-save 실행 지시"]
I --> J["Obsidian 기록"]
PostToolUse에서 모든 작업을 처리하지 않은 이유도 있다.
PR이 만들어졌다는 사실을 감지하는 것과, Claude에게 전체 Obsidian 저장 작업을 수행하게 하는 것은 서로 다른 역할이라고 봤다.
그래서 PostToolUse는 사건만 기록한다.
PR이 생성됐다.
그 뒤 Stop이 기록을 읽고 Claude에게 다음 행동을 전달한다.
이번 턴에서 PR이 생성됐다. 응답을 끝내지 말고 /obsidian-save를 실행한다.
Claude Code의 additionalContext를 Stop에서 반환하면 이 내용이 Claude의 다음 모델 호출에 전달되고 대화가 계속된다. 이 동작 역시 현재 공식 문서에 명시되어 있다.
구조 자체는 여기까지 보면 단순했다.
문제는 이걸 ~/.claude/settings.json에 넣으려고 했을 때부터 시작됐다.
전역 설정에 들어가는 코드를 바로 믿기는 어려웠다
이번 Hook은 특정 프로젝트에만 필요한 기능이 아니었다.
어느 프로젝트에서 Claude Code를 사용하더라도 PR을 만들었다면 기록하고 싶었기 때문에 ~/.claude/settings.json에 등록하기로 했다.
Claude Code에서 이 위치의 설정은 해당 머신의 모든 프로젝트에 적용된다.
즉 잘못된 Hook 하나를 넣으면 특정 저장소 하나에서 끝나는 문제가 아니었다.
~/.claude/settings.json Project A ─┐ Project B ─┼─ 같은 Hook의 영향을 받음 Project C ─┤ Project D ─┘
처음 계획대로라면 몇 개의 JavaScript 파일을 만들고 settings.json에 등록하면 끝날 수도 있었다.
그런데 이 정도 영향 범위라면 AI가 만든 코드가 그럴듯해 보인다는 이유만으로 바로 적용하기에는 부담이 있었다.
그래서 이번 작업에서는 구현 코드뿐 아니라 그 코드를 만드는 과정 자체를 검증 대상으로 두기로 했다.
내가 이번 작업에서 Harness Engineering이라고 생각한 것도 이 부분이었다.
에이전트에게 좋은 프롬프트를 주고 코드를 많이 작성하게 하는 데서 끝나는 것이 아니라, 에이전트의 잘못된 판단이 실제 적용 전에 드러날 수 있도록 주변에 검증 장치를 만드는 방식이었다.
이번에는 대략 다음 순서로 진행했다.
flowchart LR
A["설계"] --> B["독립 설계 리뷰"]
B --> C["설계 수정"]
C --> D["구현 계획 작성"]
D --> E["독립 계획 리뷰"]
E --> F["TDD 구현"]
F --> G["전역 설정 적용"]
G --> H["E2E 검증"]
H --> I["실제 PR 검증"]
결과적으로 이 과정을 거치면서 처음 설계에서는 보이지 않던 문제가 여러 개 발견됐다.
그중 실제 구현 방향을 바꾼 몇 가지가 특히 기억에 남았다.
기존 설정을 그대로 따라 했으면 Hook이 동작하지 않을 뻔했다
처음에는 기존 settings.json의 스타일을 최대한 따라가는 것이 안전하다고 생각했다.
이미 등록되어 있는 Stop Hook 중에는 다음과 비슷한 설정이 여러 개 있었다.
{ "type": "command", "command": "...", "async": true, "timeout": 10 }
새 Hook도 기존 패턴대로 등록하는 계획을 세웠다.
그런데 설계 리뷰 과정에서 이 부분이 문제로 지적됐다.
async: true인 Hook은 백그라운드에서 실행된다. Claude는 해당 Hook이 끝날 때까지 기다리지 않고 이미 다음 동작으로 넘어간다.
현재 Claude Code 공식 문서도 async Hook에 대해 decision, permissionDecision, continue 같은 값을 사용해 Claude의 동작을 제어할 수 없다고 명시하고 있다. 제어해야 할 시점이 이미 지나갔기 때문이다.
내가 만들려는 Stop Hook은 정반대의 동작이 필요했다.
Stop 발생
↓
마커 발견
↓
"아직 끝내지 말고 Obsidian 저장을 실행해"
↓
Claude가 다시 작업
그런데 이 Hook에 async: true를 붙였다면 Claude의 Stop 흐름을 제어하는 용도로 사용할 수 없었다.
결국 새 Stop Hook에는 의도적으로 async를 넣지 않았다.
실제 등록 형태는 다음과 같다.
{ "matcher": "*", "hooks": [ { "type": "command", "command": "node \"~/.claude/scripts/hooks/run-with-flags.js\" \"stop:trigger-obsidian-save\" \"scripts/hooks/trigger-obsidian-save.js\" \"minimal,standard,strict\"" } ] }
기존 파일의 스타일을 따르는 것이 항상 안전한 것은 아니었다.
오히려 이번에는 기존 패턴을 그대로 복사하는 것이 자동화 전체를 무력화할 수 있었다.
기존 Hook에서는 존재하지 않는 필드를 읽고 있었다
Claude Code 설정을 조사하다가 기존에 이미 존재하던 PR 관련 Hook도 확인했다.
이 Hook 역시 gh pr create 이후 PR URL을 찾아 리뷰 안내 메시지를 보여주려는 용도였다.
그런데 코드를 읽어보니 Bash 실행 결과를 다음과 비슷한 경로에서 가져오고 있었다.
input.tool_output?.output
문제는 실제 PostToolUse 입력 구조였다.
Claude Code의 PostToolUse에서는 도구 입력이 tool_input, 실행 결과가 tool_response로 전달된다. 공식 Hook Reference의 PostToolUse 입력 예시에서도 같은 구조를 확인할 수 있다.
새 Hook에서는 실제 Bash 응답을 확인한 뒤 tool_response.stdout을 사용했다.
const input = JSON.parse(raw); const command = String(input.tool_input?.command || ''); if (/\bgh\s+pr\s+create\b/.test(command)) { const stdout = String(input.tool_response?.stdout || ''); const match = stdout.match( /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/ ); if (match && input.session_id) { const prUrl = match[0]; const repo = prUrl.replace( /https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1' ); marker.appendMarkerEntry( input.session_id, { prUrl, repo, cwd: String(input.cwd || ''), timestamp: new Date().toISOString(), }, marker.getBaseDir() ); } }
기존 Hook을 조사하지 않았다면 그냥 지나칠 수도 있던 문제였다.
특히 위험한 점은 이런 오류가 꼭 크게 실패하지는 않는다는 것이다.
Hook 자체는 등록되어 있고 프로세스도 정상 종료된다. 단지 읽으려는 값이 없어서 PR URL을 찾지 못할 뿐이다.
겉으로 보면 “Hook이 설치되어 있다”는 사실 때문에 오히려 정상적으로 동작한다고 착각하기 쉬웠다.
JSON 배열로 저장하려다 병렬 실행에서 문제가 생겼다
PR이 만들어졌다는 사실은 PostToolUse와 Stop 사이에 잠시 보존해야 했다.
처음 생각한 방식은 단순한 JSON 파일이었다.
[ { "prUrl": "https://github.com/example/example/pull/1" } ]
새 PR이 생기면 파일을 읽고 배열에 추가한 다음 다시 저장하면 된다.
read → JSON.parse → push → JSON.stringify → write
단일 프로세스만 생각하면 별문제가 없다.
하지만 Claude Code에서는 Bash 호출이 병렬로 실행될 수 있었다.
두 개의 gh pr create가 거의 동시에 끝났다고 가정하면 다음과 같은 상황이 가능하다.
Hook A: [] Hook B: [] Hook A: [PR-A] 저장 Hook B: [PR-B] 저장
두 프로세스가 같은 이전 상태를 읽은 뒤 각각 전체 파일을 덮어쓰면 마지막에 쓴 값만 남는다.
PR 하나가 사라지는 것이다.
그래서 JSON 배열 대신 NDJSON을 사용했다.
NDJSON(Newline Delimited JSON)은 하나의 큰 JSON 배열을 만드는 대신 한 줄에 JSON 객체 하나를 저장하는 형식이다.
{"prUrl":".../pull/1","timestamp":"..."}
{"prUrl":".../pull/2","timestamp":"..."}
{"prUrl":".../pull/3","timestamp":"..."}
실제 저장 코드도 단순해졌다.
function appendMarkerEntry(sessionId, entry, baseDir) { const filePath = markerFilePath(sessionId, baseDir); fs.appendFileSync( filePath, `${JSON.stringify(entry)}\n`, 'utf8' ); }
이 변경에서 내가 중요하게 본 것은 “NDJSON이면 모든 동시성 문제가 해결된다”는 주장이 아니었다.
기존 방식에 존재했던 애플리케이션 수준의 read → modify → 전체 파일 overwrite 과정을 제거했다는 점이었다.
각 Hook이 기존 배열 상태를 읽어서 다시 조립할 필요가 없어지면서 실제로 발견했던 덮어쓰기 경쟁 구간을 없앨 수 있었다.
Claude Code의 상태 대신 내 자동화의 상태를 직접 저장했다
중복 실행 문제도 있었다.
Stop Hook이 Obsidian 저장을 요청하면 Claude가 다시 작업을 시작한다. 그리고 그 작업이 끝나면 다시 Stop이 발생한다.
같은 PR을 계속 발견하면 다음과 같이 반복될 수 있다.
Stop → /obsidian-save 실행 요청 → 저장 완료 → Stop → 또 /obsidian-save 실행 요청 → ...
Claude Code의 Stop 입력에는 stop_hook_active라는 값이 있다.
처음에는 이 값을 이용해서 중복 실행을 막을 생각이었다.
하지만 이 값은 내 Hook이 실행됐는지 여부를 의미하지 않는다.
공식 문서에서는 Claude Code가 이미 어떤 Stop Hook의 결과로 대화를 계속하고 있을 때 true가 된다고 설명한다.
같은 세션에 다른 Stop Hook도 존재하는 환경이라면 문제가 생긴다.
다른 Stop Hook이 대화를 계속시킴
↓
stop_hook_active = true
↓
내 Hook: "내가 이미 처리했나?"
내 자동화가 만들지 않은 상태를 내 자동화의 상태처럼 해석하게 되는 것이다.
그래서 이미 사용자에게 전달한 PR URL을 별도 파일에 기록하기로 했다.
const emittedUrls = new Set( marker .readNdjsonEntries( marker.emittedFilePath(sessionId, baseDir) ) .map((entry) => entry.prUrl) ); const toEmit = fresh.filter( (entry) => !emittedUrls.has(entry.prUrl) );
Stop Hook의 전체 흐름은 다음과 비슷하다.
const ownEntries = marker.readNdjsonEntries(ownFilePath); marker.deleteFileSafe(ownFilePath); const fresh = ownEntries.filter( (entry) => !marker.isStale(entry.timestamp, now) ); const stale = ownEntries.filter( (entry) => marker.isStale(entry.timestamp, now) ); const emittedUrls = new Set( marker .readNdjsonEntries( marker.emittedFilePath(sessionId, baseDir) ) .map((entry) => entry.prUrl) ); const toEmit = fresh.filter( (entry) => !emittedUrls.has(entry.prUrl) ); if (toEmit.length > 0) { marker.appendEmittedUrls( sessionId, toEmit.map((entry) => entry.prUrl), baseDir ); output.hookSpecificOutput = { hookEventName: 'Stop', additionalContext: `${prList}\n\n지금 전체 /obsidian-save를 실행하세요...`, }; }
Claude Code가 제공하는 상태는 Claude Code의 상태로만 사용하고, 내 자동화가 이미 무엇을 처리했는지는 내가 직접 관리하는 쪽을 선택했다.
코드 리뷰만 한 게 아니라 구현 계획도 실행해봤다
설계를 수정한 다음 바로 구현하지 않았다.
어떤 파일을 추가할지, 테스트는 무엇을 작성할지, 마지막에 settings.json을 어떤 방법으로 검증할지까지 구현 계획을 먼저 작성했다.
그 뒤 이 계획을 서로 독립된 두 리뷰에 넣었다.
여기서 예상하지 못한 문제가 하나 발견됐다.
계획서에는 실제 설정 적용 이후 async 설정을 확인하기 위한 명령어가 들어 있었다.
grep -c '"async": true, "timeout": 10' ~/.claude/settings.json
처음 봤을 때는 그럴듯했다.
하지만 실제 settings.json은 pretty-print된 JSON이었다.
즉 실제 파일은 다음처럼 생겨 있었다.
{ "async": true, "timeout": 10 }
async와 timeout이 서로 다른 줄에 있으니 위 grep은 일치할 수 없다.
중요했던 점은 두 리뷰가 단순히 계획 문서를 읽고 의견만 준 것이 아니었다.
계획서에 적혀 있는 코드를 각각 실제로 실행했고, 서로 독립된 두 리뷰에서 정확히 같은 문제가 재현됐다.
결국 해당 검증은 문자열 검색이 아니라 JSON 구조를 읽어서 확인하는 방식으로 바꿨다.
이 사건 이후에는 계획에 테스트가 적혀 있다는 것과 그 테스트가 실제 검증을 한다는 것을 분리해서 보게 됐다.
테스트가 성공했는데 아무것도 검증하지 못한 경우도 있었다
비슷한 문제가 스모크 테스트에서도 나왔다.
Hook에 JSON을 전달하기 위해 계획에 다음과 비슷한 명령이 있었다.
echo '...\n...'
그런데 사용 중인 shell에서는 문자열 안의 \n이 실제 개행으로 처리됐다.
결과적으로 Hook에 들어가는 JSON 자체가 깨졌다.
보통이라면 테스트가 실패했어야 한다.
하지만 이번 Hook에는 또 다른 요구사항이 있었다.
자동 기록용 Hook 때문에 원래 Claude Code의 Bash 호출까지 실패하면 안 됐다.
그래서 잘못된 입력을 받더라도 예외를 삼키고 원래 흐름을 방해하지 않는 방어 로직을 넣어둔 상태였다.
그 결과는 이랬다.
깨진 JSON 전달
↓
JSON.parse 실패
↓
catch
↓
안전하게 정상 종료
↓
스모크 테스트 성공
테스트 결과만 보면 성공이었다.
하지만 내가 검증하고 싶었던 PR 감지 코드는 단 한 줄도 실행되지 않았다.
이 문제는 입력을 그대로 전달하도록 printf '%s'를 사용하면서 해결했다.
여기서 얻은 기준은 단순했다.
테스트가 초록색인가만 볼 것이 아니라, 내가 검증하려던 코드 경로를 실제로 지나갔는지도 확인해야 했다.
특히 실패에 강하게 만들어진 프로그램을 테스트할 때는 더 중요했다.
오류를 잘 처리하기 때문에 잘못된 테스트까지 성공처럼 보일 수 있기 때문이다.
준비한 개발 방식도 환경과 맞지 않아서 버렸다
구현 단계에서는 원래 다른 방법을 사용하려고 했다.
각 태스크를 에이전트에게 맡기고 다음 순서로 진행하는 방식이었다.
구현 → git commit → 해당 commit diff 리뷰 → 다음 태스크
평소 git 저장소 안의 프로젝트라면 잘 맞는 방식이었다.
그런데 이번 작업의 대상은 애플리케이션 저장소가 아니었다.
~/.claude
이 디렉터리 자체가 git 저장소가 아니었다.
즉 태스크마다 commit을 만들고 commit diff를 기준으로 리뷰하는 개발 방식의 전제부터 성립하지 않았다.
처음 세운 방법이니 억지로 유지하기보다는 바로 포기했다.
대신 같은 세션 안에서 직접 TDD로 진행했다.
테스트 작성
↓
실패 확인 - RED
↓
구현
↓
통과 확인 - GREEN
에이전틱 개발을 한다고 해서 항상 같은 에이전트 워크플로우를 유지할 필요는 없었다.
이번에는 구현 대상보다 개발 방법론이 환경에 맞는지 먼저 확인하는 것도 하네스의 일부였다.
총 21개의 테스트를 작성했다
최종 구현에서는 Node.js에 내장된 node:test를 사용했다.
기존 ~/.claude 스크립트 환경에 별도의 테스트 프레임워크가 없었기 때문에 이 작업을 위해 새로운 의존성을 추가하지 않는 쪽을 선택했다.
테스트는 네 영역으로 나눴다.
| 영역 | 테스트 수 |
|---|---|
| Marker 공용 라이브러리 | 6 |
| PostToolUse Hook | 3 |
| Stop Hook | 7 |
| settings.json Installer | 5 |
| 합계 | 21 |
Marker 라이브러리에서는 NDJSON append/read, 손상된 행 처리, TTL 판정 같은 동작을 확인했다.
PostToolUse에서는 정상적인 gh pr create, 관련 없는 Bash 명령, 잘못된 Hook 입력을 테스트했다.
Stop에서는 정상 통지, 이미 전달한 PR의 중복 방지, 만료된 마커 처리, 손상된 데이터 처리, 아무 작업도 없을 때 정상 종료하는 경우 등을 확인했다.
Installer에서는 특히 기존 settings.json을 훼손하지 않는 것을 중요하게 봤다.
모든 구현은 테스트를 먼저 실패시키고 실제 코드를 추가한 뒤 통과시키는 RED → GREEN 순서로 진행했다.
최종적으로 21개 테스트가 모두 통과했다.
전역 설정을 바꾸는 마지막 단계에서는 다시 확인했다
테스트가 끝나고 남은 작업은 실제 ~/.claude/settings.json에 Hook을 등록하는 것이었다.
전체 작업 계획에서는 이미 이 단계까지 자동으로 진행하도록 정해둔 상태였다.
그래도 실제 적용 직전에는 한 번 더 확인하고 진행했다.
이유는 전역 설정의 영향 범위였다.
잘못된 애플리케이션 코드라면 해당 프로젝트에서 수정하면 된다.
하지만 여기서 잘못 설정하면 이후 다른 프로젝트에서 시작하는 Claude Code 세션에도 같은 문제가 반복될 수 있다.
그래서 적용 스크립트도 그냥 JSON을 수정하고 저장하는 방식으로 만들지 않았다.
settings.json 읽기
↓
기존 JSON 파싱 확인
↓
백업 생성
↓
임시 파일에 수정본 작성
↓
수정본 JSON 파싱 재확인
↓
원본 교체
↓
기존 설정이 보존됐는지 deep-equal 검증
새 Hook 두 개가 추가된 것 외에 기존 Hook이나 다른 설정이 바뀌지 않았는지를 별도 검증까지 포함해 확인했다.
결과적으로 PostToolUse에는 새 항목 하나, Stop에도 새 항목 하나가 추가됐다.
마지막 E2E 검증이 오히려 구현의 한계를 보여줬다
구현과 설정 적용이 끝난 뒤 마지막으로 실제 Hook 실행기를 거치는 E2E 스모크 테스트를 진행했다.
이 검증 과정에서 예상하지 못한 일이 생겼다.
테스트 에이전트가 PostToolUse Hook을 확인하기 위해 가짜 Bash 입력을 만들었다.
그 테스트 명령 안에는 당연히 다음 문자열이 들어 있었다.
gh pr create
그리고 가짜 결과에는 테스트용 PR URL도 들어 있었다.
https://github.com/.../pull/99
문제는 그 시점에는 새로 만든 실제 Hook이 이미 활성화되어 있었다는 것이다.
Hook의 판단 조건은 대략 다음과 같았다.
명령 문자열에 "gh pr create"가 존재함 + stdout에서 GitHub PR URL을 찾음 = PR 생성으로 판단
그러다 보니 Hook을 테스트하기 위해 만든 가짜 입력을, 실제로 동작 중인 Hook이 진짜 PR 생성으로 감지했다.
결국 실제 마커 디렉터리에 존재하지 않는 pull/99가 기록됐다.
검증을 위해 만든 입력이 검증 대상 자체를 발화시킨 셈이었다.
가짜 마커는 바로 발견해서 삭제했다.
하지만 이 사건은 현재 정규식 기반 탐지 방식에 false positive가 존재할 수 있다는 것을 실제로 보여줬다.
예를 들어 이 자동화 자체를 테스트하거나 문서화하면서 명령 문자열 안에 gh pr create와 PR URL을 함께 넣으면 비슷한 상황이 생길 수 있다.
당시 구현에서는 이 문제를 후속 개선 대상으로 남겼다. 이 글에서는 당시 구현과 검증 과정을 기준으로 다루고 있으며, 이후 해당 매칭 로직을 개선했는지는 별도로 다루지 않는다.
오히려 마지막 검토에서 이 문제가 나온 것이 나쁘지만은 않았다.
“모든 테스트를 통과했으니 완성됐다”로 끝났다면 발견하지 못했을 실제 한계가 하나 더 생겼기 때문이다.
마지막에는 실제 PR까지 만들어봤다
처음 구현을 끝낸 시점에는 격리된 테스트 환경에서 Hook 동작을 확인한 상태였다.
이후 새 Claude Code 세션에서 실제 작업을 진행하고 gh pr create를 실행해 라이브 동작도 확인했다.
최종적으로 확인한 범위는 다음과 같았다.
21개 자동 테스트
↓
settings.json 적용 검증
↓
실제 Hook 실행기를 통한 E2E 스모크 테스트
↓
새 세션에서 실제 gh pr create
↓
Obsidian 저장 흐름 동작 확인
처음 원했던 동작도 여기서 확인할 수 있었다.
작업이 끝나고 PR을 만든 뒤 내가 /obsidian-save를 기억해서 직접 입력할 필요가 없어졌다.
이번 작업에서 Harness Engineering을 어떻게 적용했나
처음 이 작업을 생각했을 때는 꽤 작은 자동화라고 생각했다.
gh pr create를 찾는 Hook을 하나 만들고, PR이 만들어졌다면 /obsidian-save를 호출하게 하면 된다고 봤다.
실제로 추가된 코드의 규모만 보면 그렇게 큰 기능은 아니다.
그런데 만드는 과정에서는 예상보다 많은 문제가 나왔다.
기존 Hook은 잘못된 결과 필드를 읽고 있었다.
기존 Stop 설정 방식을 그대로 따라 했다면 async 때문에 원하는 제어를 하지 못할 수 있었다.
JSON 배열 방식은 병렬 실행에서 값을 덮어쓸 수 있었다.
Claude Code가 제공하는 stop_hook_active를 내 Hook의 실행 여부처럼 사용하려던 설계도 바꿔야 했다.
구현 코드가 아니라 계획서에 있던 grep 검증 명령에서도 실제로 동작하지 않는 부분이 나왔다.
심지어 스모크 테스트는 검증 대상 코드를 실행하지 않고 catch로 빠졌는데도 성공하고 있었다.
마지막 검증에서는 테스트 데이터가 실제 Hook을 발화시키면서 false positive까지 확인했다.
하나하나만 보면 특별히 복잡한 버그는 아니다.
문제는 에이전트가 만든 설계와 코드를 한 번 읽어보고 바로 적용했다면 이 중 상당수가 그대로 남을 수 있었다는 점이었다.
이번 작업에서 내가 적용한 Harness Engineering은 에이전트에게 더 긴 지시를 쓰는 것과는 조금 달랐다.
에이전트가 설계함
↓
다른 실행 맥락에서 검증함
↓
계획에 적힌 명령도 실제로 실행함
↓
실패하는 테스트를 먼저 만듦
↓
실제 환경에 적용하기 전에 격리해서 검증함
↓
영향 범위가 큰 변경은 마지막에 사람이 다시 판단함
↓
실제 환경에서 한 번 더 확인함
에이전트가 작성하는 코드의 양이 늘어나면 내가 직접 타이핑해야 하는 코드의 양은 줄어든다.
그렇다고 검증할 것이 같이 줄어드는 것은 아니었다.
오히려 여러 에이전트가 병렬로 작업하고 자동화의 범위가 커질수록 어떤 결과를 믿어도 되는지 판단하기 위한 구조가 더 필요했다.
이번에 만든 결과물 자체는 PR 생성 이후 Obsidian 기록을 빠뜨리지 않기 위한 작은 Hook 자동화였다.
하지만 실제로 더 오래 고민한 것은 Hook 코드보다 그 자동화를 어떤 과정을 거쳐 믿고 전역 환경에 넣을 것인가였다.
그리고 그 검증 과정에서 실제 설계가 여러 번 바뀌었다.
내가 이번 작업에서 경험한 Harness Engineering은 그런 작업에 더 가까웠다.
첫 댓글을 남겨보세요.