사용자 트래픽이 몰리는 피크 타임, 앱이 멈추거나 스크롤이 버벅이는 현상(Jank)을 겪어본 적이 있으신가요? AsyncTask의 메모리 누수 문제나 RxJava의 가파른 러닝 커브로 인해 유지보수에 고통받던 레거시 프로젝트를 맡았을 때, 가장 먼저 직면한 문제는 바로 비동기 로직의 복잡성이었습니다. 특히 메인 스레드를 점유하는 무거운 연산은 치명적인 ANR(Application Not Responding)로 이어지곤 합니다. 오늘은 이 복잡한 비동기 처리를 Kotlin Coroutines와 Flow로 우아하게 해결한 경험을 공유합니다.
왜 여전히 '콜백 지옥'에서 벗어나지 못하는가?
기존의 콜백 기반 구조나 초기 RxJava 코드는 데이터 흐름을 추적하기 어렵게 만듭니다. 비즈니스 로직이 복잡해질수록 flatMap, switchMap 연산자가 중첩되며 이른바 '연산자 지옥'이 펼쳐집니다. 하지만 코틀린 코루틴은 동기 코드처럼 작성하는 비동기 코드를 지향합니다.
코루틴 내부에서는 컴파일러가 CPS(Continuation-Passing Style) 변환을 수행하여 상태 머신을 생성합니다. 개발자는 suspend 키워드 하나만으로 이 복잡한 매커니즘을 추상화하여 사용할 수 있습니다.
LiveData를 대체하는 StateFlow와 SharedFlow
단순한 1회성 비동기 작업은 suspend function으로 충분하지만, 지속적인 데이터 스트림(예: 위치 정보 업데이트, 검색어 입력 감지)은 Flow가 필요합니다. 특히 안드로이드 개발자에게 StateFlow는 생명주기를 인식하지 못하는 RxSubject의 완벽한 대안이자, 메인 스레드 친화적인 LiveData의 상위 호환입니다.
다음은 레거시 콜백 구조를 StateFlow를 활용한 모던 아키텍처로 변환한 예시입니다.
// ViewModel 영역: UI 상태를 관리하는 StateFlow
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
// 내부 수정용 MutableStateFlow
private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Loading)
// 외부 노출용 (읽기 전용)
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
_uiState.value = SearchUiState.Loading
try {
// IO 디스패처로 자동 전환되어 네트워크 요청 수행
repository.fetchResults(query)
.catch { e ->
// 에러 처리도 스트림 내부에서 우아하게 가능
_uiState.value = SearchUiState.Error(e.message)
}
.collect { results ->
_uiState.value = SearchUiState.Success(results)
}
} catch (e: Exception) {
// 코루틴 스코프 내의 예기치 못한 에러 포착
_uiState.value = SearchUiState.Error("Unknown Error")
}
}
}
}
치명적인 실수: 안전하게 Flow 수집하기
안드로이드 프래그먼트나 액티비티에서 Flow를 수집할 때 가장 많이 하는 실수는 단순히 lifecycleScope.launch를 사용하는 것입니다. 이는 앱이 백그라운드로 내려가도 수집을 멈추지 않아 리소스 낭비와 크래시를 유발할 수 있습니다.
반드시 repeatOnLifecycle API를 사용하여 UI 생명주기에 맞춰 수집을 시작하고 중지해야 합니다.
// Fragment 또는 Activity
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
// STARTED 상태일 때만 수집하고, STOPPED가 되면 자동으로 취소됨
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
when(state) {
is SearchUiState.Loading -> showProgressBar()
is SearchUiState.Success -> adapter.submitList(state.data)
is SearchUiState.Error -> showToast(state.message)
}
}
}
}
}
RxJava vs Coroutines Flow 성능 비교
실제 프로덕션 환경에서 리스트 10,000개를 필터링하고 매핑하는 작업을 수행했을 때의 벤치마크 결과입니다. 코루틴은 가벼운 스레드 모델 덕분에 메모리 오버헤드가 현저히 낮습니다.
| 지표 (Metric) | RxJava 2/3 | Kotlin Coroutines & Flow |
|---|---|---|
| 메서드 수 (APK Size) | 약 15,000+ (High) | 약 1,500+ (Low) |
| 메모리 사용량 | 객체 생성 많음 (High overhead) | 경량 구조체 사용 (Low overhead) |
| 코드 가독성 | 연산자 체이닝 복잡 | 순차적 코드 (Imperative style) |
| Backpressure 처리 | 복잡한 전략 필요 (MissingBackpressureException) | 기본적으로 Suspend 기능으로 자동 조절 |
결론: 이제는 넘어갈 때
코틀린 코루틴과 플로우는 단순한 유행이 아닙니다. 안드로이드의 ViewModelScope, LifecycleScope와 결합된 구조적 동시성(Structured Concurrency)은 비동기 작업의 누수를 원천적으로 차단합니다. 아직 RxJava나 AsyncTask를 사용하고 있다면, 신규 기능부터라도 Flow로 작성해 보십시오. 코드의 양은 줄어들고 앱의 안정성은 극적으로 향상될 것입니다.
Post a Comment