FastAPI에서 파일 다운로드 구현 가이드: 방식별 특징과 한글 파일명 문제 해결

2025. 4. 16. 10:47·파이썬/Fast API
반응형

FastAPI는 파일 다운로드를 구현하기 위한 여러 방법을 제공합니다. 특히 FileResponse와 StreamingResponse는 각각 다른 상황에 최적화된 기능을 제공합니다. 이 글에서는 두 방식의 차이점과 한글 파일명 문제 해결, 그리고 다양한 파일 타입별 다운로드 구현 방법을 알아보겠습니다.

FileResponse와 StreamingResponse의 차이점

FileResponse

FileResponse는 파일 시스템에 있는 파일을 직접 제공하는 데 최적화된 응답 클래스입니다.

from fastapi import FastAPI
from fastapi.responses import FileResponse
import os

app = FastAPI()

@app.get("/download/file")
async def download_file():
    file_path = os.path.join(os.getcwd(), "files", "example.txt")
    return FileResponse(file_path, filename="downloaded_example.txt")

장점:

  • 파일 경로만 지정하면 되어 사용이 간단합니다.
  • 메모리 효율적: 전체 파일을 메모리에 로드하지 않고 스트리밍합니다.
  • filename, media_type 등 파일 다운로드에 필요한 헤더를 자동으로 설정합니다.
  • 적절한 청크 크기로 대용량 파일도 효율적으로 전송합니다.

적합한 상황:

  • 디스크에 저장된 정적 파일 제공
  • 대용량 파일 다운로드
  • 간단한 구현으로 파일 다운로드 제공 시

StreamingResponse

StreamingResponse는 바이트 스트림을 생성하는 제너레이터를 통해 데이터를 스트리밍하는 방식입니다.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import io

app = FastAPI()

@app.get("/download/stream")
async def download_stream():
    # 스트림 생성
    def file_generator():
        for i in range(10):
            yield f"Line {i}\n".encode('utf-8')
    
    # 응답 헤더 설정
    headers = {
        'Content-Disposition': 'attachment; filename="generated_file.txt"'
    }
    
    return StreamingResponse(file_generator(), media_type="text/plain", headers=headers)

장점:

  • 파일이 디스크에 없어도 실시간으로 생성하여 전송 가능합니다.
  • 메모리 사용을 최소화하며 스트리밍할 수 있습니다.
  • 동적 콘텐츠 생성에 유연합니다.

적합한 상황:

  • 동적으로 생성되는 콘텐츠
  • 데이터베이스 쿼리 결과를 직접 스트리밍
  • 대용량 데이터 처리 시 메모리 사용 최적화가 필요한 경우
  • 실시간으로 생성되는 데이터 스트림 제공

한글 파일명 문제 해결하기

한글이 포함된 파일명으로 다운로드 시 다음과 같은 에러가 발생할 수 있습니다:

UnicodeEncodeError: 'latin-1' codec can't encode characters in position 22-25: ordinal not in range(256)

이 문제는 HTTP 헤더가 기본적으로 ASCII 문자만 지원하기 때문에 발생합니다. RFC 6266과 RFC 5987에 따라 filename*=UTF-8''인코딩된_파일명 형식을 사용하여 해결할 수 있습니다.

해결 방법

import urllib.parse
from fastapi import FastAPI
from fastapi.responses import FileResponse

app = FastAPI()

@app.get("/download/korean")
async def download_korean_filename():
    file_path = "files/example.txt"
    
    # 한글 파일명
    filename = "한글파일명.txt"
    
    # RFC 5987 형식으로 인코딩
    encoded_filename = urllib.parse.quote(filename)
    
    headers = {
        "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return FileResponse(file_path, headers=headers)

이 방식을 사용하면:

  1. 일반 ASCII 지원 브라우저는 filename 값을 사용합니다.
  2. 유니코드를 지원하는 현대 브라우저는 filename* 값을 우선적으로 사용합니다.

다양한 파일 타입별 다운로드 구현

텍스트 파일 (TXT)

from fastapi import FastAPI
from fastapi.responses import FileResponse
import os

app = FastAPI()

@app.get("/download/text")
async def download_text():
    file_path = os.path.join("files", "document.txt")
    
    # 한글 파일명 처리
    filename = "텍스트문서.txt"
    encoded_filename = urllib.parse.quote(filename)
    
    headers = {
        "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return FileResponse(file_path, headers=headers, media_type="text/plain")

이미지 파일 (JPG)

@app.get("/download/image")
async def download_image():
    file_path = os.path.join("files", "image.jpg")
    
    # 한글 파일명 처리
    filename = "이미지.jpg"
    encoded_filename = urllib.parse.quote(filename)
    
    headers = {
        "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return FileResponse(file_path, headers=headers, media_type="image/jpeg")

PDF 파일

@app.get("/download/pdf")
async def download_pdf():
    file_path = os.path.join("files", "document.pdf")
    
    # 한글 파일명 처리
    filename = "문서파일.pdf"
    encoded_filename = urllib.parse.quote(filename)
    
    headers = {
        "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return FileResponse(file_path, headers=headers, media_type="application/pdf")

엑셀 파일 (XLSX)

엑셀 파일은 특별한 주의가 필요합니다. openpyxl 또는 pandas 라이브러리를 사용하여 동적으로 생성하는 경우가 많습니다.

import pandas as pd
from io import BytesIO
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import urllib.parse

app = FastAPI()

@app.get("/download/excel")
async def download_excel():
    # 데이터 생성 (예시)
    df = pd.DataFrame({
        '이름': ['홍길동', '김철수', '이영희'],
        '나이': [30, 25, 28],
        '직업': ['개발자', '디자이너', '마케터']
    })
    
    # 엑셀 파일로 변환
    excel_buffer = BytesIO()
    df.to_excel(excel_buffer, index=False, engine='openpyxl')
    excel_buffer.seek(0)
    
    # 한글 파일명 처리
    filename = "데이터분석.xlsx"
    encoded_filename = urllib.parse.quote(filename)
    
    headers = {
        "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return StreamingResponse(
        excel_buffer, 
        headers=headers, 
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    )

동적으로 생성되는 대용량 CSV 파일

대용량 데이터를 CSV로 제공할 때는 StreamingResponse가 메모리 효율성 측면에서 유리합니다.

import csv
from io import StringIO
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import urllib.parse

app = FastAPI()

@app.get("/download/large-csv")
async def download_large_csv():
    async def generate_csv():
        # CSV 헤더 생성
        buffer = StringIO()
        writer = csv.writer(buffer)
        writer.writerow(["ID", "이름", "이메일"])
        yield buffer.getvalue().encode('utf-8')
        buffer.seek(0)
        buffer.truncate(0)
        
        # 대량의 데이터를 청크로 생성
        for i in range(100000):  # 대량 데이터 예시
            writer.writerow([i, f"사용자{i}", f"user{i}@example.com"])
            output = buffer.getvalue().encode('utf-8')
            buffer.seek(0)
            buffer.truncate(0)
            yield output
    
    # 한글 파일명 처리
    filename = "대용량데이터.csv"
    encoded_filename = urllib.parse.quote(filename)
    
    headers = {
        "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return StreamingResponse(
        generate_csv(),
        headers=headers,
        media_type="text/csv"
    )

파일 다운로드 구현 시 추가 고려사항

1. 파일 타입별 적절한 MIME 타입 설정

파일 확장자별 MIME 타입을 정확히 설정하면 브라우저가 파일을 올바르게 처리할 수 있습니다.

def get_mime_type(file_extension):
    mime_types = {
        "txt": "text/plain",
        "pdf": "application/pdf",
        "jpg": "image/jpeg",
        "jpeg": "image/jpeg",
        "png": "image/png",
        "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        "xls": "application/vnd.ms-excel",
        "csv": "text/csv",
        "zip": "application/zip",
        "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        "mp4": "video/mp4",
        # 기타 필요한 확장자 추가
    }
    return mime_types.get(file_extension.lower(), "application/octet-stream")

@app.get("/download/{filename}")
async def download_file(filename: str):
    file_path = os.path.join("files", filename)
    
    # 파일 확장자 추출
    _, extension = os.path.splitext(filename)
    extension = extension[1:]  # 앞의 '.' 제거
    
    # MIME 타입 설정
    media_type = get_mime_type(extension)
    
    # 한글 파일명 처리
    encoded_filename = urllib.parse.quote(filename)
    
    headers = {
        "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return FileResponse(file_path, headers=headers, media_type=media_type)

2. 보안 고려사항

파일 다운로드 기능을 구현할 때는 경로 탐색 공격(path traversal)을 방지해야 합니다.

import os
from pathlib import Path
from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.get("/download/secure/{filename}")
async def download_secure(filename: str):
    # 안전한 디렉토리 경로
    base_dir = Path("safe_files")
    
    # 경로 조작 방지
    try:
        file_path = base_dir / filename
        file_path = file_path.resolve()
        
        # 요청된 파일이 base_dir 외부에 있는지 확인
        if not str(file_path).startswith(str(base_dir.resolve())):
            raise HTTPException(status_code=403, detail="접근이 금지된 파일입니다")
        
        if not os.path.exists(file_path):
            raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
        
        # 파일 다운로드 제공
        return FileResponse(file_path)
        
    except (ValueError, TypeError):
        raise HTTPException(status_code=400, detail="잘못된 파일명입니다")

3. 대용량 파일의 부분 다운로드 지원

HTTP Range 요청을 지원하면 대용량 파일의 부분 다운로드나 스트리밍 재생이 가능합니다. FastAPI의 FileResponse는 기본적으로 이를 지원합니다.

@app.get("/stream/video")
async def stream_video():
    video_path = os.path.join("files", "video.mp4")
    
    # FileResponse는 자동으로 Range 요청을 처리합니다
    return FileResponse(
        video_path,
        media_type="video/mp4",
        filename="동영상.mp4"
    )

통합 파일 다운로드 유틸리티 함수

위의 모든 내용을 고려한 통합 파일 다운로드 유틸리티 함수를 만들어 보겠습니다.

import os
import urllib.parse
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from pathlib import Path

app = FastAPI()

def create_download_response(
    file_path: str, 
    filename: Optional[str] = None,
    media_type: Optional[str] = None,
    as_attachment: bool = True
):
    """
    파일 다운로드 응답을 생성하는 유틸리티 함수
    
    Args:
        file_path: 파일 경로
        filename: 다운로드 시 표시될 파일명 (None이면 원본 파일명 사용)
        media_type: 미디어 타입 (None이면 자동 감지)
        as_attachment: True면 다운로드, False면 브라우저에서 열기
    
    Returns:
        FileResponse: 파일 다운로드 응답
    """
    # 파일 존재 확인
    if not os.path.exists(file_path):
        raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
    
    # 파일명 처리
    if filename is None:
        filename = os.path.basename(file_path)
    
    # 파일 확장자로 미디어 타입 추론
    if media_type is None:
        ext = os.path.splitext(filename)[1].lower()[1:]
        media_type = get_mime_type(ext)
    
    # 한글 파일명 처리
    encoded_filename = urllib.parse.quote(filename)
    
    # Content-Disposition 헤더 설정
    disposition_type = "attachment" if as_attachment else "inline"
    headers = {
        "Content-Disposition": f"{disposition_type}; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return FileResponse(
        file_path,
        headers=headers,
        media_type=media_type
    )

# 사용 예시
@app.get("/files/{filename}")
async def download_any_file(filename: str, inline: bool = False):
    file_path = os.path.join("files", filename)
    return create_download_response(file_path, as_attachment=not inline)

# 엑셀 파일 동적 생성 예시
@app.get("/reports/excel")
async def generate_excel_report():
    # 파일 동적 생성 (예시)
    df = pd.DataFrame({"데이터": range(10)})
    buffer = BytesIO()
    df.to_excel(buffer, index=False)
    buffer.seek(0)
    
    # 한글 파일명 처리
    filename = "보고서.xlsx"
    encoded_filename = urllib.parse.quote(filename)
    
    headers = {
        "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{encoded_filename}"
    }
    
    return StreamingResponse(
        buffer,
        headers=headers,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    )

결론

FastAPI는 FileResponse와 StreamingResponse를 통해 다양한 방식의 파일 다운로드를 구현할 수 있습니다. 정적 파일 제공에는 FileResponse가 효율적이며, 동적으로 생성되는 콘텐츠나 대용량 데이터는 StreamingResponse가 적합합니다.

한글 파일명 문제는 filename*=UTF-8''인코딩된_파일명 형식을 사용하여 해결할 수 있으며, 각 파일 타입에 맞는 적절한 MIME 타입 설정과 보안 고려사항을 함께 적용하면 안정적인 파일 다운로드 기능을 구현할 수 있습니다.

특히 엑셀 파일과 같은 바이너리 형식의 파일 다운로드에는 적절한 미디어 타입과 파일 확장자를 설정하여 브라우저가 올바르게 처리할 수 있도록 해야 합니다. 대용량 파일 처리 시에는 메모리 사용을 최소화하는 스트리밍 방식을 활용하는 것이 좋습니다.

FastAPI의 비동기 특성을 활용하면 높은 동시성을 지원하는 파일 다운로드 시스템을 구현할 수 있으며, 이는 대규모 API 서비스에서 특히 유용합니다.

저작자표시 비영리 변경금지 (새창열림)

'파이썬 > Fast API' 카테고리의 다른 글

FastAPI Response Set-Cookie와 HttpOnly 보안 구현  (1) 2025.02.27
FastAPI Security: API 보안 구현  (0) 2025.02.24
SlowAPI: FastAPI에서 Rate Limiting 구현  (0) 2025.02.23
FastAPI Permissions: 권한 관리 구현  (0) 2025.02.22
FastAPI-Users with MongoDB: JWT 인증  (0) 2025.02.21
'파이썬/Fast API' 카테고리의 다른 글
  • FastAPI Response Set-Cookie와 HttpOnly 보안 구현
  • FastAPI Security: API 보안 구현
  • SlowAPI: FastAPI에서 Rate Limiting 구현
  • FastAPI Permissions: 권한 관리 구현
코샵
코샵
나의 코딩 일기장
    반응형
  • 코샵
    끄적끄적 코딩 공방
    코샵
    • 분류 전체보기 (725)
      • 스마트팜 (0)
      • 상품 추천 (223)
      • MongoDB (4)
      • 하드웨어 (17)
      • 일기장 (4)
      • 파이썬 (130)
        • Basic (41)
        • OpenCV (8)
        • Pandas (15)
        • PyQT (3)
        • SBC(Single Board Computer) (1)
        • 크롤링 (14)
        • Fast API (29)
        • Package (6)
      • Unity (138)
        • Tip (41)
        • Project (1)
        • Design Pattern (8)
        • Firebase (6)
        • Asset (2)
      • Linux (4)
      • C# (97)
        • Algorithm (11)
        • Window (7)
      • TypeScript (51)
        • CSS (10)
      • Git (11)
      • SQL (5)
      • Flutter (10)
        • Tip (1)
      • System (1)
      • BaekJoon (6)
      • Portfolio (2)
      • MacOS (1)
      • 유틸리티 (1)
      • 서비스 (6)
      • 자동화 (3)
      • Hobby (10)
        • 물생활 (10)
        • 식집사 (0)
  • 인기 글

  • 태그

    Python
    리스트
    리뷰관리
    C#
    cv2
    셀레니움
    codingcommunity
    rtsp
    appdevelopment
    list
    리뷰이관
    상품 리뷰 크롤링
    ipcamera
    쇼핑몰리뷰
    unity
    programming101
    스마트스토어리뷰
    스크립트 실행
    learntocode
    파이썬
    programmerlife
    devlife
    긴유통기한우유
    스크립트 실행 순서
    카페24리뷰이관
    라떼우유
    codingtips
    카페24리뷰
    유니티
    믈레코비타멸균우유
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
코샵
FastAPI에서 파일 다운로드 구현 가이드: 방식별 특징과 한글 파일명 문제 해결
상단으로

티스토리툴바