kotlin Coroutine: 코루틴 예외 전파 제한 왜 하는거지?(with SupervisorJob)

Murjune·2024년 8월 30일
3

Coroutine

목록 보기
4/8
post-thumbnail

테코톡에서는 [3:48 ~ 8:12] 에 해당하는 내용입니다.
지난 시간에 배운 내용 리마인드~

  • 코루틴 예외 전파 메커니즘
    1) 예외가 발생할 시, 자기 자신을 취소시킨다. (자식 코루틴들 모두 취소)
    2) 예외 발생 시, 부모로 예외를 전파시킨다. (부모, 형제 코루틴들 모두 취소)

  • 이전 포스팅
    코루틴 예외가 전파되는 방식

이번 포스팅에서는 SupervisorJob 을 활용해서 예외 전파 제한하는 방법에 대해서 알아볼 것입니다.


1. 예외 전파 제한이 필요한 경우

코루틴을 활용하여 비동기 작업을 하다 보면 하나의 작업을 여러 작업으로 쪼개 병렬처리하는 경우가 종종 있습니다. 보통 suspend 함수에서 코루틴 빌더함수 async 와 코루틴 스코프 함수coroutineScope를 활용하여 처리합니다.

async 와 coroutineScope 를 사용하여 병렬 처리하는 이유를 자세히 알고 싶으신 분은 Kotlin Coroutine: suspend 함수를 Effective 하게 설계하자! 에서 2) suspend function 에서 병렬 처리할 때, CoroutineScope를 사용하지 말자3) coroutineScope or withContext 함수를 활용하자!⭐️ 부분을 참고해주세요 😉

로컬 저장소의 이미지 경로를 통해 서버에 이미지들을 업로드한 후, 이미지 url을 받아오는 예제를 통해 예외 전파 제한이 필요성에 대해 알아볼 것이에요!😎

현재 다수의 이미지를 업로드하고 있습니다. 하나의 이미지 업로드 당 하나의 child 코루틴에게 할당하여 병렬처리하였습니다. 코드로 보면 다음과 같습니다.

suspend fun uploadImages(localImagePaths: List<String>): List<String> = coroutineScope {
    localImagePaths.map { localImagePath ->
        async { uploadImage(localImagePath) }
    }.awaitAll()
}

fun main() = runBlocking {
    val paths = listOf("이미지 1", "이미지 2", "이미지 3", "이미지 4")
    val result = uploadImages(paths)
    println(result)
    // output: ["서버 이미지 1", "서버 이미지 2", "서버 이미지 3", "서버 이미지4"]
} 

언듯 보기에는 별 문제가 없는 코드입니다.

이때! '이미지 4' 에 해당하는 이미지를 업로드할 때 에러가 발생했다고 해봅시다.

그럼, '이미지 4' 에서 발생한 이미지는 coroutineScope 코루틴에게 예외를 전파하고 모든 이미지 업로드 작업들을 취소시킬 것입니다.

그러면 사용자는 다음과 같은 화면을 마주하게 될 것입니다.

현재, 기획단에서는 업로드에 실패한 이미지만 에러뷰를 보여주고, 업로드에 성공한 이미지는 모두 보여달라고 요청하고 있습니다. 어떻게 해야할까요?

바로 이럴 때 SuperVisorJob 을 활용하여 예외 전파 제한을 활용하여 해결할 수 있습니다.
SuperVisorJob 에 대해 알아봅시다!

2. SupervisorJob

SupervisorJob 은 자식 코루틴으로부터 예외를 전파받지 않은 특수한 Job 이고, SupervisorJob() 팩토리 함수를 통해 생성할 수 있습니다.

SupervisorJob() 에 의해 생성된 SupervisorJob 은 Job() 팩토리 함수와 자식 코루틴의 예외 전파를 방지 제한한다는 점을 빼고 동일합니다. 팩토리 함수에 의해 생성된 Job 은 같이 다음 2가지 특징을 가지고 있습니다.

1) 부모 코루틴과의 구조화된 동시성을 깬다
2) Job 팩토리 함수를 통해 생성된 Job 은 항상 active 하다 (별도의 처리가 없다면)

위 특징과 부모 코루틴은 자식 코루틴이 작업을 끝날 때까지 기다린다 는 코루틴의 특징을 함께 생각해보면
SupervisorJob() 을 왜 유의해서 사용해야하는지 알 수 있습니다.

한 번 곰곰히 생각해보고 다음 챕터를 읽어보시죠 🤔

2-1) SupervisorJob() 의 유의점 1 : 독립적인 코루틴이 될 수 있다

Job() 과 동일하게 SuperVisorJob() 으로 생성된 Job 은 파라미터로 부모 Job 을 넣어주지 않으면 새로운 root Job이 됩니다. 즉, SuperVisorJob() 을 호출한 코루틴과의 부모-자식 관계가 끊어진다는 점을 뜻합니다.

부모 자식 관계가 깨지게되면 호출자 코루틴은 더이상 SupervisorJob을 기다리지 않게 됩니다.

suspend fun foo() = coroutineScope {
    val job = SupervisorJob()
    launch(CoroutineName("Child") + job) { // coroutineScope 코루틴과 독립적인 코루틴
        delay(10)
        println("나를 이제 기다리지마오~") // 출력 ❌
    }
    println("끝")
}

coroutineScope 는 SupervisorJob() 와 독립적인 코루틴 관계가 되기에 Child 코루틴이 끝날 때까지 대기해주지 않습니다.

따라서, 부모-자식 관계를 깨고 싶지 않다면 SupervisorJob() 의 부모를 coroutineScope 의 job 로 설정해주어야합니다.

suspend fun foo() = coroutineScope {
    val supervisorJob = SupervisorJob(parent = coroutineContext.job)
    launch(CoroutineName("Child") + supervisorJob) {
        ...
        println("이제 출력됨 ✅")
    }
    println("끝")
}

현재 foo() 은 종료가 되지 않고 있습니다.
왜 그럴까요? 그건 Job 이 active 한 상태이기 때문입니다.

2-2) SuperVisorJob() 의 유의점 2 : 항상 active 하다

일반적인 Job 빌더함수 launch(), async() 를 통해 생성된 Job은 위와 같은 생명주기를 갖습니다. launch 블럭이 끝나면 Completed 상태, 취소가 되면 Canceled 상태로 종료됩니다.

그러나, SuperVisorJob(), Job() 와 같은 잡 팩토리 함수에 의해 생성된 Job 은 항상 active 합니다. 따라서, coroutineScope 입장에서는 supervisorJob 이 계속 active 하기에 끝날때까지
계속 대기하는거죠

따라서, complete() 함수를 통해 명시적으로 job 을 종료시켜주어야합니다.

complete() : 잡의 상태를 completed 상태로 만듦. 만약, 자식 코루틴이 아직 active 하다면 완료될 때까지 기다린 후 completed 상태가 됨

suspend fun foo() = coroutineScope {
    val supervisorJob = SupervisorJob(parent = coroutineContext.job)
    launch(CoroutineName("Child") + supervisorJob) {
        ..
    }
    supervisorJob.complete() // 명시적으로 종료
    println("끝")
}

이제야 작업을 마치고 프로그램을 종료하네요 😁

자 그럼 이제 이미지 업로드하는 예시에 SupervisorJob() 을 적용해볼까요?

3. 이미지 업로드 예제: SupervisorJob() 적용

이미지 업로드 예제에서 한가지 더 처리해줘야합니다. 바로 await() 를 할 때 예외 처리를 해줘야합니다. async{}는 Deferred 잡 객체에 결과값을 저장하고, await() 를 통해 결과값을 불러오는 특징이 있습니다. 그래서, async{} 블럭 내부에 예외를 발생시킬 경우, await() 를 호출하면 예외가 발생합니다.

따라서, await() 를 호출하는 부분에 try-catch 로 감싸주어 예외처리 해주어야합니다.

완성된 코드는 다음과 같습니다!

suspend fun uploadImage(imagePath: String): String = withContext(Dispatchers.IO) {
    delay(100) // 로컬 이미지를 불러와 Form 데이터 형태로 바꾸는 작업이라 가정
    if (imagePath == "이미지 4") error("예외 발생 😵")
    val imageUrl = "서버 이미지: $imagePath"
    imageUrl
}


suspend fun uploadImages(localImagePaths: List<String>): List<String?> = coroutineScope {
    val supervisor = SupervisorJob(coroutineContext.job) // 부모 코루틴 설정
    val result = localImagePaths.map { localImagePath ->
        async(supervisor) { uploadImage(localImagePath) } { uploadImage(localImagePath) }
    }.map {
        try { // await() 예외 처리
            it.await()
        } catch (e: IllegalStateException) {
            null
        }
    }
    supervisor.complete() // supervisor 명시적 종료
    result
}

fun main() = runBlocking {
    val localImagePaths = listOf("이미지 1", "이미지 2", "이미지 3", "이미지 4")
    val images = uploadImages(localImagePaths)
    println(images)
}

예외가 발생한 이미지의 경우에는 null 을 반환하도록 했습니다.
그럼 사용자는 기획이 원하는 화면을 마주할 수 있겠습니다 😁

위 코드 구조를 그림으로 나타내면 다음과 같습니다.

4. SuperVisorJob() 을 사용할 때 자주하는 실수

SupervisorJob 을 처음 사용할 때 자주하는 실수입니다.

CoroutineScope 안에 supervisorJob 을 넣거나, launch 에 supervisorJob을 넣고 그 내부 블럭에
launch{} 를 열면 예외 전파 방지가 되지 않습니다.

코드로 보면 다음과 같습니다.

suspend fun foo() = coroutineScope {
    val supervisor = SupervisorJob(coroutineContext.job)
    // 잘못된 예외 전파 방식 1
    CoroutineScope(supervisor).launch {
        launch { error("에러") }
    }
    // 잘못된 예외 전파 방식 2
    launch(supervisor) { 
        launch { error("에러") } 
    }
}

그 이유는 SupervisorJob 과 예외가 발생하는 코루틴 사이에 launch 빌더에 의해 만들어진 Job 이 존재하기 때문입니다.
아리까리 하면 다음 그림으로 보시면 이해하기 쉬울거에요!

추가로 제가 테코톡에서 7분 32초 경 아래 슬라이드에서 해당 부분을 잘못 설명했습니다 ^__^

아래 다음과 같이 써야 잘못된 사례입니다.

suspend fun foo() = coroutineScope {
  val supervisor = SupervisorJob(coroutineContext.job)
  launch(supervisor) {
      launch(CoroutineName("Child1")) {
          error("에러 발생")
      }
      launch(CoroutineName("Child2")) {
          delay(10)
          println("Child2")
      }
      launch(CoroutineName("Child3")) {
          delay(10)
          println("Child3")
      }
  }
}

>> foo()

예외 전파를 못해서 Child2 와 Child3는 출력 안됨

5. 정리

1) SupervisorJob 은 자식 코루틴의 예외 전파를 방지한다.
2) SupervisorJob() 로 생성된 SupervisorJob 은 root Job 이 된다.
3) SupervisorJob() 로 생성된 SupervisorJob 은 항상 active 하다.

오늘은 이미지를 업로드하는 예시를 통해 SupervisorJob로 예외 전파를 제한하는 방법을 배웠습니다.

사실 오늘 예시에서는 위 2번, 3번 특징 때문에 supervisorScope를 사용하는 것이 더 적절한데요! 다음 포스팅에서는 위 이미지 업로드 예시를 supervisorScope 로 리팩토링해보면서 왜 supervisorScope 가 더 적절한지 배울 것입니다 💪

이번 포스팅에서 사용된 예시는 supervisorScope 를 설명하기 위한 빌드업으로 사용된 것이니 위 예시에서 사용하는 코드를 실 프로젝트 코드에 적용하는 것은 비추천드립니다 😨

SupervisorJob 은 CoroutineScope() 와 함께 root Coroutine 에서 사용하는 것이 더 적절한데요 이 내용도 추가로 포스팅하도록 하겠습니다

profile
열심히 하겠슴니다:D

2개의 댓글

comment-user-thumbnail
2024년 12월 12일

좋은 글 감사합니다~

1개의 답글

관련 채용 정보