큰 파일을 response.content로 받으면 응답 전체를 메모리에 두는 부담이 생길 수 있습니다. 파이썬 requests에서는 stream=True와 iter_content()로 나누어 저장하고, 상태 확인 후 임시 파일에 쓴 다음 필요한 검증을 통과했을 때만 최종 이름으로 바꾸는 방법을 선택할 수 있습니다. 읽기나 저장이 실패하면 부분 파일을 후속 작업에 넘기지 않도록 정리합니다.
메모리 문제인지, 불완전한 파일인지 먼저 구분하기
짧고 크기가 관리 가능한 응답이라면 전체를 읽는 편이 간단할 수 있습니다. 반면 크기를 예측하기 어려운 백업이나 데이터 파일을 저장하는 작업이라면 스트리밍이 적합합니다. requests.get()은 기본적으로 응답 본문을 즉시 내려받습니다. stream=True를 지정하면 응답 헤더를 먼저 받고 본문은 필요할 때 읽습니다. iter_content()로 청크를 순회하며 파일에 쓰면 응답 전체를 한 번에 메모리에 둘 필요가 없습니다. Requests API와 Advanced Usage에서 이 동작을 확인할 수 있습니다.
메모리 부담과 파일 무결성은 별개의 문제입니다. 스트리밍으로 바꿔도 중간에 끊긴 파일을 최종 이름으로 저장하거나, 서버가 정상 상태로 보낸 잘못된 내용을 그대로 사용하면 후속 처리가 실패할 수 있습니다. 먼저 상태와 저장 단계의 실패를 구분하고, 내용이 중요한 파일에는 별도 검증을 더해야 합니다.
상태 확인 후 임시 파일에 저장하기
다음 예제는 기존 Requests 설치를 전제로 합니다. 임시 파일을 목적지와 같은 디렉터리에 고유한 이름으로 만들고, 읽기와 쓰기가 예외 없이 끝난 뒤 최종 파일로 교체합니다. 내용 검증은 포함하지 않은 저장 예제이므로, 해시나 파일 형식 확인이 필요한 경우 아래에서 설명하는 위치에 검증을 추가해야 합니다.
from pathlib import Path
import os
import tempfile
import requests
def download_file(url: str, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with requests.get(url, stream=True, timeout=30) as response:
response.raise_for_status()
with tempfile.NamedTemporaryFile(
mode="wb", dir=destination.parent, prefix=".download-", delete=False
) as output:
temporary = Path(output.name)
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
output.write(chunk)
# 필요한 내용 검증은 임시 파일을 대상으로 이 위치에서 수행합니다.
os.replace(temporary, destination)
temporary = None
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
raise_for_status()는 HTTP 오류가 발생하면 HTTPError를 발생시킵니다. 임시 파일을 열기 전에 확인하면 HTTP 오류 응답을 다운로드 파일로 저장하는 일을 피할 수 있습니다. 다만 오류가 없는 상태 코드가 파일 내용까지 검증해 주지는 않습니다. Requests Quickstart는 바이너리 파일에 청크를 쓰는 방식을 설명합니다.
iter_content()의 chunk_size는 읽을 바이트 크기를 지정하지만 반환되는 각 청크가 반드시 그 길이인 것은 아닙니다. chunk_size=1024 * 1024는 예제로 선택한 값이지 모든 환경에 최적인 크기는 아닙니다. timeout=30도 예제 값이며 운영 환경에 맞게 정해야 합니다. 이 값들은 실제 운영 측정 결과가 아닙니다. Requests API를 참고해 청크 크기와 시간 제한을 작업 조건에 맞추세요.
응답을 전부 소비하지 못해도 with 범위를 벗어나면서 응답이 닫힙니다. 일반적인 예외가 발생하면 finally가 임시 파일 삭제를 시도하며, 예외는 호출자에게 전달됩니다. 삭제 자체가 실패할 수도 있고 프로세스가 강제로 종료되면 정리 코드가 실행되지 않을 수 있으므로, 남은 임시 파일은 작업이 진행 중인지 확인한 뒤 따로 정리해야 합니다. 응답 정리에 관한 설명은 Requests Advanced Usage를 참고하세요.
기존 파일과 실패 원인에 따라 다음 행동 고르기
- HTTP 오류가 났다면 주소와 접근 조건, 서버 응답을 확인합니다. 이 예제에서는 상태 확인이 임시 파일 생성보다 앞서므로 새 부분 파일을 만들지 않습니다.
- 본문을 읽거나 디스크에 쓰는 동안 예외가 났다면 예외 내용과 저장 공간·권한 등 해당 상황을 확인합니다. 원인을 확인하지 않고 네트워크 문제로 단정하지 않습니다. 실패 시 기존 최종 파일은 교체하지 않지만, 남은 임시 파일이 있는지는 확인해야 합니다.
- 예외 없이 교체가 끝났더라도 기존 최종 파일이 있었다면 새 파일로 대체됩니다. 기존 파일을 보존해야 한다면 다른 목적지나 별도 보관 정책을 정하고 실행하세요. 같은 목적지에 여러 작업이 동시에 쓰면 마지막 교체가 결과를 결정할 수 있으므로 작업 간 조정도 필요합니다.
- 프로세스가 강제로 종료됐다면 임시 파일이 남을 수 있습니다. 고유 임시 이름은 동시 작업의 임시 파일 충돌을 피하기 위한 것이며, 이어받기 정보는 아닙니다. 사용 중이 아닌 파일을 확인한 다음 삭제하거나 별도 복구 절차로 다룹니다.
임시 저장과 교체는 애플리케이션의 설계 선택입니다. 최종 이름이 뜻하는 것은 이 예제에서 저장 단계가 예외 없이 끝났다는 사실까지입니다. 서버가 잘못된 내용을 정상 응답으로 보낸 경우까지 보증하지는 않습니다.
Content-Length와 실제 내용은 어떻게 확인할까
응답의 Content-Length는 콘텐츠 길이를 바이트 단위로 나타내며 서버가 보내지 않을 수도 있습니다. 또한 iter_content()는 gzip·deflate 인코딩을 해제할 수 있으므로 헤더 값과 저장된 바이트 수를 무조건 같은 기준으로 비교하면 안 됩니다. 길이의 의미는 RFC 9110, 디코딩 동작은 Requests Quickstart를 참고하세요.
Content-Length가 없거나 부정확하다면 다운로드 완료 여부를 파일 크기 하나로 확정할 수 없습니다. 신뢰할 수 있는 제공자가 별도 해시를 제공하면 임시 파일의 해시와 비교하고, 형식이 정해진 파일이라면 파서나 압축 검사로 읽을 수 있는지도 확인합니다. 형식 검사는 내용의 정확성 전체를 보증하지 않습니다. 검증이 실패하면 os.replace()를 호출하지 말고 임시 파일을 삭제하거나, 별도 정책에 따라 격리하세요. 위 예제에 검증 함수를 추가한다면 주석 위치에서 실패 시 예외를 발생시켜 정리 경로로 넘길 수 있습니다.
다시 받기와 이어받기 선택하기
이 예제는 실패한 부분을 정리하고 오류를 호출자에게 알립니다. 다시 실행한다면 처음부터 받습니다. 이어받기를 원한다면 Range 요청, 서버의 응답 상태와 범위 확인, 기존 파일과 새 응답의 결합 규칙을 별도로 설계해야 합니다. stream=True와 iter_content()만으로 이어받기가 자동 구현되지는 않습니다. 파일을 다시 받는 비용과 부분 파일을 검증·관리할 필요를 비교해 선택하세요.