launch 코루틴 빌더를 통해 생성되는 코루틴 → 작업 실행 후 결과를 반환하지 않음

하지만 네트워크 통신과 같이 코루틴을 통해 비동기로 실행하는 작업의 경우 결과를 수신받아야 하는 경우가 많음

코루틴 라이브러리는 비동기 작업으로 부터 결과를 수신해야 하는 경우를 위해 async 코루틴 빌더를 제공함

launch 코루틴 빌더 함수 → 걀괏값이 없는 코루틴 객체인 Job을 반환

async 코루틴 빌더 함수 → 결괏값이 있는 코루틴 객체인 Deferred를 반환

5장에서 다루는 내용

  • async-await을 사용해 코루틴으로부터 결괏값 수신하기
  • awaitAll 함수를 사용해 복수의 코루틴으로부터 결괏값 수신
  • withContext를 사용해 실행 중인 코루틴의 CoroutineContext 변경하기

1. async 사용해 결괏값 수신하기

1-1. async 사용해 Deferred 만들기

// async 빌더 함수 선언부
public fun <T> CoroutineScope.asycn(
	context: CoroutineContext = EmptyCoroutineContext,
	start: CoroutineStart = CoroutineStart.DEFAULT,
	block: suspend CoroutineScope.() -> T
): Deferred<T>
  • async 함수도 launch 함수와 마찬가지로 context 인자로 CoroutineDispatcher를 설정할 수 있음
  • start 인자로 CoroutineStart.LAZY를 설정해 코루틴이 지연 시작되도록 할 수 있음
  • 코루틴에서 실행할 코드를 작성하는 block 람다식을 가짐

launch 코루틴 빌더는 코루틴에서 결괏값이 반환되지 않기 때문에 Job 객체를 반환함

async 코루틴 빌더는 코루틴에서 결괏값을 담아 반환하기 위해 Deferred 타입의 객체를 반환함

Deferred 객체는 Job 객체와 마찬가지로 코루틴을 추상화한 객체이지만 코루틴으로부터 생성된 결괏값을 감싸는 기능을 추가로 가지며, 이 결괏값의 타입은 제네릭 타입인 T로 표현됨

Deferred 객체의 반환값 타입을 지정하기 위해선 Deferred에 명시적으로 타입을 설정하거나 async 블록의 반환값으로 반환할 결괏값을 설정하면 됨

val networkDeferred: Deferred<String> = async(Dispatchers.IO) {
	delay(1000L)
	return@async "Dummy Response"
}

1-2. await을 사용한 결괏값 수신

Deferred 객체 → 미래의 어느 시점에 결괏값이 반환될 수 있음을 표현하는 코루틴 객체임

코루틴이 실행 완료될 때 결괏값이 발생하므로 언제 결괏값이 반환될지 정확히 알 수 없으며, 결괏값이 필요한 경우 결괏값이 수신될 때까지 대기해야 함

Deferred 객체 → 결괏값의 수신을 위해 await 함수를 제공

await 함수는 await의 대상이 된 Deferred 코루틴이 실행 완료될 때까지 await 함수를 호출한 코루틴을 일시 중단, Deferred 코루틴이 실행 완료되면 결괏값을 반환하고 호출부의 코루틴을 재개함

코루틴의 실행이 완료될 때까지 대기한다는 점에서 Job 객체의 Join함수와 매우 유사하게 동작함

fun main() = runBlocking<Unit> {
	val networkDeferred: Deferred<String> = async(Dispatchers.IO) {
		delay(1000L)
		return@async "Dummy Response"
	}
	
	val result = networkDeferred.await()
	println(result)
}

networkDeferred.await()를 호출하면 networkDeferred 코루틴이 완료될 때까지 runBlocking 코루틴이 일시 중단됨

이후 networkDeferred 코루틴으로부터 “Dummy Response”가 반환되면 runBlocking 코루틴이 재개되며, “Dummy Response”는 result 변수에 할당됨

println(result)를 통해 “Dummy Response”가 출력됨


2. Deferred는 특수한 형태의 Job이다.

4장에서 모든 코루틴 빌더는 Job 객체를 생성한다고 언급했었다.

하지만 await 코루틴 빌더는 Deferred 객체를 반환한다.

그렇다고 await 코루틴 빌더가 특별한 코루틴 객체를 반환하는 것은 아니다.

Deferred 객체는 Job 객체의 특수한 형태로 Deferred 인터페이스는 Job 인터페이스의 서브타입으로 선언된 인터페이스다.

Deferred 객체는 코루틴으로부터 결괏값 수신을 위해 Job 객체에 몇 가지 기능.이 추가되었을 뿐, 여전히 Job 객체의 일종이다.

// Deferred 인터페이스의 선언부

public interface Deferred<out T>: Job {
	public suspend fun await(): T
	...
}

Deferred 인터페이스가 Job 인터페이스의 서브 타입으로 선언되었기 때문에 Deferred 객체는 Job 객체의 모든 프로퍼티를 사용할 수 있다.

Join을 사용해 Deferred 객체가 완료될 때까지 호출부의 코루틴을 일시 중단할 수도 있도, Deferred 객체가 취소돼야 할 경우 cancel 함수를 호출해 취소할 수 도 있다.

isActive, isCancelled, isCompleted와 같은 상태 프로퍼티도 사용할 수 있다.

정리하자면 Deferred 객체는 결괏값을 받는 기능이 추가된 Job 객체이며, Job 객체의 모든 함수와 변수를 사용할 수 있다. (특수한 형태의 Job)


3. 복수의 코루틴으로부터 결괏값 수신하기

프로그램을 만들 때 여러 비동기 작업으로부터 결괏값을 반환받아 병합해야 하는 경우가 자주 생긴다.

3-1. await을 사용해 복수의 코루틴으로부터 결괏값 수신하기

콘서트 개최 시 관람객을 2개의 플래폼에서 모집하는 상황 → 각 플랫폼에 등록된 관람객을 조화한 후 병합해야 함

fun main() = runBlocking<Unit> {
	val startTime = System.currentTimeMillis()
	val participantDeferred1: Deferred<Array<String>> = async(Dispatchers.IO) {
		delay(1000L)
		return@async arrayOf("James", "Jason")
	}
	val participants1 = participantDeferred1.await()
	
	val participantDeferred2: Deferred<Array<String>> = async(Dispatchers.IO) {
		delay(1000L)
		return@async arrayOf("Jenny")
	}
	val participants2 = participantDeferred2.await()
	
	println("[${getElapsedTime(startTime)}] 참여자 목록: ${listOf(*participants1, *participants2)}")
}

// [지난 시간" 2018ms] 참여자 목록: [Jamse, Jason, Jenny]
  • 별표(*)가 쓰인 이유 ➡️ 배열을 vararg 인자로 펼칠 때 필요
  1. 시작시 startTime 변수에 시작 시간을 기록
  2. participantDeferred1 코루틴을 통해 플랫폼1에서 등록한 관람객 목록을 가져옴
  3. participantDeferred1.await()을 통해 플랫폼1의 서버로부터 결과가 수신될 때까지 대기
  4. participantDeferred2 코루틴을 통해 플랫폼2에서 등록한 관람객 목록을 가져옴
  5. participantDeferred2.await()을 통해 플랫폼2의 서버로부터 결과가 수실될 때까지 대기
  6. 모든 작업을 마친 후 참여자 목록을 병합해 출력

코드의 실행결과를 통해 참여자 목록이 병합되고 각 서버를 호출하는데 1초씩 걸려 총 2초의 시간이 걸리는 것을 확인할 수 있음

서버 호출이 총 2초가 걸린 이유 → await을 호출하면 결괏값이 반환될 때까지 호출부의 코루틴이 일시 중단되기 때문

Dispatchers.IO를 사용해 백그라운드 스레드에서 코루틴을 실행하더라도 await를 호출하면 코루틴이 실행 완료될 때까지 runBlocking 코루틴이 일시 중단돼 대기하게 됨

따라서 participantDeferred1.await()가 participantDeferred2 코루틴이 생성되기 전에 호출되면 participantDeferred2 코루틴은 순차적으로 처리됨

두 작업은 동시에 독립적으로 실행될 수 있는 작업임에도 순차적으로 처리되는 건 비효율적임

이 문제를 해결하려면 await()의 호출 시점을 두 코루틴이 모두 생성된 이후로 변경하면 된다.

fun main() = runBlocking<Unit> {
	val participantDeferred1: Deferred<Array<String>> = async(Dispatchers.IO) {
		delay(1000L)
		return@async arrayOf("James", "Jason")
	}
	
	val participantDeferred2: Deferred<Array<String>> = async(Dispatchers.IO) {
		delay(1000L)
		return@async arrayOf("Jenny")
	}
	
	val participants1 = participantDeferred1.await()
	val participants2 = participantDeferred2.await()
	
	println("[${getElapsedTime(startTime)}] 참여자 목록: ${listOf(*participants1, *participants2)}")
}

// [지난 시간" 1018ms] 참여자 목록: [Jamse, Jason, Jenny]

participantDeferred1.await()가 호출되기 전에 participantDeferred2 코루틴이 실행되므로 두 코루틴이 동시에 실행됨

두 코루틴이 동시에 실행될 수 있도록하는 것은 코루틴의 성능 측면에서 매우 중요함

await의 호출 시점에 따라 코루틴이 순차적으로 처리될 수도 있고 동시에 처리될 수 있을을 유의해야 한다.

3-2. awaitAll 사용한 결괏값 수신

앞의 콘서트 관람객 예시에서는 관람객을 등록받는 플랫폼의 개수가 적었다. 만약 플랫폼의 개수가 100개로 늘어난다면 어떨까?

각 코루틴의 결과를 반환받기 위해 100개의 await()를 호출해야할까? 다행히도 그렇지 않다.

코루틴 라이브러리는 복수의 Deferred 객체로부터 결괏값을 수신하기 위한 awaitAll() 함수를 제공한다.

public suspend fun <T> awaitAll(vararg deferreds: Deferred<T>): List<T>

awaitAll 함수의 구현부를 보면 가변 인자로 Deferred 타입의 객체를 받아 인자로 받은 모든 Deferred 코루틴으로부터 결과가 수신될 때까지 호출부의 코루틴을 일시 중단한 후 결과가 모두 수신되면 Deferred 코루틴들로부터 수신한 결괏값들로 List를 만들어 반환하고 호출부의 코루틴을 재개한다.

participantDeferred1과 participantDeferred2의 예시에 awaitAll() 함수를 적용해보자

fun main() = runBlocking<Unit> {
	val startTime = System.currentTimeMillis()
	val participantDeferred1: Deferred<Array<String>> = async(Dispatchers.IO) {
		delay(1000L)
		arrayOf("James", "Jason")
	}
	
	val participantDeferred2: Deferred<Array<String>> = async(Dispatchers.IO) {
		delay(1000L)
		arrayOf("Jenny")
	}
	
	val results: List<Array<String>> = awaitAll(participantDeferred1, participantDeferred2)
	
	println("[${getElapsedTime(startTime)}] 참여자 목록: ${listOf(*results[0], *results[1])}")
}

// [지난 시간: 1013ms] 참여자 목록: [Jamse, Jason, Jenny]

runBlocking 코루틴에서 awaitAll() 함수가 호출되면 awaitAll() 함수의 인자로 전달된 코루틴들의 실행이 모두 완료될 때까지 runBlocking 코루틴이 일시 중단된다.

이후 인자로 전달된 코루틴들의 실행이 완료되면 결과들이 리스트로 만들어져 반화되고 runBlocking의 코루틴이 재개된다.

3-3. 컬렉션에 대해 awaitAll 사용하기

awaitAll 함수 → Collection 인터페이스에 대한 확장 함수로도 제공함

public suspend fun <T> Collection<Deferred<T>>.awaitAll(): List<T>

Collection<Deferred>에 대해 awaitAll() 함수를 호출하면 컬렉션에 속한 Deferred 객체들의 코루틴이 모두 완료돼 결괏값을 반환할 때까지 대기한다.

fun main() = runBlocking<Unit> {
	val startTime = System.CurrentTimeMillis()
	val participantDeferred1: Deferred<Array<String>> = async(Dispatchers.IO) {
		delay(1000L)
		arrayOf("James", "Jason")
	}
	
	val participantDeferred2: Deferred<Array<String>> = async(Dispatchers.IO) {
		delay(1000L)
		arrayOf("Jenny")
	}
	
	val results: List<Array<String>> = listof(participantDeferred1, participantDeferred2).awaitAll()
	
	println("[${getElapsedTime(startTime)}] 참여자 목록: ${listOf(*results[0], *results[1])}")
}

// [지난 시간: 1015ms] 참여자 목록: [Jamse, Jason, Jenny]

가변 인자를 받는 awaitAll() 함수를 사용한 것과 완전히 같게 동작한다.


4. withContext

4-1. withContext로 async-await 대체하기

public suspend fun <T> withContext(
	context: CoroutineContext,
	block: suspend CoroutineScope.() -> T
): T

withContext 함수가 호출되면 함수의 인자로 설정된 CoroutineContext 객체를 사용해 block 람다식을 실행하고, 완료되면 그 결과를 반환한다.

withContext 함수를 호출한 코루틴은 인자로 받은 CoroutineContext 객체를 사용해 block 람다식을 실행하며, block 람다식을 모두 실행하면 다시 기존의 CoroutineContext 객체를 사용해 코루틴이 재개된다.

이 동작은 async-await 쌍을 연속적으로 실행했을 때의 동작과 매우 유사하다.

fun main() = runBlocking<Unit> {
	val networkDeferred: Deferred<String> = async(Dispatchers.IO) {
		delay(1000L)
		"Dummy Response"
	}
	
	val result = networkDeferred.await()
	println(result)
}

// Dummy Response

이 코드에서는 async 함수를 호출해 Deferred 객체를 만들고, 곧바로 Deferred 객체에 대해 await 함수를 호출한다.

이런 async 함수를 호출한 후 연속적으로 await 함수를 호출해 연속적으로 결괏값 수신을 대기하는 코드는 withContext 함수로 대체할 수 있다.

fun main() = runBlocking<Unit> {
	val result: String = withContext(Dispatchers.IO) {
		delay(1000L)
		return@withContext "Dummy Response"
	}
	
	println(result)
}

// Dummy Response

async-await 쌍이 withContext 함수로 대체되면 Deferred 객체가 생성되는 부분이 없어지고 “Dummy Response”가 결과로 바로 반환된다.

4-2. withContext의 동작 방식

withContext 함수는 async-await 쌍과 비슷하게 동작하는 것처럼 보이지만 내부적으로 보면 다르게 동작한다.

async-await 쌍은 새로운 코루틴을 생성해 작업을 처리하지만 withContext 함수는 실행 중이던 코루틴을 그대로 유지한 채로 코루틴의 실행 환경만 변경해 작업을 처리한다.

fun main() = runBlocking<Unit> {
	println("[${Thread.currentThread().name]] runBlocking 블록 실행")
	withContext(Dispatchers.IO) {
		println("[${Thread.currentThread().name]] withContext 블록 실행")
	}
}

/*
[main @coroutine#1] runBlocking 블록 실행
[DefaultDispatcher-worker-1 @coroutine#1] withContext 블록 실행
*/

코드의 실행 결과를 보면 runBlocking 함수의 block 람다를 실행하는 스레드와 withContext 함수의 block 람다식을 실행하는 스레드는 mainDefaultDispatcher-worker-1 로 다르지만 코루틴은 coroutine#1으로 같은 것을 확인할 수 있다.

withContext 함수는 새로운 코루틴을 만드는 개신 기존의 코루틴에서 CoroutineContext 객체만 바꿔서 실행하는 것을 알 수 있다.

위의 예시에서는 CoroutineContext 객체가 Dispatchers.IO로 바뀌었기 때문에 백그라운드 스레드에서 실행됐다.

다만 CoroutineContext 객체에 대해 아직 다루지 않았기 때문에 “CoroutineContext가 변경됐다.”는 표현을 “코루틴을 실행시키는 CoroutineDispatcher 객체가 변경돼 코루틴의 실행 스레드가 변화한다.”라고 이해하는 것이 좋다.
CoroutineContext 객체에 대해서는 추후에 다룰 예정이다.

withContext 함수가 호출되면 실행 중인 코루틴의 실행 환경이 withContext 함수의 context 인자 값으로 변경돼 실행되며, 이를 컨텍스트 스위칭(context switching)이라고 부른다.

context 인자로 coroutineDispatcher 객체가 넘어오면 코루틴은 해당 CoroutineDispatcher 객체를 사용해 다시 실행된다.

withContext 함수는 함수의 block 람다식이 실행되는 동안 코루틴의 실행 환경을 변경시킨다.

withContext 함수가 block 람다식을 벗어나면 다시 원래의 CoroutineContext 객체를 사용해 실행된다.

fun main() = runBlocking<Unit> {
	println("[${Thread.currentThread().name]] runBlocking 블록 실행")
	async(Dispatchers.IO) {
		println("[${Thread.currentThread().name]] withContext 블록 실행")
	}.await()
}

/*
[main @coroutine#1] runBlocking 블록 실행
[DefaultDispatcher-worker-1 @coroutine#2] withContext 블록 실행
*/

이 코드의 실행 결과를 보면 async 블록을 실행하는 코루틴은 coroutine#2로 runBlocking 블록을 실행하는 coroutine#1과 다른 것을 확인할 수 있다.

async-await 쌍을 사용하면 새로운 코루틴을 만들지만 await 함수가 호출돼 순차 처리 되기 때문에 동기적으로 실행된다.


4-3. withContext 사용 시 주의점

withContext 함수 → 새로운 코루틴을 만들지 않기 때문에 하나의 코루틴에서 withContext 함수가 여러 번 호출되면 순차적으로 실행됨 (withContext 여러개를 한 코루틴에서 호출하면 성능 문제가 발생할 수 있음)

fun main() = runBlocking<Unit> {
	val startTime = System.currentTimeMillis()
	val helloString = withContext(Dispatchers.IO) {
		delay(1000L)
		return@withContext "Hello"
	}
	val worldString = withContext(Dispatchers.IO) {
		delay(1000L)
		return@withContext "World"
	}
	
	println("[${getElapsedTime(startTime)} ${helloString} ${worldString}")
}

// [지난 시간: 2018ms] Hello World

“Hello” 문자열과 “World”문자열을 반환하는 두 코루틴이 순차 처리되어 총 2초의 실행 시간이 걸린 것을 확인할 수 있다.

위의 코드에선 runBlocking 함수에 의해 하나의 코루틴만 생성된다.

이후 withContext 함수를 사용해 코루틴을 유지한 채로 실행 스레드풀만 Dispatchers.IO로 변경된다.

따라서 1초 동안 대기한 후 문자열을 반환받는 두 작업이 한 코루틴에서 순차적으로 처리되어 총 2초 정도의 시간이 걸리는 것이다.

이는 withContext 함수가 새로운 코루틴을 생성하지 않기 때문에 생기는 문제다.

이 문제를 async-await 쌍으로 해결해보자

fun main() = runBlocking<Unit> {
	val startTime = System.currentTimeMillis()
	val helloDeferred = async(Dispatchers.IO) {
		delay(1000L)
		return@async "Hello"
	}
	val worldDeferred = async(Dispatchers.IO) {
		delay(1000L)
		return@async "World"
	}
	
	val results = awaitAll(helloDeferred, worldDeferred)
	
	println("[${getElapsedTime(startTime)}] ${results[0]} ${results[1]}")
}

// [지난 시간: 1013ms] Hello World

HelloDeferred와 WorldDeferred가 모두 실행된 뒤에 awaitAll 함수를 호출해 여러 async 코루틴의 결괏값을 반환받는다.

두 코루틴이 병렬로 처리된 후 한번에 결괏값을 반환받기 때문에 총 1초 정도의 시간이 걸린 것을 확인할 수 있다.

그림에서는 백그라운드 스레드가 2개 사용되었지만 코루틴은 스레드를 사용하지 않을 때는 스레드를 양보하기 때문에 백그라운드 스레드를 하나만 사용할 수도 있다.

withContext 함수를 사용하면 코드가 깔끔해지는 효과를 낼 수 있지만 잘못 사용하면 코루틴이 동기적으로 실행될 수 있어 사용시 주의가 필요하다.


4-추가 자료. withContext를 사용한 코루틴 스레드 전환

private val myDispatcher1 = newSingleThreadContext("MyThread1")
private val myDispatcher2 = newSingleThreadContext("MyThread2")

fun main() = runBlocking<Unit> {
	println("[${Thread.currentThread().name}] 코루틴 실행")
	withContext(myDispatcher1) {
		println("[${Thread.currentThread().name}] 코루틴 실행")
		withContext(myDispatcher2) {
			println("[${Thread.currentThread().name}] 코루틴 실행")
		}
		println("[${Thread.currentThread().name}] 코루틴 실행")
	}
	println("[${Thread.currentThread().name}] 코루틴 실행")
}

/*
[main @coroutine#1] 코루틴 실행
[MyThread1 @coroutine#1] 코루틴 실행
[MyThread2 @coroutine#1] 코루틴 실행
[MyThread1 @coroutine#1] 코루틴 실행
[main @coroutine#1] 코루틴 실행
*/

코드의 실행 결과를 보면 모든 코루틴이 @coroutine#1인데 스레드 풀만 Main, MyThread1, MyThread2로 변경되는 것을 볼 수 있다.

withContext 함수를 사용하면 CoroutineDispatcher 객체를 통해 코루틴이 실행되는데 사용되는 CoroutineDispatcher 객체를 자유롭게 바꿀 수 있다.

withContext 함수를 통해 변경된 CoroutineDispatcher 객체는 withContext 함수 블록 냅에서만 유효하다. withContext 블록을 벗어나면 다시 이전의 CoroutineDispatcher 객체를 사용하게 되며 스레드가 다시 전환된다.


5. 요약

  1. async 함수를 사용해 코루틴을 실행하면 코루틴의 결과를 감싸는 Deferred 객체를 반환받는다.
  2. Deferred 객체는 Job의 서브타입으로 Job 객체에 결괏값을 감싸는 기능이 추가된 객체다.
  3. Deferred 객체에 대해 await 함수를 호출하면 결괏값을 반환받을 수 있다. await 함수를 호출한 코루틴은 Deferred 객체가 결괏값을 반환할 때까지 일시 중단 후 대기할 수 있다.
  4. awaitAll 함수를 사용해 복수의 Deferred 코루틴이 결괏값을 반환할 때까지 대기할 수 있다.
  5. awaitAll 함수는 컬렉션에 대한 확장 함수로도 제공된다.
  6. withContext를 사용해 async-await 쌍을 대체할 수도 있다.
  7. withContet 함수는 코루틴을 새로 생성하지 않는다. 코루틴의 실행 환경을 담는 CoroutineDispatcher 객체만 변경해 코루틴을 실행하므로 이를 활용해 코루틴이 실행되는 스레드를 변경할 수 있다.
  8. withContext 함수는 코루틴을 새로 생성하지 않으므로 병렬로 실행되야 하는 복수의 작업을 withContext로 감싸 실행하면 순차적으로 실행된다. 이럴때는 async를 사용해 작업이 병렬로 실행될 수 있도록 해야한다.
  9. withContext로 실행환경이 변경돼 실행되는 코루틴은 withContext의 작업을 모두 실행하면 다시 이전의 실행 환경으로 돌아온다.

0개의 댓글