목차
- Intro
- 코루틴 순차 처리
- join 함수
- joinAll 함수
- 코루틴 지연 시작: CoroutineStart.LAZY
- 코루틴 취소하기
- cancel 사용해 Job 취소하기
- cancelAndJoin을 사용한 순차 처리
- 코루틴의 취소 확인
- 코루틴이 취소되지 않는 경우
- 1️⃣ delay를 사용한 취소 확인
- 2️⃣ yield를 사용한 취소 확인
- 3️⃣ CoroutineScope.isActive를 사용한 취소 확인
- Outro
들어가기 전
이번 글의 전반적인 내용은 『코틀린 코루틴의 정석』이라는 책을 읽으며 이해한 내용을 바탕으로 작성한다. 정보를 전달하기보다는, 필자가 이해한 바를 정리해 기록하기 위한 목적이 크다.
지난 시간에는 CoroutineDispatcher에 대해 알아보았다. 오늘은 코루틴 빌더와 Job을 살펴보겠다.
runBlocking이나 launch를 호출하면 새로운 코루틴이 생성된다. 이렇게 코루틴을 생성하는 데 사용하는 함수를 코루틴 빌더 함수라고 한다.
모든 코루틴 빌더 함수는 코루틴을 만들고 코루틴을 추상화한 Job 객체를 생성한다. 반환된 Job 객체는 코루틴의 상태를 추적하고 제어하는 데 사용된다.
fun main() = runBlocking<Unit> {
val job: Job = launch(Dispatchers.IO) { // Job 객체 반환
println("[${Thread.currentThread().name}] 실행")
}
}
인증 토큰을 업데이트한 후에 네트워크 요청을 보낸다고 가정해보자.
만약 작업이 순차적으로 진행되지 않는다면 어떻게 될까?
fun main() = runBlocking<Unit> {
val updateTokenJob: Job = launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 토큰 업데이트 시작")
delay(100L)
println("[${Thread.currentThread().name}] 토큰 업데이트 완료")
}
val networkCallJob = launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 네트워크 요청")
}
}
[DefaultDispatcher-worker-1 @coroutine#2] 토큰 업데이트 시작
[DefaultDispatcher-worker-3 @coroutine#3] 네트워크 요청
[DefaultDispatcher-worker-1 @coroutine#2] 토큰 업데이트 완료
위 코드에서는 인증 토큰을 갱신하는 작업과 네트워크 요청이 병렬로 실행된다. 토큰이 업데이트 되기 전에 네트워크 요청이 먼저 수행될 수 있다는 것이다. 만약 위 코드가 실제 코드였다면 오류가 발생했을 것이다.
Job 객체는 이런 문제 해결을 위해 join 함수를 제공한다.
Job 객체의 join 함수를 호출하면 join의 대상이 된 코루틴의 작업이 끝날 때까지 join을 호출한 코루틴은 일시 중단된다. 코드로 확인해보자.
fun main() = runBlocking<Unit> {
val updateTokenJob: Job = launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 토큰 업데이트 시작")
delay(100L)
println("[${Thread.currentThread().name}] 토큰 업데이트 완료")
}
// updateTokenJob이 완료될 때까지 runBlocking 코루틴 일시 중단
updateTokenJob.join()
val networkCallJob = launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 네트워크 요청")
}
}
[DefaultDispatcher-worker-1 @coroutine#2] 토큰 업데이트 시작
[DefaultDispatcher-worker-1 @coroutine#2] 토큰 업데이트 완료
[DefaultDispatcher-worker-1 @coroutine#3] 네트워크 요청
위 코드의 실행 결과를 보면 updateTokenJob 코루틴이 완료된 후 networkCallJob 코루틴이 실행된 것을 확인할 수 있다. 즉, runBlocking 코루틴이 updateTokenJob 코루틴이 완료될 때까지 일시 중단된다. updateTokenJob 내부의 코드가 모두 실행되면 runBlocking 코루틴이 재개돼 networkCallJob을 실행한다.
여기서 중요한 점은 join()을 호출한 코루틴이 join의 대상이 된 코루틴이 끝날 때까지 일시 중단된다는 것이다. 따라서 join 함수는 일시 중단이 가능한 지점에서만 호출할 수 있다.
또 하나 기억해야 할 점은 join()은 join을 호출한 코루틴만 일시 중단하며 다른 코루틴에는 영향을 주지 않는다는 것이다. 아래 코드와 실행 결과를 보면 이 동작 방식을 이해할 수 있다.
fun main() = runBlocking<Unit> {
val updateTokenJob: Job = launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 토큰 업데이트 시작")
delay(100L)
println("[${Thread.currentThread().name}] 토큰 업데이트 완료")
}
val independentJob = launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 독립적인 작업 실행")
}
updateTokenJob.join()
val networkCallJob = launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 네트워크 요청")
}
}
[DefaultDispatcher-worker-1 @coroutine#2] 토큰 업데이트 시작
[DefaultDispatcher-worker-3 @coroutine#3] 독립적인 작업 실행
[DefaultDispatcher-worker-1 @coroutine#2] 토큰 업데이트 완료
[DefaultDispatcher-worker-1 @coroutine#4] 네트워크 요청
runBlocking 코루틴은 updateTokenJob.join()을 호출하기 전에 이미 launch를 통해 independentJob을 실행한다.
다른 스레드에서 이미 실행이 시작된 independentJob은 join()으로 인한 일시 중단의 영향을 받지 않는다.
코루틴 라이브러리는 여러 코루틴이 모두 끝날 때까지 호출한 코루틴을 일시 중단시키는 joinAll 함수도 제공한다.
public suspend fun joinAll(vararg jobs: Job): Unit = jobs.forEach {
it.join()
}
joinAll을 사용하면 대상이 된 모든 코루틴의 실행이 끝날 때까지 호출한 코루틴을 안전하게 일시 중단할 수 있다.
fun main() = runBlocking<Unit> {
val convertImageJob1: Job = launch(Dispatchers.Default) {
Thread.sleep(1_000L) // 이미지 변환 작업 실행 시간
println("[${Thread.currentThread().name}] 이미지1 변환 완료")
}
val convertImageJob2: Job = launch(Dispatchers.Default) {
Thread.sleep(1_000L) // 이미지 변환 작업 실행 시간
println("[${Thread.currentThread().name}] 이미지2 변환 완료")
}
// 이미지1과 이미지2가 변환될 때까지 대기
joinAll(convertImageJob1, convertImageJob2)
val uploadImageJob: Job = launch(Dispatchers.IO) {
println("[${Thread.currentThread().name}] 이미지1, 2 업로드")
}
}
[DefaultDispatcher-worker-1 @coroutine#2] 이미지1 변환 완료
[DefaultDispatcher-worker-2 @coroutine#3] 이미지2 변환 완료
[DefaultDispatcher-worker-1 @coroutine#4] 이미지1, 2 업로드
launch 함수를 사용해 코루틴을 생성했을 때 사용할 수 있는 스레드가 있으면 코루틴이 바로 실행된다. 하지만 나중에 실행해야 할 코루틴을 미리 생성해야 하는 경우도 있다. 이런 상황에서 CoroutineStart.LAZY를 사용할 수 있다.
지연 시작이 적용된 코루틴은 생성 직후 대기 상태에 놓이며, 실행을 요청하지 않으면 시작되지 않는다. 코루틴을 지연 시작하려면 launch 함수의 start 인자로 CoroutineStart.LAZY를 전달하면 된다.
fun getElapsedTime(startTime: Long): String =
"지난 시간: ${System.currentTimeMillis() - startTime}ms"
fun main() = runBlocking<Unit> {
val startTime = System.currentTimeMillis()
val lazyJob: Job = launch(start = CoroutineStart.LAZY) {
println("[${getElapsedTime(startTime)}] 지연 실행")
}
}
위 코드를 실행해 보면 아무런 결과도 나오지 않는다. 지연 코루틴은 명시적으로 실행을 요청하지 않으면 생성만 되고 실행되지 않기 때문이다. 실행하려면 Job 객체의 start 함수를 명시적으로 호출해야 한다.
fun main() = runBlocking<Unit> {
val startTime = System.currentTimeMillis()
val lazyJob: Job = launch(start = CoroutineStart.LAZY) {
println("[${Thread.currentThread().name}][${getElapsedTime(startTime)}] 지연 실행")
}
delay(1_000L)
lazyJob.start()
}
[main @coroutine#2][지난 시간: 1014ms] 지연 실행
코드의 실행 결과를 보면 lazyJob 코루틴은 곧바로 실행되지 않고 1초 정도 대기 후 실행된 것을 확인할 수 있다.
코루틴을 실행하는 도중 더 이상 작업이 필요 없으면 즉시 취소해야 한다.
필요 없는 코루틴을 계속 실행하면 코루틴은 스레드를 사용하게 되어 애플리케이션 성능 저하로 이어질 수 있다.
fun main() = runBlocking<Unit> {
val startTime = System.currentTimeMillis()
val longJob: Job = launch(Dispatchers.Default) {
repeat(10) { repeatTime ->
delay(1_000L)
println("[${getElapsedTime(startTime)}] 반복횟수 ${repeatTime}")
}
}
delay(3_500L)
longJob.cancel()
}
[지난 시간: 1016ms] 반복횟수 0
[지난 시간: 2021ms] 반복횟수 1
[지난 시간: 3027ms] 반복횟수 2
cancel()을 호출한 직후에 다른 작업을 수행하면 해당 작업이 코루틴이 취소되기 전에 실행될 수 있다. Job 객체에 cancel()을 호출하면 코루틴이 즉시 종료되는 것이 아니라 내부의 취소 확인용 플래그가 ‘취소 요청됨’으로 변경되어 코루틴에게 취소가 필요함을 알린다. 이후 코루틴은 미래의 어느 시점에서 이 플래그를 확인하고 취소된다.
즉, cancel()은 대상 코루틴을 즉시 취소하지 않고 나중에 취소되도록 요청하는 기능이다. 따라서 취소 순서를 보장하기 위해서는 Job 객체의 cancelAndJoin() 함수를 사용해야 한다. cancelAndJoin()을 호출하면 대상 코루틴의 취소가 완료될 때까지 호출한 코루틴이 일시 중단된다.
fun main() = runBlocking<Unit> {
val startTime = System.currentTimeMillis()
val longJob: Job = launch(Dispatchers.Default) {
// 작업 실행
}
longJob.cancelAndJoin()
executeAfterJobCancelled()
}
cancel()이나 cancelAndJoin()을 사용하더라도 코루틴이 즉시 취소되는 것은 아니다.
Job 객체 내부의 취소 확인용 플래그를 변경할 뿐 코루틴이 이 플래그를 확인하는 시점에 비로소 취소가 이루어진다.
만약 코루틴이 취소를 확인할 수 있는 시점이 없다면 코루틴은 취소되지 않는다.
fun main() = runBlocking<Unit> {
val whileJob: Job = launch(Dispatchers.Default) {
while(true) {
println("작업 중")
}
}
delay(100L)
whileJob.cancel()
}
...
작업 중
작업 중
작업 중
...
위 코드는 코루틴 블록 내부에 취소를 확인할 수 있는 지점이 없기 때문에 코루틴이 취소되지 않는다. while 문 안에서 코드가 반복적으로 실행되므로 while 문을 벗어날 수 없다. while문 내부에도 일시 중단 지점이 없기 때문에 일시 중단될 수도 없다.
이 코드가 취소되도록 만드는 데는 세 가지 방법이 있다.
1. delay를 사용한 취소 확인
2. yield를 사용한 취소 확인
3. CoroutineScope.isActive를 사용한 취소 확인
delay 함수는 일시 중단 함수(suspend fun)로 선언돼 특정 시간만큼 호출부의 코루틴을 일시 중단시킨다. 다음과 같이 작업 중간에 delay(1L)을 주게 되면 while문이 반복될 때마다 코루틴이 1밀리초 동안 일시 중단되고, 그 시점에 취소 여부를 확인할 수 있다.
fun main() = runBlocking<Unit> {
val whileJob: Job = launch(Dispatchers.Default) {
while(true) {
println("작업 중")
delay(1L)
}
}
delay(100L)
whileJob.cancel()
}
...
작업 중
작업 중
작업 중
Process finished with exit code 0
하지만 이 방법은 while 문이 반복될 때마다 작업을 강제로 1밀리초 동안 일시 중단시키므로 비효율적이다.
yield를 직역하면 '양보'라는 뜻이다. yield 함수가 호출되면 코루틴은 자신이 사용하던 스레드를 양보한다. 스레드 사용을 양보한다는 것은 스레드 사용을 중단한다는 뜻이므로 yield를 호출한 코루틴이 일시 중단되며, 이 시점에 취소됐는지 체크가 일어난다.
fun main() = runBlocking<Unit> {
val whileJob: Job = launch(Dispatchers.Default) {
while(true) {
println("작업 중")
yield()
}
}
delay(100L)
whileJob.cancel()
}
...
작업 중
작업 중
작업 중
Process finished with exit code 0
yield를 사용하는 방법 또한 while문을 한 번 돌 때마다 스레드 사용이 양보되면서 일시 중단되는 문제가 있다. 코루틴이 아무리 경량 스레드라고 하더라도 매번 일시 중단되는 것은 작업을 비효율적으로 만든다.
CoroutineScopes는 코루틴이 활성화됐는지 확인할 수 있는 Boolean 타입의 isActive 프로퍼티를 제공한다. 코루틴에 취소가 요청되면 isActive 프로퍼티의 값도 false로 변경된다.
fun main() = runBlocking<Unit> {
val whileJob: Job = launch(Dispatchers.Default) {
while(this.isActive) {
println("작업 중")
}
}
delay(100L)
whileJob.cancel()
}
...
작업 중
작업 중
작업 중
Process finished with exit code 0
이 방법을 사용하면 코루틴이 잠시 멈추지 않고, 스레드 사용을 양보하지 않으면서도 작업을 계속 수행할 수 있어 효율적이다.
조세영, 『코틀린 코루틴의 정석』