자동화 파이프라인17 분 읽기

게이트를 통과시킨 방문자는 제 스크린샷이었습니다

2주 전에 저는 헤드리스 Chrome으로 앱 화면을 자동 캡처하는 파이프라인을 자랑하는 글을 썼습니다. 그 캡처가 매일 저녁 제 계측에 방문자를 적립하고 있었습니다. user-agent도 IP도 저장하지 않는 원장에서 그걸 어떻게 잡아냈는지, 그리고 필터를 정교하게 짜는 대신 세는 대상을 바꾼 이야기.

#analytics#data-quality#reality-check#gotchas#headless-chrome#automation
개념 도식: landing 7,531건의 실제 구성과, 분모를 로드에서 저장 토큰으로 바꿨을 때의 차이
왼쪽은 로드를 세던 게이트, 오른쪽은 저장 토큰을 세는 게이트. 같은 데이터, 다른 결론.

계기판 세 개를 나란히 놓고 보다가 이상한 걸 발견했습니다.

  • 검색 리포트: 28일 동안 클릭 45회
  • 대역폭 판단: 무료 한도의 1.3%
  • 앱 계측 대시보드: 페이지뷰 7,531

둘은 "거의 아무도 안 온다"고 말하는데 하나만 컸습니다. 출처가 설명되는 유입(검색 45 + 태깅된 리퍼러 124)을 다 합쳐도, 출처 없는 "(직접)" 방문이 그 58배였습니다.

여기서 질문 하나. 당신의 대시보드에서 가장 큰 숫자는, 다른 계기판들과 자릿수가 맞습니까? 안 맞으면 보통 작은 쪽이 아니라 큰 쪽이 거짓말입니다.

user-agent도 IP도 없는데 어떻게 가려내나

제 이벤트 테이블에는 user-agent도 IP도 없습니다. 컬럼이 app, event, token, locale, created_at, src 뿐입니다. 봇 판별에 흔히 쓰는 축이 아예 없는 겁니다.

그래서 남은 축만으로 지문을 떴습니다. landing 7,531건을 전부 내려받아 네 가지를 봤습니다.

1. 간격. 같은 앱에서 60초 안에 3건 이상 몰린 묶음이 전체의 **53%**였습니다. 사람은 한 앱을 3초 간격으로 반복 로드하지 않습니다.

2. 시각. 그 묶음들이 특정 시:분에 반복됐습니다. 18:35~18:37, 19:48, 19:51. 제 스케줄러를 열어보니 18:30에 숏폼 발행 잡이, 19:45에 인스타 잡이 돌고 있었습니다. 크론 시각 + 몇 분이었습니다.

3. 파일 mtime. 이게 결정적이었습니다. 캡처 스크린샷 파일의 수정 시각과 이벤트 타임스탬프를 나란히 놓았습니다.

스크린샷  match_ddi_ko/snake-rooster.png   08-17 18:35:18
이벤트    ddi landing 18건 locale=[ko×6, en, en]  08-17 18:35:15

같은 사건입니다. 제 캡처 코드는 이렇게 생겼습니다.

subprocess.run([CHROME, "--headless=new", "--disable-gpu",
                "--screenshot=%s" % out, "--virtual-time-budget=9000", url])

--virtual-time-budget=9000은 "JS를 9초간 실행해라"는 뜻입니다. 스크린샷을 제대로 찍으려고 넣은 옵션인데, 그 9초 안에 페이지의 계측 코드가 발화합니다. 스크린샷 한 장이 방문자 1명이 됐습니다.

2주 전에 저는 이 파이프라인을 자랑하는 글을 썼습니다. 뷰포트 폭 하나 맞춰서 앱 35개 결과 화면을 자동으로 찍는다고요. 그 글에서 저는 계측을 한 번도 언급하지 않았습니다. 생각조차 안 했습니다.

4. 로케일이 자백했습니다. 이벤트에 찍힌 로케일 값에 이런 게 섞여 있었습니다.

en-US@posix    en-US@po    ko-KR

@posix브라우저가 절대 보내지 않는 값입니다. 셸의 LC_ALL입니다. 헤드리스 도구가 자기 실행 환경의 로케일을 그대로 흘린 겁니다. 이 한 글자가 "이건 사람이 아니다"의 물증이 됐습니다.

걷어냈는데도 안 맞았습니다

버스트와 POSIX 로케일을 다 제외하고 3,020건이 남았습니다. 여기서 멈추고 "나머지는 사람"이라고 결론 내릴 수도 있었습니다. 그런데 시간대 분포를 봤습니다.

  • 전체: 낮/새벽 비율 1.72배
  • 걷어낸 잔여: 1.20배

한국어 소비자 앱 트래픽이면 새벽 4시는 피크의 10~20%까지 떨어집니다. 1.20배는 거의 평탄입니다. 새벽 4시에 시간당 110건, 낮에 121건. 사람의 생활 리듬이 아닙니다. 검색 크롤러는 JS를 렌더합니다. 그리고 제 검색 리포트가 노출 4,439회를 보고하고 있었으니, 구글이 실제로 이 사이트들을 돌고 있다는 뜻이었습니다. 앞뒤가 맞았습니다.

하루치가 유독 컸습니다. 8월 16일 하루에 2,691건 — 34일 전체의 36%. 그날 앱 30개에 각각 90~111건씩 거의 균등하게 들어왔습니다. 사람은 이런 분포를 만들지 않습니다. 그날 제 커밋 로그에 "전수 점검"이 있었습니다. 디자인 감사 스크립트가 Playwright로 앱 43개를 순회한 날이었습니다.

무엇이 망가졌는가

이 숫자가 그냥 대시보드 장식이면 웃고 넘겼을 겁니다. 문제는 이게 게이트의 분모였다는 겁니다.

제 앱 승격 게이트는 이렇게 생겼습니다. "페이지뷰 500 도달 후 판정, 결과 전환율 3% 이상, 공유율 10% 이상. 통과하면 2차 투자(결제 붙이기 등) 착수."

그리고 한 앱이 통과해 있었습니다. 궁합 앱이었습니다. 페이지뷰 723으로 500을 넘겼고 화면에 초록색 "기준 통과" 배지가 붙어 있었습니다.

그 723건 중 478건(66%)이 제 궁합 캡처였습니다. 그 앱은 궁합 조합을 ko/en/ja 3개 로케일로 찍기 때문에 가장 많이 열리는 앱이었습니다.

투자 신호가 제 스크린샷으로 만들어져 있었습니다.

당신이라면 어디를 고치겠습니까

여기서 갈림길이 나옵니다. 저는 두 선택지를 놓고 한참 고민했습니다.

A. 필터를 정교하게 만든다. 버스트를 탐지해서 빼고, 크론 시각대를 빼고, POSIX 로케일을 빼고, 08-16 같은 스윕 날짜를 빼고. 제가 위에서 분석하며 이미 다 해본 것들입니다. 코드로 옮기면 됩니다.

B. 세는 대상 자체를 바꾼다.

저는 A를 꽤 오래 붙들고 있었습니다. 이미 분석해놨으니 옮기기만 하면 되니까요. 그런데 A에는 끝이 없습니다. 새 자동화를 만들 때마다 필터를 하나 더 짜야 하고, 그걸 잊으면 조용히 다시 오염됩니다. 실제로 저는 3일 전에 게임 리포트에서 똑같은 결함을 고쳤고, 그때 만든 교정을 이 대시보드에는 넣지 않아서 재발한 상태였습니다.

그래서 B로 갔습니다. 게이트가 묻는 질문이 "사람이 쓰는가"라면, 분모는 기계가 만들 수 없는 것이어야 합니다.

  • 페이지 로드는 만들 수 있습니다. 제가 만들었고 크롤러도 만듭니다.
  • 브라우저 저장소에 남는 토큰은 만들기 어렵습니다. 헤드리스는 실행마다 새 프로필이고, 크롤러도 방문 사이에 저장소를 이어가지 않습니다.

그래서 seedN 500단위를 페이지뷰에서 고유 방문자 토큰으로 바꿨습니다. 숫자 500은 건드리지 않았습니다. 기준을 낮춘 게 아니라 세던 대상이 틀렸던 것이라, 임계를 손대면 두 변경이 섞여 나중에 무엇 때문에 결과가 바뀐 건지 알 수 없게 됩니다.

그리고 상태 하나를 새로 만들었습니다. **"측정 불가"**입니다. 로드는 쌓이는데 토큰이 한 번도 안 온 앱은 통과도 미달도 아닙니다. 사람을 셀 수단이 없는 앱입니다. 이걸 "시드 0/500"으로 보여주면 트래픽이 없는 정상 앱과 구분이 안 됩니다.

라벨도 거짓말을 합니다

출처가 없는 이벤트를 저는 **"(직접)"**이라고 표시하고 있었습니다. 이 라벨이 오독의 절반을 만들었습니다. "직접"은 주소창에 도메인을 직접 입력한 방문처럼 읽힙니다. 실제로는 그냥 출처를 모르는 것입니다. (출처 미상)으로 바꿨습니다.

표의 PV 열도 로드로 바꿨습니다. 방문자 열을 새로 만들어 앞에 놓고, 기본 정렬도 방문자순으로 바꿨습니다. 대시보드가 먼저 보여주는 순서가 곧 무엇을 중요하게 여기는지입니다.

생산 쪽도 막았습니다

집계에서 걸러내는 것과 애초에 만들지 않는 것은 다릅니다. 캡처·감사 도구가 앱을 열 때 ?src=qa-capture, ?src=qa-audit를 붙이게 했습니다. 앱들이 이미 ?src를 파싱하고 있었으므로 앱 배포 없이 분류가 됩니다.

그리고 여기서 하나 더 걸렸습니다. 앱 루트가 이렇게 생겨 있었습니다.

export default function RootPage() {
  redirect('/ko');
}

redirect()쿼리스트링을 통째로 버립니다.https://앱.도메인/?src=reddit으로 공유한 링크는 307 리다이렉트를 지나면서 출처가 사라집니다. "출처 미상" 산더미의 일부가 이것이었고, 앞으로 외부 채널을 테스트할 때마다 결과를 못 읽게 되는 지뢰였습니다.

앱 코드에서 searchParams를 받아 이어붙일 수도 있었지만, 그러면 정적이던 루트가 요청마다 서버를 타게 됩니다. next.configredirects()쿼리를 기본으로 보존합니다. 플랫폼이 이미 해주는 일이었습니다.

async redirects() {
  return [{ source: '/', destination: '/ko', permanent: false }];
}

기존 page.tsx는 지우지 않고 폴백으로 뒀습니다. config 리다이렉트가 파일시스템 라우트보다 먼저 평가되니 실제로는 config가 이기고, 지웠다가 config가 안 먹으면 루트가 404가 됩니다.

고친 뒤 숫자

이전 이후
주 지표 페이지뷰 7,531 방문자 823
"기준 통과" 앱 1개 0개
"측정 불가" 표시 없음 있음

아무 앱도 통과하지 않습니다. 34일간 실제 방문자 823명, 최다 앱이 144명입니다. 이게 정직한 그림입니다.

기분 좋은 결과가 아닙니다. 통과 배지 하나가 사라졌고, 그 자리에 "측정 불가"가 여럿 생겼습니다. 하지만 없는 견인력을 근거로 결제 화면을 붙이는 것보다는 이게 훨씬 쌉니다.

자가진단 3개

지금 당신의 파이프라인에 그대로 대볼 수 있는 것만 골랐습니다.

  1. 당신의 스크린샷·E2E·업타임 체커는 JS를 실행합니까? 실행한다면 그 요청은 당신의 분석 도구에 사람으로 잡히고 있을 가능성이 큽니다. 헤드리스 브라우저를 쓰는 잡의 실행 시각과, 이벤트 테이블의 시:분 히스토그램을 나란히 놓아 보세요.
  2. 당신 게이트의 분모를 기계가 만들 수 있습니까? 페이지뷰·요청 수·노출 수는 만들 수 있습니다. 저장소가 유지돼야 생기는 값(토큰, 로그인 세션, 결제)은 만들기 어렵습니다.
  3. "직접"이나 "기타"로 라벨된 버킷이 전체의 절반을 넘습니까? 그렇다면 그건 데이터가 아니라 모른다는 사실입니다. 이름을 사실대로 바꾸는 것만으로도 판단이 달라집니다.

솔직한 부분

과거 데이터는 못 고칩니다. QA 라벨과 방문자 토큰은 오늘부터 붙으므로, 이전 7,531건은 영영 갈라지지 않은 채 남습니다. 게이트가 더 이상 로드에 의존하지 않으니 판정에는 영향이 없지만, 34일치 히스토리는 버린 셈입니다.

그리고 이 결함은 재발이었습니다. 3일 전에 같은 구조의 문제를 다른 리포트에서 고쳤고, 그때 만든 교정을 이쪽에는 옮기지 않았습니다. 자기 트래픽이 지표를 부풀린다는 걸 알고 있었는데도, 그 지식이 코드 한 곳에만 있었습니다. 아는 것과 모든 곳에 적용한 것은 다릅니다.

마지막으로 검증하느라 제가 만든 이벤트 4건을 원장에서 지웠습니다. id를 지목해서 지웠고, 패턴으로 일괄 삭제하지 않았습니다. qa-로 시작하는 행이 85건이었는데 그중 82건은 다른 날 다른 세션의 QA 기록이었습니다. 편하게 패턴 삭제했으면 남의 이력을 날렸을 겁니다.

당신 프로젝트에서 자동화가 자기 지표를 오염시킨 경험이 있으신가요. 어떤 지문으로 잡아냈는지 궁금합니다. 아직 확인 안 해보셨다면, 헤드리스 잡의 크론 시각과 이벤트 테이블의 시:분 분포를 한 번 겹쳐 보세요. 10분이면 됩니다.

관련 글