글에 넣을 이미지를 AI로 만들고 100KB 아래로 줄이기 — 생성·압축·동시 생성 기록

2026년 09월 22일 · AI 자동화, 개발 일지

AI 이미지 생성, 동시 생성, WebP 압축을 거쳐 업로드로 가는 흐름과 외부 이미지 한 장이 거절돼 글 전체가 멈춘 사고

앞 글에서는 네이버 블로그 에디터에 게시판·캡션·링크카드를 넣는 이야기를 했다. 본문에 들어갈 요소를 하나씩 넣을 수 있게 되자 다음으로 걸린 게 이미지 자체였다. 넣을 자리는 생겼는데 넣을 그림이 없었다.

글을 자동으로 만들면 이미지도 자동으로 필요하다. 매번 사람이 찾아서 붙이면 자동화라고 부르기 민망하다. 그래서 AI 이미지 생성 API를 발행 도구에 붙이기로 했다.

키는 도구 안에 두고, AI에는 결과만

처음 정한 건 API 키를 어디에 두느냐였다.

글을 쓰는 AI가 직접 이미지 API를 부르게 하면 간단하다. 그런데 그러려면 키가 채팅 환경에 들어가야 한다. 그게 싫었다.

그래서 키는 발행 도구의 설정 저장소에 암호화해서 넣었다. AI는 프롬프트만 넘기고, 도구가 OpenAI를 불러 이미지를 파일로 저장한 뒤 경로만 돌려준다. 설정 화면에서도 키는 가려서 보여주고 설정돼 있는지 여부만 알려준다.

모델마다 파라미터가 달라서 분기가 필요했다. gpt-image-1은 품질을 low·medium·high·auto로 받고, dall-e-3는 standard·hd로 받으면서 응답 형식까지 따로 지정해야 한다. 같은 “품질”이라는 말인데 받는 값이 다르다. 인자는 하나로 받고 모델에 따라 요청을 다르게 조립하게 했다.

기본값을 낮게 잡은 이유

품질 기본값을 정하다가 가격을 봤다. gpt-image-1 high는 장당 0.167~0.25달러였다. 한 장이면 괜찮은데 대량 발행으로 천 장을 뽑으면 250달러 가까이 된다. low는 장당 0.01~0.02달러 수준이다.

그래서 기본을 low, 1024×1024로 잡고 중요한 글만 high로 올리기로 했다. 도구 인자를 비워 두면 설정 화면의 기본값을 따르고, AI가 필요할 때만 그 호출에서 덮어쓴다. 기본값을 코드에 박아두면 비용을 조절할 때마다 코드를 열어야 한다. 설정 화면이 실제 기본값이 되게 한 것이다.

올리기 전에 줄인다

이미지를 만들고 나니 다음 문제는 용량이었다. 생성된 이미지를 그대로 올리면 무겁다.

업로드 직전에 압축하는 단계를 넣었다. 먼저 비율을 유지하면서 해상도를 줄이고 WebP로 다시 인코딩한다. 목표 용량은 기본 100KB 미만이다. 한 번에 맞추려 하지 않고 품질을 단계적으로 낮춰가며 목표 아래로 들어오는지 본다. 품질을 끝까지 내려도 안 되면 해상도를 한 번 더 줄여 다시 시도한다.

최악의 경우로 순수 노이즈 사진 3000×2000을 넣어봤다. 압축이 제일 안 먹히는 종류다. 결과는 819×545, 88KB였고 비율은 그대로였다.

WebP로 정한 건 같은 화질에서 용량이 작고 투명도도 유지되기 때문이다. 워드프레스가 WebP 업로드를 받아주는지는 실제 사이트에 초안을 올려 확인했다. 이미지로 열리지 않는 파일이거나 최적화를 꺼두면 원본을 그대로 올린다.

처음엔 프리셋을 여러 단계로 만들었다. 그런데 써보니 선택은 “줄인다”와 “안 줄인다” 둘뿐이었다. 중간 단계는 화면에서 뺐다. 옵션을 많이 만든다고 쓸모가 늘지는 않았다.

네이버 쪽은 아직 확인하지 못한 게 남아 있다. WebP 파일을 올렸을 때 에디터가 그대로 쓰는지, 안에서 다른 형식으로 다시 바꾸는지다. 이건 실발행으로 눈으로 봐야 한다.

본문 이미지도 같이 처리했다. 발행할 때 본문의 이미지를 워드프레스 미디어 라이브러리에 올리고 주소를 바꿔 끼운다. 이미 그 사이트에 올라가 있는 이미지와 본문에 직접 박힌 데이터 이미지는 건너뛴다. 이 단계에서 한 장이 업로드에 실패하면 원래 주소를 그대로 두고 글은 발행하게 했다.

하나씩 부르면 기다린다

한 글에 대표 이미지와 본문 이미지 여러 장이 필요하다. 처음엔 생성 도구를 장수만큼 차례로 불렀다. 한 장이 끝나야 다음 장이 시작되니 기다리는 시간이 장수만큼 늘었다.

그래서 여러 프롬프트를 한 번에 받아 안에서 동시에 생성하는 도구를 따로 만들었다. 조건을 세 가지 걸었다.

첫째, 동시 호출 수를 제한했다. 기본 4개다. 한꺼번에 다 보내면 API의 요청 제한에 걸린다.

둘째, 결과는 요청과 같은 순서, 같은 개수로 돌려준다. 세 번째 프롬프트의 결과는 항상 세 번째 자리에 있다. 호출하는 쪽이 번호로 짝을 맞출 수 있어야 한다.

셋째, 한 장이 실패해도 나머지는 계속 만든다. 실패한 자리에는 오류 내용을 채운다. 키가 설정되지 않은 것처럼 전체가 안 되는 경우에도 예외를 던지지 않고 실패 항목을 요청 개수만큼 돌려준다. 결과 모양이 늘 같아야 받는 쪽이 헷갈리지 않는다.

sem = asyncio.Semaphore(4)
async def one(i, prompt):
    async with sem:
        try:
            return {"success": True, "path": await make(prompt, idx=i)}
        except Exception as e:
            return {"success": False, "error": str(e)}
results = await asyncio.gather(*(one(i, p) for i, p in enumerate(prompts)))

만들고 나서 하나 걸린 게 있었다. 파일 이름을 저장 시각으로 짓고 있었는데, 동시에 저장하면 같은 밀리초에 두 장이 떨어져 이름이 겹칠 수 있다. 뒤에 번호를 붙여 구분했다. 차례로 부를 때는 생길 수 없던 문제다.

남의 이미지 한 장이 글을 멈췄다

사고는 무인 배치 발행을 돌리던 중에 났다.

글 한 편이 통째로 발행에 실패했다. 원인은 본문에 넣은 외부 사이트의 이미지 한 장이었다. 그 서버가 요청을 403으로 거절했다. 본문 이미지는 발행 과정 중간에 받아 오는데, 한 장에서 예외가 올라가자 그 글의 발행 자체가 멈췄다. 앞에서 업로드 실패는 원래 주소를 두고 넘어가게 해놨지만, 받아 오는 단계는 그런 장치가 없었다.

들여다보니 원격 이미지를 받는 코드가 워드프레스 쪽과 네이버 쪽에 따로 있었다. 네이버 쪽은 아무 처리 없이 요청만 보내고 있었다. 한쪽을 고치면 다른 쪽은 그대로 남는 구조였다.

그래서 원격 이미지 받기를 한 곳으로 모았다. 이제 워드프레스와 네이버가 같은 규칙으로 받는다. 그리고 받기에 실패했을 때 그게 조용한 성공으로 둔갑하지 않게 했다. 어떤 이미지가 왜 막혔는지가 호출한 쪽까지 올라와야 한다.

고민은 남아 있다. 이미지를 못 받으면 그 이미지만 빼고 글을 내보낼지, 글 전체를 멈출지다. 사진 빠진 글이 아무도 모르게 올라가는 것도 나쁘다고 봐서 지금은 전체 실패로 두고, 그 이미지만 건너뛰고 경고를 남기는 쪽으로 낮출지 검토하고 있다.

돌아보면 뿌리는 남의 서버에 걸린 이미지를 쓴 데 있었다. 받을 수 있을지는 내가 아니라 그 서버가 정한다. 직접 만든 이미지는 거절당할 일이 없다. 앞에서 생성 도구까지 만들어 놓고 굳이 남의 이미지를 끌어올 이유가 없었다.

정리하면

비용과 용량은 기본값이 정한다. 고품질과 원본을 기본으로 두면 대량으로 돌리는 순간 청구서와 용량이 같이 불어난다. 기본은 낮게 두고 필요한 곳만 올리는 편이 맞았다.

여러 개를 한꺼번에 처리할 때는 속도보다 결과 모양이 먼저였다. 순서와 개수가 보장되고 한 장의 실패가 그 칸에 갇혀야 동시 처리를 믿고 쓸 수 있다. 동시에 돌리면 차례로 돌릴 때 없던 충돌도 생긴다.

그리고 이미지 하나가 글 전체를 멈추게 할지는 아직 답을 못 냈다. 다만 멈추든 넘어가든, 무엇이 왜 막혔는지는 반드시 남아야 한다. 조용히 넘어가는 실패가 제일 나쁘다. 무엇보다 남의 이미지는 가능하면 쓰지 않는 게 속 편하다. 내가 만든 이미지는 거절당하지 않는다.