본문으로 건너뛰기 Kotlin 코루틴 vs 스레드 완벽 비교 | 비동기 처리 선택 가이드

Kotlin 코루틴 vs 스레드 완벽 비교 | 비동기 처리 선택 가이드

Kotlin 코루틴 vs 스레드 완벽 비교 | 비동기 처리 선택 가이드

이 글의 핵심

Kotlin 코루틴과 스레드의 차이점을 성능, 메모리, 사용성 관점에서 비교. 실전에서 어떤 비동기 처리 방식을 써야 하는지 선택 기준과 예제를 설명합니다.

들어가며

“코루틴과 스레드 중 무엇을 써야 할까요?” 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. 실전 선택 가이드

코루틴을 써야 하는 경우 (대부분)

  1. 네트워크 요청
    suspend fun fetchUsers(): List<User> = withContext(Dispatchers.IO) {
        api.getUsers()
    }
  2. 데이터베이스 쿼리
    suspend fun saveUser(user: User) = withContext(Dispatchers.IO) {
        database.insert(user)
    }
  3. 동시 작업
   suspend fun fetchAll() = coroutineScope {
       val users = async { fetchUsers() }
       val posts = async { fetchPosts() }
       Pair(users.await(), posts.await())
   }

스레드를 써야 하는 경우 (드물음)

  1. CPU 집약적 작업 (코루틴도 가능)
   // 코루틴으로도 가능
   withContext(Dispatchers.Default) {
       heavyComputation()
   }
   
   // 스레드로도 가능 (레거시)
   Thread {
       heavyComputation()
   }.start()
  1. 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 비동기 처리의 핵심:

  1. 기본은 코루틴 (경량, 구조적 동시성)
  2. 스레드는 특수한 경우만 (레거시, Java 통합)
  3. Dispatchers로 스레드 풀 관리
  4. 구조적 동시성으로 안전성 확보 핵심: 코루틴은 스레드의 상위 추상화입니다. 특별한 이유가 없다면 코루틴을 사용하세요.

관련 글


키워드

Kotlin, Coroutine, 코루틴, Thread, 스레드, 비동기, 동시성, 성능, 메모리, 구조적 동시성, 비교


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Kotlin 코루틴과 스레드의 차이점을 성능, 메모리, 사용성 관점에서 비교. 실전에서 어떤 비동기 처리 방식을 써야 하는지 선택 기준과 예제를 설명합니다. Kotlin·Coroutine·코루틴 중심으로 설명합니다.… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Kotlin, Coroutine, 코루틴, Thread, 스레드, 비동기, 성능, 비교 등으로 검색하시면 이 글이 도움이 됩니다.