정적 블로그에 댓글과 조회수를 붙였습니다
블로그 본체는 정적 그대로 두고, 맥미니에 작은 서버 세 개를 붙여 댓글·조회수·방문자 분석을 달았습니다. 고른 이유와 삽질한 기록.
목차
한 줄 요약: 정적 블로그는 그대로 두고, 댓글·조회수·방문자 분석만 맥미니의 작은 컨테이너 세 개로 붙였습니다.
블로그를 공개하고 나서 제일 먼저 궁금했던 건 댓글이 아니라 “몇 명이 왔나”였습니다. 댓글은 없어도 된다고 생각했는데, 막상 글을 올리고 나니 틀린 얘기를 했으면 어쩌나 하는 마음이 생겼습니다. 그래서 둘 다 붙였습니다.
처음부터 정한 조건은 세 가지였습니다. 댓글은 로그인 없이 익명으로, 방문자 데이터는 맥미니 안에, 쿠키 동의 배너는 없이. 여기에 하나 더, 16GB 맥미니에서 다른 홈서버 서비스와 같이 돌아가야 했습니다.
이 블로그가 도는 기계
이 블로그와 댓글·분석 서버가 도는 기계는 M4 Mac mini 기본형입니다.
| 항목 | 사양 |
|---|---|
| CPU | M4 10코어 |
| GPU | M4 10코어 |
| 메모리 | 16GB 통합 메모리 |
| 역할 | 블로그·홈서버·작은 모델 테스트 |
원래 이 맥미니는 24시간 켜두는 서버입니다. 블로그와 홈서버, 작은 모델 테스트를 담당합니다. 통합 메모리라 GPU와 시스템이 같은 16GB를 나눠 쓰고, macOS가 GPU에 내주는 건 10~11GB 정도입니다. 그래서 여기에 올리는 서비스는 “메모리를 많이 먹지 않는 것”이 조건이었습니다. 세 컨테이너를 합쳐 70MB 남짓이니, 이 조건은 넉넉히 통과한 셈입니다.
구조
컨테이너가 세 개 늘었습니다. 블로그 본체는 그대로 정적이고, 옆에 댓글 서버와 분석 서버가 붙은 형태입니다.
| 역할 | 도구 | 포트 | 메모리(대략) |
|---|---|---|---|
| 블로그 | Next.js 정적 빌드 + nginx | 8090 | 10MB |
| 댓글·글별 조회수 | Artalk | 8360 | 24MB |
| 방문자 분석 | GoatCounter | 8370 | 38MB |
방문자
├─ blog.localpixel.dev → Cloudflare Tunnel → nginx (정적 out/)
├─ comments.localpixel.dev → Cloudflare Tunnel → Artalk
└─ stats.localpixel.dev → Cloudflare Tunnel → GoatCounter
블로그 컨테이너가 죽어도 댓글 데이터는 별도 SQLite에 남고, 댓글 컨테이너를 내려도 블로그는 그대로 뜹니다. 서비스를 하나씩 내려보면서 “이건 없어도 블로그는 된다”를 확인할 수 있는 구조가 마음에 들었습니다.
Cloudflare Tunnel
포트 포워딩은 하고 싶지 않았습니다. 공유기 설정을 열고, 집 IP가 바뀔 때마다 손보는 일이 싫었습니다. Tailscale Funnel도 이미 쓰고 있지만, 블로그는 공개 웹이라 Cloudflare 쪽이 다루기 편했습니다.
cloudflared가 맥미니에서 Cloudflare로 밖으로 연결을 맺고, Cloudflare가 그 연결로 요청을 넘깁니다. 공유기 포트를 열 필요가 없고, DNS·TLS·프록시를 Cloudflare가 맡습니다. 무료 플랜으로 충분했습니다.
# ~/.cloudflared/config.yml (요약)
ingress:
- hostname: blog.localpixel.dev
service: http://127.0.0.1:8090
- hostname: comments.localpixel.dev
service: http://127.0.0.1:8360
- hostname: stats.localpixel.dev
service: http://127.0.0.1:8370
- service: http_status:404
launchd에 올려서 로그인할 때 자동으로 시작하게 했습니다. 재부팅 후에 터널이 안 올라오면 블로그가 502가 되니까, 이건 꼭 자동화하는 게 좋습니다.
댓글: Artalk
Artalk는 Go로 만든 댓글 서버입니다. SQLite를 쓰고, 한국어 UI가 있고, 무엇보다 익명 댓글이 됩니다. 글별 조회수(PV)도 같이 세줘서, 글 상단의 VIEWS는 Artalk 값을 씁니다.
설정에서 신경 쓴 건 익명 댓글이었습니다. auth.anonymous: true를 켜고, 일반 방문자 이메일 로그인은 껐습니다. 댓글은 바로 공개하고, 3회 이후부터 이미지 캡차를 붙였습니다. 이미지 업로드는 열지 않았습니다. 스팸이 오면 그때 조정하면 되니까요.
닉네임은 서버가 아니라 브라우저가 만듭니다. 처음 오면 춤추는 병아리 같은 이름을 뽑아 localStorage에 넣고, Artalk이 읽는 ArtalkUser에도 같은 값을 채웁니다. “다시 뽑기”를 누르면 새 이름으로 바뀌고, 이미 열린 댓글 폼의 닉네임도 같이 갱신합니다. 브라우저 데이터를 지우면 새 이름이 되고요.
이메일은 [email protected] 같은 가짜 주소를 씁니다. 알림을 보낼 생각이 없어서 진짜 주소를 받을 이유가 없었습니다. Artalk이 사용자를 구분하는 키로만 쓰고 있습니다.
분석: GoatCounter
GoatCounter는 처음 써봤습니다. 화려하진 않지만, 제가 원하는 건 “어느 글이 읽혔나” 정도였습니다. 쿠키를 쓰지 않고, 방문자·페이지·유입 경로·국가·브라우저·화면 크기를 보여줍니다.
스크립트는 공개 도메인으로 빌드했을 때만 넣습니다.
{isPublicBuild && (
<script
async
data-goatcounter="https://stats.localpixel.dev/count"
src="https://stats.localpixel.dev/count.js"
/>
)}
로컬 미리보기(localhost)는 GoatCounter가 알아서 세지 않습니다. 덕분에 개발 중에 페이지를 새로고침해도 통계가 오염되지 않습니다.
Cloudflare Web Analytics도 후보였습니다. 설정은 더 간단하지만, 글별 조회수를 글 안에 직접 넣기가 애매했습니다. 그래서 방문자는 GoatCounter, 글별 조회수는 Artalk로 역할을 나눴습니다.
삽질한 것
1. Artalk이 계속 unhealthy
Artalk을 올리고 healthcheck가 계속 빨간불이었습니다. 루트(/)로 걸었는데, Artalk 루트가 /sidebar/로 302합니다. busybox wget이 그 리다이렉트를 따라가다 bad address '.'로 죽더군요. healthcheck를 /api/v2/version으로 바꾸니까 바로 초록불이 됐습니다. 별거 아닌데 처음엔 서버가 죽은 줄 알았습니다.
2. 관리자 토큰이 즉시 만료
관리자 로그인을 하자마자 403이 났습니다. 토큰을 디코드해보니 exp와 iat가 같았습니다. 커스텀 artalk.yml에 login_timeout을 안 넣어서 세션이 즉시 만료된 거였습니다. login_timeout: 259200(3일)을 넣으니 해결됐습니다. 설정 파일을 직접 쓰면 기본값이 자동으로 채워지지 않는다는 걸 이때 알았습니다.
3. nginx 리다이렉트 루프
localpixel.dev와 www를 블로그로 보내려고 redirect server block을 맨 위에 뒀습니다. 그런데 nginx는 첫 번째 server block을 default server로 잡습니다. blog.localpixel.dev 요청이 redirect block으로 들어가서 자기 자신으로 301하는 루프가 났습니다. 블로그 블록을 먼저 두고 server_name blog.localpixel.dev;를 명시하니 끝났습니다.
server {
listen 80;
server_name blog.localpixel.dev;
# ...
}
server {
listen 80;
server_name localpixel.dev www.localpixel.dev;
return 301 https://blog.localpixel.dev$request_uri;
}
4. GoatCounter 백업
DB 파일만 복사했더니 4KB짜리 빈 파일이었습니다. 데이터는 -wal에 있었습니다. GoatCounter는 SQLite를 WAL 모드로 쓰기 때문에 db.sqlite3만 복사하면 최근 데이터가 빠집니다. db.sqlite3-wal, db.sqlite3-shm까지 같이 복사하거나, 컨테이너를 잠깐 멈추고 checkpoint를 해야 합니다.
5. 로컬 DNS만 안 됨
localpixel.dev CNAME을 새로 만들었는데, 맥에서만 apex가 안 열렸습니다. 1.1.1.1에서는 되는데, 라우터/Tailscale MagicDNS가 이전의 “없음” 응답을 캐시하고 있었습니다. 서버 문제가 아니라 로컬 리졸버 문제였습니다. 캐시를 비우니 해결됐습니다.
6. HTTP가 그대로 열려 있었음
Cloudflare의 Always Use HTTPS가 꺼져 있어서 http://로도 200이 났습니다. 켜고 나서는 HTTP가 HTTPS로 먼저 이동하고, 그다음 nginx가 apex/www를 블로그로 보냅니다.
지금 상태
https://blog.localpixel.dev/200https://localpixel.dev/,https://www.localpixel.dev/→https://blog.localpixel.dev/301- 댓글: 공개 테스트로 작성→삭제까지 확인. 지금 0개.
- 조회수·방문: 테스트 기록을 지워서 0에서 시작.
- 라이트/다크, 390px/768px/1440px에서 가로 넘침 없음.
- 메모리: 블로그 10MB, Artalk 24MB, GoatCounter 38MB. 16GB 맥미니에서 70MB 남짓.
관리자 화면은 comments.localpixel.dev/sidebar/(Artalk)와 stats.localpixel.dev/(GoatCounter)에 있습니다. 비밀번호는 블로그 글에 쓰지 않고 따로 보관합니다.
닫으며
정적 블로그의 장점은 그대로입니다. 빌드 결과만 있으면 되고, 본문은 가볍습니다. 댓글·분석은 옆의 작은 서비스로 분리해서, 필요 없으면 컨테이너만 내리면 됩니다. 데이터는 집에 있고, 백업은 파일 복사입니다. 이 정도면 블로그 하나에 붙일 만한 선에서 멈춘 것 같습니다.
다음에는 로컬 LLM 실험 글로 돌아오겠습니다.
— 로컬픽셀공방
COMMENTS
공방에 남기는 말
로그인 없이 익명으로 남길 수 있습니다. 아래 닉네임은 이 브라우저에만 저장되고, 다른 사람에게는 보이지 않습니다.