다음에 올 것을 만들어가는 기술에 대한 심층 기사.

SQLite를 넘어서는 임베디드 그래프 데이터베이스

그래프 데이터베이스가 임베디드 형태로 진화하는 이유, Rust가 가져다주는 강점, 관계형 DB보다 그래프 모델이 유리한 경우를 정리합니다.

녹슨 기계 껍데기 안에 놓인, 빛나는 연결된 노드들의 작은 큐브

SQLite는 어디에나 있습니다. 여러분의 휴대폰, 브라우저, 스마트 TV, 아마 자동차 안에도 들어 있죠. SQLite는 별도의 서버 없이 애플리케이션에 완전한 SQL 데이터베이스를 제공한다는 근본적인 문제를 해결했고, 그 성과가 워낙 좋아서 ‘임베디드 데이터베이스’라고 하면 곧 SQLite를 떠올리게 됐습니다. 하지만 SQLite의 관계형 모델이 언제나 정답은 아닙니다. 데이터의 본질이 관계라면, 예를 들어 소셜 연결, 의존성 그래프, 지식 네트워크, 라우팅 문제 같은 경우에는 이를 외래 키가 달린 테이블에 억지로 욱여넣는 순간 쿼리가 끔찍하게 복잡해지거나 지독하게 느려집니다.

새로운 임베디드 그래프 데이터베이스들이 그래프 데이터에 대해 SQLite가 관계형 데이터에 해냈던 일을 하려고 합니다. 즉, 빠르고, 의존성이 없으며, 프로세스 안에서 동작하고, 연결된 데이터에 맞는 쿼리 언어를 제공하는 데이터베이스를 주는 것이죠. 이들 중 여러 개가 Rust로 작성되었는데, 이 문제에 아주 잘 맞는 선택으로 드러났습니다. 왜 그래프 데이터베이스가 임베디드 형태로 진화하고 있는지, Rust 생태계가 무엇을 가져다주는지, 그리고 언제 실제로 그래프 데이터베이스를 고려해야 하는지 살펴보겠습니다.

관계형 모델의 맹점

관계형 데이터베이스는 대부분의 데이터 패턴을 잘 처리합니다. 일대다? 외래 키. 다대다? 조인 테이블. 단순 조회, 집계, 필터링은 SQL이 이런 용도로 만들어졌습니다. 문제는 쿼리가 경로, 깊이, 연결성을 다루기 시작할 때 생깁니다.

의존성 해석기를 생각해 봅시다. 패키지가 있고, 각 패키지는 다른 패키지에 의존하며, 각각 버전 제약 조건을 가집니다. 이런 질문에 답해야 합니다. ‘패키지 X를 설치하면 전체 전이 의존성 트리는 어떻게 되는가? 순환 의존성이 있는가? 어느 깊이에서든 버전 요구 사항이 충돌하는가?’ SQL에서는 이를 위해 재귀 CTE(공통 테이블 식)가 필요한데, 장황하고, 최적화하기 어렵고, 그래프가 깊어질수록 기하급수적으로 느려집니다.

-- Finding all transitive dependencies in SQL
-- This works, but gets ugly fast
WITH RECURSIVE deps AS (
-- Base case: direct dependencies
SELECT dependency_id, 1 as depth
FROM package_dependencies
WHERE package_id = 'my-package'
UNION ALL
-- Recursive case: dependencies of dependencies
SELECT pd.dependency_id, d.depth + 1
FROM package_dependencies pd
JOIN deps d ON pd.package_id = d.dependency_id
WHERE d.depth < 50  -- safety limit to prevent infinite loops
)
SELECT DISTINCT dependency_id, MIN(depth) as min_depth
FROM deps
GROUP BY dependency_id
ORDER BY min_depth;
-- Now try detecting circular dependencies.
-- Or finding the shortest path between two packages.
-- Or querying all packages within 3 hops that match a version constraint.
-- Each query gets progressively more painful.

그래프 데이터베이스에서는 이것이 기본 쿼리 패턴입니다. 데이터 모델과 싸울 필요 없이 그대로 활용하면 됩니다. 간선을 따라가고, 경로를 추적하고, 사이클을 찾는 일이 나중에 덧붙인 재귀가 아니라 1급 연산입니다.

왜 임베디드가 중요한가

Neo4j는 10년 넘게 대표적인 그래프 데이터베이스였고, 정말 훌륭합니다. 하지만 이것은 서버입니다. 별도 프로세스로 실행하고, 네트워크 프로토콜로 연결하며, JVM 메모리를 관리하고, 운영 오버헤드를 감당해야 합니다. 전담 운영팀이 있는 프로덕션 애플리케이션이라면 괜찮습니다. 하지만 CLI 도구, 데스크톱 앱, 빌드 시스템, 임베디드 기기라면 말도 안 되는 일입니다.

SQLite의 통찰이 여기에도 적용됩니다. 많은 사용 사례가 서버 오버헤드 없이 그래프 쿼리 기능을 필요로 합니다. 호출 그래프를 만드는 코드 분석 도구, 엔티티 관계를 저장하는 게임 엔진, 양방향 링크를 지원하는 로컬 우선 메모 앱, 네트워크 토폴로지 분석기가 그렇습니다. 모두 그래프 쿼리를 원하지만, 사용자에게 데이터베이스 서버를 설치하고 설정하라고 말하고 싶은 곳은 없습니다.

임베디드 그래프 데이터베이스는 여러분의 프로세스 안에서 실행되고, 데이터를 로컬 파일에 저장하며, 네트워크 프로토콜 대신 라이브러리 API를 제공합니다. 애플리케이션은 SQLite에 링크하듯 이것들에 링크하면 됩니다. 서버도, 포트도, 인증도, 배포의 복잡성도 없습니다.

그래프 데이터베이스에 Rust가 가져다주는 것

Rust는 새로운 데이터베이스 프로젝트에서 과도할 만큼 많이 선택되는 언어가 되었고, 그 이유는 흔히 말하는 ‘가비지 컬렉션 없는 메모리 안전성’이라는 설명을 넘어섭니다.

  • 예측 가능한 지연 시간. 그래프 순회는 지연 시간에 민감합니다. 순회의 각 홉은 메모리 접근이고, 깊은 순회는 그런 접근을 수백만 번 합니다. 10홉짜리 순회 중간에 GC 일시 정지가 발생하면 꼬리 지연 시간(tail latency)이 망가집니다. Rust의 소유권 모델은 GC 일시 정지 없이 결정적인 메모리 관리를 제공하며, 이는 일관된 쿼리 성능에 결정적입니다.
  • 안전한 동시성. 그래프 데이터베이스는 병렬 순회의 혜택을 크게 받습니다. 여러 경로를 동시에 탐색하면 100ms짜리 쿼리가 10ms로 줄어들 수 있습니다. Rust의 타입 시스템은 컴파일 시점에 데이터 레이스를 막아주므로, 그래프 데이터를 망칠 걱정 없이 공격적으로 병렬화할 수 있습니다.
  • 작은 바이너리, 런타임 없음. 임베디드 데이터베이스에서는 배포 크기가 중요합니다. Rust 그래프 데이터베이스는 런타임 의존성 없이 단일 네이티브 라이브러리로 컴파일됩니다. 200MB짜리 런타임이 필요한 JVM 기반 솔루션이나, 요청한 적도 없는 가비지 컬렉터를 번들하는 Go 솔루션과 비교해 보세요.
  • C FFI. Rust는 C 호환 API를 노출할 수 있어서, 사실상 어떤 언어에서든 데이터베이스를 쓸 수 있습니다. Rust로 작성하고 Python, JavaScript, Go, Swift 등 C를 말할 수 있는 무엇에서든 호출하면 됩니다.

속성 그래프 모델

대부분의 임베디드 그래프 데이터베이스는 속성 그래프(property graph) 모델을 사용합니다. 그래프 데이터베이스를 다뤄 본 적이 없다면 이해해 둘 가치가 있습니다. 이 모델의 기본 요소는 세 가지입니다.

  • 노드 — 레이블과 키-값 속성을 가진 엔티티입니다. 고정된 스키마가 없다는 점만 빼면 테이블의 행이라고 생각하면 됩니다.
  • 간선 — 노드 사이를 잇는 방향성 연결로, 역시 레이블과 속성을 가집니다. 레이블은 관계의 종류를 나타냅니다(DEPENDS_ON, AUTHORED_BY, LINKS_TO).
  • 순회 — 간선을 따라 노드에서 노드로 이동하는 쿼리로, 속성으로 필터링하거나, 결과를 집계하거나, 경로를 찾을 수 있습니다.
// Conceptual API for an embeddable graph database
let db = GraphDB::open("my_graph.db")?;
// Create nodes
let alice = db.create_node("Person", props! {
"name" => "Alice",
"role" => "engineer"
})?;
let project = db.create_node("Project", props! {
"name" => "Auth Service",
"language" => "Rust"
})?;
// Create edges
db.create_edge(alice, project, "WORKS_ON", props! {
"since" => "2025-01"
})?;
// Traverse: find all projects worked on by Alice's teammates
let results = db.traverse()
.from(alice)
.follow("WORKS_ON")        // Alice -> projects
.reverse("WORKS_ON")       // projects <- other people
.follow("WORKS_ON")        // other people -> their projects
.filter(|node| node.label() == "Project")
.collect()?;

속성 그래프 모델은 빠르게 변하는 데이터에 대해 관계형 스키마보다 유연합니다. 스키마를 미리 정의할 필요도 없고, 새로운 관계 유형을 추가할 때 마이그레이션을 돌릴 필요도 없습니다. 새 레이블로 간선만 만들면 됩니다. 이런 스키마리스 유연성은 양날의 검입니다. 잘 정의된 스키마가 주는 안전성 보장은 잃게 되지만, 그래프 구조가 계속 진화하는 애플리케이션(지식 베이스, 소셜 네트워크, 의존성 추적)에서는 실용적인 트레이드오프입니다.

언제 실제로 그래프 데이터베이스를 써야 할까

그래프 데이터베이스는 필요 없는 상황에서 추천되기도 하고, 실제로 도움이 될 상황에서는 간과되기도 합니다. 제가 생각하는 강점과 약점을 솔직하게 정리해 보겠습니다.

잘 맞는 경우: 의존성 해석, 접근 제어(어떤 그룹 멤버십을 통해 누가 무엇에 접근할 수 있는가), 사기 탐지(엔티티 간 연결 찾기), 추천 엔진(X를 좋아한 사용자가 Y도 좋아함), 네트워크 토폴로지 분석, 지식 그래프, 코드 분석 도구. 공통점은 쿼리가 자연스럽게 경로와 연결성을 표현한다는 것입니다.

잘 맞지 않는 경우: 단순 CRUD 애플리케이션, 시계열 데이터, 분석/집계 작업, 그리고 쿼리가 주로 ‘조건 X에 맞는 레코드 가져오기’인 모든 경우. SELECT * FROM users WHERE country = 'US' ORDER BY created_at 같은 쿼리를 날린다면 관계형 데이터베이스가 맞는 도구입니다. 그냥 흥미롭게 들린다는 이유로 그래프 데이터베이스를 쓰지 마세요. 쿼리가 정말로 그래프 형태일 때 쓰면 됩니다.

제가 쓰는 판단 기준은 이것입니다. 데이터 모델을 화이트보드에 그려 보세요. 대부분 행에 나열된 상자(속성을 가진 엔티티)라면 관계형 데이터베이스를 쓰세요. 상자들이 화살표로 연결되어 있고 화살표가 상자만큼 중요하다면 그래프 데이터베이스를 고려해 보세요.

스토리지 엔진과 트레이드오프

내부적으로 임베디드 그래프 데이터베이스는 흥미로운 스토리지 엔진 결정에 직면합니다. 가장 흔한 접근법은 다음과 같습니다.

  • 인접 리스트 저장. 각 노드가 나가는 간선 목록을 저장합니다. 지역 순회(노드의 이웃 찾기)에는 빠르지만, 전역 쿼리(레이블 X를 가진 모든 간선 찾기)에는 느립니다. 대부분의 임베디드 그래프 데이터베이스는 지역 순회가 가장 흔한 연산이라서 이 방식을 씁니다.
  • 간선 리스트 저장. 간선을 별도의 정렬된 구조에 저장하고, 출발 노드, 도착 노드, 또는 레이블로 인덱싱합니다. 전역 쿼리와 대량 작업에는 더 낫지만, 지역 순회에는 간접 참조가 추가됩니다.
  • 하이브리드 방식. 일부 데이터베이스는 순회에는 인접 리스트를 쓰고, 필터링된 쿼리를 위해 간선 레이블이나 노드 속성에 보조 인덱스를 유지합니다. 가장 유연하지만 저장 공간을 더 쓰고 쓰기 비용이 높아집니다.

많은 Rust 기반 그래프 데이터베이스는 RocksDB나 sled 같은 기존 임베디드 키-값 저장소 위에 만들어집니다. 실용적인 선택입니다. 검증된 영속성, 장애 복구, 컴팩션을 공짜로 얻을 수 있습니다. 그래프 계층은 노드와 간선을 키-값 연산으로 매핑합니다. 단점은 성능의 한계가 키-값 저장소의 특성에 묶인다는 것이고, 캐시 친화적인 순회를 위해 노드의 간선을 디스크에 연속으로 저장하는 것 같은 그래프 특화 최적화는 구현하기 더 어려울 수 있다는 점입니다.

쿼리 언어: 아직 정해지지 않은 문제

SQL은 관계형 데이터베이스의 보편적인 언어입니다. 그래프 데이터베이스에는 이에 상응하는 합의가 없습니다. Cypher(Neo4j), Gremlin(Apache TinkerPop), SPARQL(RDF 그래프용), 그리고 새로 등장한 GQL 표준이 모두 주목받기 위해 경쟁하고 있습니다.

대부분의 임베디드 그래프 데이터베이스는 이 문제를 피해 가기 위해, 쿼리 언어 대신 호스트 언어의 빌더 패턴 API를 제공합니다. 메서드 체이닝으로 순회를 구성하는데, 타입이 있는 언어에서는 편리하고 쿼리 언어를 파싱하고 최적화하는 복잡성을 피할 수 있습니다. 트레이드오프는 쿼리가 데이터베이스 간에 이식되지 않는다는 점입니다. 임베디드 그래프 데이터베이스를 다른 것으로 바꾸면 쿼리 코드를 다시 작성해야 합니다.

GQL(Graph Query Language)은 그래프 쿼리를 통합하겠다는 ISO 표준입니다. Cypher에서 많이 차용했고 점차 채택이 늘고 있습니다. 오늘 임베디드 그래프 데이터베이스를 고르고 있다면 GQL 지원이 로드맵에 있는지 확인해 볼 만합니다. 표준화된 쿼리 언어는 독점 언어가 처음에는 더 세련되더라도, 장기적으로 이기는 경향이 있습니다.

실무 고려 사항

프로젝트에 임베디드 그래프 데이터베이스를 평가하고 있다면, 실제로 중요한 것은 다음과 같습니다.

  1. 여러분의 워크로드로 측정하세요. 그래프 데이터베이스 벤치마크는 악명 높게 오해를 부릅니다. 얕고 넓은 순회(소셜 네트워크의 친구의 친구)에 강한 데이터베이스가 깊고 좁은 순회(의존성 체인 해석)에서는 고전할 수 있습니다. 실제 쿼리 패턴으로 프로토타입을 만들어 보세요.
  2. 충돌 안전성을 확인하세요. 임베디드 데이터베이스는 내구성 보장에 따라 살고 죽습니다. 데이터베이스가 write-ahead 로깅을 사용하나요? 충돌에 안전한가요? 정전 후에도 데이터 손실 없이 복구할 수 있나요? ‘예기치 않은 종료 시 손상’은 프로덕션 사용에서 받아들일 수 없는 답입니다.
  3. 메모리 모델을 살펴보세요. 일부 임베디드 그래프 데이터베이스는 그래프 전체를 메모리 매핑하는데, 그래프가 사용 가능한 RAM을 넘기 전까지는 잘 동작합니다. 다른 것들은 캐싱을 곁들인 디스크 우선 방식을 씁니다. 선택한 데이터베이스가 어떤 모델을 쓰는지, 그리고 여러분의 그래프가 들어가는지 알아 두세요.
  4. 바인딩 품질을 고려하세요. 데이터베이스가 Rust로 작성되었지만 Python에서 쓴다면, Rust 내부 구현보다 Python 바인딩이 더 중요합니다. 바인딩이 유지 관리되고, 문서화되어 있고, 성능이 좋은지, 아니면 뒤늦게 붙인 것인지 확인하세요.
  5. 마이그레이션을 생각하세요. 스키마리스라고 해서 변경이 없는 것은 아닙니다. 그래프 모델이 진화하면 기존 데이터는 어떻게 처리할까요? 일부 데이터베이스는 마이그레이션 스크립트나 버전이 지정된 스키마를 지원합니다. 다른 곳은 전적으로 여러분에게 맡깁니다.

임베디드 그래프 데이터베이스 생태계는 관계형 세계에 비하면 아직 어립니다. SQLite는 20년 넘게 다듬어져 왔지만, 대부분의 임베디드 그래프 데이터베이스는 출시된 지 5년도 안 되었습니다. 그만큼 거친 부분이 많고, 커뮤니티 자료가 적으며, 위험도 더 큽니다. 하지만 서버 오버헤드 없이 그래프 쿼리를 제공한다는 핵심 가치는 탄탄합니다. 적합한 사용 사례라면, 임베디드 그래프 데이터베이스는 수백 줄의 재귀 SQL을 몇 줄의 순회 코드로 대체하고, 그 과정에서 더 빠르게 실행하며, 데이터 모델을 여러분이 푸는 문제와 실제로 맞아떨어지게 만들어 줄 수 있습니다.