TL;DR


1. 개념

``` Coroutine = Co(협력) + Routine(루틴) └── 협력적으로 실행되는 루틴 └── “일시 중단 가능한 함수” └── 스레드보다 훨씬 가벼움

2. 배경

Kotlin Coroutine과 @Async 비교이(가) 등장한 배경과 기존 한계를 정리한다.

3. 이유

이 주제를 이해하고 적용해야 하는 이유를 정리한다.

4. 특징

5. 상세 내용

작성일: 2026-01-29 카테고리: Backend / Kotlin / Concurrency 포함 내용: Coroutine, async, launch, suspend, Continuation, @Async, 동시성


1. Coroutine이란?

개념

Coroutine = Co(협력) + Routine(루틴)
            └── 협력적으로 실행되는 루틴
            └── "일시 중단 가능한 함수"
            └── 스레드보다 훨씬 가벼움

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  핵심 특징:                                             │
│  ├── Suspend (일시 중단): 실행 중 멈출 수 있음          │
│  ├── Resume (재개): 멈춘 곳에서 다시 시작               │
│  └── 스레드 차단 없이 대기                              │
│                                                         │
│  스레드 vs 코루틴:                                      │
│  ┌──────────────────┬──────────────────────────────┐   │
│  │     스레드        │       코루틴                 │   │
│  ├──────────────────┼──────────────────────────────┤   │
│  │ OS가 관리        │ 라이브러리/언어가 관리        │   │
│  │ 무거움 (~1MB)    │ 가벼움 (~수백 바이트)         │   │
│  │ 선점형 스케줄링  │ 협력적 스케줄링               │   │
│  │ 컨텍스트 스위칭  │ 상태 객체 교체                │   │
│  │   비용 높음      │   비용 낮음                   │   │
│  └──────────────────┴──────────────────────────────┘   │
│                                                         │
└─────────────────────────────────────────────────────────┘

2. 등장 배경

2.1 전통적인 비동기 처리의 고통

┌─────────────────────────────────────────────────────────┐
│                  콜백 지옥 (Callback Hell)               │
│                                                         │
│  fetchUser(userId) { user ->                            │
│      fetchOrders(user.id) { orders ->                   │
│          fetchProducts(orders[0].id) { products ->      │
│              fetchReviews(products[0].id) { reviews ->  │
│                  // 계속 들여쓰기...                    │
│              }                                          │
│          }                                              │
│      }                                                  │
│  }                                                      │
│                                                         │
│  문제점:                                                │
│  ├── 가독성 최악 (피라미드 코드)                        │
│  ├── 에러 처리 복잡                                     │
│  ├── 취소 처리 어려움                                   │
│  └── 디버깅 지옥                                        │
│                                                         │
└─────────────────────────────────────────────────────────┘

2.2 비동기 처리의 진화

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  1세대: Callback                                        │
│  └── 콜백 지옥 문제                                     │
│                                                         │
│  2세대: Future/Promise (Java CompletableFuture)         │
│  └── 나아졌지만 여전히 체이닝이 복잡                    │
│                                                         │
│  3세대: RxJava/Reactor (리액티브)                       │
│  └── 강력하지만 학습 곡선 높음                          │
│                                                         │
│  4세대: Coroutine (2018, Kotlin 1.3)                    │
│  val user = fetchUser(userId)       // 동기처럼 보이지만│
│  val orders = fetchOrders(user.id)  // 실제로는 비동기! │
│  └── 동기 코드처럼 작성, 비동기로 실행                  │
│                                                         │
└─────────────────────────────────────────────────────────┘

3. launch vs async

3.1 launch - Fire and Forget

fun main() = runBlocking {
    val job: Job = launch {
        delay(1000)
        println("Hello")  // 결과 반환 없음
    }

    job.join()  // 완료 대기 (결과는 없음)
}
┌─────────────────────────────────────────────────────────┐
│                                                         │
│  launch:                                                │
│  ├── 반환 타입: Job                                     │
│  ├── 결과값: 없음                                       │
│  ├── 용도: 결과가 필요 없는 비동기 작업                 │
│  └── 비유: "이거 해두고, 나 안 기다려도 돼"             │
│                                                         │
└─────────────────────────────────────────────────────────┘

3.2 async - 결과를 기다림

fun main() = runBlocking {
    val deferred: Deferred<Int> = async {
        delay(1000)
        42  // 결과 반환!
    }

    val result: Int = deferred.await()  // 결과 받기
    println(result)  // 42
}
┌─────────────────────────────────────────────────────────┐
│                                                         │
│  async:                                                 │
│  ├── 반환 타입: Deferred<T> (결과를 담는 그릇)          │
│  ├── 결과값: await()로 받음                             │
│  ├── 용도: 결과가 필요한 비동기 작업                    │
│  └── 비유: "이거 해두고, 나중에 결과 알려줘"            │
│                                                         │
└─────────────────────────────────────────────────────────┘

3.3 핵심 비교

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  ┌────────────┬─────────────────┬─────────────────┐    │
│  │            │     launch      │     async       │    │
│  ├────────────┼─────────────────┼─────────────────┤    │
│  │ 반환 타입  │ Job             │ Deferred<T>     │    │
│  │ 결과값     │ 없음            │ T (await으로)   │    │
│  │ 예외 처리  │ 즉시 전파       │ await시 전파    │    │
│  │ 용도       │ 실행만          │ 결과 필요       │    │
│  │ 비유       │ 심부름 시키기   │ 배달 주문하기   │    │
│  └────────────┴─────────────────┴─────────────────┘    │
│                                                         │
│  선택 기준:                                             │
│  "결과가 필요해?" → Yes → async                         │
│                  → No  → launch                         │
│                                                         │
└─────────────────────────────────────────────────────────┘

4. 단일 스레드에서 여러 코루틴 동작 원리

4.1 코드 예시

fun main() = runBlocking {  // main 스레드 1개만 사용

    println("메인 시작")

    launch {
        println("☕ 커피 주문 시작")
        delay(3000)
        println("☕ 커피 완성!")
    }

    launch {
        println("🥪 샌드위치 주문 시작")
        delay(2000)
        println("🥪 샌드위치 완성!")
    }

    launch {
        println("🧃 주스 주문 시작")
        delay(1000)
        println("🧃 주스 완성!")
    }

    println("메인: 모든 주문 접수 완료!")
}

4.2 실행 순서 (핵심!)

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  launch의 2단계:                                                        │
│                                                                         │
│  1️⃣ 스케줄링 단계 (매우 빠름, 수 나노초)                                │
│     └── 코루틴 객체 생성                                                │
│     └── 실행 큐에 추가                                                  │
│     └── 즉시 리턴 (블로킹 없음!)                                        │
│                                                                         │
│  2️⃣ 실행 단계 (스케줄러가 처리)                                         │
│     └── 큐에서 코루틴 꺼내서 실행                                       │
│     └── suspend 만나면 다음 코루틴으로                                  │
│     └── resume 되면 다시 실행                                           │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.3 타임라인

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  시간   │  main 스레드가 하는 일                                        │
│  ───────┼──────────────────────────────────────────────────────────────│
│         │                                                               │
│   0ms   │  "메인 시작" 출력                                             │
│         │  launch{A} → Queue에 추가 (즉시 리턴)                         │
│         │  launch{B} → Queue에 추가 (즉시 리턴)                         │
│         │  launch{C} → Queue에 추가 (즉시 리턴)                         │
│         │  "메인: 모든 주문 접수 완료!" 출력                            │
│         │                                                               │
│   1ms   │  A 실행: "커피 시작" → delay(3000) → SUSPEND                  │
│   2ms   │  B 실행: "샌드위치 시작" → delay(2000) → SUSPEND              │
│   3ms   │  C 실행: "주스 시작" → delay(1000) → SUSPEND                  │
│         │                                                               │
│         │  ═══ 3개 모두 delay 중, 스레드는 idle ═══                     │
│         │                                                               │
│  1003ms │  C resume → "주스 완성!" → C 종료                             │
│  2002ms │  B resume → "샌드위치 완성!" → B 종료                         │
│  3001ms │  A resume → "커피 완성!" → A 종료                             │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.4 핵심 포인트

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  단일 스레드에서:                                       │
│  ├── 스케줄링은 "순차적"이지만 매우 빠름 (나노초)       │
│  ├── 실행은 "한 번에 하나", suspend 시 교대             │
│  └── 결과적으로 "동시에 대기" 가능 (I/O 효율적)         │
│                                                         │
│  Thread Pool (Dispatchers.Default 등) 사용 시:          │
│  ├── 여러 스레드에서 진짜 병렬 실행 가능                │
│  └── CPU 작업에 유리                                    │
│                                                         │
└─────────────────────────────────────────────────────────┘

5. Coroutine이 가벼운 이유: Stack vs Heap

5.1 스레드의 무거움

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  Thread의 메모리 구조:                                                  │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                    Thread 1 (1MB Stack)                         │   │
│  │  ┌─────────────────────────────────────────────────────────┐   │   │
│  │  │  Stack Frame: main()                                     │   │   │
│  │  ├─────────────────────────────────────────────────────────┤   │   │
│  │  │  Stack Frame: functionA()                                │   │   │
│  │  ├─────────────────────────────────────────────────────────┤   │   │
│  │  │  Stack Frame: functionB()                                │   │   │
│  │  ├─────────────────────────────────────────────────────────┤   │   │
│  │  │                    (미사용 공간도 할당됨)                │   │   │
│  │  └─────────────────────────────────────────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  스레드 10,000개 = Stack만 10GB 메모리 필요!                            │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

5.2 코루틴의 가벼움 (Stackless)

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  Coroutine의 메모리 구조:                                               │
│                                                                         │
│  스레드 Stack을 사용하지 않음!                                          │
│  대신 Heap에 작은 객체(Continuation)만 저장                             │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                         Heap                                    │   │
│  │  ┌──────────────────┐  ┌──────────────────┐                    │   │
│  │  │ Continuation 1   │  │ Continuation 2   │  ...               │   │
│  │  │  - state: 2      │  │  - state: 0      │                    │   │
│  │  │  - label: 3      │  │  - label: 1      │                    │   │
│  │  │  - 지역변수들    │  │  - 지역변수들    │                    │   │
│  │  │  (수백 바이트)   │  │  (수백 바이트)   │                    │   │
│  │  └──────────────────┘  └──────────────────┘                    │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  코루틴 10,000개 = 수 MB만 필요!                                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

5.3 Continuation (상태 저장)

// 원본 suspend 함수
suspend fun fetchData(): String {
    val a = api1()  // suspend point 1
    val b = api2()  // suspend point 2
    return a + b
}

// 컴파일러가 변환한 상태 머신 (개념적)
class FetchData$Continuation : Continuation<String> {
    var label = 0           // 현재 어디까지 실행했는지
    var a: String? = null   // 지역 변수 저장
    var b: String? = null

    fun invokeSuspend(result: Any?): Any {
        when (label) {
            0 -> { label = 1; /* api1 호출 */ }
            1 -> { a = result; label = 2; /* api2 호출 */ }
            2 -> { b = result; return a + b }
        }
    }
}

5.4 Context Switching 비교

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  ┌─────────────────────────┬─────────────────────────────────────────┐ │
│  │       Thread            │           Coroutine                     │ │
│  ├─────────────────────────┼─────────────────────────────────────────┤ │
│  │  OS 커널 개입 필요      │  유저 스페이스에서 처리                 │ │
│  │  모든 레지스터 저장     │  label + 지역변수만 저장               │ │
│  │  TLB 플러시 가능성      │  TLB 영향 없음 (같은 스레드)           │ │
│  │  캐시 미스 발생         │  캐시 유지                              │ │
│  │  비용: 1~10 μs          │  비용: 수십~수백 ns                     │ │
│  │  메모리: ~1MB/스레드    │  메모리: 수백 바이트/코루틴             │ │
│  └─────────────────────────┴─────────────────────────────────────────┘ │
│                                                                         │
│  💡 코루틴 전환이 스레드 전환보다 약 100배 빠름!                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

6. @Async vs Coroutine (Spring)

6.1 @Async 동작 방식

@Service
class MyService {

    @Async  // 별도 스레드에서 실행!
    fun heavyTask(): CompletableFuture<String> {
        println("실행 스레드: ${Thread.currentThread().name}")
        Thread.sleep(1000)  // 블로킹! 이 스레드는 점유됨
        return CompletableFuture.completedFuture("결과")
    }
}
┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  @Async 호출 시:                                                        │
│                                                                         │
│  Main Thread                    Thread Pool (TaskExecutor)              │
│       │                                                                 │
│       │── heavyTask() 호출 ────►│                                       │
│       │◄─ Future 즉시 리턴 ─────│                                       │
│       │  (다른 작업 계속)       │── 새 스레드에서 실행 ──┐              │
│       │                         │                   Thread-1            │
│       │                         │                   [heavyTask]         │
│       │                         │                   Thread.sleep()      │
│       │                         │                   (블로킹!)           │
│       │── future.get() ─────────┼───────────────────────┘               │
│                                                                         │
│  💡 별도 스레드가 생성됨! (스레드 풀에서 가져옴)                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

6.2 핵심 비교

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  ┌──────────────────┬─────────────────────┬───────────────────────────┐│
│  │                  │      @Async         │       Coroutine           ││
│  ├──────────────────┼─────────────────────┼───────────────────────────┤│
│  │ 스레드           │ 별도 스레드 필요    │ 기존 스레드 재사용        ││
│  │ 메모리           │ ~1MB/스레드         │ ~수백 바이트/코루틴       ││
│  │ 전환 비용        │ OS Context Switch   │ 상태 객체 교체            ││
│  │ 동시 실행 수     │ 스레드 풀 크기 제한 │ 수십만 개 가능            ││
│  │ I/O 대기 시      │ 스레드 블로킹       │ 스레드 양보 (suspend)     ││
│  │ 사용법           │ @Async 어노테이션   │ suspend fun, launch       ││
│  │ 반환 타입        │ CompletableFuture   │ Deferred, Flow            ││
│  └──────────────────┴─────────────────────┴───────────────────────────┘│
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

6.3 I/O 대기 시 차이

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  10개의 API 호출 (각 1초 대기):                                         │
│                                                                         │
│  @Async 방식:                                                           │
│  ┌────────┐ ┌────────┐ ┌────────┐     ┌────────┐                       │
│  │Thread-1│ │Thread-2│ │Thread-3│ ... │Thread-10│ ← 10개 스레드 필요!   │
│  │[1초대기]│ │[1초대기]│ │[1초대기]│     │[1초대기]│   (각 스레드 블로킹) │
│  └────────┘ └────────┘ └────────┘     └────────┘                       │
│  메모리: 10 × 1MB = 10MB                                                │
│                                                                         │
│  Coroutine 방식:                                                        │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │                         Thread-1 (하나!)                         │  │
│  │  [코루틴1 suspend][코루틴2 suspend]...[코루틴10 suspend]         │  │
│  │       ↓ 모두 대기 중, 스레드는 비어있음!                        │  │
│  │  [코루틴1 완료][코루틴2 완료]...[코루틴10 완료]                  │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│  메모리: 10 × 수백 바이트 = 수 KB                                       │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

6.4 언제 뭘 쓸까?

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  @Async 적합한 경우:                                    │
│  ├── CPU 집약적 작업 (계산, 인코딩, 압축)               │
│  ├── 레거시 블로킹 코드 호출                            │
│  ├── 진짜 병렬 실행 필요 (멀티코어 활용)                │
│  └── 기존 Spring 프로젝트에서 간단히 비동기 추가        │
│                                                         │
│  Coroutine 적합한 경우:                                 │
│  ├── I/O 집약적 작업 (네트워크, DB)                     │
│  ├── 대량의 동시 요청 처리                              │
│  ├── 자원 효율성이 중요한 경우                          │
│  └── 새 Kotlin 프로젝트                                 │
│                                                         │
│  💡 판단 기준:                                          │
│  "작업이 CPU를 계속 쓰나?" → @Async (별도 스레드 필요)  │
│  "작업이 주로 대기하나?"   → Coroutine (스레드 낭비 없음)│
│                                                         │
└─────────────────────────────────────────────────────────┘

7. Dispatcher (실행 스레드 지정)

launch(Dispatchers.Main) {
    // UI 스레드에서 실행
}

launch(Dispatchers.IO) {
    // I/O 작업용 스레드 풀 (DB, 네트워크, 파일)
}

launch(Dispatchers.Default) {
    // CPU 집약적 작업용 (코어 수만큼 스레드)
}

withContext(Dispatchers.IO) {
    // 컨텍스트 전환
    val data = networkCall()
}
┌─────────────────────────────────────────────────────────┐
│                                                         │
│  Dispatcher 선택:                                       │
│                                                         │
│  ├── Dispatchers.Main                                   │
│  │   └── Android UI 업데이트, 짧은 작업                 │
│  │                                                      │
│  ├── Dispatchers.IO                                     │
│  │   └── 네트워크, DB, 파일 I/O                         │
│  │   └── 스레드 수 많음 (64개 또는 코어 수)             │
│  │                                                      │
│  ├── Dispatchers.Default                                │
│  │   └── CPU 집약적 계산                                │
│  │   └── 스레드 수 = CPU 코어 수                        │
│  │                                                      │
│  └── Dispatchers.Unconfined                             │
│      └── 특수 용도 (거의 안 씀)                         │
│                                                         │
└─────────────────────────────────────────────────────────┘

8. suspend 포인트 (스레드 양보 시점)

┌─────────────────────────────────────────────────────────┐
│                                                         │
│  코루틴이 스레드를 양보하는 순간:                       │
│                                                         │
│  ├── delay(ms)           : 시간 대기                   │
│  ├── yield()             : 명시적 양보                 │
│  ├── channel.send()      : 채널 전송                   │
│  ├── channel.receive()   : 채널 수신                   │
│  ├── mutex.lock()        : 뮤텍스 획득                 │
│  ├── deferred.await()    : 결과 대기                   │
│  ├── withContext()       : 컨텍스트 전환               │
│  └── 모든 suspend 함수   : 네트워크, DB, 파일 등       │
│                                                         │
│  ⚠️ suspend 포인트가 없으면 양보하지 않음!             │
│                                                         │
│  // 스레드 양보 안 함 (CPU 독점)
│  launch {
│      while(true) { /* 계산 */ }
│  }
│
│  // 스레드 양보함
│  launch {
│      while(true) {
│          yield()  // 매번 양보
│      }
│  }
│                                                         │
└─────────────────────────────────────────────────────────┘

9. 정리

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│  Coroutine = 경량 스레드, 일시 중단 가능한 함수                         │
│                                                                         │
│  가벼운 이유:                                                           │
│  ├── Stack 없음 (Stackless) - Heap에 Continuation만                    │
│  ├── Context Switching 없음 - 상태 객체 교체만                         │
│  ├── 캐시 친화적 - 같은 스레드 사용                                    │
│  └── 컴파일러가 상태 머신으로 변환                                     │
│                                                                         │
│  launch vs async:                                                       │
│  ├── launch: 결과 없음, fire-and-forget                                │
│  └── async: 결과 있음, await()로 받기                                  │
│                                                                         │
│  @Async vs Coroutine:                                                   │
│  ├── @Async: 별도 스레드, 무거움, CPU 작업용                           │
│  └── Coroutine: 같은 스레드 재사용, 가벼움, I/O 작업용                 │
│                                                                         │
│  비유:                                                                  │
│  ├── 스레드 = 각자 책상(Stack)이 있는 직원, 교대 시 책상 전체 정리     │
│  ├── 코루틴 = 책상 하나 공유, 교대 시 메모(Continuation)만 남김        │
│  ├── @Async = 일꾼을 더 고용 (월급 비쌈)                               │
│  └── Coroutine = 한 일꾼이 효율적으로 여러 일 처리                     │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

관련 키워드

Coroutine, launch, async, suspend, Continuation, @Async, Dispatcher, Stackless, 동시성, 비동기