포스트

RunWay 1.4 (8) 멈춰 있는 동안에도 가고 있던 시계

RunWay 1.1 부터는 Claude Code 와 함께 만든다. 코드와 글의 문장은 거기서 나오고, 방향을 정하고 실기기에서 확인해 틀린 걸 찾아오는 건 직접 한다. 그래도 과정을 글로 남기는 건 업데이트를 AI 에게 맡기고 끝내지 않으려는 것이다. 거기서 배울 점을 찾아 적어 두고 나중에 공부하려고 한다.

고친 빌드를 깔고 짧게 나갔다. 3분쯤 걷고, 중간에 1분 정도 그냥 서 있었다. 거리로는 250m밖에 안 되는 기록이다.

끝내고 요약 화면과 FLIGHT DATA RECORDER를 나란히 봤는데 두 화면이 서로 다른 시간을 말하고 있었다.


3분 러닝인데 4분이라고 나온 기록

요약 화면의 TIME과 스플릿 페이스, 그리고 FLIGHT DATA RECORDER의 T+ 를 한 화면에 모은 것

어디값
FLIGHT SUMMARY 의 TIME00:03:01
FLIGHT DATA RECORDER 의 마지막 T+00:04:00

차이가 59초다. 내가 서 있던 그 1분이다. 그동안 앱은 멀쩡히 일시정지 상태였고 화면에도 그렇게 떠 있었다.

시간만 틀린 게 아니었다.

1
2
요약 평균 페이스   11:58 /km
스플릿 1 페이스    16:00 /km

구간이 하나뿐인 러닝에서 페이스가 두 개 나왔다. 250m를 한 번 움직인 게 전부인데 그 한 구간의 페이스가 화면마다 다르다.

나눠보면 어디서 갈렸는지 바로 보인다.

1
2
181초 ÷ 0.252km = 718초 = 11:58   <- 멈춘 시간 뺀 것
240초 ÷ 0.250km = 960초 = 16:00   <- 멈춘 시간 포함한 것

따로 돌던 시계 두 개

러닝 하나에 시간을 재는 자리가 두 군데 있었다.

1
2
3
4
// RunViewModel - 1초마다 올리는 타이머. 멈추면 안 센다.
if !isPaused {
    elapsedTime += 1
}
1
2
// RunningCenter - GPS 타임스탬프. 멈춰도 계속 간다.
let elapsed = Int(location.timestamp.timeIntervalSince(runStartTime))

요약의 TIME과 평균 페이스만 위를 쓴다. 아래를 쓰는 쪽이 훨씬 많았다. 좌표의 시각, 표본의 시각, 그리고 스플릿 페이스.

아래쪽 코드에 달려 있던 주석이 왜 이렇게 됐는지 그대로 적어두고 있었다.

별도 타이머 없이도 … 정확한 스플릿 시각을 남긴다

1초 타이머는 틱이 밀리면 오차가 쌓인다. 10분 뛰면 몇 초씩 어긋난다. 그게 싫어서 GPS가 찍어주는 시각을 쓰기로 한 거다. 그 판단 자체는 맞다.

다만 GPS 타임스탬프는 일시정지를 모른다. 멈춰 서 있어도 위치 갱신은 계속 들어오고, 그때마다 시작 시각과의 차이는 계속 벌어진다. 오차는 피했는데 일시정지도 같이 피해버렸다.


멈춘 만큼을 빼는 자리

벽시계는 멈춘 59초까지 세서 240초가 되고 움직인 시간은 그 59초를 빼고 181초가 된다

정지 판단은 이미 RunningCenter가 하고 있다. 자기가 아는 값이니 자기가 세면 된다.

1
2
3
4
if let lastTimestamp = lastLocationTimestamp, isStationary {
    pausedDuration += location.timestamp.timeIntervalSince(lastTimestamp)
}
lastLocationTimestamp = location.timestamp

그리고 경과 시간을 물어보는 자리를 한 곳으로 모았다.

1
2
3
4
private func movingElapsed(at timestamp: Date) -> Int {
    guard let runStartTime else { return 0 }
    return max(0, Int(timestamp.timeIntervalSince(runStartTime) - pausedDuration))
}

좌표, 표본, 스플릿이 전부 이걸 쓴다.

같은 상황을 그대로 돌려봤다. 240초 중 120초부터 179초까지 서 있는 러닝이다.

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
import Foundation

let start = Date(timeIntervalSince1970: 0)
let stopFrom = 120.0, stopTo = 179.0
let total = 240.0

var pausedDuration: TimeInterval = 0
var lastTimestamp: Date?

func movingElapsed(at t: Date) -> Int {
    max(0, Int(t.timeIntervalSince(start) - pausedDuration))
}

var wallLast = 0, movingLast = 0
for second in 0...Int(total) {
    let t = start.addingTimeInterval(Double(second))
    let isStationary = Double(second) >= stopFrom && Double(second) < stopTo

    if let last = lastTimestamp, isStationary {
        pausedDuration += t.timeIntervalSince(last)
    }
    lastTimestamp = t

    wallLast = Int(t.timeIntervalSince(start))
    movingLast = movingElapsed(at: t)
}

let distanceKm = 0.25
func pace(_ seconds: Int) -> String {
    let perKm = Double(seconds) / distanceKm
    return String(format: "%d:%02d", Int(perKm) / 60, Int(perKm) % 60)
}

print("멈춘 시간          \(Int(pausedDuration))초")
print("벽시계 경과        \(wallLast)초 -> 페이스 \(pace(wallLast))/km")
print("움직인 시간 경과   \(movingLast)초 -> 페이스 \(pace(movingLast))/km")
1
2
3
멈춘 시간          59초
벽시계 경과        240초 -> 페이스 16:00/km
움직인 시간 경과   181초 -> 페이스 12:04/km

화면에서 본 16:00이 그대로 나온다. 고친 쪽은 12:04인데, 요약의 11:58과 6초 차이는 거리를 0.25km로 반올림해서 쓴 탓이다.

실기기에도 깔고 200m만 걸어봤다. 100m 걷고, 제자리에 1분 서 있고, 다시 100m 걷는 식이다.

멈춰 있던 구간을 짚은 FLIGHT DATA RECORDER 화면. PACE 가 비어 있다

세로선이 서 있던 구간 한가운데에 있다. PACE 가 --:-- 이고 줄도 그 자리에서 끊겨 있다. 멈춘 구간이 그래프에서 사라지지는 않았다.

 고치기 전이면실제로 나온 값
요약 TIME1:561:56
끝 T+2:561:51

1분이 5초로 줄었다. 그 5초는 성격이 다른 값이라 아래에 따로 적었다.


일부러 벽시계로 남겨둔 한 군데

전부 바꾸면 안 되는 자리가 하나 있었다. 표본을 얼마나 자주 담을지 재는 곳이다.

1
2
let wallElapsed = max(0, Int(timestamp.timeIntervalSince(runStartTime)))
if let last = lastSampleElapsedTime, wallElapsed - last < sampleInterval { return }

여기까지 움직인 시간으로 바꾸면 멈춰 있는 동안 간격이 안 늘어난다. 5초가 영영 안 지나가니 표본이 하나도 안 쌓인다. 그러면 쉰 구간이 그래프에서 통째로 사라진다.

그건 고친 게 아니다. 멈춰 있었다는 사실은 남아야 한다. 지금은 쉰 구간에도 표본이 5초마다 쌓이고, 그 표본들의 페이스가 0이라 줄 끊김으로 그대로 보인다. 다만 그 표본들이 달고 있는 시각은 전부 같은 값이다. 멈춰 있었으니 움직인 시간이 안 늘어난 게 맞다.

담는 간격은 실제로 흐른 시간으로, 담은 값의 시각은 움직인 시간으로. 같은 함수 안에서 두 시계를 쓰는 셈이지만 재는 대상이 다르다.


아직 안 맞는 자리

GPS가 완전히 끊기면 RunViewModel이 15초 뒤에 일시정지를 건다. 그동안은 위치 갱신 자체가 안 들어와서 RunningCenter는 멈춘 줄 모른다. 신호가 돌아오면 그 공백이 통째로 움직인 시간으로 들어간다.

드물게 생기는 경로라 이번엔 안 건드렸다. 아직 확인 못 했다.


남겨두기로 한 5초

실기기에서 재보니 TIME 은 1분 56초인데 FDR 의 끝 T+ 는 1분 51초였다. 5초가 비었다.

멈춘 시간과는 상관없는 차이다. 표본을 5초마다 담기 때문이다.

1
2
private let sampleInterval = 5
if let last = lastSampleElapsedTime, wallElapsed - last < sampleInterval { return }

러닝은 아무 때나 끝난다. 마지막 표본을 담고 4초 뒤에 종료를 누르면 그 4초는 표본이 없다. 빨리 눌러서가 아니라 언제 눌러도 생기고, 상한이 정확히 5초다.

없앨 수는 있다. 종료할 때 표본을 하나 더 담으면 된다. 스플릿은 이미 그렇게 한다.

그런데 그 표본에 넣을 값이 없다. 종료 순간에 새로 측정되는 건 없으니 마지막으로 받은 값을 그대로 복사해야 한다. 그리고 러닝은 보통 멈춰 서서 끝낸다. 멈춘 자리의 페이스는 튄 값이거나 0이다. 그걸 한 점 더 찍어 넣으면 시계를 맞추려고 그래프를 더럽히는 꼴이다.

그래서 안 채우고 ⓘ 에 적었다.

1
2
3
표본은 5초마다 담아서, 마지막 표본 뒤에 끝낸 몇 초는 안 들어가요.
그래서 맨 오른쪽 T+ 가 요약 화면의 TIME 보다 최대 5초 짧아요.
종료 순간의 값을 지어내서 채우지는 않거든요.

숫자를 맞추는 것과 숫자가 뭘 뜻하는지 적어두는 것 중에 뒤를 골랐다. T+ 는 “마지막으로 담은 표본의 시각”이고 TIME 은 “실제로 뛴 시간”이라, 애초에 같은 값이어야 할 이유가 없다.


선 하나를 설명하는 두 가지 방법

Free Flight 기록에는 PACE 줄에 가로선이 하나 그어진다. 그 러닝에서 실제로 움직인 구간들의 평균 페이스다.

그런데 Mission Flight 의 목표선과 생김새가 똑같다. 둘 다 같은 색 실선 한 줄이다. 목표선은 위아래 점선과 옅은 띠가 같이 있어서 구분이 되는데, 평균선은 선 하나뿐이라 뭘 기준으로 그은 선인지 알 방법이 없었다.

선 옆에 작게 AVG 라고 적어봤다. 그리고 지웠다.


그래프를 가린 글자

처음엔 글자가 그래프에 덮여서 안 보였다. 위치 문제가 아니라 그리는 순서 문제였다. 목표 띠를 그리는 함수에 처음부터 이렇게 적혀 있었다.

목표 띠를 그린다. 값 선보다 먼저 그려서 값이 위에 오게 한다.

띠는 배경이니까 값 선에 덮이는 게 맞다. 글자를 같은 함수에 넣었으니 글자도 같이 덮였다.

그래서 글자만 떼서 맨 마지막으로 옮기고, 선이 지나가는 자리라 바탕도 깔았다. 이번엔 반대가 됐다. 글자가 그래프를 가렸다.


안 적는 쪽이 나은 이유

줄 하나의 세로 폭이 46에서 90 사이다. 거기에 7pt 글자와 바탕을 얹으면 그 자리의 선은 안 보인다. 선이 어떻게 생겼는지 보려고 들어온 화면에서 선을 가리는 건 원래 풀려던 문제보다 큰 손해다.

애초에 이 그림에는 글자를 놓을 빈 자리가 없다. 네 줄이 좁은 높이를 나눠 쓰고 가로는 전부 시간축이라, 어디에 적어도 값 위다.


설명이 있어야 할 자리

대신 오른쪽 위 ⓘ 안에 있는 설명을 고쳤다. 원래도 이 선을 설명하고 있었는데 무슨 모드에서 나오는 선인지를 안 적고 있었다.

1
2
3
4
5
// 전
"가운데 실선은 움직인 구간의 평균 페이스예요. 멈춰 있던 시간은 빼고 냈어요."

// 후
"목표 없이 뛴 Free Flight라서 가운데 실선이 이 러닝의 평균 페이스예요. 움직인 구간만 모아서 냈고, 멈춰 있던 시간은 빼요."

이 설명은 보고 있는 기록에 따라 갈린다. 목표를 정하고 뛴 기록이면 목표선과 허용 오차 설명이 대신 나온다. 그러니까 지금 이 선이 평균선이라는 건 그 문장이 보인다는 것만으로 이미 정해진다. 모드 이름을 적어주니 그게 눈에 보이게 됐다.

그림 위에 글자를 얹는 대신 글자가 있어야 할 자리에 적는 쪽으로 갔다. 대신 한 번 눌러야 보인다.


말로는 안 그려지던 모양

문구만 고치고 다시 읽어보니 설명이 안 읽혔다. “위아래 점선이 허용 오차고 그 사이가 옅게 칠해져 있어요”를 글로만 읽고 그 모양을 떠올릴 수 있는 사람은 이미 본 사람뿐이다.

더 큰 문제가 있다. 이 설명은 보고 있는 기록에 따라 갈린다. Free Flight 기록을 열면 평균선 설명만 나오고 목표 띠 설명은 아예 안 나온다. 기록 하나로는 둘 중 하나만 평생 볼 수 있다.

그래서 실제 기록에서 PACE 줄을 두 장 잘라서 넣었다.

Free Flight 의 평균선. 오른쪽에 선이 끊긴 자리가 하나 있다

실선 하나가 전부다. 글로 “선이 끊긴다”고 적는 것보다 이 한 장이 빠르다.

캡션을 처음엔 “끊긴 자리가 멈춰 서 있던 구간”이라고 적었다가 고쳤다. 이 기록에서는 멈춘 게 아니라 그 사이 속도가 안 들어온 것이었다. 끊김은 페이스가 기록되지 않았다는 뜻이고, 거기까지가 그림에서 읽을 수 있는 전부다. 어느 쪽인지는 그림이 말해주지 않는다.

둘을 구분해서 적으면 틀린 쪽을 보고도 맞다고 읽게 된다. 그래서 둘 다 적었다.

1
2
선이 끊긴 자리는 페이스가 기록되지 않은 구간이에요.
멈춰 서 있었거나, 그 사이 속도가 제대로 안 들어온 거예요.

Mission Flight 의 목표 띠. 위로 솟았다 사라지는 자리가 줄 밖으로 나간 값이다

이쪽은 실선이 목표, 위아래 점선이 허용 오차, 그 사이가 칠해져 있다. 그리고 위로 솟았다가 사라지는 자리가 두 군데 보인다. 값이 세로 폭을 벗어나 줄 밖으로 나간 것이다.

이건 지난 글에서 일부러 그렇게 만든 동작이다. 범위 밖 값을 줄 끝에 눌러 붙이면 정상적으로 끝 근처에 있는 값과 모양이 똑같아져서, 한참 벗어난 페이스가 오차선에 걸친 것처럼 보였다. 빠져나가 사라지는 쪽이 “여기서 벗어났다”로 읽힌다.

그 설명도 글로는 안 읽혔다. 그림이 들어가니 한 줄로 끝난다.

1
2
3
4
5
6
7
8
9
10
11
12
13
private func figure(_ image: String, _ mode: String, _ lines: [LocalizedStringKey]) -> some View {
    VStack(alignment: .leading, spacing: 8) {
        // 모드 이름은 앱 안에서도 영문 그대로 쓰므로 번역을 타지 않게 `String`으로 넘긴다.
        Text(mode)
            .font(.orbitron(12, weight: .bold))
            .foregroundColor(.rwGreen)
            .kerning(1)
        Image(image)
            .resizable()
            .aspectRatio(contentMode: .fit)
        // 생략
    }
}

설명 그림을 새로 그리지 않고 내 기록을 그대로 잘라 썼다. 새로 그리면 실제와 다르게 그릴 위험이 있고, 코드가 바뀌면 그림만 옛날 모양으로 남는다.


밖에 나가야 아는 것과 안 그래도 되는 것

오늘 고친 두 개가 성격이 완전히 다르다.

멈춘 시간이 안 빠지던 건 밖에 나가야 알 수 있었다. 실제로 걷고, 실제로 서 있고, GPS가 그동안 뭘 보내는지 봐야 나온다. 책상에서는 재현할 조건 자체가 없다.

다이얼이 1 작게 나오던 건 아니다. 입력 하나에 출력 하나짜리다.

1
Int(439.9999)  // 439

이걸 워치 차고 나가서 크라운 돌려보고 사진 찍어서 찾았다.


손으로는 못 가는 145번

다이얼은 180초부터 900초까지 5초 간격이다. 고를 수 있는 값이 145개다. 전부 확인하려면 145번 돌려야 한다.

같은 걸 코드로 적으면 이렇다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import Testing
@testable import Pace

/// 워치 다이얼은 180초부터 900초까지 5초 간격으로만 고를 수 있다.
/// 고를 수 있는 값 전부가 고른 그대로 보여야 한다.
@Test("다이얼로 고를 수 있는 모든 값이 그대로 표시된다")
func everyDialValueRoundTrips() {
    for totalSeconds in stride(from: 180, through: 900, by: 5) {
        let pace = Double(totalSeconds) / 60.0
        let (m, s) = PaceFormatter.minuteSecond(pace)
        #expect(m * 60 + s == totalSeconds, "\(totalSeconds)초를 골랐는데 \(m)'\(s)\" 로 나왔다")
    }
}

Test와 expect로 145개를 한 번에 돌리기

@Test는 이 함수가 테스트라고 표시한다. 괄호 안 문자열은 결과 목록에 그대로 뜨는 이름이라, 함수 이름 대신 읽을 문장을 쓸 수 있다.

#expect는 조건이 참인지 본다. 거짓이면 멈추지 않고 기록만 남기고 계속 간다. 첫 실패에서 멈추는 방식이면 뒤를 못 보지만 expect 는 끝까지 돌아 틀린 48개를 한 번에 모은다

그래서 반복문 안에 두면 145개를 끝까지 돌려 틀린 것을 전부 모을 수 있다. 뒤에 붙인 문자열은 틀렸을 때 같이 찍히는 설명이다.

stride(from:through:by:)는 일정 간격으로 값을 훑는다. to 로 쓰면 끝 값이 빠져 144개가 되고 through 로 쓰면 900초까지 포함해 145개가 된다

through 는 끝 값을 포함하고 to 는 안 포함한다. 900초도 고를 수 있는 값이라 through 를 썼다.

고치기 전 코드로 돌려봤다.

1
2
3
4
5
6
7
8
190초를 골랐는데 3'9
205초를 골랐는데 3'24
220초를 골랐는데 3'39
235초를 골랐는데 3'54
245초를 골랐는데 4'4
260초를 골랐는데 4'19
...
Test run failed after 0.002 seconds with 48 issues.

145개 중 48개가 틀렸다. 0.002초 걸렸다.


왜 이제서야 보였나

저 목록을 보면 답이 있다. 틀리는 건 190 205 220 이고 300(5’00) 420(7’00) 600(10’00) 은 멀쩡하다.

손으로 고를 때는 깔끔한 값을 고른다. 5분, 7분, 10분. 전부 60으로 나누어떨어지는 값이고 전부 맞게 나온다. 7분 20초를 고른 그날 처음 보인 게 우연이 아니다.

테스트를 덜 해서 늦게 나온 게 아니다. 손으로는 저기까지 못 간다.


넣을 자리와 안 넣을 자리

 밖에서만 확인되는 것코드로 확인되는 것
 멈춘 시간이 안 빠짐다이얼 값 왕복 변환
 걷는 구간 페이스 끊김멈춘 시간 빼는 계산
 띠가 얇으면 안 보임스플릿 페이스 계산
 워치 종료 신호좌표 솎아내기

왼쪽은 진짜 GPS가 뭘 주는지를 봐야 안다. 오른쪽은 입력과 출력이 전부다.

지금은 둘이 섞여 있어서 10분 걷고 와서야 Int() 버림을 발견한다. 오른쪽 열을 왼쪽 열 비용으로 치르는 셈이다.

저장소에 자동 테스트가 아직 하나도 없다. 배포가 끝나면 Shared/ 안의 계산만 보는 테스트 타깃을 붙일 생각이다. GPS나 화면은 넣지 않는다. 거기는 원래 밖에 나가야 하는 자리다.


정리

 전후
FDR 마지막 T+04:0003:01
스플릿 1 페이스16:0012:04
요약 TIME03:0103:01

고친 줄은 많지 않은데 이 버그가 오래 안 보인 이유는 따로 있는 것 같다. 3km를 쉬지 않고 뛰면 두 시계가 정확히 같은 값을 낸다. 멈춰야만 갈라진다. 그동안 짧게 뛰고 짧게 확인했으니 갈라질 일이 없었다.

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