이 글은 누구를 위한 것인가
- Flutter 앱에서 네이티브 SDK(NFC, 블루투스, 카메라)를 연동해야 하는 팀
- Platform Channel의 MethodChannel과 EventChannel 차이를 모르는 개발자
- Pigeon으로 타입 안전한 채널 코드를 생성하고 싶은 엔지니어
들어가며
Flutter 플러그인이 없는 네이티브 기능은 Platform Channel로 직접 구현해야 한다. 한 번 패턴을 이해하면 iOS Swift + Android Kotlin + Flutter Dart를 연결하는 것이 어렵지 않다.
이 글은 bluefoxdev.kr의 Flutter 네이티브 연동 가이드 를 참고하여 작성했습니다.
1. Platform Channel 유형
[채널 유형 비교]
MethodChannel:
단방향 호출: Flutter → Native → Flutter
use case: 일회성 요청 (파일 읽기, 권한 확인)
패턴: invokeMethod("methodName", args)
EventChannel:
스트리밍: Native → Flutter (지속)
use case: 센서 데이터, 위치, BLE 이벤트
패턴: receiveBroadcastStream().listen()
BasicMessageChannel:
양방향 메시지: Flutter ↔ Native
use case: 커스텀 직렬화, 대용량 데이터
패턴: send() + setMessageHandler()
[Pigeon (권장)]
타입 안전한 코드 자동 생성
컴파일 타임 오류 감지
Dart + Swift + Kotlin 코드 동시 생성
null 안전성 보장
[채널 이름 규칙]
"com.company.app/feature"
예: "com.myapp.bluetooth"
충돌 방지를 위해 도메인 역순
2. Platform Channel 구현
// Flutter 측 (Dart)
import 'package:flutter/services.dart';
class NfcChannel {
static const _channel = MethodChannel('com.myapp/nfc');
static const _eventChannel = EventChannel('com.myapp/nfc_events');
// NFC 읽기 요청
Future<String?> readNfcTag() async {
try {
final result = await _channel.invokeMethod<String>('readTag');
return result;
} on PlatformException catch (e) {
throw NfcException(e.code, e.message);
}
}
// NFC 쓰기
Future<void> writeNfcTag({required String data}) async {
await _channel.invokeMethod('writeTag', {'data': data});
}
// NFC 이벤트 스트림 (EventChannel)
Stream<NfcEvent> get nfcEventStream {
return _eventChannel
.receiveBroadcastStream()
.map((event) => NfcEvent.fromMap(event as Map));
}
}
class NfcEvent {
final String type;
final String? tagId;
final String? data;
NfcEvent({required this.type, this.tagId, this.data});
factory NfcEvent.fromMap(Map map) => NfcEvent(
type: map['type'] as String,
tagId: map['tagId'] as String?,
data: map['data'] as String?,
);
}
class NfcException implements Exception {
final String code;
final String? message;
NfcException(this.code, this.message);
}
// iOS 측 (Swift) - AppDelegate.swift 또는 별도 플러그인
import Flutter
import CoreNFC
class NfcPlugin: NSObject, FlutterPlugin, NFCNDEFReaderSessionDelegate {
private var channel: FlutterMethodChannel!
private var eventSink: FlutterEventSink?
private var readerSession: NFCNDEFReaderSession?
private var pendingResult: FlutterResult?
static func register(with registrar: FlutterPluginRegistrar) {
let instance = NfcPlugin()
let methodChannel = FlutterMethodChannel(
name: "com.myapp/nfc",
binaryMessenger: registrar.messenger(),
)
registrar.addMethodCallDelegate(instance, channel: methodChannel)
instance.channel = methodChannel
let eventChannel = FlutterEventChannel(
name: "com.myapp/nfc_events",
binaryMessenger: registrar.messenger(),
)
eventChannel.setStreamHandler(instance)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "readTag":
pendingResult = result
startNFCSession()
case "writeTag":
guard let args = call.arguments as? [String: Any],
let data = args["data"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "데이터 누락", details: nil))
return
}
writeNFCTag(data: data, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func startNFCSession() {
readerSession = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
readerSession?.begin()
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
guard let record = messages.first?.records.first,
let text = String(data: record.payload, encoding: .utf8) else {
pendingResult?(FlutterError(code: "READ_FAILED", message: "태그 읽기 실패", details: nil))
return
}
pendingResult?(text)
pendingResult = nil
// 이벤트 스트림으로도 알림
eventSink?(["type": "tagRead", "data": text])
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
pendingResult?(FlutterError(code: "NFC_ERROR", message: error.localizedDescription, details: nil))
pendingResult = nil
}
private func writeNFCTag(data: String, result: @escaping FlutterResult) {
// 쓰기 구현...
result(nil)
}
}
extension NfcPlugin: FlutterStreamHandler {
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
}
// Android 측 (Kotlin)
class NfcPlugin(private val activity: Activity) : MethodCallHandler, EventChannel.StreamHandler {
private var eventSink: EventChannel.EventSink? = null
companion object {
fun registerWith(registrar: PluginRegistry.Registrar) {
val instance = NfcPlugin(registrar.activity())
MethodChannel(registrar.messenger(), "com.myapp/nfc")
.setMethodCallHandler(instance)
EventChannel(registrar.messenger(), "com.myapp/nfc_events")
.setStreamHandler(instance)
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"readTag" -> readNfcTag(result)
"writeTag" -> {
val data = call.argument<String>("data")
?: return result.error("INVALID_ARGS", "데이터 누락", null)
writeNfcTag(data, result)
}
else -> result.notImplemented()
}
}
private fun readNfcTag(result: MethodChannel.Result) {
// NFC 읽기 구현...
result.success("tag_data")
}
private fun writeNfcTag(data: String, result: MethodChannel.Result) {
// NFC 쓰기 구현...
result.success(null)
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
}
마무리
Platform Channel은 Flutter가 네이티브의 모든 기능에 접근하는 탈출구다. MethodChannel은 일회성 호출, EventChannel은 지속 스트림에 사용한다. 팀 규모가 크거나 채널이 많아지면 Pigeon으로 타입 안전한 코드를 자동 생성하면 런타임 에러를 컴파일 타임에 잡을 수 있다.