[인프런] 2시간으로 끝내는 코루틴 - 3~5강 정리

안녕하세요, 해을입니다🦖

코루틴 복습 차원에서 인프런 강의 중 ‘2시간으로 끝내는 코루틴’ 강의를 수강하게 되었는데요.

이번 글에서는 코루틴 기초 섹션 내 3~5강내용을 정리해보겠습니다!

💡3강. 코루틴 빌더와 Job

🥨 runBlocking

  • 새로운 코루틴을 만들고, 루틴 세계와 코루틴 세계를 이어줌

  • runBlocking으로 생성된 코루틴과 그 안에서 실행되는 모든 코루틴이 완료될 때까지 현재 스레드를 블로킹

  • 잘못 사용하면 스레드가 블락되어 다른 코드를 실행 할 수 없게 됨
    ⇒ 일반 로직에서는 지양해야 하며, 프로그램 진입점(main 함수)이나 테스트 코드에서만 사용하는 것이 권장됨

      fun main() {
          runBlocking {
              printWithThread("START")
              launch {
                  delay(2_000L) // yield()
                  printWithThread("LAUNCH END")
              }
          }
    
          printWithThread("END")
      }
    
    • END가 출력되기 위해서는 runBlocking 내부의 모든 코루틴이 모두 완전히 종료되어야 함

🥨 launch

  • 반환값이 없는 작업을 실행할 때 사용

  • runBlocking과 달리, 코루틴을 제어할 수 있는 Job 객체를 반환

  • 반환된 Job 객체를 통해 코루틴의 시작 / 취소 / 완료 대기 등의 제어가 가능

    Job 객체 활용 정리
    1. start() : 코루틴 시작

       fun example1(): Unit = runBlocking{
           val job = launch(start = CoroutineStart.LAZY) {
               printWithThread("Hello launch")
           }
      
           delay(1_000L)
           job.start()
       }
      
      • CoroutineStart.LAZY : 코루틴이 즉시 실행되지 않도록 설정
      • job.start() : 시작 신호 전달
    2. cancel() : 코루틴 취소

       fun example2(): Unit = runBlocking {
           val job = launch {
               (1..5).forEach {
                   printWithThread(it)
                   delay(500)
               }
           }
      
           delay(1_000L)
           job.cancel()
       }    
      
      • job.cancel() : 취소 신호 전달
    3. join() : 코루틴 종료까지 대기

       fun example3(): Unit = runBlocking {
           val job1 = launch {
               delay(1_000L)
               printWithThread("Job 1")
           }
           job1.join()
      
           val job2 = launch {
               delay(1_000L)
               printWithThread("Job 2")
           }
       }
      
      • job1.join() 사용 X

        • 약 1.1초 소요
        • job1에서 1초를 기다리는 동안 job2가 시작되어 함께 1초를 기다리기 때문
      • job1.join() 사용 O

        • 2초 이상 소요
        • join()을 호출하여 첫 번째 코루틴이 완전히 끝날때까지 기다리기 때문

🥨 async

  • 값을 반환하는 코루틴을 만들 때 사용

  • launch()와 마찬가지로 코루틴 제어 객체를 반환하지만, 반환 타입은 Deferred<T>

  • DeferredJob의 하위 타입으로 Job과 동일한 기능들이 있고, 실행 결과를 가져오는 await() 함수를 제공

      fun main(): Unit = runBlocking {
          val job = async {
              3+5
          }
    
          val eight = job.await()
          printWithThread(eight)
      }
    
    async 장점

    1️⃣ 여러 API를 동시에 호출하여 성능 개선

      ```kotlin
      fun main(): Unit = runBlocking {
          val time = measureTimeMillis {
              val job1 = async { apiCall1() }
              val job2 = async { apiCall2() }
              printWithThread(job1.await() + job2.await())
          }
    
          printWithThread("소요 시간 : $time ms")
      }
    
      suspend fun apiCall1(): Int {
          delay(1_000L)
          return 1
      }
    
      suspend fun apiCall2(): Int {
          delay(1_000L)
          return 2
      }
      ```
      ```
      [main @coroutine#1] 3 
      [main @coroutine#1] 소요 시간 : 1013 ms
      ```
      - 두 API가 병렬로 실행되어 전체 시간이 단축됨
    

    2️⃣ 콜백 없이 동기식 코드처럼 작성 가능

      ```kotlin
      fun main(): Unit = runBlocking {
          val time = measureTimeMillis {
              val job1 = async { apiCall1() }
              val job2 = async { apiCall2(job1.await()) }
              printWithThread(job2.await())
          }
    
          printWithThread("소요 시간 : $time ms")
      }
    
      suspend fun apiCall1(): Int {
          delay(1_000L)
          return 1
      }
    
      suspend fun apiCall2(num: Int): Int {
          delay(1_000L)
          return num + 2
      }
      ```
      ```
      [main @coroutine#1] 3 
      [main @coroutine#1] 소요 시간 : 2024 ms
      ```
      - 비동기 처리지만, 코드 흐름은 동기처럼 읽힘
    
    async 주의사항

    1️⃣ CoroutineStart.LAZY 옵션을 사용하면, await() 함수를 호출했을 때 계산 결과를 계속 기다림

      ```kotlin
      fun main(): Unit = runBlocking {
          val time = measureTimeMillis {
              val job1 = async(start = CoroutineStart.LAZY) { apiCall1() }
              val job2 = async(start = CoroutineStart.LAZY) { apiCall2() }
              printWithThread(job1.await() + job2.await())
          }
    
          printWithThread("소요 시간 : $time ms")
      }
    
      suspend fun apiCall1(): Int {
          delay(1_000L)
          return 1
      }
    
      suspend fun apiCall2(): Int {
          delay(1_000L)
          return 2
      }
      ```
      ```
      [main @coroutine#1] 3 
      [main @coroutine#1] 소요 시간 : 2024 ms
      ```
      - `await()` 호출 시점에 코루틴이 **순차적으로 실행**
      - 결과적으로 동시 실행 효과가 사라짐
    

    2️⃣ 지연 코루틴을 async() 와 함께 사용하는 경우라도, 동시에 API 호출을 원한다면 start() 함수를 먼저 사용해야 함

      ```kotlin
      fun main(): Unit = runBlocking {
          val time = measureTimeMillis {
              val job1 = async(start = CoroutineStart.LAZY) { apiCall1() }
              val job2 = async(start = CoroutineStart.LAZY) { apiCall2() }
                
              job1.start()
              job2.start()
              printWithThread(job1.await() + job2.await())
          }
            
          printWithThread("소요 시간 : $time ms")
      }    
      ```
      [main @coroutine#1] 3 
      [main @coroutine#1] 소요 시간 : 1018 ms
      ```
      - `start()`를 명시적으로 호출해야 동시에 실행
    

💡4강. 코루틴의 취소

  • 더 이상 필요하지 않은 코루틴은 적절히 취소하여 컴퓨터 자원을 절약해야 함

🥨 취소 방법

  • cancel() 함수 사용 => 단, 취소 대상인 코루틴이 취소에 협조(cooperative)해야 함

👉 코루틴의 취소는 강제 종료가 아닌 ‘요청’ 이기 때문에, 코루틴이 취소 신호를 인지하고 스스로 멈춰야 한다.

🥨 취소에 협조하는 방법

1️⃣ delay() / yield() 같은 kotlinx.coroutines 패키지의 suspend 함수 사용

fun main(): Unit = runBlocking {
    val job1 = launch {
        delay(1_000L)
        printWithThread("Job 1")
    }

    val job2 = launch {
        delay(1_000L)
        printWithThread("Job 2")
    }

    delay(100) // 첫 번째 코루틴 코드가 시작되는 것을 잠시 기다림
    job1.cancel()
}
[main @coroutine#3] Job 2
  • suspend 함수는 실행 전에 취소 여부를 체크해서 취소에 협조하도록 되어 있음

    suspend 함수 사용하지 않을 경우 취소 안됨
      fun main(): Unit = runBlocking {
          val job = launch {
              var i = 1
              var nextPrintTime = System.currentTimeMillis()
              while (i <= 5) {
                  if(nextPrintTime <= System.currentTimeMillis()){
                      printWithThread("${i++}번재 출력!")
                      nextPrintTime += 1_000L
                  }
              }
          }
    
          delay(100L)
          job.cancel()
      }
    
      [main @coroutine#2] 1 번째 출력! 
      [main @coroutine#2] 2 번째 출력! 
      [main @coroutine#2] 3 번째 출력! 
      [main @coroutine#2] 4 번째 출력! 
      [main @coroutine#2] 5 번째 출력!
    
    • 협력하는 코루틴이여야만 취소 가능

2️⃣ 코루틴이 스스로 상태를 확인하고 취소 처리

  • 코루틴 내부에서 직접 취소 상태를 확인

  • 취소 요청을 받았다면 CancellationException을 던져 종료

      fun main(): Unit = runBlocking {
          val job = launch(Dispatchers.Default){
              var i = 1
              var nextPrintTime = System.currentTimeMillis()
              while (i <= 5) {
                  if(nextPrintTime <= System.currentTimeMillis()){
                      printWithThread("${i++}번재 출력!")
                      nextPrintTime += 1_000L
                  }
    
                  if(!isActive){
                      throw CancellationException()
                  }
              }
          }
    
          delay(100L)
          printWithThread("취소 시작")
          job.cancel()
            
      }
    
      [DefaultDispatcher-worker-1 @coroutine#2] 1 번째 출력!
      [main @coroutine#1] 취소 시작
    
    • isActive
      • 코루틴 빌더(launch, async) 내부에서 접근 가능한 프로퍼티
      • 현재 코루틴이 활성화 되어 있는지 / 취소 신호를 받았는지 구분
    • Dispatchers.Default
      • 취소 신호를 정상적으로 전달하려면, 우리가 만든 코루틴이 다른 스레드에서 동작해야 함
      • Dispatchers.Defaultlaunch() 함수에 전달하면 코루틴을 다른 스레드에서 동작시킬 수 있음

💡5강. 코루틴의 예외 처리와 Job의 상태 변화

🥨 중첩된 코루틴 간의 관계

  • 코루틴은 부모-자식 관계를 가질 수 있음

  • 가장 바깥에서 시작된 코루틴을 흔히 root 코루틴이라고 부름

      fun main(): Unit = runBlocking {
          val job1 = launch {
              delay(1_000L)
              printWithThread("Job 1")
          }
    
          val job2 = launch {
              delay(1_000L)
              printWithThread("Job 2")
          }
      }
    
    • runblocking : 부모 코루틴
    • job1, job2 : 자식 코루틴

🥨 새로운 root 코루틴 생성

  • 코루틴을 새로운 CoroutineScope에서 생성하면 기존 부모와 분리된 독립적인 root 코루틴이 됨

  • 이를 위해 CoroutineScope()를 사용

      fun lec05Example1(): Unit = runBlocking {
          val job1 = CoroutineScope(Dispatchers.Default).launch {
              delay(1_000L)
              printWithThread("Job 1")
          }
    
          val job2 = CoroutineScope(Dispatchers.Default).launch {
              delay(1_000L)
              printWithThread("Job 2")
          }
      }
    
    • job1, job2 : runBlocking과 부모-자식 관계가 아닌 독립적인 코루틴

🥨 예외 발생 처리

  • launch

    • 예외가 발생하면, 해당 예외를 출력하고 코루틴 종료
      fun main(): Unit = runBlocking {
          val job = CoroutineScope(Dispatchers.Default).launch {
              throw IllegalArgumentException()
          }
    
          delay(1_000L)
      }
    
      Exception in thread "DefaultDispatcher-worker-1 @coroutine#2"
      java.lang.IllegalArgu mentException
    
  • async

    • 예외가 발생해도, 예외를 출력하지 않음(예외 확인하려면, await() 사용)
    • asynclaunch 와 다르게 값을 반환하는 코루틴에 사용되기에,
      예외 역시 값을 반환할 때 처리할 수 있도록 설계된 것
      fun main(): Unit = runBlocking {
      val job = CoroutineScope(Dispatchers.Default).async {
          throw IllegalArgumentException()
      }
    
      delay(1_000L)
      job.await()
    }
    
      Exception in thread "main" java.lang.IllegalArgumentException
    

🥨 예외 전파(자식 -> 부모)

  • async 를 사용해도 예외가 발생하는 이유는?

    ⇒ 자식 코루틴의 예외가 부모 코루틴으로 전파되기 때문

      fun main(): Unit = runBlocking {
          val job = async {
              throw IllegalArgumentException()
          }
    
          delay(1_000L)
      }
    
      Exception in thread "main" java.lang.IllegalArgumentException
    
    자식 코루틴의 예외를 부모에게 전파하지 않는 방법

    SupervisorJob() 사용

      fun main(): Unit = runBlocking {
          val job = async(SupervisorJob()) {
              throw IllegalArgumentException()
          }
    
          delay(1_000L)
          // job.await()
      }
    
    • async 함수에 SupervisorJob()을 넣어주면 async 자식 코루틴에서 발생한 예외가 부모 코루틴으로 전파되지 않음

    • job.await() 사용 해야 예외가 발생하는 원래 행동 패턴으로 돌아감

🥨 예외를 다루는 방법

  • try-catch 사용

      fun main(): Unit = runBlocking {
          val job = launch() {
              try {
                  throw IllegalArgumentException()
              }catch (e: IllegalArgumentException){
                  printWithThread("정상 종료")
              }
          }
      }
    
      [main @coroutine#2] 정상 종료
    
    • try catch 사용하면, 발생한 예외를 잡아 코루틴이 취소되지 않게 만들 수도 있고,
      적절한 처리를 한 이후 다시 예외를 던질 수 있음
  • CoroutineExceptionHandler 사용

      fun main(): Unit = runBlocking {
          val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
              printWithThread("예외")
              throw  throwable
          }
    
          val job = CoroutineScope(Dispatchers.Default).launch(exceptionHandler) {
              throw IllegalArgumentException()
          }
    
          delay(1_000L)
      }
    
      [DefaultDispatcher-worker-1 @coroutine#2] 예외
    
    • CoroutineExceptionHandlerlaunch 에만 적용 가능
    • 부모 코루틴이 있으면 동작하지 않음

🥨 코루틴 취소 예외 정리

  • CASE 1. 발생한 예외가 CancellationException 인 경우

    ⇒ 취소로 간주하고 부모 코루틴에게 전파 X

  • CASE 2. 그 외 다른 예외 발생

    실패로 간주하고 부모 코루틴에게 전파 O

👉 다만 내부적으로는 취소 실패 모두 취소됨 상태로 관리


.

이번 글에서는 코루틴 생성부터 취소와 예외처리까지 코루틴을 실제로 사용하기 위한 기본 방법들을 정리해봤습니다.

이번 강의 구간부터는 코루틴이 추상적인 개념이 아니라 실제 코드에서 어떻게 쓰이는지 조금씩 감이 잡히기 시작하더라구요!

다음 글에서는 코루틴의 구성 요소와 원리를 다룬 6~7강을 정리해보겠습니다.

끝!🦕

.


👍 참고


© 2022. Haeeul All rights reserved.

🐾해을의 개발자국🐾

Powered by Hydejack v9.1.5