정적 블로그는 조회수 못 단다고요? 무료로 직접 붙여봤습니다

GitHub Pages에 올린 정적 블로그는 서버가 없으니 글별 조회수도 못 붙인다고 생각하기 쉽다. 나도 처음엔 외부 분석 도구의 숫자만 봐야 하나 싶었다. 그런데 글 상세에서만 호출되는 작은 API를 두고 상태를 서버리스 저장소에 맡기니 누적 조회수 표시는 충분히 만들 수 있었다.
이번 블로그에는 Astro 프런트, Cloudflare Worker, Durable Objects, Google Sheets를 연결했다. 별도 유료 서버를 상시 운영하지 않고 개인 블로그 규모에서 적용한 구조다.
글을 열면 어떤 일이 일어날까
글 상세 페이지의 ViewCounter는 브라우저에 저장한 임의 방문자 ID와 글 slug를 Worker로 보낸다. Worker는 slug마다 하나의 Durable Object를 찾아 누적 수와 마지막 방문 시각을 확인한다. 집계된 수는 화면으로 돌려주고, 실제로 증가했을 때만 Google Sheets의 blog_views 탭에도 기록한다.

| 구간 | 역할 |
|---|---|
| Astro 글 상세 | 조회수 요청·표시 |
| Worker | 요청 검증·시트 동기화 |
| Durable Object | 글별 카운터·방문 시각 저장 |
| Google Sheets | 운영용 누적 조회수 목록 |
Durable Object는 객체별로 저장소를 함께 둘 수 있다. 그래서 post:글-slug를 객체 이름으로 사용했다. 여러 사람이 동시에 같은 글을 열어도 해당 글의 카운터를 한곳에서 처리할 수 있다.
새로고침 중복은 2시간 동안 막았다
조회수를 매번 +1 하면 새로고침 몇 번으로 숫자가 늘어난다. 이 블로그는 같은 브라우저가 같은 글을 다시 열어도 마지막 집계 후 2시간이 지나기 전에는 올리지 않게 정했다. 실제 Worker의 핵심 조건은 아래와 같다.
if (previous?.viewed_at && now - Number(previous.viewed_at) < TWO_HOURS_MS) {
return { count, counted: false };
}
로컬 Worker에서 같은 방문자 ID로 요청하니 첫 요청은 counted: true, 즉시 재요청은 counted: false와 같은 누적 수를 반환했다. 방문자 ID는 로그인 정보가 아니라 localStorage에 만든 UUID다. 정확한 사용자 추적이 아니라, 가볍게 중복을 줄이는 기준으로 본다.
시트는 카운터가 아니라 운영 기록으로
처음에는 시트를 카운터 원본으로 쓰려 했다. 하지만 모든 방문 때 시트를 읽고 수정하면 응답 속도와 동시 요청 처리가 부담이 된다. 그래서 실시간 판정은 Durable Object가 맡고, 시트에는 slug·조회수·갱신 시각만 남겼다. 서비스 계정 JSON과 시트 ID는 Worker Secret으로 관리했다. 민감값을 프런트 환경 변수나 코드에 넣지 않는 것이 중요하다.

운영자 실전 노트
- 새 글은 파일명으로 만들어지는 slug가 자동 연결된다. 조회수용 frontmatter나 시트 행을 따로 만들 필요가 없다.
- 홈·카테고리·검색 결과에서 카드만 보인 경우는 집계하지 않고, 글 상세에서만 요청한다.
PUBLIC_VIEW_API_URL이 실제 Worker 주소여야 숫자가 표시된다. 비어 있으면 화면은 조용히 생략된다.- Worker 변경이 있을 때만
npm run deploy:all로 함께 배포하고, 글만 추가한 날은 블로그만 배포하면 된다.
정적 사이트도 필요한 기능을 작은 API로 분리하면 운영 부담을 크게 늘리지 않고 데이터를 남길 수 있다. 이제는 어떤 글이 실제 유입을 만드는지, 내 시트에서 직접 확인할 수 있게 됐다.
FAQ
Q. 같은 사람이 다른 브라우저에서 보면 조회수가 오르나요?
오른다. 현재 방식은 브라우저별 임의 방문자 ID를 기준으로 하므로 기기나 브라우저가 다르면 별도 방문으로 본다.
Q. Google Sheets만으로 카운터를 처리하면 안 되나요?
가능하지만 동시 요청과 응답 속도를 다루기 불리하다. 카운트 판정은 Durable Object에 두고, 시트는 확인용 기록으로 나누는 편이 단순하다.