Sale Commerce에서 선착순 특가상품 구매 기능을 구현하면서 가장 먼저 고려해야 했던 문제는 동시성이었다.
재고가 5개뿐인데 20명의 사용자가 거의 동시에 구매 버튼을 누르면 어떻게 될까?
단순히
재고 조회
→ 재고가 0보다 크면
→ 재고 차감
으로 구현하면 실제로 가지고 있는 재고보다 더 많은 주문이 성공할 가능성이 있다.
이번 글에서는 Redisson 분산 락과 DB Lock을 이용해 특가상품의 재고 정합성을 제어한 과정을 정리한다.
현재 재고가 1개라고 생각해보자.
두 개의 요청이 동시에 들어온다.
Request A → 재고 조회 → 1
Request B → 재고 조회 → 1
Request A → 구매 가능
Request B → 구매 가능
Request A → 재고 차감
Request B → 재고 차감
두 요청 모두 자신이 데이터를 읽었던 시점에는 재고가 존재한다고 판단했다.
실제로 판매할 수 있는 상품은 하나였지만 두 주문이 모두 성공할 수 있는 상황이다.
선착순 판매에서는 바로 초과 판매로 이어질 수 있다.
애플리케이션 서버가 하나라면 Java의 synchronized를 이용해 한 번에 하나의 Thread만 구매 로직에 진입하도록 만들 수도 있다.
하지만 애플리케이션 서버가 여러 개라면 문제가 달라진다.
Server A
→ synchronized Lock A
Server B
→ synchronized Lock B
각 서버는 서로 다른 JVM에서 실행되기 때문에 Server A가 Lock을 획득해도 Server B는 그 사실을 알지 못한다.
여러 애플리케이션 인스턴스에서도 동일한 Lock을 공유할 수 있도록 Redis 기반 Redisson 분산 락을 사용했다.
모든 특가상품 구매 요청을 하나의 Lock으로 막으면 문제가 생긴다.
예를 들어 상품 A가 구매 처리 중이라고 해서 전혀 관계없는 상품 B의 구매 요청까지 기다릴 필요는 없다.
그래서 Lock을 특가상품 ID 단위로 구분했다.
개념적으로는 다음과 같은 형태다.
promotion-product:{상품 ID}
같은 상품을 구매하는 요청끼리만 동일한 Lock을 두고 경쟁한다.
덕분에 서로 다른 상품의 구매는 동시에 처리할 수 있다.
분산 락을 요청하기 전에 조회한 상품 데이터를 Lock 획득 이후에도 그대로 사용하면 안 된다.
Lock을 기다리는 동안 다른 요청이 먼저 재고를 변경했을 수 있기 때문이다.
그래서 구매 흐름을 다음과 같이 구성했다.
구매 대상 상품 확인
↓
상품 단위 분산 Lock 획득
↓
상품 최신 상태 재조회
↓
현재 재고 확인
↓
구매 처리
↓
Lock 해제
Lock을 획득한 이후 최신 데이터를 다시 조회함으로써 오래된 재고 값을 기준으로 구매가 처리되지 않도록 했다.
분산 락은 여러 애플리케이션 인스턴스가 동일한 임계 영역에 동시에 진입하는 것을 제어한다.
하지만 최종 재고 데이터가 저장되는 곳은 MySQL이다.
그래서 실제 재고를 수정하는 과정에서는 DB의 비관적 쓰기 Lock도 함께 적용해 DB 갱신 과정의 정합성을 보강했다.
또 구매 과정에서만 Lock을 적용하는 것으로 끝내지 않았다.
같은 재고는 다음과 같은 상황에서도 변경된다.
구매 성공
→ 재고 감소
결제 실패
→ 재고 복구
환불
→ 재고 복구
재고를 감소시키는 로직에는 Lock을 적용하고 증가시키는 경로에서는 아무런 제어를 하지 않는다면 다른 동시성 문제가 생길 수 있다.
그래서 구매·결제 실패·환불 과정에서도 동일한 상품 Lock 기준을 사용하도록 했다.
동시성 테스트 조건은 다음과 같이 구성했다.
초기 이벤트 재고 : 5개
동시 구매 요청 : 20건
요청당 구매 수량 : 1개
단순히 성공 요청 개수만 확인한 것은 아니다.
다음 조건들을 함께 검증했다.
성공 주문 수 <= 초기 재고
남은 재고 >= 0
성공 주문 수 + 남은 재고 = 5
생성된 주문 수
= 실제 성공한 구매 수
생성된 주문상품 수
= 실제 성공한 구매 수
생성된 재고이력 수
= 실제 성공한 구매 수
테스트 결과 초과 판매와 음수 재고가 발생하지 않는 것을 확인했다.
동시성 문제는 코드를 순서대로 읽을 때는 잘 보이지 않는다.
재고 확인
→ 재고 차감
이라는 로직 하나만 보면 아무런 문제가 없어 보인다.
하지만 여러 요청이 동시에 실행되는 순간 전혀 다른 문제가 된다.
그래서 이번 기능에서는 구현 자체보다
"실제로 여러 요청이 같은 순간 들어오면 어떤 결과가 만들어질까?"
를 테스트로 재현하는 것을 중요하게 생각했다.
또 Lock을 적용할 때 단순히
"여기에 Lock을 붙이면 되겠지."
라고 생각하는 것이 아니라
무엇을 Lock Key로 사용할 것인지
언제 Lock을 획득할 것인지
Lock 획득 후 어떤 데이터를 다시 읽어야 하는지
어디까지를 임계 영역으로 설정할 것인지
재고 복구 경로에도 같은 기준을 적용해야 하는지
까지 함께 설계해야 한다는 것을 배웠다.