Friday, June 9, 2023

안드로이드와 AWS IoT의 만남: MQTT 통신 구현과 콜백 스레드 문제 해결

서론: 왜 모바일 IoT 애플리케이션에 AWS와 MQTT가 중요한가?

사물 인터넷(Internet of Things, IoT)은 더 이상 미래 기술이 아닌 우리 삶의 일부가 되었습니다. 스마트 홈 기기부터 산업용 센서, 웨어러블 장치에 이르기까지 수십억 개의 디바이스가 네트워크에 연결되어 데이터를 주고받고 있습니다. 이러한 IoT 생태계의 중심에는 모바일 애플리케이션이 있습니다. 사용자는 스마트폰 앱을 통해 원격으로 기기를 제어하고, 상태를 모니터링하며, 수집된 데이터를 분석합니다. 따라서 안드로이드 개발자에게 안정적이고 효율적인 IoT 통신 기술을 구현하는 능력은 매우 중요한 역량이 되었습니다.

IoT 통신을 위한 여러 프로토콜 중, MQTT(Message Queuing Telemetry Transport)는 단연 돋보이는 선택지입니다. MQTT는 제한된 대역폭과 불안정한 네트워크 환경을 고려하여 설계된 경량의 메시징 프로토콜입니다. '발행-구독(Publish-Subscribe)' 모델을 기반으로 동작하여, 메시지를 보내는 '발행자(Publisher)'와 메시지를 받는 '구독자(Subscriber)' 사이의 직접적인 연결 없이 '브로커(Broker)'를 통해 통신합니다. 이 구조는 디바이스와 애플리케이션 간의 결합도를 낮추고, 확장성 높은 시스템을 구축하는 데 매우 유리합니다.

이러한 MQTT 브로커 역할을 수행하는 강력한 클라우드 서비스가 바로 AWS IoT Core입니다. Amazon Web Services(AWS)에서 제공하는 이 관리형 서비스는 수십억 개의 디바이스와 수조 개의 메시지를 안정적으로 처리할 수 있는 인프라를 제공합니다. 개발자는 서버 인프라 구축 및 관리에 대한 부담 없이 디바이스 인증, 권한 관리, 데이터 처리 및 라우팅 등 IoT 백엔드에 필요한 핵심 기능들을 손쉽게 활용할 수 있습니다. 안드로이드 애플리케이션에서 AWS IoT SDK를 사용하면 AWS IoT Core와 안전하고 효율적으로 연동하여 MQTT 통신을 구현할 수 있습니다.

이 글에서는 안드로이드 환경에서 AWS IoT SDK를 사용하여 MQTT 통신을 구현하는 전반적인 과정을 다룹니다. 특히, AWSIotMqttManager를 사용하여 메시지를 구독하고 처리하는 과정에서 흔히 발생할 수 있는 특정 스레딩 문제, 즉 콜백 함수 내에서 연결을 해제하려 할 때 발생하는 오류와 그 해결책을 Kotlin 코루틴을 통해 심도 있게 분석하고 제시할 것입니다. 단순히 문제를 해결하는 코드를 넘어, 그 원인과 근본적인 해결 원리를 이해함으로써 더 견고하고 안정적인 IoT 애플리케이션을 구축하는 데 필요한 깊이 있는 지식을 제공하는 것을 목표로 합니다.

1단계: AWS IoT Core 사전 준비 - 통신을 위한 기반 다지기

안드로이드 코드 작성에 앞서, AWS 클라우드 환경에서 통신을 위한 기반을 마련해야 합니다. 이는 마치 통신할 상대방의 주소와 신원을 확인하는 과정과 같습니다. AWS IoT Core에서는 통신의 주체가 되는 각 디바이스나 애플리케이션을 '사물(Thing)'이라는 논리적인 단위로 표현합니다.

사물(Thing) 생성

먼저 AWS Management Console에 로그인하여 AWS IoT Core 서비스로 이동합니다. 좌측 메뉴에서 '관리' > '사물'을 선택하고 '사물 생성' 버튼을 클릭합니다. 단일 사물을 생성하는 옵션을 선택하고, 사물의 이름을 지정합니다. 이 이름은 안드로이드 애플리케이션을 식별하는 고유한 이름이 될 수 있습니다. 예를 들어, `MyAndroidApp-Client-01`과 같이 명명 규칙을 정해두면 관리가 용이합니다.

보안 인증서 및 정책 생성/연결

IoT 통신에서 보안은 가장 중요한 요소 중 하나입니다. AWS IoT Core는 X.509 인증서 기반의 상호 인증을 통해 안전한 통신을 보장합니다. 사물을 생성하는 과정에서 '새 인증서 자동 생성(권장)' 옵션을 선택하여 인증서를 생성합니다. 이 과정이 완료되면 다음 4개의 파일을 다운로드할 수 있습니다:

  • 사물 인증서 (Thing certificate): `xxxxxx-certificate.pem.crt` 형식의 파일. 클라이언트의 신원을 증명합니다.
  • 퍼블릭 키 (Public key): `xxxxxx-public.pem.key` 형식의 파일.
  • 프라이빗 키 (Private key): `xxxxxx-private.pem.key` 형식의 파일. 클라이언트만이 소유해야 하는 비밀 키입니다.
  • 루트 CA 인증서 (Root CA certificate): 서버(AWS IoT Core)의 신원을 확인하기 위한 인증서입니다. Amazon Root CA 1 또는 다른 루트 CA를 다운로드합니다.

이 파일들, 특히 사물 인증서와 프라이빗 키는 안드로이드 애플리케이션에 포함되어야 하므로 안전하게 보관해야 합니다.

다음으로, 이 인증서를 가진 클라이언트가 무엇을 할 수 있는지 정의하는 '정책(Policy)'을 생성해야 합니다. 좌측 메뉴의 '보안' > '정책'에서 '정책 생성'을 클릭합니다. 정책은 JSON 형식으로 작성되며, 특정 MQTT 동작(연결, 발행, 구독)과 대상 토픽(Topic)에 대한 허용(Allow) 또는 거부(Deny) 규칙을 명시합니다. 예를 들어, 모든 클라이언트가 연결하고 특정 토픽에 메시지를 발행 및 구독할 수 있도록 허용하는 정책은 다음과 같이 작성할 수 있습니다.


{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:Connect",
      "Resource": "arn:aws:iot:ap-northeast-2:123456789012:client/*"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Publish",
      "Resource": "arn:aws:iot:ap-northeast-2:123456789012:topic/your/topic/*"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Subscribe",
      "Resource": "arn:aws:iot:ap-northeast-2:123456789012:topicfilter/your/topic/*"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Receive",
      "Resource": "arn:aws:iot:ap-northeast-2:123456789012:topic/your/topic/*"
    }
  ]
}

정책 생성이 완료되면, 방금 생성한 인증서에 이 정책을 '연결'해야 합니다. 또한, 인증서를 사물에도 연결하여 사물, 인증서, 정책 간의 관계를 완성합니다.

엔드포인트(Endpoint) 확인

마지막으로, 안드로이드 애플리케이션이 접속해야 할 AWS IoT Core의 고유 주소, 즉 엔드포인트를 확인해야 합니다. AWS IoT Core 대시보드의 좌측 메뉴 '설정'에서 확인할 수 있으며, `xxxxxx.iot.ap-northeast-2.amazonaws.com`과 같은 형식을 가집니다. 이 주소는 코드에서 MQTT 연결을 시도할 때 사용됩니다.

2단계: 안드로이드 프로젝트 설정 - 개발 환경 구축하기

AWS IoT Core 설정이 완료되었다면, 이제 안드로이드 스튜디오에서 프로젝트를 설정할 차례입니다. AWS IoT SDK를 사용하기 위한 의존성을 추가하고, 필요한 권한을 설정하며, 다운로드한 인증서 파일을 프로젝트에 포함시켜야 합니다.

`build.gradle`에 AWS IoT SDK 의존성 추가

모듈 수준의 `build.gradle` 파일(보통 `app/build.gradle`)을 열고, `dependencies` 블록에 AWS IoT SDK 라이브러리를 추가합니다. 2023년 기준, AWS SDK for Android v2를 사용하는 것이 일반적입니다.


dependencies {
    // ... 다른 의존성들
    implementation 'com.amazonaws:aws-android-sdk-core:2.22.0' // AWS SDK Core
    implementation 'com.amazonaws:aws-android-sdk-iot:2.22.0'  // AWS IoT SDK
}

버전 번호는 최신 버전을 확인하여 사용하는 것이 좋습니다. 의존성을 추가한 후에는 반드시 'Sync Now'를 클릭하여 라이브러리를 프로젝트에 다운로드하고 통합해야 합니다.

`AndroidManifest.xml`에 인터넷 권한 추가

안드로이드 애플리케이션이 네트워크 통신을 하기 위해서는 `INTERNET` 권한이 필요합니다. `app/src/main/AndroidManifest.xml` 파일을 열고 `` 태그 앞에 다음 권한을 추가합니다.


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.yourapp">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        ...>
        ...
    </application>
</manifest>

인증서 파일을 프로젝트에 포함시키기

AWS에서 다운로드한 인증서 파일들을 안드로이드 프로젝트에서 사용하기 위해서는 앱 리소스에 포함시켜야 합니다. 이를 위한 가장 일반적인 방법은 `res/raw` 디렉토리를 사용하는 것입니다. `app/src/main/res` 디렉토리 내에 `raw`라는 이름의 새 디렉토리를 생성하고, 다운로드한 사물 인증서 파일과 프라이빗 키 파일을 이곳에 복사합니다. 루트 CA 인증서 파일도 필요한 경우 함께 복사할 수 있습니다. 이 파일들은 나중에 코드에서 `R.raw.your_certificate_file`과 같은 리소스 ID로 접근할 수 있게 됩니다.

3단계: AWSIotMqttManager를 이용한 연결 수립

이제 모든 준비가 끝났습니다. 코드를 통해 실제로 AWS IoT Core에 연결을 시도해 보겠습니다. AWS IoT SDK의 핵심 클래스인 AWSIotMqttManager는 MQTT 연결, 구독, 발행, 연결 해제 등 모든 통신 과정을 관리하는 역할을 합니다.

AWSIotMqttManager 인스턴스화

AWSIotMqttManager 객체를 생성하기 위해서는 두 가지 중요한 정보가 필요합니다: `clientId`와 `endpoint`입니다.

  • clientId: MQTT 브로커에 연결하는 각 클라이언트를 식별하는 고유한 ID입니다. 동일한 ID로 두 개의 클라이언트가 동시에 접속을 시도하면 기존 연결이 끊어질 수 있으므로, 각 클라이언트마다 유일한 값을 사용해야 합니다. 예를 들어, 안드로이드 기기의 고유 ID나 UUID를 사용할 수 있습니다.
  • endpoint: 1단계에서 확인했던 AWS IoT Core의 엔드포인트 주소입니다.

import com.amazonaws.mobileconnectors.iot.AWSIotMqttManager

// ...

val clientId = "my-unique-android-client-id" // 실제 앱에서는 기기별 고유 ID를 사용하는 것이 좋음
val endpoint = "xxxxxx.iot.ap-northeast-2.amazonaws.com"

val awsIotMqttManager = AWSIotMqttManager(clientId, endpoint)

// 연결 유지 시간 설정 (옵션)
awsIotMqttManager.keepAlive = 300 // 초 단위

인증서를 이용한 보안 연결

단순히 `AWSIotMqttManager`를 생성하는 것만으로는 연결할 수 없습니다. 앞서 준비한 인증서를 사용하여 클라이언트가 누구인지, 그리고 접속하려는 서버가 신뢰할 수 있는지 증명해야 합니다. AWS IoT SDK는 이 과정을 돕는 `AWSIotKeystoreHelper` 클래스를 제공합니다.

`res/raw`에 저장된 인증서와 프라이빗 키 파일을 사용하여 `KeyStore` 객체를 생성하는 과정은 다음과 같습니다.


import com.amazonaws.mobileconnectors.iot.AWSIotKeystoreHelper
import java.security.KeyStore

// ...

// 애플리케이션 컨텍스트
val context = applicationContext 

// KeyStore 파일 저장을 위한 경로와 이름
val keystorePath = context.filesDir.path
val keystoreName = "iot_keystore"
val keystorePassword = "keystore_password" // 실제 앱에서는 안전한 곳에 보관

// 인증서 및 키 파일의 리소스 ID
val certificateId = R.raw.xxxxxx_certificate_pem
val privateKeyId = R.raw.xxxxxx_private_pem

// KeyStore 헬퍼를 사용하여 KeyStore 생성 및 저장
// 이 작업은 한 번만 수행하면 됨
AWSIotKeystoreHelper.saveCertificateAndPrivateKey(
    "my-cert-alias", // KeyStore 내에서 인증서를 식별할 별칭
    context.resources.openRawResource(certificateId).bufferedReader().use { it.readText() },
    context.resources.openRawResource(privateKeyId).bufferedReader().use { it.readText() },
    keystorePath,
    keystoreName,
    keystorePassword
)

// 저장된 KeyStore 로드
val clientKeyStore = AWSIotKeystoreHelper.getIotKeystore(
    "my-cert-alias",
    keystorePath,
    keystoreName,
    keystorePassword
)

connect() 메소드와 비동기 연결 프로세스

이제 생성된 `KeyStore`를 사용하여 `connect()` 메소드를 호출할 수 있습니다. 연결 과정은 네트워크를 통해 이루어지므로 비동기적으로 처리됩니다. 연결 성공, 실패, 상태 변경 등의 이벤트는 콜백 인터페이스인 `AWSIotMqttClientStatusCallback`을 통해 전달받습니다.


import com.amazonaws.mobileconnectors.iot.AWSIotMqttClientStatusCallback
import com.amazonaws.mobileconnectors.iot.AWSIotMqttQos

// ...

try {
    awsIotMqttManager.connect(clientKeyStore) { status, throwable ->
        // 이 콜백은 메인 스레드에서 호출될 수 있음
        runOnUiThread {
            Log.d("AWS_MQTT", "Connection Status: $status")
            when (status) {
                AWSIotMqttClientStatusCallback.AWSIotMqttClientStatus.Connecting -> {
                    // 연결 중 상태 UI 업데이트
                }
                AWSIotMqttClientStatusCallback.AWSIotMqttClientStatus.Connected -> {
                    // 연결 성공! 이제 메시지를 구독하거나 발행할 수 있음
                }
                AWSIotMqttClientStatusCallback.AWSIotMqttClientStatus.Reconnecting -> {
                    // 연결 끊김 후 재연결 시도 중
                }
                AWSIotMqttClientStatusCallback.AWSIotMqttClientStatus.ConnectionLost -> {
                    // 연결 완전히 상실. throwable 객체로 원인 확인 가능
                    Log.e("AWS_MQTT", "Connection lost", throwable)
                }
                else -> {
                    // 기타 상태 처리
                }
            }
        }
    }
} catch (e: Exception) {
    Log.e("AWS_MQTT", "Connection attempt failed", e)
}

4단계: MQTT 메시지 구독 및 수신

성공적으로 연결되었다면, 이제 특정 '토픽'을 구독하여 메시지를 수신할 준비를 해야 합니다.

subscribeToTopic 메소드 심층 분석

AWSIotMqttManager의 `subscribeToTopic` 메소드는 토픽 구독을 위한 핵심 함수입니다. 이 함수는 세 개의 파라미터를 받습니다.


fun subscribeToTopic(
    topic: String,
    qos: AWSIotMqttQos,
    callback: AWSIotMqttNewMessageCallback
)
  • topic: 구독할 토픽의 이름(문자열)입니다.
  • qos: 메시지 전송 품질을 나타내는 Quality of Service 레벨입니다.
  • callback: 해당 토픽으로 새로운 메시지가 도착했을 때 호출될 콜백 함수입니다.

Topic의 개념과 설계 패턴

토픽은 MQTT에서 메시지를 분류하는 주소와 같은 역할을 합니다. 슬래시(`/`)로 구분되는 계층적 구조를 가지며, 이를 통해 메시지를 체계적으로 관리할 수 있습니다. 예를 들어, `home/living_room/temperature`와 같은 토픽은 '집'의 '거실'에 있는 센서가 보낸 '온도' 데이터를 의미하도록 설계할 수 있습니다. 구독자는 와일드카드(`+`, `#`)를 사용하여 여러 토픽을 한 번에 구독할 수도 있습니다. `home/+/temperature`는 집 안의 모든 방의 온도 센서 데이터를 구독하는 예시입니다.

QoS (Quality of Service) 레벨의 이해

QoS는 메시지가 얼마나 안정적으로 전달될지를 보장하는 수준을 정의합니다. AWS IoT SDK에서는 `AWSIotMqttQos` 열거형으로 표현됩니다.

  • QoS 0 (AWSIotMqttQos.QOS0 - At most once): 메시지를 최대 한 번 보냅니다. 전송 확인(ACK)이 없으므로 메시지가 유실될 수 있지만, 가장 빠르고 오버헤드가 적습니다. 주기적으로 변하는 센서 데이터와 같이 일부가 유실되어도 괜찮은 경우에 적합합니다.
  • QoS 1 (AWSIotMqttQos.QOS1 - At least once): 메시지가 최소 한 번은 전달되는 것을 보장합니다. 브로커로부터 전송 확인을 받을 때까지 메시지를 재전송할 수 있습니다. 이 때문에 메시지가 중복으로 수신될 가능성이 있습니다. 반드시 전달되어야 하는 명령어 등에 사용됩니다.
  • QoS 2 (AWSIotMqttQos.QOS2 - Exactly once): 메시지가 정확히 한 번만 전달되는 것을 보장합니다. 4단계 핸드셰이킹을 통해 중복 전송을 방지하지만, 가장 오버헤드가 큽니다. 금융 거래와 같이 데이터의 정합성이 매우 중요한 경우에 사용됩니다.

콜백 람다를 이용한 메시지 처리 코드 예시

Kotlin의 람다 표현식을 사용하면 콜백을 간결하게 구현할 수 있습니다. 아래 코드는 `your/topic`이라는 토픽을 QoS 0으로 구독하고, 메시지가 도착하면 로그를 출력하는 예제입니다.


import java.nio.charset.Charset

// ...

val topic = "your/topic"
val qos = AWSIotMqttQos.QOS0 // QoS 0 선택

try {
    awsIotMqttManager.subscribeToTopic(topic, qos) { messageTopic, payload ->
        // 이 콜백은 MQTT 라이브러리의 내부 스레드에서 호출됨
        try {
            val message = String(payload, Charset.forName("UTF-8"))
            Log.d("MQTT_Message", "Received from topic '$messageTopic': $message")

            // 여기서 수신된 메시지를 기반으로 UI 업데이트나 다른 로직을 수행할 수 있음
            // 단, UI 업데이트는 반드시 메인 스레드에서 수행해야 함
            // runOnUiThread { /* UI 업데이트 코드 */ }

        } catch (e: Exception) {
            Log.e("MQTT_Message", "Error processing message", e)
        }
    }
} catch (e: Exception) {
    Log.e("AWS_MQTT", "Subscription failed for topic $topic", e)
}

5단계: 흔히 마주치는 함정: 콜백 내에서의 `disconnect()` 호출 오류

애플리케이션 로직에 따라, 특정 메시지를 수신했을 때 MQTT 연결을 종료해야 하는 경우가 있습니다. 예를 들어, 기기로부터 작업 완료 신호를 받으면 더 이상 통신이 필요 없어 연결을 끊는 시나리오입니다. 개발자는 자연스럽게 메시지 수신 콜백 내에서 `awsIotMqttManager.disconnect()`를 호출하려고 시도할 것입니다.


// 잘못된 예시 코드
awsIotMqttManager.subscribeToTopic(topic, qos) { topic, payload ->
    val message = String(payload, Charset.forName("UTF-8"))
    Log.d("MQTT_Message", "Received: $message")

    if (message == "TASK_COMPLETE") {
        // 특정 메시지를 받으면 연결을 끊으려고 시도
        try {
            awsIotMqttManager.disconnect() // !!! 이 부분에서 오류 발생 !!!
        } catch (e: Exception) {
            Log.e("AWS_MQTT", "Error on disconnect", e)
        }
    }
}

하지만 이 코드를 실행하면 `disconnect()` 호출 시점에서 다음과 같은 예외가 발생하며 앱이 비정상적으로 종료될 수 있습니다.

"com.amazonaws.mobileconnectors.iot.AWSIotMqttException: disconnect is not allowed from within a callback. (32107)"

에러 코드 `32107`의 의미와 문제의 근원

이 오류 메시지는 매우 명확합니다: "콜백 메소드 내부에서는 연결 끊기가 허용되지 않습니다." 이 문제의 근본적인 원인은 스레딩 모델에 있습니다.

AWSIotMqttManager와 같은 MQTT 클라이언트 라이브러리는 내부적으로 네트워크 I/O와 메시지 처리를 위한 별도의 스레드(또는 스레드 풀)를 운영합니다. `subscribeToTopic`에 등록한 콜백 함수는 새로운 메시지가 수신되었을 때, 바로 이 MQTT 라이브러리의 내부 작업 스레드에서 직접 호출됩니다.

이제 상상해 봅시다. 라이브러리의 작업 스레드가 현재 콜백 함수를 실행하고 있는 도중에, 그 콜백 함수 안에서 `disconnect()`를 호출하면 어떤 일이 벌어질까요? `disconnect()` 메소드는 연결을 종료하기 위해 바로 그 작업 스레드를 포함한 라이브러리의 내부 리소스들을 정리하고 중지시키는 로직을 수행해야 합니다. 즉, 자신이 실행되고 있는 스레드를 스스로 중지시키려는 모순적인 상황이 발생하는 것입니다. 이는 데드락(Deadlock)이나 경쟁 조건(Race Condition)과 같은 심각한 문제를 유발하여 라이브러리의 상태를 불안정하게 만들 수 있습니다.

따라서 AWS IoT SDK는 이러한 위험을 원천적으로 차단하기 위해, 콜백 함수가 실행 중인 스레드와 `disconnect()`를 호출하는 스레드가 동일한 경우 예외를 발생시키는 방어적인 설계를 채택한 것입니다.

6단계: 코루틴을 활용한 우아한 해결책

문제의 원인이 '콜백이 실행되는 현재 스레드에서 disconnect를 호출했기 때문'이라면, 해결책은 간단합니다. `disconnect()` 호출을 다른 스레드에서 수행하도록 위임하면 됩니다. 과거에는 이를 위해 새로운 `Thread`를 생성하거나 `AsyncTask`를 사용했지만, 현대 안드로이드 개발에서는 단연 Kotlin 코루틴(Coroutines)이 가장 효율적이고 우아한 해결책입니다.

Kotlin 코루틴: 현대 안드로이드 비동기 처리의 표준

코루틴은 비동기 코드를 동기 코드처럼 간결하고 순차적으로 작성할 수 있게 해주는 Kotlin의 언어 수준 기능입니다. 스레드를 직접 다루는 복잡함과 콜백 지옥(Callback Hell) 문제에서 벗어나, 가볍고 효율적인 동시성 프로그래밍을 가능하게 합니다.

`kotlinx-coroutines-core` 라이브러리 추가

코루틴을 사용하려면 먼저 관련 라이브러리를 `build.gradle` 파일에 추가해야 합니다.


dependencies {
    // ...
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1" // Android 메인 스레드 Dispatcher 등을 위해 필요
}

해결 코드 상세 분석: `CoroutineScope(Dispatchers.Default).launch`

이제 코루틴을 사용하여 `disconnect()` 호출 문제를 해결한 코드를 살펴보겠습니다.


import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

// ...

awsIotMqttManager.subscribeToTopic(topic, qos) { topic, payload ->
    val message = String(payload, Charset.forName("UTF-8"))
    Log.d("MQTT_Message", "Received: $message")

    if (message == "TASK_COMPLETE") {
        // 코루틴을 사용하여 disconnect 호출을 다른 스레드로 위임
        CoroutineScope(Dispatchers.Default).launch {
            try {
                Log.d("AWS_MQTT", "Disconnecting via Coroutine on thread: ${Thread.currentThread().name}")
                awsIotMqttManager.disconnect()
            } catch (e: Exception) {
                Log.e("AWS_MQTT", "Error on disconnect in coroutine", e)
            }
        }
    }
}

이 코드는 어떻게 동작하는 것일까요? 각 요소를 자세히 분석해 보겠습니다.

  • `CoroutineScope(...)`: 코루틴이 실행될 수 있는 컨텍스트(범위)를 정의합니다. 모든 코루틴은 특정 스코프 내에서 실행되어야 하며, 스코프가 취소되면 그 안의 모든 코루틴도 함께 취소됩니다. 이는 메모리 누수 방지에 매우 중요합니다.
  • `Dispatchers.Default`: 코루틴을 어떤 스레드 풀에서 실행할지 결정하는 '디스패처'를 지정합니다. `Dispatchers.Default`는 CPU 집약적인 작업을 위해 최적화된 백그라운드 스레드 풀을 사용하도록 지시합니다. 즉, 이 디스패처를 사용하면 코루틴 내부의 코드가 MQTT 콜백 스레드가 아닌, 별도의 백그라운드 스레드에서 실행됩니다.
  • `launch`: '코루틴 빌더' 중 하나로, 새로운 코루틴을 시작하고 즉시 반환합니다 (Fire-and-forget). `launch`로 시작된 코루틴은 지정된 디스패처의 스레드에서 비동기적으로 실행됩니다.

왜 이 방법이 효과적인가: 스레드 전환의 원리

이 코드의 핵심은 스레드 전환입니다. 1. MQTT 라이브러리가 메시지를 수신하면, 자신의 내부 작업 스레드 A에서 우리의 콜백 람다를 호출합니다. 2. 콜백 람다 내부의 `CoroutineScope(Dispatchers.Default).launch` 코드가 실행됩니다. 3. 코루틴 시스템은 `launch` 블록 내부의 코드(`awsIotMqttManager.disconnect()`)를 `Dispatchers.Default`가 관리하는 백그라운드 스레드 풀의 스레드 B에게 실행하도록 요청합니다. 4. 콜백 람다는 `launch` 호출 후 즉시 종료되고, 스레드 A는 다음 작업을 위해 해제됩니다. 5. 잠시 후, 스레드 B에서 `awsIotMqttManager.disconnect()`가 실행됩니다. 이 시점에서 `disconnect()`를 호출하는 스레드(B)와 콜백이 실행되었던 스레드(A)는 서로 다르기 때문에, AWS IoT SDK의 내부 검사를 통과하고 연결은 정상적으로 해제됩니다.

이처럼 코루틴은 단 몇 줄의 코드로 복잡한 스레드 전환을 안전하고 직관적으로 처리해 줍니다.

7단계: 더 나은 코드를 위한 제언 및 대안

앞서 제시한 코루틴 해결책은 완벽하게 동작하지만, 실제 프로덕션 애플리케이션에서는 몇 가지를 더 고려하여 코드를 개선할 수 있습니다.

`ViewModelScope` 또는 `LifecycleScope`의 활용

`CoroutineScope(Dispatchers.Default)`를 직접 생성하는 방식은 간단하지만, 생성된 스코프의 생명주기를 직접 관리해야 하는 잠재적인 위험이 있습니다. 만약 Activity나 Fragment가 파괴된 후에도 이 코루틴이 계속 실행된다면 메모리 누수를 유발할 수 있습니다. 안드로이드 Jetpack 라이브러리는 이러한 문제를 해결하기 위해 생명주기를 인식하는 코루틴 스코프를 제공합니다.

  • `viewModelScope`: `ViewModel` 내에서 사용하며, `ViewModel`이 소멸될 때(`onCleared()` 호출 시) 자동으로 취소됩니다. 비즈니스 로직을 처리하기에 가장 이상적인 스코프입니다.
  • `lifecycleScope`: Activity나 Fragment 내에서 사용하며, 해당 컴포넌트의 `Lifecycle`에 연결됩니다. `lifecycleScope.launchWhenStarted`와 같이 특정 생명주기 상태에서만 코드를 실행하도록 제어할 수 있습니다.

만약 MQTT 관련 로직을 `ViewModel`에서 관리한다면, 다음과 같이 코드를 개선할 수 있습니다.


// In a ViewModel class
class MyViewModel : ViewModel() {
    // ...
    fun handleMqttMessage(message: String) {
        if (message == "TASK_COMPLETE") {
            viewModelScope.launch(Dispatchers.IO) { // 네트워크 작업이므로 IO 디스패처가 더 적합할 수 있음
                try {
                    awsIotMqttManager.disconnect()
                } catch (e: Exception) {
                    // ...
                }
            }
        }
    }
}

코루틴 외의 다른 해결 방법

코루틴이 표준적인 방법이긴 하지만, 다른 대안도 존재합니다. 예를 들어, 전통적인 안드로이드 `Handler`를 사용하여 `disconnect()` 호출을 메인 스레드의 메시지 큐에 게시(post)할 수도 있습니다. 메인 스레드 역시 콜백 스레드와는 다른 스레드이므로 문제를 해결할 수 있습니다. 하지만 `disconnect()`는 네트워크 I/O를 포함할 수 있는 블로킹(blocking) 작업일 수 있으므로, 메인 스레드에서 실행하는 것은 UI 버벅임(ANR)을 유발할 수 있어 권장되지 않습니다. 굳이 다른 방법을 찾는다면, Java의 `ExecutorService`를 사용하여 직접 관리하는 스레드 풀에 작업을 제출하는 것도 가능하지만, 코루틴에 비해 코드가 훨씬 복잡해집니다.

결론: 안정적인 IoT 애플리케이션 구축을 향하여

이 글을 통해 우리는 안드로이드 애플리케이션에서 AWS IoT Core와 MQTT를 연동하는 실질적인 과정을 단계별로 살펴보았습니다. AWS 클라우드에서의 사전 준비부터 안드로이드 프로젝트 설정, `AWSIotMqttManager`를 이용한 연결 및 구독, 그리고 가장 중요하게는 메시지 수신 콜백 내에서 `disconnect()`를 호출할 때 발생하는 스레딩 관련 문제를 분석하고 Kotlin 코루틴을 통해 해결하는 방법을 깊이 있게 다루었습니다.

핵심적인 교훈은 비동기 이벤트 기반 라이브러리를 다룰 때, 콜백 함수가 어떤 스레드에서 실행되는지 이해하는 것이 매우 중요하다는 점입니다. AWS IoT SDK의 `disconnect` 오류는 라이브러리가 내부 상태를 보호하기 위해 마련한 안전장치이며, 개발자는 이를 우회하는 것이 아니라 스레드 모델을 존중하며 올바른 비동기 처리 패턴을 적용해야 합니다. Kotlin 코루틴은 이러한 스레드 문제를 해결하는 데 있어 간결함, 안전성, 가독성을 모두 제공하는 강력한 도구입니다.

성공적인 IoT 애플리케이션은 단순히 기능을 구현하는 것을 넘어, 예외 상황 처리, 네트워크 단절 시 재연결 로직, 그리고 생명주기 관리를 통한 리소스 누수 방지 등 견고한 아키텍처 위에서 만들어집니다. 오늘 다룬 내용을 바탕으로 더 안정적이고 효율적인 IoT 서비스를 구축해 나가시기를 바랍니다.


0 개의 댓글:

Post a Comment