코루틴은 아주 중요한 개념입니다.
Android에서 비동기 작업할 때 사용하기에 몰라서는 안되는 친구입니다!!
아직은 우리 코루틴과 친하지 않은 것 같아 친해지기 위하여 열심히 알아봤습니다...! 😇
코루틴
Co(함께, 서로) + routine(규칙적인 일의 순서, 작업의 집합) 2개가 합쳐진 단어로 함께 동작하며 규칙이 있는 일의 순서를 뜻합니다.
다른 말로 하면 실행의 지연과 재개를 허용함으로써, 비선점적 멀티태스킹을 위한 서브루틴을 일반화한 컴퓨터 프로그래밍 구성요소입니다.
보통 루틴은 일직선적인 흐름을 가지는데, 코루틴은 이 흐름을 중간에 suspend(지연) 했다가 resume(재시작)을 하는 것이 가능합니다.
코틀린에서는 suspend 라는 접두어를 붙이면 코루틴 즉, 중간에 멈출 수 있는 함수가 됩니다.
일반적으로 함수는 실행되면 끝날 때까지 실행되지만 suspend 함수는 멈췄다가 다른 작업을 하고 resume을 통해 다시 작업할 수 있습니다.
공식 가이드에 따르면 suspend 접두어를 사용하면 CPS (Continuation Passing Style) 이라는 것으로 변환이 되고 이게 Coroutine Builder를 통해 적절한 쓰레드 상에서 OS가 관리하는 시나리오에 따라서 동작하도록 구성됩니다.
구성요소
- Scope
- Context
- Builder
Scope
스코프에는 CoroutineScope와 GlobalScope가 존재합니다.
CoroutineScope는 특정 스코프 내에서만 유효하며, 스코프가 종료되면 해당 스코프 내의 코루틴도 함께 취소됩니다.
CoroutineScope는 코루틴을 시작하고 관리하는 범위(scope)를 정의하는 인터페이스입니다. 코루틴을 실행하려면 CoroutineScope가 필요하며, 이는 코루틴을 실행할 수 있는 컨텍스트와 생명 주기를 결정합니다.
GlobalScope는 애플리케이션 전체에서 사용할 수 있는 전역적인 스코프입니다. 이 스코프에서 실행된 코루틴은 특정 컴포넌트의 생명 주기와는 관계없이 계속 실행됩니다. 따라서 GlobalScope에서 실행된 코루틴은 수동으로 취소하지 않는 이상 계속 실행됩니다.
❓ 그럼 백그라운드 작업할 때 GlobalScope를 사용하면 좋은걸까?
공식 문서에는 GlobalScope를 아래와 같이 설명하고 있습니다.
Global scope is used to launch top-level coroutines which are operating on the whole application lifetime and are not cancelled prematurely.
전역 범위는 전체 애플리케이션 수명 동안 작동하고 조기에 취소되지 않는 최상위 코루틴을 시작하는 데 사용됩니다.
It is easy to accidentally create resource or memory leaks when GlobalScope is used.
GlobalScope를 사용할 때 실수로 리소스 또는 메모리 누수가 발생하기 쉽습니다.
GlobaScope는 작업이 실패했을 때 재시도나 상태 관리를 제공하지 않으며, 애플리케이션 종료 시 취소되지 않기 때문에 메모리 누수나 불필요한 리소스 소모가 발생할 수 있습니다.
공식문서에서 조차 GlobalScope는 제어되지 않는 범위에서 코드가 실행되고, 테스트가 어려워지고, 실행을 제어할 수 없다는 이유로 GlobalScope 사용을 피하라고 권장하고 있습니다.
Android의 코루틴 권장사항 | Kotlin | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Android의 코루틴 권장사항 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 페이지에서는 코루틴을
developer.android.com
그렇기에 백그라운드 작업은 GlobalScope보다는 공식문서에서 권장하는 WorkManger를 사용하는 것 같습니다.
앱 아키텍처: 데이터 영역 - WorkManager로 작업 예약 - Android 개발자 | Android Developers
데이터 영역 라이브러리에 관한 이 앱 아키텍처 가이드를 통해 지속적인 작업 유형과 기능 등을 알아보세요.
developer.android.com
Context
Coroutine Context의 주요 구성요소는 Dispatcher와 Job이 있습니다.
Dispatcher의 구성요소
public abstract class **CoroutineDispatcher**
: AbstractCoroutineContextElement, ContinuationInterceptor
public abstract class AbstractCoroutineContextElement
: CoroutineContext. Element
public interface ContinuationInterceptor
: CoroutineContext. Element
public interface Element
: CoroutineContext
public interface CoroutineContext
Job의 구성요소
public interface Job : CoroutineContext.Element
public interface Element
: CoroutineContext
public interface CoroutineContext
즉 Job와 Dispatcher는 모두 CoroutineContext의 구성요소입니다.
CoroutineContext는 코루틴이 실행되는 환경이라 생각하면 됩니다.
CoroutineContext는 Coroutine이 실행되는 환경에 대한 정보를 담고 있는 객체인데, 이는 Coroutine이 어떤 스레드에서 실행될지, 어떤 예외 처리를 할지, 어떤 작업 취소 메커니즘을 사용할지 등을 결정합니다.
여기서 조금 더 Deep 하게 들어가볼까요?!
CoroutineContext는 코루틴이 실행될 환경에 대한 정보를 담고 있으며, CoroutineContext의 각 Element는 코루틴을 어떻게 실행할지를 정의합니다.
위 내부코드를 살펴보면 총 4개의 메서드가 존재하는 것을 볼 수 있습니다.
• get() : operator 함수이며, key에 대한 Context를 반환한다.
public operator fun <E : Element> get(key: Key<E>): E?
• fold() : 초기값(initalVlaue)을 시작으로 제공된 병합 함수(operation)를 이용해서 대상 컨텍스트 요소들을 병합한 후 결과를 반환하는 함수
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
• plus() : 현재(기존) Context와 파라미터로 주어진 context가 갖는 요소(Element)들을 모두 포함하는 새로운 Context를 반환하는 함수
public operator fun plus(context: CoroutineContext): CoroutineContext
• minusKey() : 현재(기존) Context에서 주어진 key를 갖는 요소(Element)를 제외한 새로운 Context를 반환
public fun minusKey(key: Key<*>): CoroutineContext
❓ 여기서 Key란 무엇일까?
public interface Key<E : Element>
Key는 제네릭으로 Element 타입을 갖고, 이 Key를 기반으로 Context에 Element를 등록하는 것입니다.
요약하자면 코루틴 컨텍스트(CoroutineContext)에는 코루틴 컨텍스트를 상속한 요소(Element) 들이 등록될 수 있고, 각 요소들이 등록 될때는 요소의 고유한 키를 기반으로 등록된다는 것입니다.
여기서 Key에 해당하는 요소(Element)란, CoroutineContext를 구성하는 요소를 뜻하는데
CoroutineId, CoroutineName, Dispatchers, CoroutineInterceptor, CoroutineExceptionHandler 등을 의미합니다.
Coroutine Context의 구현체
이 상속구조를 보면 알겠지만, 총 3개의 구현체가 존재합니다.
- EmptyCoroutineContext
- 위 이미지에 보다시피 싱글톤 객체(object)입니다.
- 이름 그대로 특별한 컨텍스트가 명시되지 않았을 때 사용되는 객체입니다.
- ConbinedContext
- 두개 이상의 컨텍스트가 명시되면 컨텍스트 간 연결을 위한 컨테이너 역할을 하는 컨텍스트입니다.
- 예를 들어, Dispatcher.IO + Job 처럼 두 개의 컨텍스트 연결합니다.
- Element
- Context의 각 요소(Element)들도 CoroutineConext를 구현
launch() {..} 함수 내부 CoroutineContext를 추가하는 건데 Element는 Context의 구현체로, 즉 Element가 개별적인 Context입니다.
Element끼리 + 연산자로 설정을 하는데 +는 Context메서드 중 하나인 plus() 함수로 Element들을 합쳐 새로운 Context를 반환하는 것으로 보면됩니다.
Element + Element + ...는 결국 plus() 함수의 연속으로 하나로 병합된 Context를 만들어내는 점!
위 이미지에서 주황색 테두리는 Context구현체 중 하나인 CombinedContext로서 Context들 간을 묶어주는 하나의 CoroutineContext가 되는 개념입니다.
여기서 Continuation Interceptor 는 항상 마지막에 위치해있는데, 이는 인터셉터로의 빠른 접근을 위해서라고 커멘트 되어 있습니다.
대표적인 Continuation Interceptor 은 위에서 봤다시피 Dispatcher 입니다.
public abstract class CoroutineDispatcher: ..., ContinuationInterceptor
간단한 예시와 함께 보자!
launch { } 와 같은 코루틴 생성 함수 내부에서, 코루틴의 컨텍스트가 Element 들을 통해 구성됩니다.
이때 각 Element들은 plus() 연산자를 통해 하나의 CoroutineContext로 합쳐지며, 이 컨텍스트는 코루틴이 실행되는 동안 지속됩니다.
val context = Dispatchers.IO + CoroutineName("MyCoroutine") + Job()
launch(context) {
// 이 코루틴은 Dispatchers.IO에서 실행되고,
// CoroutineName이 "MyCoroutine"으로 지정되며,
// Job을 통해 취소 관리가된다.
}
get() 의 예시
fun main(){
val context = CoroutineName("MyCoroutine") + Dispatchers.IO
// 특정 키를 기반으로 요소 가져오기 get()
val coroutineName = context[CoroutineName]
println(coroutineName) // Output: CoroutineName(MyCoroutine)
val dispatcher = context[ContinuationInterceptor]
println(dispatcher) // Output: Dispatchers.IO
val missingElement = context[Job] // Job은 context에 없음
println(missingElement) // Output: null
val context = CoroutineName("MyCoroutine") + Dispatchers.IO + SupervisorJob()
val Element = context[Job] // Job은 SupervisorJob
println(missingElement) // Output: SupervisorJobImpl{Active}@527740a2
}
fold() 의 예시
fun main() {
val context = CoroutineName("MyCoroutine") + Dispatchers.IO
// fold를 사용해 모든 요소를 문자열로 병합
// acc는 누적값을 의미
val result = context.fold("") { acc, element ->
acc + "${element.key}: $element\\n"
}
println("Fold 결과:")
println(result)
// Output:
// Fold 결과:
// CoroutineName: CoroutineName(MyCoroutine)
// ContinuationInterceptor: Dispatchers.IO
}
minusKey() 예시
fun main() {
// CoroutineContext 생성: CoroutineName + Dispatcher
val context = CoroutineName("MyCoroutine") + Dispatchers.IO
println("Original Context:")
context.fold("") { acc, element ->
println("${element.key}: $element")
acc
}
// CoroutineName 요소를 제거한 새 Context 생성
val newContext = context.minusKey(CoroutineName.Key)
println("\\nModified Context:")
newContext.fold("") { acc, element ->
println("${element.key}: $element")
acc
}
// 확인: 제거된 요소는 null 반환
println("\\nRemoved CoroutineName: ${newContext[CoroutineName]}") // Output: null
}
// 결과
// Original Context:
// kotlinx.coroutines.CoroutineName$Key@1e397ed7: CoroutineName(MyCoroutine)
// kotlin.coroutines.ContinuationInterceptor$Key@490ab905: Dispatchers.IO
//
// Modified Context:
// kotlin.coroutines.ContinuationInterceptor$Key@490ab905: Dispatchers.IO
//
// Removed CoroutineName: null
이제 다시 넘어와서!
Dispatcher의 구성요소
Dispatcher는 코루틴이 실행되는 쓰레드를 지정하는데 4개의 옵션이 있습니다.
- Defalut
- 모든 표준 빌더에서 사용된다. 공유된 백그라운드 쓰레드의 공통 풀을 사용한다.
- Defalut는 주로 연속적인 작업, CPU를 많이 사용하는 작업 등을 수행할 때 적절하다.
- 스레드 풀에서 보통 CPU Core의 개수에 해당하는 Thread를 생성하여 할당이 가능하다.
- IO
- 파일 IO나 네트워크 IO를 사용하는 작업에서 사용된다.
- 스레드 풀 크기가 크며, 동시 실행 가능한 작업을 효율적으로 처리한다.
- Main
- 메인 안드로이드에서의 UI와 관련된 것을 변경해야할 때 사용된다.
- 메인 스레드는 오래 걸리는 작업을 실행하면 ANR이 발생할 수 있기에 조심해야 한다.
- Unconfined
- 코틀린 공식 문서에 따르면 일반적으로 코드에서 사용하면 안되는 Dispatcher라고 명시되어있다.
❓왜 Unconfined를 일반적인 상황에서 사용하지 않는 걸까요?
fun main() = runBlocking {
launch {
println("main runBlocking : ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) {
println("Unconfined : ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) {
println("Default : ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyOwnThread")) {
println("newSingleThreadContext : ${Thread.currentThread().name}")
}
}
// 출력
// Unconfined : I'm working in thread main
// Default : I'm working in thread DefaultDispatcher-worker-1
// main runBlocking : I'm working in thread main
// newSingleThreadContext: I'm working in thread MyOwnThread
여기서 알 수 있는 점은 Unconfined는 main thread에서 실행되고 있다는 점입니다.
그렇다면 Unconfined는 메인 쓰레드에서 실행되는 코루틴인 걸까요??
위의 예시 때문에 그렇게 생각할 수 있지만 메인 쓰레드에서 실행되는 코루틴은 아닙니다.
Dispatchers.Unconfined 는 코루틴을 호출한 쓰레드에서 코루틴을 실행하지만, 코루틴이 일시 중단되었다가 다시 재개될 때는 어떤 스레드에서 실행될지 보장되지 않는 특수한 디스패처입니다.
이를 조금 더 자세히 설명하면 다음과 같습니다.
Unconfined 코루틴을 시작하면 이 코루틴을 호출한 쓰레드, 위 예시에서는 main 쓰레드에서 실행됩니다.
코루틴이 일시 중단되었다가 재개되면, 그 다음 실행은 호출 쓰레드와 상관없이 다른 쓰레드에서 실행될 수 있는 것입니다.
이 특성 때문에 Dispatchers.Unconfined는 다른 디스패처와 다르게 동작합니다.
주의할 점은, 이 디스패처는 특정 상황에서는 예측하지 못한 동작을 할 수 있으므로, 주로 UI 업데이트나 특정 쓰레드에서 작업이 수행되어야 하는 경우에는 사용하지 않는 것이 좋습니다.
예를 들어 Android UI에서는 메인 쓰레드에서 UI를 업데이트 해야 하기 때문에 Dispatchers.Main을 사용하는 것이 적합합니다.
Job
루틴을 실행하고 값을 반환하지 않을 때에는 launch { .. } 로 실행시켜 Job 객체를 반환합니다.
하지만 코루틴을 사용하면서 값을 반환해야할 필요가 있을 때에는 async { .. } 로 코루틴을 실행시키는데 이는 Deferred 객체를 반환하게 됩니다.
코루틴에는 3개의 지연 방법이 있습니다.
- delay : 딜레이의 정해진 시간동안 코루틴 실행을 중단시켜 대기 상태로 만든다.
- join : launch { .. } 로 실행한 코루틴에 대해서는 join을 사용하여 대기시켜야 한다.
- await : async { .. } 로 실행한 코루틴은 await을 사용하여 대기시켜야 한다.
코루틴 Builder
코루틴 Builder는 코루틴을 생성하고 실행하는데 사용되는 메서드입니다.
대표적으로 launch { } async{ } withContext runBlocking 등이 있습니다.
1. launch{ }
launch 는 결과값을 반환하지 않고 백그라운드 작업을 실행할 때 사용됩니다.
launch 블록으로 실행한 코루틴을 대기시키고 싶을 땐 join 을 사용해야 합니다.
반환 타입은 Job 입니다.
fun coroutinetest() {
CoroutineScope(Dispatchers.IO).launch {
delay(2000)
println("Task completed!")
}
println("Waiting for task...")
println("Done!")
}
// 실행 결과
// Waiting for task...
// Done
// 2초 후
// Task completed!
suspend fun coroutinetest() {
val job = CoroutineScope(Dispatchers.IO).launch {
delay(2000)
println("Task completed!")
}
println("Waiting for task...")
job.join() // 작업이 끝날 때 까지 대기
println("Done!")
}
// 실행 결과
// Waiting for task...
// 2초 후
// Task completed!
// Done!
이 때, join 을 사용하여 작업이 끝날 때 까지 일시 중단(suspend)된다는 특성으로 인해 suspend fun 을 사용해야 합니다.
2. async
async 는 결과값을 반환하는 비동기 작업에 사용됩니다.
반환값 타입은 Deferred<T> 입니다.
suspend fun coroutineText() {
val deferred = CoroutineScope(Dispatchers.IO).async {
delay(2000)
"Async!!"
}
println("Waiting for result...")
val result1 = deferred.join()
val result2 = deferred.await()
println("result1 : $result1")
println("result2 : $result2")
}
// 실행 결과
// Waiting for result...
// 2초 후
// result1 : kotlin.Unit
// result2 : Async!!
값을 반환받기 위해서는 await 를 사용해야 합니다.
이 Deferred<T> 는 Job 인터페이스를 상속한 인터페이스입니다.
즉, Deffered<T> 도 Job인 것입니다!
3. withContext
withContext는 코틀린 코루틴에서 특정 코루틴 컨텍스트를 설정하고, 그 안에서 지정된 블록의 코드를 실행하는 함수입니다.
쉽게 말해서, 코루틴이 실행될 환경을 바꿔주는 것입니다.
withContext 의 가장 큰 특징은 새로운 Job을 생성하지 않는다는 것입니다.
결과값을 Job이 아닌 제네릭으로 반환하는 하는 것을 볼 수 있습니다.
또한 코루틴 컨텍스트를 newCoroutineContext 메서드를 통해 새로운 컨텍스트로 전환하는 것을 볼 수 있습니다.
async 와 유사하게 반환값을 가지면서도 가장 큰 차이점은 새로운 코루틴을 생성하는 것이 아닌, 현재 실행 중인 코루틴의 컨텍스트를 변경시켜 실행한다는 것입니다.
그렇기에 withContext 는 비동기 병렬 처리 작업에 사용되는 async 와는 다르게 순차적(동기적)으로 실행된다는 것입니다.
// withContext
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
withContext(Dispatchers.IO){
delay(1000)
}
withContext(Dispatchers.IO){
delay(1000)
}
withContext(Dispatchers.IO){
delay(1000)
}
println("Time elapsed: ${System.currentTimeMillis() - startTime} ms")
}
// 실행 결과
// Time elapsed: 3046 ms
// async의 병렬처리
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job1 = async(Dispatchers.IO){
delay(1000)
}
val job2 = async(Dispatchers.IO){
delay(1000)
}
val job3 = async(Dispatchers.IO){
delay(1000)
}
awaitAll(job1, job2, job2)
println("Time elapsed: ${System.currentTimeMillis() - startTime} ms")
}
// 실행 결과
// Time elapsed: 1025 ms
또 다른 차이가 있는데, 예외를 처리하는 방식입니다.
fun main() = runBlocking {
try {
val async = async(Dispatchers.Default){
throw Exception() // async 블록 내에서 catch 해야 함...
}
async.await()
} catch (e: Exception) {
println("error catch")
}
}
// 실행 결과 (예외를 잡지 못함)
// Exception in thread "main" java.lang.Exception
fun main() = runBlocking {
try {
withContext(Dispatchers.Default) {
throw Exception()
}
} catch (e: Exception) {
println("error catch")
}
}
// 실행 결과 (catch로 잡아 print문 처리)
// error catch
위와 비슷한 이유인데, async 는 새로운 코루틴을 실행시켰기에 예외가 발생한 그 블록 내부에서 예외를 잡아야 합니다.
하지만 withContext 는 새로운 코루틴을 생성한 것이 아니라 컨텍스트만 변경한 것이기에 예외를 잡을 수 있던 것입니다.
❓ 그렇다면 Job이 뭘까?
launch, async 의 반환값은 전부 Job이었습니다.
Job은 코루틴에서 중요한 개념 중 하나입니다.
코루틴은 비동기 작업을 실행하는 단위인데, 이 코루틴의 실행 상태를 관리하고 제어할 수 있게 해주는 객체가 바로 Job입니다.
Job은 코루틴의 실행 상태를 추적하고, 취소, 대기, 완료 등 코루틴의 생명 주기를 제어하는 객체입니다. 기본적으로 Job은 코루틴의 실행을 시작하고 종료하며, 코루틴을 취소하거나 결과를 기다리는 등의 작업을 할 수 있습니다.
코루틴의 생명주기는 아래와 같습니다.
이 생명주기 상태에 접근하기 위해서는 Job에서 아래의 변수에 접근하여 확인할 수 있습니다.
isActive 는 Job이 활성상태라면 true를 반환합니다.
또한 하위 작업이 완료되기를 기다리고 있는 작업은 취소되거나 실패하지 않은 경우 계속 활성 상태인 것으로 간주됩니다.
isCompleted 는 어떤 이유로든 이 작업이 완료되면 true를 반환합니다.
취소되거나 실패하여 실행이 완료된 작업도 완료된 것으로 간주됩니다.
isCancelled 는 취소를 명시적으로 호출하거나 작업이 실패했거나 하위 또는 상위가 취소되어 어떤 이유로든 이 작업이 취소된 경우 true를 반환합니다.
일반적인 경우 작업이 이미 완료되었음을 의미하지 않습니다.
아래의 표와 함께 본다면 이해하기 쉬울 것 입니다.
New | false | false | false |
Active | true | false | false |
Completing | true | false | false |
Cancelling | false | false | true |
Cancelled | false | true | true |
Completed | false | true | false |
이제는 스레드와 코루틴이 헷갈리기 시작했다….
코루틴에 대하여 자세하게 알아본다곤 했지만 너무 양이 방대합니다....
또 너무 어렵습니다... 파도 파도 괴담이야...!!! 😂
'안찌의 개발일기 > Android' 카테고리의 다른 글
[Android] Composition 뜯어보기 (0) | 2025.01.08 |
---|---|
[Android] by remember의 리컴포지션 문제 (Compose) (1) | 2024.11.19 |
[Android] Compose Preview 심폐소생술!! (0) | 2024.11.19 |
[Android] 서버 통신 (2) | 2024.09.25 |
[Android] 코루틴 (Coroutine) (1) | 2024.06.18 |