-
동시성 문제 해결 방안Spring-Boot/재고시스템으로 알아보는 동시성이슈 해결방법 2023. 4. 24. 09:22
예상
- 스레드 1의 작업이 끝나고 스레드 2의 작업이 이루어질 것이라고 예상
실제
- 스레드 1이 데이터를 가져가서 갱신하기 전에 스레드 2가 데이터를 가져간다.
- 둘 다 갱신을 하지만 quantity가 5이기에 갱신 누락 발생
* 이렇게 둘 이상의 스레드가 공유 데이터에 Access할 수 있고 동시에 변경할려고 할 때 발생하는 문제점이 동시성 문제이다.
1. Synchronized 이용
// @Transactional public synchronized void decrease(Long id, Long quantity) { Stock stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); }
- @Transactional 동작방식으로 인해 테스트 실패
ㆍ클래스를 새로 만들어 실행
ㆍ트랜잭션 종료시점에 데이터 업데이트를 하기 전에 메소드 호출이 가능
문제점
- 자바의 Synchronized는 하나의 프로세스 안에서만 보장된다.
2. DB 이용
Mysql을 활용한 다양한 방법
1. Pessimistic Lock (비관적 락)
- 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법이다.
- exclusive lock 을 걸게되면 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없게 된다.
- 데드락이 걸릴 수 있기 때문에 주의해야 한다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE) @Query("select s from Stock s where s.id=:id") Stock findByIdWithPessimisticLock(Long id);
장점
- 충돌이 빈번하게 일어난다면 Optimistic Lock보다 좋을 수 있다.
- lock을 통해 업데이트를 제어하기 때문에 데이터 정합성이 어느정도 보장
단점
- 별도의 락을 잡기 때문에 성능 감소가 있을 수 있다.
2. Optimistic Lock (낙관적 락)
- 실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법이다.
- 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이터 한다.
- 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은후 작업을 수행해야 한다.
// domain @Version private Long version; // repository @Lock(value = LockModeType.OPTIMISTIC) @Query("select s from Stock s where s.id = :id") Stock findByIdWithOptimisticLock(Long id); // facade : 재시도 로직 public void decrease(Long id, Long quantity) throws InterruptedException { while (true) { try { optimisticLockStockService.decrease(id, quantity); break; } catch (Exception e) { Thread.sleep(50); } } }
장점
- 별도의 락을 잡지 않으므로 Pessimistic Lock 보다 성능상 이점이 있다.
단점
- 업데이트가 실패했을 때 재시도 로직을 개발자 직접 작성해야 한다.
* 충돌이 비번하게 일어난다면 Pessimistic Lock / 충돌이 빈번하게 일어나지 않는다면Optimistic Lock
3. Named Lock
- 이름을 가진 metadata locking 이다.
- 이름을 가진 lock을 획득한 후 해제할 때까지 다른 세션은 이 lock을 획득할 수 없도록 한다.
- 주의할 점으로는 transaction이 종료될 때 lock이 자동으로 해제되지 않는다.
- 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제된다.
- 실제로 사용할 때는 dataSource를 분리하여 사용하는 것을 추천
ㆍ같은 dataSource를 사용하면 커넥션 풀이 부족해지는 현상 발생
// repository @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); // facade @Transactional public void decrease(Long id, Long quantity) { try { lockRepository.getLock(id.toString()); stockService.decrease(id, quantity); } finally { lockRepository.releaseLock(id.toString()); } } // service @Transactional(propagation = Propagation.REQUIRES_NEW) public void decrease(Long id, Long quantity) { Stock stock = stockRepository.findById(id).orElseThrow(); stock.decrease(quantity); stockRepository.saveAndFlush(stock); }
장점
- 주로 분산락을 사용할 때 사용, Pessimistic Lock은 타임아웃을 구현하기 힘들지만 Named Lock은 손쉽게 구현할 수 있다.
- 데이터 삽입시에 정합성을 맞춰야할 때 사용
단점
- 트랜잭션 종료 시에 락 해제와 세션 관리를 잘 해줘야 함으로 주의해서 사용해야한다.
- 실제로 사용할 때는 구현방법이 복잡할 수 있다.
3. Redis 이용
1. Lettuce
- setnx 명령어를 활용하여 분산락 구현
ㆍkey-value 가 기존의 값이 없을 때 set하는 명령어
- spin lock 방식
ㆍretry 로직을 개발자 작성
ㆍ락을 획득할려는 스레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방식
// RedisLockRepository @Component public class RedisLockRepository { private RedisTemplate<String, String> redisTemplate; public RedisLockRepository(RedisTemplate<String, String> redisTemplate) { this.redisTemplate = redisTemplate; } 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(); } }
// facade 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); } }
장점
- 구현이 간단하다.
- spring data redis를 이용하면 lettuce가 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 된다.
단점
- spin lock 방식이기 때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis에 부하가 갈 수 있다.
2. Redisson
- pub-sub 기반으로 Lock 구현 제공
ㆍ채널을 하나 만들고 락을 점유 중인 스레드가 락 획득 대기 중인 스레드에게 해제를 알려주면
안내를 받은 스레드가 락 획득 시도
ㆍ별도의 retry 로직을 작성하지 않아도 된다.
- mvnRepository - Redisson / Spring Boot Starter 의존성 추가
// facade @Component public class RedissonLockStockFacade { private RedissonClient redissonClient; private StockService stockService; public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) { this.redissonClient = redissonClient; this.stockService = stockService; } 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(); } } }
장점
- 락 획득 재시도를 기본으로 제공한다.
- pub-sub 방식으로 구현이 되어있기 때문에 lettuce와 비교했을 때 redis에 부하가 덜 간다.
단점
- 별도의 라이브러리를 사용해야 한다.
- lock을 라이브러리 차원에서 제공해주기 때문에 사용법을 공부해야 한다.
실무에서는 ?
- 재시도가 필요하지 않은 lock 은 lettuce 활용
- 재시도가 필요한 경우에는 redisson 를 사용
4. Mysql 과 Redis 비교하기
Mysql
- 이미 Mysql을 사용하고 있다면 별도의 비용없이 사용가능하다.
- 어느 정도의 트래픽까지는 문제없이 활용이 가능하다.
- Redis보다는 성능이 좋지 않다.
Redis
- 활용중인 Redis가 없다면 별도의 구축비용과 인프라 관리비용이 발생한다.
- Mysql 보다 성능이 좋다