대부분의 동반 앱은 얇은 BLE 리모컨입니다: 버튼을 누르고, 값을 작성하고, 끝. 디스펜서는 그렇게 얇을 수 없습니다 — 디스펜스는 정확히 한 번 기록되어야 하는 건강 이벤트이며, 장치는 표준 GATT 프로필이 아닌 맞춤형 이진 프로토콜을 사용하며, "복용을 놓치지 마세요"는 전화기가 깨어 있는지에 의존할 수 없습니다. 여기서는 탭에서 내구성 있는 조정 가능한 상태로의 경로를 구축한 방법을 설명합니다, 우리의 웰니스 & 피트니스 앱 개발 작업의 일환으로.
한눈에 보기
도메인 연결된 웰니스 — 스마트 보충제 디스펜싱
핵심 기술 React Native + Expo, react-native-ble-plx, Zustand, NestJS, MongoDB 8 (트랜잭션)
주요 기능 맞춤형 BLE 명령/텔레메트리 프로토콜, SMP/CBOR 펌웨어 업데이트, 트랜잭션 디스펜스 기록, 시간대 인식 알림
상태 개발 2단계 진입
도전 과제
우리의 고객은 연결된 웰니스 제품을 만들고 있었습니다: 보충제를 분배하는 장치로, 전화기로 제어됩니다. 동반 앱을 구축하는 일반적인 방법 — 대부분이 그렇게 구축됩니다 — 은 특성에 기록하고 결과를 신뢰하는 얇은 리모컨으로 만드는 것입니다. 이는 전구에는 작동하지만, 디스펜서에는 거의 작동하지 않습니다.
우리는 그것을 알고 있었기 때문에 그렇게 만들지 않았습니다. 얇은 리모컨 모델의 한계는 구조적이지, 조정 문제가 아닙니다:
- 디스펜스는 정확히 한 번 기록되어야 합니다. 이는 물리적 재고를 감소시키고, 일일 복용량에 포함되며, 영양 분석에 피드됩니다. 이중 계산이나 기록 손실은 이 세 가지 모두를 손상시킵니다.
- 장치는 프로필이 아닌 맞춤형 프로토콜을 사용합니다. 명령은 프레임화된 이진 패킷이며, "하나의 태블릿을 분배"라는 의미의 기성 특성은 없습니다.
- 명령과 텔레메트리는 형태를 공유하지 않습니다. 앱은 압축된 이진 명령을 작성하지만 JSON 텔레메트리(배터리, 충전, 카트리지 ID, 물의 양)를 다른 특성을 통해 읽습니다.
- 펌웨어 업데이트는 동일한 연결을 사용합니다. 장치는 현장에서 별도의 관리 서비스를 통해 업그레이드 가능해야 하며, 두 번째 도구 없이 가능합니다.
- 알림은 장치에 있을 수 없습니다. "오늘 복용하지 않았습니다"는 시간대, 역사, 중복 제거에 관한 질문이며, 이는 서버의 문제이지 전화 타이머의 문제가 아닙니다.
근본 원인은 간단합니다: 디스펜스는 버튼 누르기가 아니라 트랜잭션입니다 — 네트워크와 데이터베이스 모두에서. 이를 단순한 기록으로 취급하는 모델은 어느 쪽도 신뢰할 수 없게 만듭니다.
우리의 솔루션
각 계층이 가장 잘하는 일을 하도록 책임을 분리했습니다: 전화기는 조정하고, 장치는 우리의 IoT 애플리케이션 개발 실습을 통해 정의된 맞춤형 프로토콜을 실행하며, 클라우드는 트랜잭션 시스템의 기록입니다 — 알림은 전적으로 서버 측에서 처리됩니다.
아키텍처
- React Native + Expo + Zustand — BluetoothStore는 BLE 연결, 디스펜스 흐름, 텔레메트리를 소유하며, AsyncStorage에 지속됩니다.
- react-native-ble-plx — 스캔, 연결/재연결, 특성 읽기/쓰기.
- 맞춤형 명령 계층 — 장치를 위한 프레임화된 이진 명령을 구축하고, 펌웨어 업데이트를 위한 SMP 서비스를 통해 CBOR을 사용합니다.
- NestJS API — 디스펜스 및 일정 엔드포인트, 우리가 클라이언트 제품 전반에 사용하는 동일한 SaaS 애플리케이션 개발 기반으로 구축; 각 디스펜스를 MongoDB 트랜잭션 내에 기록합니다.
- MongoDB 8 (Mongoose) — 기록 시스템, 다운스트림 분석이 읽는 쓰기 시점 롤업 컬렉션 포함.
- NestJS Schedule 크론 + Expo 푸시 (ActiveMQ/STOMP를 통해 큐잉) — 시간대 인식 알림, Mongo 로그를 통해 중복 제거.
- Sentry (모바일) — 앱의 프로덕션 오류 추적.

맞춤형 와이어 프로토콜
어떤 BLE 제품에서든 어려운 부분은 장치가 표준 언어를 사용하지 않는다는 것입니다. 우리의 장치는 명령/텔레메트리 서비스와 별도의 펌웨어 (SMP) 서비스를 노출합니다. 앱은 react-native-ble-plx와 연결한 후 텔레메트리에 구독합니다 — 이는 알림 특성에서 JSON으로 도착합니다:
// BluetoothStore.ts — react-native-ble-plx + Zustand
const device = await bleManager.connectToDevice(deviceId, { autoConnect: true });
await device.discoverAllServicesAndCharacteristics();
// Telemetry: JSON over the notify characteristic (UUIDs are app-side constants).
bleManager.monitorCharacteristicForDevice(device.id, CH_SERVICE_UUID, TX_UUID, (_err, c) => {
if (!c?.value) return;
const t = JSON.parse(base64ToString(c.value));
// { battery_percentage, charging_status, cartridge_id, water_qty, sequence, timestamp }
set({ batteryPercentage: Number(t.battery_percentage), chargingStatus: t.charging_status });
});
디스펜스는 컴팩트한 프레임화된 이진 명령입니다 — 시작 마커, 명령 ID, 길이, 카트리지 바이트, 읽기/쓰기 플래그, 종료 마커 — base64로 인코딩되어 명령 특성에 기록됩니다. (여기서는 실제 UUID와 프레임 마커가 생략되었습니다.)
// Frame shape: [SOF] [CMD] [LEN] [DATA] [RW] [EOF] — built by the command service.
const command = SNBCommandService.buildDispenseNutritionCommand(cartridgeId); // CMD = DISPENSE
await bleManager.writeCharacteristicWithResponseForDevice(
device.id, CH_SERVICE_UUID, RX_UUID,
SNBCommandService.uint8ArrayToBase64(command),
);
앱은 장치의 확인을 기다립니다 (응답 프레임에서 명령 ID를 다시 매칭) 그런 다음에야 서버 측에 기록합니다. 펌웨어 업데이트는 동일한 연결을 사용하지만 다른 언어를 사용합니다: SMP 서비스를 통한 청크화된 CBOR 페이로드.
탭에서 기록까지
확인된 디스펜스는 하나의 엔드포인트를 통해 내구성 있는 상태가 됩니다 — POST /dispense/tablet/:cartridgeId — 이는 단일 MongoDB 트랜잭션 내에서 기록하여 반영됩니다:
// dispense.service.ts — record exactly once, atomically
await session.withTransaction(async () => {
await this.dispensed.create([{ cartridgeId, cartridgeModalId, dispensedBy: userId, dispensedAt }], { session });
// write-time roll-up: totalDaysConsumed only increments on the first dose of the day
await this.monthly.findOneAndUpdate(
{ userId, cartridgeModalId, month, year },
{ $inc: { totalTablets: 1, ...(firstDoseToday ? { totalDaysConsumed: 1 } : {}) } },
{ upsert: true, session },
);
await this.cartridge.updateOne({ cartridgeId }, { $inc: { tablets: -1 } }, { session });
await this.dailyDose.updateOne({ userId, dateLocal }, { $inc: { tabletsTaken: 1 } }, { upsert: true, session });
await this.notifications.cancelPending(userId, 'SUPPLEMENT_REMINDER', { session }); // mark IS_MISSED
});
그 단일 트랜잭션은 디스펜스 로그, 월별 롤업, 물리적 재고, 일일 복용량 카운트, 그리고 모든 대기 중인 알림을 업데이트합니다 — 이것이 바로 디스펜스가 기록이 아닌 트랜잭션이어야 하는 이유입니다.
알림은 서버의 문제입니다
디스펜스가 내구성을 가지면, 전화 타이머가 답할 수 없는 질문들이 일상적이 됩니다 — 그리고 이는 앱이 아닌 크론 작업에 의해 답변됩니다:
- 이 사용자는 오늘, 그들의 시간대에서 복용했습니까?
- 카트리지가 비어 있습니까 (교체) 아니면 재고가 비어 있습니까 (재주문), 아니면 둘 다 (중단)?
- 그들은 이전에 복용했던 건강 영역에서 멀어졌습니까?
세 가지 @nestjs/schedule 서비스가 이를 처리합니다 — 일정/복용 알림, 카트리지 상태 알림, 교차 건강 비활성 알림. 각 서비스는 사용자의 현지 시간을 date-fns-tz와 함께 해결하고, 오늘 복용했는지 여부를 Mongo에서 확인하고, 로그 컬렉션에 대해 중복 제거하며, ActiveMQ를 통해 Expo 푸시를 큐잉합니다. 장치는 절대 관여하지 않습니다.
결과
"디스펜스는 트랜잭션이다"로 설계하는 것은 시스템이 보장할 수 있는 것을 변화시켰습니다:
- 정확히 한 번 기록. 디스펜스는 로그, 롤업, 재고, 일일 복용량을 원자적으로 업데이트합니다 — 이중 계산 없음, 부분 기록 없음.
- 문서화된 장치 프로토콜, 필드 업그레이드 포함. 명령과 텔레메트리는 일급이며, 펌웨어 업데이트는 SMP/CBOR을 통해 동일한 연결로 전송됩니다.
- 정확한 알림, 근사치가 아닌. 시간대 인식, 역사 인식, 서버 측 중복 제거 — 앱이 열려 있든 아니든 알림이 작동합니다.
- 신뢰할 수 있는 분석. 모든 디스펜스가 작성 시점에 롤업되기 때문에, 차트와 준수에 피드되는 데이터는 항상 로그와 일치합니다.
API, 크론, 푸시 큐는 우리의 클라우드 인프라 서비스를 통해 관리되는 인프라에서 실행되므로, 장치 플릿과 사용자 기반이 확장됨에 따라 동일한 신뢰성 보장이 유지됩니다.
기술 스택: React Native · Expo · TypeScript · react-native-ble-plx · Zustand · CBOR (SMP firmware) · NestJS · MongoDB 8 · Mongoose (transactions) · @nestjs/schedule · ActiveMQ / STOMP · Expo Server SDK · JWT · AWS S3 · AWS SES · Sentry (mobile)
우리 팀의 더 많은 글 읽기
1. Apple Health & Health Connect 동기화

