주문은 저장되었지만, 주문 항목은 데이터베이스에 반영되지 않았습니다. 혹은 재고 카운터는 줄었는데 결제 게이트웨이에서 타임아웃이 발생하여, 고객의 카드는 결제되었지만 주문 기록은 없는 상황일 수도 있습니다. 이런 시나리오는 예외적인 상황처럼 들리지만, 실제로는 그렇지 않습니다. 트래픽이 많은 애플리케이션에서는 매일 발생하는 골칫거리가 됩니다.

근본 원인은 거의 항상 동일합니다. 바로 별개의 독립적인 이벤트로 취급된 일련의 데이터베이스 쓰기 작업 때문입니다. 하나가 실패하면 나머지는 그대로 남게 됩니다. 해결책은 데이터베이스 트랜잭션이며, Laravel에서는 DB::transaction()이라는 도구를 사용합니다.

트랜잭션이 실제로 보장하는 것

데이터베이스 트랜잭션은 여러 작업을 하나의 작업 단위로 묶습니다. 데이터베이스 엔진은 내부의 모든 작업이 영구적으로 커밋되거나, 아니면 완전히 롤백될 것을 보장합니다. 중간 단계란 없습니다.

은행 송금을 생각해 보세요. 시스템은 한 계좌에서 돈을 출금하고 다른 계좌로 입금해야 합니다. 출금 후 입금에 실패한다면, 돈이 허공으로 사라져서는 안 됩니다. 은행은 출금 내역을 취소합니다. 이 취소 과정이 바로 롤백입니다. 두 단계가 모두 성공하면 송금이 커밋되며, 이는 새로운 잔액이 안전하게 저장되었음을 의미합니다.

이러한 '전부 아니면 전무(all-or-nothing)' 방식이 데이터의 일관성을 유지해 줍니다. 이것이 없다면, 부분적인 실패로 인해 테이블 곳곳에 절반만 작성된 레코드가 흩어지게 되고, 자동화된 프로세스가 이를 안전하게 정리하는 방법을 모르기 때문에 해당 레코드들은 데이터베이스 내에서 썩게 됩니다.

Laravel이 연결하는 방식

순수 SQL을 사용한다면, 트랜잭션이 중단된 상태로 남지 않도록 모든 가능한 오류를 잡아내면서 직접 BEGIN, COMMIT, ROLLBACK을 작성해야 합니다. Laravel은 이러한 반복적인 코드를 하나의 메서드로 감싸줍니다.

DB::transaction()에 클로저를 전달합니다. Laravel은 트랜잭션을 시작하고 코드를 실행하며, 클로저가 예외를 던지지 않고 완료되면 자동으로 커밋합니다. 만약 무언가 실패하면, Laravel은 예외를 잡아 모든 작업을 롤백하고, 로깅 및 에러 핸들링이 예상대로 작동할 수 있도록 에러를 다시 던집니다(rethrow).

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create([/* ... */]);
    
    foreach ($cart->items as $item) {
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item->product_id,
            'quantity' => $item->quantity,
        ]);
        
        Product::find($item->product_id)
               ->decrement('stock', $item->quantity);
    }
    
    Payment::create([
        'order_id' => $order->id,
        'amount' => $cart->total,
        'status' => 'completed',
    ]);
});

외래 키가 누락되었거나 데이터베이스 연결이 끊겨 결제 삽입에 실패하면, 주문, 주문 항목, 재고 변경 사항이 모두 취소됩니다. 이유 없이 재고가 사라지거나, 결제되지 않은 주문에 대해 배송을 요구하는 상황이 발생하지 않습니다.

트랜잭션이 유용한 상황

어떤 워크플로우는 트랜잭션의 안전성에 절대적으로 의존합니다. 결제 예시가 가장 명확하지만, 이 패턴은 어디에서나 나타납니다.

사용자 등록. 사용자 행을 생성한 다음 프로필 행을 만들고 기본 설정을 생성합니다. 만약 유효성 검사 예외 상황으로 인해 프로필 삽입에 실패하면, 프로필이 없는 사용자는 유령 계정이 됩니다. 모든 사용자가 프로필을 가지고 있다고 가정하는 모든 페이지는 충돌하거나 깨진 UI를 보여주게 됩니다.

대량 가져오기(Bulk imports). 50개의 레코드를 삽입하는 CSV 업로드 과정에서 26번째 행의 날짜 형식이 잘못되었다고 해서 앞선 25개의 레코드가 남겨져서는 안 됩니다. 배치 작업을 트랜잭션으로 감싸면 전체 가져오기가 하나의 깨끗한 단위로 실패하게 됩니다. 관리자는 어떤 행이 부분적으로 누출되었는지 찾는 데 몇 시간을 허비하는 대신, 파일을 수정하고 다시 시도하면 됩니다.

재고 및 회계. 한 테이블이 물리적 자원을 추적하고 다른 테이블이 돈이나 크레딧을 추적할 때마다, 두 테이블은 함께 움직여야 합니다. 이 둘을 분리하면 정산되지 않는 감사 결과와 잘못된 보고서를 초래하게 됩니다.

수동으로 제어해야 할 때

클로저 방식이 대부분의 경우를 해결하지만, 때로는 더 많은 제어가 필요할 때가 있습니다. 서비스 클래스 내부의 복잡한 조건부 로직이나, 런타임에 커밋 여부를 결정해야 하는 경우 단일 클로저만으로는 어색하게 느껴질 수 있습니다. 그럴 때는 트랜잭션을 직접 관리할 수 있습니다.

DB::beginTransaction();

try {
    // Run your operations
    $order = Order::create([/* ... */]);
    // ... more work ...
    
    if ($someBusinessRulePasses) {
        DB::commit();
    } else {
        DB::rollBack();
    }
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

catch 블록 내부의 순서에 주목하세요. 먼저 롤백한 다음 예외를 던져야 합니다. 롤백하기 전에 예외를 던지면 트랜잭션이 연결에 열린 상태로 남게 됩니다. 이는 행을 잠그거나(lock), 다른 쿼리의 데드락(deadlock)을 유발하거나, 커넥션 풀을 고갈시킬 수 있습니다. 수동 트랜잭션은 강력하지만, 정리 작업의 부담은 개발자에게 돌아갑니다.

트랜잭션에서 사이드 이펙트(Side Effects)를 분리하세요

이는 운영 환경에서 팀을 곤경에 빠뜨리는 규칙입니다. 트랜잭션은 데이터베이스 작업만 취소할 수 있습니다. 이미 보낸 이메일을 회수하거나, 클라우드 스토리지에서 파일을 삭제하거나, 결제 API를 통해 환불을 진행할 수는 없습니다.

만약 트랜잭션 클로저 내부에 Mail::send() 호출을 배치했는데, 두 줄 뒤에서 데이터베이스가 롤백된다면, 그 이메일은 여전히 고객의 편지함에 도착합니다. 수신자는 시스템에는 존재하지 않는 주문에 대한 인보이스를 받게 됩니다. Slack 알림, S3 파일 업로드, 웹훅 발송도 마찬가지입니다.

올바른 순서는 다음과 같습니다:

  1. 트랜잭션을 완료하고 필요한 ID나 결과값을 캡처합니다.
  2. 그 다음에만 외부 사이드 이펙트를 발생시킵니다.

예를 들어, 확인 이메일은 트랜잭션 내부가 아니라 커밋(commit) 후에 큐에 넣어야 합니다:

$order = DB::transaction(function () {
    // database work only
    return Order::create([/* ... */]);
});

// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);

외부 호출을 트랜잭션 외부에 두어야 하는 두 번째 이유는 시간 때문입니다. 트랜잭션은 락(lock)을 보유하고 데이터베이스 연결을 점유합니다. 트랜잭션 내부에서 Stripe API 응답을 기다리며 3초를 소비하는 것은 3초 동안 불필요하게 락을 유지하는 것과 같습니다. 트랜잭션은 짧고 빠르게 유지하십시오.

이 버그가 숨어 있는 이유

단일 사용자와 로컬 데이터베이스를 사용하는 개발 환경에서는 연관된 인서트(insert) 작업이 거의 항상 성공합니다. 네트워크는 안정적이고, 디스크는 가득 차지 않으며, 경쟁하는 부하도 없습니다. 코드는 보통 잘 작동하기 때문에 문제가 없어 보입니다.

운영 환경은 다릅니다. 두 고객이 정확히 같은 밀리초에 주문을 제출합니다. 배포 중에 큐 워커(queue worker)가 작업 도중 재시작됩니다. 결제 제공업체가 30초 동안 타임아웃됩니다. 트랜잭션이 없다면, 이러한 이벤트는 추적하기 매우 까다로운 고아 레코드(orphan records)와 불일치하는 합계를 생성합니다. 가장 최악인 점은 표준 기능 테스트(feature tests)로는 이를 거의 잡아낼 수 없다는 것입니다. 실패 모드가 단순한 로직 오류가 아니라 타이밍에 의존하며 인프라와 연결되어 있기 때문입니다.

해결책은 거창하지 않습니다. 기계적일 뿐입니다. 두 개 이상의 연관된 쓰기(write) 작업이 보이면, 그것들을 하나로 감싸십시오. 시간이 지나면 요청을 검증(validate)하는 것만큼이나 당연한 습관이 될 것입니다.

습관으로 만드세요

DB::transaction()은 복잡성을 더하지 않습니다. 오히려 사후에 불완전한 데이터를 정리하려 애쓰는 숨겨진 복잡성을 제거해 줍니다. 일련의 데이터베이스 작업이 하나의 단위로 묶여야 한다면, 처음부터 그렇게 처리하십시오. 그러면 부하가 걸려도 애플리케이션은 데이터 무결성을 유지할 것이고, 에러 로그는 깨끗하게 유지될 것이며, 데이터베이스는 미완성 레코드들의 무덤이 되지 않을 것입니다.