시배's Android

Kotlin Coroutine | Kotlin Coroutines patterns & anti-patterns 본문

Kotlin/Kotlin Coroutine

Kotlin Coroutine | Kotlin Coroutines patterns & anti-patterns

si8ae 2023. 9. 23. 14:23

생각에 Kotlin 코루틴을 사용할 해야 일과 하지 말아야 (또는 최소한 피해야 ) 가지에 대해 글을 쓰기로 결정했습니다.

Wrap async calls with coroutineScope or use SupervisorJob to handle exceptions

async 블록이 예외를 발생시킬 수 있는 경우 try/catch 블록으로 감싸는 것에 의존하지 마세요.

val job: Job = Job()
val scope = CoroutineScope(Dispatchers.Default + job)
// may throw Exception
fun doWork(): Deferred<String> = scope.async { ... }   // (1)
fun loadData() = scope.launch {
    try {
        doWork().await()                               // (2)
    } catch (e: Exception) { ... }
}

위의 예에서 doWork 함수는 처리되지 않은 예외를 던질 수 있는 새 코루틴(1)을 시작합니다. doWork를 try/catch 블록(2)으로 감싸려고 하면 여전히 충돌이 발생합니다.

 

이는 작업의 자식 하나가 실패하면 부모가 즉시 실패하기 때문에 발생합니다.

(1) 충돌을 피할 수 있는 한 가지 방법은 SupervisorJob을 사용하는 것입니다.

자식의 실패 또는 취소로 인해 SupervisorJob 역할이 실패하는 것은 아니며 다른 자식에게도 영향을 미치지 않습니다.

val job = SupervisorJob()                               // (1)
val scope = CoroutineScope(Dispatchers.Default + job)

// may throw Exception
fun doWork(): Deferred<String> = scope.async { ... }

fun loadData() = scope.launch {
    try {
        doWork().await()
    } catch (e: Exception) { ... }

참고: 방법은 SupervisorJob 사용하여 코루틴 범위에서 async 명시적으로 실행하는 경우에만 작동합니다. 따라서 아래 코드는 부모 코루틴(1) 범위에서 async 실행되기 때문에 애플리케이션이 여전히 충돌합니다.

val job = SupervisorJob()                               
val scope = CoroutineScope(Dispatchers.Default + job)

fun loadData() = scope.launch {
    try {
        async {                                         // (1)
            // may throw Exception 
        }.await()
    } catch (e: Exception) { ... }
}

✅  (2) 더 바람직한 방법은 async를 코루틴 스코프로 감싸는 것입니다.

val job = SupervisorJob()                               
val scope = CoroutineScope(Dispatchers.Default + job)

// may throw Exception
suspend fun doWork(): String = coroutineScope {     // (1)
    async { ... }.await()
}

fun loadData() = scope.launch {                       // (2)
    try {
        doWork()
    } catch (e: Exception) { ... }
}

Prefer the Main dispatcher for root coroutine

Root 코루틴 내에서 백그라운드 작업을 수행하고 UI를 업데이트해야 하는 경우, Main 디스패처가 아닌 디스패처로 실행하지 마세요.

val scope = CoroutineScope(Dispatchers.Default)          // (1)

fun login() = scope.launch {
    withContext(Dispatcher.Main) { view.showLoading() }  // (2)  
    networkClient.login(...)
    withContext(Dispatcher.Main) { view.hideLoading() }  // (2)
}

위의 예에서는 기본 디스패처(1) 있는 스코프를 사용하여 루트 코루틴을 시작합니다. 접근 방식을 사용하면 사용자 인터페이스를 터치해야 때마다 컨텍스트(2) 전환해야 합니다.

대부분의 경우 메인 디스패처를 사용하여 스코프를 생성하는 것이 코드가 간단하고 컨텍스트 전환이 명시적인 결과를 낳습니다.

val scope = CoroutineScope(Dispatchers.Main)

fun login() = scope.launch {
    view.showLoading()    
    withContext(Dispatcher.IO) { networkClient.login(...) }
    view.hideLoading()
}

Avoid usage of unnecessary async/await

async 함수를 사용하다가 즉시 await() 사용하는 경우 작업을 중단해야 합니다.

launch {
    val data = async(Dispatchers.Default) { /* code */ }.await()
}

 

 코루틴 컨텍스트를 전환하고 부모 코루틴을 즉시 일시 중단하려면 컨텍스트를 사용하는 것이 바람직합니다.

launch {
    val data = withContext(Dispatchers.Default) { /* code */ }
}

성능 측면에서는 큰 문제가 되지 않지만(비동기화는 작업을 수행하기 위해 새로운 코루틴을 생성한다고 생각하더라도), 의미상 비동기화는 백그라운드에서 여러 코루틴을 시작한 다음 대기만 한다는 것을 의미합니다.

Avoid cancelling scope job

코루틴을 취소해야 하는 경우 애초에 스코프 작업을 취소하지 마세요.

class WorkManager {
    val job = SupervisorJob()
    val scope = CoroutineScope(Dispatchers.Default + job)
    
    fun doWork1() {
        scope.launch { /* do work */ }
    }
    
    fun doWork2() {
        scope.launch { /* do work */ }
    }
    
    fun cancelAllWork() {
        job.cancel()
    }
}

fun main() {
    val workManager = WorkManager()
    
    workManager.doWork1()
    workManager.doWork2()
    workManager.cancelAllWork()
    workManager.doWork1() // (1)
}

코드의 문제점은 작업을 취소하면 완료 상태로 전환된다는 것입니다. 완료된 작업의 범위에서 실행된 코루틴은 실행되지 않습니다(1).

특정 범위의 모든 코루틴을 취소하려면 cancelChildren 함수를 사용할 수 있습니다. 또한 개별 작업을 취소할 수 있는 기능을 제공하는 것도 좋은 방법입니다(2).

class WorkManager {
    val job = SupervisorJob()
    val scope = CoroutineScope(Dispatchers.Default + job)
    
    fun doWork1(): Job = scope.launch { /* do work */ } // (2)
    
    fun doWork2(): Job = scope.launch { /* do work */ } // (2)
    
    fun cancelAllWork() {
        scope.coroutineContext.cancelChildren()         // (1)                             
    }
}
fun main() {
    val workManager = WorkManager()
    
    workManager.doWork1()
    workManager.doWork2()
    workManager.cancelAllWork()
    workManager.doWork1()
}

Avoid writing suspend function with an implicit dispatcher

 특정 코루틴 디스패처의 실행에 의존하는 일시 중단 함수를 작성하지 마세요.

suspend fun login(): Result {
    view.showLoading()
    
    val result = withContext(Dispatcher.IO) {  
        someBlockingCall() 
    }
    view.hideLoading()
    
    return result
}

위의 예시에서 로그인 함수는 메인 디스패처가 아닌 코루틴에서 실행하면 충돌이 발생하는 일시 중단 함수입니다.

launch(Dispatcher.Main) {     // (1) no crash
    val loginResult = login()
    ...
}

launch(Dispatcher.Default) {  // (2) cause crash
    val loginResult = login()
    ...
}
CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

모든 코루틴 디스패처에서 실행할 수 있는 방식으로 일시 중단 함수를 설계하세요.

suspend fun login(): Result = withContext(Dispatcher.Main) {
    view.showLoading()
    
    val result = withContext(Dispatcher.IO) {  
        someBlockingCall() 
    }
    
    view.hideLoading()
	return result
}

이제 모든 디스패처에서 로그인 기능을 호출할 수 있습니다.

launch(Dispatcher.Main) {     // (1) no crash
    val loginResult = login()
    ...
}

launch(Dispatcher.Default) {  // (2) no crash ether
    val loginResult = login()
    ...
}

Avoid usage of global scope

❌  Android 애플리케이션의 모든 곳에서 GlobalScope 사용하고 있다면 작업을 중단해야 합니다.

GlobalScope.launch {
    // code
}

GlobalScope는 전체 애플리케이션 수명 동안 작동하며 조기에 취소되지 않는 최상위 코루틴을 실행하는 데 사용됩니다.

 

애플리케이션 코드는 일반적으로 애플리케이션 정의 코루틴 스코프를 사용해야 하며, GlobalScope 인스턴스에서 async 또는 launch를 사용하는 것은 매우 권장하지 않습니다.

안드로이드에서 코루틴은 activity, fragment, view 또는 viewmodel 수명 주기로 쉽게 범위를 지정할 수 있습니다.

class MainActivity : AppCompatActivity(), CoroutineScope {
    
    private val job = SupervisorJob()
    
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job
    
    override fun onDestroy() {
        super.onDestroy()
        coroutineContext.cancelChildren()
    }
    
    fun loadData() = launch {
        // code
    }
}