광고 생성 AI
제품 정보에서 광고 상세페이지까지, 생성과 합성을 나눠 만들다
제품 정보를 입력하면 광고 상세페이지를 만들어 주는 부트캠프 팀 프로젝트. GPT-Image 기반 생성 파이프라인과 서비스 통합을 담당했습니다.
- Problem
- 제품 사진 한 장에서 광고 상세페이지까지 — 생성 품질과 API 비용을 동시에 통제하는 것
- Decision
- 관측성을 선택적 의존으로 설계 — 관측 키가 없어도 no-op으로 떨어져 생성 파이프라인 자체는 멈추지 않는다
- Evidence
- 키 없이 테스트 114개 통과·4개 skip — 2026-09-05 · Python 3.14.5 / Streamlit 1.63.0 / FastAPI 0.141.1 (model-v2 브랜치, PR #6으로 팀 main 병합)
- Role
- 생성 모델 파이프라인 전담 — GPT-Image 파이프라인 구현 · 이후 model-v2 작성과 병렬 통합 (브랜치 2개)
- Scope
- 생성·합성 모듈과 GEO 레이어 신규 작성, 팀 FastAPI·Streamlit 연결부 수정, 관측성 추가 / model-v2 모델 작성·별도 워커·폐쇄 스위치·테스트
Contents · 목차
무엇을 만드는 서비스인가
제품 사진과 정보를 입력하면 광고 상세페이지(카피 · 생성 이미지 · 레이아웃)와 썸네일을 만들어 주는 서비스입니다. 부트캠프 고급 팀 프로젝트로 진행했고, 팀 공동 저장소입니다. 팀에서 생성 모델 파트를 맡았고, 아래는 제 커밋으로 확인되는 범위만 적었습니다. 제 작업은 브랜치 두 개로 나뉘며, 둘 다 PR로 팀 main에 병합됐습니다.
| 브랜치 | 내용 | 병합 |
|---|---|---|
minseong/gpt-image-pipeline | GPT-Image 파이프라인 · GEO 레이어 · 창의성 슬라이더 · 관측성 | PR #1 (2026-07-09) · #3 (2026-07-14) |
feature/model-v2-integration | 직접 발전시킨 최신 모델(model_v2/)을 별도 워커로 병렬 통합 · 유료 생성 폐쇄 스위치 · 테스트 | PR #6 (2026-07-23) |
세 PR 모두 팀 저장소 관리자가 병합했고, GitHub에 남은 정식 리뷰 기록은 없습니다. 아래 "파이프라인 구조"부터 "서비스 구성"까지는 첫 번째 브랜치 이야기입니다.
파이프라인 구조
run_pipeline()이 요청 하나를 5단계로 처리하고, 마지막에 GEO 텍스트 레이어를 덧붙입니다.
- 이미지 프롬프트 생성 — 카테고리에서 아키타입을 고르고, 필요한 이미지 슬롯(역할)을 정한 뒤 슬롯별 프롬프트를 만듭니다
- 카피·스펙 생성 — 페이지 카피와 스펙 표를 LLM으로 생성. 텍스트 LLM은 OpenAI 호환 엔드포인트로 호출해서, 같은 코드로 OpenAI와 로컬 Ollama를 오갈 수 있습니다 (LangChain
ChatOpenAI) - 이미지 생성 — 슬롯마다 GPT-Image 호출
- 상세페이지 조립 — 생성물과 카피를 레이아웃에 배치
- 썸네일 생성 — 대표 썸네일과 갤러리 썸네일
생성과 합성을 분리했습니다. 모델이 만드는 것(model/baseline/ — 프롬프트·카피·이미지·배경
제거·업스케일)과 그것을 페이지로 배치하는 것(model/composer/ — 블록·레이아웃·테마·썸네일)이
다른 패키지에 있어, 한쪽을 바꿔도 다른 쪽을 다시 만들지 않습니다.
이미지 생성 — 제품 보존과 재해석 사이
image_generator.generate_image()는 제품 사진이 있으면 images.edit, 없으면
images.generate로 갈라집니다. 여기에 creativity 1~5 값을 둬서, 낮을수록 원본 제품을
보존하는 문구를 프롬프트 앞에 붙이고 높을수록 자유롭게 재해석하도록 했습니다.
배경 제거는 rembg, 업스케일은 Pillow LANCZOS 리사이즈를 씁니다.
GEO 레이어
model/geo/geo_layer.py가 이미지와 별개로 "AI가 읽는" 구조화 산출물을 만듭니다.
geo_main()이 반환하는 것은 네 가지입니다.
geo_html— JSON-LD를 임베드한 시맨틱 HTMLstructured_data— Product / FAQPage JSON-LDfaq— 제품 정보 범위 안에서 생성한 Q&Awarnings— 사실 가드레일 경고
가드레일은 생성된 텍스트의 숫자가 입력 근거에 없으면 경고를 남기는 방식입니다.
# model/geo/geo_layer.py — 생성 텍스트의 수치가 입력에 없으면 경고
def guardrail_warnings(req: dict, texts: list[str]) -> list[str]:
"""생성 텍스트의 수치가 입력 근거에 없으면 경고(허위 수치 방지)."""
src = " ".join(str(req.get(k, "")) for k in ("product_details", "emphasis", "price"))
src_nums = set(re.findall(r"\d[\d,.]*", src))
warns = {
f"근거 없는 수치 의심: '{m}'"
for t in texts for m in re.findall(r"\d[\d,.]*", t or "")
if m not in src_nums and len(m) >= 2
}
return sorted(warns)
JSON-LD도 같은 원칙입니다 — gtin·mpn·sku·price처럼 입력에 있는 필드만 넣고,
없는 값은 만들지 않습니다. FAQ 생성 프롬프트에도 "없는 수치·수상·후기·전문가 인용을 지어내지
마라, 모르면 '판매자에게 문의'로 답한다"는 규칙을 넣었습니다.
관측성을 선택적 의존으로
생성 파이프라인은 관측 도구가 없어도 돌아가야 한다고 봤습니다.
model/baseline/observability.py의 데코레이터는 키와 패키지가 있을 때만 LangFuse를 붙이고,
없으면 원본 함수를 그대로 돌려줍니다.
# model/baseline/observability.py — 활성 시에만 추적, 아니면 원본 함수 그대로
def observe(func=None, *, name=None):
def wrap(fn):
if not enabled():
return fn
try:
from langfuse import observe as _obs
return _obs(name=name)(fn) if name else _obs()(fn)
except Exception:
return fn
return wrap(func) if callable(func) else wrap
파이프라인 끝에서 flush()를 호출하는데, 비활성 상태면 이것도 아무 일도 하지 않습니다.
관측을 위해 생성이 멈추는 상황을 만들지 않으려는 설계입니다.
서비스 구성
API와 화면의 뼈대는 팀원이 먼저 만들었고, 제 커밋은 그 위에 파이프라인을 연결하고 입력·출력을 바꾼 부분입니다. 파일을 통째로 만든 것이 아니라 이어받아 고쳤습니다.
- FastAPI —
POST /api/generate-detail-page가 제품·응용 이미지를 업로드받아 파이프라인을 실행하고, 결과 이미지를 base64로 돌려줍니다.GET /api/options로 카테고리 같은 선택지를 제공합니다 - Streamlit — 카테고리 선택, 이미지 업로드, 창의성 슬라이더, 결과 미리보기와 다운로드(상세 이미지 PNG · GEO 페이지 HTML)
생성·합성·GEO 모듈(model/baseline/ · model/composer/ · model/geo/)은 이 브랜치에서
새로 만든 코드입니다.
model-v2 — 최신 모델을 팀 코드 옆에 붙이기
첫 브랜치가 병합된 뒤에도 모델을 계속 발전시켰고, 그 최신본을 feature/model-v2-integration에서
팀 서비스에 붙였습니다. 인계 문서(MODEL_V2_HANDOFF.md)와 함께 PR #6으로 올렸고,
2026-07-23 팀 main에 병합됐습니다.
model_v2/는 첫 브랜치의 model/을 직접 발전시킨 것입니다. 이미지 생성 전에 컷별 처리 경로를
정하는 이미지 계획(image_plan.py · image_planner.py), 씬 템플릿, 제품 컷을 Pillow로 합성하는
compositor.py가 여기서 추가됐고, 이미지 모델 기본값은 gpt-image-2입니다.
조건으로 삼은 것은 팀 코드를 한 줄도 바꾸지 않는 것이었습니다.
팀 기준 커밋 660bc84 대비 git diff --name-status를 돌리면 새로 만든 파일만 A로 나옵니다 —
추가 53개 파일, 팀 파일 수정 0건입니다. 검증 기준을 움직이는 main이 아니라 분기 지점으로
잡아, 팀이 main에 새 커밋을 올려도 같은 사실이 유지되게 했습니다.
바꾸지 않으려면 같은 프로세스에 올릴 수 없었습니다. 팀 model/과 새로 넣은 model_v2/는
최상위 패키지 이름(baseline · composer · geo · evaluation)이 겹치고, 파이썬은 한
인터프리터에서 같은 최상위 이름을 한 트리만 바인딩합니다. sys.path 순서로 바꿔치기하면
먼저 import된 쪽이 이겨서, 어느 쪽이 살아 있는지가 import 순서에 좌우됩니다.
그래서 팀 워커(8000)는 그대로 두고 v2를 별도 프로세스(8010) 로 띄웠습니다.
이 격리는 tests/test_model_v2_isolation.py가 워커를 각각 독립 subprocess로 올려
baseline.__file__이 어느 트리에서 resolve되는지 확인하는 방식으로 고정했습니다.
생성이 유료 API를 부르기 때문에, 돈이 드는 경로와 들지 않는 경로를 갈라 놨습니다.
POST /api/model-v2/preview— 무과금. 어떤 컷을 몇 장 만들지, 각 컷을 합성·재편집·생성·원본 통과 중 어느 경로로 처리할지는 순수 함수라 호출이 필요 없습니다. 응답의expected_calls가 실제 생성 시 발생할 호출 수를 미리 알려줍니다.POST /api/model-v2/generate-detail-page— 유료. 환경변수MODEL_V2_PAID_ENABLED가 정확히1일 때만 열립니다.true·yes·on·빈 값은 전부 비활성이고, 비활성일 때는 ASGI 미들웨어가 본문을 읽기 전에 503으로 끊어 multipart 파싱도 파이프라인 진입도 일어나지 않습니다.
테스트 5개 모듈은 실제 LLM·이미지 API·외부 HTTP를 한 번도 부르지 않습니다(전부 mock).
2026-09-05에 API 키를 설정하지 않은 상태로 돌려 확인했습니다 —
Python 3.14.5 · Streamlit 1.63.0 · FastAPI 0.141.1에서 114개 전부 통과, 4개 건너뜀.
건너뛴 4개는 upstream/main 원격이 없는 클론이라 비교 대상이 없는 2개와, 저장소 밖 샘플
패키지를 요구하는 2개입니다.
결과 이미지를 싣지 못한 이유
이 페이지에는 생성 결과 화면이 없습니다. 만들어 붙일 수 있었지만, 그러면 실제 산출물이 아닙니다. 지금 확인되는 사실은 이렇습니다.
- 팀 저장소 두 곳(포크·원본)의 모든 브랜치·모든 커밋을 통틀어 이미지 파일이 0개입니다. 생성물은 응답에 base64로 담겨 나가고 저장소에 커밋되지 않았습니다.
- 발표용 사전 생성 샘플 패키지가 있었지만 저장소 밖 자산이고, 원격 어느 브랜치에도 올라가 있지 않습니다. 로컬 사본도 남아 있지 않습니다.
채우려면 아래 네 장이면 충분합니다. 장식이 아니라 각각 하나의 결정을 증명하는 컷입니다.
| 필요한 이미지 | 어떤 결정의 증거인가 | 어느 단계 |
|---|---|---|
| 제품 보존본 / 재해석본 상세페이지 나란히 | 창의성 값에 따라 제품 픽셀을 보존할지 다시 그릴지 갈라지는 구조 | 이미지 생성 |
| 배경 제거 전후 한 컷 | rembg(u2net) 경계가 어디서 무너지는지, 왜 SAM2가 후속 과제인지 | 배경 제거 |
| 조립된 상세페이지 전체 | 생성과 합성을 다른 패키지로 나눈 결과 — 같은 생성물로 레이아웃만 바뀌는 것 | 합성 |
GEO 산출물 화면 (geo_html + warnings) | 입력에 없는 수치에 경고가 실제로 붙는지 | GEO · 출력 |
재현 절차는 유료입니다 — model/.env에 OPENAI_API_KEY를 넣고 파이프라인을 실행해야
컷이 나옵니다. 제품 사진은 직접 찍은 것이어야 합니다. 이 절차를 밟아 나온 컷만 이 자리에
넣겠습니다.
한계
- 정량 성능 지표가 없습니다. 생성 품질을 수치로 평가한 기록이 저장소에 없어 이 페이지에도 넣지 않았습니다
- 업스케일은 LANCZOS 리사이즈이고 초해상 모델은 쓰지 않았습니다
- 배경 제거는
rembg(u2net) 기준이며, 코드 주석에 SAM2 등으로 경계 정밀도를 높이는 방향이 후속 과제로 적혀 있습니다 - LangFuse는 연동 코드까지가 확인되는 범위입니다. 키를 넣고 대시보드에서 실제 호출 기록을 확인한 기록은 남아 있지 않습니다
- model-v2는 팀 main에 병합됐지만 배포 구성(Docker, 8000/8501)에는 v2 워커(8010)가 들어가 있지 않습니다
- 유료 폐쇄 스위치는 비용 사고 방지 장치이지 접근 제어가 아닙니다. 워커 자체에 인증이 없어 기본 바인딩이
127.0.0.1입니다 - 팀 공동 저장소이며, 이 페이지에서 개인 기여로 서술한 범위는 제 커밋의 diff로 확인되는 것뿐입니다. 팀원이 만든 파일을 고친 부분과 새로 만든 모듈을 구분해 적었습니다
- 저장소 링크는 지금 걸어 두지 않았습니다