조회수는 거짓말을 한다: 봇을 걸러내는 Cloudflare D1 방문자 카운터를 직접 만든 기록

seonoh.dev 푸터의 공개 방문자 수를 GA4와 별개로 직접 셌다. 봇과 링크 미리보기로 부풀려진 조회수 대신, Cloudflare D1로 봇을 걸러 원본 IP 없이 정직하게 센 기록이다.

한 줄 요약

  • seonoh.dev 푸터에는 “오늘 N명 · 누적 N명”이 걸려 있다. 이 숫자는 공개용이라 정직해야 하고, 이미 붙어 있는 GA4(Google Analytics 4, 구글 애널리틱스 4)가 대신 세 주지 못하는 숫자다.
  • 공개 조회수는 봇·링크 미리보기·나 자신 때문에 쉽게 부풀려진다. 그래서 봇을 걸러내는 방문자 카운터를 Cloudflare Workers와 D1로 직접 만들었다.
  • 저장소로 KV(Key-Value, 키-값) 스토어가 아니라 D1을 택한 이유, 원본 IP를 저장하지 않고 중복을 거르는 법, 동시 요청에도 한 번만 세는 법을 코드로 정리한다.

이 글은 개인 사이트에 방문자 수를 공개로 걸고 싶은데 그 숫자가 부끄럽지 않게 정직하길 바라는 1인 운영자를 위한 구현 기록이다. 서버를 상주시키지 않고, 방문자 프라이버시도 지키면서, 봇을 걸러 세는 최소 설계를 다룬다.

먼저 오해를 하나 걷어 낸다. 이 사이트에는 GA4가 라이브로 붙어 있다. 커피챗 버튼이나 이메일 클릭 같은 주요 액션은 click_email처럼 이름붙은 이벤트로 GA4에 들어간다. 그러니 이 글은 “분석 도구 없이 살았다”는 이야기가 아니다. GA4가 구조적으로 못 하는 일 두 가지를 내 손으로 메운 이야기다. 사이트를 Astro와 Workers로 짓고 배포한 토대는 개인 사이트를 Astro와 Cloudflare Workers로 짓기에 정리해 뒀고, 이 글은 그 위에 얹은 작은 동적 기능의 기록이다.

개인 사이트 푸터에 공개된 방문자 숫자를 직접 확인하며 정직하게 세는 1인 운영 작업 맥락

왜 공개 조회수를 못 믿는가

공개 방문자 수는 정직해야 한다고 본다. 누군가에게 보여 주려고 화면에 거는 숫자가 부풀려져 있다면, 그건 방문자에게 하는 작은 거짓말이다. 그런데 그냥 서버 로그의 페이지뷰를 세서 걸면 십중팔구 거짓말이 된다. 세 방향에서 숫자가 샌다. 검색·AI·모니터링 봇이 끊임없이 문서를 긁어 가고, 메신저에 링크를 붙이면 미리보기 크롤러가 한 번씩 방문으로 잡히고, 배포 직후 내가 직접 새로고침하는 것도 페이지뷰로는 방문이다.

그럼 GA4가 있으니 그 숫자를 걸면 되지 않느냐고 물을 수 있다. 여기서 두 도구의 목적을 갈라야 한다. GA4는 오너 전용 비공개 대시보드다. 구글 뒤편에서 유입 경로, 이벤트, 체류를 상세히 본다. 반면 푸터에 거는 숫자는 누구나 보는 공개 지표다. 이 둘은 공존하지만 하는 일이 다르다.

GA4가 이 공개 지표 자리를 대신 못 하는 이유는 두 가지고, 둘 다 구조적이다. 첫째, GA4의 집계는 구글 대시보드 안에 갇혀 있어 내 페이지 푸터에 실시간 숫자로 내걸기에 맞지 않다. 둘째, GA4가 세는 raw 페이지뷰 자체가 위에서 말한 봇·미리보기로 부풀려져 있다. 그래서 결론은 이렇다. GA4가 못 세고 못 보여 주는 숫자를, 봇을 걸러 가며 내 손으로 정직하게 센다. 분석은 GA4에게 맡기고, 공개 숫자는 내가 책임진다.

실제 사람 방문은 적은데 봇과 링크 미리보기 때문에 화면 지표만 부풀려 보이는 대비를 개념적으로 나타낸 작업 맥락

’방문자 1명’을 어떻게 정의했나

숫자를 세기 전에 정의가 먼저다. 여기서 방문자 1명은 페이지뷰가 아니다. 한국 시간(KST) 기준 하루 안에서, 같은 사람은 한 번이다. 한국 사이트라 “오늘”은 한국 날짜여야 하므로 날짜 경계도 KST로 맞춘다.

기술적으로는 (오늘 날짜, 방문자 지문) 조합이 그날 처음 등장했을 때만 1명으로 센다. 방문자 지문은 IP·UA(User-Agent, 사용자 에이전트)·날짜·솔트를 이어 붙여 해시한 값의 앞 24자다.

const vid = (await sha256Hex(`${ip}|${ua}|${date}|${salt}`)).slice(0, 24);

해시 입력에 날짜가 들어간다는 점이 핵심이다. 날이 바뀌면 같은 사람도 다른 지문이 되어, 어제와 오늘을 이어 붙여 추적할 수 없다. 이건 부작용이 아니라 의도한 설계다. “오늘 몇 명”을 세는 데 필요한 건 하루짜리 동일성뿐이고, 그 이상은 세지 않는 편이 방문자에게 정직하다.

브라우저 쪽에도 1차 중복 제거가 있지만, 그건 같은 사람의 새로고침으로 서버를 두드리지 않으려는 요청 절약일 뿐이다. 최종 판정은 언제나 서버가 한다. 클라이언트 신호는 위조·삭제가 가능하므로 판정 근거로 삼지 않는다.

저장소 선택: 왜 KV가 아니라 D1인가

Cloudflare는 데이터를 담는 저장소를 여럿 제공하고, 이 카운터의 후보는 둘이었다. KV(Cloudflare Workers KV)는 키 하나에 값 하나를 저장하는, 전 세계에 복제되는 분산 키-값 저장소다. 빠른 읽기에 강하다. D1(Cloudflare D1)은 SQLite 기반의 서버리스 SQL 데이터베이스로, 테이블·인덱스·트랜잭션 같은 관계형 SQL을 그대로 쓴다.

Cloudflare에서 작은 값을 저장한다면 KV가 먼저 떠오른다. 하지만 카운터에는 KV가 맞지 않다고 판단했다. 두 개의 벽이 있었다.

KV의 두 벽

첫 번째는 쓰기 총량이다. KV 무료 한도는 서로 다른 키에 대해 하루 1,000회 쓰기다.[1] 만약 KV로 방문자당 여러 키를 쓰도록 설계한다면 — 예를 들어 방문자당 3회를 쓴다고 가정하면 — 하루 333명 언저리가 천장이 된다. 이 “333명”은 KV로 그렇게 설계했을 때의 산술적 가정치이지 어디서 측정한 값이 아니다. 다만 개인 사이트의 성장 여지를 이 천장에 묶어 두고 싶지 않았다.

두 번째 벽이 더 근본적이다. KV는 같은 키에 대한 쓰기가 초당 1회로 제한된다. 무료·유료 공통이다.[1] d:오늘 같은 카운터 키 하나에 동시 방문이 몰리면, 값을 읽고 더해 다시 쓰는 사이에 다른 요청의 갱신이 조용히 덮인다. 이건 돈을 더 낸다고 풀리는 문제가 아니라 KV라는 도구의 구조적 성격이다.

D1이 주는 것

D1은 이 두 벽이 없다. 무료 한도가 하루 쓰기 100,000행, 읽기 5,000,000행이다. KV의 하루 쓰기 1,000회와 나란히 놓으면 쓰기 한도의 자릿수가 둘 더 크다.[2] 그리고 SQL이라 INSERT OR IGNOREON CONFLICT DO UPDATE(UPSERT, update와 insert를 합쳐 있으면 갱신·없으면 삽입) 같은 원자적 연산을 쓸 수 있다. 동시성 문제를 애플리케이션 코드가 아니라 데이터베이스가 대신 막아 준다.

덧붙이면, 위에서 KV의 “방문자당 3회”는 어디까지나 KV로 설계했을 때의 가정이다. 실제 D1 코드는 신규 방문 1건당 2쓰기다 — visitors에 지문을 넣고(INSERT), daily의 그날 카운트를 올린다(UPSERT). 재방문은 아예 쓰기가 없다.

카운터처럼 “동시에 같은 값을 올리는” 작업에 KV를 쓰는 건 도구를 잘못 고른 것이라고 생각한다. 원자성이 필요한 자리에는 SQL이 답이다. 데이터베이스 운영에서 원자성을 코드가 아니라 스키마로 보장하는 감각은 Firestore 인덱스 배포 기록에서 다룬 판단과도 이어진다.

Cloudflare KV와 D1의 무료 쓰기 한도와 동시성 처리를 두 열로 비교해, 방문자 카운터에는 원자적 처리가 되는 D1이 맞다고 정리한 카드

봇을 걸러내는 방어선 — 여섯 겹이라 세고 싶지만, 정직하게는 넷만 일한다

봇 필터를 “여러 겹”이라 뭉뚱그리고 싶은 유혹이 있다. 하지만 정직하게 세면 실제로 일하는 건 넷이고, 하나는 보조이며, 하나는 무료 플랜에서 죽어 있다. 독자를 위해 여섯 겹을 하나씩 세워 두고 역할과 상태를 표로 구분한다.

방어선 하는 일 상태
JS 비콘 전용 엔드포인트 카운트 API는 브라우저 스크립트만 호출한다. JS를 실행하지 않는 크롤러는 애초에 도달 못 함 실작동 (구조적, 위조 불가)
동일 출처 강제 Origin·Referer가 자기 호스트가 아니면 거부. 외부 페이지나 curl의 직접 호출 차단 실작동
IP 단위 일일 상한(8) 한 IP가 하루에 만들 수 있는 방문자 수를 8로 제한. UA만 갈아끼우는 부풀리기 차단 실작동
경로 필터 스캐너 프로브·정적 자산·API 경로 제외 실작동
User-Agent 블록리스트 알려진 봇 이름 30여 패턴을 1차로 컷 보강 (위조 가능)
Cloudflare 봇 점수·verifiedBot 엣지가 매긴 봇 점수로 거르기 무력화 (Enterprise 전용, 무료 미작동)

가장 먼저 짚을 건 맨 아래 줄이다. request.cf.botManagement.scoreverifiedBot 같은 엣지 봇 신호는 Cloudflare Bot Management를 쓸 때만 채워진다.[3] 무료 플랜은 이 필드들이 오지 않는다. 그래서 코드는 값이 있을 때만 쓰도록 방어해 두되, 무료 플랜에서는 아예 없는 셈 치고 설계했다.

function botReason(request: Request, path: string): string | null {
  const cf = (request as unknown as { cf?: Record<string, unknown> }).cf;

  // ↓ Bot Management 가 있을 때만 채워진다(무료 플랜은 undefined)
  const bm = cf?.botManagement as { score?: number; verifiedBot?: boolean } | undefined;
  if (bm?.verifiedBot === true) return 'verified-bot';
  if (bm && typeof bm.score === 'number' && bm.score <= 30) return `bot-score:${bm.score}`;

  const ua = request.headers.get('user-agent') ?? '';
  if (!ua || ua.length < 20) return 'ua-missing-or-short';
  if (BOT_UA.test(ua)) return 'ua-pattern';       // 위조 가능하므로 보조 수단
  if (PROBE_PATH.test(path)) return 'probe-path';

  return null;
}

봇 점수 하나에 기대는 건 게으르다고 생각한다. 무료 플랜에서는 그 점수가 오지도 않으니 더욱 그렇다. UA 블록리스트도 헤더 한 줄만 바꾸면 통과하는 보조 수단일 뿐이다. 그래서 실질 방어는 위조하기 어려운 구조적 장치에 둬야 한다. 자바스크립트를 실행해야만 도달하는 비콘 엔드포인트, 같은 출처에서 온 요청만 받는 동일 출처 강제, 그리고 UA를 아무리 갈아끼워도 결국 같은 IP에 걸리는 일일 상한이 그 바닥을 이룬다.

// 외부 페이지·curl 이 카운터를 두드려 부풀리는 것을 막는다.
function isSameOrigin(request: Request): boolean {
  const host = new URL(request.url).host;
  const origin = request.headers.get('origin');
  if (origin) {
    try { return new URL(origin).host === host; } catch { return false; }
  }
  const referer = request.headers.get('referer');
  if (referer) {
    try { return new URL(referer).host === host; } catch { return false; }
  }
  return false; // 둘 다 없으면 브라우저 fetch 가 아니다
}

IP 상한은 지문을 세기 직전에 그 IP가 오늘 이미 8명을 만들었는지 확인한다.

const capRow = await env.DB
  .prepare('SELECT COUNT(*) AS n FROM visitors WHERE date = ?1 AND iph = ?2')
  .bind(date, iph).first<{ n: number }>();
if ((capRow?.n ?? 0) >= MAX_VISITORS_PER_IP_PER_DAY) {
  return json({ ok: true, counted: false, reason: 'ip-cap' });
}
여러 겹의 관문을 사람은 통과하고 자동화된 봇은 걸러지는 방어선 통과 과정을 개념적으로 나타낸 맥락

원본 IP를 저장하지 않는 법

방문자 수를 세겠다고 원본 IP를 로그에 쌓는 건 과하다고 본다. 세는 데 필요한 건 “같은 사람인가”라는 동일성뿐이고, 그건 원본 없이 해시만으로 충분하다. 그래서 D1에는 IP를 그대로 넣지 않는다. 두 개의 해시만 저장한다.

const vid = (await sha256Hex(`${ip}|${ua}|${date}|${salt}`)).slice(0, 24); // 방문자 지문
const iph = (await sha256Hex(`${ip}|${date}|${salt}`)).slice(0, 16);       // IP 상한 판정용

vid는 중복 제거용 방문자 지문이고, iph는 IP 단위 남용 상한을 판정하기 위한 값이다. 둘 다 SHA-256 해시의 앞부분만 잘라 쓰며 원본 IP는 어디에도 저장하지 않는다. 해시 입력의 salt(솔트)는 소스에 박지 않고 wrangler secret으로 주입한다. 솔트가 코드에 없으니, 설령 저장된 지문이 유출돼도 임의의 IP를 대입해 맞춰 보는 역산이 어렵다.

앞서 말했듯 입력에 날짜가 들어가므로 날이 바뀌면 같은 사람의 지문도 바뀐다. “오늘 안에서 같은 사람”까지만 알 수 있고 그 이상은 모른다. 세는 목적에 딱 필요한 만큼만 알고, 나머지는 모르도록 만든 셈이다.

원본 값은 남기지 않고 하루짜리 지문만 남겨 같은 사람 여부만 판별하는 프라이버시 설계를 개념적으로 나타낸 맥락

동시 요청에도 한 번만 세는 법

KV로는 풀 수 없었던 동시성 문제를, D1에서는 스키마와 SQL 두 줄로 막는다. 두 지점 모두 원자적이다.

첫째, 중복 제거다. visitors 테이블은 (date, vid)를 복합 PK(Primary Key, 기본키)로 둔다. 그래서 INSERT OR IGNORE가 곧 원자적 중복 제거가 된다. 같은 사람의 요청 두 개가 동시에 겹쳐 들어와도, 삽입에 성공하는 건 한 번뿐이다. 성공 여부는 변경된 행 수로 판정한다.

const ins = await env.DB
  .prepare('INSERT OR IGNORE INTO visitors (date, vid, iph) VALUES (?1, ?2, ?3)')
  .bind(date, vid, iph).run();

const isNew = (ins.meta?.changes ?? 0) > 0;
if (!isNew) return json({ ok: true, counted: false, reason: 'dedup' });

둘째, 증가다. 신규 방문으로 판정됐을 때만 그날 카운트를 올리는데, 이 역시 UPSERT라 동시성에 안전하다.

await env.DB
  .prepare('INSERT INTO daily (date, count) VALUES (?1, 1) ON CONFLICT(date) DO UPDATE SET count = count + 1')
  .bind(date).run();

이 두 줄이 바로 KV로는 안 됐던 read-modify-write 경쟁을 우회하는 지점이다. 값을 읽고 더해 쓰는 세 박자를 애플리케이션이 하지 않고, 데이터베이스가 한 번의 원자적 연산으로 처리한다.

그대로 따라 만들기

재현할 수 있게 순서대로 정리한다. 스키마, wrangler로 만들기, Worker 라우트 골격 순이다.

스키마는 테이블 둘과 인덱스 하나로 끝난다.

-- 일자별 방문자 수. UPSERT 로 원자적 증가.
CREATE TABLE IF NOT EXISTS daily (
  date  TEXT PRIMARY KEY,   -- KST 기준 YYYY-MM-DD
  count INTEGER NOT NULL DEFAULT 0
);

-- 방문자 중복 제거용. (date, vid) 복합 PK 라 INSERT OR IGNORE 가 곧 원자적 dedup 이다.
CREATE TABLE IF NOT EXISTS visitors (
  date TEXT NOT NULL,
  vid  TEXT NOT NULL,   -- SHA-256(IP+UA+date+salt) 앞 24자 (원본 IP 저장 안 함)
  iph  TEXT NOT NULL,   -- SHA-256(IP+date+salt) 앞 16자 (IP 상한 판정용)
  PRIMARY KEY (date, vid)
);

-- IP 단위 상한 조회를 인덱스로 받친다(풀스캔 방지 = 읽기 행 수 절감)
CREATE INDEX IF NOT EXISTS idx_visitors_date_iph ON visitors (date, iph);

데이터베이스를 만들고, 스키마를 올리고, 솔트를 주입한다.

npx wrangler d1 create seonoh-visits
npx wrangler d1 execute seonoh-visits --remote --file=./worker/schema.sql
npx wrangler secret put VISIT_SALT

Worker는 딱 두 경로만 처리하고, 나머지는 정적 자산 바인딩(ASSETS)이 그대로 서빙한다. 카운터가 사이트를 막지 않도록 오류는 항상 200으로 삼킨다.

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === '/api/hit' && request.method === 'POST') {
      try { return await handleHit(request, env); }
      catch { return json({ ok: false, counted: false, reason: 'error' }); }
    }
    if (url.pathname === '/api/stats' && request.method === 'GET') {
      try { return await handleStats(request, env); }
      catch { return json({ today: 0, total: 0, error: true }); }
    }
    return env.ASSETS.fetch(request); // 그 외 전부 정적 자산
  },
};

푸터는 /api/stats가 준 값을 채워 “오늘 N명 · 누적 N명”으로 보여 준다. 숫자가 도착하기 전에는 아예 숨겨, 0이 잠깐 스치는 깜빡임을 막는다.

이 숫자의 한계

정직하게 세겠다고 시작한 글이니 한계도 숨기지 않는다. 이 숫자는 진실이 아니라 근사치다.

  • 공유 IP는 과소집계된다. 회사나 카페처럼 여러 사람이 NAT(Network Address Translation, 네트워크 주소 변환) 뒤에서 한 IP를 공유하면, 일일 상한 8에 걸려 아홉 번째부터는 세지 못한다. 남용을 막는 상한이 정상 방문자까지 자른다.
  • 모바일은 과대집계될 수 있다. 이동 중 IP가 바뀌면 같은 사람이 다른 지문이 되어 두 번 잡힐 수 있다.
  • 헤드리스 브라우저에 정상 UA를 씌우면 통과한다. 자바스크립트를 실행하고 UA를 위장한 자동화는 사람처럼 보인다. 완벽한 봇 차단은 없고, 문턱을 높였을 뿐이다.
  • GA4와 숫자가 다른 게 정상이다. GA4의 raw 페이지뷰와 이 카운터의 하루 단위 중복 제거 방문자는 정의가 다르다. 두 숫자가 어긋나는 건 버그가 아니라 서로 다른 것을 세고 있다는 뜻이다.

그래서 이 숫자를 “정확한 진실”이라 부르지 않는다. “부풀리지 않으려 애쓴 정직한 근사치”라 부른다. 공개 숫자에 필요한 건 소수점까지의 정밀함이 아니라, 최소한 거짓말은 아니라는 정직함이라고 생각한다.

자주 묻는 질문

무료 플랜이라 Cloudflare 봇 점수를 못 쓰는데 봇을 어떻게 거르나?

엣지 봇 점수는 Bot Management를 쓸 때만 채워지므로 무료 플랜에서는 없는 셈 치고 설계했다. 대신 위조하기 어려운 구조적 방어를 바닥에 깐다. 자바스크립트를 실행해야만 도달하는 비콘 엔드포인트, 같은 출처의 요청만 받는 동일 출처 강제, 한 IP의 하루 방문자를 8로 묶는 일일 상한이다. UA 블록리스트는 이 위에 얹는 보조 수단으로만 취급한다.

GA4가 있는데 방문자 수를 왜 또 세나?

목적이 다르기 때문이다. GA4는 유입·이벤트를 상세히 보는 오너 전용 비공개 대시보드이고, 이 카운터는 푸터에 내거는 공개 지표다. GA4의 집계는 구글 대시보드 안에 있어 페이지에 실시간으로 내걸기 어렵고, raw 페이지뷰는 봇으로 부풀려져 공개 숫자로 삼기엔 정직하지 않다. 둘은 공존하며 서로를 대체하지 않는다.

KV로도 방문자 카운터를 만들 수 있지 않나?

만들 수는 있지만 두 가지가 걸린다. 무료 쓰기 한도가 하루 1,000회라 작게 설계해도 천장이 낮고, 무엇보다 같은 키에 대한 쓰기가 초당 1회로 제한돼 동시 방문이 몰리면 카운트가 조용히 누락된다. 뒤 문제는 유료 전환으로도 풀리지 않는 구조적 제약이라, 원자적 UPSERT가 되는 D1을 택했다.

원본 IP를 저장하지 않으면서 어떻게 중복을 거르나?

IP·UA·날짜·솔트를 이어 붙여 SHA-256으로 해시한 지문만 저장하고, 원본 IP는 버린다. 같은 사람은 같은 입력이라 같은 지문이 나오므로 (date, vid) 복합 기본키로 중복을 걸러낸다. 입력에 날짜가 들어가 날이 바뀌면 지문도 바뀌므로, 하루 단위 동일성만 알고 장기 추적은 불가능하다.

이 글은 AI 업무 자동화 허브의 ‘기록·검증’ 단계 사례다.

참고 자료

[1] KV 한도, [2] D1 한도는 모두 작성 시점(2026-07) 무료 플랜 기준이며 요금·한도는 이후 바뀔 수 있으니 배포 전 공식 문서에서 재확인하는 편이 안전하다. [3] 엣지 봇 점수는 Bot Management(유료) 전용이라 무료 플랜에서는 오지 않는다. 본문 수치와 코드는 실제 운영 중인 seonoh.dev 소스에서 가져와 사람이 검수했다. “하루 333명 천장”은 KV로 방문자당 3회 쓰기를 가정했을 때의 산술치이지 측정값이 아니며, 실제 D1 코드는 신규 방문 1건당 2쓰기다.

← 글 목록