개인 프로젝트에 graphify 적용기

개인 프로젝트에 graphify를 붙여 코드베이스 전체를 지식 그래프로 만들어본 기록이다. 목적은 리팩토링 후보(중복·저사용 코드) 탐색이었고, 실제로 부딪힌 한계 위주로 정리한다.

graphify란

코드·문서·논문·이미지가 섞인 폴더를 넣으면 노드-엣지 지식 그래프로 바꿔주는 CLI 도구다. Claude Code 같은 에이전트 환경에서는 /graphify 스킬로 동작하고, 별도 API 키 없이도 코드 전용 코퍼스(분석 대상 파일 묶음)는 정적 파싱만으로 그래프를 만든다.

파이프라인은 다음 순서로 진행된다.

graphify 파이프라인 — 파일 감지부터 그래프 빌드, 커뮤니티 디텍션, 분석, 리포트·시각화 출력까지의 처리 흐름 graphify의 처리 흐름. 코드 파일은 AST 정적 파싱(Part A)만 거치고, 문서·이미지 같은 시맨틱 자료만 LLM 서브에이전트(Part B)로 처리된다.

  • 구조 추출(Part A) — 코드 파일은 tree-sitter 기반 AST(Abstract Syntax Tree, 소스 코드를 컴파일러가 구문 규칙대로 분석해서 만드는 트리 구조. 문자열이 아니라 “이 부분이 클래스 선언, 저 부분이 메서드 호출”처럼 구조로 코드를 본다)로 함수·클래스·호출 관계를 추출한다. LLM을 쓰지 않아 무료이고 결정적(deterministic)이다.
  • 시맨틱 추출(Part B) — 문서·논문·이미지만 LLM(서브에이전트 병렬 디스패치, 또는 GEMINI_API_KEY 설정 시 Gemini)로 처리한다. 코드만 있는 레포는 이 단계 자체를 스킵한다.
  • 그래프 빌드 — 두 결과를 NetworkX 그래프로 병합.
  • 커뮤니티 디텍션 — 연결 밀도로 노드를 클러스터링, 커뮤니티별 cohesion(응집도) 점수 산출.
  • 분석 — God Node(연결이 몰린 허브 노드), Surprising Connection(커뮤니티 경계를 넘는 의외의 연결) 탐지.
  • 출력 — graph.html(인터랙티브 시각화), GRAPH_REPORT.md(사람이 읽는 감사 리포트), graph.json(원본 데이터).

자주 쓰는 명령은 이 정도다.

명령 동작
/graphify <path> 전체 파이프라인 실행
/graphify <path> --mode deep 문서·이미지 시맨틱 추출을 더 정밀하게(코드 파싱에는 영향 없음, 뒤에서 다룬다)
/graphify <path> --update 변경된 파일만 재추출
/graphify query "<질문>" 그래프 위에서 BFS/DFS 순회로 답 찾기
/graphify path "A" "B" 두 노드 간 최단 경로
/graphify explain "X" 노드 하나와 인접 연결을 자연어로 설명

시각화와 내보내기

graph.html은 기본으로 항상 생성되고, 브라우저로 열면 인터랙티브 그래프(검색, 커뮤니티 색상 구분, 확대/축소)를 볼 수 있다. 노드가 5,000개를 넘으면 자동으로 커뮤니티 단위로 뭉쳐 렌더링한다. 그 외 내보내기는 목적에 따라 고른다.

옵션 용도
--svg 문서·GitHub에 임베드할 정적 이미지
--graphml Gephi, yEd 같은 전문 그래프 툴
--neo4j / --neo4j-push Neo4j에 Cypher로 적재, 쿼리 언어로 탐색
--falkordb / --falkordb-push FalkorDB(Redis 기반 그래프 DB)에 적재
--obsidian 노드 하나당 마크다운 파일 하나로, Obsidian vault 생성
--mcp graphify.serve MCP 서버 기동. query_graph, get_neighbors, shortest_path 등을 다른 에이전트가 실시간 호출

stock-msa에서 실제로 나온 graph.html을 그대로 붙여넣는다. 좌측 검색창에 클래스·메서드 이름을 입력하면 해당 노드로 이동하고, 노드를 클릭하면 우측 패널에 소스 위치와 인접 노드가 뜬다.

stock-msa 코드베이스를 돌린 실제 graph.html(313개 커뮤니티, 7,603개 노드). 검색·클릭으로 직접 탐색해볼 수 있다.

CLI와 에이전트 스킬은 그래프 질의 방식이 다르다

터미널에서 graphify CLI를 직접 쓸 때는 서브커맨드를 명시해야 한다. query, path, explain, update 등은 최상위 서브커맨드로 파싱되므로, 자연어 질문만 치면 인식하지 못한다.

graphify query "AuthService는 어디서 호출되나"
graphify query "가격 조회 흐름" --dfs --budget 1500

Claude Code에서 /graphify 스킬을 통해 쓸 때는 다르다. 스킬 지침에 “graphify-out/graph.json이 이미 있고 사용자의 요청이 리빌드 명령이 아닌 자연어 질문이면, 곧바로 graphify query를 대신 실행하라”는 fast path가 있다. 즉 에이전트를 거치면 query라는 단어를 직접 치지 않아도 자연어 질문만으로 CLI가 내부에서 호출된다. CLI 단독 사용과 에이전트 경유 사용의 차이다.

stock-msa 규모와 최초 실행 결과

대상은 개인 프로젝트 stock-msa다. Spring Boot 서비스 9개(인증, 게이트웨이, 시세, 재무, 전략, 트레이딩 등)와 공통 모듈 1개로 나뉜 MSA(마이크로서비스 아키텍처) 구조라, 서비스 하나하나는 작아도 전체로는 944개 파일·약 55만 단어 규모다. 이 코퍼스에 /graphify .를 돌린 최초(shallow) 빌드 결과는 다음과 같다.

항목 값
노드 7,258개
엣지 28,466개
커뮤니티 291개(218개 표시, 나머지 73개는 노드 수가 적어(thin) 리포트에서 생략)
토큰 비용 이 실행 1회 기준 output 1,511,688 토큰(input 0)

목적은 그래프에서 저사용·고립 노드를 찾아 리팩토링 후보를 추리는 것이었다. 실제로 이 목적을 기준으로 겪은 한계를 아래에 정리한다. --mode deep 재추출 후의 수치 변화와 이 한계들이 실제로 해소됐는지는 글 뒤쪽 “일반 모드 vs --mode deep 비교”에서 다룬다.

그래프로 실제 문제를 찾아낸 사례

한계로 넘어가기 전에, 그래프가 실제로 쓸모 있었던 지점부터 정리한다.

놓친 호출부는 INFERRED 엣지로 찾는다

TechnicalIndicatorService.calculateAndFillIndicators()를 소스만 읽어서 조사했을 때는 호출부가 StockPriceBatch 두 곳뿐인 줄 알았다. 재빌드 후 그래프에서 이 메서드로 들어오는 엣지를 다시 조회하니, 세 곳이 더 잡혔다.

  • CalculateIndicatorProcessor.process()
  • EtfPriceService.recalculateProductIndicators()
  • EtfPriceService.ingestOne()

grep으로 전수 확인한 결과 실제 호출부는 총 다섯 곳이었고, 그중 EtfPriceService.recalculateProductIndicators()가 그동안 놓치고 있던 “레코드마다 지표를 전체 재계산하는” 세 번째 사례였다. 이 건은 실제로 수정까지 마쳤다.

처음엔 이 세 엣지가 --mode deep 덕분이라고 생각했다. 그런데 엣지 메타데이터를 직접 까보니 셋 다 _origin: ast였다 — 문서를 시맨틱 추출한 결과(Part B)가 아니라 순수 AST 엣지(Part A)였다. --mode deep이 잡아준 게 아니라, 재빌드 과정에서 AST도 통째로 다시 돈 것뿐이었다. INFERRED라는 신뢰도 표시를 “LLM이 만든 엣지”로 잘못 읽은 것이었다 — 실제로는 EXTRACTED/INFERRED가 “같은 파일 안이라 확정(EXTRACTED)”과 “파일 경계를 넘어 이름만으로 맞춘 추정(INFERRED)”을 가르는 축이고, 어느 쪽 파서(Part A든 Part B든)가 만들었는지와는 별개다. 첫 빌드에는 왜 이 세 엣지가 없었는지는 확인하지 못했다 — 첫 빌드의 graph.json은 재빌드로 덮어써져 남아 있지 않다.

예상 밖 호출 트리거를 그래프로 추적한다

ReconciliationService.reconcileAll()은 스케줄러 하나가 하루 한 번 부르는 줄 알았다. 그래프를 따라가 보니 KisExecutionNotifier가 KIS(한국투자증권) WebSocket이 재연결될 때마다 별도로 호출하는 경로가 있었다. 하루 한 번이 아니라 네트워크 상태에 따라 빈도가 달라진다는 뜻이라, 이미 알고 있던 “트랜잭션 범위가 넓다”는 문제의 심각도 판단 자체가 바뀌었다.

아키텍처 문서·API 명세와 엮이면 문서 간 연결까지 잡힌다

stock-msa는 AI 코딩 에이전트의 환각을 줄이려고 문서를 두 종류로 나눠 관리한다.

  • docs/architecture/*.md — 서비스별로 손으로 쓴 아키텍처 문서. 역할, 기술 스택, 주요 클래스(Controller/Service/Batch/Client)의 실제 메서드 시그니처, 데이터 흐름, 엔티티·테이블을 적어둔다.
  • docs/api-endpoints/*.yaml — 서비스별 OpenAPI 3.1 스펙. 실행 중인 서비스에서 뽑아낸 실제 엔드포인트·파라미터·응답 스키마다.

docs/architecture/README.md에는 이 둘의 신뢰 순서까지 명시돼 있다.

주의: 본 문서는 코드 스냅샷 기준 요약이다. 정확한 엔드포인트/스키마는 항상 docs/api-endpoints/*.yaml(자동 생성)을 우선 신뢰하고, 클래스/메서드 레벨은 실제 소스를 최종 확인한다.

이 문서들에 graphify를 함께 돌리면 소스 코드와 문서가 같은 그래프 안의 노드가 된다(시맨틱 추출 Part B). 그 결과 서비스 하나의 문서만 봐서는 안 보이는 문서 간 연결이 하이퍼엣지로 잡혔다.

  • “Corp-Finance-Price sequential data collection pipeline” — docs/architecture/stock-corp.md, docs/api-endpoints/stock-finance.yaml, docs/api-endpoints/stock-price.yaml 세 파일을 하나의 파이프라인으로 묶었다(EXTRACTED, 신뢰도 0.90).
  • “Gateway JWT verification + header spoof removal + service routing pipeline” — stock-gateway 아키텍처 문서 안의 JWT 필터·보안 설정·라우팅 테이블 세 절을 하나의 흐름으로 묶었다(EXTRACTED, 신뢰도 1.00).

stock-corp 문서를 고치는 사람이 stock-finance·stock-price의 API 명세까지 동시에 펼쳐두고 있을 가능성은 낮다. graphify는 이 문서들을 전부 하나의 그래프에 넣어두므로, 한 문서를 고칠 때 같이 봐야 할 다른 서비스 문서를 하이퍼엣지로 미리 알려준다.

다만 전제가 하나 있다 — 그래프는 문서에 적힌 내용을 그대로 믿는다. docs/architecture/*.md가 실제 코드와 어긋나 있어도 graphify는 둘을 그대로 연결할 뿐 불일치를 잡아내지는 않는다. docs/architecture/README.md가 스스로 “API 명세와 소스가 우선”이라고 못 박아둔 이유이기도 하다 — graphify는 문서들 사이의 연결을 찾아줄 뿐, 문서 자체의 정확성까지 보장하지는 않는다.

God Node로 읽을 순서를 정한다

연결이 가장 많은 노드 상위 10개(BacktestRequest 136개 엣지, BacktestService 124개, StockPriceDto 123개 등)를 “조심할 곳”이 아니라 “복잡한 곳부터 순서대로 읽을 목록”으로 썼다. 이 목록 기준으로 BacktestService, SimulationEngine, TechnicalIndicatorService를 먼저 읽었고, 위 두 사례도 그 과정에서 나왔다.

중복처럼 보이는 신호도 확인이 필요하다

GRAPH_REPORT.md는 “Springdoc Prod-Disable Convention Repeated Across All Services”라는 하이퍼엣지로 7개 서비스의 application.yml에 같은 설정이 반복된다고 잡았고, CLAUDE.md↔AGENTS.md, README.md↔INSTRUCTIONS.md도 Surprising Connection으로 묶었다. 원래 목적(“중복 찾기”)에 들어맞아 보였지만, 확인해보니 둘 다 의도된 구조였다.

  • Springdoc 설정은 docs/rules/SECURITY_INFRA_GUIDE.md에 문서화된 공통 정책(${SPRINGDOC_API_DOCS_ENABLED:false})을 각 서비스가 그대로 따른 것이었다.
  • 문서 쌍은 .claude/rules/·.codex/rules/가 docs/rules/를 가리키는 심볼릭 링크였고, AI 도구별 진입 문서만 분리해둔 설계였다.

두 건 모두 실제 조치로는 이어지지 않았다. 좋아 보이는 신호도 그대로 믿지 않고 확인하는 과정이 필요했다는 뜻이다.

한계 1. 커뮤니티 라벨 중복은 라벨링 스크립트의 한계다

GRAPH_REPORT.md의 Community Hubs 목록에서 “Strategy Utility Methods”가 서로 다른 커뮤니티에 걸쳐 952개 노드에, “Price Utility Methods”가 795개 노드에 반복해서 붙어 있었다. 도메인(Strategy, Price, Trading, Auth …)마다 레이어(Utility, Controller, Test, Config)별로 쪼개진 커뮤니티가 따로따로 잡히면서, 라벨만 훑어서는 어떤 커뮤니티가 실제로 흥미로운지 구분이 안 됐다.

정확히 짚어야 할 게 있다 — 커뮤니티를 묶는 클러스터링 자체는 graphify의 기능이지만, 각 커뮤니티에 이름을 붙이는 라벨링은 별개다. graphify 스킬의 기본 절차(Step 5)는 에이전트가 커뮤니티별 노드 라벨을 보고 직접 2-5단어 이름을 지어주는 방식이고, 커뮤니티가 200개가 넘어가면 이걸 사람이 일일이 손으로 붙이기 어렵다. 이번 stock-msa 작업에서는 세션이 다수결 기준의 후처리 휴리스틱 라벨러를 별도로 짜서 자동화했는데, 라벨 중복은 이 휴리스틱의 한계였지 graphify의 클러스터링 알고리즘이 잘못 묶은 게 아니었다. --mode deep으로 재추출해도 이 라벨링 스크립트를 다시 돌리지 않는 한 그대로다(실제로 재추출 후에도 동일하게 남아있었다).

라벨 자체를 믿기보다는 God Node와 Surprising Connection 섹션이 실질적인 탐색 시작점이었다.

한계 2. 대형 코드베이스는 한 번 돌리는 데 토큰 비용이 크다

944개 파일 규모에서 graphify는 실행 시작부터 경고를 낸다.

Large corpus: 944 files · ~550,533 words. Semantic extraction will be expensive
(many Claude tokens). Consider running on a subfolder.

실제로 이번 실행 한 번에 output 토큰만 151만 개가 들었다(input 0, 캐시 히트 없는 첫 풀빌드 기준). 개인 프로젝트 규모에서는 무시할 수 없는 비용이라, 전체를 한 번에 돌리기보다 서비스 모듈 단위로 쪼개거나 --update로 변경분만 재추출하는 편이 낫다.

한계 3. 중단된 세션은 임시 파일을 그대로 남긴다

graphify-out/을 확인하는 과정에서 .graphify_chunk_01.json, .graphify_chunk_04.json, .graphify_chunk_05.json 같은 중간 산출물이 GRAPH_REPORT.md보다 몇 시간 늦은 타임스탬프로 남아있는 것을 봤다. 정상적으로 끝난 실행이라면 Step 9에서 이런 임시 파일을 정리하는데, 정리가 되지 않은 채 디렉터리에 쌓여 있었다.

정확한 원인까지는 확인하지 못했다 — 세션이 중간에 끊겼는지, 업데이트가 아직 진행 중이었는지는 추정이다. 다만 관찰된 사실은, --update나 재실행 도중 세션이 끊기면 graphify-out/에 완료되지 않은 청크 파일이 그대로 남을 수 있다는 것이고, 이 상태에서 GRAPH_REPORT.md만 보면 실제 그래프 상태와 리포트가 어긋나 있을 수 있다는 점이다.

한계 4. AST 정적 분석은 런타임에 자동으로 연결되는 관계를 놓친다

가장 크게 부딪힌 한계다. 처음 목적이 “그래프에서 저사용 노드를 찾아 정리 후보로 삼는 것”이었는데, 실제로 걸러낸 후보 중 하나가 오탐이었다.

UserLoginLockRepositoryImpl: 놓친 건 호출 엣지 하나였다

그래프에서 incoming edge가 0인 클래스 13개(UserLoginLockRepositoryImpl 포함)를 1차로 정리 후보로 뽑았다. grep으로 소스 전체를 훑어 13개 모두 어딘가에 텍스트로 등장한다는 걸 확인하고, 이 1차 스크리닝에서는 전부 후보에서 제외했다.

이후 “저사용(실제 참조 1~2회)” 등급을 매기며 다시 grep했는데, 이번에는 자기 자신의 선언부와 jacoco 커버리지 리포트를 뺀 “진짜 소스 참조”만 셌다. 그 결과 UserLoginLockRepositoryImpl만 유일하게 진짜 소스 참조가 0건이었다 — 나머지 12개는 최소 1건씩 있었다. 그래프 신호(0 incoming edge)를 곧바로 믿지 않고 두 단계로 grep 교차검증을 했기 때문에 걸러진 사례다.

graph.json을 직접 덤프해서 확인한 결과는 다음과 같았다.

  • UserLoginLockRepositoryImpl --implements--> UserLoginLockRepository (EXTRACTED) — 정상 존재
  • UserRepository --inherits--> UserLoginLockRepository(UserRepository extends JpaRepository<User,Long>, UserLoginLockRepository 선언에서 추출) — 정상 존재
  • AuthService.login()에서 나가는 엣지 14개 중, 같은 줄 근처의 userRepository.findByUsername(...)은 calls 엣지로 잡혔지만 userRepository.recordFailedLogin(...)은 잡히지 않았다. 둘 다 같은 필드(userRepository) 뒤에 점을 찍어 메서드를 호출하는 형태(필드 체이닝 호출)인데, 하나는 잡고 하나는 놓친 일관성 없는 누락이다.

실제 코드 패턴은 Spring Data JPA의 커스텀 레포지토리 프래그먼트 컨벤션이다.

// UserLoginLockRepository.java — 커스텀 프래그먼트 인터페이스
public interface UserLoginLockRepository {
    int recordFailedLogin(Long userId, ...);
}

// UserRepository.java — JpaRepository + 커스텀 프래그먼트 합성
public interface UserRepository extends JpaRepository<User, Long>, UserLoginLockRepository { ... }

// UserLoginLockRepositoryImpl.java — {프래그먼트 인터페이스명}+Impl, @Repository
@Repository
public class UserLoginLockRepositoryImpl implements UserLoginLockRepository { ... }

AuthService는 userRepository.recordFailedLogin(...)을 호출하고, userRepository는 UserRepository 타입이다. 실제 실행 시점에는 Spring이 기동할 때 classpath 스캔으로 UserLoginLockRepositoryImpl을 찾아 그 프래그먼트의 구현체로 연결한다(repositoryImplementationPostfix 기본값 Impl). 소스 어디에도 Impl 클래스명이 명시적으로 등장하지 않는다.

이 사례에는 성격이 다른 것 세 가지가 섞여 있어 헷갈리기 쉽다 — 정확했던 부분 하나와, 원인이 다른 한계 두 가지다.

  1. 인터페이스·상속·구현 관계(정적 Java 문법)는 전부 정확히 잡혔다. 그래프 자체는 틀리지 않았다.
  2. 그런데도 recordFailedLogin 호출 엣지 하나를 놓쳐서 Impl 노드가 고립되어 보였다. 이건 필드 체이닝 호출 인식의 평범한 AST 한계다. 같은 코드베이스의 SimulationEngine.calculateDailyReturn() → priceClient.getPriceByDate() 호출 누락도 같은 유형이었다 — 다른 파일에서도 재현되는, 우연이 아닌 패턴이다.
  3. 설령 그 호출 엣지가 잡혔더라도, Impl이 어떤 메커니즘으로 실행에 연결되는지(스프링의 프래그먼트 자동탐지)는 AST가 원천적으로 알 수 없는 영역이다. tree-sitter는 스프링의 프레임워크 시맨틱을 모른다.

“스프링 어노테이션 기반 와이어링이라 그래프가 못 잡는다”로 뭉뚱그리면 부정확하다. 실제로 걸린 건 2번이고, 3번은 별개로 항상 존재하는 구조적 한계다. @Autowired/@Qualifier 빈 주입, @Scheduled/JobRegistry가 이름 문자열로 잡을 배치 잡도 같은 이유(3번)로 그래프에 나타나지 않는다.

--mode deep으로 해결되지 않는다

.java 코드 파일은 Part A(AST, tree-sitter 결정적 파싱)로만 처리되고, --mode deep이 바꾸는 건 Part B(문서·논문·이미지의 LLM 시맨틱 추출 프롬프트)뿐이다. 코드 파일은 애초에 Part B를 타지 않으므로, 이 케이스는 --mode deep으로 재추출해도 그대로 남는다는 게 구조상의 예상이었다.

실제로 --mode deep 재추출을 끝낸 뒤 graph.json을 다시 덤프해서 확인했다. AuthService.login()의 엣지 14개 중 recordFailedLogin 관련 엣지는 재추출 후에도 0건이었다 — 예상대로 그대로였다. “아직 개선 중”이 아니라 “이 플래그로는 원천적으로 바뀌지 않는 종류의 문제”라고 쓰는 게 이론과 실측 모두에서 정확하다.

대형 코드베이스 병렬 파싱은 호출을 산발적으로 놓친다

대형 코드베이스를 5–10개 worker로 병렬 파싱하면서 필드 체이닝, 제네릭, 람다 내부 호출 같은 edge case가 위 사례처럼 산발적으로 누락되는 트레이드오프가 있다. 파일 전체가 안 잡히는 게 아니라, 같은 파일·같은 메서드 안에서도 호출 하나만 골라서 놓치는 식이라 예측하기 어렵다.

--mode deep 재추출 전후를 비교한다

위에서 나온 한계들이 --mode deep으로 재추출하면 나아지는지 실제로 돌려서 확인했다.

항목 shallow(최초) deep(재추출)
노드 7,258 7,603
엣지 28,466 29,038
커뮤니티 291 313
output 토큰 1,511,688 확정 못 함(아래 참고)
EXTRACTED / INFERRED / AMBIGUOUS 미측정 25,973 / 3,059 / 6
INFERRED 비율(전체 엣지 중) — 10.53%
INFERRED 평균 confidence — 0.8074

숫자를 그대로 읽으면 안 되는 이유가 몇 가지 있다.

  • shallow 빌드 시점엔 EXTRACTED/INFERRED 분포를 따로 뽑아두지 않았다. 그래프가 deep 결과로 덮어써진 뒤라 소급 계산이 불가능해서, “deep이 INFERRED 비율을 얼마나 늘렸는지”는 정량 비교를 할 수 없다.
  • 노드·엣지가 늘어난 것(+345 / +572)을 온전히 deep 모드 효과로 보기 어렵다. 재추출 사이에 문서 1개가 코퍼스에 추가됐고, 이 증가분과 deep 모드의 “더 적극적인 INFERRED 생성” 효과가 섞여 있어 분리가 안 된다.
  • deep 빌드의 output 토큰 수치는 산출물마다 다르다. cost.json에는 deep 런이 1,061,817로 기록돼 있는데, 같은 시각에 다시 쓰인 GRAPH_REPORT.md(내부 병합 버그를 고친 재실행 직후 생성)에는 1,411,817로 찍혀 있다. 둘 다 같은 .graphify_extract.json을 읽게 되어 있는데 값이 다르다는 건, 그 중간 파일이 재실행 사이에 바뀌었다는 뜻이다. 원인 파일은 정리 단계에서 지워져 지금은 재구성할 수 없다. 그래서 이 실행의 deep 토큰 비용은 “1,061,817과 1,411,817 중 하나”라고만 쓸 수 있다.
  • input 토큰은 세 번의 실행 모두 0으로 찍혀 있는데, 이건 실측값이 아니다. GEMINI_API_KEY가 없으면 시맨틱 추출을 호스트 에이전트(서브에이전트)가 대신 수행하는데, 이 경로에서는 graphify가 입력 토큰을 직접 셀 수 없다. 스킬 지침도 “청크 JSON에는 일단 0을 넣어두고, 호스트가 Agent 결과의 usage 필드를 읽어 나중에 되써넣으라”고 되어 있다 — 즉 이 토큰 수치들은 도구가 계측한 값이 아니라 호스트 에이전트가 수기로 기입한 값이다. 위에서 같은 런의 output 토큰이 두 가지로 갈린 것도 이 수기 기입 경로의 부작용으로 보인다.

결국 이번 실행 전체(최초 빌드 + deep + 병합 버그 보정 재실행)에 걸쳐 cost.json이 누적한 output 토큰은 2,573,505다. 이 누적치는 근거가 있지만, “deep 모드만 얼마”처럼 쪼갠 숫자는 이번 실행에서는 신뢰할 수 없다.

앞서 짚은 두 한계는 이 재추출로도 그대로였다.

  • AuthService.login() → recordFailedLogin 호출 엣지: 재추출 후에도 0건. 이론대로 Part A(코드 AST)는 --mode deep의 영향권 밖이었다.
  • 커뮤니티 라벨 중복(“Strategy Utility Methods” 952개 노드, “Price Utility Methods” 795개 노드): 그대로. 애초에 graphify의 클러스터링이 아니라 이 세션이 짠 후처리 라벨링 스크립트의 한계였으니, --mode deep과는 무관하다.

결국 --mode deep이 바꾼 건 문서·이미지 쪽 시맨틱 추출의 적극성(INFERRED 엣지 생성)이었고, 이 글에서 다룬 코드 파싱 한계·라벨링 한계는 애초에 이 플래그의 적용 범위 밖이었다.

그래프 신호는 교차검증을 거쳐야 판단 근거가 된다

이번 조사에서 실제로 효과가 있었던 절차는 “그래프 신호를 최종 판단으로 쓰지 않고, 항상 grep으로 소스 텍스트를 교차검증한다”였다. incoming edge 0은 후보를 좁히는 필터로는 유용했지만, 13개 후보 중 12개가 오탐이었던 걸 감안하면 단독 근거로는 신뢰도가 낮다. 리팩토링·죽은 코드 판단처럼 실수 비용이 큰 작업에는 그래프를 “탐색 범위를 좁히는 1차 필터”로, grep이나 IDE의 Find Usages를 “최종 확인”으로 쓰는 2단계 절차가 맞다.

그래프 산출물을 관리하는 방법

graphify-out/에는 graph.json·GRAPH_REPORT.md·graph.html 외에 manifest.json, cache/, cost.json처럼 매 실행마다 남는 부산물이 쌓인다. 무엇을 지키고 무엇을 지워도 되는지는 용도에 따라 갈린다.

캐시와 매니페스트는 지우면 안 된다

manifest.json은 파일별 해시를 기록해 --update가 변경된 파일만 골라내는 근거고, cache/는 문서·이미지의 시맨틱 추출 결과를 프롬프트 버전과 함께 저장해 같은 파일을 다시 LLM에 태우지 않게 한다. 이 둘을 지우면 다음 --update가 코퍼스 전체를 처음부터 다시 추출한다 — 한계 2에서 다룬 토큰 비용이 매번 다시 발생한다는 뜻이다. graph.html이나 GRAPH_REPORT.md는 언제든 재생성되므로 지워도 되지만, manifest.json과 cache/는 재추출 비용을 결정하는 핵심 상태다.

커밋 여부는 크기와 협업 방식에 따라 정한다

graphify는 그래프를 버전관리에 포함시키는 걸 전제로 한 도구도 갖고 있다. graphify merge-driver는 여러 브랜치에서 각자 갱신한 graph.json의 충돌을 union-merge로 처리한다.

다만 이건 확인된 사실이 아니라 판단이다 — stock-msa 기준 graphify-out/ 전체가 52MB, graph.json 하나가 19MB다. 이 정도 크기의 JSON은 커밋할 때마다 통째로 바뀌어 diff가 의미 없고 레포 용량만 불린다. 개인 프로젝트 규모라면 graphify-out/을 .gitignore에 넣어 로컬·CI 캐시로만 쓰고, 사람이 읽는 GRAPH_REPORT.md(수십 KB 수준)만 리뷰 기록용으로 커밋하는 절충이 낫다고 본다. 여러 사람이 동시에 그래프를 갱신하며 공유해야 하는 팀 레포라면 merge-driver 활용이 의미가 있을 것이다.

자동 갱신은 post-commit 훅에 맡길 수 있다

graphify hook install은 커밋 후 자동으로 변경된 코드 파일만 재추출해 graph.json과 GRAPH_REPORT.md를 갱신하는 post-commit 훅을 심어준다. 문서·이미지 변경은 훅이 잡지 않으므로 --update를 따로 돌려야 한다. 한계 3에서 다룬 “중단된 세션이 임시 파일을 남기는” 문제는 이 훅으로 자동화하든 수동으로 돌리든 똑같이 남는 위험이라, graphify-out/에 오래된 .graphify_chunk_*.json이 있는지는 그래프를 신뢰하기 전에 한 번씩 확인하는 편이 낫다.

정리

graphify는 대형 코드베이스를 훑을 때 “어디서부터 봐야 할지”를 좁혀주는 지도로는 쓸모가 있었다. 다만 이번 목적이었던 리팩토링 판단에는 그래프 하나만으로 부족했다. AST가 정적 문법 관계는 정확히 잡아도, 스프링처럼 컨벤션(이름 규칙)이나 리플렉션(실행 중에 클래스를 뒤져 찾는 방식)으로 코드를 자동 연결하는 프레임워크의 런타임 연결은 원천적으로 못 보고, 같은 파일 안에서도 필드 체이닝 호출을 산발적으로 놓친다. 두 한계는 원인이 다르고 대응도 다르다 — 전자는 애초에 그래프가 알 수 없는 영역이라 사람이 보완해야 하고, 후자는 grep 같은 텍스트 검증으로 걸러낼 수 있다.

--mode deep으로 재추출해봐도 이 두 한계는 바뀌지 않았다. 이 플래그가 손대는 범위는 문서·이미지의 시맨틱 추출이지, 코드 AST 파싱이나 커뮤니티 라벨링이 아니기 때문이다. 코드 위주 프로젝트에서 그래프를 리팩토링 판단에 쓰려면 플래그를 바꾸는 것보다, 그래프가 낸 후보를 grep이나 IDE로 반드시 교차검증하는 절차를 먼저 두는 편이 실익이 컸다.