트랜잭션의 기본 개념
트랜잭션은 데이터베이스의 상태를 변경시키는 하나의 논리적 작업 단위. 트랜잭션은 다음과 같은 특징이 있다. (ACID)
- 원자성(Atomicity): 트랜잭션의 모든 연산이 완전히 수행되거나 전혀 수행되지 않아야 함.
- 일관성(Consistency): 트랜잭션 전후로 데이터베이스는 일관된 상태를 유지해야 함.
- 격리성(Isolation): 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않아야 함.
- 지속성(Durability): 성공적으로 완료된 트랜잭션의 결과는 영구적으로 반영되어야 함.
Nested Transactions
Nested transactions 은 트랜잭션 내에서 또 다른 트랜잭션을 시작하는 개념. 이는 복잡한 작업을 더 작은 단위로 나누어 관리할 수 있게 해준다. 그러나 MySQL은 실제로 nested transactions를 지원하지 않는다. 대신, InnoDB 엔진에서는 savepoint를 사용하여 이와 유사한 기능을 구현한다.
- https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html
- https://dev.mysql.com/doc/refman/8.0/en/savepoint.html
Savepoints
Savepoint는 트랜잭션 내에서 특정 지점을 표시하는 방법. 이를 통해 트랜잭션의 일부만 롤백할 수 있다.
- 각 savepoint는 고유한 식별자를 가진다.
- Savepoint 생성 시, 현재 트랜잭션 상태가 메모리에 저장됨.
- Rollback to savepoint 시, 해당 savepoint 이후의 변경사항만 취소.
START TRANSACTION;
INSERT INTO users (name) VALUES ('Alice');
SAVEPOINT sp1;
INSERT INTO users (name) VALUES ('Bob');
SAVEPOINT sp2;
INSERT INTO users (name) VALUES ('Charlie');
ROLLBACK TO SAVEPOINT sp1;
COMMIT;
이 경우, 'Alice'만 데이터베이스에 삽입되고 'Bob'과 'Charlie'는 삽입되지 않는다.
Laravel의 트랜잭션 관리
자동 트랜잭션 관리
DB::transaction(function () {
// 데이터베이스 작업
}, 5); // 5는 deadlock 발생 시 재시도 횟수
이 방식은 클로저 내의 모든 데이터베이스 작업을 하나의 트랜잭션으로 묶는다. 작업이 성공적으로 완료되면 자동으로 커밋되고, 예외가 발생하면 롤백이 실행됨.
수동 트랜잭션 관리
try {
DB::beginTransaction();
// 데이터베이스 작업
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
// 예외 처리
}
이 방식은 개발자가 트랜잭션의 시작, 커밋, 롤백을 직접 제어할 수 있게 해준다.
그리고 라라벨에서 트랜잭션이 어떻게 관리되는지 코드를 살펴보면 다음과 같다.
트랜잭션 시작
public function beginTransaction()
{
$this->createTransaction();
$this->transactions++;
$this->fireConnectionEvent('beganTransaction');
}
protected function createTransaction()
{
if ($this->transactions == 0) {
try {
$this->getPdo()->beginTransaction();
} catch (Exception $e) {
$this->handleBeginTransactionException($e);
}
} elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) {
$this->createSavepoint();
}
}
- 첫 번째 트랜잭션 시작 시 실제 데이터베이스 트랜잭션을 시작.
- 이미 트랜잭션이 존재하는 경우, savepoint를 생성.
- $transactions 카운터를 증가시켜 현재 트랜잭션 깊이를 추적
커밋
public function commit()
{
if ($this->transactions == 1) {
$this->getPdo()->commit();
}
$this->transactions = max(0, $this->transactions - 1);
$this->fireConnectionEvent('committed');
}
- 가장 바깥쪽 트랜잭션(깊이가 1)일 때만 실제로 커밋을 수행.
- 트랜잭션 카운터를 감소
롤백
public function rollBack($toLevel = null)
{
$toLevel = is_null($toLevel)
? $this->transactions - 1
: $toLevel;
if ($toLevel < 0 || $toLevel >= $this->transactions) {
return;
}
try {
$this->performRollBack($toLevel);
} catch (Exception $e) {
$this->handleRollBackException($e);
}
$this->transactions = $toLevel;
$this->fireConnectionEvent('rollingBack');
}
protected function performRollBack($toLevel)
{
if ($toLevel == 0) {
$this->getPdo()->rollBack();
} elseif ($this->queryGrammar->supportsSavepoints()) {
$this->getPdo()->exec(
$this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1))
);
}
}
- 특정 레벨로 롤백 가능.
- 레벨 0으로 롤백하면 전체 트랜잭션을 롤백.
- 그 외의 경우 해당 savepoint로 롤백
라라벨에서 트랜잭션은 위와 같이 관리되는데 이때 주의해야 할 점이 있다.
루프 내에서 트랜잭션을 사용할 때 발생할 수 있는 문제
<?php
use Illuminate\Support\Facades\DB;
$orders = [/* 처리할 주문 배열 */];
foreach ($orders as $order) {
try {
DB::beginTransaction();
if ($order['status'] === 'cancelled') {
continue;
}
// 각 주문에 대해 데이터베이스 작업 수행
// 예: DB::table('orders')->where('id', $order['id'])->update(['status' => 'processed']);
DB::commit();
} catch (\Exception $e) {
DB::rollback();
// 예외 처리
\Log::error('주문 처리 중 오류 발생: ' . $e->getMessage());
}
}
- 트랜잭션을 시작했지만 commit 또는 rollback하지 않고 루프를 계속 진행할 경우, 트랜잭션 카운트가 증가.
- 이로 인해 트랜잭션 카운트와 실제 데이터베이스 트랜잭션 상태가 불일치.
- 결과적으로 의도하지 않은 데이터베이스 상태 변경이나 변경 사항이 전혀 적용되지 않는 상황이 발생할 수 있음.
해결방안
시작한 모든 트랜잭션은 반드시 종료(commit 또는 rollback)해야 한다.
<?php
use Illuminate\Support\Facades\DB;
$orders = [/* 처리할 주문 배열 */];
foreach ($orders as $order) {
try {
DB::beginTransaction();
if ($order['status'] === 'cancelled') {
DB::commit(); // 트랜잭션 종료
continue;
}
// 각 주문에 대해 데이터베이스 작업 수행
// 예: DB::table('orders')->where('id', $order['id'])->update(['status' => 'processed']);
DB::commit();
} catch (\Exception $e) {
DB::rollback();
// 예외 처리
\Log::error('주문 처리 중 오류 발생: ' . $e->getMessage());
}
}
Deadlock 발생시 savepoint 롤백 문제
protected $signature = 'test:deadlock {process}';
public function handle()
{
$process = $this->argument('process');
try {
DB::beginTransaction();
$this->info("Process {$process}: 외부 트랜잭션 시작");
// 중첩 트랜잭션 시작
$this->nestedTransaction($process);
DB::commit();
$this->info("Process {$process}: 외부 트랜잭션 커밋");
} catch (\Exception $e) {
$this->error("Process {$process}: 에러 발생 - " . $e->getMessage());
DB::rollBack(); // 에러 발생: SQLSTATE[42000]: Syntax error or access violation: 1305 SAVEPOINT trans2 does not exist
$this->error("Process {$process}: 롤백 시도");
}
}
private function nestedTransaction($process)
{
try {
$this->info("Process {$process}: 내부 트랜잭션 시작");
DB::beginTransaction();
if ($process == 1) {
// Process 1: user_id 1의 잔액을 감소
DB::table('users')->where('id', 1)->lockForUpdate()->decrement('balance', 50);
$this->info("Process 1: user_id 1의 잔액 감소");
sleep(2); // deadlock 유발을 위한 대기
DB::table('users')->where('id', 2)->lockForUpdate()->increment('balance', 50);
$this->info("Process 1: user_id 2의 잔액 증가");
} else {
// Process 2: user_id 2의 잔액을 감소
DB::table('users')->where('id', 2)->lockForUpdate()->decrement('balance', 50);
$this->info("Process 2: user_id 2의 잔액 감소");
sleep(2); // deadlock 유발을 위한 대기
DB::table('users')->where('id', 1)->lockForUpdate()->increment('balance', 50);
$this->info("Process 2: user_id 1의 잔액 증가");
}
DB::commit();
$this->info("Process {$process}: 내부 트랜잭션 종료");
} catch (\Exception $e) {
$this->error("Process {$process}: 내부 트랜잭션 에러 발생 - " . $e->getMessage()); //. SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction
DB::rollBack();
}
}
Laravel 커맨드를 사용하여 데드락 테스트.
순차적으로 아래와 같이 실행
- php artisan test:deadlock 1
- php artisan test:deadlock 2
이 오류는 deadlock으로 인해 트랜잭션이 이미 롤백되었지만, Laravel이 여전히 savepoint를 롤백하려고 시도할 때 발생한다.
Laravel의 트랜잭션 관리 로직과 데이터베이스의 deadlock 처리 방식 사이의 불일치가 원인이다. Deadlock이 발생하면 데이터베이스는 전체 트랜잭션을 롤백하지만, Laravel은 여전히 savepoint를 사용하여 부분적인 롤백을 시도한다.
해결방안
- 트랜잭션간 데드락 방지를 위해 데이터를 정렬하여 DML 을 실행.
- deadlock 발생 시 전체 트랜잭션을 재시도하는 로직을 구현.
- 중첩 트랜잭션 사용 최소화. 가능한 한 단일 레벨의 트랜잭션만 사용하여 문제 발생 가능성 줄이기
'IT > 라라벨' 카테고리의 다른 글
[Laravel] Laravel 에서 middleware 는 어떻게 동작하는가 (0) | 2024.09.17 |
---|---|
[Laravel] Facade, Singleton 그리고 alias (0) | 2024.09.03 |
[laravel] 유니코드 정규화 패키지 (0) | 2024.03.07 |
[laravel] 라라벨 여러 row 한번에 업데이트 하는 법 mass update (0) | 2024.01.10 |
[Laravel] passport 인증 실패 익셉션 발생시 Authoization header 를 날림 (0) | 2023.09.30 |