포스트

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: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. 벗어난 값이 띠 위에 겹치면 안 된다

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 백업을 통째로 막고 있는 건 몰랐다. 값을 받는 자리마다 가드를 적는 것과 값이 나오는 자리를 막는 것은 다르고, 받는 자리는 기능을 추가할 때마다 늘어난다.

백업 쪽은 그래서 두 번 걸렸다. 표본을 새로 만들 때는 백업에 넣는 걸 생각했는데, 이미 있던 좌표에 필드 하나를 더한 것은 생각하지 못했다. 새로 만드는 건 빠뜨리기 어렵고 고치는 건 빠뜨리기 쉽다.

워치 종료 건은 아직 못 고쳤다. 고칠 수 있는 자리가 넷이나 보이는데 어느 것인지 모르는 상태에서 손대면, 전에 그랬던 것처럼 틀린 곳을 두 번 고치게 된다.

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