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

무색

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

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

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

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

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
모델 로딩 30초를 없앤 방법 — bauhaus 스타일 키 비주얼
자막 파이프라인
2026. 1. 16.

모델 로딩 30초를 없앤 방법: Persistent Worker 서버

FastAPIGPUsubprocessNDJSONASR

30초씩 기다리는 게 문제였다

이전 글에서 음성 인식 파이프라인의 3개 GPU 모델(화자 분리, ASR, 강제 정렬)을 subprocess로 격리하는 패턴을 만들었다. 의존성 충돌 문제는 깔끔하게 해결됐는데, 한 가지 남은 게 있었다.

요청이 올 때마다 모델을 처음부터 로드하고, 끝나면 해제한다. ASR 모델인 Qwen2.5-7B는 로딩만 30초. 화자 분리와 정렬 모델까지 합치면 매 요청에 40초 넘게 모델 로딩으로 낭비된다. 실제 추론은 몇 초면 끝나는데.

TorchServe나 Triton 같은 모델 서빙 프레임워크를 쓸 수도 있었지만, 이미 subprocess 패턴이 잘 돌아가고 있었고 거기에 무거운 프레임워크를 얹고 싶지는 않았다. 기존 worker 코드를 최소한으로 바꿔서 모델을 메모리에 상주시키는 방법을 찾아야 했다.

Persistent Worker: 모델을 한 번만 로드하는 subprocess

기존 worker는 단순했다. stdin으로 JSON을 받고, 모델 로드하고, 처리하고, stdout으로 결과를 뱉고 죽는다. 이걸 --persistent 모드로 바꿨다. 모델을 한 번 로드한 뒤 stdin에서 계속 입력을 기다리는 구조다.

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(json.dumps({"segments": segments}), input_data.get("_output_path"))
        print("done", flush=True)  # 완료 시그널

핵심은 두 가지 텍스트 시그널이다. ready는 모델 로드 완료를 알리고, done은 요청 처리 완료를 알린다. gRPC 같은 RPC 프레임워크 없이 stdout에 텍스트 한 줄 찍는 것만으로 프로세스 간 통신이 된다.

stdout 오염 문제와 temp file IPC

처음에는 결과도 stdout으로 보냈다. 기존 one-shot worker가 그렇게 했으니까. 그런데 persistent 모드에서 문제가 터졌다. pyannote나 transformers 같은 라이브러리가 stdout에 progress bar나 로그를 찍어버린다. ready와 done 시그널 사이에 엉뚱한 텍스트가 끼어들면서 파싱이 깨졌다.

해결책은 stdout의 역할을 분리하는 거였다. 결과 데이터는 temp file에 쓰고, stdout은 시그널 전용으로 쓴다. 호출하는 쪽에서 요청 데이터에 temp file 경로를 주입하면, worker가 거기에 결과를 저장한다.

class PersistentWorker:
    def send_request(self, input_data: dict, log_queue) -> dict:
        input_data["_output_path"] = output_path  # temp file 경로 주입

        line = json.dumps(input_data) + "\n"
        self.proc.stdin.write(line.encode())
        self.proc.stdin.flush()

        self._done_event.wait(timeout=600)  # stdout에서 "done" 대기

        result = json.loads(Path(output_path).read_text())
        return result

채널 역할이 깔끔하게 나뉜다. stdin은 요청, stdout은 시그널, stderr는 로그, 결과는 temp file. 각 채널이 하나의 책임만 진다.

HTTP 서버로 감싸기: NDJSON 스트리밍

Persistent Worker를 로컬에서만 쓸 거면 여기서 끝이다. 그런데 나는 이 서버를 원격에서도 호출하고 싶었다. 로컬 PC의 파이프라인이 GPU 서버의 worker를 HTTP로 호출하는 구조. FastAPI로 얇은 HTTP 레이어를 씌웠다.

고민은 응답 형식이었다. GPU 모델은 처리에 수십 초~수 분 걸린다. 그동안 클라이언트가 멍하니 기다리게 할 수 없다. stderr에서 올라오는 로그를 실시간으로 전달해야 했다. SSE(Server-Sent Events)나 WebSocket도 고려했지만 NDJSON이 가장 단순했다. 줄 단위로 JSON을 보내면 클라이언트는 줄바꿈 기준으로 파싱하면 끝이다.

@app.post("/run/{worker_name}")
async def run_worker(worker_name, input_data):
    async def stream():
        async with _worker_lock:
            task = loop.run_in_executor(None, worker.send_request, ...)
            while not task.done():
                line = await asyncio.to_thread(log_queue.get, timeout=0.1)
                yield json.dumps({"type": "log", "line": line}) + "\n"
            result = await task
            yield json.dumps({"type": "result", "data": result}) + "\n"
    return StreamingResponse(stream(), media_type="application/x-ndjson")

여기서 asyncio.Lock이 중요한 역할을 한다. GPU는 동시에 여러 모델 요청을 처리하면 CUDA OOM이 터지거나 출력이 오염된다. Lock으로 한 번에 하나의 요청만 GPU를 사용하도록 직렬화한다.

done event의 race condition

가장 찾기 어려웠던 버그 얘기를 해야겠다.

PersistentWorker는 threading.Event로 done 시그널을 기다린다. 문제는 이전 요청의 done이 아직 Event에 남아 있을 때 발생한다. 새 요청을 보내기 전에 clear()를 안 하면 wait()가 즉시 반환돼 버린다. 아직 처리도 안 했는데 "완료"로 판단하는 거다.

순서가 반드시 clear() → stdin.write() → wait()여야 한다. 이 순서가 바뀌면 미묘한 타이밍 버그가 생긴다. 테스트에서는 잘 되다가 프로덕션에서 간헐적으로 빈 결과를 반환하는 식이었는데 원인을 찾느라 꽤 고생했다.

전체 구조

최종적으로 완성된 구조는 이렇다. 서버가 시작되면 GPU가 idle인지 확인하고, idle이면 3개 모델(diarize ~3GB, asr ~14GB, align ~1.2GB)을 모두 VRAM에 올린다. 총 ~17GB. RTX 5090의 32GB VRAM이면 넉넉하다.

  ┌──────────────────┐          ╔══════════════════════════════════╗
  │ Client           │          ║ GPU Server (asr-server:8891)     ║
  │ pipeline.py      │          ║ ╔══════════════════════════════╗ ║
  │ ╔══════════════╗ │  POST    ║ ║ worker_server.py (FastAPI)   ║ ║
  │ ║ remote.py    ║─┼─/run/──→║ ║  asyncio.Lock (1 GPU req)   ║ ║
  │ ║ HTTP client  ║ │ NDJSON  ║ ║                              ║ ║
  │ ║ log stream   ║←┼─stream──║ ║  ╔════════════════════════╗  ║ ║
  │ ╚══════════════╝ │          ║ ║  ║ PersistentWorker × 3  ║  ║ ║
  └──────────────────┘          ║ ║  ║ stdin: JSON line       ║  ║ ║
                                ║ ║  ║ stdout: ready/done     ║  ║ ║
                                ║ ║  ║ stderr: log stream     ║  ║ ║
                                ║ ║  ║ result: temp file      ║  ║ ║
                                ║ ║  ╚════════════════════════╝  ║ ║
                                ║ ╚══════════════════════════════╝ ║
                                ╚══════════════════════════════════╝
                                  diarize(~3GB) + asr(~14GB)
                                  + align(~1.2GB) = ~17GB VRAM

클라이언트(pipeline.py)의 remote.py가 httpx.stream()으로 NDJSON 응답을 실시간 파싱한다. 로그 줄이 올 때마다 화면에 바로 찍어주고, 마지막에 result 타입의 JSON이 오면 파싱해서 반환한다. GPU 서버에서 무슨 일이 일어나는지 클라이언트에서 실시간으로 볼 수 있다.

배운 것

이 작업을 하면서 느낀 건, GPU 모델 서빙에 꼭 거창한 프레임워크가 필요한 건 아니라는 점이다. TorchServe, Triton, vLLM 같은 도구는 멀티 GPU 스케줄링이나 배치 처리, 모델 버전 관리 같은 기능이 필요할 때 빛을 발한다. 하지만 "모델 3개를 메모리에 올려놓고 순차적으로 쓰겠다" 정도의 요구사항이라면 stdin/stdout 시그널 프로토콜과 FastAPI 한 겹이면 충분하다.

다만 주의할 점이 있다. 이 패턴이 잘 작동하는 조건은 worker가 stateless하고, GPU 요청이 직렬화돼야 할 때다. 동시 요청 처리가 필요하거나 모델 간 상태 공유가 필요하면 다른 방법을 찾아야 한다.

결국 문제는 모델 로딩 30초였고, 답은 "로딩을 한 번만 하면 된다"는 단순한 아이디어였다. 복잡한 건 그 아이디어를 안정적으로 구현하는 과정이었다.

자주 묻는 질문

Persistent Worker 패턴과 TorchServe의 차이는?
Persistent Worker는 stdin/stdout 시그널로 통신하는 경량 패턴이고, TorchServe는 멀티 GPU 스케줄링과 모델 버전 관리를 지원하는 본격 프레임워크입니다. 모델 수가 적고 순차 처리로 충분하면 Persistent Worker가 훨씬 간단합니다.
GPU 모델 서빙에서 stdout 오염 문제를 어떻게 해결하나요?
결과 데이터는 temp file에 쓰고 stdout은 시그널(ready/done) 전용으로 사용합니다. 채널 역할을 분리하면 라이브러리의 progress bar나 로그가 섞여도 파싱이 깨지지 않습니다.
threading.Event race condition을 어떻게 방지하나요?
반드시 clear() → stdin.write() → wait() 순서를 지켜야 합니다. 이전 요청의 done 시그널이 남아 있으면 wait()가 즉시 반환되어 빈 결과를 읽는 버그가 발생합니다.
자막 파이프라인(2/10)
Prev

GPU 모델 3개를 하나의 파이프라인에서: subprocess worker 격리 패턴

Next

Claude Code headless 모드의 JSON 파싱 함정 두 가지