걸음 수가 뒤로 갑니다 (그리고 시작하자마자 150걸음이 순간이동합니다)

지난 글에서는 전용 걸음 센서가 없는 기기에서 가속도계 값만 가지고 걸음을 직접 세는 이야기를 했습니다. 안드로이드 쪽 이야기였고, 결론은 "센서가 없으면 우리가 셉니다"였죠.
그럼 센서가 있으면요? 아니 정확히는, iOS처럼 운영체제가 아예 걸음 수를 통째로 계산해서 넘겨주는 환경이면요?
편할 것 같잖아요.
저도 그렇게 생각했습니다.
애플이 CMPedometer로 "지금까지 몇 걸음"을 알려주니까, 저는 그 숫자를 그대로 자바스크립트로 넘기기만 하면 끝인 줄 알았어요.
그런데 제가 만든 라이브러리를 제 폰에 올려놓고 굴려보니 두 가지가 이상했습니다.
- 앱을 켜자마자 걸음 수가 0이 아니라 150쯤에서 시작합니다. 저는 방금 앉아서 앱을 켰는데요.
- 그리고 가끔 걸음 수가 뒤로 갑니다. 걸었는데 줄어듭니다.
만보기가 뒤로 가는 걸 처음 본 순간의 심정을 어떻게 설명해야 할까요. (운동을 취소당한 기분입니다. 방금 걸은 거 어디 갔어요.)
이 글은 그 두 증상이 사실 같은 원인이었고, 해법도 하나였다는 이야기입니다.
증상 1 — 세션이 시작되자마자 150걸음이 꽂힌다
먼저 순간이동부터 보겠습니다.
CMPedometer는 우리가 물어보기 전에도 이미 일하고 있습니다.
아이폰은 최근 걸음 이력을 계속 들고 있어요.
그래서 앱이 "지금부터 구독할게요" 하고 붙는 순간, 애플은 아주 친절하게 "아 그동안 이만큼 걸으셨어요" 하고 그간 쌓인 걸 한 방에 밀어줍니다.
이걸 catch-up 업데이트라고 부를 수 있겠는데요. 문제는 이 첫 업데이트에 세션이 시작되기도 전에 걸은 걸음이 들어 있다는 겁니다. 제 라이브러리에서 관측된 크기가 약 150걸음이었습니다.
즉 모든 세션이 그 사람의 최근 활동량에 비례하는 가짜 스파이크로 시작하고 있었던 거예요. 가만히 앉아서 앱을 연 사람은 0 근처에서 시작하고, 지하철에서 뛰어온 사람은 150에서 시작합니다. 같은 코드인데 사람마다 다르게 틀립니다. (이게 제일 나쁜 종류의 버그예요. 재현이 안 되니까 "제 폰에서는 잘 되는데요?"를 시전하게 됩니다.)
증상 2 — 걸음 수가 뒤로 간다
이쪽이 훨씬 당황스러웠습니다.
걸음 수는 누적값입니다. 누적값의 대전제는 단조 증가죠. 방금보다 지금이 적을 수는 없습니다. 상식이잖아요.
그런데 iOS가 주는 값은 그 상식을 지키지 않습니다.
iOS는 pedometer 업데이트를 활동창(activity window) 단위로 묶어서 보고합니다. 그리고 이 창의 경계가 연속된 콜백 사이에서 겹치거나, 순서가 뒤바뀔 수 있습니다.
결과적으로 연속된 두 업데이트가 전달하는 원시 누적값은 단조 증가가 보장되지 않습니다. n번째 콜백보다 n+1번째 콜백의 숫자가 더 작을 수 있다는 뜻이에요.
애플이 거짓말을 하는 건 아닙니다. 애플은 "이 활동창의 집계"를 성실하게 알려주는 것뿐이고, 창의 경계가 나중에 조정될 수 있을 뿐입니다. 잘못은 그 값을 화면에 그대로 띄운 저한테 있었죠.
삽질 — "그럼 커지는 값만 받으면 되잖아?"
여기서 제가 제일 먼저 떠올린 해법이 뭐였냐면요.
이전 값보다 큰 값만 받고, 작으면 무시하면 되지 않나?
네. 단조 필터입니다. 너무 당연해서 코드를 쓰기도 전에 다 끝난 기분이었어요. 5분이면 될 줄 알았습니다.
그런데 이 순진한 필터에는 도망칠 구멍이 없습니다. 역주행 업데이트가 들어왔을 때 선택지가 딱 둘뿐이거든요.
받아들이면, 화면의 숫자가 눈에 띄게 뒤로 갑니다. 증상 그대로예요. 거부하면, 그 업데이트에 담겨 있던 진짜 걸음까지 같이 버립니다. 데이터가 사라집니다.
틀린 숫자를 보여주거나, 맞는 숫자를 잃거나. 어느 쪽을 골라도 지는 게임이었습니다.
한참을 붙잡고 있다가 깨달았습니다. 이건 필터로 풀 문제가 아니라는 걸요. 제가 잘못 고른 건 조건문이 아니라 애초에 무엇을 화면에 띄울 것인가였습니다.
해결 — 원시값을 버리고 델타를 내보낸다
문제를 다시 정의해봤습니다.
사용자가 만보기 화면에서 알고 싶은 게 뭘까요? "내 아이폰이 태초부터 집계한 총 걸음 수"가 아닙니다. **"이 세션을 시작한 뒤로 내가 몇 걸음 걸었나"**입니다.
그렇다면 애플이 주는 누적값을 그대로 내보낼 이유가 없습니다. 세션 시작 시점의 값을 기준선으로 잡고, 그 위의 차이만 내보내면 되죠.
그래서 2단계 구조로 다시 짰습니다.
- 세션이 시작될 때
queryPedometerDataFromDate:toDate:를 딱 한 번 호출해서, 세션 시작 시각부터 지금까지 이미 쌓여 있는 걸음 수를 붙잡아둡니다. 이게 베이스라인입니다. - 그다음
startPedometerUpdatesFromDate:로 구독하고, 라이브 콜백이 올 때마다 원시 누적값이 아니라 기록해둔 베이스라인 위의 델타만 내보냅니다.
// 1) Establish baseline at session start (start -> now).
// This makes "reset/restart" deterministic: emitted steps are delta since sessionStartDate.
[self.pedometer queryPedometerDataFromDate:_sessionStartDate
toDate:now
withHandler:^(CMPedometerData *data, NSError *error) {
dispatch_sync(self->_stateQueue, ^{
self->_baselineSteps = error ? 0 : data.numberOfSteps.integerValue;
self->_baselineReady = YES;
});
// ...
}];
여기서 조회 구간이 _sessionStartDate → now라는 게 중요합니다.
세션 시작이 곧 "지금"이면 이 창은 사실상 비어 있어서 베이스라인이 0이 되고, 사용자가 과거 날짜를 시작점으로 넘기면 그 사이에 걸은 걸음이 통째로 베이스라인에 잡힙니다.
어느 쪽이든 "시작 시점 이후 내가 걸은 것"만 남게 됩니다.
그리고 라이브 콜백 쪽이 이렇습니다.
NSInteger cumulative = data.numberOfSteps.integerValue;
NSInteger delta = cumulative - self->_baselineSteps;
// If OS performs corrections or timestamps shift, delta can go negative.
// Clamp + rebase to keep future deltas sane.
if (delta < 0) {
delta = 0;
self->_baselineSteps = cumulative;
}
// Monotonic guard: never emit backwards.
if (delta <= self->_lastEmittedSteps) return;
self->_lastEmittedSteps = delta;
핵심은 내보내는 값이 data.numberOfSteps가 아니라 delta라는 것입니다.
한 줄 바뀐 것처럼 보이지만, 무엇이 진실인가에 대한 정의가 통째로 바뀐 겁니다.
그리고 아까 버렸다고 한 단조 가드가 마지막 두 줄에 그대로 살아 있는 게 보이실 겁니다. 버린 게 아니라 좌표계를 옮긴 거예요. 누적 원시값 위에서는 답이 없던 "뒤로 가면 어쩌지"가, 델타 위에서는 "음수면 0으로 누르고 기준을 다시 잡는다"라는 명확한 처리가 됩니다.
그런데 하나가 더 필요했습니다 — 활동창 필터
여기까지 하고 끝인 줄 알았는데, 증상 2가 완전히 죽지 않았습니다.
앞에서 iOS가 활동창 단위로 보고한다고 했잖아요.
그 창들은 서로 다른 startDate를 달고 옵니다.
내 세션이 시작된 시점이 아니라, iOS가 자기 편의대로 자른 구간의 시작점을요.
그러니까 콜백으로 들어오는 데이터 중에는 애초에 내 세션 이야기가 아닌 것이 섞여 있습니다. 그걸 내 베이스라인에서 빼면 당연히 엉뚱한 값이 나오죠.
그래서 세션 시작 시각과 어긋나는 업데이트를 아예 걸러냅니다.
// Filter: accept only updates whose startDate matches the session start.
// iOS can interleave "segment/window" updates with different startDate values.
NSTimeInterval startDiff = fabs([data.startDate timeIntervalSinceDate:self->_sessionStartDate]);
if (startDiff > 1.0) return;
1초 허용 오차를 둔 이유는 부동소수점 시각 비교를 정확히 같기로 기대하면 안 되기 때문입니다.
(==로 날짜를 비교하고 싶어질 때마다 이 줄을 떠올리시면 됩니다.)
정리하면 방어가 세 겹입니다. 필터로 내 세션이 아닌 창을 버리고, 델타로 방향 모호성을 없애고, 클램프+리베이스로 그래도 음수가 나오는 경우를 받아냅니다. 하나였으면 부족했을 겁니다.
그리고 이 재설계가 두 증상을 동시에 정리합니다.
증상 1이 사라지는 이유는 간단합니다. 세션 시작 순간의 누적값이 곧 베이스라인이니까, t=0에서 델타는 정확히 0입니다. catch-up 업데이트가 150걸음을 싣고 와도 그 150은 이미 베이스라인 안에 포함되어 있어서, 화면에는 0이 찍힙니다. 가짜 스파이크가 자동으로 상쇄됩니다.
증상 2가 사라지는 이유가 더 재밌습니다. 원시값이 뒤로 가더라도, 델타는 그냥 조금 더 작은 양수가 될 뿐입니다. "뒤로 갔다"라는 방향의 모호함 자체가 없어져요. 그리고 걸음 카운터 입장에서는 그게 올바른 출력입니다. 활동창 경계가 조정돼서 집계가 살짝 줄었다면, 세션 델타도 그만큼 줄어드는 게 맞으니까요.
그래서 이 커밋을 저는 이렇게 적었습니다.
커밋 323e1fa, 제목은 fix: 🐛 improve iOS step counter accuracy and monotonicity.
본문 요지는 이렇습니다.
- 세션 시작부터의 이력 데이터를 조회해 초기 걸음 베이스라인을 세운다
- 이후 라이브 업데이트는 이 베이스라인으로부터의 델타로 변환되어, 방출되는
steps값이 항상sessionStartDate기준 상대값이 되도록 한다 - 더 엄격한 단조 가드를 넣고, 음수 델타가 나오면 0으로 클램프한 뒤 리베이스한다
- 타임스탬프 파싱 견고성을 더하고 null pedometer 데이터를 우아하게 처리한다
세 번째 줄이 앞에서 본 클램프+리베이스입니다. 같은 아이디어가 옳은 좌표계로 옮겨오니까 갑자기 말이 되더라고요.
참고로 이 구조가 들고 있는 _baselineSteps / _lastEmittedSteps / _baselineReady / _sessionStartDate를 메인 스레드와 백그라운드 핸들러가 동기화 없이 같이 만졌고, 그게 나중에 v0.3.1의 데이터 레이스 수정(전용 serial dispatch queue 도입) 동기가 됩니다.
(버그를 고치면서 다른 버그를 심었다는 뜻입니다. 그 이야기는 다음 기회에요.)
덤 — 세션 리셋이 공짜로 딸려왔다
여기서 예상 못 한 보너스가 하나 나왔습니다.
v0.3.0을 준비하던 중에 이슈 #52를 다시 열어봤어요. 2025년 2월 17일에 @OlgaSavit 님이 올린 질문이었습니다.
날짜가 바뀌면 카운터를 어떻게 리셋하나요?
이게 원래는 꽤 성가신 요청이었습니다. JS 쪽에서 누산기를 들고 있다가 자정에 0으로 밀어주는 식의 코드를 사용자가 직접 써야 했거든요.
그런데 베이스라인+델타로 바꾸고 나니, 그냥 해결되어 있었습니다.
stopStepCounterUpdate()를 부르고 startStepCounterUpdate(new Date())로 다시 시작하면, 새 시작 날짜로 베이스라인 조회가 한 번 더 돌고, 그 뒤의 델타는 전부 새 베이스라인 기준으로 측정됩니다.
JS 쪽에서 수동으로 상태를 0으로 만들 필요가 없습니다.
"리셋"이 별도 기능이 아니라, 그냥 새 세션을 여는 일이 된 거예요.
기능을 하나 더 만든 게 아니라, 구조를 바꿨더니 기능이 하나 사라졌습니다. (이런 걸 만날 때가 제일 기분 좋습니다. 코드를 안 써도 되는 승리잖아요.)
일반화 — 이 패턴이 통하는 API의 조건
이 글을 만보기 이야기로만 읽으면 좀 아깝습니다.
베이스라인+델타는 아래 세 조건을 만족하는 모든 query-then-subscribe 텔레메트리 API에 그대로 적용됩니다.
- 값이 누적값으로 보고된다
- 이력을 들고 있다가, 구독하는 순간 catch-up 업데이트를 보낸다
- 라이브 콜백들 사이의 단조 순서를 보장하지 않는다
이 셋 중 하나라도 걸린다면, 플랫폼이 주는 원시 누적값을 화면에 그대로 띄우는 건 위험합니다. 세션 시작 시점을 한 번 붙잡고, 그 위의 상대값만 내보내세요. 플랫폼이 무슨 짓을 하든, 여러분이 정의한 좌표계 안에서는 숫자가 얌전해집니다.
마무리 — 숫자가 이상하면 숫자를 의심하기 전에
정리하면 이렇습니다.
세션 시작의 150걸음 스파이크와 걸음 수 역주행은 서로 다른 버그처럼 보였지만, 둘 다 CMPedometer가 데이터를 주는 방식에서 나온 하나의 문제였습니다.
그래서 필터를 두 개 다는 대신, 무엇을 내보낼지를 다시 정의하는 한 번의 재설계로 끝났습니다.
솔직히 고백하자면, 저는 이 문제를 한참 동안 "iOS API의 버그"라고 생각하고 있었습니다. 애플이 이상한 값을 준다고요. 그런데 문서와 실제 동작을 다시 맞춰보니, 애플은 애플이 약속한 것(활동창 단위 집계)을 정확히 지키고 있었고, 저는 약속한 적 없는 것(전역 단조 증가)을 혼자 기대하고 있었을 뿐이었어요. 버그는 API가 아니라 제 기대치에 있었습니다. (이걸 인정하는 데 걸린 시간이 코드 고치는 시간보다 훨씬 길었습니다. 헤헤.)
혹시 지금 어떤 SDK가 주는 누적 카운터 때문에 고생 중이시라면, 한번 물어보세요. "내가 지금 화면에 띄우는 이 숫자는, 대체 무엇을 기준으로 한 값이지?" 기준이 애매하면 숫자도 애매합니다. 기준을 내 손으로 정하는 순간 대부분의 이상 현상이 조용해지더라고요.
그리고 혹시 CMPedometer를 저보다 오래 다뤄보신 분이 계시다면, 제가 놓친 게 있는지 꼭 알려주세요.
저는 이 라이브러리를 만들면서 배우는 중이라, 더 나은 방법을 아시는 분의 지적이 진심으로 반갑습니다.