[Sansam E-commerce] 4. MVP 이후 리팩토링 #2 : 재고 관리는 어떻게 할 것인가?
- -
👀 들어가기 전에
이 프로젝트에서 처음 맡았던 R&R은 주문과 결제 도메인이었다.
다만 MVP 이후 재고 테이블을 별도의 도메인으로 분리하는 리팩토링이 진행되면서, 자연스럽게 재고 도메인까지 함께 맡아 개발을 이어가게 되었다. 사실 재고는 개인적으로도 꼭 한 번 제대로 다뤄보고 싶었던 영역이었다. 주문 / 결제 / 재고는 결국 하나의 트랜잭션 흐름으로 엮이고, 특히 재고는 동시성 문제를 정면으로 마주하게 되는 도메인이기 때문이었다.
그래서 재고 관리 로직을 어떤 기준으로 설계하는 것이 맞는지부터 다시 고민하게 되었다.
MVP 단계에서는 낙관락 기반으로 재고 차감으로 구현되어 있었는데, 아무리 봐도 재고 도메인에서는 실제 케이스가 지나치게 많아질 수밖에 없는 구조라는 생각이 들었다.
이 시점부터 "재고를 어떻게 관리하는게 맞을까?" 에 대한 고민이 본격적으로 시작되었다.
👀 본론
낙관락의 한계와 새로운 접근
낙관락은 충돌이 적은 환경에서는 충분히 좋은 선택이다.
하지만 재고 도메인은 전제가 다르다고 생각했다.
- 동일 상품에 대한 요청이 동시에 몰릴 수 있다.
- 혹여나 상품이 Oversell 될 경우, 아래와 같은 상황이 발생할 수 있다.

상품 재고가 아예 동나버린 경우 사용자는 구매에 분명히 성공했는데 판매자의 입장에서는 재고가 없으니 주문 자체를 취소 시켜버리는 것이다. 이는 사용자의 경험과도 밀접하게 관련이 되어있고 사용자의 경험이 곧 회사의 손실로 다가올 것이라 판단하였다.
즉, 충돌이 날 때마다 재시도를 전제로 하는 낙관 락은 오히려 사용자를 잃을 수 있는 선택이 될 것이라고 판단하였다.
그래서 고민의 방향은 자연스럽게 아래와 같이 바뀌었다.
낙관락보다 조금은 느려질 수 있지만, 안정적인 결제를 위해 Oversell 자체를 DB레벨에서 원천 차단할 수는 없을까?
근데 또 많이 느리면 안되고 어느정도의 속도가 보장되면서 정합성이 동시에 보장되어야한다.
그럼 재고 차감의 시점은 언제가 맞을까?
재고 차감 시점에 대한 고민
재고 차감 시점에 대해서는 크게 두 가지 선택지가 존재했다.
- 주문이 확정되는 시점에 재고를 차감 (선차감 방식)
- 결제가 완료된 이후에 재고를 차감 (후차감 방식)

위 주문 ~ 결제 흐름에서 보면, 재고 차감 시점은 결국 두 지점 중 하나를 선택하게 된다.
1. 주문 버튼 클릭 → 결제 요청 전 재고 차감
2. 결제 완료 → 재고 차감
각 방식에 대해 동시성 관점과 사용자 경험 관점에서 문제를 하나씩 짚어보겠다.
후차감 방식 (결제 완료 후 재고 차감)
후차감 방식의 큰 장점은 명확하다.
- 결제가 실제로 완료된 주문에 대해서만 재고가 차감된다
- 잘못된 재고 차감이 발생할 가능성이 없다.
즉, 데이터 정합성만 놓고 보면 가장 깔끔한 방식이다.
하지만 동시성 상황을 고려하면 문제가 발생한다.
- 사용자는 정상적으로 결제를 진행한다
- 결제는 PG사를 거쳐 모두 완료된다
- 그 이후에야 "이미 재고가 소진되었다"는 사실을 알게 된다
결과적으로 사용자는 결제가 완료된 뒤에 주문이 취소되는 경험을 하게 된다.
이 상황은 사용자 입장에서 왜 결제가 됐는데 주문이 취소됐는지 이해하기 어렵고 서비스에 대한 신뢰도에 부정적인 영향을 준다고 판단한다.
선차감 방식 (주문 확정 시 재고 차감)
선차감 방식은 주문이 확정되는 시점에 재고를 차감한다.
이 방식의 장점은 다음과 같다.
- 결제가 완료된 이후 재고 부족으로 주문이 취소되는 상황이 발생하지 않는다
- 사용자 입장에서 "결제 완료 = 주문 성공"이라는 일관된 경험을 제공할 수 있다.
다만, 단점도 분명하다.
- 사용자가 주문 화면까지 들어왔다가 결제를 하지 않고 이탈하는 경우, 재고가 일시적으로 맞지 않게 된다.
즉, 결제 실패 또는 이탈 시 재고 복구 로직이 필요하다.
선택 : 사용자 경험을 기준으로 한 선차감 방식
두 방식을 비교했을 때, 사용자 경험 측면에서는 선차감 방식이 더 적절하다고 판단했다.
- 결제까지 완료했는데 주문이 취소되는 경험은 사용자 입장에서 치명적일 수 있다.
- 반대로, 결제하지 않고 이탈한 주문의 재고는 일정 시간 이후 복구하는 방식으로 충분히 보완 가능하다고 보았다.
결국 재고 관리에서 가장 중요한 것은
사용자가 겪는 실패의 순간을 어떻게 다룰 것인가
였고, 그 실패를 결제 이후가 아닌, 결제 이전으로 가져오는 것이 서비스의 신뢰도를 지키는 선택이라고 판단했다.
이후 재고 차감 방식에 대한 고민이 시작되었다.
InnoDB Next-Key Lock 기반 단일 UPDATE
위 요구사항을 만족하기 위해 선택한 방식은 다음과 같다.
- 단일 UPDATE 쿼리
- MySQL InnoDB의 Next-Key Lock (Record Lock + Gap Lock)을 활용한 동시성 제어
UPDATE Stock s
SET s.stockQuantity = s.stockQuantity - :qty
WHERE s.productDetailsId = :detailId
AND s.stockQuantity >= :qty
위 쿼리로 재고의 차감 / 업데이트를 진행하게 될 경우 아래와 같이 진행된다.
동시성 제어
동일 상품에 대해 여러 트랜잭션이 동시에 UPDATE를 시도할 경우

- 선행 트랜잭션이 row-level lock을 획득
- 후행 트ㄲ랜잭션은 락이 해제될 때까지 blocking
- quantity >= :qty 조건과 차감 로직을 하나의 SQL로 묶음으로써 Lost Update 방지, Oversell을 DB레벨에서 원천 차단
즉, 어플리케이션 레벨에서 별도의 락, 재시도 로직, 복잡한 분기처리 없이 DB엔진이 트랜잭션 단위로 정합성을 보장하도록 설계하였다.
약간의 지연이 발생하더라도 재시도와 예외가 계속 발생하는 구조보다는 DB 내부에서 순차적으로 처리되는 편이 훨씬 안정적이라고 판단하였다.
(추가 고민) DB는 Update로직을 어떤 락으로 어떤 범위까지 보호할까??
앞서 재고 차감 로직에서 단일 UPDATE + InnoDB의 락 메커니즘에 정합성을 위임했다고 설명했다.
여기서 중요한 질문은 이것이다.
DB는 이 UPDATE를 어떤 락으로 어떤 범위까지 보호하고 있을까?
(앞에서 이미 정답이 나왔지만 조금 더 깊이 있게 들어가보겠다.)
이를 이해하기 위해서는 InnoDB의 세 가지 락 개념을 정확히 구분할 필요가 있다.
InnoDB의 기본 전제
InnoDB는 행 기반 스토리지 엔진이며 동시성 제어의 기본 단위는 row-level lock이다.
하지만 InnoDB의 락은 행 하나만을 잠그지 않는다. 정합성을 보장하기 위해 범위를 함께 잠그는 전략을 사용한다.
이때 등장하는 개념이 다음 세가지다.
- Record Lock
- Gap Lock
- Next-Key Lock
1. Record Lock

Record Lock은 말 그대로 인덱스 레코드 하나에 대한 락이다.
- 이미 존재하는 row에 대해 걸린다.
- 다른 트랜잭션은 이 row를 UPDATE/DELETE할 수 없다.
- SELECT FOR UPDATE, UPDATE시 기본적으로 사용된다.
즉, "이 상품의 현재 재고 row는 내가 쓰고 있으니 너 기다려." 인 것이다.
2. Gap Lock

Gap Lock은 조금 생소하지만 InnoDB 정합성에서 주요하게 다뤄진다.
Gap Lock은 존재하지 않는 레코드의 간격을 잠근다.
예를 들어 인덱스 상에 다음 값이 있다고 하자.
product_id : 10, 20, 30
이때 Gap은 다음과 같다.
(-∞, 10), (10, 20), (20, 30), (30, +∞)
Gap Lock이 걸리면 그 구간에 새로운 row INSERT가 차단되며 이미 존재하는 row 자체는 건드리지 않는다.
즉, "이 범위 안에는 새로운 데이터가 끼어들 수 없다"를 보장한다.
3. Next-Key Lock : Record Lock + Gap Lock

Next-Key Lock은 InnoDB에서 가장 중요한 락이며 Record Lock + Gap Lock의 조합이다.
- 현재 row는 Record Lock으로 보호하고 그 앞/뒤 범위는 Gap Lock으로 보호한다.
그럼 이즈음 되면 궁금해지는 것이 생긴다.
왜 이런 락이 필요할까?
Phantom Read에 대한 방지 때문이다.
3-1. Phantom Read란?
Phantom Read는 트랜잭션 격리 수준과 관련된 문제로 하나의 트랜잭션 안에서 같은 조건의 조회를 두 번 수행했을때, 결과적으로 행의 개수가 달라지는 현상을 의미한다. (3학년때 컴공과 전공 신청해서 들었던 DB과목이 생각났다...)
즉, "처음에는 없던 데이터가 같은 트랜잭션 안에서 갑자기 생겨나는 것 처럼 보이는 현상이다.
중요한 점은, 이 데이터는 내 트랜잭션이 만든 것이 아니라 다른 트랜잭션이 중간에 INSERT한 데이터라는 점이다.
예를 들면 아래와 같은 경우가 있다.
하나의 상품에 초기 재고가 5개라고 해보자

위 그림처럼 사용자 A가 주문 버튼을 누른다.
이 시점에서 A의 트랜잭션 내부적으로 현재 재고는 5개이고, 3개를 차감해도 조건을 만족하니 주문이 가능하다.
사용자 A의 화면에서는 주문이 가능하며 결제 페이지로 정상 이동된다.
사용자 B도 동일한 시점에 주문 버튼을 누른다.
B 역시 같은 조건으로 재고를 확인하고, 마찬가지로 "주문 가능"이라는 판단을 하게 된다.
이 상황에서 Phantom Read가 허용되는 환경이라면
- 트랜잭션 A가 조건을 만족한다고 판단한 이후
- 트랜잭션 B가 중간에 끼어들고 재고 상태를 변경하거나
- 동일 조건을 만족하는 row의 상태가 트랜잭션 A 기준으로 달라질 수 있다.
즉, 트랜잭션 A의 입장에서는 분명 조건을 만족해서 주문 성공 시켰더니 끝나고 나니 재고가 안 맞는 상태인 것이다.
결제는 완료됐는데 주문이 실패 처리 되게 되고 환불처리까지 이어지게 되는 환경이다.
재고와 결제 도메인에서는 한번의 실패 경험이 곧바로 이탈로 이어질 수도 있으며 사용자가 다시 돌아오지 않는 경우도 많다.
3-2. 그래서?
그래서 InnoDB는 이런 문제를 방지하기 위해 존재하는 row만 잠그는 방식을 사용하지 않는다.
대신, 조회된 row뿐만 아니라 해당 row가 속한 범위까지 함께 잠그는 Next-Key Lock 전략을 사용한다.
내가 쓴 쿼리문 기준으로 보면 다음과 같다.
UPDATE Stock s
SET s.stockQuantity = s.stockQuantity - :qty
WHERE s.productDetailsId = :detailId
AND s.stockQuantity >= :qty
이 쿼리는 이 row 하나를 업데이트한다가 아니라, productDetailsId가 들어온 detailId와 같으며, stockQuantity가 들어온 quantity를 만족하는 재고가 존재하는 범위를 락걸고 업데이트한다.
InnoDB는 이 조건을 기준으로:
- 현재 조건을 만족하는 row에 대해 Record Lock을 획득하고
- 동시에 stockQuantity >= :qty 조건이 의미하는 범위 자체에 Gap Lock을 적용한다
즉, Where 절의 조건을 만족하는 범위 전체를 하나의 보호 영역(?)으로 만들어 다른 트랜잭션이 끼어들지 못하게 막는 것이다.
락의 범위는 특정 productDetailsId에 한정되며 다른 상품의 재고 차감에는 영향이 없고 불필요하게 넓은 범위의 락을 잡지 않는다.
이렇게 하면 어느정도 속도도 보장하며 재고 차감의 정합성도 보장할 수 있을 것이라 생각했다.
4. 동시성 검증 테스트

설계가 의도대로 동작하는지 확인하기 위해 동일 상품 재고에 대해 다수의 스레드가 동시에 차감을 시도하는 동시성 테스트 코드를 작성해 검증해보았다.
- 여러 스레드가 동시에 decreaseStock() 호출
- 전체 요청 수 대비 (성공한 차감수 + 실패한 요청 수)가 항상 일치
- 재고 수량이 음수가 되거나 Oversell이 발생하는 케이스는 발생하지 않는다.
이를 통해 Next-Key Lock 기반 단일 Update 쿼리가 다중 요청 상황에서도 재고 정합성을 안정적으로 보장한다는 사실을 확인할 수 있었다.
👀 결론
겉보기에는 가벼운 프로젝트처럼 보일 수 있지만, 우리가 만드는 서비스는 결국 사용자의 경험 위에서 평가된다.
사용자가 서비스를 계속 사용하는 기준은 단순하다.
이 서비스가 나를 얼마나 불편하게 만들지 않는가.
나를 얼마나 편하게 하는가
그리고 이 기준에서 개발 방식과 설계는 결코 빠질 수 없는 요소다.
재고 차감 시점을 어디로 잡을지, 동시성을 어디에서 제어할지, 실패를 언제 사용자에게 노출할지에 따라 사용자가 겪는 경험이 완전히 달라진다. 이번 재고 도메인 설계를 통해 느낀 점은 명확했다.
- 기술적인 선택은 곧 사용자 경험으로 이어지고
- 사용자 경험은 서비스의 신뢰로 이어지며
- 그 신뢰는 결국 비즈니스 성과로 돌아온다.
앞으로의 개발에서는 설계하나를 하더라도 사용자의 경험을 고려한 설계를 해나가야겠다.
'Project' 카테고리의 다른 글
| [Sansam E-commerce] 6. MVP 이후 리팩토링 #4 : Order 트랜잭션 분리 & DB Indexing을 통한 최적화 & 테스트 툴 변경 (0) | 2025.12.26 |
|---|---|
| [Sansam E-commerce] 5. MVP 이후 리팩토링 #3 : 테스트 코드 작성과 Outbox 패턴 구현기 (0) | 2025.12.24 |
| [Sansam E-commerce] 3. MVP 이후 리팩토링 #1 : 재고 도메인 ERD 분리 과정 (0) | 2025.12.22 |
| [Sansam E-commerce] 2. ERD 설계 : 확장성 있는 설계 (0) | 2025.12.22 |
| [Sansam E-commerce] 1. 프로젝트 개요 (0) | 2025.12.22 |
당신이 좋아할만한 콘텐츠
소중한 공감 감사합니다