재고 차감으로 알아보는 동시성 제어 - DB, Redis 락

Jong Hwan
|2026. 2. 21. 11:42
※ 이 글은 최상용님의 인프런 강의 '재고시스템으로 알아보는 동시성이슈 해결방법'을 기반으로 학습한 내용을 정리한 것입니다.

 

자바 synchronized

@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

//    @Transactional
    public synchronized void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}
  • 자바에서 지원하는 synchronized 메서드를 사용하면 동시에 여러 스레드가 하나의 공유 자원을 접근하는 Race Condition을 예방할 수 있다.
  • 즉, 한 스레드가 마치면 그제서야 해당 메서드를 다시 접근할 수 있는 것이다.
  • 하지만 해당 방식은 스프링 트랜잭션과 함께 사용할 수 없다는 치명적인 문제가 있다. -> 롤백 불가, 자동 커밋 모드
  • 스프링의 트랜잭션 프록시는 decrease() 메서드 호출이 끝난 뒤에 DB 커밋을 수행하는데, 메서드가 종료되는 순간 synchronized 락이 먼저 풀려버리기 때문이다.
  • 즉, 앞선 트랜잭션의 변경사항이 DB에 커밋되기도 전에 다른 스레드가 메서드에 진입해 갱신되지 않은 과거의 데이터를 조회하게 되면서 동시성이 깨진다.
  • 또한, synchronized는 해당 애플리케이션이 실행 중인 단일 JVM(프로세스) 안에서만 유효하다.
  • 따라서 서버가 여러 대로 확장된 실제 운영 환경에서는 각 서버마다 락이 따로 걸리게 되므로 동시성을 전혀 보장할 수 없다.
  • 이러한 이유들로 인해 실무에서는 거의 사용하지 않는 방식이다.

 

 

 

 

 

이제, 분산 환경에서의 동시성 문제를 해결해보자

아래의 락 구현 방식들은 MySQL 기준이다.

비관적 락

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(Long id);
}
  • DB에서 데이터를 가져올 때 해당 로우에 대해 락을 걸고 가져오는 방식. (select for update)
  • 해당 데이터에 대한 락이 풀리기 전까지 다른 트랜잭션들은 해당 로우를 수정하거나 락을 걸 수 없다.
  • 하지만, 락을 사용하지 않는 단순 조회(SELECT)는 MVCC (다중 버전 동시성 제어)에 의해 이전 스냅샷 데이터를 읽어올 수 있다.
  • 따라서 데이터를 수정하려는 모든 스레드 요청은 비관적 락을 획득하는 조회 로직을 사용하도록 해야 정합성이 보장된다.
  • 수정을 위한 조회는 앞선 락이 풀릴 때까지 대기하게 되므로, 과거 데이터를 읽어와 덮어쓰는 갱신 손실 문제를 예방할 수 있다.
  • 하지만 데드락이 발생할 수 있기에 주의해서 사용해야 하며, DB에 실제 락을 걸기에 성능 감소가 발생할 수 있다.

 

 

낙관적 락

@Entity
@Getter
public class Stock {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    // 낙관적 락을 위해 추가
    @Version
    private Long version;

    public Stock() {
    }

    public Stock(Long quantity) {
        this.quantity = quantity;
    }

    public Stock(Long productId, Long quantity) {
        this.productId = productId;
        this.quantity = quantity;
    }

    public void decrease(Long quantity) {
        if (this.quantity - quantity < 0) {
            throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
        }

        this.quantity -= quantity;
    }
}

 

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithOptimisticLock(Long id);
}



@Service
@RequiredArgsConstructor
public class OptimisticLockStockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findByIdWithOptimisticLock(id);

        stock.decrease(quantity);
    }
}



@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {

    private final OptimisticLockStockService stockService;

    public void decrease(Long id, Long quantity) throws InterruptedException {
        while (true) {
            try {
                stockService.decrease(id, quantity);

                // 정상 수행되었다면 반복문 빠져나오기
                break;
            } catch (Exception e) {
                // 실패하면 잠시 후에 다시 시도
                Thread.sleep(50);
            }
        }
    }
}
  • 실제로 락을 사용하지 않고 버전을 이용하는 방식
  • 데이터를 읽은 후에 update를 할 때 처음에 읽은 버전이 맞는지 확인뒤에 update를 하고 버전을 올린다.
  • 만약 처음 읽은 버전에서 수정사항이 생긴 경우에 다시 읽은 후에 작업을 수행해야 한다.
  • 락을 걸지 않기 때문에 비관적 락에 비해 성능상 이점이 있지만, 업데이트에 실패했을 때 재시도 로직을 별도로 작성해야 한다.
  • 충돌이 빈번하게 일어나는 곳에서는 비관적 락을, 그렇지 않다면 낙관적 락을 사용하는 것이 좋다.

 

 

 

네임드 락

public interface LockRepository extends JpaRepository<Stock, Long> {

    @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
    void getLock(String key);

    @Query(value = "select release_lock(:key)", nativeQuery = true)
    void releaseLock(String key);
}



@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {

    private final LockRepository lockRepository;
    private final StockService stockService;

    @Transactional
    public void decrease(Long id, Long quantity) {
        try {
            lockRepository.getLock(id.toString());
            stockService.decrease(id, quantity);
        } finally {
            lockRepository.releaseLock(id.toString());
        }
    }
}



@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    // 락을 획득하는 상위 퍼사드와 별도 트랜잭션으로 분리되어야 함
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}
  • 로우에 대해 락을 거는 비관적 락과 달리, 메타데이터 (이름)에 대해 락을 거는 방식
  • 이름을 가진 락을 획득 후, 해제할 때까지 다른 세션은 이 락을 획득할 수 없다.
  • 트랜잭션이 종료될 때 해당 락은 자동으로 해제되지 않기 때문에 별도로 해제 해주어야 하며, 하나의 요청에 대해 데이터베이스 커넥션 풀을 2개 사용하게 된다.

 

 

 

Redis를 이용한 동시성 제어

Lettuce

@Component
@RequiredArgsConstructor
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;

    // 로직 수행 전 setnx로 락 획득
    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                // 락 만료 시간 설정
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    // 로직 수행 후 언락
    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }
}



@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    public void decrease(Long id, Long quantity) throws InterruptedException {
        // 락 획득
        while (!redisLockRepository.lock(id)) {
            Thread.sleep(100);
        }

        try {
            stockService.decrease(id, quantity);
        } finally {
            redisLockRepository.unlock(id);
        }
    }
}
  • Lettuce는 SETNX (Set if Not eXists) 명령어를 통해 동시성을 제어한다. 키가 존재하지 않을 때만 값을 세팅하기 때문에, 이를 락을 획득했다는 의미로 사용한다.
  • 락을 획득한 서버가 다운되면 락을 해제하지 못해 다른 모든 스레드가 영원히 대기하는 데드락 상태에 빠지게 된다. 따라서 락을 생성할 때는 만료 시간(TTL)을 설정해야 한다.
  • spin lock 방식으로, 락을 획득할 때까지 재시도하기에 Redis에 부하가 갈 수 있으므로 재시도 간격을 둬야 한다.
  • 구현이 간단하며, spring data redis를 사용하면 lettuce가 기본이므로 별도 라이브러리를 사용하지 않아도 된다.
  • 따라서 락 획득 재시도가 굳이 필요 없거나 (예: 결제하기, 스케줄러 등), 충돌이 거의 발생하지 않는 환경에서 사용하는 것이 좋다.

 

Redisson

@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final StockService stockService;

    public void decrease(Long id, Long quantity) {
        RLock lock = redissonClient.getLock(id.toString());

        try {
            // 10: 락 획득을 위해 기다리는 최대 시간, 1: 락 획득 후 점유하는 최대 시간.
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);

            if (!available) {
                log.info("lock 획득 실패");
                return;
            }

            stockService.decrease(id, quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
  • 락을 획득하기 위해 계속 Redis에 요청을 보내는 spin lock과 달리, 락을 점유 중인 스레드가 락을 해제할 때 채널에 알려줌으로써 해당 채널에서 대기 중인 스레드들이 자연스럽게 락을 획득할 수 있도록 한다. -> Pub/Sub
  • 알림을 받은 스레드들만 락 획득을 시도하므로 Redis에 가는 부하가 줄어든다.
  • 락 획득과 재시도 로직을 자체로 제공(tryLock) 해주지만, 구현이 복잡하고 별도 라이브러리를 사용해야 해서 학습 곡선이 존재한다.
  • 트래픽이 많고 락 획득 재시도가 빈번한 비즈니스 로직(예: 선착순 쿠폰 발급, 재고 차감)에 적합하다.

 

 

 

트랜잭션 밖 (서비스 로직 밖)에서 락을 획득하는 이유

 

  • 트랜잭션 커밋은 서비스 메서드가 완전히 종료된 이후에 일어난다.
  • 서비스 내부에서 락을 걸고 푼다면, 락은 풀렸는데 DB 트랜잭션은 아직 커밋되지 않은 아주 짧은 틈이 생기게 된다.
  • 이때 다른 스레드가 락을 획득하고 DB를 읽으면, 아직 갱신되지 않은 과거의 재고 데이터를 읽는 문제가 발생한다.
  • 따라서 락 획득 -> 트랜잭션 시작 -> 비즈니스 로직 -> 트랜잭션 커밋 -> 락 해제 순서를 반드시 보장해야 하며, 이를 위해 Facade 패턴을 사용한다.