Android Retrofit2 RxJava polling: 일정 간격으로 API 호출하기
이 글은 2022년에 작성한 내용을 2026년 기준으로 전면 개정한 글입니다. 기존 URL은 유지하면서 오래된 코드와 사라진 참고 링크를 정리하고, 수명주기를 고려한 RxJava polling 구현과 Kotlin Coroutines 대안까지 새로 작성했습니다.
Android Retrofit2 RxJava polling: 일정 간격으로 API 호출하기
안드로이드 앱에서 서버 상태, 장치 상태 또는 작업 진행률을 몇 초마다 확인해야 할 때가 있습니다. 서버가 WebSocket이나 푸시 방식을 제공하지 않는다면 일정한 간격으로 API를 호출하는 polling 방식이 가장 단순한 해결책입니다.
하지만 단순히 타이머에서 Retrofit을 반복 호출하면 다음과 같은 문제가 생길 수 있습니다.
- 이전 요청이 끝나기 전에 다음 요청이 시작됨
- 화면이 사라진 뒤에도 네트워크 요청이 계속됨
- 오류가 한 번 발생하면 전체 polling이 종료됨
- Activity 또는 Fragment가 다시 만들어질 때 polling이 중복 실행됨
- Disposable을 정리하지 않아 메모리 누수가 발생함
이 글에서는 Retrofit2와 RxJava를 이용해 이러한 문제를 피하는 구현을 만들어 봅니다. 마지막에는 현재 안드로이드에서 널리 사용하는 Kotlin Coroutines와 Flow 방식도 비교합니다.
| 문제 | 이 글에서 사용하는 해결 방법 |
|---|---|
| 첫 호출이 늦음 | interval(0, 10, ...)으로 즉시 시작 |
| 이전 요청과 다음 요청이 겹침 | switchMapSingle()로 최신 요청만 유지 |
| 한 번의 오류로 polling 종료 | API 요청 내부에서 오류를 상태 값으로 변환 |
| 화면이 사라져도 계속 호출 | onStart()와 onStop()에서 시작·중지 |
| polling이 중복 실행됨 | 기존 Disposable 상태 확인 |
| ViewModel 제거 후 작업이 남음 | onCleared()에서 Disposable 정리 |
예제의 동작 방식
예제 앱은 다음 순서로 동작합니다.
- 화면이 표시되면 polling을 시작합니다.
- 시작 직후 한 번 호출하고 이후 10초마다 API를 호출합니다.
- 새 호출 시점에 이전 호출이 아직 진행 중이면 이전 호출을 취소합니다.
- 성공 결과와 오류 상태를 UI에 표시합니다.
- 화면이 보이지 않으면 polling을 중지합니다.
Observable.interval()은 지정한 주기로 숫자를 발생시킵니다. 첫 번째 인자를 0으로 지정하면 10초를 기다리지 않고 구독 직후 첫 요청을 실행할 수 있습니다.
| 설정 | 예제 값 | 의미 |
|---|---|---|
| 최초 지연 시간 | 0초 | 화면 진입 직후 첫 요청 실행 |
| 호출 간격 | 10초 | 이후 10초마다 새 요청 시작 |
| 화면이 보이지 않을 때 | 중지 | 네트워크·배터리 사용 방지 |
| 오류 발생 시 | 다음 주기에 재시도 | polling 스트림 유지 |
필요한 라이브러리
프로젝트의 모듈 수준 build.gradle.kts에 Retrofit, Gson converter, RxJava adapter와 Android RxJava binding을 추가합니다. 아래의 버전은 프로젝트에서 사용하는 Android Gradle Plugin 및 Kotlin 버전에 맞는 현재 안정 버전으로 지정하세요.
dependencies {
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-gson:3.0.0")
implementation("com.squareup.retrofit2:adapter-rxjava3:3.0.0")
implementation("io.reactivex.rxjava3:rxjava:3.1.12")
implementation("io.reactivex.rxjava3:rxandroid:3.0.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.11.0")
}
위 버전은 2026년 7월 확인 기준입니다. 프로젝트의 Android Gradle Plugin 및 Kotlin 호환성을 함께 확인하세요. 여기서는 RxJava 3 기준으로 작성합니다. 기존 프로젝트가 RxJava 2라면 패키지 이름과 Retrofit adapter가 다르므로 두 버전을 섞지 않아야 합니다.
응답 모델과 Retrofit API 정의
예제 응답은 간단한 메시지 하나를 받는다고 가정합니다.
data class JokeResponse(
val value: String
)
Retrofit API는 Single을 반환하도록 정의합니다.
import io.reactivex.rxjava3.core.Single
import retrofit2.http.GET
interface JokeApi {
@GET("jokes/random")
fun getRandomJoke(): Single<JokeResponse>
}
Retrofit 인스턴스를 만들 때 RxJava 3 call adapter를 추가합니다.
import retrofit2.Retrofit
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
object ApiProvider {
val jokeApi: JokeApi by lazy {
Retrofit.Builder()
.baseUrl("https://api.chucknorris.io/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.build()
.create(JokeApi::class.java)
}
}
baseUrl은 반드시 /로 끝나야 합니다. 이 글에서는 별도 API 키 없이 테스트할 수 있는 공개 예제 API를 사용합니다. 실제 앱에서는 운영 서버 주소로 교체하세요.
UI 상태 정의
성공과 실패를 UI에서 명확하게 처리하기 위해 상태를 분리합니다.
sealed interface PollingUiState {
data object Idle : PollingUiState
data object Loading : PollingUiState
data class Success(val message: String) : PollingUiState
data class Error(val message: String) : PollingUiState
}
| 상태 | 화면에서 처리할 내용 |
|---|---|
Idle |
아직 polling을 시작하지 않은 상태 |
Loading |
최초 결과를 기다리는 상태 |
Success |
새로 받은 데이터를 화면에 표시 |
Error |
오류를 표시하고 다음 호출은 유지 |
ViewModel에서 RxJava polling 구현
핵심은 Observable.interval()과 switchMapSingle() 조합입니다.
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.util.concurrent.TimeUnit
class PollingViewModel(
private val api: JokeApi = ApiProvider.jokeApi
) : ViewModel() {
private val _uiState = MutableLiveData<PollingUiState>(PollingUiState.Idle)
val uiState: LiveData<PollingUiState> = _uiState
private var pollingDisposable: Disposable? = null
fun startPolling() {
if (pollingDisposable?.isDisposed == false) return
_uiState.value = PollingUiState.Loading
pollingDisposable = Observable
.interval(0, 10, TimeUnit.SECONDS)
.switchMapSingle {
api.getRandomJoke()
.subscribeOn(Schedulers.io())
.map<PollingResult> { response ->
PollingResult.Success(response.value)
}
.onErrorReturn { error ->
PollingResult.Failure(
error.message ?: "서버 요청에 실패했습니다."
)
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
_uiState.value = when (result) {
is PollingResult.Success -> {
PollingUiState.Success(result.message)
}
is PollingResult.Failure -> {
PollingUiState.Error(result.message)
}
}
}
}
fun stopPolling() {
pollingDisposable?.dispose()
pollingDisposable = null
}
override fun onCleared() {
stopPolling()
super.onCleared()
}
}
private sealed interface PollingResult {
data class Success(val message: String) : PollingResult
data class Failure(val message: String) : PollingResult
}
왜 switchMapSingle()을 사용하는가
네트워크가 느려서 한 요청이 10초 이상 걸릴 수 있습니다. 단순한 flatMapSingle()은 이전 요청이 끝나지 않아도 다음 요청을 시작할 수 있어 요청이 겹칠 수 있습니다.
switchMapSingle()은 새로운 interval 값이 발생하면 이전 작업을 해제하고 가장 최근 요청만 유지합니다. 상태 화면이나 검색어 자동완성처럼 최신 결과만 의미가 있는 경우에 적합합니다.
모든 요청을 순서대로 완료해야 한다면 concatMapSingle()을 고려할 수 있습니다. 단, 서버 응답이 polling 간격보다 느리면 처리 대기열이 늘어날 수 있습니다.
| 연산자 | 동작 | 적합한 상황 |
|---|---|---|
switchMapSingle() |
새 요청이 시작되면 이전 요청 해제 | 최신 상태만 중요한 화면 |
concatMapSingle() |
앞 요청이 끝난 후 다음 요청 처리 | 모든 결과를 순서대로 처리할 때 |
flatMapSingle() |
여러 요청을 동시에 처리할 수 있음 | 요청 중첩이 허용될 때 |
오류가 발생해도 polling을 유지하는 이유
오류 처리를 바깥쪽 Observable에 두면 한 번의 네트워크 오류로 전체 interval 스트림이 종료될 수 있습니다. 위 코드는 각 API 요청 내부에서 오류를 PollingResult.Failure로 변환합니다. 따라서 오류 상태를 UI에 표시한 뒤 다음 주기에 다시 요청할 수 있습니다.
Fragment에서 시작과 중지 처리
화면이 보일 때 시작하고 보이지 않을 때 중지합니다.
class PollingFragment : Fragment(R.layout.fragment_polling) {
private val viewModel: PollingViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.uiState.observe(viewLifecycleOwner) { state ->
when (state) {
PollingUiState.Idle -> Unit
PollingUiState.Loading -> showLoading()
is PollingUiState.Success -> showMessage(state.message)
is PollingUiState.Error -> showError(state.message)
}
}
}
override fun onStart() {
super.onStart()
viewModel.startPolling()
}
override fun onStop() {
viewModel.stopPolling()
super.onStop()
}
}
이 구조에서는 다른 화면으로 이동하거나 앱이 백그라운드로 들어가면 불필요한 짧은 주기의 polling을 중단합니다. startPolling()에서 중복 실행도 막고 있으므로 화면이 다시 시작되어도 스트림이 여러 개 만들어지지 않습니다.
짧은 주기의 polling과 백그라운드 동기화는 다르다
몇 초 간격으로 화면을 갱신하는 작업은 사용자가 해당 화면을 보고 있을 때만 실행하는 편이 좋습니다. 앱이 종료되어도 계속되어야 하는 백그라운드 동기화라면 RxJava interval 대신 WorkManager를 검토해야 합니다.
WorkManager의 주기 작업은 최소 간격이 15분이며, 배터리 최적화와 제약 조건 때문에 정확한 시각에 실행된다고 보장되지 않습니다. 따라서 5초 또는 10초마다 실행해야 하는 화면 polling 용도로는 적합하지 않습니다.
| 요구사항 | 적합한 방법 |
|---|---|
| 화면을 보는 동안 5~10초 간격 갱신 | RxJava interval 또는 Flow |
| 앱을 닫아도 실행되는 느슨한 주기 작업 | WorkManager |
| 변경 사항을 즉시 받는 실시간 화면 | WebSocket 또는 서버 푸시 |
Coroutines와 Flow로 구현하는 대안
새로운 Kotlin 프로젝트라면 Retrofit의 suspend 함수와 Flow를 사용하는 방식도 좋습니다. Android 공식 아키텍처 권장사항은 일회성 호출에 suspend, 지속적으로 변하는 데이터에 Flow를 사용하는 방향입니다.
Retrofit API를 다음과 같이 정의합니다.
interface JokeCoroutineApi {
@GET("jokes/random")
suspend fun getRandomJoke(): JokeResponse
}
Repository에서 polling Flow를 만듭니다.
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import retrofit2.HttpException
import java.io.IOException
class JokeRepository(
private val api: JokeCoroutineApi
) {
fun pollJokes(intervalMillis: Long = 10_000L): Flow<PollingUiState> = flow {
while (currentCoroutineContext().isActive) {
val state = try {
val response = api.getRandomJoke()
PollingUiState.Success(response.value)
} catch (error: IOException) {
PollingUiState.Error("네트워크 연결을 확인해 주세요.")
} catch (error: HttpException) {
PollingUiState.Error("서버 오류: ${error.code()}")
}
emit(state)
delay(intervalMillis)
}
}
}
CancellationException까지 일반 예외로 잡아버리면 화면이 종료되어도 coroutine 취소가 제대로 전달되지 않을 수 있습니다. 따라서 네트워크 오류처럼 실제로 처리할 예외만 구체적으로 잡는 것이 안전합니다.
UI에서 Flow를 수집할 때는 repeatOnLifecycle()을 사용하면 화면이 STARTED 상태일 때 수집을 시작하고, 화면이 중지되면 자동으로 취소할 수 있습니다.
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
어떤 방식을 선택해야 할까
| 상황 | 권장 방식 |
|---|---|
| 기존 프로젝트가 RxJava 중심 | Retrofit + RxJava polling |
| 새로운 Kotlin 프로젝트 | Retrofit suspend + Coroutines/Flow |
| 화면이 보이는 동안 몇 초 간격 갱신 | RxJava interval 또는 Flow + delay |
| 앱이 종료되어도 유지되는 15분 이상 주기 작업 | WorkManager |
| 서버가 실시간 연결을 지원 | WebSocket 또는 서버 푸시 우선 검토 |
Polling은 구현하기 쉽지만 요청 횟수만큼 서버와 배터리에 부담을 줍니다. 변경이 자주 발생하고 즉시 반영되어야 한다면 WebSocket이나 서버 푸시가 더 적합할 수 있습니다.
확인해야 할 항목
구현 후에는 다음을 반드시 확인합니다.
| 확인 항목 | 기대 결과 |
|---|---|
| 화면 진입 직후 | 첫 요청이 즉시 실행됨 |
| 10초 대기 | 다음 요청이 실행됨 |
| 네트워크 연결 해제 | 오류를 표시하지만 앱은 종료되지 않음 |
| 네트워크 다시 연결 | 다음 주기에 정상 결과를 받음 |
| 다른 화면으로 이동 | 네트워크 요청이 중지됨 |
| 화면을 여러 번 열기 | polling 스트림이 하나만 실행됨 |
| 서버 응답을 지연 | 이전 요청과 새 요청이 겹치지 않음 |
마무리
Retrofit2와 RxJava로 polling을 구현할 때 중요한 것은 단순 반복 호출이 아니라 요청 중복, 오류 복구, 화면 수명주기와 Disposable 정리입니다.
기존 RxJava 프로젝트에서는 Observable.interval()과 switchMapSingle()을 이용하면 최신 요청만 유지하면서 비교적 간단하게 구현할 수 있습니다. 새로운 Kotlin 프로젝트라면 Retrofit suspend 함수와 Flow를 사용하면 구조화된 동시성과 취소 처리를 자연스럽게 적용할 수 있습니다.
댓글
댓글 쓰기