lifecarelog
백엔드

정적 블로그에 백엔드 붙이기 — Cloudflare Pages Functions + D1로 조회수·댓글·좋아요 만들기

서버 없는 정적 블로그(Next.js export → Cloudflare Pages)에 조회수·좋아요·익명 댓글·방문자 통계를 붙인 방법이에요. 같은 배포에 Pages Functions와 D1만 얹어서, 따로 서버를 띄우지 않고 만들었어요.

7분 읽기AI 보조 작성

이 블로그는 Next.js를 output: 'export'로 빌드해서 Cloudflare Pages에 올리는 완전 정적 사이트예요. 그런데 조회수·좋아요·댓글처럼 "쓰기가 필요한" 기능은 정적 파일만으로는 안 돼요. 그렇다고 이것 하나 때문에 별도 서버나 Supabase를 띄우는 건 1인 운영엔 과해요. 그래서 동적인 부분만 같은 Cloudflare 배포 위에 Pages Functions(엣지 API) + D1(SQLite) 로 얹었어요.

같은 배포에 서버 부분만 얹기

핵심은 새 인프라를 늘리지 않는 거예요. 정적 결과물(out/)을 올리는 그 Pages 프로젝트에 functions/ 디렉터리만 추가하면, 그 안의 파일이 엣지에서 도는 API가 돼요. functions/api/views/[slug].ts는 그대로 POST /api/views/:slug 라우트가 되고요. 데이터는 같은 생태계의 D1에 넣으니 keepalive 걱정도, 외부 위젯도 필요 없어요. Workers 런타임이라 Node가 아닌 Web 표준 API(crypto.subtle, Request/Response)를 쓴다는 점만 기억하면 돼요.

조회수: 같은 방문자는 하루 한 번만

조회수를 그냥 +1 하면 새로고침마다 올라가요. 그래서 (slug, visitor_hash, day)를 기본키로 둔 view_dedup 테이블에 먼저 INSERT OR IGNORE를 시도하고, 실제로 행이 들어갔을 때만(meta.changes > 0) 카운트를 올려요. 중복 판정과 증가가 한 흐름에 묶여서 별도 잠금이 필요 없어요.

// dedup: 같은 방문자·당일 1회만 카운트 증가
const dedup = await env.DB.prepare(
  'INSERT OR IGNORE INTO view_dedup (slug, visitor_hash, day) VALUES (?,?,?)',
).bind(slug, vh, todayUTC()).run();
 
if (dedup.meta.changes > 0) {
  await env.DB.prepare(
    'INSERT INTO post_views (slug, count) VALUES (?,1) ON CONFLICT(slug) DO UPDATE SET count = count + 1',
  ).bind(slug).run();
}

daytoISOString().slice(0, 10)로 UTC 날짜만 잘라 써요. 좋아요도 같은 방식으로 (slug, visitor_hash) 기본키에 토글(있으면 DELETE, 없으면 INSERT)만 하면 돼요.

visitor_hash: 원문을 저장하지 않는 식별자

방문자를 구분하려면 식별값이 필요한데, IP나 User-Agent를 그대로 저장하면 개인정보가 쌓여요. 그래서 IP + UA + 솔트를 SHA-256으로 한 번 해시해서 해시만 저장해요. 원문은 어디에도 남기지 않아요(PIPA 최소수집).

export async function visitorHash(request: Request, env: Env): Promise<string> {
  const ip = request.headers.get('cf-connecting-ip') ?? '0.0.0.0';
  const ua = request.headers.get('user-agent') ?? '';
  return sha256(`${ip}|${ua}|${env.HASH_SALT}`);
}

솔트(HASH_SALT)는 코드가 아니라 Cloudflare 시크릿으로 주입해요. 댓글에 이메일을 받을 때도 같은 원리로, 원문 대신 이메일 해시만 저장해요(나중에 gravatar용).

익명 댓글의 스팸을 막는 세 겹

댓글을 익명·즉시 공개로 열면 스팸이 가장 큰 문제예요. 사전 검토 없이 운영하려고 방어를 코드 쪽에 세 겹 뒀어요.

1) 허니팟 — 사람 눈엔 숨긴 website 필드를 폼에 넣어두고, 값이 채워져 들어오면 봇으로 보고 에러 대신 조용히 성공처럼 응답해요(봇이 실패를 학습하지 못하게).

// honeypot: 사람에겐 숨긴 필드. 채워져 오면 봇 → 조용히 성공처럼 무시.
if (typeof payload.website === 'string' && payload.website.length > 0) {
  return json({ ok: true }, 200, cors);
}

2) 레이트리밋 — 같은 ip_hash로 최근 60초 안에 들어온 댓글 수를 세서 한도(코드 기준 3개)를 넘으면 429로 막아요. SQLite의 datetime('now', ?)로 시간 창을 계산해요.

const recent = await env.DB.prepare(
  "SELECT COUNT(*) AS c FROM comments WHERE ip_hash = ? AND created_at > datetime('now', ?)",
).bind(ipHash, `-${RATE_WINDOW_SEC} seconds`).first<{ c: number }>();
if ((recent?.c ?? 0) >= RATE_MAX) {
  return json({ error: '너무 자주 작성했어요. 잠시 후 다시 시도해주세요.' }, 429, cors);
}

3) XSS 이스케이프 — 본문은 가공 없이 plain text로 저장하고, 화면에는 React가 그대로 렌더링하게 둬요. dangerouslySetInnerHTML을 쓰지 않으면 React가 텍스트를 자동 이스케이프하니까 <script> 같은 입력도 그냥 글자로 보여요. 여기에 본문·이름 길이 제한(1000자·40자)까지 두면 기본적인 남용은 걸러져요.

dangerouslySetInnerHTML로 댓글 본문을 직접 넣는 순간 자동 이스케이프가 사라져 저장형 XSS가 열려요. 익명 입력은 "저장은 그대로, 표시는 텍스트로"가 기본이에요.

정리

정적 사이트라고 동적 기능을 포기할 필요는 없었어요. 같은 Cloudflare 배포에 Pages Functions와 D1만 얹으니, 서버를 따로 운영하지 않고도 조회수·좋아요·익명 댓글·방문자 통계를 다 붙일 수 있었어요. PII는 해시만 남기고, 익명 입력은 허니팟·레이트리밋·이스케이프로 막는 — 이 두 원칙만 지키면 1인 운영에도 부담이 적어요.


이 블로그를 운영하는 라이프케어로그는 일상 기록을 돕는 서비스를 만드는 1인 개발 스튜디오예요.

#cloudflare-pages#d1#serverless#backend#privacy#spam

라이프케어로그 서비스가 궁금하신가요?

AI 기반 건강·일정·재활 관리 앱을 직접 써보세요.

서비스 살펴보기

관련 글

댓글

아직 댓글이 없어요. 첫 댓글을 남겨주세요.