1줄 요약 (TL;DR)
성능은 가상스레드, 멋들어진 방법은 코루틴
코루틴으로 테스트를?
보통 자바 환경에서 테스트 코드로 동시성 로직을 검증할 때 스레드 풀과CountDownLatch 를 사용할 텐데, 코틀린을 쓴다면 코루틴을 사용해도 좋다.
테스트를 위한 코루틴 세팅
코루틴은 테스트를 위한 api 를 제공한다. TestScope, runTest 등 여러 기능을 사용할 수 있다.
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:{coroutine-version}")
동시성 테스트 속도 비교
흔히 동시성 테스트를 만들면 아래와 같을 것이다.
@Test
fun `자바 동시성 테스트`() {
val threadSize = 10
val threadPool = Executors.newFixedThreadPool(threadSize)
val latch = CountDownLatch(threadSize)
repeat(threadSize) {
threadPool.execute {
// something()
latch.countDown()
}
}
latch.await()
}
코루틴으로 만들면 아래와 같다.
@Test
fun `코루틴 동시성 테스트`() = runTest {
val jobSize = 10
val jobs = List(jobSize) {
launch {
counter++
}
}
jobs.joinAll()
}
신경 써야 하는 객체도 적고 코드도 간결해진다.
내가 가장 궁금했던 것은 테스트 속도 차이가 어느 정도 나는지였는데, 1000번의 증가 연산 작업을 하는 데 걸리는 시간을 측정해 봤다.
@Test
fun `자바 동시성 테스트`() {
val startTime = System.currentTimeMillis()
val threadSize = 1000
val threadPool = Executors.newFixedThreadPool(threadSize)
val latch = CountDownLatch(threadSize)
var counter = 0
repeat(threadSize) {
threadPool.execute {
counter++
latch.countDown()
}
}
latch.await()
val endTime = System.currentTimeMillis()
println("thread duration = ${endTime - startTime} ms")
}
@Test
fun `코루틴 동시성 테스트`() = runTest {
val startTime = System.currentTimeMillis()
val jobSize = 1000
var counter = 0
val jobs = List(jobSize) {
launch {
counter++
}
}
jobs.joinAll()
val endTime = System.currentTimeMillis()
println("coroutine duration = ${endTime - startTime} ms")
}
coroutine duration = 38 ms
thread duration = 73 ms
약 2배가량 코루틴이 빨랐다. 물론 스레드를 1000 개 생성하고, 스위칭하는데 비용 차이가 크기 때문에 작업 단위가 많아질수록 차이는 커질 것이다. (별도로 진행한 가상 스레드 테스트는 같은 작업에 7ms 가 소요됐다)
반환 값 테스트
어떤 메서드의 반환 값을 검증할 필요가 있을 수도 있다. 개인적으로 이 경우에 코루틴의 장점이 확실히 드러난다고 생각한다.
@Test
fun `자바 동시성 반환값 테스트`() {
val threadSize = 10
val threadPool = Executors.newFixedThreadPool(threadSize)
var counter = 0
val futures: List<Future<Int>> = List(threadSize) {
threadPool.submit(Callable {
increase(counter++)
})
}
val actual = futures.map(Future<Int>::get)
assertContentEquals(actual, listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))
}
@Test
fun `코루틴 동시성 반환값 테스트`() = runTest {
val jobSize = 10
var counter = 0
val actual = List(jobSize) {
async {
increase(counter++)
}
}.awaitAll()
assertContentEquals(actual, listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))
}
fun increase(value: Int): Int = value + 1
스레드와 코루틴 모두 반환 값을 받아서 검증할 수 있다. 하지만 스레드 기반 테스트에서 문제점은 순서를 보장하지 못한다는 점이다.
코루틴은 순차적 실행을 보장하기 때문에 항상 순서가 일정하지만 스레드풀의 submit 은 순서를 보장하지 않아 테스트를 실행할 때마다 결과가 달라진다.
결론
사실 성능을 놓고 보면 대부분의 경우엔 가상스레드를 사용하는 것이 압도적으로 빠를 것이다.
하지만 코틀린을 사용하는 이유 중에 syntactic sugar 가 있을 수 있고 위의 경우처럼 순서 보장도 되기 때문에 상황에 맞는 테스트 방법을 사용하면 더 좋은 테스트 작성 방법이 될 것이다.