과제를 수행하던 중 동시성 이슈에 대한 처리가 부족하다는 피드백을 받았다.
그동안 말로만 들었고 실무에서도 경험할 일이 없어서 어떻게 문제를 해결하는지 모르고 있었다. 그러다보니 어떤 경우에 동시성 이슈를 처리해야하는지 몰랐고, 과제를 수행할 때 동시성 이슈를 고려하지 못했다.
동시성이슈의 대표적인 예로 재고시스템 로직을 들수가 있는데, 인프런강의를 통해 직접 개발하면서 개념을 습득할 수 있었다.
먼저 DB의 재고 값을 감소 시키는 서비스 로직이다.
@Service
public class StockService {
...
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
@Test
public void 동시에_100개요청() throws InterruptedException {
Stock stock = new Stock(1L, 100L);
repository.saveAndFlush(stock); // 재고 100개 저장
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32); //
CountDownLatch latch = new CountDownLatch(threadCount); // 메인쓰레드가 다른 스레드 작업이 끝날때 까지 기다려 주기 위해 사용했다.
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
service.decrease(1L, 1L); 스래드 100개를 이용해서 비동기로 재고를 1개씩 감소시켜준다.
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = repository.findById(1L).orElseThrow();
assertEquals(0L, stock.getQuantity());
}
100개의 재고가 존재하고 해당 재고를 100번 감소시키면 기대 결과 값은 0개다.
하지만 결과는 fail
동시성 처리를 안해줬기 떄문에 다음과같은 결과가 나왔다.
문제를 해결하기 위해서는 3가지 방법이 있다.
- application에서 Synchronized를 이용해 문제를 해결
- MySql에서 lock을 걸어줘서 문제를 해결
- Redis에서 lock을 걸어줘서 문제를 해결
Synchronized의 문제점
- 한 서버에서 여러 스레드의 접근을 제어할 수 있어서 레이스 컨디션을 해결할 수 있지만 서버가 여러개일 경우에는 레이스 컨디션이 발생할 수 있는 문제가 있다.
- 여러대의 서버를 이용할 경우 DB 락 설정이나, Redis 락을 이용하면 문제를 해결 할 수 있다.
MySql과 Redis의 비교
- 성능: MySql < Redis
- 비용: MySql < Redis
Lettuce VS Redisson
- Lettuce를 이용할 때 락 획득하기 위해 계속 요청을 해준다. 이 spin lock 방식 때문에 성능저하가 발생할 수 있으므로 적절한 딜레이를 통해 개선해줘야 한다. 기본적으로 제공해주는 기능이므로 사용하기 편하다.
- Redisson을 이용하기 위해서는 별도의 라이브러리를 설치해 이용해야 한다. 라이브러리를 학습해야 사용할 수 있다.
- 락 획득시 재시도가 필요하면 Redisson을 고려할만하고, 재시도가 필요하지 않다면 Lettuce 사용을 고려할만하다.
# Redisson
public void decrease(Long key, Long quantity) {
RLock lock = redissonClient.getLock(key.toString());
try {
final boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS); // 락 획득을 기본적으로 제공해준다. 웨이팅 시간을 적절히 설정하지 않는다면 락을 얻지 못할 수도 있다.
if (!available) {
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(key, quantity); // 락 획득성공시 재고 감소 수행
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
#Lettuce
public void decrease(Long key, Long quantity) throws InterruptedException {
while (!repository.lock(key)) { // 락 획득할 때까지 대기
Thread.sleep(100); // 요청시간에 텀을 둬서 성능 저하를 예방한다.
}
try {
service.decrease(key, quantity); // 락 획득 성공시 재고 감소
} finally {
repository.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));
}
이제 문제를 해결할 수 있는 기술을 알게 되었고 Redis 이용방법도 알게되었다.
앞으로 동시성 이슈가 발생할수 있는 서비스 로직을 작성할 때 위 방식들의 장단점을 고려하여 이슈가 발생하지 않도록 작성해야겠다.