본문 바로가기

Kotlin

[Kotlin] 코루틴(coroutine)(2) - 코루틴 사용 방법

이번 포스팅에서는 코루틴 사용 방법에 대해 알아보자.

 

CouroutineContext

: 코루틴 처리를 어떻게 할 것인지에 대한 요소들의 집합

 

CouroutineContext 요소

Dispatcher : 코루틴을 처리한 스레드를 setting하고 할당하는 역할

Job : 생성된 코루틴을 컨트롤 (생명주기, 상속관계 정리 및 관리)

 

Coroutine Dispatcher

  • 코루틴을 스레드에 배분하는 역할
  • 코루틴을 시작하거나 재개할 스레드를 결정하기 위해 사용
  • 스레드 풀에서 스레드를 하나 할당해 코루틴을 배당
    코루틴은 스레트풀을 생성하지만 직접 제어하지는 않고 오직 Dispatcher을 통해서만 제어 가능
  • 코루틴 디스패처는 CoroutineDispatcher 인터페이스를 구현해야 함

 

Dispatcher 종류

Dispatchers.Main
: Android Main(UI) Thread에서 Coroutine을 실행하는 Dispatcher
  반드시 UI 와 상호작용하기 위해서만 사용

Dispatchers.IO
: File Input/Output , Network IO, Parser 작업에 최적화된 Dispatcher

Dispatchers.Default
: CPU 사용량이 많은 작업에 사용대규모 Sorting, Graphics Rendering 등

Dispatchers.Unconfined
 : 특수한 상황에서 코루틴을 실행 (사용을 권장하지 않음)

 

코루틴 사용해보기

 

1. 앱 수준의 gradle 추가

// 코루틴 사용
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")

 

2. Coroutine Scope 지정

 

Coroutine Scope

- 코루틴이 실행되는 범위를 생성

- 다양한 Scope 확장함수(= Coroutine Builder)를 이용하여 코루틴을 생성

 

Coroutine Scope 종류

CoroutineScope

  • 가장 기본적인 스코프
  • Job을 설정하지 않을 시 디폴트로 job 생성
val scope = CoroutineScope(Dispatchers.Main)

val job = scope.launch {
    
}

job.cancel()

 

일반적으로 안드로이드 activity에서는 다음과 같이 정의하여 코루틴 스코프와 activity의 lifecycle을 일치시킨다.

class PlaygroundActivity() : CoroutineScope { // 코루틴 스코프를 상속

    private lateinit var job: Job
    override val coroutineContext: CoroutineContext // 코루틴 컨텍스트를 재정의
        get() = Main + job

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        job = Job()
        ...
    }

    fun doOnBackground() {
        launch(IO) {
            // network, file I/O, DB operation
            withContext(Main) {
                // Update UI
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        job.cancel()
    }
}

 

GlobalScope

  • 코루틴 스코프 & 객체
  • GlobalScope란 lifetime이 프로그램 전체를 가진 lifetime을 의미 (전역 스코프)
    Application이 시작하고 종료될 때까지 계속 유지
  • Singletone 이기 때문에 따로 생성하지 않아도 되며 어디에서든 바로 접근이 가능하여 간단하게 사용하기 쉽다
  • GlobalScope를 사용하면 메모리 누수의 원인이 될 수 있기 때문에 신중히 사용해야 한다
    앱이 실행된 이 후 계속 수행이 되어야 한다면 GlobalScope 를 사용해야 하는 것이고,
    특정 Activity나 Service 에서만 잠깐 사용하는 것이라면 GlobalScope를 사용하면 안된다.
fun main() {

    GlobalScope.launch {
    
    }
}

 

 

ViewModelScope

  • 앱의 각 ViewModel을 대상으로 함
  • 이 범위에서 시작된 모든 코루틴은 ViewModel이 삭제되면 자동으로 취소됨
    ViewModel이 활성 상태인 경우에만 실행해야 할 작업이 있을 때 유용
class MainActivityViewModel : ViewModel() {
    private var userRepository = UserRepository()
    var users: MutableLiveData<List<User>> = MutableLiveData()

    fun getUserData() {
        viewModelScope.launch {   
        . . .
       
        }
    }
}

 

LifecycleScope

  • 실행하는 액티비티의 lifecycle안에서 코루틴이 실행되며 액티비티의 destroy와 함께 종료
LifecycleScope의 한계점
onDestroy 시 Job을 cancel하므로 백그라운드로 내려가는 onStop이 일어났을 때 여전히 Job이 수행된다.
즉 activity를 finish 시키는 것이 아닌 onStop만 되었다면 activity에서는 여전히 데이터를 수집하게 된다.
이러한 불필요한 동작은 백그라운드로 내려간 앱의 메모리 사용량을 증가시켜 시스템에 의한 crash를 만들어낼 수 있고, 사용자가 원치 않은 데이터 사용이 일어날 수 있다.
이러한 경우 onStop에서 cancel을 해주면 된다.
그러나 cancel 메소드를 추가하게 되어 보일러 플레이트 코드를 생성시키게 된다.
이러한 상황을 방지하기 위해 안드로이드에서는 onStart에서의 job의 생성과 onStop에서의 job의 cancel을 위한 API를 제공한다.
바로 lifecycleScope에서 쓸 수 있는 repeatOnLifeCycle()이다.

 

 

3. CoroutineBuilder로 코루틴 생성

코루틴을 실행 시키려면 coroutine builder가 필요하다.

 

CoroutineBuilder

: 일시 중단 람다를 받아 그것을 실행시키는 코루틴을 생성하는 함수
  독립적인 것이 아니라 스코프가 있기 때문에 실행 가능

 

빌더의 종류에 대해 알아보자.

  • launch: 결과값을 반환하지 않는 코루틴.
    Job 객체를 리턴
    자체/ 자식 코루틴 실행을 취소할 수 있는 Job 반환.
  • async: 결과값을 반환하는 코루틴.
    결과가 예상되는 코루틴 시작에 사용(결과 반환)
    전역으로 예외 처리 가능
    결과, 예외 반환 가능한 Deferred 반환
    Defferred 로 감싸서 값 리턴.
  • runBlocking(): T (코루틴 완료할 때 까지 스레드를 점유)
    Blocking 코드를 일시 중지(Suspend) 가능한 코드로 연결하기 위한 용도
    main함수나 Unit Test때 많이 사용됨
    코루틴의 실행이 끝날 때 까지 현재 스레드를 차단함
    CoroutineBuilder에서 반환된 Job, Deferred 객체를 이용하여 각각의 Coroutine을 제어할 수 있다.
  • withContext(): T 반환(결과값 T 를 그대로 반환하는 코루틴)
  • actor(): SendChannel
  • produce(): ReceiveChannel
import kotlinx.coroutines.*

fun main() {
    GlobalScope.launch {  
        delay(1000L) //1초 딜레이
        println("World!")
    }
    println("Hello,")
    Thread.sleep(2000L)
}
// Hello,가 먼저 출력하고 World! 출력

//delay() : suspending function 일시중단
//sleep() : thread를 blocking

 

GlobalScope.launch { delay(1000L) println("World!") }

→ 이부분이 코루틴 빌더이다.

 

일반적으로 async와 launch가 많이 사용된다.

// Async 사용
val result = GlobalScope.async {
  ...
}
reult.await()

// Launch 사용
val result2 = GlobalScope.launch {
  ...
}
result2.join()

 

위 코드를 보면 알 수 있듯이 async는 await(), launch는 join() 메소드를 이용해 코루틴 종료를 기다린다.

 

그렇다면 launch와 runBlocking의 차이는 무엇이 있을까?

import kotlinx.coroutines.*
import kotlin.concurrent.thread

fun main() {
    GlobalScope.launch{
        delay(1000L)
        println("World!")
    }

    println("Hello,")
    runBlocking {
        delay(2000L)
    }
}

 

launch 자신을 호출한 스레드를 블로킹하지 않는다.

runBlocking 스레드를 블로킹한다.

 

 Coroutine Job 함수

start : 현 코루틴의 상태를 알아내어 동작 중 = true, 준비/종료 = false
join : 현 코루틴이 종료되기를 기다림, async Deferred의 await 와 같은 역할.
cancel : 현 코루틴을 즉시종료 (Thread의 interrupt 와 같은 역할).
cancelAndJoin : 현 코루틴을 종료하고 대기.
cancelChildren : 현 Coroutine Scope 내에 작성한 자식 코루틴들을 종료.부모 코루틴은 종료되지 않음.

 

 

이렇게 기본적인 코루틴 사용방법에 대해 알아보았다.

다음으로 코루틴의 장점과 어떻게 사용하는 것이 효율적인지 알아보자.

 

Coroutines ARE light-weight

코루틴은 light-weight threads이다.

코루틴이 상당히 가벼움을 직접 코드로 확인해보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(100_000) { //십만개 코루틴을 한번에 만들어서
        launch {
            delay(1000L) // 1초뒤에 점을 찍게 만듦
            print(".")
        }
    }
}

위 코드를 스레드로 바꿔보면 아래와 같다. 

import kotlinx.coroutines.*
import kotlin.concurrent.thread

fun main() = runBlocking {
    repeat(100_000) { //십만개 코루틴을 한번에 만들어서
        launch {
            thread {
                Thread.sleep(1000L)
            }
            print(".")
        }
    }
}

실행시켜보면 코루틴 코드보다는 스레드 코드가 훨씬 버벅인다.

코루틴이 스레드보다는 구조적으로 훨씬 가벼운 것을 확인할 수 있다.

 

 

Thread.sleep(2000L)을 runBlocking으로 바꿔보자

fun main() {
    thread{
        //delay(1000L)
        Thread.sleep(1000L)
        println("World!")
    }
    println("Hello,")
    delay(2000L) // 오류남
}

delaysuspending function이므로 코루틴 스코프 안에서만 실행되거나 다른 suspending function에서만 실행된다.

그래서 명시적으로 blocking하는 코루틴을 만들어준다.

 

좀더 관용적인 형태

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        GlobalScope.launch{
            delay(1000L)
            println("World!")
        }

        println("Hello,")
        delay(2000L)
    }
}

runBloking을 마지막에만 사용하는 것이 아니라 코드 전체를 감싼다

→ 메인함수가 완료되기전에 리턴되지 않기를 원하기 때문이다.

이렇게 전체를 감싸면 전체 코드가 다 실행되기 전까지는 리턴되지 않는다.

 

Job을 기다려 보기

delay를 사용하는 것은 좋은 접근이 아니다.

그래서 delay가 없게 하는 방법으로 명시적으로 job을 만들어 기다리도록 한다.

import kotlinx.coroutines.*

fun main() { //1. 메인함수 실행되면 
    runBlocking {
        GlobalScope.launch{ //2. 코루틴 실행되고
            delay(3000L) //4. 3초뒤에 world를 찍으려고 하면 프로그램 끝나서 안찍힘
            println("World!")
        }

        println("Hello,")
        delay(2000L) //3. 2초동안 기다렸다가 프로그램 끝냄
    }
}
//hello만 출력됌

 

위 코드는 world가 출력되기 전에 프로그램이 끝난다.

이를 어떻게 해결하는가 → job을 만들어서 기다린다.

launch를 하게되면 반환되는게 job이다.

 

import kotlinx.coroutines.*

fun main() = runBlocking { 
    val job = GlobalScope.launch { 
        delay(3000L)
        println("World!")
    }
    println("Hello,")
    job.join() // wait until child coroutine completes
}

//hello world 모두 출력

job.join()

job객체에 조인을 하게 되면 이 job이 완료될때까지 기다리다가 종료한다.

 

Structured concurrency
→ join,  job등을 관리하지 않아도 여러 코루틴을 기다려줄 수 있음

이전 예제에서 코루틴이 완료되는 것을 기다려보려고 sleep처리, job객체에 join을 거는 등을 한다.

코루틴 여러개 걸면 하나하나 다 처리하기 번거롭다.

이런 방식보다 좀더 좋은 솔루션 → structured concurrency

 

structured concurrency

runblocking과 launch 실행된 코루틴이 구조적 관계를 가지면 서로 기다려줄 수 있다.

 

import kotlinx.coroutines.*

fun main() = runBlocking {this:coroutineScope //여기서 launch 하자
    this.launch {  //GlobalScope 가 없는 것을 볼 수 있음
        delay(1000L)
        println("World!")
    }

    this.launch {  
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}
//hello world world 출력
// 최종코드
import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {  
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}
//hello world 출력

즉 GlobalScope에서 launch하지 말고

runBlocking에서 들어온 이 코루틴스코프에서 launch하자.

 

top level 코루틴을 만들지 말고

이 코루틴의 child로 코루틴을 만들면 부모코루틴이 완료되는 것까지 다 기다려주기 때문에

이런 구조적 형태를 추구하자.

 

Extract function refactoring

이번에는 suspending function을 만들어보고 왜 필요한지 알아보자.

목적 : world 찍는 부분을 따로 함수로 만들어 호출

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        myWorld()
    }
    println("Hello,")
}

suspend fun myWorld() {
    delay(1000L) // 호출하려면 suspend 키워드 붙여야함
    println("World!")
}

 

 

delay를 호출하려면 suspendig function이 필요함을 알 수 있다.

 

Global coroutines are like daemon threads

코루틴이 계속 실행이 되고있다고 해서 프로세스가 유지되는 것은 아니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    GlobalScope.launch {
        repeat(1000) { i ->
            println("I'm sleeping $i ...")
            delay(500L)
        }
    }
    delay(1300L) // just quit after delay
}

프로세스가 살아있을 때만 동작한다.

 

 

Suspend ↔Resume 

import kotlinx.coroutines.*

fun main() = runBlocking {
    // 2개 코루틴이 5번씩 실행됌
    launch {
        repeat(5) { i ->
            println("Coroutine A, $i")
        }
    }

    launch {
        repeat(5) { i ->
            println("Coroutine B, $i")
        }
    }

    println("Coroutin Outer")
}

결과

Coroutin Outer Coroutine A, 0 Coroutine A, 1 Coroutine A, 2 Coroutine A, 3 Coroutine A, 4 Coroutine B, 0 Coroutine B, 1 Coroutine B, 2 Coroutine B, 3 Coroutine B, 4

 

→다 메인스레드에서 출력됌

여기서 일시중단이나 재개같은 느낌이 없음

delay를 호출해보자

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        repeat(5) { i ->
            println("Coroutine A, $i")
            delay(10L)
        }
    }

    launch {
        repeat(5) { i ->
            println("Coroutine B, $i")
        }
    }

    println("Coroutin Outer")
}

결과 → a가 찍히다 b가 찍히고 a가 찍힘

Coroutin Outer Coroutine A, 0 Coroutine B, 0 Coroutine B, 1 Coroutine B, 2 Coroutine B, 3 Coroutine B, 4 Coroutine A, 1 Coroutine A, 2 Coroutine A, 3 Coroutine A, 4

 

 

다음 포스팅에서는 코루틴을 취소하는 방법들에 대해 자세히 알아보자.