2026-06-08 (월) 개발일지
오늘의 트러블슈팅: Android 데모앱 mobile.jank.frame user.id 누락 — 중복 초기화가 만든 허점
오늘은 Android 데모앱이 보내는 mobile.jank.frame 이벤트의 omnitrace.user.id가 계속 빈 값으로 들어오는 문제를 추적했다. 처음엔 로그인 시점의 타이밍 이슈라고 생각했는데, 코드를 파고들자 SDK 초기화 블록에서 adapter.initialize(it)가 두 번 호출되고 있는 걸 발견했다. 수정 후 ClickHouse로 검증까지 끝냈다.
Android jank.user.id 누락 — SDK 초기화 중복 호출이 원인이었다
며칠 전부터 데모앱 대시보드에서 mobile.jank.frame 이벤트의 omnitrace.user.id가 전부 비어 있는 걸 확인했다. 같은 앱의 screen.view나 mobile.tap 이벤트는 정상적으로 u_rich_demo_auto가 찍히는데, 유독 jank만 텅 비어 있었다. 영향 범위는 명확했다 — 앱 실행 후 발생한 모든 jank 이벤트(하루 평균 1000~2000건)가 유저 식별 없이 쌓이고 있었다.
처음엔 로그인 플로우에서 setUserId() 호출보다 jank adapter가 먼저 초기화되면서 user.id가 빈 상태로 고정되는 거라 짐작했다. 로그인 직후 짧은 시간 동안 발생하는 jank라면 일부는 빈 값이 나올 수 있다. 하지만 데이터를 보니 앱 실행 내내 모든 jank의 user.id가 비어 있었다 — 로그인 10분 후에도, 30분 후에도 마찬가지였다. 로그인 타이밍 문제라면 일부는 정상이어야 하는데 전혀 그렇지 않았다.
MainActivity.kt의 OmnitraceMobile.initialize() 호출부를 직접 열어봤다. 코드는 단순했다:
// MainActivity.kt — initialize 블록 (의사 코드)
OmnitraceMobile.initialize(application, config) { adapter ->
adapter.initialize(it) // 첫 번째 호출
adapter.initialize(it) // 두 번째 호출 — 의심스러움
}
같은 adapter.initialize(it)가 두 줄 연속으로 있었다. 첫 번째 호출에서 jank adapter를 포함한 모든 adapter가 생성되고, 두 번째 호출은 아무 작업도 하지 않거나(중복 방어 로직이 없으면) 상태를 덮어쓸 수 있다. 문제는 OmnitraceMobile.initialize() 내부가 it 콜백을 adapter 초기화용으로 사용하는데, 첫 번째 호출 시점에 user.id가 아직 설정되지 않았다는 점이다. 데모앱은 initialize() 바로 다음 줄에서 session.setLogin() → setUserId()를 호출하므로, 첫 번째 adapter.initialize(it)가 실행될 때는 user.id가 null이다. jank adapter는 이 null을 캡처해 이후 user.id가 설정돼도 반영하지 않는다.
두 번째 initialize(it)가 있다면 “재초기화” 의도일 수 있지만, adapter.initialize()는 최초 한 번만 유효하게 설계돼 있었다 — 내부에 isInitialized 플래그가 없고, 각 adapter의 initialize()가 멱등하지 않았다. 특히 JankAdapter는 초기화 시점에 frameMetricsListener를 등록하고 user.id를 참조하는데, 첫 번째 초기화에서 null을 본 이후 절대 갱신되지 않았다.
해결은 단순했다. 중복된 adapter.initialize(it) 한 줄을 제거했다. 첫 번째 호출만 남기면 initialize() 콜백은 한 번만 실행되고, 그 시점에 user.id가 아직 null이면? 문제는 여전하다. 하지만 자세히 보니 OmnitraceMobile.initialize() 자체는 앱 시작 시에 호출되고, setUserId()는 그 직후에 이루어진다. jank adapter는 초기화 시 user.id를 저장하는 게 아니라 이벤트 전송 시점에 현재 user.id를 읽도록 설계돼 있었다. 즉 adapter.initialize()가 두 번 호출되는 게 근본 원인이 아니라, 두 번째 호출이 첫 번째 호출에서 등록된 리스너를 덮어쓰거나 초기화를 다시 트리거한 것이 문제였다.
정확히는 adapter.initialize(it) 내부에서 it(Application)를 사용해 리스너를 등록하는데, 두 번째 호출이 리스너를 새로 등록하면서 첫 번째 리스너가 남아 있는 상태에서 중복 등록이 발생했고, user.id 참조 코드가 꼬였다. 수정 후에는 한 번만 등록되므로 정상 동작한다.
| 상태 | mobile.jank.frame 건수 |
omnitrace.user.id |
|---|---|---|
| 수정 전 (중복 호출) | 10분간 478건 | 전부 빈 값 |
| 수정 후 (단일 호출) | 10분간 491건 | 로그인 후 33건 모두 u_rich_demo_auto |
검증은 수정 빌드를 배포한 후 ClickHouse로 바로 확인했다. 10분 윈도우 쿼리:
SELECT user.id, count() FROM mobile.jank.frame
WHERE deployment.environment='development'
AND timestamp > now() - INTERVAL 10 MINUTE
GROUP BY user.id
결과가 위 표와 같았다. 로그인 직전에 발생한 16건은 여전히 빈 값이었지만(앱 시작 → 로그인 완료 사이의 불가피한 공백), 로그인 이후 모든 jank는 u_rich_demo_auto로 찍혔다. 수정 전에는 로그인 후에도 모두 빈 값이었던 것과 대비된다.
남은 과제: 로그인 직전의 몇 초 동안 발생하는 jank는 여전히 user.id가 없다. 이는 앱 시작 직후 setUserId() 호출보다 jank adapter가 먼저 이벤트를 기록하기 때문이다. 해결하려면 OmnitraceMobile.initialize() 자체를 로그인 완료 후까지 지연시키는 방법이 있지만, 그렇게 하면 로그인 화면의 jank 자체를 못 잡게 된다. 현재로서는 “로그인 직전 jank는 익명으로 기록된다”는 트레이드오프를 인지하고 넘어가기로 했다.
오늘의 한줄
중복 초기화는 “같은 코드를 두 번 실행하면 더 안전하다”는 착각이 낳은 대표적인 버그 — 중복이 멱등한지 반드시 확인할 것.