모바일 애플리케이션에서 백그라운드 작업 처리, 특히 블루투스 저전력(BLE) 장치와의 지속적인 통신은 기술적으로 복잡한 문제입니다. 이러한 도전은 Flutter와 같은 크로스 플랫폼 프레임워크를 사용할 때 더욱 증폭됩니다. 본 글에서는 Flutter 애플리케이션이 백그라운드 상태에서도 BLE 디바이스와 안정적으로 통신하기 위한 아키텍처를 설계하고 구현한 과정을 상세히 다룹니다.
1. 문제 정의와 기술적 도전
1.1 프로젝트 배경
스마트 웨어러블 디바이스와 연동되는 건강 관리 애플리케이션에서는 사용자가 앱을 적극적으로 사용하지 않거나 디바이스 화면이 꺼진 상태에서도 지속적인 데이터 수집이 필요합니다. 이 애플리케이션은 다음과 같은 기능이 요구되었습니다:
- 실시간 데이터 수집: 걸음 수, 심박수, 수면 패턴 등의 사용자 건강 데이터를 지속적으로 수집
- 백그라운드 동기화: 앱이 포그라운드에 있지 않을 때도 일정 주기로 웨어러블 디바이스와 데이터 동기화
- 배터리 효율성: 지속적인 BLE 연결을 유지하면서도 배터리 소모 최소화
- 안정적인 연결 유지: 앱 상태 변화와 무관하게 BLE 연결 유지
1.2 기술적 제약 사항
이러한 요구사항은 다음과 같은 기술적 제약에 직면했습니다:
- 안드로이드 백그라운드 제한: 안드로이드 8.0(Oreo) 이상에서 도입된 백그라운드 실행 제한으로 인해 앱이 백그라운드 상태일 때 지속적인 작업 수행이 제한됨
- Flutter 백그라운드 처리 한계: Flutter 자체만으로는 안드로이드의 백그라운드 서비스를 완전히 활용하기 어려움
- BLE 연결 지속성: BLE 연결은 앱의 생명주기와 독립적으로 유지되어야 함
- 플랫폼 간 호환성: 네이티브 백그라운드 기능을 최대한 활용하면서도 Flutter의 크로스 플랫폼 이점을 유지
2. 여러 접근법과 실패 사례
2.1 Flutter 플러그인 기반 접근법
처음에는 순수한 Flutter 솔루션을 찾기 위해 여러 플러그인을 시도했습니다:
2.1.1 flutter_background_service
flutter_background_service 플러그인은 Flutter 애플리케이션에서 백그라운드 서비스를 제공하기 위해 설계되었습니다.
// flutter_background_service 구현 시도
import 'package:flutter_background_service/flutter_background_service.dart';
void initBackgroundService() async {
final service = FlutterBackgroundService();
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: onStart,
autoStart: true,
isForegroundMode: true,
),
iosConfiguration: IosConfiguration(
autoStart: true,
onForeground: onStart,
onBackground: onIosBackground,
),
);
service.startService();
}
@pragma('vm:entry-point')
void onStart(ServiceInstance service) async {
// 백그라운드 동기화 로직
while (true) {
// BLE 데이터 동기화
await syncBleData();
await Future.delayed(Duration(minutes: 1));
}
}
실패 원인:
- 서비스가 장시간 실행될 때 간헐적인 연결 끊김 발생
- 안드로이드의 Doze 모드와 배터리 최적화에 의해 실행 간격이 불규칙해짐
- 타사 BLE SDK와의 통합 과정에서 복잡성 증가
2.1.2 flutter_workmanager
WorkManager를 활용하여 주기적인 백그라운드 작업을 구현하려 했습니다.
// flutter_workmanager 구현 시도
import 'package:workmanager/workmanager.dart';
void initializeWorkManager() {
Workmanager().initialize(callbackDispatcher);
Workmanager().registerPeriodicTask(
"ble-sync-task",
"syncBleData",
frequency: Duration(minutes: 15),
constraints: Constraints(
networkType: NetworkType.not_required,
requiresBatteryNotLow: false,
),
);
}
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((taskName, inputData) async {
switch (taskName) {
case "syncBleData":
await BleService().syncData();
break;
}
return Future.value(true);
});
}
실패 원인:
- WorkManager는 지연 가능한 작업에 적합하나, 실시간에 가까운 데이터 수집에는 부적합
- 작업 실행 간격이 OS에 의해 조정되어 정확한 주기 보장 불가
- 작업 실행 시간이 제한되어 복잡한 BLE 동기화 작업 완료 불가능
2.1.3 background_fetch
iOS의 Background Fetch와 안드로이드의 JobScheduler/AlarmManager를 활용하는 background_fetch 플러그인도 시도했습니다.
// background_fetch 구현 시도
import 'package:background_fetch/background_fetch.dart';
void initBackgroundFetch() {
BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
stopOnTerminate: false,
enableHeadless: true,
startOnBoot: true,
),
(String taskId) async {
// BLE 데이터 동기화
await BleService().syncData();
BackgroundFetch.finish(taskId);
},
(String taskId) async {
// 타임아웃 처리
BackgroundFetch.finish(taskId);
}
);
}
// 헤드리스 모드 설정
@pragma('vm:entry-point')
void backgroundFetchHeadlessTask(HeadlessTask task) async {
String taskId = task.taskId;
bool isTimeout = task.timeout;
if (isTimeout) {
BackgroundFetch.finish(taskId);
return;
}
// BLE 데이터 동기화
await BleService().syncData();
BackgroundFetch.finish(taskId);
}
실패 원인:
- 실행 간격의 최소값이 15분으로 제한되어 있어 더 빈번한 동기화가 필요한 경우 적합하지 않음
- 운영체제에 의해 백그라운드 실행이 지연되거나 제한될 수 있음
- 복잡한 BLE 동작에 필요한 기능이 제한적
2.2 Flutter Isolate 활용 시도
Flutter의 Isolate를 사용하여 메인 스레드와 독립적인 백그라운드 처리를 시도했습니다.
// Isolate 기반 접근법
import 'dart:isolate';
import 'dart:ui';
@pragma('vm:entry-point')
void isolateEntryPoint(SendPort sendPort) async {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen((message) async {
if (message == 'start_sync') {
while (true) {
try {
// BLE 데이터 동기화
await syncBleData();
await Future.delayed(Duration(minutes: 1));
} catch (e) {
print('Sync error: $e');
}
}
}
});
}
void startBackgroundProcessing() async {
final receivePort = ReceivePort();
await Isolate.spawn(isolateEntryPoint, receivePort.sendPort);
final SendPort sendPort = await receivePort.first;
sendPort.send('start_sync');
}
실패 원인:
- Flutter 엔진이 중지될 때 Isolate도 함께 중지되므로 앱이 백그라운드로 전환될 때 작업 지속 불가
- 네이티브 BLE 스택과의 통신을 위해 추가적인 채널 설정이 필요하여 복잡성 증가
- 메모리 관리와 리소스 할당 문제 발생
3. 하이브리드 솔루션: 최종 아키텍처
초기 접근법의 한계를 극복하기 위해, Flutter와 네이티브 안드로이드의 기능을 결합한 하이브리드 아키텍처를 개발했습니다. 이 접근법은 네이티브 기능의 강력함과 Flutter의 사용자 경험 이점을 모두 활용합니다.
3.1 아키텍처 개요
최종 아키텍처는 다음 요소로 구성됩니다:
- 안드로이드 포그라운드 서비스: 지속적인 실행 보장과 BLE 연결 유지
- Flutter 백그라운드 엔진: 앱이 백그라운드에 있을 때도 Flutter 코드 실행
- Method Channel: Flutter와 네이티브 코드 간 양방향 통신
- 알람 매니저: 주기적인 동기화 작업 스케줄링
3.2 안드로이드 포그라운드 서비스 구현
포그라운드 서비스는 지속적인 BLE 연결을 위한 핵심 컴포넌트입니다:
// BleService.kt
class BleService : BluetoothLeService() {
companion object {
const val CHANNEL_ID = "BRingServiceChannel"
const val NOTIFICATION_ID = 1
const val ACTION_UPDATE_STEP_COUNT = "com.apposter.smart_device.UPDATE_STEP_COUNT"
const val ACTION_LOW_BATTERY = "com.apposter.smart_device.LOW_BATTERY_ALERT"
private const val BACKGROUND_SYNC_INTERVAL: Long = 1 * 60 * 1000 // 1분 간격
}
private var smartDeviceService: IRemoteService? = null
private lateinit var flutterEngine: FlutterEngine
private lateinit var backgroundMethodChannel: MethodChannel
override fun onCreate() {
super.onCreate()
// 포그라운드 서비스 설정
createNotificationChannel()
val notification = createNotification(0, 0, 0)
startForegroundService(notification)
// Flutter 엔진 초기화
initializeFlutterEngine()
// BLE 서비스 초기화
initializeSmartDeviceService()
// 주기적 백그라운드 동기화 설정
setupBackgroundAlarmManager()
}
private fun initializeFlutterEngine() {
val prefs = getSharedPreferences("flutter_background_prefs", Context.MODE_PRIVATE)
val callbackHandle = prefs.getLong("callback_handle", 0L)
val flutterLoader = FlutterInjector.instance().flutterLoader()
flutterLoader.startInitialization(this)
flutterLoader.ensureInitializationComplete(this, null)
if (callbackHandle == 0L) {
Log.e(TAG, "No callback handle registered yet.")
flutterEngine = FlutterEngine(this)
} else {
val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
if (callbackInfo == null) {
Log.e(TAG, "Invalid callback information.")
flutterEngine = FlutterEngine(this)
} else {
flutterEngine = FlutterEngine(this)
flutterEngine.dartExecutor.executeDartCallback(
DartExecutor.DartCallback(
assets,
flutterLoader.findAppBundlePath(),
callbackInfo
)
)
Log.d(TAG, "Dart callback executed successfully.")
}
}
backgroundMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.apposter.smart_device/channel/background"
)
backgroundMethodChannel.setMethodCallHandler { call, result ->
// 메서드 콜 처리 로직
when (call.method) {
"getDataByDay" -> {
try {
val type: Int? = call.argument("type")
val day: Int? = call.argument("day")
val safeType = type ?: 1
val safeDay = day ?: 0
if (smartDeviceService != null) {
val data = smartDeviceService?.getDataByDay(safeType, safeDay)
result.success(data)
} else {
result.error("UNAVAILABLE", "Service not connected", null)
}
} catch (e: RemoteException) {
result.error("REMOTE_ERROR", e.message, null)
}
}
else -> result.notImplemented()
}
}
FlutterEngineCache.getInstance().put("background_engine", flutterEngine)
}
private fun setupBackgroundAlarmManager() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, BleService::class.java).apply {
action = "BACKGROUND_SYNC"
}
val pendingIntent = PendingIntent.getService(
this, 1001, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val triggerTime = System.currentTimeMillis() + BACKGROUND_SYNC_INTERVAL
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent
)
} else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == "BACKGROUND_SYNC") {
backgroundSync()
}
return START_STICKY
}
private fun backgroundSync() {
try {
val isConnected = smartDeviceService?.isConnectBt() ?: false
if (isConnected) {
backgroundMethodChannel.invokeMethod("backgroundServiceInit", true)
}
setupBackgroundAlarmManager()
} catch (e: RemoteException) {
Log.e(TAG, "Error checking connection state", e)
}
}
// 기타 필요한 메서드들...
}
이 포그라운드 서비스는:
- 사용자에게 알림을 표시하여 시스템에 의해 종료되지 않도록 함
- Flutter 엔진의 백그라운드 인스턴스를 초기화하여 앱이 백그라운드에 있을 때도 Dart 코드 실행 가능
- AlarmManager를 사용하여 주기적인 동기화 작업을 예약
3.3 MainActivity에서의 BLE 서비스 연결 및 Flutter 통합
MainActivity는 Flutter UI와 네이티브 BLE 서비스 간의 브릿지 역할을 합니다:
// MainActivity.kt
class MainActivity : FlutterActivity() {
private val CHANNEL_SDK = "com.apposter.smart_device/channel/sdk"
private val CHANNEL_UI = "com.apposter.smart_device/channel/ui"
private val CHANNEL_MODE = "com.apposter.smart_device/channel/mode"
private val CHANNEL_BACKGROUND = "com.apposter.smart_device/channel/background"
private var smartDeviceService: IRemoteService? = null
private var isBind = false
private lateinit var methodChannel: MethodChannel
private var isActive = true // 앱 활성화 상태 추적
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
smartDeviceService = IRemoteService.Stub.asInterface(service)
try {
smartDeviceService?.registerCallback(mServiceCallback)
// 기타 초기화 코드...
} catch (e: RemoteException) {
Log.e(TAG, "Failed to register callback: ${e.message}")
}
}
override fun onServiceDisconnected(arg0: ComponentName) {
isBind = false
}
}
private val mServiceCallback = object : IServiceCallback.Stub() {
@Throws(RemoteException::class)
override fun onGetDataByDay(type: Int, timestamp: Long, step: Int, heartrate: Int) {
val date = Date(timestamp * 1000)
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val recorddate = sdf.format(date)
val data = mapOf(
"type" to type,
"time" to recorddate,
"step" to step,
"heartrate" to heartrate
)
if (isActive) {
// 앱이 포그라운드 상태일 때 UI로 데이터 전송
runOnUiThread {
methodChannel.invokeMethod("onGetDataByDay", data)
}
} else {
// 앱이 백그라운드 상태일 때 백그라운드 엔진으로 데이터 전송
sendToBackgroundFlutter("background_onGetDataByDay", data)
}
}
// 기타 콜백 메서드들...
}
private fun sendToBackgroundFlutter(method: String, data: Any?) {
val cachedFlutterEngine = FlutterEngineCache.getInstance().get("background_engine")
if (cachedFlutterEngine != null) {
val backgroundMethodChannel = MethodChannel(
cachedFlutterEngine.dartExecutor.binaryMessenger,
"com.apposter.smart_device/channel/background"
)
Handler(Looper.getMainLooper()).post {
try {
backgroundMethodChannel.invokeMethod(method, data)
} catch (e: Exception) {
Log.e(TAG, "Failed to send to background Flutter: ${e.message}")
}
}
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL_SDK)
methodChannel.setMethodCallHandler { call, result ->
when (call.method) {
"registerBackgroundCallback" -> {
val handle = call.arguments as Long
val prefs = getSharedPreferences("flutter_background_prefs", Context.MODE_PRIVATE)
prefs.edit().putLong("callback_handle", handle).apply()
result.success(null)
}
// 기타 메서드 처리...
}
}
}
override fun onStart() {
super.onStart()
isActive = true
// BLE 서비스 시작 및 바인딩
val intent = Intent(this, BleService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
override fun onStop() {
super.onStop()
isActive = false
if (isBind) {
unbindService(connection)
isBind = false
}
}
override fun onPause() {
super.onPause()
isActive = false
}
override fun onResume() {
super.onResume()
isActive = true
}
}
주요 포인트:
- 여러 MethodChannel을 통해 Flutter와 네이티브 코드 간 통신 지원
- 앱 상태(포그라운드/백그라운드)에 따라 데이터를 적절한 Flutter 인스턴스로 라우팅
- BLE 서비스와의 연결 관리 및 콜백 처리
3.4 Flutter 백그라운드 처리 구현
Flutter 측에서는 백그라운드 처리를 위한 전용 엔트리 포인트를 구현했습니다:
// background_service.dart
import 'dart:ui';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:smart_device/constants/device_modes.dart';
import 'package:smart_device/services/smart_device_service.dart';
import 'package:smart_device/utils/app_logger.dart';
const backgroundChannel =
MethodChannel('com.apposter.smart_device/channel/background');
@pragma('vm:entry-point')
void backgroundMain() {
WidgetsFlutterBinding.ensureInitialized();
int currentSyncStep = 0;
backgroundChannel.setMethodCallHandler((call) async {
switch (call.method) {
case 'backgroundServiceInit':
if (call.arguments == true) {
currentSyncStep = 0;
BackgroundService().executeNextSyncStep(currentSyncStep);
}
AppLogger.debug("[Background Method Call] - backgroundServiceInit");
break;
case 'background_onGetDataByDay':
await SmartDeviceService.instance.onGetDataByDay(call);
break;
case 'background_onGetDataByDayEnd':
currentSyncStep++;
BackgroundService().executeNextSyncStep(currentSyncStep);
AppLogger.debug('[Background] Sync step $currentSyncStep completed');
break;
default:
AppLogger.error('Unknown Background Method Call: ${call.method}');
}
});
}
class BackgroundService {
static final BackgroundService _instance = BackgroundService._internal();
factory BackgroundService() => _instance;
BackgroundService._internal();
static Future<void> initialize() async {
const channel = MethodChannel('com.apposter.smart_device/channel/sdk');
final callbackHandle = PluginUtilities.getCallbackHandle(backgroundMain);
await channel.invokeMethod(
'registerBackgroundCallback', callbackHandle!.toRawHandle());
}
void executeNextSyncStep(int step) {
if (step > 3) {
AppLogger.debug('[Background] All sync steps completed!');
return;
}
AppLogger.debug('[Background] Executing sync step: $step');
switch (step) {
case 0: // 첫 번째 단계: 당일 활동,수면 데이터
backgroundChannel.invokeMethod(
'getDataByDay', {'type': DEVICE_MODE_EXERCISE, 'day': 0});
break;
case 1: // 두 번째 단계: 당일 심박수 데이터
backgroundChannel.invokeMethod(
'getDataByDay', {'type': DEVICE_MODE_HEART_RATE, 'day': 0});
break;
case 2: // 세 번째 단계: 전날 활동,수면 데이터
backgroundChannel.invokeMethod(
'getDataByDay', {'type': DEVICE_MODE_EXERCISE, 'day': 1});
break;
case 3: // 네 번째 단계: 전날 심박수 데이터
backgroundChannel.invokeMethod(
'getDataByDay', {'type': DEVICE_MODE_HEART_RATE, 'day': 1});
break;
}
}
}
주요 포인트:
- @pragma('vm:entry-point') 어노테이션으로 백그라운드 시작점 지정
- 단계적 동기화 과정을 관리하여 체계적인 데이터 수집
- 네이티브에서 전달된 데이터를 적절히 처리하고 다음 단계 실행
3.5 데이터 처리 및 서비스 클래스
Flutter 내부에서 BLE 데이터를 처리하기 위한 서비스 클래스를 구현했습니다:
// smart_device_service.dart
class SmartDeviceService {
// Private constructor
SmartDeviceService._privateConstructor();
// 활동(Step) 서비스
static final _userStepService = UserStepService();
// 심박수 서비스
static final _userHeartRateService = UserHeartRateService();
// SPO2 서비스
static final _userBloodOxygenService = UserBloodOxygenService();
// Sleep 서비스
static final _userSleepService = UserSleepService();
// The single instance of the class
static final SmartDeviceService instance = SmartDeviceService._privateConstructor();
// 네이티브에서 전달받은 데이터 처리
Future<void> onGetDataByDay(MethodCall call) async {
Map<String, dynamic> data = Map<String, dynamic>.from(call.arguments);
int type = data['type'];
String timestamp = data['time'];
int step = data['step'];
int heartrate = data['heartrate'];
// 데이터 타입에 따라 적절한 처리
switch (type) {
case DEVICE_MODE_EXERCISE:
await _processStepData(timestamp, step);
break;
case DEVICE_MODE_HEART_RATE:
await _processHeartRateData(timestamp, heartrate);
break;
// 기타 데이터 타입 처리...
}
AppLogger.debug(
'Processed data - Type: $type, Time: $timestamp, Step: $step, HeartRate: $heartrate');
}
Future<void> _processStepData(String timestamp, int step) async {
if (step > 0) {
await _userStepService.createUserStep(UserStep(
stepCount: step,
distance: calculateDistance(step),
calories: calculateCalories(step),
recordedAt: DateTime.parse(timestamp),
));
// 데이터 집계 처리
await DailyRecordsService().aggregateSteps(timestamp, step);
}
}
Future<void> _processHeartRateData(String timestamp, int heartrate) async {
if (heartrate > 0) {
await _userHeartRateService.createUserHeartRate(UserHeartRate(
bpm: heartrate,
sleepStatus: 0,
measurementMethod: 'A',
recordedAt: DateTime.parse(timestamp),
));
}
}
// 기타 필요한 메서드들...
}
이 서비스 클래스는:
- 싱글톤 패턴을 사용하여 일관된 인스턴스 접근 제공
- 네이티브에서 전달받은 데이터를 적절한 데이터 모델로 변환하고 처리
- 다양한 건강 데이터 유형을 각각의 서비스 클래스가 담당하도록 분리
4. 핵심 기술 구현과 도전 해결
4.1 Flutter와 네이티브 통신 최적화
Flutter와 네이티브 안드로이드 간의 통신은 프로젝트의 성공에 매우 중요했습니다. 이를 최적화하기 위해 다음과 같은 전략을 채택했습니다:
4.1.1 다중 채널 아키텍처
여러 Method Channel을 사용하여 데이터 흐름을 논리적으로 분리했습니다:
// method_channels.dart
import 'package:flutter/services.dart';
class Channels {
// SDK 관련 기능을 위한 채널
static const MethodChannel methodSDKChannel =
MethodChannel('com.apposter.smart_device/channel/sdk');
// UI 업데이트를 위한 채널
static const MethodChannel methodUIChannel =
MethodChannel('com.apposter.smart_device/channel/ui');
// 운동 모드 설정을 위한 채널
static const MethodChannel methodModeChannel =
MethodChannel('com.apposter.smart_device/channel/mode');
// 백그라운드 작업을 위한 채널
static const MethodChannel backgroundChannel =
MethodChannel('com.apposter.smart_device/channel/background');
}
이러한 채널 분리는:
- 코드 구조를 더 명확하게 만듦
- 각 채널이 특정 책임에 집중하도록 함
- 데이터 흐름을 더 효과적으로 추적할 수 있게 함
4.1.2 앱 상태에 따른 데이터 라우팅
앱이 포그라운드에 있는지 백그라운드에 있는지에 따라 데이터를 적절한 대상으로 라우팅하는 메커니즘을 구현했습니다:
// MainActivity.kt의 콜백 메서드
@Throws(RemoteException::class)
override fun onGetDataByDay(type: Int, timestamp: Long, step: Int, heartrate: Int) {
val data = mapOf(
"type" to type,
"time" to recorddate,
"step" to step,
"heartrate" to heartrate
)
if (isActive) {
// 앱이 활성 상태일 때 UI 채널로 데이터 전송
runOnUiThread {
methodChannel.invokeMethod("onGetDataByDay", data)
}
} else {
// 앱이 백그라운드 상태일 때 백그라운드 채널로 데이터 전송
sendToBackgroundFlutter("background_onGetDataByDay", data)
}
}
이 접근법의 이점:
- 앱이 백그라운드에 있을 때도 데이터 처리 가능
- UI 스레드 차단 방지
- 일관된 데이터 흐름 유지
4.2 안드로이드 Doze 모드 우회
안드로이드의 Doze 모드는 배터리 최적화를 위해 백그라운드 작업을 제한하는데, 이를 극복하기 위한 방법을 구현했습니다:
private fun setupBackgroundAlarmManager() {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, BleService::class.java).apply {
action = "BACKGROUND_SYNC"
}
val pendingIntent = PendingIntent.getService(
this, 1001, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val triggerTime = System.currentTimeMillis() + BACKGROUND_SYNC_INTERVAL
// Doze 모드에서도 실행 보장을 위한 설정
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent
)
} else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
}
}
setExactAndAllowWhileIdle는 안드로이드 Doze 모드에서도 알람이 작동하도록 보장합니다. 이 방법은 완벽하진 않지만, 제한된 백그라운드 동작 내에서 최대한의 신뢰성을 확보할 수 있었습니다.
4.3 Flutter 엔진의 복수 인스턴스 관리
Flutter 엔진의 여러 인스턴스를 관리하는 것은 메모리 사용량과 성능 관점에서 도전적이었습니다:
private fun initializeFlutterEngine() {
// Flutter 로더 초기화
val flutterLoader = FlutterInjector.instance().flutterLoader()
flutterLoader.startInitialization(this)
flutterLoader.ensureInitializationComplete(this, null)
// 콜백 핸들 가져오기
val prefs = getSharedPreferences("flutter_background_prefs", Context.MODE_PRIVATE)
val callbackHandle = prefs.getLong("callback_handle", 0L)
if (callbackHandle != 0L) {
val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
if (callbackInfo != null) {
// 백그라운드용 Flutter 엔진 생성 및 Dart 엔트리포인트 실행
flutterEngine = FlutterEngine(this)
flutterEngine.dartExecutor.executeDartCallback(
DartExecutor.DartCallback(
assets,
flutterLoader.findAppBundlePath(),
callbackInfo
)
)
// 엔진 캐싱
FlutterEngineCache.getInstance().put("background_engine", flutterEngine)
}
}
}
이 구현에서 주의해야 할 점:
- Flutter 엔진의 생성과 초기화는 리소스 집약적인 작업임
- 엔진 인스턴스의 수명 주기를 적절히 관리하여 메모리 누수 방지
- FlutterEngineCache를 활용하여 엔진 재사용 최적화
4.4 데이터 일관성 보장
백그라운드에서의 데이터 처리에서 중요한 요소는 데이터 일관성 유지입니다:
Future<void> _processStepData(String timestamp, int step) async {
if (step <= 0) return; // 유효하지 않은 데이터 무시
final DateTime recordDate = DateTime.parse(timestamp);
final String dateKey = DateFormat('yyyy-MM-dd').format(recordDate);
// 트랜잭션으로 일관성 보장
await database.transaction((txn) async {
// 기존 데이터 확인
final existingData = await txn.query(
'user_steps',
where: 'date = ?',
whereArgs: [dateKey]
);
if (existingData.isNotEmpty) {
// 기존 데이터가 있을 경우, 더 큰 값으로만 업데이트
final int currentSteps = existingData.first['steps'] as int;
if (step > currentSteps) {
await txn.update(
'user_steps',
{'steps': step, 'updated_at': DateTime.now().toIso8601String()},
where: 'date = ?',
whereArgs: [dateKey]
);
}
} else {
// 새 데이터 삽입
await txn.insert('user_steps', {
'date': dateKey,
'steps': step,
'recorded_at': timestamp,
'created_at': DateTime.now().toIso8601String()
});
}
});
// 집계 데이터 업데이트
await DailyRecordsService().aggregateSteps(timestamp, step);
}
이 접근법은:
- 트랜잭션을 사용하여 ACID 속성 보장
- 중복 데이터 처리 로직 구현
- 데이터 정합성 검증 및 보호
5. 백그라운드 작업의 순차적 실행 구현
백그라운드 작업의 순차적 실행은 데이터의 완전성과 시스템 리소스 관리에 중요했습니다:
void executeNextSyncStep(int step) {
if (step > 3) {
AppLogger.debug('[Background] All sync steps completed!');
return;
}
AppLogger.debug('[Background] Executing sync step: $step');
switch (step) {
case 0: // 첫 번째 단계: 당일 활동,수면 데이터
backgroundChannel.invokeMethod(
'getDataByDay', {'type': DEVICE_MODE_EXERCISE, 'day': 0});
break;
case 1: // 두 번째 단계: 당일 심박수 데이터
backgroundChannel.invokeMethod(
'getDataByDay', {'type': DEVICE_MODE_HEART_RATE, 'day': 0});
break;
case 2: // 세 번째 단계: 전날 활동,수면 데이터
backgroundChannel.invokeMethod(
'getDataByDay', {'type': DEVICE_MODE_EXERCISE, 'day': 1});
break;
case 3: // 네 번째 단계: 전날 심박수 데이터
backgroundChannel.invokeMethod(
'getDataByDay', {'type': DEVICE_MODE_HEART_RATE, 'day': 1});
break;
}
}
각 단계가 완료되면 다음 단계가 실행되도록 하는 이 패턴은:
- 데이터 수집 작업을 작은 단위로 분할하여 안정성 향상
- 각 작업의 진행 상황을 추적 가능
- 오류 발생 시 특정 지점부터 복구 가능
5.1 피드백 메커니즘 구현
백그라운드 작업의 성공 여부를 확인하기 위한 피드백 메커니즘이 중요했습니다:
// 백그라운드 Dart 코드에서
backgroundChannel.setMethodCallHandler((call) async {
switch (call.method) {
case 'background_onGetDataByDay':
try {
await SmartDeviceService.instance.onGetDataByDay(call);
// 성공 시 네이티브에 알림
backgroundChannel.invokeMethod('onDataProcessed', {'success': true});
} catch (e) {
// 실패 시 오류 정보 전달
backgroundChannel.invokeMethod('onDataProcessed', {
'success': false,
'error': e.toString()
});
AppLogger.error('Error processing data: $e');
}
break;
case 'background_onGetDataByDayEnd':
currentSyncStep++;
// 다음 단계 실행 전 잠시 지연
await Future.delayed(Duration(milliseconds: 500));
BackgroundService().executeNextSyncStep(currentSyncStep);
AppLogger.debug('[Background] Sync step $currentSyncStep started');
break;
}
});
네이티브 쪽에서의 처리:
// BleService.kt
backgroundMethodChannel.setMethodCallHandler { call, result ->
when (call.method) {
"onDataProcessed" -> {
val success = call.argument<Boolean>("success") ?: false
if (!success) {
val error = call.argument<String>("error")
Log.e(TAG, "Data processing failed: $error")
// 필요한 경우 오류 처리 로직 실행
}
result.success(null)
}
// 기타 메서드 처리...
}
}
이 피드백 루프는:
- 작업 성공/실패 여부를 네이티브 코드에 알려줌
- 오류 발생 시 적절한 로깅 및 복구 조치 가능
- 전체 작업 흐름의 상태 추적 가능
6. 성능 최적화 및 배터리 효율성
6.1 배터리 사용량 최소화
BLE 연결과 백그라운드 작업은 배터리를 빠르게 소모할 수 있으므로, 이를 최소화하기 위한 전략을 채택했습니다:
private fun determineOptimalSyncInterval(): Long {
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val isCharging = batteryManager.isCharging()
return when {
isCharging -> 5L * 60 * 1000 // 충전 중일 때 5분마다
batteryLevel > 50 -> 15L * 60 * 1000 // 배터리가 충분할 때 15분마다
else -> 30L * 60 * 1000 // 배터리가 부족할 때 30분마다
}
}
이 적응형 동기화 간격 조정은:
- 배터리 상태에 따라 동기화 빈도 조절
- 충전 중일 때 더 빈번한 업데이트 제공
- 배터리가 부족할 때 필수 업데이트만 수행
6.2 리소스 사용량 모니터링
백그라운드 작업의 리소스 사용량을 모니터링하고 제한하는 것도 중요했습니다:
private fun monitorResourceUsage() {
val runtime = Runtime.getRuntime()
val usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024
Log.d(TAG, "Used memory: $usedMemory MB")
if (usedMemory > 100) { // 메모리 사용량이 100MB를 초과하면
// 불필요한 리소스 정리
System.gc()
// 필요한 경우 일부 기능 비활성화
if (usedMemory > 150) {
Log.w(TAG, "Memory usage too high, disabling non-critical features")
// 비필수 기능 비활성화 로직
}
}
}
리소스 모니터링의 이점:
- 메모리 누수 조기 발견
- 과도한 리소스 사용 방지
- 안정적인 장기 실행 보장
7. 학습된 교훈과 모범 사례
7.1 플랫폼별 접근 방식의 중요성
Flutter의 크로스 플랫폼 특성에도 불구하고, 백그라운드 작업과 BLE 연결 같은 기능은 플랫폼별 구현이 필요했습니다:
Future<bool> isConnectBt() async {
try {
if (Platform.isIOS) {
return await _iosConnectivityCheck();
} else {
return await methodChannel.invokeMethod('isConnectBt');
}
} catch (e) {
AppLogger.error("Failed to check Bluetooth connection: $e");
return false;
}
}
교훈:
- 백그라운드 처리 및 하드웨어 통합은 플랫폼별 구현 필요
- 플랫폼별 코드와 공통 코드의 균형 찾기
- 각 플랫폼의 특성과 제한 사항 이해하기
7.2 데이터 흐름 설계의 중요성
분산 시스템에서 데이터 흐름을 명확히 설계하는 것은 매우 중요했습니다:
[BLE 디바이스] → [네이티브 BLE 서비스] → [Method Channel] → [Flutter 로직] → [로컬 DB]
이 흐름을 분리하고 각 단계에 대한 책임을 명확히 정의했습니다:
- 네이티브 BLE 서비스: 장치 연결 및 원시 데이터 수신
- Method Channel: 데이터 변환 및 전달
- Flutter 로직: 비즈니스 로직 및 데이터 처리
- 로컬 DB: 영구 저장 및 쿼리
7.3 점진적 구현과 테스트
복잡한 기능을 한 번에 구현하는 대신, 점진적인 접근 방식을 채택했습니다:
- 먼저 간단한 포그라운드 통신 구현: 기본 Flutter-네이티브 통신 검증
- 포그라운드 서비스 추가: 지속적인 BLE 연결 확립
- 백그라운드 Flutter 엔진 통합: 백그라운드 데이터 처리 능력 추가
- 알람 매니저를 통한 주기적 동기화 추가: 지속적인 데이터 수집 보장
- 배터리 최적화 및 성능 튜닝: 사용자 경험 향상
각 단계에서 철저한 테스트를 수행하고 안정성을 확인한 후 다음 단계로 진행했습니다.
8. 결론
Flutter와 네이티브 안드로이드 기능을 결합한 하이브리드 접근법을 통해 안정적인 백그라운드 BLE 데이터 동기화를 구현할 수 있었습니다. 순수 Flutter 플러그인이나 단일 접근법의 한계를 극복하기 위해 여러 기술을 조합한 것이 핵심 성공 요인이었습니다.
이 구현은 다음과 같은 이점을 제공합니다:
- 안정적인 백그라운드 동작: 안드로이드의 포그라운드 서비스를 통해 지속적인 실행 보장
- Flutter 코드 재사용: 백그라운드와 포그라운드에서 동일한 Flutter 로직 활용
- 효율적인 리소스 사용: 배터리 및 메모리 최적화 전략 적용
- 확장 가능한 아키텍처: 새로운 기능이나 데이터 유형 쉽게 추가 가능
이 접근법은 건강 모니터링 앱 외에도 백그라운드 작업과 하드웨어 통합이 필요한 다양한 Flutter 애플리케이션에 적용할 수 있습니다. 플랫폼별 특성을 이해하고 활용하는 것이 크로스 플랫폼 개발에서도 중요하다는 점을 보여주는 사례라 할 수 있습니다.