🤖안드로이드🤖

[안드로이드] async와 Deferred

bbooyaaa 2025. 10. 29. 00:06

안녕하세요. 오늘은 코틀린 코루틴의 정석 - CH5를 읽고 정리한 글입니다.

 

launch를 통해 생성되는 코루틴은 기본적으로 결과를 반환하지 않습니다.

하지만 코루틴을 사용할 때 결과를 수신해야하는 경우가 많죠.

ex) 네트워크 통신 → 응답 처리

 

이런 경우를 위해 코루틴 라이브러리는 async 코루틴 빌더를 통해 결괏값을 수신받을 수 있도록 합니다.

launch함수 → (결괏값 X)Job 반환 / async 함수 → (결괏값 O)Deferred 반환

5장에서는 async 함수와 그로부터 반환되는 Deferred 객체를 통해 결괏값을 수신하는 방법에 대해 다룹니다.

 

📌5장에서 다루는 내용
1. async-await 사용해 코루틴으로부터 결괏값 수신하기
2. awaitAll 함수를 사용해 복수의 코루틴으로부터 결괏값 수신하기
3. withContext 사용해 실행 중인 코루틴의 CoroutineContext 변경하기

 

5.1 async-await 사용해 코루틴으로부터 결괏값 수신하기

5.1.1 async 사용해 Deferred 만들기

launch와 async는 매우 비슷합니다.

async 코루틴 빌더 선언부를 살펴볼게요.

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

 

async 함수도 launch 함수와 마찬가지로

context 인자로 CoroutineDispatcher 설정이 가능하고,

start 인자로 CoroutineStart.LAZY를 설정해 지연 시작이 가능하고,

코루틴에서 실행할 코드를 작성하는 block 람다식을 가집니다.

 

launch와 async의 차이는 위에서 말했듯이 결괏값을 직접 반환할 수 있느냐에 있습니다.

launch는 결괏값이 반환되지 않는 Job 객체를 반환하는데

async는 결괏값을 담아 반환하기 위해 Deferred<T>타입의 객체를 반환합니다.

→ Deferred는 Job과 같이 코루틴을 추상화한 객체이지만 코루틴으로부터 생성된 결괏값을 감싸는 기능을 추가로 가집니다. +) 결괏값의 타입 = 제네릭 타입 T

 

Deferred의 제네릭 타입을 지정하기 위해서는 명시적으로 설정하거나 async 블록의 반환값으로 반환할 값을 설정하면 됩니다.

ex) 코루틴을 사용해 네트워크 요청을 하고 1초간 대기 후 문자열을 반환받는다면,

val networkDeferred: Deferred<String> = async(Dispatchers. IO){
	delay(1000L) // 네트워크 요청
	return@async "Dummy Response"
}

async에 네트워크 요청을 위해 Dispatchers.IO를 사용하고,

1초 지연 후 “Dummy Response”를 결과를 반환하는 코루틴을 만들면 됩니다.

문자열을 반환받기 때문에 Deferred<String>으로 타입을 설정해주면 되겠죠.

→ 이런 식으로 코루틴으로부터 수신받을 결과의 타입을 지정할 수 있습니다.

 

5.1.2 await를 사용한 결괏값 수신

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

코루틴이 실행 완료될 때 반환되므로 언제 반환될지 정확히 알 수 없습니다.

→ 결괏값이 필요하면 수신될 때까지 대기해야 하는거죠.

 

그래서 Deferred 객체는 수신 대기를 위해 await 함수를 제공합니다.

await 함수는 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() // networkDeferred로부터 결괏값이 반환될 때까지 runBlocking 일시중단
	println(result) // Dummy Response 출력
}

  1. networkDeferred.await()를 호출하면 코루틴이 완료될 때까지 runBlocking 코루틴이 일시 중단
  2. 완료되면(”Dummy Response” 반환) runBlocking 코루틴이 재개
  3. result 변수에 “Dummy Response” 할당
  4. println(result) 실행 → 출력

5.2 Deferred는 특수한 형태의 Job이다

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

하지만 async 코루틴 빌더는 Deferred 객체를 생성해 반환하죠.

async 빌더만 다른 걸 반환하는 걸까요?

아닙니다.

 

Deferred 객체는 Job 객체의 특수한 형태입니다.

Deferred 인터페이스는 Job 인터페이스의 서브타입으로 선언된 인터페이스에요.

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

 

Deferred 인터페이스의 선언부를 살펴볼게요.

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

Deferred 인터페이스는 Job 인터페이스의 서브타입이고,

await 함수는 결괏값을 반환받기 위해 Deferred에 추가된 함수임을 확인할 수 있죠.

 

이런 특성 때문에 Deferred 객체는 Job 객체의 모든 함수와 프로퍼티를 사용할 수 있어요.

join을 사용해 완료될 때까지 호출부의 코루틴을 일시 중단할 수 도 있고, cancel 함수를 호출해 취소할 수도 있습니다.

또한 상태 조회를 위해 isActive, isCancelled, isCompleted 같은 프로퍼티를 사용할 수도 있죠.

 

fun main() = runBlocking<Unit>{
	val networkDeferred: Deferred<String> = async(Dispatchers. IO){
		delay(1000L) // 네트워크 요청
		"Dummy Response"
	}
	networkDeferred.join() // networkDeferred가 실행 완료될 때까지 대기
	printlnJobState(networkDeferred) // Job이 입력돼야 할 자리에 Deferred 입력
}

fun printJobState (job: Job) {
	println(
		"Job State\\n" +
		"isActive > ${job,isActive}\\n" +
		"isCancelled > ${job.isCancelled}\\n" +
		"isCompleted » ${job.isCompleted} "
	)
}

/*
//결과:
Job State
isActive » false
isCancelled » false
isCompleted » true
*/

위 코드를 통해

Deferred 객체로 Job 객체의 모든 함수와 변수를 사용할 수 있다

는 점을 알 수 있죠.

→ Deferred 객체는 특수한 형태의 Job인겁니다.

 

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

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

이때 복수의 코루틴을 생성해 결괏값을 취합해야 해요.

여기서 복수의 코루틴으로부터 결괏값을 효율적으로 수신하는 방법에 대해 다룹니다.

 

5.3.1 await를 사용해 복수의 코루틴으로부터 결괏값 수신하기

콘서트 개최 시 관람객을 2개의 플랫폼에서 모집한다고 가정합시다.

→ 각 플랫폼에 등록된 관람객을 조회한 후 병합해야 하겠죠.

⇒ 각 플랫폼의 서버로부터 등록된 관람객들의 데이터를 가져와 병합합니다.

fun main() = runBlocking<Unit> {
	val startTime = System.currentTimeMillis() // 1. 시작 시간 기록
	val participantDeferred1: Deferred<Array<String>> = async(Dispatchers.IO){ // 2. 플랫폼1에서 등록한 관람객 목록을 가져오는 코루틴
		delay(1000L)
		return@async arrayOf("James", "Jason")
	}
	val participants1 = participantDeferred1.await() // 3. 결과가 수신될 때까지 대기
	
	val participantDeferred2: Deferred<Array<String>> = async(Dispatchers.IO){ // 4. 플랫폼2에서 등록한 관람객 목록을 가져오는 코루틴
		delay(1000L)
		return@async arrayOf("Jenny")
	}
	val participants2 = participantDeferred2.await() // 5. 결과가 수신될 때까지 대기
	
	println("[${getElapsedTime(startTime)}] 참여자 목록: ${listOf(*participants1, *participants2)}") // 6. 지난 시간 표시 및 참여자 목록을 병합해 출력	
}

/*
// 결과:
[지난 시간: 2018ms] 참여자 목록: [James, Jason, Jenny]
*/	

  1. startTime 변수에 시작 시간을 기록
  2. participantDeferred1 코루틴을 통해 플랫폼1에서 등록한 관람객 목록을 가져옴.
  3. participantDeferred1.await()로 플랫폼1의 서버로부터 결과가 수신될 때까지 대기
  4. participantDeferred2 코루틴을 통해 플랫폼2에서 등록한 관람객 목록을 가져옴.
  5. participantDeferred2.await()로 플랫폼2의 서버로부터 결과가 수신될 때까지 대기
  6. 모든 작업을 마친 후, getElapsedTime 함수를 호출해 지난 시간 표시, 참여자 목록 병합해 출력

→ 실행 결과를 보면 참여자 목록이 제대로 병합됐고, 각 서버를 호출해 1초씩 걸려 총 2초의 시간이 걸렸음

 

왜 2초가 걸렸을까?

await을 호출했기 때문에 호출부의 코루틴이 일시 중단됐기 때문입니다.

이러면 Dispatchers.IO를 통해 백그라운드 스레드에서 실행하더라도 호출부(=runBlocking)의 코루틴이 일시 중단돼 두 코루틴이 순차적으로 실행됩니다.

이러면 매우 비효율적이죠.

독립적인 작업인데 왜 순차적으로 합니까, 동시에 처리해야지 !

 

그럼 어떻게 해야할까요?

fun main() = runBlocking<Unit> {
	val startTime = System.currentTimeMillis() // 1. 시작 시간 기록
	val participantDeferred1: Deferred<Array<String>> = async(Dispatchers.IO){ // 2. 플랫폼1에서 등록한 관람객 목록을 가져오는 코루틴
		delay(1000L)
		return@async arrayOf("James", "Jason")
	}
	
	val participantDeferred2: Deferred<Array<String>> = async(Dispatchers.IO){ // 3. 플랫폼2에서 등록한 관람객 목록을 가져오는 코루틴
		delay(1000L)
		return@async arrayOf("Jenny")
	}
	
	val participants1 = participantDeferred1.await() // 4. 결과가 수신될 때까지 대기
	val participants2 = participantDeferred2.await() // 5. 결과가 수신될 때까지 대기
	
	println("[${getElapsedTime(startTime)}] 참여자 목록: ${listOf(*participants1, *participants2)}") // 6. 지난 시간 표시 및 참여자 목록을 병합해 출력	
}

/*
// 결과:
[지난 시간: 1018ms] 참여자 목록: [James, Jason, Jenny]
*/	

이렇게

participantDeferred1.await()가 호출되기 전에 participantDeferred2 코루틴을 실행

하면 두 코루틴 동시 실행이 가능해집니다.

 

  1. participantDeferred1 실행
  2. participantDeferred2 실행
  3. participantDeferred1.await() 호출 → runBlocking 코루틴 일시 중단
  4. participantDeferred1 코루틴 결과 반환 → runBlocking 코루틴 재개
  5. participantDeferred2.await() 호출 → runBlocking 코루틴 일시 중단
  6. participantDeferred2 코루틴 결과 반환 → runBlocking 코루틴 재개
  7. 결과 병합 및 출력

→ 두 코루틴이 동시에 실행되기 때문에 결과를 수신할 때까지 1초정도만 소요됩니다.

 

성능 측면에서 각 코루틴이 동시에 실행될 수 있도록 하는 것이 매우 중요해요.

await 함수의 호출 시점에 따라 코루틴이 순차적으로 처리될 수도, 동시에 처리될 수도 있다는 점을 이해하고,

코루틴이 동시에 실행될 수 있도록 만들어야 합니다.

성능을 생각해서요 !

 

5.3.2 awaitAll을 사용한 결괏값 수신

앞에서는 2개의 사이트에서 등록받는 경우를 예시로 들었었죠.

만약 10개라면요?

await 함수를 사용한다면 열 줄에 걸쳐서 await 함수를 호출해야 합니다.

이 쓸데없는 반복을 줄이기 위해 코루틴 라이브러리는 복수의 Deferred 객체로부터 결괏값을 수신하기 위한 awaitAll 함수를 제공해요.

public suspend fun <T> awaitAll(vararg deferreds: Deferred<T>): List<T>
  1. 가변 인자로 Deferred 객체를 받아 인자로 받은 모든 Deferred 코루틴으로부터 결과가 수신될 때까지 호출부의 코루틴을 일시 중단하고
  2. 모두 수신되면 결괏값들을 List로 만들어 반환합니다.
  3. 그리고 호출부 코루틴을 재개해요.

앞의 예시를 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] 참여자 목록: [James, Jason, Jenny]
*/	

이런 식으로

  1. runBlocking 코루틴에서 awaitAll 함수 호출
  2. 그 대상인 participantDeferred1, participantDeferred2 코루틴들이 모두 완료될 때까지 runBlocking 코루틴 일시 중단
  3. 완료되면 결과들이 리스트도 만들어져 반환
  4. runBlocking 코루틴 재개

됩니다.

→ 위 코드에서 results[0] = participantDeferred1의 결과, results[1] = participantDeferred2의 결과인거죠.

 

5.3.3 컬렉션에 대해 awaitAll 사용하기

코루틴 라이브러리는 awaitAll 함수를 Collection 인터페이스의 확장함수로도 제공해요.

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

이런 식으로요.

함수를 호출하면 컬렉션에 속한 코루틴들이 모두 완료돼 결과를 반환할 때까지 대기합니다.

 

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])}")	
}

/*
// 결과:
[지난 시간: 1013ms] 참여자 목록: [James, Jason, Jenny]
*/	

그래서 이렇게 실행하면 가변 인자로 받는 awaitAll 함수 사용한거랑 완전 똑같이 동작해요.

 

5.4 withContext

5.4.1 withContext로 async-await 대체하기

withContext로 위에서 사용했던 async-await 작업을 대체할 수 있습니다.

withContext가 뭐냐구요?

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

이 함수가 호출되면

  1. 함수의 인자로 설정된 CoroutineContext 객체를 사용해
  2. block 람다식을 실행하고
  3. 완료되면 결과 반환

하는 동작을 가집니다.

 

async-await 쌍을 연속적으로 실행한 것과 동작이 매우 비슷하다는 것을 알 수 있어요.

 

fun main() = runBlocking<Unit>{
	val networkDeferred: Deferred<String> = async(Dispatchers.IO){
		delay(1000L) // 네트워크 요청
		return@async "Dummy Response" // 문자열 반환
	}
	val result = networkDeferred.await() // networkDeferred로부터 결괏값이 반환될 때까지 대기
	println(result)
}

*/
// 결과:
Dummy Response
*/

위 코드에서는

  1. async 함수를 호출해 Deferred 객체를 만들고
  2. await 함수를 호출합니다.

fun main() = runBlocking<Unit>{
	val result: String = withContext(Dispatchers.IO){
		delay(1000L) // 네트워크 요청
		return@async "Dummy Response" // 문자열 반환
	}
	println(result)
}

*/
// 결과:
Dummy Response
*/

위 코드에서는

  1. withContext 함수를 호출합니다

어떤가요?

동작은 동일하게 하면서 확실하게 깔끔해졌죠.

 

5.4.2 withContext의 동작방식

겉보기로는 비슷하게 동작합니다.

하지만 내부적으로는 아주 다르죠.

 

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 블록 실행

이 코드는

  1. runBlocking 스레드와 코루틴을 출력하고
  2. 내부 withContext를 호출해서 그 스레드와 코루틴을 출력해요.

실행 결과를 보면

둘의 스레드는 다르지만(main과 DefaultDispatcher-worker-1)

코루틴은 같죠.(coroutine#1)

→ withContext 함수는 새로운 코루틴을 만드는게 아니라 기존 코루틴의 CoroutineContext 객체만 바꾼다는 점을 알 수 있습니다.

 

+) CoroutineContext? → 이거 바로 다음 장인 6장에서 다룬다고 합니다.

여기서는 CoroutineContext 변경에 의해 실행 스레드도 변경된다고만 이해하시면 돼요.

 

withContext 함수의 동작을 좀 더 알아볼까요

withContext가 실행되면

코루틴의 실행 환경이 context 인자 값으로 변경돼 실행된다고 했죠.

이걸 컨텍스트 스위칭 Context Switching이라고 부릅니다.

앞의 코드에서도

메인 스레드에 있다가

Dispatchers.IO의 작업 대기열로 이동해서

그 스레드로 Context Switching 된거죠.

 

여기서 중요한 점은

withContext가 block 람다식을 벗어나면(다 실행하면)

다시 원래의 CoroutineContext 객체로 실행된다는 거에요.

 

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

/*
// 결과:
[main @coroutine#1] runBlocking 블록 실행
[DefaultDispatcher-worker-1 @coroutine#2] async 블록 실행

async-await을 사용한 이 코드를 보시면

새로운 코루틴을 만들지만

await 함수가 호출돼

순차처리로, 동기적으로 실행되는 것을 알 수 있어요.

 

이렇게요.

 

 

withContext를 호출하면

  1. 코루틴 유지
  2. 실행 스레드만 변경

→ 동기적 실행

 

async-await을 사용하면

  1. 새로운 코루틴 생성
  2. await 함수를 통해 순차 처리

→ 동기적 실행

 

이런 차이를 가지는 겁니다.

근데 그래서 withContext를 사용할 때는 주의해야 해요.

 

5.4.3 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
*/

위 코드를 보시면 1초 대기 후 Hello, 1초 대기 후 World 문자 반환하는 두가지 작업을 실행 중인 것을 알 수 있어요.

각 작업은 병렬적으로 실행되는 것처럼 보이지만 실제로는 순차적으로 실행됩니다.

그래서 실행 결과가 2초죠.

어떻게 동작하는가?

  1. runBlocking 함수에 의해 하나의 코루틴만 생성
  2. 해당 코루틴 = 처음에는 메인 스레드에서 실행
  3. withContext(Dispatchers.IO)에 의해 백그라운드 스레드로 옮겨짐
  4. 실행 완료 후 메인으로 돌아갔다가
  5. 또 ! withContext(Dispatchers.IO)에 의해 백그라운드 스레드로 옮겨짐
  6. 실행 완료 후 메인으로 돌아감

→ 이렇게 순차적으로 진행되기 때문에 1초 + 1초 = 2초가 걸리게 됩니다.

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

 

이 순차 진행 문제 어떻게 해결할까요?

단순합니다.

withContext를 제거하고 async-await을 사용하면 병렬 실행이 가능해집니다.

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

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

이렇게요.

메인 스레드가 백그라운드 스레드1,2에 있는 hello와 world 코루틴을 기다리고,

각각의 코루틴이 각각의 스레드에서 병렬적으로 실행됩니다.

바로 결과가 1초가 됐습니다.

 

이처럼 withContext 함수를 사용하면 코드는 깔끔해지지만

코루틴을 동기적으로 실행하게 만들어

실행 시간 증가로

성능이 안좋아질 수 있습니다.

→ 사용에 주의해야 하는 이유. 입니다.

 

5.5 요약

  1. async 함수로 코루틴 실행 → 코루틴 결과 감싸는 Deferred 객체를 반환 받음
  2. Deferred = Job의 서브타입, Job 객체에 결괏값을 감싸는 기능이 추가됨
  3. Deferred 객체에 await 함수 호출 → 결괏값 반환 await 함수 호출한 코루틴 = 결괏값 반환할 때까지 일시 중단 후 대기
  4. awaitAll 함수 사용해 복수의 Deferred 코루틴 결괏값 반환까지 대기 가능
  5. awaitAll 함수는 컬렉션의 확장 함수로도 제공됨
  6. withContext 함수로 async-await 대체 가능
  7. withContext = 코루틴 새로 생성 X 코루틴의 실행 환경을 담는 CoroutineContext만 변경 → 코루틴이 실행되는 스레드를 변경 가능하게 함
  8. withContext 함수로 병렬로 실행돼야 하는 복수의 작업 실행 → 순차 실행 ⇒ withContext 대신 async 사용해서 병렬로 실행해야 함
  9. withContext로 인해 실행 환경이 변경된 코루틴 작업 모두 실행 → 이전 실행 환경으로 돌아옴

이렇게 오늘은 async와 Deferred에 대해서 알아보았어요.

이번 챕터에서는 이전 챕터에서 가볍게 다뤘던 async, awaitAll 등에 대해서 더 자세히 알아볼 수 있어서 좋았습니다.

 

다음에는 CH6 - CoroutineContext로 찾아오겠습니다.

다음 챕터에서 뵈어요