Projects/hub-eleven

[트러블슈팅] Redisson 분산 락 적용 중 트랜잭션 커밋 순서 문제를 해결한 과정 (1)

annovation 2026. 6. 22. 14:27

진행 환경

💡진행 환경

  • Java 17
  • Spring Boot 3.5.7
  • MySQL 8.0.44
  • Gradle

문제 상황

💡 문제 상황

 

💡 문제 증상

  • Redisson 분산락을 적용했지만, 100개의 동시 요청을 보냈을 때 재고가 기대값처럼 정확히 차감되지 않았다.
  • 테스트 기대값은 다음과 같다.
초기 재고 : 100
요청 수 : 100개
요청당 차감 수량 : 1
기대 결과 : 최종 재고 0
실제 결과 : 최종 재고가 0이 아니거나, 일부 요청이 실패하는 동시성 문제가 발생

원인 분석

💡 원인 1 : 락 해제가 트랜잭션 커밋보다 먼저 일어난다.

 

❗️StockServiceImpl.java

@Transactional
public StockResult decreaseStock(StockRequests.Decrease request) {
    return stockLockManager.executeWithLock(
            "stock:decrease:" + request.productId(),
            () -> {
                Product product = getProductOrThrow(request.productId());
                Stock stock = getStockOrThrow(request.productId());
                stock.decreaseQuantity(request.quantity());
                return StockResult.from(stock, product.getName());
            }
    );
}
  • 현재 구조는 StockServiceImpl.decreaseStock()에 @Transactional이 붙어 있고, 그 메서드 내부에서 Redisson 락을 획득한다.

❗️Redisson 분산락 적용한 재고 감소 서비스 로직 흐름

트랜잭션 시작
→ Redisson 락 획득
→ 재고 조회
→ 재고 차감
→ 락 해제
→ 트랜잭션 커밋
  • Spring의 @Transactional은 프록시가 메서드 호출 전후로 트랜잭션을 관리한다. 따라서 메서드 내부의 finally에서 unlock()이 실행된 뒤, 메서드가 완전히 반환되고 나서야 트랜잭션 커밋이 일어난다.
  • 락이 풀렸지만 이전 요청의 재고 감소가 아직 DB에 commit되지 않은 상태라면, 다음 요청이 lock을 획득하고 확정되지 않은 데이터를 기준으로 재고를 조회할 수 있다.
  • 분산 락의 목적은 같은 상품 재고 감소를 순차 처리하는 것인데, commit 이전에 lock이 풀리면 정합성 보장이 약해진다.

❗️잘못된 흐름 예시

Thread A: 재고 100 조회
Thread A: 재고 99로 변경
Thread A: 락 해제
Thread B: 락 획득
Thread B: 아직 A 커밋 전이라 DB에서 재고 100 조회
Thread A: 커밋
Thread B: 재고 99로 변경
Thread B: 커밋

해결 방안 검토

💡 해결 방안 1 : 


해결 방법

💡 해결 방법 : lock을 잡는 책임과 실제 DB 변경 트랜잭션을 분리

  • StockServiceImpl은 lock 획득/해제 흐름을 담당하고, StockDecreaseProcessor는 @Transactional로 실제 재고 감소를 담당하게 했다.
  • 1
public StockResult decreaseStock(Decrease request) {
    return stockLockManager.executeWithLock(
            "stock:decrease:" + request.productId(),
            () -> stockDecreaseProcessor.decrease(request)
    );
}
  • 2
@Transactional
public StockResult decrease(Decrease request) {
    Stock stock = stockRepository.findByProductId(request.productId())
            .orElseThrow(...);

    stock.decreaseQuantity(request.quantity());

    return StockResult.from(stock, productName);
}

트랜잭션 분리 ?

💡트랜잭션을 분리하는 이유

 

Redisson 분산 락을 적용할 때 단순히 `@Transactional` 메서드 내부에서 lock/unlock을 처리하면, `finally`에서 lock이 해제된 뒤 Spring 트랜잭션 commit이 수행될 수 있다.

 

이 경우 다음 요청이 lock을 획득했지만 이전 요청의 DB 변경은 아직 commit되지 않은 상태가 될 수 있어, 재고 정합성 보장이 약해진다.

 

이를 방지하기 위해 lock을 잡는 바깥 서비스와 실제 DB 변경을 수행하는 `@Transactional` Processor를 분리했다.

 

결과적으로 실행 순서를 다음과 같이 보장했다.

`lock 획득 → 트랜잭션 시작 → 재고 감소 → commit → lock 해제`

 

✅ 분리하지 않은 구조

 

➡️ 문제 흐름

  • 락은 풀렸는데 DB 변경은 아직 커밋되지 않은 상태가 생길 수 있다.
  • 그러면 다음 요청이 확정되지 않은 데이터를 기준으로 동작할 위험이 있다.

 

✅ 분리한 구조

 

➡️ 요청이 여러 개일 때

  • 다음 요청은 이전 요청의 DB commit이 끝난 뒤 lock을 획득한다.
  • 따라서 확정된 재고 값을 기준으로 감소 로직을 수행할 수 있다.

  1. 프록시가 뭔지
  2. 프록시 에서 커밋하는 로직
  3. 트랜잭션이 뭔지
  4. 트랜잭션 분리하는 이유

참고 자료