본문 바로가기

파일 업로드 및 처리

1. 파일 업로드 소개

파일 업로드는 많은 웹 애플리케이션에서 중요한 기능입니다. 사용자 프로필 사진 업로드, 문서 제출, 이미지 갤러리 등 다양한 상황에서 파일 업로드 기능이 필요합니다. 동시에 가장 사고가 잦은 기능이기도 합니다. 사용자가 보낸 파일을 서버에 저장한다는 것은, 사용자가 서버의 디스크에 무언가를 쓸 수 있게 해준다는 뜻이기 때문입니다.

FastAPI는 파일 업로드를 쉽게 구현할 수 있는 기능을 제공합니다. 이번 절에서는 파일을 받는 방법과 함께, 받을 때 반드시 확인해야 할 것들을 다룹니다.

1.1 실습 세팅

앞 실습의 서버가 실행 중이면 Ctrl + C로 멈추고, 가상환경이 켜져 있다면 deactivate로 빠져나옵니다. 아래 명령은 실습 폴더들을 모아둔 상위 폴더에서 실행하세요.

mkdir 06_2_upload
cd 06_2_upload
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install "fastapi[standard]"

macOS/Linux에서는 python -m venv venv 대신 python3 -m venv venv를, 활성화 명령 대신 source ./venv/bin/activate를 사용합니다. 이후 명령은 가상환경이 활성화된 상태에서 실행합니다.

파일 업로드에는 python-multipart 패키지가 필요한데, fastapi[standard]에 이미 포함되어 있습니다.

2. 단일 파일 업로드

FastAPI에서 단일 파일을 업로드하는 방법을 알아보겠습니다.

from fastapi import FastAPI, UploadFile

app = FastAPI()


@app.post("/upload")
async def upload_file(file: UploadFile):
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size": file.size,
    }

UploadFile 타입 힌트 하나로 파일을 받을 수 있습니다. UploadFile 객체에서 얻을 수 있는 정보는 아래와 같습니다.

속성내용신뢰할 수 있나
filename클라이언트가 보낸 파일 이름신뢰할 수 없습니다
content_type클라이언트가 보낸 파일 종류신뢰할 수 없습니다
size실제 받은 바이트 수신뢰할 수 있습니다
file파일 내용에 접근하는 객체실제 데이터입니다

앞의 두 가지는 클라이언트가 보낸 값을 그대로 옮긴 것입니다. 파일 이름이 photo.png이고 종류가 image/png라고 해서 실제로 그 파일이 PNG라는 보장은 없습니다. 이 점이 잠시 뒤 검증 부분에서 중요해집니다.

file: UploadFile = File(...)을 쓴 코드를 봤다면

다른 자료에서 아래처럼 적은 코드를 자주 볼 수 있습니다.

async def upload_file(file: UploadFile = File(...)):
    ...

file: UploadFile만 적어도 동작합니다. File()은 파일에 설명을 붙이는 등 추가 설정이 필요할 때만 씁니다. 그럴 때도 Annotated를 씁니다.

from typing import Annotated

from fastapi import File, UploadFile


async def upload_file(file: Annotated[UploadFile, File(description="업로드할 이미지")]):
    ...

2.1 테스트하기

.http 파일로 파일을 보내려면 폼 형식으로 작성해야 합니다. 프로젝트 폴더에 아무 이미지 파일이나 하나 두고 sample.png라는 이름으로 저장한 뒤, 아래와 같이 작성합니다.

### 파일 업로드
POST http://127.0.0.1:8000/upload
Content-Type: multipart/form-data; boundary=----Boundary

------Boundary
Content-Disposition: form-data; name="file"; filename="sample.png"
Content-Type: image/png

< ./sample.png
------Boundary--

< ./sample.png는 "이 자리에 이 파일의 내용을 넣어라"라는 뜻입니다.

형식이 복잡하므로, 파일 업로드만큼은 Swagger UI가 훨씬 편합니다. http://127.0.0.1:8000/docs에서 POST /upload를 펼치고 Try it out을 누르면 파일 선택 버튼이 나타납니다.

3. 다중 파일 업로드

여러 파일을 동시에 업로드하는 방법도 있습니다. 리스트로 선언하면 됩니다.

from fastapi import FastAPI, UploadFile

app = FastAPI()


@app.post("/upload-many")
async def upload_files(files: list[UploadFile]):
    return {"filenames": [file.filename for file in files]}

이 예제에서는 여러 파일을 리스트로 받아 처리합니다. Swagger UI에서 파일을 여러 개 선택할 수 있게 됩니다.

3.1 파일과 다른 데이터를 함께 받기

파일과 텍스트를 함께 보내야 할 때가 많습니다. 프로필 사진과 함께 이름을 받는 경우가 그렇습니다.

from typing import Annotated

from fastapi import FastAPI, Form, UploadFile

app = FastAPI()


@app.post("/profile")
async def update_profile(
    name: Annotated[str, Form()],
    bio: Annotated[str, Form()] = "",
    avatar: UploadFile | None = None,
):
    return {
        "name": name,
        "bio": bio,
        "avatar": avatar.filename if avatar else None,
    }

여기서 주의할 점이 있습니다. 파일이 포함된 요청에는 JSON 본문을 함께 쓸 수 없습니다. HTTP 요청의 본문은 하나이고, 파일을 보낼 때는 그 본문 전체가 multipart/form-data 형식이 되기 때문입니다.

그래서 텍스트 값도 Form()으로 받아야 합니다. Pydantic 모델로 받으려고 하면 동작하지 않습니다.

4. 파일 저장하기

업로드된 파일을 서버에 저장하는 방법을 알아보겠습니다. 여기서부터 조심할 것이 나옵니다.

4.1 위험한 코드부터 보기

많은 자료에 아래와 같은 코드가 나옵니다.

import shutil

from fastapi import FastAPI, UploadFile

app = FastAPI()


@app.post("/upload")
async def upload_file(file: UploadFile):
    # 이 코드는 쓰면 안 됩니다
    with open(f"uploads/{file.filename}", "wb") as buffer:
        shutil.copyfileobj(file.file, buffer)
    return {"filename": file.filename}

shutil.copyfileobj로 업로드된 파일을 서버의 로컬 디렉토리에 저장하는 코드이고, 동작은 합니다. 그런데 file.filename을 그대로 경로에 넣은 것이 문제입니다. 앞서 말했듯이 파일 이름은 클라이언트가 정한 값입니다.

누군가 파일 이름을 아래와 같이 보내면 어떻게 될까요.

../../main.py

경로가 uploads/../../main.py로 합쳐져서, 우리 서버의 소스 코드를 덮어쓰게 됩니다. 이것을 경로 탐색(Path Traversal) 공격이라고 하며, 파일 업로드에서 가장 흔한 취약점입니다.

4.2 안전하게 저장하기

파일 이름을 서버가 새로 만들어 쓰는 것이 가장 확실합니다.

import shutil
import uuid
from pathlib import Path

from fastapi import FastAPI, HTTPException, UploadFile

app = FastAPI()

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}


@app.post("/upload")
async def upload_file(file: UploadFile):
    # 1. 확장자만 원본에서 가져오고, 이름은 버립니다
    suffix = Path(file.filename or "").suffix.lower()
    if suffix not in ALLOWED_EXTENSIONS:
        raise HTTPException(status_code=400, detail="허용되지 않는 파일 형식입니다")

    # 2. 이름을 서버가 새로 만듭니다
    saved_name = f"{uuid.uuid4().hex}{suffix}"
    saved_path = UPLOAD_DIR / saved_name

    # 3. 저장합니다
    with saved_path.open("wb") as buffer:
        shutil.copyfileobj(file.file, buffer)

    return {"original_name": file.filename, "saved_name": saved_name}

세 가지가 바뀌었습니다.

  1. 확장자만 가져옵니다: Path(...).suffix는 ../../main.py에서도 .py만 뽑아냅니다.
  2. 이름을 서버가 만듭니다: uuid4()로 겹치지 않는 이름을 만듭니다. 원본 이름은 필요하면 데이터베이스에 따로 저장합니다.
  3. 폴더를 미리 만듭니다: mkdir(exist_ok=True)는 폴더가 없으면 만들고 있으면 넘어갑니다.

원본 이름이 필요하다면 파일 이름이 아니라 데이터베이스 컬럼에 저장하세요. 그러면 어떤 문자가 들어 있어도 파일 시스템에 영향을 주지 않습니다.

확장자를 검사해도 안전하지는 않습니다

.png 확장자를 붙였다고 해서 내용이 이미지라는 보장은 없습니다. 악성 코드가 담긴 파일을 photo.png로 이름만 바꿔 올릴 수 있습니다.

실제 내용을 확인하려면 파일의 앞부분(매직 넘버)을 읽어 형식을 판별하거나, Pillow 같은 라이브러리로 열어보는 방법을 씁니다.

from io import BytesIO

from PIL import Image

try:
    image = Image.open(BytesIO(content))
    image.verify()
except Exception:
    raise HTTPException(status_code=400, detail="올바른 이미지 파일이 아닙니다")

여기에 더해 업로드 폴더에서는 파일이 실행되지 않도록 서버를 설정하고, 가능하면 서버가 아닌 외부 저장소에 두는 것이 좋습니다. 파일 업로드는 이 정도까지 신경 써야 하는 기능입니다.

5. 파일 유효성 검사

업로드된 파일의 크기나 타입을 검증하는 것은 중요한 보안 조치입니다. 확장자 검사는 4.2절에서 했으니, 이번에는 크기를 제한해보겠습니다.

5.1 흔히 쓰지만 위험한 방법

크기를 제한하는 코드로 아래 형태를 자주 봅니다.

MAX_FILE_SIZE = 1_000_000  # 1MB


@app.post("/upload")
async def upload_file(file: UploadFile):
    # 이 코드에는 문제가 있습니다
    content = await file.read()
    if len(content) > MAX_FILE_SIZE:
        raise HTTPException(status_code=400, detail="파일이 너무 큽니다")
    return {"size": len(content)}

await file.read()는 파일 전체를 메모리에 올립니다. 크기를 확인하려고 이미 전부 읽어버린 뒤에 확인하는 셈입니다. 누군가 5GB짜리 파일을 보내면, "너무 크다"고 답하기 전에 서버 메모리가 먼저 바닥납니다.

5.2 나눠 읽으며 확인하기

읽으면서 중간에 끊는 방식으로 바꿉니다.

import uuid
from pathlib import Path

from fastapi import FastAPI, HTTPException, UploadFile

app = FastAPI()

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

MAX_FILE_SIZE = 5 * 1024 * 1024  # 5MB
CHUNK_SIZE = 1024 * 1024  # 1MB씩 읽습니다
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}


@app.post("/upload")
async def upload_file(file: UploadFile):
    suffix = Path(file.filename or "").suffix.lower()
    if suffix not in ALLOWED_EXTENSIONS:
        raise HTTPException(status_code=400, detail="허용되지 않는 파일 형식입니다")

    saved_name = f"{uuid.uuid4().hex}{suffix}"
    saved_path = UPLOAD_DIR / saved_name
    total = 0

    try:
        with saved_path.open("wb") as buffer:
            while chunk := await file.read(CHUNK_SIZE):
                total += len(chunk)
                if total > MAX_FILE_SIZE:
                    raise HTTPException(status_code=413, detail="파일이 너무 큽니다")
                buffer.write(chunk)
    except HTTPException:
        saved_path.unlink(missing_ok=True)  # 쓰다 만 파일을 지웁니다
        raise

    return {"saved_name": saved_name, "size": total}

await file.read(CHUNK_SIZE)처럼 크기를 지정하면 그만큼만 읽어옵니다. 누적 크기가 한도를 넘는 순간 바로 멈추므로, 메모리에는 1MB씩만 올라갑니다.

413 Payload Too Large는 "요청 본문이 너무 크다"는 뜻의 상태 코드입니다.

에러가 났을 때 saved_path.unlink(missing_ok=True)로 쓰다 만 파일을 지우는 것도 잊지 마세요. 이 처리가 없으면 실패한 업로드의 조각이 디스크에 계속 쌓입니다.

더 앞단에서 막는 방법

실제 서비스에서는 FastAPI 앞에 Nginx 같은 웹 서버를 두는 경우가 많습니다. 여기에 client_max_body_size 5M; 같은 설정을 하면 파이썬 코드까지 오기 전에 차단됩니다.

두 곳 모두에 설정을 두는 것이 좋습니다. 앞단은 서버를 보호하고, 애플리케이션은 사용자에게 이유를 알려줍니다.

6. 파일 돌려주기와 스트리밍

업로드한 파일을 다시 내려받게 하려면 FileResponse를 사용합니다.

from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse

app = FastAPI()


@app.get("/files/{saved_name}")
async def get_file(saved_name: str):
    file_path = UPLOAD_DIR / saved_name

    # 경로 탐색을 막습니다
    if not file_path.resolve().is_relative_to(UPLOAD_DIR.resolve()):
        raise HTTPException(status_code=400, detail="잘못된 경로입니다")

    if not file_path.is_file():
        raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")

    return FileResponse(file_path)

여기서도 경로 탐색을 막아야 합니다. saved_name에 ../main.py가 들어오면 서버의 소스 코드를 그대로 내려주게 됩니다. is_relative_to로 최종 경로가 업로드 폴더 안에 있는지 확인합니다.

내려받기 창을 띄우고 싶다면 파일 이름을 함께 지정합니다.

return FileResponse(file_path, filename="다운로드용_이름.png")

파일을 디스크에 저장하지 않고 메모리에서 바로 돌려줄 때는 StreamingResponse를 씁니다. 받은 파일을 그대로 되돌려주는 예입니다.

import io

from fastapi import FastAPI, UploadFile
from fastapi.responses import StreamingResponse

app = FastAPI()


@app.post("/echo")
async def echo_file(file: UploadFile):
    contents = await file.read()
    return StreamingResponse(io.BytesIO(contents), media_type=file.content_type)

FileResponse는 디스크의 파일을 그대로 내보낼 때, StreamingResponse는 만들어지는 대로 조금씩 보내야 하는 응답에 씁니다.

이미지 여러 장을 화면에 보여줄 때

이미지마다 이 엔드포인트를 거치면 파이썬 코드가 매번 실행됩니다. 5장에서 배운 app.frontend()나 정적 파일 서빙을 쓰면 파이썬을 거치지 않고 파일이 바로 나가므로 훨씬 빠릅니다.

권한 확인이 필요한 파일은 엔드포인트로, 누구나 볼 수 있는 파일은 정적 서빙으로 나누는 것이 일반적입니다.

7. 업로드 처리 점검표

파일 업로드 기능을 만들 때 확인할 것을 표로 모았습니다.

항목확인 방법
파일 이름을 그대로 경로에 쓰지 않는가uuid로 새 이름을 만들었는가
확장자를 검사하는가허용 목록 방식인가 (차단 목록이 아니라)
크기를 제한하는가전부 읽기 전에 끊는가
실패했을 때 뒷정리를 하는가쓰다 만 파일을 지우는가
내려줄 때 경로를 확인하는가업로드 폴더 밖으로 나가지 못하게 막았는가
업로드 폴더가 실행 가능한가웹 서버 설정에서 실행을 막았는가

확장자 검사를 허용 목록으로 해야 하는 이유가 있습니다. ".exe는 막자"는 식의 차단 목록은 빠뜨린 것이 반드시 생깁니다. ".png, .jpg만 받자"는 허용 목록은 모르는 형식이 들어와도 자동으로 막힙니다. 보안 설정의 기본 원칙입니다.

연습문제

  1. 이미지 파일만 허용하는 업로드 엔드포인트를 만들고, 업로드된 이미지의 크기(폭과 높이)를 반환해보세요. pip install pillow로 Pillow를 설치하면 됩니다.
  2. 파일 이름을 ../../main.py로 바꿔서 업로드를 시도해보세요. Swagger UI로는 어려우므로 .http 파일의 filename= 부분을 직접 고쳐서 보내보세요. 4.1절의 코드와 4.2절의 코드에서 결과가 어떻게 다른지 확인해보세요.
  3. CSV 파일을 업로드 받아 그 내용을 JSON 형식으로 반환하는 엔드포인트를 구현해보세요.
  4. 업로드된 파일의 SHA-256 해시를 계산하여 반환하는 엔드포인트를 만들어보세요. 나눠 읽기 방식과 함께 구현해보세요.
  5. 여러 파일을 한 번에 업로드 받아 ZIP 파일로 압축하여 반환하는 엔드포인트를 구현해보세요.
  6. 5장의 블로그에 썸네일 이미지 업로드 기능을 추가해보세요. BlogModel에 thumbnail 컬럼을 추가하고, 저장된 파일 이름을 기록하면 됩니다.
파일 업로드 및 처리 - FastAPI 베이스캠프 | 위니버시티