늘 동시성 이슈에 대해 어떻게 처리할 수 있을지 궁금했다.
“데이터를 수정하려고 하면 DB 락을 걸어버리는 걸까? 찰나의 순간에 100명이 동시에 접근하게 되면?”
“티켓팅이나 수강신청은 ms단위로 처리 해야 할텐데 락을 걸면 서비스 사용에 문제가 생기지 않을까?”
이런 질문을 어느정도 해결할 수 있는 인프런 강의를 찾았고 수강하였다.
본 문서는 재고시스템으로 알아보는 동시성이슈 해결방법을 듣고 정리한 내용이다.
아래 테스트 코드를 보자.
재고가 100개 있는데 1개를 감소 하였으므로 재고는 99개가 되어야 한다.
decrease메소드는 의미가 명확하므로 설명하지는 않겠다.
@Test
public void decrease_test() {
stockService.decrease(1L, 1L);
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(99, stock.getQuantity());
}
하지만 100명이 동시에 주문하게 된다면 문제가 발생한다.
100개의 thread에서 decrease했음에도 불구하고 재고는 0이 아닌, 96개가 남는다.
@Test
public void 동시에_100명이_주문() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (100 * 1) = 0
assertEquals(0, stock.getQuantity());
}
참고) latch.countDown()은 현재 latch의 수를 감소하고, latch.await()는 사용한 Thread가 다른 Thread의 작업을 기다리게 해준다. 테스트 결과와 무관함만 알아두자.
Race Condition이 발생하여 테스트가 실패한다.
Thread1이 재고를 감소하지 않았음에도 불구하고 Thread2도 데이터를 조회 후 감소 하였기에 정상적으로 재고가 감소하지 않았다.
Race Condition, Semaphore, Mutex의 내용은 다른 게시글로 정리해보도록 하겠다.
이를 해결하기 위해 한 데이터에 하나의 Thread만 접근하도록 설정한다면 해결될 것이다.
Java의 Synchronized는 하나의 Thread가 진행될 때 다른 Thread는 간섭하지 못한다.
그렇기에 stockService.decrease()를 수정하면 테스트가 성공은 한다.
// @Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
메소드 블럭에 synchronized를 추가하였고 @Transactional에는 주석처리를 하였다.
왜 @Transactional에 주석처리를 해야 하는가? @Transactional는 AOP임을 다시 떠올려보자.
@Transactional도 메소드이다. synchronized를 decrease에만 선언하였으므로 @Transactional에는 적용되지 않는다.
Synchronized를 사용해도 @Transactional에 동기화 처리를 할수는 없고 @Transactional이점을 이용하지도 못하는 문제가 발생한다. 추가적으로 Synchronized는 하나의 프로세스에 대해서만 동기화를 보장한다. 운영 환경에선 2개 이상의 WAS를 사용해도 여전히 문제가 남아있다.
경합이 발생하지 않으리라 예상. @Version을 이용하여 정합성을 맞춘다. Version과 함께 조회 후, 데이터가 업데이트 되면 Version을 증가한다. 데이터가 커밋되기 전에 다른 Thread에서 데이터를 업데이트 하려고하면 조회한 version이 일치하지 않아 fail이 발생한다.
@Lock(value = LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
경합이 발생할거라 생각하고 Lock을 걸어서 정합성을 맞춘다. S-Lock(다른 요청이 오면 읽기만 가능하고 수정은 불가능), E-Lock(다른 요청이 읽는것도 불가능)을 걸수 있다. 데드락이 발생할 수 있으니 유의해서 사용하자.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id=:id")
Stock findByIdWithPessimisticLock(Long id);
사용자가 직접 락을 요청하고 해제한다. 직접 구현해야 하는 번거로움 때문에 실무에서 사용을 많이 할까 싶다. 또한 MySQL에서만 제공하기에 환경이 다를경우 사용이 불가능 하다.
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
@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);
Lock을 잘 이해하고 사용한다면 Redis를 사용하지 않고도 동시성 문제를 해결할 수 있다. 다만 Redis는 성능이 MySQL보다 더 좋다는것을 알아두자.
Thread1이 setnx로 Lock을 걸어둔다. Thread2가 접근하려고 해도 Thread1이 Lock을 반환하지 않았으므로 n초 후 다시 시도한다. 이후 Thread1이 Lock을 해제해야 Thread2가 데이터에 접근 가능해진다.
public void decrease(Long key, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(key)) {
Thread.sleep(100);
}
try {
stockService.decrease(key, quantity);
} finally {
redisLockRepository.unlock(key);
}
}
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));
}
Lettuce는 계속해서 Lock이 해제되었는지 확인해야 하지만 Redisson에선 메세지 채널이 있으므로 계속 기다리지 않아도 된다.
public void decrease(Long key, Long quantity) {
RLock lock = redissonClient.getLock(key.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(key, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
Spring Boot에서 lettuce는 기본 라이브러리로 제공하고 있기에 운영비용이 낮게 사용 가능하다. 다만 많은 thread가 접근하여 lock 획득 대기 상태라면 redis에 부하가 갈수있다. 대신 Redisson은 구현이 어렵지만 Redis에는 부하가 덜 간다.
요약하자면 재시도가 필요하지 않다면 lettuce를, 재시도가 필요하면 redisson을 활용하자.