자동 commit/rollback이 포함된 타입 안전 다중 엔티티 트랜잭션.
트랜잭션이란?
데이터베이스 트랜잭션은 여러 데이터베이스 작업을 하나의 원자적 단위로 그룹화하는 방법입니다. 이것은 다음을 의미합니다:
- 전부 아니면 전무: 모든 작업이 성공하거나, 아무것도 적용되지 않습니다
- 자동 롤백: 어떤 작업이 실패하면 이전 변경 사항이 자동으로 취소됩니다
- 데이터 일관성: 데이터베이스가 비일관 상태가 되지 않습니다
왜 트랜잭션이 필요한가요?
은행 앱을 만들고 계좌 간 송금이 필요하다고 상상해보세요:
1. 계좌 A에서 100원 차감
2. 계좌 B에 100원 추가
트랜잭션 없이, 1단계가 성공했지만 2단계가 실패하면(네트워크 오류, DB 충돌 등), 100원을 잃어버립니다! A에서 차감되었지만 B에는 추가되지 않았습니다.
트랜잭션과 함께, 2단계가 실패하면 1단계가 자동으로 롤백됩니다. 돈은 아무 일도 없었던 것처럼 계좌 A에 남아있습니다.
트랜잭션 활성화
엔티티에 transactions 속성을 추가하세요:
use entity_derive::Entity;
use uuid::Uuid;
#[derive(Entity)]
#[entity(table = "accounts", transactions)] // <- 이것을 추가
pub struct Account {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub user_id: Uuid,
#[field(create, update, response)]
pub balance: i64,
}
무엇이 생성되나요?
#[entity(transactions)]가 있는 Account 엔티티의 경우, 매크로가 생성합니다:
1. 트랜잭션 리포지토리 어댑터
pub struct AccountTransactionRepo<'t> {
tx: &'t mut sqlx::Transaction<'static, sqlx::Postgres>,
}
일반 리포지토리와 같지만, 모든 작업이 트랜잭션 내에서 발생합니다.
2. 빌더 확장 트레잇
pub trait TransactionWithAccount<'p> {
fn with_accounts(self) -> Transaction<'p, PgPool, AccountTransactionRepo<'static>>;
}
이것은 트랜잭션 빌더에 with_accounts() 메서드를 추가합니다.
사용 가능한 메서드
트랜잭션 내에서 다음 메서드를 사용할 수 있습니다:
| 메서드 | 시그니처 | 설명 |
|---|---|---|
create | create(dto) -> Result<Entity, Error> | 새 레코드 삽입 |
find_by_id | find_by_id(id) -> Result<Option<Entity>, Error> | 기본 키로 찾기 |
update | update(id, dto) -> Result<Entity, Error> | 기존 레코드 업데이트 |
delete | delete(id) -> Result<bool, Error> | 레코드 삭제 (또는 소프트 삭제) |
list | list(limit, offset) -> Result<Vec<Entity>, Error> | 페이지네이션 목록 |
기본 예제
use entity_core::prelude::*;
async fn create_account(pool: &PgPool, user_id: Uuid) -> Result<Account, AppError> {
Transaction::new(pool) // 1. 트랜잭션 빌더 생성
.with_accounts() // 2. Account 리포지토리 추가
.run(|mut ctx| async move { // 3. 작업 실행
let account = ctx.accounts().create(CreateAccountRequest {
user_id,
balance: 0,
}).await?;
Ok(account) // 4. 결과 반환 (자동 커밋)
})
.await
}
단계별 설명:
Transaction::new(pool)- 데이터베이스 풀로 새 트랜잭션 빌더 생성.with_accounts()- 트랜잭션 컨텍스트에 Account 리포지토리 추가.run(|mut ctx| async move { ... })- 트랜잭션 내에서 작업 실행Ok(account)-Ok반환 시 트랜잭션 커밋.Err반환 시 롤백.
전체 예제: 송금
이 예제는 트랜잭션의 전체 기능을 보여줍니다:
use entity_core::prelude::*;
use uuid::Uuid;
#[derive(Debug)]
pub enum TransferError {
Database(sqlx::Error),
AccountNotFound(Uuid),
InsufficientFunds { available: i64, requested: i64 },
}
impl From<sqlx::Error> for TransferError {
fn from(e: sqlx::Error) -> Self {
TransferError::Database(e)
}
}
impl From<TransactionError<sqlx::Error>> for TransferError {
fn from(e: TransactionError<sqlx::Error>) -> Self {
TransferError::Database(e.into_inner())
}
}
/// 두 계좌 간 원자적 송금.
///
/// 어떤 단계라도 실패하면 모든 변경이 자동으로 롤백됩니다.
pub async fn transfer(
pool: &PgPool,
from_id: Uuid,
to_id: Uuid,
amount: i64,
) -> Result<(), TransferError> {
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
// 1단계: 출금 계좌 가져오기
let from = ctx.accounts()
.find_by_id(from_id)
.await?
.ok_or(TransferError::AccountNotFound(from_id))?;
// 2단계: 잔액 확인
if from.balance < amount {
return Err(TransferError::InsufficientFunds {
available: from.balance,
requested: amount,
});
}
// 3단계: 입금 계좌 가져오기
let to = ctx.accounts()
.find_by_id(to_id)
.await?
.ok_or(TransferError::AccountNotFound(to_id))?;
// 4단계: 출금 계좌에서 차감
// 이것이 성공했지만 5단계가 실패하면, 롤백됩니다
ctx.accounts().update(from_id, UpdateAccountRequest {
balance: Some(from.balance - amount),
user_id: None,
}).await?;
// 5단계: 입금 계좌에 추가
ctx.accounts().update(to_id, UpdateAccountRequest {
balance: Some(to.balance + amount),
user_id: None,
}).await?;
// 모든 작업 성공 - 트랜잭션 커밋
Ok(())
})
.await
}
다양한 시나리오에서 발생하는 일:
| 시나리오 | 결과 |
|---|---|
| 두 업데이트 모두 성공 | 트랜잭션 커밋, 송금 완료 |
| 출금 계좌 없음 | 트랜잭션 롤백 (변경 없음) |
| 잔액 부족 | 트랜잭션 롤백 (변경 없음) |
| 첫 번째 업데이트 성공, 두 번째 실패 | 트랜잭션 롤백 (첫 번째 업데이트 취소!) |
| 트랜잭션 중 네트워크 오류 | 트랜잭션 롤백 (부분 변경 없음) |
하나의 트랜잭션에서 여러 엔티티
여러 엔티티를 원자적으로 처리할 수 있습니다:
#[derive(Entity)]
#[entity(table = "accounts", transactions)]
pub struct Account {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub balance: i64,
}
#[derive(Entity)]
#[entity(table = "transfer_logs", transactions)]
pub struct TransferLog {
#[id]
pub id: Uuid,
#[field(create, response)]
pub from_account_id: Uuid,
#[field(create, response)]
pub to_account_id: Uuid,
#[field(create, response)]
pub amount: i64,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
async fn transfer_with_logging(
pool: &PgPool,
from_id: Uuid,
to_id: Uuid,
amount: i64,
) -> Result<TransferLog, AppError> {
Transaction::new(pool)
.with_accounts() // Account 리포지토리 추가
.with_transfer_logs() // TransferLog 리포지토리 추가
.run(|mut ctx| async move {
// 잔액 업데이트
let from = ctx.accounts().find_by_id(from_id).await?
.ok_or(AppError::NotFound)?;
ctx.accounts().update(from_id, UpdateAccountRequest {
balance: Some(from.balance - amount),
}).await?;
let to = ctx.accounts().find_by_id(to_id).await?
.ok_or(AppError::NotFound)?;
ctx.accounts().update(to_id, UpdateAccountRequest {
balance: Some(to.balance + amount),
}).await?;
// 로그 항목 생성 - 모두 같은 트랜잭션 내에서!
let log = ctx.transfer_logs().create(CreateTransferLogRequest {
from_account_id: from_id,
to_account_id: to_id,
amount,
}).await?;
Ok(log)
})
.await
}
로그 생성이 실패하면 두 계좌 업데이트가 모두 롤백됩니다!
오류 처리
자동 롤백
클로저에서 반환된 모든 오류는 롤백을 트리거합니다:
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
ctx.accounts().update(id, dto).await?; // 성공
// 유효성 검사 실패
if amount < 0 {
return Err(AppError::InvalidAmount); // <- 롤백 트리거!
}
// 이것은 실행되지 않고, 위의 업데이트도 취소됨
ctx.accounts().update(other_id, other_dto).await?;
Ok(())
})
.await
트랜잭션 오류 유형
TransactionError 열거형이 무엇이 잘못되었는지 알려줍니다:
use entity_core::transaction::TransactionError;
let result = Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move { /* ... */ })
.await;
match result {
Ok(value) => {
println!("성공: {:?}", value);
}
Err(e) => {
if e.is_begin() {
println!("트랜잭션 시작 실패");
} else if e.is_operation() {
println!("작업 실패: {}", e);
} else if e.is_commit() {
println!("커밋 실패");
} else if e.is_rollback() {
println!("롤백 실패");
}
// 내부 데이터베이스 오류 가져오기
let db_error: sqlx::Error = e.into_inner();
}
}
소프트 삭제와 함께
트랜잭션은 soft_delete 속성을 준수합니다:
#[derive(Entity)]
#[entity(table = "documents", transactions, soft_delete)]
pub struct Document {
#[id]
pub id: Uuid,
#[field(create, response)]
pub title: String,
#[field(skip)]
pub deleted_at: Option<DateTime<Utc>>, // soft_delete에 필수
}
async fn archive_document(pool: &PgPool, id: Uuid) -> Result<bool, AppError> {
Transaction::new(pool)
.with_documents()
.run(|mut ctx| async move {
// DELETE 대신 deleted_at = NOW() 설정
let deleted = ctx.documents().delete(id).await?;
Ok(deleted)
})
.await
}
모범 사례
1. 트랜잭션을 짧게 유지
잘못된 예: 긴 트랜잭션
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let account = ctx.accounts().find_by_id(id).await?;
// 하지 마세요: 트랜잭션 내에서 외부 API 호출
let rate = external_api.get_exchange_rate().await?; // <- 느림!
ctx.accounts().update(id, dto).await?;
Ok(())
})
.await
좋은 예: 느린 작업은 외부에서
// 트랜잭션 시작 전에 외부 데이터 가져오기
let rate = external_api.get_exchange_rate().await?;
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
ctx.accounts().update(id, UpdateAccountRequest {
balance: Some(calculate_new_balance(rate)),
}).await?;
Ok(())
})
.await
2. 단일 작업에는 트랜잭션 사용하지 않기
불필요한 예:
Transaction::new(pool)
.with_users()
.run(|mut ctx| async move {
ctx.users().find_by_id(id).await // 단일 작업!
})
.await
더 나은 방법: 일반 리포지토리 사용
pool.find_by_id(id).await // 트랜잭션 불필요
3. 모든 오류를 올바르게 처리
항상 ?로 오류를 전파하세요:
Transaction::new(pool)
.with_accounts()
.run(|mut ctx| async move {
let result = ctx.accounts().update(id, dto).await;
// 하지 마세요: 오류 무시
if let Err(e) = result {
log::error!("업데이트 실패: {}", e);
// 트랜잭션이 제대로 롤백되지 않습니다!
}
// 이렇게 하세요: 오류 전파
ctx.accounts().update(id, dto).await?; // <- ? 사용
Ok(())
})
.await
일반적인 패턴
확인 후 업데이트
Transaction::new(pool)
.with_products()
.run(|mut ctx| async move {
let product = ctx.products().find_by_id(id).await?
.ok_or(AppError::NotFound)?;
if product.stock < quantity {
return Err(AppError::OutOfStock);
}
ctx.products().update(id, UpdateProductRequest {
stock: Some(product.stock - quantity),
..Default::default()
}).await?;
Ok(product)
})
.await
여러 관련 레코드 생성
Transaction::new(pool)
.with_orders()
.with_order_items()
.run(|mut ctx| async move {
// 부모 생성
let order = ctx.orders().create(CreateOrderRequest {
customer_id,
status: "pending".to_string(),
}).await?;
// 자식 생성
for item in items {
ctx.order_items().create(CreateOrderItemRequest {
order_id: order.id,
product_id: item.product_id,
quantity: item.quantity,
}).await?;
}
Ok(order)
})
.await
참고
- [[속성|속성 참조]] - 전체 속성 문서
- [[훅|라이프사이클 훅]] - 작업 전/후 코드 실행
- [[커맨드|커맨드]] - CQRS 커맨드 패턴
- [[이벤트|이벤트]] - 엔티티 변경 추적