본문 바로가기
도서/코틀린 코루틴

2장 코루틴 라이브러리 - 코루틴 스코프 함수

by 안스 인민군 2024. 2. 4.

먼저 여러개의 엔드포인트에서 데이터를 동시에 얻어야하는 중단 함수를 떠올려보자.

일단 가장 바람직한 방법을 보기 전에 차선책부터 보자.

아래는 동기적으로 진행되므로 2초가 걸린다.

suspend fun getUserProfile(): UserProfileData {
    val user = getUserData() //(1초후)
    val notifications = getNotifications() //(1초후)
    return UserProfileData(user, notifications)
}

두개의 중단 함수를 동시에 실행하기 위해 async로 매칭해야 한다. 하지만 async는 스코프를 필요로 하고 차선으로 GlobalScope를 사용하는 것은 올바르지 않다.

suspend fun getUserProfile(): UserProfileData {
    val user = GlobalScope.async { getUserData() }
    val notifications =  GlobalScope.async { getNotifications() }
    return UserProfileData(user.await(), notifications.await())
}

GlobalScope는 그저 EmptyCoroutineContext를 가진 스코프일 뿐이다.

public object GlobalScope : CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

이렇게 GlobalScope에서 호출하면 부모 코루틴과 관계가 없어져 아래와 같은 문제가 있다.

  • 부모의 컨텍스트를 상속받지 못해 취소될 수 없으므로 자원이 낭비된다.
  • 코루틴을 단위 테스트하는 도구가 작동하지 않아 테스트하기 어렵다.

그럼 다음으로 스코프를 인자로 넘기는 방법을 생각해보자.

suspend fun getUserProfile(
    scope: CoroutineScope,
): UserProfileData {
    val user = scope.async { getUserData() }
    val notifications =  scope.async { getNotifications() }
    return UserProfileData(user.await(), notifications.await())
}

 

이 방식은 취소가 가능하며 적절한 단위테스트 또한 가능해진다.

그러나 이러한 방식은 스코프가 함수에서 함수로 전달되어야 한다는 문제가 있다.

예를 들면 async에서 예외가 발생하면 모든 스코프가 닫히게 된다. 또한 스코프에 접근하는 함수가 cancel 메서드를 사용해 스코프를 취소하는 등 조작을 허용하게 되어 위험하다.

CoroutineScope

CoroutineScope는 스코프를 시작하는 중단 함수이며, 인자로 들어온 함수가 생성한 값을 반환한다.

async나 launch와는 다르게 coroutineScope의 본체는 리시버 없이 곧바로 호출된다.

CoroutineScope 는 새로운 코루틴을 생성하지만 새로운 코루틴이 끝날때까지 coroutineScope를 호출한 코루틴을 중단하지 때문에 호출한 코투린이 작업을 동시에 시작하지 않는다.

따라서 생성된 스코프는 부모가 해야 할 책임을 이어받는다.

  • 부모로부터 컨텍스트를 상속받는다.
  • 자신의 작업을 끝내기 전까지 모든 자식을 기다린다.
  • 부모다 취소되면 자식들 모두를 취소한다.
fun main() = runBlocking {
    val a = coroutineScope { 
        delay(1000)
        1
    }
    println("a is calculated")
    val b = coroutineScope {
        delay(1000)
        2
    }
    println(a)
    println(b)
}

//(1초뒤)
//a is calculated
//(1초뒤)
//1
//2

아래의 예시를 보면 CoroutineName아 부모에서 자식으로 전달된다.

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

다음 코드에서 취소가 어떻게 동작하는지 확인할 수 있다. 부모가 취소되면 아직 끝나지 않는 자식 코루틴이 전부 취소된다.

suspend fun longTask() = coroutineScope {
    launch {
        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(): Unit = runBlocking {
    val job = launch(CoroutineName("Parent")) {
        LongTask()
    }
    delay(1500)
    job.cancel()
}
// [Parent] Finished task 1

아래 예제는 사용자의 이름과 팔로워 수를 얻어오는 예제 이다.

getUserDetails() 함수는 coroutineScope를 통해 getUserName()getFollowersNumber() 함수를 비동기적으로 호출하고, 그 결과를 Details 객체로 반환한다. 이때 getFollowersNumber() 함수가 ApiException을 발생시키므로, getUserDetails() 함수는 ApiException을 발생시킨다.

main() 함수에서는 getUserDetails() 함수를 호출하여 사용자 세부 정보를 얻어오려고 시도하고, 실패하면(null을 반환하면) 트윗 목록만 출력하게 된다.

data class Tweet(val text: String)
data class Details(val name: String, val followers: Int)

class ApiException(val code: Int, message: String) : Throwable(message)

fun getFollowersNumber(): Int = throw ApiException(500, "Service unavailable")

suspend fun getUserName(): String {
    delay(500)
    return "marcinmoskala"
}

suspend fun getTweets(): List<Tweet> {
    return listOf(Tweet("Hello, world"))
}

suspend fun getUserDetails(): Details = coroutineScope {
    val userName = async { getUserName() }
    val followersNumber = async { getFollowersNumber() }
    Details(userName.await(), followersNumber.await())
}

fun main() = runBlocking<unit> {
    val details = try {
        getUserDetails()
    } catch (e: ApiException) {
        null
    }
    val tweets = async { getTweets() }
    println("User: $details")
    println("Tweets: ${tweets.await()}")
}
// User: null
// Tweets: [Tweet(text=Hello, world)]

코루틴 스코프 함수

코루틴 스코프함수가 중단함수에서 코루틴 스코프를 만들기 위해 사용된다. 코루틴의 생명 주기를 제어하는 데 도움이 되는 함수들이다.

코루틴 스코프함수는 코루틴 빌더와 혼동되지만 두 함수는 개념적으로나 사용함에 있어서 다르다.

코루틴 빌더(runBlocking 제외) 코루틴 스코프 함수
launch, async, produce coroutineScope,supervisorScope,withContext, withTimeout
CoroutineScope의 확장 함수 중단 함수
CoroutineScope 리시버의 코루틴 컨텍스트를 사용 중단 함수의 컨티뉴에이션 객체가 가진 코루틴
컨텍스트를 사용
예외는 Job을 통해 부모로 전파됨 일반 함수와 같은 방식으로 예외를 던짐
비동기인 코루틴을 시작함 코루틴 빌더가 호출된 곳에서 코루틴을 시작함

※ runBlocking 은 코루틴 빌더보다 본체를 바로 호출하고 그 결과를 반환하는 점에서 코루틴 스코프 함수와 비슷하다.

그러나 runBlocking은 블로킹 함수이고 코루틴 스코프 함수는 중단 함수이다. 따라서 runBlocking과 코루틴 스코프와 코루틴 계층이 다르다.

더보기
  1. 블로킹 함수: 블로킹 함수는 해당 함수가 완료될 때까지 실행을 멈추고 대기하는 함수를 말한다. 즉, 해당 함수가 종료되기 전까지는 그 다음 줄의 코드가 실행되지 않는다. runBlocking은 이러한 블로킹 함수의 예시로, 이 함수가 종료될 때까지 현재 스레드를 블로킹한다. 이는 테스트나 메인 함수에서 주로 사용된다.
  2. 중단 함수: 중단 함수는 코루틴 내에서 실행되며, 실행을 일시 중단할 수 있는 함수를 말한다. 이 함수는 suspend 키워드를 사용하여 선언되며, 이 함수가 실행되는 동안에는 다른 코루틴들이 해당 스레드를 사용할 수 있다. 따라서 중단 함수는 블로킹 없이 비동기 작업을 수행할 수 있게 해준다.

, 블로킹 함수는 실행을 멈추고 대기하는 반면, 중단 함수는 실행을 일시 중단하고 다른 코루틴들이 스레드를 사용할 있게 해준다.

withContext

withContext()는 coroutineScope{ } 와 차이는 CoroutionContext를 override하는것이 딱하나의 차이점만 있다.

그러니  CoroutionContext를 재정의 할때만 withContext() 사용하고 이외에는 coroutineScope{ } 것이 좋다.

supervisorScope

supervisorScope 함수는 호출한 스코프로부터 상속받은 CoroutineScope를 만들고 지정된 중단 함수를 호출한다는 점에서 coroutineScope와 비슷하다. 둘의 차이는 컨텍스트의 Job을 SupervisorJob으로 오버라이딩하는 것이기 떄문에 자식 코루틴이 예외를 던지더라도 취소되지 않는다.

fun main() = runBlocking {
    println("Before")
    supervisorScope {
        launch {
            delay(1000)
            throw Error()
        }
    }
    launch {
        delay(2000)
        println("Done")
    }
    println("After")
}
// Before
// (1초 후)
// 예외가 발생합니다..
// (1초 후)
// Done
// After

supervisorScope 대신 withContext(SupervisorJob)을 사용할 수 있을까?

결론적으로 "그렇게 할 수 없다."

withContext(SupervisorJob)을 사용하면 withContext는 여전히 기존에 가지고 있던 Job()을 사용하며 SupervisorJob()이 부모가 된다. 따라서 예외를 던지면 다른 자식들 또한 취소가 된다.

fun main() = runBlocking {
    println("Before")
    withContext(SupervisorJob()) {
        launch {
            delay(1000)
            throw Error()
        }
        launch {
            delay(2000)
            println("Done")
        }
    }
    println("After")
}
// Before
// 11 (1초 후)
// Exception...

withTimeout

이 함수는 큰 타임아웃 값을 넣어주는 것으로 coroutinescope와 다른점이 없다.

withTimeout은 인자로 들어온 람다식을 실행할때 시간제한이 있다. 실행하는데 시간이 너무 오래 걸리면 람다식은 취소되고 TimeoutCancellationException을 던진다.

withTimeout 함수는 테스트를 할떄 유용하다. 특정 함수가 시간이 많게 혹은 적세 걸리는지 확인하는 테스트용도로 사용된다.

class Test {
    @Test
    fun testTime2() = runTest {
        withTimeout(1000) { // 1000ms보다 오래 걸리는 작업
            delay(900) // 가상 시간
        }
    }
}
}
@Test(expected = Timeout CancellationException ::class)
fun testTime1() = runTest {
    withTimeout(1000) { // 1000ms보다 오래 걸리는 작업
        delay(1100) // 가상 시간
    }
}

@Test
fun testTime3() = runBlocking {
    withTimeout(1000) { // 그다지 오래 걸리지 않는 일반적인 테스트
        delay(900) // 실제로 900ms만큼 기다립니다.
    }
}