Rust로 처음부터 만든 수평 확장형 이벤트 소싱 전용 데이터베이스 SierraDB의 설계, 파티션 모델, 합의·복제, RESP3 기반 프로토콜과 구독, libp2p/Kameo 네트워킹, Inspector 등을 소개한다.
모든 시스템에는 이야기가 있다 — 이벤트 소싱은 그 이야기를 잊지 않도록 해준다. 이벤트 소싱은 틈새지만 중요한 소프트웨어 개발 패턴이다. 그럼에도 불구하고, 특히 이벤트를 어떻게, 어디에 저장할지에 대해서는 새로운 프로젝트가 접근할 “정석”이 뚜렷하지 않다. 어떤 이들은 Postgres 같은 범용 데이터베이스나 Kafka 같은 시스템을 쓰라고 조언한다. 하지만 이벤트 소싱은 범용 데이터베이스가 과하다거나, 반대로 핵심 기능이 빠져 있기도 한, 특정 요구 사항을 갖는다.
이벤트 소싱 전용 데이터베이스 영역의 이 격차가 내가 Rust로 처음부터 수평 확장 가능하도록 만든 데이터베이스인 SierraDB를 만들게 된 배경이다.
스토리지 솔루션의 스펙트럼에서, 이벤트 소싱에 특화된 데이터베이스는 손에 꼽는다. Kurrent(이전 명칭 EventStoreDB), AxonServer, 그리고 Postgres 위에 구축된 몇몇 솔루션들이 있다. 그러나 처음부터 자체 엔진으로 만든 전자의 두 데이터베이스는 가비지 컬렉션 언어로 구현되어 있는데, 내 관점에서는 고성능 데이터베이스를 만드는 데 최적이라 보기 어렵다.
이벤트 소싱은 범용 데이터베이스가 애를 먹는 구체적인 보장을 요구한다: 진정한 추가 전용(append-only) 스토리지, 갭 없는 시퀀스 번호, 효율적인 스트림 읽기, 프로젝션을 위한 내장 구독 등. 결국 데이터베이스와 싸우며 이런 불변식을 유지하거나, 성능을 일급 시민으로 두지 않은 해법을 수용하게 된다.
SierraDB는 확장성과 파티셔닝 측면에서는 Cassandra에서, 세그먼트 기반 추가 전용 파일 구조 측면에서는 AxonServer에서 영감을 얻었지만, 이를 Rust로 재구상하여 가비지 컬렉션 오버헤드 없이 예측 가능한 성능을 지향한다.
SierraDB는 이벤트 소싱 데이터베이스 지형에서 특정한 공백을 메운다.
redis-cli로도 이벤트 스토어를 탐색할 수 있어 디버깅과 개발이 간편하다.SierraDB는 클러스터 내에 고정 개수의 "논리" 파티션으로 이벤트를 저장한다. 이는 데이터베이스 초기화 시 결정되며, 단일 노드 구성에서는 32개, 다수 노드 구성에서는 1024개로 설정할 수 있다.
파티션 수는 핵심적이다. 각 파티션은 쓰기를 순차적으로 처리하므로, 갭 없는 단조 증가 시퀀스 번호를 제공한다. 즉 파티션 수는 사실상 쓰기 최대 동시성이다 — 32개 파티션이면 최대 32개의 동시 쓰기 스트림이다. 하지만 많다고 언제나 좋은 것은 아니다. 이벤트 핸들러는 파티션별로 자신의 처리 위치를 추적해야 하므로, 파티션이 지나치게 많으면 소비자 측 오버헤드가 늘어난다. 쓰기 처리량과 소비자 복잡성 사이의 균형이 필요하다.
클러스터의 각 노드는 버킷에 속한 추가 전용 파일에 이벤트를 저장한다. 버킷 수는 일반적으로 파티션 수로 나누어떨어지도록 설정한다. 예를 들어, 32개 파티션과 4개 버킷이라면, 각 버킷은 8개 파티션을 저장한다. 각 버킷은 256MB 세그먼트 파일에 쓰며, 가득 차면 새 파일로 롤오버한다. 이렇게 하면 데이터가 불변이 되어, 세그먼트를 한 번 인덱싱해 영구 캐시할 수 있고, 파일 크기가 무한정 커지지 않는다.
노드의 데이터 디렉터리는 다음과 같을 수 있다:
data/
buckets/
00000/
segments/
0000000000/
data.evts # 원시 추가 전용 이벤트 데이터
index.eidx # 이벤트 ID 인덱스(ID로 이벤트 조회)
partition.pidx # 파티션 인덱스(파티션 ID로 이벤트 스캔)
stream.sidx # 스트림 인덱스(스트림 ID로 이벤트 스캔)
0000000001/...
00001/...
00002/...
00003/...
이벤트 소싱에는 타협 불가 요구사항이 있다: 바로 스트림 버전 번호다. 스트림 내 모든 이벤트는 0부터 시작해 갭 없이 단조 증가하는 번호를 갖는다. 0, 1, 2, 3 — 0, 1, 3, 4가 되어서는 안 된다.
이 보장은 낙관적 잠금을 가능하게 한다. 이벤트를 추가할 때 기대 버전을 지정하면, 마지막으로 읽은 이후 다른 프로세스가 해당 스트림에 썼다면 추가는 실패하고 재시도한다. 이는 비차단 동시성 제어지만, 누락된 버전이 충돌을 의미한다는 절대적 확신이 필요하다. 버전에 갭이 생기면 재앙이다 — 이벤트가 유실되었음을 뜻하기 때문이다.
왜 전역 시퀀스 번호를 쓰지 않을까? 일부 데이터베이스는 그렇게 하지만, 분산 시스템에서는 병목이 된다. 모든 쓰기가 단일 카운터를 통해 조정되어야 하기 때문이다. SierraDB는 다른 접근을 한다. 각 파티션이 자체적으로 갭 없는 단조 증가 시퀀스를 갖는다. 파티션은 독립적으로 진행되며, 조정 오버헤드 없이 필요한 순서 보장을 제공한다. 이벤트 핸들러는 전역 포지션 대신 파티션별로 처리한 시퀀스를 추적하면 된다. 이는 소비자 측의 병렬 이벤트 처리도 가능하게 한다.
공유 파티션 키를 통한 스트림 간 트랜잭션. SierraDB에서 스트림은 파티션 키로 파티션에 매핑된다 — 기본값은 스트림 ID에서 파생한 UUIDv5다. 하지만 핵심 포인트는, 여러 스트림에 동일한 파티션 키를 줄 수 있다는 것이다. user-123 스트림과 account-456 스트림이 같은 파티션 키를 공유하면, 두 스트림은 같은 파티션에 배치될 것이 보장된다. 이는 분산 조정 없이도 단일 트랜잭션에서 두 스트림 모두에 이벤트를 원자적으로 추가할 수 있음을 뜻한다. 파티션 키는 상관 ID처럼 동작하여, 트랜잭션 일관성이 필요한 관련 스트림들을 묶을 수 있게 한다.
SierraDB에서 노드 클러스터를 실행하면 복제를 통해 중복성을 확보한다. 복제 팩터를 3으로 설정하면, 모든 이벤트가 서로 다른 3개 노드에 저장된다. 모든 쓰기는 과반수 쿼럼(3개 중 2개 동의)을 충족해야 성공으로 간주되어, 노드 하나가 실패해도 내구성이 보장된다.
여기서 SierraDB는 전통적 분산 데이터베이스와 다르다. 읽기에는 쿼럼이 필요 없다. 대신 각 이벤트는 메타데이터에 확인 카운트를 저장한다. 쓰기가 쿼럼을 달성하면, 백그라운드 프로세스가 이 확인을 모든 복제본에 브로드캐스트하여 로컬 확인 카운트를 업데이트한다. 따라서 단일 노드가 네트워크 왕복 없이도 일관된 읽기를 제공할 수 있다 — 성능에 큰 이득이다.
하지만 분산 시스템은 지저분하다. 쓰기는 순서 밖으로 완료될 수 있고, 네트워크 메시지는 지연되며, 확인은 어떤 순서로든 도착할 수 있다. 파티션 시퀀스 100이 확인되었지만 99는 아직 대기 중이라고 상상해보자 — 100까지 읽으면 갭이 생겨 우리의 보장이 깨진다.
여기서 워터마크 시스템이 등장한다. 각 파티션은 "연속적으로 확인된 시퀀스 중 가장 높은 값"을 추적한다 — 즉, 갭 없이 읽을 수 있는 한계다. 시퀀스 1–98과 100–102가 확인된 상태라면 워터마크는 98에 머문다. 99가 확인되는 순간 102로 점프한다. 리더는 워터마크까지만 이벤트를 노출해, 항상 일관되고 갭 없는 파티션 뷰를 보장한다.
Sequences: 96 97 98 | 99 100 101 102
State 1: ✓ ✓ ✓ | ? ✓ ✓ ✓ ← 워터마크: 98
└─ 갭 때문에 98을 넘어 읽을 수 없음
State 2: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ← 워터마크: 102
└─ 연속적, 모두 읽기 가능
리더십과 조정을 위해 SierraDB는 Raft에서 영감을 받은 term 기반 합의를 사용한다. 각 파티션에는 쓰기를 조정하는 리더 노드가 있으며, 리더가 사용 불가해지면 자동으로 장애 조치된다. 하지만 완전한 Raft와 달리, 클러스터 토폴로지에 기반한 결정적 리더 선정을 사용하므로 비용이 큰 선거가 필요 없다.
다음은 SierraDB 내부의 3단계 쓰기 프로토콜 상세 다이어그램이다.
데이터베이스와 통신하는 데 선택한 프로토콜은 Redis의 RESP3다. 현대적이고 단순하며, 가장 중요한 것은 실전에서 검증되었다는 점이다. 모든 언어에 Redis 클라이언트가 있으므로 SierraDB는 기존 라이브러리와 즉시 동작한다. 커스텀 드라이버가 필요 없다. 심지어 redis-cli로 이벤트 스토어를 탐색할 수도 있어, 디버깅과 개발이 놀라울 정도로 단순해진다.
노드 간 통신에는 libp2p를 사용한다 — IPFS와 다른 분산 시스템을 구동하는 바로 그 네트워킹 스택이다. 피어 발견, 연결 관리의 복잡성을 처리하고, 토폴로지 관리를 위한 가십 프로토콜을 제공한다. 노드들은 서로를 찾고, 클러스터 상태를 공유하며, 정적 설정이나 서비스 디스커버리 없이도 메시지를 라우팅할 수 있다.
네트워킹 레이어는 내가 Rust로 내결함성 분산 시스템을 만들기 위해 작성한 액터 프레임워크 Kameo로 구축했다. 액터는 메시지 전달로 통신하는 고립된 연산 단위를 제공하며, 분산 데이터베이스에서 동시 작업을 처리하는 데 적합하다. 각 파티션 리더, 복제 코디네이터, 백그라운드 프로세스는 감독되는 액터로서 실행된다. 하나가 크래시해도 시스템의 나머지에 영향 없이 자동으로 재시작된다. Kameo 자체가 libp2p 위에 구축되어 네트워킹 스택 전체가 동일한 기반을 공유한다.
이는 임의의 선택이 아니다. RESP3는 즉각적인 에코시스템 호환성을 제공한다. libp2p는 실전에서 검증된 P2P 네트워킹을 제공한다. 액터는 부분 장애에도 견디는 장애 격리와 감독 트리를 제공해, 문제가 생겨도 계속 동작해야 하는 데이터베이스에 필수적인 탄력성을 부여한다.
데이터베이스를 다른 이들에게 유용한 단계로 끌어올리는 데에는, 단순한 프로토타입 제작보다 훨씬 많은 작업이 필요하다. 명령어에 RESP3를 사용하기로 하면서, 기존 Redis 명령과 충돌하지 않도록 모든 SierraDB 명령에 E 접두사(EAPPEND, ESCAN, ESUB 등)를 붙였다.
이 데이터베이스는 이벤트 소싱을 위해 목적에 맞게 설계되었기 때문에, 구독의 일급 지원이 우선순위였다. 클라이언트는 모든 파티션이나 특정 파티션에 구독할 수 있고, 특정 파티션 시퀀스부터 시작할 수 있다. 그러면 SierraDB는 지정된 시퀀스부터의 모든 과거 이벤트와 이후 새로 추가되는 이벤트를 스트림한다. 덕분에 프로젝션과 이벤트 핸들러를 만들 때 폴링이나 복잡한 캐치업 로직이 필요 없다.
Rust 애플리케이션을 위해 kameo_es는 애그리게이트 정의와 SierraDB에 영속되는 명령 실행을 위한 트레이트와 타입을 제공해, 도메인 모델링 보일러플레이트의 상당 부분을 처리한다.
데이터베이스 가시성은 특히 이벤트 소싱에서 매우 중요하다. 이벤트가 왜 발생했는지를 볼 수 있다는 점이 큰 장점이기 때문이다. 이를 위해 SierraDB Inspector를 만들었다 — 이벤트를 탐색하고 JavaScript로 프로젝션을 실행할 수 있는 웹 인터페이스다. 이벤트 스트림을 빠르게 질의하고 시각화할 수 있어 디버깅이 훨씬 쉬워진다.
SierraDB Inspector 웹 인터페이스의 스크린샷.

마지막으로, 시작은 마찰 없이 쉬워야 한다. DockerHub에 Docker 이미지를 제공하며, 원라이너로 바로 시작할 수 있다.
SierraDB는 장시간의 스트레스 테스트에서 안정적으로 동작하며, 크리티컬한 손상 이슈는 대부분 해결했다. 파티션, 스트림, 구독, RESP3로 요약되는 코어 아키텍처는 올바르고 안정적이라고 느낀다. 성능은 여전히 성장 여지가 있지만, 기반은 탄탄하다. 이러한 설계 결정은 이벤트 소싱 시스템을 만드는 다양한 접근을 수년간 시행착오하며 얻은 결과다.
나의 목표는 SierraDB를 현대적 워크로드에 맞게 확장되는, 프로덕션 준비된 오픈 소스 이벤트 스토어로 발전시키는 것이다 — 그리고 그 여정에 도움을 환영한다. 문서와 테스트는 아직 초기 단계이므로, 기여는 언제나 대환영이다.
docker run -p 9090:9090 tqwewe/sierradb
redis-cli -p 9090
> EAPPEND user-123 UserCreated '{"name": "Alice"}'
> ESCAN user-123 - +