🟣 Flutter가 직접 못 하는 일
앞 장까지는 Flutter 안에서 화면과 상태를 다뤘다. 그런데 학습 프로젝트로 라즈베리파이와 블루투스로 통신하는 앱을 만들려니 벽에 부딪혔다. 블루투스 저전력 통신은 운영체제의 하드웨어 API에 직접 붙어야 하는 일이다. iOS라면 CoreBluetooth, 안드로이드라면 BluetoothAdapter를 써야 한다. Dart로 짠 Flutter 코드는 이 네이티브 API를 직접 부를 방법이 없다.
여기서 Flutter가 크로스플랫폼으로서 어디까지 책임지는지가 드러난다. Flutter는 자체 렌더링 엔진으로 화면을 그리기 때문에 UI는 플랫폼과 무관하게 똑같이 나온다. 하지만 카메라, 센서, 블루투스처럼 OS 고유 기능은 Flutter 엔진 바깥에 있다. 이 경계를 넘어 네이티브 코드와 값을 주고받는 다리가 플랫폼 채널이다.
🟣 MethodChannel: 이름 붙은 통로로 함수를 부른다
플랫폼 채널의 기본형은 MethodChannel이다. Dart 쪽과 네이티브 쪽이 같은 문자열 이름의 채널을 열어 두고, Dart가 그 채널로 메서드 이름과 인자를 보내면 네이티브가 그 요청을 받아 실제 OS API를 호출한다. 이름으로 상대를 찾아 통신한다는 점이, 시스템 프로그래밍에서 이름 있는 파이프나 POSIX 큐가 경로 문자열로 서로를 찾던 방식과 겹쳤다.
const channel = MethodChannel("app/ble");
Future<void> startScan() async {
await channel.invokeMethod("startScan");
}네이티브 쪽에서는 같은 이름의 채널을 열고 들어온 요청을 분기한다. iOS라면 Swift에서 FlutterMethodChannel을 만들고, startScan이 들어오면 CoreBluetooth의 스캔을 시작한다.
let channel = FlutterMethodChannel(name: "app/ble", binaryMessenger: registrar.messenger())
channel.setMethodCallHandler { call, result in
if call.method == "startScan" {
centralManager.scanForPeripherals(withServices: nil)
result(nil)
}
}여기서 오가는 값은 그냥 넘어가지 않고 정해진 규칙으로 인코딩된다. Dart의 맵과 리스트, 문자열, 숫자가 표준 이진 형식으로 바뀌어 채널을 건너고, 반대편에서 네이티브 타입으로 풀린다. 메시지 큐에서 프레이밍이 필요했던 것처럼, 서로 다른 두 세계가 통신하려면 이렇게 값을 직렬화하는 약속이 반드시 필요했다.
🟣 EventChannel: 계속 들어오는 값을 흘려보낸다
MethodChannel은 한 번 부르고 한 번 받는 요청에 맞는다. 그런데 블루투스 스캔은 그렇지 않다. 기기가 발견될 때마다, 연결 상태가 바뀔 때마다 이벤트가 계속 올라온다. 이런 연속적인 흐름에는 EventChannel을 쓴다. 네이티브에서 일어나는 이벤트를 Dart의 Stream으로 흘려보내는 채널이다.
1장에서 봤던 Stream이 여기서 제 역할을 한다. 네이티브가 스캔 결과를 이벤트로 방출하면 Dart 쪽에서는 그것이 하나의 스트림이 되고, 위젯은 그 스트림을 구독해 새 기기가 잡힐 때마다 목록을 갱신한다. Combine에서 블루투스 이벤트를 퍼블리셔로 받아 뷰를 갱신하던 구조와 사실상 같았다.
const scanEvents = EventChannel("app/ble/scan");
Stream<String> get deviceStream =>
scanEvents.receiveBroadcastStream().map((e) => e.toString());위젯 쪽에서는 StreamBuilder로 이 스트림을 받아 자동으로 화면을 다시 그린다. 스트림에 새 값이 실릴 때마다 StreamBuilder가 build를 다시 부르니, 3장에서 본 상태 구독 재빌드가 네이티브 이벤트에도 그대로 적용되는 셈이다. 선언형 UI의 원칙이 데이터 출처가 네이티브로 바뀌어도 흔들리지 않는다는 게 이 지점에서 확인됐다.
🟣 플러그인은 이 채널을 감싼 것이다
실제 프로젝트에서는 블루투스 채널을 직접 짜기보다 flutter_blue 같은 플러그인을 썼다. 처음엔 플러그인이 마법처럼 보였는데, 안을 열어 보니 결국 방금 본 플랫폼 채널을 잘 정리해 감싼 것이었다. iOS 쪽 CoreBluetooth 호출과 안드로이드 쪽 API 호출을 각각 네이티브로 구현해 두고, 그 위에 MethodChannel과 EventChannel을 얹어 Dart 개발자에게는 하나의 깔끔한 API로 노출한다.
이 구조를 이해하고 나니 2장의 메시지 큐 학습에서 얻은 감각이 되살아났다. 커널 메시지 큐 위에 ZeroMQ가, 공유 메모리 위에 Redis가 얹혔던 것처럼, 여기서도 저수준 플랫폼 채널 위에 플러그인이라는 추상화 층이 얹혀 있었다. 아래층이 무엇을 보장하는지 알고 나면 위층 라이브러리가 무엇을 대신해 주는지가 선명해진다는 원리는 IPC든 Flutter 플러그인이든 똑같았다.
플랫폼 채널에는 대가도 있었다. 네이티브와 Dart를 오갈 때마다 값을 직렬화하고 역직렬화하니, 큰 데이터를 초당 수백 번 주고받으면 이 비용이 눈에 띈다. 매 프레임 대량의 픽셀을 넘겨야 하는 경우라면 채널은 병목이 됐다. 그래서 블루투스처럼 이벤트 빈도가 관리되는 통신에는 잘 맞았지만, 고빈도 대용량 전송에는 다른 방법을 고민해야 했다. 크로스플랫폼이 공짜가 아니라 경계를 넘을 때마다 값을 옮기는 비용을 치른다는 걸, 이 프로젝트로 몸으로 알았다.
🟣 정리
- Flutter는 UI를 자체 엔진으로 그려 플랫폼과 무관하게 통일하지만, 블루투스 같은 OS 고유 기능은 엔진 바깥이라 플랫폼 채널로 네이티브와 이어야 한다.
MethodChannel은 이름 붙은 통로로 Dart가 네이티브 함수를 부르는, 한 번 요청하고 한 번 받는 통신이다.EventChannel은 네이티브 이벤트를 DartStream으로 흘려보낸다. 블루투스 스캔처럼 값이 계속 올라오는 흐름에 맞고,StreamBuilder로 화면이 자동 갱신된다.- 채널을 건너는 값은 표준 형식으로 직렬화된다. 경계를 넘을 때마다 이 변환 비용이 들어서 고빈도 대용량 전송에는 불리하다.
flutter_blue같은 플러그인은 이 플랫폼 채널을 감싼 추상화 층이다. 아래층을 알면 위층이 무엇을 대신하는지 보인다.
이 장은 이력서 관점에서 특히 힘을 실은 부분이었다. 크로스플랫폼 프레임워크의 경계가 어디인지 알고, 그 너머의 네이티브 코드를 직접 이어 블루투스 기기와 통신하는 실제 문제를 풀어 본 경험은, 하이브리드 앱에서 네이티브와 웹뷰를 잇는 브릿지 설계 경험으로 그대로 번역된다. 면접에서 "플랫폼 채널로 대량 데이터를 넘기면 왜 느린가"를 받아도, 직렬화 비용이라는 근거로 답할 수 있는 깊이를 이 프로젝트에서 얻었다.
Flutter를 iOS 개발자의 눈으로 다시 정리한 이 책은 여기서 끝난다. 선언형 UI, 위젯 트리, 상태 구독, 네이티브 경계까지 네 지점 모두에서 Swift 생태계와 겹치는 원리를 확인했다. 새 프레임워크를 배운다는 것은 결국 이미 아는 개념이 다른 이름으로 어떻게 다시 나타나는지를 찾는 일이라는 걸, 이 학습으로 정리할 수 있었다.