Wild Almonds에서 데이터 복제와 로컬 퍼스트 저장 방식을 위해 SeaORM을 선택한 이유와, 마이그레이션·엔티티 생성·Seaography(GraphQL)·테스트·향후 RBAC까지 이어지는 일관된 스택이 어떻게 문제를 해결하는지 설명한다.
Wild Almonds는 내가 Tauri 위에서 만들고 있는 개발자 생산성 도구다 — Gists, To-Do 리스트, Notion, 그리고 알림용으로 휴대폰을 따로따로 굴리는 대신, 코드 스니펫/작업/리마인더/노트를 한곳에서 관리하는 것을 목표로 한다.
Almonds에서 가장 초기의 아키텍처 고민 중 하나는 데이터 복제였다. 모델은 단순하다:
이 모델은 SeaORM 선택을 포함해 모든 기술적 의사결정을 좌우한다. 이 글에서는 SeaORM이 그 데이터 저장 접근법에 어떻게 잘 들어맞는지 설명하겠다.
문제는 데이터 모델이다. NoSQL 저장소는 처음에는 유연성을 주지만, 그 유연성은 데이터가 관계형 백엔드와 대화해야 하는 순간 조용히 부채가 된다.
오늘날 오프라인 퍼스트 JavaScript 클라이언트 데이터베이스는 여러 가지가 있다. PouchDB, RxDB, Dexie처럼 사용성이 매우 좋은 것들도 있고, 나도 어느 시점엔가 모두 만져본 적이 있다.
하지만 나에게 중요한 요구사항 하나는, 시작부터 데이터 모델에 대해 명확한 정방향/역방향 호환성을 유지하는 것이었다.
NoSQL 데이터를 PostgreSQL, MySQL, Oracle 같은 관계형 데이터베이스와 통합해야 할 때, 관리는 복잡해질 수 있다.
나는 또한 첫날부터 명확한 스키마 진화(schmea evolution)를 원했다. 문서 저장소에서 “마이그레이션”은 종종 시작 시점에 실행되는 일회성 스크립트를 작성하고 아무것도 깨지지 않기만을 바라는 것을 의미한다. 반면 SeaORM과 SQLite에서는 마이그레이션이 일급 시민이다: 버전이 매겨지고, 순서가 있으며, 되돌릴 수 있다.
또 다른 요인은 Tauri 자체였다. Almonds는 기본적으로 Rust 애플리케이션이다. JavaScript 데이터베이스를 선택하면, 모든 쿼리마다 JS/Rust 경계를 넘나드는 브리징이 필요해 오버헤드와 복잡도가 늘어나거나, 아니면 완전히 분리된 두 개의 데이터 레이어를 운영해야 한다. 둘 다 맞지 않았다. SeaORM을 쓰면 모든 것을 Rust 안에 유지할 수 있고, 그 결과 언어 하나, 타입 시스템 하나, 그리고 마샬링 비용(marshalling tax)도 없다.
SeaORM은 여기에서 깔끔하게 들어맞는다: 로컬에서는 SQLite, 구조화된 관계형 모델, 그리고 서버 데이터베이스와 호환성을 유지하는 스키마. 모든 것은 공유 Kernel 라이브러리 안에 들어 있으며, 향후 모바일 앱이 이를 소비하더라도 모델 드리프트(model drift) 없이 사용할 수 있다.
SeaORM은 마이그레이션 도중에 데이터베이스 백엔드에 따라 분기할 수 있게 해준다 — SQLite와 Postgres가 스키마 변경을 다르게 처리할 때 유용하다. 예를 들어 SQLite는 외래 키를 추가하기 위한 ALTER TABLE을 직접 지원하지 않는다. 우회 방법은 새 테이블을 만들고, 데이터를 복사한 뒤, 기존 테이블을 드롭하는 것이다. SeaORM의 마이그레이션 API는 런타임에 백엔드를 노출하므로, 둘 다에 안 좋게 동작하는 “최소공배수” 접근을 택하는 대신 각 경우를 명시적으로 처리할 수 있다:
if db_backend == DbBackend::Sqlite {
// SQLite can't add FK constraints via ALTER TABLE
// so we recreate the table with the new schema and migrate data
manager.create_table(...).await?;
db_connection.execute_unprepared("INSERT INTO reminders_new SELECT ...").await?;
return Ok(());
}
// Postgres/MySQL support ALTER TABLE natively
manager.alter_table(...).await?;
manager.create_foreign_key(...).await?;
이 패턴은 Almonds에 워크스페이스를 추가하면서 즉시 등장했다. reminders 테이블에 외래 키를 백필(backfill)해야 했기 때문이다. 백엔드 인지(backend-aware) 마이그레이션이 없었다면, 마이그레이션 파일을 두 개로 쪼개서 항상 동기화되길 바라거나, 혹은 제약 조건 자체를 아예 피했을 것이다. 장기적으로 어느 쪽도 받아들일 수 없었다.
sqlx는 훌륭하고, 나도 광범위하게 써 왔다 — 하지만 Rust 바인딩을 손으로 작성하면, 타입 불일치를 컴파일 타임에 잡았어야 할 것을 단 한 번의 오타로 런타임 패닉으로 맞을 수 있다. 테이블이 많아질수록 그 위험은 기하급수적으로 커진다.
SeaORM은 라이브 데이터베이스 스키마에서 직접 엔티티를 생성하므로, Rust 타입은 항상 데이터베이스에 실제로 들어있는 내용의 정확한 반영이 된다:
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "bookmark")]
#[serde(rename_all = "camelCase")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub identifier: Uuid,
pub title: String,
pub url: String,
pub workspace_identifier: Option<Uuid>,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
관계도 생성된다 — belongs_to와 Related 구현은 손으로 쓰는 것이 아니라, 스키마의 외래 키에서 추론된다. 스키마가 바뀌면 다시 생성하면 된다. 그러면 컴파일러가 정확히 어떤 호출 지점이 깨졌는지 알려준다. 이 피드백 루프는 중요하다: 조용한 런타임 버그의 한 부류를 시끄러운 컴파일 타임 에러로 바꿔 준다.
Seaography는 SeaORM 엔티티로부터 완전하게 동작하는 GraphQL 서버를 생성한다. 코드 생성 명령에 플래그 하나만 추가하면 된다:
# Kernel: entities only
sea-orm-cli generate entity --database-url {{url}} --with-serde both -o src/entities
# Backend (Orchard): entities + GraphQL bindings
sea-orm-cli generate entity --database-url {{url}} --seaography -o src/entities
Almonds에서는 이것이 중요했다. 복제 백엔드 — 클라이언트로부터 데이터를 받아 클라우드 데이터베이스에 쓰는 서비스 — 는 엔티티 간 관계를 표현할 수 있는 쿼리 인터페이스가 필요했다: 리마인더를 포함하는 워크스페이스, 워크스페이스를 가로질러 태그된 북마크 등. REST 엔드포인트로 이를 손으로 만들었다면, 각 관계마다 라우트 핸들러를 작성하고 유지보수해야 했을 것이다. Seaography를 쓰면, 이미 존재하는 엔티티 정의로부터 그런 순회(traversal)가 자동으로 따라온다.
이는 또한 API와 데이터 모델이 서로 드리프트할 수 없다는 뜻이다. GraphQL 스키마는 로컬 SQLite 데이터베이스를 구동하는 동일한 엔티티들에서 파생된다. 컬럼을 하나 추가하고 다시 생성하면, ORM 레이어와 API 레이어가 둘 다 그 변경을 반영한다. 이런 종류의 강한 결합은 보통 경고 신호지만 — 여기서는 그게 목적이다.
SeaORM으로 테스트하는 것은 내가 JavaScript와 Rust에서 써 본 다른 데이터베이스 드라이버들에 비해 눈에 띄게 단순했다. 테스트는 실제 SQLite 인스턴스를 대상으로 실행된다 — 목(mocking)도 없고, 인메모리 가짜도 없고, 데이터베이스인 척하는 테스트 더블도 없다. 각 테스트는 실제 스키마를 띄우고, 연산을 실행한 뒤, 실제 쿼리 결과로 assert한다. 즉 테스트가 정직하다: 데이터베이스 레이어에서 무언가가 깨지면, 잡아낸다.
setup_workspace 헬퍼는 워크스페이스를 프로비저닝하고 리포지토리와 메타데이터를 반환하는 보일러플레이트를 처리하므로, 개별 테스트는 검증하려는 동작에 집중할 수 있다:
#[tokio::test]
async fn test_store_with_workspace_recycle_bin() -> Result<(), KernelError> {
let (meta, repo) = setup_workspace(get_recycle_bin_repository).await?;
let payload = CreateRecycleBinEntry {
item_id: Uuid::new_v4(),
item_type: RecycleBinItemType::Note,
payload: Paragraph(1..2).fake(),
workspace_identifier: Some(meta.workspace_identifier),
};
let entry = repo.store(&payload, &Some(meta.clone())).await?;
assert_eq!(entry.item_id, payload.item_id);
assert_eq!(entry.workspace_identifier, Some(meta.workspace_identifier));
Ok(())
}
전체 테스트 스위트는 생성, ID와 타입 기반 조회, 페이지네이션, 그리고 단일/벌크 퍼지(purging)까지를 모두 커버한다. 모든 테스트가 SQLite 오프라인 환경에서 돌기 때문에, 빠르고 결정적이며, 어떤 머신에서든 실행 중인 데이터베이스 서버 없이 재현 가능하다. 이는 CI에도 중요하고, 기여자들이 cargo test를 돌리기 위해 인프라를 갖출 필요가 없다는 점에서도 중요하다.
SeaORM Pro에는 내장 RBAC 지원이 포함되어 있다. Almonds는 지금은 필요 없다 — 단일 사용자 도구이기 때문이다 — 하지만 워크스페이스 모델은 이미 데이터 소유권에 대한 경계를 그어 주며, 이는 역할과 권한에 깔끔하게 매핑된다. 언젠가 멀티유저 지원이 목표가 된다면, RBAC로 가는 길은 재작성(rewrite)이 아니라 설정 변경이 된다.
ORM의 핵심 가치는 데이터 모델을 한 번 표현하고, 표현 간 변환을 직접 처리하는 일을 피할 수 있게 해준다는 점이다. Almonds에서 이것은 모든 레이어에서 중요하다: 로컬 SQLite 데이터베이스, 클라우드 Postgres 인스턴스, GraphQL 복제 API, 그리고 그것들을 묶는 공유 Kernel 라이브러리.
나는 어떤 데이터베이스 엔진이 아래에서 돌아가느냐에 따라 애플리케이션 로직 결정을 하고 싶지 않았다. 노트북의 SQLite를 대상으로 실행되든 클라우드의 Postgres를 대상으로 실행되든, 이를 호출하는 Rust 코드는 동일하게 보여야 한다. SeaORM은 그걸 제공한다.
마이그레이션 시스템, 엔티티 생성, Seaography 통합은 서로 독립적인 기능이 아니다 — 그것들은 일관된 스택이다. 마이그레이션은 스키마를 정직하게 유지한다. 코드 생성은 타입을 정직하게 유지한다. Seaography는 API를 정직하게 유지한다. 클라우드 복제가 있는 로컬 퍼스트 앱에서, 환경 전반의 데이터 일관성이 곧 해결하려는 문제 전체인 상황에서는, 이 일관성은 부수적인 것이 아니다. SeaORM이 올바른 선택이었던 이유가 바로 그것이다.