Flutter 튜토리얼 62편: 배포 준비와 코드 난독화

개요#

Flutter 앱을 스토어에 배포하기 전에 수행해야 할 준비 작업을 알아봅니다. 코드 난독화로 앱을 보호하고, 빌드 플레이버1로 개발/스테이징/프로덕션 환경을 분리하는 방법을 배웁니다.

graph TB subgraph "배포 준비 과정" A[코드 난독화] --> B[리버스 엔지니어링 방지] C[빌드 플레이버] --> D[환경별 분리] E[에셋 최적화] --> F[앱 크기 감소] end

1. 코드 난독화#

Why: 왜 코드 난독화가 필요한가요?#

앱 바이너리를 분석하면 클래스명, 함수명 등을 확인할 수 있습니다. 난독화를 통해 코드를 이해하기 어렵게 만들어 리버스 엔지니어링2을 방지할 수 있습니다.

What: 코드 난독화란 무엇인가요?#

코드 난독화는 컴파일된 Dart 바이너리의 심볼(함수명, 클래스명 등)을 알아보기 어려운 이름으로 대체하는 과정입니다.

원본 코드난독화 후
MaterialAppex
Scaffoldey
MyHomePageez
calculateTotala1

지원되는 빌드 타겟#

타겟지원
apkO
appbundleO
ios / ipaO
macosO
linuxO
windowsO
webX (대신 Minification 적용)

How: 코드 난독화 적용 방법#

기본 난독화 명령#

Terminal window
# Android APK 빌드
flutter build apk --obfuscate --split-debug-info=./symbols/android
# Android App Bundle 빌드
flutter build appbundle --obfuscate --split-debug-info=./symbols/android
# iOS IPA 빌드
flutter build ipa --obfuscate --split-debug-info=./symbols/ios
# macOS 빌드
flutter build macos --obfuscate --split-debug-info=./symbols/macos
# Linux 빌드
flutter build linux --obfuscate --split-debug-info=./symbols/linux
# Windows 빌드
flutter build windows --obfuscate --split-debug-info=./symbols/windows

심볼 파일 구조#

symbols/
├── android/
│ ├── app.android-arm.symbols
│ ├── app.android-arm64.symbols
│ └── app.android-x64.symbols
├── ios/
│ └── app.ios-arm64.symbols
└── macos/
└── app.macos-arm64.symbols

난독화된 스택 트레이스 해석#

크래시 리포트에서 난독화된 스택 트레이스를 받았을 때, 원래 심볼로 변환할 수 있습니다.

Terminal window
# 스택 트레이스 파일과 심볼 파일을 사용하여 원본으로 변환
flutter symbolize \
-i ./crash_stacktrace.txt \
-d ./symbols/android/app.android-arm64.symbols

난독화 맵 생성#

원본 이름과 난독화된 이름의 매핑을 JSON 파일로 생성할 수 있습니다.

Terminal window
flutter build apk \
--obfuscate \
--split-debug-info=./symbols/android \
--extra-gen-snapshot-options=--save-obfuscation-map=./symbols/android/map.json

생성된 맵 파일 예시:

["MaterialApp", "ex", "Scaffold", "ey", "MyHomePage", "ez"]

Watch out: 주의사항#

// 이 코드는 난독화 후 실패할 수 있습니다!
expect(foo.runtimeType.toString(), equals('Foo'));
// 대신 이렇게 사용하세요
expect(foo, isA<Foo>());
  • 심볼 파일 백업 필수: 크래시 분석을 위해 반드시 보관하세요.
  • 리플렉션 주의: runtimeType.toString() 등은 난독화 후 동작이 달라질 수 있습니다.
  • Enum 이름: 현재 Enum 이름은 난독화되지 않습니다.
  • 릴리스 빌드만: 난독화는 릴리스 빌드에서만 작동합니다.
  • 보안의 한계: 난독화는 리소스를 암호화하지 않으며, 완전한 보안을 보장하지 않습니다.

2. 빌드 플레이버 (Android)#

Why: 왜 플레이버가 필요한가요?#

개발, 스테이징, 프로덕션 환경마다 다른 API 엔드포인트, 앱 아이콘, 앱 이름 등을 사용해야 할 때 플레이버가 필요합니다.

What: 플레이버란 무엇인가요?#

플레이버는 동일한 코드베이스에서 다양한 버전의 앱을 빌드할 수 있게 해주는 기능입니다.

graph TB subgraph "빌드 변형 (Build Variants)" A[Product Flavors] --> C[stagingDebug] A --> D[stagingRelease] B[Build Types] --> C B --> D A --> E[productionDebug] A --> F[productionRelease] B --> E B --> F end
구성 요소설명예시
Product Flavor앱의 변형staging, production
Build Type빌드 유형debug, release
Build Variant조합 결과stagingDebug, productionRelease

How: Android 플레이버 설정#

1단계: build.gradle.kts 수정#

android/app/build.gradle.kts
android {
// ... 기존 설정 ...
buildTypes {
getByName("debug") {
// debug 설정
}
getByName("release") {
// release 설정
}
}
// 플레이버 차원 정의
flavorDimensions += "default"
// 플레이버 정의
productFlavors {
create("dev") {
dimension = "default"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
resValue(
type = "string",
name = "app_name",
value = "My App (Dev)"
)
}
create("staging") {
dimension = "default"
applicationIdSuffix = ".staging"
versionNameSuffix = "-staging"
resValue(
type = "string",
name = "app_name",
value = "My App (Staging)"
)
}
create("production") {
dimension = "default"
// production은 기본 applicationId 사용
resValue(
type = "string",
name = "app_name",
value = "My App"
)
}
}
}

2단계: AndroidManifest.xml 수정#

android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
...>
<!-- 앱 내용 -->
</application>
</manifest>

3단계: 플레이버별 아이콘 설정#

android/app/src/
├── main/
│ └── res/
│ └── mipmap-*/
│ └── ic_launcher.png # 기본 아이콘
├── dev/
│ └── res/
│ └── mipmap-*/
│ └── ic_launcher.png # dev 전용 아이콘
├── staging/
│ └── res/
│ └── mipmap-*/
│ └── ic_launcher.png # staging 전용 아이콘
└── production/
└── res/
└── mipmap-*/
└── ic_launcher.png # production 전용 아이콘

4단계: 플레이버 빌드 및 실행#

Terminal window
# 플레이버로 실행
flutter run --flavor dev
flutter run --flavor staging
flutter run --flavor production
# 플레이버로 APK 빌드
flutter build apk --flavor dev
flutter build apk --flavor staging
flutter build apk --flavor production
# 플레이버로 App Bundle 빌드
flutter build appbundle --flavor production

기본 플레이버 설정#

pubspec.yaml
flutter:
default-flavor: dev

이제 flutter run 명령만 입력해도 자동으로 dev 플레이버가 선택됩니다.

플레이버별 에셋 번들링#

pubspec.yaml
flutter:
assets:
- assets/common/
- path: assets/dev/
flavors:
- dev
- path: assets/staging/
flavors:
- staging
- path: assets/production/
flavors:
- production

3. 빌드 플레이버 (iOS)#

iOS 플레이버 설정#

iOS에서는 Xcode의 Scheme과 Configuration을 사용하여 플레이버를 구현합니다.

1단계: Xcode에서 Configuration 생성#

  1. Xcode에서 프로젝트를 엽니다.
  2. Project Navigator에서 Runner 프로젝트를 선택합니다.
  3. Info 탭에서 Configurations를 확인합니다.
  4. Debug, Release를 복제하여 새 Configuration을 만듭니다.
    • Debug-dev, Debug-staging, Debug-production
    • Release-dev, Release-staging, Release-production

2단계: Scheme 생성#

  1. Product > Scheme > Manage Schemes를 선택합니다.
  2. Runner 스킴을 복제하여 각 플레이버별 스킴을 만듭니다.
    • dev, staging, production

3단계: Build Settings 수정#

각 Configuration별로 다른 Bundle Identifier와 Display Name을 설정합니다.

// Build Settings
PRODUCT_BUNDLE_IDENTIFIER = com.example.app$(BUNDLE_ID_SUFFIX)
// User-Defined Settings (각 Configuration별로 다르게 설정)
BUNDLE_ID_SUFFIX = .dev // Debug-dev, Release-dev
BUNDLE_ID_SUFFIX = .staging // Debug-staging, Release-staging
BUNDLE_ID_SUFFIX = // Debug-production, Release-production

4단계: Info.plist 수정#

<!-- ios/Runner/Info.plist -->
<key>CFBundleDisplayName</key>
<string>$(DISPLAY_NAME)</string>

4. Dart 코드에서 플레이버 사용#

플레이버 감지#

import 'package:flutter/foundation.dart';
enum AppFlavor { dev, staging, production }
class FlavorConfig {
static AppFlavor get appFlavor {
// Flutter CLI에서 --flavor 옵션으로 전달된 값을 가져옵니다
const flavor = String.fromEnvironment('FLAVOR', defaultValue: 'production');
switch (flavor) {
case 'dev':
return AppFlavor.dev;
case 'staging':
return AppFlavor.staging;
case 'production':
default:
return AppFlavor.production;
}
}
static String get apiBaseUrl {
switch (appFlavor) {
case AppFlavor.dev:
return 'https://dev-api.example.com';
case AppFlavor.staging:
return 'https://staging-api.example.com';
case AppFlavor.production:
return 'https://api.example.com';
}
}
static bool get enableLogging {
return appFlavor != AppFlavor.production;
}
}

플레이버별 main.dart 분리#

lib/main_dev.dart
import 'package:my_app/app.dart';
import 'package:my_app/config/flavor_config.dart';
void main() {
FlavorConfig.init(AppFlavor.dev);
runApp(const MyApp());
}
// lib/main_staging.dart
import 'package:my_app/app.dart';
import 'package:my_app/config/flavor_config.dart';
void main() {
FlavorConfig.init(AppFlavor.staging);
runApp(const MyApp());
}
// lib/main_production.dart
import 'package:my_app/app.dart';
import 'package:my_app/config/flavor_config.dart';
void main() {
FlavorConfig.init(AppFlavor.production);
runApp(const MyApp());
}

플레이버별 실행 명령#

Terminal window
# 다른 main 파일로 실행
flutter run --flavor dev -t lib/main_dev.dart
flutter run --flavor staging -t lib/main_staging.dart
flutter run --flavor production -t lib/main_production.dart

5. 종합 예제: 배포 준비 체크리스트#

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
void main() {
// 릴리스 모드에서만 에러 핸들링 설정
if (kReleaseMode) {
FlutterError.onError = (details) {
// 크래시 리포팅 서비스로 에러 전송
// Crashlytics, Sentry 등
};
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
debugShowCheckedModeBanner: false, // 릴리스에서 배너 제거
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const DeploymentInfoPage(),
);
}
}
class DeploymentInfoPage extends StatelessWidget {
const DeploymentInfoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('배포 정보')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildInfoCard(
'빌드 모드',
_getBuildMode(),
Icons.build,
),
const SizedBox(height: 12),
_buildInfoCard(
'프로파일 모드',
kProfileMode ? '활성화' : '비활성화',
Icons.speed,
),
const SizedBox(height: 12),
_buildInfoCard(
'릴리스 모드',
kReleaseMode ? '활성화' : '비활성화',
Icons.rocket_launch,
),
const SizedBox(height: 24),
_buildChecklistCard(),
],
),
);
}
String _getBuildMode() {
if (kReleaseMode) return 'Release';
if (kProfileMode) return 'Profile';
return 'Debug';
}
Widget _buildInfoCard(String title, String value, IconData icon) {
return Card(
child: ListTile(
leading: Icon(icon, size: 40),
title: Text(title),
subtitle: Text(value),
),
);
}
Widget _buildChecklistCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'배포 전 체크리스트',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildCheckItem('코드 난독화 적용', kReleaseMode),
_buildCheckItem('심볼 파일 백업', true),
_buildCheckItem('버전 번호 업데이트', true),
_buildCheckItem('API 키 프로덕션용 사용', kReleaseMode),
_buildCheckItem('디버그 로그 제거', kReleaseMode),
_buildCheckItem('테스트 완료', true),
],
),
),
);
}
Widget _buildCheckItem(String text, bool checked) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
checked ? Icons.check_circle : Icons.radio_button_unchecked,
color: checked ? Colors.green : Colors.grey,
),
const SizedBox(width: 8),
Text(text),
],
),
);
}
}

배포 전 체크리스트#

항목설명확인
코드 난독화--obfuscate --split-debug-info 적용[ ]
심볼 파일 백업크래시 분석용 심볼 파일 보관[ ]
버전 번호pubspec.yaml의 version 업데이트[ ]
앱 아이콘모든 해상도의 아이콘 준비[ ]
스플래시 화면플랫폼별 스플래시 화면 설정[ ]
API 키프로덕션 API 키 사용 확인[ ]
디버그 코드 제거print 문, 테스트 코드 제거[ ]
권한 설정필요한 권한만 요청하도록 설정[ ]
테스트릴리스 빌드로 전체 테스트[ ]

마무리#

이번 튜토리얼에서는 Flutter 앱 배포 준비에 필요한 코드 난독화와 빌드 플레이버 설정을 살펴보았습니다.

핵심 요약#

기능핵심 포인트
코드 난독화--obfuscate --split-debug-info 옵션 사용
스택 트레이스 해석flutter symbolize 명령으로 원본 복원
Android 플레이버build.gradle.kts에서 productFlavors 설정
iOS 플레이버Xcode의 Scheme과 Configuration 사용

다음 단계#

  • 63편에서는 Android/iOS 스토어 배포를 다룹니다.
  • 각 플랫폼의 스토어 정책을 미리 확인해보세요.
  • CI/CD 파이프라인에 난독화와 플레이버를 통합해보세요.

참고 자료#

Footnotes#

  1. 빌드 플레이버 (Build Flavor): 동일한 코드베이스에서 다양한 버전의 앱(개발용, 테스트용, 배포용 등)을 빌드할 수 있게 해주는 기능입니다.

  2. 리버스 엔지니어링 (Reverse Engineering): 앱 바이너리를 분석하여 원래 소스 코드나 로직을 파악하는 기술입니다.

공유

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

Flutter 튜토리얼 62편: 배포 준비와 코드 난독화
https://moodturnpost.net/posts/flutter/flutter-deployment-prep/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차