설계 결정 네 가지
동시성 처리, 상태 관리, 기기 간 동기화, GPWS 판정 로직 - RunWay를
만들며 가장 많이 고민한 네 가지 지점입니다.
“GPS 업데이트가 연속적으로 들어오면서 페이스, 거리, GPWS 판정까지 매번 계산해야 하므로, 이 계산 로직을 RunningCenter Actor로 격리해 상태 무결성을 보장했습니다. 심박수와 케이던스는 WatchConnectivity가 받아서 판정 로직 없이 값만 스레드 안전하게 들고 있는 HealthCenter Actor에 넘기고, RunningCenter가 필요할 때 그 값을 가져가 @Observable ViewModel로 전달합니다.”
전체 시스템맵
GPS·심박·고도 센서부터 화면, 저장, 아이폰↔워치 동기화까지 실제 앱을 구성하는
17개 컴포넌트와 그 사이를 오가는 데이터를 한 장으로 그렸습니다.
모든 러닝 계산의 중심. GPS/심박/고도를 받아 거리·페이스·GPWS·스플릿을 계산해 FlightData 스트림으로 내보낸다.
1actor RunningCenter {2 private(set) var totalDistance: Double = 03 private(set) var gpwsStatus: GPWSState = .normal4 private(set) var isStationary: Bool = false56 func processLocation(_ location: CLLocation) async {7 if location.speedAccuracy >= 0 {8 }9 continuation?.yield(flightData)10 }11}- ▸누적 거리 계산, 이중 지수 이동평균(Double EMA)으로 페이스 스무딩
- ▸raw 속도 기반 정지 판단(isStationary), speedAccuracy 신뢰도 체크
- ▸GPWS(SINK RATE/OVERSPEED/MINIMUMS) 판정, 정지 중엔 normal로 리셋
- ▸km 단위 스플릿 기록, 상대 고도 스무딩과 이상치 필터링
iOS와 Watch는 각자 GPS·심박·고도를 따로 받기 때문에, RunningCenter도 기기마다 독립된 인스턴스로 돈다. 정지 판단은 GPS 신뢰도(speedAccuracy)가 낮은 샘플을 걸러가며 확정하는데, 처음엔 이 체크가 정지 판단에만 걸려있고 페이스 계산용 속도 스무딩에는 안 걸려있었다. 오버스피드에 반응해 급감속하는 순간 신뢰도가 자주 음수로 찍히는 바람에, 정지 확정은 안 되는데 스무딩 값만 계속 낮아져 페이스가 무한정 부풀다 앱이 죽는 실사용 버그로 이어졌다. 스무딩 갱신을 같은 신뢰도 체크 안으로 옮기는 것으로 1.3.1에서 해결했다.