
논문 번역 파이프라인에서 vLLM을 원격으로 관리하는 첫 버전은 단순했다. nvidia-smi로 GPU 메모리를 확인하고 0 MiB이면 "유휴"로 판단해서 vLLM을 시작하는 방식이었다. 실시간 진행률 시스템을 만들고 원격 GPU 관리까지 구현한 직후라 꽤 만족하고 있었는데, 며칠 안 가서 문제가 터졌다.
vLLM은 모델을 로딩하는 데 수 분이 걸린다. 로딩 초반에는 GPU 메모리 사용량이 아주 조금만 올라간 상태다. 이때 번역 요청이 또 들어오면? nvidia-smi는 "아직 메모리가 적으니 유휴"라고 판단하고, 시스템은 vLLM을 한 번 더 시작한다.
결과는 뻔하다. GPU 메모리가 두 배로 소비되면서 OOM(Out of Memory)이 나거나, 두 인스턴스가 서로 포트를 뺏으며 둘 다 죽는다. 메모리 사용량이라는 단일 지표로는 "로딩 중"과 "유휴"를 구분할 수 없다는 게 근본 원인이었다.
그리고 또 다른 문제도 있었다. ComfyUI처럼 다른 프로세스가 GPU를 쓰고 있으면 어떻게 해야 하나? nvidia-smi는 "메모리 쓰고 있음"이라고만 알려줄 뿐 누가 쓰는지는 말해주지 않는다.
해결 방향은 명확했다. 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과 나머지를 구분하는 게 전부였다. 이제는 "로딩 중"이라는 상태가 생겨서 중복 시작 문제가 원천 차단된다. 프로세스의 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 # 다른 프로세스 점유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를 다른 프로세스가 가져간 경우에도 대응할 수 있다.
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 레이트 리밋이든, 단순한 숫자 하나로 상태를 판단하다 보면 결국 엣지 케이스에서 터진다. 리소스 상태를 제대로 모델링하는 데 드는 시간은 삽질로 날릴 시간보다 항상 적다.