이동식 저장소

[Kotlin] Coroutines - Basics 본문

Primary/Kotlin

[Kotlin] Coroutines - Basics

해스끼 2021. 1. 19. 11:05

코루틴

코루틴이란 비동기적으로 실행되는 코드를 간단하게 작성하기 위해 사용할 수 있는 설계 패턴이다.

  • 코루틴은 실행 중인 스레드를 중단시키지 않는 suspend를 지원하기 때문에 하나의 스레드에서 여러 개의 코루틴을 실행할 수 있다. 코루틴은 스택조차 없기 때문에 스레드보다도 메모리를 더 절약할 수 있다.
  • 코루틴은 작업 범위를 설정하여 그 안에서 비동기 작업을 구조화한다. 글을 읽다 보면 무슨 말인지 알게 될 것이다.
  • 코루틴은 부모-자식 간의 관계를 설정하여 자식의 취소(코드 중단)를 부모에게 전달할 수 있다.
  • (안드로이드 한정) 많은 Jetpack 라이브러리에서 코루틴을 완벽히 지원하고 있다.

시작하기 전에

사실 코루틴은 코틀린의 기본 패키지에 포함되어 있지 않다. 플러그인을 로드해야 코루틴을 제대로 사용할 수 있다. IntelliJ IDEA 기준으로 Settings-Project Structure-Libraries에서 Maven을 선택하자. Android Studio에서도 동일하다.

설정 화면

kotlinx-coroutines-core로 검색하여 최신 stable 버전을 설치하자. Android Studio에서 사용하려면 core 말고 kotlinx-coroutines-android를 설치해야 한다.

패키지 설치

정상적으로 설치했으면 IntelliJ에서 kotlinx-coroutines-core 패키지를 확인할 수 있다.

설치 완료

Basics

다음의 코드를 무작정 실행해 보자.

fun main() {
    GlobalScope.launch { 
        delay(1000L) 
        println("World!")
    }
    println("Hello,") 
    Thread.sleep(2000L) 
}
Hello,
World!

Hello,가 출력되고 약 1초 후에 World!가 출력된다. 위의 코드에서는 launch 코루틴 빌더에 의해 코루틴이 실행되었다. GlobalScope에서 코루틴이 실행되었는데, GlobalScope 안의 코루틴은 전체 프로그램과 생명 주기가 같다. 즉 프로그램이 종료되지 않는 한 계속해서 실행될 수 있다.

물론 GlobalScope를 잘 이용하면 daemon 스레드와 비슷한 효과를 낼 수 있다.

blocking vs. non-blocking

위에서 delay()Thread.sleep()을 사용하였다. 초보 프로그래머라도 이 함수의 의미를 쉽게 알 수 있을 것이다. 두 함수는 모두 일정 시간(ms) 동안 프로그램의 작동을 멈추지만, 각각 _non-blocking_과 _blocking_이라는 큰 차이점이 있다.

  • _non-blocking_이란 호출된 함수(callee)가 _어떤 값_을 즉시 반환하여 caller에게 자신의 작업을 계속 할 수 있도록 한다. 물론 callee의 실행 결과까지 즉시 반환되지는 않을 수 있으므로 진짜 결과값은 나중에 받아야 한다. launch 함수가 _non-blocking_이다.
  • _blocking_이란 callee가 실행될 때까지 caller를 기다리게 하는 것을 의미한다. 보통 우리가 작성하는 함수는 _blocking_이다.

참고: Blocking-NonBlocking, Synchronous/Asynchronous

blocking 함수 Thread.sleep() 대신 non-blocking 함수만 사용해 보자.

import kotlinx.coroutines.*

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

출력 결과는 같지만 사용한 함수가 다름을 확인할 수 있다. runBlocking은 블럭 안의 코드가 모두 실행될 때까지 caller의 스레드를 _block_한다.

그런데 위의 코드는 코루틴이 마구 얽혀 있어 보기에 좋지 않다. 사실 다음과 같이 main 함수 전체를 runBlocking으로 묶을 수도 있다.

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    GlobalScope.launch {
        delay(1000L)
        println("World!")
    }
    println("Hello,")
    delay(2000L)
}

runBlocking<Unit> {...}을 사용하여 top-level 코루틴을 작성하였다. 브래킷 안의 Unit은 반환 값을 의미한다. 코틀린에서 main 함수는 Unit을 반환하기 때문이다.

같은 방법을 사용하여 suspending 함수를 테스트하는 코드를 작성할 수 있다. suspending 함수가 무엇인지는 나중에 알아볼 것이다.

class MyTest {
    @Test
    fun testMySuspendingFunction() = runBlocking<Unit> {
        // 여기서 suspending 함수를 테스트
    }
}

작업이 완료되었습니다

사실 위에서처럼 일정 시간 동안 delay하는 코드는 별로 좋지 않다. 작업이 언제 끝날 줄 알고?

일정 시간 동안 기다리는 대신, 작업이 완료될 때까지 기다리는 것이 더 안전하다. non-blocking 방식으로 코루틴이 종료될 때까지 기다려 보자.

val job = GlobalScope.launch { // launch는 Job 객체를 반환한다.
    delay(1000L)
    println("World!")
}
println("Hello,")
job.join() // job이 완료될 때까지 기다린다.
Hello,
World!

여전히 결과는 같지만, 로직 자체가 완전히 달라졌음을 알 수 있다. launch 함수가 반환하는 Job 객체를 사용하여 해당 코드가 완료될 때까지 기다렸다.

Structured concurrency

GlobalScope.launch를 사용하면 최상위 코루틴을 만들 수 있다. 코루틴 역시 코드이기 때문에 (적은 양이지만) 프로그램이 실행되는 동안 메모리를 차지한다. GlobalScope 코루틴은 프로그램이 종료될 때까지 실행될 수 있기 때문에, 철저히 관리하지 않으면 메모리 누수가 발생하기 쉽다. 따라서 GlobalScope 대신 structured concurrency 개념을 적용하여 코루틴의 실행 범위를 지정하는 것이 좋다.

다음의 코드를 보자.

fun main() = runBlocking { // this: CoroutineScope
    launch { // runBlocking의 제어 안에서 launch 실행
        delay(1000L)
        println("World!")
    }
    println("Hello,")
}

runBlocking을 사용하여 최상위 코루틴을 작성하였다. runBlocking을 포함한 모든 코루틴 빌더는 CoroutineScope 객체를 블럭 안에서 참조할 수 있다. 이 안에서 실행된 launchrunBlocking의 자식 코루틴이 되고, runBlocking은 다음 줄을 실행하기 전에 자식(여기서는 launch)이 종료될 때까지 기다린다.

Hello,
World!

Scope builder

물론 자동으로 제공되는 CoroutineScope 대신 coroutineScope 함수를 이용하여 직접 범위를 지정할 수 있다. coroutineScope 함수를 이용하여 만든 코루틴 스코프는 모든 자식이 종료될 때까지 기다린다.

위에서 살펴본 runBlocking 역시 자식이 종료될 때까지 기다렸다. 하지만 runBlocking은 기다리는 동안 스레드를 _block_하며, coroutineScope는스레드를 _suspend_하기 때문에 스레드가 다른 작업을 실행할 수 있다. _block_과 _suspend_를 잘 구분하자.

  • blocking: 코드가 실행될 때까지 스레드를 잡아둔다. 따라서 해당 스레드는 기다리는 것 외의 다른 작업을 할 수 없다.
  • suspend: 코드가 실행되는 동안 스레드는 다른 작업을 수행할 수 있다.

다음의 코드를 직접 실행하여 이해해 보자.

fun main() = runBlocking { // this: CoroutineScope
    launch { 
        delay(200L)
        println("Task from runBlocking") // 2
    }

    coroutineScope { // Creates a coroutine scope
        launch {
            delay(500L) 
            println("Task from nested launch") // 3
        }

        delay(100L)
        println("Task from coroutine scope") // 1
    }

    println("Coroutine scope is over") // 4
}
Task from coroutine scope
Task from runBlocking
Task from nested launch
Coroutine scope is over

coroutineScope가 완료되지 않았지만("Task from nested launch"가 아직 출력되지 않았지만) launch의 "Task from runBlocking"이 출력되었음을 알 수 있다. 즉 coroutineScope 내부의 launch를 기다리는 동안 외부의 launch가 실행될 수 있는 것이다.

물론 최상위 runBlocking은 내부의 모든 코루틴이 종료될 때까지 기다려야 하지만, coroutineScope을 기다리는 동안 다른 작업(외부의 launch)를 수행할 수 있다는 말이다.

suspend 함수

맨 처음의 "Hello World" 코드에서 launch 부분을 별도의 함수로 떼어내 보자.

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

fun doWorld() {
    delay(1000L)
    println("World!")
}

그런데 코드를 보면 다음과 같은 에러가 발생한다.

Suspend function 'delay' should be called only from a coroutine or another suspend function

doWorld는 코루틴에서 실행되고 있긴 하지만 코루틴 자체는 아니므로, doWorldsuspend 함수로 만들어야 한다.

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

// suspending function
suspend fun doWorld() {
    delay(1000L)
    println("World!")
}

suspend 함수 내부에서는 delay등의 다른 suspend 함수를 사용할 수 있다. 즉 suspend 함수를 호출하는 함수 역시 _suspend_여야 한다는 말이다.

코루틴은 정말 가볍습니다

다음의 코드를 실행해 보자.

fun main() = runBlocking {
    repeat(100_000) { // launch a lot of coroutines
        launch {
            delay(5000L)
            print(".")
        }
    }
}

이 코드는 10만 개의 코루틴을 만든다. 코루틴 대신 스레드를 사용하면 아마도 OutOfMemoryError 류의 에러가 발생할 것이다.

참고 문헌

Basics - Kotlin Programming Language (kotlinlang.org)

Comments