TL;DR
- AI 에이전트가 작성한 코드의 의존 관계를 Neo4j에 넣고,
컴파일러나 linter가 보고하지 않는 부채를 감지하는 도구 depgraph를 출시했습니다. - 대상은 '아무도 사용하지 않는 export / 파일 / 패키지', '이름을 바꿔 복제된 동일한 로직', '순환 import, 레이어 위반, 무엇에나 의존되는 허브 패키지'입니다. 컴파일 에러나 사용되지 않은 로컬 변수처럼 프로젝트의 툴체인이 보고하는 것은
의도적으로 다루지 않습니다. - 규칙은 모두
Cypher 그래프 쿼리입니다. 의존 관계는 그래프이므로, 그래프 DB에 넣어 질의하는 것이 가장 직관적이었습니다. -
depgraph watch
을 실행하면 Neo4j를 Testcontainers처럼 자체 컨테이너로 구동하며, 종료 시에는 (크래시나 SIGKILL에도) 반드시 정리합니다. compose 파일도, Neo4j 설치도 필요 없습니다. Claude Code의 hook에서 감지 결과를 문맥으로 되돌려주기 때문에,
에이전트가 작업하는 도중에 부채가 늘어났다는 것을 파악할 수 있습니다. - Go / TypeScript / JavaScript / Python을 지원합니다. 구문 분석은 pure Go의 tree-sitter 구현이므로 cgo가 필요 없는 단일 바이너리입니다.
배경: AI 코드는 '통과'하지만 '쌓인다'
Claude Code 같은 에이전트에게 코드를 작성하게 하면, 컴파일도 타입 체크도 lint도 테스트도 통과함에도 불구하고 리포지토리가 조금씩 나빠지는 느낌을 받을 때가 있습니다.
자주 발생하는 문제는 다음과 같습니다.
- 기존 함수를 찾지 않고,
동일한 처리를 다른 이름으로 다시 작성하는 것 (예: formatDate와 toDateString이 별도의 파일에서 같은 작업을 수행)
- 리팩토링 도중에 호출 원본이 사라져서,
아무도 사용하지 않는 export나 파일이 남는 경우
- '일단 작동시키기 위해' 하위 레이어부터 상위 레이어를 import하여,
레이어가 무너지는 것
- 편리한 공통 패키지에 모든 것을 채워 넣어,
모두가 의존하는 허브가 생기는 경우
이 모든 것은 에러로 나타나지 않습니다. go vet나 tsc --noUnusedLocals나 eslint는 파일이나 패키지 내부에서 '프로젝트의 어느 곳에서도 import되지 않은 export'나 '다른 패키지에 있는 동일한 로직'은 볼 수 없기 때문입니다.
리뷰를 통해 사람이 발견하려면, 차이점(diff)이 아니라 리포지토리 전체를 봐야 합니다. 에이전트가 하루에 수십 번씩 수정하는 속도라면, 그것을 따라잡는 것은 거의 불가능합니다.
그래서 depgraph는 프로젝트 자체의 정적 검사로는 알 수 없고, 프로젝트 전체를 봐야만 알 수 있는 부채만을 걸러냈습니다. 반대로, import가 해결되지 않거나 타입이 맞지 않는 같은 '툴체인이 보고하는 것'은 보고하지 않습니다. 동일한 내용을 두 번 말하는 도구는 금방 무시되기 때문입니다.
무엇을 감지하는가
규칙은 다음 8가지입니다.
| rule | 내용 |
|---|
unused-export | 프로젝트 내에서 아무도 사용하지 않는 export (Go의 exported 함수/타입, TS/JS의 export, Python의 모듈 바로 아래 public 함수/클래스) |
unreachable-file | 어떤 엔트리 포인트에서도 import로 도달할 수 없는 TS/JS/Python 파일 |
unreachable-package | main · 테스트 · 공개 API 어느 곳에서도 import되지 않는 Go 패키지 |
duplicate-code | 이름에 관계없이, 동일한 로직(또는 약간 손을 댄 복제본)이 여러 곳에 존재하는 경우 |
duplicate-symbol | 동명이의 exported 함수/클래스가 여러 곳에 존재하는 경우 |
import-cycle | 패키지 간 순환 import |
layer-violation | import해서는 안 되는 레이어를 import하는 경우 |
god-package | 많은 패키지에서 import되며, 자신도 리포지토리 내의 다수 패키지에 의존하고 있는 허브 |
오탐지는 버그로 취급합니다
「사용되지 않은 코드」를 탐지할 때 가장 어려운 것은 import 그래프 외부에서 사용되는 것들입니다. package.json의 bin, Next.js의 app/ 디렉터리, Django가 자동으로 읽는 models.py, Dockerfile이나 CI가 실행하는 스크립트, 그리고 importlib.import_module(f"app.plugins.{name}")와 같은 동적 import 등이 해당됩니다.
이러한 요소들을 사용되지 않았다고 보고하면, 이용자들은 몇 번 만에 "이 도구는 거짓말을 한다"고 판단하고 외면하게 됩니다. 따라서 depgraph에서는 오탐지를 '정확도의 문제'가 아닌 버그로 간주하며, 진입점의 징후를 대량으로 수집하도록 설계했습니다. 그럼에도 불구하고 탐지할 수 없는 경우에는 // depgraph:entry 주석이나 .depgraph.yml의 entries에 표시할 수 있습니다.
이름을 바꾼 복제본 찾기
duplicate-code는 함수 본문의 '형태'를 비교합니다.
- tree-sitter 구문 토큰에서 주석을 제거하고, 식별자와 리터럴을 모두 마스킹합니다 (이름만 바꾼 복제본은 같은 형태를 유지하기 때문). 이후 토큰 4-gram에 winnowing(Schleimer 등)을 적용하여 지문을 만듭니다.
- 지문의 Jaccard 계수가 0.8 이상이면 복제본으로 간주합니다.
형태가 완전히 일치하는 것뿐만 아니라, 문장을 추가하거나 변경한 복제본도 찾아냅니다. 모든 함수에 존재하는 오류 처리나 반복문과 같은 '흔한 지문'(10개를 초과하는 함수에서 공통적으로 나타나는 패턴)은 후보 생성 단계에서 제외하며, 테스트 코드는 대상에서 제외합니다. 테이블 테스트는 의도적으로 유사하게 작성되기 때문입니다.
왜 그래프DB(Neo4j)와 Cypher인가
의존성은 본질적으로 그래프입니다.
상자는 노드(Neo4j의 레이블)이고, 화살표의 레이블이 관계(Relationship)입니다. Package → Package의 IMPORTS는 파일 단위의 import를 패키지 단위로 재정리한 것이며, 순환 참조나 계층 위반 규칙은 이를 따라갑니다. CALLS/REFERENCES는 watch 내에서 언어 서버 프로토콜(LSP)을 통해 추가됩니다.
이를 Neo4j에 넣으면, 규칙은 '그래프에 대한 질의'가 됩니다. 예를 들어 god-package는 제외 설정 등을 생략하고 단순화하면 다음과 같이 작성할 수 있습니다.
MATCH (p:Package {project: $project})<-[:IMPORTS]-(q:Package {project: $project})
WHERE p.external = false AND q.external = false
WITH p, count(DISTINCT q) AS fanIn
...
단순히 "10개 이상의 패키지에서 import되고 있다"는 조건만으로 판정하면, 도메인 모델처럼 많은 곳에서 의존되지만 자신은 아무것도 의존하지 않는 안정적인 패키지까지 걸러집니다. 이는 안정 의존의 원칙상 오히려 건전한 상태이므로, "자신도 리포지토리 내 3개 이상의 패키지에 의존하고 있다"는 조건을 추가했습니다. 이러한 조정 사항을 하나의 쿼리 안에서 읽을 수 있다는 점이 Cypher를 사용한 이유입니다.
규칙을 추가하고 싶을 때도 브라우저에서 Neo4j에 연결하여 쿼리를 테스트하고, 이를 그대로 규칙으로 만들 수 있습니다.
순환 탐지(Cycle Detection)를 다항 시간으로 만든 이야기
초기 순환 탐지는 '길이 2~8의 모든 폐루프를 열거하는' 단순한 쿼리였습니다. 루프의 개수는 경로 조합에 따라 증가하기 때문에, import가 밀집된 그래프에서는 실행 시간이 지수적으로 늘어납니다. 에이전트가 수정할 때마다 평가해야 하는 규칙으로는 사용할 수 없습니다.
현재는 "import 간선 a → b 각각에 대해, b에서 a로 돌아가는 최단 경로를 하나만 찾는" 형태로 변경했습니다. 보고하고 싶은 것은 '어떤 import가 순환에 걸려 있는지'이므로, 간선마다 대표적인 순환이 하나만 있어도 충분합니다. 이전 쿼리와 동일한 간선을 포괄하는지 여부는 무작위 그래프 25개로 구성된 테스트에서 이전 쿼리 결과와 대조하여 확인하고 있습니다.
Neo4j는 depgraph가 자동으로 준비해준다
그래프DB를 사용하는 도구들 중 가장 번거로운 것은 "먼저 Neo4j를 세팅해야 한다"는 점입니다. depgraph는 Testcontainers와 같은 발상으로, 필요할 때 Neo4j를 컨테이너로 구동하고, 끝나면 정리합니다.
depgraph watch --root /path/to/your/repo
이것만으로도,
- 응답하는 Docker, Podman, nerdctl 중 하나를 찾아 Neo4j를 시작합니다 (최초 실행 시에만 이미지를 가져옵니다).
- 리포지토리를 전체 스캔하고, 이후부터는 파일 변경 건별로 차이점(diff)만 재분석합니다.
- http://127.0.0.1:7777에서 브라우저 UI를 제공합니다 (그래프와 탐지 결과가 실시간으로 업데이트됩니다).
CLAUDE.md
의 관리 구역에 탐지 결과를 작성하는 것까지 작동합니다.
Testcontainers를 그대로 사용하지 않은 이유
처음에는 Testcontainers-go를 그대로 사용할 생각이었지만, 테스트 외적으로 상용화할 도구에 포함시키는 것은 제외했습니다. Testcontainers는 테스트가 끝난 후에 남아 있는 컨테이너를 청소하는 전용 컨테이너인 Ryuk (resource reaper = '리소스 수확기')을 옆에서 실행합니다. 이 Ryuk이 Docker 소켓을 마운트하고 포트를 열거나, 환경 변수로 동작 방식이 바뀌는 것이 개발자의 머신에 상주하는 도구로서는 우려되는 부분이었습니다.
대신, 각 런타임의 CLI를 고정된 인자열로, 셸을 거치지 않고 실행하고 있습니다.
- Bolt 포트는
127.0.0.1
의 임의 포트에만 공개됩니다 - 비밀번호는 컨테이너마다 무작위이며, 환경 변수로 전달되어 커맨드라인( ps로 보이는 위치)에는 노출되지 않습니다 -
디스크에 아무것도 쓰지 않습니다 (연결 정보는 매번 컨테이너의 레이블과 inspect를 통해 읽습니다).
어떻게 끝나든 반드시 정리한다
상주 도구가 컨테이너를 실행하는 경우, 충돌했을 때 잔여물이 남지 않는 것이 중요합니다. depgraph는 컨테이너를 시작한 직후, 자신을 작은 watchdog 자식 프로세스로 시작합니다.
부모 프로세스가 정상 종료하든, panic이 발생하든, SIGKILL로 종료되든 파이프는 커널에 의해 닫힙니다. watchdog는 이를 신호로 받아 컨테이너만 정리하고 종료합니다. Ryuk 같은 청소용 컨테이너나 그 네트워크 포트도 사용하지 않습니다. 전원이 꺼져서 watchdog가 사라지는 경우라면, 다음 시작 시점에 '소유 프로세스가 없는' 레이블이 붙은 컨테이너를 정리합니다.
이 '반드시 정리됨' 기능은 panic 및 SIGKILL 각각에 대해 컨테이너가 사라지는 것을 실제 컨테이너 런타임에서 확인하는 결합 테스트로 되어 있으며, CI에서는 Docker와 Podman 양쪽 모두에서 실행하고 있습니다.
영속성은 전환 가능(Switchable)
기본값은 임시적(ephemeral)이며, 데이터는 컨테이너 내부의 tmpfs에 있어 중지되면 사라집니다. 큰 리포지토리에서 매번 전체 스캔을 하고 싶지 않다면 --persist (또는 .depgraph.yml의 store.persist: true)로 이름 지정 볼륨에 저장할 수 있습니다. 남기는 것은 데이터만이며, 프로세스는 남기지 않습니다. 영속 데이터가 조용히 쌓이지 않도록 depgraph store ls / store rm으로 목록 확인 및 삭제가 가능합니다.
이미 Neo4j를 직접 운영하고 있다면, NEO4J_URI를 설정하기만 하면 depgraph는 연결만 하고 시작하거나 삭제하지 않습니다.
에이전트가 작업하는 도중에 반환한다
탐지 결과는 CI에서 보는 것보다 에이전트가 작성하는 도중에 돌려주는 것이 효과적입니다. depgraph는 Claude Code의 hook으로 작동합니다.
{
"hooks": {
"SessionStart": [
...
session-start: 통계와 탐지 결과 요약을 컨텍스트에 포함시킵니다 (빈도가 높은 규칙은 대표 예시로 압축하여 약 4,000자에 맞춥니다) -
prompt: 이전부터 새롭게 추가되거나 해소된 탐지 결과만 넣습니다. 변화가 없으면 아무것도 출력하지 않습니다
실제로 depgraph 자체 리포지토리에서 세션을 시작하면 다음과 같은 컨텍스트가 포함됩니다.
depgraph: 이 리포지토리의 의존성 분석
29 packages, 170 files, 1303 symbols, 335 package-level import edges.
Findings: 1 warn. Fix errors first and do not add code that makes these worse.
...
hook는 프롬프트를 절대 멈추지 않습니다. Neo4j에 연결되지 않거나, 스캔되지 않았거나, 타임아웃(기본값 3초)인 경우에는 아무것도 출력하지 않고 종료합니다. 세션별 '이전 상태'는 리포지토리에 파일을 쓰지 않고 Neo4j 프로젝트 데이터와 함께 저장하며, 프로젝트를 삭제하면 같이 사라지도록 했습니다.
claude-hook-gate로 '부채를 늘린 채 끝낼 수 없게' 만들기
위의 prompt hook에는 약점이 있습니다. 작동하는 것은 사용자가 프롬프트를 보낼 때뿐이므로, 에이전트가 1턴에 수십 개의 파일을 수정하고 있는 동안은 감지 결과가 한 번도 도착하지 않습니다. 긴 턴의 마지막에 '완료했습니다'라는 말이 나오는 시점에는 이미 부채가 쌓여 있습니다.
그래서 편집할 때와 에이전트 종료 전에 명령을 실행하는 품질 게이트 claude-hook-gate(소개 기사)에서 depgraph를 호출할 수 있도록 했습니다. .claude/gate.yaml에 규칙(rule) 하나만 추가하면 됩니다.
rules:
# depgraph: 이 세션에서 늘어난 부채 (depgraph watch가 필요합니다. 작동하지 않으면 통과시킵니다)
- match: ["**/*.go", "**/*.{ts,tsx,js,jsx,mjs,cjs}", "**/*.py"]
...
depgraph gate는 다음과 같이 작동합니다.
- gate에서 전달된 변경 파일(
$CLAUDE_GATE_FILES)을, watch와 같은 방식으로 직접 그래프에 반영한 후 규칙을 평가합니다. 이는 watch의 반영 대기(debounce)와 경쟁하지 않기 위함입니다.
- 세션 시작 시점의 감지 결과(hook session-start가 기록한 기준)와 비교하여, 이번 세션에서 늘어난 것만 목록으로 만들고, 1건이라도 있으면 실패합니다.
- claude-hook-gate는 실패한 rule의 출력을 에이전트에게 반환하고, 종료하려고 할 때도 재실행됩니다. 따라서,
자신이 증가시킨 부채를 고치거나(또는 의도된 것이라고 설명할) 때까지 에이전트는 끝낼 수 없습니다.
세션을 시작할 때부터 존재했던 부채로는 실패하지 않습니다. 비교하는 것은 '이번 세션에서 늘어난 분'만이기 때문에, 기존 부채가 많은 리포지토리에서도 매번 막혀서 작업이 불가능해지는 일은 없습니다.
설계 과정에서 고민했던 부분은 '어떤 심각도(severity)로 멈출 것인가'였습니다. 처음에는 '순환이나 레이어 위반(error)만 종료를 막고, 미사용 코드나 중복(warn)은 알리기만 하는' 방식으로 할 생각이었습니다. 그런데 claude-hook-gate에서는 성공한 체크의 출력은 에이전트에게 도달하지 않고, 실패한 체크는 종료 시점에 반드시 재실행됩니다. '알리기는 하지만 멈추지 않는' 것은 만들 수 없습니다. 그래서 기본값은 warn 이상일 때 멈추도록(--fail-on warn) 했습니다. AI가 증가시킨 부채를 AI가 끝나기 전에 처리하도록 하는 것. 그것이 이 도구의 목적 그 자체이기 때문입니다. 만약 멈춤을 순환과 레이어 위반에만 하고 싶다면 --fail-on error로 설정합니다(그 외는 지금까지처럼 prompt hook으로 전달됩니다).
참고로, gate에는 세션 시작 시점이 없습니다. 시작 시점의 요약과 기준 기록은 계속해서 SessionStart hook이 담당합니다. 또한, Neo4j가 작동하지 않거나, 미스캔되었거나, 타임아웃 등 판단할 수 없는 경우에는 반드시 통과시킵니다. 이는 도구의 편의를 위해 에이전트를 멈추지 않기 위함입니다.
Serena MCP와의 차이점
코드를 이해하기 위한 MCP 서버로는 언어 서버(LSP)를 사용하는 Serena가 유명합니다. 비슷해 보이지만, 역할은 다릅니다.
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
Serena: 에이전트가 코드를 검색하거나 편집하기 위한 도구입니다. 심볼 단위로 정의를 검색하고, 참조원을 추적하며, 함수 본문을 수정하는 등 IDE의 작업을 에이전트에게 전달합니다.
depgraph: 에이전트가 작성한 결과에 부채(Debt)가 늘어나지 않았는지 검사하는 도구입니다.
| Serena | depgraph |
|---|
| 목적 | 심볼 단위의 검색 및 편집 |
| 작동 방식 | 에이전트가 필요하다고 느낄 때 스스로 호출함<br>파일 변경 시마다 재분석하며, hook이 매번 컨텍스트에 주입됨 |
| 확인 범위 | '이 심볼의 참조원은?'과 같은 한 지점 단위의 문의 |
| 메커니즘 | 언어 서버(Language Server)<br>tree-sitter로 만든 의존 그래프를 Neo4j에 넣고, 규칙은 Cypher 사용 |
Serena에서는 채워지지 않는 부분이 바로 depgraph가 목표하는 지점입니다.
에이전트가 묻지 않은 것은 알 수 없습니다. '이 처리는 어딘가에 있을지도 모른다'라고 생각하지 못한 에이전트는 검색 도구를 호출하지 않고 그대로 재작성합니다. depgraph는 호출되기를 기다리지 않고 '복제가 늘어났다'고 알려줍니다.
이름이 다른 복제는 LSP로는 추적할 수 없습니다. LSP의 참조 검색은 '동일한 심볼에 대한 참조'만 추적할 수 있기 때문에, formatDate와 toDateString가 같은 작업을 하더라도 연결되지 않습니다. depgraph는 식별자를 가린 본문 형태로 비교합니다.
전체 집계가 없습니다. 아무도 사용하지 않는 export 목록, 순환 import, 레이어 위반, 허브 패키지 등은 전체를 집계해야만 알 수 있습니다. 모든 심볼에 참조 검색을 수행하면 근접한 것은 가능하지만, 느리고 게다가 에이전트가 자발적으로 할 일은 아닙니다.
아키텍처 규칙을 둘 곳이 없습니다. depgraph는 .depgraph.yml 파일로 레이어와 금지된 import를 선언하고 위반을 감지합니다.
따라서 경쟁 관계가 아니라 병용하는 것입니다. Serena로 검색하고 편집한 후, 그 편집으로 부채가 늘어나지 않았는지 depgraph가 검사하는 조합이 가장 효과적입니다.
자신에게 적용해 보기
depgraph의 CI에는 depgraph 자체를 스캔하여 감지 결과가 1건이라도 있으면 실패(fail)하는 테스트가 있습니다. 만드는 과정에서 실제로 이런 것을 발견했습니다.
실제 중복: import 해결 계층과 구문 분석 계층에 'root별로 결과를 캐시한다'는 거의 동일한 구현이 다른 이름으로 2개 있었습니다. 이는 AI가 흔히 저지르는 '검색 없이 재작성하는' 패턴이었고, 공통 패키지로 분리했습니다.
규칙 측의 오탐지: 같은 Go 패키지 내 build constraint 차이 파일(foo_unix.go와 foo_windows.go)에 있는 동명 함수를 duplicate-symbol이 복제로 보고했습니다. Go에서는 이것이 동명을 공존시키는 유일한 방법이므로, Go 패키지는 1개로 계산하도록 수정했습니다.
자신의 도구가 자신의 리포지토리에서 내놓은 지적을 방치하지 않고, 오탐지라면 도구를 고치는 운영 방식(운영 체계)으로 가져가면 규칙의 정확도가 상당히 단련됩니다.
사용해 보기
Releases에서 OS/CPU에 맞는 아카이브(linux / darwin / windows × amd64 / arm64)를 다운로드하고, checksums.txt로 검증한 후 PATH에 배치합니다.
sha256sum --ignore-missing -c checksums.txt # macOS: shasum -a 256 --ignore-missing -c checksums.txt
tar xzf depgraph_linux_amd64.tar.gz && install depgraph ~/.local/bin/
depgraph watch --root /path/to/your/repo
필요한 것은 컨테이너 런타임(Docker, Podman, nerdctl 중 하나)뿐입니다.
아키텍처 규칙은 .depgraph.yml로 선언할 수 있습니다.
layers:
domain: ["pkg/domain/"]
services: ["pkg/services/"]
...
알 수 없는 키(key), 규칙 이름(rule name) 또는 존재하지 않는 레이어(layer)를 지정하는 forbidden은, 조용히 무시하지 않고 오류로 처리합니다. 이는 설정 실수로 인해 '아무것도 감지되지 않으니 괜찮다'고 착각하는 것을 방지하기 위함입니다.
향후 기능
- 설치된 Neo4j를 자식 프로세스로 실행합니다
local
(컨테이너 런타임이 없는 환경을 위한) 프로바이더 - AI 세션 단위로 '얼마나 의존성이 증가했는지'를 추적하는 시계열 분석
- MCP 서버로서, 에이전트가 그래프에 직접 질의하는 모드
AI가 코드를 작성할수록, '작동하지만 쌓이는' 부채(debt)를 감지하는 것은 인간의 눈으로는 따라잡기 어려워집니다. 컴파일러와 linter 바깥을 보는 도구로서, 한번 사용해 보시면 좋겠습니다.
Discussion