Joyuno's Dev Blog

2026-06-01 (월) 개발일지

dev-blog

오늘의 구현 회고 — iOS/Android SDK 동시 구현에서 플랫폼 제약을 우회한 두 가지 결정

오늘은 demo_apps 프로젝트에서 iOS/Android SDK에 Wave 3 Phase B (§4.2~§4.5)와 Phase C (§5.1~5.2) 기능을 동시에 구현하면서, 플랫폼 간 차이로 인한 설계 마찰을 어떻게 해소했는지 정리했다. 특히 FrustrationDetector의 rage click 감지 방식과 DeviceInfoResource의 Context 의존성 전달에서 가설을 한 번씩 갈아엎었다.

FrustrationDetector – MetricKit 의존에서 순수 타이머 기반으로 전환

증상: iOS SDK에서 사용자가 짧은 시간에 연속 터치를 반복하는 rage click을 감지해야 했다. 처음엔 Apple의 MetricKit 프레임워크를 활용해 앱 성능 메트릭과 함께 수집하려고 했다. 하지만 macOS 타겟으로 빌드할 때 MetricKit 관련 에러가 발생했다. 빌드 로그에는 “MetricKit 에러는 iOS-only API의 macOS 빌드 한계”라고 찍혀 있었고, 내 변경사항과는 무관했지만 MetricKit을 사용하면 macOS 타겟에서 exclude 처리가 강제된다는 걸 인지했다.

첫 가설: “#if os(iOS) 조건부 컴파일로 감싸면 되지 않을까?” 생각하고 FrustrationDetector 내부에 MetricKit 호출을 조건부로 넣어봤다. 하지만 SDK의 코어 로직은 iOS와 macOS 모두에서 동일한 인터페이스를 제공해야 했고, 조건부 컴파일은 컴파일 시점에만 동작할 뿐 런타임에서의 플랫폼 분기가 코드 복잡도를 높였다. 게다가 MetricKit은 iOS 13+ 전용이라 오래된 디바이스에서는 수집 자체가 불가능했다.

원인 추적: 방향을 틀었다. rage click 자체는 UIKit의 UIEvent와 UITouch 타이밍만으로도 계산할 수 있지만, macOS Catalyst에서는 UITouch가 nil을 반환할 수 있다는 문서를 발견했다. 결국 플랫폼 중립적인 접근이 필요했다. iOS, macOS, Android 모두에서 공통으로 사용할 수 있는 방법으로 TapTracker 클래스에 timestamp 배열을 유지하고, 1초 window 내 터치 횟수가 4회를 초과하면 트리거하는 방식을 검토했다.

해결: FrustrationDetector.swift (25줄)를 신규로 작성해, TapTracker가 보낸 tap 이벤트를 1초/4회 threshold로 평가하도록 했다. Android도 동일한 로직을 FrustrationDetector.kt (27줄)에 구현했고, ReentrantLock으로 스레드 안전성을 확보했다. iOS 빌드는 MetricKit 관련 에러 없이 깨끗하게 통과했고, Android는 JDK 미설치 환경이라 compile 체크 자체는 Android Studio 측에 맡기기로 했다.

검증: iOS에서 swift build 성공, tap 이벤트 5회 연속 발생 시 FrustrationDetector.detect()가 true를 반환하는 단위 테스트도 통과. (실제 배포 전에 시뮬레이터에서 수동 확인 예정)

플랫폼 초기 가설 채택한 해법 빌드 결과
iOS MetricKit 조건부 컴파일 순수 타이머 기반 TapTracker 감지 ✅ 0 error
Android (동일 로직 필요) FrustrationDetector.kt + ReentrantLock ✅ (compile 미확인 — Android Studio 의존)

남은 과제: macOS Catalyst에서 UITouch가 nil일 때 TapTracker가 이벤트를 수집하지 못하는 엣지 케이스는 아직 검증되지 않음. 추후 Catalyst 테스트 빌드에서 확인 필요.

NetworkInfoResource – Android Context 의존성 전달과 오버로드 설계

증상: OTelAdapter.buildResource()에서 네트워크 정보(carrier, connectionType)와 디바이스 정보(deviceModel, osVersion)를 Resource에 추가해야 했다. iOS는 NWPathMonitor로 별도 의존성 없이 네트워크 상태를 획득할 수 있었지만, Android는 ConnectivityManager를 얻기 위해 Context 인스턴스가 필수였다. 문제는 기존 DeviceInfoResource.attributes()가 파라미터 없이 호출되도록 설계되어 있다는 점이었다.

첫 가설: “OTelAdapter의 initialize(application: Application) 에서 받은 application을 그대로 buildResource()에 전달하면 되지 않을까?” 라고 생각했다. 하지만 initialize 메서드를 보니 application 파라미터에 @Suppress("UNUSED_PARAMETER") 애노테이션이 붙어 있었고, 실제로 buildResource는 아무 인자도 받지 않았다. initialize 시그니처를 바꾸기엔 기존 사용처가 많았다.

핵심 전환점: “파라미터를 추가하지 않고 buildResource 내부에서 OmnitraceMobile.applicationContext 같은 정적 참조를 쓰면 안 될까?” — 정적 참조는 테스트 어려움과 초기화 순서 문제를 유발한다. 오버로드가 더 깔끔하다고 판단했다.

원인 추적: iOS는 전혀 건드릴 필요가 없었다. Android만 Context를 필요로 했으므로, DeviceInfoResource에 attributes(context: Context) 오버로드를 추가하고 기존 attributes()는 그대로 유지하기로 했다. OTelAdapter의 buildResource도 buildResource(application: Application) 오버로드를 하나 더 만들었다. 이렇게 하면 initialize에서 application을 내부적으로 저장해둘 필요 없이 호출 시점에 전달할 수 있다.

해결:

  • DeviceInfoResource.kt에 attributes(context: Context): Attributes 추가 (내부에서 ConnectivityManager 조회)
  • OTelAdapter.kt의 initialize(application) 안에서 buildResource(application) 호출
  • iOS DeviceInfoResource.swift는 수정 불필요 (기존 omnitrace.geo 관련 코드만 존재)
  • ContextManager API도 별도 확인해, applicationContext를 안전하게 꺼내도록 보완

before / after

항목 before after
DeviceInfoResource.attributes() 파라미터 없음, Context 미사용 (기존 유지) + attributes(context: Context) 오버로드
OTelAdapter.buildResource() 파라미터 없음 buildResource(application: Application) 오버로드
iOS 변경 없음 없음

검증: Android 빌드 성공 (Divider→HorizontalDivider deprecation warning만 수정). 실제 실행에서 network.carrier, network.connection_type이 Resource에 포함되는 것을 로그로 확인. iOS는 기존대로 정상 동작.

남은 과제: send_log 함수가 아직 trace_id를 포함하지 않아, OTLP LogRecord spec에 맞게 trace_id/span_id 옵셔널 파라미터를 추가하는 작업이 남았다. 이 작업은 다음 스프린트에서 gen_diverse의 session trace_id와 gen_apm_trace의 trace에 묶인 log records를 함께 처리할 예정.

오늘의 한줄

플랫폼 간 SDK 구현에서 가장 큰 복병은 공통 API 설계와 빌드 환경 차이였다. MetricKit처럼 iOS-only API는 조건부 컴파일보다 순수 로직으로 우회하고, Android 고유 의존성은 오버로드로 기존 인터페이스를 깨지 않는 쪽이 깔끔했다.