RunWay 1.4 (6) 납작한 페이스 줄과 안 돌아오는 PFD
Flight Data Recorder를 만들어놓고 3km를 왕복으로 뛰었다. 시뮬레이터로는 끝까지 확인할 수 없던 것들이 거기서 나왔다. 그래프가 못 쓸 모양으로 나온 것, 그 원인이 엉뚱한 데서 한 번 더 터진 것, 그리고 아직 원인을 못 잡은 것 세 가지다.
바닥에 붙어버린 페이스 줄
심박도 케이던스도 고도도 제대로 그려지는데 페이스 줄만 바닥에 붙은 평평한 선이었다.
판독 패널의 첫 값이 554:23이었다. 1km를 554분에 간다는 뜻이고, 시속으로 치면 0.18km다. 사람이 낼 수 있는 값이 아니다.
여기서부터 여섯 번을 고쳤다. 고칠 때마다 다음 문제가 드러났다.
속도가 0에 가까울 때의 페이스 폭주
페이스는 1 / 속도로 구한다. 속도가 0에 가까워지면 이 값은 끝없이 커진다.
RunningCenter에는 시작 직후 5초 동안 정지 판단을 보류하는 구간이 있다. 시작 버튼을 누르자마자 “일시정지”로 뜨면 안 되니까 일부러 둔 것이다. 그런데 그 5초 동안 스무딩된 속도는 0에서 올라오는 중이다. 그 사이에 표본이 찍히면 몇백 분/km가 그대로 저장된다.
그래서 그 구간에서는 페이스를 아예 기록하지 않기로 했다. 0은 이미 “기록 없음”으로 읽히도록 해둔 값이라 선이 끊기고 판독 패널에도 --:--로 뜬다.
시작 구간만 고쳐서는 안 되는 이유
고치고 다시 보니 그대로였다. 첫 점이 이미 --:--인 기록에서도 페이스 줄만 바닥에 붙어 있었다.
시작 구간만의 문제가 아니었다. 속도가 0에 가까워지는 순간은 러닝 내내 생긴다. 신호에 걸릴 때, 코너를 돌 때, 왕복이면 돌아서는 지점에서. 그때마다 정지로 확정되기까지 2초 정도가 걸리고, 그 2초 동안 같은 폭주가 일어난다.
그런데 애초에 한 점이 세로 폭 전체를 차지하는 건 그리기 쪽 문제다. 줄의 세로 범위를 최소값과 최대값으로 잡고 있었기 때문이다. 그래서 양 끝 2%를 떼고 나머지로 범위를 잡도록 바꿨다.
2%로 걸러지지 않는 걷기
이번엔 다른 러닝에서 걸렸다. 숨 고르려고 중간중간 걸었더니 12분대가 한참 이어진 기록이었다. 달린 구간이 여전히 바닥에 눌려 있었다.
걸은 건 튄 값이 아니다. 진짜로 그 페이스로 간 것이고, 러닝의 4분의 1을 차지하면 2%를 떼봐야 아무 소용이 없다. 비슷한 데이터를 만들어 돌려봤다.
| 범위 잡는 법 | 달린 구간이 차지하는 세로 폭 |
|---|---|
| 최소~최대 | 21% |
| 양 끝 2% 제거 | 21% |
| 보통 모여 있는 범위 | 47% |
2% 제거가 전혀 듣지 않는다는 게 숫자로 나왔다.
그래서 끝값이 아니라 값들이 보통 어디쯤 모여 있는지로 폭을 잡도록 바꿨다. 가운데 값을 찾고, 각 값이 거기서 얼마나 떨어져 있는지의 가운데 값을 구해 그 세 배를 위아래로 둔다.
1
2
3
4
5
6
7
8
9
10
11
12
13
private var typicalRange: (min: Double, max: Double) {
let sorted = validValues.sorted()
guard sorted.count >= 20 else {
return (sorted.first ?? 0, sorted.last ?? 0)
}
let center = median(of: sorted)
let spread = median(of: sorted.map { abs($0 - center) }.sorted())
guard spread > 0 else {
let cut = max(1, sorted.count / 50)
return (sorted[cut], sorted[sorted.count - 1 - cut])
}
return (center - spread * 3, center + spread * 3)
}
멀리 떨어진 값이 꽤 많아도 이 “보통”은 흔들리지 않는다. 걷기가 4분의 1을 차지해도 가운데 값은 여전히 달린 구간에 있다. 2% 제거와 결정적으로 다른 점이 그거다. 2% 제거는 “몇 개만 이상할 때”만 통하는데 이건 5분의 1이 달라도 통한다.
세 방법을 같은 데이터에 나란히 걸어봤다. 걷기 비율을 바꾸면 어디서 갈라지는지 보인다.
걷기 0%에서는 세 방법이 거의 같다. 2% 제거가 듣는 것처럼 보이는 건 이 상태에서 본 결과다.
걷기 1%에서 갈린다. 324개 중 3개라 양 끝에서 떼어내는 6개 안에 들어가고, 2% 제거가 범위를 되살린다. 폭주 한 점을 넣어봐도 마찬가지다. 점 하나는 0.3%라 2% 안이다.
그런데 걷기 5%면 16개다. 떼어내는 건 여전히 6개뿐이라 나머지 10개가 그대로 남는다. 여기서부터 2% 제거는 최소~최대와 같은 값이 된다. 2%라는 숫자를 몇으로 바꿔도 마찬가지다. 걷는 양을 미리 알 수 없으니 그만큼을 떼도록 정할 방법이 없다.
중앙값은 걷기가 40%가 돼도 달린 구간 안에 있다. 절반을 넘지 않는 한 안 움직이기 때문이다.
덤으로 554분 건도 같은 식으로 처리된다. 그런 값이 하나 있어도 가운데 값은 전혀 안 움직이기 때문에, 시작 구간을 위한 특별 규칙이 따로 필요 없어진다.
맨 위의 납작한 그림과 같은 러닝이다. 저장된 값은 하나도 안 바뀌었고 그리는 규칙만 바꿨다. 판독 패널의 554:23이 그대로 있는데도 달린 구간이 줄의 절반을 쓴다.
페이스를 아예 기록하지 않기로 한 건 이 기록보다 뒤의 일이라 여기엔 그 값이 남아 있다. 이미 저장된 기록은 고칠 방법이 없으니, 그리는 쪽이 이런 값을 안고도 읽히게 만들어야 한다는 뜻이기도 하다.
값이 아닌 상태로 가르기
그런데 애초에 554분을 저장할 이유가 있나를 다시 생각했다.
값으로 자르는 건 안 된다. 12분대 걷기는 남아야 하고 554분은 지워야 하는데, 그 사이 어디에 선을 그어도 근거가 없다. 15분? 20분? 오르막을 힘들게 걷는 사람은 어쩌나.
그런데 앱이 이미 그 구분을 갖고 있었다. lowSpeedSince다. 속도가 정지 기준(0.5m/s) 아래로 떨어진 순간부터 채워지고 다시 올라가면 비워지는 값이다.
| 속도 | lowSpeedSince | 결과 | |
|---|---|---|---|
| 서 있음 | 0.5m/s 미만 | 채워짐 | 기록 안 함 |
| 걷기 | 1.4m/s 쯤 | 비어 있음 | 그대로 기록 |
| 달리기 | 2.5m/s 이상 | 비어 있음 | 그대로 기록 |
걷기는 정지 기준보다 세 배 가까이 빨라서 한 번도 안 걸린다. 값을 보고 자르는 게 아니라 “멈춰 있었냐”로 가르니 숫자를 정할 필요가 없어졌다.
1
2
3
let isWithinStartupGrace = timestamp.timeIntervalSince(runStartTime) < startupGracePeriod
let isBelowMovingSpeed = lowSpeedSince != nil
let pace = (isWithinStartupGrace || isBelowMovingSpeed) ? 0 : flightData.pace
화면에 실시간으로 뜨는 페이스는 이때 안 건드렸다. 그 값은 GPWS 판정에도 쓰이니 손대면 경고가 엉킬 거라고 봤다. 이 판단은 뒤에서 다시 뒤집힌다.
그래프를 밀어낸 목표 띠
Mission Flight 기록이라면 목표 페이스가 저장돼 있다. 그걸 페이스 줄에 같이 그으면 어느 구간이 목표 안에 있었는지가 선 모양만으로 읽힌다.
가운데 실선이 목표, 위아래 점선이 허용 오차, 그 사이는 옅게 채웠다.
여기서 띠가 줄 밖으로 밀려나지 않게 범위에 띠를 포함시켰다. 값이 목표 근처에만 머물렀다면 띠가 안 보이고, 목표를 크게 벗어났다면 그 간격이 보여야 하기 때문이다.
그랬더니 허용 오차가 넓은 러닝에서 띠가 줄을 통째로 차지했다. 정작 띠를 기준으로 봐야 할 페이스가 납작해졌다.
서로 당기는 세 규칙
정리하면 이렇게 됐다.
- 목표 띠가 보여야 한다
- 달린 구간의 변화가 보여야 한다
- 벗어난 값이 띠 위에 겹치면 안 된다
1번을 지키려고 범위를 넓히니 2번이 죽었다. 3번 때문에 여백까지 더하니 더 죽었다. 3번이 필요했던 이유는, 범위 밖 값을 줄 끝에 붙여 그리는데 그 끝이 곧 오차 점선 자리라서 한참 벗어난 페이스가 띠에 걸친 것처럼 보였기 때문이다. 6’49” 목표에 8:35가 겨우 걸친 것처럼 보였다.
1번을 반만 지키니 풀렸다. 가운데 목표선만 반드시 범위에 넣고, 오차 점선은 범위 밖이면 아예 안 그린다.
| 경우 | 띠 전체 포함 | 목표선만 |
|---|---|---|
| 변화 큼 + 오차 좁음(15초) | 51% | 56% |
| 변화 작음 + 오차 넓음(30초) | 31% | 53% |
아래 경우가 실제로 걸린 상황이다.
점선이 안 보이는 것도 정보다. 허용 오차가 내가 뛴 페이스의 변화보다 넓었다는 뜻이고, 그건 내내 여유 있게 안에 있었다는 말이다. 옅은 채우기는 줄 전체에 그대로 깔리니 “다 안쪽이었다”는 건 똑같이 보인다.
점선을 줄 끝에 눌러 그리지 않고 아예 안 그린 이유도 3번이다. 줄 끝은 범위 밖 값이 붙는 자리라, 거기 점선이 있으면 다시 같은 문제가 생긴다.
목표가 없는 러닝의 기준선
Free Flight에는 목표가 없으니 가로선도 없었다. 그런데 기준선이 없으면 선이 올라간 게 평소보다 느린 건지 아닌지를 알 수가 없다.
그래서 그 러닝의 평균 페이스를 가운데 실선으로 깔았다. 허용 오차는 없으니 점선도 채우기도 없다.
1
2
3
4
5
6
7
8
9
10
private var paceTarget: (center: Double, tolerance: Double)? {
if flight.mode == "modeA",
flight.missionTarget == ModeATarget.pace.rawValue,
flight.missionTargetPace > 0 {
return (center: flight.missionTargetPace,
tolerance: Double(flight.missionPaceDeviation) / 60.0)
}
guard flight.pace > 0 else { return nil }
return (center: flight.pace, tolerance: 0)
}
심박 목표 러닝이나 목표 거리만 정한 경우도 평균 쪽으로 간다. 둘 다 페이스 기준이 따로 없기 때문이다.
끝에 눌러 붙이는 방식의 한계
범위를 벗어난 값은 줄의 위아래 끝에 붙여서 그리고 있었다. 그 구간이 통째로 사라지면 안 되니까 그렇게 뒀는데, 여기서 다른 문제가 나왔다.
±40초로 잡은 러닝에서 8분 50초짜리 구간을 짚었더니, 오차 점선 바로 위에 찍혀서 겨우 걸친 것처럼 보였다. 목표가 6분 49초니까 2분이나 벗어난 건데.
끝에 붙이면 정상적으로 끝 근처에 있는 값과 모양이 똑같아진다. 처음엔 자리를 벌려보려고 위아래로 여백을 뒀고, 그다음엔 벗어난 구간을 흐리게 그려봤다. 둘 다 근본 해결이 아니었다. 여백을 늘리면 그래프가 다시 납작해지고, 범위를 좁히면 띠가 줄을 더 차지해서 위 공간이 오히려 줄어든다.
눌러 붙이는 걸 그만두니 풀렸다. 값을 실제 자리에 그리고 줄 경계에서 자르면, 선이 위나 아래로 빠져나가며 사라진다.
1
2
3
// 범위를 벗어난 값은 줄 밖 좌표로 그대로 둔다. Canvas 가 줄 경계에서 잘라주므로
// 선이 위나 아래로 빠져나가며 사라진다.
let normalized = span > 0 ? (value - range.min) / span : 0.5
선이 줄 밖으로 나가는 것 자체가 “여기서 벗어났다”다. 끝에서 평평하게 멈추는 것과 혼동될 일이 없다. 눌러 붙이는 걸 설명하려고 만들었던 여백, 흐리게 그리기, 속이 빈 점도 같이 걷어냈다.
대신 얼마나 벗어났는지는 그림이 말해주지 않는다. 2분 벗어난 것과 5분 벗어난 것이 똑같이 “밖으로 나감”이다. 그건 그 자리를 짚어서 숫자로 본다.
설명 없이는 거꾸로 읽히는 그래프
여기까지 오고 보니 이 화면은 보통의 그래프와 다르게 읽어야 하는 지점이 여러 군데가 됐다.
- 세로 폭이 끝값이 아니라 값들이 보통 모여 있는 범위다
- 거기서 벗어난 값은 선이 줄 밖으로 빠져나가며 사라진다
- 값이 없는 구간도 선이 끊긴다
두 번째와 세 번째가 겹친다. 선이 안 보이는 데가 “벗어났다”일 수도 있고 “기록이 없다”일 수도 있다. 구별은 선이 끝에서 끊겼는지 밖으로 나갔는지로 하는데, 그걸 설명 없이 알아내길 기대할 수는 없다.
페이스 줄은 위로 빠져나가 사라진 데가 있고, 심박과 케이던스는 가운데가 통째로 끊겨 있다. 앞은 “벗어났다”고 뒤는 “기록이 없다”인데, 그림만 보면 둘 다 선이 없는 자리다.
허용 오차도 화면에 안 적혀 있었다. 띠가 줄의 대부분을 차지해도 그게 ±40초로 잡아서인지 그래프 탓인지 알 방법이 없었다. 미션 바에 PACE 6'49"/km ±40s처럼 같이 적었다.
그리고 헤더에 정보 버튼을 달고 읽는 법을 적었다. 네 줄이 뭔지, 세로 폭을 어떻게 잡는지, 선이 끊기면 무슨 뜻인지, 페이스 줄의 가로선이 뭔지, 코스는 어떻게 보는지.
페이스 줄 설명은 보고 있는 기록에 따라 문구가 바뀐다. 목표 띠가 있는 기록과 평균선만 있는 기록은 읽는 법이 다르기 때문이다.
평균선만 그려지는 Free Flight 기록을 보고 있을 때의 문구다. Mission Flight 기록이면 “목표 페이스와 허용 오차” 쪽으로 바뀐다.
iCloud 백업을 통째로 막던 무한대
그래프를 다 고친 뒤에 설정에서 iCloud 백업을 눌러봤다. 한 개도 올라가지 않고 이 문구가 떴다.
데이터가 올바른 포맷이 아니기 때문에 기록할 수 없습니다.
CloudKit이 뭔가 거절한 줄 알았는데 아니었다. 이 문구는 JSONEncoder가 던지는 에러를 번역한 것이다. 같은 상황을 따로 만들어보면 바로 나온다.
1
2
3
4
5
6
7
8
struct S: Codable { let pace: Double }
do {
_ = try JSONEncoder().encode([S(pace: Double.infinity)])
} catch {
print(error.localizedDescription)
print(error)
}
1
2
3
The data couldn't be written because it isn't in the correct format.
EncodingError.invalidValue: inf (Double). Path: [0].pace.
Debug description: Unable to encode Double.inf directly in JSON.
JSON에는 무한대를 적는 방법이 없다. 그래서 인코더는 값을 건너뛰지 않고 에러를 던지고 멈춘다.
같은 나눗셈이 남긴 값
pace라는 경로가 나와 있으니 찾는 건 금방이었다. 앞에서 내내 다룬 그 줄이다.
1
rawPace = 1 / (smoothingSpeedSecond * 60 / 1000)
앞에서는 이 값이 크다는 쪽만 봤다. 554분/km 같은 값이 그래프를 납작하게 만드는 것까지였다. 그런데 smoothingSpeedSecond가 정확히 0이면 크기만 커지는 게 아니라 무한대가 된다.
0에 머무는 경로가 있다. 바로 위에 이런 게이트가 있다.
1
2
3
if location.speedAccuracy >= 0 {
// 정지 판단과 속도 스무딩
}
speedAccuracy가 음수면 그 샘플의 속도 자체를 기기가 신뢰하지 않는다는 뜻이라 아예 반영하지 않는다. 실내나 지하에서 시작하면 이런 샘플만 한동안 들어온다. 그동안 스무딩 속도는 0에 그대로 있고, 페이스는 1 / 0이 된다.
표본 쪽 가드도 이 게이트를 함께 쓰고 있어서 비껴갔다. 시작 5초 유예가 끝난 뒤에는 lowSpeedSince가 막아주는데, 그 값도 저 게이트 안에서만 채워진다. 신뢰 못 할 샘플만 5초 넘게 들어오면 유예는 끝났고 lowSpeedSince는 비어 있다. 그 틈으로 무한대가 저장됐다.
기록 하나가 전부를 막는 구조
백업은 이렇게 생겼다.
1
2
3
4
5
6
7
let records = try flights.map { flight -> CKRecord in
// 생략
record["samplesData"] = try JSONEncoder().encode(samples) as NSData
return record
}
let (saveResults, _) = try await database.modifyRecords(saving: records, ...)
모든 기록의 JSON을 먼저 다 만들고 나서 올린다. 그래서 망가진 기록 하나가 map 안에서 던지면 네트워크에 닿기도 전에 함수가 빠져나간다. 멀쩡한 기록 스무 개도 같이 못 올라간다.
atomically: false를 줘서 기록별로 따로 저장되게 해둔 게 무의미해졌다. 그 설정은 올리는 단계의 이야기인데, 실패가 올리기 전에 일어났다.
만드는 쪽과 저장된 쪽
고칠 자리가 둘이다. 만드는 쪽을 막아도 이미 저장된 기록에는 무한대가 그대로 남아 있다.
만드는 쪽:
1
2
let speedKmPerMinute = smoothingSpeedSecond * 60 / 1000
rawPace = speedKmPerMinute > 0 ? 1 / speedKmPerMinute : 0
앞 절에서 “화면 페이스는 GPWS 때문에 못 건드린다”고 했던 그 값이다. 다시 보니 그 걱정은 근거가 없었다. 페이스 경고는 이 게이트 뒤에서만 돈다.
1
2
3
case .pace:
guard isReachedPace else { return .normal }
return calculateGPWSStatus(pace)
isReachedPace는 유한한 페이스가 목표 구간에 한 번 들어와야 켜진다. 시작 직후 페이스가 0이어도 이 문은 아직 닫혀 있으니 경고가 뜰 수 없다. 막는 장치가 이미 있는데 없다고 가정하고 피해 간 것이었다.
저장된 쪽은 올리기 직전에 한 번 걸러낸다.
1
2
3
private static func finite(_ value: Double) -> Double {
value.isFinite ? value : 0
}
0은 앱 전체가 이미 “기록 없음”으로 읽는 값이라 따로 처리할 게 없다. 페이스 표시도 PaceFormatter가 --:--로 돌려준다.
그래프도 같이 막아야 했다. 무한대가 섞인 기록을 열면 가운데 값과 퍼진 정도를 구하는 계산이 통째로 망가져서 그 줄이 아예 안 보인다.
1
2
3
4
private func hasValue(_ value: Double) -> Bool {
guard value.isFinite else { return false }
return treatsZeroAsMissing ? value > 0 : true
}
조용히 실패하던 자리
이 버그에서 제일 걸리는 건 원인이 아니라 드러난 방식이다.
그래프가 납작한 건 열면 바로 보인다. 백업은 눌러봐야 보이고, 평소에 자주 누르는 화면이 아니다. 같은 값 하나가 두 곳을 망가뜨렸는데 한쪽은 바로 눈에 띄고 한쪽은 눌러볼 때까지 조용했다.
나중에 알고 보니 평균 페이스를 저장하는 자리에는 이미 가드가 있었다.
1
let totalPace = (rawPace.isFinite && totalDistance >= minimumValidDistance) ? rawPace : 0
isFinite를 거기에 적어뒀다는 건 무한대가 나올 수 있다는 걸 그때 알고 있었다는 뜻이다. 그런데 값을 만드는 자리는 안 고치고 받는 자리 하나만 막아뒀다. 그래서 새로 받는 자리가 생길 때마다 같은 버그가 다시 나온다. 이번에 생긴 새 자리가 5초마다 담는 표본이었다.
“여기서 막았으니 됐다”와 “이 값은 이제 안 나온다”는 다른 말이다.
백업이 따라오지 않은 또 하나
백업 코드를 들여다보다 같은 모양을 하나 더 찾았다. 좌표를 담는 구조체다.
1
2
3
4
5
private struct CoordinateBackup: Codable {
let latitude: Double
let longitude: Double
let order: Int
}
이번에 SwiftDataCoordinate에 경과 시간을 추가했는데 여기는 안 따라왔다. 표본을 담는 구조체는 이번에 새로 만들면서 당연히 넣었고, 좌표는 원래 있던 거라 손을 안 댔다.
그래서 복원한 기록을 열면 좌표의 경과 시간이 전부 0이다. 코스에서 짚은 자리를 찾는 함수가 이렇게 시작한다.
1
if elapsedTime >= last.elapsedTime { return (last.latitude, last.longitude) }
last.elapsedTime이 0이니 어디를 짚어도 마지막 좌표가 나온다. 계기 네 줄은 멀쩡하게 움직이는데 항공기만 도착점에 붙어 있다. 표본은 각자 시각을 들고 있으니 그쪽은 영향이 없다.
고칠 때 필드를 그냥 추가하면 안 된다. 이 필드가 생기기 전에 올린 백업에는 키가 없어서 디코딩이 실패하는데, 복원 코드가 이렇게 생겼다.
1
2
if let data = record["coordinatesData"] as? Data,
let decoded = try? JSONDecoder().decode([CoordinateBackup].self, from: data) {
try?라서 실패하면 조용히 넘어간다. 항공기가 이상한 자리에 있는 것보다 좌표가 통째로 없어지는 쪽이 더 나쁘다. 그래서 옵셔널로 받고 없으면 0으로 둔다.
시각이 전부 0인 코스는 항공기와 꼬리를 안 그리기로 했다. 좌표 순서대로 나눠서 어림잡을 수도 있지만, 쉬거나 걸은 구간이 실제보다 짧게 그려진다. 모르는 걸 그럴듯하게 그리는 것보다 안 그리는 쪽이 맞다고 봤다.
고친 뒤 실기기에서 백업하고 다시 복원해 확인했다. 복원한 기록에서 타임라인을 밀면 코스 위 항공기가 따라 움직인다.
하나 더 알게 된 건 화면을 열었을 때 항공기가 시작점에 있는 건 정상이라는 것이다. 시각을 못 읽는 기록도 똑같이 시작점에 있다. 저 함수가 elapsedTime <= first.elapsedTime을 먼저 보고, 화면을 열면 짚은 자리가 0이라서 그렇다. 둘을 구분하려면 타임라인을 밀어서 따라오는지 봐야 한다. 처음에 이걸 “도착점에 붙어 있다”고 적어뒀는데, 그건 민 다음에야 나타나는 모습이었다.
워치에서 끝냈는데 안 돌아오는 PFD
같은 테스트에서 하나 더 나왔다. 아이폰에서 러닝을 시작하고 워치에서 종료했는데, 아이폰의 PFD 화면이 홈으로 돌아가지 않았다.
1.3.1 때 지인이 제보한 것과 같은 증상이다. 그때도 지금도 다시 해보면 정상이었다.
종료를 아는 하나뿐인 길
워치가 미러링 중에 종료하면 sendStopSignal()을 보낸다. 아이폰은 이 신호 말고는 러닝이 끝난 걸 알 방법이 없다. 미러링에서 세션을 소유한 쪽은 아이폰이라, 워치가 자기 미러 세션을 끝내도 아이폰 세션은 그대로 돈다.
그 하나뿐인 길에 끊길 수 있는 자리가 넷이다.
1
2
3
4
guard session.isReachable else {
session.transferUserInfo(message) // 보관했다가 나중에 배달
return
}
하나, transferUserInfo는 “언제든” 배달이라 몇 초에서 몇 분까지 늦을 수 있다. 그동안 아이폰은 PFD에 앉아있다.
둘, 아이폰 쪽에 이전 종료를 처리 중이라는 표시가 남아 있으면 신호를 받아도 통째로 건너뛴다.
셋, 버려진 뷰모델이 먼저 받아서 공용 상태를 정리하고, 화면에 떠 있는 쪽은 자기 경로를 안 비운다. 로그북 저장이 안 되던 버그가 정확히 이 모양이었다.
넷, 아이폰에 “상대가 끝냈는데 신호가 안 왔다”를 보는 장치가 아예 없다. 15초 타이머가 있지만 그건 “데이터가 안 온다 → 일시정지”만 본다.
추측 대신 로그
넷 중 어느 것인지 좁혀지지 않는다. 그런데 재현이 안 된다. 코드만 읽어서 가설을 세우면 틀린 곳을 고치게 된다. 지인이 제보한 워치 버그 때 가설을 두 번 틀리고 나서 배운 것이다.
그래서 각 단계에 한 줄씩 남기기로 했다.
1
2
3
RemoteStopLogger.log("vm.sink",
"인스턴스=\(ObjectIdentifier(self).debugDescription) "
+ "처리중=\(self.isHandlingRemoteStop) 러닝중=\(self.isRunning)")
vm.sink에 찍히는 인스턴스 식별자가 핵심이다. 두 개가 찍히면 셋째 경우고, 하나인데 건너뛰었다고 나오면 둘째고, 아예 안 찍히면 신호가 안 온 것이다. 한 번만 더 재현되면 바로 갈린다.
어느 경우든 갇히는 사용자
원인과 별개로 지금 분명한 게 하나 있다. 어디서 끊기든 사용자는 PFD에 갇힌다. 러닝 중에는 닫기 버튼도 숨겨져 있어서 앱을 강제로 끄는 수밖에 없다.
아이폰이 다시 앞으로 나올 때 워치에 상태를 한 번 물어보는 식의 맞춰보기가 있으면 어느 경우든 빠져나온다. 다만 통신 경로를 하나 더 만드는 일이라 배포 직전에 넣을 건 아니라고 봤다. 로그를 들고 1.4를 내보내고, 재현되면 원인을 잡은 뒤에 고치기로 했다.
정리
페이스 줄 하나를 여섯 번 고쳤다. 고칠 때마다 “데이터를 고칠까 그리는 걸 고칠까”를 다시 물었는데, 돌아보니 둘 중 하나가 아니라 둘 다였고 경계가 따로 있었다.
데이터에서 걷어낼 건 읽히지 않은 값이다. 서 있는 동안의 1 / 속도는 아무도 그 속도로 달리지 않았으니 기록이 아니다. 그리기에서 다룰 건 진짜인데 극단적인 값이다. 숨 고르느라 걸은 12분대는 실제로 그렇게 간 것이라 지우면 안 되고, 다만 그것 때문에 나머지가 안 보이면 안 된다.
이 둘을 가르는 기준이 끝까지 값이 아니었다는 게 이번에 남은 것이다. 처음엔 “몇 분 이상이면 이상한 값”으로 자르려 했는데 어디에 선을 그어도 근거가 없었다. 앱이 이미 들고 있던 “지금 멈춰 있나”가 답이었다.
그리기 쪽에서는 모든 값을 화면 안에 담으려 한 게 길게 돌아간 이유였다. 벗어난 값을 끝에 붙여두고 그게 벗어난 것처럼 보이게 하려다 여백을 두고 흐리게 그리고 점 모양까지 바꿨는데, 담기를 포기하니 한 번에 풀렸다. 줄 높이는 정해져 있고 벗어나는 값은 한계가 없으니 애초에 다 담을 수가 없었다. 그림은 “벗어났다”까지만 말하고 얼마나인지는 숫자가 맡으면 됐다.
같은 값이 두 곳을 망가뜨린 것도 남는다. 1 / 0이 그래프를 납작하게 만든 것까지는 봤는데, 그게 저장까지 타고 들어가 iCloud 백업을 통째로 막고 있는 건 몰랐다. 값을 받는 자리마다 가드를 적는 것과 값이 나오는 자리를 막는 것은 다르고, 받는 자리는 기능을 추가할 때마다 늘어난다.
백업 쪽은 그래서 두 번 걸렸다. 표본을 새로 만들 때는 백업에 넣는 걸 생각했는데, 이미 있던 좌표에 필드 하나를 더한 것은 생각하지 못했다. 새로 만드는 건 빠뜨리기 어렵고 고치는 건 빠뜨리기 쉽다.
워치 종료 건은 아직 못 고쳤다. 고칠 수 있는 자리가 넷이나 보이는데 어느 것인지 모르는 상태에서 손대면, 전에 그랬던 것처럼 틀린 곳을 두 번 고치게 된다.




