블로그 만들기 04 - 방문횟수, 인기글 조회하기
2025-08-28
🧠 요약
-
방문자 수 추적: localStorage를 활용하여 페이지 당 하루 1회만 방문을 기록하도록 제한
-
인기글 집계: Supabase의
page_views테이블을 기준으로 인기글 상위 5개를 실시간 집계 -
서버 없는 구현: 서버 없이 Supabase + 클라이언트 코드만으로 구현
📌 본문
1. Supabase 세팅
-
supabase에 접속하여 회원가입 및 자신의 Organization 생성
-
Organization에서 새로운 프로젝트 생성
대시보드를 살짝 내리면 API URL과 키를 제공한다.
로컬에서는 .env 파일에 이 값들을 저장하고, 배포 시에는 배포 환경에 직접 환경변수를 세팅한다.
# .env NEXT_PUBLIC_SITE_URL=http://localhost:3001 NEXT_PUBLIC_SUPABASE_URL=https://yoursupabaseurl.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=ey...
vercel의 경우, Settings -> Environment Variables 에서 설정하면 알아서 세팅된다.
-
사이드바에서 Table Editor -> Create a table -> 원하는 테이블 생성
나는 블로그 방문 기록과, 인기 게시글을 조회하고 싶어서 테이블을 2개 생성했다.
1 ~ 2분정도 기다리면 테이블 생성 완료
2. RLS 설정
RLS(Row Level Security) 란, 데이터베이스에서 각 사용자의 요청에 따라 접근 가능한 행을 제어하는 보안 기능이다. PostgreSQL 기반인 Supabase는 이 기능을 PostgreSQL 보안 정책 기능 위에 얹어 사용자 친화적으로 제공한다.
설정하는 이유
Supabase는 API 키만으로도 데이터베이스에 직접 접근할 수 있기 때문에, 모든 사용자가 DB를 조회하거나 삽입할 수 있는 상태가 되어버리면 위험하다.
이를 막기 위해 Supabase는 기본적으로 모든 테이블에 대해 RLS를 활성화하고,
- 어떤 사용자는 읽기만 가능
- 어떤 사용자만 쓰기 가능
- 특정 조건에 맞는 행만 조회 가능
이런 식으로 세밀한 보안 정책을 설정할 수 있다.
설정 방법
- Table Editor -> RLS policies -> Create policy
SELECT와 INSERT 두개를 이런 형식으로 만들어주면 된다. USE OPTIONS ABOVE TO EDIT의 7번째 행에 true 추가 해줘야함
각 테이블 별로 INSERT, SELECT에 대한 RLS를 설정해주자.
3. Next.js 연동
- 패키지 설치
# npm npm install --save-dev @supabase/supabase-js # pnpm pnpm add -D @supabase/supabase-js
- 클라이언트 인스턴스 생성
// app/_lib/supabase.ts import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; export const supabase = createClient(supabaseUrl, supabaseKey);
- 라우트 설정
// app/api/visit/route.ts import { supabase } from "app/_lib/supabase"; export async function POST() { const { error } = await supabase.from("visited").insert([{}]); if (error) { console.error("Supabase insert error:", error); return new Response("Insert failed", { status: 500 }); } return new Response("OK"); } // app/api/track/route.ts import { supabase } from "app/_lib/supabase"; export async function POST(req: Request) { const { path } = await req.json(); const { error } = await supabase.from("page_views").insert([ { path, }, ]); if (error) { console.error("Supabase insert error:", error); return new Response("Insert failed", { status: 500 }); } return new Response("OK"); }
4. 방문자 추적
- 방문 트래킹
"use client"; import { useEffect } from "react"; import { usePathname } from "next/navigation"; /** * 방문 기록 추적 */ const Tracker = () => { const pathname = usePathname(); // 페이지 별 방문 기록 추적 useEffect(() => { const today = new Date().toDateString(); const visitedStr = localStorage.getItem("visited"); const visited: { path: string; date: string }[] = visitedStr ? JSON.parse(visitedStr) : []; const alreadyVisitedToday = visited.some( (entry) => entry.path === pathname && entry.date === today, ); if (alreadyVisitedToday) return; fetch("/api/track", { method: "POST", body: JSON.stringify({ path: pathname }), headers: { "Content-Type": "application/json" }, }).then(() => { visited.push({ path: pathname, date: today }); localStorage.setItem("visited", JSON.stringify(visited)); }); }, [pathname]); // 일별 방문 기록 추적 useEffect(() => { const now = new Date(); const kstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000); // 현재 시간을 KST로 변환 const todayKST = kstNow.toISOString().split("T")[0]; // YYYY-MM-DD const visited = localStorage.getItem("daily_visited"); if (visited) { const dateVisited = new Date(visited); const kstVisited = new Date(dateVisited.getTime() + 9 * 60 * 60 * 1000); const visitedDateStr = kstVisited.toISOString().split("T")[0]; if (visitedDateStr === todayKST) return; // 이미 오늘 방문함 } fetch("/api/visit", { method: "POST", headers: { "Content-Type": "application/json" }, }).then(() => { localStorage.setItem("daily_visited", new Date().toISOString()); // UTC 기준 저장 }); }, []); return null; }; export default Tracker;
이렇게 Tracker를 만들어둔 뒤, RootLayout에서 provider처럼 사용하면 된다.
이 때, localStorage를 사용하여, 현재 사용자가 오늘 첫 방문인지, 게시글 방문 여부를 추적하는 캐시를 저장해두어, ip 수집과 같은 민감할 수 있는 개인정보 수집을 하지 않는 방향으로 구성했다.
이 경우, 사용자의 캐시 조작에 따라 방문 수가 달라질 수 있어, 더 정밀한 추적을 원한다면 개인정보 처리방침을 잘 세워서 ip 해시값으로 방문 여부를 추적해보는것도 괜찮을 것 같다.
5. 방문자 수 조회
이제 만들어 둔 것들을 사용할 시간이다.
"use client"; import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import { supabase } from "app/_lib/supabase"; interface VisitorCount { allday: number; today: number; yesterday: number; } const INITIAL_COUNT: VisitorCount = { allday: 0, today: 0, yesterday: 0, }; // KST 자정 기준 날짜 생성 const getKSTMidnight = (offsetDays: number) => { const now = new Date(); const kst = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + offsetDays, -9, 0, 0)); return kst.toISOString(); }; // Supabase count 쿼리 함수 const fetchCount = async (from: string, to?: string) => { let query = supabase.from("visited").select("*", { count: "exact", head: true }).gte("visited_at", from); if (to) query = query.lt("visited_at", to); const { count, error } = await query; if (error) throw error; return count || 0; }; export const useVisitorCount = (): VisitorCount => { const pathname = usePathname(); const [visitorCount, setVisitorCount] = useState<VisitorCount>(INITIAL_COUNT); useEffect(() => { const fetchCounts = async () => { try { const [all, today, yesterday] = await Promise.all([ fetchCount("1970-01-01T00:00:00.000Z"), // 전체 fetchCount(getKSTMidnight(0)), // 오늘 fetchCount(getKSTMidnight(-1), getKSTMidnight(0)), // 어제 ]); setVisitorCount({ allday: all, today, yesterday, }); } catch (err) { console.error("방문자 수 조회 실패:", err); } }; fetchCounts(); }, [pathname]); return visitorCount; };
방문자 수 조회는 visited 테이블에 각각 전체, 어제, 오늘 방문자 수를 조회하는 요청을 보낸다.
조회가 잘 된다.
6. 인기글 조회
인기글을 조회를 위해서는 SQL로 방문횟수를 카운트하여 필요한 만큼 추출하면 된다.
이 경우, Supabase SDK를 이용하여 클라이언트에서 진행할 수도 있지만, Supabase 대시보드에서 View를 생성하고, 이 View를 조회하는게 조금 더 수월하다고 생각한다.
따라서, 인기글 조회를 위해 다시 Supabase를 이용한다.
- SQL Editor에서 다음 쿼리 실행
create view popular_posts as select path, count(*) as view_count from page_views where path like '/posts/%' group by path order by view_count desc limit 5;
View 생성 확인. 여기서 Unrestricted가 뜨면, 우측 상단부에 Auto Fix 실행
그 후 클라이언트에서 조회 수행
"use client"; import { useEffect, useState } from "react"; import { supabase } from "app/_lib/supabase"; import { z } from "zod"; const PageViewSchema = z.object({ path: z.string(), view_count: z.number(), }); export function usePopularPosts() { const [posts, setPosts] = useState<string[]>([]); useEffect(() => { const fetch = async () => { const { data, error } = await supabase .from("popular_posts") .select("*"); if (!error && data) { const rows = PageViewSchema.array().safeParse(data); if (rows.success) { setPosts(rows.data.map((row) => row.path.replace("/posts/", ""))); } } }; fetch(); }, []); return posts; }
인기글 조회 완료
🔄 회고 및 인사이트
-
📊 Supabase로 충분하다: 서버 없이 통계 기능을 만들 수 있다는 점에서 Supabase의 강력함을 다시 체감
-
💡 IP 대신 localStorage: 개인정보 이슈를 우회하면서도 하루 1회 제한을 구현하기 위한 좋은 방법
-
🌍 UTC → KST 보정 필수: Supabase는 UTC로 저장되므로, 한국 시간 기준으로 처리할 땐 -9시간 보정을 반드시 해줘야 함
-
📈 인기글도 실시간 가능: 누적된 데이터를 정렬하면 실시간 인기글 기능도 어렵지 않게 구현 가능