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}"
)
세 가지 안전장치가 들어 있습니다.
- "Use the image_generation tool" — 사용할 툴을 명시적으로 지정
- "Do not write code, do not edit files" — 코딩 모드로 빠지는 것 방지
- "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분 잡아 두고, 초과하면 프로세스 강제 종료(kill 후 wait).
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자로 자른 슬러그idx—n>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
흐름은 다음과 같습니다.
- 입력 검증 (
n범위) - 프리셋 펼치기 → Codex용 프롬프트 빌드
- N번 반복 — 한 번에 한 장씩 호출(Codex는 한 호출당 1장 안정적)
- 실패한 호출은 텍스트 메시지로 변환하고 다음 시도 계속 (전부 실패해도 그 사실만 사용자에게)
- 성공한 PNG는 사본·메타데이터·base64 미리보기·자동 뷰어 열기
- 마지막에 "몇 장 / 어디 저장" 요약 텍스트 추가
수업 포인트:
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에서 호출 → 트러블슈팅 까지 한 번에 돌려 봅니다.