[위드마켓 개발기] 위드마켓에 논블로킹을 적용시켜보자

Doccimann·2022년 7월 12일
1

위드마켓 개발기

목록 보기
6/10
post-thumbnail

🤗 이전까지의 위드마켓 구현을 되돌아보자

이전까지의 위드마켓은 아래의 구조를 가지고 있었습니다.

  • common layer: 필요한 타입을 정의해놓는 레이어(모듈)
  • domain layer: database에 직접 접근하는 로직을 보관하는 모듈
    -> domain-dynamo: dynamoDB에 접근하기 위해서 dynamoDB의 config 설정, 그리고 repository를 정의하는 모듈
    -> domain-redis: dynamoDB의 데이터를 캐싱하기 위해서 redis 설정 정보를 보관하는 모듈
  • domain-queryservice: CRUD 중에서 R만을 담당하는 Service Code를 품는 모듈
  • application-query: CRUD중에 R에 해당하는 모듈들을 모두 재조립하여 실제 배포를 시행하는 모듈

그런데 여기까지는 좋습니다. 역할과 책임의 원칙에 의거해서 모듈을 잘 분리했다고 저는 생각하거든요. 그런데 문제가 하나 있습니다. 저는 지금까지 모든 로직을 전부 동기식으로 작성해왔습니다.

그리고 최근에 Kotlin의 Coroutines를 배우면서 느낀점이 하나 있다면, 동기적으로 코드를 작성하는 것은 효율면에서 아주 쓰레기가 따로 없다. 라는 것입니다. 따라서 저는 이제껏 작성해온 코드를 모두 비동기를 기반으로 바꿔볼 예정입니다.

이번 시간에는 제가 프로젝트를 비동기로 전환한 것중 일부만 소개시켜드리도록 하겠습니다.


🤔 왜 갑자기 비동기로 바꾸려고 하십니까?

우선 한가지 설명을 드리고 시작하겠습니다.

스프링의 경우 톰캣을 기반으로 서블릿이 구동되는 방식인데, 이 때 톰캣에 별도의 설정이 없다면 스프링은 ThreadPool에 200개의 쓰레드를 가지고 시작합니다. 그리고 요청 1건당 1개의 쓰레드가 담당하는 Thread Per Request 방식으로 처리합니다.

그런데 스프링을 동기적으로 작성하게 된다면 여러건의 request가 thread를 block시키는 로직을 가지고 있다면 매우 큰 비효율을 낳게됩니다. request를 처리하는데 쓰레드가 쓸데없이 block되어 cost가 발생하기 때문이죠.

Coroutine 혹은 Spring Webflux를 사용하게된다면 Thread가 block된 상태에 빠지면 즉시 다른 task로 실행 흐름이 변경되어서 다른 task부터 우선 처리를 하게 될겁니다.

따라서 저는 기존의 동기식 코드를 이제부터 비동기 기반의 코드로 모두 전환하겠습니다.


🔥 우선 설정 정보부터 수정하겠습니다.

DynamoDbConfig에는 아래와 같이 코드를 추가해줍니다.

🔨 DynamoDbConfig.kt

	@Bean
    fun dynamoDbAsyncClient(): DynamoDbAsyncClient = DynamoDbAsyncClient.builder()
        .region(Region.AP_NORTHEAST_2)
        .credentialsProvider(
            StaticCredentialsProvider.create(
                AwsBasicCredentials.create(accessKey, secretKey)
            )
        )
        .build()

    // Async가 적용된 DynamoDbEnhancedAsyncClient 생성
    @Bean(name = ["dynamoDbEnhancedAsyncClient"])
    fun dynamoDbEnhancedAsyncClient(
        @Qualifier("dynamoDbAsyncClient") dynamoDbAsyncClient: DynamoDbAsyncClient
    ): DynamoDbEnhancedAsyncClient = DynamoDbEnhancedAsyncClient.builder()
        .dynamoDbClient(dynamoDbAsyncClient)
        .build()

dynamoDB의 sdk의 경우 enhancedAsyncClient를 제공하고 있기 때문에 이렇게 Bean을 등록해주면 비동기 기반의 client를 사용할 수 있게 됩니다.

그 다음으로 Redis의 설정 정보도 바꿔보겠습니다.

🔨 RedisConfig.kt

@Configuration
class RedisConfig(
    @Value("\${spring.redis.host}") val host: String,
    @Value("\${spring.redis.port}") val port: Int
) {

    @Primary
    @Bean
    fun connectionFactory(): ReactiveRedisConnectionFactory? {
        return LettuceConnectionFactory(host, port)
    }

}

Lettuce 기반의 ConnectionFactory의 경우 Lettuce는 Jedis와는 다르게 애초에 Non-blocking 기반이기 때문에 LettuceConnectionFactory를 생성해서 Bean으로 등록해주면 끝납니다.

다음으로는 Repository를 일부 수정하도록 하겠습니다.


🔥 Cache hit 기반의 Repository를 생성하도록 하겠습니다.

우선 본격적으로 설명하기 이전에 알아둬야할 것이 있습니다.

비동기식 Redis를 사용하는데 있어서 기존의 @Cacheable, @CacheEvict 등의 어노테이션은 통하지 않습니다. 따라서 이 어노테이션의 역할을 별도의 기능으로 추가를 시켜줘야하는데요, 저는 이를 위해서 Cache Hit 전략을 채택하였습니다. 제가 생각한 전략은 아래와 같습니다.


1. 우선 Redis에 캐시된 shop 정보가 있는지 검사한다.
2. 없다면 DynamoRepository를 통해서 shop을 하나 찾아와서 리턴함과 동시에 캐싱을 수행한다
3. 만약에 있다면 그걸 그대로 반환해준다

이제 위의 사항들을 모두 비동기를 기반으로 작성해주면 되는데요, Repository 단계까지는 Webflux를 이용해서 작성하도록 하겠습니다. (이 글에서는 위에서 말씀드렸다시피 하나의 shop을 찾아오는 로직만 설명드리겠습니다!)

🤗 개인적인 생각입니다만, Coroutines의 경우 Webflux에서 지원해주는 Back Pressure같은 기능이 존재하지 않다보니 Repository 단계까지는 Coroutine으로 작성하는게 아닌 Webflux를 기반으로 작성하기로 결정하였습니다. 나중에 Service layer에서 Coroutines를 사용할지, 혹은 대용량 비동기 처리를 위해서 Webflux를 채택할지 결정하면 되니까요.

우선은 ShopDynamoRepository부터 수정하겠습니다.

🔨 ShopDynamoRepository.kt

fun findShopByIdAndNameAsync(shopId: String, shopName: String): Mono<Shop?> {
        val shopKey = generateKey(shopId, shopName)
        return Mono.fromFuture(asyncTable.getItem(shopKey))
    }

dynamoEnhancedAsyncClient의 경우 table을 통해서 shop을 하나 가져오면 CompletableFuture를 반환하게 됩니다. Spring Webflux의 경우 CompletableFuture를 Mono 타입으로 바꿀수있으며, Mono 타입의 경우 0..1개의 데이터를 처리하는데 사용하는 타입이기 때문에 Mono 타입으로 바꿔서 shop을 반환해주는 모습을 확인할 수 있습니다.

아래는 위의 코드를 작성하기 위해서 작성한 테스트 코드들입니다.

🔨 ShopDynamoRepositoryTest.kt

	@ParameterizedTest
    @CsvSource(value = ["33daf043-7f36-4a52-b791-018f9d5eb218:역전할머니맥주"], delimiter = ':')
    @DisplayName("비동기로 아이템을 하나 가져온다")
    fun findOneShopAsync1(shopId: String, shopName: String): Unit = runBlocking {
        val table = dynamoDbEnhancedAsyncClient.table("shop", TableSchema.fromBean(Shop::class.java))
        val key = generateKey(shopId, shopName)
        val shopFuture = table.getItem(key)
        // kotlinx-coroutines-reactor로부터 확장 함수를 이용해서 Mono -> coroutines로 변환
        val shop = Mono.fromFuture(shopFuture).awaitSingleOrNull()

        assertNotNull(shop)
        shop?.let {
            assertEquals(it.shopId, shopId)
            assertEquals(it.shopName, shopName)
            println(it)
        }
    }

    @ParameterizedTest
    @CsvSource(value = ["33daf043-7f36-4a52-b791-018f9d5eb218:가짜할머니맥주"], delimiter = ':')
    @DisplayName("비동기로 잘못된 이름으로 요청을 보내서 아이템을 가져오지 못한다")
    fun findOneShopAsync2(shopId: String, shopName: String): Unit = runBlocking {
        val table = dynamoDbEnhancedAsyncClient.table("shop", TableSchema.fromBean(Shop::class.java))
        val key = generateKey(shopId, shopName)
        val shopFuture = table.getItem(key)
        val shop = Mono.fromFuture(shopFuture).awaitSingleOrNull()

        assertNull(shop)

        println(shop)
    }

테스트를 할때는 runBlocking 으로 묶어서 Coroutine을 이용해서 테스트를 진행하였고, 성공 케이스와 실패 케이스를 모두 점검해본 다음에 repository를 작성하였습니다.

다음으로, Reactive 기반의 Redis Template를 선언한 내용을 알려드리겠습니다.

🔨 ReactiveRedisConfig.kt

@Configuration
class ReactiveRedisConfig {

    /**
     * ReactiveRedisTemplate를 생성하기 위한 공통 로직을 선언한 메소드
     * @param factory Lettuce를 이용한 Non-blocking connection factory
     * @param clazz ReactiveRedisTemplate에서 사용할 클래스
     */
    private fun <T> commonReactiveRedisTemplate(
        factory: ReactiveRedisConnectionFactory?,
        clazz: Class<T>
    ): ReactiveRedisTemplate<String, T> {
        val keySerializer = StringRedisSerializer()
        val redisSerializer = Jackson2JsonRedisSerializer(clazz)
            .apply {
                setObjectMapper(
                    jacksonObjectMapper()
                        .registerModule(JavaTimeModule())
                )
            }

        val serializationContext = RedisSerializationContext
            .newSerializationContext<String, T>()
            .key(keySerializer)
            .hashKey(keySerializer)
            .value(redisSerializer)
            .hashValue(redisSerializer)
            .build()

        return ReactiveRedisTemplate(factory!!, serializationContext)
    }

    // Shop에 대한 reactive redis template
    @Bean
    fun shopReactiveRedisTemplate(
        factory: ReactiveRedisConnectionFactory,
    ): ReactiveRedisTemplate<String, Shop> = commonReactiveRedisTemplate(factory, Shop::class.java)
}

ReactiveRedisTemplate의 경우 여러가지 타입을 받아서 처리를 수행하기 때문에 boilerplate를 줄여주기 위해서 제네릭을 사용해서 공통 부분을 추출해서 작성하였습니다. 이를 commonReactiveRedisTemplate로 분리시켜서 작성을 하였습니다.

그리고 shopReactiveRedisTemplate에서는 위에서 작성한 commonReactiveRedisTemplate를 이용해서 Shop을 받을수있도록 메소드를 작성한 뒤, Bean으로 등록시켰습니다.

shopReactiveRedisTemplate를 이용해서 본격적으로 Cache hit을 구현하겠습니다.

🔨 ShopRepository.kt

@Repository
class ShopRepository(
    private val shopDynamoRepository: ShopDynamoRepository,
    private val shopReactiveRedisTemplate: ReactiveRedisTemplate<String, Shop>
) {
    companion object {
        // Cache를 보관할 기간을 정의
        val DAYS_TO_LIVE = 1L

        fun generateKey(shopId: String, shopName: String) = "shop:${shopId}-${shopName}"
    }

    /**
     * Cache hit 방식으로 DynamoDB로부터 가게를 찾아오는 메소드
     * @param shopId 가게의 Id
     * @param shopName 가게의 이름
     * @return Mono<Shop?>
     */
    fun findOneShopByIdAndNameWithCaching(shopId: String, shopName: String): Mono<Shop?> {
        val key = generateKey(shopId, shopName)
        val alternativeShopMono: Mono<Shop?> = shopDynamoRepository.findShopByIdAndNameAsync(shopId, shopName)
            .doOnSuccess {
                it?.let {
                    shopReactiveRedisTemplate.opsForValue().set(key, it, Duration.ofDays(DAYS_TO_LIVE))
                        .subscribe()
                }
            }.onErrorResume {
                Mono.empty()
            }
        // Redis에서 key에 해당하는 값을 찾지 못한경우 alternativeShopMono를 이용해 Dynamo에서 찾아온다
        // Dynamo에서 찾아오는데 성공하는 경우 동시에 Redis에 캐싱한다
        return shopReactiveRedisTemplate.opsForValue().get(key)
            .switchIfEmpty(alternativeShopMono)
    }


}

이 코드를 처음 보시게되면 이 표정을 지으실겁니다.

천천히 설명드리겠습니다.

우선 저희는 Redis를 두드려서 거기 캐싱된 Shop이 있나요? 라고 물어봐야합니다. 따라서 우선 reactiveRedisTemplate를 사용해서 get을 날려보고, 없다면 다른 방향으로 흐름을 돌려야합니다.

이를 저는 이렇게 구현했습니다.

return shopReactiveRedisTemplate.opsForValue().get(key)
    .switchIfEmpty(alternativeShopMono)

이를 이용해서 캐싱된 데이터가 존재하면 opsForValue().get(key) 를 이용해서 바로 가져와서 Mono<Shop?> 타입으로 반환을 해주고, 만약에 캐싱된 데이터가 존재하지 않는다면 switchIfEmpty(alternativeShopMono)를 이용해서 실행 흐름을 바꿔줍니다.

그러면 이제 캐싱된 데이터가 없다면 취할 행동을 정의해야합니다. 이 또한 Webflux를 이용해서 구현하겠습니다.

우선 DynamoDB에 두드려서 데이터 내놔! 를 해줘야합니다. 그래서 우선 이전에 선언한 findShopByIdAndNameAsync()를 호출해서 데이터를 가져옵니다. 그리고 데이터를 가져오는데 성공하면 비동기적으로 캐싱을 진행해줘야하는데요, 이 때 문제가 있습니다. shopId, shopName 둘 중 하나가 잘못되어 empty mono가 반환된 경우입니다.

이 때는 empty mono가 반환된건지 검사해서 캐싱할지말지 결정을 해줘야합니다. empty mono인데 null이 캐싱되어버리면 곤란하니까요!
TDD의 중요성...나도 이걸 테스트코드를 작성해보고 깨달았지 ㅠ

따라서 doOnSuccess를 연결해서 receiver인 it이 null이 아닌 경우에만 캐싱이 되도록 코드를 작성하고, subscribe를 통해서 비동기적으로 실행되도록 하였습니다.

따라서 alternativeShopMono는 아래와 같이 작성이 됩니다.

	val alternativeShopMono: Mono<Shop?> = shopDynamoRepository.findShopByIdAndNameAsync(shopId, shopName)
            .doOnSuccess {
                it?.let {
                    shopReactiveRedisTemplate.opsForValue().set(key, it, Duration.ofDays(DAYS_TO_LIVE))
                        .subscribe()
                }
            }.onErrorResume {
                Mono.empty()
            }

그리고 이 메소드를 통해 반환된 Mono<Shop?> 타입의 변수의 경우 이후에 Coroutine으로 변환시켜 deferred로 바꾼 다음 코루틴으로 실행시킬 예정입니다.

아래는 위의 repository 코드를 작성하기 위해서 테스트한 모든 코드들입니다.

🔨 ShopRepositoryTest.kt

@SpringBootTest
internal class ShopRepositoryTest @Autowired constructor(
    val shopDynamoRepository: ShopDynamoRepository,
    val shopRepository: ShopRepository,
    val shopReactiveRedisTemplate: ReactiveRedisTemplate<String, Shop>
) {

    @ParameterizedTest
    @CsvSource(value = ["33daf043-7f36-4a52-b791-018f9d5eb218:역전할머니맥주"], delimiter = ':')
    @DisplayName("Cache hit이 성공하는지 테스트하는 메소드")
    fun findOneShopCachingSuccess1(shopId: String, shopName: String): Unit = runBlocking {
        // given
        val key = generateKey(shopId, shopName)
        val alternativeShopMono: Mono<Shop?> = shopDynamoRepository.findShopByIdAndNameAsync(shopId, shopName)
            .doOnSuccess {
                it?.let {
                    shopReactiveRedisTemplate.opsForValue().set(key, it, Duration.ofDays(1L))
                        .subscribe()
                }
            }.onErrorResume {
                Mono.empty()
            }
        val resultMono: Mono<Shop> = shopReactiveRedisTemplate.opsForValue().get(key)
            .switchIfEmpty(alternativeShopMono)

        // when
        val shopDeferred = CoroutinesUtils.monoToDeferred(resultMono) // Mono를 Coroutine으로 변환
        val resultShop = shopDeferred.await() // Mono로부터 결과를 얻을 때까지 Coroutine을 block

        // redis로부터 결과를 받아오기
        val redisShopMono: Mono<Shop> = shopReactiveRedisTemplate.opsForValue().get(key)
        val redisShop = CoroutinesUtils.monoToDeferred(redisShopMono).await()

        // then
        assertNotNull(resultShop)
        resultShop?.let {
            assertEquals(it.shopId, shopId)
            assertEquals(it.shopName, shopName)
        }

        assertNotNull(redisShop)
        redisShop?.let {
            assertEquals(it.shopId, shopId)
            assertEquals(it.shopName, shopName)
        }

        println(resultShop)
    }

    @ParameterizedTest
    @CsvSource(value = ["33daf043-7f36-4a52-b791-018f9d5eb218:역전할머니맥주 페이크"], delimiter = ':')
    @DisplayName("잘못된 shopName으로 인해서 아이템을 가져오지 못하는 테스트")
    fun findOneShopCachingFail1(shopId: String, shopName: String): Unit = runBlocking {
        // given
        val key = generateKey(shopId, shopName)
        val alternativeShopMono: Mono<Shop?> = shopDynamoRepository.findShopByIdAndNameAsync(shopId, shopName)
            .doOnSuccess {
                it?.let {
                    shopReactiveRedisTemplate.opsForValue().set(key, it, Duration.ofDays(1L))
                        .subscribe()
                }
            }.onErrorResume {
                Mono.empty()
            }

        // redis에서 key를 통해서 가게를 못 찾아내는 경우 slternative mono를 실행한다
        val resultMono: Mono<Shop> = shopReactiveRedisTemplate.opsForValue().get(key)
            .switchIfEmpty(alternativeShopMono)

        // when
        val shopDeferred = CoroutinesUtils.monoToDeferred(resultMono)
        val resultShop = shopDeferred.await()

        // then
        assertNull(resultShop)

        println("Test passed!!")
    }

    @ParameterizedTest
    @CsvSource(value = ["xxxxxxx-7f36-4a52-b791-018f9d5eb218:역전할머니맥주"], delimiter = ':')
    @DisplayName("ShopId가 잘못되어서 가게를 찾지 못하는 케이스 테스트")
    fun findOneShopCachingFail2(shopId: String, shopName: String): Unit = runBlocking {
        // given
        val key = generateKey(shopId, shopName)
        val alternativeShopMono: Mono<Shop?> = shopDynamoRepository.findShopByIdAndNameAsync(shopId, shopName)
            .doOnSuccess {
                it?.let {
                    shopReactiveRedisTemplate.opsForValue().set(key, it, Duration.ofDays(1L))
                        .subscribe()
                }
            }.onErrorResume {
                Mono.empty()
            }
        val resultMono = shopReactiveRedisTemplate.opsForValue().get(key)
            .switchIfEmpty(alternativeShopMono)

        // when
        val shopDeferred: Deferred<Shop?> = CoroutinesUtils.monoToDeferred(resultMono)
        val resultShop: Shop? = shopDeferred.await()

        val redisShopMono: Mono<Shop?> = shopReactiveRedisTemplate.opsForValue().get(key)
        val redisShopDeferred = CoroutinesUtils.monoToDeferred(redisShopMono)
        val shopFromRedis = redisShopDeferred.await()

        // then
        assertNull(resultShop)
        assertNull(shopFromRedis)

        println("Test passed!!")
    }

    @ParameterizedTest
    @CsvSource(value = ["xxxxxxx-7f36-4a52-b791-018f9d5eb218:역전할머니맥주"], delimiter = ':')
    @DisplayName("[Repository Test] ShopId가 잘못되어서 가게를 찾지 못하는 케이스 테스트")
    fun testFindOneShopFail1(shopId: String, shopName: String): Unit = runBlocking {
        // given
        val key = generateKey(shopId, shopName)
        val shopMono: Mono<Shop?> = shopRepository.findOneShopByIdAndNameWithCaching(shopId, shopName)

        // when
        val result = CoroutinesUtils.monoToDeferred(shopMono).await()
        val redisResult = CoroutinesUtils.monoToDeferred(shopReactiveRedisTemplate.opsForValue().get(key))
            .await()

        // then
        assertNull(result)
        assertNull(redisResult)

        println("Test passed!!")
    }

    @ParameterizedTest
    @CsvSource(value = ["33daf043-7f36-4a52-b791-018f9d5eb218:역전할머니맥주 페이크"], delimiter = ':')
    @DisplayName("[Repository Test] 잘못된 shopName으로 인해서 아이템을 가져오지 못하는 테스트")
    fun testFindOneShopFail2(shopId: String, shopName: String): Unit = runBlocking {
        // given
        val key = generateKey(shopId, shopName)
        val shopMono: Mono<Shop?> = shopRepository.findOneShopByIdAndNameWithCaching(shopId, shopName)

        // when
        val result = CoroutinesUtils.monoToDeferred(shopMono).await()
        val redisResult = CoroutinesUtils.monoToDeferred(shopReactiveRedisTemplate.opsForValue().get(key))
            .await()

        // then
        assertNull(result)
        assertNull(redisResult)

        println("Test passed!!")
    }

    @ParameterizedTest
    @CsvSource(value = ["33daf043-7f36-4a52-b791-018f9d5eb218:역전할머니맥주"], delimiter = ':')
    @DisplayName("[Repository Test] Cache hit이 정상 동작하면서 아이템을 가져오는지 테스트")
    fun testFindOneShopSuccess1(shopId: String, shopName: String): Unit = runBlocking {
        // given
        val key = generateKey(shopId, shopName)
        val shopMono: Mono<Shop?> = shopRepository.findOneShopByIdAndNameWithCaching(shopId, shopName)

        // when
        val result = CoroutinesUtils.monoToDeferred(shopMono).await()
        val redisResult = CoroutinesUtils.monoToDeferred(shopReactiveRedisTemplate.opsForValue().get(key))
            .await()

        // then
        assertNotNull(result)
        result?.let {
            assertEquals(it.shopId, shopId)
            assertEquals(it.shopName, shopName)
        }

        assertNotNull(redisResult)
        redisResult?.let {
            assertEquals(it.shopId, shopId)
            assertEquals(it.shopName, shopName)
        }
    }

    @ParameterizedTest
    @CsvSource(value = ["33daf043-7f36-4a52-b791-018f9d5eb218:역전할머니맥주"], delimiter = ':')
    @DisplayName("[Repository Test] Cache hit 성능 비교")
    fun compareCaching(shopId: String, shopName: String): Unit = runBlocking {
        val stopWatch = StopWatch()
        val normalAsyncResult = mutableListOf<Shop?>() // 일반적인 비동기로 처리했을 때의 속도
        val cachingAsyncResult = mutableListOf<Shop?>() // 캐시 히팅을 통해서 비동기 처리했을 때의 속도

        stopWatch.start()
        val normalJob = launch {
            repeat(100) {
                val shopMono: Mono<Shop?> = shopDynamoRepository.findShopByIdAndNameAsync(shopId, shopName)
                val shopDeferred = CoroutinesUtils.monoToDeferred(shopMono)
                normalAsyncResult.add(shopDeferred.await())
            }
        }

        normalJob.join()
        stopWatch.stop()

        val normalSpeed = stopWatch.totalTimeMillis

        println("normal async: $normalSpeed") // 3306 mills

        stopWatch.start()
        val cachingJob = launch {
            repeat(100) {
                val shopMono: Mono<Shop?> = shopRepository.findOneShopByIdAndNameWithCaching(shopId, shopName)
                val shopDeferred = CoroutinesUtils.monoToDeferred(shopMono)
                cachingAsyncResult.add(shopDeferred.await())
            }
        }

        cachingJob.join()
        stopWatch.stop()

        val cachingSpeed = stopWatch.totalTimeMillis - normalSpeed

        println("caching async: $cachingSpeed") // 1780 mills
    }

    private fun generateKey(shopId: String, shopName: String): String = "shop:${shopId}-${shopName}"
}

특히나 아래의 코드를 주목해서 볼 필요가 있습니다.

	@ParameterizedTest
    @CsvSource(value = ["33daf043-7f36-4a52-b791-018f9d5eb218:역전할머니맥주"], delimiter = ':')
    @DisplayName("[Repository Test] Cache hit 성능 비교")
    fun compareCaching(shopId: String, shopName: String): Unit = runBlocking {
        val stopWatch = StopWatch()
        val normalAsyncResult = mutableListOf<Shop?>() // 일반적인 비동기로 처리했을 때의 속도
        val cachingAsyncResult = mutableListOf<Shop?>() // 캐시 히팅을 통해서 비동기 처리했을 때의 속도

        stopWatch.start()
        val normalJob = launch {
            repeat(100) {
                val shopMono: Mono<Shop?> = shopDynamoRepository.findShopByIdAndNameAsync(shopId, shopName)
                val shopDeferred = CoroutinesUtils.monoToDeferred(shopMono)
                normalAsyncResult.add(shopDeferred.await())
            }
        }

        normalJob.join()
        stopWatch.stop()

        val normalSpeed = stopWatch.totalTimeMillis

        println("normal async: $normalSpeed") // 3306 mills

        stopWatch.start()
        val cachingJob = launch {
            repeat(100) {
                val shopMono: Mono<Shop?> = shopRepository.findOneShopByIdAndNameWithCaching(shopId, shopName)
                val shopDeferred = CoroutinesUtils.monoToDeferred(shopMono)
                cachingAsyncResult.add(shopDeferred.await())
            }
        }

        cachingJob.join()
        stopWatch.stop()

        val cachingSpeed = stopWatch.totalTimeMillis - normalSpeed

        println("caching async: $cachingSpeed") // 1780 mills
    }

위의 메소드의 경우 비동기적으로 Cache hit 없이 100번의 요청을 날렸을 때의 속도비동기적으로 Cache hit을 이용해서 100번의 요청을 날리는 것의 비교를 위한 테스트 코드입니다.

두 개의 launch 블록을 열어서 100번 반복을 시킨 뒤, join을 통해 100번이 처리될 때 까지의 속도를 측정하였습니다.

결론은 아래와 같습니다.

Cache hit 적용 x: 3306 mills
Cache hit 적용 o: 1780 mills

거의 2배의 차이가 발생하였다고 보시면 되겠습니다. 따라서 Cache hit을 이용해서 조회 로직을 처리하는 것이 Throughput이 더 좋다 라는게 위의 실험을 통해 검증이 되었습니다.


🌲 글을 마치며

이번 포스트에서는 위드마켓의 가게노출 서비스를 비동기로 전환하는 과정 일부분을 소개시켜드렸습니다.

다음 포스트는 아마 기존의 MVC 구조에서 Functional Endpoint 구조로 전환하는 과정을 다룰 예정입니다. 아닐수도 있고?

다음 포스트에서 뵙겠습니다. 감사합니다!

profile
Hi There 🤗! I'm college student majoring Mathematics, and double majoring CSE. I'm just enjoying studying about good architectures of back-end system(applications) and how to operate the servers efficiently! 🔥

1개의 댓글

comment-user-thumbnail
2023년 12월 23일

혹시 소스 코드 어디서 볼 수 있을까요?

답글 달기