동시성 이슈를 해결하기 위한 Redisson 분산락 알아보기(2) - 재고 테스트
1편에서는 동시성 이슈의 관한 이론과 Redisson의 이론적인 부분을 알아봤다면 2편에서는 코드를 통해서 동시성 이슈에 대한 테스트를 진행한 내용에 대해서 정리한다.
동시성 이슈를 해결하기 위한 Redisson 분산락 알아보기(2) - 재고 테스트
설정
의존성 추가
implementation("org.redisson:redisson-spring-boot-starter:3.19.3")
Config 설정 클래스 추가
@Configuration
class RedissonConfig(
val redisProperties: RedisProperties
) {
@Bean
fun redissonClient(): RedissonClient {
val config = Config()
config.useSingleServer().address = "redis://" + redisProperties.host + ":" + redisProperties.port
return Redisson.create(config)
}
}
레디스 설정과 재고 테스트 설정에 필요한 Properties 클래스 추가
@ConstructorBinding
@ConfigurationProperties("spring.redis")
class RedisProperties(
var host: String,
var port: Int,
)
@ConstructorBinding
@ConfigurationProperties("redis.stock")
class StockProperties(
var prefix: String,
)
재고 클래스 추가
class Stock(
val name: String,
val keyId: String,
val amount: Int? = 0
)
재고 관련 기본 함수 추가
재고 테스트를 하기 위해서 기본적인 재고 관련 메서드들을 추가해준다. 레디스 키를 만들어 주는 keyGenerator 함수 / 설정한 갯수만큼 재고를 설정해주는 setStock 함수 / 현재 재고의 수량을 알려주는 currntStock 함수 총 3개를 추가해준다.
@Service
class StockService(
val redissonClient: RedissonClient,
val stockProperties: StockProperties
) {
companion object {
private const val EMPTY_NUMBER = 0
}
val log: Logger = LoggerFactory.getLogger(this::class.java)
fun keyGenerator(domain: String, keyId: String?): String {
val prefix = "${stockProperties.prefix} : $domain: %s"
return String.format(prefix, keyId)
}
fun setStock(key: String?, amount: Int) {
redissonClient.getBucket<Int>(key).set(amount)
}
fun currentStock(key: String?): Int {
val result = redissonClient.getBucket<Int>(key).get()
log.info("현재 재고 수량 $result")
return result
}
}
기본 테스트 설정
이후 재고 테스트를 진행한 테스트 클래스 작성 이후 아래와 같이 setup 함수를 작성해준다. setUp에서는 키 설정과 재고 설정을 해준다.
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class StockServiceTest(
private var stockService: StockService
) {
val log: Logger = LoggerFactory.getLogger(this::class.java)
private lateinit var stockKey: String
private lateinit var stock: Stock
@BeforeEach
@DisplayName("레디스 키와 재고 설정")
fun stockSetup() {
val name = "apple"
val keyId = "001"
val amount = 100
val apple = Stock(name, keyId, amount)
stockKey = stockService.keyGenerator(apple.name, apple.keyId)
this.stock = apple
stockService.setStock(stockKey, amount)
}
}
이 후 현재 재고 수량을 확인하는 테스트 케이스 작성 후 setup을 통해 제대로 재고가 설정되는지 확인해준다.
@Test
@DisplayName("현재 재고 수량 확인")
fun currentStock() {
val amount: Int? = stock.amount
val currentCount = stockService.currentStock(stockKey)
assertEquals(amount, currentCount)
}
분산락을 이용한 재고 감소 구현하기
이제 정상적으로 설정이 되는 것을 확인했으니 분산락을 통해 재고를 감소시키는 함수를 구현해본다.
아래 코드에서 보듯이 처음에 redissonClient를 통해 락을 생성 후 락을 걸고 setStock을 통해 재고를 감소 시키고 마지막으로 락을 해제하는 것을 볼 수 있다.
1편에서 알아보았듯이 1은 waitTime으로 락을 사용할 수 있을 때까지 해당 값 만큼 기다리며, 3은 leaseTime으로 해당 값 만큼 락을 점유하는 시도를 한다. TimeUnit.SECONDS에서 알 수 있듯이 해당 값들은 초로 인식된다.
fun decrease(key: String, count: Int) {
val lockName = "$key:lock"
val lock = redissonClient.getLock(lockName)
val worker = Thread.currentThread().name
try {
if (!lock.tryLock(1, 3, TimeUnit.SECONDS)) return
val stock = currentStock(key)
if (stock <= EMPTY_NUMBER) {
log.info("[$worker] 현재 남은 재고가 없습니다. (${stock}개)")
return
}
log.info("현재 진행중 Worker : $worker & 현재 남은 재고 : ${stock}개")
setStock(key, stock - count)
} catch (e: InterruptedException) {
e.printStackTrace()
} finally {
if (lock != null && lock.isLocked) {
lock.unlock()
}
}
}
락을 통해 재고를 감소 시키는 decrease() 함수를 검증하기 위해 아래 테스트 케이스를 작성해준다. 한번 실행하였을 때 설정한 2개 만큼 재고가 감소되는 것을 확인 할 수 있다.
@Test
@DisplayName("상품 재고 카운트만큼 감소")
fun decreaseStockByCount() {
val amount: Int? = stock.amount
val count = 2
stockService.decrease(stockKey, count)
val currentCount = stockService.currentStock(stockKey)
if (amount != null) {
assertEquals(amount - count, currentCount)
}
}
자 그럼 100명의 사람이 동시에 접근해서 2개씩 구매를 한다고 했을때의 동시성 테스트 케이스를 작성해보자.
분산락을 이용한 재고 감소 테스트
사람의 명수 만큼 쓰레드를 생성해준다.이렇게 생성한 쓰레드를 실행시켜 동시성 테스트를 진행할 수 있다.
@Test
@DisplayName("락 있는 경우 재고 감소 테스트")
@Throws(InterruptedException::class)
fun decreaseStockByLock() {
log.info(":: 락 있는 경우 재고 감소 테스트")
val people = 100
val count = 2
val soldOut = 0
val workers = mutableListOf<Thread>()
val countDownLatch = CountDownLatch(people)
for(i in 1..people) {
val thread = Thread(Worker(stockKey, count, countDownLatch))
log.info("$i 번 쓰레드 생성 - ${thread.name}")
workers.add(thread)
}
workers.forEach {
log.info("$it 쓰레드 start() ")
it.start()
}
countDownLatch.await()
val currentCount = stockService.currentStock(stockKey)
assertEquals(soldOut, currentCount)
}
여기서 Worker Class는 Runnable 을 상속 받아 run() 메서드를 구현해줍니다.
private inner class Worker(
private val stockKey: String,
private val count: Int,
private val countDownLatch: CountDownLatch
): Runnable {
override fun run() {
stockService.decrease(this.stockKey, count)
countDownLatch.countDown()
}
}
위에서 쓰레드를 생성할 때 Runnable을 실행 개체로 사용하여 만들게 되면 쓰레드 시작 시 Runnable 객체의 run() 메서드가 호출된다.
로그를 확인하기 이전에 코드를 보고 어떻게 로그를 찍힐지 예상을 해보자.
먼저 쓰레드가 100개 생성되는 로그가 찍힐 것이고, 이후로는 쓰레드 start() 로그가 찍힐 것이다. 그리고 락이 걸린 감소가 실행되면서부터 현재 재고 수량과 감소를 반복하며 재고가 0이 되는 순간 현재 남은 재고가 없다는 로그가 찍힐 것이다.
락이 없는 경우 재고 감소 구현 및 테스트
자 그렇다면 락이 없는 경우에 해당 동작을 동일하게 하면 어떻게 될까?
아래에는 락 없이 구현한 decrease() 함수이다.
fun decreaseNoLock(key: String?, count: Int) {
val worker = Thread.currentThread().name
val stock = currentStock(key)
if (stock <= EMPTY_NUMBER) {
log.info("[$worker] 현재 남은 재고가 없습니다. (${stock}개)")
return
}
log.info("현재 진행중 Worker : $worker & 현재 남은 재고 : ${stock}개")
setStock(key, stock - count)
}
락이 없는 경우의 테스트도 동일하게 작성해준다.
@Test
@DisplayName("락 없는 경우 재고 감소 테스트")
@Throws(InterruptedException::class)
fun decreaseStockByNoLock() {
log.info(":: 락 없는 경우 재고 감소 테스트")
val people = 100
val count = 2
val soldOut = 0
val workers = mutableListOf<Thread>()
val countDownLatch = CountDownLatch(people)
for(i in 1..people) {
val thread = Thread(NoLockWorker(stockKey, count, countDownLatch))
log.info("$i 번 쓰레드 생성 - ${thread.name}")
workers.add(thread)
}
workers.forEach {
log.info("$it 쓰레드 start() ")
it.start()
}
countDownLatch.await()
val currentCount = stockService.currentStock(stockKey)
assertNotEquals(soldOut, currentCount)
}
private inner class NoLockWorker(
private val stockKey: String,
private val count: Int,
private val countDownLatch: CountDownLatch
): Runnable {
override fun run() {
stockService.decreaseNoLock(this.stockKey, count)
countDownLatch.countDown()
}
}
락이 없는 경우에도 로그를 확인하기 이전에 코드를 보고 어떻게 로그를 찍힐지 예상을 해보자.
먼저 쓰레드가 100개 생성되는 로그는 락이 있는 경우와 마찬가지로 순서대로 찍힐 것이다.
하지만 그 이후로는 로그가 어떻게 찍힐까?
해당 경우는 락이 없으므로 여러 쓰레드가 동시 실행되면서 로그가 뒤죽박죽 찍힐 것이다. 결과를 확인해보자.
100번 쓰레드까지는 For문을 통해 순차적으로 찍힌 것을 확인 할 수 있지만 그 이후로는 쓰레드 시작과 재고 감소가 각각의 쓰레드별로 순서 없이 실행되는 것을 확인할 수 있다.