dev notes

MySQL 선착순 쿠폰 재고를 조건부 UPDATE로 차감한 이유

2023-06-1515 min read
공유

재고 조회와 차감을 분리한 초기 구조#

기존에는 선착순 쿠폰은 남은 재고를 확인한 뒤 수량을 하나 줄이고, 발급 내역을 저장하는 순서로 처리했습니다.

text
재고 조회 → stock > 0 확인 → stock - 1 저장 → 발급 내역 저장

요청이 하나일 때는 재고가 1 → 0으로 줄고 발급 내역도 한 건 생성됩니다. 하지만 재고를 읽는 시점과 차감하는 시점이 나뉘어 있어, 두 요청이 모두 stock=1을 읽으면 각 요청이 발급 가능한 것으로 판단합니다.

이 흐름에서는 최종 재고만 확인해서는 중복 발급을 찾기 어렵습니다. 재고가 음수가 되지 않았더라도 차감 한 번에 발급 내역이 두 건 생길 수 있고, 같은 사용자가 재고를 두 번 차감할 수도 있습니다. 그래서 재고 차감과 발급 건수의 일치, 사용자별 중복 발급 방지를 함께 확인해야 했습니다.

MySQL 두 커넥션으로 재현한 중복 발급#

MySqlCouponRaceProbe.java를 보면 MySQL 8.0의 두 커넥션이 모두 재고를 조회한 뒤 UPDATE를 실행하도록 하면서 격리 수준은 InnoDB의 기본값인 REPEATABLE READ로 두었습니다.

text
T1: SELECT stock → 1
T2: SELECT stock → 1
T1: UPDATE stock = 0 → 발급 내역 저장
T2: UPDATE stock = 0 → 발급 내역 저장

두 요청이 Java에서 계산한 stock=0을 그대로 저장하므로 최종 재고는 0이지만 발급 내역은 두 건이 됩니다. SET stock = stock - 1처럼 상대값으로 차감하면서 stock > 0 조건을 UPDATE에 넣지 않았다면 같은 실행 순서에서 재고가 -1까지 내려갈 수 있습니다.

아래는 MySQL 8.0.44와 Connector/J 8.4.0으로 실행한 결과입니다.

text
mysql=8.0.44 isolation=REPEATABLE-READ useAffectedRows=false
check-then-set stock=0 issued=2 updateCounts=[1, 1]

SELECTUPDATE는 정상적으로 끝났지만, 문제는 두 명령 사이에 있던 stock > 0 판단이 같은 재고를 두 번 사용하도록 허용한 데 있었습니다.

REPEATABLE READ의 스냅샷 조회와 조건부 UPDATE#

재고 확인과 차감을 다음 조건부 UPDATE로 합쳤습니다.

sql
UPDATE coupon_stock
SET stock = stock - 1
WHERE coupon_id = :couponId
  AND stock > 0;

여기서 중요한 부분은 REPEATABLE READ의 일반 SELECTUPDATE가 같은 방식으로 row를 읽지 않는다는 점입니다. 일반 SELECT는 트랜잭션의 스냅샷을 읽지만, 값을 변경하는 UPDATE는 잠금을 획득한 뒤 커밋된 최신 row를 기준으로 WHERE 조건을 다시 확인합니다.

재현 코드에서는 T2가 먼저 일반 SELECTstock=1인 스냅샷을 만들고, T1이 조건부 UPDATE로 재고를 0까지 차감한 뒤 커밋하도록 했습니다. 이후 T2가 같은 조건부 UPDATE를 실행하면 기존 스냅샷의 1이 아니라 현재 재고 0을 확인하므로 변경되는 row가 없게됩니다.

text
current-read snapshot=1 firstUpdate=1 secondUpdate=0
conditional-update stock=0 issued=1 updateCounts=[0, 1]

따라서 이 방식은 단순히 두 요청을 순서대로 대기시키는것 뿐만이 아니라, 대기 후 최신 재고에 stock > 0을 다시 적용하고, 그 결과를 UPDATE의 반환값으로 받을 수 있게 됩니다.

격리 수준이 READ COMMITTED여도 조건부 UPDATE의 결과는 동일합니다. 일반 SELECT가 statement마다 새로운 스냅샷을 읽는다는 차이는 있지만, UPDATE는 현재 row를 기준으로 조건을 확인하고 차감합니다.

JDBC update count로 발급 여부 결정하기#

애플리케이션에서는 조건부 UPDATE의 반환값이 1일 때만 발급 내역을 저장합니다.

java
int updated = couponStockRepository.decreaseIfAvailable(couponId);
if (updated != 1) {
    throw new SoldOutException();
}
 
couponRepository.save(issuedCoupon);

여기서 updated는 MySQL의 ROW_COUNT()를 직접 조회한 값이 아니라 JDBC가 반환한 update count입니다. Connector/J는 기본 설정인 useAffectedRows=false에서 변경된 row가 아닌 조건에 매칭된 row 수를 반환합니다. 이 쿼리는 stock > 0에 매칭되면 반드시 stock = stock - 1로 값이 바뀌므로 매칭된 row 수와 실제 차감된 row 수가 같습니다.

이 성질은 모든 UPDATE에 그대로 적용되지 않습니다. 이미 같은 값일 수 있는 SET status = 'USED' 같은 멱등 쿼리에서는 매칭된 row와 변경된 row의 의미가 다르므로, JDBC 설정과 쿼리 조건을 확인한 뒤 반환값을 성공 기준으로 사용해야 합니다.

Spring Data JPA에서 @Query로 벌크 UPDATE를 실행한다면 @Modifying을 함께 사용합니다.

java
@Modifying
@Query("""
    update CouponStock stock
       set stock.quantity = stock.quantity - 1
     where stock.couponId = :couponId
       and stock.quantity > 0
    """)
int decreaseIfAvailable(Long couponId);

벌크 쿼리는 영속성 컨텍스트의 엔티티를 거치지 않고 DB를 직접 변경합니다. 같은 트랜잭션에서 CouponStock을 먼저 조회했거나 차감 후 다시 사용할 경우에는 1차 캐시에 이전 수량이 남을 수 있으므로, 해당 흐름에서는 엔티티를 다시 조회하거나 clearAutomatically 사용을 검토해야 합니다. 실행 전에 반영해야 할 변경이 영속성 컨텍스트에 남아 있다면 flushAutomatically도 같은 맥락에서 선택합니다.

재고 차감과 1인 1매를 같은 트랜잭션에서 보장하기#

조건부 UPDATE는 남은 재고보다 많이 발급되는 문제를 막지만, 같은 사용자의 중복 요청까지 구분하지는 않습니다. (따닥) 발급 테이블에는 (coupon_id, user_id) 유일 제약을 두고, 재고 차감과 발급 내역 저장을 같은 트랜잭션에서 실행했습니다.

sql
ALTER TABLE issued_coupon
ADD CONSTRAINT uk_coupon_user UNIQUE (coupon_id, user_id);
java
@Transactional
public void issueCoupon(Long couponId, Long userId) {
    int updated = couponStockRepository.decreaseIfAvailable(couponId);
    if (updated != 1) {
        throw new SoldOutException();
    }
 
    couponRepository.save(IssuedCoupon.of(couponId, userId));
}

본문의 SoldOutExceptionRuntimeException 계열로, 품절 분기에서는 변경된 row가 없어 롤백할 데이터도 없지만, 같은 예외가 다른 발급 단계에서 사용되더라도 Spring의 기본 롤백 규칙이 적용됩니다.

동일 사용자의 요청 두 건이 모두 재고 차감까지 도달하더라도 두 번째 INSERT는 유일 제약에 걸립니다. 이 예외로 트랜잭션이 롤백되면 두 번째 요청이 차감한 재고도 복구됩니다. 시작 재고를 2로 둔 재현 결과에서도 발급 내역은 한 건만 남고 재고는 1이었습니다.

text
unique-per-user stock=1 issued=1 duplicateRejected=1

Spring의 기본 트랜잭션 규칙은 RuntimeExceptionError에서 롤백합니다. DB 유일 제약 위반은 일반적으로 Spring의 런타임 데이터 접근 예외로 변환되지만, 차감 이후 체크 예외를 직접 던지는 흐름이라면 rollbackFor를 지정하지 않는 한 같은 결과를 기대할 수 없습니다.

existsBy 조회만으로 중복 저장을 막기 어려운 이유도 비슷한 내용을 다루고 있는데, 사전 조회는 빠른 실패를 돕는 용도로 사용할 수 있지만, 최종 중복 방지는 DB의 유일 제약이 담당해야 합니다.

조건부 UPDATE가 남기는 핫 로우#

조건부 UPDATE를 사용해도 같은 쿠폰을 요청한 트랜잭션은 하나의 재고 row에서 차례로 처리됩니다. InnoDB는 UPDATE 대상 index record에 exclusive lock을 설정하고, 해당 lock은 트랜잭션이 커밋되거나 롤백될 때까지 유지합니다.

재현 코드에서 첫 번째 트랜잭션이 재고를 차감한 뒤 400ms 동안 커밋하지 않자, 같은 쿠폰을 차감한 두 번째 커넥션은 그동안 반환되지 않았습니다. 반면 다른 coupon_id의 row는 첫 번째 트랜잭션이 끝나기 전에도 바로 변경됐습니다.

text
same-row-wait blockedBeforeCommit=true waitMs=416
row-lock-scope coupon1=1 coupon2=0 otherRowUpdated=1

같은 row의 대기 시간이 트랜잭션 길이에 영향을 받는다는 점을 확인할수 있는데, 실제 발급 코드에서는 재고 UPDATE 뒤에 발급 INSERT와 커밋이 이어지므로, 같은 쿠폰을 기다리는 요청은 이 과정이 끝날 때까지 커넥션을 점유할 수 있습니다. SELECT ... FOR UPDATE를 선택해도 같은 재고 row를 직렬화한다는 비용은 달라지지 않습니다.

쿠폰 상태와 유효 기간처럼 여러 값을 읽은 뒤 발급 여부를 결정해야 한다면 SELECT ... FOR UPDATE로 조회부터 잠그는 편이 낫습니다. 현재 조건은 stock > 0 하나이므로 조회를 유지한 채 lock을 추가하지 않고, 조건부 UPDATE의 결과로 발급 여부를 결정했습니다.

추가적으로 동일한 재고에 요청이 몰리면 lock을 기다리는 동안 커넥션이 반환되지 않아, 다음 요청이 DB 커넥션을 얻지 못하는 상황으로 이어질 수 있습니다. MySQL 8.0의 innodb_lock_wait_timeout 기본값은 50초지만 애플리케이션의 커넥션 풀 대기 시간이 더 짧으면 pool 획득 실패가 먼저 나타납니다. 따라서, 이 경로에서는 DB timeout만 확인하지 않고 pool의 active·pending connection과 row lock 대기를 함께 봐야 합니다.

그래서 조건부 UPDATE를 적용한 뒤에도 트랜잭션 안에는 재고 차감과 발급 저장처럼 함께 성공해야 하는 DB 작업만 남기고, 외부 API 호출이나 별도의 계산은 밖으로 분리해야 하는것을 추천합니다.

그 정도로도 특정 쿠폰의 재고 row가 병목이 된다면, 재고를 여러 row로 나누거나 Redis와 Queue에서 유입을 조절하는 구조를 검토할 수 있습니다. 대신 재고 조각의 합산, 실패 복구, DB와의 정합성을 별도로 다뤄야 하므로 트래픽 규모가 그 복잡성을 감수할 수준인지 먼저 확인해야 합니다.

마무리하면서#

조건부 UPDATE는 초과 발급을 막지만 처리량을 늘려주는 방법은 아닙니다. 같은 쿠폰의 요청은 여전히 하나의 재고 row에서 순서대로 처리되며, 이 구조는 동시성 제어를 DB의 짧은 트랜잭션 안으로 좁혀 정확성을 확보하는 선택입니다.

실제 발급 흐름에서는 차감 쿼리만 바꿔서는 부족했습니다. UPDATE가 최신 재고로 조건을 다시 확인하는지, JDBC update count가 무엇을 뜻하는지, 사용자별 유일 제약 위반이 재고 차감까지 롤백하는지, lock을 얼마나 오래 보유하는지를 함께 확인해야 했습니다. 같은 row의 대기가 감당할 수 있는 범위를 넘으면 그때부터는 lock의 종류가 아니라 재고를 한곳에 모아 둔 구조 자체를 나눠야 합니다.

Connected Notes