Android 개발/Jetpack Compose

로직은 KMP인데 아이콘은 세 벌이던 프로젝트, Compose Resources로 끝낸 기록

stackD 2026. 10. 14. 18:00
반응형

얼마 전에 버튼 아이콘 하나 바꾸려고 안드로이드 drawable 폴더 열고, iOS 에셋 열고, 데스크톱까지 열다가 손이 멈췄습니다. 세 벌씩 관리하던 이 리소스를 컴포즈 멀티플랫폼 리소스(Compose Multiplatform Resources) 하나에 몰아넣은 기록이에요.

 

로직이랑 화면은 진작에 commonMain에 다 올렸는데, 정작 app_name 문자열 하나가 안드로이드 strings.xml, iOS Localizable.strings, 데스크톱까지 세 군데에 복붙돼 있더군요. 마지막 1% 리소스가 유지보수 비용의 절반을 갉아먹고 있었습니다.

 

로직만 공유하고 문자열·아이콘은 세 벌 관리하던 시절

KMP 튜토리얼이 대부분 로직·UI 공유에서 딱 끝나는 게 문제였어요. 리소스는 "네이티브에 그대로 두라"고 얼버무리고 넘어가거든요. 그러다 보니 아이콘 하나 교체하려고 폴더 세 개를 여는 상황이 반복됐습니다.

 

사실 이게 라이브러리 탓만은 아니었어요. 문자열·이미지 공유를 공식으로 지원한 게 컴포즈 멀티플랫폼 1.6.0(2024년 2월)부터라고 합니다. 그 전엔 모코 리소스(MOKO Resources) 같은 서드파티가 사실상 표준이었고, MR.strings.xxx 방식이 코드에 깊게 박혀 있었죠.

 

오타 하나 고치는데 파일 세 개에 diff가 걸리는 구조라니. 공유의 마지막 조각이 제일 아팠습니다.

 

commonMain 아래 composeResources 디렉토리 구조 잡기

일단 commonMain의 의존성에 라이브러리랑 그래들(Gradle) 플러그인을 넣습니다. 플러그인 적용이 전제라 이 두 줄이 세트라고 보시면 돼요.

 

// build.gradle.kts

// 1) Compose Gradle 플러그인 적용 (plugins 블록)
plugins {
    id("org.jetbrains.compose")
}

// 2) commonMain 의존성
implementation(compose.components.resources)

 

이러면 빌드할 때 타입 세이프한 Res 객체가 자동으로 생성돼요. 안드로이드의 R 클래스에 대응한다고 보시면 됩니다.

 

배치 규칙은 안드로이드 폴더 컨벤션이랑 거의 똑같아서 익숙했어요. src/commonMain/composeResources/ 아래에 이렇게 나눠 넣습니다.

 

  • drawable/ — 이미지 (PNG/JPEG/WebP, 순수 벡터 XML)
  • font/ — 폰트
  • values/strings.xml — 문자열
  • files/ — raw 리소스

 

참고로 1.6에서는 안드로이드 스튜디오(Android Studio) 프리뷰가 깨지는 게 좀 거슬렸는데, 1.7.0부터 리소스가 안드로이드 assets로 패키징되면서 컴포저블 프리뷰가 정상 동작하게 됐습니다. 이거 하나로 작업 속도가 확 붙었어요.

 

다국어 문자열과 이미지를 한 번에 부르는 접근 API

접근 코드가 진짜 깔끔해집니다. androidMain/iosMain 분기가 통째로 사라져요.

 

// 이미지
Image(painterResource(Res.drawable.ic_play), contentDescription = null)

// 문자열
Text(stringResource(Res.string.app_name))

 

다국어랑 다크테마는 폴더 접미사로 자동 처리됩니다. values-ko/, values-es/ 넣으면 언어별 문자열이 자동 선택되고, drawable-dark/는 다크테마에서 알아서 골라줘요. 안드로이드 qualifier 그대로라 따로 배울 것도 없었습니다.

 

근데 여기서 제가 한 번 크게 당했어요. stringResource나 painterResource가 컴포저블 함수라 당연히 비동기로 로딩할 줄 알았거든요. 아니었습니다. 문자열·이미지는 호출 스레드에서 동기로 로딩됩니다. 큰 이미지를 이걸로 불렀다가 스크롤에서 프레임이 뚝뚝 떨어지더라고요. raw나 웹 리소스만 getString() 같은 suspend 계열로 비동기 처리되니, 대용량은 경로를 나눠 생각하셔야 해요.

 

Res 클래스 멀티모듈 충돌과 packageOfResClass 설정

모듈을 여러 개 쓰는 프로젝트라면 여기서 진짜 골치가 아파집니다. 모든 모듈이 똑같이 Res라는 이름으로 클래스를 생성합니다. 3개 모듈 리소스를 한 파일에서 쓰면 이름이 정면충돌해요. Res 이름 커스터마이징은 아직 막혀 있는 것으로 알려져 있어서(#4763), import alias로 우회하는 게 현재로선 답입니다.

 

import my.feature.login.generated.resources.Res as LoginRes
import my.feature.home.generated.resources.Res as HomeRes

 

멀티모듈이면 compose.resources 블록 설정을 처음부터 잡아두시는 게 좋습니다.

 

compose.resources {
    publicResClass = true                          // 다른 모듈에서 Res 접근 허용
    packageOfResClass = "me.sample.library.resources" // 패키지 고정
}

 

packageOfResClass를 명시 안 하면 generated.resources 패키지에 부모 프로젝트명이 섞여 들어가서, 모듈 이름 바꿀 때마다 import가 꼬이는 이슈가 있습니다(#4320). 저는 이거 모르고 리팩터링 한 번에 import를 수십 개 다시 잡았어요. 처음부터 패키지를 고정해두시길 권합니다.

 

MOKO에서 Compose Resources 마이그레이션할 때 막히는 지점

"폴더만 옮기면 된다"는 말, 절반만 맞습니다. MR.strings.xxx를 Res.string.xxx로 전면 치환해야 하는데, 정규식으로 일괄 바꿔도 손볼 게 남더라고요.

 

  1. 모코 전용 기능은 대체재를 직접 만들어야 합니다. 색상 리소스 같은 건 공식 API에 아직 없어서 우회 코드를 짰어요.
  2. 복수형 처리에 함정이 있습니다. pluralStringResource는 count를 개수 선택용으로 한 번, 포맷 인자로 또 한 번, 총 두 번 넘겨야 합니다. 버그가 아니라 설계상 그렇게 동작하는 거라, 이 계약을 모르면 "3 items"가 "1 item"으로 찍혀요(관련 논의 #4675).
  3. qualifier 재정리랑 빌드 재생성이 겹치면서 예상보다 시간이 꽤 들었습니다.

 

개인적으로는 이 마이그레이션을 얕봤다가 반나절 넘게 잡았어요. 공식 지원이 2024년 2월에 시작될 땐 아직 어린 편이었지만, 지금은 2년 반쯤 지나 1.7.x대까지 안정화 단계에 들어선 안정권이라, 그때보단 마음 놓고 옮기셔도 됩니다.

 

commonMain에 로직을 올렸다면 리소스도 올릴 수 있습니다

결국 로직을 commonMain에 올렸다면 리소스도 올릴 수 있습니다. KMP 리소스 공유는 이제 서드파티 없이도 충분히 굴러갑니다.

 

두 갈래 중에 뭘 먼저 챙길지 물으신다면, 저는 주저 없이 동기 로딩과 멀티모듈 Res 충돌 이 둘을 꼽습니다. 화려한 다국어·다크테마 자동 처리는 폴더만 나눠두면 알아서 굴러가지만, 이 두 함정은 모르고 들어가면 그날 반나절이 통째로 날아가니까요. 대용량 이미지는 경로를 나누고, 멀티모듈이면 packageOfResClass와 alias를 처음부터 깔고 시작하는 것. 저는 이 순서로 다시 짤 생각입니다.

 

반응형
LIST