Hyunsoo Ro

Cloudflare D1으로 블로그 조회수 기능 만들기 Cover Image

Cloudflare D1으로 블로그 조회수 기능 만들기

서버리스 SQL DB를 붙여 조회수를 직접 집계하기까지

 · Dev- views

블로그를 거의 다 만든 뒤 홈 화면 우측 카드에 무엇을 넣을지 고민했습니다. 그러다 떠오른 게 조회수였습니다. 포스트 카드에 숫자가 보이면 소소하게 읽을 맛도 날 것 같았습니다.

Vercel Analytics를 이미 붙여뒀지만 조회수는 대시보드에서만 확인할 수 있었습니다. 페이지나 게시물에 숫자를 직접 보여주려면 별도로 집계하고 저장할 DB가 필요했습니다. Vercel에 배포한 Next.js 블로그라 설정만 보면 Supabase가 더 간단했습니다. 하지만 도메인, S3 스토리지, 이메일 인증까지 Cloudflare로 관리하고 있던 터라 DB도 같은 생태계 안에서 해결하고 싶었습니다.


Cloudflare D1이 뭔가요?

D1은 Cloudflare가 제공하는 서버리스 SQLite 데이터베이스입니다. Cloudflare Workers 바인딩뿐 아니라 REST API도 지원해서, Vercel처럼 Cloudflare Workers 외부에서 실행되는 Next.js 서버에서도 HTTP 요청으로 쿼리할 수 있습니다.

이 블로그는 Vercel에 배포되어 있어 Workers 바인딩을 쓸 수 없었지만, REST API 덕분에 문제없이 연결했습니다. 선택한 이유는 단순했습니다. 무료 플랜으로 충분했고 SQL을 그대로 쓸 수 있었으며, 별도 인프라를 관리할 필요도 없었습니다.


테이블 구조

조회수는 화면에 숫자만 보여주면 되는 기능입니다. 방문 이벤트를 모두 로그로 쌓는 대신, 보여줄 숫자는 숫자 테이블에 저장하고 중복 방지에 필요한 최근 방문 기록만 짧게 보관하는 구조로 잡았습니다.

이 구조라면 조회할 때마다 전체 행을 셀 필요가 없습니다. IP는 로컬 개발 요청인지 확인할 때만 사용하고 DB에는 저장하지 않습니다. 중복 여부는 브라우저에 발급한 익명 방문자 키의 최근 기록으로 판단합니다.

CREATE TABLE page_view_counts (
  pathname   TEXT PRIMARY KEY,
  total      INTEGER NOT NULL DEFAULT 0,
  updated_at INTEGER NOT NULL
);

CREATE TABLE daily_site_visit_counts (
  visit_date TEXT PRIMARY KEY,
  total      INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE recent_page_viewers (
  visitor_key TEXT NOT NULL,
  pathname    TEXT NOT NULL,
  visited_at  INTEGER NOT NULL,
  PRIMARY KEY (visitor_key, pathname)
);

CREATE TABLE recent_site_visitors (
  visitor_key TEXT PRIMARY KEY,
  visited_at  INTEGER NOT NULL
);

page_view_counts에는 게시물별 조회수를 저장합니다. /posts/filmlog-01 같은 각 경로를 하나의 row로 두고, 방문이 인정될 때마다 total을 1씩 올립니다.

홈 화면의 Total Visitsdaily_site_visit_counts에서 계산합니다. 페이지뷰를 모두 더한 값은 아닙니다. 같은 방문자가 여러 글을 읽더라도 30분 안에는 한 번만 올라가는 사이트 방문수에 가깝습니다.

recent_page_viewersrecent_site_visitors는 중복을 막기 위한 테이블입니다. 영구 분석 로그가 아니라 "이 방문자가 마지막으로 집계된 뒤 30분이 지났는지"만 판단하는 임시 기록입니다.


D1에 쿼리 날리기

Cloudflare REST API를 호출할 유틸 함수도 하나 만들었습니다.

interface D1BatchResult<T> {
  success: boolean;
  results: T[];
  errors?: { message: string }[];
}

interface D1ApiResponse<T> {
  success: boolean;
  errors?: { message: string }[];
  result?: D1BatchResult<T>[];
}

export async function queryD1<T = Record<string, unknown>>(
  sql: string,
  params: (string | number)[] = []
): Promise<T[]> {
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
  const databaseId = process.env.CLOUDFLARE_D1_DATABASE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;

  if (!accountId || !databaseId || !apiToken) {
    throw new Error('Missing Cloudflare D1 environment variables');
  }

  const res = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`,
    {
      method: 'POST',
      cache: 'no-store',
      headers: {
        Authorization: `Bearer ${apiToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ sql, params }),
    }
  );

  if (!res.ok) {
    throw new Error(`D1 request failed: ${res.status}`);
  }

  const data = (await res.json()) as D1ApiResponse<T>;

  if (!data.success) {
    const message = data.errors?.map((error) => error.message).join(', ') ?? 'unknown error';
    throw new Error(`D1 query error: ${message}`);
  }

  const first = data.result?.[0];
  if (!first) return [];

  if (!first.success) {
    const message = first.errors?.map((error) => error.message).join(', ') ?? 'unknown error';
    throw new Error(`D1 query error: ${message}`);
  }

  return first.results ?? [];
}

환경변수 3개(CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_D1_DATABASE_ID, CLOUDFLARE_API_TOKEN)로 인증합니다. HTTP 상태 코드만 확인하면 쿼리 실패를 빈 결과로 오해할 수 있습니다. 그래서 Cloudflare API 응답과 개별 쿼리 결과의 success까지 검사했습니다.


조회수 기록 API

Next.js Route Handler로 GET /api/views(조회)와 POST /api/views(기록) 두 가지를 만들었습니다.

새로고침이나 반복 방문으로 조회수가 중복 집계되지 않도록 익명 방문자 쿠키를 사용했습니다. 쿠키가 없으면 crypto.randomUUID()로 새 값을 만들고, 서버에서는 visitor:<uuid> 형태의 키로 사용합니다.

const INTERVAL_MS = 30 * 60 * 1000;
const VISITOR_COOKIE = 'views_visitor_id';

const visitorId = request.cookies.get(VISITOR_COOKIE)?.value ?? crypto.randomUUID();
const visitorKey = `visitor:${visitorId}`;

새로 만든 UUID는 응답 쿠키에 저장합니다. JavaScript에서 읽을 필요가 없으므로 httpOnly로 설정했고, 이후 방문 시 동일한 사용자를 식별할 수 있도록 유지 기간은 180일로 설정했습니다.

response.cookies.set(VISITOR_COOKIE, visitorId, {
  httpOnly: true,
  maxAge: 60 * 60 * 24 * 180,
  path: '/',
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
});

물론 쿠키만으로 방문자를 완전히 식별할 수는 없습니다. 쿠키를 삭제하거나 시크릿 창, 다른 브라우저 또는 기기를 사용하면 새로운 방문자로 집계됩니다. 이번 구현은 통계 용도의 조회수 기능이기 때문에, 이러한 수준의 중복 방지만으로도 충분하다고 판단했습니다.

방문 기록을 저장하기 전에 요청 자체도 검증합니다. 홈(/)과 실제로 존재하는 /posts/[slug] 경로만 허용하고, 빈 문자열이나 160자를 초과하는 값, 쿼리 문자열이나 해시가 포함된 경로는 모두 거부합니다. 이렇게 하면 잘못된 요청이나 임의의 경로로 인해 DB에 불필요한 데이터가 쌓이는 것을 막을 수 있습니다.

또한 로컬호스트에서 발생한 요청은 조회수에 반영하지 않습니다. 개발 중 반복되는 새로고침이나 테스트 기록이 실제 운영 통계에 섞이지 않도록 하기 위함입니다.

const ip = getClientIp(request);

if (ip === '127.0.0.1' || ip === '::1') {
  return NextResponse.json({ ok: true, counted: false });
}

중복 여부는 최근 방문자 테이블에서 확인합니다. 같은 방문자가 마지막 집계 후 30분 안에 같은 페이지를 다시 보면 숫자를 올리지 않습니다.

const cutoff = Math.floor((Date.now() - INTERVAL_MS) / 1000);

const recent = await queryD1<{ cnt: number }>(
  `SELECT COUNT(*) as cnt
   FROM recent_page_viewers
   WHERE visitor_key = ? AND pathname = ? AND visited_at >= ?`,
  [visitorKey, pathname, cutoff]
);

중복 방문이 아니라면 page_view_counts의 숫자를 1 올립니다. 처음 들어온 경로는 row를 만들고, 이미 있는 경로는 total만 증가시킵니다.

await queryD1(
  `INSERT INTO page_view_counts (pathname, total, updated_at)
   VALUES (?, 1, ?)
   ON CONFLICT(pathname) DO UPDATE SET
     total = total + 1,
     updated_at = excluded.updated_at`,
  [pathname, now]
);

최근 방문 기록은 조회수가 인정됐을 때만 갱신합니다. 그 안에 다시 방문해 집계되지 않은 요청은 visited_at을 연장하지 않습니다. 30분의 기준은 마지막 접근 시점이 아니라 마지막 집계 시점입니다.

await queryD1(
  `INSERT INTO recent_page_viewers (visitor_key, pathname, visited_at)
   VALUES (?, ?, ?)
   ON CONFLICT(visitor_key, pathname) DO UPDATE SET visited_at = excluded.visited_at`,
  [visitorKey, pathname, now]
);

사이트 방문수도 흐름은 비슷합니다. 페이지별 조회수가 아니라 사이트 전체 방문수이므로 daily_site_visit_counts에서 오늘 날짜의 row를 1 올립니다. 날짜는 서버의 기본 시간대에 맡기지 않고 Asia/Seoul 기준의 YYYY-MM-DD로 만듭니다.

await queryD1(
  `INSERT INTO daily_site_visit_counts (visit_date, total)
   VALUES (?, 1)
   ON CONFLICT(visit_date) DO UPDATE SET total = total + 1`,
  [today]
);

마지막으로 30분이 지난 중복 방지 기록을 삭제합니다. D1이 TTL로 자동 정리해 주는 구조는 아니라서, 조회수 기록 요청이 들어올 때 애플리케이션에서 직접 지웁니다.

await queryD1(`DELETE FROM recent_site_visitors WHERE visited_at < ?`, [cutoff]);
await queryD1(`DELETE FROM recent_page_viewers WHERE visited_at < ?`, [cutoff]);

조회수 / 방문수 계산

게시물 조회수는 숫자로 저장해 두었으므로 해당 경로의 row 하나만 읽으면 됩니다.

SELECT 0 as today, total
FROM page_view_counts
WHERE pathname = ?

홈 화면의 TodayTotal Visits는 날짜별 사이트 방문수 테이블에서 계산합니다.

SELECT
  COALESCE(SUM(CASE WHEN visit_date = ? THEN total ELSE 0 END), 0) AS today,
  COALESCE(SUM(total), 0) AS total
FROM daily_site_visit_counts

페이지뷰와 방문수를 나눈 이유는 홈 화면 숫자가 지나치게 빨리 올라갔기 때문입니다. 한 사람이 글 3개를 읽고 홈으로 돌아왔다면 각 경로에 페이지뷰가 남는 건 자연스럽습니다. 하지만 사이트 방문수까지 네 번 올라가는 건 어색했습니다. 그래서 페이지 조회수는 경로별로 집계하고, Total Visits는 같은 방문자 쿠키에서 마지막으로 집계된 뒤 30분이 지나야 다시 올라가게 했습니다.


서버 렌더링에서 조회수 분리하기

처음에는 서버 컴포넌트에서 조회수를 가져왔습니다. 그런데 조회수 하나 때문에 페이지 HTML 응답 전체가 D1 REST API를 기다리는 문제가 생겼습니다.

조회수는 글 본문보다 덜 중요한 정보입니다. 페이지를 먼저 정적으로 보여주고 조회수만 클라이언트에서 따로 가져오도록 바꿨습니다. 게시물 카드와 글 상세 화면은 같은 조회 훅을 사용하고, 숫자가 오기 전에는 - views를 보여줍니다.

export const PostViews = ({ slug }: PostViewsProps) => {
  const { data } = useViewsQuery(`/posts/${slug}`);
  return (
    <span className='ml-4 tabular-nums'>{data ? data.total.toLocaleString() : '-'} views</span>
  );
};

TanStack Query로 실시간 반영

클라이언트에서 조회수를 가져오게 바꾸면서 TanStack Query도 붙였습니다. 페이지가 먼저 뜬 다음, 조회수 API가 최신 숫자를 가져오면 화면이 갱신됩니다.

오래된 조회수가 화면에 남지 않도록 API 응답과 fetch 모두 no-store로 맞췄습니다. TanStack Query도 staleTime: 0, gcTime: 0, refetchOnMount: 'always'로 설정했습니다.

홈과 글 상세 화면이 마운트되면 POST /api/views로 방문 기록을 시도합니다. 요청이 끝난 뒤에는 성공 여부와 관계없이 해당 경로의 조회수 쿼리와 사이트 방문수 쿼리를 invalidate해 최신 값을 다시 가져옵니다.

const mutation = useMutation({
  mutationFn: () => postViews(pathname),
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['views', pathname] });
    queryClient.invalidateQueries({ queryKey: ['views', '__site__'] });
  },
});

덕분에 페이지를 먼저 보여주면서도 기록 요청이 끝난 시점의 최신 조회수로 화면을 갱신할 수 있습니다.


마치며

D1은 간단한 집계 용도로 쓰기에 잘 맞았습니다. Workers 바인딩 없이 REST API만으로 연결할 수 있고, 2026년 6월 기준 무료 플랜은 하루 500만 rows read, 10만 rows written, 총 5GB 저장 공간을 제공합니다. 개인 블로그에서 사용하기에는 충분히 넉넉한 수준입니다.

조회수처럼 가벼운 기능에 별도 DB 서비스를 붙이는 게 과하게 느껴질 수도 있습니다. 직접 써보니 이 정도 규모에서는 운영 부담이 크지 않았습니다.

Cloudflare의 현재 한도와 과금 기준은 D1 PricingD1 Limits에서 확인할 수 있습니다.


Check them out