이전 글에서 BLE의 연결(Connection)과 연동(Bonding)의 개념적 차이를 살펴봤다.
이번 글에서는 BLE 기기 연동, 측정 데이터 동기화, 연동 해제까지의 전체 구현 흐름을 정리해보도록 하겠다.
구현 대상은 혈당계와 혈압계로, Bluetooth SIG(Bluetooth 표준을 관리하는 단체)에서 정의한 Glucose Profile과 Blood Pressure Profile 스펙을 따른다.
BLE 기기와 데이터를 주고받으려면 먼저 앱이 기기를 '발견(discover)' 해야 한다.
BLE 기기(Peripheral)는 주기적으로 Advertising 신호("나 여기 있어"라고 알리는 짧은 패킷) 를 브로드캐스트하여 자신의 존재를 알리고, 앱(Central)은 이 신호를 스캔하여 기기를 발견한 뒤 GATT 연결을 수립한다.
1. 기기: Advertising (주기적 신호 브로드캐스트)
2. 앱: Scan → 기기 발견
3. 앱: connectGatt() → GATT 연결 수립
4. 앱: discoverServices() → 서비스/Characteristic/Descriptor 발견
5. 앱: enableNotification() → Descriptor에 수신 방식 등록
6. 기기: Notification 또는 Indication으로 데이터 전송
GATT 연결이 먼저 수립되어야, 서비스/Characteristic/Descriptor 정보를 조회할 수 있다.
Service: Glucose Service (0x1808)
├── Characteristic: Glucose Measurement (0x2A18)
│ └── Descriptor: CCCD (0x2902) ← Indication 구독 설정
├── Characteristic: Glucose Feature (0x2A51)
│ └── (read-only, Descriptor 없음)
└── Characteristic: RACP (0x2A52)
└── Descriptor: CCCD (0x2902) ← Indication 구독 설정
- Service - 기능의 묶음. "이 기기는 혈당 측정 기능을 제공한다"는 선언. 혈당계는 Glucose Service(0x1808), 혈압계는 Blood Pressure Service(0x1810)을 가짐
- Characteristic - 실제 데이터가 담기는 항목. Glucose Measurement에는 혈당 수치, 측정 시각 등이 들어있고, RACP는 "저장된 데이터 다 보내줘" 같은 명령을 주고받는 통로
- Descriptor - Characteristic에 대한 부가 설정. 대표적으로 CCCD(Client Characteristic Configuration Descriptor, 0x2902)가 있으며, Notification과 Indication 중 어떤 방식으로 데이터를 수신할지 설정하는 역할
Advertising 단계에서는 기기가 자신의 이름, UUID 정도만 짧은 패킷으로 브로드캐스트하고, 실제로 어떤 서비스와 Characteristic을 가지고 있는지는 연결 후 discoverServices()를 호출해야 알 수 있다.
기기 등록을 시작하면 먼저 블루투스 권한 상태를 확인한다.
권한이 없으면 OS 권한 팝업을 띄우거나 시스템 설정으로 이동하여 직접 권한을 허용할 수 있다.
권한이 허용된 상태에서 이미 등록된 기기가 있으면 기존 기기 목록을 보여주고, 없으면 BLE 스캔을 시작한다. 스캔 결과 검색된 기기 목록에서 연동할 기기를 선택하면 본딩 처리로 넘어간다.
GATT 연결 → 서비스 발견 → 본딩 상태 확인 순서로 진행된다.
onServicesDiscovered 시점에 bondState를 확인하여 분기한다.
BOND_NONE: createBond()를 호출하여 OS 페어링 팝업을 띄운다. 페어링이 완료되면 인증 키가 저장되어 본딩 상태가 된다.
혈당계는 PIN 입력이 필요하고, 혈압계는 연결 요청 팝업내에 등록 버튼을 누르면 페어링이 완료된다.
BroadcastReceiver로 BOND_BONDED 상태를 수신하면 DataStore에 기기 정보를 저장한다.
BOND_BONDING: 이미 본딩 진행 중이므로 receiver를 유지하며 대기한다.
BOND_BONDED: 이미 본딩된 상태이므로 바로 DataStore 저장 후 진행한다.
BLE 연결 상태는 sealed class로 관리한다.
sealed class ConnectionState {
data object Disconnected : ConnectionState()
data class Connecting(val device: BLEDevice) : ConnectionState()
data class Connected(val device: BLEDevice) : ConnectionState()
data class ServicesDiscovered(val device: BLEDevice) : ConnectionState()
data class Error(val error: ConnectionError) : ConnectionState()
}
/**
* 연결 에러
*/
sealed class ConnectionError {
data object MissingPermissions : ConnectionError()
data object ConnectionFailed : ConnectionError()
}
Disconnected → Connected → ServicesDiscovered 순서로 전환되며, ServicesDiscovered 상태에 도달해야 실제 데이터 통신이 가능하다.
onConnectionStateChange(STATE_DISCONNECTED)에서는 bondState와 status 조합으로 분기한다.
BOND_BONDED + (status=0 || status=19) → 본딩 성공, DataStore 저장
BOND_BONDED + 기타 status → 연결 실패 처리
BOND_BONDING → 본딩 결과 대기
BOND_NONE → 연결 실패 처리
여기서 status=0(
GATT_SUCCESS)은 정상적으로 연결이 해제된 경우이고, status=19(GATT_CONN_TERMINATE_PEER_USER)는 기기 측에서 정상적으로 연결을 종료한 경우다.
혈당계가 RACP 응답 후 약 10초간 추가 명령이 없으면 자체적으로 연결을 끊는데, 이때 이 status가 들어온다. status=133(GATT_ERROR)이나 status=147(GATT_CONN_TIMEOUT)은 실패로 처리한다.
연동된 기기 정보는 DataStore에 저장한다.
BLEConnectionManager
└─ BLEPairedDeviceDataSource (DataStore)
├─ getPairedDevice(deviceType): Flow<PairedDeviceInfo?>
├─ setPairedDevice(deviceType, address, name)
└─ removePairedDevice(deviceType)
DataStore의 Key는 기기 타입(혈당계/혈압계)으로 구분하고, Value는 MAC 주소와 기기 이름을 담은 JSON으로 저장한다.
// Key: "BLOOD_GLUCOSE" 또는 "BLOOD_PRESSURE"
// Value:
{
"address": "AA:BB:CC:DD:EE:FF",
"name": "DeviceName 1234"
}
Android는 BluetoothAdapter.getBondedDevices() 함수를 통해 OS에 본딩된 기기 목록을 제공하지만, 이 API는 시스템에 등록된 모든 Bluetooth 기기를 반환한다. 이어폰, 스피커 등 앱과 무관한 기기도 포함되며, 어떤 기기가 혈당계이고, 혈압계인지까지는 구분할 수 없다.
// https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#getBondedDevices()
val bondedDevices: Set<BluetoothDevice> = bluetoothAdapter.bondedDevices
bondedDevices.forEach { device ->
device.name // "DeviceName 1234", "Galaxy Buds2 Pro", "JBL Flip 6" 등
device.address // "AA:BB:CC:DD:EE:FF"
device.type // DEVICE_TYPE_LE, DEVICE_TYPE_CLASSIC, DEVICE_TYPE_DUAL
device.bondState // BOND_BONDED
}
따라서, 앱에서 연동한 기기의 타입과 MAC 주소를 별도로 관리할 필요가 있고, 이를 위해 위와 같은 DataStore 구조를 사용한다.
DataStore는 최초 읽기 시 디스크에서 데이터를 로드한 뒤 내부적으로 인메모리 캐시인 DataStoreInMemoryCache 에 보관한다. 따라서 이후 조회 시 불필요한 디스크 I/O 없이 캐싱된 값을 바로 반환하므로, 성능을 위한 별도의 인메모리 캐시를 구현할 필요가 없다.
다만, Android에서 제공하는 API가 아닌, DataStore로 연동 정보를 관리하기 때문에 발생하는 문제도 있다.
앱을 삭제하거나 앱 데이터를 초기화(설정 > 저장공간 > 데이터 지우기)하면 DataStore의 연동 정보는 사라지지만, OS 본딩은 그대로 유지된다.
이 경우 앱에서는 연동된 기기가 없는 것으로 인식하지만, BLE 기기 측에서는 여전히 휴대폰과 페어링된 상태가 유지된다. 이 상태에서 다시 연동을 시도하면 기존 페어링 정보가 남아있어 PIN 입력 없이 즉시 연동이 완료된다.
반대로, 진짜 문제는 DataStore에는 연동된 것으로 기록되어 있지만, OS의 BLE 본딩이 해제된 경우다. 사용자가 시스템 설정에서 직접 페어링을 해제하거나, BLE 기기 자체를 초기화한 경우 이런 상황이 발생할 수 있다.
이 경우 앱에서는 정상적으로 연동된 것으로 표시되지만, 실제 BLE 연결 시도 시 본딩이 되어있지 않아 연결에 실패하게 된다.
이 문제는 Android BLE API와 DataStore의 교차 검증으로 해결할 수 있다.
연동 여부 확인 시, DataStore에 저장된 기기의 MAC 주소와 Android API(BluetoothManager 등)를 통해 조회한 실제 OS의 본딩 목록의 MAC 주소를 비교한다. 두 값이 일치하지 않는 경우, DataStore의 연동 정보를 실제 페어링 상태에 맞게 동기화하여 불일치 상태를 사전에 해소할 수 있다.
// 예시 코드
suspend fun validateBondedDevice(context: Context): Boolean {
// DataStore에서 MAC 주소 조회
val savedMacAddress = dataStore.data.first().macAddress
val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
// OS 페어링 목록 조회
val bondedDevices = bluetoothManager.adapter.bondedDevices
val isActuallyBonded = bondedDevices.any { it.address == savedMacAddress }
if (!isActuallyBonded) {
// 불일치 → DataStore 초기화
dataStore.updateData { it.copy(macAddress = null) }
}
return isActuallyBonded
}
이제 BLE 기기를 통해 측정된 데이터를 동기화하는 방법에 대해 알아보도록 하겠다.
연결이 수립된 이후, BLE 기기를 통해 측정한 데이터가 앱으로 전송(push)하는 방법에는 Notification 과 Indication 이 있다.
둘 다
기기 -> 앱방향의 push 방식이며onCharacteristicChanged콜백을 통해 수신한다.
Notification은 fire-and-forget 방식이다. 문자 메시지처럼 보내면 끝이고, 수신 측이 읽었는지 확인하지 않는다. Descriptor에 ENABLE_NOTIFICATION_VALUE를 쓴다.
Indication은 등기 우편에 가깝다. 수신 측(앱)이 ACK(Acknowledgement, 수신 확인) 응답을 보내야 기기가 다음 데이터를 전송한다. Descriptor에 ENABLE_INDICATION_VALUE를 쓴다.
| 항목 | Notification | Indication |
|---|---|---|
| 방향 | 기기 → 앱 (push) | 기기 → 앱 (push) |
| ACK 응답 | 없음 (fire-and-forget) | 앱이 ACK 응답 필요 |
| 신뢰성 | 상대적으로 낮음 | 높음 (ACK 보장) |
| Descriptor 값 | ENABLE_NOTIFICATION_VALUE | ENABLE_INDICATION_VALUE |
| 비유 | 문자 메시지 (보내면 끝) | 등기 우편 (수령 확인 필요) |
| 대표 사용처 | Heart Rate Measurement (0x2A37), Battery Level (0x2A19) 등 실시간 스트리밍 | Blood Pressure Measurement (0x2A35), Glucose Measurement (0x2A18), RACP (0x2A52) 등 정확한 전달이 중요한 측정값 |
각 characteristic의 UUID는 Bluetooth SIG에서 표준으로 정의한 값이며, characteristic_uuids.json에서 전체 목록을 조회할 수 있다.
앱내 코드에서는 characteristic의 속성(PROPERTY_INDICATE / PROPERTY_NOTIFY)에 따라 자동으로 분기처리 되기 때문에, 개발자가 기기별로 Notification인지 Indication인지 직접 파악할 필요 없이 characteristic 속성만 읽으면 된다.
fun enableNotification(characteristic: BluetoothGattCharacteristic) {
val descriptorValue = if (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) {
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
} else {
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
}
// descriptor write...
}
데이터 동기화를 시작하면 앱은 BLE 기기에 연결을 시도한다.
이때 기존 GATT 연결이 남아있으면(페어링 직후 등) 먼저 해제한 뒤 autoConnect=true로 새 연결을 시작한다.
| 항목 | autoConnect=false | autoConnect=true |
|---|---|---|
| 연결 방식 | 즉시 직접 연결 시도 | 기기가 advertising할 때까지 백그라운드 대기 |
| 타임아웃 | 약 30초 후 자동 타임아웃 | 타임아웃 없음 (앱에서 직접 취소 필요) |
| 연결 속도 | 빠름 | 느림 (advertising 대기) |
| 사용 시점 | 기기가 근처에 있고 advertising 중일 때 | 기기가 언제 켜질지 모를 때 |
| 사용 예시 | 스캔 목록에서 기기 선택 직후 연결 | 의료기기 측정 데이터 동기화 등 기기의 advertising 시점을 알 수 없는 경우 |
혈당계와 혈압계 모두 autoConnect=true를 사용한다. 사용자가 앱에서 동기화를 시작한 시점에 기기가 반드시 advertising 중이라는 보장이 없기 때문이다. 예를 들어 혈당계는 측정 완료 후 일정 시간이 지나야 advertising을 시작하고, 혈압계는 측정 버튼을 눌러야 advertising이 시작된다.
autoConnect=false를 사용하면 이 타이밍이 맞지 않을 경우 30초 타임아웃으로 연결이 실패하므로, 기기의 advertising을 기다렸다가 자동으로 연결하는 autoConnect=true가 적합하다고 판단하였다.
참고로
autoConnect=true의 백그라운드 대기는 앱이 Service를 띄워서 주기적으로 스캔하는 방식이 아니다. Android OS의 Bluetooth 스택(컨트롤러 펌웨어 레벨)이 BLE 신호 감지를 담당하고, advertising이 감지되면 앱에 콜백으로 알려주는 구조다. 구체적인 콜백 흐름은 이전 글을 참고.
데이터 수신에 성공하면 사용자가 기록할 데이터를 선택하여 서버에 등록하고, 동기화 완료 시점에 로컬 캐시 삭제와 GATT 연결 해제가 수행된다. 일정 시간 내에 데이터가 수신되지 않으면 타임아웃으로 처리한다.
BLE 프로토콜인 GATT는 한 번에 하나의 write 명령만 처리할 수 있다. 따라서 onDescriptorWrite 콜백을 통한 순차 실행이 필수다.
1. enableNotification(Glucose Measurement, 0x2A18)
└─ onDescriptorWrite 콜백
2. enableNotification(RACP, 0x2A52)
└─ onDescriptorWrite 콜백
3. writeCharacteristic(RACP, reportAllStoredRecords)
→ Glucose Measurement Indication으로 측정값 수신
→ RACP Indication으로 완료 응답 수신
예를 들어 혈당계의 경우, Glucose Measurement의 Indication으로 저장된 측정값이 순차적으로 들어오고, 마지막에 RACP의 Indication으로 SUCCESS 응답이 오면 동기화 준비가 완료된다.
두 기기는 측정 시점과 데이터 조회 방식이 근본적으로 다르다.
각 기기마다 측정 시점, 측정 데이터를 조회하는 방식과 타이밍이 다른게 BLE 통신 구현의 어려운 점중 하나라 생각한다.
혈압계는 선 연결 → 후 측정 방식이다. 앱이 먼저 BLE로 연결한 상태에서 사용자가 측정하면, 측정값이 Indication(0x2A35)으로 push된다. 전송 완료 신호가 따로 없기 때문에, 마지막 수신 후 debounce를 적용하여 완료를 판단한다. 기기에서 데이터를 삭제할 수 없으므로 lastSyncDate 이후 측정값만 필터링하며, 필터링된 데이터는 JSON 배열로 DataStore에 로컬 캐싱한다.
혈당계는 선 측정 → 후 연결 방식이다. 사용자가 기기에서 먼저 측정하면 기기 내부 메모리에 결과가 저장되고, 이후 앱이 BLE 연결 → Indication 등록 → RACP 전송 순서로 저장된 데이터를 일괄 조회한다. RACP의 SUCCESS Indication 수신으로 완료를 판단한다. 서버 동기화가 필요한 경우, API를 통한 동기화 완료 후 RACP delete 명령으로 혈당계 기기내에 저장된 데이터를 삭제한다.
혈당계에서 주의할 점은, BLE 연결 -> RACP 응답 후 약 10초간 추가 명령이 없으면 기기가 자체적으로 BLE 연결을 종료(status=19, GATT disconnect)한다는 것이다.
이처럼 연결을 유지할 수 있는 시간이 짧기 때문에 측정 중에 재연결을 반복하며 억지로 연결을 유지하는 방식은 적합하지 않고, 먼저 측정을 완료한 뒤 연결하여 저장된 데이터를 조회하는 선측정 → 후연결 방식을 택하게 되었다.
여기서 문제는, 사용자가 데이터 확인 및 기록할 데이터에 대한 선택에 시간을 소요하면 RACP delete 명령을 실행하는 시점엔 이미 연결이 끊긴 상태일 수 있다.
이 경우 기기에 데이터가 제거되지 않고, 잔존하여 다음 동기화 시 중복 수신될 수 있는데, 이를 해결하기 위해 서버 측에서도 데이터의 중복 처리를 해줘야 한다.
이를 해결하기 위해 Report First, Then Delete 전략을 사용한다.
RACP Report All로 데이터를 먼저 메모리에 수신한 뒤, 연결이 끊기기 전에 Delete All을 전송하여 기기 데이터를 정리하는 방식이다.
구체적인 흐름은 다음과 같다.
BLE 기기에서 삭제하더라도 앱에서는 데이터를 유지할 수 있다.
lastSyncDate 확인lastSyncDate 존재 → 이전 동기화 기록이 있으므로 RACP Delete All 전송lastSyncDate 없음 → 최초 연결이므로 삭제 없이 바로 동기화 진행lastSyncDate 갱신연동 해제 요청이 들어오면 다음 순서로 처리한다. 비교적 단순한 작업이지만, 코드상의 문제점이 존재한다.
BluetoothDevice.removeBond() 호출removeBond()는 android.bluetooth.BluetoothDevice 클래스내에 존재하지만 모든 Android 버전에서 @hide API로 숨겨져 있다.
공식적인 본딩 해제 수단이 없으므로, 울며 겨자 먹기로 리플렉션을 사용할 수 밖에 없다...
리플렉션은 런타임에 클래스의 메서드/필드 정보를 조회하고 호출하는 기법으로 컴파일 타임에 접근할 수 없는(@hide, private 등) 멤버도 호출할 수 있어서, 숨겨진 API를 우회할 때 사용할 수 있다. 단, 메서드 시그니처가 OS 버전에 따라 변경되거나 제거되면 RuntimeException이 발생할 수 있고, Android 9 부터 도입된 비 SDK 인터페이스 제한 정책에 의해 접근이 차단될 수도 있어 사용을 지양해야한다.
/**
* BluetoothDevice.removeBond()는 모든 Android 버전에서 @hide API이므로
* 리플렉션으로 호출해야 한다.
* Android 16에서 CompanionDeviceManager.removeBond()가 공식 API로 추가되었지만,
* CompanionDeviceManager를 통해 연동한 기기에만 사용 가능하다.
*/
fun removeBond(device: BluetoothDevice): Boolean {
return try {
device.javaClass.getMethod("removeBond").invoke(device) as? Boolean ?: false
} catch (e: Exception) {
Log.e("BLE", "removeBond failed", e)
false
}
}
Android 16에서 CompanionDeviceManager.removeBond()가 공식 API로 추가되었지만, 이는 CompanionDeviceManager를 통해 연동한 기기에만 사용할 수 있다.
BluetoothDevice를 직접 사용하는 일반적인 BLE 구현에서는 여전히 리플렉션에 의존해야 하는 점이 아쉬운 부분이다.
| 항목 | BluetoothDevice | CompanionDeviceManager |
|---|---|---|
removeBond() 지원 | @hide API (리플렉션 필요) | Android 16부터 공식 API |
| 기기 연동 방식 | BluetoothAdapter로 직접 스캔/연결 | 시스템 UI를 통한 기기 선택/연동 |
| 적용 범위 | 모든 BLE 기기 | CompanionDeviceManager로 연동한 기기만 |
| 본딩 해제 호환성 | 전 버전 가능 (리플렉션) | Android 16+ 한정 |
BLE 기기 연동에서 핵심은 세 가지다.
첫째, 연결(Connection)과 연동(Bonding)의 생명주기가 다르다.
연결은 매 세션마다 필요한 일시적 상태이고, 연동은 한번 성립하면 앱을 종료하거나 기기를 재시작해도 유지된다. DataStore에 연동 정보를 저장하고, OS 본딩 상태와 동기화를 유지하는 것이 중요하다.
둘째, 기기마다 동기화 전략이 다르다.
혈압계는 연결 상태에서 Indication으로 수신 + lastSyncDate 필터링, 혈당계는 RACP 기반 일괄 조회 + delete 방식이다. 기기 특성에 맞는 동기화 플로우 설계가 필요하다.
셋째, BLE 기기의 동작 특성에 맞춘 방어적 설계가 필요하다.
혈당계의 자체 연결 종료(status=19)에 대응하는 Report First, Then Delete 전략, autoConnect=true를 통한 advertising 타이밍 차이 대응, 앱 데이터 초기화 시 OS 본딩 잔존에 대한 교차 검증, removeBond() 호출을 위한 리플렉션 우회 등...BLE 스펙과 Android API의 간극을 메우는 처리가 동기화 플로우의 안정성을 좌우한다.
BLE 기기 연동, 측정 데이터 동기화, 연동 해제까지의 전체 구현 흐름을 정리해보면서 이해했다고 착각했던 부분들의 빈틈을 채울 수 있었다.
이번 정리를 통해 앞으로 더 다양한 BLE 기기들과의 통신을 구현을 하게될때, 흔들리지 않을 초석을 다졌다고 생각한다.
reference)
https://developer.android.com/develop/connectivity/bluetooth/ble/ble-overview?hl=ko
https://developer.android.com/reference/android/bluetooth/BluetoothDevice
https://developer.android.com/about/versions/16/behavior-changes-16?hl=ko#bond-removal-api
https://android.googlesource.com/platform/frameworks/base/+/b1dc1757071ba46ee653d68f331486e86778b8e4/core/java/android/bluetooth/BluetoothDevice.java#948
https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/companion/CompanionDeviceManager.java;l=1212?q=removebond&sq=
https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Assigned_Numbers/out/en/Assigned_Numbers.pdf
https://github.com/NordicSemiconductor/bluetooth-numbers-database?tab=readme-ov-file
https://developer.android.com/reference/android/companion/CompanionDeviceManager#removeBond(int)
https://gist.github.com/ddibiasi/944e6b1e682c857c8ebc89640b4f901c
https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#getBondedDevices()