ps [option] : 현재 실행 중인 프로세스 목록 및 상태 출력

  • ps aux : 실행 중인 모든 프로세스 확인
  • ps aux | grep [프로세스명] : '프로세스명'에 해당되는 프로세스 확인

'명령어' 카테고리의 다른 글

k8s 명령어  (0) 2023.06.11
도커 명령어  (0) 2023.06.11
코드는 깃허브에 있습니다.

 

서론

요구사항에 따라 로직을 구현하다 보면, 종종 최대 구매 수량 제한과 같이 동시성 이슈에 민감한 사항들이 있습니다. 애플리케이션 단에서도 동시성을 제어할 순 있지만, 분산 환경에서는 제약적인 방법이죠.

이럴 때 레디스를 고려하면 좋은 선택이 될 수 있습니다. 레디스는 인메모리 기반으로 동작하기 때문에 성능이 매우 빠르고, 또한 싱글 쓰레드 기반으로 동작하기 때문입니다.

 

이번 포스팅에서는 레디스의 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

Lettuce란?

Lettuce란, 동기와 비동기 통신을 모두 지원하는 논블로킹 레디스 자바 클라이언트이다.

 

 

Jedis란?

Jedis란 사용 편의성을 위해 설계된 레디스 자바 클라이언트이다. 동기식으로만 작동하며, 다른 Redis 클라이언트에 비해 가볍다는 특징을 가지고 있다.

 

 

Lettuce vs Jedis

  • 성능 : Lettuce는 동기와 비동기 통신 모두를 지원하지만, Jedis는 동기식 통신만 지원한다. 따라서 대량의 요청과 응답 처리에 있어서, Lettuce가 더욱 유리하다. TPS, CPU, Connection 수, 응답속도 등 모든 면에서 Lettuce가 우위에 있다.
  • thread safe : Lettuce는 thread safe 하기 때문에, 멀티 쓰레드 환경에서 단일 Lettuce 인스턴스를 사용해도 문제없다. 하지만 Jedis는 thread safe 하지 않기 때문에, 여러 쓰레드 간에 단일 Jedis 인스턴스를 공유하지 말아야 한다. 
    • Jedis를 멀티 쓰레드 환경에서 사용하려면 커넥션 풀을 사용해야 한다. 하지만 1) 레디스 자체의 커넥션 수에 제한이 걸리거나 2) 합리적인 커넥션 수를 초과하지 않기 위해, 커넥션 수를 제한해야 하는 상황이 있다는 점을 유의해야 한다.

 

즉, 성능과 쓰레드 안정성의 측면을 고려하면 Lettuce가 더 나은 선택이다.

 

 

참고자료

+ Recent posts