분산 트랜잭션 실패 시 데이터 정합성 보장하기

현재 상황

펀딩을 진행할 때 서비스 간 트랜잭션 일관성을 위해 사가패턴을 사용하고 있다.

 

펀딩 생성 플로우

재고 예약은 동기 방식으로 즉시 처리하여 재고 정합성을 보장하고, 결제는 비동기 방식으로 처리하여 응답 속도를 개선하는 구조다.

 

문제 발견

재고 차감에 대해 부하 테스트를 하면서 동시성 문제는 이미 잡았다고 생각했는데 재고가 안 맞는 문제를 발견했다.

 

문제 상황 

사용자가 펀딩 시 여러 리워드를 선택할 수 있다. 예를 들어 "리워드1 + 리워드2"를 함께 선택하면, 리워드 서비스에서는 요청을 순차적으로 처리한다.

리워드1 재고 차감 → 성공 
리워드2 재고 차감 → 재고 부족으로 실패

이 경우 리워드2의 실패로 전체 펀딩이 실패하고, 리워드1의 예약도 취소되어야 한다.

하지만 실제로는 리워드1은 차감된 상태로 남아있고, 선점한 데이터도 그대로 DB에 존재하여 정합성이 깨져있다.

 

원인 분석

리워드 서비스는 여러 리워드를 순차적으로 처리하고 있다.

하지만 리워드마다 개별 락을 획득하고 해제하는 구조로 되어 있어, 단일 트랜잭션이지만 락 범위는 리워드별로 분리된 상태였다.

처음에는 첫 번째 리워드의 재고 차감과 선점이 완료된 직후 바로 커밋되고 락이 해제된다고 생각했다.

이로 인해 두 번째 리워드가 락을 획득한 뒤 재고 차감을 시도할 때 재고 부족으로 실패하며, 이 예외가 펀딩 서비스까지 전파되어 전체 트랜잭션 롤백을 시도하지만 첫 번째 리워드는 이미 커밋된 상태이기 때문에 되돌릴 수 없어서 생기는 문제라고 생각했다.

 

로그 확인

하지만 실제로 로그를 확인했을 때 예상과 다른 상황인 것을 확인했다.

2026-01-23T16:59:49.016 [exec-19] Creating new transaction with name [RewardStockService.reserveStock]
2026-01-23T16:59:49.017 [exec-19] 재고 예약 시작 - fundingId: 1d7e0a95-9234-402d-bec5-3805aab0c1b2, items: 2개
2026-01-23T16:59:49.022 [exec-19] 분산 락 획득 성공 - lockKey: reward:stock:a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
2026-01-23T16:59:49.024 [exec-19] Participating in existing transaction
2026-01-23T16:59:49.024 [exec-19] 리워드 재고 예약 완료 - fundingId: 1d7e0a95-9234-402d-bec5-3805aab0c1b2, rewardId: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
2026-01-23T16:59:49.027 [exec-19] 분산 락 해제 완료 - lockKey: reward:stock:a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d

2026-01-23T16:59:49.024 [exec-17] 분산 락 획득 성공 - lockKey: reward:stock:f6e5d4c3-b2a1-4c5d-9e8f-7d6c5b4a3210
2026-01-23T16:59:49.024 [exec-17] INSERT stock_reservations (reward2)
2026-01-23T16:59:49.026 [exec-17] UPDATE p_rewards (재고 차감)

2026-01-23T16:59:49.024 [exec-8] Initiating transaction rollback
2026-01-23T16:59:49.024 [exec-8] Rolling back JPA transaction on EntityManager
2026-01-23T16:59:49.025 [exec-8] 비즈니스 규칙 위반: 재고가 부족합니다

로그를 봤을 때 exec-19, exec-17, exec-8서로 다른 HTTP 요청이었다.

단일 요청 내 여러 리워드의 문제가 아니라, 동시에 여러 요청이 서로 간섭해서 생긴 문제였다.

 

  • 요청 A:  첫 번째 성공 → 두 번째 리워드 성공 [커밋] 
  • 요청 B: 첫 번째 리워드 성공 → 두 번째 리워드 성공 [커밋]
  • 요청 C: 첫 번째 리워드 성공 → 두 번째 리워드 실패 [롤백]

요청 C가 롤백을 시도하지만, 첫 번째 리워드는 락 해제 시점에 JPA flush로 이미 DB에 반영된 상태였다.

결국 첫 번째 리워드 차감만 DB에 남아 재고 불일치가 발생했다.

 

문제 해결

1. 재고 선점 3단계로 확장

기존에는 재고 선점 과정을 2단계(차감, 복구)로 설계했다.

  • DEDUCTED (차감): 재고를 차감하고 예약 완료
  • RESTORED (복구): 펀딩 실패/취소 시 재고 복원

하지만 이렇게 설계했을 때 각 재고 선점이 독립적인 트랜잭션으로 처리되어 먼저 차감된 재고가 후에 들어온 요청이 재고 부족으로 실패했을 때 되돌릴 수 없다.

 

이를 해결하기 위해 보상 트랜잭션을 도입했고, 재고 선점 방식을 다음과 같이 3단계로 확장했다.

  • 임시 예약 (PENDING): 재고는 차감하되, 펀딩이 확정되지 않은 상태
  • 예약 확정 (CONFIRMED): 펀딩 생성이 완료되면 예약을 확정
  • 재고 복원 (RESTORED): 펀딩 실패/취소 시 재고 복원
이렇게 하면 중간에 실패하더라도 PENDING 상태의 예약을 보상 트랜잭션으로 즉시 복원할 수 있고, 복원에 실패하더라도 스케줄러가 만료된 PENDING 상태를 자동으로 정리한다.
 
 

2. 트랜잭션 범위 재설계

@Transactional
public StockReserveResult reserveStock(command) {
    for (item : items) {
        executeWithLock(() -> {
            transactionService.reserveStockForItem(item);
        });
    }
}

기존에는 위와 같이 재고 차감 전체를 하나의 트랜잭션으로 묶었다.

이때 문제는 JPA가 중간에 flush를 호출할 수 있고, 락을 해제할 때 일부 변경사항이 DB에 반영될 수 있어, 이후 실패 시 롤백이 불가능 했다.

 

이를 해결하기 위해 트랜잭션 범위를 각 리워드 단위로 분리했다.

// RewardStockService
public StockReserveResult reserveStock(command) {
            ...
    try {
        for (item : items) {
            executeWithLock(() -> {
                transactionService.reserveStockForItem(item);
            });
            ...
            ...
    }
}

// RewardStockTransactionService
@Transactional
public ReservationWithPrice reserveStockForItem(...) {
    reward.decreaseStock();
    reservation = save();
    return ...;
}

이렇게 하면 각 리워드 처리가 원자적으로 완료되므로 중간 상태가 발생하지 않고, 실패 시 보상 트랜잭션으로 이미 성공한 예약들을 명시적으로 복원할 수 있다.

 

3. 보상 트랜잭션 도입

중간 실패 시 이미 성공한 예약들을 즉시 복원하는 로직을 추가했다.

private void compensateReservations(List reservations) {
            ...
    for (var item : reservations) {
        try {
            ===보상 트랜잭션 코드===
        } catch (Exception e) {
            log.error("보상 실패 - 스케줄러가 처리 예정", e);
        }
    }
}

또한 보상 트랜잭션마저 실패하는 경우를 대비해 자동 복원 스케줄러를 추가했다.

@Scheduled(cron = "0 * * * * *")
public void restoreExpiredReservations() {
    List expiredReservations =
        repository.findExpiredPendingReservations(LocalDateTime.now());

    expiredReservations.forEach(reservation -> {
        rewardStockService.restoreStock(reservation.getFundingId().getId());
    });
}

보상 트랜잭션이 실패하더라도 즉시 장애로 이어지지 않도록, 비동기 구조로 설계해 시스템이 자동으로 복원할 수 있도록 구현했다.

 

4. 펀딩 성공 시 예약 확정

펀딩 생성이 성공적으로 완료되면, PENDING 상태에 있던 재고를 CONFIRMED 상태로 확정한다.

@Transactional
public CreateFundingResult createFunding(CreateFundingCommand command) {
    Funding savedFunding = fundingRepository.save(funding);
    
    if (command.hasRewards()) {
        // 1. 재고 임시 예약 (PENDING)
        StockReserveResponse response = rewardClient.reserveStock(...);
        
        addReservationsToFunding(funding, response);
        
        // 3. 재고 예약 확정 (PENDING → CONFIRMED)
        rewardClient.confirmReservations(savedFunding.getId());
    }
    
    publishPaymentProcessEvent(funding, command);
    return CreateFundingResult.success(savedFunding.getId());
}

 

마무리

이번 문제를 해결하면서 재고 정합성 문제는 락이나 트랜잭션만으로 해결되는 문제가 아니라, 사가패턴 관점에서 실패를 어떻게 되돌릴지 미리 예방하는 설계를 해야 된다고 느꼈다.

처음에는 락과 트랜잭션만 잘 걸면 정합성이 보장될 거라 생각했지만, 실제 분산 환경에서 일부 성공 일부 실패가 섞인 상태는 더 복잡하고 위험하게 느껴졌다. 

또한 사가패턴은 단순히 단계를 나누는 것이 아니라, 실패했을 때 되돌릴 수 있는 상태와 경계를 명확히 정의하는 것이 핵심이라는 점을 배웠다.