유랑하는 나그네의 갱생 기록

だけど素敵な明日を願っている -HANABI, Mr.children-

Devlog/SpringBoot

재고는 왜 음수가 됐을까? - 비관적 락, 낙관적 락, 그리고 트랜잭션이 지켜주지 못하는 것들

Madirony 2026. 3. 6. 12:52
반응형

Intro

tl;dr 동시성 문제는 @Transactional 만으로 해결되지 않습니다. 커머스 주문 시스템에서 재고, 쿠폰, 포인트의 동시 접근을 어떻게 제어했는지, 세 가지 동시성 전략의 선택 기준과 판단 과정을 정리해봅시다.

 

Lock

 

트랜잭션이면 과연 안전할까?

주문 API를 구현하고 통합 테스트를 돌렸을 때, 단일 스레드 환경에서는 예측한 대로 완벽하게 동작했습니다. 재고 10개짜리 상품에 10번 주문하면 재고가 0이 되고, 11번째는 정상적으로 예외가 발생했습니다. 하지만 10개의 스레드가 동시에 주문을 요청하는 상황을 테스트하자 문제가 발생했습니다.

 

트랜잭션 A: SELECT stock FROM options WHERE id = 1;  → stock = 10
트랜잭션 B: SELECT stock FROM options WHERE id = 1;  → stock = 10
트랜잭션 A: UPDATE options SET stock = 9 WHERE id = 1;
트랜잭션 B: UPDATE options SET stock = 9 WHERE id = 1;  ← Lost Update!

두 트랜잭션이 같은 시점에 재고를 읽고, 각각 1을 차감한 값을 덮어씁니다. 결과적으로 2건의 주문이 처리되었지만 재고는 1만 줄어드는 Lost Update(갱신 손실) 문제입니다.

 

@Transactional은 논리적 작업 단위의 원자성을 보장할 뿐, 동시에 같은 데이터를 읽고 쓰는 것을 직렬화해주지 않습니다. MySQL의 기본 격리 수준인 REPEATABLE READ 역시 다른 트랜잭션이 같은 row를 동시에 수정하는 것을 막지 못합니다. 결국 애플리케이션 또는 DB 락(Lock) 레벨에서의 명시적인 제어가 필요했습니다.

 

본론

비관적 락 (SELECT FOR UPDATE) - 확정적 직렬화

초기에는 데이터 정합성에 대한 우려로 여러 도메인에 방어적 코딩을 하려 했습니다. 하지만 비즈니스 요구사항을 분석해보니 재고, 쿠폰, 포인트는 충돌 시 단순히 예외를 뱉어내거나 실패 처리하기에는 고객 경험에 미치는 영향이 너무 큰 '핫 리소스'였습니다.

 

충돌이 일어날 것을 전제로 하고, 대기가 발생하더라도 확실하게 줄을 세우는 비관적 락(Pessimistic Lock)을 주력 전략으로 채택했습니다.

 

// OptionJpaRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("SELECT o FROM Option o WHERE o.id = :id AND o.deleted = false")
Optional<Option> findByIdWithLock(@Param("id") Long id);

Spring Data JPA의 @Lock 어노테이션을 통해 SELECT ... FOR UPDATE 쿼리를 발생시켜 해당 row에 배타적 잠금(exclusive lock)을 겁니다. 이때, 예상치 못한 상황으로 락 대기가 길어질 경우 커넥션 풀이 고갈되는 것을 막기 위해 @QueryHints를 통해 락 타임아웃(3초)을 명시적으로 설정했습니다.

 

테스트 결과

startLatch 패턴을 활용하여 10개 스레드를 동시에 출발시키는 동시성 테스트를 진행했습니다.

@Test
@DisplayName("재고 10개, 10 스레드 동시 주문 → 재고 정확히 0")
void concurrentStockDecrease() throws InterruptedException {
    // ... 스레드 풀 및 Latch 설정 생략
    for (int i = 0; i < threadCount; i++) {
        executor.submit(() -> {
            try {
                startLatch.await(); // 모든 스레드가 준비될 때까지 대기
                orderFacade.createOrder(command);
                successCount.incrementAndGet();
            } // ...
        });
    }

    startLatch.countDown(); // 동시 출발
    latch.await(30, TimeUnit.SECONDS);

    Option updatedOption = productAppService.getOptionById(optionId);
    assertThat(successCount.get()).isEqualTo(10);
    assertThat(updatedOption.getStock()).isZero(); // 정확히 0
}

 

비관적 락 적용 후 10개의 동시 주문 요청이 모두 성공하고 재고는 정확히 0으로 수렴했습니다. 쿠폰 발급이나 포인트 동시 결제 시나리오 등에서도 의도한 대로 동작함을 확인했습니다.

 


데드락 방지 - Lock Ordering Protocol

비관적 락의 가장 큰 리스크는 데드락(Deadlock)입니다. 여러 개의 상품을 장바구니에 담아 한 번에 결제하는 시나리오를 상상해 보겠습니다.

트랜잭션 A: Option 1 락 획득 → Option 2 락 대기
트랜잭션 B: Option 2 락 획득 → Option 1 락 대기
→ 교차 대기(Circular Wait) 발생

 

이를 해결하기 위해 시스템 전체에 고정된 락 획득 순서(Lock Ordering) 프로토콜을 도입했습니다.

① Member (포인트) → ② IssuedCoupon → ③ Option (ID 오름차순)

주문 생성 시 트랜잭션은 항상 위 순서대로 자원에 접근합니다. 여러 개의 Option(재고)을 잠글 때는 진입 순서와 무관하게 반드시 Option ID를 오름차순(ASC)으로 정렬하여 순차 획득하도록 강제했습니다.

 

// OrderFacade.java
List<OrderCreateCommand.OrderItemCommand> sortedItems = command.getItems().stream()
        .sorted(Comparator.comparing(OrderCreateCommand.OrderItemCommand::getOptionId))
        .toList();

for (OrderCreateCommand.OrderItemCommand itemCommand : sortedItems) {
    Option option = optionRepository.findByIdWithLock(itemCommand.getOptionId()).orElseThrow(...);
    option.decreaseStock(itemCommand.getQuantity());
}

이 규칙은 주문 취소 로직에도 동일하게 적용됩니다. 모든 트랜잭션이 같은 방향으로만 락을 획득하도록 설계함으로써 데드락 발생 가능성을 원천 차단했습니다.

 


좋아요는 왜 락을 걸지 않았나 - @Modifying 원자적 쿼리

모든 동시 접근에 비관적 락을 거는 것은 안전하지만, '상품 좋아요(like)' 기능에는 적합하지 않다고 판단했습니다. 특정 상품에 수십 명의 유저가 동시에 좋아요를 누를 때마다 엔티티 락을 걸고 대기시킨다면 리소스 낭비가 큽니다. 좋아요는 현재 상태를 읽고 복잡하게 가공하는 연산이 아니라, 단순히 카운터를 증감하는 단일 연산입니다.

 

따라서 락 대신 DB가 자체적으로 지원하는 원자적 연산을 활용했습니다.

// ProductJpaRepository.java
@Modifying
@Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id AND p.deleted = false")
int increaseLikeCount(@Param("id") Long id);

영속성 컨텍스트로 엔티티를 끌어와서 수정하는 방식을 포기하고, UPDATE 쿼리 한 줄로 DB에 처리를 위임했습니다. 락 대기 시간을 극단적으로 줄이면서도 충돌에 안전한 구조를 만들었습니다.

 


낙관적 락이라는 대안 - 채택하지 않은 이유

JPA의 @Version 어노테이션을 활용하는 낙관적 락(Optimistic Lock)은 훌륭한 대안입니다. 하지만 이번 주문 시나리오에서는 채택하지 않았습니다.

 

1. 복합 연산에서의 재시도 비용 주문 생성은 재고 차감, 쿠폰 상태 변경, 포인트 차감 등 여러 엔티티를 동시에 수정합니다. 만약 트랜잭션 마지막에 버전 충돌(OptimisticLockException)이 발생한다면, 이 모든 조회와 연산 로직을 처음부터 다시 재시도해야 합니다.

 

2. 충돌 빈도에 비례하는 악순환 선착순 쿠폰 발급처럼 동시 접근이 몰리는 대표적인 핫스팟 리소스에 낙관적 락을 쓰면, 대부분의 요청이 예외를 발생시키고 재시도가 재시도를 낳으며 시스템에 과부하를 줄 수 있습니다.

 

결론적으로 현재 구현된 시나리오들이 "높은 충돌 빈도 + 복합 연산"의 특성을 가졌기에 비관적 락이 합리적인 선택이었습니다.

 


트랜잭션 롤백이 안 되는 경우에 대한 고려

Spring의 @Transactional은 기본적으로 unchecked exception(RuntimeException)에서만 롤백됩니다. 이 프로젝트에서는 모든 비즈니스 예외를 RuntimeException을 상속한 CoreException으로 일원화하여, 재고 부족 등으로 실패 시 전체 트랜잭션이 안전하게 롤백되도록 구성했습니다.

 

다만, 트랜잭션 내부에 외부 호출(이메일 발송, 외부 API 연동 등)이 존재할 경우, DB가 롤백되더라도 이미 실행된 외부 호출은 되돌릴 수 없습니다. 현재는 트랜잭션 내부에 외부 시스템 호출이 없지만, 추후 알림 시스템 등이 추가된다면 @TransactionalEventListener(phase = AFTER_COMMIT)나 아웃박스(Outbox) 패턴 도입을 고려해야 할 것입니다.

 


 

향후 고려 사항

현재 아키텍처에서 일정과 복잡도를 이유로 타협한 부분들은 다음 리팩토링 과제로 남겨두었습니다.

  • 포인트(Point) 도메인 분리: 현재 포인트는 Member 엔티티의 필드입니다. 결제 시 포인트 차감을 위해 Member 테이블 전체에 락을 거는 것은 포인트와 무관한 회원 정보 수정 로직까지 블로킹하는 병목 지점입니다. 추후 원장(Ledger) 테이블 형태로 물리적 분리를 진행해 락 범위를 최소화해야 할 것 같습니다.
  • 분산 환경에서의 락: 현재는 단일 DB 인스턴스라 SELECT FOR UPDATE로 충분하지만, 다중 DB나 MSA로 확장된다면 Redis 기반의 분산 락(Redisson)이나 메시지 큐(MQ) 도입이 필요합니다.
  • 낙관적 락 재시도 전략: 회원 프로필 수정처럼 충돌 빈도가 낮고 연산이 단순한 도메인이 추가된다면, 낙관적 락과 Spring Retry 등을 결합한 전략을 적용해 볼 수 있습니다.

 

동시성 제어에 일괄적인 정답은 없습니다. 도메인의 충돌 빈도와 트랜잭션 복잡도를 분석하고, 그에 맞는 전략을 선택해 테스트로 증명해 내는 과정이 중요하다고 생각합니다.


P.S.

대규모 트래픽을 다루는 실무에서는 비관적 락을 거의 사용하지 않는다는 이야기를 들었던 것 같습니다.

돌이켜보면, 좋아요 카운트뿐만 아니라 재고나 포인트 차감 역시 명시적인 DB 락 없이 원자적 쿼리(UPDATE ... SET stock = stock - 1 WHERE id = ? AND stock >= 1)만으로 해결할 수 있지 않았을까 하는 생각이 듭니다.

 

물론 여러 도메인이 얽힌 복합 연산이긴 했지만, 데이터 무결성을 핑계로 비관적 락에 너무 쉽게 의존한 것은 아닌지 아쉬움이 남습니다. 다음에는 아예 락(Lock) 자체를 배제하는 아키텍처나 이벤트 기반 처리에 대해 더 깊게 고민해 볼 생각입니다.

반응형
TOP