WEB BE Repository/JAVA & Kotlin

[코틀린 코루틴] 구조적 동시성

조금씩 차근차근 2026. 1. 7. 20:00

코루틴 컨텍스트

이전 글에서 Dispatcher를 설정할 때, 이부분에 대해 의구심이 생길 수 있다.

우린 분명 launch 의 인자로 Dispatcher를 넘겼는데, launch의 파라미터는 context를 받고 있다.
이 컨텍스트는 뭐고, 왜 Dispatcher를 넘겨도 동작할까?

 

사실 우리가 넘긴 디스패처는 기존 코루틴 컨텍스트에 덮어씌워지는 역할을 수행한다.

 

코루틴 컨텍스트에는 다양한 정보들이 들어있다.

  • 코루틴 디스패처
  • 코루틴 생명주기
  • Job 객체
  • 코루틴 객체 이름
  • 코루틴 ExceptionHandler

그리고, 다음과 같이 파라미터로 넘긴 Context로 덮어씌우는게 가능하다.


실제로 다음과 같이 코루틴 컨텍스트가 바뀐 것을 확인할 수 있다.

구조적 동시성

지금까지 코루틴 컨텍스트를 왜 알아봤냐 하면, 구조적 동시성을 이용한 코루틴 스코프에 대해 이해해야 하기 때문이다.

 

마치 객체 제거가 자식 객체까지 모두 제거하는 것처럼, 코루틴 객체 또한 그렇게 소유권 개념을 주고, 아무도 소유하지 않은 코루틴 객체는 모두 재귀적으로 소거시킨다.

 

정확히 말하자면, 코루틴의 Job 객체들 간에는 부모-자식 관계가 존재하고, 자신의 부모 Job 객체의 취소 명령을 자식 Job 객체에 전파시킨다.

coroutineScope - 코루틴 스코프 생성

코루틴 스코프의 전형적인 사용 사례는 작업 병렬 실행 후 병합(concurrent decomposition of work)이다.

coroutineScope 함수는 suspend 함수로, 새로운 코루틴 스코프를 생성하고 해당 영역 안의 모든 자식 코루틴이 완료될 때까지 기다린다.

 

runBlocking과 유사하다고 느낄 수 있는데, 다음과 같은 차이점이 있다.

  • coroutineScope는 부모 코루틴의 컨텍스트를 그대로 상속하는 형태로 동작한다.
    • runBlocking은 새로운 코루틴 컨텍스트를 연다.
  • coroutineScope는 부모 코루틴을 suspend 시키는 형태로 동작한다.
    • runBlocking은 현재 스레드의 동작을 일시정지시킨다.

이를 다이어그램으로 나타내면 다음과 같다.

CoroutineScope - 수동으로 스코프의 생명주기를 관리하고 싶을 때

이 생성자는 수동으로 스코프의 생명주기를 관리하고 싶을 때 사용하며, 주로 인프라/프레임워크 구현 레벨에서 사용하게 된다.

일반적으로는 runBlocking이나 고수준 구조(withContext, coroutineScope 등)를 통해 필요한 동안만 존재하는 스코프를 만드는 것이 좋다.

실무에서 CoroutineScope 사용 시에는 함께 SupervisorJob을 사용하는 것이 좋다.
SupervisorJob은 동일한 영역과 관련된 다른 코루틴을 취소하지 않고, 처리되지 않은 예외를 전파하지 않게 해주는 특수한 Job이다.


이와 같이 직접 코루틴 스코프를 생명주기대로 제어해야 할 때 사용하게 된다.

 

이 객체를 사용하는 코드는 다음과 같다.

fun main(){  
    val c = ComponentWithScope()  
    c.start()  
    Thread.sleep(2000)  
    c.stop()  
}

이 코드를 실행하면 다음과 같은 결과를 받을 수 있다.

코루틴 컨텍스트와 구조적 동시성

이제 구조적 동시성에 대한 간단한 시각자료와 함께 이해해보자.

  • 새로운 코루틴을 시작할 때 자식 코루틴은 부모의 컨텍스트를 상속받는다.
  • 그런 다음, 새로운 코루틴은 부모-자식 관계를 설정하는 역할을 하는 새 Job 객체를 생성한다.
  • 마지막으로 코루틴 컨텍스트에 전달된 인자가 적용된다. 이 인자들은 상속받은 값을 덮어쓸 수 있다.

 

취소 - 구조적 동시성을 이용한 코루틴 일괄 종료

코틀린 코루틴의 취소는 협력적(cooperative) 방식으로 동작한다.
즉, 코루틴은 실행 도중에 스스로 취소 요청을 체크해야만 실제 취소가 이루어진다.

구체적으로,

  1. 일시 중단 함수(suspending function)를 호출하여 일시 중단(suspension) 되는 순간에 코루틴은 취소 여부를 확인한다.
  2. 만약 이미 취소된 상태라면 즉시 CancellationException을 발생시키면서 자신을 종료한다.

따라서 delay()await()과 같은 대부분의 일시 중단 함수들은 내부적으로 취소 여부를 확인하도록 구현되어 있어(cancellable suspend functions), 이런 지점에서 코루틴이 안전하게 중단될 수 있다.

 

반면에 CPU 연산을 오래 수행하여 일시 중단 함수 호출 없이 긴 시간이 흐를 경우에는 코루틴이 취소 신호를 체크하지 못하므로 즉시 취소되지 않는다.
이러한 경우 개발자가 명시적으로 취소 여부를 확인하여 중간에 빠져나와야 한다.

 

이를 위해 isActive 속성을 확인하거나 yield()/ensureActive() 함수를 호출하여 취소 상태를 검사할 수 있다.

  • 예를 들어 긴 루프 내에서 주기적으로 if (!isActive) return 같은 코드를 넣어주면 루프 도중 취소 신호를 받았을 때 빠르게 종료될 수 있다.
  • 이는 하단의 취소의 동작 방식 챕터에서 좀 더 자세히 다룰 것이다.

아래 코드를 실행해보자.

import kotlinx.coroutines.*

private var zeroTime = System.currentTimeMillis()  
fun log(message: Any?) = println("[${System.currentTimeMillis() - zeroTime} ms] ${Thread.currentThread().name}: $message")

fun main() = runBlocking {  
    val job = launch {  
        try {  
            for (i in 1..10) {  
                log("작업 중... $i")  
                delay(100)  // 일시 중단 지점에서 취소를 체크  
            }  
        } finally {  
            // 취소 신호를 받으면 이 블록이 실행됨  
            log("정리 작업 수행 (코루틴 취소됨)")  
        }  
    }  

    delay(350)       // 일정 시간 후 작업 취소  
    log("코루틴 취소 요청")  
    job.cancel()     // 코루틴 취소 시도  
    job.join()       // 코루틴이 완전히 종료될 때까지 대기  
    log("메인 종료")  
}

위 코드에서는 launch로 시작한 코루틴이 0.1초 간격으로 "작업 중..."을 출력하며 반복한다.

 

delay(100) 호출은 취소를 확인하는 일시 중단 지점이므로, 메인에서 약 0.35초 후 job.cancel()을 호출하면 코루틴은 다음 delay 지연 시점에 취소를 감지하고 CancellationException을 발생시킨다.

 

이로 인해 루프가 중단되고 finally 블록 안의 "정리 작업 수행 (코루틴 취소됨)"이 출력된 뒤 코루틴이 종료된다.

위 출력에서 1, 2, 3까지만 출력된 후 취소가 이루어지고 루프가 종료된 것을 볼 수 있다.
job.cancel() 이후 job.join()으로 코루틴의 종료를 기다렸기 때문에, 메인 종료가 가장 마지막에 출력된다.

취소의 동작 방식 - CancellationException과 협력적 취소

취소 매커니즘은 CancellationException라는 특수한 예외를 특별한 지점에서 던지는 방식으로 동작한다.

우선적으로는 일시 중단 지점이 해당 예외를 던질 수 있는 지점이다.

coroutineScope {
    log("A")
    delay(500.milliseconds) //이 지점에서 예외를 던질 수 있음
    log("B")
    log("C")
}

코루틴은 해당 지점에서 "자신이 취소되어야 하는지"를 확인한 후, 자신을 취소시킨다.

 

즉, 다른 코루틴이 특정 코루틴(자식 코루틴)을 강제종료 시키는 것이 아닌, "우아한 종료"를 위해 자식 코루틴이 직접 종료하도록 만든다.

따라서, 일시 중단 함수는 스스로 취소 가능하게 로직을 제공해야 한다. (ex: I/O 이전/이후)
그렇지 않으면, 코루틴은 항상 자신의 기능을 모두 완수해야만 종료할 것이다.

 

하지만, 종료를 위해 매번 delay를 호출하는 것을 원하는 개발자는 없을 것이다.

그래서 코루틴은 다음 네 가지 매커니즘을 지원한다.

  • isActive
    • 현재 코루틴이 활성화되어도 되는 상태인지를 알리는 boolean 값.
  • ensureActive()
    • isActive == false이면 CancellationException을 던져 즉시 작업을 중단한다.
  • yield()
    • CPU 집약적인 작업이 기저 스레드(또는 스레드 풀)를 소모하는 것을 방지하기 위해, 의도적으로 계산 자원을 양도한다.
    • 즉, 자신을 suspend 시킨다. (일시 중단 함수이다.)
  • withTimeOut계열
    • 일정 시간이 지난 뒤에 해당 작업을 취소시킨다.

 

이를 이용해 특정 코루틴이 취소되었을 때, 불필요한 코루틴이 계속 남아 리소스를 낭비하지 않도록 정리할 수 있다.