Flutter 튜토리얼 68편: 기존 Android 앱에 Flutter 추가
요약
핵심 요지
- 문제 정의: 기존 Android 앱을 Flutter로 완전히 재작성하는 것은 위험하고 시간이 많이 든다.
- 핵심 주장: Flutter 모듈을 기존 앱에 점진적으로 통합하면 위험을 줄이면서 Flutter의 이점을 얻을 수 있다.
- 주요 근거: Add-to-app 기능으로 Flutter를 화면 단위로 추가하고, FlutterEngine으로 성능을 최적화할 수 있다.
- 실무 기준: AAR 또는 소스 코드 방식으로 통합하며, FlutterActivity나 FlutterFragment로 화면을 표시한다.
- 한계: AndroidX만 지원하며, 일부 아키텍처(mips, x86)는 지원하지 않는다.
문서가 설명하는 범위
- Flutter 모듈 생성과 Android 프로젝트 통합
- AAR과 소스 코드 의존성 방식 비교
- FlutterActivity와 FlutterFragment 사용법
- FlutterEngine 캐싱과 성능 최적화
- Dart와 Android 간 통신 (MethodChannel)
읽는 시간: 25분 | 난이도: 고급
참고 자료
- Add Flutter to existing app - Add-to-app 개요
- Android project setup - Android 프로젝트 설정
- Add Flutter screen - Flutter 화면 추가
- Add Flutter Fragment - Flutter Fragment 추가
- add_to_app samples - 공식 샘플 코드
문제 상황
회사에서 이미 운영 중인 Android 앱이 있습니다. Flutter로 새로운 기능을 개발하고 싶지만, 앱 전체를 다시 작성할 수는 없습니다.
// 기존 Android 앱class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // 수년간 쌓인 Kotlin/Java 코드... }}문제는 다음과 같습니다.
- 기존 앱을 완전히 재작성하면 리스크가 크다.
- 점진적으로 Flutter를 도입할 방법이 필요하다.
- 기존 코드와 Flutter 코드가 공존해야 한다.
해결 방법
Flutter의 “Add-to-app” 기능을 사용하면 기존 앱에 Flutter를 모듈로 추가할 수 있습니다. 화면 단위로 점진적인 마이그레이션이 가능합니다.
챕터 1: Add-to-app 이해하기
Why
NOTE완전한 재작성 대신 점진적 도입이 실용적입니다.
Add-to-app의 장점:
- 기존 코드를 유지하면서 새 기능만 Flutter로 개발
- 팀이 Flutter를 점진적으로 학습
- 위험 분산 (문제 발생 시 롤백 가능)
- 일부 화면만 Flutter로 전환하여 A/B 테스트 가능
What
NOTEAdd-to-app은 두 가지 방식을 지원합니다.
Multi-engine 모드:
- 여러 개의 독립적인 Flutter 인스턴스 실행
- 각 인스턴스가 별도의 Dart 프로그램
- 메모리는 더 사용하지만 완전한 격리
하이브리드 네비게이션:
- 기존 화면과 Flutter 화면을 자유롭게 오가기
- 예: 네이티브 홈 → Flutter 상세 → 네이티브 결제
부분 화면 통합:
- 한 화면에 네이티브 UI와 Flutter UI 공존
- 예: 네이티브 헤더 + Flutter 콘텐츠 영역
How
TIP지원되는 기능:
기능 Android Gradle 자동 빌드 O AAR 빌드 O FlutterEngine API O Java/Kotlin 호스트 앱 O Flutter 플러그인 사용 O Hot reload 디버깅 O 다중 Flutter 인스턴스 O 폴더 구조 예시:
project/├── my_android_app/ # 기존 Android 앱│ ├── app/│ ├── build.gradle│ └── settings.gradle└── my_flutter_module/ # Flutter 모듈├── lib/├── pubspec.yaml└── .android/
Watch out
WARNING제한 사항:
- AndroidX 필수: 기존 앱이 AndroidX를 사용해야 합니다.
- 아키텍처 제한: Flutter는
armeabi-v7a,arm64-v8a,x86_64만 지원합니다.app/build.gradle.kts android {defaultConfig {ndk {// Flutter가 지원하는 아키텍처만 포함abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")}}}
- 단일 Flutter 라이브러리: 여러 Flutter 라이브러리를 하나의 앱에 패키징할 수 없습니다.
챕터 2: Flutter 모듈 생성하기
Why
NOTE기존 앱에 Flutter를 추가하려면 먼저 Flutter 모듈을 생성해야 합니다. 모듈은 일반 Flutter 앱과 비슷하지만, Android/iOS 프로젝트가 숨겨져 있습니다.
What
NOTE
flutter create --template=module명령어로 모듈을 생성합니다.Terminal window # 기존 Android 프로젝트와 같은 레벨에 생성cd some/pathflutter create --template=module my_flutter_module생성되는 구조:
my_flutter_module/├── lib/│ └── main.dart # Flutter 앱 진입점├── pubspec.yaml # 의존성 설정├── test/└── .android/ # 숨겨진 Android 래퍼 프로젝트├── app/├── build.gradle└── Flutter/└── src/main/java/└── io/flutter/plugins/└── GeneratedPluginRegistrant.java
.android/폴더는 Git에서 제외됩니다. 필요할 때flutter pub get으로 재생성됩니다.
How
TIP1단계: 모듈 생성
Terminal window cd /path/to/projectsflutter create --template=module --org com.example my_flutter_modulecd my_flutter_module2단계: Flutter 코드 작성
lib/main.dart import 'package:flutter/material.dart';void main() => runApp(const MyFlutterApp());class MyFlutterApp extends StatelessWidget {const MyFlutterApp({super.key});@overrideWidget build(BuildContext context) {return MaterialApp(title: 'Flutter Module',theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),useMaterial3: true,),home: const MyHomePage(),);}}class MyHomePage extends StatelessWidget {const MyHomePage({super.key});@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('Flutter 화면'),),body: const Center(child: Text('Android에서 Flutter로!',style: TextStyle(fontSize: 24),),),);}}3단계: 독립 실행 테스트
Terminal window # .android 폴더로 이동cd .android# 에뮬레이터에서 실행flutter run
Watch out
WARNING모듈 vs 앱의 차이:
항목 모듈 앱 android/ 폴더 .android/ (숨김) android/ (표시) 독립 실행 가능 (.android/ 사용) 기본 동작 호스트 앱 통합 주 목적 불가 pubspec.yaml 설정:
# 모듈 전용 설정flutter:module:androidX: trueandroidPackage: com.example.my_flutter_moduleiosBundleIdentifier: com.example.myFlutterModule
챕터 3: Android 프로젝트에 통합하기
Why
NOTEFlutter 모듈을 생성했으면 기존 Android 프로젝트에 통합해야 합니다. 두 가지 방식이 있습니다.
- 소스 코드 통합: 개발 중 편리, Flutter SDK 필요
- AAR 통합: 배포에 적합, Flutter SDK 없이 빌드 가능
What
NOTE방식 1: 소스 코드 의존성 (개발용 권장)
settings.gradle.kts // Flutter 모듈 경로 설정setBinding(extra["gradle.settings"]!!)evaluate(File(settingsDir.parentFile, "my_flutter_module/.android/include_flutter.groovy"))// 또는 절대 경로 사용val flutterProjectRoot = file("../my_flutter_module")include(":flutter")project(":flutter").projectDir = File(flutterProjectRoot, ".android/Flutter")include(":app")app/build.gradle.kts dependencies {implementation(project(":flutter"))}방식 2: AAR 의존성 (배포용 권장)
Terminal window # Flutter 모듈에서 AAR 빌드cd my_flutter_moduleflutter build aar이 명령어는 로컬 Maven 저장소를 생성합니다.
How
TIPAndroid Studio에서 통합 (권장):
- Android Studio에서 기존 Android 프로젝트 열기
- File > New > New Project… 선택
- Flutter 선택
- 기존 Flutter 모듈 경로 지정 또는 새로 생성
- Finish 클릭
수동 설정 (소스 코드 방식):
settings.gradle.kts pluginManagement {repositories {google()mavenCentral()gradlePluginPortal()}}dependencyResolutionManagement {repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)repositories {google()mavenCentral()maven("https://storage.googleapis.com/download.flutter.io")}}rootProject.name = "MyAndroidApp"include(":app")// Flutter 모듈 포함val flutterModulePath = file("../my_flutter_module")apply {from("$flutterModulePath/.android/include_flutter.groovy")}app/build.gradle.kts dependencies {implementation(project(":flutter"))}AAR 방식 설정:
settings.gradle.kts dependencyResolutionManagement {repositories {maven("https://storage.googleapis.com/download.flutter.io")maven("../my_flutter_module/build/host/outputs/repo")}}app/build.gradle.kts android {buildTypes {release { }debug { }create("profile") {initWith(getByName("debug"))}}}dependencies {debugImplementation("com.example.my_flutter_module:flutter_debug:1.0")releaseImplementation("com.example.my_flutter_module:flutter_release:1.0")add("profileImplementation", "com.example.my_flutter_module:flutter_profile:1.0")}
Watch out
WARNING빌드 타입 매칭:
Flutter는 debug, profile, release 세 가지 빌드 모드가 있습니다. Android 앱의 빌드 타입과 매칭해야 합니다.
// profile 빌드 타입 추가 필요android {buildTypes {create("profile") {initWith(getByName("debug"))}}}중국 사용자:
storage.googleapis.com대신 미러 사이트를 사용하세요.
챕터 4: Flutter 화면 추가하기 (FlutterActivity)
Why
NOTE통합이 완료되면 Flutter 화면을 표시할 수 있습니다. 가장 간단한 방법은
FlutterActivity1를 사용하는 것입니다.FlutterActivity는 전체 화면을 Flutter로 렌더링합니다.
What
NOTEAndroidManifest.xml에 Activity 등록:
app/src/main/AndroidManifest.xml <application><!-- 기존 Activity들 --><activityandroid:name=".MainActivity"android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter></activity><!-- Flutter Activity 추가 --><activityandroid:name="io.flutter.embedding.android.FlutterActivity"android:theme="@style/LaunchTheme"android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"android:hardwareAccelerated="true"android:windowSoftInputMode="adjustResize" /></application>
How
TIP기본 사용법:
MainActivity.kt import io.flutter.embedding.android.FlutterActivityclass MainActivity : AppCompatActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main)findViewById<Button>(R.id.open_flutter_button).setOnClickListener {// Flutter 화면 열기startActivity(FlutterActivity.createDefaultIntent(this))}}}특정 진입점으로 시작:
// Dart에서 특정 함수를 진입점으로 사용startActivity(FlutterActivity.withNewEngine().initialRoute("/settings") // 초기 라우트 설정.build(this))lib/main.dart void main() => runApp(const MyApp());class MyApp extends StatelessWidget {const MyApp({super.key});@overrideWidget build(BuildContext context) {return MaterialApp(initialRoute: '/',routes: {'/': (context) => const HomePage(),'/settings': (context) => const SettingsPage(),},);}}투명 배경 사용:
startActivity(FlutterActivity.withNewEngine().backgroundMode(FlutterActivityLaunchConfigs.BackgroundMode.transparent).build(this))
Watch out
WARNING성능 고려사항:
withNewEngine()은 매번 새 FlutterEngine을 생성합니다. 첫 프레임까지 시간이 걸릴 수 있습니다.// 느림: 매번 새 엔진 생성FlutterActivity.withNewEngine().build(this)// 빠름: 미리 생성된 엔진 사용 (다음 챕터 참조)FlutterActivity.withCachedEngine("my_engine_id").build(this)테마 설정:
시작 시 흰색 화면을 방지하려면 적절한 테마를 설정하세요.
res/values/styles.xml <style name="LaunchTheme" parent="Theme.AppCompat.NoActionBar"><item name="android:windowBackground">@android:color/white</item></style>
챕터 5: FlutterFragment 사용하기
Why
NOTEActivity가 아닌 Fragment로 Flutter를 표시할 수도 있습니다. Fragment를 사용하면 더 유연한 화면 구성이 가능합니다.
- 탭 레이아웃의 한 탭에 Flutter 표시
- ViewPager의 한 페이지에 Flutter 표시
- 화면의 일부분에만 Flutter 표시
What
NOTE
FlutterFragment2는 Fragment 내에서 Flutter를 렌더링합니다.import io.flutter.embedding.android.FlutterFragmentclass MyActivity : FragmentActivity() {companion object {private const val TAG_FLUTTER_FRAGMENT = "flutter_fragment"}override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_my)// 기존 Fragment가 있는지 확인var flutterFragment = supportFragmentManager.findFragmentByTag(TAG_FLUTTER_FRAGMENT) as? FlutterFragment// 없으면 새로 생성if (flutterFragment == null) {flutterFragment = FlutterFragment.createDefault()supportFragmentManager.beginTransaction().add(R.id.flutter_container, flutterFragment, TAG_FLUTTER_FRAGMENT).commit()}}}
How
TIP레이아웃 설정:
res/layout/activity_my.xml <?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"android:orientation="vertical"><!-- 네이티브 UI --><TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:text="네이티브 헤더" /><!-- Flutter가 렌더링될 영역 --><FrameLayoutandroid:id="@+id/flutter_container"android:layout_width="match_parent"android:layout_height="0dp"android:layout_weight="1" /><!-- 네이티브 UI --><Buttonandroid:id="@+id/native_button"android:layout_width="match_parent"android:layout_height="wrap_content"android:text="네이티브 버튼" /></LinearLayout>다양한 생성 방법:
// 기본 생성val fragment = FlutterFragment.createDefault()// 초기 라우트 지정val fragment = FlutterFragment.withNewEngine().initialRoute("/profile").build<FlutterFragment>()// 캐시된 엔진 사용val fragment = FlutterFragment.withCachedEngine("my_engine").build<FlutterFragment>()// 투명 배경val fragment = FlutterFragment.withNewEngine().transparencyMode(TransparencyMode.transparent).build<FlutterFragment>()ViewPager에서 사용:
class MyPagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {override fun getItemCount() = 3override fun createFragment(position: Int): Fragment {return when (position) {0 -> NativeFragment()1 -> FlutterFragment.createDefault()2 -> AnotherNativeFragment()else -> throw IllegalArgumentException()}}}
Watch out
WARNINGFragment 수명주기 관리:
FlutterFragment는 다른 Fragment와 마찬가지로 수명주기를 따릅니다. 화면 회전이나 백그라운드 전환 시 주의가 필요합니다.
// 설정 변경 시 Fragment 재생성 방지override fun onConfigurationChanged(newConfig: Configuration) {super.onConfigurationChanged(newConfig)// Flutter가 자체적으로 처리}백 버튼 처리:
override fun onBackPressed() {val flutterFragment = supportFragmentManager.findFragmentByTag(TAG_FLUTTER_FRAGMENT) as? FlutterFragmentif (flutterFragment?.onBackPressed() == true) {// Flutter가 백 버튼 처리함return}super.onBackPressed()}
챕터 6: FlutterEngine 캐싱으로 성능 최적화
Why
NOTEFlutter 화면이 처음 로드될 때 시간이 걸립니다. FlutterEngine을 미리 시작(warming up)하면 사용자 경험이 크게 향상됩니다.
시작 시간 비교:
방식 첫 프레임까지 시간 새 엔진 생성 ~1.5초 캐시된 엔진 ~0.3초
What
NOTE
FlutterEngineCache3를 사용하여 엔진을 미리 생성하고 캐싱합니다.// Application 클래스에서 엔진 미리 생성class MyApplication : Application() {companion object {const val ENGINE_ID = "my_flutter_engine"}lateinit var flutterEngine: FlutterEngineoverride fun onCreate() {super.onCreate()// FlutterEngine 생성 및 워밍업flutterEngine = FlutterEngine(this)// Dart 코드 실행 시작flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())// 캐시에 저장FlutterEngineCache.getInstance().put(ENGINE_ID, flutterEngine)}}
How
TIPApplication 설정:
MyApplication.kt import android.app.Applicationimport io.flutter.embedding.engine.FlutterEngineimport io.flutter.embedding.engine.FlutterEngineCacheimport io.flutter.embedding.engine.dart.DartExecutorclass MyApplication : Application() {override fun onCreate() {super.onCreate()// FlutterEngine 미리 생성val flutterEngine = FlutterEngine(this)// Dart 진입점 실행 (main 함수 시작)flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())// 캐시에 등록FlutterEngineCache.getInstance().put("my_engine_id", flutterEngine)}}AndroidManifest.xml <applicationandroid:name=".MyApplication"... >캐시된 엔진 사용:
// FlutterActivity에서startActivity(FlutterActivity.withCachedEngine("my_engine_id").build(this))// FlutterFragment에서val fragment = FlutterFragment.withCachedEngine("my_engine_id").build<FlutterFragment>()초기 라우트와 함께 사용:
// 캐시된 엔진은 이미 Dart 코드가 실행 중이므로// initialRoute 대신 MethodChannel로 통신flutterEngine.navigationChannel.setInitialRoute("/settings")
Watch out
WARNING메모리 관리:
캐시된 엔진은 메모리를 계속 사용합니다. 앱 종료 시 정리가 필요합니다.
class MyApplication : Application() {override fun onTerminate() {super.onTerminate()FlutterEngineCache.getInstance().get("my_engine_id")?.destroy()}}다중 엔진 주의:
여러 개의 엔진을 캐싱하면 메모리 사용량이 증가합니다. 필요한 만큼만 캐싱하세요.
// 여러 엔진 캐싱 시 메모리 사용량 증가FlutterEngineCache.getInstance().put("engine_1", engine1)FlutterEngineCache.getInstance().put("engine_2", engine2)// 각 엔진마다 약 40-50MB 메모리 사용
챕터 7: Android와 Dart 간 통신
Why
NOTEFlutter 화면과 네이티브 Android 코드 간에 데이터를 주고받아야 할 때가 있습니다.
- 네이티브 센서 데이터를 Flutter로 전달
- Flutter에서 네이티브 API 호출
- 양방향 이벤트 전파
What
NOTE
MethodChannel4을 사용하여 양방향 통신이 가능합니다.통신 흐름:
Android (Kotlin/Java) <--MethodChannel--> Flutter (Dart)채널 유형:
채널 용도 MethodChannel 메서드 호출 (요청-응답) EventChannel 이벤트 스트림 (일방향) BasicMessageChannel 단순 메시지
How
TIPDart 측 설정:
lib/main.dart import 'package:flutter/material.dart';import 'package:flutter/services.dart';class MyHomePage extends StatefulWidget {const MyHomePage({super.key});@overrideState<MyHomePage> createState() => _MyHomePageState();}class _MyHomePageState extends State<MyHomePage> {static const platform = MethodChannel('com.example.app/channel');String _batteryLevel = 'Unknown';Future<void> _getBatteryLevel() async {String batteryLevel;try {final int result = await platform.invokeMethod('getBatteryLevel');batteryLevel = 'Battery level: $result%';} on PlatformException catch (e) {batteryLevel = 'Failed: ${e.message}';}setState(() {_batteryLevel = batteryLevel;});}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('MethodChannel 예제')),body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Text(_batteryLevel),ElevatedButton(onPressed: _getBatteryLevel,child: const Text('배터리 레벨 확인'),),],),),);}}Android 측 설정:
// FlutterActivity를 상속하는 경우class MainActivity : FlutterActivity() {private val CHANNEL = "com.example.app/channel"override fun configureFlutterEngine(flutterEngine: FlutterEngine) {super.configureFlutterEngine(flutterEngine)MethodChannel(flutterEngine.dartExecutor.binaryMessenger,CHANNEL).setMethodCallHandler { call, result ->when (call.method) {"getBatteryLevel" -> {val batteryLevel = getBatteryLevel()if (batteryLevel != -1) {result.success(batteryLevel)} else {result.error("UNAVAILABLE", "Battery level not available.", null)}}else -> result.notImplemented()}}}private fun getBatteryLevel(): Int {val batteryManager = getSystemService(BATTERY_SERVICE) as BatteryManagerreturn batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)}}캐시된 엔진에서 MethodChannel 설정:
// Application에서 설정class MyApplication : Application() {override fun onCreate() {super.onCreate()val flutterEngine = FlutterEngine(this)// MethodChannel 설정MethodChannel(flutterEngine.dartExecutor.binaryMessenger,"com.example.app/channel").setMethodCallHandler { call, result ->// 처리 로직}flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())FlutterEngineCache.getInstance().put("my_engine", flutterEngine)}}
Watch out
WARNING채널 이름 규칙:
채널 이름은 고유해야 하며, 역방향 도메인 표기법을 사용합니다.
// 좋은 예const val CHANNEL = "com.example.myapp/battery"const val CHANNEL = "com.example.myapp/settings"// 나쁜 예const val CHANNEL = "channel" // 충돌 가능스레드 주의:
MethodChannel 콜백은 메인 스레드에서 실행됩니다. 무거운 작업은 백그라운드 스레드로 이동하세요.
setMethodCallHandler { call, result ->thread {val data = heavyComputation()runOnUiThread {result.success(data)}}}
한계
이 문서는 Android add-to-app의 기초를 다룹니다. 다음 주제는 별도로 학습해야 합니다.
- 다중 Flutter 인스턴스: FlutterEngineGroup 사용
- 플러그인 통합: 기존 네이티브 플러그인과의 호환성
- ProGuard 설정: 릴리스 빌드 최적화
- 심층 디버깅: flutter attach 사용법
Footnotes
-
FlutterActivity: Flutter 콘텐츠를 전체 화면으로 표시하는 Android Activity이다. ↩
-
FlutterFragment: Android Fragment 내에서 Flutter 콘텐츠를 렌더링하여 더 유연한 화면 구성을 가능하게 한다. ↩
-
FlutterEngineCache: FlutterEngine 인스턴스를 캐싱하여 Flutter 화면의 시작 시간을 단축시키는 싱글톤 클래스이다. ↩
-
MethodChannel: Flutter(Dart)와 네이티브 코드(Kotlin/Swift) 간에 메서드를 호출하고 결과를 반환받는 양방향 통신 채널이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!