관리 메뉴

피터의 개발이야기

[kotlin] 코틀린 코루틴의 정석- 예외처리 본문

Programming/Kotlin

[kotlin] 코틀린 코루틴의 정석- 예외처리

기록하는 백앤드개발자 2024. 6. 4. 10:10
반응형

ㅁ 들어가며

 코틀린 코루틴의 정석 책을 보고 정리한 글입니다.

 

8장 예외 처리

- 예외전파 방식
- 예외전파의 제한
- 예외를 CoroutineExcetionHandler 처리
- try catch문을 이용한 예외처리
- async를 통해 생성된 코루틴의 예외처리방법
- 전파되지 않는 예외

 

ㅁ 예외전파 방식

fun main() = runBlocking<Unit> {
  launch(CoroutineName("작업1")) {
    launch(CoroutineName("작업1-1")) {
      delay(100L)
      throw Exception("작업1-1 예외 발생")
    }
    delay(100L)
    println("[${Thread.currentThread().name}] 작업1 실행")
  }
  launch(CoroutineName("작업2")) {
    delay(100L)
    println("[${Thread.currentThread().name}] 작업2 실행")
  }
  delay(1000L)
}
/*
[main @작업1#2] 작업1 실행
[main @작업2#3] 작업2 실행
Exception in thread "main" java.lang.Exception: 작업1-1 예외 발생

종료 코드 1(으)로 완료된 프로세스
*/

// exception 발생 시간을 50L로 축소
~~~
    launch(CoroutineName("작업1-1")) {
      delay(50L)  //exception 발생 시간 조정
      throw Exception("작업1-1 예외 발생")
    }
~~~
/*
Exception in thread "main" java.lang.Exception: 작업1-1 예외 발생

종료 코드 1(으)로 완료된 프로세스
*/

ㅇ 코루틴에서 예외가 발생하면 부모코루틴에 전파되고 작업은 취소된다. 만약 예외처리가 되어 있으면 전체로 전파되지는 않는다. 예외전파로 인해 상위 코루틴이 취소가 되면, scope에 따라 전체가 취소처리 될 수 있다. 

ㅇ 따라서 Exception 발생 시점에 따라 작업들의 진행 상태가 달라질 수 있다. 

 

ㅁ 예외전파의 제한

fun main() = runBlocking<Unit> {
  launch(CoroutineName("작업1")) {
    launch(CoroutineName("작업1-1") + Job()) { // 새로운 Job을 연결
      delay(50L)
      throw Exception("작업1-1 예외 발생")
    }
    delay(100L)
    println("[${Thread.currentThread().name}] 작업1 실행")
  }
  launch(CoroutineName("작업2")) {
    delay(100L)
    println("[${Thread.currentThread().name}] 작업2 실행")
  }
  delay(1000L)
}
/*
Exception in thread "main @작업1-1#4" java.lang.Exception: 작업1-1 예외 발생
	
[main @작업1#2] 작업1 실행
[main @작업2#3] 작업2 실행

종료 코드 0(으)로 완료된 프로세스
*/

ㅇ 예외 전파를 제한하기 위하여 새로운 Job을 연결하였다.

ㅇ 예외가 발생하여도 다른 작업들은 정상적으로 수행된다.

ㅇ 하지만 이 방법은 cancel시 또 다른 문제점이 될 수 있다.

 

Job을 이용한 예외전파의 한계

fun main() = runBlocking<Unit> {
  val job1 = launch(CoroutineName("작업1")) {
    launch(CoroutineName("작업1-1") + Job()) {
      delay(100L)
//      throw Exception("작업1-1 예외 발생")
      println("[${Thread.currentThread().name}] 작업1-1 실행")
    }
    delay(100L)
    println("[${Thread.currentThread().name}] 작업1 실행")
  }
  val job2 = launch(CoroutineName("작업2")) {
    delay(100L)
    println("[${Thread.currentThread().name}] 작업2 실행")
  }
  delay(50L)
  job1.cancel()
  job2.cancel()
  delay(1000L)
}
/*
[main @작업1-1#4] 작업1-1 실행

종료 코드 0(으)로 완료된 프로세스
*/

ㅇ Job을 분리시키면 작업이 분할되면서 cancel이 적용되지 않는다.

ㅇ Job을 이용한 예외전파의 한계를 극복하기 위해서는 SupervisorJob을 사용하면 된다.

 

SupervisorJob를 이용한 예외 전파 제한

fun main() = runBlocking<Unit> {
  val supervisorJob = SupervisorJob()  // 예외 전파를 제한하는 Job 선언
  val job1 = launch(CoroutineName("작업1") + supervisorJob) {
    launch(CoroutineName("작업1-1")) {
      delay(50L)
      throw Exception("작업1-1 예외 발생")
    }
    delay(100L)
    println("[${Thread.currentThread().name}] 작업1 실행")
  }
  val job2 = launch(CoroutineName("작업2") + supervisorJob) {
    delay(100L)
    println("[${Thread.currentThread().name}] 작업2 실행")
  }

  delay(1000L)
}
/*
Exception in thread "main @작업1-1#4" java.lang.Exception: 작업1-1 예외 발생

[main @작업1#2] 작업1 실행
[main @작업2#3] 작업2 실행

종료 코드 0(으)로 완료된 프로세스
*/

 

ㅇ 이전 소스에서 Job-> SupervisorJob으로 변경하였다.

ㅇ 작업1-1이 예외가 발생하였지만, 예외가 전파되지 않고 다른 작업들이 수행되었다.

ㅇ SupervisorJob()을 생성 시 parent가 널이라 새로운 rootJob으로 이루어지기 때문에 cancel이 되지 않는 구조적 문제가 발생한다.

ㅇ 이를 방지 하기 위해서는 root의 Job을 주입시켜야 한다.

 

fun main() = runBlocking<Unit> {
  val supervisorJob = SupervisorJob(this.coroutineContext[Job])  // 구도화된 동시성을 위해 상위 Job을 부모로 넘겨준다.
  val job1 = launch(CoroutineName("작업1") + supervisorJob) {
    launch(CoroutineName("작업1-1")) {
      delay(100L)
      throw Exception("작업1-1 예외 발생")
    }
    delay(100L)
    println("[${Thread.currentThread().name}] 작업1 실행")
  }
  val job2 = launch(CoroutineName("작업2") + supervisorJob) {
    delay(100L)
    println("[${Thread.currentThread().name}] 작업2 실행")
  }
  supervisorJob.complete() // supervisorJob 완료처리를 해야한다.
}
/*
[main @작업1#2] 작업1 실행
[main @작업2#3] 작업2 실행
Exception in thread "main @작업1-1#4" java.lang.Exception: 작업1-1 예외 발생

종료 코드 0(으)로 완료된 프로세스
*/

ㅇ supervisorJob는 Job처럼 자동완료가 되지 않아 따로 명시적으로 완료처리를 해야한다.

 

SupervisorJob와 CoroutineScope를 이용한 예외제한 

fun main() = runBlocking<Unit> {
  val coroutineScope = CoroutineScope(SupervisorJob())
  coroutineScope.apply {
    launch(CoroutineName("작업1")) {
      launch(CoroutineName("작업1-1")) {
        throw Exception("작업1-1 예외 발생")
      }
      delay(200L)
      println("[${Thread.currentThread().name}] 작업1 실행")
    }
    launch(CoroutineName("작업2")) {
      delay(200L)
      println("[${Thread.currentThread().name}] 작업2 실행")
    }
  }
  delay(1000L)
}
/*
Exception in thread "DefaultDispatcher-worker-1 @작업1#2" java.lang.Exception: 작업1-1 예외 발생
	...
[DefaultDispatcher-worker-1 @작업2#3] 작업2 실행

종료 코드 0(으)로 완료된 프로세스
*/

ㅇ 하나의 Scope로 묶어서 SupervisorJob을 한번 선언하면 예외전파를 제한할 수 있다.

 

SupervisorScope를 이용한 예외제한

fun main() = runBlocking<Unit> {
  supervisorScope {
    launch(CoroutineName("작업1")) {
      launch(CoroutineName("작업1-1")) {
        throw Exception("작업1-1 예외 발생")
      }
      delay(100L)
      println("[${Thread.currentThread().name}] 작업1 실행")
    }
    launch(CoroutineName("작업2")) {
      delay(100L)
      println("[${Thread.currentThread().name}] 작업2 실행")
    }
  }
}
/*
Exception in thread "main @작업1#2" java.lang.Exception: 작업1-1 예외 발생
...
[main @작업2#3] 작업2 실행

종료 코드 0(으)로 완료된 프로세스
*/

ㅇ SupervisorScope 내부에서 실행되는 코루틴은 SupervisorJob과 부모자식으로 구조화되는데 supervisorScope의 SupervisorJob는 코드가 모두 실행되고 자식 코루틴도 모두 실행완료되면 자동으로 완료처리 된다. 

ㅇ 그래서 supervisorScope를 사용하면 복잡한 설정 ㅇ벗이 구조화를 깨지 않고 예외 전파를 제한할 수 있다.

 

Job
runBlocking
SupervisorJob
supervisorScope
Job
작업1
Job
작업2
Job
작업1-1
 

ㅇ SupervisorScope 사용시 코루틴의 구조

 

ㅁ CoroutineExcetionHandler를 이용한 예외 처리

fun main() = runBlocking<Unit> {
  val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    println("[예외 발생] ${throwable}")
  }
  CoroutineScope(exceptionHandler).launch(CoroutineName("작업1")) {
    throw Exception("작업1에 예외가 발생")
  }
  delay(1000L)
 /*
[예외 발생] java.lang.Exception: 작업1에 예외가 발생
종료 코드 0(으)로 완료된 프로세스
  */
}

ㅇ CoroutineExceptionHandler가 마지막으로 전파되는 CoroutineScope의 CoroutineContext에 루트Job과 CoroutineExceptionHandler가 함께 설정돼 있었기 때문이다. 

ㅇ 이처럼 CoroutineExceptionHandler를 마지막 전파위치에 설정하면 예외처리가 동작한다. 

ㅇ CoroutineExceptionHandler는 예외를 전파하지 않는다.

 

ㅁ try catch문을 이용한 예외처리

fun main() = runBlocking<Unit> {
  launch(CoroutineName("작업1")) {
    try {
      throw Exception("작업1에 예외가 발생했습니다")
    } catch (e: Exception) {
      println(e.message)
    }
  }
  launch(CoroutineName("작업2")) {
    delay(100L)
    println("작업2 실행 완료")
  }
}
/*
작업1에 예외가 발생했습니다
작업2 실행 완료
*/

ㅇtry catch문으로 작업1의 예외를 처리하여 작업2에 영향을 주지 않았다.

 

fun main() = runBlocking<Unit> {
  try {
    launch(CoroutineName("작업1")) {
      // 새로운 코루틴에서 에러가 발생하는 경우 try가 catch할 수 없다.
      throw Exception("작업1에 예외가 발생했습니다")
    }
  } catch (e: Exception) {
    println(e.message)
  }

  launch(CoroutineName("작업2")) {
    delay(100L)
    println("작업2 실행 완료")
  }
}
/*
Exception in thread "main" java.lang.Exception: 작업1에 예외가 발생했습니다
*/

ㅇ try catch는 같은 스래드 안에서 발생하는 경우만 catch가 가능하다.

 

ㅁ async의예외처리

fun main() = runBlocking<Unit> {
  supervisorScope {
    val deferred: Deferred<String> = async(CoroutineName("작업1")) {
      throw Exception("작업1 예외 발생")
    }
    try {
      deferred.await()
    } catch (e: Exception) {
      println("[예외] ${e.message}")
    }
  }
}
/*
Exception in thread "main" java.lang.Exception: 작업1에 예외가 발생했습니다
*/

 

// 취소전파
fun main() = runBlocking<Unit> {
  async(CoroutineName("작업1")) {
    throw Exception("작업1 예외 발생")
  }
  // 작업1에 의해 작업2는 취소된다.
  launch(CoroutineName("작업2")) {
    delay(100L)
    println("[${Thread.currentThread().name}] 작업2 실행")
  }
}
/*
Exception in thread "main" java.lang.Exception: 작업1 예외 발생
...
종료 코드 1(으)로 완료된 프로세스
*/


// 슈퍼바이저 예외 차단
fun main() = runBlocking<Unit> {
  // 슈퍼바이저스코프를 설정하여 예외 전파를 차단한다.
  supervisorScope {
    async(CoroutineName("작업1")) {
      throw Exception("작업1 예외 발생")
    }
    launch(CoroutineName("작업2")) {
      delay(100L)
      println("[${Thread.currentThread().name}] 작업2 실행")
    }
  }
}
/*
[main @작업2#3] 작업2 실행
*/

 

ㅁ 전파되지 않는 예외

fun main() = runBlocking<Unit>(CoroutineName("runBlocking 작업")) {
  launch(CoroutineName("작업1")) {
    launch(CoroutineName("작업2")) {
      println("[${Thread.currentThread().name}] 작업2 실행")
      throw CancellationException() //CancellationException 예외가 부모에게 전파되지 않는다
    }
    delay(100L)
    println("[${Thread.currentThread().name}] 작업1 실행")
  }
  delay(100L)
  println("[${Thread.currentThread().name}] runBlocking 작업 실행")
}
/*
[main @작업2#3] 작업2 실행
[main @runBlocking 작업#1] runBlocking 작업 실행
[main @작업1#2] 작업1 실행

종료 코드 0(으)로 완료된 프로세스
*/

ㅇ CancellationException는 예외가 부모에게 전파되지 않는다.

 

ㅇ 코루틴 취소 시 발생하는 JobCancellationException도 전파하지는 않는다. 상세 294p

ㅇ withTimeout으로 시간제약을 주는 경우 TimeoutCancellationException이 발생하지만 예외전파는 하지 않는다.

 

ㅁ 함께 보면 좋은 사이트

 조세영 - 코틀린 코루틴의 정석

반응형
Comments