IT/라라벨

[laravel] Laravel 의 중첩 트랜잭션 관리기법

_이준호_ 2024. 7. 1. 02:32

트랜잭션의 기본 개념

트랜잭션은 데이터베이스의 상태를 변경시키는 하나의 논리적 작업 단위. 트랜잭션은 다음과 같은 특징이 있다. (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 발생 시 전체 트랜잭션을 재시도하는 로직을 구현.
  • 중첩 트랜잭션 사용 최소화. 가능한 한 단일 레벨의 트랜잭션만 사용하여 문제 발생 가능성 줄이기