|

코틀린 coroutineScope와 supervisorScope 차이: 실패 전파를 어떻게 다르게 다룰까

코틀린 coroutineScope와 supervisorScope의 실패 전파 차이를 설명하는 대표 이미지
전체 실패가 맞는지, 부분 실패 허용이 맞는지부터 보면 선택이 쉬워진다

coroutineScope supervisorScope 차이는 이름보다 실패 전파 정책부터 이해하는 편이 훨씬 빠릅니다. 자식 하나의 실패를 전체 실패로 봐야 하면 coroutineScope, 일부 실패를 허용하고 다른 자식은 계속 가야 하면 supervisorScope 쪽이 더 자연스럽습니다.

헷갈리는 지점은 둘 다 새 scope를 만들고, 둘 다 부모가 취소되면 같이 취소된다는 점입니다. 진짜 차이는 자식 코루틴 하나가 예외로 실패했을 때 나머지 자식에게 그 실패를 어떻게 전파하느냐에 있습니다.


coroutineScope supervisorScope 차이를 먼저 한 문장으로 보자

  • coroutineScope: 자식 중 하나가 실패하면 같은 묶음 전체를 실패로 본다
  • supervisorScope: 자식 하나의 실패가 다른 자식까지 자동으로 무너지게 하지는 않는다

이 문장만 기억해도 대부분의 선택이 빨라집니다. 다만 supervisorScope가 실패를 없애주는 것은 아닙니다. 실패를 분리해서 다루게 해주는 것에 가깝습니다.


coroutineScope는 왜 전체를 같이 취소할까

공식 문서 기준으로 coroutineScope에서는 block 또는 어떤 child coroutine이 예외로 실패하면 scope 전체가 실패하고, 다른 child와 block도 취소됩니다. 즉, 자식 작업들이 하나의 결과를 만들기 위해 서로 묶여 있다고 보는 기본 모델입니다.

suspend fun loadProfileScreen(): ProfileScreenData = coroutineScope {
    val user = async { api.loadUser() }
    val posts = async { api.loadPosts() }
    val badges = async { api.loadBadges() }

    ProfileScreenData(
        user = user.await(),
        posts = posts.await(),
        badges = badges.await(),
    )
}

이 예시에서는 user, posts, badges가 함께 있어야 화면 데이터를 완성할 수 있습니다. 그래서 하나가 실패하면 나머지를 계속 돌리는 것이 오히려 낭비일 수 있습니다.

한 결과를 만들기 위해 자식들이 모두 중요하다면 coroutineScope 쪽이 기본값에 가깝습니다.


supervisorScope는 무엇을 다르게 보나

supervisorScope는 새 scope 안에 SupervisorJob을 두고 동작합니다. 공식 문서 기준으로 child coroutine 하나가 실패해도 다른 child에는 영향을 주지 않습니다.

suspend fun loadDashboardWidgets(): DashboardResult = supervisorScope {
    val news = async { runCatching { api.loadNews() } }
    val weather = async { runCatching { api.loadWeather() } }
    val stocks = async { runCatching { api.loadStocks() } }

    DashboardResult(
        news = news.await().getOrNull(),
        weather = weather.await().getOrNull(),
        stocks = stocks.await().getOrNull(),
    )
}

이 장면에서는 위젯 일부가 실패해도 나머지 위젯은 계속 보여줄 수 있습니다. 즉, 실패를 분리해서 수집하고 화면에서 부분 성공을 허용하는 모델에 더 잘 맞습니다.

중요한 점은 여기서도 실패가 사라진 것은 아니라는 것입니다. 각 child의 실패를 어떻게 기록하고, 어떤 기본값이나 대체 UI를 줄지까지 직접 결정해야 합니다.


둘 다 부모 취소는 내려온다

coroutineScope든 supervisorScope든 둘 다 호출자 coroutine context를 바탕으로 새 scope를 만들기 때문에, 부모가 취소되면 그 새 scope도 함께 취소됩니다.

즉, supervisorScope를 쓴다고 해서 부모 생명주기에서 독립하는 것은 아닙니다. 그래서 이 비교는 부모 취소 전파를 막느냐의 문제가 아니라, 형제 자식 사이에 실패를 어떻게 전파하느냐의 문제로 이해해야 맞습니다.


block 자체가 실패하면 supervisorScope도 전체가 취소된다

supervisorScope를 처음 접하면 자식 실패를 막아주니까 전체가 안 깨지겠네라고 생각하기 쉽습니다. 하지만 공식 문서는 block 자체가 예외로 끝나면 scope와 모든 child가 취소된다고 분명히 설명합니다.

suspend fun loadWithFallback(): Unit = supervisorScope {
    val primary = async { api.loadPrimary() }
    val secondary = async { api.loadSecondary() }

    val primaryResult = primary.await()
    if (primaryResult.isInvalid()) {
        throw IllegalStateException("핵심 데이터가 유효하지 않습니다")
    }

    secondary.await()
}

여기서는 child끼리 독립적일 수 있어도, block 본문에서 핵심 검증이 실패하면 전체 scope는 취소됩니다. 따라서 supervisorScope는 아무도 전체를 깨지 못한다가 아니라, child 하나의 실패를 다른 child에 자동 전파하지 않는다로 이해하는 편이 정확합니다.


실무에서는 어떤 장면에서 갈릴까

전체 결과가 하나여야 할 때

  • 프로필 화면을 그리려면 핵심 데이터 세 묶음이 모두 필요하다
  • 파일 두 개를 모두 받아야 비교가 가능하다
  • 여러 검증 단계가 모두 통과해야 다음 단계로 갈 수 있다

이런 경우에는 coroutineScope가 더 자연스럽습니다. 하나가 실패했는데 나머지를 계속 돌려도 최종 결과를 쓸 수 없는 경우가 많기 때문입니다.

부분 성공이 의미 있을 때

  • 대시보드 위젯 여러 개를 독립적으로 그린다
  • 추천 섹션 3개 중 1개만 실패해도 나머지는 보여줄 수 있다
  • 부가 지표 수집이 일부 실패해도 핵심 요청은 계속 처리해야 한다

이런 경우에는 supervisorScope를 검토할 만합니다. 실패를 격리하고, 남은 결과를 계속 활용할 수 있기 때문입니다.


launch와 async는 예외 처리 감각이 조금 다르다

supervisorScope를 설명할 때 흔히 빠지는 함정이 여기입니다. 공식 문서는 supervisorScope가 새 scope에 CoroutineExceptionHandler를 설치해주지 않으므로, launch로 시작한 child의 예외가 uncaught exception으로 남을 수 있다고 경고합니다.

suspend fun refreshWidgets() = supervisorScope {
    launch {
        repository.refreshNews()
    }

    launch {
        repository.refreshWeather()
    }
}

이 구조에서 child 하나가 실패해도 다른 child가 자동으로 취소되지는 않을 수 있습니다. 하지만 그렇다고 예외 처리가 저절로 끝나는 것은 아닙니다. launch 쪽은 별도의 handler나 내부 try-catch가 필요할 수 있습니다.

suspend fun refreshWidgets() = supervisorScope {
    launch {
        try {
            repository.refreshNews()
        } catch (e: Exception) {
            logger.warn("news refresh failed", e)
        }
    }

    launch {
        try {
            repository.refreshWeather()
        } catch (e: Exception) {
            logger.warn("weather refresh failed", e)
        }
    }
}

반대로 async는 보통 await() 지점에서 실패를 읽고 조합하는 흐름을 만들기 쉽습니다. 그래서 부분 성공을 다루는 코드에서는 runCatching + async/await 조합이 더 읽기 좋을 때가 많습니다.


supervisorScope가 맞아 보여도 남겨야 할 질문

  • 실패한 child의 결과를 무엇으로 대체할 것인가
  • 로그는 어디서 남길 것인가
  • 사용자에게 부분 실패를 어떻게 보여줄 것인가
  • 재시도 기준은 child별로 둘 것인가, 전체로 둘 것인가

즉, supervisorScope는 편한 만능 도구가 아니라 부분 실패 허용 정책을 코드로 드러내는 도구에 가깝습니다. 정책이 없다면 오히려 실패가 조용히 흩어져서 디버깅이 어려워질 수도 있습니다.


가장 빠른 선택 규칙

  1. 자식 작업 중 하나가 실패하면 최종 결과 전체가 무의미해지는가
  2. 일부 결과만 남아도 사용자나 호출자에게 가치가 있는가
  3. 실패한 child를 어디서 어떻게 기록하고 복구할지 정했는가
  • 모두 함께 성공해야 의미 있음 → coroutineScope
  • 일부 실패를 허용하고 나머지는 계속 진행 → supervisorScope
  • 부분 실패를 허용하지만 예외 처리 정책이 아직 없음 → supervisorScope부터 쓰기보다 정책 먼저 정리

선택 기준은 문법이 아니라 실패를 하나로 묶을지, 분리해서 다룰지입니다.


자주 하는 오해

supervisorScope는 더 안전한 상위 버전이다

아닙니다. 둘은 상하 관계라기보다 실패 정책이 다릅니다. 전체 실패가 맞는 작업에 supervisorScope를 넣으면 오히려 오류를 늦게 발견하거나, 부분 결과를 어설프게 조합하게 될 수 있습니다.

supervisorScope면 예외를 신경 안 써도 된다

아닙니다. 실패 전파를 끊는 것과 예외를 처리하는 것은 다른 문제입니다. 특히 launch child는 예외가 uncaught로 남지 않게 별도 처리를 고민해야 합니다.

coroutineScope는 부모까지 무조건 다 망가뜨린다

공식 문서 설명대로 이 scope의 failure는 부모 Job을 직접 취소하는 일반 child failure와는 다르게 동작하며, 예외는 호출자에게 다시 던져집니다. 실무에서는 현재 함수 호출은 실패로 끝난다고 이해하는 편이 더 안전합니다.


마무리

coroutineScope supervisorScope 차이는 문법 표보다 실패 전파를 어떻게 설계하느냐에서 갈립니다. 자식들이 한 결과를 위해 함께 움직이는 작업이면 coroutineScope, 일부 실패를 허용해도 남은 결과를 계속 살리고 싶다면 supervisorScope를 먼저 떠올리면 됩니다.

다만 supervisorScope를 고르는 순간 예외 처리, 대체값, 로그, 재시도 같은 운영 정책도 함께 따라와야 합니다. 그래서 진짜 질문은 어느 scope가 더 좋아 보이나가 아니라, 이 작업의 실패를 하나로 묶어야 하나, 분리해서 다뤄야 하나입니다.

같이 보면 흐름이 이어질 만한 글로는 StateFlow와 SharedFlow 차이: 안드로이드에서 상태와 이벤트를 왜 나눠야 할까, repeatOnLifecycle vs launchWhenStarted, ViewModel은 왜 필요할까를 추천합니다.

공식 기준은 coroutineScope 문서supervisorScope 문서를 함께 읽어보면 가장 정확합니다.

함께보면 좋은 글