Kim-Baek 개발자 이야기

Kotlin 코루틴 기초 | launch, async, suspend로 비동기 정복하기 본문

개발/java basic

Kotlin 코루틴 기초 | launch, async, suspend로 비동기 정복하기

김백개발자 2025. 12. 29. 09:57
반응형

이 글을 읽으면: Thread의 복잡함 없이 Kotlin 코루틴으로 쉽고 안전하게 비동기 프로그래밍하는 방법을 배울 수 있습니다. 실무에서 바로 쓸 수 있는 launch, async, suspend 패턴을 실전 예제로 마스터하세요.


📌 목차

  1. 들어가며 - 왜 코루틴을 배워야 할까?
  2. 코루틴이란? - Thread와의 차이
  3. launch - 결과 없이 실행하기
  4. async/await - 결과 받아오기
  5. suspend 함수 - 일시 중단의 마법
  6. CoroutineScope와 Job
  7. 실전 비동기 패턴
  8. 마무리 - 다음 편 예고

들어가며 - 왜 코루틴을 배워야 할까?

실제 프로젝트에서 겪은 문제

2024년 9월, 신규 기능 개발 중

요구사항: 
- 사용자 정보 조회 (DB, 300ms)
- 주문 내역 조회 (DB, 400ms)  
- 추천 상품 조회 (API, 500ms)
→ 총 3개 작업을 동시에 처리하고 싶음

Thread 사용: 복잡하고 메모리 많이 먹음
CallbackHell: 코드가 너무 지저분함
RxJava: 러닝커브 너무 높음

해결책: Kotlin 코루틴 → 간단하고 직관적!

Thread의 문제점

Thread는 비싸다

// ❌ Thread 10만 개 만들기 - 불가능!
repeat(100_000) {
    Thread {
        Thread.sleep(1000)
        println("작업 완료")
    }.start()
}
// 결과: OutOfMemoryError 💥

비유로 이해하기

Thread = 직원 고용
- 직원 1명당 월급(메모리) 지급
- 10만 명 고용? → 회사 망함

Coroutine = 업무 분담
- 소수의 직원이 10만 개 업무를 번갈아 처리
- 효율적이고 경제적!

코루틴이 해결하는 것들

1. 간단한 문법

// Thread - 복잡함
Thread {
    val result = fetchData()
    runOnUiThread {
        updateUI(result)
    }
}.start()

// Coroutine - 간단함
launch {
    val result = fetchData()
    updateUI(result)
}

2. 메모리 효율

방식 10만 개 작업 메모리 사용

Thread 불가능 수 GB
Coroutine 가능 수십 MB

3. 구조적 동시성

부모 코루틴이 취소되면 자식도 자동 취소
→ 메모리 누수 방지
→ 앱 종료 시 안전

코루틴이란? - Thread와의 차이

코루틴의 핵심 개념

"일시 중단 가능한 경량 쓰레드"

// 이 함수는 "일시 중단"될 수 있다
suspend fun fetchUser(): User {
    delay(1000)  // 1초 대기 (Thread는 안 멈춤!)
    return User("규철")
}

// 사용
launch {
    println("시작")
    val user = fetchUser()  // 여기서 1초 대기
    println("완료: ${user.name}")
}

일시 중단이 뭐길래?

일반 함수 (blocking):
Thread.sleep(1000) 
→ 쓰레드가 멈춤 (다른 일 못 함)

suspend 함수 (non-blocking):
delay(1000)
→ 코루틴만 멈춤 (쓰레드는 다른 일 함)

비유: 요리하기

Thread 방식 (blocking):
파스타 삶는 동안 가만히 서서 기다림 ❌
→ 다른 요리 못 함

Coroutine 방식 (non-blocking):
파스타 삶는 동안 샐러드 만듦 ✅
→ 효율적!

실제 동작 원리

Thread는 1:1

// Thread 3개 = 작업 3개
repeat(3) {
    Thread {
        Thread.sleep(1000)
        println("Thread $it 완료")
    }.start()
}
// 실제 생성된 Thread: 3개

Coroutine은 N:M

// Thread 1개 = 작업 10만 개
repeat(100_000) {
    launch {
        delay(1000)
        println("Coroutine $it 완료")
    }
}
// 실제 생성된 Thread: CPU 코어 수만큼 (예: 4개)

launch - 결과 없이 실행하기

launch의 기본 사용법

"그냥 실행만 하면 돼, 결과는 필요 없어"

import kotlinx.coroutines.*

fun main() = runBlocking {  // 메인 함수를 코루틴으로
    println("시작")
    
    launch {
        delay(1000)  // 1초 대기
        println("World!")
    }
    
    println("Hello")
    
    delay(2000)  // 코루틴이 끝날 때까지 기다림
}
// 출력:
// 시작
// Hello
// (1초 후)
// World!

왜 Hello가 먼저 나올까?

1. launch는 "나중에 실행"을 예약
2. 메인 코드는 계속 실행
3. delay(1000) 동안 대기
4. 시간 되면 실행

실전 예제: 로깅 시스템

문제 상황

// ❌ 동기 방식 - 로그 쓰는 동안 멈춤
fun processOrder(order: Order) {
    saveLog("주문 시작: ${order.id}")  // 500ms 걸림 ⏱️
    validateOrder(order)
    saveLog("주문 검증 완료")  // 500ms 걸림 ⏱️
    processPayment(order)
    saveLog("결제 완료")  // 500ms 걸림 ⏱️
}
// 총 시간: 원래 작업 + 1500ms 😢

코루틴으로 해결

// ✅ 비동기 방식 - 로그는 백그라운드에서
fun processOrder(order: Order) = runBlocking {
    launch { saveLog("주문 시작: ${order.id}") }  // 백그라운드
    validateOrder(order)
    launch { saveLog("주문 검증 완료") }  // 백그라운드
    processPayment(order)
    launch { saveLog("결제 완료") }  // 백그라운드
}
// 총 시간: 원래 작업만! 😊

여러 개 동시 실행

순차 실행 vs 동시 실행

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    
    // ❌ 순차 실행 - 느림
    println("=== 순차 실행 ===")
    delay(1000)
    delay(1000)
    delay(1000)
    println("소요 시간: ${System.currentTimeMillis() - startTime}ms")
    // 소요 시간: 3000ms
}
fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    
    // ✅ 동시 실행 - 빠름
    println("=== 동시 실행 ===")
    launch { delay(1000); println("작업 1 완료") }
    launch { delay(1000); println("작업 2 완료") }
    launch { delay(1000); println("작업 3 완료") }
    
    delay(1500)  // 모두 끝날 때까지 대기
    println("소요 시간: ${System.currentTimeMillis() - startTime}ms")
    // 소요 시간: 1500ms (3배 빠름!)
}

async/await - 결과 받아오기

launch vs async 차이

간단 비교

launch async

반환값 Job (완료 여부만) Deferred<T> (결과값)
용도 로깅, 업데이트 등 계산, API 호출 등
비유 "청소해" (끝났는지만 확인) "계산해" (결과 받기)

async 기본 사용법

fun main() = runBlocking {
    val deferred = async {
        delay(1000)
        42  // 결과값
    }
    
    println("계산 중...")
    val result = deferred.await()  // 결과 기다림
    println("결과: $result")
}
// 출력:
// 계산 중...
// (1초 후)
// 결과: 42

실전 예제: 병렬 API 호출

문제 상황

// ❌ 순차 호출 - 느림
fun loadDashboard() = runBlocking {
    val user = fetchUser()        // 300ms
    val orders = fetchOrders()    // 400ms
    val products = fetchProducts() // 500ms
    
    Dashboard(user, orders, products)
}
// 총 시간: 1200ms 😢

async로 병렬 처리

// ✅ 병렬 호출 - 빠름
fun loadDashboard() = runBlocking {
    val userDeferred = async { fetchUser() }        // 동시 시작
    val ordersDeferred = async { fetchOrders() }    // 동시 시작
    val productsDeferred = async { fetchProducts() } // 동시 시작
    
    val user = userDeferred.await()     // 결과 기다림
    val orders = ordersDeferred.await()
    val products = productsDeferred.await()
    
    Dashboard(user, orders, products)
}
// 총 시간: 500ms (가장 긴 작업 시간) 😊

실제 측정

data class User(val name: String)
data class Order(val id: Int)
data class Product(val name: String)
data class Dashboard(val user: User, val orders: List<Order>, val products: List<Product>)

suspend fun fetchUser(): User {
    delay(300)
    return User("규철")
}

suspend fun fetchOrders(): List<Order> {
    delay(400)
    return listOf(Order(1), Order(2))
}

suspend fun fetchProducts(): List<Product> {
    delay(500)
    return listOf(Product("노트북"), Product("마우스"))
}

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    
    val dashboard = loadDashboard()
    
    println("소요 시간: ${System.currentTimeMillis() - startTime}ms")
    println("대시보드: $dashboard")
}
// 출력:
// 소요 시간: 약 500ms
// 대시보드: Dashboard(user=User(name=규철), ...)

async의 함정 - await()을 바로 호출하면?

// ❌ 나쁜 예 - async 의미 없음
fun loadDashboard() = runBlocking {
    val user = async { fetchUser() }.await()     // 바로 await
    val orders = async { fetchOrders() }.await()  // 순차 실행됨!
    val products = async { fetchProducts() }.await()
    
    Dashboard(user, orders, products)
}
// 총 시간: 1200ms (순차 실행과 동일)

올바른 사용법

// ✅ 좋은 예 - 먼저 시작, 나중에 await
fun loadDashboard() = runBlocking {
    val userDeferred = async { fetchUser() }
    val ordersDeferred = async { fetchOrders() }
    val productsDeferred = async { fetchProducts() }
    
    // 모두 시작된 후에 await
    Dashboard(
        userDeferred.await(),
        ordersDeferred.await(),
        productsDeferred.await()
    )
}

suspend 함수 - 일시 중단의 마법

suspend가 뭐길래?

suspend = "일시 중단 가능한"

// 일반 함수
fun normalFunction() {
    Thread.sleep(1000)  // 쓰레드 멈춤 (다른 일 못 함)
}

// suspend 함수
suspend fun suspendFunction() {
    delay(1000)  // 코루틴만 멈춤 (쓰레드는 다른 일 함)
}

비유: 전화 통화

일반 함수 (blocking):
통화 중에는 아무것도 못 함 ❌

suspend 함수 (non-blocking):
통화 중에 문자 확인, 메모 작성 가능 ✅

suspend 함수 만들기

규칙: suspend 함수 안에서만 호출 가능

suspend fun fetchUser(): User {
    delay(1000)  // suspend 함수는 delay 호출 가능
    return User("규철")
}

fun main() {
    // fetchUser()  // ❌ 에러! 일반 함수에서 호출 불가
    
    // ✅ 코루틴 안에서만 가능
    runBlocking {
        val user = fetchUser()
        println(user)
    }
}

실전 예제: 데이터베이스 작업

// Repository 패턴
class UserRepository {
    // suspend 함수로 선언
    suspend fun findById(id: Long): User? {
        delay(300)  // DB 조회 시뮬레이션
        return User("규철")
    }
    
    suspend fun save(user: User) {
        delay(500)  // DB 저장 시뮬레이션
        println("사용자 저장 완료: ${user.name}")
    }
}

// Service 레이어
class UserService(private val repository: UserRepository) {
    suspend fun createUser(name: String): User {
        val user = User(name)
        repository.save(user)
        return user
    }
    
    suspend fun getUserWithOrders(id: Long): UserWithOrders {
        // 동시 조회
        val userDeferred = async { repository.findById(id) }
        val ordersDeferred = async { fetchOrders(id) }
        
        return UserWithOrders(
            user = userDeferred.await()!!,
            orders = ordersDeferred.await()
        )
    }
}

suspend의 장점

1. 체이닝이 자연스럽다

suspend fun processUser(userId: Long) {
    val user = fetchUser(userId)      // 1단계
    val profile = fetchProfile(user)  // 2단계
    val orders = fetchOrders(user)    // 3단계
    
    updateDashboard(user, profile, orders)
}
// 마치 동기 코드처럼 읽힘!

2. 예외 처리가 간단하다

suspend fun safeProcess(userId: Long) {
    try {
        val user = fetchUser(userId)
        processUser(user)
    } catch (e: Exception) {
        println("에러 발생: ${e.message}")
    }
}
// try-catch 그대로 사용 가능!

CoroutineScope와 Job

CoroutineScope가 뭐길래?

"코루틴의 생명주기를 관리하는 범위"

// ❌ 잘못된 예 - 메모리 누수
fun startBackgroundWork() {
    GlobalScope.launch {  // 앱 종료까지 살아있음
        while (true) {
            delay(1000)
            println("작업 중...")
        }
    }
}
// 문제: 화면 나가도 계속 실행됨!

비유: 프로젝트 팀

CoroutineScope = 프로젝트
- 프로젝트 종료되면 팀원(코루틴) 모두 해산

GlobalScope = 평생 고용
- 회사 망할 때까지 일함

Scope 종류

Scope 생명주기 용도

GlobalScope 앱 전체 ❌ 거의 사용 금지
CoroutineScope 직접 관리 ✅ 일반적 사용
viewModelScope ViewModel ✅ Android
lifecycleScope Activity/Fragment ✅ Android

Job으로 제어하기

Job = "작업 제어권"

fun main() = runBlocking {
    val job = launch {
        repeat(10) {
            delay(500)
            println("작업 $it")
        }
    }
    
    delay(2000)
    job.cancel()  // 작업 취소
    println("취소됨")
}
// 출력:
// 작업 0
// 작업 1
// 작업 2
// 취소됨

실전 예제: Activity 생명주기

문제 상황

// ❌ 나쁜 예 - Activity 종료 후에도 실행
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        GlobalScope.launch {
            while (true) {
                delay(1000)
                updateUI()  // 💥 Activity 종료 후 크래시!
            }
        }
    }
}

올바른 방법

// ✅ 좋은 예 - Scope로 관리
class MainActivity : AppCompatActivity() {
    private val scope = CoroutineScope(Dispatchers.Main)
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        scope.launch {
            while (true) {
                delay(1000)
                updateUI()
            }
        }
    }
    
    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()  // Activity 종료 시 자동 취소
    }
}

구조적 동시성

"부모가 취소되면 자식도 취소"

fun main() = runBlocking {
    val parentJob = launch {
        println("부모 시작")
        
        launch {
            delay(3000)
            println("자식 1 완료")  // 실행 안 됨
        }
        
        launch {
            delay(3000)
            println("자식 2 완료")  // 실행 안 됨
        }
        
        delay(1000)
        println("부모 완료")
    }
    
    delay(500)
    parentJob.cancel()  // 부모 취소 → 자식도 자동 취소
    println("모두 취소됨")
}
// 출력:
// 부모 시작
// 모두 취소됨

실전 패턴 모음

패턴 1: 타임아웃 처리

"3초 안에 응답 없으면 실패"

suspend fun fetchWithTimeout(): User? {
    return withTimeout(3000) {  // 3초 제한
        fetchUser()
    }
}

// 사용
fun main() = runBlocking {
    try {
        val user = fetchWithTimeout()
        println("성공: $user")
    } catch (e: TimeoutCancellationException) {
        println("타임아웃!")
    }
}

패턴 2: 진행 상황 업데이트

"다운로드하면서 진행률 표시"

suspend fun downloadFile(
    url: String,
    onProgress: (Int) -> Unit
) {
    repeat(100) { progress ->
        delay(50)  // 다운로드 시뮬레이션
        onProgress(progress + 1)
    }
}

// 사용
fun main() = runBlocking {
    downloadFile("https://example.com/file.zip") { progress ->
        println("진행률: $progress%")
    }
}

패턴 3: 재시도 로직

"실패하면 3번까지 재시도"

suspend fun <T> retryWithDelay(
    times: Int = 3,
    initialDelay: Long = 100,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    
    repeat(times - 1) {
        try {
            return block()
        } catch (e: Exception) {
            println("실패, ${currentDelay}ms 후 재시도")
            delay(currentDelay)
            currentDelay = (currentDelay * factor).toLong()
        }
    }
    
    return block()  // 마지막 시도
}

// 사용
fun main() = runBlocking {
    val result = retryWithDelay {
        fetchUser()  // 네트워크 요청
    }
    println("결과: $result")
}

패턴 4: 동시 실행 수 제한

"한 번에 최대 3개씩만 실행"

import kotlinx.coroutines.channels.Channel

suspend fun processWithLimit(
    items: List<String>,
    limit: Int = 3,
    process: suspend (String) -> Unit
) {
    val channel = Channel<Unit>(limit)
    
    items.forEach { item ->
        channel.send(Unit)  // 슬롯 확보
        
        launch {
            try {
                process(item)
            } finally {
                channel.receive()  // 슬롯 반환
            }
        }
    }
}

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • 코루틴 개념 - 경량 쓰레드, 일시 중단
  • launch - 결과 없이 실행 (Fire and Forget)
  • async/await - 결과값 받기 (병렬 처리)
  • suspend - 일시 중단 가능한 함수
  • Scope와 Job - 생명주기 관리
  • 실전 패턴 - 타임아웃, 재시도, 진행 상황

다음 편에서 배울 것 📚

15편: 코루틴 심화 | Flow, Channel로 데이터 스트림 다루기

  • Flow가 뭐길래?
  • cold vs hot 스트림
  • collect, map, filter 연산자
  • StateFlow, SharedFlow
  • Channel로 통신하기
  • 실전 반응형 프로그래밍

핵심 정리

코루틴 3원칙

  1. 결과 필요 없으면 launch
  2. 결과 필요하면 async + await
  3. 항상 Scope 관리 (cancel 잊지 말기)

코루틴 vs Thread

Thread Coroutine

생성 비용 비쌈 (1MB) 저렴 (수 KB)
개수 제한 수천 개 수십만 개
문법 복잡 간단
비유 직원 고용 업무 분담

💬 댓글로 알려주세요!

  • 코루틴을 어디에 사용해보셨나요?
  • Thread에서 코루틴으로 바꾼 경험이 있나요?
  • 이 글이 도움이 되셨나요?

태그: #Kotlin #코루틴 #Coroutine #launch #async #suspend #비동기

 

반응형
Comments