2026-05-28 (목) 개발일지
오늘은 데모앱 demo_apps에서 두 가지 문제를 잡았다. 첫째는 모바일 앱의 텔레메트리가 OpenTelemetry Collector에 전혀 들어오지 않는 연결 끊김 현상, 둘째는 iOS 쪽에 P0 디바이스 정보가 누락되어 있어 Android와 동일한 수준의 attribute를 부착하는 작업이다. 두 이슈 모두 결국 trace join이 제대로 걸리지 않는 근본 원인으로 이어져 있었다.
데모앱 텔레메트리 연결 끊김 — OTLP endpoint 이중화와 traceparent 헤더 누락
며칠 전부터 모바일 앱에서 보낸 span이 ClickHouse에 쌓이지 않는다는 보고가 들어왔다. 웹(backend)은 정상적으로 들어오고 있었고, Collector 서버(10번, 9080 port)는 살아 있었다. 내 첫 가설은 모바일 SDK가 잘못된 endpoint를 바라보고 있다는 쪽이었다.
실제로 Android는 OTLP gRPC 14317, iOS는 OTLP HTTP 14318로 각각 다른 port를 사용하고 있었지만, 이는 의도한 이중화 구성이었다. transport가 달라도 같은 Collector 군집으로 라우팅되므로 port 차이는 문제가 아니었다. 그럼에도 모바일 데이터만 안 들어오는 상황이 계속됐다.
좀 더 파고들어 BackendClient.kt의 HTTP 요청 로그를 찍어봤다. 요청 헤더에 traceparent가 아예 없었다. Android SDK의 HeaderInjector가 traceIdProvider를 구현하지 않아 manual span을 생성할 때마다 새로운 TraceId를 부여하고 있었고, 그 결과 backend의 span과 join되지 않은 orphan span이 되어 Collector가 받아도 버려졌다. 사용자가 “연결이 끊겼다”고 생각한 순간은 사실 데이터는 보내지고 있었지만, 대시보드에 보이지 않았던 것이다.
핵심 발견: transport 연결은 정상이지만 traceparent가 없으면 collector가 부모 span을 찾지 못해 orphan 처리 — 데이터는 오지만 보이지 않음.
해결은 간단했다. BackendClient.kt(Android)와 BackendClient.swift(iOS)에 각각 Interceptor를 추가해 현재 스레드의 traceparent를 HTTP 헤더에 실어 보내도록 했다. Android는 HeaderInjector.kt에 traceIdProvider를 직접 구현했고, iOS는 currentTraceparent를 수동으로 읽어서 Interceptor에 주입했다.
검증: commit 후 rebuild, load-gen v3를 돌려 ClickHouse에서 동일한 TraceId가 app-backend와 omnitrace-android, omnitrace-ios에 모두 묶이는 걸 확인했다. P0 attribute(os.name, device.model.identifier, omnitrace.geo.country)도 함께 도달했다.
남은 과제: iOS SDK에 아직 currentTraceparent helper가 없어서 수동 구현한 부분 — 향후 SDK 업데이트가 나오면 네이티브 지원으로 갈아탈 예정.
iOS DeviceInfoResource 및 7개 어댑터 통합 — P0 attribute 부착과 buildResource merge
Android 쪽은 이미 DeviceInfoResource.kt가 구현되어 디바이스 정보가 span에 박혀 있었다. iOS는 아예 빠져 있어서 os.*, device.* attribute가 누락된 채로 텔레메트리가 수집되고 있었다. 특히 omnitrace.geo.country는 아예 하드코딩 기본값만 들어가 있었다.
처음 생각은 “각 adapter에 DeviceInfoResource를 복붙하면 되겠지”였지만, iOS에는 7개의 adapter(iOSCrashAdapter, iOSHangAdapter, iOSJankAdapter, iOSMemoryAdapter, iOSStartupAdapter, BehaviorAdapter, OTelAdapter)가 각자 bootstrapLogger()에서 Resource를 직접 생성하고 있었다. 하나하나 수정하면 유지보수하기 어려워질 게 뻔했다.
리팩터링 방향을 공통 Resource를 한 번 만들고, 각 adapter의 buildResource에서 merge하는 쪽으로 잡았다. 우선 DeviceInfoResource.swift를 신규 생성했다. 내부에서 UIDevice.current, ProcessInfo.processInfo, Locale.current 등으로 os.name, os.version, device.model.name, device.model.identifier, device.manufacturer, omnitrace.geo.country를 조합한 Resource를 반환하도록 했다. shared 싱글톤으로 만들어 중복 생성을 막았다.
그다음 7개 adapter의 buildResource 메서드(없는 경우 bootstrapLogger() 내부)에서 DeviceInfoResource.shared.resource.merge()를 호출하도록 수정했다. TapTracker.swift에도 tap.label attribute를 span에 부착하는 로직을 함께 추가했다.
검증: iOS 빌드 성공, load-gen v3의 full-feature-tour.js를 1 iteration 돌려 모든 span에서 os.name, device.model.identifier, tap.label이 포함되는지 확인. ClickHouse 쿼리로도 정상 수집 확인.
남은 과제: omnitrace.geo.country는 현재 System Locale 기반 추정값이다. CoreLocation 권한을 받아 실제 GPS 위치로 교체할지 검토 중 — 데모앱 특성상 권한 요청이 사용자 경험을 해칠 수 있어서 우선순위는 낮게 잡았다.
오늘의 한줄
trace join이 안 되면 데이터가 있어도 보이지 않는다 — transport 연결만으로 부족하고, 올바른 traceparent가 모든 레이어를 관통해야 한다.