📄 v1개정 이력 보기
Voronoi GPU 서비스 동시성 위험 분석 및 대비책 — localhost 버그·이벤트 루프 블로킹·VRAM 경쟁
초록
photo.hyperbook.com의 Voronoi GPU 서비스(hb5u RTX 5060)에서 발견된 세 가지 문제를 분석한다. (1) GPU_API가 localhost로 하드코딩되어 외부 접속 시 GPU 경로 전체가 실패하는 버그, (2) 단일 uvicorn 워커에서 동기 render_voronoi_gpu가 이벤트 루프를 블로킹하여 동시 요청이 큐잉·타임아웃되는 구조적 문제, (3) 다중 출처(photo.hyperbook.com, hb5u.hyperbook.com)에서 동시에 대용량 이미지를 처리할 때 VRAM 고갈 위험. 각 문제의 원인을 도식화하고 단기·중기 대비책을 제시한다.
1. 시스템 구조 개요
[사용자 브라우저]
│
├─ photo.hyperbook.com ─┐
│ ├─→ nginx(hb5u) → /photo/ → React dist (VoronoiConverter.tsx)
└─ hb5u.hyperbook.com ─┘
│
GPU_API 요청 (POST /api/voronoi)
│
nginx(hb5u) → localhost:8892
│
voronoi_gpu_service.py (single process)
│
RTX 5060 CUDA
2. 문제 1 — GPU_API localhost 버그
원인
// VoronoiConverter.tsx line 5-6
const GPU_API = 'http://localhost:8892/api/voronoi';
const GPU_HEALTH_API = 'http://localhost:8892/api/voronoi/health';
브라우저에서 localhost는 사용자 PC(클라이언트)를 가리킨다. hb5u(서버)가 아니다.
실패 흐름
외부 사용자 브라우저
│
└─→ fetch('http://localhost:8892/api/voronoi')
│
└─→ 사용자 PC:8892 (포트 없음) → ERR_CONNECTION_REFUSED
│
catch(e) → setDone(false) 유지
│
┌───────────┴────────────┐
│ │
PDF 버튼 미표시 "Moojoco Offline" 표시
영향
| 증상 | 원인 |
|---|---|
| PDF/PNG/Number 버튼 미표시 | done=false 유지 (GPU 경로 실패) |
| "Moojoco Offline" 표시 | GPU_HEALTH_API도 localhost → 3초 타임아웃 → gpuOnline=false |
| cellCount < 3000만 정상 | CPU 경로(d3-delaunay)는 GPU_API 미사용 |
수정 (단 2줄)
const GPU_API = 'https://hb5u.hyperbook.com/api/voronoi';
const GPU_HEALTH_API = 'https://hb5u.hyperbook.com/api/voronoi/health';
3. 문제 2 — 이벤트 루프 블로킹
현재 구조
# voronoi_gpu_service.py
async def voronoi_endpoint(...): # FastAPI async 엔드포인트
...
result = render_voronoi_gpu(pix, ...) # ← 동기 함수 직접 호출!
# render_voronoi_gpu: CPU Sobel + GPU JFA + cp.asnumpy → ~50초
uvicorn 단일 워커 + async 엔드포인트에서 동기 함수를 직접 호출 → asyncio 이벤트 루프 블로킹.
동시 요청 시 타임라인
t=0s 요청 A 도착 → render_voronoi_gpu 시작 (이벤트 루프 점유)
t=1s 요청 B 도착 → 이벤트 루프 블로킹 중 → 수락 불가 → 큐 대기
t=50s 요청 A 완료 → 응답 반환
t=51s 요청 B 처리 시작
t=101s 요청 B 완료
∴ 요청 B 총 대기: 101초 → 브라우저 기본 타임아웃(~60초) 초과 → 실패
VRAM 경쟁 시나리오 (이벤트 루프 블로킹이 해결된 경우)
요청 A: 4000×3000 이미지 → VRAM ~1.5GB 점유
요청 B: 동시에 4000×3000 → VRAM 추가 ~1.5GB
요청 C: 추가 요청 → VRAM 부족 → cupy OOM → 500 에러
RTX 5060 VRAM: 7707MB
현재 여유: 6217MB
안전 처리 가능: 동시 4건 (1500MB × 4 = 6000MB) — 이론치
4. 문제 3 — 다중 출처 중복 요청
출처별 접근 경로
photo.hyperbook.com ─┐
├─→ 동일 nginx → 동일 dist/ → 동일 GPU 서비스
hb5u.hyperbook.com ──┘
∴ 두 도메인은 같은 GPU 엔드포인트를 공유
→ 사용자 A(photo)와 사용자 B(hb5u)가 동시 요청 가능
→ 같은 사용자가 탭을 두 개 열거나 Re-apply를 연속 클릭해도 중복 발생
중복 요청 흐름
사용자가 Re-apply 연속 클릭
│
├─ 요청 1 → GPU 처리 중 (50초)
└─ 요청 2 → 이벤트 루프 블로킹으로 대기
(또는 스레드풀 해결 후엔 VRAM 경쟁)
5. 대비책
단기 (즉시 적용 가능)
① GPU_API URL 수정 ← 현재 Haru에게 요청 완료
② asyncio.Semaphore로 동시 처리 수 제한
import asyncio
_gpu_sem = asyncio.Semaphore(1) # 동시 1건
async def voronoi_endpoint(...):
async with _gpu_sem:
result = await asyncio.get_event_loop().run_in_executor(
None, render_voronoi_gpu, pix, cell_count, show_edges, edge_opacity, edge_color
)
run_in_executor: 동기 함수를 스레드풀에서 실행 → 이벤트 루프 해방Semaphore(1): 동시 1건만 허용, 나머지는 대기
③ 대기 중 503 반환 (큐 초과 시)
_gpu_sem = asyncio.Semaphore(2) # 최대 2건 동시
_queue_limit = 3
async def voronoi_endpoint(...):
if _gpu_sem._value == 0 and _waiting >= _queue_limit:
raise HTTPException(503, "GPU busy, try again later")
async with _gpu_sem:
...
④ 프론트엔드 중복 요청 방지
const abortRef = useRef<AbortController | null>(null);
const process = useCallback(async (url, s) => {
abortRef.current?.abort(); // 이전 요청 취소
abortRef.current = new AbortController();
const res = await fetch(GPU_API, {
method: 'POST', body: form,
signal: abortRef.current.signal,
});
...
}, []);
중기
| 방안 | 효과 |
|---|---|
uvicorn --workers 1 명시 + Semaphore |
안정적 단일 처리 보장 |
| VRAM 점유 추정 후 처리 거부 | OOM 방지 |
| 요청 ID 발급 → 폴링 패턴 | 긴 처리 중 타임아웃 방지 |
| max_dim 동적 조정 (동시 요청 多 → 해상도 ↓) | 처리 시간 단축 |
6. 현재 상태 요약
| 항목 | 상태 |
|---|---|
| GPU_API localhost 버그 | 🔴 수정 요청 완료 (Haru) |
| 이벤트 루프 블로킹 | 🟡 Semaphore+executor 미적용 |
| 다중 출처 중복 요청 | 🟡 클라이언트 AbortController 미적용 |
| VRAM OOM 방지 | 🟡 미적용 (VRAM 6217MB 여유 — 현재는 안전) |
