Showing posts with label Kotlin. Show all posts
Showing posts with label Kotlin. Show all posts

Thursday, August 17, 2023

코틀린 비동기 프로그래밍: 코루틴과 플로우의 원리부터 실전까지

현대 애플리케이션 개발, 특히 사용자 경험이 무엇보다 중요한 모바일 환경에서 비동기 프로그래밍은 더 이상 선택이 아닌 필수입니다. 사용자는 네트워크 요청, 데이터베이스 접근, 복잡한 연산 등 시간이 소요되는 작업이 진행되는 동안에도 앱이 멈추지 않고 부드럽게 반응하기를 기대합니다. 과거 안드로이드 개발자들은 이러한 요구사항을 충족시키기 위해 AsyncTask, 콜백(Callback), RxJava 등 다양한 도구를 사용해왔지만, 이들 각각은 복잡성, 가독성 저하(콜백 지옥), 그리고 미묘한 생명주기 관리 문제라는 단점을 안고 있었습니다. 코틀린 코루틴(Coroutine)은 이러한 문제들에 대한 현대적이고 우아한 해답을 제시합니다.

코루틴은 '경량 스레드'라는 개념을 통해 비동기 코드를 마치 동기 코드처럼 순차적이고 직관적으로 작성할 수 있게 해줍니다. 여기에 더해, 코틀린 플로우(Flow)는 코루틴을 기반으로 비동기적인 데이터 스트림을 처리하기 위한 강력한 API를 제공합니다. 이 두 가지를 조합하면, 복잡한 비동기 데이터 처리를 간결하고, 안전하며, 테스트하기 쉬운 코드로 구현할 수 있습니다. 이 글에서는 코루틴과 플로우의 근본적인 원리부터 시작하여, 안드로이드 개발 현장에서 마주할 수 있는 실질적인 문제들을 해결하는 고급 기법과 최적화 전략까지 심도 있게 다룰 것입니다.

1. 코루틴의 핵심 원리: 가벼움과 구조적 동시성

코루틴을 효과적으로 사용하기 위해서는 그저 '비동기 작업을 쉽게 해주는 것'이라는 표면적인 이해를 넘어서야 합니다. 코루틴의 강력함은 '일시 중단(Suspension)'과 '구조적 동시성(Structured Concurrency)'이라는 두 가지 핵심 개념에서 비롯됩니다.

코루틴이란 무엇인가? 스레드와의 비교

코루틴은 종종 '경량 스레드(Lightweight Thread)'라고 불립니다. 스레드는 운영체제(OS) 수준에서 관리되는 자원으로, 생성과 컨텍스트 스위칭(Context Switching)에 상당한 비용이 발생합니다. 수백, 수천 개의 스레드를 동시에 실행하는 것은 시스템에 큰 부담을 주며 현실적으로 불가능에 가깝습니다.

반면, 코루틴은 OS에 의해 직접 관리되지 않고 코틀린 런타임과 라이브러리 수준에서 관리됩니다. 하나의 스레드 위에서 수천, 수만 개의 코루틴을 실행할 수 있으며, 코루틴 간의 전환은 OS의 개입 없이 이루어지므로 스레드의 컨텍스트 스위칭보다 훨씬 빠르고 비용이 저렴합니다. 이러한 특성 덕분에 코루틴은 I/O 작업이 잦은 환경에서 높은 동시성을 효율적으로 달성할 수 있습니다.

핵심 개념 1: 일시 중단 가능한 함수 (suspend fun)

코루틴의 마법은 suspend 키워드에서 시작됩니다. 함수에 suspend 한정자를 붙이면, 해당 함수는 '일시 중단 가능한' 함수가 됩니다. 이는 함수가 실행 도중에 멈추었다가 나중에 다시 그 지점부터 실행을 재개할 수 있음을 의미합니다.

중요한 점은 '일시 중단'이 '차단(Blocking)'과 다르다는 것입니다. 스레드를 차단하는 작업(예: Thread.sleep())은 해당 스레드가 다른 어떤 작업도 할 수 없도록 완전히 멈추게 만듭니다. 만약 메인 스레드에서 이런 일이 발생하면 앱은 응답 없음(ANR) 상태에 빠집니다. 하지만 코루틴의 delay()와 같은 일시 중단 함수는 현재 작업을 잠시 멈추고 자신을 실행하던 스레드를 다른 코루틴이 사용할 수 있도록 양보합니다. 정해진 시간이 지나면, 코루틴은 다시 스레드 풀의 가용한 스레드에 할당되어 중단되었던 지점부터 실행을 이어갑니다. 이 과정에서 스레드는 결코 차단되지 않고 계속해서 다른 유용한 작업을 수행할 수 있습니다.

컴파일러는 내부적으로 일시 중단 함수를 CPS(Continuation-Passing Style)로 변환하여 이러한 동작을 구현합니다. 개발자는 복잡한 콜백 구조 없이 순차적인 코드를 작성하기만 하면, 컴파일러가 상태 머신과 콜백을 자동으로 생성해주는 것입니다.

suspend fun fetchUserData(): String {
    println("Fetching user data...")
    delay(1000L) // 현재 코루틴을 1초간 '일시 중단'합니다. 스레드는 차단되지 않습니다.
    println("User data fetched.")
    return "User Data"
}

suspend fun fetchUserPreferences(): String {
    println("Fetching user preferences...")
    delay(500L) // 현재 코루틴을 0.5초간 '일시 중단'합니다.
    println("User preferences fetched.")
    return "User Preferences"
}

위의 두 함수는 동기 코드처럼 보이지만, delay 호출 시 자신을 실행하던 스레드를 놓아주기 때문에 UI를 멈추지 않고 비동기적으로 실행될 수 있습니다.

핵심 개념 2: 구조적 동시성 (Structured Concurrency)

구조적 동시성은 코루틴의 생명주기를 코드의 특정 스코프(Scope)에 바인딩하여 관리하는 패러다임입니다. 이는 비동기 작업에서 흔히 발생하는 메모리 누수나 좀비 프로세스(작업이 시작되었지만 추적하거나 취소할 방법이 없는 상태) 문제를 근본적으로 해결합니다.

코루틴은 항상 CoroutineScope 내에서 실행되어야 합니다. 이 스코프는 자신이 실행한 모든 코루틴을 추적하고 관리합니다. 만약 스코프가 취소되면, 그 스코프 내에서 실행 중이던 모든 코루틴도 함께 취소됩니다. 안드로이드에서는 이 개념이 매우 중요합니다.

  • CoroutineScope: 코루틴의 범위를 정의합니다. 모든 코루틴 빌더(launch, async 등)는 CoroutineScope의 확장 함수이므로, 스코프 내에서만 호출될 수 있습니다.
  • Job: 코루틴의 핸들입니다. 코루틴의 상태(활성, 취소, 완료)를 나타내며, job.cancel()을 통해 코루틴을 명시적으로 취소할 수 있습니다. 코루틴들은 부모-자식 관계를 형성하며, 부모 Job이 취소되면 모든 자식 Job들도 재귀적으로 취소됩니다. 이것이 구조적 동시성의 핵심입니다.
  • CoroutineContext: 코루틴의 동작을 정의하는 요소들의 맵입니다. Job, CoroutineDispatcher(다음에 설명) 등이 여기에 포함됩니다. + 연산자를 사용하여 컨텍스트들을 결합할 수 있습니다.

안드로이드 KTX 라이브러리는 액티비티나 프래그먼트의 생명주기와 연결된 lifecycleScope, 뷰모델의 생명주기와 연결된 viewModelScope를 기본으로 제공합니다. 예를 들어 viewModelScope를 사용하면 뷰모델이 소멸(cleared)될 때 해당 스코프에서 실행된 모든 코루틴이 자동으로 취소되므로, 더 이상 필요 없는 네트워크 요청이나 데이터베이스 작업을 정리하는 코드를 직접 작성할 필요가 없습니다.

class MyViewModel : ViewModel() {
    fun loadData() {
        // viewModelScope는 뷰모델이 파괴될 때 자동으로 취소됩니다.
        viewModelScope.launch {
            // 이 블록 안의 모든 작업은 viewModelScope의 생명주기를 따릅니다.
            val data = fetchUserData() // suspend 함수 호출
            // UI 업데이트 로직...
        }
    }
}

코루틴 디스패처 (Coroutine Dispatchers)

디스패처는 코루틴을 어떤 스레드 또는 스레드 풀에서 실행할지 결정하는 역할을 합니다. 주요 디스패처는 다음과 같습니다.

  • Dispatchers.Main: 안드로이드의 메인 스레드에서 코루틴을 실행합니다. UI 업데이트, 사용자 이벤트 처리 등 메인 스레드에서만 수행해야 하는 작업에 사용됩니다.
  • Dispatchers.IO: 네트워크 통신, 파일 입출력, 데이터베이스 접근 등 I/O 바운드 작업에 최적화된 스레드 풀입니다.
  • Dispatchers.Default: JSON 파싱, 리스트 정렬 등 CPU를 많이 사용하는 연산(CPU-bound)에 최적화된 스레드 풀입니다.
  • Dispatchers.Unconfined: 특정 스레드에 국한되지 않습니다. 처음에는 호출한 스레드에서 시작하지만, 일시 중단 후 재개될 때는 다른 스레드에서 실행될 수 있습니다. 특별한 경우가 아니면 사용을 권장하지 않습니다.

withContext 함수를 사용하면 코루틴 블록 내에서 실행 컨텍스트(디스패처)를 안전하게 전환할 수 있습니다.

viewModelScope.launch { // 기본적으로 Dispatchers.Main에서 시작 (viewModelScope의 기본값)
    val result = withContext(Dispatchers.IO) {
        // 이 블록은 I/O 스레드 풀에서 실행됩니다.
        // 오래 걸리는 네트워크 요청
        api.fetchData()
    }
    // withContext 블록이 끝나면 원래의 디스패처(Dispatchers.Main)로 돌아옵니다.
    updateUi(result) // 메인 스레드에서 안전하게 UI 업데이트
}

2. 실전 코루틴: 빌더, 예외 처리, 동시성 패턴

코루틴의 기본 원리를 이해했다면, 이제 실제 코드에서 어떻게 활용하는지 알아볼 차례입니다. 코루틴 빌더는 코루틴을 시작하는 관문이며, 올바른 예외 처리 전략은 안정적인 앱을 만드는 데 필수적입니다.

코루틴 빌더: `launch` vs `async`

코루틴을 시작하는 가장 일반적인 방법은 launchasync 빌더를 사용하는 것입니다.

  • launch: '발사하고 잊어버리는(fire-and-forget)' 방식의 코루틴을 시작합니다. Job 객체를 반환하며, 이 Job을 통해 코루틴을 취소하거나 상태를 확인할 수는 있지만, 결과를 반환하지는 않습니다. 반환 값이 필요 없는 작업(예: 데이터베이스에 데이터 저장, UI 업데이트)에 적합합니다.
  • async: 결과를 반환하는 코루틴을 시작합니다. Deferred 객체를 반환하는데, 이는 Job을 상속하며 미래의 결과값(T 타입)을 담는 '약속'과 같습니다. await() 함수를 호출하여 결과가 준비될 때까지 (일시 중단 방식으로) 기다린 후 결과값을 얻을 수 있습니다.
// launch 예제
viewModelScope.launch {
    val user = fetchUserData()
    Log.d("Coroutine", "User: $user") // 반환값이 필요 없는 로그 출력
}

// async 예제
viewModelScope.launch {
    val userDeferred = async(Dispatchers.IO) { fetchUserData() }
    val preferencesDeferred = async(Dispatchers.IO) { fetchUserPreferences() }

    // 두 작업이 동시에 시작되고, 둘 다 끝날 때까지 기다립니다.
    val user = userDeferred.await()
    val preferences = preferencesDeferred.await()

    // 두 결과를 조합하여 UI 업데이트
    showUserProfile(user, preferences)
}

async 예제는 두 개의 네트워크 요청을 병렬로 실행하여 전체 대기 시간을 단축시키는 강력한 동시성 패턴을 보여줍니다. 만약 이들을 순차적으로 호출했다면 총 1.5초(1000ms + 500ms)가 걸렸겠지만, async를 사용하면 더 오래 걸리는 작업의 시간인 1초 만에 두 결과를 모두 얻을 수 있습니다.

코루틴 예외 처리의 정석

비동기 코드의 예외 처리는 까다로울 수 있습니다. 코루틴은 구조적 동시성 덕분에 예외 처리 또한 체계적으로 할 수 있습니다.

1. `try-catch` 블록 사용
가장 간단하고 직관적인 방법입니다. 동기 코드에서와 마찬가지로 예외가 발생할 수 있는 코드를 try-catch 블록으로 감싸면 됩니다.

viewModelScope.launch {
    try {
        val data = api.fetchData() // 만약 여기서 예외가 발생하면
        updateUi(data)
    } catch (e: Exception) {
        // catch 블록에서 처리됩니다.
        showError("데이터를 불러오는 데 실패했습니다: ${e.message}")
    }
}

2. `CoroutineExceptionHandler`
`launch` 빌더에 의해 잡히지 않은(uncaught) 예외를 처리하기 위한 전역적인 핸들러입니다. 주로 로깅, 오류 리포팅 등 공통적인 예외 처리 로직에 사용됩니다. `CoroutineContext`의 일부로 스코프에 설치할 수 있습니다.

val handler = CoroutineExceptionHandler { _, exception ->
    Log.e("Coroutine", "Coroutine exception: $exception")
}

// 스코프 생성 시 핸들러 추가
private val viewModelScope = CoroutineScope(Dispatchers.Main + SupervisorJob() + handler)

fun someFunction() {
    viewModelScope.launch {
        // 이 launch 블록 내에서 처리되지 않은 예외는 위 handler에서 잡힙니다.
        throw RuntimeException("Test Exception")
    }
}

3. `async`와 예외 전파
async 빌더는 예외를 발생 즉시 전파하지 않고, Deferred 객체 내에 저장해 둡니다. 그리고 await()가 호출되는 시점에 예외를 다시 던집니다. 따라서 async를 사용할 때는 await() 호출 부분을 `try-catch`로 감싸야 합니다.

viewModelScope.launch {
    val dataDeferred = async { api.fetchDataThatThrowsException() }
    try {
        val data = dataDeferred.await() // 예외는 여기서 발생합니다.
        updateUi(data)
    } catch (e: Exception) {
        showError(e.message)
    }
}

`Job` vs `SupervisorJob`

일반적인 Job은 자식 코루틴 중 하나에서 예외가 발생하면, 그 예외를 부모에게 전파하고 부모는 자신과 다른 모든 자식 코루틴들을 취소시킵니다. '하나가 실패하면 모두 실패(fail-fast)'하는 정책입니다.

반면, SupervisorJob을 사용하면 자식의 실패가 다른 자식이나 부모에게 영향을 주지 않습니다. 각 자식은 독립적으로 실패하고 처리될 수 있습니다. UI와 관련된 독립적인 여러 작업을 동시에 시작할 때 유용합니다. `viewModelScope`는 내부적으로 `SupervisorJob`을 사용하므로, 한 작업의 실패가 다른 작업에 영향을 주지 않습니다.

3. 비동기 데이터 스트림: 플로우(Flow)의 세계

코루틴의 일시 중단 함수는 단일 값을 비동기적으로 반환하는 데 탁월합니다. 하지만 시간이 지남에 따라 여러 값을 생성하는 비동기 데이터 소스를 다루려면 어떻게 해야 할까요? 예를 들어, 위치 정보의 지속적인 업데이트, 데이터베이스의 실시간 변경 감지, 사용자의 입력 이벤트 스트림 등이 있습니다. 바로 이 지점에서 플로우(Flow)가 등장합니다.

플로우란 무엇인가? Cold Stream의 개념

플로우는 코루틴을 기반으로 구축된 비동기 데이터 스트림입니다. RxJava의 `Observable`과 유사한 개념이지만, 코루틴의 일시 중단 기능을 활용하여 더 간단하고 효율적으로 작동합니다.

플로우의 가장 중요한 특징 중 하나는 '콜드 스트림(Cold Stream)'이라는 것입니다. 이는 플로우가 구독(수집)되기 전까지는 아무런 동작도 하지 않는다는 의미입니다. `flow { ... }` 빌더 블록 안의 코드는 `collect`와 같은 터미널 연산자(Terminal Operator)가 호출될 때에만 실행됩니다. 각 `collect` 호출은 독립적인 스트림 실행을 유발합니다. 이는 마치 레시피(Flow)와 같아서, 요리사가 요리를 시작(collect)할 때마다 처음부터 레시피를 따라가는 것과 같습니다.

import kotlinx.coroutines.flow.*
import kotlinx.coroutines.delay

fun numberFlow(): Flow<Int> = flow {
    println("Flow started")
    for (i in 1..3) {
        delay(100)
        emit(i) // 데이터를 방출(emit)합니다.
    }
}

// 메인 함수
suspend fun main() {
    val flow = numberFlow()
    println("Calling collect...")
    flow.collect { value -> println(value) } // 이 시점에 "Flow started"가 출력됩니다.
    println("Calling collect again...")
    flow.collect { value -> println(value) } // "Flow started"가 다시 출력됩니다.
}

플로우 빌더와 연산자

플로우는 다양한 빌더와 중간 연산자(Intermediate Operators)를 제공하여 강력한 데이터 처리 파이프라인을 구축할 수 있게 해줍니다.

플로우 빌더

  • flow { ... }: 가장 일반적인 빌더로, emit() 함수를 사용하여 값을 방출합니다.
  • flowOf(...): 고정된 개수의 인자를 받아 플로우를 생성합니다.
  • asFlow(): 컬렉션, 시퀀스, 배열 등을 플로우로 변환합니다.

중간 연산자
중간 연산자는 업스트림(upstream) 플로우를 변환하여 다운스트림(downstream)으로 전달하는 역할을 합니다. 이들 자체는 일시 중단 함수가 아니며, 단순히 플로우의 정의를 꾸며주는 역할을 합니다. 실제 실행은 터미널 연산자가 호출될 때 일어납니다.

  • map: 각 값을 다른 값으로 변환합니다. (예: userFlow.map { it.name })
  • filter: 특정 조건에 맞는 값만 통과시킵니다. (예: numberFlow.filter { it % 2 == 0 })
  • onEach: 스트림에 영향을 주지 않고 각 값에 대해 액션을 수행합니다. 디버깅이나 로깅에 유용합니다.
  • debounce: 값이 방출된 후 특정 시간 동안 다른 값이 방출되지 않으면 그 값을 통과시킵니다. 사용자 검색어 입력 처리에 매우 유용합니다.
  • flatMapLatest: 새로운 값이 들어올 때마다 이전 값에 대한 처리를 취소하고 새로운 값으로 새 플로우를 만들어 이어 붙입니다. 검색 기능 구현에 핵심적인 연산자입니다.
// 검색어 입력 스트림을 처리하는 예제
fun searchFlow(queryFlow: Flow<String>): Flow<List<String>> {
    return queryFlow
        .debounce(300) // 300ms 동안 입력이 없으면 최신 검색어 사용
        .filter { it.isNotBlank() } // 빈 검색어는 무시
        .flatMapLatest { query ->
            // 이전 네트워크 요청은 자동으로 취소되고, 새로운 검색어로 요청
            flow {
                emit(api.search(query))
            }
        }
}

터미널 연산자
터미널 연산자는 플로우의 수집을 시작하는 일시 중단 함수입니다.

  • collect: 각 값을 소비하는 가장 기본적인 방법입니다.
  • toList(), toSet(): 플로우의 모든 값을 모아 컬렉션으로 반환합니다.
  • first(): 첫 번째 값만 받고 플로우를 닫습니다.
  • reduce(), fold(): 값들을 누적하여 단일 결과값을 만듭니다.

컨텍스트 보존과 `flowOn`

플로우는 '컨텍스트 보존(Context Preservation)'이라는 중요한 원칙을 따릅니다. flow { ... } 빌더와 대부분의 중간 연산자는 수집이 일어나는 코루틴의 컨텍스트(디스패처 포함)를 그대로 따릅니다. 즉, 메인 스레드에서 `collect`를 호출하면 `flow` 블록과 `map`, `filter` 등도 모두 메인 스레드에서 실행됩니다. 이는 UI를 차단할 수 있는 위험한 동작입니다.

이 문제를 해결하기 위해 `flowOn` 연산자를 사용합니다. `flowOn`은 자신보다 위에 있는(upstream) 모든 연산자들의 실행 컨텍스트를 지정된 디스패처로 변경합니다. 데이터 생산(네트워크, DB)은 백그라운드 스레드에서, 소비(UI 업데이트)는 메인 스레드에서 하도록 분리하는 데 필수적입니다.

viewModelScope.launch {
    numberFlow() // 데이터 생산
        .map { performHeavyCalculation(it) } // CPU 집약적 작업
        .flowOn(Dispatchers.Default) // map과 numberFlow는 Default 디스패처에서 실행
        .catch { e -> Log.e("Flow", "Error: $e") } // 예외 처리
        .collect { value ->
            // collect는 launch의 컨텍스트(Main)에서 실행
            updateUiWithValue(value)
        }
}

플로우의 예외 처리는 catch 연산자를 사용하여 선언적으로 처리할 수 있습니다. `catch`는 자신보다 위에 있는(upstream) 플로우에서 발생한 예외만 잡을 수 있습니다.

4. 안드로이드 UI를 위한 현대적 플로우: StateFlow와 SharedFlow

콜드 스트림인 일반 `Flow`는 데이터 소스로서 훌륭하지만, 여러 구독자에게 상태를 공유하거나 최신 상태 값만 유지해야 하는 안드로이드 UI 상태 관리에는 적합하지 않을 수 있습니다. 이를 위해 '핫 스트림(Hot Stream)'인 `StateFlow`와 `SharedFlow`가 도입되었습니다.

`StateFlow`: UI 상태를 위한 홀더

`StateFlow`는 현재 상태를 나타내는 값을 가지며, 이 값이 업데이트될 때마다 새로운 값을 구독자에게 방출하는 핫 스트림입니다. `LiveData`와 매우 유사하지만 코루틴과 플로우의 세계에 더 잘 통합됩니다.

주요 특징:

  • 항상 초기값을 가져야 합니다.
  • 항상 최신 값 하나만 저장하고, 새로운 구독자는 구독 즉시 최신 값을 받습니다.
  • value 프로퍼티를 통해 현재 값을 동기적으로 읽을 수 있습니다.
  • 값이 변경될 때만 새로운 값을 방출합니다(distinctUntilChanged 동작이 내장됨).

주로 `ViewModel`에서 UI 상태를 관리하고, UI 컨트롤러(Activity/Fragment)에서 이를 관찰하는 패턴에 사용됩니다.

// ViewModel
sealed class UiState {
    object Loading : UiState()
    data class Success(val data: String) : UiState()
    data class Error(val message: String) : UiState()
}

class MyViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun fetchData() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val result = withContext(Dispatchers.IO) { api.fetchData() }
                _uiState.value = UiState.Success(result)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// Fragment
class MyFragment : Fragment() {
    private val viewModel: MyViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    when (state) {
                        is UiState.Loading -> showLoading()
                        is UiState.Success -> showData(state.data)
                        is UiState.Error -> showError(state.message)
                    }
                }
            }
        }
    }
}

위 예제의 `repeatOnLifecycle`은 생명주기를 인지하여, 프래그먼트가 `STARTED` 상태일 때만 수집을 시작하고 `STOPPED` 상태가 되면 수집을 중단하여 불필요한 리소스 낭비와 메모리 누수를 방지하는 안전한 수집 방법입니다.

`SharedFlow`: 일회성 이벤트를 위한 채널

`SharedFlow`는 여러 구독자에게 값을 브로드캐스트하기 위한 더 일반적인 핫 스트림입니다. `StateFlow`와 달리 초기값이 필요 없으며, 이전 값들을 버퍼에 저장하고 새로운 구독자에게 다시 재생(replay)해줄 수 있는 기능을 제공합니다.

주로 스낵바 표시, 화면 이동 등 '상태'가 아닌 '이벤트'를 전달하는 데 사용됩니다. 상태는 화면이 다시 그려질 때마다 반영되어야 하지만, 이벤트는 한 번만 소비되어야 합니다. (예: 화면 회전 후 스낵바가 다시 표시되면 안 됨)

// ViewModel
class LoginViewModel : ViewModel() {
    private val _events = MutableSharedFlow<LoginEvent>()
    val events: SharedFlow<LoginEvent> = _events.asSharedFlow()

    fun onLoginClicked() {
        viewModelScope.launch {
            if (loginSuccess()) {
                _events.emit(LoginEvent.NavigateToHome)
            } else {
                _events.emit(LoginEvent.ShowInvalidCredentialsError)
            }
        }
    }
}
sealed class LoginEvent {
    object NavigateToHome : LoginEvent()
    object ShowInvalidCredentialsError : LoginEvent()
}

// Activity에서 이벤트 수집
lifecycleScope.launch {
    viewModel.events.collect { event ->
        when (event) {
            is LoginEvent.NavigateToHome -> navigateToHomeScreen()
            is LoginEvent.ShowInvalidCredentialsError -> showSnackbar("Invalid credentials")
        }
    }
}

5. 테스트와 최적화: 완성도 높은 비동기 코드 만들기

코루틴과 플로우를 사용하여 작성된 코드는 강력하지만, 그 안정성과 성능을 보장하기 위해서는 체계적인 테스트와 최적화가 필수적입니다. `kotlinx-coroutines-test` 라이브러리는 비동기 코드를 결정론적(deterministic)이고 빠르게 테스트할 수 있는 강력한 도구를 제공합니다.

코루틴 테스트하기

테스트 라이브러리의 핵심은 `runTest` 빌더와 `TestCoroutineScheduler`입니다. `runTest`는 가상 시간(virtual time)을 사용하여 테스트를 실행하므로, `delay`와 같은 시간 기반 동작을 실제 시간만큼 기다리지 않고 즉시 완료시킬 수 있습니다.

// build.gradle에 의존성 추가
// testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"

@OptIn(ExperimentalCoroutinesApi::class)
class MyViewModelTest {
    private lateinit var viewModel: MyViewModel
    private lateinit var mockApi: MockApi

    // Main 디스패처를 테스트용 디스패처로 교체하기 위한 규칙
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Before
    fun setUp() {
        mockApi = MockApi()
        viewModel = MyViewModel(api = mockApi)
    }

    @Test
    fun `fetchData - 성공 시 Success 상태 방출`() = runTest {
        // Given
        val expectedData = "Success Data"
        mockApi.setResponse(expectedData)
        
        // When
        viewModel.fetchData()
        
        // Then
        // StateFlow의 첫 번째 값(Loading)을 건너뛰고 두 번째 값을 기다림
        val finalState = viewModel.uiState.drop(1).first() 
        assertEquals(UiState.Success(expectedData), finalState)
    }

    @Test
    fun `fetchData - 실패 시 Error 상태 방출`() = runTest {
        // Given
        val errorMessage = "Network Error"
        mockApi.setError(RuntimeException(errorMessage))

        // When
        viewModel.fetchData()

        // Then
        val finalState = viewModel.uiState.drop(1).first()
        assertEquals(UiState.Error(errorMessage), finalState)
    }
}

테스트에서는 실제 디스패처 대신 `TestDispatcher`를 주입하여 테스트 환경을 완벽하게 제어하는 것이 중요합니다. 이를 의존성 주입(Dependency Injection)과 함께 사용하면 테스트 용이성이 크게 향상됩니다.

성능 최적화 전략

마지막으로, 코루틴과 플로우를 사용할 때 고려해야 할 몇 가지 성능 최적화 전략입니다.

  1. 올바른 디스패처 사용: 앞서 설명했듯이, 작업의 종류(UI, I/O, CPU-bound)에 맞는 디스패처를 사용하는 것이 기본이자 가장 중요합니다.
  2. 백프레셔(Backpressure) 관리: 플로우에서 데이터 생산 속도가 소비 속도보다 훨씬 빠를 때 백프레셔 문제가 발생할 수 있습니다. buffer(), conflate(), collectLatest() 같은 연산자를 사용하여 이 문제를 완화할 수 있습니다.
    • buffer(): 생산자와 소비자 사이의 버퍼를 만들어 독립적으로 실행되게 합니다.
    • conflate(): 소비자가 처리 중일 때 들어온 중간 값들을 버리고 최신 값만 처리하게 합니다.
    • collectLatest(): 새로운 값이 들어오면 이전 값에 대한 처리 로직을 취소하고 새로운 값으로 다시 시작합니다.
  3. 자원 정리: 구조적 동시성은 대부분의 자원 정리를 자동화하지만, 코루틴 외부의 리소스(소켓, 파일 핸들 등)를 사용하는 경우 `onCompletion` 플로우 연산자나 `try-finally` 블록을 사용하여 명시적으로 해제해야 합니다.
  4. 라이브러리 최신 상태 유지: 코루틴과 플로우는 활발하게 개발되고 있습니다. 새로운 버전에는 성능 개선, 버그 수정, 유용한 기능들이 포함되므로 항상 최신 버전을 사용하는 것이 좋습니다.

코루틴과 플로우는 단순히 비동기 코드를 작성하는 새로운 방법을 넘어, 애플리케이션의 아키텍처를 더 반응적이고, 안정적이며, 간결하게 만드는 패러다임의 전환을 이끌고 있습니다. 이 글에서 다룬 원리와 패턴들을 깊이 이해하고 적용한다면, 어떠한 복잡한 비동기 요구사항에도 자신 있게 대처할 수 있는 견고한 기반을 다지게 될 것입니다.

KotlinコルーチンとFlow: 現代的非同期処理の実践

現代のアプリケーション開発、特にAndroidのようなUI中心のプラットフォームでは、非同期処理は避けて通れない課題です。ネットワーク通信、データベースアクセス、時間のかかる計算など、メインスレッドをブロックしかねない処理は数多く存在します。これらを不適切に扱うと、UIがフリーズし、ユーザー体験は著しく損なわれます。かつて、この問題はコールバックやAsyncTask、RxJavaといった手法で解決が試みられてきましたが、それぞれに「コールバック地獄」や学習コストの高さといった課題を抱えていました。KotlinコルーチンとFlowは、こうした非同期プログラミングの複雑さを抜本的に解決するために登場した、現代的かつ強力なソリューションです。

この記事では、コルーチンの基本的な概念である「構造化された並行性」から始め、非同期データストリームをエレガントに扱うFlowの仕組み、そしてこれらを組み合わせた実践的なAndroidアーキテクチャへの応用まで、深く掘り下げていきます。単なる機能の紹介に留まらず、なぜこれらが必要とされ、どのようにしてコードの可読性、保守性、そして堅牢性を向上させるのかを、具体的なコード例と共に解き明かしていきます。

第1章: コルーチンの核心概念

コルーチンを単に「軽量なスレッド」と理解するのは、その本質の一部しか捉えていません。コルーチンの真価は、非同期コードをあたかも同期的なコードのように、直線的かつ直感的に記述できる能力にあります。これを可能にしているのが、中断と再開のメカニズム、そして「構造化された並行性」という設計思想です。

中断可能な計算: `suspend` 関数

コルーチンの魔法の根幹をなすのが `suspend` 修飾子です。関数に `suspend` を付けると、その関数は「中断可能」になります。これは、関数の実行を途中で一時停止し、後で同じ場所から再開できることを意味します。

重要なのは、`suspend` 関数がコルーチンをブロックしないということです。例えば、ネットワークリクエストを行う `suspend` 関数を呼び出すと、コルーチンはそのリクエストが完了するまで中断されます。しかし、その間、コルーチンが実行されていたスレッドは解放され、他のタスク(例えばUIの描画)を実行できます。そして、ネットワークリクエストが完了すると、コルーチンは中断したまさにその場所から、適切なスレッドで実行を再開します。これにより、スレッドを効率的に利用し、アプリケーション全体の応答性を高めることができます。

import kotlinx.coroutines.delay

// この関数は中断可能。呼び出し元スレッドをブロックしない。
suspend fun fetchUserData(userId: String): User {
    println("Fetching data for $userId on ${Thread.currentThread().name}")
    delay(1000) // ネットワーク遅延をシミュレート
    println("Data fetched for $userId on ${Thread.currentThread().name}")
    return User(userId, "John Doe")
}

`suspend` 関数は、他の `suspend` 関数またはコルーチンビルダー(`launch`, `async` など)からのみ呼び出すことができます。この制約が、コルーチンの世界と通常の同期的な世界を明確に分離し、安全な非同期コードを記述するための基盤となります。

構造化された並行性 (Structured Concurrency)

これはコルーチンを他の非同期手法と一線を画す最も重要な概念です。構造化された並行性とは、コルーチンのライフサイクルを特定のスコープに限定するという原則です。

コルーチンは必ず `CoroutineScope` の中で起動されます。このスコープは、自身が起動したすべてのコルーチンの親子関係を追跡・管理します。もしスコープがキャンセルされると、そのスコープ内で起動されたすべての子コルーチンも再帰的にキャンセルされます。これにより、リソースリークや「ゾンビ」タスクの発生を構造的に防ぐことができます。

例えば、AndroidのViewModelでは `viewModelScope` が提供されています。このスコープはViewModelのライフサイクルに紐づいており、ViewModelが破棄されるとき(`onCleared()`が呼ばれるとき)に自動的にキャンセルされます。これにより、ViewModelがもはや不要になった後もバックグラウンドでネットワークリクエストが走り続ける、といった問題を心配する必要がなくなります。

class MyViewModel : ViewModel() {
    fun loadData() {
        // viewModelScopeはViewModelのライフサイクルと連動する
        viewModelScope.launch {
            // このコルーチンはViewModelが破棄されると自動的にキャンセルされる
            val user = fetchUserData("123")
            // UIを更新する処理...
        }
    }
}

この仕組みは、手動でのライフサイクル管理の負担を劇的に軽減し、コードをより安全で予測可能なものにします。

コルーチンビルダー: `launch` と `async`

`CoroutineScope` 内でコルーチンを開始するには、コルーチンビルダーを使用します。

  • `launch`: 「Fire and Forget」型のコルーチンを開始します。結果を返さず、`Job` オブジェクトを返します。この `Job` を使って、コルーチンの状態を監視したり、手動でキャンセルしたりできます。UIの更新やデータの保存など、戻り値が不要な非同期タスクに適しています。
  • `async`: 結果を返すコルーチンを開始します。`Deferred` オブジェクトを返します。`Deferred` は `Job` の一種で、将来得られるであろう結果 `T` を保持しています。結果を取得するには、`Deferred` オブジェクトの `.await()` メソッドを呼び出します。`.await()` は `suspend` 関数であり、結果が利用可能になるまでコルーチンを中断します。複数の非同期処理を並行して実行し、すべての結果が揃うのを待ちたい場合に特に有用です。
viewModelScope.launch {
    // launch: 結果を待たない
    launch {
        // ログを送信するなどの副作用的な処理
        sendAnalyticsEvent("data_loading_started")
    }

    // async: 結果を待つ
    val userDeferred = async(Dispatchers.IO) { fetchUserData("user1") }
    val permissionsDeferred = async(Dispatchers.IO) { fetchUserPermissions("user1") }

    // .await()で結果が揃うまで中断
    val user = userDeferred.await()
    val permissions = permissionsDeferred.await()

    // 両方の結果を使ってUIを更新
    updateUi(user, permissions)
}

ディスパッチャとコンテキストの切り替え

コルーチンはどのスレッドで実行されるのでしょうか?それを決定するのが `CoroutineDispatcher` です。`CoroutineDispatcher` は `CoroutineContext` の一部であり、コルーチンの実行スレッドを制御します。

  • `Dispatchers.Main`: UI操作専用のメインスレッド。Androidでは必須です。UIコンポーネントの更新は必ずこのディスパッチャ上で行う必要があります。
  • `Dispatchers.IO`: ネットワーク通信やファイルI/Oなど、ブロッキングが発生しうるI/O集約的なタスクに最適化されたスレッドプール。
  • `Dispatchers.Default`: CPUを大量に消費する計算集約的なタスク(リストのソート、JSONのパースなど)に最適化されたスレッドプール。

コルーチンは、`withContext` を使うことで、ブロック内で実行コンテキスト(ディスパッチャなど)を安全かつ効率的に切り替えることができます。

fun loadAndProcessData() {
    viewModelScope.launch(Dispatchers.Main) { // UIスレッドで開始
        // UIにローディング表示
        showLoadingSpinner()

        val data = withContext(Dispatchers.IO) {
            // I/Oスレッドに切り替えてネットワークリクエスト
            fetchRemoteData()
        }

        val processedData = withContext(Dispatchers.Default) {
            // CPU集約的な処理のためにDefaultスレッドに切り替え
            parseAndSort(data)
        }

        // Mainスレッドに戻ってUIを更新
        updateUiWith(processedData)
        hideLoadingSpinner()
    }
}

このように `withContext` を使うことで、コールバックを使わずに、スレッド切り替えを伴う複雑な非同期処理を、あたかも上から下へ流れる同期コードのように記述できます。

例外処理

構造化された並行性は例外処理にも及びます。子コルーチンでキャッチされなかった例外は、親コルーチンへと伝播します。これにより、例外が一箇所に集約され、管理が容易になります。

`launch` で起動したコルーチン内で発生した例外は、親に伝播し、親とその兄弟コルーチンすべてをキャンセルします。これは「フェイルファスト」の原則に基づいています。

一方で、`async` で起動したコルーチン内の例外は、`.await()` が呼ばれるまで発生しません。`.await()` を呼び出した時点で、例外がスローされます。これは、例外を遅延させることで、より柔軟なエラーハンドリングを可能にします。

標準の `try-catch` ブロックが、コルーチン内の例外を捕捉するための最も一般的な方法です。

viewModelScope.launch {
    try {
        val user = async(Dispatchers.IO) { fetchUserData("invalid_id") }.await()
        updateUi(user)
    } catch (e: Exception) {
        // ネットワークエラーなどをここでキャッチ
        showError("Failed to load user data: ${e.message}")
    }
}

独立した子コルーチンの失敗が他の兄弟に影響を与えないようにしたい場合は、`SupervisorJob` を使用します。`viewModelScope` はデフォルトで `SupervisorJob` を使用しているため、ViewModel内の1つの `launch` が失敗しても、他の `launch` は影響を受けません。

第2章: Flowによる非同期データストリーム

コルーチンが単一の非同期な値を扱うのに優れているのに対し、Flowは時間とともに生成される複数の非同期な値を扱うための仕組みです。データベースのクエリ結果、ユーザーの入力イベント、サーバーからの継続的な更新など、値の「ストリーム」を表現するのに最適です。

Flowの本質: "Cold Stream"

Flowの最も重要な特性は「Cold(冷たい)」であることです。これは、Flowが定義されただけでは何も実行されず、`collect` などの終端オペレータが呼び出されるまでは、コードが実行を開始しないことを意味します。

データベースからユーザーのリストを取得するFlowを考えてみましょう。

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

fun getAllUsers(): Flow<User> = flow {
    println("Flow started") // この行は collect が呼ばれるまで実行されない
    val userIds = db.getAllUserIds() // DBアクセス
    for (id in userIds) {
        emit(db.getUserById(id)) // emitで値をストリームに流す
        delay(100) // 1秒ごとにユーザーを放出
    }
}

この `getAllUsers()` 関数を呼び出しても、コンソールに "Flow started" と表示されることはありません。実際にDBアクセスが始まるのは、誰かがこのFlowを `collect` したときです。

// ... somewhere in a coroutine
val userFlow = getAllUsers() // ここではまだ何も起こらない
println("Flow created")

// collect を呼び出した瞬間に、flow { ... } の中のコードが実行を開始する
userFlow.collect { user ->
    println("Collected user: ${user.name}")
}

この「遅延実行」の性質により、リソースを必要な時まで確保せず、効率的なデータ処理が可能になります。

Flowの生成、変換、消費

Flowの操作は、大きく3つのステップに分けられます。

  1. 生成 (Creation): `flow { ... }` ビルダーが最も一般的です。他にも `flowOf(1, 2, 3)` や、コレクションをFlowに変換する `listOf(a, b, c).asFlow()` などがあります。
  2. 変換 (Transformation) / 中間オペレータ (Intermediate Operators): Flowから放出される各値を加工します。これらはFlowを返し、連鎖的に呼び出すことができます(チェイン)。中間オペレータもまたColdであり、`collect` が呼ばれるまで実行されません。
    • `map`: 各要素を別の値に変換します。 `flow.map { it * 2 }`
    • `filter`: 条件に合致する要素のみを通過させます。 `flow.filter { it % 2 == 0 }`
    • `onEach`: 各要素が下流に流れる際に副作用(ロギングなど)を実行します。デバッグに便利です。`flow.onEach { println(it) }`
    • `debounce`: 値が立て続けに放出された場合、指定した時間、新しい値が来なければ最後の値のみを流します。検索クエリの入力などに使われます。
  3. 消費 (Consumption) / 終端オペレータ (Terminal Operators): Flowの実行をトリガーし、結果を待ち受けます。これらは `suspend` 関数です。
    • `collect`: 最も基本的な終端オペレータ。各値を順番に受け取ります。
    • `toList`, `toSet`: Flowのすべての値をコレクションに変換します。
    • `first`: 最初の値のみを取得します。
    • `reduce`, `fold`: すべての値を集約して単一の結果を生成します。
suspend fun processUserStream() {
    (1..10).asFlow() // 生成 (1から10の数字を放出)
        .filter { it % 2 != 0 } // 変換 (奇数のみ)
        .map { id -> "User $id" } // 変換 (文字列にマッピング)
        .collect { userName -> // 消費
            println("Processing $userName")
        }
}

コンテキストの分離: `flowOn`

Flowには「コンテキスト保存」という重要な原則があります。これは、`collect` を呼び出したコルーチンのコンテキスト(特に `CoroutineDispatcher`)が、Flowの上流(`emit` する側)にまで伝播することを意味します。

しかし、重い処理(DBアクセスなど)をUIスレッドで行うわけにはいきません。そこで登場するのが `flowOn` オペレータです。`flowOn` は、それより上流のオペレータの実行コンテキストを指定したディスパッチャに変更します

fun getUserUpdates(): Flow<User> = flow {
    // このブロックは Dispatchers.IO で実行される
    val user = db.fetchUserFromNetwork()
    emit(user)
}.flowOn(Dispatchers.IO) // 上流の実行コンテキストをIOスレッドに指定

// ... in ViewModel
viewModelScope.launch { // ここは Dispatchers.Main
    getUserUpdates()
        .map { user -> user.name.uppercase() } // このmapはMainで実行
        .collect { uppercasedName ->
            // このcollectもMainで実行
            _uiState.value = UiState.Success(uppercasedName)
        }
}

`flowOn` は、データ生成の責務(バックグラウンドスレッド)とデータ消費の責務(UIスレッド)を明確に分離するための、非常にクリーンで強力なツールです。

第3章: 高度なFlowの活用

基本的な操作に慣れたら、より複雑なシナリオに対応するための高度なFlowの機能を見ていきましょう。エラー処理、完了処理、そしてUIの状態管理に革命をもたらす「Hot Stream」について解説します。

宣言的なエラー処理: `catch`

Flowのストリーム処理中に例外が発生する可能性があります。`try-catch` ブロックで `collect` を囲むこともできますが、Flowはより宣言的な `catch` オペレータを提供します。

`catch` オペレータは、上流で発生した例外のみを捕捉します。これにより、エラーハンドリングのロジックをデータ処理のパイプラインに自然に組み込むことができます。

fun getUsersWithPotentialError(): Flow<User> = flow {
    emit(db.getUser(1))
    emit(db.getUser(2))
    throw IOException("Network connection lost") // ここで例外が発生
    emit(db.getUser(3)) // これは実行されない
}

// ...
viewModelScope.launch {
    getUsersWithPotentialError()
        .catch { e ->
            // 上流のIOExceptionをキャッチ
            Log.e("FlowError", "An error occurred: ${e.message}")
            emit(User.defaultUser) // エラー時にデフォルト値を放出することも可能
        }
        .collect { user ->
            // user 1, user 2, そして defaultUser が収集される
            println("Collected: ${user.name}")
        }
}

完了のハンドリング: `onCompletion`

Flowの処理が正常に完了したか、例外で終了したかに関わらず、最後に特定の処理(例:ローディングインジケータを非表示にする)を行いたい場合があります。そのために `onCompletion` オペレータが用意されています。

fun fetchDataFlow(): Flow<String> = // ...

viewModelScope.launch {
    _showLoading.value = true // 処理開始前にローディング表示

    fetchDataFlow()
        .onCompletion { cause ->
            // causeは、正常完了ならnull、例外終了ならその例外
            _showLoading.value = false // 常に最後にローディングを非表示
            if (cause != null) {
                println("Flow completed with an error: $cause")
            }
        }
        .catch { /* エラー処理 */ }
        .collect { /* データ処理 */ }
}

UI状態管理の変革: `StateFlow` と `SharedFlow`

これまで見てきたFlowは「Cold」でした。つまり、購読者(コレクター)が現れるたびに、新しいデータストリームが生成されます。しかし、UIの状態のように、複数の購読者で最新の値を共有し、アプリがアクティブな間は常に存在し続けるデータソースも必要です。このようなケースに対応するのが「Hot Stream」である `StateFlow` と `SharedFlow` です。

`StateFlow`

`StateFlow` は、現在の「状態」を保持するためのFlowです。`LiveData` に似ていますが、より多機能でコルーチンの世界に完全に統合されています。

  • 常に値を持ち、最新の値(`.value` プロパティ)に同期的にアクセスできます。
  • 新しい購読者は、接続した瞬間に最新の値を受け取ります。
  • 値の更新は `conflation`(合体)の仕組みで行われます。処理が追いつかないほど高速に値が更新された場合、中間的な値はスキップされ、最新の値のみが購読者に通知されます。これはUIの状態を表現するのに非常に適しています。

ViewModelでの使用例:

class UserProfileViewModel(private val repository: UserRepository) : ViewModel() {

    // UIに公開する読み取り専用のStateFlow
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun loadUser(userId: String) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            repository.getUserStream(userId)
                .catch { e -> _uiState.value = UiState.Error(e.message) }
                .collect { user -> _uiState.value = UiState.Success(user) }
        }
    }
}

// UI状態を表す sealed interface
sealed interface UiState {
    object Loading : UiState
    data class Success(val user: User) : UiState
    data class Error(val message: String?) : UiState
}

`SharedFlow`

`SharedFlow` は、より汎用的なHot Streamです。一度だけのイベント(例:トーストメッセージの表示、画面遷移の指示)を複数の購読者に通知したい場合に使用します。

  • `StateFlow` と異なり、初期値を持ちません。
  • 過去に放出された値をいくつまで新しい購読者に再生(リプレイ)するか (`replay` パラメータ) を細かく設定できます。
  • 購読者がいない場合でも値をバッファリングする容量 (`extraBufferCapacity`) を設定できます。

ViewModelでのイベント通知の例:

class LoginViewModel : ViewModel() {
    private val _events = MutableSharedFlow<LoginEvent>()
    val events: SharedFlow<LoginEvent> = _events.asSharedFlow()

    fun onLoginClicked() {
        viewModelScope.launch {
            if (loginSuccess()) {
                _events.emit(LoginEvent.NavigateToHome)
            } else {
                _events.emit(LoginEvent.ShowErrorToast("Invalid credentials"))
            }
        }
    }
}

sealed interface LoginEvent {
    object NavigateToHome : LoginEvent
    data class ShowErrorToast(val message: String) : LoginEvent
}

これらのHot Streamを適切に使い分けることで、現代のAndroidアプリにおける複雑な状態管理とイベントハンドリングを、非常にクリーンかつリアクティブに実装することができます。

第4章: 実践的Androidアーキテクチャへの応用

理論を学んだところで、これらを実際のAndroidアプリケーション、特にGoogleが推奨するMVVM(Model-View-ViewModel)アーキテクチャにどのように統合するかを見ていきましょう。ここでは、ネットワーク(Retrofit)、データベース(Room)、そしてUI(ViewModelとCompose/Fragment)を連携させる一般的なシナリオを構築します。

シナリオ: リモートAPIから記事のリストを取得し、ローカルのRoomデータベースにキャッシュします。UIはデータベースを監視し、データが変更されると自動的に更新されるようにします。

1. データ層 (Data Layer) - Retrofit と Room

Retrofit での suspend 関数の活用

Retrofitはコルーチンをネイティブでサポートしています。APIインターフェースの関数に `suspend` 修飾子を付けるだけで、非同期なネットワーク呼び出しを簡単に行えます。

// ApiService.kt
interface ApiService {
    @GET("articles")
    suspend fun getArticles(): List<ArticleDto>
}

Room での Flow の活用

Roomもまた、Flowを強力にサポートしています。DAOのクエリメソッドの戻り値を `Flow<List<Article>>` のように宣言するだけで、Roomはデータベースのテーブルが変更されるたびに、自動的に新しいデータのリストを放出するFlowを生成してくれます。

// ArticleDao.kt
@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY publishDate DESC")
    fun getAllArticles(): Flow<List<ArticleEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(articles: List<ArticleEntity>)
}

Repository の実装

Repositoryは、データソース(ネットワーク、DB)を抽象化する役割を担います。ここでコルーチンとFlowが接着剤として機能します。

// ArticleRepository.kt
class ArticleRepository(
    private val apiService: ApiService,
    private val articleDao: ArticleDao
) {
    // UIはこのFlowを購読する。DBの変更が自動的にUIに通知される。
    val articles: Flow<List<Article>> = articleDao.getAllArticles().map { entities ->
        entities.map { it.toDomainModel() }
    }

    // データを更新するためのsuspend関数
    suspend fun refreshArticles() {
        try {
            // IOスレッドでネットワークからデータを取得
            val remoteArticles = apiService.getArticles()
            // 取得したデータをDBエンティティに変換してDBに保存
            articleDao.insertAll(remoteArticles.map { it.toEntity() })
        } catch (e: Exception) {
            // エラーハンドリング
            Log.e("Repository", "Failed to refresh articles", e)
        }
    }
}

2. ViewModel層 (ViewModel Layer)

ViewModelは、UIの状態を保持し、ビジネスロジックを実行します。RepositoryからFlowを受け取り、`StateFlow` に変換してUIに公開するのが一般的なパターンです。

// ArticleListViewModel.kt
class ArticleListViewModel(
    private val repository: ArticleRepository
) : ViewModel() {

    // RepositoryのFlowをStateFlowに変換する
    // `stateIn` はCold FlowをHotなStateFlowに変換するためのオペレータ
    val articles: StateFlow<List<Article>> = repository.articles
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000), // 購読者がいなくなって5秒後に上流の購読を停止
            initialValue = emptyList() // 初期値
        )

    init {
        // ViewModelが初期化されたときにデータを更新する
        viewModelScope.launch {
            repository.refreshArticles()
        }
    }
}

`stateIn` オペレータは、UIのライフサイクル(画面回転など)を考慮しつつ、効率的にデータを共有するための重要なツールです。

3. UI層 (UI Layer) - Fragment/Activity または Jetpack Compose

UI層は、ViewModelの `StateFlow` を購読し、状態が変化するたびに画面を更新します。このとき、UIのライフサイクルを考慮して安全にFlowを収集することが非常に重要です。

Fragment/Activity での収集

`lifecycleScope.launch` と `repeatOnLifecycle` を使います。これにより、UIが `STARTED` 状態のときだけFlowの収集を行い、`STOPPED` になると自動的に収集を停止します。これにより、バックグラウンドでの不要なUI更新やリソースリークを防ぎます。

// ArticleListFragment.kt
class ArticleListFragment : Fragment() {
    private val viewModel: ArticleListViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // UIがSTARTED状態のときだけ、このブロックが実行される
                viewModel.articles.collect { articles ->
                    // RecyclerViewのアダプタを更新するなどのUI処理
                    articleAdapter.submitList(articles)
                }
            }
        }
    }
}

Jetpack Compose での収集

Jetpack Composeでは、`collectAsState` という拡張関数が用意されており、さらに簡潔に記述できます。これにより、Flowが新しい値を放出するたびに、Composableが自動的に再コンポーズされます。

// ArticleListScreen.kt
@Composable
fun ArticleListScreen(viewModel: ArticleListViewModel = viewModel()) {
    val articles by viewModel.articles.collectAsState()

    // articlesリストを使ってUIを構築する
    LazyColumn {
        items(articles) { article ->
            ArticleItem(article)
        }
    }
}

このように、コルーチンとFlowはデータ層からUI層まで一貫した非同期処理のパラダイムを提供し、リアクティブで堅牢、かつテストしやすい現代的なAndroidアプリケーションの構築を可能にします。

第5章: パフォーマンス最適化とテスト

コルーチンとFlowを使いこなす上で、パフォーマンスへの配慮と、その動作を保証するためのテストは不可欠です。ここでは、協調的なキャンセルと、`kotlinx-coroutines-test` ライブラリを用いたテスト手法について解説します。

協調的なキャンセル (Cooperative Cancellation)

コルーチンのキャンセルは「協調的」です。つまり、コルーチンにキャンセルが要求されても、即座に中断されるわけではありません。コルーチン自身が定期的にキャンセルの状態を確認し、中断処理に応じる必要があります。

`kotlinx.coroutines` ライブラリに含まれる `suspend` 関数(`delay`, `yield`, `withContext` など)はすべてキャンセル可能です。つまり、これらの関数を呼び出すと、コルーチンがキャンセルされているかどうかを内部的にチェックします。もしキャンセルされていれば、`CancellationException` をスローしてコルーチンを停止させます。

しかし、CPUを占有する重いループ処理など、キャンセル可能な `suspend` 関数を呼び出さないコードを書く場合は注意が必要です。そのようなコードはキャンセル要求を無視して走り続けてしまいます。

// 悪い例: キャンセルに応じない
scope.launch {
    var nextPrintTime = System.currentTimeMillis()
    while (true) { // このループはキャンセルされない
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping...")
            nextPrintTime += 500L
        }
    }
}

これを解決するには、ループ内で明示的にコルーチンの状態を確認する必要があります。`isActive` プロパティを使うのが最も簡単な方法です。

// 良い例: キャンセルに協調する
scope.launch {
    var nextPrintTime = System.currentTimeMillis()
    while (isActive) { // ループの継続条件に isActive を追加
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping...")
            nextPrintTime += 500L
        }
    }
    // ループを抜けた後、キャンセル時のクリーンアップ処理などを行える
    println("job: I've been cancelled.")
}

長時間実行される可能性のある処理を書く際には、常に協調的なキャンセルを意識することが、リソースを適切に管理し、アプリケーションを安定させる鍵となります。

`kotlinx-coroutines-test` によるテスト

非同期コードのテストは、タイミングの問題などが絡むため、伝統的に困難でした。`kotlinx-coroutines-test` ライブラリは、この問題を解決するための強力なツールを提供します。

まず、プロジェクトにライブラリを追加します。

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1"
}

`runTest` ビルダー

テストは `runTest` コルーチンビルダーの中で実行します。`runTest` は、テスト用の特別な `TestScope` を提供し、仮想時間を使ってコルーチンを即座に実行します。これにより、`delay` など時間のかかる処理を含むテストも、実際には一瞬で完了します。

`TestDispatcher` の注入

ViewModelなどをテストする際、`Dispatchers.Main` や `Dispatchers.IO` がハードコードされていると、テストの制御が難しくなります。これを解決するために、ディスパッチャを依存性注入(Dependency Injection)できるように設計し、テスト時には `TestDispatcher` を注入するのがベストプラクティスです。

ViewModelのテスト例を見てみましょう。

// MainCoroutineRule.kt - JUnit4用のルール
@ExperimentalCoroutinesApi
class MainCoroutineRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        super.finished(description)
        Dispatchers.resetMain()
    }
}

// ArticleListViewModelTest.kt
@ExperimentalCoroutinesApi
class ArticleListViewModelTest {
    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    private lateinit var viewModel: ArticleListViewModel
    private lateinit var fakeRepository: FakeArticleRepository

    @Before
    fun setup() {
        fakeRepository = FakeArticleRepository()
        viewModel = ArticleListViewModel(fakeRepository)
    }

    @Test
    fun `articles stateFlow correctly emits articles from repository`() = runTest {
        // 準備: フェイクのリポジトリが返す記事リストを定義
        val articles = listOf(Article(1, "Title 1"), Article(2, "Title 2"))
        fakeRepository.setArticles(articles)

        // 実行: ViewModelのFlowから最初の値を取得
        val result = viewModel.articles.first()

        // 検証: 取得した値が期待通りか確認
        assertEquals(articles, result)
    }
}

このテストでは、`Dispatchers.setMain` を使ってメインディスパッチャをテスト用のものに置き換えています。これにより、`viewModelScope` がテストスレッド上で即座にコルーチンを実行するため、安定したテストが可能になります。

このように、テストライブラリを適切に活用することで、コルーチンとFlowを用いた複雑な非同期ロジックも、決定論的かつ高速にテストすることができます。

まとめ

KotlinコルーチンとFlowは、もはや単なるライブラリの一つではなく、現代のKotlinアプリケーション開発における非同期処理のスタンダードです。構造化された並行性による安全性、`suspend` 関数による直線的なコード、そしてFlowによる宣言的なデータストリーム処理は、開発者がより複雑な問題に集中し、保守性が高く、ユーザー体験に優れたアプリケーションを構築することを可能にします。

本記事では、その基礎から実践的なアーキテクチャへの応用、そしてテスト手法までを網羅的に解説しました。この知識を武器に、あなたのアプリケーションを次のレベルへと引き上げてください。非同期プログラミングの世界は奥深く、進化し続けています。公式ドキュメントを探求し、コミュニティと交流しながら、常に学び続ける姿勢が、優れた開発者であり続けるための鍵となるでしょう。

Modern Android Concurrency with Kotlin Coroutines and Flow

In the landscape of modern application development, particularly on resource-constrained platforms like Android, managing background tasks and handling asynchronous operations efficiently is not just a feature—it's a necessity. A responsive user interface is paramount for a positive user experience. Any long-running operation, such as a network request, database transaction, or complex computation, executed on the main thread can lead to a frozen UI, unresponsive controls, and ultimately, an "Application Not Responding" (ANR) dialog. This is the core problem that asynchronous programming aims to solve.

Historically, the Android framework has provided several tools to tackle this, from the now-deprecated AsyncTask to raw Threads, Handlers, and third-party libraries like RxJava. While functional, these solutions often introduced their own complexities. Callbacks led to deeply nested, hard-to-read code often dubbed "callback hell." Managing thread pools manually was error-prone, and reactive extensions, while powerful, came with a steep learning curve and a different programming paradigm. Kotlin Coroutines emerged as a direct answer to these challenges, offering a new way to write asynchronous code that is sequential in appearance, easier to reason about, and deeply integrated into the Kotlin language.

This article provides a comprehensive exploration of Kotlin Coroutines and its companion for data streams, Flow. We will start from the fundamental principles that make coroutines work, move into their practical application within the Android framework, and then dive deep into the world of asynchronous data streams with Flow. Our goal is to illustrate not just the "how" but the "why," demonstrating how these tools enable the creation of cleaner, more robust, and more performant Android applications.

The Foundation: Understanding Kotlin Coroutines

At its core, a coroutine is often described as a "lightweight thread." While this analogy is helpful, it doesn't capture the full picture. More accurately, a coroutine is a suspendable computation. It is a block of code that can be executed, paused (or suspended), and resumed at a later time. Unlike threads, which are managed by the operating system and are expensive to create and switch between, coroutines are managed by the Kotlin runtime and are incredibly cheap. Thousands, even millions, of coroutines can be launched without significant overhead.

The Magic of `suspend` Functions

The central concept that powers coroutines is the suspend keyword. When you mark a function with suspend, you are signaling to the compiler that this function can be paused and resumed. This allows it to call other suspend functions without blocking the underlying thread.

Consider a network request. In a traditional blocking model, the thread that initiates the request would sit idle, waiting for the response. With a suspend function, the coroutine suspends its execution at the point of the network call. The underlying thread is immediately freed up to do other work, such as drawing the UI or running another coroutine. Once the network response is available, the coroutine resumes its execution on an appropriate thread, right where it left off, with its entire state (local variables, call stack) preserved.

Under the hood, the Kotlin compiler performs a transformation known as Continuation-Passing Style (CPS). Every suspend function is implicitly given an extra parameter of type Continuation. This object holds the information needed to resume the function. This complex transformation is handled entirely by the compiler, allowing developers to write asynchronous code that looks deceptively simple and sequential.


// This function can pause its execution without blocking the thread.
suspend fun fetchUserData(userId: String): User {
    // delay is a built-in suspend function that pauses the coroutine.
    // It does not block the thread.
    delay(1000) 
    return User(id = userId, name = "Jane Doe") // Simulate a network response
}

Coroutine Builders: `launch` and `async`

You cannot call a suspend function from a regular, non-suspending function. You need a bridge to enter the world of coroutines. This is where coroutine builders come in. They are started from a CoroutineScope and create a new coroutine.

  • `launch`: This builder is used for "fire-and-forget" operations. It starts a new coroutine that runs concurrently with the rest of the code and doesn't return a result to the caller. It returns a Job object, which is a handle to the coroutine. You can use this job to wait for the coroutine to complete (job.join()) or to cancel it (job.cancel()). It's ideal for tasks like updating a database or triggering a one-off network call where you don't immediately need the result.
  • `async`: This builder is used when you need a result from the coroutine. It also starts a new coroutine, but it returns a Deferred<T>, which is a lightweight future that promises a result of type T. You can get the result by calling the .await() method on the Deferred object. await() is a suspend function itself, so it will pause the calling coroutine until the result is ready. async is perfect for performing multiple independent tasks in parallel and then combining their results.

// Example using launch
viewModelScope.launch {
    // This block runs in a coroutine.
    val user = fetchUserData("123")
    updateUi(user)
}

// Example using async for parallel execution
viewModelScope.launch {
    val userDeferred = async { fetchUserData("123") }
    val permissionsDeferred = async { fetchUserPermissions("123") }

    // .await() suspends until the results are ready
    val user = userDeferred.await()
    val permissions = permissionsDeferred.await()

    showUserDashboard(user, permissions)
}

Structured Concurrency: The Safety Net

One of the most powerful features of Kotlin Coroutines is structured concurrency. This principle ensures that when a coroutine is started from a specific scope, its lifetime is bound to that scope. If the scope is cancelled, all the coroutines it launched are automatically cancelled as well. This creates a clear parent-child hierarchy.

This is a game-changer for preventing resource leaks. In an unstructured system (like launching a task on a global executor), if the UI component that started the task is destroyed, the background task might continue running, wasting resources and potentially crashing the app if it tries to update a non-existent UI. With structured concurrency, if a user navigates away from a screen, the associated CoroutineScope is cancelled, and any ongoing network requests or computations started within that scope are automatically cleaned up. This makes code safer and more robust by default.

Coroutine Context and Dispatchers

Every coroutine runs within a specific CoroutineContext. This context is a set of elements that define the behavior of the coroutine. The most important of these elements is the CoroutineDispatcher, which determines which thread or thread pool the coroutine will execute on.

The standard dispatchers provided by the `kotlinx.coroutines` library are:

  • `Dispatchers.Main`: This dispatcher is confined to the main UI thread on Android. It should be used for any task that interacts with the UI, such as updating a TextView or showing a Toast. The library kotlinx-coroutines-android provides this main thread dispatcher.
  • `Dispatchers.IO`: This dispatcher is optimized for offloading blocking I/O (input/output) tasks. This includes network requests, reading from or writing to files, and database operations. It uses a shared pool of on-demand created threads.
  • `Dispatchers.Default`: This dispatcher is optimized for CPU-intensive work that happens off the main thread. Examples include sorting a large list, performing complex calculations, or parsing a large JSON object. It is backed by a shared pool of threads with a size equal to the number of CPU cores (at least two).

The ability to easily switch between contexts is a cornerstone of coroutine usage in Android. The common pattern is to start a coroutine on the `Main` dispatcher, switch to the `IO` dispatcher to perform a background task, and then switch back to the `Main` dispatcher to update the UI with the result. This is done using the `withContext` function.


viewModelScope.launch { // Coroutine starts on Dispatchers.Main (by default for viewModelScope)
    val userProfile = withContext(Dispatchers.IO) {
        // This block executes on a background I/O thread.
        // It's safe to make a blocking network call here.
        apiService.fetchUserProfile()
    }
    // Execution resumes on Dispatchers.Main here.
    // It's safe to update the UI with the userProfile.
    nameTextView.text = userProfile.name
}

Integrating Coroutines into the Android Framework

To begin using coroutines in an Android project, you need to add the necessary dependencies to your module's `build.gradle` file. The `coroutines-core` library provides the main APIs, while `coroutines-android` adds support for the Android main looper.


// build.gradle.kts (or build.gradle)
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
}

Lifecycle-Aware Coroutine Scopes

The Android Jetpack libraries provide built-in `CoroutineScope`s that are tied to the lifecycle of app components. Using these scopes is the recommended practice as they automatically handle cancellation for you, embracing structured concurrency.

  • `viewModelScope`: Available in `ViewModel`s from the `androidx.lifecycle:lifecycle-viewmodel-ktx` library. This scope is bound to the `ViewModel`'s lifecycle. All coroutines launched in this scope are automatically cancelled when the `ViewModel`'s `onCleared()` method is called. This is the perfect place to launch business logic operations that should survive configuration changes but not the screen's complete destruction.
  • `lifecycleScope`: Available in `Activity`s and `Fragment`s from the `androidx.lifecycle:lifecycle-runtime-ktx` library. This scope is bound to the component's `Lifecycle`. It will cancel any coroutines launched within it when the `Lifecycle` is destroyed. While useful, it's often better to place business logic in a `ViewModel` and use `viewModelScope` to avoid re-fetching data on configuration changes.

Here's a more complete `ViewModel` example showcasing `viewModelScope`:


class UserProfileViewModel(private val userRepository: UserRepository) : ViewModel() {

    // Using MutableStateFlow to hold the UI state. We'll cover StateFlow later.
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState

    fun fetchUser(userId: String) {
        // Launch a coroutine in the ViewModel's scope.
        // It will be automatically cancelled when the ViewModel is cleared.
        viewModelScope.launch {
            try {
                // Switch to the IO dispatcher for the network call.
                val user = withContext(Dispatchers.IO) {
                    userRepository.fetchUser(userId)
                }
                // Back on the Main thread, update the UI state.
                _uiState.value = UserUiState.Success(user)
            } catch (e: Exception) {
                // Handle exceptions, e.g., network errors.
                _uiState.value = UserUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// Sealed interface to represent the different UI states.
sealed interface UserUiState {
    object Loading : UserUiState
    data class Success(val user: User) : UserUiState
    data class Error(val message: String) : UserUiState
}

Handling Asynchronous Data Streams with Kotlin Flow

Coroutines are excellent for one-shot operations: fetch data once, perform a calculation, and return a result. But what about situations where data arrives over time? Consider receiving live location updates, getting real-time updates from a database, or handling a stream of user input events. This is where Kotlin Flow comes in. Flow is a type in the coroutine library for representing an asynchronous stream of data.

A Flow is conceptually similar to an `Iterator`, but it produces values asynchronously. It emits multiple values sequentially over a period of time, and it completes when it's done. It's built on top of coroutines and fully supports structured concurrency.

The Nature of Flow: Cold Streams

By default, a Flow is a cold stream. This means the code inside a Flow builder (like `flow { ... }`) does not run until a terminal operator, such as `collect`, is called on it. Furthermore, a new, separate execution of the flow is started for every single collector. This is analogous to a YouTube video: each viewer gets their own playback of the video, starting from the beginning. This makes Flows resource-efficient, as they only do work when there is an observer.

Creating Flows

There are several ways to create a Flow:

  • flow { ... }: The most common builder. Inside the lambda, you can use the emit() function (a `suspend` function) to produce values.
  • flowOf(...): A simple builder to create a flow that emits a fixed set of values.
  • .asFlow(): An extension function to convert various types, like a `List` or a `Range`, into a Flow.

// A flow that emits numbers 1 through 5 with a delay.
val numberFlow: Flow<Int> = flow {
    for (i in 1..5) {
        // Simulate work or data arriving
        delay(500)
        println("Emitting $i")
        emit(i)
    }
}

Consuming Flows: Terminal Operators

Flows are started by calling a terminal operator. These are `suspend` functions that begin listening for values. The most common one is `collect`.


viewModelScope.launch {
    println("Collector is ready.")
    numberFlow.collect { number ->
        // This block is executed for each value emitted by the flow.
        println("Collected $number")
    }
    println("Flow has completed.")
}

// Output:
// Collector is ready.
// Emitting 1
// Collected 1
// Emitting 2
// Collected 2
// ... and so on
// Flow has completed.

Other terminal operators include `toList()`, `toSet()`, `first()` (gets only the first emitted value), and `reduce()` (accumulates values into a single result).

Transforming Flows: Intermediate Operators

Flows provide a rich set of operators, similar to those for collections, to transform the data stream. These are intermediate operators. They don't execute the flow; instead, they return a new, transformed flow. These operations are applied lazily when a terminal operator is called.

  • map: Transforms each emitted value.
  • filter: Emits only the values that satisfy a given predicate.
  • onEach: Performs an action on each element without modifying it, useful for logging or side effects.

viewModelScope.launch {
    numberFlow
        .filter { it % 2 != 0 } // Keep only odd numbers: 1, 3, 5
        .map { "Value: $it" }  // Transform them into strings
        .collect { stringValue ->
            println(stringValue)
        }
}
// Output:
// Value: 1
// Value: 3
// Value: 5

The `flowOn` Operator: Context Preservation

A crucial rule of Flows is context preservation: a flow's collection always happens in the `CoroutineContext` of the collecting coroutine. The code inside the `flow { ... }` builder runs in that same context. But what if the flow's producer needs to do heavy work, like accessing a database?

You cannot use `withContext` inside a `flow` builder to change the context. Instead, you use the `flowOn` operator. This operator changes the context for all the upstream operators (the ones that come before it). The collection (downstream) remains on the original context.


fun getUserUpdates(): Flow<User> = flow {
    // This part of the flow runs on the dispatcher specified by flowOn.
    val userList = database.userDao().getAll() // Blocking DB call
    userList.forEach { user ->
        delay(200) // Simulate a stream
        emit(user)
    }
}.flowOn(Dispatchers.IO) // Specify that the upstream operations should run on the IO dispatcher.

// In the ViewModel:
viewModelScope.launch { // This scope is on Dispatchers.Main
    getUserUpdates()
        .collect { user ->
            // This collection block runs on Dispatchers.Main,
            // so it's safe to update the UI.
            userNameTextView.text = user.name
        }
}

Combining Flows

Flows can be combined in various ways:

  • zip: Combines two flows by pairing up their emissions. The resulting flow emits a `Pair` of values. It waits for both flows to emit an item before producing a new pair.
  • combine: Combines the latest values from two or more flows. Whenever any of the source flows emits a new value, the `combine` block is executed with the latest values from all sources. This is extremely useful for UI state that depends on multiple independent data sources.

val flowA = flowOf(1, 2, 3).onEach { delay(300) }
val flowB = flowOf("A", "B").onEach { delay(500) }

// Using combine
combine(flowA, flowB) { number, letter ->
    "$number$letter"
}.collect {
    println(it) // Output will be: 1A, 2A, 2B, 3B
}

Advanced Flow: `StateFlow` and `SharedFlow` for UI State Management

While standard flows are cold, there are situations where you need a hot stream—one that exists independently of any collectors. The `kotlinx.coroutines` library provides two special-purpose Flow implementations for this: `StateFlow` and `SharedFlow`.

`StateFlow`

StateFlow is a specialized, hot flow designed to hold state. It always has a value, and when a new collector starts observing, it immediately receives the current value. It is an excellent modern replacement for `LiveData` in Android ViewModels.

Key properties:
  • It is a state-holder; it has a .value property to access the current state.
  • It emits only distinct values; if you set the value to the same thing it currently holds, it will not emit an update.
  • It replays exactly one last value to new subscribers.

`SharedFlow`

`SharedFlow` is a more general-purpose hot flow for broadcasting values to multiple collectors. It can be configured with a `replay` cache to send a number of recent values to new collectors and a `buffer` to handle backpressure. It's ideal for one-time events that should be consumed by one or more observers, like showing a `SnackBar` or navigating to a new screen.


// ViewModel using StateFlow for UI state and SharedFlow for events
class MyViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState(isLoading = true))
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    private val _events = MutableSharedFlow<UiEvent>()
    val events: SharedFlow<UiEvent> = _events.asSharedFlow()

    fun onLoginClicked(user: String, pass: String) {
        viewModelScope.launch {
            // ... perform login logic
            if (success) {
                _uiState.update { it.copy(isLoggedIn = true) }
            } else {
                _events.emit(UiEvent.ShowLoginError("Invalid credentials"))
            }
        }
    }
}

Error Handling and Best Practices

Robust applications must gracefully handle errors. Coroutines and Flow provide clear mechanisms for this.

Error Handling in Coroutines

Since coroutine code is sequential, you can use standard `try-catch` blocks to handle exceptions, just as you would with synchronous code. This is one of the biggest advantages over callback-based systems.


viewModelScope.launch {
    try {
        val data = repository.fetchData()
        // ... process data
    } catch (e: HttpException) {
        // Handle HTTP-specific errors
    } catch (e: IOException) {
        // Handle network connectivity errors
    }
}

Error Handling in Flow

Flow has a declarative `catch` operator. This operator can catch any exceptions that happen in the upstream flow (before the `catch` operator itself). It can then choose to log the error, emit a default value, or re-throw a different exception.


repository.getDataStream() // This flow might throw an exception
    .map { processItem(it) }
    .catch { e ->
        // This catches exceptions from getDataStream() and map()
        Log.e("FlowError", "An error occurred", e)
        emit(UiState.ErrorState) // Emit a default state
    }
    .collect { uiState ->
        updateUi(uiState)
    }

Writing Cancellable Coroutines

Most built-in `suspend` functions from `kotlinx.coroutines` (like `delay`, `withContext`, `yield`) are cancellable. They check for the coroutine's cancellation status and throw a `CancellationException` if it has been cancelled. However, if you are writing your own long-running computation, you must make it cooperative with cancellation. You can do this by periodically checking the `isActive` property of the coroutine scope.


val job = launch(Dispatchers.Default) {
    var nextPrintTime = System.currentTimeMillis()
    while (isActive) { // Check for cancellation
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin() // Cancels the job and waits for its completion
println("main: Now I can quit.")

Conclusion: A New Era for Android Concurrency

Kotlin Coroutines and Flow represent a significant evolution in how we write asynchronous code on Android. They move us away from the complexities of callback hell and the steep learning curve of other reactive frameworks, offering an approach that is both powerful and intuitive. By embracing structured concurrency, they provide a safety net that eliminates common sources of bugs and memory leaks. The ability to write asynchronous code that reads like synchronous code reduces cognitive load and makes complex logic easier to maintain.

From simple, one-shot background tasks with `launch` and `async`, to handling complex, real-time data streams with Flow, and managing UI state reactively with `StateFlow`, this modern toolkit provides a comprehensive solution for the concurrency challenges faced in Android development. As you continue to build with these tools, you will find your code becomes cleaner, more resilient, and more aligned with the declarative and reactive patterns that define modern application architecture.

Friday, August 17, 2018

코틀린 DI, Koin으로 가볍고 명쾌하게 프로젝트 구성하기

왜 우리는 의존성 주입(DI)에 주목해야 하는가?

소프트웨어 개발의 세계, 특히 안드로이드와 같이 복잡한 생명주기를 가진 플랫폼에서는 '결합도(Coupling)'를 낮추고 '응집도(Cohesion)'를 높이는 것이 양질의 코드를 만드는 핵심 원칙으로 꼽힙니다. 의존성 주입(Dependency Injection, DI)은 바로 이 원칙을 실현하는 가장 강력하고 보편적인 디자인 패턴 중 하나입니다.

간단한 비유를 들어보겠습니다. 자동차를 만든다고 상상해보세요. 자동차라는 클래스(`Car`)는 엔진(`Engine`), 타이어(`Tire`), 변속기(`Transmission`) 등 수많은 부품(객체)에 의존합니다. DI를 사용하지 않는다면, `Car` 클래스 내부에서 직접 `Engine`, `Tire` 객체를 생성해야 합니다.


class Car {
    private val engine = Engine() // 직접 의존성 생성
    private val tire = Tire()     // 직접 의존성 생성

    fun start() {
        engine.ignite()
        // ...
    }
}

이 방식의 문제점은 명확합니다. `Car`는 `Engine`의 구체적인 구현에 강하게 결합됩니다. 만약 `V8Engine`이나 `ElectricEngine`으로 교체하고 싶다면 `Car` 클래스의 코드를 직접 수정해야 합니다. 테스트는 더욱 어려워집니다. `Car`를 테스트하기 위해서는 항상 실제 `Engine` 객체가 필요하며, 특정 상황을 가정한 가짜 엔진(`MockEngine`)을 사용하기가 매우 까다롭습니다.

의존성 주입은 이 문제를 우아하게 해결합니다. 객체 생성의 책임을 외부로 옮기는 것입니다. 필요한 의존성을 외부(DI 컨테이너 또는 생성자)로부터 '주입'받습니다.


class Car(private val engine: Engine, private val tire: Tire) { // 외부에서 의존성 주입
    fun start() {
        engine.ignite()
        // ...
    }
}

// 사용하는 측
fun main() {
    val myEngine = V8Engine() // 원하는 엔진 선택
    val myTire = SportsTire()   // 원하는 타이어 선택
    val myCar = Car(myEngine, myTire) // 의존성 주입!
    myCar.start()
}

이렇게 했을 때의 장점은 다음과 같습니다.

  • 유연성 및 확장성 증가: `Car` 코드 수정 없이 `ElectricEngine`, `OffroadTire` 등 다양한 부품으로 쉽게 교체할 수 있습니다.
  • 테스트 용이성: 단위 테스트 시 실제 `Engine` 대신 `MockEngine`을 주입하여 `Car`의 로직만을 순수하게 테스트할 수 있습니다.
  • 코드 재사용성 향상: `Engine`과 같은 컴포넌트는 다른 종류의 차량에서도 재사용될 수 있습니다.
  • 관심사의 분리(Separation of Concerns): `Car`는 '주행'이라는 자신의 핵심 책임에만 집중하고, '부품 생성'이라는 책임은 외부 DI 프레임워크에 위임합니다.

안드로이드 개발에서는 액티비티, 프래그먼트, 뷰모델, 리포지토리, 데이터소스 등 수많은 컴포넌트들이 거미줄처럼 얽혀있습니다. DI는 이 복잡한 의존성 관계를 체계적으로 관리하고, 앱의 유지보수성과 테스트 커버리지를 획기적으로 높여주는 필수적인 기술입니다. 그리고 코틀린 생태계에서 이러한 DI를 가장 직관적이고 가볍게 구현할 수 있도록 도와주는 도구가 바로 Koin입니다.

Koin의 철학: 복잡함을 덜어낸 순수 코틀린 DI

코틀린 개발자들에게 Koin이 매력적인 이유는 그 철학에 있습니다. Dagger나 Hilt와 같은 다른 강력한 DI 프레임워크와 비교했을 때 Koin은 독자적인 노선을 걷습니다. Koin의 핵심 철학은 '단순함'과 '실용성'입니다.

1. 코드 생성(Code Generation)의 부재

Dagger와 Hilt는 어노테이션 프로세싱(Annotation Processing)을 기반으로 컴파일 타임에 필요한 의존성 그래프와 팩토리 코드를 자동으로 생성합니다. 이 방식은 컴파일 시점에 의존성 관련 오류를 모두 잡아낼 수 있다는 막강한 장점이 있습니다. 하지만 단점도 명확합니다.

  • 빌드 시간 증가: 어노테이션 프로세서는 빌드 과정에 추가적인 단계를 포함시켜 프로젝트 규모가 커질수록 빌드 시간을 눈에 띄게 늘립니다.
  • 복잡한 설정: 초기 설정이 다소 복잡하며, 생성된 코드를 이해하고 디버깅하기가 까다로울 수 있습니다.

반면, Koin은 코드 생성을 전혀 사용하지 않습니다. 대신 코틀린 언어가 가진 강력한 기능, 특히 DSL(Domain-Specific Language)인라인 함수(Inline Functions)의 Reified 제네릭 타입을 활용하여 런타임에 의존성을 해결합니다. 이는 다음과 같은 장점을 가져옵니다.

  • 매우 빠른 빌드 속도: 추가적인 코드 생성 단계가 없으므로 빌드 속도에 거의 영향을 미치지 않습니다.
  • 간결함: 순수 코틀린 코드로 의존성을 정의하므로 코드를 읽고 이해하기가 매우 쉽습니다. 별도의 어노테이션이나 복잡한 개념을 학습할 필요가 적습니다.
물론 단점도 존재합니다. 의존성 정의에 오타가 있거나 필요한 의존성을 선언하는 것을 잊었을 경우, 컴파일 시점이 아닌 앱 실행 시점(런타임)에 에러가 발생합니다. 하지만 Koin은 이를 보완하기 위해 모듈의 무결성을 체크할 수 있는 테스트 유틸리티를 제공합니다.

2. 서비스 로케이터(Service Locator) 패턴 기반의 실용성

엄밀히 말해 Koin은 순수한 의미의 DI 프레임워크라기보다는 '실용적인 DI 프레임워크의 외형을 갖춘 서비스 로케이터'에 가깝습니다. 서비스 로케이터는 중앙 레지스트리(Koin의 경우, 컨테이너)에 모든 서비스를 등록해두고, 클라이언트가 필요할 때 직접 레지스트리에 요청하여 서비스를 가져오는 방식입니다.

전통적인 DI 패턴에서는 클래스가 자신이 어떤 의존성을 필요로 하는지 외부에 명시(주로 생성자를 통해)하고, DI 프레임워크가 이를 자동으로 해결하여 주입해줍니다. 반면, Koin에서는 `by inject()`나 `get()`과 같은 키워드를 사용하여 개발자가 능동적으로 "Koin 컨테이너야, 나에게 이 의존성을 줘"라고 요청하는 형태를 띕니다.

이론가들 사이에서는 서비스 로케이터 패턴이 의존성을 숨기고 테스트를 어렵게 만들 수 있다는 비판도 있지만, Koin은 코틀린의 확장 함수와 위임 프로퍼티(delegated properties)를 통해 이 문제를 매우 우아하게 해결했습니다. `by inject()`를 사용하면 마치 클래스의 프로퍼티인 것처럼 자연스럽게 의존성을 사용할 수 있어, 서비스 로케이터의 단점은 최소화하고 DI의 장점은 대부분 누릴 수 있습니다. Koin은 이론적 순수성보다는 개발 현장에서의 실용성과 편리성에 더 큰 가치를 둡니다.

Koin 프로젝트 설정: 3단계로 끝내는 간편함

Koin의 가장 큰 매력 중 하나는 설정이 믿을 수 없을 만큼 간단하다는 점입니다. 단 3단계면 프로젝트에 Koin을 통합할 수 있습니다.

1단계: 의존성 추가 (build.gradle.kts)

가장 먼저 프로젝트의 `build.gradle.kts` (또는 `build.gradle`) 파일에 Koin 관련 라이브러리를 추가해야 합니다. Koin은 다양한 모듈로 구성되어 있으므로 필요한 것만 선택하여 추가할 수 있습니다.


// build.gradle.kts (Module Level)

dependencies {
    // Koin의 핵심 라이브러리 (필수)
    def koin_version = "3.5.3" // 작성 시점의 최신 버전을 확인하세요.
    implementation "io.insert-koin:koin-core:$koin_version"

    // 안드로이드 프로젝트를 위한 확장 기능 (Activity, Fragment, ViewModel 등 지원)
    implementation "io.insert-koin:koin-android:$koin_version"

    // (선택) Jetpack Compose와의 통합을 위함
    implementation "io.insert-koin:koin-androidx-compose:$koin_version"

    // (선택) 테스트 코드 작성을 위한 라이브러리
    testImplementation "io.insert-koin:koin-test:$koin_version"
    testImplementation "io.insert-koin:koin-test-junit4:$koin_version" // JUnit4 사용 시
    // testImplementation "io.insert-koin:koin-test-junit5:$koin_version" // JUnit5 사용 시
}

2단계: Koin 모듈(Module) 작성

모듈은 Koin에게 "어떻게 객체를 생성하고 주입할지" 알려주는 레시피와 같습니다. `module { ... }` DSL 블록 안에 의존성들을 정의합니다. 일반적으로 기능별, 레이어별(e.g., `appModule`, `networkModule`, `databaseModule`)로 모듈 파일을 분리하여 관리하는 것이 좋습니다.


// 예시: app/src/main/java/com/example/myapp/di/AppModule.kt

import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module

// 가상의 클래스들 정의
interface GreetingRepository {
    fun getHello(): String
}

class GreetingRepositoryImpl : GreetingRepository {
    override fun getHello() = "Hello, Koin!"
}

class MainViewModel(private val repository: GreetingRepository) : ViewModel() {
    fun sayHello(): String {
        return repository.getHello()
    }
}


// Koin 모듈 정의
val appModule = module {
    // single: 앱 전체 생명주기 동안 단 하나의 인스턴스만 유지 (싱글톤)
    // GreetingRepository 타입으로 요청이 오면 GreetingRepositoryImpl 인스턴스를 반환
    single<GreetingRepository> { GreetingRepositoryImpl() }

    // factory: 요청할 때마다 새로운 인스턴스를 생성
    // factory<SomeClass> { SomeClass() }

    // viewModel: 안드로이드 ViewModel 전용. 뷰모델의 생명주기를 따름
    // 내부적으로 get()을 호출하여 GreetingRepository 의존성을 자동으로 주입받음
    viewModel { MainViewModel(get()) }
}

위 코드에서 사용된 주요 Koin DSL 키워드는 다음과 같습니다.

  • `module { ... }`: 의존성 정의를 그룹화하는 최상위 블록입니다.
  • `single<T> { ... }`: 앱이 실행되는 동안 전역적으로 유일한 인스턴스를 생성합니다. 데이터베이스 핸들, 네트워크 클라이언트처럼 공유 자원에 적합합니다.
  • `factory<T> { ... }`: `inject()` 또는 `get()`으로 요청할 때마다 매번 새로운 인스턴스를 생성하여 반환합니다. 상태를 가지지 않는 간단한 객체나, 매번 새로운 상태가 필요한 경우에 사용합니다.
  • `viewModel<T> { ... }`: 안드로이드 `ViewModel`을 위해 특별히 제공되는 선언입니다. 내부적으로 `ViewModelProvider.Factory`를 처리하여, 해당 ViewModel이 필요한 Activity나 Fragment의 생명주기에 맞게 ViewModel 인스턴스를 관리해줍니다.
  • `get<T>()`: 다른 의존성을 정의할 때, 해당 의존성이 필요로 하는 또 다른 의존성을 Koin 컨테이너로부터 가져올 때 사용합니다. `MainViewModel`이 `GreetingRepository`를 필요로 하므로 `get()`을 사용하여 주입합니다.

3단계: Application 클래스에서 Koin 시작

마지막으로, `Application` 클래스의 `onCreate()` 메서드에서 `startKoin`을 호출하여 Koin을 초기화하고 작성한 모듈들을 로드해야 합니다.


// 예시: app/src/main/java/com/example/myapp/MyApplication.kt

import android.app.Application
import com.example.myapp.di.appModule // 위에서 작성한 모듈
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
import org.koin.core.logger.Level

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        startKoin {
            // Koin 로그를 안드로이드 Logcat으로 출력
            // 프로덕션 빌드에서는 Level.NONE으로 설정하는 것을 권장
            androidLogger(Level.DEBUG)

            // 안드로이드 Context를 Koin에 제공
            androidContext(this@MyApplication)

            // 위에서 정의한 모듈들을 로드
            modules(appModule) // 여러 개일 경우 modules(appModule, networkModule, ...)
        }
    }
}

그리고 `AndroidManifest.xml`에 이 `Application` 클래스를 등록하는 것을 잊지 마세요.



    ...

이것으로 모든 설정이 끝났습니다. 이제 앱의 어느 곳에서든 Koin을 통해 의존성을 주입받을 준비가 되었습니다.

Koin 실전 활용: 다양한 컴포넌트에 생명력 불어넣기

설정을 마쳤으니, 이제 실제 안드로이드 컴포넌트에서 Koin을 어떻게 활용하는지 구체적인 예시와 함께 알아보겠습니다.

Activity/Fragment에서 의존성 주입받기

Koin은 Activity나 Fragment 같은 안드로이드 UI 컨트롤러에서 의존성을 손쉽게 주입받을 수 있는 방법을 제공합니다. 가장 일반적인 방법은 `by inject()` 위임 프로퍼티를 사용하는 것입니다.


class MainActivity : AppCompatActivity() {
    
    // by inject(): MainViewModel 타입의 의존성을 Koin에 요청합니다.
    // 'lazy'하게 주입되므로, 'mainViewModel' 프로퍼티가 처음 사용되는 시점에 Koin이 인스턴스를 찾아 할당합니다.
    private val mainViewModel: MainViewModel by viewModel()
    
    // MainViewModel이 아닌 다른 일반 클래스(e.g., Repository)를 직접 주입받을 수도 있습니다.
    private val repository: GreetingRepository by inject()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 주입받은 viewModel과 repository 사용
        val greetingMessage = mainViewModel.sayHello()
        Log.d("MainActivity", "From ViewModel: $greetingMessage")
        Log.d("MainActivity", "From Repository directly: ${repository.getHello()}")
        
        // ...
    }
}

여기서 `by viewModel()`과 `by inject()`의 차이점은 중요합니다.

  • `by viewModel()`: `ViewModel`을 주입받기 위해 특별히 제작된 위임 프로퍼티입니다. 화면 회전과 같은 설정 변경에도 `ViewModel` 인스턴스가 안전하게 유지되도록 내부적으로 `ViewModelStoreOwner`를 참조하여 처리해줍니다. Activity나 Fragment에서 ViewModel을 주입받을 때는 반드시 이것을 사용해야 합니다.
  • `by inject()`: `ViewModel`이 아닌 일반적인 객체(리포지토리, 유스케이스, 서비스 등)를 주입받을 때 사용합니다. `lazy`하게 동작하여 성능상 이점을 가집니다.

만약 즉시 인스턴스가 필요하다면 `get()` 함수를 사용할 수도 있습니다. 하지만 `by inject()`를 사용하는 것이 코드를 더 선언적이고 깔끔하게 만들어주므로 일반적으로 권장됩니다.

생성자 주입 (Constructor Injection) 활용

앞선 모듈 정의에서 `viewModel { MainViewModel(get()) }` 부분을 다시 살펴보겠습니다. `MainViewModel`은 생성자를 통해 `GreetingRepository`를 전달받습니다. 이처럼 클래스가 필요로 하는 의존성을 생성자를 통해 명시적으로 받는 것을 '생성자 주입'이라고 하며, 이는 의존성 주입에서 가장 권장되는 방식입니다.

왜 생성자 주입이 좋을까요?

  1. 불변성(Immutability): 생성자에서 주입받은 의존성을 `val`로 선언하면, 해당 객체의 생명주기 동안 의존성이 변경되지 않음을 보장할 수 있습니다. 이는 코드의 예측 가능성을 높입니다.
  2. 명시적인 의존성: 클래스를 사용하기 위해 어떤 객체들이 필요한지 생성자 시그니처만 봐도 명확하게 알 수 있습니다. 숨겨진 의존성이 없습니다.
  3. 테스트 용이성 극대화: 단위 테스트 시, Koin과 같은 DI 프레임워크 없이도 원하는 mock 객체를 생성자에 직접 전달하여 쉽게 테스트 인스턴스를 만들 수 있습니다.

// MainViewModel은 GreetingRepository 없이는 존재할 수 없음이 명확함
class MainViewModel(private val repository: GreetingRepository) : ViewModel() {
    // ...
}

// 단위 테스트 코드 예시 (Koin 없이도 테스트 가능)
@Test
fun testSayHello() {
    // 1. Mock Repository 생성
    val mockRepository = mock(GreetingRepository::class.java)
    `when`(mockRepository.getHello()).thenReturn("Test Greeting")

    // 2. Mock 객체를 생성자에 직접 주입하여 ViewModel 인스턴스화
    val viewModel = MainViewModel(mockRepository)

    // 3. 테스트 실행 및 검증
    val result = viewModel.sayHello()
    assertEquals("Test Greeting", result)
}

따라서, 안드로이드 생명주기에 강하게 묶여 생성자를 직접 제어하기 어려운 Activity/Fragment를 제외한 대부분의 클래스(ViewModel, Repository, UseCase 등)는 생성자 주입을 사용하는 것이 모범 사례(Best Practice)입니다.

이름을 이용한 의존성 구분 (Named Dependencies)

때로는 같은 인터페이스나 클래스 타입이지만, 목적에 따라 다른 구현체나 설정을 가진 객체를 주입해야 할 때가 있습니다. 예를 들어, 일반적인 API 호출을 위한 `OkHttpClient`와 로그 인터셉터가 추가된 디버깅용 `OkHttpClient`가 모두 필요할 수 있습니다. 이때 `named()` 한정자를 사용해 의존성을 구분할 수 있습니다.

1. 모듈에서 이름 정의하기


import org.koin.core.qualifier.named

val networkModule = module {
    // 기본 OkHttpClient
    single(named("default")) {
        OkHttpClient.Builder().build()
    }
    
    // 로깅 인터셉터가 포함된 OkHttpClient
    single(named("logging")) {
        val loggingInterceptor = HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        }
        OkHttpClient.Builder()
            .addInterceptor(loggingInterceptor)
            .build()
    }
    
    // Retrofit 정의
    single {
        Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            // 'logging' 이라는 이름이 붙은 OkHttpClient를 주입해달라고 명시
            .client(get(named("logging")))
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

2. 주입받는 곳에서 이름으로 요청하기


class SomeApiService(
    // get() 함수에 named qualifier를 전달하여 특정 의존성을 선택
    private val retrofit: Retrofit = get() 
) {
    //...
}

class SomeOtherClass {
    // by inject()를 사용할 때도 named qualifier를 지정할 수 있음
    private val defaultClient: OkHttpClient by inject(named("default"))
    private val loggingClient: OkHttpClient by inject(named("logging"))
}

이처럼 `named()`를 사용하면 동일 타입의 여러 의존성을 명확하게 관리하고 필요한 곳에 정확히 주입할 수 있습니다.

Koin 스코프: 의존성 생명주기 정교하게 관리하기

`single`과 `factory`는 매우 유용하지만, 더 복잡한 시나리오에서는 부족할 수 있습니다. 예를 들어, 사용자가 로그인한 동안에만 유효한 객체들(예: 사용자 정보, 세션 토큰)이 있고, 로그아웃하면 이 객체들이 모두 메모리에서 해제되어야 하는 경우가 있습니다. 이럴 때 사용하는 것이 바로 스코프(Scope)입니다.

스코프는 특정 생명주기를 가진 의존성 그룹을 의미합니다. 스코프가 살아있는 동안에는 해당 스코프에 정의된 의존성들이 `single`처럼 동작(하나의 인스턴스 공유)하지만, 스코프가 닫히면(close) 해당 스코프 내의 모든 인스턴스가 함께 파괴됩니다.

안드로이드에서 가장 흔한 스코프 활용 예시는 '로그인 세션 스코프'입니다.

1. 스코프 전용 모듈 정의

먼저 스코프 내에서 사용될 의존성들을 정의합니다. 이때 `scoped` 키워드를 사용합니다.


// SessionManager: 로그인 상태를 관리하는 클래스라고 가정
class SessionManager(private val api: UserApi) {
    // ... 유저 정보, 토큰 등 관리
}

interface UserApi
class UserApiImpl(retrofit: Retrofit) : UserApi

// 스코프 ID를 위한 Qualifier
val SESSION_SCOPE_ID = "SESSION_SCOPE_ID"
val SESSION_SCOPE_NAME = named(SESSION_SCOPE_ID)

// 스코프 모듈
val sessionModule = module {
    // 이 모듈은 SESSION_SCOPE_NAME 이라는 이름의 스코프에 묶임
    scope(SESSION_SCOPE_NAME) {
        // 'scoped'는 이 스코프 내에서 싱글톤으로 동작
        scoped { SessionManager(get()) }
        scoped<UserApi> { UserApiImpl(get()) }
    }
}

2. 스코프 생성, 사용 및 소멸

스코프는 필요할 때 수동으로 생성하고, 더 이상 필요 없을 때 닫아주어야 합니다.


class AuthManager : KoinComponent { // KoinComponent를 구현하면 Koin 기능 직접 사용 가능
    
    private var userSessionScope: Scope? = null

    fun login(userId: String) {
        // 기존 세션이 있다면 닫고 새로 시작
        logout()

        // ID와 이름을 가진 새로운 스코프를 생성
        userSessionScope = koin.createScope(SESSION_SCOPE_ID, SESSION_SCOPE_NAME)
        
        // 스코프가 성공적으로 생성됨
        Log.d("AuthManager", "User session scope created for $userId")
    }

    fun logout() {
        // 열려 있는 스코프를 닫음. 이 때 스코프 내부의 모든 (scoped) 인스턴스가 메모리에서 해제됨.
        userSessionScope?.close()
        userSessionScope = null
        Log.d("AuthManager", "User session scope closed.")
    }

    // 현재 활성화된 세션 스코프를 외부에서 가져갈 수 있는 함수
    fun getSessionScope(): Scope? = userSessionScope
}

// Activity나 ViewModel에서 사용
class UserProfileViewModel(private val authManager: AuthManager) : ViewModel() {
    
    // 현재 세션 스코프에서 SessionManager를 가져옴
    // 스코프가 없다면(로그아웃 상태) 에러가 발생하므로, 스코프 존재 여부를 확인해야 함
    private val sessionManager: SessionManager? = authManager.getSessionScope()?.get()
    
    fun displayUser() {
        // sessionManager를 사용하여 사용자 정보 표시
    }
}

이처럼 스코프를 사용하면 의존성의 생명주기를 애플리케이션의 전체 생명주기보다 더 짧게, 특정 컨텍스트(로그인, 특정 기능 플로우 등)에 맞춰 정교하게 제어할 수 있습니다. 이는 메모리 누수를 방지하고 자원을 효율적으로 관리하는 데 매우 중요합니다.

Koin vs. Dagger/Hilt: 언제 Koin을 선택해야 할까?

코틀린/안드로이드 개발자라면 DI 프레임워크를 선택할 때 Koin과 Dagger/Hilt 사이에서 고민하게 됩니다. 두 솔루션 모두 훌륭하지만, 지향하는 바가 다르므로 프로젝트의 특성과 팀의 상황에 맞게 선택하는 것이 중요합니다.

특징 Koin Dagger / Hilt
핵심 원리 런타임 의존성 해결 (서비스 로케이터 기반) 컴파일 타임 코드 생성 기반
에러 발견 시점 런타임 (앱 실행 후 크래시) 컴파일 타임 (빌드 실패)
빌드 속도 매우 빠름 (영향 거의 없음) 느림 (어노테이션 프로세싱으로 인한 오버헤드)
학습 곡선 낮음 (직관적인 DSL, 순수 코틀린) 높음 (어노테이션, 모듈, 컴포넌트 등 학습 필요)
코드량 및 보일러플레이트 적음 많음 (Hilt가 Dagger보다는 개선됨)
멀티플랫폼 지원 우수 (Kotlin Multiplatform 지원) 제한적 (주로 JVM, 안드로이드 타겟)
안드로이드 공식 지원 X (써드파티 라이브러리) O (Hilt는 Google 공식 권장 DI 솔루션)

결론: 어떤 상황에 무엇을 선택할까?

  • Koin이 빛을 발하는 경우:
    • 빠른 프로토타이핑이나 소규모~중규모 프로젝트를 진행할 때
    • 빌드 속도가 매우 중요할 때
    • DI 개념을 처음 도입하거나, 복잡한 설정 없이 가볍게 시작하고 싶을 때
    • 팀원들이 Dagger/Hilt에 대한 경험이 부족할 때
    • Kotlin Multiplatform(KMP) 프로젝트를 진행할 때
  • Dagger/Hilt가 더 적합한 경우:
    • 수십 명의 개발자가 참여하는 대규모 엔터프라이즈급 프로젝트
    • 컴파일 타임의 안정성을 무엇보다 중요하게 생각할 때
    • 런타임에 발생할 수 있는 잠재적 오류를 최대한 배제하고 싶을 때
    • 팀원들이 이미 Dagger/Hilt에 익숙하고, 관련 생태계를 활용하고 있을 때
    • Google의 공식 지원과 가이드라인을 따르는 것이 중요할 때

선택은 트레이드오프의 문제입니다. Koin은 '개발 속도와 단순성'을 얻는 대신 '컴파일 타임 안정성'을 일부 포기합니다. 반면 Hilt는 '컴파일 타임 안정성'을 보장하는 대신 '빌드 속도와 학습 곡선'이라는 비용을 치릅니다. 프로젝트의 성격과 목표를 명확히 이해하고 가장 적합한 도구를 선택하는 것이 현명한 개발자의 자세입니다.

결론: Koin과 함께하는 즐거운 코틀린 개발 여정

Koin은 복잡하고 어렵게만 느껴졌던 의존성 주입의 장벽을 크게 낮춰준 고마운 라이브러리입니다. 순수 코틀린의 특징을 십분 활용한 간결한 DSL, 코드 생성의 부재로 인한 빠른 빌드 속도, 그리고 직관적인 사용법은 특히 DI를 처음 접하는 개발자나 빠른 개발 속도를 추구하는 팀에게 큰 축복과도 같습니다.

물론 런타임 에러의 가능성이라는 트레이드오프가 존재하지만, 모듈 단위 테스트와 꼼꼼한 코드 리뷰를 통해 충분히 극복할 수 있는 부분입니다. Koin은 단순히 DI를 위한 도구를 넘어, 코틀린 언어의 장점을 어떻게 실용적으로 활용할 수 있는지 보여주는 좋은 예시이기도 합니다.

만약 당신의 코틀린 프로젝트가 의존성 관리의 부재로 인해 점점 더 복잡해지고 테스트하기 어려워지고 있다면, 지금 바로 Koin을 도입해보는 것은 어떨까요? 몇 줄의 설정만으로도 코드의 구조가 훨씬 더 명확해지고, 유연하며, 견고해지는 놀라운 경험을 하게 될 것입니다. Koin과 함께라면 의존성 관리의 스트레스에서 벗어나 더욱 즐거운 코틀린 개발 여정을 떠날 수 있을 것입니다.

Thursday, November 16, 2017

Kotlin - Basic(Basic Types)

Basic Types

코틀린에서 모든 것은 객체로서 변수에 대한 멤버 함수나 프로퍼티를 호출할 수 있다. 어떤 타입은 특별한 내부 표현을 갖는다 -예를 들어 숫자, 문자, 불리언 같은 타입은 러타임에 기본 값으로 표현된다- 하지만 사용자에게는 일반 클래스처럼 보인다. 이 절에서는 코틀린의 기본 타입인 숫자, 문자, 불리언, 배열, 문자열에 대해 설명한다.

Numbers

코틀린은 자바와 유사한 방법으로 숫자를 다루지만 정확히 일치하진 않는다. 그 예로 숫자에 대해 넓은 타입으로 자동변환이 없고, 어떤경우에는 리터럴도 약간 다르다.
코틀린이 제공하는 숫자 내장타입은 다음과 같다(이는 자바와 유사하다.)


Type Bit width
Double   64
Float 32
Long 64
Int 32
Short 16
Byte 8

코틀린에서 문자는 숫자가 아니다.


Literal Constants

정수 값을 위한 리터럴 상수 종류는 다음과 같다.
- 10진수 : 123
   - Long은 대문자 L로 표시 : 123L
- 16진수 : 0x0F
- 2진수 : 0b00001011
8진수 리터럴은 지원하지 않는다.

또한 부동소수점을 위한 표기법을 지원한다:
- 기본은 Double : 123.5, 123.5e10
- Float은 fF로 표시 : 123.5f

Underscores in numeric literals (since 1.1)

1.1부터는 가독성을 위해 상수에 밑줄 사용이 가능하다.
1
2
3
4
5
val oneMillion = 1_000_000
val creditCardNumber = 1234_5678_9012_3456L
val socialSecurityNumber = 999_99_9999L
val hexBytes = 0xFF_EC_DE_5E
val bytes = 0b11010010_01101001_10010100_10010010
cs

Representation

자바 플랫폼에서는 JVM 기본 타입으로 숫자를 내장(physically stored)한다. nullable 숫자 레퍼런스(예 Int? )가 필요하거나 지네릭이 관여하면 박싱(boxing) 타입으로 저장한다. 
숫자를 박싱하면 동일성을 보장하지 않는다:
1
2
3
4
5
val a: Int = 10000
print(a === a) // Prints 'true'
val boxedA: Int? = a
val anotherBoxedA: Int? = a
print(boxedA === anotherBoxedA) // !!!Prints 'false'!!!
cs

반면에 아래는 동등함을 유지한다.(값 동일)
1
2
3
4
5
val a: Int = 10000
print(a == a) // Prints 'true'
val boxedA: Int? = a
val anotherBoxedA: Int? = a
print(boxedA == anotherBoxedA) // Prints 'true'
cs

Explicit Conversions

표현이 다르므로 작은 타입이 큰 타입의 하위 타입은 아니다. 하위 타입이 된다면 다음과 같은 문제가 발생한다:

1
2
3
4
// Hypothetical code, does not actually compile:
val a: Int? = 1 // A boxed Int (java.lang.Integer)
val b: Long? = a // implicit conversion yields a boxed Long (java.lang.Long)
print(a == b) // Surprise! This prints "false" as Long's equals() check for other part to be Long as well
cs

동일성뿐만 아니라 동등함조차 모든 곳에서 알아차리지 못하고 사라지게 된다. 

이런 이유로 작은 타입을 큰 타입으로 자동으로 변환하지 않는다. 이는 명시적 변환없이 Byte 타입 값을 Int 변수에 할당할 수 없음을 뜻한다. 
1
2
val b: Byte = 1 // OK, literals are checked statically
val i: Int = b // ERROR
cs

명시적으로 숫자를 넓히는 변환을 할 수 있다
1
val i: Int = b.toInt() // OK: explicitly widened
cs

모든 숫자 타입은 다음의 변환을 지원한다.




- toByte() : Byte
- toShort() : Short
- toInt() : Int
- toLong() : Long
- toFloat() : Float
- toDouble() : Double
- toChar() : Char

자동 변환의 부재를 거의 느낄 수 없는데 이유는 타입을 컨텍스트에서 추론하고 수치 연산을 변환에 알맞게 오버로딩했기 때문이다. 다음은 예이다
1
val l = 1L + 3 // Long + Int => Long
cs

Operations

코틀린은 숫자에 대한 표준 수치 연산을 지원한다. 이들 연산은 알맞은 클래스의 멤버로 선언되어 있다. (하지만 컴파일러는 메서드 호출을 대응하는 명령어로 최적화한다.) 연산자 오버로딩 을 참고하자.

비트 연산자를 위한 특수 문자는 없고 중의 형식으로 호출할 수 있는 함수를 제공한다. 다음은 예이다: 
1
val x = (1 shl 2) and 0x000FF000
cs

다음은 전체 비트 연산자 목록이다( Int 와 Long 에서만 사용 가능):
- shl(bits) - 부호있는 왼쪽 시프트 (자바의 <<)
- shr(bits) - 부호있는 오른쪽 시프트 (자바의 >>)
- ushr(bits) - 부호없는 오른쪽 시프트 (자바의 >>>)
- and(bits) - 비트의 AND
- or(bits) - 비트의 OR
- xor(bits) - 비트의 XOR
- inv() - 비트의 역

Floating Point Numbers Comparison

이 섹션에서는 부동소수의 연산을 설명한다.

- 동등함 비교 : a == b and a != b
- 비교 연산자 : a < b, a > b, a <= b, a >= b
- 범의 예시와 검사 : a..b, x in a..b, x !in a..b

피연산자 a 와 b 가 정적으로 Float 나 Double 이거나 또는 이에 해당하는 null 가능 타입일 때(타입을 선언하거나 추정하거나 또는 스마트 변환 의 결과), 숫자나 범위에 대한 연산은 부동소수 연산에 대한 IEEE 754 표준을 따른다. 

하지만 범용적인 용례를 지원하고 완전한 순서를 제공하기 위해, 피연산자가 정적으로 부동소수점 타입이 아니면(예 Any , Comparable<...> , 타입 파라미터) 연산은 Float 과 Double 을 위한 equals 와 compareTo 구현을 사용한다. 이는 표준과 비교해 다음이 다르다.

- NaN은 자신과 동일하다고 간주한다.
- NaNPOSITIVE_INFINITY를 포함한 모든 다른 요소보다 크다고 간주한다.
- -0.00.0보다 작다고 간주한다.

Characters

문자는 Char타입으로 표현한다. 이 타입을 바로 숫자로 다룰 수 없다
1
2
3
4
5
fun check(c: Char) {
    if (c == 1) { // ERROR: incompatible types
        // ...
    }
}
cs

문자 리터럴은 작은 따옴표 안에 표시한다 : '1' . 특수 문자를 역슬래시로 표시한다. 다음 특수문자를 지원한다 : \t, \b, \n, \r, \', \", \\, \$. 임의의 문자를 인코딩하려면 유니코드 표기법을 사용한다 : '\uFF00'

문자를 Int 숫자로 명시적으로 변환할 수 있다: 
1
2
3
4
5
fun decimalDigitValue(c: Char): Int {
    if (c !in '0'..'9')
        throw IllegalArgumentException("Out of range")
    return c.toInt() - '0'.toInt() // Explicit conversions to numbers
}
cs

숫자와 마찬가지로, 문자도 null 가능 레퍼런스가 필요하면 박싱된다. 박싱 연산을 하면 동일성은 유지되지 않는다.

Booleans

Boolean 타입은 불리언을 표현하고 두 값이 존재한다 : true 와 false .
null 가능 레퍼런스가 필요하면 불리언도 박싱된다. 
불리언에 대한 내장 연산에는 다음이 있다.
- || - 지연 논리합
- && - 지연 논리곱
- ! - 부정

Arrays

코틀리은 Array클래스로 배열을 표현하며, 이 클래스는 get 과 set 함수(연산자 오버로딩 관례에 따라 [] 로 바뀜), size 프로퍼티와 그외 유용한 멤버 함수를 제공한다:
1
2
3
4
5
6
7
8
class Array<T> private constructor() {
    val size: Int
    operator fun get(index: Int): T
    operator fun set(index: Int, value: T): Unit
    operator fun iterator(): Iterator<T>
    // ...
}
cs

라이브러리 함수 arrayOf() 를 사용해서 배열을 생성할 수 있다. 이 함수에는 항목 값을 전달하는데 arrayOf(1, 2, 3) 은 [1, 2, 3] 배열을 생성한다. arrayOfNulls() 라이브러리 함수를 사용하면 주어진 크기의 null로 채운 배열을 생성할 수 있다. 

배열을 생성하는 다른 방법은 팩토리 함수를 사용하는 것이다. 팩토리 함수는 배열 크기와 해당 인덱스에 위치한 요소의 초기값을 리턴하는 함수를 인자로 받는다:
1
2
// Creates an Array<String> with values ["0", "1", "4", "9", "16"]
val asc = Array(5, { i -> (i * i).toString() })
cs

앞서 말했듯이, [] 연산자는 get()set() 멤버 함수 호출을 의미한다. 

공지: 자바와 달리 코틀린 배열은 불변식이다. 이는 코틀린은 Array<String> Array<Any>에 할당할 수 없음을 의미하며, 런타임 실패를 방지한다. (하지만 Array<out Any>을 사용할 수 있다. 타입 프로젝션을 참고하자.) 

코틀린은 또한 ByteArray, ShortArray, IntArray등 기본 타입의 배열을 표현하기 위한 특수 클래스를 제공한다. 이들 클래스는 박싱 오버헤드가 없다. 이 클래스는 Array클래스와 상속 관계에 있지 않지만 같은 메서드와 프로퍼티를 제공한다. 각 배열 타입을 위한 팩토리 함수를 제공한다:
1
2
val x: IntArray = intArrayOf(1, 2, 3)
x[0] = x[1] + x[2]
cs

Strings

문자열은 String타입으로 표현한다. 문자열은 불변이다. 문자열의 요소는 문자로서 s[i] 와 같은 인덱스 연산으로 접근할 수 있다. for -루프로 문자열을 순회할 수 있다: 
1
2
3
for (c in str) {
    println(c)
}
cs

String Literals

코틀린은 두 가지 타입의 문자열 리터럴을 지원한다. 하나는 escaped 문자열로 탈출 문자를 가질 수 있다. 다른 하나는 raw 문자열로 뉴라인과 임의 텍스트를 가질 수 있 다. escaped 문자열은 자바 문자열과 거의 같다: 
1
val s = "Hello, world!\n"
cs

특수 문자는 전형적인 방식인 역슬래시를 사용한다. 지원하는 특수 문자 목록은 앞서 문자 를 참고한다. raw 문자열은 세 개의 따옴표로 구분하며( """ ), 특수 문자를 포함하지 않고 뉴라인과 모든 다른 문자를 포함할 수 있다:
1
2
3
4
val text = """
    for (c in "foo")
        print(c)
"""
cs

trimMargin() 함수를 사용해서 앞쪽 공백을 제거할 수 있다:
1
2
3
4
5
6
val text = """
    |Tell me and I forget.
    |Teach me and I remember.
    |Involve me and I learn.
    |(Benjamin Franklin)
    """.trimMargin()
cs

기본으로 | 를 경계 접두문자로 사용하지만 trimMargin(">") 과 같이 파라미터를 이용해서 다른 문자를 경계 문자로 사용할 수 있다.

String Templates

문자열은 템플릿 식을 포함할 수 있다. 예를 들어, 코드 조각을 계산하고 그 결과를 문자열에 넣을 수 있다. 템플릿 식은 달러 부호($)로 시작하고 간단한 이름으로 구성된다
1
2
val i = 10
val s = "i = $i" // evaluates to "i = 10"
cs

또는 중괄호 안에 임의의 식을 넣을 수 있다:
1
2
val s = "abc"
val str = "$s.length is ${s.length}" // evaluates to "abc.length is 3"
cs

raw 문자열과 escaped 문자열 안에 템플릿을 넣을 수 있다. 역슬래시 특수 문자를 지원하지 않는 raw 문자열에서 $ 문자를 표현하고 싶다면 다음 구문을 사용한다:
1
2
3
val price = """
${'$'}9.99
"""
cs