# Midjourney API Python 사용법: 이미지 생성과 저장

> Python으로 Midjourney 이미지 작업을 만들고 상태를 폴링해 그리드를 저장하는 방법, 사용할 수 있는 기능과 재실행 요령을 정리합니다.

- Canonical HTML: https://omniakey.com/ko/blog/midjourney-api-python
- Markdown: https://omniakey.com/ko/blog/midjourney-api-python.md
- Published: 2026-11-12 (updated 2026-11-12)
- Author: Kiki, COO at OmniaKey
- Language: ko
- Category: API setup
- Reading time: 8 min
- All posts: https://omniakey.com/blog/llms.txt

---

2026년 11월 12일 연동 테스트에서 OmniaKey의 서드파티 [Midjourney](https://omniakey.com/ko/midjourney) API로 만든 작업 하나가 197.1초 만에 2048x2048 이미지를 반환했습니다. 그 이미지는 후보 네 개를 담은 2x2 그리드였습니다. 이 튜토리얼의 Python 예제는 작업 생성, 상태 폴링, 서명된 애셋 URL에서 PNG 다운로드 순서로 진행합니다. 아래 스크립트는 상태 파일을 추가하므로 시간 초과나 다운로드 실패 뒤에 다시 실행해도 유료 작업이 두 번 생기지 않습니다.

## 짧은 답변

- `POST https://api.omniakey.com/v1/media/tasks`에 `Idempotency-Key`를 붙여 보내고, `GET /v1/media/tasks/{id}`를 폴링한 뒤 `assets[0].url`을 내려받습니다.
- 작업 하나는 이미지 네 개를 담은 PNG 그리드 한 장을 반환합니다. 파일 네 개가 아닙니다.
- 이 API의 Midjourney는 텍스트→이미지만 지원합니다. 편집, 참조 이미지, blend, 업스케일, vary, 패닝, 버전 선택은 없습니다.
- 키는 OmniaKey에서 발급하며 Midjourney 구독과 별개입니다. POST 전에 요청 본문과 멱등성 키를 저장하고, API가 요청을 받아들이면 작업 ID를 추가합니다. 상태 파일을 남겨 두면 나중에 이어서 할 수 있습니다. 로컬 시간 초과로 서버 작업이 취소되지는 않습니다.

## 이 Python 예제는 어떤 API를 쓰나요?

예제는 OmniaKey가 제공하는 Midjourney용 서드파티 API를 호출합니다. OmniaKey는 Midjourney가 아니며, OmniaKey 키는 자체 잔액을 가진 별도 계정입니다. Midjourney 웹사이트 구독은 별도 제품이고 OmniaKey 키를 만들지 않습니다.

[Midjourney 모델 페이지](https://omniakey.com/ko/midjourney)에서 OmniaKey 쪽 모델과 현재 요금을 볼 수 있습니다. 아래에서는 이 연동과 작업 흐름을 다룹니다.

## 필요한 것

세 가지가 필요합니다.

1. OmniaKey API 키. 환경 변수 `OMNIAKEY_API_KEY`에 설정합니다.
2. Python 3. 스크립트는 표준 라이브러리만 쓰므로 SDK를 설치할 필요가 없습니다.
3. 해당 키/계정에서 `midjourney`를 쓸 수 있어야 합니다. 키의 모델 목록에 없다면 생성 요청을 보내기 전에 계정 접근 권한을 확인하세요.

```bash
export OMNIAKEY_API_KEY="your-key"
python3 midjourney_grid.py
```

키는 스크립트나 로그에 남기지 마세요. 코드는 환경 변수에서 한 번만 읽습니다.

## 요청 본문과 파라미터

Midjourney에는 프롬프트와 파라미터 세 개를 보냅니다. bot type은 OmniaKey 쪽에 미리 설정되어 있어 본문에 모델 버전이나 `botType`을 쓸 필요가 없습니다.

|필드|값|설명|
|-|-|-|
|`input.prompt`|텍스트|원하는 장면|
|`parameters.aspectRatio`|`1:1`|`16:9`, `9:16`, `3:2`, `2:3`도 가능|
|`parameters.quality`|`"1"`|문서에서는 문자열 `"1"`|
|`parameters.n`|`1`|1만 가능|

이미지 모양은 픽셀 크기가 아니라 `aspectRatio`로 지정합니다. 필드는 카멜 표기법이며 `aspect_ratio`가 아니라 `aspectRatio`입니다. 위에 나온 다섯 값 중 하나를 쓰세요.

샘플 프롬프트의 본문은 다음과 같습니다.

```json
{
  "model": "midjourney",
  "type": "image",
  "operation": "generate",
  "input": {
    "prompt": "A small red paper boat floating on a quiet blue lake at sunrise, cinematic lighting, elegant composition, no text"
  },
  "parameters": {"aspectRatio": "1:1", "quality": "1", "n": 1}
}
```

## Python에서 Midjourney API 호출하기

`midjourney_grid.py`로 저장하고 실행하세요. 처음 실행하면 POST 전에 `midjourney-task.json`을 쓰고, OmniaKey가 요청을 받아들이면 작업 ID를 추가합니다.

```python
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path

API = "https://api.omniakey.com/v1"
KEY = os.environ["OMNIAKEY_API_KEY"]
STATE = Path("midjourney-task.json")
OUT = Path("midjourney-grid.png")
PROMPT = ("A small red paper boat floating on a quiet blue lake at sunrise, "
          "cinematic lighting, elegant composition, no text")


def save(state):
    tmp = STATE.with_suffix(".tmp")
    tmp.write_text(json.dumps(state, indent=2))
    tmp.replace(STATE)


def read_json(response):
    raw = response.read()
    try:
        return json.loads(raw)
    except ValueError:
        return {"raw": raw[:300].decode("utf-8", "replace")}


def call(method, path, body=None, headers=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(
        API + path, data=data, method=method,
        headers={"Authorization": f"Bearer {KEY}", **(headers or {})})
    if body is not None:
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=60) as response:
            return response.status, read_json(response)
    except urllib.error.HTTPError as error:
        return error.code, read_json(error)
    except (urllib.error.URLError, TimeoutError) as error:
        return None, {"error": str(error)}


def main():
    state = json.loads(STATE.read_text()) if STATE.exists() else None
    if state is None:
        body = {
            "model": "midjourney", "type": "image", "operation": "generate",
            "input": {"prompt": PROMPT},
            "parameters": {"aspectRatio": "1:1", "quality": "1", "n": 1},
        }
        state = {"idempotency_key": str(uuid.uuid4()), "body": body,
                 "task_id": None, "status": "creating"}
        save(state)  # no API key here; write before POST
    elif state["body"]["input"]["prompt"] != PROMPT:
        sys.exit("The state file holds a different prompt. Move it aside to start a new job.")

    if OUT.exists() and state.get("status") == "downloaded":
        saved_sha = state.get("download_sha")
        if not saved_sha or hashlib.sha256(OUT.read_bytes()).hexdigest() == saved_sha:
            print("already saved", OUT)
            return

    if not state["task_id"]:
        code, task = call("POST", "/media/tasks", state["body"],
                          {"Idempotency-Key": state["idempotency_key"]})
        if code is None:
            sys.exit(f"Create not confirmed: {task['error']}. Rerun to retry the same key and body.")
        if code == 409:
            sys.exit("409 idempotency_key_reused: same key, different body. Restore the saved body.")
        if code == 503:
            sys.exit("503 no_available_channel: check model access for this key/account; "
                     "contact support with the request id if it persists.")
        if code not in (200, 202) or "id" not in task:
            sys.exit(f"Create failed: HTTP {code} {task}")
        state.update(task_id=task["id"], status=task["status"])
        save(state)

    deadline = time.monotonic() + 900
    while True:
        code, task = call("GET", f"/media/tasks/{state['task_id']}")
        if code is None:
            sys.exit(f"Polling stopped: {task['error']}. The job is still saved; rerun this script.")
        if code != 200 or "status" not in task:
            sys.exit(f"Poll failed: HTTP {code} {task}")
        state["status"] = task["status"]
        save(state)
        if task["status"] in ("failed", "canceled"):
            sys.exit(f"Job {task['status']}: {task.get('error')}")
        if task["status"] == "succeeded":
            break
        if time.monotonic() > deadline:
            sys.exit(f"Still {task['status']} after 900 s. Rerun with the same state file.")
        time.sleep(min(8, 2 + task.get("progress", {}).get("percent", 0) // 20))

    asset = task["assets"][0]
    if asset.get("mime_type") != "image/png":
        sys.exit(f"Expected a PNG grid, got {asset.get('mime_type')}")
    try:
        with urllib.request.urlopen(asset["url"], timeout=120) as response:
            data = response.read()  # signed URL: no Authorization header
    except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error:
        sys.exit(f"Download failed: {error}. Rerun to get a fresh signed URL and retry.")
    digest = hashlib.sha256(data).hexdigest()
    if asset.get("sha256") and digest != asset["sha256"]:
        sys.exit("SHA-256 mismatch. Rerun to download the asset again.")
    part = OUT.with_suffix(".part")
    part.write_bytes(data)
    part.replace(OUT)
    state.update(status="downloaded", download_sha=digest)
    save(state)
    print("saved", OUT, len(data), "bytes")


if __name__ == "__main__":
    main()
```

요청과 응답 형태는 11월 12일 연동 테스트에서 가져왔습니다. 재시작 상태 관리는 여기서 추가한 부분이며, 수명 주기 경로는 모의 응답으로 오프라인에서 확인했습니다.

상태 파일에는 이어서 실행하는 데 필요한 것이 남습니다. JSON 본문과 멱등성 키로 쓰는 UUID입니다. API 키는 저장하지 않습니다. POST 전에 기록하므로 연결이 끊겨도 복구에 필요한 두 값이 디스크에 남습니다.

생성 호출은 성공 코드 두 개를 반환할 수 있습니다. `202`는 새 작업을 받아들였다는 뜻입니다. `200`은 OmniaKey가 같은 키와 같은 본문을 전에 본 적이 있어 원래 작업을 돌려준다는 뜻입니다. 그 작업을 계속 폴링하세요. 같은 본문과 키를 다시 보내는 것은 연결 장애 뒤 생성 결과를 알 수 없을 때뿐입니다. 본문을 바꾸면 `409 idempotency_key_reused`가 됩니다.

폴링은 `status`로 제어합니다. 퍼센트는 참고값이라 작업이 도는 동안 0에 머물 수 있습니다. `succeeded`, `failed`, `canceled`에서 멈춥니다. OmniaKey의 [미디어 작업 수명 주기 문서](https://docs.omniakey.com/en/media/overview)(2026년 11월 12일 확인)에 이 상태들이 나와 있습니다.

폴링 상한은 로컬 설정입니다. 상한에 닿아도 작업은 계속 실행 중이거나 대기 중이며, 같은 스크립트를 다시 실행하면 저장된 작업 ID를 읽어 폴링을 이어 갑니다. 로컬 시간 초과는 취소가 아닙니다. 취소는 별도 엔드포인트로 합니다.

## 돌아오는 Midjourney 이미지 그리드

성공한 작업에는 애셋이 하나뿐입니다. PNG 그리드입니다. 아래는 2026년 11월 12일 연동 테스트에서 받은 샘플입니다.

![해 뜰 무렵 푸른 물 위에 떠 있는 빨간 종이배 네 척. 2x2 그리드로 놓여 있고 따뜻한 빛이 물에 반사됩니다](https://omniakey.com/_img/blog/midjourney-api-python/paper-boat-grid.03735622.1600.webp)

_2026년 11월 12일 연동 테스트 샘플: midjourney 작업 1개, 2048x2048 PNG 1장, 후보 4개, 생성부터 다운로드까지 197.1초. 내려받은 바이트는 작업의 SHA-256과 일치했습니다._

아래 응답은 줄인 버전입니다. URL은 가렸고 `expires_at`은 당시 값입니다. 필요한 애셋 필드를 덧붙였습니다.

```json
{
  "id": "task_DbCWJPfPL1jyhSD38IihNHiiFQao8hjV",
  "status": "succeeded",
  "progress": {"percent": 100, "stage": "completed"},
  "assets": [
    {
      "kind": "image",
      "mime_type": "image/png",
      "width": 2048,
      "height": 2048,
      "byte_size": 5834301,
      "sha256": "f503cef038d15fa150b36b5fe7c41874febad3aa1167698d790781e47a4a10b9",
      "expires_at": "2026-11-12T04:16:41Z",
      "url": "https://…redacted-signed-url…"
    }
  ]
}
```

2048을 고정값으로 두지 말고 `width`와 `height`를 애셋에서 읽으세요. 2048x2048과 197.1초는 샘플 하나일 뿐입니다. 후보 네 개가 PNG 한 장을 공유하므로 낱장 이미지가 필요하면 다운로드 후 그리드를 나누세요.

`url`은 서명되어 있고 약 1시간만 유효합니다. OmniaKey의 [이미지 API 문서](https://docs.omniakey.com/en/media/image)(2026년 11월 12일 확인)에 따르면 작업 기록과 이미지는 생성 후 24시간 보관됩니다. URL이 만료되기 전에 내려받으세요. 만료됐다면 작업 ID를 다시 폴링해 새 서명 URL을 받습니다.

애셋 URL은 일반 GET으로 내려받고 `Authorization` 헤더를 붙이지 않습니다. 서명은 URL 안에 있습니다. 스크립트는 내려받은 바이트를 작업의 `sha256`과 비교하므로 잘린 파일을 멀쩡한 복사본 위에 덮어쓰기 전에 잡아냅니다.

## 시간 초과나 다운로드 실패 후 다시 실행하기

같은 명령을 다시 실행하고 작업을 이어 갑니다. 스크립트는 `midjourney-task.json`을 읽고 가장 짧은 안전 경로를 고릅니다.

- 생성이 확인되지 않음: 같은 본문을 같은 멱등성 키로 POST합니다.
- 작업 ID가 있고 작업이 아직 실행 중: 기존 ID를 폴링합니다.
- 작업은 성공했지만 PNG가 없음: 새 서명 URL을 받기 위해 한 번 폴링하고 다시 내려받습니다.
- PNG가 이미 저장되어 있고 기록된 SHA-256과 일치: 추가 요청 없이 종료합니다. 손상된 파일은 폴링해 다시 내려받습니다.

이 방법은 24시간 보관 기간 안에서 동작합니다. 같은 키로 프롬프트나 파라미터를 바꾸면 `409`가 됩니다. 스크립트는 프롬프트가 맞지 않는 상태 파일을 거부합니다. 파라미터를 바꿀 때는 상태 파일을 다른 곳으로 옮기세요.

## 오류와 해결

아래 실패는 API의 작업 규칙과 멱등성 규칙에서 나옵니다. 원인을 고친 뒤 해당되면 같은 상태 파일로 다시 실행하세요.

|응답|코드|할 일|
|-|-|-|
|400|`missing_idempotency_key`|헤더를 추가하고 255자 이내로 유지|
|409|`idempotency_key_reused`|원래 본문을 보내거나 새 키로 새 작업 시작|
|503|`no_available_channel`|이 키/계정의 접근 권한 확인, 계속되면 지원팀에 문의|
|`failed`|`error.code`, `error.message`|작업 오류 확인. 실패한 작업은 환불됨|
|`canceled`|—|작업이 중지됨. 필요하면 새로 만들기|

`503 no_available_channel`은 키에 모델 권한이 없다는 뜻이 아닙니다. 이 배치에서 사용한 키 하나로 두 번 제출했지만 `503`이 반환되고 작업은 생성되지 않았습니다. 다른 키는 같은 날 이른 시간의 연동 테스트에서 성공했습니다. 원인은 알 수 없으며 키/계정 가용성이나 업스트림 채널 가용성일 수 있습니다. 현재 키/계정에서 `midjourney`를 사용할 수 있는지 확인하세요. `503`이 계속되면 응답 본문과 요청 ID를 지원팀에 보내세요.

생성 시 `200`은 원래 작업의 재전송입니다. 종료 실패는 `failed`와 `canceled`뿐입니다. 스크립트를 멈춰도 작업은 계속 실행됩니다.

## 비용과 다른 이미지 API를 고를 때

OmniaKey는 성공한 작업마다 한 번 과금하고, 실패하거나 취소된 작업은 자동으로 환불합니다. 현재 요금은 [Midjourney 모델 페이지](https://omniakey.com/ko/midjourney)에서 확인하세요. 배치를 견적 내기 전에 확인하는 것이 좋습니다.

Midjourney는 한 그리드에서 네 가지 변형을 비교할 때 잘 맞습니다. 이 연동은 현재 텍스트→이미지 그리드를 반환합니다. 호출당 이미지 한 장이 필요하거나 원본 장면을 유지하는 편집이 필요하면 [GPT Image 2.5 API](https://omniakey.com/ko/blog/gpt-image-2-5-api) 또는 [Nano Banana Pro API](https://omniakey.com/ko/blog/nano-banana-pro-api)를 보세요.

코드 대신 프롬프트를 입력하고 싶다면 [AI Image Generator](https://omniakey.com/ko/ai-image-generator)에서 브라우저로 이미지 모델을 실행할 수 있습니다.

## 자주 묻는 질문

### 이 API를 쓰려면 Midjourney 구독이 필요한가요?

아니요. 키는 OmniaKey에서 발급하고 호출은 OmniaKey의 서드파티 API로 갑니다. OmniaKey는 Midjourney가 아닙니다. Midjourney 웹사이트 구독은 별도 제품이고 OmniaKey 키를 만들지 않습니다.

### 작업 하나가 이미지 네 장을 파일 하나로 반환하는 이유는 무엇인가요?

Midjourney는 후보 네 개를 2x2 그리드 PNG 한 장으로 반환합니다. 이 연동에서는 업스케일과 vary를 쓸 수 없어 네 후보가 같은 파일을 공유합니다. 파일을 따로 쓰려면 다운로드 후 그리드를 직접 나누세요.

### 409 idempotency\_key\_reused는 무슨 뜻인가요?

OmniaKey가 이미 가진 멱등성 키를 보냈지만 요청 본문이 바뀌었다는 뜻입니다. 원래 본문을 보내거나 새 키로 새 작업을 시작하세요. 같은 키로 프롬프트를 바꾸는 것이 흔한 원인입니다.

### 폴링 중 스크립트가 시간 초과되면 어떻게 되나요?

작업은 계속 실행됩니다. 작업 ID가 `midjourney-task.json`에 있으므로 같은 스크립트를 다시 실행하면 그 ID를 폴링하거나 완료된 애셋을 내려받습니다. 로컬 시간 초과는 작업과 과금을 그대로 둡니다.
