재고 차감으로 알아보는 동시성 제어 - DB, Redis 락
|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 패턴을 사용한다.
'공부 > Spring' 카테고리의 다른 글
| [인프런 김영한] 실전 데이터베이스 설계 1 (0) | 2026.03.29 |
|---|---|
| OAuth 2.0과 JWT를 활용한 Spring Security 로그인 개념 정리 (0) | 2026.02.23 |
| [인프런 김영한] 스프링 트랜잭션 AOP, 커밋/롤백, 전파 (0) | 2026.02.18 |
| [인프런 김영한] 자바 예외 (0) | 2026.02.08 |
| [인프런 김영한] 스프링과 트랜잭션 (0) | 2026.02.05 |
