PyO3로 파이썬 확장 모듈 만들기: maturin 빌드부터 wheel 배포까지

4 분 소요

파이썬 생태계의 최근 흐름 하나는 “겉은 파이썬, 속은 Rust"입니다. 데이터프레임의 Polars, 린터의 Ruff, 검증 라이브러리 pydantic의 코어가 전부 이 구조이고, 파이썬 패키지 관리자 uv까지 Rust로 만들어졌습니다. 이 조합을 가능하게 하는 다리가 PyO3(Rust와 파이썬 인터프리터의 바인딩)와 maturin(빌드·패키징 도구)입니다. 이 글은 “느린 파이썬 함수 하나를 Rust로 바꿔 끼우는” 최소 경로를 처음부터 끝까지 밟습니다.

언제 쓸 카드인가 #

먼저 판단 기준입니다. 파이썬이 느린 코드의 다수는 알고리즘이나 IO 문제라서 파이썬 안에서 풀립니다. Rust 카드가 유효한 것은 프로파일링으로 병목이 CPU 연산임이 확인됐고, NumPy 같은 기존 네이티브 라이브러리로 표현이 안 되는 커스텀 로직일 때입니다(병목 찾기는 py-spy 편에서 다뤘습니다). 전체 재작성이 아니라 병목 함수 하나부터, 가 이 경로의 정신입니다.

시작: maturin이 뼈대를 만든다 #

프로젝트 셋업
pip install maturin
maturin new --bindings pyo3 fastcount
cd fastcount

생성되는 구조는 Rust 프로젝트에 파이썬 포장이 덧붙은 형태입니다.

프로젝트 구조
fastcount/
├── Cargo.toml       # crate-type = ["cdylib"] — 동적 라이브러리로 빌드
├── pyproject.toml   # 빌드 백엔드가 maturin
└── src/
    └── lib.rs       # 확장 모듈 본체

pyproject.toml의 빌드 백엔드가 maturin으로 지정되어 있어서, 파이썬 쪽에서 보면 이 프로젝트는 평범한 패키지입니다. pip install .도 통하고, 뒤에서 보듯 wheel 배포도 표준 절차 그대로입니다.

Rust 함수를 파이썬에 노출하기 #

단어 빈도를 세는 함수를 예제로 씁니다. 순수 파이썬 반복문으로는 느려지기 쉬운, 전형적인 CPU 바운드 루프입니다.

src/lib.rs
use pyo3::prelude::*;
use std::collections::HashMap;

/// 텍스트의 단어 빈도를 센다
#[pyfunction]
fn word_count(text: &str) -> HashMap<String, u64> {
    let mut counts: HashMap<String, u64> = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word.to_lowercase()).or_insert(0) += 1;
    }
    counts
}

#[pymodule]
fn fastcount(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(word_count, m)?)?;
    Ok(())
}

#[pyfunction]이 Rust 함수에 파이썬 호출 규약을 입히고, #[pymodule]이 모듈에 함수를 등록합니다. 눈여겨볼 것은 타입 변환이 시그니처만으로 해결된다는 점입니다. 파이썬 str가 &str로 들어오고, 반환한 HashMap<String, u64>는 파이썬 dict[str, int]가 되어 나갑니다. Vec ↔ list, Option ↔ None 허용 인자 같은 대응이 같은 원리로 작동하고, 변환 불가능한 타입을 넘기면 파이썬 쪽에 TypeError가 발생합니다. Rust의 Result를 반환하면 Err가 파이썬 예외로 변환되는 것까지, 두 언어의 에러 모델도 자연스럽게 이어집니다.

개발 사이클: maturin develop #

개발 사이클
python -m venv .venv && source .venv/bin/activate
maturin develop --release
python -c "import fastcount; print(fastcount.word_count('a b a')['a'])"  # 2

maturin develop은 컴파일한 확장 모듈을 현재 가상 환경에 바로 설치해 주는 명령으로, “고치고, 빌드하고, 파이썬에서 확인"의 반복 고리를 만듭니다. 성능을 볼 때는 --release를 붙여야 한다는 것(실전 4편의 규칙)이 여기서도 그대로 적용됩니다. 이런 문자열 처리 루프라면 순수 파이썬 대비 수십 배 수준의 개선이 일반적인 기대치이고, 정확한 배율은 언제나 자기 데이터로 측정해서 말해야 합니다.

free-threaded 파이썬과의 궁합 #

파이썬 쪽의 큰 변화인 free-threading(GIL 제거 빌드)과의 관계도 짚어 둘 필요가 있습니다. 전통적으로 확장 모듈은 GIL 덕분에 스레드 안전을 공짜로 얻어 왔는데, free-threaded 빌드에서는 그 전제가 사라집니다. PyO3는 free-threaded 파이썬 지원을 갖추고 있고, Rust 쪽 코드는 애초에 컴파일러가 데이터 레이스를 차단하므로(실전 5편) 이 전환에서 유리한 위치에 있습니다. “GIL 없는 파이썬에서 진짜 병렬 확장 모듈"이라는 조합이 이 경로의 다음 단계 이야기입니다.

배포: wheel까지 #

배포
maturin build --release        # 현재 플랫폼용 wheel 생성
maturin publish                # PyPI 업로드까지

maturin이 만든 wheel은 PyPI 배포 절차에 그대로 올라갑니다. 주의점은 네이티브 확장의 숙명인 플랫폼별 빌드입니다. 순수 파이썬 wheel과 달리 OS·아키텍처별 wheel이 필요하므로, 실제 배포에서는 CI의 매트릭스 빌드(Rust 실전 8편과 같은 구도)로 리눅스·맥·Windows용을 병렬 생성하는 것이 관례입니다. maturin은 이를 위한 GitHub Actions 워크플로 생성 명령(maturin generate-ci)까지 제공합니다.

정리 #

  • “겉은 파이썬, 속은 Rust"는 Polars·Ruff·pydantic이 검증한 표준 경로이고, PyO3 + maturin이 그 도구입니다.
  • 유효한 상황은 프로파일링으로 확인된 CPU 병목 + 기존 네이티브 라이브러리로 안 되는 커스텀 로직입니다. 병목 함수 하나부터 시작합니다.
  • #[pyfunction]·#[pymodule]로 노출하고, 타입 변환은 시그니처가 담당합니다(str↔&str, dict↔HashMap, 예외↔Result).
  • 개발은 maturin develop --release 반복 고리, 배포는 maturin build로 wheel을 만들어 PyPI 표준 절차에 태웁니다. 플랫폼별 wheel은 CI 매트릭스가 정석입니다.
  • free-threaded 파이썬 시대에 확장 모듈의 스레드 안전은 공짜가 아니게 되는데, 데이터 레이스를 컴파일 타임에 막는 Rust는 이 전환의 유리한 자리에 있습니다.
X