본문으로 건너뛰기

서버리스 GPU 위의 AI 이미지 생성기는 왜 로컬보다 느렸을까 - PIEmgmaker 구축기

배너에 들어갈 그래픽 오브젝트를 잔뜩 만드는 팀을 보면서, AI의 도움을 받으면 제작 시간을 크게 줄이면 리서치나 아이데이션 같이 더 나은 기획을 할 수 있을 것이라고 생각했습니다. 그래서 AI를 활용해 배너에 들어갈 그래픽 오브젝트를 알파 PNG로 뽑는 도구를 만들었습니다.

로컬 Mac mini M4에서 잡 하나에 96분이 걸려서, 결국 GPU를 빌리기로 했습니다. 그런데 GCP Cloud Run으로 옮긴 첫 잡은 59분 26초가 걸렸고 $1.69가 나갔습니다. 프로파일을 떠보니 CPU는 6%였습니다. 계산은 거의 안 하고 파일 읽기만 기다리고 있었습니다.

이 글은 그 원인을 찾기까지와, 그 앞뒤로 밟은 함정들의 기록입니다. 결과물인 PIEmgmakerMANAPIE/piemgmaker에 올려두었습니다.


배너를 만들지 않는 배너 도구

배너 안에 들어갈 그래픽 오브젝트만 알파 채널(alpha channel) 이 있는 PNG로 뽑아내고 싶었습니다. 알파 채널은 픽셀마다 붙는 투명도 값입니다. 0이면 완전 투명, 255면 완전 불투명입니다.

캠페인 문구는 받긴 받는데 그려지지 않습니다. 컨셉을 잡는 참고용입니다.

입력은 이렇게 생겼습니다.

campaign_text:  "여름 시즌 최대 30% 할인"       # 컨셉 참고용, 그려지지 않음
object_concept: "3D 쇼핑백과 퍼센트 기호, 작은 화분 클러스터"
model:           qwen-image                 # qwen-image | flux2-dev
assets:         [link-logo-3d]              # 원본 그대로 삽입될 자산 (선택)
size_preset:    1:1 1024x1024
style:
  style_packs:  [sp-3d-glossy]              # 12종 중 선택 (선택)
  free_text:     null                       # 팩 위에 얹거나 팩 없이 단독 사용 (선택)
reference_images: []                        # 무드·질감 참조, 최대 2장 (선택)
negative:        null                       # "이건 그리지 마라" (선택)
candidate_count: 4                          # 후보 몇 장
seed:            random

그림을 그리는 부분은 직접 만들지는 않았습니다. 공개된 확산 모델(diffusion model)ComfyUI로 돌립니다. 확산 모델은 노이즈 화면에서 시작해 조금씩 그림으로 되돌려가는 방식입니다. 요즘 이미지 생성이라고 부르는 것 대부분이 이쪽입니다. ComfyUI는 그걸 노드 그래프로 조립해 실행하는 도구이고, 모델 파일을 직접 들고 쓸 때는 사실상 표준입니다.

이 프로젝트는 ComfyUI를 그림 그리는 엔진으로 두고 그 앞뒤를 조립한 것입니다.

원칙 세 개를 먼저 세웠습니다.

  1. 브랜드 자산은 생성하지 않고 합성합니다. 3D 레터링 형태의 로고 변형도 자산입니다: 디자이너가 만들어 라이브러리에 등록하고, 서비스는 삽입만 합니다.
  2. 배경 제거는 파이프라인의 책임입니다. 사용자에게 떠넘기지 않습니다.
  3. 알파는 이진 마스크가 아니라 연속값입니다. 유리와 반투명 재질을 지원해야 합니다.

세 번째가 나머지 설계를 거의 다 결정했습니다.

PIEmgmaker 생성 파이프라인 다이어그램. 브리프 입력에서 시작해 스타일 팩과 자산을 해석한 뒤 GenerationParams로 버전을 고정한다. 자산이 없으면 object-gen, 자산이 있으면 object-inpaint, 참조 이미지가 있으면 object-gen-styleref 경로로 분기한다. 각 경로는 다시 재질에 따라 불투명이면 segment 매팅으로, 반투명이면 native_alpha 우선 trimap 폴백으로 갈라진다. 이후 엔진 제출과 수확을 거쳐 결정론 후처리로 넘어가고, 자산 경로일 경우 paste-back과 코어 해시 검증을 추가로 거친 뒤 마지막에 QA 판정으로 끝난다.

시드를 화면에 노출한 이유

확산 모델은 노이즈에서 출발합니다. 그 출발 노이즈를 정하는 번호가 시드(seed) 입니다. 같은 설정에 같은 시드면 같은 그림이 나옵니다.

그래서 후보 카드마다 시드를 표시해뒀습니다. “이건 마음에 드는데 살짝만 바꾸고 싶다”면 시드를 고정한 채 다른 값을 건드립니다. “같은 설정으로 다른 그림”을 원하면 시드만 새로 뽑습니다. 이 둘을 화면에서 구분해주지 않으면 사용자가 매번 처음부터 다시 뽑게 됩니다.

PIEmgmaker 생성하기 화면 스크린샷. 좌측 짙은 녹색 사이드바에 생성하기·히스토리·자산 세 메뉴가 있다. 본문은 위에서부터 캠페인 텍스트와 AI가 그릴 요소를 받는 브리프 카드, 생성 모델 라벨 옆에 원격 GCP 배지가 붙고 Qwen-Image와 FLUX.2 dev 토글 아래 스타일 팩 12종이 깔린 모델·스타일 카드, 원본 그대로 삽입될 자산 카드, 사이즈와 후보 수·시드·금지 요소를 고르는 출력 설정 카드 순으로 쌓여 있다. 맨 아래에 생성 시작 버튼과 접힌 샘플 갤러리 아코디언이 있다.

시드는 맨 아래 출력 설정에 있습니다. 랜덤과 고정을 드롭다운으로 고릅니다.

스타일 팩이 소유하지 않는 것

스타일은 팩으로 관리합니다. 12종을 기본 제공하고, 팩이 프롬프트 틀과 팔레트, 그림자 정책을 갖습니다.

id: sp-3d-glossy
version: 1
name: 3D 글로시
prompt_template: "{subject}, 3D rendered, glossy, ... {mood}"
negative: "..."              # "이건 그리지 마라" — 배경 억제 문구가 기본 포함
palette: [...]
material_class: opaque       # ← 배경 제거 전략을 여기가 결정한다
shadow_policy: soft_floor    # none | soft_floor | ambient
matting: { strategy: segment, model: ..., hash: ... }

팩은 틀이고, {subject} 자리에 사용자가 쓴 소재가 들어갑니다.

설계에서 중요한 건 팩이 무엇을 소유하지 않는가입니다. 팩은 스타일만 소유하고 모델 가중치는 소유하지 않습니다. 모델은 별도 파일이 단일 소스입니다. 안 그러면 팩을 바꿨더니 모델이 바뀌는 상황이 생깁니다.

그리고 material_class 한 줄이 뒤에 오는 배경 제거 전략을 결정합니다. 사용자가 고르는 게 아니라 팩에 적힌 재질이 고릅니다.

5종으로 시작했다가 12종이 됐습니다. 그런데 팩을 늘리자마자 문제가 하나 생겼습니다. 이름만 봐서는 뭐가 나올지 모릅니다. “페이퍼 크래프트”가 어떤 질감인지, “벌룬 3D”가 얼마나 부풀어 있는지는 글자로 전달되지 않았습니다.

그래서 팩마다 대표 샘플을 실제로 생성해서 선택 카드에 미리보기로 붙였습니다.

PIEmgmaker 모델·스타일 영역 스크린샷. 맨 위에 생성 모델 라벨과 원격 GCP 배지, Qwen-Image 20B Q8과 FLUX.2 dev Q8 토글이 있다. 그 아래 클레이 3D·3D 글로시·벌룬 3D·크롬 메탈·플랫 일러스트·글래스·아이소메트릭·네온 글로우·페이퍼 크래프트·픽셀 아트·스티커·수채화 12개 팩이 카드 그리드로 놓여 있고, 각 카드는 체커보드 위에 그 팩으로 실제 생성한 대표 샘플을 띄운 뒤 아래에 팩 이름과 작은 글씨의 id를 적어둔다.

모델이 둘이니 팩마다 두 모델을 다 뽑았고, 마음에 안 드는 팩은 다시 뽑다 보니 지금은 36장입니다. 샘플 갤러리는 아코디언으로 접어뒀습니다.

PIEmgmaker 샘플 갤러리 스크린샷. 접혀 있던 아코디언이 펼쳐진 상태이고 제목에 총 36장이라는 개수가 붙어 있다. 우측에 모델 필터와 스타일 팩 필터 드롭다운이 있고, 샘플이 4열 그리드로 놓인다. 각 카드 아래에 생성에 쓴 프롬프트 한 줄, 모델명과 팩 이름, 그리고 사용된 매팅 전략과 시드가 차례로 적혀 있다.

나중에는 팩을 선택 사항으로 완화했습니다. 참조 이미지나 자유 텍스트만으로도 생성할 수 있고, 팩이 없으면 불투명으로 처리합니다.


알파를 어떻게 얻을 것인가

유리는 떼어낼 수 없다

불투명 오브젝트는 단순합니다. 프롬프트에 단색 배경을 강제해서 그리게 하고, 그 배경을 떼어냅니다. 배경이 복잡하면 경계 판별이 흔들리니 애초에 단순한 배경을 만들어놓고 시작하는 겁니다.

유리는 이 방식이 통하지 않습니다. 배경을 지우면 유리 뒤에 비쳐야 할 것까지 같이 지워집니다. 유리의 본질이 “뒤가 비친다”인데, 뒤를 지우면 유리가 아니라 유리 모양의 판이 남습니다.

그래서 트랙이 셋으로 갈립니다.

트랙쓰는 것방식언제
segmentBiRefNet-matting + 전경 정제생성된 이미지에서 오브젝트 영역을 판별해 알파를 만듦불투명 기본
native_alphaQwen-Image-Layered + 전용 VAE떼어내지 않음. 생성 단계에서 알파가 붙은 레이어가 그대로 나옴반투명 1순위
trimapViTMatte확실한 전경 / 확실한 배경 / 애매한 경계 3구역을 주고 경계만 정밀 추정반투명 폴백

BiRefNetViTMatte는 둘 다 배경 제거 전용으로 공개된 모델입니다. 이미 있는 이미지에서 오브젝트만 골라내는 일만 합니다. BiRefNet은 오브젝트 영역을 통째로 잘 잡는 쪽이고, ViTMatte는 “여기는 확실히 앞, 여기는 확실히 뒤, 이 띠 안은 모르겠다”를 알려주면 그 애매한 띠의 투명도를 정밀하게 추정하는 쪽입니다.

여기서 VAE가 걸립니다. 보통 VAE는 만들어진 결과를 RGB 3채널 픽셀로 펴줍니다. 알파가 붙은 그림을 생성 단계에서 바로 뽑으려면 알파까지 펴줄 줄 아는 전용 VAE가 따로 필요합니다. native_alpha 트랙이 전용 모델과 전용 VAE를 함께 쓰는 이유입니다.

폴백 체인은 재질이 결정합니다.

# MattingRouter.chain_for(material)
opaque      -> [segment]
translucent -> [native_alpha, trimap]

체인이 소진되면 잡을 실패 처리하고 재생성 힌트를 남깁니다. 조용히 나쁜 결과를 내보내지 않습니다. 어느 트랙을 탔든 결과는 AlphaResult(rgba, strategy_id, confidence) 한 형태로 통일됩니다. 위쪽 코드는 어느 트랙을 탔는지 몰라도 됩니다.

배경 제거 3트랙 폴백 체인 다이어그램. 재질 판정에서 시작해 불투명은 segment 트랙 하나로 직행하고, 반투명은 native_alpha를 먼저 시도한 뒤 실패하면 trimap으로 폴백한다. 세 트랙 모두 성공 시 AlphaResult라는 하나의 출력 형태로 수렴하고, trimap마저 실패하면 체인 소진으로 잡 실패 처리되어 재생성 힌트가 기록된다.

반투명 트랙이 굳이 둘인 이유는 비용 때문입니다. native_alpha가 soft alpha 충실도가 가장 높지만, 전용 모델과 전용 VAE라 기본 모델과 따로 올려야 합니다. 메모리 부담이 생깁니다. 그래서 실패하면 trimap으로 내려갑니다. 폴백도 색 오염은 거의 없습니다.

세 번째 원칙이 여기서 눈에 보입니다. 알파가 이진 마스크였다면 오른쪽 유리는 불투명한 판이 됐을 겁니다.

불투명 오브젝트와 유리 재질 오브젝트의 알파 채널 비교 이미지. 회색 체커보드 배경 위에 두 결과물이 나란히 놓여 있다. 왼쪽은 segment 트랙으로 뽑은 3D 쇼핑백으로, 실루엣 안쪽으로 체커보드가 전혀 비치지 않고 경계만 부드럽게 처리되어 중간 알파가 1.8%에 그친다. 오른쪽은 native_alpha 트랙으로 뽑은 유리 물방울로, 몸통 전체에서 체커보드 무늬가 그대로 비쳐 보이고 하이라이트 부분만 상대적으로 불투명해 중간 알파가 44.9%다.

코어 해시는 지각 해시가 아니다

후가공은 전부 비 GPU 구간이고, 전부 결정론입니다.

순서단계하는 일
1디프린지경계에 남은 배경색 오염을 걷어냄
2bbox 크롭알파가 있는 영역의 경계 상자로 잘라냄
3여백 정규화일정한 여백을 다시 부여
4paste-back원본 자산을 알파째로 되붙임 (자산 경로만)
5코어 해시 검증자산의 불투명 코어 픽셀이 원본과 정확히 같은지 (자산 경로만)
6품질 검사하드 룰 3종

프린지(fringe) 는 배경을 떼어낼 때 오브젝트 가장자리 한두 픽셀에 원래 배경색이 섞여 남는 현상입니다. 경계 픽셀은 원래 두 색이 혼합된 값이라 어쩔 수 없이 생깁니다. 헤일로라고도 부릅니다. 불투명 트랙에서 단색 배경을 강제한 대가가 여기서 돌아옵니다.

bbox 크롭을 넣은 이유는 배너 작업의 편의입니다. 잘라내면 “오브젝트 크기 = 이미지 크기”가 되어 얹을 때 크기 감각이 일정해집니다.

5번이 이 프로젝트에서 가장 타협하지 않은 부분입니다.

def verify_core_hash(result: Image, asset: AssetVariant, placement: Placement) -> bool:
    # 코어 영역 픽셀 buffer의 sha256 정확 일치. 지각 해시가 아니다.
    # 불일치 → 재시도 1회 → 실패

지각 해시가 아니라 바이트 단위 정확 일치입니다. “비슷하다”를 허용하지 않습니다.

그래서 자산을 등록할 때 완전 불투명한 코어가 있어야 받아줍니다. 전부 반투명인 PNG는 검증의 기준점이 없어서 등록을 거부합니다. 실제로 이 검증에 막혀 등록이 안 됐던 적이 있는데, 처음엔 버그인 줄 알았습니다. 버그가 아니라 그렇게 설계한 것이었습니다.

완전 불투명(alpha=255) 코어가 없습니다 — 코어 해시 검증이 불가능해 등록할 수 없습니다

등록 검증은 이렇습니다. RGBA + 알파 채널 존재, 완전 불투명 코어 존재, id 규칙과 중복 검사, 파일 크기 상한이 있습니다. PNG만 받습니다. 삭제는 숨김 처리이고 실제로 지우지 않습니다.

PIEmgmaker 자산 라이브러리 화면 스크린샷. 상단 새 자산 등록 영역에 알파 PNG만 등록되며 완전 불투명 코어가 있어야 코어 해시 검증이 가능하다는 안내가 붙어 있고, PNG 선택 영역과 자산 id·표시 이름·종류·variant id·최소 스케일·여백 입력란이 배치되어 있다. 그 아래 등록된 자산 카드가 하나 있는데 예시 배지라는 이름과 object 태그, example-badge라는 id, 최소 스케일과 여백 값, main variant 썸네일, 그리고 숨김과 variant 추가 링크가 붙어 있다. 우측 상단에는 숨김 항목 표시 체크박스가 있다.

품질 검사는 하드 룰 3종입니다.

판정
경계 접촉오브젝트가 이미지 가장자리에 닿으면 실패잘린 오브젝트는 배너에 못 씀
알파 커버리지12~80% 밖이면 실패너무 작으면 매팅 실패, 너무 크면 배경이 안 지워진 것
헤일로경계 오염 검사재질을 알고 판정: 유리의 정상 반투명을 오염으로 오탐하지 않게

세 번째가 핵심입니다. 재질을 모르고 판정하면 잘 만들어진 유리를 전부 불합격시킵니다. 형태 연산은 외부 의존성 없이 numpy로 직접 구현했습니다.

판정은 후보별로 리포트에 기록되어 리뷰 화면에 표시되고, 최종 채택은 사람이 합니다. 룰이 통과했다고 좋은 그림인 건 아니니까요.

PIEmgmaker 리뷰·확정 화면 스크린샷. 상단에 큐 대기부터 엔진 준비·생성·후처리 QA를 거쳐 완료까지 이어지는 진행 단계 표시가 있고, 우측에 배경 제거 전략이 native_alpha라고 적혀 있다. 그 아래 상세 설정 아코디언이 펼쳐져 캠페인·오브젝트·모델·스타일 팩·크기·후보 수·시드 네 개·매팅·워크플로우 해시가 나열되는데, 매팅 항목은 native_alpha에서 trimap으로 이어지는 폴백 체인과 실제로 사용된 전략을 함께 보여준다. 맨 아래에는 체커보드·밝음·어두움·브랜드·사진 배경 토글과 함께 유리 향수병 후보 4장이 체커보드 위에 놓여 있고, 각 카드 하단에 녹색 QA PASS 배지와 시드 값, 선택 체크박스, PNG 저장 링크가 붙어 있다.

룰에 걸리면 이렇게 보입니다. 경계 밴드의 오염 비율이 43.3%라 한도 5%를 넘겼고, 재생성 힌트까지 카드에 붙습니다.

QA 하드 룰에 걸린 후보와 통과한 후보를 나란히 놓은 스크린샷. 왼쪽 카드는 픽셀 아트 오브젝트의 경계에 검은 배경색이 띠처럼 남아 있고, 붉은 QA FAIL 배지 아래 실패 사유 1건이 펼쳐져 헤일로 룰에서 경계 밴드 헤일로 비율 43.3%로 한도 5%를 넘겼다는 판정과 경계에 배경색 헤일로가 남았으니 디프린지와 매팅 전략을 점검하라는 재생성 힌트가 적혀 있다. 오른쪽 카드는 같은 잡의 다른 후보로 경계가 깨끗해 녹색 QA PASS 배지가 붙어 있다.

결정론 구간과 생성 구간

이 시스템에서 가장 중요한 설계 결정은 시스템을 두 구간으로 쪼갠 것입니다.

구간내용검증 방법
결정론입력 검증 · 후처리 · paste-back · 코어 해시 · 품질 검사같은 입력 → 항상 같은 출력 → 일반적인 단위 테스트
생성그림 그리는 부분같은 입력에도 결과가 흔들림 → 기준 이미지 세트와 비교해 점수화

이걸 안 쪼갰으면 “그림이 마음에 안 드는 것”과 “코드가 틀린 것”을 구분할 방법이 없었습니다.

결정론 구간은 단위 테스트로 봅니다. ComfyUI HTTP는 전부 모킹합니다. 생성 구간은 골든 세트(golden set) 로 봅니다. 그림은 맞았다·틀렸다로 판정할 수 없으니, 기준이 되는 이미지를 정답처럼 저장해두고 새 결과가 거기서 얼마나 벗어났는지를 봅니다.

지표는 셋입니다.

  • SSIM: 사람 눈이 느끼는 유사도에 가깝게 만든 지표. 색과 구조가 얼마나 닮았는지
  • 알파 IoU: 투명·불투명을 반반으로 딱 자른 뒤 두 실루엣이 얼마나 겹치는지. 모양이 맞는지를 봄
  • soft alpha MAE: 알파의 사이 값이 평균적으로 얼마나 어긋났는지. 반투명 표현이 유지되는지를 봄

앞의 둘만 보면 유리 재질에서 안 먹혔습니다. 그래서 세 번째가 필요했습니다. 임계값은 설정 파일이 아니라 케이스 파일에 적습니다. 케이스마다 허용 범위가 다르기 때문입니다.


로컬 Mac mini에서 부딪힌 세 가지

메모리가 3GB 모자란다

모델은 한 덩어리가 아니라 세 부품 세트입니다.

부품하는 일크기
텍스트 인코더”3D 쇼핑백”이라는 문장을 모델이 알아듣는 숫자로 바꿈수십 GB
그림 그리는 부분그 숫자를 보고 실제로 그림을 만듦수십 GB
VAE만들어진 결과를 볼 수 있는 픽셀로 펴줌상대적으로 작음

문제는 앞의 둘이 둘 다 수십 GB이고 동시에 올라가야 한다는 것입니다.

구성크기
텍스트 인코더35.6 GB
그림 그리는 부분32 GB
합계67 GB
장비 메모리64 GB

문장을 숫자로 바꾸는 부품 하나가 35GB를 먹는다는 게 처음엔 잘 안 믿겼습니다.

증상은 connection reset, 또는 아무 로그도 없이 프로세스가 사라지는 것이었습니다. 간헐적이라 커스텀 노드를 먼저 의심하고 한참을 뒤졌습니다. 결국 OOM이 터진 것이었습니다. 3GB 차이입니다.

그래서 양자화는 얼마나 손해인가

해결은 양자화(quantization) 였습니다. 텍스트 인코더를 8비트로 낮추니 35.6GB가 18GB가 되면서 해소됐습니다. 그런데 낮추면 뭘 잃는지는 알고 낮춰야 합니다.

등급크기품질
16비트 (bf16 / fp16)기준기준
fp8 (8비트 부동소수)절반네이티브 FP8 지원 GPU에서는 손실이 거의 안 보임
Q8 (GGUF 8비트 정수)절반출력이 거의 동일: 메모리가 부족할 때 첫 선택지
Q6_K그 아래차이가 감지되나 대부분 용도에 수용 가능. 미세 디테일이 부드러워짐
Q4 이하1/4복잡한 프롬프트에서 눈에 띄게 무너짐

이미지는 텍스트보다 양자화에 예민합니다. 언어 모델은 토큰이 살짝 어긋나도 잘 안 보이지만, 이미지는 픽셀이 어긋나면 바로 보입니다. 그래서 그림 그리는 부분보다 텍스트 인코더 쪽을 먼저 낮췄습니다. 문장을 숫자로 바꾸는 단계는 조금 뭉개져도 최종 픽셀에 미치는 영향이 상대적으로 간접적입니다.

실제 구성은 이렇습니다.

그림 부분텍스트 인코더
로컬Q8fp8
원격 qwenQ6_K (L4 24GB에 맞추려 한 단계 더)fp8

A/B로 재보지는 않았습니다. 예전에 ComfyUI로 이 조합을 여러 번 돌려본 감으로 결정했습니다.

프로파일 id에는 이게 안 드러납니다. 뒤에 나오는 리뷰 화면의 모델명이 qwen-image-layered-q8-gguf인데, 원격에서 실제로 올라간 파일은 qwen-image-layered-Q6_K.gguf입니다. id는 로컬 빌드 기준으로 지어두고 원격 프로파일이 파일만 갈아끼우는 구조라 그렇습니다. 화면에 뜨는 이름과 실제 가중치가 다를 수 있다는 뜻이니, 정확한 파일은 payload.json의 매니페스트를 봐야 합니다.

모델 파일은 프로파일에 sha256으로 고정해뒀습니다. 같은 이름의 다른 빌드가 섞여 들어오면 결과가 조용히 달라지기 때문입니다.

# model_profiles.yaml — 워크플로우·샘플링·sha256 핀의 단일 소스
qwen-image:
  workflow_gen: object-gen-qwen-v1
  workflow_inpaint: object-inpaint-qwen-v1
  workflow_styleref: object-gen-styleref-v1
  supports_negative: true
  manifest:
    unet: { name: ..., sha256: ... }
    clip: { name: ..., sha256: ... }
    vae:  { name: ..., sha256: ... }

두 계열을 같이 못 띄운다, 그리고 96분

같은 이유로 Qwen 스택과 FLUX.2 스택을 한 프로세스에 올릴 수 없었습니다. 그래서 모델 계열을 전환할 때는 엔진을 재기동하는 게 운영 수칙이 됐고, 배치는 모델별로 묶어서 실행하게 됐습니다.

그리고 느립니다. 1024² 후보 4장에 약 96분이 걸립니다. 워커 타임아웃 기본값을 4시간으로 잡아야 했습니다.

PIEmgmaker 로컬 실행 플로우 다이어그램. 웹 UI에서 제출한 잡이 FastAPI 단일 워커 큐에 들어가고, 워커가 로컬 ComfyUI 프로세스에 워크플로우를 제출한 뒤 폴링으로 상태를 확인하고 결과를 수확한다. ComfyUI는 로컬 SSD에 놓인 모델 파일을 읽으며, 좌측에 텍스트 인코더 35.6GB와 unet 32GB의 합이 장비 메모리 64GB를 넘어 무음 OOM이 발생한다는 메모리 경계 주석이 붙어 있다. 수확된 결과는 결정론 후처리를 거쳐 잡 폴더에 저장된다.

되긴 됩니다. 그런데 96분 동안 제 맥이 통째로 묶입니다.

모델 선정 히스토리도 여기 적어둡니다. FLUX은 1 시절부터 써서 프롬프트가 어떻게 먹히는지 감이 있었고, 그래서 FLUX.2 dev가 첫 후보였습니다.

막힌 건 라이선스였습니다. dev 계열은 비상업 라이선스입니다. 개인 실험은 되지만 결과물을 실무로 끌고 나가는 순간 확인이 필요해집니다. 배너 소재를 뽑는 도구에서는 작은 문제가 아닙니다.

Qwen-Image는 Apache 2.0입니다. 라이선스를 신경 쓰지 않아도 되는 쪽이 기본값이어야 한다고 판단했습니다. 기능적으로도 유리했습니다. 참조 이미지로 무드를 잡는 Qwen-Image-Edit, 생성 단계에서 알파가 바로 나오는 Qwen-Image-Layered가 같은 계열에 있습니다.

FLUX.2를 남긴 이유는 단순합니다. 그림체가 더 마음에 들었습니다. 라이선스 확인은 쓰는 사람 책임으로 두고 선택지로 유지했습니다.


AWS와 GCP 사이

서버리스 GPU가 한쪽에만 있었다

요구는 단순했습니다. 성능은 필요한데 돈은 많이 안 썼으면 좋겠습니다. 하루 종일 쓸 것도 아니니 안 쓸 때 0원이 아니면 의미가 없었습니다.

GCPAWS
서버리스 GPUCloud Run에 GPU 부착 가능. 요청 있을 때만 뜨고 없으면 0대. 초 단위 과금대응물 없음. Lambda·Fargate는 GPU 미지원
그나마 비슷한 것-SageMaker 비동기 엔드포인트가 0대까지 내려감. 단 S3로 넣고 S3에서 꺼내는 구조
큰 GPURTX PRO 6000 Blackwell 96GB 단일 카드일반 인스턴스 최대가 L40S 48GB. 그 위는 A100/H100 멀티 GPU급
우리 코드가 할 일HTTPS 주소 + 토큰만 알면 끝EC2를 API로 켜고 끄는 오케스트레이션 계층을 직접 작성

결정적이었던 건 둘입니다.

SageMaker 비동기는 인터페이스가 다릅니다. “안 쓸 때 0원”이라는 목적은 같은데 S3 기반입니다. 지금의 “HTTP로 제출 → 상태 문의 → 결과 수신” 엔진을 전부 다시 짜야 했습니다. 처음부터 다시 만들 생각은 없었습니다.

FLUX.2 스택 50GB가 AWS에서 애매했습니다. 제일 맞는 카드가 L40S 48GB인데 빠듯합니다. 오프로드가 필요해지고, 그러면 시스템 메모리가 큰 상위 인스턴스로 올라갑니다. 96GB 단일 GPU를 그냥 고를 수 있는 쪽이 훨씬 단순했습니다.

AWS가 나았을 조건도 있습니다. 하루 종일 돌릴 거라 상시 스팟으로 싸게 가는 방향이었거나, 애초에 배치 파이프라인으로 재설계할 생각이었다면 불리하지 않습니다. 지금 구조가 웹에서 잡을 넣고 폴링하는 형태라 그렇습니다.

직접 켜고 끄는 방식도 검토했습니다. VM을 API로 기동·종료하는 자작 온디맨드, 또는 스팟 인스턴스였습니다. 비용은 더 내려가지만 사이드 프로젝트 하나에 시간을 더 태우기는 아까웠고, Cloud Run이 알아서 해주는 걸 굳이 다시 만들 이유가 없었습니다.

상시 운영이면 월 $4,036

Cloud Run이 아니었다면 얼마가 들었을지 계산해봤습니다.

시간당하루월(730시간)
qwen용 (L4 24GB)$1.71$40.92$1,245
flux2용 (RTX PRO 6000 96GB)$3.82$91.78$2,792
둘 다약 $4,036

지금 실제 비용은 잡당 $0.24 + 상시 월 $10 안팎입니다. 상시 비용은 버킷과 이미지, 캐시에서 나옵니다.

다만 상시 유지가 항상 손해는 아닙니다. 잡마다 콜드 스타트(cold start) 값을 치르니 잡이 촘촘하면 켜두는 게 이득입니다. 콜드 스타트는 서버가 0대인 상태에서 요청이 들어와, 컨테이너를 새로 띄우고 모델을 다 읽어 준비를 마치기까지의 시간입니다.

분기점은 튜닝 전 기준으로 하루 28잡, 튜닝 후에는 단순 환산으로 하루 200잡 안팎입니다. 개인이 쓰는 도구에서 하루 200잡은 나오지 않습니다.

GPU 존 이중화는 껐습니다. 켰다면 시간당 $2.16 / $4.70이 되니, 끄는 것만으로 각각 21%와 19% 가 절감됩니다. 개인용이라 가용성을 그만큼 살 이유가 없었습니다.

네 가지 결정사항

PIEmgmaker 원격 구성 다이어그램. 좌측 로컬에 웹 UI와 FastAPI 잡 큐가 있고 PM_ENGINE 환경변수 하나가 local-comfy와 remote-comfy 사이를 분기한다. 우측 GCP asia-southeast1 리전에는 pm-comfy-qwen과 pm-comfy-flux2 두 Cloud Run 서비스가 있고, 각 컨테이너는 nginx Bearer 프록시와 ComfyUI, 540초 자기 종료 와치독으로 구성되며 scale-to-zero로 동작한다. 두 서비스 모두 단일 리전 모델 버킷을 GCS FUSE로 읽기 전용 마운트하고, 하단에 Direct VPC egress와 Private Google Access가 없으면 인스턴스 대역폭이 600Mbps로 제한된다는 요구사항이 표시되어 있다.

서비스를 모델 계열별로 분리했습니다. FLUX.2 스택은 압축해도 약 50GB라 L4 24GB에 안 들어갑니다. 로컬에서 두 계열을 같이 못 띄웠던 그 제약이 클라우드에서도 그대로 돌아왔습니다. 이번에는 재기동 대신 서비스를 나누는 방식으로 풀었습니다.

리전은 싱가포르입니다. GPU를 붙일 수 있는 Cloud Run 리전에 서울이 없습니다. 레이턴시는 수용하기로 했습니다.

모델은 클라우드 저장소에 두고 서버에 폴더처럼 마운트했습니다. 컨테이너 이미지에 수십 GB를 굽지 않으려는 선택이었습니다. 그런데 이 방식에는 함정이 있었습니다. 코드가 자기가 네트워크 너머를 읽고 있다는 걸 모릅니다. 이것 때문에 발생한 이슈는 아래쪽에 나옵니다.

인증은 컨테이너 안 프록시가 토큰을 검사합니다. 보안 그 자체보다는, 인터넷에 열린 GPU 주소가 남의 지갑으로 돌아가는 걸 막는 목적입니다.

엔진을 인터페이스로 뽑아둔 값

코드 변경은 의외로 작았습니다. 처음부터 엔진을 인터페이스로 뽑아뒀기 때문입니다.

class WorkflowEngine(Protocol):
    def submit(self, payload) -> JobHandle: ...
    def poll(self, handle)    -> JobStatus: ...
    def fetch(self, handle)   -> list[CandidateResult]: ...
    def cancel(self, handle)  -> None: ...

제출·상태 문의·결과 수신·취소. 이 넷만 지키면 위쪽 코드는 로컬인지 원격인지 몰라도 됩니다. HTTP 코어는 공통 베이스가 갖고, 로컬 구현은 여기에 온디맨드 기동과 하트비트만 얹습니다. 원격 구현은 모델 계열별 URL로 라우팅하고 콜드 스타트를 기다립니다.

사용자 쪽 스위치는 환경변수 하나입니다.

PM_ENGINE=remote-comfy          # 기본값은 local-comfy
PM_REMOTE_URL_QWEN=https://...  # https만 허용 — 토큰 평문 전송 차단
PM_REMOTE_URL_FLUX2=https://...

안 건드리면 동작이 전과 완전히 같습니다. 그리고 시작할 때 어느 백엔드로 도는지 한 줄 남기게 했습니다. 원격 URL이 설정됐는데 엔진이 로컬이면 경고합니다. 설정 불일치로 생성이 조용히 로컬로 나가는 걸 막기 위해서입니다.

구현은 간단했습니다. 트러블슈팅이 빡셌습니다.


모델을 옮기는 것 자체가 프로젝트였다

“클라우드로 옮긴다”가 코드만 옮기는 일인 줄 알았습니다. 아니었습니다. 수십 GB짜리 모델도 같이 올라가야 합니다.

모델 6종. 압축 상태로도 qwen 세트가 26GB, flux2 세트가 53GB입니다. 처음엔 로컬에서 버킷으로 올리려 했는데, 집 회선으로 80GB를 미는 그림이 나왔습니다. 예상 소요가 12시간이었습니다.

방향을 틀었습니다. 모델 배포처에서 클라우드 저장소로 직접 받게 했습니다. 클라우드로 들어오는 트래픽은 무료이니 로컬 회선을 아예 거치지 않으면 됩니다.

여기서도 세 번의 이슈가 있었습니다.

증상원인
처음 몇 GB는 잘 받다가 KB/s로 떨어짐익명으로 받고 있었음 → 토큰 인증하니 속도 유지
다운로드 작업이 그냥 죽음작업 머신 메모리가 4GB → 8코어짜리로 상향
8GB로 올려도 여전히 죽음다운로드 도구의 고성능 모드가 메모리를 크게 씀 → 기본 모드로 복귀

첫 번째가 특히 얄궂었습니다. 15GB 파일을 받는데 12GB 지점까지는 멀쩡히 오다가 갑자기 떨어집니다.

[#7c6e86 12GiB/15GiB(80%) CN:3 DL:144KiB ETA:6h16m16s]
[#7c6e86 12GiB/15GiB(80%) CN:3 DL:88KiB  ETA:10h15m44s]
[#7c6e86 12GiB/15GiB(80%) CN:3 DL:98KiB  ETA:9h8m13s]

처음엔 회선 문제나 대용량 파일의 특성인 줄 알았습니다. 익명 요청에 걸린 속도 제한이었습니다. 토큰을 시크릿에 넣고 인증 요청으로 바꾸니 끝까지 속도가 유지됐습니다.

두 번째와 세 번째는 같은 증상이 다른 원인에서 나온 경우입니다. 작업이 아무 메시지 없이 멈춥니다. 머신을 키웠는데도 계속 죽길래 한참 헤맸는데, 다운로드 도구의 고성능 모드가 메모리를 크게 쓰는 게 원인이었습니다. 기본 모드로 되돌리니 8코어에서 안정적으로 돌았습니다.

배포 순서도 함정이 있었습니다. 이미지를 먼저 빌드해서 올린 다음에 인프라를 적용해야 합니다. 반대로 하면 시작 프로브가 붙을 컨테이너가 없어서 실패합니다. 플레이스홀더 이미지로 때울 수가 없습니다.

커스텀 노드는 import 실패를 삼킨다

모델을 다 올리고 이미지를 굽고 배포까지 끝냈습니다. 스모크 점검도 통과했습니다. 무인증 요청은 401, 토큰 요청은 200. 정상입니다.

그리고 첫 생성에서 터졌습니다.

배경 제거 폴백 체인 소진 — 재생성이 필요합니다
  node_id: mat_F1
  node_type: BiRefNetRMBG
  exception_message: Error loading BiRefNet model: No module named 'timm'

배경 제거 커스텀 노드가 timm을 필요로 하는데, 그 노드의 requirements에는 timm이 선언되어 있지 않았습니다. 로컬에서는 다른 무언가가 이미 깔아둬서 안 보였던 것입니다.

문제는 이게 왜 안 잡혔는가입니다. ComfyUI의 커스텀 노드 로더는 import 예외를 삼키고 해당 노드 등록만 건너뜁니다. 프로세스는 정상 기동하고, 헬스체크는 계속 초록이고, 스모크 점검도 통과합니다. 그 노드를 실제로 호출하는 워크플로우가 돌기 전까지는 아무도 모릅니다.

빌드도 통과, 기동도 통과, 스모크도 통과. 첫 생성에서 폭발. 그 사이에 콜드 스타트와 모델 적재 시간이 다 들어갑니다.

대응은 빌드 시점 정적 검사였습니다. 이미지에 굽는 모델 코드의 최상위 import를 훑어서, 설치되지 않은 모듈이 있으면 빌드가 실패합니다.

헬스체크가 통과한다고 워크로드가 통과하는 게 아닙니다.


59분 26초를 8분 25초로

진범은 절약 기능이었다

첫 원격 잡이 59분 26초, $1.69였습니다. 로컬 맥보다 느립니다. GPU를 빌린 의미가 없습니다.

프로파일을 떴더니 CPU 6%, 전 구간 I/O 대기였습니다. 계산은 안 하고 파일 읽기만 기다리고 있었습니다.

범인은 ComfyUI의 절약 기능이었습니다. GPU 메모리를 아끼려고 가중치를 미리 다 올리지 않고, 추론 도중 필요한 조각만 그때그때 가져오는 동작이 기본으로 켜져 있습니다.

로컬 SSD에서는 훌륭한 최적화입니다. 그런데 원격에서 그 파일은 로컬 폴더처럼 보일 뿐, 실제로는 클라우드 저장소를 마운트한 것입니다. 조각마다 네트워크 왕복이 붙습니다.

# 전량 적재로 되돌린다. 이게 결정적이었다.
python main.py --disable-dynamic-vram

앞에서 “코드가 자기가 네트워크 너머를 읽는 걸 모른다”는 게 이런 이슈로 돌아왔습니다.

mmap을 껐더니 16배 느려졌다

파일을 읽는 방식은 크게 둘입니다. 통째로 복사해 올리거나, 메모리 주소에 대응시켜 두고 필요한 부분만 가져오거나. 뒤쪽이 mmap입니다.

“네트워크 파일시스템이니까 mmap도 끄는 게 맞겠지”라고 생각했습니다. 껐더니 더 느려졌습니다.

읽기 방식처리량
mmap 경로89 MB/s
mmap을 끈 경로5.2 MB/s

16배 차이입니다. mmap 쪽이 파일을 앞에서 뒤로 순서대로 읽어서, 저장소 마운트의 미리 읽기 최적화가 붙는 것으로 보였습니다. 비-mmap 경로는 그 최적화를 못 받습니다.

직관과 반대라 한참 붙들고 있었습니다. 네트워크 파일시스템에서 랜덤 액세스가 나쁜 건 맞는데, mmap이 곧 랜덤 액세스는 아니었습니다. 접근 방식의 이름과 실제 접근 순서는 다른 얘기였습니다.

클라우드 내부 경로는 선택이 아니라 요구사항이다

마지막은 네트워크 경로였습니다. 클라우드 내부 전용 경로를 켜지 않으면 인스턴스 대역폭이 600 Mbps로 묶입니다. 구글이 GPU와 저장소를 함께 쓰는 조합에 요구사항으로 명시해둔 항목입니다. 권장이 아니라 요구사항입니다.

여기에 마운트 옵션과 저장소 캐시를 얹었습니다.

enable-buffered-read=true       # 순차 읽기 버퍼링
read-global-max-blocks=64       # 읽기 블록 상한
metadata-cache-ttl-secs=-1      # 모델 파일은 안 바뀌므로 무기한

버킷 쪽에는 3개 존에 캐시를 붙였습니다. 버킷 송신량이 2.9배로 증폭되는 현상이 남았는데, 같은 리전 안의 전송은 무료라 무해하다고 보고 더 쫓지 않았습니다.

결과는 8분 25초, $0.24입니다. 시간도 비용도 7배 줄었습니다.

잡 1건 소요 시간 비교 다이어그램. 같은 스케일의 가로 막대 두 개로, 위쪽 튜닝 전 막대는 총 59분 26초이며 대부분이 모델 적재 구간이고 생성 구간은 끝에 짧게 붙어 있다. 아래쪽 튜닝 후 막대는 총 8분 25초로 훨씬 짧고, 그 오른쪽으로 이어진 점선에 7배 감소가 적혀 있다. 튜닝 후 막대의 양끝에서 옅은 점선 두 줄이 아래로 뻗어 내려가 구간별 확대 막대의 양끝에 닿는다. 왼쪽은 수직으로, 오른쪽은 비스듬히 벌어져 아래 막대가 위 막대를 확대한 것임을 나타낸다. 확대 막대는 컨테이너 기동 31초, 모델 적재 6분 20초, 생성 약 1분 30초의 세 구간 비율을 보여준다. 잡당 비용은 각각 1.69달러와 0.24달러다.

이 구간은 다시 되돌아갈 수 있습니다. ComfyUI나 커스텀 노드 버전을 올린 뒤에는 잡 1건의 구간별 소요를 다시 재야 합니다. 적재 관련 기본값이 바뀌면 이 튜닝이 조용히 무효가 됩니다.

가만히 있는데 돈이 나간다

성능을 잡고 나니 다른 게 보였습니다. Cloud Run GPU는 요청이 끝난 뒤에도 최대 10분간 인스턴스가 살아 있고, 그 시간이 전액 과금됩니다.

재실행이 빠르라고 있는 동작입니다. 그런데 실측하니 잡당 비용의 38~56% 가 여기서 나갔습니다. 아무 일도 안 하는 구간이 비용의 절반 가까이입니다.

문제는 그 10분을 조절하는 설정이 없다는 것입니다. 컨테이너가 스스로 죽는 수밖에 없습니다.

임계값을 어디에 둘지 계산해봤습니다. 살려두는 시간과 모델을 읽는 시간이 같은 인스턴스 요율로 과금됩니다. 그러면 요율이 약분되고, 손익분기점은 곧 모델 적재 시간(6분 20초) 이 됩니다. 6분 20초 안에 다음 잡이 오면 살려두는 게 이득입니다.

그런데 조절 가능한 구간이 0~10분이고, 10분이 되면 어차피 Cloud Run이 GPU를 회수해갑니다. 조절 구간 대부분이 분기점 근처거나 아래입니다. 5분은 너무 짧아서 9분으로 잡았습니다.

# 큐가 비고 이 시간만큼 요청이 없으면 프로세스를 스스로 종료
IDLE_SHUTDOWN_SEC=540

보너스 함정이 하나 더 있었습니다. 웹 화면이 3초·5초마다 서버에 큐 상태를 물어봅니다. 그 요청이 잠든 인스턴스를 깨우면 아무 일도 안 하는데 돈이 나갑니다. 진행 중인 잡이 없으면 원격 쪽으로 아예 통신하지 않도록 고쳤습니다. 진행 중일 때도 해당 잡이 쓰는 서비스만 찔러봅니다.

프론트의 폴링 주기가 청구서를 만드는 구조였습니다.

큐는 단순합니다. GPU 1장에 잡 1건이니 서버에 단일 워커 큐를 두고 순서대로 처리합니다. 항목마다 무슨 요청인지 요약을 표시했습니다. 대기 중인 잡은 즉시 취소되고, 실행 중인 잡은 GPU 작업까지 끊습니다.

PIEmgmaker 실시간 대기열 패널 스크린샷. 맨 위 카드가 실행 중인 잡으로 오브젝트 컨셉과 캠페인 텍스트, 스타일 팩·모델·후보 장수·시드 요약이 적혀 있고, 우측에 생성 중 0/4 표시와 생성 중단 버튼, 하단에 진행률 막대가 있다. 그 아래 대기 중인 잡 두 건이 #1과 #2 순번을 달고 같은 요약 형식으로 나열되며 각각 우측에 취소 버튼이 붙어 있다.

백엔드 상태 확인이 연속 5회 실패하면 잡을 실패 처리합니다. 이게 없으면 죽은 백엔드를 붙들고 타임아웃 4시간을 다 채웁니다. 서버가 재시작되면 미완 잡을 자동으로 실패 처리합니다.


처음과 지금

원격은 왜 로컬보다 빠른가

튜닝을 끝내고 나니 생성 구간만 놓고는 로컬 96분 대 원격 1분 30초입니다. 이유는 셋입니다.

연산 회로가 다릅니다. 확산 모델이 하는 일 대부분이 거대한 행렬 곱셈입니다. NVIDIA 계열 GPU에는 이 연산 전담 회로가 따로 있고 낮은 정밀도에서 특히 빠릅니다. Apple Silicon GPU에는 대응 경로가 없어 범용 경로로 돕니다.

실제로 실행되는 코드가 다릅니다. 확산 모델 구현체와 커스텀 노드 대부분이 NVIDIA 기준으로 최적화되어 있습니다. macOS 백엔드는 미지원 연산이 CPU로 떨어지거나 느린 대체 경로를 탑니다.

메모리 여유가 다릅니다. 로컬 64GB는 OS와 다른 앱이 함께 쓰고, 모델이 경계에 걸려 있었습니다. 경계에 걸리면 페이지 조각을 밀어냈다 다시 올리는 동작이 계속 붙습니다. 원격은 모델 크기에 맞춰 서비스를 나눠서 이게 없습니다.

다만 원격 qwen은 L4 24GB에 맞추려 Q6_K로 한 단계 더 압축했습니다. 파일이 작으면 계산량도 줄어듭니다. 구조적으로 몇 배 빠른 건 맞지만 정밀한 측정은 아직 안 해봤습니다.

숫자로 보는 처음과 지금

로컬 (Mac mini M4)원격 첫 시도지금
잡 1건 소요약 96분59분 26초8분 25초
├ 컨테이너 기동--31초
├ 모델 적재수십 초 (로컬 SSD)50분 이상6분 20초
└ 생성대부분나머지약 1분 30초
잡당 비용0원 (전기 제외)$1.69$0.24
상시 비용0원월 $10 안팎월 $10 안팎
같은 사양 상시 운영이면--월 약 $4,036
작업 중 내 맥96분 내내 점유자유자유
모델 계열 전환엔진 재기동 필요서비스 분리로 불필요불필요
FLUX.2원본 정밀도 불가 (67GB)전용 서비스 (96GB GPU)전용 서비스
두 번째 잡--9분 안이면 적재 생략

로컬과 원격은 기기와 양자화 조건이 달라 절대 비교는 아닙니다.

PIEmgmaker 전체 구조 다이어그램. 세 층으로 구성되어 있다. 위층은 Next.js App Router 기반 웹으로 게이트·생성하기·리뷰 확정·히스토리·자산 라이브러리 페이지가 있고 큐 폴링은 진행 중 잡이 있을 때만 수행한다. 가운데층은 파이썬 패키지와 FastAPI 잡 서버로 스키마 검증·워크플로우 렌더·파이프라인 오케스트레이션·자산 라이브러리·능력 판정·골든 세트 러너가 들어 있고 별도 DB 없이 파일 기반 잡 저장소를 쓴다. 아래층은 WorkflowEngine Protocol 뒤에 LocalComfy와 RemoteComfy 두 구현체가 나란히 있다. 하단에는 결정론 구간과 생성 구간의 검증 방법 차이가 정리되어 있다.

구조는 파이썬 패키지 + 잡 큐 서버 + 웹 화면의 3부 구성입니다. 별도 DB가 없습니다. 잡 하나가 폴더 하나이고, 히스토리는 폴더 목록입니다. 개인 규모라 의존성을 더 붙이지 않았습니다.

웹은 게이트 하나로 통째로 잠급니다. 계정 체계 없이 암호 하나이고, 세션은 12시간입니다.

PIEmgmaker 게이트 화면 스크린샷. 짙은 녹색 배경 가운데에 체커보드 패턴 아이콘과 PIEmgmaker 로고타입이 있고, 그 아래 흰 카드에 배너 오브젝트 생성 스튜디오라는 안내와 암호 입력란, 입장 버튼이 배치되어 있다.

잡 폴더는 이렇게 생겼습니다.

PM_STORAGE/jobs/<job_id>/
├── payload.json               # 재현용 잡 페이로드 (버전 핀)
├── candidates/<i>_<seed>.png  # 엔진에서 갓 수확한 원본
├── final/<i>_<seed>.png       # 결정론 후처리를 마친 알파 PNG — QA는 이걸 본다
├── qa_report.json             # 후보별 룰 판정 + 재생성 힌트
├── status.json                # 상태 전이 기록
└── brief.yaml                 # 입력 브리프

payload.json에 워크플로우 해시와 모델 매니페스트, 스타일 팩 버전, 매팅 전략, 시드, 사이즈가 다 들어 있습니다. 히스토리의 “이 설정으로 다시 생성”이 이걸 그대로 재사용합니다.

PIEmgmaker 히스토리 화면 스크린샷. 상단 검색 입력란에 "증설"이 들어가 있고 스타일 팩 필터는 픽셀 아트로 걸려 있어, 두 조건을 모두 만족하는 기록만 남은 상태다. 아래 목록의 각 행에 체커보드 썸네일, 캠페인 텍스트, 오브젝트 컨셉, 스타일 팩 id와 생성 시각이 표시되고, 우측 끝에 DONE·FAILED·CANCELED 같은 상태 배지가 붙는다. 확정까지 끝난 항목은 DONE 옆에 확정 표시가 추가로 붙어 있다.

남은 것들

에셋이 장면에 안 녹습니다. 자산의 픽셀을 그대로 보존하라고 했더니, 그 말은 다시 말하면 그 로고만 주변과 따로 논다는 뜻입니다. 조명도 원근도 주변과 맞지 않습니다. 코어 해시를 바이트 단위로 잠근 대가입니다. 기술로 풀 문제가 아니라 정책으로 정할 문제에 가깝고, 아직 답을 못 정했습니다.

골든 세트는 아직 비어 있습니다. 틀은 만들어뒀는데 케이스를 못 채웠습니다. 생성 구간을 검증할 유일한 수단인데 말이죠.

로컬에서 다 만들어놓고 클라우드로 옮기기로 한 순서는 결과적으로 옳았습니다. 옮기는 과정에서 부딪힌 문제 대부분이 인프라 쪽이었고, 그때 파이프라인이 이미 안정적이었기 때문에 원인을 분리해서 볼 수 있었습니다. 두 가지를 동시에 흔들었다면 59분이 어디서 온 건지 영영 못 찾았을 겁니다.


참고 자료