재고처리는 트랜잭션으로 충분할까?

시작하기에 앞서

커머스 앱을 개발하다 보면 반드시 마주치는 기능이 몇 가지 있는데, 그중 하나가 재고 처리다.
커머스 도메인의 핵심은 주문결제라고 생각한다.

결제 도메인은 주문의 완성이자, 주문 흐름을 끝까지 이어 주는 역할을 한다. 결제는 “완료”로 끝나지 않는다. 취소가 있고, 현금영수증이나 에스크로 같은 부가 기능도 따라온다. 이 과정에서 발생하는 수많은 금액 계산은 결코 단순하지 않다.

예를 들어 결제 도메인에는 보통 아래 같은 기능들이 녹아 있다.

  • 결제 수단/PG 연동: 카드/계좌이체/간편결제 등 수단별 승인/실패 처리, 리다이렉트/웹훅 수신
  • 결제 승인/캡처/매입: 승인 이후 단계(캡처/매입)가 분리되는 케이스, 타임아웃/중복 요청 방지(멱등)
  • 취소/부분 취소/환불: 부분 환불, 복수 결제수단 혼합 결제, 환불 수수료/정산 영향
  • 정산/증빙: 현금영수증, 세금계산서(또는 매출전표), 에스크로, 정산 데이터 적재/대사
  • 금액 계산: 쿠폰/포인트/프로모션, 배송비, 부가세/면세, 반올림 규칙 등

주문 도메인 역시 커머스에서 빼놓을 수 없다.

주문 도메인도 생각보다 넓다. 단순히 “주문을 만든다”에서 끝나지 않고, 아래처럼 상태와 후속 흐름이 이어진다.

  • 장바구니/주문서: 옵션/수량, 가격 스냅샷, 쿠폰/포인트 적용, 배송지/배송 방법 선택
  • 주문 생성/상태 관리: 주문/주문상품(라인아이템), 상태 머신(결제대기→결제완료→상품준비→배송→구매확정 등)
  • 배송/출고: 묶음배송/분리배송, 송장/택배사 연동, 출고 지시, 배송비 정책
  • 클레임: 취소/반품/교환, 부분 클레임, 재배송, 환불/재결제와의 연동
  • CS/관리 기능: 주문 변경(주소/옵션), 수동 처리, 이력/감사 로그, 알림(이메일/SMS/푸시)

주문/결제 도메인의 세부를 들여다보면 정말 다양한 기능이 있겠지만, 오늘은 그중에서도 재고 처리에 대해 이야기해 보려고 한다.

단순하지만은 않은 재고처리

재고 처리 설계를 보면 보통 아래 같은 형태로 시작한다.

Copy
BEGIN TRANSACTION
TRY
  DECREASE_STOCK(quantity = 1)
  COMMIT
CATCH error
  ROLLBACK
  RAISE error
END

트랜잭션으로 재고를 처리하면 row lock(로우 락)이 걸린다. 그래서 여러 사용자가 동시에 재고를 수정하려고 해도, 한 번에 한 트랜잭션만 처리되면서 재고가 일관되게 차감된다.
구현이 단순하다는 장점도 크다.

그런데 사용자가 많아지면 어떨까? 어떤 쇼핑몰에서 이벤트를 한다고 가정해 보자.
특가 상품 재고 1,000개가 정확히 13:00에 풀린다. 광고를 크게 해서 초당 요청 수(RPS)가 10만까지 치솟는 상황이다.

자, 이제 13:00이 됐다. 구매 전환율이 10%라고 치면 1만 명이 동시에 주문을 시도한다.

과연 어떤 일이 벌어질까?

1만명이 동시에 재고차감을 시도하게 된다면

  1. MySQL(InnoDB)에서 재고 차감은 어떤 락이 걸리나?

보통 다음과 같은 코드가 실행될 것이다.

Copy
START TRANSACTION;

-- 재고 확인 + 차감(원자적으로)
UPDATE product_stock
SET stock = stock - 1
WHERE product_id = ?
  AND stock >= 1;

-- 영향받은 row가 1이면 성공, 0이면 품절

COMMIT;

이때 InnoDB는 대략 이렇게 동작한다.

UPDATE ... WHERE product_id = ? 는 해당 row(또는 해당 인덱스 레코드)에 배타 락(X lock)을 건다.
같은 product_id를 동시에 업데이트하려는 트랜잭션은 그 X lock이 풀릴 때까지 기다린다.

즉 재고 row 하나가 단일 병목 지점이 된다. 트랜잭션은 안전성을 주지만, 동시성은 사실상 직렬 처리가 된다.

추가로 범위 조건이 있거나(예: BETWEEN), 격리 수준이 REPEATABLE READ인 경우에는 next-key lock(레코드 락 + 갭 락)이 붙을 수도 있다.
다만 여기서는 “특정 PK 1건 업데이트”라서 레코드 락 1건이 핵심 병목이라고 보면 된다.

  1. 10,000번째 요청자는 얼마나 기다리나? (대략 계산)

같은 row에 대한 업데이트는 “동시에 1개만” 진행되고, 나머지는 줄을 선다.
따라서 N번째 요청자의 대기시간은 (앞사람들 처리시간의 합)과 거의 같다.

처리시간을 구성하는 요소(한 트랜잭션당)

  • 네트워크 왕복 + 쿼리 파싱
  • 락 획득/대기
  • row 업데이트
  • redo log / binlog / flush 정책에 따른 I/O
  • COMMIT

락이 걸리기 시작하면 대부분은 대기가 된다.

  • 재고 차감 트랜잭션이 평균 5ms에 끝난다고 가정한다(실서비스에서는 커밋/로그 정책, 디스크/복제 상황에 따라 1~10ms+로 흔들린다)
  • 같은 상품 1개 row에 10,000명이 몰림 → 사실상 직렬 처리

그러면

  • 1번째: ~5ms
  • 10번째: ~50ms
  • 100번째: ~500ms (0.5초)
  • 1,000번째: ~5,000ms (5초)
  • 10,000번째: ~50,000ms (50초)

만약 트랜잭션이 2ms면 ~20초, 10ms면 ~100초 수준까지 간다.
즉 “트랜잭션/row lock 덕분에 안전하지만”, 대량 동시에는 대기열이 곧 응답시간이 된다.

또한 MySQL에는 innodb_lock_wait_timeout(기본값이 50초인 경우가 많다)이 있어서, 뒤쪽 요청은 Lock wait timeout으로 실패할 가능성이 커진다.

  1. 이 대기가 서버 전체 부하에 주는 영향은?

락 대기 자체는 CPU를 계속 태우는 작업은 아니다. 하지만 시스템 전체로 보면 부하가 커진다. 이유는 “일이 안 끝나서 줄이 길어지기” 때문이다.

(1) DB 커넥션/스레드/메모리 고갈

  • 10,000개 요청이 DB에서 락 대기를 하면
    • MySQL의 연결(threads)이 늘어나고
    • 각 커넥션의 버퍼/스택/정렬 버퍼 등 메모리가 늘고
    • 스케줄링/컨텍스트 스위칭 비용이 올라간다.
  • 애플리케이션 서버도 동일하게
    • 요청 스레드(혹은 이벤트루프의 pending 작업)가 쌓이고
    • 타임아웃/재시도까지 겹치면 “증폭” 된다.

(2) “슬로우 쿼리”는 대부분 ‘실행’이 아니라 ‘대기’ 때문에 발생 슬로우 로그에는 “Query time”이 길게 찍히는데, 실제 CPU 작업이 아니라 Lock wait 시간이 길어서 느려진 경우가 많다.

(3) tail latency + 재시도 폭탄

  • 앞쪽 몇 명은 성공하지만
  • 뒤쪽은 10~60초 대기하다가 타임아웃 → 클라/서버 재시도 → 더 많은 동시 요청 → 더 긴 대기
  • 이게 흔히 “결제 러시 때 시스템이 무너지는” 패턴이다.
  1. 요청자 순서별 시간(대기열)을 그림으로 설명

아래는 “동일 상품 row 1개”에 대한 업데이트가 직렬 처리되면서 응답이 늘어나는 모습을 단순화한 타임라인을 그려보았다. (각 트랜잭션 5ms 가정)

Copy
시간(ms) →
0      5     10    15    20    ...                               50,000
|------|------|------|------|------------------------------------------|

요청 #1:   [락획득+UPDATE+COMMIT]
요청 #2:          [대기][락획득+UPDATE+COMMIT]
요청 #3:                 [대기........][락획득+UPDATE+COMMIT]
...
요청 #10:                         (약 45ms 대기) [처리 5ms]
요청 #100:                        (약 495ms 대기) [처리 5ms]
요청 #1000:                       (약 4,995ms 대기) [처리 5ms]
요청 #10000:                      (약 49,995ms 대기) [처리 5ms]

그리고 “응답시간(대략)”을 계단 그래프처럼 보면

Copy
요청 순번(n) 증가 →
응답시간
^
|                         *
|                     *
|                 *
|             *
|         *
|     *
|  *
+--------------------------------------------------> n
   1   10   100   1000   10000

(거의 선형: 응답시간 ≈ n * 트랜잭션 시간)

정리하면 트랜잭션만으로 재고 처리를 하면 1만 번째 요청자는 오래 기다리다가 타임아웃으로 실패할 수 있다.

그래서 어떻게 해결할건데?

핵심은 병목 row에 대한 동시 UPDATE를 피하는 것이다. 이걸 달성하는 방법은 여러 가지가 있다.

1. “재고 차감”을 결제 트랜잭션에서 분리: 예약(Reservation) / 홀드(hold) 모델

결제 요청 순간에 DB 재고를 직접 깎지 말고, 먼저 “예약 토큰”을 발급하고 결제는 그 토큰으로 진행.

(1) 빠른 경로: 재고 예약(짧고 빠르게) (2) 결제 완료 후: 예약 확정/차감 반영(비동기 가능) (3) 결제 실패/만료: 예약 해제(TTL)

장점

  • 결제 API가 DB 락에 오래 묶이지 않음
  • 마지막 10,000번째 의 기다림을 즉시 실패/대기열/추첨 등 으로 UX 제어 가능

주의

  • 예약 만료 처리(스케줄러/TTL)와 중복 결제 방지(멱등성)가 필요

아래 흐름의 포인트는 결제 트랜잭션에서 재고 row를 오래 잡고 있지 않게 만드는 것이다.

Copy
[사용자]
  |
  | 주문/결제 시도
  v
[API]
  |
  | (1) 짧게 재고 예약 요청
  v
[예약 저장소(DB/Redis)] -- 예약 토큰 --> [API] -- 토큰 포함 결제 --> [PG]
                                            |
                                            | 결제 성공
                                            v
                                      [API] -- 예약 확정/최종 차감 --> [재고(DB)]
                                            ^
                                            | 결제 실패/타임아웃
                                            |
[예약 저장소(DB/Redis)] <-- 예약 해제/만료(TTL) <-- [API]

2. Redis(또는 인메모리)에서 원자 차감 + 비동기 확정 (고성능)

핫한 재고를 DB가 아니라 Redis에서 DECR/Lua로 먼저 차감하고, 성공분만 Kafka로 흘려 DB에 반영.

  • Redis: 원자 연산으로 동시 10,000도 빠르게 처리
  • DB: 소비자(컨슈머)가 순차/배치로 반영

장점

  • DB row lock 병목을 제거(또는 크게 완화)
  • “즉시 품절” 응답을 빠르게 줄 수 있음

주의

  • Redis/DB 불일치(일시적) 설계가 핵심
  • 재처리(리트라이), 중복 메시지, 컨슈머 멱등 처리 필요
  • 장애 시 재고 복구/정합성 전략이 필요(스냅샷, 이벤트 소싱/로그 등)

여기서의 핵심은 핫 패스를 DB에서 Redis로 옮기고, DB는 나중에 확정 반영하는 것이다.

Copy
[사용자] --> [API] --> (원자 차감: DECR/Lua) --> [Redis 재고]
                       |                         |
                       | 성공(>=0)                | 실패(<0)
                       v                         v
                 [이벤트 발행]                [즉시 품절 응답]
                       |
                       v
                [Kafka/Queue] --> [Consumer] --> [MySQL 반영]
                       ^
                       |
                 (API는 즉시 성공 응답)

redis

3. Optimistic Lock(버전 컬럼) + 재시도 (단, “품절 임박/핫 상품”엔 재시도 폭탄 위험)

Copy
stock_version을 두고 WHERE version = ?로 업데이트
실패 시 최신 버전 다시 읽고 재시도

장점

  • 대기 대신 실패 후 재시도 라서 DB 락 대기열이 줄 수 있음

단점

  • 동시 10,000이면 재시도 폭탄으로 오히려 더 나빠질 수 있음 (CPU/쿼리 수 폭증)

낙관적 락은 “기다리지 않고” 충돌하면 실패시키는 방식이다. 대신 실패한 요청은 다시 읽고 재시도한다.

Copy
[사용자] --> [API]
               |
               | (1) stock, version 조회
               v
            [MySQL]
               |
               | (2) UPDATE ... WHERE version = v
               v
         +-----------------------+
         | 영향 row = 1 ?         |
         +-----------------------+
           | yes           | no
           v               v
       [성공 응답]     [재시도(최대 N회)]
           |               |
           v               |
        [사용자]  <-------------------+

4. 재고를 버킷/샤딩해서 한 row 병목을 분산

Copy
product_stock_bucket(product_id, bucket_id, stock)
구매 시 임의 bucket 하나를 골라 차감 (또는 라운드로빈)
최종 재고는 합산

장점

  • 한 row에 10,000명이 몰리는 걸 100개 버킷이면 대략 100개 row로 분산

단점

  • 구현 복잡도 증가(합계, 품절 판정, 정합성)
  • 여전히 핫하면 버킷 수를 늘려야 함

한 줄(1 row)로 서던 줄을 여러 줄로 쪼개서 병목을 분산한다.

Copy
            (기존) product_stock 1 row
              -> (변경) product_stock_bucket N rows

[사용자] --> [API] --> (bucket 선택: 랜덤/라운드로빈) --> [bucket k]에서 차감
                                |
                                v
                           [MySQL buckets]
                                |
                                v
                      (총 재고 = sum(bucket stocks))

5. “대기열(Queue)”을 공식화해서 UX/시스템을 같이 살리기

결제 페이지 진입 자체를 대기열로 제어(토큰, 슬로틀)
초당 처리량을 정해 서버/DB가 감당 가능한 수준으로 평탄화

장점

  • 시스템 안정성 최고
  • tail latency를 “통제 가능한 대기”로 전환

단점

  • 제품/UX 합의가 필요

트래픽을 “그냥 맞는다”가 아니라, 앞단에서 입장권(토큰)을 나눠 주고 서버가 처리 가능한 속도로만 흘려보낸다.

Copy
[사용자] --> [대기열/게이트]
              |
              | 입장권 발급/대기(토큰)
              v
           [대기 상태]
              |
              | 허용된 속도로만 통과(Throttle)
              v
[API] --> [MySQL] / [PG] --> [사용자]

결론

race condition 상황에서 재고를 안전하게 차감하는 방법은 여러 가지가 있다. 이번에 회사에서도 재고처리 개선이 필요해서 위의 여러 제안 중 한 가지로 선택해서 구현했다. 다만 정답은 없다.

트래픽 패턴도 다르고(평시/이벤트), 시스템 구조도 다르고(DB/캐시/메시지), 비즈니스가 허용하는 정합성 수준도 다르기 때문이다.
그래도 결론은 단순하다. 병목이 되는 row에 락이 오래 걸리는 구조를 피하고, 시스템이 감당 가능한 방식으로 동시성을 설계해야 한다.

  • 즉시 정합성이 필요한가?: “절대 초과 판매 금지”인지, “잠깐의 불일치(최종 정합성)”를 허용하는지
  • 실패를 어떻게 보여줄 것인가?: 즉시 품절, 대기열, 추첨/응모, 예약 토큰 등 UX 정책
  • 재시도/중복 요청이 들어오면 어떻게 되는가?: API/컨슈머/결제 콜백 전 구간 멱등성 설계 여부
  • 장애 시 복구가 가능한가?: 이벤트 재처리, 재고 복구 시나리오, 데이터 대사(정산/주문/재고)
  • 관측이 가능한가?: 락 대기, 타임아웃, 재시도 횟수, 큐 적체, 실패율을 모니터링/알람으로 잡는지

이런 질문들의 답변에 따라서 어떻게 설계할지, 어디까지 오류를 인정하고 넘어갈것인지 를 결정해야 한다.

정리하면, 핵심은 락을 잡는 시간을 최소화하거나 락이 걸리는 구간을 더 얇게(혹은 다른 계층으로) 이동시키는 것이다.
그 위에 멱등성/재처리/관측을 얹어야 이벤트 트래픽 에서도 버티는 재고 처리가 된다.