[Android] In-app update 적용 해보기 with Compose

이지훈·2024년 12월 11일
2
post-thumbnail

서두

PlayStore 에 앱을 배포하고 운영하다 보면, PlayStore 의 자체 캐시 문제 때문에 앱을 업데이트하여 사용자에게 이를 알려도, 업데이트가 제때 반영되지 않는 경우가 종종 발생한다. (업데이트 버튼이 노출되지 않음. 이는 내부 테스트를 진행할 때도 마찬가지이다.)

그때마다 사용자에게 플레이스토어 앱의 캐시를 지우라고 안내를 해줄 수 도 없는 노릇이고, 캐시를 지우더라도 바로 업데이트가 반영이 되지 않는 상황도 존재하였는데, 이 때문에 In-app update 라는 선택지를 고민해보게 되었다.


어떻게 업데이트 관련 처리를 진행하는지 여러 앱들을 분석해봤는데, 툴팁을 통해 캐시 문제를 해결하는 방법을 안내하는 앱도 존재하였다! wow

사실 예전부터 '한번 적용해봐야지...' 라고 생각하면서, 미뤄왔던 인앱 업데이트를 현재 사이드로 운영 중인 개인 앱에 적용해보고 구현 방법과 구현 시 주의할 점에 대해서 공유 해보도록 하겠다.

개인의 주관이 다소 많이 반영된 글이며, 반박시 당신이 맞습니다.

In-app update 구현 방식 소개

우선 앱의 업데이트에는 크게 두 가지의 케이스가 존재한다.

강제 업데이트(IMMEDIATE, 즉시 업데이트)

  • 업데이트를 하지 않으면 앱을 사용하는데 문제가 존재
  • 업데이트를 거절할 수 없음(거절할 경우, 앱이 종료)

선택 업데이트(FLEXIBLE, 유연한 업데이트)

  • 업데이트를 하지 않아도 앱을 사용하는데 문제가 없음
  • 업데이트를 거절할 수 있음(나중에 업데이트를 원할 때, 진행할 수 있음)
  • 위에 이미지 처럼 바텀 시트의 형태로 업데이트 진행 여부를 선택(바텀 시트 뒤에 앱의 화면이 보임, 시스템 백버튼을 누르면 바텀시트가 닫힘, 업데이트 거절로 판단)
  • 인앱 업데이트의 선택 업데이트의 경우, 백그라운드 다운로드를 지원(앱 업데이트 버전을 다운로드 하는 동안, 앱을 이용할 수 있음)

이번 인앱 업데이트 구현시, 처음엔 이 두 경우의 업데이트를 평소처럼 한 화면 내에서 분기 처리를 통해 구현 하려고 하였다.

하지만 두 업데이트는 엄연히 성격이 다르고, 선택 업데이트의 경우 반드시 앱 진입시 최초로 뜨는 스플래시 화면에서 진행할 필요는 없다고 생각하였다. (업데이트 버전이 다운로드 되는 동안, 사용자가 앱을 이용할 수 있기 위해선 더더욱)

업데이트의 진행 상황에 따른 분기 처리 및 Activity 의 Lifecycle 에 따른 분기 처리, InstallUpdateListener 의 등록, 해제 여부 등 각각의 업데이트 마다 다르게 처리해줘야 할 부분이 존재한다.
이때 각 업데이트를 다른 화면에서 진행한다면, 화면의 복잡성을 낮출 수 있다.
(그에 따른 버그의 발생 가능성도 감소시킬 수 있다고 생각한다.)

따라서 강제 업데이트는 스플래시 화면에, 선택 업데이트는 홈 화면에 각각을 분리하여 코드를 작성하게 되었고, 업데이트 플로우는 다음과 같다.

강제 업데이트

앱 실행 -> 스플래시 화면(강제 업데이트 조건이 만족되면 강제 업데이트 트리거)

-> 업데이트 진행 -> 업데이트 완료 후 재시작 -> 홈 화면(로그인이 있다면 로그인 화면)으로 진입

-> 업데이트 거절 -> 앱 종료

선택 업데이트

앱 실행 -> 스플래시 화면 -> 홈 화면(선택 업데이트 조건이 만족되면 선택 업데이트 트리거, 선택 업데이트를 위한 바텀시트가 홈화면 위에 노출됨)

-> 업데이트 진행 -> 업데이트 완료 시, 스낵바를 통해 업데이트 완료를 안내, 앱 재시작 버튼을 제공 -> 버튼을 클릭하여, 앱 재시작 후 업데이트 된 앱 사용

-> 업데이트 거절 -> 홈 화면에 노출된 선택 업데이트 바텀 시트가 내려가고, 정상적으로 앱 실행 가능

다만, 선택 업데이트를 거절하였는데도 불구하고, 이후 앱에 재진입할 때마다 업데이트가 트리거될 경우, 사용자의 경험을 저해할 수 있다고 판단하였다.

따라서 사용자가 선택 업데이트를 거절한 경우엔, 해당 거절 버전을 DataStore 에 저장하여, 다음에 앱에 진입시 이미 거절한 버전인지 조회하여, 다시 업데이트 트리거가 동작하지 않도록 구현해보았다.(이후 업데이트 버전이 갱신되면 선택 업데이트가 트리거 된다.)

업데이트 거절 시각을 저장하여, 일정 시간이 지난 시점(ex. 하루 뒤)에 다시 선택 업데이트를 트리거하는 방식도 괜찮을 듯 하다.

본론

코드 레벨로 넘어가도록 하겠다.

우선 인앱 업데이트를 구현할 모듈에 인앱 업데이트 라이브러리 의존성을 추가해준다.

app-update = { group = "com.google.android.play", name = "app-update", version.ref = "app-update" }
// For Kotlin users also add the Kotlin extensions library for Play In-App Update:
app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "app-update" }

강제 업데이트와 선택 업데이트를 구현한 코드는 아래와 같다.

강제 업데이트(Splash 화면)

@Composable
fun SplashRoute(
    navigateToOnBoarding: (NavOptions) -> Unit,
    navigateToHome: (NavOptions) -> Unit,
    viewModel: SplashViewModel = hiltViewModel(),
) {
    val context = LocalContext.current
    val activity = context.findActivity()
    // AppUpdateManager 초기화
    val appUpdateManager: AppUpdateManager = remember { AppUpdateManagerFactory.create(context) }
    // Activity 의 Lifecycle(onResume 콜백 호출)을 감지하기 위함 
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    // https://developer.android.com/topic/libraries/architecture/compose?hl=ko 참고 
    val lifecycleState by lifecycle.currentStateFlow.collectAsStateWithLifecycle()

    val appUpdateResultLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartIntentSenderForResult(),
    ) { result ->
        // 사용자가 업데이트를 취소하는 경우, 앱 종료
        if (result.resultCode == Activity.RESULT_CANCELED) {
            activity.finish()
        }
    }

    LaunchedEffect(Unit) {
        try {
            val appUpdateInfo = appUpdateManager.appUpdateInfo.await()

            // 업데이트가 가능한 상황인지 확인
            if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
                // 업데이트 버전 코드 조회 
                val availableVersionCode = appUpdateInfo.availableVersionCode()
                // 강제 업데이트가 필요한 상황인지 확인
                if (isValidImmediateAppUpdate(availableVersionCode) &&
                    appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
                ) { 
                	// 업데이트 시작 
                    appUpdateManager.startUpdateFlowForResult(
                        appUpdateInfo,
                        appUpdateResultLauncher,
                        AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(),
                    )
                } else {
                	// 강제 업데이트가 필요하지 않은 경우, 기존 로직 진행(온보딩 진행 여부 확인)
                    viewModel.checkOnboardingStatus()
                }
            } else {
                // 강제 업데이트가 필요하지 않은 경우, 기존 로직 진행(온보딩 진행 여부 확인)
                viewModel.checkOnboardingStatus()
            }
        } catch (e: Exception) {
            Timber.e(e, "Failed to check for immediate update")
            viewModel.checkOnboardingStatus()
        }
    }

    // Activity 의 Lifecycle(onResume 콜백 호출)을 감지하기 위함 
    // 아쉽지만 LifecycleResumeEffect 는 내부에 suspend 함수를 사용할 수 없다.
    LaunchedEffect(lifecycleState) {
        if (lifecycleState == Lifecycle.State.RESUMED) {
            try {
                val appUpdateInfo = appUpdateManager.appUpdateInfo.await()
                // 앱 업데이트 도중 항상 업데이트 UI 가 보이도록 설정 
                if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
                    appUpdateManager.startUpdateFlowForResult(
                        appUpdateInfo,
                        appUpdateResultLauncher,
                        AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(),
                    )
                }
            } catch (e: Exception) {
                Timber.e(e, "Failed to check update status on resume")
            }
        }
    }

    SplashScreen()
}

AppUpdateManager 클래스의 appUpdateInfo 함수는 addOnCompleteListener(addOnSuccessListener, addOnFailureListener) 와 같은 콜백 형식으로 앱의 업데이트 정보를 가져온다.

이를 Coroutine 블럭 내에서 동기적으로 처리하고 싶어 suspendCoroutine 을 사용하여, suspend 함수로 만들어 사용해보았다.

suspend fun Task<AppUpdateInfo>.await(): AppUpdateInfo {
    return suspendCoroutine { continuation ->
        addOnCompleteListener { result ->
            if (result.isSuccessful) {
                continuation.resume(result.result)
            } else {
                result.exception?.let { continuation.resumeWithException(it) }
            }
        }
    }
}

선택 업데이트(Home 화면)

@Composable
fun HomeRoute(
    navigateToComplete: (Long, String, String, String) -> Unit,
    onShowSnackbar: suspend (String) -> Boolean,
    modifier: Modifier = Modifier,
    homeViewModel: HomeViewModel = hiltViewModel(),
) {
    val uiState by homeViewModel.uiState.collectAsStateWithLifecycle()
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    // 앱 업데이트 다운로드 완료 및 재시작 안내를 위한 snackbar 정의 
    val snackbarHostState = remember { SnackbarHostState() }
	// AppUpdateManager 초기화
    val appUpdateManager = remember { AppUpdateManagerFactory.create(context) }

    // 업데이트 설치 상태를 구독하기 위한 리스너 설정 
    val installStateUpdatedListener = remember {
        InstallStateUpdatedListener { state ->
            // 업데이트가 다운로드 된 경우 
            if (state.installStatus() == InstallStatus.DOWNLOADED) {
                // 영구 스낵바를 화면에 노출하여, 앱의 업데이트 다운로드 상태를 사용자에게 알림
                // 재시작 버튼을 통해 앱을 업데이트 버전으로 재시작
                scope.launch {
                    val snackbarResult = snackbarHostState.showSnackbar(
                        message = context.getString(R.string.update_ready_to_install),
                        actionLabel = context.getString(R.string.update_action_restart),
                        duration = Indefinite,
                    )

                    // 재시작 버튼 클릭시
                    if (snackbarResult == SnackbarResult.ActionPerformed) {
                        appUpdateManager.completeUpdate()
                    }
                }
            }
        }
    }

    // installStateUpdatedListener 등록 및 해제 관리 
    // Activity 에서 구현할 경우, onCreate() 에서 등록, onDestroy() 에서 해제
    DisposableEffect(Unit) {
        appUpdateManager.registerListener(installStateUpdatedListener)
        onDispose {
            appUpdateManager.unregisterListener(installStateUpdatedListener)
        }
    }

    val appUpdateResultLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartIntentSenderForResult(),
    ) { result ->
        // 업데이트를 거절할 경우, 거절한 버전을 DataStore 에 저장(같은 버전에 업데이트인 경우, 업데이트 트리거 발생 X
        if (result.resultCode == Activity.RESULT_CANCELED && result.data != null) {
            scope.launch {
                appUpdateManager.appUpdateInfo.await().availableVersionCode().let { versionCode ->
                    homeViewModel.setLastRejectedUpdateVersion(versionCode)
                }
            }
        }
    }

    LaunchedEffect(Unit) {
        try {
            val appUpdateInfo = appUpdateManager.appUpdateInfo.await()

            if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
                val availableVersionCode = appUpdateInfo.availableVersionCode()
                // 선택 업데이트 인지 && 기존에 이미 거절한 업데이트 버전이 아닌지 확인 
                if (!isValidImmediateAppUpdate(availableVersionCode) &&
                    !homeViewModel.isUpdateAlreadyRejected(availableVersionCode) &&
                    appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
                ) {
                 	// 선택 업데이트 진행     
                 	appUpdateManager.startUpdateFlowForResult(
                        appUpdateInfo,
                        appUpdateResultLauncher,
                        AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
                    )
                }
            }
        } catch (e: Exception) {
            Timber.e(e, "Failed to check for flexible update")
        }
    }

    HomeScreen(
    	...
        snackbarHostState = snackbarHostState,
    )
}

@Composable
fun HomeScreen(
    ...
    snackbarHostState: SnackbarHostState,
    modifier: Modifier = Modifier,
) {
    Surface(
        modifier = modifier.fillMaxSize(),
        color = Gray50,
    ) {
        Box(modifier = Modifier.fillMaxSize()) {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .verticalScroll(rememberScrollState())
                    .padding(bottom = 32.dp),
            ) {
            ...
            
            SnackbarHost(
                hostState = snackbarHostState,
                modifier = Modifier.align(Alignment.BottomCenter),
            )
            ...
        }
    }
}

공식문서에서 제안하는 업데이트 다운로드 완료시 스낵바를 화면에 노출하는 방법을 차용하였고, 필요에 따라 재시작 버튼외에, 당장 업데이트 설치를 미루는 '나중에' 버튼 등을 추가할 수 있을 것 같다.

문제 발생 1)

val availableVersionCode = appUpdateInfo.availableVersionCode()

if (isValidImmediateAppUpdate(availableVersionCode) &&
	appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
) {
	appUpdateManager.startUpdateFlowForResult(
    	appUpdateInfo,
        appUpdateResultLauncher,
        AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(),
    )
 }

위의 코드 처럼 AppUpdateManager 클래스에서 제공하는 AppUpdateInfo 를 통해 앱의 버전을 가져올 때, VersionName 이 아닌, VersionCode 만을 가져올 수 있다.

기존에는 다음과 같이 앱의 버전을 관리하였는데,

libs.version.toml

[versions]

minSdk = "28"
targetSdk = "34"
compileSdk = "34"
versionCode = "5" # 버전 코드, 업데이트 마다 1씩 증가 
versionName = "2.1.1" # 버전 네임, 강제 업데이트의 경우 major or minor 버전 1 증가, 선택 업데이트의 경우 patch 버전 1 증가
packageName = "com.nexters.bandalart.android"

... 

현재의 버전 관리 방식으로는 VersionCode 만으로는 이번 업데이트가 강제인지, 선택인지 알 수 있는 방법이 없었다.

또한 availableVersionCode 의 함수는 반환 타입이 Int 이므로, "020101" 과 같이 VersionCode 를 관리한다면 20101 의 형태로 반환된다. 문자열의 인덱스를 통한 버전 비교시, 원하는 대로 동작하지 않는 버그가 발생할 수 있어 주의를 필요로 한다.알고 싶지 않았다.

문제 해결 1)

버전 관리 방식을 다음과 같이 변경하였다.

[versions]

minSdk = "28"
targetSdk = "34"
compileSdk = "34"
majorVersion = "2"
minorVersion = "2"
patchVersion = "0"
packageName = "com.nexters.bandalart.android"

??? versionCode, versionName 어디감?

internal class AndroidApplicationConventionPlugin : BuildLogicConventionPlugin(
    {
        applyPlugins(Plugins.ANDROID_APPLICATION, Plugins.KOTLIN_ANDROID)

        extensions.configure<ApplicationExtension> {
            configureAndroid(this)

            defaultConfig {
                targetSdk = libs.versions.targetSdk.get().toInt()

                val major = libs.versions.majorVersion.get().toInt()
                val minor = libs.versions.minorVersion.get().toInt()
                val patch = libs.versions.patchVersion.get().toInt()

                versionCode = (major * 10000) + (minor * 100) + patch
                versionName = "$major.$minor.$patch"
            }
        }
    },
)

이처럼 defaultConfig 블럭 내에서 각 x.y.z 버전을 조합하여 명시해주었다.

versionCode = 20200
versionName = "2.2.0"

이제 업데이트를 진행하게될 경우, VersionCode 와 VersionName 을 각각 변경해주지 않고, 변경할 x.y.z 버전만 수정해주면 된다.

versionCode 의 경우 Int 타입으로 관리하도록 변경하였다.
이를 반영한 버전 코드 비교 함수는 다음과 같다.

// core:common utils.InAppUpdate.kt

fun isValidImmediateAppUpdate(updateVersion: Int): Boolean {
    val updateMajor = updateVersion / 10000
    val updateMinor = (updateVersion % 10000) / 100

    val currentMajor = BuildConfig.VERSION_CODE / 10000
    val currentMinor = (BuildConfig.VERSION_CODE % 10000) / 100

    return updateMajor > currentMajor || updateMinor > currentMinor
}

app 모듈이 아닌 다른 모듈에서 VERSION_CODE 를 사용하고자 하는 경우, build.gradle.kts 에 buildConfigField 를 정의하여 가져올 수 있다.

// core:common 모듈 build.gradle.kts

android {
    namespace = "com.nexters.bandalart.core.common"

    defaultConfig {
        val major = libs.versions.majorVersion.get().toInt()
        val minor = libs.versions.minorVersion.get().toInt()
        val patch = libs.versions.patchVersion.get().toInt()
        val versionCode = (major * 10000) + (minor * 100) + patch

        buildConfigField("int", "VERSION_CODE", "$versionCode")
    }

    buildFeatures {
        buildConfig = true // 이거 곧 deprecated 된다고 함 
    }
}

문제 발생 2)

강제 업데이트 코드를 적용한 이후, 테스트를 해봤는데 업데이트 자체를 정상적으로 이뤄지나, 업데이트 완료 직후, 앱이 종료되고 나서, 재시작이 되지않는 문제가 발생하였다...

분명 공식 문서에서는 재시작까지 이뤄지는게 기본 동작이라고 언급이 되어있는데 무엇이 문제일까?...

문제 해결 2)

관련해서 구글링을 해보았는데, 생각보다 많은 글들이 있었다. 그 중엔 이와 같은 문제에 대한 Issue Tracker 글도 있었다. (Android 10 이상의 기기에서 발생하는 문제인 듯 하다.)

하지만, 시간이 지나도 fix update 는 진행되지 않는 듯 했다...ㅠ

다행스럽게도, Flutter 의 In-app update 라이브러리 깃허브에도 같은 이슈가 올라온 것을 확인할 수 있었고, 제안해주신 방법대로 Manifest 내의 인앱 업데이트를 수행하는 Activity launch mode 를 singleTask 로 설정하여 업데이트 이후 앱이 재시작하지 않는 문제를 해결할 수 있었다!

결과

인앱 업데이트를 성공적으로 적용하여, 앱을 업데이트할 수 있었고 이제 다음 업데이트 부터는 굳이 플레이스토어에 들어와서, 앱을 업데이트 할 필요가 없어졌다.

찾아본 대부분의 예제가 XML 기반의 Activity 내에서 수행된 코드들이었기에, 이를 참고하여 Composable 함수내에서 구현해보기 위해 Side Effect API 를 여럿 사용(남발) 해봤는데, 비효율적인 부분이 분명 존재할 것이라 판단된다.

이후 리팩토링을 진행하며 개선해보도록 해야겠다.

혹시 코드에 비효율적인 부분이 보이거나, 개선할 부분이 있다면 덧글을 통해 피드백 주시면 감사하겠습니다! 피드백 언제든 환영!

또한 테스트를 여러번 해봤지만, 아직 발견하지 못한 edge 케이스들이 존재할 수 있기 때문에(강제 업데이트 진행 중 프로그래스바 위에 'X' 버튼을 클릭한다던가, 뒤로가기 버튼을 누르거나 앱을 강제 종료하는 경우 등) 추가적인 테스트를 진행해보도록 해야겠다.

개인적인 의견으론 강제 업데이트이기 때문에, 프로그래스바 위에 'X' 버튼의 활성화 여부를 제어할 수 있는 옵션이 있으면 좋을 것 같은데, 지원하지 않는 것 같다.

전체 코드는 아래 깃허브 레포리토리 링크에서 확인할 수 있습니다.
https://github.com/Nexters/BandalArt-Android

P.S

배포와 관련된 작업이니 만큼, 문제가 발생하면 치명적이기에, 내부 앱 공유를 통한 테스트 를 많이 해봐야 했는데, 그 과정이 정말 귀찮았다.

문제를 해결하기 위한 코드가 제대로 적용되었는지 해보기 위해선, 최소 2번의 배포가 필요하기 때문이다. (새로 업데이트 버전이 배포되었을 때, 그 이전 버전에 해당 문제 상황에 대한 대응이 되어있어야 하기 때문)

이 글을 통해 다른 분들이 나와 같이 테스트 반복과정을 그나마 덜 겪었으면 좋겠다.

레퍼런스)
https://developer.android.com/guide/playcore/in-app-updates
https://blog.kmshack.kr/AppUpdateManager/
https://developer.android.com/reference/com/google/android/play/core/appupdate/AppUpdateInfo
https://github.com/jonasbark/flutter_in_app_update/issues/82
https://medium.com/@agangurde333/exploring-the-in-app-update-feature-in-android-77532708908a
https://stackoverflow.com/questions/78117226/in-gradle-how-to-access-the-version-of-a-dependency-from-the-version-catalog-so
https://developer.android.com/topic/libraries/architecture/compose?hl=ko
https://medium.com/@csh153/%EC%8B%A0%EC%83%81%EC%9D%B4%EC%9A%94-lifecycle-2-7-0%EC%9C%BC%EB%A1%9C-jetpack-compose-%EC%83%9D%EB%AA%85%EC%A3%BC%EA%B8%B0-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0-3531ceba1f57

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글

관련 채용 정보