Kotlin Coroutine (6) : Coroutine Builder

Giyun Kim·2026년 3월 2일

Kotlin Coroutine

목록 보기
6/8
post-thumbnail

0. 들어가며

이번 글에서는 마르친 모스카와의 Kotlin Coroutine 6장을 기반으로, Coroutine Builder에 대해 알아볼 것이다. 본격적으로 kotlinx.coroutines 라이브러리를 다루는 만큼, 기본적인 Coroutine 지식을 학습하지 못했다면 이전 글을 읽고 오는 것을 추천한다.

1. Coroutine Builder

앞선 글에서 중단 함수는 Continuation 객체를 다른 중단함수로 전달해야 한다는 내용을 다룬 적 있다.
해당 내용에서 유추할 수 있듯, 중단함수가 일반함수를 호출할 수는 있으나, 그 역은 성립하지 않는다.

suspend fun suspendingFunction() {
	defaultFunction() /* 문제 없음 */
}

fun defaultFunction() {
	suspendingFunction() 
    /* Error 발생. 
     * Suspend function should be called 
     * only from a coroutine or another suspend function 
     */
}

위 예시는 모든 중단 함수는 타 중단함수에 의해 호출돼야 한다는 것을 보여준다.

한 가지 의문점이 생기지 않는가?

닭은 달걀을 낳는다.
달걀에서 병아리가 부화하고 닭으로 자라난 뒤 다시 달걀을 낳는다.
그럼 최초의 닭은 누구일까?

중단함수를 연속으로 호출하면 시작되는 지점이 반드시 존재한다. 그 역할을 하는 것이 바로 Coroutine Builder로서, 일반 함수와 중단 가능한 개체 간을 연결하는 가교가 된다.

kotlinx.coroutines 라이브러리가 제공하는 세 가지 Coroutine Builder는 각자의 쓰임새가 다르니 잘 살펴보도록 하자.

  • launch
  • runBlocking
  • async

2. Builder의 종류

2-1. launch Builder

launch가 작동하는 방식은 마치 thread 함수를 호출해 새 thread를 시작하는 것과 유사하다.

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

위 예시의 출력 순서는 어떻게 될까?

Hello, World
(1초 후)
Hello, World
Hello, World
Hello, World

launch 함수는 CoroutineScope 인터페이스의 확장함수다.

CoroutineScope란? 해당 인터페이스는 부모 Coroutine과 자식 Coroutine 간 관계 정립 목적으로 사용되는 구조화된 동시성의 핵심이다. 해당 개념은 추후에 다루기로 한다.

다시 launch로 돌아와서, 예시에서는 Coroutine의 동작을 직관적으로 보여주고자 GlobalScope 객체에서 launch를 호출하는 방식을 사용했다. 실제로는 사용하지 않는 방식이며 GlobalScope의 활용은 지양해야 한다.

또한 main의 끝자락에 Thread.sleep이 호출된 것을 볼 수 있는데, 스레드를 잠들게 하지 않으면 main은 Coroutine을 실행하자마자 끝나는 불상사가 발생한다. 즉, Coroutine이 일할 기회조차 없는 것이다. 그렇기에 1초 뒤 Coroutine이 일한 결과를 보기 위해 넣어두었다.

일단 launch가 작동하는 방식은 데몬 스레드(백그라운드에서 돌아가는 우선 순위가 낮은 스레드)와 꽤 유사하지만 훨씬 가볍다. 둘을 비교한 이유는, 둘 다 프로그램이 종료되는 걸 막는 기능이 없기 때문이다.

그럼 우리는 실행 결과를 보기 위해 항상 Thread.sleep을 걸어야 할까? 결론은 그렇지 않다.

2-2. runBlocking Builder

Coroutine이 스레드 블로킹 없이 작업을 중단시키기만 하는 것이 일반적이다. 하지만 앞선 예제처럼 main이 너무 빨리 끝나는 등 블로킹이 필요한 경우도 있다. 이럴 때는 runBlocking을 사용하면 된다.

fun main() {
	runBlocking {
    	delay(1000L)
        println("Hello, World")
    }
    runBlocking {
    	delay(1000L)
        println("Hello, World")
    }
    runBlocking {
    	delay(1000L)
        println("Hello, World")
    }
}

실행 결과는 아래와 같다.

Hello, World
(1초 후)
Hello, World
(1초 후)
Hello, World

runBlocking은 실제로는 특수한 경우에만 사용된다.

  • 프로그램 종료를 막고자 스레드를 블로킹할 필요가 있는 메인함수에서 사용
  • 유닛 테스트
fun main() = runBlocking {
	GlobalScope.launch {
    	delay(1000L)
        println("Hello, World")
    }
    GlobalScope.launch {
    	delay(1000L)
        println("Hello, World")
    }
    GlobalScope.launch {
    	delay(1000L)
        println("Hello, World")
    }
    println("Hello, World")
    delay(2000L) /* Thread.sleep 대신 교체 가능. */
}

이전까지 runBlocking은 CoroutineBuilder로 중요하게 사용되었지만 현재는 거의 사용되지 않는다.

  • main에서는 runBlocking 대신 suspend를 붙여 중단함수로 만드는 방법을 주로 사용한다.
  • 유닛 테스트에서도 Coroutine을 가상시간으로 실행시키는 runTest를 주로 사용한다.

2-3. async Builder

async Coroutine Builder는 얼핏 보면 launch와 비슷하지만, 람다 표현식에 의해 반환되는 값을 생성하도록 설계되어 있다.
이 때 async 함수는 Deferred<T> 타입 객체를 리턴하며, T는 생성되는 값의 타입이다.

Deferred에는 작업이 끝나면 값을 반환하는 중단 함수인 await가 있다.

val resultDeferred : Deffered<Int> = GlobalScope.async {
	delay(1000L)
    42
}

println(resultDeffered.await()) /* 42 */

launch Builder처럼, async Builder 역시 호출되자마자 Coroutine을 즉시 시작하기에, 몇 개의 작업을 한 번에 시작하고 모든 결과를 한꺼번에 기다릴 때 주로 사용한다.

반환된 Deferred의 경우 값이 생성되면 해당 값을 내부에 저장한다. 그렇기에 await에서 값이 반환되는 즉시 사용가능하나, 값이 생성되기 전 await가 호출되는 경우에는 값이 생성될 때까지 기다리게 된다.

다만 launch Builder와의 차이점은, 값을 반환한다는 것이다. 그렇기에 launch와 async가 비슷하다 해서 launch를 async로 마음껏 치환하면 안 된다!

  • async는 값을 생성할 때
  • launch는 값이 필요하지 않을 때

위와 같이 구분해서 사용하기로 한다.

async Builder는 두 가지 다른 소스로부터 데이터를 가져와 합치는 경우와 같이, 두 작업을 병렬로 실행할 때 주로 사용한다.

3. 구조화된 동시성

Coroutine이 GlobalScope에서 시작되었다면, 해당 프로그램은 Coroutine을 기다려주지 않는다. Coroutine의 특성 상 어떤 스레드도 블로킹시키지 않기에 프로그램이 제 알아서 끝나는 걸 막을 방도가 전무하다. 즉, delay를 추가로 호출해주어야 한다는 것이다.

그런데 launch나 async, runBlocking를 열어보면, block 파라미터의 리시버 타임이 CoroutineScope인 함수형 타입임을 확인할 수 있다.

fun <T> runBlocking(
	context: CoroutineContext = EmptyCoroutineContext,
    block : suspend CoroutineScope.() -> T
) : T

fun CoroutineScope.launch(
	context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> Unit
) : Job

fun <T> CoroutineScope.async(
	context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT, 
    block: suspend CoroutineScope.() -> Unit
) : Deferred<T>

다시 말해, GlobalScope를 굳이 사용하지 않아도 runBlocking이 제공하는 리시버를 활용해 this.launch, launch 등으로 호출해도 된다는 것이다!

fun main() = runBlocking {
	this.launch {
    	delay(1000L)
        println("Hello, World")
    }
    launch {
    	delay(1000L)
        println("Hello, World")
    }
    launch {
    	delay(1000L)
        println("Hello, World")
    }
    println("Hello, World")
}

GlobalScope가 this로, 또는 그냥 launch만 사용하도록 바뀌었다. 앞서 말했듯 GlobalScope는 해당 Coroutine이 끝나는 걸 전혀 기다려주지 않는데, launch의 부모를 runBlocking으로 두게 되면 부모가 자식을 기다리는 것은 당연하므로 runBlocking은 모든 자식 Coroutine이 작업을 끝낼 때까지 기다리게 된다! 그래서 delay가 필요없게 된 것이다.

부모는 자식을 위한 Scope를 제공하고, 자식은 해당 Scope 내에서 호출된다. 이를 구조화된 동시성 관계가 성립한다고 말 할 수 있다.

부모-자식 간 관계의 특징은 아래와 같다.

  • 자식은 부모로부터 Context를 상속받는다. 다만, 자식은 이를 재정의할 수 있다.
  • 부모 Coroutine이 취소되면, 자식 Coroutine도 취소된다.
  • 부모는 모든 자식이 작업을 마칠 때까지 기다린다.
  • 자식 Coroutine에서 에러 발생 시, 부모 Coroutine도 에러로 소멸하게 된다.

다른 Coroutine Builder와 달리 runBlocking은 CoroutineScope의 확장함수가 아니다. 또한 runBlocking은 무언가의 자식이 될 수도 없고, 오로지 Root Coroutine으로 사용된다는 점에서 다른 Builder와는 정말, 많이 다르다.

4. 현업에서의 Coroutine 사용

앞서 살펴보았듯 runBlocking을 제외한 모든 Coroutine Builder는 CoroutineScope에서 시작되어야 한다.

간단한 예제에서는 Scope를 runBlocking이 제공한다. 하지만, 더 큰 애플리케이션을 개발하는 경우 Scope를 직접 만들거나, 프레임워크에서 제공하는 Scope를 활용한다.

첫 Builder가 Scope에서 시작되면, 다른 Builder가 첫 Builder의 Scope에서 실행될 수 있게 되면서 자연스럽게 애플리케이션이 구조화 된다.

다만, 한 가지 문제가 있다. 중단 함수에선 Scope를 어떻게 처리할까?
중단 함수 내부에서 중단될 수 있다지만 함수 내부에서는 Scope가 없다. 그렇다고 해서 Scope를 인자로 넘기는 건 후에 소개하겠지만 좋지 않다.

대신 Coroutine Builder가 사용할 Scope를 만들어주는 중단함수 coroutineScope 함수를 사용하는 쪽이 옳다.

suspend fun getNews(
	userToken : String?
) : List<NewsJson> = coroutineScope {...}

coroutineScope는 람다식이 필요로 하는 Scope를 만들어주는 중단함수로, 람다식이 반환하는 것이면 무엇이든 반환해준다. 따라서 coroutineScope 함수는 중단 함수 내에서 Scope가 필요할 시 일반적으로 사용된다.

중단함수를 coroutineScope와 함께 시작할 수도 있는데, 이 것이 main과 runBlocking을 함께 사용하는 것보다 세련되고 우아한 방식이라고 볼 수 있겠다.

suspend fun main(): Unit = coroutineScope {
	launch{
    	delay(1000L)
        println("Hello, world")
    }
}


kotlinx.coroutines 라이브러리의 요소들이 활용되는 방식을 나타낸 구조도이다. Coroutine은 Scope나 runBlocking에서 시작된다. 이후 타 Coroutine Builder, 중단함수를 호출한다. 중단함수에서 Builder를 직접 실행할 수 없기에 coroutineScope와 같은 Coroutine Scope 함수를 사용하는 것으로 해결한다.

5. 마치며

여기까지 왔다면 이제 Kotlin Coroutine을 사용할 수 있다. 대부분 다른 중단함수나 일반 함수를 호출하는 중단함수만 사용하기 때문이다.

대부분 프로젝트에서는 Scope가 한 번 정의되면 딱히 건드릴 일이 없다.
필수적인 원리들을 배우고 있지만 아직도 많이 남았다. 다음 장에선 Coroutine을 보다 심도 깊게 다루기로 한다. 타 Context도 사용해보고, 취소나 예외 처리도 활용하고, Coroutine을 테스트하는 법도 살펴보기로 한다.

여담이지만, 나의 문체가 실제 생활 속 나에 비해 굉장히 담백하다는 피드백을 지인으로부터 받았다. 지식을 공유하고 전달하는 입장에서 딱딱하고 냉랭할 필요까지는 없겠으나, 정제된 문체를 사용하는 것이 옳다는 생각이기에 앞으로도 이를 유지할 요량이다.

내심 놀란 것은 사실이다. 담백하다는 말만 들었다면 그러려니 하고 넘어갔겠으나, 백엔드 아저씨의 테크 블로그 같다는 지인의 말에 약간 울컥한 면도 있다. 강조하지만 절대절대 긁힌 것이 아니다. 창창한 20대 중반의 개발자를 순식간에 백엔드 아저씨로 만든 지인에게 서운함을 담은 사담을 남기며 이번 장을 마치도록 한다.

profile
Android 개발자가 되기까지

0개의 댓글