RunWay (18) 실기기 테스트 & 버그 수정
실기기 테스트를 진행하면서 발견한 버그들을 정리한다.
발견된 버그
1. 일시정지 동기화 안됨
미러링 중 위치 데이터 업데이트가 5초 이상 없으면 주도 기기는 isPaused가 걸리는데, 미러링 기기는 그 상태를 전달받지 못해 계속 러닝 중인 것처럼 표시된다.
Before
미러링 기기(startOrigin == .remote)는 start()를 호출하지 않고, startStream()에서도 FlightData 스트림을 돌리지 않는다.
타이머 자체가 없으니 lastReceivedTime 기준 자체 pause 판단도 동작하지 않는다. 결국 주도 기기가 일시정지 상태가 되어도 미러링 기기는 그 상태를 전달받을 방법이 없어 계속 러닝 중인 것처럼 표시됐다.
After
isPaused 전용 메시지 타입(pauseData)을 별도로 만들어서 주도 기기가 일시정지 상태가 되는 순간 즉시 상대 기기로 전달하도록 했다. sendFlightData()는 throttle이 걸려 있어 최대 3초 지연이 있는 반면, sendPauseData()는 상태 변화 시점에 바로 호출되기 때문에 응답성이 더 빠르다.
1
2
3
4
5
6
7
8
// sendPauseData — iOS / watchOS 동일
func sendPauseData(_ pause: Bool) {
let message: [String: Any] = [
"type": "pauseData",
"isPaused": viewModel?.isPaused ?? false
]
session.sendMessage(message, replyHandler: nil)
}
타이머에서 isPaused = true가 세팅되는 시점에 바로 호출한다.
1
2
3
4
5
if isRunning && Date().timeIntervalSince(lastReceivedTime) >= 5 {
timerCancellable.removeAll()
isPaused = true
watchConnectivityService.sendPauseData(isPaused)
}
didReceiveMessage()에서는 pauseData 타입을 별도 분기로 처리한다.
1
2
3
4
5
6
7
if let type = message["type"] as? String, type == "pauseData" {
let isPaused = message["isPaused"] as? Bool ?? false
Task { @MainActor in
vm?.isPaused = isPaused
}
return
}
러닝이 재개될 때는 flightData 메시지가 다시 들어오는 시점에 isPaused = false로 리셋한다. 데이터가 수신된다는 것 자체가 러닝이 재개됐다는 신호이기 때문이다.
1
2
3
4
5
Task { @MainActor in
vm?.flightData = flightData
vm?.elapsedTime = elapsedTime
vm?.isPaused = false
}
2. Pause 개선
기존에는 isPaused 상태가 되면 뷰 전체를 덮어버리는 방식이었다. 테스트도 불편하고, 실사용에서 일시정지 상태에서 러닝을 종료하려 해도 overlay가 터치를 가로채 종료 버튼 자체가 눌리지 않는 문제가 있었다.
overlay에 .allowsHitTesting(false)를 추가해서 터치가 뒤로 통과되도록 바꿨다. 이제 일시정지 상태에서도 종료 버튼을 그대로 누를 수 있다. 상태를 안내하는 텍스트도 함께 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// iPhone PFDView
Color.rwBg.opacity(0.85)
.ignoresSafeArea()
.allowsHitTesting(false)
VStack(spacing: 12) {
Image(systemName: "pause.circle.fill")
.font(.system(size: 44))
.foregroundColor(.rwAmber)
Text("PAUSED")
.font(.orbitron(20, weight: .bold))
.foregroundColor(.rwAmber)
.kerning(3)
Text("AWAITING SIGNAL")
.font(.orbitron(11, weight: .regular))
.foregroundColor(.rwMuted)
.kerning(1.5)
}
.allowsHitTesting(false)
// Watch WatchPFDView
Color.rwBg.opacity(0.85)
.ignoresSafeArea()
.allowsHitTesting(false)
VStack(spacing: 8) {
Image(systemName: "pause.circle.fill")
.font(.system(size: 32))
.foregroundColor(.rwAmber)
Text("PAUSED")
.font(.orbitron(14, weight: .bold))
.foregroundColor(.rwAmber)
.kerning(2)
Text("AWAITING SIGNAL")
.font(.orbitron(9, weight: .regular))
.foregroundColor(.rwMuted)
.kerning(1.5)
}
.allowsHitTesting(false)
GPS 데이터가 다시 들어오면 startStream()에서 자동으로 해제된다.
3. elapsedTime 싱크 문제
elapsedTime은 FlightData 구조체가 아닌 ViewModel에서 별도 타이머로 관리되어 미러링 기기에 전달되지 않고 있었다. 미러링 기기는 타이머 자체가 없으니 항상 0으로 표시됐다.
sendFlightData()에 포함시키는 방법도 있었지만 3초 throttle 때문에 화면에서 숫자가 3초마다 뚝뚝 튀는 문제가 있었다. 그래서 elapsedTime만 별도 함수로 분리해서 타이머에서 1초마다 전송하는 방식으로 갔다. 페이로드가 작아서 블루투스 부담도 크지 않다.
1
2
3
4
5
6
7
func sendElapsedTime(_ time: Int) {
let message: [String: Any] = [
"type": "elapsedTime",
"elapsedTime": time
]
session.sendMessage(message, replyHandler: nil)
}
타이머에서 1초마다 호출한다.
1
2
elapsedTime += 1
watchConnectivityService.sendElapsedTime(elapsedTime)
didReceiveMessage()에도 분기를 추가했다. iOS/watchOS 양쪽 동일하게 적용한다.
1
2
3
4
5
6
7
if type == "elapsedTime" {
let elapsedTime = message["elapsedTime"] as? Int ?? 0
Task { @MainActor in
vm?.elapsedTime = elapsedTime
}
return
}
4. 종료 시나리오별 동기화 문제
실기기 테스트 결과 시나리오별로 다른 문제가 확인됐다.
- iPhone 주도 미러링
- 앱 종료: 정상 작동
- Watch 종료 후 Watch 주도 미러링 시도: iPhone이 반응하지 않음. 단, 이후 iPhone 주도로 한 번 더 러닝을 하고 나면 Watch 주도 미러링이 다시 가능해짐.
resetState()에서startOrigin이 리셋되지 않아 이전 세션 상태가 남아있는 것으로 추정.
- Watch 주도 미러링
- 앱 종료: 정상 작동
- Watch 종료: Watch가 Summary 없이 바로 홈으로 돌아감.
stopOrigin = .local임에도 TOUCHDOWN → Summary 흐름을 타지 않는 것으로 보임.
sink의 print를 사용해 출력해보기
일단 워치에서 워치종료를 하면 home으로 안가지고 summary로 가졌다. 위에서 발견한 시나리오와는 다르다.
- 워치시작 워치 종료 (정상)
1 2 3 4 5
receive subscription: (PassthroughSubject) request unlimited The session has completed activation. receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.running, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: nil, startOrigin: Optional(RunWayWatch_Watch_App.StartOrigin.local))) receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.stopped, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: Optional(RunWayWatch_Watch_App.StopOrigin.local), startOrigin: nil))
여기서부터 문제
- 앱 시작 워치 종료
1 2
receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.running, runningMode: RunWayWatch_Watch_App.RunningMode.standalone, stopOrigin: nil, startOrigin: Optional(RunWayWatch_Watch_App.StartOrigin.remote))) receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.stopped, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: Optional(RunWayWatch_Watch_App.StopOrigin.local), startOrigin: nil))
- 워치로 미러링 시작 (엡에서 미러링 안됨)
1 2
receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.running, runningMode: RunWayWatch_Watch_App.RunningMode.standalone, stopOrigin: nil, startOrigin: Optional(RunWayWatch_Watch_App.StartOrigin.local))) receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.stopped, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: Optional(RunWayWatch_Watch_App.StopOrigin.local), startOrigin: nil))
- 이후 앱으로 미러링 시작 (워치 미러링 됨)
1 2
receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.running, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: nil, startOrigin: Optional(RunWayWatch_Watch_App.StartOrigin.remote))) receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.stopped, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: Optional(RunWayWatch_Watch_App.StopOrigin.remote), startOrigin: nil))
- 이후 워치로 미러링 시작 워치 종료 (home 비정상)
1 2 3
receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.running, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: nil, startOrigin: Optional(RunWayWatch_Watch_App.StartOrigin.local))) receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.stopped, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: Optional(RunWayWatch_Watch_App.StopOrigin.local), startOrigin: nil)) receive value: (SessionStateEvent(state: RunWayWatch_Watch_App.WorkoutSessionState.stopped, runningMode: RunWayWatch_Watch_App.RunningMode.mirrored, stopOrigin: Optional(RunWayWatch_Watch_App.StopOrigin.remote), startOrigin: nil))
startOrigin / stopOrigin 리셋 누락
5번에서 .stopped, stopOrigin: .remote가 한 번 더 오는 게 문제였다. Watch가 stopOrigin: .local로 sendStopSignal()을 iPhone에 보내면, iPhone handleStopSignal()이 updateAndSendState(.stopped, stopOrigin: .remote)를 발행하고, 이게 Watch sessionStatePublisher로 다시 흘러들어와 resetState()를 트리거하는 구조였다.
근본 원인은 resetWorkout()에서 startOrigin과 stopOrigin을 초기화하지 않아 이전 세션의 상태가 남아있었던 것이다. 두 값을 resetWorkout()에 추가했다.
1
2
3
4
5
6
7
func resetWorkout() {
builder = nil
workout = nil
session = nil
startOrigin = nil
stopOrigin = nil
}
이후 5번 시나리오에서 .remote가 다시 오지 않는 것을 확인했다.
앱 주도 미러링에서 Watch 종료 후 Watch 주도 미러링 불가
앱 주도 미러링 상태에서 Watch가 종료하면, 이후 Watch에서 새로 주도로 미러링을 시작해도 iPhone이 반응하지 않는 문제였다.
retrieveRemoteSession()에 로그를 추가해서 확인해보니 핸들러 등록 자체는 정상이었다.
1
retrieveRemoteSession: handler registered
Watch가 미러링을 시도해도 핸들러가 전혀 불리지 않다가, 앱에서 다시 미러링을 한 번 거치고 나서야 비로소 핸들러가 발동됐다.
1
retrieveRemoteSession handler fired: state=1
state=1은 HKWorkoutSessionState.notStarted의 rawValue로, 앱이 새로 미러링을 시작하면서 세션이 생성됐지만 아직 .running 상태가 아닌 시점에 핸들러가 불린 것이다. 즉 Watch가 시도한 미러링이 아니라 앱이 다시 주도로 시작한 세션을 잡은 것이었다.
원인은 iPhone 주도 미러링에서 Watch가 종료해도 iPhone 쪽 HKWorkoutSession이 살아있는 채로 남아있어서, HKHealthStore가 “아직 세션 중”으로 인식하고 새 미러링 신호를 무시하는 것이었다. retrieveRemoteSession()을 재호출해도 해결되지 않았고, 세션을 명시적으로 종료해야 했다.
iPhone 쪽 resetWorkout()에 session?.end()를 추가했다.
1
2
3
4
5
6
7
8
9
// HealthKitService+iOS
func resetWorkout() {
session?.end()
builder = nil
workout = nil
session = nil
startOrigin = nil
stopOrigin = nil
}
이후 Watch 주도 미러링 핸들러가 정상적으로 발동되는 것을 확인했다.
1
retrieveRemoteSession handler fired: state=2
state=2는 HKWorkoutSessionState.running의 rawValue로, Watch가 이미 러닝 중인 세션을 미러링으로 전달했다는 의미다. 기존에는 핸들러 자체가 발동되지 않아 이 로그가 찍히지 않았다.
정리
여기도 간단하게 정리를 해보려 한다.
실기기 테스트를 통해 발견한 버그들을 하나씩 잡아나갔다. 일시정지 동기화는 sendPauseData()를 별도로 만들어 해결했고, Pause overlay는 .allowsHitTesting(false)로 종료 버튼 접근성을 확보했다. elapsedTime 싱크는 1초마다 별도 전송하는 방식으로 해결했다. 미러링 세션 관련 두 문제는 resetWorkout()에서 startOrigin/stopOrigin 초기화 누락과 iPhone 세션 미종료가 원인이었고, 각각 resetWorkout()에 nil 초기화와 session?.end() 추가로 해결했다.


