
구조
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.py | FastAPI 앱 실행, 라우터 등록, 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
- 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
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
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에 사용자가 존재하지 않으면 → 또다시 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 실행).
등록/로그인


블랙리스트
- 여기에 로그아웃하면 로그인때 발급받은 토큰을 여기에 넣어서 로그인 못하게 함