하나의 코드 베이스로 iOS와 Android 앱을 동시에 개발할 수 있다는 것은 Flutter의 가장 큰 매력입니다. 하지만 실제 프로덕트를 개발하고 운영하는 과정은 단순히 코드를 작성하는 것에서 끝나지 않습니다. 서비스의 생명주기에는 최소한 개발(development), 품질 검수(QA 또는 Staging), 그리고 실제 운영(production)이라는 세 가지 핵심 환경이 존재하며, 각 환경은 서로 다른 구성과 설정을 요구합니다. 예를 들어, 개발 중에는 내 로컬 머신이나 개발팀 전용 API 서버에 접속해야 하고, QA 단계에서는 테스트용 데이터베이스를 사용하는 스테이징 서버를, 최종 사용자에게 배포되는 앱은 당연히 실제 운영 서버를 바라봐야 합니다. 더 나아가, 각 환경별로 앱 아이콘이나 앱 이름을 다르게 표시하여 현재 실행 중인 앱의 버전을 명확히 구분하고 싶은 요구사항은 매우 흔합니다.
이러한 복잡한 요구사항에 직면할 때마다 코드의 특정 부분을 수동으로 수정하고, 주석을 풀고 잠그는 작업을 반복하는 것은 단순히 비효율적인 것을 넘어, 치명적인 인적 오류(human error)를 유발하는 시한폭탄과도 같습니다. 개발용 API 키가 포함된 앱이 실수로 앱스토어에 배포되거나, 운영 DB를 바라보는 테스트 빌드가 QA팀에 전달되는 끔찍한 상황을 상상해 보셨나요? Flutter의 Flavor는 바로 이 근본적인 문제를 해결하기 위해 탄생한 우아하고 강력한 해답입니다. Flavor를 사용하면 동일한 코드 베이스 내에서 빌드 시점의 설정만으로 여러 '맛'의 앱을 손쉽게 생성할 수 있습니다.
이 글에서는 Flutter Flavor의 개념과 필요성부터 시작하여, Android와 iOS 각 네이티브 플랫폼에 대한 구체적이고 상세한 설정 방법, 그리고 Dart 코드 레벨에서 이를 효과적으로 관리하고 VS Code와 같은 개발 도구에서 개발 생산성을 극대화하는 방법까지, 실무에 즉시 적용할 수 있는 모든 노하우를 총망라하여 설명합니다. 풀스택 개발자의 관점에서 각 설정이 왜 필요한지, 그리고 어떻게 유기적으로 동작하는지에 대한 깊이 있는 분석을 더했습니다. 이 가이드를 끝까지 따라오시면 더 이상 환경 변수 때문에 스트레스받지 않고, 개발부터 배포까지의 전 과정을 자동화하고 안정적으로 관리하는 견고한 시스템을 구축하게 될 것입니다.
1. Flutter Flavor: 개념과 압도적인 필요성
Flavor는 단어 그대로 '맛'이나 '풍미'를 의미합니다. 우리가 아이스크림 가게에서 바닐라, 초콜릿, 딸기 맛 아이스크림을 고를 때, 이들은 모두 '아이스크림'이라는 기본 베이스는 공유하지만 각기 다른 맛, 색, 토핑을 가집니다. Flutter에서의 Flavor도 정확히 같은 개념입니다. 우리 앱의 핵심 로직과 UI 코드(아이스크림 베이스)는 단 하나로 동일하게 유지하면서, 어떤 Flavor로 빌드하느냐에 따라 앱의 세부 설정(맛과 토핑)을 동적으로 변경하는 기법입니다.
그렇다면 왜 우리는 Flavor를 사용해야만 할까요? 수동으로 관리하는 방식과 비교했을 때 Flavor가 제공하는 가치는 명확합니다.
| 항목 | 수동 환경 관리 방식 | Flutter Flavor 기반 관리 방식 |
|---|---|---|
| 환경 전환 | API 주소, 앱 이름 등 코드 내 상수를 직접 수정하고 주석 처리. | 빌드 명령어에 --flavor dev 와 같은 옵션 하나만 추가하면 끝. |
| 인적 오류 가능성 | 매우 높음. 개발용 설정을 커밋하거나 운영 버전에 포함시킬 위험 상존. | 원천적으로 차단. 빌드 시스템이 환경을 분리하므로 실수가 개입할 여지가 없음. |
| 동시 설치 | 불가능. 패키지 이름이 동일하여 개발/운영 앱을 한 기기에 설치 불가. | 가능. Flavor별로 applicationId를 다르게 설정하여 동시 설치 및 테스트 용이. |
| CI/CD 자동화 | 복잡하고 불안정함. 빌드 전 환경에 맞게 소스 코드를 변경하는 스크립트 필요. | 매우 간단하고 안정적. CI/CD 파이프라인에서 빌드 명령어만 다르게 호출하면 됨. |
| 생산성 | 환경 변경 시마다 코드 수정, 확인, 커밋 등 불필요한 시간 소요. | IDE 설정 한 번으로 클릭/단축키로 즉시 다른 환경 실행 가능. 개발 흐름이 끊기지 않음. |
Flavor를 통해 분리할 수 있는 대표적인 설정들은 다음과 같습니다.
- API 엔드포인트: 개발 서버(
dev-api.my.app), 스테이징 서버(stg-api.my.app), 운영 서버(api.my.app)의 각기 다른 URL - 앱 이름(App Name): "My App (Dev)", "My App (QA)", "My App" 과 같이 명확한 구분
- 애플리케이션 ID (Bundle ID):
com.example.myapp.dev,com.example.myapp.qa,com.example.myapp와 같이 설정하여 하나의 기기에 여러 버전의 앱을 동시에 설치 가능하게 함 - 앱 아이콘: 각 환경별로 다른 아이콘(예: 개발용 아이콘에 'DEV' 워터마크 추가)을 사용하여 시각적으로 명확히 구분
- 서드파티 서비스 키: Firebase, Google Maps, Amplitude, Sentry 등 각종 분석/모니터링 툴의 API 키를 환경별로 완벽히 분리
- 기능 플래그(Feature Flags): 특정 환경에서만 새로운 기능을 활성화하거나 비활성화하여 점진적으로 테스트
결론적으로, Flavor를 도입하는 것은 선택이 아닌 필수입니다. 안정성을 향상시키고, 생산성을 증대시키며, 효율적인 테스트 환경을 구축하고, 나아가 유연한 CI/CD 파이프라인을 구축하는 모든 과정의 가장 근본적인 초석이 됩니다. 이제 `dev`(개발용)와 `prod`(운영용) 두 가지 Flavor를 설정하는 과정을 플랫폼별로 아주 상세하게 알아보겠습니다.
2. Android Flavor 설정: build.gradle의 마법
Android에서 Flavor 설정의 심장부는 바로 android/app/build.gradle 파일입니다. 이 파일은 Groovy 또는 Kotlin DSL로 작성된 빌드 스크립트로, Android 빌드 시스템인 Gradle에게 우리 앱을 어떻게 빌드해야 하는지에 대한 모든 지침을 제공합니다. 우리는 이 파일에 우리가 만들고자 하는 Flavor의 종류와 각 Flavor가 가져야 할 구체적인 속성을 체계적으로 정의할 것입니다.
2.1. 핵심 `build.gradle` 파일 수정하기
먼저 여러분의 Flutter 프로젝트에서 android/app/build.gradle 파일을 열어주세요. 수많은 설정들이 보이지만 겁먹을 필요 없습니다. 우리는 android { ... } 블록 내부, 통상적으로 `defaultConfig` 블록 바로 위나 아래에 새로운 코드를 추가할 것입니다.
// android/app/build.gradle
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
compileSdkVersion 34 // 예시 SDK 버전
// ... 기타 설정
// ================== Flavor 설정 시작 ==================
// 1. flavorDimensions는 Flavor들을 그룹화하는 '차원'을 정의합니다.
// 'environment'라는 이름의 차원을 만들어, 이 차원 안에 dev와 prod를 포함시킵니다.
// 이름은 원하는 대로 정할 수 있습니다. (예: 'app', 'mode' 등)
flavorDimensions "environment"
// 2. productFlavors 블록에서 각 Flavor를 구체적으로 정의합니다.
productFlavors {
// 'dev' Flavor 정의
dev {
dimension "environment"
// applicationIdSuffix는 defaultConfig의 applicationId 뒤에 접미사를 붙입니다.
// 예: com.example.my_app -> com.example.my_app.dev
// 이것이 바로 dev와 prod 앱을 한 기기에 '동시에' 설치할 수 있게 해주는 핵심입니다.
applicationIdSuffix ".dev"
// versionNameSuffix는 버전 이름 뒤에 접미사를 붙입니다.
// 예: 1.0.0 -> 1.0.0-dev
// 앱 정보에서 버전을 확인할 때 매우 유용합니다.
versionNameSuffix "-dev"
// resValue는 빌드 시점에 동적으로 Android 리소스(문자열 등)를 생성하는 강력한 기능입니다.
// 여기서는 앱 이름을 'My App Dev'로 설정하기 위해 'app_name'이라는 문자열 리소스를 만듭니다.
resValue "string", "app_name", "My App Dev"
}
// 'prod' Flavor 정의
prod {
dimension "environment"
// 운영 버전은 접미사를 붙일 필요가 없으므로 아무것도 설정하지 않습니다.
// applicationId나 versionName은 defaultConfig의 값을 그대로 상속받습니다.
resValue "string", "app_name", "My App"
}
}
// ================== Flavor 설정 끝 ==================
defaultConfig {
// ...
applicationId "com.example.my_app"
minSdkVersion 21
targetSdkVersion 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
// ...
}
코드 심층 분석:
flavorDimensions "environment": Flavor들을 묶는 그룹, 즉 '차원'을 정의합니다. 'environment'라는 이름의 차원을 만들고, 우리가 정의할dev와prod가 모두 이 차원에 속하도록 할 것입니다. 만약 유료/무료 버전과 개발/운영 환경을 조합해야 한다면flavorDimensions "version", "environment"처럼 여러 차원을 정의하여freeDev,paidProd같은 복잡한 조합도 구성할 수 있습니다. 하지만 대부분의 경우 단일 차원으로 충분합니다.productFlavors { ... }: 이 블록 안에서 실제 Flavor들을 정의합니다. 블록의 이름(dev,prod)이 그대로 Flavor의 이름이 되며, 이 이름은 나중에 빌드 명령어에서 사용됩니다.dimension "environment": 이 Flavor가 'environment' 차원에 속함을 명시합니다. 모든 Flavor는 반드시 하나의 차원에 속해야 합니다.applicationIdSuffix ".dev": 이것이 마법의 핵심입니다. Android는applicationId를 기준으로 앱을 식별합니다. 여기에 접미사를 붙여줌으로써, 운영 버전(com.example.my_app)과 개발 버전(com.example.my_app.dev)을 완전히 다른 앱으로 인식하게 만들어, 한 기기에 동시에 설치할 수 있게 됩니다. QA팀과 개발자가 협업할 때 엄청난 편의성을 제공합니다.versionNameSuffix "-dev": 사용자가 '설정 > 애플리케이션'에서 앱 정보를 볼 때, 버전이 `1.2.5-dev`와 같이 표시됩니다. 버그 리포트를 받을 때 어떤 빌드에서 발생했는지 명확히 알 수 있어 매우 유용합니다.resValue "string", "app_name", "My App Dev": Gradle의 강력한 기능 중 하나로, 빌드 시점에generated.xml과 같은 리소스 파일을 동적으로 생성합니다. 여기서는app_name이라는 이름의 문자열 리소스를 만들고 그 값을My App Dev로 설정합니다. 이 `app_name`을AndroidManifest.xml에서 참조하면 Flavor에 따라 앱 이름이 마법처럼 바뀌게 됩니다. 하드코딩을 피하고 빌드 시스템에 책임을 위임하는 매우 깔끔한 방법입니다.
2.2. `AndroidManifest.xml`과 동적 리소스 연결
이제 `build.gradle`에서 동적으로 생성한 app_name 리소스를 사용하도록 `AndroidManifest.xml` 파일을 수정해야 합니다. 이 작업을 통해 앱 이름이 Flavor에 따라 변경되도록 만들 수 있습니다. Flavor별로 별도의 `AndroidManifest.xml` 파일을 만들 수도 있지만, resValue를 사용하면 단일 파일로 깔끔하게 관리할 수 있어 유지보수성이 훨씬 좋습니다.
android/app/src/main/AndroidManifest.xml 파일을 열고 <application> 태그의 android:label 속성을 찾아 수정합니다.
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.my_app">
<application
android:label="@string/app_name" <!-- 이 부분을 수정합니다. 하드코딩된 이름 대신 리소스를 참조하도록 변경 -->
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
...
android:label="@string/app_name"> <!-- 액티비티의 label도 함께 변경해주는 것이 좋습니다 -->
...
</activity>
...
</application>
</manifest>
기존에는 `android:label="my_app"`과 같이 프로젝트 이름이 하드코딩되어 있었을 것입니다. 이것을 @string/app_name으로 변경하면, 앱은 더 이상 고정된 이름을 사용하지 않고, 빌드 시점에 Gradle이 `build.gradle`의 `resValue` 설정에 따라 생성해준 `app_name` 문자열 리소스를 참조하게 됩니다. 그 결과, dev Flavor로 빌드하면 "My App Dev"가, prod Flavor로 빌드하면 "My App"이 앱의 이름으로 최종 설정됩니다.
2.3. Flavor별 앱 아이콘 분리: 시각적 명확성 확보
개발용 앱과 운영용 앱을 명확히 구분하는 가장 좋은 방법은 아이콘을 다르게 설정하는 것입니다. 예를 들어, 개발용 앱 아이콘에는 'DEV'라는 글자가 새겨진 리본을 추가하여 홈 화면에서 한눈에 알아볼 수 있도록 만들 수 있습니다. 이는 사소해 보이지만, 잘못된 버전의 앱을 열고 테스트하는 실수를 방지하는 데 매우 효과적입니다.
Android의 빌드 시스템은 소스셋(Source Set)이라는 개념을 통해 이를 우아하게 지원합니다. 간단히 말해, Flavor의 이름과 동일한 디렉토리를 만들어 리소스를 분리하면, 빌드 시 해당 Flavor의 리소스를 우선적으로 사용합니다.
1. `android/app/src` 경로 아래에 Flavor 이름과 정확히 일치하는 디렉토리를 생성합니다: dev, prod
2. 각 디렉토리 안에 Android 리소스를 담을 res 폴더를 만듭니다.
3. 각 `res` 폴더 안에, 기존의 `main` 소스셋에 있던 아이콘 폴더들(mipmap-hdpi, mipmap-mdpi 등)을 그대로 복사하고, 내부의 `ic_launcher.png` 파일을 각 Flavor에 맞는 아이콘 이미지로 교체합니다.
최종 디렉토리 구조는 다음과 같아집니다:
android/app/src/
├── main/ <-- 공통 리소스 및 기본값
│ ├── java/
│ ├── res/
│ │ ├── mipmap-hdpi/ic_launcher.png (기본 아이콘, prod용으로 사용 가능)
│ │ └── ... (다른 공통 리소스들)
│ └── AndroidManifest.xml
├── dev/ <-- 'dev' Flavor 전용 리소스
│ └── res/
│ ├── mipmap-hdpi/ic_launcher.png (dev용 아이콘, 'DEV' 리본 포함)
│ ├── mipmap-mdpi/ic_launcher.png
│ ├── mipmap-xhdpi/ic_launcher.png
│ └── ...
└── prod/ <-- 'prod' Flavor 전용 리소스 (선택사항)
└── res/
├── mipmap-hdpi/ic_launcher.png (prod용 아이콘, main과 다를 경우)
└── ...
이렇게 구조를 설정하면, Gradle은 빌드 프로세스 중에 리소스를 병합(merge)합니다. `dev` Flavor로 빌드할 때는 `dev` 디렉토리의 리소스가 `main` 디렉토리의 리소스보다 우선순위가 높게 적용됩니다. 따라서 `dev/res/` 안의 아이콘이 최종 앱에 포함됩니다. 만약 `prod` Flavor로 빌드하는데 `prod` 디렉토리에 해당 리소스가 없다면, Gradle은 자연스럽게 `main` 디렉토리의 리소스를 사용합니다. 따라서 `prod` 디렉토리는 `main`과 다른 리소스를 사용할 때만 만들어주면 됩니다.
이것으로 Android 플랫폼의 Flavor 설정은 완벽하게 마무리되었습니다. 이제 iOS로 넘어가 보겠습니다. 접근 방식이 사뭇 다르지만 원리는 동일합니다.
3. iOS Flavor 설정: Xcode Scheme과 Configuration의 조화
iOS에서 Flavor를 설정하는 것은 Android와는 다른 접근 방식을 필요로 합니다. Android의 `build.gradle`처럼 단일 파일에서 모든 것을 선언적으로 정의하는 대신, Xcode 프로젝트의 Build Configurations와 Schemes라는 두 가지 핵심 개념을 조합하여 사용합니다. 여기에 .xcconfig 파일을 더해 설정을 외부에서 주입하는 방식을 사용하면, 유지보수가 용이한 매우 강력한 환경 분리 시스템을 구축할 수 있습니다. 처음에는 조금 더 복잡하게 느껴질 수 있지만, 각 요소의 역할을 정확히 이해하면 Android만큼이나, 혹은 그 이상으로 체계적인 관리가 가능합니다.
가장 먼저, Flutter 프로젝트의 `ios` 디렉토리를 Xcode에서 열어야 합니다. 터미널에서 프로젝트 루트로 이동한 후 다음 명령어를 실행하세요.
open ios/Runner.xcworkspace
.xcworkspace 파일을 통해 통합 관리됩니다. .xcodeproj를 열면 프로젝트가 제대로 빌드되지 않을 수 있습니다.
3.1. Build Configurations 생성하기
Build Configuration은 특정 목적의 빌드를 위한 설정값들의 집합입니다. 예를 들어, 디버깅을 위한 빌드는 최적화가 덜 된 대신 디버깅 심볼이 포함되고, 배포를 위한 릴리즈 빌드는 코드가 최적화되고 불필요한 정보가 제거됩니다. 기본적으로 Flutter 프로젝트는 `Debug`와 `Release` 두 가지 Configuration을 가지고 있습니다. 우리는 이를 `dev`와 `prod` Flavor에 맞춰 확장할 것입니다.
- Xcode 왼쪽의 프로젝트 네비게이터에서 최상단에 있는 프로젝트 파일인 `Runner`를 선택합니다.
- 중앙 에디터 창에서
PROJECT섹션의 `Runner`를 선택하고, 상단 탭에서 `Info` 탭을 엽니다. - `Configurations` 섹션을 보면 `Debug`와 `Release`가 보일 것입니다.
- 하단의
+버튼을 클릭하고 "Duplicate 'Debug' Configuration"을 선택합니다. 새로 생긴 `Debug Copy`의 이름을Debug-dev로 변경합니다. - 다시
+버튼을 눌러 "Duplicate 'Release' Configuration"을 선택하고, `Release Copy`의 이름을Release-dev로 변경합니다. - 같은 방식으로 `prod` Flavor를 위한 Configuration도 만들어 줍니다. 기존의 `Debug` 이름을
Debug-prod로, `Release` 이름을Release-prod로 변경하면 명확성이 높아집니다.
작업이 완료되면 아래와 같이 4개의 명시적인 Build Configuration이 생성되어야 합니다.
Debug-dev(개발용 디버그 빌드)Release-dev(개발용 릴리즈 빌드)Debug-prod(운영용 디버그 빌드)Release-prod(운영용 릴리즈 빌드)
3.2. `.xcconfig` 파일로 환경 변수 외부화하기
Flavor별로 달라지는 값들(앱 이름, 번들 ID 등)을 Xcode의 Build Settings에 직접 하드코딩하는 것은 좋은 방법이 아닙니다. 설정이 흩어지게 되어 관리가 어렵습니다. 대신, 이러한 변수들을 별도의 설정 파일로 분리하는 것이 훨씬 효율적이며, iOS 개발에서는 .xcconfig (Xcode Configuration) 파일이 바로 이 역할을 수행합니다.
- Xcode의 프로젝트 네비게이터에서
Runner폴더를 우클릭하고New Group을 선택하여Config라는 이름의 그룹(파란색 폴더)을 만듭니다. - 새로 만든
Config그룹을 우클릭하고New File...을 선택합니다. - 필터 창에 `Configuration Settings File`을 검색하여 선택하고 `Next`를 클릭합니다.
- 파일 이름을
Dev.xcconfig로 지정하고, 타겟은 `Runner`가 체크된 상태로 생성합니다. - 같은 방법으로
Prod.xcconfig파일도 생성합니다.
이제 각 `.xcconfig` 파일에 Flavor별 변수를 키-값 형태로 정의합니다.
ios/Runner/Config/Dev.xcconfig 파일 내용:
// 앱 이름 (Display Name)
APP_NAME = My App Dev
// 번들 ID 접미사 (Product Bundle Identifier Suffix)
BUNDLE_ID_SUFFIX = .dev
// 앱 아이콘 에셋 카탈로그 이름
// Xcode 14 이상에서는 ASSETCATALOG_COMPILER_APPICON_NAME 대신 이 값을 사용합니다.
ASSETCATALOG_COMPILER_PRIMARY_APP_ICON_SET_NAME = AppIcon-Dev
// 이 부분은 CocoaPods를 사용할 때 필수입니다.
// Flutter가 생성한 기본 설정 파일을 포함하여 Pods 라이브러리가 올바르게 연결되도록 합니다.
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
ios/Runner/Config/Prod.xcconfig 파일 내용:
// 앱 이름 (Display Name)
APP_NAME = My App
// 번들 ID 접미사 (운영 버전은 접미사가 없으므로 비워둡니다)
BUNDLE_ID_SUFFIX =
// 앱 아이콘 에셋 카탈로그 이름
ASSETCATALOG_COMPILER_PRIMARY_APP_ICON_SET_NAME = AppIcon
// 이 부분은 CocoaPods를 사용할 때 필수입니다.
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
이제 이 `.xcconfig` 파일들을 우리가 앞에서 만든 Build Configuration에 연결해야 합니다.
- 다시
PROJECT-> `Runner` -> `Info` 탭의 `Configurations` 섹션으로 돌아갑니다. - 각 Configuration의 `Based on Configuration File` 열을 보면 `None`으로 되어있을 것입니다. 이 부분을 클릭하여 방금 만든 `.xcconfig` 파일을 지정해줍니다.
Debug-dev: Dev.xcconfigRelease-dev: Dev.xcconfigDebug-prod: Prod.xcconfigRelease-prod: Prod.xcconfig
이제 각 Build Configuration은 해당하는 `.xcconfig` 파일에 정의된 변수들을 기본값으로 가지게 됩니다.
3.3. Xcode Scheme 생성 및 설정
Scheme은 어떤 타겟을, 어떤 Build Configuration으로 빌드, 실행(Run), 테스트(Test), 프로파일(Profile), 아카이브(Archive)할지를 정의하는 하나의 '실행 계획'입니다. 우리는 `dev`와 `prod` Flavor를 위한 별도의 Scheme을 만들어, Flutter CLI가 --flavor 옵션을 이해할 수 있도록 할 것입니다.
- Xcode 상단 메뉴 바에서 현재 Scheme 이름(기본값: `Runner`) 옆의 디바이스 선택 부분을 클릭하고, 드롭다운 메뉴에서 `Manage Schemes...`를 선택합니다.
- 기존의 `Runner` Scheme을 선택하고, 창 하단의 톱니바퀴 아이콘을 클릭한 뒤 `Duplicate`를 선택합니다.
- 새로 복제된 `Runner copy`를 더블클릭하여 이름을 `dev`로 변경합니다.
- 기존 `Runner` Scheme의 이름은 `prod`로 변경합니다. 이렇게 하면 두 개의 명확한 Scheme이 생깁니다.
- 이제 각 Scheme을 편집하여 올바른 Build Configuration을 사용하도록 설정해야 합니다.
- `dev` Scheme을 선택하고 `Edit...` 버튼을 클릭합니다.
- 왼쪽 목록에서 각 액션(Action)을 선택하고, 오른쪽의 `Build Configuration`을 우리가 만든 dev용 Configuration으로 변경합니다.
Run:Debug-devTest:Debug-devProfile:Release-devAnalyze:Debug-devArchive:Release-dev
- `Close`를 눌러 저장합니다.
- `prod` Scheme에 대해서도 동일한 과정을 반복하여, 모든 액션에 `prod`용 Configuration(`Debug-prod`, `Release-prod`)을 할당합니다.
이 작업을 통해 `flutter run --flavor dev` 명령을 실행하면 Flutter 툴은 `dev` Scheme을 찾아 실행하고, 이 Scheme은 `Debug-dev` Configuration을 사용하며, 이 Configuration은 `Dev.xcconfig` 파일을 로드하여 빌드 설정을 구성하게 되는 완벽한 연결고리가 완성됩니다.
3.4. Build Settings에서 `.xcconfig` 변수 연결하기
이제 `.xcconfig`에 정의한 변수들을 실제 프로젝트 설정(Build Settings)에 연결하여 적용될 수 있도록 만들 차례입니다.
PROJECT섹션이 아닌TARGETS섹션의 `Runner`를 선택하고 `Build Settings` 탭으로 이동합니다.- 검색창에 `Product Bundle Identifier`를 검색합니다.
- 값을 더블클릭하여
$(PRODUCT_BUNDLE_IDENTIFIER)$(BUNDLE_ID_SUFFIX)로 수정합니다.$(...)구문은 Xcode Build Settings에서 다른 설정이나 변수 값을 참조하는 문법입니다. 이렇게 하면 기본 번들 ID 뒤에 `.xcconfig`에 정의된 `BUNDLE_ID_SUFFIX` 값이 동적으로 붙게 됩니다. (`prod`의 경우 빈 문자열이 붙으므로 원래 ID가 유지됩니다.) - 홈 화면에 표시되는 앱 이름은 `Info.plist` 파일의 `Bundle display name` 값을 따릅니다. 네비게이터에서
Runner/Info.plist파일을 열고, `Bundle display name` 키의 값을$(APP_NAME)으로 변경합니다. 만약 해당 키가 없다면 새로 추가하면 됩니다. 이 설정은 `.xcconfig` 파일의 `APP_NAME` 변수 값을 참조하게 됩니다.
3.5. Flavor별 앱 아이콘 설정 (iOS)
iOS에서도 Android와 유사하게 Flavor별로 다른 앱 아이콘을 설정할 수 있습니다. 이는 Asset Catalog을 활용하여 간단하게 구현할 수 있습니다.
- 프로젝트 네비게이터에서
Runner/Assets.xcassets를 선택합니다. - 기존에 있던 `AppIcon` 에셋을 우클릭하고 `Duplicate`를 선택하여 복사본을 만듭니다.
- 복사된 에셋의 이름을
AppIcon-Dev로 변경합니다. (이 이름은Dev.xcconfig파일의 `ASSETCATALOG_COMPILER_PRIMARY_APP_ICON_SET_NAME` 값과 정확히 일치해야 합니다.) - 새로 생성된 `AppIcon-Dev`를 선택하고, 준비된 개발용 아이콘 이미지들을 각 사이즈에 맞게 드래그 앤 드롭으로 채워 넣습니다.
- 마지막으로, 이 설정이 동적으로 적용되도록 Build Settings를 수정합니다.
TARGETS> `Runner` > `Build Settings` 탭으로 돌아갑니다. - 검색창에 `Primary App Icon Set Name`을 검색합니다. (구버전 Xcode에서는 `Asset Catalog App Icon Set Name`일 수 있습니다.)
- 이 설정의 값을
$(ASSETCATALOG_COMPILER_PRIMARY_APP_ICON_SET_NAME)으로 변경합니다.
이제 모든 준비가 끝났습니다. 빌드 시점에 활성화된 Scheme에 따라 `.xcconfig` 파일이 선택되고, 그 파일에 정의된 아이콘 셋 이름 변수에 따라 Xcode는 `AppIcon` 또는 `AppIcon-Dev` 에셋을 앱의 최종 아이콘으로 사용하게 됩니다.
여기까지 따라오셨다면, Android와 iOS 양쪽 플랫폼에서 Flavor를 위한 네이티브 레벨의 모든 설정이 완료되었습니다. 하지만 아직 가장 중요한 단계가 남았습니다. 이 모든 네이티브 설정을 Flutter의 Dart 코드에서 어떻게 인지하고 활용하여 API 주소와 같은 로직을 분기할 수 있을까요?
4. Dart 코드에서 Flavor 분기 처리하기
플랫폼 레벨에서 앱 이름이나 아이콘을 바꾸는 것도 매우 중요하지만, Flavor의 진정한 힘은 Dart 코드 내에서 API 서버 주소, 서드파티 서비스 키, 기능 플래그(Feature Flags)와 같은 핵심 로직을 분기 처리하는 데에서 발휘됩니다. 이를 위해 앱의 진입점(entrypoint)을 Flavor별로 분리하고, 현재 환경에 맞는 설정을 담은 객체를 앱 전체에 주입(inject)하는 깔끔하고 확장 가능한 아키텍처를 사용할 것입니다.
4.1. 환경 설정 클래스 추상화 및 구현
먼저, `lib` 디렉토리 아래에 환경 설정을 체계적으로 관리할 파일을 만듭니다. 예를 들어, `lib/config/` 디렉토리를 만들고 그 안에 `app_config.dart` 파일을 생성하는 것이 좋은 구조입니다.
우리는 추상 클래스(또는 인터페이스)를 사용하여 각 환경 설정이 반드시 가져야 할 속성들을 정의하고, 각 Flavor에 맞는 구상 클래스를 만들어 이를 구현할 것입니다. 이는 객체 지향의 다형성을 활용하는 매우 좋은 패턴입니다.
// lib/config/app_config.dart
// 각 Flavor를 대표하는 enum. 코드 내에서 타입을 안전하게 확인하기 위해 사용합니다.
enum Flavor {
dev,
prod,
}
// 모든 환경 설정 클래스가 구현해야 할 '계약'을 정의하는 추상 클래스
abstract class AppConfig {
// abstract getter들을 선언
String get baseUrl;
String get appTitle;
Flavor get flavor;
// 모든 환경에서 동일한 값은 여기서 바로 구현 가능
String get termsOfServiceUrl => 'https://my.app/terms';
}
// 개발 환경용 설정 클래스
class DevConfig implements AppConfig {
@override
String get baseUrl => 'https://dev-api.myapp.com/v1/';
@override
String get appTitle => 'My App (Dev)';
@override
Flavor get flavor => Flavor.dev;
}
// 운영 환경용 설정 클래스
class ProdConfig implements AppConfig {
@override
String get baseUrl => 'https://api.myapp.com/v1/';
@override
String get appTitle => 'My App';
@override
Flavor get flavor => Flavor.prod;
}
4.2. Flavor별 main 진입점(Entrypoint) 파일 생성
모든 Flutter 앱은 `lib/main.dart` 파일의 `main()` 함수에서 실행을 시작합니다. 우리는 이 단일 진입점 구조를 깨고, 각 Flavor를 위한 별도의 진입점 파일을 만들 것입니다. 이 방식을 통해 빌드 시점에 어떤 설정을 주입할지 결정할 수 있습니다.
- 기존의
lib/main.dart파일의 이름을lib/main_common.dart로 변경합니다. 이 파일은 모든 Flavor가 공유하는 공통 앱 실행 로직을 담게 됩니다. main_common.dart의main()함수 (이제는mainCommon()으로 이름을 변경)가 우리가 만든 `AppConfig` 객체를 인자로 받도록 수정합니다.
// lib/main_common.dart
import 'package:flutter/material.dart';
import 'package:my_app/config/app_config.dart';
// AppConfig를 인자로 받는 공통 main 함수
// 이 함수는 앱의 실제 실행 로직을 담당합니다.
void mainCommon(AppConfig config) {
// 이 시점에서 우리는 어떤 Flavor로 앱이 실행되었는지 명확히 알 수 있습니다.
// 여기서 config 객체를 사용하여 앱의 초기 설정을 수행합니다.
// 예를 들어, 서비스 로케이터(GetIt)나 DI 컨테이너(Provider)에 등록하여
// 앱 전역에서 현재 환경 설정에 안전하게 접근할 수 있도록 합니다.
// 예시: GetIt에 AppConfig 인스턴스 등록
// GetIt.I.registerSingleton(config);
print('==================================================');
print('🚀 App is running in ${config.flavor.name.toUpperCase()} mode!');
print(' Bsse URL: ${config.baseUrl}');
print('==================================================');
runApp(MyApp(config: config));
}
class MyApp extends StatelessWidget {
final AppConfig config;
const MyApp({Key? key, required this.config}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: config.appTitle, // 설정 객체에서 앱 제목을 가져와 사용
theme: ThemeData(
primarySwatch: config.flavor == Flavor.dev ? Colors.orange : Colors.blue,
),
home: MyHomePage(title: config.appTitle),
);
}
}
class MyHomePage extends StatelessWidget {
final String title;
const MyHomePage({Key? key, required this.title}) : super(key: key);
@override
Widget build(BuildContext context) {
// 만약 GetIt을 사용했다면 아래와 같이 접근
// final appConfig = GetIt.I();
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
// ...
),
);
}
}
3. 이제 각 Flavor를 위한 실제 진입점 파일을 생성합니다.
lib/main_dev.dart 파일 생성:
// lib/main_dev.dart
import 'package:flutter/widgets.dart';
import 'package:my_app/config/app_config.dart';
import 'package:my_app/main_common.dart';
void main() {
// 앱 실행 전 필요한 초기화 작업 (예: Flutter 엔진 바인딩)
WidgetsFlutterBinding.ensureInitialized();
// DevConfig 인스턴스를 생성하여 공통 main 함수에 전달합니다.
// 이 파일이 실행되면 우리 앱은 '개발' 환경으로 동작하게 됩니다.
mainCommon(DevConfig());
}
lib/main_prod.dart 파일 생성:
// lib/main_prod.dart
import 'package:flutter/widgets.dart';
import 'package:my_app/config/app_config.dart';
import 'package:my_app/main_common.dart';
void main() {
// 앱 실행 전 필요한 초기화 작업
WidgetsFlutterBinding.ensureInitialized();
// ProdConfig 인스턴스를 생성하여 공통 main 함수에 전달합니다.
// 이 파일이 실행되면 우리 앱은 '운영' 환경으로 동작하게 됩니다.
mainCommon(ProdConfig());
}
이 구조를 통해 어떤 `main_*.dart` 파일을 실행 진입점으로 지정하느냐에 따라, 앱의 모든 동작과 설정을 완벽하게 제어할 수 있게 되었습니다. mainCommon 함수 내에서 `AppConfig` 객체를 Provider, Riverpod, GetIt과 같은 상태 관리/DI 도구를 통해 등록하면, 앱 내 어느 위젯이나 서비스 클래스에서든 현재 Flavor에 맞는 설정값(`baseUrl` 등)에 타입-세이프(type-safe)하고 안전하게 접근할 수 있습니다. 더 이상 전역 변수나 싱글톤 패턴에 의존할 필요가 없습니다.
5. Flavor 실행 및 빌드하기: 터미널과 VS Code
이제 모든 플랫폼별, 그리고 Dart 코드 레벨의 설정이 끝났습니다. 마지막으로, 특정 Flavor를 지정하여 앱을 실행하고 빌드하는 방법을 알아볼 차례입니다. 이 과정이야말로 우리가 구축한 시스템의 가치를 체감하고 개발 생산성을 극적으로 끌어올리는 단계입니다.
5.1. 터미널(CLI)에서 실행 및 빌드
Flutter CLI는 Flavor를 다루기 위한 두 가지 핵심 옵션을 제공합니다. 이 두 옵션을 조합하여 우리가 원하는 모든 작업을 수행할 수 있습니다.
--flavor <flavor_name>: 사용할 네이티브 Flavor를 지정합니다. 이 이름은 Android의 `productFlavor` 이름(dev,prod), 그리고 iOS의 `Scheme` 이름(dev,prod)과 정확히 일치해야 합니다.-t <dart_file_path>(또는--target=<dart_file_path>): 앱의 진입점으로 사용할 Dart 파일의 경로를 지정합니다.
개발(dev) Flavor로 앱 실행 (디버그 모드):
flutter run --flavor dev -t lib/main_dev.dart
이 명령을 실행하면, Flutter는 dev Scheme(iOS) 또는 `dev` productFlavor(Android)를 사용하여 네이티브 앱을 빌드하고, Dart 코드의 진입점으로는 lib/main_dev.dart를 사용하여 앱을 실행합니다.
운영(prod) Flavor로 앱 실행 (디버그 모드):
flutter run --flavor prod -t lib/main_prod.dart
운영(prod) Flavor로 Android 앱(APK) 빌드 (릴리즈 모드):
flutter build apk --release --flavor prod -t lib/main_prod.dart
운영(prod) Flavor로 iOS 앱(IPA) 빌드 (릴리즈 모드):
flutter build ipa --release --flavor prod -t lib/main_prod.dart
CI/CD 파이프라인(GitHub Actions, Codemagic, Jenkins 등)을 구축할 때 바로 이 빌드 명령어들을 사용하게 됩니다. 브랜치나 태그에 따라 다른 명령어를 실행하도록 설정하면 빌드 자동화가 완성됩니다.
5.2. VS Code에서 실행 설정 (`launch.json`)으로 생산성 극대화
매번 터미널에 긴 명령어를 입력하는 것은 매우 번거롭고 개발 흐름을 방해합니다. 다행히 VS Code는 .vscode/launch.json 파일을 통해 디버그 및 실행 설정을 저장하고 재사용하는 강력한 기능을 제공합니다. 이 파일을 설정하면 마우스 클릭 한 번, 또는 단축키(F5) 한 번으로 원하는 Flavor를 즉시 실행하고 디버깅할 수 있습니다.
- VS Code의 왼쪽 액티비티 바에서 `Run and Debug` 탭(단축키: Ctrl+Shift+D)으로 이동합니다.
- 상단에 "RUN AND DEBUG" 버튼 아래에 `create a launch.json file`이라는 링크가 보이면 클릭합니다. 만약 파일이 이미 있다면, 드롭다운 메뉴에서 "Add Configuration..."을 선택합니다.
- 환경 선택 창이 뜨면 "Dart & Flutter"를 선택합니다. 그러면
.vscode/launch.json파일이 생성되거나 열립니다. - 아래와 같이
configurations배열에 각 Flavor에 대한 실행 설정을 객체 형태로 추가합니다.
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "🚀 Launch DEV",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"program": "lib/main_dev.dart",
"args": [
"--flavor",
"dev"
]
},
{
"name": "📦 Launch PROD",
"request": "launch",
"type": "dart",
"flutterMode": "debug",
"program": "lib/main_prod.dart",
"args": [
"--flavor",
"prod"
]
},
{
"name": "✅ Profile DEV",
"request": "launch",
"type": "dart",
"flutterMode": "profile",
"program": "lib/main_dev.dart",
"args": [
"--flavor",
"dev"
]
}
]
}
이제 `Run and Debug` 탭의 상단 드롭다운 메뉴를 클릭하면 방금 설정한 "🚀 Launch DEV", "📦 Launch PROD" 와 같은 이름들이 나타납니다. 원하는 설정을 선택하고 F5 키를 누르거나 녹색 재생 버튼을 클릭하면, VS Code가 내부적으로 해당 설정에 맞는 `flutter run` 명령어를 실행하여 디버깅 세션을 시작합니다. 이로써 개발 생산성이 비약적으로 향상되며, 더 이상 환경 전환에 대해 신경 쓸 필요가 없어집니다.
결론: 안정적이고 확장 가능한 앱 개발의 초석
지금까지 Flutter Flavor를 사용하여 개발, 운영 환경을 완벽하게 분리하는 전 과정을 매우 상세하게 살펴보았습니다. Android의 `build.gradle`부터 iOS의 `Build Configurations`, `.xcconfig`, `Schemes`, 그리고 Dart 코드 레벨에서의 추상화를 통한 분기 처리와 IDE 연동까지, 처음에는 다소 복잡하고 손이 많이 가는 과정처럼 보일 수 있습니다. 하지만 한 번 이 견고한 시스템을 제대로 구축해두면 그로부터 얻는 안정성과 생산성의 가치는 실로 엄청납니다.
Flavor는 단순히 편의를 위한 기능이 아닙니다. 이것은 수동 설정 변경으로 인한 치명적인 실수를 원천적으로 방지하고, 여러 환경을 동시에 관리해야 하는 복잡성을 체계적으로 해결하며, 자동화된 빌드 및 배포(CI/CD) 파이프라인의 기반을 마련하는, 프로페셔널한 앱 개발의 핵심적인 실천 방법(Best Practice)입니다.
오늘 이 가이드에서 배운 내용을 여러분의 현재 또는 다음 프로젝트에 바로 적용해 보십시오. 더 이상 "어, 이거 개발 서버였네" 하는 아찔한 순간 없이, 오로지 비즈니스 로직과 사용자 경험을 만드는 데에만 집중할 수 있게 될 것입니다. Flavor는 여러분의 Flutter 개발 워크플로우를 한 단계 더 높은 수준으로 끌어올려 줄 가장 강력한 무기 중 하나입니다.
Post a Comment