
이전 글에서 Jinja2 템플릿 엔진을 사용하여 백엔드 Python 로직에서 프론트엔드 HTML 코드를 분리하여 프로젝트 구조를 더 명확하게 만들었습니다.
현재 포럼은 익명으로 게시물을 작성할 수 있도록 허용하는데, 이는 커뮤니티를 운영하는 올바른 방법이 아닙니다. 포럼은 사용자 중심으로 구축되어야 합니다. 각 사용자는 자신만의 신원, 게시물, 댓글을 갖습니다.
따라서 이 글에서는 사용자 등록, 로그인, 로그아웃 기능을 포함한 완전한 사용자 시스템을 포럼에 추가할 것입니다.
비밀번호 암호화를 처리할 라이브러리가 필요합니다. 사용자 비밀번호는 평문으로 저장하면 매우 위험합니다. passlib와 pbkdf2_sha256 알고리즘을 사용할 것입니다.
다음 명령을 실행하세요:
pip install "passlib[pbkdf2_sha256]"
사용자 정보를 저장할 새로운 테이블이 필요하며, 각 게시물의 작성자를 기록하기 위해 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 구문을 직접 실행할 수 있습니다.

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: 일반 텍스트 비밀번호를 해시 값으로 변환하여 데이터베이스에 저장할 수 있도록 합니다.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>
이제 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와 같은 라우트는 이제 현재 사용자 정보를 가져와 템플릿에 전달하므로 로그인 상태를 페이지에 표시할 수 있습니다.마지막으로, 로그인 상태에 따라 다른 콘텐츠를 표시할 수 있도록 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을 방문하세요. 홈페이지 오른쪽 상단에 "로그인" 및 "가입" 링크가 표시되며, 페이지에 게시물 생성 항목이 없습니다.

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

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

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