배포와 인프라9 분 읽기

테스트를 전부 통과시켰더니 프로덕션에 유저가 생겼다

첫 세션 퍼널 계측을 붙이고 184개 테스트를 통과시켰습니다. 확인 삼아 서버 테이블을 조회하니 배포도 안 한 계측이 프로덕션에 6행을 써놓았습니다. 테스트를 통과시키는 행위 자체가 측정 대상을 바꾸고 있었습니다.

#ios#android#testing#analytics
개념 도식: 초록 체크가 줄줄이 찍힌 테스트 러너에서 화살표가 빠져나와 프로덕션 DB로 들어가고, DB 옆에 '유저 6명 (존재하지 않음)'.
테스트는 전부 초록색이었고, 그 초록색이 프로덕션 퍼널에 유령 유저를 만들었다.

지도 기반 걷기 앱에 첫 세션 계측을 넣었습니다. 서버에는 계정 생성과 성공(걷기 완료, 영토 획득)만 남아서, 신규 유저가 어디서 사라지는지가 통째로 안 보였습니다. 앱을 열고 끝났는지, 위치 권한을 거부했는지, 걷다가 폐곡선을 못 만들어 실패했는지가 구분이 안 됐습니다. 이 상태를 어떻게 발견했는지는 따로 썼습니다 → 측정되지 않는 깔때기.

이벤트 8개를 정의하고, 전용 테이블과 SECURITY DEFINER RPC를 만들고, 클라이언트에 붙였습니다. 단위 테스트도 5개 짰습니다. xcodebuild test를 돌렸습니다. 184개 전부 통과.

그리고 확인 삼아 서버 테이블을 조회했습니다.

first_open        install b4c53bc9  1.5.1
app_open          install b4c53bc9  1.5.1
walk_cancelled    install 3c679dea  1.5.1
walk_cancelled    install 3c679dea  1.5.1
walk_ended        install 3c679dea  {"closed":"true"}
territory_claimed install 3c679dea  {"lobes":"2"}

배포도 안 한 계측이 프로덕션에 6행을 썼습니다. 영토를 2개 획득한 사람은 아무도 없습니다.

여기서 질문 하나. 당신의 테스트 스위트는 지난주에 프로덕션에 몇 행을 썼습니까? 그 답을 숫자로 갖고 계십니까?

두 가지가 겹쳤다

첫째, xcodebuild test는 호스트 앱을 실제로 띄웁니다. 유닛 테스트라도 앱 프로세스 안에서 돕니다. 그래서 App.init어떤 테스트보다 먼저 실행됩니다. 거기 넣은 logFirstOpenIfNeeded()가 그대로 네트워크를 탔습니다. 테스트에서 관찰용 sink를 꽂는 코드는 setUp()에 있는데, 그건 앱이 뜬 다음입니다. 순서상 절대 못 막습니다.

둘째, 뷰모델 테스트가 진짜 객체를 씁니다. 걷기 세션 테스트가 start()close()를 실제로 호출합니다. 그게 정확히 계측을 심어둔 자리입니다. territory_claimedlobes: 2가 찍힌 건 8자 경로 테스트가 두 개의 로브를 만들어 통과한 흔적입니다. 테스트가 잘 돌았다는 증거가 그대로 오염 데이터가 됐습니다.

반전은 이겁니다. 퍼널은 사람의 행동을 세는 표입니다. CI를 한 번 돌리면 도달률이 변합니다. 게다가 이건 조용합니다 — 테스트는 초록색이고, 앱은 정상이고, 표에는 존재하지 않는 유저가 한 명 늘어납니다.

가드는 XCTest 감지, 그런데 순서가 함정이다

private static var isUnderTest: Bool {
    ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
        || NSClassFromString("XCTestCase") != nil
}

여기서 순서를 잘못 잡으면 반대쪽으로 부러집니다. isDisabled가 sink보다 먼저 걸리면 테스트가 계측을 관찰할 수 없습니다. 테스트 안에서는 전부 비활성이니 sink에 아무것도 안 들어옵니다.

static func log(_ event: Event, payload: [String: String] = [:]) {
    if let sink { sink(event, payload); return }   // 관찰 경로가 먼저
    guard !isDisabled else { return }              // 그 다음에 차단
    // …RPC
}

logFirstOpenIfNeeded에는 조건이 하나 더 붙습니다. 비활성 상태에서 firstOpen 플래그조차 건드리면 안 됩니다. 스크린샷 촬영이 플래그를 태우고 나면 그 기기의 실제 첫 실행이 영영 기록되지 않습니다.

guard sink != nil || !isDisabled else { return }

Android는 언어가 다르니 방식도 다릅니다. DI로 주입되는 값이라 테스트에서 mock을 넣으면 끝납니다 — 다만 relaxed mock이 필수입니다.

funnel = mockk(relaxed = true)   // 실제 인스턴스면 프로덕션 테이블에 쓴다

검증 기준은 하나로 정했습니다. 전체 테스트를 한 번 돌린 뒤 테이블 행 수가 0이어야 한다. 실제로 그렇게 확인했습니다: iOS 184 tests / Android 192 tests 통과, 프로덕션 테이블 0행.

곁다리 함정 — strings로 ipa를 검증하면 절반만 참이다

"계측이 실제로 빌드에 들어갔나"를 바이너리에서 확인하려고 strings를 걸면 오판합니다. Swift는 15바이트 이하 리터럴을 small string으로 인라인해서 바이너리 상수로 남기지 않습니다. first_open·walk_started는 0건으로 나오고 location_permission_asked·log_funnel_event만 보입니다. 짧은 이벤트명이 "빠졌다"고 보이는 겁니다. 존재 확인용으로 쓰려면 긴 심볼 하나를 기준점으로 잡아야 합니다.

지금 바로 점검할 3가지

  1. 전체 테스트를 한 번 돌린 직후 프로덕션 텔레메트리 테이블의 행 수를 세어 보세요. 0이 아니면 그게 그대로 답입니다.
  2. 앱 진입점(init/Application.onCreate)에서 네트워크를 타는 호출이 있습니까? 그건 테스트의 setUp()보다 먼저 돕니다.
  3. 테스트용 비활성 플래그가 관찰용 sink보다 먼저 걸려 있지 않은지 확인하세요. 먼저 걸리면 테스트가 계측을 검증하지 못합니다.

솔직한 부분

스크린샷 모드 가드는 처음부터 넣었습니다. 스토어 자산 촬영이 데이터를 오염시키는 건 예상했는데, 테스트가 같은 일을 한다는 건 예상하지 못했습니다. 같은 종류의 위험인데 한쪽만 막아뒀습니다. 그리고 세 번째 오염원이 하나 더 있었습니다 → 앱 심사원이 신규 유저로 집계됐다.

오염을 발견한 것도 설계 검토가 아니라 결과가 궁금해서 테이블을 조회한 우연입니다. 조회 안 했으면 그대로 배포됐고, 베이스라인 첫날부터 유령 유저 6명이 섞인 표로 판단을 시작했을 것입니다.

총 10행이 들어갔고 전부 지웠습니다. 프로덕션 테이블에서 DELETE를 두 번 돌렸습니다. 계측 데이터라 손실이 없었지만, 이게 결제나 사용자 데이터였으면 같은 실수의 대가가 완전히 달랐습니다.

Android로 이식할 때는 같은 함정을 알고 있었는데도 컴파일이 먼저 깨져서 알게 됐습니다(생성자에 인자가 늘어 테스트 두 곳이 컴파일 실패). 컴파일러가 안 잡아주는 언어였으면 또 당했을 겁니다.

그 계측이 실제로 뭘 보여줬는지는 113일 결제 원장 글의 첫 세션 퍼널에 있습니다.

지금 CI를 한 번 돌려 보시고, 프로덕션 이벤트 테이블을 select count(*) 해 보시겠습니까? 그 숫자가 0이 아니면, 당신의 퍼널에는 존재하지 않는 사람이 살고 있습니다.

유튜브

테스트 184개 전부 통과 배포도 안 한 계측이 프로덕션에 6행을 썼다

관련 글