포스트

RunWay 1.4 (11) 걸어서 얻은 것과 못 얻은 것

고친 것들을 들고 11.3km 를 걸었다. 1시간 53분이다. 지금까지 만든 실기기 기록 중 제일 길다.

한 트랙으로만 했다. 앱에서 시작하고 워치에서 끝냈고, 중간에 끄거나 다시 시작하지 않았다. 조건을 하나로 고정해야 결과를 해석할 수 있어서다.


3초로 끝난 시계 확인

멈춰 있던 시간이 기록 쪽 시계에만 계속 쌓이던 문제를 고쳤었다. 3분짜리 러닝에서 4분으로 나오던 것이다. 그게 두 시간짜리에서도 맞게 도는지가 이번에 제일 보고 싶던 것이었다.

요약 화면의 TIME 과 FLIGHT DATA RECORDER 의 끝 T+ 가 3초 차이로 맞은 화면

1
2
3
요약 TIME      01:53:21
FDR 끝 T+      01:53:18
차이           3초

표본을 5초마다 담으니 마지막 표본 뒤에 끝낸 몇 초는 원래 안 들어간다. 3초는 그 안쪽이다. 멈춘 시간은 정확히 빠졌다.

중간에 서 있던 구간도 같이 봤다. PACE 줄이 걷는 내내 이어지다가 멈춰 선 자리에서만 끊겼다. 둘 다 이어지면 너무 풀어준 것이고 둘 다 끊기면 안 고쳐진 것인데, 가르는 쪽으로 나왔다.


쓸모없이 넓던 고도 눈금

걷는 중에 계기판을 보다가 오른쪽 ALT 가 눈에 걸렸다.

왼쪽은 눈금이 20m 간격이던 화면, 오른쪽은 5m 로 바꾼 화면

왼쪽이 고치기 전이다. 지금 고도가 -4 인데 눈금은 +20 부터 -20 까지 간다. 40m 폭이다. 오른쪽이 바꾼 뒤고 +10 부터 -10 까지다.

1
2
3
let step = 10.0
let center = (currentAltitude / step).rounded()
return [-2, -1, 0, 1, 2].map { Int((center + Double($0)) * step) }.reversed()

비행기 고도계를 본떠서 10m 간격으로 잡아뒀었다. 비행기는 그게 맞지만 러닝에서 그 폭은 거의 언제나 빈칸이다. 산책로를 걸으면 눈금 다섯 칸이 하나도 안 움직인다.

왼쪽 SPD 와 비교하면 차이가 분명하다.

1
2
SPD   15:15 ~ 14:15      약 1분 폭
ALT   +20 ~ -20          40m 폭

2m 로 줄였다가 5m 로 되돌린 이유

처음엔 2m 로 잡았다. 테이프가 항상 현재값을 가운데 두니 간격을 좁혀도 범위를 벗어날 일은 없고, 언덕을 오르면 숫자가 빨리 흐를 뿐인데 그건 실제로 빨리 오르고 있다는 뜻이라 틀린 표시가 아니다.

그런데 이번 기록이 숫자를 줬다.

1
2
총 상승고도    27m
짚은 고도      -4 와 +3 (최소 7m 차이)

산책로라도 10m 안팎은 오르내린다. 2m 간격이면 보여주는 폭이 ±4m 라, 완만한 내리막 하나에 눈금이 통째로 흘러간다. 숫자가 계속 바뀌면 그것대로 안 읽힌다.

간격보이는 폭 
10m±20m아무것도 안 움직인다
5m±10m이 기록의 폭이 들어온다
2m±4m계속 흐른다

5m 로 정했다. 추측으로 두 번 바꾸는 것보다 한 번 걸어보고 재는 쪽이 빨랐다.


소리만 나던 1km 알림

1km 를 채우면 음성으로 알려주는데, 바람 소리나 음악에 묻힌다. 진동을 같이 넣었다.

GPWS 경고와 헷갈리면 안 되므로 패턴을 따로 뒀다. 경고는 이미 유형별로 다른 진동을 쓰고 있다.

1
2
3
SINK RATE    directionUp     2번, 0.45초 간격
OVERSPEED    directionDown   4번, 0.15초 간격
MINIMUMS     notification    1번

워치는 방향성 진동과 notification 을 이미 쓰고 있어서, 남아 있는 success 를 짧게 두 번 울린다. 아이폰은 경고가 한 번 세게 울리니 같은 세기로 두 번 울려서 횟수로 가른다.

1
2
3
4
for await split in await runningCenter.streamSplitAnnouncements() {
    self.playSplitHaptic()
    self.speechAnnouncerService.announce(split: split)
}

음성이 나가는 바로 그 자리에 넣었다. 같은 조건으로 같이 나간다.


실기기에서도 안 끌린 화면

표본이 1000개를 넘기면서 두 자리가 끊겼었다. 요약에서 스플릿을 스크롤할 때와 FLIGHT DATA RECORDER 로 넘어갈 때다.

줄마다 전체를 다시 정렬하던 것, 기준선을 매 프레임 평균 내던 것, 매 프레임 배열을 새로 만들던 것, 그리고 화면이 들어오는 중에 저장소를 읽던 것. 네 군데였다.

이번 기록은 표본이 1300개가 넘는다. 전환도 드래그도 끌리지 않았다. 시뮬레이터에서 고친 것이 실기기에서도 그대로 맞았다.


처음으로 다른 답이 나온 분류기

3km 뛰고 6km 걷고 3km 뛰었다. 한 트랙 안에 두 가지 속도가 섞인 셈이다.

스플릿 목록 위에 불규칙 배지가 붙은 화면

1
2
3
4
1km   6:32
2km   6:44
3km   6:47
평균   10:00 /km

앞 3km 가 6분대인데 전체 평균은 10분이다. 뒤쪽 걷기 구간이 평균을 끌어내렸다.

분류는 불규칙으로 나왔다. 구간 간 편차가 평균의 15% 를 넘으면 경향을 따지지 않고 그렇게 본다. 뛰다 걷다 뛴 기록이니 맞는 답이다.

시뮬레이터로 돌린 10km 는 같은 분류기가 일정함 이라고 했었다. 서로 다른 입력에 서로 다른 답이 나온 것을 처음 봤다.


안 나온 것

워치에서 끝냈는데 아이폰이 안 끝나는 문제는 이번에 재현되지 않았다. 두 시간을 걸었는데도 그렇다.

긴 러닝을 모아보면 이렇게 된다.

시간결과
40분실패
45분성공
62분성공
90분실패
114분성공
113분성공

일곱 번 중 두 번이다. 길다고 반드시 나는 것이 아니라는 데이터가 하나 더 쌓였다.

정상일 때의 모양

안 났으니 대신 정상일 때 로그가 어떻게 생겼는지를 받아뒀다. 다음에 실패한 로그가 나오면 나란히 놓고 뭐가 빠졌는지 볼 수 있다.

1
2
3
4
5
[receive]   sentAt=1791119188.17634 세션시작=1791112064.5708818 통과=true
[handle]    stopOrigin 을 remote 로 두고 상태 발행
[vm.sink]   인스턴스=ObjectIdentifier(0x0000000108d91180) 처리중=false 러닝중=true
[vm.done]   정리 끝, navigationPath 비움
[pfd.gone]  PFD 가 화면에서 빠짐

다섯 줄이 전부 찍혔고 끊긴 데가 없다. 시각을 재면 이렇게 나온다.

1
2
3
4
워치가 보냄   22:06:28.176
받음         22:06:28.576    배달 0.40s
정리 끝       22:06:28.725    저장 149ms
화면 빠짐     22:06:29.239    전환 514ms

워치 버튼을 누른 순간부터 화면이 빠질 때까지 1.4초다. 전에 5.3km 에서 5초쯤 기다렸던 것과 다르다. 그 차이는 로그가 시작되기 전 구간, 그러니까 워치가 버튼을 받고 신호를 보내기까지에 있다.

눈여겨볼 게 둘 더 있다. vm.sink 가 한 줄이고, 두 시간 간격으로 끝낸 두 러닝의 인스턴스 주소가 같다. 화면이 둘로 갈라지는 경우는 아니었다는 뜻이다. 그리고 watch.silent 가 없다. 60초 안전망이 돌지 않았으니 정상으로 끝난 것이 맞다.

세션 시작과 보낸 시각의 차이는 118.7분인데 요약 TIME 은 113.4분이다. 두 시간을 걷는 동안 신호등 같은 데서 5분 24초를 서 있었고, 그게 전부 빠졌다.

다만 짧은 러닝은 다섯 번 다 성공했다. 임계값이 아니라 확률로 보면 길이는 여전히 후보로 남는다.

지금 빌드에는 조용히 빠져나가던 자리 세 곳에 로그가 들어가 있고, 워치가 1분 넘게 조용하면 끝낼지 묻는 알림도 있다. 다음에 나면 그때 잡힌다. 그때까지는 평소대로 뛰면서 기다리는 수밖에 없다.


재현 대신 고른 길

여기서 하나가 분명해졌다. 두 시간을 걸어도 안 나는 버그는 걸어서 못 잡는다. 일곱 번 중 두 번이고, 길이로도 조건으로도 선이 안 그어진다. 이쯤 되면 재현으로 잡는 건 운에 기대는 일이다.

다른 길을 하나 택했다. 버튼에서 신호까지 가는 길을 코드로 끝까지 펴보는 것. 운에 안 기대고, 나가지 않아도 된다.

AI 에게 그 구간 전체를 훑어달라고 했다. 사람이 읽으면 이미 아는 곳을 또 읽게 되는데, 처음 보는 쪽은 그냥 한 줄씩 따라간다.


열한 걸음짜리 체인

버튼을 누르고 아이폰에 신호가 닿기까지 거치는 자리를 전부 세어보니 이렇게 나왔다.

위쪽 줄이 정상 경로고 맨 아래 줄이 조용히 돌아서는 자리다. 각 상자에 붙은 작은 글자가 지금 찍히는 로그 이름이다.

버튼 하나가 이만큼을 거친다. 중간 어디서 멈춰도 밖에서는 똑같이 “아무 일도 안 일어난 것”으로 보인다.


조용히 돌아서는 네 자리

세 자리는 전에 찾아뒀다. 이번에 훑으면서 하나가 더 나왔고, 그보다 중요한 건 어디에 흔적이 없는지가 분명해진 것이다.

세션이 없을 때

1
2
3
4
func stopWorkout() {
    stopOrigin = .local
    session?.stopActivity(with: Date())
}

옵셔널 체이닝이다. 세션이 nil 이면 아무 일도 안 하고 그냥 지나간다. 상태 변화 콜백이 없으니 그걸 받아 신호를 보내는 자리도 안 돈다.

로그를 한 줄 넣어뒀었는데 있다/없다만 적고 있었다. 세션이 살아는 있지만 이미 끝난 상태면 stopActivity 를 불러도 콜백이 안 오는데, 그 경우를 못 가린다. 상태값까지 남기도록 고쳤다.

상태가 stopped 로 안 올 때

1
2
3
4
5
6
if toState == .stopped {
    // 발행
} else if toState == .running {
    // 발행
}
// 그 외는 아무것도 안 한다

이번에 새로 찾은 자리다. .ended 로 바로 넘어가면 어느 갈래에도 안 들어간다. 이벤트가 아예 안 나가고, 종료 신호는 그 이벤트를 받아야 나간다.

두 갈래만 적어두면 나머지는 없는 것처럼 느껴지는데, 실제로는 나머지로 빠졌을 때 가장 조용하다.

마무리가 안 끝날 때

1
2
3
4
if toState == .stopped {
    await finishWatchWorkout(at: date)   // 이게 안 끝나면
    updateAndSendState(event)            // 여기가 영영 안 돈다
}

finishWatchWorkout 안에는 HealthKit 의 endCollection 과 finishWorkout 이 들어 있다. 워크아웃을 닫고 건강 앱에 쓰는 일이다.

긴 러닝일수록 정리할 데이터가 많다. 길이와 상관이 있으면서 간헐적이라는 성질이 둘 다 맞아떨어지는 유일한 자리다. 지금 가장 유력하게 보고 있다.

보내지 않기로 할 때

1
2
3
if HealthKitService.shared.startOrigin != .local {
    watchConnectivityService.sendStopSignal()
}
1
guard WCSession.default.activationState == .activated else { return }

둘 다 이른 return 이라 아무 말도 안 남긴다. 두 번째는 실제로 한 번 걸리는 걸 봤다. 앱이 막 켜진 직후, 세션 활성화가 끝나기 전에 종료를 누른 경우였다.


열세 개로 늘린 흔적

빈 자리마다 한 줄씩 넣었다. 이제 체인에 끊긴 데가 없다.

1
2
3
4
watch.tapped → watch.saved → stopWorkout → watch.state
  → watch.finishing → watch.finished → watch.published
  → watch.sink → send.message / send.queued / send.notActivated
  → receive → vm.sink → vm.done → pfd.gone

마지막으로 찍힌 줄이 어디서 멈췄는지 가리킨다. 재현이 되든 안 되든, 나면 그때 잡힌다.

watch.sink 는 아이폰의 vm.sink 와 같은 이유로 넣었다. 구독이 살아 있는 인스턴스와 화면에 떠 있는 인스턴스가 다르면 여기까지 와도 아무 일이 안 난다. 아이폰 쪽은 그 주소를 찍고 있었는데 워치 쪽은 안 찍고 있었다.


이렇게 해서 얻은 것

원인은 아직 모른다. 네 자리 중 어디인지도 모르고, 다섯 번째가 없다고 말할 수도 없다.

대신 “아무 일도 안 일어났다”는 상태가 없어졌다. 전에는 증상이 나도 받아올 게 없었는데, 이제는 어디까지 갔는지가 남는다.

그리고 훑는 동안 재현을 기다리지 않아도 됐다. 2시간을 걸어서 안 나온 날에도 코드는 읽을 수 있었다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.