Backend

재고 1개에 주문 2건? 데이터베이스 동시성 제어 정리


재고가 1개 남은 상품에 주문 두 건이 거의 동시에 들어왔다. 코드에는 “재고가 0보다 클 때만 주문”이라는 조건이 있고, 트랜잭션도 걸려 있다. 그런데 주문은 2건이 생겼다. 왜 이런 일이 생기는지, 어떻게 막는지 정리한다. 예제는 Laravel과 MySQL(InnoDB), PostgreSQL 기준이다.


재고 1개에 주문 2건이 발생하는 상황

주문 로직은 흔히 이렇게 작성한다.

public function order(int $productId): Order
{
    return DB::transaction(function () use ($productId) {
        $product = Product::findOrFail($productId);

        if ($product->stock <= 0) {
            throw new SoldOutException();
        }

        $product->stock -= 1;          // PHP에서 계산한 값으로
        $product->save();              // UPDATE products SET stock = 0 ...

        return Order::create(['product_id' => $productId]);
    });
}

DB에는 SELECT로 재고를 읽고, PHP에서 계산한 값으로 UPDATE하고, 주문을 INSERT하는 SQL이 나간다. 두 요청이 겹치면 이런 순서가 생길 수 있다.

시간   트랜잭션 A                    트랜잭션 B
 t1    SELECT stock → 1
 t2                                  SELECT stock → 1
 t3    1 > 0 이므로 통과
 t4                                  1 > 0 이므로 통과
 t5    UPDATE stock = 0, 주문 INSERT
 t6    COMMIT
 t7                                  UPDATE stock = 0, 주문 INSERT
 t8                                  COMMIT

둘 다 “재고 1”을 보고 검사를 통과했다. 결과는 재고 0, 주문 2건이다. B가 A의 변경을 덮어썼기 때문에 이를 **갱신 손실(lost update)**이라고 한다. $product->decrement('stock')처럼 SET stock = stock - 1로 바꿔도 검사는 이미 통과했으므로 재고가 -1이 될 뿐이다.


트랜잭션의 원자성과 격리성

“트랜잭션을 걸었는데 왜 재고가 초과 판매됐을까?” 이 의문을 이해하려면 트랜잭션의 원자성과 격리성을 구분해야 한다.

**원자성(Atomicity)**은 트랜잭션 안의 작업이 전부 반영되거나 전부 취소된다는 보장이다. 예를 들어 재고를 차감한 후 주문 생성에 실패했다면 재고 차감도 함께 롤백된다. 하지만 앞의 예제에서 두 트랜잭션은 각각 정상적으로 완료됐다. 원자성은 여러 트랜잭션이 순서대로 실행된다는 것을 보장하지 않는다.

**격리성(Isolation)**은 동시에 실행되는 트랜잭션이 서로에게 미치는 영향을 제어하는 성질이다. 데이터베이스는 이러한 동시 실행을 제어하기 위해 격리 수준(Isolation Level)을 제공한다.

다만 트랜잭션을 사용했다고 해서 다른 트랜잭션이 같은 데이터를 동시에 읽거나 수정하려는 상황이 모두 차단되는 것은 아니다. 따라서 재고처럼 여러 요청이 동시에 접근하는 데이터는 별도의 동시성 제어가 필요한지 확인해야 한다.


격리 수준과 잠금의 개념적 차이

격리 수준과 잠금은 관련이 있지만 같은 개념은 아니다.

  • 격리 수준은 트랜잭션 사이에서 어떤 데이터 가시성과 간섭을 허용할지 정하는 규칙이다.
  • **잠금(Lock)**은 동시성을 제어하는 수단 중 하나로, 충돌하는 작업의 실행을 제한하거나 기다리게 한다.

예를 들어 두 사용자가 같은 상품의 재고를 조회한 뒤 수정하려고 한다면, 단순히 트랜잭션을 사용했다는 사실만으로 재고 초과 판매가 방지되는 것은 아니다.

이때 SELECT ... FOR UPDATE를 사용하면 수정할 데이터를 조회하면서 잠금을 획득할 수 있다. 다른 트랜잭션이 같은 데이터를 잠금 조회하거나 수정하려고 하면 먼저 획득한 잠금이 해제될 때까지 기다리게 된다.

이처럼 격리 수준은 트랜잭션 간 동시 실행에 관한 규칙이고, SELECT ... FOR UPDATE는 특정 데이터에 대한 충돌을 제어하기 위해 명시적으로 잠금을 사용하는 방법이다. 격리 수준에 따라 데이터가 어떻게 보이는지, DBMS마다 어떤 차이가 있는지는 별도의 글에서 다룬다.


SELECT FOR UPDATE와 Laravel lockForUpdate()

비관적 잠금은 충돌이 일어날 것이라고 보고 읽는 순간 잠그는 방법이다. SQL로는 SELECT ... FOR UPDATE를 쓴다.

BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;   -- 이 행을 잠근다

-- 애플리케이션에서 조회한 stock을 확인한다
--   stock > 0 이면 아래 UPDATE, INSERT 후 COMMIT
--   stock = 0 이면 ROLLBACK 후 품절 처리
UPDATE products SET stock = stock - 1 WHERE id = 1;
INSERT INTO orders (product_id) VALUES (1);
COMMIT;                                               -- 잠금 해제

위 SQL은 애플리케이션이 하나의 트랜잭션 안에서 DB로 보내는 문장을 순서대로 나타낸 것이다. 이 중 재고 확인만은 SQL로 보내지 않고 애플리케이션 코드에서 처리한다. 핵심은 이 확인이 잠금을 쥔 상태에서 이루어진다는 점이다. 확인하고 UPDATE할 때까지 다른 트랜잭션이 이 행을 바꿀 수 없으므로 앞에서 본 틈이 생기지 않는다.

B는 A가 커밋할 때까지 SELECT ... FOR UPDATE에서 기다렸다가 최신 값 0을 읽는다. 재고가 없으므로 UPDATE를 실행하지 않고 품절로 처리한다.

Laravel에서는 쿼리에 lockForUpdate()를 붙이면 이 SQL이 나간다.

return DB::transaction(function () use ($productId) {
    $product = Product::where('id', $productId)
        ->lockForUpdate()
        ->firstOrFail();

    if ($product->stock <= 0) {
        throw new SoldOutException();
    }

    $product->decrement('stock');

    return Order::create(['product_id' => $productId]);
});

lockForUpdate()는 반드시 DB::transaction() 안에서 써야 한다. 트랜잭션 밖에서는 자동 커밋 모드라서 SELECT가 끝나자마자 잠금이 풀린다. 에러도 나지 않기 때문에 잠금이 걸린 줄 알고 넘어가기 쉽다.

비관적 잠금은 확실하지만, 요청이 몰리면 한 줄로 서서 기다리고 그동안 DB 커넥션을 쥐고 있다.


조건부 UPDATE를 이용한 재고 차감

문제는 “읽기 → 앱에서 판단 → 쓰기” 사이의 틈이다. 판단과 쓰기를 SQL 한 문장으로 합치면 틈이 사라진다.

UPDATE products
SET stock = stock - 1
WHERE id = 1 AND stock > 0;

영향받은 행이 1이면 성공, 0이면 품절이다. B는 A가 커밋할 때까지 기다린 뒤 최신 값으로 조건을 다시 평가하고 0행으로 끝난다.

Laravel에서는 조건을 건 쿼리에 decrement()를 호출하면 영향받은 행의 수를 돌려준다.

return DB::transaction(function () use ($productId) {
    $affected = Product::where('id', $productId)
        ->where('stock', '>', 0)
        ->decrement('stock');

    if ($affected === 0) {
        throw new SoldOutException();
    }

    return Order::create(['product_id' => $productId]);
});

모델 인스턴스의 $product->decrement('stock')과 헷갈리면 안 된다. 인스턴스 메서드도 stock = stock - 1을 원자적으로 실행하지만 stock > 0 조건이 붙지 않는다.

UPDATE도 수정한 행에 잠금을 걸고 트랜잭션이 끝날 때까지 유지한다. 잠금이 없는 것이 아니라, 개발자가 잠금을 따로 선언하지 않고 조회와 판단 단계 없이 한 문장으로 끝낸다는 점이 다르다. 그러니 UPDATE 뒤에 오래 걸리는 작업을 두지 않는다.


두 방식의 장단점과 실무 선택 기준

lockForUpdate() (비관적 잠금) 조건부 UPDATE
동작 읽을 때 잠그고 앱에서 판단 조건과 수정을 한 문장으로
충돌하면 앞 트랜잭션이 끝날 때까지 기다린다 기다린 뒤 영향받은 행 0
장점 읽은 값으로 복잡한 판단을 할 수 있다 코드가 단순하고 쿼리가 하나 줄어든다
단점 대기, 데드락, 트랜잭션 밖에서 쓰면 효과 없음 규칙을 WHERE 조건으로 쓸 수 있어야 한다
  • 재고, 포인트, 쿠폰 수량처럼 규칙을 한 줄 조건으로 쓸 수 있으면 조건부 UPDATE가 가장 단순하다.
  • 재고, 회원 등급, 구매 한도를 함께 보고 판단하는 것처럼 여러 값을 읽고 결정해야 하면 lockForUpdate()로 필요한 행을 잠그고 처리한다.
  • 어느 쪽이든 트랜잭션은 짧게 유지한다. 잠금을 쥔 채 결제 API 같은 외부 호출을 하면 같은 상품의 모든 주문이 멈춘다.

마무리

이 글의 예제는 Laravel로 작성했지만, 두 방법은 Laravel의 기능이 아니라 데이터베이스의 기능이다. 다른 프레임워크에서도 이름만 다를 뿐 같은 SQL을 만든다.

SELECT ... FOR UPDATE 조건부 UPDATE
Laravel lockForUpdate() where('stock', '>', 0)->decrement('stock')
Spring Data JPA @Lock(LockModeType.PESSIMISTIC_WRITE) @Modifying @Query("update ... where p.stock > 0")
Django select_for_update() filter(stock__gt=0).update(stock=F("stock") - 1)
TypeORM lock: { mode: "pessimistic_write" } QueryBuilder update() + affected 확인

트랜잭션을 걸었다고 동시성 문제가 해결되지는 않는다. 어떤 프레임워크를 쓰든 “읽기 → 판단 → 쓰기” 사이에 다른 요청이 끼어들 틈이 있는지 먼저 확인하자. 틈이 있다면 읽을 때 잠그거나, 판단과 쓰기를 한 문장으로 합치면 된다. 그리고 실제로 어떤 SQL이 나가는지 쿼리 로그로 확인하는 습관을 들이면 프레임워크가 바뀌어도 같은 기준으로 판단할 수 있다.


참고