이 글은 누구를 위한 것인가
- 지하철, 엘리베이터 등 불안정한 환경에서 앱이 멈추는 문제를 겪는 팀
- "요청 실패" 에러만 보여주고 재시도 UX가 없는 앱 개발자
- 오프라인에서도 기본 기능이 동작하는 앱을 만들고 싶은 팀
들어가며
모바일 사용자의 인터넷 연결은 불안정하다. 지하철 터널, 엘리베이터, 지하주차장 — 이런 환경에서 앱이 "오류가 발생했습니다"만 보여준다면 사용자는 이탈한다.
네트워크 복원력(Resilience)은 "실패를 어떻게 처리하는가"가 아니라 "실패가 사용자에게 느껴지지 않도록 하는가"다.
이 글은 bluefoxdev.kr의 모바일 오프라인 전략 가이드 를 참고하고, 네트워크 복원력 실전 구현 관점에서 확장하여 작성했습니다.
1. 네트워크 오류 유형과 대응
[오류 유형별 대응 전략]
일시적 오류 (재시도로 해결 가능):
└── 타임아웃, 503, 429 (Rate Limit), 네트워크 끊김
→ 지수 백오프 재시도
영구적 오류 (재시도 불필요):
└── 400 (잘못된 요청), 401 (인증), 404 (없는 리소스)
→ 즉시 에러 표시, 재시도 없음
서버 오류 (불명확):
└── 500, 502, 504
→ 제한적 재시도 (2-3회)
[재시도 불가 작업]
❌ 결제 요청 (중복 결제 위험)
❌ 파일 업로드 완료 후 확인
✅ 읽기 요청 (GET)
✅ 멱등성 있는 쓰기 (PUT, 고유 ID 포함)
2. 지수 백오프 구현 (iOS)
import Foundation
actor RetryExecutor {
struct RetryConfig {
let maxAttempts: Int
let initialDelay: TimeInterval
let multiplier: Double
let maxDelay: TimeInterval
let jitter: Bool
static let `default` = RetryConfig(
maxAttempts: 3,
initialDelay: 1.0,
multiplier: 2.0,
maxDelay: 30.0,
jitter: true
)
}
func execute<T>(
config: RetryConfig = .default,
operation: () async throws -> T
) async throws -> T {
var lastError: Error?
for attempt in 0..<config.maxAttempts {
do {
return try await operation()
} catch {
lastError = error
// 재시도 불가 오류는 즉시 던짐
if let urlError = error as? URLError,
[.userAuthenticationRequired, .badURL].contains(urlError.code) {
throw error
}
if let httpError = error as? HTTPError,
(400..<500).contains(httpError.statusCode),
httpError.statusCode != 429 {
throw error // 클라이언트 오류는 재시도 없음
}
// 마지막 시도면 그냥 던짐
if attempt == config.maxAttempts - 1 {
break
}
// 지수 백오프 + 지터
var delay = min(
config.initialDelay * pow(config.multiplier, Double(attempt)),
config.maxDelay
)
if config.jitter {
delay *= Double.random(in: 0.5...1.5)
}
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
throw lastError!
}
}
// 사용 예시
let retryExecutor = RetryExecutor()
func fetchUserProfile(userId: String) async throws -> UserProfile {
return try await retryExecutor.execute(
config: .init(maxAttempts: 3, initialDelay: 1.0, multiplier: 2.0, maxDelay: 10.0, jitter: true)
) {
try await apiClient.getUser(id: userId)
}
}
3. 오프라인 모드 설계 (Android)
// WorkManager로 오프라인 작업 큐 관리
import androidx.work.*
import com.google.gson.Gson
class OfflineActionWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val actionJson = inputData.getString("action") ?: return Result.failure()
val action = Gson().fromJson(actionJson, OfflineAction::class.java)
return try {
when (action.type) {
"LIKE_POST" -> api.likePost(action.targetId)
"ADD_COMMENT" -> api.addComment(action.targetId, action.payload)
"UPDATE_PROFILE" -> api.updateProfile(action.payload)
else -> return Result.failure()
}
Result.success()
} catch (e: HttpException) {
if (e.code() in 400..499) Result.failure() // 클라이언트 오류
else Result.retry() // 서버 오류 → 재시도
} catch (e: IOException) {
Result.retry() // 네트워크 오류 → 재시도
}
}
}
data class OfflineAction(
val type: String,
val targetId: String,
val payload: String,
val createdAt: Long = System.currentTimeMillis()
)
// 오프라인 액션 큐에 추가
fun queueOfflineAction(context: Context, action: OfflineAction) {
val inputData = Data.Builder()
.putString("action", Gson().toJson(action))
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequestBuilder<OfflineActionWorker>()
.setInputData(inputData)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
java.util.concurrent.TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context).enqueue(workRequest)
}
// 네트워크 상태 감지
class NetworkMonitor(context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}
override fun onLost(network: Network) {
trySend(false)
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
4. 낙관적 UI (Optimistic UI)
// iOS — 낙관적 UI: 서버 응답 전에 UI 먼저 업데이트
@Observable
class PostViewModel {
var posts: [Post] = []
var isLiking: Set<String> = []
func toggleLike(postId: String) async {
guard !isLiking.contains(postId) else { return }
// 1. 즉시 UI 업데이트 (낙관적)
isLiking.insert(postId)
if let index = posts.firstIndex(where: { $0.id == postId }) {
let wasLiked = posts[index].isLiked
posts[index].isLiked = !wasLiked
posts[index].likeCount += wasLiked ? -1 : 1
do {
// 2. 백그라운드에서 API 호출
let result = try await api.toggleLike(postId: postId)
// 3. 서버 실제 값으로 동기화
posts[index].likeCount = result.likeCount
} catch {
// 4. 실패 시 롤백
posts[index].isLiked = wasLiked
posts[index].likeCount += wasLiked ? 1 : -1
// 사용자에게 알림
showError("좋아요 처리 중 오류가 발생했습니다.")
}
isLiking.remove(postId)
}
}
}
마무리
네트워크 복원력은 "오류 처리"가 아니라 "사용자 경험 연속성"의 문제다. 지수 백오프로 일시적 오류를 자동으로 극복하고, 오프라인 큐로 연결이 끊겨도 액션을 보존하며, 낙관적 UI로 지연감을 제거하면 — 사용자는 네트워크 문제를 인식하지 못한다.
가장 먼저 구현할 것은 재시도다. 재시도 없이 "오류" 메시지만 보여주는 앱은 사용자를 잃는다.