
홈랩에 RTX 5090이 한 장 있다. VRAM 32GB. 이걸로 논문 번역(vLLM, 27B 모델), 이미지 생성(ComfyUI), 음성인식(Whisper), 화자분리(PyAnnote), 강제정렬(Forced Aligner), GPU 모니터링까지 6개 이상의 AI 서비스를 돌리고 있다.
물론 동시에 전부 올리진 못한다. vLLM 하나만 띄워도 VRAM 27GB를 먹고, ComfyUI 모델 로딩에 20GB가 날아간다. 그래서 GPU 하나를 시분할로 나눠 쓰는 전략이 필요했다. 2주간 여러 서비스를 만들면서 시행착오를 거쳤고, 그 과정에서 GPU 공유 패턴 4가지가 정리됐다.
각 서비스가 요구하는 VRAM을 나열하면 상황이 바로 보인다.
합계 65GB+. 32GB짜리 카드 한 장으로는 절대 동시에 못 올린다. 심지어 ASR 파이프라인은 화자분리 + 음성인식 + 강제정렬 세 모델을 순서대로 돌려야 하니까, 한 작업 안에서도 VRAM 관리가 필요하다.
가장 원시적인 방법이다. 한 서비스를 쓰고, 끄고, 다음 서비스를 띄운다. ASR 파이프라인을 처음 만들 때 이 방식으로 시작했다.
화자분리(3GB) 실행 후 모델을 명시적으로 해제하고, 음성인식(14GB)을 로딩하고, 끝나면 또 해제하고, 강제정렬(1.2GB)을 올린다. 핵심은 Python에서 모델을 지울 때 세 줄을 빠뜨리면 안 된다는 것.
del model
gc.collect()
torch.cuda.empty_cache()del만 하면 Python GC가 돌기 전까지 VRAM이 안 풀린다. gc.collect()로 즉시 수거하고, torch.cuda.empty_cache()로 CUDA 캐시까지 비워야 다음 모델이 올라갈 자리가 생긴다.
이 방법의 가장 큰 문제는 매번 모델 로딩 시간이 든다는 점이다. Whisper 7B 모델은 로딩에만 30초가 넘는다. 동영상 자막을 만들 때마다 화자분리 로딩 10초 + ASR 로딩 30초 + 정렬 로딩 5초, 모델 로딩에만 45초를 쓰게 된다.
순차 실행의 업그레이드 버전이다. ASR 파이프라인에서 세 모델(PyAnnote, Whisper, Forced Aligner)의 Python 의존성이 서로 충돌하는 문제가 터졌다. 같은 가상환경에서 세 모델을 import하면 torch 버전이 안 맞아서 에러가 난다.
Docker 컨테이너로 각각 감싸는 건 GPU 워크로드치고 오버헤드가 크다. 대신 각 모델을 독립 Python 패키지로 분리하고 자기만의 venv를 갖게 했다. 메인 파이프라인이 subprocess로 각 worker를 호출한다.
def run_worker(worker_name: str, input_data: dict) -> dict:
worker_dir = WORKERS_DIR / worker_name
venv_python = worker_dir / ".venv" / "bin" / "python"
proc = subprocess.Popen(
[str(venv_python), "-m", f"{worker_name}_worker"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# stdin: JSON 입력, stdout: JSON 결과, stderr: 로그
stdout, _ = proc.communicate(input=json.dumps(input_data).encode())
return json.loads(stdout)stdin으로 JSON을 보내고, stdout으로 결과를 받고, stderr는 실시간 로그로 쓰는 3채널 프로토콜이다. 프로세스가 종료되면 VRAM이 자동으로 반환되니까 수동으로 del/gc/empty_cache를 할 필요가 없어졌다.
이 방식은 의존성 충돌과 VRAM 해제 문제를 동시에 해결했다. 다만 근본적인 한계는 그대로다. 매번 프로세스를 새로 띄우고 모델을 로딩하는 시간.
모델 로딩 시간을 없앨 방법은 하나다. 모델을 VRAM에 상주시키는 것. ASR 파이프라인의 세 모델(3GB + 14GB + 1.2GB = 약 18GB)은 32GB 안에 동시에 올라간다. 그래서 세 모델을 서버 시작 시 미리 로딩해두고 HTTP 요청이 오면 바로 처리하는 persistent worker 서버를 만들었다.
각 worker를 --persistent 모드로 실행한다. 모델 로드가 끝나면 stdout에 "ready"를 출력하고, stdin에서 JSON 한 줄이 오면 처리 후 "done"을 보낸다.
def main_persistent():
model = Diarizer() # 모델 1회 로드, 이후 상주
print("ready", flush=True)
for line in sys.stdin:
input_data = json.loads(line.strip())
segments = model.run(input_data["wav_path"])
_write_result(segments, input_data.get("_output_path"))
print("done", flush=True) # 완료 시그널결과 데이터는 stdout 대신 임시 파일로 저장한다. stdout을 결과 전송에 쓰면 일부 라이브러리가 progress bar나 로그를 stdout에 뿌려서 JSON 파싱이 깨진다. stdout은 ready/done 시그널 전용으로 쓰고 실제 데이터는 파일로 분리하는 게 안전하다.
FastAPI로 HTTP 인터페이스를 감쌌다. asyncio.Lock으로 GPU 동시 접근을 막고, NDJSON 스트리밍으로 실시간 로그를 내려보낸다. 이렇게 하니 모델 로딩 시간이 사실상 0초가 됐다.
다만 이 패턴은 VRAM을 항상 점유한다. ASR 서버가 18GB를 물고 있으면 vLLM(27GB)이나 ComfyUI(20GB)는 올릴 수가 없다. 서비스 간 전환이 필요할 때는 다른 전략이 필요했다.
vLLM과 ComfyUI처럼 VRAM을 통째로 먹는 서비스는 상시 실행이 불가능하다. 필요할 때 켜고, 끝나면 끄는 온디맨드 패턴을 쓴다.
ComfyUI 관리 스크립트(comfyui_ctl.py)가 대표적인 예다. GPU가 유휴인지 확인 후 ComfyUI를 시작하고, 이미지 생성이 끝나면 프로세스를 종료해서 VRAM을 반납한다.
def cmd_start() -> int:
if check_comfyui():
print("ComfyUI is already running.")
return 0
gpu = check_gpu() # GPU API로 상태 확인
if not gpu.get("is_idle", False):
print("ERROR: GPU is busy.")
return 1
jupyter_send_command(COMFYUI_START_CMD) # JupyterLab 터미널로 시작
# health check 폴링 (5초 간격, 최대 60초)
for elapsed in range(0, 60, 5):
time.sleep(5)
if check_comfyui():
return 0여기서 핵심 질문이 나온다. "GPU가 유휴인지 어떻게 아는가?" 이게 다음 이야기의 출발점이다.
처음에는 단순했다. nvidia-smi를 subprocess로 호출해서 메모리 사용량이 0이면 유휴, 아니면 사용 중. 논문 번역 서버(zotero_server.py)의 첫 버전이 이 방식이었다.
문제는 금방 터졌다. vLLM이 모델을 로딩하는 중에는 메모리를 점진적으로 쓴다. 로딩 초기에 200MB만 쓰고 있으면 "유휴"로 오판해서 vLLM을 한 번 더 시작하려는 사태가 벌어졌다. 그러면 CUDA OOM으로 둘 다 죽는다.
메모리 양만으로는 부족하다. "지금 GPU에서 뭐가 돌고 있는지"를 알아야 한다. FastAPI로 GPU 상태 API 서버를 만들었다. nvidia-smi 출력에 더해서 /proc/PID/cmdline을 읽어 각 프로세스의 전체 커맨드라인을 확인한다.
async def _check_gpu_and_decide(self) -> GPUDecision:
status = await self._query_gpu_status() # GPU API 호출
if status.get("is_idle", False):
return GPUDecision.IDLE # 시작 가능
processes = status.get("processes", [])
for proc in processes:
if "vllm" in proc.get("command", "").lower():
return GPUDecision.VLLM_LOADING # 로딩 중이면 대기
return GPUDecision.BUSY # 다른 프로세스가 점유 중GPUDecision이라는 Enum으로 네 가지 상태를 정의했다. IDLE(비어있음, 시작 가능), VLLM_LOADING(vLLM이 로딩 중, 대기만), BUSY(다른 프로세스 점유, 시작 안 함), UNKNOWN(API 실패, 시작 안 함). 단순히 "쓰고 있냐 아니냐"가 아니라 "누가 쓰고 있고 뭘 하는 중이냐"까지 파악하는 게 핵심이다.
이 API의 is_idle 플래그 하나가 홈랩 전체의 GPU 스케줄링 판단 기준이 됐다. 번역 서버는 이 값을 보고 vLLM을 올릴지 결정하고, ComfyUI 컨트롤러도 이 값으로 시작 여부를 판단한다. 에이전트 팀의 comfy-agent도 이미지 생성 전에 GPU API를 확인한다.
GPU API를 만들고 나니 한 가지 욕심이 생겼다. 개발하면서 바탕화면에서 GPU 상태를 실시간으로 보고 싶다는 것. macOS WidgetKit으로 GPU 모니터 위젯을 만들었다.
60초마다 gpu.xssh.org의 /status API를 폴링해서 GPU 사용률, VRAM, 온도, 실행 중인 프로세스 목록을 보여준다. VRAM 사용률에 따라 초록(여유)/주황(주의)/빨강(위험)으로 색이 바뀌고, is_idle 플래그로 유휴 여부를 한눈에 파악할 수 있다.
func getTimeline(in context: Context, completion: @escaping (Timeline<GPUEntry>) -> Void) {
Task {
let entry = await fetchEntry()
let nextUpdate = Calendar.current.date(byAdding: .second, value: 60, to: entry.date)!
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}이 위젯 덕분에 "vLLM이 끝났나?" 궁금할 때 터미널을 열지 않아도 된다. 바탕화면 한 구석을 힐끗 보면 된다. 사소해 보이지만 GPU를 시분할로 쓰는 환경에서는 꽤 실용적이다.
GPU 서비스들은 WSL에서 돌아간다. K8s 클러스터의 VM에는 GPU가 패스스루되지 않으니까. 그런데 n8n 같은 K8s 서비스가 이 GPU 서비스들을 호출해야 한다. 이걸 어떻게 연결하느냐.
답은 수동 Endpoints 프록시 패턴이다. Pod selector 없는 Service와 수동 IP를 가리키는 Endpoints를 한 세트로 만든다. Windows portproxy로 WSL 포트를 LAN IP로 포워딩하고, Endpoints에 그 IP를 적는다. 이러면 K8s 안에서 클러스터 DNS로 GPU 서비스에 접근할 수 있다.
이 패턴을 GPU API, ComfyUI, ASR 서버, vLLM, Zotero 서버 5개에 반복 적용했다. 매니페스트가 거의 복붙 수준이 되어서 Helm 차트로 추상화하고 싶어지지만, 아직은 그냥 쓰고 있다.
ComfyUI는 idle 상태에서도 모델을 GPU 캐시에 유지한다. /interrupt로 작업을 중단해도 프로세스가 살아있으면 VRAM이 안 풀린다. 반드시 프로세스를 kill해야 한다. 이게 온디맨드 패턴에서 "쓰고 나면 반드시 끄기"가 중요한 이유다.
vLLM의 QPS를 무작정 올리면 KV cache가 터진다. 번역 동시성을 QPS 4에서 64로 올렸더니 VRAM이 부족해져서 OOM이 났다. --gpu-memory-utilization과 --max-model-len을 함께 조절해야 안정적으로 돌아간다.
Tailscale IP로 K8s Endpoints를 만들면 안 된다. K8s 노드에 Tailscale이 없으면 100.x.x.x 주소로 접근이 안 된다. 반드시 LAN IP(192.168.31.2)를 써야 한다. 같은 실수를 두 번 했다.
systemd에서 nvidia-smi가 안 보인다. WSL 일반 쉘에서는 PATH에 /usr/lib/wsl/lib/가 잡혀있지만 systemd user service는 다른 PATH를 쓴다. GPU API 서버를 만들자마자 1분 만에 fix 커밋을 날린 이유다. 절대경로를 쓰거나 Environment=PATH=...로 명시해야 한다.
2주간의 시행착오로 정리된 기준은 이렇다.
결국 GPU 공유의 핵심은 두 가지다. 첫째, GPU 상태를 정확히 아는 것. 메모리 사용량만으로는 부족하고 프로세스 수준까지 파악해야 한다. 둘째, 각 서비스의 특성에 맞는 전략을 고르는 것. 자주 쓰는 모델은 상주시키고, 가끔 쓰는 대형 모델은 온디맨드로 돌린다.
GPU 한 장으로도 꽤 많은 걸 할 수 있다. 다만 그러려면 "지금 GPU에서 뭐가 돌고 있는지" 항상 알아야 한다.