이번 트렐로를 제작하는 프로젝트를 시작했는데 필수로 구현해야할 기능은 구현을 했고 이제 컬림이나 컬럼에 있는 카드를 옮길때 동시성 문제가 생길수 있기때문에 어떻게 동시성제어를 하면 좋을까 공부를했다.
동시성 제어란 동시에 실행되는 여러개의 트랜젝션이 성공적으로 작업을 수행 할 수 있도록 실행 순서를 제어하는 기법이다.
재고관리를 예를 들어보자.
총 100권의 책이 있고 30명의 사용자들이 동시에 1권씩 책을 구매하기 버튼을 눌렀을 때 남은 책의 권수는 70권이어야한다.
하지만 동시성제어를 하지 않았다면 남은 책의 권수는 99권이 되어버린다.
이런 문제가 발생하기 때문에 동시성 제어를 한다면
트랜젝션의 직렬성을 보장 할 수 있으며, 공유도 최대, 응답시간 최소, 시스템 활동의 최대 보장, 데이터의 무결성과 일관성을 보장할 수 있다.
public synchronized void decrease(Long id, Long quantity){
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
메소드에 synchronized를 붙여주면 synchronized를 이용한 동시성 제어가 가능하다.
예를들어 책이 5권이 있다고 가정을 하자. 1번 서버가 10시에 책을 구매하려고 데이터에 접근해서 10시 5분에 구매 동작을 끝낸다고 했을때 10시 ~ 10시 5분 사이에 2번 서버가 책을 구매했을때에는 책이 5권이 있던 시점이기 때문에 2대의 서버에서 총 2권의 책을 샀기 때문에 3권의 책이 남아야하는데 총 4권이 남게된다.
즉, synchronized는 하나의 프로세스만 지원한다는 것을 알 수 있고 2대이상의 서버를 운영하게 되면 동시성 제어에 제약이 생긴다.
Optimistic Lock은 낙관적 락이라고도 하며 version을 통해 동시성 제어를 구현한다
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
public Stock() {
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("foo");
}
this.quantity -= quantity;
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public Long getQuantity() {
return quantity;
}
}
@Service
public class OptimisticLockStockService {
private StockRepository stockRepository;
public OptimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
version을 이용하여 동시성 제어를 구현한다.
예를들어 1번 서버와 2번 서버가 version이 1인 데이터에 동시에 접근하고 1번 서버가 책을 먼저 구매했을때 버전을 1 증가시켜 version이 2가된다. 그 다음으로 책의 구매를 끝낸 2번 서버는 version이 1인 데이터에 접근했고 현재 version이 2이므로 책 구매에 실패하게 된다.
Optimistic Lock은 Lock을 잡지 않으므로 Pessimistic Lock에 비해 성능상 이점이 있지만, 업데이트에 실패했을때 재시도 로직을 별도로 구현해야한다는 번거로움이 있다.
Pessimistic Lock은 비관전 락이라고도 부르며 실제 데이터에 Lock을 걸어 데이터 정합성을 맞춘다.
exclusive Lock을 걸게되면 다른 트랜젝션에서는 Lock이 해제되기전까지 데이터에 접근 할 수 없게된다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id =:id")
Stock findByIdWithPessimisticLock(Long id);
}
@Service
public class PessimisticLockStockService {
private final StockRepository stockRepository;
public PessimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity){
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
예를 들어 1번 서버가 책을 구매하기 위해 데이터에 Lock을 걸면 2번 서버에서는 1번 서버가 데이터를 모두 구매하고 Lock을 해제하기 전까지 데이터에 접근 할 수 없게된다.
충돌이 빈번하게 일어난다면 Optimistic Lock보다 성능이 좋으며,
Lock을 통해 업데이트가 일어나기때문에 데이터 정합성에 더 좋다.
하지만 별도로 Lock을 잡기 때문에 성능 저하가 있을수 있다.
Named Lock은 이름을 가진 MetadataLock이며 한 세션이 Lock을 획득한 후 다른 세션은 Lock이 해제될때까지 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);
}
@Service
public class StockService {
private StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity){
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
@Transactional
public void decrease(Long id, Long quantity){
try{
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
주의할점으로는 Transaction이 종료될때 자동으로 Lock이 해제되지 않으므로 별도의 명령을 통해 Lock을 해제하거나 선점시간이 끝나야 Lock이 해제된다.
MySQL에서는 gap-lock 명령어를 통해 Lock을 획득할 수 있고 named-lock을 통해 Lock을 해제할 수 있다.
부모의 트랜젝션과 별도로 수행되어야하기때문에propagation = Propagation.REQUIRES_NEW을 설정해주어야한다.
NamedLock은 주로 분산락을 구현할 때 사용되며 타임아웃을 손쉽게 구현할 수 있기 때문에 Pessimistic Lock보다는 Named Lock을 사용하는것이 좋다.
하지만 트랜젝션 종료시 락 해제, 세션관리를 잘 해야하기 때문에 주의해서 사용해야하며 구현방법이 다소 복잡할 수 있다.
Lettuce는 redis를 활용한 동시성 제어 기법이다.
setnx 명령어를 활용한다.
setnx란
set if not exist의 줄임말로 특정 key값이 존재하지 않다면 set하라는 명령어이다.
spin lock 방식을 활용한다.
spin lock이란
Lock을 획득하려는 쓰레드가 Lock을 획득할 수 있는지 반복적으로 확인하면서 Lock을 획득하는것을 말한다
public void decrease(Long id, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(id)) {
Thread.sleep(100);
}
try {
stockService.decrease(id, quantity);
} finally {
redisLockRepository.unlock(id);
}
}
@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();
}
}
쓰레드1이 키 값이 1인 데이터에 value값을 set하려고 한다.
키 값이 1인 데이터에 value값이 없으므로 set에 성공한다.
쓰레드2가 키 값이 1인 데이터에 value값을 set하려고하자 redis에는 이미 키 값이 1인 데이터가 있으므로 실패를 리턴하게 된다.
쓰레드2는 Lock 획득에 실패하였으므로 일정시간이 지난후에 다시 Lock 획득을 시도한다.
즉, Lettuce는 Lock을 획득할때까지 로직을 작성해줘야한다.
락 획득을 위해 대기중인 쓰레드가 많다면 redis에 부하가 갈 수 있다.
Redisson은 pub-sub방식을 이용하여 동시성 제어를 구현한다.
pubsub 방식이란
채널을 하나 만든 후에 Lock을 가지고 있는 쓰레드가 Lock을 해제하면 Lock을 획득하기위해 대기하고있는 쓰레드에게 해제를 알려주면 안내를 받은 쓰레드가 Lock을 획득하는 방식이다.
public void decrease(Long id, Long quantity){
RLock lock = reddisonClient.getLock(id.toString());
try{
boolean available = lock.tryLock(10,1, TimeUnit.SECONDS);
if(!available){
System.out.println("lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
}catch(InterruptedException e){
throw new RuntimeException(e);
}finally {
lock.unlock();
}
}
Redisson은 Lettuce와 다르게 리트라이 로직을 별도로 작성하지않아도 되며 pub-sub방식으로 구현되어있기 때문에 Lettuce에 비해 redis에 부하가 덜 간다는 장점이 있다.
만약 재시도가 필요하다면 Reddison을, 재시도가 필요하지않다면 Lettuce를 사용한다.