배경
데이터베이스와 소프트웨어 개발에서 레이스 컨디션은 여러 프로세스 또는 스레드가 동시에 데이터베이스 자원에 접근하려 할 때 발생하는 문제입니다. 특히 동시에 데이터를 변경하려고 할 때 이 문제는 더욱 심각해집니다. 만약 레이스 컨디션에 대한 고려 없이 개발이 된다면, 데이터의 불일치, 손상, 또는 기타 예기치 않은 동작이 발생할 수 있고 이는 제품의 안정성과 신뢰성을 떨어뜨리게 됩니다.
이런 레이스 컨디션은 웹 애플리케이션, 분산 시스템, 병렬 컴퓨팅 등에서 특히 빈번하게 발생합니다. 이러한 시스템에서는 여러 사용자, 클라이언트, 또는 서비스가 동시에 데이터베이스에 접근하고 변경을 요청하기 때문입니다.
예를 들어, 은행 계좌의 잔고를 변경하는 상황을 생각해보겠습니다. 동시에 두 개의 서비스가 동일한 계좌에서 출금을 요청하면, 출금 작업이 겹치는 시점에 따라 잔고가 제대로 감소하지 않을 수 있습니다. 이로 인해 계좌의 잔고가 실제로는 출금 금액보다 적어질 수 있지만, 잔고 확인 시에는 출금 전의 금액을 보여줄 수 있습니다.
SeaORM과 트랜잭션을 이용한 레이스 컨디션 피하기
레이스 컨디션을 피하기 위해서 Rust 의 SeaORM(Rust ORM 라이브러리) 을 활용하여 트랜잭션을 구성해 보려고 합니다.
트랜잭션 시작하기
SeaORM에서 트랜잭션을 시작하려면 DatabaseConnection 인스턴스의 begin() 메서드를 호출하면 됩니다. 이 메서드는 새로운 Transaction 인스턴스를 반환합니다.
let transaction = db.begin().await?;
Rust
복사
이 Transaction 인스턴스를 사용하여 데이터베이스에 대한 변경을 수행할 수 있습니다. 모든 변경이 완료되면 commit() 메서드를 호출하여 트랜잭션을 커밋합니다.
transaction.commit().await?;
Rust
복사
격리 수준 설정하기
격리 수준은 트랜잭션 간의 동시성을 어떻게 제어할 것인지를 결정합니다. SeaORM에서는 IsolationLevel 열거형을 사용하여 격리 수준을 설정할 수 있습니다.
let transaction = db.begin_with(IsolationLevel::Serializable).await?;
Rust
복사
여기에서 Serializable은 가장 높은 격리 수준을 나타냅니다. 이 격리 수준에서는 트랜잭션 간에 아무런 중첩이 발생하지 않도록 합니다. 즉, 한 트랜잭션이 커밋될 때까지 다른 트랜잭션은 해당 트랜잭션이 사용하는 데이터에 접근할 수 없습니다.
실무에 적용해보기
이제 SeaORM과 트랜잭션을 사용해 레이스 컨디션을 방지하는 방법을 실제 코드에 적용해 보겠습니다.
보통 실무에서 config 를 DB에 저장할 때, revision 을 함께 저장합니다.
일반적인 상황에서는 문제가 없겠지만, A와 B가 거의 동시에 같은 config row 의 revision 칼럼을 1씩 증가시킨다고 가정해보겠습니다.
id | revision |
1 | 7 |
1.
A가 config의 id = 1 의 row 의 revision 값을 가져온다.
2.
A는 변수로 revision + 1 값인 8을 저장해둔다.
3.
B는 config의 id = 1 의 row 의 revision 값을 가져온다.
4.
B는 변수로 revision + 1 값인 8을 저장해둔다.
5.
B가 먼저 해당 row 를 update 한다.
id | revision |
1 | 8 |
6.
A가 B보다 늦게 해당 row 를 update 한다.
id | revision |
1 | 8 |
위의 과정이 전형적으로 Race Condition이 발생한 상황 입니다.
config 는 두번 변경이 되었음에도 불구하고 revision 값이 9 가 아닌 최종적으로 8의 값으로 변경이 되었습니다.
이는 trasnaction 으로 데이터를 가져온다고 해도 마찬가지 입니다.
필요한 것은 IsolationLevel 을 설정하는 것 입니다.
SeaORM 의 IsolationLevel 의 내용은 다음과 같습니다.
RepeatableRead:
이 격리 수준에서, 동일한 트랜잭션 내에서 일관된 읽기는 첫 번째 읽기에 의해 설정된 스냅샷을 읽습니다. 이것은 한 트랜잭션 내에서 동일한 데이터를 반복해서 읽을 때 항상 같은 결과를 보게 해주는 수준입니다. 따라서, 한 번의 트랜잭션 내에서는 데이터가 변경되지 않는 것처럼 보입니다. 이렇게 하면 "Non-repeatable read"라는 문제를 방지할 수 있습니다.
ReadCommitted:
이 격리 수준에서는, 동일한 트랜잭션 내에서도 각 일관된 읽기가 자신만의 신선한 스냅샷을 설정하고 읽습니다. 이는 각 쿼리가 실행되는 시점의 데이터 상태를 보게 해줍니다. 따라서, 한 트랜잭션 내에서 같은 데이터를 여러 번 읽더라도, 그 사이에 다른 트랜잭션이 해당 데이터를 변경한 경우 각 쿼리는 다른 결과를 볼 수 있습니다.
ReadUncommitted:
이 격리 수준에서는, SELECT 문이 잠금 없이 수행되며, 행의 이전 버전이 사용될 수 있습니다. 이는 "dirty read"를 허용하는 가장 낮은 격리 수준입니다. "dirty read"는 한 트랜잭션에서 변경된 (하지만 아직 커밋되지 않은) 데이터를 다른 트랜잭션이 읽을 수 있게 해줍니다. 이 수준에서는 트랜잭션 충돌이 최소화되지만, 데이터의 일관성을 보장할 수 없습니다.
Serializable:
이 격리 수준에서는, 현재 트랜잭션의 모든 문장이 이 트랜잭션에서 첫 번째 쿼리나 데이터 수정 문장이 실행되기 전에 커밋된 행만 볼 수 있습니다. 이는 가장 높은 격리 수준으로, 트랜잭션 간에 아무런 중첩이 발생하지 않도록 합니다. 이 수준에서는 모든 트랜잭션이 순차적으로 (즉, 직렬화 가능하게) 실행되는 것처럼 보입니다. 이로 인해 트랜잭션 충돌을 완전히 방지할 수 있지만, 성능에 부정적인 영향을 미칠 수 있습니다.
Revision 문제를 해결 하기 위해서 IsolationLevel::Serializable 을 사용했습니다.
pub async fn update(&self, id: ModelId, data: ActiveModel) -> Result<Model, DbErr> {
// Begin a transaction with Serializable isolation level
let tx = self.db.begin_with_config(IsolationLevel::Serializable).await?;
match self.update_aux(&tx, id, data).await {
Ok(result) => {
// Commit the transaction if the update is successful
tx.commit().await;
Ok(result)
},
Err(error) => {
// Roll back the transaction in case of an error
tx.rollback().await;
Err(error)
},
}
}
Rust
복사
update_aux 함수는 Entity를 찾고 그 값을 업데이트하는 작업을 수행합니다.
async fn update_aux(&self, tx: &DatabaseTransaction, id: i64, data: ActiveModel) -> Result<Model, DbErr> {
// Find the Entity with the given ID
let entity_result = Entity::find_by_id(id).one(tx).await;
let new_revision = entity_result.and_then(|result| {
match result {
Some(entity) => {
// Increment the revision number if the Entity is found
Ok(entity.revision + 1)
},
None => {
// Return an error if the Entity is not found
Err(DbErr::RecordNotFound(format!("Entity not found (id: '{}')", id)))
}
}
})?;
// Prepare the new data
let mut new_data = data;
new_data.id = Set(id);
new_data.revision = Set(new_revision);
// Update the Entity with the new data
Entity::update(new_data).exec(tx).await
}
Rust
복사
Serializable 격리 수준을 설정한 트랜잭션을 사용하여 레이스 컨디션을 방지하고 있습니다.
아까 위의 시나리오와 같은 6. A가 B보다 늦게 해당 row 를 update 한다. 상황이 발생할 경우, could not serialize access due to concurrent update 에러 메시지와 함께 롤백이 된다.
즉, Serializable 로 트랜잭션을 시작할 경우, 만약 트랜잭션 안에서 가져온 값이 변경이 되었을 경우, Update 하는 것이 아닌 rollback 처리를 한 다는 것이다.
자주 변경되는 테이블일 경우 Write 성능이 떨어질 수 있으므로 유의해서 사용해야 한다.
컬렉션 찾아보기
시리즈