무색
기술블로그
에세이
연구
프로덕트
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

[email protected]

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관
INCHEON, KR
Post #62 Key Visual — sumi-e style
크로스커팅
2026. 1. 29.

RTX 5090 하나로 AI 서비스 6개 돌리기: GPU 공유 전략

GPUVRAMRTX-5090homelabMLOps

홈랩에 RTX 5090이 한 장 있다. VRAM 32GB. 이걸로 논문 번역(vLLM, 27B 모델), 이미지 생성(ComfyUI), 음성인식(Whisper), 화자분리(PyAnnote), 강제정렬(Forced Aligner), GPU 모니터링까지 6개 이상의 AI 서비스를 돌리고 있다.

물론 동시에 전부 올리진 못한다. vLLM 하나만 띄워도 VRAM 27GB를 먹고, ComfyUI 모델 로딩에 20GB가 날아간다. 그래서 GPU 하나를 시분할로 나눠 쓰는 전략이 필요했다. 2주간 여러 서비스를 만들면서 시행착오를 거쳤고, 그 과정에서 GPU 공유 패턴 4가지가 정리됐다.

문제: 32GB로는 부족하다

각 서비스가 요구하는 VRAM을 나열하면 상황이 바로 보인다.

  • vLLM (TranslateGemma 27B): ~27GB
  • ComfyUI (z_image_turbo): ~20GB
  • Whisper ASR (VibeVoice-7B): ~14GB
  • PyAnnote 화자분리: ~3GB
  • Forced Aligner (Qwen2.5): ~1.2GB
  • GPU API (모니터링): GPU 점유 없음

합계 65GB+. 32GB짜리 카드 한 장으로는 절대 동시에 못 올린다. 심지어 ASR 파이프라인은 화자분리 + 음성인식 + 강제정렬 세 모델을 순서대로 돌려야 하니까, 한 작업 안에서도 VRAM 관리가 필요하다.

전략 1: 순차 실행과 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초를 쓰게 된다.

전략 2: subprocess 격리

순차 실행의 업그레이드 버전이다. 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 해제 문제를 동시에 해결했다. 다만 근본적인 한계는 그대로다. 매번 프로세스를 새로 띄우고 모델을 로딩하는 시간.

전략 3: persistent worker HTTP 서버

모델 로딩 시간을 없앨 방법은 하나다. 모델을 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)는 올릴 수가 없다. 서비스 간 전환이 필요할 때는 다른 전략이 필요했다.

전략 4: 온디맨드 로딩/언로딩

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에서 GPU API로

v1: nvidia-smi 파싱

처음에는 단순했다. nvidia-smi를 subprocess로 호출해서 메모리 사용량이 0이면 유휴, 아니면 사용 중. 논문 번역 서버(zotero_server.py)의 첫 버전이 이 방식이었다.

문제는 금방 터졌다. vLLM이 모델을 로딩하는 중에는 메모리를 점진적으로 쓴다. 로딩 초기에 200MB만 쓰고 있으면 "유휴"로 오판해서 vLLM을 한 번 더 시작하려는 사태가 벌어졌다. 그러면 CUDA OOM으로 둘 다 죽는다.

v2: GPU API + 프로세스 분석

메모리 양만으로는 부족하다. "지금 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를 확인한다.

macOS 위젯으로 GPU 상태 모니터링

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를 시분할로 쓰는 환경에서는 꽤 실용적이다.

WSL에서 K8s까지: 네트워크 통합

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주간의 시행착오로 정리된 기준은 이렇다.

  • 순차 실행: 프로토타이핑 단계에서 빠르게 검증할 때. 코드가 단순하고 별도 인프라가 필요 없다.
  • subprocess 격리: 모델 간 의존성이 충돌하거나 VRAM 해제를 보장해야 할 때. 프로세스 종료가 곧 VRAM 반납이다.
  • persistent worker: 동일 모델을 반복 호출하고 응답 지연이 중요한 서비스. 모델 로딩 0초의 쾌감이 있다.
  • 온디맨드 로딩: VRAM을 통째로 먹는 서비스(vLLM, ComfyUI)를 다른 서비스와 교대로 쓸 때. GPU API + 상태 머신이 필수다.

결국 GPU 공유의 핵심은 두 가지다. 첫째, GPU 상태를 정확히 아는 것. 메모리 사용량만으로는 부족하고 프로세스 수준까지 파악해야 한다. 둘째, 각 서비스의 특성에 맞는 전략을 고르는 것. 자주 쓰는 모델은 상주시키고, 가끔 쓰는 대형 모델은 온디맨드로 돌린다.

GPU 한 장으로도 꽤 많은 걸 할 수 있다. 다만 그러려면 "지금 GPU에서 뭐가 돌고 있는지" 항상 알아야 한다.

자주 묻는 질문

RTX 5090 하나로 AI 서비스를 여러 개 돌릴 수 있나요?
동시 실행은 VRAM 한계로 불가능하지만, 순차 실행·subprocess 격리·persistent worker·온디맨드 로딩 4가지 전략을 서비스 특성에 맞게 적용하면 6개 이상의 AI 서비스를 운영할 수 있습니다.
GPU가 유휴 상태인지 자동으로 확인하는 방법은?
nvidia-smi 메모리 확인만으로는 모델 로딩 중 오판이 발생합니다. GPU API 서버를 만들어 프로세스 cmdline까지 분석하고 IDLE/LOADING/BUSY 상태를 구분하는 것이 안정적입니다.
GPU 모델을 메모리에 상주시키면서 VRAM을 효율적으로 관리하려면?
자주 쓰는 소형 모델은 persistent worker로 상주시키고, 대형 모델은 온디맨드로 필요할 때만 로딩/언로딩하는 혼합 전략이 효과적입니다.
크로스커팅(16/18)
Prev

9개 프로젝트에서 발견한 Config as Code의 공통 원칙

Next

2주간 20개 프로젝트에서 만난 삽질 패턴 총정리