Rust에서 SQLx 및 Diesel을 사용한 견고한 트랜잭션 관리
James Reed
Infrastructure Engineer · Leapcell

소개
데이터 중심 애플리케이션의 세계에서 데이터 무결성과 일관성을 보장하는 것이 가장 중요합니다. 예상치 못한 오류로 인해 한 계좌에서 돈이 출금되었지만 다른 계좌로 입금되지 않은 금융 거래를 상상해 보세요. 이러한 시나리오를 처리할 강력한 메커니즘이 없으면 전체 시스템의 신뢰성이 무너집니다. 바로 여기서 데이터베이스 트랜잭션이 사용됩니다. 트랜잭션은 "모두 아니면 전무"라는 보증을 제공하여 일련의 작업이 모두 성공하고 커밋되거나, 하나라도 실패하면 모두 초기 상태로 롤백되도록 합니다.
Rust 생태계에서 sqlx
와 diesel
은 트랜잭션 관리를 훌륭하게 지원하는 두 가지 인기 있고 강력한 ORM/쿼리 빌더입니다. 이 문서는 이러한 도구를 활용하여 안전한 트랜잭션 처리 및 오류 롤백을 수행하고 Rust 애플리케이션이 안전하고 안정적으로 데이터베이스와 상호 작용하도록 하는 방법을 자세히 살펴봅니다.
트랜잭션 기초 이해
sqlx
와 diesel
의 구체적인 내용으로 들어가기 전에 데이터베이스 트랜잭션과 관련된 몇 가지 핵심 개념을 정의해 보겠습니다.
- 트랜잭션: 하나 이상의 작업을 포함하는 단일 논리적 작업 단위입니다. 이러한 작업은 단일, 분할 불가능한 시퀀스로 처리됩니다.
- ACID 속성: 유효한 트랜잭션을 보장하는 속성 세트입니다.
- 원자성(Atomicity): 트랜잭션 내의 모든 작업은 성공적으로 완료되거나 완전히 실패합니다. 부분 완료는 없습니다.
- 일관성(Consistency): 트랜잭션은 데이터베이스를 한 유효한 상태에서 다른 유효한 상태로 가져옵니다.
- 격리(Isolation): 동시 트랜잭션은 서로를 방해하지 않습니다. 각 트랜잭션은 독립적으로 실행되는 것처럼 보입니다.
- 지속성(Durability): 트랜잭션이 커밋되면 해당 변경 사항은 영구적이며 시스템 장애에서도 살아남습니다.
- 커밋(Commit): 트랜잭션 중에 발생한 변경 사항을 데이터베이스에 영구적으로 저장하는 프로세스입니다.
- 롤백(Rollback): 트랜잭션 중에 발생한 모든 변경 사항을 취소하고 트랜잭션 시작 전의 상태로 데이터베이스를 복원하는 프로세스입니다.
- 저장점(Savepoint): 트랜잭션 내에서 부분 롤백을 허용하는 마커입니다. 전체 트랜잭션을 되돌리지 않고 특정 저장점으로 롤백할 수 있습니다.
sqlx
와diesel
은 저장점을 사용할 수 있지만, 단순성과 일반적인 사용 사례를 위해 전체 트랜잭션 범위를 주로 다룹니다.
이러한 개념은 안정적인 데이터베이스 상호 작용의 근간을 형성하며, sqlx
와 diesel
은 Rust에서 이를 구현할 우아한 방법을 제공합니다.
SQLx를 사용한 안전한 트랜잭션 관리
sqlx
는 코드 생성 없이 유형 안전 쿼리를 제공하는 것을 목표로 하는 비동기 순수 Rust SQL 크레이트입니다. 트랜잭션 관리는 간단하며 Rust의 비동기 특성과 잘 통합됩니다.
원칙 및 구현
sqlx
는 데이터베이스 연결에서 begin()
메서드를 제공하여 트랜잭션을 시작합니다. 이 메서드는 Transaction
객체를 반환하며, 이는 Drop
을 구현합니다. 중요한 것은 Transaction
객체가 명시적으로 커밋되지 않고 범위를 벗어나면 drop
이 호출될 때 자동으로 롤백된다는 것입니다. 트랜잭션에 대한 이러한 "RAII와 유사한" 동작은 강력한 안전 기능입니다.
다음은 예시입니다.
use sqlx::{PgPool, Error, Postgres}; async fn transfer_funds_sqlx(pool: &PgPool, from_account_id: i32, to_account_id: i32, amount: f64) -> Result<(), Error> { let mut tx = pool.begin().await?; // 송금 계좌에서 출금 let rows_affected = sqlx::query!( "UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1", amount, from_account_id ) .execute(&mut tx) .await?; .rows_affected(); if rows_affected == 0 { // 업데이트된 행이 없으면 계좌가 없거나 자금이 부족한 것입니다. // `tx`가 커밋 없이 드롭되므로 트랜잭션은 롤백됩니다. return Err(Error::RowNotFound); // 여기서 더 구체적인 오류가 더 나을 수 있습니다 } // 수신 계좌에 입금 sqlx::query!( "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to_account_id ) .execute(&mut tx) .await?; // 두 작업 모두 성공하면 트랜잭션을 커밋합니다. tx.commit().await?; Ok(()) } // 사용 예시(시연용으로 단순화됨) #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let database_url = "postgres://user:password@localhost/my_database"; let pool = PgPool::connect(&database_url).await?; // accounts 테이블이 존재하고 일부 데이터가 있다고 가정 // INSERT INTO accounts (id, balance) VALUES (1, 100.00), (2, 50.00); match transfer_funds_sqlx(&pool, 1, 2, 25.00).await { Ok(_) => println!("Funds transferred successfully!"), Err(e) => println!("Failed to transfer funds: {:?}", e), } match transfer_funds_sqlx(&pool, 1, 2, 200.00).await { // 자금 부족으로 실패해야 함 Ok(_) => println!("Funds transferred successfully!"), Err(e) => println!("Failed to transfer funds: {:?}", e), } Ok(()) }
이 sqlx
예시에서:
pool.begin().await?
는 새 트랜잭션을 시작합니다.tx
변수는 이제 트랜잭션 핸들을 보유합니다.- 이 트랜잭션의 일부임을 보장하기 위해
&mut tx
를 사용하여 데이터베이스 작업을 수행합니다. - 오류(
?
연산자)가 발생하면 함수가 일찍 반환됩니다.tx.commit().await?
에 도달하지 않으므로tx
변수가 범위를 벗어나drop
구현을 트리거합니다.drop
구현은 자동으로 데이터베이스 연결에ROLLBACK
을 호출하여 원자성을 보장합니다. - 모든 작업이 성공하면
tx.commit().await?
가 호출되어 변경 사항이 영구적이 됩니다.
이 패턴은 Rust의 유형 시스템과 소유권을 활용하여 실수로 커밋되지 않은 트랜잭션을 방지하므로 매우 안전하고 관용적입니다.
적용 시나리오
이 sqlx
트랜잭션 패턴은 원자성이 필요한 모든 시나리오에 이상적입니다.
- 자금 이체: 보여준 것처럼 돈이 완전히 이체되거나 전혀 이체되지 않도록 보장합니다.
- 주문 처리: 주문 생성, 재고 업데이트, 확인 이메일 발송 – 모두 단일 단위로 처리됩니다.
- 관련 데이터와 함께 사용자 만들기: 사용자 레코드와 기본 프로필 설정을 생성합니다.
Diesel을 사용한 안전한 트랜잭션 관리
diesel
은 Rust를 위한 강력하고 안전하며 확장 가능한 ORM/쿼리 빌더입니다. 데이터베이스와 상호 작용할 수 있는 보다 선언적인 방법을 제공하며 트랜잭션 관리는 똑같이 강력합니다.
원칙 및 구현
diesel
은 연결 유형(예: PostgreSQL의 PgConnection
)에 transaction
메서드를 제공합니다. 이 메서드는 트랜잭션 작업을 캡슐화하는 클로저(FnOnce(&mut Self) -> Result<T, E>
)를 받습니다. 클로저가 Ok(T)
를 반환하면 트랜잭션이 커밋됩니다. Err(E)
를 반환하면 트랜잭션이 롤백됩니다. 이 함수형 접근 방식은 매우 표현력이 뛰어나며 관심사 분리를 유지하는 데 도움이 됩니다.
diesel
에 대한 자금 이체 예시를 조정해 보겠습니다.
use diesel::prelude::*; use diesel::pg::PgConnection; use diesel::result::Error as DieselError; // 모호함을 피하기 위해 별칭 지정 // Diesel CLI에서 생성한 `schema.rs`가 있다고 가정 // table! { // accounts (id) { // id -> Int4, // balance -> Float8, // } // } // use crate::schema::accounts; // 이것이 범위 내에 있는지 확인 // 시연을 위해 간단한 `Account` 구조체를 정의해 보겠습니다. #[derive(Queryable, Selectable, Debug)] #[diesel(table_name = accounts)] pub struct Account { pub id: i32, pub balance: f64, } fn transfer_funds_diesel(conn: &mut PgConnection, from_account_id: i32, to_account_id: i32, amount: f64) -> Result<(), DieselError> { conn.transaction::<(), DieselError, _>(|conn| { use accounts::dsl::*; // 송금 계좌에서 출금 let updated_rows = diesel::update(accounts.filter(id.eq(from_account_id).and(balance.ge(amount)))) .set(balance.eq(balance - amount)) .execute(conn)?; if updated_rows == 0 { // sqlx의 RowNotFound와 유사하지만 Diesel의 오류 유형은 다릅니다. // 여기에서 사용자 정의 오류 또는 특정 Diesel 오류를 반환할 수 있습니다. // 단순화를 위해 일반 오류를 사용하지만, 사용자 정의 `NotEnoughFunds` 오류가 더 좋습니다. return Err(DieselError::NotFound); } // 수신 계좌에 입금 diesel::update(accounts.filter(id.eq(to_account_id))) .set(balance.eq(balance + amount)) .execute(conn)?; Ok(()) }) } // 사용 예시(시연용으로 단순화됨) fn main() -> Result<(), Box<dyn std::error::Error>> { let database_url = "postgres://user:password@localhost/my_database"; let mut conn = PgConnection::establish(&database_url)?; // accounts 테이블이 존재하고 일부 데이터가 있다고 가정 // INSERT INTO accounts (id, balance) VALUES (1, 100.00), (2, 50.00); match transfer_funds_diesel(&mut conn, 1, 2, 25.00) { Ok(_) => println!("Funds transferred successfully!"), Err(e) => println!("Failed to transfer funds: {:?}", e), } match transfer_funds_diesel(&mut conn, 1, 2, 200.00) { // 자금 부족으로 실패해야 함 Ok(_) => println!("Funds transferred successfully!"), Err(e) => println!("Failed to transfer funds: {:?}", e), } Ok(()) }
이 diesel
예시에서:
conn.transaction::<(), DieselError, _>(|conn| { ... })
는 새 트랜잭션 범위를 만듭니다.- 클로저 내의 모든 데이터베이스 작업은 해당 클로저에 전달된
conn
에서 수행되어 해당 작업이 트랜잭션의 일부임을 보장합니다. - 클로저 내의 작업 중 하나라도
Err(E)
를 반환하면(?
연산자 또는 명시적return Err(...)
때문),transaction
메서드는 이 오류를 포착하고ROLLBACK
을 수행합니다. - 클로저가 성공적으로 완료되고
Ok(())
를 반환하면transaction
메서드는COMMIT
을 수행합니다.
이 디자인은 트랜잭션 로직과 커밋/롤백 메커니즘을 명확하게 분리하여 코드를 깨끗하고 강력하게 만듭니다.
적용 시나리오
sqlx
와 마찬가지로 diesel
의 트랜잭션 기능은 다음을 위해 필수적입니다.
- 복잡한 비즈니스 로직: 원자적으로 처리되어야 하는 여러 데이터베이스 쓰기를 포함하는 모든 작업입니다.
- 데이터 마이그레이션 스크립트: 오류가 발생하면 데이터 변환이 완전히 적용되거나 완전히 되돌려지도록 보장합니다.
- 중요 데이터 처리 API 엔드포인트: 민감한 정보 업데이트가 일관성 규칙을 준수하도록 보장합니다.
결론
sqlx
와 diesel
모두 Rust에서 데이터베이스 트랜잭션 및 오류 롤백을 관리하는 훌륭하고 안전하며 관용적인 방법을 제공합니다. sqlx
는 오류 시 암시적 롤백을 위해 Transaction
객체의 Drop
구현과 함께 Rust의 RAII 원칙을 활용합니다. 반면 diesel
은 클로저의 반환 값에 따라 커밋/롤백을 처리하는 transaction
메서드를 통해 함수형 접근 방식을 제공합니다. 이러한 기능을 신중하게 사용함으로써 개발자는 예상치 못한 실패에도 불구하고 데이터 무결성을 보장하면서 매우 안정적이고 내결함성이 있는 애플리케이션을 구축할 수 있습니다. 안전한 트랜잭션 관리는 단순한 모범 사례가 아니라 안정적인 데이터 시스템의 근본적인 요구 사항입니다.