[FastAPI 시리즈 13편] 파일 업로드와 정적/미디어 기본기

English version

앞선 편의 작업 관리 API 위에서 이제 이미지를 업로드하고 정적 리소스를 안정적으로 노출하는 패턴을 다룹니다. 정적 파일은 CSS/JS 같은 변하지 않는 자산입니다. 미디어는 사용자가 업로드해 계속 쌓이는 파일입니다. UploadFile은 FastAPI가 파일을 스트림으로 읽고 쓰게 해 주는 타입이라서 대용량 업로드에 유리합니다. 핵심 개념만 빠르게 살피고 바로 실습으로 넘어갑니다.

이번 글에서 새로 나오는 용어

  1. UploadFile: FastAPI가 multipart/form-data 요청에서 파일을 효율적으로 읽어 들이도록 제공하는 타입으로, 메모리에 전부 올리지 않고 청크 단위로 처리합니다.
  2. StaticFiles: FastAPI 앱에 정적 또는 미디어 디렉터리를 마운트해 /static, /media 같은 경로로 직접 노출하게 해 주는 도우미입니다.
  3. 정적 자산(static): CSS·JS처럼 배포 시점에 고정된 파일로, 빌드 결과를 그대로 서빙하기 때문에 캐시와 버전 관리가 중요합니다.
  4. 미디어(media): 사용자가 업로드하면서 계속 늘어나는 파일 모음으로, 저장소 경로와 접근 권한을 별도로 관리해야 합니다.
  5. 청크(chunk): 큰 파일을 일정 크기 덩어리로 잘라 전송·저장하는 단위로, 업로드 시 메모리를 아끼고 스트림 처리할 때 핵심이 됩니다.

실습 카드

  • 예상 소요 시간: 45분
  • 사전 준비: 12편 프로젝트, Pillow 설치(선택)
  • 실습 목표: UploadFile로 파일을 받고 media/static 경로를 분리한다

업로드 폼 정의하기

FileUploadFile 타입은 ASGI 서버가 multipart/form-data 요청을 효율적으로 처리하도록 돕습니다.

from fastapi import FastAPI, File, UploadFile

app = FastAPI()

@app.post("/photos")
async def create_photo(file: UploadFile = File(...)):
    contents = await file.read()
    return {"filename": file.filename, "size": len(contents)}

File(...)은 필수 필드임을 명시합니다. UploadFile은 내부적으로 SpooledTemporaryFile을 사용해 큰 파일도 메모리를 과도하게 쓰지 않습니다. 이 패턴을 적용하면 클라이언트로부터 받은 파일을 서버에서 바로 다른 서비스로 전달하기 쉬워집니다.

저장 경로와 미디어 폴더

실무에서는 업로드 데이터를 지정 폴더나 클라우드 스토리지로 옮깁니다. 로컬 저장소 예시는 다음과 같습니다.

from pathlib import Path

MEDIA_ROOT = Path("media")
MEDIA_ROOT.mkdir(exist_ok=True)

@app.post("/photos", status_code=201)
async def create_photo(file: UploadFile = File("image/*")):
    destination = MEDIA_ROOT / file.filename
    with destination.open("wb") as buffer:
        while chunk := await file.read(1024 * 1024):
            buffer.write(chunk)
    return {"url": f"/media/{file.filename}"}

처음 등장한 chunk는 파일을 일정한 크기로 나눈 데이터 덩어리를 뜻합니다. 이렇게 읽고 쓰면 대용량 파일도 스트림처럼 조금씩 처리해 메모리 사용량을 낮춥니다. 이후 클라우드 스토리지로 옮길 때도 같은 구조를 그대로 사용할 수 있습니다.

정적 파일과 미디어 분리

  • 정적(static): 버전 관리되는 JS, CSS, 폰트 등.
  • 미디어(media): 사용자가 업로드해 동적으로 늘어나는 파일.

StaticFiles를 앱에 마운트해 이 둘을 다른 경로로 노출합니다.

from fastapi.staticfiles import StaticFiles

app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.mount("/media", StaticFiles(directory="media"), name="media")

정적 파일은 빌드 시점에 최적화(CSS/JS 번들링)를 거친 후 고정된 경로에 배포합니다. 미디어 파일은 별도 스토리지(S3 같은 객체 스토리지)나 CDN으로 오프로드하는 편이 일반적입니다. 이렇게 분리하면 백엔드 서버가 파일 전송으로 과부하되지 않습니다.

업로드 메타데이터 검증

업로드 파일의 확장자, MIME 타입, 크기 제한을 통해 보안 사고를 줄입니다.

MAX_SIZE = 5 * 1024 * 1024

async def validate_upload(file: UploadFile):
    if not file.content_type.startswith("image/"):
        raise HTTPException(status_code=400, detail="이미지 파일만 허용됩니다.")
    size = 0
    while chunk := await file.read(1024 * 512):
        size += len(chunk)
        if size > MAX_SIZE:
            raise HTTPException(status_code=413, detail="최대 5MB까지 업로드 가능합니다.")
    await file.seek(0)

seek(0)은 파일 포인터를 처음으로 되돌립니다. 이후 저장 단계에서 데이터를 다시 읽을 수 있게 해 주므로 검증 이후에도 저장을 진행할 수 있습니다.

단순 이미지 썸네일 API

이미지 업로드 후 Pillow로 썸네일을 생성하는 예시입니다. 실습할 때는 MEDIA_ROOT가 실제로 존재하는 폴더를 가리키는지 먼저 확인합니다. 운영 환경에서는 파일명이 겹치지 않도록 UUID 같은 고유 이름을 함께 써야 안전합니다. 또한 ../../secret.txt 같은 경로 순회(path traversal) 입력을 막기 위해 허용된 문자만 남기거나 서버에서 새 파일명을 만들어야 합니다.

from PIL import Image

@app.post("/photos/thumbnail")
async def create_thumbnail(file: UploadFile = File("image/*")):
    with Image.open(file.file) as img:
        img.thumbnail((300, 300))
        thumb_path = MEDIA_ROOT / f"thumb_{file.filename}"
        img.save(thumb_path)
    return {"thumbnail": f"/media/{thumb_path.name}"}

file.fileUploadFile 내부의 실제 파일 객체입니다. Pillow 같은 라이브러리가 바로 활용할 수 있어 추가 메모리 복사가 필요 없습니다.

D2로 보는 간단 흐름

FastAPI브라우저Media Folder POST /photossave chunksaved pathJSON URL

간단한 흐름이지만 저장소를 별도 컨테이너나 외부 서비스를 써도 동일한 단계를 그대로 거칩니다. 이 흐름을 이해하면 스토리지 종류가 달라져도 구현 순서를 쉽게 바꿀 수 있습니다.

uvicorn 실행 팁

업로드 테스트 시 uvicorn app.main:app --reload --limit-concurrency 10처럼 동시 처리 제한을 둡니다. 로컬 환경에서 과도한 파일 업로드로 인한 리소스 고갈을 막을 수 있습니다. 파일 업로드와 정적/미디어 분리는 배포 환경에서 자주 문제가 되는 영역입니다. 지금 구조를 기반으로 클라우드 스토리지 연계나 서명된 URL 발급 같은 고급 기능도 쉽게 붙일 수 있습니다.

실습

  • 따라 하기: /photos 업로드 엔드포인트와 /media StaticFiles 마운트를 구성해 파일 저장 후 URL을 반환한다.
  • 확장하기: 업로드 검증 함수를 작성해 MIME 타입/파일 크기 제한을 걸고, 썸네일 생성 API를 추가한다.
  • 디버깅: 파일명이 겹치거나 경로 순회 입력이 들어올 때를 대비해 UUID 파일명으로 교체하고, 로그에서 저장 경로를 확인한다.
  • 완료 기준: 업로드→저장→미디어 URL 응답이 한 번에 동작하고, 제한 조건을 바꾸면 오류가 재현된다.

마무리

UploadFile과 StaticFiles를 다루는 패턴을 익혀 두면 이미지나 첨부 파일이 필요한 앱을 빠르게 확장할 수 있습니다. 실무에서는 CDN과 스토리지를 조합하더라도 지금 정리한 개념이 그대로 이어지므로, 각 단계의 책임을 명확히 구분해 연습해 두세요.

💬 댓글

이 글에 대한 의견을 남겨주세요