폰 시절 조회수는 JSON 파일 하나, 방문 기록은 또 다른 JSON 파일, 댓글은 또 다른 파일이었다. Node 서버가 메모리에서 30분 중복 방지를 하고 주기적으로 파일에 썼다. 클라우드로 오면서 이 셋을 D1 하나로 모았다. D1은 Cloudflare의 서버리스 SQLite다. 이 글은 그 스키마와, 옮기면서 내린 결정들이다.

표 일곱 개

CREATE TABLE IF NOT EXISTS meta         (k TEXT PRIMARY KEY, v TEXT);
CREATE TABLE IF NOT EXISTS views        (slug TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS views_daily  (day TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS recent_views (k TEXT PRIMARY KEY, ts INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS visitors     (id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL,
                                         ip TEXT, slug TEXT, country TEXT, city TEXT, lat REAL, lon REAL);
CREATE TABLE IF NOT EXISTS visitor_daily(day TEXT NOT NULL, ip TEXT NOT NULL, PRIMARY KEY (day, ip));
CREATE TABLE IF NOT EXISTS comments     (id TEXT PRIMARY KEY, slug TEXT NOT NULL, name TEXT NOT NULL,
                                         content TEXT NOT NULL, password_hash TEXT NOT NULL, ip TEXT,
                                         created_at INTEGER NOT NULL, edited_at INTEGER);

마이그레이션 도구는 쓰지 않았다. 첫 요청이 들어올 때 CREATE TABLE IF NOT EXISTS 묶음을 한 번 실행하고, 그 결과를 모듈 변수에 캐시해 같은 인스턴스에서는 다시 돌지 않게 했다. 표가 몇 개 안 되고 변경도 드물어서 이 정도가 알맞았다. 나중에 열을 추가할 일이 생겼을 때는 PRAGMA table_info로 열이 없을 때만 ALTER TABLE을 하는 작은 마이그레이션 목록을 보탰다.

시드는 딱 한 번

조회수 합계는 폰 시절 숫자를 이어받아야 했다. git에 있던 조회수 JSON을 빌드 때 _view-seed.json으로 복사해 assets에 넣고, Worker가 처음 준비될 때 meta에 seeded 표시가 없으면 그 파일로 views를 채우고 표시를 남긴다. 두 번째부터는 건드리지 않는다. 이 시드 파일은 외부 요청에 404로 답하는 내부 파일 목록에 들어 있다.

30분 중복 제거를 D1로

폰에서는 “같은 IP가 같은 글을 30분 안에 다시 열면 안 센다"를 메모리 Map으로 했다. Worker는 인스턴스가 여러 개고 수명도 짧아서 메모리를 믿을 수 없다. 그래서 recent_views에 IP:slug 키와 마지막으로 센 시각을 넣고, 센 지 30분이 지난 것만 다시 센다. 오래된 행은 요청 처리 뒤 waitUntil로 지운다. 방문 기록(visitors)은 중복 제거와 무관하게 모든 접속을 남기되 최근 3000건만 유지한다. 이 “전부 남긴다"는 결정이 나중에 과거 집계를 다시 계산할 수 있게 해 준 열쇠가 됐다.

위치 정보는 Cloudflare가 준다

폰에서는 외부 지오로케이션 API를 불렀다. Worker에서는 request.cf에 국가, 도시, 위도, 경도가 이미 들어 있다. 외부 호출 하나가 사라졌고, 관리 통계의 지도는 그대로 그려진다.

옛 IP 로그는 옮기지 않았다

폰에는 424건의 방문 기록이 있었다. IP가 들어 있는 파일이라 git에는 처음부터 올리지 않은 데이터다. 이걸 D1로 옮길지 결정해야 했는데, 옮기지 않기로 했다. 통계상 가치는 작고, 개인정보가 든 파일을 또 한 곳으로 복사하는 비용이 더 컸다. 조회수 합계만 이어받고 방문 기록은 새로 시작했다. 댓글은 양쪽 다 0개라 옮길 것이 없었다.

공개 API에는 IP가 없다

댓글 공개 API는 응답에서 IP를 빼고, 관리자용 조회만 IP를 포함한다. 관리자 여부는 요청 헤더의 토큰을 Worker 시크릿과 상수 시간 비교로 확인한다. 댓글 비밀번호는 PBKDF2(10만 회)로 해시해 저장한다. 폰 시절부터 지켜 온 “IP는 저장하되 공개하지 않는다"는 원칙을 그대로 가져왔다.

용량 생각

Workers 무료 플랜은 하루 10만 요청이고, run_worker_first라 모든 요청이 Worker를 거친다. 당시 하루 조회수가 수십 건이었으니 여유는 충분했지만, 방문 기록 3000건 상한처럼 무한히 자라는 표는 두지 않는 쪽으로 설계했다. 나중에 추가된 문의 표도 5000건 상한을 뒀다. 작은 블로그의 데이터는 “잘라도 되는 것"과 “잘라선 안 되는 것"을 처음부터 나눠 두면 운영이 편하다.