[Kotlin in Action] 14장. 코루틴

도서 <코틀린 인 액션 2/e>를 읽으며 코틀린에 대해 이해한 내용을 정리한 글이다.
드디어!! 동시성을 공부하며 가장 궁금했던 코루틴에 관한 첫 번째 장이다.
14장에서는 스레드와 코루틴의 차이를 통해 기본적인 코루틴 개념을 이해하고, 어떨 때 어떻게 사용할 수 있는지 배운다. 또한 동시성을 만드는 다른 접근 방법들과 비교하면서, 비슷하면서도 다른 기술들을 한번에 정리해볼 수 있었다.
14.1 동시성과 병렬성
코루틴을 다루기 전, 동시성과 병렬성의 차이를 가볍게 짚고 넘어가자. 무겁게 짚고 넘어가고 싶다면 여기로
동시성
- 여러 작업을 동시에 실행하는 것
- 물리적으로 동시에 실행하지 않고, 동시성 태스크를 전환해 실행하는 것도 동시성을 달성한다.
병렬성
- 여러 작업을 여러 CPU 코어에서 물리적으로 동시에 실행하는 것
코루틴을 사용하면 동시성 계산과 병렬성 계산을 모두 할 수 있다.
14.2 스레드와 코루틴의 차이
코루틴은 매우 매우 가벼운 스레드라고 볼 수 있다. 코틀린 언어의 관점으로 스레드와 코루틴이 어떻게 다른지 알아보자.
스레드
스레드를 사용하면 독립적으로 동시에 실행되는 코드 블록을 지정할 수 있다.
import kotlin.concurrent.thread
fun main() {
println("I'm on ${Thread.currentThread().name}")
thread {
println("And I'm on ${Thread.currentThread().name}")
}
}
// I'm on main
// And I'm on Thread-0
- JVM에서 생성하는 스레드는 운영체제가 관리하는 스레드 시스템 스레드 이다.
- 운영체제 스레드는 메모리 할당과 컨텍스트 스위칭으로 인해 비용이 많이 든다. 효과적으로 관리할 수 있는 건 한 번에 몇 천 개의 스레드뿐이다.
- 스레드가 어떤 작업이 완료되길 기다리는 동안에는 블로킹된다.
- 독립적인 프로세스이므로 작업을 관리하고 조정하기 어렵다. 직접 조작하고 관리할 수 있는 ‘계층’의 개념이 없다.
코루틴
코루틴은 일시 중단 가능한 계산을 나타내는 포괄적인 개념이다.
- 스레드의 초경량 추상화로, 일반 노트북에서도 10만 개 이상의 코루틴을 쉽게 실행 가능하다.
- 시스템 자원을 블록시키지 않고 실행을 일시 중단할 수 있으며, 나중에 중단된 지점에서 실행을 재개할 수 있다. 비동기 작업에서 블로킹 스레드보다 효율적이다.
- 구조화된 동시성을 통해 동시 작업의 구조와 계층을 확립한다. 이를 통해 취소 및 오류 처리를 위한 메커니즘을 활용할 수 있다.
- 예를 들어 동시 계산의 일부가 실패했을 때, 자식으로 시작된 다른 코루틴들도 함께 취소되도록 보장한다.
- 하나 이상의 JVM 스레드에서 실행된다. JVM 스레드와 운영체제 스레드 간의 일대일 결합을 해소한다.
cf. 자바의 프로젝트 룸(Project Loom)
- 자바 21에서도 가상 스레드라는 경량 동시성을 도입해 JVM 스레드와 운영체제 스레드 간의 일대일 결합을 해소하려 했다.
- 프로젝트 룸의 주요 목표는 기존의 I/O 중심 레거시 코드를 가상 스레드로 포팅할 수 있게 하는 것이다. 이는 큰 장점이지만, 반면 호환성을 위해 레거시 코드의 변경을 최소화하다보니 빠른 로컬 계산 CPU 작업 을 하는 함수인지 느린 네트워크 I/O 함수인지 언어 상에서 구별을 할 수가 없다.
- 반면 코루틴은
suspend키워드를 통해 성격이 다른 두 작업을 코드 상에서 읽고 구별할 수 있다. - cf. 우아한 형제들의 가상 스레드 관련 아티클
14.3 일시 중단 함수
코루틴 문법으로는 잠시 멈출 수 있는 함수를 만들 수 있다. 이는 스레드 블로킹으로 인한 문제를 해결한다.
코루틴이 다른 동시성 접근 방식에 비해 지니는 장점은 대부분의 경우 코드 형태를 크게 변경할 필요가 없다는 점이다. 코드는 여전히 순차적으로 보인다.
suspend fun login(credentials: Credentials): UserId
suspend fun loadUserData(userId: UserId): UserData
fun showData(data: UserData)
suspend fun showUserInfo(credentials: Credentilas) {
val userId = login(credentials)
val userData = loadUserData(userId)
showData(userData)
}
함수에
suspend변경자를 붙이면, 함수가 다른 작업을 기다리는 동안 실행이 일시 중단 될 수 있다는 것을 의미한다. 일시 중단은 기저 스레드를 블록시키지 않는다. 대신 다른 코드가 같은 스레드에서 실행될 수 있다.이러한 코드 흐름 제어가, 코드 구조를 변경하지 않고 수행된다. 또한 코드는 여전히 위에서 아래로 명령문 하나하나가 순차적으로 실행된다.
코루틴으로 실행되는 함수도 이 흐름을 고려해 작성해야 한다.
- 예를 들어 네트워크 요청의 경우 케이토 HTTP 클라이언트, 레트로핏, OkHttpj 등 논블로킹 API를 제공하는 라이브러리들로 구성해야 코루틴을 의도대로 쓸 수 있다.
14.4 코루틴 vs. 동시성을 만드는 다른 접근 방법
사실 코루틴이 아니더라도 동시성을 만들 수 있는 다른 기술들이 많이 존재한다. 이들에 비해 코틀린이 지니는 장점을 이해해보자.
14.4.1 콜백(Callback)
비동기 작업이 완료된 후 실행되도록 다른 함수에 전달되는 함수를 콜백이라고 한다.
fun loginAsync(credentilas: Credentials, callback: (UserId) -> Unit)
fun loadUserDataAsync(userId: UserId, callback: (UserData) -> Unit)
fun showData(data: UserData)
fun showUserInfo(credentials: Credentials) {
loginAsync(credentials) { userId ->
loadUserDataAsync(userId) { userData ->
showData(userData
)
}
}
}
- 복잡한 순차적 의존성을 처리해야 할 경우, 콜백을 사용하면 뎁스가 깊어진다. 콜백 지옥!!
요즘은 이를 해결하기 위한 새로운 기술들이 많이 나와서 콜백 지옥이 발생할 일은 거의 없지 않나? 했는데, 아무래도 새로운 기술을 배우는 데 드는 리소스가 있긴 하다. 콜백의 개념 자체는 이벤트 기반 비동기 처리를 할 때 최적의 솔루션이기도 하고! 새로운 기술들도 내부 시스템에서는 콜백으로 처리한다. 콜백 지옥이 왜 발생하는지 이해하고 있다면, 지옥같은 코드를 짤 일은 없을 것이다~
14.4.2 퓨처(Future)
퓨처는 미래에 완료될 비동기 작업의 결과에 대한 약속을 컨테이너 객체로 반환하는 방식이다.
fun loginAsync(credentilas: Credentials): CompletableFuture<UserId>
fun loadUserDataAsync(userId: UserId): CompletableFuture<UserData>
fun showData(data: UserData)
fun showUserInfo(credentials: Credentials) {
loginAsync(credentials)
.thenCompose { loadUserDataAsync(it) }
.thenAccept { showData(it) }
}
- 퓨처는 비동기 작업의 실행과 결과를 분리한다.
loginAsync()를 호출하는 순간 작업은 즉시 백그라운드 스레드에서 실행되도록 예약된다. 함수는 결과가 아니라 미래의 결과를 담을 수 있는 컨테이너인 Future 객체를 즉시 반환한다. 따라서 기저 스레드가 블로킹되지 않는다.thenCompose가 콜백을 예약하여, 작업이 완료되면 다음 작업을 실행하도록 한다.- 콜백 지옥은 해결하지만 함수의 반환 타입을
CompletableFuture로 감싸야 하며, 새로운 연산자의 의미를 알아야 한다.
cf. 위 코드는 선언형 프로그래밍의 특징을 많이 지니고 있다. 선언형 프로그래밍은 try-catch로 감싸는 대신 별도의 예외 처리 체인을 추가해야 한다는 점이 조금 복잡하다.
14.4.3 반응형 스트림(RxJava)
반응형 스트림은 데이터를 비동기적으로 흐르는 연속적인 스트림으로 간주하고, 변화에 반응하여 데이터를 처리하는 방식이다.
fun login(credentilas: Credentials): Single<UserId>
fun loadUserData(userId: UserId): Single<UserData>
fun showData(data: UserData)
fun showUserInfo(credentials: Credentials) {
login(credentials)
.flatMap { loadUserData(it) }
.doOnSuccess { showData(it) }
.subscribe()
}
- 이 역시도 인지적 부가 비용이 있고, 새로운 연산자를 사용해야 한다. 반면, 코루틴은
suspend만 추가하여 스레드 블로킹을 피할 수 있다.
cf. 경험담: 이 친구 역시 선언적이라 기존 코드처럼 예외 처리하기 어렵다. 또, 애플리케이션 전체가 Reactive하면 선언형 코드의 장점이 극대화될 것 같은데, 동기 프로그램의 일부만 비동기 처리하려고 하니 굉장히 불편했다. 반환 값을 하나 하나 비동기 컨테이너로 감싸고.. 흐름 중 하나라도 블로킹되면 효율이 떨어지고.. 동기로 처리해야 하는 부분을 관리하기 어려웠다. 새로운 기술을 익숙하지 않은 채로 쓰니 NPE가 발생하기도 했다(!!) 선언형 코드는 어디서 예외가 발생할지 예측하기 더 어렵더라..
- 코틀린에도
deferred라는 자체적인 퓨처 스타일이 있고, 코루틴용 반응형 스트림 스타일의 추상화인 플로우라는 개념도 존재한다. 기존 코드가 다른 동시성 모델을 사용하고 있다면 코틀린은 그런 요소를 코루틴 친화적인 버전으로 변환할 수 있는 확장 함수를 제공한다.
14.5 코루틴 빌더
일시 중단 함수는 실행을 잠깐 중단할 수 있는 코드 블록 안에서만 호출할 수 있다. 함수가 실행을 일시 중단할 수 있다면 그 함수를 호출하는 함수의 실행도 잠재적으로 일시 중단될 수 있음을 기억해야 한다.
그러려면 맨 처음 일시 중단 함수를 호출할 때 suspend 키워드를 사용해야 하는데, main 함수의 시그니처를 변경하는 게 과연 항상 옳을까? 이를 위해 필요한 것이 코루틴 빌더이다.
코루틴 빌더는 새로운 코루틴을 생성하는 역할을 하며, 일시 중단 함수를 호출하기 위한 일반적인 진입점으로 사용된다.
runBlocking은 블로킹 코드와 일시 중단 함수의 세계를 연결할 때 쓰인다.launch는 값을 반환하지 않는 새로운 코루틴을 시작할 때 쓰인다.async는 비동기적으로 값을 계산할 때 쓰인다.
14.5.1 runBlocking 함수
- 메인 스레드나 테스트 코드처럼 동기적인 환경에서 비동기적인 코루틴이 완료될 때까지 기다리도록 설계된 코루틴 빌더이다.
- 동기 코드들이 작업이 완료될 때까지 먼저 실행되는 것을 막는다.
- 이때, 내부의 코루틴들은 비블로킹 방식(
launch,async)으로 스레드를 해방하며 동시성을 극대화한다.- I/O 작업을 만나
suspend되면 자신을 실행하던 스레드를 즉시 해방한다. I/O 작업을 기다리는 건 OS 커널에 위임되고, 작업이 완료되면 커널이 JVM에게 이를 알려준다. Dispatcher는 유휴 스레드를 사용하여 다음 흐름을 계속 실행한다.
- I/O 작업을 만나
cf. Reactive도 OS 커널의 I/O 위임 능력을 활용해 논블로킹을 구현하는 것.
import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.miliseconds
suspend fun doSomthingSlowly() {
delay(500.miliseconds)
println("I'm done")
}
fun main() = runBlocking {
doSomthingSlowly()
}
- runBlocking은 하나의 스레드를 블로킹한다. 대신 이 코루틴 안에서는 추가적인 자식 스레드를 얼마든지 시작할 수 있고, 이들은 다른 스레드를 더 이상 블록시키지 않는다. 대신, 일시 중단될 때마다 하나의 스레드가 해방되어 다른 코루틴이 코드를 실행할 수 있게 된다.
14.5.2 launch 함수
- 새로운 자식 코루틴을 시작할 때 쓰는 빌더이다.
- 어떤 코드를 실행하되 그 결괏값을 기다리지 않는 경우에 적합하다.
private var zeroTime = Systme.currentTimeMills()
fun lolg(message: Any?) =
println("${System.currentTimeMillis() - zeroTime} " +
"[${Thread.currentThread() - name}] $message")
fun main() = runBlocking {
log ("The first, parent, coroutine starts") // 1
launch {
log("The second coroutine starts and is ready to be suspended") // 3
delay (100 milliseconds)
log("The second coroutine is resumed") // 5
}
launch {
log ("The third coroutine can run in the meantime") // 4
}
log("The first coroutine has launched two more coroutines") // 2
}
- 로깅, 파일/데이터베이스 쓰기처럼 부수 효과를 일으키지 않는 작업에 적합하다.
- launch는 Job 타입의 객체를 반환하는데, 이는 시작된 코루틴에 대한 핸들이다. 코루틴 실행을 제어(취소 등)할 수 있다.
14.5.3 async 함수
- 비동기 계산을 수행할 때 계산 결과를 반환하는 경우 사용하는 빌더이다. 대기 가능한 연산을 의미한다.
- 반환 타입은
Deffered<T>인스턴스다.await이라는 일시 중단 함수로 그 결과를 기다리는 역할을 한다.
suspend fun slowlyAddNumbers(a: Int, b: Int): Int {
log("Waiting a bit before calculating $a + $b")
delay(100.milliseconds * a)
return a + b
}
fun main() = runBlocking {
log("Starting the async computation") // 1
val myFirstDeferred = async { slowlyAddNumbers (2, 2) }
val mySecondDeferred = async { slowlyAddNumbers (4, 4) }
log("Waiting for the deferred value to be available") // 2
// suspend fun이 순차적으로 실행됨
10g("The first result: $(myFirstDeferred await()}") // 3
1og("The second result: ${mySecondDeferred.await()}") // 4
}
async를 호출할 때마다 새로운 코루틴을 시작함으로써 두 계산이 동시에 일어나게 했다.await를 호출하면 그 Deffered(아직 사용할 수 없는 값을 나타냄)에서 결괏값이 사용 가능해질 때까지 루트 코루틴이 일시 중단된다.- 전체 계산 시간은 가장 늦게 끝난 작업이 수행되는 데 걸린 시간이 된다.
- 다른 언어와 다르게 기본적인 코드에서 일시 중단 함수를 순차적으로 호출할 때에는
async, awit를 사용할 필요가 없다. 결과를 기다려야 할 때 사용.
14.6 코루틴 디스패처
코드가 실제로 어떤 스레드에서 실행될까? 디스패처는 코루틴을 실행할 스레드를 결정한다.
- 본질적으로 코루틴은 특정 스레드에 고정되지 않는다.
- 디스패처를 선택하여 코루틴을 특정 스레드로 제한하거나 스레드 풀에 분산시킬 수 있다.
- 일반적으로 부모 디스패처를 상속 받지만 용도에 맞게 지정할 수 있다.
Dispatcher.Default
- 가장 일반적인 디스패처이다.
- CPU 코어 수만큼의 스레드로 구성된 스레드 풀을 기본으로 한다.
Dispatcher.Main
- 사용자 인터페이스 요소를 다시 그리는 등의 작업을 할 때, UI 스레드나 메인 스레드에서 실행해야 할 때가 있다.
- 이런 작업을 안전하게 실행하기 위해 Main을 사용할 수 있다.
Dispatcher.IO
- 블로킹되는 IO 작업을 처리할 때, 자동으로 확장되는 스레드 풀에서 실행된다.
- 예를 들어 데이터이스 시스템과 상호작용하는 서드파티 블로킹 API를 사용할 때를 생각. 외부 API는 코루틴을 염두에 둔 호출이 아니기 때문에 기본 디스패처로 호출하면 스레드 풀이 빨리 소진될 수 있다.
- CPU 집약적이지 않은 작업에 적합하다.
[디스패처를 지정해 코루틴을 시작하기]
launch(Dispathcers.Default) {
// ...
}
[코루틴 내에서 디스패처 바꾸기]
launch(Dispathcers.Default) {
// ...
withContext(Dispathcer.Main) {
updateUI(result)
}
}
코루틴, 디스패처에서의 스레드 안전성
코루틴은 동시성 문제를 알아서 해결해주지 않는다. 여러 코루틴이 동일한 데이터에 접근하는 경우, 스레드 안전성을 고려해야 한다.
fun main() {
runBlocking {
var x = 0
repeat(10_000) {
launch(Dispatchers.Default) {
x++
}
}
delay(1.seconds)
println(x)
}
}
// Kotlin Playground: 9999
- 다중 스레드 디스패처에서 여러 코루틴이 실행되어 일부 작업이 서로의 결과를 덮어쓰게 된다.
fun main() {
runBlocking {
val mutex = Mutex()
var x = 0
repeat(10_000) {
launch(Dispatchers.Default) {
mutex.withLock {
x++
}
}
}
delay(1.seconds)
println(x)
}
}
// Kotlin Playground: 10000
- 코루틴은 이를 방지하기 위해 Mutex 잠금을 제공한다. 코드 임계 영역이 한 번에 하나의 코루틴만 실행되게 보장할 수 있다.
스레드 안전한 데이터 구조에 대해서도 공부해보자!
14.7 코루틴 컨텍스트
코루틴 컨텍스트는 디스패처, 코루틴의 생명 주기, 각종 메타데이터 등 코루틴의 실행 환경에 대한 문맥 정보를 담고 있는 요소이다.
this.CoroutineContext속성에 접근하면 필요한 코루틴 콘텍스트를 확인할 수 있다.- 실제 구현은 코틀린 컴파일러가 처리한다.
- 코루틴 빌더나
withContext함수에 인자를 전달하면 자식 코루틴의 콘텍스트에서 해당 요소를 덮어쓴다.
fun main() {
runBlocking(Dispatchers.IO + CoroutineName("Coolroutine")) {
introspect()
}
}
- 위 코드는 해당 코루틴의 디스패처를
Dispatchers.IO로 설정하고, 코루틴의 이름을 지정하고 있다. - 새로운 코루틴을 만들 때 이 설정을 유연하게 상속하고 덮어쓸 수 있다.
다음 15장은 ‘계층’이 존재하지 않아 작업을 명시적으로 관리하기 어려웠던 문제를 해결하는 구조화된 동시성에 관한 내용이다. 올해가 가기 전에 꼭 공부해보자!