Compose Multiplatform 디자인 시스템을 독립 모듈로 만들기
Compose Multiplatform 디자인 시스템을 독립 모듈로 만들기
지금까지 작성해 온 Compose 화면은 아마 대부분 같은 모습으로 시작했을 겁니다. UI를 MaterialTheme { ... }으로 감싸고, 그 아래에서 MaterialTheme.colorScheme.primary, MaterialTheme.typography.bodyLarge, MaterialTheme.shapes.medium처럼 값을 꺼내 쓰는 방식이죠. 하도 익숙해서 테마가 Compose의 일부처럼 느껴지지만, 사실은 그렇지 않습니다. MaterialTheme은 머티리얼 디자인에 맞춰 설계된 하나의 디자인 시스템일 뿐입니다. 제품이 자체 브랜드 팔레트와 고유한 텍스트 스타일 체계, 직접 정한 모서리 반경, 자기만의 눌림 피드백을 요구하기 시작하면 그 편리함은 어느 순간 넘기 힘든 천장이 됩니다. 그래서 진짜 질문은 여기서 시작됩니다. 토큰을 스스로 소유하고, 폰트와 드로어블을 직접 실어 나르며, 안드로이드와 iOS, 데스크톱, 웹에서 모두 동작하면서, MaterialTheme에는 조금도 기대지 않는 디자인 시스템을 어떻게 만들 수 있을까요.
이번 아티클에서는 오픈 소스인 KotlinConf 앱이 바로 그 구조를 어떻게 구현했는지 깊이 파고듭니다. 디자인 시스템이 별도의 Kotlin Multiplatform 모듈로 어떻게 분리되어 있는지,
UiRes라는 이름의 public 리소스 클래스를 어떻게 생성하는지, 두 모듈이 각자 생성한 리소스가 어떻게 충돌 없이 공존하는지 살펴봅니다. 이어서 디자인 토큰이 MaterialTheme 대신 CompositionLocal을 타고 흐르는 방식, 단 하나의 테마 컴포저블이 색상과 셰이프, 타이포그래피, 인디케이션을 엮어 내는 방식, 리플을 머티리얼이 아니라 foundation의 LocalIndication으로 테마화하는 방식, 그리고 다크 드로어블이 운영체제가 아니라 앱 자체의 테마를 따르도록 expect/actual 객체가 Compose Resources의 한계를 우회하는 방식까지 차례로 뜯어보겠습니다.
근본적인 문제: MaterialTheme에 붙어 버린 디자인 시스템
가장 먼저 떠오르는 선택지는 역시 MaterialTheme에 기대는 것입니다. ColorScheme과 Typography, Shapes를 만들어 앱 루트를 감싸 두면, 그 아래 어디서든 토큰을 꺼내 쓸 수 있습니다.
MaterialTheme(
colorScheme = brandColorScheme,
typography = brandTypography,
shapes = brandShapes,
) {
App()
}
이 방식은 브랜드가 머티리얼의 어휘와 어긋나기 시작하는 순간부터 삐걱댑니다. 머티리얼의 ColorScheme은 primary, onPrimary, surface, surfaceVariant, outline처럼 역할이 고정되어 있습니다. 반면 제품의 디자인 언어가 요구하는 역할은 전혀 다릅니다. 타일 배경, 포커스가 잡힌 입력 필드의 테두리, 플레이스홀더 텍스트 색, 이미 지나간 세션을 나타내는 카드 같은 것들이죠. 정해진 슬롯에 팔레트를 욱여넣다 보면 역할 이름을 겹쳐 쓰게 됩니다. strokeInputFocus를 outline에 매핑해 두고, 머티리얼 컴포넌트가 outline을 읽어 엉뚱한 것을 그리지 않기만을 바라는 식입니다. 결국 이름이 색의 의미를 속이는 상태가 됩니다.
여기에 비용이 두 가지 더 따라붙습니다. 우선 MaterialTheme에 의존한다는 말은 material이나 material3 아티팩트 전체에 의존한다는 뜻이라, 요청한 적도 없는 컴포넌트와 엘리베이션 오버레이, 리플 시스템까지 딸려 옵니다. 그리고 테마가 기능 코드와 같은 모듈에 있으면 테마를 건드리는 모든 기능이 머티리얼을 전이 의존성으로 떠안게 되고, 다른 팀에 그대로 넘겨줄 만한 재사용 가능한 디자인 시스템의 경계도 생기지 않습니다. KotlinConf가 택한 방식은 이 세 가지 비용을 한 번에 걷어 냅니다. 디자인 시스템을 자기만의 모듈로 떼어 내고, MaterialTheme을 이루는 조각을 하나씩 foundation 기반의 대체 구현으로 갈아 끼웁니다.
자기만의 모듈이 된 디자인 시스템
디자인 시스템은 :app:ui-components라는 Gradle 모듈입니다. settings.gradle.kts에 선언되어 있고, 타입 세이프 접근자인 projects.app.uiComponents로 참조합니다. 빌드 파일은 Compose Multiplatform 플러그인과 비교적 최근에 등장한 Android Kotlin Multiplatform 플러그인을 함께 적용하며 시작합니다.
plugins {
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinMultiplatform)
}
Kotlin Multiplatform과 Compose Multiplatform을 함께 쓰는 독립 라이브러리를 선언한 셈입니다. androidMultiplatformLibrary 플러그인은 안드로이드 타겟에 기존 Android Gradle 플러그인 설정 대신 최신 androidLibrary { } DSL을 쓸 수 있게 해 줍니다.
다음은 타겟 목록입니다. 이 모듈은 안드로이드, JVM(데스크톱), iOS 두 타겟, 웹 두 타겟을 대상으로 빌드됩니다.
kotlin {
applyDefaultHierarchyTemplate()
androidLibrary {
namespace = "com.jetbrains.kotlinconf.ui"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions.jvmTarget = JvmTarget.JVM_11
androidResources.enable = true
}
jvm()
iosArm64()
iosSimulatorArm64()
wasmJs { browser() }
js { browser() }
iOS 타겟은 실기기용 iosArm64와 애플 실리콘 시뮬레이터용 iosSimulatorArm64 두 가지뿐이고 iosX64는 없습니다. 안드로이드 타겟의 androidResources.enable = true는 반드시 필요한 설정인데, 이 값이 켜져 있어야 Compose Resources 파이프라인이 해당 타겟용 리소스를 생성하고 패키징할 수 있습니다.
모듈의 경계가 실제로 그어지는 곳은 의존성 블록입니다. runtime과 foundation, animation에는 의존하지만 material이나 material3에는 손대지 않습니다.
sourceSets {
commonMain.dependencies {
api(libs.compose.runtime)
api(libs.compose.foundation)
api(libs.compose.animation)
implementation(libs.compose.material.ripple)
api(libs.compose.components.resources)
implementation(libs.compose.ui.tooling.preview)
// ... coil, lifecycle, 마크다운 렌더러 등이 이어집니다
}
무엇이 들어 있고 무엇이 빠져 있는지를 눈여겨보실 만합니다. runtime, foundation, animation은 api로 노출되어 이 모듈을 쓰는 쪽까지 전이됩니다. 머티리얼 테마 관련 의존성은 아예 하나도 없습니다. 유일한 머티리얼 아티팩트는 org.jetbrains.compose.material:material-ripple 하나뿐이며, 그마저도 implementation으로 선언되어 있어 바깥으로 새어 나가지 않습니다. 머티리얼 테마는 걷어 내되 리플을 그리는 독립 서브모듈 하나만 재사용한다는 뜻인데, 이 구분은 리플을 다루는 절에서 다시 짚어 보겠습니다.
Kotlin 블록의 마지막 부분에서는 중간 소스 셋을 손수 만들고 컴파일러 플래그를 하나 켭니다.
val nonAndroidMain by creating { dependsOn(commonMain.get()) }
configure(listOf(iosMain, jvmMain, webMain)) { get().dependsOn(nonAndroidMain) }
}
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
nonAndroidMain 소스 셋은 commonMain에 의존하고, iosMain과 jvmMain, webMain이 다시 nonAndroidMain에 의존합니다. 나중에 등장할 테마 훅의 비안드로이드 공통 구현이 여기에 자리 잡으며, 덕분에 JVM과 iOS, 웹이 파일 하나를 공유하고 안드로이드만 별도 구현을 갖습니다. -Xexpect-actual-classes 플래그가 필요한 이유는 그 테마 훅이 expect/actual 객체이기 때문입니다. Kotlin에서 expect/actual 클래스와 객체는 아직 실험 단계라, 이 플래그로 명시적으로 허용해 주어야 합니다.
독자적인 리소스 클래스, UiRes
폰트와 드로어블, 문자열을 품은 Compose Multiplatform 모듈은 그 리소스에 접근할 클래스를 자동으로 생성받습니다. 기본값은 internal object Res입니다. 이 디자인 시스템은 관련 설정 세 가지를 모두 바꿔 놓았습니다.
compose.resources {
publicResClass = true
nameOfResClass = "UiRes"
packageOfResClass = "org.jetbrains.kotlinconf.ui.generated.resources"
}
한 줄이 하나씩 역할을 맡습니다. publicResClass = true는 생성된 접근자를 public으로 만들어 다른 모듈에서도 읽을 수 있게 합니다. nameOfResClass = "UiRes"는 기본 이름인 Res를 다른 이름으로 바꿉니다. packageOfResClass는 전용 패키지에 클래스를 배치합니다. 그 결과 Compose Resources Gradle 태스크는 public object UiRes를 만들어 내고, 런타임 경로는 composeResources/org.jetbrains.kotlinconf.ui.generated.resources/ 아래에 네임스페이스가 잡힙니다. 모듈 안의 모든 폰트와 드로어블, 문자열은 각각 UiRes.font.*, UiRes.drawable.*, UiRes.string.*으로 접근하며, 클래스가 public이라 이 참조는 모듈 경계를 그대로 넘어갑니다.