Published on

OpenAI Responses API 400 invalid_image_url 해결 가이드

Authors

서버에서 OpenAI Responses API로 이미지+텍스트 멀티모달 요청을 붙이다 보면, 가장 흔하게 맞닥뜨리는 에러 중 하나가 400 invalid_image_url입니다. 겉으로는 “URL이 잘못됐다”는 뜻처럼 보이지만, 실제로는 OpenAI가 해당 URL의 이미지를 가져와서 검증하는 과정에서 실패할 때 폭넓게 발생합니다. 즉, 애플리케이션에서 브라우저로는 잘 열리는 이미지 URL이라도, OpenAI 입장에서는 접근/다운로드/판독이 불가능하면 동일한 에러로 귀결됩니다.

이 글에서는 invalid_image_url재현 가능한 체크리스트로 쪼개고, “왜 브라우저에서는 되는데 API에서는 안 되지?”를 설명하며, 실무에서 바로 적용 가능한 해결책(프리사인 URL, 헤더/MIME, 리다이렉트 제거, 이미지 프록시, base64 data URL 전환)을 코드와 함께 정리합니다.

또한 이미지 URL 문제를 해결한 뒤에도 운영에서 자주 이어지는 장애(500/503 재시도, 429 원인 분석)까지 한 번에 엮을 수 있도록 관련 글도 함께 연결합니다: OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커, OpenAI Responses API 429인데 TPM만 넘는 6가지 원인

invalid_image_url의 본질: “URL 문자열”이 아니라 “OpenAI가 가져올 수 있는 이미지”

Responses API에서 input_image.image_url은 단순 문자열 검증만 하는 게 아니라, 서버 측에서 다음을 수행합니다.

  1. URL 스킴/형식 검증(대개 http/https)
  2. 원격 서버로 요청(리다이렉트 포함)
  3. 응답 바디 다운로드(크기 제한/시간 제한 추정)
  4. Content-Type 및 바이트 시그니처(매직넘버)로 이미지 판독
  5. 손상/지원 포맷 여부 확인

이 단계 중 어디에서든 실패하면, 사용자 입장에서는 똑같이 400 invalid_image_url로 보일 수 있습니다. 따라서 해결은 “URL을 바꾸는 것”이 아니라 OpenAI가 안정적으로 다운로드할 수 있는 이미지 제공 방식을 만드는 것입니다.

가장 흔한 원인 10가지와 빠른 판별법

아래는 실무에서 빈도 높은 순으로 정리한 원인입니다.

1) 사설망/로컬호스트/내부 DNS

  • 예: http://localhost:3000/a.png, http://10.0.0.5/img.jpg, http://service.namespace.svc.cluster.local/x.png
  • 브라우저(사내망/VPN)에서는 되지만 OpenAI는 접근 불가

해결: 공인 인터넷에서 접근 가능한 URL로 노출하거나, 이미지 프록시를 둡니다.

2) 인증이 필요한 URL(쿠키/세션/Authorization)

  • 예: 로그인 후에만 열리는 이미지, 서명 헤더가 필요한 CDN
  • OpenAI는 기본적으로 여러분의 쿠키/세션을 갖고 있지 않습니다.

해결: 누구나 접근 가능한 공개 URL 또는 시간 제한 프리사인 URL을 사용합니다.

3) 리다이렉트(301/302/307/308) 체인이 길거나 최종 URL이 막힘

  • 브라우저는 리다이렉트를 잘 따라가지만,
  • 일부 환경에서는 리다이렉트가 차단되거나, 최종 목적지가 인증/봇 차단에 걸립니다.

해결: 최종 이미지 URL을 직접 넣거나, 리다이렉트를 제거한 고정 URL을 사용합니다.

4) User-Agent/봇 차단(WAF, Cloudflare, Bot Control)

  • OpenAI의 fetch가 봇으로 분류되어 403/503/JS 챌린지로 막힐 수 있습니다.

해결:

  • 이미지 제공 도메인을 WAF 예외 처리(가능하다면)
  • 또는 자체 이미지 프록시(서버에서 다운로드 후 base64로 전달)

5) Content-Type이 이미지가 아님(HTML/JSON 반환)

  • “이미지 URL”이라고 믿었는데 실제로는 에러 페이지(HTML)나 JSON을 반환
  • 예: 404 페이지가 HTML로 반환되거나, 핫링크 방지로 안내 페이지를 반환

빠른 판별: curl -IContent-Type 확인

6) 핫링크 방지(Referer 체크)

  • 특정 CDN/호스팅은 Referer 없으면 이미지 대신 차단 페이지를 반환

해결: 핫링크 방지 정책 수정 또는 프록시/스토리지로 재호스팅

7) 프리사인 URL 만료/시계 오차

  • S3 presigned URL이 짧게 발급되거나, 서버 시계가 틀어져 즉시 만료처럼 동작

해결: 만료 시간을 늘리고(N분 단위), 서버 NTP 동기화, 캐시 전략 정리

8) 응답 크기/해상도가 과도함

  • 초고해상도 원본(수십 MB)이나 비정상적으로 큰 이미지

해결: 업로드 시 리사이즈/압축(JPEG/WebP), 최대 바이트 제한 적용

9) 지원하지 않는 포맷 또는 손상 파일

  • 드물지만 HEIC/AVIF 같은 포맷이나 깨진 파일

해결: 서버에서 JPEG/PNG로 변환 후 제공

10) TLS/인증서/암호군 문제

  • 오래된 TLS 설정, 잘못된 인증서 체인 등

해결: 표준 TLS 구성(예: ACM/Let’s Encrypt 최신 체인), 중간 인증서 점검

재현/진단: “OpenAI가 보는 관점”으로 URL을 검사하기

가장 먼저 해야 할 일은, 애플리케이션 서버(또는 CI 환경)에서 OpenAI와 유사한 네트워크 조건으로 해당 URL을 검사하는 것입니다.

1) curl로 헤더/리다이렉트/Content-Type 확인

# 리다이렉트 포함 최종 응답 확인
curl -sS -L -D - "https://example.com/path/image" -o /dev/null

# 헤더만 확인
curl -sS -I "https://example.com/path/image"

체크 포인트:

  • 최종 상태 코드가 200인가?
  • Content-Type: image/jpeg / image/png 등으로 내려오는가?
  • Content-Length가 과도하게 크지 않은가?

2) 실제 바디가 이미지인지 매직넘버로 확인

curl -sS -L "https://example.com/path/image" | head -c 16 | xxd
  • PNG는 89 50 4E 47 ...
  • JPEG는 FF D8 FF ...
  • 만약 <html 같은 게 나오면 “이미지 URL”이 아닙니다.

3) 서버에서 다운로드 후 MIME을 강제 판별(파이썬)

import requests
import imghdr

url = "https://example.com/path/image"
resp = requests.get(url, timeout=10)
resp.raise_for_status()

kind = imghdr.what(None, h=resp.content)
print("http content-type:", resp.headers.get("content-type"))
print("detected kind:", kind)
print("bytes:", len(resp.content))
  • content-type은 이미지인데 imghdrNone이면 깨진 파일일 수 있습니다.

해결 패턴 1: 가장 안정적인 방법 — base64 data URL로 보내기

운영에서 URL 접근성 문제(WAF/리다이렉트/인증/핫링크)를 한 번에 제거하려면, 서버가 이미지를 직접 가져온 뒤 data URL로 Responses API에 전달하는 방식이 가장 확실합니다.

Node.js(서버) 예시: 원격 이미지를 data URL로 변환해 전송

import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function toDataUrl(imageUrl) {
  const res = await fetch(imageUrl, { redirect: "follow" });
  if (!res.ok) throw new Error(`image fetch failed: ${res.status}`);

  const contentType = res.headers.get("content-type") || "application/octet-stream";
  const arrayBuffer = await res.arrayBuffer();
  const base64 = Buffer.from(arrayBuffer).toString("base64");

  // data URL 형식
  return `data:${contentType};base64,${base64}`;
}

export async function analyze(imageUrl) {
  const dataUrl = await toDataUrl(imageUrl);

  const resp = await client.responses.create({
    model: "gpt-4.1-mini",
    input: [{
      role: "user",
      content: [
        { type: "input_text", text: "이 이미지의 핵심 내용을 요약해줘." },
        { type: "input_image", image_url: dataUrl },
      ]
    }]
  });

  return resp.output_text;
}

장점:

  • OpenAI가 외부 URL을 fetch하지 않아도 됨 → invalid_image_url의 상당수를 제거
  • WAF/봇 차단/Referer 문제 회피

주의:

  • 이미지 바이트가 커지면 요청 payload가 커집니다. 업로드 전에 리사이즈/압축을 권장합니다.

해결 패턴 2: S3/Cloud Storage 프리사인 URL로 “공개 가능”하게 만들기

이미지를 여러분이 제어하는 스토리지(S3 등)에 올리고, 만료 시간이 충분한 presigned URL을 발급해 전달하면 인증 문제를 깔끔히 해결할 수 있습니다.

Python(Boto3) 예시: S3 presigned URL 생성

import boto3

s3 = boto3.client("s3")

def presign(bucket, key, expires=900):
    return s3.generate_presigned_url(
        "get_object",
        Params={"Bucket": bucket, "Key": key},
        ExpiresIn=expires,
    )

url = presign("my-bucket", "images/sample.jpg", expires=900)
print(url)

권장 설정:

  • ExpiresIn은 최소 수 분(예: 10~30분)로 설정
  • 서버 시계(NTP) 동기화
  • 가능하면 CloudFront Signed URL/쿠키를 쓰더라도, OpenAI가 접근 가능한 경로인지 확인

해결 패턴 3: 이미지 프록시(정규화)로 리다이렉트/WAF/MIME 문제를 흡수

외부에서 들어오는 제각각의 이미지 URL을 그대로 OpenAI에 넘기면, 운영 중에 실패율이 올라갑니다. 이때는 “이미지 정규화 레이어”를 둬서 다음을 수행합니다.

  • 외부 URL 다운로드(서버가 수행)
  • 최대 크기 제한(예: 10MB)
  • 이미지 디코딩/재인코딩(JPEG/PNG)
  • 올바른 Content-Type으로 재서빙
  • 캐시(동일 URL 반복 호출 방지)

Next.js API Route 예시: 간단 이미지 프록시

export default async function handler(req, res) {
  const url = req.query.url;
  if (!url) return res.status(400).send("missing url");

  const r = await fetch(url, { redirect: "follow" });
  if (!r.ok) return res.status(502).send(`upstream ${r.status}`);

  const contentType = r.headers.get("content-type") || "application/octet-stream";
  const buf = Buffer.from(await r.arrayBuffer());

  // 최소한의 방어: 크기 제한
  if (buf.length > 10 * 1024 * 1024) return res.status(413).send("too large");

  res.setHeader("Content-Type", contentType);
  res.setHeader("Cache-Control", "public, max-age=3600");
  res.status(200).send(buf);
}

이 프록시 URL을 input_image.image_url로 넣으면, OpenAI는 “항상 같은 방식으로” 이미지를 가져가게 됩니다.

Responses API 요청 예시와 흔한 실수

아래는 Responses API 멀티모달 입력의 전형적인 형태입니다.

import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const response = await client.responses.create({
  model: "gpt-4.1-mini",
  input: [{
    role: "user",
    content: [
      { type: "input_text", text: "이미지에서 보이는 오류 메시지를 읽고 원인을 추정해줘." },
      { type: "input_image", image_url: "https://cdn.example.com/logs/screenshot.png" }
    ]
  }]
});

console.log(response.output_text);

흔한 실수:

  • image_url에 실제 이미지가 아닌 “뷰어 페이지 URL”을 넣음(예: 구글 드라이브 공유 페이지)
  • 이미지 링크가 단기 토큰 기반인데 만료 시간을 너무 짧게 설정
  • CDN이 text/html로 200을 내려주는데 눈치 못 챔(핫링크 방지/에러 페이지)

운영 체크리스트: invalid_image_url을 “사전에” 줄이는 방법

  1. 서버에서 URL을 미리 검증: 상태코드, 최종 URL, Content-Type, 바이트 크기
  2. 리다이렉트 제거: 최종 URL을 저장하거나 프록시로 정규화
  3. WAF/봇 차단 회피: 가능하면 이미지 전용 도메인 분리 또는 프록시
  4. 프리사인 URL 만료 여유: 최소 10분 이상, 서버 시간 동기화
  5. 리사이즈/압축 파이프라인: 업로드 시점에 원본을 정규화
  6. 실패 시 폴백: URL fetch 실패 시 base64 data URL 전환(또는 반대)

특히 운영에서는 invalid_image_url을 해결한 뒤 트래픽이 늘면서 500/503(일시 장애)나 429(레이트 리밋) 이슈로 넘어가는 경우가 많습니다. 재시도/폴백/서킷브레이커는 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커에서 함께 정리해두었고, 429가 TPM 때문만은 아닌 케이스는 OpenAI Responses API 429인데 TPM만 넘는 6가지 원인에서 확인할 수 있습니다.

결론: “URL을 고치는 문제”가 아니라 “이미지 전달 경로를 설계하는 문제”

400 invalid_image_url은 단순 오타보다, 네트워크/보안/콘텐츠 제공 방식의 결함을 드러내는 신호인 경우가 많습니다. 가장 빠른 해결은 다음 우선순위를 추천합니다.

  • 1순위: 서버에서 이미지를 가져와 base64 data URL로 전송(가장 확실)
  • 2순위: 제어 가능한 스토리지에 올리고 presigned URL로 제공
  • 3순위: 외부 URL을 받아야 한다면 이미지 프록시로 정규화

이 3가지만 제대로 갖추면, 멀티모달 운영에서 invalid_image_url로 인한 실패율을 눈에 띄게 낮출 수 있습니다.