Table of contents
Open Table of contents
코루틴 스코프 함수가 소개되기 전에 사용한 방법들
여러 개의 엔드포인트에서 데이터를 동시에 얻어야하는 중단 함수가 있을 때 가장 바람직한 방법을 보기전에 차선책부터 살펴봅시다.
중단 함수에서 중단 함수를 호출하기
문제는 작업이 동시에 진행되지 않는다는 점입니다. (sync)
하나의 엔드포인트에서 1초씩 걸린다면 함수가 끝나는데 1초 대신 2초가 걸립니다.
suspend fun getArticlesForUser(
userToken: String?,
): List<ArticleJson> {
val articles = getArticles(userToken) // (1초후)
val user = userService.getUser(userToken) // (1초후)
return articles
}
두 개의 중단 함수를 동시에 실행하려면 각각 aysnc
로 래핑해야 합니다. 하지만 async
는 스코프를 필요로 하며 GlobalScope
를 사용하는 것은
좋은 방법이 아닙니다.
// 잘못된 구현
suspend fun getArticlesForUser(
userToken: String?,
): List<ArticleJson> = coroutineScope {
val articles = async { getArticles(userToken) }
val user = async { userService.getUser(userToken) }
articles.await()
}
GlobalScope
에서 async
를 호출하면 부모 코루틴과 아무런 관계가 없습니다. 이 때 코루틴은
- 취소될 수 없습니다.(부모가 취소되어도 async 내부의 함수가 실행 중인 상태가 되므로 작업이 끝날 때까지 자원이 낭비됩니다.)
- 부모로부터 스코프를 상속받지 않습니다(항상 기본 디스패처에서 실행되며, 부모의 컨텍스트를 전혀 신경쓰지 않습니다)
가장 중요한 결과는 다음과 같습니다.
- 메모리 누수가 발생할 수 있으며 쓸데없이 CPU를 낭비합니다.
- 코루틴을 단위 테스트하는 도구가 작동하지 않아 함수를 테스트하기 아주 어렵습니다.
스코프를 인자로 넘기기
suspend fun getArticlesForUser(
userToken: String?,
scope: CoroutineScope,
): List<ArticleJson> {
val articles = scope.async { getArticles(userToken) }
val user = scope.async { userService.getUser(userToken) }
return articles.await()
}
이 방법은 취소가 가능하며 적절한 단위 테스트를 추가할 수 있다는 점에서 좀 더 나은 방식이라 할 수 있습니다.
문제는 스코프가 함수에서 함수로 전달되어야 한다는 것입니다.
스코프가 함수로 전달되며 예상하지 못한 부작용이 발생할 수 있습니다.
예를 들면, async에서 예외가 발생하면 모든 스코프가 닫히게 됩니다. (SupervisorJob이 아닌 Job을 사용한다고 가정합니다)
또한 스코프에 접근하는 함수가 cancel
메서드를 사용해 스코프를 취소하는 등 스코프를 조작할 수도 있습니다. 이러한 접근 방식은 다루기 어려울 뿐만 아니라
잠재적으로 위험하다고 볼 수 있습니다.
data class Details(val name: String, val: followers: Int)
data class Tweet(val text: String)
fun getFollowersNumber(): Int = throw Error("Service exception")
suspend fun getUserName(): String {
delay(1000)
return "John Doe"
}
suspend fun getTweets(): List<Tweet> {
return listOf(Tweet("Hello, world!"))
}
suspend fun CoroutineScope.getDetails(): Details {
val name = async { getUserName() }
val followers = async { getFollowersNumber() }
return Details(name.await(), followers.await())
}
fun main() = runBlocking {
val details = try {
getDetails()
} catch (e: Exception) {
null
}
val tweets = async { getTweets() }
println("Details: $details")
println("Tweets: ${tweets.await()}")
}
위 코드를 보면 사용자 세부사항을 들고 오는 데 문제가 있더라도 최소한 Tweets는 가져올 수 있습니다.
하지만 getFollowersNumber
에서 예외가 발생하면 async를 종료시키고 전체 스코프가 종료되는 걸로 이어져 프로그램이 끝나버리게 됩니다.
예외가 발생하면 종료되는 대신 예외를 그대로 던지는 함수가 더 낫습니다.
coroutineScope
coroutineScope
는 스코프를 시작하는 중단 함수이며, 인자로 들어온 함수가 생성한 값을 반환합니다.
suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R
async
와 launch
와는 다르게 coroutineScope
의 본체는 리시버 없이 곧바로 호출됩니다.
coroutineScope
는 새로운 코루틴을 생성하지만 새로운 코루틴이 끝날 때까지 coroutineScope
를 호출한 코루틴을 중단하기 때문에 호출한 코리틴이 작업을 동시에 시작하지는 않습니다.
두 delay 호출 모두 runBlocking
을 중단시키는 다음 예를 봅시다.
fun main() = runBlocking {
val a = coroutineScope {
delay(1000)
10
}
println("a is calculated")
val b = coroutineScope {
delay(1000)
20
}
println(a) // 10
println(b) // 20
}
// (1초 후)
// a is calculated
// (1초 후)
// 10
// 20
생성된 스코프는 바깥의 스코프에서 coroutineContext
를 상속받지만 컨텍스트의 Job
을 오버라이딩 합니다. 따라서 생성된 스코프는 부모가 해야할 책임을 이어받습니다.
- 부모로부터 컨텍스트를 상속받습니다.
- 자신의 작업을 끝내기 전까지 모든 자식을 기다립니다.
- 부모가 취소되면 자식들 모두를 취소합니다.
코루틴 스코프 함수
스코프를 만드는 다양한 함수가 있으며, coroutineScope
와 비슷하게 작동합니다. supervisorScope
는 coroutineScope
와 비슷하지만, Job
대신 SupervisorJob
을 사용합니다.
withContext
는 코루틴 컨텍스트를 바꿀 수 있는 coroutineScope
입니다. withTimeout
은 제한 시간이 지나면 취소되는 coroutineScope
입니다.
코루틴 스코프 함수는 코루틴 빌더와 혼동되지만 두 함수는 개념적으로나 사용함에 있어서 아주 다르기 때문에 쉽게 구분할 수 있습니다.
두 함수의 특징을 비교한 다음 표를 보면 차이점이 명확하게 드러납니다.
코루틴 빌더(runBlocking 제외) | 코루틴 스코프 함수 |
---|---|
launch , async , produce | coroutineScope , supervisorScope , withContext , withTimeout |
CoroutineScope의 확장함수 | 중단 함수 |
CoroutineScope 리시버의 코루틴 컨텍스트를 사용 | 중단 함수의 컨티뉴에이션 객체가 가진 코루틴 컨텍스트를 사용 |
예외는 Job을 통해 부모로 전파됨 | 일반 함수와 같은 방식으로 예외를 던짐 |
비동기인 코루틴을 시작함 | 코루틴 빌더가 호출된 곳에서 코루틴을 시작함 |
withContext
withContext
는 coroutineScope
와 비슷하지만, 스코프의 컨텍스트를 변경할 수 있다는 점에서 다릅니다.
withContext
의 인자로 컨텍스트를 제공하면 부모 스코프의 컨텍스트를 대체합니다.
withContext
함수는 기존 스코프와 컨텍스트가 다른 코루틴 스코프를 설정하기 위해 주로 사용됩니다.
디스패처와 함께 종종 사용되곤 합니다.
launch(Dispatchers.Main) {
view.showLoading()
withContext(Dispatchers.IO) {
repository.saveData(data)
}
view.hideLoading()
}
coroutineScope
의 작동하는 방식이 async { }.await()
와 비슷하지만, 가장 큰 차이는 async는 스코프를 필요로 하지만
coroutineScope
와 withContext
는 해당 함수를 호출한 중단점에서 스코프를 들고 온다는 점입니다. 두 경우 모두 async
await
를 곧바로 호출하는 방법 대신
coroutineScope
와 withContext
를 사용하는 편이 좋습니다.