[Rust Wave] Day 5: Python 개발자를 위한 Rust, PyO3로 고성능 UDF 작성하기
서론: “Two Language Problem”의 해결
데이터 과학과 엔지니어링 분야에는 오랜 딜레마가 있다.
- Python: 쉽고 생산성이 높지만, 반복문(Loop)과 연산 속도가 느리다.
- C/C++: 빠르지만, 코드를 작성하기 어렵고 메모리 관리가 위험하다(Segfault).
그래서 우리는 성능이 중요한 부분만 C로 짜서 Python에 붙이는 방식(C-Extension)을 사용해 왔다. NumPy와 Pandas가 그렇게 만들어졌다. 하지만 C/C++ 확장을 직접 짜는 것은 진입 장벽이 매우 높다.
Rust와 PyO3는 이 장벽을 허물었다. Rust의 강력한 타입 시스템과 메모리 안전성을 그대로 가져오면서, Python과 데이터를 주고받는 복잡한 과정을 자동화했다. 이제 Python 개발자는 “느린 구간만 Rust로 교체하는” 하이브리드 전략을 손쉽게 구사할 수 있다.
1. 도구 소개: PyO3와 Maturin
Rust로 Python 모듈을 만들기 위해 필요한 도구는 딱 두 가지다.
1.1 PyO3 (The Bridge)
Rust 코드와 Python 인터프리터 사이를 연결하는 라이브러리다.
- Rust의
struct나function에 매크로(#[pyfunction])만 붙이면, 자동으로 Python 모듈로 변환된다. - Python의
List,Dict와 Rust의Vec,HashMap간의 타입 변환을 자동으로 처리한다.
1.2 Maturin (The Build Tool)
복잡한 CMake나 Makefile 없이, Rust 프로젝트를 Python 패키지(.whl)로 빌드하고 배포하는 도구다. pip install maturin으로 설치하며, 가상환경(venv)에 빌드된 라이브러리를 즉시 주입할 수 있다.
2. 실전 예제: 문자열 거리 계산 (Levenshtein Distance)
데이터 전처리 과정에서 두 문자열의 유사도를 계산하는 작업은 매우 빈번하지만, Python의 이중 반복문으로 구현하면 끔찍하게 느리다. 이를 Rust로 구현해 보자.
2.1 Rust 프로젝트 생성
1
2
3
# 터미널
maturin new rust_utils --src
Cargo.toml 파일에 pyo3 의존성이 자동으로 추가된다.
2.2 Rust 코드 작성 (src/lib.rs)
Rust의 문법을 몰라도 직관적으로 이해할 수 있다. Python에서 입력을 받아 계산 후 결과만 돌려준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use pyo3::prelude::*;
// 1. 순수 Rust 로직 구현 (메모리 안전성 보장)
fn levenshtein(s1: &str, s2: &str) -> usize {
// ... (표준적인 Levenshtein 알고리즘 구현 생략) ...
// Rust의 강력한 이터레이터와 패턴 매칭을 사용하여 고속 처리
3 // 예시 결과값
}
// 2. Python에 노출할 함수 정의
#[pyfunction]
fn calc_distance(s1: &str, s2: &str) -> PyResult<usize> {
Ok(levenshtein(s1, s2))
}
// 3. 모듈 등록
#[pymodule]
fn rust_utils(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(calc_distance, m)?)?;
Ok(())
}
2.3 빌드 및 사용
1
2
maturin develop # 현재 가상환경에 라이브러리 설치
이제 Python에서 일반 라이브러리처럼 불러와 사용하면 된다.
1
2
3
4
5
6
7
8
import rust_utils
import time
# Rust 함수 호출
start = time.time()
dist = rust_utils.calc_distance("kitten", "sitting")
print(f"Distance: {dist}, Time: {time.time() - start}")
벤치마크 결과, 순수 Python 구현 대비 약 50배에서 100배 이상의 속도 향상을 보인다. GIL(Global Interpreter Lock)을 해제하고 병렬 처리를 추가하면 차이는 더 벌어진다.
3. Polars Plugin: UDF의 새로운 표준
단순히 함수 하나를 빠르게 만드는 것을 넘어, 데이터 프레임 연산 자체를 가속화할 수 있다. Day 1에서 다룬 Polars는 Rust로 작성된 사용자 정의 표현식(Custom Expression), 즉 Plugin을 지원한다.
기존 Pandas의 apply(lambda x: ...)는 Python 인터프리터를 행마다 호출하므로 느리다. 하지만 Polars Plugin은 Rust로 작성된 코드를 컴파일하여 Polars의 실행 엔진에 직접 주입한다.
- Zero Copy: Python에서 Rust로 데이터를 넘길 때 Arrow 메모리 포인터만 공유하므로 복사 비용이 없다.
- Native Performance: 사용자가 작성한 로직이 Polars의 네이티브 함수들과 동일한 레벨에서 실행된다. SIMD 최적화와 병렬 처리를 그대로 누릴 수 있다.
4. 언제 Rust UDF를 작성해야 하는가?
모든 코드를 Rust로 짤 필요는 없다. ROI(투자 대비 효과)가 확실한 지점이 있다.
- 복잡한 문자열 파싱: 정규표현식(Regex)으로 해결하기 어려운 비정형 로그 파싱.
- 수학/통계 연산: 반복문(Loop)이 깊게 중첩된 시뮬레이션 로직.
- 직렬화/역직렬화: 커스텀 바이너리 포맷을 파싱해야 할 때.
- 암호화/복호화: Python 라이브러리가 느리거나 보안이 중요할 때.