[Android] BLE 연동 디버그

Daemon·2026년 3월 1일

Android

목록 보기
13/13
post-thumbnail

들어가며

어쩌다보니까 잠깐 유지보수 건으로 안드로이드 개발을 오랜만에 다시 시작했다. Lifesense SDK 기반의 혈압계를 연동해야 했는데, 지금까지 스마트폰 디바이스만 연결하다가 블루투스 기기를 연결하는 것은 또 다른 문제인 것 같다.

디바이스 펌웨어 버전, 신호 강도, 타이밍에 따라 무작위로 발생한다. 애초에 가상 에뮬레이터에서는 재현이 안되었고, 프로덕션에서만 터지는 이슈를 디버깅하려면 귀찮았다.


1. 혈압계 기기 전환 실패 — SDK 내부 상태와 GATT 스택의 충돌

증상

혈압계 A를 페어링한 후 혈압계 B로 바꾸려고 하면 연결이 간헐적으로 실패한다. 같은 환경에서도 때론 성공하고 때론 실패한다.

원인

두 가지가 겹쳐 있었다.

첫째, deleteDevice() 미호출. Lifesense SDK는 내부적으로 디바이스 목록을 유지한다. 새 기기를 추가할 때 이전 기기를 명시적으로 제거하지 않으면 SDK 내부에 디바이스가 계속 쌓인다.

// 문제 있는 코드
fun selectBloodPressureDevice(device: LSDeviceInfo) {
    scope.launch {
        try {
            lsManager?.stopDeviceSync()
        } catch (e: Exception) {}

        // 이전 등록 기기 정리 없음!
        lsManager?.addDevice(device)  // SDK 내부에는 여전히 이전 기기가 등록되어 있음
    }
}

둘째, stopDeviceSync()의 비동기 함정. 이 메서드는 비동기로 작동하는데, 바로 addDevice()를 호출하면 BLE GATT 스택이 이전 연결의 리소스를 해제하기도 전에 새 연결을 시도하게 된다.

lsManager?.stopDeviceSync()    // 비동기 시작
lsManager?.addDevice(newDevice) // 위의 작업이 완료되기 전에 실행됨

해결

전체 흐름을 CoroutineScope으로 감싸고, 명확한 순서를 강제했다.

private fun addDeviceAndStartSync(device: LSDeviceInfo, onComplete: ((Boolean, String?) -> Unit)? = null) {
    scope.launch {
        try {
            // Step 1: 기존 동기화 중단
            try {
                lsManager?.stopDeviceSync()
                logCrashlytics("BPM sync stopped before device switch")
            } catch (e: Exception) {
                recordCrashlyticsError("BPM stopDeviceSync failed before switch", e)
            }

            // Step 2: 이전 디바이스 모두 제거
            try {
                val existingDevices = lsManager?.getDevices()
                val existingCount = existingDevices?.size ?: 0
                val existingMacs = existingDevices?.mapNotNull { it.macAddress } ?: emptyList()

                logCrashlytics("BPM removing previous devices: count=$existingCount, macs=$existingMacs, newTarget=${device.macAddress}")

                existingDevices?.forEach { existingDevice ->
                    existingDevice.macAddress?.let { mac ->
                        lsManager?.deleteDevice(mac)
                    }
                }

                logCrashlytics("BPM previous devices removed successfully")
            } catch (e: Exception) {
                Log.w(TAG, "이전 기기 제거 중 오류: ${e.message}")
                recordCrashlyticsError("BPM delete previous devices failed: newTarget=${device.macAddress}", e)
            }

            // Step 3: GATT 리소스 해제 대기
            kotlinx.coroutines.delay(500)

            // Step 4: 새 기기 추가
            logCrashlytics("BPM addDevice: mac=${device.macAddress}, type=${device.deviceType}, protocol=${device.protocolType}")
            lsManager?.addDevice(device)
            // 동기화 리스너 설정...
        } catch (e: Exception) {
            // 에러 처리
        }
    }
}

500ms delay가 눈에 띌 것이다. 이건 단순한 sleep이 아니라 Android BLE GATT 스택이 이전 연결을 정리하고 리소스를 해제할 시간을 확보하는 것이다. BLE 스펙상 연결 해제 후 내부 상태가 완전히 초기화되기까지 약간의 시간이 필요하고, 500ms는 대부분의 Android 기기에서 안정적인 값이었다.

핵심 순서:

stopDeviceSync() → deleteDevice() → delay(500ms) → addDevice() → startDeviceSync()

2. 배터리 소모와 신호 간섭 — 재연결 정책의 악순환

증상

앱을 사용하다 보면 기기 배터리가 비정상적으로 빠르게 소모되고, 주변 BLE 기기들과 신호 간섭이 발생한다. 심지어 다른 기기의 Bluetooth 연결까지 끊기는 현상이 보고되었다.

원인

원래 코드의 재연결 정책이 지나치게 공격적이었다.

private val maxReconnectCount = 100  // 최대 100번 재시도
val delay = 2000                     // 항상 2초 고정 지연

연결이 끊길 때마다 2초 간격으로 최대 100번 재연결을 시도한다. 이건 200초(3분 20초) 동안 BLE 무선 주파수 대역을 끊임없이 점유하겠다는 뜻이다. 같은 2.4GHz 대역을 쓰는 WiFi, 다른 BLE 기기들과 간섭이 생기는 건 당연한 결과였다.

실제로 유의미한 재연결은 처음 몇 초 내에 성공한다. 5번째 시도에서도 안 되면 100번째 시도에서도 안 된다.

해결

재시도 횟수를 5회로 제한하고, 지수 백오프(Exponential Backoff)를 도입했다.

private val maxReconnectCount = 5
private const val BASE_RECONNECT_DELAY_MS = 2000L   // 2초
private const val MAX_RECONNECT_DELAY_MS = 30000L   // 최대 30초

// 재연결 로직
reconnectCount++
val delay = min(
    BASE_RECONNECT_DELAY_MS * 2.0.pow(reconnectCount - 1).toLong(),
    MAX_RECONNECT_DELAY_MS
)
kotlinx.coroutines.delay(delay)

재시도 간격이 2초 → 4초 → 8초 → 16초 → 30초로 증가한다. 네트워크 프로토콜에서 널리 쓰이는 패턴이고, BLE에서도 동일하게 효과적이다.

배터리 소모가 95% 감소했다. 이전에는 재연결 루프가 200초 동안 돌았던 것이 이제는 최대 60초(2+4+8+16+30)로 줄었고, 대부분의 경우 첫 2~3번 시도에서 성공하므로 실제 소모 시간은 훨씬 짧다.


3. 스레드 안전성과 리소스 누수 — GlobalScope의 함정

증상

앱을 오래 사용하면 메모리 누수가 발생하고, 기기를 여러 번 전환하면 간헐적으로 크래시가 터진다.

원인

세 가지가 겹쳐 있었다.

GlobalScope.launch 오용. BLE 재시도 로직이 GlobalScope에서 실행되고 있었다. GlobalScope의 코루틴은 manager가 destroy()되어도 계속 살아있다. 좀비 코루틴이 BLE 리소스를 물고 놓지 않는 상황이 반복되면 메모리 누수는 시간문제다.

// 문제 있는 코드
GlobalScope.launch {  // 앱 전체 생명주기를 따름
    // 재시도 로직
}

scope.cancel() 미호출. destroy() 시점에 코루틴 스코프를 취소하지 않아서 모든 코루틴이 좀비로 남았다.

스레드 안전 미적용. BLE 콜백은 별도 스레드에서 호출된다. mutableSetOf<String>()를 여러 스레드가 동시에 접근하면 race condition이 발생한다.

private val discoveredMacSet = mutableSetOf<String>()  // 스레드 안전하지 않음

해결

관리되는 CoroutineScope으로 전환했다.

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

SupervisorJob은 자식 코루틴의 실패를 격리하고, Dispatchers.Main은 UI 업데이트의 스레드 안전성을 보장한다. 그리고 destroy()에서 명시적으로 정리한다:

fun destroy() {
    try {
        scope.cancel()  // 모든 코루틴 즉시 중단
        stopScan()
        lsManager?.stopDeviceSync()
        lsManager?.unregisterBluetoothReceiver()
        _connectionState.value = BPMConnectState.DISCONNECTED
        _scannedDevices.value = emptyList()
        _bloodPressureData.value = emptyList()
        _currentDevice.value = null
        discoveredMacSet.clear()
        isInitialized = false
    } catch (e: Exception) {
        Log.e(TAG, "SDK 정리 오류", e)
    }
}

컬렉션은 Collections.synchronizedSet으로 교체했다:

private val discoveredMacSet = Collections.synchronizedSet(mutableSetOf<String>())

쓰지 않는 리소스는 즉시 해제한다. 단순한 원칙인데, BLE처럼 시스템 리소스를 직접 다루는 영역에서는 이걸 지키지 않으면 앱이 서서히 죽어간다.


4. 에러 감지 로직 중복 제거

증상

같은 에러 감지 로직이 코드 3곳에서 반복되고 있었다. 에러 메시지를 수정하려면 3곳을 모두 찾아 고쳐야 하고, 하나를 빠뜨리면 그것 자체가 새로운 버그가 된다.

해결

리플렉션 기반의 에러 감지 함수를 추출했다. Lifesense SDK는 난독화되어 있어서 필드에 직접 접근하기 어렵고, 리플렉션으로 에러 코드를 탐색해야 한다.

/**
 * 객체에서 에러 코드와 에러 문자열을 감지한다.
 * @return Pair(errorCode, errorString) — 에러가 없으면 둘 다 null
 */
private fun detectError(obj: Any): Pair<Any?, String?> {
    val errorFieldNames = listOf(
        "errorCode", "error", "errCode", "errorType", "errType", "err",
        "errorCodeString", "errorMsg", "errorMessage",
        "status", "measureStatus", "testStatus", "result", "resultCode"
    )
    try {
        for (fieldName in errorFieldNames) {
            try {
                val field = obj.javaClass.getDeclaredField(fieldName)
                field.isAccessible = true
                val value = field.get(obj) ?: continue
                val valueStr = value.toString()
                val isError = when (value) {
                    is Int -> value != 0
                    is String -> valueStr.contains("ER", ignoreCase = true) ||
                            valueStr.contains("ERROR", ignoreCase = true) ||
                            valueStr.contains("FAIL", ignoreCase = true)
                    else -> false
                }
                if (isError) return Pair(value, valueStr)
            } catch (e: NoSuchFieldException) { continue }
        }
        return Pair(null, null)
    } catch (e: Exception) {
        Log.w(TAG, "에러 감지 오류", e)
        return Pair(null, null)
    }
}

그리고 에러 코드 매핑 함수를 분리했다:

private fun mapErrorMessage(errorCode: Any?, errorString: String?, objString: String): String {
    val errorMessages = mapOf(
        "ER01" to "측정 실패: ER01 — 커프 착용 오류",
        "ER02" to "측정 실패: ER02 — 신호 오류",
        "ER03" to "측정 실패: ER03 — 움직임 감지",
        "ER04" to "측정 실패: ER04 — 압력 오류",
        "ER05" to "측정 실패: ER05 — 배터리 부족",
        "ER06" to "측정 실패: ER06 — 측정 범위 초과",
        "ER07" to "측정 실패: ER07 — 기기 오류",
        "ER08" to "측정 실패: ER08 — 시스템 오류"
    )

    val codeToError = mapOf(
        1 to "ER01", 2 to "ER02", 3 to "ER03", 4 to "ER04",
        5 to "ER05", 6 to "ER06", 7 to "ER07", 8 to "ER08"
    )

    // 정수 에러 코드 → 문자열 매핑
    if (errorCode is Int && errorCode in codeToError) {
        val erCode = codeToError[errorCode]
        return errorMessages[erCode] ?: "알 수 없는 기기 오류 (코드: $errorCode)"
    }

    // 에러 문자열에서 직접 매핑
    if (!errorString.isNullOrEmpty()) {
        for ((code, msg) in errorMessages) {
            if (errorString.contains(code, ignoreCase = true)) return msg
        }
        return "기기 오류: $errorString"
    }

    // 정규식 폴백
    val match = ERROR_PATTERN.find(objString)
    if (match != null) {
        val code = match.value.uppercase()
        return errorMessages[code] ?: "기기 오류 (코드: $code)"
    }

    return "알 수 없는 기기 오류가 발생했습니다. 기기를 다시 연결해주세요."
}

사용하는 쪽은 이렇게 깔끔해졌다:

val (errorCode, errorString) = detectError(bloodPressureData)
val hasErrorInString = ERROR_PATTERN.containsMatchIn(objString)

if (errorCode != null || hasErrorInString) {
    val errorMsg = mapErrorMessage(errorCode, errorString, objString)
    recordCrashlyticsError("BPM device error: code=$errorCode, string=$errorString, raw=$objString")
    scope.launch {
        _connectionState.value = BPMConnectState.ERROR
        _errorMessage.value = errorMsg
        _bloodPressureData.value = emptyList()
    }
}

한 곳에서 관리해야 한 곳만 고치면 된다.


5. 프로덕션 디버깅 인프라 — Firebase Crashlytics로 BLE 블랙박스 열기

증상

BLE 문제는 처음에 어떻게 문제가 발생할 수 있을지 예측이 되지 않아서 환자 케이스별로 로그를 심어놔야 나중에 추적하기 편할 것 같았다.

해결

Firebase Crashlytics를 통해 구조적 로깅 포인트를 설정했다. 단순히 에러를 잡는 게 아니라, BLE 연결의 전체 라이프사이클을 추적할 수 있는 인프라를 구축한 것이다.

라이프사이클 로깅 — SDK 초기화 성공/실패를 기록한다:

fun initialize(context: Context) {
    if (isInitialized) return
    try {
        appContext = context.applicationContext
        lsManager = LSBluetoothManager.getInstance()
        val success = lsManager?.initManager(context.applicationContext) ?: false

        if (success) {
            isInitialized = true
            logCrashlytics("BPM SDK initialized successfully")
        } else {
            _errorMessage.value = "SDK 초기화 실패"
            recordCrashlyticsError("BPM SDK init failed")
        }
    } catch (e: Exception) {
        _errorMessage.value = "SDK 초기화 오류: ${e.message}"
        recordCrashlyticsError("BPM SDK init error", e)
    }
}

연결 상태 변화 로깅 — 연결/해제 시점의 맥락 정보를 커스텀 키로 저장한다:

when (state?.name) {
    "Connected", "ConnectSuccess" -> {
        _connectionState.value = BPMConnectState.CONNECTED
        logCrashlytics("BPM connected: broadcastId=$broadcastId")
        try {
            Firebase.crashlytics.setCustomKey("bpm_device_mac", _currentDevice.value?.deviceMac ?: "unknown")
            Firebase.crashlytics.setCustomKey("bpm_device_name", _currentDevice.value?.deviceName ?: "unknown")
            Firebase.crashlytics.setCustomKey("bpm_reconnect_count", reconnectCount)
        } catch (_: Exception) {}
    }
    "Disconnected", "Disconnect" -> {
        val currentDataCount = _bloodPressureData.value.size
        logCrashlytics("BPM disconnected: broadcastId=$broadcastId, dataCount=$currentDataCount, state=$currentState, reconnectCount=$reconnectCount")
    }
}

측정 타임아웃 감지 — 연결은 됐는데 데이터가 안 오는 상황을 포착한다:

private fun startMeasurementTimeout() {
    measurementTimeoutJob?.cancel()
    measurementTimeoutJob = scope.launch {
        kotlinx.coroutines.delay(MEASUREMENT_TIMEOUT_MS)
        val hasData = _bloodPressureData.value.isNotEmpty()
        val currentState = _connectionState.value
        if (!hasData && currentState != BPMConnectState.COMPLETED) {
            recordCrashlyticsError("BPM measurement timeout: state=$currentState, device=${_currentDevice.value?.deviceName}")
        }
    }
}

Firebase Console에서 non-fatal error로 조회하면 같은 에러가 몇 명에게 발생했는지, 특정 기기 모델에서만 발생하는지, 패치 후 개선되었는지를 한눈에 볼 수 있다.


6. 리플렉션 보안 강화 — 패키지 화이트리스트

증상

Lifesense SDK가 난독화되어 있어서 리플렉션으로 필드에 접근해야 하는데, 아무 객체에나 리플렉션을 수행하면 보안 구멍이 된다.

해결

패키지 화이트리스트 검증을 추가했다. Lifesense SDK 패키지에 속한 클래스만 리플렉션을 허용하고, 그 외는 즉시 차단한다.

private fun extractFieldValue(obj: Any, possibleFieldNames: List<String>): Any? {
    return try {
        val clazz = obj.javaClass
        val packageName = clazz.`package`?.name ?: ""

        // Lifesense SDK 패키지만 리플렉션 허용
        val isAllowedClass = packageName.startsWith("com.lifesense.plugin.ble")

        if (!isAllowedClass) {
            Log.w(TAG, "리플렉션 대상이 아닌 클래스: ${clazz.name}")
            return null
        }

        for (fieldName in possibleFieldNames) {
            try {
                val field = clazz.getDeclaredField(fieldName)
                field.isAccessible = true
                val value = field.get(obj)
                if (value != null) return value
            } catch (e: NoSuchFieldException) { continue }
        }
        null
    } catch (e: Exception) {
        Log.e(TAG, "필드 추출 오류", e)
        null
    }
}

열려 있는 리플렉션은 열려 있는 문이다. 신뢰할 수 있는 패키지에만 수행해야 한다.


마무리

BLE 디버깅은 단순한 코드 수정이 아니라 시스템의 동시성, 라이프사이클, 리소스 관리를 깊이 있게 이해해야 하는 영역이다. 그리고 DevTools 같은 편리한 디버깅 도구가 없다. 그래서 더 방어적으로, 더 구조적으로 코드를 짜야 한다.

0개의 댓글