이 글은 누구를 위한 것인가
- 어떤 분석 도구를 써야 할지 모르는 팀
- 이벤트를 어떻게 설계해야 나중에 분석이 잘 되는지 모르는 개발자
- iOS 14 이후 ATT로 추적이 제한된 상황을 대응해야 하는 팀
들어가며
이벤트를 나중에 생각하면 너무 늦다. "구매 완료" 이벤트를 추가했는데 카테고리 정보가 없어서 카테고리별 분석을 못 한다. 이벤트 택소노미 설계가 먼저다.
이 글은 bluebutton.kr의 모바일 분석 설계 가이드 를 참고하여 작성했습니다.
1. 분석 도구 선택과 이벤트 설계
[Firebase Analytics vs Amplitude]
Firebase Analytics:
비용: 무료 (BigQuery 수출 비용 별도)
강점: Google 생태계, Crashlytics, FCM 연동
약점: 실시간 분석 제한, 커스텀 퍼널 불편
적합: 소규모 앱, Google 광고 운영
Amplitude:
비용: 월 100만 이벤트 무료, 이후 유료
강점: 퍼널/코호트/리텐션 분석, 실시간
약점: 비용, Google 연동 별도 작업
적합: 제품 분석 집중, B2C 앱
[이벤트 택소노미 설계 원칙]
네이밍: "명사_동사" 형식
❌ click_buy, buy_btn_pressed
✅ product_viewed, checkout_completed
공통 속성 (모든 이벤트):
user_id, session_id
platform (ios/android)
app_version, os_version
timestamp
이벤트별 속성:
product_viewed: product_id, category, price
checkout_completed: order_id, total, payment_method
content_shared: content_id, share_method
[iOS ATT 대응]
iOS 14.5+: 추적 권한 팝업 의무화
거부 시: IDFA 사용 불가
대안: first-party ID, SKAdNetwork
허용 요청 전: 가치 설명 필수
2. Analytics 구현 (이중 전송 패턴)
// iOS Swift
import FirebaseAnalytics
import Amplitude
// 이벤트 택소노미 정의 (타입 안전)
enum AnalyticsEvent {
case productViewed(productId: String, category: String, price: Double)
case checkoutStarted(cartItems: Int, totalValue: Double)
case checkoutCompleted(orderId: String, total: Double, paymentMethod: String)
case contentShared(contentId: String, shareMethod: String)
var name: String {
switch self {
case .productViewed: return "product_viewed"
case .checkoutStarted: return "checkout_started"
case .checkoutCompleted: return "checkout_completed"
case .contentShared: return "content_shared"
}
}
var properties: [String: Any] {
switch self {
case let .productViewed(id, category, price):
return ["product_id": id, "category": category, "price": price]
case let .checkoutStarted(items, total):
return ["cart_items": items, "total_value": total]
case let .checkoutCompleted(orderId, total, payment):
return ["order_id": orderId, "total": total, "payment_method": payment]
case let .contentShared(contentId, method):
return ["content_id": contentId, "share_method": method]
}
}
}
class AnalyticsManager {
static let shared = AnalyticsManager()
private var commonProperties: [String: Any] = [:]
func configure(userId: String) {
// Firebase
Analytics.setUserID(userId)
// Amplitude
Amplitude.instance().setUserId(userId)
// 공통 속성
commonProperties = [
"platform": "ios",
"app_version": Bundle.main.appVersion,
"os_version": UIDevice.current.systemVersion,
]
Analytics.setDefaultEventParameters(commonProperties)
}
func track(_ event: AnalyticsEvent) {
var properties = commonProperties.merging(event.properties) { _, new in new }
// Firebase
Analytics.logEvent(event.name, parameters: properties as [String: Any])
// Amplitude
Amplitude.instance().logEvent(event.name, withEventProperties: properties)
// 디버그 로그
#if DEBUG
print("[Analytics] \(event.name): \(properties)")
#endif
}
func setUserProperty(key: String, value: String) {
Analytics.setUserProperty(value, forName: key)
let identity = AMPIdentify()
identity.set(key, value: value as NSObject)
Amplitude.instance().identify(identity)
}
func startSession() {
commonProperties["session_id"] = UUID().uuidString
}
// 퍼널 추적 헬퍼
func trackCheckoutFunnel(step: CheckoutStep) {
switch step {
case .cartViewed(let items):
track(.checkoutStarted(cartItems: items, totalValue: 0))
case .paymentEntered:
Analytics.logEvent("payment_info_entered", parameters: nil)
case .orderPlaced(let orderId, let total, let method):
track(.checkoutCompleted(orderId: orderId, total: total, paymentMethod: method))
}
}
}
enum CheckoutStep {
case cartViewed(items: Int)
case paymentEntered
case orderPlaced(orderId: String, total: Double, method: String)
}
extension Bundle {
var appVersion: String {
infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
}
// ATT 권한 요청
func requestTrackingPermission() async -> Bool {
if #available(iOS 14, *) {
let status = await ATTrackingManager.requestTrackingAuthorization()
return status == .authorized
}
return true
}
// Android Kotlin
import com.google.firebase.analytics.FirebaseAnalytics
import com.amplitude.api.Amplitude
import org.json.JSONObject
class AnalyticsManager(private val context: android.content.Context) {
private val firebaseAnalytics = FirebaseAnalytics.getInstance(context)
private val amplitude = Amplitude.getInstance()
fun configure(userId: String) {
firebaseAnalytics.setUserId(userId)
amplitude.setUserId(userId)
}
fun track(eventName: String, properties: Map<String, Any> = emptyMap()) {
// Firebase
val bundle = android.os.Bundle().apply {
val enriched = properties + mapOf(
"platform" to "android",
"app_version" to getAppVersion(),
)
enriched.forEach { (k, v) ->
when (v) {
is String -> putString(k, v)
is Int -> putInt(k, v)
is Long -> putLong(k, v)
is Double -> putDouble(k, v)
is Boolean -> putBoolean(k, v)
}
}
}
firebaseAnalytics.logEvent(eventName, bundle)
// Amplitude
val jsonProps = JSONObject(properties)
amplitude.logEvent(eventName, jsonProps)
}
private fun getAppVersion(): String {
return try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
} catch (e: Exception) {
"unknown"
}
}
}
마무리
이벤트 택소노미는 초기에 설계하고 문서화해야 한다. "이름_동사" 네이밍, 공통 속성 자동 첨부, 타입 안전한 이벤트 열거형으로 구현 오류를 방지하라. Firebase는 무료라 부담이 없지만, 제품 분석이 중요하면 Amplitude의 퍼널·코호트 분석이 훨씬 강력하다.