시작하며
인프런의 모든강의가 30% 할인 하길래 동시성관련 강의를 구매 후 공부한 내용을 기록하였다. [강의]
동시성 문제란?
동시성 문제란 여러 쓰레드들이 공유 자원에 대한 경쟁을 벌여 의도하지 않은 결과를 말한다.
강의는 상품의 재고에대한 동시성문제를 다루는 내용이다.
재고 감소로직의 순서로는
1) 재고 감소 로직은 해당 ID값을 통해 엔티티를 조회
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
2) 요청 재고가 기존 재고보다 많지 않다면, 재고를 감소하는 로직이다.
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
만약 1번이라는 상품에 재고가 100개 있을경우 1개를 감소시키면 재고가 99개가 될것이다.
@Test
@DisplayName("재고의 수량만큼 재고가 감소 테스트")
void decrease() {
// given
stockService.decrease(1L, 1L);
// when
Stock stock = stockRepository.findById(1L).orElseThrow();
// then
assertEquals(stock.getQuantity(), 99);
}
하지만 여러 쓰레드가 재고를 감소한다면 결과는 달라진다.
@Test
@DisplayName("재고의 수량 감소 - 동시성 문제")
void concurrent_decrease() throws InterruptedException {
// given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
// when
Stock stock = stockRepository.findById(1L).orElseThrow();
// then
assertEquals(0, stock.getQuantity());
}
100 요청했으니, 재고가 0이 되기를 기대하지만 테스트는 실패한다.
이유는 스레드 1번이 재고를 조회 후 재고 감소하기전 다른 스레드 2번이 재고를 조회하기에 결과값이 다르게 발생한다.

해결 방법으로는 공유자원에 하나의 스레드만 접근하도록 하면된다!
Synchronized
synchronized를 메소드에 명시해주면 하나의 스레만 접근이 가능하다.
공유되는 자원의 Thread-safe를 하기 위해, synchronized로 스레드간 동기화를 시켜 Thread-safe하게 만들어 준다.
여기서 알게된 사실로는 아래처럼 synchronized를 사용한다해도 해결되지않는다.
@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
이유는 아래와 같이 lock을 반납한다하더라도, Transaction이 종료되지 않았기 때문에 DB에 반영되지 않았기 때문이다.
Transaction 시작
lock 획득
비즈니스 로직 수행
lock 반납
Transaction 종료
@Transactional 어노테이션을 주석달고 실행한다면 테스트가 통과한다.
// @Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
synchronized의 문제점
자바의 synchronized는 하나의 프로세스 내에서만 보장이 된다.
즉 서버가 1대일 경우는 문제없지만, 서버가 2대 이상일경우 Thread-safe하지않다.

DB Lock
Pessimistic Lock(비관적 락)
비관적 락은 충돌이 발생할 확률이 높다고 가정하여, 하나의 트랜잭션이 자원에 접근시 락을 걸고, 다른 트랜잭션이 접근하지 못하게 한다.
DB에서 Shared Lock(공유, 읽기 잠금) 이나 Exclusive Lock(베타, 쓰기 잠금)을 건다.
Shared Lock은 다른 트랜잭션에서 읽기만 가능하고 Exclusive Lock는 다른 트랜잭션에서 읽기, 쓰기가 둘다 불가능하다.
장점 : 충돌이 자주 발생하는 환경에 대해서는 롤백 횟수를 줄일 수 있으므로, 성능적 이점이 있다.
단점 : 동시 처리 성능 저하 및 교착 상태(DeadLock) 발생 가능성이 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
Optimistic Lock(낙관적 락)
낙관적 락은 데이터에 락을 걸지않고, 동시성 문제가 발생하면 발생한 시점에 버전을 이용하여 관리한다.
장점 : 충돌이 안난다는 가정하에, 동시 요청에 대해서 처리 성능이 좋다.
단점 : 충돌이 자주발생할 경우 롤백처리에 비용이 많이들며, 구현이 복잡하다.
Entity에@Version 추가
@Entity
@NoArgsConstructor
@Getter
@ToString
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
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;
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
log.info("=== Thread Sleep ===");
Thread.sleep(50);
}
}
}
}
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
Redis
Lettuce
Lettuce는 Netty기반의 Redis Client이며, 요청을 논블로킹으로 처리하여 높은 성능을 가진다.
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key),"lock");
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
lock() 메서드 내부에서 사용하는 setIfAbsent()를 통해 setnx를 사용하여, 값이 없다면 값을 set한다.
이어서 재고감소 로직이다.
1. SprinLock 방식으로 락을 얻기 위해 시도하고,
2. 락을 얻는다면 재고 감소 로직을 실행하고,
3. 학을 해제해주는 방식
@Component
@RequiredArgsConstructor
public class LettuceLockFacade {
private final RedisLockRepository lockRepository;
private final StockService stockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (!lockRepository.lock(id)) {
Thread.sleep(100);
}
try {
stockService.decrease(id,quantity);
} finally {
lockRepository.unlock(id);
}
}
}
장점 : 별도의 설정없이 간단히 구현가능하다.
단점 : Sprin Lock 방식이, Lock을 얻을때까지 Lock을 얻기 위해 시도하기 때문에, Redis에 부하를 준다.
Redisson
Redis는 Pub/sub 기능을 제공해준다. Redisson에서는 Pub/Sub 기반의 분산락을 이미 구현하여 제공해주고있다.
의존성 추가
implementation 'org.redisson:redisson-spring-boot-starter:3.27.1'
@Component
@RequiredArgsConstructor
@Slf4j
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 {
boolean result = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!result) {
log.info("Lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
장점 : Redisson에서 이미 구현되어 있기때문에, 별도의 구현 로직이 필요하지않다. 또한, Lettuce Sprin Lock에 비해 분산락으로 Redis에 부하를 덜 준다.
단점 : 별도의 의존성 주입이 필요하다.