🦀

include_str!() 패턴 — Rust 바이너리에 Python ML을 임베딩하는 방법

Nightingale가 보여준 실용적 타협 — 단일 바이너리 배포, 영속 프로세스 통신, 버전 불일치 제거

Nightingale(Rust製カラオ케アプリ)のコードで발견한 패턴이다. Rust에서 Python ML 스택을 다뤄야 하는 상황에서 쓸 수 있는 실전 아키텍처.

문제: Rust 앱에서 Python ML을 어떻게 돌리나

Nightingale은 UVR/Demucs(음성 분리)와 WhisperX(음성 인식)를 써야 한다. 둘 다 PyTorch 기반이고, Python 생태계에서만 제대로 돌아간다.

일반적인 선택지와 각각의 문제:

시스템 Python에 의존 — 사용자마다 Python 버전이 다르고, 패키지 충돌이 일상이다. conda, pyenv, system python이 뒤섞인 환경에서 "내 컴에서는 되는데"가 반복된다.

Python 스크립트를 별도 파일로 배포 — 파일 누락, 경로 꼬임, 사용자가 실수로 삭제하는 문제. 배포할 때 .py 파일을 빠뜨리면 런타임에 터진다.

PyO3로 Rust↔Python 바인딩 — 단순한 함수 호출은 괜찮지만, PyTorch + WhisperX + audio-separator 같은 무거운 ML 스택을 바인딩하는 건 의존성 지옥이다. 빌드 환경마다 달라지고, GIL 문제도 있다.

모든 걸 Rust로 재작성 — 비현실적이다. ML 생태계(PyTorch, Hugging Face, ONNX Runtime)가 Python에 집중되어 있다. 같은 모델을 Rust에서 돌리려면 인퍼런스 파이프라인을 처음부터 짜야 한다.

Nightingale의 해법: include_str!() + 영속 프로세스

// app-core/src/vendor_scripts.rs
const ANALYZE_PY: &str = include_str!("../analyzer/analyze.py");
const SERVER_PY: &str = include_str!("../analyzer/server.py");
const PIPELINE_PY: &str = include_str!("../analyzer/pipeline.py");
const STEMS_PY: &str = include_str!("../analyzer/stems.py");
const TRANSCRIBE_PY: &str = include_str!("../analyzer/transcribe.py");
// ... 10개 Python 파일

Rust의 include_str!() 매크로는 컴파일 시점에 파일 내용을 문자열 상수로 바이너리에 포함시킨다. 런타임에 파일을 읽는 게 아니다.

작동 흐름

빌드 시점:

cargo build → stems.py 등 10개 .py 파일이 문자열로 바이너리에 포함
→ 배포 파일은 nightingale 실행 파일 하나

첫 실행 시 (부트스트랩):

1. ~/.nightingale/vendor/ 디렉토리 생성
2. 임베딩된 .py 문자열을 파일로 추출 (vendor/analyzer/stems.py 등)
3. uv로 Python 3.10 설치
4. uv venv + uv pip install로 PyTorch, WhisperX 등 설치
5. .ready 마커 파일 생성

분석 실행 시:

1. Rust가 server.py를 자식 프로세스로 스폰
2. Python 서버가 시작 → WhisperX 모델 로드 → 대기
3. Rust → stdin: {"command": "analyze", "audio_path": "/path/to/song.mp3"}
4. Python → stdout: [nightingale:PROGRESS:15] Separating vocals...
5. Python → stdout: [nightingale:PROGRESS:60] Transcribing...
6. Python → stdout: [nightingale:DONE]
7. 서버는 죽지 않고 대기 → 다음 곡 분석 요청을 기다림

이 패턴의 장점 5가지

1. 단일 바이너리 배포

사용자한테 전달하는 파일이 하나다. .py 파일을 따로 챙기거나, 디렉토리 구조를 유지할 필요가 없다. 실행 파일만 다운로드받으면 끝.

2. 버전 불일치 불가능

Rust 코드와 Python 코드가 같은 커밋에서 빌드된다. Rust 측에서 {"command": "analyze_v2"}를 보내는데 Python 측이 아직 analyze_v1만 아는 상황이 구조적으로 발생할 수 없다.

3. 사용자가 건드릴 수 없음

바이너리 안에 들어있으니 사용자가 실수로 .py 파일을 수정하거나 삭제할 수 없다. 추출된 파일이 꼬이면 .ready 마커를 삭제하고 다시 실행하면 원본이 재추출된다.

4. Python 서버가 영속 프로세스

WhisperX 모델 로드에 수 초가 걸린다. 곡마다 프로세스를 새로 띄우면 매번 모델을 다시 로드해야 한다. 영속 프로세스이므로 모델이 메모리에 상주하고, 두 번째 곡부터는 즉시 분석을 시작한다.

CUDA OOM이 발생하면? 서버 프로세스를 kill하고, GPU 메모리를 정리한 후, 새 프로세스를 스폰한다. 앱 전체가 죽지 않는다.

5. 통신이 단순 JSON

PyO3 같은 FFI 바인딩은 타입 매핑, GIL 관리, 에러 전파가 복잡하다. stdin/stdout JSON이면:

  • Rust 측: serde_json으로 직렬화/역직렬화

  • Python 측: json.loads()/json.dumps()

  • 디버깅: 프로토콜을 그대로 로그로 찍을 수 있다

이 패턴이 맞는 상황

  • ML 추론처럼 Python 생태계가 압도적으로 강한 영역

  • 호출 빈도가 낮은 경우 (곡 단위 분석 — 초당 수천 회 호출이 아님)

  • 프로세스 간 통신 오버헤드가 무시할 수 있을 때

  • 배포 단순성이 중요할 때 (일반 사용자 대상 데스크톱 앱)

이 패턴이 안 맞는 상황

  • 초당 수천 회 호출이 필요한 실시간 추론 → PyO3 직접 바인딩이나 ONNX Runtime Rust 바인딩이 낫다

  • Python 스크립트가 수백 개이거나 수 MB 이상 → 바이너리 크기가 과도해진다

  • 양방향 스트리밍 데이터 교환 → gRPC나 소켓이 더 적합

핵심 요약

"ML은 Python이 잘하니까 Python으로 하되, 배포와 제어는 Rust가 잡는다." 이게 이 패턴의 본질이다.

PyO3 같은 바인딩으로 두 언어를 하나의 프로세스에 우겨넣는 대신, 프로세스를 분리하되 배포는 하나로 묶었다. 결합도는 낮추면서 배포 복잡도도 낮춘 실용적 타협이다.

핵심 개념

1

include_str!()로 Python 스크립트 10개를 컴파일 시점에 Rust 바이너리에 임베딩

2

첫 실행 시 ~/.nightingale/vendor/analyzer/에 .py 파일로 추출

3

uv로 Python 3.10 + PyTorch + ML 패키지 자동 설치 (부트스트랩)

4

server.py를 영속 자식 프로세스로 스폰 → WhisperX 모델 메모리 상주

5

stdin/stdout JSON 프로토콜로 분석 요청·진행률·완료를 통신

6

CUDA OOM 시 서버 kill → GPU 정리 → 재스폰 (앱은 안 죽음)

사용 사례

ML 추론 데스크톱 앱 — Python ML을 써야 하지만 단일 바이너리로 배포해야 하는 경우 Rust ↔ Python 경량 통신 — PyO3 없이 stdin/stdout JSON으로 충분한 경우 자급형 앱 부트스트랩 — uv + 자동 설치로 사용자에게 Python 설치를 요구하지 않는 구조