원문 캡처 · delta.dev
원문 캡처 · delta.dev

Zed Industries는 에이전트와 사람이 만든 코드 변경을 실시간으로 기록하고, 각 변경을 이를 낳은 대화와 연결하는 버전 관리 시스템 DeltaDB를 개발했다. 이 시스템은 Rust와 SQLite, Cloudflare Durable Objects를 활용해 에이전트 작업의 맥락을 추적하고 여러 참여자의 변경을 동기화한다.

2021년 Nathan, Max와 함께 Zed Industries를 설립했을 때 목표는 빠른 코드 편집기를 만들고 팀의 소프트웨어 협업 방식을 바꾸는 것이었다. 현재 수백만 명이 Zed를 사용하고 있으며, 지난 1년 동안 두 번째 목표를 위해 작업해 왔다.

소프트웨어를 작성하는 일은 문제를 명확히 이해해 코드가 자연스럽게 도출될 때까지 생각을 다듬는 과정이다. 에이전트가 이 과정을 바꾸지는 않았다. 코드가 더 빠르게 만들어지고, 문제를 명확히 하는 대화가 에이전트와 더 많이 이뤄질 뿐이다. 이런 대화의 맥락은 특정 에이전트 하네스 안에 갇히거나, 슬랙에서 사라지거나, 에이전트가 작성했지만 읽지 않은 커밋 메시지로 압축될 수 있다.

DeltaDB는 에이전트 맥락을 위한 버전 관리 시스템이다. 코드의 한 부분을 지정해 그 뒤에 있는 추론을 확인하고, 해당 코드를 작성한 에이전트나 사람에게 왜 그렇게 작성했는지 물을 수 있다.

데이터 구조와 변경 기록

DeltaDB는 여러 충돌 없는 복제 데이터 유형(CRDT)을 SQLite에 로컬로 저장하고, 이를 방향성 비순환 그래프(DAG)로 연결한다. 그래프의 각 노드는 델타(delta)라 부르며, 표현 가능한 가장 작은 변경 단위다. 이를 조합해 대화, 파일과 주변 상태를 포함하는 에이전트 스레드의 상태를 모델링한다.

CRDT를 사용하면 클라이언트가 각자 작업을 진행하고 네트워크 장애나 단절에도 대응할 수 있다. 상태는 나중에 서버와 동기화되지만 클라이언트가 계속 연결돼 있을 필요는 없다. 변경은 먼저 로컬에 적용되고 연결이 가능할 때 동기화된다. 따라서 스레드를 비동기적으로 넘기는 방식부터 팀원과 실시간으로 협업하는 방식까지 구현할 수 있다.

DAG는 시스템에서 가능한 모든 중간 상태를 표현한다. 덕분에 스레드와 작업 트리의 기록을 오가며 살펴볼 수 있다. 시스템의 전체 상태도 그래프 끝에 있는 델타, 즉 프런티어(frontier)만으로 간결하게 표현할 수 있다. 프런티어의 크기는 지금 열려 있는 브랜치 수에 따라 늘어나며, 과거에 글을 쓴 사람 수에 따라 늘어나지는 않는다. 이 설계는 Gentle과 Kleppmann이 Eg-walker 논문에서 발전시킨 작업과 유사하다.

DAG의 주된 단점은 한 델타에 다른 델타의 영향이 이미 포함됐는지 확인하려면 그래프를 탐색해야 한다는 점이다. 버전 벡터를 쓰면 카운터를 비교해 더 저렴하게 확인할 수 있지만, 버전 벡터는 행위자 수가 적고 오래 유지된다는 가정에 기반한다. 스레드에서는 사람과 에이전트가 계속 들어오고 나간다. 행위자가 변경을 만들고 나면 그 카운터를 벡터에서 제거할 수 없어, 떠난 뒤에도 벡터가 계속 커진다. 점이 있는 버전 벡터에도 비슷한 문제가 있다.

이 시스템은 어차피 모든 버전을 저장하므로 조상 노드를 조회하는 방식은 감수할 만한 절충이다. 델타의 조상 정보는 변경 내용과 분리해 저장한다. 조상 데이터는 작고 인과 순서대로 저장돼, 관련 조회에서 데이터의 지역성을 활용할 수 있다.

각 델타를 DAG의 노드로 만들면 네트워크에 보내는 표현과 CRDT 내부에서 변경 영향을 나타내는 방식을 분리할 수도 있다. 텍스트 편집은 다음과 같이 정의한다.

struct EditTextDelta {
 parent: Version,
 offset: usize,
 text: String,
}

parent와 offset만으로 동시 편집이 있어도 버퍼 내 위치를 고유하게 지정할 수 있다. 대개 버퍼는 이미 parent 버전에 있으므로 편집을 바로 적용한다. 그렇지 않으면, 예를 들어 그 사이에 동시 편집이 발생했다면 클라이언트가 parent 버전의 버퍼를 복원한다. 그런 다음 오프셋을 내부 표현(이 경우 Zed에서 사용하는 방식과 비슷한 조각 ID)으로 변환하고, 편집을 적용할 버전의 버퍼에서 해당 조각 ID를 찾는다.

이 방식은 네트워크로 전송하는 델타의 크기를 줄이고, 내부 조각 표현을 바꾸거나 사용하지 않는 조각을 메모리에서 내보낼 수 있게 한다.

B+ 트리와 버전 복원

모든 데이터는 SQLite에 저장한다. 대략 관계형 데이터와 버전 관리 데이터로 나뉜다. 클라이언트에서 생성하거나 연 스레드 같은 관계형 데이터는 일반 테이블에 저장한다.

버전 관리 데이터에서는 임의의 버전을 빠르게 복원하는 일이 중요하다. 동시 델타를 적용할 때도 버전 복원이 필요하며, 기록을 살펴보거나 오래된 스레드 버전의 작업 트리를 파일 시스템에 마운트할 때 오래 기다리지 않아야 한다.

이를 위해 DAG에서 최근 사용된 노드와 일부 중요하게 지정한 버전의 전체 상태 스냅샷을 캐시한다. 원하는 버전에서 시작해 DAG를 거슬러 올라가 캐시된 스냅샷이 있는 가장 가까운 조상을 찾고, 그 사이의 델타를 적용한다.

캐시된 버전마다 CRDT 전체를 복사해 저장하면 비용이 지나치게 커진다. 대신 전체 상태를 쓰기 시 복사(copy-on-write) 방식의 B+ 트리로 표현한다. 이전 스냅샷의 노드를 대부분 재사용하고, 재생한 델타가 건드린 경로에 대해서만 비용을 지불한다.

DeltaDB의 B+ 트리는 Zed의 SumTree를 변형한 구조다. Zed의 SumTree는 내부 노드가 자식 노드의 요약을 담는 추가 전용 B+ 트리다. SumTree를 편집하려면 삽입 지점까지 트리의 앞부분을 잘라내고, 새 항목을 넣고 삭제할 항목을 건너뛴 다음 뒷부분을 덧붙여 잘라낸 경로의 모든 노드를 다시 만들어야 한다. 반면 DeltaDB의 B+ 트리는 변경 가능한 커서로 제자리에서 수정한다.

이 방식은 트리에 필요한 조정 횟수를 크게 줄여 더 빠르다. 제자리 변경을 통해 어떤 노드가 할당되거나 수정되거나 해제됐는지도 정확히 추적할 수 있다. 커밋할 때 새 노드는 삽입되고, 수정된 노드는 기존 행에서 다시 작성되며, 해제된 노드는 참조 횟수만 감소한다. 모든 노드는 범용 nodes 테이블에 저장된다.

타임라인과 동기화

클라이언트는 타임라인을 사용해 DAG의 여러 프런티어를 추적할 수 있다. 예를 들어 하위 에이전트를 시작할 때 부모 스레드의 프런티어에 새 타임라인을 만들고, 병렬로 변경을 시작한다. 모든 타임라인은 하나의 DAG를 공유하므로 어느 시점에서든 다시 병합할 수 있다. 이 과정은 Git을 건드리지 않으며, 에이전트 간 협업을 위해 커밋할 필요도 없다.

타임라인은 클라이언트 간 동기화에도 쓰인다. 스레드를 공유하면 클라이언트는 서버에 WebSocket 연결을 열고 관심 있는 타임라인을 구독한다. 각 타임라인에서 양쪽이 마지막 연결 이후 놓친 델타를 교환한다. 처음 서버에 연결하는 경우에는 전체 델타를 교환한다. DAG를 탐색하고 로그 간격으로 배치한 버전 목록을 보내 최근의 공통 조상을 찾는다. 이후 새 델타는 WebSocket을 통해 하나씩 전송돼 서버에 저장되고, 해당 타임라인을 구독한 다른 클라이언트에 전달된다.

재연결도 같은 방식으로 처리하므로 델타 전달의 정확성을 추론하기 쉽다.

스레드마다 하나의 Durable Object

앞으로 한 사람이 점점 더 많은 에이전트를 만들고 상호작용할 것으로 본다. 이런 상황에서 모든 스레드 상태를 하나의 서버에 보관하기는 어렵다.

DeltaDB에서는 각 스레드를 전용 Cloudflare Durable Object에 동기화한다. 객체는 클라이언트가 처음 연결할 때 생성되며, 서버에서 스레드를 만드는 데 드는 비용은 없다. 클라이언트는 관심 있는 스레드마다 WebSocket 연결을 하나씩 만든다.

연결을 시작할 때만 이뤄지는 인증을 제외하면, 이 구조는 각 구성 요소가 서로의 상태를 공유하지 않는 방식이다. Cloudflare가 트래픽을 처리할 수 있는 범위에서 스레드 수에 따라 수평 확장할 수 있다.

이 구조는 스레드 간 확장을 지원하지만, 하나의 스레드 안에서도 여러 에이전트와 사람이 동시에 많은 변경을 만들 수 있다. Durable Object에서 이를 효율적이고 비용 효과적으로 처리하는 데도 공을 들였으며, 그 방법은 추후 글에서 다룰 예정이다.

Cloudflare는 DeltaDB의 첫 배포 대상이다. 현재 멀티테넌트 배포와, 자체 Cloudflare 계정을 사용하려는 고객을 위한 싱글테넌트 배포에서 운영된다. 다만 객체 저장소, 인증용 장애 허용 Postgres 데이터베이스, 자체 트랜잭션 저장소를 갖춘 주소 지정 가능한 단일 스레드 프로세스를 실행할 방법이 있는 플랫폼이라면 다른 곳으로도 이식할 수 있도록 설계했다.

클라이언트 API

실제로 DeltaDB를 사용할 때는 앞서 설명한 내부 구조를 직접 다룰 필요가 없다. 시스템은 다음 기본 요소를 제공하는 상위 수준의 클라이언트 API로 노출된다.

  • Register<T>는 마지막 작성자의 값을 우선하는 컨테이너다.
  • Map<K, V>는 추가한 항목을 우선하는 맵이다.
  • Buffer는 로프 자료 구조를 기반으로 하는 협업용 텍스트다.
  • Worktree는 Git 위에 계층을 이루는 파일 트리로, 디스크 어디에나 마운트할 수 있다. 작업 트리는 커밋에서 시작하며 각 파일은 커밋된 블롭에 고정되거나 이후 작성된 델타를 담는다. 커밋을 만들면 델타도 생성돼 DAG와 Git을 연결한다.

오래 유지되는 deltadb::Client 구조체를 사용해 기본 요소와 상호작용할 수 있다.

client.create(|cx| {
 let register = cx.create_register(42);
 register.set(43, cx);

 let map = cx.create_map();
 map.insert("a", 1, cx);
 map.insert("b", 2, cx);

 let buffer = cx.create_buffer("hello, world");
 buffer.edit([(0..5, "goodbye")], cx);
});

기본 요소를 업데이트하면 새 델타가 자동으로 만들어져 DAG에 추가되고, 사용자 대신 디스크와 서버에 동기화된다. #[derive(Replicate)] 매크로를 이용해 기본 요소를 더 복잡한 구조체로 조합할 수도 있다. 에이전트 스레드도 이런 방식으로 표현한다.

#[derive(Clone, Replicate)]
struct Thread {
 #[field(0)]
 transcript: Buffer,
 #[field(1)]
 name: Register<String>,
 #[field(2)]
 worktree: Worktree,
 #[field(3)]
 comments: Map<u64, Comment>,
}

#[derive(Clone, Replicate)]
struct Comment {
 #[field(0)]
 body: Buffer,
 #[field(1)]
 resolved: Register<bool>,
}

내부적으로 이 유형들은 Snapshot이라 부르는 하나의 큰 평면 CRDT를 가리키는 포인터다. 포인터는 field 속성을 통해 컴파일 시점에 만들거나, 해당 포인터를 만든 델타의 ID를 사용해 실행 중에 만들 수 있다.

#[derive(Clone)]
struct Register<T> { id: RegisterId, _type: PhantomData<T> }

#[derive(Clone)]
struct Map<K, V> { id: MapId, _type: PhantomData<(K, V)> }

#[derive(Clone)]
struct Buffer { id: BufferId }

#[derive(Clone)]
struct Worktree { id: WorktreeId }

#[derive(Clone)]
struct Snapshot {
 registers: CowBTree<RegisterId, RegisterSnapshot>,
 maps: CowBTree<MapId, MapSnapshot>,
 buffers: CowBTree<BufferId, BufferSnapshot>,
 worktrees: CowBTree<WorktreeId, WorktreeSnapshot>,
}

클라이언트 API는 평면 스냅샷 자체를 직접 노출하지 않고, 위에서 설명한 포인터 방식으로 데이터를 제공한다.

Delta의 기본 UI와 연결하기

현재 CLI나 서드파티 에이전트 하네스와 통합하는 플러그인을 통해 DeltaDB를 사용할 수 있다. CLI는 이미 별도 프로세스에서 실행되므로 SQLite와 직접 상호작용해도 문제가 없다.

Zed Industries는 전체 스택을 직접 관리해 다른 제품이 정한 에이전트 사용 방식에 맞추기보다 원하는 사용자 경험을 설계하려 한다. 이에 자체 에이전트 하네스인 Delta를 만들었다. Delta가 에이전트 코딩 분야에서 최첨단이라고 보고 있으며, Zed의 속도를 뒷받침하는 기반 기술 중 상당수를 공유한다.

네이티브 UI에서는 상황이 다르다. 화면 주사율에 맞춰 렌더링해야 하므로 메인 스레드에서 차단형 입출력 같은 비용이 큰 작업을 하면 8밀리초의 엄격한 프레임 예산을 훌쩍 넘게 된다.

위에서 소개한 API는 메인 스레드에서 사용하도록 설계했다. 초기 로드 시 데이터베이스에서 비동기적으로 읽어올 수 있는 시점에 Arc로 할당한 Snapshot을 만들고 유지한다. 이후 메인 스레드는 복제된 기본 요소를 조작하며 동기적으로 델타를 생성한다. 그 델타는 스냅샷의 복제본과 함께 백그라운드 스레드로 비동기 전송된다. 스냅샷 복제는 쓰기 시 복사 자료 구조만 사용하므로 비용이 적다.

백그라운드에서는 전용 쓰기 스레드 하나와 머신의 코어 수에 따라 읽기 스레드 두 개에서 네 개를 둔다. 전경 스레드가 방금 만든 델타를 전달하면 쓰기 스레드는 이를 SQLite에 영속적으로 저장하고, 앞서 설명한 nodes 테이블에 노드가 있는 SQLite 할당 스냅샷에 적용한다. 트랜잭션이 커밋되면 읽기 스레드가 새 델타를 불러와 서버에 업로드하며, 쓰기 스레드가 끝날 때까지 기다리지 않는다.

그 사이 원격 델타가 도착하면 백그라운드 스냅샷과 메인 스레드에서 마지막으로 받은 전경 스냅샷에 모두 적용한다. 갱신된 Arc 할당 스냅샷은 다시 메인 스레드로 전달된다.

전경 스레드는 이 과정에서 차단되지 않으며, 자체 스냅샷을 변경하고 델타를 계속 만들 수 있다. 이때 백그라운드에서 만들어진 스냅샷을 가져와 동시 발생한 전경 델타를 그 위에 다시 적용할 수 있다.

전체 상태를 하나의 큰 CRDT로 표현하기 때문에 이 시스템이 작동한다. 유효한 인과 순서라면 어떤 순서로든 델타를 적용할 수 있어, 전경 스레드와 백그라운드 스레드를 같은 CRDT를 조작하는 두 동시 클라이언트처럼 다룬다.

코드에서 대화까지 추적

이번 글은 주로 클라이언트에서 DeltaDB가 작동하는 방식을 다뤘다. 개발 중인 작업은 더 있으며, 그중 일부는 곧 출시될 예정이다.

예를 들어 세밀한 변경 기록을 활용하면 파일과 버전을 넘어 코드 조각을 추적해, 그 코드를 만들어낸 대화까지 거슬러 올라갈 수 있다. Zed의 앵커를 안다면, 같은 아이디어로 모든 문자에 정체성을 부여하고 코드가 바뀌어도 이를 따라갈 수 있다. 문자별로 동작하는 한층 강화된 git blame과 비슷하다.

화면에 픽셀이 나타나기 전에 수백만 개의 델타를 재생하지 않고도 클라이언트가 스레드를 불러오도록 서버 쪽에서도 많은 작업을 했다. 단일 Durable Object가 동시 쓰기 급증을 처리하도록 하는 작업도 진행했다. 두 주제는 모두 추후 글에서 다룰 예정이다.