FastAPI로 Python API 만들기: 타입 검증, 의존성 주입, SQLModel·asyncpg, JWT, 배포
이 글의 핵심
FastAPI는 타입 힌트로 요청 검증·직렬화·OpenAPI 문서를 한 번에 얻는 ASGI 기반 Python API 프레임워크입니다. 이 글은 async def와 def를 언제 나눠 써야 하는지, 동기 DB 드라이버가 이벤트 루프를 막는 이유, python-jose·passlib 같은 오래된 튜토리얼 의존성의 함정, BackgroundTasks의 한계까지 예제와 함께 정리합니다.
설치
pip install "fastapi[standard]" # uvicorn + 기타 의존성 포함
# 또는
pip install fastapi uvicorn[standard]
FastAPI 자체는 얇은 계층입니다. HTTP 처리와 라우팅, 미들웨어는 Starlette가, 요청·응답 데이터의 검증과 직렬화는 Pydantic이 맡고, FastAPI는 둘을 함수 시그니처로 연결해 OpenAPI 스키마를 만드는 역할을 합니다. 그래서 에러 메시지를 따라가다 보면 스택 트레이스가 starlette나 pydantic_core에서 끝나는 경우가 많고, 문제를 검색할 때도 이 두 프로젝트의 문서를 같이 보는 편이 빠릅니다. fastapi[standard]는 uvicorn, email-validator, python-multipart(폼·파일 업로드 파싱)와 fastapi CLI를 함께 설치합니다. 최소 설치로 시작했다가 폼 엔드포인트를 추가하는 순간 Form data requires "python-multipart" to be installed. 에러를 만나는 경우가 흔하므로, 특별한 이유가 없다면 standard 옵션으로 설치하는 편이 편합니다.
첫 API
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/items/{item_id}")
async def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
uvicorn main:app --reload
# http://127.0.0.1:8000
# http://127.0.0.1:8000/docs ← Swagger UI
item_id: int는 경로 파라미터로, q: str | None = None은 기본값이 있으므로 선택적 쿼리 파라미터로 해석됩니다. FastAPI는 이름이 경로 템플릿에 있으면 경로, 기본 타입이면 쿼리, Pydantic 모델이면 요청 본문이라는 규칙으로 파라미터 위치를 추론합니다. /items/abc를 호출하면 핸들러는 실행조차 되지 않고 422 응답에 "Input should be a valid integer" 메시지가 담겨 돌아옵니다. --reload는 파일 변경을 감시하는 개발용 옵션이라 운영에서는 빼야 하며, 워커를 여러 개 띄우는 --workers와도 함께 쓸 수 없습니다.
타입 검증
from fastapi import FastAPI, Query
from pydantic import BaseModel, EmailStr
app = FastAPI()
class User(BaseModel):
name: str
email: EmailStr
age: int | None = None
@app.post("/users/")
async def create_user(user: User):
return user
@app.get("/search/")
async def search(q: str = Query(..., min_length=3, max_length=50)):
return {"q": q}
User모델: JSON body 자동 파싱·검증Query(): 쿼리 파라미터 제약
FastAPI가 Django REST Framework의 Serializer나 Flask에서 흔히 쓰는 marshmallow와 근본적으로 다른 지점이 여기입니다 — 검증 로직을 별도 클래스로 다시 작성하는 게 아니라, 함수 시그니처의 타입 힌트 자체가 곧 검증 스키마가 됩니다. age: int | None = None이라고만 써도 문자열 "25"가 들어오면 자동으로 정수로 캐스팅을 시도하고, 캐스팅 불가능하면 422 에러와 함께 어떤 필드가 왜 실패했는지 상세한 JSON을 돌려줍니다. 이 부분이 “Pydantic 덕분에 개발 경험이 좋다”는 평가의 실체이고, 반대로 이 자동 캐스팅을 모르고 있으면 “숫자를 보냈는데 왜 문자열로 들어오지”류의 혼란이 생기기도 합니다.
경로 파라미터
from enum import Enum
class ModelName(str, Enum):
alexnet = "alexnet"
resnet = "resnet"
lenet = "lenet"
@app.get("/models/{model_name}")
async def get_model(model_name: ModelName):
return {"model_name": model_name, "message": f"Model {model_name.value}"}
Enum이면 OpenAPI에 enum 값이 자동으로 표시되고, 목록에 없는 값이 들어오면 422로 거부됩니다. str을 함께 상속하는 이유는 JSON 응답과 비교 연산에서 멤버가 문자열처럼 동작하게 하기 위해서입니다. 경로 파라미터에서 주의할 점은 라우트 선언 순서입니다. /models/{model_name}을 /models/latest보다 먼저 선언하면 latest가 model_name으로 매칭되어 고정 경로 핸들러에 도달하지 못합니다. 고정 경로는 항상 파라미터 경로보다 위에 둡니다.
의존성 주입
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
# 토큰 검증 로직
if token != "secret":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"username": "johndoe"}
@app.get("/users/me")
async def read_users_me(current_user: dict = Depends(get_current_user)):
return current_user
Depends()로 재사용 가능한 의존성 정의. DB 세션·인증·설정 주입에 활용. 이 패턴이 실전에서 진짜 빛을 발하는 지점은 테스트입니다 — 위 get_current_user처럼 실제 토큰 검증 로직을 함수 하나로 캡슐화해두면, 테스트 코드에서 app.dependency_overrides[get_current_user] = lambda: {"username": "test"}로 손쉽게 목(mock) 유저로 갈아끼울 수 있습니다. 인증 로직을 매 엔드포인트 안에 직접 박아 넣었다면 이런 치환이 불가능하고, 테스트할 때마다 진짜 토큰을 발급받아야 하는 번거로움이 생깁니다.
의존성은 요청 단위로 캐시된다는 점도 알아 두면 좋습니다. 한 요청 안에서 여러 의존성이 같은 get_db를 요구해도 기본적으로 한 번만 호출되고 결과가 공유됩니다(Depends(get_db, use_cache=False)로 끌 수 있습니다). 또 yield를 쓰는 의존성은 yield 뒤의 코드가 정리 단계로 실행되므로, DB 세션을 열고 닫는 로직을 한곳에 둘 수 있습니다. 흔한 실수는 Depends(get_current_user())처럼 함수를 호출한 결과를 넘기는 것입니다. Depends에는 호출 가능한 객체 자체를 넘겨야 하며, 괄호를 붙이면 앱 로딩 시점에 함수가 실행되어 엉뚱한 에러가 납니다.
데이터베이스 (SQLModel)
pip install sqlmodel psycopg2-binary
from sqlmodel import Field, Session, SQLModel, create_engine, select
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
secret_name: str
age: int | None = None
DATABASE_URL = "postgresql://user:pass@localhost/db"
engine = create_engine(DATABASE_URL)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
# ⚠️ @app.on_event("startup")는 FastAPI 0.93+에서 deprecated 되었습니다.
# 새 프로젝트는 lifespan 컨텍스트 매니저를 씁니다:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables() # startup
yield
# shutdown 로직은 yield 다음에 작성
app = FastAPI(lifespan=lifespan)
@app.post("/heroes/")
def create_hero(hero: Hero):
with Session(engine) as session:
session.add(hero)
session.commit()
session.refresh(hero)
return hero
@app.get("/heroes/")
def read_heroes():
with Session(engine) as session:
heroes = session.exec(select(Hero)).all()
return heroes
SQLModel = SQLAlchemy 2.0 + Pydantic. 같은 클래스를 DB 모델 + API 스키마로 사용.
create_hero/read_heroes가 async def가 아니라 그냥 def인 걸 눈여겨봐야 합니다 — 의도적인 선택입니다. psycopg2는 동기(blocking) 드라이버라서, 이 함수를 async def로 선언하고 그 안에서 블로킹 DB 호출을 하면 이벤트 루프 전체가 그 쿼리가 끝날 때까지 멈춰버려 다른 모든 요청 처리가 지연됩니다. FastAPI는 일반 def 엔드포인트를 자동으로 별도 스레드풀에서 실행해주기 때문에, 동기 드라이버를 쓴다면 오히려 async를 붙이지 않는 게 맞는 선택입니다. “무조건 async를 붙이면 빠르다”는 흔한 오해가 실제로는 정반대의 결과(이벤트 루프 블로킹)를 낳을 수 있다는 걸 보여주는 대표적인 예입니다.
반대로 def 쪽에도 한계가 있습니다. 일반 def 엔드포인트는 AnyIO 스레드풀에서 실행되는데, 이 풀의 기본 크기는 40개입니다. 느린 쿼리가 몰려 40개 스레드가 모두 대기 중이면 41번째 요청은 CPU가 놀고 있어도 스레드가 빌 때까지 기다립니다. 부하 테스트에서 응답 시간이 어느 지점부터 계단식으로 늘어난다면 이 한도를 의심해 볼 만합니다.
이 예제의 또 다른 단순화는 Hero 테이블 모델을 요청 본문으로 그대로 받는다는 점입니다. 클라이언트가 id를 넣어 보내면 그 값으로 INSERT가 시도되고, 나중에 password_hash 같은 필드가 추가되면 응답에도 그대로 노출됩니다. SQLModel 공식 튜토리얼도 실제 코드에서는 HeroCreate(입력), HeroPublic(출력)처럼 table=True가 아닌 모델을 따로 두고, response_model로 응답 필드를 제한하도록 권합니다. create_all도 테이블이 없을 때만 만들 뿐 컬럼 변경은 반영하지 않으므로, 스키마가 바뀌기 시작하면 아래 Alembic 섹션처럼 마이그레이션 도구로 넘어가야 합니다.
async DB (asyncpg)
pip install databases[asyncpg] asyncpg
from contextlib import asynccontextmanager
from databases import Database
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/db"
database = Database(DATABASE_URL)
@asynccontextmanager
async def lifespan(app: FastAPI):
await database.connect() # startup
yield
await database.disconnect() # shutdown
app = FastAPI(lifespan=lifespan)
@app.get("/users/")
async def read_users():
query = "SELECT * FROM users"
rows = await database.fetch_all(query)
return rows
databases 패키지는 raw SQL을 비동기로 실행하기 쉬워서 예제에 자주 등장하지만, 최근에는 업데이트가 뜸한 편입니다. 새 프로젝트라면 SQLAlchemy 2.0의 create_async_engine과 AsyncSession(드라이버는 그대로 asyncpg)을 쓰는 구성이 더 일반적이고, SQLModel도 이 위에서 비동기로 쓸 수 있습니다. 어느 쪽이든 SELECT *를 그대로 반환하면 테이블에 컬럼이 추가될 때 API 응답이 함께 바뀌므로, response_model로 응답 스키마를 고정해 두는 편이 안전합니다.
비동기 드라이버로 바꾼 뒤 흔히 만나는 에러가 sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called입니다. AsyncSession에서 가져온 객체의 지연 로딩 관계 속성(hero.team 같은)에 접근하면 SQLAlchemy가 동기 방식으로 추가 쿼리를 날리려다 실패하는 것으로, selectinload()로 미리 로딩하거나 await session.refresh(obj, ["team"])처럼 명시적으로 불러와야 합니다.
인증 (JWT)
pip install python-jose[cryptography] passlib[bcrypt]
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
SECRET_KEY = "secret" # ⚠️ 예시용입니다 — 실제로는 os.environ["JWT_SECRET"]처럼
# 환경변수에서 읽어야 합니다. 코드에 하드코딩된 채
# 커밋되면 저장소 히스토리에 영구히 남고, 유출 시
# 모든 발급된 토큰을 위조할 수 있게 됩니다.
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain, hashed):
return pwd_context.verify(plain, hashed)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
# 유저 검증 (DB에서 조회)
user = fake_db.get(form_data.username)
if not user or not verify_password(form_data.password, user["hashed_password"]):
raise HTTPException(status_code=400, detail="Incorrect username or password")
access_token = create_access_token({"sub": user["username"]})
return {"access_token": access_token, "token_type": "bearer"}
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=401)
except JWTError:
raise HTTPException(status_code=401)
return {"username": username}
@app.get("/users/me")
async def read_users_me(current_user: dict = Depends(get_current_user)):
return current_user
이 코드는 오랫동안 FastAPI 공식 튜토리얼에 실렸던 구성이지만, 지금 그대로 쓰기에는 의존성 쪽에 주의할 점이 있습니다. python-jose와 passlib은 모두 수년간 릴리스가 거의 없었고, FastAPI 문서도 JWT는 PyJWT, 비밀번호 해싱은 pwdlib로 예제를 바꿨습니다. 특히 passlib은 bcrypt 4.1 이상과 함께 쓰면 시작할 때 (trapped) error reading bcrypt version과 AttributeError: module 'bcrypt' has no attribute '__about__' 로그가 찍힙니다. PyJWT로 옮기면 코드는 거의 같고, jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])에서 잡는 예외만 jwt.InvalidTokenError로 바뀝니다.
OAuth2PasswordRequestForm은 JSON이 아니라 application/x-www-form-urlencoded 형식의 username, password 필드를 받습니다. 프런트엔드에서 JSON으로 보내면 422가 나고 "Field required" 에러가 뜨는데, 이것은 OAuth2 password flow 규격을 따르기 때문입니다. 이 형식을 쓰는 대가로 /docs의 Authorize 버튼으로 바로 로그인해 보호된 엔드포인트를 테스트할 수 있습니다. 또 401 응답에는 headers={"WWW-Authenticate": "Bearer"}를 붙이는 것이 규격에 맞습니다.
CORS
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
CORS 미들웨어는 미들웨어 등록 순서의 영향을 받습니다. 인증 미들웨어나 예외 처리 미들웨어가 CORS보다 바깥에서 먼저 응답을 만들어 버리면 그 응답에는 Access-Control-Allow-Origin 헤더가 붙지 않아, 실제 원인은 401이나 500인데 브라우저 콘솔에는 CORS 에러로만 보입니다. “CORS 설정을 했는데도 CORS 에러가 난다”면 브라우저 개발자 도구의 네트워크 탭에서 실제 상태 코드부터 확인하는 것이 빠릅니다. allow_origins의 값은 http://localhost:3000처럼 스킴과 포트까지 정확히 일치해야 하고, 끝에 /를 붙이면 매칭되지 않습니다.
백그라운드 태스크
from fastapi import BackgroundTasks
def send_email(email: str, message: str):
# 이메일 전송 로직
print(f"Sending email to {email}: {message}")
@app.post("/send-notification/")
async def send_notification(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(send_email, email, "Notification!")
return {"message": "Notification sent in the background"}
BackgroundTasks를 진짜 백그라운드 작업 큐로 착각하면 나중에 곤란해집니다. 이건 어디까지나 “응답을 클라이언트에게 보낸 직후, 같은 프로세스 안에서 실행”되는 수준의 기능이라, 서버가 재시작되면 대기 중이던 작업은 그냥 사라지고, 작업 안에서 예외가 나도 재시도 로직 같은 건 전혀 없습니다. 이메일 전송 정도의 가벼운 fire-and-forget 작업엔 충분하지만, “결제 웹훅 처리”나 “실패 시 반드시 재시도해야 하는 작업”이라면 Celery나 RQ 같은 실제 작업 큐로 넘겨야 합니다. 롤링 배포에서 이전 컨테이너가 종료 신호를 받는 순간 아직 끝나지 않은 태스크가 함께 사라지는데, 에러 로그조차 남지 않아 한참 뒤에 데이터 불일치로 발견되는 것이 이 패턴의 전형적인 실패 방식입니다. “간단해 보인다”는 이유로 프로덕션 신뢰성이 중요한 작업까지 이 기능에 맡기지 않는 게 좋습니다.
파일 업로드
from fastapi import File, UploadFile
@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile = File(...)):
contents = await file.read()
return {"filename": file.filename, "size": len(contents)}
file.read()로 파일 전체를 메모리에 한 번에 올리는 이 패턴은 작은 파일에는 문제없지만, 사용자가 수백 MB짜리 동영상을 업로드하면 그 크기만큼 서버 메모리를 그대로 소비합니다 — 동시 업로드 요청이 몇 개만 겹쳐도 메모리 부족으로 워커가 죽을 수 있습니다. UploadFile은 내부적으로 SpooledTemporaryFile을 쓰기 때문에 file.read(chunk_size)로 청크 단위로 읽어 디스크나 스토리지로 스트리밍하는 방식이 대용량 업로드에는 훨씬 안전합니다.
WebSocket
from fastapi import WebSocket, WebSocketDisconnect
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
try:
while True:
data = await websocket.receive_text()
await websocket.send_text(f"Message: {data}")
except WebSocketDisconnect:
# 클라이언트가 탭을 닫거나 새로고침하면 receive_text()가
# WebSocketDisconnect를 던집니다. try/except 없이 두면 사용자가
# 페이지를 나갈 때마다 서버 로그에 매번 처리되지 않은 예외 스택
# 트레이스가 찍힙니다 — 정상적인 연결 종료인데 에러처럼 보이는
# 흔한 오탐(false alarm) 원인입니다.
pass
테스트
from fastapi.testclient import TestClient
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}
def test_create_user():
response = client.post("/users/", json={"name": "Alice", "email": "[email protected]"})
assert response.status_code == 200
TestClient가 실제 uvicorn 서버를 띄우지 않고도 ASGI 애플리케이션을 직접 호출해 테스트할 수 있는 이유는, Starlette이 제공하는 httpx 기반 클라이언트가 실제 네트워크 소켓을 거치지 않고 요청/응답을 인메모리로 처리하기 때문입니다. 덕분에 테스트가 실제 HTTP 요청보다 훨씬 빠르지만, 반대로 “네트워크 계층에서만 발생하는 문제”(타임아웃, 실제 TCP 연결 실패 등)는 이 방식으로는 재현할 수 없다는 한계도 같이 딸려옵니다 — 통합 테스트 스위트에 실제 서버를 띄운 E2E 테스트를 별도로 병행하는 이유가 여기 있습니다.
Docker
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://user:pass@db/dbname
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: dbname
Alembic (Migration)
pip install alembic
alembic init alembic
# alembic/env.py
from sqlmodel import SQLModel
from myapp.models import Hero # 모든 모델 import
target_metadata = SQLModel.metadata
alembic revision --autogenerate -m "Create hero table"
alembic upgrade head
위 Dockerfile은 컨테이너당 uvicorn 프로세스 하나를 띄웁니다. Kubernetes처럼 오케스트레이터가 컨테이너 수로 확장하는 환경에서는 이 방식이 오히려 권장되고, 단일 서버라면 --workers N을 추가합니다. 리버스 프록시(Nginx, 로드 밸런서) 뒤에서 실행한다면 --proxy-headers와 --forwarded-allow-ips를 지정해야 request.url의 스킴과 클라이언트 IP가 프록시 기준으로 잘못 찍히지 않습니다. compose의 depends_on은 DB 컨테이너가 시작되기만 기다릴 뿐 접속 가능해질 때까지 기다리지 않으므로, 앱이 먼저 떠서 connection refused로 죽는다면 healthcheck와 condition: service_healthy를 함께 설정해야 합니다.
프로덕션 배포
# Gunicorn + Uvicorn workers
# (uvicorn 0.30부터 uvicorn.workers는 deprecated, 별도 패키지 uvicorn-worker 권장)
pip install gunicorn uvicorn-worker
gunicorn main:app -w 4 -k uvicorn_worker.UvicornWorker --bind 0.0.0.0:8000
여기서 워커 개수(-w 4)를 어떻게 정할지가 실무에서 자주 잘못 이해되는 부분입니다. FastAPI 앱이 CPU 바운드(무거운 연산)인지 I/O 바운드(DB·외부 API 호출 대기가 대부분)인지에 따라 최적 워커 수가 완전히 달라지는데, 흔히 쓰는 “CPU 코어 수 × 2 + 1” 공식은 동기 워커 모델(Gunicorn 기본)을 전제로 한 값이라 async 위주의 FastAPI 워크로드에는 그대로 적용하면 워커가 과도하게 많아질 수 있습니다. 실제 트래픽 패턴으로 부하 테스트를 해보고 워커 수를 조정하는 게, 공식만 믿고 설정하는 것보다 훨씬 안전합니다.
또는 Railway·Fly.io·Render에 Dockerfile 배포.
트러블슈팅
RuntimeError: no running event loop / asyncio.run() cannot be called from a running event loop
- 두 에러는 원인이 반대입니다. 뒤쪽은 이미 이벤트 루프 위에서 도는
async def엔드포인트 안에서asyncio.run()을 호출했을 때 납니다. 그냥await하면 됩니다. - 앞쪽은 스레드풀에서 실행되는 일반
def엔드포인트나 별도 스레드에서asyncio.get_running_loop(),asyncio.create_task()를 호출할 때 납니다. 이 경우 함수를async def로 바꾸거나, 동기 코드에서 비동기 함수를 불러야 한다면anyio.from_thread.run()을 씁니다.
Pydantic validation 에러 (422)
- 응답의
detail[].loc가 실패 위치를 알려 줍니다.["body", "email"]이면 본문,["query", "q"]이면 쿼리 파라미터입니다. - 본문을 보냈는데
["query", ...]로 나온다면 파라미터를 Pydantic 모델이 아닌 기본 타입으로 선언했기 때문입니다. 단일 값을 본문으로 받으려면Body()를 씁니다.
DB 연결 끊김
- connection pool 설정 (
create_engine(..., pool_pre_ping=True)): 방화벽이나 DB의 유휴 연결 정리로 끊긴 연결을 사용 전에 확인합니다. - 엔진은 앱 전체에서 하나만 만들고, 연결 생성·정리는
lifespan에 둡니다.
CORS 에러
allow_origins=["*"]와allow_credentials=True는 함께 쓰지 말고, 명시적인 origin 목록을 둡니다. 브라우저 규칙상 credential 요청에는*응답이 허용되지 않습니다.
체크리스트
- FastAPI + uvicorn 설치
- Pydantic 모델 정의
-
/docsOpenAPI 확인 - SQLModel 또는 SQLAlchemy 연동
- JWT 인증 구현
-
pytest+TestClient테스트 - Alembic migration
- Docker + docker-compose
- Gunicorn 프로덕션 배포
마무리
FastAPI는 “타입 힌트만 잘 쓰면 프레임워크가 모든 boilerplate를 해결한다”는 철학의 Python 웹 프레임워크입니다. Django·Flask가 지배하던 Python 백엔드 생태계에 async·타입 안전성·자동 문서를 가져왔으며, 신규 API 프로젝트에서 널리 선택되고 있습니다. Pydantic v2 + SQLModel + uv 조합으로 타입 기반 개발 경험이 좋아졌고, I/O 위주 API라면 async 덕분에 충분한 처리량을 냅니다. 다만 CPU 연산이 많은 서비스라면 Python 인터프리터 자체의 한계는 그대로라, Go·Rust 같은 컴파일 언어와 같은 성능을 기대하기는 어렵습니다. 관리자 화면·인증·ORM이 모두 필요한 전형적인 웹 서비스라면 Django가 여전히 더 적은 코드로 끝나는 경우가 많고, 이미 안정적으로 돌아가는 Flask API를 옮기는 것도 검증 스키마와 문서가 실제로 부족할 때만 이득입니다. 새로 시작하는 JSON API이면서 타입 기반 검증과 문서를 원한다면 FastAPI가 좋은 출발점입니다.
자주 묻는 질문 (FAQ)
Q. RuntimeError: no running event loop 오류는 왜 나나요?
A. 이벤트 루프가 없는 스레드에서 루프를 찾을 때 납니다. FastAPI의 일반 def 엔드포인트는 스레드풀에서 실행되므로, 그 안에서 asyncio.create_task()나 asyncio.get_running_loop()를 호출하면 이 에러가 납니다. 비동기 코드를 써야 한다면 엔드포인트를 async def로 바꾸고 await하세요. 반대로 async def 안에서 asyncio.run()을 호출하면 cannot be called from a running event loop 에러가 납니다. 블로킹 라이브러리를 쓰는 함수는 굳이 async로 선언하지 말고 일반 def로 두면 FastAPI가 스레드풀에서 실행해 줍니다.