Kotlin 코루틴 vs 스레드 완벽 비교 | 비동기 처리 선택 가이드
이 글의 핵심
Kotlin 코루틴 vs 스레드 비교 - 성능, 메모리, 구조적 동시성 차이와 선택 기준
들어가며
“코루틴과 스레드 중 무엇을 써야 할까요?” Kotlin으로 비동기 처리를 할 때 자주 나오는 질문입니다. 이 글에서는 코루틴과 스레드의 차이를 명확히 이해하고, 실전에서 어떤 것을 써야 하는지 선택 기준을 제시합니다.
비유로 말씀드리면, 스레드는 직원을 한 명 더 고용하는 것이고, 코루틴은 한 직원이 작업을 번갈아 처리하되, 기다리는 동안 다른 일을 보게 하는 것에 가깝습니다. I/O 대기가 많으면 코루틴이 메모리·컨텍스트 비용에서 유리한 경우가 많습니다.
언제 코루틴을, 언제 스레드를 쓰나요?
| 관점 | 코루틴 | 스레드 |
|---|---|---|
| 성능 | 대량 생성 시 가벼운 스케줄 단위 | OS 스레드마다 스택·전환 비용 |
| 사용성 | suspend, 구조적 동시성으로 취소·에러 전파 | 블로킹·CPU 바운드·레거시 API와 궁합 |
| 적용 시나리오 | 네트워크·DB 대기 | 병렬 CPU 작업·JNI 등 |
이 글을 읽으면
- 코루틴과 스레드의 동작 원리를 이해합니다
- 성능과 메모리 사용량 차이를 배웁니다
- 구조적 동시성의 이점을 익힙니다
- 실전에서 어떤 것을 써야 하는지 판단할 수 있습니다
목차
1. 빠른 비교표
| 특성 | 코루틴 | 스레드 |
|---|---|---|
| 무게 | 경량 (수천~수만 개 가능) | 무거움 (수십~수백 개) |
| 메모리 | ~KB | ~MB (스택 크기) |
| 생성 비용 | 매우 낮음 | 높음 (OS 호출) |
| 컨텍스트 스위칭 | 빠름 (사용자 공간) | 느림 (커널 공간) |
| 취소 | 구조적 취소 지원 | 수동 구현 필요 |
| 예외 처리 | 자동 전파 | 수동 처리 |
| 디버깅 | 어려움 | 상대적으로 쉬움 |
| 권장 사용 | ✅ 기본 선택 | 특수한 경우만 |
2. 동작 원리
스레드: OS 레벨
// 스레드 생성
val thread = Thread {
println("Running in thread: ${Thread.currentThread().name}")
Thread.sleep(1000)
}
thread.start()
thread.join()
// 메모리 구조
// 각 스레드마다:
// - OS 스레드 생성 (커널 리소스)
// - 스택 메모리 할당 (보통 1-2MB)
// - 컨텍스트 스위칭 (커널 개입)
코루틴: 사용자 레벨
import kotlinx.coroutines.*
// 코루틴 생성
runBlocking {
launch {
println("Running in coroutine")
delay(1000) // 중단 (스레드는 블록 안 됨)
}
}
// 메모리 구조
// - 코루틴은 스레드 위에서 실행
// - 중단 시 상태만 저장 (수십 bytes)
// - 재개 시 다른 스레드에서도 실행 가능
3. 성능 비교
생성 비용
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") // 약 5000ms (5초)
// 코루틴 10,000개 생성
val coroutineTime = measureTimeMillis {
runBlocking {
val jobs = List(10000) {
launch { delay(100) }
}
jobs.forEach { it.join() }
}
}
println("Coroutines: ${coroutineTime}ms") // 약 150ms (30배 빠름)
컨텍스트 스위칭
// 스레드: 커널 개입 (느림)
// - 레지스터 저장/복원
// - 스택 포인터 변경
// - TLB 플러시
// 약 1-10 마이크로초
// 코루틴: 사용자 공간 (빠름)
// - 상태 객체만 교체
// - 커널 호출 없음
// 약 0.1 마이크로초 (10-100배 빠름)
4. 메모리 사용량
스레드 메모리
// 스레드 1개: 약 1-2MB
val threads = List(1000) { Thread { Thread.sleep(1000) } }
threads.forEach { it.start() }
// 총 메모리: 1000 × 1MB = 1GB
// → 메모리 부족 가능성
코루틴 메모리
// 코루틴 1개: 약 수십 bytes
runBlocking {
val jobs = List(100000) { launch { delay(1000) } }
jobs.forEach { it.join() }
}
// 총 메모리: 100,000 × 50 bytes = 5MB
// → 10만 개도 문제없음
5. 구조적 동시성
스레드: 수동 관리
// ❌ 나쁜 패턴: 스레드 누수
fun fetchData() {
Thread {
val data = api.fetch()
// 예외 발생 시 스레드가 죽지만 호출자는 모름
}.start()
// 스레드가 끝날 때까지 기다리지 않음
}
// 취소도 수동
val thread = Thread { /* ... */ }
thread.start()
// 취소 방법이 없음! (interrupt는 협력적)
코루틴: 구조적 동시성
// ✅ 좋은 패턴: 자동 관리
suspend fun fetchData(): Data = coroutineScope {
val data = async { api.fetch() }
data.await()
// 예외 발생 시 자동으로 상위로 전파
// 함수 종료 시 모든 자식 코루틴 자동 취소
}
// 취소도 자동
val job = launch {
fetchData()
}
job.cancel() // 모든 자식 코루틴도 취소됨
6. 실전 선택 가이드
코루틴을 써야 하는 경우 (대부분)
-
네트워크 요청
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 { /* ... */ }
7. 코드 비교
예제: 동시에 10개 API 호출
// 스레드 방식
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()
// 예외 자동 전파, 취소 자동 처리
}
마무리
Kotlin 비동기 처리의 핵심:
- 기본은 코루틴 (경량, 구조적 동시성)
- 스레드는 특수한 경우만 (레거시, Java 통합)
- Dispatchers로 스레드 풀 관리
- 구조적 동시성으로 안전성 확보
핵심: 코루틴은 스레드의 상위 추상화입니다. 특별한 이유가 없다면 코루틴을 사용하세요.
관련 글
- Kotlin 코루틴 완벽 가이드
- Kotlin Dispatchers 선택 가이드
- Kotlin 구조적 동시성
키워드
Kotlin, Coroutine, 코루틴, Thread, 스레드, 비동기, 동시성, 성능, 메모리, 구조적 동시성, 비교