데이터베이스/기타

[동시성제어] MySQL과 Redis에서 동시성 제어하기

lazy man 2023. 8. 27. 15:25

동시성제어란 동시에 실행되는 여러 개의 트랜잭션이 작업을 "성공적으로" 마칠 수 있도록 트랜잭션의 실행 순서를 제어할 수 있는 기법이다. 동시성 제어를 하지 않으면 다음과 같은 상황이 발생할 수 있다.

 

이벤트 상품의 재고가 100개가 있고 구매자는 각 1개씩만 구매할 수 있다. 즉 100명에게만 판매하려고 했는데 이벤트가 끝나고보니 구매자는 150명인 상황이다. 늦게 주문한 50명에게 주문 취소 안내를 나가야만 한다 ㄷㄷ..

 

위와 같은 문제를 예방하기 위해서는 동시성을 제어해야 하는데 동시성을 제어하는 방법에는 Application Level, Database, Lock, Redis를 이용하는 방법이 있다.

 

1. 애플리케이션에서 동시성제어


1-1. synchronized

자바에서 지원하는 synchronized 키워드를 이용하여 동시성을 제어할 수 있다. 아래는 synchronized 키워드를 이용하여 재고감소 로직을 처리한 것이다.

    public synchronized void decrease(Long id, Long quantity) {
        // 1. 재고조회
        Stock stock = stockRepository.findById(id).orElseThrow();
        
        // 2. 재고감소
        stock.decrease(quantity);
		
        // 3. 감소된 재고 DB에 반영
        stockRepository.saveAndFlush(stock);
    }

synchronized는 프로세스를 기준으로 동시성을 제어하기 때문에 1대의 서버에서 서비스를 운영하면 동시성 제어가 가능하지만 분산화된 서버 환경에서는 동시성을 제어할 수 없는 단점이 있다.

 

 

2. Database Lock


2-1. Pessimistic Lock

비관적 락이란 동시성 문제가 발생할 것을 가정하고 트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 트랜잭션을 시작하는 방법이다. 즉 Shared Lock을 걸게되면 다른 트랜잭션에서 읽을 수는 있지만 Lock이 풀릴 때까지 쓰기는 안되고, Exclusive Lock을 걸게되면 다른 트랜잭션에서는 Lock이 풀릴 때까지 읽지도 쓰지도 못하고 대기하게 된다.  

 

다음은 서비스에서 Exclusive Lock을 취득하여 동시성을 제어하는 예시이다. LockModeType을 PESSIMISTIC_READ로 설정하면 Shared Lock을 사용한다.

public interface StockRepository extends JpaRepository<Stock, Long> {
	// Exclusive Lock
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(Long id);
}
// 서비스에서 비관적락 사용
@Transactional
public void decrease(Long id, Long quantity) {
    Stock stock = stockRepository.findByIdWithPessimisticLock(id);
    stock.decrease(quantity);
    stockRepository.saveAndFlush(stock);
}

 

2-2. Optimistic Lock

낙관적 락이란 동시성 문제가 발생하지 않을 것이라고 가정하는 방식이다. 락을 사용하지 않으며 동시성 문제가 발생했을 때 적절한 조치를 통해 동시성을 제어한다. version을 저장하 필드가 필요하며 JPA에서는 @Version 애노티에션을 이용하여 낙관적 락을 제어할 수 있다.

// repository
public interface StockRepository extends JpaRepository<Stock, Long> {
    @Lock(LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithOptimistickLock(Long id);
}
@Entity
public class Stock {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    @Version
    private Long version;
}

JPA에서는 낙관적 락을 사용할 때 읽을 때의 버전과 쓰기할 때 버전을 비교하는데 동시성 문제가 발생하는 경우 재처리하기 위한 코드가 별도로 필요하다.아래 코드는 낙관적 락을 사용했을 때 동시성 문제가 발생하여 예외가 발생하면 재처리를 시도하는 코드이다.

@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);
            }
        }
    }
}

 

2-3. Named Lock

이름을 가진 락으로 트랜잭션이 종료될 때 락이 자동으로 해제되지 않으며 선점시간이 끝나야 락이 해제되기 때문에 주의가 필요하다. 아래 코드는 key에 대해서 락을 얻고, 해제하는 코드이다. decrease 메소드에서 락을 사용 후 finally에서 락을 해제하는 것을 볼 수 있다.

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;
    }

    public void decrease(Long id, Long quantity) {
        try {
            lockRepository.getLock(id.toString());
            stockService.decrease(id, quantity);
        } finally {
            lockRepository.releaseLock(id.toString());
        }
    }
}

 

 

3. Redis 분산


레디스를 이용하여 락을 제어하는 방법에는 Lettuce와 Redisson을 이용하는 방법이 있다.

3-1. Lettuce 방식

Lettuce는 setnx 명령어를 사용하여 락을 제어한다.spin lock 방식이기 때문에 lock 획득에 실패했을 때 lock을 얻기 위한 재시도 로직을 개발자가 구현해야 한다. spin lock이란 락을 사용할 수 있는지 반복적으로 확인하는 것을 말한다.

 

아래 예제에서는 LuttuceLockStockFacade에서 0.1초마다 Lock 획득을 시도하면서 키를 Redis에 저장한다. Lock 획득 이후 재고감소 로직이 완료된 후 Redis에서 키를 삭제하고 있다.

@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();
    }
}
@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 id, Long quantity) throws InterruptedException {
        while (!redisLockRepository.lock(id)) {
            Thread.sleep(100);
        }

        try {
            stockService.decrease(id, quantity);
        } finally {
            redisLockRepository.unlock(id);
        }
    }
}

 

3-2. Redisson 방식

Redisson은 pub-sub 기반으로 락을 제어한다. pub-sub 방식이란 채널에서 한 쓰레드가 lock을 다 사용하면 lock 획득을 기다리는 쓰레드에게 lock이 해제되었음을 알려주는 방식이다. 때문에 Lettuce와 달리 재시도 요청을 위한 로직을 구현할 필요가 없다.

 

아래 예제에서는 RedissonClient를 통해 락 획득을 시도하고 있다. 이미 선점한 쓰레드에서는 1초 동안 락을 점유할 수 있고, Lock 획득을 기다리는 쓰레드에서는 20초 이상 Lock을 획득하지 못하는 경우 로직을 수행하지 못하고 종료된다. 

@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 id, Long quantity) {
        RLock lock = redissonClient.getLock(id.toString());
        try {
            boolean available = lock.tryLock(20, 1, TimeUnit.SECONDS);
            if (!available) {
                System.out.println("lock 획득 실패");
                return;
            }
            stockService.decrease(id, quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}