매일 자동으로 만들어지는 리포트에 "지난 28일"이라고 적힌 열이 있습니다. 그 열이 정말 28일치인지, 마지막으로 확인해본 게 언제입니까?
저는 확인한 적이 없었습니다. 2주 전에 이 리포트의 다른 결함을 파헤쳐 글까지 써놓고도요.
리포트에서 가장 이상해 보이던 앱
일일 리포트의 퍼널 표는 이렇게 생겼습니다. 2026-08-13자 실제 출력입니다.
| app | days | Impr | PageView | PPV/Imp |
|----------------------------|-----:|------:|---------:|--------:|
| Plotta GPS: Territory Walk | 28 | 56006 | 5816 | 10.4% |
| Zone 2: Heart Rate Trainer | 28 | 50141 | 842 | 1.7% |
| Reelo: AI Video Editor | 28 | 26661 | 694 | 2.6% |두 번째 줄이 계속 눈에 걸렸습니다. 노출은 플릿 2위인데 탭 전환이 1.7%입니다. 1위인 Plotta가 10.4%니까 6분의 1입니다. 해석은 자동으로 따라옵니다 — "검색에는 잘 뜨는데 아이콘과 첫 스크린샷이 사람을 못 붙잡는 앱." 스토어 자산을 갈아야 하는 전형적인 증상이고, 노출이 5만이나 되니 A/B 테스트를 돌릴 표본도 충분합니다.
세 문장 모두 틀렸습니다.
이 앱의 지난 28일 실제 노출은 1,036회입니다. 하루 37회입니다. 자산이 문제인 게 아니라 애초에 보여지지가 않고 있었습니다.
2주 전의 결론은 어디까지 맞았나
지난 글에서 저는 App Store Connect의 ONGOING 분석 리포트가 직전 3일치를 매일 다시 싣는다(restatement)는 걸 찾아냈습니다. 인스턴스를 전부 합산하면 같은 날이 23번 계산됩니다. 실측 배율은 2.763.01배였습니다.
그때 제가 스스로를 안심시킨 문장이 있습니다. "분자와 분모가 같은 배율로 부풀기 때문에 비율은 왜곡되지 않는다."
오늘 세 앱으로 다시 쟀는데, 그 문장 자체는 정확했습니다.
합산(중복 그대로) 중복 제거 후
Zone 2 impr 3,298 pv 241 7.3% 1,036 76 7.3%
Plotta impr 50,334 pv 5,310 10.5% 15,577 1,640 10.5%
Reelo impr 27,232 pv 593 2.2% 8,235 184 2.2%노출은 3.2배씩 부풀어 있는데 PPV는 소수점까지 같습니다. restatement는 정직한 종류의 오류입니다. 모든 행을 똑같이 복제하니까요.
그런데 리포트에 실린 Zone 2의 숫자는 50,141입니다. 중복을 제거한 1,036도 아니고, 중복을 그대로 둔 3,298도 아닙니다. 15배가 더 있습니다.
창이 열려 있었습니다
인스턴스를 하나씩 열어 어떤 날짜의 행이 들어 있는지 찍어봤습니다.
== Zone 2: instances=28 날짜범위=2026-04-21..2026-08-16 고유일수=118
전체합(행 날짜로 자르지 않음) impr=50,158 pv=854
inst ('2026-08-17', rows=90, days=3, '2026-08-14'..'2026-08-16')
inst ('2026-08-16', rows=95, days=3, '2026-08-13'..'2026-08-15')50,158. 리포트의 50,141과 사실상 같은 값입니다. 답이 나왔습니다.
인스턴스 하나는 보통 3일치를 담습니다. 그런데 28개 인스턴스가 함께 덮는 날짜 범위는 4월 21일부터 8월 16일까지, 고유 날짜로 118일이었습니다. 개중에 과거를 통째로 실어 나르는 백필 인스턴스가 섞여 있다는 뜻입니다.
그래서 이렇게 됩니다.
- 가져올 인스턴스는
processingDate(애플이 그 파일을 만든 날)로 고릅니다. 최근 28일치 인스턴스를 고르는 건 맞습니다. - 그 안의 행은 각자 자기
Date를 갖고 있습니다. 이걸로 다시 잘라내지 않으면, 4월치 행이 "지난 28일" 합계에 그대로 들어갑니다.
Plotta도 똑같이 새고 있었습니다. 28개 인스턴스가 87일을 덮고 있었고, 행 날짜로 자르지 않은 합계는 59,557이었습니다. 다만 Plotta는 최근 트래픽이 압도적이라 누출분이 18%밖에 안 됩니다. Zone 2는 최근이 텅 비어 있어서 과거가 전부였습니다.
두 결함을 앱별로 분해하면 이렇게 갈립니다.
| 앱 | restatement 배수 | 창 누출 배수 | 리포트 최종 배수 |
|---|---|---|---|
| Plotta | ×3.2 | ×1.18 | ×3.6 |
| Reelo | ×3.3 | ×1.0 | ×3.2 |
| Zone 2 | ×3.2 | ×15.2 | ×48.4 |
왜 이쪽이 더 나쁜가
restatement는 모든 앱을 똑같이 3배로 만듭니다. 배율이 공통이면 비율도, 앱 간 순위도 살아남습니다. 절대값으로 만든 결론만 버리면 됩니다.
창 누출은 배율이 앱마다 다릅니다. 앱마다 백필의 깊이가 다르고, 최근 트래픽과 과거 트래픽의 비율도 다르기 때문입니다. 배율이 다르면 무엇 하나 살아남지 못합니다.
- Zone 2의 PPV는 7.3%에서 1.7%로 무너졌습니다. 이건 오차가 아니라 다른 기간의 지표입니다. 최근 28일의 전환율 자리에 4개월치 누적 전환율이 앉아 있었던 것이고, 그사이 앱이 달라졌으니 값이 다릅니다.
- 앱 간 비교도 죽습니다. Plotta(×3.6)와 Zone 2(×48.4)를 같은 표에서 나란히 읽는 순간, 없는 격차가 만들어집니다.
"3배 틀렸다"는 눈에 띕니다. 앱마다 다르게 틀린 건 안 띕니다. 그럴듯한 표가 되기 때문입니다.
대가는 리포트가 아니라 그다음 결정에서 나왔습니다
이 숫자를 눈으로만 읽었다면 손해는 적었을 겁니다. 문제는 이게 검정력 계산의 입력값이었다는 것입니다.
같은 주에 저는 스토어 스크린샷 A/B 테스트(Product Page Optimization)를 설계하고 있었습니다. 2-proportion z-test로 앱별 필요 기간을 계산해서, 90일 안에 결과가 읽히는 앱만 대상으로 삼는 방식입니다. 당시 계산기는 리포트의 노출 수를 그대로 표본 크기로 받고 있었습니다.
부풀린 노출을 넣으면 계산기는 성립하지 않는 실험을 성립한다고 답합니다. 가드를 붙이기 전에 돌렸을 때 28일 노출이 31건인 앱이 "+20% 리프트 검출 가능"으로 통과했습니다. 그 앱의 PPV는 45%로 나와 있었는데, 사건 수가 한 자리라 비율 자체가 노이즈였습니다.
그래서 계산기에 표본 가드를 넣었습니다.
- 관측 노출 300건 미만이면 판정에서 제외 — 비율이 신호가 아니라 노이즈인 구간입니다.
- arm당 최소 사건 10건.
- 계산은 일일 리포트 표가 아니라 중복 제거를 태운 스냅샷 JSON을 입력으로 받습니다.
고치고 나서야 보인 게 하나 더 있습니다. 90일·2팔·검정력 80%·α=0.05로 식을 정리하면 노출이 분자와 분모에서 상쇄돼 MDE = sqrt(9.6 / 27일_설치수)만 남습니다. 구속조건은 노출이 아니라 설치 수였습니다. 제가 넉 달을 들여다본 그 열은 판정 가능성을 정하는 숫자가 아니었습니다. 노출이 상쇄된다는 건 지금 계산기가 이 결함에 면역이라는 뜻이기도 합니다 — 틀렸던 값을 아예 쓰지 않으니까요.
가드와 중복 제거를 모두 태운 뒤 실제로 실험을 건 앱은 43개 중 4개입니다(MDE 28~39%). Zone 2는 그 목록에 없습니다. 설치가 27일에 한 자릿수라 MDE가 98%를 넘습니다 — 90일을 꽉 채워도 "판정 불가"만 받습니다. 부풀린 표를 입력으로 썼다면 훨씬 많은 앱이 자격을 얻었을 것이고, 그중 상당수는 90일을 다 쓰고 "차이 없음"이라는 아무 정보 없는 결론을 받았을 겁니다. 실험은 실패해도 배우는 게 있지만, 애초에 검출력이 없던 실험은 배울 것이 없습니다.
고침
수정은 열 줄이 안 됩니다. 핵심은 인스턴스를 고르는 날짜와 행을 자르는 날짜가 서로 다른 필드라는 것입니다.
def dedupe_restated(instances, date_field="Date", lo=None, hi=None):
"""중복 제거 + 창 절단을 한 번에.
1) 인스턴스는 후행 3일을 재게시하므로 그냥 합치면 같은 날이 2~3번 세어진다.
processingDate 오름차순으로 돌면서 마지막 값이 이기게 둔다.
2) 창은 인스턴스의 processingDate가 아니라 행 자신의 Date로 자른다.
백필 인스턴스가 창 밖의 과거를 통째로 끌고 들어온다.
"""
latest = {}
for _pd, inst_id in sorted(instances):
for row in download_segments(inst_id):
d = row.get(date_field, "")
if lo and d < lo: # ← 이 두 줄이 48배를 만들었다
continue
if hi and d > hi:
continue
key = tuple(sorted((k, v) for k, v in row.items()
if k not in ("Counts", "Unique Counts")))
latest[key] = row # ← 이 한 줄이 3배를 만들었다
return list(latest.values()), {r[date_field] for r in latest.values()}정직하게, 아직 모르는 것
- Zone 2에만 4월까지 거슬러 올라가는 백필이 있는 이유를 모릅니다. 리포트 요청을 다시 만든 이력일 수도 있고 애플 쪽 재처리일 수도 있습니다. 확인 못 했습니다. 다만 원인을 몰라도 방어는 됩니다 — 행 날짜로 자르면 백필이 얼마나 깊든 상관이 없어집니다.
- 08-13 리포트를 만든 코드 사본이 어느 것이었는지 재구성하지 못했습니다. 수정 커밋은 08-04에 들어가 있는데 리포트는 08-13까지 옛 숫자를 냈습니다. 같은 프로젝트가 두 머신에 서로 다른 히스토리로 존재하고 예약 작업이 옛 경로를 가리키고 있던 시기가 겹칩니다. 심증은 있고 물증은 없습니다.
- 진짜 수정은 아직 안 했습니다. 위 코드는 값을 고칠 뿐, 다음에 같은 일이 생겨도 또 조용합니다. 리포트가 스스로 창을 증언하게 만들어야 합니다. 열 이름을 "28d"로 두지 말고 실제로 합산된 날짜의 최솟값·최댓값·고유 일수를 같이 찍는 것, 그것 한 줄이면 이 글이 필요 없었습니다.
자가진단
파이프라인이 외부 분석 API에서 기간 집계를 만들고 있다면, 세 가지만 확인해보십시오.
- 집계 창을 자르는 필드가 데이터가 만들어진 날짜입니까, 사건이 일어난 날짜입니까? 둘이 다르면 후자로 잘라야 합니다.
- 실제로 합산된 행의 날짜 범위를 출력해본 적이 있습니까?
min(Date),max(Date),len(set(Date))세 줄이면 됩니다. 라벨과 다르면 그게 답입니다. - 그 숫자를 소비하는 곳에 표본 가드가 있습니까? 부풀린 값은 리포트를 읽을 때보다 계산기에 들어갈 때 훨씬 비싸집니다.
한 줄로 남기면 이렇습니다. 부풀려진 지표보다 나쁜 건, 앱마다 다르게 부풀려진 지표입니다. 전자는 눈에 띄고 후자는 그럴듯한 표가 됩니다.