Flutter 튜토리얼 58편: iOS App Clip과 확장
요약
핵심 요지
- 문제 정의: Flutter 앱에서 iOS의 최신 기능과 시스템 통합을 활용하려면 고급 플랫폼 통합이 필요하다.
- 핵심 주장: App Clip1, 앱 확장, Apple 프레임워크 연동으로 iOS 생태계에 깊이 통합할 수 있다.
- 주요 근거: Flutter는 iOS 최신 버전을 지원하며, 다양한 플러그인으로 Apple 프레임워크에 접근할 수 있다.
- 실무 기준: App Clip은 10MB 미만으로 유지하고, 앱 확장은 별도 타겟으로 구현한다.
- 한계: App Clip과 앱 확장은 Flutter 코드를 직접 실행하기 어려우며, 네이티브 코드가 필요하다.
문서가 설명하는 범위
- iOS 최신 버전 지원 현황
- Apple 프레임워크 대응 플러그인
- App Clip 구현
- iOS 앱 확장 개발
읽는 시간: 25분 | 난이도: 고급
참고 자료
- Flutter on latest iOS - iOS 최신 버전 지원
- Apple frameworks - Apple 프레임워크 연동
- iOS App Clip - App Clip 구현
- App extensions - 앱 확장 개발
문제 상황
Flutter 앱을 iOS 생태계에 깊이 통합하려면 단순한 앱 실행을 넘어 다양한 시스템 기능을 활용해야 합니다. App Clip으로 앱 설치 없이 빠른 체험을 제공하고, 앱 확장으로 시스템 전반에 기능을 노출해야 합니다.
iOS 고급 통합이 필요한 상황
최신 iOS → 새로운 API와 기능 활용Apple 프레임워크 → HealthKit, MapKit 등 시스템 연동App Clip → 설치 없이 앱의 일부 기능 제공앱 확장 → 위젯, Share Extension 등 시스템 통합문제는 다음과 같습니다.
- iOS 버전별로 지원하는 API가 다르다.
- Apple 프레임워크는 네이티브 코드로 접근해야 한다.
- App Clip은 10MB 크기 제한이 있어 Flutter 앱 전체를 포함하기 어렵다.
- 앱 확장은 메인 앱과 별도 프로세스로 실행된다.
해결 방법
Flutter는 iOS의 고급 기능들과 통합할 수 있는 다양한 방법을 제공합니다. 플러그인, App Clip, 앱 확장을 통해 iOS 생태계에 깊이 통합하는 방법을 알아봅니다.
챕터 1: iOS 최신 버전 지원
Why
NOTE매년 Apple은 새로운 iOS 버전을 출시하며 새로운 API와 기능을 추가합니다. Flutter 앱도 이러한 최신 기능을 활용하려면 호환성을 확인하고 적절히 대응해야 합니다.
iOS 새 버전 출시 → Flutter 호환성 확인 → 필요시 업데이트 → 새 기능 활용
What
NOTEFlutter의 iOS 지원 정책입니다.
항목 내용 최소 지원 버전 iOS 12.0 (Flutter 3.x 기준) 권장 버전 최신 iOS 버전 베타 지원 iOS 베타 출시 시 Flutter 베타에서 테스트 업데이트 주기 iOS 정식 출시 전후로 Flutter 업데이트
How
TIP1. 최소 iOS 버전 설정
ios/Podfile:# 최소 iOS 버전 지정platform :ios, '12.0'
ios/Runner.xcodeproj/project.pbxproj에서도 확인:
- IPHONEOS_DEPLOYMENT_TARGET 설정
2. iOS 버전별 조건부 코드
Dart에서:
import 'dart:io';void checkiOSVersion() {if (Platform.isIOS) {// iOS 버전 확인은 device_info_plus 패키지 사용}}
device_info_plus사용:import 'package:device_info_plus/device_info_plus.dart';Future<void> checkVersion() async {final deviceInfo = DeviceInfoPlugin();final iosInfo = await deviceInfo.iosInfo;final version = iosInfo.systemVersion; // "17.0"final majorVersion = int.parse(version.split('.').first);if (majorVersion >= 17) {// iOS 17 이상 기능 사용} else {// 대체 기능 사용}}3. Swift에서 버전 체크
import UIKitfunc checkVersion() {if #available(iOS 17.0, *) {// iOS 17 이상 코드print("iOS 17 이상")} else {// 이전 버전 대체 코드print("iOS 17 미만")}}4. 새 iOS 버전 대응 체크리스트
1. Flutter 최신 stable 버전으로 업데이트2. 모든 플러그인 업데이트3. Xcode 최신 버전으로 업데이트4. iOS 시뮬레이터에서 테스트5. 실제 기기에서 테스트6. 디자인 가이드라인 변경 확인5. Info.plist 새 키 추가
iOS 버전이 올라가면 새로운 권한 키가 필요할 수 있습니다:
<!-- iOS 14+: 추적 투명성 --><key>NSUserTrackingUsageDescription</key><string>맞춤 광고를 위해 추적 권한이 필요합니다.</string><!-- iOS 14+: 로컬 네트워크 --><key>NSLocalNetworkUsageDescription</key><string>로컬 기기 검색을 위해 필요합니다.</string>
Watch out
WARNING베타 iOS에서 앱을 테스트할 때는 Flutter 베타 채널을 사용하세요. Stable 채널은 정식 iOS 버전만 지원합니다.
Terminal window # 베타 채널로 전환flutter channel betaflutter upgrade# 테스트 후 stable로 복귀flutter channel stableflutter upgrade프로덕션 앱은 항상 stable 채널을 사용하세요.
결론: 최소 iOS 버전을 설정하고 버전별 조건부 코드를 사용하면 다양한 iOS 버전을 지원할 수 있습니다.
챕터 2: Apple 프레임워크 활용
Why
NOTEApple은 HealthKit, MapKit, StoreKit 등 강력한 프레임워크를 제공합니다. Flutter 플러그인을 통해 이러한 프레임워크에 접근하면 네이티브 수준의 기능을 구현할 수 있습니다.
Flutter 앱 → 플러그인 → Apple 프레임워크 → 시스템 기능
What
NOTE주요 Apple 프레임워크와 대응 Flutter 플러그인입니다.
Apple 프레임워크 Flutter 플러그인 용도 MapKit apple_maps_flutter Apple 지도 StoreKit in_app_purchase 인앱 결제 HealthKit health 건강 데이터 EventKit device_calendar 캘린더 접근 LocalAuthentication local_auth Face ID/Touch ID CoreLocation geolocator 위치 서비스 AVFoundation camera 카메라 Photos photo_manager 사진 라이브러리
How
TIP1. Apple 지도 사용
dependencies:apple_maps_flutter: ^1.2.0import 'package:apple_maps_flutter/apple_maps_flutter.dart';class MapScreen extends StatelessWidget {const MapScreen({super.key});@overrideWidget build(BuildContext context) {return AppleMap(initialCameraPosition: CameraPosition(target: LatLng(37.5665, 126.9780), // 서울zoom: 14,),onMapCreated: (controller) {print('지도 생성됨');},);}}2. Face ID/Touch ID 인증
dependencies:local_auth: ^2.0.0
ios/Runner/Info.plist:<key>NSFaceIDUsageDescription</key><string>로그인을 위해 Face ID를 사용합니다.</string>import 'package:local_auth/local_auth.dart';class AuthService {final LocalAuthentication _auth = LocalAuthentication();Future<bool> authenticate() async {// 생체 인증 가능 여부 확인final canAuthenticate = await _auth.canCheckBiometrics;if (!canAuthenticate) return false;// 사용 가능한 생체 인증 타입 확인final availableBiometrics = await _auth.getAvailableBiometrics();print('사용 가능: $availableBiometrics');// 인증 시도try {return await _auth.authenticate(localizedReason: '앱에 로그인하려면 인증하세요',options: const AuthenticationOptions(stickyAuth: true,biometricOnly: true,),);} catch (e) {print('인증 에러: $e');return false;}}}3. 인앱 결제
dependencies:in_app_purchase: ^3.0.0import 'package:in_app_purchase/in_app_purchase.dart';class PurchaseService {final InAppPurchase _inAppPurchase = InAppPurchase.instance;Future<void> initialize() async {// 스토어 사용 가능 여부 확인final available = await _inAppPurchase.isAvailable();if (!available) {print('스토어를 사용할 수 없습니다');return;}// 구매 스트림 구독_inAppPurchase.purchaseStream.listen((purchases) {for (final purchase in purchases) {_handlePurchase(purchase);}});}Future<void> loadProducts() async {const productIds = {'premium_monthly', 'premium_yearly'};final response = await _inAppPurchase.queryProductDetails(productIds);if (response.notFoundIDs.isNotEmpty) {print('찾을 수 없는 상품: ${response.notFoundIDs}');}for (final product in response.productDetails) {print('${product.title}: ${product.price}');}}void _handlePurchase(PurchaseDetails purchase) {if (purchase.status == PurchaseStatus.purchased) {// 구매 완료 처리_inAppPurchase.completePurchase(purchase);}}}4. 건강 데이터 접근
dependencies:health: ^4.0.0
ios/Runner/Info.plist:<key>NSHealthShareUsageDescription</key><string>건강 데이터를 읽기 위해 필요합니다.</string><key>NSHealthUpdateUsageDescription</key><string>건강 데이터를 기록하기 위해 필요합니다.</string>import 'package:health/health.dart';class HealthService {final HealthFactory _health = HealthFactory();Future<void> fetchSteps() async {final types = [HealthDataType.STEPS];// 권한 요청final hasPermission = await _health.requestAuthorization(types);if (!hasPermission) return;// 오늘의 걸음 수 가져오기final now = DateTime.now();final midnight = DateTime(now.year, now.month, now.day);final healthData = await _health.getHealthDataFromTypes(midnight,now,types,);int totalSteps = 0;for (final data in healthData) {if (data.type == HealthDataType.STEPS) {totalSteps += (data.value as NumericHealthValue).numericValue.toInt();}}print('오늘 걸음 수: $totalSteps');}}
Watch out
WARNINGApple 프레임워크를 사용할 때는 반드시 Info.plist에 사용 목적을 명시해야 합니다. 그렇지 않으면 앱 심사에서 거절됩니다.
<!-- ❌ 누락하면 크래시 또는 심사 거절 --><!-- ✅ 모든 권한에 사용 목적 명시 --><key>NSCameraUsageDescription</key><string>사진 촬영을 위해 카메라가 필요합니다.</string><key>NSPhotoLibraryUsageDescription</key><string>사진 선택을 위해 라이브러리 접근이 필요합니다.</string>사용자에게 명확하게 왜 이 권한이 필요한지 설명하세요.
결론: Flutter 플러그인을 통해 Apple 프레임워크에 접근하면 네이티브 수준의 iOS 기능을 구현할 수 있습니다.
챕터 3: App Clip 구현
Why
NOTEApp Clip은 앱 설치 없이 앱의 일부 기능을 빠르게 체험할 수 있게 합니다. NFC 태그, QR 코드, Safari에서 App Clip을 실행하면 10MB 미만의 경량 앱이 즉시 시작됩니다.
NFC/QR/링크 → App Clip 다운로드 (10MB 미만) → 즉시 실행 → 전체 앱 설치 유도
What
NOTEApp Clip의 특징입니다.
항목 내용 크기 제한 10MB 미만 (압축 후) 실행 방식 NFC, QR, App Clip 코드, Safari, 지도 기능 제한 백그라운드 실행, 푸시 알림 등 제한 데이터 공유 메인 앱과 App Group으로 데이터 공유 가능
How
TIP1. Xcode에서 App Clip 타겟 추가
- Xcode에서 프로젝트 열기
- File > New > Target 선택
- App Clip 템플릿 선택
- 이름 입력 (예: RunnerClip)
2. App Clip 타겟 설정
ios/RunnerClip/Info.plist:<key>NSAppClip</key><dict><key>NSAppClipRequestLocationConfirmation</key><false/><key>NSAppClipRequestEphemeralUserNotification</key><false/></dict>3. App Clip에서 Flutter 사용 (제한적)
App Clip의 크기 제한(10MB) 때문에 전체 Flutter 엔진을 포함하기 어렵습니다. 일반적으로 네이티브 SwiftUI로 App Clip UI를 구현합니다.
ios/RunnerClip/ContentView.swift:import SwiftUIstruct ContentView: View {var body: some View {VStack {Image(systemName: "app.gift").font(.system(size: 80)).foregroundColor(.blue)Text("App Clip 체험").font(.largeTitle).padding()Text("전체 기능을 사용하려면 앱을 설치하세요").font(.body).foregroundColor(.gray)Button("앱 설치하기") {// App Store로 이동if let url = URL(string: "https://apps.apple.com/app/id123456") {UIApplication.shared.open(url)}}.padding().background(Color.blue).foregroundColor(.white).cornerRadius(10)}}}4. App Group으로 데이터 공유
메인 앱과 App Clip 간 데이터 공유:
- Xcode에서 두 타겟 모두에 App Group capability 추가
- 동일한 App Group ID 사용 (예:
group.com.example.myapp)// 데이터 저장 (App Clip)let userDefaults = UserDefaults(suiteName: "group.com.example.myapp")userDefaults?.set("user123", forKey: "userId")// 데이터 읽기 (메인 앱)let userDefaults = UserDefaults(suiteName: "group.com.example.myapp")let userId = userDefaults?.string(forKey: "userId")5. App Clip URL 처리
App Clip은 URL을 통해 실행됩니다:
import SwiftUI@mainstruct RunnerClipApp: App {var body: some Scene {WindowGroup {ContentView().onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity inif let url = activity.webpageURL {handleAppClipURL(url)}}}}func handleAppClipURL(_ url: URL) {// URL에서 파라미터 추출// 예: https://example.com/clip?product=123let components = URLComponents(url: url, resolvingAgainstBaseURL: false)let productId = components?.queryItems?.first(where: { $0.name == "product" })?.valueprint("상품 ID: \(productId ?? "없음")")}}6. App Clip 크기 최적화
크기 줄이기 전략:1. 필요한 기능만 포함2. 이미지 최적화 (WebP, HEIC 사용)3. 불필요한 프레임워크 제거4. bitcode 활성화5. 릴리스 빌드로 테스트
Watch out
WARNINGApp Clip은 제한된 API만 사용할 수 있습니다. CallKit, 백그라운드 작업, 일부 HealthKit 기능은 사용할 수 없습니다.
// ❌ App Clip에서 사용 불가- 백그라운드 앱 새로고침- CallKit- CareKit- HealthKit (일부)- HomeKit- 모든 종류의 Extension// ✅ App Clip에서 사용 가능- Apple Pay- App Attest- Sign in with Apple- 위치 확인 (1회성)- 알림 (8시간 제한)전체 기능이 필요하면 메인 앱 설치를 유도하세요.
결론: App Clip은 네이티브 SwiftUI로 구현하고, App Group으로 메인 Flutter 앱과 데이터를 공유합니다.
챕터 4: iOS 앱 확장 개발
Why
NOTE앱 확장(App Extensions)은 메인 앱 외부에서 앱의 기능을 제공합니다. 위젯, Share Extension, Today Extension 등으로 iOS 시스템 전반에 앱을 통합할 수 있습니다.
메인 앱 + 위젯 Extension + Share Extension + ...
What
NOTE주요 앱 확장 유형입니다.
확장 유형 용도 Widget Extension 홈 화면/잠금 화면 위젯 Share Extension 공유 시트에서 앱으로 데이터 공유 Today Extension 알림 센터 위젯 (레거시) Intents Extension Siri 연동 Notification Content 풍부한 알림 UI Action Extension 다른 앱에서 작업 수행
How
TIP1. Widget Extension 추가
- Xcode에서 File > New > Target
- Widget Extension 선택
- 이름 입력 (예: MyWidget)
2. 위젯 구현
ios/MyWidget/MyWidget.swift:import WidgetKitimport SwiftUI// 위젯 데이터 모델struct WidgetData: TimelineEntry {let date: Datelet message: Stringlet count: Int}// 데이터 제공자struct Provider: TimelineProvider {func placeholder(in context: Context) -> WidgetData {WidgetData(date: Date(), message: "로딩 중...", count: 0)}func getSnapshot(in context: Context, completion: @escaping (WidgetData) -> Void) {let data = loadDataFromAppGroup()completion(data)}func getTimeline(in context: Context, completion: @escaping (Timeline<WidgetData>) -> Void) {let data = loadDataFromAppGroup()let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!let timeline = Timeline(entries: [data], policy: .after(nextUpdate))completion(timeline)}func loadDataFromAppGroup() -> WidgetData {let userDefaults = UserDefaults(suiteName: "group.com.example.myapp")let message = userDefaults?.string(forKey: "widget_message") ?? "데이터 없음"let count = userDefaults?.integer(forKey: "widget_count") ?? 0return WidgetData(date: Date(), message: message, count: count)}}// 위젯 뷰struct MyWidgetEntryView: View {var entry: WidgetDatavar body: some View {VStack {Text(entry.message).font(.headline)Text("\(entry.count)").font(.largeTitle).bold()Text(entry.date, style: .time).font(.caption)}.padding()}}// 위젯 설정@mainstruct MyWidget: Widget {let kind: String = "MyWidget"var body: some WidgetConfiguration {StaticConfiguration(kind: kind, provider: Provider()) { entry inMyWidgetEntryView(entry: entry)}.configurationDisplayName("내 위젯").description("Flutter 앱 데이터를 표시합니다").supportedFamilies([.systemSmall, .systemMedium])}}3. Flutter에서 위젯 데이터 업데이트
dependencies:home_widget: ^0.4.0import 'package:home_widget/home_widget.dart';class WidgetService {static const appGroupId = 'group.com.example.myapp';static const iOSWidgetName = 'MyWidget';Future<void> updateWidget({required String message,required int count,}) async {// App Group에 데이터 저장await HomeWidget.saveWidgetData<String>('widget_message', message);await HomeWidget.saveWidgetData<int>('widget_count', count);// 위젯 업데이트 요청await HomeWidget.updateWidget(iOSName: iOSWidgetName,);}}// 사용 예시void main() async {final widgetService = WidgetService();await widgetService.updateWidget(message: '안녕하세요!',count: 42,);}4. Share Extension 추가
- Xcode에서 File > New > Target
- Share Extension 선택
- 이름 입력 (예: ShareExtension)
ios/ShareExtension/ShareViewController.swift:import UIKitimport Socialimport MobileCoreServicesclass ShareViewController: SLComposeServiceViewController {override func isContentValid() -> Bool {return true}override func didSelectPost() {// 공유된 항목 처리if let item = extensionContext?.inputItems.first as? NSExtensionItem {for attachment in item.attachments ?? [] {if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {attachment.loadItem(forTypeIdentifier: kUTTypeURL as String) { (url, error) inif let shareURL = url as? URL {self.saveToAppGroup(url: shareURL)}}}}}extensionContext?.completeRequest(returningItems: [], completionHandler: nil)}func saveToAppGroup(url: URL) {let userDefaults = UserDefaults(suiteName: "group.com.example.myapp")userDefaults?.set(url.absoluteString, forKey: "shared_url")}override func configurationItems() -> [Any]! {return []}}5. Flutter에서 공유된 데이터 읽기
import 'package:shared_preferences/shared_preferences.dart';import 'package:home_widget/home_widget.dart';class ShareService {Future<String?> getSharedUrl() async {// App Group에서 공유된 URL 읽기return await HomeWidget.getWidgetData<String>('shared_url');}Future<void> clearSharedUrl() async {await HomeWidget.saveWidgetData<String?>('shared_url', null);}}6. App Group 설정
메인 앱과 모든 확장이 데이터를 공유하려면:
- 각 타겟에서 Signing & Capabilities 탭 선택
- + Capability > App Groups 추가
- 동일한 그룹 ID 사용 (예:
group.com.example.myapp)
Watch out
WARNING앱 확장은 메인 앱과 별도 프로세스로 실행됩니다. 메모리 제한이 있으며, 직접적인 통신은 불가능합니다.
// ❌ 확장에서 직접 Flutter 코드 실행 불가// Flutter 엔진은 메인 앱에서만 실행// ✅ App Group을 통한 데이터 공유let userDefaults = UserDefaults(suiteName: "group.com.example.myapp")// 확장 → 메인 앱: 데이터 저장userDefaults?.set("data", forKey: "from_extension")// 메인 앱에서: 앱 시작 시 확인// WidgetsBinding.instance.addObserver()로// 앱이 포그라운드로 올 때 데이터 확인위젯 업데이트 빈도에도 제한이 있습니다. 너무 자주 업데이트하면 시스템이 무시할 수 있습니다.
결론: 앱 확장은 네이티브 코드로 구현하고, App Group을 통해 Flutter 메인 앱과 데이터를 공유합니다.
한계
iOS 고급 통합에는 몇 가지 한계가 있습니다.
- App Clip 크기 제한: 10MB 제한으로 인해 전체 Flutter 엔진을 포함하기 어렵습니다.
- 확장 메모리 제한: 앱 확장은 제한된 메모리에서 실행되며, 무거운 작업은 불가능합니다.
- 네이티브 코드 필요: App Clip과 앱 확장은 SwiftUI/UIKit으로 구현해야 합니다.
- 데이터 동기화: App Group을 통한 데이터 공유는 실시간이 아니며, 동기화 로직이 필요합니다.
Footnotes
-
App Clip(앱 클립): iOS 14에서 도입된 기능으로, 앱 설치 없이 앱의 일부 기능을 즉시 사용할 수 있게 한다. 10MB 미만의 경량 앱이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!