Sale Commerce 결제 실패 재고 복구

임선구·2026년 8월 28일

스파르타

목록 보기
27/28

🔄 결제 실패 재고 복구도 동시성 문제였다 — 재고 8 → 9 → 10 검증하기

동시성 문제라고 하면 보통 여러 사용자가 동시에 상품을 구매하면서 재고를 감소시키는 상황을 먼저 떠올린다.

나 역시 처음에는 선착순 구매 과정만 안전하게 만들면 된다고 생각했다.

하지만 특가상품 구매 이후 결제에 실패하면 차감했던 재고를 다시 증가시켜야 한다.

이 재고 복구 과정에서도 동시성 문제가 발생할 수 있었다.

이번 글에서는 결제 실패 처리 과정에서

  • 동일 주문의 중복 재고 복구
  • 서로 다른 주문의 동시 재고 복구
  • Lost Update

문제를 어떻게 처리하고 테스트했는지 정리한다.


🔥 문제 1. 같은 주문이 두 번 실패 처리된다면?

특가상품 재고가 10개 있다고 생각해보자.

사용자가 상품 하나를 구매한다.

재고 10
 ↓
주문 생성
 ↓
재고 9

이후 결제에 실패하면 차감했던 재고를 다시 복구해야 한다.

9 → 10

그런데 동일 주문에 대한 결제 실패 요청이 두 번 들어온다면 어떻게 될까?

아무런 검증이 없다면

첫 번째 실패 요청
9 → 10

두 번째 실패 요청
10 → 11 ❌

실제 초기 재고보다 더 많은 재고가 생길 수 있다.

따라서 동일한 주문의 결제 실패가 이미 처리됐는지 확인하고 한 번만 재고가 복구되도록 해야 했다.


🔥 문제 2. 서로 다른 주문이 동시에 실패한다면?

이번에는 서로 다른 두 주문이 같은 특가상품을 하나씩 구매했다고 생각해보자.

초기 재고 : 10

주문 A 구매
10 → 9

주문 B 구매
9 → 8

이후 두 주문의 결제가 거의 동시에 실패한다.

최종적으로는 두 재고가 모두 돌아와야 하기 때문에

8 → 9 → 10

이 되어야 한다.

하지만 두 Thread가 동시에 현재 재고를 8로 읽는다면 다음과 같은 문제가 생길 수 있다.

Request A
현재 재고 8 읽음
→ 9 저장

Request B
현재 재고 8 읽음
→ 9 저장

두 번의 복구가 발생했지만 최종 재고는 9가 된다.

한 번의 업데이트가 사라지는 Lost Update다.


✅ 같은 주문의 중복 실패는 주문 Lock으로 제어

먼저 동일한 주문이 두 번 실패 처리되는 것을 막아야 했다.

결제 실패 처리 시 주문 Row Lock을 획득하고 현재 주문과 결제 상태를 확인했다.

이미 실패 처리가 완료된 주문이라면 다시 실패 처리하지 않는다.

결제 실패 요청
 ↓
주문 Row Lock
 ↓
이미 처리된 주문인가?
 ↓
YES → 중복 처리 차단

NO
 ↓
결제 실패 처리 진행

이렇게 동일한 주문이 여러 번 처리되면서 재고가 반복적으로 복구되는 것을 방지했다.


✅ 서로 다른 주문의 재고 경쟁은 상품 Lock으로 제어

하지만 주문 Row Lock만으로는 두 번째 문제가 해결되지 않는다.

주문 A와 주문 B는 서로 다른 주문이기 때문이다.

주문 A Lock
주문 B Lock

→ 서로 다른 Row이므로 동시에 획득 가능

하지만 두 주문이 변경하려는 특가상품 재고는 동일하다.

그래서 실제 재고를 복구할 때는 특가상품 Row에도 Lock을 적용했다.

Order A Lock
       \
        → 동일 Promotion Product Lock
       /
Order B Lock

같은 상품의 재고를 수정하는 작업을 순서대로 처리해 Lost Update를 방지했다.


✅ 결제 실패와 재고 복구 이력을 하나의 트랜잭션으로

결제 실패 시에는 단순히 재고만 증가시키는 것이 아니다.

다음 작업들이 함께 수행된다.

실패 Payment 저장

+

재고 복구

+

StockHistory 복구 이력 저장

만약

Payment 저장 성공
재고 복구 성공
StockHistory 저장 실패

처럼 일부만 DB에 반영된다면 이후 재고가 왜 변경됐는지 추적하기 어려워진다.

그래서 세 가지 작업을 하나의 트랜잭션으로 묶었다.

BEGIN

실패 Payment 저장

재고 +1

재고 복구 StockHistory 저장

COMMIT

중간에 하나라도 실패하면 전체 DB 변경을 Rollback하도록 구성했다.


🧪 실제 동시성 테스트

테스트 조건은 다음과 같이 구성했다.

초기 이벤트 재고 : 10개

주문 A 구매 이후 : 9개

주문 B 구매 이후 : 8개

동시에 처리할 결제 실패 요청 : 2건

두 결제 실패 요청을 동시에 실행했다.

정상적인 결과는 다음과 같아야 한다.

8 → 9 → 10

테스트 결과 최종 재고는 정확히 10개로 복구됐다.

추가로 다음 데이터도 함께 확인했다.

FAILED Payment
→ 2건

StockHistory RESTORE
→ 2건

재고 이력에도

8 → 9
9 → 10

순서로 정확하게 기록되는 것을 확인했다.


💡 이번 구현에서 배운 점

재고 동시성은 재고를 줄이는 순간에만 발생하는 문제가 아니었다.

같은 재고 값은 여러 기능에서 변경된다.

구매 성공
→ 재고 감소

결제 실패
→ 재고 증가

환불
→ 재고 증가

선착순 구매에서 재고 감소만 안전하게 만든다고 해서 전체 재고 정합성이 보장되는 것이 아니었다.

이번 경험을 통해 공유 데이터를 변경하는 기능을 구현할 때는

"이 데이터를 변경하는 다른 기능은 없는가?"

를 함께 확인해야 한다는 기준이 생겼다.

하나의 API만 정상적으로 동작하는지 보는 것이 아니라 같은 데이터를 변경하는 여러 기능이 동시에 실행됐을 때도 결과가 일관적인지 확인하는 것이 중요하다는 것을 배웠다.

profile
끝까지 가면 내가 다 이겨

0개의 댓글