코루틴은 현대 코틀린에서 느린 작업을 다루는 핵심 도구입니다. 서버는 데이터베이스 응답을 기다려야 하고, 앱은 네트워크 요청을 보내면서도 화면을 멈추면 안 됩니다. 파일을 읽고, API를 호출하고, 여러 계산을 동시에 진행하는 일도 흔합니다.
이런 작업을 단순히 순서대로 실행하면 프로그램은 자주 멈춘 것처럼 보입니다. 반대로 스레드를 무작정 많이 만들면 메모리와 운영체제 자원을 빠르게 소모합니다. 코루틴은 이 사이에서 균형을 잡습니다. 읽기 쉬운 순차 코드 형태를 유지하면서, 기다리는 동안 스레드를 낭비하지 않게 해 줍니다.
공식 문서는 코루틴을 스레드보다 가벼운 동시성 도구로 설명합니다. 코루틴은 실행을 중단할 수 있고, 중단되는 동안 시스템 자원을 붙잡아 두지 않아 작은 단위의 동시 작업을 많이 다루는 데 적합합니다.1

코루틴을 사용하려면 라이브러리가 필요하다
suspend 키워드는 코틀린 언어에 포함되어 있습니다. 하지만 launch, async, delay, Dispatchers 같은 실용적인 코루틴 도구는 대부분 kotlinx.coroutines 라이브러리에서 제공합니다.
Gradle Kotlin DSL을 사용한다면 보통 다음 의존성을 추가합니다.
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}그리고 예제에서는 다음 import를 자주 사용합니다.
import kotlinx.coroutines.*안드로이드, 서버 프레임워크, 테스트 환경에서는 이미 일부 코루틴 의존성이 들어와 있을 수도 있습니다. 그래도 핵심은 같습니다. 코루틴을 실행하려면 코루틴을 시작하는 빌더와 실행 범위를 관리하는 스코프가 필요합니다.
스레드와 코루틴은 같은 것이 아니다
스레드는 운영체제가 관리하는 실행 흐름입니다. JVM에서 코드는 결국 스레드 위에서 실행됩니다. 코루틴도 예외가 아닙니다. 코루틴은 스레드를 대체해서 CPU 없이 실행되는 마법이 아닙니다. 코루틴 역시 어느 순간에는 스레드 위에서 실행됩니다.
차이는 기다릴 때 드러납니다.
스레드가 Thread.sleep()으로 잠들면 그 스레드는 아무 일도 하지 못한 채 붙잡힙니다.
fun blockingWork() {
Thread.sleep(1000)
println("작업 완료")
}반면 코루틴에서 delay()를 사용하면 현재 코루틴은 중단되지만 스레드는 다른 일을 할 수 있습니다.
suspend fun suspendingWork() {
delay(1000)
println("작업 완료")
}두 코드는 모두 약 1초 후에 다음 줄로 진행됩니다. 그러나 자원 사용 방식이 다릅니다.
Thread.sleep()은 스레드를 막습니다.delay()는 코루틴을 중단하고 스레드를 반환합니다.중단된 코루틴은 시간이 지나거나 결과가 준비되면 다시 재개됩니다.
그래서 코루틴은 네트워크 요청, 파일 입출력, 데이터베이스 접근처럼 "기다리는 시간이 많은 작업"에 잘 맞습니다. 반대로 CPU를 계속 사용하는 무거운 계산은 코루틴을 쓴다고 자동으로 빨라지지 않습니다. 그런 작업은 적절한 스레드 풀과 병렬 처리 전략이 필요합니다.
suspend 함수는 중단 가능한 함수다
코루틴의 가장 중요한 키워드는 suspend입니다.
suspend fun fetchUserName(): String {
delay(1000)
return "민지"
}suspend 함수는 실행 도중 멈췄다가 나중에 이어서 실행될 수 있는 함수입니다. 공식 문서도 suspend 함수를 실행을 잠시 멈추고 나중에 재개할 수 있는 함수로 설명합니다.2
중요한 점이 있습니다.
suspend를 붙였다고 해서 함수가 자동으로 새 스레드에서 실행되지는 않습니다. suspend는 "이 함수 안에서 중단 지점이 생길 수 있다"는 표시입니다.
suspend 함수는 다음 위치에서 호출할 수 있습니다.
다른
suspend함수 안코루틴 빌더인
launch,async등의 블록 안runBlocking처럼 코루틴 세계로 들어가는 특수한 진입점 안
예를 들어 다음 코드는 자연스럽게 읽힙니다.
suspend fun loadProfile(): String {
val name = fetchUserName()
return "사용자: $name"
}겉으로는 일반 함수 호출처럼 보입니다. 하지만 fetchUserName() 안에서 네트워크 응답을 기다리는 동안 현재 코루틴은 중단될 수 있습니다. 응답이 오면 중단된 지점 다음 줄부터 다시 실행됩니다.
이것이 코루틴의 장점입니다. 콜백을 여러 겹으로 중첩하지 않아도 비동기 코드를 순서대로 읽을 수 있습니다.
runBlocking은 다리일 뿐이다
처음 코루틴을 실험할 때는 main 함수에서 runBlocking을 자주 봅니다.
fun main() = runBlocking {
val profile = loadProfile()
println(profile)
}runBlocking은 현재 스레드를 막고, 그 안의 코루틴이 끝날 때까지 기다립니다. 그래서 일반 main 함수나 간단한 예제에서는 편리합니다.
하지만 실전 코드에서 남용하면 안 됩니다. 특히 서버 요청 처리 코드나 UI 코드 안에서 runBlocking을 사용하면 코루틴의 장점이 사라집니다. 스레드를 막지 않으려고 코루틴을 쓰는데, 다시 스레드를 막아 버리기 때문입니다.
runBlocking은 "코루틴이 아닌 세계에서 코루틴 세계로 들어가는 다리"라고 생각하면 됩니다. 이미 suspend 함수 안에 있다면 보통 runBlocking이 필요하지 않습니다.
CoroutineScope는 코루틴의 생명주기를 정한다
코루틴은 아무 곳에서나 떠다니는 작업이 아닙니다. 코루틴은 반드시 어떤 범위 안에서 실행되어야 합니다. 이 범위가 CoroutineScope입니다.
CoroutineScope는 코루틴의 생명주기를 관리합니다. 쉽게 말해 "이 코루틴들이 언제 시작되고, 언제 끝나며, 언제 취소되어야 하는가"를 정하는 울타리입니다.
가장 기본적인 스코프 생성 함수는 coroutineScope입니다.
suspend fun loadScreen() = coroutineScope {
launch {
delay(300)
println("사용자 정보 로딩")
}
launch {
delay(500)
println("알림 목록 로딩")
}
println("화면 로딩 시작")
}이 함수는 바로 끝나지 않습니다. coroutineScope 안에서 시작한 자식 코루틴이 모두 끝날 때까지 기다립니다. 그래서 위 코드는 "화면 로딩 시작"을 출력한 뒤에도 두 개의 launch 작업이 끝날 때까지 loadScreen()이 완료되지 않습니다.
이 성질이 중요합니다. 호출자는 loadScreen()이 끝났을 때, 그 안에서 시작한 관련 작업도 끝났다고 믿을 수 있습니다.
Job은 코루틴의 작업 핸들이다
launch는 Job을 반환합니다.
suspend fun runJobExample() = coroutineScope {
val job: Job = launch {
repeat(3) { index ->
delay(300)
println("작업 중: $index")
}
}
job.join()
println("작업 종료")
}Job은 코루틴 작업을 가리키는 핸들입니다. 이 핸들을 통해 작업을 기다리거나 취소할 수 있습니다.
자주 쓰는 기능은 다음과 같습니다.
join(): 코루틴이 끝날 때까지 기다립니다.cancel(): 코루틴에 취소를 요청합니다.isActive: 코루틴이 아직 활성 상태인지 확인합니다.
취소는 강제 종료와 다릅니다. 코루틴의 취소는 협력적입니다. 즉, 코루틴이 취소 가능한 지점에 도달하거나 isActive를 확인해야 자연스럽게 멈춥니다.
suspend fun cancellableWork() = coroutineScope {
val job = launch {
while (isActive) {
println("작업 실행 중")
delay(200)
}
}
delay(700)
job.cancel()
job.join()
println("취소 완료")
}delay()는 취소 가능한 중단 함수입니다. 따라서 취소 요청이 들어오면 다음 중단 지점에서 코루틴이 멈출 수 있습니다.
Dispatcher는 어디에서 실행할지 정한다
CoroutineScope가 생명주기를 관리한다면, Dispatcher는 코루틴이 어떤 스레드나 스레드 풀에서 실행될지 정합니다.
대표적인 디스패처는 다음과 같습니다.
Dispatchers.Default: CPU 계산처럼 일반적인 백그라운드 작업에 사용합니다.Dispatchers.IO: 파일, 네트워크, 데이터베이스처럼 입출력이 많은 작업에 사용합니다.Dispatchers.Main: UI 애플리케이션에서 메인 스레드 작업에 사용합니다. 안드로이드에서 화면을 갱신할 때 자주 등장합니다.
예를 들어 파일이나 네트워크 작업은 보통 Dispatchers.IO로 옮깁니다.
suspend fun readUserFromNetwork(): String = withContext(Dispatchers.IO) {
delay(1000)
"서버에서 가져온 사용자"
}withContext는 지정한 컨텍스트에서 블록을 실행하고 결과를 반환합니다. 이 함수 자체도 중단 함수입니다. 블록이 끝날 때까지 현재 코루틴은 중단되고, 결과가 준비되면 다시 이어서 실행됩니다.
주의할 점은 코루틴이 항상 같은 스레드에서 시작하고 끝난다는 보장이 없다는 것입니다. 코루틴은 중단 후 다른 스레드에서 재개될 수 있습니다. 따라서 특정 스레드에 묶인 자원, 예를 들어 UI 업데이트는 반드시 알맞은 디스패처에서 처리해야 합니다.
launch는 결과가 필요 없는 작업에 쓴다
launch는 새 코루틴을 시작하고 Job을 반환합니다. 결과값은 반환하지 않습니다.
suspend fun sendLogExample() = coroutineScope {
launch(Dispatchers.IO) {
delay(300)
println("로그 전송 완료")
}
println("다른 작업 계속 진행")
}이런 작업에 launch가 어울립니다.
로그 전송
알림 발송
캐시 갱신
결과값이 필요 없는 백그라운드 작업
다만 "결과가 필요 없다"는 말이 "아무렇게나 실행해도 된다"는 뜻은 아닙니다. launch도 스코프 안에서 실행해야 합니다. 그래야 부모 작업이 취소될 때 함께 취소되고, 예외도 예측 가능한 흐름으로 전파됩니다.
async와 await는 결과가 필요한 동시 작업에 쓴다
async는 새 코루틴을 시작하고 Deferred<T>를 반환합니다. Deferred는 나중에 완성될 결과를 나타냅니다. 결과가 필요할 때 await()를 호출합니다.
suspend fun loadDashboard(): Dashboard = coroutineScope {
val userDeferred = async(Dispatchers.IO) {
delay(500)
User("민지")
}
val ordersDeferred = async(Dispatchers.IO) {
delay(700)
listOf(Order(1), Order(2))
}
val user = userDeferred.await()
val orders = ordersDeferred.await()
Dashboard(user, orders)
}
data class User(val name: String)
data class Order(val id: Int)
data class Dashboard(val user: User, val orders: List<Order>)여기서 사용자 정보와 주문 목록은 서로 의존하지 않습니다. 따라서 동시에 가져올 수 있습니다. async 두 개를 먼저 시작하고, 나중에 await()로 결과를 기다립니다.
잘못 쓰면 동시성이 사라질 수 있습니다.
suspend fun loadSlowly(): Dashboard = coroutineScope {
val user = async {
delay(500)
User("민지")
}.await()
val orders = async {
delay(700)
listOf(Order(1), Order(2))
}.await()
Dashboard(user, orders)
}이 코드는 첫 번째 async를 시작하자마자 await()합니다. 그래서 첫 작업이 끝난 뒤에야 두 번째 작업을 시작합니다. 동시 실행의 이점이 거의 없습니다.
결과가 필요한 독립 작업은 다음 순서로 작성하는 것이 좋습니다.
async로 먼저 시작합니다.필요한 작업을 모두 시작한 뒤
await()합니다.await()한 결과를 조합합니다.
launch와 async의 차이 정리
둘 다 코루틴을 시작하는 빌더입니다. 차이는 결과를 다루는 방식입니다.
| 구분 | launch | async |
|---|---|---|
| 반환값 | Job | Deferred<T> |
| 결과값 | 없음 | 있음 |
| 기다리는 방법 | join() | await() |
| 주 용도 | 결과가 필요 없는 작업 | 결과가 필요한 동시 계산 |
| 예외 확인 | 예외가 발생하면 부모로 전파 | await()에서 예외를 다시 받으며, 구조화된 스코프 안에서는 부모에도 영향을 줌 |
실무 기준은 단순합니다.
결과가 필요 없으면 launch, 결과가 필요하면 async를 사용합니다. 단지 동시에 실행하고 싶다는 이유만으로 async를 쓰지는 않습니다.
구조화된 동시성은 코루틴을 잃어버리지 않게 한다
코루틴을 제대로 이해하려면 구조화된 동시성을 이해해야 합니다.
구조화된 동시성은 관련 있는 코루틴을 부모-자식 관계로 묶는 원칙입니다. 부모 코루틴은 자식 코루틴이 끝날 때까지 기다립니다. 부모가 취소되면 자식도 취소됩니다. 자식에서 처리되지 않은 예외가 발생하면 부모도 영향을 받습니다. 공식 문서도 코루틴이 부모-자식 트리 구조를 이루며, 부모는 자식 완료를 기다리고 취소와 실패가 계층적으로 전파된다고 설명합니다.2
다음 예제를 봅시다.
suspend fun loadPage() = coroutineScope {
launch {
delay(1000)
println("본문 로딩 완료")
}
launch {
delay(300)
throw IllegalStateException("댓글 로딩 실패")
}
}두 번째 자식 코루틴에서 예외가 발생하면 coroutineScope 전체가 실패합니다. 첫 번째 자식 코루틴도 계속 실행되지 않고 취소됩니다.
이 동작은 처음에는 엄격해 보일 수 있습니다. 하지만 실전에서는 안전합니다. 한 화면을 구성하는 여러 작업 중 하나가 실패했는데 나머지 작업이 몰래 계속 실행되면 상태가 꼬일 수 있습니다. 구조화된 동시성은 "관련 작업은 함께 성공하거나 함께 정리된다"는 규칙을 제공합니다.
취소는 예측 가능해야 한다
코루틴을 취소할 때 중요한 것은 정리 코드입니다. 파일을 닫거나, 임시 상태를 되돌리거나, 로딩 표시를 끄는 작업은 취소되어도 실행되어야 합니다.
suspend fun loadWithCleanup() = coroutineScope {
val job = launch {
try {
delay(1000)
println("데이터 로딩 완료")
} finally {
println("정리 작업 실행")
}
}
delay(300)
job.cancelAndJoin()
}finally 블록은 코루틴이 취소될 때도 실행될 수 있습니다. 그래서 취소 상황에서 필요한 정리 작업을 넣기에 적합합니다.
시간 제한도 자주 사용합니다.
suspend fun loadWithTimeout(): String? {
return withTimeoutOrNull(500) {
delay(1000)
"결과"
}
}withTimeoutOrNull은 제한 시간 안에 결과가 나오면 값을 반환하고, 시간이 지나면 null을 반환합니다. 예외를 던지는 withTimeout보다 흐름을 단순하게 만들고 싶을 때 유용합니다.
예외는 부모 방향으로 전파된다
코루틴에서 예외를 다룰 때는 "어디에서 시작했고, 어떤 부모 아래에 있는가"를 봐야 합니다.
suspend fun handleError() = coroutineScope {
try {
val result = async {
delay(300)
error("서버 오류")
}
println(result.await())
} catch (e: IllegalStateException) {
println("처리한 오류: ${e.message}")
}
}async 안에서 발생한 예외는 await()할 때 다시 던져집니다. 그래서 호출자는 try-catch로 처리할 수 있습니다.
하지만 예외를 처리하지 않으면 부모 스코프가 실패합니다. 부모가 실패하면 같은 부모 아래의 다른 자식도 취소됩니다. 이것이 구조화된 동시성의 기본 규칙입니다.
어떤 경우에는 한 자식의 실패가 다른 자식에게 영향을 주지 않아야 합니다. 예를 들어 추천 상품 로딩이 실패해도 사용자 정보는 보여 줄 수 있어야 합니다. 이런 경우에는 supervisorScope나 SupervisorJob을 고려할 수 있습니다. 다만 기본값은 "실패를 숨기지 말고 부모에게 알린다"는 쪽입니다. 초보 단계에서는 먼저 기본 규칙을 익히는 것이 중요합니다.
공유 상태는 여전히 조심해야 한다
코루틴은 동시성을 쉽게 만들지만, 공유 상태 문제를 없애 주지는 않습니다.
var count = 0
suspend fun increaseManyTimes() = coroutineScope {
repeat(1000) {
launch(Dispatchers.Default) {
count++
}
}
}이 코드는 겉보기에는 단순하지만 안전하지 않습니다. 여러 코루틴이 동시에 count를 읽고 수정하면 값이 예상과 다를 수 있습니다. 이런 문제를 경쟁 상태라고 합니다.
해결 방법은 상황에 따라 다릅니다.
가능한 한 변경 가능한 공유 상태를 줄입니다.
한 코루틴만 상태를 수정하게 만듭니다.
Mutex같은 동기화 도구를 사용합니다.상태 흐름이 필요하면
StateFlow같은 도구를 검토합니다.
코루틴은 "많은 작업을 가볍게 실행하는 방법"이지, 동시성 버그를 자동으로 없애는 장치는 아닙니다.
실전 흐름으로 묶어 보기
다음 예제는 화면에 필요한 데이터를 불러오는 전형적인 흐름입니다.
suspend fun loadHome(userId: Long): Home = coroutineScope {
val userDeferred = async(Dispatchers.IO) {
fetchUser(userId)
}
val noticesDeferred = async(Dispatchers.IO) {
fetchNotices(userId)
}
val user = userDeferred.await()
val notices = noticesDeferred.await()
Home(user, notices)
}
suspend fun fetchUser(userId: Long): User {
delay(300)
return User("사용자$userId")
}
suspend fun fetchNotices(userId: Long): List<String> {
delay(500)
return listOf("새 알림", "이벤트")
}
data class Home(
val user: User,
val notices: List<String>
)이 코드에는 코루틴의 핵심이 모두 들어 있습니다.
loadHome은suspend함수입니다.coroutineScope가 자식 코루틴의 범위를 만듭니다.async가 독립적인 두 작업을 동시에 시작합니다.Dispatchers.IO가 입출력 작업에 맞는 실행 위치를 지정합니다.await()가 결과를 기다립니다.둘 중 하나가 실패하면 전체 로딩이 실패하고 다른 작업도 정리됩니다.
코드는 위에서 아래로 읽힙니다. 하지만 내부적으로는 두 작업이 동시에 진행됩니다. 이것이 코루틴을 사용하는 가장 큰 이유입니다.
코루틴을 사용할 때의 기본 원칙
코루틴을 처음 사용할 때는 다음 원칙을 기억하면 좋습니다.
첫째, suspend는 새 스레드를 만든다는 뜻이 아닙니다. 중단과 재개가 가능하다는 뜻입니다.
둘째, 결과가 필요 없으면 launch, 결과가 필요하면 async를 사용합니다.
셋째, async를 쓴다면 여러 작업을 먼저 시작한 뒤 나중에 await()합니다.
넷째, 코루틴은 반드시 적절한 CoroutineScope 안에서 실행합니다. 범위 없는 코루틴은 취소와 예외 처리가 어려워집니다.
다섯째, 작업 성격에 맞는 Dispatcher를 선택합니다. CPU 작업은 Default, 입출력 작업은 IO, UI 작업은 Main을 우선 고려합니다.
여섯째, 취소와 예외 전파를 설계의 일부로 생각합니다. 코루틴은 시작하기 쉬운 만큼, 끝나는 방식도 명확해야 합니다.
코루틴은 복잡한 비동기 코드를 단순한 순차 코드처럼 보이게 해 줍니다. 그러나 핵심은 단순히 문법이 아닙니다. 어떤 작업을 함께 묶고, 어디에서 실행하며, 실패했을 때 어떻게 정리할지를 구조로 표현하는 것입니다. 이 구조를 이해하면 코틀린에서 느린 작업을 안전하고 읽기 쉽게 다룰 수 있습니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.