ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Kotlin 동시성 테스트하기
    프로젝트/미디어 스트리밍 서버 프로젝트 2022. 10. 7. 00:01

    개요

    Multi Thread 환경에서 데이터의 정합성을 보장할 수 있는지 확인하고자 합니다.

     

    코틀린 공식문서를 기반으로 학습해 보겠습니다.

    https://kotlinlang.org/docs/multiplatform-mobile-concurrency-overview.html

     

    Concurrency overview | Kotlin

     

    kotlinlang.org

     

     

    규칙 1 : 변경 가능한 상태라면 1개의 스레드에서 동작해야 합니다.

    data class SomeData(var count:Int)
    
    fun simpleState(){
        val sd = SomeData(42)
        sd.count++
        println("My count is ${sd.count}") // It will be 43
    }

    위의 메서드 처럼 상태를 가지는 경우에는 스레드가 1개일 경우에 동시성 문제가 발생하지 않습니다.

     

    규칙 2 : 변경이 불가능한 상태라면 여러개의 스레드에서 동작할 수 있습니다.

     

    val을 통해서 불변 객체를 만드려고 하지만 객체의 경우에는 내부적으로 변경 가능할 수 있습니다.

    따라서 freeze() 메서드를 제공함으로써 불변성을 조금 더 지원합니다.

     

    또한 @ThreadLocal, @SharedImmutable 등을 활용하여 Object, companion ojbect에서 공유 변수를 사용할 수 있습니다.

     

     

    하지만 상태를 가지더라도 동시성이 필요할 수 있습니다.

    규칙1,2에 따르면 상태를 가지면 1개의 스레드에서 동작해야 하지만 여러 개의 스레드에서 동작해야 하는 경우도 존재합니다.

     

    이때 사용할 수 있는 다양한 기술들이 있습니다.

    - Atomics

    - Thread-isolated states

    - Low-level capabilities

     

     

    동시성을 활용해서 테스트 작성해보기

    자바에서는 ExecutorService를 활용해서 멀티 쓰레드 환경을 구축할 수 있었습니다.

     

    코틀린에서는 코루틴을 사용할 수 있습니다.

    class CoroutinesTutorial {
    
        var counter = 0
    
        @Test
        fun coroutinesTest() {
            runBlocking {
                GlobalScope.massiveRun {
                    counter++
                }
                println("Counter = $counter") //100 * 1000번인 100_000이 아닌 54486이 출력됨 공유 변수를 다루게 되면 동시성 문제
            }
        }
    }
    
    
    suspend fun CoroutineScope.massiveRun(action: suspend () -> Unit) {
        val n = 100  // number of coroutines to launch
        val k = 1000 // times an action is repeated by each coroutine
        val time = measureTimeMillis {
            val jobs = List(n) {
                launch {
                    repeat(k) { action() }
                }
            }
            jobs.forEach { it.join() }
        }
        println("Completed ${n * k} actions in $time ms")
    }

    공유 변수인 counter를 증가시키는 연산을 수행합니다.

    이때 100_000번 수행하기 때문에 100_000이 예상되지만 동시성 문제로 훨씬 적은 값이 출력됩니다.

     

     

    그러면 이 점을 활용해서 DB에 여러 요청이 동시에 들어왔을 때 원하는 대로 동작하는지 확인해보겠습니다.

     

    조회수를 1증가시키는 메서드를 테스트해보겠습니다.

     

    Service

      fun plusHitCount(hitId: Long) {
            videoRepository.plusHitCountById(hitId)
        }

    Reposiotry

    @Modifying
    @Query(value = "update Hit t set t.hitCount = t.hitCount + 1 where t.id =:hitId")
    fun plusHitCountById(hitId: Long)

    Test 코드

    @Test
    @DisplayName("동시성 환경에서 조회수 증가 정합성이 보장되어야 한다")
    @Trasnactional
    fun multiThreadUpdateTest() {
            //given
            val registerVideo = videoService.registerVideo(
                UploadRequest(
                    "subject",
                    "content",
                )
            )
    
            //when
            runBlocking {
               GlobalScope.massiveUpdate {
                   videoService.plusHitCount(registerVideo.id)
               }
            }
    
            videoRepository.flush()
            entityManager.clear()
            println("db processing 끝")
    
            //then
            val video = videoService.findSingleVideo(registerVideo.id)                
            val count = video.hit.hitCount
            Assertions.assertThat(count).isEqualTo(10*100)
        }
    
        suspend fun CoroutineScope.massiveUpdate(action: suspend () -> Unit){
            val n = 10  // number of coroutines to launch
            val k = 100 // times an action is repeated by each coroutine
            val time = measureTimeMillis {
                val jobs = List(n) {
                    launch {
                        repeat(k) { action() }
                    }
                }
                jobs.forEach { it.join() }
            }
            println("Completed ${n * k} actions in $time ms")
    }

    1. 비디오를 등록합니다.

    2. 코루틴을 활용하여 1000번의 업데이트를 실행시킵니다.

    3. 비디오를 조회해서 조회수가 1000인지 검증합니다.

     

     

    Exception 발생

    위의 테스트를 실행시키면 time out exception이 발생합니다.

    HikariPool-1 - Connection is not available, request timed out after 30013ms.

     

    Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
    Caused by: org.hibernate.PessimisticLockException: could not execute statement

     

    이를 해결하기 위해 여러 가지 방법들을 시도해 보았습니다.

    1. 코루틴의 문제일 수 있다고 생각하여 ExecutorService를 사용해 보았습니다.

     

    2. 테스트시 h2 db를 사용하고 있는데 Mysql과 차이점이 있을 수 있을 것 같아 MySql으로 테스트해보았습니다.

     

    3. update query가 나가므로 DeadLock이 발생할 수 있겠다고 생각하여 Hikari의 Connection Pool 설정을 변경해보고, Lock Time out 등의 설정을 해주어 보았습니다.

     

    4. 동시 요청 수가 너무 많을 수 있다고 생각하여 요청 수를 점점 줄여 보았습니다. 하지만 100개의 요청을 보내던 2개의 요청을 보내던 동일한 에러가 발생했습니다.

     

    5. update query가 문제일 수 있다고 생각하여 select query를 날려보았습니다. 이때에는 db에서 값을 제대로 조회해오지 못해서 null이 들어와 Exception이 발생했습니다.

    Exception in thread "DefaultDispatcher-worker-3 @coroutine#8" java.lang.IllegalArgumentException

     

    이때부터 무언가 근본적으로 잘못되었음을 깨달았지만 무엇이 문제인지 알 수 없었습니다.

     

    원인은 @Transactionl 어노테이션 때문이였습니다.

    test 클래스에 선언된 트랜잭션 때문에  여러 개의 요청이 하나의 트랜잭션에서 처리되어 lock을 계속 점유하고 있어서 발생한 일이었습니다.

     

    @Transactional 어노테이션을 제거한 후 테스트는 정상적으로 동작하여 1000건의 동시 요청에 대해 조회수가 1000 증가하는 것을 검증할 수 있었습니다.

     

     

     

     

     

    출처

    https://tourspace.tistory.com/163

     

    [Kotlin] 코틀린 - 코루틴#8 - 동기화 제어

    이 글은 아래 링크의 내용을 기반으로 하여 설명합니다. https://github.com/Kotlin/kotlinx.coroutines/blob/master/coroutines-guide.md 또한 예제에서 로그 print시 println과 안드로이드의 Log.e()를 혼용합..

    tourspace.tistory.com

     

    댓글

Designed by Tistory.