
LLM이 코드베이스를 환각하는 것을 막는 방법: 리포지토리를 요약하기 위한 그래프 우선(Graph-First) 방식
요약
LLM이 코드베이스를 요약할 때 발생하는 환각 현상을 방지하기 위해, 정적 분석을 통한 그래프 기반의 사실 집합을 먼저 구축하는 방식을 소개합니다. 원시 코드 대신 Joern을 활용해 생성된 코드 속성 그래프(CPG) 정보를 LLM에 전달함으로써 요약의 정확도를 높이는 패턴을 다룹니다.
핵심 포인트
- LLM의 코드 분석 시 발생하는 통계적 추측과 환각 문제 지적
- 정적 분석을 통해 검증된 사실(Fact-sheet)을 LLM에 전달하는 설계 패턴
- Joern을 활용하여 AST, CFG, 데이터 의존성을 통합한 CPG 구축
- 원시 소스 코드 대신 구조화된 그래프 데이터를 활용한 효율적 요약
1. 우리가 실제로 해결하려는 문제점
어떤 LLM에게 “이 리포지토리를 요약해줘”라고 요청하면, 모델은 기꺼이 응할 것이며 동시에 내용 없는 것을 지어낼 것입니다. 존재하지 않는 테스트 스위트를 언급합니다. 폴더 이름 api/에서 추론한 API 엔드포인트를 설명합니다. 자신이 실제로 추적하지 않은 “데이터 흐름(data flow)”에 대해 자신감 있게 이야기합니다 :(
이유:
LLM은 코드 분석기가 아니라 패턴 완성기입니다. 파일 더미를 컨텍스트 창에 넣고 요약을 요청하면, 모델은 이 코드를 이해하는 것이 아니라 명명 규칙과 자신이 본 수백만 개의 다른 리포지토리에서 얻은 통계적 사전 지식(statistical priors)을 기반으로 추측합니다.
해결책:
code-graph-ai-summarizer는 다른 접근 방식을 취하는 작고 파이썬(Python) 프로젝트입니다: LLM이 원시 코드를 전혀 보지 못하게 합니다.
대신, 실제 정적 분석(static analysis)을 사용하여 리포에 대한 정확하고 구조화된 그래프 기반의 사실 집합(graph-derived set of facts)을 먼저 구축한 다음, 선별된 사실 시트(fact-sheet)만 LLM에게 전달하여 요약을 작성하도록 요청합니다.
이로써 LLM의 임무는 “이 코드베이스를 이해하는 것”에서 “내가 이미 검증한 이 사실들을 서술하는 것”으로 줄어들고, 이는 LLM이 매우 잘 수행할 수 있는 작업입니다.
바로 이 설계 결정 하나가 이 리포지토리 전체 이야기이며, 실제로 이 도구를 사용하게 되든 아니든 배울 가치가 있는 패턴입니다.
2. 핵심 아이디어, 한 그림으로 보기
다섯 단계입니다. 각 단계는 다음 단계가 필요로 하는 것만 전달하고, 원시 소스 코드는 절대 전달하지 않습니다.
기본부터 쌓아 올리면서 각각을 살펴보겠습니다.
3. 1단계: 코드를 그래프로 변환하기 (Joern + CPG)
코드베이스에 대해 추론하려면, 프로그램이 질의할 수 있는 형태의 표현(representation)이 필요합니다.
.py 파일의 문자를 읽는 것만으로는 어떤 함수가 다른 어떤 함수를 호출하는지에 대해 아무것도 알 수 없습니다. 구조가 필요합니다. 바로 이 지점에서 Joern이 등장합니다.
**Joern**은 소스 코드를 **코드 속성 그래프 (Code Property Graph, CPG)**로 파싱하는 오픈 소스 정적 분석 (static analysis) 플랫폼입니다. CPG는 다음과 같은 여러 표현(representation)을 하나로 융합한 단일 그래프 데이터 구조입니다:
- 추상 구문 트리 (Abstract Syntax Tree, AST - 코드가 문자 그대로 무엇을 말하는지)
- 제어 흐름 그래프 (Control Flow Graph, CFG - 작업이 실행되는 순서)
- 데이터 의존성 그래프 (Data Dependence Graph, DDG - 어떤 값이 어디로 흐르는지)
- 호출 그래프 엣지 (Call graph edges - 무엇이 무엇을 호출하는지)
리포지토리가 Joern으로 임포트되면, 이 모든 것들은 CPGQL을 통해 질의할 수 있게 됩니다. CPGQL은 전체 코드베이스를 필터링, 매핑, 순회(traverse)할 수 있는 하나의 거대한 그래프로 취급하는 Scala 기반의 질의 언어입니다.
code-graph-ai-summarizer 리포지토리에서, 해당 연결은 joern/client.py에 구현되어 있습니다:
class JoernRunner:
def __init__(self, server: str) -> None:
self.client = CPGQLSClient(server)
...
JoernRunner는 로컬에서 실행 중인 Joern 서버(joern --server, 기본적으로 localhost:8080에서 리스닝)와 통신하는 cpgqls_client를 감싸는 얇은 래퍼(wrapper)일 뿐입니다.
로컬 폴더를 지정하면 리포지토리를 임포트하며, 그 이후부터는 CPGQL 쿼리를 날릴 수 있습니다. (이 과정은 code-graph-ai-summarizer가 수행하므로 사용자가 직접 할 필요는 없습니다.)
왜 파일당 하나의 AST가 아니라 그래프인가요?
코드베이스에 대한 흥미로운 질문들은 본질적으로 파일 간(cross-file)의 것이기 때문입니다: "이 함수를 호출하는 것이 무엇인가", "이 값이 최종적으로 어디에 도달하는가", "어떤 파일이 가장 중심적인가" 등입니다.
이러한 것들은 단일 파일 파싱 문제가 아니라 그래프 순회(graph-traversal) 문제입니다.
CPG는 전체 리포지토리에 걸쳐 있는 하나의 그래프를 제공하므로, 이러한 질문들을 다룰 수 있게 됩니다.
4. Stage 2: 그래프에 올바른 질문 던지기
CPG 그 자체는 단지 메모리에 놓여 있는 거대한 그래프일 뿐입니다.
그 가치는 해당 그래프를 대상으로 실행하는 구체적인 쿼리(queries)에서 나옵니다.
joern/queries.py는 그중 6개의 쿼리를 정의하고 있으며, 이를 읽어보는 것만으로도 “유용한 정적 분석 도구(static analysis tool)가 코드베이스에 대해 실제로 알아야 하는 것이 무엇인가”에 대한 미니 레슨을 받는 것과 같습니다.
쿼리 및 추출 항목:
files : 리포지토리 내의 모든 소스 파일
methods: 파일, 전체 이름, 시그니처(signature), 라인을 포함한 모든 함수/메서드
types: 선언된 모든 클래스/타입
call_edges: 각 메서드에 대해 호출하는 다른 메서드들 (내부 + 외부)
calls: 코드 텍스트를 포함한 모든 개별 호출 지점(call site)
entry_candidates: 엔트리 포인트(entry points)로 보이는 메서드들
source_sink_calls: 데이터 소스(data sources) 또는 데이터 싱크(data sinks)로 보이는 호출들
entry_candidates
entry_candidates 쿼리는 매우 중요합니다. Python, JS, Go 등 다양한 언어에 걸쳐 “main 함수를 찾아라”라고 말할 수 있는 보편적인 CPGQL 방식은 존재하지 않습니다.
따라서 이 리포지토리는 대신 이름/파일명 휴리스틱(heuristic)을 사용합니다:
val entryRe = "(?i).*(main|run|start|serve|handler|handle|route|controller|command|execute|process|consume|worker|app).*"
cpg.method
...
주목할 만한 또 다른 세부 사항:
joern/client.py는 모든 개별 쿼리를 try/except로 감싸고 있습니다:
for name, query in joern_queries(max_items).items():
try:
facts[name] = self.run_json_query(name, query)
...
만약 하나의 쿼리가 실패하더라도 (예를 들어, 특정 언어 오버레이에 대해 데이터 흐름(data-flow) 쿼리가 지원되지 않는 경우), 파이프라인은 중단되지 않고 단순히 빈 결과를 기록한 뒤 다음으로 넘어갑니다.
5. Stage 3: 가공되지 않은 사실에서 순위가 매겨진 신호로 (Python 분석 레이어)
Joern은 가공되지 않은 리스트를 반환합니다: 모든 파일, 모든 메서드, 모든 호출. 이는 수백 또는 수천 개의 항목이며, LLM에 바로 던져주기에는 너무 많고, 구조화되어 있지 않으며, 노이즈가 너무 심합니다.
여기에서 리포지토리의 analysis/ 패키스가 등장합니다.
이 패키지의 전체 역할은 판단력을 갖춘 압축입니다: 그래프 사실(graph facts)의 홍수를 순위가 매겨지고 라벨이 붙은 작은 신호(signals) 세트로 변환하는 것입니다.
5.1 단 하나의 import 문 없이 코드가 무엇인지 분류하기
analysis/patterns.py는 일반적인 카테고리를 위한 키워드 버킷(keyword buckets)을 정의합니다: api_web, cli, storage_db, filesystem, llm, network, auth, queue_worker.
코드 스니펫:
CATEGORY_PATTERNS = {
"storage_db": [
"sqlite", "postgres", "mysql", "mongodb", "redis", "sqlalchemy",
...
그 후 analysis/classify.py는 이러한 서브스트링(substring) 중 어느 하나라도 호출의 이름/코드/대상 텍스트에 나타나는지 확인합니다.
이는 의도적으로 단순하게 설계되었습니다: 임베딩 (embeddings)도, 머신러닝 (ML) 모델도 사용하지 않고 오직 서브스트링 매칭 (substring matching)만 수행합니다. 이유는 다음과 같습니다: 속도가 빠르고, 디버깅이 가능하며, 언어에 구애받지 않고(language-agnostic), 그 결과값이 최종 정답이 아니라 다운스트림 랭킹 (downstream ranking)과 LLM이 추가로 해석할 신호 (signal)이기 때문에 "충분히 훌륭"하기 때문입니다.
키워드 리스트가 거의 제로에 가까운 비용으로 문제의 90%를 해결할 수 있을 때, 무거운 모델을 찾지 마세요.
5.2 리포지토리의 "중요한" 파일 찾기
analysis/architecture.py는 호출 엣지 (call-edge) 사실들을 파일별 중요도 점수로 변환합니다.
단순화된 로직은 다음과 같습니다:
- 파일이 수행하는 모든 내부 호출(internal call)에 대해 점수를 얻습니다 (무언가를 수행하고 있음).
- 다른 파일들이 해당 파일을 호출할 때 더 많은 점수를 얻습니다 (의존 대상임).
- 파일의 호출이 위의 카테고리 패턴 중 하나와 일치할 때마다 점수를 얻습니다 (스토리지, 네트워크, LLM 등을 다룸).
file_scores[caller_file] += len(internal_callees) + len(external_callees)
...
file_edge_counts[(caller_file, callee_file)] += 1
...
점수에 따라 정렬하면 "중심 파일 (central files)"의 순위 목록을 얻을 수 있으며, 이는 아키텍처적 중요성을 나타내는 저렴하지만 효과적인 대리 지표 (proxy)가 됩니다.
5.3 런타임 흐름 찾기: 추측이 아닌 그래프 탐색
이 부분은 이 리포지토리에서 개념적으로 가장 흥미로운 부분입니다.
analysis/flows.py는 각 엔트리 포인트 (entry-point) 후보에서 시작하여 너비 우선 탐색 (BFS)을 실행하고, 호출 그래프 (call graph)를 따라 앞으로 나아가며 발견되는 모든 경로에 점수를 매깁니다:
entry_point
-> method A 호출
-> method B 호출 ("storage_db"를 다룸)
...
queue = deque([[entry]])
while queue and seen_paths < 80:
path = queue.popleft()
...
각 경로의 점수(analysis/graph.py)는 길이에 따라 보상을 주며, 훨씬 더 강력하게 api_web, storage_db, 또는 llm과 같은 "중요한" 카테고리를 접할 때 보상을 부여합니다:
score = len(path) + 4 * len(set(categories))
important = {"api_web", "storage_db", "filesystem", "llm", "network", "auth", "queue_worker"}
score += 5 * len(important.intersection(categories))
쉽게 말하면:
진입점(entry point)에서 데이터베이스 호출(database call)이나 LLM 호출(LLM call)까지 이어지는 경로는, 단순히 두 개의 유틸리티 함수(utility functions) 사이를 오가는 경로보다 더 "흥미로운" 경로입니다.
이는 단순하지만 효과적인 휴리스틱 (heuristic)입니다.
find_data_flows는 이와 반대되는 구조입니다. 진입점에서 시작하는 대신, 데이터 소스 (data sources) (request, input, argv, env, ...)처럼 보이는 호출에서 시작하여, 데이터 싱크 (data sinks) (write, save, insert, chat, post, ...)처럼 보이는 호출에 도달할 때까지 너비 우선 탐색 (BFS)을 수행합니다.
source: read user input
|
v
...
README에서 명시적으로 언급하고 코드에서도 뒷받침하는 중요한 뉘앙스는 다음과 같습니다: 이것들은 그래프에서 유도된 후보(candidates)일 뿐, 증명된 런타임 트레이스 (runtime traces)가 아닙니다.
Joern은 정적 분석 (static analysis)을 수행하며, 코드를 절대 실행하지 않습니다.
호출 그래프 (call graph)를 통한 BFS 경로는 그럴듯한 흐름 (plausible flow)일 뿐, 보장된 흐름 (guaranteed flow)은 아닙니다.
6. Stage 4: 모든 것을 하나의 팩트 시트 (fact sheet)로 압축하기
위의 모든 분석 결과는 summarization/facts_builder.py에서 하나의 summary_facts 딕셔너리 (dictionary)로 조립됩니다:
def build_summary_facts(repo_path: Path, facts: dict) -> dict:
repo_map = build_repo_map(facts.get("files", []))
architecture = derive_architecture(facts)
...
여기에 포함되지 않은 항목들을 주목하세요:
- 가공되지 않은 소스 코드 (raw source code),
- 전체 호출 목록 (full call lists),
- 리포지토리 내의 모든 메서드 (every method in the repo).
important_symbols는 이미 식별된 "중심 파일 (central files)"에 존재하는 메서드/타입으로만 의도적으로 필터링됩니다. 이는 최종적인 LLM 프롬프트 (prompt)를 작고 집중력 있게 유지하기 위한 또 다른 압축 단계입니다.
리포지토리 자체가 아니라, 바로 이 딕셔너리가 LLM이 실제로 보게 될 내용입니다.
7. Stage 5: 제안이 아닌 계약처럼 프롬프트 작성하기 (Writing the prompt like a contract, not a suggestion)
summarization/prompts.py
이 단계는 최종 프롬프트 (prompt)를 생성합니다. 단순히 모델이 잘 작동하기를 바라는 것이 아니라, 어떻게 LLM을 제약(constrain)할 수 있는지 보여주기 때문에 주의 깊게 읽어볼 가치가 있습니다.
return f"""
You are generating a repository summary using Joern Code Property Graph facts.
...
몇 가지 중요한 세부 사항:
- 폐쇄 세계 지침 (Closed-world instruction): "제공된 사실(facts)만 사용하세요" + "X, Y, Z를 지어내지 마세요"는 모델에게 줄 수 있는 가장 영향력 있는 환각 방지 (anti-hallucination) 지침입니다. 환각을 완전히 제로로 만들 수는 없지만, 작고 정확한 사실 명세서 (fact-sheet)와 결합하면 모델이 엉뚱한 방향으로 벗어날 여지를 극적으로 좁힐 수 있습니다.
- 교정된 언어 (Calibrated language)는 선택이 아닌 필수: 모델이 단정적으로 주장하는 대신 "가능성이 높음 (likely)" 또는 "감지되지 않음 (not detected)"라고 말하도록 강제함으로써, 모델의 확신도를 독자가 확인 가능한 가시적인 신호로 전환합니다.
- 고정된 출력 스키마 (A fixed output schema): 정확히 8개의 섹션을 지정함으로써 출력 결과가 예측 가능해지며, 리포지토리 간에 파싱 (parse), 렌더링 (render) 또는 차이점 비교 (diff)를 수행하기 쉬워집니다. 시간이 지남에 따라 요약본을 비교하거나 이를 기반으로 UI를 구축하려는 경우 매우 유용합니다.
- 명시적인 "감지되지 않음 / 알 수 없음 (Not Detected / Unknown)" 섹션: 대부분의 프롬프트는 모델에게 무엇을 알고 있는지 묻지만, 이 프롬프트는 무엇을 모르는지도 명시하도록 요청합니다. 이는 신뢰도 (trustworthiness) 측면에서 작은 변화지만 엄청난 효과를 가져옵니다.
llm/client.py
그 다음 llm/client.py가 지루하지만 중요한 작업을 수행합니다. 이는 Groq, OpenRouter, Gemini의 OpenAI 호환 엔드포인트(OpenAI-compatible endpoint), 또는 Cerebras와 같이 OpenAI와 호환되는 모든 엔드포인트에서 작동하는 얇은 OpenAI-SDK 래퍼 (wrapper)이며, 순수하게 .env 설정을 통해 제어됩니다.
LLM_PROVIDER=groq
LLM_API_KEY=your_api_key_here
LLM_MODEL=llama-3.3-70b-versatile
def generate_repo_summary(summary_facts: dict, config: LLMConfig) -> str:
client = make_client(config)
response = client.chat.completions.create(
...
8. 모두 합치기: 실행 시 실제로 일어나는 일
uv run code-graph-ai-summarizer /path/to/local/repo
cli/main.py 파일의 run() 함수를 처음부터 끝까지 따라가기:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기