FastAPI로 완벽한 블로그 만들기: 방문자 분석

Leapcell HQ·2025년 10월 8일
post-thumbnail

이전 게시물(https://leapcell.io/blog/build-a-perfect-blog-with-fastapi-full-text-search-for-posts)에서는 블로그에 전문 검색 기능을 통합하여 훌륭한 콘텐츠를 더 쉽게 찾을 수 있도록 했습니다.

이제 블로그의 기능이 더욱 풍부해지고 콘텐츠가 늘어남에 따라 자연스럽게 새로운 질문이 생깁니다. 어떤 게시물이 독자들 사이에서 가장 인기가 있을까요?

독자의 관심을 이해하면 더 높은 품질의 콘텐츠를 만드는 데 도움이 될 수 있습니다.

따라서 이번 튜토리얼에서는 블로그에 기본적이면서도 매우 중요한 기능인 방문자 추적을 추가합니다. 각 게시물이 읽힌 횟수를 기록하고 페이지에 조회수를 표시합니다.

Google Analytics와 같은 타사 서비스를 사용하는 것을 고려할 수 있습니다. 하지만 자체적으로 백엔드 기반 추적 시스템을 구축하면 데이터를 더 많이 자체적으로 관리하고 수집하려는 데이터를 사용자 정의할 수 있습니다.

시작해 보겠습니다:

단계 1: 페이지 보기 데이터 모델 생성

1. 데이터베이스 테이블 생성

각 보기가 발생한 시간, 해당 게시물, 향후 심층 분석을 위한 방문자 정보(IP 주소 및 사용자 에이전트 등)를 기록할 pageview 테이블을 생성하기 위해 PostgreSQL 데이터베이스에서 다음 SQL 문을 실행하세요.

CREATE TABLE "pageview" (
    "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    "postId" UUID REFERENCES "post"("id") ON DELETE CASCADE,
    "ipAddress" VARCHAR(45),
    "userAgent" TEXT
);

참고: ON DELETE CASCADE는 게시물이 삭제될 때 관련 페이지 보기 레코드가 모두 자동으로 삭제되도록 합니다.

Leapcell에서 데이터베이스를 생성한 경우,

Leapcell

그래픽 인터페이스를 사용하여 SQL 문을 쉽게 실행할 수 있습니다. 웹사이트의 데이터베이스 관리 페이지로 이동하여 위 문장을 SQL 인터페이스에 붙여넣고 실행하면 됩니다.

ImageP0

2. PageView 엔티티 생성

다음으로 models.py 파일을 열고 PageView 모델을 추가하고 Post 모델을 업데이트하여 양방향 관계를 설정합니다.

# models.py
import uuid
from datetime import datetime
from typing import Optional, List
from sqlmodel import Field, SQLModel, Relationship

# ... User 클래스 ...

class Post(SQLModel, table=True):
    id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True)
    title: str
    content: str
    createdAt: datetime = Field(default_factory=datetime.utcnow, nullable=False)
    comments: List["Comment"] = Relationship(back_populates="post")
    
    # PageView와 일대다 관계 추가
    page_views: List["PageView"] = Relationship(back_populates="post")

# ... Comment 클래스 ...

class PageView(SQLModel, table=True):
    id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True)
    createdAt: datetime = Field(default_factory=datetime.utcnow, nullable=False)
    ipAddress: Optional[str] = Field(max_length=45, default=None)
    userAgent: Optional[str] = Field(default=None)

    # Post 테이블에 연결되는 외래 키 정의
    postId: uuid.UUID = Field(foreign_key="post.id")

    # 다대일 관계 정의
    post: "Post" = Relationship(back_populates="page_views")

main.py에서 create_db_and_tables 함수를 구성했으므로 SQLModel이 애플리케이션이 시작될 때 모델 변경 사항을 자동으로 감지하고 데이터베이스 테이블 구조를 업데이트하므로 수동으로 SQL을 실행할 필요가 없습니다.

단계 2: 추적 서비스 구현

코드를 깔끔하게 유지하기 위해 페이지 보기 추적 기능에 대한 새 서비스 파일을 생성합니다.

페이지 보수와 관련된 모든 로직을 처리하기 위해 프로젝트 루트 디렉토리에 새 파일 tracking_service.py를 만듭니다.

# tracking_service.py
import uuid
from typing import List, Dict
from sqlmodel import Session, select, func
from models import PageView

def record_view(post_id: uuid.UUID, ip_address: str, user_agent: str, session: Session):
    """새 페이지 보기를 기록합니다"""
    new_view = PageView(
        postId=post_id,
        ipAddress=ip_address,
        userAgent=user_agent,
    )
    session.add(new_view)
    session.commit()

def get_count_by_post_id(post_id: uuid.UUID, session: Session) -> int:
    """단일 게시물의 총 보기 수를 가져옵니다"""
    statement = select(func.count(PageView.id)).where(PageView.postId == post_id)
    # 단일 스칼라 값을 반환하는 쿼리에는 .one() 또는 .one_or_none()이 필요합니다
    count = session.exec(statement).one_or_none()
    return count if count is not None else 0

def get_counts_by_post_ids(post_ids: List[uuid.UUID], session: Session) -> Dict[uuid.UUID, int]:
    """효율성을 위해 여러 게시물의 보기 수를 한 번에 가져옵니다"""
    if not post_ids:
        return {}
    
    statement = (
        select(PageView.postId, func.count(PageView.id).label("count"))
        .where(PageView.postId.in_(post_ids))
        .group_by(PageView.postId)
    )
    
    results = session.exec(statement).all()
    
    # 결과를 {post_id: count} 형식의 사전으로 변환합니다
    return {post_id: count for post_id, count in results}

get_counts_by_post_ids 메서드는 SQLModel(SQLAlchemy)의 func.countgroup_by를 사용하여 효율적인 GROUP BY 쿼리를 실행합니다. 이는 특히 홈페이지에서 여러 게시물의 보기 수를 표시해야 할 때 각 게시물에 대해 별도의 count 쿼리를 실행하는 것보다 훨씬 빠릅니다.

단계 3: 게시물 페이지에 보기 기록 통합

다음으로 방문자가 게시물을 볼 때마다 tracking_servicerecord_view 메서드를 호출해야 합니다. 여기에 가장 적합한 장소는 routers/posts.pyget_post_by_id 라우트입니다.

routers/posts.py를 열고 새 서비스를 가져와 호출합니다.

# routers/posts.py
# ... 다른 가져오기
import tracking_service # 추적 서비스 가져오기

# ...

@router.get("/posts/{post_id}", response_class=HTMLResponse)
def get_post_by_id(
    request: Request,
    post_id: uuid.UUID,
    session: Session = Depends(get_session),
    user: dict | None = Depends(get_user_from_session),
):
    post = session.get(Post, post_id)
    if not post:
        # 게시물을 찾을 수 없는 경우 처리
        return HTMLResponse(status_code=404)
        
    comments = comments_service.get_comments_by_post_id(post_id, session)

    # 보기 기록 (발송 후 잊기)
    client_ip = request.client.host
    user_agent = request.headers.get("user-agent", "")
    tracking_service.record_view(post_id, client_ip, user_agent, session)
    
    # 보기 수 가져오기
    view_count = tracking_service.get_count_by_post_id(post_id, session)

    # Markdown 콘텐츠 구문 분석
    post.content = markdown2.markdown(post.content)

    return templates.TemplateResponse(
        "post.html",
        {
            "request": request,
            "post": post,
            "title": post.title,
            "user": user,
            "comments": comments,
            "view_count": view_count, # 보기 수를 템플릿에 전달
        },
    )

단계 4: 프론트엔드에 보기 수 표시

게시물 상세 페이지

이전 단계에서 view_count를 이미 검색하여 post.html 템플릿에 전달했습니다. 이제 템플릿에 표시하기만 하면 됩니다.

templates/post.html을 열고 게시물의 메타 정보 영역에 보기 수를 추가합니다.

<article class="post-detail">
  <h1>{{ post.title }}</h1>
  <small>{{ post.createdAt.strftime('%Y-%m-%d') }} | Views: {{ view_count }}</small>
  <div class="post-content">{{ post.content | safe }}</div>
</article>

블로그 홈페이지

홈페이지의 게시물 목록에도 보기 수를 표시하려면 get_all_posts 라우트에 몇 가지 조정을 해야 합니다.

routers/posts.py 업데이트:

# routers/posts.py
# ...

@router.get("/posts", response_class=HTMLResponse)
def get_all_posts(
    request: Request, 
    session: Session = Depends(get_session),
    user: dict | None = Depends(get_user_from_session)
):
    # 1. 모든 게시물 가져오기
    statement = select(Post).order_by(Post.createdAt.desc())
    posts = session.exec(statement).all()
    
    # 2. 모든 게시물의 ID 가져오기
    post_ids = [post.id for post in posts]
    
    # 3. 보기 수 일괄 가져오기
    view_counts = tracking_service.get_counts_by_post_ids(post_ids, session)
    
    # 4. 각 게시물 객체에 보기 수 연결
    for post in posts:
        post.view_count = view_counts.get(post.id, 0)

    return templates.TemplateResponse(
        "index.html", 
        {"request": request, 
         "posts": posts, 
         "title": "Home", 
         "user": user
        }
    )

# ...

마지막으로 templates/index.html 템플릿을 업데이트하여 보기 수를 표시합니다.

<div class="post-list">
  {% for post in posts %}
  <article class="post-item">
    <h2><a href="/posts/{{ post.id }}">{{ post.title }}</a></h2>
    <p>{{ post.content[:150] }}...</p>
    <small>{{ post.createdAt.strftime('%Y-%m-%d') }} | Views: {{ post.view_count }}</small>
  </article>
  {% endfor %}
</div>

실행 및 테스트

애플리케이션을 다시 시작합니다:

uvicorn main:app --reload

브라우저를 열고 블로그 홈페이지로 이동합니다.

블로그 목록에서 각 게시물 옆에 "Views: 0"이 표시됩니다.

ImageP1

아티클 상세 페이지로 들어가서 페이지를 몇 번 새로고침하면 해당 게시물의 조회수가 증가한 것을 알 수 있습니다.

ImageP2

결론

이제 FastAPI 블로그에 백엔드 보기 수 추적 시스템을 성공적으로 추가했습니다. 사용자 방문 데이터가 이제 여러분의 손에 달려 있습니다.

이 원시 데이터를 통해 더 심층적인 데이터 작업 및 분석을 수행할 수 있습니다. 예를 들면 다음과 같습니다:

  • 중복 제거: 특정 시간 창(예: 하루) 내 동일 IP 주소에서의 여러 방문을 단일 보기로 계산합니다.
  • 봇 필터링: User-Agent를 분석하여 검색 엔진 크롤러의 방문을 식별하고 필터링합니다.
  • 데이터 대시보드: 차트를 사용하여 게시물 보기 추세를 시각화하는 비공개 페이지를 만듭니다.

데이터는 여러분의 손에 있으므로 이러한 탐색은 여러분에게 맡깁니다.

Leapcell에서 블로그를 배포하는 경우, Leapcell은 이미 무료로 제공되는 웹 분석 기능을 자동으로 사용 설정했습니다.

Leapcell의 웹 분석에는 유용하고 강력한 방문자 분석 기능이 많이 포함되어 있습니다. 이를 사용하면 직접 개발하는 수고 없이 방문자 행동에 대한 기본 분석을 쉽게 수행할 수 있습니다.

Analytics


X에서 저희를 팔로우하세요: @LeapcellKR


블로그에서 읽기

관련 글:

0개의 댓글