티스토리 뷰
중단 함수는 Continuation 객체를 다른 중단 함수로 전달한다.
따라서 중단 함수는 일반 함수를 호출할 수 있지만, 일반 함수는 중단 함수를 호출할 수 없다.
모든 중단 함수는 다른 중단 함수에 의해 호출되어야 한다.
이때 중단 함수를 시작하는 지점이 코루틴 빌더다.
코루틴 빌더는 일반 함수와 중단 함수를 연결시키는 다리가 된다.
kotlinx.coroutines 라이브러리가 제공하는 필수적인 코루틴 빌더 세 가지를 탐색해 보자.
- launch
- runBlocking
- async
launch 빌더
thread 함수를 호출하여 새로운 스레드를 시작하는 것과 비슷하다.
코루틴을 시작하면 별개의 작업으로 실행된다.
fun main() {
GlobalScope.launch {
delay(1000L)
println("World!")
}
GlobalScope.launch {
delay(1000L)
println("World!")
}
GlobalScope.launch {
delay(1000L)
println("World!")
}
println("Hello,")
Thread.sleep(2000L)
}
Hello,
(1초 후)
World!
World!
World!
위 코드처럼 main 함수의 끝에 Thread.sleep()을 호출해야 한다.
그렇지 않으면 코루틴을 실행하자마자 메인 함수가 끝나버린다.
delay()는 정해진 시간 뒤에 재개하기 위한 타이머만 설정한다. 스레드를 블록시키지 않고 그 시간 동안 코루틴을 중단시킨다.
스레드가 블로킹되지 않으면 할 일이 없어져 그대로 종료된다.
fun main() {
thread(isDaemon = true) {
Thread.sleep(1000L)
println("World!")
}
thread(isDaemon = true) {
Thread.sleep(1000L)
println("World!")
}
thread(isDaemon = true) {
Thread.sleep(1000L)
println("World!")
}
println("Hello,")
Thread.sleep(2000L)
}
launch와 데몬 스레드는 별개의 작업을 시작하고 작업을 하는 동안 프로그램이 끝나는 걸 막는 무언가가 필요하다는 점이 비슷하다.
하지만 블로킹된 스레드를 유지하는 것과 중단된 코루틴을 유지하는 것의 비용 차이는 매우 크다.
runBlocking 빌더
코루틴은 스레드를 블로킹하지 않고 작업을 중단시키기만 하는 것이 일반적이다. 하지만 블로킹이 필요한 경우도 있다.
메인 함수에서는 프로그램을 너무 빨리 끝내지 않기 위해 스레드를 블로킹해야 한다.
이럴 때 runBlocking을 사용한다.
코루틴이 중단되었을 경우 runBlocking 빌더는 코루틴을 시작한 스레드를 중단시킨다.
따라서 runBlocking 내부에서 delay(1000L)을 호출하면 Thread.sleep(1000L)과 비슷하게 작동한다.
fun main() {
runBlocking {
delay(1000L)
println("World!")
}
runBlocking {
delay(1000L)
println("World!")
}
runBlocking {
delay(1000L)
println("World!")
}
println("Hello,")
}
(1초 후)
World!
(1초 후)
World!
(1초 후)
World!
Hello,
이 코드는 스레드를 사용한 아래 코드와 결과가 동일하다.
fun main() {
Thread.sleep(1000L)
println("World!")
Thread.sleep(1000L)
println("World!")
Thread.sleep(1000L)
println("World!")
println("Hello,")
}
runBlocking이 사용되는 특수한 경우는 두 가지가 있다. 하지만 현재는 runBlocking이 거의 사용되지 않는다.
fun main() = runBlocking {
// ...
}
첫 번째 경우는 메인 함수다. 프로그램이 끝나는 걸 방지하기 위해 스레드를 블로킹한다.
현재는 runBlocking 대신에 메인 함수 앞에 suspend를 붙여 중단 함수로 만드는 방법을 주로 사용한다.
class MyTests {
@Test
fun `a test`() = runBlocking {
// ...
}
}
또 다른 경우는 유닛 테스트다. 메인 함수와 마찬가지로 프로그램이 끝나는 걸 방지하기 위해 스레드를 블로킹한다.
현재는 유닛 테스트 환경에서 runBlocking 대신 코루틴을 가상 시간으로 실행시키는 runTest를 주로 사용한다.
fun main() = runBlocking {
GlobalScope.launch {
delay(1000L)
println("World!")
}
GlobalScope.launch {
delay(1000L)
println("World!")
}
GlobalScope.launch {
delay(1000L)
println("World!")
}
println("Hello,")
delay(2000L) // 여전히 필요함
}
Hello,
(1초 후)
World!
World!
World!
Thread.sleep(2000) 대신 runBlocking 안에서 delay(2000)을 사용하는 방식으로 대체할 수 있다.
구조화된 동시성에 대해 공부하면 이 방식이 좀 더 유용하다는 걸 알 수 있다.
async 빌더
launch와 비슷하지만 값을 생성하도록 설계되어 있다. 값은 람다에 의해 반환된다.
async 함수는 Deferred<T> 타입의 객체를 리턴한다. 여기서 T는 생성되는 값의 타입이다.
Deferred에는 작업이 끝나면 값을 반환하는 중단 함수 await()가 있다.
fun main() = runBlocking {
val resultDeferred: Deferred<Int> = GlobalScope.async {
delay(1000L)
42
}
val result: Int = resultDeferred.await() // (1초 후)
printIn(result) // 42
}
await()가 Int 타입인 42를 반환하기 때문에 생성되는 값은 타입이 Int인 42가 된다.
fun main() = runBlocking {
val res1 = GlobalScope.async {
delay(1000L)
"Text 1"
}
val res2 = GlobalScope.async {
delay(3000L)
"Text 2"
}
val res3 = GlobalScope.async {
delay(2000L)
"Text 3"
}
println(res1.await())
println(res2.await())
println(res3.await())
}
(1초 후)
Text 1
(2초 후)
Text 2
Text 3
반환된 Deferred는 값이 생성되면 해당 값을 내부에 저장한다.
즉, await()에서 값이 반환되는 즉시 값을 사용할 수 있다.
하지만 값이 생성되기 전에 await()를 호출하면 값이 나올 때까지 기다린다.
scope.launch {
val news = async {
newsRepo.getNews()
.sortedByDescending { it.date }
}
val newsSummary = newsRepo.getNewsSummary()
view.showNews(
newsSummary,
news.await()
)
}
launch 빌더와 비슷하게 호출되자마자 코루틴을 즉시 시작한다.
launch 함수를 async로 대체해도 똑같은 방식으로 작동한다.
하지만 좋지 못한 방식이다. async는 값을 생성할 때 사용하고, 값이 필요하지 않을 때는 launch를 써야 한다.
async 빌더는 각기 다른 두 곳에서 데이터를 얻어와 합치는 경우처럼, 두 작업을 병렬로 실행할 때 주로 사용된다.
구조화된 동시성
코루틴이 GlobalScope에서 시작되었다면 프로그램은 코루틴을 기다리지 않는다. GlobalScope은 구조화된 동시성을 보장하지 않기 때문)
코루틴은 어떤 스레드도 블록하지 않기 때문에 프로그램이 끝나는 걸 막을 방법이 없다.
fun main() = runBlocking {
GlobalScope.launch {
delay(1000L)
println("World!")
}
GlobalScope.launch {
delay(2000L)
println("World!")
}
GlobalScope.launch {
delay(2000L)
println("World!")
}
println("Hello,")
// delay(3000L)
}
Hello,
위 코드에서 "World!"가 출력되려면, runBlocking 마지막에 delay를 추가해야 한다.
GlobalScope는 왜 필요할까? launch와 async가 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.() -> T,
): Deferred<T>
그런데 빌더 함수의 시그니처를 보면, block 파라미터의 리시버 타입이 CoroutineScope인 것을 알 수 있다.
fun main() = runBlocking {
this.launch {
delay(1000L)
println("World!")
}
this.launch {
delay(2000L)
println("World!")
}
println("Hello,")
}
Hello,
(1초 후)
World!
(1초 후)
World!
즉, GlobalScope를 굳이 사용하지 않고 runBlocking이 제공하는 리시버를 통해 launch를 호출해도 된다.
이렇게 하면 launch는 runBlocking의 자식이 된다.
부모가 자식 모두를 기다리는 건 당연한 일이므로, runBlocking은 모든 자식이 작업을 끝낼 때까지 기다린다.
부모는 자식들을 위한 스코프를 제공하고 자식들을 해당 스코프 내에서 호출한다. 이를 통해 구조화된 동시성이라는 관계가 성립한다.
부모-자식 관계의 가장 중요한 특징은 다음과 같다.
- 자식 코루틴은 부모 코루틴으로부터 컨텍스트를 상속받는다. (하지만 자식 코루틴이 컨텍스트를 재정의할 수도 있다)
- 부모 코루틴은 모든 자식 코루틴이 작업을 마칠 때까지 기다린다.
- 부모 코루틴이 취소되면 자식 코루틴도 취소된다.
- 자식 코루틴에서 에러가 발생하면, 부모 코루틴도 에러로 소멸한다.
중단 함수는 다른 중단 함수들로부터 호출되어야 하며, 모든 중단 함수는 코루틴 빌더로 시작되어야 한다.
그리고 runBlocking을 제외한 모든 코루틴 빌더는 CoroutineScope에서 시작되어야 한다.
첫 번째 빌더가 스코프에서 시작되면 다른 빌더가 첫 번째 빌더의 스코프에서 시작될 수 있다. 이것이 구조화된 동시성의 본질이다.
다른 코루틴 빌더와 달리, runBlocking은 Coroutinescope의 확장 함수가 아니다.
이는 runBlocking이 자식이 될 수 없고 루트 코루틴으로만 사용될 수 있다는 것을 의미한다.
runBlocking은 다른 코루틴과 쓰임새가 매우 다르다.
현업에서의 코루틴 사용
앞에서 본 간단한 예제들에서는 runBlocking이 스코프를 제공했다.
좀 더 큰 애플리케이션에서는 스코프를 직접 만들거나 프레임워크에서 제공하는 스코프를 사용한다. (예를 들어 안드로이드에서는 Android KTX)
아래는 코루틴이 실제 프로젝트에서 사용되는 예시 코드이다.
class NetworkUserRepository(
private val api: UserApi,
) : UserRepository {
suspend fun getUser(): User = api.getUser().toDomainUser()
}
class NetworkNewsRepository(
private val api: NewsApi,
private val settings: SettingsRepository,
) : NewsRepository {
suspend fun getNews(): List<News> = api.getNews().map { it.toDomainNews() }
suspend fun getNewsSummary(): List<News> {
val type = settings.getNewsSummaryType()
return api.getNewsSummary(type)
}
}
class MainPresenter(
private val view: MainView,
private val userRepo: UserRepository,
private val newsRepo: NewsRepository,
) : BasePresenter {
fun onCreate() {
scope.launch {
val user = userRepo.getUser()
view.showUserData(user)
}
scope.launch {
val news = async {
newsRepo.getNews()
.sortedByDescending { it.date }
}
val newsSummary = async {
newsRepo.getNewsSummary()
}
view.showNews(newsSummary.await(), news.await())
}
}
}
coroutineScope 사용하기
중단 함수에선 스코프를 어떻게 처리할까?
중단 함수 내부에서 코루틴이 중단될 수 있지만 스코프는 존재하지 않는다.
그렇다고 스코프를 인자로 넘기는 건 좋은 방법이 아니다.
대신 coroutineScope 함수를 사용하는 것이 좋다.
coroutineScope 함수는 코루틴 빌더가 사용할 스코프를 만들어 주는 중단 함수다.
Repository 함수에서 비동기적으로 두 개의 데이터를 가져오는 상황을 떠올려 보자.
async를 호출하려면 스코프가 필요하지만 함수에 스코프를 넘기고 싶지는 않다. 이때 coroutineScope 함수를 사용한다.
suspend fun getArticlesForUser(
userToken: String?,
): List<ArticleJson> = coroutineScope {
val articles = async { articleRepository.getArticles() }
val user = userService.getUser(userToken)
articles.await()
.filter { canSeeOnList(user, it) }
.map { toArticleJson(it) }
}
coroutineScope는 중단 함수 내에서 스코프가 필요할 때 일반적으로 사용하는 중단 함수다.
함수의 반환값은 람다의 반환값과 같다.
위 코드에서 getArticlesForUser() 함수는 List<ArticleJson>을 반환한다. 람다에서 반환한 것이 List<ArticleJson>이기 때문이다.
suspend fun main(): Unit = coroutinescope {
launch {
delay(1000L)
println("World!")
}
println("Hello,")
}
Hello,
(1초 후)
World!
중단 함수를 coroutineScope와 함께 시작하는 것도 가능하다.
이는 메인 함수에서 runBlocking을 함께 사용하는 것보다 좋은 방법이다.
위 그림은 코루틴 라이브러리의 다양한 요소들이 어떻게 사용되는지 보여준다.
코루틴은 스코프 또는 runBlocking에서 시작된다.
이후에 다른 코루틴 빌더나 중단 함수를 호출할 수 있다.
중단 함수에서 코루틴 빌더를 호출할 수 없기 때문에 coroutineScope와 같은 코루틴 스코프 함수를 사용한다.
출처
'app > kotlin' 카테고리의 다른 글
2.3 코틀린 코루틴 라이브러리 - 잡과 자식 코루틴 기다리기 (0) | 2025.01.12 |
---|---|
2.2 코틀린 코루틴 라이브러리 - 코루틴 컨텍스트 (0) | 2025.01.11 |
1.5 코틀린 코루틴 이해하기 - 언어 차원에서의 지원 vs 라이브러리 (0) | 2025.01.10 |
1.4 코틀린 코루틴 이해하기 - 코루틴의 실제 구현 (0) | 2025.01.10 |
1.3 코틀린 코루틴 이해하기 - 중단은 어떻게 작동할까? (0) | 2025.01.09 |