FastAPI REST API 실전 구축기 — 인증, 에러 처리, 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% 줄었다.

SQLAlchemy async 세션에서 `relationship`을 lazy load하면 `MissingGreenlet` 에러가 뜬다. `selectinload`나 `joinedload`로 eager loading을 명시해야 한다. 이걸 몰라서 반나절을 날렸다.

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_oncondition: 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는 httpxAsyncClient로 테스트하는 게 공식 권장 방식이다(출처: 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+에서 권장).

관련 글

Chiko IT
Chiko IT

Platform Engineer. Python, AI, Infra에 관심이 많습니다.