콘텐츠로 이동

3장: Codex CLI를 서브프로세스로 — 인증·캡처·메타데이터

도구 외형은 다 짰습니다. 이제 진짜 일을 시킬 차례입니다. Codex CLI를 자식 프로세스로 돌리고, ChatGPT 구독 쿼터로 이미지를 받아내고, 그 PNG가 어디 떨어졌는지 안전하게 찾아내는 부분을 만듭니다. 이 장이 이 프로젝트의 핵심 트릭입니다.


3.1 왜 서브프로세스인가 — 큰 그림 다시

[server.py 안]
  expanded_prompt 만들기
        ↓
  codex exec --full-auto --skip-git-repo-check "<프롬프트>"  ← 자식 프로세스
        ↓
  Codex 가 ChatGPT OAuth 인증으로 image_generation 툴 호출
        ↓
  ~/.codex/generated_images/<session-uuid>/ig_*.png  생성
        ↓
  우리는 "새로 생긴 파일이 무엇인가?"를 알아내서
        ↓
  output/2026-04-25/HHMMSS_<safe_prompt>.png  로 사본 + .json 메타

Codex CLI는 우리 코드가 직접 OpenAI API를 부르지 않게 막아 주는 우회로입니다. 인증, 모델 선택, 쿼터 차감 — 전부 Codex가 알아서 합니다. 우리 서버는 단지 "프롬프트를 영어 한 문장으로 넘기고", "결과 PNG가 어디 떨어졌는지 잡아내기"만 하면 됩니다.


3.2 프리셋 적용 — _expand_with_preset

도구의 preset="luxury_dark" 인자가 들어오면 presets.yaml 의 suffix를 붙여 줍니다.

def _expand_with_preset(prompt: str, preset: str) -> str:
    if not preset:
        return prompt
    entry = PRESETS.get(preset)
    if not entry:
        return prompt
    suffix = entry.get("suffix", "")
    return f"{prompt}, {suffix}" if suffix else prompt

예:

# presets.yaml
luxury_dark:
  suffix: "luxury dark aesthetic, deep black or charcoal background, ..."
prompt = "wristwatch on table"
preset = "luxury_dark"
expanded = _expand_with_preset(prompt, preset)
# → "wristwatch on table, luxury dark aesthetic, deep black or charcoal background, ..."

존재하지 않는 프리셋 키나 빈 prompt는 조용히 통과 시킵니다 — 사용자 시나리오 한가운데서 죽이지 않는 게 좋은 도구의 조건입니다.


3.3 Codex 에 보낼 프롬프트 만들기 — _build_imagegen_prompt

여기서 한 가지 미묘한 함정을 만납니다.

codex exec 는 본래 코딩 에이전트입니다. "이미지 만들어 줘" 라고 모호하게 던지면 Codex가 친절하게 pip install pillow 부터 시작해서 코드를 짜기 시작할 수 있습니다. 그게 아니라 자기 안의 image_generation 툴을 한 번만 호출하고 끝내게 만들어야 합니다.

def _build_imagegen_prompt(user_prompt: str, size: str, quality: str) -> str:
    return (
        f"Use the image_generation tool to create the following image. "
        f"Do not write code, do not edit files - just call the image generation tool once and return.\n\n"
        f"Image: {user_prompt}\n"
        f"Aspect/size: {size}\n"
        f"Quality: {quality}"
    )

세 가지 안전장치가 들어 있습니다.

  1. "Use the image_generation tool" — 사용할 툴을 명시적으로 지정
  2. "Do not write code, do not edit files" — 코딩 모드로 빠지는 것 방지
  3. "call the image generation tool once and return" — 무한 변주·재시도 방지

수업 포인트: 다른 LLM 에이전트를 우리 도구 안에서 부를 때는, 그 에이전트의 본업 성향을 무력화하는 한 줄을 항상 넣으세요. Codex는 코딩 에이전트, Claude Code도 코딩 에이전트 — 둘 다 그냥 두면 코드를 쓰려 합니다.


3.4 핵심 트릭 — 스냅샷 diff 로 새 PNG 찾기

codex exec새로 생성한 PNG 파일의 경로를 stdout에 깔끔히 알려 주지 않습니다. 출력에 어디쯤 등장은 하지만 포맷이 모델 버전·세션마다 미세하게 달라집니다. 정규식으로 긁어내면 깨지기 쉽습니다.

대신 우리는 파일시스템을 호출 직전 / 직후에 두 번 스캔해서 그 차집합을 새 파일로 봅니다.

def _snapshot_existing_images() -> set:
    if not CODEX_IMG_DIR.exists():
        return set()
    return set(CODEX_IMG_DIR.rglob("ig_*.png"))
before = _snapshot_existing_images()
# ... codex exec 호출 ...
after = _snapshot_existing_images()
new_images = sorted(after - before, key=lambda p: p.stat().st_mtime)
  • rglob("ig_*.png") — Codex 가 만드는 파일은 항상 ig_ 로 시작
  • 차집합 after - before — 이번 호출이 만들어 낸 것만 정확히 추출
  • mtime 정렬 — 한 호출에 여러 장이 나와도 시간 순으로 정렬

수업 포인트: 외부 도구의 stdout을 파싱하지 말고, 사이드이펙트를 관측하세요. CLI는 출력 포맷을 자주 바꾸지만, 파일시스템에 떨어진 결과물은 잘 안 바뀝니다.


3.5 서브프로세스 호출 — _run_codex_imagegen

이제 모든 조각을 합칩니다.

async def _run_codex_imagegen(
    full_prompt: str,
    input_images: list = None,
    timeout: int = 240,
) -> tuple:
    before = _snapshot_existing_images()

    cmd = [
        "codex", "exec",
        "--full-auto",
        "--skip-git-repo-check",
        full_prompt,
    ]
    for img in input_images or []:
        cmd.insert(-1, "-i")
        cmd.insert(-1, str(img))

    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdin=asyncio.subprocess.DEVNULL,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        raise RuntimeError(f"codex exec 타임아웃 ({timeout}s 초과)")

    after = _snapshot_existing_images()
    new_images = sorted(after - before, key=lambda p: p.stat().st_mtime)

    combined = (stdout + stderr).decode(errors="replace")

    if not new_images:
        # 실패 분기 — 다음 절에서 자세히
        ...

    session_dir = new_images[0].parent.name
    return new_images, combined, session_dir

이 함수의 결정 사항을 하나씩 봅시다.

3.5.1 --full-auto

Codex 가 사용자에게 "이거 실행해도 됩니까?" 같은 승인 프롬프트를 띄우면 우리는 답할 방법이 없습니다(stdin이 막혀 있음). 그래서 자동 승인 모드 필수.

3.5.2 --skip-git-repo-check

Codex 는 기본적으로 "여기 git 저장소 맞아?" 를 검사하고, 아니면 경고합니다. 우리 호출 위치는 git 저장소가 아닐 수도 있으므로(이 프로젝트도 그렇습니다) 검사를 건너뜁니다.

3.5.3 stdin=DEVNULL

asyncio.subprocess.DEVNULL이걸 빼먹으면 Codex 가 무한히 입력을 기다리며 멈춥니다. Codex는 기본적으로 stdin이 TTY에 연결돼 있다고 가정합니다. 명시적으로 "입력 없음"을 알려 주어야 합니다.

수업 포인트: 자식 프로세스의 stdin/stdout/stderr 은 전부 명시적으로 처리하세요. 어느 하나라도 잘못 두면 데드락 또는 파이프 가득 참 으로 멈춥니다.

3.5.4 stdout/stderr=PIPE

Codex 의 출력을 모두 우리가 캡처합니다 — Claude Code 의 채팅창으로 새어 나가지 못하게.

3.5.5 -i <image_path> 의 위치

for img in input_images or []:
    cmd.insert(-1, "-i")
    cmd.insert(-1, str(img))

-i 플래그는 입력 이미지를 첨부하는 옵션입니다. 마지막 위치(full_prompt 바로 앞)에 끼워 넣어야 Codex가 "프롬프트 + 첨부" 형태로 인식합니다.

3.5.6 timeout 240초

high quality + xhigh reasoning 으로 큰 사이즈를 만들면 한 장에 30~60초 걸립니다. 안전하게 4분 잡아 두고, 초과하면 프로세스 강제 종료(killwait).


3.6 실패 분기 — 친절한 에러 메시지

new_images 가 비어 있을 때 — 즉 호출은 끝났는데 새 PNG 가 없을 때 — 가능한 원인은 셋입니다.

if not new_images:
    if proc.returncode != 0:
        raise RuntimeError(
            f"codex exec 실패 (exit {proc.returncode}).\n"
            f"출력 마지막:\n{combined[-1500:]}"
        )
    if "not supported when using Codex with a ChatGPT account" in combined:
        raise RuntimeError(
            "현재 모델이 ChatGPT 계정에서 지원되지 않습니다.\n"
            "~/.codex/config.toml 의 model을 gpt-5.5 등 Plus 지원 모델로 변경하세요."
        )
    if "login" in combined.lower() and "auth" in combined.lower():
        raise RuntimeError(
            "Codex 인증 만료. 터미널에서 `codex login` 실행 후 재시도."
        )
    raise RuntimeError(
        f"이미지가 생성되지 않았습니다. 출력 마지막 부분:\n{combined[-1500:]}"
    )

3가지 알려진 실패 패턴 + 알 수 없는 실패 의 4분기 구조입니다.

패턴 원인 해결
returncode != 0 Codex 자체 크래시 출력 끝부분 보여 주고 보고
"not supported ... ChatGPT account" API 키 전용 모델을 ChatGPT 계정으로 호출 config.toml 의 model 교체
"login" + "auth" OAuth 토큰 만료 codex login 재실행
그 외 알 수 없음 출력 마지막 1500자 노출

수업 포인트: 외부 도구를 감싸는 래퍼는 그 도구의 알려진 에러 시나리오를 분기로 흡수해야 합니다. "그냥 raise" 대신 "구체적인 처방을 함께 raise" — 이게 도구를 도구답게 만듭니다.


3.7 결과 사본 + 메타데이터

Codex 가 만든 파일은 ~/.codex/generated_images/<session>/ig_<id>.png 라서 이름만 봐선 무엇을 그렸는지 알 수 없습니다. 우리는 사본을 떠서 의미 있는 이름으로 저장합니다.

def _save_image_copy(src: Path, prompt: str, idx: int) -> Path:
    today = datetime.now().strftime("%Y-%m-%d")
    ts = datetime.now().strftime("%H%M%S")
    safe = "_".join(
        "".join(c if c.isalnum() or c == " " else "" for c in prompt).split()
    )[:40].lower() or "img"
    suffix = f"_{idx}" if idx else ""
    dst = OUTPUT_DIR / today / f"{ts}{suffix}_{safe}.png"
    dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy(src, dst)
    return dst

만들어지는 경로 예: output/2026-04-25/145236_a_minimalist_poster_design_featuring_a_s.png

  • today/ — 날짜 폴더로 자동 분류
  • ts — 시·분·초 (충돌 거의 없음)
  • safe — 프롬프트에서 영숫자만 남기고 40자로 자른 슬러그
  • idxn>1 일 때 0, 1, 2, ... 식으로 번호 부여

옆에 JSON 메타데이터도 함께 떨어집니다.

def _save_metadata(image_path: Path, meta: dict) -> Path:
    json_path = image_path.with_suffix(".json")
    json_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2))
    return json_path
{
  "prompt": "흰 배경 빨간 사과 포스터",
  "expanded_prompt": "A minimalist poster design featuring a single vivid crimson red apple ...",
  "image_model": "gpt-image-2",
  "via": "codex-cli",
  "size": "1024x1536",
  "quality": "high",
  "preset": null,
  "tool": "generate_image",
  "input_images": [],
  "codex_session_dir": "session_xxx",
  "codex_source_path": "/home/.../ig_abc.png",
  "created_at": "2026-04-25T14:52:36"
}

왜 메타데이터를 따로 저장하는가?

  • 나중에 "이 사진 어떻게 만들었더라?" 가 1초 만에 답이 됩니다
  • preset / size / quality 조합별로 결과 비교 분석 가능
  • 마음에 드는 시안의 expanded_prompt 를 다시 시드 삼아 재변주 가능

수업 포인트: 생성형 결과물을 다루는 도구는 결과물 옆에 입력 조건을 박제해야 합니다. "잘 나온 사진의 프롬프트를 잃어버려서 다시 못 만들겠다" 사고를 막는 작은 보험입니다.


3.8 도구 본체에서의 흐름 — generate_image 예시

이제 모든 헬퍼를 합쳐 generate_image 도구의 본체를 봅시다.

@mcp.tool()
async def generate_image(prompt, size="1024x1024", quality="high",
                        n=1, preset=None, open_viewer=True):
    from mcp.types import ImageContent, TextContent

    if not 1 <= n <= 4:
        return [TextContent(type="text", text="n은 1~4 사이여야 합니다.")]

    expanded = _expand_with_preset(prompt, preset)
    full_prompt = _build_imagegen_prompt(expanded, size, quality)

    output_blocks = []
    saved_paths = []

    for i in range(n):
        try:
            new_imgs, _stdout, session_id = await _run_codex_imagegen(full_prompt)
        except RuntimeError as e:
            output_blocks.append(TextContent(type="text",
                                             text=f"[{i+1}/{n}] 실패: {e}"))
            continue

        src = new_imgs[0]
        dst = _save_image_copy(src, prompt, i)
        saved_paths.append(dst)

        meta = { ... }                # 위에서 본 메타데이터 dict
        _save_metadata(dst, meta)

        output_blocks.append(ImageContent(
            type="image", data=_read_as_b64(dst), mimeType="image/png",
        ))
        if open_viewer:
            _open_image(dst)

    if saved_paths:
        paths_str = "\n".join(str(p) for p in saved_paths)
        output_blocks.append(TextContent(
            type="text",
            text=f"{len(saved_paths)}장 생성 완료.\n\n저장 위치:\n{paths_str}",
        ))
    return output_blocks

흐름은 다음과 같습니다.

  1. 입력 검증 (n 범위)
  2. 프리셋 펼치기 → Codex용 프롬프트 빌드
  3. N번 반복 — 한 번에 한 장씩 호출(Codex는 한 호출당 1장 안정적)
  4. 실패한 호출은 텍스트 메시지로 변환하고 다음 시도 계속 (전부 실패해도 그 사실만 사용자에게)
  5. 성공한 PNG는 사본·메타데이터·base64 미리보기·자동 뷰어 열기
  6. 마지막에 "몇 장 / 어디 저장" 요약 텍스트 추가

수업 포인트: n>1 인 도구는 "전부 실패하면 깨끗하게 raise" 가 아니라 "부분 실패를 텍스트로 보고하고 가능한 만큼 살린다" 가 사용자 친화적입니다.


3.9 부가 헬퍼 — 미리보기와 뷰어

def _read_as_b64(path: Path) -> str:
    return base64.b64encode(path.read_bytes()).decode()


def _open_image(path: Path) -> None:
    for viewer in ("/usr/bin/eog", "/usr/bin/xdg-open"):
        if Path(viewer).exists():
            subprocess.Popen(
                [viewer, str(path)],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                start_new_session=True,
            )
            return
  • _read_as_b64 — 채팅창 인라인 미리보기를 위해 PNG 를 base64 로 인코딩
  • _open_image — Linux 환경에서 eog (GNOME Image Viewer) 또는 xdg-open 으로 자동 띄움. start_new_session=True 가 중요한데, 부모(MCP 서버)가 죽어도 뷰어가 살아남게 분리하는 옵션입니다.

3.10 정리

이번 장에서 한 일:

  • codex exec 호출 명령을 정확히 짜고, stdin/stdout/stderr 을 모두 명시적으로 처리
  • 새로 생성된 PNG 를 stdout 파싱 없이 파일시스템 스냅샷 diff 로 정확히 캡처
  • 알려진 실패 패턴 3종 + 미지의 실패 1종을 모두 친절한 한국어 메시지로 변환
  • 결과물을 의미 있는 이름으로 사본 + JSON 메타데이터 박제
  • 도구 본체에서 부분 실패도 우아하게 처리

이제 코드는 다 짰습니다. 다음 장에서는 실제로 설치 → MCP 등록 → Claude Code에서 호출 → 트러블슈팅 까지 한 번에 돌려 봅니다.