오늘 다룰 주제는 MQTT에서 https://github.com/eclipse-paho/paho.mqtt.android 이 라이브러리 입니다. 제가 이 주제를 다루는 이유는 제가 이 라이브러리를 사용중인데, 업데이트가 9년째 이루어지지 않고 있기 때문입니다. 그래서 이 라이브러리를 지금 당겨서 쓰려면 당연히 여러 문제에 봉착할 수 있습니다. 혹여나 저와 비슷한 상황이 온 분이 있을까 싶기도 하고 저 스스로도 나중에 어떤 수정을 가했는지 기록할 겸 정리를 해볼까 합니다.
라이브러리 정리
이 라이브러리를 aar 파일로 만들어서 libs 폴더에 넣으려면 기본적으로 org.eclipse.paho.android.service 를 보셔야 합니다. 이쪽이 라이브러리를 구성하는 부분이니까요. 혹시 앱에 직접 넣어서 확인하는게 아니라면 org.eclipse.paho.android.sample 패키지에서 샘플 코드를 확인해볼 수 있습니다(옛날 코드라 받아서 바로 실행시키면 아마도 환경설정 이슈로 정상 빌드 되지 않을 가능성이 매우 높으니 추가적인 설정을 생각하셔야 합니다).
발생한 문제 및 해결 방안
가장 먼저 발생한 문제는 android 12 대응입니다. 놀랍게도 이 라이브러리의 TargetSDK는 24입니다. 그렇기 때문에 왠만한 대응이 없을 겁니다. 특히 Android 12에서는 PendingIntent의 플래그 관련인데요. TargetSDK 31부터는 Intent 생성시 PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_IMMUTABLE 중 하나는 같이 들어가야 한다는 정책이 문제입니다. 그래도 다행인 건 라이브러리에 이 부분만 수정해주면 Android 12 대응은 문제 없다는 점입니다.
물론 여러가지 수정들이 더 필요하겠지만... 안되는 부분이 추가적으로 발생한다면 https://stackoverflow.com/questions/71155187/android-paho-mqtt-crashes-android-12-targeting-s-version-31-and-above-requi/71839305#71839305 이 글을 참고하면 좋을 것 같습니다.
그 다음은 Android 14 대응을 위한 수정입니다. 여기서도 IntentFilter가 제일 말썽입니다. 버전을 변경하고 나면, AlarmPingSender의 service.registerReceiver의 IntentFilter에 버전에 따라 Context.RECEIVER_NOT_EXPORTED 및 Context.RECEIVER_EXPORTED를 추가해주어야 합니다. 또한 MqttService 에서도 registerReceiver에 위와 같은 행동을 해야 합니다. 기본적으로는 브로드캐스트에 RECEIVER_NOT_EXPORTED를 추가하면 되는데요. 여기에 대해서 문제가 있음은 Android 14 이상 단말에서 Context.RECEIVER_NOT_EXPORTED를 추가한 후 실행해보면 알게 됩니다.
그것이 이 글을 쓰게 된 가장 큰 계기인 AlarmPingSencer의 onReceive가 동작하지 않는 문제인데요. 이 문제의 원인을 도저히 이해할 수 없어 안드로이드 SDK 24버전부터 35 버전까지 전부 테스트를 진행해봤습니다. 전부 에뮬레이터로 돌렸고, 실기기도 몇 대 돌려봤으나, 모든 SDK를 돌려볼 순 없어서 에뮬레이터의 기록만 추가했습니다.
SDK 24(에뮬레이터) -> 정상 동작
SDK 25(에뮬레이터) -> 정상 동작
SDK 26(에뮬레이터) -> 정상 동작
SDK 27(에뮬레이터) -> 정상 동작
SDK 28(에뮬레이터) -> 정상 동작
SDK 29(에뮬레이터) -> 정상 동작
SDK 30(에뮬레이터) -> 정상 동작
SDK 31(에뮬레이터) -> 정상 동작
SDK 32(에뮬레이터) -> 정상 동작
SDK 33(에뮬레이터) -> 정상 동작
SDK 34(에뮬레이터) -> 동작 안함
SDK 35(에뮬레이터) -> 동작 안함
이 문제는 문제의 원인을 파악하지 못해서 삽질하느라 시간은 엄청 잡아먹었지만, 결론적으로 생각보다 가볍게 해결되었는데요. 일단 왜 자꾸 서버와의 연결이 끊기는 지 파악한 후, Ping이 정상동작하지 않음을 라이브러리에 있는 Logger를 이용해 파악한 후 어디까지 도달하는지 이해해서 onReceive의 비정상동작이라는 것을 파악했습니다. 여기까지도 오래 걸렸는데, 이게 아래와 같은 걸로 해결이 될 거라고 생각하는데에는 좀 더 시간이 걸렸습니다(ChatGPT를 통해 힌트를 얻어보고자 했으나 GPT는 아무리 질문을 여러번 돌려서 해봐도 잡아내질 못하는 것 같더라구요. 제 질문의 수준이 문제였을지도 모르겠지만...).
MQTT 라이브러리 중 registerReceiver를 사용하는 부분을 아래와 같이 바꿨습니다.
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
service.registerReceiver(alarmReceiver, new IntentFilter(action), Context.RECEIVER_NOT_EXPORTED);
} else service.registerReceiver(alarmReceiver, new IntentFilter(action));
이는 Android 14부터 생긴 이슈 중 하나로 registerReceiver를 할 때 Context.RECEIVER_NOT_EXPORTED 또는 Context.RECEIVER_EXPORTED 중 하나를 무조건 flag에 포함해야 하는 이슈입니다. 없으면 앱에 크래시가 발생해서 죽어버리는데요. Context.RECEIVER_NOT_EXPORTED 플래그는 외부앱의 broadcast 수신을 하지 않겠다는 의미이고 Context.RECEIVER_EXPORTED는 반대로 수신하겠다는 의미입니다. MQTT의 경우 내부 라이브러리에서 호출하는 경우이므로 내부라고 생각되는데, 어째서인지 Context.RECEIVER_NOT_EXPORTED를 사용하면 MQTT 라이브러리 내의 AlarmReceiver의 onReceive가 호출되지 않습니다. 그것도 Android 14(SDK 34)부터 말이죠.
다행인 건 외부에서 호출할 수 있도록 플래그를 수정해주는 것 만으로 해결이 되긴 해서 한 줄만 수정하면(기존에 Android 14 대응이 끝났고 Context.RECEIVER_NOT_EXPORTED를 사용했다는 전제에서는) 간단하게 해결되는 문제입니다.
이 부분은 살짝 의심되는게, 라이브러리에서 선언한 브로드캐스트 리시버의 내부 전달은 이루어지지 않지만, 프로젝트 내에 있는 브로드캐스트 리시버의 onReceive는 잘 도달하는 것을 보면 라이브러리와 프로젝트가 서로 분리되어 있다고 인식하는 것 같기도 합니다. 아니면 MQTT의 경우 idle 타임이 존재하지만 제 프로젝트에서는 바로 sendBroadCast를 통해 전달하기 때문인지도 모르겠습니다. 중요한 건 플래그가 변경되어야 한다는 점입니다(물론 이 작업을 해도 프로젝트의 설정이 다르다면 안될 지도 모르겠습니다. 그때는 다시 살펴봐야겠네요).
package org.eclipse.paho.android.service;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.util.Log;
import org.eclipse.paho.client.mqttv3.IMqttActionListener;
import org.eclipse.paho.client.mqttv3.IMqttToken;
import org.eclipse.paho.client.mqttv3.MqttPingSender;
import org.eclipse.paho.client.mqttv3.internal.ClientComms;
/**
* AlarmPingHandler:
* 기존 AlarmPingSender를 대체하는 PingSender.
* - HandlerThread 기반으로 동작 → 메인 스레드 차단 없음
* - keepAliveInterval 주기마다 comms.checkForActivity() 호출
*/
public class AlarmPingHandler implements MqttPingSender {
private static final String TAG = "AlarmPingHandler";
private ClientComms comms;
private HandlerThread handlerThread;
private Handler handler;
private boolean hasStarted = false;
private long keepAliveInterval;
private final Runnable pingRunnable = new Runnable() {
@Override
public void run() {
if (comms != null && comms.isConnected()) {
Log.d(TAG, "Sending Ping at: " + System.currentTimeMillis());
comms.checkForActivity(new IMqttActionListener() {
@Override
public void onSuccess(IMqttToken asyncActionToken) {
Log.d(TAG, "Ping success: " + System.currentTimeMillis());
}
@Override
public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
Log.e(TAG, "Ping failed: " + exception.getMessage());
}
});
// 스케줄링 계속 반복
if (handler != null) {
handler.postDelayed(this, keepAliveInterval);
}
} else {
Log.w(TAG, "Comms disconnected, stopping ping loop");
stop();
}
}
};
@Override
public void init(ClientComms comms) {
this.comms = comms;
}
@Override
public void start() {
if (hasStarted) return;
keepAliveInterval = comms.getKeepAlive(); // ms 단위
handlerThread = new HandlerThread("AlarmPingHandlerThread");
handlerThread.start();
Looper looper = handlerThread.getLooper();
handler = new Handler(looper);
// 최초 스케줄링
handler.postDelayed(pingRunnable, keepAliveInterval);
hasStarted = true;
Log.d(TAG, "AlarmPingHandler started, keepAlive=" + keepAliveInterval);
}
@Override
public void stop() {
hasStarted = false;
if (handler != null) {
handler.removeCallbacksAndMessages(null);
handler = null;
}
if (handlerThread != null) {
handlerThread.quitSafely();
handlerThread = null;
}
Log.d(TAG, "AlarmPingHandler stopped");
}
@Override
public void schedule(long delayInMilliseconds) {
if (handler != null) {
handler.removeCallbacks(pingRunnable);
handler.postDelayed(pingRunnable, delayInMilliseconds);
Log.d(TAG, "Ping rescheduled, delay=" + delayInMilliseconds);
}
}
}
그리고 위의 코드는 그냥 BroadCast를 handler로 변경한 코드입니다. 딱히 특별한 건 없고 로그까지 최대한 비슷하게 가져와서 작성했는데, 핑을 정상적으로 내보내고 있습니다. 이를 위해 MqttConnection에서 기존 AlarmPingSender를 사용하는 부분을 이 Handler로 변경해주어야 하는데요. 혹시나 브로드캐스트 리시버를 사용하는 것에 대해 걱정이 된다면 이렇게 다른 방식으로 코드를 작성해서 사용하는 것도 괜찮은 것 같습니다.
마무리
만약 새로 채팅 시스템이나 IoT 서비스에 MQTT를 구축할 예정이라면 이 라이브러리를 사용하면 안된다고 생각합니다. 그나마 IoT는 AndroidSDK 버전이 낮더라도 충분히 설치 후 서비스가 가능하니 이 라이브러리를 사용할 수 있는 최대버전으로 작업하면 된다지만 안드로이드 앱 프로젝트에 채팅 시스템이나 실시간 서비스를 이 라이브러리로 만들면 손봐야 할 부분이 너무 많아지는 문제가 있으므로 결국 다른 대안을 찾거나 수동으로 수정해야 하기 때문입니다.
그리고 직접 수정할 시간은 없지만, 이 라이브러리를 사용하고 싶다면
https://github.com/hannesa2/paho.mqtt.android/tree/master
GitHub - hannesa2/paho.mqtt.android: Kotlin MQTT client for Android
Kotlin MQTT client for Android . Contribute to hannesa2/paho.mqtt.android development by creating an account on GitHub.
github.com
이 라이브러리를 사용하시는 게 좋을 것 같습니다. paho의 mqtt 안드로이드 라이브러리를 가져와 추가적인 수정을 지속적으로 해오고 있습니다. 기능 자체는 동일하며, 적극적으로 수정하기보다는 안드로이드의 버전대응이나 버그등을 수정해서 올리고 있으니 적어도 몇년간은 이 라이브러리를 이용해서 MQTT를 작업하는데 무리가 없을 것입니다.
처음에 MQTT라는 라이브러리를 알게 되었을 때는 참 좋았습니다. 그러나 제가 직접 라이브러리를 수정해야 한다는 사실은 굉장히 어지러운데요(open source 수정 경험도 없는 주제에). 덕분에 자신감은 많이 얻은 것 같긴 합니다. 비록 엄청 코드 수가 많은 라이브러리가 아니고 직접 사용하는 프로젝트가 있는데다 기본적인 동작 방식을 이해하고 있어서 수정이 가능한 게 크지만, 나중에 적당히 수정이 잘 되고 있는 라이브러리를 사용중일 때 오류를 발견하게 된다면 자연스럽게 기여를 해볼 수 있지 않을까... 하는 기대감이 조금 생겼습니다(이 안드로이드 라이브러리가 버려져서 조금 아쉽네요. 물론 안버려졌으면 제가 수정할 일 없이 이미 다 수정되어 있었겠지만요).
이제 웹소켓이랑 MQTT, FCM 정도를 다룰 줄 알게 되었는데요. 막연하게 이런 기능해야하니까 작업해주세요~ 하면 자신있게 진행할 수 있는데, 설명해보라면 내부 구조가 아직도 좀 어려운게 할 줄 아는거랑 알고 있는 거랑은 정말 천지차이라는게 새삼 느껴집니다. 틈틈히 공부해서 기본은 하는 개발자라고 열심히 말하고 다니고 싶네요(연차가 쌓이다보니 이제는 모른다고 대답할 수도 없어서...).
'안드로이드 > 자바' 카테고리의 다른 글
스레드(Thread)란? (1) | 2022.08.26 |
---|---|
requireContext() vs getContext() (0) | 2022.05.10 |
자바의 스트림에 대하여(2) - java 8 (0) | 2021.10.05 |
자바의 스트림에 대하여(1) - java 8 (0) | 2021.09.27 |
자바의 인터페이스란? (0) | 2021.08.10 |
댓글