잘못된 추상화를 배포했고, 결국 삭제했습니다
요약
git-prism 개발자가 Claude Code 훅 방식의 한계를 극복하기 위해 PATH shim 방식으로 아키텍처를 변경한 과정을 다룹니다. 에이전트가 git 데이터를 더 효율적으로 이해할 수 있도록 구조화된 데이터를 제공하는 MCP 서버의 구현 원리를 설명합니다.
핵심 포인트
- Claude Code 훅 방식은 셸 호출 시 git 명령어를 가로채지 못하는 한계가 있음
- PATH shim 방식을 도입하여 프로세스 계층에서 git 호출을 확실히 가로챔
- git-prism은 에이전트에게 구조화된 JSON 데이터를 제공하여 토큰 효율성을 높임
- MCP 서버로서 에이전트가 변경 사항의 의미를 직접 파악하도록 지원
git-prism의 첫 번째 git-interception (git 가로채기) 메커니즘은 Claude Code hook 이었습니다. 그것은 잘 작동했습니다. 하지만 제가 한 에이전트(agent)가 make review를 실행하는 것을 지켜보기 전까지는 말이죠. Makefile이 git diff를 셸(shell)에서 호출했고, hook은 전혀 실행되지 않았습니다. 제가 배포했던 가로채기 방식은 정작 가장 잡아내야 했던 사례 중 하나에 대해 보이지 않는 존재였습니다.
v0.9.0에서 이 문제를 해결했습니다. hook은 제거되었고, 프로세스 계층(process layer)에서 git을 가로채는 PATH shim (shim)으로 교체되었습니다. 이것은 왜 hook이 여기서 승리할 수 없는지, spike (스파이크)를 통해 무엇을 증명했는지, 그리고 작동 중인, 이미 배포된 코드를 삭제할 수 있는 확신을 무엇이 주었는지에 대한 이야기입니다.
git-prism이란 무엇인가 (한 단락 요약)
git-prism은 AI 코딩 에이전트에게 인간 중심적인 porcelain (포슬린/고수준 명령어) 대신 구조화된 git 데이터를 제공하는 MCP server 입니다. 에이전트가 unified diff (통합 diff)를 읽을 때, 의미론적(semantic) 의미가 없는 @@ hunk headers, +/- 라인 접두사, 그리고 공백 컨텍스트(whitespace context)에 토큰을 소비하게 됩니다. 그런 다음 에이전트는 가공되지 않은 텍스트로부터 실제로 무엇이 변했는지 (어떤 함수가, 어떤 임포트가, 파일이 생성된 것인지 여부 등)를 재구성해야 합니다. git-prism은 그 구조를 직접 전달합니다. 다섯 가지 MCP 도구가 이를 수행합니다: get_change_manifest (무엇이 변했는가), get_file_snapshots (변경 전/후 콘텐츠), get_commit_history (커밋별 매니페스트), get_function_context (호출자, 피호출자, 테스트 참조), 그리고 review_change (git diff <ref>..<ref>를 대체하기 위해 구축된, 매니페스트와 컨텍스트를 한 번의 호출로 제공하는 도구).
다음은 git-prism 자체의 이력 중 실제 변경 사항에서 나타나는 차이점입니다: shim이 127(찾을 수 없음) 대신 126(찾았으나 실행 불가능) 종료 코드(exit code)를 반환하도록 수정한 내용입니다. 에이전트가 git diff를 통해 받는 porcelain은 다음과 같습니다:
@@ -67,8 +67,8 @@ impl<E: EnvSource> RealGitExec for StdRealGitExec<'_, E> {
- eprintln!("git-prism shim: failed: {err}");
- ExitCode::from(127)
...
get_change_manifest 페이로드(payload)로 전달되는 동일한 변경 사항은 다음과 같습니다 (하나의 소스 파일로 축소됨):
{
"path": "src/shim/real_git.rs",
"language": "rust",
...
에이전트는 어떤 함수가 이동했는지 재구성하지 않습니다. 대신 그 사실을 전달받습니다. 그것이 제품의 핵심입니다. 따라서 이번 릴리스를 이끄는 질문은 다음과 같습니다: 어떻게 하면 에이전트의 git 호출이 실제로 git-prism에 도달하도록 보장할 수 있는가?
첫 번째 추상화: 리다이렉트 훅 (redirect hook)
원래의 해답은 Claude Code의 PreToolUse 훅(ADR-0008)이었습니다. 에이전트가 Bash 명령을 내리면, 훅은 실행 전에 명령 문자열을 검사합니다. 만약 git diff main..HEAD를 발견하면, 해당 호출을 git-prism의 구조화된 출력(structured output)을 통해 라우팅되도록 다시 작성합니다.
에이전트가 직접 입력하는 명령의 경우, 이 방식은 잘 작동합니다. 또한 이는 심(shim)이 따라올 수 없는 속성을 가지고 있습니다. 바로 에이전트의 의도(intent), 즉 전체 대화 문맥이 포함된 문자 그대로의 텍스트를 볼 수 있다는 점입니다. 덕분에 대화 도중에 조언을 하거나 부드러운 경고를 보낼 수 있습니다. 이는 진정한 역량이며, 제가 이 방식을 버릴 것이라고 예상하지 못했던 이유이기도 합니다.
사각지대
PreToolUse 훅은 최상위 Bash 명령 문자열에서 실행됩니다. 이는 시스템 호출 인터셉터(syscall interceptor)가 아닙니다. 서브프로세스(subprocess) 내부에서 실행되는 모든 git 호출은 이 이벤트를 절대 트리거하지 않습니다:
make review: Makefile 타겟이git diff를 실행하는 경우- 내부적으로
git을 실행하는 pre-push 훅 (lefthook,husky등) cargo빌드 스크립트, 테스트 하네스(test harness), 또는 에이전트가 호출하는 모든 래퍼(wrapper)
훅은 make review를 봅니다. 하지만 그 아래 세 단계 밑에서 일어나는 git diff는 결코 보지 못합니다. 그러한 호출들은 실제 git에 아무런 수정 없이 도달하며, 에이전트에게 git-prism이 대체하려고 존재했던 바로 그 'porcelain' git의 결과물을 그대로 전달하게 됩니다.
어떤 훅 패치로도 이를 해결할 수 없습니다. 이것은 버그의 깊이 문제가 아니라, 훅이 작동하는 계층(layer)의 문제입니다. 가로채기(interception)가 명령 문자열(string) 단계에서 일어나는 한, 에이전트가 직접 타이핑하지 않은 프로세스를 통해 git에 도달하는 모든 것은 보이지 않는 상태로 남습니다.
피벗(Pivot): PATH 심(shim)
해결책은 명령 문자열 대신 프로세스 계층에서 한 단계 아래를 가로채는 것입니다(ADR-0009).
git이라는 이름의 바이너리를 실제 git보다 앞선 PATH에 배치합니다. 이제 해당 프로세스 트리의 모든 git 호출은 중첩된 호출을 포함하여 PATH를 상속받기 때문에 가장 먼저 git-prism으로 해결됩니다. 그러면 git-prism은 호출마다 다음과 같이 결정합니다:
- 가로채기 (Intercept): AI 에이전트가 감지되고, 서브커맨드(subcommand)가 감시 목록(
diff/log/show/blame/pickaxe)에 있으며, 참조 범위(ref range, 예:main..HEAD)를 포함하고 있는 경우. 이 경우 구조화된 JSON을 반환합니다. - 통과 (Pass through): 그 외의 경우. 사람, CI, 비-에이전트, 단순한
git status,git diff --staged등은 모두 변경 없이 순정 git(vanilla git)으로 연결됩니다.
중요했던 몇 가지 설계 선택 사항은 다음과 같습니다:
- 단일 바이너리,
argv[0]디스패치 (dispatch). 심(shim) 자체가 곧git-prism입니다. 시작 시main함수는 호출된 이름이git으로 끝나는지 확인합니다. 만약 그렇다면 심 모드(shim mode)로 진입하고, 그렇지 않으면 일반 CLI를 실행합니다. 하나의 빌드, 하나의 릴리스 아티팩트(release artifact)를 통해 심(shim)과 재사용되는 JSON 경로 간의 버전 불일치(version skew)를 방지합니다. 이는 전형적인 유닉스 멀티 콜(multi-call) 트릭(busybox, coreutils의[등)입니다. - 프로세스 간 루프 차단 (A cross-process loop break). 심이 실제 git으로 제어권을 넘길 때, 그 git 자체가 셸 명령을 실행할 수 있으며(훅(hooks), 빌드 스크립트 등), 이러한 중첩된 호출은 여전히
PATH의 첫 번째 위치에 있기 때문에 다시 심으로 재진입하게 됩니다. 프로세스 내부의 카운터로는 프로세스 경계를 넘어서 볼 수 없으며, 오직 상속된 환경 변수만이 가능합니다. 따라서 심은 생성하는 모든 자식 프로세스에GIT_PRISM_INSIDE_SHIM=1을 설정하며, 진입 시 이 플래그가 확인되면 즉시 통과시킵니다. 보너스로,GIT_PRISM_INSIDE_SHIM=1 git …은 한 번의 명령에 대해 순정 git을 강제하는 사용자용 탈출구(escape hatch) 역할을 합니다. - 기존 분류기(classifier)의 정확한 포팅 (An exact port). 감시 목록(watch-list)과 참조 범위(ref-range) 로직은 훅(hook)의 Python 분류기를
src/shim/classify.rs로 충실하게 포팅한 것입니다(동일한 감시 목록, 동일한 참조 범위 감지). 따라서 두 가로채기 지점(interception points)이 무엇을 가로챌 수 있는지에 대해 서로 의견이 다를 수 없습니다.
이 shim(심)은 git 너머로도 확장됩니다. 실제 gh 앞에 gh 심볼릭 링크(symlink)를 두면, shim은 argv[0] == "gh"를 인식하고 gh pr diff <number>를 동일한 manifest 파이프라인을 통해 라우팅합니다. 이 과정에서 gh pr view를 사용하여 PR의 base..head를 해결(resolve)하고 동일한 JSON을 반환합니다. 그 외의 모든 gh 서브커맨드(subcommand)는 그대로 통과합니다. 에이전트가 접근하는 모든 채널에 대해 하나의 가로채기 계층(interception layer)을 두는 것입니다.
어려운 부분: 고정된 PATH
계획 전체를 거의 무너뜨릴 뻔한 하나의 결정적인 미지수가 있었습니다. 바로 shim의 PATH 엔트리가 Claude Code가 각 Bash 명령을 위해 생성하는 서브쉘(subshell)에 실제로 도달하는가? 하는 점이었습니다. 만약 Claude Code가 rc 파일로 수정된 PATH를 인식하지 못하는 새로운 쉘에서 명령을 실행한다면, shim은 결코 해결(resolve)되지 않을 것이고 전체 접근 방식은 실패하게 됩니다.
저는 조사 대상인 정확한 프로세스(ADR-0010) 내에서, 실행 중인 Claude Code 세션으로부터 이를 테스트했습니다. 확인한 결과는 다음과 같습니다:
- 각 Bash 도구 호출은
/bin/zsh -c로 실행되며, 명령은 먼저 **쉘 스냅샷(shell snapshot)**을 소싱(source)하도록 래핑(wrap)됩니다. - 해당 스냅샷은
claude실행 시마다 한 번 생성되며, **PATH를 단일하게 해결된 문자열로 하드코딩(hardcode)**합니다. 명령마다 rc 파일을 다시 평가하지 않습니다. - 스냅샷은 rc 파일에 정의된 별칭(alias)과 함수(function)도 캡처합니다. 이는 실행 쉘이 rc 파일을 불러오지 않았더라도, 스냅샷이 생성될 때 rc 파일을 소싱한다는 것을 증명합니다.
따라서 Bash 도구 내부의 PATH는 claude가 실행될 당시의 rc 파일로부터 유도된 PATH이며, 세션별 스냅샷으로 고정된 상태입니다.
그 후, 실제 git 앞에 가짜 git을 두고 다섯 가지 방식으로 테스트했습니다:
| 호출 방식 | 가로채기 성공 여부? |
|---|---|
직접 git status 실행 | 예 |
| ... |
두 번째부터 네 번째 행은 리다이렉트 후크(redirect hook)가 구조적으로 인지하지 못하는 호출들입니다. shim은 이들 모두를 잡아냅니다. 이 표는 "shim ⊇ hook"이라는 주장에 대한 실증적인 증거입니다.
또한 단 하나의 제약 사항을 정의합니다. 스냅샷은 실행 시점에 고정되므로, shim을 설치한 후 Claude Code를 재시작해야 합니다. 실행 후의 PATH 변경은 현재 세션에서 보이지 않습니다. 이는 일회성 단계이며 지속적인 경합(race) 상황은 아니지만, 명확하게 인지시켜야 합니다. 이것이 바로 git-prism shim install이 사용자의 rc 파일에 export PATH 라인을 추가하도록 요청하고, Claude Code를 재시작하라고 명시적으로 안내하는 이유입니다.
내 기능 삭제하기
스파이크(spike)를 통해 올바르게 구성된 PATH 상에서 shim이 hook의 엄격한 상위 집합(strict superset)임을 증명하자, hook의 상태가 변했습니다. hook은 더 이상 shim이 다루지 못하는 것을 다루지 않았으며, 오히려 더 적은 범위를 다루게 되었습니다. 이를 유지한다는 것은 중복되는 두 개의 가로채기 메커니즘(interception mechanisms), 동기화해야 할 두 개의 분류기(classifiers), 그리고 문서화하고 추론해야 할 두 가지 요소를 배포한다는 의미였습니다.
그래서 v0.9.0에서 이를 제거했습니다 (ADR-0011). 이는 '기능은 작동하지만 권장되지 않는(deprecated-but-functional)' 상태가 아니라, 완전히 삭제된 것입니다. Python hook 스크립트는 저장소에서 사라졌고, 임베드 코드는 src/hooks.rs에서 제거되었으며, git-prism hooks install은 이제 shim을 안내하는 메시지와 함께 0이 아닌 종료 코드(non-zero exit code)를 반환하며 종료됩니다. 해당 커밋으로 3,883줄이 삭제되었습니다.
이미 배포된 기능을 삭제하는 것은 무모하게 느껴져야 하는 부분입니다. 하지만 저는 그렇게 느끼지 않았고, 그 이유는 담력이 세서가 아닙니다.
주저 없이 삭제 버튼을 누를 수 있었던 이유
저는 1인 개발자입니다. 저를 잡아줄 팀도 없고, hook이 왜 존재했는지 기억해 줄 제2의 리뷰어도 없습니다. 저와 '확신에 차 있지만 잘못된 삭제' 사이를 가로막는 유일한 것은 제가 스스로에게 적용하는 프로세스뿐입니다. 이 에픽(epic)에서 그 프로세스가 바로 안전망이었습니다:
-
프로덕션 코드를 작성하기 전, Spike → ADR 단계 거치기. 미지의 영역은 오직 ADR(Architecture Decision Record)만을 결과물로 내놓는, 일회성 스파이크(spike) 브랜치에서 다루었습니다. 여기에는 TDD(Test-Driven Development)도, 애착을 가질 만한 코드도 없었습니다. ADR-0008, 0009, 0010은 제가 구현체에 손을 대기 전, 해당 훅(hook)이 왜 존재했는지, 왜 심(shim)이 이를 대체하는지, 그리고 이를 뒷받침하는 근거가 무엇인지를 문서로 기록했습니다. 삭제 단계에 이르렀을 때, ADR-0011은 직관에 의존한 결정이 아니라 세 개의 논거 문서를 뒤에 업은 결론이었습니다. 0011 상단에 적힌 "ADR-0008을 대체함"이라는 문구가 바로 그 증거입니다.
-
내부 구현이 아닌 동작에 결합하는 BDD 시나리오. 인수 테스트(acceptance tests)는
behave에 의해 실행되는 Gherkin 언어로 작성되었습니다. 의도적으로 Python을 사용하여 Rust 바이너리를 블랙박스(black box)로 제어했습니다. 프로덕션 코드와 다른 언어를 사용한다는 것은 테스트가 내부 구현에 접근할 수 없음을 의미하며, 오직 관찰 가능한 동작에 대해서만 단언(assert)할 수 있음을 뜻합니다. 이 테스트들은 제가 심(shim)을 작성하기 전, 훅(hook)이 놓쳤던 정확한 사례들(중첩된 git,gh pr diff)을 심의 계약(contract)으로 인코딩했습니다. 테스트가 통과(green)되었을 때, "심이 훅이 할 수 없었던 부분을 커버한다"는 말은 더 이상 주장이 아니라 통과된 테스트가 되었습니다. -
구현을 위한 엄격한 TDD. Red, Green, 삼각측량(triangulate), 리팩터링(refactor). 앞서 보여드린 종료 코드(exit-code) 수정 사항은 실행 불가능한 git에 대해 126을, 누락된 git에 대해 127을 단언하는
tests/shim_exit_codes.rs와 함께 배포되었습니다. 심이 가진 모든 동작은 테스트로 고정(pin down)되어 있습니다. -
매번 수행하는 병합 전 관문(pre-merge gauntlet). 버그 사냥, 품질 감사, 보안 검토, 적대적 QA(adversarial QA), 그리고 언어별 순수성 검사(purity checks) 세트가
main에 도달하기 전에 실행됩니다. "테스트 통과"는 필요조건일 뿐 충분조건은 아닙니다. 이 에픽(epic)의 그 어떤 것도 이 과정을 건너뛰지 않았습니다. -
최종 관문으로서의 캡스톤 데모. 중첩된
make review사례와 실제 PR에 대한gh pr diff를 포함하여, 제대로 작동함을 증명하는 설명이 포함된 엔드 투 엔드(end-to-end) 녹화 영상이 나오기 전까지 이 에픽은 완료된 것이 아닙니다. (그 영상이 아래에 있습니다.) 시연할 수 없다면, 완료된 것이 아닙니다.
그 중 어느 것도 영웅적인 행동이 아닙니다. 지루한 일이며, 그것이 핵심입니다. 작동하는 코드를 삭제할 수 있었던 자신감은 허세가 아니라 산출물 (artifacts)에서 나왔습니다. ADR (Architecture Decision Records)은 이유를 설명했고, BDD (Behavior-Driven Development) 시나리오와 capstone은 여전히 작동함을 증명했습니다. 프로세스가 보수적이었기에 저는 정확히 대담해질 여유를 가질 수 있었습니다.
실행 모습 확인
데모 GIF 위치: 이 지점에 dev.to 에디터로
capstone.gif를 드래그하거나 호스팅된 URL을 붙여넣으세요.
여섯 단계: git-prism shim install (PATH 동의 포함) → git-prism shim status → 에이전트 세션 내에서 JSON을 반환하는 직접적인 git diff main..feature → make review가 이를 서브프로세스 (subprocess)로 실행할 때 포착되는 동일한 diff → 실제 PR에 대한 gh pr diff가 매니페스트 (manifest)를 반환 → 그리고 공식적으로 은퇴한 훅 (hook)으로서 에러를 발생시키는 git-prism hooks install.
(전체 영상: 호스팅이 완료되면 .mp4 링크를 연결하세요.)
훅 (hook)으로부터의 마이그레이션
만약 리다이렉트 훅 (redirect hook)을 실행 중이었다면, 업그레이드는 두 개의 명령과 재시작으로 완료됩니다:
# 오래된 settings.json 훅 항목 제거
git-prism hooks uninstall --scope user # 또는 --scope project / local
...
git-prism shim status로 확인하십시오. 이 명령은 shim이 활성화되어 있는지와 어디에 위치하는지를 보여줍니다. 의도적인 변경 사항에 유의하십시오: git-prism hooks install은 이제 설계상 0이 아닌 종료 코드 (non-zero exit code)로 종료됩니다. 또한 git-prism hooks install --path-shim은 이번 릴리스에서 git-prism shim install의 지원 중단 예정인 별칭 (deprecated alias)으로서 (경고와 함께) 여전히 작동합니다. git-prism hooks uninstall과 hooks status는 레거시 (legacy) 정리를 위해 유지됩니다.
직접 시도해보세요
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기