모바일 앱 장애가 났을 때 보통 가장 먼저 여는 것이 크래시 대시보드다. 스택 트레이스를 확인하고, 영향받은 기기 수를 보고, 핫픽스 릴리즈를 준비한다. 그런데 정작 "그 크래시 직전에 어떤 API가 실패했는지", "사용자가 어떤 흐름을 타고 있었는지", "백엔드는 이미 응답이 느려지고 있었는지"를 알 수 있는 경우가 얼마나 되는가.
크래시 리포트는 앱이 종료된 위치를 알려줄 뿐이다. 장애의 맥락을 재구성하려면 크래시, 로그, 네트워크 실패, 비즈니스 이벤트가 하나의 타임라인으로 연결되어야 한다.
왜 모바일 관측성은 백엔드보다 어려운가
백엔드 서버는 제어된 환경에서 돌아간다. 배포 파이프라인이 일관되고, 인프라 로그에 직접 접근할 수 있으며, 네트워크 상태도 예측 가능하다.
모바일은 다르다. 동일한 릴리즈가 구형 Android 8 기기와 최신 Android 15 기기에 동시에 올라가 있다. 사용자는 LTE에서 Wi-Fi로 전환하는 도중에 결제 버튼을 누른다. 백그라운드 제한 정책 때문에 텔레메트리 업로드 자체가 실패할 수 있다.
이 환경에서 관측성 시스템은 "완벽한 데이터"를 가정해서는 안 된다. 지연되고, 불완전하고, 손실 가능한 신호에서 충분한 정보를 끌어내도록 설계해야 한다.
다섯 가지 신호를 연결해야 하는 이유
성숙한 모바일 관측성 아키텍처는 다섯 클래스의 신호를 연결한다.
크래시는 앱이 예기치 않게 종료된 사실을 알려준다. 로그는 종료 직전에 무슨 일이 있었는지를 설명한다. 트레이스는 모바일에서 시작된 요청이 백엔드 서비스들을 어떻게 통과했는지 보여준다. API 실패 텔레메트리는 타임아웃, HTTP 상태, 재시도 횟수, 레이턴시를 클라이언트 관점에서 기록한다. 비즈니스 이벤트는 결제 재시도가 급증했는지, 온보딩 완료율이 떨어졌는지처럼 실제 영향을 드러낸다.
이 다섯 가지를 동일한 세션 컨텍스트로 묶는 것이 핵심이다. 각 신호를 개별적으로 보면 노이즈에 가깝지만, 하나의 타임라인으로 연결하면 인과관계가 보인다.
세션 ID, 플로우 ID, 상관관계 ID 설계
앱이 시작될 때 세션 ID를 생성하고, 중요한 사용자 흐름이 시작될 때 플로우 ID를 추가로 부여한다. 백엔드를 호출할 때는 이 컨텍스트를 헤더에 실어 보낸다.
data class ObservabilityContext(
val sessionId: String,
val flowId: String?,
val deviceFingerprint: String,
val appVersion: String,
val osVersion: String
)
object ContextHolder {
private var current: ObservabilityContext? = null
fun init(appVersion: String) {
current = ObservabilityContext(
sessionId = UUID.randomUUID().toString(),
flowId = null,
deviceFingerprint = buildDeviceFingerprint(),
appVersion = appVersion,
osVersion = Build.VERSION.RELEASE
)
}
fun startFlow(flowName: String): ObservabilityContext {
val ctx = current ?: error("ObservabilityContext not initialized")
return ctx.copy(flowId = "$flowName-${UUID.randomUUID()}").also { current = it }
}
fun get(): ObservabilityContext = current ?: error("ObservabilityContext not initialized")
}
OkHttp 인터셉터에서 이 컨텍스트를 요청 헤더로 주입하면, 백엔드 트레이스와 모바일 신호가 동일한 ID로 연결된다.
class ObservabilityInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val ctx = ContextHolder.get()
val request = chain.request().newBuilder()
.header("X-Session-Id", ctx.sessionId)
.header("X-Flow-Id", ctx.flowId ?: "none")
.header("X-App-Version", ctx.appVersion)
.build()
val startMs = SystemClock.elapsedRealtime()
return try {
val response = chain.proceed(request)
recordApiTelemetry(request, response, SystemClock.elapsedRealtime() - startMs)
response
} catch (e: IOException) {
recordApiFailure(request, e, SystemClock.elapsedRealtime() - startMs)
throw e
}
}
}
API 실패 텔레메트리를 제대로 기록하는 방법
HTTP 상태 코드만 기록하는 것은 불충분하다. 재시도 횟수, 네트워크 타입, 요청이 타임아웃된 것인지 서버 오류인지를 구분해야 나중에 패턴 분석이 가능하다.
data class ApiFailureEvent(
val sessionId: String,
val flowId: String?,
val endpoint: String,
val httpStatus: Int?,
val errorType: String, // "timeout" | "server_error" | "network_unavailable"
val retryCount: Int,
val latencyMs: Long,
val networkType: String, // "wifi" | "lte" | "5g" | "unknown"
val timestamp: Long = System.currentTimeMillis()
)
fun recordApiFailure(request: Request, error: IOException, latencyMs: Long) {
val ctx = ContextHolder.get()
val event = ApiFailureEvent(
sessionId = ctx.sessionId,
flowId = ctx.flowId,
endpoint = request.url.encodedPath,
httpStatus = null,
errorType = when (error) {
is SocketTimeoutException -> "timeout"
is UnknownHostException -> "network_unavailable"
else -> "io_error"
},
retryCount = request.tag(RetryTag::class.java)?.count ?: 0,
latencyMs = latencyMs,
networkType = NetworkUtils.currentType()
)
TelemetryQueue.enqueue(event)
}
이 이벤트를 크래시 직전 타임라인과 겹쳐 보면, "결제 API가 3번 타임아웃된 뒤 크래시 발생"이라는 인과관계를 실제로 확인할 수 있다.
이 아키텍처가 맞지 않는 상황
모든 앱에 이 수준의 관측성이 필요한 것은 아니다. 세션 ID와 플로우 ID를 전체 네트워크 레이어에 주입하려면 백엔드도 동일한 컨텍스트 전파 체계를 갖추고 있어야 한다. 백엔드가 이 헤더를 무시하거나 로깅하지 않는다면, 모바일에서 ID를 아무리 정밀하게 심어도 트레이스 연결은 단절된다.
텔레메트리 수집 자체도 비용이다. 배터리, 데이터 사용량, 오프라인 큐 관리 로직이 추가된다. 초기 스타트업처럼 릴리즈 속도가 최우선인 상황이라면, Firebase Crashlytics에 커스텀 키 몇 개를 추가하는 수준에서 시작하는 것이 현실적이다.
규모가 커지고 특정 흐름의 실패율이 비즈니스 지표에 직접 영향을 주기 시작할 때, 이 아키텍처의 가치가 실질적으로 드러난다.
비즈니스 이벤트를 연결하는 마지막 레이어
기술적 신호만으로는 장애의 비즈니스 영향을 설명하기 어렵다. 결제 흐름에서 플로우 ID를 기준으로 "진입 → API 호출 → 실패 → 재시도 → 포기" 각 단계를 이벤트로 기록하면, 특정 릴리즈 이후 결제 포기율이 얼마나 변했는지 기술 지표와 함께 분석할 수 있다.
sealed class PaymentFlowEvent {
data class Started(val flowId: String, val sessionId: String) : PaymentFlowEvent()
data class ApiAttempt(val flowId: String, val attempt: Int) : PaymentFlowEvent()
data class Failed(val flowId: String, val reason: String, val totalAttempts: Int) : PaymentFlowEvent()
data class Abandoned(val flowId: String) : PaymentFlowEvent()
data class Completed(val flowId: String) : PaymentFlowEvent()
}
이 이벤트들이 세션 ID와 플로우 ID로 묶이면, 특정 기기군이나 OS 버전에서 결제 포기율이 높은 경우를 API 실패 패턴과 직접 연결해서 볼 수 있다. 크래시가 없어도 장애를 탐지할 수 있게 된다.
※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.
'안드로이드' 카테고리의 다른 글
| 프롬프트 한 줄로 앱이 생긴다면, 개발자의 일은 어디서 시작하는가 (0) | 2026.06.29 |
|---|---|
| Android 17이 운영체제를 그만두려는 이유 (0) | 2026.06.29 |
| list.map{}이 느린 건 CPU 때문이 아니다 (0) | 2026.06.26 |
| Kotlin 2.4가 바꾸는 것: 15년 된 언어가 지금 가장 흥미로운 이유 (0) | 2026.06.25 |
| Compose에 Mesh Gradient가 생겼다, 단 함정이 있다 (0) | 2026.06.24 |