안드로이드는 이미지를 res/drawable에, 문자열을 res/values/strings.xml에 두고 R.drawable.xxx, R.string.xxx로 참조합니다. iOS는 Assets.xcassets와 Localizable.strings를 씁니다. commonMain에 화면을 공유해도 리소스는 플랫폼마다 따로 넣고 각 플랫폼 API로 불러와야 했는데, JetBrains가 Compose Multiplatform에 내장한 Resources 라이브러리는 이 리소스 자체를 commonMain에 한 번만 두고 양쪽에서 같은 방식으로 참조할 수 있게 해줍니다.
왜 필요한가
리소스가 플랫폼별로 나뉘어 있으면 이미지 하나를 바꿀 때도 안드로이드 drawable과 iOS 에셋 카탈로그를 각각 열어 교체해야 합니다. 문자열도 마찬가지로 strings.xml과 Localizable.strings에 같은 키를 중복해서 관리하게 되는데, 둘 중 하나만 고치고 넘어가면 플랫폼마다 다른 문구가 보이는 문제가 생깁니다. Compose Multiplatform Resources는 리소스 파일을 commonMain의 정해진 디렉터리에 두면 빌드 시점에 Res라는 타입 안전한 접근자를 생성해줘서, 이 중복 관리 자체를 없애줍니다.
핵심 개념
먼저 commonMain에 리소스 모듈 의존성을 추가합니다.
// composeApp/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(compose.components.resources)
}
}
}
compose.resources {
publicResClass = true
packageOfResClass = "com.example.app.resources"
}
리소스는 commonMain/composeResources 아래 정해진 이름의 하위 폴더에 넣습니다. 이미지는 drawable, 문자열은 values, 폰트는 font 폴더를 씁니다.
composeApp/src/commonMain/composeResources/
├── drawable/
│ └── ic_logo.png
└── values/
└── strings.xml
<!-- composeApp/src/commonMain/composeResources/values/strings.xml -->
<resources>
<string name="greeting">환영합니다, %1$s님</string>
<string name="retry_button">다시 시도</string>
</resources>
Gradle sync가 끝나면 이 리소스들을 가리키는 Res 객체가 자동 생성됩니다. stringResource, painterResource는 둘 다 컴포저블 함수 안에서 호출해야 하고, 내부적으로 플랫폼별 리소스 로딩 방식(안드로이드는 Resources, iOS는 번들에 포함된 파일)으로 분기됩니다.
// commonMain/ui/GreetingCard.kt
import androidx.compose.foundation.Image
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import com.example.app.resources.generated.resources.Res
import com.example.app.resources.generated.resources.ic_logo
import com.example.app.resources.generated.resources.greeting
@Composable
fun GreetingCard(userName: String) {
Image(painterResource(Res.drawable.ic_logo), contentDescription = null)
Text(stringResource(Res.string.greeting, userName))
}
실전 예시: 로케일별 문자열 분기
values 폴더에 로케일 접미사를 붙인 폴더를 추가로 두면, 시스템 로케일에 맞는 문자열이 자동으로 선택됩니다.
composeApp/src/commonMain/composeResources/
├── values/
│ └── strings.xml # 기본값 (한국어)
└── values-en/
└── strings.xml # 영어 로케일
<!-- values-en/strings.xml -->
<resources>
<string name="greeting">Welcome, %1$s</string>
<string name="retry_button">Retry</string>
</resources>
컴포저블 코드는 그대로 두고 로케일 리소스만 추가하면 됩니다. 어떤 로케일 폴더를 선택할지는 각 플랫폼의 시스템 언어 설정을 따르므로, 코드에서 분기 로직을 직접 짤 필요가 없습니다.
겪은 문제: 하이픈이 들어간 파일명
이미지 파일을 app-logo.png처럼 하이픈이 들어간 이름으로 넣었다가 빌드가 실패했습니다.
> Task :composeApp:generateComposeResClass FAILED
Resource name 'app-logo' is invalid. Resource names must match [a-z][a-z0-9_]*
리소스 이름이 그대로 Res.drawable.app_logo처럼 Kotlin 프로퍼티 이름으로 생성되기 때문에, 코틀린 식별자 규칙(소문자, 숫자, 언더스코어만 허용)을 벗어난 파일명은 애초에 받아들여지지 않습니다. 안드로이드 리소스 이름 규칙과 같은 제약인데, 기존에 안드로이드 프로젝트에서 하이픈 없이 관성적으로 짓던 습관과 달리 iOS 쪽 에셋 이름 관례(카멜케이스, 하이픈 혼용)를 그대로 옮기다 보니 걸렸습니다. app_logo.png로 바꾸고 나서 해결됐습니다.
장단점 정리
장점
- 이미지·문자열·폰트를 commonMain 한 곳에 두면 안드로이드/iOS 양쪽에서 같은 리소스를 참조하므로, 플랫폼별로 중복 등록하다 하나를 빠뜨리는 실수가 사라짐
- 리소스 키가 컴파일 타임에
Res프로퍼티로 생성돼서, 존재하지 않는 키를 참조하면 문자열이 아니라 빌드 에러로 바로 드러남 - 로케일 폴더(
values-en등)만 추가하면 다국어 문자열 분기가 시스템 설정을 따라 자동으로 처리됨
단점
- 리소스를 새로 추가하거나 이름을 바꾸면 Gradle sync를 다시 돌려야
Res클래스가 갱신되고, 갱신 전까지 IDE에서 참조가 빨간 줄로 표시되는 경우가 있음 - 앱 아이콘이나 스플래시 화면처럼 플랫폼 자체가 요구하는 리소스는 이 시스템 밖에서 각 플랫폼 방식대로 별도로 등록해야 함
- 리소스 파일명이 코틀린 식별자 규칙을 따라야 해서, 기존에 다른 방식으로 리소스를 관리하던 프로젝트에서 옮겨올 때는 파일명을 한 차례 정리해야 함
플랫폼별로 나뉘어 있던 이미지·문자열 리소스를 commonMain으로 옮기는 작업은 화면 코드를 공유하는 것보다 손이 덜 가면서도, 리소스가 플랫폼 간에 어긋나는 문제를 구조적으로 막아준다는 점에서 도입 우선순위를 높게 둘 만합니다.