프로젝트
방문자 수 확인 및 관리하는 방법
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년, 집계 데이터만 장기 보관