
모바일 환경에서는 네트워크 연결이 끊기는 상황이 매우 빈번하게 발생합니다. 만약 이에 대한 적절한 대응이 없다면, 사용자는 네트워크를 연결한 뒤 화면을 다시 열거나 앱을 재시작하는 것 외에는 앱을 계속 사용할 수 없습니다. 이러한 경험은 UX 측면에서 매우 불친절하며, 장기적으로 사용자 이탈로 이어질 수 있습니다.
목표
API 호출 시 네트워크가 연결되어 있지 않으면 ConnectException이 발생합니다. 이를 방지하기 위해 API 호출 전 네트워크 연결 상태를 확인하여 네트워크가 연결이 되어 있을 때만 API를 호출하며, 실패한 API 요청을 적절히 재시도하는 방법을 소개합니다.

Connectivity
Connectivity는 기기의 네트워크의 다양한 연결 상태를 추적할 수 있는 API로, 단순한 네트워크 연결 상태나, 와이파이, 셀룰러 등 어떤 종류의 네트워크가 연결되었는지 등 다양한 정보를 제공합니다. 이를 구현하기에 앞서, 네트워크 연결 상태를 관리하는 객체를 위한 인터페이스를 선언합니다.
interface ConnectivityObserver {
fun subscribe(): Flow<ConnectivityState>
fun value(): ConnectivityState
}
enum class ConnectivityState {
Idle,
Lost,
Available;
companion object {
fun from(hasInternet: Boolean, isValidated: Boolean): ConnectivityState {
return if (hasInternet && isValidated) Available else Lost
}
}
}
- subscribe() : 네트워크 상태 변화를 지속적으로 관찰할 수 있는 Flow 반환
- value() : 호출 시점의 현재 네트워크 상태 확인
- Status : 초기 상태(Idle), 연결 끊김(Lost), 연결 가능(Available)을 표현
Connectivity는 ConnectivityManager를 통해 가져올 수 있습니다.
class NetworkConnectivityObserver(
context: Context,
) : ConnectivityObserver {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
ConnctivityObserver#value
class NetworkConnectivityObserver(
private val scope: CoroutineScope,
context: Context,
) : ConnectivityObserver {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
override fun value(): ConnectivityState {
val network = connectivityManager.activeNetwork ?: return ConnectivityState.Lost
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return ConnectivityState.Lost
val hasInternet =
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val isValidated =
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
return ConnectivityState.from(hasInternet, isValidated)
}
}
activeNetwork
현재 사용 가능한 기본 네트워크 객체를 제공합니다. 네트워크가 끊기거나 차단된 경우 null을 반환하므로, 이때는 즉시 Lost 상태를 반환합니다.
getNetworkCapabilities()
activeNetwork의 네트워크의 기능(예: Wi-Fi, 셀룰러, 네트워크 가능 여부 등)에 대한 정보를 가진 객체인 NetworkCapabilities를 가져오는 메서드로, hasCapability 메서드를 통해 현재 네트워크의 기능을 확인할 수 있습니다.
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
- 네트워크가 인터넷에 접속할 수 있음을 나타냅니다.
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
- 네트워크의 연결이 성공적으로 검증되었음을 나타냅니다.
최종적으로 두 조건이 모두 충족되면 Available, 아니면 Lost를 반환합니다. 즉, value() 메서드는 “지금 이 순간, 네트워크가 실제로 사용 가능한가?”를 판단하는 역할을 합니다.
ConnctivityObserve#subscribe
fun subscribe(onStateChanged: (ConnectivityState) -> Unit) {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
onStateChanged(ConnectivityState.Available)
}
override fun onLost(network: Network) {
onStateChanged(ConnectivityState.Lost)
}
}
connectivityManager.registerDefaultNetworkCallback(callback)
}
Connectivity는 네트워크 상태를 콜백으로 반환하는 NetworkCallback를 제공합니다. NetworkCallback은 네트워크 상태에 기본적으로 4가지로 네트워크 상태를 표현합니다.
- onAvailable : 네트워크가 연결되었고 사용 가능한 경우 호출
- onLosing : 현재 사용 중인 네트워크가 곧 끊길 예정임을 알림
- onLost : 네트워크 연결이 실제로 끊어졌을 때 호출
- onUnavailable : 연결 시도 자체가 실패 (예: Wi-Fi 연결을 시도했는데 비밀번호가 틀려서 실패)
여기선 네트워크가 정확히 연결과 미연결 상태를 표현하는 onAvailable과 onLost 콜백을 사용해 메서드의 결과를 콜백으로 반환하고 있습니다.
단순히 콜백만 사용하는 방식은 네트워크 상태를 확인할 때마다 메서드를 호출해야 하고 상태 변화를 지속적으로 관찰하기 어렵습니다. 이러한 문제를 해결하기 위해 콜백 이벤트를 코루틴 Flow로 변환할 수 있는 callbackFlow를 사용합니다.
callbackFlow는 네트워크 상태 변화를 연속적인 이벤트처럼 다룰 수 있으며, awaitClose 메서드를 사용해 콜백이 자동으로 해제되도록 등록하면 메모리 누수를 방지할 수 있습니다. 또한 ViewModel이나 UI에서 Flow를 구독하면 상태가 바뀔 때마다 화면에 자동으로 반영할 수 있습니다.
override fun subscribe(): StateFlow<ConnectivityState> =
callbackFlow {
val callback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(ConnectivityState.Available)
}
override fun onLost(network: Network) {
trySend(ConnectivityState.Lost)
}
}
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.stateIn(
scope,
WhileSubscribed(5000),
ConnectivityState.Idle,
)
callbackFlow에 대한 자세한 내용은 다소 방대하기 때문에 이 글에선 자세히 다루지 않으며, "콜백을 LiveData처럼 관찰할 수 있게 만들어주는 API" 정도로 이해하면 될 것 같습니다.
callbackFlow는 이벤트를 전달할 때 내부적으로 버퍼(Channel)를 사용합니다. 기본 버퍼 크기는 64이며, 이벤트를 처리하기 위해 두 가지 전송 방법을 제공합니다.
send
public interface SendChannel<in E> {
public suspend fun send(element: E)
}
send는 suspend 함수로 버퍼가 꽉 차거나 수신자가 준비되지 않으면 일시 중단됩니다. 전달받은 이벤트를 버퍼에 순서대로 저장하기 때문에 모든 이벤트 순서를 보장할 수 있지만 수신자가 이벤트를 수신하지 않을 때도 이벤트를 저장합니다.
trySend
public interface SendChannel<in E> {
public fun trySend(element: E): ChannelResult<Unit>
}
trySend는 즉시 반환되는 함수로, 버퍼가 가득 차면 실패를 반환합니다. suspend 하지 않기 때문에 최신 상태만 전달할 때 적합하지만 이벤트가 손실될 수 있고 순서를 보장하지는 않습니다.
따라서 모든 이벤트를 순서대로 처리해야 한다면 send를, 최신 상태만 필요하고 빠른 전달이 중요한 경우에는 trySend를 사용하는 것이 적합합니다. 저는 네트워크의 이전 상태 여부와 관계없이 필요한 시점의 네트워크 상태만 전달받으면 되기 때문에 trySend를 사용했습니다.
registerDefaultNetworkCallback
connectivityManager.registerDefaultNetworkCallback(callback)
- NetworkCallback을 시스템에 등록하여 네트워크 상태 변화 감지를 시작합니다.
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
Flow가 종료될 때 실행할 정리 작업을 미리 정의하는 역할을 합니다. 메모리 누수를 방지하기 위해 Flow의 수집이 중단되거나 코루틴 스코프가 취소되면 자동으로 unregisterNetworkCallback(callback)이 호출되어 등록된 콜백을 시스템에서 해제합니다.
NetworkCallback을 등록만 하고 해제하지 않으면 앱이 종료될 때까지 시스템에서 콜백이 계속 동작하여 메모리 누수가 발생할 수 있으며 적절한 해제 없이 콜백을 계속 등록하면 RuntimeException이 발생할 수 있습니다.
stateIn
public fun <T> Flow<T>.stateIn(
scope: CoroutineScope,
started: SharingStarted,
initialValue: T
): StateFlow<T> {
val config = configureSharing(1)
val state = MutableStateFlow(initialValue)
val job = scope.launchSharing(config.context, config.upstream, state, started, initialValue)
return ReadonlyStateFlow(state, job)
}
마지막으로 Ui에서 항상 최신 상태로 구독하기 위해 형태로 변환해 줍니다. stateIn은 Flow를 StateFlow 형태로 변환해 주는 메서드입니다.
- scope : flow를 구독하기 위한 코루틴
- started : 구독자 수에 따라 플로우를 구독을 시작/중단하는 시점을 설정하기 위한 전략
- initialValue : 초기값
BaseViewModel
사용자 인터렉션에 의한 API 호출에 대한 책임은 ViewModel의 책임이기 때문에 모든 ViewModel에서 공통적으로 재사용할 수 있도록 BaseViewModel을 만듭니다. ConnectivityObserver를 인자로 받고 API 호출에 실패한 메서드를 저장하는 ConcurrentHashMap을 선언했습니다.
abstract class BaseViewModel(
private val connectivityObserver: ConnectivityObserver,
) : ViewModel() {
private val pendingActions = ConcurrentHashMap<String, suspend () -> Unit>()
}
ConcurrentHashMap
일반적인 HashMap은 멀티스레드 환경에서 안전하지 않아, 동시에 여러 API 호출이 발생하면 데이터가 덮어 쓰일 수 있습니다. 예를 들어 키워드 검색 API를 여러 번 호출할 때 쿼리가 각각 다르다면, HashMap에서는 어떤 쿼리가 최종적으로 저장될지 예측할 수 없습니다.
ConcurrentHashMap은 멀티스레드 환경에서 안전하게 데이터를 읽고 쓰도록 내부 구조가 설계되어 있는데, 내부 데이터를 여러 세그먼트로 나누고, 각 세그먼트별로 별도의 락을 사용합니다.
이를 통해 API 호출이 여러 번 발생하더라도 pendingActions에 저장된 작업들은 안전하게 관리되며 마지막으로 등록된 요청만 유지할 수 있어 네트워크 복구 시에는 정확하게 마지막 요청만 재실행해 동시성 문제를 예방합니다.
suspend fun test(isConcurrent: Boolean) {
val map = if (isConcurrent) ConcurrentHashMap<String, String>() else HashMap()
coroutineScope {
repeat(100) { i ->
launch(Dispatchers.Default) {
map["keyword_$i"] = "value_$i"
}
}
}
println("Size = ${map.size}")
}
fun main() = runBlocking {
repeat(100) {
println("ConcurrentHashMap")
test(true)
println("\nHashMap")
test(false)
}
}
실제로 코루틴 환경에서 가상의 API 호출 상황을 테스트 해본 결과 값이 유실되는 것을 확인할 수 있었습니다.

BaseViewModel#runAsync
runAsync 함수는 API를 호출하는 메서드를 실행 시점에 네트워크 상태를 확인하고 네트워크가 연결되지 않을 시 요청을 저장해 두었다 재실행할 수 있도록 설계되어 있습니다.
import kotlin.Result
abstract class BaseViewModel(
private val connectivityObserver: ConnectivityObserver,
) : ViewModel() {
private val pendingActions = ConcurrentHashMap<String, suspend () -> Unit>()
protected fun <T> runAsync(
key: String,
action: suspend () -> Result<T>,
handleSuccess: (T) -> Unit,
handleFailure: () -> Unit,
) {
val job: suspend () -> Unit = {
action()
.onSuccess {
pendingActions.remove(key)
handleSuccess(it)
}.onFailure { handleFailure(it) }
}
if (connectivityObserver.value() != ConnectivityObserver.Status.Available) {
pendingActions[key] = job
handleFailure()
return
}
viewModelScope.launch { job() }
}
}
runAsync 메서드는 ConcurrentHashMap에 작업을 저장하기 위한 Key를 인자로 받습니다. 따라서 동일한 API에 대한 중복 요청이 발생하더라도 같은 Key를 전달하면 마지막 요청만 Map에 남게 됩니다. 또한, API 호출 메서드는 Result 타입으로 전달되는 action으로 받고, 호출 성공 시와 실패 시 각각 실행할 콜백 함수를 인자로 전달할 수 있습니다.
val job: suspend () -> Unit = {
action()
.onSuccess {
pendingActions.remove(key)
handleSuccess(it)
}.onFailure { handleFailure(it) }
}
API 호출과 성공/실패 처리 등을 하나의 suspend 람다로 묶어 하나의 Job 객체로 정의하고 ConnectivityObserver의 value() 메서드를 호출하여 실행 시점의 네트워크 연결 상태를 확인합니다.
if (connectivityObserver.value() != ConnectivityObserver.Status.Available) {
pendingActions[key] = job
handleFailure(TodokTodokExceptions.UnknownHostError)
return
}
- 네트워크가 연결되어 있지 않다면, Job은 실행하지 않고 pendingActions에 저장한 뒤 즉시 반환(return)합니다.
viewModelScope.launch(recordExceptionHandler) { job() }
- 네트워크가 연결되어 있다면 정의한 Job을 viewModelScope.launch 내에서 실행하여 API 호출을 수행합니다.
BaseViewModel#observeConnectivity
네트워크 연결 상태를 지속적으로 관찰합니다. ConnectivityObserver.subscribe()로 네트워크 상태 변화를 구독하고, 네트워크가 복구되어 Available 상태가 되면 pendingActions에 저장된 모든 Job을 순차적으로 실행합니다.
이 과정에서 Map을 먼저 비워 동일 요청이 중복되지 않도록 보장하며, 사용자가 개입하지 않아도 실패한 요청을 자동으로 재시도할 수 있습니다.
abstract class BaseViewModel(
private val connectivityObserver: ConnectivityObserver,
) : ViewModel() {
private val pendingActions = ConcurrentHashMap<String, suspend () -> Unit>()
init {
observeConnectivity()
}
private fun observeConnectivity() {
viewModelScope.launch {
connectivityObserver.subscribe().collect { status ->
if (status == ConnectivityObserver.Status.Available && pendingActions.isNotEmpty()) {
val actionsToRetry = pendingActions.values.toList()
pendingActions.clear()
actionsToRetry.forEach { action ->
action()
}
}
}
}
}
}
결과

실제 프로젝트에선 프로그래스바의 상태와 API 복원을 재시도하기 위한 플래그 값을 조합해 사용하고 있습니다. 전체 코드는 여기서 확인할 수 있습니다. 이번 글에서는 모바일 환경에서 빈번하게 발생할 수 있는 네트워크 연결 문제를 안전하게 처리하기 위한 구조를 소개했습니다.
ConnectivityObserver를 통해 네트워크 상태를 실시간으로 관찰하고 ConcurrentHashMap과 runAsync 메서드를 활용해 API 요청을 안전하게 관리함으로써, 네트워크 연결이 없는 상태에서도 요청을 저장하고 연결 복구 시 자동으로 재시도할 수 있는 패턴을 구현했습니다.
이 패턴을 적용하면 사용자 개입 없이도 실패한 요청을 안정적으로 복원할 수 있으며 멀티스레드 환경에서도 데이터 일관성을 유지할 수 있습니다. 또한, Flow와 StateFlow를 활용한 실시간 상태 구독을 통해 UI를 항상 최신 상태로 업데이트할 수 있어 사용자 경험을 크게 개선할 수 있습니다.
향후에는 ConcurrentHashMap 내부 구조와 동시성 처리 메커니즘, 그리고 실제 프로젝트에서 발생할 수 있는 다양한 네트워크 에러 상황에 대한 추가 검증 및 최적화 방법을 다룰 예정입니다.
'Android' 카테고리의 다른 글
| HiltViewModel 의존성 주입 원리 (0) | 2025.11.06 |
|---|---|
| 아주 쉽게 알아보는 뷰가 그려지기까지의 여정 (0) | 2025.10.29 |
| Retrofit Internals - Retrofit In Coroutine (0) | 2025.06.20 |
| Retrofit Internals - Retrofit은 어떻게 인터페이스의 구현체를 만들까 ? (1) | 2025.06.16 |
| ViewModel의 One Time Event를 다루는 다양한 솔루션 (0) | 2025.02.07 |
