관리 메뉴

나만을 위한 블로그

[코틀린 코루틴] 11. 코루틴 스코프 함수 본문

책/코틀린 코루틴

[코틀린 코루틴] 11. 코루틴 스코프 함수

참깨빵위에참깨빵 2024. 3. 10. 17:55
728x90
반응형

여러 엔드포인트에서 데이터를 동시에 얻어야 하는 중단 함수를 구현할 때 차선책은 뭐가 있을까?

 

코루틴 스코프 함수 전에 쓰인 방법들

 

첫 번째 방법은 중단 함수에서 중단 함수를 호출하는 것이다. 문제는 작업이 동시에 진행되지 않는다는 것이다. 하나의 엔드포인트에서 데이터를 얻는데 1초 걸려서 함수가 끝나려면 2초가 걸린다.

두 중단 함수를 동시 실행하려면 각각 async로 래핑해야 한다. 하지만 async는 스코프가 필요하고 GlobalScope 사용은 좋은 방법이 아니다.

GlobalScope에서 async를 호출하면 아래 결과가 발생한다.

 

  • 메모리 누수가 발생할 수 있고 쓸데없이 CPU를 낭비함
  • 코루틴을 단위 테스트하는 도구가 작동하지 않아서 함수 테스트가 어려움

 

따라서 위 방법 대신 스코프를 인자로 넘기는 방법을 확인한다.

 

// 이렇게 구현하면 안 됨
suspend fun getUserProfile(scope: CoroutineScope): UserProfileData {
    val user = scope.async { getUserData() }
    val notifications = scope.async { getNotifications() }

    return UserProfileData(
        user = user.await(),
        notifications = notifications.await()
    )
}

// 이렇게도 구현하면 안 됨
suspend fun CoroutineScope.getUserProfile(): UserProfileData {
    val user = async { getUserData() }
    val notifications = async { getNotifications() }

    return UserProfileData(
        user = user.await(),
        notifications = notifications.await()
    )
}

 

이 방법은 취소 가능하고 단위 테스트도 추가할 수 있지만 스코프가 함수에서 함수로 전달돼야 한다.

스코프가 함수로 전달되면 스코프에서 예상 못한 사이드 이펙트가 발생할 수 있다. SupervisorJob이 아닌 Job을 쓸 경우 async에서 예외가 발생하면 모든 스코프가 닫힌다. 또한 스코프에 접근하는 함수가 cancel()을 써서 스코프를 취소하는 등 스코프를 조작할 수도 있다. 이런 접근 방식은 다루기 어렵고 잠재적으로 위험하다.

 

coroutineScope

 

이것은 스코프를 시작하는 중단 함수고 인자로 들어온 함수가 생성한 값을 리턴한다.

async, launch와 다르게 coroutineScope의 본체는 리시버 없이 바로 호출된다. coroutineScope 함수는 새 코루틴을 만들지만 새 코루틴이 끝날 때까지 coroutineScope를 호출한 코루틴을 중단하기 때문에 호출한 코루틴이 동시에 작업을 시작하진 않는다.

아래 코드에서 coroutineScope는 모든 자식이 끝날 때까지 종료되지 않아서 after가 마지막에 출력된다. 또한 coroutineName이 부모에서 자식으로 전달된다.

 

import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking(CoroutineName("Parent")) {
    println("before")
    longTask()
    println("after")
}

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")
    }
}

// before
// (1초 후)
// [Parent] Finished Task 1
// (1초 후)
// [Parent] Finished Task 2
// after

 

코루틴 빌더와 달리 coroutineScope나 스코프에 속한 자식에서 예외가 발생하면 다른 모든 자식이 취소되고 예외가 다시 던져진다.

중단 함수에서 병렬로 작업을 수행할 경우 앞에서 말한 특성을 가진 coroutineScope를 쓰는게 낫다.

 

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

 

coroutineScope는 중단 메인 함수 본체를 래핑할 때 주로 쓴다. coroutineScope 함수는 기존의 중단 컨텍스트에서 벗어난 새 스코프를 만든다. 부모로부터 스코프를 상속받고 구조화된 동시성을 지원한다.

 

코루틴 스코프 함수

 

supervisorScope는 coroutineScope와 비슷하지만 Job 대신 SupervisorJob을 쓴다. withContext는 코루틴 컨텍스트를 바꿀 수 있는 코루틴 스코프고 타임아웃이 있다. 지금은 코루틴 스코프를 만들 수 있는 여러 함수가 있단 것만 알면 되고 이 함수들을 포함하는 그룹명을 정의한다. 어떻게 이름을 정하는가?

누구는 스코핑 함수라 부르지만 스코핑이 뭔지 모른다. 스코프 함수(let, apply)와 구분하고 싶던 것 같다. 코루틴 스코프 함수는 스코프 함수와 확실히 구분되고 더 정확한 의미를 갖고 있다. 코루틴 스코프 함수가 중단 함수에서 코루틴 스코프를 만들기 위해 쓰인단 것만 알면 된다.

코루틴 스코프 함수는 코루틴 빌더와 혼동되지만 두 함수는 개념적으로나 사용함에 있어서나 달라서 쉽게 구분 가능하다.

 

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

 

withContext

 

withContext는 coroutineScope와 비슷하지만 스코프의 컨텍스트를 바꿀 수 있단 점에서 다르다. withContext의 인자로 컨텍스트를 제공하면 부모 스코프의 컨텍스트를 대체한다. 따라서 withContext(EmptyCoroutineContext)와 coroutineScope()는 같은 방식으로 작동한다.

withContext는 기존 스코프, 컨텍스트가 다른 코루틴 스코프를 설정하기 위해 쓰며 디스패처와 같이 쓰이곤 한다.

 

launch(Dispatchers.Main) {
    view.showProgressBar()
    withContext(Dispatchers.IO) {
        fileRepository.saveData(data)
    }
    view.hideProgressBar()
}

 

supervisorScope

 

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

supervisorScope 함수는 서로 독립적인 작업을 시작하는 함수에서 주로 쓰인다.

 

suspend fun notifyAnalytics(actions: List<UserAction>) =
    supervisorScope { 
        actions.forEach { action ->
            launch { 
                notifyAnalytics(action)
            }
        }
    }

 

supervisorScope 대신 withContext(SupervisorJob())을 쓸 수는 없다. withContext(SupervisorJob())을 쓰면 withContext는 기존에 갖고 있던 Job을 쓰며 SupervisorJob()이 해당 Job의 부모가 된다. 따라서 하나의 자식 코루틴이 예외를 던지면 다른 자식들도 취소된다. withContext도 예외를 던져서 SupervisorJob()은 사실상 쓸모없다.

 

withTimeout

 

이 함수도 스코프를 만들고 값을 리턴한다. withTimeout에 아주 큰 값을 넣으면 coroutineScope와 다를 게 없다.

이것은 인자로 받은 람다식을 실행할 때 시간 제한이 있다는 게 다르다. 실행에 시간이 너무 오래 걸리면 람다식은 취소되고 CancellationException의 서브타입인 TimeoutCancellationException이 발생한다.

 

import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout

suspend fun main(): Unit = coroutineScope {
    try {
        test()
    } catch (e: TimeoutCancellationException) {
        println("취소됨")
    }
    delay(1000)
}

suspend fun test(): Int = withTimeout(1500) {
    delay(1000)
    println("연산 중...")
    delay(1000)
    println("Done!")
    42
}

// (1초 후)
// 연산 중...
// (1초 후)
// 취소됨

 

이 함수는 테스트에 유용하다. 특정 함수가 시간이 많게 or 적게 걸리는지 확인하는 테스트에 쓰인다.

runTest 안에서 쓰면 withTimeout은 가상 시간으로 작동한다. 특정 함수의 실행 시간을 제한하기 위해 runBlocking 안에서도 쓸 수 있다.

withTimeout이 좀 더 완화된 형태인 withTimeoutOrNull은 예외를 던지지 않는다. 타임아웃을 초과하면 람다식이 취소되고 null이 리턴된다. 이것은 래핑 함수에서 걸리는 시간이 너무 길 때 뭔가 잘못된 걸 알리기 위해 쓸 수 있다.

 

import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield

suspend fun main(): Unit = coroutineScope {
    val user = getUserOrNull()
    println("User : $user")
}

suspend fun fetchUser(): User {
    // 영원히 실행된다
    while (true) {
        yield()
    }
}

suspend fun getUserOrNull(): User? = withTimeoutOrNull(5000) {
    fetchUser()
}

// (5초 후)
// User : null

 

코루틴 스코프 함수 연결하기

 

서로 다른 코루틴 스코프 함수의 2개 기능이 모두 필요하면 코루틴 스코프 함수에서 다른 기능을 가진 코루틴 스코프 함수를 호출해야 한다. 타임아웃, 디스패처 모두 설정하면 withContext 안에서 withContextOrNull을 쓸 수 있다.

 

suspend fun calculateAnswerOrNull(): User? = withContext(Dispatchers.Default) {
    withTimeoutOrNull(1000) {
        calculateAnswer()
    }
}

 

추가적인 연산

 

유저 프로필을 보여준 다음 분석하기 위해 요청할 수 있다. 이 때 같은 스코프에서 launch를 쓰는 방법이 자주 쓰인다.

 

class ShowUserDataUseCase(
    private val repo: UserDataRepository,
    private val view: UserDataView,
) {
    suspend fun showUserData() = coroutineScope { 
        val name = async { repo.getName() }
        val friends = async { repo.getFriends() }
        val profile = async { repo.getProfile() }
        val user = User(
            name = name.await(),
            friends = friends.await(),
            profile = profile.await(),
        )
        view.show(user)
        launch { 
            repo.notifyProfileShown()
        }
    }
}

 

이 방식엔 문제가 몇 개 있다. 먼저 coroutineScope가 유저 데이터를 보여준 뒤 launch로 시작된 코루틴이 끝나길 기다려야 해서 launch에서 함수 목적과 관련된 의미 있는 작업을 한다고 보기는 어렵다.

뷰를 업데이트할 때 프로그레스 바를 보여주고 있다면 notifyProfileShown()이 끝나기까지 기다려야 한다. 이 방식은 추가 연산을 처리하는 방식으로 부적절하다.

 

2번째 문제는 취소다. 코루틴은 기본적으로 예외가 발생하면 다른 연산을 취소하게 설계돼 있다.

하지만 분석을 위한 호출이 실패했다고 전체 과정이 취소되는 건 말이 안 된다. 그렇다면 핵심 동작에 영향을 주는 추가 연산이 있으면 또 다른 스코프에서 시작하는 게 낫다. 쉬운 방법은 추가 연산을 위한 스코프를 만드는 것이다.

 

val analyticsScope = CoroutineScope(SupervisorJob())

 

생성자로 주입하면 단위 테스트를 추가할 수도 있고 스코프 사용도 편하다.

 

class ShowUserDataUseCase(
    private val repo: UserDataRepository,
    private val view: UserDataView,
    private val analyticsScope: CoroutineScope
) {
    suspend fun showUserData() = coroutineScope {
        val name = async { repo.getName() }
        val friends = async { repo.getFriends() }
        val profile = async { repo.getProfile() }
        val user = User(
            name = name.await(),
            friends = friends.await(),
            profile = profile.await(),
        )
        view.show(user)
        analyticsScope.launch {
            repo.notifyProfileShown()
        }
    }
}

 

스코프를 전달하면 전달된 클래스를 통해 독립 작업을 실행하는 걸 명확하게 알 수 있다. 따라서 중단 함수는 주입된 스코프에서 시작한 연산이 끝날 때까지 기다리지 않는다. 스코프가 전달되지 않으면 중단 함수는 모든 연산이 끝날 때까지 종료되지 않는다고 예상할 수 있다.

반응형
Comments