네트워킹 및 백그라운드 작업을 위한 Kotlin Coroutines와 RxJava 비교
Kotlin Coroutines와 RxJava 비교: 실제 Android 앱의 네트워킹 및 백그라운드 작업에서 취소, 오류 처리, 테스트 패턴을 비교합니다.

이 선택이 프로덕션 네트워킹에서 중요한 이유
실제 Android 앱에서 네트워킹과 백그라운드 작업은 단순한 API 호출 하나 이상입니다. 로그인과 토큰 갱신, 화면이 로드 중에 회전되는 경우, 사용자가 화면을 떠난 후의 동기화, 사진 업로드, 배터리를 소모하지 않는 주기 작업 등이 포함됩니다.
가장 큰 영향을 주는 버그들은 보통 문법 오류가 아닙니다. 비동기 작업이 UI보다 오래 살아남아 메모리 누수가 발생하거나, 취소가 UI만 멈추게 하고 실제 요청은 멈추지 않아 불필요한 트래픽과 멈춘 스피너가 생기거나, 재시도로 요청이 증폭되어 속도 제한이나 차단이 발생하거나, 서로 다른 계층이 오류를 각기 다르게 처리해서 사용자에게 무엇이 보일지 예측할 수 없게 될 때 나타납니다.
Kotlin Coroutines와 RxJava의 선택은 일상적인 신뢰성에 영향을 줍니다:
- 작업을 모델링하는 방식(일회성 호출 vs 스트림)
- 취소가 어떻게 전파되는지
- 오류가 어떻게 표현되어 UI에 드러나는지
- 네트워크, 디스크, UI 스레드를 어떻게 제어하는지
- 타이밍, 재시도, 엣지 케이스를 테스트하기 얼마나 쉬운지
아래 패턴들은 로드가 걸리거나 네트워크가 느릴 때 주로 깨지는 부분들에 초점을 둡니다: 취소, 오류 처리, 재시도와 타임아웃, 그리고 회귀를 막는 테스트 습관입니다. 예제는 짧고 실용적입니다.
핵심 사고 모델: suspend 호출, 스트림, 그리고 Flow
Kotlin Coroutines와 RxJava의 주요 차이는 당신이 모델링하는 작업의 형태입니다.
suspend 함수는 단일 결과를 나타냅니다. 하나의 값이나 하나의 실패를 반환합니다. 프로필 가져오기, 설정 업데이트, 사진 업로드 같은 대부분의 네트워킹 호출에 잘 맞습니다. 호출 코드는 위에서 아래로 읽히므로 로깅, 캐시, 분기 로직을 추가해도 가독성이 유지됩니다.
RxJava는 먼저 값이 한 개인지 여러 개인지 묻습니다. Single은 일회성 결과(성공 또는 오류)이고, Observable(또는 Flowable)은 여러 값을 방출하다가 완료되거나 실패할 수 있는 스트림입니다. 타이핑 이벤트, 웹소켓 메시지, 폴링 같은 이벤트성 기능에 잘 맞습니다.
Flow는 코루틴 친화적인 스트림 표현 방식입니다. 구조화된 취소를 갖고 있고, suspend API와 자연스럽게 맞아떨어집니다.
간단한 경험 법칙:
- 한 번의 요청과 한 번의 응답에는
suspend를 사용하세요. - 시간이 지나며 값이 바뀌면
Flow를 사용하세요. - 이미 연산자와 복잡한 스트림 조합에 크게 의존하는 앱이라면 RxJava를 사용하세요.
기능이 커질수록, 일회성 호출에 스트림 모델을 억지로 적용하거나 진행 중인 이벤트를 단일 반환값처럼 다루려 하면 가독성이 먼저 무너집니다. 먼저 추상화를 현실에 맞추고, 그다음 규약을 정하세요.
실전에서의 취소 (간단한 코드 예제와 함께)
취소는 비동기 코드가 안전하게 느껴지는지, 아니면 무작위 충돌과 불필요한 호출로 이어지는지를 결정합니다. 목표는 단순합니다: 사용자가 화면을 떠나면 그 화면을 위해 시작된 모든 작업은 중단되어야 합니다.
Kotlin Coroutines에서는 취소가 모델에 내장되어 있습니다. Job은 작업을 나타내고, 구조화된 동시성(structured concurrency)에서는 보통 Job을 여기저기 전달하지 않습니다. ViewModel scope 같은 스코프 내부에서 작업을 시작하면, 그 스코프가 취소될 때 내부의 모든 작업도 취소됩니다.
class ProfileViewModel(
private val api: Api
) : ViewModel() {
fun loadProfile() = viewModelScope.launch {
// If the ViewModel is cleared, this coroutine is cancelled,
// and so is the in-flight network call (if the client supports it).
val profile = api.getProfile() // suspend
// update UI state here
}
}
운영 환경에서 중요한 두 가지 세부사항:
- 취소 가능한 클라이언트를 통해 suspend 네트워킹을 호출하세요. 그렇지 않으면 코루틴은 멈추지만 HTTP 호출은 계속 실행될 수 있습니다.
- 멈춰선 안 되는 요청에는
withTimeout(또는withTimeoutOrNull)을 사용하세요.
RxJava는 명시적인 해제가 필요합니다. 각 구독에 대해 Disposable을 보관하거나 CompositeDisposable에 모아둡니다. 화면이 사라지면 dispose를 호출하면 체인이 멈춰야 합니다.
class ProfilePresenter(private val api: ApiRx) {
private val bag = CompositeDisposable()
fun attach() {
bag += api.getProfile()
.subscribe(
{ profile -\u003e /* render */ },
{ error -\u003e /* show error */ }
)
}
fun detach() {
bag.clear() // cancels in-flight work if upstream supports cancellation
}
}
현실적인 화면 종료 규칙: 취소가 어디서 일어나는지(스코프 취소나 dispose() 같은)를 가리킬 수 없다면, 그 작업은 계속 실행될 것이라고 가정하고 출시 전에 고치세요.
이해하기 쉬운 오류 처리
Kotlin Coroutines와 RxJava의 큰 차이는 오류가 전달되는 방식입니다. 코루틴은 실패가 일반 코드처럼 보이게 합니다: suspend 호출이 예외를 던지고, 호출자가 무엇을 할지 결정합니다. Rx는 실패를 스트림을 통해 흘려보내는데 강력하지만, 주의하지 않으면 문제를 숨기기 쉽습니다.
예상치 못한 실패(타임아웃, 500번, 파싱 버그)는 예외로 처리하세요. UI가 특정 응답(잘못된 비밀번호, “이미 사용된 이메일”)을 필요로 하면 오류를 도메인 모델의 일부로서 데이터로 모델링하세요.
간단한 코루틴 패턴은 스택 트레이스를 보존하고 읽기 쉽습니다:
suspend fun loadProfile(): Profile = try {
api.getProfile() // may throw
} catch (e: IOException) {
throw NetworkException("No connection", e)
}
runCatching과 Result는 진짜로 성공 또는 실패를 반환하고 싶을 때 유용합니다:
suspend fun loadProfileResult(): Result\u003cProfile\u003e =
runCatching { api.getProfile() }
실패를 다루지 않으면서 getOrNull()을 사용하면 실제 버그가 조용히 “빈 상태” 화면으로 바뀔 수 있으니 주의하세요.
RxJava에서는 오류 경로를 명확히 유지하세요. 안전한 대체값에만 onErrorReturn을 사용하고, 캐시 데이터로 전환하려면 onErrorResumeNext를 선호하세요. 재시도의 경우 retryWhen으로 규칙을 좁게 유지해 “잘못된 비밀번호” 같은 경우에 재시도하지 않게 하세요.
오류가 삼켜지는 것을 방지하는 습관 세트:
- 컨텍스트가 있는 곳 가까이에서 한 번만 오류를 로깅하거나 보고하세요.
- 래핑할 때 원래 예외를
cause로 보존하세요. - 모든 오류를 기본값으로 바꾸는 포괄적 잡기는 피하세요.
- 사용자에게 보이는 오류는 문자열이 아니라 타입화된 모델로 만드세요.
스레딩 기초: Dispatchers vs Schedulers
많은 비동기 버그는 스레딩 문제에서 옵니다: 메인 스레드에서 무거운 작업을 하거나 백그라운드 스레드에서 UI를 건드는 경우입니다. Kotlin Coroutines와 RxJava는 주로 스레드 전환을 표현하는 방식이 다릅니다.
코루틴에서는 UI 작업을 위해 보통 메인에서 시작하고, 비싼 작업은 백그라운드 디스패처로 이동합니다. 자주 쓰는 선택지는:
Dispatchers.Main— UI 업데이트Dispatchers.IO— 네트워킹, 디스크 같은 블로킹 I/ODispatchers.Default— JSON 파싱, 정렬, 암호화 같은 CPU 작업
단순한 패턴: 데이터를 가져오고, 메인에서 파싱을 빼고, 그다음 렌더링합니다.
viewModelScope.launch(Dispatchers.Main) {
val json = withContext(Dispatchers.IO) { api.fetchProfileJson() }
val profile = withContext(Dispatchers.Default) { parseProfile(json) }
_uiState.value = UiState.Content(profile)
}
RxJava는 subscribeOn으로 “작업이 어디서 실행되는지”를 표현하고 observeOn으로 “결과를 어디서 소비할지”를 표현합니다. 흔한 오해는 observeOn이 상류의 작업에 영향을 줄 것이라 기대하는 것입니다. 그렇지 않습니다. subscribeOn은 소스와 그 위의 연산자들의 스레드를 설정하고, 각 observeOn은 그 시점부터 스레드를 바꿉니다.
api.fetchProfileJson()
.subscribeOn(Schedulers.io())
.map { json -\u003e parseProfile(json) } // still on io unless you change it
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ profile -\u003e render(profile) },
{ error -\u003e showError(error) }
)
놀라움을 피하는 규칙: UI 작업은 한 곳에 모으세요. 코루틴에서는 Dispatchers.Main에서 상태를 할당하거나 수집하고, RxJava에서는 렌더링 바로 전에 한 번 observeOn(main)을 호출해 불필요한 observeOn 남발을 피하세요.
화면이 버벅거린다면 먼저 파싱과 매핑을 메인 밖으로 옮기세요. 이 한 가지 변경으로 많은 실무 문제를 해결할 수 있습니다.
네트워크 호출의 재시도, 타임아웃, 병렬 작업
정상 경로는 드물게 문제를 일으킵니다. 문제는 호출이 멈춰서거나, 재시도가 상황을 악화시키거나, “병렬” 작업이 실제로는 병렬이 아닐 때 발생합니다. 이런 패턴이 팀이 Kotlin Coroutines와 RxJava 중 무엇을 선호하는지를 결정하곤 합니다.
빨리 실패하는 타임아웃
코루틴에서는 어떤 suspend 호출 주위에도 강한 제한을 둘 수 있습니다. 타임아웃은 호출 지점 가까이에 두어 적절한 UI 메시지를 보여줄 수 있게 하세요.
val user = withTimeout(5_000) {
api.getUser() // suspend
}
RxJava에서는 스트림에 타임아웃 연산자를 붙입니다. 공통 파이프라인의 일부로 타임아웃 동작을 포함시킬 때 유용합니다.
피해를 주지 않는 재시도
재시도는 안전할 때만 하세요. 간단한 규칙: 멱등 요청(GET 등)은 재시도해도 비교적 안전하지만, 사이드 이펙트가 있는 요청(예: 주문 생성)은 재시도하지 마세요. 제한 횟수와 지연 또는 지터를 추가하세요.
좋은 기본 가드레일:
- 네트워크 타임아웃과 일시적 서버 오류에서 재시도하세요.
- 검증 오류(400번)나 인증 실패에서는 재시도하지 마세요.
- 재시도 상한(보통 2–3회)을 두고 최종 실패를 로깅하세요.
- 서버를 때리지 않도록 백오프 지연을 사용하세요.
RxJava에서는 retryWhen으로 “이 오류들에만 이 지연으로 재시도” 같은 규칙을 표현할 수 있습니다. 코루틴에서는 Flow에 retry와 retryWhen이 있고, 일반 suspend 함수에서는 작은 루프에 delay를 넣어 구현합니다.
엉킨 코드 없이 병렬 호출
코루틴은 병렬 작업을 직접적으로 만듭니다: 두 요청을 시작하고 둘 다 기다립니다.
coroutineScope {
val profile = async { api.getProfile() }
val feed = async { api.getFeed() }
profile.await() to feed.await()
}
RxJava는 여러 소스를 결합하는 것이 체인의 핵심일 때 빛을 발합니다. 두 결과를 기다리는 일반 도구는 zip이고, 결과가 도착하는 대로 받고 싶다면 merge가 유용합니다.
큰 스트림이나 빠른 스트림의 경우 백프레셔가 여전히 중요합니다. RxJava의 Flowable은 성숙한 백프레셔 도구를 제공합니다. 코루틴의 Flow도 많은 경우 잘 처리하지만, 이벤트가 UI나 데이터베이스 쓰기 속도를 초과할 수 있다면 버퍼링이나 드롭 정책이 필요할 수 있습니다.
상호운용과 마이그레이션 패턴(혼합 코드베이스)
대부분의 팀은 한 번에 전환하지 않습니다. 실용적인 Kotlin Coroutines vs RxJava 마이그레이션은 앱을 안정적으로 유지하면서 모듈 단위로 옮기는 것입니다.
Rx API를 suspend 함수로 감싸기
기존 Single\u003cT\u003e나 Completable이 있다면, 취소 가능한 어댑터로 감싸 코루틴이 취소될 때 Rx 구독도 해제되게 하세요.
suspend fun <T : Any> Single<T>.awaitCancellable(): T =
suspendCancellableCoroutine { cont -\u003e
val d = subscribe(
{ value -\u003e cont.resume(value) {} },
{ error -\u003e cont.resumeWithException(error) }
)
cont.invokeOnCancellation { d.dispose() }
}
이것은 흔한 실패 모드를 피합니다: 사용자가 화면을 떠나 코루틴이 취소되지만 네트워크 호출은 계속 실행되어 나중에 공유 상태를 업데이트하는 상황을 막습니다.
코루틴 코드를 Rx 호출자에게 노출하기
마이그레이션 중에는 일부 계층이 여전히 Rx 타입을 기대할 수 있습니다. Single.fromCallable로 suspend 작업을 감싸고, 오직 백그라운드 스레드에서만 블로킹하도록 하세요.
fun loadProfileRx(api: Api): Single<Profile> =
Single.fromCallable {
runBlocking { api.loadProfile() } // ensure subscribeOn(Schedulers.io())
}
이 경계는 작고 문서화해 두세요. 새 코드는 코루틴 스코프에서 suspend API를 직접 호출하는 편이 낫습니다.
Flow가 맞는 곳과 맞지 않는 곳
Flow는 많은 Observable 사용 사례를 대체할 수 있습니다: UI 상태, 데이터베이스 업데이트, 페이징 같은 스트림. 다만 hot 스트림, subject, 고급 백프레셔 튜닝이나 팀이 이미 잘 아는 커스텀 연산자 집합에 많이 의존한다면 덜 직접적일 수 있습니다.
혼란을 줄이는 마이그레이션 전략:
- 리프 모듈(네트워크, 저장소)부터 suspend API로 전환하세요.
- 모듈 경계에서 작은 어댑터(Rx→suspend, suspend→Rx)를 추가하세요.
- 소비자도 제어할 수 있을 때만 Rx 스트림을 Flow로 바꾸세요.
- 기능 영역별로 하나의 비동기 스타일을 유지하세요.
- 마지막 호출자가 마이그레이션되면 어댑터를 삭제하세요.
실제로 사용할 테스트 패턴
타이밍과 취소 이슈는 비동기 버그가 숨어 있는 곳입니다. 좋은 비동기 테스트는 시간을 결정적(deterministic)으로 만들고 결과를 쉽게 단언할 수 있게 합니다. 이 부분도 Kotlin Coroutines와 RxJava가 다르게 느껴지지만, 둘 다 잘 테스트할 수 있습니다.
코루틴: runTest, TestDispatcher, 시간 제어
코루틴 코드는 runTest와 테스트 디스패처를 사용하세요. 테스트가 실제 스레드나 실제 지연에 의존하지 않게 하고, 가상 시간을 사용해 타임아웃, 재시도, 디바운스 창을 실제로 기다리지 않고 트리거하세요.
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `emits Loading then Success`() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val repo = Repo(api = fakeApi, io = dispatcher)
val states = mutableListOf<UiState>()
val job = launch(dispatcher) { repo.loadProfile().toList(states) }
testScheduler.runCurrent() // run queued work
assert(states.first() is UiState.Loading)
testScheduler.advanceTimeBy(1_000) // trigger delay/retry windows
testScheduler.runCurrent()
assert(states.last() is UiState.Success)
job.cancel()
}
취소를 테스트하려면 수집하는 Job(또는 부모 스코프)를 취소하고, 페이크 API가 멈추었는지 혹은 더 이상의 상태가 방출되지 않는지 단언하세요.
RxJava: TestScheduler, TestObserver, 결정적 시간
Rx 테스트는 보통 TestScheduler와 TestObserver를 조합해 시간과 어서션을 결정적으로 만듭니다.
@Test
fun `disposes on cancel and stops emissions`() {
val scheduler = TestScheduler()
val observer = TestObserver<UiState>()
val d = repo.loadProfileRx(scheduler)
.subscribeWith(observer)
scheduler.triggerActions()
observer.assertValueAt(0) { it is UiState.Loading }
d.dispose()
scheduler.advanceTimeBy(1, TimeUnit.SECONDS)
observer.assertValueCount(1) // no more events after dispose
}
어떤 스타일이든 오류 경로를 테스트할 때는 예외 타입 자체보다는 매핑된 결과에 집중하세요. 401, 타임아웃, 잘못된 응답이 왔을 때 UI 상태가 어떻게 매핑되는지를 검증하세요.
작은 검사 세트로 대부분의 회귀를 포괄할 수 있습니다:
- 로딩 상태와 최종 상태(Success, Empty, Error)
- 취소 정리(잡이 취소되었는지, Disposable이 해제되었는지)
- 오류 매핑(서버 코드 → 사용자 메시지)
- 재시도 후 중복 방출 없음
- 시간 기반 로직은 실제 지연이 아닌 가상 시간으로 테스트
프로덕션 버그를 유발하는 일반적인 실수
대부분의 프로덕션 이슈는 Kotlin Coroutines vs RxJava 선택 때문이 아니라, 작업이 생각보다 오래 실행되게 하거나, 두 번 실행되게 하거나, 잘못된 스레드에서 UI를 건드리는 몇 가지 습관에서 옵니다.
흔한 누수는 잘못된 스코프에서 작업을 시작하는 것입니다. 화면보다 오래 지속되는 스코프에서 네트워크 호출을 시작하거나 스코프를 직접 생성하고 취소하지 않으면 요청이 사용자가 떠난 뒤에 끝나서 상태를 업데이트하려 합니다. 코루틴에서는 보통 장수명 스코프를 기본으로 사용한 것이 원인이고, RxJava에서는 잊힌 dispose가 흔한 원인입니다.
또 다른 고전적인 실수는 “발사하고 잊기(fire and forget)”입니다. 전역 스코프와 잊힌 Disposable은 몇 번은 괜찮아 보이다가 작업이 쌓이면 문제가 됩니다. 예를 들어 채팅 화면이 resume할 때마다 새로고침을 하면 네비게이션을 몇 번 수행한 뒤엔 여러 개의 새로고침 잡이 동시에 실행되어 메모리를 잡아먹고 네트워크를 경쟁하게 됩니다.
재시도도 잘못 사용하기 쉽습니다. 무제한 재시도나 지연 없는 재시도는 백엔드를 스팸하고 배터리를 소모합니다. 특히 로그아웃 이후의 401처럼 영구적인 실패에는 매우 위험합니다. 재시도를 조건부로 하고 백오프를 추가하며, 회복 불가능한 오류인 경우 중단하세요.
스레딩 실수는 재현하기 어려운 충돌을 일으킵니다. 메인 스레드에서 JSON을 파싱하거나, 디스패처/스케줄러를 어디에 두느냐에 따라 백그라운드에서 UI를 업데이트할 수 있습니다.
이러한 문제를 잡아내는 빠른 점검 목록:
- 작업을 라이프사이클 소유자에 묶고 해당 소유자가 끝날 때 취소하세요.
- 정리는 명확하게 하세요: 한 곳에서 Job을 취소하거나 Disposable을 정리하세요.
- 재시도에 엄격한 한계를 두세요(횟수, 지연, 대상 오류).
- 코드 리뷰에서 UI 업데이트는 메인 스레드만 하도록 규칙을 강제하세요.
- 백그라운드 동기화는 단순 함수 호출이 아니라 제약이 있는 시스템으로 다루세요.
생성된 Kotlin 코드(예: AppMaster에서 생성된 코드)로 Android 앱을 배포하더라도 같은 함정이 적용됩니다. 스코프, 취소, 재시도 제한, 스레드 규칙에 대한 명확한 컨벤션이 필요합니다.
Coroutines, RxJava, 또는 둘을 선택하기 위한 빠른 체크리스트
작업의 형태(shape)로 시작하세요. 대부분의 네트워킹 호출은 일회성이지만, 연결 상태, 인증 상태, 라이브 업데이트 같은 지속적인 신호도 있습니다. 초기부터 잘못된 추상화를 선택하면 나중에 취소가 엉망이 되고 오류 경로가 읽기 어려워집니다.
팀에 설명하기 쉬운 간단한 결정 방식:
- 일회성 요청(로그인, 프로필 가져오기):
suspend를 선호하세요. - 지속적인 스트림(이벤트, DB 업데이트):
Flow또는 RxObservable을 선호하세요. - UI 라이프사이클 취소:
viewModelScope나lifecycleScope에서 코루틴이 수동 Disposable보다 더 간단한 경우가 많습니다. - 고급 스트림 연산자와 백프레셔에 크게 의존하면 RxJava가 더 적합할 수 있습니다(특히 오래된 코드베이스).
- 복잡한 재시도와 오류 매핑: 팀이 읽기 쉽게 유지할 수 있는 접근을 선택하세요.
실용적인 규칙: 한 화면이 한 번의 요청을 하고 한 번의 결과를 렌더링한다면, 코루틴은 코드를 일반 함수 호출에 가깝게 유지합니다. 많은 이벤트의 파이프라인을 만드는 경우(타이핑, 디바운스, 이전 요청 취소, 필터 결합)에는 RxJava나 Flow가 더 자연스럽습니다.
일관성이 완벽보다 낫습니다. 기능마다 다섯 가지의 “최고” 패턴을 쓰는 것보다, 두 가지 좋은 패턴을 전역적으로 쓰는 편이 유지보수에 유리합니다.
예시 시나리오: 로그인, 프로필 조회, 백그라운드 동기화
일반적인 프로덕션 플로우는: 사용자가 로그인 버튼을 누르면 인증 엔드포인트를 호출하고, 홈 화면용 프로필을 가져오고, 마지막으로 백그라운드 동기화를 시작하는 것입니다. 여기서 Kotlin Coroutines와 RxJava의 차이가 일상 유지보수에서 더 크게 느껴질 수 있습니다.
코루틴 버전(순차 + 취소 가능)
코루틴에서는 “이걸 하고 그다음 저걸 한다” 형태가 자연스럽습니다. 사용자가 화면을 닫으면 스코프 취소가 진행 중 작업을 중단합니다.
suspend fun loginAndLoadProfile(): Result<Profile> = runCatching {
val token = api.login(email, password) // suspend
val profile = api.profile("Bearer $token")
syncManager.startSyncInBackground(token) // fire-and-forget
profile
}.recoverCatching { e ->
throw when (e) {
is HttpException -> when (e.code()) {
401 -> AuthExpiredException()
in 500..599 -> ServerDownException()
else -> e
}
is IOException -> NoNetworkException()
else -> e
}
}
// UI layer
val job = viewModelScope.launch { loginAndLoadProfile() }
override fun onCleared() { job.cancel() }
RxJava 버전(체인 + 해제)
RxJava에서는 같은 흐름이 체인으로 표현됩니다. 취소는 보통 CompositeDisposable에 담아 dispose()로 처리합니다.
val d = api.login(email, password)
.flatMap { token -> api.profile("Bearer $token").map { it to token } }
.doOnSuccess { (_, token) -> syncManager.startSyncInBackground(token) }
.onErrorResumeNext { e: Throwable ->
Single.error(
when (e) {
is HttpException -> if (e.code() == 401) AuthExpiredException() else e
is IOException -> NoNetworkException()
else -> e
}
)
}
.subscribe({ (profile, _) -> show(profile) }, { showError(it) })
compositeDisposable.add(d)
override fun onCleared() { compositeDisposable.clear() }
최소 테스트 스위트는 세 가지 결과를 다뤄야 합니다: 성공, 매핑된 실패(401, 500대, 네트워크 없음), 취소/해제.
다음 단계: 컨벤션을 정하고 일관성 있게 유지하세요
문제는 기능마다 패턴이 제각각이라서 생기는 경우가 많습니다. 한 장짜리 의사결정 노트라도 있으면 리뷰 시간이 단축되고 동작이 예측 가능해집니다.
일회성 작업(단일 네트워크 호출)과 스트림(시간에 따라 업데이트되는 것)을 명확히 구분하세요. 각 경우의 기본값을 정하고 예외를 허용하는 조건을 정의하세요.
그다음 모든 기능에서 네트워크가 이상할 때 동일하게 동작하도록 작은 공통 헬퍼들을 추가하세요:
- HTTP 코드, 타임아웃, 오프라인을 앱 수준 실패로 매핑하는 한 곳
- 장기 작업을 위한 기본 타임아웃 값과 긴 작업을 위한 오버라이드 방법
- 안전하게 재시도할 수 있는 정책(예: GET vs POST)
- 사용자가 화면을 떠날 때 무엇을 멈출지에 대한 취소 규칙
- 민감한 데이터 노출 없이 지원에 도움이 되는 로깅 규칙
테스트 컨벤션도 똑같이 중요합니다. 테스트가 실제 시간이나 스레드에 의존하지 않도록 표준 접근법에 합의하세요. 코루틴은 보통 테스트 디스패처와 구조화된 스코프를, RxJava는 TestScheduler와 명시적 해제를 사용합니다. 어느 쪽이든 슬립 없이 빠르고 결정적인 테스트를 목표로 하세요.
전반적으로 더 빨리 진행하고 싶다면 AppMaster (appmaster.io) 같은 도구로 백엔드 API와 Kotlin 기반 모바일 앱의 골격을 생성해 시작할 수 있습니다. 그러나 생성된 코드에서도 취소, 오류, 재시도 제한, 테스트 규약 같은 프로덕션 컨벤션은 여전히 중요합니다.
자주 묻는 질문
기본적으로 로그인이나 프로필 조회처럼 한 번만 결과를 반환하는 요청에는 suspend 함수를 사용하세요. 값이 시간에 따라 바뀌는 경우(웹소켓 메시지, 연결 상태, 데이터베이스 업데이트)에는 Flow(또는 Rx 스트림)를 사용하세요.
예. 다만 사용 중인 HTTP 클라이언트가 취소를 지원해야 합니다. 코루틴은 스코프가 취소되면 코루틴을 멈추지만, 기반의 HTTP 호출이 취소를 지원하지 않으면 요청이 백그라운드에서 계속될 수 있습니다.
작업을 viewModelScope 같은 라이프사이클 스코프에 묶어 스코프가 끝날 때 취소되게 하세요. 전역 스코프나 장수명 스코프에서 실행하지 마세요. 또한 Disposable을 잊지 말고 명확한 한 곳에서 정리하세요.
코루틴에서는 예외가 발생해 throw되고 호출자가 try/catch로 처리하는 방식이 일반적입니다. RxJava에서는 오류가 스트림을 통해 흘러가므로 오류 경로를 명시적으로 처리하고, 실패를 조용히 기본값으로 바꾸는 연산자는 신중히 사용하세요.
타임아웃, 500 에러, 파싱 오류처럼 예기치 않은 실패에는 예외를 사용하세요. UI가 특정 반응(예: 잘못된 비밀번호, 이미 사용된 이메일)을 필요로 한다면 타입화된 오류 데이터를 모델로 사용해 문자열 매칭에 의존하지 마세요.
호출 지점 가까이에 타임아웃을 적용해 적절한 UI 메시지를 보여줄 수 있게 하세요. 코루틴에는 withTimeout이 직관적이고, RxJava에는 timeout 연산자를 사용해 체인의 일부로 만들 수 있습니다.
재시도는 안전할 때만 하세요. 일반 규칙: GET 같이 멱등한 요청은 비교적 자유롭게 재시도하되, 사이드 이펙트가 있는 요청(예: 주문 생성)은 재시도하지 마세요. 재시도 횟수를 제한하고 지연이나 지터를 추가하세요.
코루틴은 Dispatchers를 사용해 UI는 Dispatchers.Main, 네트워크·디스크 같은 입출력은 Dispatchers.IO, CPU 작업은 Dispatchers.Default로 분리합니다. RxJava는 subscribeOn이 소스와 상류 연산의 실행 위치를 정하고 observeOn이 이후 소비 위치를 전환합니다. 렌더링 바로 앞에 한 번만 메인 스레드로 전환하세요.
가능합니다. 경계는 작고 취소를 고려해야 합니다. Rx를 suspend로 감쌀 때는 코루틴 취소 시 Rx 구독을 해제하도록 하고, 반대로 suspend 작업을 Rx 호출자가 필요로 할 때는 문서화된 브리지로만 노출하세요.
가상 시간을 사용하세요. 코루틴은 runTest와 테스트 디스패처로 딜레이와 취소를 제어하고, RxJava는 TestScheduler를 사용해 실제 시간에 의존하지 않게 테스트하세요. 취소 후에는 더 이상 이벤트가 발생하지 않음을 명확히 검증하세요.


