FastAPI로 나만의 포럼 만들기: 4단계 - 사용자 시스템
Min-jun Kim
Dev Intern · Leapcell

이전 글에서 Jinja2 템플릿 엔진을 사용하여 백엔드 Python 로직에서 프론트엔드 HTML 코드를 분리하여 프로젝트 구조를 더 명확하게 만들었습니다.
현재 포럼은 익명으로 게시물을 작성할 수 있도록 허용하는데, 이는 커뮤니티를 운영하는 올바른 방법이 아닙니다. 포럼은 사용자 중심으로 구축되어야 합니다. 각 사용자는 자신만의 신원, 게시물, 댓글을 갖습니다.
따라서 이 글에서는 사용자 등록, 로그인, 로그아웃 기능을 포함한 완전한 사용자 시스템을 포럼에 추가할 것입니다.
1단계: 종속성 설치
비밀번호 암호화를 처리할 라이브러리가 필요합니다. 사용자 비밀번호는 평문으로 저장하면 매우 위험합니다. passlib와 pbkdf2_sha256 알고리즘을 사용할 것입니다.
다음 명령을 실행하세요:
pip install "passlib[pbkdf2_sha256]"
2단계: 데이터베이스 모델 업데이트
사용자 정보를 저장할 새로운 테이블이 필요하며, 각 게시물의 작성자를 기록하기 위해 posts 테이블을 users 테이블과 연결해야 합니다.
models.py 파일을 열고 다음과 같이 변경하세요:
models.py (업데이트됨)
from sqlalchemy import Column, Integer, String, ForeignKey from sqlalchemy.orm import relationship from database import Base class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True, index=True) hashed_password = Column(String) posts = relationship("Post", back_populates="owner") 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")) owner = relationship("User", back_populates="posts")
여기서 두 가지 작업이 수행되었습니다:
User모델 생성:id, 고유한username,hashed_password필드를 포함하는users테이블을 정의합니다.Post와User연결:Post모델에users테이블의id를 가리키는 외래 키(owner_id) 필드를 추가했습니다. SQLAlchemy의relationship을 사용하여Post와User간에 양방향 연결을 설정했습니다. 이제post.owner를 통해 게시물의 작성자에 접근할 수 있으며,user.posts를 통해 사용자의 모든 게시물에 접근할 수도 있습니다.
이 모델들을 적용하기 전에 데이터베이스를 수동으로 업데이트해야 합니다. users 테이블을 생성하고 posts 테이블을 수정해야 합니다.
해당 SQL 구문은 다음과 같습니다:
-- users 테이블 생성 CREATE TABLE users ( id SERIAL PRIMARY KEY, username VARCHAR UNIQUE, hashed_password VARCHAR ); -- posts 테이블 수정, owner_id 컬럼 및 외래 키 제약 조건 추가 ALTER TABLE posts ADD COLUMN owner_id INTEGER; ALTER TABLE posts ADD CONSTRAINT fk_owner_id FOREIGN KEY (owner_id) REFERENCES users (id);
Leapcell을 사용하여 데이터베이스를 생성한 경우,
웹 기반 작동 패널에서 이러한 SQL 구문을 직접 실행할 수 있습니다.

3단계: 비밀번호 처리
auth.py라는 새 파일을 만들고 비밀번호 해싱 및 검증을 위한 함수를 작성하여 비밀번호를 안전하게 처리합니다.
auth.py
from passlib.context import CryptContext # 1. CryptContext 인스턴스를 생성하고 암호화 알고리즘 지정 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # 2. 비밀번호 검증 함수 def verify_password(plain_password, hashed_password): return pwd_context.verify(plain_password, hashed_password) # 3. 비밀번호 해시 생성 함수 def get_password_hash(password): return pwd_context.hash(password)
verify_password: 사용자가 입력한 일반 텍스트 비밀번호를 데이터베이스에 저장된 해시된 비밀번호와 비교하여 일치하는지 확인합니다.get_password_hash: 일반 텍스트 비밀번호를 해시 값으로 변환하여 데이터베이스에 저장할 수 있도록 합니다.
4단계: 사용자 등록 및 로그인 페이지 생성
posts.html과 마찬가지로 templates 폴더에 register.html과 login.html이라는 두 개의 새 HTML 파일을 만듭니다.
templates/register.html
<!DOCTYPE html> <html> <head> <title>Register - My FastAPI Forum</title> <style> body { font-family: sans-serif; margin: 2em; } form { width: 300px; margin: 0 auto; } input { width: 100%; padding: 8px; margin-bottom: 10px; box-sizing: border-box; } button { padding: 10px 15px; background-color: #007bff; color: white; border: none; cursor: pointer; width: 100%; } .error { color: red; } </style> </head> <body> <h1>Register New User</h1> {% if error %} <p class="error">{{ error }}</p> {% endif %} <form method="post"> <input type="text" name="username" placeholder="Username" required /><br /> <input type="password" name="password" placeholder="Password" required /><br /> <button type="submit">Register</button> </form> </body> </html>
templates/login.html
<!DOCTYPE html> <html> <head> <title>Login - My FastAPI Forum</title> <style> body { font-family: sans-serif; margin: 2em; } form { width: 300px; margin: 0 auto; } input { width: 100%; padding: 8px; margin-bottom: 10px; box-sizing: border-box; } button { padding: 10px 15px; background-color: #007bff; color: white; border: none; cursor: pointer; width: 100%; } .error { color: red; } </style> </head> <body> <h1>User Login</h1> {% if error %} <p class="error">{{ error }}</p> {% endif %} <form method="post"> <input type="text" name="username" placeholder="Username" required /><br /> <input type="password" name="password" placeholder="Password" required /><br /> <button type="submit">Login</button> </form> </body> </html>
5단계: 인증 관련 API 라우트 구현
이제 main.py를 리팩토링하여 등록, 로그인, 로그아웃 및 현재 사용자 상태 관리 기능을 추가합니다. 이는 상당히 큰 업데이트입니다.
main.py (최종 전체 버전)
from fastapi import FastAPI, Form, Depends, Request, Response, HTTPException, status from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from sqlalchemy.orm import selectinload from typing import Optional import models from database import get_db from auth import get_password_hash, verify_password app = FastAPI() templates = Jinja2Templates(directory="templates") # --- 사용자 상태 종속성 --- async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> Optional[models.User]: username = request.cookies.get("forum_user") if not username: return None result = await db.execute(select(models.User).where(models.User.username == username)) return result.scalar_one_or_none() # --- 라우트 --- @app.get("/", response_class=RedirectResponse) def read_root(): return RedirectResponse(url="/posts", status_code=status.HTTP_302_FOUND) @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)): # N+1 쿼리 문제를 피하기 위해 owner 관계를 미리 로드하도록 selectinload 사용 result = await db.execute( select(models.Post).options(selectinload(models.Post.owner)).order_by(desc(models.Post.id)) ) posts = result.scalars().all() return templates.TemplateResponse("posts.html", {"request": request, "posts": posts, "current_user": current_user}) @app.post("/api/posts") async def create_post( title: str = Form(...), content: str = Form(...), db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): if not current_user: return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) new_post = models.Post(title=title, content=content, owner_id=current_user.id) db.add(new_post) await db.commit() await db.refresh(new_post) return RedirectResponse(url="/posts", status_code=status.HTTP_303_SEE_OTHER) @app.get("/register", response_class=HTMLResponse) async def get_registration_form(request: Request): return templates.TemplateResponse("register.html", {"request": request}) @app.post("/register") async def register_user( request: Request, username: str = Form(...), password: str = Form(...), db: AsyncSession = Depends(get_db) ): result = await db.execute(select(models.User).where(models.User.username == username)) if result.scalar_one_or_none(): return templates.TemplateResponse("register.html", {"request": request, "error": "Username already exists"}) hashed_password = get_password_hash(password) new_user = models.User(username=username, hashed_password=hashed_password) db.add(new_user) await db.commit() return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) @app.get("/login", response_class=HTMLResponse) async def get_login_form(request: Request): return templates.TemplateResponse("login.html", {"request": request}) @app.post("/login") async def login_user( response: Response, request: Request, username: str = Form(...), password: str = Form(...), db: AsyncSession = Depends(get_db) ): result = await db.execute(select(models.User).where(models.User.username == username)) user = result.scalar_one_or_none() if not user or not verify_password(password, user.hashed_password): return templates.TemplateResponse("login.html", {"request": request, "error": "Incorrect username or password"}) # 간단한 세션 구현을 위해 쿠키 사용 response = RedirectResponse(url="/posts", status_code=status.HTTP_303_SEE_OTHER) response.set_cookie(key="forum_user", value=user.username, httponly=True) return response @app.get("/logout") async def logout_user(response: Response): response = RedirectResponse(url="/posts", status_code=status.HTTP_303_SEE_OTHER) response.delete_cookie(key="forum_user") return response
이 파일은 주로 다음과 같은 변경 사항이 있었습니다:
get_current_user함수 추가: 이 함수는 요청에서forum_user쿠키를 읽어 현재 사용자를 식별합니다. 후속 라우트에서Depends(get_current_user)를 통해 로그인한 사용자 정보에 직접 접근할 수 있습니다.- 사용자 등록 및 로그인 관련 라우트 추가
- 등록 (
/register): GET 요청은 등록 양식을 표시하고, POST 요청은 양식 제출을 처리합니다. 사용자 이름이 이미 존재하는지 확인한 다음, 비밀번호를 해싱하여 데이터베이스에 저장합니다. - 로그인 (
/login): GET 요청은 로그인 양식을 표시합니다. POST 요청은 사용자 이름과 비밀번호를 확인합니다. 성공하면 응답에forum_user라는 쿠키를 설정하고 사용자 이름을 값으로 지정합니다. 이는 간단한 세션 구현입니다. - 로그아웃 (
/logout):forum_user쿠키를 삭제하고 홈페이지로 리디렉션합니다.
- 등록 (
- 라우트 보호:
create_post라우트는 이제get_current_user에 종속됩니다. 사용자가 로그인하지 않은 경우 로그인 페이지로 리디렉션됩니다. 게시물을 작성할 때 게시물의owner_id는 현재 로그인한 사용자의 ID로 자동 설정됩니다. - 보기 업데이트:
/posts와 같은 라우트는 이제 현재 사용자 정보를 가져와 템플릿에 전달하므로 로그인 상태를 페이지에 표시할 수 있습니다.
6단계: 홈페이지 템플릿 업데이트하여 사용자 상태 표시
마지막으로, 로그인 상태에 따라 다른 콘텐츠를 표시할 수 있도록 templates/posts.html을 수정해야 합니다.
templates/posts.html (업데이트됨)
<!DOCTYPE html> <html> <head> <title>My FastAPI Forum</title> <style> body { font-family: sans-serif; margin: 2em; } input, textarea { width: 100%; padding: 8px; margin-bottom: 10px; box-sizing: border-box; } button { padding: 10px 15px; background-color: #007bff; color: white; border: none; cursor: pointer; } header { display: flex; justify-content: space-between; align-items: center; } </style> </head> <body> <header> <h1>Welcome to My Forum</h1> <div class="auth-links"> {% if current_user %} <span>Welcome, {{ current_user.username }}!</span> <a href="/logout">Logout</a> {% else %} <a href="/login">Login</a> | <a href="/register">Register</a> {% endif %} </div> </header> {% if current_user %} <h2>Create a New Post</h2> <form action="/api/posts" method="post"> <input type="text" name="title" placeholder="Post Title" required /><br /> <textarea name="content" rows="4" placeholder="Post Content" required></textarea><br /> <button type="submit">Post</button> </form> {% else %} <p><a href="/login">Login</a> to create a new post.</p> {% endif %} <hr /> <h2>Post List</h2> {% for post in posts %} <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 10px;"> <h3>{{ post.title }}</h3> <p>{{ post.content }}</p> <small>Author: {{ post.owner.username if post.owner else 'Unknown' }}</small> </div> {% endfor %} </body> </html>
템플릿은 주로 다음과 같은 변경 사항이 있었습니다:
- 상단 탐색은 로그인 상태를 결정하기 위해
{% if current_user %}를 사용합니다. 사용자가 로그인하면 환영 메시지와 "로그아웃" 링크가 표시되고, 그렇지 않으면 "로그인" 및 "가입" 링크가 표시됩니다. - 새 게시물 생성 양식은 로그인한 사용자만 볼 수 있도록 제한됩니다.
- 각 게시물 하단에
{{ post.owner.username }}을 통해 작성자 사용자 이름이 표시됩니다.
실행 및 확인
이제 결과를 확인할 때입니다! uvicorn 서버를 다시 시작하세요:
uvicorn main:app --reload
http://127.0.0.1:8000을 방문하세요. 홈페이지 오른쪽 상단에 "로그인" 및 "가입" 링크가 표시되며, 페이지에 게시물 생성 항목이 없습니다.

새 사용자를 등록하고 로그인한 다음 로그인하세요. 로그인하면 게시물 양식이 나타나고 페이지 상단에 사용자 이름이 표시됩니다.

게시물을 작성하면 해당 작성자가 사용자 이름으로 올바르게 표시됩니다.

요약
이 글을 통해 포럼에 대한 사용자 시스템을 구축했습니다. 이제 모든 사용자가 등록하고, 로그인하고, 자신의 게시물을 게시할 수 있습니다.
게시물에 사용자가 지정된 후에는 다음 사항을 고려할 수 있습니다. 사용자가 자신이 게시한 콘텐츠를 수정하기를 원한다면 어떻게 해야 할까요?
다음 글에서는 현재 사용자 시스템을 기반으로 새로운 기능을 구현하여 사용자가 이미 생성한 게시물을 편집할 수 있도록 할 것입니다.


