Kotlin 코루틴 vs 스레드: 비용 차이와 비동기 처리 선택 기준
이 글의 핵심
스레드 수백 개를 띄워 블로킹 호출을 기다리면 메모리와 스케줄링 비용이 빠르게 늘어납니다. 코루틴이 중단 지점에서 스레드를 반납하는 원리, 자식 작업 하나가 실패하면 형제 작업이 취소되는 구조적 동시성을 짚고, 그래도 스레드를 직접 써야 하는 드문 경우까지 구분해 선택 기준을 제시합니다.
들어가며
“코루틴과 스레드 중 무엇을 써야 할까요?” Kotlin으로 비동기 처리를 할 때 자주 나오는 질문입니다. 이 글에서는 코루틴과 스레드의 차이를 명확히 이해하며, 실전에서 어떤 것을 써야 하는지 선택 기준을 제시합니다. 비유로 말씀드리면, 스레드는 직원을 한 명 더 고용하는 것이며, 코루틴은 한 직원이 작업을 번갈아 처리하되, 기다리는 동안 다른 일을 보게 하는 것에 가깝습니다. I/O 대기가 많으면 코루틴이 메모리·컨텍스트 비용에서 유리한 경우가 많습니다.
언제 코루틴을, 언제 스레드를 쓰나요?
| 관점 | 코루틴 | 스레드 |
|---|---|---|
| 성능 | 대량 생성 시 가벼운 스케줄 단위 | OS 스레드마다 스택·전환 비용 |
| 사용성 | suspend, 구조적 동시성으로 취소·에러 전파 | 블로킹·CPU 바운드·레거시 API와 궁합 |
| 적용 시나리오 | 네트워크·DB 대기 | 병렬 CPU 작업·JNI 등 |
먼저 오해 하나를 짚고 가겠습니다. 코루틴은 스레드를 대체하지 않습니다. 모든 코루틴은 결국 어떤 스레드 위에서 실행되고, Dispatchers.Default나 Dispatchers.IO는 그 스레드들을 모아 둔 풀입니다. 코루틴이 바꾸는 것은 “기다리는 동안 스레드를 붙잡고 있느냐”입니다. 스레드 모델에서 네트워크 응답을 기다리는 스레드는 아무 일도 하지 않으면서 스택 메모리와 스케줄러 자리를 차지합니다. 코루틴은 suspend 지점에서 자기 상태를 힙 객체로 저장하고 스레드를 풀에 돌려줍니다. 그래서 스레드 수십 개로 동시 요청 수만 개를 처리할 수 있습니다. 반대로 기다리는 시간이 거의 없는 CPU 계산에서는 이 장점이 사라지고, 동시에 실행되는 양은 결국 CPU 코어 수가 정합니다.
코루틴과 스레드 비교표
| 특성 | 코루틴 | 스레드 |
|---|---|---|
| 무게 | 경량 (수천~수만 개 가능) | 무거움 (수십~수백 개) |
| 메모리 | ~KB | ~MB (스택 크기) |
| 생성 비용 | 매우 낮음 | 높음 (OS 호출) |
| 컨텍스트 스위칭 | 빠름 (사용자 공간) | 느림 (커널 공간) |
| 취소 | 구조적 취소 지원 | 수동 구현 필요 |
| 예외 처리 | 자동 전파 | 수동 처리 |
| 디버깅 | 어려움 | 상대적으로 쉬움 |
| 권장 사용 | ✅ 기본 선택 | 특수한 경우만 |
표의 “디버깅” 행은 과소평가하기 쉬운 비용입니다. 코루틴이 중단됐다가 다른 스레드에서 재개되면, 예외 스택 트레이스에는 재개된 이후의 프레임만 남고 “누가 이 코루틴을 시작했는지”가 보이지 않습니다. 개발 중에는 JVM 옵션 -Dkotlinx.coroutines.debug를 켜면 스레드 이름에 코루틴 이름이 붙고, IntelliJ의 코루틴 디버거 탭에서 중단된 코루틴 목록을 볼 수 있어 상당 부분 보완됩니다.
OS 스레드와 사용자 레벨 코루틴의 동작 방식
스레드: OS 레벨
// 스레드 생성
val thread = Thread {
println("Running in thread: ${Thread.currentThread().name}")
Thread.sleep(1000)
}
thread.start()
thread.join()
// 메모리 구조
// 각 스레드마다:
// - OS 스레드 생성 (커널 리소스)
// - 스택 메모리 예약 (64비트 JVM 기본 -Xss 1MB, 실제 사용분만 물리 메모리 점유)
// - 컨텍스트 스위칭 (커널 개입)
Thread.sleep(1000)은 이 스레드를 1초 동안 완전히 멈춥니다. 그동안 이 스레드는 다른 일을 할 수 없고, OS 스케줄러는 깨어날 시점까지 이 스레드를 대기 목록에 둡니다. JVM의 Thread는 OS 스레드와 1:1로 대응하므로(Java 21의 가상 스레드는 예외), 스레드를 만들 때마다 커널 호출과 스택 예약이 일어납니다.
코루틴: 사용자 레벨
import kotlinx.coroutines.*
// 코루틴 생성
runBlocking {
launch {
println("Running in coroutine")
delay(1000) // 중단 (스레드는 블록 안 됨)
}
}
// 메모리 구조
// - 코루틴은 스레드 위에서 실행
// - 중단 시 지역 변수와 재개 위치만 힙 객체에 저장 (보통 수백 bytes 수준)
// - 재개 시 다른 스레드에서도 실행 가능 (멀티스레드 디스패처인 경우)
delay가 스레드를 막지 않는 원리는 컴파일러 변환에 있습니다. Kotlin 컴파일러는 suspend 함수를 상태 기계로 바꾸고, 각 중단 지점을 상태 번호로 표시합니다. delay(1000)에 도달하면 코루틴은 “1초 뒤에 상태 1부터 재개해 달라”고 디스패처에 예약하고 즉시 반환합니다. 스레드는 그 사이 다른 코루틴을 실행하고, 1초 뒤 예약된 continuation이 호출되면 저장해 둔 지역 변수를 복원해 다음 줄부터 이어갑니다. 이 과정에 커널 호출이 없다는 점이 코루틴이 가벼운 이유입니다.
주의할 점은 이 변환이 suspend 함수에만 적용된다는 것입니다. 코루틴 안에서 Thread.sleep()이나 JDBC 같은 블로킹 호출을 하면 코루틴이 아니라 스레드가 멈추고, 그 스레드를 공유하는 다른 코루틴도 모두 함께 멈춥니다. runBlocking이나 Android 메인 디스패처처럼 스레드가 하나뿐인 환경에서는 앱 전체가 굳습니다.
생성 비용과 컨텍스트 스위칭
생성 비용
import kotlin.system.measureTimeMillis
// 스레드 10,000개 생성
val threadTime = measureTimeMillis {
val threads = List(10000) {
Thread { Thread.sleep(100) }
}
threads.forEach { it.start() }
threads.forEach { it.join() }
}
println("Threads: ${threadTime}ms") // sleep 100ms + 스레드 1만 개 생성·종료 비용
// 코루틴 10,000개 생성
val coroutineTime = measureTimeMillis {
runBlocking {
val jobs = List(10000) {
launch { delay(100) }
}
jobs.forEach { it.join() }
}
}
println("Coroutines: ${coroutineTime}ms") // delay 100ms에 가까움 (스레드 하나로 처리)
두 코드 모두 작업 자체는 “100ms 기다리기”이므로, 이상적으로는 둘 다 100ms 남짓에 끝나야 합니다. 차이는 그 위에 얹히는 생성·정리 비용입니다. 스레드 쪽은 1만 번의 OS 스레드 생성과 종료가 필요하고, 환경에 따라 그 비용이 sleep 시간보다 훨씬 커집니다. 코루틴 쪽은 runBlocking의 스레드 하나가 1만 개의 코루틴을 전부 처리합니다. 정확한 수치는 OS, JVM 버전, 코어 수에 따라 크게 달라지므로 직접 돌려 보는 것을 권합니다. 이런 마이크로 벤치마크는 JIT 워밍업 전후로 결과가 크게 흔들리니, 여러 번 반복한 뒤의 값을 보거나 JMH를 쓰는 편이 정확합니다.
스레드 쪽은 숫자를 10만으로 늘리면 느려지는 정도가 아니라 실패할 수 있습니다. OS의 프로세스당 스레드 수 제한(Linux의 ulimit -u, /proc/sys/kernel/threads-max)에 걸리면 java.lang.OutOfMemoryError: unable to create native thread가 납니다. 이름은 OutOfMemoryError지만 힙이 부족한 것이 아니라 OS가 새 스레드를 거부한 것이라, -Xmx를 늘려도 해결되지 않습니다.
컨텍스트 스위칭
// 스레드: 커널 개입 (상대적으로 느림)
// - 커널 모드 진입, 레지스터 저장/복원
// - 스택 포인터 변경
// - 캐시 오염 (같은 프로세스 스레드 간 전환은 주소 공간이 같아 TLB 플러시는 불필요)
// 보통 마이크로초 단위
// 코루틴: 사용자 공간 (빠름)
// - continuation 객체 호출
// - 커널 호출 없음
// 일반 함수 호출 몇 번 수준
스레드 전환 비용 자체보다 더 큰 차이는 전환 횟수와 시점을 누가 정하느냐입니다. OS 스케줄러는 스레드가 수천 개면 타임 슬라이스마다 선점적으로 전환하며 CPU 캐시를 계속 밀어냅니다. 코루틴은 suspend 지점에서만 협력적으로 전환하므로 불필요한 전환이 거의 없습니다. 대신 이 협력성 때문에, 중단 지점 없이 오래 도는 루프는 다른 코루틴에게 스레드를 양보하지 않습니다. 긴 계산 루프에는 yield()나 ensureActive()를 넣어 줘야 합니다.
스레드 스택과 코루틴 객체의 메모리
스레드 메모리
// 스레드 1개: 스택 1MB 예약 (기본 -Xss)
val threads = List(1000) { Thread { Thread.sleep(1000) } }
threads.forEach { it.start() }
// 가상 메모리 예약: 1000 × 1MB = 약 1GB
// 실제 물리 메모리는 사용한 스택 페이지만큼 (sleep만 하면 훨씬 적음)
// → 32비트 환경이나 컨테이너 메모리 제한에서는 문제가 될 수 있음
스레드 스택 크기는 흔히 “스레드 하나에 1MB를 쓴다”고 설명되지만 정확히는 예약(reserve)입니다. 64비트 OS는 스택용 가상 주소를 먼저 잡아 두고, 실제로 스택이 깊어져 페이지에 접근할 때 물리 메모리를 할당합니다. 그래서 sleep만 하는 1000개 스레드가 곧바로 1GB의 RAM을 먹지는 않습니다. 그렇다고 비용이 없는 것은 아니어서, 스레드마다 커널 자료구조, 스레드 로컬 저장소, GC가 매번 스캔해야 하는 스택 루트가 생깁니다. 스레드가 수천 개를 넘으면 GC 일시 정지 시간이 눈에 띄게 늘어나는 이유가 이것입니다.
코루틴 메모리
// 코루틴 1개: 약 수십 bytes
runBlocking {
val jobs = List(100000) { launch { delay(1000) } }
jobs.forEach { it.join() }
}
// 총 메모리: 코루틴 객체 + continuation, 10만 개여도 수십 MB 이내
// → 10만 개도 문제없음
코루틴 하나의 크기는 중단 시점에 살아 있는 지역 변수의 양에 따라 달라집니다. delay만 하는 코루틴은 작지만, 큰 리스트를 지역 변수로 들고 중단되면 그 리스트도 continuation에 붙잡혀 GC되지 않습니다. 코루틴 누수는 그래서 메모리 누수로 나타납니다. 끝나지 않는 코루틴을 GlobalScope에서 수만 개 띄워 두면, 개수가 많아서가 아니라 각각이 붙잡은 객체 때문에 힙이 찹니다.
수동 관리 스레드와 구조적 동시성
스레드: 수동 관리
// ❌ 나쁜 패턴: 스레드 누수
fun fetchData() {
Thread {
val data = api.fetch()
// 예외 발생 시 스레드가 죽지만 호출자는 모름
}.start()
// 스레드가 끝날 때까지 기다리지 않음
}
// 취소도 수동
val thread = Thread { /* ... */ }
thread.start()
// 안전한 강제 종료 방법이 없음 (Thread.stop()은 폐기됨, interrupt는 협력적)
코루틴: 구조적 동시성
// ✅ 좋은 패턴: 자동 관리
suspend fun fetchData(): Data = coroutineScope {
val data = async { api.fetch() }
data.await()
// 예외 발생 시 자동으로 상위로 전파
// 함수 종료 시 모든 자식 코루틴 자동 취소
}
// 취소도 자동
val job = launch {
fetchData()
}
job.cancel() // 모든 자식 코루틴도 취소됨
“구조적 동시성”은 코루틴이 반드시 부모 스코프 안에서 시작되고, 부모는 모든 자식이 끝나기 전에는 끝나지 않는다는 규칙입니다. 위 coroutineScope는 안에서 시작한 async가 전부 끝날 때까지 반환하지 않으므로, 함수가 반환된 뒤에도 백그라운드에서 몰래 도는 작업이 생길 수 없습니다. 스레드 예제의 fetchData()가 호출자 모르게 스레드를 남기는 것과 정반대입니다. 이 규칙 덕분에 Android에서 화면이 닫히면 viewModelScope가 취소되며 그 화면이 시작한 네트워크 요청까지 모두 정리됩니다.
한 가지 오해를 바로잡자면, 코루틴의 취소도 협력적입니다. job.cancel()은 코루틴에 취소 표시를 할 뿐이고, 코루틴은 다음 중단 지점(delay, await, withContext 등)에서 CancellationException을 받고 멈춥니다. 중단 지점 없이 while (true) { compute() }처럼 도는 코드는 cancel()을 호출해도 계속 돕니다. 스레드의 interrupt와 같은 원리지만, kotlinx.coroutines의 모든 suspend 함수가 취소를 확인하도록 만들어져 있어서 대부분의 코드가 별도 처리 없이 취소에 응답한다는 점이 다릅니다. 반대로 catch (e: Exception)으로 모든 예외를 잡으면 CancellationException까지 삼켜 취소가 동작하지 않게 되는데, 코루틴을 처음 도입한 코드에서 가장 자주 보는 실수입니다. 잡았다면 다시 던지거나, 잡을 예외 타입을 좁혀야 합니다.
코루틴을 쓸 때와 스레드를 쓸 때
코루틴을 써야 하는 경우 (대부분)
- 네트워크 요청
suspend fun fetchUsers(): List<User> = withContext(Dispatchers.IO) { api.getUsers() } - 데이터베이스 쿼리
suspend fun saveUser(user: User) = withContext(Dispatchers.IO) { database.insert(user) } - 동시 작업
suspend fun fetchAll() = coroutineScope {
val users = async { fetchUsers() }
val posts = async { fetchPosts() }
Pair(users.await(), posts.await())
}
스레드를 써야 하는 경우 (드물음)
- CPU 집약적 작업 (코루틴도 가능)
// 코루틴으로도 가능
withContext(Dispatchers.Default) {
heavyComputation()
}
// 스레드로도 가능 (레거시)
Thread {
heavyComputation()
}.start()
- Java 라이브러리 통합
// ExecutorService 등 기존 Java 코드 val executor = Executors.newFixedThreadPool(4) executor.submit { /* ... */ }
기존 ExecutorService가 있다면 둘 중 하나를 고를 필요도 없습니다. executor.asCoroutineDispatcher()로 기존 스레드 풀을 코루틴 디스패처로 쓸 수 있고, CompletableFuture는 kotlinx-coroutines-jdk8의 await()로 코루틴에서 기다릴 수 있습니다. 스레드를 직접 다뤄야 하는 진짜 예외는 스레드 자체에 의미가 있는 경우입니다. 스레드 로컬 상태에 의존하는 네이티브 라이브러리(일부 OpenGL 컨텍스트, JNI 라이브러리), 우선순위나 CPU 친화도를 지정해야 하는 실시간 작업, 특정 스레드에서만 호출해야 하는 레거시 API가 그렇습니다.
Java 21의 가상 스레드(Project Loom)도 선택지로 알아 둘 만합니다. 가상 스레드는 JVM이 소수의 OS 스레드 위에 스케줄링하는 경량 스레드라, 블로킹 I/O를 해도 OS 스레드를 붙잡지 않습니다. 기존 블로킹 코드(JDBC 등)를 그대로 두고 동시성만 늘리고 싶다면 좋은 선택입니다. 반면 구조적 동시성, 취소 전파, Flow 같은 도구는 코루틴 쪽이 훨씬 성숙해 있어서, Kotlin 코드베이스라면 여전히 코루틴이 기본이고 블로킹 라이브러리 호출 부분만 Dispatchers.IO나 가상 스레드 기반 디스패처로 격리하는 조합이 현실적입니다.
API 10개 동시 호출을 두 방식으로 작성하기
// 스레드 방식
fun fetchAllThreads(): List<User> {
val results = mutableListOf<User>()
val threads = (1..10).map { id ->
Thread {
try {
val user = api.getUser(id)
synchronized(results) {
results.add(user)
}
} catch (e: Exception) {
// 예외 처리 복잡
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
return results
}
// 코루틴 방식
suspend fun fetchAllCoroutines(): List<User> = coroutineScope {
(1..10).map { id ->
async { api.getUser(id) }
}.awaitAll()
// 예외 자동 전파, 취소 자동 처리
}
두 코드의 길이 차이보다 동작 차이가 더 중요합니다. 스레드 방식은 세 가지 문제를 안고 있습니다. 결과 리스트의 순서가 요청 순서가 아니라 응답이 도착한 순서이고, catch에서 예외를 삼키므로 사용자 10명 중 3명이 빠진 리스트가 에러 없이 반환되며, 호출자가 기다리는 도중 요청을 취소할 방법이 없습니다. 코루틴 방식은 awaitAll()이 map의 순서대로 결과를 돌려주고, 하나라도 실패하면 나머지 요청을 취소한 뒤 예외를 호출자에게 던지며, 호출자 코루틴이 취소되면 10개 요청이 모두 취소됩니다. 공유 리스트가 없으니 synchronized도 필요 없습니다.
실무에서 이 패턴을 쓸 때 한 가지 더 챙겨야 할 것은 동시 요청 수 제한입니다. 10개는 괜찮지만 ID가 1만 개라면 1만 개의 요청이 동시에 나가 상대 서버의 rate limit에 걸립니다. Semaphore(10)의 withPermit { }으로 감싸거나 Dispatchers.IO.limitedParallelism(10)을 쓰면 동시성을 제한할 수 있습니다. 코루틴은 만들기 쉬워서, 스레드였다면 자연스럽게 걸렸을 제한이 사라진다는 점을 제가 가장 자주 놓쳤습니다.
마무리
Kotlin 비동기 처리의 핵심:
- 기본은 코루틴 (경량, 구조적 동시성)
- 스레드는 특수한 경우만 (레거시, Java 통합)
- Dispatchers로 스레드 풀 관리
- 구조적 동시성으로 안전성 확보 핵심: 코루틴은 스레드의 상위 추상화입니다. 특별한 이유가 없다면 코루틴을 사용하세요.
같이 보면 좋은 글
- Kotlin 코루틴
- Kotlin Android 개발
- Rust String vs &str: 소유권 차이와 함수 인자로 무엇을 받을지
- C++ 개발자를 위한 Rust | 차이점과 전환 가이드
자주 묻는 질문 (FAQ)
Q. coroutineScope 안에서 async 하나가 실패하면 나머지 작업은 어떻게 되나요?
A. 구조적 동시성 규칙에 따라 자식 하나가 예외로 실패하면 같은 스코프의 다른 자식들이 취소되고, 예외는 coroutineScope를 호출한 쪽으로 전파됩니다. 사용자 정보와 게시글을 함께 가져와야 하나의 화면이 완성되는 경우처럼 하나라도 실패하면 전체를 버려야 할 때 맞는 동작입니다. 작업들이 서로 독립적이어서 하나가 실패해도 나머지 결과는 살려야 한다면 supervisorScope를 쓰고 각 await에서 예외를 따로 처리합니다.