📱 Mobile
Flutter 프로젝트 개발/운영 환경 분리하기
date
Aug 3, 2023
slug
flutter-flavor
author
status
Public
tags
CI/CD
Flutter
summary
type
Post
thumbnail
category
📱 Mobile
updatedAt
Aug 2, 2023 07:18 PM
아찔한 경험을 하고난 후..
최근 운영 중인 Flutter 서비스를 업데이트 하던 중 결국은 저질러 버렸다… 로컬서버를 바라보게 하고 릴리즈 해버리는 일을… 다시는 이런 일이 없도록 후속 조치를 취하기 위해 가장 먼저 해야할 이슈로 개발/운영 앱으로 환경을 분리하기로 결정하였고 해당 글에서는 소 잃고 외양간 튼튼하게 하는 과정을 담아보려 한다.
Flavor..?
안드로이드에는 Flavor 라는 개념이 있다. 위 글을 보면 자세히 알 수 있는데
빌드 변형을 구성하여 단일 프로젝트에서 다양한 버전의 앱을 만드는 방법과 종속 항목 및 서명 구성을 올바르게 관리하는 방법
이 가장 명쾌한 설명되시겠다. IOS에서는 Build Configurations 라는 이름으로 불리며 스킴(Scheme)과 타겟(Target)으로 해당 기능을 제공하고 있다. Flutter 는 크로스플랫폼 프레임워크이고 현재 운영 중인 프로젝트는 Android, IOS 양쪽 모두에 서비스하고 있으므로 양 쪽 모두에 적절한 조취를 취해주도록 하려 한다.
Flutter 의 최대 장점 중 하나인 잘 만들어진 공식 문서를 기반으로 진행해보도록 하겠다. 그런데 문제가, 유독 flavor 공식문서가 관리가 안되어있는 듯한 느낌? github 코드를 뜯어봐도 가장 최근 코드가 2개월 전, 대부분 2,3년 전이니 약 5시간 삽질한 이 글을 참고하는 것이 낫겠다.
IOS 환경 분리
Build Scheme 생성 및 Configuration 연결
먼저 IOS 부터 시작하자. 일단 Xcode로 이미 존재하는 프로젝트를 열어준다. 그 후 PROJECT의 Runner 에서 Info tab을 선택하고 Configurations 를 복사하여 원하는 이름으로 수정해준다.
나는 개발 버전을 의미하는 -dev 와 실제 제품을 의미하는 -prod로 나누었다.

그 다음에는 만든 Configuration들을 build scheme 에 연결시키자. 여기서 scheme 이 뭔지 짚고 가면, 특정 빌드 환경이 미리 정해진 하나의 묶음이다. 실제 빌드 단계에서는 여러 scheme 중 하나만을 선택할 수 있기에 결과물이 명확히 구분된다는 특징이 있다. 즉, -dev 로는 죽어도 -prod 버전을 생성할 수는 없다는 뜻.
Product > Scheme > Manage Schemes 를 선택하자

그 후 아래의 Duplicate Scheme 버튼을 눌러 Dev 이름의 scheme을 하나 생성해주고 Build Configuration 을 -dev 로 바꿔준다. Prod Scheme 도 물론 동일한 방법으로 생성해준다.
여기서 좀 조심해야 하는 것이 아래 스킴이름을 꼭 내가 설정할 flavor 이름과 맞춰줘야 한다. 그렇지 않으면
Undefined symbol:
OBJC_CLASS
$_FMDatabaseQueue
에러와 씨름하다 포기하고 싶어질지 모른다..
이때 아래의 Run, Test, Profile, Analyze, Archive 등 모두 해당하는 설정으로 바꿔주었는지 꼭 확인하자.

아래와 같이 선택할 수 있다면 성공!

빌드 세팅 설정하기
그 다음 Target 의 Runner, Build Settings 를 선택하고 product 로 검색하여 패키징 명을 각각의 환경에 맞게 변경한다. (-dev 는 .dev를 붙이는 식) 이때 어차피 product 는 기존 패키지 명과 같을 테니까 dev에만 .dev로 패키징 명을 추가해주면 된다.

그 후 이름을 맞춰줄 건데 검색하면 바로 밑에 같이 나오는 Product Name에 각 flavor에 맞는 이름을 추가해준다.

그 다음 Info.plist 의 Bundle display name 에서 해당 속성을 읽을 수 있도록 $(PRODUCT_NAME)으로 바꿔준다.

그 후에 --flavor 뒤에 dev/prod로 구별하여 옵션을 줄 것인데 이 옵션을 정상적으로 빌드과정에 넘겨줄 수 있도록 App-Flavor 옵션을 받을 수 있도록 하자.
왼쪽 위 +를 누르면 사용자 설정 세팅이 가능한데 눌러서 추가해주고

이름을 APP_FLAVOR로 바꿔준뒤 각각 실행해줄 스킴 이름에 맞게 작성해준다.

그 후 info.plist 파일에 직접 추가하던지 아니면 key/type/value를 xcode에서 직접 info에서 추가해주면 된다.

이제 마지막으로 AppDelegate.swift 파일을 수정하여 Method channel을 통해 flavor를 전달할 수 있도록 하면 끝이다.
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
let controller = window.rootViewController as! FlutterViewController
let flavorChannel = FlutterMethodChannel(
name: "flavor",
binaryMessenger: controller.binaryMessenger)
flavorChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
// Note: this method is invoked on the UI thread
let flavor = Bundle.main.infoDictionary?["App - Flavor"]
result(flavor)
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
그럼 위 코드에서 옵션을 읽어 info.plist 딕셔너리의 App - Flavor 옵션에 flavor를 전달해줄 것 이고 해당 flavor에 맞춘 bundle identifier 로 빌드하여 개발환경이 다른 두 어플리케이션을 동시에 관리할 수 있게 된다.
Android 환경분리
이제 Android 다. 먼저 android/app/build.gradle 에 flavorDimensions 와 productFlavors 를 추가해줘야 한다. 나는 dev 와 prod 만 구별하면 되니 아래와 같이 추가해주었다. BuildTypes는 단순히 1차원적으로 타입을 정할 수 있다면, ProductFlavors는 다차원적(multi-dimensional)으로 빌드를 분류할 수 있다. 쉽게 flavorDimensions 로 여러번 , 내부 설정으로도 여러번 나누며 분류할 수 있는 기능이라 할 수 있겠다.
android {
// ...
flavorDimensions "app" // "region" 요렇게 여러개도 추가가능
productFlavors {
dev {
dimension "app"
applicationId "com.codeforchrist.mohim.dev"
resValue "string", "app_name", "데브힘"
}
prod {
dimension "app"
applicationId "com.codeforchrist.mohim"
resValue "string", "app_name", "모힘"
}
// korea {
// dimension = "region"
// applicationIdSuffix = ".korea"
// }
// us {
// dimension = "region"
// applicationIdSuffix = ".us"
// }
}
}
그 다음 android/app/src/main/AndroidManifest.xml 의 아래 항목을 위 flavor 별 이름으로 바꿔 줄 수 있도록 아래와 같이 바꿔준다.

마지막으로 MainActivity에도 설정된 값을 method channels 를 통해 전달 하자.
package com.example.xxx // 자기 패키지 명 안의 코틀린 파일 수정하기
import androidx.annotation.NonNull;
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant
class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine);
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "flavor").setMethodCallHandler {
call, result -> result.success(BuildConfig.FLAVOR)
}
}
}
Config 파일로 서버 환경 분리하기
마지막으로 설정파일을 만들어주어 로컬과 실제 서버로 분리하여주자. 아래와 같이 Config.dart 클래스를 생성해준 후에
import 'dart:io';
import 'package:phonebook/common/const/data.dart' as data;
class Config {
final String baseUrl;
Config._dev()
: baseUrl =
Platform.isAndroid ? data.emulatorIp : data.simulatorIp; // dev url
Config._product() : baseUrl = data.baseUrl; // product url
factory Config(String? _flavor) {
if (_flavor == 'dev') {
instance = Config._dev();
} else if (_flavor == 'prod') {
instance = Config._product();
} else {
throw Exception("Unknown flaver : $_flavor}");
}
return instance;
}
static late final Config instance;
}
main.dart 파일을 다음과 같이 수정해주면 앱을 빌드하기 전에 어떤 flavor 인지 검사한 후 알맞은 baseUrl을 설정해줄 것이다.
void main() async {
// ...
String? flavor =
await const MethodChannel('flavor').invokeMethod<String>('getFlavor');
Config(flavor);
runApp(_App());
}
성공!


마무리
삽질을 꽤나 오래했다. 안드로이드는 생각보다 간단했지만 ios 는 xcode로 설정을 만지는 것이 아직까지는 익숙하지 않다. 그래도 앞으로는 저번과 같은 실수는 없을 것(?)이니까 보람찬 시간이라고 볼 수 있겠다. 다음에는 귀찮은 build 파일을 일일이 만들어서 배포하는 과정을 자동화해보려 한다. 많관부!