이 글은 누구를 위한 것인가
- IoT 기기와 모바일 앱을 BLE로 연결하려는 개발자
- CoreBluetooth Central/Peripheral 모드를 처음 구현하는 iOS 개발자
- Android BLE GATT 통신에서 연결 안정성 문제를 겪는 팀
들어가며
BLE(Bluetooth Low Energy)는 헬스케어 기기, 스마트 홈, 웨어러블 연결의 표준이다. iOS와 Android 모두 BLE Central(스캔/연결)과 Peripheral(광고/응답) 역할을 지원하지만 구현 방식이 다르다.
이 글은 bluefoxdev.kr의 BLE 통신 구현 가이드 를 참고하여 작성했습니다.
1. BLE 아키텍처와 GATT 구조
[BLE 역할]
Central (스캐너):
- 주변 기기 스캔
- 연결 요청
- 특성 읽기/쓰기
예: 스마트폰
Peripheral (광고자):
- 자신을 광고
- 연결 수락
- 특성 제공
예: 심박수 센서, 스마트 잠금장치
[GATT 계층 구조]
Profile (표준 프로파일)
└── Service (UUID)
└── Characteristic (UUID)
├── Properties: Read / Write / Notify / Indicate
└── Value: 최대 512 bytes (기본 20 bytes)
[표준 서비스 UUID]
0x180D: Heart Rate Service
0x180F: Battery Service
0x1800: Generic Access
0x1801: Generic Attribute
[커스텀 서비스]
128-bit UUID 사용 (예: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E)
Nordic UART Service가 범용적으로 사용됨
[연결 안정성]
Connection Interval: 7.5ms ~ 4s
짧을수록: 빠른 응답, 배터리 소모
길수록: 느린 응답, 배터리 절약
MTU: 기본 23 bytes, 협상으로 최대 517 bytes
재연결: 자동 재연결 로직 필수
2. iOS CoreBluetooth Central 구현
import CoreBluetooth
import Combine
class BLEManager: NSObject, ObservableObject {
@Published var discoveredDevices: [CBPeripheral] = []
@Published var connectedDevice: CBPeripheral?
@Published var receivedData: Data?
@Published var connectionState: ConnectionState = .disconnected
private var centralManager: CBCentralManager!
private var targetPeripheral: CBPeripheral?
private var writeCharacteristic: CBCharacteristic?
private var notifyCharacteristic: CBCharacteristic?
// 연결할 서비스/특성 UUID
private let serviceUUID = CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
private let rxUUID = CBUUID(string: "6E400002-B5A3-F393-E0A9-E50E24DCCA9E") // 쓰기
private let txUUID = CBUUID(string: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E") // 읽기/Notify
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: .main)
}
func startScanning() {
guard centralManager.state == .poweredOn else { return }
discoveredDevices.removeAll()
connectionState = .scanning
// 특정 서비스를 가진 기기만 스캔 (배터리 절약)
centralManager.scanForPeripherals(
withServices: [serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
// 10초 후 자동 중지
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
self.stopScanning()
}
}
func stopScanning() {
centralManager.stopScan()
if connectionState == .scanning {
connectionState = .disconnected
}
}
func connect(to peripheral: CBPeripheral) {
targetPeripheral = peripheral
connectionState = .connecting
centralManager.connect(peripheral, options: nil)
}
func disconnect() {
if let peripheral = connectedDevice {
centralManager.cancelPeripheralConnection(peripheral)
}
}
// 데이터 쓰기
func sendData(_ data: Data) {
guard let peripheral = connectedDevice,
let characteristic = writeCharacteristic else { return }
// MTU 크기에 맞게 분할
let mtu = peripheral.maximumWriteValueLength(for: .withoutResponse)
if data.count <= mtu {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else {
// 청크로 분할 전송
var offset = 0
while offset < data.count {
let chunk = data[offset..<min(offset + mtu, data.count)]
peripheral.writeValue(chunk, for: characteristic, type: .withResponse)
offset += mtu
}
}
}
func sendString(_ text: String) {
guard let data = text.data(using: .utf8) else { return }
sendData(data)
}
}
extension BLEManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
print("BLE 사용 가능")
case .unauthorized:
print("BLE 권한 없음")
case .poweredOff:
connectionState = .disconnected
default:
break
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard !discoveredDevices.contains(peripheral) else { return }
// RSSI 필터 (신호가 너무 약한 기기 제외)
guard RSSI.intValue > -80 else { return }
discoveredDevices.append(peripheral)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
connectedDevice = peripheral
connectionState = .connected
peripheral.delegate = self
peripheral.discoverServices([serviceUUID])
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
connectedDevice = nil
connectionState = .disconnected
// 의도치 않은 연결 해제 시 자동 재연결
if error != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.connect(to: peripheral)
}
}
}
}
extension BLEManager: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let services = peripheral.services else { return }
for service in services where service.uuid == serviceUUID {
peripheral.discoverCharacteristics([rxUUID, txUUID], for: service)
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard let characteristics = service.characteristics else { return }
for characteristic in characteristics {
switch characteristic.uuid {
case rxUUID:
writeCharacteristic = characteristic
case txUUID:
notifyCharacteristic = characteristic
// Notify 구독
peripheral.setNotifyValue(true, for: characteristic)
// MTU 협상
peripheral.maximumWriteValueLength(for: .withoutResponse)
default:
break
}
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let data = characteristic.value else { return }
receivedData = data
}
}
enum ConnectionState {
case disconnected, scanning, connecting, connected
}
// Android - BLE Central 구현
import android.bluetooth.*
import android.bluetooth.le.*
class BleManager(private val context: Context) {
private val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
private var bluetoothGatt: BluetoothGatt? = null
private val SERVICE_UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
private val RX_UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
private val TX_UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
val connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val receivedData = MutableSharedFlow<ByteArray>()
fun startScan(onDevice: (BluetoothDevice) -> Unit) {
val scanner = bluetoothAdapter.bluetoothLeScanner
val filter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(SERVICE_UUID))
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (result.rssi > -80) {
onDevice(result.device)
}
}
}
scanner.startScan(listOf(filter), settings, callback)
}
fun connect(device: BluetoothDevice) {
bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
}
fun sendData(data: ByteArray) {
val service = bluetoothGatt?.getService(SERVICE_UUID) ?: return
val characteristic = service.getCharacteristic(RX_UUID) ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bluetoothGatt?.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
@Suppress("DEPRECATION")
characteristic.value = data
@Suppress("DEPRECATION")
bluetoothGatt?.writeCharacteristic(characteristic)
}
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
connectionState.value = ConnectionState.CONNECTED
gatt.requestMtu(512) // MTU 협상 먼저
}
BluetoothProfile.STATE_DISCONNECTED -> {
connectionState.value = ConnectionState.DISCONNECTED
}
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
gatt.discoverServices() // MTU 협상 후 서비스 탐색
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val service = gatt.getService(SERVICE_UUID) ?: return
val txChar = service.getCharacteristic(TX_UUID) ?: return
// Notify 활성화
gatt.setCharacteristicNotification(txChar, true)
val descriptor = txChar.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
} else {
@Suppress("DEPRECATION")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
gatt.writeDescriptor(descriptor)
}
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
CoroutineScope(Dispatchers.IO).launch {
receivedData.emit(value)
}
}
}
fun disconnect() {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
bluetoothGatt = null
}
enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED }
}
마무리
BLE 구현의 핵심은 상태 관리와 재연결 로직이다. 연결이 끊어지는 것은 정상이며, 자동 재연결과 exponential backoff를 구현해야 한다. iOS는 CBCentralManager 상태를 항상 확인하고, Android는 GATT callback이 메인 스레드에서 실행되지 않으므로 UI 업데이트 시 메인 스레드로 전환해야 한다. MTU 협상으로 데이터 전송 효율을 높이는 것도 중요하다.