관리 메뉴

나만을 위한 블로그

[Android] Coroutine Best Practices - 1 - 본문

Android

[Android] Coroutine Best Practices - 1 -

참깨빵위에참깨빵_ 2022. 6. 26. 19:22
728x90
반응형

https://proandroiddev.com/kotlin-coroutines-patterns-anti-patterns-f9d12984c68e

 

Kotlin Coroutines patterns & anti-patterns

Decided to write about several things which in my opinion you should and shouldn’t do (or at least try to avoid) when using Kotlin…

proandroiddev.com

 

코루틴을 사용할 때 해야 할 것, 하지 말아야 할 것을 정리한 포스트 (2018.03)

4년 전에 작성됐기 때문에 최근 안드로이드 진영에서 언급되는 Best Practices와 비교 검증이 필요하다.

 

CoroutineScope로 비동기 호출을 래핑하거나 SupervisorJob을 통해 예외처리하라

 

비동기 블록에서 예외가 발생할 수 있는 경우 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()는 처리되지 않은 예외를 throw할 수 있는 새 코루틴(1)을 시작한다. doWork()를 try-catch로 래핑해도 여전히 충돌한다. 이것은 Job의 자식 중 하나가 실패하면 부모가 즉시 실패하기 때문에 발생한다.

충돌을 피하는 방법은 SupervisorJob을 쓰는 것이다. 1차 자식 컴포넌트의 실패 or 취소는 SupervisorJob의 실패를 유발하지 않으며 다른 1차 자식 컴포넌트에 영향을 주지 않는다.

 

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을 써서 CoroutineScope에서 비동기를 명시적으로 실행하는 경우에만 작동한다. 따라서 비동기가 상위 코루틴(1)의 범위에서 시작되기 때문에 아래 코드는 여전히 충돌이 발생한다.

 

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

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

충돌을 피하는 다른 방법은 비동기를 CoroutineScope로 래핑하는 것(1)이다. 이제 비동기 내부에서 예외가 발생하면 외부 범위를 건드리지 않고 이 범위에서 생성된 모든 코루틴을 취소한다.

 

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) { ... }
}

 

SupervisorJob :

 

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-supervisor-job.html

 

SupervisorJob

Creates a supervisor job object in an active state. Children of a supervisor job can fail independently of each other. A failure or cancellation of a child does not cause the supervisor job to fail and does not affect its other children, so a supervisor ca

kotlin.github.io

활성 상태의 SupervisorJob 객체를 만든다. SupervisorJob의 하위 항목은 서로 독립적으로 실패할 수 있다. 1차 하위 컴포넌트의 실패 or 취소는 SupervisorJob의 실패를 유발하지 않으며 다른 1차 하위 컴포넌트에 영향을 주지 않으므로 감독자는 하위 실패를 처리하기 위한 사용자 정의 정책을 구현할 수 있다

- launch로 생성된 자식 Job의 상태는 컨텍스트에서 CoroutineExceptionHandler를 통해 처리할 수 있다
- 비동기를 써서 생성된 자식 Job의 실패는 지연된 결과값에서 Deferred.await를 통해 처리할 수 있다

 

https://victorbrandalise.com/coroutines-part-ii-job-supervisorjob-launch-and-async/

 

Coroutines (Part II) – Job, SupervisorJob, Launch and Async by Victor Brandalise

Learn about Job and SupervisorJob and then proceed to creating Coroutines with launch and async. Learned about Job and SupervisorJob and how they work.

victorbrandalise.com

자식이 서로 독립적으로 실패할 수 있다는 걸 빼면 일반 Job과 유사하다. 자식의 실패 or 취소는 SupervisorJob이 실패하게 하지 않으므로 SupervisorJob은 자식의 실패를 처리하기 위한 고유 정책을 만들 수 있다. SupervisorScope 또는 CoroutineScope(SupervisorJob())을 써서 생성된다. 

 

-> SupervisorJob()을 쓰지 않으면 코루틴 계층 안에서 부모-자식 코루틴들에게 양방향으로 전달된다 (부모->자식, 자식->부모). 하위 작업 하나가 실패하면 모든 작업이 실패하는 것이다. SupervisorJob을 쓰면 취소가 부모 작업에서 자식 작업으로만 전파된다.

Job : 생성된 코루틴을 고유하게 식별하고 생명주기를 관리할 수 있게 하는 백그라운드 작업. 완료로 끝나는 생명주기를 갖고 있으며 취소 가능하다. 부모를 취소하면 모든 자식이 재귀적으로 즉시 취소되는 부모-자식 계층 구조로 정렬될 수 있다. CancellationException 이외의 예외가 있는 자식 실패는 즉시 부모를 취소하고 결과적으로 다른 모든 자식을 취소한다. 이 동작은 SupervisorJob을 써서 커스텀할 수 있다.

 

root coroutine에 대해 Default 디스패처를 선호하라

 

루트 코루틴 안에서 백그라운드 작업을 수행하고 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)
}

위 코드에선 Default 디스패처가 있는 범위를 써서 루트 코루틴을 시작한다(1). 이 접근 방식을 쓰면 UI를 터치할 때마다 컨텍스트를 전환해야 한다(2). 대부분 Main 디스패처로 스코프를 생성하는 게 좋다. 그러면 코드가 더 단순해지고 컨텍스트 전환이 덜 명확해진다.

 

val scope = CoroutineScope(Dispatchers.Main)

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

 

async / await를 불필요하게 쓰지 마라

 

비동기 함수를 사용하고 즉시 await를 쓰는 경우 이 작업을 멈춰야 한다.

 

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

코루틴 컨텍스트를 전환하고 즉시 부모 코루틴을 suspend하려면 withContext가 바람직한 방법이다.

 

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

성능 면에서 큰 문제는 아니지만 의미상 비동기는 백그라운드에서 여러 코루틴을 시작한 다음에만 대기하고 싶어한다는 걸 의미한다.

 

scope job 취소를 피하라

 

코루틴을 취소해야 한다면 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)
}

위 코드의 문제는 작업 취소 시 완료 상태로 전환한다는 것이다. 완료된 Job의 범위에서 시작된 코루틴은 실행되지 않는다(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()
}

 

암시적 디스패처로 suspend fun을 작성하지 마라

 

특정 코루틴 디스패처의 실행에 의존하는 suspend fun을 만들지 마라.

 

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

위 코드에서 로그인 함수는 메인이 아닌 디스패처를 사용하는 코루틴에서 실행하면 충돌하는 suspend fun이다.

 

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

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

CalledFromWrongThreadException : 뷰 계층 구조를 생성한 원래 쓰레드만 해당 뷰를 만질 수 있음을 나타내는 에러

 

모든 코루틴 디스패처에서 실행할 수 있는 방식으로 suspend fun을 디자인하라.

 

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()
    ...
}

 

전역 범위(global scope) 사용을 피하라

 

안드로이드 앱의 모든 곳에서 GlobalScope를 쓰는 경우 이 작업을 중지하라.

 

GlobalScope.launch {
    // code
}

global scope는 전체 앱 수명 동안 작동하고 조기 취소되지 않는 최상위 코루틴을 시작하는 데 사용된다. 응용 프로그램 코드는 일반적으로 응용 프로그램 정의 CoroutineScope를 써야 하며 비동기를 사용하거나 GlobalScope 인스턴스에서 시작하는 것은 매우 권장하지 않는다.

안드로이드에서 코루틴은 액티비티, 프래그먼트, 뷰 또는 viewmodel lifecycle로 범위를 지정할 수 있다.

 

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
    }
}
반응형
Comments