본문으로 건너뛰기
Previous
Next
Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드

Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드

Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드

이 글의 핵심

Kotlin 코루틴: 비동기 프로그래밍 코루틴 설정·코루틴 빌더.

들어가며

코루틴 얘기할 때 스레드 수십 개 띄우는 그림만 먼저 떠올리면 이미 절반은 틀렸다고 본다. 핵심은 “멈췄다가 이어갈 수 있는 일”을 문법으로 쓰게 해 준 suspend랑, 그걸 어디 스레드에 올릴지 고르는 디스패처거든.

Go 고루틴·채널은 “많은 경량 일꾼 + 통신” 쪽 느낌이 강하고, Kotlin은 suspend에서 실행을 양보하는 쪽이 async/await 계열에 가깝다. Rust async/Tokio·Node·JS Promise랑 자주 견줘지는 이유가 그거다.

솔직한 취향: 나는 Dispatchers.Unconfined는 거의 쓰지 말자는 쪽이다. “디버그할 때 누가 어디서 돌아가는지”가 먼저 흐려져서, 이득이 체감될 때까지 기다리느니 처음부터 Main/IO/Default로 쪼개는 게 낫다.

  • 경량이라 수천 개 겹쳐도 괜찮은 경우가 많고
  • async/await 스타일이라 읽는 사람 뇌엔 덜 상처 준다(콜백 지옥 대비)
  • 구조화된 동시성 덕에 취소를 “끄덕”이 아니라 “트리”로 잡을 수 있음
  • 취소·타임아웃을 언어 차원에서 이야기할 수 있음

안드로이드에서 겪는 게 제일 잘 감 온다

옛날에 RecyclerView 쪽에서 GlobalScope.launch로 API 쏘고, 스크롤하다가 화면 뺐더니 “어? 콜백은 와 있는데 뷰는 죽었네” 같은 거 한 번쯤 겪어봤다면, 그게 생명주기랑 엮인 코루틴이 제일 잘 터지는 환경이다. 메인스레드는 한 개인데, 일은 비동기로 잔뜩 풀어 놨으니, 누가 취소해 줄지가 곧 UX랑 직결이야.

내가 흔히 봤던 패턴은 이런 느낌이다. 리스트 셀에 썸네일 로딩 붙이면서 viewModelScope는 안 쓰고, 컴포저블/프래그먼트에 직접 launch 박은 다음, 로딩 끝날 때 findViewById가 null이라 터지는. 혹은 Dispatchers.Main에서 JSON 통째로 파싱하다 ANR 한 방 먹는. 둘 다 “코루틴 썼으니 멀쩡한 줄” 알았는데, 스레드랑 생명주기만 틀어져도 끝나는 케이스다.

viewModelScope + Dispatchers.IO + 구조화된 취소(화면 나가면 Job 정리) 이 삼박자가 Android에서 코루틴을 “쓰기”에서 “쓸 만하게” 바꾼다고 생각한다. 이 길 말고 Kotlin Android 쪽이 더 촘촘하니, 여기서는 “코루틴 뼈대”만 잡고 넘기자.


launch vs async, 표 말고 결론부터

launch결과를 안 남기는 불Job이 나오고, 보통 부수 효과(로그, UI 갱신, 이벤트) 쪽에 쓴다. asyncDeferred<T>를 줘서 나중에 값 꺼내 먹는 병렬 작업에 쓴다. 예외는 launch 쪽이 CoroutineExceptionHandler/부모 전파 쪽에 더 잘 터지고, asyncawait() 하는 순간에 “아 맞다 여기 터지는구나” 느낌이 강하다.

의견: “그냥 async만 잔뜩” 붙이고 await를 빼 둔 코드, 나중에 조용히 썩는다. 완료·실패를 awaitjoinAll 같은 걸로 반드시 건드리게 만드는 쪽이 맞다.

  • 결과가 없고 기다리기만 하면 launch + 필요하면 join().
  • 여러 군데 동시에 굴리다 합쳐야 하면 async + await() / awaitAll().
fun main() = runBlocking {
    launch { println("fire-and-forget") }
    val a = async { delay(10); 1 }
    val b = async { delay(10); 2 }
    println(a.await() + b.await())  // 병렬 후 합산
}

구조화된 동시성, 주방 얘기 말고 그냥

동시에 여러 API 때리는 건 “주방”보다 부모 Job이 죽으면 자식도 같이 죽는다는 쪽이 실무에 더 와닿는다. coroutineScope { }, supervisorScope { }, ViewModelviewModelScope가 그 규칙을 쓰는 케이스다.

  • coroutineScope: 자식 하나라도 실패하면 나머지 취소 + 실패로 전파. “한 묶음이면 같이 죽자”
  • supervisorScope: 자식 실패가 형제한테 퍼지지 않음. “피드는 가져올 수 있는데 프로필만 500” 같은 UI에서 유리
suspend fun loadDashboard() = supervisorScope {
    val profile = async { api.profile() }   // 실패해도
    val feed = async { api.feed() }         // feed는 시도 가능
    runCatching { profile.await() }.getOrNull() to feed.await()
}

내 입장: Android에서 supervisorScope는 “한 화면에 요청이 여러 갈래로 갈릴 때” 꽤 자주 쓴다. 반대로 “결제 한 번 = 트랜잭션 한 방”이면 coroutineScope가 맞다.


1. 코루틴 설정

build.gradle.kts

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}

2. 코루틴 빌더

runBlocking

import kotlinx.coroutines.*
fun main() = runBlocking {
    println("시작")
    delay(1000)
    println("끝")
}

launch

fun main() = runBlocking {
    launch {
        delay(1000)
        println("World!")
    }
    println("Hello,")
    delay(2000)  // 코루틴 완료 대기
}
// 출력:
// Hello,
// World!

async

fun main() = runBlocking {
    val deferred = async {
        delay(1000)
        "결과"
    }
    
    println("작업 중...")
    val result = deferred.await()
    println(result)
}

3. suspend 함수

suspend fun fetchUser(): String {
    delay(1000)  // 네트워크 요청 시뮬레이션
    return "홍길동"
}
suspend fun fetchPosts(): List<String> {
    delay(1500)
    return listOf("Post 1", "Post 2")
}
fun main() = runBlocking {
    val user = fetchUser()
    val posts = fetchPosts()
    
    println("사용자: $user")
    println("게시글: $posts")
}

4. 코루틴 스코프

CoroutineScope

val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
    println("백그라운드 작업")
}
// 종료
scope.cancel()

Dispatchers

// Main: UI 스레드 (Android)
launch(Dispatchers.Main) {
    updateUI()
}
// IO: 네트워크, 파일
launch(Dispatchers.IO) {
    fetchData()
}
// Default: CPU 집약적
launch(Dispatchers.Default) {
    heavyComputation()
}
// Unconfined: 제한 없음
launch(Dispatchers.Unconfined) {
    // 작업
}

5. 병렬 실행

async + await

fun main() = runBlocking {
    val time = measureTimeMillis {
        val user = async { fetchUser() }      // 1초
        val posts = async { fetchPosts() }    // 1.5초
        
        println("사용자: ${user.await()}")
        println("게시글: ${posts.await()}")
    }
    
    println("총 시간: ${time}ms")  // 약 1500ms (병렬)
}

6. Flow

기본 Flow

fun numbers(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(100)
        emit(i)
    }
}
fun main() = runBlocking {
    numbers().collect { value ->
        println(value)
    }
}

Flow 연산자

fun main() = runBlocking {
    (1..10).asFlow()
        .filter { it % 2 == 0 }
        .map { it * it }
        .collect { println(it) }
    // 4, 16, 36, 64, 100
}

StateFlow와 SharedFlow

class ViewModel {
    private val _state = MutableStateFlow(0)
    val state: StateFlow<Int> = _state
    
    fun increment() {
        _state.value++
    }
}
fun main() = runBlocking {
    val vm = ViewModel()
    
    launch {
        vm.state.collect { value ->
            println("State: $value")
        }
    }
    
    delay(100)
    vm.increment()
    vm.increment()
    delay(100)
}

7. 실전 예제

예제 1: API 호출

data class User(val id: Int, val name: String)
suspend fun fetchUserFromApi(id: Int): User {
    delay(1000)  // 네트워크 지연
    return User(id, "사용자$id")
}
fun main() = runBlocking {
    val users = (1..5).map { id ->
        async { fetchUserFromApi(id) }
    }.awaitAll()
    
    users.forEach { println(it) }
}

예제 2: 타임아웃

fun main() = runBlocking {
    try {
        withTimeout(1500) {
            repeat(3) {
                println("작업 $it")
                delay(1000)
            }
        }
    } catch (e: TimeoutCancellationException) {
        println("타임아웃!")
    }
}

8. Dispatchers 상세 (Main, IO, Default, Unconfined)

  • Main: Android에선 UI 스레드. 여기는 짧게. 네트워크/파일/CPU 작업 넣지 마라가 정석이다(넣는 순간 “코루틴 쓰는데 왜 끊기지” 시작).
  • IO: 블로킹 I/O 냄새 나는 풀. 파일, 소켓, DB. 스레드가 좀 더 많다.
  • Default: CPU 잡는 일. 정렬, 이미지, 무식한 루프. 코어 수에 맞게 제한.
  • Unconfined: “어디서 돌지”를 고정하지 않는다. 첫 suspend 전까지는 호출 스레드에 붙을 수도 있고… 나는 테스트·아주 좁은 특수 케이스 아니면 그냥 피한다고 봄. 예측이 어렵고, UI 코드랑 엮이면 잡기 빡세다.
// UI → 백그라운드 → 다시 UI
withContext(Dispatchers.IO) { readFile() }
withContext(Dispatchers.Default) { heavySort(data) }
withContext(Dispatchers.Main) { updateUi() }

9. (아까 썼지만) 주방 얘기 보태면

일상 비유로 이해하기: 동시에 여러 타이머를 돌리는 느낌(스택 하나로 여러 요리)이 동시성에 가깝고, 진짜로 요리사 여럿이 병렬로 굽는 게 병렬성에 가깝다. 코루틴은 둘 다 잡을 수 있지만, Android에서 자주 쓰는 그림은 “메인 1 + IO/디폴트 풀 N”.


10. Flow vs Channel, 표 말고 감으로

Flow는 콜드/핫 스트림 쪽, 특히 flow { }는 “구독할 때” 흐름이 잡히는 쪽이 강하다. UI 상태, 페이지 네이션, 이벤트 스트림 — ViewModel + StateFlow/SharedFlow 루트면 거의 Flow 쪽. 다수 소비자는 SharedFlow / StateFlow로 뿌리면 된다.

Channel송신–수신 파이프. 액터 스타일, 워커 큐, “작업 한 건 넣고 한 건 꺼내는” 느낌. 백프레셔가 꼭 필요한 설계에서 자주 이야기된다. 소비자 여럿 붙이는 건 Channel이 더 손이 간다(그래서 Flow 쪽이 UI에 잘 박힘).

실무 팁: 화면 상태·API 결과 스트림이면 Flow가 자연스럽다. “코루틴끼리 일 한 건 던지기”면 Channel. 둘 다 필요하면 channelFlow { }로 합치기도 한다. 취향 싸움 아니고, “누가 누구를 구독하느냐”에 가깝다.


11. 실전 에러 처리 (try-catch, CoroutineExceptionHandler)

  • suspend동기 예외 → 그냥 try / catch.
  • launch 루트에서 터지는 미처리 예외 → CoroutineExceptionHandler나 부모로 전파.
  • async → 예외는 대개 await()에서 터진다(그 전엔 CancellationException 쪽이 될 수도).
val handler = CoroutineExceptionHandler { _, e ->
    println("Unhandled: ${e.message}")
}
fun main() = runBlocking {
    val scope = CoroutineScope(SupervisorJob() + handler)
    scope.launch {
        error("from launch")
    }
    delay(100)
    scope.cancel()
}

async 내부를 기록만 하고 흐르게 하려면 runCatching { }supervisorScope 조합이 익숙하다.


12. 취소와 타임아웃

  • 협력적 취소: delay, yield, isActive 이런 애들이 있어야 빨리 멈춘다. Java 블로킹만 있으면 취소가 늦게 먹힌다(그래서 I/O는 중단 가능한 API + Dispatchers.IO 조합이 이득인 경우가 많다).
  • withTimeoutTimeoutCancellationException (상위는 CancellationException 계열).
  • withTimeoutOrNull → 초과 시 null로 실패를 단순화.
suspend fun work() = coroutineScope {
    launch {
        while (isActive) {
            delay(100)
            // 취소 시 루프 종료
        }
    }
}
suspend fun fetchOrNull() = withTimeoutOrNull(3000) {
    api.slowCall()
}

실전 심화 보강

실전 예제: coroutineScope로 부분 실패 수집

여러 비동기 작업을 동시에 시도하되, 하나가 실패해도 나머지 결과를 모으고 싶을 때 supervisorScope와 조합할 수 있다. 아래는 표준 라이브러리만으로 성공 문자열과 예외를 분리하는 예이다.

import kotlinx.coroutines.*
suspend fun mayFail(id: Int): String {
    delay(50)
    if (id % 4 == 0) error("fail on $id")
    return "ok-$id"
}
suspend fun gather(ids: List<Int>): Pair<List<String>, List<Throwable>> = coroutineScope {
    val results = ids.map { id ->
        async {
            runCatching { mayFail(id) }
        }
    }.awaitAll()
    val oks = results.mapNotNull { it.getOrNull() }
    val errs = results.mapNotNull { it.exceptionOrNull() }
    oks to errs
}
fun main() = runBlocking {
    val (oks, errs) = gather((1..12).toList())
    println("성공: $oks")
    println("실패 수: ${errs.size}")
}

자주 하는 실수

  • GlobalScope.launch — 생명주기랑 끊으면 “언제 죽는지” 모르는 유령 Job이 남는다. Android에선 거의 금지에 가깝다고 본다.
  • Main에서 장시간 연산 — 코루틴이 아니라 스레드 문제다. Default로 보내라.
  • asyncawait 없이 — 조용한 실패/자원 낭비.

주의사항

  • 뷰모델·Android 쪽에선 SupervisorJob + viewModelScope 패턴으로 취소를 맞추는 경우가 많다(세부는 Android 편으로).

실무에서는 이렇게

  • I/O는 IO, CPU는 Default. Unconfined는 “정말 이유가 있을 때만”.
  • 부모 취소 = 자식 취소, 이거 안 지키면 “화면 껐는데 API만 도는” 그림이 나온다.

RxJava랑 뭐가 다르냐 (한 줄 취향)

  • 코루틴은 Kotlin/구조화된 동시성 쪽 1지망. 새 프로젝트면 이쪽으로 수렴시키는 게 유지보수에 이득인 경우가 많다.
  • RxJava는 레거시 Android에서 아직 튼튼하다. “갈아엎는 비용”이 크면 점진적으로 suspend 래핑 쪽이 현실적이다.
  • Java 21 가상 스서버/JVM 이야기에 가깝다. ANR이 있는 Android 메인이랑은 문제 정의가 다르다.

추가 리소스


정리

핵심 요약

  1. launch / async: 부수 효과 vs Deferred로 병렬 결과; await 누락 = 함정
  2. Dispatchers: Main(UI) 짧게, IO(I/O), Default(CPU), Unconfined는 난 쓰기 싫다는 입장
  3. 구조화된 동시성: 부모 취소 = 자식 취소; coroutineScope vs supervisorScope
  4. Flow vs Channel: 스트림/상태는 Flow, 파이프/큐느낌은 Channel. UI는 Flow가 편한 경우가 많다
  5. 에러 처리: try/catch, CoroutineExceptionHandler, asyncawait 시점
  6. 취소·타임아웃: 협력적 취소, withTimeout / withTimeoutOrNull
  7. suspend / Flow·StateFlow — 비동기의 문법
  8. Android에선 viewModelScope + Main/IO 분리가 체감 품질을 좌우한다

다음 단계


관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드」)를 구현·런타임·운영 관점에서 다시 압축한다. 도메인마다 다르지만, 입력 검증 → 핵심 연산 → 부작용 → 관측으로 쪼개면 장애 추적이 빨라진다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건을 문장으로 적어 두면 디버깅 비용이 준다(버퍼, FD, 격리 수준).
  • 순수/비순수 층을 나누면 테스트·장애 분석이 쉬워진다.
  • 백프레셔는 “누가 느리게 먹을지”를 미리 정해 두는 쪽이 덜 터진다.

프로덕션 운영에서 자주 박는 질문(표 대신)

  • 관측성: 상관 ID, p95/p99, 의존성 타임아웃/재시도가 대시보드에 보이냐. 안 보이면 “감”으로 돌리는 것과 같다.
  • 안전성: 권한·검증·감사 로그가 경로마다 일관되냐. 한 군데만 구멍 나도 그게 3시간 야근이다.
  • 신뢰성: 재시도는 멱등일 때만. 그 외는 서킷/백오프/DLQ 쪽.
  • 성능: N+1, 직렬화, 락, 풀 크기, 캐시. 한 번에 한 가지만 의심하는 게 수확이 좋다.
  • 배포: 카나리, 롤백, 마이그레이션, 피처플래그 — 문서에 이름이 박혀 있냐.
  • 용량: 피크때 FD·스레드·디스크 상한, 주기적으로 찍어 보냐.

스테이징은 데이터 양·RTT·동시성을 프로덕에 가깝게 맞출수록 “현장 재현”이 쉬워진다.

확장 예시: 엔드투엔드 미니 시나리오

앞 주제를 배포·운영 흐름에 옮긴 체크리스트다. 도메인 이름만 갈아 끼우면 된다.

  1. 입력 계약 고정: 스키마, 최대 페이로드, 타임아웃, 에러 코드
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 코드
  3. 실패 주입: 5xx, 타임아웃, 부분 데이터(스테이징)
  4. 롤백 루트 확인: 설정·마이그·클라이언트
  5. 부하 후 p99·에러율·알람 임계값 점검
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting), 표 말고 체감

  • 간헐: 레이스, DNS, 외부 의존성, 타임아웃. 최소 재현 + 트레이스부터.
  • 느려짐: N+1, 동기 I/O, 락, 직렬화, 캐시 미스. 프로파일러로 핫스팟 하나씩.
  • 메모리: 캡 없는 캐시, 리스너/구독 누수, 커넥션 미반납. 힌트는 스냅샷 비교.
  • 빌드만 깨짐: 환경·lockfile·이미지 핀. CI 로그 vs 로컬.
  • 설정 뒤죽박죽: 시크릿/프로필/리전. “단일 소스 + 검증”이 답.
  • 데이터 꼬임: 비멱등 재시도, 캐시 무효화 누락, 부분 쓰기. 멱등 키·트랜잭션 다시.

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 (3) 환경 차이 (4) 관측으로 가설 (5) 수정 후 부하. 배포 전엔 git addcommitpushnpm run deploy — 이건 Cloudflare/SSG 쪽 루틴.


자주 묻는 질문 (FAQ)

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

A. 서버도 앱도 “비동기 흐름 꼬이면” 다 여기로 귀결된다. 코루틴은 문법·런타임·운영(로그, 타임아웃)까지 한 번에 생각하라는 뜻에 가깝다.

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

A. 이전 글·관련 글 링크를 따라가도 되고, Kotlin 시리즈 목차가 한눈에 편하다.

Q. 더 깊이 공부하려면?

A. Kotlin Coroutines Guide이 본家다. C++ 쪽 끌고 오면 cppreference도 쓰지만, 이 글 주제는 Kotlin 문서가 더 직빵이다.


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

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


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

Kotlin, 코루틴, 비동기, Coroutines 등으로 검색해 보시면 이 글이 도움이 될 수 있다.