Coding

인프런 2시간만에 끝내는 코루틴 1강 ~ 7강 정리

Hide­ 2024. 6. 12. 19:20
반응형

1장. 루틴과 코루틴의 차이

  • runBlocking: 일반 루틴과 코루틴을 연결하는 함수이다. runBlocking을 통해 새로운 코루틴을 만들 수 있다.
  • launch: 반환값이 없는 코루틴을 만든다.
  • suspend fun: 다른 suspend fun을 호출할 수 있다.
  • yield: 현재 코루틴을 중단하고 다른 코루틴이 실행될 수 있도록 스레드를 양보한다. (제어권을 넘겨준다)
fun main(): Unit = runBlocking {
    println("START")  # 1
    launch {          # 2
        newRoutine()
    }
    yield()           # 3
    println("END")    # 6
}

suspend fun newRoutine() {
    val num1 = 1      # 4
    val num2 = 2      # 4
    yield()           # 5
    println("${num1 + num2}")  # 7
}

 

위 코드의 흐름은 다음과 같다.

  1. 메인 함수가 시작되면 runBlocking에 의해 코루틴이 생성된다. 
  2. START가 출력된다.
  3. launch를 통해 새로운 코루틴을 생성한다. 하지만 launch는 새로운 코루틴을 바로 실행시키지 않는다. 따라서 아래 yield() 라인으로 넘어간다.
  4. yield를 통해 스레드의 제어권을 넘겨줬기 때문에 main 함수에서의 코드 실행이 중단되고 newRoutine 함수가 실행된다.
  5. newRoutine에서 num1, num2 지역변수가 초기화된다.
  6. yield를 통해 다시 한번 스레드에 제어권을 넘겨주고 main함수에서 END가 출력된다.
  7. 메인 함수가 종료되고 최종적으로 newRoutine의 num1 + num2가 출력되고 프로그램이 종료된다.

이를 메모리 관점에서 살펴보자. 일반 루틴의 경우 함수가 한번 불리고 종료되면 끝이기 때문에 해당 함수에서 사용했던 데이터들을 보관하지 않아도 된다. 하지만 코루틴의 경우 새로운 루틴이 호출된 후 완전히 종료된게 아니라면 중단된 경우 해당 루틴에서 사용했던 정보들을 보관하고 있어야 한다. 

즉 루틴이 중단되었다가 해당 메모리에 다시 접근이 가능하다는 점이 포인트이다. 

2장. 스레드와 코루틴

코루틴은 코루틴이 직접 코드를 실행시키는게 아니라 코루틴이 가지고 있는 코드를 스레드에 넘겨서 실행하게 된다. 그렇기 때문에 코루틴의 코드가 실행되려면 스레드가 있어야만 한다. 하지만 특정 스레드에만 종속되는것은 아닌데, 예를 들어 코루틴이 중단되었다가 재개될 때 처음 실행된 스레드와는 다른 스레드에 배정될수도 있기 때문이다. 

코루틴과 스레드는 컨텍스트 스위칭에서도 차이를 가진다. 스레드끼리는 같은 프로세스에 소속되어 있기 때문에 힙 영역을 공유할 수 있다. 그리고 스레드마다 독립적인 스택 영역을 가진다. 예를 들어 스레드1에 있는 코드1이 실행되다가 스레드2에 있는 코드2가 실행되는 경우 힙 영역은 그대로 남겨둔 채 스택 영역은 스레드2의 스택으로 교체하게 된다. 한 마디로 힙 영역을 공유하고 스택 영역만 교체되므로 프로세스보다 컨텍스트 스위칭 비용이 적다.

코루틴은 중단 지점이 있으면 다른 코루틴이 실행될 수 있다. 이 말인즉슨 한 스레드에서 여러개의 코루틴이 실행될 수 있다는 뜻이다. 예를 들어 코루틴1이 스레드1에 배정되어 실행되고 있다고 해보자. 그리고 스레드1은 힙 영역과 스택 영역을 가지고 있다. 이후 코루틴1이 중단되면 코루틴2가 스레드1에 배정되고 여전히 동일한 힙 영역과 스택 영역을 사용한다. 그렇기에 동일 스레드에서 코루틴이 실행되면 메모리 전부를 공유하므로 스레드보다 컨텍스트 스위칭 비용이 낮다. (코루틴은 한 스레드에서 실행되는 경우 컨텍스트 스위칭 발생 시 메모리 교체가 없다)

코루틴은 스레드와는 다르게 yield를 통해 스스로 자리를 양보할 수 있다. 반면 스레드끼리 자리가 바뀔때는 OS가 개입해서 특정 스레드를 멈추고 다른 스레드를 돌게한다. 이처럼 스스로 자리를 양보하는것을 비선점형이라고 한다. 반면 스레드처럼 어떤 다른 존재에 자리가 비켜지는것을 선점형이라고 한다.

3장. 코루틴 빌더와 Job

runBlocking

runBlocking은 코루틴 빌더로써 새로운 코루틴을 만들고 일반 함수와 코루틴 함수를 이어주는 역할을 한다. 주의할점은 이름에 Blocking이 들어가있다는 점이다. runBlocking으로 인해 만들어진 코루틴과 runBlocking 안에서 추가적으로 만든 코루틴이 모두 완료될 때 까지 스레드를 Blocking한다. 따라서 해당 스레드는 runBlocking으로 인해 Blocking된게 풀릴 때 까지 다른 코드를 실행할 수 없다.

fun main() {
    runBlocking {
        printWithThread("START")
        launch {
            delay(2_000L)
            printWithThread("LAUNCH END")
        }
    }
    printWithThread("END")
}

fun printWithThread(str: Any) {
    println("Thread: ${Thread.currentThread().name}, $str")
}

delay는 자기자신을 특정 시간만큼 멈추고 다른 코루틴으로 제어권을 넘긴다. 예를 들어 위 코드에서 launch에서 delay를 통해 다른 코루틴으로 제어권을 넘기더라도 runBlocking의 특성인 본인과 본인 내부에 있는 코루틴이 완전히 끝날 때 까지 스레드 전체를 블락킹하기 때문에 가장 아래에 있는 END가 출력되지 않는다. 따라서 출력 결과는 다음과 같다.

Thread: main @coroutine#1, START
Thread: main @coroutine#2, LAUNCH END
Thread: main, END

따라서 runBlocking은 코루틴을 만들고 싶을때마다 사용하는게 아니라 프로그램에 최초에 진입할 때 작성해주거나 테스트 코드를 시작할 때 특정 테스트 코드에서만 사용하는편이 좋다.

fun main(): Unit = runBlocking {
    val job = launch(start = CoroutineStart.LAZY) {
        printWithThread("Hello launch")
    }
    
    job.start()
}

위 코드를 보면 launch의 start인자로 LAZY를 주었다. 이렇게 하면 즉시 코루틴이 실행되는게 아닌 start()라는 메소드를 통해 원할 때 코루틴을 실행시킬 수 있다. 또한 cancel()을 통해 취소를, join()을 통해 특정 코루틴이 끝날 때 까지 대기시킬수도 있다.

async

fun main(): Unit = runBlocking {
    val job = async {
        3 + 5
    }
}

async는 launch와 거의 유사하지만 한가지 다른점이 있는데 위처럼 주어진 함수의 실행 결과를 반환할 수 있다는 점이다. 물론 결과를 즉시 반환하는게 아닌 Deferred를 반환한다. Deferred는 Job을 상속받고 있는 객체이기에 동일한 기능들이 있다. 그리고 async에만 존재하는 특별한 기능인 await()도 있다.

fun main(): Unit = runBlocking {
    val job = async {
        3 + 5
    }
    val result = job.await()
}

await은 async의 결과를 가져오는 함수이다. 따라서 result를 출력해보면 8이 나오게 된다. 이렇게 async를 활용하면 여러 API를 동시에 호출하여 소요시간을 최소화시킬 수 있다.

fun main(): Unit = runBlocking {
    val job1 = async { apiCall1() }
    val job2 = async { apiCall2() }
    printWithThread("Result: ${job1.await() + job2.await()}")
}

suspend fun apiCall1(): Int {
    delay(1_000L)
    return 1
}

suspend fun apiCall2(): Int {
    delay(1_000L)
    return 2
}

async의 최대 장점은 콜백을 사용하지않고 동기 방식으로 코드를 작성할 수 있게 해주는 것이다. 주의사항으로는 CoroutineStart.LAZY 옵션을 사용하면 await() 함수를 호출했을 때 계산 결과를 계속 기다린다는 점이다. (물론 LAZY옵션을 사용하더라도 start() 함수를 한번 호출하면 상관없다)

4강. 코루틴의 취소

취소에 협조하는 방법 1

코루틴의 취소는 굉장히 중요하다. 여러개의 코루틴을 사용할 때 필요없어진 코루틴을 적절히 취소해 컴퓨터 자원을 아껴야하기 때문이다. 위에서 말한 cancel()을 사용하면 코루틴을 취소할 수 있지만 사실 취소 대상인 코루틴도 취소에 협조해줘야 한다. 

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()
}

delay는 내부적으로 suspend fun으로 선언되어있다. 코루틴이 취소에 협조려면 delay나 yield같은 kotlinx.coroutines 패키지의 suspend 함수를 사용해야 한다. suspend함수를 사용하는 코루틴은 delay, yield를 호출할 때 자동으로 취소 여부를 확인한다. 

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(100)
    job.cancel()
}

// 결과
Thread: main @coroutine#2, 1번째 출력
Thread: main @coroutine#2, 2번째 출력
Thread: main @coroutine#2, 3번째 출력
Thread: main @coroutine#2, 4번째 출력
Thread: main @coroutine#2, 5번째 출력

위 코드를 실행시켜보면 cancel()을 통해 코루틴을 취소했음에도 불구하고 1번째 출력 ~ 5번째 출력까지 프린트된 모습을 확인할 수 있다. 여기서 한가지 사실을 알 수 있는데 바로 협력하는 코루틴이어야만 취소가 된다는 점이다. 코드의 흐름을 보면 다음과 같다.

  1. runBlocking에서 delay를 통해 0.1초 딜레이를 걸고 cancel을 통해 취소시키려고 한다.
  2. 하지만 launch를 통해 실행된 코루틴은 코루틴 내부에 suspend 함수를 사용하지 않고 있기 때문에 1부터 5까지 출력하게 된다. 
  3. 스레드 입장에서는 delay 0.1초를 실행시키고 runBlocking이 0.1초를 기다리는 동안 launch에 있는 코드를 가져와 실행하게 된다.
  4. launch의 코드가 모두 끝나면 cancel이 실행된다.
  5. 따라서 launch의 코드는 진작에 완료된 상태이다.

취소에 협조하는 방법 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(100)
    job.cancel()
}

// 결과
Thread: DefaultDispatcher-worker-1 @coroutine#2, 1번째 출력

launch안에 Dispatchers.Default를 넣으면 우리가 launch에 의해 만든 코루틴이 메인 스레드와는 별도의 스레드에서 동작하게 된다. 위 코드를 보면 while문을 통해 하나씩 출력하던 도중 job.cancel에 의해 코루틴이 취소되었고, isActive가 false로 바뀌었다. 따라서 별도의 스레드로 동작하던 launch 내부 코드가 중단되게 된다.

  • isActive: 현재 코루틴이 활성화되어있는지, 취소 신호를 받았는지 확인할 때 사용
  • Dispatchers.Default: 코루틴을 별도의 스레드에 배정할 때 사용

사실 delay, yield와 같은 suspend 함수들도 CancellationException을 던지면서 취소가 된다. 따라서 만약 try-catch를 써서 CancellationException을 잡아버리면 코틀린이 취소되지 않는다. 

fun main(): Unit = runBlocking {
    val job = launch {
        try {
            delay(1_000L)
        } catch (e: CancellationException) {
            // Do nothing
        }
        printWithThread("delay에 의해 취소되지 않았다.")
    }

    delay(100)
    printWithThread("취소 시작")
    job.cancel()
}

// 결과
Thread: main @coroutine#1, 취소 시작
Thread: main @coroutine#2, delay에 의해 취소되지 않았다.

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

예외 처리 방법에 앞서 Root Coroutine을 만드는 방법부터 알아보자. 

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()
}

위 코드를 보면 총 3개의 코루틴이 존재한다. runBlocking으로 만들어진 코루틴이 부모(루트) 코루틴이고 그 안에서 launch로 만들어진 2개의 코루틴이 자식 코루틴이다. 이 때 새로운 루트 코루틴을 만들고 싶은 경우 새로운 CoroutineScope를 만들어야 한다.

fun main(): 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")
    }
    delay(100)
    job1.cancel()
}

job1의 launch가 CoroutineScope().launch()로 변경되었다. 이를 해석해보면 새로운 영역에서 코루틴을 생성하고 Dispatchers.Default로 인해 메인 스레드가 아닌 다른 스레드에서 코루틴을 실행시킨다는 의미이다. 따라서 위 코드는 각각이 모두 루트 코루틴이 된다. 

launch / async 예외 발생 차이

fun main(): Unit = runBlocking {
    val job = CoroutineScope(Dispatchers.Default).launch {
        throw IllegalArgumentException()
    }
    delay(1_000L)
}

// 결과
Exception in thread "DefaultDispatcher-worker-1" java.lang.IllegalArgumentException

launch는 예외가 발생하면 예외를 출력하며 코루틴이 종료된다. 반면 async는 조금 다르다.

fun main(): Unit = runBlocking {
    val job = CoroutineScope(Dispatchers.Default).async {
        throw IllegalArgumentException()
    }
    delay(1_000L)
}

위 코드를 실행시키면 예외가 발생하지 않는다. async는 launch와는 다르게 예외를 즉시 출력해서 종료되지 않기 때문이다. 만약 async에서 발생한 예외를 확인하고 싶다면 job을 await()해줘야 한다. 정리하자면 다음과 같다.

  • launch: 예외가 발생하면 예외를 출력하고 코루틴이 종료
  • async: 예외가 발생해도 예외를 출력하지 않음. 예외를 확인하려면 await()가 필요함
fun main(): Unit = runBlocking {
    val job = async {
        throw IllegalArgumentException()
    }
    delay(1_000L)
}

// 결과
Exception in thread "main" java.lang.IllegalArgumentException

이번에는 전과 다르게 CoroutineScope를 사용하지 않았기 때문에 runBlocking의 자식 코루틴으로 수행된다. 위 코드를 실행해보면 await()를 하지 않았음에도 불구하고 예외가 출력되며 종료된다. 

이러한 차이가 발생하는 이유는 코루틴에서 발생한 예외는 부모 코루틴으로 전파되기 때문이다. async/launch 모두 자식 코루틴에서 예외가 발생했다면 부모 코루틴에게 전파된다. 

만약 자식 코루틴의 예외를 부모에게 전파하지 않고 싶다면 루트 코루틴을 만드는 것 말고 또 다른 방법이 있을까? 바로 SupervisorJob() 이란것을 사용하면 된다.

fun main(): Unit = runBlocking {
    val job = async(SupervisorJob()) {
        throw IllegalArgumentException()
    }
    delay(1_000L)
}

이렇게 async에 SupervisorJob()을 넣어주게 되면 자식 코루틴의 예외를 부모에게 전파하지 않을 수 있다. 물론 여기서도 job.await()를 하면 예외를 발생시킬 수 있다.

예외를 다루는 방법 1 - try - catch - finally

fun main(): Unit = runBlocking {
    val job = launch {
        try {
            throw IllegalArgumentException()
        } catch (e: IllegalArgumentException) {
            printWithThread("정상 종료")
        }
    }
}

// 결과
Thread: main, 정상 종료

 

try ~ catch를 통해 예외를 잡고 밖으로 throw하지 않으면 예외가 발생하지 않는다. 

예외를 다루는 방법 2 - CoroutineExceptionHandler

만약 try-catch 대신 에러를 로깅하거나 다른곳에 전파하는 등 공통된 로직을 처리하고 싶다면 CoroutineExceptionHandler 객체를 사용할 수 있다. 이 객체는 try-catch-finally와 달리 예외 발생 이후 에러 로깅/에러 메시지 전송 등에 활용된다.

val exceptionHandler = CoroutineExceptionHandler { context, throwable ->
    printWithThread("CoroutineExceptionHandler: $throwable")
}

context는 코루틴의 구성요소이고 throwable이 발생한 예외가 된다. 

fun main(): Unit = runBlocking {

    val exceptionHandler = CoroutineExceptionHandler { _, _ ->
        printWithThread("예외")
    }

    val job = CoroutineScope(Dispatchers.Default).launch(exceptionHandler) {
        throw IllegalArgumentException()
    }

    delay(1_000L)
}

// 결과
Thread: DefaultDispatcher-worker-1, 예외

launch에 생성해준 exceptionHandler를 등록해줬다. 위 코드를 실행하면 에러를 로깅하고 종료되는걸 확인할 수 있다. 주의할점으론 launch에만 사용이 가능하고 부모 코루틴이 있으면 동작하지 않는다는 점이다. 그렇기 때문에 위 코드에서 Dispatchers.Default 옵션을 준 모습을 확인할 수 있다.

CancellationException vs 일반 예외

취소는 내부적으로 CancellationException을 던진다. 이 예외는 일반적인 예외랑 어떻게 다를까? 

  1. 발생한 예외가 CancellationException인 경우 취소로 간주하고 부모 코루틴에게 전파하지 않는다.
  2. CancellationException이 아닌 다른 예외가 발생한 경우 실패로 간주하고 부모 코루틴에게 전파한다.
  3. 다만 내부적으로는 "취소""실패" 모두 "취소됨" 상태로 관리한다.

코루틴의 라이프 싸이클은 다음과 같다.

  1. 코루틴이 생성된다.
    1. Lazy로 만든 경우 NEW에 멈춰있다가 Start했을 때 ACTIVE가 된다.
    2. Lazy가 아닌 경우 바로 ACTIVE가 된다.
  2. 이후 예외(CancellationException/일반예외 모두 포함)가 발생하면 취소중(CANCELLING) 상태로 들어간다.
    1. 이후 취소됨(CANCELLED) 상태가 된다.
  3. 예외가 발생하지 않았다면 COMPLETING 상태로 넘어간다.
  4. 이후 COMPLETED로 넘어간다.

Structured Concurrency

코루틴 라이프 싸이클에서 작업이 완료되었을 때 COMPLETING과 COMPLETED로 나눠진 이유는 자식 코루틴을 기다려야하기 때문이다. 부모 코루틴 입장에서는 자식 코루틴이 여러개 있을 수 있고 만약 첫 번째 자식 코루틴이 끝났더라도 두 번째 자식 코루틴에서 예외가 발생되면 내가 더 이상 실행할 코드는 없어도 자식 코루틴을 기다렸다가 취소 상태로 돌아가야하기 때문이다.

fun main(): Unit = runBlocking {
    launch {
        delay(500L)
        printWithThread("A")
    }

    launch {
        delay(600L)
        throw IllegalArgumentException("코루틴 실패")
    }
}

// 결과
Thread: main, A
Exception in thread "main" java.lang.IllegalArgumentException: 코루틴 실패

위 코드를 실행하면 첫 번째 자식 코루틴은 0.5초만에 끝나서 A를 출력하지만 두 번째 자식 코루틴은 0.6초 후에 에러를 던진다. 따라서 부모 코루틴도 실패하게 된다. 첫 번째 자식 코루틴은 정상적으로 끝났지만 두 번째 자식 코루틴에서 예외가 발생했기 때문에 이 예외가 부모 코루틴에게 전파되고 부모 코루틴은 COMPLETING 상태에서 대기하고 있다가 취소 상태로 변경되기 때문이다.

fun main(): Unit = runBlocking {
    launch {
        delay(600L)
        printWithThread("A")
    }

    launch {
        delay(500L)
        throw IllegalArgumentException("코루틴 실패")
    }
}

// 결과
Exception in thread "main" java.lang.IllegalArgumentException: 코루틴 실패

이번엔 반대로 0.6초, 0.5초로 수정해보자. 이렇게 되면 마치 0.5초 후에 두 번째 자식 코루틴에서 예외가 발생하면서 부모 코루틴은 취소되고 0.6초후에 A를 출력하는 첫 번째 코루틴은 정상 동작할 것 같지만 실제로는 예외만 발생하고 A 자체가 출력되지 않는다. 

자식 코루틴을 기다리다 예외가 발생하면 예외가 부모로 전파되고 다른 자식 코루틴에게 취소 요청을 보낸다고 했다. 그런데 부모에게 예외가 전파된 순간 부모 입장에서는 본인이 취소가 되어야 하기 때문에 다른 자식 코루틴에게도 취소 요청을 보내게 된다. 좀 더 자세히 풀어보면,

  1. 첫 번째 자식 코루틴에서 예외가 발생하면 부모 코루틴에게 예외가 전파된다.
  2. 부모 코루틴은 취소 상태로 접어들면서 다른 자식 코루틴들에게도 취소 요청을 보낸다.

이처럼 부모 - 자식 관계의 코루틴이 마치 한 몸처럼 움직이는 것을 Structured Concurrency라고 한다. Structured Concurrency는 수많은 코루틴이 유실되거나 누수되지 않도록 보장한다. 또한 코드 내의 에러가 유실되지 않고 적절히 보고될 수 있도록 보장한다.

정리

  • 자식 코루틴에서 예외가 발생한 경우 Structured Concurrency에 의해 부모 코루틴이 취소되고 부모 코루틴의 다른 자식 코루틴들도 취소된다.
  • 자식 코루틴에서 예외가 발생하지 않더라도 부모 코루틴이 취소되면 자식 코루틴들도 취소된다.
    • 단 CancellationException은 정상적인 취소로 간주하기 때문에 부모 코루틴에게 전파되지 않고 부모 코루틴의 다른 자식 코루틴을 취소시키지도 않는다.

7강. CoroutineScope와 CoroutineContext

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {

이전에 사용했던 launch, async는 CoroutineScope의 확장함수이다. 따라서 CoroutineScope 없이는 사용할 수 없다. 위에서 사용한 runBlocking이 코루틴과 루틴의 세계를 이어주며 CoroutineScope을 제공해준 것이다. 

바꿔말하면, 루트 코루틴을 만들었던 것 처럼 우리가 직접 CoroutineScope를 만든다면 runBlocking이 필요하지 않다. 

fun main(): Unit = runBlocking {
    CoroutineScope(Dispatchers.Default).launch {
        delay(1_000L)
        printWithThread("Hello")
    }

    Thread.sleep(1_500L)
}

// 결과
Thread: DefaultDispatcher-worker-1, Hello

예를 들어 위처럼 runBlocking 없이도 launch를 사용할 수 있다. 이 때는 스레드를 넉넉하게 멈춰줘야 하는데 runBlocking을 사용했을때는 runBlocking이 다른 코루틴이 끝날 때 까지 스레드를 블락킹 해주었지만 위와 같은 코드에서는 스레드를 블락킹하는 기능이 없기 때문이다. 따라서 Thread.sleep이 없다면 Hello가 출력되지않고 즉시 종료하게 된다.

suspend fun main(): Unit = runBlocking {
    val job = CoroutineScope(Dispatchers.Default).launch {
        delay(1_000L)
        printWithThread("Hello")
    }

    job.join()
}

// 결과
Thread: DefaultDispatcher-worker-1, Hello

아니면 위처럼 main 자체를 suspend 함수로 만들고 join을 통해 코루틴이 끝날 때 까지 기다리게 해줄수도 있다. 

CoroutineScope의 주요 역할

주요 역할은 CoroutineContext라는 데이터를 보관하는 것이다.

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

실제 CoroutineScope는 위처럼 굉장히 간단한 인터페이스로 이루어져 있다. CoroutineContext는 코루틴과 관련된 여러가지 데이터를 가지고 있다. 예를 들어 코루틴의 이름이나 이전 예외 처리에서 살펴봤던 CoroutineExceptionHandler가 있을수도 있고 코루틴 그 자체가 들어있을수도 있으며 CoroutineScope을 만들 때 넣어주었던 CoroutineDispatcher도 있을 수 있다. (CoroutineDispatcher는 코루틴이 어떤 스레드에 배정될지를 관리하는 역할을 한다) 정리하자면,

  • CoroutineScope: 코루틴이 탄생할 수 있는 영역으로써 CoroutineContext를 가진다.
  • CoroutineContext: 코루틴과 관련된 데이터를 보관하고 있다.

코루틴의 Structed Concurrency 기반

  • CoroutineScope 영역 내부에 부모 코루틴이 존재한다.
  • CoroutineScope 영역 내부에 부모 코루틴에 대한 CoroutineContext가 존재한다. 여기에는 부모 코루틴 그 자체가 들어있기도 하고 이름, 실행되고 있는 Dispatcher가 들어있기도 하다.
  • 이 때 부모 코루틴에서 자식 코루틴을 만들면 부모 코루틴과 동일한 CoroutineScope 영역 안에 자식 코루틴이 생성된다.
  • CoroutineScope를 통해 부모 코루틴의 CoroutineContext를 가져오고 필요한 내용을 복사하여 자식 코루틴만의 새로운 CoroutineContext를 생성한다.

클래스 내부에서 독립적인 CoroutineScope을 관리

동일한 영역에 있는 코루틴들은 영역 자체를 취소시킴으로써 모든 코루틴을 종료할 수 있다. 예를 들어 클래스 내부에서 독립적인 Scope을 가지고 있다면 해당 클래스에서 사용하던 코루틴을 한번에 종료시킬 수 있다.

class AsyncLogic {
    private val scope = CoroutineScope(Dispatchers.Default)

    fun doSomething() {
        scope.launch { 
            // 코루틴 작업
        }
    }

    fun destroy() {
        scope.cancel()
    }
}

예를 들어 doSomething()을 통해 특정한 작업을 실행시키다가 해당 작업이 필요없어진다면 destroy() 메소드에 있는것 처럼 CoroutineScope 영역 자체를 취소시킴으로써 해당 영역에서 실행되고있던 모든 코루틴에게 취소 신호를 보낼 수 있다.

CoroutineContext

CoroutineContext는 Map과 Set을 합친 자료구조와 같다. CoroutineContext에 저장된 데이터는 기본적으로 Key/Value로 이루어지고 Set처럼 같은 Key의 데이터는 유일하게 존재한다. 이 때 Key - Value를 Element라고 부르는데 실제 CoroutineContext를 살펴보면

public interface CoroutineContext {
    /**
     * Returns the element with the given [key] from this context or `null`.
     */
    public operator fun <E : Element> get(key: Key<E>): E?

마치 맵과 비슷한 인터페이스로 엘리먼트를 특정 키로 가져오는 기능을 확인할 수 있다.

fun main() {
    val context = CoroutineName("나만의 코루틴") + Dispatchers.Default
}

또한 덧셈 기호를 통해 여러 엘리먼트들을 합칠수도 있다.

val job = CoroutineScope(Dispatchers.Default).launch {
    delay(1_000L)
    printWithThread("Hello")
    coroutineContext + CoroutineName("이름")
    coroutineContext.minusKey(CoroutineName.Key)
}

위처럼 코루틴 블록 내부에서 coroutineContext 필드를 통해 컨텍스트에 직접 접근할수도 있다. 이를 통해 추가적인 엘리먼트를 더할수도 있고 컨텍스트에 존재하는 데이터를 제거할수도 있다. 

CoroutineDispatcher

코루틴에 존재하는 코드는 특정 스레드에 배정되어 실행된다고 했다. 그리고 이 때 중단지점이 존재한다면 하나의 스레드에 여러개의 코루틴이 실행될수도 있다. 이처럼 코루틴을 스레드에 배정하는 역할을 하는것이 CoroutineDispatcher이다. 

  • Dispatchers.Default: 가장 기본적인 디스패처로써 CPU자원을 많이 쓸 때 권장한다. 별다른 설정이 없으면 이 디스패처가 사용된다.
  • Dispatchers.IO: I/O 작업에 최적화된 디스패처
  • Dispatchers.Main: UI 컴포넌트를 조작하기 위한 디스패처로써 특정 의존성을 갖고 있어야 정상적으로 활용할 수 있다.

ExecutorService를 디스패처로 바꿔 사용할수도 있다. 자바에 존재하는 ExecutorService를 디스패처로 바꿀 수 있다. 이 때 ExecutorService를 사용하여 스레드 풀을 만들고 asCoroutineDispatcher() 확장함수를 사용하면 디스패처로 바뀐다. 

fun main() {
    CoroutineName("코루틴") + Dispatchers.Default
    val threadPool = Executors.newSingleThreadExecutor()
    CoroutineScope(threadPool.asCoroutineDispatcher()).launch {
        
    }
}

위 코드를 보면 Executors.newSingleThreadExecutor()를 통해 하나의 스레드를 가진 Executor를 만들었다. 그리고 CoroutineScope를 통해 새로운 영역을 만들 때 threadPool.asCoroutineDispatcher()라는 확장함수를 통해 해당 스레드 풀을 CoroutineDispatcher로 바꿔줬다. 최종적으로 launch를 통해 생성한 코루틴을 실행하면 해당 코루틴은 위에서 생성한 스레드풀에 배정되어 실행된다. 이 방법은 활용하면 손쉽게 스레드풀을 만들어 여러 코루틴을 해당 스레드풀에서 돌릴 수 있게 된다.

Reference

인프런 2시간만에 끝내는 코루틴