Post

coroutine 알아보기 (2) - coroutine 만드는법

coroutine 빌더와 Job

코루틴 빌더와 Job

가장 쉽게는 runBlocking으로 만드는 방법입니다. 이 함수는 새로운 코루틴을 만들고 다른 함수와 이어주는 역할을 합니다.

이렇게 코루틴을 만드는 함수를 코루틴 빌더라고합니다. 이 글에서는 코루틴을 만드는 방법과 코루틴에 대해 설명하겠습니다.

코루틴 빌더

kotlin에서는 코루틴을 시작하기 위한 다양한 빌더를 제공합니다.

kotlin, springboot3.x기준으로 설명합니다.
전체 sample은 github-sample를 참조해주세요.

runBlocking

runBlocking 함수는 runBlocking으로 인해 만들어진 코루틴과 그 안에 있는 코루틴이 모두 완료될 때지 스레드를 블락시킵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun main() {
    runBlocking {
        printWithThread("Start")
        launch {
            delay(2_000L) // 특정시간만큼 멈추고 다른 코루틴에게 넘김
            printWithThread("Launch end")
        }
    }

    printWithThread("End")
}

// [main] Start
// 2초후
// [main] Launch end
// [main] End

main 함수에는 runBlocking으로 만들어진 코루틴이 있고, runBlocking 으로 만들어진 코루틴 안에는 다시 한번 launch 로 만들어진 코루틴이 있습니다. 여기서 사용된 delay() 함수는 코루틴을 지정된 시간 동안 지연시키는 함수입니다.

runBlocking 때문에 두개의 코루틴이 모두 완전히 종료되고 나서야 printWithThread("End")가 실행됩니다.

launch

launch 역시 코루틴 빌더이며 반환 값이 없는 코드를 실행할 때 사용합니다. launch는 runBlocking과 다르게 만들어진 코루틴을 결과로 반환하고, 이 객체를 이용해 코루틴을 제어할 수 있습니다. 이 객체의 타입은 Job으로 코루틴을 나타냅니다.

즉, Job을 하나 받으면 코루틴을 하나 만든것입니다. 아래의 코드를 보면서 설명하겠습니다.

1
2
3
4
5
6
7
8
9
10
fun main(): Unit = runBlocking {
    val job = launch(start = CoroutineStart.LAZY) {
        printWithThread("hello launch")
    }

    job.start()
}

// [main] hello launch

launch로 코루틴 빌더를 사용할때 CoroutineStart.LAZY 옵션을 주어 코루틴이 즉시 실행되지 않도록 변경하였고, job.start()를 직접 호출하여 시작 신호를 주어 동작하게 하였습니다. 즉, 코루틴이 시작되도록 제어한 것입니다.

시작과 같이 취소 또한 가능합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun main(): Unit = runBlocking {
    val job = launch {
        (1..5).forEach {
            printWithThread("Routine $it")
            delay(500)
        }
    }

    delay(1_000L)
    job.cancel()
}

// [main] Routine 1
// [main] Routine 2

1부터 5까지 출력할 수 있지만, job.cancel()을 호출해 코루틴을 취소했기에 2까지만 출력이 됩니다.

또한, join()을 사용하면 제어하고있는 코루틴이 끝날때까지 대기할 수도 있습니다. 그 전에 아래 코드를 먼저 설명합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun main(): Unit = runBlocking {
    val time = measureTimeMillis {
        val job1 = launch {
            delay(1_000L)
            printWithThread("job 1")
        }

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

    printWithThread("소요시간 : $time")

}

// [main] 소요시간 : 3 ms
// [main] job 1
// [main] job 2


위의 코드는 각각의 코루틴에서 delay가 1초씩 걸려있지만, job1과 job2가 출력되는데 1초정도면 다 실행됩니다.

job1에서 1초를 기다리는 동안 job2가 시작되어 함께 1초를 기다리기 때문입니다. 그림으로 보자면 아래와 같습니다.

alt text

그러나 여기서 join을 추가한다면 job1이 끝날때 까지 기다리게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun main(): Unit = runBlocking {
    val time = measureTimeMillis {
        val job1 = launch {
            delay(1_000L)
            printWithThread("job 1")
        }
        job1.join()
        val job2 = launch {
            delay(1_000L)
            printWithThread("job 2")
        }
    }

    printWithThread("소요시간 : $time ms")

}


// [main] job 1
// [main] 소요시간 : 1015 ms
// [main] job 2

위의 코드는 첫번째와 다르게 2초 이상이 걸린다. 첫번째 코루틴에 대해 join()을 호출하며 첫 번째 코루틴이 끝날 때까지 완전히 기다렸기 때문입니다.

alt text

async

launch()와 유사하지만, async()는 주어진 함수 실행 결과를 반환할 수 있습니다.

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

async()역시 launch()처럼 코루틴을 제어할 수 있는 객체를 반환하며 그 객체는 Deferred입니다.

Deferred란?
Deferred는 결과가 있는 비동기 작업을 수행하기 위해 결과 값이 없는 Job을 확장하는 인터페이스입니다. 즉, Deferred는 Job의 하위타입으로 Job의 모든 특성을 갖습니다. async()에서 실행된 결과를 가져오는 await()함수가 추가적으로 존재합니다. Job의 상태 변수(isActive, isCancelled, isCompleted), Job의 Exception Handling 등을 모두 Deferred에서 똑같이 적용할 수 있습니다.
Job은 Deferred가 아님을 주의하자.

아래와 같이 async()에서 실행된 결과를 가져오는 await()를 적용해볼 수 있습니다.

1
2
3
4
5
6
7
fun main(): Unit = runBlocking {
    val job = async {
        3 + 5
    }
    val result = job.await() // await : async의 결과를 가져옴
    printWithThread(result)
}

특히, async()는 여러 외부 자원을 동시에 호출해야하는 상황에서 유용합니다.

예를 들어, 두 API를 각각 호출해 결과를 합치는 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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] 3
// [main] 소요시간 : 1014 ms

또한, 첫 번째 API의 결과가 두 번째 API에 필요한 경우에는 callback을 이용하지 않고도, 동기 방식으로 코드를 작성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fun main(): Unit = runBlocking {
    val time = measureTimeMillis {
        val job1 = async { apiCall1() }
        val job2 = async { apiCall2Int(job1.await()) }
        printWithThread(job2.await())
    }

    printWithThread("소요시간 : $time")

}

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

suspend fun apiCall2Int(num: Int): Int {
    delay(1_000L)
    return 2 + num
}

// [main] 3
// [main] 소요시간 : 2018

async() 와 관련해 한 가지 주의할 점으로는, CoroutineStart.LAZY 옵션을 사용해 코루틴을 지연 실행시킨다면, await() 함수를 호출했을 때 계산 결과를 계속해서 기다린다는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Lazy를 사용하면 await()을 호출했을 때 계산 결과를 계속 기다린다.
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")

}

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

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

// [main] 3
// [main] 소요시간 : 2017

만약, 지연 코루틴을 async() 와 함께 사용하는 경우라도, 동시에 API 호출을 하고 싶다면 start() 함수를 먼저 사용해 주어야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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")
}

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

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

// [main] 3
// [main] 소요 시간 : 1017 ms

정리

Kotlin의 코루틴 빌더는 runBlocking, launch, async 등의 함수를 제공하며, 각각의 특성과 사용 목적이 다릅니다.

  • runBlocking
    • 현재 스레드를 블로킹하며, 내부의 모든 코루틴이 완료될 때까지 기다립니다.
    • 테스트나 main 함수에서 코루틴을 시작할 때 유용합니다.
  • launch
    • 결과 값을 반환하지 않는 코루틴을 실행합니다.
    • 반환되는 Job 객체를 통해 코루틴의 실행을 제어(start(), cancel(), join() 등)할 수 있습니다.
  • async
    • 결과 값을 반환하는 코루틴을 실행합니다.
    • Deferred 객체를 반환하며, await()를 호출해 실행 결과를 가져올 수 있습니다.
    • 여러 개의 비동기 작업을 병렬로 실행할 때 유용합니다.
  • CoroutineStart.LAZY 옵션
    • 즉시 실행되지 않고 start() 또는 await() 호출 시 실행됩니다.
    • async에서 사용 시 start()를 명시적으로 호출해야 병렬 실행이 가능합니다.

[출처]

  • https://kotlinlang.org/docs/coroutines-overview.html
This post is licensed under CC BY 4.0 by the author.