본문 바로가기

배포하기

1. 개발 서버와 실서버

지금까지 fastapi dev로 서버를 실행했습니다. 이 명령은 개발할 때만 쓰는 것입니다. 실제로 서비스할 때는 fastapi run을 씁니다.

fastapi run app/main.py

두 명령의 차이는 아래와 같습니다.

fastapi devfastapi run
접속 주소127.0.0.1 (내 컴퓨터만)0.0.0.0 (외부에서 접속 가능)
자동 새로고침켜짐꺼짐
프로세스 개수1개지정 가능
환경 변수FASTAPI_ENV=development 기본 설정자동 설정하지 않음

문서 공개 여부와 디버그 설정은 실행 명령만 바꾼다고 자동으로 달라지지 않습니다. 7-1절에서 만든 DEBUG 환경 변수와 앱 설정을 별도로 확인하세요.

127.0.0.1과 0.0.0.0의 차이가 중요합니다. 127.0.0.1은 그 컴퓨터 안에서만 접속할 수 있는 주소입니다. 서버에 올려놓고 fastapi dev로 실행하면 밖에서 접속되지 않습니다.

1.1 프로세스 여러 개 띄우기

6장에서 배웠듯이 파이썬은 프로세스 하나에 이벤트 루프가 하나입니다. CPU 코어가 여러 개여도 하나만 쓰게 됩니다.

fastapi run app/main.py --workers 4

--workers로 프로세스 개수를 지정하면 요청이 나눠서 처리됩니다. 보통 CPU 코어 수 정도로 정합니다.

프로세스를 나누면 생기는 문제

프로세스마다 메모리가 따로입니다. 3장에서 메모리 리스트에 데이터를 저장했던 코드를 기억해보세요. 그런 코드는 워커가 여러 개일 때 동작하지 않습니다. 1번 워커가 저장한 값을 2번 워커는 모릅니다.

메모리에 무언가를 저장하는 코드가 있다면 데이터베이스나 Redis 같은 공용 저장소로 옮겨야 합니다. 4장에서 데이터베이스로 넘어간 것이 이런 이유이기도 합니다.

lifespan에 넣은 코드도 워커마다 실행된다는 점을 기억해두세요. 테이블을 만드는 정도는 괜찮지만, 초기 데이터를 넣는 코드는 중복될 수 있습니다.

2. 배포 전 점검표

배포하기 전에 확인할 것을 정리했습니다. 앞선 장들에서 다룬 내용들이 여기서 모입니다.

항목확인 방법다룬 곳
비밀 키가 코드에 없는가.env로 옮겼는가7-1
.env가 커밋되지 않았는가git status에 안 보이는가7-1
DEBUG가 꺼져 있는가/docs가 404인가7-1
CORS가 *가 아닌가실제 도메인만 적었는가5-2
테스트가 모두 통과하는가python -m pytest7-2
데이터베이스 파일이 커밋되지 않았는가.gitignore 확인6-3
페이지네이션 상한이 있는가limit에 le가 있는가2-4
업로드 크기 제한이 있는가나눠 읽으며 확인하는가6-2
예상 못한 에러가 로그에 남는가전역 핸들러가 있는가6-1
개발용 엔드포인트를 지웠는가/debug/... 같은 것4-4

마지막 항목을 특히 확인하세요. 4장에서 저장된 해시를 확인하려고 만들었던 /debug/users 같은 엔드포인트가 그대로 배포되는 일이 생각보다 자주 있습니다.

3. Docker로 배포하기

배포 방법은 여러 가지가 있지만, 지금은 Docker를 쓰는 경우가 가장 많습니다. 내 컴퓨터에서 되는 것을 그대로 서버로 옮길 수 있다는 것이 이유입니다.

3.1 Dockerfile 작성

6-5절의 프로젝트에 7-1절의 설정 변경까지 적용한 상태에서 진행합니다. 로컬 명령은 프로젝트 최상위에서 가상환경을 활성화한 뒤 실행하세요.

먼저 실행용 requirements.txt를 준비합니다. Windows에서 pip freeze로 만든 목록에는 Windows 전용 패키지가 들어갈 수 있으므로, Linux 이미지에서 설치가 실패한다면 배포용 복사본의 requirements.txt를 아래 실행용 목록으로 작성하세요. 버전은 이 책의 코드를 확인할 때 사용한 값이며, 테스트 도구는 포함하지 않습니다.

fastapi[standard]==0.141.1
sqlalchemy==2.0.52
PyJWT==2.13.0
pwdlib[argon2]==0.3.1
pydantic-settings==2.15.0

이 목록은 직접 사용하는 패키지의 버전을 고정합니다. 간접 의존성까지 고정하려면 이미지를 빌드하고 테스트한 뒤 docker run --rm weniv-blog python -m pip freeze > requirements-docker.txt로 Linux 환경의 목록을 저장하세요. UTF-8로 저장한 이 파일을 배포용 requirements.txt로 사용해 다시 빌드하고 확인하면 됩니다. 로컬 개발 도구 목록은 requirements-dev.txt로 따로 관리합니다. 실무에서는 uv처럼 잠금 파일을 만들어주는 도구를 쓰면 운영체제별 목록을 손으로 맞추는 이 과정이 단순해집니다. 이 책은 설치 복잡도를 줄이기 위해 pip을 쓰며, uv는 6-3절의 참고 내용을 보세요.

프로젝트 최상위에 Dockerfile 파일을 만듭니다.

FROM python:3.13-slim

WORKDIR /code

# 의존성 파일만 먼저 복사해서 설치합니다
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt

# 나머지 코드를 복사합니다
COPY ./app ./app
COPY ./static ./static

EXPOSE 8000

CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]

의존성 파일을 먼저 복사하고 설치한 뒤에 코드를 복사한 순서가 중요합니다. Docker는 각 단계의 결과를 캐시해두는데, 코드만 바뀌었을 때 패키지 설치 단계를 건너뛸 수 있기 때문입니다. 순서를 반대로 하면 코드를 한 줄 고칠 때마다 패키지를 전부 다시 설치합니다.

옵션의미
-r requirements.txt파일에 적힌 패키지를 설치합니다
--no-cache-dir다운로드 캐시를 이미지에 남기지 않습니다

컨테이너는 독립된 환경이므로 이 예제에서는 내부에 가상환경을 추가로 만들지 않습니다. static 폴더는 5장에서 만든 화면 파일을 복사해 준비해야 합니다.

3.2 .dockerignore 작성

이미지 안에 들어가면 안 되는 것들을 적습니다.

venv/
.venv/
__pycache__/
*.pyc
.env
*.db
.git/
tests/
uploads/

.env가 여기 있는 것이 중요합니다. 비밀 키가 이미지 안에 들어가면, 그 이미지를 받은 사람은 누구나 키를 볼 수 있습니다. 설정값은 이미지가 아니라 실행할 때 전달합니다.

3.3 실행하기

아래 여러 줄 명령은 macOS/Linux 터미널 기준입니다. Windows PowerShell에서는 줄 끝의 \를 없애고 한 줄로 이어서 입력하세요.

docker build -t weniv-blog .

docker run -p 8000:8000 \
    -e SECRET_KEY="여기에_실제_비밀키" \
    -e DEBUG=false \
    -e ALLOWED_ORIGINS='["https://blog.weniv.co.kr"]' \
    weniv-blog

-e로 환경 변수를 전달합니다. 7-1절에서 만든 설정 클래스가 이 값을 읽습니다. .env 파일이 없어도 동작합니다.

-p 8000:8000은 "내 컴퓨터의 8000번 포트를 컨테이너의 8000번 포트에 연결하라"는 뜻입니다.

3.4 데이터를 어디에 둘까

지금 구성에는 문제가 하나 있습니다. SQLite 파일이 컨테이너 안에 만들어지므로, 컨테이너를 다시 만들면 데이터가 전부 사라집니다.

임시로는 폴더를 연결해서 해결할 수 있습니다.

docker run -p 8000:8000 \
    -v weniv-blog-data:/code/data \
    -e DATABASE_URL="sqlite:///./data/blogs.db" \
    -e SECRET_KEY="..." \
    weniv-blog

weniv-blog-data는 Docker가 관리하는 볼륨 이름입니다. 컨테이너를 다시 만들 때 같은 볼륨을 연결하면 데이터를 유지할 수 있습니다. 여러 서버로 확장할 때는 별도의 데이터베이스 서버를 사용하는 편이 적합합니다.

4. SQLite에서 PostgreSQL로

SQLite는 학습과 소규모 서비스에는 충분하지만, 아래 상황에서는 한계가 있습니다.

상황SQLitePostgreSQL
동시에 쓰기한 번에 하나만여러 개 동시에
서버 여러 대파일을 공유할 수 없음여러 서버가 접속
백업파일 복사전용 도구
데이터 타입제한적다양함

바꾸는 방법 자체는 간단합니다. SQLAlchemy를 쓴 덕분입니다.

pip install "psycopg[binary]"

배포용 requirements.txt에도 psycopg[binary]와 확인한 버전을 추가하고 Docker 이미지를 다시 빌드해야 합니다. 로컬에만 설치하면 컨테이너에서는 드라이버를 찾지 못합니다. PostgreSQL 서버와 blogdb 데이터베이스도 별도로 준비하세요.

그다음 환경 변수만 바꿉니다.

DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/blogdb

app/database.py에서 SQLite 전용 옵션을 조건부로 처리합니다.

from app.config import settings

connect_args = {}
if settings.database_url.startswith("sqlite"):
    connect_args["check_same_thread"] = False

engine = create_engine(settings.database_url, connect_args=connect_args)

4장에서 check_same_thread를 설명하면서 "SQLite를 쓸 때만 필요하다"고 했던 부분이 여기서 실제로 필요해집니다.

모델과 엔드포인트 코드는 한 줄도 바뀌지 않습니다. ORM을 쓰는 이유 중 하나입니다.

테이블 구조를 바꿔야 한다면

4장에서 언급한 대로 Base.metadata.create_all은 이미 있는 테이블을 고치지 않습니다. 실습에서는 .db 파일을 지우면 됐지만, 실제 데이터가 있는 서비스에서는 그럴 수 없습니다.

이때 쓰는 도구가 Alembic입니다. "이 컬럼을 추가하라"는 변경 내역을 파일로 만들어두고, 배포할 때 순서대로 적용합니다. 되돌리는 것도 가능합니다.

pip install alembic
alembic init migrations

이 책의 범위를 벗어나지만, 실제 서비스를 운영하게 되면 반드시 만나게 되는 도구입니다.

5. 리버스 프록시 뒤에 두기

실제 서비스에서는 FastAPI 앞에 Nginx 같은 웹 서버를 두는 경우가 많습니다.

앞에 두는 이유는 아래와 같습니다.

  • HTTPS 인증서를 여기서 처리합니다.
  • 정적 파일을 파이썬을 거치지 않고 바로 내보냅니다.
  • 요청 본문 크기 제한 등을 걸 수 있습니다.
  • 서버가 여러 대일 때 요청을 나눠줍니다.

이렇게 구성하면 FastAPI 입장에서는 모든 요청이 Nginx에서 오는 것처럼 보입니다. 실제 사용자의 IP 주소와 프로토콜을 알려면 옵션이 필요합니다.

fastapi run app/main.py --proxy-headers --forwarded-allow-ips="*"

"*"는 모든 발신자의 전달 헤더를 신뢰하므로 FastAPI에 신뢰하는 프록시만 접속할 수 있는 구성에서 사용합니다. 직접 접속도 가능한 환경에서는 --forwarded-allow-ips에 프록시의 IP만 지정하세요.

이 옵션이 없으면 사용자 IP가 전부 프록시의 IP로 기록되고, HTTPS로 접속했는데도 애플리케이션은 HTTP로 인식합니다.

경로 일부를 떼어내고 전달하는 구성이라면 --root-path도 필요합니다.

fastapi run app/main.py --root-path /api

이 옵션이 없으면 /docs에서 만든 요청이 잘못된 주소로 나갑니다.

6. 로그 남기기

6장에서 만든 전역 예외 핸들러가 여기서 힘을 발휘합니다. 배포한 뒤에는 브라우저 화면이나 터미널을 볼 수 없으므로, 로그가 유일한 단서입니다.

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)

컨테이너로 실행할 때는 파일이 아니라 표준 출력으로 내보내는 것이 기본입니다. 컨테이너는 언제든 사라질 수 있어서, 안에 있는 로그 파일도 함께 사라지기 때문입니다.

docker logs -f 컨테이너이름

규모가 커지면 로그를 모아서 검색할 수 있는 서비스를 붙이게 됩니다. 그때도 표준 출력으로 내보내는 방식이 그대로 이어집니다.

6.1 로그에 남기면 안 되는 것

남기면 안 되는 것이유
비밀번호평문으로 남습니다
토큰 전체그대로 쓰면 로그인됩니다
주민등록번호, 카드번호개인정보 유출입니다
요청 본문 전체위 항목들이 섞여 들어갑니다

로그를 남길 때는 무엇이 담기는지 한 번 더 확인하는 습관이 필요합니다. 로그는 오래 보관되고 여러 사람이 볼 수 있습니다.

7. 배포할 곳 고르기

방식특징언제
클라우드 배포 서비스Git에 올리면 자동 배포작은 서비스, 빠른 시작
VPS + Docker직접 서버를 관리비용 절감, 자유도
컨테이너 서비스자동 확장트래픽 변동이 큰 서비스
서버리스요청이 있을 때만 실행요청이 드문 서비스

처음이라면 클라우드 배포 서비스를 권합니다. Dockerfile만 있으면 되는 곳이 많고, HTTPS 인증서와 도메인 연결도 자동으로 처리해 줍니다.

fastapi[standard]에는 fastapi-cloud-cli가 포함되어 있어 fastapi deploy 명령도 사용할 수 있습니다. 다만 계정과 요금 정책은 시점에 따라 달라지므로 공식 문서를 확인하시기 바랍니다.

FastAPI 배포 문서

8. 배포한 다음에

배포한 뒤에도 최소한 아래 두 가지는 준비해두는 것이 좋습니다.

서버가 살아 있는지 확인하는 엔드포인트를 만듭니다.

@app.get("/health", include_in_schema=False)
def health_check():
    return {"status": "ok"}

include_in_schema=False를 붙이면 API 문서에는 나타나지 않습니다. 배포 서비스들이 이 주소를 주기적으로 호출해서 서버 상태를 확인합니다.

데이터베이스는 정기적으로 백업합니다. 서비스가 잠시 멈추는 것은 복구할 수 있지만, 데이터가 사라지면 복구할 수 없습니다.

9. 더 공부할 주제

이 책은 여기서 끝나지만 FastAPI로 할 수 있는 일은 더 많습니다. 다음으로 찾아볼 만한 주제들입니다.

주제왜 필요한가
Alembic데이터를 지키면서 테이블 구조를 바꾸기
비동기 데이터베이스트래픽이 많아졌을 때
Redis캐시, 세션, 토큰 무효화 목록
백그라운드 작업메일 발송처럼 오래 걸리는 일
WebSocket실시간 채팅, 알림
서버 전송 이벤트AI 응답을 한 글자씩 흘려보내기
모니터링어디가 느린지 측정하기

연습문제

  1. 6장 구조에 Dockerfile과 .dockerignore를 만들고 이미지를 빌드해보세요.
  2. 컨테이너를 실행하고 .http 파일로 API가 동작하는지 확인해보세요.
  3. SECRET_KEY를 전달하지 않고 컨테이너를 실행해보세요. 어떻게 되는지 확인해보세요.
  4. DEBUG=false로 실행하고 /docs가 404인지 확인해보세요.
  5. /health 엔드포인트를 추가하고, /docs에 나타나지 않는지 확인해보세요.
  6. 2절의 점검표를 하나씩 확인하면서 빠뜨린 것이 없는지 점검해보세요.