정비 기록
개발 중 실제로 마주친 문제와 원인 추적, 해결 과정을 정비 로그
형식으로 정리했습니다. 모든 것이 해결되지는 않았고, 구조적
한계는 한계라고 정직하게 남겨두었습니다.
- FINDING
문제 인식
iPhone과 Watch가 미러링 중에도 각자 LocationService와 RunningCenter를 독립적으로 돌리고 있었다. Watch에서 시작해도 iPhone이 GPS 락을 새로 잡는 동안 딜레이가 발생.
- ACTION
방향 전환
이미 쓰이던 startOrigin을 위치 추적 여부의 기준으로 확장. 주도 기기만 GPS를 켜고 계산 결과를 상대에게 전송, 미러링 기기는 수신 후 표시만.
- DISCOVERY
양방향 구현
sendFlightData()가 iOS 쪽에만 있어서 iPhone → Watch 방향만 가능했다. Watch 주도 미러링에서 Watch가 계산한 데이터를 iPhone에 보낼 경로 자체가 없던 숨은 허점이었다.
- RESULT
보완
elapsedTime까지 3초 throttle에 묶으면 오차가 누적되니, FlightData와 분리해 1초마다 별도 메시지(sendElapsedTime())로 동기화. iPhone 단독 / Watch 단독 / 양방향 미러링 4가지 시나리오가 startOrigin 하나로 정리됐다.
- FINDING
발견
스택 트레이스 추적 결과 session(_:activationDidCompleteWith:error:)에서 크래시 발생.
- ROOT CAUSE
원인
클래스는 nonisolated로 선언했지만, extension 안 delegate 메서드 자체에 명시 안 하면 Xcode 26의 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 기본값 때문에 다시 @MainActor로 추론된다.
- FINDING
재발
1차 수정 후에도 didReceiveUserInfo에서 동일 패턴의 크래시가 실기기에서만 재현됐다.
- ACTION
전수 점검
+iOS, +watchOS extension의 WCSessionDelegate 콜백 전체를 점검해 빠진 nonisolated를 모두 추가했다.
- APPROACH
1차 구현
Timer.publish().connect()로 시작 시점을 직접 제어. 일시정지 구현을 염두에 두고 autoconnect() 대신 선택했다.
- FINDING
재시작 실패
정지 후 재시작 시 타이머가 안 돎. connect()는 1회성 연결이라 한 번 cancel()한 Publisher는 재연결이 불가능하다.
- ACTION
autoconnect 전환
start()가 호출될 때마다 autoconnect() + sink로 매번 새로 구독. Set<AnyCancellable>에 저장하고 stop() 시 removeAll()로 정리했다.
- RESULT
중복 구독 방지
start()를 연달아 누르면 구독이 누적돼 초가 2, 3씩 뛰는 문제도 발견. start() 진입 시점에 기존 구독을 먼저 정리하도록 보완해 최종 해결했다.
- FINDING
발견
Instrument의 Swift Concurrency 프로파일러로 확인한 결과, 위치 업데이트마다 새 AsyncStream과 새 Task가 반복 생성되고 있었다.
- ACTION
프로퍼티 승격
continuation을 Actor 프로퍼티로 승격. 스트림은 한 번만 열어두고, processLocation() 내부에서 저장된 continuation으로 직접 yield()하도록 변경했다.
- FINDING
Sendable 충돌
onTermination에서 continuation = nil 직접 호출 시 'Actor-isolated property can not be mutated from a Sendable closure' 에러. 임의 스레드에서 실행되는 @Sendable 클로저라 Actor 보호 프로퍼티에 직접 접근할 수 없다.
- RESULT
init → task 분리
Actor-isolated 메서드(clearContinuation())를 만들고 Task { await ... }로 감싸 해결. 이후 startStream()으로 분리해 View의 .task에서 호출하도록 재정리했다.
- FINDING
문제 인식
정상 흐름(버튼 클릭)만 상태 정리를 처리하고 있었고, 비정상 이탈(탭바 클릭, Watch 크라운)은 전혀 커버되지 않았다는 걸 실기기 테스트로 발견했다.
- FINDING
판단 기준
isRunning 플래그로 판단하려 했으나, 미러링 중인 iPhone은 start()를 안 불러 세션이 살아있는데도 false인 경우가 있었다. HealthKitService.shared.session != nil로 기준을 교체했다.
- ACTION
플래그 패턴
버튼 액션에서 didNavigateToTouchdown 같은 플래그를 먼저 true로 세팅하고, .onDisappear는 그 플래그의 부재를 '비정상 이탈'로 해석하는 패턴을 5개 View에 동일 적용했다.
- RESULT
예외 처리
FlightSummaryView는 Logbook 열람 시에도 쓰이는 View라 selectedFlight == nil 조건으로 구분. Watch는 WatchSummaryView만 예외적으로 무조건 정리하도록 했다.
- FINDING
1차 시도
startDate 기준 5초 경과 시 좀비로 간주하는 방식을 시도했으나 검증 불가 - 디버거로 강제종료한 직후 연결이 끊겨 로그를 확인할 수 없었다.
- DISCOVERY
원인 확정
os_log / Console.app로 전환해 privacy 마스킹 문제를 해결한 뒤에야 진짜 원인을 특정했다. retrieveRemoteSession이 재실행 시 이미 살아있는 세션을 다시 감지하는 구조였다.
- ACTION
플래그 기반 판별
UserDefaults 플래그(wasZombieSuspected)로 판별은 정확했으나, .end() 호출 시 Watch 세션까지 연쇄 종료되는 부작용이 발생했다.
- FINDING
대안 탐색
appLaunchTime 비교 방식으로 전환. 좀비로 판단해도 무시하면 새 미러링이 차단되고, end()를 호출하면 앞선 부작용이 재발하는 딜레마였다.
- RESULT
구조적 한계 확정
HKWorkoutSession은 healthd(시스템 데몬) 레벨 자원이라 앱 코드로는 완전한 제어가 불가능하다고 결론. 관련 코드를 전체 롤백하고 로깅 인프라만 유지했다.
HKWorkoutSession이 healthd(시스템 데몬) 레벨 자원이라 앱 코드로는 완전한 제어가 불가능하다고 결론. os_log/Console.app 기반으로 원인을 정확히 특정했지만, 부작용 없이는 해결이 불가능해 v1.0에서는 known limitation으로 명시했다.
- FINDING
증상 이동
앱 주도 미러링에서 나타나던 증상(Watch 표시 안 됨, 종료 동기화 안 됨)이 코드 수정 없이 재빌드만으로 사라지고, 대신 Watch 주도 미러링에서 location 자체가 안 잡히는 새 증상이 나타났다.
- ROOT CAUSE
비동기 대입과 동기 체크의 충돌
updatePhase(.cruise) 안의 startOrigin = .local 대입이 Task {} 안에 있어 비동기로 실행된다. 바로 다음 줄 start()는 이 값을 한 번, 동기적으로만 확인하기 때문에 대입이 끝나기 전에 체크가 먼저 실행되면 GPS가 그 러닝 내내 켜지지 않는다.
- DISCOVERY
iPhone이 무사했던 이유
동일한 호출 순서인데도 iPhone은 멀쩡했다. TakeoffView에 Pre-flight Check를 붙이며 prepareTracking()을 미리 호출해 GPS를 켜두고 있었기 때문에, start() 시점엔 이미 GPS가 돌고 있어 레이스 자체를 밟지 않았던 것. Watch만 이 사전 단계가 없어 그대로 노출됐다.
- ACTION
레이스 자체를 제거
Watch에도 iPhone과 동일한 prepareTracking()/stopTracking() 패턴을 도입. GPS 시작 로직을 start()에서 완전히 분리해, start()는 더 이상 startOrigin을 확인하지 않도록 재구성했다.
- RESULT
플랫폼 구조 통일
didStartFlight 플래그로 ROTATE를 통한 정상 진입과 카운트다운 중 이탈을 구분. iOS와 Watch가 동일한 아키텍처를 갖게 되어 이후 두 플랫폼 코드를 나란히 봐도 헷갈릴 일이 줄었다.
- FINDING
화면과 저장의 불일치
PFD 실시간 페이스 계산에는 이미 isFinite 가드가 있어 화면엔 항상 --'--"로 안전하게 표시됐다. 하지만 SwiftData에 저장하는 saveRunningData()에는 같은 가드가 빠져 있었다.
- ROOT CAUSE
inf의 전파
거리가 0이면 Double은 크래시 없이 inf를 반환한다. 이 inf가 그대로 저장되고, 월별 평균을 구하는 reduce 연산에 inf가 하나만 섞여도 합계 전체가 inf로 오염되어 그 달 평균이 통째로 깨졌다.
- ACTION
저장·집계 이중 방어
저장 시점에 isFinite 가드를 추가해 계산 불가능한 값은 0으로 저장하고, 집계 시점에도 isFinite 필터를 한 번 더 걸었다. 동일 계산식이 있던 Watch 쪽 WatchPFDView.swift에도 같은 가드를 적용했다.
- FINDING
후속 문제
러닝 시작 직후 바로 종료하면 페이스가 비정상적으로 크게 튀는 별도 문제를 발견했다. inf는 아니지만 시간 대비 거리가 극단적으로 작아 나온 비현실적인 유한값이라 isFinite 가드로는 걸러지지 않았다.
- RESULT
임계값 도입과 데이터 정리
최소 유효 거리(50m)와 현실적인 페이스 상한선(30 min/km)을 추가로 걸었다. 이미 오염된 레코드 14개는 일회성 정리 스크립트로 삭제. 화면에 안 보이면 괜찮다가 아니라 저장되는 값 자체의 안전성을 확인해야 한다는 교훈을 얻었다.
- FINDING
패턴 확인
미러링 4가지 조합(앱주도/워치주도 × 앱종료/워치종료)을 코드로 전부 재검토한 결과, 지금까지 나온 문제의 절반 이상이 '워치 주도 + iPhone이 그걸 실시간으로 따라가야 한다'는 조합 하나에서만 발생하고 있었다.
- DISCOVERY
GPS 교차 추적의 실체
WatchPFDView에는 '.onDisappear에서 상태를 정리한다'는 문서 주석이 있었지만 실제 코드는 없었다. 크라운으로 이탈해도 GPS 추적이 멈추지 않아, 멈추지 않은 이전 추적이 orphan 상태로 남은 채 다음 러닝과 겹치고 있었다.
- DISCOVERY
다이나믹 아일랜드도 같은 뿌리
Live Activity를 갱신하는 updateCruise()가 iPhone 자신의 GPS 스트림에서만 호출되고, Watch가 보내는 flightData 수신 로직엔 연결돼 있지 않았다. 워치 주도일 땐 Live Activity가 시작 화면에서 한 발짝도 못 움직이는 구조였다.
- ACTION
타협점
완전한 양방향 미러링 대신, 앱 주도(+ 워치 센서 데이터 미러링)만 유지하고 워치 주도는 미러링을 아예 시도하지 않도록 startOrigin으로 분기했다. 종료 시엔 기존 '워치 단독 러닝' 경로 그대로 기록만 iPhone Logbook에 전달한다.
워치로 뛸 땐 대개 화면을 보지 않는다는 현실을 인정했다. 워치 주도 러닝에 iPhone 실시간 동기화가 꼭 필요하지 않다고 판단해서, 완전한 양방향 미러링 대신 실사용에 맞춰 스코프를 의도적으로 좁혔다. 01번(미러링 아키텍처 재설계)에서 완성했던 4가지 시나리오 중 하나를 다시 걷어낸 셈이다.
- FINDING
실기기 증상
미러링 상태에서 워치로 러닝을 종료했는데 아이폰은 아직 살아있었다. 그 상태에서 워치로 러닝을 재개하니 워치와 아이폰이 각자 위치 정보를 쌓는 상태가 겹쳤고, 워치 러닝을 마저 종료하니 로그에 값이 세 번 찍혔다.
- ROOT CAUSE
같은 러닝이라는 공통 식별자가 없었다
두 기기 다 자기 쪽에서 끝난 러닝을 각자 저장할 뿐, 서로 '이건 같은 러닝이다'를 확인할 방법이 없었다. 종료 신호가 조금만 늦게 도착해도 새 러닝과 예전 세션이 뒤섞였다.
- ACTION
공유 세션 ID 도입
러닝을 시작하는 기기에서 UUID를 발급해 runSessionID로 들고 있다가, flightData와 최종 저장 메시지에 매번 실어 보낸다. 저장 시점엔 이 값을 그대로 SwiftDataFlight.id로 써서, 같은 id가 이미 저장돼 있으면 다시 저장하지 않는다.
- ACTION
수신 쪽 가드 추가
워치가 자기 혼자 새 러닝을 시작하면(startOrigin == .local) 아이폰이 보내는 예전 flightData를 무시하게 가드를 걸었다. 반대 방향도 동일하게 처리했다.
- DISCOVERY
아이폰 쪽 native 미러링 수신도 정리
이 조사 중에 HealthKitService+iOS.swift의 retrieveRemoteSession()이 심박수 전달에도 Apple Health 저장에도 안 쓰이고 있다는 걸 확인했다. 오히려 있으면 저장 가드가 깨지거나 PFDView가 내비게이션에 중복으로 쌓일 위험만 있어서 등록을 껐다.
09번에서 워치 주도 미러링 자체를 없앴는데도, 아이폰이 워치의 세션을 되받는 코드는 남아있었다. 시작 쪽은 이제 완전히 아이폰 하나로만 판단되고, 종료 쪽은 세션 ID로 같은 러닝인지 확인하는 걸로 정리됐다.
- FINDING
실기기 증상
아이폰이 startWatchApp으로 워치 앱을 직접 실행시키면서 미러링하는 경우, 워치에서 러닝을 종료해도 아이폰 쪽 러닝이 전혀 끝나지 않았다.
- ROOT CAUSE
10번에서 끈 코드에 은근히 의존하고 있었다
WatchViewModel이 자기 종료를 아이폰에 알릴지 말지를 runningMode == .mirrored로 판단하고 있었는데, runningMode는 startMirroringToCompanionDevice()가 성공했을 때만 .mirrored로 바뀐다. 10번에서 retrieveRemoteSession()(이 핸드셰이크를 받아주는 리스너)을 꺼서 아이폰 쪽에 받아줄 게 없어졌고, 이 호출이 실패하면서 runningMode가 계속 .standalone으로 남아 sendStopSignal() 자체가 안 불렸다.
- ACTION
판단 기준 교체
판단 기준을 runningMode 대신 startOrigin != .local로 바꿨다. startOrigin은 AppDelegate.handle()에서 핸드셰이크 성공 여부와 무관하게 항상 먼저 세팅되고, stopWorkout()도 이 값을 안 건드려서 종료 시점까지 그대로 남아있다.
10번에서 retrieveRemoteSession()을 끄면서 생긴 부작용이었다. 코드를 지울 때 그 코드에 은근히 기대고 있던 다른 로직(이번엔 완전히 다른 함수의 판단 기준)까지 같이 점검해야 한다는 걸 다시 느꼈다.
- FINDING
실기기 증상
음악을 들으며 뛰어보니 km 스플릿 안내 자체가 안 들렸다. SpeechAnnouncerService는 AVAudioSession을 .duckOthers 옵션으로 설정해 안내가 나오는 동안만 음악을 줄이는데, 이 활성화 코드가 러닝 시작 시점에 딱 한 번만 불리고 있어서 중간에 음악 앱이 세션을 가져가며 인터럽트되면 그 뒤로 복구되지 않았다.
- ACTION
재활성화로 1차 수정, 그리고 반대 문제
speak() 직전에 세션을 매번 재활성화하도록 고쳤더니, 이번엔 음악 소리가 러닝 내내 줄어든 채로 안 돌아왔다. start()에 남아있던 활성화 호출이 원인이었다. 덕킹은 speak()가 아니라 세션이 활성화된 동안 계속 걸리는 것이라, 이 호출을 지우고 AVSpeechSynthesizerDelegate의 didFinish/didCancel에서 즉시 비활성화하도록 바꿨다.
- DISCOVERY
기기 간 오디오 세션은 격리되어 있다
워치 단독 러닝 중 아이폰에서 음악을 들으면 TTS가 아예 안 들렸다. 에어팟은 두 기기 모두에 페어링돼 있어도 오디오 스트림은 한 기기만 갖고, AVAudioSession은 기기별로 완전히 독립된 인스턴스라 워치 설정이 아이폰 오디오에 영향을 줄 방법이 없다.
- FINDING
나이키 런 클럽도 같은 한계
커뮤니티를 찾아보니 나이키 런 클럽도 동일한 증상을 갖고 있었고, 제시된 해법은 '음악도 워치에서 재생하라'는 것뿐이었다. 앱만의 버그가 아니라 애플 생태계 전체의 구조적 한계였다.
온디바이스 덕킹 버그 두 개는 고쳤지만, 기기 간 오디오 세션 격리라는 근본 한계는 코드로 풀 수 없었다. 대신 앱 공지사항에 '음악도 Apple Watch에서 재생해달라'는 안내를 추가해 사용자 행동으로 우회했다. 모든 문제가 코드로 풀리는 건 아니고, 때로는 정직하게 안내하는 것도 답이 된다.