티스토리 뷰
여러 비동기 함수에서 데이터를 동시에 얻어야 하는 경우를 생각해 보자.
코루틴 스코프 함수가 소개되기 전에 사용한 방법들
suspend fun getUserProfile(): UserProfileData {
val user = getUserData() // 1초 후
val notifications = getNotifications() // 1초 후
return UserProfileData(
user = user,
notifications = notifications,
)
}
먼저 중단 함수에서 중단 함수를 호출하는 방법이 있다. 하지만 이런 방식은 작업이 동시에 진행되지 않는다.
하나의 함수에서 데이터를 얻는 데 1초씩 걸리기 때문에 총 2초가 걸린다.
suspend fun getUserProfile(): UserProfileData {
val user = GlobalScope.async { getUserData() }
val notifications = GlobalScope.async { getNotifications() }
return UserProfileData(
user = user.await(), // 1초 후
notifications = notifications.await(),
)
}
두 개의 중단 함수를 동시에 실행하기 위해 각각 async로 감쌌다.
하지만 async는 스코프를 필요로 한다.
그렇다고 위 코드처럼 GlobalScope를 사용하는 건 좋은 방법이 아니다.
object GlobalScope : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}
GlobalScope는 그저 EmptyCoroutineContext를 가진 스코프일 뿐이다.
GlobalScope에서 async를 호출하면, async 코루틴은 부모 코루틴과 아무런 관계가 없다.
따라서 async 코루틴은
- 취소될 수 없다. (부모가 취소되어도 async 코루틴은 실행 중이므로 async 코루틴의 작업이 끝날 때까지 자원이 낭비된다)
- 부모로부터 스코프를 상속받지 않는다. (항상 기본 디스패처에서 실행된다)
결과적으로
- 메모리 누수가 발생할 수 있고, 쓸데없이 CPU를 낭비한다.
- 코루틴을 단위 테스트하는 함수들이 작동하지 않아 함수를 테스트하기 아주 어렵다. (항상 기본 디스패처에서 실행되므로)
suspend fun getUserProfile(
scope: CoroutineScope,
): UserProfileData {
val user = scope.async { getUserData() }
val notifications = scope.async { getNotifications() }
return UserProfileData(
user = user.await(), // 1초 후
notifications = notifications.await(),
)
}
suspend fun CoroutineScope.getUserProfile(): UserProfileData {
val user = async { getUserData() }
val notifications = async { getNotifications() }
return UserProfileData(
user = user.await(), // 1초 후
notifications = notifications.await(),
)
}
GlobalScope의 문제를 해결하기 위해 스코프를 함수의 인자로 넘길 수 있다.
이 방법은 부모가 취소됐을 때 자식도 취소되며, 단위 테스트를 쉽게 할 수 있다.
문제는 스코프가 함수에서 함수로 전달된다는 것이다. 이러한 방식은 스코프에서 예상하지 못한 문제가 발생할 수 있다.
문제가 발생할 수 있는 예시는 아래와 같다.
- 인자로 전달된 CoroutineScope에서 SupervisorJob이 아닌 Job을 사용했다고 가정한다. 위 코드의 async에서 예외가 발생한다면 해당 스코프의 모든 코루틴이 취소된다.
- 스코프를 인자로 전달받은 함수에서 cancel() 함수를 사용해 스코프를 취소할 수 있다.
스코프를 인자로 전달하면 다루기 어렵고, 잠재적인 위험성을 갖는다.
data class Details(val name: String, val followers: Int)
data class Tweet(val text: String)
fun main() = runBlocking {
val details = try {
getUserDetails()
} catch (e: Error) {
null
}
val tweets = async { getTweets() }
println("User: $details")
println("Tweets: ${tweets.await()}")
}
suspend fun CoroutineScope.getUserDetails(): Details {
val userName = async { getUserName() }
val followersNumber = async { getFollowersNumber() } // 예외 발생
return Details(userName.await(), followersNumber.await())
}
suspend fun getUserName(): String {
delay(500)
return "marcinmoskala"
}
fun getFollowersNumber(): Int = throw Error("Service exception")
suspend fun getTweets(): List<Tweet> {
return listOf(Tweet("Hello, world"))
}
Exception..
스코프를 인자로 전달하는 또 다른 예제를 보자.
getUserDetails()에서 예외가 발생하더라도, getTweets()에서 얻은 데이터는 정상적으로 출력될 것이라 예상한다.
실제 프로그램이 작동하는 과정은 아래와 같다.
- getUserDetails() 내부의 getFollowersNumber()에서 예외가 발생한다.
- getFollowersNumber()를 감싼 async 코루틴을 종료시킨다.
- 예외가 전파되어 전체 스코프가 종료된다.
- 프로그램이 끝난다.
따라서 예외가 발생하면 종료되는 대신, 예외를 그대로 던지는 함수가 더 낫다. 이때 coroutineScope를 사용한다.
coroutineScope
suspend fun <R> coroutineScope(
block: suspend CoroutineScope.() -> R
): R
coroutineScope는 스코프를 시작하는 중단 함수다. 인자로 들어온 함수가 생성한 값을 반환한다.
async나 launch와는 다르게 확장 함수 형태가 아니기 때문에 리시버 없이 바로 호출할 수 있다.
coroutineScope() 함수는 새로운 코루틴을 생성한다.
새로운 코루틴이 끝날 때까지 coroutineScope를 호출한 코루틴을 중단한다.
따라서 coroutineScope를 호출한 코루틴이 작업을 동시에 수행하지는 않는다.
fun main() = runBlocking {
val a = coroutineScope {
delay(1000)
10
}
println("a is calculated")
val b = coroutineScope {
delay(1000)
20
}
println(a)
println(b)
}
(1초 후)
a is calculated
(1초 후)
10
20
위 코드에서 두번의 delay() 함수 호출이 각각 runBlocking을 중단시킨다.
coroutineScope로 생성된 스코프는 바깥 스코프의 coroutineContext를 상속받고, 컨텍스트의 Job을 오버라이딩한다.
따라서 coroutineScope로 생성된 스코프는 부모의 모든 책임을 갖는다.
- 부모로부터 컨텍스트를 상속받는다.
- 모든 자식이 끝날 때까지 기다린다.
- 부모가 취소되면 모든 자식들을 취소한다.
아래의 두 예시 코드에서 이를 확인할 수 있다.
suspend fun longTask() = coroutineScope {
launch {
delay(1000)
val name = coroutineContext[CoroutineName]?.name
println("[$name] Finished task 1")
}
launch {
delay(2000)
val name = coroutineContext[CoroutineName]?.name
println("[$name] Finished task 2")
}
}
fun main() = runBlocking(CoroutineName("Parent")) {
println("Before")
longTask()
println("After")
}
Before
(1초 후)
[Parent] Finished task 1
(1초 후)
[Parent] Finished task 2
After
coroutineScope는 모든 자식이 끝날 때까지 기다리기 때문에 longTask()가 모두 끝난 후 마지막에 "After"가 출력된다.
또한 CoroutineName인 "Parent"가 부모(runBlocking)에서 자식(coroutineScope)으로 전달된다.
suspend fun longTask() = coroutineScope {
launch {
delay(1000)
val name = coroutineContext[CoroutineName]?.name
println("[$name] Fin丄shed task 1")
}
launch {
delay(2000)
val name = coroutineContext[CoroutineName]?.name
println("[$name] Finished task 2")
}
}
fun main() = runBlocking {
val job = launch(CoroutineName("Parent")) {
longTask()
}
delay(1500)
job.cancel()
}
[Parent] Finished task 1
부모가 취소되었기 때문에 아직 끝나지 않은 자식 코루틴(두 번째 launch)도 취소된다.
data class Details(val name: String, val followers: Int)
data class Tweet(val text: String)
fun main() = runBlocking {
val details = try {
getUserDetails()
} catch (e: Error) {
null
}
val tweets = async { getTweets() }
println("User: $details")
println("Tweets: ${tweets.await()}")
}
suspend fun getUserDetails(): Details = coroutineScope {
val userName = async { getUserName() }
val followersNumber = async { getFollowersNumber() } // 예외 발생
Details(userName.await(), followersNumber.await())
}
suspend fun getUserName(): String {
delay(500)
return "marcinmoskala"
}
fun getFollowersNumber(): Int = throw Error("Service exception")
suspend fun getTweets(): List<Tweet> {
return listOf(Tweet("Hello, world"))
}
User: null
Tweets: [Tweet(text=Hello, world)]
코루틴 빌더와 달리 coroutineScope는 스코프의 자식에서 예외가 발생하면 모든 자식이 취소되고, 예외가 다시 던져진다.
즉, 스코프를 인자로 사용하는 예시 코드의 해결책이 된다.
예외가 다시 던져지기 때문에 try-catch문을 통해 getUserDetails()에서 발생하는 예외를 잡을 수 있다. getTweets()에서 얻은 데이터도 출력된다.
suspend fun produceCurrentUserSeq(): User {
val profile = repo.getProfile()
val friends = repo.getFriends()
return User(profile, friends)
}
suspend fun produceCurrentUserSym(): User = coroutineScope {
val profile = async { repo.getProfile() }
val friends = async { repo.getFriends() }
User(profile.await(), friends.await())
}
중단 함수에서 병렬로 작업을 수행할 경우 coroutineScope를 사용하는 것이 좋다.
coroutineScope 함수의 특징을 정리해 보면 아래와 같다.
- 기존 컨텍스트에서 벗어난 새로운 스코프를 만든다.
- 부모로부터 스코프를 상속받는다.
- 구조화된 동시성을 지원한다.
코루틴 스코프 함수
스코프를 만드는 다양한 함수들은 coroutineScope와 비슷하다.
- supervisorScope: coroutineScope와 비슷하지만, Job 대신 SupervisorJob을 사용한다.
- withContext: 코루틴 컨텍스트를 바꿀 수 있는 coroutineScope다.
- withTimeout: 타임아웃이 있는 coroutineScope다.
이 함수들을 "코루틴 스코프 함수"라 부른다.
코루틴 스코프 함수는 중단 함수에서 새로운 코루틴 스코프를 만들기 위해 사용된다.
코루틴 스코프 함수는 코루틴 빌더는 개념과 용도가 아주 다르다.
아래 표를 통해 두 함수의 특징을 비교해 보자.
코루틴 빌더 (runBlocking 제외) | 코루틴 스코프 함수 |
launch, async, produce | coroutineScope, supervisorScope, withContext, withTimeout |
CoroutineScope의 확장 함수 | 중단 함수 |
리시버인 CoroutineScope의 컨텍스트를 사용 | 중단 함수의 Continuation 객체가 가진 컨텍스트를 사용 |
예외가 부모로 전파됨 | 일반 함수처럼 예외를 던짐 |
비동기인 코루틴을 시작함 | 스코프 내 코루틴 빌더가 호출된 곳에서 코루틴을 시작함 |
runBlocking은 코루틴 빌더보다 코루틴 스코프 함수와 더 비슷해 보인다.
- 동일한 점
- 함수 본체를 바로 호출하고 그 결과를 반환한다.
- 차이점
- runBlocking은 블로킹 함수이고, 코루틴 스코프 함수는 중단 함수다.
- runBlocking은 코루틴 계층 가장 상위에 있고, 코루틴 스코프 함수는 계층 중간에 있다.
withContext
fun CoroutineScope.log(text: String) {
val name = this.coroutineContext[CoroutineName]?.name
println("[$name】 $text")
}
fun main() = runBlocking(CoroutineName("Parent")) {
log("Before")
withContext(CoroutineName("Child 1")) {
delay(1000)
log("Hello 1")
}
withContext(CoroutineName("Child 2")) {
delay(1000)
log("Hello 2")
}
log("After")
}
[Parent] Before
(1초 후)
[Child 1] Hello 1
(1초 후)
[Child 2] Hello 2
[Parent] After
withContext 함수는 coroutineScope와 비슷하지만 스코프의 컨텍스트를 변경할 수 있다는 점이 다르다.
(코루틴 빌더와 동일하게) withContext의 인자로 전달된 컨텍스트가 부모 스코프의 컨텍스트를 대체한다.
따라서 withContext(EmptyCoroutineContext)와 coroutineScope()는 동일하다.
launch(Dispatchers.Main) {
view.showProgressBar()
withContext(Dispatchers.IO) {
fileRepository.saveData(data)
}
view.hideProgressBar()
}
기존 스코프와 컨텍스트가 다른 스코프를 설정하기 위해 주로 사용한다.
일반적으로 디스패처와 함께 사용한다.
coroutineScope { /*...*/ }와 withContext(context)는 async { /*...*/ }.await()와 작동 방식이 비슷하다.
async를 호출하려면 스코프가 필요하지만, coroutineScope와 withContext는 호출한 곳의 스코프를 들고온다.
SupervisorScope
fun main() = runBlocking {
println("Before")
supervisorScope {
launch {
delay(1000)
throw Error()
}
launch {
delay(2000)
println("Done")
}
}
println("After")
}
Before
(1초 후)
Exception..
(1초 후)
Done
After
coroutineScope는 컨텍스트의 잡을 일반 Job으로 오버라이딩하지만, supervisorScope는 컨텍스트의 잡을 SupervisorJob으로 오버라이딩한다.
따라서 자식 코루틴이 예외를 던지더라도 부모에 전파되지 않고, 부모는 취소되지 않는다.
suspend fun notifyAnalytics(actions: List<UserAction>) =
supervisorScope {
actions.forEach { action ->
launch {
notifyAnalytics(action)
}
}
}
서로 독립적인 작업을 시작하는 함수에서 주로 사용한다.
class ArticlesRepositoryComposite(
private val articleRepositories: List<ArticleRepository>,
) : ArticleRepository {
override suspend fun fetchArticles(): List<Article> =
supervisorscope {
articleRepositories
.map { async { it.fetchArticles() } }
.mapNotNull {
try {
it.await()
} catch (e: Throwable) {
e.printStackTrace()
null
}
}
.flatten()
.sortedByDescend丄ng { it.publishedAt }
}
}
supervisorScope를 사용하더라도 async는 추가적인 예외 처리가 필요하다.
async 코루틴이 예외로 끝났다면 await를 호출했을 때 예외를 다시 던지게 되기 때문이다.
따라서 async에서 발생하는 예외를 처리하려면 try-catc문으로 await 호출을 감싸야 한다.
fun main() = runBlocking {
println("Before")
withContext(SupervisorJob()) {
launch {
delay(1000)
throw Error()
}
launch {
delay(2000)
println("Done")
}
}
println("After")
}
Before
(1초 후)
Exception...
그렇다면 supervisoScope와 withContext(SupervisorJob())는 동일할까? 그렇지 않다.
withContext(SupervisorJob())에서 withContext는 여전히 기존에 가지고 있던 잡을 사용한다.
SupervisorJob()은 기존 잡의 부모가 된다.
따라서 하나의 자식 코루틴이 예외를 던지면 다른 자식 모두 취소된다.
또한 withContext은 예외를 던지기 때문에 SupervisorJob()은 아무 의미가 없다.
withContext(SupervisorJob())는 사용할 필요가 없다.
withTimeout
suspend fun test(): Int = withTimeout(1500) {
delay(1000)
println("Still thinking")
delay(1000)
println("Done!")
42
}
suspend fun main(): Unit = coroutineScope {
try {
test()
} catch (e: TimeoutCancellationException) {
println("Cancelled")
}
delay(1000) // test() 함수가 취소되었기 때문에, delay 시간을 늘려도 결과는 동일하다.
}
(1초 후)
Still thinking
(0.5초 후)
Cancelled
withTimeout은 인자로 들어온 람다를 실행할 때 시간 제한을 설정한다.
람다가 시간 제한보다 오래 걸리면 람다는 취소되고 TimeoutCancellationException을 던진다.
withTimeout에 아주 큰 타임아웃 값을 넣어 주면 coroutineScope와 동일할 것이다.
class Test {
@Test
fun testTime1() = runTest {
withTimeout(1000) {
delay(900) // 1000ms보다 적게 걸리는 작업
}
}
@Test(expected = TimeoutCancellationException::class)
fun testTime2() = runTest {
withTimeout(1000) {
delay(1100) // 1000ms보다 오래 걸리는 작업
}
}
@Test
fun testTime3() = runBlocking {
withTimeout(1000) {
delay(900) // 실제 900ms만큼 기다린다.
}
}
}
withTimeout 함수는 테스트에서 특히 유용하다.
특정 함수가 시간이 얼마나 걸리는지 테스트하는 용도로 사용된다.
withTimeout이 던지는 TimeoutCancellationException은 CancellationException의 서브 클래스다.
CancellationException는 코루틴이 취소되었을 때 던지는 예외다.
따라서 코루틴 내부에서 TimeoutCancellationException을 던지면 해당 코루틴만 취소가 되고 부모에는 전파되지 않는다.
suspend fun main(): Unit = coroutineScope {
launch { // 1
launch { // 2
delay(2000)
println("Will not be printed")
}
withTimeout(1000) { // 3
delay(1500)
}
}
launch { // 4
delay(2000)
println("Done")
}
}
(2초 후)
Done
위 코드가 동작하는 과정은 아래와 같다.
- 3번 코루틴은 TimeoutCancellationException을 던진다.
- 1번 코루틴에서 예외가 잡힌다.
- 1번 코루틴은 자기 자신과 자식인 2번 코루틴을 취소한다.
- 1번 코루틴은 예외를 부모로 전파하지 않기 때문에 4번 코루틴은 아무런 영향을 받지 않고 끝까지 수행된다.
suspend fun fetchUser(): User {
// 영원히 실행된다.
while (true) {
yield()
}
}
suspend fun getUserOrNull(): User? =
withTimeoutOrNull(5000) {
fetchUser()
}
suspend fun main(): Unit = coroutineScope {
val user = getUserOrNull()
println("User: $user")
}
withTimeoutOrNull은 예외를 던지지 않는다. 타임아웃을 초과하면 람다가 취소되고 null이 반환된다.
withTimeoutOrNull은 람다가 너무 오래걸릴 때 무언가 잘못되었음을 알리는 용도로 사용할 수 있다.
코루틴 스코프 함수 연결하기
suspend fun calculateAnswerOrNull(): User? =
withContext(Dispatchers.Default) {
withTimeoutOrNull(1000) {
calculateAnswer()
}
}
코루틴 스코프 함수의 기능이 여러개 필요한 경우에는 스코프 함수에서 또 다른 스코프 함수를 호출하면 된다.
위 코드처럼 타임아웃과 디스패처를 동시에 설정하기 위해 withContext 내부에서 withTimeoutOrNull을 사용한다.
추가적인 연산
작업을 수행하는 도중에 추가적인 연산을 수행하는 경우를 살펴봅시다.
예를 들어 사용자 프로필을 보여 준 다음, 분석을 위한 요청을 보내는 경우가 있다.
동일한 스코프에서 launch를 호출해 요청을 보내는 코드를 보자.
class ShowUserDataUseCase(
private val repo: UserDataRepository,
private val view: UserDataView,
) {
suspend fun showUserData() = coroutineScope {
val name = async { repo.getName() }
val friends = async { repo.getFriends() }
val profile = async { repo.getProfile() }
val user = User(
name = name.await(),
friends = friends.await(),
profile = profile.await(),
)
view.show(user)
launch { repo.notifyProfileShown() }
}
}
이 방식의 문제는 showUserData()에서 launch가 끝날 때까지 기다려야 한다는 것이다.
launch는 showUserData() 함수의 목적과 관련된 작업이 아니다.
showUserData()는 사용자 데이터를 보여주기 위한 함수지만, launch는 분석을 위한 목적이기 때문이다.
fun onCreate() {
viewModelScope.launch {
_progressBar.value = true
showUserData()
_progressBar.value = false
}
}
뷰를 업데이트할 때 프로그레스 바를 보여 주고 있다면, 분석을 위한 코루틴이 끝날 때까지 기다려야 한다.
또 다른 문제는 취소다.
기본적으로 코루틴은 하나의 코루틴에서 예외가 발생했을 때 코루틴 내 모든 연산을 취소한다.
getProfile()에서 예외가 발생하면 getName()과 getFriends()의 응답도 의미가 없어지기 때문에 모두 취소되어야 한다.
하지만 분석을 위한 호출이 실패했다고 해서 사용자 데이터를 보여주는 모든 연산이 취소되는 건 말도 안 되는 일이다.
이 문제를 어떻게 해결하면 좋을까?
핵심 동작에 영향을 주지 않는 추가적인 연산은 다른 스코프에서 시작하는 것이 좋다.
val analyticsScope = CoroutineScope(SupervisorJob())
즉, 추가적인 연산을 위한 스코프를 만든다.
class ShowUserDataUseCase(
private val repo: UserDataRepository,
private val view: UserDataView,
private val analyticsScope: CoroutineScope,
) {
suspend fun showUserData() = coroutineScope {
val name = async { repo.getName() }
val friends = async { repo.getFriends() }
val profile = async { repo.getProfile() }
val user = User(
name = name.await(),
friends = friends.await(),
profile = profile.await(),
)
view.show(user)
analyticsScope.launch { repo.notifyProfileShown() }
}
}
생성자로 추가적인 연산을 위한 스코프를 주입하면 유닛 테스트를 추가할 수도 있고, 스코프를 사용하는 데도 편리하다.
추가적인 연산을 위해 스코프를 주입하는 방식은 자주 사용된다.
생성자나 함수를 통해 스코프를 주입받으면, 주입받은 스코프로부터 독립적인 작업을 실행한다는 것을 명확하게 알 수 있다.
따라서 핵심적인 기능을 하는 중단 함수는 새로운 스코프의 코루틴이 끝날 때까지 기다리지 않는다.
만약 새로운 스코프를 주입받지 않는다면, 중단 함수는 모든 코루틴이 완료될 때까지 종료되지 않는다는 것을 쉽게 예상할 수 있다.
출처
'kotlin > coroutines' 카테고리의 다른 글
2.8 코틀린 코루틴 라이브러리 - 코루틴 스코프 만들기 (0) | 2025.01.17 |
---|---|
2.7 코틀린 코루틴 라이브러리 - 디스패처 (2) | 2025.01.16 |
2.5 코틀린 코루틴 라이브러리 - 예외 처리 (0) | 2025.01.14 |
2.4 코틀린 코루틴 라이브러리 - 취소 (0) | 2025.01.12 |
2.3 코틀린 코루틴 라이브러리 - 잡과 자식 코루틴 기다리기 (0) | 2025.01.12 |