스마트 홈트레이닝 IoT App 서비스 개발
하드웨어 센서 기반 운동 데이터를 실시간으로 처리하고 사용자에게 카운팅 및 인터랙션을 제공하는 홈트레이닝 하이브리드 앱 개발을 전담하였다.
1. 실시간 BLE 데이터 스트림 처리 및 운동 화면 구현
센서로부터 블루투스를 통해 약 200ms 주기로 수신되는 실시간 데이터를 기반으로 푸쉬업, 스쿼트, 플랭크 운동 상태를 실시간으로 처리하고 UI에 반영하는 구조를 구현하였다.
2. 렌더링 성능 최적화 및 상태 관리 개선
운동 세트 및 목표 횟수가 증가할 때 발생하는 렌더링 지연 문제를 분석하고 최적화하였다.
3. TypeScript 마이그레이션 및 FSD 아키텍처 도입
외주 과정에서 작성된 JSX 코드를 점진적으로 TypeScript로 전환하고 FSD 아키텍처를 도입하여 유지보수성을 개선하였다.
4. 사용자 인터페이스 및 공통 컴포넌트 개발
사용자가 운동 설정 및 진행에 필요한 UI 컴포넌트를 설계 및 구현하였다.
5. 앱 빌드 및 스토어 등록
Android와 iOS 환경에서 빌드 및 스토어 등록을 수행하였다.
센서 데이터가 약 200ms 주기로 지속적으로 유입되는 환경에서 데이터 처리와 UI 렌더링을 동시에 수행해야 했으며 다음과 같은 문제가 발생했다.
실시간 데이터는 들어오고 있었지만 처리 구조와 렌더링 구조가 분리되지 않은 상태였다.
앱에서 센서 데이터 수신과 상태 업데이트를 동시에 처리하면서 입력 처리 주기가 UI 성능에 종속되는 구조였다.
useState 기반으로 구성됨상태를 기준으로 사용할 경우 입력 주기와 렌더링 타이밍이 엇갈리며 지연이 발생하였다.
데이터를 “UI 상태”가 아닌 “독립적인 처리 흐름”으로 다루도록 변경하였다.
1.1. 입력 수신과 처리 단계 분리
입력 수신 단계에서 처리 로직을 직접 실행하지 않고 분기만 수행하도록 구성하였다.
1.2. 처리 기준과 UI 상태 분리
판단의 기준이 되는 값들은 useRef에 저장하고, 판단 이후 UI에 반영해야 되는 경우에 useState를 활용하였다.
useRef로 관리useState로 관리입력 주기와 무관하게 내부 처리 기준이 유지되도록 구성하였다.
1.3. 상태 업데이트 최소화
렌더링 발생 횟수를 줄여 입력 처리 흐름이 끊기지 않도록 구성하였다.
입력 수신부는 분기만 담당하고 실제 처리는 분리된 구조에서 실행되도록 구성하였다.
useMemo, useCallback을 적용하여 함수 재생성 방지FlatList로 변경하여 렌더링 범위 제한렌더링 비용이 데이터 처리 주기에 영향을 주지 않도록 구조를 정리하였다.
이 구조 개선의 핵심은 처리 로직 변경이 아니라 구조 분리에 있다.
실시간 데이터를 UI 상태의 일부로 처리하던 구조에서 독립적인 처리 파이프라인으로 전환
운동 진행 화면에서 사용자에게 제공되는 정보는 단순 카운트 값 중심으로 구성되어 있었으며 다음과 같은 문제가 존재했다.
운동 데이터는 존재하지만 사용자 행동 흐름과 연결되지 않는 UI 구조였다.
1.1. 세트 기반 상태 표현 UI 재구성

기존 목표 설정 UI는 숫자 입력 기반으로 구성되어 있었으며 모바일 환경에서 키보드 기반 입력은 입력 흐름을 끊고 불편함을 유발하였다.
3.1. 직접 구현
외부 라이브러리 및 플랫폼 기본 컴포넌트를 검토하였으나 다음과 같은 제약이 있었다.
이러한 이유로 요구사항에 맞는 구조를 확보하기 위해 직접 구현하였다.
3.2. 컴포넌트 구조 설계
3.3. 상태 관리 방식
입력 이벤트가 아닌 위치 기반으로 상태를 계산하도록 구성하여 상태 변경 흐름을 단순화하였다.
렌더링 상태와 선택 상태를 분리하였다.
이 구조를 통해 불필요한 리렌더링을 줄일 수 있었다.
3.4. 스크롤 및 snap 처리
스크롤 중에는 상태 업데이트를 최소화하고 종료 시점에만 상태를 반영하도록 처리하였다.
3.5. 렌더링 최적화

이 개선의 핵심은 UI 요소를 추가한 것이 아니라,
데이터를 보여주는 UI에서 사용자의 행동 흐름을 유도하는 인터랙션 중심 UI로 전환
const onBleManagerUpdate = useCallback(({ value }: { value: any }) => {
const data = String.fromCharCode(...value);
const [ble_header, ble_value] = data.split("=");
if (headerActions[ble_header as keyof typeof headerActions]) {
headerActions[ble_header as keyof typeof headerActions](ble_value);
}
}, []);if (!hasCrossedThresholdRef.current) {
exCrossStartTimeRef.current = Date.now(); // 되는 값들은 useRef에 저장.
hasCrossedThresholdRef.current = true;
}
filteredDataBufferRef.current.push(
data - standardWeightRef.current
);
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++;
sendProtocol("PUSH_CNT=" + exCountRef.current);
setExCount(exCountRef.current); // 판단 이후 UI에 반영해야 되는 경우에 useState.
}constheaderActions = useMemo(
() => ({
PUSH: (ble_value:string) => countPush(parseNumberValue(ble_value)),
SQUAT: (ble_value:string) => countSquat(parseNumberValue(ble_value)),
PLANK: (ble_value:string) => countPlank(parseNumberValue(ble_value)),
}),
[countPush, countSquat, countPlank]
);