Pigeon 생성 모델을 공개 API로 내보내면 안 되는 이유

Flutter 플러그인에서 Pigeon을 쓰면 참 편합니다. Dart와 Kotlin, Swift 사이에 오가는 메시지 타입과 채널 코드를 만들어주니까요.
스키마를 작성하고 생성 명령을 돌리면 DTO도 생깁니다. 이미 타입도 있고 필드도 다 있는데 굳이 모델을 하나 더 만들 필요가 있을까요?
있습니다. 그것도 꽤 강하게요.
생성된 DTO를 그대로 플러그인의 공개 API로 내보내면, 플랫폼 채널의 내부 사정이 사용자 코드의 계약이 됩니다. Pigeon 스키마를 바꾸는 순간 생성 코드만 바뀌는 게 아니라 플러그인을 사용하는 모든 앱의 타입까지 흔들릴 수 있습니다.
warm_alarm의 구조는 이 문제를 공개 모델과 전송 모델의 경계로 풀었습니다.
앱 코드
-> 손으로 작성한 공개 모델
-> 플랫폼 구현의 매핑 계층
-> Pigeon 생성 DTO
-> Kotlin / Swift 네이티브 코드
한 번 더 옮겨 적는 수고가 생깁니다. 대신 코드 생성기가 공개 API의 주인이 되는 일을 막습니다.
(코드 생성기는 일을 잘합니다. 고용 계약서까지 쓰게 하면 곤란합니다.)
생성 코드는 왜 공개 계약이 되면 안 될까요?
Pigeon DTO의 첫 번째 목적은 플랫폼 경계를 안전하게 통과하는 것입니다. 앱 개발자가 오래 사용하기 좋은 도메인 API를 만드는 것이 아닙니다.
두 목적은 비슷해 보이지만 변화 이유가 다릅니다.
- 전송 모델은 채널 직렬화와 플랫폼별 구현에 맞춰 바뀝니다.
- 공개 모델은 플러그인 사용자의 코드가 안정적으로 컴파일되도록 바뀝니다.
예를 들어 Android에만 필요한 필드가 생겼다고 해봅시다. 플랫폼별 Pigeon 스키마가 분리되어 있다면 Android 전송 DTO는 그 필드를 가져야 할 수 있습니다. 그렇다고 앱이 보는 공통 Dart 모델까지 Android 내부 필드에 종속되어야 하는 것은 아닙니다.
반대도 마찬가지입니다. 공개 API에는 의미가 분명한 enum이나 sealed event가 필요하지만, 전송 계층에서는 직렬화하기 쉬운 원시 값 조합이 더 적합할 수 있습니다.
두 타입을 하나로 만들면 어느 한쪽이 다른 쪽의 편의를 계속 떠안게 됩니다.
lib/src/가 그냥 폴더 이름이 아닌 이유
warm_alarm의 원본 지침은 Pigeon 생성 Dart 파일을 플랫폼 패키지의 lib/src/messages.g.dart에 둡니다.
그리고 이 타입들을 다시 export하지 않습니다.
Dart 패키지에서 lib/src/는 내부 구현을 두는 관례적인 위치입니다.
파일이 존재하더라도 패키지의 공개 라이브러리에서 export하지 않으면 사용자가 정상적인 import 경로로 접근하지 않게 만들 수 있습니다.
공개 모델은 별도로 warm_alarm_platform_interface/lib/ 아래에 손으로 작성합니다.
플랫폼 인터페이스는 앱과 각 구현 패키지가 공유하는 장기 계약이 됩니다.
구조를 단순화하면 이렇습니다.
warm_alarm_platform_interface/lib/
public models and abstract platform contract
warm_alarm_android/lib/
warm_alarm_android.dart Android mapping code
src/messages.g.dart generated Pigeon DTOs (not exported)
warm_alarm_ios/lib/
warm_alarm_ios.dart iOS mapping code
src/messages.g.dart generated Pigeon DTOs (not exported)
warm_alarm_macos/lib/
warm_alarm_macos.dart macOS mapping code
src/messages.g.dart generated Pigeon DTOs (not exported)
생성된 파일은 수정하지 않습니다. 스키마를 수정하고 생성 명령을 다시 실행합니다. 이 규칙까지 있어야 경계가 완성됩니다.
생성물을 손으로 고치기 시작하면 다음 생성 때 변경이 사라지고, 사라질까 봐 생성을 피하면 스키마와 구현이 어긋납니다. 둘 다 익숙하게 피곤한 길입니다.
매핑 계층이 하는 일
플랫폼 구현은 공개 모델을 Pigeon DTO로 바꾼 뒤 생성된 API에 전달합니다. 네이티브에서 돌아온 DTO는 다시 공개 모델로 바꿉니다.
아래 코드는 현재 구현의 매핑 패턴을 설명하기 위한 축약 예시이며, 실제 메서드나 타입을 그대로 옮긴 코드는 아닙니다.
// Schematic example: names are illustrative.
Future<PublicScheduleResult> schedule(PublicSchedule schedule) async {
final wireRequest = mapPublicScheduleToWire(schedule);
final wireResult = await generatedApi.schedule(wireRequest);
return mapWireResultToPublic(wireResult);
}
여기서 매핑 함수는 단순 복사가 아닐 수 있습니다.
- 플랫폼별 기본값을 적용합니다.
- 전송용 enum과 공개 enum을 변환합니다.
- 네이티브 오류 코드를 공개 실패 모델로 바꿉니다.
- 플랫폼에서만 필요한 필드를 내부에 가둡니다.
- 공개 모델의 불변식과 null 정책을 유지합니다.
이 계층이 귀찮아 보인다면 정상입니다. 경계 코드는 원래 귀찮습니다. 그 귀찮음이 다른 패키지로 퍼질 변경 비용을 한곳에 모아둔 것이니까요.
Federated plugin에서는 경계가 더 중요합니다
warm_alarm은 플랫폼 인터페이스와 Android, iOS, macOS 구현을 분리한 federated plugin 구조를 사용합니다.
각 플랫폼 패키지는 같은 순서를 따릅니다.
- 플랫폼 인터페이스의 기본 계약을 상속합니다.
registerWith에서 자신의 구현을 등록합니다.- 실제 호출은 Pigeon이 생성한 플랫폼 API에 위임합니다.
공개 facade는 현재 플랫폼 구현이 무엇인지 몰라도 됩니다. 플랫폼 구현은 자신의 Pigeon 스키마와 네이티브 사정을 처리합니다. 플랫폼 인터페이스는 그 사이에서 앱이 의존할 안정적인 모델과 메서드를 유지합니다.
이 구조에서 Pigeon DTO를 공개해버리면 분리의 의미가 크게 줄어듭니다. Android와 Apple 스키마가 독립적으로 진화할 수 있어도, 앱 코드가 생성 DTO에 직접 의존하면 다시 하나의 변경 축으로 묶이기 때문입니다.
공개 모델을 손으로 쓰면 얻는 것
API 이름을 도메인 언어로 유지할 수 있습니다
전송 계층은 직렬화에 맞는 이름을 선택할 수 있고, 공개 API는 사용자에게 의미 있는 이름을 유지할 수 있습니다. 내부 스키마가 바뀌어도 사용자 문서와 자동완성의 언어를 지킬 수 있습니다.
코드 생성기 교체 비용을 가둘 수 있습니다
Pigeon 버전 변경이나 다른 채널 기술로의 교체가 생겨도 공개 모델은 그대로 둘 수 있습니다. 바뀌는 곳은 생성 스키마와 매핑 계층입니다.
물론 모든 변경이 공짜가 되는 것은 아닙니다. 실제 기능 의미가 바뀌면 공개 API도 바뀌어야 합니다. 이 경계가 막아주는 것은 도구의 내부 변화가 이유 없이 외부 파괴 변경으로 번지는 것입니다.
플랫폼 차이를 정직하게 표현할 수 있습니다
모든 플랫폼이 같은 기능을 똑같이 지원한다고 거짓말할 필요가 없습니다. 공개 모델은 지원 상태나 제한 이유를 도메인 타입으로 표현하고, 각 플랫폼 구현은 자신의 네이티브 결과를 그 모델로 매핑할 수 있습니다.
플랫폼 추상화의 목표는 차이를 지우는 것이 아니라, 차이를 예측 가능한 계약으로 바꾸는 것입니다.
테스트는 어디에 붙여야 할까요?
경계가 생기면 테스트 위치도 명확해집니다.
- 공개 모델의 불변식은 platform interface 패키지에서 테스트합니다.
- 공개 모델과 DTO의 왕복 변환은 각 플랫폼 패키지에서 테스트합니다.
- 생성 API 호출 위임은 플랫폼 구현 테스트에서 확인합니다.
- 실제 채널과 네이티브 동작은 통합 테스트에서 확인합니다.
특히 매핑 테스트는 필드 추가 때 효과가 큽니다. 스키마에 필드를 하나 추가하고 생성은 했는데 공개 모델 변환에서 빼먹는 실수를 바로 드러냅니다.
생성 코드가 타입 안전하다고 해서 매핑 코드까지 자동으로 완전해지는 것은 아닙니다. 타입 안전은 멋지지만 텔레파시는 아닙니다.
언제 별도 공개 모델이 과한가요?
모든 내부 DTO에 무조건 쌍둥이 모델을 만들어야 한다는 뜻은 아닙니다.
앱 내부에서만 쓰는 단발성 채널이고 소비자가 같은 저장소에 있으며 API 안정성을 약속할 필요가 없다면 생성 타입을 직접 쓰는 편이 단순할 수 있습니다.
반면 다음 조건이 있다면 경계를 만드는 쪽이 안전합니다.
- 여러 앱이 사용하는 패키지입니다.
- federated plugin처럼 플랫폼 구현이 독립적으로 바뀝니다.
- 생성 스키마가 플랫폼별로 다릅니다.
- 공개 API의 호환성이 중요합니다.
- 생성 도구를 업그레이드하거나 교체할 가능성이 있습니다.
라이브러리 경계에서는 중복 몇 줄보다 공개 계약의 수명이 더 비쌉니다.
정리
Pigeon은 플랫폼 채널 코드를 생성하는 훌륭한 도구입니다. 그래서 더더욱 역할을 정확히 제한해야 합니다.
- Pigeon DTO는 전송 계약입니다.
- 손으로 작성한 모델은 사용자 계약입니다.
- 플랫폼 구현의 매핑 계층이 두 계약을 연결합니다.
- 생성 파일은
lib/src/에 두고 export하지 않습니다. - 스키마를 바꾸면 생성기를 다시 돌리고, 생성물은 손으로 고치지 않습니다.
모델을 두 벌 유지하는 것이 낭비처럼 보일 수 있습니다. 하지만 둘은 같은 일을 하지 않습니다.
하나는 플랫폼을 건넙니다. 다른 하나는 시간을 견딥니다.
그 둘을 분리해두면 다음 Pigeon 업데이트 때 앱 사용자까지 함께 날아가는 일은 줄어듭니다. 그 정도면 모델 하나 더 쓸 이유로 충분하지 않을까요?
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.