# 보안 관점에서 이해하는 Pillow의 `open()`, `verify()`, `load()` 이미지 업로드는 “그림 파일을 받는다”가 아니라, **외부 입력을 디코더(파서)에 통과시키는 일**입니다. 그래서 Pillow의 세 메서드는 기능 설명보다도, **언제 호출하느냐(=공격 표면을 언제 열어주느냐)**가 핵심입니다. --- ## `open()`은 “픽셀을 올리는 함수”가 아니다 {#sec-2e015f3fe462} `Image.open()`은 **lazy** 동작입니다. 즉, 파일을 “열고 식별”만 하고, **픽셀 데이터는 아직 읽지 않을 수 있습니다.** 그리고 파일 핸들이 열린 채로 남을 수 있습니다. 보안/운영에서 `open()`을 잘 쓰는 방식은 단순합니다. * `open()`으로 포맷 식별, 폭/높이 같은 **가벼운 정보**를 먼저 얻고 * 정책으로 차단: 허용 포맷, 최대 해상도/픽셀 수, 업로드 용량 제한 * 그 다음 단계(검증/디코딩)를 진행 즉, `open()`은 “디코딩 전 단계에서 판단할 정보를 뽑는 도구”로 쓰는 게 안전합니다. --- ## `verify()`는 무엇을 보장하고, 무엇을 보장하지 않는가 {#sec-bb315850bac9} Pillow의 `verify()`는 “파일이 깨졌는지”를 확인하려고 시도하지만, **실제 이미지 데이터를 디코딩하지 않고** 검사합니다. 문제가 있으면 예외를 던지고, `verify()` 후에 이미지를 계속 쓰려면 **파일을 다시 열어야** 합니다. 여기서 보안 관점의 결론은 두 가지입니다. * **장점:** 디코딩(=무거운 작업)을 피하면서 “망가진 파일”을 빠르게 걸러낼 수 있음 * **한계:** `verify()` 통과는 “안전”이 아니라 “당장 크게 깨져 보이지 않음”에 가깝습니다. 디코딩을 끝까지 수행하지 않기 때문에, `load()` 시점에야 드러나는 문제도 존재할 수 있습니다. --- ## `load()`는 검증 단계에서 함부로 호출하면 위험하다 {#sec-1eb3249bd446} `load()`는 실제로 **디코딩(압축 해제 포함)을 수행해 픽셀을 메모리에 올리는 단계**입니다. 이 지점이 곧바로 DoS(자원 고갈) 공격 표면이 됩니다. 겉보기 파일 크기가 작아도, 디코딩 결과가 매우 커질 수 있기 때문입니다. Pillow는 “디컴프레션 봄” 위험을 경고/예외로 다루며, 기본 임계치(예: 128Mpx 수준) 같은 보호 장치를 두고 있습니다. Django도 같은 이유로, 이미지 업로드 검증에서 `load()` 대신 `verify()`를 씁니다. 소스에 **“load()는 전체 이미지를 메모리에 올려 DoS 벡터가 된다”**는 코멘트가 있고, 실제로 `Image.open()` 후 `verify()`를 호출합니다. --- ## Django/DRF 사용 시: `ImageField`에서 `verify()`를 또 하면 중복일 수 있다 {#sec-c03f143819d2} Django의 폼 `ImageField` 검증이 내부에서 `Image.open()` + `verify()`를 수행한다는 건 앞에서 본 그대로입니다. DRF의 `serializers.ImageField`도 이미지 검증을 “Django 구현에 위임한다”는 주석과 함께 Django 쪽 검증을 호출하는 흐름을 갖습니다. 그래서 DRF에서 `serializers.ImageField`를 이미 쓰고 있다면: * 단순히 “손상 여부 확인”을 위해 `validate()`에서 `verify()`를 또 호출하는 건 **대개 중복 작업**이 됩니다. * 비즈니스 검증/추가 보안 검증을 강하게 커스터마이즈하려면, **`ImageField` 대신 `FileField`로 받고**(검증 파이프라인을 직접 설계) 비용과 책임을 명확히 가져가는 선택이 더 깔끔할 수 있습니다. --- ## 사용자가 업로드한 파일에 대해 “안전”을 확보하려면 {#sec-bafb5e21c1fb} ![업로드 파일을 안전하게 처리하는 다이어그램](/media/editor_temp/6/489648ac-eb83-4132-808b-f3ce0e07c366.png) 가장 현실적인 답은 이겁니다. **“업로드 원본을 그대로 쓰지 말고, 서버가 디코딩해서 새 파일로 다시 저장한 결과물만 사용한다.”** * `open()`으로 폭/높이/포맷 등 **저비용 정보**를 읽고 정책으로 1차 차단 * `verify()`로 **명백히 깨진 파일**을 제거 * 그 다음(통과한 것만) 제한된 환경에서 디코딩 후 **RGB/RGBA 같은 표준 픽셀로 정규화** * 서버가 선택한 포맷으로 **재인코딩하여 새 파일 생성** * 서비스는 **서버가 재생성한 파일만** 저장/서빙 이 전략의 장점은 “서버가 최종 출력의 형태를 통제한다”는 점입니다. 원본에 있던 불필요한 메타데이터나 이상한 구조를 상당 부분 제거할 수 있습니다. 단, 재인코딩은 결국 `load()`에 준하는 디코딩을 포함합니다. 따라서 **픽셀 수/메모리 제한(디컴프레션 봄 방어)** 같은 가드레일을 먼저 세우고, 가능하면 워커/격리 프로세스에서 실행하는 것이 안전합니다. --- ## 정리 {#sec-7d348ef3294d} * `open()` : “식별 + 저비용 정보 확인” 단계(픽셀은 아직 아닐 수 있음) * `verify()` : “손상 여부 1차 필터”(디코딩 없이 검사, 이후 사용하려면 재오픈) * `load()` : “디코딩/메모리 사용이 시작되는 지점”(검증 단계에서 남발 금지) * 업로드 안전의 실무 답: **서버가 재인코딩한 결과물만 신뢰**(단, 제한/격리 필수) **관련글 보기** : - [개발자가 바라보는 이미지 파일의 모습은? 이미지파일을 분해 해보자](/ko/whitedec/2026/1/14/developer-view-image-file-common-structure/) - [Django 이미지 업로드 보안 가이드: 서버 터지지 않게 효율적으로 처리하기](/ko/whitedec/2026/1/13/django-image-upload-security-guide/) - [python-magic: 확장자 대신 파일 내용을 믿는 가장 실용적인 방법](/ko/whitedec/2026/1/15/python-magic-file-type-detection/)