코루틴 스코프 함수 'coroutineScope'를 왜 사용할까?

SSY·2025년 1월 6일
0

Coroutine

목록 보기
8/8
post-thumbnail

1. 시작하며

다른 코루틴 스코프들과는 달리, coroutineScope는 자신만의 고유한 스코프를 생성하지 않고 부모의 CoroutineContext를 상속받아, 그 내부에서 새로운 코루틴을 실행시킬 수 있는 '코루틴 스코프 함수'이다. 해당 스코프는 왜 사용하는걸까?

참고 : https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/coroutine-scope.html

coroutine의 예외 처리 방식

해당 함수의 쓰임새를 이해하기 위해선 코루틴에서 발생 가능한 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문으로 감싼다 해도 예외 처리가 되지 않고 프로그램이 종료된다.

그럼 어떻게 진행해야할까?

사용 이유1 : 예외처리의 구조화

최종적으로 에러가 발생한 메서드는 결국 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를 지정할게 아니면 후자를 쓰자.

사용 이유2 : 구조적 동시성 처리

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{...} 사용함으로써 아래의 이점을 누리게 되었다.

  1. CoroutineScope를 파라미터로 설정하지 않음으로써 에러에 안전하다.
  2. 부모의 CoroutineContext를 상속받안 환경에서 코루틴을 실행시킬 수 있게 되었다.

정리

  • coroutineScope함수는 부모의 CoroutineContext를 상속받음.
  • 코루틴 에러는 2가지가 있음.
    ㄴ> 코루틴 빌더 내에서 발생하여 Job을 통해 상위 코루틴 스코프로 예외를 전파시키는 방식
    ㄴ> 일반적인 Exception으로 예외를 발생시키는 방식
  • 중단함수 내, coroutineScope선언을 통해 하위 코루틴들이 발생시킨 예외를 다시 던질 수 있고, 이로 인한 예외 처리를 구조화할 수 있다.
  • withContext(EmptyCoroutineScope) {...}coroutineScope {...}는 같다.
  • CoroutineScope를 파라미터로 설정하는 행위는 예상치 못한 예외 전파로 코루틴이 멈춰버릴 수 있다.
profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글

관련 채용 정보