-
LSP를 메모리 안으로 — jedi를 통한 초고속 함수 단위 CALLS 그래프 구축IT 2026. 6. 3. 23:00
앞서 Stage A에서 tree-sitter를 사용해 코드의 뼈대(함수명, 임포트)를 세웠다. 하지만 "함수 A가 함수 B를 호출한다"는 실질적인 의존성 관계는 단순 구문 분석만으로 알 수 없다. 파일 안에 단순히
bar()라는 호출 구문이 있더라도, 그bar가 이 파일 안의 함수인지, 임포트된 라이브러리의 함수인지, 혹은 동명이인의 전혀 다른 모듈 소속인지 정적으로 해결하기(Resolve) 어렵기 때문이다.이를 해결하려면 심볼 테이블과 스코프를 뒤져 의미를 해석하는 Semantic Resolution이 필요하다. 일반적으로 VS Code 같은 IDE는 이를 위해 백그라운드에 무거운 LSP(Language Server Protocol) 데몬을 띄워 통신한다. 하지만 수십 개의 프로젝트를 배치(Batch) 모드로 스캔해야 하는 nightly 파이프라인에서 매번 LSP 서버를 띄우고 JSON-RPC로 통신하는 것은 막대한 인프라 리소스와 지연 시간을 소모한다.
deep-wikiStage B는 이에 대한 완화책으로 jedi를 단일 프로세스 내에서 라이브러리로 사용하는 길을 택했다.1. 왜 jedi인가: 가볍고 빠른 in-process 분석
jedi는 파이썬에서 자동완성 및 코드 정적 분석을 제공하는 대표적인 라이브러리다. 대개의 정적 분석 도구가 LSP daemon 형태(
pylsp등)로 동작하여 데몬 수명 주기 관리 및 소켓 네트워킹 비용을 요구하는 반면, jedi는 다음과 같은 차별점을 제공한다.- In-process Library: 별도의 백그라운드 프로세스나 도커 컨테이너를 띄우지 않고, 단순
import jedi를 통해 파이썬 스크립트 내부에서 즉시 구동할 수 있다. - Lightweight: 디스크 용량이 15MB 남짓하며 CPU 리소스만 사용하기 때문에, GPU를 선점해야 하는 무거운 LLM 추론 과정(Stage D)과 리소스 충돌 없이 병렬로 실행할 수 있다.
- Narrow Queries: 파일 전체를 복잡하게 색인하지 않고도, 특정 파일의 특정 좌표(
line,column)를 찝어 정의(goto)나 레퍼런스(get\_references)를 조회할 수 있다.
2. 작동 원리: 심볼의 실체 추적
Stage B는 Stage A가 이미 발라낸 스켈레톤 정보를 기초로 동작한다. 가장 먼저 저장소 루트로
jedi.Project를 한 번만 설정해sys.path와 virtual env 같은 심볼 해석의 토대를 잡는다. 이 프로젝트 컨텍스트를 공유한 채, 무식하게 소스 파일을 전부 다 긁는 것이 아니라 "특정 함수가 정의된 위치"를 타겟 삼아 함수마다jedi.Script로 쿼리를 보낸다.jedi는 지정된 파이썬 파일의 특정 줄에 있는 심볼을 찾은 뒤, 프로젝트 전체(
jedi.Project)의 스코프 분석을 통해 이 심볼이 참조되는 모든 소스 좌표(get\_references)를 가져온다. 그 좌표가 다른 함수 정의 영역 안에 속한다면 두 함수 사이에CALLS엣지를 연결하여 완벽한 함수 단위 호출 그래프를 완성한다.3. Stage B 핵심 구현 예시
extractor/stage_b.py에 포함된 jedi 호출 및 호출 그래프(CALLS) 빌드의 핵심 파트다.import jedi from pathlib import Path def extract_calls_for_repo(project_root: Path, functions_metadata: list[dict]): # 프로젝트 환경 설정 (sys.path 해결을 위해 root 디렉터리 바인딩) project = jedi.Project(str(project_root)) calls_edges = [] for fn in functions_metadata: src_path = project_root / fn["rel_path"] if not src_path.exists(): continue try: # 1-based line과 0-based column으로 Script 초기화 script = jedi.Script(path=str(src_path), project=project) # 해당 함수의 정의 시작줄(def 키워드 부분)에서 레퍼런스 조회 refs = script.get_references(line=fn["line"], column=fn["col"]) for r in refs: # 레퍼런스가 선언부가 아닌 실제 호출부(is_trigger)인지 확인 if r.is_trigger: # r.module_path 및 r.line 정보를 기반으로 caller 식별 caller = find_enclosing_function(r.module_path, r.line) if caller: calls_edges.append({ "caller": caller, "callee": fn["name"], "kind": "CALLS" }) except Exception as exc: # jedi가 실패하더라도 파이프라인이 중단되지 않도록 warning 처리 print(f"Jedi resolve error on {src_path}:{fn['line']}: {exc}") return calls_edges4. 우리 프로젝트에서의 효과와 가치
jedi를 적용한 Stage B 파이프라인은 우리 프로젝트에서 즉각적인 성과를 보였다.
첫째, 100배 이상의 압도적인 속도 성능이다. 처음 Stage B를 설계할 때 저장소 하나당 수분이 소요되어 22개 저장소를 모두 돌리면 30분 이상 걸릴 것으로 예측했다. 그러나 jedi를 인프로세스로 직접 호출하고 Stage A 스켈레톤의
file:line좌표를 활용해 탐색 영역을 최소화한 결과, 22개 저장소의 3,095개 호출 관계(CALLS)를 단 19.4초 만에 추출했다.둘째, 엄격한 신뢰 기준의 충족이다.
deep-wiki의 RAG 지식 베이스는 에이전트의 오염을 막기 위해 철저히 false positive를 배제한다. jedi는 dynamic dispatch(예:getattr, dynamic decorator)나 복잡한 메타프로그래밍으로 호출 경로를 확신할 수 없으면 엣지를 그리지 않고 건너뛴다. 이로 인해 생기는 일부 누락(false negative)은 허용하지만, 잘못된 호출 관계를 가르쳐주지 않으므로 에이전트에게 안전한 지식만 제공한다.5. 향후 확장: 수천 개 저장소로 늘면
지금은 22개 저장소를 19.4초에 처리하니 매일 밤 전부 다시 돌려도 부담이 없다. 하지만 대상이 수백, 수천 개로 늘면 이야기가 달라진다. 매일 밤 코드가 실제로 바뀌는 저장소는 보통 한두 개뿐인데, 나머지 수천 개의 CALLS 관계를 똑같이 다시 계산하는 것은 명백한 낭비다. 19.4초가 선형으로 늘어 수 분이 되고, 그 대부분이 "어제와 한 글자도 안 바뀐 코드를 다시 분석하는" 시간이다.
해법은 바뀐 저장소만 다시 계산하고, 안 바뀐 것은 어제 만든 결과를 그대로 재사용하는 것이다. 이런 "안 바뀐 건 건너뛰고 캐시를 재활용하는 판정"을 walk-reuse 게이트라고 부른다. 빌드 도구가 수정 시각을 보고 안 바뀐 파일의 컴파일을 건너뛰는 것과 같은 발상이다.
다이어그램 설명. 왼쪽은 기존 방식 — 저장소 6개가 모두 노란색 "재계산" 상태다. 한 글자도 안 바뀐 저장소까지 매번 jedi로 다시 훑으므로 총비용이 저장소 수에 정비례한다. 오른쪽은 walk-reuse 게이트를 적용한 모습 — 코드가 실제로 바뀐 repo 3만 노란색으로 재계산하고, 나머지 5개는 초록색 "재사용"으로 어제 만든 CALLS 관계를 그래프에서 그대로 들고 온다. 작업량이 "전체 저장소 수"가 아니라 "오늘 바뀐 저장소 수"에 비례하게 바뀌는 것이 핵심이다.
그렇다면 "안 바뀌었다"를 어떻게 판정할까. 저장소의 현재 git 커밋(HEAD)과 추출 로직 자체의 버전 두 가지를 어제 값과 비교한다. 커밋이 그대로면 코드가 안 바뀐 것이고, 추출 로직이 그대로면 "다시 돌려도 같은 결과가 나온다"는 뜻이다. 둘 다 같을 때만 재사용한다. 추출 로직(jedi 호출 코드)을 고치면 버전이 자동으로 바뀌어 모든 캐시가 무효화되므로, 옛 로직이 만든 잘못된 관계가 그래프에 눌러앉는 일을 막는다.
def should_reuse(prev_state, repo, sha, version) -> bool: # 커밋·버전이 같으면 tree-sitter와 jedi를 함께 건너뛴다. return prev_state.get(repo) == (sha, version)규모가 더 커지면 한 가지 카드가 더 있다. Stage A(tree-sitter)와 Stage B(jedi) 모두 분석 범위를 같은 저장소 안으로 제한한다. Stage A는 저장소 내 파일들의 구문만 파싱하고, 임포트 경로에 다른 저장소나 외부 라이브러리가 등장해도 그 소스를 직접 따라가지 않는다. Stage B도 마찬가지로 호출 관계를 같은 저장소 안에서만 해석한다. 덕분에 저장소마다의 분석이 서로 완전히 독립적이다. 즉 저장소 1,000개를 워커 10개에 100개씩 나눠(샤딩) 동시에 돌려도 결과가 달라지지 않는다 — 한 저장소의 CALLS 계산이 다른 저장소를 참조하지 않으니, 합쳐서 돌리든 쪼개서 돌리든 같은 그래프가 나온다. "변경분만 계산"으로 일감 자체를 줄이고, "샤딩"으로 남은 일감을 병렬로 펴면 저장소 수가 늘어도 처리 시간이 따라 늘지 않는다.
여기서 멈춘 지점도 분명히 해 둔다. "바뀐 파일만, 더 나아가 바뀐 함수만" 다시 계산하는 더 잘게 쪼갠 증분도 가능하지만 도입하지 않았다. 한 함수의 호출자가 다른 파일에 흩어져 있어 "무엇을 다시 계산해야 하는가"의 범위가 넓게 번지고, 노드를 식별하는 키에 저장소 커밋이 박혀 있어 커밋이 한 칸만 움직여도 안 바뀐 파일의 노드까지 전부 새 키가 된다. 이 문제는 "수천 개 저장소"가 아니라 "단일 거대 저장소"가 병목일 때 풀 가치가 있어, 별도 과제로 미뤄 두었다.
6. 정리
jedi는 무거운 아웃프로세스 LSP 데몬을 인프로세스 라이브러리로 압축하여 초고속 분석을 보장한다. Stage A의 좌표 안내와 Stage B jedi의 정교한 lookup이 결합함으로써,
deep-wiki는 비용 효율적이고 강력한 함수 레벨 상호참조 인덱스를 완성할 수 있었다. 그리고 "바뀐 저장소만 다시 계산하고 나머지는 재사용"하는 walk-reuse 게이트를 더하면, 이 인덱스는 저장소가 수천 개로 늘어도 매일 밤 가볍게 갱신되는 살아 있는 자산이 된다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
내 코드 위키를 남의 코딩 에이전트가 쓰게 하려면 — SKILL.md 한 장으론 부족하다 (0) 2026.06.05 생성된 위키 문서에서 틀린 줄을 발견했다 — 고치지 말고, 입력을 바꿔라 (0) 2026.06.05 같은 사실도 신뢰 레벨이 다르다 — 코드 위키의 Trust Gradient (0) 2026.06.04 불필요한 껍데기 걸러내기 — vulture를 이용한 데드 코드 탐지 및 지식 정제 (0) 2026.06.04 코드의 어두운 구석을 드러내기 — radon을 통한 순환 복잡도 지표 수집 (0) 2026.06.04 tree-sitter Stage A — 어려웠던 건 빠른 파싱이 아니라 캐시 무효화였다 (0) 2026.06.03 repo마다 5종 자동 페이지 — 단촐한 위키를 결정적으로 채우기 (0) 2026.06.03 repo마다 신뢰도가 다르다 — 4-tier 분류와 2-layer egress allowlist (0) 2026.06.02 기존 pre-push 훅을 깨뜨리지 않고 끼워 넣기 — chain mode와 fail-soft 정책 (0) 2026.06.02 1인 로컬 환경에도 outbox 패턴 — NetworkX·Qdrant·파일 3-way 정합성 (0) 2026.06.01 - In-process Library: 별도의 백그라운드 프로세스나 도커 컨테이너를 띄우지 않고, 단순