Django/Security 이미지 업로드, 무작정 받다간 서버 터집니다: 보안과 효율을 모두 잡는 완벽한 가이드
웹 서비스에서 이미지 업로드는 “늘 있는 기능”이라 대충 만들기 쉽습니다. 하지만 업로드 엔드포인트는 외부 데이터가 서버 내부로 들어오는 가장 직접적인 통로이고, 공격자 입장에서는 가장 싼 비용으로 가장 큰 피해를 노릴 수 있는 지점입니다. (웹쉘 업로드, 이미지 파서 취약점, DoS 등)
이 글은 “보안은 편집증적으로, 자원은 경제적으로”라는 모순처럼 보이는 목표를 둘 다 만족시키는 방법을 정리합니다. 핵심은 간단합니다.
- 검증 단계에서는 최대한 ‘안 읽는다’ (디코딩 금지, 헤더/메타데이터만)
- 저장 단계에서는 과감하게 ‘다시 만든다’ (Transcoding으로 Sanitizing)
업로드 보안의 본질: “신뢰하지 말고, 최대한 늦게 믿어라”
업로드에서 우리가 믿을 수 있는 건 거의 없습니다.
- 파일명: 사용자가 바꾼다
- 확장자: 마음대로 바꾼다
- Content-Type: 클라이언트가 써서 보낸다
- 파일 내용: 공격자가 만든다
따라서 전략은 2가지로 수렴합니다.
- 가벼운 비용으로 빠르게 거른다 (cheap checks)
- 최종 저장본은 항상 서버가 만든다 (server-generated artifact)
흔한 오해: “확실하게 검사하려면 끝까지 읽어봐야죠?”
보안을 잘하려는 개발자들이 자주 하는 실수가 있습니다. 업로드 검증 단계에서 아래 같은 코드를 넣는 겁니다.
img = Image.open(file)
img.verify() # 또는 img.load()
문제는 이게 서버 자원을 공격자에게 ‘선불로 결제’해주는 행위라는 점입니다.
왜 위험한가?
- 압축 폭탄(Decompression Bomb)
겉보기엔 몇 MB인데, 디코딩하면 수십 GB가 되는 파일이 들어올 수 있습니다.
load()는 실제 픽셀 디코딩을 유발하고, 메모리/CPU를 순식간에 고갈시켜 DoS로 이어집니다. - 불필요한 I/O
verify()는 파일을 끝까지 읽는 성격이라 I/O 비용이 크고, 이후 처리를 하려면 보통 다시seek(0)/ 재오픈이 필요합니다.
결론: 업로드 “검증” 단계에서 픽셀을 디코딩하지 마세요. 헤더 + 메타데이터 + 해상도 제한만으로 1차 방어는 충분히 강력합니다.
방어는 한 방이 아니라 3단계: Defense in Depth
이미지 업로드는 “한 줄짜리 if”로 끝낼 수 있는 문제가 아닙니다. 현실적인 균형점을 만들려면 레이어를 쌓아야 합니다.
1단계: 확장자는 거짓말이다 — Magic Number로 MIME 판별
profile.png라는 이름은 아무 의미가 없습니다. 파일의 시그니처(Magic Number)를 읽어 실제 타입을 확인해야 합니다.
- 파일 전체를
read()하지 마세요. 앞부분 1~2KB면 충분합니다. - 라이브러리 예:
python-magic(libmagic 기반)
2단계: Pillow는 “열기만” 하면 아직 안전하다 — Lazy Loading으로 해상도 제한
Pillow의 Image.open()은 일반적으로 즉시 픽셀을 로드하지 않고 헤더만 파싱합니다. 이 성질을 이용하면, 디코딩/메모리 폭탄을 유발하기 전에 해상도(픽셀 수)로 차단할 수 있습니다.
- 체크할 것:
width * height <= MAX_PIXELS - 포인트:
load()/verify()없이size만 본다
3단계: 최고의 소독은 “새로 그리기” — Transcoding으로 Sanitizing
가장 중요한 원칙입니다.
원본을 그대로 저장하지 마세요.
이미지는 메타데이터(EXIF), 프로파일, 슬랙 공간, 파서 트릭 등 “픽셀이 아닌 영역”에 이상한 것들이 숨어들 수 있습니다. 반대로, 픽셀 데이터만 추출해서 서버가 새 포맷으로 다시 저장하면 상당 부분이 자연스럽게 제거됩니다.
- 권장: WebP(또는 AVIF/JPEG)로 서버가 재인코딩하여 저장
- 효과: Sanitizing + 용량 최적화 + 일관된 포맷 정책
실전 구현: DRF Serializer (보안 + 메모리 경제성)
아래 코드는 “검증 단계에서 최대한 안 읽고”, “저장 단계에서 다시 만든다”는 철학을 그대로 담습니다.
f.size는 Django가 이미 알고 있는 메타데이터이므로 적극 활용- Magic Number는 앞부분만
- Pillow는
open()으로 해상도만 - 최종 저장은 Transcoding(WebP)
- 매 단계마다
seek(0)로 파일 포인터 원복 (실수하면 다음 단계가 깨집니다)
from io import BytesIO
import magic
from PIL import Image, ImageOps, UnidentifiedImageError
from rest_framework import serializers
# 정책(서비스에 맞게 조정)
MAX_SIZE = 5 * 1024 * 1024 # 5MB
MAX_PIXELS = 4_194_304 # 2048 * 2048 = 약 4MP
ALLOWED_MIME = {"image/png", "image/jpeg", "image/webp"}
class SecureImageUploadSerializer(serializers.Serializer):
file = serializers.ImageField()
def validate_file(self, f):
# [1] 용량 제한: 가장 싸고 빠른 필터
if f.size > MAX_SIZE:
raise serializers.ValidationError("파일 크기가 너무 큽니다.")
# [2] Magic Number로 MIME 확인: 확장자/Content-Type 불신
f.seek(0)
head = f.read(2048) # 앞부분만
f.seek(0)
mime = magic.from_buffer(head, mime=True)
if mime not in ALLOWED_MIME:
raise serializers.ValidationError("지원하지 않는 파일 형식입니다.")
# [3] 해상도 제한: load/verify 없이 헤더 기반으로 size만 확인
try:
with Image.open(f) as img:
w, h = img.size
if (w * h) > MAX_PIXELS:
raise serializers.ValidationError("이미지 해상도가 너무 큽니다.")
except UnidentifiedImageError:
raise serializers.ValidationError("유효하지 않은 이미지입니다.")
except Exception:
raise serializers.ValidationError("이미지 검증 중 오류가 발생했습니다.")
finally:
f.seek(0)
return f
def create(self, validated_data):
f = validated_data["file"]
# 최종 저장본은 항상 서버가 생성 (Sanitizing)
try:
with Image.open(f) as img:
# EXIF 회전 보정 (모바일 업로드에서 특히 중요)
img = ImageOps.exif_transpose(img)
# 안전하고 일관된 색 공간으로 정규화
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
out = BytesIO()
img.save(out, format="WEBP", quality=85, method=6)
out.seek(0)
safe_bytes = out.getvalue()
# 여기서 safe_bytes를 스토리지에 저장하세요.
# - 파일명 난수화(UUID)
# - 디렉토리 샤딩(예: ab/cd/uuid.webp)
# - DB에는 원본명 대신 서버 생성 키만 저장
return safe_bytes
except Exception:
raise serializers.ValidationError("이미지 처리 중 오류가 발생했습니다.")
“편집증적인 보안” vs “자원의 경제성” 균형 잡는 법
여기서 중요한 균형 감각은 이겁니다.
검증 단계에서 욕심내지 말기
검증 단계는 트래픽이 가장 많이 몰리고, 공격자가 비용 없이 반복 호출 가능한 영역입니다.
여기서 load() 같은 “비싼 연산”을 해버리면, 공격자가 서버 비용을 마음대로 태울 수 있습니다.
- ✅ 용량 제한 / 헤더 기반 MIME / 해상도 제한
- ❌ 픽셀 디코딩 강제 / 파일 전체 읽기 / 다중 재오픈
“진짜 안전”은 저장 단계에서 만든다
검증 단계는 “거르는” 단계이고, 저장 단계는 “표준화” 단계입니다. 저장 단계에서 서버가 새로운 바이트 스트림을 생성하면 보안과 운영이 쉬워집니다.
- 포맷 통일 → 캐시 전략/썸네일 파이프라인 단순화
- 메타데이터 정리 → 개인정보(EXIF GPS) 제거에도 유리
- 악성 페이로드 삽입 여지 축소
이런 부분까지 챙긴다면 더욱 완벽!

- 파일명은 절대 신뢰하지 말고 서버에서 생성 (UUID 권장)
- 업로드는 앱 서버가 직접 받지 않는 구조도 고려 (대규모 서비스면 presigned URL로 객체 스토리지 직업로드 + 비동기 검사/변환)
- 처리 시간 제한/워커 격리 이미지 변환은 CPU를 씁니다. 웹 요청-응답 경로에서 장시간 잡고 있지 말고 워커/큐로 분리하는 것도 현실적인 선택입니다.
- 로그/메트릭 거절 사유(MIME 불일치, 해상도 초과, 크기 초과)를 집계하면 공격/오용 패턴이 빨리 보입니다.
요약 체크리스트
- 메모리에 전체 파일을 올리지 마세요.
read()는 앞부분만, 나머지는 스트리밍/파일 객체 기반. - 확장자/Content-Type을 믿지 말고 Magic Number로 MIME 확인
- 검증 단계에서
load()/verify()로 디코딩하지 말고 해상도만 체크 - 원본을 저장하지 말고 Transcoding으로 서버가 새 파일을 생성
- 파일 포인터는 매 단계
seek(0)로 원복
업로드 보안은 “사용자를 의심하는 마음”에서 시작하지만, 업로드 성능은 “시스템이 어떻게 일하는지 이해하는 태도”에서 완성됩니다. 둘 중 하나만 챙기면 결국 운영에서 터집니다. 둘을 함께 잡는 구조로, 업로드 포트를 안전하게 만드세요.
관련 포스트도 확인해보세요
댓글이 없습니다.