본문 바로가기

Android/Firebase

[Firebase] Firebase의 RemoteConfig 데이터를 사용할 때 주의할 점.

728x90

필자가 실무에서 Firebase의 RemoteConfig 데이터를 가져와서 사용하는 경우는 대부분 앱의 version을 체크하기 위해서였고, 지금 업무에서도 version 체크하는 용도로 RemoteConfig를 사용하고 있다.

그리고 그 RemoteConfig에서 데이터를 가져와 사용하는 부분에서 문제가 발생하여, 필자가 입사하기 전 개발자가 짜둔 코드를 확인해 보았다.

코드를 확인하다 보니 생각보다 정확히 알고 있지 않다면 문제가 발생할 수 있겠거니 싶은 부분에서 문제가 생겼었고, 강제 업데이트가 자주 발생했다면 진즉에 문제가 발생했었겠지만 그렇지 않았기 때문에 지금까지 방치되고 있던 문제가 아니었나 싶었다.

 

그리고 이번 글에서는 간단하게나마 해당 이슈가 발생한 코드와 이유, 해결하는 방법에 대해서 작성해보고자 한다.

설명하기 앞서, Firebase에서 제공해주는 코드와 같은 부분만 가져왔으며, 추가적으로 앱에서 사용되는 코드는 제거했음을 미리 안내하고자 한다.


우선,

Firebase에서 RemoteConfig를 사용하기 위한 기본적인 작업들에 대해서는 이미 아주 상세히 작성해 둔 블로그들도 많기 때문에 쉽게 찾아서 따라 할 수 있을 것이라 생각하고 넘어가도록 하겠다.

 

필자는 앞서 언급한 것 처럼 RemoteConfig의 데이터를 version을 체크하는 용도로 사용하고 있었기 때문에, Splash 화면에서 해당 데이터를 가져와서 사용하고 있었다.

 

사용하던 코드는 다음과 같다. 

private fun getServerVersion(): String {
    val firebaseRemoteConfig = FirebaseRemoteConfig.getInstance()
    firebaseRemoteConfig.fetch(3600)
        .addOnCompleteListener(this) { task ->
            if (task.isSuccessful) {
                firebaseRemoteConfig.activate()
            }
        }

    return firebaseRemoteConfig.getString("SERVER_APP_VERSION")
}

 

이와 같이 코드를 작성해주고, remoteConfig에서 version 데이터를 가져와서 현재 버전과 비교한 후 이후 flow를 수행시키고 있었다.

해당 코드와, firebase 공식 문서에서 제공하는 코드를 확인해 보면 차이점이 있다는 것을 알 수 있다.

 

Firebase 문서에서 확인할 수 있는 코드는 다음과 같다.

remoteConfig.fetchAndActivate()
    .addOnCompleteListener(this) { task ->
        if (task.isSuccessful) {
            val updated = task.result
            Log.d(TAG, "Config params updated: $updated")
            Toast.makeText(
                this,
                "Fetch and activate succeeded",
                Toast.LENGTH_SHORT,
            ).show()
        } else {
            Toast.makeText(
                this,
                "Fetch failed",
                Toast.LENGTH_SHORT,
            ).show()
        }
        displayWelcomeMessage()
    }

 

fetchAndActivate()를 사용한 반면, 프로젝트에 적용된 코드는 fetch와 activate가 나뉜 방식을 사용하고 있었다.

년 단위로 이전에 짜여진 코드이기 때문에, 이렇게 구현한 이유는 모르겠지만 이 두 가지 방식의 차이점은 존재한다.

 

첫 번째, 이전 코드에서의 flow를 확인하면 fetch가 발생하고 완료 시 activate를 호출하게 된다.

fetch를 사용해 비동기 적으로 Firebase에 설정한 값들을 가져오고,

activate를 통해 선언한 remoteConfig 객체에 데이터를 저장하여 객체를 통해 값을 가져올 수 있도록 한다.

즉 기존 사용하던 코드의 문제점은,

비동기로 동작하는 fetch와 activate 함수를 각각 호출하는데 그 결과로 얻을 수 있는 데이터를 return 해주고 있기 때문에 return 하는 시점에 fetch와 activate 함수가 모두 끝나지 않을 가능성이 매우 크기 때문에 정상적인 데이터를 가져오지 못할 가능성이 크다.

또한, 최초 접속시에는 캐싱한 데이터조차 존재하지 않아 빈 값을 반환할 수 있으며, 

fetch가 끝나더라도 activate가 끝나지 않은 시점에서 return을 수행하게 되면 원하지 않는 데이터가 반환될 가능성이 상당히 높다.

 

두 번째, firebase에서 안내하는 코드를 보면 fetchAndActivate를 사용하고 있는데 첫 번째 방법의 문제점인 fetch와 activate 함수가 비동기적으로 동작한다는 것을 해결한 것이다. 함수의 이름 그대로 fetch와 activate를 동시에 수행하여 결과 값을 반환해 주는 것인데, 내부 함수를 보면 다음과 같이 되어있다.

@NonNull
public Task<Boolean> fetchAndActivate() {
  return fetch().onSuccessTask(executor, (unusedVoid) -> activate());
}

 

사용자가 수행해야하는 동기화 작업을 해당 함수를 통해 간단하게 수행할 수 있게 만들어둔 것이라고 생각하면 된다.

 

다시 돌아와서, 필자는 첫 번째 코드와 같은 문제로 인해서 잘못된 데이터를 가져와 문제가 발생하게 된 것인데,

fetch가 정상적으로 끝난 후 return이 발생하도록 동기적으로 처리한다고 하더라도, fetchAndActivate를 사용하지 않았기 때문에 activate가 끝나기 전에 return이 발생하여 동일한 문제가 발생할 가능성이 존재하기 때문에 근본적으로 함수를 수정할 수밖에 없다.

 

따라서, 해당 작업을 동기적으로 처리하기 위해서는 suspend와 별도의 쓰레드를 적용하여 다음과 같이 구현할 수 있다.

private suspend fun getServerVersion(): String = withContext(Dispatchers.IO) {
    val firebaseRemoteConfig = FirebaseRemoteConfig.getInstance()
    val configSettings = FirebaseRemoteConfigSettings.Builder()
        .setMinimumFetchIntervalInSeconds(3600)
        .build()
    firebaseRemoteConfig.setConfigSettingsAsync(configSettings)

    try {
        val fetchTask = firebaseRemoteConfig.fetchAndActivate()
        Tasks.await(fetchTask)
        if (fetchTask.isSuccessful) {
            firebaseRemoteConfig.getString("SERVER_APP_VERSION")
        } else {
            // 오류 처리나 기본값 반환
        }
    } catch (e: Exception) {
        // 예외 처리
    }
}

 

IO Thread에서 해당 작업을 수행하도록 설정하며, Tasks.await를 통해 fetchAndActivate가 정상적으로 끝난 후 결과 값을 반환시키도록 구현한 부분이다.

 

여기서 잠시, setMinimumFetchIntervalInSeconds 함수에 대해 짚어보고 넘어가자.

말 그대로 최소 fetch에 대한 최소 시간을 지정해주는 것인데, 위의 코드를 사용하면 3600초, 즉 한 시간의 최소 fetch 시간이 설정되어 있기 때문에 한 시간 내에 fetch를 다시 요청하게 되면 기존에 캐싱해 둔 데이터를 사용하게 된다.

 

설명만 보면, 그럼 해당 기간을 최소화 해서 항상 최신의 데이터를 가져오면 되는 것 아닌가?라고 생각할 수 있다.

이 의문에 대한 답은 firebase 공식 문서에 나와있다.

앱에서 단기간에 가져오기를 너무 많이 수행하면 가져오기 호출이 제한되고 SDK는 FirebaseRemoteConfigFetchThrottledException을 반환합니다.
SDK 버전 17.0.0 이전에는 60분 동안 가져오기 요청 수가 5회로 제한되었지만 최신 버전에서는 좀 더 많이 허용됩니다.

 

짧은 시간동안 계속해서 요청하게 되면 오류가 발생하기 때문에, 적당한 시간을 기준으로 데이터를 가져오는 것이 좋다.

그래서 보통(?) 가장 적절하다고 하는 interval 시간인 1시간을 사용하여 가져온다.

물론 더 짧게 요청해도 되고, 최신 버전을 사용하면 실시간으로 데이터를 가져올 수 있는 addOnConfigUpdateListener를 사용하여 업데이트를 할 수 있기 때문에 필요에 따라서 선택하여 사용하면 될 것으로 보인다.

 

다시 위의 함수로 넘어가서,

위와 같이 별도의 thread를 지정하고 함수 수행이 끝날 때 까지 기다렸다가 호출하면 문제는 없다.

하지만, 사용에 따라 IO Thread에서 수행하는 것이 아닌 모든 함수를 main Thread에서 수행해야 하는 경우가 있을 수 있다.

 

따라서 필자는 config 데이터를 반환하는 값으로 사용하지 않고, 함수를 반환하게 하여 이후 플로우를 수행시켰다.

쉽게 이해할 수 있게 코드로 확인해보자.

private fun getServerVersion(callback: (String) -> Unit) {
    val firebaseRemoteConfig = FirebaseRemoteConfig.getInstance()
    val configSettings = FirebaseRemoteConfigSettings.Builder()
        .setMinimumFetchIntervalInSeconds(3600)
        .build()
    firebaseRemoteConfig.setConfigSettingsAsync(configSettings)

    firebaseRemoteConfig.fetchAndActivate()
        .addOnCompleteListener { task ->
            if (task.isSuccessful) {
                val version = firebaseRemoteConfig.getString("SERVER_APP_VERSION")
                callback(version)
            } else {
                // 오류 처리
                callback("")
            }
        }
}

 

이처럼 고차 함수로 만들어서 사용하면 된다.

결과 값이 들어오면 그에 따른 callback 함수를 호출하여 getServerVersion 함수를 호출한 부분에서 함수로 받아서 처리하면 발생할 수 있는 비동기적 타이밍 이슈를 한 번에 해결할 수 있다.

 

여기까지 잘 이해한 사람은 한 가지 의문이 들 것이다.

초기 함수에서 반환 타입만 바꾸면 되는 것 아닌가?

맞다.

필자도 반환하고 나서 눈치챘던 부분인데, 그냥 처음 부분에서 activate 부분에 completeListener를 달아주고, 거기서 콜백 함수를 반환하면 되는 것이다.

private fun getServerVersion(callback: (String) -> Unit) {
    val firebaseRemoteConfig = FirebaseRemoteConfig.getInstance()
    val configSettings = FirebaseRemoteConfigSettings.Builder()
        .setMinimumFetchIntervalInSeconds(3600)
        .build()
    firebaseRemoteConfig.setConfigSettingsAsync(configSettings)

    firebaseRemoteConfig.fetch(3600)
        .addOnCompleteListener(this) { task ->
            if (task.isSuccessful) {
                firebaseRemoteConfig.activate()
                    .addOnCompleteListener { resultTask ->
                        if (resultTask.isSuccessful) {
                            val version =
                                firebaseRemoteConfig.getString(FirebaseInfo.SERVER_APP_TEST_VERSION)
                            callback(version)
                        } else {
                            callback("")
                        }
                    }
            }
        }
}

 

이렇게 말이다.

 

이렇게 구현한 후, String 타입으로 버전을 받아오던 부분에서 데이터 처리 부분만 살짝 손봐주면 문제없이 동작하는 것을 확인할 수 있다.


이렇게, 

많은 허튼짓을 거치며 돌아 돌아 처음 함수에서 큰 차이 없이 문제를 해결해 보았다.

정말 간단한 부분이지만, 정말 쉽게 놓칠 수 있는 부분이라고 생각이 들었던 부분이며 firebase의 remoteConfig에서 데이터를 가져다 사용하는 경우가 많지 않다 보니 비동기적 처리에 대해 인지를 못하고 개발을 할 수 있겠구나 싶었다.

 

그래도 덕분에, remoteConfig를 사용한 데이터 처리에 대해 다시 알아볼 수 있는 좋은 기회가 되었다고 생각하며

비동기적인 flow를 동기적인 flow로 변환하는 것에 대해서 고차 함수를 사용하여 아주 간단하게 변환할 수 있다는 것을 다시 한번 깨닫게 되었다.

 

해당 게시글에 해당하는 예제는 별도로 만들지 않았지만,

위의 코드만을 사용하여 정상적인 동작을 하기 때문에 샘플은 만들지 않고 넘어가도록 하겠다.

728x90