모바일 네트워크 복원력: 재시도·오프라인·낙관적 UI 패턴

모바일 개발

네트워크 복원력오프라인 모드재시도 전략Optimistic UI모바일 UX

이 글은 누구를 위한 것인가

  • 지하철, 엘리베이터 등 불안정한 환경에서 앱이 멈추는 문제를 겪는 팀
  • "요청 실패" 에러만 보여주고 재시도 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로 지연감을 제거하면 — 사용자는 네트워크 문제를 인식하지 못한다.

가장 먼저 구현할 것은 재시도다. 재시도 없이 "오류" 메시지만 보여주는 앱은 사용자를 잃는다.