스마트폰은 걸음 수를 어떻게 셀까? (센서가 없으면 직접 셉니다)

만보기 앱을 켜두면 주머니 속 폰이 알아서 걸음을 셉니다. 당연하게 쓰고 있었는데, 막상 제가 직접 걸음 수를 세는 라이브러리를 만들려니까 아주 근본적인 질문 앞에서 멈추게 되더라고요.
폰은 대체 "한 걸음"을 어떻게 아는 걸까?
요즘 폰에는 대부분 전용 걸음 센서(TYPE_STEP_COUNTER 같은 하드웨어 센서)가 박혀 있어서, 사실 앱이 하는 일이 별로 없습니다. "야 몇 걸음이야?" 물어보면 센서가 "3521걸음" 하고 대답해주거든요. 편하죠.
문제는 그 센서가 없는 기기입니다. 오래된 안드로이드, 저가형, 혹은 그냥 제조사가 안 넣은 경우. 이럴 때는 앱이 직접 세는 수밖에 없습니다. 재료는 딱 하나, 가속도계(accelerometer) 뿐이고요. (이게 없는 스마트폰은 없다고 봐도 될 것 같아요.)
이 글은 "센서가 없을 때 가속도계 값만 가지고 걸음을 세는" 그 알고리즘 이야기입니다. 교과서가 말하는 정석 3단계를 먼저 보고, 그다음에 제 라이브러리의 진짜 코드가 그 교과서를 어떻게 살짝 배신하는지까지 보겠습니다. (스포: 코드가 더 똑똑합니다. 제가 똑똑한 게 아니고요.)
재료: 가속도계는 뭘 주는가
가속도계는 매 순간 3축 가속도 벡터 (x, y, z)를 뱉습니다. 폰이 어느 방향으로 얼마나 세게 움직이는지를 실시간으로 알려주는 거죠.
그런데 이게 은근히 성가십니다. 왜냐하면 이 값에는 중력이 항상 섞여 있기 때문입니다. 폰을 가만히 책상에 둬도 z축에 9.8 정도가 계속 찍혀요. 지구가 폰을 아래로 당기고 있으니까요. 걸음이라는 "진짜 움직임"은 이 거대한 중력 배경 위에 살짝 얹힌 잔물결에 가깝습니다.
그러니까 우리가 할 일은, 이 지저분한 신호에서 걸음의 리듬만 골라내는 것입니다. 전통시장에서 좋은 콩나물만 골라 담듯이요.
정석은 3단계입니다.
1단계 — 필터: 노이즈를 걷어낸다
사람의 보행 리듬은 대략 0.5~3Hz 대역에 있습니다. 초당 0.5~3걸음, 즉 아무리 빨리 걸어도 초당 세 걸음 언저리라는 뜻이죠. 그보다 훨씬 빠르게 떨리는 값들은 대부분 센서 노이즈거나 손 떨림입니다.
그래서 저역 통과 필터나 이동 평균(moving average) 으로 고주파 잡음을 눌러줍니다. 제 라이브러리는 이동 평균을 씁니다. 최근 50개 값을 계속 굴리면서 평균을 내요.
private const val ACCEL_RING_SIZE = 50
// 최근 50개 가속도 벡터를 링 버퍼에 계속 덮어쓴다
accelRingX[accelRingCounter % ACCEL_RING_SIZE] = eventData[0]
accelRingY[accelRingCounter % ACCEL_RING_SIZE] = eventData[1]
accelRingZ[accelRingCounter % ACCEL_RING_SIZE] = eventData[2]
// 그 50개의 평균 = "지금 중력이 어느 쪽을 향하는가"의 추정치
val gravity: FloatArray =
floatArrayOf(
sum(accelRingX) / min(accelRingCounter, ACCEL_RING_SIZE),
sum(accelRingY) / min(accelRingCounter, ACCEL_RING_SIZE),
sum(accelRingZ) / min(accelRingCounter, ACCEL_RING_SIZE),
)
여기서 재밌는 포인트. 이 평균값을 그냥 "부드러워진 신호"로 쓰는 게 아니라, "중력이 향하는 방향"의 추정치로 씁니다. 짧은 순간의 걸음 진동은 위아래로 왔다 갔다 하다가 평균 내면 상쇄되고, 남는 건 꾸준히 아래를 향하는 중력뿐이니까요. (필터 하나로 노이즈도 걷어내고 중력 방향도 알아내는, 일석이조입니다.)
2단계 — 크기(magnitude): 어떻게 들든 똑같이
여기가 교과서와 실제 코드가 갈리는 지점입니다.
교과서 버전은 이렇게 말합니다: 벡터의 크기 sqrt(x² + y² + z²)를 구해라. 이러면 폰을 세로로 들든 가로로 눕히든 주머니에 처박든, 방향에 상관없이 움직임의 세기만 딱 나오거든요. 그래서 이 단계를 "orientation-invariant(방향 불변)하게 만든다"라고 부릅니다.
이론적으로는 깔끔합니다. 그런데 제 코드는 이걸 안 씁니다.
// 현재 가속도를 "중력 방향" 축에 투영하고, 중력 크기만큼을 뺀다
val currentZ: Float = dot(normalize(gravity), eventData) - norm(gravity)
한 줄인데 하는 일이 꽤 영리합니다. 풀어보면:
normalize(gravity)— 1단계에서 얻은 중력 방향을 길이 1짜리 방향 벡터로 만듭니다.dot(..., eventData)— 지금 이 순간의 가속도를 그 중력 축 방향으로 투영합니다. "수직 방향으로 얼마나 움직였나"만 뽑아내는 거죠.- norm(gravity)— 거기서 중력 크기(≈9.8)를 빼버립니다. 그러면 순수하게 걸음 때문에 생긴 수직 가속도만 남습니다.
왜 이게 교과서의 단순 크기 계산보다 나을까요? 걸음은 본질적으로 수직 운동이거든요. 발을 디딜 때마다 몸이 위아래로 통통 튑니다. 그냥 전체 크기를 재면 좌우로 팔을 흔드는 것 같은 무관한 움직임까지 다 섞여 들어오는데, 중력 축(=수직)에만 투영하면 딱 걸음의 상하 진동만 남습니다. 방향 불변성은 챙기면서, 노이즈는 더 줄인 거죠.
참고로 normalize에는 이런 방어 코드가 들어 있습니다. 별거 아닌 것 같아도 없으면 앱이 죽습니다.
fun normalize(vector: FloatArray): FloatArray {
val normed = norm(vector)
if (normed == 0.0f) return vector.copyOf() // 0으로 나누기 방지
return FloatArray(vector.size) { i -> vector[i] / normed }
}
무중력 상태(크기 0인 벡터)가 들어오면 0으로 나누기가 터지면서 NaN이 온 신호에 전염됩니다. 실제로 걸을 때 이런 값이 나올 일은 거의 없지만, "거의 없다"와 "절대 없다"는 프로덕션에서 하늘과 땅 차이잖아요. 네. 하셨죠? 이런 가드는 꼭 넣읍시다.
3단계 — 피크 검출 + 불응기: 한 번 튄 걸 한 걸음으로
한 가지만 짚고 가면, 실제 코드는 2단계에서 나온 currentZ를 곧장 쓰지 않고 최근 10개를 한 번 더 합산해 velocityEstimate라는 값으로 부드럽게 만듭니다(VELOCITY_RING_SIZE = 10). 순간적인 튐 하나에 과민 반응하지 않으려는 완충 장치죠. 아래 코드에 나오는 velocityEstimate가 바로 이 평활화된 신호입니다.
이제 깨끗한 수직 신호가 있으니, "튀어 오른 순간"을 걸음으로 세면 됩니다. 근데 여기 함정이 하나 있어요.
한 번의 걸음이 만드는 봉우리가 뾰족하지 않고 여러 번 잘게 떨릴 수 있다는 겁니다. 그러면 한 걸음이 두세 걸음으로 뻥튀기돼요. (만보기가 가만히 있는데 걸음이 올라가는 그 짜증, 다들 아시죠.)
그래서 두 개의 장치를 씁니다. 임계값 상향 교차와 불응기 창(refractory window).
private const val STEP_THRESHOLD = 12f // 걸음으로 인정할 최소 세기
private const val STEP_DELAY_NS = 250000000 // 250ms
val isWalkingOrRunning: Boolean =
STEP_THRESHOLD in oldVelocityEstimate..<velocityEstimate && // 임계값을 "아래→위"로 넘는 순간만
timeNs - lastStepTimeNs > STEP_DELAY_NS // 직전 걸음에서 250ms는 지났을 때만
첫 번째 조건 STEP_THRESHOLD in oldVelocityEstimate..<velocityEstimate가 예쁩니다. 이전 값은 12 아래였는데 지금 값이 12 위라면, 그 사이 어딘가에서 신호가 임계값을 아래에서 위로 뚫고 올라왔다는 뜻이죠. 봉우리의 "올라가는 모서리(rising edge)"만 딱 잡습니다. 위에 계속 떠 있는 동안 계속 세지 않도록요.
두 번째 조건이 바로 불응기입니다. 한 걸음을 센 뒤 250ms 동안은 무조건 무시합니다. 사람이 아무리 뛰어도 0.25초 안에 두 걸음을 걷지는 못하니까요. 이 짧은 침묵이 "한 번 튄 걸 한 걸음으로" 묶어주는 핵심입니다. (심장에도 불응기가 있죠. 한 번 뛴 직후엔 아무리 자극해도 안 뛰는. 원리가 똑같습니다.)
그래서, 정석은 정석인데
여기까지가 필터 → 크기 → 피크검출+불응기 3단계 파이프라인입니다.
재밌는 건, 이 구조가 어느 한 논문의 발명이 아니라는 점입니다. 학계 논문이든, 반도체 벤더(Analog Devices 같은)의 앱노트든, 블로그 튜토리얼이든 — 걸음 검출을 다루는 자료들은 하나같이 이 같은 뼈대로 수렴합니다. 다만 세부에서 갈리죠.
- 필터 선택 — 이동 평균이냐 IIR 저역통과냐
- 임계값 전략 — 고정값이냐 사용자별로 보정하냐
- 불응기 크기 — 200ms냐 400ms냐
그러니 제가 위에서 쓴 STEP_THRESHOLD = 12f, 250ms 같은 숫자들은 제 라이브러리의 선택이지 우주의 진리가 아닙니다. (주석에 // 4f-16f라고 적어둔 게 보이시죠? 저 범위 안에서 저도 한참 만졌다는 흔적입니다.) 여러분 앱에서 걸음이 헐렁하게 세지거나 빡빡하게 세진다면, 가장 먼저 만질 손잡이가 바로 이 두 숫자예요.
마무리 — 당연하던 게 안 당연해질 때
주머니 속 폰이 걸음을 세는 그 평범한 일이, 뜯어보면 이렇게 생겼습니다.
노이즈를 걷어내고(필터) → 걸음의 수직 진동만 뽑아내고(중력 축 투영) → 봉우리의 상승 모서리를 불응기와 함께 세는(피크검출), 세 박자의 신호 처리.
솔직히 저도 전용 센서만 쓸 땐 이 안쪽을 들여다볼 일이 없었습니다. 센서가 없는 기기를 만나고 나서야 "직접 세야 하네?" 하고 등 떠밀려 열어본 거죠. 그런데 열어보니, 한 줄짜리 벡터 투영 안에 걸음의 물리학이 통째로 접혀 있더라고요. 이런 순간이 개발이 재밌는 지점 같아요. 당연하던 게 갑자기 안 당연해지는 순간. 꺄하하.
혹시 만보기 앱 만드시다가 걸음이 이상하게 세진다면, 위의 세 단계 중 어디가 말썽인지 짚어보세요. 십중팔구 임계값 아니면 불응기입니다. 그리고 혹시 제 설명 중에 틀린 부분을 발견하셨다면, 주저 말고 지적해주세요. 저도 이거 만들면서 배운 거라, 여러분이 아는 더 나은 방법이 있다면 진심으로 궁금합니다.