프로젝트

방문자 수 확인 및 관리하는 방법

2026년 3월 9일

블로그 방문자 수 확인 및 관리

블로그 방문자를 추적하는 방법은 크게 두 가지로 나뉜다. DB에 직접 기록하는 방식과 세션/쿠키를 활용해 중복을 걸러내는 방식이다. 실제로는 두 방식을 조합해서 사용하는 게 일반적이다.


1. 핵심 개념 — 무엇을 기록할 것인가

지표설명
Page View (PV)페이지 로드 횟수. 새로고침도 카운트
Unique Visitor (UV)중복 제거한 고유 방문자 수
Session일정 시간 내 연속된 방문 묶음 (보통 30분 기준)
Daily Active User (DAU)하루 기준 순 방문자 수

2. 방식 비교 — DB 기록 vs 세션 기반

2-1. DB 직접 기록 방식

모든 방문 요청을 DB에 INSERT한다.

장점

  • 과거 데이터를 집계 쿼리로 자유롭게 분석 가능
  • 어느 글이 얼마나 읽혔는지 게시글 단위 추적 가능
  • 대시보드, 통계 차트 구현이 용이

단점

  • 새로고침마다 카운트 증가 → 중복 제거 로직 필요
  • 트래픽이 많으면 DB 부하 증가 → 비동기 처리 또는 Redis 큐 필요
  • 봇 트래픽 필터링이 별도로 필요

2-2. 세션/쿠키 기반 방식

방문자 브라우저에 세션 ID 또는 쿠키를 발급해서 중복 방문을 구분한다.

장점

  • 같은 사람이 여러 번 방문해도 하나로 집계 (UV 정확도 향상)
  • DB 요청 횟수를 줄일 수 있음

단점

  • 쿠키 삭제, 시크릿 모드, 다른 기기에서의 방문은 별도로 집계됨
  • 완벽한 UV 추적은 불가능 (개인정보 이슈로 fingerprinting도 한계 있음)

[!tip] 실무에서 많이 쓰는 조합

  • 세션/쿠키로 중복 필터링 + DB에 UV만 기록
  • 또는 PV는 무조건 기록 + UV는 세션으로 구분해서 별도 컬럼

3. DB 스키마 설계

3-1. 게시글별 단순 카운터 (가장 심플)

sql
-- 게시글 테이블에 컬럼 추가
ALTER TABLE posts ADD COLUMN view_count INTEGER DEFAULT 0;

-- 방문 시마다
UPDATE posts SET view_count = view_count + 1 WHERE id = :post_id;

빠르고 단순하지만 상세 분석이 불가능하다.

3-2. 방문 로그 테이블 (상세 분석용)

sql
CREATE TABLE page_views (
    id          BIGSERIAL PRIMARY KEY,
    post_id     INTEGER REFERENCES posts(id) ON DELETE CASCADE,
    visitor_id  VARCHAR(64),      -- 세션 또는 쿠키 ID (해시값)
    ip_hash     VARCHAR(64),      -- IP 해시 (원본 IP 저장 X, 개인정보)
    user_agent  TEXT,
    referer     TEXT,
    visited_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 인덱스 (조회 성능)
CREATE INDEX idx_page_views_post_id ON page_views(post_id);
CREATE INDEX idx_page_views_visited_at ON page_views(visited_at);
CREATE INDEX idx_page_views_visitor_id ON page_views(visitor_id);

3-3. 일별 집계 테이블 (대용량 최적화)

로그 테이블이 너무 커지면 일별로 집계해서 별도 저장한다.

sql
CREATE TABLE daily_stats (
    id          SERIAL PRIMARY KEY,
    post_id     INTEGER REFERENCES posts(id) ON DELETE CASCADE,
    date        DATE NOT NULL,
    pv          INTEGER DEFAULT 0,  -- Page View
    uv          INTEGER DEFAULT 0,  -- Unique Visitor
    UNIQUE(post_id, date)
);

4. FastAPI 구현

4-1. 세션 ID 생성 및 방문 기록 엔드포인트

python
# app/routers/analytics.py
import hashlib
import uuid
from fastapi import APIRouter, Request, Response, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import PageView, Post

router = APIRouter()

def get_visitor_id(request: Request, response: Response) -> str:
    """
    쿠키에서 visitor_id를 읽거나 새로 발급.
    브라우저에 30일간 유지.
    """
    visitor_id = request.cookies.get("visitor_id")
    if not visitor_id:
        visitor_id = str(uuid.uuid4())
        response.set_cookie(
            key="visitor_id",
            value=visitor_id,
            max_age=60 * 60 * 24 * 30,  # 30일
            httponly=True,
            samesite="lax",
        )
    return visitor_id


def hash_ip(ip: str) -> str:
    """IP를 SHA-256 해시로 저장 (개인정보 보호)."""
    return hashlib.sha256(ip.encode()).hexdigest()


@router.post("/api/views/{post_id}")
async def record_view(
    post_id: int,
    request: Request,
    response: Response,
    db: AsyncSession = Depends(get_db),
):
    visitor_id = get_visitor_id(request, response)
    ip_hash = hash_ip(request.client.host)

    # 같은 visitor_id + 같은 게시글을 1시간 내 중복 방문 시 카운트 안 함
    from datetime import datetime, timedelta
    from sqlalchemy import select, and_

    one_hour_ago = datetime.utcnow() - timedelta(hours=1)
    existing = await db.execute(
        select(PageView).where(
            and_(
                PageView.post_id == post_id,
                PageView.visitor_id == visitor_id,
                PageView.visited_at >= one_hour_ago,
            )
        )
    )
    if existing.scalar_one_or_none():
        return {"counted": False}

    # 방문 기록 저장
    view = PageView(
        post_id=post_id,
        visitor_id=visitor_id,
        ip_hash=ip_hash,
        user_agent=request.headers.get("user-agent", ""),
        referer=request.headers.get("referer", ""),
    )
    db.add(view)

    # 게시글 view_count도 함께 증가
    post = await db.get(Post, post_id)
    if post:
        post.view_count += 1

    await db.commit()
    return {"counted": True}


@router.get("/api/views/{post_id}/stats")
async def get_view_stats(
    post_id: int,
    db: AsyncSession = Depends(get_db),
):
    """게시글 PV / UV 반환."""
    from sqlalchemy import select, func

    result = await db.execute(
        select(
            func.count().label("pv"),
            func.count(PageView.visitor_id.distinct()).label("uv"),
        ).where(PageView.post_id == post_id)
    )
    row = result.one()
    return {"post_id": post_id, "pv": row.pv, "uv": row.uv}

4-2. SQLAlchemy 모델

python
# app/models.py
from sqlalchemy import Column, Integer, BigInteger, String, Text, DateTime, ForeignKey
from sqlalchemy.sql import func
from app.database import Base

class PageView(Base):
    __tablename__ = "page_views"

    id         = Column(BigInteger, primary_key=True, autoincrement=True)
    post_id    = Column(Integer, ForeignKey("posts.id", ondelete="CASCADE"), nullable=False)
    visitor_id = Column(String(64), nullable=True)
    ip_hash    = Column(String(64), nullable=True)
    user_agent = Column(Text, nullable=True)
    referer    = Column(Text, nullable=True)
    visited_at = Column(DateTime(timezone=True), server_default=func.now())

4-3. 봇 트래픽 필터링

python
# app/utils/bot_filter.py
BOT_USER_AGENTS = [
    "googlebot", "bingbot", "slurp", "duckduckbot",
    "baiduspider", "yandexbot", "facebot", "ia_archiver",
    "python-requests", "curl", "wget", "scrapy",
]

def is_bot(user_agent: str) -> bool:
    ua_lower = user_agent.lower()
    return any(bot in ua_lower for bot in BOT_USER_AGENTS)
python
# 엔드포인트에서 사용
@router.post("/api/views/{post_id}")
async def record_view(post_id: int, request: Request, ...):
    ua = request.headers.get("user-agent", "")
    if is_bot(ua):
        return {"counted": False, "reason": "bot"}
    # ... 이하 동일

5. Next.js 구현

5-1. 페이지 방문 시 자동으로 조회수 전송

typescript
// hooks/usePageView.ts
import { useEffect } from "react";

export function usePageView(postId: number) {
  useEffect(() => {
    const recordView = async () => {
      try {
        await fetch(`/api/views/${postId}`, {
          method: "POST",
          credentials: "include",  // 쿠키 포함
        });
      } catch (e) {
        // 조회수 실패는 무시 (UX에 영향 없음)
        console.warn("Failed to record view", e);
      }
    };

    recordView();
  }, [postId]);
}
typescript
// app/blog/[slug]/page.tsx (Next.js 13+ App Router)
"use client";
import { usePageView } from "@/hooks/usePageView";

export default function PostPage({ post }: { post: Post }) {
  usePageView(post.id);  // 마운트 시 한 번 전송

  return <article>...</article>;
}

5-2. Next.js API Route로 프록시 (선택 사항)

FastAPI를 직접 노출하지 않고 Next.js를 거치게 할 수 있다.

typescript
// app/api/views/[postId]/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(
  request: NextRequest,
  { params }: { params: { postId: string } }
) {
  const res = await fetch(
    `${process.env.FASTAPI_URL}/api/views/${params.postId}`,
    {
      method: "POST",
      headers: {
        "X-Forwarded-For": request.ip ?? "",
        "User-Agent": request.headers.get("user-agent") ?? "",
        "Cookie": request.headers.get("cookie") ?? "",
      },
    }
  );

  const data = await res.json();
  const response = NextResponse.json(data);

  // FastAPI에서 set-cookie가 있으면 클라이언트에 전달
  const setCookie = res.headers.get("set-cookie");
  if (setCookie) {
    response.headers.set("set-cookie", setCookie);
  }

  return response;
}

5-3. 조회수 표시 컴포넌트

typescript
// components/ViewCount.tsx
import { useEffect, useState } from "react";

interface ViewStats {
  pv: number;
  uv: number;
}

export function ViewCount({ postId }: { postId: number }) {
  const [stats, setStats] = useState<ViewStats | null>(null);

  useEffect(() => {
    fetch(`/api/views/${postId}/stats`)
      .then((r) => r.json())
      .then(setStats)
      .catch(() => {});
  }, [postId]);

  if (!stats) return null;

  return (
    <span className="text-sm text-gray-500">
      조회 {stats.pv.toLocaleString()}회
    </span>
  );
}

6. Redis를 활용한 성능 최적화 (선택 사항)

트래픽이 많아지면 매 요청마다 DB에 INSERT하는 게 부담이 된다. Redis에 임시로 쌓은 뒤 주기적으로 DB에 플러시하는 방식을 쓴다.

python
# app/utils/view_counter.py
import redis.asyncio as aioredis

redis_client = aioredis.from_url("redis://localhost:6379")

async def increment_view(post_id: int, visitor_id: str) -> bool:
    """
    Redis에 방문 기록. 1시간 내 같은 visitor_id 중복 방문 방지.
    Returns True if counted (new visit).
    """
    key = f"view:{post_id}:{visitor_id}"
    # NX = 키가 없을 때만 SET, EX = 1시간 후 자동 만료
    result = await redis_client.set(key, 1, ex=3600, nx=True)
    if result:
        # PV 카운터 증가 (영구 보관)
        await redis_client.incr(f"pv:{post_id}")
    return bool(result)


async def get_view_count(post_id: int) -> int:
    count = await redis_client.get(f"pv:{post_id}")
    return int(count) if count else 0
python
# 주기적으로 Redis → DB 동기화 (예: APScheduler로 5분마다)
from apscheduler.schedulers.asyncio import AsyncIOScheduler

scheduler = AsyncIOScheduler()

@scheduler.scheduled_job("interval", minutes=5)
async def flush_views_to_db():
    async with AsyncSession(engine) as session:
        # Redis의 pv:* 키를 모두 스캔해서 DB 업데이트
        async for key in redis_client.scan_iter("pv:*"):
            post_id = int(key.decode().split(":")[1])
            count = await redis_client.getdel(key)  # 읽고 삭제
            if count:
                await session.execute(
                    text("UPDATE posts SET view_count = view_count + :c WHERE id = :id"),
                    {"c": int(count), "id": post_id},
                )
        await session.commit()

7. 전체 아키텍처 흐름

mermaid
sequenceDiagram
    participant Browser
    participant Next.js
    participant FastAPI
    participant Redis
    participant PostgreSQL

    Browser->>Next.js: 게시글 페이지 접속
    Next.js->>Browser: HTML 렌더링 + visitor_id 쿠키 확인
    Browser->>FastAPI: POST /api/views/{post_id} (쿠키 포함)
    FastAPI->>Redis: visitor_id 중복 체크 (1시간 내)
    alt 새 방문
        Redis-->>FastAPI: OK (새 방문자)
        FastAPI->>Redis: PV 카운터 증가
        FastAPI->>PostgreSQL: page_views INSERT
        FastAPI-->>Browser: {counted: true} + visitor_id 쿠키 발급
    else 중복 방문
        Redis-->>FastAPI: 이미 기록됨
        FastAPI-->>Browser: {counted: false}
    end
    Note over FastAPI,PostgreSQL: 5분마다 Redis PV → PostgreSQL 동기화

8. 구현 방식 선택 가이드

상황권장 방식
작은 개인 블로그, 트래픽 적음DB 직접 기록 + 세션 쿠키로 중복 제거
어느 정도 트래픽 있음Redis로 PV 캐싱 + 주기적 DB 플러시
대형 서비스 수준Kafka/Queue로 비동기 처리 + 별도 분석 DB
외부 툴 활용하고 싶음Google Analytics, Umami, Plausible 등

[!note] 셀프 호스팅 오픈소스 Analytics Umami 또는 Plausible을 Docker로 직접 올리면 GA 없이도 프라이버시 친화적인 방문자 분석이 가능하다. FastAPI/Next.js 블로그에 스크립트 한 줄만 추가하면 된다.


9. 개인정보 고려사항

  • IP 원본 저장 금지 — SHA-256 해시로만 저장 (역추적 불가)
  • 쿠키 동의 — GDPR/개인정보보호법에 따라 분석 쿠키는 동의 후 발급 권장
  • User-Agent — 브라우저 지문으로 사용 시 동의 필요. 봇 필터링 용도만 사용 권장
  • 데이터 보존 기간 — 원시 로그는 6개월~1년, 집계 데이터만 장기 보관