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

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

이 글의 핵심

Kotlin 코루틴 vs 스레드 비교 - 성능, 메모리, 구조적 동시성 차이와 선택 기준

들어가며

“코루틴과 스레드 중 무엇을 써야 할까요?” Kotlin으로 비동기 처리를 할 때 자주 나오는 질문입니다. 이 글에서는 코루틴과 스레드의 차이를 명확히 이해하고, 실전에서 어떤 것을 써야 하는지 선택 기준을 제시합니다.

비유로 말씀드리면, 스레드직원을 한 명 더 고용하는 것이고, 코루틴한 직원이 작업을 번갈아 처리하되, 기다리는 동안 다른 일을 보게 하는 것에 가깝습니다. I/O 대기가 많으면 코루틴이 메모리·컨텍스트 비용에서 유리한 경우가 많습니다.

언제 코루틴을, 언제 스레드를 쓰나요?

관점코루틴스레드
성능대량 생성 시 가벼운 스케줄 단위OS 스레드마다 스택·전환 비용
사용성suspend, 구조적 동시성으로 취소·에러 전파블로킹·CPU 바운드·레거시 API와 궁합
적용 시나리오네트워크·DB 대기병렬 CPU 작업·JNI 등

이 글을 읽으면

  • 코루틴과 스레드의 동작 원리를 이해합니다
  • 성능과 메모리 사용량 차이를 배웁니다
  • 구조적 동시성의 이점을 익힙니다
  • 실전에서 어떤 것을 써야 하는지 판단할 수 있습니다

목차

  1. 빠른 비교표
  2. 동작 원리
  3. 성능 비교
  4. 메모리 사용량
  5. 구조적 동시성
  6. 실전 선택 가이드
  7. 마무리

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()
  2. 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 코루틴 완벽 가이드
  • Kotlin Dispatchers 선택 가이드
  • Kotlin 구조적 동시성

키워드

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