Apple is Apple
article thumbnail
Published 2024. 3. 7. 22:07
[Kotlin] Coroutine Language/Kotlin

1. 코루틴 소개

코루틴이란 코틀린, 안드로이드에서 간단하게 비동기적으로 코드를 실행 할 수 있는 동시성 디자인패턴이다 (구글 문서 발췌)

 

비동기 프로그래밍이란?

  • 여러 작업을 동시에 수행할 수 있도록 하는 프로그래밍 패러다임
  • Non blocking 방식으로 수행하며 CPU리소스를 효율적으로 사용
  • 전통적으로는 쓰레드 기반 작업으로 진행되었음 (쓰레드 -> 코루틴)

synchronous vs asynchronous

synchronous - 작업을 순서대로 수행

asynchronous - 순서와 무관하게 작업을 수행

 

Blocking - Caller가 Callee를 호출 하면서 작업을 멈추고 대기함

Non-Blocking

Non Blocking - Caller는 Callee를 호출하고 대기하지 않음. 바로 다음 작업을 수행
Asynchronous with non blocking 
-각 작업을 요청하고 바로 다른 일을 시작
-작업의 실행 순서와 완료 순서가 다를 수 있음
 
전통적으로 쓰레드 방식을 사용 - 각 쓰레드가 각 작업을 수행 (Job#1 - Thread#1, Job#2 - Thread#2...)  
 
쓰레드 방식의 문제점
  • 효율성 - 쓰레드 생성및 컨텍스트 스위칭 할때마다 비용이 많이 발생 (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 함수를 이용해서 직접 생성 

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가 정말 종료 되는 경우에만 취소

 

취소가 될 때 자원을 해체해야 한다면?

- 메모리 관리를 위해 직접 해제를 해줘야 할 때가 있음

      1. try finally
        1. 취소가 요청되면 코루틴이 중단 되는 시점에서 CancellationException 발생
        2. 예외 처리로 finally부분에 자원을 해제 --> use 메소드 -> Closable 인터페이스가 구현되어있어 use {} 를 사용하면 내부적으로 finally { close() } 를 호출
      2. invokeOnCompletion
        1. Job에 대해서 종료가 될 때 불리는 리스너를 등록할 수 있음 → invokeOnCompletion를 리스너 처럼 사용하여 이 메소드 안에서 자원을 해
        2. 정상 종료와 취소 모두 불리며, 인자로 받는 예외가 무엇이냐로 취소인지 확인 할 수 있음
      3. suspendCancellableCoroutine
        1. 콜백 기반 API와 호환해야 할 때는 suspendCancellableCoroutine이 유용
        2. 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
    1. 느리게 데이터를 발행, 요청을 하지 않으면 발행 시작을 하지 않음
    2. 데이터를 발행하는 방식을 정의해야하고, 실제 구독자가 나타나야 데이터를 생성해서 방출함
  • Hot Stream
    1. 빠르게 데이터를 발행, 요청을 하지 않아도 발행 시작
    2. 데이터가 발행되는 즉시 방출하고, 자신을 구독하는 모든 곳에 방출함

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
profile

Apple is Apple

@mjjjjjj