ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Mutex와 Coroutine을 이용한 동시 트래픽 시뮬레이션 2 - 테스트
    Kotlin 2024. 3. 15. 15:44

    목차

    • 개요
    • 왜 코루틴이 동시성 테스트를 하기에 적합한가?
    • 코루틴의 기본적 동작방식 설명
    • 구현 코드 설명
    • CancellationException의 당위성
    • 한계

    개요

    이전 글 (https://sseung00921.tistory.com/10)에서 다룬 바 있는 애플리케이션에 대해 코루틴으로 동시성 테스트를 진행하고 설명하는 글입니다. 해당 애플리케이션의 요구사항 및 구현에 대해서는 이전 글을 참조해주시기 바랍니다.

     

    왜 코루틴이 동시성 테스트를 하기에 적합한가?

    자바에서 동시성 테스트를 위해 ExecuterService.submit()과 같은 API들을 많이 사용해 보셨을 수 있습니다. 이런 API가 필요한 이유는 근본적으로 이러합니다.

    • 테스트할 로직을 여러 스레드에 실어줄 하나의 스레드가 필요합니다.
    • 그 스레드는 테스트 로직을 여러 스레드에 실어주되 그 모든 스레드들이 종료될 때까지 대기해야 합니다.
    • 여러 스레드는 병렬적으로 작업을 수행합니다. 이는 테스트하고 싶던 바로 그 부분입니다.
    • 그리고 대기중이었던 스레드는 각각의 테스트로직을 실행하는 스레드들이 종료된 직후에 각종 assert문을 통해 검증과정을 수행해야 합니다.

     

    코루틴을 사용하는 이유는 단순합니다. 위의 과정들을 적절하게 수행해주는 API들을 사용할 수 있기 때문입니다. 한 테스트만 가져와서 살펴보며 코루틴의 기본 동작을 설명하겠습니다. 코루틴에 대해서 잘 아시는 분들은 읽을 필요가 없는 부분입니다.

     

    코루틴의 기본적 동작방식 설명 

        @DisplayName("같은 인형에게 연속적으로 입고시는 첫 번째 요청 반영, 두 번째 요청 에러 반환")
        @Test
        fun storeFailIfInARowForOneDoll(): Unit = runBlocking {
            //첫 번째 입고
            val storeJob1 = async(Dispatchers.IO) {
                return@async controller.store(1L, 10000L)
            }
    
            //거의 바로 연속으로 (DB 부분을 보면 입고에 300L 밀리세컨드가 소요되므로 10L이면 동시에 요청이 온것으로 볼 수 있다.)
            delay(10L)
    
            //두 번째 입고
            val storeJob2 = async(Dispatchers.IO) {
                return@async controller.store(1L, 10000L)
            }
    
            joinAll(storeJob1, storeJob2)
    
            //두 번째 입고가 예외를 던지며 실패하는 것 확인
            try {
                storeJob2.await()
                fail("No Exception");
            } catch (e : Exception) {
                assertTrue(e is CancellationException)
                assertEquals("입고가 불가능합니다.", e.message)
            }
    
            //첫 번째 입고만 반영된 것 확인
            val doll = controller.inquire(1L)
            assertEquals(10000L, doll.stock)
        }

    스레드의 계층 구조를 살펴보면 아래와 같습니다.(정확히는 코루틴의 계층구조이나 코루틴에 익숙하지 않다면 스레드의 계층구조로 이해해도 무방합니다.)

    runBlocking이하의 {}안의 소스코드가 실행되는 스레드

    -------첫 번째 async이하의 {}안의 소스코드(storeJob1)가 실행되는 스레드

    -------두 번째 async이하의 {}안의 소스코드(storeJob2)가 실행되는 스레드

     

    runBlocking 이하의 소스코드를 실행하는 스레드는 서로 다른 두개의 스레드에게 작업(async {}안의 소스코드)을 실어만 주고 joinAll 부분에서 두 스레드가 모든 작업을 완료할 때까지 대기합니다. joinAll(인자 1, 인자 2)를 호출하면 작업을 실은 스레드(여기서는 runBlocking을 수행하는 스레드)가 두 async작업이 끝날 때까지 대기합니다.

     

    만약 joinAll같은 코드가 없으면 작업을 실어준 스레드는 그 작업들이 끝나기도 전에 바로 assert검증을 하러 달려가 버립니다. 그래서 테스트가 실패하게 됩니다. 정리하면 runBlocking 스레드는 병렬적으로 처리될 작업들을 각각의 스레드에 실어주고 그 작업들이 끝나길 기다렸다가 모든 작업이 끝난 후에 검증을 수행하는 스레드입니다.

     

    각각의 async안의 작업들은 서로 다른 스레드에서 병렬 실행됩니다. async에 인자로 준 Dispathcer.IO는 백그라운드 스레드풀이라는 스레드 풀을 이용하는 데 여기에는 적어도 4개보다는 많은 스레드들(해당 애플리케이션의 테스트에서는 최대 4개의 작업이 병렬실행됩니다.)이 작업을 실행시켜주고자 대기하고 있기 때문에 충분히 동시적인 상황을 재현할 수 있습니다.

     

    구현 코드 설명

    해당 테스트 코드 전문은(https://github.com/sseung00921/MutexAndCoroutinePractice/blob/main/src/test/kotlin/com/example/demo/doll/DollApplicationTests.kt)에서 찾아볼 수 있습니다. 해당 애플리케이션의 테스트는 총 7개로 각각 아래와 같은 상황을 테스트합니다.

    • test 1 - 단순 조회, 입고, 출고 동작여부 테스트
    • test 2 - 한 인형에 충분한 시간차를 두고 입고가 이루어질 경우 성공
    • test 3 - 한 인형에 동시적으로 입고가 이루어질 경우 첫번째 요청만 반영 두번째 요청은 미반영 및 예외 반환
    • test 4 - 한 인형에 동시적으로 출고가 이루어질 경우 순차적으로 두 요청 모두 반영
    • test 5- 서로 다른 두 인형에게 동시적으로 입고가 이루이질 경우 인형별로 첫번째 요청만 반영 두번째 요청은 미반영 및 예외 반환
    • test 6 - 서로 다른 두 인형에게 동시적으로 출고가 이루어질 경우 인형별로 순차적으로 두 요청 모두 반영 
    • test 7- 서로 다른 두 인형에게 동시적으로 입고->출고 혹은 출고->입고가 이루어질 경우 인형별로 순차적으로 두 요청 모두 반영 

     

    테스트 전문이 길기 때문에 위에 첨부한 test3 코드만 살펴보며 해당 테스트 코드가 어떻게 요구사항을 동시성 테스트 하는지 살펴보겠습니다.

    • 우선 runBlocking 을 수행하는 스레드가 병렬적으로 서로 다른 두 스레드에게 한 인형의 입고요청을 요청하는 작업을 싣습니다. 이 떄 두 요청은 아주 찰나(0.01초)의 시간 간격을 두고 요청되는데 이렇게 한 이유는 두가지입니다.
      • 첫째로 입고 작업을 완료하는 데 0.3초가 소요되기 때문에 위와 같이 할 경우 두 번째 작업이 첫 번째 작업이 진행되는 도중에 요청된 상황을 재현할 수 있습니다.
      • 그리고 찰나의 차이긴 하지만 그래도 먼저온 입고 요청을 구분할 수는 있어야 합니다. 그래야 반영될 요청을 구분할 수 있기 때문입니다. 그래서 0.01초의 시간간격을 두었습니다.
    • joinAll에서 root스레드가 대기합니다. (처음 실행된 runBlocking스레드를 root로 볼 수 있습니다. 물론 엄밀히는 root 코루틴으로 보아야 하나 여기서는 이해의 편의성을 위한 설명임을 말씀드립니다.)
    • 두 병렬작업이 완료되면 root스레드는 storeJob2.await()를 통해 이 작업이 수행한 결과를 꺼내서 검증해보고자 시도합니다. 그런데 이 storeJob2는 예외를 반환하게 됩니다. catch이하 블록에서 그 예외를 검증할 수 있게 됩니다. 즉 예외의 타입 및 예외 메시지를 검사할 수 있습니다. 만약 storeJob2가 예외를 반환하지 않으면 그거는 그거대로 비즈니스 요구사항을 정확히 충족시키지 못한 것입니다. 그경우에는 그냥 테스트가 실패하도록 fail("No Exception")을 넣었습니다.
    • 마지막에 재고 확인을 해서 첫번째 요청은 반영되었는지 확인합니다.
    • 위에는 test 한 사례만 보여드렸지만 전문을 보시면 테스트 7개가 다 돌기 위해 DB Collection을 초기화하는 @BeforeEach 메서드가 있습니다. 이 메서드는 id 1, id 2의 재고를 0으로 초기화합니다.

     

    위와 같은 방법으로 코루틴을 이용해 동시성을 재현하고 테스트해 볼 수 있습니다.

     

    CancellationException의 당위성

    이 앱의 비즈니스 코드는 입고 불가시 코루틴 라이브러리가 제공하는 CancellationException을 뱉도록 구현되어 있습니다. (물론 현업에서는 도메인에 맞게 이 CancellationException을 추상화해서 커스터마이징한 Exception을 사용해야 할 것입니다.)  이는 이유가 있습니다. 아까 말씀드린 계층구조를 보시면

    root 스레드

    ------async1 스레드

    ------async2 스레드

    와 같은 계층구조 였습니다. 여기서 코루틴이 특별히 정의한 CancellationException을  상속하지 않은 예외는 위로 위로 전파됩니다. 무슨이야기이냐면 만약 async2 스레드에서 CancellationException을 상속하지 않은 예외를 던지면 바로 root 스레드로 그 예외가 전달되어 joinAll을 수행하기 전에 root스레드가 예외를 뱉으며 종료되어 버립니다.

    즉 async2 스레드가 예외를 뱉으며 종료되는게 즉시! root 스레드로 전파되어 root 스레드도 같은 예외를 이유로 즉각 종료됩니다. 하지만 CancellationException을 사용하면 그 예외를 뱉은 스레드만 영향을 받고 그 스레드를 호출한 스레드에는 예외를 전파하지 않습니다.

     

    정리하면

    부모 스레드 밑의 자식 스레드들 중 하나라도 CancellationException이 아닌 Exception을 뱉으면 부모도 실패합니다. 참고로 부모가 실패하면 이후 모든 자식들도 실패하는 것이 코루틴의 동작 방식입니다. 즉 관련된 작업들이 모조리 실패합니다.

    하지만 부모 스레드 밑의 자식 스레드들 중 일부가 CancellationException으로 실패하면 그 스레드만 실패하고 끝납니다.

     

    그래서 입고 불가능 예외 반환을 테스트하려면 그것을 검증하는 부모스레드가 죽지 않게 하기 위해 CancellationException예외를 던져야 합니다.

     

    비단 테스트 상황 뿐만 아니라 실무적으로 자식의 작업이 부모의 작업의 영향을 주지 않게끔 코루틴을 사용하려 한다면 해당 Exception을 사용해야 합니다. 그렇지 않고 자식 작업의 실패는 곧 연관된 모든 작업의 실패로 구현하고 싶으면 의도적으로 다른 Exception을 던지면 됩니다.

     

    한계

    1. 위 테스트는 단위 기능을 검증하지만 Mock객체를 활용한 진정한 단위테스트랑은 거리가 있습니다. 실제로 어플리케이션을 다 띄워놓고 테스트를 진행하기 떄문입니다. 그런 점에서 통합테스트에 가깝습니다. 그래서 실행시간이 10초 정도 걸리는데 이는 분명 더 개선하는 방안이 있을 것으로 보입니다.
    2. RunBlocking보다는 testScope같은 코루틴 빌더를 이용해서 테스트를 하는 것이 더 Best Practice인 것으로 알고 있습니다만 해당 요구사항을 테스트하는데 RunBlocking같은 코루틴 빌더도 충분한 면이 있어서 그렇게 구현했습니다. 즉 위 테스트 구현방식이 Best Practice는 아니고 개선될 부분이 많을 것입니다.
Designed by Tistory.