코루틴 기초

코루틴 기초 (Coroutines basics)

한 번에 여러 작업을 수행하는 애플리케이션, 즉 동시성(concurrency)을 만드려고 할 때 Kotlin은 코루틴을 사용해요. 코루틴은 일시 중단 가능한 계산(suspendable computation)으로, 동시성 코드를 명확하고 순차적인 스타일로 작성하게 해 줘요. 코루틴은 다른 코루틴과 동시에, 잠재적으로는 병렬로 실행될 수 있어요.

JVM과 Kotlin/Native에서 코루틴을 포함한 모든 동시성 코드는 운영 체제가 관리하는 스레드에서 실행돼요. 코루틴은 스레드를 막는 대신 실행을 일시 중단할 수 있어요. 이 덕분에 한 코루틴이 데이터가 도착하기를 기다리며 일시 중단되는 동안 같은 스레드에서 다른 코루틴이 실행될 수 있어, 자원을 효과적으로 활용할 수 있죠.

코루틴과 JVM 스레드의 차이에 대한 자세한 내용은 코루틴과 JVM 스레드 비교를 참고하세요.

출처: Kotlin 공식 문서

본문

일시 중단 함수 (Suspending functions)

코루틴의 가장 기본적인 구성 요소는 일시 중단 함수예요. 이것은 실행 중인 연산이 코드 구조에 영향을 주지 않고 나중에 일시 중지하고 재개하게 해 줘요.

일시 중단 함수를 선언하려면 suspend 키워드를 사용해요.

suspend fun greet() {
    println("Hello world from a suspending function")
}

일시 중단 함수는 다른 일시 중단 함수에서만 호출할 수 있어요. Kotlin 애플리케이션의 진입점에서 일시 중단 함수를 호출하려면 main() 함수를 suspend 키워드로 표시해요.

suspend fun main() {
    showUserInfo()
}

suspend fun showUserInfo() {
    println("Loading user...")
    greet()
    println("User: John Smith")
}

suspend fun greet() {
    println("Hello world from a suspending function")
}

이 예시는 아직 동시성을 사용하지 않아요. 하지만 함수들을 suspend 키워드로 표시함으로써, 그들이 다른 일시 중단 함수를 호출하고 내부에서 동시성 코드를 실행할 수 있게 만들었어요.

suspend 키워드는 Kotlin 핵심 언어의 일부이지만, 대부분의 코루틴 기능은 kotlinx.coroutines 라이브러리를 통해 제공돼요.

프로젝트에 kotlinx.coroutines 라이브러리 추가하기

프로젝트에 kotlinx.coroutines 라이브러리를 포함하려면 빌드 도구에 따라 대응하는 의존성 구성을 추가해요.

// build.gradle.kts
repositories {
    mavenCentral()
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}
// build.gradle
repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0'
}
<!-- pom.xml -->
<project>
    <dependencies>
        <dependency>
            <groupId>org.jetbrains.kotlinx</groupId>
            <artifactId>kotlinx-coroutines-core</artifactId>
            <version>1.11.0</version>
        </dependency>
    </dependencies>
    ...
</project>

첫 번째 코루틴 만들기

이 페이지의 예시들은 코루틴 빌더 함수 CoroutineScope.launch()CoroutineScope.async()와 함께 명시적 this 표현식을 사용해요. 이 코루틴 빌더들은 CoroutineScope확장 함수이고, this 표현식은 현재 CoroutineScope를 리시버로 가리켜요.

실용적인 예시는 코루틴 스코프에서 코루틴 빌더 추출하기를 참고하세요.

Kotlin에서 코루틴을 만들려면 다음이 필요해요.

멀티스레드 환경에서 여러 코루틴을 사용하는 예시를 살펴볼게요.

  • kotlinx.coroutines 라이브러리를 임포트해요.
import kotlinx.coroutines.*
  • 일시 중지하고 재개할 수 있는 함수를 suspend 키워드로 표시해요.
suspend fun greet() {
    println("The greet() on the thread: ${Thread.currentThread().name}")
}

suspend fun main() {}

일부 프로젝트에서는 main() 함수를 suspend로 표시할 수 있지만, 기존 코드와 통합하거나 프레임워크를 사용할 때는 그럴 수 없을 수도 있어요. 그 경우 프레임워크의 문서를 확인해 일시 중단 함수 호출을 지원하는지 봐요. 지원하지 않으면 runBlocking()을 사용해 현재 스레드를 막아 그것들을 호출해요.

  • 데이터 가져오기나 데이터베이스 쓰기 같은 일시 중단 작업을 흉내 내기 위해 delay() 함수를 추가해요.
suspend fun greet() {
    println("The greet() on the thread: ${Thread.currentThread().name}")
    delay(1000L)
}
  • 공유 스레드 풀에서 실행되는 멀티스레드 동시성 코드의 진입점을 정의하기 위해 withContext(Dispatchers.Default)를 사용해요.
suspend fun main() {
    withContext(Dispatchers.Default) {
        // 여기에 코루틴 빌더를 추가하세요
    }
}

일시 중단 withContext() 함수는 주로 컨텍스트 전환에 사용되지만, 이 예시에서는 동시성 코드를 위한 비차단 진입점을 정의하기도 해요. 멀티스레드 실행을 위해 공유 스레드 풀에서 코드를 실행하도록 Dispatchers.Default 디스패처를 사용해요. 기본적으로 이 풀은 런타임에 사용 가능한 CPU 코어 수만큼의 스레드를 최대한 사용하는데, 최소 두 개의 스레드를 사용해요.

withContext() 블록 안에서 시작된 코루틴은 같은 코루틴 스코프를 공유하며, 이것이 구조적 동시성을 보장해요.

suspend fun main() {
    withContext(Dispatchers.Default) { // this: CoroutineScope
        // CoroutineScope.launch()로 스코프 안에서 코루틴 시작
        this.launch { greet() }
        println("The withContext() on the thread: ${Thread.currentThread().name}")
    }
}
  • 이 조각들을 결합해 공유 스레드 풀에서 여러 코루틴을 동시에 실행해요.
// 코루틴 라이브러리 임포트
import kotlinx.coroutines.*

// 초를 초 단위로 표현하기 위해 kotlin.time.Duration 임포트
import kotlin.time.Duration.Companion.seconds

// 일시 중단 함수 정의
suspend fun greet() {
    println("The greet() on the thread: ${Thread.currentThread().name}")
    // 1초 동안 일시 중단하고 스레드를 해제
    delay(1.seconds) 
    // 여기서 delay() 함수는 일시 중단 API 호출을 흉내 냄
    // 네트워크 요청 같은 일시 중단 API 호출을 여기에 추가할 수 있음
}

suspend fun main() {
    // 공유 스레드 풀에서 이 블록 안의 코드 실행
    withContext(Dispatchers.Default) { // this: CoroutineScope
        this.launch() {
            greet()
        }

        // 다른 코루틴 시작
        this.launch() {
            println("The CoroutineScope.launch() on the thread: ${Thread.currentThread().name}")
            delay(1.seconds)
            // 여기서 delay 함수는 일시 중단 API 호출을 흉내 냄
            // 네트워크 요청 같은 일시 중단 API 호출을 여기에 추가할 수 있음
        }

        println("The withContext() on the thread: ${Thread.currentThread().name}")
    }
}

이 예시를 여러 번 실행해 보세요. OS가 스레드가 언제 실행될지 결정하기 때문에, 프로그램을 실행할 때마다 출력 순서와 스레드 이름이 달라질 수 있다는 걸 알아차릴 수 있어요.

추가 정보를 위해 코드 출력에서 스레드 이름 옆에 코루틴 이름을 표시할 수도 있어요. 이렇게 하려면 빌드 도구나 IDE 실행 구성에서 -Dkotlinx.coroutines.debug VM 옵션을 전달해요. 자세한 내용은 코루틴 디버깅을 참고하세요.

코루틴 스코프와 구조적 동시성

애플리케이션에서 많은 코루틴을 실행할 때는 그것들을 그룹으로 관리할 방법이 필요해요. Kotlin 코루틴은 구조적 동시성(structured concurrency)이라는 원칙에 의존해 이런 구조를 제공해요.

이 원칙에 따르면 코루틴은 부모-자식 작업의 트리 계층을 이루며 수명 주기가 연결돼요. 코루틴의 수명 주기는 생성부터 완료, 실패, 취소까지의 상태의 연속이에요.

부모 코루틴은 자식들이 완료될 때까지 기다렸다가 끝나요. 부모 코루틴이 실패하거나 취소되면, 그 모든 자식 코루틴도 재귀적으로 취소돼요. 코루틴을 이런 식으로 연결해 두면 취소와 오류 처리를 예측 가능하고 안전하게 만들 수 있어요.

구조적 동시성을 유지하려면 새 코루틴은 수명 주기를 정의하고 관리하는 CoroutineScope에서만 시작될 수 있어요. CoroutineScope는 디스패처와 다른 실행 속성을 정의하는 코루틴 컨텍스트를 포함해요. 다른 코루틴 안에서 코루틴을 시작하면, 그것은 자동으로 부모 스코프의 자식이 돼요.

CoroutineScope에서 CoroutineScope.launch() 같은 코루틴 빌더 함수를 호출하면 그 스코프와 연결된 코루틴의 자식 코루틴이 시작돼요. 빌더 블록 안에서 리시버는 중첩된 CoroutineScope이므로, 거기서 시작하는 어떤 코루틴이든 그 자식이 돼요.

coroutineScope() 함수로 코루틴 스코프 만들기

현재 코루틴 컨텍스트로 새 코루틴 스코프를 만들려면 coroutineScope() 함수를 사용해요. 이 함수는 코루틴 하위 트리의 루트 코루틴을 만들어요. 이것은 블록 안에서 시작된 코루틴의 직접 부모이고, 그것들이 시작한 어떤 코루틴의 간접 부모이기도 해요. coroutineScope()는 일시 중단 블록을 실행하고, 블록과 블록 안에서 시작된 어떤 코루틴이 완료될 때까지 기다려요.

예시를 볼게요.

// 초를 초 단위로 표현하기 위해 kotlin.time.Duration 임포트
import kotlin.time.Duration.Companion.seconds

import kotlinx.coroutines.*

// 코루틴 컨텍스트가 디스패처를 지정하지 않으면,
// CoroutineScope.launch()는 Dispatchers.Default를 사용함
//sampleStart
suspend fun main() {
    // 코루틴 하위 트리의 루트
    coroutineScope { // this: CoroutineScope
        this.launch {
            this.launch {
                delay(2.seconds)
                println("Child of the enclosing coroutine completed")
            }
            println("Child coroutine 1 completed")
        }
        this.launch {
            delay(1.seconds)
            println("Child coroutine 2 completed")
        }
    }
    // coroutineScope 안의 모든 자식이 완료된 후에만 실행됨
    println("Coroutine scope completed")
}
//sampleEnd

이 예시에서는 디스패처가 지정되지 않았으므로, coroutineScope() 블록 안의 CoroutineScope.launch() 빌더 함수들은 현재 컨텍스트를 상속해요. 그 컨텍스트에 지정된 디스패처가 없으면 CoroutineScope.launch()는 공유 스레드 풀에서 실행되는 Dispatchers.Default를 사용해요.

코루틴 스코프에서 코루틴 빌더 추출하기

어떤 경우에는 CoroutineScope.launch() 같은 코루틴 빌더 호출을 별도의 함수로 추출하고 싶을 수 있어요.

다음 예시를 살펴볼게요.

suspend fun main() {
    coroutineScope { // this: CoroutineScope
        // CoroutineScope가 리시버인 위치에서 CoroutineScope.launch() 호출
        this.launch { println("1") }
        this.launch { println("2") }
    } 
}

this.launch를 명시적 this 표현식 없이 launch로도 쓸 수 있어요. 이 예시들은 그것이 CoroutineScope의 확장 함수임을 강조하기 위해 명시적 this 표현식을 사용해요.

리시버가 있는 람다가 Kotlin에서 어떻게 동작하는지에 대한 자세한 내용은 리시버가 있는 함수 리터럴을 참고하세요.

coroutineScope() 함수는 CoroutineScope 리시버를 가진 람다를 받아요. 이 람다 안에서 암시적 리시버는 CoroutineScope이므로, CoroutineScope.launch()CoroutineScope.async() 같은 빌더 함수들이 그 리시버의 확장 함수로 분해돼요.

코루틴 빌더를 다른 함수로 추출하려면 그 함수가 CoroutineScope 리시버를 선언해야 해요. 그렇지 않으면 컴파일 오류가 발생해요.

import kotlinx.coroutines.*
//sampleStart
suspend fun main() {
    coroutineScope {
        launchAll()
    }
}

fun CoroutineScope.launchAll() { // this: CoroutineScope
    // CoroutineScope에서 .launch() 호출
    this.launch { println("1") }
    this.launch { println("2") } 
}
//sampleEnd
/* -- CoroutineScope를 리시버로 선언하지 않고 launch를 호출하면 컴파일 오류 발생 --

fun launchAll() {
    // 컴파일 오류: this가 정의되지 않음
    this.launch { println("1") }
    this.launch { println("2") }
}
 */

이 예시에서 launchAll() 함수는 suspend 키워드가 필요하지 않아요. 현재 CoroutineScope에서 코루틴을 시작하고 즉시 반환하기 때문이에요. 함수를 반환하기 전에 일시 중지하고 재개하는 것이 의미 있을 때만 suspend로 표시해요.

코루틴 빌더 함수

코루틴 빌더 함수는 실행할 코루틴을 정의하는 suspend 람다를 받는 함수예요. 몇 가지 예시:

코루틴 빌더 함수는 실행되기 위해 CoroutineScope가 필요해요. 이는 기존 스코프이거나 coroutineScope(), runBlocking(), withContext() 같은 헬퍼 함수로 만든 것일 수 있어요. 각 빌더는 코루틴이 어떻게 시작되고 결과와 어떻게 상호작용하는지 정의해요.

CoroutineScope.launch()

CoroutineScope.launch() 코루틴 빌더 함수는 CoroutineScope의 확장 함수예요. 기존 코루틴 스코프 안에서 스코프의 나머지를 막지 않고 새 코루틴을 시작해요.

결과가 필요 없거나 그것을 기다리지 않으려 할 때 다른 작업과 나란히 작업을 실행하려면 CoroutineScope.launch()를 사용해요.

// 밀리초 단위로 기간을 표현할 수 있게 하는 kotlin.time.Duration 임포트
import kotlin.time.Duration.Companion.milliseconds

import kotlinx.coroutines.*

suspend fun main() {
    withContext(Dispatchers.Default) {
        performBackgroundWork()
    }
}

//sampleStart
suspend fun performBackgroundWork() = coroutineScope { // this: CoroutineScope
    // 스코프를 막지 않고 실행되는 코루틴 시작
    this.launch {
        // 배경 작업을 흉내 내기 위해 일시 중단
        delay(100.milliseconds)
        println("Sending notification in background")
    }

    // 이전 코루틴이 일시 중단되는 동안 메인 코루틴은 계속됨
    println("Scope continues")
}
//sampleEnd

이 예시를 실행한 뒤, main() 함수가 CoroutineScope.launch()에 의해 막히지 않고 코루틴이 배경에서 작업하는 동안 다른 코드를 계속 실행한다는 것을 알 수 있어요.

CoroutineScope.launch() 함수는 Job 핸들을 반환해요. 이 핸들을 사용해 시작된 코루틴이 완료될 때까지 기다릴 수 있어요. 자세한 내용은 취소와 타임아웃을 참고하세요.

CoroutineScope.async()

CoroutineScope.async() 코루틴 빌더 함수는 CoroutineScope의 확장 함수예요. 기존 코루틴 스코프 안에서 동시 계산을 시작하고, 최종 결과를 나타내는 Deferred 핸들을 반환해요. .await() 함수를 사용해 결과가 준비될 때까지 코드를 일시 중단해요.

// 밀리초 단위로 기간을 표현할 수 있게 하는 kotlin.time.Duration 임포트
import kotlin.time.Duration.Companion.milliseconds

import kotlinx.coroutines.*

//sampleStart
suspend fun main() = withContext(Dispatchers.Default) { // this: CoroutineScope
    // 첫 페이지 다운로드 시작
    val firstPage = this.async {
        delay(50.milliseconds)
        "First page"
    }

    // 두 번째 페이지 다운로드를 병렬로 시작
    val secondPage = this.async {
        delay(100.milliseconds)
        "Second page"
    }

    // 두 결과를 기다렸다가 비교
    val pagesAreEqual = firstPage.await() == secondPage.await()
    println("Pages are equal: $pagesAreEqual")
}
//sampleEnd

runBlocking()

runBlocking() 코루틴 빌더 함수는 코루틴 스코프를 만들고, 그 스코프에서 시작된 코루틴이 끝날 때까지 현재 스레드를 막아요.

runBlocking()은 non-suspending 코드에서 일시 중단 코드를 호출할 다른 방법이 없을 때만 사용해요.

import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.*

// 변경할 수 없는 서드파티 인터페이스
interface Repository {
    fun readItem(): Int
}

object MyRepository : Repository {
    override fun readItem(): Int {
        // 일시 중단 함수로 연결
        return runBlocking {
            myReadItem()
        }
    }
}

suspend fun myReadItem(): Int {
    delay(100.milliseconds)
    return 4
}

코루틴 디스패처

코루틴 디스패처는 코루틴이 실행에 사용할 스레드나 스레드 풀을 제어해요. 코루틴은 항상 단일 스레드에 묶이지는 않아요. 디스패처에 따라 한 스레드에서 일시 중지하고 다른 스레드에서 재개할 수 있죠. 이 덕분에 코루틴마다 별도의 스레드를 할당하지 않고도 많은 코루틴을 동시에 실행할 수 있어요.

코루틴이 다른 스레드에서 일시 중지하고 재개할 수 있더라도, 코루틴이 일시 중지되기 전에 작성된 값들은 재개될 때 같은 코루틴 안에서 여전히 사용 가능하다는 것이 보장돼요.

디스패처는 코루틴 스코프와 함께 작동해 코루틴이 언제 어디서 실행되는지 정의해요. 코루틴 스코프가 코루틴의 수명 주기를 제어하는 동안, 디스패처는 실행에 어떤 스레드가 사용되는지 제어해요.

모든 코루틴에 디스패처를 지정할 필요는 없어요. 기본적으로 코루틴은 부모 스코프에서 디스패처를 상속해요. 코루틴을 다른 컨텍스트에서 실행하려면 디스패처를 지정할 수 있어요.

코루틴 컨텍스트가 디스패처를 포함하지 않으면, 코루틴 빌더들은 Dispatchers.Default를 사용해요.

kotlinx.coroutines 라이브러리는 사용 사례마다 다른 디스패처를 포함해요. 예를 들어 Dispatchers.Default는 공유 스레드 풀에서 코루틴을 실행하는데, 메인 스레드와 분리된 배경에서 작업을 수행해요. 이것은 데이터 처리 같은 CPU 집약적 연산에 이상적인 선택이죠.

CoroutineScope.launch() 같은 코루틴 빌더에 디스패처를 지정하려면 인자로 전달해요.

suspend fun runWithDispatcher() = coroutineScope { // this: CoroutineScope
    this.launch(Dispatchers.Default) {
        println("Running on ${Thread.currentThread().name}")
    }
}

또는 withContext() 블록을 사용해 그 안의 모든 코드를 지정된 디스패처에서 실행할 수도 있어요.

// 밀리초 단위로 기간을 표현할 수 있게 하는 kotlin.time.Duration 임포트
import kotlin.time.Duration.Companion.milliseconds

import kotlinx.coroutines.*

//sampleStart
suspend fun main() = withContext(Dispatchers.Default) { // this: CoroutineScope
    println("Running withContext block on ${Thread.currentThread().name}")

    val one = this.async {
        println("First calculation starting on ${Thread.currentThread().name}")
        val sum = (1L..500_000L).sum()
        delay(200L)
        println("First calculation done on ${Thread.currentThread().name}")
        sum
    }

    val two = this.async {
        println("Second calculation starting on ${Thread.currentThread().name}")
        val sum = (500_001L..1_000_000L).sum()
        println("Second calculation done on ${Thread.currentThread().name}")
        sum
    }

    // 두 계산을 기다렸다가 결과 출력
    println("Combined total: ${one.await() + two.await()}")
}
//sampleEnd

Dispatchers.IODispatchers.Main 같은 다른 디스패처를 포함해 코루틴 디스패처와 그 용도에 대해 더 배우려면 코루틴 컨텍스트와 디스패처를 참고하세요.

코루틴과 JVM 스레드 비교

코루틴은 JVM에서 스레드처럼 코드를 동시에 실행하는 일시 중단 가능한 계산이지만, 내부적으로는 다르게 동작해요.

스레드는 운영 체제가 관리해요. 스레드는 여러 CPU 코어에서 작업을 병렬로 실행할 수 있으며, JVM에서 동시성의 표준적인 접근 방식을 나타내요. 스레드를 만들면 운영 체제가 그 스택에 메모리를 할당하고 스레드 사이를 전환하기 위해 커널을 사용해요. 이것은 스레드를 강력하게 하지만 자원도 많이 쓰게 해요. 각 스레드는 보통 몇 메가바이트의 메모리가 필요하고, 전형적으로 JVM은 한 번에 수천 개의 스레드만 처리할 수 있어요.

반면 코루틴은 특정 스레드에 묶이지 않아요. 한 스레드에서 일시 중지하고 다른 스레드에서 재개할 수 있으므로, 많은 코루틴이 같은 스레드 풀을 공유할 수 있어요. 코루틴이 일시 중지되면 스레드는 막히지 않고 다른 작업을 실행할 자유가 남아요. 이 덕분에 코루틴은 스레드보다 훨씬 가볍고, 시스템 자원을 고갈시키지 않으면서 한 프로세스에서 수백만 개를 실행할 수 있어요.

50,000개의 코루틴이 각각 5초를 기다린 뒤 마침표(.)를 출력하는 예시를 살펴볼게요.

import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.*

suspend fun main() {
    withContext(Dispatchers.Default) {
        // 각각 5초를 기다린 뒤 마침표를 출력하는 50,000개의 코루틴 시작
        printPeriods()
    }
}

//sampleStart
suspend fun printPeriods() = coroutineScope { // this: CoroutineScope
    // 각각 5초를 기다린 뒤 마침표를 출력하는 50,000개의 코루틴 시작
    repeat(50_000) {
        this.launch {
            delay(5.seconds)
            print(".")
        }
    }
}
//sampleEnd

이제 JVM 스레드를 사용한 같은 예시를 볼게요.

import kotlin.concurrent.thread

fun main() {
    repeat(50_000) {
        thread {
            Thread.sleep(5000L)
            print(".")
        }
    }
}

이 버전은 각 스레드가 자기만의 메모리 스택이 필요하기 때문에 훨씬 많은 메모리를 사용해요. 50,000개의 스레드에 대해서는 최대 100GB까지 될 수 있는 반면, 같은 수의 코루틴은 약 500MB면 충분해요.

운영 체제, JDK 버전, 설정에 따라 JVM 스레드 버전은 한 번에 너무 많은 스레드가 실행되는 것을 피하기 위해 out-of-memory 오류를 던지거나 스레드 생성을 느리게 할 수 있어요.

더 알아보기 (Learn more)