목차
- Before — Flask 시절의 상태
- 프로젝트 구조 설계 — 첫 주에 한 일
- Pydantic으로 요청 검증 갈아엎기
- JWT 인증 — 예상보다 까다로웠던 구간
- 비동기 DB 접근 — SQLAlchemy 2.0 async
- Docker 배포 — 이미지 다이어트
- 운영 중 터진 것들
- 테스트 — 나중에 붙이면 이렇게 고생한다
- 배포 후 성능 튜닝
Before — Flask 시절의 상태
FastAPI REST API 마이그레이션을 시작하기 전, 기존 서버의 상태는 이랬다.
| 항목 | Before (Flask) | After (FastAPI) |
|---|---|---|
| 엔드포인트 수 | 40개 | 40개 (동일) |
| 평균 응답 시간 | 120~180ms | 60~90ms (체감) |
| API 문서 | Postman 컬렉션 수동 관리 | Swagger UI 자동 생성 |
| 요청 검증 | if not request.json.get('email') 수동 |
Pydantic 모델 자동 |
| Docker 이미지 | 1.2GB | 340MB |
| 타입 힌트 | 거의 없음 | 전체 적용 |
Flask 2.x 기반 서버를 2년 넘게 운영하고 있었다. @app.route에 dict를 jsonify로 감싸서 반환하는 전형적인 구조였는데, 엔드포인트가 40개를 넘어가면서 문제가 드러났다. 요청 파라미터 검증 코드가 라우트 함수마다 중복되고, API 문서는 Postman 컬렉션을 수동으로 업데이트하고 있었다. 프론트엔드 팀에서 "이 필드 타입이 뭐냐"고 물어올 때마다 코드를 열어봐야 했다.
FastAPI REST API로의 전환을 결정한 건 새 기능 추가 요청이 들어왔을 때였다. 기존 코드에 엔드포인트 하나 더 붙이려니 검증 로직만 30줄이 필요했다. Pydantic 모델로 처리하면 10줄이면 끝난다는 걸 알고 있었기에, 이참에 전체를 옮기기로 했다.
프로젝트 구조 설계 — 첫 주에 한 일
FastAPI 0.115.x(2026-04 기준 최신은 0.115.8)를 설치하고 프로젝트 뼈대부터 잡았다. Flask에서는 블루프린트로 라우터를 나눴는데, FastAPI는 APIRouter가 그 역할을 한다.
# app/main.py — 진입점
from fastapi import FastAPI
from app.routers import auth, users, items
app = FastAPI(
title="내부 API 서버",
version="2.0.0",
docs_url="/docs", # Swagger UI 경로
redoc_url="/redoc", # ReDoc 경로
)
# 라우터 등록
app.include_router(auth.router, prefix="/api/v2/auth", tags=["인증"])
app.include_router(users.router, prefix="/api/v2/users", tags=["사용자"])
app.include_router(items.router, prefix="/api/v2/items", tags=["아이템"])
디렉토리 구조는 이렇게 잡았다.
project/
├── app/
│ ├── main.py
│ ├── config.py # 환경 변수 관리
│ ├── database.py # SQLAlchemy 세션
│ ├── models/ # DB 모델
│ ├── schemas/ # Pydantic 스키마
│ ├── routers/ # 엔드포인트
│ ├── services/ # 비즈니스 로직
│ └── dependencies/ # 의존성 주입
├── tests/
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
Flask 시절에는 models.py 하나에 모든 모델을 때려넣었다. 이번에는 처음부터 모듈을 분리했다. schemas/와 models/를 나눈 건 의도적인 선택이었는데, Pydantic 스키마(요청/응답 형태)와 SQLAlchemy 모델(DB 테이블)은 역할이 다르기 때문이다. 초반에 이걸 섞어서 쓰다가 순환 참조에 빠진 적이 있어서 분리가 낫다.
Pydantic으로 요청 검증 갈아엎기
기존 Flask 코드에서 가장 지저분했던 부분이 요청 검증이다. 전형적인 패턴이 이랬다.
# Flask 시절 — 수동 검증의 늪
@app.route('/api/users', methods=['POST'])
def create_user():
data = request.get_json()
if not data:
return jsonify({"error": "JSON 필요"}), 400
if not data.get('email'):
return jsonify({"error": "이메일 필수"}), 400
if not isinstance(data.get('age'), int):
return jsonify({"error": "나이는 숫자"}), 400
# ... 이런 게 20줄 넘게 이어짐
FastAPI + Pydantic으로 바꾸면 이 검증 코드가 사라진다.
# app/schemas/user.py — Pydantic v2 기반
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr # 이메일 형식 자동 검증
name: str = Field(min_length=2, max_length=50)
age: int = Field(ge=1, le=150) # 1~150 사이만 허용
class UserResponse(BaseModel):
id: int
email: str
name: str
model_config = {"from_attributes": True} # ORM 모드 활성화
# app/routers/users.py
from fastapi import APIRouter, Depends
from app.schemas.user import UserCreate, UserResponse
router = APIRouter()
@router.post("/", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
# user.email은 이미 검증 완료된 상태
# 잘못된 요청이 들어오면 FastAPI가 422를 자동 반환
...
422 에러와의 첫 만남
Pydantic을 도입하고 처음 겪은 문제가 422 Unprocessable Entity였다. 프론트엔드에서 age를 문자열 "25"로 보내고 있었는데, Flask에서는 그냥 통과시켰던 것이 FastAPI에서는 타입 불일치로 422가 떨어졌다. Pydantic v2는 기본적으로 "25"를 int로 자동 변환(coercion)해주긴 하지만, strict 모드를 켜둔 상태였다.
해결은 간단했다. model_config에서 strict=False(기본값)로 두거나, 프론트엔드에 타입을 맞춰달라고 요청하면 된다. 우리는 후자를 택했다. 느슨한 변환을 허용하면 결국 나중에 또 문제가 된다.
커스텀 에러 응답
FastAPI가 뱉는 기본 422 응답은 형태가 좀 장황하다. 프론트엔드 팀이 "에러 포맷을 통일해달라"고 해서 커스텀 핸들러를 붙였다.
# app/main.py — 에러 핸들러
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
errors = []
for error in exc.errors():
errors.append({
"field": ".".join(str(loc) for loc in error["loc"]),
"message": error["msg"],
})
return JSONResponse(
status_code=422,
content={"success": False, "errors": errors}
)
이렇게 하면 {"success": false, "errors": [{"field": "body.age", "message": "Input should be a valid integer"}]} 형태로 일관된 에러가 나간다.
JWT 인증 — 예상보다 까다로웠던 구간
인증 구현에서 시행착오가 가장 많았다. python-jose 라이브러리를 쓰려고 했는데, 이게 2024년 이후로 유지보수가 거의 안 되고 있었다. PyJWT 2.9.x로 방향을 틀었다(출처: PyJWT GitHub, 2026-04 기준).
토큰 발급과 검증
# app/dependencies/auth.py
import jwt
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
security = HTTPBearer()
SECRET_KEY = "your-secret-key" # 실제로는 환경 변수에서 로드
ALGORITHM = "HS256"
def create_access_token(user_id: int, expires_minutes: int = 30) -> str:
payload = {
"sub": str(user_id),
"exp": datetime.now(timezone.utc) + timedelta(minutes=expires_minutes),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> int:
token = credentials.credentials
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = int(payload["sub"])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="토큰 만료")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
return user_id
Refresh Token 전략
Access Token 만료 시간을 30분으로 잡았는데, 프론트에서 "30분마다 재로그인시키냐"는 항의가 들어왔다. Refresh Token을 도입해야 했다.
Access Token은 짧게(30분), Refresh Token은 길게(7일) 설정하고, Refresh Token은 DB에 저장해서 강제 만료(로그아웃, 비밀번호 변경 시)가 가능하게 했다. Refresh Token을 Redis에 저장하는 방식도 고려했지만, 서버가 한 대라 PostgreSQL 테이블로 충분했다. 규모가 커지면 Redis로 옮기는 게 맞다.
한 가지 헤맨 부분이 있었다. HTTPBearer를 쓰면 Swagger UI에서 자물쇠 아이콘이 자동으로 뜨는데, OAuth2PasswordBearer를 쓰면 로그인 폼까지 제공된다. 처음에 OAuth2PasswordBearer로 구현했다가, 우리 로그인 플로우와 안 맞아서 HTTPBearer로 다시 바꿨다. 이건 공식 문서(출처: FastAPI Security 문서, 2026-04 기준)를 보면 둘 다 나오는데 차이 설명이 부족해서 직접 부딪혀봐야 감이 온다.
비동기 DB 접근 — SQLAlchemy 2.0 async
FastAPI가 async 기반이니 DB도 비동기로 가자는 판단이었다. SQLAlchemy 2.0의 async 세션을 도입했다.
# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=10)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with async_session() as session:
yield session
asyncpg 드라이버를 쓰면 동기 psycopg2 대비 쿼리 처리량이 올라간다. 정확한 벤치마크를 돌리진 않았지만, 동시 요청 50개 기준으로 응답 시간이 체감상 30~40% 줄었다.
expire_on_commit=False도 중요하다. 이걸 안 붙이면 커밋 후에 객체 속성에 접근할 때 또 MissingGreenlet이 터진다. 동기 SQLAlchemy에서는 신경 안 써도 되던 부분이라 async 전환 초기에 자주 걸리는 함정이다.
Docker 배포 — 이미지 다이어트
개발이 어느 정도 끝나고 배포 단계로 넘어갔다. 기존 Flask 서버의 Docker 이미지가 1.2GB였던 건 python:3.11 풀 이미지를 base로 쓰고 있었기 때문이다.
# Dockerfile — 멀티스테이지 빌드
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY ./app ./app
# uvicorn 실행 — 워커 수는 CPU 코어 * 2 + 1 공식
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "3"]
멀티스테이지 빌드로 이미지 크기가 340MB까지 줄었다. --no-cache-dir 플래그 하나로 pip 캐시가 이미지에 안 들어가게 막는 것도 잊지 말아야 한다.
docker-compose 구성
로컬 개발과 스테이징 환경에서는 docker-compose를 썼다.
# docker-compose.yml
version: "3.9"
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/mydb
- SECRET_KEY=${SECRET_KEY} # .env에서 로드
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
retries: 5
volumes:
pgdata:
depends_on에 condition: service_healthy를 넣은 건, DB가 완전히 뜨기 전에 API 서버가 연결 시도하다 크래시나는 걸 막기 위해서다. 이거 안 넣으면 처음 docker-compose up 할 때 열에 아홉은 연결 에러가 뜬다.
운영 중 터진 것들
배포 후 일주일 안에 몇 가지 문제가 터졌다.
CORS 에러. 프론트엔드가 다른 도메인에서 요청을 보내는데 CORS 설정을 안 했다. Flask에서는 flask-cors 패키지를 쓰고 있었는데, FastAPI로 옮기면서 빠뜨렸다. FastAPI는 미들웨어로 처리한다.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://frontend.example.com"], # "*"는 운영에서 쓰지 마라
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
allow_origins=["*"]로 열어두면 편하긴 한데, 운영 환경에서는 도메인을 명시하는 게 맞다.
로깅 누락. uvicorn 기본 로그만으로는 디버깅이 안 됐다. 요청 본문, 응답 시간, 에러 트레이스백을 찍는 미들웨어를 직접 만들었다. 이건 간단하다. @app.middleware("http") 데코레이터로 요청/응답을 가로채서 logging 모듈로 기록하면 된다.
메모리 누수 의심. 배포 3일째부터 컨테이너 메모리가 서서히 올라갔다. 원인은 SQLAlchemy 세션을 yield로 닫지 않은 엔드포인트가 2개 있었기 때문이었다. Depends(get_db)를 빼먹고 직접 async_session()을 호출한 부분에서 세션이 안 닫히고 있었다. 의존성 주입을 일관되게 쓰는 게 이래서 중요하다.
테스트 — 나중에 붙이면 이렇게 고생한다
솔직히 테스트를 프로젝트 초반에 안 붙인 게 가장 후회되는 부분이다. 엔드포인트 40개를 다 옮긴 다음에 테스트를 작성하려니, 어디서부터 손대야 할지 막막했다.
FastAPI는 httpx의 AsyncClient로 테스트하는 게 공식 권장 방식이다(출처: FastAPI Testing 문서, 2026-04 기준).
# tests/test_users.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.mark.anyio
async def test_create_user():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v2/users/", json={
"email": "test@example.com",
"name": "테스트",
"age": 30
})
assert response.status_code == 201
assert response.json()["email"] == "test@example.com"
다만 테스트용 DB를 분리하는 설정이 은근 번거롭다. conftest.py에서 테스트 전용 DB URL을 주입하고, 테스트마다 트랜잭션을 롤백하는 fixture를 만들어야 한다. 이 부분은 공식 문서에 깔끔한 예제가 없어서 커뮤니티 글들을 참고했다.
현재 커버리지는 65% 정도다. 핵심 엔드포인트(인증, 사용자 CRUD)만 우선 작성했고, 나머지는 아직 손을 못 댔다. 처음부터 TDD로 갔으면 전환 과정에서 발생한 regression 버그 3~4건은 미리 잡았을 거다.
배포 후 성능 튜닝
uvicorn 워커 수를 조정하는 것만으로도 처리량 차이가 크다. 서버 스펙이 2코어 4GB RAM이라서 워커를 3개(코어 × 2 − 1)로 설정했다. gunicorn + uvicorn worker 조합도 시도했는데, 서버 한 대 운영에서는 체감 차이가 크지 않아서 uvicorn 단독으로 갔다.
--limit-concurrency 옵션으로 동시 연결 수를 제한하는 것도 고려해볼 만하다. 기본값은 무제한인데, 메모리가 넉넉하지 않으면 100~200 정도로 잡아두는 게 안전하다. 이건 아직 적용 안 해봐서 정확한 효과는 모르겠다.
다음 프로젝트에서는 FastAPI 0.115.x의 Lifespan 이벤트를 활용해서 앱 시작/종료 시 리소스 정리를 더 깔끔하게 처리할 생각이다. 기존 @app.on_event("startup")은 deprecated 예정이라 asynccontextmanager 기반의 lifespan으로 옮겨야 한다(출처: FastAPI Lifespan Events, FastAPI 0.109+에서 권장).
관련 글
- FastAPI JWT 인증 구현 — 리프레시 토큰과 권한 분리까지 실무에서 겪은 것들 – FastAPI에서 JWT 인증을 직접 구현하면서 3시간을 날린 경험을 바탕으로, 토큰 설계부터 Depends 체이닝을 이용한 권한 분리까지…
- Python asyncio 실전 가이드 — aiohttp와 gather로 API 호출 5배 빠르게 – requests 순차 호출로 8초 걸리던 API 집계를 asyncio.gather와 aiohttp로 1.6초까지 줄인 과정이다. event…
- Python pytest 테스트 자동화 — unittest에서 전환하며 깨달은 것들 – 커버리지 80%를 달성했는데 버그는 왜 안 줄었나. unittest에서 pytest로 전환하면서 겪은 시행착오와 fixture·mock·커…