Post

Kotlin Coroutine UnitTest With Spring Boot

서론

요즘은 업무로는 Kotlin 을 주력 언어로 쓰고 있고, 그러다보니 성능이나 빠른 응답, 동시성이 필요 한 부분에는 Coroutine 을 조금씩 사용 하고 있다. 하지만 Kotlin 의 Coroutine 은 내가 익숙한 C#의 Task 모델이나 C++의 promise-future 패턴과는 꽤 달랐다.
그렇기에 업무를 하면서 이해 부족으로 인한 이슈가 생겼고, 따라서 Kotlin 의 Coroutine 에 대해 필요 한 부분을 다시 공부하면서 정리하기로 했다. 다른 구현에 비해 상당히 디테일 한 부분까지 전면에 노출 되어 있으며, 조금이라도 더 활용하려면 알아야 할게 많은 형태의 구현이다. 아마 다른 구현체들은 VM / Language / Framework / Platform 이 조금씩 나눠서 한 구현을 라이브러리 하나로 Android / JVM / Native 등을 모두 지원하려 한 결과물이 아닌가 싶다

사용한 TechSpec 은 아래와 같다. Kotlin Coroutine 은 아직 Api 가 안정화 되지 않고 버전에 따른 변화가 꽤 있는 편이므로, 버전이 다르면 코드가 달라질 수 있다

  • IntelliJ 2023.4
  • Kotlin 1.9.22
  • SpringBoot 3.2.3
  • Kotlin Coroutine 1.8.0
  • Kotest 5.8.1

아래의 내용은 모두 내가 개인적으로 공부하면서, 필요한 부분만 이해한 것을 정리한 것이고 결과물은 정답이라는 보장이 없다. 만약 더 좋은 방법이 있다면 댓글로 알려달라

용어정리

일단 Coroutine 에서 사용하는 용어부터 정리 하고 가자. 여기 나온것은 Coroutine 에 대한 모든 것이 아니며, 내가 이번에 필요한 부분만을 정리했다.

  1. Dispatcher
    • Thread 에 Operation 을 분배 하는 역할을 한다
    • 아래와 같은 Dispatcher 가 제공된다
      1. Default : 기본 Thread Pool. 별도의 설정이 없을 시 CPU 의 Core 개수와 같은 Thread 를 가진다
      2. IO : IO 용 Thread Pool. 별도의 설정이 없을 시 64개의 Thread 를 가진다. 이 Thread 는 Default Dispatcher 와 공유 될 수 있다
      3. Main : UI Thread 에서만 동작시킨다. Android / JavaFX / Swing 호환성이 있다. AWT 는 아마 대응 하지 않는 것으로 보임
  2. Context
    • Dispatcher 와 연결 되어 여러 가지 추가 기능을 부여하는 역할을 한다
      • 연결되는 속성들은 ContextElement 라 한다. 아래와 같은 것을을 자주 사용 하게 된다
        • SupervisorJob : 일반 Job 은 Coroutine 이 실패할 경우, Parent Coroutine 까지 Cancel 시키는 것이 기본 동작이다. SupervisorJob 는 해당 Coroutine 의 Child 만 취소하도록 한다
        • CoroutineName : Coroutine 디버깅을 위한 이름을 붙일 수 있다
    • 새로운 Coroutine 은 Parent Coroutine 의 Context 를 상속 받는다
  3. Scope
    • Coroutine 의 LifeCycle 을 관리한다
    • 기본적으로 GlobalScope 를 제공하며, 이 Scope 는 Application 자체와 LifeCycle 을 함께한다
      • SpringBoot Application 기본적으로 Application 전체가 아니라 Spring 과 LifeCycle 을 같이 해야 하기 떄문에, 기본적으로 제외하고 생각 하는것이 좋다
    • Scope 라는 이름 답게, 최소한의 범위에서 작동 하는 것이 좋다
  4. Job
    • Coroutine 실행의 결과물이다. 대기 / 취소 등의 작업이 가능하다.

이슈

내가 만들어야 하는 것은 2가지다. runBlocking Block 내에서 작동하는 기능이 있는 함수(A) 의 동작과 테스트, 그리고 Fire&Forget 으로 작동 하는 함수(B)의 동작과 테스트 이다 (A) 의 경우는 이슈가 딱히 없다. runBlocking 은 자체적으로 새로운 Context 와 Scope 를 생성 하며, 해당 Scope 는 runBlocking 내부의 모든 Job이 종료될 때 까지, Block 밖으로 빠져나가지 않도록 Blocking 된다. 일반적인 함수 처럼 테스트 가능하다.

문제는 (B) 의 경우이다. 다음 예제를 보자. 함수 내에서 2개의 Task 를 Fire&Forget 으로 실행한다 Thread.sleep(1000) 은 예제 작성의 편의를 위해 넣었다. 적당히 무거운 작업이라고 생각 해 보자.

여기서 Coroutine 의 delay() 가 아니라, Thread.sleep() 을 사용한 이유가 궁금할 수 있는데, 뒤에서 설명할 TestDispatcher 는 Test 편의성을 위해, delay() 를 무시하는 기능이 있기에, 의도적으로 해당 기능을 배제하기 위하여 사용하였다. 일반적으로 Coroutine 에서의 지연을 위해서는 delay() 를 쓰는것이 맞다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ExampleNonService(
    private val classForMock: ClassForMock,
) {
    fun launchGlobalScopeCall(): Long {
        GlobalScope.launch(Dispatchers.IO) {
            Thread.sleep(1000)
            classForMock.foo()
        }

        GlobalScope.launch(Dispatchers.IO) {
            Thread.sleep(1000)
            classForMock.boo()
        }

        return 19860512
    }
}

이 코드를 테스트 하는 코드는 다음과 같게 된다 sleep 을 Random 하게 주었는데 역시 예제 편의성을 위해서다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ExampleNonServiceTest : BehaviorSpec({
    Given("NonService Class Test. Random Failed") {
        val classForMock = mockk<ClassForMock>()
        val exampleNonService = ExampleNonService(
            classForMock
        )

        justRun { classForMock.foo() }
        justRun { classForMock.boo() }

        When("Call Function") {
            val result = exampleNonService.launchGlobalScopeCall()
            Thread.sleep(Random.nextLong(0, 2000))
            Then("Success Boo & Foo") {
                result shouldBe 19860512
                verify(exactly = 1) { classForMock.foo() }
                verify(exactly = 1) { classForMock.boo() }
            }
        }
    }
})

이렇게 할 경우 랜덤하게 해당 Test 가 실패 하는 것을 볼 수 있다. sleep 이 없더라도, Thread Switching 타이밍에 따라 실패하게 된다. Image

이 경우 어차피 F&F 니까… 하고 테스트 코드를 포기 할 수 도 있다. 하지만 Kotlin 에서는 StandardTestDispatcher 와 UnconfinedTestDispatcher 라는 Dispatcher 를 제공한다. 이 Dispatcher 들은 Test 용 Dispatcher 인데, 각각 Default Dispatcher 와, Unconfined 에 매칭된다. Coroutine 을 테스트 함과 동시에 delay() 를 무시하여 빠른 테스트를 가능하게 하는 기능이 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class ExampleNonServiceWithDispatcher(
    private val classForMock: ClassForMock,
    private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
    fun launchGlobalScopeCallWithDispatcher(): Long {
        GlobalScope.launch(dispatcher) {
            Thread.sleep(1000)
            classForMock.foo()
        }

        GlobalScope.launch(dispatcher) {
            Thread.sleep(1000)
            classForMock.boo()
        }

        return 19860512
    }
}

class ExampleNonServiceWithDispatcherTest : BehaviorSpec({
    Given("NonService Class Test. Always Success") {
        val classForMock = mockk<ClassForMock>()
        val testDispatcher = StandardTestDispatcher()
        val exampleNonServiceWithDispatcher = ExampleNonServiceWithDispatcher(
            classForMock,
            testDispatcher
        )

        When("Call Function") {
            justRun { classForMock.foo() }
            justRun { classForMock.boo() }

            var result: Long? = null
            runTest(testDispatcher) {
                result = exampleNonServiceWithDispatcher.launchGlobalScopeCallWithDispatcher()
            }

            Then("Success Boo & Foo") {
                result shouldBe 19860512
                verify(exactly = 1) { classForMock.foo() }
                verify(exactly = 1) { classForMock.boo() }
            }
        }
    }
})

test 에서 Sleep 이 빠졌지만, launchGlobalScopeCallWithDispatcher 함수에 있는 foo() 와 boo() 호출이 모두 실행되어 Test 가 항상 성공 하는 것을 볼 수 있다

하지만 위 코드는 GlobalScope 를 사용 하고 있다. SpringBoot 프로젝트의 경우(혹은 다른 경우라도) GlobalScope 를 쓰는 경우는 권장되지 않으므로, 별도의 Scope 를 사용하는것이 일반적이다. 특히 Scope 종료 시, 실행 중인 Job 을 Cancel 하는 등의 추가 동작을 위해서라도 Scope 를 직접 관리 하는 것은 매우 중요하다고 할 수 있겠다

예를들어 Bean 과 LifeCycle 을 같이 하는 Scope 를 만든다면 다음과 같은 방법을 쓸 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
class ExampleServiceWithDispatcher(
    private val classForMock: ClassForMock,
    private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
    private lateinit var scope: CoroutineScope

    @PostConstruct
    fun setScope() {
        scope = CoroutineScope(dispatcher)
    }

    @PreDestroy
    fun cancelScope() {
        scope.cancel()
    }

    fun launchGlobalScopeCallWithDispatcher(): Long {
        scope.launch {
            Thread.sleep(2000)
            classForMock.foo()
        }

        scope.launch {
            Thread.sleep(2000)
            classForMock.boo()
        }

        return 19860512
    }
}

하지만 이 코드는 두가지 귀찮은 문제가 발생한다 첫번째 문제는 아직 IntelliJ 가 Kotlin 의 Constructor 의 Default Value 를 인식하지 못한다는 것이다. 그래서 경고가 발생하게 된다 Image

두번째 문제는 현실적으로 Bean 의 Constructor 의 Argument 를 다른 Bean 이 아니라 고정값으로 인지하기 쉽지 않다는 것이다

그럼 선택지는 둘로 나뉘는데

  1. Scope 를 Bean 으로 만들어 LifeCycle 을 맞춘다
  2. Test 시에만 Scope 를 Reflection 으로 교체한다

난 Scope 를 Bean 으로 만들어 여러 곳에서 공유하거나, 관리 Focus 를 넓히는것이 좋지 않다고 판단했다.
또한 TestCode 에 Spring 과 Bean 의존성을 넣는 것을 나는 그다지 선호하지 않는 편이다.

그래서 Scope 를 교체하기로 했고 그 코드는 다음과 같다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Service
class ExampleServiceWithPrivateScope(
    private val classForMock: ClassForMock,
) {
    private lateinit var scope: CoroutineScope

    @PostConstruct
    fun setScope() {
        scope = CoroutineScope(Dispatchers.Default)
    }

    @PreDestroy
    fun cancelScope() {
        scope.cancel()
    }

    fun launchGlobalScopeCallWithScope(): Long {
        scope.launch {
            Thread.sleep(2000)
            classForMock.foo()
        }

        scope.launch {
            Thread.sleep(2000)
            classForMock.boo()
        }

        return 19860512
    }
}

class ExampleServiceWithPrivateScopeTest : BehaviorSpec({
    val classForMock = mockk<ClassForMock>()
    val exampleServiceWithPrivateScope = ExampleServiceWithPrivateScope(
        classForMock,
    )

    val testDispatcher = StandardTestDispatcher()
    val testScope = TestScope(testDispatcher)

    beforeTest {
        val scopeField = ExampleServiceWithPrivateScope::class.java.getDeclaredField("scope")
        scopeField.isAccessible = true
        scopeField.set(exampleServiceWithPrivateScope, testScope)
    }

    afterTest {
        val scopeField = ExampleServiceWithPrivateScope::class.java.getDeclaredField("scope")
        scopeField.isAccessible = true
        scopeField.set(exampleServiceWithPrivateScope, null)
    }

    Given("Service Class Test. Always Success") {
        justRun { classForMock.foo() }
        justRun { classForMock.boo() }

        When("Call Function") {
            var result: Long? = null
            testScope.runTest {
                result = exampleServiceWithPrivateScope.launchGlobalScopeCallWithScope()
            }

            Then("Success Boo & Foo") {
                result shouldBe 19860512
                verify(exactly = 1) { classForMock.foo() }
                verify(exactly = 1) { classForMock.boo() }
            }
        }
    }
})

이로서 내가 원하는 결과가 만들어 졌다

만약 Spock Framework 를 Test Framework 로 사용한다면, Reflection 이 필요 없다. Groovy 는 private 접근자를 무시하고 접근 가능 하기 때문

참고자료

This post is licensed under CC BY 4.0 by the author.