ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 코루틴을 이해해보자! (기초 3편)
    Coroutines & Flow 2025. 7. 13. 22:37

     

    안녕하세요~ 글의 도입부에는 내용의 딱딱함을 줄이고자(?) 다른 이야기로 시작하려고 해요. 저는 재수를 하고 정시로 대학에 왔어요. 1지망엔 통계학과, 2지망엔 생명공학과, 3지망에 정보융합학부(AI 관련 소프트 학부)를 넣었는데요. 1지망에서 예비 2번으로 떨어지고 2지망, 3지망 학교를 붙었는데 고등학교때 생명과학을 좋아했었지만, 뭔가 아예 새로운걸 배우고 싶은 마음도 있었어요. "컴퓨터? IT? 와 멋있고 신기하다!" 라는 단순한 호기심에 고민하다가 결국 3지망 대학에 최종 원서를 지원했었어요. 근데 4학년 중반쯤 돼서 돌아보니 "만약에 연극영화과에 갔으면 내 일상이 어떻게 달라졌을까?" 라는 생각이 문득 들어요. 아무래도 사람의 감정을 분석하고 표현하는 것 자체가 공부다보니 궁금하더라구요. 연극 동아리나 뮤지컬 동아리라도 한번 해볼껄 이라는 아쉬움이 남아있네요😅 오늘은 3편으로 코루틴 스코프, Job 객체, 경량성에 대해 알아보고자 합니다!

     

    1. 코루틴 빌더(launch) 는 코루틴 스코프 내에서 호출되어야 한다.

    import kotlinx.coroutines.*
    
    suspend fun doOneTwoThree() {
        launch {
            println("launch1: ${Thread.currentThread().name}") // (2) main@coroutine#2
            delay(1000L) // 1.0s (suspension point)
            println("4!") // (7)
        }
        launch {
            println("launch2: ${Thread.currentThread().name}") // (3) main@coroutine#3
            println("2!") // (4) 
        }
        launch {
            println("launch3: ${Thread.currentThread().name}") // (5) main@coroutine#4
            delay(500L) // 0.5s (suspension point) 
            println("3!") // (6)
        }
        println("1!") // (1)
    }
    
    fun main() = runBlocking {
        doOneTwoThree() // suspension point
        println("runBlocking: ${Thread.currentThread().name}") // (8) main@coroutine#1
        println("5!") // (9)
    }

     

    먼저 해당 코드를 실행할 때 뜨는 에러 코드를 살펴보겠습니다!

    Unresolved reference 'launch'

     

    코루틴 빌더인 launch 는 내부적으로 CoroutineScope.launch { .. } 로 정의되어 있습니다. 즉, 코루틴 스코프 내에서 호출해야 합니다. 하지만 suspend fun, delay 와 같은 지연 함수는 CoroutineScope 를 제공하지 않기에 launch 를 내부에 직접적으로 호출할 수 없습니다. launch 가 코루틴 스코프를 제공받는 상황을 가정했을 때 실행되는 흐름은 다음과 같습니다.

    4개의 코루틴 실행 순서

     

    초록색 박스는 메인 스레드를 표현했습니다. 첫 번째로 짚어볼 부분은 2! 가 호출된 이후 coroutine3 은 종료되고 launch1 또는 launch3 이 메인 스레드를 받아서 실행됩니다. 여기서 중요한 점은 runBlocking 은 메인 스레드를 양보받는 대상이 아닙니다. runBlocking 은 부모 코루틴으로 자식 코루틴인 launch 가 전부 완료될 때까지 대기 상태에 있는 것 뿐입니다. 두 번째로, launch3 에서 delay(500L) 이 호출된 이후 launch1 에게 스레드를 양보하지 않습니다. 그림상으로는 코드 실행 순서를 가시화하기 위해 블록을 크게 그려 시간 지연이 명확하게 표시되지 않았지만, launch3 이 0.5초 쉴 동안에 launch1 은 1초를 쉬게 됩니다. 따라서, launch3 은 launch1 에게 스레드를 양보하지 않고 0.5초 쉰 다음에 3! 을 호출합니다. 자식 코루틴이 모두 끝난 후에야 부모 코루틴인 runBlocking 이 스레드를 이어받아 실행되는 것을 볼 수 있습니다. 여기서, 어떻게 하면 launch 를 실행하기 위한 코루틴 스코프를 가져올 수 있을까요? 다음 코드를 봅시다!

    import kotlinx.coroutines.*
    
    suspend fun doOneTwoThree() = coroutineScope {
        launch {
            println("launch1: ${Thread.currentThread().name}") // (2) main@coroutine#2
            delay(1000L) // 1.0s
            println("4!") // (6)
        }
        launch {
            println("launch2: ${Thread.currentThread().name}") // (3) main@coroutine#3
            println("2!") // (4)
        }
        launch {
            println("launch3: {Thread.currentThread().name}") // (4) main@coroutine#4
            delay(500L)
            println("3!") // (5)
        }
        println("1!") // (1)
    }
    
    fun main() = runBlocking {
        doOneTwoThree()
        println("runBlocking: ${Thread.currentThread().name}") // (7) main@coroutine#1
        println("5!") // (8)
    }

     

    launch 코루틴 빌더를 suspend fun 내에서 호출하려면 coroutineScope 을 사용하면 됩니다. coroutineScope 는 코루틴 스코프를 만들어 내부에서 코루틴 빌더를 사용할 수 있게 해줍니다. 또한, 코루틴 빌더가 아닌 지연 함수(suspend fun) 이기에 다른 지연 함수나 runBlocking 과 같이 코루틴 내부에서만 호출이 가능합니다. coroutineScope 는 내부 자식 코루틴(launch)들이 모두 실행이 완료될 때까지 suspend 상태로 기다립니다. 만약에 coroutineScope 가 캔슬되면 launch 도 캔슬됩니다. 그리고, launch 코루틴 빌더와 달리 runBlocking 코루틴 빌더는 내부적으로 coroutineScope 를 포함하기 때문에 바로 사용할 수 있습니다

    사용 형태가 거의 비슷한 점에서 coroutineScope 와 runBlocking 을 비교할 수 있습니다. 아래에 표로 정리해보았습니다.

    특징 runBlocking coroutineScope
    스레드를 멈추는가? O (blocking) X (non-blocking)
    코루틴을 만드는가? O (코루틴 빌더) X (코루틴 빌더 아님)
    코루틴 내에서
    호출해야 하는가?
    ( = 지연 함수인가?)
    X O

     

     

    2. launch 코루틴 빌더의 반환값인 Job 객체를 통한 실행 순서 제어

    import kotlinx.coroutines.*
    
    doOneTwoThree() = coroutineScope {
        val job: Job = launch {
            println("launch1: ${Thread.currentThread().name}") // (1) main@coroutine#2
            delay(1000L)
            println("1!") // (2)
        }
        job.join()
        launch {
            println("launch2: ${Thread.currentThread().name}") // (4) main@coroutine#3
            println("3!") // (5)
        }
        launch {
            println("launch3: ${Thread.currentThread().name}") // (6) main@coroutine#4
            delay(500L) 
            println("4!") // (7)
        }
        println("2!") // (3)
    }
    
    fun main() = runBlocking {
        doOneTwoThree()
        println("runBlocking: ${Thread.currentThread().name}") // (8)
        println("5!") // (9)
    }

     

     

    코루틴 빌더 launch 는 Job 객체를 반환할 수 있습니다. 이 반환값을 변수에 지정해서 join 메소드를 호출하게 되면 해당 코루틴 2(launch1) 은 작업을 완료할 때까지 다른 코드를 먼저 실행하지 않습니다. 이전까지 예제에서는 launch 실행을 예약으로만 걸어두었기에 2! 가 가장 먼저 호출되었는데 Job 객체의 join 메소드로 인해 처음으로 실행 순서가 바뀐 점을 주목할 수 있습니다. 이후에는 예약된 다른 launch 보다 runBlocking 이 먼저 실행됩니다. 두 번째로, launch3 에서 delay 메소드가 호출되어 현재 실행되는 코루틴이 잠들었지만 다른 자식 코루틴의 호출이 전부 끝났기에 스레드를 양보할 수 없습니다. 이때, 부모 코루틴은 자식 코루틴이 실행을 전부 완료할 때까지 기다리기에 양보 대상에 제외됩니다. 정리하자면 delay(1000L) 은 job 객체의 join 으로 묶여있어 양보할 수 없고, delay(500L) 은 양보 대상이 없다는 차이점이 있습니다.

     

    launch 의 job 객체

     

    3. 코루틴의 경량성 

    import kotlinx.coroutines.*
    
    suspend fun doOneTwoThree() = coroutineScope {
        val job = launch {
            println("launch1: ${Thread.currentThread().name}") // (1) main@coroutine#2
            delay(1000L) // 양보 x
            println("1!") // (2)
        }
        job.join()
        launch {
            println("launch2: ${Thread.currentThread().name}") // (4)
            println("3!") // (5)
        }
        repeat(1000) {
            launch {
                println("launch3: ${Thread.currentThread().name}") // (6), (7), ... (1005)
                delay(500L)
                println("4!") // (1006), (1007), ... (2005)
            }
        }
        println("2!") // (3)
    }
    
    fun main() = runBlocking {
        doOneTwoThree()
        println("runBlocking: ${Thread.currentThread().name}") // (2006)
        println("5!") // (2007)
    }

     

    여기서는 코루틴의 경량성을 확인할 수 있습니다. coroutine 4 까지 실행되고 나서 repeat 반복문을 통해 1000 개의 코루틴을 추가로 만듭니다. 이때, 각각의 코루틴은 현재 스레드 이름을 출력하는 코드 이후에 delay 메소드로 500ms 동안 잠들면서 스레드를 다음 코루틴에게 양보합니다. 그렇게 1000개의 코루틴이 생성된 후에 500ms 동안 잠들어있었던 코루틴 5가 처음으로 깨어나면서 4! 가 마찬가지로 1000번 호출됩니다. 이후엔 부모 코루틴에게 스레드가 넘어가게 됩니다. 여기서 중요한 부분은 delay 를 통해 스레드를 다른 코루틴에게 양보하는 협력성 때문에 여러 개의 코루틴을 만드는데에 큰 비용이 들지 않는다는 것인데요. 예제에서는 1000개를 생성했지만, 10만개 정도의 간단한 동작을 하는 코루틴을 생성하는 것도 큰 부담은 아닙니다. 하지만, 스레드는 코루틴보다 단위가 커서 그런 일은 매우 무겁고 어렵습니다

    코루틴의 경량성

     

    오늘은 코루틴 스코프, Job 객체, 코루틴의 경량성에 대해 알아보았습니다. 읽어주셔서 감사합니다!

    'Coroutines & Flow' 카테고리의 다른 글

    코루틴을 이해해보자! (기초 2편)  (0) 2025.07.06
    코루틴을 이해해보자! (기초 1편)  (2) 2025.06.29
Designed by Tistory.