9.3 KiB
9.3 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mobile-background-sync | Background Sync — iOS / Android 비교 | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
Background Sync
App 가 background 일 때 sync. iOS = BGTaskScheduler (제약 강함). Android = WorkManager (더 유연). Battery + 데이터 절약 OS 가 throttle.
📖 핵심 개념
- iOS: OS 가 사용 패턴 학습 → 자유 결정.
- Android: WorkManager + 제약 (battery, network).
- Doze (Android) / Low Power (iOS): 추가 throttle.
- Push: 가장 reliable trigger.
💻 코드 패턴
iOS — BGTaskScheduler (modern)
import BackgroundTasks
// Info.plist
// BGTaskSchedulerPermittedIdentifiers: ["com.acme.refresh"]
// UIBackgroundModes: [fetch, processing]
// Register (App init)
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.acme.refresh", using: nil) { task in
handleRefresh(task as! BGAppRefreshTask)
}
func handleRefresh(_ task: BGAppRefreshTask) {
scheduleNextRefresh()
let op = SyncOperation()
task.expirationHandler = {
op.cancel()
}
op.completionBlock = {
task.setTaskCompleted(success: !op.isCancelled)
}
OperationQueue().addOperation(op)
}
func scheduleNextRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.acme.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15분 후+
try? BGTaskScheduler.shared.submit(request)
}
→ OS 가 사용자 패턴 학습 — "보통 9시 사용" 시간 가까이 트리거.
iOS — Long task (BGProcessingTask)
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.acme.cleanup", using: nil) { task in
handleCleanup(task as! BGProcessingTask)
}
let request = BGProcessingTaskRequest(identifier: "com.acme.cleanup")
request.requiresNetworkConnectivity = false
request.requiresExternalPower = true // 충전 중 만
try? BGTaskScheduler.shared.submit(request)
→ 1-30 min. 충전 중 / 야간.
iOS — silent push (background)
// Server
{
aps: {
'content-available': 1
},
syncKey: '...'
}
// AppDelegate
func application(_ app: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completion: @escaping (UIBackgroundFetchResult) -> Void) {
Task {
await syncData(key: userInfo["syncKey"] as? String ?? "")
completion(.newData)
}
}
→ Push 가 trigger. Server-driven.
⚠️ iOS 가 silent push throttle (1-2 / hour). Reliable 가정 X.
Android — WorkManager
implementation("androidx.work:work-runtime-ktx:2.9.0")
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
return try {
val data = api.fetchUpdates()
db.update(data)
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry()
else Result.failure()
}
}
}
// Schedule
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(ctx).enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, request)
→ 15 min minimum. OS 가 정확 시점 결정.
Android — Expedited (즉시 + 짧은)
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(ctx).enqueue(request)
→ Foreground priority. 10 min limit.
Android — Periodic vs One-time
Periodic: 15 min minimum. Repeats.
One-time: 한 번. (즉시 또는 delay)
Android — FCM data message
// Server
{
data: { syncKey: '...' },
android: { priority: 'high' }
}
class MyFcmService : FirebaseMessagingService() {
override fun onMessageReceived(msg: RemoteMessage) {
val key = msg.data["syncKey"] ?: return
// Schedule WorkManager (즉시)
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf("key" to key))
.build()
WorkManager.getInstance(this).enqueue(request)
}
}
→ Push trigger + WorkManager 처리.
Common 패턴 — 동기화 strategy
1. Pull (period):
- WorkManager / BGTask
- 매 15-60 min
- Battery / data 비싸지만 simple
2. Push-driven:
- Server send notification
- App 가 fetch
- Reliable + efficient
3. WebSocket / SSE (foreground 만):
- Real-time
- Background = X (suspend)
4. CDC / sync:
- Cursor / version
- Delta only
→ Push + delta sync = best.
Delta sync
// Server
GET /sync?since=<lastSyncTimestamp>
→ { items: [...changed], deleted: [...ids], cursor: 'new-cursor' }
// Client
const response = await api.sync({ since: lastSync });
db.applyDelta(response.items, response.deleted);
lastSync = response.cursor;
→ 적은 bandwidth + battery.
iOS Doze / Low Power
import UIKit
if ProcessInfo.processInfo.isLowPowerModeEnabled {
// Reduce sync frequency
}
NotificationCenter.default.addObserver(forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: .main) { _ in
// Re-evaluate
}
Android Doze
// Idle 상태 — WorkManager 가 throttle (15분 이하 안 됨).
// FCM high-priority 가 wake up 가능.
if (powerManager.isIgnoringBatteryOptimizations(packageName)) {
// 사용자가 unrestricted 허용
}
Background 권한 (사용자 friendly)
Android 12+:
- Background activity 차단 강
- Foreground service type 명시 (위 [[Android_Foreground_Service_Patterns]])
iOS:
- Background mode 일부만
- 권한 자동 X — Apple 가 사용 패턴 결정
Sync conflict
// Server 와 client 가 둘 다 변경
- Last-write-wins (간단)
- Merge (CRDT)
- Conflict UI (사용자 해결)
- Three-way merge (base + client + server)
Test (Android WorkManager)
@Test
fun testSyncWorker() = runTest {
val context = ApplicationProvider.getApplicationContext<Context>()
val worker = TestListenableWorkerBuilder<SyncWorker>(context).build()
val result = worker.startWork().get()
assertEquals(Result.success(), result)
}
Test (iOS BGTaskScheduler)
디버깅:
- Xcode → Debug → Simulate Background Fetch
- Physical device 권장 (정확한 throttle)
Battery / data 모니터링
val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val isCharging = batteryManager.isCharging
val isMetered = ConnectivityManager.isActiveNetworkMetered
if (batteryLevel < 20 && !isCharging) skipSync()
if (isMetered) lightSyncOnly()
iOS NetworkPathMonitor
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.usesInterfaceType(.cellular) {
// Cellular — 작게
} else if path.usesInterfaceType(.wifi) {
// Wifi OK
}
}
monitor.start(queue: .global())
Cross-platform abstraction (RN / Flutter)
// react-native-background-fetch
import BackgroundFetch from 'react-native-background-fetch';
BackgroundFetch.configure({
minimumFetchInterval: 15,
enableHeadless: true,
}, async (taskId) => {
await syncData();
BackgroundFetch.finish(taskId);
});
Sync UI feedback
사용자에 sync 결과 명시:
- "Last synced 5 min ago"
- "Sync failed — tap to retry"
- "Pending changes: 3"
→ Trust + control.
Recurring vs one-time
Daily report: PeriodicWork / BGAppRefresh
Specific event: OneTimeWork
On data change: Push trigger
On wifi: Constraint required
Best practices
1. Minimize battery / data.
2. Push 가 reliable, periodic 는 best-effort.
3. Sync UI 보임 (last synced time).
4. Conflict resolution 명시.
5. Failure 알람 (사용자에).
6. Cellular 시 작게.
7. Test on real device.
🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| 일반 sync (15 min+) | iOS BGTask / Android WorkManager |
| Real-time | Push + sync trigger |
| 큰 작업 | iOS BGProcessingTask / Android Foreground Service |
| 배터리 절약 | Constraint (charging, wifi) |
| Reliable | Push primary |
| Cross-platform | RN background-fetch / capacitor |
❌ 안티패턴
- iOS background guarantee 가정: Apple 가 결정. Best-effort.
- Periodic 너무 자주 (1 min): throttle.
- 모든 사용자 push: opt-out 무.
- Sync 매번 모든 데이터: delta 만.
- Conflict 무시: data loss.
- Battery / data 무관심: 사용자 uninstall.
- Foreground service 없는 long task: kill.
🤖 LLM 활용 힌트
- Push trigger + WorkManager / BGTask 처리.
- Delta sync + cursor.
- Constraint (battery, wifi).
- iOS = best-effort. Android = 더 reliable.