Cloudflare Workers + D1로 방문자 통계 대시보드 만들기 — 서버 없이, 설정부터 코드까지
결론 먼저: 이 사이트의 방문자 통계는 GA4도 GoatCounter도 아닌, 직접 만든 셀프호스트 애널리틱스로 잡습니다. 백엔드 서버는 없어요 — Cloudflare Worker + D1 위에 로직을 올렸을 뿐입니다. 이 글은 그걸 처음부터 끝까지 풉니다 — 뼈대 셋업(D1 생성·wrangler 배포)부터, 응답은 먼저 기록은 나중(ctx.waitUntil), fail-open, 쿠키리스 방문자 구분, 봇 판정, 그리고 아무나 못 보는 대시보드까지 전부 코드째로.
왜 직접 만들었나
브라우저에서 자바스크립트로 재는 애널리틱스(GA4·GoatCounter 등)는 애드블록·프라이버시 브라우저·봇에 상당수를 놓칩니다. 서버에서 재야 정확한데, 통계 하나 때문에 EC2를 세우는 건 배보다 배꼽이죠. 그 사이를 Cloudflare Worker가 메웁니다 — 요청이 원본 서버에 닿기 전에 엣지에서 가로채 D1에 기록하면, 백엔드 서버 없이 서버사이드 통계가 됩니다.
핵심 시퀀스 — 응답은 먼저, 기록은 나중
이 구조의 심장은 딱 하나입니다. 방문자에게 응답을 먼저 돌려주고, 기록은 그 뒤에 비동기로 한다. 그래서 통계를 붙여도 체감 속도가 1밀리초도 안 느려집니다.
코드로는 이 한 조각이 전부입니다.
const response = await fetch(request); // origin 그대로 프록시
// ...사람 HTML 조회인지 판별...
ctx.waitUntil(logHit(request, env, url, ua, bot, botName)); // 응답 후 비동기 기록
return response; // 방문자는 이미 응답을 받음
ctx.waitUntil은 "응답은 보냈지만 이 백그라운드 작업이 끝날 때까지 Worker를 살려둬라"는 뜻입니다. 덕분에 D1 INSERT가 방문자 응답을 막지 않아요.
먼저 뼈대부터 — D1 만들고 Worker 배포하기
로직 얘기 전에, 이걸 굴릴 기본 골격부터 세워야죠. 저도 처음엔 "Worker? D1? 뭐부터 건드리지" 싶어 막막했는데, 순서만 잡으면 별거 없더라고요. 제가 실제로 밟은 순서 그대로입니다.
준비물 — Cloudflare 계정(무료) · Cloudflare로 관리되는 도메인(네임서버를 Cloudflare로 옮겨 프록시가 켜진 상태 — Worker를 경로에 물리려면 필요) · wrangler(Cloudflare CLI).
npm install -D wrangler # 프로젝트에 설치
npx wrangler login # 브라우저로 인증
① D1 만들기
D1은 Cloudflare가 주는 서버리스 SQLite입니다. 서버를 안 띄워도 SQL 테이블을 써요.
npx wrangler d1 create taystudios_analytics
성공하면 database_id를 뱉어줍니다. 복사해서 다음 단계 wrangler.toml에 박습니다.
② wrangler.toml — Worker를 어디에 물릴지
프로젝트 루트에. 우리가 실제로 쓰는 파일이 이겁니다(값만 바꿔 쓰면 돼요).
name = "taystudios-analytics"
main = "src/worker.js"
compatibility_date = "2024-11-01"
workers_dev = false
[[routes]]
pattern = "taystudios.com/*" # 이 경로의 모든 요청이 Worker를 먼저 거침
zone_name = "taystudios.com"
[triggers]
crons = ["0 19 * * *"] # 매일 롤업 (뒤 ④)
[[d1_databases]]
binding = "DB" # 코드에서 env.DB 로 접근
database_name = "taystudios_analytics"
database_id = "e4aeb8a8-3a2e-....." # ① 에서 받은 값
[[routes]]가 "서버사이드 인터셉트"의 핵심 — taystudios.com/*로 들어오는 모든 요청이 이 Worker를 먼저 거칩니다. 좁히려면 /api/*처럼.
③ secret — 비밀은 코드에 안 넣는다
토큰·솔트는 코드나 wrangler.toml에 절대 안 넣습니다. 따로 등록하면 런타임에서 env.XXX로만 읽혀요(git에 안 올라감).
npx wrangler secret put STATS_TOKEN # 대시보드 조회용 토큰 (길고 랜덤하게)
npx wrangler secret put HASH_SALT # 방문자 해시용 솔트 (길고 랜덤하게)
④ 스키마 반영 + 배포
schema.sql에 CREATE TABLE을 적고 D1에 실행한 뒤, 배포합니다.
npx wrangler d1 execute taystudios_analytics --remote --file=schema.sql # 원격 D1에 테이블
npx wrangler deploy # 라이브
--remote가 실제 배포되는 D1이에요. 빼먹으면 로컬만 바뀌어서 "테이블 없음"이 납니다. 배포 후 셋만 확인하면 돼요 — ① 사이트 200 유지(fail-open), ② 토큰 없이 /_stats/query → 401, ③ 페이지 몇 개 방문 후 D1에 행이 쌓이는지.
제가 밟은 지뢰 셋 — ① --remote 빼먹고 "왜 데이터 없지?"(로컬만 반영). ② 빌드 캐시가 옛 코드 배포 → rm -rf .wrangler node_modules/.cache 후 재배포. ③ route가 너무 넓어 오버헤드 → 에셋은 즉시 통과시키고 사람 HTML 조회만 로깅.
여기까지가 뼈대예요. D1 하나, route 하나, secret 두 개, 배포 한 줄. 저는 처음엔 반나절 헤맸는데 두 번째부턴 10분이면 되더라고요. 이제 이 위에 무슨 로직을 올렸는지가 진짜 알맹이입니다.
① 관측 코드가 서비스보다 위에 있으면 안 된다 (fail-open)
Worker는 모든 요청 앞단에 끼는 코드입니다. 로깅에 버그가 나 예외가 터지면 최악의 경우 사이트가 통째로 500을 뱉을 수 있어요. 통계 만들려다 사이트를 내리는 건 말이 안 되죠. 그래서 맨 앞에 안전장치를 깝니다.
async fetch(request, env, ctx) {
ctx.passThroughOnException(); // 예외 나면 origin 응답을 그대로 서빙
...
}
passThroughOnException()은 무슨 에러가 나든 원본 응답을 그냥 통과시켜라는 뜻입니다. 로깅이 통째로 터져도 방문자는 멀쩡한 페이지를 받고, 통계만 그 요청 하나를 놓칠 뿐이에요. 관측(통계)은 없어도 되지만 서비스(사이트)는 떠 있어야 한다 — 이게 fail-open 원칙입니다.
② 개인정보는 저장하지 않으면서 방문자는 구분하기
서버사이드로 가면 IP·UA가 다 손에 들어오지만, 그대로 저장하면 개인정보를 쌓는 셈입니다. 그래서 쿠키 없이, IP 원본도 저장하지 않으면서 방문자를 구분합니다. IP·UA를 그날의 솔트와 합쳐 해시만 남기는 방식이에요.
const salt = (env.HASH_SALT || 'salt') + '|' + day; // 솔트에 "날짜"를 섞음
const vh = await visitorHash(ip, ua, salt); // SHA-256 앞 8바이트만 보관
솔트에 날짜를 섞는 게 포인트입니다. 같은 사람도 날짜가 바뀌면 해시가 완전히 달라져서, 하루 안에서는 방문자를 구분하되 어제와 오늘을 이어 추적하는 건 불가능해요. 원본 IP는 어디에도 남지 않습니다(Plausible 같은 프라이버시 도구와 같은 계열). 대가로 "며칠에 걸친 순수 유니크"는 못 세지만, 방문자를 "하루 단위 유니크의 합"으로 정의하고 그 트레이드오프를 받아들였습니다.
③ "미국이 한국보다 많다"는 봇이었다
배포 며칠 뒤 데이터가 이상했어요. 한국 사용자용 사이트인데 미국 41% > 한국 31%. 데이터센터 봇이 사람으로 잡히고 있었습니다. 요즘 크롤러는 브라우저인 척 UA를 위장해서 UA만으론 구분이 안 돼요. Worker에 붙는 request.cf.asOrganization(접속 네트워크 소속)으로 걸러냈습니다.
// UA는 브라우저인데 접속처가 데이터센터(AWS/GCP/Azure...)면 위장 봇
const org = cf?.asOrganization || '';
if (org && !HUMAN_ASN_RE.test(org) && DC_RE.test(org)) return '데이터센터: ' + org;
단, 한국 통신사(SK·KT·LGU+)는 예외로 무조건 사람 처리했어요. 안 그러면 통신사 망을 탄 진짜 사용자까지 봇으로 오해하거든요. 봇/사람을 태깅해서 저장하고, 대시보드에서 토글로 걸러 봅니다.
④ 롤업은 cron으로 충분했다
원본(hits)은 계속 쌓이니 하루 한 번 롤업으로 날짜·페이지별 집계 테이블(daily)을 만들고 오래된 원본을 정리합니다. Cloudflare cron 트리거로 처리했어요.
[triggers]
crons = ["0 19 * * *"] # 매일 19:00 UTC = 04:00 KST
다단계 실행이 되는 Workflows도 있지만 안 썼습니다. 롤업이 무거워지는 건 데이터가 커져 훑는 양이 느는 문제인데, Workflows로 감싼다고 그 양이 줄지 않아요. 우리 규모(하루 수백~수천 조회)엔 cron + SQL 한 방이면 충분하고 더 단순합니다.
⑤ 대시보드는 아무나 못 본다 — 접근 제어
통계를 모아도 아무나 보면 안 되죠. 방문 데이터는 운영자만 봐야 합니다. 세 겹으로 잠갔어요.
(1) 조회 API는 비밀 토큰이 없으면 401. 대시보드가 부르는 집계 엔드포인트(/_stats/query 등)는 전부 토큰 검사를 통과해야 데이터를 줍니다.
const token = url.searchParams.get('token') || request.headers.get('x-stats-token') || '';
if (!env.STATS_TOKEN || token !== env.STATS_TOKEN) return json({ error: 'unauthorized' }, 401);
토큰은 쿼리 파라미터(?token=…)나 x-stats-token 헤더로 받고, Worker secret에 넣어둔 STATS_TOKEN과 일치할 때만 통과합니다. 토큰 값은 코드에도 글에도 절대 노출하지 않아요(노출되면 그대로 뚫립니다). 토큰이 없거나 틀리면 데이터 대신 401만 돌아갑니다.
(2) 대시보드 페이지는 색인 대상이 아니다. 대시보드 경로는 noindex + robots 차단으로 검색에 안 걸리게 했고, Worker가 이 경로는 로깅도 건너뜁니다(운영자 자기 조회가 통계를 오염시키지 않게).
if (url.pathname.startsWith('/dashboard')) {
// 항상 최신으로(엣지 캐시 우회) + 로깅 스킵
...
return r;
}
토큰을 URL에 담는 건 편하지만 완벽하진 않습니다. 링크가 새거나 referer로 샐 수 있어요. 그래서 이건 "1차 자물쇠"이고, 더 강하게는 (3) Cloudflare Access(Zero Trust)로 대시보드 경로 자체를 로그인 게이트 뒤에 둘 수 있습니다. 이메일 OTP·SSO로 "허용된 사람"만 페이지에 도달하게 하는 방식이라, 토큰이 새도 페이지 자체를 못 엽니다.
정리하면 토큰(API) + noindex(발견 차단) + Cloudflare Access(페이지 게이트) 세 겹. 통계 도구를 직접 만들 때 가장 놓치기 쉬운 게 이 접근 제어인데, 데이터가 곧 프라이버시라 제일 먼저 챙겨야 합니다.
그래서 대시보드는
이 위에 티스토리식 페이지별 통계 UI를 얹었습니다 — KPI(오늘·어제·누적), 일별 막대 차트, 유입 채널(검색·SNS·직접), 디바이스·국가 분해, "지금 N명" 실시간, 404 유입까지. 전부 우리 도메인 안에서, 서버 한 대 없이 돕니다.
서버 없이 여기까지 왔습니다. 통계 하나 붙이자고 백엔드를 세울 뻔했는데, Cloudflare Worker + D1로 정확도는 정확도대로, 서버는 0대로 끝났어요. 혹시 정적 사이트를 운영하는데 "이것 때문에 서버까지…" 싶은 게 있으면, 한 번 떠올려 보시길. 생각보다 코드가 많지 않아요 ^^
댓글