Jwhyee / kotlin-coroutine-study

코틀린 코루틴 북 스터디 📚
4 stars 1 forks source link

2부 코틀린 코루틴 라이브러리 - 3 #4

Open lee-ji-hoon opened 5 months ago

lee-ji-hoon commented 5 months ago

14, 15장

lee-ji-hoon commented 5 months ago

14. 공유 상태로 인한 문제

코루틴을 직접 사용하다보면 직면할 수 있는 문제 중 하나가 데이터 동기화이다.

데이터의 원자성을 보장하기 위해서는 Atomic, 싱글 스레드(limitedParallelism), Mutex, synchronized, semaphore 등의 다양한 방법이 존재한다.

class UserDownloader(
    private val api: NetworkService
) {

    private val users = mutableListOf<String>()

    private val dispatcher = Dispatchers.IO
        .limitedParallelism(1)

    fun downloaded(): List<String> {
        return users.toList()
    }
    suspend fun fetchUser() {
        val userList = api.fetchUser()
        // 함수 전체에 싱글 스레드를 하는 것이 아니라 진짜로 원자성을 보장해야하는 상황에서만 싱글 스레드 dispatcher 사용
        withContext(dispatcher) {
            users.addAll(userList)
        }
    }
}

위 처럼 가장 자주 사용하는 방법은 limitedParallelism를 사용해서 코스 그레인드 스레드 한정을 하는 방법이다.

그렇다고 Atomic이나 Mutex 등이 사용을 안하는 것은 아니다. 코루틴을 사용하지 않거나 연산과 같은 작업이 아니라면 굳이 필요가 없기 때문이다. 예시로 코틀린의 lazy 가 있다.

image

15장 코틀린 코루틴 테스트

코틀린 코루틴을 테스트를 시작하기전에 세팅이 꼭 필요한 것이 있다.

TestDispatcher 설정

Coroutine의 기본 Dispatcher는 Main이다. 하지만 테스트 환경에서는 존재하지 않아서 별도의 세팅이 필요하다.

abstract class BaseTest {

    @JvmField
    @RegisterExtension
    val mainCoroutineExtensions = MainCoroutineExtensions()
}

class MainCoroutineExtensions : BeforeEachCallback, AfterEachCallback {

    private val scheduler: TestCoroutineScheduler = TestCoroutineScheduler()

    val testDispatcher: TestDispatcher = StandardTestDispatcher(scheduler)

    override fun beforeEach(p0: ExtensionContext?) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun afterEach(p0: ExtensionContext?) {
        Dispatchers.resetMain()
        clearAllMocks()
    }
}

그래서 위처럼 BaseTest(abstract)를 만들고 모든 Test가 해당 Base를 상속받게 구현을 하는 편이다.

테스트

테스트가 가능하게 하기 위해서는 dispatcher를 직접 명시하는 것이 아닌 주입을 받게 구현을 해야 한다.

image

launch나 withContext에 직접 dispatcher를 넣는 것이 아닌 주입을 받아서 해야지 위처럼 testDispatcher를 주입해서 테스트 가능하다.

Jwhyee commented 5 months ago

15장. 코틀린 코루틴 테스트하기

TestCoroutineScheduler

TestCoroutineSchedulerdelay를 가상 시간 동안 실행하여, 실제 시간이 흘러간 상황과 동일하게 작동한다. 때문에 정해진 시간만큼 기다리지 않을 수 있다.

fun main() {  
    val scheduler = TestCoroutineScheduler()  

    println(scheduler.currentTime)  
    scheduler.advanceTimeBy(1000)  
    println(scheduler.currentTime)  
    scheduler.advanceTimeBy(1000)  
    println(scheduler.currentTime)  
}

실제로 위 코드를 실행하면, 1초씩 기다리지 않아도 실제 상황과 비슷한 흉내를 낼 수 있다.

StandardTestDispatcher

StandardTestDispatcher를 사용할 경우, 가상 시간만큼 진행되기 전까지 실행되지 않으며, advanceUntilIdle을 호출할 경우 코루틴을 시작하게 된다.

fun main() {  
    val scheduler = TestCoroutineScheduler()  
    val testDispatcher = StandardTestDispatcher(scheduler)  

    CoroutineScope(testDispatcher).launch {  
        println("Some work 1")  
        delay(1000)  
        println("Some work 2")  
        delay(1000)  
        println("Coroutine done")  
    }  

    println("[${scheduler.currentTime}] Before")  
    scheduler.advanceUntilIdle()  
    println("[${scheduler.currentTime}] After")  
}
[0] Before
Some work 1
Some work 2
Coroutine done
[2000] After

사실, TestCoroutineScheduler는 직접 명시하지 않아도, StandardTestDispatcher를 만들 때, 인자가 비어있을 경우 알아서 TestCoroutineScheduler을 만들어주게 된다.

아래 코드를 보면 알 수 있듯, scheduler가 null일 경우 TestMainDispatcher.currentTestScheduler를 사용하게 되고, 그것마저도 null일 경우 TestCoroutineScheduler()를 만들어서 사용하게 된다.

@Suppress("FunctionName")  
public fun StandardTestDispatcher(  
    scheduler: TestCoroutineScheduler? = null,  
    name: String? = null  
): TestDispatcher = StandardTestDispatcherImpl(
    scheduler ?: TestMainDispatcher.currentTestScheduler ?: 
        TestCoroutineScheduler(), name
)  

private class StandardTestDispatcherImpl(  
    override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),  
    private val name: String? = null  
) : TestDispatcher() {  
    // ...
}

때문에 실제로 아래 코드로도 동작하는 것을 볼 수 있다.

fun main() {  
    val testDispatcher = StandardTestDispatcher()  

    CoroutineScope(testDispatcher).launch {  
        println("Some work 1")  
        delay(1000)  
        println("Some work 2")  
        delay(1000)  
        println("Coroutine done")  
    }  

    println("[${testDispatcher.scheduler.currentTime}] Before")  
    testDispatcher.scheduler.advanceUntilIdle()  
    println("[${testDispatcher.scheduler.currentTime}] After")  
}

만약, advenceUntilIdle()을 호출하지 않을 경우 코루틴이 재게되지 않아, 코드가 영원히 실행된다. 코드가 영원히 실행되는 이유는, delay가 걸린 만큼의 가상 시간을 흐르게하지 않았기 때문이다.

advanceUntilIdle()

위 함수를 그대로 번역하면, '한가할 때까지 나아가다'라는 뜻이 된다. 함수 정의를 보면 다음과 같다.

public fun advanceUntilIdle(): Unit = 
    advanceUntilIdleOr { events.none(TestDispatchEvent<*>::isForeground) }

internal fun advanceUntilIdleOr(condition: () -> Boolean) {  
    while (true) {  
        if (!tryRunNextTaskUnless(condition)) return  
    }  
}

internal fun tryRunNextTaskUnless(condition: () -> Boolean): Boolean {  
    val event = synchronized(lock) {  
        if (condition()) return false  
        val event = events.removeFirstOrNull() ?: return false  
        if (currentTime > event.time)  
            currentTimeAheadOfEvents()  
        currentTime = event.time  
        event  
    }  
    event.dispatcher.processEvent(event.marker)  
    return true  
}

advanceUntilIdle를 호출하게 되면 advanceUntilIdleOr을 통해 tryRunNextTaskUnless를 실행하게 된다.

실제 시간처럼 작동하는 가상 시간을 흐르게하여, 그 시간동안 호출되었을 모든 작업을 실행

책에 나온 위 글과 같이 advanceUntilIdleOr함수 내부에 있는 tryRunNextTaskUnless를 호출하는 부분이 바로 호출되었을 모든 작업을 진행하는 것이다. 또한, 실제 시간(currentTime)과 이벤트에 등록된 시간을 비교하고, 조건에 맞을 경우 해당 이벤트를 디스패처에 전달해 processEvent를 통해 처리하도록 한다.

runTest

runTest 구현을 살펴보면 다음과 같다.

public fun runTest(  
    context: CoroutineContext = EmptyCoroutineContext,  
    timeout: Duration = DEFAULT_TIMEOUT.getOrThrow(),  
    testBody: suspend TestScope.() -> Unit  
): TestResult {  
    check(context[RunningInRunTest] == null) {  
        // ... 
    }  
    return TestScope(context + RunningInRunTest).runTest(timeout, testBody)  
}

public fun TestScope.runTest(  
    timeout: Duration = DEFAULT_TIMEOUT.getOrThrow(),  
    testBody: suspend TestScope.() -> Unit  
): TestResult = asSpecificImplementation().let { scope ->  
    scope.enter()  
    createTestResult {  
        scope.start(CoroutineStart.UNDISPATCHED, scope) {  
            yield()  
            testBody()  
        }  
        // ...
    }  
}

runTest 블록 내부는 testBody를 의미하고, 이를 현재 context와 함께 TestScope().runTest()로 넘기게 된다. TestScope.runTest를 보면 알 수 있듯, 테스트 결과를 바로 만들어 내기 위해 CoroutineStart.UNDISPATCHED를 이용해 현재 스레드에서 scope를 바로 시작하게 되고, 우리가 작성한 testBody()를 실행하게 된다.

scope.start(CoroutineStart.UNDISPATCHED, scope) {  
    yield()  
    testBody()  
}  
var timeoutError: Throwable? = null  
var cancellationException: CancellationException? = null  
val workRunner = launch(CoroutineName("kotlinx.coroutines.test runner")) {  
    while (true) {  
        val executedSomething = testScheduler.tryRunNextTaskUnless { !isActive }  
        if (executedSomething) {   
            yield()  
        } else {  
            testScheduler.receiveDispatchEvent()  
        }  
    }  
}
// ...

코루틴 실행 이후의 코드를 보면, 테스트 블록 외에 실행되지 않은 코루틴이 있는지 확인하고, 실행되지 않은 상태를 yield()를 통해 확인하고, 모두 실행될 때까지 대기한다.

try {  
    withTimeout(timeout) {  
        coroutineContext.job.
            invokeOnCompletion(onCancelling = true) { exception ->  
            if (exception is TimeoutCancellationException) {  
                dumpCoroutines()  
                val activeChildren = 
                    scope.children.filter(Job::isActive).toList()  
                val completionCause = 
                    if (scope.isCancelled) 
                        scope.tryGetCompletionCause() else null  
                var message = "After waiting for $timeout"  
                if (completionCause == null)  
                    message += ", the test coroutine is not completing"  
                if (activeChildren.isNotEmpty())  
                    message += ", there were active child jobs: $activeChildren"
                if (completionCause != null && activeChildren.isEmpty()) {  
                    message += if (scope.isCompleted)  
                        ", the test coroutine completed"  
                    else  
                        ", the test coroutine was not completed"  
                }  
                timeoutError = UncompletedCoroutinesError(message)  
                cancellationException = 
                    CancellationException("The test timed out")  
                (scope as Job).cancel(cancellationException!!)  
            }  
        }  
        scope.join()  
        workRunner.cancelAndJoin()  
    }  
}

다음으로, 타임아웃이 발생해서 완료되지 않은 코루틴을 취소 상태로 완료시킨다.