pets를 파이썬 클래스 Pet으로 맵핑하여 아래와 같은 동작을 수행할 수 있음pet = Pet() 이라고 가정하고,pet.type -> SQL pets 테이블의 type 컬럼에 접근 가능pet.owner -> SQL pets 테이블의 owner 컬럼에 접근 가능Django-ORM, SQLAlchemy, TortoiseORM, Peewee 가 있음Django-ORM : 장고 프레임워크에 내장된 ORM으로 FastAPI 개발시 사용할 일이 없음SQLAlchemy : 파이썬의 가장 표준적인 ORMPeewee : 가볍고 간단한 기능을 제공하는 ORM으로 sqlite / mysql / postgresql / cockroachdb 만 지원함Tortoise ORM : 등장한지 얼마 안된 ORM으로 비동기 처리에 적합SQLAlchemy 튜토리얼 한국어 번역 문서 (비공식) : 링크create_enginefrom sqlalchemy import create_engine
SQLALCHEMY_DATABASE_URL = "mysql://user:password@postgresserver/db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, pool_size=15, max_overflow=0, encoding='utf8', convert_unicode=True
)
create_enigne 객체에 각종 설정 값을 인자로 넣어 초기화하여 이를 수행한다.connect_args={"check_same_thread": False} 옵션을 켜줘야함 sessionmakerfrom sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "mysql://user:password@postgresserver/db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, pool_size=15, max_overflow=0, encoding='utf8', convert_unicode=True
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
create_eninge을 초기화하여 DB 연결에 대한 설정을 마쳤으면, 실질적인 통신을 할 세션에 대한 정의를 해야한다. sessionmaker라는 객체를 초기화하여 이를 수행한다.
SessionLocal로 이름을 지정한 이유는 추후에 사용할 Session과 분리하기 위함
여기서 선언한 SessionLocal은 그 자체가 데이터베이스 세션이 아니다.
이는 설정값을 넘겨받아 선언된 새로운 클래스일 뿐이다.
이렇게 설정값을 넘겨받아 새로 선언된 SessionLocal클래스의 인스턴스 객체가 생성되면 그 인스턴스 객체가 실질적인 데이터베이스 세션 역할을 수행한다.
(* sessionmaker의 docstring을 읽어보면 자세히 이해가능)

from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
items = relationship("Item", back_populates="owner")
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True)
description = Column(String, index=True)
owner_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="items")
Base를 상속받는다.Base Class 에 대해 설정을 해두면 여러가지 반복된 코드를 줄이거나, 모델 클래스 정의의 일부분을 자동화 할 수 있다.import re
from datetime import datetime
from sqlalchemy import Column, DateTime
from sqlalchemy.ext.declarative import as_declarative, declared_attr
from sqlalchemy.ext.declarative import declarative_base
@as_declarative()
class Base:
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
__name__: str
# CamelCase의 클래스 이름으로부터 snake_case의 테이블 네임 자동 생성
@declared_attr
def __tablename__(cls) -> str:
return re.sub(r'(?<!^)(?=[A-Z])', '_', cls.__name__).lower()
created_at 과 updated_at 이라는 컬럼을 자동으로 생성해주고,orm_mode 설정Config 서브 클래스를 선언하여 각종 설정값을 수정할 수 있다.ORM의 객체이지, dict와 같은 파이썬의 내장 자료형이 아니다.json 타입으로 API를 통해 반환해주기 위해서는 별도의 처리가 핋요하다.orm_mode = True 설정이다. .을 찍어 객체의 속성에 접근할 수 있다.dict 자료형에서 id = data.get('id') 이렇게 접근해야 하는것을 단순히 data.id 이렇게 접근할 수 있는 것이다.lazy loading을 기본값으로 한다. orm_mode = True로 설정해둔 Pydantic 모델을 response_model로 설정하지 않았을 경우, 관계 테이블의 데이터는 표시되지 않는다. (어찌보면 당연)def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
get_db는 호출될 때마다 db라는 변수를 생성해내는데, 이 db는 SessionLocal클래스의 인스턴스인 것이다.close()된다.from sqlalchemy.orm import Session과 SessionLocal은 어떻게 다른가?create_engine)을 바인딩해 정의한 sessionmaker로 SessionLocal을 만들었다. \SessionLocal을 호출해 db connection 객체를 매 커넥션마다 만들어내는 get_db함수를 정의했다. \get_db함수는 db 커넥션을 필요로 하는 엔드포인트 함수에 dependency로 주입되어 db connection에 대한 request가 있을 때 호출되어 request가 종료되면 close 된다.SessionLocal이 아닌 Session으로 하게 된다.\
SessionLocal로 지정해두어도 엔드포인트는 문제없이 작동한다.\Session 으로 지정해둠으로써 Session에서 제공하는 메서드에 대해 자동완성 기능을 제공 받을 수 있다. \
sessionmaker로 초기화되는 SessionLocal은 create_engine을 바인딩해서 Session을 랩핑한 클래스인데, \SessionLocal이 생성하는 세션은 sqlalchemy의 Session 객체 이다. 다만 sessionmaker함수로 한번 감싸져있기 때문에 IDE가 이것이 Session임을 인식 못할 뿐이다.user = await db.query(User).first() <- 이렇게 쓸 수 없다는 뜻from fastapi import Request, Response, FastAPI
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "mysql://user:password@postgresserver/db"
app = FastAPI()
engine = create_engine(
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, pool_size=15, max_overflow=0, encoding='utf8', convert_unicode=True
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@app.middleware("http")
async def db_session_middleware(request: Request, call_next):
response = Response("Internal server error", status_code=500)
try:
request.state.db = SessionLocal()
response = await call_next(request)
finally:
request.state.db.close()
return response
# Dependency
def get_db(request: Request):
return request.state.db

하나의 DB Session으로 2개의 커넥션을 담당하는 엔드포인트 함수가 있었음
프론트 페이지 구현 때문에 Create 함수의 반환값으로 성공/실패 여부가 아닌, Create 작업이 반영된 DB를 Read 하는 것으로 구성함
Create와 Read 모두 하나의 Session으로 처리하는 꼴로 구성됨
개발 당시 원하던 형태는 Create의 결과가 Read에 반영되어 Read의 결과 값이 반환되도록 하는 거였지만, Create 하기 전의 결과만 계속 반환되는 문제 발생
create 함수가 commit 되기 전에 get 함수가 실행되는 것인지(너무 빨라서) 중간에 wait을 주거나 하지 않으면 계속 오류 발생
찾아보니 격리 수준에 따라 create 함수가 db connection을 어느 수준까지 다뤘느냐에 따라 get 함수의 결과가 달라질 수 있다는 내용 발견
create 함수가 커밋 되던 말던 수정사항이 생기면 무조건 읽어올 수 있도록 db의 격리수준을 READ UNCOMMITED 로 변경
문제 해결 되었지만 READ UNCOMMITED 격리수준은 매우 불안정한 격리 수준으로 가급적 사용해서는 안되는 것
create 엔드포인트 함수는 create에 대해서만 책임지고, 반영된 수정사항이 필요했으면 프론트에서 리다이렉트 하는게 더 좋지 않았을까?!APIRouter 클래스를 통해 복잡한 API 엔드포인트를 정리할 수 있다. app = FastAPI() 이런식으로 정의하는데, router = APIRouter() 이런식으로 똑같이 쓸 수 있는 미니 FastAPI 객체 인 것이다.
APIRouter() 클래스에 인자를 넣어 전역 dependency 를 주입할 수 있다.from fastapi import APIRouter, Depends, HTTPException, Header
async def get_token_header(x_token: str = Header(...)):
if x_token != "fake-super-secret-token":
raise HTTPException(status_code=400, detail="X-Token header invalid")
router = APIRouter(
prefix="/items",
tags=["items"],
dependencies=[Depends(get_token_header)],
responses={404: {"description": "Not found"}},
)
@router.get("")
async def create_item(item_id: str):
...
@router.get("")
async def read_items():
...
@router.get("/{item_id}")
async def read_item(item_id: str):
...
이렇게 하면 각 엔드포인트 함수에 get_token_header라는 의존성을 주입하지 않더라도 전역적으로 의존성을 주입할 수 있다.
위의 예시의 경우 모든 엔드포인트에서 헤더의 토큰 여부를 체크하게 된다.
전역 Dependency 를 지정하였어도, 각 엔드포인트 함수별로의 Dependency도 추가로 지정할 수 있다.
! 개별 함수에 Dependency를 지정하는 것처럼 tags와 response를 개별로 한번 더 지정할 수 있다.
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def write_notification(email: str, message=""):
with open("log.txt", mode="w") as email_file:
content = f"notification for {email}: {message}"
email_file.write(content)
@app.post("/send-notification/{email}")
async def send_notification(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(write_notification, email, message="some notification")
return {"message": "Notification sent in the background"}
from typing import Optional
from fastapi import BackgroundTasks, Depends, FastAPI
app = FastAPI()
def write_log(message: str):
with open("log.txt", mode="a") as log:
log.write(message)
def get_query(background_tasks: BackgroundTasks, q: Optional[str] = None):
if q:
message = f"found query: {q}\n"
background_tasks.add_task(write_log, message)
return q
@app.post("/send-notification/{email}")
async def send_notification(
email: str, background_tasks: BackgroundTasks, q: str = Depends(get_query)
):
message = f"message to {email}\n"
background_tasks.add_task(write_log, message)
return {"message": "Message sent"}
위의 예시나, 아래의 Background 클래스의 코드 예시에서 볼 수 있듯이 FastAPI 내장 백그라운드 작업 모듈은 굉장히 제한적인 기능만 제공한다.

대안으로 사용할 수 있는 2개 패키지가 있다.
| 항목 | FastAPI 내장 모듈 | APScheduler | Celery |
|---|---|---|---|
| 워커 | 쓰레드 | 쓰레드 | 프로세스 |
| 스케쥴링 가능 | X | O | O |
| 사용 난이도 | 쉬움 | 덜쉬움 | 꽤나 복잡 |
| 대용량 처리 능력 | 낮음 | 덜낮음 | 꽤나 높음 |
| GUI 관리 툴 | X | X | O |
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
/static 폴더에 404.html 405.html 파일들을 저장해두면 알아서 서빙한다.
from .main import app
client = TestClient(app)
def test_read_main():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"msg": "Hello World"}
conftest.py 라는 파일을 정의해둠으로써 테스트 함수에 여러 인자를 넘겨줄 수 있다. conftest.py 파일은 pytest를 위한 비품(fixture) 집합소라고 생각 하면 된다.# confest.py
import random
import pytest
@pytest.fixture(scope="module")
def random_endpoint() -> int:
return random.randint(1,100)
confest.py를 위와 같이 정의해두고# test.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
# Check if endpoint is incorrect
def test_get_series_contents_bad_request(random_endpoint: int):
response = client.get(f"/v1/series/{random_endpoint}")
assert response.status_code == 404
test.py(테스트 파일)에서 테스트할 함수에 인자로 confest.py의 인자로 정의해둔 함수를 넣어주면 된다.confest.py의 함수를 test.py로 import 하지 않아도 된다.로그인 토큰에 접목시킨다면 이렇게 할 수 있을 것이다. (FastAPI 공식 Docs의 템플릿 프로젝트 출처)
confest.py

test_items.py

confest.py에서 정의한 superuser_token_header 함수가 test_items.py에서 테스트 될때 작동하여 토큰을 발급받고 이를 헤더에 주입한다.