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을 보세요.
언어 개념: Python에서 Rust로
섹션 제목: “언어 개념: Python에서 Rust로”이 표를 회화 사전처럼 써 보세요. 마지막 열은 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을 써요) |
슬라이스 |
도구: pip에서 cargo로
섹션 제목: “도구: pip에서 cargo로”많은 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에도 내 트레이트를 구현할 수 있어요.
나란히 보기: None은 Option으로
섹션 제목: “나란히 보기: None은 Option으로”Python에서는 어떤 값이든 몰래 None일 수 있고, 그 값을 쓸 때에야 알게 돼요.
def find_email(user_id): 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 { _ => 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: 등록된 이메일이 없어요알 수 없음Some("[email protected]")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: 3cat: 1end: 1이 예제의 문장은 일부러 영어로 두었어요. 글자 수(w.len() == 3), 대문자 변환, 알파벳순 정렬이 결과에 영향을 주는데, 한글은 UTF-8에서 한 글자가 3바이트라서 결과가 달라지기 때문이에요.
체인은 안쪽에서 바깥쪽이 아니라 왼쪽에서 오른쪽으로 읽히지만, 아이디어는 같아요. 제너레이터 표현식처럼 게을러서(lazy), collect(), sum(), count(), for 반복문 같은 게 값을 끌어가기 전까지는 아무 일도 일어나지 않아요. collect()는 무엇을 만들지 알아야 해서 loud에 Vec<String> 타입을 붙였어요. 그리고 정렬 단계를 눈여겨보세요. Python의 dict는 넣은 순서를 기억하지만 Rust의 HashMap은 기억하지 않아서, 출력하기 전에 항상 정렬해요. 정렬은 정렬과 검색 페이지에서 다뤄요.
나란히 보기: GIL과 진짜 스레드
섹션 제목: “나란히 보기: GIL과 진짜 스레드”표준 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개합계: 9592thread::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 라이브러리가 이렇게 만들어져요.
(활성화한 가상 환경 안에서) 프로젝트를 만들어요.
pip install maturinmaturin new --bindings pyo3 fastmathcd fastmathmaturin이 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])) # 30print(fastmath.word_count("the quick brown fox")) # 4fastmath.sum_of_squares(["a"]) # TypeError: 'str' object cannot be interpreted as an integerPyO3가 타입을 변환해 줘요. 정수가 든 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` trait3 | let mut b = a; | - value moved here4 | 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 개발자를 위한 학습 경로
섹션 제목: “Python 개발자를 위한 학습 경로”이 사이트를 순서대로 읽을 필요는 없어요. Python 개발자라면 이 경로로 Rust가 여러분이 아는 것과 가장 다른 부분에 시간을 쓸 수 있어요.
- Rust 시작하기와 Rust 도구와 개발 환경: Rust를 설치하고
cargo new를 실행해요. - 변수, 데이터 타입, 정수 오버플로: 타입이 첫 번째 새 습관이에요.
- 함수, if/else, 반복문: 금방 읽혀요. 식(expression)과 빠진 세미콜론을 눈여겨보세요.
- 소유권, 대여, 슬라이스 순서로: 여러분에게 가장 중요한 페이지예요. 천천히 읽으세요.
- 문자열:
String과&str, 그리고 문자열에 인덱스로 접근할 수 없는 이유. - 구조체와 열거형, 그다음 match와 패턴 매칭.
- Option과 Result, 그다음 사용자 정의 에러.
- 벡터, HashMap, HashSet과 BTreeMap.
- 클로저와 반복자: 여러분의 컴프리헨션과 제너레이터예요.
- 트레이트, 제네릭, 자주 쓰는 트레이트, 트레이트 객체.
- 모듈, Cargo와 크레이트, 테스트.
- 라이프타임과 스마트 포인터.
- 동시성, 공유 상태, async/await.
- Serde로 JSON 다루기, 그다음 clap으로 CLI 도구나 웹 API를 만들어 봐요.
연습 문제
섹션 제목: “연습 문제”두 문제 모두 Python 코드를 주고, 그걸 Rust로 옮기는(포팅하는) 문제예요.
1. 컴프리헨션과 dict 옮기기
섹션 제목: “1. 컴프리헨션과 dict 옮기기”다음 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.0HashMap을 순회하면 참조의 쌍을 얻기 때문에 **score와 *name이 보여요. 참조를 따라가서 값을 꺼내는 거예요. 여기서 sort()는 빼면 안 돼요. 정렬하지 않으면 이름이 아무 순서로나 나올 수 있어요. 그리고 Rust는 정수를 몰래 실수로 바꾸지 않기 때문에 total as f64가 필요해요.
2. try/except를 Result로 옮기기
섹션 제목: “2. try/except를 Result로 옮기기”다음 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 string1명 저장했어요두 개의 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의 머신 러닝과 데이터 크레이트를 정리한 사이트예요.