1. 코루틴 소개
코루틴이란 코틀린, 안드로이드에서 간단하게 비동기적으로 코드를 실행 할 수 있는 동시성 디자인패턴이다 (구글 문서 발췌)
비동기 프로그래밍이란?
- 여러 작업을 동시에 수행할 수 있도록 하는 프로그래밍 패러다임
- Non blocking 방식으로 수행하며 CPU리소스를 효율적으로 사용
- 전통적으로는 쓰레드 기반 작업으로 진행되었음 (쓰레드 -> 코루틴)

synchronous - 작업을 순서대로 수행
asynchronous - 순서와 무관하게 작업을 수행
Blocking - Caller가 Callee를 호출 하면서 작업을 멈추고 대기함
Non-Blocking
- 효율성 - 쓰레드 생성및 컨텍스트 스위칭 할때마다 비용이 많이 발생 (CPU, 메모리)
- 코드 관리 - 예외 처리 및 취소가 쉽지 않음, 쓰레드 간 데이터 교환이 어려움, 코드 가독성이 좋지 않음
쓰레드 방식의 문제를 개선한 코루틴
- 자원 효율성 - 쓰레드보다 휠씬 적은 메모리와 CPU를 사용, 중단 메커니즘을 통해 하나의 쓰레드 안에서 여러 작업을 계속 할 수 있음 --> light weighted thread 라고도 불림
- 코드 관리 - 예외 처리 및 취소가 쉬움(API가 잘 설계되어있음), 쓰레드간 데이터 소통이 쉬움, 가독성이 쓰레드 방식보다 좋음
2. 코루틴 작성 방법
<kotlin />
fun main() {
// Coroutine을 만드는 Builder
// caller thread를 코루틴이 끝날때까지 block함
// main 함수 같이 특별한 경우에만 사용
runBlocking {
// Coroutine을 만드는 Coroutine Builder
// Coroutine Scope 안에서만 사용 가능
// Caller Coroutine을 중단하지 않음
launch {
}
}
}
// 중단 함수
// Coroutine Scope 안에서만 사용 가능
// Caller Coroutine을 중단시키고 자신의 작업을 수행
suspend fun abc() {
}
Coroutine Scope의 이해
- 비동기 작업을 할 때, 작업들의 상관관계와 범위를 나눠서 생각 해야함
- 부모자식 관계: 어떤 작업안에 내부적에 다른 어떤 작업들이 있음
- 작업 의존 관계: 어떤 작업이 끝나야 다음 작업을 시작하는
여기서 Coroutine Scope의 이름답게 코루틴의 작업 범위를 의미함
Coroutine Scope로 작업 범위를 만드는 것으로 코루틴이 시작
코루틴엔 내부에 많은 API 및 중단함수가 있는데, Coroutine Scope안에서만 사용 가능
--> Coroutine Scope의 확장함수이기 때문에...
결론
- Coroutine Scope는 코루틴의 실행 범위를 정의하며, 코루틴의 생명주기를 관리
- Structured concurrency(구조화된 동기성)을 제공함 (부모가 종료 되면 모든 자식 코루틴이 종료됨)
- 부모 자식 관계 구조에 따라서, Scope의 종료 및 취소가 전파
Coroutine Context의 이해
- Coroutine Scope에 바인딩 되는 실행 환경 정보
- 코루틴의 이름, 디스패처, 잡 등 실행에 필요한 정보들을 보유
- Coroutine Scope 내에 Context가 정의 되어있음
- 정보들은 연결 리스트의 형태로 존재
- Context를 추가하고 싶을 때는 플러스 연산을 사용가능
- 타입은 고유함
- 주요 Context : Coroutine Name, Job, Dispatcher
- 미리 정의 된 Scope, 커스텀 Scope 두 종류가 있음
- 미리 정의된 Scope
-
GlobalScope : 앱의 생명주기와 함께 하는 Scope
- lifecycleScope : Android에서 컴포넌트의 라이프사이클에 해당하는 Scope
-
viewModelScope : Android에서 ViewModel의 라이프사이클에 해당하는 Scope
-
- 커스텀 Scope - CoroutineScope 함수를 이용해서 직접 생성
- 미리 정의된 Scope
Coroutine Builder의 이해
Coroutine Scope은 작업에 대한 정보와 범위의 명세
Scope 정보를 기반으로 실제 코루틴을 메모리에 올리려면 Coroutine Builder를 사용
주요 API
- runBlocking : 특수한 경우에만 사용
- launch : 실행 후 제거
-
Caller Coroutine을 중단하지 않음
-
생성된 작업을 의미하는 Job을 리턴
-
비동기 작업 실행 요청하고, 자기 할 일 하는 경우에 주로 사용
-
- async : 실행 후 대기 가능
-
Caller Coroutine을 중단하지 않음
- 생성되는 값을 가져올 수 있는 Deferred<T>를 리턴 -> 코루틴을 중단하며 컨드롤 가
-
비동기 작업 실행 요청하고 자기 할 일을 하는데 나중에 작업 결과를 받아오고 싶을 때 사용
-
- 중단 함수 (suspend fun)
-
중단(suspend), 재개(resume)가 가능한 함수
-
Thread Blocking 없이 다른 작업을 기다릴 수 있음
-
비동기 코드의 결과 값을 콜백 없이 return 방식으로 순차적으로 받아올 수 있음
-
- 다른 쓰레드를 이용하고 싶을 때 - Dispatcher
- Coroutine Builder에 Context로써 정의하거나 withContext를 사
- 코루틴을 어느 쓰레드를 보낼 지 정의
-
Dispatchers.Main : 주로 UI 작업을 위해 사용
-
Dispatchers.IO : I/O 작업을 위해 사용.
-
Dispatchers.Default : CPU 집약적 작업을 위해 사용
-
Dispatchers.Unconfined : 호출한 쓰레드에서 이어서 작업을 하기 위해 사용
-
3. 취소
작업은 취소도 가능해야함
- 화면을 종료해서 더이상 데이터를 받아올 필요가 없는 경우...
- 시간이 너무 오래걸려 작업을 진행할 수 없는 경우...
만약 취소를 하지 않으면, 사용하지 않는 CPU와 메모리에 낭비가 있음
코루틴의 실행 취소는 CoroutinContext의 한 종류인 Job이 관리
Job
- 코루틴의 생명주기 관리
- CoroutineContext의 한 종류로써 CoroutineScope가 갖고 있음
- 부모 자식을 갖고 있기에, 구조적 동시성을 지원해 줌
<kotlin />
// launch
fun main() {
runBlocking {
// launch는 코루틴을 만들며 Job을 리턴함
val launchJob = launch {
// ...
}
delay(1000)
// 이때 Job을 취소 할 수 있음
launchJob.cancel()
}
}
<kotlin />
// async
fun main() {
runBlocking {
// async는 코루틴을 만들며 Deffered<T>을 리턴함
// Deffered는 Job의 자식 인터페이스
// Deffered를 통해 취소 가능
val asyncJob = launch {
// ...
}
delay(1000)
// await 중에 취소하게 되면 해당 지점에서 취소 관련 에러 방생
asyncJob.await()
// 이때 Job을 취소 할 수 있음
asyncJob.cancel()
}
}
++ 추가
withTimeout
- 시간 제한을 두고 취소하고 싶을 때 사용
- 중단 함수에서 입력받은 결과를 기다렸다가 반환
- 시간이 초과 되면 TimeoutCancellationException 반환
CoroutineScope
- 정의된 Scope 내의 모든 코루틴을 취소
- Scope는 내부에 등록된 Job을 탐색해서 취소를 요청
viewModelScope
- Android에서 자주 사용하는 취소의 대표적인 예시
- 내부적으로 Activity 관찰자는 Activity가 정말 종료 되는 경우에만 취소
취소가 될 때 자원을 해체해야 한다면?
- 메모리 관리를 위해 직접 해제를 해줘야 할 때가 있음
- try finally
- 취소가 요청되면 코루틴이 중단 되는 시점에서 CancellationException 발생
- 예외 처리로 finally부분에 자원을 해제 --> use 메소드 -> Closable 인터페이스가 구현되어있어 use {} 를 사용하면 내부적으로 finally { close() } 를 호출
- invokeOnCompletion
- Job에 대해서 종료가 될 때 불리는 리스너를 등록할 수 있음 → invokeOnCompletion를 리스너 처럼 사용하여 이 메소드 안에서 자원을 해
- 정상 종료와 취소 모두 불리며, 인자로 받는 예외가 무엇이냐로 취소인지 확인 할 수 있음
- suspendCancellableCoroutine
- 콜백 기반 API와 호환해야 할 때는 suspendCancellableCoroutine이 유용
- continuation을 콜백 안에서 사용해서 코루틴을 완료할 수 있음
4. 예외 처리
코루틴 내부에서 예외처리하기
- 코루틴 내부에서 예외가 발생하면 앱이 죽어버림 (안드로이드)
예외를 다루는 방법
- try - catch
- CoroutineExceptionHandler
Try - Catch
- 기본적으로 에러가 나올 수 있는 로직에 걸어 오류 처리
- 대부분의 에러 처리는 이 방식으로 처리하는 것을 권장
- 단, async의 경우 예외가 발생할 시, 즉시 처리하는게 아니라 await()가 불리는 시점에서 처리됨 try - catch를 await() 시점에 사용해야 함
CoroutineExceptionHandler
- Handler가 적용된 Scope 내부에서 에러가 발생하면 try-catch 구문에서 에러를 처리하듯이 해당 Scope에 설정된 에러 핸들러에서 에러를 받아서 처리 가능
부모 자식 관계에서의 예외 처리
- 취소와 마찬가지로 에러가 발생하면 자식 코루틴들은 취소 됨
- 취소와 다르게 에러가 발생하면 부모 코루틴도 취소가 됨 → 취소 요청을 받은 부모 코르틴은 다시 모든 자식 코루틴(결과적으로 형제 코루틴)을 취소 함
- 결과적으로 계층 구조 내 모든 코루틴에 에러가 전파 됨
SupervisorJob
한 코루틴이 죽어도 다른 코루틴을 죽고 싶지 않게 할 때 사용 -> 코루틴의 에러 전파 범위를 제어할 수 있음
5. 공유 자원
코루틴 역시 여러 쓰레드에서 작업 단위가 동시에 실행 되는 구조이기 문에 공유 자원에 대해 동기화가 필요
synchronized
- lock을 통해서 한 번에 단 하나의 쓰레드만 내부 블럭을 실행하도록 함
- 하지만 synchronized는 블럭 내부에서 중단 함수를 부를 수 없고, 다른 쓰레드가 블로킹이 되버릴 수 있기 때문에 효율적이지 못 함
Atomic instance
- Atomic 객체는 연산을 할 때 여러 쓰레드가 충돌해도 원자성을 보장해줄 수 있음
- CoroutineScope에서 실행될 수 있고, 쓰레드간 블로킹이 일어나지 않기 때문에 간단한 공유자원의 경우 권장
- 여러 라인을 블럭단위로 막아주는 것은 아니기 때문에 복잡한 로직에서는 원자성을 보장하지 못함
SingleThreadDispatcher
- 여러 쓰레드 경합이 문제의 원인이므로 하나의 쓰레드에서만 코루틴이 실행되도록 하면 해결이 됨
- 하지만 여러 쓰레드를 쓰지 못하기 때문에 성능적인 한계 존재
Mutex
- Mutex를 사용하면 특정 구간에 대해서 다른 쓰레드의 접근을 막을 수 있음
- synchronized와 비슷하게 withLock 을 사용 할 수 있음
- syncronized에 비해 큰 장점은 경합이 발생한 경우 쓰레드를 블로킹 하는 것이 아니라 중단 시킴
Semaphore
- Mutex와 비슷하게 Semaphore를 사용하면 구간에 대해서 다른 쓰레드의 접근을 막을 수 있음
- 차이점은 접근 쓰레드를 여러개로 설정할 수 있음
- withPermit을 통해 사용 가능
6. Channel
Queue 처럼 이루어져 있는 구조로, 여러 코루틴에서 데이터를 안전하고 효율적으로 주고 받고 싶을 때 사용한다 (코루틴 연결)
send를 통해 삽입하고, receive를 통해 받는다.
생산자 - 소비자 패턴 플로우를 따른다.
- 한 코루틴에서는 데이터를 지속적으로 생산해서 send 하고 한 코루틴에서는 데이터를 지속적으로 receive해서 소비함
- Channel의 경우 병목 현상이 일어나도 쓰레드 블로킹이 아닌 중단이 되는 장점이 있음 (중단 가능한 코루틴)
Sender와 Receiver의 전송속도 차이가 있을 수 있기 때문에 channel의 capacity 정책(총량)이 필요하다.
- RENDEZVOUS (default)
-
데이터를 쌓을 수 있는 버퍼가 없는 경우(capacity: 0)
-
생산이 되면 다음 소비가 일어날 때까지 생산이 안 됨
-
- UNLIMITED
-
-무한하게 데이터가 쌓일 수 있기 때문에 Out of Memory 조심해야 함
-
-
Buffered
-
고정된 버퍼를 사용하는 경우 (시스템이 수량을 결정)
-
-
CONFLATED
-
Capacity가 1인 경우지만, 데이터가 추가로 들어올 경우 가장 마지막 데이터가 버퍼를 덮어씀
-
++ 버퍼를 넘어서서 들어오는 경우의 정책
1. SUSPEND(default) - 생산 함수를 중단시키고 대기
2. DROP_OLDEST - 가장 오래된 데이터를 삭제하고 추가
3. DROP_LATEST - 가장 최 데이터를 삭제하고 추가
7. Flow
리액티브 프로그래밍을 지원하기 위한 API로써, 데이터 스트림을 나타냄
Reactive Programming ->연속된 데이터의 변화를 관찰(Observer Pattern)하고 변화된 데이터를 가공(Functional Programming)해서 지속적으로 전파 하는 프로그래밍 패러다임
Flow에서 이론적으로 쓰이는 용어들
생산자(Producer) : 데이터를 발행해서 Stream을 만들어주는 요소
중개자(Intermediary) : Stream 내부에 data를 목적에 맞게 수정해주는 연산자. 생략 가능
소비자(Consumer) : Stream을 관찰하고 발행되는 데이터를 소비해서 최종 결과 로직을 수행하는 요소
Flow를 만드는 법
<kotlin />
// 1 - 직접 생성
flowOf(
// 인스턴스 삽입 - 타입 일치시켜야함
)
// 2 - 형 변환
Collection.asFlow()
IntRange.asFlow()
// 3 - flow builder
// 데이터를 방출(emit) 할 수 있는 블럭을 만들 수 있음
// collect는 연결된 Flow가 종료될때 혹은 해당 코루틴이 종료될 때 까지 유지
flow {
emit(data)
}
// 4- callbackFlow
// callback은 suspend function이 아니기 때문에 emit을 호출 불가능
// callbackFlow { } 를 통해 데이터를 보낼(trySend) 수 있는 블럭을 만들 수 있음
// awaitClose {} 를 통해 flow가 종료됬을 때 실행할 코드 블럭을 등록 할 수 있음
callbackFlow {
trySend(data)
// ..
awaitClose { println(data) }
}
StateFlow
- Flow + StateHolder의 구조로 기본 Flow에 상태에 관련된 기능이 추가된 것
- 현재 상태 보존(마지막 상태 값 보존) 및 변경이 일어난 데이터만 방출(이벤트 발생시 데이터가 그대로면 방출 안함) 됨
- 주로 안드로이드의 뷰 상태를 나타내는데 사용
- 이런 특성으로 뷰가 재생성 되어도 마지막 상태를 읽고 렌더링 가능하며, 변경이 되었을 때만 새로 그리게 제어할 수 있음
Cold Stream vs Hot Stream
데이터(flow)를 발행하는 방식에 따라 Cold, Hot 형태로 나뉨
- Cold Stream
- 느리게 데이터를 발행, 요청을 하지 않으면 발행 시작을 하지 않음
- 데이터를 발행하는 방식을 정의해야하고, 실제 구독자가 나타나야 데이터를 생성해서 방출함
- Hot Stream
- 빠르게 데이터를 발행, 요청을 하지 않아도 발행 시작
- 데이터가 발행되는 즉시 방출하고, 자신을 구독하는 모든 곳에 방출함
SharedFlow
- 데이터를 공유하고 싶을 때 마지막 상태가 필요없는 경우도 있음
- 대표 예시 : 이벤트 버스
- 이벤트를 지속적으로 방출하는 스트림을 만들고 싶다면 SharedFlow를 사용
'Language > Kotlin' 카테고리의 다른 글
더블 콜론 참조(::) (0) | 2024.05.09 |
---|---|
URL Encoding (0) | 2024.04.02 |
Sealed Class (0) | 2023.09.15 |
[Kotlin] Scope function (0) | 2023.07.31 |