Custom Android Gradle Plugin을 통한 Product Flavor 확장성 있게 구성하기
Android 프로젝트를 진행하다 보면, 다양한 환경과 요구사항에 맞춰 여러 버전의 앱을 빌드해야하는 경우가 흔하게 발생합니다. 예를 들어 개발(dev), 스테이징(staging), 운영(prod)과 같이 환경 별로 다른 API 엔드포인트를 제공해야 하는 경우가 존재하고, 앱을 분류하기 위해 다른 이름을 사용하거나 환경별로 별도의 키를 사용해야 하는 케이스들이 존재합니다.
이러한 상황에서 배포를 수행할 때마다 코드를 복사 붙혀넣기 하고, 각각의 케이스마다 분기하여 처리하는 방식은 비효율적이며, 궁극적으로 유지보수의 큰 어려움을 초래합니다. 이러한 방식은 버그를 발생시킬 확률이 굉장히 높이며, 새로운 기능이 추가되거나 변경 시에 귀찮은 부수작업으로 이어지게 됩니다.
이러한 문제들을 해결하고 Android 개발의 유연성과 효율성을 극대화하기 위해 Android 빌드 시스템은 ProductFlavor
를 제공합니다. ProductFlavor
는 앞서 예시로 제공한 개발,
스테이징, 운영과 같이 다양한 버전을 생성하고 손쉽게 관리할 수 있도록 도와주는 기능으로 환경별 설정을 효과적으로 분리할 수 있게 해줍니다.
일반적으로 ProductFlavor
를 설정하는 방식은 버전 처리를 하고자 하는 모듈에 Gradle 스크립트를 구성하면 됩니다. 예시로 App 모듈을 여러 버전으로 처리하기 위해 아래와 같은 스크립트를
구성하였습니다. 해당 에서스크립트는 dev, staging, prod에 따른 환경을 구성하고, 그에 따른 전용 엔드포인트와 시크릿을 연동하였습니다.
android {
productFlavors {
create("dev") {
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
resValue("string", "app_name", "My App (Dev)")
buildConfigField("String", "BASE_URL", "\"https://dev.api.example/\"")
buildConfigField("String", "SECRET_KEY", "\"1!2@3#4\$_dev\"")
}
create("staging") {
applicationIdSuffix = ".staging"
versionNameSuffix = "-staging"
resValue("string", "app_name", "My App (Staging)")
buildConfigField("String", "BASE_URL", "\"https://staging.api.example/\"")
buildConfigField("String", "SECRET_KEY", "\"1!2@3#4\$_staging\"")
}
create("prod") {
resValue("string", "app_name", "My App")
buildConfigField("String", "BASE_URL", "\"https://api.example/\"")
buildConfigField("String", "SECRET_KEY", "\"1!2@3#4$\"")
}
}
}
dependencies{
...
}
Gradle 빌드 후 Android Studio에서 빌드 변형(Build Variants) 메뉴를 확인해 보면 빌드 종류가 분기된 것을 확인하실 수 있습니다.
결과에 대해 부연 설명을 하자면 Android는 Product Flavor
외에도 Build Type
이라는 기능이 존재합니다. 흔히 알고 있는 Debug, Release가 이에 해당됩니다.
Product Flavor
가 어떤 버전의 앱을 만들 것인가를 의미한다면, Build Type
은 그 버전을 어떻게 만들 것인가를 의미합니다. 예를 들어 Debug 빌드 타입에는 디버깅 기능과 최적화를 하지
않고 빠르게 빌드해서 결과를 확인하도록 설정할 수 있고, Release 타입에는 디버깅을 제외하고 최적화 및 서명을 하여 안전하고 성능이 향상된 앱을 만들어낼 수 있습니다.
중요한 점은 Product Flavor와 Build Type이 조합되어 최종적으로 빌드 변형(Build Variants)을 생성해 낸다는 점입니다. 위의 예시는 Product Falvor( dev,staging,prod) 3개와 Build Type(Debug,Release) 2개가 조합하여 총 6개의 빌드 변형을 만들어 내었습니다. 사례로, Product 버전에서의 앱을 디버깅을 하고 싶다면 prodDebug를 사용하면 될 것이고, 반대로 개발 중 최적화 시 성능이 어느 정도 나오는지 체크하거나 난독화가 문제없이 잘 동작하는지 확인 할때는 devRelease를 활용할 수 있습니다.
Product Flavor
Product Flavor
에는 다양하게 적용할 수 있는 필드들이 있어, 유연하게 Android 애플리케이션을 설정할 수 있습니다. 앞서 예시에도 다양한 필드를 사용하였지만, 실제로 어떠한 필드가 있는지 간략하게
다루어보겠습니다.
applicationId
앱의 고유한 패키지 이름을 빌드에 따라 다르게 설정할 수 있습니다. 이 설정을 사용할 경우 PlayStore나 kakao 또는 Firebase같은 서드 파티툴에 각각 다르게 applicationId를 설정하여 구성할 수 있게 됩니다.
하지만, 일반적인 경우에는 환경에 따라 applicationId가 크게 바뀌지 않기 때문에 applicationIdSuffix 라는 필드를 사용하여 패키지 뒤에 접미사만 추가할 수 있습니다.
create("dev") {
applicationIdSuffix = ".dev"
}
versionCode
앱의 내부 버전 번호입니다. 흔히 PlayStore에 앱을 신규 업데이트하여 제출할 때 번호를 올려 제출하게 되는데, 해당 값 또한 마찬가지로 환경에 따라 변경할 수 있습니다.
versionName, versionNameSuffix
사용자에게 표시되는 앱의 버전 이름입니다. (1.0.0, 1.4.9-alpha, …) applicationId와 같이 suffix를 추가할 수 있는 필드가 존재합니다.
minSdk, targetSdk
각각 앱이 실행될 수 있는 최소 Android API 레벨과 앱이 대상으로 하는 Android API 레벨 입니다.
dimension
dimension은 여러 가지 독립적인 차원을 사용하여 Product Flavor
를 그룹화하고 조합할 수 있게 해주는 기능입니다. 예를 들어, 앱이 무료 버전과 유료 버전이 있다고 가정하였을 때,
Product Flavor
만을 사용한다면 freeDev, freeStaging, freeProd, premiumDev, premiumStaging, premiumProd와 같이 6개의 조합을 생성해야만 합니다.
하지만 dimension 필드에 새로운 차원을 생성하고 그 차원에 free와 premium을 정의한다면, 자동으로 6개의 경우의 수를 생성하여 간편하고 유연하게 빌드 변형을 구성할 수 있게 됩니다.
아래의 예시는 falvorDimensions를 통해 환경(evrionment)에 대한 차원과 티어(tier)에 대한 차원을 구성하고, 각 차원 별로 productFlavor를 설정합니다. 해당 설정을 적용한 후 빌드 변형을 확인해 보면, 총 12가지의 빌드 변형이 생성되게 됩니다.
buildType(Debug,Release) * environment(Dev,Staging,Prod) * tier(Free,Premium)
android {
...
flavorDimensions += listOf("environment", "tier")
productFlavors {
create("free") {
dimension = "tier"
applicationIdSuffix = ".free"
versionNameSuffix = "-free"
}
create("premium") {
dimension = "tier"
applicationIdSuffix = ".premium"
versionNameSuffix = "-premium"
}
create("dev") {
dimension = "environment"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
resValue("string", "app_name", "My App (Dev)")
buildConfigField("String", "BASE_URL", "\"https://dev.api.example/\"")
buildConfigField("String", "SECRET_KEY", "\"1!2@3#4\$_dev\"")
}
create("staging") {
dimension = "environment"
applicationIdSuffix = ".staging"
versionNameSuffix = "-staging"
resValue("string", "app_name", "My App (Staging)")
buildConfigField("String", "BASE_URL", "\"https://staging.api.example/\"")
buildConfigField("String", "SECRET_KEY", "\"1!2@3#4\$_staging\"")
}
create("prod") {
dimension = "environment"
resValue("string", "app_name", "My App")
buildConfigField("String", "BASE_URL", "\"https://api.example/\"")
buildConfigField("String", "SECRET_KEY", "\"1!2@3#4$\"")
}
}
...
}
추가로 각 차원의 필드 값 또한 조합이 되기 때문에, 아래와 같이 devFreeDebug 빌드 변형을 선택할 때 versionName이 -dev-free 로 설정된 것을 확인하실 수 있습니다.
주의할 점으로는 차원을 추가 정의하였지만, 실제 productFlavor 블록에서 정의된 차원을 사용하지 않았을 경우 에러가 발생될 수 있습니다.
buildConfigField
BuildConfig 파일에 사용자 정의 필드를 추가하는 방식을 정의합니다. 주로 API 엔드포인트나 플래그 값을 설정할 때 사용됩니다.
buildConfigField(type: String, name: String, value: String)
resValue
프로젝트의 리소스 파일 내에 값을 동적으로 생성하는 방식을 정의합니다. 아래의 예시는 리소스 파일에 app_name이라는 키와 string 타입으로 설정된 값을 정의하였습니다.
create("dev") {
resValue("string", "app_name", "My App (Dev)")
}
resValue(type: String, name: String, value: String)
manifestPlaceholders
AndroidManifest.xml 파일 내에 플레이스 홀더를 동적으로 생성하는 방식을 정의합니다. 마찬가지로 Manifest 파일에 저장해야 할 특정 키나 스키마를 주입 해야하는 상황일 때 사용됩니다. 대표적으로 kakao api 기능을 설정할 때, Manifest 파일 내 스키마를 주입해야 하는데, 해당 방식을 통해 동적으로 주입이 가능해집니다.
manifestPlaceholders는 다른 필드 값과 다르게 Map으로 설정되어 있어 Map의 set 메서드를 사용하여 주입합니다.
create("dev") {
manifestPlaceholders["KAKAO_SCHEME"] = "kakao1231289dasd12323d131"
}
...
<data
android:host="oauth"
android:scheme="${KAKAO_SCHEME}"
/>
...
소개한 항목 이 외에도 다양한 속성들이 존재하기 합니다. 프로젝트에 따라 요구될 만한 기능이 있을 경우 [해당 소스]를 참고하여 빌드를 구성하시길 바랍니다.
Custom Android Gradle Plugin
현대의 Android 아키텍처 패턴은 멀티 모듈을 기반하여 구성하는 방식을 채택해 나가고 있습니다. 하지만, 멀티 모듈 방식을 채택할경우 각 모듈에 따른 Gradle script를 구성해 나가야만 합니다. 물론 소규모의 모듈을 가진 프로젝트의 경우에는 기존의 Gradle script를 구성하는 방식이 나을 수도 있지만, 모듈의 개수가 많아질 경우 기존방식은 유지보수가 복잡해질 뿐만 아니라 휴먼에러를 발생시킬 확률이 높아집니다. (특정 모듈만 빌드 스크립트 업데이트를 잊어먹는 케이스, … )
이러한 문제들을 해결하고 빌드 시스템의 재사용성, 확장성, 유지보수성을 높이기 위한 방법으로 Custom Amdroid Gradle Plugin
방식을 채택할 수 있습니다. 해당 방식은 일반적인 애플리케이션
코드에서 유틸리티 함수 등을 활용하여 Gradle 빌드 설정의 복잡한 로직을 수월하게 구성할 수 있게됩니다.
Producut Flavor
의 경우 특히 여러 가지의 설정값을 적용하고 구성해야하기 때문에, 멀티 모듈 환경에서는 Custom Android Gradle Plugin
방식이 더더욱 요구됩니다. 아래에서부터는
Custom Android Gradle Plugin
을 활용하여 Product Flavor
를 효율적으로 구성 및 관리하는 방식에 대해서 다루어보겠습니다.
Custom Android Gradle Plugin
에 대한 기본적인 내용과 설정 방법들은 해당 포스트에서는 다루지 않기 때문에, 이해를 위해 아래와 같은 소스를 참고하시면 좋을 것 같습니다.
가장 먼저 Product Flavor
를 정의할 클래스를 만들어 보겠습니다. 해당 클래스에는 앞서 설명한 Product Flavor
에서 사용될 수 있는 필드 값들을 바탕으로 설계하였습니다. 이때
buildConfigField와 resourceValue는 다수 개의 파라미터가 요구되기 때문에 하위 클래스를 구성하여 설계하였습니다. manifestPlaceholder 는 앞서 Map 구조를 사용한다 언급하였기
때문에 해당 클래스에서도 동일한 구조를 채택하였습니다.
data class ProductFlavorType(
val name: String,
val dimension: FlavorDimension,
val suffix: String? = null,
val versionCode: Int = VERSION_CODE, // 상수 값; 별도 파일에 설정할 것
val versionName: String = VERSION_NAME, // 상수 값; 별도 파일에 설정할 것
val buildConfigField: List<BuildConfigField> = emptyList(),
val resourceValue: List<ResourceType> = emptyList(),
val manifestPlaceholder: Map<String, Any> = emptyMap()
) {
enum class FlavorDimension(val dimension: String) {
ENVIRONMENT(dimension = "environment"),
TIER(dimension = "tier")
}
data class BuildConfigField(
val type: Type,
val name: String,
val value: Any
) {
enum class Type(val field: String) {
STRING(field = "String"),
INT(field = "int"),
BOOLEAN(field = "boolean")
}
}
data class ResourceType(
val type: Type,
val name: String,
val value: Any
) {
enum class Type(val field: String) {
STRING(field = "string"),
INT(field = "int"),
BOOLEAN(field = "boolean")
}
}
}
일반적으로 소프트웨어에서 사용되는 Secret 들은 하드 코딩방식으로 저장하는 것이 아닌 외부 파일에 접근하여 사용하도록 설계하는 것이 안전합니다. 따라서 API 주소나 키 같은 Secret에 접근하기 위한 유틸리티 익스텐션을 만들겠습니다. 루트 디렉터리에 local.properties 파일을 생성하고 Secret 정보들을 작성한 뒤, 해당 파일에 접근하도록 하였습니다.
import org.gradle.api.Project
import java.util.Properties
val Project.localProperties
get():Properties = Properties().apply {
this.load(this@localProperties.rootProject.file("local.properties").inputStream())
}
이제 ProductFlavorType
클래스를 바탕으로 Product Flavor
인스턴스 목록을 구성하여 정의하도록 하겠습니다. Flavor는 tier 차원과(free,premium) environment
차원(dev,staging,prod)을 사용하였습니다. 추가로 앞서 정의한 localProperties를 사용하여 Secret 정보를 연동하였습니다.
import org.gradle.api.Project
object AndroidBuildConfig {
const val VERSION_NAME = "1.0.0"
const val VERSION_CODE = 1
val Project.flavorList: List<ProductFlavorType>
get() = listOf(
ProductFlavorType(
name = "free",
suffix = ".free",
dimension = ProductFlavorType.FlavorDimension.TIER,
),
ProductFlavorType(
name = "premium",
suffix = ".premium",
dimension = ProductFlavorType.FlavorDimension.TIER
),
ProductFlavorType(
name = "dev",
dimension = ProductFlavorType.FlavorDimension.ENVIRONMENT,
suffix = ".dev",
buildConfigField = listOf(
BuildConfigField(
type = BuildConfigField.Type.STRING,
name = "BASE_URL",
value = localProperties["DEV_BASE_URL"] as String
),
BuildConfigField(
type = BuildConfigField.Type.STRING,
name = "SECRET_KEY",
value = localProperties["DEV_SECRET_KEY"] as String
)
),
manifestPlaceholder = mapOf(
"KAKAO_SCHEME" to localProperties["DEV_KAKAO_SCHEME"] as String
),
resourceValue = listOf(
ResourceType(
type = ResourceType.Type.STRING,
name = "app_name",
value = "My App (Dev)"
)
)
),
ProductFlavorType(
name = "staging",
suffix = ".staging",
dimension = ProductFlavorType.FlavorDimension.ENVIRONMENT,
buildConfigField = listOf(
BuildConfigField(
type = BuildConfigField.Type.STRING,
name = "BASE_URL",
value = localProperties["STAGING_BASE_URL"] as String
),
BuildConfigField(
type = BuildConfigField.Type.STRING,
name = "SECRET_KEY",
value = localProperties["STAGING_SECRET_KEY"] as String
)
),
manifestPlaceholder = mapOf(
"KAKAO_SCHEME" to localProperties["STAGING_KAKAO_SCHEME"] as String
),
resourceValue = listOf(
ResourceType(
type = ResourceType.Type.STRING,
name = "app_name",
value = "My App (Staging)"
)
)
),
ProductFlavorType(
name = "prod",
dimension = ProductFlavorType.FlavorDimension.ENVIRONMENT,
buildConfigField = listOf(
BuildConfigField(
type = BuildConfigField.Type.STRING,
name = "BASE_URL",
value = localProperties["PROD_BASE_URL"] as String
),
BuildConfigField(
type = BuildConfigField.Type.STRING,
name = "SECRET_KEY",
value = localProperties["PROD_SECRET_KEY"] as String
)
),
manifestPlaceholder = mapOf(
"KAKAO_SCHEME" to localProperties["PROD_KAKAO_SCHEME"] as String
),
resourceValue = listOf(
ResourceType(
type = ResourceType.Type.STRING,
name = "app_name",
value = "My App (Prod)"
)
)
)
)
}
이어서 구성한 Product Flavor
목록을 적용할 함수를 생성하겠습니다. 전체적인 구조는 gradle.kts에서 DSL로 적용한 문법과 유사하다는 것을 확인할 수 있습니다.
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.ApplicationProductFlavor
import com.android.build.api.dsl.CommonExtension
import com.gradation.lift.buildSystem.extension.type.ProductFlavorType
fun CommonExtension<*, *, *, *, *, *>.configureProductFlavor(
flavorList: List<ProductFlavorType>,
) = run {
flavorDimensions += ProductFlavorType.FlavorDimension.values().map { it.dimension }
productFlavors {
flavorList.forEach {
create(it.name) {
dimension = it.dimension.dimension
// BuildConfig 설정
it.buildConfigField.forEach { field ->
buildConfigField(
type = field.type.field,
name = field.name,
value = "${field.value}"
)
}
// Resource Value 필드 설정
it.resourceValue.forEach { res ->
resValue(
type = res.type.field,
name = res.name,
value = "${res.value}"
)
}
// Manifest Placeholders 설정
addManifestPlaceholders(it.manifestPlaceholder)
// 아래 설정은 com.android.application 플러그인이 적용된 모듈에서만 가능한 설정이기 때문에, 제약을 겁니다.
if (this is ApplicationExtension && this is ApplicationProductFlavor) {
if (it.suffix != null) applicationIdSuffix = it.suffix
versionCode = it.versionCode
versionName = it.versionName
}
}
}
}
}
이제 모듈에 Product Flavor
를 적용하기 위해 플러그인을 셋업하는 과정이 필요합니다. ApplicationExtension 및 LibraryExtension을 적용한 Plugin을 구성한 뒤, 이전에
만든 configureProductFlavor
함수를 사용하여 Prdouct Flavor
를 추가합니다.
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class AndroidApplicationPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.application")
}
//... 기타 설정 추가
extensions.configure<ApplicationExtension> {
configureProductFlavor(flavorList)
//... 기타 설정 추가
}
}
}
}
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class AndroidLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
}
//... 기타 설정 추가
extensions.configure<LibraryExtension> {
configureProductFlavor(flavorList)
//... 기타 설정 추가
}
}
}
}
}
이제 기존 Android Gradle Plguin
설정 방식대로 해당 플러그인을 빌드 시스템에 적용할 경우, Product Flavor
로 분리된 빌드 변형을 활용할 수 있게 됩니다. 추후 새로운 엔드포인트가
도입되거나, 새로운 Product Flavor
가 생기는 등의 요구사항이 생길 경우, AndroidBuildConfig.flavorList
의 값을 확장하여 대응할 수 있게 됩니다.