- [Part 1] 방치된 뽀모도로 앱의 환골탈태 — 왜 'Lo-Fi 작업실'과 'Focus-to-Earn(주간 복권)'인가?
- [Part 2] 수익화와 공정성의 줄타기 — AdMob 보상형 광고와 해커·매크로를 막는 4중 방어 아키텍처
- [Part 3] "합성음은 가짜 같다" — Google 공식 현장 녹음 음원 도입과 다중 채널 백색소음 믹서 엔진 제작기
- [Part 4] iOS 26 JIT 메모리 보호 크래시 돌파와 Apple Watch Series 10 원형 UI & 아이콘 제작기
- [Part 5] 화면이 꺼져도, 앱을 강제 종료해도 멈추지 않는 타이머 — Android Doze·iOS 무음 루프와 워치 양방향 실시간 동기화 정복기 (현재 글)
타이머 앱의 생명은 어떤 극한 상황에서도 시간이 정확히 흐르고 제때 알람이 울리는 신뢰성입니다. 본 최종편(5편)에서는 앱을 위로 쓸어 올려 강제 종료(Swipe Kill)해도 오차 0초로 복원되는 절대 목표 시각(
targetEndEpochMs) 영속화 설계, 잠금화면 진입 시 OS가 프로세스를 재우는 것을 막는 iOS 인메모리 무음 PCM WAV 루프 & Android PARTIAL_WAKE_LOCK, 그리고 Apple Watch(WCSession)와 Galaxy Watch(Wearable Data Layer)의 양방향 실시간 동기화 및 레이스 컨디션(Ping-Pong) 방지 알고리즘까지 4대 OS 통합 아키텍처의 모든 것을 공개합니다.
1. 왜 초보 개발자의 타이머는 화면을 끄거나 앱을 끄면 멈출까?
많은 타이머 예제 코드들이 Timer.periodic(Duration(seconds: 1), (_) => remainingSeconds--) 방식으로 매초 변수를 1씩 깎습니다. 하지만 이 방식은 실전 모바일 환경에서 두 가지 치명적인 결함을 드러냅니다.
- 백그라운드 스레드 동결: 사용자가 홈 버튼을 누르거나 화면을 잠그면, iOS와 Android 커널은 배터리 절약을 위해 수 초~수 분 내에 Dart VM의 이벤트 루프를 일시 정지(Suspend)시킵니다.
- 앱 강제 종료(Swipe Kill) 시 상태 증발: 사용자가 실수로 최근 앱 목록에서 앱을 쓸어 올려 종료하면 메모리에 있던
remainingSeconds가 날아가 버려, 다시 앱을 켜면25:00으로 초기화됩니다.
해결책: '남은 초'가 아니라 '목표 종료 절대 시각(targetEndEpochMs)'을 디스크에 박아라!
TimerService(lib/service/timer_service.dart)는 타이머가 시작되는 즉시 현재 시각에 남은 시간을 더한 절대 목표 Epoch 밀리초(_targetEndEpochMs)를 계산하여 SharedPreferences에 영속화합니다.
// lib/service/timer_service.dart — 절대 시각(targetEndEpochMs) 영속화 및 강제 종료 복원
Future<void> _restorePersistedTimerState() async {
final prefs = await SharedPreferences.getInstance();
final savedRunning = prefs.getBool('v2_timer_is_running') ?? false;
final savedTargetEndMs = prefs.getInt('v2_timer_target_end_ms') ?? 0;
if (savedRunning && savedTargetEndMs > 0) {
final nowMs = DateTime.now().millisecondsSinceEpoch;
final diffMs = savedTargetEndMs - nowMs;
if (diffMs > 0) {
// 케이스 A: 앱이 꺼져 있는 동안에도 목표 시각 전이라면 -> 정확한 남은 초로 즉시 이어 달리기!
_remainingSeconds = (diffMs / 1000).ceil();
_targetEndEpochMs = savedTargetEndMs;
_isRunning = true;
_startInternalTicker();
} else {
// 케이스 B: 앱이 꺼져 있는 사이에 이미 목표 종료 시각을 통과했다면 -> 즉시 완료 및 티켓 보상 지급!
_remainingSeconds = 0;
_isRunning = false;
_handleSessionCompletion(completedWhileBackgrounded: true);
}
}
}
이 아키텍처 덕분에 사용자가 25분 타이머를 켜고 앱을 완전히 강제 종료(Kill)한 뒤 10분 뒤에 다시 앱을 실행해도, 정확히 15:00부터 1초의 오차도 없이 카운트다운이 이어집니다.
2. 잠금화면 백그라운드 생존 전략: iOS 무음 WAV 루프 vs Android WakeLock
앱을 다시 켰을 때 시간이 복원되는 것만으로는 부족합니다. 화면이 꺼진 잠금화면 상태에서 정확히 25분이 되는 순간 알람과 진동이 울리고, 손목 위의 워치에도 실시간으로 남은 시간이 전송되어야 합니다. 이를 위해 각 모바일 OS의 네이티브 계층에 특화된 생존 엔진을 구현했습니다.
| 비교 항목 | iOS (AppDelegate.swift) |
Android (MainActivity.kt) |
|---|---|---|
| 백그라운드 생존 기법 | 인메모리 8kHz 무음 PCM WAV 루프 재생 (.playback + .mixWithOthers) |
PowerManager.PARTIAL_WAKE_LOCK 획득 + 네이티브 Handler.postDelayed |
| 잠금화면 정각 알람 | UNTimeIntervalNotificationTrigger 예약 + AudioServicesPlayAlertSound(1005) |
RingtoneManager(USAGE_ALARM) 사운드 출력 + VibrationEffect 파형 진동 |
| 워치에 의한 백그라운드 깨움 | 워치의 sendMessage(..., replyHandler:) 호출 시 iOS가 백그라운드 앱 즉시 깨움 |
MessageReceiverService 및 DataClient 리스너가 메인 프로세스 즉시 깨움 |
iOS: 외부 파일 없이 메모리에서 즉석 생성하는 1초 무음 WAV 루프
iOS에서는 화면을 잠그면 일반 앱 프로세스가 수 초 내에 Suspend되어 애플워치로의 실시간 틱 전송이 끊깁니다. 이를 해결하기 위해 AppDelegate.swift에서 타이머가 도는 동안에만 메모리에서 직접 생성한 8kHz 무음 WAV 데이터(볼륨 0.0)를 무한 루프로 재생하여 프로세스 활성을 완벽하게 유지했습니다.
// ios/Runner/AppDelegate.swift — 잠금화면에서도 타이머와 워치 동기화를 유지하는 무음 WAV 루프
private func updateBackgroundKeepAlive(isRunning: Bool) {
if isRunning {
guard !isKeepAliveActive else { return }
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, options: [.mixWithOthers])
try audioSession.setActive(true)
if silentKeepAlivePlayer == nil {
silentKeepAlivePlayer = try AVAudioPlayer(data: Self.makeSilentWavData())
silentKeepAlivePlayer?.numberOfLoops = -1
silentKeepAlivePlayer?.volume = 0.0
silentKeepAlivePlayer?.prepareToPlay()
}
silentKeepAlivePlayer?.play()
isKeepAliveActive = true
} catch {}
} else {
guard isKeepAliveActive else { return }
silentKeepAlivePlayer?.pause()
isKeepAliveActive = false
}
}
Android: Partial WakeLock과 네이티브 알람 러너블
안드로이드에서는 화면을 끄면 Doze 모드가 진입하며 CPU 클럭을 늦춥니다. 이를 방어하기 위해 MainActivity.kt에서 타이머 작동 중에만 최대 2시간 한도의 PARTIAL_WAKE_LOCK을 획득하고, 네이티브 Handler가 목표 시각(targetEndMs)에 정확히 USAGE_ALARM 채널 벨소리와 커스텀 파형 진동(0, 400, 200, 400, 200, 600ms)을 울리도록 이중 안전장치를 걸었습니다.
3. 4대 기기(iPhone · Apple Watch · Android · Galaxy Watch) 양방향 동기화 아키텍처
이제 시리즈의 대미를 장식하는 스마트폰 ↔ 스마트워치 양방향 실시간 동기화입니다. 사용자는 스마트폰에서 타이머를 켠 뒤 손목의 워치로 남은 시간을 확인하다가, 워치 화면의 일시정지(⏸)나 스킵(⏭) 버튼을 눌러 주머니 속 스마트폰을 원격 제어할 수 있어야 합니다.
2. 고유 UUID actionId 생성 & 1.5초 디바운스 시작 Watch->>Native: Dual-Channel 전송
• 실시간: sendMessage (replyHandler 포함)
• 영속성: updateApplicationContext / PutDataMapRequest Native->>Native: isNewWatchAction(actionId) 멱등성 검사
& WakeLock / 무음 루프 상태 즉시 갱신 Native->>Dart: MethodChannel.invokeMethod("sync_from_watch", payload) Dart->>Dart: targetEndEpochMs 및 세션 상태 동기화 완료! Note over Watch,Dart: [Case 2] 스마트폰 백그라운드 중 워치 조작 후 앱 복귀 시 Dart->>Native: onResume -> invokeMethod("checkPendingWatchSync") Native-->>Dart: 대기 중이던 최신 워치 Payload 즉시 반환 (오차 0초)
핵심 엔지니어링 포인트 1: 왜 '실시간 채널'과 '상태 저장 채널'을 동시에 쓰는가?
애플의 WatchConnectivity와 구글의 Wearable Data Layer API 모두 두 가지 통신 채널을 제공합니다.
- 실시간 메시지 채널 (
sendMessage/MessageClient): 양쪽 화면이 켜져 있을 때 0.1초 만에 패킷을 쏘지만, 상대 기기 앱이 꺼져 있으면 패킷이 유실될 수 있습니다. (단, iOS에서는 워치가replyHandler를 포함해sendMessage를 호출하면 백그라운드의 아이폰 앱을 강제로 깨워줍니다!) - 상태 컨텍스트 채널 (
updateApplicationContext/DataClient): OS가 최신 상태 스냅샷을 버퍼에 보관해 두었다가, 상대 기기 앱이 나중에 켜지는 순간 즉시 전달해 줍니다.
따라서 우리는 1초마다 흐르는 단순 틱(isTickOnly: true)은 실시간 메시지 채널로만 가볍게 쏘고, 시작·일시정지·리셋·스킵 같은 상태 변경 이벤트는 두 채널 모두에 동시에 기록하는 하이브리드 프로토콜을 설계했습니다.
핵심 엔지니어링 포인트 2: 버튼이 혼자 춤추는 'Ping-Pong 레이스 컨디션' 해결
양방향 동기화를 처음 구현했을 때 기괴한 버그가 발생했습니다. 워치에서 일시정지(⏸)를 눌렀더니, 0.2초 뒤에 워치 버튼이 다시 재생 중(▶)으로 바뀌었다가 또 멈추는 '핑퐁(Ping-Pong) 현상'이 일어난 것입니다.
원인은 명확했습니다. 워치에서 일시정지를 누른 바로 그 찰나에, 스마트폰이 0.1초 전에 보냈던 "나 아직 달리는 중(isRunning: true)이야!"라는 지연 패킷이 워치에 뒤늦게 도착해 워치의 일시정지 상태를 덮어써 버린 것이었습니다.
이를 해결하기 위해 ① 고유 UUID actionId 기반 멱등성(Idempotency) 검증과 ② 로컬 액션 직후 1.5초 디바운스 가드(Debounce Guard)를 양쪽 워치(Swift & Kotlin)와 스마트폰에 모두 적용했습니다.
// android/wearrico/.../MainActivity.kt — Galaxy Watch (Wear OS) 양방향 동기화 & 레이스 컨디션 방어
private fun applyPayload(payload: Map<String, Any>) {
val completed = (payload["sessionCompleted"] as? Boolean) ?: false
if (completed) {
WatchDataHolder.playCompletionHaptics(this)
}
if (!payload.containsKey("sessionState") && !payload.containsKey("isRunning")) {
return
}
// 🛡️ [1.5초 Debounce Guard] 워치에서 방금 버튼을 누른 직후(1.5초 이내)에
// 네트워크 지연으로 뒤늦게 날아온 스마트폰의 구버전 패킷은 무시하여 Ping-Pong 방지!
if (!completed && System.currentTimeMillis() - lastLocalActionMs < 1500L) {
return
}
// ... 최신 상태 및 targetEndEpochMs 반영
}
private fun pushWatchStateToPhone() {
val nowMs = System.currentTimeMillis()
lastLocalActionMs = nowMs
// 🛡️ [Idempotency] 매 워치 조작마다 고유 UUID actionId를 부여하여 중복 실행 원천 차단
lastActionId = UUID.randomUUID().toString()
saveLocalWatchState()
val payloadObj = JSONObject().apply {
put("command", "sync_from_watch")
put("actionId", lastActionId)
put("sessionState", sessionState.value)
put("isRunning", isRunning.value)
put("remainingSeconds", remainingSeconds.intValue)
put("totalSeconds", totalSeconds.intValue)
put("targetEndEpochMs", targetEndEpochMs.longValue)
put("updatedAtMs", nowMs)
}
val bytes = payloadObj.toString().toByteArray(Charsets.UTF_8)
// 1. 실시간 MessageClient 전송 + 2. DataClient 영속 스냅샷 동시 기록
Wearable.getNodeClient(this).connectedNodes.addOnSuccessListener { nodes ->
nodes.forEach { node ->
Wearable.getMessageClient(this).sendMessage(node.id, "/pomodoro-command", bytes)
}
}
val putReq = PutDataMapRequest.create("/pomodoro-watch-sync").apply {
dataMap.putString("payload_json", payloadObj.toString())
dataMap.putLong("timestamp", nowMs)
}.asPutDataRequest().setUrgent()
Wearable.getDataClient(this).putDataItem(putReq)
}
4. 에필로그: 4개의 OS, 하나의 완벽한 몰입 경험
1년 전 하얀 화면에 숫자 하나만 떠 있던 방치된 사이드 프로젝트가, 이번 v2.0 풀 리빌드를 통해 다음과 같은 크로스플랫폼 프로덕트로 거듭났습니다.
- Lo-Fi Focus Studio UI: 새벽 작업실 감성의 다크 차콜 & 앰버/에메랄드 4탭 공간 아키텍처
- Focus-to-Earn & 4-Layer Security: 하루 3장 집중 복권 시스템과 AdMob 옵트인 보상형 광고, 그리고 Supabase PostgreSQL
SECURITY DEFINERRPC 기반의 4중 어뷰징 방어선 - Studio Ambient Audio Engine: Google 공식 현장 녹음 OGG 음원(3.1MB)과 24시간 무중단(Gapless) 5채널 병렬 오디오 믹서
- 4-OS Native Deep Integration: iPhone(iOS 26.3.1), Apple Watch Series 10(watchOS 26.2), Android 스마트폰, Galaxy Watch4 Classic(Wear OS) 실기기 검증 및 0.1초 양방향 실시간 동기화
단순히 "돌아가는 앱"을 만드는 것과, "사용자가 화면을 끄든, 앱을 강제 종료하든, 손목 위의 워치로 조작하든 단 1초의 오차도 없이 신뢰할 수 있는 프로덕트"를 만드는 것은 완전히 다른 차원의 엔지니어링이었습니다. 5부작으로 정리한 이 치열한 삽질과 아키텍처 기록이 크로스플랫폼 앱과 웨어러블 연동을 고민하는 동료 개발자분들께 든든한 이정표가 되기를 바랍니다. 감사합니다!