Android 프로젝트에서는 buildConfigField로 API 서버 주소나 디버그 플래그 같은 값을 빌드타임에 심어 넣을 수 있습니다. 이 기능은 Android Gradle Plugin 전용이라 commonMain에서는 참조할 수 없고, iOS 타겟까지 같은 값을 공유하려면 다른 방법이 필요합니다. expect/actual로 직접 상수를 나눠 선언해도 되지만 플레이버가 여러 개로 늘어나면 소스셋마다 값을 맞추는 일이 번거로워집니다. 이 문제를 BuildKonfig Gradle 플러그인으로 정리한 과정을 정리합니다.

왜 필요한가

KMP 앱은 보통 개발 서버와 운영 서버 주소, 로깅 활성화 여부 같은 값을 빌드 타입이나 플레이버에 따라 다르게 심어야 합니다. Android라면 build.gradle.ktsbuildTypes { debug { buildConfigField(...) } }로 끝나지만, 이 API는 com.android.build.gradle 플러그인이 만드는 BuildConfig 클래스에 의존하기 때문에 androidMain에서만 보입니다.

직접 expect val API_BASE_URL: StringcommonMain에 선언하고 androidMain, iosMainactual을 각각 써주는 방법도 있습니다. 값이 한두 개일 때는 문제없지만, 플레이버(dev/release)와 타겟(android/ios)을 곱하면 조합이 늘어나면서 어느 소스셋에 어떤 값을 넣었는지 놓치기 쉽습니다. BuildKonfig는 이 expect/actual 쌍을 Gradle 설정에서 선언한 값으로 자동 생성해줍니다.

핵심 개념

플러그인을 적용하면 buildkonfig { } 블록 안에서 세 가지를 선언합니다.

  • packageName: 생성될 BuildKonfig 오브젝트가 위치할 패키지
  • defaultConfigs: 모든 타겟에 공통으로 들어갈 필드. 플레이버 이름을 인자로 주면(defaultConfigs("release") { }) 해당 플레이버 전용 값으로 오버라이드됨
  • targetConfigs: 특정 타겟(android, ios 등)에서만 다른 값을 쓰고 싶을 때 선언

플레이버는 gradle.propertiesbuildkonfig.flavor 키로 기본값을 정하고, CI에서는 -Pbuildkonfig.flavor=release처럼 커맨드라인 인자로 덮어씁니다.

실전 예시

composeApp/build.gradle.kts에 플러그인을 추가하고 필드를 선언합니다.

import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.BOOLEAN
import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING

plugins {
    kotlin("multiplatform")
    id("com.codingfeline.buildkonfig") version "0.23.0"
}

buildkonfig {
    packageName = "com.example.app.config"

    defaultConfigs {
        buildConfigField(STRING, "API_BASE_URL", "https://api-dev.example.com")
        buildConfigField(BOOLEAN, "ENABLE_LOGGING", "true")
    }

    defaultConfigs("release") {
        buildConfigField(STRING, "API_BASE_URL", "https://api.example.com")
        buildConfigField(BOOLEAN, "ENABLE_LOGGING", "false")
    }
}

gradle.properties에 기본 플레이버를 지정해둡니다.

buildkonfig.flavor=dev

이제 commonMain에서 생성된 BuildKonfig 오브젝트를 바로 참조할 수 있습니다.

// commonMain
import com.example.app.config.BuildKonfig
import io.ktor.client.HttpClient
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.url

fun createApiClient() = HttpClient {
    defaultRequest {
        url(BuildKonfig.API_BASE_URL)
    }
}

fun log(message: String) {
    if (BuildKonfig.ENABLE_LOGGING) {
        println("[APP] $message")
    }
}

빌드하면 generateBuildKonfig 태스크가 컴파일 태스크 실행 전에 자동으로 붙어서, commonMain에는 expect object BuildKonfig가, androidMainiosMain에는 각각 actual object BuildKonfig가 생성됩니다. 플레이버를 바꿔서 운영 빌드를 만들 때는 이렇게 실행합니다.

./gradlew assembleRelease -Pbuildkonfig.flavor=release

타겟별로 값을 다르게 주고 싶을 때는(예: iOS는 URL 스킴이 다른 딥링크를 쓰는 경우) targetConfigs를 추가합니다.

buildkonfig {
    packageName = "com.example.app.config"

    defaultConfigs {
        buildConfigField(STRING, "API_BASE_URL", "https://api-dev.example.com")
    }

    targetConfigs {
        create("ios") {
            buildConfigField(STRING, "DEEPLINK_SCHEME", "myapp-ios")
        }
        create("android") {
            buildConfigField(STRING, "DEEPLINK_SCHEME", "myapp-android")
        }
    }
}

처음 붙였을 때 IDE에서 Unresolved reference: BuildKonfig 에러가 났는데, 원인은 generateBuildKonfig 태스크가 한 번도 실행되지 않아 생성 소스 디렉터리가 비어 있었기 때문입니다. ./gradlew generateBuildKonfig를 한 번 실행하거나 Gradle Sync를 다시 돌리면 해결됩니다.

기본적으로 생성되는 BuildKonfig 오브젝트는 internal이라 같은 모듈 밖에서는 참조할 수 없습니다. 여러 모듈에서 공유하는 상수라면 exposeObjectWithName으로 이름을 지정해 public으로 노출해야 합니다.

장단점 정리

장점

  • expect/actual 쌍을 손으로 안 써도 Gradle 설정만으로 타겟별/플레이버별 값을 생성
  • CI에서 -Pbuildkonfig.flavor=로 환경별 빌드를 스크립트화하기 쉬움
  • 값이 컴파일 타임에 박히므로 런타임에 환경 설정 파일을 읽는 것보다 빠르고 타입 안전함

단점

  • 플러그인이 생성하는 소스 디렉터리를 IDE가 한 번 Gradle Sync를 해야 인식해서, 처음 붙였을 때 Unresolved reference 에러를 만나기 쉬움
  • API 키처럼 민감한 값을 여기 박아두면 소스 저장소에 그대로 커밋되므로, local.properties나 환경 변수에서 읽어와 Gradle 스크립트에 주입하는 절차는 별도로 필요함
  • 값 하나 바꿀 때마다 재빌드가 필요해서, 런타임에 바로 반영되는 리모트 설정(feature flag 서버 등)이 필요한 경우에는 맞지 않음

빌드 타입이나 플레이버에 따라 서버 주소가 달라지는 시점부터는 expect/actual을 직접 관리하기보다 BuildKonfig로 옮기는 편이 소스셋 사이 값 불일치를 줄여줍니다.