블랙 프라이데이 한정 수량 판매에서 초과 결제가 발생했던 이유와 해결 과정
블랙 프라이데이 기간에 한정 수량으로 진행한 전기 자전거 판매 이벤트에서 초과 결제가 발생하면서 이슈가 있었고, 이를 어떻게 해결했는지 정리해본다. 단순 트래픽 폭주 정도가 아니라, 분산 환경에서의 재고 관리 방식 자체를 다시 설계해야 했던 케이스다.
문제 상황
- 전기 자전거 180대 한정 판매
- 블프 시간대에 재고 이상 고객 동시 접속
- 실제 확보한 재고보다 N건 이상 초과 결제 발생
- 초과 결제된 사용자에게 환불을 해야 했고, 불만도 상당히 발생
문제 원인
1) 재고 차감 위치가 결제 트랜잭션 이후에 있었다
기존 로직 흐름은 다음과 같았다.
- 사용자가 결제 페이지로 이동
- 결제 요청 → 외부 PG 결제 승인
- 결제 성공 후 재고 차감
문제는 단계 2번에서 동시에 수백 건이 승인될 수 있다는 점이었다.
PG 승인까지 성공해버리면 로컬 재고가 0이어도 결제는 이미 완료된 상태가 된다.
문제 정의와 해결의 실패
트래픽이 서버가 받을 수 있는 한계를 넘었다고 오인하여 무식하게 오토스케일링으로 인스턴스 수를 2배까지 늘렸지만, 각각의 서버는 재고 상태를 공유하지 않기 때문에 여전히 중복 결제가 가능했다.
결제 중복 검증 로직이 존재하지 않았다
결제 요청 자체는 멱등하지 않았다. 즉, 같은 재고에 대해 서로 다른 사용자가 동시에 "나 먼저"를 주장해도 이를 제어할 장치가 없었다.
해결 목표
- 재고 이상의 결제 요청을 근본적으로 차단할 것
- 분산 환경에서도 정확한 재고를 보장할 것
- 사용자의 결제 실패 경험을 최소화할 것
1차 시도 - 세마포어 적용
동시 접근 수를 제어하기 위해 세마포어를 도입했다.
- 세마포어 허용 수 = 판매 재고 180대
- 사용자가 결제 페이지로 진입할 때 세마포어 획득
- 재고가 소진되면 결제 페이지 진입 자체를 차단

개발계에서 테스트가 성공적이었다.
하지만 바보같은 실수가 있었다..
세마포어는 인스턴스 단위 락이라 분산 환경에서는 재고 동기화가 불가능하다는 치명적 문제가 있었다.
2차 시도 - 비관적락 적용
DB 차원에서 비관적 락을 적용해 재고 업데이트를 단건 단위로 직렬화했다.

문제는 확실히 막을 수는 있지만:
- 동시에 수백 명이 락을 기다리며 대기
- PG 결제는 완료됐는데 재고 업데이트까지 대기
- 전체 트랜잭션 latency증가
결론적으로 사용자 경험을 해치기 때문에 재고 선점 방식으로 전환해야 했다.
3차 시도 - 분산락 + 재고 선점 프로세스 설계
기존 방식은 "결제 성공 → 재고 차감" 흐름이었지만,
우리는 이를 “재고 선점 → 결제 진행” 구조로 완전히 바꿨다.
선점 프로세스
1. 사용자 구매 요청이 들어옴 -> 락 획득
2. 재고 확인 -> 재고 감소
3. PurchaseHistory(PROCESSING) 상태로 저장
4. 락 해제 -> 이후 결제 시도
@Transactional
public PurchaseHistory processPurchase(Product product, User user, LocalDateTime now) {
// 재고 락 획득
String stockLockKey = redisLockManager.createStockLockKey(product.getId());
boolean stockLockAcquired = redisLockManager.acquireStockLock(stockLockKey);
if (!stockLockAcquired) {
throw new IllegalStateException("재고 락 획득 실패 : productId=" + product.getId());
}
try {
// 재고 확인 및 감소
int updated = productStockRepository.decreaseStock(product.getId());
if (updated == 0) {
throw new IllegalStateException("재고가 소진되었습니다.");
}
log.info("전기 자전거 재고 점유 : userId={}, productId={}, price={}", user.getId(), product.getId(), product.getPrice());
PurchaseHistory history = PurchaseHistory.builder()
.product(product)
.user(user)
.price(0)
.purchasedAt(now)
.status(PurchaseHistory.Status.PROCESSING)
.requestId(null)
.build();
purchaseHistoryRepository.save(history);
return history;
} finally {
// 재고 락 해제
redisLockManager.releaseLock(stockLockKey);
}
}
추가된 프로세스
결제 단계에서의 멱등성 검증로직 추가
- PurchaseHistory 상태가 PROCESSING인지 확인
- PG 결제 실패 시 → 상태를 PAYMENT_FAILED로 변경 후 재시도 이벤트 발행
- PG 결제 성공 시 → PAID로 변경
동시성 테스트 – 여러 사용자가 동시에 구매할 때 재고만큼만 성공하는가?
블랙프라이데이 문제의 핵심은 결국 “동시에 몇 명이 들어와도 재고 수량 이상으로 결제가 발생하면 안 된다”는 보장이다.
이걸 검증하기 위해 단위 테스트 수준에서 3000명 사용자가 동시에 상품 구매를 요청했을 때, 재고 수량(예: 177개) 만큼만 실제로 성공하는지 확인하는 테스트를 작성했다.
테스트 목표
- 3000명의 사용자가 동시에 구매 요청을 보낸다.
- 재고(stock)가 177개일 경우, 정확히 177명만 성공하고 나머지는 실패해야 한다.
- 선점 로직, 분산락, 재고 검증 등에 의도한 동작이 잘 작동하는지 검증한다.
@Test
@DisplayName("여러 사용자 동시 구매 - 재고만큼만 성공")
void multipleUsersConcurrentPurchaseTest() throws InterruptedException {
int userCount = 200;
int stock = 177;
testProduct = productRepository.save(Product.builder()
.id(testProduct.getId())
.externalProductId(testProduct.getExternalProductId())
.name(testProduct.getName())
.description(testProduct.getDescription())
.imageUrl(testProduct.getImageUrl())
.price(testProduct.getPrice())
.saleStartAt(testProduct.getSaleStartAt())
.saleEndAt(testProduct.getSaleEndAt())
.build());
// 재고 업데이트
ProductStock stockEntity = productStockRepository.findByProductId(testProduct.getId()).orElseThrow();
stockEntity.initializeStock(stock);
productStockRepository.save(stockEntity);
List<User> users = new ArrayList<>();
for (int i = 0; i < userCount; i++) {
users.add(userRepository.save(User.builder()
.externalUserId(UUID.randomUUID().toString())
.userName("user" + i)
.age(20 + i)
.region("서울")
.build()));
}
ExecutorService executor = Executors.newFixedThreadPool(userCount);
CountDownLatch latch = new CountDownLatch(userCount);
List<PurchaseResponse> responses = Collections.synchronizedList(new ArrayList<>());
List<Exception> exceptions = Collections.synchronizedList(new ArrayList<>());
for (User user : users) {
executor.submit(() -> {
try {
PurchaseResponse response = productService.purchase(testProduct.getId(), user.getId());
responses.add(response);
} catch (Exception e) {
exceptions.add(e);
} finally {
latch.countDown();
}
});
}
latch.await();
executor.shutdown();
assertThat(responses.size()).isEqualTo(stock);
assertThat(purchaseRepository.count()).isEqualTo(stock);
assertThat(purchaseHistoryRepository.count()).isEqualTo(stock);
assertThat(exceptions.size()).isEqualTo(userCount - stock);
}
최종 프로세스

