코루틴이 멀티 스레드 환경에서 실행될 때 테스트가 어려운 이유
코루틴은 일반적으로 멀티 스레드 환경에서 비동기적으로 실행된다. 이 때문에 멀티 스레드 환경의 문제가 그대로 테스트에 나타난다. 그 대표적인 문제는 다음과 같다.
1. 함수 호출 순서 문제
2. 경쟁 상태 문제
3. 코루틴이 사용하는 디스패처 문제
함수 호출 순서 문제
멀티 스레드 환경에서 실행되는 함수들은 함수의 호출 순서를 파악하기 어렵다. 코루틴을 테스트 할 때도 이 문제가 나타나며, 병렬성으로 인해 실행을 코루틴이 실행되기 전에 다른 코루틴이 실행될 수 있다. 또한, 코루틴은 함께 실행되기 때문에 어떤 코드가 먼저 실행될지 파악하기 매우 어렵다.
예를 들어 하나의 코루틴이 일시 중단하게 되면 스레드를 다른 스레드가 사용할 수 있도록 양보하는데, 이런 경우 다른 코루틴이 해당 스레드를 점유하고 실행될 수 있다. 이러한 특성으로 인해 코루틴에서는 함수의 호출 순서가 예상하기 어려워 테스트를 하기가 어렵다.
경쟁 상태 문제
또 다른 문제는 경쟁 상태 문제이다. 멀티 스레드 환경에서 여러 스레드에서 공통으로 사용되는 변수가 동시적으로 접근되고 수정되면 경쟁 상태 문제가 일어나게 되는데, 코루틴 또한 여러 스레드를 사용해 병렬적으로 실행되면 같은 문제가 나타난다. 특히 경쟁 상태 문제로 인해 간헐적으로 오류가 나타나게 되면, 어떤 부분에서 오류가 나는지 파악하기 매우 어렵다.
예를 들어 아래와 같은 RepeatAdder 객체를 테스트 할 때 result는 올바른 값이 나올 수도 있고 틀린 값이 나올 수도 있다.
class RepeatAdder() {
suspend fun add(repeatTime: Int): Int = coroutineScope {
var result = 0
val jobs = (1..repeatTime).map {
launch {
result += 1
}
}
jobs.joinAll()
return@coroutineScope result
}
}
이를 확인하기 위해 간단하게 열 번만 더하는 테스트를 다음과 같이 만들어보자.
class RepeatAdderTest {
@Test
fun testAdd10() = runBlocking {
val repeatAdder = RepeatAdder()
val result = withContext(Dispatchers.IO) {
repeatAdder.repeatAdd(10)
}
assertEquals(10, result)
}
}
그러면 실패가 나올 수도 있겠지만, 웬만해서는 성공으로 나온다.
그러면 이번에는 repeatAdd 함수에 1000을 입력해 테스트 해보자.
class RepeatAdderTest {
@Test
fun testAdd1000() = runBlocking {
val repeatAdder = RepeatAdder()
val result = withContext(Dispatchers.IO) {
repeatAdder.repeatAdd(1000)
}
assertEquals(1000, result)
}
}
그러면Test Fail과 Test Success가 랜덤으로 나오는 것을 볼 수 있다.
코루틴이 사용하는 디스패처 문제
또 다른 문제는 코루틴이 사용하는 디스패처 문제이다. 만약 일시 중단 함수를 테스트할 때 해당 일시 중단 함수 내부에서 여러 코루틴이 만들어지게 된다면, 해당 일시 중단 함수는 병렬적으로 실행될 수 있다. 이런 경우 만약 단일 스레드를 사용하는 디스패처를 사용해 테스트를 진행한다면, 이 테스트는 일시 중단 함수에 동시성 문제가 생기더라도 오류를 잡아낼 수 없는 의미 없는 테스트가 된다.
예를 들어 아래와 같은 getUserProfileById 일시 중단 함수에 대한 테스트를 진행할 때, 내부에서 코루틴 두 개가 병렬로 실행될 수 있으므로 싱글 스레드를 사용하는 디스패처를 사용하면 의미 없는 테스트가 될 수 있다.
class UserProfileFetcher(
private val userRepository: UserRepository,
) {
suspend fun getUserProfileById(id: String): UserProfile = coroutineScope {
val userNameDeferred = async { userRepository.getNameByUserId(id) }
val userPhoneNumberDeferred = async { userRepository.getPhoneNumberByUserId(id) }
return@coroutineScope UserProfile(
id = id,
name = userNameDeferred.await(),
phoneNumber = userPhoneNumberDeferred.await()
)
}
정리
이번 글에서는 코루틴을 테스트할 때 생길 수 있는 여러 문제를 살펴보았다. 다음 글부터는 코루틴을 테스트 하기 위한 방법을 본격적으로 살펴보자.