정적 사이트에 동적 기능 구현하기 — GitHub Pages 블로그의 확장
목차
Astro + GitHub Pages 정적 블로그에 SEO, 댓글, 좋아요, 분석을 서버 비용 0원으로 구현한 기록
정적 사이트 생성기로 블로그를 만들면 호스팅은 단순해지지만, 댓글이나 좋아요 같은 동적 기능은 포기해야 할 것 같다. 서버가 없으니까. 하지만 외부 서비스와 엣지 인프라를 조합하면 서버 비용 0원으로도 꽤 완성도 높은 블로그가 된다.
이 글은 Astro와 GitHub Pages로 운영하는 정적 블로그에 SEO, 소셜 공유, 댓글, 좋아요, 분석 기능을 하나씩 얹어 나간 기록이다. 모든 외부 서비스는 무료 플랜으로 운영 중이다.
전체 구조
graph LR
V[Obsidian Vault] -->|vault-to-blog| R[GitHub Repo]
R -->|GitHub Actions| GP[GitHub Pages]
CF[Cloudflare DNS] -->|Proxied CNAME| GP
CF -->|Worker Route /api/*| W[Cloudflare Worker]
W --> KV[Workers KV]
GP --> B[브라우저]
B -->|댓글| GS[Giscus]
B -->|분석| GC[GoatCounter]
B -->|좋아요/조회수| W기술 스택은 Astro 5와 Tailwind CSS v4이다. 빌드 타임에 모든 페이지를 정적 HTML로 생성(SSG)하고, GitHub Actions로 배포한다. 커스텀 도메인은 Cloudflare Proxied CNAME으로 연결했다.
SEO 메타 태그 전략
검색엔진과 소셜 미디어에서 블로그가 제대로 노출되려면 메타 태그 설정이 필수다.
Open Graph와 Twitter Card
소셜 미디어에 링크를 공유하면 제목, 설명, 이미지가 포함된 미리보기 카드가 표시된다. 이를 위해 BaseLayout에서 모든 페이지에 Open Graph(og:title, og:description, og:image)와 Twitter Card(twitter:card="summary_large_image") 메타 태그를 삽입한다.
여기서 한 가지 판단이 필요했다. og:description에 한글을 넣을지 영문을 넣을지. 결론은 둘 다 쓰는 것이었다. frontmatter에 tldr 필드를 추가하여 영문 요약을 별도로 작성하고, 이것을 og:description과 twitter:description에 사용한다. 한글 description은 <meta name="description">에 그대로 유지해서 네이버 같은 국내 검색엔진 노출에 활용한다. 영문 tldr을 따로 둔 이유는 글로벌 SEO와 AI 인덱싱(ChatGPT, Perplexity 등)에서 영문이 더 효과적이기 때문이다.
구조화 데이터
포스트별로 JSON-LD BlogPosting 스키마를 삽입한다. headline, datePublished, dateModified, keywords, 그리고 abstract(영문 tldr) 필드를 포함한다. Google 리치 스니펫에 활용되며, dateModified는 콘텐츠 신선도 신호로, abstract는 AI 크롤러가 글의 핵심을 빠르게 파악하는 데 쓰인다.
검색엔진 등록
Google Search Console과 Naver Search Advisor에 각각 사이트 인증 메타 태그를 추가했다. @astrojs/sitemap으로 빌드 타임에 sitemap을 자동 생성하고, robots.txt에 sitemap 경로를 명시했다. RSS 피드도 @astrojs/rss로 생성하여 BaseLayout에 alternate 링크를 포함했다.
OG 이미지 동적 생성
포스트마다 고유한 OG 이미지를 수동으로 만드는 건 현실적이지 않다. 대신 빌드 타임에 자동 생성하도록 구현했다.
satori 라이브러리가 JSX를 SVG로 변환하고, resvg-js가 SVG를 PNG로 렌더링하는 파이프라인이다. 1200x630px 크기로, 상단에 로고와 도메인, 중앙에 포스트 제목, 하단에 태그 배지를 배치했다. 폰트는 Noto Sans KR Bold를 사용하여 한글이 깨지지 않게 했다.
소셜 미디어에 링크를 공유하면 제목과 태그가 포함된 카드 이미지가 자동으로 표시된다. 한 번 만들어두면 새 글을 작성할 때마다 신경 쓸 것이 없다.
Giscus 댓글
정적 사이트에 댓글을 넣는 선택지는 여러 가지다. Disqus는 광고가 붙고 무겁다. utterances는 GitHub Issues를 사용하는데, Issues가 댓글로 뒤덮이는 게 어색하다. Giscus는 GitHub Discussions 기반이라 더 자연스럽다.
URL pathname으로 Discussion을 자동 매핑하고, 한국어 UI로 설정했다. 다크 모드 연동도 구현했는데, 테마 토글 시 postMessage로 giscus iframe의 테마를 동기화한다. <script is:inline>으로 동적 로드하여 Astro의 island 아키텍처 없이도 가볍게 동작한다.
Cloudflare Workers로 좋아요/조회수
가장 공들인 부분이다. 정적 사이트에는 서버가 없으니 상태를 저장할 곳이 없다. 로그인을 요구하면 대부분의 방문자가 좋아요를 누르지 않을 것이다. 로그인 없이 동작하면서도 중복을 방지해야 했다.
블로그 도메인이 이미 Cloudflare Proxied CNAME으로 연결되어 있었기 때문에, Worker Route를 /api/* 경로에 걸어서 나머지 요청은 GitHub Pages로 그대로 통과시키는 구성이 가능했다. 좋아요(/api/likes/:slug)와 조회수(/api/views/:slug)를 하나의 Worker에서 처리한다.
클라이언트 식별
좋아요와 조회수 모두 동일한 2단계 식별 체계를 공유한다.
- 1차: localStorage UUID — 브라우저에서
crypto.randomUUID()로 생성한 UUID를 localStorage에 영구 저장하고, 요청 시X-Client-ID헤더로 전송한다. 같은 공유기 뒤의 다른 브라우저도 각각 고유한 UUID를 가지므로 정확히 구분된다. - 2차: IP 해시 (fallback) — UUID가 없는 경우(시크릿 모드 등) Cloudflare가 제공하는
CF-Connecting-IP를 SHA-256 + salt로 해싱한 뒤 앞 16자만 사용한다. 원본 IP는 보관하지 않는다.
Worker 내부에서 getClientKey(request, env, prefix, slug) 함수가 이 로직을 공통 처리한다. 좋아요 투표 기록은 영구 저장하고, 조회수 기록은 8시간 TTL로 설정하여 같은 클라이언트가 8시간 후 재방문하면 다시 카운트된다.
| 기능 | KV 키 패턴 | TTL |
|---|---|---|
| 좋아요 | voted:{slug}:c:{uuid} | 영구 |
| 조회수 | viewed:{slug}:c:{uuid} | 8시간 |
좋아요 4계층 상태 관리
sequenceDiagram
participant B as 브라우저
participant LS as localStorage
participant W as Cloudflare Worker
participant KV as Workers KV
B->>LS: liked:{slug} 확인 (즉시)
B->>W: GET /api/likes/:slug (X-Client-ID 헤더)
W->>KV: count:{slug} + voted:{slug}:c:{uuid}
KV-->>W: count, voted 여부
W-->>B: { count, voted }
B->>B: 좋아요 클릭
B->>LS: liked:{slug} = 1 (즉시 저장)
B->>B: count+1, 하트 채우기 (Optimistic)
B->>W: POST /api/likes/:slug (X-Client-ID 헤더)
W->>KV: count +1, voted 영구 저장
W-->>B: { count, voted } (실제 값으로 보정)Workers KV가 Source of Truth다. count:{slug}에 누적 카운트를, voted:{slug}:c:{uuid}에 투표 기록을 영구 저장한다.
localStorage가 즉시 상태를 담당한다. 페이지 로드 시 API 응답이 오기 전에 이전 투표 상태를 즉시 반영한다. 네트워크가 느리거나 실패해도 UX가 끊기지 않는다.
Optimistic Update로 체감 속도를 높인다. POST 요청 전에 UI를 먼저 갱신(하트 채우기 애니메이션, 카운트 +1)하고, API 응답으로 실제 값을 보정한다.
중복 방지는 2중으로 동작한다. 프론트엔드(localStorage)와 백엔드(UUID/IP hash) 양쪽에서 차단한다. 한쪽을 우회해도 다른 쪽이 막는다. 완벽한 방지는 아니지만, 로그인 없는 좋아요의 현실적인 타협점이다.
조회수 중복 방지
조회수도 좋아요와 동일한 클라이언트 식별을 사용하되, 8시간 TTL로 차별화했다. 같은 클라이언트가 8시간 내 재방문하면 카운트가 증가하지 않고, 8시간 후에는 다시 카운트된다. GoatCounter의 세션 기반 유니크 방문자(IP 해시 + 8시간)와 유사한 정책이지만, UUID 기반이라 공유기 뒤의 다른 사용자도 정확히 구분한다는 점이 다르다.
Worker 프로젝트는 블로그 레포 내 별도 디렉토리에 구성하고 wrangler deploy로 독립 배포한다. GitHub Actions의 deploy 워크플로우에서 Worker 경로를 제외하여, Worker 변경이 블로그 재배포를 트리거하지 않도록 분리했다.
Cloudflare Workers 무료 플랜(일 10만 요청, KV 10만 읽기 + 1천 쓰기)으로 개인 블로그에는 충분하다. 다만 KV 쓰기 한도가 일 1,000회로 낮기 때문에, 봇 트래픽이나 비정상 요청에 대한 보호가 필요하다.
KV 쿼터 보호
세 가지 계층으로 KV 사용량을 절약한다.
첫째, Cache API로 GET 응답을 edge에 60초간 캐싱한다. caches.default를 사용하여 같은 slug에 대한 반복 GET은 KV를 조회하지 않는다. 목록 페이지에서 10개 글의 조회수를 표시하려면 원래 10번 KV를 읽어야 하지만, 캐시가 있으면 분당 1회로 줄어든다. likes의 경우 count만 캐싱하고, voted(사용자별 투표 여부)는 매번 KV에서 조회한다. POST로 값이 변경되면 cache.delete()로 즉시 무효화하여 데이터 일관성을 유지한다.
둘째, Bot User-Agent를 필터한다. POST 요청에서 봇/크롤러 User-Agent를 감지하면 카운트 증가 없이 현재값만 반환한다. GET은 봇도 허용한다 — SNS 미리보기에서 조회수를 표시해야 하기 때문이다.
셋째, IP 기반으로 POST 요청을 분당 30회로 제한한다. rl:{ip_hash}:{minute} 키를 KV에 TTL 120초로 저장한다. 핵심은 dedup 체크(viewed/voted)를 rate limit보다 먼저 수행하는 것이다. 정상 재방문은 dedup에서 조기 반환되어 rate limit 키를 조회하지 않으므로 추가 KV 비용이 없다. rate limit은 dedup을 통과한 새 요청에만 적용된다.
GoatCounter 분석
Google Analytics 대신 GoatCounter를 선택했다. 프라이버시 친화적인 오픈소스 분석 도구로, 쿠키를 사용하지 않기 때문에 GDPR 동의 배너가 불필요하다. 트래픽 추세, 리퍼러, 지역 등 분석 대시보드 전용으로 사용하며, 블로그 UI에 표시되는 조회수는 Workers에서 가져온다. GoatCounter는 외부 SaaS로 IP 해시 기반 세션 추적을 하므로, 공유기 뒤 동일 IP 문제 등 식별 정밀도에는 한계가 있다.
Astro 컴포넌트 패턴
Giscus, LikeButton, 코드 헤더 등 클라이언트 인터랙션이 필요한 컴포넌트는 동일한 패턴을 따른다. <script is:inline>으로 번들링 없이 클라이언트 로직을 삽입하고, CSS 변수로 라이트/다크 모드에 자동 대응한다. View Transitions를 사용할 때는 astro:after-swap 이벤트로 재초기화한다.
React나 Svelte 같은 프레임워크 없이도 vanilla JS만으로 충분한 인터랙션이 가능하다. Astro의 zero-JS 기본 철학과 잘 맞는 접근이다.
서버 비용 0원의 구성
| 서비스 | 용도 | 비용 |
|---|---|---|
| GitHub Pages | 정적 호스팅 | 무료 |
| Cloudflare DNS + Workers + KV | 도메인, 좋아요/조회수 API | 무료 |
| Giscus | 댓글 (GitHub Discussions) | 무료 |
| GoatCounter | 분석 (조회수) | 무료 |
| Google Search Console | 검색 색인 관리 | 무료 |
| Naver Search Advisor | 네이버 검색 노출 | 무료 |
정적 사이트라고 해서 기능이 제한될 필요는 없다. 외부 서비스와 엣지 인프라를 조합하면 SEO, 댓글, 좋아요, 분석을 모두 갖추면서 서버 비용은 0원인 블로그가 가능하다. 핵심은 “서버를 직접 운영하지 않되, 서버가 필요한 기능은 서비스로 위임한다”는 판단이다.