[Kotlin in Action 2/e] 14장 코루틴

왕왕조현·2026년 2월 17일

Kotlin in Action 2/e

목록 보기
14/18
post-thumbnail

안녕하세요!

코루틴의 개념에 대한 정리글로 돌아온 개발자 꿈나무 김조현입니다.

이번 글에서는 동시성과 병렬성이 무엇인지, 코루틴이 무엇이고 어떻게 사용되는지 등에 대해 정리해보겠습니다.


동시성과 병렬성이란?

동시성은 코드를 여러 부분으로 나눠서 동시에 수행할 수 있는 능력을 말하고, 병렬성은 여러 작업을 여러 CPU 코어에서 물리적으로 동시에 실행하는 것을 말합니다.


코틀린의 동시성 처리 방법은?

코틀린은 코루틴이라는 것을 사용해 동시성을 처리합니다. 코루틴은 비동기적으로 실행되는 넌블로킹 동시성 코드를 우아하게 작성할 수 있게 해줍니다. 스레드와 비교했을 때 훨씬 더 가볍게 작동하며, 구조화된 동시성을 통해 동시성 작업과 그 생명주기를 관리할 수 있는 기능도 제공합니다.

코틀린에서 코루틴을 사용할 때 기본적인 추상화인 일시 중단 함수를 사용합니다.

일시 중단 함수는 스레드를 블록시키는 단점이 없이 순차적 코드처럼 보이는 동시성 코드를 작성할 수 있게 해줍니다.


스레드와 코루틴의 차이는?

JVM에서 병렬 프로그래밍과 동시성 프로그래밍을 위한 고전적인 추상화는 스레드를 사용하는 것입니다. 스레드는 서로 독립적으로 동시에 실행되는 코드 블록을 지정할 수 있게 해줍니다.

thread 함수를 사용하면 새 스레드를 시작할 수 있습니다.

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

스레드는 애플리케이션을 더 반응성 있게 만들어주고, 여러 코어에 작업을 분산시켜 현대적 시스템을 더 효율적으로 사용할 수 있게 해주지만, 스레드를 사용하는 데는 비용이 듭니다.

또한 스레드가 어떤 작업이 완료되길 기다리는 동안에는 블록되어 다른 의미있는 작업을 할 수 없으며, 그냥 자면서 시스템 자원을 차지합니다.

코틀린은 스레드에 대한 대안으로 코루틴이라는 추상화를 도입했습니다. 스레드보다 코루틴을 사용했을 때 생기는 장점은 다음과 같습니다.

  • 코루틴은 초경량 추상화입니다. 그렇기에 생성하고 관리하는 비용이 저렴합니다.
  • 코루틴은 시스템 자원을 블록시키지 않고 실행을 일시 중단할 수 있으며, 나중에 중단된 지점에서 실행을 재개할 수 있습니다.
  • 코루틴은 구조화된 동시성이라는 개념을 통해 동시 작업의 구조와 계층을 확립하며, 취소 및 오류 처리를 위한 메커니즘을 제공합니다.

일시 중단 함수란?

일시 중단 함수는 코루틴의 가장 기본적인 구성 요소이자 실행을 잠시 멈출 수 있는 함수를 의미합니다. 동시성을 사용하지 않고 여러 함수를 호출하는 코드를 작성해보면 아래와 같습니다.

fun login(credentials: Credentials): UserID
fun loadUserData(userID: UserID): UserData
fun showData(data: UserData)

fun showUserInfo(credentials: Credentials) {
	val userID = login(credentials)
	val userData = loadUserData(userID)
	showData(userData)
}

이 작업에서는 함수에서 사용되는 시간보다 데이터를 불러오며 대기하는 과정에서 생기는 시간이 더 많아 계산 자원을 낭비하게 됩니다. 이제 일시 중단 함수를 사용해 구현해보겠습니다. 일시 중단 함수는 함수명 앞에 suspend 변경자를 붙이면 됩니다.

suspend fun login(credentials: Credentials): UserID
suspend fun loadUserData(userID: UserID): UserData
fun showData(data: UserData)

suspend fun showUserInfo(credentials: Credentials) {
	val userID = login(credentials)
	val userData = loadUserData(userID)
	showData(userData)
}

suspend 변경자를 붙은 것은 함수가 실행을 잠시 멈출 수도 있다는 의미입니다. 일시 중단은 기저 스레드를 블록시키지 않는 대신 함수 실행이 중단되면 다른 코드가 같은 스레드에서 실행될 수 있도록 합니다.

이 때는 코드 구조를 변경하지 않으며 순차적으로 보이고 동작합니다.

일시 중단 함수는 실행을 일시 중단할 수 있기 때문에 일반 코드 아무 곳에서나 호출할 수 없습니다. 일시 중단 함수는 실행을 중단할 수 있는 코드 블록 안에서만 호출할 수 있습니다. 일반적인 일시 중단 코드가 아닌 코드에서 일시 중단 함수를 호출하려고 하면 오류가 발생합니다.

맨 처음에 일시 중단 함수를 호출하는 가장 간단한 방법은 프로그램의 main 함수를 suspend로 하는 것입니다. 이는 일반적으로 규모가 작은 프로그램에서 자주 쓰입니다.

더 범용적이고 강력한 방법은 코루틴 빌더 함수를 사용하는 것입니다. 코루틴 빌더는 새로운 코루틴을 생성하는 역할을 하며, 일시 중단 함수를 호출하기 위한 일반적인 진입점으로 사용됩니다.

코루틴 빌더 함수는 다음과 같습니다.

  • runBlocking은 블로킹 코드와 일시 중단 함수를 연결할 때 쓰입니다.
  • launch는 값을 반환하지 않는 새로운 코루틴을 시작할 때 쓰입니다.
  • async는 비동기적으로 값을 계산할 때 쓰입니다.

runBlocking 함수

runBlocking 함수를 사용하면 새 코루틴을 생성하고 실행하며, 해당 코루틴이 완료될 때까지 현재 스레드를 블록시킵니다.

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

suspend fun doSomethingSlowly() {
	delay(500.milliseconds)
	println("I'm done")
}

fun main() = runBlocking {
	doSomethingSlowly()
}

runBlocking을 사용할 때는 하나의 스레드를 블로킹합니다. 그러나 이 코루틴 안에서는 추가적인 자식 코루틴을 얼마든지 시작할 수 있고, 이 자식 코루틴들은 다른 스레드를 더 이상 블록시키지 않습니다.


launch 함수

launch 함수는 새로운 자식 코루틴을 시작하는데 쓰입니다. 이는 일반적으로 어떤 코드를 실행하되 그 결과값을 기다리지 않는 경우에 적합합니다.

private var zeroTime = System.currentTimeMillis()
fun log(message: Any?) =
	println("${System.currentTimeMillis() - zeroTime} " +
		"[${Thread.currentThread().name}] $message")
		
fun main() = runBlocking {
	log("The first, parent, coroutine starts")
	launch {
		log("The second coroutine starts and is ready to be suspended")
		delay(100.milliseconds)
		log("The second coroutine is resumed")
	}
	launch {
		log("The third coroutine can run in the meantime")
	}
	log("The first coroutine has launched two more coroutines")
}

// 117 [main @coroutine#1] The first, parent, coroutine starts
// 125 [main @coroutine#1] The first coroutine has launched two more coroutines
// 127 [main @coroutine#2] The second coroutine starts and is ready to be suspended
// 143 [main @coroutine#3] The third coroutine can run in the meantime
// 241 [main @coroutine#2] The second coroutine is resumed

이 코드를 실행하면 다음과 같은 결과를 얻을 수 있습니다. 이 코드에는 3개의 코루틴이 시작됩니다. 첫 번째는 runBlocking에 의해 시작된 부모 코루틴이고, 두 번째와 세 번째는 launch 호출에 의한 자식 코루틴입니다.

coroutine#2가 delay 함수를 호출하면 코루틴이 일시 중단 됩니다. 이를 일시 중단 지점이라고 하며, 이 때 coroutine#2는 지정된 시간 동안 일시 중단되고, 메인 스레드는 다른 코루틴이 실행될 수 있게 합니다. 그 결과 coroutine#3이 작업을 시작할 수 있습니다. 지정된 100 밀리초 후 coroutine#2가 작업을 재개하고 프로그램이 완료되는 것입니다.

그렇다면 일시 중단된 코루틴은 어디로 갈까요? 일시 중단될 때 해당 시점의 상태 정보는 메모리에 저장되고, 이 정보를 바탕으로 나중에 실행을 복구하고 재개할 수 있습니다.

launch를 사용하면 새로운 기본 코루틴을 시작할 수 있습니다. launch 함수는 Job 타입의 객체를 반환하는데, 이를 사용하면 코루틴 실행을 제어할 수 있습니다.


async 빌더

async 빌더 함수는 비동기 계산을 수행할 때 사용할 수 있습니다. async 함수는 launch와 마찬가지로 실행할 코드를 코루틴으로 전달할 수 있지만 Deferred<T> 인스턴스입니다. Deferred를 사용해 주로 할 일은 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")
	val myFirstDeferred = async { slowlyAddNumbers(2, 2) }
	val mySecondDeferred = async { slowlyAddNumbers(4, 4) }
	log("Waiting for the deferred value to be available")
	log("The first result: ${myFirstDeferred.await()}")
	log("The second result: ${mySecondDeferred.await()}")
}

// 119 [main @coroutine#1] Starting the async computation
// 126 [main @coroutine#1] Waiting for the deferred value to be available
// 131 [main @coroutine#2] Waiting a bit before calculating 2 + 2
// 142 [main @coroutine#3] Waiting a bit before calculating 4 + 4
// 343 [main @coroutine#1] The first result: 4
// 543 [main @coroutine#1] The second result: 8

이 코드를 실행하면 다음과 같은 결과가 나옵니다. async를 호출할 때마다 새로운 코루틴을 시작함으로써 두 계산이 동시에 일어나게 했습니다. launch와 마찬가지로 async를 호출한다고 해서 코루틴이 일시 중단되는 것은 아닙니다. await을 호출하면 그 Deferred에서 결괏값이 사용 가능해질 때까지 루트 코루틴이 일시 중단됩니다.

Deferred 객체는 아직 사용할 수 없는 값을 의미하기에 그 값을 계산하거나 어디서 읽어와야만 합니다.


어디서 코드를 실행할지 정하는 방법은?

코루틴에서는 디스패처를 사용해 코루틴을 실행할 스레드를 정합니다. 디스패처를 선택함으로써 코루틴을 특정 스레드로 제한하거나 스레드 풀에 분산시킬 수 있으며, 코루틴이 한 스레드에서만 실행될지 여러 스레드에서 실행될지 결정할 수 있습니다.


스레드 풀이란?

스레드 풀은 스레드 집합을 관리하고, 집합에 속한 스레드들 위에서 코루틴 실행을 허용합니다. 작업이 실행될 때마다 새 스레드를 할당하는 대신, 스레드 풀은 일정한 수의 스레드를 유지하면서 내부 논리와 구현에 따라 들어오는 작업을 분배합니다.


디스패처를 선택하기

코루틴은 기본적으로 부모 코루틴에서 디스패처를 상속 받으므로 모든 코루틴에 대해 명시적으로 디스패처를 지정할 필요가 없습니다. 하지만 선택할 수 있는 디스패처들이 있습니다.


Dispatchers.Default

Dispatchers.Default는 가장 일반적인 디스패처로, 일반적인 작업에 사용할 수 있습니다. 이 디스패처는 CPU 코어 수만큼의 스레드로 구성된 스레드 풀을 기반으로 합니다. 즉, 기본 디스패처에서 코루틴을 스케줄링하면 여러 스레드에서 코루틴이 분산돼 실행되며, 멀티코어 시스템에서는 병렬로 실행될 수 있습니다.


Dispatchers.Main

UI 프레임워크를 사용할 때는 특정 작업을 UI 스레드나 메인 스레드라고 불리는 특정 스레드에서 실행해야할 때가 있습니다. 예를 들어 사용자 인터페이스 요소를 다시 그리는 작업같은 것을 안전하게 실행하려면 디스패치할 때 Dispatchers.Main을 사용해야 합니다.


Dispatchers.IO

기본 디스패처의 스레드 수는 CPU 코어 수와 동일하기 떄문에, 예를 들어 듀얼 코어 기계에서 2개의 스레드를 블로킹하는 작업을 호출하면 기본 스레드 풀이 소진돼 다른 코루틴은 완료될 때까지 실행되지 못합니다. 이런 상황을 처리하기 위해 설계된 것이 Dispatchers.IO입니다.

이 디스패처에서 실행된 코루틴은 자동으로 확장되는 스레드 풀에서 실행되며 CPU 집약적이지 않은 작업에 적합합니다.


코루틴 빌더에 디스패처를 전달하기

runBlocking, launch, async 같은 모든 코루틴 빌더 함수는 코루틴 디스패처를 명시적으로 지정할 수 있습니다.

fun main() {
	runBlocking {
		log("Doing some work")
		launch(Dispatchers.Default) {
			log("Doing some background work")
		}
	}
}

launch 함수에 기본 디스패처를 인자로 전달해 코루틴을 시작합니다.


코루틴 안에서 디스패처를 바꾸기

이미 실행 중인 코루틴에서 디스패처를 바꿀 때는 withContext 함수에 다른 디스패처를 전달하면 됩니다.

launch(Dispatchers.Default) {
	val result = performBackgroundOperation()
	withContext(Dispatchers.Main) {
		updateUI(result)
	}
}

이 코드를 실행하면 기본 디스패처에서 메인 디스패처로 변경해서 UI를 갱신하는 것입니다.


코루틴과 디스패처는 스레드 안전성 문제에 대해 완벽하지 않다

한 코루틴은 항상 순차적으로 실행됩니다. 즉, 어느 단일 코루틴의 어떤 부분도 병렬로 실행되지 않으며 이는 단일 코루틴에 연관된 데이터가 전형적인 동기화 문제를 일으키지 않는다는 것을 의미합니다. 하지만 여러 코루틴이 동일한 데이터를 읽거나 변경하는 경우에는 문제가 발생합니다.

fun main() {
	runBlocking {
		var x = 0
		repeat(10_000) {
			launch(Dispatchers.Default) {
				x++
			}
		}
		delay(1.seconds)
		println(x)
	}
}
// 9,923

이 경우는 예상한 숫자인 10000보다 작은 결과가 나타납니다. 여러 코루틴이 같은 데이터를 수정하고 있기 때문에 다중 스레드 디스패처에서 실행되면 일부 증가 작업이 서로의 결과를 덮어쓰는 상황이 발생할 수 있기 때문입니다.
이런 상황을 해결하기 위해서 코루틴은 Mutex 잠금을 제공하며, 이를 통해 코드 임계 영역이 한 번에 하나의 코루틴만 실행되게 보장할 수 있습니다.

fun main() {
	runBlocking {
		val mutex = Mutex()
		var x = 0
		repeat(10_000) {
			launch(Dispatchers.Default) {
				mutex.withLock {
					x++
				}
			}
		}
		delay(1.seconds)
		println(x)
	}
}
// 10000

코루틴을 사용할 때는 스레드를 사용할 떄와 같은 동시성 문제가 발생합니다. 데이터가 한 코루틴에만 연관돼 있다면 기본적으로 예상한 대로 코드가 동작하지만 여러 코루틴이 병렬로 동일한 데이터를 변경한다면 스레드와 마찬가지로 동기화나 잠금 처리를 해야합니다.


코루틴은 코루틴 콘텍스트에 추가적인 정보를 담고 있다

withContext 함수에 서로 다른 디스패처를 인자로 전달했습니다. 하지만 파라미터의 이름을 보면 CoroutineDispatcher가 아니라 CoroutineContext입니다.

각 코루틴은 추가적인 문맥 정보를 담고 있는데, 이 문맥은 CoroutineContext라는 형태로 제공됩니다. 이는 여러 요소로 이뤄진 집합과 같습니다.


마무리입니다!

이번 글에서는 코루틴에 대한 기초 개념에 대해 정리해봤습니다.

이전 우테코 오픈 미션을 진행해보면서 앱을 만들었는데, 이 때 코루틴을 사용한 경험이 있습니다. 그 당시에는 앱의 UI와 데이터 전송을 부드럽게 하기 위한 목적으로 사용했었습니다. 개념에 대해서만 짧게 공부하고 사용했었기에 다루는데 어려움을 가졌었습니다.

이번 기회에 코루틴에 공부해보니 코루틴의 철학과 스레드보다 유용한 점에 대해 알게 되었고, 디스패처를 상황에 따라 사용하며 명시적으로 지정하며 스레드를 효율적으로 지정할 수 있다는 활용 방법에 대해서도 배울 수 있었습니다.

다음에는 구조화된 동시성에 대한 정리글로 돌아오겠습니다.

읽어주셔서 감사합니다!🙂‍↕️

profile
천천히, 꾸준히, 한 걸음씩

0개의 댓글