오늘은 기존 습관 기록 앱에 쿠팡 파트너스 API를 연동하여 광고 상품을 보여주는 페이지를 개발하고, 사용자 경험을 개선하기 위해 이미지 로딩 성능을 최적화하는 과정을 거쳤습니다. 이 글에서는 전체 개발 과정과 마주쳤던 문제들, 그리고 해결 방법을 공유합니다.
1. ViewModel 상태 관리 및 API 연동
가장 먼저, API 통신 결과를 UI에 효과적으로 전달하기 위해 ViewModel에서 상태 관리를 구현했습니다. API 요청 상태를 Loading, Success, Error로 나누어 관리하는 AdProductState Sealed Interface를 정의하고, 이를 StateFlow로 UI에 노출시켰습니다.
// MainViewModel.kt
sealed interface AdProductState { object Loading : AdProductState data class Success(val products: List<BestProduct>) : AdProductState data class Error(val message: String) : AdProductState }
@HiltViewModel class MainViewModel @Inject constructor(...) : ViewModel() {
private val _adProductState = MutableStateFlow<AdProductState>(AdProductState.Loading) val adProductState = _adProductState.asStateFlow()
fun fetchCupangData(context: Context) { viewModelScope.launch { _adProductState.value = AdProductState.Loading try { // ... API 호출 로직 ... val response = CupangClient.getClient().getBestCategories(...)
if (response.data != null) { _adProductState.value = AdProductState.Success(response.data) // 성능 개선을 위한 이미지 프리로딩 호출 preloadImages(context, response.data) } else { _adProductState.value = AdProductState.Error("No products found") } } catch (e: Exception) { _adProductState.value = AdProductState.Error(e.message ?: "Unknown error") } } } // ... }
2. Composable UI 구현: AdProductPage
ViewModel에서 제공하는 adProductState를 구독하여 상태에 따라 다른 UI를 보여주는 Composable 화면을 만들었습니다. LazyColumn을 사용하여 상품 목록을 효율적으로 표시하도록 구성했습니다.
// AdProductPage.kt
@Composable fun AdProductPage(viewModel: MainViewModel = hiltViewModel()) { val state = viewModel.adProductState.collectAsState() when (val currentState = state.value) { is AdProductState.Loading -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } is AdProductState.Success -> { LazyColumn(modifier = Modifier.fillMaxSize()) { items(currentState.products) { product -> // 상품 아이템 UI } } } is AdProductState.Error -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(text = currentState.message) } } } }
3. 이미지 로딩 성능 개선 과정
단순히 이미지 URL을 Coil에 넘겨주자, 사용자가 스크롤할 때마다 로딩 인디케이터가 보이면서 사용자 경험을 해쳤습니다. 앱 시작 시점에 이미 상품 정보를 모두 가져왔으므로, 광고 페이지에 진입했을 때는 이미지가 즉시 표시되어야 했습니다.
3.1. 1차 개선: 로딩 인디케이터와 플레이스홀더
우선 로딩 중임을 명확히 보여주기 위해 AsyncImage 대신 SubcomposeAsyncImage를 사용하고, loading 파라미터에 CircularProgressIndicator를 설정해주었습니다. 이는 로딩이 길어질 때 사용자에게 피드백을 주기 위한 최소한의 조치였습니다.
근본적인 문제를 해결하기 위해, 이미지 프리로딩 기법을 도입했습니다. ViewModel에서 상품 정보 API를 호출한 직후, 응답으로 받은 이미지 URL 목록을 Coil의 캐시에 미리 저장하는 방식입니다.
주의: 처음에는 preloadImages 함수 내에서 ImageLoader(context)로 새로운 이미지 로더 인스턴스를 만드는 실수를 했습니다. 이 경우 프리로딩에 사용된 캐시와 UI에서 사용된 캐시가 달라져 효과가 없었습니다.
애플리케이션 전역에서 사용되는 싱글톤 ImageLoader 인스턴스 (context.imageLoader)를 사용해야 캐시가 공유되어 프리로딩이 정상적으로 동작합니다.
// MainViewModel.kt (최종)
private fun preloadImages(context: Context, products: List<BestProduct>) { // context.imageLoader를 사용하여 싱글톤 인스턴스에 접근 val imageLoader = context.imageLoader products.forEach { val request = ImageRequest.Builder(context) .data(it.productImage) // 실제 이미지를 보여줄 필요는 없으므로, 메모리/디스크 캐시에만 저장 .build() imageLoader.enqueue(request) } }
4. 캐시 동작 확인 및 디버깅
프리로딩이 정말 효과가 있는지 확인하기 위해, SubcomposeAsyncImage의 onSuccess 콜백을 사용하여 이미지 데이터의 출처(dataSource)를 로그로 확인했습니다.
앱을 처음 실행하고 광고 페이지에 접근했을 때는 NETWORK 또는 DISK에서 이미지를 로드했지만, 앱을 다시 시작하거나 다른 화면에 갔다 돌아오면 MEMORY 캐시에서 이미지를 즉시 불러오는 것을 확인할 수 있었습니다.
OnBackInvokedCallback 경고: Android 13의 예측 뒤로가기 제스처 지원을 위해 AndroidManifest.xml에 android:enableOnBackInvokedCallback="true" 속성을 추가했습니다.
hiltViewModel Deprecation:androidx.hilt.navigation.compose.hiltViewModel 대신 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel를 사용하도록 import 경로를 수정했습니다.
결론
단순한 기능 구현에서 시작하여, 사용자 경험을 저해하는 성능 문제를 발견하고 이를 개선하는 과정을 통해 많은 것을 배울 수 있었습니다. 특히 이미지 로딩과 같은 비동기 작업을 다룰 때, 캐시 전략과 라이브러리의 동작 원리를 정확히 이해하는 것이 얼마나 중요한지 다시 한번 깨닫게 되었습니다. 이제 사용자는 광고 페이지에 진입했을 때, 지연 없이 상품 이미지를 즉시 확인할 수 있게 되었습니다.
#billcorea #운동동아리관리앱
🏸 Schneedle, um aplicativo obrigatório para clubes de badminton!
👉 Match Play – Grave pontuações e encontre oponentes 🎉
Perfeito para qualquer lugar, sozinho, com amigos ou em um clube! 🤝
Se você gosta de badminton, definitivamente experimente
Acesse o aplicativo 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay
습관관리 앱 구현 과정 : Jetpack Compose에서 TopAppBar 구현 과정, 동적 버전 표시 및 웹 연동
앱 추가 및 수정 화면
Jetpack Compose를 사용한 안드로이드 앱 개발 중, 사용자에게 일관된 경험을 제공하기 위해 공통 TopAppBar를 구현한 과정을 공유합니다. 이 글에서는 TopAppBar에 앱 아이콘, 동적으로 가져온 앱 이름과 버전, 그리고 외부 URL로 연결되는 정보 아이콘을 추가하는 방법을 단계별로 설명합니다.
1. TopAppBar 구현 위치 결정: MainActivity
처음에는 각 화면(HomeScreen)에 TopAppBar를 추가할까 고민했지만, 앱 전체의 일관성 및 확장성을 위해 MainActivity.kt의 MainScreen Composable 내에 Scaffold를 사용해 구현하기로 결정했습니다. 이렇게 하면 모든 NavHost 화면에 동일한 TopAppBar가 적용됩니다.
// MainActivity.kt
@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen() { // ... NavController, Context 등 초기화
TopAppBar에 표시될 앱 이름은 strings.xml 리소스에서, 버전 이름은 앱의 빌드 정보에서 동적으로 가져오도록 구현했습니다.
문제 발생: BuildConfig 참조 오류
처음에는 build.gradle.kts의 versionName을 가져오기 위해 BuildConfig.VERSION_NAME을 사용하려고 했습니다. 이를 위해 build.gradle.kts 파일에 buildConfig = true 옵션을 추가하고 Gradle 동기화를 수행했지만, IDE에서 BuildConfig 클래스를 찾지 못하는 문제가 계속 발생했습니다.
해결 방안: PackageManager 사용
BuildConfig 문제의 대안으로, PackageManager를 사용하여 런타임에 직접 앱의 버전 정보를 가져오는 안정적인 방법을 선택했습니다. 이 방식은 Gradle 빌드 과정의 영향을 받지 않아 더 유연합니다.
수정된 MainActivity.kt
@Composable fun MainScreen() { // ... val context = LocalContext.current val versionName = remember { try { val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) packageInfo.versionName } catch (e: Exception) { "1.0" // 예외 발생 시 기본값 } }
이번 과정을 통해 Jetpack Compose에서 일관된 UI를 제공하는 TopAppBar를 구현하고, PackageManager를 이용해 동적으로 앱 정보를 표시하며, Intent를 통해 외부 앱과 연동하는 방법을 적용해 보았습니다. 특히 BuildConfig 문제 발생 시 대안을 찾아 해결하는 과정이 좋은 경험이 되었습니다.