다른 코루틴 스코프들과는 달리, 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를 파라미터로 설정하는 행위는 예상치 못한 예외 전파로 코루틴이 멈춰버릴 수 있다.