인프런 재고시스템으로 알아보는 동시성이슈 해결방법 강의를 듣고 작성한 글입니다.
머뭄 프로젝트를 진행하면서 재고 관리 기능을 구현했고, 프로젝트가 끝나고 다시 확인하다가 실시간으로 동시에 재고를 수정할 경우 재고가 일치하지 않는 오류를 발견했습니다. 이 오류를 해결하려고 방법을 찾다가 인프런에서 제공하는 동시성 이슈에 관한 강의를 발견해 학습하게 되었습니다.
간단한 재고 시스템을 만들어 보면서 해결 방법에 대해 알아보겠습니다.
재고 감소 로직을 작성한 자세한 내용은 깃허브에 있습니다.
@Test
public void 재고감소(){
stockService.decrease(1L, 1L);
//100-1 = 99
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(99, stock.getQuantity());
}
테스트 케이스 요청이 1개씩 들어오는 상황에서는 문제 없이 구현됩니다.
그러면 요청이 동시에 여러 개 들어오면 어떻게 될까요...??
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100; //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-(1*100) = 0
assertEquals(0, stock.getQuantity());
}
재고가 0개일 것이라고 예상했지만 실제로는 96개여서 오류가 발생합니다.
Why? 레이스 컨디션이 일어났기 때문
Thread1이 데이터를 가져가서 갱신한 값을 Thread2가 가져가 갱신한다고 예상했지만,
실제로는 Thread1이 데이터를 가져가서 갱신하기 이전에 Thread2가 갱신하기 이전의 값을 가져가게 됩니다.
그리고 Thread1이 갱신을 하고 Thread2도 갱신을 하지만 둘다 같은 재고인 상태에서 1을 줄인 값을 갱신하기 때문에 갱신이 누락되게 됩니다.
이런 문제를 해결하기 위해서는 하나의 스레드가 작업을 완료하면 다른 스레드가 데이터에 접근할 수 있도록 하면 됩니다.
이제 문제를 해결할 수 있는 방법에 대해 알아봅시다.
먼저 Java에서 지원하는 방법으로 문제를 해결해 볼 수 있습니다.
Java에서는 Synchronized 키워드 사용하여 손쉽게 한 개의 스레드만 접근이 가능하도록 할 수 있습니다.
synchronized
를 메서드 선언부에 넣어주면 해당 메서드는 한 개의 스레드만 접근이 가능하게 됩니다.
@Transactional
//재고 감소 메서드
public synchronized void decrease(Long id, Long quantity){
// Stock 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고를 감소
stock.decrease(quantity);
// 갱신된 값을 저장
stockRepository.saveAndFlush(stock);
}
Spring의 Transactional 어노테이션의 동작 방식 때문에 오류가 납니다.
synchronized
는 하나의 프로세스 안에서만 보장이 됩니다.
synchronized
는 각 프로세스 안에서만 보장되기 때문에 결국은 여러 스레드에서 동시에 데이터에 접근이 가능하게 되고, 레이스 컨디션이 발생하게 됩니다.
실제 운영 중인 서비스는 대부분 두 대 이상의 서버를 사용하기 때문에synchronized
는 거의 사용하지 않습니다.
데이터베이스를 활용하여(데이터베이스가 제공하는 Lock을 이용) 데이터 정합성을 맞추는 여러 가지 방법이 있습니다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimistic(Long id);
}
@Lock
이라는 어노테이션을 통해 Pessimistic Lock을 구현할 수 있습니다.@Transactional
public void decrease(Long id, Long quantity){
//Pessimistic Lock을 활용해서 데이터를 가져옴
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
//재고를 감소시킴
stock.decrease(quantity);
//데이터를 저장
stockRepository.save(stock);
}
for update
문구가 있는데, 이 부분이 락을 걸고 데이터를 가져오는 것입니다.@Version
private Long version;
@Version
어노테이션을 이용하여 버전 컬럼을 추가해야 합니다.javax.persistence
패키지에 있는 @Version
어노테이션을 사용해야 합니다!public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
@Transactional
public void decrease(Long id, Long quantity){
//Optimistic Lock을 이용해 데이터를 가져옴
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
//수량을 감소시킴
stock.decrease(quantity);
//데이터를 저장
stockRepository.save(stock);
}
@Component
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService){
this.optimisticLockStockService = optimisticLockStockService;
}
public void decrease(Long id, Long quantity) throws InterruptedException{
while (true){ //업데이트를 실패했을 때 재시도를 해야함
try {
optimisticLockStockService.decrease(id, quantity);
//정상적으로 업데이트 되면 빠져나감
break;
} catch (Exception e) {
//수량 감소 실패 시, 재시도
Thread.sleep(50);
}
}
}
}
get_lock
명령어를 통해 획득할 수 있고 release_lock
명령어를 통해 해제할 수 있다.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
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
public NamedLockStockFacade(LockRepository lockRepository, StockService stockService){
this.lockRepository = lockRepository;
this.stockService = stockService;
}
//decrease 메서드
@Transactional
public void decrease(Long id, Long quantity){
try {
//lock 획득
lockRepository.getLock(id.toString());
//재고 감소
stockService.decrease(id, quantity);
}finally {
//모든 로직이 종료되었을 때, lock 해제
lockRepository.releaseLock(id.toString());
}
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public synchronized void decrease(Long id, Long quantity){
// Stock 조회
Stock stock = stockRepository.findById(id).orElseThrow();
// 재고를 감소
stock.decrease(quantity);
// 갱신된 값을 저장
stockRepository.saveAndFlush(stock);
}
maximum-pool-size: 40
동시성 이슈를 해결하는 대표적인 라이브러리 Lettuce
Redisson
작업 환경을 세팅하고 Lettuce와 Redisson을 활용하여 동시성 이슈를 해결해 보겠습니다.
setnx 명령어를 활용하여 분산락 구현 가능
setnx
: SET if Not eXist
의 줄임말로, 특정 key에 value 값이 존재하지 않을 경우에 값을 설정(set)하는 명령어spin lock 방식
Spring data redis를 이용하면 lettuce가 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 됩니다.
Lettuce를 활용해 재고감소 로직 작성하기
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
//로직 실행 전 키와 setnx 명령어를 활용해 lock
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
//로직 실행 후 unlock 메소드를 통해 lock 해제
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
@Component
public class LettuceLockStockFacade {
private RedisLockRepository redisLockRepository;
private StockService stockService;
public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
this.redisLockRepository = redisLockRepository;
this.stockService = stockService;
}
public void decrease(Long key, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(key)) {
Thread.sleep(100); //lock 획득 시도 실패 시
}
try {
stockService.decrease(key, quantity); //lock 획득 성공 시
} finally {
redisLockRepository.unlock(key);
}
}
}
테스트 케이스 작성하고 테스트 실행
구현이 간단하다는 장점이 있지만, spin lock 방식이므로 redis에 부하를 줄 수 있습니다.
그렇기 때문에 Thread.sleep(100);
으로 lock 획득 재시도 간 텀을 두어야 합니다.
pub-sub 기반으로 Lock 구현 제공
lock 획득 재시도를 기본으로 제공
Redisson를 활용해 재고감소 로직 작성하기
public void decrease(Long id, Long quantity){
//lock 객체 가져오기
RLock lock = redissonClient.getLock(id.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
//lock 획득 실패 시
if(!available){
System.out.println("lock 획득 실패");
return;
}
//lock 획득 성공 시
stockService.decrease(id,quantity);
}catch (InterruptedException e){
throw new RuntimeException(e);
}finally {
lock.unlock();
}
}
테스트 케이스 작성하고 테스트 실행
redis 부하를 줄여준다는 장점이 있지만 구현이 복잡하고 별도의 라이브러리를 이용해야하는 단점이 있습니다.
실무에서는?
- 재시도가 필요한 경우에는 redisson 활용
- 재시도가 필요하지 않은 경우에는 lettuce 활용
Mysql
Redis