Flutter 튜토리얼 69편: 기존 iOS 앱에 Flutter 추가

요약#

기존 iOS 앱에 Flutter를 추가하면 새로운 기능을 Flutter로 점진적으로 개발할 수 있습니다. 이 튜토리얼에서는 Flutter 모듈1을 생성하고 iOS 프로젝트에 통합하는 세 가지 방법과 FlutterEngine2, FlutterViewController3를 활용한 Flutter 화면 표시 방법을 다룹니다.

참고 자료#

문제 상황#

  • 기존 iOS 앱에 Flutter UI를 어떻게 추가하나요?
  • CocoaPods4를 사용한 통합 방법은 무엇인가요?
  • Framework를 사용한 통합 방법은 어떤 장점이 있나요?
  • FlutterEngine과 FlutterViewController는 어떻게 사용하나요?
  • Swift와 Dart 간의 통신은 어떻게 구현하나요?

해결 방법#

Chapter 1: Flutter 모듈 생성#

Why

기존 iOS 앱에 Flutter를 추가하려면 먼저 Flutter 모듈을 생성해야 합니다. Flutter 모듈은 iOS 앱에서 임베드할 수 있는 형태로 Flutter 코드를 패키징합니다.

What

Flutter 모듈은 일반 Flutter 프로젝트와 유사하지만, 기존 앱에 임베드할 수 있도록 설계되었습니다. .ios/ 숨김 디렉터리에는 Xcode 워크스페이스와 CocoaPods 통합을 위한 헬퍼 스크립트가 포함됩니다.

Terminal window
# Flutter 모듈 생성
cd /path/to/projects
flutter create --template module my_flutter
# 모듈 디렉터리 구조
my_flutter/
├── .ios/ # iOS 래퍼 프로젝트 (자동 생성)
├── Runner.xcworkspace
└── Flutter/
└── podhelper.rb # CocoaPods 헬퍼 스크립트
├── lib/
└── main.dart # Flutter 코드
├── test/
└── pubspec.yaml
Watch out

.ios/ 디렉터리는 소스 컨트롤에서 제외하세요. 새 머신에서 빌드하기 전에 flutter pub get을 실행하여 .ios/ 디렉터리를 재생성해야 합니다. .ios/ 디렉터리의 변경사항은 기존 iOS 앱에 반영되지 않으며 Flutter에 의해 덮어쓰일 수 있습니다.


Chapter 2: CocoaPods를 사용한 통합 (권장)#

Why

CocoaPods를 사용한 통합은 가장 간단한 방법입니다. Xcode에서 빌드할 때마다 Flutter 모듈이 소스에서 컴파일되므로 최신 코드가 항상 반영됩니다.

What

CocoaPods 통합을 위해서는 Flutter SDK와 CocoaPods가 설치되어 있어야 합니다. podhelper.rb 스크립트가 Flutter 모듈, 플러그인, Flutter 엔진을 자동으로 임베드합니다.

프로젝트 구조 (권장):

/path/to/projects/
├── my_flutter/ # Flutter 모듈
│ └── .ios/
│ └── Flutter/
│ └── podhelper.rb
└── MyApp/ # 기존 iOS 앱
└── Podfile

Podfile 설정:

# MyApp/Podfile
platform :ios, '13.0'
# Flutter 모듈 경로 설정
flutter_application_path = '../my_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
target 'MyApp' do
# Flutter 모듈과 플러그인 설치
install_all_flutter_pods(flutter_application_path)
end
post_install do |installer|
flutter_post_install(installer) if defined?(flutter_post_install)
end

의존성 설치 및 빌드:

Terminal window
# Flutter 의존성 설치
cd /path/to/my_flutter
flutter pub get
# CocoaPods 설치
cd /path/to/MyApp
pod install
# MyApp.xcworkspace 열기 (중요: .xcodeproj가 아닌 .xcworkspace 사용)
open MyApp.xcworkspace
Watch out

반드시 MyApp.xcworkspace를 열어야 합니다. MyApp.xcodeproj를 열면 CocoaPods 의존성이 포함되지 않습니다. Flutter 의존성이 변경될 때마다 flutter pub getpod install을 실행하세요.


Chapter 3: iOS Framework를 사용한 통합#

Why

Framework를 사용한 통합은 모든 개발자가 Flutter SDK를 설치할 필요가 없습니다. 빌드된 Framework를 배포하면 순수 iOS 개발자도 Flutter 모듈을 사용할 수 있습니다.

What

Flutter는 Debug, Profile, Release 모드별로 서로 다른 Framework를 생성합니다. flutter build ios-framework 명령으로 필요한 모든 Framework를 생성할 수 있습니다.

Terminal window
# Framework 생성
cd /path/to/my_flutter
flutter build ios-framework --output=some/path/to/MyApp/Flutter
# 생성되는 Framework 구조
some/path/to/MyApp/Flutter/
├── Debug/
├── Flutter.xcframework
├── App.xcframework
└── FlutterPluginRegistrant.xcframework
├── Profile/
└── ...
└── Release/
└── ...

Xcode에서 Framework 설정:

  1. 프로젝트 설정 열기: Target → General → Frameworks, Libraries, and Embedded Content
  2. Framework 추가: Flutter.xcframework, App.xcframework 등을 추가
  3. Embed 설정: 모든 xcframework를 “Embed & Sign”으로 설정
  4. Framework Search Paths 설정: Build Settings → Framework Search Paths에 $(PROJECT_DIR)/Flutter/Debug 등 추가
Watch out

빌드 모드에 맞는 Framework를 사용해야 합니다. Debug Framework로 Release 빌드를 하면 앱 심사에서 거부될 수 있습니다. App Store 제출 전 반드시 Release Framework를 사용하세요.


Chapter 4: FlutterEngine 생성 및 관리#

Why

FlutterEngine은 Dart VM과 Flutter 런타임을 호스팅합니다. 엔진을 미리 워밍업하면 Flutter 화면이 더 빠르게 표시됩니다.

What

FlutterEngine을 앱 시작 시 미리 생성하고 워밍업하는 것이 권장됩니다. 이렇게 하면 Flutter 화면을 표시할 때 지연 시간이 줄어들고, Dart 상태가 여러 ViewController 간에 유지됩니다.

SwiftUI에서 FlutterEngine 설정:

MyApp.swift
import SwiftUI
import Flutter
import FlutterPluginRegistrant
@Observable
class FlutterDependencies {
let flutterEngine = FlutterEngine(name: "my flutter engine")
init() {
// 기본 Dart 엔트리포인트(main()) 실행
flutterEngine.run()
// 플러그인 등록
GeneratedPluginRegistrant.register(with: self.flutterEngine)
}
}
@main
struct MyApp: App {
@State var flutterDependencies = FlutterDependencies()
var body: some Scene {
WindowGroup {
ContentView()
.environment(flutterDependencies)
}
}
}

UIKit에서 FlutterEngine 설정:

AppDelegate.swift
import UIKit
import Flutter
import FlutterPluginRegistrant
@main
class AppDelegate: FlutterAppDelegate {
lazy var flutterEngine = FlutterEngine(name: "my flutter engine")
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Flutter 엔진 시작
flutterEngine.run()
// 플러그인 등록
GeneratedPluginRegistrant.register(with: self.flutterEngine)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
Watch out

FlutterEngine을 매번 새로 생성하면 성능이 저하됩니다. 앱 시작 시 한 번 생성하고 재사용하는 것이 좋습니다. 다만 메모리 사용량이 증가하므로 앱의 특성에 맞게 결정하세요.


Chapter 5: FlutterViewController로 Flutter 화면 표시#

Why

FlutterViewController는 Flutter UI를 렌더링하고 사용자 입력을 처리합니다. 미리 워밍업된 FlutterEngine과 연결하여 빠르게 Flutter 화면을 표시할 수 있습니다.

What

FlutterViewController는 UIKit의 UIViewController를 상속하므로 일반 ViewController처럼 사용할 수 있습니다. SwiftUI에서는 UIViewControllerRepresentable을 사용하여 래핑합니다.

SwiftUI에서 Flutter 화면 표시:

ContentView.swift
import SwiftUI
import Flutter
struct FlutterViewControllerRepresentable: UIViewControllerRepresentable {
@Environment(FlutterDependencies.self) var flutterDependencies
func makeUIViewController(context: Context) -> some UIViewController {
return FlutterViewController(
engine: flutterDependencies.flutterEngine,
nibName: nil,
bundle: nil
)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
Text("기존 iOS 앱")
NavigationLink("Flutter 화면 열기") {
FlutterViewControllerRepresentable()
.ignoresSafeArea()
}
}
}
}
}

UIKit에서 Flutter 화면 표시:

ViewController.swift
import UIKit
import Flutter
class ViewController: UIViewController {
@IBAction func showFlutter(_ sender: Any) {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
let flutterViewController = FlutterViewController(
engine: appDelegate.flutterEngine,
nibName: nil,
bundle: nil
)
// 모달로 표시
present(flutterViewController, animated: true, completion: nil)
// 또는 네비게이션 푸시
// navigationController?.pushViewController(flutterViewController, animated: true)
}
}
Watch out

FlutterViewController는 자체적으로 FlutterEngine을 생성할 수도 있지만, 이 경우 첫 프레임 표시가 느려집니다. 암시적 엔진 생성은 Flutter 화면이 드물게 사용되고 상태 유지가 필요 없을 때만 고려하세요.


Chapter 6: Dart 엔트리포인트와 라우트 설정#

Why

기본적으로 Flutter는 lib/main.dartmain() 함수를 실행합니다. 다른 진입점이나 초기 라우트를 지정하면 다양한 Flutter 화면을 iOS에서 시작할 수 있습니다.

What

runWithEntrypoint 메서드로 다른 Dart 함수를 진입점으로 지정할 수 있습니다. initialRoute 파라미터로 Flutter Navigator의 초기 라우트를 설정할 수 있습니다.

다른 엔트리포인트 실행:

// 다른 Dart 함수를 진입점으로 지정
flutterEngine.run(withEntrypoint: "myOtherEntrypoint")
// 다른 파일의 함수 지정
flutterEngine.run(
withEntrypoint: "myOtherEntrypoint",
libraryURI: "other_file.dart"
)

Dart 코드 (엔트리포인트 정의):

lib/other_file.dart
// 다른 진입점은 반드시 @pragma 어노테이션이 필요합니다
// 이 어노테이션이 없으면 트리 쉐이킹으로 제거될 수 있습니다
@pragma('vm:entry-point')
void myOtherEntrypoint() {
runApp(const MyOtherApp());
}

초기 라우트 설정:

// FlutterEngine에서 초기 라우트 설정
let flutterEngine = FlutterEngine()
flutterEngine.run(
withEntrypoint: "main",
initialRoute: "/onboarding"
)
// 또는 FlutterViewController에서 직접 설정
let flutterViewController = FlutterViewController(
project: nil,
initialRoute: "/onboarding",
nibName: nil,
bundle: nil
)
Watch out

main() 이외의 진입점에는 반드시 @pragma('vm:entry-point') 어노테이션을 추가하세요. 이 어노테이션이 없으면 릴리즈 빌드에서 트리 쉐이킹으로 제거될 수 있습니다.


Chapter 7: MethodChannel을 통한 iOS-Dart 통신#

Why

iOS 코드와 Dart 코드 간의 통신이 필요한 경우가 많습니다. MethodChannel을 사용하면 양방향 메시지 전달이 가능합니다.

What

MethodChannel은 문자열 이름으로 식별되는 양방향 통신 채널입니다. iOS에서는 FlutterMethodChannel, Dart에서는 MethodChannel을 사용합니다.

iOS 측 코드 (Swift):

// Flutter 채널 설정
import Flutter
class FlutterDependencies {
let flutterEngine = FlutterEngine(name: "my flutter engine")
private var methodChannel: FlutterMethodChannel?
init() {
flutterEngine.run()
GeneratedPluginRegistrant.register(with: flutterEngine)
// MethodChannel 설정
methodChannel = FlutterMethodChannel(
name: "com.example.myapp/channel",
binaryMessenger: flutterEngine.binaryMessenger
)
// Dart에서 오는 메서드 호출 처리
methodChannel?.setMethodCallHandler { [weak self] call, result in
switch call.method {
case "getBatteryLevel":
let batteryLevel = self?.getBatteryLevel() ?? -1
result(batteryLevel)
case "showNativeAlert":
if let message = call.arguments as? String {
self?.showAlert(message: message)
result(nil)
} else {
result(FlutterError(
code: "INVALID_ARGUMENT",
message: "Message is required",
details: nil
))
}
default:
result(FlutterMethodNotImplemented)
}
}
}
// iOS에서 Dart 메서드 호출
func sendMessageToDart(data: [String: Any]) {
methodChannel?.invokeMethod("receiveData", arguments: data) { result in
if let error = result as? FlutterError {
print("Error: \(error.message ?? "")")
} else {
print("Success: \(result ?? "nil")")
}
}
}
private func getBatteryLevel() -> Int {
UIDevice.current.isBatteryMonitoringEnabled = true
let batteryLevel = UIDevice.current.batteryLevel
return Int(batteryLevel * 100)
}
private func showAlert(message: String) {
// Alert 표시 로직
}
}

Dart 측 코드:

lib/platform_channel.dart
import 'package:flutter/services.dart';
class PlatformChannel {
static const platform = MethodChannel('com.example.myapp/channel');
// iOS 메서드 호출
static Future<int> getBatteryLevel() async {
try {
final int result = await platform.invokeMethod('getBatteryLevel');
return result;
} on PlatformException catch (e) {
print('Failed to get battery level: ${e.message}');
return -1;
}
}
// iOS에 Alert 표시 요청
static Future<void> showNativeAlert(String message) async {
try {
await platform.invokeMethod('showNativeAlert', message);
} on PlatformException catch (e) {
print('Failed to show alert: ${e.message}');
}
}
// iOS에서 오는 메서드 호출 수신
static void setupMethodCallHandler() {
platform.setMethodCallHandler((call) async {
switch (call.method) {
case 'receiveData':
final data = call.arguments as Map<dynamic, dynamic>;
print('Received data from iOS: $data');
return 'Data received successfully';
default:
throw MissingPluginException('Not implemented: ${call.method}');
}
});
}
}
Watch out

채널 이름은 iOS와 Dart 양쪽에서 정확히 일치해야 합니다. MethodChannel은 메인 스레드에서 동작하므로 무거운 작업은 별도 스레드에서 처리 후 결과만 전달하세요. 메모리 누수를 방지하기 위해 [weak self]를 사용하세요.


Chapter 8: 디버깅과 Hot Reload#

Why

add-to-app 환경에서도 Flutter의 디버깅 기능과 Hot Reload를 사용할 수 있습니다. flutter attach 명령으로 실행 중인 앱에 연결하여 개발 생산성을 유지합니다.

What

Xcode에서 앱을 실행한 후 flutter attach로 연결하면 Hot Reload, DevTools, 브레이크포인트를 사용할 수 있습니다. VS Code나 Android Studio에서도 같은 방식으로 디버깅이 가능합니다.

터미널에서 디버깅:

Terminal window
# Flutter 모듈 디렉터리에서 실행
cd /path/to/my_flutter
# 실행 중인 Flutter 앱에 연결
flutter attach
# 특정 디바이스 지정
flutter attach -d <deviceId>

연결 후 사용 가능한 기능:

Syncing files to device iPhone 15 Pro...
7,738ms (!)
To hot reload the changes while running, press "r".
To hot restart (and rebuild state), press "R".

로컬 네트워크 권한 설정 (iOS 14 이상):

Debug 빌드에서 Hot Reload와 DevTools를 사용하려면 로컬 네트워크 권한이 필요합니다.

<!-- Info-Debug.plist -->
<key>NSBonjourServices</key>
<array>
<string>_dartVmService._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Flutter 개발 도구와 연결하기 위해 로컬 네트워크 접근이 필요합니다.</string>
Watch out

로컬 네트워크 권한 설정은 반드시 Debug 빌드에서만 포함하세요. Release 빌드에 이 설정이 포함되면 App Store 심사에서 거부될 수 있습니다. Build Configuration별로 다른 Info.plist를 사용하도록 설정하세요.


Footnotes#

  1. Flutter 모듈은 기존 네이티브 앱에 임베드할 수 있도록 설계된 Flutter 프로젝트입니다.

  2. FlutterEngine은 Dart VM과 Flutter 런타임을 호스팅하는 컨테이너로, Flutter 코드 실행을 담당합니다.

  3. FlutterViewController는 FlutterEngine에 연결되어 Flutter UI를 렌더링하고 사용자 입력을 처리하는 iOS ViewController입니다.

  4. CocoaPods는 Swift와 Objective-C 프로젝트의 의존성 관리 도구로, Flutter 모듈 통합에 사용됩니다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 69편: 기존 iOS 앱에 Flutter 추가
https://moodturnpost.net/posts/flutter/flutter-add-to-app-ios/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차