ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • tree-sitter Stage A — 어려웠던 건 빠른 파싱이 아니라 캐시 무효화였다
    IT 2026. 6. 3. 22:00
    tree-sitter Stage A — 어려웠던 건 빠른 파싱이 아니라 캐시 무효화였다

    코드 위키가 코드베이스의 변화를 추적하기 위해 첫 번째로 해야 하는 일은 코드를 파싱하는 것이다. 어떤 파일에 어떤 함수가 정의되어 있는지, 어떤 모듈이 임포트되었는지를 알아야 위키의 뼈대를 세울 수 있다. 하지만 수십 개의 저장소, 수만 줄의 소스코드를 매일 밤 분석해야 할 때, 전통적인 파싱 방법론은 커다란 장벽에 부딪힌다.

    단순 정규식(Regex)으로 `def func_name`을 매칭하는 방식은 주석 안의 텍스트나 멀티라인 정의를 처리하지 못해 쉽게 깨진다. 그렇다고 소스 언어별 온전한 컴파일러 툴체인을 띄우는 것은 무겁고 느리며, 다언어 환경으로 확장할 때 복잡도가 폭발한다. deep-wiki Stage A는 이 모순을 해결하기 위해 tree-sitter를 도입했다.

    1. 왜 tree-sitter인가: 배경과 한계 극복

    전통적인 AST(Abstract Syntax Tree) 파서는 주로 컴파일러나 인터프리터의 종속물이었다. 이는 코드에 아주 작은 문법 오류만 있어도 파싱을 완전히 중단하거나 에러를 내뿜는다. 또한, 코드 한 줄이 수정되었을 때 파일 전체를 다시 파싱해야 하므로 에디터나 실시간 인덱서 환경에서는 비용이 크다.

    tree-sitter는 이 문제를 해결하기 위해 세 가지 핵심 목표를 가지고 등장했다.

    • Robustness (견고함): 코드가 작성 중이라 문법이 깨져 있거나(Syntax Error) 불완전한 상태에서도 에러로 중단되지 않고, 복구 노드를 심어가며 트리 생성을 마친다.
    • Speed (점진적 파싱): 증분(Incremental) 파싱 알고리즘을 지원하여 소스 파일의 일부분이 변경되었을 때 전체 트리를 다시 그리지 않고 변경 부분만 초고속으로 업데이트한다. (다만 이 증분 능력을 우리가 어떻게 — 그리고 어느 층위에서 — 쓰는지는 4장에서 다룬다. 결론부터 말하면, tree-sitter가 자랑하는 그 증분이 아니다.)
    • Multi-language (다언어 일관성): C로 작성된 런타임 위에 다양한 언어의 문법 정의(Grammar)를 플러그인 형태로 얹어, 여러 언어의 소스코드를 단일한 인터페이스로 파싱할 수 있다.

    2. 작동 원리: 구문 트리에서 스켈레톤 추출

    tree-sitter는 소스코드를 바이트 단위로 읽어 구문 트리(Concrete Syntax Tree)를 생성한다. deep-wiki는 파이썬 바인딩을 사용해 구문 트리를 순회하면서 필요한 노드만 낚아챈다.

    diagram

    구문 트리 안에서 function_definition 타입을 만나면 그 아래 정의된 함수 이름과 실제 줄 번호(시작 라인과 끝 라인)를 가져온다. import_statementimport_from_statement 노드를 만나면 의존하는 모듈 정보를 추출한다. name resolution 단계(Stage B)를 얹기 전, 단순히 구문(Syntax) 관점에서 코드 구조를 가장 빠르고 정확하게 발라내는 과정이다.

    3. Stage A 핵심 구현 예시

    extractor/stage_a.py가 소스 바이트에서 함수 이름과 그 범위를 결정적으로 추출하는 핵심 로직의 축약 버전이다.

    from tree_sitter import Language, Parser
    import tree_sitter_python as tspython
    
    # 파이썬 문법 언어 로드 및 파서 초기화
    PY_LANGUAGE = Language(tspython.language())
    parser = Parser(PY_LANGUAGE)
    
    def extract_functions(source_bytes: bytes) -> list[tuple[str, int, int]]:
        tree = parser.parse(source_bytes)
        out = []
    
        def walk(node) -> None:
            # 함수 정의 노드를 만나면 함수명과 시작/끝 라인을 추출
            if node.type == "function_definition":
                name_node = node.child_by_field_name("name")
                if name_node is not None:
                    name = source_bytes[name_node.start_byte : name_node.end_byte].decode("utf-8")
                    # 1-based index로 라인 번호 저장
                    out.append((name, node.start_point[0] + 1, node.end_point[0] + 1))
            
            # 하위 노드 재귀 탐색
            for child in node.children:
                walk(child)
    
        walk(tree.root_node)
        return out
    

    정규식 매칭과 달리, AST 트리를 직접 돌며 탐색하므로 주석 내에 적힌 가짜 def를 걸러내고, 정확하게 함수 영역의 boundary(`start_point` ~ `end_point`)를 알아낼 수 있다.

    4. 그런데 — 매일 밤 다시 파싱해야 할까?

    여기까지가 원래 이 글이 끝나려던 지점이었다. "tree-sitter는 빠르고 견고하다, 끝." 그런데 Stage A를 매일 밤 도는 cron에 올려놓고 보니 한 가지가 거슬렸다. 22개 저장소 중 어제와 오늘 사이에 실제로 코드가 바뀐 건 한두 개뿐인데, 매일 밤 전부를 처음부터 다시 파싱하고 있었다. 1장에서 그렇게 자랑한 "증분 파싱"은 어디 갔나?

    함정이 여기 있다. tree-sitter의 증분 파싱(tree.edit())은 에디터를 위한 기능이다. 키를 누를 때마다 메모리에 살아있는 직전 트리에 "여기가 바뀌었다"고 알려주고 그 부분만 다시 그리는 것. 하지만 우리 Stage A는 밤마다 새로 뜨는 배치 프로세스라 "직전 트리"가 메모리에 없다. 트리 객체를 디스크에 직렬화해두었다 되살리는 비용은, 솔직히 그냥 처음부터 다시 파싱하는 것보다 비싸다. 그러니 우리에게 맞는 증분은 tree-sitter가 자랑하는 파일 내부 트리 재사용이 아니라, 한 단계 거친 입도 — "안 바뀐 저장소는 추출 결과를 통째로 재사용"이다.

    4.1. 진짜 비싼 건 파싱이 아니다

    그리고 더 중요한 사실. 파싱을 아껴봐야 별로 안 아낀다. 앞서 적었듯 22개 저장소 풀 파싱은 전체 빌드의 몇 퍼센트도 안 된다. 정작 비싼 건 그 뒤에 오는 단계 — 추출한 그래프를 바탕으로 LLM이 위키 산문을 다시 써내는 과정이다. 이쪽은 GPU를 물고 분 단위로 돌아간다. 따라서 "변경분만 처리하자"의 진짜 가치는 파싱 속도가 아니라 LLM이 다시 써야 할 페이지의 범위를 좁히는 것에 있다. 파싱 캐싱은 거기 딸려오는 보너스일 뿐이다.

    4.2. 모든 코드는 git 위에 있다 — commit id가 곧 신호

    그렇다면 "이 저장소가 바뀌었는가"를 어떻게 아는가? 답은 단순하다. 우리가 다루는 모든 코드는 git 저장소 안에 있다. 그러니 commit id가 곧 변경 여부의 정확한 신호다. 저장소의 현재 HEAD SHA를, 직전에 그래프를 만들어낸 SHA와 비교한다. 같으면 그 저장소의 서브그래프를 이전 결과에서 복사해 오고, 다르면 다시 walk한다.

    4.3. 증분의 진짜 어려움은 파싱이 아니라 무효화다

    그런데 commit id "만"으로는 조용한 함정이 하나 있다. 저장소 HEAD는 그대로인데, 우리가 추출기 코드를 고쳤다면? 예를 들어 가짜 의존성을 걸러내는 필터나 포트 매칭 정규식을 손봤다고 하자. HEAD만 보면 "변경 없음 → 재사용"이라 판단하지만, 추출 로직이 달라졌으니 캐시된 결과는 이미 거짓이다. 옛 로직이 만든 낡은 엣지가 그래프에 눌러앉는다.

    그래서 캐시 키는 commit id 단독이 아니라 (git HEAD, extractor_version) 한 쌍이어야 한다. extractor_version은 추출기 소스 자체의 해시라서, 로직을 한 줄이라도 건드리면 값이 바뀌고 모든 재사용본이 자동으로 무효화된다. 핵심은 이것이다 — 증분 인덱싱의 어려움은 "무엇을 다시 파싱할까"가 아니라 "무엇을 더 이상 믿으면 안 되는가", 즉 무효화(invalidation)에 있다. 파싱은 쉽다. 어려운 건 캐시를 언제 버려야 하는지 아는 것이다.

    diagram

    위 그림이 게이트의 전부다. 저장소마다 (HEAD sha, extractor_version) 한 쌍을 만들어 직전 기록과 비교하고, 일치하면 왼쪽(복사·마이크로초), 어긋나면 오른쪽(재파싱)으로 간다. 코드의 HEAD가 움직였든, 우리 추출기가 바뀌었든, 둘 중 하나라도 다르면 다시 walk한다. 그리고 맨 아래 — 이 게이트가 실제로 돈을 아끼는 곳은 파싱 단계가 아니라, "바뀐 저장소 목록"을 입력으로 받는 그 뒤의 LLM 재생성 단계다.

    4.4. repo 단위로 재사용해도 cross-repo 엣지는 안 깨지나?

    한 가지 의심이 남는다. A 저장소가 B에 의존하는데 B만 바뀌었다면, A가 가진 "A→B 의존" 엣지는 낡은 것 아닌가? 안 깨진다. 그 엣지는 A의 소스 텍스트(포트 번호, 파일 경로, import 문)에서만 뽑아내기 때문이다. B가 내부적으로 어떻게 바뀌든, A가 B를 부르는 코드 자체가 그대로라면 A의 HEAD는 움직이지 않고, 따라서 A→B 엣지도 여전히 정확하다. 재사용 단위를 파일이 아니라 저장소로 잡은 것이 안전한 이유가 바로 이 지점이다.

    def run_extraction(targets, db_path=None):
        version = extractor_version()          # 추출기 소스 자체의 해시
        prev_g = load_graph(db_path)           # 직전에 영속화한 그래프
        prev_state = load_repo_state(db_path)  # {repo: (last_sha, version)}
    
        g = MultiDiGraph()
        for repo_path, tier in targets:
            repo = repo_path.name
            sha = commit_sha(repo_path)        # git rev-parse HEAD
            if prev_state.get(repo) == (sha, version):
                copy_repo_subgraph(prev_g, g, repo)   # 재사용: walk 생략
            else:
                extract_repo(g, repo_path, tier)      # 변경됨: 다시 walk
        persist_graph(g, db_path)
        save_repo_state(...)                   # 이번 (sha, version) 기록
        return g
    

    이 함수에서 설계의 무게를 다 짊어진 줄은 단 하나, prev_state.get(repo) == (sha, version)이다. sha만 비교했다면 추출 로직 변경에 눈을 감았을 것이고, version만 비교했다면 코드 변경에 눈을 감았을 것이다. 둘을 한 튜플로 묶어 "이 저장소를 다시 파싱할 가치가 있는가"를 한 번의 비교로 결정한다.

    4.5. 더 잘게 쪼개지 않은 이유

    그렇다면 저장소가 아니라 파일 단위로, 바뀐 파일만 다시 파싱하면 더 좋지 않을까? 맞다. 다만 지금 구조에선 막혀 있다. 우리 그래프의 노드 id가 repo@{commit_sha}::lang::path::symbol 꼴로 저장소의 sha를 id 안에 박고 있어서, HEAD가 한 칸만 움직여도 바뀌지 않은 파일의 노드 id까지 전부 갈린다 — "안 바뀐 파일은 그대로 둔다"가 id 차원에서 성립하지 않는다. 이걸 풀려면 sha를 id에서 떼어내 속성으로 내리는, 그래프 식별자 전반을 건드리는 더 큰 수술이 필요하다. 그래서 지금은 저장소 단위에서 멈췄다. 이 "여기까지만 한다"는 경계선과 그 거절 사유까지, 미래의 내가 같은 고민을 반복하지 않도록 ADR에 적어 남겼다.

    5. 정리

    tree-sitter는 문법 오류에 굴하지 않는 견고함과 다언어 지원을 갖춘 현대적인 AST 구문 분석기이고, deep-wiki Stage A는 이를 발판으로 22개 저장소의 코드 구조를 빠르게 스켈레톤 그래프로 바꾼다. 하지만 이 글이 정작 가르쳐준 건 파서 그 자체가 아니었다. "증분"이라는 단어를 도구가 광고하는 의미로만 받아들이지 말 것. 우리에게 맞는 증분의 층위는 파일 내부 트리가 아니라 저장소 단위 결과 캐싱이었고, 그 캐시를 지탱한 건 빠른 파싱이 아니라 (HEAD, extractor_version)이라는 정직한 무효화 키였다. 파싱은 처음부터 어렵지 않았다. 어려운 건, 언제 캐시를 버려야 하는지 아는 일이었다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.