콘텐츠로 이동

Python 개발자를 위한 Rust 입문

Python을 알고 있다면 Rust의 아이디어도 이미 많이 알고 있는 셈이에요. Rust에도 반복자를 도는 for 반복문, 튜플, 클로저(Python의 람다와 비슷하지만 더 쓸모가 많아요), Python의 match처럼 동작하는 match 문, 그리고 리스트 컴프리헨션이 하는 일을 해 주는 반복자 체인이 있어요. Rust는 Python의 취향도 조금 빌려 왔어요. 코드를 포매팅하는 뚜렷한 방법이 하나 있고, 친절한 패키지 도구가 있고, 에러 메시지가 여러분을 도우려고 애써요.

새로운 건 주로 컴파일러 쪽이에요. Python은 프로그램이 실행되는 동안 타입을 검사하지만, Rust는 실행하기 전에 전부 검사해요. Python은 가비지 컬렉터로 메모리를 정리하지만, Rust는 컴파일할 때 검사하는 소유권(ownership)과 대여(borrowing)를 써요. Python에는 None과 예외가 있고, Rust에는 Option과 Result가 있어요. 공부 시간은 대부분 이 세 가지에 쓰게 될 거예요.

그럼 Python 개발자는 왜 Rust를 배울까요? 솔직한 이유는 이런 것들이에요.

  • 속도. Rust는 네이티브 기계어로 컴파일돼요. 순수 Python에서 느린 CPU 중심 작업(파싱, 수치 계산, 큰 반복문)은 특별한 튜닝 없이도 Rust에서 보통 훨씬 빨라요.
  • 진짜 스레드. 표준 CPython에서는 GIL 때문에 한 번에 한 스레드만 Python 코드를 실행할 수 있어요. Rust 스레드는 모든 CPU 코어에서 동시에 돌아가고, 데이터 경쟁(data race)은 프로그램을 실행하기 전에 컴파일러가 막아 줘요.
  • 버그를 더 일찍 찾아요. 속성 이름의 오타, 깜빡한 None, 잘못된 타입으로 호출한 함수. Python에서는 그 줄이 실행될 때 알게 되지만, Rust에서는 이걸 고치기 전까지 프로그램이 컴파일되지 않아요.
  • 하나만 고를 필요가 없어요. PyO3(영어)와 maturin을 쓰면 Python 프로젝트의 느린 부분만 Rust로 작성하고, 다른 모듈처럼 Python에서 import할 수 있어요. Polars, Ruff, uv 같은 인기 Python 도구도 Rust로 만들어졌어요.

Python, Go, C++와의 더 넓은 비교가 궁금하다면 Rust vs C++, Go, Python을 보세요.

이 표를 회화 사전처럼 써 보세요. 마지막 열은 Rust 쪽을 설명하는 이 사이트의 강좌로 연결돼요.

Python Rust 더 배우기
동적 타입, 실행할 때 검사 정적 타입, 컴파일할 때 검사. 대부분은 Rust가 추론해 줘서 생각보다 타입을 적게 써요 데이터 타입
타입 힌트 (def f(x: int) -> str) 함수 시그니처의 타입은 필수이고, 컴파일러가 강제해요 함수
x = 5 let x = 5; (바꿀 수 없음). 바꾸려면 let mut x = 5; 변수
int (필요한 만큼 커짐) 크기가 정해진 정수: i32, i64, u64, usize, i128… 오버플로가 날 수 있어요 데이터 타입, 정수 오버플로
float f64 (또는 f32) 데이터 타입
str String(소유하고, 늘릴 수 있음)과 &str(빌려 온 텍스트 뷰) 문자열
f-문자열: f"Hi {name}" format!("Hi {name}")과 println!("Hi {name}") 출력
print(repr(x)) / __repr__ #[derive(Debug)]를 붙이고 println!("{x:?}") 출력, 자주 쓰는 트레이트
__str__ Display 구현 (그러면 .to_string()도 따라와요) 자주 쓰는 트레이트
__eq__, __hash__, __lt__ PartialEq, Eq, Hash, PartialOrd, Ord를 derive 자주 쓰는 트레이트
None, Optional[T] Option<T>: Some(value) 아니면 None Option
x or default x.unwrap_or(default) Option
raise, try/except Result<T, E> + ? 연산자 Result, 사용자 정의 에러
진짜 버그를 알리는 예외 (assert, IndexError) panic! (스레드를 멈춰요) panic
__init__과 메서드가 있는 class 데이터는 struct, 메서드는 impl 블록. new는 평범한 함수예요 구조체
@dataclass #[derive(Debug, Clone, PartialEq)]를 붙인 struct 구조체
self &self(읽기), &mut self(변경), 또는 self(소유권을 가져감) 구조체
상속 상속은 없어요. 컴포지션(구조체 안에 구조체) + 기본 메서드가 있는 트레이트를 써요 트레이트, 디자인 패턴
덕 타이핑, Protocol, ABC trait 트레이트
서로 다른 클래스의 객체를 담은 리스트 Vec<Box<dyn Trait>> (트레이트 객체) 트레이트 객체
TypeVar / 제네릭 함수 트레이트 바운드가 붙은 제네릭: fn largest<T: Ord>(items: &[T]) 제네릭
Enum 클래스 배리언트마다 자기 데이터를 담을 수 있는 enum 열거형
match / case (3.10+) match (모든 경우를 다뤘는지 컴파일러가 검사해요) match, 패턴 매칭
if my_list: (truthiness) 명시적으로 써요: if !my_list.is_empty(). if에는 bool만 들어가요 if/else
for i in range(10) for i in 0..10 for 반복문
enumerate(xs), zip(a, b) xs.iter().enumerate(), a.iter().zip(b.iter()) 반복자
lambda x: x + 1 클로저: |x| x + 1 (여러 줄로 써도 돼요) 클로저
리스트 컴프리헨션, map, filter 반복자 어댑터: filter, map, 그다음 collect() 반복자
제너레이터와 yield Iterator를 구현한 직접 만든 타입, 또는 std::iter::from_fn 사용자 정의 반복자
with open(...) as f: 따로 쓸 게 없어요. 파일과 락은 주인이 스코프를 벗어나면 닫혀요(Drop) 스코프, 파일 입출력
기본 인자, *args, **kwargs 지원하지 않아요. Option 매개변수, 슬라이스(&[T]), Default를 구현한 구조체, 또는 빌더를 써요 함수, 디자인 패턴
데코레이터 딱 맞는 대응은 없어요. 클로저로 감싸거나, 어트리뷰트 매크로(#[tokio::main] 같은)를 써요 클로저, 절차적 매크로
import와 모듈 mod와 use 모듈
PyPI의 패키지 crates.io의 크레이트 Cargo와 크레이트
if __name__ == "__main__": fn main() Rust 시작하기
threading (GIL 있음) std::thread: 진짜 병렬 스레드 동시성
multiprocessing, concurrent.futures 스레드, 또는 병렬 반복자를 주는 rayon 크레이트 동시성, 공유 상태
asyncio, async def, await async fn과 .await, 그리고 tokio 같은 런타임 async/await
7 // 2, 2 ** 10 7 / 2 (정수끼리는 정수 나눗셈), 2_i64.pow(10) 연산자
int("42"), str(42) "42".parse::<i32>() (Result를 반환), 42.to_string() 타입 변환, 타입 변환 (From, Into)

Python의 내장 컬렉션은 모두 Rust 표준 라이브러리에 짝이 있어요. 가장 큰 차이는 두 가지예요. Rust 컬렉션은 한 가지 타입만 담고(Vec<i32>에는 i32만 들어가요), HashMap은 넣은 순서를 기억하지 않아요.

Python Rust 더 배우기
list Vec<T> 벡터
tuple 튜플 (i32, String), 또는 필드에 이름이 있는 작은 struct 튜플
dict HashMap<K, V> (정해진 순서가 없어요!) HashMap
키가 정렬되어야 하는 dict BTreeMap<K, V> HashMap
넣은 순서가 필요한 dict indexmap 크레이트
set, frozenset HashSet<T>, BTreeSet<T> HashSet과 BTreeMap
collections.Counter, defaultdict entry API를 쓰는 HashMap: *map.entry(k).or_insert(0) += 1 HashMap
collections.deque VecDeque<T> VecDeque와 BinaryHeap
heapq (작은 것부터) BinaryHeap<T> (큰 것부터 나와요. 작은 것부터 꺼내려면 Reverse로 감싸요) VecDeque와 BinaryHeap
namedtuple struct 구조체
bytes, bytearray &[u8], Vec<u8> 슬라이스
슬라이싱: xs[1:3] 슬라이스: &xs[1..3] (음수 인덱스는 없어요. xs.len() - 1을 써요) 슬라이스

많은 Python 개발자가 여기서 조금 안심해요. Rust에는 공식 도구 Cargo 하나가 있고, 이게 pip, venv, setuptools, pytest 등이 하던 일을 모두 해요. “패키징 도구는 뭘 써야 하지?” 같은 논쟁이 없어요.

Python Rust 참고
python 인터프리터 rustc 컴파일러. 보통 cargo를 통해 호출해요 먼저 컴파일한 다음 네이티브 바이너리를 실행해요. Rust 시작하기 참고
pyenv rustup (버전을 고정하려면 rust-toolchain.toml 파일) Rust 도구와 개발 환경 참고
pip, poetry, uv cargo 빌드, 실행, 테스트, 패키지 추가, 배포까지. Cargo와 크레이트 참고
pip install requests cargo add reqwest 이 프로젝트의 Cargo.toml에만 크레이트를 추가해요
pyproject.toml / requirements.txt Cargo.toml
poetry.lock / uv.lock Cargo.lock 자동으로 만들어져요
venv / virtualenv 필요 없어요 프로젝트마다 자기 의존성을 갖고, 자기 target/ 폴더에 빌드돼요
PyPI crates.io 공개된 모든 크레이트의 API 문서는 docs.rs(영어)에 있어요
python main.py cargo run (최고 속도로 돌리려면 cargo run --release) debug 빌드는 실행 속도가 훨씬 느려요. 속도는 release 모드에서 재세요
pipx install ruff cargo install ripgrep 커맨드 라인 도구를 설치해요
PyInstaller cargo build --release 네이티브 바이너리 하나가 나와요. 사용자는 Rust를 설치할 필요가 없어요
black cargo fmt 모두가 쓰는 표준 스타일 하나
ruff, pylint, flake8 cargo clippy 도움이 되는 린트 수백 개, 공식 도구예요
mypy, pyright 컴파일러 자체 타입 검사는 선택이 아니고, 따로 하는 단계도 아니에요
pytest, unittest 내장된 #[test]와 cargo test (더 빠른 실행기로 cargo nextest가 인기예요) Rust 테스트 참고
pytest 픽스처와 parametrize rstest
hypothesis proptest 속성 기반 테스트. 테스트 더 알아보기 참고
unittest.mock mockall, 또는 트레이트를 구현한 직접 만든 가짜 객체
timeit, pytest-benchmark criterion 성능 참고
cProfile, py-spy cargo flamegraph, perf, 또는 아무 네이티브 프로파일러
docstring + Sphinx 마크다운으로 쓰는 /// 문서 주석 + cargo doc 문서 안의 코드 예제가 테스트로 실행돼요. Rust 문서화 참고
Python REPL, Jupyter Rust Playground(영어), evcxr_repl과 evcxr_jupyter Rust에는 내장 REPL이 없어요. 작은 cargo run 프로젝트로 실험하는 게 보통이에요
VS Code + Pylance, PyCharm VS Code + rust-analyzer, 또는 JetBrains RustRover rust-analyzer가 자동 완성, 정의로 이동, 실시간 에러 표시를 해 줘요

pyproject.toml과 비교해 볼 수 있게 작은 Cargo.toml을 보여 드릴게요.

[package]
name = "weather-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.13", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
clap = { version = "4", features = ["derive"] }

라이브러리와 프레임워크: “그럼 대신 뭘 쓰나요?”

섹션 제목: “라이브러리와 프레임워크: “그럼 대신 뭘 쓰나요?””

“저는 requests, FastAPI, pandas를 쓰는데, Rust 버전은 뭐예요?” 가장 가까운 것들을 정리해 볼게요. Python 생태계가 훨씬 오래되고 크기 때문에, 어떤 분야(특히 데이터 과학)에서는 Rust의 선택지가 더 적고 더 젊어요.

Python Rust 참고
Flask, FastAPI axum (프로젝트: Axum으로 웹 API에서 직접 만들어 봐요), actix-web 핸들러는 평범한 async fn이에요. 요청과 응답 타입은 여러분의 구조체에서 나와요. pydantic을 쓰는 FastAPI와 조금 비슷해요
Django 완전히 같은 건 없어요. Loco(loco-rs)가 필요한 걸 다 갖춘 프레임워크예요. 대부분의 팀은 axum + ORM + 템플릿 엔진을 조합해요 내장 관리자 사이트는 없어요
requests, httpx reqwest (비동기, 블로킹 클라이언트도 선택 가능), ureq (단순하고 블로킹) 네트워킹 참고
aiohttp reqwest + tokio
Jinja2 minijinja (Jinja를 만든 사람이 직접 만들었어요), tera, askama (컴파일할 때 템플릿을 검사해요)
BeautifulSoup scraper
Python Rust 참고
SQLAlchemy ORM Diesel (컴파일할 때 쿼리 타입을 검사), SeaORM (비동기, 엔티티 방식) 데이터베이스에서 동작하는 코드로 둘 다 볼 수 있어요
psycopg, 순수 SQL sqlx: 순수 SQL을 쓰고, 컴파일할 때 실제 데이터베이스에 대고 검사해요 비동기, 아주 인기 있음, ORM은 아니에요
sqlite3 rusqlite
redis-py redis
Celery 작업 큐는 apalis, 프로세스 안의 백그라운드 작업은 tokio::spawn
Python Rust 참고
pandas Polars Rust로 만들어졌어요. 여러분이 이미 알지도 모르는 Polars의 Python API가 바로 이 Rust 라이브러리 위에 만들어져 있어요
NumPy ndarray 슬라이싱과 브로드캐스팅을 지원하는 N차원 배열. NumPy보다 부가 기능은 훨씬 적어요
scikit-learn linfa, smartcore 알고리즘이 더 적고 커뮤니티도 훨씬 작아요
PyTorch candle (크레이트 이름은 candle-core), burn 모델을 실행하기에는 좋아요. 학습은 대부분 아직 Python에서 해요
matplotlib plotters
csv 모듈 csv 크레이트 + serde
Python Rust 참고
json serde + serde_json 사실상 표준이에요. TOML, YAML 등도 다뤄요. Serde로 JSON 다루기 참고
pydantic 파싱은 serde, 검증 규칙은 validator나 garde 구조체가 곧 스키마예요
argparse, click, typer clap 인자를 구조체로 정의해요. 프로젝트: clap으로 CLI 도구 참고
logging tracing, 또는 log + env_logger 로깅 참고
re regex 전후방 탐색(look-around)과 역참조는 없지만, 선형 시간을 보장해요. 필요하면 fancy-regex에 있어요. 정규 표현식 참고
datetime chrono 또는 jiff 날짜와 시간 참고
random rand 난수 참고
asyncio tokio async/await 참고
multiprocessing.Pool.map rayon: .iter()를 .par_iter()로 바꾸면 돼요
itertools 대부분 Iterator에 내장되어 있고, 나머지는 itertools 크레이트가 채워 줘요
os, pathlib, subprocess std::env, std::path, std::fs, std::process 파일 입출력, 환경 변수와 프로세스 참고

나란히 보기: 클래스는 struct + impl로

섹션 제목: “나란히 보기: 클래스는 struct + impl로”

작은 Python 클래스예요.

class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def __str__(self):
return f"{self.owner} has {self.balance}"
account = Account("Ada", 100)
account.deposit(50)
print(account)

같은 걸 Rust로 쓰면 이래요.

use std::fmt;
struct Account {
owner: String,
balance: i64,
}
impl Account {
// `new`는 관례일 뿐, 키워드가 아니에요. 기본 인자가 없어서
// 호출하는 쪽이 항상 두 값을 모두 넘겨요.
fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// `&mut self`: 이 메서드는 계좌를 바꿔요.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
}
// 이게 Rust판 __str__이에요.
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}의 잔액: {}", self.owner, self.balance)
}
}
fn main() {
let mut account = Account::new("Ada", 100);
account.deposit(50);
println!("{account}");
}

실행 결과:

Ada의 잔액: 150
▶ 직접 실행해 보기

몇 가지가 달라요. 필드는 타입과 함께 미리 선언하기 때문에, Python에서 account.nickname = "A"처럼 나중에 속성을 추가할 수는 없어요. 데이터(struct)와 동작(impl)은 서로 다른 블록에 들어 있어요. 메서드마다 값을 읽기만 하는지(&self) 바꾸는지(&mut self)를 밝히고, deposit이 계좌를 바꾸기 때문에 변수는 let mut으로 선언해야 해요. Rust에는 기본 인자가 없어요. 잔액 0으로 시작하는 계좌가 필요하다면 fn empty(owner: &str) -> Self 같은 함수를 하나 더 만들면 돼요.

나란히 보기: 덕 타이핑은 트레이트로

섹션 제목: “나란히 보기: 덕 타이핑은 트레이트로”

Python은 객체가 어떤 클래스인지는 신경 쓰지 않고, 알맞은 메서드가 있는지만 봐요.

class Duck:
def speak(self):
return "Quack"
class Robot:
def speak(self):
return "Beep boop"
def introduce(thing):
print(f"It says: {thing.speak()}")
for thing in [Duck(), Robot()]:
introduce(thing)
introduce(42) # AttributeError, 하지만 이 줄이 실행될 때에야 알 수 있어요

Rust에서는 “speak 메서드가 있다”는 사실을 트레이트(trait)로 적어 두고, 각 타입이 impl 블록으로 직접 참여해요.

trait Speak {
fn speak(&self) -> String;
}
struct Duck;
struct Robot;
impl Speak for Duck {
fn speak(&self) -> String {
"Quack".to_string()
}
}
impl Speak for Robot {
fn speak(&self) -> String {
"Beep boop".to_string()
}
}
// Speak를 구현한 타입이라면 무엇이든 받아요.
fn introduce(thing: &dyn Speak) {
println!("이렇게 말해요: {}", thing.speak());
}
fn main() {
let things: Vec<Box<dyn Speak>> = vec![Box::new(Duck), Box::new(Robot)];
for thing in &things {
introduce(thing.as_ref());
}
// introduce(&42); // 컴파일 에러: `{integer}`는 Speak를 구현하지 않아요
}

실행 결과:

이렇게 말해요: Quack
이렇게 말해요: Beep boop
▶ 직접 실행해 보기

정신은 여전히 덕 타이핑이지만, 컴파일러가 검사해 줘요. introduce(&42)는 운영 환경에서 터지는 AttributeError가 아니라, 프로그램을 실행하기도 전에 나는 에러예요. typing.Protocol을 써 봤다면 트레이트가 자연스럽게 느껴질 거예요. 여러 타입이 섞인 리스트에는 Box<dyn Speak>가 필요해요. 항목마다 메서드를 어떻게 호출할지 Rust가 알아야 하기 때문이에요(트레이트 객체 참고). 그리고 내가 만들지 않은 타입, 예를 들어 String이나 i32에도 내 트레이트를 구현할 수 있어요.

Python에서는 어떤 값이든 몰래 None일 수 있고, 그 값을 쓸 때에야 알게 돼요.

def find_email(user_id):
emails = {1: "[email protected]", 2: "[email protected]"}
return emails.get(user_id) # 없으면 None을 반환해요
print(find_email(1).upper())
print(find_email(3).upper()) # AttributeError: 'NoneType' object has no attribute 'upper'

Rust에서는 없을 수도 있는 값의 타입이 Option<T>예요. None인 경우를 처리하기 전에는 평범한 값처럼 쓸 수 없어요.

fn find_email(user_id: u32) -> Option<String> {
match user_id {
1 => Some("[email protected]".to_string()),
2 => Some("[email protected]".to_string()),
_ => None,
}
}
fn main() {
for id in [1, 3] {
match find_email(id) {
Some(email) => println!("{id}: {}", email.to_uppercase()),
None => println!("{id}: 등록된 이메일이 없어요"),
}
}
// Python의 `find_email(3) or "unknown"`과 같아요.
let email = find_email(3).unwrap_or("알 수 없음".to_string());
println!("{email}");
// 값이 있으면 안의 값을 바꿔요.
let upper = find_email(2).map(|e| e.to_uppercase());
println!("{upper:?}");
}

실행 결과:

3: 등록된 이메일이 없어요
알 수 없음
▶ 직접 실행해 보기

Rust 컴파일러는 Option<String>에 .to_uppercase()를 호출하는 걸 허락하지 않아요. 그래서 “'NoneType' object has no attribute” 에러는 아예 일어날 수 없어요. Rust의 String은 언제나 진짜 문자열이에요. 도우미 메서드는 Option 페이지에서, if let Some(email) = ... 같은 짧은 형태는 if let / while let에서 배워 보세요.

나란히 보기: 예외는 Result와 ?로

섹션 제목: “나란히 보기: 예외는 Result와 ?로”

Python에서는 int()가 예외를 던지고, 호출 스택 위쪽 어딘가에서 그 예외를 잡아요.

def parse_port(text):
port = int(text) # ValueError가 날 수 있어요
if not 1 <= port <= 65535:
raise ValueError(f"port {port} is out of range")
return port
for text in ["8080", "http", "70000"]:
try:
print(f"port {parse_port(text)}")
except ValueError as e:
print(f"error: {e}")

Rust에서는 실패할 수 있는 함수가 반환 타입으로 그 사실을 알려요. ? 연산자는 “이게 실패했으면 지금 바로 호출한 쪽으로 에러를 돌려줘”라는 뜻이에요. 예외가 위로 전파되는 것과 같은 일을 하지만, 코드에 눈에 보이게 드러나요.

fn parse_port(text: &str) -> Result<u32, String> {
let port: u32 = text
.parse()
.map_err(|e| format!("{text:?}: 숫자가 아니에요 ({e})"))?;
if !(1..=65535).contains(&port) {
return Err(format!("포트 번호가 범위를 벗어났어요: {port}"));
}
Ok(port)
}
fn main() {
for text in ["8080", "http", "70000"] {
match parse_port(text) {
Ok(port) => println!("포트 {port}"),
Err(e) => println!("에러: {e}"),
}
}
}

실행 결과:

포트 8080
에러: "http": 숫자가 아니에요 (invalid digit found in string)
에러: 포트 번호가 범위를 벗어났어요: 70000
▶ 직접 실행해 보기

괄호 안의 invalid digit found in string은 Rust 표준 라이브러리가 만드는 에러 메시지라서 영어로 나와요. “문자열에 숫자가 아닌 글자가 있다”는 뜻이에요.

가장 큰 차이는 이거예요. Python에서는 어떤 함수든 예외를 던질 수 있고, 시그니처만 봐서는 알 수 없어요. Rust에서는 Result가 시그니처의 일부라서, 호출하는 쪽이 잊어버릴 수가 없어요. 배우는 동안에는 에러 타입으로 String을 써도 괜찮아요. 실제 프로젝트에서는 직접 만든 에러 열거형(사용자 정의 에러)이나, “아무 예외나 raise하기”와 아주 비슷한 느낌의 anyhow 크레이트를 써요(anyhow와 thiserror 참고).

나란히 보기: 컴프리헨션과 dict는 반복자와 HashMap으로

섹션 제목: “나란히 보기: 컴프리헨션과 dict는 반복자와 HashMap으로”

리스트 컴프리헨션과 dict로 개수 세기는 Python에서 매일 쓰는 코드예요.

text = "the cat sat on the mat the end"
loud = [w.upper() for w in text.split() if len(w) == 3]
print(loud)
counts = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
for word, n in top:
print(f"{word}: {n}")

Rust에서는 컴프리헨션이 collect()로 끝나는 반복자 체인이 되고, dict는 HashMap이 돼요.

use std::collections::HashMap;
fn main() {
let text = "the cat sat on the mat the end";
// [w.upper() for w in text.split() if len(w) == 3]
let loud: Vec<String> = text
.split_whitespace()
.filter(|w| w.len() == 3)
.map(|w| w.to_uppercase())
.collect();
println!("{loud:?}");
// counts[word] = counts.get(word, 0) + 1
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
// 많이 나온 것부터, 개수가 같으면 알파벳순으로.
let mut top: Vec<(&str, u32)> = counts.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (word, n) in top.iter().take(3) {
println!("{word}: {n}");
}
}

실행 결과:

["THE", "CAT", "SAT", "THE", "MAT", "THE", "END"]
the: 3
cat: 1
end: 1
▶ 직접 실행해 보기

이 예제의 문장은 일부러 영어로 두었어요. 글자 수(w.len() == 3), 대문자 변환, 알파벳순 정렬이 결과에 영향을 주는데, 한글은 UTF-8에서 한 글자가 3바이트라서 결과가 달라지기 때문이에요.

체인은 안쪽에서 바깥쪽이 아니라 왼쪽에서 오른쪽으로 읽히지만, 아이디어는 같아요. 제너레이터 표현식처럼 게을러서(lazy), collect(), sum(), count(), for 반복문 같은 게 값을 끌어가기 전까지는 아무 일도 일어나지 않아요. collect()는 무엇을 만들지 알아야 해서 loud에 Vec<String> 타입을 붙였어요. 그리고 정렬 단계를 눈여겨보세요. Python의 dict는 넣은 순서를 기억하지만 Rust의 HashMap은 기억하지 않아서, 출력하기 전에 항상 정렬해요. 정렬은 정렬과 검색 페이지에서 다뤄요.

표준 CPython에서는 GIL(Global Interpreter Lock) 때문에 한 번에 한 스레드만 Python 바이트코드를 실행할 수 있어요. 네트워크를 기다릴 때는 스레드가 도움이 되지만, 이런 CPU 중심 작업은 스레드를 늘려도 빨라지지 않아요. 그래서 Python 개발자는 대신 multiprocessing을 꺼내 들어요.

from concurrent.futures import ThreadPoolExecutor
def is_prime(n):
if n < 2:
return False
d = 2
while d * d <= n:
if n % d == 0:
return False
d += 1
return True
def count_primes(lo, hi):
return sum(1 for n in range(lo, hi) if is_prime(n))
ranges = [(0, 25_000), (25_000, 50_000), (50_000, 75_000), (75_000, 100_000)]
with ThreadPoolExecutor(4) as pool:
counts = list(pool.map(lambda r: count_primes(*r), ranges))
print("total:", sum(counts))

Rust에는 GIL이 없어요. thread::spawn은 하나하나가 진짜 운영체제 스레드이고, 서로 다른 CPU 코어에서 동시에 돌아가요.

use std::thread;
fn is_prime(n: u32) -> bool {
if n < 2 {
return false;
}
let mut d = 2;
while d * d <= n {
if n % d == 0 {
return false;
}
d += 1;
}
true
}
fn count_primes(lo: u32, hi: u32) -> usize {
(lo..hi).filter(|&n| is_prime(n)).count()
}
fn main() {
let ranges = [(0, 25_000), (25_000, 50_000), (50_000, 75_000), (75_000, 100_000)];
// 범위마다 자기 OS 스레드를 하나씩 받아요. 정말로 동시에 실행돼요.
let counts: Vec<usize> = thread::scope(|s| {
let handles: Vec<_> = ranges
.iter()
.map(|&(lo, hi)| s.spawn(move || count_primes(lo, hi)))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
for ((lo, hi), count) in ranges.iter().zip(&counts) {
println!("{lo}..{hi}: 소수 {count}개");
}
println!("합계: {}", counts.iter().sum::<usize>());
}

실행 결과:

0..25000: 소수 2762개
25000..50000: 소수 2371개
50000..75000: 소수 2260개
75000..100000: 소수 2199개
합계: 9592
▶ 직접 실행해 보기

thread::scope는 모든 스레드가 끝날 때까지 기다린 다음 반환하기 때문에, 스레드가 지역 데이터를 안전하게 빌려 쓸 수 있어요. GIL이 없으면 두 스레드가 같은 값을 동시에 바꾸지 않을까 걱정될 수도 있어요. Rust는 이걸 컴파일할 때 처리해요. 여러 스레드가 공유하면서 바꾸는 데이터는 Arc<Mutex<T>> 같은 것으로 감싸야 하고, 그러지 않으면 프로그램이 컴파일되지 않아요(공유 상태 참고). 실제 프로젝트에서는 rayon 크레이트를 쓰면 더 짧아져요. 스레드로 도는 multiprocessing.Pool.map과 조금 비슷해요.

use rayon::prelude::*;
let total = (0..100_000u32).into_par_iter().filter(|&n| is_prime(n)).count();

Python도 이 부분에서 변하고 있어요. Python 3.13에 GIL 없는 “free-threaded” 빌드가 선택 사항으로 추가됐어요. 하지만 대부분의 사람이 설치하고 배포하는 표준 CPython에는 여전히 GIL이 있고, 많은 C 확장 모듈이 아직 free-threading을 지원하지 않아요.

Rust로 Python 확장 모듈 만들기 (PyO3 + maturin)

섹션 제목: “Rust로 Python 확장 모듈 만들기 (PyO3 + maturin)”

Rust를 쓰려고 Python을 떠날 필요는 없어요. PyO3(영어)를 쓰면 Python 모듈을 Rust로 작성할 수 있고, maturin(영어)이 그걸 pip으로 설치할 수 있는 평범한 wheel로 빌드해 줘요. Polars, pydantic-core를 비롯한 많은 빠른 Python 라이브러리가 이렇게 만들어져요.

(활성화한 가상 환경 안에서) 프로젝트를 만들어요.

Terminal window
pip install maturin
maturin new --bindings pyo3 fastmath
cd fastmath

maturin이 Cargo.toml과 pyproject.toml을 만들어 줘요. 그다음 src/lib.rs에 Rust 함수를 넣어요.

use pyo3::prelude::*;
/// Rust로 작성한 Python 모듈이에요.
#[pymodule]
mod fastmath {
use pyo3::prelude::*;
/// 숫자 리스트의 제곱을 모두 더해요.
#[pyfunction]
fn sum_of_squares(numbers: Vec<i64>) -> i64 {
numbers.iter().map(|n| n * n).sum()
}
/// 텍스트의 단어 수를 세요.
#[pyfunction]
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
}

maturin develop으로 가상 환경에 빌드하고(속도가 중요하면 --release를 붙여요), 다른 모듈처럼 Python에서 써요.

import fastmath
print(fastmath.sum_of_squares([1, 2, 3, 4])) # 30
print(fastmath.word_count("the quick brown fox")) # 4
fastmath.sum_of_squares(["a"]) # TypeError: 'str' object cannot be interpreted as an integer

PyO3가 타입을 변환해 줘요. 정수가 든 Python list는 Vec<i64>가 되고, str은 &str이 되고, 문서 주석은 Python docstring이 돼요. 잘못된 타입은 평범한 Python TypeError로 바뀌어요. 공유할 준비가 되면 maturin build --release로 wheel을 만들고, maturin publish로 PyPI에 올려요. 좋은 규칙이 하나 있어요. 먼저 측정하고, 정말 뜨거운 반복문만 Rust로 옮기세요. Python과 Rust 사이를 오가는 데는 작은 비용이 들어서, 작은 호출 백만 번보다 일을 많이 하는 호출 한 번이 나아요.

Python 개발자가 자주 걸려 넘어지는 부분

섹션 제목: “Python 개발자가 자주 걸려 넘어지는 부분”

Python에서 온 사람은 누구나 같은 곳에서 걸려요. 미리 알고 있으면 훨씬 수월해요.

Python에서 b = a를 하면 같은 리스트에 이름이 두 개 생겨요. Rust에서 let b = a;를 하면 Vec이나 String의 소유권이 b로 이동하고, a는 더 이상 쓸 수 없어요.

fn main() {
let a = vec![1, 2, 3];
let mut b = a;
b.push(4);
println!("{a:?}");
}
error[E0382]: borrow of moved value: `a`
--> src/main.rs:5:16
|
2 | let a = vec![1, 2, 3];
| - move occurs because `a` has type `Vec<i32>`, which does not implement the `Copy` trait
3 | let mut b = a;
| - value moved here
4 | b.push(4);
5 | println!("{a:?}");
| ^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let mut b = a.clone();
| ++++++++

컴파일러 메시지는 영어로 나와요. “a의 값은 3번째 줄에서 b로 이동했는데, 5번째 줄에서 이동한 값을 다시 빌리려고 했다”는 뜻이고, 성능 비용이 괜찮다면 .clone()을 써 보라고 제안하고 있어요.

해결하려면 빌리거나(읽기만 한다면 let b = &a;) 의도적으로 진짜 복사본을 만드세요(a.clone()). 이 규칙 하나, 소유권이 여러분에게 가장 중요한 새 개념이에요. 그 페이지와 대여를 천천히 읽어 보세요.

  • 정수에는 크기가 있어요. Python의 int는 절대 오버플로가 나지 않아요. Rust의 i32는 2,147,483,647에서 멈추고, 그걸 넘으면 debug 빌드에서 패닉이 나요. 큰 수에는 i64나 u64를 고르고, 정수 오버플로를 참고하세요.
  • 나눗셈과 나머지가 다르게 동작해요. Rust에서 7 / 2는 3이에요. 양쪽이 모두 정수라서 그래요(Python의 //). 3.5를 얻으려면 7.0 / 2.0이라고 쓰세요. 또 -7 % 3은 Rust에서는 -1이지만 Python에서는 2예요. Python과 같은 답을 원하면 (-7_i32).rem_euclid(3)을 쓰세요.
  • 숫자 타입을 섞을 수 없어요. let x: f64 = 2.5 * 3;은 컴파일되지 않아요. 3이 정수이기 때문이에요. 3.0이라고 쓰거나 as f64로 변환하세요(타입 변환 참고).
  • 문자열에 인덱스로 접근할 수 없어요. word[0]은 컴파일 에러예요. Rust 문자열은 UTF-8이고 “글자” 하나가 여러 바이트일 수 있기 때문이에요. word.chars().next()나 word.chars().nth(i)를 쓰세요. 문자열을 참고하세요.
  • String과 &str. Python의 문자열 타입은 str 하나지만 Rust에는 두 개가 있어요. String은 텍스트를 소유하고, &str은 빌려 써요. 함수는 보통 &str을 받고, 구조체는 보통 String을 저장해요.
  • 변수는 기본적으로 불변이에요. 값을 바꿔야 하면 let mut이라고 쓰세요. 깜빡하면 컴파일러가 알려 줘요.
  • 중괄호와 세미콜론. Rust에서 들여쓰기는 의미가 없고, { }가 의미가 있어요. 그리고 함수의 마지막 줄에 세미콜론이 없으면 그게 반환값이에요. fn double(x: i32) -> i32 { x * 2 }처럼요.
  • 모든 if와 match는 빠짐없고 타입이 맞아야 해요. truthiness는 없어요. if count는 컴파일되지 않고, if count > 0이라고 써야 해요. match는 모든 경우를 다뤄야 해요.
  • HashMap에는 순서가 없어요. Python의 dict는 넣은 순서를 기억하지만 Rust의 HashMap은 기억하지 않아요. 출력하기 전에 정렬하거나, BTreeMap을 쓰거나, indexmap 크레이트를 추가하세요.
  • 컴파일 단계가 있어요. REPL이 없고, 크레이트가 많은 프로젝트를 처음부터 빌드하면 시간이 좀 걸려요. 코드를 고치는 동안에는 cargo check를 쓰고(코드 생성을 건너뛰어서 훨씬 빨라요), 속도를 잴 때는 cargo run --release를 쓰세요.
  • 대여 검사기(borrow checker)가 반발할 거예요. 여러 객체가 공유 객체 하나를 들고 바꾸거나, 객체가 부모를 다시 가리키는 것처럼 Python에서는 평범한 패턴도 스마트 포인터나 다른 설계가 필요해요. 몇 주는 답답할 거예요. 그래도 금방 훨씬 쉬워져요.

이 사이트를 순서대로 읽을 필요는 없어요. Python 개발자라면 이 경로로 Rust가 여러분이 아는 것과 가장 다른 부분에 시간을 쓸 수 있어요.

  1. Rust 시작하기와 Rust 도구와 개발 환경: Rust를 설치하고 cargo new를 실행해요.
  2. 변수, 데이터 타입, 정수 오버플로: 타입이 첫 번째 새 습관이에요.
  3. 함수, if/else, 반복문: 금방 읽혀요. 식(expression)과 빠진 세미콜론을 눈여겨보세요.
  4. 소유권, 대여, 슬라이스 순서로: 여러분에게 가장 중요한 페이지예요. 천천히 읽으세요.
  5. 문자열: String과 &str, 그리고 문자열에 인덱스로 접근할 수 없는 이유.
  6. 구조체와 열거형, 그다음 match와 패턴 매칭.
  7. Option과 Result, 그다음 사용자 정의 에러.
  8. 벡터, HashMap, HashSet과 BTreeMap.
  9. 클로저와 반복자: 여러분의 컴프리헨션과 제너레이터예요.
  10. 트레이트, 제네릭, 자주 쓰는 트레이트, 트레이트 객체.
  11. 모듈, Cargo와 크레이트, 테스트.
  12. 라이프타임과 스마트 포인터.
  13. 동시성, 공유 상태, async/await.
  14. Serde로 JSON 다루기, 그다음 clap으로 CLI 도구나 웹 API를 만들어 봐요.

두 문제 모두 Python 코드를 주고, 그걸 Rust로 옮기는(포팅하는) 문제예요.

다음 Python 코드를 Rust로 옮겨 보세요. 힌트: 이름을 순서대로 출력하려면 Vec<&str>로 모은 다음 .sort()를 호출하고 .join(", ")을 쓰세요. 평균은 나누기 전에 as f64로 변환하세요.

scores = {"Ada": 92, "Bob": 47, "Cy": 75, "Dee": 38}
passed = sorted(name for name, score in scores.items() if score >= 50)
print("passed:", ", ".join(passed))
odd_squares = [n * n for n in range(1, 10) if n % 2 == 1]
print(odd_squares)
average = sum(scores.values()) / len(scores)
print(f"average: {average:.1f}")
정답 보기
use std::collections::HashMap;
fn main() {
let scores: HashMap<&str, u32> =
HashMap::from([("Ada", 92), ("Bob", 47), ("Cy", 75), ("Dee", 38)]);
let mut passed: Vec<&str> = scores
.iter()
.filter(|(_, score)| **score >= 50)
.map(|(name, _)| *name)
.collect();
passed.sort();
println!("합격: {}", passed.join(", "));
let odd_squares: Vec<u32> = (1..10).filter(|n| n % 2 == 1).map(|n| n * n).collect();
println!("{odd_squares:?}");
let total: u32 = scores.values().sum();
let average = total as f64 / scores.len() as f64;
println!("평균: {average:.1}");
}

실행 결과:

합격: Ada, Cy
[1, 9, 25, 49, 81]
평균: 63.0
▶ 직접 실행해 보기

HashMap을 순회하면 참조의 쌍을 얻기 때문에 **score와 *name이 보여요. 참조를 따라가서 값을 꺼내는 거예요. 여기서 sort()는 빼면 안 돼요. 정렬하지 않으면 이름이 아무 순서로나 나올 수 있어요. 그리고 Rust는 정수를 몰래 실수로 바꾸지 않기 때문에 total as f64가 필요해요.

다음 Python 코드를 Rust로 옮겨 보세요. 각 줄은 name: age 형식이어야 해요. 힌트: line.split_once(':')는 Option<(&str, &str)>를 반환해요. .ok_or(...)로 Result로 바꾼 다음 ?를 쓰세요. 함수는 Result<(String, u32), String>을 반환하게 만드세요.

def parse_line(line):
if ":" not in line:
raise ValueError("missing ':'")
name, age = line.split(":", 1)
try:
return name.strip(), int(age.strip())
except ValueError as e:
raise ValueError(f"bad age: {e}")
ages = {}
for line in ["Ada: 36", "Grace 45", "Linus: forty"]:
try:
name, age = parse_line(line)
ages[name] = age
print(f"ok: {name} is {age}")
except ValueError as e:
print(f"skipped {line!r}: {e}")
print(len(ages), "saved")
정답 보기
use std::collections::HashMap;
fn parse_line(line: &str) -> Result<(String, u32), String> {
let (name, age) = line.split_once(':').ok_or("':'가 없어요")?;
let age: u32 = age.trim().parse().map_err(|e| format!("잘못된 나이: {e}"))?;
Ok((name.trim().to_string(), age))
}
fn main() {
let mut ages = HashMap::new();
for line in ["Ada: 36", "Grace 45", "Linus: forty"] {
match parse_line(line) {
Ok((name, age)) => {
println!("성공: {name}, {age}살");
ages.insert(name, age);
}
Err(e) => println!("건너뜀 {line:?}: {e}"),
}
}
println!("{}명 저장했어요", ages.len());
}

실행 결과:

성공: Ada, 36살
건너뜀 "Grace 45": ':'가 없어요
건너뜀 "Linus: forty": 잘못된 나이: invalid digit found in string
1명 저장했어요
▶ 직접 실행해 보기

두 개의 raise는 Err 값이 되고, try/except는 match가 됐어요. ok_or("':'가 없어요") 뒤의 ?는 에러가 &str이고 함수의 에러 타입은 String인데도 동작해요. ?가 From으로 에러를 변환해 주기 때문이에요. Ok 안의 순서도 눈여겨보세요. 먼저 출력하고, 그다음 insert가 name을 맵 안으로 이동시켜요. 두 줄의 순서를 바꾸면 컴파일러가 name이 이동했다고 알려 줄 거예요.

  • Python의 많은 부분을 그대로 가져갈 수 있어요. 반복자를 도는 반복문, 튜플, 클로저, match, 그리고 컴프리헨션 스타일의 데이터 처리(반복자 체인으로)까지요.
  • 크게 달라지는 건 이거예요. 실행 전에 검사하는 정적 타입, 가비지 컬렉터 대신 소유권, None 대신 Option, 예외 대신 Result + ?.
  • 클래스는 struct + impl이 되고, 덕 타이핑은 트레이트가 되고, with 블록은 자동으로 실행되는 Drop이 돼요. HashMap에는 순서가 없고, 정수에는 정해진 크기가 있어요.
  • Rust에는 GIL이 없어요. 스레드가 병렬로 돌아가고, 데이터 경쟁은 컴파일러가 막아 줘요.
  • Cargo가 pip, venv 등을 대신해요. cargo fmt, cargo clippy, cargo test가 black, ruff, pytest를 대신해요.
  • 두 언어를 섞어 쓸 수 있어요. PyO3와 maturin이 Rust 코드를 Python 모듈로 만들어 줘요.

이해도 점검

1. Rust는 Python의 None 대신 무엇을 쓰나요?

2. Python에서 b = a를 하면 같은 리스트에 이름이 두 개 생겨요. Rust에서 Vec에 let b = a;를 하면 어떻게 될까요?

3. 두 숫자가 모두 정수일 때, Rust에서 7 / 2의 결과는 무엇일까요?

4. Python 프로젝트의 느린 함수 하나를 Rust로 빠르게 만들고 싶어요. 어떤 도구를 쓸까요?

점수: 0 / 4

  • The Rust Programming Language 한국어판: 공식 책의 한국어 번역이에요. Python 개발자에게는 4장(소유권), 6장(열거형과 Option), 9장(에러 처리), 13장(클로저와 반복자)이 가장 중요해요.
  • PyO3 사용자 가이드 (영어): Python에서 Rust를 호출하는 방법(그리고 Rust에서 Python을 호출하는 방법)의 모든 것.
  • Maturin 사용자 가이드 (영어): Rust로 만든 Python 패키지를 빌드하고, 테스트하고, 배포하는 방법.
  • Rust by Example (영어): 바로 실행해 볼 수 있는 짧은 예제 모음이에요. 코드를 읽으면서 배우는 걸 좋아한다면 잘 맞아요.
  • Are We Learning Yet? (영어): Rust의 머신 러닝과 데이터 크레이트를 정리한 사이트예요.