목록으로

이커머스 재고 차감, -1 고정 버그부터 비관적 락까지 — 동시성 제어 기록

8

들어가며

최근에 신규 쇼핑몰 프로젝트를 맡게 되었다. 기존에 구축되어 있던 코드베이스를 이어받아 기능을 추가하고 운영을 준비하는 과정인데, 코드를 읽어가다 보니 재고 관련 로직에서 몇 가지 문제가 눈에 들어왔다.

결제 승인이 되었는데 재고가 음수로 내려갈 수 있는 구조, 동시 주문이 들어왔을 때 둘 다 결제가 성공할 수 있는 구조, 취소했는데 재고가 복원되지 않는 구조 — "재고"라는 두 글자가 생각보다 무겁다는 걸 체감하게 되었다.

이번 글에서는 인수받은 코드에서 발견한 재고 차감 버그 3종을 수정하면서, 동시성 제어를 어떤 관점에서 설계했는지 그 과정과 고민을 정리해보려 한다. 정답이라기보다는 현재 서비스 규모와 상황에서 내가 내린 판단의 기록이다.

다루는 내용:

  1. qty를 무시하고 -1만 차감하던 버그
  2. 낙관적 락 vs 비관적 락 — 왜 비관적 락을 선택했는가
  3. PESSIMISTIC_WRITE 락의 동작 원리와 적용
  4. 데드락 방지를 위한 락 순서 설계
  5. 트랜잭션 타임아웃과 커넥션 풀 설정
  6. 타입 불일치(Long → Integer)로 생기는 silent overflow
  7. 회귀 방지 테스트 전략

1. 첫 번째 버그: qty를 무시하고 -1만 차감

발견

주문 상품의 재고를 차감하는 stockMinus() 메서드를 보다가 눈을 의심했다.

// Before — 버그가 있던 코드
public void stockMinus() {
  if (this.optionCode != null) {
    this.product.getProductOptionCombinations()
        .stream()
        .filter(x -> x.getOptionCode().equals(this.optionCode))
        .findAny()
        .ifPresent(poc -> poc.setStock(poc.getStock() - 1)); // ← 항상 -1
  } else {
    this.product.setStockQuantity(this.product.getStockQuantity() - 1); // ← 항상 -1
  }
}

고객이 같은 상품을 5개 주문해도, 재고는 1개만 줄어든다. this.qty를 아예 참조하지 않고 있었다.

문제가 오래 숨어있을 수 있었던 이유

몇 가지 요인이 겹쳐 있었다.

첫째, 재고 차감에 대한 단위 테스트가 없었다. qty=1인 케이스만으로는 이 버그가 드러나지 않는데, qty > 1을 검증하는 테스트 자체가 존재하지 않았다.

둘째, 결제 테스트를 실결제로만 할 수 있었다. PG 테스트 키가 없는 환경이어서, 결제 흐름을 검증하려면 실제 카드로 결제해야 했다. 자연히 2개 이상 구매하는 테스트를 자주 해보기 어려웠고, 대부분 1개 결제로만 확인하게 되었다.

셋째, 쇼핑몰 자체의 기능이 워낙 많았다. 상품 등록, 옵션 관리, 쿠폰, 배송, 정산 등 검증해야 할 영역이 넓다 보니, 재고 차감 로직 하나에까지 깊이 들여다보기가 쉽지 않은 상황이었다.

결과적으로 1개 주문이면 -1-qty의 결과가 같으니, 테스트에서도 운영에서도 눈에 띄지 않은 채 숨어있었던 것이다.

수정

// After — qty 반영 + 음수 가드
public void stockMinus() {
  if (this.optionCode != null) {
    ProductOptionCombination poc = this.product.getProductOptionCombinations()
        .stream()
        .filter(x -> x.getOptionCode().equals(this.optionCode))
        .findAny()
        .orElseThrow(() -> new BadRequestException(
            String.format("[%s] 옵션이 존재하지 않습니다. optionCode=%s",
                this.productName, this.optionCode)));

    int newStock = poc.getStock() - this.qty;  // qty 반영
    if (newStock < 0) {
      throw new BadRequestException(
          String.format("[%s %s] 재고가 부족합니다. (재고: %d / 주문: %d)",
              this.productName, this.optionName,
              poc.getStock(), this.qty));
    }
    poc.setStock(newStock);
  } else {
    int newStock = this.product.getStockQuantity() - this.qty;  // qty 반영
    if (newStock < 0) {
      throw new BadRequestException(
          String.format("[%s] 재고가 부족합니다. (재고: %d / 주문: %d)",
              this.productName,
              this.product.getStockQuantity(), this.qty));
    }
    this.product.setStockQuantity(newStock);
  }
}

핵심 변경 3가지:

  • - 1- this.qty : 주문 수량만큼 차감
  • ifPresentorElseThrow : 옵션이 없으면 조용히 넘어가는 대신 예외 발생
  • 음수 가드 추가: 차감 전에 재고 부족 여부를 검증

취소/반품 시 재고 복원도 누락되어 있었다

기존에는 stockMinus()만 있고 stockPlus()가 아예 없었다. 주문을 취소하거나 반품해도 재고가 돌아오지 않는 상태였다.

// 신규 추가 — 재고 복원
public void stockPlus() {
  if (this.optionCode != null) {
    ProductOptionCombination poc = this.product.getProductOptionCombinations()
        .stream()
        .filter(x -> x.getOptionCode().equals(this.optionCode))
        .findAny()
        .orElseThrow(() -> new BadRequestException(
            String.format("[%s] 옵션이 존재하지 않습니다. optionCode=%s",
                this.productName, this.optionCode)));
    poc.setStock(poc.getStock() + this.qty);
  } else {
    this.product.setStockQuantity(this.product.getStockQuantity() + this.qty);
  }
}

2. 두 번째 버그: 동시 주문 시 재고 검증 없음

문제 상황

시점 T1: 고객A가 상품X(재고 1) 결제 승인 요청
시점 T2: 고객B도 상품X(재고 1) 결제 승인 요청
→ 둘 다 재고 1을 읽고, 둘 다 결제 성공, 재고 -1

전형적인 lost update 문제다. 기존 코드에는 결제 승인 시점에 재고를 검증하는 로직 자체가 없었고, 트랜잭션 격리 수준만으로는 이 문제를 막기 어렵다고 판단했다.

잠깐 — 트랜잭션 격리 수준으로는 왜 안 되는가?

이 부분을 좀 짚고 넘어가고 싶다. "트랜잭션 격리 수준을 올리면 동시성 문제가 해결되지 않나?"라는 생각이 들 수 있기 때문이다.

MySQL InnoDB의 기본 격리 수준은 REPEATABLE READ다. SQL 표준에서 정의한 4가지 격리 수준 중 두 번째로 높은 레벨이다.

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTED발생발생발생
READ COMMITTED차단발생발생
REPEATABLE READ차단차단InnoDB에서는 차단
SERIALIZABLE차단차단차단

REPEATABLE READ는 "내 트랜잭션 안에서 같은 SELECT를 여러 번 날려도 항상 같은 결과를 보장한다"는 뜻이다. InnoDB는 여기에 더해 MVCC(Multi-Version Concurrency Control)를 사용하는데, 각 트랜잭션이 시작 시점의 스냅샷을 기준으로 데이터를 읽는다.

문제는 이 "스냅샷 읽기"가 오히려 동시성 버그를 만들 수 있다는 점이다:

트랜잭션 A (시작)                    트랜잭션 B (시작)
│                                    │
├─ SELECT stock FROM product         ├─ SELECT stock FROM product
│  WHERE id=100                      │  WHERE id=100
│  → stock = 1 (스냅샷)              │  → stock = 1 (스냅샷)
│                                    │
├─ (재고 1 ≥ 주문 1, OK)             ├─ (재고 1 ≥ 주문 1, OK)
│                                    │
├─ UPDATE product                    ├─ UPDATE product
│  SET stock = stock - 1             │  SET stock = stock - 1
│  WHERE id=100                      │  WHERE id=100
│                                    │
├─ COMMIT (stock = 0)                ├─ COMMIT (stock = -1!)

두 트랜잭션 모두 자기 스냅샷에서 stock = 1을 읽었기 때문에, 둘 다 "재고 충분"이라고 판단하고 UPDATE를 실행한다. REPEATABLE READ는 읽기의 일관성을 보장하지, 쓰기의 배타성을 보장하지는 않는다.

그렇다면 격리 수준을 SERIALIZABLE로 올리면? 이론적으로는 모든 읽기에 공유 락이 걸리므로 위 문제가 방지된다. 하지만 실제로는 모든 SELECT가 SELECT ... FOR SHARE처럼 동작하면서 락 경합이 극심해지고, 데드락 발생률도 크게 올라간다. 재고 차감 한 곳을 위해 전체 서비스의 격리 수준을 SERIALIZABLE로 올리는 건 부작용이 너무 크다고 느꼈다.

결국 **"읽는 시점에 해당 행을 명시적으로 잠근다"**는 접근이 필요했고, 그래서 비관적 락(SELECT ... FOR UPDATE)을 선택하게 되었다. 격리 수준은 REPEATABLE READ 그대로 두되, 재고를 읽는 그 한 곳에서만 정확하게 락을 거는 방식이다.

락(Lock)이란?

본격적인 해결 이야기에 앞서, 락 자체를 간단히 짚고 넘어가고 싶다.

데이터베이스에서 은 여러 트랜잭션이 동시에 같은 데이터에 접근할 때 "한 번에 하나만 수정할 수 있게" 통제하는 장치다. 카페 화장실 열쇠가 하나뿐인 것과 비슷하다고 생각한다 — 누군가 들어가 있으면 다음 사람은 열쇠가 돌아올 때까지 기다려야 한다.

JPA/Hibernate에서 동시성을 제어하는 방법은 크게 두 가지가 있다:

낙관적 락 (Optimistic Lock)

"충돌은 잘 안 나겠지"라는 가정 하에 동작한다.

@Entity
public class Product {
  @Version
  private Long version;  // JPA가 자동 관리
}
  • 엔티티에 @Version 필드를 추가하면, UPDATE 시 WHERE version = ? 조건이 붙는다
  • 내가 읽었을 때의 version과 UPDATE 시점의 version이 다르면 → 누군가 먼저 수정한 것 → OptimisticLockException 발생
  • DB 락을 잡지 않으므로 대기가 없고, 읽기 위주의 트래픽에서는 성능이 좋다
  • 실제 SQL은 이런 식이다:
UPDATE product SET stock_quantity = 9, version = 2
WHERE id = 100 AND version = 1;
-- affected rows = 0 이면 충돌 → 예외 발생

비관적 락 (Pessimistic Lock)

"충돌이 날 거다"라는 가정 하에 동작한다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);
  • SELECT ... FOR UPDATE 쿼리를 날려서, 해당 행을 DB 레벨에서 잠근다
  • 다른 트랜잭션이 같은 행을 FOR UPDATE로 읽으려 하면 → 앞 트랜잭션이 커밋/롤백할 때까지 대기
  • 락을 잡고 있는 동안 다른 트랜잭션의 일반 SELECT는 가능하다 (읽기는 차단하지 않는다, MySQL InnoDB 기준)
  • 실제 SQL:
SELECT * FROM product WHERE id = 100 FOR UPDATE;
-- 이 트랜잭션이 끝날 때까지, 다른 트랜잭션의 FOR UPDATE는 대기

왜 비관적 락을 선택했는가

솔직히 처음에는 낙관적 락(@Version)도 고려했다. 충돌 시 재시도하면 되니까 구현이 더 단순하지 않을까 하는 생각이었다. 하지만 결제 흐름에서는 몇 가지 이유로 비관적 락이 더 적절하다고 판단했다.

비교 항목낙관적 락비관적 락
충돌 감지 시점UPDATE(커밋) 시점에야 알 수 있음SELECT 시점에 선점
충돌 시 동작예외 → 재시도 필요대기 → 순서대로 처리
외부 API 호출과 조합재시도 시 PG 결제를 다시 호출해야 할 수 있음락 안에서 순서대로 흘러가므로 PG 이중 호출 위험이 낮음
적합한 상황읽기 많고 쓰기 충돌이 드문 경우쓰기 충돌이 빈번하거나 재시도 비용이 큰 경우

결제 승인 흐름에서 낙관적 락을 쓰면 이런 시나리오가 생길 수 있다고 봤다:

1. 재고 읽기 (version=1, stock=1)
2. PG사 결제 승인 API 호출 → 성공 (카드 결제 완료)
3. UPDATE product SET stock=0, version=2 WHERE version=1
   → 다른 트랜잭션이 먼저 version을 올렸으면 affected rows=0
   → OptimisticLockException 발생
4. 그런데 PG 결제는 이미 승인됨 → 취소 API를 다시 호출해야 함

"결제는 됐는데 재고 차감은 실패" 상황이 되는 것이다. 물론 PG 취소 → 재시도 로직을 구현하면 해결할 수 있지만, 그 보상 트랜잭션(compensation) 자체가 복잡도를 크게 올린다고 느꼈다.

반면 비관적 락은:

1. SELECT ... FOR UPDATE (락 획득, 다른 트랜잭션은 여기서 대기)
2. 재고 검증 → 충분하면 진행, 부족하면 여기서 바로 실패
3. PG사 결제 승인 API 호출 → 성공
4. 재고 차감 → 락이 잡혀있으므로 안전
5. 커밋 → 락 해제, 대기 중이던 다음 트랜잭션이 진행

PG를 호출하기 전에 재고를 확정할 수 있으니, "결제했는데 재고가 없다"는 상황이 원천적으로 차단된다. 물론 비관적 락은 대기 시간이 발생한다는 트레이드오프가 있지만, 재고 차감처럼 짧은 구간에서만 잠그면 실질적인 영향은 크지 않을 거라 판단했다.

정답은 아닐 수 있다. 트래픽이 훨씬 크거나 분산 환경이라면 Redis 기반의 분산 락이나, 메시지 큐로 직렬화하는 방식이 더 나을 수도 있다. 다만 현재 서비스 규모와 인프라에서는 DB 비관적 락이 가장 단순하면서도 충분하다고 생각했다.

적용: JPA에서 비관적 락 설정

// ProductRepository — 재고 수정용 비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
    @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
})
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);

몇 가지 설명을 덧붙이면:

LockModeType.PESSIMISTIC_WRITE: JPA 표준으로 정의된 비관적 쓰기 락이다. Hibernate가 이걸 MySQL의 SELECT ... FOR UPDATE로 변환해준다. 비슷한 것으로 PESSIMISTIC_READ(FOR SHARE)가 있는데, 이건 "읽기는 허용하되 쓰기만 차단"하는 공유 락이다. 재고 차감은 반드시 쓰기가 동반되므로 WRITE를 선택했다.

lock.timeout = 3000 (3초): 락을 기다리는 최대 시간이다. 이 값을 따로 설정하지 않으면 DB 기본값(MySQL InnoDB의 경우 innodb_lock_wait_timeout, 기본 50초)을 따르게 되는데, 50초 동안 커넥션을 물고 대기하는 건 너무 길다고 느꼈다. 3초 안에 락을 못 잡으면 빠르게 실패시키고 클라이언트에게 "잠시 후 다시 시도해주세요"를 안내하는 편이 사용자 경험에 나을 거라 생각했다.

주의: MySQL에서 jakarta.persistence.lock.timeout 힌트가 완벽하게 동작하는지는 Hibernate 버전과 드라이버에 따라 다를 수 있다. 실제로는 innodb_lock_wait_timeout 세션 변수를 직접 설정하는 것이 더 확실한 방법이기도 하다. 나는 JPA 표준 힌트로 먼저 적용하고, 모니터링하면서 필요하면 세션 변수 방식으로 전환하려는 계획이다.

MySQL에서 FOR UPDATE가 실제로 하는 일

이 부분을 좀 더 깊이 들여다보고 싶었다. SELECT ... FOR UPDATE가 MySQL InnoDB에서 어떻게 동작하는지 이해하면 락 설계에 대한 감이 잡히기 때문이다.

-- 트랜잭션 A
BEGIN;
SELECT * FROM product WHERE id = 100 FOR UPDATE;
-- → id=100 행에 배타 락(X Lock)을 건다
-- → 이 트랜잭션이 COMMIT 또는 ROLLBACK 할 때까지 유지

-- 트랜잭션 B (거의 동시에)
BEGIN;
SELECT * FROM product WHERE id = 100 FOR UPDATE;
-- → 트랜잭션 A가 락을 해제할 때까지 여기서 대기
-- → lock.timeout 초과 시 LockTimeoutException

InnoDB의 락은 행 단위(row-level lock)로 동작한다. 테이블 전체가 잠기는 게 아니라 id = 100 행만 잠기므로, id = 200 상품에 대한 주문은 전혀 영향을 받지 않는다. 이게 비관적 락을 쓸 수 있었던 이유이기도 하다 — 테이블 락이었다면 모든 상품의 주문이 직렬화되어 성능이 크게 떨어졌을 것이다.

한 가지 주의할 점은, WHERE 조건이 인덱스를 타지 않으면 InnoDB가 테이블 풀 스캔을 하면서 스캔한 모든 행에 락을 걸 수 있다는 것이다. id는 PK이므로 이 문제는 없지만, 만약 인덱스가 없는 컬럼으로 FOR UPDATE를 하면 의도치 않게 넓은 범위가 잠길 수 있다.

StockService: 락 → 검증 → 차감 흐름

재고 관련 로직을 하나의 서비스로 모아서, 호출하는 쪽에서 실수할 여지를 줄이고 싶었다.

@Service
@RequiredArgsConstructor
public class StockService {

  private final ProductRepository productRepository;

  /**
   * 결제 승인 전: 락 획득 + 재고 검증
   */
  @Transactional(propagation = Propagation.MANDATORY)
  public void lockAndValidate(Order order) {
    lockProducts(order);
    order.getOrderProducts().forEach(this::validateStockForOrderProduct);
  }

  /**
   * 결제 승인 후: 재고 차감
   */
  @Transactional(propagation = Propagation.MANDATORY)
  public void decreaseStock(Order order) {
    order.getOrderProducts().forEach(OrderProduct::stockMinus);
  }

  /**
   * 취소/반품 승인 시: 재고 복원
   */
  @Transactional(propagation = Propagation.MANDATORY)
  public void restoreStock(OrderProduct orderProduct) {
    Product locked = productRepository.findByIdWithLock(
            orderProduct.getProduct().getId())
        .orElseThrow(() -> new NotFoundException("상품이 존재하지 않습니다."));
    orderProduct.setProduct(locked);  // 잠긴 인스턴스로 교체
    orderProduct.stockPlus();
  }
}

Propagation.MANDATORY를 쓴 이유:

이 메서드들은 반드시 기존 트랜잭션 안에서 호출되어야 한다고 생각했다. 만약 트랜잭션 없이 호출하면 IllegalTransactionStateException이 발생한다. "실수로 트랜잭션 밖에서 재고를 건드리는 것"을 컴파일 타임은 아니더라도 런타임에 즉시 잡아낼 수 있으니, 일종의 안전장치 역할이다.

REQUIRES_NEW도 고려는 했었다. 하지만 이걸 쓰면 별도 트랜잭션이 열리면서 결제 승인 트랜잭션과 재고 차감 트랜잭션이 분리된다. 결제는 성공했는데 재고 차감만 롤백되는 정합성 문제가 생길 수 있어서, 같은 트랜잭션 안에 묶어두는 게 맞다고 판단했다.

결제 흐름에 적용

기존에는 각 결제 메서드 안에서 orderProduct.stockMinus()를 직접 호출하고 있었다. 이걸 StockService로 통합해서, 재고 관련 로직이 흩어지지 않도록 했다.

[결제 요청]
    │
    ├─ lockAndValidate(order)    ← 락 획득 + 재고 검증
    │
    ├─ PG사 결제 승인 API 호출
    │
    ├─ decreaseStock(order)      ← 재고 차감
    │
    └─ 주문 상태 업데이트 (ORDST_WAIT)

이 순서에 나름의 의도가 있다:

  • 락을 먼저 잡고, PG 호출 전에 재고를 검증한다 → 재고 없는 상품에 대한 불필요한 PG 호출을 막을 수 있다
  • PG 호출이 실패하면 트랜잭션이 롤백되면서 락도 자연스럽게 해제된다
  • PG 호출이 성공한 후에야 실제 재고를 차감한다 → "결제됐는데 재고가 없다"는 역전 상황을 막는다

3. 데드락 방지: 락 순서를 강제하기

비관적 락을 도입하면 반드시 따라오는 문제가 있다 — 데드락(Deadlock).

데드락이 발생하는 시나리오

트랜잭션 A: 상품 100 락 → 상품 200 락 시도 (대기)
트랜잭션 B: 상품 200 락 → 상품 100 락 시도 (대기)
→ 서로가 서로를 기다리며 영원히 풀리지 않음 = 데드락

주문에 여러 상품이 포함되어 있을 때, 두 트랜잭션이 서로 다른 순서로 락을 잡으면 이런 일이 벌어진다. MySQL InnoDB는 데드락을 감지하면 한쪽 트랜잭션을 강제 롤백하긴 하지만, 그 자체가 불필요한 실패이고 사용자 경험에도 좋지 않다.

해결 방법은 의외로 단순하다고 느꼈다 — 모든 트랜잭션이 같은 순서로 락을 잡으면 데드락은 구조적으로 발생할 수 없다.

private void lockProducts(Order order) {
  // 1. productId 오름차순 정렬
  List<Long> sortedProductIds = order.getOrderProducts().stream()
      .map(op -> op.getProduct().getId())
      .distinct()
      .sorted()       // ← 핵심: 오름차순 정렬
      .toList();

  // 2. 정렬된 순서로 락 획득
  Map<Long, Product> lockedProducts = new HashMap<>();
  for (Long productId : sortedProductIds) {
    Product locked = productRepository.findByIdWithLock(productId)
        .orElseThrow(() -> new NotFoundException("상품이 존재하지 않습니다."));
    lockedProducts.put(productId, locked);
  }

  // 3. 잠긴 인스턴스로 OrderProduct의 Product 참조를 교체
  for (OrderProduct op : order.getOrderProducts()) {
    op.setProduct(lockedProducts.get(op.getProduct().getId()));
  }
}

distinct()가 필요한가?

같은 상품을 옵션만 다르게 2개 담는 경우가 있다 (예: 사이즈 S 1개 + 사이즈 L 1개). 이때 productId가 중복되는데, 같은 행에 FOR UPDATE를 두 번 호출하면 DB에 따라 동작이 다를 수 있으므로 중복을 제거해두는 게 안전하다고 생각했다.

3번(잠긴 인스턴스로 교체)은 왜 하는 걸까?

findByIdWithLock으로 가져온 엔티티가 "락이 걸린 상태"의 엔티티다. 사실 JPA 1차 캐시 덕분에 이미 로드된 엔티티와 같은 인스턴스를 반환할 가능성이 높다. 그래서 이 교체가 기술적으로 반드시 필요한지는 논란의 여지가 있다. 다만, "이 OrderProduct가 참조하는 Product는 락이 걸린 인스턴스다"라는 의도를 코드에 명시하고 싶었고, 혹시라도 detached 상태의 Product를 참조하는 엣지 케이스가 있을 때 안전망이 되어줄 거라 판단했다.


4. 트랜잭션 타임아웃과 커넥션 풀

비관적 락을 도입하면서 한 가지 더 걱정이 생겼다 — 트랜잭션이 오래 걸리면 어떡하지?

문제: PG 응답이 안 오면?

결제 승인 과정에서 외부 PG사 API를 호출한다. 이 API가 30초, 60초 이상 응답하지 않으면?

  1. @Transactional 메서드가 끝나지 않으므로 DB 커넥션을 계속 물고 있다
  2. 비관적 락도 해제되지 않으므로 같은 상품에 대한 다른 주문도 전부 대기
  3. 커넥션 풀이 고갈되면 재고와 무관한 API까지 전부 멈출 수 있다 — cascade failure

비관적 락 자체의 문제는 아니지만, 비관적 락을 쓰면 이 문제의 영향이 더 커지기 때문에 함께 대응해야 한다고 생각했다.

해결 1: 트랜잭션 타임아웃

// Before
@Transactional
public OrderDto.DetailResponse orderCpcgi(...) { ... }

// After
@Transactional(timeout = 40)  // 40초 제한
public OrderDto.DetailResponse orderCpcgi(...) { ... }

40초로 잡은 나름의 근거:

  • 사용 중인 PG사 API 타임아웃이 대략 30초 정도
  • 30초(PG) + 10초(DB 작업 여유) = 40초
  • 이 안에 끝나지 않으면 TransactionTimedOutException이 발생하고 롤백된다
  • 물론 이 값은 PG사 사양과 서비스 특성에 따라 조정이 필요할 수 있다

해결 2: HikariCP 커넥션 풀 명시 설정

spring:
  datasource:
    hikari:
      maximum-pool-size: 20         # 운영 환경
      connection-timeout: 10000     # 10초 (기본값 30초에서 단축)

connection-timeout을 30초 → 10초로 줄인 이유:

풀이 고갈된 상태에서 새 요청이 30초나 기다리는 건 사용자 경험에 치명적이라고 느꼈다. 10초 안에 커넥션을 못 얻으면 빠르게 실패시키고 503을 반환하는 편이 나을 거라 생각했다. "느린 실패"보다 "빠른 실패"가 사용자에게도, 시스템에도 유리한 경우가 많다.

풀 사이즈를 무작정 늘리면 안 되는 이유:

커넥션 풀 사이즈를 100으로 늘려도 DB 측 max_connections는 유한하다. 서버 인스턴스가 여러 대이면 인스턴스 수 × pool size가 DB 한계를 넘을 수 있다. 적정 사이즈는 (CPU 코어 수 × 2) + 유효 디스크 수 정도가 일반적인 가이드라인인데 (HikariCP 공식 문서 참고), 실제로는 서비스 특성에 따라 조정이 필요하다. 나도 이 값은 운영하면서 모니터링 결과를 보고 조정해나갈 생각이다.


5. 락 충돌 시 사용자 응답

비관적 락을 쓰면 필연적으로 락 타임아웃이나 충돌이 발생할 수 있다. 이걸 그냥 500 에러로 내보내면 사용자 입장에서는 "서버 에러"로만 보이니, 적절한 응답으로 변환해줄 필요가 있다고 판단했다.

@ExceptionHandler({
    PessimisticLockingFailureException.class,
    CannotAcquireLockException.class,
    LockTimeoutException.class,
    PessimisticLockException.class
})
public ResponseEntity<JSONObject> handleLockException(Exception ex) {
  log.warn("Lock conflict/timeout - path={}, type={}, message={}",
      path, ex.getClass().getSimpleName(), ex.getMessage());

  JSONObject body = new JSONObject();
  body.put("status", 409);
  body.put("error", "StockLockConflictException");
  body.put("message", "주문이 몰리고 있습니다. 잠시 후 다시 시도해주세요.");

  return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}

왜 4개 예외를 다 잡았는가:

예외발생 상황
PessimisticLockingFailureExceptionSpring Data JPA 래핑 예외 (가장 흔함)
CannotAcquireLockExceptionJDBC 레벨 락 획득 실패
LockTimeoutExceptionJPA 표준 락 타임아웃
PessimisticLockExceptionJPA 표준 비관적 락 실패

DB 벤더(MySQL, PostgreSQL 등)와 JPA 구현체(Hibernate)에 따라 어떤 예외가 올라오는지 달라질 수 있어서, 넉넉하게 4개를 전부 잡아두는 게 안전하다고 봤다.

log.warn을 쓴 이유:

락 충돌은 동시 주문이 많을 때 정상적으로 발생할 수 있는 상황이다. log.error로 잡으면 알림이 폭주할 수 있으므로 warn이 더 적절하다고 생각했다. 물론 warn이 지나치게 자주 찍힌다면 그건 락 범위나 타임아웃 설정을 재검토해야 할 신호일 수 있다.


6. 사전 검증: 장바구니/결제 준비 단계

결제 승인 시점의 비관적 락이 최종 방어선이긴 하지만, 재고가 부족한 상품이 결제 단계까지 가는 것 자체를 줄이고 싶었다. 그래서 장바구니 추가/수량 변경결제 준비 단계에서도 재고를 미리 검증하도록 했다.

// 장바구니 단계 — 락 없이 현재 재고만 확인
public void validateStockForCart(Product product,
    ProductOptionCombination option,
    int qty) {
  if (qty <= 0) {
    throw new BadRequestException("수량은 1개 이상이어야 합니다.");
  }

  if (Boolean.TRUE.equals(product.getIsUseOption()) && option != null) {
    if (option.getStock() < qty) {
      throw new BadRequestException(
          String.format("[%s] 선택한 옵션의 재고가 부족합니다. (재고: %d / 요청: %d)",
              product.getName(), option.getStock(), qty));
    }
  } else {
    if (product.getStockQuantity() < qty) {
      throw new BadRequestException(
          String.format("[%s] 재고가 부족합니다. (재고: %d / 요청: %d)",
              product.getName(), product.getStockQuantity(), qty));
    }
  }
}

장바구니 단계에서는 의도적으로 락을 걸지 않았다. 이유는 이렇다:

  • 장바구니에 담는 행위가 곧 구매는 아니다 — 담기만 하고 결제를 안 하는 경우가 훨씬 많다
  • 장바구니에 담을 때마다 FOR UPDATE를 걸면 불필요한 락 경합이 생겨서 오히려 성능을 해칠 수 있다
  • 이 검증은 "사용자에게 빠른 피드백을 주기 위한 best-effort 검증"이지, 정합성을 보장하는 검증은 아니다
  • 정합성의 최종 책임은 결제 승인 시점의 lockAndValidate에 있다
장바구니 추가 → validateStockForCart (낙관적, 락 없음)
수량 변경     → validateStockForCart (낙관적, 락 없음)
결제 준비     → validateStockForCart (낙관적, 락 없음)
결제 승인     → lockAndValidate     (비관적, FOR UPDATE)
              → PG 승인
              → decreaseStock       (실제 차감)

7. 놓치기 쉬운 버그: 타입 불일치와 silent overflow

재고 관련 코드를 정리하다가 장바구니 수량 변경 DTO에서 한 가지 더 발견했다.

// Before — qty가 Long 타입
public static class UpdateQty {
  private Long qty;  // ← Cart.qty는 Integer인데...
}

Cart.qtyInteger인데 DTO의 qtyLong이었다. MapStruct가 Long.intValue()로 자동 변환(narrowing)을 수행하는데, 만약 qty2147483648L(Integer.MAX_VALUE + 1) 같은 값이 들어오면 오버플로우가 발생해서 음수가 된다. 에러도 없이.

// After — Integer로 통일
public static class UpdateQty {
  private Integer qty;
}

MapStruct의 타입 변환은 편리하지만, narrowing 변환(Long → Integer, Double → Float)은 unmappedTargetPolicy=ERROR로도 잡히지 않는다는 걸 이번에 알게 됐다. DTO와 엔티티의 타입이 다르면 생성된 매핑 코드를 한 번쯤 직접 열어보는 습관이 필요할 것 같다.


8. 회귀 방지 테스트

버그를 고쳤으면, 같은 버그가 다시 발생하지 않도록 테스트를 남기고 싶었다.

핵심 테스트 케이스

@Test
@DisplayName("qty=5 차감 → 재고 -5 (이전 -1 고정 차감 버그 회귀 방지)")
void decrementByFive() {
  OrderProduct op = buildOrderProduct(5, null);

  op.stockMinus();

  assertThat(product.getStockQuantity()).isEqualTo(5); // 10 - 5 = 5
}

이 테스트의 이름에 **"이전 -1 고정 차감 버그 회귀 방지"**라고 명시했다. 미래에 누군가 stockMinus()를 리팩토링할 때, 이 테스트가 깨지면 "아, 이전에 이런 버그가 있었구나"를 바로 알 수 있다.

데드락 방지 테스트

@Test
@DisplayName("여러 상품 락 획득 시 productId 오름차순 호출 (데드락 방지)")
void multipleProductsLockedInOrder() {
  // 의도적으로 productC(300), productA(100), productB(200) 순으로 등록
  Order order = buildOrder(
      buildOrderProduct(productC, 1),
      buildOrderProduct(productA, 1),
      buildOrderProduct(productB, 1)
  );

  stockService.lockAndValidate(order);

  // productId 오름차순(100, 200, 300) 으로 호출되었는지 검증
  InOrder inOrder = inOrder(productRepository);
  inOrder.verify(productRepository).findByIdWithLock(100L);
  inOrder.verify(productRepository).findByIdWithLock(200L);
  inOrder.verify(productRepository).findByIdWithLock(300L);
}

InOrder를 사용해서 메서드 호출 순서를 검증한다. 누군가 정렬 로직을 실수로 제거하면 이 테스트가 즉시 깨진다.

깨진 테스트도 고쳤다

새 테스트를 작성하면서 기존 PaymentIntegrateServiceTest가 14일간 컴파일 에러 상태로 방치되어 있었다는 걸 발견했다. 메서드 시그니처가 변경된 후 테스트가 업데이트되지 않았던 것으로 보인다.

깨진 테스트를 방치하면 테스트 스위트 전체에 대한 신뢰가 무너지기 쉽다고 느꼈다. "이 테스트는 원래 실패해"라는 분위기가 한 번 생기면, 새로 추가한 테스트가 실패해도 무시하게 되지 않을까. 그래서 새 테스트를 추가하기 전에 기존 깨진 테스트부터 먼저 복구했다.


정리: 재고 동시성 제어 체크리스트

이번 작업을 하면서 스스로 확인한 항목들을 체크리스트로 정리해봤다. 비슷한 작업을 하시는 분들께도 참고가 되면 좋겠다.

항목확인 포인트
차감 단위qty를 반영하고 있는가? -1 고정이 아닌가?
음수 가드차감 전에 newStock < 0 검증이 있는가?
동시성결제 승인 시 SELECT ... FOR UPDATE 또는 동등한 락이 있는가?
데드락여러 행을 잠글 때 순서가 고정되어 있는가? (PK 오름차순 등)
락 타임아웃무한 대기가 아닌 적절한 타임아웃이 설정되어 있는가?
트랜잭션 타임아웃외부 API 호출이 포함된 트랜잭션에 timeout이 있는가?
커넥션 풀connection-timeout이 적절한가? pool-size가 DB 한계를 넘지 않는가?
복원취소/반품 시 재고가 복원되는가?
사전 검증장바구니/결제 준비에서 미리 검증하여 불필요한 PG 호출을 줄이는가?
타입 일치DTO ↔ 엔티티 간 narrowing 변환이 없는가?
테스트qty > 1 케이스, 음수 가드, 락 순서에 대한 회귀 테스트가 있는가?

이 방법이 최선인가?

글을 쓰면서 스스로에게 계속 던진 질문이다. DB 비관적 락으로 재고 동시성을 제어하는 건 동작은 하지만, 과연 이게 규모가 커져도 괜찮은 방식인지에 대해서는 솔직히 확신이 없다.

비관적 락의 한계를 느끼는 지점

트래픽이 몰리면 결국 직렬화된다. 같은 상품에 대한 주문이 초당 수십 건 이상 들어오면, FOR UPDATE로 잠긴 행 앞에 요청들이 줄을 서게 된다. 락 타임아웃 3초를 걸어뒀으니 무한 대기는 아니지만, 타임아웃으로 실패하는 요청이 늘어나면 사용자 경험이 나빠진다. 특히 한정판 상품이나 타임세일 같은 상황에서는 이런 병목이 바로 드러날 것 같다.

DB에 부하가 집중된다. 행 락은 DB 엔진이 관리하므로, 동시성 제어의 부담이 전부 DB에 실린다. 애플리케이션 서버는 스케일 아웃이 쉽지만, DB는 그렇지 않다. 서비스가 성장하면 이 구조가 병목이 될 수 있다는 건 인지하고 있다.

트랜잭션 안에 외부 API 호출이 있다. 이건 이번 구조에서 가장 찜찜한 부분이다. PG 승인 API 호출이 트랜잭션 안에 있으므로, PG 응답이 느려지면 락 보유 시간도 함께 늘어난다. 타임아웃을 40초로 걸어뒀지만, 그 40초 동안 해당 상품의 다른 주문이 전부 대기하게 되는 건 분명히 약점이다.

다른 선택지를 고려해봤다

1. Redis 분산 락 (Redisson 등)

SETNX stock_lock:{productId} {txId} EX 5

DB 락 대신 Redis에서 락을 잡으면, DB 커넥션을 물고 있지 않아도 되고 락 획득/해제가 훨씬 빠르다. 하지만 Redis 자체가 SPOF(단일 장애점)가 될 수 있고, 락과 실제 DB 데이터 사이의 정합성을 애플리케이션이 직접 보장해야 한다. 인프라 복잡도가 올라가는 셈이다.

현재 서비스에 Redis가 아직 도입되어 있지 않은 상태라, "재고 락 하나 때문에 Redis를 들이는 게 맞는가?"라는 고민이 있었다. 나중에 캐시 등 다른 용도로 Redis를 도입하게 되면 그때 함께 전환하는 게 자연스럽지 않을까 생각하고 있다.

2. 재고를 별도 테이블로 분리 + UPDATE WHERE 조건

UPDATE product_stock
SET quantity = quantity - :qty
WHERE product_id = :id AND quantity >= :qty;
-- affected rows = 0 이면 재고 부족

SELECT → 검증 → UPDATE 대신 한 번의 원자적 UPDATE로 처리하는 방식이다. 락을 명시적으로 잡지 않아도 되고, DB가 알아서 행 수준의 원자성을 보장해준다. 넓은 범위의 비관적 락보다 경합이 적을 수 있다.

다만 이 방식은 "왜 실패했는지"에 대한 정보(현재 재고가 얼마인지, 어떤 옵션이 부족한지)를 돌려주기가 까다롭다. affected rows = 0이면 재고 부족이라는 것만 알 수 있을 뿐이다. 또한 현재 코드 구조가 JPA 엔티티 중심이라 네이티브 쿼리로 전환하면 영속성 컨텍스트와 불일치가 생길 수 있어서, 리팩토링 범위가 꽤 커진다.

3. 메시지 큐로 주문 직렬화

주문 요청을 Kafka나 SQS 같은 메시지 큐에 넣고, 컨슈머가 순서대로 처리하는 방식이다. 동시성 문제 자체가 구조적으로 사라진다. 대규모 이커머스에서는 실제로 이런 아키텍처를 쓰는 곳이 많다고 알고 있다.

하지만 이건 "재고 차감"만의 문제가 아니라 주문 처리 아키텍처 전체를 바꿔야 하는 일이다. 현재 프로젝트의 규모와 팀 상황에서 이걸 도입하는 건 과하다고 판단했다.

그래서 왜 DB 비관적 락인가

결국 "현재 상황에서 가장 적은 변경으로, 가장 확실하게 문제를 해결할 수 있는 방법"이 DB 비관적 락이었다고 생각한다.

  • 추가 인프라 없이 JPA + DB만으로 구현 가능
  • 동작 원리가 명확해서 디버깅이 쉬움
  • 현재 트래픽 수준에서는 성능 병목이 되지 않을 것으로 예상

다만 이건 "지금 괜찮다"는 판단이지, "앞으로도 괜찮다"는 보장은 아니다. 트래픽이 늘어나서 락 타임아웃 실패율이 올라가거나, PG 응답 지연으로 인한 대기가 문제가 되는 시점이 오면 Redis 분산 락이나 원자적 UPDATE 방식으로 전환을 검토해야 할 것 같다.

기술 선택에는 "정답"보다 "맥락에 맞는 답"이 있다고 생각한다. 같은 문제라도 서비스 규모, 팀 역량, 인프라 상태에 따라 적절한 해법이 달라진다. 이번에는 DB 비관적 락이 그 "맥락에 맞는 답"이었고, 그 판단이 맞았는지는 운영하면서 검증해나갈 생각이다.


배운 것

  1. -1 고정 차감은 초기에 안 터진다. 대부분의 주문이 1개이므로, 사업이 성장한 후에야 드러나기 쉬운 버그다. 재고 차감 로직에는 qty > 1 테스트를 넣어두는 게 좋겠다는 걸 체감했다.

  2. 비관적 락은 범위를 최소화하는 게 중요하다고 느꼈다. 장바구니처럼 "구매로 이어지지 않을 수 있는" 단계에서 FOR UPDATE를 걸면 불필요한 경합만 늘어난다. 실제로 정합성이 필요한 결제 승인 시점에만 좁게 잠그는 게 맞다고 생각했다.

  3. 데드락 방지는 생각보다 단순하게 풀렸다. 복잡한 락 전략보다, "항상 같은 순서로 잠근다"는 규칙 하나로 구조적으로 데드락을 차단할 수 있었다.

  4. 트랜잭션에 외부 API 호출이 포함되면 타임아웃이 필수라고 느꼈다. 안 그러면 PG 장애가 DB 커넥션 고갈로 번지는 cascade failure가 발생할 수 있다.

  5. Propagation.MANDATORY가 꽤 유용했다. "이 메서드는 반드시 기존 트랜잭션 안에서 호출되어야 한다"는 계약을 코드로 표현할 수 있어서, 실수를 미리 잡아주는 안전장치 역할을 해줬다.

  6. 깨진 테스트를 방치하면 안 된다는 걸 다시 느꼈다. 새 테스트를 추가하기 전에, 기존에 깨진 테스트부터 고치는 게 순서인 것 같다.

이커머스 재고 차감, -1 고정 버그부터 비관적 락까지 — 동시성 제어 기록 | KYUDORI