Test-based 운동 카운팅 App 알고리즘 개발
해당 앱 서비스는 압력 센서가 내장된 하드웨어를 기반으로 푸쉬업, 스쿼트 카운팅 기능을 제공한다.
이 과정에서 하드웨어 의존적인 운동 카운팅 구조를 앱 기반으로 전환하고, 데이터 시각화 및 테스트 시스템을 구축하였다. 이를 통해서 넓이 기반 알고리즘 구현 및 영점 보정, 노이즈 제거 로직 적용하고, 카운팅 알고리즘의 재현율(Recall)을 개선하였다.
1. 카운팅 알고리즘 구조 전환
하드웨어 펌웨어(C++) 기반 카운팅 구조를 앱으로 이전하여 서비스 이후에도 알고리즘 개선이 가능하도록 구조를 재설계하였다.
2. 데이터 시각화 기반 넓이 기반 운동 판단 알고리즘 개발
센서 데이터와 보조 지표를 그래프로 시각화하여 운동 패턴을 분석하였다.
이를 기반으로 시간과 압력 값을 결합한 넓이 개념을 도입하여 기존 임계값 기반 알고리즘의 한계를 개선하였다.
4. 카운팅 알고리즘 테스트 시스템 구축
알고리즘 검증을 위한 별도의 테스트 환경을 구축하여 재현성과 개발 속도를 개선하였다.
5. 테스트 시스템 기반 영점 보정 및 노이즈 제거 로직 구현
테스트 시스템을 기반으로 운동 중 발생하는 기준 값 변화와 노이즈 문제를 해결하기 위한 보정 로직을 설계하였다.
6. 성능 측정
기존 카운팅 알고리즘은 하드웨어 내부 펌웨어(C++)에서 수행되며 카운팅 결과 값만 앱으로 전달되는 구조였다.
기존 구조는 “카운팅 결과만 전달하는 폐쇄적인 구조”로 구성되어 있어 알고리즘을 개선하거나 확장할 수 없는 상태였다.
데이터 흐름이 “센서 → 펌웨어 → 결과 → 앱” 구조로 구성되어 있어 앱에서는 판단 과정에 개입할 수 없는 구조였다.
센서 데이터의 전체 흐름이 제거된 상태로 전달되기 때문에 데이터 기반 분석이나 알고리즘 개선이 불가능한 구조였다.
기존 구조는 알고리즘을 서비스 레벨에서 개선할 수 없는 상태였다.
데이터 흐름을 다음과 같이 변경하였다.

1.1. 기존 판단 방식 정리
1.2. 변경된 판단 구조
앱이 카운팅 로직을 직접 수행하도록 구조를 변경하였다.
이 개선의 핵심은 데이터 흐름을 재구성한 것에 있다.
카운팅 결과를 전달하던 구조에서 센서 데이터를 전달하고 앱에서 판단하는 구조로 전환
기존 푸쉬업 카운팅은 기준 값을 넘는지 여부를 중심으로 판단하는 구조였고 실제 운동 데이터에서는 이 방식으로 해결되지 않는 문제가 반복되었다.
기존 구조는 특정 시점의 값이 기준을 넘는지 여부와 원점 복귀 조건을 조합한 방식이었고 운동 전체 구간이 아닌 단일 이벤트 중심으로 판단하는 한계를 가진 상태였다.
기존 알고리즘은 특정 시점의 값이 기준 값을 초과하는지 여부를 중심으로 상태를 전환하는 구조였다.
이후 원점 범위로 복귀했는지를 확인하고 일정 시간 유지되면 카운팅을 수행했다.
판단 기준이 구간이 아닌 특정 시점의 값 초과 여부였기 때문에 운동의 전체 흐름이나 크기를 반영하지 못하는 구조였다.
기존 구조는 기준 값 초과 여부와 유지 시간을 각각 별도의 조건으로 처리했다.
이 구조에서는 “얼마나 크게 움직였는가”와 “얼마 동안 수행했는가”가 하나의 판단 기준으로 결합되지 않았다.
결과적으로 빠르게 반동을 주는 동작과 천천히 수행하는 동작을 동일한 기준으로 처리하지 못하는 문제가 발생했다.
센서 데이터는 기준 값을 중심으로 하강 후 상승하고 다시 복귀하는 형태의 파형을 가지지만 기존 구조는 이를 하나의 구간으로 처리하지 않았다.
이 두 구간이 하나의 운동 사이클로 묶이지 않고 분리된 상태로 처리되었다.
특히 기준 값 아래로 크게 떨어졌다가 반동으로 올라오는 치팅 패턴을 구분할 수 없었다.
기존 구조는 운동을 연속된 구간이 아닌 단일 이벤트로 처리하는 방식이었기 때문에 실제 데이터 패턴을 반영하지 못했다.
기존 구조는 특정 시점의 값으로 판단했지만 변경 구조는 하나의 운동을 시작부터 종료까지 하나의 구간으로 정의했다.
데이터를 단일 값이 아닌 구간 단위로 수집하고 해당 구간 전체를 기반으로 판단하도록 변경하였다.
1.1. 기존 판단 방식 정리
구간 전체를 계산하지 않고 특정 시점 조건의 조합으로 판단하는 방식이었다.
1.2. 변경된 판단 구조
판단 단위를 “값”이 아니라 “구간”으로 변경하였다.
1.3. 판단 기준 재설계
구간 동안의 데이터로 높이와 시간을 계산하고 이를 곱한 값으로 카운팅을 판단했다.
판단 기준을 기준 값 초과 여부에서 “구간의 크기 × 수행 시간”으로 변경하였다.
로그 데이터를 기반으로 시간 순서대로 weight, 기준 값, 넓이 값을 시각화하여 실제 운동 파형을 분석했다.

시각화 결과 다음과 같은 특징을 확인했다.
데이터 파형을 기준으로 알고리즘 구조를 설계하도록 방향을 변경하였다.
초기 구조는 기준 값 위 구간만 계산했지만 이후 하강 구간까지 포함하도록 변경하였다.
최종 높이는 두 값을 합산하여 계산하였다.
운동을 단일 방향이 아닌 하강과 상승을 포함한 하나의 사이클로 처리하도록 변경하였다.
기준 값 조정으로 해결되지 않던 치팅 동작과 느린 동작 문제를 동시에 해결할 수 있는 구조를 확보하였다
이후 범위 조정, 시간 가중치, 보정 로직을 추가할 수 있는 구조적 기반이 마련되었다
이 개선의 핵심은 카운팅 기준을 기준 값 초과 여부에서 운동 구간 전체의 넓이 계산으로 변경한 데 있다.
단순 기준 값으로 판단하던 구조에서 운동 구간 전체의 면적을 기준으로 판단하는 구조로 전환
넓이 기반 알고리즘으로 판단 구조를 바꾼 이후에도 실제 데이터에서는 안정 구간, 튀는 값, 기준 값 변화 때문에 오탐과 미탐이 반복되었다. 테스트를 진행할수록 같은 알고리즘이라도 로그 형태와 사용자 조건에 따라 다른 문제가 드러났다.
넓이 기반 판단 구조를 도입한 것만으로는 충분하지 않았고, 실제 로그를 반복 재생할 수 있는 테스트 환경과 기준 값 변화 및 노이즈를 처리하는 보정 구조가 함께 필요한 상태였다.
알고리즘을 수정해도 같은 입력을 다시 넣어 비교할 수 없었기 때문에, 어떤 수정이 실제로 문제를 해결했는지 빠르게 확인하기 어려운 구조였다.
운동 중 기준 값이 바뀌는 상황을 별도로 처리하지 못하면 같은 동작이라도 이전 구간의 기준 값이 다음 구간 계산에 남아 오차를 누적시키는 구조였다.
계산 구간이 끝난 뒤 필요한 값만 남기고 정리하지 않으면 이전 구간의 노이즈와 극값이 다음 카운팅 판단에 개입하는 구조였다.

알고리즘을 수정한 뒤 같은 로그를 다시 넣어 결과를 비교할 수 있는 구조로 바꾸어, 문제 재현과 수정 검증을 한 번의 흐름에서 처리할 수 있게 했다.
1.1. 변경된 테스트 구조
운동을 수행하는 동안 발생한 로그를 기반으로 입력을 재현할 수 있도록 만들어 알고리즘 수정 결과를 비교 가능한 형태로 바꾸었다.
1.2. 시각화 및 결과 확인 구조
카운팅이 발생한 지점과 실제 압력 값 변화가 맞는지 시각적으로 바로 확인할 수 있게 했다.
2.1. 초기 영점 보정 방식
영점을 다시 잡는 구조 자체는 만들었지만 조건이 느슨하면 운동 중간에도 기준 값이 바뀌는 문제가 있었다.
2.2. 보정값 기반 구조로 변경
기준 값이 바뀌더라도 수식 계산에는 차이 값만 반영해 현재 구간 계산이 무너지지 않도록 구성했다.
2.3. 이동 평균 적용
기준 값을 단일 측정 값으로 고정하지 않고 평균 값으로 관리해 느린 동작과 큰 흔들림이 동시에 들어와도 기준이 급격히 무너지지 않도록 했다.
width ** 1.5를 제거했고, 보정 값도 제외했다.이전 사이클의 최저점이 다음 상승 판단에 남지 않도록 정리해 중간 값 오판을 줄였다.
기준 상수를 사용자 데이터 범위에 맞게 조정해 여성이나 변화량이 작은 데이터도 정상적으로 처리할 수 있게 했다.
이 개선의 핵심은 알고리즘 수식만 고친 것이 아니라, 같은 입력을 반복 검증할 수 있는 테스트 시스템 구축과 기준 값 변화 및 노이즈를 처리하는 보정 구조를 함께 만든 데 있다.
테스트 시스템을 기반으로, 넓이 계산식 하나로 문제를 해결하던 구조에서 영점 보정, 노이즈 제거를 함께 다루는 안정화 구조로 전환
int push_cnt = 0;
...
if (weight_data > push_ref_val * push_range) {
has_crossed_threshold_pushup = true;
}PUSH=1
PUSH=2push_cnt++;
pTxCharacteristic->setValue("PUSH=" + intToString(push_cnt));PUSH=19612.9
PUSH=20460.0const data = parseNumberValue(ble_value);
countPush(data);if (weight_data > push_ref_val * push_range) {
has_crossed_threshold_pushup = true;
}if (millis() - push_zero_time >= min_zero_hold_time_push) {
push_cnt++;
}if (weight_data > push_ref_val * push_range) {
has_crossed_threshold_pushup = true;
}filteredDataBufferRef.current.push(
data - standardWeightRef.current
);
if (!hasCrossedThresholdRef.current) {
exCrossStartTimeRef.current = Date.now();
hasCrossedThresholdRef.current = true;
}const height =
filteredDataBufferRef.current.reduce(
(sum, value) => sum + value,
0
) / filteredDataBufferRef.current.length;
const width = Date.now() - exCrossStartTimeRef.current;
if (height * width > MINIMUM_AREA) {
exCountRef.current++;
}const height = highestWeight.current + lowestWeight.current;
const width = Date.now() - exCrossStartTimeRef.current;const interval = setInterval(() => {
setCurrentLogData(exValues[indexRef.current]);
graphDataRef.current.push({
time: Date.now() - startedTimeRef.current,
Weight: exValues[indexRef.current],
});
setGraphData([...graphDataRef.current]);
ex === "SQUAT"
? countSquat(Number(exValues[indexRef.current]))
: ex === "PUSH"
? countPush(Number(exValues[indexRef.current]))
: countPlank(Number(exValues[indexRef.current]));
indexRef.current++;
if (indexRef.current >= exValues.length) {
clearInterval(interval);
}
}, 200);useEffect(() => {
if (count === 0) return;
countIndexRef.current.push(indexRef.current);
setCountIndex([...countIndexRef.current]);
}, [count]);const renderDotByIndex = (props) => {
const { cx, cy, index } = props;
if (countIndex.includes(index)) {
return <circle cx={cx} cy={cy} r={6} fill="red" />;
}
return null;
};if (
standardSetupBufferRef.current[0] > data * 0.95 &&
standardSetupBufferRef.current[0] < data * 1.05 &&
standardSetupBufferRef.current[1] > data * 0.95 &&
standardSetupBufferRef.current[1] < data * 1.05
) {
exLowerLimitRef.current = data;
exUpperLimitRef.current = data;
standardWeightRef.current = data;
}if (
standardSetupBufferRef.current[0] > data * 0.98 &&
standardSetupBufferRef.current[0] < data * 1.04 &&
standardSetupBufferRef.current[1] > data * 0.99 &&
standardSetupBufferRef.current[1] < data * 1.02 &&
Math.abs(data - standardWeightRef.current) < 2000
) {
correctionValueRef.current = standardWeightRef.current - data;
standardWeightRef.current = data;
}const height =
highestWeight.current +
lowestWeight.current +
correctionValueRef.current;standardWeightStackRef.current.push(data);
standardWeightRef.current = standardWeightStackRef.current.length
? standardWeightStackRef.current.reduce((sum, num) => sum + num, 0) /
standardWeightStackRef.current.length
: data;if (data > standardWeightRef.current) {
if (!hasCrossedThresholdRef.current) {
exCrossStartTimeRef.current = Date.now();
hasCrossedThresholdRef.current = true;
}
lowestWeight.current = 0;
if (highestWeight.current < data - standardWeightRef.current)
highestWeight.current = data - standardWeightRef.current;
}const adjustmentConstant =
standardWeightRef.current < 27000 ? 0.6 : 1.0;
if (result > SQUAT_MINIMUM_AREA * adjustmentConstant) {
exCountRef.current++;
}