이번 WAPP를 출시하기 위해, Google Play Console에 등록을 하고, 심사를 기다렸다.
그러나,, 와피 1.0.0v의 앱 심사 결과는 알 수 없는 ANR 문제로 거절당했다.
당연히 거절은 당할 수 있다고 생각했는데, 어떤 화면에서, 어떤 로직에서 발생했는지 확인할 수 없어 너무 답답했다.
(나와 동료 개발자분 뿐만아니라, 다른 많은 분께서도 릴리즈 앱 테스트를 도와주셨는데, ANR 문제는 그동안 겪은 적도 없기 때문이다.)
이런 상황을 해결하기 위해 동료분과 논의하며, 일단 의심이 가는 로직을 소거하고, Analytics Event를 통해 기록을 남기는 것으로 결정했다.
NowInAndroid의 Firebase Analytics을 기반으로 하여 구현하였습니다.
무엇을 기록해야 할까 ?
앱 내에서는 많은 이벤트가 발생한다.
회원이 처음 화면을 키는 순간, 앱을 처음으로 터치하는 순간, 화면이 전환되는 순간 등등
이러한 이벤트를 기록하면, 앱 내의 사용자의 특성을 확인할 수 있을 뿐더러, 각각이 데이터가 되어 성장할 수 있는 서비스가 되도록 돕는다.
모든 이벤트가 중요하지만, 나는 가장 기본적인 사용자에게 나타나는 화면과, 사용자의 상태를 기록하고자 했다.
일반적으로 Android Xml 기반에서는 Activity 뿐만 아니라 Fragment 단위로도 자동으로 기록이 된다.
하지만, Compose의 경우 자동으로 기록이 되지 않을 뿐더러,
Navigiation이 아닌 State로 화면을 전환하는 경우가 있어 더더욱 자동으로 트래킹하기 어렵다.
따라서 Screen Name과 사용자의 Sign In, Sign Out을 기록하기로 결정했다.
Analytics Set Up !
Firebase 등록과 의존성 추가는 따로 담지 않았습니다.
가장 고민했던 부분은 "어디에 Analytics 구현부가 있어야 할까?" 였다.
내가 생각하는 Analytics는 로그를 찍는 동작이기에, 비즈니스의 핵심 로직은 아니지만, 네트워크는 필요한 나름 무거운 작업이라고 생각했다.
WAPP는 모든 네트워크 비즈니스 로직을 유스케이스로 만들어서 처리하고 있는데,
Analytics도 DataSource-Repository-UseCase 이러한 구조를 거쳐야할까? 에 대해 고민을 했다.
하지만 생각해보면, Analytics는 트래킹이기에 응답이 없고, 트래킹은 서비스내 핵심 로직도 아니며, 외부로부터 데이터를 가져오지 않는다는 점에서 일반적인 구조가 아닌, Analytics의 새로운 모듈을 만들어 구현해야겠다고 생각했다.
안드로이드 공식문서에도 위와 같이, 아키텍처에 대한 고려 없이 Analytics 모듈을 구성하는 것을 추천하고 있었다 ..!
Analytics Model
Analytics Event는 {EventName, {{key : value}, {key : value} ... }과 같이, 기록하고자 하는 이벤트 이름과 함께 key와 value가 묶인 param의 묶음을 전달한다.
따라서 Analytics 구현체에 전달받을 데이터의 형식을 다음과 같이 구성했다.
data class AnalyticsEvent(
val type: String,
val extras: List<Param> = emptyList(),
) {
data class Param(
val key: String,
val value: String,
)
companion object {
const val SCREEN_VIEW = "screen_view" // TYPE
const val SCREEN_NAME = "screen_name" // EXTRA_KEY
}
}
EventName이 들어갈 Type과 Analytics Event와 같이 key, value를 가지는 형식으로 구현하였다.
SCREEN_VIEW, SCREEN_NAME의 경우, 해당 Model을 사용할 때 자주 사용하는 상수로, AnalyticsEvent를 통해 접근하도록 구현하였다.
Analytics Helper
어디서 Analytics를 접근하고 기록할까?
외부로 부터 데이터를 가져오는 DataSource에서도, 유스케이스를 처리하는 ViewModel에서도, 화면을 그리는 View에서도 접근이 가능해야 한다.
WAPP의 경우 Hilt를 통해 의존성을 주입해주고 있으며, Analytics 역시 DataSource 및 ViewModel에서 Hilt를 통한 주입을 통해 구현하고자 했다.
따라서 AnalyticsEvent를 인자로 가지는 인터페이스를 구성하고,
interface AnalyticsHelper {
fun logEvent(event: AnalyticsEvent)
}
AnalyticsHelper의 구현체도 구현하였다.
class AnalyticsHelperImpl @Inject constructor(
private val firebaseAnalytics: FirebaseAnalytics,
) : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) {
firebaseAnalytics.logEvent(event.type) {
for (extra in event.extras) {
// Key, Value Max Length에 따른 slicing
param(
key = extra.key.take(40),
value = extra.value.take(100),
)
}
}
}
}
마지막으로 의존성 주입까지 구현하여, DataSource 및 ViewModel에서의 AnalyticsHelper의 준비가 끝났다.
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
@Binds
@Singleton
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: AnalyticsHelperImpl): AnalyticsHelper
companion object {
@Provides
@Singleton
fun provideFirebaseAnalytics(): FirebaseAnalytics {
return Firebase.analytics
}
}
}
LocalAnalyticsHelper
중요한건 View의 경우였다.
Composition Scope 내에서는 의존성 주입을 따로 받을 수 없어, MainActivity에서 주입을 받아 인자를 통해 넘겨줘야 했다.
이런 경우 모든 Screen을 기록하기에, 모든 Screen Route의 인자가 늘어나며, 이에 따른 보일러 플레이트도 늘어난다.
이런점을 피하고자 CompositionLocal를 활용하여, LocalAnalyticsHelper를 구현하기로 결정했다.
CompositionLocal의 경우, Ui Tree에서 State가 내려오는 것과 다르게, 하위 계층에서 상위 계층의 상태를 접근하는 방법이다.
CompositionLocal을 사용하여, MainActivity부터 Screen까지 AnalyticsHelper 구현체를 전달하는 것이 아닌, 모든 Screen에서 MainActivity의 AnalyticsHelper 구현체에 접근할 수 있게 된다.
필자는 아래와 같이, analytics 모듈내에서 다음과 같은 CompostionLocal 변수를 구성하였다.
구현체의 경우 MainActivity내에서 Hilt를 통해 주입하며, 주입되지 않는 객체에 접근한 경우 예외를 발생하도록 구현하였다.
val LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {
error("Any AnalyticsHelper Did Not Provided")
}
마지막으로 ComposoableLocalProvider를 통해 컴포저블 함수를 감싸는 것으로, 하위 컴포저블 함수가 AnalyticsHelper에 접근할 수 있다.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var analyticsHelper: AnalyticsHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CompositionLocalProvider(
LocalAnalyticsHelper provides analyticsHelper,
) {
...
}
AnalyticsExtensions
Hilt나 CompositionLocalProvider를 통해 AnalyticsHelper를 접근하고 트래킹 함수를 호출할 수도 있지만,
많은 Screen에서 접근하고 사용하고 있어, core:ui 내 유틸함수를 만들어 사용할 수 있다.
필자의 경우, 유저의 상태와 화면을 기록하는 AnalyticsExtensions를 구현하였다.
fun AnalyticsHelper.logScreenView(screenName: String) {
logEvent(
AnalyticsEvent(
type = AnalyticsEvent.SCREEN_VIEW,
extras = listOf(
Param(AnalyticsEvent.SCREEN_NAME, screenName),
),
),
)
}
fun AnalyticsHelper.logUserSignedIn(userId: String, userName: String) {
logEvent(
AnalyticsEvent(
type = "signed_in",
extras = listOf(
Param("user_id", userId),
Param("user_name", userName),
),
),
)
}
@Composable
fun TrackScreenViewEvent(
screenName: String,
analyticsHelper: AnalyticsHelper = LocalAnalyticsHelper.current,
) = LaunchedEffect(Unit) {
analyticsHelper.logScreenView(screenName)
}
유저의 화면을 기록하는 경우, 대부분 컴포저블 함수내에서 기록되므로 TrackScreenViewEvent()와 같이 Side Effect Handler를 사용하여 구현하였다.
Analytics 기록하기
지금까지 구현한 Analytics를 통해 다음과 같이 사용할 수 있다.
컴포저블 함수내에서 이전 구현한 유틸 함수를 호출하거나, CompositionLocal 변수를 호출하여 기록할 수 있다.
@Composable
internal fun ManagementScreen() {
...
TrackScreenViewEvent(screenName = "ManagementScreen")
val analyticsHelper = LocalAnalyticsHelper.current
analyticsHelper.logScreenView("ManagementScreen")
}
ViewModel 내에서는 Hilt를 통해 주입받은 객체를 통해 기록할 수 있다.
@HiltViewModel
class SignUpViewModel @Inject constructor(
private val getUserProfileUseCase: GetUserProfileUseCase,
private val analyticsHelper: AnalyticsHelper,
) : ViewModel() {
private fun logUserSignedIn() = viewModelScope.launch {
getUserProfileUseCase()
.onSuccess { userProfile ->
analyticsHelper.logUserSignedIn(
userId = userProfile.userId,
userName = userProfile.userName,
)
}
}
}
마치며
Firebase Analytics의 경우, 사실 필요성을 많이 못느꼈던 것 같다.
앱 내에서 발생하는 이벤트를 기록하는 것이 큰 의미가 있을까? 라고 생각하여, 구현할 생각을 하지 못했다.
하지만, 이번 계기로 Analytics Event에 대해 알아보면서 이벤트 하나하나가 유저의 데이터이고, 이러한 데이터는 성장할 수 있는 서비스를 만드는 기반이 된다는 것을 깨달았다.
또한, 우리가 직면한 문제와 같이, 문제가 발생했을 때 다각면에서 시야를 제공해주는 매개체가 된다는 점에서 매우 유용한 것 같다.