FastAPI - MiniProject - 1

김기훈·2025년 10월 23일

부트캠프 프로젝트

목록 보기
3/39

구조

fastapi_mini_project/
├── app/
│   ├── api/              # FastAPI 라우터 (엔드포인트)
│   ├── core/             # 공통 설정, 보안, 의존성 (Config, JWT, Password 등)
│   ├── models/           # Tortoise ORM 모델 (DB 테이블 정의)
│   ├── schemas/          # Pydantic 스키마 (요청/응답 데이터 검증)
│   ├── services/         # 비즈니스 로직 (회원가입, 로그인, CRUD 처리)
│   ├── repositories/     # DB 접근 로직 (CRUD 메서드)
│   ├── db/               # DB 연결 및 마이그레이션 설정
│   ├── scraping/         # 스크래핑 기능 (명언/질문 수집용)
│   ├── main.py           # FastAPI 진입점 (앱 실행 및 라우터 등록)
│   └── __init__.py
├── tests/                # 테스트 코드
├── .env / .env.dev       # 환경변수
├── pyproject.toml        # Poetry 설정
└── README.md             # 프로젝트 설명
폴더주요 역할예시 파일
api/실제로 클라이언트 요청이 들어오는 엔드포인트 정의auth.py, diary.py
core/앱의 핵심 설정 (DB URL, JWT, 암호화 등)config.py, security.py, dependencies.py
models/데이터베이스 테이블 정의 (Tortoise ORM 모델)user.py, diary.py
schemas/요청(Request) / 응답(Response) 검증용 모델 (Pydantic)user.py
services/핵심 비즈니스 로직 (회원가입, 로그인 등)auth_service.py
repositories/DB 조작 담당 (CRUD 기능 구현)user_repo.py
db/DB 연결 및 마이그레이션 관련base.py, session.py
scraping/외부 웹 데이터 수집 기능quote_scraper.py
tests/pytest 등으로 테스트 실행test_auth.py
main.pyFastAPI 앱 실행, 라우터 등록, DB 초기화

상세 구조

fastapi_mini_project/
├── app/
│   ├── api/
│   │   └── v1/
│   │       ├── auth.py
│   │       ├── diary.py
│   │       ├── quote.py
│   │       ├── question.py
│   │       └── __init__.py
│   │
│   ├── core/
│   │   ├── config.py
│   │   ├── security.py
│   │   ├── dependencies.py   
│   │   └── __init__.py
│   │
│   ├── models/
│   │   ├── user.py
│   │   ├── diary.py
│   │   ├── quote.py
│   │   ├── question.py
│   │   └── __init__.py
│   │
│   ├── schemas/
│   │   ├── user.py
│   │   ├── diary.py
│   │   ├── quote.py
│   │   ├── question.py
│   │   └── __init__.py
│   │
│   ├── services/
│   │   ├── auth_service.py
│   │   ├── diary_service.py
│   │   ├── quote_service.py
│   │   ├── question_service.py
│   │   └── __init__.py
│   │
│   ├── repositories/
│   │   ├── user_repo.py
│   │   ├── diary_repo.py
│   │   ├── quote_repo.py
│   │   ├── question_repo.py
│   │   └── __init__.py
│   │
│   ├── db/
│   │   ├── base.py
│   │   ├── session.py
│   │   └── migrations/
│   │
│   ├── scraping/
│   │   ├── quote_scraper.py
│   │   ├── question_scraper.py
│   │   └── __init__.py
│   │
│   ├── main.py
│   └── __init__.py
│
├── tests/
│   ├── test_auth.py
│   ├── test_diary.py
│   ├── test_quote.py
│   ├── test_question.py
│
├── .env
├── .env.dev
├── pyproject.toml
├── poetry.lock
└── README.md

  • poetry run uvicorn app.main:app --reload
  • psycopg2-binary
    • PostgreSQL을 연결하기 위해서 필요
  • poetry add email-validator
    • EmailStr 타입을 검증하기 위해서 email-validator 패키지가 필요
  • poetry add pydantic-settings

가상환경 버전 변경하기

  • 현재 환경 확인: poetry env info
  • 현재 가상환경 삭제: poetry env remove 3.9(3.9버전일 경우)
    • 전체 삭제: poetry env remove --all
  • poetry run python --version: 현재 사용중인 버전 확인
  • poetry install: 의존성 설치
    • pyproject.toml 안에 있는 FastAPI, Tortoise-ORM 등 전부 새 버전 환경에 맞게 설치됨.

.env

  • postgres://[사용자이름]:[비밀번호]@[호스트주소]:[포트번호]/[데이터베이스이름]
    • ex. DATABASE_URL=postgres://postgres:1234@localhost:5432/fastapi_db
docker run --name postgres \
    -e POSTGRES_USER=postgres \
    -e POSTGRES_PASSWORD=postgres \
    -e POSTGRES_DB=fastapi_db \
    -p 5432:5432 -d postgres

프로젝트를 하면서 알게 된 것

from_attributes = True

  • ORM 객체(모델 인스턴스)를 Pydantic 모델로 직렬화할 수 있게 하는 옵션
    • v1의 orm_mode = True 와 동일한 기능
      - “모델의 .id, .title, .content 같은 속성(attribute) 을 읽어도 된다”
      - PostSchema.model_validate(post) , ()안에 dict값만 가능하지만 orm객체도 가능하게 함
  • from_attributes = True
    • 없으면 PostSchema.model_validate(post) 이거 실행하면 오류 발생
      • dict가 아니라 “SQLAlchemy ORM 객체”를 줬기 때문에 Pydantic이 값을 추출 불가라는 것

라이브러리 설치 방법

  • poetry add passlib[bcrypt]
    • 이렇게 하면 sh의 쉘 문법 때문에 passlib[bcrypt]를 “파일 패턴”으로 착각하고 오류를 냄
  • poetry add 'passlib[bcrypt]'
    • 따옴표로 감싸면 문제 해결

Tortoise-ORM

# app/models/user
from tortoise import fields
from tortoise.models import Model

class User(Model):
    id = fields.IntField(pk=True)
    username = fields.CharField(50, unique=True)
    email = fields.CharField(100, unique=True)
    password = fields.CharField(255)
    created_at = fields.DatetimeField(auto_now_add=True)

    def __str__(self):
        return self.username

# app/repositories/user_repo.py
from app.models.user import User

class UserRepository:
    @staticmethod
    async def create_user(username: str, email: str, password: str):
        return await User.create(username=username, email=email, password=password)

    @staticmethod
    async def get_by_username(username: str):
        return await User.get_or_none(username=username)
  • User
    • 데이터베이스의 users 테이블과 직접 연결된 Tortoise-ORM 모델 클래스
  • class UserRepository:
    • 이 클래스는 데이터베이스에 접근하는 로직만 담당하는 계층
    • "비즈니스 로직”(service)과 “DB 조작 로직”(repository)을 분리하기 위해 사용
  • @staticmethod
    • 인스턴스를 만들지 않고 바로 호출할 수 있도록 하는 정적 메서드
    • UserRepository() 객체를 만들지 않아도 호출 가능
      • await UserRepository.create_user(...) 가능
  • return await User.create(username=username, email=email, password=password)
    • User.create() : Tortoise-ORM의 비동기 DB 삽입 함수
    • User.get_or_none
    • DB에 새로운 유저 레코드를 추가하고, 생성된 User 객체를 반환
    • await 키워드는 비동기 I/O(즉, DB 작업이 완료될 때까지 기다림)를 의미

app/core/security.py

  • 비밀번호 해싱과 JWT 토큰 생성을 담당하는 핵심 인증 유틸리티 모듈
    • 🔒 비밀번호 해싱 (get_password_hash)
    • ✅ 비밀번호 검증 (verify_password)
    • 🎫 JWT 액세스 토큰 생성 (create_access_token)
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password):
    return pwd_context.hash(password)

def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()
    expire = datetime.now() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
함수역할사용 시점
get_password_hash()비밀번호를 bcrypt로 해싱회원가입
verify_password()입력 비밀번호와 저장된 해시 비교로그인
create_access_token()JWT 토큰 발급로그인 성공 시
  • pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
    • CryptContext
      • passlib 라이브러리의 객체로, 비밀번호를 안전하게 암호화/검증하는 도구
    • schemes=["bcrypt"]:
      • bcrypt 알고리즘을 사용하겠다는 뜻 (현재 가장 널리 쓰이는 안전한 해싱 방식 중 하나)
    • deprecated="auto":
      • 이전 버전의 해싱 알고리즘을 자동으로 “낡은 방식”으로 처리하도록 지정
      • 나중에 bcrypt에서 다른 알고리즘으로 교체할 때도 자연스럽게 마이그레이션 가능
def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)
  • 사용자가 로그인할 때 입력한 평문 비밀번호(plain_password)를
    • DB에 저장된 암호화된 비밀번호(hashed_password)와 비교함, 내부적으로 bcrypt 검증이 일어남
      • 즉, 평문을 복호화하지 않고, 다시 같은 방식으로 해싱한 뒤 결과를 비교
      • 일치: True / 불일치: False
def get_password_hash(password):
    return pwd_context.hash(password)
  • 회원가입 시 평문 비밀번호를 해싱하여 DB에 저장하기 위한 함수
  • 같은 문자열이라도 매번 랜덤한 salt 가 포함되어 해시 결과가 다르게 나옴
def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()
    expire = datetime.now() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
  • 사용자가 로그인 성공 시 발급받는 JWT 토큰을 생성
    • to_encode = data.copy() : 입력 데이터 복사
    • expire = datetime.now() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
      • expires_delta가 직접 주어지면 그걸 쓰고, 없으면 .env에서 설정한 기본값 사용.
    • to_encode.update({"exp": expire}) : payload에 만료시간 추가
    • jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
      • SECRET_KEY로 서명하여 안전하게 암호화된 JWT 문자열을 생성

app/core/dependencies.py

  • JWT 토큰을 해석해 현재 로그인한 사용자 정보를 얻는 함수
    • “현재 사용자 가져오기(get_current_user)” 의존성
from fastapi import Depends, HTTPException, status
from jose import jwt, JWTError
from app.core.config import settings
from app.repositories.user_repo import UserRepository

async def get_current_user(token: str):
    try:
        payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise HTTPException(status_code=401, detail="Invalid token")
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

    user = await UserRepository.get_by_username(username)
    if not user:
        raise HTTPException(status_code=401, detail="User not found")
    return user
  • async def get_current_user(token: str):
    • 매개변수로 token(JWT 액세스 토큰 문자열)을 받음
      • 이 토큰은 보통 HTTP 요청의 Authorization 헤더에서 전달
      • ex: "Bearer eyJhbGciOi..."
  • payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
    • jwt.decode() : 토큰을 해석(decode) 하여 안에 들어있는 정보를 추출합니다.
    • settings.SECRET_KEY : 토큰을 서명할 때 사용된 비밀키 (발급 시와 같아야 함).
    • settings.ALGORITHM : 암호화 알고리즘 (예: "HS256").
      • 디코드 성공하면,
        • "sub": "kihoon", # username
        • "exp": 1730000000 # 만료 시간 (timestamp)
  • username: str = payload.get("sub")
    • JWT 토큰 안에는 보통 "sub" 키에 사용자 식별자(username)를 저장
except JWTError:
    raise HTTPException(status_code=401, detail="Invalid token")
  • 비밀키가 다르거나, 토큰이 만료되었거나, 형식이 잘못된 경우 JWTError 가 발생
user = await UserRepository.get_by_username(username)
if not user:
    raise HTTPException(status_code=401, detail="User not found")
return user
  • Tortoise ORM을 사용하는 UserRepository 클래스에서 username
    • 실제 DB의 사용자 정보를 가져옵니다.
  • DB에 사용자가 존재하지 않으면 → 또다시 401 오류 발생.
  • 정상적으로 찾으면 user 객체(모델 인스턴스)를 반환

app/api/v1/diary.py

from fastapi import APIRouter, Depends, HTTPException
from app.core.dependencies import get_current_user
from app.models.diary import Diary

router = APIRouter(prefix="/diary", tags=["Diary"])

@router.put("/{diary_id}")
async def update_diary(diary_id: int, content: str, current_user=Depends(get_current_user)):
    diary = await Diary.get_or_none(id=diary_id)
    if not diary:
        raise HTTPException(status_code=404, detail="Diary not found")

    if diary.user_id != current_user.id:
        raise HTTPException(status_code=403, detail="Not authorized")

    diary.content = content
    await diary.save()
    return {"message": "Updated successfully"}
  • 로그인한 사용자가 자신의 일기만 수정할 수 있게 하는 권한 처리 포함 CRUD
  • Depends(get_current_user)
    • JWT 토큰을 통해 현재 로그인한 사용자 정보를 가져오는 의존성 주입.
  • Diary
    • Tortoise ORM의 일기 모델 클래스, DB의 diary 테이블과 연결됨.
@router.put("/{diary_id}")
async def update_diary(diary_id: int, content: str, current_user=Depends(get_current_user)):
  • diary_id: 수정할 일기의 ID
  • content: 새로 수정할 일기 내용 (쿼리 파라미터나 JSON body로 전달)
  • current_user: Depends(get_current_user)
    • 덕분에 자동으로 로그인한 사용자 정보가 들어옴 (JWT 토큰에서 추출된 User 객체)
diary = await Diary.get_or_none(id=diary_id)
if not diary:
    raise HTTPException(status_code=404, detail="Diary not found")
  • Diary.get_or_none()
    • Tortoise ORM 메서드 → 지정된 id를 가진 일기 레코드를 가져옴. 없으면 None 반환.
      • 없을 경우 404 Not Found 예외 발생.
if diary.user_id != current_user.id:
    raise HTTPException(status_code=403, detail="Not authorized")
  • 일기의 작성자(diary.user_id)와 현재 로그인한 사용자(current_user.id)가 다르면
    • 수정 권한이 없으므로 403 Forbidden 반환.
diary.content = content
await diary.save()
  • await diary.save() : DB에 변경사항 반영 (UPDATE SQL 실행).

등록/로그인

블랙리스트

  • 여기에 로그아웃하면 로그인때 발급받은 토큰을 여기에 넣어서 로그인 못하게 함
profile
안녕하세요.

0개의 댓글