📱 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로 나누었다.
notion image
그 다음에는 만든 Configuration들을 build scheme 에 연결시키자. 여기서 scheme 이 뭔지 짚고 가면, 특정 빌드 환경이 미리 정해진 하나의 묶음이다. 실제 빌드 단계에서는 여러 scheme 중 하나만을 선택할 수 있기에 결과물이 명확히 구분된다는 특징이 있다. 즉, -dev 로는 죽어도 -prod 버전을 생성할 수는 없다는 뜻.
Product > Scheme > Manage Schemes 를 선택하자
notion image
그 후 아래의 Duplicate Scheme 버튼을 눌러 Dev 이름의 scheme을 하나 생성해주고 Build Configuration 을 -dev 로 바꿔준다. Prod Scheme 도 물론 동일한 방법으로 생성해준다.
여기서 좀 조심해야 하는 것이 아래 스킴이름을 꼭 내가 설정할 flavor 이름과 맞춰줘야 한다. 그렇지 않으면
Undefined symbol: OBJC_CLASS$_FMDatabaseQueue 에러와 씨름하다 포기하고 싶어질지 모른다..
notion image
이때 아래의 Run, Test, Profile, Analyze, Archive 등 모두 해당하는 설정으로 바꿔주었는지 꼭 확인하자.
notion image
아래와 같이 선택할 수 있다면 성공!
notion image
 

빌드 세팅 설정하기

그 다음 Target 의 Runner, Build Settings 를 선택하고 product 로 검색하여 패키징 명을 각각의 환경에 맞게 변경한다. (-dev 는 .dev를 붙이는 식) 이때 어차피 product 는 기존 패키지 명과 같을 테니까 dev에만 .dev로 패키징 명을 추가해주면 된다.
notion image
그 후 이름을 맞춰줄 건데 검색하면 바로 밑에 같이 나오는 Product Name에 각 flavor에 맞는 이름을 추가해준다.
notion image
그 다음 Info.plist 의 Bundle display name 에서 해당 속성을 읽을 수 있도록 $(PRODUCT_NAME)으로 바꿔준다.
notion image
그 후에 --flavor 뒤에 dev/prod로 구별하여 옵션을 줄 것인데 이 옵션을 정상적으로 빌드과정에 넘겨줄 수 있도록 App-Flavor 옵션을 받을 수 있도록 하자.
왼쪽 위 +를 누르면 사용자 설정 세팅이 가능한데 눌러서 추가해주고
notion image
이름을 APP_FLAVOR로 바꿔준뒤 각각 실행해줄 스킴 이름에 맞게 작성해준다.
notion image
그 후 info.plist 파일에 직접 추가하던지 아니면 key/type/value를 xcode에서 직접 info에서 추가해주면 된다.
notion image
이제 마지막으로 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 flavorDimensionsproductFlavors 를 추가해줘야 한다. 나는 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 별 이름으로 바꿔 줄 수 있도록 아래와 같이 바꿔준다.
notion image
마지막으로 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());
}
 
성공!
notion image
notion image
 

마무리

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

Reference