블로그 만들기 04 - 방문횟수, 인기글 조회하기

2025-08-28

#블로그#Nextjs#Supabase#방문자통계#인기글

🧠 요약

  • 방문자 수 추적: localStorage를 활용하여 페이지 당 하루 1회만 방문을 기록하도록 제한

  • 인기글 집계: Supabase의 page_views 테이블을 기준으로 인기글 상위 5개를 실시간 집계

  • 서버 없는 구현: 서버 없이 Supabase + 클라이언트 코드만으로 구현


📌 본문

1. Supabase 세팅

  • supabase에 접속하여 회원가입 및 자신의 Organization 생성

  • Organization에서 새로운 프로젝트 생성

    스크린샷 2025-08-28 12.31.59.png
스크린샷 2025-08-28 12.39.59.png

대시보드를 살짝 내리면 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 에서 설정하면 알아서 세팅된다.

스크린샷 2025-08-28 12.43.38.png
  • 사이드바에서 Table Editor -> Create a table -> 원하는 테이블 생성 스크린샷 2025-08-28 12.33.50.png
스크린샷 2025-08-28 12.35.07.png

나는 블로그 방문 기록과, 인기 게시글을 조회하고 싶어서 테이블을 2개 생성했다.

스크린샷 2025-08-28 12.35.51.png

1 ~ 2분정도 기다리면 테이블 생성 완료

2. RLS 설정

RLS(Row Level Security) 란, 데이터베이스에서 각 사용자의 요청에 따라 접근 가능한 행을 제어하는 보안 기능이다. PostgreSQL 기반인 Supabase는 이 기능을 PostgreSQL 보안 정책 기능 위에 얹어 사용자 친화적으로 제공한다.

설정하는 이유

Supabase는 API 키만으로도 데이터베이스에 직접 접근할 수 있기 때문에, 모든 사용자가 DB를 조회하거나 삽입할 수 있는 상태가 되어버리면 위험하다.

이를 막기 위해 Supabase는 기본적으로 모든 테이블에 대해 RLS를 활성화하고,

  • 어떤 사용자는 읽기만 가능
  • 어떤 사용자만 쓰기 가능
  • 특정 조건에 맞는 행만 조회 가능

이런 식으로 세밀한 보안 정책을 설정할 수 있다.

설정 방법

  • Table Editor -> RLS policies -> Create policy
스크린샷 2025-08-28 12.48.19.png SELECT와 INSERT 두개를 이런 형식으로 만들어주면 된다. USE OPTIONS ABOVE TO EDIT의 7번째 행에 true 추가 해줘야함 스크린샷 2025-08-28 12.47.11.png 각 테이블 별로 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 테이블에 각각 전체, 어제, 오늘 방문자 수를 조회하는 요청을 보낸다.

스크린샷 2025-08-28 13.18.10.png 조회가 잘 된다.

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;
스크린샷 2025-08-28 13.32.44.png 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;
}
스크린샷 2025-08-28 13.36.17.png 인기글 조회 완료

🔄 회고 및 인사이트

  • 📊 Supabase로 충분하다: 서버 없이 통계 기능을 만들 수 있다는 점에서 Supabase의 강력함을 다시 체감

  • 💡 IP 대신 localStorage: 개인정보 이슈를 우회하면서도 하루 1회 제한을 구현하기 위한 좋은 방법

  • 🌍 UTC → KST 보정 필수: Supabase는 UTC로 저장되므로, 한국 시간 기준으로 처리할 땐 -9시간 보정을 반드시 해줘야 함

  • 📈 인기글도 실시간 가능: 누적된 데이터를 정렬하면 실시간 인기글 기능도 어렵지 않게 구현 가능