# Django/Security 이미지 업로드, 무작정 받다간 서버 터집니다: 보안과 효율을 모두 잡는 완벽한 가이드 웹 서비스에서 이미지 업로드는 “늘 있는 기능”이라 대충 만들기 쉽습니다. 하지만 업로드 엔드포인트는 **외부 데이터가 서버 내부로 들어오는 가장 직접적인 통로**이고, 공격자 입장에서는 **가장 싼 비용으로 가장 큰 피해를 노릴 수 있는 지점**입니다. (웹쉘 업로드, 이미지 파서 취약점, DoS 등) 이 글은 “보안은 편집증적으로, 자원은 경제적으로”라는 모순처럼 보이는 목표를 **둘 다 만족**시키는 방법을 정리합니다. 핵심은 간단합니다. * **검증 단계에서는 최대한 ‘안 읽는다’** (디코딩 금지, 헤더/메타데이터만) * **저장 단계에서는 과감하게 ‘다시 만든다’** (Transcoding으로 Sanitizing) --- ## 업로드 보안의 본질: “신뢰하지 말고, 최대한 늦게 믿어라” {#sec-98341e6a33e1} 업로드에서 우리가 믿을 수 있는 건 거의 없습니다. * 파일명: 사용자가 바꾼다 * 확장자: 마음대로 바꾼다 * Content-Type: 클라이언트가 써서 보낸다 * 파일 내용: 공격자가 만든다 따라서 전략은 2가지로 수렴합니다. 1. **가벼운 비용으로 빠르게 거른다** (cheap checks) 2. **최종 저장본은 항상 서버가 만든다** (server-generated artifact) --- ## 흔한 오해: “확실하게 검사하려면 끝까지 읽어봐야죠?” {#sec-f9b6b6b99eb2} 보안을 잘하려는 개발자들이 자주 하는 실수가 있습니다. 업로드 검증 단계에서 아래 같은 코드를 넣는 겁니다. ```python img = Image.open(file) img.verify() # 또는 img.load() ``` 문제는 이게 **서버 자원을 공격자에게 ‘선불로 결제’해주는 행위**라는 점입니다. ### 왜 위험한가? {#sec-7ed56ee48a1d} * **압축 폭탄(Decompression Bomb)** 겉보기엔 몇 MB인데, 디코딩하면 수십 GB가 되는 파일이 들어올 수 있습니다. `load()`는 실제 픽셀 디코딩을 유발하고, 메모리/CPU를 순식간에 고갈시켜 DoS로 이어집니다. * **불필요한 I/O** `verify()`는 파일을 끝까지 읽는 성격이라 I/O 비용이 크고, 이후 처리를 하려면 보통 다시 `seek(0)` / 재오픈이 필요합니다. **결론:** 업로드 “검증” 단계에서 픽셀을 디코딩하지 마세요. **헤더 + 메타데이터 + 해상도 제한**만으로 1차 방어는 충분히 강력합니다. --- ## 방어는 한 방이 아니라 3단계: Defense in Depth {#sec-5756153c302b} 이미지 업로드는 “한 줄짜리 if”로 끝낼 수 있는 문제가 아닙니다. 현실적인 균형점을 만들려면 레이어를 쌓아야 합니다. ### 1단계: 확장자는 거짓말이다 — Magic Number로 MIME 판별 {#sec-bbad131e1332} `profile.png`라는 이름은 아무 의미가 없습니다. 파일의 **시그니처(Magic Number)**를 읽어 실제 타입을 확인해야 합니다. * 파일 전체를 `read()` 하지 마세요. **앞부분 1~2KB면 충분**합니다. * 라이브러리 예: `python-magic` (libmagic 기반) ### 2단계: Pillow는 “열기만” 하면 아직 안전하다 — Lazy Loading으로 해상도 제한 {#sec-f0a8fb4bda8a} Pillow의 `Image.open()`은 일반적으로 **즉시 픽셀을 로드하지 않고 헤더만 파싱**합니다. 이 성질을 이용하면, 디코딩/메모리 폭탄을 유발하기 전에 **해상도(픽셀 수)로 차단**할 수 있습니다. * 체크할 것: `width * height <= MAX_PIXELS` * 포인트: `load()` / `verify()` 없이 `size`만 본다 ### 3단계: 최고의 소독은 “새로 그리기” — Transcoding으로 Sanitizing {#sec-398d6ae888a9} 가장 중요한 원칙입니다. > 원본을 그대로 저장하지 마세요. 이미지는 메타데이터(EXIF), 프로파일, 슬랙 공간, 파서 트릭 등 **“픽셀이 아닌 영역”**에 이상한 것들이 숨어들 수 있습니다. 반대로, 픽셀 데이터만 추출해서 서버가 새 포맷으로 다시 저장하면 상당 부분이 자연스럽게 제거됩니다. * 권장: **WebP(또는 AVIF/JPEG)**로 서버가 재인코딩하여 저장 * 효과: **Sanitizing + 용량 최적화 + 일관된 포맷 정책** --- ## 실전 구현: DRF Serializer (보안 + 메모리 경제성) {#sec-7b99e0ec685d} 아래 코드는 “검증 단계에서 최대한 안 읽고”, “저장 단계에서 다시 만든다”는 철학을 그대로 담습니다. * `f.size`는 Django가 이미 알고 있는 메타데이터이므로 적극 활용 * Magic Number는 **앞부분만** * Pillow는 `open()`으로 **해상도만** * 최종 저장은 **Transcoding(WebP)** * 매 단계마다 `seek(0)`로 파일 포인터 원복 (실수하면 다음 단계가 깨집니다) ```python 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 “자원의 경제성” 균형 잡는 법 {#sec-7ec5087de2e9} 여기서 중요한 균형 감각은 이겁니다. ### 검증 단계에서 욕심내지 말기 {#sec-5c32bd66af4d} 검증 단계는 트래픽이 가장 많이 몰리고, 공격자가 비용 없이 반복 호출 가능한 영역입니다. 여기서 `load()` 같은 “비싼 연산”을 해버리면, 공격자가 서버 비용을 마음대로 태울 수 있습니다. * ✅ 용량 제한 / 헤더 기반 MIME / 해상도 제한 * ❌ 픽셀 디코딩 강제 / 파일 전체 읽기 / 다중 재오픈 ### “진짜 안전”은 저장 단계에서 만든다 {#sec-a49dab56fd86} 검증 단계는 “거르는” 단계이고, 저장 단계는 “표준화” 단계입니다. 저장 단계에서 서버가 **새로운 바이트 스트림을 생성**하면 보안과 운영이 쉬워집니다. * 포맷 통일 → 캐시 전략/썸네일 파이프라인 단순화 * 메타데이터 정리 → 개인정보(EXIF GPS) 제거에도 유리 * 악성 페이로드 삽입 여지 축소 --- ## 이런 부분까지 챙긴다면 더욱 완벽! {#sec-733fb04d797a} ![악의적인 파일업로드를 방어하는 이미지](/media/editor_temp/6/60e76b29-7657-4042-8e5b-3b7b2670a4c7.png) * **파일명은 절대 신뢰하지 말고 서버에서 생성** (UUID 권장) * **업로드는 앱 서버가 직접 받지 않는 구조도 고려** (대규모 서비스면 presigned URL로 객체 스토리지 직업로드 + 비동기 검사/변환) * **처리 시간 제한/워커 격리** 이미지 변환은 CPU를 씁니다. 웹 요청-응답 경로에서 장시간 잡고 있지 말고 워커/큐로 분리하는 것도 현실적인 선택입니다. * **로그/메트릭** 거절 사유(MIME 불일치, 해상도 초과, 크기 초과)를 집계하면 공격/오용 패턴이 빨리 보입니다. --- ## 요약 체크리스트 {#sec-ab5708035e92} 1. **메모리에 전체 파일을 올리지 마세요.** `read()`는 앞부분만, 나머지는 스트리밍/파일 객체 기반. 2. **확장자/Content-Type을 믿지 말고 Magic Number로 MIME 확인** 3. **검증 단계에서 `load()`/`verify()`로 디코딩하지 말고 해상도만 체크** 4. **원본을 저장하지 말고 Transcoding으로 서버가 새 파일을 생성** 5. **파일 포인터는 매 단계 `seek(0)`로 원복** 업로드 보안은 “사용자를 의심하는 마음”에서 시작하지만, 업로드 성능은 “시스템이 어떻게 일하는지 이해하는 태도”에서 완성됩니다. 둘 중 하나만 챙기면 결국 운영에서 터집니다. 둘을 함께 잡는 구조로, 업로드 포트를 안전하게 만드세요. --- **관련 포스트도 확인해보세요** - [[웹에서 파일업로드 쉽게하기] Dropzone.js 완전 정복 가이드](/ko/whitedec/2025/12/24/dropzone-js-complete-guide/)