물리 복제의 단순성과 논리 복제의 효율을 결합해, 지연·부분 복제와 강한 일관성을 제공하는 오픈 소스 트랜잭션 스토리지 엔진 Graft를 소개합니다. 에지·오프라인 환경에서 필요한 페이지만 객체 스토리지로 복제하며, SQLite 확장 libgraft, 활용 사례, 로드맵, 다른 SQLite 복제 솔루션과의 비교를 다룹니다.
URL: https://sqlsync.dev/posts/stop-syncing-everything/
Title: 모든 것을 동기화하지 마세요
back개요
부분 복제는 쉽다고들 합니다—앱에 필요한 데이터만 동기화하면 되니까요. 하지만 실제 접근 방식을 고르는 일은 까다롭습니다. 논리 복제는 모든 변경을 정밀 추적해 강한 일관성을 복잡하게 만들고, 물리 복제는 그 복잡함을 피하는 대신 버려질 변경까지 모두 동기화해야 하죠. 그렇다면 물리 복제의 단순함과 논리 복제의 효율을 결합할 수 있다면 어떨까요? 오늘 제가 공개하는 오픈 소스 트랜잭션 스토리지 엔진 **Graft**의 핵심 아이디어가 바로 그것입니다. Graft는 강한 일관성, 수평 확장성, 객체 스토리지 내구성을 갖춘 지연(lazy)·부분(partial) 복제에 특화되어 설계되었습니다.
읽는 것보다 보는 걸 선호하나요?
제 Vancouver Systems 발표에서 Graft를 시각적으로 설명합니다.
Graft는 다음 사용 사례를 염두에 두고 설계되었습니다:
저는 SQLSync를 만들면서 Graft의 필요성을 처음 깨달았습니다. SQLSync는 SQLite 위에 구축된 프런트엔드 최적화 데이터베이스 스택으로, Git과 분산 시스템의 아이디어에서 영감을 받은 동기화 엔진으로 구동됩니다. SQLSync는 브라우저에서 직접 실행되는 인터랙티브 앱을 위해 멀티플레이어 SQLite 데이터베이스를 현실로 만듭니다.
하지만 SQLSync는 일부 데이터베이스가 물리 복제를 구현하는 방식과 유사하게, 모든 변경 로그를 모든 클라이언트로 복제합니다. 이 방식은 서버에서는 잘 작동하지만, 에지와 브라우저 환경의 제약에는 잘 맞지 않습니다.
SQLSync를 출시한 뒤, 저는 에지에 더 적합한 복제 해법을 찾기로 했습니다. 제가 필요로 했던 것은 다음과 같았습니다:
그런 건 없었습니다. 그래서 직접 만들었습니다.

클라이언트와 서버 간 데이터를 동기화해 본 적이 있다면, 말처럼 쉽지 않다는 걸 아실 겁니다. 대부분의 기존 솔루션은 다음 두 부류로 나뉩니다:
Graft는 다른 길을 택합니다.
전체 복제처럼 Graft는 스키마에 구애받지 않습니다. 저장하는 데이터의 종류는 전혀 상관없습니다—그저 바이트를 복제할 뿐입니다2. 하지만 모든 데이터를 보내는 대신, 논리 복제에 더 가깝게 동작합니다. 즉, 클라이언트는 마지막 동기화 이후의 변경 사항을 간결하게 요약한 정보를 받습니다.
이 모델의 핵심에는 볼륨(Volume)이 있습니다. 볼륨은 희소(sparse)하고 순서가 있는 고정 크기 페이지(Page)의 모음입니다. 클라이언트는 특정 스냅샷(Snapshot)에서 읽고 쓰는 트랜잭션 API를 통해 볼륨과 상호작용합니다. 내부적으로 Graft는 필요한 것만 영속화하고 복제하며, 내구성과 확장성을 가진 백엔드로 객체 스토리지를 사용합니다.
그 결과, Graft는 지연형이고, 부분적이며, 에지 친화적이고, 일관적입니다.
Graft의 매니지드 버전을 써 보고 싶으신가요?
얼리 액세스를 위해 대기자 명단에 등록하세요: 여기에서 신청 →
이제 각 특성을 하나씩 살펴보겠습니다.
Graft는 현실 세계를 위해 설계되었습니다—에지 클라이언트는 종종 깨어나고, 불안정한 네트워크를 겪으며, 짧게 실행되는 자원 제약 환경에서 돌아갑니다. 지속적인 복제에 의존하는 대신, 클라이언트가 동기화할 시점을 선택하고 Graft는 최신 스냅샷으로 빠르게 건너뛸 수 있게 도와줍니다.
동기화는 단순한 질문에서 시작합니다. 마지막 스냅샷 이후 무엇이 바뀌었지?

서버는 graft—마지막 스냅샷 이후 모든 커밋에 걸쳐 변경된 페이지 인덱스를 담은 컴팩트한 비트셋—으로 응답합니다3. 프로젝트 이름도 여기서 따왔습니다. graft는 기존 스냅샷에 새 변경을 “접붙이는” 역할을 합니다. 클라이언트에게 어떤 페이지는 재사용 가능하고, 어떤 페이지는 필요 시 가져와야 하는지 알려주는 안내서 역할을 합니다.
중요한 점은, 클라이언트가 서버에서 graft를 가져올 때 실제 데이터는 전혀 받지 않는다는 것입니다—오직 변경에 관한 메타데이터만 받습니다. 덕분에 클라이언트가 무엇을 언제 가져올지 완전히 통제할 수 있고, 이는 부분 복제의 기반이 됩니다.
브라우저 탭, 모바일 앱, 서버리스 함수 같은 에지 환경을 위해 개발할 때, 소수의 쿼리를 처리하려고 전체 데이터셋을 내려받을 여유는 없습니다. 그래서 부분 복제가 필요합니다.
클라이언트는 graft를 가져온 뒤 정확히 무엇이 바뀌었는지 알게 됩니다. 이를 활용해 어떤 페이지는 여전히 유효하고, 어떤 페이지는 가져와야 하는지 정밀하게 판별할 수 있습니다. 모든 것을 끌어오는 대신, 실제로 사용할 페이지들만 선별적으로 가져옵니다—그 이상도 그 이하도 아닙니다.

응답성을 높이기 위해 Graft는 여러 사전 패치(prefetch) 방식을 지원합니다:
그리고 Graft는 페이지를 객체 스토리지에서 직접 호스팅하므로, 본질적으로 내구성과 확장성을 지니며 에지 네이티브 복제의 견고한 토대를 이룹니다.

에지 복제는 단지 어떤 데이터를 동기화할지 고르는 문제에 그치지 않습니다—그 데이터가 실제로 필요한 곳에 존재하도록 보장하는 일입니다. Graft는 두 가지 핵심 방식으로 이를 실현합니다.
첫째, 페이지는 전 세계 에지 서버 플릿을 통해 객체 스토리지에서 제공되어, 자주 접근되는(“핫”) 페이지가 클라이언트 가까이에 캐시됩니다. 이를 통해 사용자가 어디에 있든 지연시간을 낮추고 반응성을 높입니다.
둘째, Graft 클라이언트 자체가 가볍고 임베딩에 특화되어 설계되었습니다. 의존성이 최소이고 런타임이 매우 작아, 브라우저·디바이스·모바일 앱·서버리스 함수 같은 제약된 환경에 쉽게 통합됩니다.
결과적으로, 데이터는 가장 가치 있는 위치—바로 에지이자 애플리케이션 내부—에 정확히 캐시됩니다.
하지만 에지에서 데이터를 캐시하면, 일관성을 유지하고 충돌을 안전하게 처리하는 등 새로운 과제가 생깁니다. 여기서 Graft의 견고한 일관성 모델이 등장합니다.
강한 일관성은 매우 중요합니다—특히 가끔 충돌할 수 있는 클라이언트 간 동기화에서는 더더욱 그렇습니다. Graft는 명확하고 견고한 일관성 모델인 직렬 가능 스냅샷 격리(Serializable Snapshot Isolation)를 제공합니다.5
이 모델은 특정 스냅샷에서 고립되고 일관된 데이터 뷰를 제공해, 읽기가 서로 간섭 없이 동시에 진행될 수 있게 합니다. 동시에 모든 쓰기가 엄격히 직렬화되도록 보장하여, 각 트랜잭션에 대해 명확하고 전역적으로 일관된 순서를 제공합니다.
하지만 Graft는 오프라인 우선, 지연 복제를 염두에 두고 설계되었기 때문에, 클라이언트가 오래된 스냅샷을 바탕으로 변경을 커밋하려 시도할 때가 있습니다. 이를 무턱대고 받아들이면 엄격한 직렬 가능성을 위반하게 됩니다. 대신 Graft는 커밋을 안전하게 거절하고 클라이언트가 상황을 어떻게 해결할지 선택하게 합니다. 일반적으로 클라이언트는 다음 중 하나를 수행합니다:
리셋 후 재실행: 최신 스냅샷을 가져와 로컬 트랜잭션을 재적용하고 다시 시도합니다. * 전역적으로 데이터는 계속 엄격 직렬 가능성을 유지합니다. * 로컬에서는 클라이언트가 **낙관적 스냅샷 격리(Optimistic Snapshot Isolation)**를 경험합니다. 의미는 다음과 같습니다:
로컬 상태를 서버의 최신 스냅샷과 머지합니다. 이는 전역 일관성 모델을 스냅샷 격리로 저하시킬 수 있습니다.
볼륨을 **영구적으로 포크(fork)**하여 새롭고 분리된 볼륨을 생성—전역 직렬 가능성을 유지합니다.

요약하면, Graft는 클라이언트가 산발적으로 동기화하거나, 오프라인으로 동작하거나, 동시 쓰기와 충돌하더라도 일관성을 포기할 필요가 없도록 보장합니다.
지연 동기화, 부분 복제, 에지 친화적 배포, 강한 일관성을 결합해, Graft는 다양한 에지 네이티브 애플리케이션의 견고한 기반을 제공합니다. 다음은 Graft로 만들 수 있는 몇 가지 예시입니다:
오프라인 우선 앱: 메모, 작업 관리, CRUD 앱처럼 부분적으로 오프라인에서 동작하는 앱. Graft가 동기화를 책임지므로, 애플리케이션은 네트워크의 존재를 잊을 수 있습니다. 충돌 처리기를 더하면, 임의의 데이터 위에서도 멀티플레이어를 구현할 수 있습니다.
크로스 플랫폼 데이터: 벤더 종속을 없애고 사용자가 모바일·디바이스·웹 전반에서 자신의 데이터에 끊김 없이 접근하도록 하세요. Graft는 어디에나 임베드될 수 있도록 설계되었습니다6.
상태 없는 읽기 복제본: Graft의 독특한 복제 방식 덕분에, 데이터베이스 복제본은 로컬 상태 없이도 최신 스냅샷 메타데이터만 가져와 즉시 쿼리를 실행하기 시작할 수 있습니다. 모든 데이터를 내려받고 로그를 재생할 필요가 없습니다.
무엇이든 복제: Graft는 일관된 “페이지” 복제에만 집중합니다. 페이지 내부의 내용은 개의치 않습니다. 그러니 맘껏 활용하세요! Graft로 AI 모델, Parquet나 Lance 파일, 지리공간 타일셋, 혹은 당신의 고양이 사진까지 동기화하세요. Graft와 함께라면 한계는 하늘뿐입니다.
libgraft)오늘날 libgraft는 Graft를 사용하기 가장 쉬운 방법입니다. SQLite가 동작하는 어디에서나 사용할 수 있는 네이티브 SQLite 확장입니다. 클라이언트가 실제로 사용하는 데이터베이스의 일부만 Graft로 복제하여, 자원 제약 환경에서도 SQLite를 실행할 수 있게 합니다.
libgraft는 SQLite 가상 파일 시스템(VFS)을 구현하여 데이터베이스에 대한 모든 읽기/쓰기를 가로챕니다. 또한 WAL 모드에서 실행될 때와 동일한 트랜잭션·동시성 의미를 제공합니다. libgraft를 사용하면 애플리케이션은 다음과 같은 이점을 얻습니다:
libgraft 사용에 관심이 있다면, 여기에서 문서를 확인하세요.
Graft는 GitHub에서 공개적으로 개발되며, 커뮤니티의 기여를 환영합니다. 이슈를 열거나, 논의에 참여하거나, 풀 리퀘스트를 제출할 수 있습니다—자세한 내용은 기여 가이드를 확인하세요.
Graft에 대해 대화를 나누고 싶다면, Discord에 참여하시거나 이메일을 보내 주세요. 에지에서의 지연·부분 복제에 대한 Graft의 접근법에 대한 의견을 듣고 싶습니다.
또한 Graft 매니지드 서비스를 출시할 계획입니다. 대기자 명단에 합류하고 싶다면 여기에서 등록하세요.
아직 끝이 아닙니다!
아래에서 Graft의 로드맵과 기존 SQLite 복제 솔루션과의 상세 비교를 확인하세요.
Graft는 1년간의 연구, 수많은 반복, 그리고 한 번의 대대적인 방향 전환7의 결과물입니다. 하지만 아직 끝나지 않았습니다. 해야 할 일이 많고 로드맵은 야심차죠. 순서는 상관없이, 다음을 계획하고 있습니다:
WebAssembly 지원: WebAssembly(Wasm)를 지원하면 브라우저에서도 Graft를 사용할 수 있습니다. 궁극적으로는 SQLite 공식 Wasm 빌드, wa-sqlite, sql.js까지 지원하고 싶습니다.
Graft와 SQLSync 통합: Graft가 Wasm을 지원하면 SQLSync와의 통합은 간단해집니다. 계획은 SQLSync의 뮤테이션, 리베이스, 쿼리 구독 레이어를 분리해, Graft 복제를 사용하는 데이터베이스 위에 올려두는 것입니다.
더 많은 클라이언트 라이브러리: Python, Javascript, Go, Java 등 인기 언어를 위한 네이티브 Graft 클라이언트 래퍼를 보고 싶습니다. 이를 통해 SQLite에 국한되지 않고 해당 언어들에서 임의의 데이터를 복제할 수 있게 됩니다8.
저지연 쓰기: 현재 Graft는 푸시 작업이 객체 스토리지에 완전히 커밋될 때까지 대기합니다. 이는 여러 방식으로 개선할 수 있습니다:
가비지 컬렉션, 체크포인팅, 컴팩션: 쿼리 성능을 극대화하고 낭비 공간을 최소화하며 데이터를 영구 삭제할 수 있게 하는 데 필요한 기능입니다. 모두 Graft가 데이터를 객체 스토리지에 직접 저장하고 변경을 세그먼트(segment)라는 파일로 배치(batch)하는 설계와 관련이 있습니다.
인증과 인가: Graft 매니지드 서비스의 계정부터 볼륨 읽기/쓰기의 세분화된 권한 부여까지 포괄하는 비교적 넓은 과제입니다.
볼륨 포크(forking): Graft 서비스는 이미 세그먼트 참조를 새 볼륨으로 쉽게 복사할 수 있어 제로-카피 포크를 수행하도록 구성되어 있습니다. 다만 로컬 포크를 수행하려면 현재 모든 페이지를 복사해야 합니다. 로컬에 볼륨을 레이어링하고 읽기를 폴스루(하위 레이어로) 허용하거나, 로컬 페이지 주소 지정 방식을 변경하여 해결할 수 있습니다.
충돌 처리: Graft는 내장된 충돌 해결 전략과 확장 지점을 제공해 애플리케이션이 충돌 처리 방식을 제어할 수 있어야 합니다. 초기 기본 전략은 겹치지 않는 트랜잭션을 자동으로 머지하는 것입니다. 이는 전역 일관성을 낙관적 스냅샷 격리로 완화하지만, 협업/멀티플레이어 시나리오에서 성능을 크게 높일 수 있습니다.
Graft는 많은 선행 프로젝트의 아이디어 위에 자신만의 고유한 기여를 더합니다. 아래는 SQLite 복제 생태계의 간략한 조망과 Graft의 비교입니다.
주의
이 섹션의 정보는 문서와 블로그 게시물에서 수집되었으며 완전히 정확하지 않을 수 있습니다. 제가 어떤 프로젝트를 잘못 표현하거나 오해했다면 알려주세요.
SQLite 기반 프로젝트 중 mvSQLite는 개념적으로 Graft와 가장 가깝습니다. FoundationDB에 SQLite 페이지를 직접 저장하는 커스텀 VFS 레이어를 구현합니다.
mvSQLite에서는 각 페이지가 콘텐츠 해시로 저장되고 (page_number, snapshot version)으로 참조됩니다. 이 구조 덕분에 리더는 필요할 때 FoundationDB에서 페이지를 지연 가져오기(lazy fetch)할 수 있습니다. 페이지 수준 버저닝을 활용해, 읽기/쓰기 집합이 겹치지 않는 한 동시 쓰기 트랜잭션을 지원합니다.
대조: Graft와의 비교: Graft와 mvSQLite는 페이지 수준 버저닝을 사용해 지연형 온디맨드 가져오기와 부분 데이터베이스 보기를 허용한다는 점에서 유사한 스토리지 레이어 설계를 공유합니다. 핵심 차이는 데이터 저장 위치와 페이지 변경 추적 방식에 있습니다. mvSQLite는 FoundationDB에 의존해 모든 노드가 클러스터에 직접 접근해야 하므로, 폭넓게 분산된 에지 디바이스와 웹 애플리케이션에는 적합하지 않습니다. 또한 Graft의 Splinter 기반 변경셋은 자기완결적(self-contained)이고 쉽게 배포할 수 있으며, 변경된 페이지 버전을 알아내기 위해 FoundationDB에 직접 질의할 필요가 없습니다.
Litestream은 SQLite WAL 프레임을 객체 스토리지로 지속적으로 복제하는 스트리밍 백업 솔루션입니다. 비동기 내구성, 시점 복구, 읽기 복제본을 주된 목표로 하며, 애플리케이션 외부에서 실행되어 파일시스템을 통해 SQLite의 WAL을 모니터링합니다.
대조: Graft와의 비교: Litestream과 달리, Graft는 커스텀 VFS를 통해 SQLite의 커밋 과정에 직접 통합되어 지연형·부분 복제와 분산 쓰기를 가능하게 합니다. Litestream처럼 Graft도 객체 스토리지로 페이지를 복제하고 시점 복구를 지원합니다.
cr-sqlite는 테이블을 CRDT(Conflict-free Replicated Data Types)로 바꿔 행 수준의 논리 복제를 가능하게 하는 SQLite 확장입니다. 자동 충돌 해결을 제공하지만, 스키마 인지와 애플리케이션 수준 통합이 필요합니다.
대조: Graft와의 비교: Graft는 스키마에 구애받지 않으며 논리적 CRDT에 의존하지 않아, 임의의 SQLite 확장과 커스텀 데이터 구조와도 호환됩니다. 다만 전역 직렬 가능성을 달성하려면 애플리케이션이 명시적으로 충돌 해결을 처리해야 합니다. 반면 cr-sqlite는 여러 작성자의 변경을 자동으로 병합하여 인과 일관성을 달성합니다.
Durable Objects와 SQLite를 결합하면 Cloudflare의 거대한 에지 네트워크에서 사용자와 가깝게 호스팅되는, 비즈니스 로직으로 감싼 강한 일관성과 높은 내구성을 가진 데이터베이스를 얻을 수 있습니다. 내부적으로는 SQLite WAL을 객체 스토리지에 복제하고 주기적으로 체크포인트를 수행한다는 점에서 Litestream과 유사합니다.
대조: Graft와의 비교: Graft는 복제를 일급 기능으로 노출하며, 에지로의 효율적 복제와 에지로부터의 복제를 위해 설계되었습니다. 반면 Durable Objects의 SQLite는 Durable Objects를 SQLite의 모든 기능으로 확장하는 데 초점을 맞춥니다.
Cloudflare D1은 Amazon RDS나 Turso 같은 전통적 데이터베이스 서비스와 유사하게 동작하는 매니지드 SQLite 데이터베이스로, 애플리케이션은 HTTP API를 통해 접근합니다.
대조: Graft와의 비교: Graft는 데이터를 에지로 직접 복제하여 클라이언트 애플리케이션 내부에 임베드합니다. 이러한 분산형 복제 모델은 D1의 중앙집중식 데이터 서비스와 크게 대조됩니다.
Turso는 libSQL이라는 오픈 소스 SQLite 포크를 통해 매니지드 SQLite 데이터베이스와 임베디드 복제본을 제공합니다. Litestream과 Cloudflare Durable Objects SQL Storage와 유사하게, Turso는 SQLite WAL 프레임을 객체 스토리지로 복제하고 주기적으로 체크포인트를 생성합니다. 복제본은 이 체크포인트를 가져와 로그를 재생함으로써 따라잡습니다.
대조: Graft와의 비교: Graft는 부분 복제와 임의의 스키마 비인지 데이터 구조 지원으로 차별화됩니다. Graft의 백엔드 서비스는 페이지 수준에서 직접 동작하고, 전체 트랜잭션 라이프사이클을 클라이언트로 위임합니다.
rqlite와 dqlite의 핵심 아이디어는 SQLite를 여러 서버에 분산시키는 것입니다. 이는 Raft 기반 합의와, SQLite 연산을 현재 Raft 리더에게 네트워크 프로토콜을 통해 라우팅하는 방식으로 달성됩니다.
대조: Graft와의 비교: 이 프로젝트들은 합의와 전통적 복제를 통해 SQLite의 내구성과 가용성을 높이는 데 집중합니다. 상호 연결을 유지하는 상태 있는 노드 집합을 동기화 상태로 유지하도록 설계되었습니다. Graft는 객체 스토리지 위에 구축된 상태 없는 시스템으로, 에지로부터 에지로의 데이터 복제를 위해 설계되었다는 점에서 근본적으로 다릅니다.
Verneuil은 객체 스토리지를 통해 SQLite 스냅샷을 읽기 복제본으로 비동기 복제하는 데 초점을 맞추며, 추가적인 장애 모드를 도입하지 않으면서 신뢰성을 우선합니다. Verneuil은 복제 지연이나 최신성(staleness)을 최소화하는 메커니즘을 명시적으로 회피합니다.
대조: Graft와의 비교: Graft는 다중 작성 분산 데이터베이스에 더 가깝게 동작하면서, 선택적인 실시간 부분 복제에 중점을 둡니다. 반면 Verneuil은 복제 최신성에 대한 보장 없이 단방향 비동기 스냅샷 복제를 강조합니다.
예: SQLite 데이터베이스, JSON 문서, 파일, 심지어 커스텀 바이너리 포맷까지 ↩
엄밀히 말하면, Graft는 바이트로 가득 찬 페이지를 복제합니다. 이를 통해 메타데이터 오버헤드를 작게 유지할 수 있습니다. Graft가 모든 바이트를 추적한다면, 메타데이터가 데이터보다 더 많아질 겁니다! ↩
내부적으로 graft는 Roaring Bitmaps를 기반으로 하지만 작은 희소 32비트 정수 집합에 최적화된 커스텀 비트맵 포맷 Splinter로 인코딩됩니다. Splinter는 제로-카피 읽기를 지원하여, 메모리 매핑 파일과 네트워크 버퍼에서 graft를 직접 조회할 수 있습니다. ↩
패턴 탐지와 데이터베이스 인덱스를 조합하면, 과다 페치 없이 왕복 횟수를 놀랍도록 잘 줄일 수 있습니다. ↩
Graft의 격리 모델에 대한 더 자세한 내용은 readme에서 확인할 수 있습니다. ↩
제발 Graft를 색다른 데 넣어보세요. 플레이스테이션 2? 라즈베리 파이? 왜인지 인터넷에 연결된 당신의 칫솔? ↩
저는 콘텐츠 주소 지정 복제 아이디어에 홀렸습니다. 물리 페이지 추적에 최적화된 머클 트리를 보고 싶다면, 언젠가 그 작업물을 공개할지도 모르겠습니다. ↩
libgraft라는 Graft의 네이티브 SQLite 확장은 이미 대부분의 SQLite 라이브러리와 함께 작동하며 특정 언어 지원을 요구하지 않습니다. ↩
이 이름은 조금 길어요. Cloudflare, 이 글을 보고 있다면, 멋진 제품에는 간결한 이름이 필요하잖아요(Cloudflare D2 같은?). ↩