모바일 환경에서의 IoT 애플리케이션 개발은 일반적인 REST API 통신과는 근본적으로 다른 접근이 필요합니다. 연결의 지속성(Persistence), 배터리 효율, 그리고 불안정한 네트워크 대역폭을 모두 고려해야 하기 때문입니다. 특히 수백만 대의 디바이스가 연결되는 환경에서 MQTT(Message Queuing Telemetry Transport) 프로토콜은 사실상 표준으로 자리 잡았습니다. 그러나 AWS IoT SDK를 안드로이드에 통합하는 과정에서, 비동기 이벤트 처리와 스레드 모델에 대한 이해 부족으로 인해 런타임 크래시를 겪는 경우가 빈번합니다. 본 포스트에서는 AWS IoT Core 연동의 아키텍처를 간략히 짚고, 실무에서 가장 빈번하게 발생하는 AWSIotMqttException (32107) 에러의 근본 원인과 Kotlin Coroutines를 활용한 해결책을 엔지니어링 관점에서 분석합니다.
1. AWS IoT Core 보안 모델 및 인증 아키텍처
AWS IoT Core는 일반적인 ID/PW 방식이 아닌, X.509 인증서 기반의 상호 인증(Mutual Auth)을 요구합니다. 이는 클라이언트가 서버를 검증할 뿐만 아니라, 서버도 클라이언트의 인증서를 통해 신원을 확인한다는 의미입니다. 안드로이드 클라이언트 구현 시 가장 먼저 직면하는 장벽은 이 인증서 파일을 안전하게 관리하고 로드하는 것입니다.
AWS 콘솔에서 생성한 '사물(Thing)'의 인증서, 프라이빗 키, 루트 CA는 앱 내부에 포함되어야 합니다. 프로덕션 레벨에서는 AWS Cognito 등을 통해 동적으로 인증서를 발급받는 것이 권장되지만, 초기 구현 및 고정된 키오스크 디바이스의 경우 BKS(Bouncy Castle KeyStore) 또는 Android KeyStore 시스템을 활용합니다. 다음은 IAM 정책(Policy)의 최소 권한 원칙(Least Privilege)을 적용한 JSON 예시입니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:ap-northeast-2:YOUR_ACCOUNT_ID:client/${iot:ClientId}"
},
{
"Effect": "Allow",
"Action": ["iot:Publish", "iot:Subscribe", "iot:Receive"],
"Resource": "arn:aws:iot:ap-northeast-2:YOUR_ACCOUNT_ID:topic/app/device/${iot:ClientId}/*"
}
]
}
${iot:ClientId} 변수를 사용하면, 디바이스가 자신의 Client ID에 해당하는 리소스에만 접근하도록 강제할 수 있어 보안성이 대폭 향상됩니다.
2. 안드로이드 클라이언트 구현: AWSIotMqttManager
AWS Mobile SDK for Android는 AWSIotMqttManager 클래스를 통해 연결 풀링과 재연결 로직을 추상화합니다. 이 매니저는 내부적으로 Paho MQTT 클라이언트를 래핑하고 있으며, 네트워크 상태 변화에 따른 자동 재연결(Auto-reconnect) 기능을 제공합니다. 그러나 이 편의성 이면에는 스레드 관리에 대한 책임이 개발자에게 전가된다는 트레이드오프가 존재합니다.
KeyStore 초기화 및 연결
인증서 파일(PEM)을 안드로이드가 인식할 수 있는 KeyStore 객체로 변환하는 과정이 필수적입니다. SDK에서 제공하는 AWSIotKeystoreHelper를 사용하면 로우(Raw) 리소스에서 인증서를 읽어 메모리 상의 KeyStore로 로드할 수 있습니다.
// Keystore 로드 및 연결 설정
val keystorePath = context.filesDir.path
val keystoreName = "iot_keystore"
val keystorePassword = "secure_password_placeholder" // 실제 구현 시 보안 저장소 사용 권장
// 인증서 및 키 로드 (1회 수행)
if (!AWSIotKeystoreHelper.isKeystorePresent(keystorePath, keystoreName)) {
AWSIotKeystoreHelper.saveCertificateAndPrivateKey(
"cert-alias",
readResource(R.raw.cert_pem),
readResource(R.raw.private_key_pem),
keystorePath,
keystoreName,
keystorePassword
)
}
// KeyStore 객체 획득
val clientKeyStore = AWSIotKeystoreHelper.getIotKeystore(
"cert-alias",
keystorePath,
keystoreName,
keystorePassword
)
// MQTT Manager 연결
val mqttManager = AWSIotMqttManager(clientId, endpoint)
mqttManager.connect(clientKeyStore) { status, throwable ->
Log.i("AWS_IoT", "Connection Status: $status")
}
3. QoS 수준과 트레이드오프
MQTT 통신 설계 시 가장 중요한 결정 요소는 QoS(Quality of Service) 레벨입니다. 모바일 네트워크는 패킷 손실이 빈번하므로, 데이터의 중요도에 따라 적절한 레벨을 선택해야 합니다. 과도한 QoS 설정은 불필요한 핸드셰이크로 인한 지연(Latency)과 배터리 소모를 유발합니다.
| QoS Level | 설명 | 전송 보장 | 사용 사례 |
|---|---|---|---|
| QoS 0 | At most once | 보장 없음 (Fire & Forget) | 실시간 센서 데이터, 일부 유실되어도 무방한 로그 |
| QoS 1 | At least once | 최소 1회 보장 (중복 가능) | 대부분의 제어 명령, 상태 업데이트 (Idempotency 처리 필요) |
| QoS 2 | Exactly once | 정확히 1회 보장 | 결제 알림 등 중복이 치명적인 경우 (오버헤드 큼) |
4. 스레드 경합 이슈: Callback 내 disconnect 호출
개발 과정에서 흔히 발생하는 시나리오는 "특정 종료 메시지(예: TASK_COMPLETE)를 수신하면 MQTT 연결을 끊는 것"입니다. 이때 개발자는 직관적으로 구독 콜백(Callback) 내부에서 disconnect()를 호출하게 됩니다. 하지만 이 코드는 AWSIotMqttException (32107)을 발생시킵니다.
원인 분석
이 문제는 재진입성(Reentrancy)과 스레드 락(Lock) 설계 원칙에서 기인합니다.
subscribeToTopic의 콜백 함수는 AWS IoT SDK가 관리하는 네트워크 스레드(또는 이벤트 루프 스레드)에서 실행됩니다.- 이 스레드는 현재 메시지를 처리 중이며, 해당 리소스를 점유하고 있습니다.
- 동일한 스레드 내에서
disconnect()를 호출하면, SDK는 연결 해제를 위해 현재 실행 중인 스레드를 포함한 리소스를 정리하려 시도합니다. - 즉, 자기 자신을 죽이려는(Self-termination) 모순이 발생하거나, 이미 락이 걸린 리소스에 접근하려다 데드락(Deadlock)에 빠질 위험이 있습니다.
5. 솔루션: Kotlin Coroutines를 이용한 Context Switching
해결책은 disconnect() 호출을 콜백 스레드가 아닌 별도의 스레드로 위임(Dispatch)하는 것입니다. 과거 AsyncTask나 Thread를 직접 생성하는 방식은 리소스 비용이 높고 코드가 복잡했습니다. Kotlin Coroutines를 사용하면 Dispatchers를 통해 실행 컨텍스트를 명시적으로, 그리고 저비용으로 전환할 수 있습니다.
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
// ...
mqttManager.subscribeToTopic(topic, AWSIotMqttQos.QOS0) { topic, data ->
val message = String(data, Charsets.UTF_8)
if (message == "TASK_COMPLETE") {
// [Bad Practice] SDK 내부 스레드에서 직접 호출 시 크래시 발생
// mqttManager.disconnect()
// [Best Practice] Coroutine을 통한 스레드 컨텍스트 스위칭
// Dispatchers.Default (CPU/Worker Thread) 혹은 Dispatchers.IO 로 작업 위임
CoroutineScope(Dispatchers.Default).launch {
try {
// 현재 콜백 스레드와는 다른 스레드에서 실행됨을 보장
mqttManager.disconnect()
Log.d("AWS_IoT", "Disconnected safely via Coroutine")
} catch (e: Exception) {
Log.e("AWS_IoT", "Disconnect failed", e)
}
}
}
}
launch(Dispatchers.Default)는 disconnect() 로직을 SDK의 콜백 스레드가 아닌, 코루틴의 공유 백그라운드 스레드 풀로 작업을 스케줄링합니다. 이를 통해 콜백 함수는 즉시 종료되고, 별도의 스레드에서 안전하게 연결 해제가 수행됩니다.
아키텍처 제언: LifecycleScope 활용
위의 예제에서 CoroutineScope(Dispatchers.Default)를 직접 생성하는 것은 Fire-and-forget 방식에는 유효하지만, 구조적인 동시성(Structured Concurrency) 관점에서는 부족함이 있습니다. 안드로이드 컴포넌트(Activity/Fragment)의 수명 주기와 연동하기 위해 lifecycleScope 또는 viewModelScope를 사용하는 것이 메모리 누수를 방지하는 더 안전한 패턴입니다.
// ViewModel 내부 예시
fun handleMessage(message: String) {
if (shouldDisconnect(message)) {
viewModelScope.launch(Dispatchers.IO) {
mqttManager.disconnect()
}
}
}
결론
안드로이드와 AWS IoT Core의 연동은 단순한 라이브러리 호출을 넘어, 비동기 시스템의 스레드 모델에 대한 이해를 요구합니다. 특히 MQTT 클라이언트의 콜백 메서드는 라이브러리 내부 스레드에서 실행되므로, 블로킹 작업이나 리소스 해제 작업을 수행할 때 주의가 필요합니다. Kotlin Coroutines는 이러한 복잡한 스레딩 문제를 Dispatchers를 통해 간결하고 직관적으로 해결할 수 있게 해줍니다. 실무에서는 기능 구현뿐만 아니라 예외 상황과 리소스 관리, 그리고 라이프사이클을 고려한 견고한 아키텍처 설계를 지향해야 합니다.
Post a Comment