통계 도구마다 숫자가 다른 데는 이유가 있습니다. 구글 애널리틱스 스크립트는 광고 차단기와 브라우저의 추적 방지에 자주 막혀 적게 잡히고, 검색엔진 쪽 통계나 호스팅 대시보드는 수집봇의 접속까지 섞여 많게 잡힙니다. 방문자를 쿠키로 셀지, 세션을 몇 분으로 볼지, 하루를 어느 시간대로 자를지도 도구마다 다릅니다. 그래서 목표를 "다른 도구와 숫자를 맞춘다"가 아니라 "우리 기준으로 일관되게 센 숫자를 갖는다"로 정했습니다.
01정한 것 — 무엇을 세고, 무엇을 세지 않나
- 보고 싶은 것 세 가지 — 페이지별 방문 횟수, PC·모바일 구분, 유입 경로(네이버·다음·구글 등). 여기에 그날 방문자 수와 이전 기간 대비 증감을 더했습니다.
- 검색어는 뺀다 — 지금은 네이버·구글·다음 모두 사이트로 넘어올 때 "어디서 왔는지"만 넘기고 검색어는 넘기지 않습니다. 사이트에서 직접 세는 방식으로는 어떤 사이트도 검색어를 알 수 없어서, 검색어는 구글 서치 콘솔과 네이버 서치어드바이저에서 보기로 하고 범위에서 뺐습니다.
- 쿠키 없이, IP 저장 없이 — 같은 날 같은 사람을 한 명으로 세기 위해 IP·브라우저 정보에 날짜와 비밀값을 섞어 SHA-256으로 암호화한 값의 앞 16자리만 저장합니다. 날짜가 바뀌면 값도 바뀌어 날짜를 넘어 사람을 추적할 수 없습니다. 이전 사이트는 도메인만 저장하고, 2년 지난 기록은 자동으로 지웁니다.
- 보는 곳은 사이트 안 — 외부 서비스에 들어가지 않고 각 사이트의
/stats에서 비밀번호로 봅니다. 이 주소는 robots.txt, 응답 헤더, 메타 태그 세 곳에서 검색 노출을 막았습니다.
02전체 구조 — 스크립트 한 줄, API 둘, 표 하나
방문자 브라우저 각 페이지 <head>(또는 공통 스크립트)의 집계 코드 │ POST /api/p { p: 페이지 주소, r: 이전 페이지 주소, t: 터치 지원 수 } │ sendBeacon → 실패하면 fetch(keepalive) · 관리자 브라우저(nocount=1)는 보내지 않음 ▼ Cloudflare 파일이 있는 주소는 정적 파일 그대로, 파일이 없는 주소만 코드로 ├─ /api/p 수집: 봇·다른 출처 거르기 → 기기·유입 경로 분류 → D1에 한 줄 저장 ├─ /api/stats 조회: Bearer 비밀번호 확인 → 기간별 쿼리 7개를 batch 한 번에 └─ 그 밖 정적 처리로 넘김 (404.html · 끝 슬래시) ▼ D1 hits day · ts · path · device · source · ref_host · visitor — 첫 기록 때 표 자동 생성 ▲ 관리자 /stats 비밀번호(sessionStorage) → /api/stats → 요약 · 일별 그래프 · 기기 · 유입 경로 · 페이지
페이지가 한 번 열리면 한 줄 — 집계는 조회할 때 SQL로
미리 합계를 만들어 두지 않고 원본 한 줄씩만 쌓습니다. 조회할 때 GROUP BY로 그때그때 묶으면, 나중에 보고 싶은 항목이 늘어도 기록 형식을 바꿀 필요가 없습니다. 수집 주소를 /api/p처럼 짧고 평범하게 둔 것은 광고 차단기의 추적 주소 목록에 걸리지 않게 하려는 것이고, 같은 도메인으로 보내기 때문에 외부 분석 스크립트보다 덜 막힙니다.
03한 줄을 판정하는 규칙
- 봇 — 자바스크립트를 실행해야 기록이 나가는 구조라 대부분의 봇은 여기서 빠집니다. 남은 것은 User-Agent로 한 번 더 거릅니다(네이버 Yeti, Daumoa, headless 브라우저, 미리보기 봇 등). 다른 사이트에서 보낸 요청(Origin 불일치)과 형식이 이상한 주소도 버립니다.
- 기기 — User-Agent로 PC·모바일·태블릿을 나눕니다. 아이패드는 맥과 같은 정보를 보내서, 브라우저가 알려 주는 터치 지원 수(
maxTouchPoints)가 1보다 크면 태블릿으로 봅니다. - 유입 경로 — 이전 페이지 주소의 도메인을 규칙 목록에 위에서부터 대어 봅니다. 카페·블로그를 검색보다 먼저 두어
cafe.daum.net은 '다음 카페',search.daum.net은 '다음'으로 갈립니다. 팬카페와 블로그 공유가 많은 사이트라 이 구분이 중요했습니다. - 앱 안에서 연 링크 — 카카오톡처럼 앱 안의 브라우저로 열면 이전 주소가 비어 '직접 방문'으로 잡힙니다. 이전 주소가 없을 때는 User-Agent의 앱 표시(
KAKAOTALK,NAVER(inapp)로 대신 구분했습니다.
04대시보드 — 라이브러리 없이 한 파일
- 구성 — 기간 선택(오늘·어제·7일·30일·이번 달·지난 달·직접 선택) → 요약 숫자 셋(페이지뷰를 가장 크게, 이전 같은 기간 대비 증감) → 일별 페이지뷰 막대그래프 → 기기별 → 유입 경로 × 기기 표 → 페이지별 상위 100 → '기타 사이트' 도메인.
- 그래프 — 차트 라이브러리 없이 SVG를 직접 그립니다. 막대 두께 최대 24px, 끝만 4px 둥글게, 눈금은 연한 1px. 막대보다 넓은 날짜 칸 전체를 터치 영역으로 잡아 휴대폰에서도 눌러서 그날 방문자를 보고, 같은 내용을 '표로 보기'로도 엽니다.
- 색은 계산으로 — 막대 색은 눈대중 대신 명도·채도·대비 검사기로 확인했습니다. 트롯본방의 보라색은 통과했고, 건강퍼스트의 브랜드 색
#0A6E8A는 채도가 0.092로 기준(0.1)에 조금 못 미쳐 막대가 회색처럼 보일 수 있었습니다. 거의 같은 색인#00789A로 바꿔 통과시켰습니다. - 주소 대신 제목 — 트롯본방은 사이트 데이터 파일(
data.js)을, 건강퍼스트는 RSS(feed.xml·en/feed.xml)를 불러와 페이지 주소를 글 제목으로 바꿔 보여 줍니다. 사이트마다 이미 있는 목록을 재사용했습니다. - 내 방문은 빼기 —
/stats에 로그인하면 그 브라우저에 표시를 남겨 이후 방문은 보내지 않습니다. 확인하느라 여러 번 들어가도 숫자가 부풀지 않습니다.
05첫 배포에서 막힌 곳 — Pages인 줄 알았는데 Worker
- "Bindings cannot be added to a Worker that only has static assets" — 처음엔 Cloudflare Pages 방식(
functions/폴더 + 대시보드 Bindings)으로 만들었는데, 트롯본방은 Pages가 아니라 정적 파일만 올린 Worker였습니다. 이런 Worker에는 대시보드에서 DB도 비밀값도 붙일 수 없습니다. Workers & Pages 목록의 아이콘(<>는 Worker, 번개는 Pages)으로 먼저 확인했어야 했습니다. - 설정 파일로 전환 — 저장소에
wrangler.jsonc를 두어 정적 파일 폴더, Worker 코드(worker/index.js), D1 연결을 한 번에 적었습니다. 기존 동작을 지키려고not_found_handling: "404-page",html_handling: "auto-trailing-slash"를 넣고,.assetsignore로 Worker 코드·설정·.git이 사이트 파일로 올라가지 않게 했습니다. 머지 전에wrangler deploy --dry-run으로 바인딩과 업로드 목록을 확인했습니다. Worker 이름은 대시보드 이름과 같아야 하고, 연결된 도메인은 파일에 적지 않으면 그대로 유지됩니다. - 비밀번호를 넣었는데 "등록되지 않았다" — Settings 화면에 Variables and secrets 칸이 두 곳 있습니다. Deploy Hooks 위의 칸은 빌드 때만 쓰이고, 실제 Worker가 읽는 칸은 위쪽의 Runtime variables and secrets입니다. 오류 문구와 안내에 "Build 설정 아님"을 덧붙였습니다.
- 설정 화면이 옛 상태 그대로 — 배포는 끝났는데 설정 화면은 여전히 "static assets only"였습니다. 화면의 Compatibility date가 설정 파일 값과 다른 것으로 새 배포 전에 열린 화면임을 알았고, 새로고침으로 해결했습니다.
- 로컬 검증 — 배포 전에 Node 내장 SQLite로 D1을 흉내 낸 테스트 서버를 띄워, 실제 브라우저로 페이지를 열고 기록이 쌓이는지, 봇·다른 출처·잘못된 주소가 걸러지는지, 대시보드가 데스크톱과 휴대폰 폭에서 깨지지 않는지 확인했습니다. 작업 중
robots.txt를 고치다 파일을 비운 실수도 있었는데, git에서 원본을 되살려 다음 웹마스터 인증 줄까지 지켰습니다.
06두 번째 사이트 — 건강퍼스트에 옮기기
배포 방식 트롯본방 = Worker(wrangler.jsonc) 건강퍼스트 = Pages(functions/) 코드 위치 worker/index.js · collect.js · stats.js functions/api/p.js · stats.js + lib/stats-db.js D1 연결 설정 파일 d1_databases 대시보드 Settings → Bindings (DB) 비밀번호 Runtime variables and secrets Settings → Variables and Secrets 반영 시점 비밀값은 저장 즉시 연결·비밀값 모두 다음 배포부터 집계 코드 각 페이지 <head> (생성 스크립트 + index) assets/site.js 한 곳 (noindex 페이지는 스스로 건너뜀) 글 제목 data.js feed.xml · en/feed.xml
같은 판정 규칙과 화면 — 바뀐 건 코드를 거는 자리뿐
건강퍼스트는 모든 페이지가 같은 공통 스크립트를 쓰고 있어서 집계 코드를 site.js에 한 번만 넣었습니다. 앞으로 기존 글을 복사해 만드는 새 글에도 자동으로 들어가고, 404처럼 검색 제외된 페이지는 메타 태그를 보고 스스로 건너뜁니다. 공통 스크립트가 하루 동안 캐시되는 사이트라 92개 페이지의 ?v= 값을 함께 올렸습니다. 영어 페이지가 있어 개인정보처리방침은 한국어·영어 두 쪽에 같은 안내를 넣었습니다.
07다음 사이트에 옮길 때 체크리스트
0 확인 이 사이트는 Pages인가 Worker인가 (목록 아이콘) · 이미 Worker 코드나 D1이 있는가 1 DB D1 → Create (사이트명-stats) · Location 자동 · Database ID 복사 2 코드 Worker: worker/ 4파일 + wrangler.jsonc(name·database_id·404-page) + .assetsignore Pages : functions/api/p.js · stats.js + lib/stats-db.js 3 집계 공통 <head> 또는 공통 스크립트에 집계 코드 · 404·noindex 제외 · nocount 키 이름 정하기 4 화면 stats 페이지 복사 → 색(검사기 통과값) · 글 제목 출처(data.js / RSS) 바꾸기 5 노출 robots.txt Disallow /stats /api/ · 응답 헤더 noindex · 개인정보처리방침 항목 6 연결 Worker: 설정 파일로 머지 → 배포 · Pages: Bindings에 DB 7 비밀번호 Runtime(또는 Pages) Variables and Secrets에 Secret STATS_PASSWORD (Build 칸 아님) 8 확인 다른 기기로 페이지 열기 → /stats '오늘' · 봇·404 제외 · 휴대폰 폭
가장 먼저 볼 것: 그 사이트가 Pages인지 Worker인지
08지금 이 자리
트롯본방은 2026년 10월 26일부터 숫자가 쌓이기 시작했고, 건강퍼스트는 D1 연결과 비밀번호 등록을 마치면 같은 화면으로 수집을 시작합니다. 만드는 일은 끝났고, 이제는 쌓이는 숫자를 지켜보는 관찰(Observe)의 단계입니다.
몇 주 쌓이면 볼 것은 세 가지입니다. 구글 애널리틱스·서치 콘솔과 얼마나, 어느 방향으로 차이 나는지. 팬카페·카카오톡처럼 검색 밖에서 들어오는 비중이 얼마인지. 새 글이 첫 주에 어디서 읽히는지. 사이트가 더 늘면 D1 하나에 사이트 구분 칸을 두고 여러 사이트를 한 화면에서 보는 쪽으로 넓히는 것이 다음 흐름입니다.