iOS 러닝 앱 측정 기능 구현 회고
실제 코드 기반 트러블슈팅 문서. 각 섹션은 실제로 마주한 문제와 그 해결 과정을 담는다.
1. Auto-Pause 방식 변천 — GPS speed → CMPedometer step 통합
배경/문제
초기 계획(.ai/plan/run-auto-control-implementation.md)에는 자동 일시정지 판단 기준으로 "GPS 속도 0.8 m/s 이하 + 케이던스 20 SPM 이하"인 Strava 방식 하이브리드를 제안했다. 이후 1차 구현에서는 GPS 속도(location.speed)만 사용하는 단순 방식으로 진행했다.
문제는 두 가지였다.
- GPS 속도는 Doppler 측정 기반이라 실내에서는 아예 신뢰 불가. 실내 트레드밀에서 GPS가 잡히지 않으면
speed = -1이 되어 자동정지가 발동되지 않거나, 반대로 건물 반사 신호로 noise가 섞여 오발동한다. - 신호등 대기 같은 짧은 정지에서 GPS 속도가 즉시 0으로 떨어지지 않는다. GPS 업데이트 주기(최대 3m 이동 시에만 콜백)와 Doppler 측정의 latency 때문에 실제로 멈춰도 속도가 0.5~1.0 m/s를 유지하는 구간이 있어 자동정지가 지연됐다.
시도한 방법들
- GPS speed threshold 방식 → 실내 모드에서 아예 동작 안 함. 실외에서도 신호등 대기 시 오탐.
- 케이던스(lastOutdoorCadenceSpm) 조건 추가 → 케이던스 콜백이 CMPedometer에서 비동기로 오고, cadence stale 감지(
lastCadenceUpdateTime) 없이는 마지막 케이던스 값이 수십 초 전 값일 수 있음. - CMPedometer numberOfSteps 증분 감지 → 실외/실내 모두 통합 가능, 걸음이 실제로 발생하는 순간
lastStepDetectedTime을 갱신하는 방식으로 실시간성 확보.
최종 해결책
실외(LocationTracker)와 실내(WorkoutSessionManager) 모두 CMPedometer의 numberOfSteps 증분 감지를 자동정지/재개의 유일한 판단 기준으로 통합했다.
// LocationTracker — 실외 pedometer 콜백
if let steps = data.numberOfSteps?.intValue, steps > self.lastOutdoorStepCount {
self.lastOutdoorStepCount = steps
self.lastStepDetectedTime = now // 걸음 감지 시각 갱신
if self.isPaused && self.autoPauseEnabled {
self.triggerAutoResume()
}
}
// checkAutoPause() — 마지막 걸음으로부터 3초 경과 시 정지
let elapsed = Date().timeIntervalSince(lastStep)
if elapsed >= 3.0 {
triggerAutoPause()
}CMPedometer 권한이 거부된 경우에만 pedometerAutoPauseFallback = true로 전환하여 GPS 업데이트 부재 방식으로 폴백한다.
교훈
- 자동정지 판단에 GPS speed를 쓰면 실외 실내 양쪽 모두 edge case가 쌓인다. 걸음 감지(
numberOfSteps증분)가 물리적으로 가장 직접적인 신호다. - 케이던스(
currentCadence) 값은 stale할 수 있으므로lastCadenceUpdateTime으로 freshness를 항상 검증해야 한다.
2. GPS/Pedometer 이중 거리 측정 & 자동 전환
배경/문제
터널, 지하철, 고층 빌딩 사이에서 GPS 신호가 10초 이상 끊기면 totalDistanceMeters가 멈춘다. 이 구간에서도 러너는 실제로 이동 중이므로 거리가 누락된다.
시도한 방법들
- GPS 신호 없을 때 마지막 속도로 외삽(extrapolation) → 속도 오차가 시간과 함께 누적되어 수백 미터 오차 발생.
- CMPedometer.distance만 사용 → iOS가 CMPedometer.distance를 보수적으로 계산(실제보다 5~10% 짧음)하므로 GPS 구간과 단위가 맞지 않는 점프가 생김.
최종 해결책
baseline snapshot + delta 방식으로 두 소스를 투명하게 전환한다.
// GPS → Pedometer 전환 시
private func transitionToPedometer() {
distanceSource = .pedometer
distanceAtFallbackStart = totalDistanceMeters // 전환 시점 GPS 누적거리 저장
pedometerBaselineDistance = lastPedometerDistance // 전환 시점 pedometer 누적거리
// 이후: totalDistanceMeters = distanceAtFallbackStart + (현재 pedometer - baseline)
}
// Pedometer → GPS 복귀 시
private func transitionToGPS() {
// 최종 pedometer 거리 flush 후 GPS로 복귀
let delta = max(0, lastPedometerDistance - pedometerBaselineDistance!)
let newTotal = distanceAtFallbackStart + delta
if newTotal > totalDistanceMeters { totalDistanceMeters = newTotal }
distanceSource = .gps
lastRawLocation = nil // 다음 GPS fix를 새 기준점으로 (점프 방지)
}전환 임계값:
- GPS→Pedometer: 양호한 GPS 없이 10초 경과 (
gpsDegradationThreshold = 10.0) - Pedometer→GPS: 3초 연속 양호 GPS (
gpsRecoverySustainedThreshold = 3.0)
페이스 윈도우(smoothPace, instantPace)는 distanceSource == .gps일 때만 샘플을 추가하여 pedometer 혼합 오염을 방지한다.
교훈
- 전환 시 반드시 "현재 누적거리"를 snapshot으로 저장하고, 새 소스의 delta만 더해야 한다. 누적값을 직접 합치면 중복 계산이 된다.
- GPS 복귀 후
lastRawLocation = nil을 반드시 해줘야 한다. 그렇지 않으면 GPS 끊긴 동안 pedometer가 이동한 거리 + GPS 재수신 후 이동한 거리가 한꺼번에 점프로 잡힌다.
3. GPS 초기 점프 버그 (isFirstValidFix 패턴)
배경/문제
러닝 시작 후 첫 15초 사이에 0.010.02 km가 순간적으로 찍히는 현상. 거리뿐 아니라 페이스도 999′99″로 튀었다.
원인은 두 가지였다.
- GPS 수신 초기에 캐시된 오래된 위치(최대 10초 전 데이터)가 기준점으로 잡히면, 실제 시작 위치와 수백 미터 차이가 날 수 있다.
- 5초 안정화 대기(
startTime + 5초) 동안lastRawLocation을 갱신하지 않으면, 5초 후 첫 정상 fix와 startTracking 직전에 잡힌 오래된 캐시 위치 간의 거리가 통째로 누적됐다.
시도한 방법들
- 단순 5초 대기 후 첫 fix를 기준점으로 →
locationAge <= 10조건으로 최대 10초 전 fix를 허용했는데, 안정화 기간 중에 들어온 오래된 fix가lastRawLocation으로 남아 5초 후 첫 계산에서 점프.
최종 해결책
안정화 기간 동안 fresh fix(locationAge <= 3.0)만 lastRawLocation을 갱신한다.
// 5초 안정화 대기 구간
if let start = startTime, Date().timeIntervalSince(start) < 5.0 {
lastLocation = kalmanFilter.filter(location)
if locationAge <= 3.0 {
lastRawLocation = location // 3초 이내 fresh fix만 기준점으로
}
return
}이 패턴은 isFirstValidFix 변수가 없어도 동일한 효과를 낸다. 안정화 기간에 들어온 오래된 캐시 fix는 기준점을 오염시키지 않고, 안정화 종료 후 첫 거리 계산은 3초 이내 fix 기준에서 시작된다.
교훈
- GPS warmup 기간 동안
didUpdateLocations에 들어오는 데이터는location.timestamp가 최신이 아닐 수 있다.locationAge(=-location.timestamp.timeIntervalSinceNow)를 항상 확인해야 한다. - 기준점(
lastRawLocation) 오염이 모든 거리 계산 버그의 근원이다. 갱신 조건을 명시적으로 코딩하는 것이 안전하다.
4. 실내↔실외 전환 시 자동정지 오발동
배경/문제
실내 러닝(트레드밀) 중 실외로 전환하거나, GPS→Pedometer fallback 전환 직후 자동정지가 즉시 발동되는 현상이 있었다.
구체적 시나리오:
- GPS 신호 불량으로 pedometer fallback 전환 → 이 순간
lastStepDetectedTime이 수 초 전 값 → checkAutoPause가 "3초 이상 걸음 없음"으로 즉시 정지 트리거. - 수동 재개 직후 race condition:
lastManualResumeTime설정 전에 GPS 콜백이 들어와checkAutoPause발동.
시도한 방법들
- 전환 시 grace period 5초 부여 →
lastManualResumeTime = Date()방식으로 구현했으나,resumeTracking()에서 상태 리셋 후 grace period를 설정하면 리셋과 GPS 콜백 사이에 race condition 발생.
최종 해결책
grace period를 항상 가장 먼저 설정하는 패턴으로 race condition 차단.
func resumeTracking(...) {
// Grace period를 가장 먼저 설정 (race condition 방지)
lastManualResumeTime = Date()
print("✅ [LocationTracker] resumeTracking: grace period SET FIRST")
// 이후 상태 리셋
isPaused = false
lastStepDetectedTime = Date() // step 타이머도 리셋
...
}GPS→Pedometer 전환 시에는 lastStepDetectedTime = Date()를 전환 직후 설정하여 전환 시점을 "걸음 감지"로 취급, 즉시 자동정지를 방지한다.
세션 시작 시에는 lastManualResumeTime = Date().addingTimeInterval(5) (실질 10초 grace period), lastStepDetectedTime = Date()로 초기화한다.
교훈
- 상태 guard와 grace period는 항상 "가장 먼저" 설정해야 한다. 다른 상태를 리셋한 후 설정하면 그 사이에 타이머/GPS 콜백이 들어와 의도한 방어가 무력화된다.
- 실내/실외 전환은 단순한 flag 변경이 아니다. 타이머, step 카운터, 페이스 윈도우 등 관련 상태를 모두 초기화하거나 동기화해야 한다.
5. 위젯 동기화 문제 — averagePace 버그, throttle, adjustedStartMs
배경/문제 1: averagePace 위젯 버그 (900,000 초과)
초기 위젯에서 페이스 값이 "900′00″" 또는 완전히 이상한 숫자로 표시되는 현상이 발생했다.
원인: 러닝 시작 직후 totalDistanceKm이 극히 작은 값(예: 0.001 km)일 때 averagePaceMs = activeTimeMs / distanceKm을 계산하면 분모가 너무 작아 수백만이 나온다. 위젯 ContentState에 이 값이 그대로 들어가면 "900′00″" 같은 쓰레기 값이 표시된다.
averagePace는 위젯에서 사용 금지로 결정. 위젯 페이스 소스는 smoothPace(30초 윈도우) → instantPace(10초 윈도우) → nil 순 fallback만 허용한다.
// GPS 콜백에서 위젯 페이스 선택
let widgetPaceMs = paceData.smoothPace ?? paceData.instantPace ?? paceData.averagePace
// averagePace는 마지막 fallback — 200m 이상, 120,000~900,000 범위 내에서만 표시앱 UI에서는 averagePace를 사용하되 >= 120_000 && <= 900_000 범위 가드를 항상 적용한다.
배경/문제 2: 위젯 타이머 드리프트 (adjustedStartMs)
iOS Live Activity의 Text(timerInterval:) 타이머는 startTime을 고정값으로 받기 때문에, 일시정지 후 재개하면 타이머가 일시정지 시간까지 포함하여 계속 전진한다. 30분 러닝 후 5분 정지하고 재개하면 위젯 타이머가 35분을 표시하는 문제가 발생했다.
최종 해결책
매 업데이트마다 조정된 startTime을 재계산하여 전달한다.
// 매 틱마다 재계산 (drift 방지)
let activeMs = calculateActiveElapsedMs() // wall clock - 누적 일시정지 시간
let adjustedStartMs = Date().timeIntervalSince1970 * 1000 - activeMs
// → 위젯은 이 adjustedStartMs를 startTime으로 써서 타이머가 activeMs를 정확히 표시calculateActiveElapsedMs()는 현재 일시정지 중인 시간(pauseStartDate부터 지금)까지 포함해 차감한다.
배경/문제 3: LAM throttle과 state change 충돌
LAM(LiveActivityManager)의 3초 throttle 때문에 일시정지/재개 직후 위젯이 최대 3초간 stale 상태를 보여줬다. 특히 자동재개 직후 거리가 갱신되지 않는 현상이 있었다.
최종 해결책
forceUpdate()는 lastUpdateTime = nil로 throttle을 리셋한다. 일시정지/재개 같은 **상태 변경(state change)**은 forceUpdate()를 사용하고, 일반 주기적 업데이트는 3초 throttle update()를 사용한다.
state change 판단: isPaused != cachedIsPaused이면 throttle을 건너뛰고 즉시 업데이트.
교훈
averagePace = activeTimeMs / distanceKm공식은 초기 수십 초 동안 분모가 극히 작아 수백만 ms가 나온다. 위젯처럼 제한된 공간에는 이 값을 절대 직접 전달하지 말 것.- adjustedStartMs는 "지금 - activeElapsedMs"로 매번 재계산해야 한다. 한번 계산한 값을 캐시하면 일시정지 구간이 생길 때마다 drift가 누적된다.
6. JS-Native 브릿지 백그라운드 suspend 문제
배경/문제
앱이 백그라운드로 전환되면 React Native의 JS 브릿지가 일시 중단된다. 이 상태에서 Native가 sendEvent()를 호출해도 JS 이벤트 핸들러가 실행되지 않는다. 결과적으로:
- 백그라운드에서 자동정지가 발생해도 JS 상태(
useRunSession)가 업데이트되지 않음. - 포그라운드 복귀 시 앱 화면이 위젯과 다른 값을 표시.
시도한 방법들
- JS에서 AppState 감지 후 Native API로 현재 상태 풀링 → 가능하지만 타이밍이 맞지 않는 경우 발생.
- 위젯만 Native에서 직접 업데이트 → 앱 UI와 위젯이 다른 값을 보여주는 근본 원인은 해결 안 됨.
최종 해결책
두 가지를 조합:
-
Native가 위젯을 직접 관리: 백그라운드에서 JS 브릿지가 끊겨도 LAM은 계속
activity.update()를 호출할 수 있다. GPS 콜백(didUpdateLocations)과 틱 타이머 모두 Native에서 위젯을 직접 업데이트. -
포그라운드 복귀 시
syncDataOnForeground()강제 실행:UIApplication.didBecomeActiveNotification옵저버를startWorkout()시점에 등록하고, 포그라운드 복귀 시forceUpdate()로 위젯과workoutMetricsUpdated이벤트로 JS 앱을 동기화.
foregroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.syncDataOnForeground()
}syncDataOnForeground()는 distanceKm = 0이면 조기 return하여 초기화 직후 JS store를 0으로 덮어쓰는 위험을 방지한다.
틱 타이머(workoutTickTimer)는 DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .userInitiated))를 사용하여 백그라운드에서도 발화를 보장한다. NSTimer나 Timer.scheduledTimer는 RunLoop 기반이라 백그라운드에서 중단된다.
교훈
- 백그라운드에서 JS 브릿지는 믿지 말 것. 상태의 SSOT는 Native에 두고, 위젯 업데이트도 Native가 직접 처리해야 한다.
DispatchSource.makeTimerSource는 RunLoop와 무관하게 백그라운드에서도 발화한다. 백그라운드 타이머는 반드시 이 방식을 써야 한다.
7. 수동 일시정지/재개와 자동 일시정지/재개 충돌
배경/문제
수동 정지 상태(pauseWorkout() 호출, isTracking = false)에서 걸음이 감지되면 자동 재개(triggerAutoResume())가 발동되어 사용자가 의도하지 않은 재개가 일어났다. 또 반대로 자동 정지 상태에서 수동 재개 버튼을 누르면 WSM과 LT의 isPaused 상태가 불일치하는 경우가 있었다.
WSM과 LT 각각 isPaused 변수를 독립적으로 가지고 있어서, 한 쪽이 업데이트되면 다른 쪽이 stale 상태가 되는 것이 근본 원인이었다.
시도한 방법들
- LT를 WSM의 isPaused를 참조하게 함 → 단방향 의존성 원칙(WSM → LT, LT는 WSM 직접 참조 불가) 위반.
- syncPauseState()로 WSM이 LT에 push → 수동 정지 시
LT.syncPauseState(true), 수동 재개 시LT.syncPauseState(false)호출.
최종 해결책
SSOT는 WSM의 isPaused. LT는 자신의 isPaused를 독립적으로 관리하되, 수동 정지/재개 시 WSM이 LT.syncPauseState()를 호출해 동기화한다.
자동 정지/재개는 LT → WSM Delegate 프로토콜을 통해 요청한다. WSM이 실제 상태 업데이트와 이벤트 발생을 일괄 처리하는 SSOT 역할을 담당한다.
// LT에서 자동정지 발동 시 — WSM에 위임
delegate?.locationTrackerRequestsAutoPause(elapsedMs: elapsedMs, distanceKm: distanceKm)
// WSM이 받아서 처리 (SSOT)
func locationTrackerRequestsAutoPause(elapsedMs: Double, distanceKm: Double) {
guard !isPaused && isWorkoutActive else { return }
isPaused = true
pauseStartDate = Date()
// LiveActivity, JS 이벤트 일괄 처리
}자동정지 중 걸음 감지 시 자동 재개는 pausedAt 타임스탬프로 1초 쿨다운을 적용하여, 정지 직후 micro-movement로 즉시 재개되는 것을 방지한다.
if isPaused && autoPauseEnabled {
guard let p = pausedAt, now.timeIntervalSince(p) >= 1.0 else { return }
triggerAutoResume()
}교훈
- 두 객체가 각자
isPaused를 갖는 이중 상태는 불가피하지만, SSOT를 명시하고 동기화 방향을 단방향으로 고정해야 한다. - 자동 정지/재개 쿨다운은 필수다. 정지 판단 후 0.1초 내에 micro-movement가 감지되면 즉시 재개 → 즉시 정지를 반복하는 flapping 현상이 발생한다.
8. Thermal 최적화
배경/문제
장시간 러닝(1시간 이상) 시 기기가 과열되어 배터리 소모가 빨라지고, 심한 경우 iOS가 앱을 강제로 제한하는 현상이 있었다. 주요 원인:
- GPS distanceFilter = None (모든 위치 업데이트 수신)
- 일시정지 중에도
kCLLocationAccuracyBestForNavigation유지 - 타이머가 1초마다 발화하며 불필요한 계산 수행
최종 해결책
여러 계층의 thermal 최적화를 적용했다.
GPS distanceFilter:
- 추적 중:
3.0m(None → 3m로 변경). 3m 미만 이동은 콜백 생략 → CPU 부하 감소, GPS noise 필터링 이중 효과. - 일시정지 중:
kCLLocationAccuracyNearestTenMeters로 정확도 낮춤. - 재개 시:
kCLLocationAccuracyBestForNavigation으로 복원.
// triggerAutoPause()
locationManager?.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
// triggerAutoResume()
locationManager?.desiredAccuracy = kCLLocationAccuracyBestForNavigation타이머 주기:
- LT auto-pause check timer: 1초
- WSM tick timer: 2초 (LAM update, split check)
- LAM throttle: 3초 (Apple 위젯 리렌더 budget 보호)
일시정지 중 틱 타이머 중지:
// triggerAutoPause()/pauseWorkout() 시
stopWorkoutTickTimer() // 불필요한 LAM 업데이트 방지
// triggerAutoResume()/resumeWorkout() 시
startWorkoutTickTimer()CMPedometer 스로틀: 200ms 최소 간격 적용 (pedometer 콜백은 매우 빈번하게 발화할 수 있음).
오디오 세션: 평소 .mixWithOthers(다른 앱 음악과 공존), 음성 안내 시에만 .duckOthers로 전환하고 완료 후 복원. AVSpeechSynthesizerDelegate의 didFinish에서 세션을 원복.
교훈
- distanceFilter를 None으로 두면 정지 중에도 GPS 노이즈(1-2m 흔들림)로 콜백이 계속 오고, 이게 모두 거리로 누적된다. 3m filter는 발열 감소와 노이즈 필터링을 동시에 해결한다.
- 일시정지 중 틱 타이머를 중지하지 않으면 CPU를 불필요하게 쓰면서 UI 업데이트도 없는 상태가 유지된다.
9. 백그라운드 Goal Achievement
배경/문제
목표 거리/시간 달성이 백그라운드에서 감지되지 않는 경우가 있었다. 틱 타이머(workoutTickTimer)가 백그라운드에서 중단되거나, JS 브릿지가 suspend되어 달성 이벤트가 유실됐다.
최종 해결책
- GPS 콜백에서 조기 goal check:
didUpdateLocations의 속도/정확도 필터 이전에delegate?.locationTrackerUpdatesGoalProgress()를 호출한다. GPS 콜백은 백그라운드에서도 계속 발화하므로 달성 감지 신뢰성이 높다.
// 백그라운드 goal 체크: speed/accuracy 필터와 무관하게 매 GPS 콜백마다 실행
if !isPaused {
delegate?.locationTrackerUpdatesGoalProgress()
}-
달성은 Native에서 1회 처리:
goalAchieved는false → true단방향 전환, Native가 SSOT. 중복 달성 이벤트 없음. -
duration goal 타이머:
ProgressView(timerInterval:countsDown:true)를 사용하여 위젯이 백그라운드에서도 자동으로 카운트다운. JS나 Native가 매 초 업데이트를 보내지 않아도 됨. -
틱 타이머를
global(qos: .userInitiated)queue에서 실행: main queue가 아니므로 백그라운드에서 RunLoop suspension 없이 발화. -
달성 음성과 마일스톤 음성 충돌 방지:
isGoalAchievementSpeaking플래그로 달성 음성 재생 중엔 1km 마일스톤 음성을 스킵.
교훈
- 백그라운드에서 JS 이벤트에 의존하는 로직은 신뢰할 수 없다. goal 달성처럼 "1회성"인 중요 이벤트는 Native에서 직접 처리하고 JS는 수신만 해야 한다.
ProgressView(timerInterval:)을 위젯에 쓰면 Native/JS 업데이트 없이도 위젯이 실시간으로 타이머를 표시한다. 배터리 budget에도 훨씬 유리하다.
10. WSM ↔ LT 의존성 아키텍처 정립
배경/문제
초기에는 WorkoutSessionManager와 LocationTracker가 서로를 참조하는 양방향 의존이 있었다. LT가 자동정지를 발동할 때 WSM의 메서드를 직접 호출하고, WSM이 거리를 읽을 때 LT의 프로퍼티에 직접 접근했다. 이 구조에서는 초기화 순서 문제, 순환 참조, 테스트 어려움이 발생했다.
최종 해결책
단방향 의존성 + Delegate 프로토콜로 정리:
WSM → LT (직접 참조 허용)
LT → WSM (Delegate 프로토콜을 통해서만)// LocationTrackerDelegate 프로토콜 (LT → WSM 통신 인터페이스)
@objc protocol LocationTrackerDelegate: AnyObject {
func locationTrackerRequestsAutoPause(elapsedMs: Double, distanceKm: Double)
func locationTrackerRequestsAutoResume()
func locationTrackerGetsTotalPausedDuration() -> TimeInterval
func locationTrackerGetsWorkoutStartDate() -> Date?
func locationTrackerMarksLap(distanceM: Double, activeElapsedMs: Double)
func locationTrackerUpdatesGoalProgress()
}
// WSM.startWorkout()에서 delegate 연결
LocationTracker.shared?.delegate = selfLT는 WSM을 직접 import하거나 참조하지 않는다. 자동정지/재개 요청, 일시정지 시간 조회, 랩 마킹 모두 delegate를 통해 간접 호출한다. WSM은 LocationTracker.shared로 LT를 직접 참조하여 currentDistanceMeters, currentCalories 등을 읽는다.
shared 인스턴스(LocationTracker.shared, WorkoutSessionManager.shared)는 초기화 시 self-assign하며, 앱 생존 기간 동안 단일 인스턴스가 유지된다.
교훈
- Native 모듈 간 양방향 참조는 반드시 프로토콜로 차단해야 한다. 한 쪽이 다른 쪽을 직접 import하면 빌드 타겟 의존성도 꼬인다.
- "상태 SSOT를 어디에 둘 것인가"를 먼저 정하고, 나머지는 그 SSOT에 요청하는 구조로 설계해야 한다. WSM이
isPausedSSOT이고, LT는 자신의isPaused를 동기화받는 follower다.
부록: 기타 버그 및 수정 사항
GPS 점프 필터 (Strava 방식)
GPS 신호가 순간적으로 튀어 수백 미터 이동으로 잡히는 현상. Doppler 속도(location.speed)와 이전 위치의 속도를 기반으로 물리적으로 가능한 최대 이동거리를 계산하여 필터링.
let refSpeed = max(lastRaw.speed > 0 ? lastRaw.speed : 3.0,
location.speed > 0 ? location.speed : 3.0, 3.0)
let maxAllowed = max(50.0, refSpeed * timeDelta * 4.0)
if distanceIncrement > maxAllowed { return } // GPS 점프 무시checkSplitMilestone 스플릿 페이스 버그
GPS가 2km 이상 점프하면 while 루프에서 두 번째 스플릿부터 lastSplitDistanceM이 distanceM(점프 후 값)으로 설정되어 splitElapsedMs가 거의 0이 되고 페이스가 0으로 표시됐다.
수정: lastSplitDistanceM = distanceM → lastSplitDistanceM = Double(splitCount) * 1000.0 (완성된 1km 구간은 항상 정확히 1,000m 고정).
위젯 배경 이미지 GeometryReader 레이아웃 문제
goalProgressBar가 추가되어 콘텐츠 높이가 늘어나면 GeometryReader가 ZStack에서 greedy layout을 시도해 배경 이미지가 잘리거나 위젯 높이가 의도와 달라지는 현상. ZStack에서 GeometryReader에 zIndex(1), 콘텐츠 VStack에 zIndex(2)를 적용하고, 배경 이미지는 .scaledToFill() + .clipped()로 처리.
수동 재개 시 일시정지 중 pedometer 거리 편입 방지
수동 재개 시 distanceSource == .pedometer이면 pedometerBaselineDistance = lastPedometerDistance로 baseline을 리셋한다. 이를 하지 않으면 일시정지 중 pedometer가 측정한 (가방에 넣고 걸어다닌 등) 거리가 재개 후 totalDistanceMeters에 합산된다.
작성일: 2026-04-02 | 코드 기준: commit d4d88c4