작업 일지

화면 체류 시간을 재는 SDK를 만들며 걸린 수명주기 함정들

자체 분석 수집기의 iOS·Flutter 클라이언트를 만드는 동안 화면 체류 시간이 계속 틀렸다. 원인은 대부분 플랫폼 수명주기의 세부였다. 시트를 닫아도 부모가 다시 나타나지 않고, Flutter는 pop 때 initState를 다시 부르지 않으며, GA4를 걷어내자 release 빌드에서 인터넷 권한이 사라졌다.

8분#analytics#ios#flutter#debugging

GA4를 걷어내고 수집기를 직접 만든 이유는 앞 글에 썼다. 이 글은 그 뒤 클라이언트 SDK를 만들면서 화면 체류 시간이 틀렸던 이유들이다. 클라이언트는 iOS용 Swift 패키지와 Flutter용 Dart 패키지 하나씩이고, 수집 서버는 Cloudflare Worker와 D1이다.

화면을 세는 모델

처음에는 "화면이 나타나면 enter"였다. 지금은 스택으로 센다. 연산은 세 가지다.

  • enter는 현재 화면을 대체한다. 탭 전환이나 일반 화면 이동이 여기에 해당한다.
  • push는 아래 화면을 덮는다. 시트가 여기에 해당한다. 덮을 때 부모에게 exit를 낸다. 시트가 떠 있는 동안 부모가 체류를 쌓으면 같은 시간이 두 번 세어진다.
  • pop은 덮은 것을 걷는다. 부모를 이어서 재개하지 않고 새 시계로 시작한다. 시트 아래에 있던 시간은 시트의 몫이다.

서버는 짝이 없는 enter를 버린다. 그래서 홈 버튼으로 나간 세션은 짧게 남는 게 아니라 아예 사라진다. 앱이 백그라운드로 갈 때 열린 화면을 반드시 닫아야 하는데, 이걸 앱마다 배선하게 하면 27개 중 몇 개는 빠진다. 그래서 SDK가 직접 iOS의 didEnterBackground와 willTerminate를 구독해 열린 방문을 닫되 스택은 유지하고, 포그라운드로 돌아오면 새 시계로 다시 enter한다. 앱 쪽 변경은 초기화 한 줄로 끝난다.

iOS: 시트와 중첩 모달

GA4 시절의 문제는 앞 글에 썼다. 페이월 시트를 닫아도 부모의 onAppear가 다시 불리지 않아, 이후 시간이 전부 페이월에 붙었다. 7월에는 모달이 사라질 때 "마지막 non-modal 화면"을 다시 기록하는 방식으로 고쳤고, 단일 시트에서는 맞았다.

중첩 모달에서는 틀렸다. 홈이 시트를 띄우고 그 시트가 카메라를 풀스크린으로 띄운 뒤 카메라를 닫으면, 기록이 홈으로 돌아갔다. 시트는 아직 화면에 있는데도 그랬다. 단일 시트 테스트는 이 경우를 잡지 못했다. 복원은 "마지막 non-modal"이 아니라 스택 pop이어야 했고, 9월에 그렇게 바꾸면서 중첩 모달이 한 단계씩 풀리는 것을 테스트로 고정했다.

반대 방향도 있었다. 어떤 화면 구성에서는 SwiftUI가 시트를 닫을 때 부모의 onAppear를 다시 부른다. 이걸 닫았다 다시 여는 것으로 처리하면 시트 시간이 부모의 두 번째 방문으로 잡힌다. 어떤 조건에서 어느 쪽이 되는지 정리된 문서를 찾지 못해서, enter가 이미 맨 위에 있는 같은 화면을 받으면 새로 열지 않고 재개만 하도록 만들었다. 다시 불리든 안 불리든 결과가 같아진다.

시트를 바꾸는 동안 SwiftUI가 내용을 다시 만들면서, 아래 화면이 같은 프레임 안에서 enter와 exit를 반복하기도 했다. LogoForm의 짧은 세션 하나에서 방문 28개 중 8개가 이런 1ms 미만 방문이었다. 서버는 250ms 미만 방문을 평균에서 빼되, 원본 이벤트는 남겨 두어 나중에 다시 계산할 수 있게 했다.

Flutter: iOS와 반대로 움직이는 것

SwiftUI에서는 push한 화면을 닫으면 부모의 onAppear가 다시 발화한다. 그래서 iOS에서는 화면마다 추적 한 줄이면 됐다. Flutter에서 같은 모양으로 짜면 부모가 다시 enter되지 않는다. pop 때 부모 State의 initState는 다시 불리지 않기 때문이다. LogoForm을 이식할 때 에디터를 닫은 뒤 갤러리에서 보낸 시간이 전부 에디터로 잡혔고, 같은 날 다른 이식 앱 두 곳에서도 같은 결론이 나왔다.

Flutter 쪽은 NavigatorObserver의 didPush·didPop·didRemove·didReplace로 스택을 맞춘다. onGenerateRoute는 시트가 닫힐 때 다시 불리지 않아 닫힘을 알 수 없다. push한 페이지도 시트처럼 아래를 덮는 것으로 취급하고, 이름을 돌려주지 않는 익명 다이얼로그는 무시한다. 익명 다이얼로그가 자신을 보고하면 사용자가 실제로 보고 있는 화면 이름을 덮어쓴다.

IndexedStack도 있었다. 탭을 처음에 전부 build하기 때문에 build 시점에 보고하면 탭 셋이 차례로 enter를 내고 끝나고, 마지막 탭이 앱 전체 시간을 가져간다. 탭은 현재 보이는 탭을 기준으로 보고한다.

수명주기 이벤트는 paused와 detached에서만 화면을 닫고 플러시한다. inactive와 hidden은 제어 센터, 권한 다이얼로그, 앱 전환기 미리보기에서도 오기 때문에, 이걸 이탈로 보면 모든 화면이 짧아진다. iOS가 didEnterBackground만 쓰는 것과 같은 판단이다.

이식 과정에서 iOS 원본의 버그도 드러났다. Flutter 이식은 LogoForm의 내보내기 시트를 덮는 화면으로 처리했는데, iOS 원본은 그 시트를 모달 표시 없이 추적하고 있어서 에디터가 가려진 채 시간을 쌓고 있었다.

실패하지 않는 실패

가장 오래 걸린 건 아무것도 실패하지 않는데 데이터가 없는 경우였다.

빈 옵저버로 출고된 빌드. MaterialApp의 navigatorObservers는 한 번만 build된다. 그런데 분석 SDK가 start() 전에는 옵저버로 null을 돌려줬다. 분석을 runApp 뒤에 기다리지 않고 켜는 앱이나 수집 주소가 없는 빌드는 빈 옵저버 목록으로 나갔고, 화면 계측이 통째로 없는데도 경고 하나 없었다. 트래커를 앱 시작 때 만들어 두는 싱글턴으로 바꾸고 클라이언트만 나중에 끼우게 했다. 옵저버는 항상 있고, 분석이 꺼져 있으면 아무 일도 하지 않는다.

그 수정이 만든 다음 버그. 싱글턴은 테스트 파일 사이에서도 살아남는다. stop()이 스택을 비우지 않아서 다음 start()가 이전 실행의 화면에 exit를 냈다. 이식 앱 테스트 4개가 파일 하나만 돌리면 통과하고 전체 스위트에서만 실패해서 찾았다. stop()에서 열린 화면을 닫고, 플러시하고, 스택을 비우게 했다. 두 동작을 각각 되돌려 보고 테스트가 잡는 것을 확인했다.

release 빌드에서 사라진 인터넷 권한. Flutter는 INTERNET 권한을 debug와 profile 매니페스트에만 넣는다. release는 firebase_analytics가 매니페스트 병합으로 넣어 주던 권한에 기대고 있었고, GA4를 걷어내자 같이 사라졌다. 소켓을 열지 못하니 이벤트는 큐에 쌓이고 백오프만 반복했으며, 테스트는 전부 초록이었다. 매니페스트에 권한을 직접 선언하고 테스트로 고정했다.

가짜 sink만 보던 테스트. 앱 테스트는 기록용 가짜 sink에 단언하고 있었다. 실제 어댑터의 메서드를 전부 비워도 스위트가 그대로 초록이었다. 실제 어댑터에서 실제 클라이언트를 거쳐 가짜 전송 계층까지 이어, 보내질 바이트를 읽는 테스트를 추가했다. 화면, 시트 표시, 시트 닫기 세 곳을 각각 비워 보고 모두 잡히는 것을 확인했다. iOS는 처음부터 이 방식이었고 변이 6종(초와 밀리초 혼동, 모달 무시, 400 재시도, 금지 속성 미필터, 백그라운드 exit 누락, install id 재생성)을 넣어 확인했다. 마지막 변이는 처음에 아무 테스트도 잡지 못했다. 테스트의 두 클라이언트가 id를 둘 다 1부터 세서, 재생성된 id가 같은 문자열이었기 때문이다.

실사용자를 디버그로 분류한 환경 판정. GA4 시절, 8월에 나온 Glaze와 Bori는 실제 다운로드가 있는데도 이벤트가 0건이었고, 설치 기반이 오래된 앱은 정상이었다. 환경 판정이 앱 영수증 파일의 존재를 보고 있었는데, iOS는 영수증을 StoreKit을 통해 나중에 만든다. 갓 설치한 실사용자는 파일이 없어서 디버그 환경으로 분류됐고, first_open조차 보내지 않았다. 파일 존재 대신 영수증 URL의 이름을 보도록 바꾸고, 판정을 순수 함수로 떼어 회귀 케이스 3개를 고정했다. 판정이 애매하면 보고하는 쪽으로 정했다. 실사용자를 모두 잃는 것이 개발 빌드 몇 건이 섞이는 것보다 훨씬 나쁘다.

서버의 허용 목록. 수집 서버는 허용 목록에 없는 app_id에 403을 돌려주고, 클라이언트는 403을 재시도하지 않고 버린다. Firebase에서 옮겨 온 Mac 앱 4종이 목록에서 빠져 있어서, 3주 가까이 이벤트가 0건이었다. 앱 코드에는 문제가 없었다. 서버가 schema_version과 app_id를 먼저 검사하기 때문에, 403만 보고는 나머지 형식이 맞는지 알 수 없다. 이벤트 이름을 일부러 틀리게 보내서 403이 오면 허용 목록 문제이고, 400 bad_field가 오면 목록은 통과한 것으로 구분했다. 웹뷰 기반 앱은 또 달랐다. 교차 출처 요청의 preflight OPTIONS가 404였고 CORS 헤더도 없었다. 거절 응답(403 등)에도 CORS 헤더를 붙였다. 웹뷰가 응답을 읽지 못하면 네트워크 실패로 보고 끝없이 재시도하기 때문이다.

재시도와 큐

  • 400·403·413은 버리고 재시도하지 않는다. 영원히 받아들여지지 않을 배치가 큐 앞을 막으면 뒤의 이벤트가 하나도 나가지 못한다.
  • 429·5xx·무응답은 재시도하고, 이때 event_id를 그대로 쓴다. 서버가 UNIQUE(event_id)와 INSERT OR IGNORE로 중복을 지우므로, 응답을 못 받은 뒤의 재시도가 중복 집계로 이어지지 않는다.
  • 전송 계층은 상태 코드는 반환하고 네트워크 실패만 예외로 던진다. 둘을 섞으면 400이 네트워크 실패처럼 영원히 재시도된다.
  • 백오프는 타이머로 하고 최대 15분이다. 시계를 비교하는 방식이면 네트워크가 끊긴 동안 이벤트 10개가 요청 10개가 된다.
  • 타임스탬프는 밀리초다. 초 단위로 보내면 서버의 하한(2020년)보다 작아져 배치 전체가 거절된다.
  • 서버의 방문 상한은 원래 24시간이었다. 방문 1,788개 가운데 5개(0.3%)가 전체 시간의 47%를 차지했는데, 화면을 켜 둔 채 놓아 둔 폰이었다. 상한을 세션 간격과 같은 30분으로 줄이고, 넘는 방문은 평균에서 빼되 따로 센다.