누적/오늘 방문자 수 기능 추가하기
누적/오늘 방문자 수 기능을 supabase로 한 이유
NOTE
첫 번째 추가 기능 누적,오늘의 방문자 수 기능을 추가했습니다. 누적/오늘 방문자 수를 붙이는 과정에서 한 고민과 삽질을 정리한 글입니다.
블로그를 만들고 도메인을 붙이고 이에 관련돼서 cloudflare를 선택했는지에 대해서 쓰려고 했는데 얼마나 들어오는지 궁금하더라구요. 하루에 한명도 안들어오게 되면 속상할 것 같고. 흑흑. 근데 슬슬 궁금해지더라고요. "이거 진짜 누가 보긴 하나?" 그래서 그냥 바로 붙이기로 했습니다. 히히.
결론먼저 말씀드리자면 저는 supabase를 선택했습니다. 왜 supabase인지에 대해서 설명하기 전에 제가 쓰려고 고민했던 툴들을 정리했습니다.
1. 후보 — Hits, Upstash, Supabase
처음에 떠올린 건 세 개였습니다.
| 후보 | 방식 | 장점 | 걸리는 점 |
|---|---|---|---|
| Hits (seeyoufarm) | 외부 배지 이미지 | 설정 제로, DB 불필요 | 디자인 통일감, 외부 의존 |
| Upstash Redis | 서버리스 KV | 무료 티어에 pause 없음, INCR 한 줄 | 확장성 약함 |
| Supabase | Postgres + Auth + RLS | 댓글/로그인까지 한 큐에 | 트래픽 0이면 7일 후 pause |
처음에는 그냥 Hits로 할려고 했습니다. <img> 태그 하나 박으면 끝나는, 진짜 5분 컷짜리 솔루션이라고 해서요. 컄컄 근데 배지 스타일이 지금 블로그 스타일과 맞지도 않고 추후에
여러 api를 연결하는 걸 고려했을 때 굳이 쓸 필요가 없다고 생각했습니다. (오히려 나중에 다른걸로 바꾸게 되면 누적 방문자수도 사라지고 뜯어 고쳐야할 것 같아서..)
다음으로 본 게 Upstash Redis였습니다. 카운터라는 용도만 보면 사실 이쪽이 정답에 더 가깝습니다. INCR today:2026-04-27 한 줄로 끝나고, 무료 티어에 pause도 없고, 인메모리라 빠르고. 단순 카운터 수준에선 거의 완벽합니다.
마지막이 Supabase. Postgres라는 점, Auth/Storage가 같이 들어있는 점이 매력이었습니다.
2. 왜 Supabase로 선택했을까
기준은 "확장성" 이었습니다. 지금은 카운터 한 줄이 필요한 거지만, 나중에 댓글, 로그인, 글별 조회수 같은 게 붙을 거란 게 거의 확실?했거든요.(애정을 갖고 꾸준하게 글과 기능들을 추가하지 않을까하는 마음에)
그렇게 보니 Upstash로 시작하면 언젠가 Supabase를 어차피 한 번 더 들이게 되는 그림이었습니다. 단순 카운터엔 Redis가 더 어울리지만, 댓글 데이터를 Redis에 넣을 건 아니니까요. 결국 두 개를 동시에 관리하는 상황이 되는 거죠.
그래서 처음부터 Postgres 한 곳으로 모으는 쪽을 골랐습니다.
다른 이유 하나는 Next.js + Vercel + Supabase 조합이 사실상 표준 레퍼런스라는 점이었습니다. SDK도 잘 되어 있고, 환경변수만 잘 꽂으면 끝이라 헤맬 일이 적었습니다.
Supabase의 함정 — Free Tier pause
근데 Free Tier에 한 가지 함정이 있습니다.
7일간 DB 쿼리가 0건이면 프로젝트가 자동 pause된다.
처음 들었을 땐 좀 식겁했는데, 다시 잘 보니 하루에 한 명만 들어와도 카운터 API가 호출되어 쿼리가 발생 하니까 7일 내내 진짜 0이 아닌 이상은 안 잠듭니다.
근데 이게 동기부여까지는 아니고 뭐 쓰진 않더라도 나라도 3일에 한번씩만 들어가면 중지가 되지 않으니 큰 무리는 없었습니다.
3. 스키마
스토리지가 정해졌으니 다음은 모델링입니다. 일단 이번 단계에서 필요한 건 두 가지입니다.
- 누적 방문자 수
- 오늘 방문자 수
그래서 처음엔 total이랑 today 두 칼럼만 가진 한 행짜리 테이블을 떠올렸는데, 자정이 지나면 today를 0으로 리셋해야 하는 별도 로직이 필요해집니다.
크론을 따로 돌리든, 읽을 때마다 날짜 비교하든 어느 쪽도 깔끔하지 않더라고요.
그래서 날짜를 PK로 쓰는 일별 카운트 테이블로 갔습니다.
create table public.visits (
date date primary key,
count integer not null default 0
);이렇게 하면 자정이 지나서 새 날짜로 INSERT가 일어나는 순간 자동으로 "오늘"이 새로 시작됩니다. 리셋이 필요 없습니다. 누적은 그냥 SUM(count)이고요.
4. 증감/조회는 RPC로
이걸 SQL 함수(=Postgres function)로 감싸면 두 가지가 깔끔해집니다.
- 클라이언트(서버 코드)는
rpc("increment_visit")한 줄만 부르면 됨 - upsert + 증감을 원자적으로 처리할 수 있음 — 같은 시각에 들어와도 카운트가 누락되지 않음
create or replace function public.increment_visit()
returns void
language plpgsql
security definer
as $$
begin
insert into public.visits (date, count)
values (current_date, 1)
on conflict (date)
do update set count = public.visits.count + 1;
end;
$$;조회용도 별도 함수로 빼뒀습니다.
create or replace function public.get_visit_stats()
returns table (today_count integer, total_count bigint)
language sql
stable
as $$
select
coalesce((select count from public.visits where date = current_date), 0) as today_count,
coalesce((select sum(count) from public.visits), 0) as total_count;
$$;stable로 표시해두면 같은 트랜잭션 안에서 결과가 안 바뀐다는 힌트를 옵티마이저에 줄 수 있습니다.
5. Next.js 쪽 — 서버에서만, 그리고 best-effort로
Supabase 클라이언트는 반드시 서버에서만 쓰도록 묶어뒀습니다. service_role 키는 RLS를 우회하는 관리자 권한이라 클라이언트에 노출되면 진짜 가버리거든요. 주의!
그래서 환경변수 이름에 일부러 NEXT_PUBLIC_ 접두사를 붙이면 안됩니다. Next.js는 NEXT_PUBLIC_로 시작하는 변수만 빌드 타임에 클라이언트 번들로 인라인해주는데,
접두사 없이 두면 서버 런타임에서만 읽힙니다. service_role 키가 실수로라도 브라우저에 흘러가면 그 자체로 사고라서, 처음부터 새어나갈 통로를 막아두는 거라고 생각하면 됩니다.
// src/shared/lib/supabase.ts
import "server-only";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
let cached: SupabaseClient | null = null;
export function getSupabaseAdmin(): SupabaseClient {
if (cached) return cached;
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) {
throw new Error("SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY is not set");
}
cached = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
return cached;
}import "server-only" 한 줄이 핵심입니다. 실수로 클라이언트 컴포넌트에서 import하면 빌드 타임에 빵 터집니다. 한 번 막아두면 나중에 누가 만져도 안전해집니다.
증감은 API Route로 받습니다.
// src/app/api/visit/route.ts
import { incrementVisit } from "@/entities/stats";
const BOT_PATTERN =
/bot|crawler|spider|crawling|preview|fetch|monitor|headless|lighthouse|pingdom|slurp|baiduspider/i;
export async function POST(request: Request) {
if (process.env.NODE_ENV !== "production") {
return new Response(null, { status: 204 });
}
const ua = request.headers.get("user-agent") ?? "";
if (!ua || BOT_PATTERN.test(ua)) {
return new Response(null, { status: 204 });
}
try {
await incrementVisit();
} catch {
// 카운트는 best-effort. 실패해도 사용자에겐 알리지 않음
}
return new Response(null, { status: 204 });
}세 가지 작은 결정이 들어가 있습니다.
- 로컬에선 카운트 안 함. 개발 중에 새로고침할 때마다 숫자가 올라가면 노이즈가 됩니다.
- 봇은 거른다. 정확한 필터는 아니지만 흔한 크롤러는 잡힙니다.
- 실패는 삼킨다. 카운터 따위가 안 올라간다고 사용자에게 에러를 보여줄 이유가 없습니다.
조회는 RSC에서 직접 호출하되, unstable_cache로 1분 묶어뒀습니다.
// src/entities/stats/api/get-visit-stats.ts
import "server-only";
import { unstable_cache } from "next/cache";
import { getSupabaseAdmin } from "@/shared/lib/supabase";
async function fetchVisitStats() {
const supabase = getSupabaseAdmin();
const { data, error } = await supabase.rpc("get_visit_stats");
if (error) throw error;
const row = Array.isArray(data) ? data[0] : data;
return {
today: Number(row?.today_count ?? 0),
total: Number(row?.total_count ?? 0),
};
}
export const getVisitStats = unstable_cache(fetchVisitStats, ["visit-stats"], {
revalidate: 60,
tags: ["visit-stats"],
});방문자 카운터는 1분 정도 늦어도 아무도 모릅니다. 매 요청마다 DB를 때릴 이유가 없죠.
6. 클라이언트에서 한 번만 — sessionStorage 가드
방문 이벤트는 클라이언트에서 페이지 로드 시점에 한 번 쏩니다. 다만 같은 세션 안에서 여러 페이지를 돌아다닐 때마다 카운트되면 안 되니까 sessionStorage로 막았습니다.
"use client";
import { useEffect } from "react";
const SESSION_KEY = "visit-tracked";
export function VisitTracker() {
useEffect(() => {
if (sessionStorage.getItem(SESSION_KEY)) return;
const fire = () => {
void fetch("/api/visit", { method: "POST", keepalive: true })
.then(() => sessionStorage.setItem(SESSION_KEY, "1"))
.catch(() => {});
};
const w = window as Window & {
requestIdleCallback?: (cb: () => void) => number;
cancelIdleCallback?: (id: number) => void;
};
if (typeof w.requestIdleCallback === "function") {
const id = w.requestIdleCallback(fire);
return () => w.cancelIdleCallback?.(id);
}
const id = window.setTimeout(fire, 1500);
return () => window.clearTimeout(id);
}, []);
return null;
}requestIdleCallback을 끼워 넣은 이유는 첫 페이지 로드의 LCP를 방해하지 않기 위해서 입니다. 카운터 POST는 화면이 이미 그려진 다음에 한가할 때 보내면 됩니다.
fetch에 keepalive: true를 붙인 건, 사용자가 카운트 요청 직후에 다른 페이지로 곧장 넘어가도 요청이 살아있게 하기 위해서고요.
sessionStorage는 탭을 닫으면 날아가니까, 다음에 다시 들어오면 새 세션으로 잡힙니다. unique 카운트는 아니지만 "새로고침 스팸 방지" 목적으론 충분합니다.
7. 표시는 푸터에
표시 위치는 결국 푸터로 정할 수밖에 없었습니다. 처음 블로그 만들 때 했던 고민이 여기서 또 튀어나왔습니다.
사실은 사이트에 들어오자마자 누적 방문자 수가 바로 보이는 게 더 좋다고 생각했습니다. 근데 애초에 메인 페이지를
그런 구조로 안 짜놔서 자연스럽게 넣을 자리가 없더라구요. 그렇다고 /를 갑자기 프로필 페이지처럼 뜯어고치고 싶진 않았고요.
그래서 일단은 푸터에 두기로 했습니다. 나중에 게시물이 좀 더 쌓이고 방문자도 늘면, 그때는 메인 위쪽에 프로필 + 누적 방문자 수 + 인기 글 섹션을 새로 만들고 카운터를 그 아래로 옮길까 — 정도로 생각만 해두고 있습니다.
<p className="tabular-nums">
Today {stats.today.toLocaleString()} · Total {stats.total.toLocaleString()}
</p>tabular-nums는 숫자 너비를 고정시키는 Tailwind 유틸인데, 카운터가 1자리 → 2자리로 바뀔 때 글자가 들썩이는 걸 막아줍니다. 작은 디테일이지만 안 쓰면 눈에 거슬립니다.
8. 그리고 — Vercel에서 0명 (삽질)
코드는 다 됐고 로컬에선 잘 돌았습니다. Push하고 Vercel에 배포된 걸 확인했는데 아무것도 표시가 안 됐습니다.
원인은 단순했습니다. Vercel에 환경변수를 안 넣은 것. .env.local은 git에 안 올라가니 Vercel이 알 방법이 없습니다. 그런데도 계속 supabase랑 제 코드만 들여다봤습니다.
정리하면 — 환경변수는 Supabase가 아니라, Supabase에 접속하는 쪽이 들고 있어야 하는 값입니다.
- Supabase: DB + API 서버 (창고)
- Vercel: Next.js 앱이 실제로 돌아가는 서버 (손님)
SUPABASE_URL= 창고 주소,SUPABASE_SERVICE_ROLE_KEY= 창고 열쇠
supabase.rpc("increment_visit")를 부르는 주체는 Next.js 앱이고, 손님이 창고에 가려면 주소와 열쇠가 손에 있어야 합니다. Supabase 본인은 누가 접속할지 모릅니다.
| 어디서 Next.js가 돌아가나 | 환경변수가 어디에 있어야 하나 |
|---|---|
내 노트북 (pnpm dev) | .env.local |
| Vercel 프로덕션 | Vercel → Environment Variables |
if (!url || !key) {
throw new Error("SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY is not set");
}이 라인에서 매번 throw가 났고, API Route는 try / catch {}로 조용히 삼키게 해뒀습니다. 결과적으로 사용자에게도, 저에게도 안 보이는 채로 카운터만 안 올라가는 상태가 됐습니다.
best-effort 정책이 오히려 디버깅을 하는데 방해가 됐습니다.
해결은 5분.
- Vercel 대시보드 → Settings → Environment Variables
SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY추가- Production / Preview / Development 중 필요한 환경 체크
- Deployments 탭에서 최근 빌드 Redeploy
교훈 하나 — try/catch {} 안에 로그 한 줄이라도 남기자.
Comments