-
위키 하나에 저장소가 셋인 이유 — 그래프 DB·벡터 DB·파일의 분업IT 2026. 6. 1. 21:00
deep-wiki의 한 페이지가 세 개의 저장소에 동시에 들어간다. 그래프 DB(SQLite + NetworkX), 벡터 DB(Qdrant), 파일 시스템(wiki-output + git). "왜 셋일까? 하나로는 안될까?"이 질문에 대한 답이 사실 가장 근본적이다. 뒤따라오는 정합성 문제도, 신뢰 계층 분리도 모두 "저장소가 여러 개"라는 사실에서 파생되기 때문이다. 결론을 먼저 말하면 이렇다 — 한 페이지의 같은 정보를 가지고 사람이 물어보는 질문이 세 종류로 본질이 다르고, 그 세 질문을 하나의 저장 엔진으로 잘 답할 수는 없다. 이 글은 그 세 질문이 무엇이고, 각 저장소가 왜 존재할 수밖에 없으며, 각자 어떤 방식으로 어떤 역할을 맡는지의 기록이다.
1. 배경 — 한 페이지가 답해야 하는 세 종류의 질문
qemu(Tizen SDK 에뮬레이터의 기반이 되는 머신 에뮬레이터)의 위키 페이지 하나를 떠올려 보자. 이 페이지에 담긴 정보를 두고 사람과 AI 에이전트가 던지는 질문은 의외로 결이 다르다.- "이 페이지 그냥 보여줘" — 사람이 읽을 마크다운 본문 자체. 그리고 "어제 대비 오늘 뭐가 바뀌었나". 읽기·이력의 질문.
- "qemu를 고치면 어떤 모듈이 깨지나" — 이 노드에서 출발해 의존 관계를 따라 걷는 질문. "이 함수는 누가 호출하나", "이 ABI를 바꾸면 어디가 영향받나". 관계·영향 범위의 질문.
- "메모리 풀 관리하는 코드 어디 있지?" — 정확한 함수 이름을 모른 채 의미로 더듬는 질문.
qemu라는 단어가 본문에 없어도 의미가 가까우면 찾아와야 한다. 의미 기반 검색의 질문.
핵심은 이것이다. 세 질문은 자료구조 차원에서 서로 다른 연산을 요구한다. 읽기·이력은 파일 입출력과 버전 관리, 관계는 그래프 순회(traversal), 의미 검색은 벡터 유사도 계산. 하나의 저장 엔진이 이 셋을 모두 잘하는 경우는 없다. SQLite에 마크다운을 욱여넣을 수는 있지만 의미 검색은 못 하고, 벡터 DB는 그래프 순회를 못 하며, 파일 시스템은 "이 함수를 누가 부르나"를 1초 안에 답하지 못한다. 그래서 셋이다.
2. 직관 — 도서관에 비유하면
저장소 셋의 분업을 가장 쉽게 잡는 비유는 도서관이다.
- 파일 + git = 책장에 꽂힌 실제 책. 사람이 손에 들고 읽는 건 결국 책 그 자체다. 그리고 도서관은 같은 책의 판본 이력(초판·개정판)을 보관한다 — 이게 git이다.
- 그래프 DB = 책들 사이의 인용·참조 지도. "이 논문이 저 논문을 인용한다"는 관계망. 한 책에서 출발해 인용을 타고 걸으면 "이 주장을 바꾸면 어떤 후속 연구가 흔들리나"를 추적할 수 있다.
- Qdrant = 사서. 책 제목을 정확히 몰라도 "음… 분산 시스템에서 데이터 맞추는 거 다룬 책 있어요?"라고 막연히 설명하면, 사서는 의미를 알아듣고 알맞은 책장으로 안내한다.
책(파일)만 있으면 읽을 수는 있어도 "무엇이 무엇을 인용하나"를 일일이 책을 펴서 확인해야 한다. 인용 지도(그래프)만 있으면 관계는 알지만 정작 읽을 본문이 없다. 사서(벡터 검색)만 있으면 찾아는 주지만 책도 지도도 없으면 줄 게 없다. 셋이 한 도서관 안에서 각자 다른 일을 할 때 비로소 "찾고 → 관계를 보고 → 읽는" 한 흐름이 완성된다.
3. 파일 + git — 사람이 읽고, 버전이 흐르는 곳
왜 존재할 수밖에 없나. 위키의 최종 소비자는 사람이다. 사람은 그래프 DB의 표(row)나 Qdrant의 1,024차원 벡터를 읽을 수 없다. 읽을 수 있는 형태는 마크다운 텍스트뿐이다. 게다가 위키 페이지는 변경될 수 있으므로 "어제와 오늘 무엇이 달라졌나"를 추적할 수단이 필요하다 — 이건 버전 관리(git)의 고유 영역이다. 그래프 DB도 Qdrant도 "지난주 화요일 상태"를 보여 주지는 못한다.
선택한 방법, 결코 화려한 것은 없다.
wiki-output/private/<repo>/*.md평범한 마크다운 파일 + atomic write(임시 파일에 다 쓴 뒤rename으로 원자적 교체 — 쓰다 만 반쪽 파일이 남지 않는다) + 로컬 git commit이 전부이다. 개인 시스템용은 push도 필요 없어서 외부로 한 바이트도 나가지 않는다.역할. ① 사람이 읽는 표시용 source, ② git diff로 일별 변경 추적. 정리하면 "사람의 눈과 시간축"을 담당한다.
4. 그래프 DB — 관계와 영향 범위를 추론하는 곳
왜 존재할 수밖에 없나. 코드 위키의 진짜 가치는 본문 그 자체보다 "이걸 바꾸면 무엇이 깨지나"에 있다. 모듈 수백 개가 얽힌 코드베이스에서 한 함수의 ABI를 바꾸면 영향이 어디까지 번지는지를 사람이 머릿속으로 추적하는 건 불가능하다. 이건 노드 사이를 따라 걷는 그래프 순회 연산이다 — A가 B를 호출하고, B가 C에 의존하고… 를 깊이 우선으로 타고 들어가야 한다. 텍스트 검색(grep)으로도, 의미 검색(벡터)으로도 이 "관계 따라 걷기"는 안 된다. 그래프를 그래프로 다루는 도구가 따로 필요하다.
선택한 방법. NetworkX(메모리) + SQLite WAL(영속)의 조합이다. NetworkX는 Python 그래프 라이브러리로, BFS·의존성 순회 같은 그래프 알고리즘을 그냥 함수 호출로 쓸 수 있다 — repo 규모(수만 노드)에서는 메모리에 통째로 올려도 충분히 빠르다. 그 위에 SQLite WAL(Write-Ahead Logging — 쓰는 도중에도 다른 프로세스가 안전하게 읽을 수 있는 모드)을 깔아 durable persistence를 얻는다. 노드 종류 9개(Repo/Module/Function/Symbol/Resource/MemoryPool/IPCChannel/HotPath/BuildArtifact), edge 종류 10개(CALLS/DEPENDS_ON/ALLOCATES/CONTENDS_WITH/EXPORTS_ABI/BREAKS_ABI_IF_CHANGED…).
거절한 대안 — Kuzu. 초기 계획은 임베디드 그래프 DB인 Kuzu였다. 그러나 repo-scale(수만 노드) 그래프에서는 NetworkX 메모리 처리가 더 단순하고 신규 의존성도 없다. Kuzu는 폐기가 아니라 보류 — 향후 NetworkX의 p99 쿼리 지연을 풀 사이즈로 측정한 뒤 재평가하기로 했다.
역할. cross-module 의존성·영향 분석.
dependencies.md같은 페이지의 생성 데이터를 공급하고, AI 코딩 어시스턴트가 "다중 모듈 동시 변경"을 안전하게 제안할 근거를 만든다. 정리하면 "무엇이 무엇과 연결돼 있는가"를 담당한다.5. Qdrant — 의미로 더듬어 들어오는 입구
왜 존재할 수밖에 없나. 사람도 AI 에이전트도 항상 정확한 이름으로 묻지 않는다. "메모리 누수 다루는 코드 어디 있더라"처럼 의미만 가지고 더듬는다. grep은 정확히 같은 단어만 찾고, 그래프 DB는 정확한 노드 id를 알아야 출발한다. "이름은 다르지만 의미가 가까운 것"을 찾는 건 임베딩 벡터의 유사도 검색으로만 가능하다. 그리고 이게 RAG의 출발점이다 — AI 에이전트는 질문이 들어오면 먼저 의미가 가까운 문서를 끌어와 prompt에 끼운다. 그 첫 관문이 벡터 검색이다.
선택한 방법. 페이지 본문과 코드를 Qwen3-Embedding으로 임베딩해 벡터로 만들고, Qdrant에 저장한 뒤 코사인 유사도로 검색한다. 컬렉션을 둘로 분리한 게 포인트다 —
tizen_wiki(위키 본문)와tizen_code(코드 심볼). 검색 대상의 성격이 다르므로 섞지 않고, 기존 개인 지식금고 컬렉션(knowledge_vault)과도 분리해 도메인 오염을 막는다. 별도 Qdrant 인스턴스를 새로 띄우지 않고 기존 것을 컬렉션 단위로 재사용한다.역할. RAG entry point — 의미 기반 검색의 입구. 사람·AI가 위키로 처음 들어올 때 통과하는 문이다. 정리하면 "무엇이 의미적으로 비슷한가"를 담당한다.
6. 셋을 잇는 공통 좌표계 — 같은 id가 아니라 같은 namespace
그럼 세 저장소가 같은 대상을 가리킨다는 건 어떻게 보장될까? "하나의 id를 셋이 공유한다"고 말하고 싶지만, 정확히는 그렇지 않다. 셋은 알갱이 크기(granularity)가 다르기 때문이다.
작은 것부터 큰 것 순으로 — 그래프 노드 < Qdrant point < 파일이다.
- 그래프 노드 — 가장 작다. 함수/심볼 하나가 노드 하나. id는
repo@commit_sha::lang::path:symbol형태. - Qdrant point — 중간. 하나의 위키 파일이 의미 단위로 여러 chunk로 쪼개지고, 그 chunk 하나가 point 하나다. 함수보다는 크고(한 chunk가 여러 함수를 품는다) 파일보다는 작다. (현재는 "파일 한 장 = chunk 한 개"지만 섹션 단위로 더 잘게 청킹할 예정.)
- 파일 — 가장 크다.
api.md,internal-graph.md같은 페이지 한 장 = 여러 chunk를 합친 것.
알갱이가 다르니 "그래프 노드 id == Qdrant point id == 파일 경로"가 문자 그대로 같을 수는 없다. 실제로 Qdrant point id는 좌표 문자열을 그대로 쓰지 않고
uuid5(좌표)로 해시한 값이다. 셋을 잇는 건 동일한 id가 아니라 동일한 좌표계(namespace) — 모두repo@commit_sha를 뿌리로, 그 아래경로:심볼(함수)·경로#청크(point)·경로(파일)를 붙여 주소를 만든다.# 공통 좌표계 — 뿌리는 repo@commit_sha, 알갱이만 다르다 (노드 < point < 파일) 그래프 노드 (함수) : qemu@a3f9c1::c::system/memory.c:memory_region_init Qdrant point (청크) : uuid5("qemu@a3f9c1::c::api.md#chunk-2") # 한 페이지 속 한 조각 파일 (페이지) : wiki-output/private/qemu/api.md # 여러 chunk를 합친 한 장이 공통 좌표계 덕분에 알갱이를 위아래로 넘나들 수 있다. 의미 검색이 어떤 청크(point)를 hit하면, 위로는 그 청크가 속한 페이지 파일로 올라가 전체 맥락을 읽고, 아래로는 그 청크가 품은 그래프의 함수 노드들로 내려가 "이걸 바꾸면 뭐가 깨지나"를 묻는다 — point를 가운데 두고 파일(위)과 함수(아래)로 알갱이를 넓혔다 좁혔다 하는 흐름이다. 그런데 알갱이가 다른 셋이 같은 좌표를 일관되게 가리키려면, 한쪽만 갱신되고 나머지가 뒤처지면 안 된다. 그래프엔 들어갔는데 Qdrant엔 안 들어가면 검색은 stale, 파일엔 있는데 그래프엔 없으면 viewer가 노드를 못 찾는다. 그래서 셋 중 하나만 갱신되고 나머지가 뒤처지지 않도록 보장하는 동기화 장치가 별도로 필요해진다 — 이건 그 자체로 또 하나의 과제이고, 별도의 글에서 따로 다룬다. "왜 셋인가"의 답이 곧 "왜 정합성 장치가 필요한가"의 전제인 셈이다.
7. 거절된 대안 — 하나로 다 하면 안 되나
- 파일 하나로 통합 (grep만) — 마크다운 파일만 두고 검색은 grep으로. 거절 — 의미 검색 불가(정확한 단어만 매칭), 관계 추적 불가. "이 함수 누가 호출하나"를 매번 전체 grep으로 답하면 수백 모듈에서 느리고 부정확하다.
- 벡터 DB 하나로 통합 (Qdrant만) — 본문도 메타도 전부 Qdrant에. 거절 — 벡터 DB는 그래프 순회를 못 한다. "A→B→C 의존 사슬"을 따라 걷는 연산이 없다. 사람이 읽을 원본 텍스트의 권위 있는 보관·버전 관리도 git만큼 못 한다.
- 관계형 DB 하나로 통합 (SQLite만) — 전부 SQLite 테이블에. 거절 — 재귀 CTE로 그래프 순회를 흉내 낼 수는 있으나 NetworkX 알고리즘만큼 표현력이 없고, 의미 검색(벡터 유사도)은 애초에 SQLite의 일이 아니다.
- 그래프 DB가 벡터까지 (Kuzu/Neo4j 벡터 인덱스) — 일부 그래프 DB는 벡터 인덱스를 곁들인다. 거절(보류) — 매력적이지만 신규 인스턴스 도입 비용, 그리고 우리는 이미 Qdrant를 재사용 중이다. 그래프 측은 NetworkX로 충분하다고 판단하였다.
네 대안 모두 "저장소를 줄이자"는 매력이 있지만, 줄이는 순간 세 질문 중 하나가 답을 잃는다. 저장소 수를 줄이는 비용이 정합성을 맞추는 비용보다 컸다. 그래서 셋을 유지하고, 대신 정합성을 맞추는 별도 장치를 두는 쪽을 택했다.
마무리 — 저장소는 질문의 모양을 따라간다
처음엔 "저장소가 셋이면 복잡하지 않나, 하나로 합치면 깔끔할 텐데"라고 생각하기 쉽다. 그런데 합치려고 시도해 보면 매번 같은 벽에 부딪힌다 — 관계 질문은 그래프를, 의미 질문은 벡터를, 읽기 질문은 파일을 원한다. 데이터의 모양이 아니라 질문의 모양이 저장소를 결정한다.
그래서 이 글의 한 줄 결론은 이렇다. "하나의 정보라도, 그 정보에 던질 질문이 여러 종류면 저장소도 여러 개여야 한다." 대신 그 대가로 정합성 문제가 따라오고, 그건 가벼운 동기화 패턴으로 갚으면 된다. 멀티 저장소 시스템을 처음 설계할 때 "몇 개로 합칠까"가 아니라 "이 데이터에 어떤 질문이 올까"부터 세어 보면, 저장소의 개수와 종류가 자연스럽게 정해진다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
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 agent가 wiki로 task를 풀 수 있느냐가 ground truth — with-wiki vs without-wiki로 측정하기 (0) 2026.05.31 같은 소스에서 매번 같은 위키가 나와야 한다 — 위키 생성 파이프라인의 흔들림 제어 (0) 2026.05.31 외부 API 100%, 내부는 trend — 양적 검증을 두 층으로 가르는 이유 (0) 2026.05.30 위키가 거짓말하지 않게 — 모든 코드 인용에 file:line을 강제하는 doctrine (0) 2026.05.30 file.py:LINE anchor가 진짜 그 줄을 가리키는가 — 매일 AST와 대조해서 RAG 거짓말 끊기 (0) 2026.05.30