
Claude Code 설정에 대한 헬스 체크를 실행해 보았습니다 — 실제로 속도를 늦추고 있었던 원인은 무엇일까요
요약
Claude Code의 `/doctor` 명령어를 사용하여 복잡한 모노레포 환경에서의 설정 상태를 점검하고 성능 저하 원인을 분석한 사례를 다룹니다. 자동화된 훅(hooks)이 매 대화 턴마다 발생시키는 지연 시간과 중복 설치, 사용되지 않는 확장 프로그램 등의 문제를 진단합니다.
핵심 포인트
- Claude Code의 `/doctor` 명령어로 설치 상태 및 설정 헬스 체크 가능
- 자동화된 훅(hooks)이 백그라운드에서 실행되며 심각한 지연을 초래할 수 있음
- 중복 설치, 미사용 MCP 서버, 비대한 CLAUDE.md 파일 등이 성능 저하 원인
- 효율적인 에이전트 사용을 위해 정기적인 설정 감사가 필요함
저는 몇 달 동안 중간 규모의 모노레포(궁금하시다면 말씀드리자면, Node.js + Spring Boot + Flutter + Kotlin으로 구성된 농업 플랫폼이며, 5개의 컴포넌트가 strangler-fig 방식의 마이크로서비스 마이그레이션 진행 중입니다)를 대상으로 Claude Code를 실행해 왔습니다. 그리고 상당히 공격적인 일련의 훅(hooks)들을 연결해 두었습니다. 즉, 매 수정 시 심볼(symbols)을 인덱싱하는 레포 메모리(repo memory), 모든 프롬프트와 도구 호출(tool call)을 저장하는 Postgres 기반의 트랜스크립트 저장소(transcript store), 서브에이전트(subagent)가 실패할 때 발생하는 자동 RCA 트리거, 세션 종료 시 피드백 캡처 등이 그것입니다. 이는 많은 자동화이며, 감사(audit)하지 않는 자동화는 결국 퇴적물처럼 쌓이게 됩니다.
그래서 저는 /doctor를 실행했습니다. 이는 Claude Code 설치 자체를 디버깅할 가치가 있는 시스템으로 취급하는 설정 헬스 체크(setup-health check)입니다. 중복 설치, 죽은 설정, 컨텍스트(context)를 낭비하는 사용되지 않는 확장 프로그램, 그리고 — 가장 중요했던 부분 — 매 턴(turn)마다 실행되면서 조용히 느려진 훅(hooks)들을 찾아냅니다.
이 포스트는 해당 감사의 기록입니다. 무엇을 발견했는지, 무엇이 저를 놀라게 했는지, 그리고 실제 작업을 수행하는 코드와는 전혀 상관없는 이유로 매 대화 턴마다 10초 이상의 시간을 잡아먹고 있었던 단 하나의 버그에 대해 다룹니다.
CLI 도구의 설정을 왜 굳이 감사해야 하는가
Claude Code의 훅(hooks)은 하네스(harness)가 특정 이벤트 전후로 자동 실행하는 셸 명령(shell commands)입니다. 도구 호출 전, 도구 호출 후, 프롬프트가 들어올 때, 모델이 응답을 마쳤을 때 등이 해당됩니다. 훅은 상태가 없는(stateless) 에이전트 루프에 지속적인 메모리, 텔레메트리(telemetry), 또는 가드레일(guardrails)을 결합하는 방법입니다. 제 경우에는 다음과 같은 일들을 수행합니다: 수정 후 코드베이스의 심볼 그래프(symbol graph)를 재인덱싱하고, 기능 귀속(feature attribution)을 위해 git diff를 캡처하며, 나중에 신뢰도 점수(confidence-scoring)를 매기기 위해 모든 프롬프트/응답 쌍을 데이터베이스에 로그로 남깁니다.
훅의 구체적인 문제는 설계상 눈에 보이지 않는다는 점입니다. 대화 중에 나타나지 않습니다. 그저... 백그라운드에서 실행되면서 매 턴마다 지연 시간(latency)을 추가할 뿐입니다. 직접 찾아보지 않는 한,
감사(Audit)가 실제로 확인하는 항목
스캔은 로컬의 읽기 전용 소스에서만 정보를 가져오며, 텔레메트리(telemetry) 업로드나 외부로의 데이터 전송은 전혀 이루어지지 않습니다:
- 설치 상태(Installation health). 중복 설치(네이티브 런처 vs 오래된 npm-global 잔재)가 되어 있는가? 확인된 바이너리(binary)가 설정 파일에 설치된 것으로 인식된 것과 일치하는가? 손상되거나 충돌하는 에이전트 정의 파일이 있는가?
- 확장 기능의 사용 신호(Usage signal for extensions). 모든 스킬(skill), 플러그인(plugin), 그리고 MCP 서버(외부 도구와의 연결)는 수명 주기 사용 카운터(lifetime usage counter)를 가지고 있거나, 카운터가 없는 MCP 서버의 경우 최근 세션의 트랜스크립트(transcript) 증거를 가지고 있습니다. 이 두 가지를 가장 최근에 사용된 50개의 세션 트랜스크립트 범위와 교차 참조합니다.
- 체크인된 메모리 파일의 비대화(Checked-in memory file bloat).
CLAUDE.md파일은 모든 세션의 컨텍스트(context)로 로드됩니다. 새로운 세션이 코드를 읽음으로써 재구성할 수 있는 내용(예: docker-compose 포트 테이블,pubspec.yaml에 이미 포함된 표준flutter build명령 등)이 파일에 들어 있다면, 이는 매 세션마다 지불해야 하는 불필요한 비용(dead weight)입니다. - 훅 성능(Hook performance). 트랜스크립트의 첨부 기록으로부터 훅(hook)별, 이벤트(event)별
durationMs를 집계합니다. 빈번하게 발생하면서(모든 프롬프트 또는 모든 도구 호출 시 실행) 동시에 느린 항목을 찾아냅니다. - 권한 마찰(Permission friction). 한 번만 사전 승인(pre-approved)하면 될 안전한 읽기 전용 명령들이 계속해서 거부되고 다시 확인을 요청받고 있지는 않은가?
저는 감사를 실행한 후 그 결과에 따라 조치를 취했으며, 이 부분이 살펴볼 가치가 있는 대목입니다.
실제로 무엇이 잘못되었었나
중복 설치. which -a claude를 실행하자 두 개의 경로가 나타났습니다. 제가 실제로 사용하는 네이티브 런처와, npm-global 접두사(prefix)에 남아 있던 두 버전이나 뒤처진 오래된 @anthropic-ai/claude-code@2.1.76였습니다. 그 자체로는 해롭지 않지만, 6개월 뒤에 "잠깐, 내가 지금 대체 어떤 버전을 실행 중이지?"라는 혼란을 야기할 수 있는 종류의 문제입니다. 이를 제거했습니다.
하나의 손상되고 조용히 죽어버린 에이전트 파일. 193개의 프로젝트 레벨 에이전트 정의 중 하나에 프론트매터(frontmatter) 내 description: 항목이 없었습니다. 이는 Claude Code가 해당 파일을 아예 로드하지 않음을 의미합니다. 또한, 이 파일은 동일한 디렉토리에 있는 정상적인 형제 파일과 name: 값이 충돌하는 문제도 있었습니다. 이는 별개의 실패 모드입니다(한 디렉토리 내의 두 파일이 동일한 name을 공유할 경우, 디렉토리 읽기 순서에 따라 패배한 파일이 폐기되는데, 이 순서는 기기 간에 안정적으로 보장되지 않습니다). 문제가 된 파일을 삭제했습니다.
진정으로 사용되지 않는 확장 프로그램들. 설치 이후 사용 기록이 전혀 없는 플러그인 하나. 전체 스캔 기간 동안 호출 횟수가 0인 MCP 서버(Dropbox) 하나. 둘 다 비활성화했습니다. 다시 복구하려면 각각 한 번의 명령만 실행하면 되므로 가역적(reversible)입니다.
코드가 이미 명시하고 있는 내용을 담고 있는 체크인된 CLAUDE.md 파일들. 제 Flutter 앱의 메모리 파일에는 전체 flutter build/flutter test 명령 세트와 디렉토리 트리가 나열되어 있었습니다. 하지만 이 두 가지는 모두 pubspec.yaml을 읽고 ls lib/를 실행함으로써 완전히 유도(derivable)할 수 있는 정보였습니다. 다른 세 개의 컴포넌트 파일에서도 동일한 패턴이 발견되었습니다: Kotlin Gradle 명령 목록, Node.js npm run 목록, docker-compose 포트 테이블이 그것입니다. 이 내용들이 틀린 것은 아니었지만, 단 몇 번의 ls/cat 호출로 무료로 재구성할 수 있는 정보를 위해 매 세션마다 비용을 지불하고 있었던 셈입니다. 이 네 가지 파일을 모두 유도할 수 없는 부분들로 축소했습니다. 즉, 외부 파일이 필요한 설정 단계, 마이그레이션 명명 규칙에 관한 주의 사항(gotcha), 외부 QA 테스트 케이스 스프레드시트에 대한 포인터 등입니다. 이러한 정보들은 저장해 두었습니다. 왜냐하면 저장소(repo)를 아무리 읽어도 Google Sheet가 존재한다는 사실은 알 수 없기 때문입니다.
그리고 가장 큰 문제: 훅(hook) 지연 시간.
턴당 12초를 잡아먹고 있던 훅
Stop 훅 — 모델의 모든 응답 후에 실행되는 훅 — 은 스캔 범위 내에서 기록된 251회의 실행 결과, 평균 12.1초를 기록했으며 최악의 경우 113초까지 소요되었습니다. UserPromptSubmit 훅 (사용자가 메시지를 보낼 때마다 실행됨)은 평균 5.5초를 기록했습니다. 참고로, 모든 프롬프트나 모든 도구 호출 (tool call) 시 실행되는 작업은 대략 2초 이내로 유지되어야 하며, 빈도가 낮은 세션 경계 훅 (SessionStart, Stop)조차도 10초 이내여야 한다는 것이 경험적인 규칙 (rule of thumb)입니다.
처음 문제를 확인했을 때 제 가설은, 훅 스크립트가 다른 기기(Mac)에서 설정할 때 사용했던 하드코딩된 경로를 참조하고 있어서 현재의 Linux 환경에서 조용히 실패하고 있을 것이라는 점이었습니다. 즉, 모든 python3 /Users/me/project/.claude/hooks/whatever.py 호출이 빠르게 에러를 내고 그냥 넘어가고 있을 것이라고 생각했습니다. 이는 절반만 맞았습니다. 실제로 정확히 그 버그를 발견하긴 했지만, 그것은 더 작고 관련 없는 스크립트(더 이상 존재하지 않는 로그 경로에 기록을 시도하던 디버그 로깅 훅)에서 발생한 것이었습니다 (이것은 약 5ms 정도로 빠르게 실패했으며, 12초 문제의 원인은 아니었습니다). 실제 Stop 및 UserPromptSubmit 훅은 이미 하드코딩된 경로 대신 $PWD와 $HOME을 사용하도록 수정된 상태였습니다.
진짜 원인은 더 단순하고 눈에 덜 띄는 것이었습니다. Stop 훅은 리포지토리 메모리 (repo-memory) CLI에 대한 6개의 별도 호출을 체인(chain) 형태로 실행하며, 각 호출은 npx -y @invariance/gps <subcommand> 형식으로 실행됩니다. npx -y는 단순히 캐시된 바이너리를 실행하는 것이 아닙니다. 매 호출마다 패키지를 다시 해석(re-resolve)하며, -y (자동 승인) 옵션의 경우 실행 전 레지스트리를 확인하여 더 새로운 버전이 있는지 체크합니다. 콜드 타임 (cold time) 측정 결과: 호출당 1.6초였습니다. 하나의 훅 안에서 이 작업이 순차적으로 6번 실행된 후 턴이
해결책은 거의 민망할 정도로 간단했습니다. 패키지를 전역(globally)으로 한 번 설치하여 $PATH에 있는 실제 바이너리로 만드는 것이었습니다.
npm install -g @invariance/gps --prefix ~/.npm-global
그런 다음 훅(hook) 설정에 있는 모든 npx -y @invariance/gps <subcommand>를 일반적인 gps <subcommand>로 교체했습니다. 호출 횟수 6번과 동작은 동일하지만, 호출할 때마다 발생하는 레지스트리 확인(registry check)과 npm 패키지 해석(package-resolution) 오버헤드가 사라졌습니다.
Before: npx -y @invariance/gps brief --root "$PWD" → ~1.6s, 매번 네트워크 라운드트립(network round-trip) 발생
After: gps brief --root "$PWD" → ~0.7s, 한 번만 해석됨, 네트워크 없음
각각 1.6초씩 걸리던 6번의 호출이 각각 0.7초로 줄어든 것은, _매 턴(every single turn)_마다 10초의 세금을 내느냐 4초의 세금을 내느냐의 차이와 같습니다. 또한, 113초라는 이상치(outlier)의 원인이 확실시되었던 네트워크 불안정성(network-flakiness) 문제도 제거되었습니다. 저는 수정 전후를 직접적인 타이밍 테스트로 검증했습니다: npx를 통한 콜드 타임(cold time)은 1.601초였고, 전역 바이너리를 통한 타임은 반복된 실행에서도 일관되게 0.740초였습니다.
이 0.7초가 여전히 무엇인지 정확히 짚고 넘어갈 가치가 있습니다. 왜냐하면 0초는 아니기 때문입니다. 이는 여전히 새로운 Python/Node 프로세스의 스핀업(spin-up)이며, 여전히 전체 CLI 인자 파싱(argument parse) 과정이고, 도구가 파일 시스템에 접근하기 전에 내부적으로 수행하는 작업들을 포함합니다. 전역 설치는 정확히 단 하나의 특정 세금, 즉 레지스트리 최신성 확인(registry freshness check)만을 제거하며 그 외의 것은 바꾸지 않습니다. 만약 제가 더 나아가고 싶었다면, 다음 단계는 6개의 순차적인 서브커맨드(subcommands)를 하나의 장시간 실행되는 호출로 통합하거나, 중요하지 않은 명령들(suggest, prune)을 비동기(asynchronous)로 만들어 턴이 해당 명령들 때문에 차단(block)되지 않도록 만드는 것이었을 것입니다. 저는 이번에 의도적으로 그렇게 하지 않았습니다. 요청 사항은 "왜 이것이 느린가"였지, "이 도구의 호출 모델을 재작성하라"가 아니었기 때문입니다. 검증하기 쉬운 6줄의 차이(diff)가, 미묘하게 틀리기 쉬운 더 큰 변경보다 낫습니다. 수확 체감(diminishing returns)은 여전히 수확이지만, 다음 수정 사항은 별도로 검토되어야 하는 별도의 변경 사항으로 다뤄져야 하는 지점이 존재합니다.
아래 다이어그램은 수정 사항의 형태를 보여줍니다: 모든 턴(turn)을 중심으로 네 개의 훅 이벤트(UserPromptSubmit, PreToolUse, PostToolUse, Stop)가 순차적으로 발생하며, 그중 세 개가 repo-memory CLI를 호출합니다. 바로 이 지점에 콜드 스타트(cold-start) 비용이 숨어 있었습니다. 별도의 트랜스크립트 로깅(transcript-logging) 훅은 모든 프롬프트/응답 쌍을 Postgres에 기록합니다. 만약을 위해 이 부분도 프로파일링(profiling)해 보았으나, 직접적인 psql 왕복 테스트를 통해 확인한 결과 쓰기당 약 50ms로 문제가 없는 것으로 나타났습니다.
건드리지 않은 부분과 그 이유
감사(audit) 과정에서 몇 가지 사항이 발견되었으나, 저는 의도적으로 그대로 두었습니다.
레포지토리 메모리 MCP 서버(gps, 위에서 논의한 CLI 바이너리와는 별개로 Claude Code 내부에서 MCP 도구 세트로 노출됨)는 스캔 기간 동안 도구 호출(tool invocation)이 전혀 발생하지 않았습니다. 다른 모든 곳에 적용된 "미사용 → 비활성화" 로직에 따르면 이는 제거 대상입니다. 하지만 프로젝트 자체 가이드라인은 사소하지 않은 편집을 수행하기 전에 이를 사용할 것을 명시하고 있으며, 이를 활성화된 상태로 두는 비용은 거의 제로에 가깝습니다. MCP 도구 스키마(schema)는 기본적으로 지연(deferred)되므로, 실제로 호출되기 전까지는 전체 스키마가 아닌 도구의 _이름_만 컨텍스트(context)에 머물기 때문입니다. 호출이 전혀 없는 얇은 트랜스크립트 창 하나만으로는 명시적인 레포지토리 전역 지침을 무시할 만큼 강력한 증거가 되지 못합니다. 특히 판단이 틀렸을 때의 리스크(실제로 하중을 견디고 있는 요소를 제거하는 것)가 맞았을 때의 이점(사실상 아무것도 절약하지 못함)보다 더 크기 때문입니다. 때때로 감사에서 내릴 수 있는 올바른 결정은 "모두 제거하라"가 아니라 "그대로 두고, 그 이유를 명시하라"입니다.
또한 저는 파괴적이거나 되돌리기 어려운 조치가 필요한 부분은 먼저 묻지 않고 건드리지 않았습니다. 강제 푸시(force-pushes)도, 별도의 확인 단계 없이 적용된 권한 모드 변경도, 명백히 쓸모없는 것이 아닌 것을 삭제하는 것도 없었습니다. 제가 만든 모든 변경 사항은 터무니없이 되돌릴 수 있거나(플러그인 비활성화는 한 개의 명령으로 취소 가능), 커밋에 가까워지기 전에 검토를 위해 남겨진 작업 트리 편집이었습니다.
핵심 요약
이 문제들 중 어느 것도 특이한 것은 아니었습니다. 오래된 중복 설치 파일, 손상된 프런트매터(frontmatter) 필드, 코드가 이미 명시하는 내용을 반복하는 메모리 파일 등이 있었습니다. 그리고 아무도 요청하지 않은 호출당 네트워크 왕복 시간(network round-trip)을 조용히 추가한 6줄짜리 후크 체인(hook chain)이 있었습니다. 그 이유는 npx -y가 (세션 동안 수백 번 실행되는 후크와 같은) 상황에서
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기