코드는 깃허브에 있습니다.
서론
요구사항에 따라 로직을 구현하다 보면, 종종 최대 구매 수량 제한과 같이 동시성 이슈에 민감한 사항들이 있습니다. 애플리케이션 단에서도 동시성을 제어할 순 있지만, 분산 환경에서는 제약적인 방법이죠.
이럴 때 레디스를 고려하면 좋은 선택이 될 수 있습니다. 레디스는 인메모리 기반으로 동작하기 때문에 성능이 매우 빠르고, 또한 싱글 쓰레드 기반으로 동작하기 때문입니다.
이번 포스팅에서는 레디스의 incr와 decr를 활용해서 동시성 이슈가 발생하지 않는지 테스트 해보려고 합니다. 실제 작성할 코드와 유사한 형태로 테스트를 진행해보려고 해요.
코드로 바로 넘어가기 전에! 단점을 먼저 파악해보면 좋을 것 같습니다.
보통 운영 환경이라면 레디스가 클러스터 구조로 이루어져 있을 텐데요. 이 경우 아래와 같은 상황에서 write 손실이 발생할 수 있습니다. 여기서 write는 incr와 decr가 될 수 있겠죠.
- write가 마스터에 도달하고 정상 응답을 반환하였으나, 슬레이브로 write가 전파되기 전에 다운된 경우.
- 파티션으로 인해 마스터로 연결할 수 없는 경우
- ...
이를 위해 분산락이라는 기능이 존재하는데요. 간략하게 말하자면, 3대 이상의 마스터 중에서 과반수 이상이 락이 걸려야 실질적으로 락이 걸렸다고 판단하는 기능이라고 이해하면 좋을 것 같습니다. 위에 서술한 단점을 효과적으로 방어할 수 있지만, 각각의 마스터가 시간이 서로 틀어질 수 있다는 한계점이 있더라고요.
아무튼 이런 기능이 있음에도 다음의 이유로 incr, decr를 사용하려고 합니다.
- 분산락을 위해 마스터를 3대 이상으로 증설하는 건 리소스 측면에서 부담스럽다.
- 분산락 자체도 한계점이 있긴 하다.
- 분산락으로 인한 성능 저하가 우려된다.
- 사용 사례를 보았을 때 크게 문제 된 경우는 없었던 것 같다.
서론이 길었습니다. 바로 코드를 확인해 보죠
레디스를 활용한 동시성 테스트
1. 환경 세팅
build.gradle.kts
// redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")
testImplementation("com.github.codemonstur:embedded-redis:1.4.3")
// test
testImplementation("io.kotest:kotest-runner-junit5:4.4.3")
testImplementation("io.kotest:kotest-assertions-core:4.4.3")
implementation("io.kotest:kotest-extensions-spring:4.4.3")
로컬이나 도커에 레디스를 띄워서 테스트를 하는 건 개인적으로 선호하지 않기 때문에, embedded redis 의존성을 추가해 주었습니다.
test 관련 의존성은, 코틀린 환경에서 테스트 코드를 직관적으로 작성하기 위한 kotest 의존성을 추가해 주었습니다.
TestRedisConfiguration.kt
@TestConfiguration
class TestRedisConfiguration {
lateinit var redisServer: RedisServer
private val testRedisHost = "127.0.0.1"
private val testRedisPort = 6370
companion object {
const val DEFAULT_DATABASE = 0
}
@PostConstruct
fun postConstruct() {
println("embedded redis start!")
redisServer = RedisServer(testRedisPort)
redisServer.start()
}
@PreDestroy
fun preDestroy() {
println("embedded redis stop!")
redisServer.stop()
}
fun lettuceConnectionFactory(database: Int): LettuceConnectionFactory {
val redisConfig = RedisStandaloneConfiguration().apply {
this.hostName = testRedisHost
this.port = testRedisPort
this.database = database
}
return LettuceConnectionFactory(redisConfig).apply {
this.afterPropertiesSet()
}
}
@Bean
fun testStringRedisTemplate(): StringRedisTemplate {
return StringRedisTemplate().apply {
this.setConnectionFactory(lettuceConnectionFactory(DEFAULT_DATABASE))
this.afterPropertiesSet()
}
}
}
Jedis 대신 Lettuce를 사용해 주었습니다. 그 이유는 Lettuce vs Jedis 글을 참고하면 좋을 것 같아요.
@PostConstruct와 @PreDestroy를 사용하여 레디스 실행과 중단을 제어해 주었습니다.
ConcurrentTaskExecutor.kt
@Component
class ConcurrentTaskExecutor {
fun runConcurrentTasks(numberOfThreads: Int, repeatCountPerThread: Int, task: () -> Unit) {
val startLatch = CountDownLatch(1) // 모든 쓰레드가 준비될 때까지 대기시키기 위한 용도
val doneLatch = CountDownLatch(numberOfThreads)
val executor = Executors.newFixedThreadPool(numberOfThreads)
repeat(numberOfThreads) {
executor.submit() {
try {
startLatch.await() // 모든 스레드가 준비될 때까지 대기
repeat(repeatCountPerThread) {
task()
}
} finally {
doneLatch.countDown() // 작업 완료 처리
}
}
}
startLatch.countDown() // 모든 스레드가 동시에 작업을 시작하도록 함
doneLatch.await() // 모든 스레드가 작업을 마칠 때까지 대기
executor.shutdown()
executor.awaitTermination(1, TimeUnit.MINUTES)
}
}
멀티 쓰레드 환경으로 task를 실행해 주는 로직입니다. 모든 쓰레드를 동시에 실행시키기 위해 CountDownLatch로 대기 시간을 확보한 게 포인트입니다. 단순 for문으로 실행시키면, 마지막 쓰레드가 작업을 실행하려 할 때 이미 첫 번째 쓰레드는 마쳤을 수 있기 때문이에요.
2. 테스트 코드
RedisConcurrencyTest.kt
@SpringBootTest(classes = [TestRedisConfiguration::class])
class RedisConcurrencyTest {
@Autowired
private lateinit var stringRedisTemplate: StringRedisTemplate
@Autowired
private lateinit var concurrentTaskExecutor: ConcurrentTaskExecutor
private val numberOfThreads = 10
private val repeatCountPerThread = 1000
@Test
fun `증감 연산자만을 사용하면 동시성 이슈가 발생한다`() {
// given
var count = 0
// when
concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
count++
}
// then
count shouldNotBe numberOfThreads * repeatCountPerThread
println(count)
}
@Test
fun `레디스 increment를 사용하면 동시성 이슈가 발생하지 않는다`() {
// given
val incrTestKey = "incr-test-key"
stringRedisTemplate.delete(incrTestKey)
// when
concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
stringRedisTemplate.opsForValue().increment(incrTestKey)
}
// then
val count = stringRedisTemplate.opsForValue().get(incrTestKey)!!.toInt()
count shouldBe numberOfThreads * repeatCountPerThread
}
@Test
fun `레디스 decrement를 사용하면 동시성 이슈가 발생하지 않는다`() {
// given
val decrTestKey = "decr-test-key"
stringRedisTemplate.delete(decrTestKey)
// when
concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
stringRedisTemplate.opsForValue().decrement(decrTestKey)
}
// then
val count = stringRedisTemplate.opsForValue().get(decrTestKey)!!.toInt()
count shouldBe -(numberOfThreads * repeatCountPerThread)
}
@Test
fun `레디스 incr와 decr를 사용하여 로직을 구현하면 동시성 이슈가 발생하지 않는다`() {
// given
val incrDecrTestKey = "incr-decr-test-key"
val maxCount = 100
stringRedisTemplate.delete(incrDecrTestKey)
// when
concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
val successIncrement = incrementIfPossible(incrDecrTestKey, maxCount) // 실제 사용할 로직과 동일한 구조로 실행
if (successIncrement) {
Thread.sleep(Random.nextLong(0, 300)) // 0 ~ 300ms 사이의 랜덤한 시간을 멈춤. 실제 로직이 들어갈 자리
}
}
// then
val currentCount = stringRedisTemplate.opsForValue().get(incrDecrTestKey)!!.toInt()
currentCount shouldBe maxCount
}
private fun incrementIfPossible(key: String, maxCount: Int): Boolean {
val incrementedCount = stringRedisTemplate.opsForValue().increment(key)!!.toInt()
if (incrementedCount > maxCount) {
decrementCount(key)
return false
}
return true
}
private fun decrementCount(key: String) {
stringRedisTemplate.opsForValue().decrement(key)
}
마지막 테스트 코드에서 실제 작성하려는 코드와 유사하게 구현하였는데요. 10개의 쓰레드가 1000번씩 수행하더라도, maxCount를 넘지 않은 것을 확인할 수 있었습니다.
참고자료
'데이터베이스 > 레디스' 카테고리의 다른 글
Lettuce vs Jedis (1) | 2023.12.03 |
---|---|
Spring Data Redis 공식문서 읽어보기 (2) | 2023.12.03 |
레디스 사용 시 주의사항 (0) | 2023.10.29 |
레디스란 (0) | 2023.10.25 |