> ## Documentation Index
> Fetch the complete documentation index at: https://docs.apimart.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 모범 사례

> Midjourney 연동의 폴링 패턴, 프롬프트 설계, 참조 이미지, 오류 재시도 전략, 동시성 및 문제 해결 권장 사항

자주 묻는 질문, 성능 최적화, 오류 처리에 대한 모범 사례를 정리했습니다. **연동 전에 통독하는 것을 권장합니다.**

## 작업 제출과 폴링

제출 엔드포인트는 모두 비동기 작업입니다. 제출 후 `task_id`를 반환하며, 이후 `GET /v1/midjourney/{task_id}`를 주기적으로 조회하여 상태를 확인하고 `SUCCESS` / `FAILURE`가 될 때까지 기다립니다.

```python theme={null}
import time, httpx

def wait_task(task_id, timeout=300):
    deadline = time.time() + timeout
    while time.time() < deadline:
        resp = httpx.get(f"{HOST}/v1/midjourney/{task_id}",
                         headers={"Authorization": f"Bearer {API_KEY}"}).json()
        if resp["status"] in ("SUCCESS", "FAILURE"):
            return resp
        if resp["status"] == "MODAL":
            raise RuntimeError(f"task {task_id} 작업을 완료하려면 /modal 호출이 필요합니다")
        time.sleep(3)
    raise TimeoutError(task_id)
```

* **폴링 주기**: 3\~5초에 한 번을 권장합니다. 더 높은 빈도는 의미가 없으며 할당량만 낭비합니다.
* **web 요청 안에서 동기적으로 차단(blocking)하며** 작업 완료를 기다리지 마세요. 제출 후 즉시 `task_id`를 반환하고, 프런트엔드에서 비동기로 폴링하도록 하세요.

## 프롬프트 설계

**좋은 프롬프트:**

```text theme={null}
a serene mountain lake at sunrise, photorealistic, soft golden light,
mist rising from water, snow-capped peaks in distance --ar 16:9 --v 8.1 --s 100
```

* **주체를 앞에**: 먼저 주체, 다음으로 장면 묘사, 마지막에 수식어 순으로 작성합니다.
* **구조화 파라미터를 명시적으로**: `--ar` / `--v` / `--s`(또는 대응하는 body 필드)를 사용하면 기본값에 의존하는 것보다 더 통제하기 쉽습니다.
* **모호한 단어 피하기**: `photorealistic`이 `realistic`보다 더 명확합니다.

**피해야 할 것:** 지나치게 추상적인 표현("make it good"), 산만한 주체(여러 병렬 객체에 주종 구분이 없는 경우), 단어에 따옴표 붙이기(리터럴 값으로 처리됨).

**Niji 애니메이션:** `niji: true` + `version: "7"`을 전달하면 플랫폼이 `--niji 7`로 정규화하며, 과금은 `midjourney@imagine-niji7`로 처리됩니다.

## 참조 이미지 모범 사례

| 출처            | 권장 방법                                   | 주의                                |
| ------------- | --------------------------------------- | --------------------------------- |
| 사용자 업로드       | 먼저 자체 OSS / CDN에 저장한 뒤, 제출 시 해당 URL을 전달 | base64를 직접 전달하지 마세요(대역폭 낭비)       |
| 공개 URL        | 직접 전달                                   | SSRF(공개 접근 가능해야 함) 및 12 MiB 제한 주의 |
| 서드파티 / 기타 산출물 | 먼저 자체 OSS로 전송                           | 서드파티 URL은 만료될 수 있음                |

* **5 MiB 미만으로 압축**: 플랫폼 상한은 12 MiB이지만, 작은 이미지가 전송 / 처리 모두 더 빠릅니다.
* 형식은 PNG / JPG / WebP 모두 가능하며, 고품질 JPG를 권장합니다.
* 해상도는 1024\~2048 px로 충분하며, 그 이상은 낭비입니다.
* 참조 이미지 가중치 `iw`(0\~3, 기본 1): >1이면 원본에 더 가깝고, \<1이면 더 자유롭습니다.

## 오류 처리와 재시도 전략

| code                  | 의미                            | 재시도 전략                                          |
| --------------------- | ----------------------------- | ----------------------------------------------- |
| `1` / `200`           | 성공                            | ✅                                               |
| `4` VALIDATION\_ERROR | 파라미터 오류                       | ❌ 재시도하지 말고 파라미터를 수정                             |
| `3` NOT\_FOUND        | 사용 가능한 인스턴스 없음 / task\_id 미존재 | 인스턴스 불가 시 잠시 후 재시도 가능; task\_id 미존재 시 재시도하지 말 것 |
| `9` FAILURE           | 서비스 거부 / 내부 오류                | ⏳ 재시도 가능, 지수 백오프(1s, 4s, 16s)                   |
| `21` MODAL            | 비종료 상태                        | ✅ 계속 `/modal` 호출                                |
| `24` BANNED\_PROMPT   | 금칙어                           | ❌ 재시도하지 말고 prompt 수정; **자동 환불됨**                |
| `429`                 | 레이트 리밋                        | ⏳ 지수 백오프 + jitter                               |
| `5xx` / 네트워크 오류       | 서버 / 네트워크                     | ⏳ 지수 백오프, 네트워크 오류는 즉시 1회 재시도 가능                 |

```python theme={null}
import time, random, httpx

def submit_with_retry(payload, max_attempts=5):
    for attempt in range(max_attempts):
        try:
            r = httpx.post(f"{HOST}/v1/midjourney/generations/imagine",
                           json=payload,
                           headers={"Authorization": f"Bearer {API_KEY}"},
                           timeout=30)
            data = r.json()
            if r.status_code == 200 and data["code"] in (1, 200):
                return data
            if data["code"] in (4, 24):
                raise ValueError(data["description"])      # 재시도 불가
            if data["code"] == 3 and "task" in data["description"]:
                raise ValueError(data["description"])      # task_id 미존재
            # 그 외(9 / 429 / 5xx)는 재시도 가능
        except httpx.RequestError:
            pass
        time.sleep((4 ** attempt) + random.uniform(0, 1))  # 1s / 4s / 16s ...
    raise RuntimeError(f"최대 재시도 횟수 {max_attempts}에 도달")
```

## 2차 작업 플로우

```python theme={null}
# imagine → 폴링 → upscale
imagine_id = submit({"prompt": "a cat"})["data"][0]["task_id"]
result = wait_task(imagine_id)           # grid_image_url + 이미지 4장 image_urls + buttons
upscale_id = submit_to("/upscale", {"task_id": imagine_id, "index": 2})["data"][0]["task_id"]
final = wait_task(upscale_id)            # upscale 로컬 합성, 1~2s
single_image = final["image_urls"][0]
```

부분 리페인트(inpaint → modal 2단계):

```python theme={null}
imagine_id = submit({"prompt": "a portrait"})["data"][0]["task_id"]; wait_task(imagine_id)
upscale_id = submit_to("/upscale", {"task_id": imagine_id, "index": 1})["data"][0]["task_id"]; wait_task(upscale_id)

inpaint_id = submit_to("/inpaint", {"task_id": upscale_id})["data"][0]["task_id"]  # status=modal
# 프런트엔드에서 mask 그리기(투명=리페인트 영역), 자체 OSS에 업로드하여 mask_url 획득
final = submit_to("/modal", {
    "task_id": inpaint_id,
    "prompt": "replace the eyes with cybernetic blue eyes",
    "mask_url": "https://your-oss.com/mask.png"
})
wait_task(final["data"][0]["task_id"])
```

> ⚠️ inpaint가 MODAL 상태에 진입하면 **30분 이내**에 반드시 `/modal`을 호출해야 합니다. 그렇지 않으면 백그라운드에서 자동으로 CANCEL + 환불됩니다.

## video 과금 제어

* 단일 클립: `batch_size: 1` → 1 × `midjourney@video` 차감
* 배치 4클립: `batch_size: 4` → 4 × `midjourney@video` 차감
* 고화질 단일 클립: `video_type: "vid_1.1_i2v_720"` + `batch_size: 1` → 1 × `midjourney@video-720p` 차감

**권장**: 결과물이 1클립만 필요하면 `batch_size=1`을 사용하고, 비교용 배치일 때만 4를 사용하세요. 기본값으로 4를 켜지 마세요(비용이 N배로 증가).

## 동시성과 처리량

```python theme={null}
import asyncio
sem = asyncio.Semaphore(10)  # 클라이언트 최대 10개 동시 제출

async def submit_one(prompt):
    async with sem:
        return await submit({"prompt": prompt})
```

* 플랫폼은 분당 제출 수에 상한이 있으며, 초과 시 `429`를 반환하므로 백오프 재시도가 필요합니다.
* 실제 생성 동시성은 시스템 용량에 따라 결정되며, 초과 시 대기열에 들어갑니다. 작업이 오랫동안 `SUBMITTED`에 멈춰 있으면 보통 대기 중인 상태입니다.
* 폴링 시 반드시 `sleep`을 넣고, sleep 없는 무한 루프를 돌리지 마세요.

## 모니터링 권장 사항

| 지표                   | 참고 임계값 | 의미                             |
| -------------------- | ------ | ------------------------------ |
| 작업 SUCCESS 비율(최근 1h) | > 95%  | 낮으면 서비스 / 네트워크 이상              |
| 평균 완료 소요 시간          | \< 90s | 높으면 대기 중                       |
| MODAL 잔류 작업 수        | 0에 근접  | 많으면 클라이언트가 `/modal`을 호출하지 않은 것 |
| `code=24` 비율         | \< 5%  | 높으면 prompt가 자주 금칙어를 유발하는 것     |

## 문제 해결 체크리스트

| 현상                   | 점검 방향                                        |
| -------------------- | -------------------------------------------- |
| 작업이 오랫동안 `SUBMITTED` | 시스템 대기 중, 잠시 후 다시 조회                         |
| 작업이 오랫동안 `NOT_START` | 플랫폼이 잠시 후 자동으로 타임아웃 환불하므로 수동 처리 불필요          |
| 작업이 `MODAL` 30분 초과   | 클라이언트가 `/modal`을 호출하지 않음, 이미 자동 CANCEL + 환불됨 |
| `prompt` 필드가 비어 있음   | describe 작업의 텍스트 결과는 `description` 필드에 있음    |
| `image_urls`가 한 장 부족 | 콘텐츠 심사가 일부 이미지를 차단한 것, `fail_reason` 확인      |
| 과금이 예상보다 큼           | `quota` 필드 확인; video는 × `batch_size`를 잊지 말 것 |
