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 에 대한 모든 것이 아니며, 내가 이번에 필요한 부분만을 정리했다.
- Dispatcher
- Thread 에 Operation 을 분배 하는 역할을 한다
- 아래와 같은 Dispatcher 가 제공된다
- Default : 기본 Thread Pool. 별도의 설정이 없을 시 CPU 의 Core 개수와 같은 Thread 를 가진다
- IO : IO 용 Thread Pool. 별도의 설정이 없을 시 64개의 Thread 를 가진다. 이 Thread 는 Default Dispatcher 와 공유 될 수 있다
- Main : UI Thread 에서만 동작시킨다. Android / JavaFX / Swing 호환성이 있다. AWT 는 아마 대응 하지 않는 것으로 보임
- Context
- Dispatcher 와 연결 되어 여러 가지 추가 기능을 부여하는 역할을 한다
- 연결되는 속성들은 ContextElement 라 한다. 아래와 같은 것을을 자주 사용 하게 된다
- SupervisorJob : 일반 Job 은 Coroutine 이 실패할 경우, Parent Coroutine 까지 Cancel 시키는 것이 기본 동작이다. SupervisorJob 는 해당 Coroutine 의 Child 만 취소하도록 한다
- CoroutineName : Coroutine 디버깅을 위한 이름을 붙일 수 있다
- 연결되는 속성들은 ContextElement 라 한다. 아래와 같은 것을을 자주 사용 하게 된다
- 새로운 Coroutine 은 Parent Coroutine 의 Context 를 상속 받는다
- Dispatcher 와 연결 되어 여러 가지 추가 기능을 부여하는 역할을 한다
- Scope
- Coroutine 의 LifeCycle 을 관리한다
- 기본적으로 GlobalScope 를 제공하며, 이 Scope 는 Application 자체와 LifeCycle 을 함께한다
- SpringBoot Application 기본적으로 Application 전체가 아니라 Spring 과 LifeCycle 을 같이 해야 하기 떄문에, 기본적으로 제외하고 생각 하는것이 좋다
- Scope 라는 이름 답게, 최소한의 범위에서 작동 하는 것이 좋다
- 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 타이밍에 따라 실패하게 된다.
이 경우 어차피 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 를 인식하지 못한다는 것이다. 그래서 경고가 발생하게 된다
두번째 문제는 현실적으로 Bean 의 Constructor 의 Argument 를 다른 Bean 이 아니라 고정값으로 인지하기 쉽지 않다는 것이다
그럼 선택지는 둘로 나뉘는데
- Scope 를 Bean 으로 만들어 LifeCycle 을 맞춘다
- 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 접근자를 무시하고 접근 가능 하기 때문