2026-06-29 (월) 개발일지
오늘의 트러블슈팅: 역프롬프트 템플릿 라이브러리 — 8개 컷 이미지 생성이 왜 한 번에 안 되는가
오늘은 propermarket-marketing 프로젝트에서 역프롬프트 템플릿 라이브러리 8개 과제 중 이미지 배치 생성 기능을 구현하면서 만난 병목을 추적했다. 처음엔 단순히 백엔드 SSE 지연 문제일 거라 생각했는데, 파고들어 보니 브라우저의 호스트당 HTTP/1.1 연결 제한(6개)이 진짜 원인이었고, 결국 백엔드 단일 SSE 종단점을 새로 만들어 해결했다.
이미지 배치 생성 오버레이 멈춤 — 브라우저 연결 제한이 숨긴 진짜 원인
증상: ImageBatchOverlay에서 “전체 이미지 생성(8컷)” 버튼을 누르면 비용 확인 다이얼로그는 정상적으로 떴다(8개 컷 … 예상 ~$0.24). 하지만 생성이 시작되면 2~3개 컷만 완료되고 나머지 5~6개가 “생성 중…” 상태에서 멈췄다. useJobStream이 SSE로 done 이벤트를 받지 못해 awaitJob이 영원히 resolve되지 않는 패턴이었다.
첫 가설: 백엔드 generate_keyframe.py에서 subprocess가 동시에 8개 뜨면서 리소스 경합(VRAM OOM 등)이 일어난 거라 생각했다. nvidia-smi로 보려 했지만 개발 환경이 컨테이너라 제대로 안 보였다. 대신 generate_keyframe.py 안에 try-catch 로그를 박고, 각 subprocess에 timeout=120을 추가해 봤다 — 증상 변함 없음.
가설 붕괴: Worker "keyframe" unexpected termination 로그는 전혀 없었다. 8개 subprocess는 정상 종료되고 각각 SSE done을 emit하고 있었다. 문제는 프론트엔드에서 EventSource가 그 이벤트를 못 받고 있었다.
진짜 원인 추적: 이 시점에서 CutInspector.tsx의 startKeyframe()을 다시 봤다.
const startKeyframe = (slug: string, body: CutKeyframeRequest) => {
const jobId = `kf-${crypto.randomUUID()}`;
const es = new EventSource(`/api/jobs/stream/${jobId}?cutId=${body.cutId}`);
// ...
};
컷 하나당 EventSource 인스턴스 1개. 8개 컷이면 동시에 8개의 SSE 연결이 열린다. 브라우저는 HTTP/1.1에서 호스트당 최대 6개의 동시 연결만 허용한다(HTTP/2는 100개지만 내부 인프라는 HTTP/1.1이었다). 7번째 EventSource는 요청이 큐에 들어가고, readyState가 CONNECTING으로 멈추면서 done 이벤트를 영영 못 받는다.
결정적 발견: “백엔드 처리 속도”가 아니라 “프론트엔드 연결 용량”이 병목이었다.
EventSource8개가 HTTP/1.1 호스트당 6개 제한에 걸려 2~3개는 아예 서버에 도착조차 못 하고 있었다.
해결 — 단일 SSE 연결 + 진행률 배열: 여러 해법을 비교했다:
| 접근 | 평가 |
|---|---|
| 연결 풀링(최대 6개 유지, 2개는 큐 대기) | 구현 복잡, 타임아웃 처리 어려움 |
| HTTP/2 강제 전환 | 인프라 레벨 변경, 당장 불가 |
백엔드 단일 SSE 종단점 + progress[] emit |
✅ 최소 변경, 기존 useJobStream 호환 |
채택한 건 백엔드에 /api/jobs/stream-all 종단점을 추가하는 것이었다. 8개 잡 ID를 배열로 받아 단일 SSE에서 { jobId, status, progress } 배열을 emit한다. 프론트엔드는 EventSource 1개만 열고, 받은 배열을 순회하며 각 컷의 상태를 업데이트한다.
변경 파일:
pipeline/server.py:/api/jobs/stream-all라우트 신규 —asyncio.gather로 모든 subprocess를 기다리되, 완료될 때마다 배열에 결과를 추가해 emit_workspace/gui/src/components/ImageBatchOverlay.tsx: 기존cutIds.map(startKeyframe)대신fetch('/api/jobs/stream-all', { body: JSON.stringify(cutIds) })→ 단일EventSource_workspace/gui/src/hooks/useJobStream.ts: 배열 응답 처리 로직 추가 (payload.jobId로 개별 컷 식별)
검증: tsc --noEmit 0 에러. 더미 런으로 8개 컷 batch 요청을 보내 Playwright로 오버레이 상태를 캡처했다 — 모든 컷이 "완료" 상태로 전환됐고, 브라우저 DevTools 네트워크 탭에서 EventSource 연결이 1개만 열린 걸 확인했다.
남은 과제: stream-all에서 완료된 잡의 마지막 done 이벤트를 SSE가 replay할 수 없는 아키텍처 한계가 있다. 프론트엔드가 연결을 늦게 맺으면 이전에 완료된 잡의 done을 놓칠 수 있다. 이건 Last-Event-ID 헤더로 히스토리를 재전송하는 로직으로 다음 스프린트에 보강할 예정.
오늘의 한줄
“병목은 항상 가장 느린 컴포넌트가 아니라, 가장 좁은 연결에서 온다 — HTTP/1.1 연결 6개 제한 덕에 8개 SSE가 동시에 죽는 줄 몰랐다.”