이미지 URL을 직접 열면 정상적으로 보인다. 서버에서 같은 주소를 요청해도 HTTP 200이 반환되고, 응답 본문은 실제 JPEG다. 그런데 웹 페이지의 이미지 요소에서는 로딩 오류가 발생한다.

이 현상을 설명하려면 요청의 성공을 여러 단계로 나눠야 한다. 바이트를 받았는지, 이미지로 해석할 수 있는지, 현재 문서가 그 응답을 사용할 수 있는지는 각각 다른 조건이다. 이미지 중계 경로를 추가하면 이 조건들을 서버가 어느 범위까지 책임질지도 새로 결정해야 한다.

이 글은 그 경계를 CORP, DNS 리바인딩, 비동기 스트리밍, 이미지 디코딩의 관점에서 살펴본다. 코드 예시는 Python 3.11 이상과 HTTPX 0.28.1을 기준으로 한다. 연결 계층은 httpcore 1.0.9, 이미지 처리 설명은 Pillow 13.0.0 공식 문서를 참고했다. 문서 확인일은 2026년 11월 4일이다.

1. HTTP 200은 브라우저의 사용 허가를 포함하지 않는다

응답이 다음과 같다고 가정하자.

http
HTTP/1.1 200 OK
Content-Type: image/jpeg
Cross-Origin-Resource-Policy: same-site

이 응답을 다른 사이트의 문서에서 이미지로 불러오면 브라우저가 사용을 차단할 수 있다. CORP는 자원 제공자가 자신의 응답을 어떤 출처에서 사용할 수 있는지 제한하는 정책이다. Fetch 표준은 no-cors 요청의 응답에 대해 별도의 CORP 검사를 정의한다. no-cors라는 요청 모드가 응답의 모든 보안 검사를 생략한다는 의미는 아니다. Fetch 표준의 CORP 정의

진단할 때는 다음 세 층을 분리하는 편이 좋다.

확인할 층성공의 의미여전히 가능한 실패
HTTP 교환상태 코드와 응답 본문을 받았다내용이 오류 페이지이거나 권한에 맞지 않음
형식 해석이미지 디코더가 포맷을 식별한다손상된 프레임, 과도한 픽셀 수, 브라우저별 지원 차이
문서 내 사용해당 문서의 보안 문맥에서 응답을 사용할 수 있다CORP, 문서의 자원 정책 등

CORS와 CORP도 구분해야 한다. CORS는 교차 출처 응답을 공유하는 프로토콜이고, CORP는 특정 교차 출처·교차 사이트 자원 사용을 제한한다. 일반적인 교차 출처 이미지 표시에 항상 CORS 허가가 필요한 것은 아니지만, CORP가 그 사용을 막을 수 있다. 이미지에 crossorigin을 붙여 CORS 요청으로 바꿔도 원본 서버가 필요한 허용 응답을 제공해야 한다. Fetch 표준의 CORS 프로토콜

서버에서 다운로드가 성공했다는 관찰은 네트워크와 파일 형식을 좁혀 보는 근거다. 브라우저 정책까지 통과했다는 증거로 확대해서 해석하면 원인을 놓친다.

2. 중계 서버는 새로운 응답의 발행자가 된다

허용된 외부 자원을 서버에서 받아 미리보기로 제공하는 구조를 선택하면 요청 경로는 다음처럼 바뀐다.

text
브라우저
  ├─ 원본 이미지 직접 요청
  └─ 실패 시 인증된 미리보기 요청
        → 목적지 정책 검사
        → 검증된 주소로 연결
        → 제한된 크기의 본문 수신
        → 허용한 이미지 형식 검증
        → 새 응답 생성

중계 서버가 반환하는 것은 자체 출처에서 발행한 새 HTTP 응답이다. 따라서 응답 헤더를 명시적으로 구성해야 한다. 원본의 Set-Cookie를 전달하면 외부 서버의 상태 설정을 중계 서비스의 응답에 섞게 된다. 원본의 Content-Type도 검증 결과를 대신할 수 없다. 반환할 MIME 유형과 캐시 정책은 중계 경로의 계약으로 정해야 한다.

요청 방향도 같다. 사용자의 세션 쿠키와 서비스용 인증 헤더는 원본 이미지 서버로 보내지 않는다. 미리보기 요청을 인증하는 행위와 외부 목적지에 자격 증명을 전달하는 행위는 서로 독립적이다.

이 구조에는 권한의 이동이 있다. 브라우저가 수행하던 외부 요청을 서버가 대신 실행하면서, 서버의 네트워크 도달 범위가 사용된다. 인증된 사용자라고 해도 내부 서비스나 메타데이터 주소를 이미지 URL로 요청할 권한까지 얻는 것은 아니다.

3. DNS 검사는 실제 연결에 결합되어야 한다

URL의 호스트를 한 번 해석하고 사설 주소가 없는지 검사하는 구현은 자연스럽다. 그러나 검사 후 HTTP 클라이언트가 같은 호스트를 다시 해석하면 시간차가 생긴다.

text
t0: 검증 단계의 DNS 응답 → 허용한 공인 주소
t1: 연결 단계의 DNS 응답 → 허용하지 않은 내부 주소

검증 결과가 실제 소켓의 목적지를 구속하지 못하는 TOCTOU 문제다. 목적지를 검증한 뒤 그 주소로 연결하도록 묶어야 한다. OWASP 역시 DNS 재해석과 리다이렉트를 SSRF 방어에서 검토해야 할 대상으로 다룬다. SSRF 방어 가이드

여기서 필요한 불변조건은 단순하다.

새 연결을 만들 때 선택하는 IP는 그 연결에 적용할 정책으로 검증한 주소 집합에 속해야 한다.

호스트가 여러 주소를 반환하면 IPv4와 IPv6를 함께 다뤄야 한다. 일부 주소가 거부 대상일 때 전체 요청을 거부할지, 검증된 주소만 후보로 남길지도 명시적인 정책이다. 보수적인 구현은 혼합된 응답을 통째로 거부할 수 있지만, 분할 DNS 같은 환경에서는 정상 요청까지 실패할 수 있다.

연결할 IP를 고정하면서도 원래 호스트의 의미는 보존해야 한다.

연결의 요소사용해야 할 값
TCP 연결 대상검증한 숫자 IP
HTTP Host원래 URL의 호스트와 필요한 포트
TLS SNI원래 호스트 이름
인증서 검증 대상원래 호스트 이름

URL 자체를 IP 주소로 치환하는 구현은 가상 호스트 라우팅과 TLS 검증을 함께 바꿀 수 있다. 네트워크 연결 대상만 고정하고, 상위 프로토콜의 호스트 식별은 유지하는 계층 분리가 필요하다.

리다이렉트도 같은 원칙을 따른다. 자동 추적을 끄면 목적지 정책을 단순하게 유지하기 쉽다. 추적을 허용한다면 각 이동의 URL 정책과 새 연결의 IP 정책을 다시 적용하고, 이동 횟수와 전체 시간도 제한해야 한다. 첫 주소의 검증 결과는 다음 주소에 승계되지 않는다.

서비스 간 통신에 필요한 내부 주소 예외를 외부 미리보기 클라이언트에 그대로 재사용하지 않는 것도 중요하다. 정책은 기능별로 부여해야 한다. 같은 네트워크 유틸리티를 사용하더라도 공개 자원 수집과 내부 서비스 호출의 허용 범위는 분리할 수 있어야 한다.

4. HTTPX의 Transport와 httpcore의 Network Backend를 구분한다

HTTPX는 요청·응답 경계의 Transport API를 제공한다. httpcore는 연결 풀과 실제 네트워크 동작을 다루며, 사용자 정의 Network Backend를 연결할 수 있다. 목적지 IP 고정은 실제 연결이 만들어지는 지점까지 내려가야 효력이 생긴다. HTTPX Transport API, httpcore Network Backend

실제 구현에서는 공개 확장 지점과 라이브러리 내부 구조를 구별해야 한다. 예를 들어 HTTPX 0.28.1의 Transport 내부에서 _pool._network_backend를 교체하는 방식은 내부 속성에 의존한다. 작은 어댑터로 의존 범위를 모으고, 버전을 고정하고, 업그레이드 시 실제 연결 경로가 바뀌지 않았는지 검증해야 한다. 공개 Network Backend 인터페이스가 있다는 사실이 상위 라이브러리의 모든 조립 방법까지 안정적인 계약으로 만든 것은 아니다.

환경 프록시도 연결 모델에 포함된다. HTTP_PROXY나 HTTPS_PROXY를 자동으로 사용하면 직접 검증한 서버가 아니라 프록시에 연결할 수 있다. 직접 연결을 전제로 설계했다면 trust_env=False 등으로 그 전제를 강제해야 한다. 프록시가 필요한 환경에서는 프록시가 수행하는 DNS 해석과 목적지 제어까지 별도로 설계해야 한다. HTTPX 환경 변수 문서

연결 풀을 공유하면 연결 재사용과 TLS 비용에서 이점이 생긴다. 동시에 사용자별 인증 상태, 목적지 정책, 정책 변경 시 기존 연결의 처리도 고려해야 한다. 요청별 클라이언트는 격리가 단순하지만 연결 재사용 이점을 줄인다. 성능을 위해 풀을 공유한다면 어떤 보안 문맥끼리 공유하는지 먼저 정해야 한다.

5. 응답 크기, 총시간, 디코딩 비용은 서로 다른 예산이다

Content-Length는 사전 판단에 사용할 수 있지만, 누적 수신량 검사를 대신할 수 없다. 헤더가 없거나 HTTP 콘텐츠 압축으로 인해 실제 처리할 본문 크기와 달라질 수 있다.

HTTPX의 스트리밍 API에서는 청크를 읽으며 크기를 제한할 수 있다. 아래 코드는 목적지 정책이 적용된 클라이언트를 이미 전달받았다는 전제에서 본문 크기와 대기 시간을 제한하는 부분만 보여 준다. URL 검증이나 DNS 고정을 구현한 전체 프록시 코드는 아니다.

python
import asyncio
import httpx


class PreviewTooLarge(Exception):
    pass


async def read_bounded(
    client: httpx.AsyncClient,
    url: str,
    *,
    max_bytes: int = 10 * 1024 * 1024,
    budget: float = 15.0,
) -> bytes:
    async with asyncio.timeout(budget):
        async with client.stream("GET", url) as response:
            response.raise_for_status()
            body = bytearray()

            async for chunk in response.aiter_bytes(
                chunk_size=64 * 1024
            ):
                if len(body) + len(chunk) > max_bytes:
                    raise PreviewTooLarge()
                body.extend(chunk)

            return bytes(body)

10 MiB와 15초는 설계 예시다. 자원 크기 분포와 서버가 감당할 동시 요청 수를 바탕으로 조정해야 한다.

HTTPX의 read timeout은 다음 청크를 기다리는 시간에 적용된다. 데이터가 조금씩 계속 도착하면 전체 요청 시간은 길어질 수 있다. asyncio.timeout()으로 요청과 본문 수신을 감싸면 별도의 전체 대기 예산을 둘 수 있다. 다만 취소에 협조하지 않는 동기 처리까지 정해진 시각에 강제 종료하는 기능은 아니다. HTTPX timeout 문서, Python asyncio timeout 문서

크기 제한의 적용 시점도 봐야 한다. aiter_bytes()는 HTTP 콘텐츠 디코딩이 적용된 바이트를 제공한다. 위 코드는 애플리케이션이 보관하는 본문의 크기를 제한하지만, 디코더 내부의 일시적인 메모리 사용까지 같은 값으로 제한하지는 않는다. 작은 chunk_size도 전체 프로세스 메모리의 상한은 아니다. HTTPX 스트리밍 응답 문서

엄격한 자원 제한이 필요하면 전송 계층의 바이트, HTTP 압축 해제 후 바이트, 이미지의 디코딩된 픽셀을 각각 구분해야 한다. 한 요청의 제한과 함께 동시 처리량도 제한해야 전체 메모리 예산이 성립한다.

text
메모리 수요 ≈ 동시 요청 수 ×
    (본문 버퍼 + 디코딩된 픽셀 + 코덱 작업 공간)

이 식은 대략적인 예산 모델이다. 버퍼 복사, 연결 상태, 코덱별 동작이 추가되므로 정확한 상한을 의미하지 않는다.

6. Pillow의 식별, 검증, 디코딩은 다른 단계다

이미지 처리에서도 함수 하나의 성공 범위를 정확히 읽어야 한다.

Pillow의 Image.open()은 지연 처리를 사용한다. 파일 형식을 식별했다고 해서 전체 픽셀을 디코딩한 것은 아니다. verify()도 이미지 데이터를 완전히 디코딩하지 않고 파일의 문제를 검사하는 API다. 이를 모든 포맷과 프레임의 완전한 디코딩 보장으로 해석하면 검증 범위를 과대평가하게 된다. Pillow Image 문서, Image.verify 문서

따라서 미리보기 경로의 계약을 먼저 정하는 편이 좋다. 허용한 래스터 포맷만 받을지, 정적인 한 프레임을 반환할지, 애니메이션 전체를 보존할지에 따라 비용과 검증 범위가 달라진다.

정적인 미리보기라면 가로·세로와 총 픽셀을 제한한 뒤 필요한 프레임을 디코딩하고, 정해진 포맷으로 다시 인코딩하는 방식을 선택할 수 있다. 애니메이션을 유지한다면 프레임 수와 누적 처리량도 예산에 들어간다. 재인코딩은 처리 비용과 표현 변경을 수반하므로 모든 이미지에 무조건 적용할 기본값으로 보기는 어렵다.

디코더를 별도 스레드에서 실행하면 이벤트 루프의 지연을 줄일 수 있다. 그러나 코루틴의 대기가 취소돼도 이미 실행 중인 스레드 작업이 즉시 끝난다고 가정해서는 안 된다. 강제 종료 가능한 실행 시간 격리가 필요하다면 별도 프로세스와 운영체제의 자원 제한까지 검토해야 한다.

7. 복구 경로에는 종료 조건과 요청의 세대가 필요하다

이미지의 onerror는 원인 분류에 충분한 정보를 주지 않는다. 정책 차단, 만료된 주소, 형식 오류, 네트워크 실패가 같은 이벤트로 들어올 수 있다.

따라서 직접 로딩이 실패하면 서버 중계를 제한적으로 시도하는 상태 전이를 두되, 실패를 특정 원인으로 단정하지 않는 편이 좋다.

text
직접 요청 ─성공→ 표시
    │ 실패
    ▼
중계 요청 ─성공→ 표시
    │ 실패
    ▼
오류 안내와 사용자가 선택하는 재시도

세션 복구를 추가한다면 서비스가 소유한 API 주소만 대상으로 삼고, 자동 시도 횟수를 별도로 제한해야 한다. 모든 외부 이미지 실패가 인증 갱신을 시작하게 만들면 무관한 장애가 세션 처리까지 확산된다.

또 하나의 경계는 요청의 시간적 수명이다. 이미지 A가 실패해 복구를 기다리는 사이 화면이 이미지 B로 바뀔 수 있다. 늦게 완료된 A의 작업이 B를 다시 요청하거나 B의 오류 상태를 지우면 상태가 섞인다. 소스별 컴포넌트 수명, 세대 번호, 취소 신호 등을 사용해 오래된 결과의 적용을 막아야 한다.

서명된 원본 URL을 복구 목적으로 임의 변경하는 것도 피해야 한다. 재시도 식별자는 자체 중계 요청에 붙이고 원본 URL은 별도 매개변수 값으로 전달할 수 있다. 원본 문자열 보존과 목적지 검증은 함께 충족해야 할 조건이다.

8. 테스트는 실제 정책이 집행되는 계층까지 내려가야 한다

목적지 검증 함수를 통째로 모킹한 테스트는 검증 결과와 소켓 연결 대상이 일치하는지 확인하지 못한다. HTTPX의 MockTransport로 HTTP 응답을 대신 반환하는 테스트도 하위 네트워크 연결을 실행하지 않는다.

연결 경계를 검증하려면 하위 네트워크 백엔드에서 실제로 요청받은 주소를 기록하거나, httpcore의 모의 스트림을 사용해 응답 이후의 동작까지 진행시킬 수 있다. 공개 주소에서 리다이렉트 응답을 받은 뒤, 다음 내부 주소 연결이 실제 소켓을 만들기 전에 거부되는지 확인하는 식이다.

검증 상황관찰해야 할 결과
DNS 검사와 연결 사이의 주소 변경검사와 무관한 재해석 경로가 연결을 바꾸지 못함
공인·비공인 주소가 섞인 응답명시한 주소 집합 정책대로 처리됨
공인 주소에서 내부 주소로 리다이렉트내부 소켓 연결 전에 거부됨
크기 제한을 넘기는 청크버퍼에 추가하기 전에 거부됨
느리지만 끊기지 않는 응답전체 대기 예산에 따라 종료됨
MIME 유형을 위장한 HTML 또는 손상된 이미지이미지 응답으로 발행되지 않음
이미지 소스 변경 뒤 도착한 이전 복구 결과현재 이미지 상태에 적용되지 않음

브라우저 검증도 필요하다. 이미지 요소의 complete는 로딩 실패 후에도 참일 수 있으므로 그것만으로 성공을 판정하지 않는다. 래스터 이미지라면 naturalWidth > 0 같은 조건과 실제 화면을 함께 확인하고, 확대 보기처럼 별도 요청을 만드는 경로도 확인해야 한다.

마지막으로 전송과 렌더링이 정상이어도 이미지의 내용이 주제와 맞는지는 남는다. 네트워크 목적지의 신뢰성, 파일 형식의 적합성, 콘텐츠의 의미적 적합성은 각각 다른 검증 대상이다.

외부 이미지 중계의 설계 기준은 어느 한 함수의 성공에 있지 않다. 검증한 목적지로 연결됐는지, 정한 예산 안에서 처리됐는지, 허용한 표현만 새 응답으로 발행됐는지, 오래된 복구 결과가 현재 상태를 바꾸지 않는지를 경계마다 설명할 수 있어야 한다.