INERIT blog에 글 생성 워크플로우를 붙이면서 대표 이미지도 함께 만들고 싶었습니다. 본문이 완성돼도 썸네일을 따로 만들고 업로드해야 한다면, 마지막 단계는 여전히 사람 손에 남기 때문입니다. 글을 여러 언어로 제공하는 만큼 이미지 안의 문구도 해당 언어에 맞춰야 했습니다.

이미 사용하던 Codex에서는 이미지를 만들 수 있었습니다. 그래서 궁금해졌습니다. 이 기능을 블로그 서버에서도 호출할 수 있을까?

Pro 요금제 안내: Codex 사용 한도와 무제한 이미지 생성 혜택

ChatGPT Pro 상품 안내 화면. 이 글에서 구현한 실행 경로는 Codex CLI입니다.

최종적으로는 ChatGPT 계정으로 로그인한 Codex CLI와 HTTP 서버를 한 컨테이너에 넣었습니다. 블로그가 이미지 작업을 요청하면 워커가 CLI를 실행하고, 생성된 파일을 받아 블로그 저장소에 올립니다. 블로그 쪽에서는 이미지 생성 API를 호출하는 모양새지만, 그 안에서 실행되는 것은 로그인된 CLI입니다. 이 이미지 작업은 별도 Image API 크레딧을 쓰지 않고 Codex 구독 사용량으로 처리됩니다. 이미 사용하던 기능을 블로그에서 호출할 수 있게 만든 것이 핵심입니다.

이 글은 그 연결을 만들면서 확인한 동작과, 썸네일 때문에 글 전체를 다시 만들지 않도록 바꾼 과정을 다룹니다. 확인한 운영 버전은 Codex CLI 0.164.0, Node.js 24이며, 확인일은 2026년 11월 5일입니다.

먼저 확인한 것은 로그인 방식과 실제 이미지 파일이었다

Codex에는 ChatGPT 구독으로 로그인하는 방식과 API 키로 인증하는 방식이 있습니다. 이번 구현에서는 전자를 사용했습니다. 블로그에 넣는 키는 직접 만든 HTTP 서버에 접근하기 위한 앱 키이고, Codex의 로그인 자격 증명은 서버 안에서 따로 관리합니다. 두 인증을 분리한 것입니다. 공식 인증 문서

공식 문서에는 내장 이미지 생성이 일반 Codex 사용 한도에 포함된다고 안내되어 있습니다. 또한 codex exec는 저장된 CLI 인증을 재사용하고, --json으로 실행 이벤트를 내보낼 수 있습니다. 이 두 기능을 연결하면 서버에서 다룰 수 있겠다고 판단했습니다. 이미지 생성 문서, 비대화형 실행 문서

다만 명령을 실행할 수 있다는 사실만으로 이미지 생성까지 된다고 볼 수는 없습니다. 확인해야 할 것은 서버의 비대화형 실행에서도 내장 이미지 도구가 호출되고, 실제 결과 파일이 생기는지였습니다.

운영 중인 버전에서는 이 경로로 PNG를 얻을 수 있었습니다. 워커는 codex exec --json의 thread.started 이벤트에서 실행 ID를 받고, 해당 실행의 CODEX_HOME/generated_images/<thread-id> 디렉터리에서 결과를 수집합니다.

여기서 성공 조건을 모델의 마지막 답변으로 잡지 않았습니다. “이미지를 만들었습니다”라는 문장이나 프로세스 종료 코드만으로는 블로그에 붙일 파일이 있다는 것을 보장하지 못합니다. 해당 실행의 PNG가 존재하고, 파일 형식과 크기 검사를 통과해야 이미지 작업을 성공으로 처리했습니다. 지정한 크기로 변환할 때는 실제 이미지 디코딩까지 수행합니다.

generated_images의 경로와 이벤트 처리 방식은 이 버전의 구현을 기준으로 확인한 동작입니다. CLI 버전을 고정한 이유이기도 합니다.

CLI 앞에 작은 작업 API를 붙였다

블로그가 CLI의 출력이나 로그인 파일을 직접 다루게 하지는 않았습니다. Node.js 서버에 필요한 요청만 정의하고, 그 안에서 CLI를 실행하도록 나눴습니다.

호출 흐름은 다음과 같습니다.

블로그 워커 → 내부 HTTP API → Codex CLI → PNG 검증 → 블로그의 WebP 저장

이미지 API와 CLI는 같은 컨테이너에서 실행합니다. 생성 파일을 회수하기 쉽고, CLI 버전과 실행 환경도 함께 고정할 수 있기 때문입니다. 블로그에는 프롬프트, 해상도, 작업 상태처럼 필요한 값만 노출했습니다.

요청 형태는 이렇게 정했습니다. 아래 경로와 파라미터는 직접 만든 래퍼의 규격입니다.

http
POST /images HTTP/1.1
Authorization: Bearer <internal-app-key>
Idempotency-Key: <saved-request-id>
Content-Type: application/json

{
  "prompt": "블로그 썸네일. 터미널에서 이미지 작업이 실행되는 장면. 큰 한글 제목은 'Codex로 썸네일 자동화'.",
  "size": "1920x1080",
  "effort": "medium"
}

서버는 이미지가 완성될 때까지 이 연결을 붙잡는 대신, HTTP 202와 작업 ID를 반환합니다. 블로그는 GET /images/{id}로 상태를 확인하고, 성공하면 GET /images/{id}/file로 PNG를 받습니다. 상태 조회와 다운로드에도 같은 앱 인증이 필요합니다.

동기 응답을 돌려주는 호환용 경로도 구현했지만, 블로그에서는 작업 ID를 받는 방식을 사용합니다. 생성에 시간이 걸리거나 블로그 프로세스가 다시 시작돼도, 어떤 작업을 기다리고 있었는지 식별할 수 있어야 했습니다.

CLI 실행에는 Node.js의 spawn()을 사용했습니다. 명령과 옵션은 인자 배열로 넘기고 shell: false로 실행하며, 이미지 설명은 표준 입력으로 전달합니다. 프롬프트를 셸 명령 문자열에 이어 붙이지 않습니다.

또한 워커의 환경 변수를 필요한 항목으로 제한했습니다. 블로그의 앱 키나 별도 OpenAI API 키를 CLI 자식 프로세스에 그대로 물려주지 않고, 이미지 생성에 필요하지 않은 셸·앱·플러그인 도구도 비활성화했습니다. 이 워커가 할 일은 내장 도구로 이미지 한 장을 만드는 것입니다.

썸네일이 실패했다고 글을 다시 쓰면 안 됐다

이미지 한 장을 만드는 것보다 신경 쓴 부분은 실패 이후였습니다.

글과 본문 이미지가 준비됐는데 썸네일 생성이나 저장에서 오류가 나면, 처음부터 다시 실행하는 방식은 이미 끝난 작업까지 반복합니다. 새로 작성된 본문이 이전과 달라질 수도 있고, 이미지 생성도 중복될 수 있습니다.

그래서 블로그의 썸네일 단계에서 다음 정보를 보존하도록 했습니다.

  • 확정한 이미지 프롬프트와 레퍼런스
  • 이미지 서버에 보낸 요청 ID
  • 이미지 서버가 반환한 작업 ID
  • 생성 후 아직 저장소에 올리지 못한 이미지 바이트

실패 위치에 따라 이어갈 작업이 달라집니다. 이미지 서버에는 작업이 접수됐지만 응답을 받기 전에 연결이 끊겼다면, 같은 요청 ID로 이미 접수된 작업을 찾습니다. PNG까지 받은 뒤 저장소 업로드만 실패했다면, 보관한 바이트로 업로드를 다시 시도합니다.

이때 쓰는 것이 Idempotency-Key입니다. 같은 앱이 같은 키와 같은 요청 내용을 다시 보내면 기존 작업을 돌려주고, 같은 키로 다른 내용을 보내면 409를 반환하도록 했습니다.

그런데 이것만으로는 복구가 충분하지 않았습니다. 서버 재시작 사이에 이미지 크기나 프롬프트 설정이 바뀌면, 블로그가 재구성한 요청이 처음 보낸 내용과 달라질 수 있기 때문입니다. 이미 접수된 작업이 있는데도 재요청이 충돌할 수 있습니다.

그래서 요청 키로 작업을 찾는 조회 경로도 추가했습니다.

http
GET /images?request_id=<saved-request-id>
Authorization: Bearer <internal-app-key>

블로그는 저장된 작업 ID가 없더라도 이 경로부터 확인합니다. 기존 작업이 있으면 그 결과를 이어받고, 없을 때만 새로 요청합니다. 현재 설정으로 과거 요청을 다시 만들어 보내는 것과, 과거 요청 자체를 찾는 것을 구분한 셈입니다.

물론 이미지 워커 프로세스 자체가 종료된 경우는 다릅니다. 대기 중이던 작업은 복원하지만, 실행 도중 끊긴 작업은 interrupted로 종료 처리합니다. 종료된 CLI 실행을 그대로 이어서 생성한다고 보장하지 않습니다. 새 이미지 작업이 필요해도 블로그의 본문과 이전 단계 결과는 남습니다.

해상도와 레퍼런스는 직접 만든 API의 계약이었다

프롬프트 외에 무엇을 파라미터로 받을지도 정해야 했습니다. 블로그에서는 글마다 여러 장을 한꺼번에 요청할 필요가 없어, 한 요청이 한 이미지를 만들도록 제한했습니다.

파라미터이 래퍼에서의 의미
prompt이미지의 내용과 구도
size최종 반환 파일의 너비와 높이
effortCodex의 추론 노력 설정
reference_images필요할 때 첨부하는 참조 이미지
transparent_background투명 배경 요청

특히 size와 effort는 이름만 보고 의미를 오해하기 쉽습니다.

size: "1920x1080"은 반환 PNG의 크기를 뜻합니다. 먼저 해당 비율로 생성하도록 요청하지만, 내장 도구가 다른 크기를 반환할 수 있어 서버에서 최종 크기를 맞춥니다. 원본 비율이 다르면 중앙을 기준으로 잘라 리사이즈하므로 가장자리의 글자나 피사체가 잘릴 수 있습니다. 생성 프롬프트에도 안전 여백을 요구하고, 썸네일을 확인할 때 크기뿐 아니라 구도를 함께 봐야 했습니다. 이 설정이 모델의 네이티브 생성 해상도를 보장하는 것은 아닙니다.

effort는 Codex의 model_reasoning_effort에 연결했습니다. 이미지 API의 품질 등급과 같은 값으로 취급하지 않았습니다.

레퍼런스는 선택적으로 사용합니다. 실제 제품이나 화면의 형태가 중요하면 도움이 되지만, 추상적인 개발 주제에 관련 없는 사진을 억지로 붙일 필요는 없습니다. 블로그에서는 후보 이미지의 실제 내용을 보고 사용할지 결정하고, 선택한 파일만 워커에 전달합니다.

서버는 원격 이미지 URL이나 임의의 파일 경로를 받는 대신, 제한된 크기의 PNG·JPEG·WebP 바이트를 받아 검증합니다. 이를 작업 디렉터리에 저장하고 CLI의 --image 입력과 내장 도구의 참조 경로로 전달합니다. 현재 구현은 최대 5장으로 제한합니다. 이 숫자는 운영 중인 Codex 내장 도구에 맞춘 제한이며, 공식 Image API의 입력 한도와 동일하다고 가정하지 않았습니다.

완성된 PNG는 블로그에서 WebP로 변환해 오브젝트 스토리지에 저장합니다. 현재 변환 품질은 85이고, 긴 변이 3840픽셀을 넘으면 축소합니다. 독자의 브라우저는 이 저장된 이미지를 읽으므로, 페이지를 열 때마다 Codex를 호출하지 않습니다.

Kubernetes에서는 로그인과 작업 상태를 남겼다

컨테이너에 CLI를 설치하는 것만으로는 구독 로그인을 유지할 수 없습니다. 파드가 교체될 때 인증 정보가 사라지면 이미지 생성도 다시 로그인할 때까지 멈춥니다.

서버에는 계정별 CODEX_HOME을 두고 영속 볼륨에 보관했습니다. 브라우저가 없는 서버에서 로그인할 때는 기기 코드 인증을 사용했습니다.

sh
codex login --device-auth

서버가 보여준 주소를 사용자 브라우저에서 열고 코드를 입력하면, 로그인 결과가 해당 CLI 환경에 저장됩니다. 로그인 파일은 자격 증명이므로 컨테이너 이미지나 Git 저장소에 넣지 않습니다. 이 방식과 파일 취급 기준은 공식 인증 문서에서도 설명합니다. 헤드리스 환경의 로그인

앱에 주는 키와 이 로그인 파일은 수명도 다릅니다. 블로그의 호출 키를 교체한다고 Codex 계정에 다시 로그인할 필요는 없습니다. 이미지 서버에는 앱 키의 해시를 저장하고, 요청 소유자를 기준으로 작업과 파일 접근을 구분합니다.

현재 이미지 서버는 파일 기반 큐를 사용하는 단일 인스턴스입니다. Kubernetes 배포도 단일 replica와 Recreate 전략을 사용하고, 파일 잠금으로 같은 데이터 디렉터리를 두 프로세스가 동시에 운영하지 않도록 했습니다. 이 구성에는 배포 중 잠깐의 중단이 있습니다. HTTP 서버 replica만 늘려도 안전하게 확장되는 구조라고 보지는 않았습니다.

동시 실행 수와 대기열에도 상한을 두었습니다. 사용 한도에 걸리면 대기 상태로 다루고, 생성 타임아웃과 재시도 횟수를 제한합니다. 안전 시스템이 구도를 차단한 경우는 일반적인 일시 오류와 구분해 반환합니다.

블로그에 붙이고 나서 달라진 것

지금 블로그는 글의 제목과 본문으로 썸네일 지침을 만들고, 필요하면 레퍼런스를 선택한 뒤 이 내부 API를 호출합니다. 생성된 이미지를 WebP로 저장하면 글에 연결하고, 오류가 생기면 썸네일 단계의 상태를 남깁니다. 본문을 다시 쓰지 않아도 이미지 작업을 이어갈 수 있게 됐습니다.

구현을 점검하면서 래퍼의 자동 테스트 14개도 실행했습니다. 중복 요청 처리, 재시작 후 대기열 복원, 실행 중단 상태, 요청 소유자별 파일 접근, 레퍼런스 입력 제한, 실제 PNG 수집과 크기 변환을 확인합니다. 이 테스트들은 생성기를 대역으로 바꿔 서버 동작을 검사하는 것이며, 모든 테스트가 OpenAI에 이미지를 생성시키는 것은 아닙니다.

구독 한도는 여전히 운영 조건입니다. 공식 문서는 내장 이미지 생성이 이미지 없는 비슷한 작업보다 포함 한도를 평균 3~5배 빠르게 소비할 수 있다고 설명합니다. 따라서 처리량을 늘리려면 생성 시간과 남은 한도를 함께 봐야 합니다. 공식적으로 프로그램에서 이미지를 생성하는 경로는 Image API이며, 여기서 설명한 것은 제 블로그를 위해 구성한 CLI 래퍼입니다. 이미지 생성과 사용 한도

구현한 뒤에 API 크레딧 혜택도 알게 됐다

이 글을 정리하면서, 조건을 충족한 Pro 구독은 본인 소유의 API 조직을 연결해 API 크레딧 혜택을 받을 수 있다는 사실도 알게 됐습니다. 해당 크레딧은 공식 Images API에도 사용할 수 있습니다. 혜택을 사용하려면 API 조직 연결과 활성화 등 해당 조건을 충족해야 합니다. 구독자의 API 크레딧 안내

새로 알게 된 이 선택지와 이번 구현은 사용하는 한도가 다릅니다. API 크레딧으로 공식 Image API를 호출하면 그 크레딧에서 차감됩니다. 제가 만든 래퍼는 로그인된 Codex의 내장 도구를 실행하므로, 별도 Image API 크레딧을 소모하지 않습니다.

이번 구성으로 API 크레딧 잔액과 별도로, 이미 보유한 Codex 사용 한도를 블로그의 이미지 생성에 활용할 수 있게 됐습니다. 공식 API와 구독 로그인 방식이 서로 다른 사용량을 소비한다는 점을 알고 나니, 작업의 성격에 따라 선택할 수 있는 경로가 하나 더 생겼습니다.

번역처럼 기다릴 수 있는 작업에도 써볼 수 있다

INERIT blog의 썸네일은 독자가 페이지를 여는 순간 생성할 필요가 없습니다. 게시 준비 단계에서 작업을 맡기고 결과를 저장하면 됩니다. 즉시 응답이 필요한 채팅 서비스보다, 생성 순서를 기다릴 여지가 있습니다.

다국어 번역도 같은 관점에서 확장해볼 만합니다. 원문과 대상 언어를 작업 큐에 넣고, CLI가 반환한 제목과 본문을 검증한 뒤 번역본으로 반영하는 방식입니다. 코드 블록, 링크, 인용과 수치가 보존됐는지 확인하고, 실패한 언어만 다시 처리한다면 원문 글을 다시 만들 필요도 없습니다. 번역에 이 경로를 적용하는 것은 아직 구현하지 않은 다음 검증 과제입니다.

다만 CLI로 옮긴다고 번역에 필요한 토큰 자체가 줄어드는 것은 아닙니다. 유료 API로 처리하던 작업이라면, 일부를 구독 사용량으로 옮겨 별도 API에서 과금되는 사용량과 비용을 줄일 여지가 있습니다. 그 대신 Codex 한도, 대기 시간, 결과 검증과 재시도 운영을 부담합니다. 현재 블로그 번역의 토큰 수나 비용이 절감됐다고 측정한 것은 아닙니다.

이번에 실제로 연결한 것은 썸네일입니다. 블로그는 내부 API로 이미지 작업을 요청하고, Codex는 구독으로 로그인한 환경에서 생성하며, 결과 파일은 블로그가 저장합니다. 필요한 것은 빠른 한 번의 응답보다, 기다렸다가 쓸 수 있는 이미지와 실패 후에도 남는 작업 상태였습니다. 그 조건 덕분에 Codex의 구독 기능을 블로그에서 API처럼 활용할 수 있었습니다.