FastAPI로 나만의 포럼 만들기: 10단계 - 카테고리
Emily Parker
Product Engineer · Leapcell

이전 글에서 포럼에 이미지 업로드 기능을 추가하여 게시물의 내용을 풍부하게 만들었습니다. 현재 모든 게시물이 동일한 홈페이지 피드에 몰려 있어, 포럼 콘텐츠가 늘어남에 따라 매우 혼란스럽습니다. 사용자는 특정 주제에만 관심이 있을 수 있지만 관련 없는 콘텐츠에 산만해질 수 있습니다.
이 문제를 해결하기 위해 이번 글에서는 카테고리 기능을 도입할 것입니다. 다양한 게시판(예: "기술 토론", "일반 채팅")을 만들어 사용자가 게시물을 작성할 때 카테고리를 선택하고 카테고리별로 게시물을 탐색할 수 있도록 할 것입니다.
1단계: 데이터베이스 모델 업데이트
카테고리 정보를 저장할 새로운 categories 테이블이 필요하며, 게시물을 연결하기 위해 posts 테이블에 외래 키를 추가해야 합니다.
models.py를 열고 Category 모델을 추가한 다음 Post 모델을 업데이트하세요.
models.py (업데이트됨)
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import TSVECTOR from database import Base # ... (User 및 Comment 모델은 변경되지 않음) ... class Post(Base): __tablename__ = "posts" id = Column(Integer, primary_key=True, index=True) title = Column(String, index=True) content = Column(String) owner_id = Column(Integer, ForeignKey("users.id")) image_url = Column(String, nullable=True) # --- 새 필드 --- category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) # --------------- owner = relationship("User", back_populates="posts") comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan") search_vector = Column(TSVECTOR, nullable=True) # --- 새 관계 --- category = relationship("Category", back_populates="posts") # --------------- class Category(Base): __tablename__ = "categories" id = Column(Integer, primary_key=True, index=True) name = Column(String, unique=True, index=True, nullable=False) description = Column(String, nullable=True) posts = relationship("Post", back_populates="category")
이 단계의 주요 변경 사항은 다음과 같습니다.
- 새로운 
Category모델 생성. Post모델에category_id를 외래 키로 추가.Post와Category간의relationship설정,post.category또는category.posts를 통해 카테고리 정보에 액세스할 수 있도록 함.
2단계: 데이터베이스 테이블 구조 업데이트
다음으로 데이터베이스에 이 테이블을 실제로 생성하고 posts 테이블을 수정해야 합니다.
categories 테이블 생성
CREATE TABLE categories ( id SERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, description TEXT );
기본 카테고리 생성
카테고리를 즉시 사용할 수 있도록 두 개를 수동으로 생성해 보겠습니다.
INSERT INTO categories (name, description) VALUES ('Technical', 'Discuss FastAPI, Python, databases, and other technical topics'), ('General', 'Share daily life, hobbies, etc.');
posts 테이블 수정
-- category_id 열 추가 ALTER TABLE posts ADD COLUMN category_id INTEGER; -- 외래 키 제약 조건 추가 ALTER TABLE posts ADD CONSTRAINT fk_category FOREIGN KEY(category_id) REFERENCES categories(id);
기존 게시물 처리
posts 테이블의 기존 데이터의 경우 기본 category_id를 할당해야 합니다.
-- 모든 기존 게시물을 'General Chat' 카테고리로 업데이트 (ID가 2라고 가정) UPDATE posts SET category_id = 2 WHERE category_id IS NULL;
Null 불가능으로 설정
마지막으로 데이터 무결성을 보장하기 위해 category_id 열을 NOT NULL로 설정합니다.
ALTER TABLE posts ALTER COLUMN category_id SET NOT NULL;
Leapcell을 사용하여 데이터베이스를 생성한 경우,
웹 기반 운영 패널에서 이러한 SQL 문을 직접 실행할 수 있습니다.

3단계: 게시물 생성 로직 업데이트
이제 사용자가 게시물을 생성할 때 카테고리를 지정해야 합니다. create_post 라우트를 category_id를 받도록 수정해야 합니다.
main.py (create_post 라우트 업데이트)
# ... (이전 임포트) ... @app.post("/api/posts") async def create_post( title: str = Form(...), content: str = Form(...), category_id: int = Form(...), # category_id 추가 image: Optional[UploadFile] = File(None), db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # ... (다른 로직은 변경되지 않음) ... # Post 객체를 생성할 때 category_id 포함 new_post = models.Post( title=title, content=content, owner_id=current_user.id, image_url=image_url, category_id=category_id # 카테고리 ID 저장 ) db.add(new_post) await db.commit() await db.refresh(new_post) # 3. 게시 후 홈페이지가 아닌 카테고리 페이지로 리디렉션 return RedirectResponse(url=f"/categories/{category_id}", status_code=status.HTTP_303_SEE_OTHER)
4단계: 카테고리별 탐색 구현
더 이상 /posts를 유일한 게시물 목록 페이지로 사용하지 않을 것입니다. 대신 특정 카테고리의 게시물을 표시하기 위해 GET /categories/{category_id}라는 새 라우트를 만들 것입니다.
기존 GET /posts 라우트를 "모든 게시물" 집계 페이지로 사용하도록 수정할 것입니다.
main.py (라우트 추가/수정)
# ... (이전 임포트) ... from sqlalchemy.orm import selectinload # ... (의존성, 등) ... # 헬퍼 함수: 데이터베이스에서 모든 카테고리 가져오기 async def get_all_categories(db: AsyncSession): result = await db.execute(select(models.Category).order_by(models.Category.id)) return result.scalars().all() @app.get("/posts", response_class=HTMLResponse) async def view_posts( request: Request, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # 모든 카테고리 쿼리 (탐색용) categories = await get_all_categories(db) # 모든 게시물 쿼리 stmt = ( select(models.Post) .options(selectinload(models.Post.owner), selectinload(models.Post.category)) .order_by(desc(models.Post.id)) ) result = await db.execute(stmt) posts = result.scalars().all() return templates.TemplateResponse("posts.html", { "request": request, "posts": posts, "categories": categories, "current_user": current_user, "current_category": None # 특정 카테고리에 있지 않음을 표시 }) # --- 새 라우트 --- @app.get("/categories/{category_id}", response_class=HTMLResponse) async def view_posts_by_category( request: Request, category_id: int, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # 모든 카테고리 쿼리 (탐색용) categories = await get_all_categories(db) # 현재 카테고리 쿼리 category_result = await db.execute(select(models.Category).where(models.Category.id == category_id)) current_category = category_result.scalar_one_or_none() if not current_category: raise HTTPException(status_code=404, detail="Category not found") # 이 카테고리의 게시물 쿼리 stmt = ( select(models.Post) .where(models.Post.category_id == category_id) .options(selectinload(models.Post.owner), selectinload(models.Post.category)) .order_by(desc(models.Post.id)) ) result = await db.execute(stmt) posts = result.scalars().all() return templates.TemplateResponse("posts.html", { "request": request, "posts": posts, "categories": categories, "current_user": current_user, "current_category": current_category # 현재 카테고리 정보 전달 }) # ... (다른 라우트) ... @app.get("/posts/{post_id}", response_class=HTMLResponse) async def view_post_detail( request: Request, post_id: int, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # 게시물을 쿼리할 때 카테고리 정보도 미리 로드 result = await db.execute( select(models.Post) .where(models.Post.id == post_id) .options(selectinload(models.Post.owner), selectinload(models.Post.category)) ) post = result.scalar_one_or_none() # ... (이후 댓글 쿼리 로직 등은 변경되지 않음) ...
GET /categories/{category_id}라는 새 라우트가 추가되었으며, 이 라우트는 posts.html 템플릿을 재사용하지만 해당 카테고리의 게시물만 전달합니다. GET /posts 및 GET /posts/{post_id} 라우트도 올바르게 카테고리 정보를 로드하고 전달하도록 수정되었습니다.
5단계: 프론트엔드 템플릿 업데이트
마지막으로 카테고리 탐색을 표시하고, 게시 시 카테고리를 선택하고, 게시물이 속한 카테고리를 보여주도록 템플릿을 업데이트해야 합니다.
templates/posts.html (업데이트됨)
<!DOCTYPE html> <html> <head> <style> /* ... (기존 스타일) ... */ .category-nav { margin-top: 20px; margin-bottom: 20px; } .category-nav a { margin-right: 15px; text-decoration: none; } .category-nav a.active { font-weight: bold; } .post-category { font-size: 0.9em; color: #888; } </style> </head> <body> <div class="category-nav"> <strong>Categories:</strong> <a href="/posts" class="{{ 'active' if not current_category else '' }}">All</a> {% for category in categories %} <a href="/categories/{{ category.id }}" class="{{ 'active' if current_category and current_category.id == category.id else '' }}"> {{ category.name }} </a> {% endfor %} </div> {% if current_user and not current_user.is_banned %} <h2> Post a new thread {% if current_category %}in {{ current_category.name }} {% endif %} </h2> <form action="/api/posts" method="post" enctype="multipart/form-data"> <input type="text" name="title" placeholder="Post Title" required /><br /> <textarea name="content" rows="4" placeholder="Post Content" required></textarea><br /> <label for="category">Select Category:</label> <select name="category_id" id="category" required> {% for category in categories %} <option value="{{ category.id }}" {{ 'selected' if current_category and current_category.id == category.id else '' }}> {{ category.name }} </option> {% endfor %} </select> <br /><br /> <label for="image">Upload Image (Optional, JPEG/PNG/GIF):</label> <input type="file" name="image" id="image" accept="image/jpeg,image/png,image/gif" /> <br /><br /> <button type="submit">Post</button> </form> {% elif current_user and current_user.is_banned %} {% else %} {% endif %} <hr /> <h2> Post List - {{ current_category.name if current_category else "All Posts" }} </h2> {% for post in posts %} <div class="post-item"> <a href="/posts/{{ post.id }}"><h3>{{ post.title }}</h3></a> <p>{{ post.content }}</p> <small> Author: {{ post.owner.username if post.owner else 'Unknown' }} | Category: <a href="/categories/{{ post.category.id }}">{{ post.category.name }}</a> </small> </div> {% endfor %} </body> </html>
templates/post_detail.html (업데이트됨)
게시물 상세 페이지에서도 카테고리 정보를 추가합니다.
...<body> <div class="post-container"> <h1>{{ post.title }}</h1> <p>{{ post.content }}</p> <small> Author: {{ post.owner.username }} | Category: <a href="/categories/{{ post.category.id }}">{{ post.category.name }}</a> </small> </div> ...
실행 및 검증
uvicorn 서버를 다시 시작하세요:
uvicorn main:app --reload
http://127.0.0.1:8000/로 방문하세요.
이제 상단에 새로운 카테고리 탐색 메뉴("All", "Technical", "General")가 표시됩니다.
"새 스레드 게시" 양식에는 이제 "카테고리 선택"이라는 새로운 필수 드롭다운 메뉴가 있습니다.

게시물 목록에서 각 게시물은 해당 카테고리가 속한 카테고리를 표시합니다.

카테고리(예: "General")를 클릭해 보세요. 페이지가 /categories/2로 리디렉션되어 해당 카테고리의 게시물만 표시되며, 양식의 "카테고리 선택" 드롭다운은 "General"로 기본 설정됩니다.

결론
Category 모델을 추가하고 라우트 및 템플릿을 업데이트하여 포럼에 카테고리 기능을 성공적으로 구현했습니다. 이제 사용자가 포럼 콘텐츠를 더 편리하게 찾고 탐색할 수 있습니다.
