노션에 그래프뷰를 구현해보았다

KO EN
2026년 6월 26일 · 15분 읽기 · 조회 140 · 💬 0

최근 AI와 LLM을 개인 작업에 활용하면서, 나만의 지식 기지(제2의 뇌)를 구축하는 일이 유행처럼 번지고 있다.

바로 'LLM 위키(Wiki)'다. 이 분야의 대표적인 도구로는 옵시디언(Obsidian)과 노션(Notion)을 들 수 있다.

옵시디언이 부럽지 않은 노션을 만들고 싶었다

노션은 UI가 깔끔하고, 데이터베이스 구축이 쉬우며, 외부 연계성도 뛰어나다.

약간의 비용만 지불하면 이토록 유용한 도구를 마음껏 쓸 수 있다.

(나 또한 개인 위키를 노션 위에 쌓아 올리며 만족스럽게 사용해 왔다.)

하지만 늘 개인적인 아쉬움이 하나 남았다.

문서 간의 연결 관계를 시각적으로 보여주는 '그래프 뷰'가 없다는 점이었다.

이 단 한 가지 결함 때문에 노션은 늘 옵시디언보다 후순위로 밀리곤 했다.

어떻게 보면 그래프 뷰는 옵시디언이 가진 최고의 무기이자, 노션의 장점들을 압도하는 킬링 콘텐츠였다.

그래서 그런지, 유튜브에서 LLM WIKI를 말하면서 옵시디언이 빠지는 꼴을 보지 못하였다.

내가 생각하기에 옵시디언은 노션만큼 유연하고 편리한 DB 환경을 제공하지 못한다.

이처럼 장단점과 트레이드오프가 너무나 뚜렷해서 선택이 쉽지 않았다.

피자와 제육 사이에서 고민하는 딜레마와 비슷하다고 할까?

제육도 먹고 싶고...

피자도 먹고 싶다면 어떻게 해야 할까?

답은 매우 간단하다.

불고기 피자를 만들면 된다.

페이지가 수백 개씩 쌓이면 사람이 머릿속만으로 전체 구조를 파악하는 것은 불가능에 가깝다.

오직 내가 길을 잃지 않기 위한 목적,

옵시디언처럼 노션의 페이지 데이터들을 노드로 묶어서 시각화하는

아주 가벼운 파이프라인을 구상했다.

Notion API로 페이지 트리를 순회하며 노드와 엣지를 수집하고,

D3.js로 그려서 /graph에 올렸다.

그리고 노션 페이지 안에 embed 블록으로 그래프를 심었다.

화면 속에서 페이지의 하위 구조가 유기적으로 얽히는 모습을 조망할 수 있는 구조가 마련되었다.

단순함에 기인한 기술부채

초기 구현은 극도로 단순했다.

Notion API를 순회하며 얻은 노드와 엣지 데이터를

notion_meta.json이라는 파일 하나에 통째로 직렬화해 집어넣었다.

읽을 때는 파일 전체를 파싱해 올렸고,

쓸 때는 변경 사항을 덧붙여 파일 전체를 새로 덮어썼다.

구조가 단순한 만큼 첫 페이지를 띄우는 데는 10분도 걸리지 않았다.

그러나 데이터가 조금씩 쌓이기 시작하면서, 이 단순함은 곧 대가로 돌아온다.

비효율적인 전체 읽기: 단 하나의 노드를 조회하려 해도 수십 메가바이트짜리 JSON 전체를 매번 파싱해야 했다.

인덱스의 부재: 특정 그룹의 노드를 검색하려면 배열 전체를 매번 처음부터 끝까지 순회(Full Scan)해야 했다.

동시성 위협: 노션 데이터를 백그라운드에서 스캔하여 파일에 쓰는 도중에 프론트엔드에서 읽기 요청이 들어오면, 깨진 데이터나 불완전한 파일 상태를 읽는 위태로운 순간이 존재했다.

점진적 갱신(Incremental Update) 불가: 하위 문서 하나가 추가되어 엣지 단 한 줄이 늘어났을 뿐인데, 무거운 JSON 전체를 매번 재작성해야 했다.

이 병목을 해결하기 위해 생각할 수 있는 대안은 크게 세 가지였다.

  1. JSON을 저장하기 좋은 DB 스택을 도입 (JSON 타입을 지원하는 PostgreSQL, 또는 NoSQL기반 MongoDB 등)

  2. 기존 오픈소스 임베디드 DB 도입 (NeDB, LowDB, 혹은 SQLite 래퍼 라이브러리들)

  3. 필요에 맞춘 자체 제작 (SQLite 위에 JSON 인덱싱 레이어를 직접 구축)

DB스택을 늘리자니 고작 json 데이터 관리하고자 RDB나 MongoDB를 올린다?

오버엔지니어링의 정수다.

牛刀割鷄, 닭 잡는데 어찌 소잡는 칼을 쓰리요..

그래서 가장 먼저 널리 쓰이는 가벼운 임베디드 데이터베이스들을 검토했다.

NeDB는 JSON 객체를 MongoDB 스타일로 다룰 수 있는 매력적인 도구였지만, 안타깝게도 오래전에 유지보수가 멈춘 유산이었다.

LowDB는 직관적이었지만 내부적으로는 여전히 JSON 파일 전체를 쓰고 읽는 방식이라

동시성과 크기 비대화라는 근본적인 문제를 해결하기에는 체급이 맞지 않았다.

그렇다면 결국 신뢰할 수 있는 관계형 데이터베이스의 표준인 SQLite가 답이었다.

하지만 시중의 SQLite 관련 라이브러리들은 단순 SQL 래퍼 역할에 그치거나,

내가 필요한 JSON 데이터 인덱싱과 캐싱을 처리하려면 매번 중복된 보일러플레이트 코드를 작성해야 했다.

결국 "어차피 SQLite를 쓸 것이라면, 내 입맛에 맞는 인터페이스로 직접 감싸서 사용하자"는 결론에 도달했다. 다행히 Node.js 진영에는 네이티브 바인딩을 통해 압도적인 속도를 자랑하는 better-sqlite3가 든든하게 버티고 있었기에 나는 금세 마련할 수 있었다.

내가 만들 라이브러리에 필요한 핵심 요구사항을 정리했다.

B-Tree 기반 인덱싱: SQLite가 기본으로 제공하는 강력한 인덱스를 활용한다.

LRU 캐시: 자주 조회하는 쿼리 결과를 메모리에 유지하여 디스크 I/O를 최소화한다.

자체 해시 함수: 캐시 키를 생성할 FNV-1a와 무결성을 검증할 SHA-256을 활용한다.

HitMap: 어떤 쿼리가 얼마나 자주 적중하는지 추적하여 캐시 효율을 눈으로 확인한다.

이 도구는 다음번 프로젝트에서도 유용하게 재사용할 수 있도록 독립적인 npm 패키지로 분리하기로 결정했다.


DJinn — Doil's JSON Indexing Node

이름은 DJinn(진)이다.

Doil's JSON Indexing Node의 약자이면서, 램프의 요정, 중동 신화의 정령을 뜻하는 언어유희다.

대충 "악마같이 좋은 녀석"이라는 뜻.

핵심 설계 결정과 삽질의 기록

  1. 스키마 설계: 항상 모든 필드를 반환해야 하는 이유

초기 스키마 검증 단계에서는 선택적(Optional) 필드가 입력되지 않으면 데이터베이스 저장 단계에서도 해당 필드를 아예 제외했다.

그런데 이 방식은 better-sqlite3 환경에서 에러를 뿜어냈다.

prepared statement 캐시에서 컬럼 수 불일치 오류가 발생한 것이다.

prepared statement는 쿼리가 처음 컴파일될 때의 컬럼 구조와 개수를 고정해 두는데,

입력 데이터에 따라 쿼리의 컬럼 수가 수시로 바뀌니 데이터베이스 엔진이 버티지 못한 탓이다.

해결책은 의외로 간단하다.

스키마 검증기(validate())가 값이 없는 선택적 필드에도 명시적으로 null을 채워 넣어,

언제나 스키마에 정의된 모든 컬럼을 가진 객체를 반환하도록 강제했다.

이 규칙 덕분에 데이터의 구조가 늘 일정하게 유지되었고,

prepared statement를 안전하게 재사용할 수 있게 되었다.

  1. 캐시 키 직렬화: 배열 replacer가 파놓은 함정

캐싱을 위해서는 쿼리 조건 객체를 고유한 키(Key) 문자열로 만드는 '직렬화' 작업이 필요하다.

처음에는 가볍게 JSON.stringify(query, ['id', 'grp'])처럼 배열 replacer를 써서 특정 키만 추출해 캐시 키로 만들려 했다.

하지만 이 방식은 치명적인 충돌 버그를 낳았다.

{id: 'abc'}{grp: 'Studia'}를 각각 직렬화했을 때,

서로 다른 쿼리 조건임에도 불구하고 동일한 직렬화 결과가 반환된 것이다.

원인은 단순했다.

배열 replacer는 최상위 객체의 키만 필터링하는 것이 아니라, 중첩된 객체의 모든 깊이(depth)에서 동일하게 작동한다.

이 때문에 파라미터의 키 이름이 달라도 값의 구조와 깊이가 비슷하면 동일한 결과물로 변환되는 문제가 발생했다.

결국 직렬화 방식을 정공법으로 선회했다.

쿼리 객체의 키들을 알파벳 순으로 먼저 정렬한 뒤,

어떤 필터도 거치지 않고 통째로 JSON.stringify를 하도록 수정하여 canonical string을 얻도록 했다.

  1. HitMap: 캐시 효율을 감시하는 눈

캐시는 도입하는 것만큼이나 실제로 잘 작동하는지 감시하는 것이 중요하다 생각했다. (LRU를 시각화 하기위하여)

DJinn에는 단순한 카운터 수준을 넘어선 HitMap 모듈을 내장했다.

쿼리 키별로 hit/miss 횟수를 실시간으로 누적하고, byCollection() 메서드를 통해 컬렉션 단위로 효율성을 집계한다.

심지어 coldKeys(n)을 호출해 캐시 적중률(Hit rate)이 낮아 메모리만 낭비하고 있는 쿼리가 무엇인지 힌트를 주기도 한다.

이는 추후 메모리 한계에 직면했을 때 캐시 정책을 튜닝할 수 있는 근거 자료가 될 것이라 생각했다.

프로젝트에 임베드

DJinn은 독립 npm 패키지(@d0iloppa/djinn)로 패키징되었고,

서비스 서버인 doil-sb의 routes/graph.js가 이를 가져와 사용한다.

이때 중요한 설계 원칙 중 하나는 제어권의 분리였다.

데이터베이스 파일의 생성 경로와 구체적인 이름은

라이브러리가 아니라 호출하는 쪽(나의 경우는 doil-sb)이 전적으로 결정하도록 만들었다.

const db = new DJinn(path.join(__dirname, '../data/notion_meta.db'));

DJinn 내부 코드에는 파일 경로나 파일명이 단 한 줄도 하드코딩되지 않도록 했다.

훗날 다른 토이 프로젝트나 서비스에서 DJinn을 활용하더라도

new DJinn('/another/path/meta.db')

같은 생성자 호출 한 줄로 완전히 격리된 독립 데이터베이스를 손쉽게 운용할 수 있다.

스캔 전략

데이터베이스를 구축했더라도, 노션의 변경 사항을 매번 동기 동기화하는 스캔 과정이 비효율적이라면 의미가 없다.

특히 노션 API는 응답 속도가 무척 느리기로 악명 높다. (진짜 오래걸린다)

페이지가 늘어날수록 매번 전체 트리를 순회하는 것은 시간 낭비에 가깝다.

여기서 바로 서브스캔(Sub Scan) 아이디어를 떠올렸다.

노션에서 새로운 페이지를 생성하고 버튼을 누르면 다음과 같은 비동기 흐름이 일어난다.

  1. Hook이 호출되면 노션 페이지에 embed 블록을 즉시 삽입하고 200 OK 응답을 곧바로 돌려준다.

사용자가 로딩 바를 보며 대기하는 물리적인 대기 시간을 없앤 것이다.

  1. 이후 백그라운드에서 notion.pages.retrieve(id)를 호출하여 방금 추가된 페이지의 실제 제목(title)과 부모 ID(parentId)를 가볍게 조회한다.

  2. 새 페이지 하위에 존재하는 자식 블록들만 buildGraph(id)로 재귀 순회하며 부분적인 그래프 구조를 수집한다.

  3. 기존 DB에서 해당 페이지 하위에 속해 있던 낡은 데이터들을 청소하고, 새로 수집한 데이터로 채워 넣는다.

  4. 부모 노드가 DB에 등록되어 있다면, '부모 → 자식' 방향의 엣지(Edge)를 만들어 지도의 연속성을 유지한다.

이 방식을 통해 사용자는 버튼 클릭 즉시 완료 화면을 마주하고, 실제 데이터 갱신은 뒤편에서 비동기로 정교하게 완료된다.

MCP: 인공지능에게 지도를 건네는 방법

다들 옵시디언만 사용하는 것에 괜한 오기가 생겼다.

처음에는 오직 나를 위한 시각적 그래프 뷰만을 목적으로 이 프로젝트를 시작했다.

이렇게 구축하고 보니 LLM Wiki의 색인데이터로도 충분히 활용할 수 있을 것 같은 가능성이 보였다.

그러면서 내 서버 안에서 활약하고 있는 AI 에이전트(DOBIS)에게도 이 데이터가 무척이나 요긴하겠다는 생각이 스쳤다.

기존에 DOBIS가 내 노션 위키의 구조를 이해하려면,

느린 노션 api를 이용하여 검색을 하거나,

또는 색인화된 notion_meta.json 파일 전체를 매번 다 읽어 들이는 원시적인 방식을 써야 했다.

파일 크기가 수 메가바이트를 넘어서는 순간부터 이 방식은 엄청난 컨텍스트 토큰 낭비이자 처리 지연의 주범이 된다.

이 문제를 해결하기 위해 DJinn 내부에 MCP(Model Context Protocol) 서버 기능을 기본으로 탑재했다.

라이브러리를 초기화할 때 serveMcp(db)라는 단 한 줄의 코드를 실행하는 것만으로,

각 데이터베이스 컬렉션에 대응하는 전용 tools이 자동으로 생성되어 노출된다.

이제 DOBIS는 내 위키에서 필요한 정보가 생겼을 때, 무겁게 파일 전체를 들춰보지 않는다.

필요한 tools을 호출해 데이터베이스에서 아주 정밀하게 원하는 조각만 질의해서 가져간다.

아이언맨의 자비스처럼 똑똑하게 말이다.

댓글 0