포스트

RunWay 1.3.2 GPWS 경고가 지도에 스무 개씩 찍히던 이유

1.3.1을 배포하고 나서 지인에게 피드백을 받았다. 마커가 너무 많이 나왔다는 부분이다. 그리고 SINK RATE가 뜬 뒤에 속도를 다시 올려도 경고가 풀리기까지 한참 걸렸다고 한다. (실제로 이건 IIR 필터 계수 값을 조정하면서 나도 느꼈던 부분이었다)

이번 글은 이 두 가지를 AI와 대화하면서 좁혀나간 과정을 그대로 옮긴 것이다. 결론부터 말하면 처음에 세운 가설은 거의 다 틀렸고, 진짜 원인은 마지막에 나왔다. 틀린 과정을 지우면 남는 게 별로 없어서 순서대로 적는다.


처음 나온 진단

증상을 설명하니 원인을 세 층위로 나눠서 정리해줬다.

  1. 경고가 켜지는 기준과 꺼지는 기준이 똑같다. calculateGPWSStatus()는 목표 페이스에서 얼마나 벗어났는지만 보고 판정한다. 그 선 하나를 두고 페이스가 왔다갔다 하면 경고도 같이 켜졌다 꺼졌다 한다.
  2. 페이스를 부드럽게 만드는 계수가 너무 낮다. 화면에 보이는 페이스는 GPS 속도를 그대로 쓰지 않는다. 이전 값에 새 값을 조금씩만 섞어서 숫자가 튀지 않게 만드는데, 지금은 그 섞는 비율(α)이 0.15다. 게다가 이 과정을 두 번 겹쳐서 쓴다. 그래서 실제로 속도를 올려도 화면 숫자가 그 변화를 대부분 따라잡는 데 25초쯤 걸린다.
  3. 기록을 거르는 조건이 없다. 경고가 뜨는 순간 바로 저장한다.

그리고 근본 조건 하나를 짚어줬는데 이게 제일 뼈아팠다. 목표 페이스 5분 45초에 허용 오차를 10초로 뒀으니 허용 범위가 5분 35초에서 5분 55초다. 이걸 속도로 바꾸면 2.817에서 2.985m/s, 폭이 0.168m/s밖에 안 된다. GPS가 알려주는 속도값 자체가 그 정도는 흔들린다.

게다가 그날 평균 페이스가 5분 36초였다. 허용 범위의 빠른 쪽 끝에 딱 붙어서 뛴 셈이다. 경고가 자주 뜰 수밖에 없는 조건이었다.


시뮬레이터를 먼저 만들었다

계수를 바꾸려면 실기기에서 여러 번 뛰어야 한다. 한 번 뛰는 데 40분씩 걸리는데 계수 하나 확인하자고 그걸 반복하는 건 무리였다.

그래서 RunningCenter의 알고리즘을 그대로 옮긴 시뮬레이터를 먼저 만들었다. 실제 km별 페이스(6분 30초, 5분 26초, 5분 31초)를 뼈대로 삼고, 거기에 GPS 속도가 흔들리는 정도를 얹어 1초 간격으로 재현하는 방식이다.

그때 돌려본 걸 여기에 다시 옮겨왔다. 위에서 안을 하나씩 켜보면 세 숫자가 어떻게 같이 움직이는지 볼 수 있다.

그래프에서 먼저 보이는 게 있다. 허용 범위(초록 띠)가 저렇게 얇다. 화면에 보이는 페이스가 그 위아래를 계속 넘나들고, 그래서 경고가 켜진 구간(붉은 음영)이 러닝 대부분을 덮는다. 계수를 어떻게 손보든 이 조건 자체가 안 바뀌면 경고는 계속 뜬다는 게 여기서 이미 보인다.

(아래 숫자들은 흔들림을 얼마로 잡느냐에 따라 같이 움직인다. 이 글 뒤쪽 “시뮬레이터 숫자에 대해”에 그 얘기를 따로 적어뒀다.)

여기서 첫 번째 제안이 바로 깨졌다.

화면에 보여줄 페이스와 경고를 판정할 페이스를 따로 두고, 판정용은 더 빠르게 반응하게 만들자는 안이었다. 돌려보니 경고가 풀리는 시간은 41초에서 20초로 줄었는데 경고가 켜졌다 꺼졌다 한 횟수가 97번에서 183번으로 두 배가 됐다. 빠르게 반응한다는 건 GPS가 흔들리는 것까지 그대로 따라간다는 뜻이니 당연한 결과였다. 실기기에서 이걸 알았으면 몇 번을 더 뛰어야 했을 거다.

켜지는 기준과 꺼지는 기준을 다르게 두는 방법도 마찬가지였다. 경고가 8초 이상 유지돼야 기록하는 조건과 같이 쓰니 기록이 17건에서 26건으로 오히려 늘었다. 꺼지는 기준을 까다롭게 하면 경고가 더 오래 켜져 있게 되고, 그러면 8초를 채우는 경고가 늘어난다. 두 장치가 노리는 게 겹쳐서 서로를 깎고 있었다.


500미터는 러닝에서 너무 크다

다음으로 나온 안은 같은 종류의 경고를 500m 안에서는 한 번만 기록하자는 거였다. 29건이 13건으로 줄어든다는 숫자까지 나왔다.

그런데 5분 36초 페이스면 500m는 2분 48초다. 그 안에 일어난 다른 이탈이 통째로 묻힌다는 뜻이다. 이건 좀 아닌 것 같아서 되물었더니, 다시 재보고 이렇게 나왔다.

8초 이상 유지 + 몇 m 안에서 묶기기록
안 묶음24건
50~150m24건
200m23건
300m19건
500m13건

150m 이하는 아무 효과가 없었다. 8초 이상 유지된 것만 남기다 보니 기록끼리 이미 그만큼 떨어져 있었기 때문이다. 그리고 200m를 넘어가면서 줄어드는 건, 8초 넘게 이어진 진짜 이탈을 지우기 시작한다는 뜻이었다. 500m로 13건을 만든 건 실제 사건 11건을 없앤 것이다.

거리 병합은 여기서 버렸다.


그런데 20초면 이미 막았어야 하는 거 아닌가

기존 코드에는 이미 쿨다운이 있었다. 같은 경고가 20초 안에 다시 뜨면 기록하지 않는 조건이다. 그래서 물었다. 20초라고 해뒀는데 왜 스무 개나 찍힌 거지?

이 질문에서 진짜 원인이 나왔다.

1
2
// RunViewModel.swift (기존)
var lastGPWSClearedAt: [String: Date] = [:]

쿨다운이 경고 종류별로 나뉘어 있었다. SINK RATE가 꺼진 시각과 OVERSPEED가 꺼진 시각을 각각 따로 기억하고, 새 경고가 뜨면 자기 종류의 시각만 본다.

1
2
3
4
5
6
7
8
9
// PFDView.swift (기존)
func saveAlert() {
    let type = runViewModel.flightData.gpwsStatus?.rawValue ?? "normal"
    if let clearedAt = runViewModel.lastGPWSClearedAt[type],   // 자기 종류만 확인
       Date.now.timeIntervalSince(clearedAt) < RunViewModel.alertRecordCooldown {
        return
    }
    // 생략
}

그러면 이렇게 된다.

시각일어난 일확인하는 값결과
0초SINK RATE 발생SINK 기록 없음저장
5초정상 복귀SINK 해제 시각 = 5초 
6초OVERSPEED 발생OVER 기록 없음저장
12초정상 복귀OVER 해제 시각 = 12초 
30초SINK RATE 발생SINK 해제 시각 5초 → 25초 경과저장
40초OVERSPEED 발생OVER 해제 시각 12초 → 28초 경과저장

40초 동안 네 건이 저장됐는데 20초 조건에 한 번도 걸리지 않았다. 두 경고가 번갈아 뜨면 각자 자기 시계만 보기 때문에 둘 다 20초를 쉽게 넘긴다.

실제 기록을 확인해보니 SINK 14건, OVER 15건으로 거의 반반이었다. 번갈아 떴다는 증거다.

허용 범위 끝에 붙어 달린 게 이 구멍을 연 조건이었다. 확실히 느리게 뛰면 SINK RATE만 계속 뜨고 꺼지니까 자기 시각이 계속 갱신되어 20초가 제대로 걸린다. 그동안 문제가 없었던 게 이 때문이다.

토글로 켜고 꺼보면서 어떻게 갈리는지 확인할 수 있게 만들어봤다.

종류별로 두면 29건이 기록되고 쿨다운이 실제로 막은 건 20번뿐이다. 통합하면 5건으로 줄고 쿨다운이 44번 동작한다. 같은 20초인데 재는 방식만 다르다.


원래 의도가 통합이었다

여기서 확실해진 게 있다. 이건 설계를 바꾸는 게 아니라 버그를 고치는 것이다.

내가 이 쿨다운을 넣을 때 의도한 건 “경고 하나가 끝나고 20초 안에 또 뜨면 같은 사건으로 본다”였다. 종류를 나눌 생각은 없었다. 그런데 구현이 [String: Date] 딕셔너리가 되면서 의도와 코드가 조용히 갈라졌다.

그래서 상수를 60초로 키우자는 얘기도 나왔었는데 그것도 접었다. 20초는 통합 동작을 전제로 고른 값이다. 지금까지 그렇게 동작한 적이 한 번도 없었을 뿐이지, 그 값이 나쁘다는 근거는 없다. 버그를 고치면 처음으로 의도대로 돌아가게 되니 그 상태로 먼저 뛰어보는 게 맞다.


고친 코드

1
2
3
// RunViewModel.swift / WatchViewModel.swift
- var lastGPWSClearedAt: [String: Date] = [:]
+ var lastGPWSClearedAt: Date?
1
2
3
4
5
6
7
8
9
// PFDView.swift / WatchPFDView.swift
- runViewModel.lastGPWSClearedAt[previous.rawValue] = .now
+ runViewModel.lastGPWSClearedAt = .now

- if let clearedAt = runViewModel.lastGPWSClearedAt[type],
+ if let clearedAt = runViewModel.lastGPWSClearedAt,
       Date.now.timeIntervalSince(clearedAt) < RunViewModel.alertRecordCooldown {
      return
  }

resetState()에서 [:]로 비우던 것도 nil로 바꿨다.

페이스를 부드럽게 만드는 비율은 0.15에서 0.18로 올렸다. 새 값을 조금 더 많이 반영한다는 뜻이고, 속도 변화를 따라잡는 시간이 25초쯤에서 21초쯤으로 줄어든다. 0.20을 넘기면 이번엔 화면 숫자가 눈에 띄게 흔들려서 그 앞에서 멈췄다.

1
2
3
4
5
// RunningCenter.swift
- smoothingSpeedFirst  = 0.85 * smoothingSpeedFirst  + 0.15 * compensatedSpeed
- smoothingSpeedSecond = 0.85 * smoothingSpeedSecond + 0.15 * smoothingSpeedFirst
+ smoothingSpeedFirst  = 0.82 * smoothingSpeedFirst  + 0.18 * compensatedSpeed
+ smoothingSpeedSecond = 0.82 * smoothingSpeedSecond + 0.18 * smoothingSpeedFirst

8초 유지 조건도, 거리로 묶는 것도, 켜지고 꺼지는 기준을 다르게 두는 것도 넣지 않았다. 전부 검토했다가 뺐다. 타입 하나와 숫자 하나가 이번 변경의 전부다.


화면 경고는 그대로 뜬다

한 가지 짚어둘 것이 있다. 이 수정은 저장만 막는다.

triggerGPWS()switch 바깥에 있어서 상태가 바뀔 때마다 항상 실행된다. Watch의 경고 오버레이도 gpwsStatus를 직접 읽기 때문에 저장 로직과 무관하다.

 쿨다운 영향
화면 플래시, 경고음없음
Watch 경고 오버레이, 반복 햅틱없음
Dynamic Island 갱신없음
Alerts 저장, 지도 마커여기만 막힘

의도한 대로다. 실시간 코칭은 매번 알려주고, 기록만 요약하는 것이다. 페이스가 벗어났으면 그 순간 알려주는 게 맞고, 나중에 지도에서 볼 때 같은 사건이 스무 번 찍힐 필요는 없다.

다만 페이스 반영 비율을 올리면 경고가 켜졌다 꺼졌다 하는 횟수 자체는 늘어난다. 모델상 97번에서 117번이다. 기록에는 안 남지만 손목의 진동은 그만큼 더 울린다는 뜻이라, 실제로 뛰어보고 거슬리면 경고가 몇 초는 유지돼야 화면과 진동이 따라가게 만드는 장치를 따로 넣을 생각이다.


시뮬레이터 숫자에 대해

이 글에 나오는 숫자는 대부분 앱을 측정한 값이 아니라 모델의 출력이다.

km별 페이스는 실제 값이지만, 초당 속도가 얼마나 흔들리는지는 내가 정한 값이다. SwiftDataCoordinate에 시각을 저장하지 않아서 실제 초당 속도를 되살릴 수 없었기 때문이다. 그래서 29건이 5건이 되는지, 15건이 11건이 되는지는 그 값을 얼마로 잡느냐에 따라 달라진다. 흔들림을 0.35에서 0.20으로 낮춰 보면 29건과 5건이 15건과 11건으로 좁혀진다.

반면 다음은 시뮬레이션과 무관하게 코드에서 확인한 사실이다.

  • 쿨다운이 경고 종류별로 나뉘어 있다는 것
  • 그래서 경고가 번갈아 뜨면 구조적으로 발동하지 않는다는 것
  • 허용 범위의 폭이 0.168m/s라는 것
  • 비율을 바꿨을 때 반응 속도가 얼마나 달라지는지

모델이 답을 준 게 아니라 가설이 틀렸다는 걸 싸게 알려줬다. 방향을 좁히는 데 썼고, 확정은 실기기에서 해야 한다.


남은 것

SwiftDataCoordinate에 시각을 같이 저장할까 고민 중이다. 지금은 저장 직전에 지도에 그릴 만큼만 좌표를 솎아내기 때문에 남은 좌표들 사이의 시간 간격도 제각각이다. 좌표마다 시각이 있으면 실제 러닝 데이터를 시뮬레이터에 그대로 넣을 수 있어서 이런 조정이 훨씬 정확해진다.

그리고 경고를 지금처럼 하나하나 따로 기록할지, 시작과 끝을 가진 한 덩어리로 묶을지도 생각해볼 문제다. 페이스가 허용 범위를 벗어나 있는 건 한동안 이어지는 상태인데, 지금은 경고가 켜지는 순간마다 별개로 세고 있다. 덩어리로 묶으면 7.87km에 서너 건이 될 것이다. 다만 저장 구조를 바꾸는 거라 기존 사용자의 데이터를 옮기는 작업이 필요하고, 1.1에서 겪은 문제를 다시 밟지 않도록 조심해야 한다.

실기기 테스트를 마치는 대로 1.3.2로 올릴 예정이다.

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