생각의 흐름
데이터를 동시에 가져와야 할 상황에서 `getUserPorfile` 함수가 있다고 생각해 보자.
다음 예제에서 중단 함수에서 중단 함수를 호출한다. 데이터를 동시에 가져와야 할 상황에서는 적합하지 않다. (2초가 걸리기 때문에)
suspend fun getUserProfile(): UserProfileData {
val user = getUserData() // 1초 후
val notifications = getNotifications() // 1초 후
return UserProfileData(
user, notifications
)
}
?? : 아니 그럼 `GlobalScope` 사용해서 `async` 사용하면 되는 거 아님? ㅋㅋ
suspend fun getUserProfile(): UserProfileData {
val user = GlobalScope.async { getUserData() }
val notifications = GlobalScope.async { getNotifications() }
return UserProfileData(
user.await(),
notifications.await()
)
}
`GlobalScope`는 그저 `EmptyCoroutineContext`를 가진 스코프일 뿐이다.
`GlobalScope`에서 `async`를 호출하면 부모 코루틴과 아무런 관계가 없기 때문에 다음과 같은 문제가 생긴다.
- 취소될 수 없다. (부모가 취소되어도 `async` 내부의 함수가 실행 중인 상태가 되므로 작업이 끝날 때까지 자원이 낭비된다.)
- 부모로부터 스코프를 상속받지 않는다. (항상 기본 디스패처에서 실행되며, 부모의 컨텍스트를 전혀 신경 쓰지 않는다.)
그래서 메모리 누수가 발생할 수 있고 CPU를 낭비한다.
?? : 아 ㅇㅋㅇㅋ 그럼 스코프를 인자로 넘기면 되는 거 아냐?
suspend fun getUserProfile(
scope: CoroutineScope
): UserProfileData {
val user = scope.async { getUserData() }
val notifications = scope.async { getNotifications() }
return UserProfileData(
user.await(),
notifications.await()
)
}
스코프가 함수로 전달되면 스코프에서 예상하지 못한 부작용이 발생할 수 있다.
- `async`에서 예외가 발생하면 모든 스코프가 닫힘(`Job`을 사용한다고 가정).
- 스코프에 접근하는 함수가 `cancel`메서드를 사용해 스코프를 취소하는 등 스코프를 조작할 수 있음.
이렇게 되면 다루기 어려울 뿐 아니라, 잠재적으로 위험하다.
-> 이를 위해 `coroutineScope`를 사용한다.
coroutineScope
`coroutineScope`는 스코프를 시작하는 중단 함수이며, 인자로 들어온 함수가 생성한 값을 반환한다.
`coroutineScope` 함수는 새로운 코루틴을 생성하지만 새로운 코루틴이 끝날 때까지 `coroutineScope`를 호출한 코루틴을 중단하기 때문에 호출한 코루틴이 작업을 동시에 시작하지는 않는다. 의 본체는 리시버 없이 곧바로 호출된다.
`coroutineScope`는 다음과 같은 특징을 가지고 있다.
- 리시버 없이 호출
- 생성한 새로운 코루틴이 끝날 때까지 기존 코루틴을 중단
또한 생성된 스코프는 바깥의 스코프에서 `coroutineContext`를 상속받지만 컨텍스트의 `Job`을 오버라이팅한다. 따라서 생성된 스코프는 부모가 해야 할 책임을 이어받는다.
- 부모로부터 컨텍스트를 상속받는다.
- 자신의 작업을 끝내기 전까지 모든 자식을 기다린다.
- 부모가 취소되면 자식들 모두를 취소한다.
그럼 위의 예제코드를 다음과 같이 바꿀 수 있겠다.
suspend fun getUserProfile(): UserProfileData = coroutineScope {
val user = aysnc { getUserData() }
val notifications = async { getNotifications() }
UserProfileData(user, notifications)
}
`coroutineScope`는 기존의 중단 컨텍스트에서 벗어난 새로운 스코프를 만든다. 부모로부터 스코프를 상속받고 구조화된 동시성을 지원한다.
코루틴 스코프 함수
스코프를 만드는 다양한 함수가 있으며, `coroutineScope`와 비슷하게 작동한다.
- `supervisorjob`: `coroutineScope`와 비슷하지만 `Job` 대신 `SupervisorJob`을 사용
- `withContext` : 코루틴 컨텍스트를 바꿀 수 있음
- `withTimeout` : 타임아웃이 있는 `coroutineScope`
코루틴 빌더 (`runBlocking` 제외) | 코루틴 스코프 함수 |
`launch`, `async`, `produce` | `coroutineScope`, `supervisorScope`, `withContext`, `withTimeout` |
`CoroutineScope`의 확장 함수 | 중단 함수 |
`CoroutineScope` 리시버의 코루틴 컨텍스트를 사용 | 중단 함수의 컨티뉴에이션 객체가 가진 코루틴 컨텍스트를 사용 |
예외는 Job을 통해 부모로 전파함 | 일반 함수와 같은 방식으로 예외를 던짐 |
비동기인 코루틴을 시작함 | 코루틴 빌더가 호출된 곳에서 코루틴을 시작함 |
`runBlocking`은 블로킹 함수이고 코루틴 스코프 함수는 중단 함수이다.
withContext
`withContext`함수는 `coroutineScope`와 비슷하지만 스코프의 컨텍스트를 변경할 수 있다는 점에서 다르다.
`withContext`의 인자로 컨텍스트를 제공하면 부모 스코프의 컨텍스트를 대체한다. 따라서 `witchContext(EmptyCoroutineContext)` 와 `coroutineScope()`는 정확히 같은 방식으로 동작한다.
supervisorScope
`supervisorScope` 함수는 호출한 스코프로부터 상속받은 `coroutineScope`를 만들고 지정된 중단 함수를 호출한다는 점에서 `coroutineScope`와 비슷하다.
둘의 차이는 컨텍스트의 `Job`을 `Supervisorjob`으로 오버라이딩하는 것이기 때문에 자식 코루틴이 예외를 던지더라도 취소되지 않는다.
suspend fun main(): Unit = runBlocking {
println("Before")
supervisorScope {
launch {
delay(1000)
throw Error()
}
launch {
delay(2000)
println("Done")
}
}
println("After")
}
// ----- 결과
Before
// 1초 후
예외 발생
// 1초 후
Done
After
??: 그럼 `supervisorScope`대신 `withContext(SupervisorJob())`을 사용해도 되나?
-> 정답은 'X'이다. `withContext(SupervisorJob())`를 사용하면 `withContext`는 여전히 기존에 가지고 있던 `Job`을 사용하며 `SupervisorJob()`이 해당 잡의 부모가 된다. 따라서 하나의 자식 코루틴이 예외를 던진다면 다른 자식들 또한 취소가 된다. `withContext` 또한 예외를 던지기 때문에 `SupervisorJob()`은 사실상 쓸모가 없게 된다.
withTimeout
`coroutineScope`와 비슷한 또 다른 함수이다. 이 함수 또한 스코프를 만들고 값을 반환한다. `withTimeout`에 아주 큰 타임아웃 값을 넣어 주면 `coroutineScope`와 다를 것이 없다. `withTimeout`은 인자로 들어온 람다식을 실행할 때 시간제한이 있다. 실행하는데 시간이 너무 오래 걸리면 람다식은 취소되고 `TimeoutCancellationException`을 던진다.
suspend fun main(): Unit = coroutineScope {
try {
thinking()
} catch (e: TimeoutCancellationException) {
println("시간 초과")
}
}
suspend fun thinking(): Int = withTimeout(1500) {
delay(1000)
println("잠시만 나 생각 중..")
delay(1000)
println("좋아 결정했ㅇㅓ")
7
}
// 결과
(1초 후)
잠시만 나 생각 중..
(0.5초 후)
시간 초과
`withTimeout`이 완화된 형태의 함수인 `withTimeoutOrNull`이 존재한다. 타임아웃을 초과하면 람다식이 취소되고 `null`이 반환된다.
코루틴 스코프 함수 연결하기
서로 다른 코루틴 스코프 함수의 두 가지 기능이 모두 필요하다면 코루틴 스코프 함수에서 다른 기능을 가지는 코루틴 스코프 함수를 호출해야 한다.
withContext(Dispatcher.Default) {
withTimeOutOrNull(2000) {
// 작업
}
}
추가적인 연산
아래 예제처럼 주입된 스코프에서 추가적인 연산을 시작하는 건 자주 사용되는 방법이다.
이로써
1. 뷰를 업데이트할 때 분석을 위한 `notifyPorfileShown`까지 기다릴 필요가 없다.
2. 프로필 가져오는 작업에 문제가 발생해 취소가 되었을 때 분석을 위한 `notifyPorfileShown`까지 취소될 필요가 없다.
class ShowUserDataUseCase(
private val repo: Repository,
private val view: DataView,
private val analyticsScope: CoroutineScope,
) {
suspend fun showUserData() = coroutineScope {
val name = async { repo.getName() }
val friends = async { repo.getFriends() }
val user = User(
name = name.await(),
friends = friends.await()
)
view.show(user)
anayticsScope.launch { repo.notifyProfileShown() }
}
}
마무리
코루틴 스코프 함수는 모든 중단 함수에서 사용될 수 있으므로 아주 유용하다.
참고자료
마르친 모스카와(2023), 코틀린 코루틴 Kotlin Coroutines : Deep Dive - 안드로이드 및 백엔드 개발자를 위한 비동기 프로그래밍, 인사이트
'Kotlin Coroutines' 카테고리의 다른 글
[Kotlin Coroutines] 코루틴 스코프 만들기 (0) | 2024.05.31 |
---|---|
[Kotlin Coroutines] 디스패처 (1) | 2024.05.27 |
[Kotlin Coroutines] 코루틴 예외 처리 (0) | 2024.05.17 |
[Kotlin Coroutines] 코루틴 취소 (0) | 2024.05.17 |
[Kotlin Coroutines] Job 과 자식 코루틴 기다리기 (0) | 2024.05.16 |