이 글은 누구를 위한 것인가
- 지하철, 엘리베이터 등 불안정한 환경에서 앱이 멈추는 문제를 겪는 팀
- "요청 실패" 에러만 보여주고 재시도 UX가 없는 앱 개발자
- 오프라인에서도 기본 기능이 동작하는 앱을 만들고 싶은 팀
들어가며
모바일 사용자의 인터넷 연결은 불안정하다. 지하철 터널, 엘리베이터, 지하주차장 — 이런 환경에서 앱이 "오류가 발생했습니다"만 보여준다면 사용자는 이탈한다.
네트워크 복원력(Resilience)은 "실패를 어떻게 처리하는가"가 아니라 "실패가 사용자에게 느껴지지 않도록 하는가"다. 재시도 간격 설계의 출발점으로는 HTTP 의미론 명세(RFC 9110)의 멱등성 정의를 확인해 두는 편이 좋다.
이 글은 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로 지연감을 제거하면 — 사용자는 네트워크 문제를 인식하지 못한다.
가장 먼저 구현할 것은 재시도다. 재시도 없이 "오류" 메시지만 보여주는 앱은 사용자를 잃는다.