현대 애플리케이션 개발, 특히 사용자 경험이 무엇보다 중요한 모바일 환경에서 비동기 프로그래밍은 더 이상 선택이 아닌 필수입니다. 사용자는 네트워크 요청, 데이터베이스 접근, 복잡한 연산 등 시간이 소요되는 작업이 진행되는 동안에도 앱이 멈추지 않고 부드럽게 반응하기를 기대합니다. 과거 안드로이드 개발자들은 이러한 요구사항을 충족시키기 위해 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`
코루틴을 시작하는 가장 일반적인 방법은 launch
와 async
빌더를 사용하는 것입니다.
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)과 함께 사용하면 테스트 용이성이 크게 향상됩니다.
성능 최적화 전략
마지막으로, 코루틴과 플로우를 사용할 때 고려해야 할 몇 가지 성능 최적화 전략입니다.
- 올바른 디스패처 사용: 앞서 설명했듯이, 작업의 종류(UI, I/O, CPU-bound)에 맞는 디스패처를 사용하는 것이 기본이자 가장 중요합니다.
- 백프레셔(Backpressure) 관리: 플로우에서 데이터 생산 속도가 소비 속도보다 훨씬 빠를 때 백프레셔 문제가 발생할 수 있습니다.
buffer()
,conflate()
,collectLatest()
같은 연산자를 사용하여 이 문제를 완화할 수 있습니다.buffer()
: 생산자와 소비자 사이의 버퍼를 만들어 독립적으로 실행되게 합니다.conflate()
: 소비자가 처리 중일 때 들어온 중간 값들을 버리고 최신 값만 처리하게 합니다.collectLatest()
: 새로운 값이 들어오면 이전 값에 대한 처리 로직을 취소하고 새로운 값으로 다시 시작합니다.
- 자원 정리: 구조적 동시성은 대부분의 자원 정리를 자동화하지만, 코루틴 외부의 리소스(소켓, 파일 핸들 등)를 사용하는 경우 `onCompletion` 플로우 연산자나 `try-finally` 블록을 사용하여 명시적으로 해제해야 합니다.
- 라이브러리 최신 상태 유지: 코루틴과 플로우는 활발하게 개발되고 있습니다. 새로운 버전에는 성능 개선, 버그 수정, 유용한 기능들이 포함되므로 항상 최신 버전을 사용하는 것이 좋습니다.
코루틴과 플로우는 단순히 비동기 코드를 작성하는 새로운 방법을 넘어, 애플리케이션의 아키텍처를 더 반응적이고, 안정적이며, 간결하게 만드는 패러다임의 전환을 이끌고 있습니다. 이 글에서 다룬 원리와 패턴들을 깊이 이해하고 적용한다면, 어떠한 복잡한 비동기 요구사항에도 자신 있게 대처할 수 있는 견고한 기반을 다지게 될 것입니다.