Cloudflare Workers + D1로 방문자 통계 대시보드 만들기 — 서버 없이, 설정부터 코드까지

결론 먼저: 이 사이트의 방문자 통계는 GA4도 GoatCounter도 아닌, 직접 만든 셀프호스트 애널리틱스로 잡습니다. 백엔드 서버는 없어요 — Cloudflare Worker + D1 위에 로직을 올렸을 뿐입니다. 이 글은 그걸 처음부터 끝까지 풉니다 — 뼈대 셋업(D1 생성·wrangler 배포)부터, 응답은 먼저 기록은 나중(ctx.waitUntil), fail-open, 쿠키리스 방문자 구분, 봇 판정, 그리고 아무나 못 보는 대시보드까지 전부 코드째로.

왜 직접 만들었나

브라우저에서 자바스크립트로 재는 애널리틱스(GA4·GoatCounter 등)는 애드블록·프라이버시 브라우저·봇에 상당수를 놓칩니다. 서버에서 재야 정확한데, 통계 하나 때문에 EC2를 세우는 건 배보다 배꼽이죠. 그 사이를 Cloudflare Worker가 메웁니다 — 요청이 원본 서버에 닿기 전에 엣지에서 가로채 D1에 기록하면, 백엔드 서버 없이 서버사이드 통계가 됩니다.

핵심 시퀀스 — 응답은 먼저, 기록은 나중

이 구조의 심장은 딱 하나입니다. 방문자에게 응답을 먼저 돌려주고, 기록은 그 뒤에 비동기로 한다. 그래서 통계를 붙여도 체감 속도가 1밀리초도 안 느려집니다.

방문자 Worker origin D1 GET /page (요청) fetch(origin) HTML HTML 즉시 반환 ① ← 방문자 응답 여기서 끝 · 지연 0 — 응답 이후 · 비동기 — ctx.waitUntil → D1 INSERT ② 사람 HTML 조회만 · 봇/에셋 제외
▲ 이 구조에서 제가 제일 신기했던 부분이에요. 응답을 먼저 보내버리고(①), D1 기록은 응답이 끝난 뒤에(②) 붙으니까 — 방문자는 통계가 달린 줄도 모릅니다.

코드로는 이 한 조각이 전부입니다.

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.sqlCREATE TABLE을 적고 D1에 실행한 뒤, 배포합니다.

npx wrangler d1 execute taystudios_analytics --remote --file=schema.sql   # 원격 D1에 테이블
npx wrangler deploy                                                        # 라이브

--remote가 실제 배포되는 D1이에요. 빼먹으면 로컬만 바뀌어서 "테이블 없음"이 납니다. 배포 후 셋만 확인하면 돼요 — ① 사이트 200 유지(fail-open), ② 토큰 없이 /_stats/query401, ③ 페이지 몇 개 방문 후 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 유입까지. 전부 우리 도메인 안에서, 서버 한 대 없이 돕니다.

GA4·GoatCounter 대비 실측하니 서버사이드가 훨씬 많이(정확하게) 잡혔어요. 애드블록·봇에 새던 방문이 서버에선 그대로 잡히니까요. 정확도가 필요한데 백엔드는 부담이라면, 이 구성이 답이 될 수 있습니다.

서버 없이 여기까지 왔습니다. 통계 하나 붙이자고 백엔드를 세울 뻔했는데, Cloudflare Worker + D1로 정확도는 정확도대로, 서버는 0대로 끝났어요. 혹시 정적 사이트를 운영하는데 "이것 때문에 서버까지…" 싶은 게 있으면, 한 번 떠올려 보시길. 생각보다 코드가 많지 않아요 ^^

이 글 공유𝕏f

댓글