RunWay 홈으로Maintenance Log

정비 기록

개발 중 실제로 마주친 문제와 원인 추적, 해결 과정을 정비 로그
형식으로 정리했습니다. 모든 것이 해결되지는 않았고, 구조적
한계는 한계라고 정직하게 남겨두었습니다.

19
Resolved
2
Known Limitation
  1. FINDING

    문제 인식

    iPhone과 Watch가 미러링 중에도 각자 LocationService와 RunningCenter를 독립적으로 돌리고 있었다. Watch에서 시작해도 iPhone이 GPS 락을 새로 잡는 동안 딜레이가 발생.

  2. ACTION

    방향 전환

    이미 쓰이던 startOrigin을 위치 추적 여부의 기준으로 확장. 주도 기기만 GPS를 켜고 계산 결과를 상대에게 전송, 미러링 기기는 수신 후 표시만.

  3. DISCOVERY

    양방향 구현

    sendFlightData()가 iOS 쪽에만 있어서 iPhone → Watch 방향만 가능했다. Watch 주도 미러링에서 Watch가 계산한 데이터를 iPhone에 보낼 경로 자체가 없던 숨은 허점이었다.

  4. RESULT

    보완

    elapsedTime까지 3초 throttle에 묶으면 오차가 누적되니, FlightData와 분리해 1초마다 별도 메시지(sendElapsedTime())로 동기화. iPhone 단독 / Watch 단독 / 양방향 미러링 4가지 시나리오가 startOrigin 하나로 정리됐다.

  1. FINDING

    발견

    스택 트레이스 추적 결과 session(_:activationDidCompleteWith:error:)에서 크래시 발생.

  2. ROOT CAUSE

    원인

    클래스는 nonisolated로 선언했지만, extension 안 delegate 메서드 자체에 명시 안 하면 Xcode 26의 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 기본값 때문에 다시 @MainActor로 추론된다.

  3. FINDING

    재발

    1차 수정 후에도 didReceiveUserInfo에서 동일 패턴의 크래시가 실기기에서만 재현됐다.

  4. ACTION

    전수 점검

    +iOS, +watchOS extension의 WCSessionDelegate 콜백 전체를 점검해 빠진 nonisolated를 모두 추가했다.

  1. APPROACH

    1차 구현

    Timer.publish().connect()로 시작 시점을 직접 제어. 일시정지 구현을 염두에 두고 autoconnect() 대신 선택했다.

  2. FINDING

    재시작 실패

    정지 후 재시작 시 타이머가 안 돎. connect()는 1회성 연결이라 한 번 cancel()한 Publisher는 재연결이 불가능하다.

  3. ACTION

    autoconnect 전환

    start()가 호출될 때마다 autoconnect() + sink로 매번 새로 구독. Set<AnyCancellable>에 저장하고 stop() 시 removeAll()로 정리했다.

  4. RESULT

    중복 구독 방지

    start()를 연달아 누르면 구독이 누적돼 초가 2, 3씩 뛰는 문제도 발견. start() 진입 시점에 기존 구독을 먼저 정리하도록 보완해 최종 해결했다.

  1. FINDING

    발견

    Instrument의 Swift Concurrency 프로파일러로 확인한 결과, 위치 업데이트마다 새 AsyncStream과 새 Task가 반복 생성되고 있었다.

  2. ACTION

    프로퍼티 승격

    continuation을 Actor 프로퍼티로 승격. 스트림은 한 번만 열어두고, processLocation() 내부에서 저장된 continuation으로 직접 yield()하도록 변경했다.

  3. FINDING

    Sendable 충돌

    onTermination에서 continuation = nil 직접 호출 시 'Actor-isolated property can not be mutated from a Sendable closure' 에러. 임의 스레드에서 실행되는 @Sendable 클로저라 Actor 보호 프로퍼티에 직접 접근할 수 없다.

  4. RESULT

    init → task 분리

    Actor-isolated 메서드(clearContinuation())를 만들고 Task { await ... }로 감싸 해결. 이후 startStream()으로 분리해 View의 .task에서 호출하도록 재정리했다.

  1. FINDING

    문제 인식

    정상 흐름(버튼 클릭)만 상태 정리를 처리하고 있었고, 비정상 이탈(탭바 클릭, Watch 크라운)은 전혀 커버되지 않았다는 걸 실기기 테스트로 발견했다.

  2. FINDING

    판단 기준

    isRunning 플래그로 판단하려 했으나, 미러링 중인 iPhone은 start()를 안 불러 세션이 살아있는데도 false인 경우가 있었다. HealthKitService.shared.session != nil로 기준을 교체했다.

  3. ACTION

    플래그 패턴

    버튼 액션에서 didNavigateToTouchdown 같은 플래그를 먼저 true로 세팅하고, .onDisappear는 그 플래그의 부재를 '비정상 이탈'로 해석하는 패턴을 5개 View에 동일 적용했다.

  4. RESULT

    예외 처리

    FlightSummaryView는 Logbook 열람 시에도 쓰이는 View라 selectedFlight == nil 조건으로 구분. Watch는 WatchSummaryView만 예외적으로 무조건 정리하도록 했다.

  1. FINDING

    1차 시도

    startDate 기준 5초 경과 시 좀비로 간주하는 방식을 시도했으나 검증 불가 - 디버거로 강제종료한 직후 연결이 끊겨 로그를 확인할 수 없었다.

  2. DISCOVERY

    원인 확정

    os_log / Console.app로 전환해 privacy 마스킹 문제를 해결한 뒤에야 진짜 원인을 특정했다. retrieveRemoteSession이 재실행 시 이미 살아있는 세션을 다시 감지하는 구조였다.

  3. ACTION

    플래그 기반 판별

    UserDefaults 플래그(wasZombieSuspected)로 판별은 정확했으나, .end() 호출 시 Watch 세션까지 연쇄 종료되는 부작용이 발생했다.

  4. FINDING

    대안 탐색

    appLaunchTime 비교 방식으로 전환. 좀비로 판단해도 무시하면 새 미러링이 차단되고, end()를 호출하면 앞선 부작용이 재발하는 딜레마였다.

  5. RESULT

    구조적 한계 확정

    HKWorkoutSession은 healthd(시스템 데몬) 레벨 자원이라 앱 코드로는 완전한 제어가 불가능하다고 결론. 관련 코드를 전체 롤백하고 로깅 인프라만 유지했다.

FINAL VERDICT

HKWorkoutSession이 healthd(시스템 데몬) 레벨 자원이라 앱 코드로는 완전한 제어가 불가능하다고 결론. os_log/Console.app 기반으로 원인을 정확히 특정했지만, 부작용 없이는 해결이 불가능해 v1.0에서는 known limitation으로 명시했다.

  1. FINDING

    증상 이동

    앱 주도 미러링에서 나타나던 증상(Watch 표시 안 됨, 종료 동기화 안 됨)이 코드 수정 없이 재빌드만으로 사라지고, 대신 Watch 주도 미러링에서 location 자체가 안 잡히는 새 증상이 나타났다.

  2. ROOT CAUSE

    비동기 대입과 동기 체크의 충돌

    updatePhase(.cruise) 안의 startOrigin = .local 대입이 Task {} 안에 있어 비동기로 실행된다. 바로 다음 줄 start()는 이 값을 한 번, 동기적으로만 확인하기 때문에 대입이 끝나기 전에 체크가 먼저 실행되면 GPS가 그 러닝 내내 켜지지 않는다.

  3. DISCOVERY

    iPhone이 무사했던 이유

    동일한 호출 순서인데도 iPhone은 멀쩡했다. TakeoffView에 Pre-flight Check를 붙이며 prepareTracking()을 미리 호출해 GPS를 켜두고 있었기 때문에, start() 시점엔 이미 GPS가 돌고 있어 레이스 자체를 밟지 않았던 것. Watch만 이 사전 단계가 없어 그대로 노출됐다.

  4. ACTION

    레이스 자체를 제거

    Watch에도 iPhone과 동일한 prepareTracking()/stopTracking() 패턴을 도입. GPS 시작 로직을 start()에서 완전히 분리해, start()는 더 이상 startOrigin을 확인하지 않도록 재구성했다.

  5. RESULT

    플랫폼 구조 통일

    didStartFlight 플래그로 ROTATE를 통한 정상 진입과 카운트다운 중 이탈을 구분. iOS와 Watch가 동일한 아키텍처를 갖게 되어 이후 두 플랫폼 코드를 나란히 봐도 헷갈릴 일이 줄었다.

  1. FINDING

    화면과 저장의 불일치

    PFD 실시간 페이스 계산에는 이미 isFinite 가드가 있어 화면엔 항상 --'--"로 안전하게 표시됐다. 하지만 SwiftData에 저장하는 saveRunningData()에는 같은 가드가 빠져 있었다.

  2. ROOT CAUSE

    inf의 전파

    거리가 0이면 Double은 크래시 없이 inf를 반환한다. 이 inf가 그대로 저장되고, 월별 평균을 구하는 reduce 연산에 inf가 하나만 섞여도 합계 전체가 inf로 오염되어 그 달 평균이 통째로 깨졌다.

  3. ACTION

    저장·집계 이중 방어

    저장 시점에 isFinite 가드를 추가해 계산 불가능한 값은 0으로 저장하고, 집계 시점에도 isFinite 필터를 한 번 더 걸었다. 동일 계산식이 있던 Watch 쪽 WatchPFDView.swift에도 같은 가드를 적용했다.

  4. FINDING

    후속 문제

    러닝 시작 직후 바로 종료하면 페이스가 비정상적으로 크게 튀는 별도 문제를 발견했다. inf는 아니지만 시간 대비 거리가 극단적으로 작아 나온 비현실적인 유한값이라 isFinite 가드로는 걸러지지 않았다.

  5. RESULT

    임계값 도입과 데이터 정리

    최소 유효 거리(50m)와 현실적인 페이스 상한선(30 min/km)을 추가로 걸었다. 이미 오염된 레코드 14개는 일회성 정리 스크립트로 삭제. 화면에 안 보이면 괜찮다가 아니라 저장되는 값 자체의 안전성을 확인해야 한다는 교훈을 얻었다.

  1. FINDING

    패턴 확인

    미러링 4가지 조합(앱주도/워치주도 × 앱종료/워치종료)을 코드로 전부 재검토한 결과, 지금까지 나온 문제의 절반 이상이 '워치 주도 + iPhone이 그걸 실시간으로 따라가야 한다'는 조합 하나에서만 발생하고 있었다.

  2. DISCOVERY

    GPS 교차 추적의 실체

    WatchPFDView에는 '.onDisappear에서 상태를 정리한다'는 문서 주석이 있었지만 실제 코드는 없었다. 크라운으로 이탈해도 GPS 추적이 멈추지 않아, 멈추지 않은 이전 추적이 orphan 상태로 남은 채 다음 러닝과 겹치고 있었다.

  3. DISCOVERY

    다이나믹 아일랜드도 같은 뿌리

    Live Activity를 갱신하는 updateCruise()가 iPhone 자신의 GPS 스트림에서만 호출되고, Watch가 보내는 flightData 수신 로직엔 연결돼 있지 않았다. 워치 주도일 땐 Live Activity가 시작 화면에서 한 발짝도 못 움직이는 구조였다.

  4. ACTION

    타협점

    완전한 양방향 미러링 대신, 앱 주도(+ 워치 센서 데이터 미러링)만 유지하고 워치 주도는 미러링을 아예 시도하지 않도록 startOrigin으로 분기했다. 종료 시엔 기존 '워치 단독 러닝' 경로 그대로 기록만 iPhone Logbook에 전달한다.

FINAL VERDICT

워치로 뛸 땐 대개 화면을 보지 않는다는 현실을 인정했다. 워치 주도 러닝에 iPhone 실시간 동기화가 꼭 필요하지 않다고 판단해서, 완전한 양방향 미러링 대신 실사용에 맞춰 스코프를 의도적으로 좁혔다. 01번(미러링 아키텍처 재설계)에서 완성했던 4가지 시나리오 중 하나를 다시 걷어낸 셈이다.

  1. FINDING

    실기기 증상

    미러링 상태에서 워치로 러닝을 종료했는데 아이폰은 아직 살아있었다. 그 상태에서 워치로 러닝을 재개하니 워치와 아이폰이 각자 위치 정보를 쌓는 상태가 겹쳤고, 워치 러닝을 마저 종료하니 로그에 값이 세 번 찍혔다.

  2. ROOT CAUSE

    같은 러닝이라는 공통 식별자가 없었다

    두 기기 다 자기 쪽에서 끝난 러닝을 각자 저장할 뿐, 서로 '이건 같은 러닝이다'를 확인할 방법이 없었다. 종료 신호가 조금만 늦게 도착해도 새 러닝과 예전 세션이 뒤섞였다.

  3. ACTION

    공유 세션 ID 도입

    러닝을 시작하는 기기에서 UUID를 발급해 runSessionID로 들고 있다가, flightData와 최종 저장 메시지에 매번 실어 보낸다. 저장 시점엔 이 값을 그대로 SwiftDataFlight.id로 써서, 같은 id가 이미 저장돼 있으면 다시 저장하지 않는다.

  4. ACTION

    수신 쪽 가드 추가

    워치가 자기 혼자 새 러닝을 시작하면(startOrigin == .local) 아이폰이 보내는 예전 flightData를 무시하게 가드를 걸었다. 반대 방향도 동일하게 처리했다.

  5. DISCOVERY

    아이폰 쪽 native 미러링 수신도 정리

    이 조사 중에 HealthKitService+iOS.swift의 retrieveRemoteSession()이 심박수 전달에도 Apple Health 저장에도 안 쓰이고 있다는 걸 확인했다. 오히려 있으면 저장 가드가 깨지거나 PFDView가 내비게이션에 중복으로 쌓일 위험만 있어서 등록을 껐다.

FINAL VERDICT

09번에서 워치 주도 미러링 자체를 없앴는데도, 아이폰이 워치의 세션을 되받는 코드는 남아있었다. 시작 쪽은 이제 완전히 아이폰 하나로만 판단되고, 종료 쪽은 세션 ID로 같은 러닝인지 확인하는 걸로 정리됐다.

  1. FINDING

    실기기 증상

    아이폰이 startWatchApp으로 워치 앱을 직접 실행시키면서 미러링하는 경우, 워치에서 러닝을 종료해도 아이폰 쪽 러닝이 전혀 끝나지 않았다.

  2. ROOT CAUSE

    10번에서 끈 코드에 은근히 의존하고 있었다

    WatchViewModel이 자기 종료를 아이폰에 알릴지 말지를 runningMode == .mirrored로 판단하고 있었는데, runningMode는 startMirroringToCompanionDevice()가 성공했을 때만 .mirrored로 바뀐다. 10번에서 retrieveRemoteSession()(이 핸드셰이크를 받아주는 리스너)을 꺼서 아이폰 쪽에 받아줄 게 없어졌고, 이 호출이 실패하면서 runningMode가 계속 .standalone으로 남아 sendStopSignal() 자체가 안 불렸다.

  3. ACTION

    판단 기준 교체

    판단 기준을 runningMode 대신 startOrigin != .local로 바꿨다. startOrigin은 AppDelegate.handle()에서 핸드셰이크 성공 여부와 무관하게 항상 먼저 세팅되고, stopWorkout()도 이 값을 안 건드려서 종료 시점까지 그대로 남아있다.

FINAL VERDICT

10번에서 retrieveRemoteSession()을 끄면서 생긴 부작용이었다. 코드를 지울 때 그 코드에 은근히 기대고 있던 다른 로직(이번엔 완전히 다른 함수의 판단 기준)까지 같이 점검해야 한다는 걸 다시 느꼈다.

  1. FINDING

    핫픽스 분리 중 발견

    아직 머지되지 않은 심박수 브랜치를 v1.0에 급하게 단독 핫픽스로 분리하던 중 발견했다. RunningCenter.reset()이 화면 전환뿐 아니라 start() 안에서도 방어적으로 다시 호출되고 있었는데, 이 호출이 modeAData(미션 목표)까지 매번 지우고 있었다.

  2. ROOT CAUSE

    한 함수에 두 가지 책임이 섞여 있었다

    start()에서의 방어적 reset()은 원래 elapsedTime/flightData가 이전 값에서 이어지는 걸 막으려던 조치였지, modeAData를 지우려던 의도가 아니었다. 그런데 같은 함수 안에 같이 들어있어서 화면 전환 때도, 재시작 방어 때도 매번 목표 데이터까지 같이 날아갔다.

  3. ACTION

    책임 분리

    목표 데이터를 지우는 역할을 clearModeAData()로 따로 뺐다. reset()은 더 이상 modeAData를 건드리지 않고, RunViewModel/WatchViewModel의 resetState()에서만 clearModeAData()를 명시적으로 호출하도록 정리했다.

FINAL VERDICT

"화면 전환용 정리"와 "재시작 방어"라는 서로 다른 두 목적이 한 함수 안에 같이 들어있으면, 한쪽만 의도한 변경이 다른 쪽까지 건드리는 부작용을 만든다. 책임을 함수 단위로 쪼개는 것만으로 해결됐다.

  1. FINDING

    테스트 방식의 사각지대

    심박 기반 GPWS를 위해 SwiftDataAlert에 target/heartRate 필드를 추가했는데, 매번 삭제 후 재설치로만 테스트하고 있어서 마이그레이션 경로 자체를 한 번도 안 타보고 있었다.

  2. ROOT CAUSE

    init 기본값은 마이그레이션에서 안 보인다

    새 필드의 기본값을 init() 파라미터에만 넣어뒀다. SwiftData의 자동 라이트웨이트 마이그레이션은 기존 레코드에 새 필드 값을 채울 때 프로퍼티 선언부의 기본값을 보는데, init 파라미터의 기본값은 여기서 보이지 않는다.

  3. ACTION

    선언부에 기본값 직접 명시

    프로퍼티 선언부에 직접 기본값을 넣었다. 자료 조사 중 이렇게 해도 크래시가 나는 사례가 있다는 것도 확인했는데, Apple 포럼에서는 이를 "의도된 동작"으로 밝혔다. 완전히 안전하게 하려면 VersionedSchema로 필드를 옵셔널로 먼저 마이그레이션한 뒤 필수값으로 바꾸는 멀티스테이지 마이그레이션이 필요하다.

FINAL VERDICT

삭제 후 재설치 테스트는 마이그레이션 경로 자체를 타지 않는다. 스키마를 바꿀 때마다 최소 한 번은 기존 버전에서 업데이트하는 경로로 확인해야 한다는 걸 배웠다.

  1. FINDING

    버그 1 - 두 번째 백업부터 조용히 실패

    백업 버튼을 눌러 CloudKit Dashboard에 레코드까지 잘 생겼는데, 두 번째로 누르니 "0개 백업했습니다"라고 떴다.

  2. ROOT CAUSE

    변경 태그 비교 실패가 조용히 묻혔다

    modifyRecords(saving:deleting:)의 기본 저장 정책(.ifServerRecordUnchanged)은 서버 레코드와 변경 태그를 비교하는데, 새로 만든 로컬 레코드엔 그 정보가 없어 충돌로 처리됐다. 게다가 이 실패가 에러를 던지지 않고 레코드별 결과 안에만 담겨서 겉으로는 문제없어 보였다.

  3. ACTION

    무조건 덮어쓰기로 전환

    savePolicy: .changedKeys로 바꿔서 항상 로컬 상태로 무조건 덮어쓰게 했다.

  4. FINDING

    버그 2 - 대시보드에서 조회가 안 됨

    백업은 되는데 CloudKit Dashboard에서 조건 없이 전체 조회하면 "Field 'recordName' is not marked queryable" 에러가 났다.

  5. ROOT CAUSE

    recordName엔 기본 인덱스가 없다

    CKQuery로 조회할 때 조건/정렬에 쓰는 필드마다 Queryable 인덱스가 필요한데, recordName은 기본으로 인덱스가 없다. 조건 없는 조회(NSPredicate(value: true))가 내부적으로 recordName을 참조하려다 막힌 것.

  6. RESULT

    쿼리 조건 변경 + 인덱스 추가

    쿼리를 date 필드 조건 기준으로 바꿔서 recordName 참조 자체를 피했고, CloudKit Dashboard의 Schema > Indexes에서 recordName에 QUERYABLE 인덱스도 직접 추가했다.

FINAL VERDICT

저장 실패가 에러 없이 결과 값 안에만 조용히 담기는 API라, "성공처럼 보이는 실패"를 잡으려면 반환값 자체를 확인하는 습관이 필요했다. CloudKit Dashboard와 앱 코드가 서로 다른 쿼리를 만든다는 점도 헷갈리기 쉬운 지점이었다.

  1. FINDING

    실기기 증상

    음악을 들으며 뛰어보니 km 스플릿 안내 자체가 안 들렸다. SpeechAnnouncerService는 AVAudioSession을 .duckOthers 옵션으로 설정해 안내가 나오는 동안만 음악을 줄이는데, 이 활성화 코드가 러닝 시작 시점에 딱 한 번만 불리고 있어서 중간에 음악 앱이 세션을 가져가며 인터럽트되면 그 뒤로 복구되지 않았다.

  2. ACTION

    재활성화로 1차 수정, 그리고 반대 문제

    speak() 직전에 세션을 매번 재활성화하도록 고쳤더니, 이번엔 음악 소리가 러닝 내내 줄어든 채로 안 돌아왔다. start()에 남아있던 활성화 호출이 원인이었다. 덕킹은 speak()가 아니라 세션이 활성화된 동안 계속 걸리는 것이라, 이 호출을 지우고 AVSpeechSynthesizerDelegate의 didFinish/didCancel에서 즉시 비활성화하도록 바꿨다.

  3. DISCOVERY

    기기 간 오디오 세션은 격리되어 있다

    워치 단독 러닝 중 아이폰에서 음악을 들으면 TTS가 아예 안 들렸다. 에어팟은 두 기기 모두에 페어링돼 있어도 오디오 스트림은 한 기기만 갖고, AVAudioSession은 기기별로 완전히 독립된 인스턴스라 워치 설정이 아이폰 오디오에 영향을 줄 방법이 없다.

  4. ACTION

    1.4.1 - 이어폰 쥔 기기가 읽게

    미러링은 코드로 풀렸다. 처음엔 설정에서 읽을 기기를 고르게 했는데, 에어팟이 러닝 중에도 두 기기 사이를 옮겨다녀서 사용자도 모르는 걸 묻는 셈이었다. 지금은 아이폰이 currentRoute로 제 출력을 직접 보고, 이어폰이 자기 쪽이면 자기가 읽고 아니면 Watch에 넘긴다. 넘기기가 실패하면 자기가 읽는다. 처음엔 블루투스 포트만 골라 보다가 유선 이어폰(.headphones)을 통째로 놓쳐서, 종류를 세는 대신 '내 스피커가 아닌가'로 뒤집었다.

  5. FINDING

    나이키 런 클럽도 같은 한계

    커뮤니티를 찾아보니 나이키 런 클럽도 동일한 증상을 갖고 있었고, 제시된 해법은 '음악도 워치에서 재생하라'는 것뿐이었다. 앱만의 버그가 아니라 애플 생태계 전체의 구조적 한계였다.

FINAL VERDICT

온디바이스 덕킹 버그 두 개를 고쳤고, 1.4.1에서 미러링 중 어느 기기가 읽을지까지 코드로 풀었다. 남은 건 워치 단독 러닝 중 아이폰에서 음악을 들을 때다. 오디오 스트림의 주인을 정하는 건 시스템이라, 워치 앱이 아이폰 세션을 건드릴 방법이 없다. 그건 공지사항에 '음악도 Apple Watch에서 재생해달라'는 안내로 남겨뒀다. 못 고치는 것과 방치하는 것은 다르다.

  1. FINDING

    이미 남아있던 TODO

    최소값 100m부터 계속 돌려보니 어중간한 값에서 멈추는 걸 발견했다. 코드에는 이미 "// 수정해야함"이라는 TODO가 남아있었다.

  2. ROOT CAUSE

    시작점이 스텝의 배수가 아니었다

    Crown 바인딩이 시작값 0.1(100m)에서 0.5씩 계속 더하는 구조였다. 100m는 0.5의 배수가 아니라서, 그 뒤로 나오는 모든 값이 시작점만큼 계속 밀렸다.

  3. ACTION

    값 대신 인덱스로 전환

    Crown 값을 거리 자체가 아니라 미리 정해둔 distanceSteps 배열의 인덱스로 바꿨다. 100m는 배열 맨 앞에 예외로 두고, 이후 0.5km 격자에 하프/풀마라톤(21.1km/42.2km)만 중간에 끼워 넣었다. 프리셋 버튼(3K/5K/10K/HALF/FULL)도 값 대신 인덱스를 찾아 설정하도록 같이 고쳤다.

FINAL VERDICT

시작점과 스텝 크기가 서로 배수 관계가 아니면 그 어긋남이 계속 누적된다는, 흔하지만 놓치기 쉬운 산술 버그였다.

  1. FINDING

    배포 직후 재현

    1.3 배포 얼마 안 돼 OVERSPEED 경고에 반응해 속도를 줄였더니 정상적으로 SINK RATE로 바뀌었는데, 다시 속도를 올려도 SINK RATE에서 안 돌아왔다. 편차 숫자는 계속 커지다 300초까지 갔고 결국 워치 앱이 강제 종료됐다.

  2. ROOT CAUSE

    정지 판단과 페이스 계산이 서로 다른 걸 보고 있었다

    GPS 신뢰도(speedAccuracy)가 음수인 샘플은 정지 판단(isStationary)에서는 확실히 건너뛰는데, 페이스 계산용 속도 스무딩은 이 체크 밖에서 매번 무조건 갱신되고 있었다. 오버스피드에 반응해 속도를 확 줄이는 구간은 신뢰도가 음수로 찍히는 일이 흔해서, 정지 확정은 못 되는데 스무딩 값은 계속 낮은 쪽으로 끌려 내려갔다.

  3. DISCOVERY

    심박 모드로 교차 확인

    심박 기반 GPWS는 이 스무딩 계산 자체를 거치지 않고 실시간 심박을 그대로 읽어서 쓰는데, 같은 조건에서도 문제가 없었다. 원인이 정확히 페이스 계산 경로에만 있다는 걸 확인해준 단서였다.

  4. ACTION

    수정

    스무딩 갱신을 정지 판단과 같은 speedAccuracy 체크 안으로 옮겼다. 못 믿을 속도값이면 정지 판단도 페이스 계산도 둘 다 이전 상태를 그대로 유지하게 만든 것.

  5. FINDING

    검증 중 두 번째 버그 발견

    이 수정을 확인하다 별개로, 멈춰서 쉬는 중에 SINK RATE/OVERSPEED 진동이 안 꺼지고 계속 울리는 걸 발견했다. 정지 중 GPWS를 얼리는 기존 로직이, 멈추기 직전 상태가 하필 경고였으면 그 상태가 그대로 굳어 경고 화면과 반복 햅틱이 계속되게 만들고 있었다.

  6. RESULT

    얼리기 대신 되돌리기

    정지 중엔 얼리는 대신 normal로 되돌리도록 바꿨다. 심박 하강으로 새 경고가 뜨는 것도 여전히 막히고, 멈추기 직전 경고가 있었어도 멈추는 순간 바로 꺼진다. 1.3.1로 배포.

FINAL VERDICT

이 버그는 한 번에 생긴 게 아니라 v1.3 준비하면서 두 번에 걸쳐 손댄 로직이 겹쳐서 만들어졌다. 정지 판단 방식 자체를 raw 속도 기반으로 갈아엎은 게 먼저였고, 그 다음엔 완전히 다른 이유로(심박 하강 오탐 방지) 얼리는 로직을 추가했다. 각각은 그 순간엔 멀쩡한 이유로 손댄 건데, 정지 판단 쪽 규칙이 나중에 추가한 얼리기 로직의 전제를 깰 수 있다는 건 그땐 몰랐다. 코드 한 부분만 보고는 못 잡는, 두 변경 사이의 관계에서만 드러나는 버그였다.

  1. FINDING

    주석이 사실이 아니었다

    시작 시각이 초 단위까지 같은 러닝이 두 건 있었고, Source는 둘 다 RunWay인데 Device만 달랐다. 코드 주석에는 "Apple Health 저장은 Watch의 builder로만 이뤄진다"고 적혀 있었는데 사실이 아니었다. iPhone도 세션에 builder를 붙여 수집을 시작하는데, 종료할 때 마무리하지 않고 두면 시스템이 그때까지 모인 데이터로 워크아웃을 마감해 저장한다.

  2. ACTION

    한쪽을 막았다

    미러링일 때 iPhone의 builder를 discardWorkout()으로 버리게 했다. 무조건 버리면 Watch 없이 뛸 때 저장 주체가 사라지므로 미러링일 때만 버린다.

  3. DISCOVERY

    종료 주체에 따라 갈렸다

    실기기에서 확인하니 워치에서 끝내면 정상인데 앱에서 끝내면 건강 앱에 아무것도 안 남았다. Watch 화면에서 끝내는 경로는 HealthKit 델리게이트가 마무리를 불러주지만, iPhone이 끝냈다는 신호로 들어오는 경로는 그 델리게이트를 안 탄다. iPhone은 자기 걸 버렸는데 Watch도 마무리를 안 해서 저장하는 쪽이 하나도 없었다.

  4. RESULT

    규칙을 먼저 정했다

    "센서를 가진 기기가 저장한다"로 정하고 나니 고칠 곳이 두 군데로 분명해졌다. Watch는 원격 종료 신호에서도 마무리하게 하고, iPhone은 미러링 여부를 판단하는 순서를 바로잡았다. finishWatchWorkout()은 세션을 먼저 끝내도록 순서도 바꿨다. 원격 종료 경로는 세션이 아직 돌고 있는 상태로 들어오기 때문이다.

FINAL VERDICT

처음엔 저장하는 쪽을 하나로 줄이는 문제라고 봤다. 실제로는 누가 저장할 것인지 정하는 문제였다. 그걸 안 정하고 한쪽을 막으니, 막힌 경로에서는 아무도 저장하지 않고 안 막힌 경로에서는 둘 다 저장했다. 중복을 없애는 것과 주체를 정하는 것은 다른 일이었다.

  1. FINDING

    남이 겪은 증상

    1.3.1 사용자 제보였다. 내 기기에서는 재현되지 않았다. 홈으로 돌아가는 코드를 전부 찾아보니 경로가 하나뿐이었고, 사용자는 아무것도 안 눌렀다는데 누군가 대신 러닝을 끝내고 있었다.

  2. ACTION

    틀린 가설 둘

    먼저 지난 러닝의 종료 신호가 큐에 남았다가 늦게 배달된 것으로 봤다. 재현 절차를 만들어 부탁했는데 재현되지 않았고, 나중에 보니 절차가 가설을 비켜가고 있었다. 다음은 스플래시 화면이 1.5초 동안 NavigationStack을 막는 구조를 발견하고 확신했다. 그런데 "완전 종료는 정상, 백그라운드만 문제"라는 추가 제보가 예측과 정반대였다.

  3. DISCOVERY

    서머리를 안 봤다

    세 번째로 들은 말이 답이었다. 워치에서 러닝을 끝내면 착륙 화면이 뜨는데, 요약을 누르지 않고 손목을 내리면 백그라운드 전환에서는 onDisappear가 불리지 않아 뒷정리가 되지 않는다. 다음에 새 러닝이 시작되면 그 화면 위로 PFD가 밀고 들어오고, 덮인 착륙 화면의 onDisappear가 그제서야 불려 방금 시작한 러닝을 정리해버린다.

  4. RESULT

    덮인 것과 나간 것을 구분

    뒷정리에 "워크아웃 세션이 돌고 있으면 건드리지 않는다" 조건을 걸었다. 정상적으로 나가는 경우는 세션이 이미 끝나 있어 그대로 통과한다. 더해서 새 러닝은 내비게이션 경로를 갈아끼워 지난 화면 위에 쌓이지 않게 했다. 틀린 가설 둘에 맞춰 손댔던 코드는 전부 되돌렸다.

FINAL VERDICT

onDisappear는 사용자가 나간 것과 다른 화면이 위에 덮인 것을 구분하지 못한다. 그런데 더 남는 건 따로 있었다. 틀린 가설 둘은 코드만 보고 세운 것이고, 맞은 하나는 제보자가 "서머리를 안 봤다"고 말해준 뒤에 나왔다. 재현되지 않는다는 답을 듣고도 가설을 의심하는 대신 절차를 의심하지 않았다. 남이 겪은 버그는 코드를 아무리 봐도 조건을 채울 수 없다. 조건은 그 사람만 안다.

  1. FINDING

    같은 함수인데 한 경우만 실패

    앱에서 끝내도, 워치를 끄고 뛰어도, 워치 단독으로 뛰어도 저장이 됐다. 워치에서 끝낸 경우만 안 됐다. 저장 함수 자체는 멀쩡하다는 뜻이었다. 그런데 저장을 건너뛰는 자리 넷이 전부 아무 말 없이 돌아가서 어디서 빠졌는지 알 수가 없었다.

  2. ACTION

    순서를 찍었다

    네 자리에 각각 이유를 붙이고 그 순서를 모아 한 번에 보여주게 했다. 밖에서 확인해야 하는 문제라 화면에도 띄웠다. 열 줄이 나왔는데 두 군데가 이상했다. 중복 처리를 막는 값이 두 번 다 꺼져 있었고, 같은 러닝인데 거리가 0m와 264m로 달랐다.

  3. DISCOVERY

    뷰모델이 두 개였다

    App 구조체의 @State 초기값은 SwiftUI가 구조체를 다시 만들 때마다 새로 생성되고 첫 번째만 쓰인다. 버려진 인스턴스도 init()에서 이미 구독을 걸어둔 상태인데, 끝나지 않는 스트림을 self를 붙든 채 기다리는 Task 때문에 해제되지 않았다. 중복 방지 값은 인스턴스마다 따로라 통하지 않았고, 먼저 도는 유령이 공용 상태를 비워버려 진짜 인스턴스의 저장이 가드에 걸렸다.

  4. RESULT

    반복마다 확인하게

    두 Task를 약한 참조로 바꾸고 반복마다 살아있는지 확인하게 했다. 버려진 인스턴스가 정상적으로 해제되고, 해제되면 구독도 끊긴다. 1.3.1 커밋으로 되돌려 같은 시험을 해보니 똑같이 실패해서, 이번 작업이 만든 문제가 아니라는 것도 확인했다.

FINAL VERDICT

구독을 거쳐 저장하는 경로가 하나뿐이라 나머지 셋은 멀쩡했다. 유령이 있어도 티가 안 나는 자리였던 것이다. 그리고 유령은 종료 신호만 받은 게 아니라 init()에서 건 구독 전부를 같이 받고 있었다. 저장이 안 되는 게 제일 눈에 띄었을 뿐이다. @State 초기값이 여러 번 만들어진다는 건 알고 있었는데, 그게 구독과 만나면 버려진 사본이 계속 일한다는 데까지는 생각이 닿지 않았다.

  1. FINDING

    조건이 안 잡혔다

    간헐적이라 다시 해보면 멀쩡했다. 긴 러닝에서만 났는데 40분에서 나고 114분에서 안 난 적이 있어 길이로도 선이 안 그어졌다. 더 큰 문제는 아이폰에 남는 게 없다는 것이었다. 신호가 안 오면 아이폰은 아무 일도 안 일어난 상태라, 워치에서 안 보낸 건지 가다가 없어진 건지 와서 버려진 건지를 구분할 수 없었다.

  2. ACTION

    읽을 방법부터 만들었다

    버튼에서 신호까지 거치는 자리마다 한 줄씩 남기게 했다. 그런데 워치 시뮬레이터는 앱이 쓴 로그를 저장소에 안 남긴다. 6시간 치를 뒤져도 우리 앱 줄은 0개인데 같은 시간대 시스템 앱 로그는 멀쩡했다. 증상은 시뮬레이터에서 나고 로그는 실기기에서만 읽히는, 재현되는 곳과 읽을 수 있는 곳이 어긋난 상태였다. 같은 줄을 앱 폴더 안 파일에도 쓰게 해서 넘겼다.

  3. DISCOVERY

    정상일 때를 먼저 쟀다

    실패한 기록을 봐도 "원래 여기까지 찍히는 게 맞나"를 몰라서, 성공한 종료를 먼저 끝까지 받았다. 버튼에서 아이폰이 홈으로 가기까지 10초였고 그중 6초가 워치의 HealthKit 저장 한 자리였다. 기준이 생기자 실패한 기록에서 어느 줄이 빠졌는지가 바로 보였다.

  4. ROOT CAUSE

    저장이 돌아오지 않았다

    재현된 날 흔적이 "마무리 시작"에서 끊겨 있었다. 시스템 로그를 보니 수집 종료는 3.5초에 끝났고 그다음 저장 단계에서 나오지 않았다. 9분을 기다려도 풀리지 않았고, 그 사이 아이폰은 실제보다 9분 10초 긴 기록을 쌓고 있었다. 종료 신호는 그 저장이 끝나야 발행되는 자리에 있었다.

  5. ACTION

    순서를 뒤집었다

    발행을 저장 앞으로 옮겼다. 아이폰이 제 러닝을 끝내는 데 워치의 저장이 끝날 이유가 없다. 저장을 없앤 게 아니라 신호 뒤로 보냈을 뿐이라, 저장은 그대로 돌고 아이폰만 거기 안 묶인다.

  6. RESULT

    10초에서 3초로

    같은 길이 러닝에서 버튼부터 홈까지 3.2초가 됐다. 6시간 41분짜리에서는 저장이 26초 걸렸는데 아이폰은 4.2초에 끝났다. 저장이 끝나기 24초 전이다. 함께 넣은 안전장치는 워치 데이터가 10초 끊기면 기다리는 중임을 표시하고 15초에 끝낼지 묻는다.

FINAL VERDICT

고친 것은 순서지 저장이 아니다. 워치의 저장이 왜 어떤 러닝에서만 안 끝나는지는 아직 모른다. 길이도 아니고 데이터 양도 아니었다. 6시간 41분짜리가 더 많은 데이터를 쓰고도 끝났고, 멈춘 쪽은 오히려 데이터베이스 쓰기가 더 빨랐다. 다만 그 답을 몰라도 고칠 수 있는 문제였다. 아이폰은 애초에 워치의 저장을 기다릴 이유가 없었다. 원인을 끝까지 밝히는 것과 사용자가 겪는 일을 없애는 것이 늘 같은 작업은 아니라는 걸 배웠다.