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

무색

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

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

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

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

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
vLLM이 두 번 뜨는 날: nvidia-smi에서 GPU API로 — sumi-e 스타일 키 비주얼
논문 번역기
2026. 1. 12.

vLLM이 두 번 뜨는 날: nvidia-smi에서 GPU API로

vLLMGPUPython상태머신리팩토링

논문 번역 파이프라인에서 vLLM을 원격으로 관리하는 첫 버전은 단순했다. nvidia-smi로 GPU 메모리를 확인하고 0 MiB이면 "유휴"로 판단해서 vLLM을 시작하는 방식이었다. 실시간 진행률 시스템을 만들고 원격 GPU 관리까지 구현한 직후라 꽤 만족하고 있었는데, 며칠 안 가서 문제가 터졌다.

vLLM이 두 번 뜬다

vLLM은 모델을 로딩하는 데 수 분이 걸린다. 로딩 초반에는 GPU 메모리 사용량이 아주 조금만 올라간 상태다. 이때 번역 요청이 또 들어오면? nvidia-smi는 "아직 메모리가 적으니 유휴"라고 판단하고, 시스템은 vLLM을 한 번 더 시작한다.

결과는 뻔하다. GPU 메모리가 두 배로 소비되면서 OOM(Out of Memory)이 나거나, 두 인스턴스가 서로 포트를 뺏으며 둘 다 죽는다. 메모리 사용량이라는 단일 지표로는 "로딩 중"과 "유휴"를 구분할 수 없다는 게 근본 원인이었다.

그리고 또 다른 문제도 있었다. ComfyUI처럼 다른 프로세스가 GPU를 쓰고 있으면 어떻게 해야 하나? nvidia-smi는 "메모리 쓰고 있음"이라고만 알려줄 뿐 누가 쓰는지는 말해주지 않는다.

GPU API 서버 구축

해결 방향은 명확했다. GPU 상태를 "메모리 사용량"이 아니라 "프로세스 수준"으로 올려서 봐야 한다. 그래서 GPU 서버에 별도 HTTP API를 하나 만들었다.

GET /status HTTP/1.1
Authorization: Bearer gpu-widget-2026
Response:
{
  "is_idle": false,
  "memory_used_mib": 8234,
  "processes": [
    {"pid": 12345, "command": "python -m vllm.entrypoints...", "memory_mib": 8200}
  ]
}

핵심은 processes 필드다. 메모리가 사용 중이라는 사실뿐 아니라 어떤 프로세스가 쓰고 있는지를 알 수 있다. is_idle은 프로세스가 0개일 때 true다.

네 가지 상태로 모델링하기

GPU API의 응답을 받으면 네 가지 상태 중 하나로 분류한다.

  • IDLE - GPU가 비어 있다. vLLM을 시작한다.
  • VLLM_LOADING - vLLM이 이미 로딩 중이다. 새로 시작하지 말고 기다리기만 한다.
  • BUSY - ComfyUI 같은 다른 프로세스가 GPU를 점유하고 있다. 시작하지 않는다.
  • UNKNOWN - GPU API 자체가 응답하지 않는다. 역시 시작하지 않는다.

이전에는 IDLE과 나머지를 구분하는 게 전부였다. 이제는 "로딩 중"이라는 상태가 생겨서 중복 시작 문제가 원천 차단된다. 프로세스의 command 필드에서 vllm이나 translategemma 키워드가 보이면 VLLM_LOADING으로 분류하는 단순한 로직인데 개인 서버 환경에서는 충분하다.

async def _check_gpu_and_decide(self) -> GPUDecision:
    status = await self._query_gpu_status()
    if status is None:
        return GPUDecision.UNKNOWN
    if status.get("is_idle", False):
        return GPUDecision.IDLE
    processes = status.get("processes", [])
    vllm_keywords = ("vllm", "translategemma")
    for proc in processes:
        command = proc.get("command", "")
        if any(kw in command.lower() for kw in vllm_keywords):
            return GPUDecision.VLLM_LOADING  # 이미 로딩 중
    return GPUDecision.BUSY  # 다른 프로세스 점유

Template Method로 Remote/Local 통합

GPU 상태 판단 로직은 원격이든 로컬이든 동일하다. 다른 건 vLLM을 실제로 시작하는 방법뿐이다. 원격 서버는 Tailscale IP로 JupyterLab WebSocket 터미널에 명령을 보내고, 로컬은 localhost의 JupyterLab을 쓴다.

이 구조가 딱 Template Method 패턴이다. VLLMManager라는 추상 클래스에 공통 흐름을 정의하고 _start_vllm()만 서브클래스에서 구현하게 했다.

async def ensure_ready(self) -> None:
    if await self.health_check():
        return  # 이미 준비됨
    decision = await self._check_gpu_and_decide()
    if decision == GPUDecision.IDLE:
        await self._start_vllm()         # 서브클래스 구현
    elif decision == GPUDecision.VLLM_LOADING:
        await self._wait_for_ready()      # 폴링만
    elif decision == GPUDecision.BUSY:
        logger.warning("GPU busy — skipping")
    else:
        logger.warning("GPU API unavailable — skipping")

흐름을 보면 health check가 성공하면 바로 리턴, 실패하면 GPU 상태를 확인해서 IDLE이면 시작하고 VLLM_LOADING이면 기다리고 나머지는 건너뛴다. 이 로직이 RemoteVLLMManager와 LocalVLLMManager 양쪽에서 공유된다.

한 가지 변경점이 더 있다. 이전에는 서버가 처음 시작될 때만 vLLM 상태를 확인했는데 이제는 매 번역 요청마다 ensure_ready()를 호출한다. 서버가 떠 있는 동안 vLLM이 죽거나 GPU를 다른 프로세스가 가져간 경우에도 대응할 수 있다.

subprocess에서 JupyterLab 터미널로

LocalVLLMManager를 처음에는 subprocess.Popen으로 만들었다. vLLM을 자식 프로세스로 띄우는 직관적인 방법이었는데, 치명적인 문제가 있었다. 번역 서버가 재시작되면 자식 프로세스인 vLLM도 함께 죽는다는 것.

vLLM 모델 로딩에 수 분이 걸리기 때문에 서버 재시작 때마다 그 시간을 기다려야 하는 건 받아들이기 어렵다. 원격 서버는 이미 JupyterLab 터미널로 관리하고 있었으니 로컬도 같은 방식으로 바꿨다. JupyterLab 터미널에서 시작한 프로세스는 번역 서버 프로세스와 독립적으로 살아남는다.

파일 다운로드 후 자동 삭제

번역된 PDF를 다운로드한 뒤에는 서버에 파일을 남겨둘 이유가 없다. 처음에는 다운로드 직후 동기적으로 삭제했는데 응답이 중간에 끊기는 문제가 생겼다. 파일을 다 보내기 전에 지워버린 거다.

Starlette의 BackgroundTask가 정확히 이런 상황을 위한 기능이다. 응답 전송이 완전히 끝난 뒤에 비동기로 정리 작업을 실행해준다.

@app.get("/translatedFile/{filename}")
async def download_file(filename: str, request: Request):
    if request.method == "GET":
        background = BackgroundTask(_delete_output_file, path)
        return FileResponse(path, filename=filename, background=background)
    return FileResponse(path, filename=filename)  # HEAD는 삭제 안 함

HEAD 요청은 파일 존재 여부만 확인하는 용도이므로 삭제하면 안 된다. GET일 때만 BackgroundTask를 붙인다.

정리하며

이번 작업의 핵심은 추상화 수준을 한 단계 올린 것이다. GPU 메모리 사용량이라는 원시 지표에서 "이 GPU에서 뭐가 돌고 있는지"라는 의미 있는 상태로 바꿨다. 상태 머신으로 모델링하니 엣지 케이스(로딩 중 재시작, 다른 프로세스 충돌, API 장애)가 자연스럽게 분기 하나씩으로 정리됐다.

GPU뿐 아니라 공유 리소스를 자동으로 관리할 때 반복되는 패턴이라고 생각한다. DB 커넥션 풀이든 외부 API 레이트 리밋이든, 단순한 숫자 하나로 상태를 판단하다 보면 결국 엣지 케이스에서 터진다. 리소스 상태를 제대로 모델링하는 데 드는 시간은 삽질로 날릴 시간보다 항상 적다.

자주 묻는 질문

nvidia-smi로 GPU 상태를 확인하면 안 되나요?
nvidia-smi는 VRAM 사용량만 보여주기 때문에, 모델 로딩 중인 상태와 실제 추론 중인 상태를 구분할 수 없습니다. GPU API를 사용하면 프로세스 수준에서 상태를 구분할 수 있습니다.
vLLM이 두 번 뜨는 원인은 무엇인가요?
nvidia-smi 기반 판단은 모델 로딩 초기에 VRAM이 0인 구간이 있어 유휴로 오판합니다. 이 틈에 두 번째 인스턴스가 시작되면서 OOM이 발생합니다.
GPU API 상태 머신의 VLLM_LOADING 상태는 어떻게 동작하나요?
GPU API에서 is_idle=false이고 프로세스 목록에 vllm 키워드가 있으면 VLLM_LOADING으로 판단하고, 새 인스턴스를 시작하지 않고 기존 인스턴스의 health check만 반복합니다.
논문 번역기(7/8)
Prev

번역 버튼 누르고 20분, 지금 뭘 하고 있는 건지 알고 싶었다

Next

번역 파이프라인 병렬화: asyncio.gather()와 Claude Code stream-json 파싱 수정