Rust로 SQLite 확장을 만들어 UUID v4/v7 생성과 바이너리/텍스트 UUID 변환 함수를 제공하고, 빌드·배포·사용 방법 및 트레이드오프를 살펴본다.
URL: https://kerkour.com/sqlite-extension-rust
클라우드 제공업체가 어떤 PostgreSQL 확장을 제공하지 않아서, 사실 몇 줄이면 끝날 기능을 우회하기 위해 코드베이스를 스파게티 코드로 부풀려야 했던 경험이 얼마나 많았나요?
내 컴퓨터가 아니면, 내 규칙도 아닙니다. 특히 그들이 특정 확장을 제공하지 않는 이유가, AWS RDS와 시계열(timeseries) 같은 자사 관리형 서비스로 당신을 “뜯어내기” 위해서라는 걸 알 때면 더 고통스럽죠.
게다가 성능이 100배 떨어지는 대가로 비용을 50배 프리미엄으로 내는 상황도 있습니다. 여전히 RDS 얘기인데, 2025년에 3000 IOPS(Input / Output Operations per Second)는… 슬프고, 웃기고, 한심합니다. 참고로 요즘 소비자용 NVME SSD는 대략 50만~100만 IOPS 정도를 제공합니다.
다행히 대안은 있습니다.
그중 하나가 SQLite입니다. SQLite는 프로그램에 직접 임베드되는 데이터베이스로, 데이터가 여러 머신과 불안정한 실제 네트워크를 거치며 네트워킹 스택의 수백만 줄 코드를 통과하지 않아도 되기 때문에 미친 듯이 빠릅니다.
SQLite가 제공하는 놀라운 기능들 중에서도, 잘 언급되지는 않지만 애플리케이션 아키텍처에 엄청난 영향을 줄 수 있는 것이 하나 있습니다. 바로 확장(Extension)을 빌드하고 배포하는 일이 얼마나 쉬운가 하는 점입니다. 더 이상 어떤 클라우드 제공업체에 제한받지 않습니다. 리눅스만 있으면 됩니다. 월 3유로짜리 클라우드 인스턴스에서 프로젝트를 시작한 다음, 필요해졌을 때 업그레이드하면 됩니다.
SQLite 확장 배포는 단지 (.so) 파일을 Docker 이미지/패키지에 복사하는 문제일 뿐입니다.
그래서 오늘은 UUID v4와 v7을 생성하고, 바이너리 UUID를 텍스트 포맷으로(또는 그 반대로) 변환하는 기본 SQLite 확장을 만들어 보겠습니다.
빌드 및 배포까지 걸리는 총 시간: 30분도 안 됩니다!
전체 코드는 글 끝에서 확인할 수 있습니다.
Cargo.toml
toml[package] name = "sqlite_extension" version = "0.1.0" edition = "2024" [lib] crate-type = ["cdylib"] [dependencies] rusqlite = { version = "0.37", features = ["functions", "trace", "loadable_extension"] } uuid = { version = "1", features = ["v4", "v7"] }
C 동적 라이브러리를 빌드한다는 것을 의미하는 crate-type = ["cdylib"]에 주목하세요.
먼저 함수들을 작성합니다:
src/lib.rs
rustfn uuidv4<'a>(_ctx: &Context<'_>) -> Result<ToSqlOutput<'a>, rusqlite::Error> { Ok(ToSqlOutput::Owned(Value::Blob( Uuid::new_v4().as_bytes().to_vec(), ))) } fn uuidv7<'a>(_ctx: &Context<'_>) -> Result<ToSqlOutput<'a>, rusqlite::Error> { Ok(ToSqlOutput::Owned(Value::Blob( Uuid::now_v7().as_bytes().to_vec(), ))) } fn uuid<'a>(ctx: &Context<'_>) -> Result<ToSqlOutput<'a>, rusqlite::Error> { let arg = ctx.get_raw(0); match arg { rusqlite::types::ValueRef::Text(text) => { // TEXT라면: BLOB으로 변환 let uuid = Uuid::try_parse_ascii(text) .map_err(|err| rusqlite::Error::UserFunctionError(Box::new(err)))?; Ok(ToSqlOutput::Owned(Value::Blob(uuid.as_bytes().to_vec()))) } rusqlite::types::ValueRef::Blob(blob) => { // BLOB이라면: TEXT로 변환 Ok(ToSqlOutput::Owned(Value::Text( Uuid::from_slice(blob) .map_err(|err| rusqlite::Error::UserFunctionError(Box::new(err)))? .to_string(), ))) } _ => Err(rusqlite::Error::UserFunctionError( "Invalid argument. Must be BLOB or TEXT.".to_string().into(), )), } }
다음으로 SQLite가 로드하는 엔트리포인트(entrypoint)를 작성합니다:
rustuse std::os::raw::{c_char, c_int}; use rusqlite::{ Connection, ffi, functions::{Context, FunctionFlags}, types::{ToSqlOutput, Value}, }; use uuid::Uuid; /// Entry point for SQLite to load the extension. #[unsafe(no_mangle)] pub unsafe extern "C" fn sqlite3_extension_init( db: *mut ffi::sqlite3, pz_err_msg: *mut *mut c_char, p_api: *mut ffi::sqlite3_api_routines, ) -> c_int { unsafe { Connection::extension_init2(db, pz_err_msg, p_api, extension_init) } } fn extension_init(db: Connection) -> Result<bool, rusqlite::Error> { db.create_scalar_function(c"uuid", 1, FunctionFlags::SQLITE_DETERMINISTIC, uuid)?; db.create_scalar_function(c"uuidv4", 0, FunctionFlags::SQLITE_DETERMINISTIC, uuidv4)?; db.create_scalar_function(c"uuidv7", 0, FunctionFlags::SQLITE_DETERMINISTIC, uuidv7)?; rusqlite::trace::log(ffi::SQLITE_WARNING, "Rusqlite extension initialized"); Ok(false) }
정말로, 이게 전부입니다!
다음 명령으로 컴파일하세요:
shcargo build --release
이제 확장은 target/release 폴더에 생성됩니다. 예를 들어 리눅스에서는 target/release/libsqlite_extension.so 입니다.
다음처럼 사용할 수 있습니다:
shsqlite3 db.db sqlite> .load target/release/libsqlite_extension sqlite> SELECT uuidv4(); �/M\�]Fz�9�M�E� sqlite> SELECT uuid(uuidv4()); 32fe393e-c2df-4050-b7af-8e4db515cae3
또는 Rust에서 sqlx로도 사용할 수 있습니다:
rustuse sqlx::{ query, sqlite::{SqliteConnectOptions, SqlitePool}, }; #[tokio::main] async fn main() -> Result<(), anyhow::Error> { // .extension()은 unsafe입니다 let sqlite_opts = unsafe { SqliteConnectOptions::from_str("db.db")? .extension("target/release/libsqlite_extension") // ... }; let db = SqlitePool::connect_with(sqlite_opts).await?; // ... }
모든 것과 마찬가지로, 알아둬야 할 트레이드오프가 몇 가지 있습니다.
제가 확인한 가장 큰 점은, 이런 방식으로 빌드된 확장은 꽤 크다는 것입니다(이 기본 확장도 약 330 KB). 따라서 서로 다른 기능을 많이 구현해야 한다면, 여러 개의 작은 확장을 만드는 대신 코드를 재사용하고 비대화를 줄이기 위해 하나의 모놀리식(monolithic) 확장으로 합치는 편이 더 나을 수 있습니다.
Rust의 features 같은 컴파일 타임 플래그로 기능을 쉽게 게이팅할 수 있으므로, 확장 소비자가 원하는 것만 외과적으로 선택하게 할 수 있습니다.
Cargo.toml
toml# 확장의 Cargo.toml # ... [features] default = ["uuid", "something-else", "another-thing"] uuid = [] something-else = [] another-thing = []
그리고 확장 사용자는 컴파일 타임에 원하는 기능을 선택할 수 있습니다:
shcargo build --release --no-default-features --features uuid
커리어를 업그레이드할 때가 왔다고 느끼나요? 제 책 **Black Hat Rust**로 안전하고 프로덕션 준비가 된 Rust 코드 작성법, 응용 암호학, 보안 엔지니어링을 배워보세요. 그 과정에서 웹 서버, 엔드-투-엔드 암호화된 원격 액세스 도구(Remote Access Tool), 그리고 Rust로 익스플로잇을 만드는 법도 다룹니다.
SQLite 확장은 단순 함수에만 한정되지 않습니다. 가상 테이블(virtual tables)과 Virtual FileSytem 인터페이스(VFS)도 구현할 수 있습니다.
SQLite는 수백만/수십억 달러의 투자자 자본이 뒤에 있는 프로젝트가 아니라서 뉴스에 자주 나오지는 않지만, 엔지니어링 관점에서 보면 클라우드 지주(cloud landlords)와 그 리셀러들이 밀어붙이는 내러티브 밖에서 생각하기 시작하는 순간, SQLite는 많은 상황에서 매우 합리적입니다. 기능을 더 빠르게 출시할 수 있을 뿐 아니라, 그렇지 않으면 지불해야 했을 비용의 일부만으로도 가능해집니다.
이전 글에서 언급했듯이 가장 큰 고충 두 가지는 1,000만+ 행 테이블에서 “느린” 스키마 변경이 10초 이상 전체 데이터베이스를 잠그는 문제, 그리고 자동 페일오버(자동 장애 조치)를 구현하기 어렵다는 점입니다. 하지만 99.999% 가용성이 필요 없는 서비스라면 SQLite는 매우 훌륭합니다.
라이선스: MIT
Cargo.toml
toml[package] name = "sqlite_extension" version = "0.1.0" edition = "2024" [lib] crate-type = ["cdylib"] [dependencies] rusqlite = { version = "0.37", features = ["functions", "trace", "loadable_extension"] } uuid = { version = "1", features = ["v4", "v7"] }
src/lib.rs
rustuse std::os::raw::{c_char, c_int}; use rusqlite::{ Connection, ffi, functions::{Context, FunctionFlags}, types::{ToSqlOutput, Value}, }; use uuid::Uuid; /// Entry point for SQLite to load the extension. #[unsafe(no_mangle)] pub unsafe extern "C" fn sqlite3_extension_init( db: *mut ffi::sqlite3, pz_err_msg: *mut *mut c_char, p_api: *mut ffi::sqlite3_api_routines, ) -> c_int { unsafe { Connection::extension_init2(db, pz_err_msg, p_api, extension_init) } } fn extension_init(db: Connection) -> Result<bool, rusqlite::Error> { db.create_scalar_function(c"uuid", 1, FunctionFlags::SQLITE_DETERMINISTIC, uuid)?; db.create_scalar_function(c"uuidv4", 0, FunctionFlags::SQLITE_DETERMINISTIC, uuidv4)?; db.create_scalar_function(c"uuidv7", 0, FunctionFlags::SQLITE_DETERMINISTIC, uuidv7)?; rusqlite::trace::log(ffi::SQLITE_WARNING, "Rusqlite extension initialized"); Ok(false) } fn uuidv4<'a>(_ctx: &Context<'_>) -> Result<ToSqlOutput<'a>, rusqlite::Error> { Ok(ToSqlOutput::Owned(Value::Blob( Uuid::new_v4().as_bytes().to_vec(), ))) } fn uuidv7<'a>(_ctx: &Context<'_>) -> Result<ToSqlOutput<'a>, rusqlite::Error> { Ok(ToSqlOutput::Owned(Value::Blob( Uuid::now_v7().as_bytes().to_vec(), ))) } fn uuid<'a>(ctx: &Context<'_>) -> Result<ToSqlOutput<'a>, rusqlite::Error> { let arg = ctx.get_raw(0); match arg { rusqlite::types::ValueRef::Text(text) => { // if it's TEXT: convert it to BLOB let uuid = Uuid::try_parse_ascii(text) .map_err(|err| rusqlite::Error::UserFunctionError(Box::new(err)))?; Ok(ToSqlOutput::Owned(Value::Blob(uuid.as_bytes().to_vec()))) } rusqlite::types::ValueRef::Blob(blob) => { // if it's BLOB: convert it to TEXT Ok(ToSqlOutput::Owned(Value::Text( Uuid::from_slice(blob) .map_err(|err| rusqlite::Error::UserFunctionError(Box::new(err)))? .to_string(), ))) } _ => Err(rusqlite::Error::UserFunctionError( "Invalid argument. Must be BLOB or TEXT.".to_string().into(), )), } }