들어가며
이커머스 셀러라면 공감할 것이다. 상세페이지 하나 만드는 데 이미지 촬영, 편집, 카피라이팅, HTML 코딩까지 반나절이 넘게 걸린다.
이 글에서는 **FLUX.1-dev(이미지 생성 AI)**와 **GPT(텍스트/HTML 생성)**를 조합해서, 제품 정보만 넣으면 상세페이지가 자동으로 나오는 파이프라인을 만들면서 겪은 기술적 삽질과 해결 과정을 정리한다. GCP L4 GPU(22GB VRAM) 환경에서 실제 동작하는 코드 기준이다.
1. 전체 아키텍처: Image-First 워크플로우
기존 방식은 GPT가 상세페이지 HTML을 쓰면서 동시에 이미지 프롬프트를 뽑고, FLUX가 생성하는 "한 방에 끝내기" 구조였다. 문제는 이미지 퀄리티를 사용자가 통제할 수 없다는 것.
그래서 2단계 분리 구조로 바꿨다:
[Phase 1] 이미지 갤러리 생성
제품 정보 입력 → GPT가 8개 이미지 프롬프트 생성 → FLUX가 8장 이미지 생성 → 사용자가 마음에 드는 이미지 선택
[Phase 2] 상세페이지 조립
GPT가 섹션 기획 → 각 섹션 HTML 작성(__IMAGE__ 플레이스홀더) → 선택된 이미지를 base64로 치환 → Playwright로 PNG 렌더링
핵심은 이미지를 먼저 확정하고, 그 이미지에 맞춰 페이지를 조립한다는 것이다. 이미지가 마음에 안 들면 Phase 1만 다시 돌리면 된다.
2. 싱글턴 패턴 — FLUX 모델 중복 로딩 방지
FLUX.1-dev 모델은 VRAM을 약 16GB 점유한다. L4 GPU 전체가 22GB인데, 만약 코드 어딘가에서 ImageGenerator()를 두 번 호출하면? OOM(Out of Memory)으로 즉사한다.
싱글턴 패턴으로 해결했다. 핵심은 __new__ 메서드를 오버라이드하는 것이다:
class ImageGenerator:
_instance = None
_init_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._init_lock:
if cls._instance is None: # Double-checked locking
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if hasattr(self, '_initialized') and self._initialized:
return
self._initialized = True
# ... 모델 로딩 코드 (최초 1회만 실행)
왜 Double-checked locking인가?
멀티스레드 환경에서 두 스레드가 동시에 __new__에 진입하면, 락 없이는 인스턴스가 2개 만들어질 수 있다. 하지만 매번 락을 거는 건 성능 낭비다. 그래서:
- 먼저 락 없이 _instance is None 체크 (대부분 여기서 걸러짐)
- None일 때만 락을 걸고, 락 안에서 한 번 더 체크
__init__에서도 _initialized 플래그로 중복 초기화를 막는다. Python은 __new__가 기존 인스턴스를 반환해도 __init__은 매번 호출하기 때문이다.
3. VRAM 최적화 — 8-bit 양자화 + VAE 최적화
L4 GPU 22GB에서 FLUX.1-dev(12B 파라미터)를 돌리려면 양자화가 필수다.
3-1. BitsAndBytes 8-bit 양자화
# Transformer (12B 파라미터) → 8-bit으로 압축
transformer_quant_config = BitsAndBytesConfig(load_in_8bit=True)
transformer = FluxTransformer2DModel.from_pretrained(
"black-forest-labs/FLUX.1-dev",
subfolder="transformer",
quantization_config=transformer_quant_config,
torch_dtype=torch.bfloat16,
)
# T5 텍스트 인코더 (4.5B 파라미터) → 8-bit으로 압축
t5_quant_config = TransformersBitsAndBytesConfig(load_in_8bit=True)
text_encoder_2 = T5EncoderModel.from_pretrained(
"black-forest-labs/FLUX.1-dev",
subfolder="text_encoder_2",
quantization_config=t5_quant_config,
torch_dtype=torch.bfloat16,
)
주의: enable_model_cpu_offload()을 쓰면 안 된다. BitsAndBytes 양자화된 모델은 GPU에 고정되는데, CPU offload가 이걸 CPU로 옮기려다가 OOM이 터진다. 이것 때문에 반나절 삽질했다.
3-2. VAE Slicing + Tiling
이미지 생성은 되는데 VAE 디코딩에서 OOM이 날 수 있다. 해결:
self.pipe.enable_vae_slicing() # 배치를 슬라이스로 나눠 디코딩
self.pipe.enable_vae_tiling() # 큰 이미지를 타일로 나눠 디코딩
이 두 줄로 768x768 이미지 생성 시 VRAM 피크를 약 2~3GB 절약할 수 있다.
4. GPU 동시 접근 제어 — Semaphore
웹 서버에서 여러 요청이 동시에 이미지 생성을 호출하면? GPU가 한 개뿐이라 충돌한다.
class ImageGenerator:
_gpu_lock = threading.Semaphore(1)
def generate(self, prompt, ...):
with ImageGenerator._gpu_lock:
# GPU 사용 전 캐시 정리
torch.cuda.empty_cache()
image = self.pipe(...).images[0]
torch.cuda.empty_cache()
return self._save_image(image, output_dir)
Semaphore(1)은 사실상 뮤텍스와 같다. 동시에 1개 스레드만 GPU를 사용하고, 나머지는 대기한다. generate() 전후로 torch.cuda.empty_cache()를 호출해서 VRAM 파편화도 방지한다.
5. IP-Adapter — 참조 이미지 기반 생성
사용자가 기존 제품 사진을 넣으면, 그 스타일을 참조해서 새 이미지를 생성하는 기능이다. XLabs-AI의 FLUX IP-Adapter를 사용한다.
def _load_ip_adapter(self):
"""최초 1회 lazy 로딩"""
self.pipe.load_ip_adapter(
"XLabs-AI/flux-ip-adapter",
weight_name="ip_adapter.safetensors",
image_encoder_pretrained_model_name_or_path="openai/clip-vit-large-patch14",
)
핵심 포인트:
- Lazy loading: IP-Adapter는 VRAM ~1GB 추가 소모하므로, 필요할 때만 로드
- 사용 후 언로드: unload_ip_adapter()로 VRAM 회수
- IP-Adapter가 로드된 상태에서 일반 생성: scale=0.0 + 더미 이미지를 넘겨서 비활성화
6. GPT 프롬프트 엔지니어링 — 이커머스 상세페이지용
GPT에게 HTML을 생성시키는 프롬프트는 상당히 구체적이어야 한다. 대충 "상세페이지 만들어줘" 하면 90년대 웹페이지가 나온다.
6-1. 글자 크기 규칙
실제 쿠팡, 마켓컬리 상세페이지를 보면 글씨가 매우 크다. 모바일 트래픽이 80% 이상이기 때문이다. 프롬프트에 구체적인 px 값을 명시해야 한다:
★★★ 글자 크기 규칙 ★★★
- 메인 타이틀/제품명: 72~96px, font-weight: 900
- 섹션 제목/캐치프레이즈: 52~64px, font-weight: 700~800
- 서브 제목: 40~48px, font-weight: 600~700
- 본문: 32~40px, font-weight: 400~500
- 보조 텍스트: 26~32px
★ 24px 이하 절대 금지 ★
처음에 "글자를 크게 써줘"로만 했더니 GPT가 알아서 16px을 썼다. 구체적인 숫자를 주지 않으면 GPT는 웹 기본값(14~16px)으로 회귀한다.
6-2. 레이아웃 강제 규칙
글자를 키웠더니 이번엔 UI가 무너졌다. 원인은 글자는 2배 키웠는데 패딩/마진은 그대로였기 때문:
★★★ 레이아웃/여백 규칙 ★★★
- 섹션 전체 패딩: padding: 80px 60px
- 제목↔본문 간격: margin 40px 이상
- 카드 내부 패딩: 40px~60px
- line-height: 글자 크기 × 1.4~1.6배
6-3. 1단 레이아웃 강제
또 하나의 삽질. GPT가 이미지와 텍스트를 좌우로 나란히 배치하는 2컬럼 레이아웃을 만들었다. 860px 너비에서 반으로 쪼개면 각 430px — 72px 글씨가 들어갈 공간이 없다.
★★★ 레이아웃 구조 (반드시 지킬 것) ★★★
- 모든 콘텐츠는 1단(single column) 세로 배치만 허용
- 이미지와 텍스트를 좌우로 나란히 배치 절대 금지
- display:flex + flex-direction:row 금지, float 금지
- 올바른 구조: 이미지(전체 너비) → 아래에 텍스트 블록
교훈: GPT에게 "하지 마"를 명확히 써야 한다. 안 쓰면 GPT는 가장 흔한 웹 패턴(2컬럼)으로 돌아간다.
7. Base64 이미지 임베딩 + Playwright 렌더링
상세페이지 HTML에 이미지를 삽입할 때, 외부 URL이 아닌 base64로 직접 삽입한다. 이유는:
- 이커머스 플랫폼에 업로드할 때 외부 이미지 URL이 차단될 수 있음
- HTML 파일 하나로 완결되어 포터블함
# Phase 2에서 이미지 치환
b64_str = image_to_base64(valid_images[i])
raw_html = raw_html.replace('__IMAGE__', f'data:image/png;base64,{b64_str}')
최종 HTML을 Playwright로 렌더링해서 PNG 이미지로도 만든다:
async def render_full_page(sections, category, png_path, html_path):
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": 860, "height": 800})
await page.set_content(full_html)
# 전체 높이 측정 후 뷰포트 재설정
height = await page.evaluate("document.body.scrollHeight")
await page.set_viewport_size({"width": 860, "height": height})
await page.screenshot(path=str(png_path), full_page=True)
8. CLI 설계 — 2단계 서브커맨드
image_first_engine.py는 argparse 서브커맨드로 Phase 1/2를 분리했다:
# Phase 1: 이미지 갤러리 생성
python image_first_engine.py gallery \
--name "유기농 모둠 쌈채소" --category farm \
--desc "GAP인증 친환경 농장에서 매일 아침 수확" \
--features "유기농인증,GAP인증,당일수확" \
--mood "fresh green, organic, morning dew"
# Phase 2: 선택한 이미지로 상세페이지 조립
python image_first_engine.py build \
--brand "초록농장" --name "유기농 모둠 쌈채소" --category farm \
--desc "..." --features "..." \
--images "01_gallery.png,03_gallery.png,05_gallery.png"
Phase 1 결과에서 마음에 드는 이미지 번호를 골라서 Phase 2에 넘기면 끝이다.
9. 레거시 코드 관리
사용하지 않는 코드는 삭제 대신 legacy/ 폴더로 이동했다:
- image_variation.py → legacy/image_variation.py (agent_engine에서 미사용 확인 후)
- test/model_loader.py → legacy/test_model_loader_sdxl.py (SDXL 시절 테스트)
완전히 삭제하지 않는 이유는 나중에 참고할 수 있고, git history를 더럽히지 않기 위해서다.
10. GCP L4 GPU 환경에서의 실전 팁
SSH 접속
gcloud compute ssh spai0511@gen-i --zone=us-central1-c
OS Login을 쓰는 경우 SSH 키 등록이 필요하다:
gcloud compute os-login ssh-keys add --key-file=~/.ssh/id_rsa.pub
파일 전송 (SCP)
폴더를 통째로 가져올 때는 --recurse 필수:
gcloud compute scp --recurse spai0511@gen-i:/home/spai0511/Ad-Lib/output/결과폴더 ~/Desktop/ --zone=us-central1-c
마무리 — 배운 것 정리
주제 핵심
| 싱글턴 패턴 | __new__ + Double-checked locking으로 모델 중복 로드 방지 |
| 8-bit 양자화 | BitsAndBytes로 12B 모델을 22GB GPU에 피팅. CPU offload 금지 |
| VAE 최적화 | slicing + tiling으로 디코딩 OOM 방지 |
| GPU 동시 접근 | Semaphore(1)로 직렬화 |
| 프롬프트 엔지니어링 | 구체적 px 값 명시, "하지 마" 규칙 필수, 반복 테스트 |
| 레이아웃 제어 | 1단 세로 배치 강제, 여백은 글자 크기에 비례하여 확대 |
| 워크플로우 | 이미지 먼저 → 사용자 선택 → 페이지 조립 (2단계 분리) |
프롬프트 엔지니어링이 코드 짜는 것보다 더 많은 반복을 필요로 했다. GPT한테 "알아서 잘 해줘"는 안 통한다. 구체적인 숫자와 금지 규칙을 넣어야 원하는 결과가 나온다.
이 글이 도움됐다면 댓글 남겨주세요. 다음엔 FastAPI + Redis로 이 파이프라인을 웹 서비스화하는 과정을 다룰 예정입니다.