다른 코루틴 스코프들과는 달리, coroutineScope
는 자신만의 고유한 스코프를 생성하지 않고 부모의 CoroutineContext
를 상속받아, 그 내부에서 새로운 코루틴을 실행시킬 수 있는 '코루틴 스코프 함수'이다. 해당 스코프는 왜 사용하는걸까?
해당 함수의 쓰임새를 이해하기 위해선 코루틴에서 발생 가능한 2가지 예외를 구별할 수 있어야 한다. 첫 번째는 Job
객체를 사용한 에러 전파이다. 이 방식은 코루틴 빌더 내에서 발생한 에러를 의미하며, 해당 에러는 에러가 발생한 지점에서 가장 가까운 상위 코루틴 스코프로 전파한다. 두 번째는 Exception
을 사용한 에러 전파 방식이며, 코루틴 빌더 바깥에서 발생한 경우이다. 그럼 아래 코드의 경우, 어떤 방식의 에러를 발생시키는지 구별해보자.
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(500L)
return "ssy"
}
suspend fun getTweets(): List<Tweet> {
return listOf(Tweet("Hello, world"))
}
suspend fun CoroutineScope.getUserDetail(): Details {
val userName = async { getUserName() }
val followersNumber = async { getFollowersNumber() }
return Details(userName.await(), followersNumber.await())
}
fun main() = runBlocking {
val details = try {
getUserDetail()
} catch (e: Error) {
null
}
val tweets = async { getTweets() }
println("User: $details")
println("Tweets: ${tweets.await()}")
}
위 코드에서 결과는 어떻게 될까? 겉으로 보았을 때, getFollowersNumber()
에서 에러를 발생시키고 있고, 해당 함수를 try-catch 로 감싸고 있기에, details에서 null을 반환받고 tweets까진 값을 받을 수 있을 것으로 보인다. 하지만 결과는 그렇지 않다.
위 코드 실행 결과는 Exception
을 사용한 에러 발생으로 프로프램 전체를 종료시키고 있으며, tweets결과값도 받지 못하고 있다. 왜 그럴까?
getFollowersNumber()
의 호출은 코루틴 빌더인 async
안에서 호출되고 있다. 따라서 위 코드의 에러 전파는 Exception
이 아닌, Job
을 사용한 상위 코루틴 스코프인 runBlocking
으로 에러를 전파한다. 따라서 이는 Job
을 사용한 에러 전파에 해당하므로, 해당 함수를 try-catch
문으로 감싼다 해도 예외 처리가 되지 않고 프로그램이 종료된다.
그럼 어떻게 진행해야할까?
최종적으로 에러가 발생한 메서드는 결국 getUserDetail
메서드이다. 따라서 해당 메서드 내부에서 코루틴 스코프를 적용해주면 된다. 기존 코드는 아래와 같았다.
[as-is]
suspend fun CoroutineScope.getUserDetail(): Details {
val userName = async { getUserName() }
val followersNumber = async { getFollowersNumber() }
return Details(userName.await(), followersNumber.await())
}
try-catch문에서 에러 감지가 가능해야 할 것이다. 따라서 위 메서드 내부에 새로운 코루틴 스코프를 지정하고 그 곳에서 에러를 다시 던지도록 지정할 필요가 있다. 따라서withContext(EmptyCoroutineContext)
나 coroutineScope
사용을 생각해볼 수 있다.
[to-be]
suspend fun getUserDetail(): Details {
return coroutineScope { // withContext(EmptyCoroutineContext)로 대체 가능
val userName = async { getUserName() }
val followersNumber = async { getFollowersNumber() }
Details(userName.await(), followersNumber.await())
}
}
위와 같이 수정 후, 코드를 빌드하면 예상했던 결과가 출력된다.
[참고]
withContext(EmptyCoroutineScope) {...}
와coroutineScope {...}
는 동일하다. 새로운CoroutineContext
를 지정할게 아니면 후자를 쓰자.
main함수에 여러개의 코루틴을 동시 실행시키는 코드가 있다고 가정하자.
suspend fun getA(): String {
delay(500L)
return "A"
}
suspend fun getB(): String {
delay(500L)
return "B"
}
suspend fun getC(): String {
delay(500L)
return "C"
}
suspend fun getD(): String {
delay(500L)
return "D"
}
fun main2() = runBlocking {
val a = async { getA() }
val b = async { getB() }
val c = async { getC() }
val d = async { getD() }
println("${a.await()}/${b.await()}/${c.await()}/${d.await()}")
}
하지만 getX()메서드가 많아짐에 따라, getA()
/getB()
코루틴만 따로 추출하고자 한다. 이때 가장 먼저 생각해볼 수 있는 방법은 아래처럼 CoroutineScope
를 인자로 넘기는 방법이다.
suspend fun CoroutineScope.getAandB(): String {
val a = async { getA() }
val b = async { getB() }
return "${a.await()}/${b.await()}"
}
하지만 위의 방법은 커다란 문제를 가지고 있다. 우선 첫 번째로 getA()
/getB()
메서드 중 하나라도 비정상 종료가 발생할 경우, getAAndB
뿐만 아니라, 이를 호출한 상위 코루틴 전체가 멈춰버릴 수 있다는 점이다. 마찬가지로, getC()
/getD()
메서드가에서 비정상 종료가 발생하더라도 getA()
/getB()
메서드 또한 종료된다는 문제가 있다. 그러므로 새로운 코루틴 스코프를 파라미터로 설정하는 행위는 예상치 못한 예외 전파로 코루틴이 멈춰버릴 수 있기에 지양되어야 한다. 그럼 어떻게할까? 확장 파라미터인 CoroutineScope
라도 지워야 할까?
suspend fun getAandB(): String {
// async는 `CoroutineScope`내부에서만 호출 가능. 따라서 아래 코드 사용 불가.
val a = async { getA() }
val b = async { getB() }
return "${a.await()}/${b.await()}"
}
컴파일러가 위 문법은 잘못되었다고 짚어준다. 그렇다면 어떤 방법을 써야할까?
coroutineScope {...}
를 사용함으로써 새로운 코루틴 스코프 범위를 재지정해주고 그 내부에서 여러 코루틴을 호출해 비동기 작업을 진행시켜주면 된다. 또한 coroutineScope{...}
는 서두에도 말해놓았다시피 부모의 CoroutineContext
도 상속받는다. 아래의 코드는 getAandB()
를 호출한 코루틴의 콘텍스트까지 상속받는다는걸 의미한다.
suspend fun getAandB(): String {
return coroutineScope {
val a = async { getA() }
val b = async { getB() }
"${a.await()}/${b.await()}"
}
}
위처럼, coroutineScope{...}
사용함으로써 아래의 이점을 누리게 되었다.
CoroutineScope
를 파라미터로 설정하지 않음으로써 에러에 안전하다.CoroutineContext
를 상속받안 환경에서 코루틴을 실행시킬 수 있게 되었다.coroutineScope
함수는 부모의 CoroutineContext
를 상속받음.Job
을 통해 상위 코루틴 스코프로 예외를 전파시키는 방식Exception
으로 예외를 발생시키는 방식coroutineScope
선언을 통해 하위 코루틴들이 발생시킨 예외를 다시 던질 수 있고, 이로 인한 예외 처리를 구조화할 수 있다.withContext(EmptyCoroutineScope) {...}
와 coroutineScope {...}
는 같다.CoroutineScope
를 파라미터로 설정하는 행위는 예상치 못한 예외 전파로 코루틴이 멈춰버릴 수 있다.