FastAPI를 활용하면 파일을 업로드하거나 다운로드할 수 있는 web server를 매우 간단하게 구현할 수 있다. 예제 코드와 함께 최대한 간단하게 파일 업로드 및 다운로드 API를 구현하고 테스트하는 방법을 알아보자.
Python FastAPI로 파일 업로드 및 다운로드 가능한 web server 개발하기
Web 파일 서버 개발 준비
이전 글에서 FastAPI를 활용하여 HTTP 요청을 통해 기본적인 데이터를 주고받을 수 있는 REST API 형태의 web server를 만드는 방법에 대해 알아보았다. 이어서 HTTP 요청을 통해 서버와 클라이언트 사이에 파일은 어떻게 주고받을 수 있는지 알아보자.
이 글의 예제 코드를 직접 테스트해보기 위해서는 이전 글에서 이미 설명한 준비사항들이 갖추어져 있어야 한다. 기본적인 FastAPI 사용법 또한 다루었기 때문에, 이 글의 설명을 따라가기 힘들다면 이전 글을 먼저 읽어보고 오는 것을 추천한다.
서버와 클라이언트 사이에 파일을 주고받는 방식은 어떻게 구현하느냐에 따라 달라진다. 예를 들어, 파일을 문자열 그대로 보낼 수도 있고, 압축해서 byte stream으로 보내거나, JSON 형식으로 만들어서 보내는 등 개발 방법에 따라 주고받는 파일 형식도 천차만별이다.
이 글에서는 사용자가 파일을 업로드하고 다운로드하는 과정을 웹 브라우저를 통해 진행한다고 가정하겠다. 다시 말해서, 별도의 클라이언트 앱을 개발하기보다는 브라우저의 방식을 그대로 사용한다는 뜻이다. 예를 들어, 브라우저는 HTML의 form 태그를 통해 사용자로부터 업로드할 파일을 받은 뒤 이를 서버에 저장한다.
그런데, 실제 HTML 페이지를 구현하여 테스트하는 것은 글을 다소 산만하게 만들 수 있다고 생각한다. 따라서, python으로 web server를 만드는 것에 초점을 맞추는 측면에서, 브라우저의 동작을 python으로 모방하여 테스트를 진행하도록 하겠다.
말은 거창하지만, 결국 파일을 업로드할 때는 content type으로 multipart/form-data
를 사용하고, 다운로드할 때는 application/octet-stream
을 사용하겠다는 의미다. 이 content type에 대한 설명은 다른 글에서 더욱 자세히 다루도록 하겠다.
File 업로드 기능 개발
파일 저장소 만들기
Backend에서 사용자의 요청으로 인해 변경되는 app의 상태를 database에 저장하는 것처럼, web server에 전송되는 파일도 어딘가에 저장해두어야 한다. 보통 AWS S3같은 object storage 또는 FTP server나 HDFS같은 제 3의 원격 파일 저장소를 활용한다. 파일 저장소에 대한 내용은 이 글의 범위를 벗어나기 때문에, 이 글에서는 사용자가 업로드한 파일을 web server의 local file system에 저장한다고 가정하겠다.
편의상 "uploaded_files"라는 디렉토리를 만들고 이 안에 사용자들이 요청한 파일들을 저장하도록 하겠다. 다음과 같이 서버를 시작할 때 "uploaded_files"라는 디렉토리를 생성해주록 코드를 작성하면 된다.
# server.py
import os
from fastapi import FastAPI
UPLOAD_DIR = "uploaded_files"
if not os.path.exists(UPLOAD_DIR):
os.mkdir(UPLOAD_DIR)
app = FastAPI()
Server 파일 업로드 API
FastAPI에서 제공하는 UploadFile
이라는 클래스 객체를 메서드 인자로 받으면 HTTP 요청을 통해 들어오는 파일을 쉽게 처리할 수 있다. 이 객체를 통해 파일의 다양한 정보를 조회할 수 있다. 이 중에서 file
이라는 attribute는 실제 파일의 내용을 담고 있다. 파일 내용은 shutil
패키지의 copyfileobj
메서드를 통해 서버 내 특정 경로에 복사할 수 있다.
# server.py
import shutil
from typing import Any, Dict
from fastapi import UploadFile
@app.post("/upload")
async def upload_file(file: UploadFile) -> Dict[str, Any]:
upload_path = f"{UPLOAD_DIR}/{file.filename}"
with open(upload_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
print(f"Headers: {file.headers}")
print(f"Size: {file.size / 1024 / 1024 / 1024} GiB")
print(f"Filename: {file.filename}")
print(f"Content type: {file.content_type}")
return {"filename": file.filename, "content_type": file.content_type}
Client 파일 업로드 테스트
앞서 만든 server API에 POST 요청을 날려서 파일을 업로드하는 클라이언트 코드를 작성해보자. Python의 requests
패키지를 사용하면 간단하다. 이때, 전송할 파일을 dict
형식으로 감싸서 post
메서드의 files
라는 parameter에 다음과 같이 전달해야 한다.
# client.py
import requests
SERVER_URL = "http://localhost:8000"
def upload_file(filename: str) -> None:
with open(filename, "rb") as f:
files = {"file": (filename, f)}
response = requests.post(f"{SERVER_URL}/upload", files=files)
print(f"Status code: {response.status_code}")
print(f"Response: {response.json()}")
클라이언트 코드를 사용해서 server에 파일을 업로드해보겠다. 약 10 MiB 정도 크기의 "test.data"를 서버에 업로드한 뒤, "uploaded_files" 디렉토리에 파일이 잘 저장되었는지 확인해보자. 먼저 서버를 구동해주어야 한다.
$ uvicorn server:app
INFO: Started server process [46408]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
다음으로 client 쪽에서 "test.data" 파일의 경로를 upload_file
메서드에 담아서 서버에 업로드 요청을 보내면 된다.
>>> from client import upload_file
>>> upload_file("test.data")
Status code: 200
Response: {'filename': 'test.data', 'content_type': None}
서버 쪽의 로그를 살펴보면, 사용자의 요청이 정상적으로 처리되었음을 확인할 수 있다. "uploaded_files" 디렉토리에도 파일이 잘 저장되어 있는 것을 볼 수 있다.
$ uvicorn server:app
...
Headers: Headers({'content-disposition': 'form-data; name="file"; filename="test.data"'})
Size: 0.0107421875 GiB
Filename: test.data
Content type: None
INFO: 127.0.0.1:56088 - "POST /upload HTTP/1.1" 200 OK
$ ls uploaded_files
test.data
주의: 대용량 파일 업로드
앞서 작성한 client 코드를 사용해서 대용량 파일도 서버에 업로드할 수 있을까? 환경마다 다르겠지만, 실험 결과 약 1.5 GiB 이상 크기의 파일을 업로드하려고 시도하면 클라이언트 쪽에서 오류가 발생하며, 서버 측에는 아예 통신을 받았다는 로그조차 찍히지 않는다. 다음은 12 GiB 정도되는 "large.data"라는 파일을 업로드 시도한 결과다.
>>> from client import upload_file
>>> upload_file(filename="large.data")
...
requests.exceptions.ConnectionError: ('Connection aborted.', OSError(22, 'Invalid argument'))
원인은 requests
패키지를 통해 큰 파일을 서버에 전송하려고 할 때, 메모리에 전체 파일 내용을 적재한 뒤에 보내기 때문이다. 따라서, 시스템 환경에 따라 전송할 수 있는 파일의 최대 크기에 제약이 생긴다. 용량이 큰 파일을 전송하는 것은 포기해야 할까?
이 문제를 해결하기 위해서는 파일의 내용을 스트리밍 형식으로 보내야 한다. 이 기능을 구현하기 위해서는 requests
패키지의 개발자들이 참여하여 유지보수하고 있는 requests-toolbelt
라는 패키지의 힘을 빌려야 한다. 다음과 같이 패키지를 먼저 설치해주자.
$ pip install requests-toolbelt
설치가 완료되었다면 클라이언트 코드의 파일 업로드 메서드를 변경해보자. 주의할 점은 앞서 살펴본 코드와 달리 post
메서드에 데이터를 넘길 때 files
가 아니라 data
에 인자로 포함시켜야 한다는 것이다. 또한, headers
에도 Content-Type: multipart/form-data
가 포함되도록 헤더 정보를 넣어줘야 한다. 이때, MultipartEncoder
클래스 객체의 content_type
이라는 속성을 참조하면 편하다.
from requests_toolbelt.multipart.encoder import MultipartEncoder
def upload_file2(filename: str):
with open(filename, "rb") as file:
encoder = MultipartEncoder(fields={"file": (filename, file)})
print(f"Content type: {encoder.content_type}")
response = requests.post(
"http://localhost:8000/upload",
data=encoder,
headers={"Content-Type": encoder.content_type},
)
print(f"Status code: {response.status_code}")
print(f"Response: {response.json()}")
만약 header에 content type 정보를 추가하지 않으면, 파일 업로드 요청 시 서버에 다음 에러 로그가 찍히며 실패하게 된다.
$ uvicorn server:app
...
INFO: 127.0.0.1:56427 - "POST /upload HTTP/1.1" 422 Unprocessable Entity
변경된 클라이언트의 파일 업로드 메서드를 실행해보면, 다음과 같이 정상적으로 파일 업로드가 완료되는 것을 확인할 수 있다.
>>> from client import upload_file2
>>> upload_file2("large.data")
Status code: 200
Response: {'filename': 'large.data', 'content_type': None}
$ uvicorn server:app
...
Headers: Headers({'content-disposition': 'form-data; name="file"; filename="large.data"'})
Size: 12.0 GiB
Filename: large.data
Content type: None
INFO: 127.0.0.1:56575 - "POST /upload HTTP/1.1" 200 OK
$ ls uploaded_files
large.data
이를 통해 용량이 큰 파일을 서버에 업로드할 때는 클라이언트에서 스트리밍 방식으로 데이터를 보내야 한다는 교훈을 얻었다. 보통 웹 서버에 파일을 업로드하는 기능은 브라우저에서 알아서 처리해주므로 크게 신경 쓸 필요는 없긴 하다. 브라우저야 고마워!
File 다운로드 기능 개발
다운로드 위치
클라이언트는 서버에서 다운로드받은 파일을 특정 디렉토리에 저장할 수 있어야 한다. 보통 브라우저를 통해 다운로드한 파일들은 "~/Downloads" 디렉토리에 저장하므로, 이를 모방하여 다음과 같이 미리 클라이언트 코드 실행 전에 디렉토리 생성 로직을 작성해두자.
# client.py
import os
DOWNLOAD_DIR = "downloads"
if not os.path.exists(DOWNLOAD_DIR):
os.mkdir(DOWNLOAD_DIR)
Server 파일 다운로드 API
서버에 저장된 파일을 사용자에게 전송할 때는 FastAPI에서 제공하는 FileResponse
클래스를 사용하면 편하다. 전송할 파일의 경로를 전달하면 서버의 로컬 파일 시스템에 저장된 파일을 HTTP로 쉽게 보낼 수 있다. 이때, media type을 application/octet-stream
으로 설정해서 사용자에게 byte stream 형태로 파일이 전송된다는 사실을 알리도록 하자.
# server.py
from fastapi import HTTPException
from fastapi.responses import FileResponse
@app.get("/download/{filename}")
async def download_file(filename: str):
filepath = os.path.join(UPLOAD_DIR, filename)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(filepath, media_type="application/octet-stream", filename=filename)
Client 파일 다운로드 테스트
앞서 만든 server API에 GET 요청을 날려서 파일을 다운로드하는 클라이언트 코드를 작성해보자. 서버로부터 받은 byte stream을 "downloads" 디렉토리의 특정 파일에 복사해야 한다. requests
패키지의 get
메서드를 호출해서 받은 response
의 iter_content
메서드를 사용하면 byte stream을 chunk_size
만큼 끊어서 읽을 수 있다. 이를 활용하여 byte stream을 특정 경로의 파일에 저장하는 코드를 살펴보자.
# client.py
import os
def download_file(filename: str) -> None:
with requests.get(f"{SERVER_URL}/download/{filename}") as response:
filepath = os.path.join(f"{DOWNLOAD_DIR}", filename)
if response.status_code == 200:
with open(filepath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8 * 1024):
f.write(chunk)
print(f"Succeed: {filepath}")
else:
print(f"Failed: {response.status_code}")
위 코드를 실행하여 서버에 파일 다운로드를 요청하면 정상적으로 처리되는 것을 확인할 수 있다.
>>> from client import download_file
>>> download_file(filename="test.data")
Succeed: downloads/test.data
$ uvicorn server:app
...
INFO: 127.0.0.1:63519 - "GET /download/test10m.data HTTP/1.1" 200 OK
클라이언트에서 생성한 "downloads" 디렉토리에 서버로부터 받은 파일이 있는지 확인해보자.
$ ls downloads
test.data
주의: 대용량 파일 다운로드
클라이언트 측의 파일 다운로드 코드는 미리 chunk로 나누어서 받도록 작성해두었기 때문에, 큰 용량의 파일 다운로드를 요청하더라도 정상적으로 동작한다. 파일 업로드 테스트와 마찬가지로 12 GiB의 "large.data"로 확인해보자.
>>> from client import download_file
>>> download_file(filename="large.data")
Succeed: downloads/large.data
$ uvicorn server:app
...
INFO: 127.0.0.1:58656 - "GET /download/large.data HTTP/1.1" 200 OK
$ ls downloads
large.data
File 유효성 검증
파일을 공유하는 서버와 클라이언트를 개발할 때는 반드시 file 유효성 검증 로직을 도입해야 한다. 이 검증 로직을 통해 변조된 파일이 오고가지 않도록 방지하거나, 파일 전송의 성공 여부를 판단할 수 있다.
보통 파일 유효성 검증은 단순히 두 파일의 길이를 비교하거나, 두 파일의 내용에 MD5같은 해시 알고리즘을 각각 사용하여 해시값을 생성한 뒤 서로 비교하는 방법 등을 사용한다. 이 글에서는 분량 조절을 위해 다양한 파일 유효성 검증 방법에 대해 자세히 다루지는 않겠다.
그런데, 어쨌든 앞서 구현한 file 업로드와 다운로드 기능의 결과를 검증해야 해야 한다. 직관적인 이해를 돕기 위해 이 글에서는 무식하게 두 파일 내용의 처음부터 끝까지 훑어보고 정확히 일치하는지 검사하는 로직을 구현하여 사용하도록 하겠다.
import os
def check_file(filepath1: str, filepath2: str) -> bool:
if not os.path.exists(filepath1):
raise FileNotFoundError(filepath1)
if not os.path.exists(filepath2):
raise FileNotFoundError(filepath2)
if os.path.getsize(filepath1) != os.path.getsize(filepath2):
return False
buffer_size = 1024 * 1024
with open(filepath1, "rb") as file1, open(filepath2, "rb") as file2:
while True:
chunk1 = file1.read(buffer_size)
chunk2 = file2.read(buffer_size)
if chunk1 != chunk2:
return False
if not chunk1:
break
return True
위 코드를 사용해서 서버 쪽에 업로드된 "uploaded_files" 디렉토리의 파일과 클라이언트 쪽에 다운로드된 "downloads" 디렉토리의 파일을 비교해보면 다음과 같이 정확히 일치한다는 것을 확인할 수 있다.
>>> from file_checker import check_file
>>> check_file("downloads/test.data", "uploaded_files/test.data")
True
>>> check_file("downloads/large.data", "uploaded_files/large.data")
True
아직 뭔가 부족하다!
이 글에서는 웹 브라우저를 사용하는 상황을 가정해놓고 서버와 클라이언트 사이에서 파일을 교환하는 기초적인 방법만 다루었다. 사실 웹 브라우저를 사용하지 않고 자유롭게 개발할 수 있는 클라이언트를 사용한다면 성능을 더욱 최적화하기에 편하다. 그리고, 이 글에서는 미처 다루지 못한 다양한 추가 기능들도 구현할 수 있다. 안정적이고 강력한 파일 서버를 만드려면, 아직 고려해야 할 부분이 더 많다는 애기다.
대용량 파일을 마구 주고받아야 하는 production 환경을 가정해보자. 만약 어떤 사용자가 수백 기가바이트의 대용량 파일을 오랜 시간에 걸쳐 서버에 전송하다가 중간에 네트워크 연결이 불안정하여 잠시 서버가 다운된다면 어떻게 될까? 사용자가 처음부터 다시 파일을 업로드하는 고통을 감내해야 할까? 만약 사용자가 업로드하는 파일이 지나치게 커서 한 대의 서버에 담기 어렵다면 어떻게 저장해야 할까? 이러한 의문들의 해소는 다른 글에서 이어가도록 하겠다.
More Posts
안전하게 무한 루프 탈출하기 - Handling SIGTERM in kubernetes with python
프로그램의 유형에 따라 명확한 종료 시점 없이 반복적인 작업을 수행해야 하는 경우가 있다. 그런데, 영원한 것은 없지 않나! 언젠가는 종료를 시켜야 한다면, 어떻게 해야 안전하게 무한 루프를 빠져나올 수 있는지 알아보자.
Python에서 decimal의 precision 문제와 수의 표현 범위
Python의 기본 자료형인 float은 정밀한 수를 담거나 연산할 때 한계가 있다. 좀 더 정밀한 수를 다루기 위해서 decimal이라는 자료형을 사용하는데, 여전히 일부 연산에서는 precision 관련 오류가 발생한다. 어떤 문제가 있는지 살펴보자.
Validation 코드는 어디에 작성해야 할까? - The 3 types of validation logics
개발자들은 다양한 validation 코드들을 작성하는데 많은 시간을 소비한다. 이곳저곳에 덕지덕지 붙어있는 validation 코드들을 바라보면, 과연 이 코드들이 여기에 있어도 되는 것인지 의문이 생긴다. 다양한 종류의 validation 코드들을 어디에 작성해야 하는지 정리해보자.
Comments