SQLite 저장은 SQL 문 몇 개로 끝나지 않습니다. schema가 어떤 순서로 바뀌는지, application이 SQLx를 어디까지 아는지, 여러 쓰기 중 하나가 실패하면 무엇이 남는지까지 정해야 합니다. 이 예제는 두 개의 migration, repository trait, 명시적인 transaction, 매 테스트마다 새로 만드는 database fixture를 한 흐름으로 묶습니다.
기준은 Rust 2024, rustc·Cargo 1.98.1, SQLx 0.8.6, Tokio 1.53.1입니다. 의존성은 exact version으로 고정했고 SQLite는 SQLx의 bundled driver를 사용합니다.
1. migration을 application 시작 경계로 둡니다
sqlx::migrate!()는 project root의 migrations directory를 binary에 포함합니다. Migrator::run은 _sqlx_migrations table에 적용된 version과 checksum을 기록하므로 같은 migration set을 다시 실행해도 이미 적용된 항목을 건너뜁니다. 예제 테스트는 같은 pool에서 migrate를 두 번 호출한 뒤 적용 건수가 2인지 확인합니다.
migration file은 수정 가능한 초기화 script가 아닙니다. 배포된 version을 고치지 않고 다음 번호의 file을 추가해야 합니다. 이미 적용된 file의 checksum이 달라지면 SQLx가 drift를 오류로 드러냅니다.
CREATE TABLE checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint_id TEXT NOT NULL REFERENCES endpoints(id) ON DELETE CASCADE,
status INTEGER NOT NULL CHECK (status BETWEEN 100 AND 599),
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX checks_endpoint_id_idx ON checks(endpoint_id);
checks.endpoint_id는 첫 migration이 만든 endpoints.id를 참조합니다. 이름 순서가 schema dependency를 표현합니다. rollback migration을 자동 생성하지 않으며, forward migration을 검토하고 backup·restore 정책을 별도로 두는 편이 안전합니다.
2. version과 feature를 Cargo.toml에서 고정합니다
SQLx는 feature에 따라 runtime, database driver, migration macro가 달라집니다. 이 fixture는 사용 범위를 Cargo.toml에 그대로 적었습니다.
[dependencies]
async-trait = "=0.1.89"
sqlx = { version = "=0.8.6", default-features = false, features = ["runtime-tokio", "sqlite", "migrate", "macros"] }
tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread"] }
[dev-dependencies]
tempfile = "=3.23.0"
Cargo.lock도 fixture에 포함합니다. direct dependency의 exact version과 lockfile을 함께 두면 글의 command를 같은 dependency graph로 다시 실행할 수 있습니다.
3. repository boundary 밖으로 SQLx type을 흘리지 않습니다
application이 필요로 하는 동작은 EndpointRepository가 정의합니다. 구현체는 SqlitePool, query, row mapping을 소유합니다. 호출자는 SQLite schema나 placeholder 문법을 몰라도 됩니다.
#[async_trait]
pub trait EndpointRepository {
async fn insert(&self, endpoint: &Endpoint) -> Result<(), sqlx::Error>;
async fn find_by_id(&self, id: &str) -> Result<Option<Endpoint>, sqlx::Error>;
async fn record_check(&self, id: &str, status: i64) -> Result<(), RepoError>;
}
#[derive(Clone)]
pub struct SqliteEndpointRepository {
pool: SqlitePool,
}
trait을 둔다고 database detail이 저절로 사라지지는 않습니다. 지금 예제의 insert와 find_by_id는 여전히 sqlx::Error를 반환합니다. transaction operation만 RepoError로 not-found와 database failure를 나눴습니다. 실제 service라면 unique violation처럼 application이 분기할 오류를 repository error로 번역하고, 나머지는 source error로 보존할 수 있습니다.
4. 함께 성공해야 하는 쓰기는 한 transaction에 넣습니다
검사 결과 기록은 endpoint의 최신 status를 바꾸고 history row를 추가합니다. 둘 중 하나만 남으면 읽기 model이 서로 모순됩니다. record_check는 pool에서 transaction을 시작하고 같은 executor에 두 statement를 보낸 뒤 마지막에만 commit합니다.
async fn record_check(&self, id: &str, status: i64) -> Result<(), RepoError> {
let mut transaction = self.pool.begin().await?;
let updated = sqlx::query("UPDATE endpoints SET last_status = ? WHERE id = ?")
.bind(status)
.bind(id)
.execute(&mut *transaction)
.await?;
if updated.rows_affected() != 1 {
return Err(RepoError::NotFound);
}
sqlx::query("INSERT INTO checks (endpoint_id, status) VALUES (?, ?)")
.bind(id)
.bind(status)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
SQLx transaction은 commit이나 rollback 없이 scope를 벗어나면 rollback을 시작합니다. 테스트는 유효 범위 밖인 status 700을 전달합니다. 첫 UPDATE는 성공하지만 두 번째 INSERT의 CHECK constraint가 실패합니다. 이후 endpoint의 last_status가 None이고 history count가 0인지 확인해 첫 update도 되돌아갔음을 고정합니다.
SQLite의 BEGIN transaction은 중첩되지 않습니다. nested unit of work가 필요하면 savepoint semantics를 검토해야 합니다. 또한 기본 transaction mode는 deferred이므로 여러 writer가 경쟁하는 운영 환경에서는 SQLITE_BUSY, busy timeout, retry ownership을 별도 정책으로 정해야 합니다.
5. memory fixture는 connection 수를 1로 제한합니다
SQLite의 :memory: database는 connection마다 서로 다릅니다. pool에 여러 connection을 허용하면 migration을 실행한 connection과 query가 배정된 connection이 다른 database를 볼 수 있습니다. 그래서 in-memory fixture는 max_connections(1)을 명시합니다.
pub async fn memory_pool() -> Result<SqlitePool, sqlx::Error> {
let options = SqliteConnectOptions::new()
.filename(":memory:")
.foreign_keys(true);
SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
}
공유 in-memory URI도 가능하지만 test isolation에는 private database 하나가 단순합니다. MemoryFixture::new는 매번 새 pool을 열고 migration을 실행합니다. 한 fixture에 저장한 row가 두 번째 fixture에서 보이지 않는 테스트가 isolation을 증명합니다.
6. file fixture는 실제 path와 cleanup을 검사합니다
in-memory test만으로는 file 생성 option과 cleanup을 확인할 수 없습니다. TempFileFixture는 새 temporary directory 안에 fixture.sqlite를 만들고 create_if_missing(true)와 foreign_keys(true)를 명시합니다.
let directory = tempfile::tempdir().expect("create temporary database directory");
let database_path = directory.path().join("fixture.sqlite");
let options = SqliteConnectOptions::new()
.filename(&database_path)
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.expect("open temporary SQLite database");
migrate(&pool)
.await
.expect("migrate temporary SQLite database");
SQLite foreign key enforcement는 connection별 setting입니다. SQLx가 기본으로 켜더라도 fixture에서 요구사항을 명시하고 PRAGMA foreign_keys가 1인지 검사합니다. cleanup test는 database file이 생겼는지 확인하고 pool을 close().await한 다음 temporary directory가 사라졌는지 봅니다. Windows처럼 열린 file 삭제가 까다로운 환경에서도 순서가 분명합니다.
7. command와 결과를 그대로 재현합니다
repository root에서 다음 명령을 실행합니다.
cd examples/article-29-sqlx-sqlite-migrations-repository
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo test --release --all-features
cargo run --quiet
검증된 binary 출력은 다음과 같습니다.
migrations=2
endpoint=alpha status=204 checks=1
rolled_back=true
7개 test는 repeatable migration, trait 경계의 round trip, commit, constraint failure 뒤 rollback, 서로 분리된 in-memory fixture, foreign key setting과 temporary file cleanup, exact binary output을 검사합니다. 테스트는 외부 database server나 공유 file을 쓰지 않습니다.
운영 설정에서는 in-memory pool의 max_connections(1)을 그대로 복사하지 마십시오. file database의 connection 수, WAL 여부, busy timeout, write contention은 workload에 맞춰 정해야 합니다. migration 실행 주체도 application instance인지 배포 job인지 하나로 정해야 합니다. repository boundary는 그 결정을 감추는 곳이 아니라 application에서 SQL 정책을 분리하는 곳입니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 29 프로젝트에서 확인할 수 있습니다.
답글 남기기