본문으로 건너뛰기
AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
Kotlin 코루틴 비동기 프로그래밍 개념 일러스트
Kotlin

Kotlin 코루틴 핵심 5가지 개념과 실전 활용법

By AICosmus
2026년 07월 28일 12 Min Read
0

네트워크 요청을 보내고 데이터베이스를 조회하는 동안 앱이 멈춰버린다면 사용자 경험은 최악이 됩니다. 자바에서는 이 문제를 콜백, Future, RxJava 같은 도구로 풀어왔지만, 코드가 복잡해지고 이른바 콜백 지옥에 빠지기 쉬웠습니다. Kotlin 코루틴은 이런 복잡함을 근본적으로 해결합니다. 비동기 코드를 마치 동기 코드처럼 위에서 아래로 읽히도록 작성할 수 있게 해주는 언어 차원의 기능이기 때문입니다.

이 글에서는 Kotlin 코루틴을 제대로 이해하고 실무에서 활용하기 위해 꼭 알아야 할 5가지 핵심 개념을 단계별로 짚어보겠습니다. suspend 함수의 동작 원리부터 CoroutineScope, 구조화된 동시성, 디스패처, 에러 처리까지 실전 코드 예제와 함께 하나씩 파헤칩니다. 코루틴을 처음 접하는 분도, 이미 사용하면서 내부 원리가 궁금한 분도 실질적인 도움을 얻어 가실 수 있을 것입니다.

시작하기 전에: 프로젝트 설정

본격적으로 코루틴을 배우기 전에 프로젝트에 필요한 의존성을 추가해야 합니다. Kotlin 코루틴은 별도의 라이브러리인 kotlinx.coroutines를 통해 제공됩니다. Gradle 기준으로 아래와 같이 설정합니다.

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
    
    // Android 프로젝트라면 추가
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}

의존성을 추가했다면 바로 첫 번째 코루틴을 실행해 볼 수 있습니다. 아래 코드를 실행해 보세요.

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch {
        delay(1000L)
        println("코루틴에서 실행!")
    }
    println("메인 흐름 계속 진행")
}
// 출력:
// 메인 흐름 계속 진행
// 코루틴에서 실행!

출력 순서를 보세요. “메인 흐름 계속 진행”이 먼저 나오고, 1초 뒤에 “코루틴에서 실행!”이 출력됩니다. launch로 시작된 코루틴이 별도로 실행되면서 메인 흐름은 멈추지 않고 바로 다음 줄로 넘어간 것입니다. 이것이 코루틴의 핵심 아이디어입니다.

Kotlin 코루틴과 스레드 비교 다이어그램

개념 1: 코루틴이란 — 스레드를 넘어서

코루틴을 이해하려면 먼저 스레드와 비교하는 것이 가장 좋습니다. 운영체제가 관리하는 스레드는 각각 고유한 스택 메모리를 차지합니다. 일반적으로 JVM 스레드 하나가 약 512KB에서 1MB의 스택 메모리를 사용합니다. 동시 요청이 1만 개라면 스레드만으로 5GB에서 10GB의 메모리가 필요합니다. 운영체제의 스레드 스케줄링 비용도 상당합니다.

코루틴은 이 문제를 완전히 다른 방식으로 풀어냅니다. 코루틴은 운영체제의 스레드 위에서 동작하는 경량 실행 단위입니다. 하나의 스레드가 수천, 수만 개의 코루틴을 번갈아 실행할 수 있습니다. 코루틴이 중단(suspend)되면 해당 스레드는 다른 코루틴의 작업을 처리하고, 원래 코루틴이 재개될 준비가 되면 다시 돌아와 이어서 실행합니다.

아래 예제로 차이를 체감해 보겠습니다.

import kotlinx.coroutines.*

// 코루틴 1만 개: 가볍게 실행
fun main() = runBlocking {
    val jobs = List(10_000) {
        launch {
            delay(1000L)
            print(".")
        }
    }
    jobs.forEach { it.join() }
    println("\n모든 코루틴 완료!")
}

이 코드를 실행하면 1만 개의 코루틴이 아무런 문제 없이 동작합니다. 메모리 사용량도 미미합니다. 만약 동일한 작업을 스레드로 시도한다면 상황이 달라집니다.

import kotlin.concurrent.thread

// 스레드 1만 개: 메모리 부족 위험
fun main() {
    val threads = List(10_000) {
        thread {
            Thread.sleep(1000L)
            print(".")
        }
    }
    threads.forEach { it.join() }
}

스레드 버전은 환경에 따라 OutOfMemoryError가 발생하거나 시스템이 극도로 느려질 수 있습니다. 이것이 코루틴이 경량 스레드라 불리는 이유입니다. 실제로 JetBrains의 공식 코루틴 가이드에서도 10만 개의 코루틴을 동시에 실행하는 예제를 통해 이 경량성을 강조하고 있습니다.

코루틴의 핵심 원리: 협력적 멀티태스킹

스레드는 운영체제가 강제로 실행 시간을 나누는 선점형(preemptive) 방식입니다. 반면에 코루틴은 스스로 실행을 양보하는 협력적(cooperative) 방식입니다. 코루틴이라는 이름 자체가 cooperative routine에서 온 것입니다.

코루틴이 delay, 네트워크 호출, 파일 I/O 같은 중단 지점을 만나면 자발적으로 실행을 양보하고, 그 스레드는 대기 중인 다른 코루틴의 작업을 처리합니다. 이 과정에서 컨텍스트 스위칭 비용이 스레드에 비해 극히 적습니다. 스레드 전환은 커널 모드 전환을 포함하지만, 코루틴 전환은 단순히 메모리상의 상태 객체를 교체하는 것에 가깝기 때문입니다.

개념 2: suspend 함수 — 코루틴의 심장

Kotlin 코루틴의 모든 것은 suspend 키워드에서 시작됩니다. suspend가 붙은 함수는 실행 도중 중단되었다가 나중에 재개될 수 있는 특별한 함수입니다.

suspend fun fetchUserData(userId: Int): User {
    val profile = fetchProfile(userId)    // 중단점 1: 네트워크 호출
    val orders = fetchOrders(userId)      // 중단점 2: 네트워크 호출
    return User(profile, orders)
}

suspend fun fetchProfile(userId: Int): Profile {
    // 실제로는 HTTP 클라이언트로 서버 호출
    delay(500L)  // 네트워크 지연 시뮬레이션
    return Profile(userId, "홍길동")
}

suspend fun fetchOrders(userId: Int): List<Order> {
    delay(800L)
    return listOf(Order(1, "노트북"), Order(2, "키보드"))
}

이 코드를 보면 마치 동기 코드처럼 읽힙니다. fetchProfile이 완료된 뒤에 fetchOrders가 실행되고, 두 결과를 조합해서 User를 반환합니다. 하지만 이 코드는 비동기로 동작합니다. 각 함수가 중단되는 동안 스레드는 다른 일을 할 수 있습니다.

suspend 함수의 규칙

suspend 함수에는 중요한 규칙이 있습니다. suspend 함수는 코루틴 안에서만 호출할 수 있다는 것입니다. 일반 함수에서 suspend 함수를 직접 호출하면 컴파일 에러가 발생합니다.

// 컴파일 에러!
fun loadData() {
    val data = fetchUserData(1)  // Suspend function 'fetchUserData'
                                  // should be called only from a coroutine
}

// 올바른 사용: 코루틴 안에서 호출
fun main() = runBlocking {
    val data = fetchUserData(1)  // OK
    println(data)
}

이 규칙은 의도적인 설계입니다. suspend 함수가 아무 데서나 호출된다면 중단과 재개를 관리할 코루틴 프레임워크가 동작하지 못합니다. 코루틴 스코프 안에서 호출해야 프레임워크가 중단 지점을 추적하고 적절한 시점에 재개할 수 있는 것입니다.

내부적으로 어떻게 동작하는가: CPS 변환

컴파일러는 suspend 함수를 CPS(Continuation Passing Style)로 변환합니다. 간단히 말하면 함수에 숨겨진 파라미터 Continuation을 추가하는 것입니다. Continuation은 “이 함수가 끝나면 다음에 뭘 해야 하는지”를 담고 있는 콜백 객체입니다.

// 우리가 작성하는 코드
suspend fun fetchProfile(userId: Int): Profile

// 컴파일러가 변환한 코드 (개념적 표현)
fun fetchProfile(userId: Int, continuation: Continuation<Profile>): Any?

반환 타입이 Any?로 바뀐 것도 주목하세요. 함수가 중단될 때는 특별한 마커 값(COROUTINE_SUSPENDED)을 반환하고, 완료되면 실제 결과를 반환합니다. 이 메커니즘 덕분에 콜백 없이도 비동기 흐름을 구현할 수 있는 것입니다. 개발자는 이 변환을 직접 다룰 필요 없이 suspend 키워드만 붙이면 됩니다.

코루틴 스코프 계층 구조와 빌더 관계도

개념 3: CoroutineScope와 빌더 함수

코루틴을 실행하려면 반드시 CoroutineScope 안에서 시작해야 합니다. 스코프는 코루틴의 생명주기를 관리하는 울타리라고 생각하면 됩니다. 스코프가 취소되면 그 안의 모든 코루틴도 함께 취소됩니다.

주요 빌더 함수: launch와 async

코루틴을 시작하는 두 가지 핵심 빌더가 있습니다. launch와 async입니다. 이 둘의 차이를 정확히 이해하는 것이 중요합니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // launch: 결과를 반환하지 않음, Job을 반환
    val job: Job = launch {
        delay(500L)
        println("launch 작업 완료")
    }
    
    // async: 결과를 반환, Deferred<T>를 반환
    val deferred: Deferred<Int> = async {
        delay(500L)
        println("async 작업 완료")
        42  // 결과값 반환
    }
    
    job.join()           // launch 완료 대기
    val result = deferred.await()  // async 결과 대기
    println("async 결과: $result")
}

launch는 결과를 반환하지 않는 코루틴을 시작합니다. “이 작업을 백그라운드에서 해줘”라는 의미입니다. 반환되는 Job 객체로 취소하거나 완료를 기다릴 수 있습니다.

async는 결과를 반환하는 코루틴을 시작합니다. “이 작업을 해서 결과를 줘”라는 의미입니다. 반환되는 Deferred 객체의 await()를 호출하면 결과를 받을 수 있습니다. 여러 작업을 병렬로 실행하고 결과를 모을 때 특히 유용합니다.

병렬 실행의 힘: async로 시간 절약

async의 진가는 여러 독립적인 작업을 동시에 실행할 때 발휘됩니다. 아래 예제를 보세요.

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun fetchUser(): String {
    delay(1000L)  // 1초 소요
    return "사용자 정보"
}

suspend fun fetchPosts(): String {
    delay(1200L)  // 1.2초 소요
    return "게시글 목록"
}

suspend fun fetchNotifications(): String {
    delay(800L)   // 0.8초 소요
    return "알림 목록"
}

fun main() = runBlocking {
    // 순차 실행: 1 + 1.2 + 0.8 = 약 3초
    val sequentialTime = measureTimeMillis {
        val user = fetchUser()
        val posts = fetchPosts()
        val notifications = fetchNotifications()
    }
    println("순차 실행: ${sequentialTime}ms")
    
    // 병렬 실행: max(1, 1.2, 0.8) = 약 1.2초
    val parallelTime = measureTimeMillis {
        val user = async { fetchUser() }
        val posts = async { fetchPosts() }
        val notifications = async { fetchNotifications() }
        
        println("${user.await()}, ${posts.await()}, ${notifications.await()}")
    }
    println("병렬 실행: ${parallelTime}ms")
}

순차 실행은 3초가 걸리지만, async로 병렬 실행하면 가장 오래 걸리는 작업 시간인 약 1.2초만에 모든 결과를 받을 수 있습니다. 세 작업이 서로 의존성이 없기 때문에 동시에 시작해도 결과에 문제가 없는 것입니다.

runBlocking vs coroutineScope

빌더로 자주 혼동되는 두 함수를 정리해 보겠습니다.

// runBlocking: 현재 스레드를 차단하고 코루틴 완료까지 대기
// 주로 main 함수나 테스트에서 사용
fun main() = runBlocking {
    // 이 블록이 끝날 때까지 메인 스레드가 차단됨
    launch { delay(1000L) }
}

// coroutineScope: 현재 코루틴을 중단하고 자식 완료까지 대기
// suspend 함수 안에서 새 스코프를 만들 때 사용
suspend fun loadDashboard() = coroutineScope {
    // 이 블록이 끝날 때까지 현재 코루틴이 중단됨 (스레드는 차단되지 않음)
    val user = async { fetchUser() }
    val posts = async { fetchPosts() }
    DashboardData(user.await(), posts.await())
}

runBlocking은 스레드를 실제로 차단합니다. 코루틴 세계와 일반 코드 세계를 연결하는 다리 역할입니다. 절대로 코루틴 안에서 runBlocking을 호출하지 마세요. 스레드가 차단되어 데드락이 발생할 수 있습니다.

coroutineScope는 스레드를 차단하지 않고 현재 코루틴만 중단합니다. suspend 함수 내부에서 여러 코루틴을 안전하게 묶어서 실행할 때 사용합니다. 이 블록 안의 모든 자식 코루틴이 완료되어야 블록이 끝납니다.

개념 4: 구조화된 동시성의 힘

Kotlin 코루틴의 가장 강력한 특징 중 하나가 구조화된 동시성(Structured Concurrency)입니다. 이 개념은 모든 코루틴이 명확한 부모-자식 관계를 갖도록 강제합니다.

부모-자식 관계와 생명주기

CoroutineScope 안에서 launch나 async로 시작된 코루틴은 자동으로 해당 스코프의 자식이 됩니다. 이 관계에서 세 가지 중요한 규칙이 적용됩니다.

첫째, 부모는 모든 자식이 끝날 때까지 완료되지 않습니다. coroutineScope 블록은 내부의 모든 코루틴이 종료된 후에야 다음 줄로 넘어갑니다.

둘째, 부모가 취소되면 모든 자식도 취소됩니다. 이것은 리소스 누수를 방지하는 핵심 메커니즘입니다.

셋째, 자식에서 발생한 예외는 부모로 전파됩니다. 처리되지 않은 예외는 부모를 취소시키고, 부모의 취소는 다른 자식들에게도 전파됩니다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val parentJob = launch {
        val child1 = launch {
            try {
                repeat(1000) { i ->
                    println("자식 1: 작업 $i")
                    delay(500L)
                }
            } finally {
                println("자식 1: 정리 작업 수행")
            }
        }
        
        val child2 = launch {
            try {
                repeat(1000) { i ->
                    println("자식 2: 작업 $i")
                    delay(300L)
                }
            } finally {
                println("자식 2: 정리 작업 수행")
            }
        }
    }
    
    delay(1200L)
    println("부모 코루틴 취소 요청")
    parentJob.cancel()  // 부모 취소 -> 자식 모두 취소
    parentJob.join()
    println("모든 코루틴이 정리됨")
}

출력을 보면 parentJob.cancel()이 호출된 뒤 두 자식 코루틴 모두 finally 블록을 실행하고 종료되는 것을 확인할 수 있습니다. 이것이 구조화된 동시성의 힘입니다. 개발자가 일일이 자식 코루틴을 추적하고 정리할 필요가 없습니다.

코루틴 에러 전파와 supervisorScope 비교

왜 GlobalScope를 피해야 하는가

GlobalScope.launch를 사용하면 구조화된 동시성의 보호를 받지 못합니다. GlobalScope로 시작된 코루틴은 부모가 없기 때문에 취소 전파도, 생명주기 관리도 되지 않습니다.

// 나쁜 예: GlobalScope 사용
fun processOrder(order: Order) {
    GlobalScope.launch {
        // 이 코루틴은 processOrder와 무관하게 살아남음
        // 메모리 누수, 불필요한 작업 지속 가능
        sendConfirmationEmail(order)
        updateInventory(order)
    }
}

// 좋은 예: 적절한 스코프 사용
suspend fun processOrder(order: Order) = coroutineScope {
    launch { sendConfirmationEmail(order) }
    launch { updateInventory(order) }
    // 두 작업이 모두 완료되어야 이 함수가 끝남
}

Android에서는 viewModelScope나 lifecycleScope처럼 생명주기에 바인딩된 스코프를 사용합니다. 화면이 닫히면 스코프가 취소되고, 불필요한 네트워크 호출이나 DB 작업도 자동으로 중단됩니다. 서버 측에서도 요청 스코프에 바인딩하면 클라이언트가 연결을 끊었을 때 불필요한 처리를 중단할 수 있습니다.

취소에 협력하는 코드 작성하기

코루틴의 취소는 협력적입니다. 코루틴이 취소 요청을 받았다고 즉시 멈추는 것이 아닙니다. 중단점(delay, yield, withContext 등)에서 취소를 확인합니다. 따라서 CPU 집약적인 루프에서는 직접 취소를 확인해야 합니다.

suspend fun heavyComputation(data: List<Int>): Int = coroutineScope {
    var result = 0
    for (item in data) {
        // 방법 1: ensureActive()로 취소 확인
        ensureActive()
        result += complexCalculation(item)
    }
    
    // 방법 2: isActive 프로퍼티 확인
    // while (isActive) { ... }
    
    result
}

ensureActive()는 현재 코루틴이 취소되었으면 CancellationException을 던집니다. 무거운 반복문 안에 이 한 줄만 넣어주면 취소에 즉시 반응하는 코드가 됩니다.

개념 5: 디스패처와 컨텍스트

코루틴이 어떤 스레드에서 실행될지를 결정하는 것이 CoroutineDispatcher입니다. 적절한 디스패처를 선택하는 것은 성능과 안정성에 직결됩니다.

4가지 기본 디스패처

Dispatchers.Main은 UI 스레드에서 코루틴을 실행합니다. Android나 JavaFX 같은 UI 프레임워크에서 화면 업데이트에 사용합니다. kotlinx-coroutines-android 의존성을 추가해야 Android에서 사용할 수 있습니다.

Dispatchers.IO는 I/O 작업에 최적화된 스레드 풀입니다. 파일 읽기/쓰기, 네트워크 호출, 데이터베이스 쿼리 같은 블로킹 I/O 작업에 사용합니다. 기본적으로 최대 64개의 스레드를 사용하며, 필요에 따라 조절됩니다.

Dispatchers.Default는 CPU 집약적인 작업을 위한 스레드 풀입니다. JSON 파싱, 정렬, 복잡한 계산 같은 작업에 적합합니다. CPU 코어 수만큼의 스레드를 사용합니다.

Dispatchers.Unconfined는 특정 스레드에 국한되지 않는 디스패처입니다. 첫 중단점까지는 호출 스레드에서 실행되고, 재개 후에는 중단 함수가 사용한 스레드에서 실행됩니다. 일반적으로 사용을 권장하지 않으며, 테스트나 특수한 경우에만 씁니다.

withContext로 스레드 전환하기

withContext는 코루틴의 실행 컨텍스트를 일시적으로 전환하는 함수입니다. 특정 작업을 적절한 디스패처에서 실행하고 결과를 받아올 때 사용합니다.

import kotlinx.coroutines.*

class UserRepository {
    suspend fun getUser(id: Int): User {
        // I/O 디스패처에서 DB 조회
        val rawData = withContext(Dispatchers.IO) {
            database.query("SELECT * FROM users WHERE id = $id")
        }
        
        // Default 디스패처에서 데이터 가공
        val processedData = withContext(Dispatchers.Default) {
            parseAndValidate(rawData)  // CPU 집약적 파싱
        }
        
        return processedData
    }
}

// Android ViewModel에서의 활용
class UserViewModel : ViewModel() {
    fun loadUser(id: Int) {
        viewModelScope.launch {  // Main 디스패처에서 시작
            val user = repository.getUser(id)  // 내부에서 IO/Default 전환
            _userState.value = user  // Main 디스패처에서 UI 갱신
        }
    }
}

withContext는 스레드를 전환하면서도 코루틴의 구조화된 동시성을 유지합니다. 부모가 취소되면 withContext 내부 작업도 함께 취소됩니다. 또한 withContext는 suspend 함수이므로 스레드를 차단하지 않고 전환이 이루어집니다.

디스패처 선택 가이드

  • 네트워크 요청, 파일 읽기/쓰기, DB 쿼리 → Dispatchers.IO
  • JSON 파싱, 리스트 정렬, 이미지 처리, 암호화 → Dispatchers.Default
  • UI 업데이트, 토스트 표시, RecyclerView 갱신 → Dispatchers.Main
  • 간단한 매핑이나 필터링 → 현재 디스패처 그대로 (별도 전환 불필요)

흔한 실수 중 하나는 모든 작업에 Dispatchers.IO를 쓰는 것입니다. CPU 집약적 작업을 IO 디스패처에서 실행하면 I/O 스레드 풀을 불필요하게 점유하여 정작 I/O 작업의 처리량이 떨어질 수 있습니다. 작업의 성격에 맞는 디스패처를 선택하는 습관을 들이세요.

실전 코루틴 패턴: 에러 처리부터 재시도까지

개념을 충분히 익혔으니 이제 실전에서 자주 만나는 패턴들을 살펴보겠습니다.

에러 처리: try-catch와 CoroutineExceptionHandler

코루틴의 에러 처리는 일반 코드와 비슷하면서도 몇 가지 주의할 점이 있습니다.

import kotlinx.coroutines.*

// 기본 패턴: try-catch
suspend fun fetchData(): String {
    return try {
        callApi()
    } catch (e: Exception) {
        println("API 호출 실패: ${e.message}")
        "기본값"  // 폴백
    }
}

// launch에서의 에러: CoroutineExceptionHandler 사용
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("잡히지 않은 예외: ${exception.message}")
    }
    
    // handler는 루트 코루틴에 설치해야 동작
    val job = CoroutineScope(Dispatchers.Default + handler).launch {
        throw RuntimeException("에러 발생!")
    }
    job.join()
}

중요한 차이가 있습니다. async의 예외는 await()를 호출할 때 다시 던져지므로 try-catch로 잡을 수 있습니다. 반면 launch의 처리되지 않은 예외는 부모 코루틴으로 전파됩니다. CoroutineExceptionHandler는 이러한 전파된 예외를 최종적으로 처리하는 안전망입니다.

SupervisorJob: 자식의 실패를 격리하기

기본적으로 자식 코루틴의 예외는 부모를 취소시키고, 부모의 취소는 다른 자식들에게도 전파됩니다. 하지만 때로는 한 자식의 실패가 나머지에 영향을 주지 않기를 원할 때가 있습니다. 이때 supervisorScope를 사용합니다.

suspend fun loadDashboardFeatures(): DashboardData = supervisorScope {
    val weather = async {
        fetchWeather()  // 실패해도 다른 기능에 영향 없음
    }
    val news = async {
        fetchNews()     // 독립적으로 실행
    }
    val stocks = async {
        fetchStocks()   // 독립적으로 실행
    }
    
    DashboardData(
        weather = runCatching { weather.await() }.getOrNull(),
        news = runCatching { news.await() }.getOrNull(),
        stocks = runCatching { stocks.await() }.getOrNull()
    )
}

supervisorScope에서는 각 자식 코루틴이 독립적입니다. 날씨 API가 실패해도 뉴스와 주식 데이터는 정상적으로 로드됩니다. runCatching과 getOrNull을 조합하면 실패한 기능은 null로 처리하고 나머지는 정상 표시할 수 있습니다. 대시보드처럼 여러 독립적인 데이터 소스를 조합하는 화면에서 특히 유용한 패턴입니다.

타임아웃 처리

네트워크 요청이 너무 오래 걸릴 때를 대비한 타임아웃 처리도 코루틴에서는 간결하게 구현할 수 있습니다.

import kotlinx.coroutines.*

// 타임아웃 초과 시 예외 발생
suspend fun fetchWithTimeout(): String {
    return withTimeout(3000L) {
        callSlowApi()  // 3초 초과 시 TimeoutCancellationException
    }
}

// 타임아웃 초과 시 null 반환 (예외 없음)
suspend fun fetchOrNull(): String? {
    return withTimeoutOrNull(3000L) {
        callSlowApi()
    }
}

// 실전 활용: 타임아웃 + 폴백
suspend fun getUserName(userId: Int): String {
    return withTimeoutOrNull(2000L) {
        userApi.fetchName(userId)
    } ?: "알 수 없는 사용자"  // 타임아웃 시 기본값
}

withTimeout은 지정된 시간이 초과되면 TimeoutCancellationException을 던집니다. 예외를 피하고 싶다면 withTimeoutOrNull을 사용하세요. 타임아웃 시 null을 반환하므로 엘비스 연산자(?:)와 조합하면 깔끔한 폴백 처리가 가능합니다.

재시도 패턴: 지수 백오프

네트워크 환경이 불안정할 때 실패한 요청을 자동으로 재시도하는 패턴은 실무에서 매우 자주 쓰입니다. 코루틴으로 구현하면 놀랍도록 간결합니다.

suspend fun <T> retryWithBackoff(
    times: Int = 3,
    initialDelay: Long = 1000L,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            println("시도 ${attempt + 1} 실패: ${e.message}")
            println("${currentDelay}ms 후 재시도...")
        }
        delay(currentDelay)
        currentDelay = (currentDelay * factor).toLong()
    }
    return block()  // 마지막 시도: 실패 시 예외 그대로 전파
}

// 사용 예시
suspend fun reliableFetch(): UserData {
    return retryWithBackoff(times = 3, initialDelay = 500L) {
        api.fetchUserData()  // 최대 3회 시도, 500ms -> 1000ms 간격
    }
}

delay가 코루틴의 suspend 함수이기 때문에 재시도 대기 중에도 스레드를 차단하지 않습니다. 지수 백오프(exponential backoff)를 적용하면 서버에 부담을 주지 않으면서 안정적으로 재시도할 수 있습니다. factor를 2.0으로 설정하면 대기 시간이 500ms, 1000ms, 2000ms로 점점 늘어납니다.

여러 패턴의 조합

실전에서는 위 패턴들을 조합해서 사용하는 경우가 많습니다. 재시도와 타임아웃을 함께 적용하는 예를 보겠습니다.

suspend fun robustApiCall(): ApiResponse {
    return retryWithBackoff(times = 3) {
        withTimeout(5000L) {
            api.call()  // 각 시도마다 5초 타임아웃 + 최대 3회 재시도
        }
    }
}

// supervisorScope + 타임아웃 + 폴백 조합
suspend fun loadHomePage(): HomePage = supervisorScope {
    val banner = async {
        withTimeoutOrNull(2000L) { fetchBanner() } ?: defaultBanner()
    }
    val products = async {
        retryWithBackoff(times = 2) { fetchProducts() }
    }
    val recommendations = async {
        withTimeoutOrNull(3000L) { fetchRecommendations() } ?: emptyList()
    }
    
    HomePage(
        banner = banner.await(),
        products = products.await(),
        recommendations = recommendations.await()
    )
}

이 코드를 콜백이나 RxJava로 작성한다면 얼마나 복잡해질지 상상해 보세요. 코루틴 덕분에 복잡한 비동기 로직도 위에서 아래로 자연스럽게 읽히는 코드로 표현할 수 있습니다.

Kotlin 코루틴 도입 시 주의할 점

코루틴은 강력하지만, 잘못 사용하면 기대와 다른 결과를 낳기도 합니다. 실무에서 자주 겪는 실수들을 정리했습니다.

runBlocking을 코루틴 안에서 사용하지 마세요. runBlocking은 현재 스레드를 차단합니다. 코루틴 안에서 호출하면 해당 스레드가 잠기면서 다른 코루틴도 실행되지 못하는 데드락 상황이 발생할 수 있습니다. 코루틴 간 연결에는 항상 coroutineScope나 withContext를 사용하세요.

suspend 함수 안에서 스레드를 차단하는 호출을 피하세요. Thread.sleep(), 블로킹 I/O, 동기 HTTP 호출 등은 코루틴의 장점을 무력화합니다. 반드시 withContext(Dispatchers.IO)로 감싸거나, 코루틴 호환 라이브러리를 사용해야 합니다.

예외 처리를 빠뜨리지 마세요. 코루틴에서 처리되지 않은 예외는 부모 코루틴을 취소시키고, 연쇄적으로 다른 작업에도 영향을 줍니다. 특히 launch로 시작한 코루틴은 try-catch를 내부에 넣거나 CoroutineExceptionHandler를 설치해야 합니다.

적절한 스코프를 사용하세요. GlobalScope는 앱 전체 생명주기를 따르므로, 화면이나 요청 단위의 작업에는 부적합합니다. Android라면 viewModelScope, 서버라면 요청 스코프에 바인딩하세요. Kotlin 공식 튜토리얼에서도 구조화된 동시성과 적절한 스코프 선택을 반복적으로 강조합니다.

마무리: 코루틴 학습의 다음 단계

지금까지 Kotlin 코루틴의 5가지 핵심 개념을 살펴봤습니다. 정리하면 다음과 같습니다.

  • suspend 함수는 중단과 재개가 가능한 함수로, 비동기 코드를 동기처럼 작성하게 해줍니다.
  • CoroutineScope와 빌더(launch, async)는 코루틴의 시작점이자 생명주기 관리의 기본 단위입니다.
  • 구조화된 동시성은 부모-자식 관계를 통해 취소 전파와 리소스 정리를 자동으로 처리합니다.
  • 디스패처는 코루틴의 실행 스레드를 결정하며, 작업 성격에 맞는 선택이 중요합니다.
  • 에러 처리 패턴(try-catch, supervisorScope, 타임아웃, 재시도)으로 안정적인 비동기 로직을 구성합니다.

코루틴의 기초를 다졌다면 다음으로 Flow를 학습하는 것을 추천합니다. Flow는 코루틴 기반의 반응형 스트림으로, 실시간 데이터 변경을 관찰하고 처리하는 데 탁월합니다. StateFlow와 SharedFlow는 Android의 LiveData를 대체하는 현대적 상태 관리 도구로 자리 잡고 있습니다. 또한 Channel을 익히면 코루틴 간의 통신 패턴을 확장할 수 있습니다.

코루틴은 한번 익숙해지면 다시 콜백이나 스레드 기반 코드로 돌아가기 어려울 만큼 편리합니다. 오늘 살펴본 예제들을 직접 실행해 보고, 자신의 프로젝트에 하나씩 적용해 보세요. 복잡한 비동기 로직이 놀랍도록 깔끔한 코드로 바뀌는 경험을 하실 수 있을 것입니다.


🔗 함께 보면 좋은 글

  • Kotlin 위임 패턴 완전 정복: by 키워드 실전 활용법
  • Kotlin Sealed Class 핵심 정리와 실전 활용 패턴
  • Kotlin DSL 만들기 실전 가이드: 빌더 패턴 완전 정복

Tags:

CoroutinesKotlin비동기프로그래밍코루틴코틀린개발
작성자

AICosmus

Follow Me
다른 기사
opencode 로컬 모델 자체 호스팅 보안 환경
Previous

[opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 12/12화: opencode 로컬 모델 완전 가이드 2026 — Ollama·에어갭·규제 환경 도입 체크리스트

opencode 에이전트 계층 구조 일러스트
Next

[opencode 시즌 2 심화 — 나만의 도메인 특화 에이전트 만들기] 1/12화: opencode 에이전트 아키텍처 완전 해부 — 2026 Primary·Subagent 5계층 구조

댓글 없음! 첫 댓글을 남겨보세요.

답글 남기기 응답 취소

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

최신 글

  • [opencode 시즌 2 심화 — 나만의 도메인 특화 에이전트 만들기] 1/12화: opencode 에이전트 아키텍처 완전 해부 — 2026 Primary·Subagent 5계층 구조
  • Kotlin 코루틴 핵심 5가지 개념과 실전 활용법
  • [opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 12/12화: opencode 로컬 모델 완전 가이드 2026 — Ollama·에어갭·규제 환경 도입 체크리스트
  • LLM 파인튜닝 실전 5단계 — 2026 LoRA 완벽 가이드
  • 금융 앱 생체인증 작동 원리, 지문·얼굴 보안 5단계 완전 해부

최신 댓글

  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 9/14화: 온프레미스 RAG 파이프라인 — bge-m3·Qdrant 자체 호스팅 실전의 Dockerfile 최적화 실전 가이드 — 빌드·크기·보안 총정리 - AICosmus
  2. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  3. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  4. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  5. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  • About
  • Contact
  • Disclaimer
  • Privacy - Policy
  • Terms of Service
Copyright 2026 — AICosmus. All rights reserved. Blogsy WordPress Theme