Android에서 CoroutineScope를 직접 만들어 사용할 때 자주 헷갈리는 부분이 있습니다.
scope.cancel()
과
scope.coroutineContext.cancelChildren()
은 둘 다 코루틴을 취소하지만, 의미가 완전히 다릅니다.
핵심은 이렇습니다.
cancel()은 Scope 자체를 폐기하는 것이고,
cancelChildren()은 Scope 안에서 실행 중인 작업만 정리하는 것입니다.
Kotlin 공식 API 문서에서도 CoroutineScope.cancel()은 해당 scope의 Job과 모든 child를 함께 취소한다고 설명합니다. 즉, 한 번 cancel()된 scope는 이후 새 코루틴을 실행하는 용도로 재사용하면 안 됩니다.
이 글에서 다루는 내용
이 글에서는 다음 내용을 정리합니다.
- scope.cancel()과 cancelChildren()의 차이
- 어떤 상황에서 어떤 취소 방식을 써야 하는지
- Android 실무 예시
- 디버깅할 때 헷갈리는 포인트
- 잘못 사용했을 때 생기는 문제
먼저 결론
상황사용
| Scope 자체를 더 이상 쓰지 않을 때 | scope.cancel() |
| Scope는 유지하고, 안에서 돌던 작업만 중단할 때 | scope.coroutineContext.cancelChildren() |
| 특정 작업 하나만 취소할 때 | 개별 Job.cancel() |
| Android 화면 생명주기에 맞춰 자동 취소하고 싶을 때 | viewModelScope, lifecycleScope |
| Flow 수집을 화면 STARTED 상태에서만 하고 싶을 때 | repeatOnLifecycle |
문제 상황
예를 들어 이런 Scope가 있다고 가정합니다.
private val mStatusScope = CoroutineScope(Dispatchers.IO + Job())
이 Scope에서 상태 체크 작업을 여러 개 실행합니다.
mStatusScope.launch {
checkBatteryStatus()
}
mStatusScope.launch {
checkConnectionStatus()
}
mStatusScope.launch {
checkDeviceStatus()
}
이후 특정 시점에 기존 상태 체크 작업을 모두 중단하고, 다시 새 작업을 실행하고 싶습니다.
이때 아래처럼 작성하면 문제가 생길 수 있습니다.
mStatusScope.cancel()
mStatusScope.launch {
checkBatteryStatus()
}
겉으로는 컴파일도 되고, 예외도 바로 보이지 않을 수 있습니다.
하지만 launch { ... } 안의 코드가 실행되지 않습니다.
Case A. cancelChildren()— Scope는 살리고 작업만 정리
mStatusScope.coroutineContext.cancelChildren()
이 코드는 부모 Scope의 Job은 살려두고, 그 아래에 붙어 있는 child Job만 취소합니다.
구조로 보면 아래와 같습니다.
mStatusScope ─ Job (Active)
├─ child1 (cancelled)
├─ child2 (cancelled)
└─ child3 (cancelled)
이후 다시 launch를 호출하면 새 child Job이 부모 Job 밑에 붙습니다.
mStatusScope.launch {
checkBatteryStatus()
}
결과:
mStatusScope ─ Job (Active)
└─ new child (Active)
즉, cancelChildren()은 “현재 실행 중인 작업만 정리하고, 같은 Scope를 계속 재사용하고 싶을 때” 사용합니다.
cancelChildren()을 써야 하는 상황 예시
1. BLE 상태 체크 작업을 다시 시작해야 할 때
예를 들어 BLE 기기 연결 상태를 주기적으로 확인한다고 가정합니다.
private val statusScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun startStatusPolling() {
statusScope.launch {
while (isActive) {
checkBleStatus()
delay(1_000)
}
}
}
기기가 재연결되거나, 선택된 기기가 바뀌면 기존 상태 체크 루프는 중단해야 합니다.
하지만 statusScope 자체는 계속 써야 합니다.
이 경우에는 cancel()이 아니라 cancelChildren()이 적합합니다.
fun restartStatusPolling() {
statusScope.coroutineContext.cancelChildren()
statusScope.launch {
while (isActive) {
checkBleStatus()
delay(1_000)
}
}
}
이렇게 하면 기존 polling 작업은 취소되고, 같은 Scope에서 새 polling 작업을 시작할 수 있습니다.
2. 검색 요청을 새로 시작할 때
사용자가 검색어를 바꿀 때마다 이전 검색 작업을 중단하고 새 검색을 시작해야 하는 경우가 있습니다.
private val searchScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun search(keyword: String) {
searchScope.coroutineContext.cancelChildren()
searchScope.launch {
val result = repository.search(keyword)
withContext(Dispatchers.Main) {
updateSearchResult(result)
}
}
}
이 경우 searchScope.cancel()을 쓰면 첫 번째 검색 이후 Scope가 죽습니다.
그 다음 검색어를 입력해도 새 코루틴이 실행되지 않습니다.
3. 화면은 살아 있고, 내부 작업만 리셋할 때
예를 들어 Fragment는 아직 살아 있는데, 탭 전환이나 필터 변경으로 내부 작업만 다시 시작해야 하는 경우입니다.
private val filterScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun onFilterChanged(filter: Filter) {
filterScope.coroutineContext.cancelChildren()
filterScope.launch {
val filteredItems = applyFilter(filter)
withContext(Dispatchers.Main) {
render(filteredItems)
}
}
}
이 경우도 Scope 자체를 폐기하면 안 됩니다.
작업만 갈아끼우는 상황이므로 cancelChildren()이 맞습니다.
Case B. cancel()— Scope 자체를 폐기
mStatusScope.cancel()
이 코드는 Scope의 부모 Job까지 취소합니다.
구조로 보면 아래와 같습니다.
mStatusScope ─ Job (Cancelled)
├─ child1 (cancelled)
├─ child2 (cancelled)
└─ child3 (cancelled)
이후 같은 Scope에서 다시 launch를 호출하면 어떻게 될까요?
mStatusScope.launch {
println("다시 실행")
}
실행되지 않습니다.
부모 Job이 이미 Cancelled 상태이기 때문입니다. launch는 부모 Job 아래에 새 child Job을 붙이려고 하지만, 부모가 이미 취소된 상태라서 새 child도 즉시 취소됩니다.
Kotlin Coroutines의 취소는 협력적이며, 취소된 코루틴은 suspend 지점에서 취소 상태를 확인하고 CancellationException으로 종료됩니다.
cancel()을 써야 하는 상황 예시
1. 객체가 완전히 종료될 때
직접 만든 Scope가 특정 클래스의 생명주기와 묶여 있다면, 그 클래스가 종료될 때 cancel()을 호출해야 합니다.
class BleStatusManager {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun start() {
scope.launch {
observeBleStatus()
}
}
fun release() {
scope.cancel()
}
}
이 경우 release() 이후에는 BleStatusManager 인스턴스를 더 이상 사용하지 않는다는 의미입니다.
manager.release()
이후 같은 manager에서 다시 start()를 호출하는 구조라면 설계가 애매합니다.
그런 경우에는 다음 중 하나로 바꾸는 편이 낫습니다.
// 방법 1. release 후 새 인스턴스 생성
val manager = BleStatusManager()
// 방법 2. Scope를 재생성하는 구조로 설계
2. ViewModel이 종료될 때
Android에서는 ViewModel 안에서 직접 Scope를 만들기보다 viewModelScope를 사용하는 것이 일반적입니다.
viewModelScope는 ViewModel이 clear될 때 자동으로 취소됩니다. Android 공식 문서에서도 viewModelScope와 lifecycleScope 같은 lifecycle-aware scope 사용을 안내합니다.
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
repository.loadData()
}
}
}
이 경우 보통 직접 viewModelScope.cancel()을 호출하지 않습니다.
ViewModel이 종료될 때 AndroidX Lifecycle이 알아서 정리합니다.
3. Activity 또는 Fragment가 완전히 종료될 때
Activity나 Fragment에서 lifecycleScope를 사용하면 Lifecycle이 종료될 때 자동으로 취소됩니다. Android 공식 문서는 lifecycle-aware components에서 coroutine scope가 작업 실행 시점을 관리하는 데 사용된다고 설명합니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
loadData()
}
}
}
이 경우도 일반적으로 직접 lifecycleScope.cancel()을 호출하지 않습니다.
Activity나 Fragment 생명주기에 맡기는 편이 안전합니다.
특정 작업 하나만 취소하고 싶다면 Job.cancel()
항상 Scope 전체를 건드릴 필요는 없습니다.
특정 작업 하나만 취소해야 한다면 Job을 따로 보관하는 방식이 더 명확합니다.
private var pollingJob: Job? = null
fun startPolling() {
pollingJob?.cancel()
pollingJob = scope.launch {
while (isActive) {
checkStatus()
delay(1_000)
}
}
}
fun stopPolling() {
pollingJob?.cancel()
pollingJob = null
}
이 방식은 다음 상황에 적합합니다.
- 상태 polling 작업 하나만 중단하고 싶을 때
- 다운로드 작업 하나만 취소하고 싶을 때
- 이전 API 요청만 취소하고 싶을 때
- Scope 안의 다른 작업은 계속 실행되어야 할 때
예를 들어 Scope 안에 여러 작업이 있을 수 있습니다.
scope.launch {
observeConnection()
}
pollingJob = scope.launch {
observeBattery()
}
여기서 배터리 polling만 멈추고 싶다면 cancelChildren()을 쓰면 안 됩니다.
pollingJob?.cancel()
cancelChildren()을 쓰면 같은 Scope 아래에 있는 observeConnection()까지 같이 취소될 수 있습니다.
Android 실무 기준 선택 가이드
1. ViewModel에서 API 요청을 취소하고 다시 요청해야 할 때
추천:
private var loadJob: Job? = null
fun loadUser(userId: String) {
loadJob?.cancel()
loadJob = viewModelScope.launch {
val user = repository.getUser(userId)
_uiState.value = UiState.Success(user)
}
}
이 경우 viewModelScope.cancel()을 호출하면 안 됩니다.
ViewModel 전체 Scope를 죽이는 것이기 때문입니다.
2. ViewModel 안의 모든 임시 작업을 한 번에 정리하고 싶을 때
예를 들어 화면 상태를 초기화하면서 ViewModel 안에서 돌고 있던 여러 임시 작업을 모두 중단해야 한다고 가정합니다.
private val temporaryScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun resetTemporaryWorks() {
temporaryScope.coroutineContext.cancelChildren()
}
이후 같은 Scope에서 다시 작업을 실행할 수 있습니다.
fun startTemporaryWork() {
temporaryScope.launch {
doSomething()
}
}
단, ViewModel에서는 가능하면 별도 Scope를 만들기보다 Job 단위로 관리하는 편이 더 명확한 경우가 많습니다.
3. Repository나 Manager 클래스에서 Scope를 직접 만들었을 때
class DeviceConnectionManager {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun connect() {
scope.launch {
connectDevice()
}
}
fun disconnect() {
scope.coroutineContext.cancelChildren()
}
fun release() {
scope.cancel()
}
}
여기서 의미를 분리하면 좋습니다.
함수의미사용
| disconnect() | 현재 연결 관련 작업만 중단 | cancelChildren() |
| release() | Manager 자체 폐기 | cancel() |
즉, “다시 쓸 가능성이 있느냐”가 기준입니다.
잘못된 예시
1. 재사용할 Scope에 cancel() 호출
private val scope = CoroutineScope(Dispatchers.IO + Job())
fun restart() {
scope.cancel()
scope.launch {
println("실행되지 않음")
}
}
문제:
- scope.cancel()로 부모 Job이 취소됨
- 이후 launch가 새 child를 만들더라도 즉시 취소됨
- 블록 내부 코드가 실행되지 않음
- 예외가 눈에 잘 보이지 않아 디버깅이 어려움
수정:
fun restart() {
scope.coroutineContext.cancelChildren()
scope.launch {
println("정상 실행")
}
}
2. 특정 작업만 취소하면 되는데 cancelChildren()사용
scope.coroutineContext.cancelChildren()
이 코드는 Scope 아래의 모든 child를 취소합니다.
다음과 같은 구조라면 위험합니다.
scope
├─ connectionJob
├─ batteryPollingJob
└─ logUploadJob
배터리 polling만 멈추고 싶었는데, 연결 감시와 로그 업로드까지 같이 취소될 수 있습니다.
이 경우에는 개별 Job을 취소해야 합니다.
batteryPollingJob?.cancel()
테스트 코드로 확인하기
cancel()을 사용한 경우
fun main() = runBlocking {
val scope = CoroutineScope(Dispatchers.Default + Job())
scope.launch {
delay(1_000)
println("A")
}
scope.cancel()
scope.launch {
println("B")
}
delay(1_500)
}
결과:
출력 없음
scope.cancel() 이후 Scope의 부모 Job이 취소됐기 때문에 B도 출력되지 않습니다.
cancelChildren()을 사용한 경우
fun main() = runBlocking {
val scope = CoroutineScope(Dispatchers.Default + Job())
scope.launch {
delay(1_000)
println("A")
}
scope.coroutineContext.cancelChildren()
scope.launch {
println("B")
}
delay(1_500)
}
결과:
B
기존 child는 취소되지만, 부모 Job은 살아 있기 때문에 새 child가 정상 실행됩니다.
SupervisorJob을 같이 쓰는 이유
직접 Scope를 만들 때는 보통 Job()보다 SupervisorJob()을 쓰는 경우가 많습니다.
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
이유는 child coroutine 하나가 실패했을 때 다른 child까지 연쇄적으로 취소되는 것을 막기 위해서입니다.
예를 들어 BLE 상태 체크, 배터리 체크, 로그 업로드가 같은 Scope에서 동시에 실행된다고 가정합니다.
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun start() {
scope.launch {
observeConnection()
}
scope.launch {
observeBattery()
}
scope.launch {
uploadLogs()
}
}
SupervisorJob()을 사용하면 한 작업의 실패가 다른 작업에 바로 전파되지 않도록 분리할 수 있습니다.
단, 이것은 “실패 전파”에 대한 이야기이고, 직접 scope.cancel()을 호출하면 전체 Scope가 취소되는 것은 동일합니다.
실무에서 헷갈리지 않는 기준
기준 1. 다시 쓸 Scope인가?
다시 쓸 Scope라면:
scope.coroutineContext.cancelChildren()
더 이상 쓰지 않을 Scope라면:
scope.cancel()
기준 2. 전체 작업을 멈출 것인가, 하나만 멈출 것인가?
전체 child를 멈출 때:
scope.coroutineContext.cancelChildren()
작업 하나만 멈출 때:
job?.cancel()
Scope 자체를 폐기할 때:
scope.cancel()
기준 3. Android 생명주기에 맡길 수 있는가?
ViewModel이면:
viewModelScope.launch {
// 작업
}
Activity 또는 Fragment이면:
lifecycleScope.launch {
// 작업
}
화면 STARTED 상태에서만 Flow를 수집해야 한다면:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
Android에서는 lifecycle-aware scope를 사용하면 생명주기에 맞춰 코루틴을 관리할 수 있습니다.
정리
cancel()과 cancelChildren()은 이름은 비슷하지만 목적이 다릅니다.
scope.cancel()
은 Scope 자체를 종료합니다.
scope.coroutineContext.cancelChildren()
은 Scope 안의 child 작업만 취소합니다.
실무에서는 아래 기준으로 선택하면 됩니다.
하고 싶은 일사용할 코드
| Scope 자체를 폐기 | scope.cancel() |
| Scope는 유지하고 내부 작업만 정리 | scope.coroutineContext.cancelChildren() |
| 특정 작업 하나만 취소 | job.cancel() |
| ViewModel 생명주기에 맞춰 자동 관리 | viewModelScope |
| Activity/Fragment 생명주기에 맞춰 자동 관리 | lifecycleScope |
가장 중요한 기준은 하나입니다.
청소 후 또 쓸 거면 cancelChildren(),
아예 버릴 거면 cancel()입니다.
참고 자료
- Kotlin API Docs - CoroutineScope.cancel()
CoroutineScope.cancel()이 scope의 Job과 모든 child를 함께 취소한다는 동작을 확인했습니다. - Kotlin Docs - Cancellation and timeouts
Coroutine cancellation이 suspension point에서 확인되고 CancellationException으로 종료되는 동작을 확인했습니다. - Android Developers - Use Kotlin coroutines with lifecycle-aware components
Android에서 viewModelScope, lifecycleScope 등 lifecycle-aware coroutine scope를 사용하는 기준을 확인했습니다.
'Android' 카테고리의 다른 글
| Android 개발자가 써본 Claude Skills (0) | 2026.05.07 |
|---|---|
| Android Compose Animation 기본 사용법 + 좌우 tween (0) | 2026.04.23 |
| Android BLE 통신하기 (0) | 2025.11.28 |
| 2025.11 Android gradle 빌드 최신 버전 (0) | 2025.11.05 |
| Android Compose TextField 천 단위 콤마로 표현하기 (0) | 2024.10.24 |
댓글