Skip to content

fix: 재고 예약 후, 서버 장애 시 재고가 소실되는 문제 해결 - #8

Merged
standard-Chan merged 8 commits into
mainfrom
fix/stock/reliable-stock-reservation
Sep 2, 2026
Merged

standard-Chan merged 8 commits into
mainfrom
fix/stock/reliable-stock-reservation

Conversation

@standard-Chan

@standard-Chan standard-Chan commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

📝 상황

결제 승인 흐름에서는 Toss Payments 승인 전에 주문 상품의 재고를 먼저 차감해 재고를 선점해요.

기존 흐름은 다음과 같았어요.

try {
    reservedLines = reserveOrderStocks(order); // 재고 예약

    tossPaymentClient.confirm(...); // 결제 진행

    payment.complete();
    order.complete();
} catch (...) {
    restoreReservedStocks(reservedLines); // 재고 복구
}

현재 재고 예약은 별도 Lock을 오래 유지하는 방식이 아니라, DB 재고를 선차감해서 다른 사용자가 같은 재고를 초과 구매하지 못하게 막는 방식이에요.


💥 문제점

1. 예약 재고 정보가 서버 메모리에만 존재

기존 구현은 어떤 상품 재고를 예약했는지 reservedLines라는 Java 메모리 변수에만 가지고 있었어요.

따라서 재고 차감 이후 Toss 승인 전 또는 승인 중 서버가 종료되면 catch 문이 실행되지 못하고, 메모리에 있던 예약 정보도 함께 사라져요.

그 결과 실제로는 결제가 완료되지 않았는데 재고만 차감된 상태로 남을 수 있었어요.

2. 재고 정합성 복구 기준 부재

예약 정보가 DB에 없기 때문에 서버 재시작 후에도 어떤 결제 시도가 어떤 상품 재고를 선점했는지 알 수 없었어요.

이로 인해 다음 문제가 발생할 수 있어요.

  • 사용자는 결제 재시도 시 “분명 재고가 있었는데 품절된 상황”을 겪을 수 있어요.
  • 판매자는 실제 판매되지 않은 재고가 소진된 것처럼 보일 수 있어요.
  • 운영자는 재고 수량이 왜 틀어졌는지 추적하기 어려워요.

💡 해결방안 선택 의도

1. 재고 차감과 예약 주체 정보를 하나의 트랜잭션으로 처리

핵심 목표는 재고를 차감할 때 “누가, 어떤 결제로, 어떤 상품을, 몇 개 예약했는지”를 DB에 함께 남기는 것이에요.

그래서 stock_reservation 테이블을 추가하고, 재고 차감과 예약 row 생성을 StockReservationService.reserve()의 같은 트랜잭션에서 처리하도록 했어요.

이제 재고 차감 중 일부 상품에서 실패하면 앞서 처리된 재고 차감과 예약 row가 함께 rollback돼요.

2. 복구 기준을 예약 row 상태로 설정

기존 복구는 주문 라인을 다시 조회해 재고를 복구했지만, 실제로 어떤 라인이 예약 성공했는지 알 수 없었어요.

이제 복구 기준을 stock_reservation row로 바꿨어요.

  • 예약 row가 있으면 RESERVED 상태의 예약만 복구하거나 완료 처리해요.
  • 예약 row가 없으면 재고 차감이 일어나지 않은 결제 시도로 보고 재고를 건드리지 않아요.

✔️ 해결 및 구현

1. 재고 예약 Schema 추가

stock_reservation을 추가했어요.

주요 상태는 다음과 같아요.

  • RESERVED: 재고가 차감되어 결제 시도에 예약된 상태
  • COMPLETED: 결제가 성공해 예약 재고가 최종 판매 확정된 상태
  • RESTORED: 결제 실패 또는 복구 처리로 재고가 반환된 상태

payment_id,status 인덱스는 결제 성공 완료, 실패 복구, 재시작 복구에서 특정 결제의 RESERVED 예약만 빠르게 찾기 위해 추가했어요.

2. 결제 승인 흐름 변경

기존 메모리 기반 reservedLines 복구를 제거하고, StockReservationService를 사용하도록 변경했어요.

try {
    reserveOrderStocks(payment.getId(), order); // 재고 차감 + 예약 row 저장

    tossPaymentClient.confirm(...); // 결제 진행

    stockReservationService.complete(payment.getId()); // 예약 확정
} catch (...) {
    stockReservationService.restore(payment.getId()); // 예약 복구
}

reserve() 내부에서는 재고 차감과 예약 row 저장을 같은 트랜잭션으로 처리해요.

@Transactional
public void reserve(Long paymentId, Long orderId, List<OrderLineSummary> orderLines) {
    for (...) {
        stockService.decrease(productId, quantity);
        stockReservationRepository.save(
            StockReservation.reserve(...)
        );
    }
}

3. 예약 재고 복구 동시성 처리

restore(paymentId)가 동시에 여러 번 호출되면 같은 RESERVED 예약 row를 여러 트랜잭션이 함께 조회할 수 있어요.

그래서 재고를 바로 증가시키지 않고, 먼저 status = RESERVED 조건부 update로 RESTORED 전환을 시도해 복구 실행권을 선점하도록 했어요.

1. paymentId 기준 RESERVED 예약 row 조회
2. 각 예약 row에 대해 status=RESERVED 조건부 update 실행
3. update count == 1 이면 해당 트랜잭션이 복구 실행권 획득
4. 복구 실행권을 얻은 row만 stock 증가
5. update count == 0 이면 다른 트랜잭션이 이미 처리한 것이므로 skip

MySQL/InnoDB 기준으로 조건부 update가 row를 변경하면 해당 row에는 배타락이 잡히고, 같은 트랜잭션이 끝날 때까지 유지돼요. 따라서 다른 restore 요청은 해당 row update에서 대기한 뒤, 먼저 처리한 트랜잭션이 커밋되면 status=RESERVED 조건이 깨져 update count = 0이 돼요.

이 방식은 일부 요청이 lock 대기 중 connection을 점유할 수 있지만, restore 내부에는 외부 API 호출이 없고 짧은 DB 작업만 있으므로 현재 요구사항에서는 별도 RESTORING 상태나 명시적 비관적 락을 추가하지 않고 조건부 update 방식을 유지했어요.

4. 서버 재시작 복구 흐름 변경

PaymentRecoveryService는 서버 재시작 시 이전에 생성된 EXECUTING 결제를 조회하고, 결제 상태와 예약 row 존재 여부에 따라 3가지 경우로 나누어 복구해요.

3가지 경우에 따른 복구 방식

4.1. 결제 상태를 진행중으로 변경한 뒤, 재고 예약 전에 서버가 종료된 경우

1. EXECUTING 결제 조회
2. 해당 paymentId의 예약 row 조회
3. 예약 row 없음
4. 재고 복구 없이 Payment FAILED, Order PENDING 처리

이 경우는 결제 상태만 진행중으로 바뀌었고 아직 재고 차감이 일어나지 않은 상태예요. 따라서 재고는 건드리지 않고 결제 시도만 실패로 닫아요.

4.2. 재고 예약 성공 후, Toss 결제 승인 전에 서버가 종료된 경우

1. EXECUTING 결제 조회
2. 해당 paymentId의 RESERVED 예약 row 조회
3. Toss 상태가 DONE이 아님
4. RESERVED 예약 row 기준으로 재고 복구
5. Payment FAILED, Order PENDING 처리

이 경우는 재고가 차감되었지만 외부 결제는 완료되지 않은 상태예요. 따라서 DB에 남아 있는 RESERVED 예약 row를 기준으로 재고를 복구하고 결제 시도를 실패로 닫아요.

4.3. 재고 예약과 Toss 결제 승인 성공 후, 내부 성공 처리 전에 서버가 종료된 경우

1. EXECUTING 결제 조회
2. 해당 paymentId의 예약 row 조회
3. Toss 상태 DONE 확인
4. Payment SUCCESS, Order COMPLETED 처리
5. 예약 row COMPLETED 처리

이 경우는 외부 결제는 이미 완료되었지만 내부 DB 상태 저장이 끝나지 않은 상태예요. Toss 상태를 기준으로 내부 Payment, Order, 예약 row를 성공 상태로 맞춰요.


✅ 테스트

다음 테스트를 추가/수정했어요.

  • StockReservationServiceTest
    • 일부 상품 재고 부족 시 전체 예약 rollback
    • 예약 복구 restore() 중복 처리
    • 예약 확정 complete() 중복 처리
  • PaymentServiceTest
    • 결제 성공 시 예약 COMPLETED 처리
    • 결제 실패 시 예약 RESTORED 처리
    • 결제 상태 진행중 변경 실패, 만료, 종료 결제, 주문 상태 거부 시 예약 미수행
  • PaymentRecoveryServiceTest
    • 예약 row 없는 EXECUTING 결제는 재고 복구 없이 실패 처리
    • 예약 row 있는 Toss DONE 결제는 성공 복구 및 예약 완료 처리
    • 예약 row 있는 Toss 미완료 결제는 예약 row 기준 재고 복구

⚠️ 한계

이번 방식은 재고 정합성을 복구 가능하게 만드는 데 초점을 맞췄지만, 다음 2가지 한계가 있어요.

1. UX 한계: 사용자 재고 선점의 해제

재고 예약을 성공한 사용자가 서버 문제로 결제하지 못한 경우, 현재는 해당 결제를 실패 처리하고 예약 재고를 복구해요. 따라서 사용자는 결제를 다시 진행해야 하고, 그 사이 다른 사용자가 재고를 선점하면 재결제에 실패할 수 있어요.

인기 제품이나 특가 상품처럼 재고 선점이 중요한 상황에서는 문제가 더 크게 드러날 수 있어요. 인터파크 티켓 예매나 CGV 좌석 예매처럼 선점 유지가 중요한 도메인이 대표적인 예예요.

향후 인기 상품이나 이벤트 상품이 추가된다면, 해당 상품에 한해 결제 중간 지점부터 이어서 진행하는 기능이나 예약 유지 정책을 검토할 필요가 있어요.

2. 복구 Lock 대기로 인한 connection 점유

예약 복구는 중복 재고 증가를 막기 위해 status=RESERVED 조건부 update로 복구 실행권을 선점해요. 이때 먼저 update에 성공한 트랜잭션이 종료될 때까지 같은 예약 row를 복구하려는 다른 트랜잭션은 대기하고, 대기 중 DB connection을 점유할 수 있어요.

현재 restore() 내부에는 외부 API 호출이 없고 짧은 DB 작업만 있으므로 이 방식을 유지했지만, 같은 결제의 복구 요청이 많이 몰리는 상황에서는 connection pool 점유가 늘어날 수 있어요.


🔗 관련 링크

재고 예약 상태와 예약 엔티티, 저장소, 예약 서비스를 추가했다.

예약 row 저장과 재고 차감을 같은 트랜잭션에서 처리해 서버 장애 후 복구 기준을 DB에 남기기 위함이다.

동일 상품 라인 합산, rollback, 완료/복구 멱등성을 검증하는 테스트를 추가했다.
결제 승인 중 메모리 reservedLines 기반 재고 복구를 제거하고 StockReservationService를 사용하도록 변경했다.

Toss 성공 시 예약을 COMPLETED로 닫고 실패 시 paymentId 기준 RESERVED 예약만 복구하도록 해 서버 메모리 소실에 의존하지 않기 위함이다.

PaymentServiceTest는 예약 서비스 호출과 성공/실패 흐름을 기준으로 갱신했다.
PaymentRecoveryService가 주문 라인 대신 stock_reservation row를 기준으로 예약 완료와 복구를 처리하도록 변경했다.

CAS 성공 후 예약 전에 서버가 종료된 EXECUTING 결제는 예약 row가 없으므로 Toss 조회 없이 Payment 실패와 Order PENDING으로 닫는다.

예약 존재 여부별 성공, 실패, 보류 복구 테스트를 갱신했다.
결제 승인 중 stock_reservation을 저장하는 기준과 재시작 복구 분기를 문서화했다.

운영 환경은 ddl-auto validate이므로 배포 전 수동 적용할 MySQL 테이블 DDL을 함께 남겼다.
StockReservationService.complete()의 트랜잭션 이유와 stock_reservation의 payment_id,status 인덱스 의도를 주석으로 남겼다.

결제 복구와 예약 완료/복구 처리에서 RESERVED 예약을 찾는 조회 기준을 문서에도 반영했다.
@standard-Chan standard-Chan added domain:stock Stock domain fix Fix labels Aug 24, 2026
@standard-Chan standard-Chan changed the title fix: 결제 재고 예약 정보를 DB에 영속화 fix: 재고 예약 후, 서버 장애 시 재고가 소실되는 문제 해결 Aug 24, 2026
현재 주문 계약상 동일 productId가 분리된 주문 라인으로 들어오지 않으므로 합산 테스트의 의도가 불명확했다.

재고 예약 서비스의 핵심 보장인 예약 실패 rollback과 완료/복구 멱등성 테스트만 유지한다.
예약 복구 시 RESERVED 상태 조건부 update로 복구 실행권을 먼저 선점하도록 변경했다.

동시에 restore가 여러 번 호출되어도 선점에 성공한 트랜잭션만 재고를 증가시켜 중복 재고 복구를 막기 위함이다.

스레드 기반 병렬 restore 테스트와 복구 정책 문서를 함께 갱신했다.
예약 복구 조건부 update가 트랜잭션 동안 배타락을 유지한다는 점을 문서에 보강했다.

동시 restore 요청은 lock 대기 중 DB connection을 점유할 수 있으므로 현재 방식의 운영 한계를 명시했다.
@standard-Chan
standard-Chan merged commit 2df130d into main Sep 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain:stock Stock domain fix Fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant