본문 바로가기
Study OR Book/코틀린 코루틴

[코틀린 코루틴] 13장 - 코루틴 스코프 만들기

by Baest 2025. 6. 11.

 

1. CoroutineScope 팩토리 함수

CoroutineScope는 아래와 같이 coroutineContext를 유일 프로퍼티로 갖는 인터페이스이다.

 

CoroutineScope 인터페이스를 구현한 클래스를 만들고 내부에서 코루틴 빌더를 직접 호출 할 수 있다.

다만 이와 같은 방법은 자주 사용되지 않고, 그 이유는 CoroutineScope를 구현한 클래스에서 cancel, ensureActive 같은 다른 CoroutineScope의 메서드를 직접 호출 시 문제가 발생하기 때문이다.

 

 

위 방법 대신 스코프 인스턴스를 프로퍼티로 가지고 있다가 코루틴 빌더를 호출할 때 사용하는 방법이 선호된다.

 

CoroutineScope 객체를 만드는 가장 쉬운 방법은 CoroutineScope 팩토리 함수를 사용하는 것이다.

이 함수는 컨텍스트를 넘겨 받아 스코프를 만든다.

만약 Job이 컨텍스트에 없다면, 구조화된 동시성을 위해 추가해 줄 수 있다.

 

  • Job 존재 확인: context[Job] != null로 전달된 Context에 Job이 있는지 확인
  • 자동 Job 추가: Job이 없으면 새로운 Job()을 Context에 추가
  • ContextScope 생성: 최종 Context로 실제 구현체인 ContextScope를 생성

 

여기서 ContextScope는 단순히 주어진 Context를 보관하는 컨테이너 역할을 한다.

 

Job이 중요한 이유

Job이 없을 경우 아래와 같은 문제 발생

  • 코루틴의 생명주기 관리 불가능
  • 취소(cancellation) 불가능
  • 구조화된 동시성 보장 불가능
  • 부모-자식 관계 설정 불가능

따라서 CoroutineScope 팩토리 함수는 개발자가 Job을 깜빡하더라도 자동으로 추가해서 안전한 코루틴 실행 환경을 보장한다.

 

 

2. 안드로이드에서 스코프 만들기

MVVM이나 MVP 아키텍처에서는 사용자에게 보여주는 부분을 ViewModels나 Presenters와 같은 객체로 추출한다
또한 이 곳이 일반적으로 코루틴이 제일 먼저 시작되는 곳이다.

안드로이드의 어떤 부분에서 코루틴을 시작하든지 간에 코루틴을 만드는 방법은 모두 비슷하다.

 

BaseViewModel에서 스코프를 만들어 모든 뷰 모델에서 쓰일 스코프를 단 한 번으로 정의

abstract class BaseViewModel : ViewModel() {
    protected val scope = CoroutineScope(TODO())
}

class MainViewModel(
    private val userRepo: UserRepository,
    private val newsRepo: NewsRepository,
) : BaseViewModel {

    fun onCreate() {
        scope.launch {
            val user = userRepo.getUser()     
            view.showUserData(user)        
        }
        scope.launch {
            val news = newsRepo.getNews()    
                .sortedByDescending { it.date }
            view.showNews(news)              
        }
    }
}

 


스코프에서의 컨텍스트 정의

abstract class BaseViewModel: ViewModel() {
	protected val scope = CoroutineScope(Dispatchers.Main)
}



스코프를 취소 가능하게 만들기

스코프를 취소하기 위해서는 Job이 필요하다.
아래 코드에서는 명시적으로 추가된 것이며, 실제로는 CouroutineScope가 Job을 추가해주기 때문에 따로 추가할 필요 없다.

abstract class BaseViewModel: ViewModel() {
    protected val scope = CoroutineScope(Dispatchers.Main + Job())
    
    override fun onCleared() {
    	scope.cancle()
    }
}

 


전체 스코프 대신 자식 코루틴만 취소하기

abstract class BaseViewModel: ViewModel() {
    protected val scope = CoroutineScope(Dispatchers.Main + Job())
    
    override fun onCleared() {
    	scope.coroutineContext.cancelChildren()
    }
}

 

 

코루틴의 독립적인 처리를 위한 Job 대신 SupervisorJob의 사용

Job을 사용하다가 에러가 발생하면, 자식 코루틴 하나의 취소로 인해 부모 및 다른 자식 코루틴이 모두 취소된다.

따라서 이 경우 코루틴이 독립적으로 작동하려면 Job 대신 SupervisorJob을 사용해야한다.

abstract class BaseViewModel: ViewModel() {
	protected val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
        
    override fun onCleared() {
    	scope.coroutineContext.cancelChildren()
    }
}

 

아래 SupervisorJobImpl을 살펴보면 Boolean = false로 되어있다.

이 부분으로 인해 SupervisorJob을 사용하면, 자식 코루틴은 종료되지 않는다.

 

 

잡히지 않은 예외 처리

BaseActivity에 예외 처리 핸들러를 한 번만 정의하고 뷰 모델에 전달하는 방법이 많이 사용된다.

잡히지 않은 예외가 있다면 CoroutineExceptionHandler를 사용해 해당 함수 호출이 가능하다.

abstract class BaseViewModel(
    private val onError: (Throwable) -> Unit
): ViewModel() {
    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        onError(throwable)
    }

    private val context = Dispatchers.Main + SupervisorJob() + exceptionHandler

    protected val scope = CoroutineScope(context)

    override fun onCleared() {
        context.cancelChildren()
    }
}

 

 

3. viewModelScope, lifecycleScope

최근 안드로이드에서는 스코프를 따로 정의하지 않고 viewModelScope 또는 lifecycleScope를 사용하고 있다.

Dispatchers.Main과 SupervisorJob을 사용하고, 뷰 모델이나 라이프사이클이 종료되었을 때 잡을 취소시킨다는 점에서 스코프와 유사하다고 볼 수 있다.

 

스코프에서 특정 컨텍스트가 필요 없다면, viewModelScope 또는 lifecycleScope를 사용하는 것이 편리하고 더 좋다.

 

4. 백엔드에서 코루틴 만들기

대부분의 백엔드 프레임워크에서 중단 함수를 지원하고 있다.

  • 스프링 부트는 컨트롤러의 함수가 suspend로 선언되는 걸 허용한다. 
  • Ktor에서 모든 핸들러는 기본적으로 중단 함수이다.

따라서 별도로 스코프를 만들 기회는 많지 않으나 만약 만들어야 되는 상황이라면, 아래와 같은 것들을 필요로 한다.

  • 스레드 풀을 가진 커스텀 디스패치
  • 각각의 코루틴을 독립적으로 만들어주는 SupervisorJob
  • 적절한 에러 코드에 응답하고, 데드 레터를 보내거나 발생한 문제에 대한 로그를 남기는 CoroutineExceptionHandler

 

5. 추가적인 호출을 위한 스코프 만들기

추가적인 연산을 위한 스코프를 만들어야할 때가 있다.

  • 스코프는 함수나 생성자의 인자를 통해 주입된다.
  • 스코프가 호출 중단을 위한 목적으로 사용된다면 SupervisorScope를 사용하는 것으로 충분하다.
  • 예외는 로그를 통해 볼 수 있으며, CoroutineExceptionHandler를 사용하면 된다.
  • 다른 디스패치를 사용하는 것도 자주 사용되는 커스텀 방법이다.
    • 스코프에서 블로킹 호출을 한다면 Dispatchers.IO 사용
    • 안드로이드의 메인 스레드를 다뤄야한다면 Dispatchers.Main 사용

 

supervisorScope vs SupervisorJob

  • SupervisorJob(): 영구적인 Job 객체
// 영구적인 스코프 생성
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

scope.launch { /* 자식 1 */ }
scope.launch { /* 자식 2 */ }
// 스코프가 살아있는 동안 계속 사용 가능
  • supervisorScope{}: 일시적인 스코프 함수
// 일시적인 스코프, 블록 실행 후 자동 정리
suspend fun processData() {
    supervisorScope {
        launch { /* 자식 1 */ }
        launch { /* 자식 2 */ }
        // 모든 자식이 완료되면 함수 종료
    }
}

 

supervisorScope 특징

  • 함수 블록 실행 동안만 존재
  • 모든 자식 완료 후 자동 정리
  • suspend 함수 내에서만 호출 가능

supervisorScope 를 언제 사용하는게 좋은가?

  • 여러 독립적인 작업을 병렬로 실행
  • 일부 실패해도 나머지는 완료되어야 함
  • 모든 작업 완료 후 결과를 종합해야 함

supervisorScope 를 사용하지 말아야하는 경우

  • 하나라도 실패하면 전체 실패해야 하는 경우 → coroutineScope 사용
  • 영구적인 백그라운드 작업 → SupervisorJob() 사용
  • HTTP 요청과 독립적인 작업 → 별도 스코프 생성