Nano Banana 2.1에서 이미지가 반환되지 않았나요? 재시도 전에 응답을 확인하세요

Nano Banana 2.1 응답에 이미지가 없을 때 이미지 데이터, 종료 사유, HTTP 오류와 타임아웃을 구분하는 방법. 오프라인 진단 코드와 재시도 기준을 제공합니다.

밝은 카드 위 톱니바퀴 선화, 기하학 장식과 Nano Banana 2.1 제목.

HTTP 요청이 성공했다고 해서 응답에 이미지가 포함된 것은 아닙니다. Gemini 이미지 생성에서는 후보 응답의 content.parts[].inlineData(또는 inline_data)에 이미지가 담기며, 텍스트 파트가 함께 반환될 수도 있습니다. response.text만 읽는 애플리케이션은 실제 응답에 이미지가 있는데도 “이미지 없음”이라고 표시할 수 있습니다.

이 가이드에서 다루는 Ofox 모델 ID는 google/gemini-nano-banana-2.1입니다. Ofox가 이 글을 게시하고 아래 테스트에 사용된 게이트웨이를 운영합니다. 여기서는 해당 테스트에서 게이트웨이가 반환한 내용과 Google이 일반적으로 문서화한 내용을 구분합니다. 모든 실패 요청의 원인이 같다고 가정하지 않습니다.

먼저 응답에 이미지가 있는지 확인하세요

네이티브 Gemini 요청에서는 각 후보의 파트를 살펴보고, 이미지 MIME 유형과 비어 있지 않은 data를 가진 inlineData 객체가 있는지 확인하세요. 텍스트 필드가 비어 있거나 없다고 해서 생성이 실패했다는 뜻은 아닙니다. 다음 조치를 결정하기 전에 HTTP 상태 코드, 후보의 finishReason, promptFeedback도 기록하세요.

다음은 2026년 10월 13일 Ofox 게이트웨이에서 성공한 테스트의 일부 구조를 공개한 발췌문입니다. 이미지 바이트는 포함하지 않습니다.

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "inlineData": {
              "mimeType": "image/png",
              "data": "[base64 image bytes omitted]"
            }
          }
        ]
      },
      "finishReason": "STOP"
    }
  ]
}

테스트 요청은 HTTP 200과 1024 × 1024 PNG를 반환했습니다. 발췌문에는 다른 응답 자료가 생략되어 있습니다. 테스트에서 보고된 사용량은 입력 토큰 54개, 보고된 텍스트 출력 토큰 80개, 이미지 출력 토큰 1,120개, 추론 토큰 639개였습니다. 이 수치는 해당 요청에 관한 것이며, 필수 응답 형식이나 고정 요금을 뜻하지 않습니다. Google의 이미지 생성 문서에는 이미지가 포함된 파트가 설명되어 있고, 모델 문서에는 모델 기능이 설명되어 있습니다.

확인 가능한 요청을 한 번 보내세요

환경 변수에 OFOX_API_KEY를 설정한 다음, 테스트에서 사용한 포스터 프롬프트를 그대로 전송하세요. 모델 ID가 중요합니다. google/gemini-nano-banana-2.1은 이전 모델인 google/gemini-3.1-flash-image도 아니고 “Lite” 변형도 아닙니다.

cat > request.json <<'JSON'
{
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "Create a square minimalist event poster on warm cream paper. Large exact headline: ORCHARD MARKET. Smaller exact line: SATURDAY 10 AM. Draw three red apples in a blue ceramic bowl below the text. No other text, no logos, no border."
        }
      ]
    }
  ],
  "generationConfig": {
    "responseModalities": ["TEXT", "IMAGE"],
    "imageConfig": {
      "aspectRatio": "1:1",
      "imageSize": "1K"
    }
  }
}
JSON

curl --silent --show-error \
  --output response.json \
  --write-out 'HTTP %{http_code}\n' \
  -H "x-goog-api-key: ${OFOX_API_KEY:?Set OFOX_API_KEY first}" \
  -H 'Content-Type: application/json' \
  --data-binary @request.json \
  'https://api.ofox.io/gemini/v1beta/models/google/gemini-nano-banana-2.1:generateContent'

curl이 출력하는 HTTP 상태 코드는 중요합니다. response.json 파일이 있다는 이유만으로 요청이 성공했다고 가정하지 말고, 응답과 함께 상태 코드도 저장하세요. 기밀 프롬프트나 이미지 데이터가 들어 있는 요청 및 응답 파일은 공개 로그와 저장소에 올리지 마세요. 이 예시 외의 엔드포인트 설정은 Nano Banana 2.1 API 가이드를 참고하세요.

프롬프트를 바꾸기 전에 오프라인 진단을 실행하세요

다음 내용을 diagnose_gemini_image.py로 저장하세요. 이 스크립트는 저장된 JSON 응답을 읽으며 API를 호출하지 않습니다. 클라이언트가 기록한 HTTP 코드는 --http-status로 전달하세요. 사용할 수 있는 응답을 받지 못한 채 클라이언트 시간이 초과됐다면 대신 --timeout을 사용하세요. 출력 항목은 의도적으로 진단에 필요한 필드로 제한되어 있습니다. 키, 원본 오류 메시지, 프롬프트, base64 이미지 데이터는 출력하지 않습니다.

#!/usr/bin/env python3
import argparse
import json
import sys
from pathlib import Path

def field(obj, camel, snake):
    if not isinstance(obj, dict):
        return None
    return obj.get(camel, obj.get(snake))

def safe_feedback(obj):
    if not isinstance(obj, dict):
        return None
    ratings = field(obj, "safetyRatings", "safety_ratings")
    result = {"blockReason": field(obj, "blockReason", "block_reason")}
    if isinstance(ratings, list):
        result["safetyRatings"] = [
            {
                "category": r.get("category"),
                "probability": r.get("probability"),
            }
            for r in ratings if isinstance(r, dict)
        ]
    return result

def diagnose(body, http_status=None, timed_out=False):
    # Accept either a native response or {"body": <native response>}.
    if isinstance(body, dict) and isinstance(body.get("body"), dict):
        if http_status is None:
            http_status = body.get("status_code", body.get("httpStatus"))
        body = body["body"]

    result = {
        "status": "no_image",
        "httpStatus": http_status,
        "imagesCount": 0,
        "finishReasons": [],
        "promptFeedback": safe_feedback(
            field(body, "promptFeedback", "prompt_feedback")
        ),
    }

    if timed_out:
        result["status"] = "transport_uncertain"
        return result

    if http_status is not None and not 200 <= http_status < 300:
        result["status"] = (
            "http_error_check_retryability"
            if http_status == 429 or 500 <= http_status <= 599
            else "http_error"
        )
        return result

    candidates = body.get("candidates", []) if isinstance(body, dict) else []
    if not isinstance(candidates, list):
        candidates = []

    for candidate in candidates:
        if not isinstance(candidate, dict):
            continue
        reason = field(candidate, "finishReason", "finish_reason")
        if reason is not None:
            result["finishReasons"].append(reason)
        content = candidate.get("content")
        parts = content.get("parts", []) if isinstance(content, dict) else []
        if not isinstance(parts, list):
            continue
        for part in parts:
            inline = field(part, "inlineData", "inline_data")
            if not isinstance(inline, dict):
                continue
            mime = field(inline, "mimeType", "mime_type")
            if (isinstance(mime, str) and mime.startswith("image/")
                    and isinstance(inline.get("data"), str)
                    and inline["data"]):
                result["imagesCount"] += 1

    if result["imagesCount"]:
        result["status"] = "image_present"
    elif (result["promptFeedback"] or {}).get("blockReason"):
        result["status"] = "blocked"
    elif any(
        r in {"SAFETY", "PROHIBITED_CONTENT", "SPII", "RECITATION"}
        for r in result["finishReasons"]
    ):
        result["status"] = "blocked"
    elif "MAX_TOKENS" in result["finishReasons"]:
        result["status"] = "max_tokens_no_image"
    return result

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("response_json", type=Path, nargs="?")
    parser.add_argument("--http-status", type=int)
    parser.add_argument("--timeout", action="store_true")
    args = parser.parse_args()
    if args.timeout:
        print(json.dumps(diagnose({}, args.http_status, timed_out=True)))
        return 0
    if args.response_json is None:
        parser.error("Provide response_json unless using --timeout")
    try:
        body = json.loads(args.response_json.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError):
        print("Cannot read a valid JSON response", file=sys.stderr)
        return 2
    print(json.dumps(
        diagnose(body, args.http_status, args.timeout),
        separators=(",", ":")
    ))
    return 0

if __name__ == "__main__":
    sys.exit(main())

예를 들어 python3 diagnose_gemini_image.py response.json --http-status 200을 실행하면 이미지 파트가 있는지 확인할 수 있습니다. imagesCount는 선언되어 있고 데이터가 비어 있지 않은 이미지 파트의 개수입니다. 데이터를 디코딩하거나 이미지 파일의 유효성을 확인하는 값은 아닙니다. 추출에 실패하면 자세한 로컬 검사를 위해 원본 응답을 보관하세요.

API 사용량을 들이지 않고도 진단 분기를 확인할 수 있습니다. 다음은 합성 테스트 데이터이며, 모델을 시뮬레이션한 테스트가 아닙니다.

printf '%s\n' \
  '{"candidates":[{"content":{"parts":[{"inline_data":{"mime_type":"image/png","data":"aGVsbG8="}}]},"finish_reason":"STOP"}]}' \
  > fixture-image.json
python3 diagnose_gemini_image.py fixture-image.json --http-status 200
# {"status":"image_present","httpStatus":200,"imagesCount":1,"finishReasons":["STOP"],"promptFeedback":null}

printf '%s\n' \
  '{"candidates":[{"finishReason":"MAX_TOKENS","content":{"parts":[]}}]}' \
  > fixture-limit.json
python3 diagnose_gemini_image.py fixture-limit.json --http-status 200
# {"status":"max_tokens_no_image","httpStatus":200,"imagesCount":0,"finishReasons":["MAX_TOKENS"],"promptFeedback":null}

테스트 데이터의 aGVsbG8=는 비어 있지 않은 base64 텍스트일 뿐이며, PNG가 아닙니다. 이 구분이 중요한 이유는 첫 번째 확인 단계가 이미지 파트의 존재 여부이고, 그다음 단계가 파일 디코딩과 유효성 검사이기 때문입니다.

진단 결과 이미지가 있다면 생성을 다시 시도하지 말고 이미지를 추출하세요. 다음 로컬 추출기는 이미지 파트를 모두 저장하며 내용을 출력하지 않습니다.

import base64
import json
from pathlib import Path

response = json.loads(Path("response.json").read_text(encoding="utf-8"))
saved = 0

for candidate in response.get("candidates", []):
    for part in candidate.get("content", {}).get("parts", []):
        inline = part.get("inlineData", part.get("inline_data", {}))
        mime = inline.get("mimeType", inline.get("mime_type", ""))
        data = inline.get("data")
        if not (mime.startswith("image/") and data):
            continue
        extension = {"image/png": ".png", "image/jpeg": ".jpg"}.get(mime)
        if extension is None:
            raise ValueError(f"Inspect unsupported image MIME type: {mime}")
        image = base64.b64decode(data, validate=True)
        saved += 1
        Path(f"image-{saved}{extension}").write_bytes(image)

print(f"Saved {saved} image(s)")

이 예시는 모든 MIME 유형에 대해 파일 이름을 추측하는 대신 PNG와 JPEG를 명시적으로 지원합니다. 디코딩에 실패하면 HTTP 상태 코드와 응답 메타데이터를 보관하되, base64를 지원 티켓이나 공개 이슈에 붙여 넣지 마세요.

이미지가 없는 응답을 해석하세요

적절한 다음 조치는 요청이 어느 단계에서 실패했는지에 따라 달라집니다. Google의 API 문제 해결 가이드에는 일반적인 오류 안내가 나와 있습니다. 아래 표는 모든 오류를 재시도할 수 있는 것으로 간주하지 않고, 이 게이트웨이 호출에 적용할 실용적인 분류 절차를 제시합니다.

확인된 내용먼저 확인할 항목적절한 조치
400요청 JSON, responseModalities, imageConfig, 필드 이름잘못된 매개변수나 페이로드를 수정하세요. 동일한 요청을 반복해도 해결될 가능성은 낮습니다.
401게이트웨이 키가 x-goog-api-key로 전달됐는지, 유효한 키인지로그에 키를 노출하지 말고 인증 문제를 해결하세요.
403계정 또는 모델 권한, 반환된 오류 분류접근 또는 정책 제한을 해결하세요. 일시적인 이미지 실패로 잘못 분류하지 마세요.
404정확한 엔드포인트와 모델 ID경로에 google/gemini-nano-banana-2.1이 사용됐는지 확인하세요. 다른 ID나 날짜에 대한 과거 404 보고만으로 현재 사용 가능 여부를 판단할 수는 없습니다.
429오류 내용과 계정 상태에 표시된 요청 제한 또는 크레딧·할당량 정보재시도 가능한 요청 제한일 때만 대기 후 다시 시도하세요. 크레딧이 소진됐거나 계정 한도에 조치가 필요한 경우 재시도해도 해결되지 않습니다.
5xx게이트웨이·서버 상태, 요청 기록이나 결과가 있는지횟수를 제한한 재시도가 적절할 수 있지만, 먼저 요청이 이미 처리됐을 가능성을 고려하세요.
클라이언트 시간 초과 또는 연결 끊김로컬 요청 ID, 확인 가능한 경우 게이트웨이 요청 기록, 저장된 응답결과가 불확실합니다. 클라이언트가 응답을 받지 못했다는 이유만으로 “이미지가 생성되지 않았다”거나 “요금이 청구되지 않는다”고 단정하지 마세요.

이미지 파트 없이 STOP이 표시된 200 응답이라면 모든 후보와 파트, 요청한 모달리티, 저장된 JSON을 살펴보세요. 이미지 없이 MAX_TOKENS가 표시된 200 응답은 단서가 다릅니다. 이미지가 전달되기 전에 출력이 끝났을 수 있습니다. Google 개발자 포럼의 사용자 보고에는 이 증상이 언급되어 있습니다. 다만 이는 검증되지 않은 사용자 보고이며, 여기서 성공한 여섯 건의 요청에서는 재현되지 않은 실패 사례입니다. 보고된 토큰 수가 설정된 한도보다 낮다는 사실만으로 모델 버그가 입증되지는 않습니다. 결론을 내리기 전에 한도, 응답 집계 방식, 해당 카운터의 구체적인 의미를 확인해야 합니다.

promptFeedback에 차단 사유가 있거나 후보가 안전성 관련 사유로 종료됐다면 이를 정책에 따른 결과로 취급하세요. 요청이 해당 Gemini API 약관에 부합하는지 검토하세요. 제한을 우회하기 위한 방법으로 재시도하거나 프롬프트를 바꾸지 마세요. 설정 세부 사항은 API 가이드를 참고하세요.

필요한 경우에만 재시도하고, 후속 턴에는 이전 맥락을 보존하세요

이미지가 없는 모든 결과를 자동 재시도 루프에 넣지 마세요. 먼저 400, 401, 403, 404의 원인을 해결하세요. 재시도 가능한 429 또는 5xx로 확인됐다면 횟수를 적게 제한하고, 대기 시간을 점차 늘리며 지터를 적용하세요. 클라이언트가 지원하는 경우 서버가 제공한 재시도 안내를 따르세요. 시간이 초과된 뒤에는 가능하면 재전송하기 전에 요청 기록을 확인하세요. 응답이 유실되면 생성 여부와 요금 청구 상태가 모두 불확실합니다. 첫 결과가 마음에 들지 않아 이미지를 추가로 생성해도 비용이 발생할 수 있는 이유는 요금 가이드에서 설명합니다.

편집 작업에서는 “재시도”와 “후속 턴”도 서로 다른 동작입니다. 후속 요청에는 이전 대화 내용과 모델이 반환한 콘텐츠 파트를 포함해야 합니다. 이전 모델 메시지를 눈에 보이는 텍스트만으로 다시 만들지 마세요. 이렇게 하면 이미지와 추론 서명이 누락될 수 있습니다. 테스트한 3단계 체인에서는 새 사용자 턴을 보낼 때마다 이전 candidates[0].content를 그대로 전송했습니다. 이어서 사용한 프롬프트는 다음과 같습니다.

Change only the headline from ORCHARD MARKET to ORCHARD FAIR. Keep SATURDAY 10 AM exactly unchanged. Preserve the three red apples, blue bowl, cream background, composition and all other pixels as closely as possible.

Now change only the bowl from blue to dark green. Preserve ORCHARD FAIR, SATURDAY 10 AM, the three red apples and the layout.

Python에서 핵심이 되는 대화 기록 처리 방식은 다음과 같습니다.

# previous_response is the complete parsed response from the preceding turn.
model_content = previous_response["candidates"][0]["content"]
contents.append(model_content)  # Keep its parts and signatures intact.
contents.append({
    "role": "user",
    "parts": [{"text": next_prompt}]
})

다음 턴을 추가하기 전에 업데이트된 응답을 저장하세요. 이전 응답이 차단됐거나 불완전하거나 편집에 필요한 콘텐츠가 없다면, 이를 동일한 방식의 이어하기라고 취급하지 마세요. 편집 시 기대할 수 있는 결과와 “보존”이 보장하는 것과 보장하지 않는 것을 알아보려면 포스터 편집 안내와 이전 제품 보존 가이드를 참고하세요. 여러 참조 이미지를 사용하는 방식의 장단점은 제품 참조 이미지 한 장과 두 장 비교에서 다룹니다.

성공한 테스트로 확인된 내용과 확인되지 않은 내용

2026년 10월 13일, Ofox를 통한 네이티브 Gemini 경로는 실제 요청 여섯 건 각각에서 HTTP 200, STOP, 1024 × 1024 PNG를 반환했습니다. Nano Banana 2.1에서 포스터 생성, 헤드라인 편집, 그릇 색상 후속 편집을 각각 한 번씩 요청했고, 이전 버전인 Nano Banana 2에서도 같은 세 가지 프롬프트를 각각 한 번씩 요청했습니다. 2.1의 출력에서는 편집 전반에 걸쳐 요청한 영어 문구와 사물이 눈에 띄게 유지됐습니다. 이는 유용한 성공 응답 사례이지, 실패 재현이나 신뢰도 측정치 또는 프로덕션 품질 순위가 아닙니다. 반복 시험은 없었으며, 모델마다 기본 설정이 다를 수 있습니다. 2.1과 2 비교에서는 이 제한적인 비교를 다룹니다.

확인 당시 공개된 Ofox Nano Banana 2.1 모델 페이지에는 해당 모델이 표시되어 있었습니다. 공개 인터페이스는 사용 가능성을 보여 주는 근거일 뿐, 로그인이 필요한 Playground에서 결과를 생성했다는 증거가 아니며, 인증된 Agent의 모델 선택도 테스트하지 않았습니다. 반대로 2.1을 사용할 수 있기 전의 과거 404가 관찰된 2.1 응답을 무효화하는 것도 아닙니다. 한쪽 관찰을 다른 쪽으로 대체하지 말고 현재 모델 ID와 본인의 접근 권한을 확인하세요.

디버깅할 때 마지막으로 유의할 비용 사항이 있습니다. 테스트한 1K 2.1 포스터 요청에서 이미지 출력 토큰 1,120개가 보고됐습니다. 관찰 당시의 백만 이미지 출력 토큰당 $30 카탈로그 요금을 적용하면 이미지 항목 비용은 $0.0336입니다. 하지만 이는 검증된 인보이스 총액이 아닙니다. 입력, 텍스트, 추론 및 그 밖에 적용되는 비용이 추가될 수 있습니다. 요청 시간이 초과됐거나 애플리케이션이 응답을 파싱하지 못했다는 사실만으로 과금 대상 작업이 없었다고 볼 수는 없습니다. 먼저 상태 코드와 진단 필드를 저장한 뒤 새 요청이 필요한지 판단하세요.