본문 바로가기

Kotlin

Coroutine Details

Unsplash, Ant rozetsky.

지극히 개인적인 생각들

Kotlin 을 독학하고 사용한 지 1년이 조금 넘는 기간 동안, 수없이 많은 코루틴 코드를 작성해왔습니다.

처음에는 단순히 비동기 처리를 위한 수단으로만 사용하였고, 코루틴이 뭔지도 잘 모르고 사용했습니다. 코루틴의 가장 큰 장점인 동기식 코드 작성법 자체를 사용하지 않았으며, Flow 의 존재도 몰라 비동기 함수 실행 결과 데이터를 고차함수로 넘겨주곤 했습니다. 

 

그래도 현재는 조금 익숙해져서 모듈화에도 적용하고, Flow 도 사용하면서 이런저런 방식으로 사용하고 있는데요. 그럼에도 불구하고 자세히 학습하지 않아 긴가민가한 부분들도 있고, 당연한 것임에도 뭐가 뭔지 잘 모르는 것들도 많습니다.

 

오늘은 해당 부분들에 대해 학습한 뒤 기록해 봅니다.

 


1. Job

처음은 가볍게 Job 입니다.

Job 은 코루틴의 실행 상태를 추적하고 취소하거나 완료 여부를 확인하는 데 사용됩니다. 각각의 코루틴은 하나의 Job 에 의해 관리되는 것입니다.

 

val job: Job = viewModelScope.launch {
    // doSomething
}

 

CoroutineScope.launch() 에 의해 생성되며, 코루틴 그 자체를 의미하기도 합니다.

주요 함수는 다음과 같습니다.

 

  • cancel() : 코루틴을 취소합니다. 코루틴의 실행을 중단을 유도합니다.
  • join() : 코루틴이 cancel() 메서드가 호출되어 Cancelling 상태에 도달했을 때, Cancelled 될 때까지 대기합니다.
  • cancelAndJoin() : cancel() 과 join() 을 함께 호출해 줍니다. 이를 통해 확실하게 코루틴이 종료되었는지 확인할 수 있습니다.
  • isActivte : 현재 코루틴이 활성화 상태인지 여부를 나타내는 Boolean 타입 프로퍼티며, isActive 가 True 이면 실행 중이거나 실행 가능한 상태임을 의미합니다. 
  • isCompleted : 코루틴이 완료되었는지 여부를 나타내는 Boolean 타입 프로퍼티입니다.
  • isCancelled : 코루틴이 취소되었는지 여부를 나타내는 Boolean 타입 프로퍼티입니다.
  • invokeOnCompletion { } : 코루틴이 완료되거나 취소될 때 호출할 콜백을 등록할 수 있습니다.

 

Job 에 대해서 깊게 알아보면 코루틴 자체를 이해하는 데에 큰 도움이 될 것 같습니다.

 


2. Cancellation

 

코루틴을 취소시키기 위해서는 cancel() 메서드를 사용하는데, cancel() 메서드는 해당 코루틴을 cancelling 상태로 만들며, 이 자체로 코루틴이 수행하던 작업이 취소되지는 않습니다.

 

네트워크 통신을 통한 데이터 로드나 파일 읽기 및 쓰기, DB 접근 등의 작업은 경우에 따라 긴 시간이 소요될 수 있으며, 임의의 이유로 코루틴이 취소되어도, 명시적으로 취소하지 않는 한 해당 작업이 취소되지는 않습니다.

 

확실한 중도 취소를 위해서는 주기적인 활성 상태 확인이 필요합니다. 이는 1번에서 언급한 코루틴의 isActive 를 활용하여 가능합니다.

 


3. SupervisorJob(), supervisorScope { }

자식 코루틴에서 CancellationException 이 아닌 예외가 발생하는 경우, 이는 부모 코루틴에 전파됩니다. 예외를 전달받은 부모 코루틴은 취소되며, 예외가 발생한 자식 코루틴 외의 다른 코루틴도 모두 취소됩니다.

 

init {
    val parent = CoroutineScope(Dispatchers.Default + coroutineExceptionHandler).launch {
        val child1 = launch {
            throw Exception()
        }.invokeOnCompletion {
            println("Child 1 Ends : $it")
        }

        val child2 = launch {
            repeat(10) {
                delay(100)
                println(it)
            }
        }.invokeOnCompletion {
            println("Child 2 Ends : $it")
        }
    }
}

// Console
Child 1 Ends : java.lang.Exception
Child 2 Ends : kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job=StandaloneCoroutine{Cancelling}@bbd7460
CoroutineExceptionHandler : java.lang.Exception

 

이와 같이 작성하면, 부모 코루틴에 해당하는 parent 에 child1 에서 발생한 예외가 전달됩니다.

Child 2 의 취소 사유를 보면, 부모 Job 이 취소되었기 때문이라고 정확하게 알립니다.

 

여러 개의 통신을 하나의 코루틴 내부에서 진행하는 상황이 좋은 예시가 될 수 있습니다. 그 중 하나가 취소되더라도, 나머지 성공한 통신의 결과를 UI 에 표시해주어야 한다면 어떻게 코드를 작성해야 할까요?

 

이 때, 우리는 SupervisorJob()supervisorScope { } 를 사용할 수 있습니다.

 

val parent = CoroutineScope(Dispatchers.Default + coroutineExceptionHandler).launch {
    val child1 = launch(SupervisorJob()) {
        throw Exception()
    }.invokeOnCompletion {
        println("Child 1 Ends : $it")
    }

    val child2 = launch {
        repeat(10) {
            delay(100)
            println(it)
        }
    }.invokeOnCompletion {
        println("Child 2 Ends : $it")
    }
}

// Console
// Console
CoroutineExceptionHandler : java.lang.Exception
Child 1 Ends : java.lang.Exception
0
1
2
3
4
5
6
7
8
9
Child 2 Ends : null

 

위와 같이 정상적으로 출력됩니다. SupervisorJob() 은 이 것이 파라미터로 넘겨진 코루틴의 예외가 부모로 전파되지 않도록 합니다. 단, CoroutineExceptionHandler 가 적용되어 있어야 합니다.

 

물론, 예외 처리가 필요한 자식 코루틴 내부에 try-catch 문이나 runCatching { } 등을 활용할 수는 있습니다. 다만, 들여쓰기도 깊어지고 같은 예외에 대해 여러 번 같은 코드를 작성해야만 합니다.

 

supervisorScope { } 는 다음과 같이 사용할 수 있습니다.

 

val parent = CoroutineScope(Dispatchers.Default + coroutineExceptionHandler).launch {
    supervisorScope {
        val child1 = launch {
            throw Exception()
        }

        val child2 = launch {
            repeat(10) {
                delay(100)
                println(it)
            }
        }.invokeOnCompletion {
            println("Child 2 Ends : $it")
        }
    }
}

// Console
CoroutineExceptionHandler : java.lang.Exception
Child 1 Ends : java.lang.Exception
0
1
2
3
4
5
6
7
8
9
Child 2 Ends : null

 

supervisorScope { } 는 내부에 또 다른 코루틴을 선언할 수 있는 suspend 블럭 하나를 제외하고는 파라미터를 추가할 수 없습니다. (조금 아쉬운 부분입니다. 확장 함수 등을 통하여 해결할 수는 있겠습니다.) 

supervisorScope { } 역시 suspend 키워드가 붙어 있어서, CoroutineContext 가 설정된 코루틴 내부에 한 번 래핑하는 방식으로 사용할 수 있습니다. 상황에 따라 예외는 있겠습니다만, 간단히 정리하자면 다음과 같습니다.

 

부모 코루틴 내의 특정 작업의 개별 실패가 허용되는 경우 -> SupervisorJob()

부모 코루틴 내의 모든 작업의 개별 실패가 허용되는 경우 -> supervisorScope { }

부모 코루틴 내의 모든 작업의 개별 실패를 허용하지 않는 경우 -> 해당 없음

 

최근 진행하고 있는 프로젝트에서는 모든 코루틴에 대해, DI 된 단 하나의 CoroutineExceptionHandler 로 예외 처리하는 방식을 채택하고, 고도화 시 적용하기로 하였습니다. 모든 뷰모델에 각기 다른 CoroutineExceptionHandler 를 선언하고 동일한 조건에 대해 중복되는 코드를 작성하기 싫었기 때문입니다. 

 

오류를 분류하는 기준은 서버에서 내려주는 에러 메시지에 포함된 자체 스탠다드 넘버인데, 이 경우 파일 읽기 또는 쓰기이미지 변환 등 네트워크와 관련이 없는 작업에서 오류가 발생할 경우 핸들링하기 애매합니다. 이와 같은 상황에서는 SupervisorJob() 을 활용하여 개별적으로 에러 핸들링 정책을 수립할 수 있을 것 입니다.

 


4. ViewModelScope (detail)

androidx.lifecycle 패키지의 ViewModel 을 상속받은 클래스에서 사용할 수 있는 프로퍼티이며, 내부적으로는 3번에서 소개한 SupervisorJob()Dispatchers.Main.immediate 을 사용합니다.

 

public val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(
            JOB_KEY,
            CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
        )
    }

 

MVI 아키텍처 사용 시, State 및 Effect 의 무결성을 위해 Main.immediate 를 사용하여야 합니다. 다만, ViewModel 에서 수행하게 되는 작업이기 때문에 별도의 코루틴을 사용할 이유는 없고, 그냥 ViewModelScope 를 바로 사용하면 됩니다.

 

// Wrong
        viewModelScope.launch(Dispatchers.Main.immediate) {
            // do Something
        }

// Correct
      viewModelScope.launch {
            // do Something
        }

 


5. collect() vs launchIn()

마지막입니다.

대규모 프로젝트에 착수하여 작업해 본 경험이 없어, collect() 와 launchIn() 의 차이를 크게 체감할 수는 없었지만, 이론 상으로는 collect() 의 경우 해당 코루틴 컨텍스트 내에서 바로 값을 소비하고, launchIn() 의 경우 전과 다르게 별도의 새로운 코루틴을 생성하여 해당 코루틴 스코프 내에서 값을 소비하는 방식입니다.

 

CoroutineScope(Dispatchers.Default).launch {
    flowOf(1, 2, 3).onEach {
        delay(100L)
    }.collect {
        println(it)
    }

    flowOf("a", "b", "c").collect {
        println(it)
    }
}

// Console
1
2
3
a
b
c

 

collect() 의 경우 suspend 키워드가 붙어있어, 코루틴 내의 작업은 동기식으로 진행됩니다.

 

fun start() {
    CoroutineScope(Dispatchers.Default).launch {
        flowOf(1, 2, 3).onEach {
            println(it)
        }.launchIn(viewModelScope)

        flowOf("a", "b", "c").onEach {
            println(it)
        }.launchIn(viewModelScope)
    }
}

// Console
a
b
c
1
2
3

 

소스 코드에서는 flowOf(1, 2, 3) 이 먼저 작성되어 있으므로, 동기식으로 코드가 진행되는 코루틴 특성 상  1, 2, 3 이 먼저 출력되어야 할 것 같은데, 이와 관계 없이 a, b, c 가 먼저 출력됩니다.

 


확실히 알아보니, 재미있는 사실들이 많았습니다. 코루틴에 대해서는 앞으로 몇 번 더 다루어 보아야 확실하게 이해할 수 있지 않을까 싶습니다.

'Kotlin' 카테고리의 다른 글

Coroutines 파헤치기  (0) 2023.10.06
Flow Cancellation (feat. Exception Handling)  (0) 2023.09.24
Kotlin Closure  (0) 2023.05.04
Generics 와 Reified 키워드  (0) 2023.04.21
by 를 사용한 Kotlin 의 Delegation Pattern  (0) 2022.11.17