AI 에이전트가 맹목적으로 grep을 수행하지 않도록 만든 방법: 코드 그래프(Code Graph) 구축하기
요약
AI 코딩 에이전트가 코드베이스 구조를 파악하기 위해 반복적으로 grep을 사용하는 비효율성을 해결하기 위해 코드 그래프를 구축하는 방법을 소개합니다. 단순 텍스트 검색의 한계를 넘어 함수와 클래스 간의 관계를 구조적으로 파악하여 리팩터링의 정확도를 높이는 것이 핵심입니다.
핵심 포인트
- grep 기반 검색은 토큰 낭비와 호출 지점 누락 문제를 야기함
- 코드 그래프를 통해 함수와 클래스를 노드로, 호출/임포트를 엣지로 구성
- 정적 분석을 통해 에이전트에게 구조적 쿼리 제공 필요
- 리팩터링 시 별칭이나 재내보내기(re-export) 문제를 해결하기 위한 필수 전략
요약 (TL;DR)
저의 AI 코딩 에이전트는 매 세션마다 코드베이스의 구조를 처음부터 다시 발견하곤 했습니다. 여기저기 grep을 돌리고, "확인 차원에서" 파일을 열어보고, 때로는 리팩터링(refactor) 중에 호출 지점(call site)을 놓치기도 했습니다. 저는 작은 코드 그래프(함수와 클래스를 노드로, 호출과 임포트(import)를 엣지(edge)로 구성)를 구축하고, 에이전트에게 가공되지 않은 grep 대신 몇 가지 타겟팅된 쿼리(query)를 제공했습니다. 무엇이 효과적이었고, 무엇이 망가졌으며, 제가 다르게 했을 방식은 무엇인지 소개합니다.
문제점
저는 대부분의 날에 중간 규모의 코드베이스(수백 개의 파일, 여러 서비스)를 대상으로 Claude Code를 실행합니다. 오랫동안 모든 세션은 동일한 방식으로 시작되었습니다. 에이전트는 심볼(symbol)을 찾기 위해 grep을 실행하고, "안전을 위해" 서너 개의 파일을 열고, 약간 다른 패턴으로 다시 grep을 수행한 뒤, 마침내 요소들이 어떻게 연결되어 있는지에 대한 정신적 모델(mental model)을 구축했습니다. 하지만 세션이 종료되는 순간 그 모델은 버려졌습니다.
이러한 재발견 비용(rediscovery tax)은 두 가지 방식으로 나타났습니다:
- 토큰 및 시간 낭비. "이 함수 이름을 변경하고 모든 호출 지점을 업데이트하라"와 같은 작업은 에이전트가 편집을 시작하기도 전에 5~6번의 grep-및-읽기(grep-and-read) 과정을 거쳐야 했습니다.
- 호출 지점 누락. grep은 텍스트 검색이지 구조적 검색이 아닙니다. 별칭(alias), 재내보내기(re-export), 간접 참조(indirection)를 통한 호출을 놓칩니다. 두 번이나 "안전한" 이름 변경 작업이 grep이 찾아내지 못한 깨진 호출자와 함께 배포되었고, 저는 CI에서야 이를 발견했습니다.
두 번째 문제가 저를 실제로 이 문제를 해결하도록 몰아붙였습니다. "이 함수를 누가 호출하는가"를 안정적으로 알려줄 수 없는 도구는, 감독 없이 리팩터링을 맡기기에 안전하지 않습니다.
이것이 타협할 수 없는 문제가 된 구체적인 사건이 있었습니다. 저는 에이전트에게 몇몇 모듈 전반에서 사용되는 헬퍼 함수(helper function)의 이름을 변경하라고 요청했습니다. 에이전트는 grep을 수행하여 4개의 호출 지점(call sites)을 찾아냈고, 4개 모두를 업데이트한 뒤, 편집 중인 모듈에 대한 로컬 테스트를 실행하고 성공했다고 보고했습니다. 하지만 20분 후 CI(지속적 통합)가 실패했습니다. grep 패턴이 일치하지 않았던 모듈 내의 re-export(재내보내기) 뒤에 다섯 번째 호출 지점이 숨어 있었기 때문입니다. 해당 임포트(import)가 다른 로컬 별칭(local alias)을 사용하고 있었습니다. 에이전트가 가진 도구들을 고려했을 때, 에이전트의 프로세스 자체에는 아무런 잘못이 없었습니다. 단지 grep이 "이것을 누가 호출하는가"를 파악하기에는 적절한 도구가 아니었을 뿐이며, 아무리 영리한 정규 표현식(regex)을 사용하더라도 그러한 유형의 실수를 해결할 수는 없습니다.
또한, 이것이 완전히 새로운 아이디어는 아니라는 점을 미리 밝혀둡니다. IDE(통합 개발 환경)에는 이미 20년 동안 "참조 찾기(find references)" 기능이 있었고, 언어 서버(language servers)는 이미 인간을 위해 이 기능을 수행하고 있습니다. 흥미로운 지점은 개념 자체가 아니라, 에이전트가 습관적으로 grep에 의존하는 대신 기본적으로 이 기능을 사용하도록 만들 만큼 충분히 저렴하고 신뢰할 수 있게 만드는 것이었습니다.
해결 방법
해결책은 거창한 정적 분석(static-analysis) 플랫폼이 아니었습니다. 몇 가지 구체적인 질문에 잘 답할 수 있는 작고 평범한 인덱스(index)였습니다.
1단계: 소스 코드를 단순한 텍스트가 아닌 구조적 인덱스로 파싱하기.
저는 tree-sitter를 사용하여 각 파일을 AST(추상 구문 트리)로 파싱하고, 탐색에 중요한 요소들인 함수 및 클래스 정의, 해당 위치, 그리고 그 내부의 호출(call) 및 임포트(import) 표현식을 추출했습니다. 단순화하면, 추출 과정은 다음과 같습니다:
from tree_sitter import Parser
from tree_sitter_languages import get_language
...
호출과 임포트 역시 동일한 방식으로 추출되며, 해당 요소가 포함된 상위 함수를 키(key)로 가집니다. 이것이 전체 그래프입니다. 노드(node)는 심볼(symbol)이고, 엣지(edge)는 "호출(calls)" 및 "임포트(imports)"입니다.
2단계: 쿼리가 가능한 곳에 저장하고, 증분(incremental) 방식으로 만들기.
첫 번째 버전은 실행할 때마다 전체 그래프를 다시 구축했습니다. 파일이 수백 개 정도일 때는 괜찮았지만, 더 큰 모노레포(monorepo)를 대상으로 지정하자 감당할 수 없는 수준이 되었습니다. 에이전트가 무엇인가를 수행하기 전에 40초 이상이 걸렸습니다. 해결책은 각 파일의 콘텐츠를 해싱(hashing)하고, 추출된 심볼(symbols)과 함께 해시를 저장한 뒤, 해시가 변경된 파일만 다시 파싱(re-parse)하는 것이었습니다.
import hashlib
def needs_reindex(file_path: str, stored_hashes: dict) -> bool:
...
이렇게 함으로써 전형적인 단일 파일 수정의 경우 전체 재구축 시간을 1초 미만으로 단축할 수 있었습니다. 변경된 파일(및 그 직계 이웃 파일)만 다시 파싱하면 되기 때문입니다.
한 가지 까다로운 점은 파일 해싱만으로는 충분하지 않다는 것이었습니다. 파일 A가 변경되면, 다른 곳에서 파일 A를 가리키는(points into) 모든 엣지(edge)는 여전히 유효하지만, 파일 A 내부에 있는 모든 엣지는 삭제하고 다시 구축해야 합니다. 저는 결국 소스 파일(source file)을 키(key)로 하여 엣지를 저장하도록 구현했습니다. 따라서 무효화(invalidation) 과정은 단순히 "source_file = A인 모든 엣지를 삭제하고, A를 다시 추출한 뒤, 다시 삽입한다"가 됩니다. 비용이 저렴하며, 파일 크기가 줄어든 후에도 오래된(stale) 엣지가 실수로 남아있는 것을 방지할 수 있습니다.
3단계: 쿼리 언어가 아닌, 쿼리를 노출하기.
저의 첫 번째 본능은 에이전트에게 일반적인 그래프 쿼리 인터페이스를 제공하는 것이었습니다.
graph LR
A[Agent] -->|find_callers / trace_call_chain| B[Query Layer]
B --> C[Code Graph: nodes + edges]
...
Lessons Learned (배운 점)
1. Grep-first 에이전트는 매 세션마다 재발견 비용(rediscovery tax)을 지불합니다. 지속적인 구조가 없다면, 에이전트는 어제
5. 진짜 ROI(투자 대비 수익)는 속도가 아니라 리팩터링(refactor) 중의 정확성이었습니다. 저는 승리 요인이 "grep에 소비되는 토큰이 줄어드는 것"일 것이라 예상했습니다. 하지만 더 큰 승리는 질적인 측면에서 나타났습니다. 이제 수많은 호출 지점(call sites)을 건드리는 리팩터링을 수행할 때, 실제로 그 모든 지점을 찾아냅니다. 이것은 단순히 이름을 변경(rename)하여 배포하는 것과, 이름 변경을 했으나 두 파일 떨어진 곳에서 빌드 오류를 일으키며 배포하는 것의 차이입니다.
6. 이것은 유일한 수단이 아니라, 최우선 수단으로 사용할 때 가장 효과적입니다. 쿼리 결과가 비어 있을 때는 여전히 에이전트가 grep을 사용하도록 허용합니다. 때로는 그래프가 실제로 사례를 커버하지 못하는 경우가 있기 때문입니다(문자열 템플릿 기반 호출, 파서(parser)가 아직 지원하지 않는 언어로 정의된 심볼 등). 그래프를 "독점적으로 신뢰해야 하는 것"이 아니라 "이것을 먼저 시도하라"로 취급함으로써, 그래프가 답을 내놓지 못하는 날에도 유용성을 유지할 수 있었습니다.
향후 계획
여전히 고민 중인 몇 가지 사항들이 있습니다:
- 교차 서비스 트레이싱 (Cross-service tracing). 현재 그래프는 프로세스 경계에서 멈춥니다. 한 서비스의 HTTP 핸들러에서 다른 서비스에서 최종적으로 호출하는 함수까지 트레이싱하려면 문자열 기반의 경로 이름을 따라가야 하는데, 이는 tree-sitter만으로는 해결할 수 없습니다.
- 동적 디스패치 (Dynamic dispatch) 및 덕 타이핑 (duck typing). 런타임에 결정되는 모든 것(의존성 주입 (dependency injection),
getattr스타일의 디스패치, 인터페이스 기반 다형성)은 정적 AST 탐색(static AST walk)으로는 보이지 않습니다. 저는 모든 동적 패턴을 정적으로 모델링하려고 시도하는 대신, 정확히 이러한 사례들을 위한 폴백(fallback)으로서 가벼운 런타임 트레이스(runtime trace)를 검토하고 있습니다. - 그래프가 모델링할 수 없는 것에 대한 폴백. 그래프가 진정으로 잘 표현할 수 없는 코드의 경우, 단순히 일반 grep으로 조용히 넘어가는 대신 보조적인 검색 경로(의미론적/임베딩 기반 검색)를 구축하고 싶습니다.
마무리
만약 당신의 에이전트가 매 세션의 절반을 코드베이스가 어떻게 구성되어 있는지 재발견하는 데 소비하고 있다면, 작은 구조적 인덱스(structural index)는 빠르게 그 가치를 증명할 것입니다. 그리고 그것이 거창할 필요는 없습니다. 네 개의 쿼리 동사와 해시 기반의 증분 파서(hash-based incremental parser)만으로도 저는 대부분의 가치를 얻을 수 있었습니다.
만약 여러분도 이와 유사한 것을 구축했거나, 정적 그래프 (static graph)에서 동적 디스패치 (dynamic dispatch)를 처리하는 더 나은 방법을 찾았다면, 진심으로 그 이야기를 듣고 싶습니다. 더 많은 빌드 로그 (build logs)를 확인하시려면 이곳 Dev.to에서 저를 팔로우해 주세요. 만약 에이전트 워크플로 (agentic workflows)를 위해 Claude Code를 실험 중이라면, 여러분의 리포지토리 (repo)에서 이 패턴을 직접 시도해 보시기 바랍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기