MethodChannel로 Flutter와 네이티브 코드 연결하기

MethodChannel로 Flutter와 네이티브 코드 연결하기

한눈에 보기

MethodChannel은 Dart에서 Swift나 Kotlin 함수를 직접 호출하는 문법이 아니다. 서로 다른 런타임이 채널 이름, 메서드 이름, 직렬화 가능한 데이터, 오류 코드로 통신하는 비동기 프로토콜이다. 따라서 호출부 한 줄보다 계약의 버전, 입력 검증, 오류 변환, 실행 스레드, 테스트 가능한 경계를 먼저 설계해야 한다.

목차

네이티브 함수 호출처럼 보여서 생기는 착각

Flutter 애플리케이션을 만들다 보면 Dart만으로 해결할 수 없는 기능을 만난다. WidgetKit에 보여 줄 스냅샷을 저장하거나, 플랫폼 SDK가 제공하는 센서 값을 읽거나, 운영체제의 공유 화면을 여는 일이 그렇다. 이때 가장 빨리 만들 수 있는 코드는 대개 다음과 비슷하다.

const channel = MethodChannel('app.example/native');

Future<void> saveSnapshot(String profileId, int itemCount) async {
  await channel.invokeMethod('saveSnapshot', {
    'profileId': profileId,
    'itemCount': itemCount,
  });
}

호출부만 보면 saveSnapshot()이라는 네이티브 함수를 실행한 것처럼 보인다. 하지만 컴파일러가 확인할 수 있는 계약은 거의 없다.

즉, 이 코드는 함수 호출처럼 생겼지만 실제 성격은 작은 원격 API에 가깝다. 서버 API라면 요청 스키마와 오류 응답, 버전 호환성을 고민하면서 플랫폼 채널에서는 Map<String, dynamic> 하나로 끝내기 쉽다. 런타임이 다르다는 점을 생각하면 오히려 반대로 접근해야 한다.

이 글의 관점

플랫폼 채널을 프로세스 내부의 API 경계로 본다. 네트워크를 타지는 않더라도 직렬화, 버전 불일치, 부분 실패, 비동기 응답이라는 API 설계 문제가 그대로 존재한다.

이 글의 코드는 특정 프로젝트에서 가져온 것이 아니다. 홈 화면 위젯용 요약 데이터를 저장한다는 가상의 요구사항을 기준으로 재구성한 예제다.

MethodChannel을 통과할 때 실제로 일어나는 일

Flutter 공식 API에서 MethodChannel은 이름을 가진 비동기 메서드 호출 채널이다. 인자와 결과는 MethodCodec으로 바이너리 메시지로 인코딩되고, 플랫폼 쪽의 같은 이름을 가진 채널이 이를 디코딩한다. Dart 타입이 dynamic이라고 해서 모든 객체를 보낼 수 있는 것은 아니다. 선택한 codec이 지원하는 값만 경계를 통과할 수 있다.

sequenceDiagram
    participant UI as Flutter UI
    participant Facade as SnapshotBridge
    participant MC as MethodChannel
    participant Host as iOS or Android handler
    participant Store as Platform storage

    UI->>Facade: save(request)
    Facade->>Facade: Dart validation
    Facade->>MC: invokeMethod(name, payload)
    MC->>Host: codec으로 직렬화된 메시지
    Host->>Host: method와 argument 검증
    Host->>Store: 비동기 저장
    Store-->>Host: 성공 또는 실패
    Host-->>MC: value 또는 platform error
    MC-->>Facade: dynamic 또는 PlatformException
    Facade-->>UI: 도메인 결과 또는 도메인 오류

여기서 눈여겨볼 부분은 UI와 MethodChannel 사이에 SnapshotBridge라는 경계를 둔 것이다. 화면과 상태 관리 코드가 invokeMethod, 채널 문자열, PlatformException을 직접 알게 만들지 않는다. 그래야 플랫폼 구현을 바꾸거나 테스트 대역을 넣을 때 영향 범위가 줄어든다.

기본 StandardMessageCodecnull, bool, 숫자, 문자열, 바이트 배열, 지원되는 값으로 구성된 리스트와 맵 등을 전달할 수 있다. 반면 다음과 같은 Dart 객체를 그대로 보낼 수는 없다.

final request = SaveSnapshotRequest(
  profileId: 'profile-demo',
  itemCount: 3,
  savedAt: DateTime.now(),
);

// 잘못된 접근: 임의의 Dart 객체를 codec이 알아서 변환해 주지 않는다.
await channel.invokeMethod('saveSnapshot', request);

DateTime, enum, 도메인 객체를 보낼 때는 양쪽이 합의한 원시 값으로 바꿔야 한다.

Map<String, Object?> encodeRequest(SaveSnapshotRequest request) {
  return {
    'schemaVersion': 1,
    'profileId': request.profileId,
    'itemCount': request.itemCount,
    'savedAtEpochMs': request.savedAt.millisecondsSinceEpoch,
  };
}

이 변환은 귀찮은 보일러플레이트가 아니라 경계의 계약을 눈에 보이게 만드는 코드다.

어떤 채널을 선택해야 할까

플랫폼 통신이라는 이유만으로 모든 기능을 MethodChannel에 넣을 필요는 없다. 메시지의 방향과 빈도, 데이터 크기에 따라 적합한 통로가 다르다.

수단 적합한 상황 호출 형태 주의할 점
MethodChannel 저장, 조회, 화면 열기처럼 요청과 결과가 한 쌍인 작업 request-response 문자열 메서드와 dynamic 계약을 직접 관리
EventChannel 센서, 연결 상태처럼 네이티브가 연속 값을 전달 stream 구독 시작·종료와 리소스 해제를 구현
BasicMessageChannel 양방향 메시지 교환이 중심인 프로토콜 message-reply 메서드라는 의미 구조는 직접 정의
Pigeon 채널 수와 DTO가 늘어 타입 안전성이 중요한 경우 생성된 typed API 양쪽 코드를 같은 Pigeon 버전으로 생성
FFI C ABI 라이브러리 호출이나 고빈도 계산 native function 메모리·스레드·ABI 관리가 필요
공유 파일·플랫폼 저장소 이미지나 큰 스냅샷을 다른 extension과 공유 path 또는 key 전달 파일 수명, 원자적 교체, 접근 권한 관리

예를 들어 가속도 값이 초당 수십 번 들어오는데 매번 invokeMethod('nextValue')로 polling하는 설계는 메시지 모델부터 어긋난다. 이 경우에는 구독 수명주기를 가진 EventChannel이 자연스럽다. 반대로 “현재 설정을 저장하고 성공 여부를 돌려받는다”는 작업은 MethodChannel과 잘 맞는다.

선택 질문

“Dart가 한 번 요청하고 결과 하나를 기다리는가?”에 예라고 답할 수 있으면 MethodChannel 후보이다. 연속 데이터이거나 payload 자체가 크다면 다른 경계를 먼저 검토한다.

문자열과 dynamic을 경계 밖으로 노출하지 않기

먼저 채널 안에서 사용하는 이름과 payload를 한곳에 모은다. 채널 이름에는 충돌하기 어려운 namespace와 계약 버전을 포함한다.

final class SnapshotChannelContract {
  SnapshotChannelContract._();

  static const channelName = 'dev.example.snapshot/v1';
  static const saveMethod = 'saveSnapshot';
  static const readMethod = 'readSnapshot';

  static const schemaVersion = 1;
}

Flutter 문서상 채널의 논리적 식별자는 이름이며, 같은 이름의 채널은 서로 간섭할 수 있다. native, common, bridge 같은 이름은 앱이 커지거나 플러그인을 붙였을 때 충돌 원인을 찾기 어렵게 만든다.

채널 이름 끝의 /v1과 payload의 schemaVersion은 서로 다른 문제를 해결한다.

작은 필드를 추가할 때마다 채널을 /v2로 바꿀 필요는 없다. 새 필드를 optional로 읽을 수 있다면 같은 계약을 확장할 수 있다. 반대로 필드 의미가 달라지거나 응답 형태가 깨진다면 새 버전이 안전하다.

요청과 결과도 Map 대신 도메인 타입으로 표현한다.

final class SaveSnapshotRequest {
  const SaveSnapshotRequest({
    required this.profileId,
    required this.itemCount,
    required this.savedAt,
  });

  final String profileId;
  final int itemCount;
  final DateTime savedAt;

  Map<String, Object?> toMessage() => {
        'schemaVersion': SnapshotChannelContract.schemaVersion,
        'profileId': profileId,
        'itemCount': itemCount,
        'savedAtEpochMs': savedAt.millisecondsSinceEpoch,
      };
}

final class SaveSnapshotResult {
  const SaveSnapshotResult({
    required this.revision,
    required this.savedAt,
  });

  final int revision;
  final DateTime savedAt;

  factory SaveSnapshotResult.fromMessage(Object? raw) {
    if (raw is! Map) {
      throw const SnapshotProtocolException('response_not_map');
    }

    final revision = raw['revision'];
    final savedAtEpochMs = raw['savedAtEpochMs'];

    if (revision is! int || savedAtEpochMs is! int) {
      throw const SnapshotProtocolException('invalid_response_fields');
    }

    return SaveSnapshotResult(
      revision: revision,
      savedAt: DateTime.fromMillisecondsSinceEpoch(savedAtEpochMs),
    );
  }
}

invokeMapMethod<String, Object?>()을 쓰면 충분해 보일 수 있지만, 플랫폼에서 넘어온 중첩 타입까지 컴파일러가 보장해 주는 것은 아니다. 경계에서 실제 값을 검사하고, 잘못된 응답을 “예상하지 못한 null”이 아니라 프로토콜 오류로 분류하는 편이 디버깅하기 쉽다.

Dart에 타입이 있는 어댑터 만들기

화면이나 상태 관리 객체는 플랫폼 통신 방법보다 기능의 의미에 의존해야 한다.

abstract interface class SnapshotBridge {
  Future<SaveSnapshotResult> save(SaveSnapshotRequest request);
}

final class MethodChannelSnapshotBridge implements SnapshotBridge {
  MethodChannelSnapshotBridge({
    MethodChannel? channel,
    this.timeout = const Duration(seconds: 3),
  }) : _channel = channel ??
            const MethodChannel(SnapshotChannelContract.channelName);

  final MethodChannel _channel;
  final Duration timeout;

  @override
  Future<SaveSnapshotResult> save(
    SaveSnapshotRequest request,
  ) async {
    _validate(request);

    try {
      final raw = await _channel
          .invokeMethod<Object?>(
            SnapshotChannelContract.saveMethod,
            request.toMessage(),
          )
          .timeout(timeout);

      return SaveSnapshotResult.fromMessage(raw);
    } on PlatformException catch (error) {
      throw SnapshotFailure.fromPlatform(error);
    } on MissingPluginException {
      throw const SnapshotFailure.notAvailable();
    } on TimeoutException {
      throw const SnapshotFailure.timeout();
    }
  }

  void _validate(SaveSnapshotRequest request) {
    if (request.profileId.trim().isEmpty) {
      throw const SnapshotFailure.invalidArgument('empty_profile_id');
    }
    if (request.itemCount < 0 || request.itemCount > 9999) {
      throw const SnapshotFailure.invalidArgument('invalid_item_count');
    }
  }
}

여기서 중요한 것은 코드 줄 수가 아니라 책임 분리다.

  1. UI는 SnapshotBridge.save()만 호출한다.
  2. Dart에서 잡을 수 있는 잘못된 값은 채널에 보내기 전에 거절한다.
  3. 플랫폼 오류는 앱에서 이해할 수 있는 실패 타입으로 바꾼다.
  4. 응답은 경계에서 검증한다.
  5. 무한히 기다리지 않도록 앱 수준의 timeout을 둔다.

timeout 값은 예제의 3초를 그대로 복사할 설정이 아니다. 작업 성격과 UX를 기준으로 정해야 한다. 로컬 설정 저장과 사진 변환의 정상 소요 시간이 같을 리 없다.

iOS에서 입력을 검증하고 한 번만 응답하기

iOS에서는 같은 채널 이름으로 FlutterMethodChannel을 만들고 handler를 등록한다. 네이티브도 Dart의 입력을 신뢰하지 않고 타입, 범위, 버전을 검사한다.

import Flutter

final class SnapshotChannelHandler {
    private let channel: FlutterMethodChannel
    private let store: SnapshotStore

    init(
        messenger: FlutterBinaryMessenger,
        store: SnapshotStore
    ) {
        self.channel = FlutterMethodChannel(
            name: "dev.example.snapshot/v1",
            binaryMessenger: messenger
        )
        self.store = store
    }

    func start() {
        channel.setMethodCallHandler { [weak self] call, result in
            guard let self else {
                result(
                    FlutterError(
                        code: "not_available",
                        message: "Snapshot handler is unavailable.",
                        details: nil
                    )
                )
                return
            }

            switch call.method {
            case "saveSnapshot":
                self.handleSave(call: call, result: result)
            default:
                result(FlutterMethodNotImplemented)
            }
        }
    }

    func stop() {
        channel.setMethodCallHandler(nil)
    }
}

지원하지 않는 메서드에는 성공 nil을 반환하지 말고 FlutterMethodNotImplemented를 반환한다. 그래야 Dart가 “값이 없는 성공”과 “해당 구현이 없음”을 구분할 수 있다.

argument 파싱도 별도 타입으로 격리한다.

private struct SaveSnapshotCommand {
    let profileId: String
    let itemCount: Int
    let savedAt: Date

    init(arguments: Any?) throws {
        guard
            let map = arguments as? [String: Any],
            let version = map["schemaVersion"] as? Int,
            version == 1,
            let profileId = map["profileId"] as? String,
            !profileId.trimmingCharacters(in: .whitespaces).isEmpty,
            let itemCount = map["itemCount"] as? Int,
            (0...9999).contains(itemCount),
            let epochMs = map["savedAtEpochMs"] as? Int64
        else {
            throw SnapshotChannelError.invalidArguments
        }

        self.profileId = profileId
        self.itemCount = itemCount
        self.savedAt = Date(
            timeIntervalSince1970: TimeInterval(epochMs) / 1000
        )
    }
}

저장이 I/O를 포함한다면 handler 안에서 메인 스레드를 막지 않는다.

private func handleSave(
    call: FlutterMethodCall,
    result: @escaping FlutterResult
) {
    let command: SaveSnapshotCommand

    do {
        command = try SaveSnapshotCommand(arguments: call.arguments)
    } catch {
        result(
            FlutterError(
                code: "invalid_argument",
                message: "Snapshot payload is invalid.",
                details: ["reason": "payload_validation_failed"]
            )
        )
        return
    }

    Task {
        do {
            let receipt = try await store.save(
                profileId: command.profileId,
                itemCount: command.itemCount,
                savedAt: command.savedAt
            )

            result([
                "revision": receipt.revision,
                "savedAtEpochMs": Int64(
                    receipt.savedAt.timeIntervalSince1970 * 1000
                )
            ])
        } catch SnapshotStoreError.containerUnavailable {
            result(
                FlutterError(
                    code: "not_available",
                    message: "Shared container is unavailable.",
                    details: nil
                )
            )
        } catch {
            result(
                FlutterError(
                    code: "io_failure",
                    message: "Could not save the snapshot.",
                    details: nil
                )
            )
        }
    }
}

각 분기에서 result를 정확히 한 번 호출하는지 확인해야 한다. 성공 후 다시 오류를 반환하거나, 특정 guard 분기에서 아무것도 반환하지 않으면 Dart의 Future가 이상하게 종료되거나 계속 대기한다. callback 형태의 코드는 return 위치까지 함께 검토해야 한다.

UI API와 실행 스레드

무거운 파일 처리나 계산은 UI 스레드를 점유하지 않게 해야 한다. 반대로 화면 표시처럼 플랫폼 UI API가 요구하는 작업은 메인 스레드에서 실행해야 한다. “모든 네이티브 작업을 background로 보낸다”가 아니라, 작업을 분해해 각 API의 스레드 요구사항을 지키는 것이 핵심이다.

Android 구현도 같은 계약을 따라야 한다

한쪽 플랫폼이 같은 기능을 지원하지 않더라도 계약의 의미는 같아야 한다. Android에서 itemCount를 문자열로 받고 iOS에서 숫자로 받는 식의 차이를 허용하면 Dart 쪽 분기만 늘어난다.

private fun handleSave(
    call: MethodCall,
    result: MethodChannel.Result,
) {
    val version = call.argument<Int>("schemaVersion")
    val profileId = call.argument<String>("profileId")
    val itemCount = call.argument<Int>("itemCount")
    val savedAtEpochMs = call.argument<Long>("savedAtEpochMs")

    if (
        version != 1 ||
        profileId.isNullOrBlank() ||
        itemCount == null ||
        itemCount !in 0..9999 ||
        savedAtEpochMs == null
    ) {
        result.error(
            "invalid_argument",
            "Snapshot payload is invalid.",
            mapOf("reason" to "payload_validation_failed"),
        )
        return
    }

    coroutineScope.launch {
        runCatching {
            snapshotStore.save(
                profileId = profileId,
                itemCount = itemCount,
                savedAtEpochMs = savedAtEpochMs,
            )
        }.onSuccess { receipt ->
            result.success(
                mapOf(
                    "revision" to receipt.revision,
                    "savedAtEpochMs" to receipt.savedAtEpochMs,
                ),
            )
        }.onFailure {
            result.error(
                "io_failure",
                "Could not save the snapshot.",
                null,
            )
        }
    }
}

실제 Android 구현에서는 사용하는 coroutine scope가 엔진이나 plugin의 수명보다 오래 살아남지 않게 취소해야 한다. Activity에 붙는 기능이라면 Activity 재생성도 고려해야 한다. 위 코드는 계약의 모양을 보여 주기 위한 예시이며, scope 소유권까지 생략해도 된다는 뜻은 아니다.

오류를 도메인 언어로 번역하기

플랫폼 경계의 오류 코드는 양쪽이 공유하는 작은 vocabulary여야 한다. Swift의 NSError 코드나 Java exception class 이름을 그대로 Dart에 노출하면 플랫폼별 분기가 앱 전체로 퍼진다.

채널 오류 코드 의미 Dart에서의 처리 예
invalid_argument 계약에 맞지 않는 요청 개발 오류 기록, 사용자 재시도 금지
not_available 기능이나 공유 컨테이너 사용 불가 기능 숨김 또는 설정 안내
permission_denied 운영체제 권한 거절 권한 안내 UI
io_failure 읽기·쓰기 실패 제한된 재시도와 오류 안내
unsupported_version schema 호환 불가 앱 업데이트 또는 구현 오류 처리
cancelled 사용자가 작업을 취소 조용히 종료

Dart에서는 이를 sealed class나 enum이 포함된 예외로 바꿀 수 있다.

enum SnapshotFailureKind {
  invalidArgument,
  notAvailable,
  permissionDenied,
  ioFailure,
  unsupportedVersion,
  timeout,
  unexpected,
}

final class SnapshotFailure implements Exception {
  const SnapshotFailure(this.kind, {this.reason});

  const SnapshotFailure.invalidArgument(String reason)
      : this(SnapshotFailureKind.invalidArgument, reason: reason);

  const SnapshotFailure.notAvailable()
      : this(SnapshotFailureKind.notAvailable);

  const SnapshotFailure.timeout()
      : this(SnapshotFailureKind.timeout);

  final SnapshotFailureKind kind;
  final String? reason;

  factory SnapshotFailure.fromPlatform(PlatformException error) {
    return switch (error.code) {
      'invalid_argument' => SnapshotFailure(
          SnapshotFailureKind.invalidArgument,
          reason: _safeReason(error.details),
        ),
      'not_available' =>
        const SnapshotFailure(SnapshotFailureKind.notAvailable),
      'permission_denied' =>
        const SnapshotFailure(SnapshotFailureKind.permissionDenied),
      'io_failure' =>
        const SnapshotFailure(SnapshotFailureKind.ioFailure),
      'unsupported_version' =>
        const SnapshotFailure(SnapshotFailureKind.unsupportedVersion),
      _ => const SnapshotFailure(SnapshotFailureKind.unexpected),
    };
  }
}

사용자 메시지는 error.message을 그대로 보여 주지 않는다. 네이티브 메시지는 개발자를 위한 설명일 수 있고, 언어가 현지화되지 않았거나 내부 구현 정보를 포함할 수 있다. 앱은 SnapshotFailureKind를 기준으로 사용자용 문구를 고른다.

details에도 파일 절대 경로, 계정 식별자, 원본 stack trace 같은 정보를 무심코 담지 않는다. 경계 오류는 로그와 분석 도구로 전파되기 쉬운 데이터다.

FIFO가 동시성 문제를 해결해 주지는 않는다

Flutter가 제공하는 기본 MethodChannel은 보낸 호출이 플랫폼에 도착하는 순서에 대해 FIFO를 보장한다. 그러나 이 사실을 “먼저 호출한 저장이 먼저 끝난다”로 확대 해석하면 안 된다.

final first = bridge.save(
  SaveSnapshotRequest(
    profileId: 'profile-demo',
    itemCount: 1,
    savedAt: DateTime.now(),
  ),
);

final second = bridge.save(
  SaveSnapshotRequest(
    profileId: 'profile-demo',
    itemCount: 2,
    savedAt: DateTime.now(),
  ),
);

await Future.wait([first, second]);

플랫폼 handler가 두 요청을 받은 순서는 유지되더라도 내부에서 각각 비동기 작업을 시작하면 두 번째 저장이 먼저 끝날 수 있다. 마지막 상태가 중요한 저장이라면 다음 중 하나가 필요하다.

actor SnapshotStore {
    private var latestRevision = 0

    func save(command: VersionedSnapshot) throws -> SaveReceipt {
        guard command.revision > latestRevision else {
            throw SnapshotStoreError.staleRevision
        }

        try replaceFileAtomically(with: command)
        latestRevision = command.revision

        return SaveReceipt(
            revision: command.revision,
            savedAt: Date()
        )
    }
}

FIFO는 메시지 전달의 한 특성일 뿐, 데이터의 원자성이나 race condition을 해결하는 저장 전략이 아니다.

큰 데이터와 이벤트 스트림은 다른 길로 보내기

채널이 바이트 배열을 지원한다고 해서 큰 이미지나 긴 JSON을 반복해서 보내는 것이 좋은 설계는 아니다. Dart 객체는 codec을 거쳐 플랫폼 객체로 복사되고, 반대 방향에서도 같은 비용이 든다. 빈도와 크기가 커지면 serialization과 메모리 peak가 눈에 띄기 시작한다.

예를 들어 위젯용 썸네일을 전달한다면 이미지 전체를 MethodChannel payload로 보내기보다 공유 컨테이너에 파일을 안전하게 기록하고, 채널에는 작은 메타데이터만 전달할 수 있다.

await channel.invokeMethod<void>('commitSnapshot', {
  'schemaVersion': 1,
  'relativeImagePath': 'snapshots/profile-demo-42.webp',
  'revision': 42,
  'checksum': 'sha256-demo-value',
});

이 설계에서도 확인할 것이 있다.

연속 이벤트라면 EventChannel로 바꾸되, 구독 종료 시 네이티브 센서나 listener를 반드시 해제한다. 화면이 사라졌는데도 listener가 남아 있으면 배터리와 메모리 문제로 이어진다.

타임아웃과 취소를 별도로 설계하기

invokeMethod()이 반환하는 Future에 timeout을 붙이면 Dart가 기다리는 것을 멈출 수 있다. 하지만 네이티브 작업까지 자동으로 취소되는 것은 아니다.

try {
  await bridge.save(request);
} on SnapshotFailure catch (error) {
  if (error.kind == SnapshotFailureKind.timeout) {
    // Dart는 더 기다리지 않지만 플랫폼 저장은 끝날 수 있다.
  }
}

timeout 직후 같은 요청을 다시 보내면 첫 번째 작업과 두 번째 작업이 모두 성공할 수도 있다. 결제나 파일 추가처럼 중복 실행이 위험한 기능은 requestId를 포함하고 플랫폼에서 중복 요청을 식별해야 한다.

명시적인 취소가 필요하다면 계약에 작업 식별자를 넣고 별도 메서드를 설계할 수 있다.

await channel.invokeMethod<void>('cancelOperation', {
  'schemaVersion': 1,
  'operationId': operationId,
});

다만 취소 메서드가 도착했을 때 이미 작업이 끝났을 수 있다. 따라서 cancelled, alreadyCompleted, notFound 같은 상태 의미를 정의하고, 완료와 취소가 경쟁하는 상황도 테스트해야 한다. 단순히 Future.timeout()을 추가하는 것과 end-to-end cancellation은 다른 기능이다.

앱 수명주기도 같은 맥락이다. 사용자가 앱을 background로 보냈다고 마지막 메시지가 반드시 처리되는 것은 아니다. 꼭 보존해야 하는 데이터는 화면 종료 시 한 번 보내는 방식보다 변경 시점에 지속적으로 저장하는 편이 안전하다. 자세한 수명주기 문제는 앱 생명주기 변화에 안전하게 대응하기에서 이어서 다룬다.

Pigeon을 도입할 기준

직접 작성한 MethodChannel은 작은 기능 하나를 연결할 때 이해하기 쉽다. 하지만 메서드와 DTO가 늘어나면 문자열 상수, 타입 변환, Swift·Kotlin의 중복 선언을 사람이 계속 맞춰야 한다. 이때 Flutter 팀이 제공하는 Pigeon을 검토할 수 있다.

Pigeon은 Dart로 인터페이스를 선언하면 Dart와 host 언어의 통신 코드를 생성한다.

class SnapshotMessage {
  SnapshotMessage({
    required this.schemaVersion,
    required this.profileId,
    required this.itemCount,
    required this.savedAtEpochMs,
  });

  int schemaVersion;
  String profileId;
  int itemCount;
  int savedAtEpochMs;
}

class SnapshotReceipt {
  SnapshotReceipt({
    required this.revision,
    required this.savedAtEpochMs,
  });

  int revision;
  int savedAtEpochMs;
}

@HostApi()
abstract class SnapshotHostApi {
  SnapshotReceipt saveSnapshot(SnapshotMessage message);
}

이 코드는 일반 앱 런타임 코드라기보다 Pigeon 입력 파일의 선언 예시다. 생성 명령과 옵션은 프로젝트에 고정하고 생성 결과를 임의로 수정하지 않는다.

Pigeon의 장점은 명확하다.

그러나 Pigeon이 의미론적 호환성까지 해결해 주지는 않는다. itemCount가 무엇을 뜻하는지, 구버전 앱이 새 enum 값을 받으면 어떻게 할지, 저장이 중복 실행돼도 안전한지는 여전히 설계해야 한다.

공식 패키지 문서는 Pigeon 생성 코드의 내부 통신 형식이 버전 사이에서 바뀔 수 있으므로 양쪽 코드를 같은 Pigeon 버전으로 생성해야 한다고 설명한다. 생성된 Dart와 host 코드를 서로 독립적으로 배포하는 공개 API로 삼기보다 앱이나 plugin 내부 구현으로 유지하는 것이 안전하다.

도입 판단

메서드 두세 개와 단순 값만 오간다면 얇은 facade를 둔 직접 구현도 충분하다. 여러 플랫폼이 같은 DTO를 공유하고 변경 때마다 런타임 오류가 반복된다면 Pigeon의 생성 비용보다 계약 불일치 비용이 커진 시점이다.

테스트는 채널이 아니라 계약을 중심으로 작성하기

Widget test에서 화면이 실제 MethodChannel을 직접 호출하게 만들면 테스트가 플랫폼 환경에 묶인다. UI는 SnapshotBridge의 fake 구현을 주입받게 한다.

final class FakeSnapshotBridge implements SnapshotBridge {
  FakeSnapshotBridge({
    this.result,
    this.failure,
  });

  final SaveSnapshotResult? result;
  final SnapshotFailure? failure;
  final List<SaveSnapshotRequest> requests = [];

  @override
  Future<SaveSnapshotResult> save(
    SaveSnapshotRequest request,
  ) async {
    requests.add(request);

    if (failure case final failure?) {
      throw failure;
    }

    return result ??
        SaveSnapshotResult(
          revision: 1,
          savedAt: DateTime.utc(2025, 1, 1),
        );
  }
}

이 fake로 다음을 빠르게 검증할 수 있다.

test('저장이 성공하면 완료 상태로 전환한다', () async {
  final bridge = FakeSnapshotBridge();
  final controller = SnapshotController(bridge);

  await controller.save(
    profileId: 'profile-demo',
    itemCount: 3,
  );

  expect(controller.state, SnapshotState.saved);
  expect(bridge.requests.single.itemCount, 3);
});

그다음 경계 자체에는 contract test를 둔다.

테스트 계층 확인할 내용
Dart 단위 테스트 request encoding, response parsing, 오류 code 변환
UI·상태 테스트 성공·권한 거절·timeout 때 사용자 상태
Swift/Kotlin 단위 테스트 잘못된 payload 거절, 저장 오류 매핑, result 한 번 호출
통합 테스트 실제 codec을 거쳐 양쪽 필드와 타입이 일치하는지
실제 기기 테스트 권한 화면, extension 공유, background 전환, 성능

contract test에는 정상 사례만 넣지 않는다.

group('SaveSnapshotResult.fromMessage', () {
  test('필수 필드가 있으면 결과를 만든다', () {
    final result = SaveSnapshotResult.fromMessage({
      'revision': 7,
      'savedAtEpochMs': 1735689600000,
    });

    expect(result.revision, 7);
  });

  test('revision 타입이 다르면 protocol error를 낸다', () {
    expect(
      () => SaveSnapshotResult.fromMessage({
        'revision': '7',
        'savedAtEpochMs': 1735689600000,
      }),
      throwsA(isA<SnapshotProtocolException>()),
    );
  });

  test('응답이 null이면 성공으로 오해하지 않는다', () {
    expect(
      () => SaveSnapshotResult.fromMessage(null),
      throwsA(isA<SnapshotProtocolException>()),
    );
  });
});

플랫폼마다 적어도 다음 케이스를 같은 표로 관리하면 한쪽 구현만 계약에서 벗어나는 일을 줄일 수 있다.

운영 환경에서 남길 관측 정보

플랫폼 경계는 실패했을 때 Dart stack trace만으로 원인을 찾기 어려운 구간이다. 그렇다고 payload 전체를 로그로 남기면 개인정보 문제가 생긴다. 구조화된 최소 정보만 남긴다.

platform_call method=saveSnapshot
schema_version=1
platform=ios
duration_ms=38
result=io_failure
payload_bytes=164

유용한 필드는 다음 정도다.

profileId, 파일 경로, 토큰, 원본 payload, 네이티브 stack trace를 분석 이벤트에 그대로 싣지 않는다. 디버그 로그와 운영 telemetry의 정보 수준도 분리한다.

지연 시간은 평균 하나보다 percentile과 timeout 비율을 보는 편이 낫다. 일부 기기에서 공유 컨테이너 I/O가 느려지는 문제는 평균에 묻힐 수 있다.

구현 체크리스트

계약

실패와 동시성

수명주기와 성능

테스트와 보안

마무리

MethodChannel을 연결하는 데 필요한 최소 코드는 몇 줄뿐이다. 어려운 부분은 그 몇 줄 뒤에 숨은 경계를 오래 유지하는 일이다. 문자열 하나가 어긋나도 런타임에서만 실패하고, dynamic payload는 양쪽의 타입 변경을 컴파일러가 함께 추적해 주지 않는다. 비동기 호출에는 timeout, 중복 실행, 수명주기 중단도 따라온다.

그래서 구현 순서는 채널 생성보다 계약 정의가 먼저다.

  1. 작업이 request-response인지 확인한다.
  2. 채널 이름과 schema version을 정한다.
  3. 요청·응답·오류 vocabulary를 문서화한다.
  4. raw 채널을 typed Dart facade 뒤에 숨긴다.
  5. 플랫폼에서 입력을 다시 검증하고 한 번만 응답한다.
  6. 동시성, timeout, 수명주기를 실패 시나리오로 테스트한다.
  7. 계약이 커지면 Pigeon으로 반복 코드를 생성한다.

이렇게 만들어 두면 네이티브 연동은 화면 곳곳에 퍼진 특수 코드가 아니라 교체하고 테스트할 수 있는 인프라가 된다. 다음 글에서는 이 채널을 통해 작은 명령을 전달한 뒤, 실제 위젯 데이터는 App Group 저장소로 공유하는 구조를 다룬다.

관련 노트

참고 자료