속도를 지키면서
메모리 사고를
없앤 언어입니다
Rust 의 강점은 C++ 만큼 빠르면서 메모리 사고와 데이터 레이스를 컴파일 단계에서 막는다는 것입니다 — 가비지 컬렉터가 없으니 멈춤도 없습니다. 그래서 Linux 커널 · Android · Cloudflare · AWS Firecracker · Deno · uv · ruff · Polars 가 전부 Rust 로 다시 쓰이고 있습니다. 이 가이드는 설치와 환경 세팅부터 기초 문법 · 소유권 · 트레이트, Axum 백엔드 서버 · 고성능 서비스 · Tauri 데스크톱 앱, 그리고 파이썬 · Node 에서 불러 쓰는 모듈까지 한 페이지로 잇습니다. 이 탭을 마치면 Rust 코드를 읽고 고칠 수 있게 됩니다.
Rust 를 실행하는 법 — cargo 하나면 됩니다
파이썬은 파일을 그냥 실행합니다. Rust 는 기계어로 번역한 뒤 그 결과물을 실행합니다.
C++ 와 같은 구조지만 결정적인 차이가 하나 있습니다 —
빌드·의존성·테스트·문서를 cargo 하나가 전부 맡습니다.
첫 프로그램 — 파이썬과 나란히
// src/main.rs
fn main() { // 시작 지점이 반드시 있어야 합니다
println!("안녕"); // print("안녕")
}
# ① 설치 — 이 한 줄이 컴파일러 + cargo + 문서를 전부 깝니다
# https://rustup.rs (윈도우는 rustup-init.exe)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# ② 프로젝트 만들기 → ③ 실행
cargo new hello # 폴더 · Cargo.toml · src/main.rs · git 저장소까지 한 번에
cd hello
cargo run # 빌드하고 바로 실행 ★ 이 한 줄이 대부분입니다
# 파이썬은 이 한 줄이 전부였습니다
python hello.py
rustup 한 번, cargo run 한 번입니다.
이 차이가 🐍 파이썬 모듈 탭에서 그대로 이어집니다 (maturin develop 한 줄, 🐍 y03).
왜 이 귀찮음을 감수하나
| 파이썬 | Rust | |
|---|---|---|
| 실행 방식 | 한 줄씩 해석 | 미리 기계어로 |
| 타입 검사 | 실행 중에 | 컴파일할 때 |
| 에러를 보는 시점 | 그 줄에 도달할 때 | 실행 전 |
| 속도 | 기준 | 수십~수백 배 |
| 메모리 관리 | 자동(GC) | 컴파일러가 검사 |
| 널 오류 | 실행 중 터짐 | 존재하지 않음 |
| 고치고 확인 | 즉시 | 빌드 기다림 |
.clone() 을 남발한 Rust 는 numpy 보다 느립니다.
빠른 이유는 메모리 배치와 복사를 내가 직접 정할 수 있기 때문이고,
그건 ⚡ 성능 탭에서 다룹니다 (⚡ e02).
이렇게도 쓸 수 있다 — cargo 명령 지도와 에러 읽는 법 🟡 중급
실무에서 실제로 쓰는 명령 여덟 개
cargo new 이름 # 실행 파일 프로젝트
cargo new 이름 --lib # 라이브러리 프로젝트 ★ 모듈을 만들 땐 이쪽
cargo check # 타입만 검사 (코드 생성 안 함) ★ 가장 자주 씁니다
# build 보다 3~5배 빠릅니다. 편집 중엔 이것만
cargo build # 디버그 빌드 → target/debug/
cargo build --release # 최적화 빌드 → target/release/ ★ 성능 측정은 반드시 이걸로
cargo run # 빌드 + 실행
cargo test # 테스트 전부 실행 (문서 안 예제까지)
cargo doc --open # 내 코드 + 의존성 문서를 만들어 브라우저로 열기 ★
cargo add rand # 의존성 추가 (Cargo.toml 을 직접 안 고쳐도 됩니다)
cargo clippy # 린트 — "이렇게 쓰는 게 낫다"를 알려 줍니다 (🛠️ b09)
cargo fmt # 자동 정렬 — 팀에서 포맷 논쟁이 사라집니다
cargo check 를 에디터에 붙여 두면
(rust-analyzer 가 자동으로 합니다) 저장하는 순간 타입 에러가 뜹니다.
Rust 개발 시간의 대부분은 “빌드 기다리기”가 아니라 “빨간 줄 지우기”입니다.
에러 메시지가 해답을 알려 줍니다 ★
error[E0308]: mismatched types
--> src/main.rs:3:20
|
3 | let x: i32 = "5";
| --- ^^^ expected `i32`, found `&str`
| |
| expected due to this
|
help: try parsing the string
|
3 | let x: i32 = "5".parse().unwrap();
| +++++++++++++++++
# ★ Rust 에러의 세 부분을 그대로 읽으면 됩니다
# ① error[E코드] — 무엇이 틀렸나
# ② --> 파일:줄:칸 + 화살표 — 정확히 어디인가
# ③ help: / note: — 대부분 고치는 방법까지 적혀 있습니다 ★
# 코드 번호로 자세한 설명 보기
rustc --explain E0308
help: 줄을 먼저 읽는 것이 빠릅니다.
특히 소유권 에러(🔐 o02)는 메시지가 고치는 방법을 거의 그대로 알려 줍니다.
실무에선 이렇게 — 툴체인을 고정하고 팀에서 같은 결과를 낸다 🔴 실무
rust-toolchain.toml — 버전을 저장소에 박아 둡니다 ★
# 프로젝트 루트에 두면 이 폴더에서는 항상 이 버전이 쓰입니다.
# 팀원·CI·내 노트북이 전부 같은 컴파일러를 쓰게 됩니다.
[toolchain]
channel = "1.83.0" # 정확한 버전 고정 ★ 실무 권장
components = ["rustfmt", "clippy"] # 없으면 자동으로 깔립니다
targets = ["x86_64-unknown-linux-musl"] # 크로스 빌드용 (🛠️ b07)
# 버전 관리 명령
rustup update # 최신 안정판으로
rustup toolchain list # 깔린 것들
rustup default 1.83.0 # 전역 기본값
rustup component add clippy rustfmt rust-src
# "왜 내 PC 에서만 빌드가 깨지지?" 의 90%는 버전 차이입니다.
# rust-toolchain.toml 을 커밋하면 이 질문 자체가 사라집니다. ★
channel = "stable" 로 두면 항상 최신을 쓰지만,
새 clippy 린트가 추가되면 어제까지 통과하던 CI 가 깨집니다.
라이브러리는 stable, 서비스·배포용은 정확한 버전 고정이 무난합니다.
프로젝트가 실제로 생기는 모양
myproj/
├─ Cargo.toml 이 프로젝트가 뭘 쓰는지 (🛠️ b02)
├─ Cargo.lock 정확히 어떤 버전이 깔렸는지 (🛠️ b03)
├─ rust-toolchain.toml 컴파일러 버전 고정
├─ .gitignore cargo new 가 만들어 줍니다 (target/ 제외)
├─ src/
│ ├─ main.rs 실행 파일의 시작점 (bin)
│ └─ lib.rs 라이브러리의 시작점 (lib) ★ 모듈은 이쪽
├─ tests/ 통합 테스트 (r11)
├─ benches/ 벤치마크 (⚡ e01)
├─ examples/ 예제 — cargo run --example 이름
└─ target/ 빌드 결과물 ★ 커밋하지 않습니다 (수 GB 까지 큽니다)
# target/ 이 디스크를 잡아먹을 때
cargo clean # 전부 지우기
du -sh target # 얼마나 큰지 (보통 수백 MB ~ 수 GB)
# 여러 프로젝트가 캐시를 공유하게 하려면 (🛠️ b10)
export CARGO_TARGET_DIR=~/.cargo-target
한 단계 더 — cargo 가 실제로 무엇을 하는가 🧬 전문가
cargo 는 rustc 를 부르는 껍데기입니다
# cargo 가 실제로 어떤 명령을 실행하는지 그대로 보기
cargo build -v
# → rustc --crate-name hello --edition=2021 src/main.rs
# --crate-type bin -C opt-level=0 -C debuginfo=2
# -L dependency=/…/target/debug/deps --extern rand=/…/librand-abc.rlib
# rustc 를 직접 부를 수도 있습니다 (의존성이 없다면)
rustc -O main.rs -o app
# 무엇이 오래 걸리는지 — 크레이트별 컴파일 시간
cargo build --timings # target/cargo-timings/*.html 리포트 ★
# 어떤 크레이트가 왜 들어왔는지 추적
cargo tree # 의존성 트리
cargo tree -i syn # syn 을 누가 끌어왔나 (역추적) ★
target/ 에는 크레이트별 결과물과 지문(fingerprint)이 있습니다.
소스·플래그·의존성 중 하나라도 바뀌면 그 크레이트만 다시 빌드합니다.
플래그를 바꾸면(예: RUSTFLAGS) 전체가 다시 빌드되므로,
릴리즈와 디버그를 오가면 느려지는 것이 정상입니다.
에디션 — 언어가 바뀌어도 옛 코드가 돕니다 ★
# Cargo.toml
[package]
edition = "2021" # 2015 · 2018 · 2021 · 2024
# 에디션은 '언어 방언'입니다. 크레이트마다 다른 에디션을 써도
# 서로 링크됩니다 — 이것이 Rust 가 하위 호환을 깨지 않고
# 언어를 개선해 온 방법입니다. ★
에디션이 실제로 바꾼 것들 (자주 만나는 것만)
2018 · 모듈 경로 규칙 정리 (extern crate 불필요)
· async/await 도입
2021 · 클로저가 구조체 필드 단위로 캡처 (🔐 o11)
· IntoIterator for [T; N] — 배열을 값으로 순회
· panic! 의 포맷 문자열 규칙 통일
2024 · unsafe 속성 표기 · gen 블록 등
# 에디션 올리기 — 자동 마이그레이션이 있습니다
cargo fix --edition
# 그다음 Cargo.toml 의 edition 을 올리고 cargo test
rustup 으로 깔고 cargo new · cargo run 두 줄이면 시작입니다.
편집 중엔 cargo check, 성능을 볼 땐 반드시 --release.
막히면 help: 줄을 먼저 읽으세요 — Rust 에러 메시지는 대부분 답까지 적어 줍니다.
팀 프로젝트라면 rust-toolchain.toml 을 커밋하는 것으로 시작하세요.변수 · 타입 — 기본이 “못 바꿈”입니다
파이썬·C++ 는 변수를 만들면 당연히 바꿀 수 있습니다. Rust 는 반대입니다.
let 은 못 바꾸고, let mut 이라야 바꿉니다.
귀찮아 보이지만, 이 기본값 하나가 나중에 동시성 버그를 통째로 막아 줍니다(⚡ e06).
선언 — 세 가지 형태만 알면 됩니다
fn main() {
let x = 5; // 못 바꿈 (기본)
// x = 6; // ❌ error: cannot assign twice to immutable variable
let mut y = 5; // 바꿀 수 있음
y = 6; // ✅
let z: i64 = 5; // 타입을 직접 적기 (필요할 때만)
// 섀도잉 — 같은 이름으로 '새 변수'를 만듭니다 ★ 파이썬엔 없는 개념
let s = "42"; // &str
let s: i32 = s.parse().unwrap(); // 같은 이름, 다른 타입
println!("{s}"); // 42
// 왜 유용한가: 변환 과정에서 이름을 새로 짓지 않아도 됩니다
// (s_str, s_num 같은 이름이 사라집니다)
}
mut 을 언제 붙이나 — 붙이지 않고 쓰다가, 컴파일러가 붙이라고 할 때 붙이면 됩니다.
에러 메시지가 정확히 그렇게 알려 줍니다(help: consider making this binding mutable).
“일단 mut 부터 붙이고 보자”는 Rust 의 이점을 스스로 버리는 습관입니다.
기본 타입 — 파이썬과 대조
| 파이썬 | Rust | 메모 |
|---|---|---|
int | i32 i64 u32… | 크기·부호를 고름 |
float | f64 f32 | 기본은 f64 |
bool | bool | 같음 |
str | String &str | 둘로 나뉨 (r06) |
list | Vec<T> | 같은 타입만 |
tuple | (A, B) | 같음 |
dict | HashMap<K,V> | r06 |
None | Option<T> | 타입으로 (r08) |
let a = 42; // i32 로 추론 (기본)
let b = 42u8; // 접미사로 지정
let c = 42_000_000;// _ 는 자릿수 구분 (값에 영향 없음)
let d = 3.0; // f64
let e = 'あ'; // char = 유니코드 스칼라 4바이트 ★ C 의 char 와 다름
let f = b'A'; // u8 (바이트 리터럴)
let g = (1, "둘"); // 튜플
let h = [0u8; 16]; // 길이 16 배열 (길이가 타입의 일부)
이렇게도 쓸 수 있다 — 정수 오버플로가 조용히 넘어가지 않습니다 🟡 중급
디버그와 릴리즈가 다르게 동작합니다 ★
let x: u8 = 255;
let y = x + 1;
// 디버그 빌드 → panic: attempt to add with overflow ★ 즉시 멈춤
// 릴리즈 빌드 → 0 (한 바퀴 돌아감, wrapping)
// ❗ 이 차이 때문에 "테스트는 통과했는데 운영에서 값이 이상하다"가 생깁니다.
// 의도를 코드에 적으면 두 빌드가 같아집니다 ↓
let a = x.checked_add(1); // Option<u8> → None (넘치면 없음) ★ 가장 안전
let b = x.saturating_add(1); // 255 → 최대값에서 멈춤
let c = x.wrapping_add(1); // 0 → 한 바퀴 (의도적일 때만)
let (d, over) = x.overflowing_add(1); // (0, true) → 값 + 넘쳤는지
// 실무 기본값: checked_* 를 쓰고 ? 로 전파 (🔐 o08)
fn safe_index(n: usize, off: usize) -> Option<usize> {
n.checked_add(off)
}
Cargo.toml 에
[profile.release] overflow-checks = true 를 넣습니다.
비용은 보통 몇 % 수준이고, 금액·인덱스 계산이 있는 서비스라면 켜 두는 편이 낫습니다(🛠️ b06).
as 변환은 조용히 잘라 냅니다
let big: i64 = 300;
let small = big as u8; // 44 ★ 경고도 없이 잘립니다
let f = 3.9_f64;
let i = f as i64; // 3 (버림)
let neg = -1_i32 as u32; // 4294967295
// ✅ 안전한 변환 — 실패를 값으로 받습니다
use std::convert::TryFrom;
let ok = u8::try_from(big); // Result<u8, _> → Err
let ok2: Result<u8, _> = big.try_into();
// ✅ 넓히는 변환은 From 으로 (절대 실패하지 않음)
let wide: i64 = i64::from(42_i32); // ✅ 명확
let wide2 = 42_i32 as i64; // 동작은 같지만 의도가 덜 드러남
// clippy 가 잡아 줍니다
// cargo clippy -- -W clippy::cast_possible_truncation
From/into(), 좁힐 때는 try_into().
as 는 “잘려도 괜찮다는 걸 내가 알고 있다”는 표시로만 쓰세요.
FFI 경계(🔗 f03)에서 특히 자주 사고가 납니다.
실무에선 이렇게 — usize · 상수 · 타입 별칭 🔴 실무
usize 는 “이 기계에서 인덱스로 쓸 수 있는 크기” ★
let v = vec![1, 2, 3];
let i: usize = 0; // 인덱스는 항상 usize
let x = v[i];
// 64비트에서는 u64, 32비트(wasm32 등)에서는 u32 크기입니다.
// → WASM 으로 빌드할 때 값이 안 맞는 사고가 여기서 납니다 (🟨 j08)
// ❌ 뺄셈이 위험합니다 — usize 는 음수가 없습니다
let n: usize = 3;
// let d = n - 5; // 디버그: panic / 릴리즈: 거대한 수
// ✅
let d = n.checked_sub(5).unwrap_or(0);
let d2 = n.saturating_sub(5); // 0
// 길이 비교는 뺄셈 말고 비교로
if v.len() > 5 { /* … */ } // ✅
// if v.len() - 5 > 0 { } // ❌ 언젠가 터집니다
const · static · 타입 별칭
// const — 값이 쓰이는 자리에 '복사되어 박힙니다' (주소 없음)
const MAX_USERS: usize = 10_000;
const TIMEOUT: Duration = Duration::from_secs(30); // const fn 이면 가능
// static — 프로그램 전체에서 '한 자리'를 차지합니다 (주소 있음)
static APP_NAME: &str = "myapp";
static COUNTER: AtomicU64 = AtomicU64::new(0); // 전역 카운터 (⚡ e06)
// ❗ static mut 은 쓰지 마세요 — unsafe 이고 2024 에디션에서 금지됩니다
// 대신 Atomic* · OnceLock · Mutex 를 씁니다 (🔬 d06)
use std::sync::OnceLock;
static CONFIG: OnceLock<Config> = OnceLock::new();
fn config() -> &'static Config {
CONFIG.get_or_init(|| Config::load())
}
// 타입 별칭 — 긴 타입에 이름을 붙입니다 (새 타입이 아닙니다)
type Result<T> = std::result::Result<T, MyError>; // ★ 아주 흔한 관용구 (🔐 o09)
type Grid = Vec<Vec<u8>>;
const, 공유 상태면 static + 동기화 타입.
const 는 쓰인 곳마다 복사되므로 큰 배열을 const 로 두면
바이너리가 커집니다(🛠️ b10).
한 단계 더 — 타입 추론은 어디까지 보는가 🧬 전문가
추론은 함수 전체를 봅니다 (뒤도 봅니다) ★
fn main() {
let mut v = Vec::new(); // 여기선 Vec<?> — 아직 모릅니다
v.push(1u8); // ★ 여기서 Vec<u8> 로 확정
// 파이썬·C++ 감각과 다른 지점: '뒤에 오는 코드'가 앞의 타입을 정합니다
// 어떤 타입인지 컴파일러에게 물어보는 요령
// let _: () = v; // 일부러 틀리게 써서 에러 메시지로 확인
// → expected `()`, found `Vec<u8>` ★ 디버깅에 자주 씁니다
// 정수 리터럴의 기본값
let a = 1; // 아무 단서 없으면 i32
let b = 1.0; // f64
let c: u64 = 1; // 단서가 있으면 그쪽
// 터보피시 — 타입을 함수 쪽에 직접 지정
let n = "42".parse::<i32>().unwrap();
let s = (1..=5).collect::<Vec<_>>(); // _ 는 "네가 정해"
}
추론이 실패하는 대표적 자리
// ❌ collect 는 무엇으로 모을지 알 수 없습니다
// let v = (1..5).collect();
// error[E0282]: type annotations needed
// ✅ 세 가지 중 아무거나
let v: Vec<i32> = (1..5).collect();
let v = (1..5).collect::<Vec<i32>>();
let v = (1..5).collect::<Vec<_>>(); // 원소 타입은 추론
// ❌ Default 도 마찬가지
// let x = Default::default();
let x: u32 = Default::default(); // ✅ 0
// 실수 정밀도 — 파이썬과 동일한 함정입니다
assert!(0.1 + 0.2 != 0.3);
assert!((0.1 + 0.2 - 0.3_f64).abs() < f64::EPSILON); // ✅ 비교는 이렇게
// 금액은 f64 대신 정수(원 단위) 또는 rust_decimal 크레이트를 쓰세요 ★
f64 는 Ord 를 구현하지 않습니다(NaN 때문에 순서가 없음).
그래서 vec_of_f64.sort() 가 컴파일되지 않습니다.
sort_by(|a, b| a.partial_cmp(b).unwrap()) 또는
sort_by(f64::total_cmp) 를 씁니다.
let 은 못 바꾸고 let mut 이라야 바뀝니다.
mut 은 컴파일러가 붙이라고 할 때만 붙이세요.
정수는 넘칠 때 무슨 일이 일어날지를 checked_/saturating_ 로 코드에 적고,
좁히는 변환은 try_into() 로 하세요. as 는 조용히 잘라 냅니다.함수와 표현식 — 세미콜론 하나가 반환을 바꿉니다
Rust 에서는 거의 모든 것이 값을 내놓는 식(expression)입니다.
if 도 match 도 블록 {} 도 값입니다.
그래서 마지막 줄에 세미콜론을 빼면 그게 반환값이 됩니다.
함수 — 파이썬과 나란히
fn add(a: i32, b: i32) -> i32 {
a + b // ★ 세미콜론 없음 = 이것이 반환값
}
fn add2(a: i32, b: i32) -> i32 {
return a + b; // 이렇게 써도 됩니다 (조기 반환엔 필수)
}
fn shout(msg: &str) { // 반환 타입이 없으면 () = 아무것도 안 돌려줌
println!("{}!", msg);
}
// 인자 타입과 반환 타입은 '반드시' 적어야 합니다 (추론 안 함)
// 함수 안의 지역 변수만 추론합니다 ★
# 파이썬
def add(a, b):
return a + b
a + b; 처럼 세미콜론을 붙이면
그 줄은 “값을 버리는 문장”이 되어 함수가 () 를 반환합니다.
에러: expected `i32`, found `()` → 세미콜론을 지우세요.
모든 것이 값입니다 ★
// if 가 값입니다 (파이썬의 삼항식 자리)
let grade = if score >= 90 { "A" } else { "B" };
// 블록도 값입니다 — 계산 과정을 묶어 둘 때 유용합니다
let area = {
let w = 3.0;
let h = 4.0;
w * h // ★ 세미콜론 없음
};
// match 도 값입니다 (r04)
let kind = match n {
0 => "영",
1..=9 => "한 자리",
_ => "여러 자리",
};
// loop 도 값을 낼 수 있습니다 (break 에 값을 실어서)
let found = loop {
let x = next();
if x > 100 { break x; }
};
// ❗ if 의 두 갈래는 타입이 같아야 합니다
// let x = if c { 1 } else { "하나" }; // ❌ 타입 불일치
이렇게도 쓸 수 있다 — 여러 값 반환과 구조 분해 🟡 중급
튜플 · 구조체 · 구조 분해
// 값 두 개를 돌려주기 — 파이썬의 return a, b 와 같습니다
fn min_max(v: &[i32]) -> (i32, i32) {
(*v.iter().min().unwrap(), *v.iter().max().unwrap())
}
let (lo, hi) = min_max(&data); // 구조 분해
// 값이 셋 이상이면 이름을 붙이세요 — 순서 실수를 막습니다 ★
struct Stats { min: i32, max: i32, mean: f64 }
fn stats(v: &[i32]) -> Stats { /* … */ }
let Stats { min, mean, .. } = stats(&data); // 필요한 것만 꺼내기
// 중첩 분해도 됩니다
let ((a, b), c) = ((1, 2), 3);
// 무시하려면 _
let (_, hi) = min_max(&data);
struct 로 이름을 붙이세요.
fn f() -> (bool, u32, String) 를 받는 쪽은 순서를 매번 문서에서 확인해야 합니다.
인자를 어떤 타입으로 받을 것인가 ★
// ❌ String 을 받으면 호출자가 소유권을 넘겨야 합니다 (r05)
fn greet_bad(name: String) { println!("안녕 {name}"); }
// greet_bad(s); → 이후 s 를 못 씁니다
// ✅ 읽기만 한다면 &str 로 받으세요 — 가장 넓게 받아집니다
fn greet(name: &str) { println!("안녕 {name}"); }
greet("리터럴"); // ✅
greet(&owned_string); // ✅ String → &str 자동 변환 (Deref, 🔐 o07)
// 슬라이스도 마찬가지
fn sum(v: &[i32]) -> i32 { v.iter().sum() } // Vec 도 배열도 받습니다
sum(&vec); sum(&[1,2,3]);
// 인자 타입 고르는 표
// 읽기만 → &str, &[T], &T
// 고칠 것 → &mut T
// 가져가 보관 → String, Vec<T>, T ★ 소유권을 받는다는 신호
실무에선 이렇게 — 유연한 시그니처와 impl Trait 🔴 실무
호출부를 편하게 만드는 관용구
use std::path::Path;
// ① 문자열·경로를 아무 형태로나 받기
fn open(p: impl AsRef<Path>) -> std::io::Result<File> {
File::open(p.as_ref())
}
open("a.txt"); open(String::from("b.txt")); open(&path_buf); // 전부 ✅
// ② 어떤 이터레이터든 받기
fn total(xs: impl IntoIterator<Item = i32>) -> i32 {
xs.into_iter().sum()
}
total(vec![1,2,3]); total(1..=10); // 전부 ✅
// ③ 반환 타입을 감추기 — 이름 없는 이터레이터·클로저를 돌려줄 때
fn evens(n: u32) -> impl Iterator<Item = u32> {
(0..n).filter(|x| x % 2 == 0)
}
// ④ 기본값이 없으므로 Option 이나 빌더로 표현합니다 ★
fn connect(host: &str, port: Option<u16>) { let port = port.unwrap_or(8080); }
new/with_capacity)나 빌더 패턴으로 표현합니다.
이 제약 덕분에 “어느 오버로드가 불렸는지” 헷갈릴 일이 없습니다.
실패할 수 있는 함수의 모양
// Rust 에는 예외가 없습니다. 실패는 '반환값'입니다 (🔐 o08)
fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
s.parse::<u16>()
}
// 없을 수도 있다 → Option
fn first_word(s: &str) -> Option<&str> {
s.split_whitespace().next()
}
// 함수 시그니처만 봐도 알 수 있는 것 ★
// -> T 반드시 성공
// -> Option<T> 없을 수 있음
// -> Result<T,E> 실패할 수 있고, 이유(E)가 있음
//
// 파이썬은 이걸 docstring 에 적었지만 Rust 는 '타입'에 적습니다.
// 그래서 호출자가 처리를 빠뜨리면 컴파일이 되지 않습니다.
한 단계 더 — never 타입 · const fn · 인라인 🧬 전문가
! — 절대 돌아오지 않는 타입
fn die(msg: &str) -> ! { // 반환하지 않는다는 타입
panic!("{msg}");
}
// ! 는 '어떤 타입으로도 강제 변환'됩니다. 그래서 이게 컴파일됩니다 ↓
let x: i32 = match opt {
Some(v) => v,
None => return, // return 의 타입은 !
};
let y: u32 = if ok { 1 } else { panic!("실패") }; // panic! 의 타입도 !
// continue · break · std::process::exit 도 전부 ! 입니다.
// 이 규칙 덕분에 match 의 한 갈래에서 탈출해도 타입이 맞습니다. ★
const fn 과 인라인 힌트
// const fn — 컴파일 타임에도 계산할 수 있는 함수
const fn kib(n: usize) -> usize { n * 1024 }
const BUF: usize = kib(64); // 컴파일 타임에 계산되어 상수로 박힘
let runtime = kib(x); // 런타임 호출도 됩니다 (제약 없음)
// 인라인 힌트 — 대부분 필요 없습니다 (컴파일러가 더 잘 압니다)
#[inline] // 크레이트 경계를 넘어 인라인 가능하게
pub fn tiny(x: u32) -> u32 { x + 1 }
#[inline(always)] // 강제 — 측정 없이 쓰면 대개 손해입니다 ★
#[inline(never)] // 프로파일링에서 함수 이름을 남기고 싶을 때
// ★ 실무 규칙: 제네릭이 아닌 '작은 pub 함수'에만 #[inline] 을 답니다.
// 제네릭 함수는 어차피 호출 크레이트에서 코드가 생성됩니다 (🔬 d10)
always 를 남발하면 코드가 커져 캐시가 밀려 오히려 느려집니다(⚡ e01·e04).
if·match·블록이 전부 값이라 임시 변수가 크게 줄어듭니다.
인자는 읽기만 하면 &str/&[T], 보관하면 String/Vec<T>.
실패는 예외가 아니라 Option·Result 라는 반환 타입으로 표현합니다.조건 · 반복 · match — 빠뜨리면 빌드가 막힙니다
if 와 for 는 파이썬과 거의 같습니다. 새로 배울 것은 match 하나입니다.
match 는 “모든 경우를 적었는지” 컴파일러가 세어 줍니다.
이 성질이 나중에 코드를 고칠 때 빠뜨린 곳을 전부 찾아 주는 안전망이 됩니다.
조건과 반복 — 파이썬과 나란히
// 조건 — 괄호 없음, 중괄호 필수, bool 만 받습니다
if n > 0 {
println!("양수");
} else if n == 0 {
println!("영");
} else {
println!("음수");
}
// ❗ if n { } 처럼 정수를 조건에 못 씁니다 (파이썬·C 와 다름)
// 반복 — 세 가지
for x in &v { println!("{x}"); } // 파이썬 for x in v
for i in 0..v.len() { } // range(len(v))
for (i, x) in v.iter().enumerate() { } // enumerate(v)
for (k, val) in &map { } // dict.items()
while n > 0 { n -= 1; }
loop { // while True
if done() { break; }
}
// 라벨 — 중첩 루프를 한 번에 빠져나오기
'outer: for a in 0..10 {
for b in 0..10 {
if a * b > 50 { break 'outer; }
}
}
match — switch 의 상위 호환 ★
let msg = match code {
200 => "성공",
301 | 302 => "리다이렉트", // 여러 값
400..=499 => "요청 오류", // 범위
n if n >= 500 => "서버 오류", // 가드 (조건 추가)
_ => "알 수 없음", // 나머지 전부
};
// ★ 결정적 차이: 빠짐없이 적지 않으면 컴파일이 안 됩니다
enum State { Idle, Running, Done }
fn label(s: State) -> &'static str {
match s {
State::Idle => "대기",
State::Running => "실행 중",
// State::Done 을 빠뜨리면 ↓
// error[E0004]: non-exhaustive patterns: `State::Done` not covered
State::Done => "완료",
}
}
State::Failed 를 추가하면,
처리하지 않은 모든 match 가 컴파일 에러로 뜹니다.
“고쳐야 할 곳을 컴파일러가 전부 찾아 주는” 경험이 Rust 의 가장 큰 실무 이점입니다.
이렇게도 쓸 수 있다 — if let · let else · while let 🟡 중급
한 갈래만 관심 있을 때
// match 를 쓰기엔 과한 경우
if let Some(v) = maybe {
println!("값 {v}");
} // else 도 붙일 수 있습니다
// ★ let else — "아니면 나간다" (Rust 1.65+) · 실무에서 매우 자주 씁니다
fn handle(input: &str) -> Result<u32, MyError> {
let Ok(n) = input.parse::<u32>() else {
return Err(MyError::BadInput);
};
// 여기서부터 n 은 그냥 u32 입니다 — 들여쓰기가 늘지 않습니다 ★
Ok(n * 2)
}
// while let — 값이 나오는 동안 계속
while let Some(top) = stack.pop() {
println!("{top}");
}
// 중첩 Option 풀기
if let Some(Some(inner)) = nested { }
let else 를 쓸 신호입니다.
“실패면 나간다”를 위쪽에 몰아 두면 본문이 성공 경로만 남아 훨씬 읽힙니다.
같은 목적의 ? 연산자는 🔐 o08 에서 봅니다.
패턴이 값을 꺼내 옵니다
enum Shape {
Circle { r: f64 },
Rect { w: f64, h: f64 },
Point,
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { r } => 3.14159 * r * r, // 필드를 바로 꺼냄
Shape::Rect { w, h } => w * h,
Shape::Point => 0.0,
}
}
// 튜플·슬라이스·구조체 전부 패턴이 됩니다
match point {
(0, 0) => "원점",
(x, 0) | (0, x) => "축 위",
_ => "그 외",
};
match v.as_slice() {
[] => "빈",
[one] => "하나",
[first, .., last] => "여럿", // 앞·뒤만 보기 ★
}
// @ 바인딩 — 조건을 확인하면서 값도 받기
match n {
x @ 1..=9 => println!("한 자리 {x}"),
_ => {}
}
실무에선 이렇게 — match 로 상태를 관리한다 🔴 실무
상태 전이를 타입으로 적기
#[derive(Debug, Clone, Copy, PartialEq)]
enum Conn { Idle, Connecting { retries: u8 }, Ready, Failed }
fn step(state: Conn, ev: Event) -> Conn {
use Conn::*;
match (state, ev) {
(Idle, Event::Start) => Connecting { retries: 0 },
(Connecting { retries }, Event::Timeout) if retries < 3
=> Connecting { retries: retries + 1 },
(Connecting { .. }, Event::Timeout) => Failed,
(Connecting { .. }, Event::Ok) => Ready,
(Ready, Event::Close) => Idle,
(s, _) => s, // 그 외 이벤트는 무시
}
}
// ★ 상태와 이벤트를 튜플로 묶어 match 하면
// '어떤 상태에서 어떤 이벤트가 오면 어떻게 되는가'가 표처럼 보입니다.
// 상태를 추가하면 컴파일러가 빠진 조합을 짚어 줍니다.
_ => {} 를 습관적으로 쓰지 마세요 — 나머지를 전부 삼키면
새 배리언트를 추가해도 컴파일러가 알려 주지 못합니다.
“정말 나머지 전부”일 때만 쓰고, 그렇지 않으면 배리언트를 나열하세요.
반복문보다 이터레이터가 먼저입니다
// ❌ 인덱스 루프 — 범위 검사가 매번 들어가고 실수하기 쉽습니다
let mut sum = 0;
for i in 0..v.len() { sum += v[i]; }
// ✅ 이터레이터 — 같은 속도이고 의도가 드러납니다 (🔐 o12 · ⚡ e03)
let sum: i32 = v.iter().sum();
let evens: Vec<_> = v.iter().filter(|x| **x % 2 == 0).collect();
let names: Vec<String> = users.iter().map(|u| u.name.clone()).collect();
// 조건 검사
if v.iter().any(|x| *x < 0) { /* 음수가 있다 */ }
if v.iter().all(|x| *x > 0) { /* 전부 양수 */ }
let pos = v.iter().position(|x| *x == 42); // Option<usize>
// 두 리스트 동시 순회 (zip)
for (a, b) in xs.iter().zip(ys.iter()) { }
// 청크 단위 처리 — 이미지·버퍼 다룰 때 자주 씁니다 (🐍 y06)
for row in pixels.chunks_exact(width * 3) { }
한 단계 더 — 패턴에도 소유권이 있습니다 🧬 전문가
match 가 값을 '가져갈' 수 있습니다 ★
let opt: Option<String> = Some("안녕".into());
match opt {
Some(s) => println!("{s}"), // ★ s 가 String 을 '가져갑니다'
None => {}
}
// println!("{:?}", opt); // ❌ opt 는 이미 이동됨 (r05)
// ✅ 빌려 보기 — 셋 중 아무거나
match &opt {
Some(s) => println!("{s}"), // s: &String (match ergonomics 로 자동)
None => {}
}
match opt.as_ref() { Some(s) => {}, None => {} }
if let Some(s) = &opt { }
// 2021 에디션의 'match ergonomics' — &Option<T> 를 match 하면
// 패턴 안의 바인딩이 자동으로 참조가 됩니다. 편하지만,
// "왜 여기선 &가 붙지?"의 원인이 이것입니다. ★
match 는 어떤 기계어가 되나
// ① 연속된 정수 → 점프 테이블 (O(1))
match code { 0 => a(), 1 => b(), 2 => c(), _ => d() }
// ② 흩어진 값 → 이진 탐색 또는 비교 연쇄
match code { 3 => a(), 400 => b(), 9999 => c(), _ => d() }
// ③ enum → 태그(판별자) 하나만 읽고 분기합니다
// Option<&T> · Option<Box<T>> 는 '널 포인터 최적화'로
// 태그조차 없이 포인터가 0인지로 구분합니다 ★
assert_eq!(size_of::<Option<&u8>>(), size_of::<&u8>()); // 8 == 8
// 확인 방법 — godbolt.org 또는
// cargo asm (cargo-show-asm) 로 실제 어셈블리를 봅니다 (⚡ e03)
// ★ 성능 결론: match 는 if 연쇄보다 느리지 않습니다.
// 가독성을 이유로 피할 필요가 전혀 없습니다.
Option<extern "C" fn()> 이나 Option<&T> 는
C 의 널 가능 포인터와 메모리 배치가 정확히 같습니다.
그래서 경계 너머로 그대로 주고받을 수 있습니다(🔗 f03·f07).
if·for 는 파이썬과 거의 같고, 새로 배울 건 match 하나입니다.
빠짐없음 검사가 나중에 코드를 고칠 때 안전망이 되므로 _ => {} 를 남용하지 마세요.
갈래가 하나면 if let·let else, 반복은 인덱스보다 이터레이터를 먼저 떠올리세요.소유권 첫 만남 — 값을 넘기면 원래 것을 못 씁니다
Rust 를 배우다 처음 벽에 부딪히는 자리입니다. 겁먹을 필요는 없습니다 — “값의 주인은 항상 한 명” 이 한 줄이 전부입니다. 여기서는 에러 메시지를 알아보는 수준까지만 하고, 깊은 규칙은 🔐 소유권 탭에서 봅니다.
이동(move) — 처음 만나는 에러
let s1 = String::from("안녕");
let s2 = s1; // ★ 복사가 아니라 '이동' — 주인이 s2 로 바뀝니다
// println!("{s1}"); // ❌ error[E0382]: borrow of moved value: `s1`
// 함수에 넘겨도 이동입니다
fn consume(s: String) { }
let s = String::from("안녕");
consume(s);
// println!("{s}"); // ❌ 같은 에러
// ✅ 해결 세 가지
let s = String::from("안녕");
consume(s.clone()); // ① 복사본을 준다 (비용 있음)
println!("{s}"); // s 는 그대로
fn peek(s: &String) { } // ② 빌려 준다 ★ 대부분 이게 정답
peek(&s);
println!("{s}");
let s = consume_and_return(s); // ③ 돌려받는다 (드묾)
s2 = s1 이면 같은 객체를 둘이 가리킵니다.
Rust 는 주인이 옮겨 가고 앞의 이름은 무효가 됩니다.
이 규칙 덕분에 “누가 언제 해제하나”가 항상 명확해서 GC 가 필요 없습니다.
이동하지 않는 타입도 있습니다 (Copy)
let a = 5;
let b = a; // 복사 (i32 는 Copy)
println!("{a} {b}"); // ✅ 둘 다 멀쩡
// Copy 인 것들 — '스택에 통째로 들어가는 작고 단순한 값'
// i32 u8 f64 bool char
// &T (불변 참조)
// 위 타입들로만 이루어진 튜플·배열·구조체 (#[derive(Copy, Clone)] 필요)
// Copy 가 아닌 것들 — '힙을 가지고 있거나 해제가 필요한 값'
// String Vec<T> Box<T> File HashMap 대부분의 구조체
#[derive(Clone, Copy)] // 이렇게 하면 내 타입도 Copy 가 됩니다
struct Point { x: f64, y: f64 }
// ★ 판단 기준: "이걸 통째로 memcpy 해도 문제없나?" 가 Copy 의 조건입니다.
// 힙 포인터가 들어 있으면 두 주인이 같은 메모리를 해제하게 되므로 불가합니다.
Copy 에 Clone 이 따라오나 — Copy 는 “비트 복사로 충분”,
Clone 은 “복사하는 방법이 있다”입니다. 전자는 후자의 특수한 경우라
Copy 를 붙이려면 Clone 도 있어야 합니다.
이렇게도 쓸 수 있다 — 에러 메시지를 해석하는 요령 🟡 중급
에러가 알려 주는 세 가지 ★
error[E0382]: borrow of moved value: `s`
--> src/main.rs:5:20
|
2 | let s = String::from("안녕");
| - move occurs because `s` has type `String`,
| which does not implement the `Copy` trait
3 | consume(s);
| - value moved here ← ② 어디서 옮겨졌나
5 | println!("{s}");
| ^^^ value borrowed here after move ← ③ 어디서 다시 썼나
|
help: consider cloning the value if the performance cost is acceptable
|
3 | consume(s.clone());
| ++++++++
# 읽는 순서
# ① 왜 이동됐나 → 그 타입이 Copy 가 아니라서
# ② 어디서 이동 → 그 줄에서 소유권이 넘어감
# ③ 어디서 재사용 → 여기가 에러 지점
# help: 가 제안하는 clone 은 '가장 쉬운' 답이지 '가장 좋은' 답은 아닙니다 ★
clone 을 쓸 때와 쓰지 말아야 할 때
// ✅ 써도 되는 경우 — 짧고, 한 번이고, 크기가 작다
let name = user.name.clone(); // 문자열 하나 · 루프 밖
// ❌ 하지 말아야 하는 경우 — 루프 안에서 큰 데이터
for _ in 0..1_000_000 {
process(data.clone()); // 100만 번 복사 ★ 여기서 성능이 죽습니다
}
// ✅
for _ in 0..1_000_000 {
process(&data); // 빌려 주기
}
// ★ 초심자 처방: "일단 clone 으로 컴파일부터 통과시키고,
// 나중에 & 로 바꾼다"는 실제로 좋은 전략입니다.
// 단, 루프 안의 clone 만은 처음부터 피하세요 (⚡ e02).
// clone 이 얼마나 비싼지 감각
// i32 → 사실상 0 (Copy)
// String 20자 → 힙 할당 1회 + memcpy 20바이트
// Vec 100만개 → 힙 할당 1회 + memcpy 4MB ★ 이건 비쌉니다
// Arc<T> → 카운터 +1 만 (얕은 복사) ★ 싸다 (🔐 o10)
실무에선 이렇게 — 소유권을 설계로 푼다 🔴 실무
함수 시그니처가 곧 계약입니다
// 읽기만 한다 — 가장 흔한 경우
fn analyze(data: &[f64]) -> Stats { }
// 고친다
fn normalize(data: &mut [f64]) { }
// 가져가서 보관한다 (소유권 이전이 '의도'일 때만)
fn store(self, data: Vec<f64>) { self.buf = data; }
// 변환한다 — 받아서 다른 것으로 돌려주기 (복사 없이)
fn into_bytes(s: String) -> Vec<u8> { s.into_bytes() }
// ★ 이름 관례로 의도를 드러냅니다 (표준 라이브러리 전체가 이 규칙)
// as_* 빌려서 다른 뷰로 (as_str, as_slice) — 공짜
// to_* 복사해서 새로 만듦 (to_string, to_vec) — 비용 있음
// into_* 소유권을 가져가 변환 (into_bytes) — 원본 사라짐
to_owned() 가 비싸고 as_ref() 가 공짜라는 것이 이름에 이미 적혀 있습니다.
구조체를 부분적으로 옮기기
struct Config { name: String, port: u16 }
let c = Config { name: "a".into(), port: 80 };
let n = c.name; // ★ 필드 단위 이동 — c.name 만 무효가 됩니다
let p = c.port; // u16 은 Copy 라 그대로
// println!("{}", c.name); // ❌ println!("{}", c.port); // ✅
// 전체를 분해하기
let Config { name, port } = c; // c 는 통째로 소모
// ★ 자주 쓰는 탈출구 — 필드를 통째로 꺼내며 기본값을 남기기
let taken = std::mem::take(&mut cfg.name); // name 은 "" 이 됨
let old = std::mem::replace(&mut cfg.name, "새 값".into());
std::mem::swap(&mut a, &mut b); // 두 값 교환
// 이 셋은 "빌린 상태에서 값을 꺼내야 할 때"의 정석입니다.
// &mut self 메서드 안에서 필드를 소모해야 할 때 반드시 필요합니다 (🔐 o02)
한 단계 더 — 이동은 실제로 무슨 일인가 🧬 전문가
이동은 memcpy 이고, 소멸이 옮겨 갑니다
// String 은 실제로 이렇게 생겼습니다 (스택 24바이트)
// { ptr: *mut u8, cap: usize, len: usize }
//
// let s2 = s1; 은 이 24바이트를 memcpy 하고
// '컴파일러가 s1 을 더 이상 쓰지 못하게 표시'할 뿐입니다.
// 힙 데이터는 건드리지 않습니다 — 그래서 이동은 공짜에 가깝습니다. ★
// 그럼 s1 은 언제 해제되나? → 해제되지 않습니다. s2 가 해제합니다.
// (이것이 C++ 의 이동과 다른 점: C++ 는 '빈 껍데기'가 남아 소멸자가 또 돕니다)
// 최적화 후에는 memcpy 조차 대부분 사라집니다 (같은 스택 슬롯 재사용).
// 다만 '큰 구조체를 값으로 여러 번 반환'하면 실제 복사가 남을 수 있습니다.
fn big() -> [u8; 1_000_000] { [0; 1_000_000] } // 스택 1MB 이동 위험
fn big_ok() -> Box<[u8; 1_000_000]> { Box::new([0; 1_000_000]) } // ✅ 포인터만
Drop 순서와 스코프
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) { println!("{} 해제", self.0); }
}
fn main() {
let _a = Noisy("A");
let _b = Noisy("B");
{
let _c = Noisy("C");
} // C 해제
println!("끝");
} // B 해제 → A 해제 ★ 선언의 역순
// 출력: C 해제 / 끝 / B 해제 / A 해제
// ★ 실무에서 이게 중요한 순간
// 락 가드(MutexGuard)와 파일 핸들의 해제 순서가 데드락·플러시 문제를 만듭니다.
// 일찍 놓고 싶으면 명시적으로:
drop(guard); // 지금 해제
let _ = &value; // (drop(&x) 는 참조만 버릴 뿐 아무 일도 안 합니다)
// _ 로만 이름 지으면 즉시 해제됩니다 — 흔한 함정 ★
let _ = Noisy("즉시"); // 바로 "즉시 해제" 가 출력됩니다
let _keep = Noisy("유지"); // 스코프 끝까지 살아 있습니다
let _ = mutex.lock() 는 락을 잡자마자 놓습니다.
let _guard = mutex.lock() 라야 스코프 동안 유지됩니다.
이름 없는 _ 와 _이름 의 차이가 실제 버그가 되는 대표 사례입니다(⚡ e07).
i32, &T)만 Copy 로 복사됩니다.
막히면 우선 & 로 빌려 주기를 시도하고, 그게 안 될 때만 clone() —
단 루프 안의 clone 만은 처음부터 피하세요. 자세한 규칙은 🔐 소유권 탭에서 이어집니다.Vec · String · 슬라이스 — 파이썬 list·str 의 자리
Vec<T> 가 파이썬 list, String 이 str 입니다.
다만 문자열이 두 종류(String 과 &str)라는 것,
그리고 인덱싱이 바이트 단위라는 것이 처음엔 걸립니다. 이 둘만 넘으면 됩니다.
Vec — 파이썬 list 와 나란히
let mut v: Vec<i32> = Vec::new();
let mut v = vec![1, 2, 3]; // 리터럴 매크로
let mut v = vec![0u8; 1024]; // 0 을 1024개 (파이썬 [0]*1024)
v.push(4); // append
v.pop(); // pop() → Option<i32>
v.len(); v.is_empty();
v.insert(0, 9); v.remove(0);
v.extend([7, 8]); // extend
v.sort(); v.reverse(); v.dedup(); // 정렬·뒤집기·중복 제거(인접)
v.contains(&3);
// 인덱싱 — 두 가지
let x = v[0]; // 범위를 벗어나면 panic
let x = v.get(0); // Option<&i32> ★ 안전한 쪽
let x = v.get(0).copied().unwrap_or(0);
// 순회 세 가지 — 이 구분이 중요합니다 ★
for x in &v { } // &i32 빌려 보기
for x in &mut v { } // &mut i32 고치기
for x in v { } // i32 가져가기 (이후 v 를 못 씀)
Vec::with_capacity(n) 은
재할당을 없애 큰 루프에서 눈에 띄게 빨라집니다(⚡ e02).
push 는 용량이 차면 2배로 늘리며 전체를 복사합니다.
String 과 &str — 왜 둘인가 ★
let owned: String = String::from("안녕"); // 힙에 내 것 (늘어날 수 있음)
let borrowed: &str = "안녕"; // 어딘가를 가리키는 창 (고정)
// 관계는 Vec<T> ↔ &[T] 와 정확히 같습니다
// String ↔ &str (소유 ↔ 빌림)
// Vec<T> ↔ &[T]
// 서로 오가기
let s: String = borrowed.to_string(); // 복사 (비용 있음)
let s: String = borrowed.to_owned(); // 같은 것
let r: &str = &owned; // 공짜 (Deref)
let r: &str = owned.as_str(); // 같은 것
// ★ 실무 규칙 하나로 요약
// 함수 인자 → &str (가장 넓게 받힘)
// 구조체 필드 → String (수명 고민이 사라짐)
// 반환값 → String (빌린 것을 돌려주려면 라이프타임이 필요 · 🔐 o03)
let mut s = String::new();
s.push_str("안"); s.push('녕');
s += " 세상";
let joined = format!("{}-{}", a, b); // 파이썬 f-string 자리
let n: i32 = "42".parse().unwrap(); // int("42")
이렇게도 쓸 수 있다 — UTF-8 이라서 다른 것들 🟡 중급
문자열 인덱싱이 안 됩니다 ★
let s = "안녕hi";
// let c = s[0]; // ❌ 컴파일 에러 — String 은 인덱싱 불가
s.len(); // 8 ★ 바이트 수입니다 (한글 3바이트씩)
s.chars().count(); // 4 문자 수 (O(n) 이니 자주 부르지 마세요)
// 문자로 순회
for c in s.chars() { } // '안' '녕' 'h' 'i'
for (i, c) in s.char_indices() { } // (0,'안') (3,'녕') (6,'h') (7,'i')
for b in s.bytes() { } // u8 8개
// n번째 문자 꺼내기
let third = s.chars().nth(2); // Option<char>
// 슬라이싱은 '바이트 경계'로만 됩니다
let head = &s[0..3]; // "안" ✅ 경계가 맞음
// let bad = &s[0..1]; // ❌ panic: byte index 1 is not a char boundary
let safe = s.get(0..1); // None ★ 패닉 대신 Option
// 자주 쓰는 조작
s.trim(); s.to_lowercase(); s.replace("hi", "야");
s.split(',').collect::<Vec<_>>();
s.starts_with("안"); s.contains("녕");
s.split_whitespace(); s.lines();
str 은 문자 단위 인덱싱을 위해
내부적으로 여러 표현을 씁니다. Rust 는 항상 UTF-8 하나로 두고
비용이 드는 연산을 눈에 보이게 만들었습니다.
덕분에 파일·네트워크 바이트를 그대로 문자열로 다룰 수 있습니다(🐍 y04).
슬라이스 — 복사 없이 들여다보기
let v = vec![1, 2, 3, 4, 5];
let all: &[i32] = &v; // 전체
let mid: &[i32] = &v[1..4]; // [2,3,4] ★ 복사 없음 (포인터+길이)
let head = &v[..2]; // [1,2]
let tail = &v[3..]; // [4,5]
// 슬라이스는 "포인터 + 길이" 두 워드입니다 (fat pointer)
assert_eq!(std::mem::size_of::<&[i32]>(), 16); // 64비트에서
// 함수는 슬라이스를 받는 것이 정석입니다
fn sum(xs: &[i32]) -> i32 { xs.iter().sum() }
sum(&v); // Vec 도
sum(&[1, 2, 3]); // 배열도
sum(&v[1..3]); // 부분도 ✅ 전부 그냥 됩니다
// 유용한 슬라이스 메서드
v.first(); v.last(); // Option
v.chunks(2); v.windows(3); // 2개씩 / 3칸 미끄러지며
v.split_at(2); // (&[1,2], &[3,4,5])
v.binary_search(&3); // 정렬돼 있을 때
v.iter().rev(); // 역순
실무에선 이렇게 — HashMap 과 컨테이너 고르기 🔴 실무
HashMap — 파이썬 dict 자리
use std::collections::HashMap;
let mut m: HashMap<String, i32> = HashMap::new();
m.insert("a".into(), 1);
m.get("a"); // Option<&i32>
m.contains_key("a");
m.remove("a");
// ★ entry API — "없으면 넣고 있으면 고치기" 의 정석
*m.entry("count".into()).or_insert(0) += 1;
m.entry("list".into()).or_default().push(1); // Vec 필드일 때
m.entry(k).or_insert_with(|| expensive()); // 필요할 때만 계산
// 순회 (순서는 매번 다릅니다 — 보장 없음)
for (k, v) in &m { }
let mut keys: Vec<_> = m.keys().collect();
keys.sort(); // 출력이 안정적이어야 하면 정렬하세요
// 순서가 필요하면 BTreeMap (키 정렬 순)
use std::collections::{BTreeMap, HashSet, VecDeque};
String 인데 &str 로 찾을 수 있는 이유 —
HashMap<String, V> 의 get 은 Borrow<Q> 를 받습니다.
그래서 m.get("a") 처럼 키를 새로 만들지 않고 조회할 수 있습니다.
무엇을 쓸지 고르는 표
| 필요 | 타입 | 메모 |
|---|---|---|
| 순서 있는 목록 | Vec<T> | 거의 항상 정답 |
| 키 → 값 | HashMap | 순서 없음 |
| 키 정렬 필요 | BTreeMap | 범위 조회 가능 |
| 중복 없는 집합 | HashSet | 파이썬 set |
| 양끝 넣고 빼기 | VecDeque | 큐·덱 |
| 우선순위 | BinaryHeap | 최대 힙 |
| 고정 길이 | [T; N] | 스택에 놓임 |
| 불변 문자열 | &'static str | 바이너리에 박힘 |
// ★ 실무 조언: 고민되면 Vec 을 쓰세요.
// 원소 수십~수백 개라면 선형 탐색이 HashMap 보다 빠른 경우가 흔합니다.
// (해시 계산 + 캐시 미스가 없기 때문 · ⚡ e04)
// 더 빠른 해시가 필요하면 (DoS 방어가 필요 없는 내부 데이터일 때)
// rustc-hash 의 FxHashMap · ahash — 표준보다 2~3배 빠릅니다
// 단, 외부 입력을 키로 쓰면 해시 충돌 공격에 취약해집니다 ★
한 단계 더 — Vec 은 메모리에서 어떻게 생겼나 🧬 전문가
세 워드 구조와 재할당
// Vec<T> = { ptr, cap, len } — 스택 24바이트 (64비트)
let mut v: Vec<u8> = Vec::new();
println!("{} {}", v.capacity(), v.len()); // 0 0 ★ 할당조차 하지 않습니다
v.push(1); // 여기서 첫 할당 (보통 cap=4 또는 8)
for i in 0..100 { v.push(i); }
// cap 이 차면 2배로 늘리며 전체 복사 → amortized O(1)
// 재할당 횟수를 세어 보기
let mut v = Vec::new();
let mut last = v.capacity();
for i in 0..1_000 {
v.push(i);
if v.capacity() != last { println!("재할당 → {}", v.capacity()); last = v.capacity(); }
}
// 0→4→8→16→32… 약 9번. with_capacity(1000) 이면 0번 ★
v.shrink_to_fit(); // 남는 용량 반납
let boxed: Box<[i32]> = v.into_boxed_slice(); // 더 이상 안 늘릴 때 (16바이트)
참조가 무효화되는 순간 ★
let mut v = vec![1, 2, 3];
let first = &v[0];
// v.push(4); // ❌ 컴파일 에러!
// println!("{first}");
//
// error[E0502]: cannot borrow `v` as mutable because it is also
// borrowed as immutable
// ★ 여기가 Rust 가 C++ 와 결정적으로 갈리는 지점입니다.
// C++ 에서 같은 코드는 '컴파일되고 실행되다가 언젠가 크래시'합니다
// (push 로 재할당되면 first 는 해제된 메모리를 가리킴).
// Rust 는 이걸 '컴파일 타임에' 막습니다. 실행조차 되지 않습니다.
// 이 규칙 때문에 못 쓰는 패턴과 해법
// ❌ 순회하면서 지우기
// for x in &v { if bad(x) { v.remove(..) } }
// ✅
v.retain(|x| !bad(x)); // 조건에 맞는 것만 남김
// ✅ 인덱스를 모아 두었다가 나중에
let idx: Vec<usize> = v.iter().enumerate().filter(|(_, x)| bad(x))
.map(|(i, _)| i).collect();
Vec<T>=list, String=str, HashMap=dict 입니다.
문자열은 소유(String)와 빌림(&str)이 나뉘고, 인자는 &str·&[T] 로 받는 것이 정석입니다.
UTF-8 이라 인덱싱 대신 .chars(). 크기를 알면 with_capacity 로 미리 잡으세요.구조체와 impl — 클래스를 반으로 갈라 둔 형태
Rust 에는 class 가 없습니다. 데이터는 struct, 동작은 impl 로 나눠 씁니다.
상속도 없습니다 — 공통 동작은 트레이트(🔐 o05)로 표현합니다.
처음엔 낯설지만, “이 타입이 무슨 데이터를 갖는가”가 한눈에 보입니다.
구조체 정의와 메서드
#[derive(Debug, Clone)] // 디버그 출력·복제를 자동 생성 ★
struct User {
name: String,
age: u32,
email: Option<String>, // 없을 수도 있는 필드 (r08)
}
impl User {
// 연관 함수 — self 가 없습니다. 파이썬의 classmethod 자리
fn new(name: &str, age: u32) -> Self {
Self { name: name.to_string(), age, email: None }
// ^^^ 이름이 같으면 축약
}
// 메서드 — 첫 인자가 self
fn greet(&self) -> String { // 읽기만
format!("안녕 {}", self.name)
}
fn birthday(&mut self) { // 고치기
self.age += 1;
}
fn into_name(self) -> String { // 소모하기 (self 를 가져감)
self.name
}
}
let mut u = User::new("현우", 30); // 연관 함수는 :: 로
u.birthday(); // 메서드는 . 으로
println!("{}", u.greet());
println!("{u:?}"); // Debug 출력 ★ 디버깅 필수
파이썬 클래스와 대조
class User:
def __init__(self, name, age):
self.name = name
self.age = age
self.email = None
def greet(self):
return f"안녕 {self.name}"
def birthday(self):
self.age += 1
| 파이썬 | Rust |
|---|---|
class | struct + impl |
__init__ | fn new() -> Self (관례) |
self | &self / &mut self / self |
@classmethod | self 없는 연관 함수 |
__repr__ | #[derive(Debug)] |
__eq__ | #[derive(PartialEq)] |
| 상속 | 없음 → 트레이트 (o05) |
| private | 기본이 비공개 (r09) |
new 는 문법이 아니라 관례입니다. 이름을 from_config,
with_capacity 처럼 무엇으로 만드는지 드러나게 지어도 됩니다.
생성 방법이 여러 개면 이름을 다르게 짓는 것이 Rust 다운 방식입니다.
이렇게도 쓸 수 있다 — derive 와 세 가지 구조체 형태 🟡 중급
#[derive] — 자주 쓰는 것 여덟 개 ★
#[derive(Debug, // {:?} 출력 — 거의 항상 붙입니다
Clone, // .clone()
Copy, // 비트 복사 (작은 타입만)
PartialEq, Eq,// == 비교
PartialOrd, Ord, // < 비교 · 정렬
Hash, // HashMap 의 키로 쓰기
Default)] // Default::default()
struct Point { x: i32, y: i32 }
let p = Point::default(); // Point { x: 0, y: 0 }
let mut v = vec![p2, p1];
v.sort(); // Ord 가 있어서 가능
map.insert(p, "값"); // Hash + Eq 가 있어서 가능
// 직렬화는 serde 로 (가장 많이 쓰는 외부 크레이트)
// Cargo.toml: serde = { version = "1", features = ["derive"] }
#[derive(serde::Serialize, serde::Deserialize)]
struct Config { host: String, port: u16 }
let json = serde_json::to_string(&cfg)?;
let cfg: Config = serde_json::from_str(&json)?;
// ★ 필드 하나라도 해당 트레이트가 없으면 derive 가 실패합니다.
// 예: f64 필드가 있으면 Eq·Hash·Ord 를 derive 할 수 없습니다 (NaN 때문)
구조체의 세 가지 형태
// ① 이름 있는 필드 — 기본
struct User { name: String, age: u32 }
// ② 튜플 구조체 — 필드 이름이 필요 없을 때
struct Meters(f64);
struct Rgb(u8, u8, u8);
let d = Meters(3.5);
println!("{}", d.0);
// ★ '뉴타입' 패턴 — 실무에서 아주 유용합니다
struct UserId(u64);
struct OrderId(u64);
fn find(id: UserId) { }
// find(OrderId(1)); // ❌ 컴파일 에러 — 섞어 쓰는 사고가 원천 차단됩니다
// ③ 유닛 구조체 — 데이터 없이 타입만 필요할 때
struct Marker;
// 트레이트 구현의 대상이나 타입 상태 표시에 씁니다
// 구조체 갱신 문법 — 나머지는 기존 것에서
let u2 = User { age: 31, ..u1 }; // ★ u1 의 나머지 필드를 가져옴
// (String 필드는 이동됩니다 — u1 이 무효가 될 수 있음)
struct UserId(u64) 는 런타임에
u64 와 완전히 동일한 메모리·비용입니다.
타입 안전을 성능 손해 없이 얻는, Rust 에서 가장 값싼 설계 도구입니다.
실무에선 이렇게 — 빌더 · Default · 생성 검증 🔴 실무
인자가 많을 때는 빌더
#[derive(Debug)]
pub struct Client { host: String, port: u16, timeout: u64, retries: u8 }
pub struct ClientBuilder { host: String, port: u16, timeout: u64, retries: u8 }
impl Client {
pub fn builder(host: &str) -> ClientBuilder {
ClientBuilder { host: host.into(), port: 443, timeout: 30, retries: 3 }
}
}
impl ClientBuilder {
pub fn port(mut self, v: u16) -> Self { self.port = v; self }
pub fn timeout(mut self, v: u64) -> Self { self.timeout = v; self }
pub fn build(self) -> Client {
Client { host: self.host, port: self.port,
timeout: self.timeout, retries: self.retries }
}
}
// 쓰는 쪽 — 기본값은 생략하고 바꿀 것만 씁니다
let c = Client::builder("api.example.com").timeout(5).build();
// ★ mut self 를 받아 Self 를 돌려주는 형태가 핵심입니다 (소유권 체이닝).
// derive_builder · bon 크레이트가 이걸 자동 생성해 줍니다.
불변식을 생성자에서 지키기 ★
pub struct Percent(f64); // 필드가 비공개 = 밖에서 못 만듦
impl Percent {
pub fn new(v: f64) -> Result<Self, &'static str> {
if (0.0..=100.0).contains(&v) { Ok(Percent(v)) }
else { Err("0~100 범위여야 합니다") }
}
pub fn value(&self) -> f64 { self.0 }
}
// ★ 이 패턴의 힘: Percent 가 존재한다는 것 자체가
// "값이 0~100 이다"의 증명입니다. 이후 코드에서 다시 검사할 필요가 없습니다.
// 이것을 '타입으로 불변식을 표현한다'고 합니다 (🔬 d08).
// Default 를 직접 구현하기
impl Default for Client {
fn default() -> Self {
Self { host: "localhost".into(), port: 8080, timeout: 30, retries: 3 }
}
}
let c = Client { port: 9000, ..Default::default() }; // ★ 흔한 관용구
pub 으로 열지 마세요 — 한 번 열면 되돌리기 어렵고,
불변식을 지킬 방법이 사라집니다. 접근자 메서드를 두면
나중에 내부 표현을 바꿔도 사용자 코드가 안 깨집니다(🛠️ b02·🔗 f03).
한 단계 더 — 메모리 배치와 크기 🧬 전문가
Rust 는 필드 순서를 바꿉니다 ★
struct A { a: u8, b: u64, c: u8 }
// C 라면 8 + 정렬 패딩으로 24바이트가 됩니다.
// Rust 는 필드를 재배치해 (b, a, c) 순으로 놓아 16바이트로 줄입니다.
println!("{}", std::mem::size_of::<A>()); // 16
// 확인 도구
// std::mem::size_of::<T>() · align_of::<T>()
// cargo +nightly rustc -- -Zprint-type-sizes (자세한 배치)
// ❗ 그래서 FFI 경계에서는 반드시 #[repr(C)] 를 붙여야 합니다 (🔗 f03)
#[repr(C)]
struct Wire { a: u8, b: u64, c: u8 } // 24바이트 · C 와 동일 배치
// enum 의 크기 = 가장 큰 배리언트 + 태그
enum E { Small(u8), Big([u8; 100]) }
println!("{}", std::mem::size_of::<E>()); // 101 이상
// ★ 배리언트 크기 차이가 크면 큰 쪽을 Box 로 감싸세요
enum E2 { Small(u8), Big(Box<[u8; 100]>) } // 16
// clippy::large_enum_variant 가 이걸 잡아 줍니다
impl 블록은 여러 개여도 됩니다
// 조건부 구현 — 제네릭 파라미터가 조건을 만족할 때만
struct Wrapper<T>(T);
impl<T> Wrapper<T> {
fn get(&self) -> &T { &self.0 }
}
impl<T: std::fmt::Display> Wrapper<T> {
fn show(&self) { println!("{}", self.0); } // Display 일 때만 생깁니다
}
impl Wrapper<u32> {
fn double(&self) -> u32 { self.0 * 2 } // u32 일 때만
}
// ★ 이 '조건부 구현'이 Rust 제네릭의 핵심 표현력입니다.
// 상속으로는 표현할 수 없는 것을 자연스럽게 씁니다.
// 크기가 0인 타입 (ZST) — 진짜로 0바이트입니다
struct Unit;
assert_eq!(std::mem::size_of::<Unit>(), 0);
assert_eq!(std::mem::size_of::<Vec<Unit>>(), 24); // 힙 할당조차 안 함
// HashSet<K> 가 HashMap<K, ()> 로 구현된 이유입니다 ★
struct, 동작은 impl, 상속은 없습니다.
#[derive(Debug, Clone)] 은 거의 항상 붙이세요.
필드를 비공개로 두고 생성자에서 검증하면 그 타입의 존재 자체가 불변식의 증명이 됩니다.
FFI 로 나갈 구조체에는 반드시 #[repr(C)](🔗 f03).열거형과 Option — 널 오류가 존재하지 않는 이유
Rust 의 enum 은 다른 언어의 열거형보다 훨씬 강력합니다 —
각 배리언트가 값을 담을 수 있습니다.
그래서 “없을 수도 있음”을 None 이 아니라 Option<T> 라는 타입으로 표현하고,
그 결과 널 참조 오류가 언어에서 사라집니다.
unwrap() 은 "여기는 절대 None 이 아니다"라는 주장입니다 — 근거가 없으면 쓰지 마세요.값을 담는 열거형
enum Message {
Quit, // 값 없음
Move { x: i32, y: i32 }, // 이름 있는 필드
Write(String), // 값 하나
Color(u8, u8, u8), // 여러 값
}
fn handle(m: Message) {
match m {
Message::Quit => println!("종료"),
Message::Move { x, y } => println!("이동 {x},{y}"),
Message::Write(s) => println!("쓰기 {s}"),
Message::Color(r, g, b) => println!("색 {r}{g}{b}"),
}
}
// ★ 다른 언어라면 이걸 '클래스 4개 + 공통 인터페이스'로 표현합니다.
// Rust 는 한 타입으로 두고, 컴파일러가 모든 경우를 검사합니다.
// 이 조합(enum + match)이 Rust 코드가 안전한 가장 큰 이유입니다.
Option — 널이 없는 세계 ★
// 표준 라이브러리에 이렇게 정의돼 있습니다
// enum Option<T> { Some(T), None }
let a: Option<i32> = Some(5);
let b: Option<i32> = None;
// ★ 핵심: Option<i32> 와 i32 는 '다른 타입'입니다.
// 그래서 값을 꺼내지 않고는 계산에 쓸 수 없습니다.
// let sum = a + 1; // ❌ 컴파일 에러
// 꺼내는 방법들
match a { Some(v) => v, None => 0 };
a.unwrap_or(0); // 없으면 0
a.unwrap_or_default(); // 없으면 기본값
a.unwrap_or_else(|| compute()); // 없으면 계산
a.unwrap(); // 없으면 panic ★ 프로토타입·테스트에서만
a.expect("설정이 없습니다"); // panic 하되 이유를 남김
// 변환 — 꺼내지 않고 그대로 다루기 (자주 씁니다)
a.map(|v| v * 2); // Some(10)
a.filter(|v| *v > 3); // 조건에 맞으면 유지
a.and_then(|v| lookup(v)); // Option 을 반환하는 함수 연결
a.is_some(); a.is_none();
a.ok_or("없음")?; // Option → Result (🔐 o08)
unwrap() 을 언제 써도 되나 — 테스트, 예제, 그리고
“여기서 None 이면 프로그램 논리가 깨진 것”이 확실할 때입니다.
라이브러리 코드에서는 expect("이유") 로 왜 그렇게 확신하는지를 남기세요.
이렇게도 쓸 수 있다 — 널 대신 Option 이 만든 차이 🟡 중급
파이썬·자바와 비교
# 파이썬 — 시그니처만 봐서는 알 수 없습니다
def find_user(id):
...
return None # ← 문서에 안 적혀 있으면 모릅니다
u = find_user(1)
print(u.name) # 💥 AttributeError: 'NoneType' ... (실행 중에)
// Rust — 타입에 적혀 있고, 처리하지 않으면 컴파일이 안 됩니다
fn find_user(id: u64) -> Option<User> { }
let u = find_user(1);
// println!("{}", u.name); // ❌ error: no field `name` on `Option<User>`
// 반드시 처리해야 합니다
if let Some(u) = find_user(1) { println!("{}", u.name); }
// ★ 여기서 오는 실무 효과
// · 널 검사 누락이 리뷰 대상이 아니라 '컴파일 에러'가 됩니다
// · 함수 시그니처만 보고 "실패할 수 있나"를 알 수 있습니다
// · 파이썬 모듈로 내보낼 때 Option<T> → None 으로 자연 매핑됩니다 (🐍 y04)
Option 을 다루는 관용구 모음
// 연쇄 — 중간에 None 이면 전체가 None
let city: Option<&str> = user.address.as_ref().map(|a| a.city.as_str());
let len = config.get("path").map(|p| p.len()).unwrap_or(0);
// Option<Option<T>> 평탄화
let flat = nested.flatten();
// Option 을 이터레이터처럼
let v: Vec<i32> = vec![Some(1), None, Some(3)].into_iter().flatten().collect();
// [1, 3] ★ None 을 걸러 내는 관용구
// 전부 Some 이어야 할 때 — 하나라도 None 이면 전체 None
let all: Option<Vec<i32>> = vec![Some(1), Some(2)].into_iter().collect();
// 기본값을 넣고 참조 얻기
let name = user.nickname.as_deref().unwrap_or("이름없음"); // &str
// mut 로 안을 고치기
if let Some(v) = opt.as_mut() { *v += 1; }
// take / replace — 꺼내면서 None 으로 만들기
let taken = opt.take(); // opt 은 None 이 됨 ★ 자주 씁니다
let old = opt.replace(5);
실무에선 이렇게 — enum 으로 도메인을 표현한다 🔴 실무
불가능한 상태를 표현할 수 없게 만들기 ★
// ❌ 이 구조체는 '말이 안 되는 상태'를 만들 수 있습니다
struct Conn {
connected: bool,
socket: Option<TcpStream>, // connected=true 인데 None 일 수 있음
error: Option<String>, // 셋 다 동시에 채워질 수도
}
// ✅ enum 으로 바꾸면 잘못된 조합이 '표현 불가능'해집니다
enum Conn {
Disconnected,
Connected(TcpStream),
Failed(String),
}
// ★ "make illegal states unrepresentable" — Rust 설계의 핵심 관용구입니다.
// 검사 코드를 줄이는 게 아니라, 검사할 필요 자체를 없앱니다.
// 배리언트에 메서드 달기
impl Conn {
fn is_ready(&self) -> bool { matches!(self, Conn::Connected(_)) }
// matches! 매크로 — 한 갈래만 볼 때 편합니다
}
C 스타일 enum 과 변환
// 판별자 값을 직접 지정 (FFI·프로토콜에서 씁니다 · 🔗 f03)
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(u8)]
enum Status { Ok = 0, NotFound = 4, Error = 5 }
let code = Status::NotFound as u8; // 4 ★ enum → 정수는 as 로
// 정수 → enum 은 자동이 아닙니다 (아무 값이나 올 수 있으니 당연)
impl TryFrom<u8> for Status {
type Error = ();
fn try_from(v: u8) -> Result<Self, ()> {
match v {
0 => Ok(Status::Ok),
4 => Ok(Status::NotFound),
5 => Ok(Status::Error),
_ => Err(()),
}
}
}
// 이 보일러플레이트는 num_enum · strum 크레이트가 derive 로 만들어 줍니다
// 나중에 배리언트를 추가해도 사용자 코드가 안 깨지게 하려면
#[non_exhaustive]
pub enum ApiError { NotFound, Timeout }
// → 외부 크레이트는 반드시 _ 갈래를 써야 합니다 (라이브러리 설계 · 🛠️ b02)
한 단계 더 — enum 은 메모리에서 어떻게 생겼나 🧬 전문가
널 포인터 최적화 ★
use std::mem::size_of;
size_of::<Option<u8>>(); // 2 (태그 1 + 값 1)
size_of::<Option<&u8>>(); // 8 ★ 태그가 없습니다!
size_of::<Option<Box<u8>>>(); // 8
size_of::<Option<String>>(); // 24 (String 과 동일)
// 왜? 참조·Box 는 '절대 널이 될 수 없다'는 것이 타입의 보장입니다.
// 그래서 컴파일러가 0 이라는 값을 None 표현으로 재활용합니다.
// → Option 을 써도 비용이 0 인 경우가 아주 많습니다.
// ★ FFI 에서 결정적으로 유용합니다 (🔗 f07)
// Option<extern "C" fn(i32)> 는 C 의 '널 가능 함수 포인터'와
// 메모리 배치가 정확히 같습니다. 그대로 주고받을 수 있습니다.
// NonZero 타입도 같은 원리
size_of::<Option<std::num::NonZeroU32>>(); // 4
태그와 배치 확인하기
// enum 의 실제 배치를 보고 싶을 때
// cargo +nightly rustc -- -Zprint-type-sizes
// print-type-sizes 출력 예
// type: `Message`: 32 bytes, alignment: 8 bytes
// variant `Write`: 24 bytes
// field `.0`: 24 bytes (String)
// variant `Move`: 8 bytes
// field `.x`: 4 bytes
// field `.y`: 4 bytes
// variant `Quit`: 0 bytes
// ★ 실무 함정: 배리언트 하나가 크면 enum 전체가 그 크기가 됩니다.
// Result<(), BigError> 를 반환하면 성공 경로도 그 크기를 냅니다.
// → 에러를 Box<dyn Error> 나 Box<BigError> 로 감싸면 8바이트가 됩니다 (🔐 o09)
// 판별자 값 읽기 (안정화된 방법)
let d = std::mem::discriminant(&msg);
// 값 비교 없이 '어느 배리언트인지'만 비교할 때 씁니다
Result<T, E> 를 반환하는 함수가 느린 이유는
거의 항상 E 가 커서입니다. 에러 타입은 작게 유지하거나 Box 로 감싸세요.
측정 방법은 ⚡ e01 에서 봅니다.
enum 은 값을 담는 “이것 아니면 저것”입니다.
Option<T> 가 널을 대체하고, 꺼내지 않으면 쓸 수 없어서 널 오류가 사라집니다.
설계할 때는 “불가능한 상태를 표현할 수 없게” enum 으로 묶으세요.
Option<&T>·Option<Box<T>> 는 추가 비용이 0 입니다.모듈 · 크레이트 — 파일이 자동으로 모듈이 되지 않습니다
파이썬은 파일을 만들면 그대로 모듈입니다. Rust 는 mod 로 “이 파일도 내 식구”라고 선언해야 합니다.
한 번만 이해하면 되는 규칙이지만, 처음 파일을 나눌 때 반드시 걸리는 자리라 여기서 정리합니다.
파일을 나누는 최소 규칙
src/
├─ lib.rs ← 크레이트의 뿌리 (라이브러리)
├─ main.rs ← 크레이트의 뿌리 (실행 파일)
├─ math.rs ← 모듈 하나
└─ shapes/
├─ mod.rs ← 옛 방식
└─ circle.rs
또는
├─ shapes.rs ← 새 방식 (2018+) ★ 이쪽을 권장
└─ shapes/
└─ circle.rs
// src/lib.rs
mod math; // ★ src/math.rs 를 읽어 들입니다 (이게 없으면 파일이 없는 것)
pub mod shapes; // 밖에서도 쓸 수 있게
pub use shapes::Circle; // 재수출 — 사용자는 crate::Circle 로 씁니다 ★
// src/math.rs
pub fn add(a: i32, b: i32) -> i32 { a + b }
fn helper() { } // pub 이 없으면 이 모듈 안에서만
// src/shapes.rs
pub mod circle; // src/shapes/circle.rs
pub struct Circle { pub r: f64 }
use crate::math; 만 쓰면
error[E0432]: unresolved import 가 납니다.
mod math; 를 lib.rs(또는 부모 모듈)에 먼저 적어야 합니다.
경로와 use
use crate::math::add; // 내 크레이트 뿌리부터
use self::helper; // 지금 모듈
use super::parent_fn; // 부모 모듈
use std::collections::HashMap; // 외부 크레이트
// 여러 개 한 번에
use std::collections::{HashMap, HashSet, BTreeMap};
use std::io::{self, Read, Write}; // io 자체 + io::Read + io::Write
// 이름이 겹치면 별칭
use std::fmt::Result;
use std::io::Result as IoResult;
// 밖에서 쓰는 이름을 정리해 주기 — 라이브러리 설계의 핵심 ★
// src/lib.rs
mod internal_impl; // 비공개
pub mod api;
pub use api::{Client, Config}; // 사용자는 mycrate::Client 로 씁니다
// 가시성 단계
pub // 어디서나
pub(crate) // 내 크레이트 안에서만 ★ 실무에서 자주 씁니다
pub(super) // 부모 모듈까지
(없음) // 이 모듈과 자식만
이렇게도 쓸 수 있다 — 크레이트 · 바이너리 · 라이브러리 🟡 중급
용어 정리 — 헷갈리는 네 가지 ★
| 용어 | 뜻 |
|---|---|
| 크레이트 | 컴파일 단위 하나 (lib.rs 또는 main.rs 하나가 뿌리) |
| 패키지 | Cargo.toml 하나 = 라이브러리 0~1개 + 바이너리 여러 개 |
| 모듈 | 크레이트 안의 이름 공간 (mod) |
| 워크스페이스 | 패키지 여러 개를 한 저장소에서 (🛠️ b05) |
mypkg/ ← 패키지
├─ Cargo.toml
└─ src/
├─ lib.rs ← 라이브러리 크레이트 (이름: mypkg)
├─ main.rs ← 바이너리 크레이트 (이름: mypkg)
└─ bin/
├─ tool_a.rs ← 바이너리 크레이트 (이름: tool_a)
└─ tool_b.rs
# 실행
cargo run --bin tool_a
cargo build --lib
lib.rs 에 로직을 전부 두고
main.rs 는 인자 파싱 + 라이브러리 호출 열 줄만 두는 것입니다.
이러면 테스트가 쉬워지고, 나중에 그대로 파이썬 모듈로 내보낼 수 있습니다(🐍 y02).
모듈을 한 파일 안에 두기
// 파일을 나누지 않고도 모듈을 만들 수 있습니다
mod math {
pub fn add(a: i32, b: i32) -> i32 { a + b }
pub mod advanced {
pub fn pow(a: i32, n: u32) -> i32 { a.pow(n) }
}
}
fn main() {
println!("{}", math::add(1, 2));
println!("{}", math::advanced::pow(2, 10));
}
// ★ 가장 흔한 용도: 테스트 모듈 (r11)
#[cfg(test)]
mod tests {
use super::*; // 부모의 것들을 전부 가져오기 ★ 관례
#[test]
fn add_works() { assert_eq!(add(1, 2), 3); }
}
// #[cfg(test)] 는 'cargo test 일 때만 컴파일'을 뜻합니다.
// 릴리즈 바이너리에는 테스트 코드가 들어가지 않습니다.
실무에선 이렇게 — 공개 API 를 설계한다 🔴 실무
내부 구조와 공개 얼굴을 분리하기 ★
// src/lib.rs — 사용자가 보는 유일한 얼굴
//! # mycrate
//! 이 크레이트가 무엇을 하는지 (crate 수준 문서 주석 · r11)
mod parser; // 비공개 — 내부 구현
mod codec;
mod error;
pub use error::{Error, Result}; // 사용자가 쓸 것만 올려 줍니다
pub use parser::Document;
/// 문자열을 파싱합니다.
pub fn parse(input: &str) -> Result<Document> { parser::parse(input) }
// ★ 이렇게 하면 얻는 것
// · 사용자는 mycrate::parse · mycrate::Document 만 봅니다 (경로가 짧음)
// · 내부 모듈 구조를 언제든 바꿀 수 있습니다 (호환 유지)
// · 무엇이 공개 API 인지 한 파일에서 전부 보입니다
// 문서에서 감추기 (공개는 하되 문서에 안 보이게)
#[doc(hidden)]
pub fn internal_used_by_macro() { }
순환 참조와 prelude
// Rust 모듈은 순환 참조가 가능합니다 (파이썬과 달리 문제없음)
// a.rs 가 b 를, b.rs 가 a 를 use 해도 됩니다.
// 단, 순환은 '설계가 꼬였다'는 신호인 경우가 많습니다.
// prelude 패턴 — 자주 쓰는 것을 한 번에
// src/prelude.rs
pub use crate::{Client, Config, Error, Result};
pub use crate::traits::{Encode, Decode};
// 사용자
use mycrate::prelude::*;
// ★ 큰 크레이트(tokio, polars, bevy)가 전부 쓰는 방식입니다.
// 단, 작은 크레이트에 prelude 를 만들면 오히려 이름 충돌만 늘립니다.
// 조건부 모듈 — 플랫폼·기능에 따라 (🛠️ b04)
#[cfg(target_os = "windows")]
mod windows_impl;
#[cfg(feature = "python")]
pub mod python_bindings; // 🐍 파이썬 모듈은 대개 이 형태입니다
pub 함수로 두고,
PyO3 바인딩은 #[cfg(feature = "python")] 모듈에 몰아 둡니다.
그러면 같은 크레이트를 Rust 라이브러리로도, 파이썬 모듈로도 쓸 수 있습니다(🐍 y02·🛠️ b04).
한 단계 더 — 이름 해석과 고아 규칙 🧬 전문가
같은 이름의 크레이트가 두 버전 들어올 때
# 이런 에러를 본 적이 있다면 이 이야기입니다
error[E0308]: mismatched types
expected struct `rand::Rng` (rand 0.8)
found struct `rand::Rng` (rand 0.7)
# 원인 — 의존성 A 는 rand 0.7 을, B 는 0.8 을 씁니다.
# cargo 는 둘 다 빌드해 넣습니다 (이름은 같지만 '다른 타입')
# 확인
cargo tree -d # 중복된 크레이트만 보여 줍니다 ★
cargo tree -i rand # 누가 rand 를 끌어왔나
# 해결
# ① 의존성 버전을 맞춘다 (cargo update -p rand)
# ② 상위 라이브러리를 올린다
# ③ 그 타입을 API 에 노출하지 않는다 ← 라이브러리 설계의 교훈 ★
고아 규칙 — 남의 타입에 남의 트레이트는 못 붙입니다
// ❌ Display 도 Vec 도 내 것이 아닙니다
// impl std::fmt::Display for Vec<u8> { }
// error[E0117]: only traits defined in the current crate can be
// implemented for types defined outside of the crate
// ✅ 해법 ① 내 트레이트를 남의 타입에 (트레이트가 내 것이면 OK)
trait Hex { fn hex(&self) -> String; }
impl Hex for Vec<u8> {
fn hex(&self) -> String { self.iter().map(|b| format!("{b:02x}")).collect() }
}
// ✅ 해법 ② 뉴타입으로 감싸기 (r07) — 가장 흔한 답
struct Bytes(Vec<u8>);
impl std::fmt::Display for Bytes { /* … */ }
// ★ 왜 이런 규칙이 있나 — 두 크레이트가 같은 조합을 구현하면
// 어느 것이 쓰일지 정할 수 없습니다. 고아 규칙이 이 충돌을 원천 차단합니다.
// 자세한 트레이트 이야기는 🔐 o05 에서 이어집니다.
mod 이름; 을 부모에 적어야 모듈이 됩니다.
기본은 비공개이고 pub·pub(crate) 로 엽니다.
라이브러리는 lib.rs 에서 pub use 로 공개 얼굴을 정리하세요 —
내부 구조를 자유롭게 바꿀 수 있게 됩니다.제네릭 입문 — 타입을 나중에 채우는 틀
같은 코드를 i32 용·f64 용으로 두 번 쓰지 않게 해 주는 장치입니다.
C++ 템플릿과 비슷하지만 훨씬 친절합니다 —
제약을 미리 적으므로 에러가 “수백 줄”이 아니라 “한 줄”로 나옵니다.
함수와 구조체의 제네릭
// 어떤 타입이든 받는 함수
fn first<T>(v: &[T]) -> Option<&T> { v.first() }
// ❗ 하지만 이건 컴파일되지 않습니다
// fn largest<T>(v: &[T]) -> &T {
// let mut m = &v[0];
// for x in v { if x > m { m = x; } } // ❌ T 를 비교할 수 있나?
// m
// }
// ✅ '비교할 수 있는 타입만' 이라고 제약을 적습니다 (트레이트 바운드)
fn largest<T: PartialOrd>(v: &[T]) -> &T {
let mut m = &v[0];
for x in v { if x > m { m = x; } }
m
}
largest(&[3, 1, 4]); // ✅
largest(&["b", "a"]); // ✅
// 구조체
struct Pair<T> { a: T, b: T }
impl<T: std::fmt::Display> Pair<T> {
fn show(&self) { println!("{} {}", self.a, self.b); }
}
the trait bound `T: PartialOrd` is not satisfied 한 줄로 끝납니다.
제약을 적는 세 가지 자리
// ① 인라인
fn f<T: Clone + std::fmt::Debug>(x: T) { }
// ② where 절 — 길어지면 이쪽이 읽힙니다 ★
fn g<T, U>(x: T, y: U) -> String
where
T: Clone + std::fmt::Debug,
U: IntoIterator<Item = T>,
{ }
// ③ impl Trait — 인자 자리에서 (이름이 필요 없을 때)
fn h(x: impl std::fmt::Display) { println!("{x}"); }
// 자주 쓰는 바운드
// Clone 복제 가능
// Copy 비트 복사 가능
// Debug {:?} 출력
// Display {} 출력
// PartialEq/Eq == 비교
// PartialOrd/Ord 정렬
// Default 기본값
// Send / Sync 스레드로 넘길 수 있음 (⚡ e06)
// 'static 빌린 것이 없음 (스레드·FFI 에서 자주 요구됨)
// 기본 타입 파라미터
struct Wrapper<T = String> { inner: T }
이렇게도 쓸 수 있다 — 단형화 — 비용이 0 인 이유 🟡 중급
컴파일러가 타입별로 복사해 만듭니다 ★
fn double<T: std::ops::Add<Output = T> + Copy>(x: T) -> T { x + x }
double(3i32);
double(3.0f64);
// 컴파일 후에는 사실상 이렇게 됩니다 (단형화 · monomorphization)
// fn double_i32(x: i32) -> i32 { x + x }
// fn double_f64(x: f64) -> f64 { x + x }
//
// ★ 그래서 제네릭에는 런타임 비용이 없습니다.
// 가상 함수 테이블도, 박싱도 없습니다. 인라인도 됩니다.
// 대가는 컴파일 시간과 바이너리 크기입니다 (🛠️ b10 · 🔬 d10)
// 타입 10개 × 함수 20개 = 코드 200벌
// → 큰 제네릭 함수는 '비제네릭 안쪽 함수'로 나누는 기법을 씁니다
fn read_all(path: impl AsRef<Path>) -> io::Result<String> {
fn inner(p: &Path) -> io::Result<String> { /* 큰 본체 */ }
inner(path.as_ref()) // ★ 큰 본체는 한 벌만 생성됩니다
}
파이썬·자바와 비교
| 동작 | 비용 | |
|---|---|---|
| 파이썬 | 타입 검사 없음 | 실행 중 확인 |
| 자바 제네릭 | 지우고 Object | 박싱·캐스팅 |
| C++ 템플릿 | 타입별 생성 | 0 (빌드↑) |
| Rust | 타입별 생성 | 0 (빌드↑) |
// 제네릭 vs 트레이트 객체 — 고르는 기준 (🔐 o06 에서 자세히)
fn fast<T: Draw>(x: &T) { x.draw(); } // 정적 · 인라인됨 · 코드 늘어남
fn small(x: &dyn Draw) { x.draw(); } // 동적 · vtable 한 번 · 코드 하나
// ★ 실무 기준
// · 성능이 중요한 안쪽 루프 → 제네릭
// · 여러 타입을 한 Vec 에 담아야 함 → Vec<Box<dyn Trait>>
// · 빌드 시간·바이너리가 문제 → dyn 쪽으로
// 라이프타임도 제네릭 파라미터입니다 (🔐 o03)
struct Parser<'a> { input: &'a str }
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { }
실무에선 이렇게 — 제네릭을 어디까지 쓸 것인가 🔴 실무
과한 제네릭은 부채입니다 ★
// ❌ 처음부터 이렇게 쓰면 읽는 사람도, 고치는 나도 힘듭니다
pub fn process<T, U, I, F>(items: I, f: F) -> Vec<U>
where I: IntoIterator<Item = T>, F: Fn(T) -> U, U: Clone + Send + 'static { }
// ✅ 구체 타입으로 시작하고, '두 번째 타입이 실제로 필요해질 때' 일반화합니다
pub fn process(items: Vec<Record>) -> Vec<Summary> { }
// ★ 실무 기준선
// · 인자 하나를 넓게 받고 싶다 → impl AsRef<str> / impl IntoIterator
// · 컬렉션·자료구조를 만든다 → 제네릭이 당연
// · 애플리케이션 로직 → 구체 타입이 대개 낫습니다
// 제네릭이 API 에 새는 것을 막기
pub struct Cache { inner: HashMap<String, Vec<u8>> } // ✅ 구체적
// pub struct Cache<K, V, S> { … } // 사용자가 매번 타입 3개를 적어야
제네릭 에러 메시지 읽기
error[E0277]: the trait bound `MyType: Clone` is not satisfied
--> src/main.rs:12:9
|
12 | process(x);
| ------- ^ the trait `Clone` is not implemented for `MyType`
| |
| required by a bound introduced by this call
|
note: required by a bound in `process`
--> src/lib.rs:3:16
|
3 | fn process<T: Clone>(x: T) { }
| ^^^^^ required by this bound
help: consider annotating `MyType` with `#[derive(Clone)]`
# 읽는 법
# ① 어떤 트레이트가 없나 → Clone
# ② 어떤 타입에 없나 → MyType
# ③ 누가 요구했나 (note:) → process 의 바운드
# ④ help: 가 대부분 답을 줍니다 → #[derive(Clone)] ★
한 단계 더 — const 제네릭과 연관 타입 🧬 전문가
값도 제네릭 파라미터가 됩니다
// 길이를 타입에 담기 — 배열 크기가 컴파일 타임 상수
struct Matrix<const R: usize, const C: usize> {
data: [[f64; C]; R],
}
impl<const R: usize, const C: usize> Matrix<R, C> {
fn zeros() -> Self { Self { data: [[0.0; C]; R] } }
}
// ★ 곱셈의 차원이 안 맞으면 '컴파일 에러'가 됩니다
fn mul<const A: usize, const B: usize, const C: usize>(
x: &Matrix<A, B>, y: &Matrix<B, C>,
) -> Matrix<A, C> { }
// 표준 라이브러리도 이걸로 배열을 다룹니다
fn sum_arr<const N: usize>(a: [i32; N]) -> i32 { a.iter().sum() }
// 실무 용도 — 고정 크기 버퍼, SIMD 폭, 암호 키 길이 (🔗 f03)
struct Key<const N: usize>([u8; N]);
type Aes256Key = Key<32>;
연관 타입 — 파라미터보다 나은 경우
// ① 타입 파라미터 — 한 타입에 여러 번 구현할 수 있습니다
trait ConvertTo<T> { fn convert(&self) -> T; }
impl ConvertTo<f64> for i32 { fn convert(&self) -> f64 { *self as f64 } }
impl ConvertTo<String> for i32 { fn convert(&self) -> String { self.to_string() } }
// 쓸 때 어느 것인지 매번 적어야 합니다: let x: f64 = n.convert();
// ② 연관 타입 — 한 타입에 딱 한 번만 구현됩니다 ★
trait Iterator {
type Item; // 연관 타입
fn next(&mut self) -> Option<Self::Item>;
}
// 쓸 때 적을 필요가 없습니다: for x in it { }
// ★ 고르는 기준
// "이 타입에 대해 구현이 여러 개일 수 있나?"
// 예 → 타입 파라미터 (From<T>)
// 아니오 → 연관 타입 (Iterator::Item, Deref::Target)
// 연관 타입에도 제약을 겁니다
fn sum_all<I>(it: I) -> i32
where I: Iterator<Item = i32> { it.sum() }
T: Clone)을 미리 적어야 하고,
그 덕분에 에러가 짧습니다. 애플리케이션 로직은 구체 타입으로 시작하고
두 번째 타입이 실제로 필요해질 때 일반화하세요.테스트와 문서 — 언어에 내장돼 있습니다
테스트 프레임워크를 고를 필요가 없습니다. 같은 파일 아래에 붙여 쓰고 cargo test 면 끝입니다.
게다가 문서에 적은 예제 코드가 그대로 테스트로 실행됩니다 —
“문서가 낡아서 안 돌아가는” 문제가 구조적으로 사라집니다.
단위 테스트 — 같은 파일 아래에
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)] // 테스트 빌드에서만 컴파일 ★
mod tests {
use super::*; // 부모의 것들 가져오기 (관례)
#[test]
fn add_works() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn assertions() {
assert!(add(1, 1) > 0);
assert_ne!(add(1, 1), 3);
assert_eq!(add(1, 1), 2, "1+1 은 {} 여야 합니다", 2); // 실패 메시지
}
#[test]
#[should_panic(expected = "0으로 나눔")]
fn div_by_zero() { divide(1, 0); }
#[test]
fn returns_result() -> Result<(), Box<dyn std::error::Error>> {
let n: i32 = "42".parse()?; // ? 를 쓸 수 있습니다 ★
assert_eq!(n, 42);
Ok(())
}
#[test]
#[ignore] // cargo test -- --ignored 로만 실행
fn slow_test() { }
}
실행과 옵션
cargo test # 전부 (병렬로 돕니다)
cargo test add_works # 이름에 이 문자열이 든 것만
cargo test -- --nocapture # println! 출력 보기 ★ 자주 씁니다
cargo test -- --test-threads=1 # 순차 실행 (공유 자원 쓸 때)
cargo test -- --ignored # #[ignore] 만
cargo test --release # 최적화 빌드로 (느린 테스트에)
cargo test --doc # 문서 테스트만
# 커버리지 (별도 도구)
cargo install cargo-llvm-cov
cargo llvm-cov --html # target/llvm-cov/html/index.html
running 3 tests
test tests::add_works ... ok
test tests::assertions ... ok
test tests::div_by_zero ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; finished in 0.00s
Doc-tests mycrate
test src/lib.rs - add (line 4) ... ok ← ★ 문서 안 예제도 실행됩니다
tempfile 크레이트)를 쓰거나
--test-threads=1 로 순차 실행하세요.
이렇게도 쓸 수 있다 — 문서 주석이 곧 테스트 🟡 중급
/// 로 쓰면 문서 + 테스트 ★
/// 두 수를 더합니다.
///
/// # 예제
///
/// ```
/// use mycrate::add;
/// assert_eq!(add(2, 3), 5);
/// ```
///
/// # 패닉
///
/// 오버플로가 나면 디버그 빌드에서 패닉합니다.
pub fn add(a: i32, b: i32) -> i32 { a + b }
//! 로 시작하면 '그 모듈/크레이트 자체'의 문서입니다
//! (파일 맨 위에 씁니다)
// ★ 위 ``` 블록은 cargo test 가 '실제로 컴파일하고 실행'합니다.
// API 를 바꾸면 문서가 깨지고 CI 가 막습니다 — 문서가 낡을 수가 없습니다.
// 실행은 하지 않고 컴파일만
/// ```no_run
/// mycrate::connect("prod.example.com");
/// ```
// 컴파일도 하지 않음 (의사 코드)
/// ```text
/// 이건 그냥 글입니다
/// ```
// 실패해야 정상
/// ```should_panic
/// mycrate::divide(1, 0);
/// ```
통합 테스트 — 밖에서 쓰듯이
myproj/
├─ src/lib.rs
└─ tests/ ← 이 폴더의 각 파일이 별도 크레이트입니다
├─ api_test.rs
└─ common/
└─ mod.rs ← 공용 헬퍼 (하위 폴더에 두면 테스트로 안 잡힘)
// tests/api_test.rs
use mycrate::{Client, Config}; // ★ 사용자와 똑같이 pub API 만 씁니다
mod common;
#[test]
fn end_to_end() {
common::setup();
let c = Client::new(Config::default());
assert!(c.ping().is_ok());
}
// ★ 단위 테스트와 통합 테스트의 역할
// src/ 안의 #[cfg(test)] → 비공개 함수까지 테스트 · 빠름
// tests/ 폴더 → 공개 API 만 · "밖에서 쓸 수 있나"를 검증
//
// 라이브러리를 만든다면 tests/ 를 반드시 하나는 두세요.
// pub 을 빠뜨렸거나 경로가 불편한 것이 여기서 드러납니다 (r09).
실무에선 이렇게 — 테스트를 믿을 수 있게 만든다 🔴 실무
자주 쓰는 테스트 크레이트
[dev-dependencies] # ★ 테스트에서만 쓰이고 배포물에 안 들어갑니다
pretty_assertions = "1" # assert_eq 실패 시 diff 를 색으로
rstest = "0.23" # 파라미터화 테스트
proptest = "1" # 속성 기반 테스트 (임의 입력 생성)
insta = "1" # 스냅샷 테스트
tempfile = "3" # 임시 디렉터리
mockall = "0.13" # 목 객체
criterion = "0.5" # 벤치마크 (⚡ e01)
// rstest — 같은 테스트를 여러 입력으로
use rstest::rstest;
#[rstest]
#[case(2, 3, 5)]
#[case(-1, 1, 0)]
#[case(0, 0, 0)]
fn add_cases(#[case] a: i32, #[case] b: i32, #[case] want: i32) {
assert_eq!(add(a, b), want);
}
// proptest — "어떤 입력에도 이 성질이 유지되는가" ★
proptest::proptest! {
#[test]
fn roundtrip(s in ".*") {
let encoded = encode(&s);
assert_eq!(decode(&encoded).unwrap(), s);
}
}
// 파서·직렬화·인코딩을 만들 때 손으로 짠 케이스보다 훨씬 많이 잡습니다
FFI 모듈에서 특히 중요한 테스트
// ★ 파이썬·Node 모듈을 만들 때 반드시 필요한 두 가지 테스트
// ① parity 테스트 — 원래 파이썬 구현과 결과가 같은가
#[test]
fn matches_reference() {
for input in load_fixtures() {
assert_eq!(fast_impl(&input), reference_impl(&input));
}
}
// ② 경계 입력 — 빈 것 · 아주 큰 것 · 잘못된 것
#[test]
fn edge_cases() {
assert!(parse("").is_err());
assert!(parse(&"x".repeat(10_000_000)).is_ok());
assert!(parse("\u{0}invalid").is_err());
}
// ★ 네이티브 모듈은 '틀리면 조용히 틀립니다'.
// 파이썬은 예외로 알려 주지만, 네이티브는 이상한 숫자를 돌려줍니다.
// 그래서 기준 구현과 대조하는 parity 테스트가 안전망의 핵심입니다 (🐍 y12).
// 파이썬 쪽 테스트도 같이 두세요 (pytest)
// tests/test_module.py — import 되는지, 타입이 맞는지, 값이 같은지
한 단계 더 — 테스트 하네스의 안쪽 🧬 전문가
cargo test 가 실제로 하는 일
# cargo test 는 테스트용 바이너리를 만들어 실행합니다
cargo test -- --list # 어떤 테스트가 있는지
cargo test --no-run # 빌드만 (바이너리 경로가 출력됨)
# Executable unittests src/lib.rs (target/debug/deps/mycrate-abc123)
# 그 바이너리를 직접 실행할 수 있습니다 — 디버거·프로파일러를 붙일 때 씁니다
./target/debug/deps/mycrate-abc123 --nocapture add_works
# 테스트 실행 시간 보기 (느린 테스트 찾기)
cargo test -- -Z unstable-options --report-time # nightly
# 컴파일 실패를 테스트하기 (trybuild 크레이트)
# "이 잘못된 사용은 컴파일되면 안 된다"를 테스트합니다
# 매크로·타입 상태 API 를 만들 때 필수입니다 (🔬 d09)
CI 에서 돌리는 최소 세트 ★
# 이 다섯 줄이면 대부분의 회귀를 막습니다 (🛠️ b08)
cargo fmt --check # 포맷이 맞나
cargo clippy --all-targets -- -D warnings # 린트 경고를 에러로
cargo test --all-features # 테스트
cargo doc --no-deps # 문서가 빌드되나 (링크 깨짐 검출)
cargo audit # 알려진 취약점이 있는 의존성
# unsafe 를 쓴다면 하나 더 (🔬 d07)
cargo +nightly miri test # 미정의 동작 검출
# 의존성 검사
cargo deny check # 라이선스·중복·금지 크레이트
cargo outdated # 낡은 의존성
-D warnings 를 켜는 시점 — 프로젝트 초기에 켜세요.
나중에 켜면 경고 수백 개를 한꺼번에 고쳐야 합니다.
clippy 는 잔소리가 아니라 대부분 진짜 개선입니다(🛠️ b09).
#[cfg(test)] mod tests 를 같은 파일에 두고 cargo test.
문서 주석의 예제가 그대로 테스트로 실행되므로 문서가 낡을 수 없습니다.
네이티브 모듈을 만든다면 기준 구현과 대조하는 parity 테스트를 반드시 두세요 —
네이티브 코드는 틀려도 조용히 틀립니다.치트시트 — 파이썬 · C++ 에서 Rust 로
이 표 하나면 남의 Rust 코드를 읽을 수 있습니다. 왼쪽에서 하던 일을 오른쪽에서 어떻게 쓰는지만 모았습니다. 막힐 때 이 섹션으로 돌아오세요.
파이썬 → Rust
| 파이썬 | Rust |
|---|---|
x = 5 | let x = 5; |
| 재대입 | let mut x = 5; |
print(x) | println!("{x}"); |
f"{a}-{b}" | format!("{a}-{b}") |
list | Vec<T> |
dict | HashMap<K,V> |
set | HashSet<T> |
None | Option<T>::None |
len(x) | x.len() |
for i, v in enumerate(x) | for (i,v) in x.iter().enumerate() |
[f(x) for x in v] | v.iter().map(f).collect() |
[x for x in v if c] | v.iter().filter(|x| c).collect() |
sum(v) | v.iter().sum::<i32>() |
sorted(v) | v.sort(); (제자리) |
try/except | Result + ? |
raise | return Err(...) |
with open(..) | 스코프 + Drop |
class | struct + impl |
lambda x: x+1 | |x| x + 1 |
import x | use x; (+ mod) |
pip install | cargo add |
C++ → Rust
| C++ | Rust |
|---|---|
std::vector<T> | Vec<T> |
std::string | String |
std::string_view | &str |
std::span<T> | &[T] |
unique_ptr<T> | Box<T> |
shared_ptr<T> | Arc<T> / Rc<T> |
optional<T> | Option<T> |
expected<T,E> | Result<T,E> |
variant<A,B> | enum |
const T& | &T |
T& | &mut T |
std::move(x) | 그냥 넘기면 이동 |
| 소멸자 | impl Drop |
| RAII | 기본 동작 |
template<T> | <T: Trait> |
| 가상 함수 | dyn Trait |
| 상속 | 없음 → 트레이트 · 합성 |
namespace | mod |
| 헤더/소스 분리 | 없음 (한 파일) |
| CMake | Cargo.toml |
| vcpkg/conan | cargo add |
이렇게도 쓸 수 있다 — 같은 프로그램을 세 언어로 🟡 중급
파일을 읽어 단어를 세기
# 파이썬
from collections import Counter
def word_count(path):
with open(path, encoding="utf-8") as f:
words = f.read().lower().split()
return Counter(words).most_common(10)
// Rust
use std::collections::HashMap;
use std::fs;
fn word_count(path: &str) -> std::io::Result<Vec<(String, usize)>> {
let text = fs::read_to_string(path)?; // with open + read
let mut counts: HashMap<&str, usize> = HashMap::new();
for w in text.split_whitespace() {
*counts.entry(w).or_insert(0) += 1; // Counter
}
let mut v: Vec<_> = counts.into_iter()
.map(|(k, n)| (k.to_string(), n))
.collect();
v.sort_by(|a, b| b.1.cmp(&a.1)); // most_common
v.truncate(10);
Ok(v)
}
Rust 코드를 읽을 때 걸리는 기호들
| 기호 | 뜻 |
|---|---|
&x | 빌리기 (참조 만들기) |
&mut x | 고칠 수 있게 빌리기 |
*p | 참조가 가리키는 값 |
x? | 실패면 즉시 반환 (o08) |
x! | 매크로 (println!) |
'a | 라이프타임 (o03) |
:: | 경로 구분 · 연관 함수 |
-> | 반환 타입 |
=> | match 갈래 |
|x| | 클로저 인자 (o11) |
_ | 무시 / 추론해 |
.. | 범위 · 나머지 전부 |
dyn | 동적 디스패치 (o06) |
impl | 구현 블록 / 익명 타입 |
#[..] | 어트리뷰트 (메타데이터) |
::<T> | 터보피시 (타입 지정) |
실무에선 이렇게 — 파이썬 개발자를 위한 이행 순서 🔴 실무
막히는 순서와 처방
| 막히는 것 | 처방 |
|---|---|
| 소유권 에러 | 일단 &, 안 되면 clone() (🔐 o02) |
| 문자열 두 종류 | 인자는 &str, 필드는 String (r06) |
라이프타임 'a | 구조체에 참조를 넣지 않기 (🔐 o03) |
| 에러 처리 | anyhow 로 시작 (🔐 o09) |
| 공유 상태 | Arc<Mutex<T>> (🔐 o10) |
| 느림 | --release 로 재기 (⚡ e01) |
| trait 에러 | help: 줄 읽기 (r10) |
★ 첫 2주 동안 스스로에게 허락할 것
· clone() 을 써도 됩니다 (루프 안만 피하세요)
· unwrap() 을 써도 됩니다 (나중에 ? 로 바꾸면 됩니다)
· 라이프타임을 안 써도 됩니다 (구조체에 참조를 안 넣으면 됩니다)
· Rc<RefCell<T>> 를 써도 됩니다 (익숙해지면 줄어듭니다)
"완벽한 Rust" 를 처음부터 쓰려다 포기하는 경우가 가장 많습니다.
일단 돌아가게 만들고, clippy 가 알려 주는 것부터 하나씩 고치세요.
이 가이드를 어떤 순서로 읽나
목표가 "파이썬 함수 하나를 빠르게" 라면
🦀 r01~r08 → 🔐 o01·o02·o08 → 🐍 y01~y03 → 🐍 y06·y07
(문법 최소) (소유권·에러) (첫 모듈) (제로카피·병렬)
목표가 "Node 애드온" 이라면
🦀 r01~r08 → 🔐 o01·o02 → 🟨 j01~j05
목표가 "브라우저에서 도는 WASM" 이라면
🦀 r01~r08 → 🟨 j08·j09
목표가 "기존 C/C++ 라이브러리와 연동" 이라면
🦀 r01~r09 → 🔗 f01~f04·f08 → 🔬 d01·d02
목표가 "언어를 제대로 익히기" 라면
🦀 전체 → 🔐 전체 → ⚡ e01~e07 → 🔬 d01·d02·d08
★ 어느 경로든 🔐 o01(소유권)·o08(Result) 은 건너뛰지 마세요.
이 둘을 모르면 남의 코드가 읽히지 않습니다.
한 단계 더 — 세 언어의 성격을 한 표로 🧬 전문가
무엇을 언제 고르나
| 파이썬 | C++ | Rust | |
|---|---|---|---|
| 시작 속도 | 즉시 | 느림 | 중간 |
| 실행 속도 | 느림 | 최고 | 최고급 |
| 메모리 안전 | GC | 수동 | 컴파일 검사 |
| 동시성 안전 | GIL | 수동 | 타입으로 |
| 빌드 도구 | — | 복잡 | cargo |
| 배포(파이썬용) | — | 어려움 | maturin |
| 학습 곡선 | 낮음 | 높음 | 높음 |
| 생태계 성숙 | 최고 | 최고 | 중간 |
| 컴파일 시간 | — | 느림 | 느림 |
C++ 대신 Rust 를 고르는 실무 근거
★ 네이티브 모듈을 만드는 맥락에서 갈리는 것들
빌드 · 배포
C++ CMake + 컴파일러별 차이 + 의존성 관리 도구를 각각 배워야
Rust cargo 하나 · maturin 한 줄로 wheel 까지 (🐍 y03 · 🛠️ b08)
의존성
C++ 헤더 경로 · 링크 순서 · ABI 호환을 사람이 맞춤
Rust Cargo.toml 한 줄 · 버전 해석 자동 (🛠️ b03)
메모리 버그
C++ ASan/UBSan 을 켜고 운이 좋으면 잡힘
Rust 대부분 컴파일 에러 · unsafe 만 Miri 로 (🔬 d07)
동시성
C++ 데이터 경쟁이 UB · TSan 으로 확률적 검출
Rust Send/Sync 로 컴파일 타임 차단 (⚡ e06)
그럼에도 C++ 가 나은 경우
· 기존 C++ 코드베이스에 몇 줄 추가하는 일
· CUDA · 특정 벤더 SDK 처럼 C++ API 만 있는 경우
· 팀에 C++ 전문가가 있고 Rust 경험자가 없는 경우 ★
· 컴파일 시간이 개발 흐름의 병목인 대형 프로젝트
clone()·unwrap() 을 써도 됩니다 — 일단 돌아가게 만드는 것이 먼저입니다.
막히는 순서는 정해져 있으니(소유권 → 문자열 → 라이프타임 → 에러) 해당 섹션으로 바로 가세요.
다음은 🔐 소유권 · 트레이트 탭입니다.Rust 가 GC 없이
안전한 이유
여기 전부 있습니다
이 탭이 Rust 의 심장입니다. “값의 주인은 한 명” 이라는 한 줄에서
빌림·라이프타임·Result·Arc 가 전부 따라 나옵니다.
여기를 넘으면 남의 Rust 코드가 읽히고, 컴파일 에러가 잔소리가 아니라 조언으로 보이기 시작합니다.
소유권 규칙 — 세 줄이 전부입니다
Rust 에는 GC 가 없고, free() 도 직접 부르지 않습니다.
그런데도 메모리가 새지 않고 이중 해제도 없습니다.
비밀은 세 줄짜리 규칙과, 그것을 컴파일 타임에 검사하는 것뿐입니다.
규칙 세 줄 ★
① 모든 값에는 주인(owner)이 있다.
② 주인은 한 번에 한 명이다.
③ 주인이 스코프를 벗어나면 값은 해제된다.
{
let s = String::from("안녕"); // ① s 가 주인
// s 를 쓴다
} // ③ 스코프 끝 → 힙 메모리 해제 (drop 호출)
let a = String::from("안녕");
let b = a; // ② 주인이 b 로 이동 (a 는 무효)
// 그래서 다음이 전부 자동으로 보장됩니다
// · 해제를 빠뜨릴 수 없다 (주인이 사라지면 반드시 해제)
// · 두 번 해제할 수 없다 (주인은 한 명)
// · 해제 후 접근할 수 없다 (무효가 된 이름은 컴파일 에러)
//
// ★ C++ 의 RAII 와 같은 아이디어인데, C++ 는 '지키면 좋은 관례'이고
// Rust 는 '어기면 컴파일이 안 되는 규칙'입니다. 이 차이가 전부입니다.
언제 해제되는지 눈으로 보기
struct Res(&'static str);
impl Drop for Res {
fn drop(&mut self) { println!("[{}] 해제", self.0); }
}
fn take(r: Res) { println!("[{}] 받음", r.0); } // r 이 주인이 됨
fn main() {
let a = Res("A");
let b = Res("B");
take(b); // ★ 여기서 B 가 해제됩니다 (함수 끝에서)
println!("함수 뒤");
} // A 해제
// 출력
// [B] 받음
// [B] 해제 ← take 가 끝나는 시점
// 함수 뒤
// [A] 해제
// ★ "값을 함수에 넘기면 그 함수가 주인이 되고, 함수가 끝나면 해제된다"
// 이 감각이 잡히면 소유권 에러의 90%가 이해됩니다.
이렇게도 쓸 수 있다 — RAII — 메모리가 아닌 자원까지 🟡 중급
Drop 이 자동 뒷정리를 합니다
use std::fs::File;
use std::io::Write;
fn write_log(msg: &str) -> std::io::Result<()> {
let mut f = File::create("app.log")?;
writeln!(f, "{msg}")?;
Ok(())
} // ★ f 가 여기서 닫힙니다. close() 를 부를 필요도, 잊을 수도 없습니다.
// 중간에 ? 로 조기 반환해도 마찬가지입니다.
// 파이썬의 with 문이 '항상 자동으로 걸려 있는' 상태라고 보면 됩니다
// with open("app.log") as f: ← Rust 에는 이 구문 자체가 필요 없습니다
// 락도 같습니다 (⚡ e07)
{
let guard = mutex.lock().unwrap();
*guard += 1;
} // ★ 락이 여기서 풀립니다. unlock 을 빠뜨릴 수 없습니다.
// 직접 만들기 — 시간 측정 · 임시 파일 · 연결 반납에 씁니다
struct Timer(&'static str, std::time::Instant);
impl Timer {
fn new(name: &'static str) -> Self { Timer(name, std::time::Instant::now()) }
}
impl Drop for Timer {
fn drop(&mut self) { println!("{} {:?}", self.0, self.1.elapsed()); }
}
let _t = Timer::new("파싱"); // ★ _t 로 이름을 줘야 스코프 끝까지 삽니다
해제 순서와 조기 해제
// 해제는 '선언의 역순'입니다 (r05)
let conn = connect();
let tx = conn.begin(); // tx 가 conn 을 빌리고 있다면
// → tx 가 먼저 해제되어야 합니다. Rust 는 이 순서를 보장합니다. ★
// 일찍 놓고 싶을 때
let data = mutex.lock().unwrap().clone(); // ★ 락을 최소 범위로
// (임시값의 락 가드가 이 문장 끝에서 바로 풀립니다)
let guard = mutex.lock().unwrap();
let v = guard.value;
drop(guard); // 명시적으로 지금 해제
expensive_work(v); // 락을 잡은 채로 오래 일하지 않기 ★
// Drop 을 막고 싶을 때 (FFI 로 소유권을 넘길 때 · 🔗 f06)
let b = Box::new(42);
let raw = Box::into_raw(b); // ★ Drop 이 호출되지 않습니다
// … C 쪽에 넘김 …
unsafe { drop(Box::from_raw(raw)); } // 나중에 다시 받아 해제
std::mem::forget(value); // 해제하지 않고 잊기 (거의 쓸 일 없음)
let m = std::mem::ManuallyDrop::new(v); // 더 안전한 방식
Drop 안에서 패닉하지 마세요 — 이미 패닉 중에 drop 이 또 패닉하면
프로세스가 즉시 abort 합니다. Drop 구현은 실패할 수 없는 일만 하고,
실패할 수 있는 정리는 close() 같은 명시적 메서드로 두세요.
실무에선 이렇게 — 소유권을 설계 도구로 쓴다 🔴 실무
데이터가 흐르는 방향을 먼저 정합니다
// ★ 설계 질문 하나: "이 데이터의 주인은 누구인가?"
// 이 질문에 답하면 시그니처가 저절로 정해집니다.
// 파이프라인 — 소유권이 한 방향으로 흐릅니다 (가장 다루기 쉬움)
fn read(path: &Path) -> io::Result<Vec<u8>>; // 만들어서 준다
fn decode(raw: Vec<u8>) -> Result<Image>; // 받아서 변환
fn resize(img: Image, w: u32) -> Image; // 받아서 돌려줌
fn save(img: &Image, path: &Path) -> io::Result<()>; // 빌려서 씀
let out = save(&resize(decode(read(p)?)?, 800), q)?;
// 저장소 — 하나가 소유하고 나머지는 빌립니다
struct App { users: Vec<User> } // App 이 주인
impl App {
fn find(&self, id: u64) -> Option<&User> { } // 빌려 줌
fn add(&mut self, u: User) { self.users.push(u) } // 받아서 보관
}
// ★ 이 두 형태로 대부분이 해결됩니다.
// Rc<RefCell<T>> 가 필요해졌다면 대개 설계를 한 번 더 볼 신호입니다 (o10).
C++ · 파이썬 개발자가 자주 하는 실수
// ❌ ① 자기 참조 구조체 — 만들 수 없습니다
// struct Bad { data: String, view: &str } // view 가 data 를 가리킴
// ✅ 인덱스나 범위로 표현하세요
struct Ok { data: String, view: std::ops::Range<usize> }
impl Ok { fn view(&self) -> &str { &self.data[self.view.clone()] } }
// ❌ ② 양방향 링크 (부모 ↔ 자식)
// struct Node { parent: &Node, children: Vec<Node> }
// ✅ 인덱스 기반 아레나 — 트리·그래프의 정석입니다 ★
struct Tree { nodes: Vec<Node> }
struct Node { parent: Option<usize>, children: Vec<usize> }
// (slotmap · petgraph 크레이트가 이걸 잘 만들어 둔 것입니다)
// ❌ ③ 전역 가변 상태
// static mut CACHE: HashMap<..> = ..; // unsafe · 2024 에디션 금지
// ✅
use std::sync::{OnceLock, Mutex};
static CACHE: OnceLock<Mutex<HashMap<String, u64>>> = OnceLock::new();
// ❌ ④ 클래스 상속을 흉내 내기
// ✅ 합성 + 트레이트 (o05)
한 단계 더 — 소유권은 런타임에 무엇을 남기나 🧬 전문가
비용은 0 입니다 — 검사가 컴파일 타임이니까
// 소유권·빌림 검사는 전부 컴파일 타임에 끝납니다.
// 생성된 기계어에는 '검사 코드'가 한 줄도 없습니다. ★
fn sum(v: &[i32]) -> i32 { v.iter().sum() }
// → C 로 손으로 쓴 for 루프와 동일한 어셈블리가 나옵니다 (⚡ e03)
// 남는 것은 딱 하나: drop 호출
// 컴파일러가 각 스코프 끝에 drop 호출을 '끼워 넣습니다'.
// 조건부로 이동됐을 수 있으면 'drop flag' (스택의 bool)를 씁니다.
let s = String::new();
if cond { consume(s); }
// → 컴파일러가 숨은 bool 하나를 두고, 스코프 끝에서
// "아직 내 것이면 drop" 을 판단합니다.
// 이 플래그는 보통 최적화로 사라집니다.
// 확인 방법
// cargo asm / godbolt.org 에서 -O 어셈블리 비교 (⚡ e03)
Drop 을 구현하면 잃는 것들
struct Guard { data: Vec<u8> }
impl Drop for Guard { fn drop(&mut self) { } }
// ★ Drop 을 구현하면 '부분 이동'이 금지됩니다
// fn take(g: Guard) -> Vec<u8> { g.data }
// error[E0509]: cannot move out of type `Guard`,
// which implements the `Drop` trait
// (필드를 빼가면 drop 이 무엇을 정리해야 할지 모호해지기 때문)
// 해법 — Option 으로 감싸고 take
struct Guard2 { data: Option<Vec<u8>> }
impl Guard2 {
fn take(mut self) -> Vec<u8> { self.data.take().unwrap() }
}
impl Drop for Guard2 {
fn drop(&mut self) { if let Some(d) = self.data.take() { /* 정리 */ } }
}
// Copy 와 Drop 은 함께 못 씁니다 (해제할 것이 있는데 비트 복사는 모순)
// ManuallyDrop 으로 우회하는 패턴은 FFI 에서 나옵니다 (🔗 f06 · 🔬 d04)
runtime 필드와 그것을 쓰는 handle 필드가 있다면
선언 순서가 곧 종료 순서가 되어, tokio 런타임 종료 시 행이 걸리는 원인이 되곤 합니다(⚡ e08).
빌림 — 읽기는 여럿, 쓰기는 하나
값을 넘길 때마다 소유권이 옮겨 가면 아무것도 못 합니다.
그래서 잠깐 빌려 보는 참조(&)가 있습니다.
규칙은 딱 하나 — “읽기 참조는 여러 개, 쓰기 참조는 하나, 둘은 공존 못 함”.
이 한 줄이 데이터 경쟁을 언어 차원에서 없앱니다.
빌림 규칙
let mut s = String::from("안녕");
// ✅ 불변 참조는 여러 개
let r1 = &s;
let r2 = &s;
println!("{r1} {r2}");
// ✅ 가변 참조는 하나
let m = &mut s;
m.push_str(" 세상");
// ❌ 불변과 가변은 함께 못 씁니다
// let r = &s;
// let m = &mut s; // error[E0502]: cannot borrow `s` as mutable
// println!("{r}"); // because it is also borrowed as immutable
// ★ 왜 이 규칙인가
// "읽는 동안 아무도 못 바꾼다" 가 보장되면
// · 이터레이터 무효화가 없고 (r06)
// · 데이터 경쟁이 없고 (⚡ e06)
// · 컴파일러가 값을 레지스터에 캐시해도 안전합니다 (최적화 이득)
참조는 언제까지 살아 있나 (NLL)
let mut s = String::from("안녕");
let r = &s;
println!("{r}"); // ★ r 의 '마지막 사용'이 여기
let m = &mut s; // ✅ 통과합니다! r 은 이미 끝났으니까
m.push('!');
// 이것을 NLL (non-lexical lifetimes) 이라고 합니다.
// 참조의 수명은 스코프 끝이 아니라 '마지막으로 쓰인 곳'까지입니다.
// → 2018 이전 Rust 의 악명 높은 답답함이 대부분 여기서 사라졌습니다. ★
// 그래서 이런 코드도 됩니다
let mut v = vec![1, 2, 3];
let first = v[0]; // 값을 복사 (참조가 아님)
v.push(4); // ✅ 문제없음
// 이건 여전히 안 됩니다 (참조를 계속 쓰니까)
// let first = &v[0];
// v.push(4);
// println!("{first}"); // ❌
copied()·clone())해 보세요.
NLL 덕분에 순서만 바꿔도 통과하는 경우가 아주 많습니다.
이렇게도 쓸 수 있다 — 실전에서 만나는 빌림 에러 다섯 가지 🟡 중급
패턴과 해법 ★
// ① 순회하면서 수정
// for x in &v { if bad(x) { v.remove(0); } } // ❌
v.retain(|x| !bad(x)); // ✅
let to_remove: Vec<usize> = /* 인덱스 수집 */; // ✅ 두 단계로
// ② self 의 두 필드를 동시에 (메서드 안에서)
// fn f(&mut self) { self.update(&self.data); } // ❌ self 를 두 번 빌림
fn f(&mut self) {
let data = std::mem::take(&mut self.data); // ✅ 꺼냈다가
self.update(&data);
self.data = data; // 돌려놓기
}
// 또는 필드를 분리해 함수로 빼기 (구조체 분해)
fn f2(&mut self) {
let Self { data, cache, .. } = self; // ✅ 필드 단위로 빌림
cache.update(data); // 컴파일러가 구분해 줍니다
}
// ③ HashMap 에서 꺼내 쓰면서 다른 키 넣기
// if let Some(v) = m.get("a") { m.insert("b", v+1); } // ❌
let v = m.get("a").copied(); // ✅ 값을 복사해 끊기
if let Some(v) = v { m.insert("b".into(), v + 1); }
// ④ 클로저가 캡처를 오래 붙잡기 → o11 에서
// ⑤ 반환값이 인자를 빌리는 경우 → o03 (라이프타임)
에러 메시지를 해석하기
error[E0502]: cannot borrow `v` as mutable because it is also
borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &v[0];
| - immutable borrow occurs here ← ① 빌린 곳
4 | v.push(4);
| ^^^^^^^^^ mutable borrow occurs here ← ② 충돌한 곳
5 | println!("{first}");
| ------- immutable borrow later used here ← ③ 왜 아직 사나
# 읽는 순서: ③ → ① → ②
# "③ 에서 아직 쓰고 있으니 ① 의 빌림이 살아 있고,
# 그래서 ② 의 가변 빌림이 안 된다"
#
# ★ 고치는 방법은 셋 중 하나입니다
# · ③ 을 ② 앞으로 옮긴다 (순서 바꾸기 — 가장 흔한 답)
# · ① 에서 값을 복사한다 (&v[0] → v[0])
# · 구조를 나눈다 (필드 분해 · 함수 추출)
실무에선 이렇게 — 빌림 규칙을 전제로 설계한다 🔴 실무
메서드 시그니처가 곧 동시성 계약
impl Cache {
fn get(&self, k: &str) -> Option<&Value>; // 읽기 — 동시에 여럿 가능
fn insert(&mut self, k: String, v: Value); // 쓰기 — 배타적
}
// ★ &self 와 &mut self 를 나눠 두면
// "동시에 호출해도 되는가"가 타입에 적힌 셈이 됩니다.
// 나중에 RwLock 을 씌울 때 그대로 대응됩니다 (⚡ e07).
// 인자를 여러 개 빌릴 때 — 서로 다른 것이면 문제없습니다
fn merge(dst: &mut Vec<u8>, src: &[u8]) { dst.extend_from_slice(src); }
// merge(&mut v, &v); // ❌ 같은 것을 동시에 가변·불변으로
// 슬라이스를 나눠 각각 가변으로 (병렬 처리의 기초 · ⚡ e05)
let (left, right) = v.split_at_mut(mid); // ✅ 겹치지 않음이 보장됨
std::thread::scope(|s| {
s.spawn(|| process(left));
s.spawn(|| process(right));
});
반환 타입으로 빌림을 끊기
// ❌ 참조를 돌려주면 호출자가 그동안 원본을 못 고칩니다
fn names(&self) -> Vec<&str> { }
let n = cache.names();
// cache.insert(..); // ❌ n 이 살아 있는 동안 불가
// ✅ 선택지 ① 소유 값을 돌려준다 (복사 비용을 감수)
fn names(&self) -> Vec<String> { }
// ✅ 선택지 ② 이터레이터를 돌려주고 즉시 소비하게 한다
fn names(&self) -> impl Iterator<Item = &str> { }
// ✅ 선택지 ③ 콜백으로 뒤집는다 (내부 이터레이션)
fn for_each_name(&self, f: impl FnMut(&str)) { }
// ★ 라이브러리 API 를 만들 때의 실무 기준
// · 작고 자주 부름 → 소유 값 (String) — 사용자가 편합니다
// · 크고 성능이 중요 → 참조·이터레이터 + 문서에 수명 설명
// · FFI 경계를 넘어간다 → 반드시 소유 값 (🔗 f06)
&str 로 만들어
사용자가 라이프타임 지옥에 빠지게 하는 것입니다.
내부는 참조, 공개 API 는 소유 값이 대체로 좋은 균형입니다.
한 단계 더 — 빌림 검사기는 실제로 무엇을 보는가 🧬 전문가
MIR 위의 흐름 분석
빌림 검사(borrowck)는 소스가 아니라 MIR(중간 표현) 위에서 돕니다 (🔬 d10).
① 각 참조가 '살아 있는 지점들'의 집합을 계산 (liveness)
② 각 지점에서 어떤 빌림이 활성인지 추적
③ 충돌(같은 자리에서 가변+불변, 또는 가변 둘)을 찾으면 에러
# 컴파일러가 보는 것을 직접 보기 (nightly)
cargo +nightly rustc -- -Z dump-mir=main
RUSTC_LOG=rustc_borrowck cargo +nightly build
★ 중요한 성질: borrowck 는 '함수 하나 안에서만' 봅니다.
함수 경계는 시그니처(라이프타임)만 보고 판단합니다 (o03).
그래서 시그니처가 정확하면 구현을 바꿔도 호출자가 안 깨집니다.
아직 안 되는 것 — 그리고 우회법
// ★ 유명한 한계: 조건부로 빌림을 반환하는 패턴 (NLL 문제 사례 #3)
// fn get_or_insert<'a>(m: &'a mut HashMap<K,V>, k: K) -> &'a mut V {
// if let Some(v) = m.get_mut(&k) { return v; } // ❌ 여기서 빌림이
// m.insert(k, V::default()); // 끝나지 않은 것으로 봄
// m.get_mut(&k).unwrap()
// }
// ✅ 우회 ① entry API (표준 라이브러리가 이걸 위해 존재합니다)
fn get_or_insert<'a>(m: &'a mut HashMap<String, V>, k: String) -> &'a mut V {
m.entry(k).or_default()
}
// ✅ 우회 ② contains_key 로 먼저 확인 (조회가 두 번)
// ✅ 우회 ③ polonius (차세대 borrowck) 를 기다리기 — 이 패턴을 허용합니다
// ★ 실무 감각: borrowck 가 막는 것의 대부분은 '진짜 문제'입니다.
// 위 사례처럼 '안전한데 못 통과하는' 경우는 손에 꼽을 정도이고,
// 전부 표준 라이브러리에 우회 API 가 마련돼 있습니다.
&self/&mut self 구분이 곧 동시성 계약입니다.라이프타임 — 'a 는 무엇을 말하는가
'a 를 보면 겁이 나지만, 뜻은 단순합니다 —
“이 참조는 저것보다 오래 살면 안 된다”는 표시일 뿐입니다.
대부분은 컴파일러가 알아서 붙여 줍니다. 우리가 적어야 하는 경우는 몇 가지로 정해져 있습니다.
왜 필요한가 — 댕글링을 막습니다
// ❌ 컴파일러가 이걸 막아야 합니다
// fn longest(a: &str, b: &str) -> &str {
// if a.len() > b.len() { a } else { b }
// }
// error[E0106]: missing lifetime specifier
// help: this function's return type contains a borrowed value, but
// the signature does not say whether it is borrowed from `a` or `b`
// ✅ "반환값은 a, b 둘 다보다 오래 살지 않는다" 고 적어 줍니다
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
// 이 표시 덕분에 호출자 쪽에서 이런 코드가 막힙니다
let r;
{
let tmp = String::from("짧다");
r = longest(&long, &tmp); // ❌ tmp 가 곧 죽는데 r 은 밖에 있음
}
// println!("{r}");
'a 는 수명을 “정하는” 것이 아니라 “관계를 적는” 것입니다.
값이 더 오래 살게 만들지 않습니다. 컴파일러에게 검사 기준을 알려 줄 뿐입니다.
생략 규칙 — 안 적어도 되는 경우 ★
// 컴파일러가 자동으로 붙이는 규칙 세 개
// ① 각 참조 인자에 서로 다른 라이프타임을 준다
// ② 인자가 참조 하나뿐이면, 반환값도 그것으로 정한다
// ③ 메서드에 &self 가 있으면, 반환값은 self 로 정한다
fn first(s: &str) -> &str { } // ② → <'a>(s: &'a str) -> &'a str
impl Doc {
fn title(&self) -> &str { } // ③ → &'a self -> &'a str
}
// ★ 이 세 규칙 덕분에 실무 코드의 90% 는 라이프타임을 안 적습니다.
// 적어야 하는 경우는 사실상 둘뿐입니다
// · 참조 인자가 둘 이상이고 반환값이 참조일 때 (longest)
// · 구조체가 참조를 필드로 가질 때 (아래)
// 구조체가 참조를 담을 때
struct Parser<'a> {
input: &'a str, // "Parser 는 input 보다 오래 못 산다"
pos: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self { Self { input, pos: 0 } }
fn rest(&self) -> &'a str { &self.input[self.pos..] }
}
이렇게도 쓸 수 있다 — 'static 과 흔한 오해 🟡 중급
'static 은 두 가지 뜻입니다 ★
// ① &'static T — "이 참조는 프로그램이 끝날 때까지 유효"
let s: &'static str = "리터럴"; // 바이너리에 박혀 있음
static NAME: &str = "앱";
// ② T: 'static (바운드) — "이 타입에는 빌린 것이 없다" ★ 훨씬 자주 만납니다
fn spawn<F: FnOnce() + Send + 'static>(f: F) { }
// → 스레드에 넘기려면 '빌린 참조가 없어야' 합니다. 소유 값이면 만족합니다.
String::from("동적") // 이것도 'static 바운드를 만족합니다! (빌린 게 없으니)
// ★ "영원히 산다"는 뜻이 아닙니다
// 자주 보는 에러
// std::thread::spawn(|| println!("{}", local));
// error: closure may outlive the current function
// ✅
std::thread::spawn(move || println!("{}", local)); // 소유권을 넘김
// ✅ 또는 스코프 스레드 (⚡ e07)
std::thread::scope(|s| { s.spawn(|| println!("{}", local)); });
'static 을 붙여 에러를 없애려 하지 마세요 — 대부분 잘못된 처방입니다.
진짜 필요한 것은 소유권을 넘기는 것(move, to_owned())이거나
구조를 바꾸는 것입니다.
구조체에 참조를 넣을지 말지
// ① 참조를 담는다 — 복사가 없어 빠르지만 전염됩니다
struct View<'a> { data: &'a [u8] }
// → 이걸 담는 모든 타입에 <'a> 가 붙습니다 ★
struct Wrapper<'a> { v: View<'a> }
struct App<'a> { w: Wrapper<'a> } // 전염
// ② 소유한다 — 라이프타임이 사라집니다
struct Owned { data: Vec<u8> }
// ★ 실무 판단 기준
// · 수명이 짧고 성능이 중요한 파서·이터레이터 → 참조 (①)
// · 오래 보관하는 설정·상태·핸들 → 소유 (②)
// · 애매하면 소유로 시작 ← 초심자 권장 ★
//
// 파싱 라이브러리(serde, nom)가 ① 을 쓰는 이유는
// "입력 버퍼가 살아 있는 동안만 쓰인다"가 명확하기 때문입니다.
// 둘 다 지원하기 — Cow (Clone on Write)
use std::borrow::Cow;
fn normalize(s: &str) -> Cow<'_, str> {
if s.contains(' ') { Cow::Owned(s.replace(' ', "_")) } // 고칠 때만 복사
else { Cow::Borrowed(s) } // 아니면 빌린 채로
}
실무에선 이렇게 — 라이프타임 에러를 푸는 순서 🔴 실무
처방을 순서대로 시도합니다 ★
① 소유 값으로 바꿔 본다
&str → String, &[T] → Vec<T>
→ 90% 는 여기서 끝납니다. 복사 비용은 대개 무시할 수준입니다.
② 참조를 반환하지 말고 인덱스·범위를 반환한다
fn find(&self) -> &str → fn find(&self) -> Range<usize>
③ 콜백으로 뒤집는다 (내부 이터레이션)
fn items(&self) -> Vec<&T> → fn for_each(&self, f: impl FnMut(&T))
④ Rc/Arc 로 공유 소유권을 만든다 (o10)
여러 곳이 진짜로 같은 값을 오래 봐야 할 때만
⑤ 그래도 안 되면 구조를 바꾼다
아레나 · 인덱스 기반 (o01)
★ 순서를 지키는 게 중요합니다. ①을 건너뛰고 ④부터 가면
Rc<RefCell<T>> 로 뒤덮인, 런타임에 패닉하는 코드가 됩니다.
자주 만나는 에러 두 개
# ① 지역 변수를 참조로 반환
error[E0106]: missing lifetime specifier
fn make() -> &str { let s = String::new(); &s }
help: consider using the `'static` lifetime ← ❌ 이 조언은 대개 틀립니다
✅ 답: String 을 반환하세요
# ② 빌린 값이 충분히 오래 못 삼
error[E0597]: `tmp` does not live long enough
| let r = process(&tmp);
| --- borrowed value does not live long enough
| }
| - `tmp` dropped here while still borrowed
| println!("{r}");
| - borrow later used here
✅ 답: tmp 를 더 바깥 스코프로 올리거나, 결과를 소유 값으로 받으세요
# ★ 컴파일러의 help: 가 'static 을 제안하면 의심하세요.
# 대부분 "소유 값으로 바꾸라"가 진짜 답입니다.
한 단계 더 — 변성 · HRTB · 라이프타임의 정체 🧬 전문가
변성(variance) — 왜 어떤 건 되고 어떤 건 안 되나
// &'long T 를 &'short T 가 필요한 자리에 넣을 수 있습니다 (공변)
fn takes_short<'s>(x: &'s str) { }
let long: &'static str = "리터럴";
takes_short(long); // ✅ 더 오래 사는 건 언제나 OK
// 하지만 &mut T 는 T 에 대해 '불변(invariant)' 입니다
// fn f<'a>(x: &mut &'a str, y: &'a str) { *x = y; }
// → &mut 안의 라이프타임은 늘리지도 줄이지도 못합니다.
// 이걸 허용하면 짧은 참조를 긴 자리에 심을 수 있게 됩니다.
// 정리
// &'a T 'a 에 공변, T 에 공변
// &'a mut T 'a 에 공변, T 에 불변 ★ 여기가 에러의 근원
// fn(T) -> U T 에 반변, U 에 공변
// Cell<T> T 에 불변
//
// PhantomData 로 변성을 직접 지정하는 일은 unsafe 추상화에서 나옵니다 (🔬 d08)
HRTB — 어떤 라이프타임이든
// "어떤 'a 에 대해서든 동작하는 함수"를 받고 싶을 때
fn apply<F>(f: F)
where F: for<'a> Fn(&'a str) -> &'a str // ★ higher-ranked trait bound
{
let s = String::from("안녕");
println!("{}", f(&s));
}
apply(|x| x); // ✅
// 대부분은 생략해도 컴파일러가 붙여 줍니다
fn apply2<F: Fn(&str) -> &str>(f: F) { } // 위와 같은 뜻
// 실제로 만나는 자리 — 클로저를 받는 API, serde 의 Deserialize<'de>
// trait Deserialize<'de>: Sized {
// fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error>;
// }
// → "입력 버퍼에서 빌려 오는 역직렬화"를 표현하기 위한 것입니다.
// 라이프타임의 정체
// 런타임에는 아무것도 남지 않습니다. 타입 검사용 표시일 뿐입니다. ★
// 즉 &'a str 와 &'b str 는 실행 파일에서 완전히 동일합니다 (포인터+길이).
for<'a> 가 에러 메시지에 나타나면
대개 클로저 인자의 참조를 반환하려 해서입니다 —
소유 값을 반환하도록 바꾸면 사라집니다.
'a 는 “이것보다 오래 살지 마라”는 관계 표시일 뿐, 수명을 늘리지 않습니다.
생략 규칙 덕분에 실무 코드의 90% 는 안 적습니다.
막히면 ①소유 값으로 바꾸기 → ②인덱스 반환 → ③콜백 → ④Arc 순서로 시도하세요.
'static 을 붙여 에러를 지우는 것은 대개 잘못된 처방입니다.슬라이스 — 복사 없이 들여다보는 창
슬라이스는 “어디서부터 몇 개”만 담은 두 워드짜리 값입니다. 복사가 없어 빠르지만, 원본이 바뀌면 창이 무효가 됩니다 — 그 위험을 컴파일러가 막아 주는 것이 핵심입니다.
슬라이스의 정체
let v = vec![10, 20, 30, 40, 50];
let s: &[i32] = &v[1..4]; // [20, 30, 40]
// 메모리에는 이것뿐입니다 — 데이터 복사 없음
// { ptr: v.as_ptr() + 1*4, len: 3 }
assert_eq!(std::mem::size_of::<&[i32]>(), 16); // 포인터 8 + 길이 8
// 문자열도 같습니다
let text = String::from("안녕 세상");
let word: &str = &text[0..6]; // "안녕" (바이트 인덱스 · r06)
assert_eq!(std::mem::size_of::<&str>(), 16);
// 소유 ↔ 빌림 대응표
// String → &str
// Vec<T> → &[T]
// Box<[T]> → &[T]
// [T; N] → &[T]
// ★ 함수 인자는 항상 오른쪽(빌림)으로 받으세요 — 왼쪽 전부를 받을 수 있습니다
가변 슬라이스와 분할
let mut v = vec![3, 1, 2];
let s: &mut [i32] = &mut v;
s.sort(); // 제자리 정렬
s[0] = 99;
// 겹치지 않게 둘로 나누기 — 병렬 처리의 기초 ★
let (a, b) = v.split_at_mut(1); // 둘 다 &mut, 겹치지 않음이 보장됨
a[0] += 1;
b[0] += 1;
// 청크로 나누기
for chunk in v.chunks_mut(2) { chunk.sort(); }
for pair in v.windows(2) { } // 겹치는 창 (불변만)
// 채우기·복사 — memcpy 로 최적화됩니다 (⚡ e02)
dst.copy_from_slice(&src); // 길이가 같아야 함 (T: Copy)
dst.clone_from_slice(&src); // Clone 이면
buf.fill(0);
// 검색
v.binary_search(&30); // 정렬돼 있을 때 O(log n)
v.iter().position(|x| *x == 30); // 선형
v.contains(&30);
이렇게도 쓸 수 있다 — 무효화를 컴파일러가 막는다 🟡 중급
C++ 에서는 크래시, Rust 에서는 빌드 에러
let mut v = vec![1, 2, 3];
let s = &v[..];
// v.push(4); // ❌ error[E0502]
// println!("{s:?}");
// push 가 재할당을 일으키면 s 의 포인터는 해제된 메모리를 가리킵니다.
// C++ 의 std::span, std::string_view 로 같은 코드를 쓰면
// '컴파일되고, 대부분 잘 돌다가, 어느 날 크래시'합니다. ★
// 슬라이스를 오래 들고 있어야 한다면
let owned: Vec<i32> = v[1..4].to_vec(); // 복사해서 소유
let boxed: Box<[i32]> = v.into_boxed_slice(); // 더 안 늘릴 때
// 인덱스 범위만 저장 (o01 의 자기 참조 회피 패턴)
struct Token { start: usize, end: usize }
impl Doc {
fn text(&self, t: &Token) -> &str { &self.src[t.start..t.end] }
}
범위를 벗어나면 패닉합니다
let v = vec![1, 2, 3];
// let x = &v[1..10]; // panic: range end index 10 out of range
// ✅ 패닉 없이
let s = v.get(1..10); // Option<&[i32]> → None
let s = &v[1..v.len().min(10)];
// 문자열은 문자 경계도 봅니다 (r06)
let t = "안녕";
// &t[0..1]; // panic: not a char boundary
t.get(0..1); // None
// ★ 성능 이야기: 슬라이싱마다 범위 검사가 들어갑니다.
// 루프 안에서 v[i] 를 반복하면 검사가 매번 돕니다.
// 이터레이터를 쓰면 검사가 사라집니다 (⚡ e03)
for x in &v { } // ✅ 검사 없음
for i in 0..v.len() { v[i]; }// 검사가 남을 수 있음
// 검사를 지우는 unsafe 는 마지막 수단입니다 (🔬 d01)
// unsafe { *v.get_unchecked(i) }
실무에선 이렇게 — 버퍼를 다루는 API 설계 🔴 실무
호출자가 버퍼를 주게 만들기 ★
// ❌ 매번 새로 할당 — 루프에서 부르면 할당이 병목이 됩니다
fn process(input: &[u8]) -> Vec<u8> { }
// ✅ 출력 버퍼를 받아 재사용하게 하기
fn process_into(input: &[u8], out: &mut Vec<u8>) {
out.clear(); // 용량은 유지됩니다 ★
out.extend(input.iter().map(|b| b ^ 0xFF));
}
let mut buf = Vec::with_capacity(4096);
for chunk in stream {
process_into(chunk, &mut buf); // 할당은 첫 회 한 번뿐
write(&buf)?;
}
// ✅ 둘 다 제공하는 것이 친절합니다 (표준 라이브러리 방식)
pub fn process(input: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
process_into(input, &mut out);
out
}
// ★ 이 패턴이 네이티브 모듈에서 특히 중요합니다.
// 파이썬·Node 에서 초당 수천 번 부르는 함수라면
// 할당 하나가 전체 성능을 좌우합니다 (🐍 y06 · ⚡ e02)
바이트를 안전하게 해석하기
// 네트워크·파일에서 온 바이트를 구조체로 읽기
fn read_header(buf: &[u8]) -> Option<(u32, u16)> {
if buf.len() < 6 { return None; } // ★ 길이 검사 먼저
let len = u32::from_be_bytes(buf[0..4].try_into().ok()?);
let kind = u16::from_be_bytes(buf[4..6].try_into().ok()?);
Some((len, kind))
}
// try_into() 로 &[u8] → [u8; 4] 변환 — 길이가 안 맞으면 Err ★
// 엔디안을 명시하세요 (FFI·프로토콜 · 🔗 f03)
u32::from_le_bytes(..); u32::from_be_bytes(..);
x.to_le_bytes(); x.to_be_bytes();
// 문자열로 해석
let s = std::str::from_utf8(&buf)?; // 검증 (실패 가능)
let s = String::from_utf8_lossy(&buf); // 깨진 건 � 로 (실패 없음)
// ❗ unsafe 캐스팅은 정렬 문제로 UB 가 됩니다 (🔬 d03)
// let h: &Header = unsafe { &*(buf.as_ptr() as *const Header) }; // ❌
// ✅ bytemuck · zerocopy 크레이트가 안전하게 해 줍니다
한 단계 더 — DST 와 팻 포인터 🧬 전문가
크기를 컴파일 타임에 모르는 타입
// [T] 와 str 은 '크기가 정해지지 않은 타입'(DST) 입니다.
// 그래서 그 자체로는 변수에 담을 수 없습니다.
// let x: [i32]; // ❌ the size for values of type `[i32]`
// // cannot be known at compilation time
// 항상 포인터 뒤에 있어야 합니다 — 그때 길이가 함께 붙습니다 (팻 포인터)
let a: &[i32] = &v; // 16바이트 (ptr + len)
let b: Box<[i32]> = v.into_boxed_slice();
let c: &str = "안녕";
let d: &dyn Draw = &circle; // 16바이트 (ptr + vtable) · o06
// 제네릭 기본 제약은 Sized 입니다
fn f<T>(x: T) { } // 사실은 T: Sized
fn g<T: ?Sized>(x: &T) { } // ?Sized 로 풀면 DST 도 받습니다 ★
// 표준 라이브러리 예
// impl<T: ?Sized> AsRef<T> for &T
// Box<dyn Error> · Arc<str> · Rc<[T]>
Arc<str> · Box<[T]> 로 메모리 아끼기
use std::sync::Arc;
use std::mem::size_of;
// 자주 쓰는 트릭 — 더 이상 안 늘어날 데이터
size_of::<String>(); // 24 (ptr + cap + len)
size_of::<Box<str>>(); // 16 (ptr + len) ★ 8바이트 절약
size_of::<Arc<str>>(); // 16
let s: Box<str> = "고정".into();
let shared: Arc<str> = Arc::from("여러 곳에서 공유");
let shared2 = Arc::clone(&shared); // 카운터만 +1 (o10)
// ★ 실무 효과: 문자열 수백만 개를 담는 인덱스·심볼 테이블에서
// String → Box<str> 만 바꿔도 메모리가 눈에 띄게 줍니다.
// (직접 재 보세요 — 데이터 성격에 따라 차이가 큽니다)
// Vec 도 마찬가지
let fixed: Box<[u8]> = vec.into_boxed_slice();
let shared: Arc<[u8]> = Arc::from(vec); // 여러 스레드가 읽기만 할 때 ★
Arc<[T]> 가 유용한 자리 — 큰 읽기 전용 데이터를 여러 스레드가 공유할 때
Arc<Vec<T>> 보다 간접 참조가 한 단계 적습니다.
병렬 처리에서 자주 씁니다(⚡ e05).
&[T]·&str 로 받는 것이 정석이고,
출력은 버퍼를 받아 재사용하게 만들면 할당이 사라집니다.
원본이 바뀌면 창이 무효가 되는 문제를 컴파일러가 막아 줍니다 — C++ 와 갈리는 지점입니다.트레이트 — “이런 일을 할 수 있다”는 자격증
Rust 에는 상속이 없습니다. 공통 동작은 전부 트레이트로 표현합니다. 자바 인터페이스와 비슷하지만 결정적인 차이가 둘 있습니다 — 남의 타입에도 붙일 수 있고, 기본 구현을 담을 수 있습니다.
정의하고 구현하기
trait Shape {
fn area(&self) -> f64; // 구현해야 하는 것
fn name(&self) -> &str { "도형" } // 기본 구현 ★
fn describe(&self) -> String { // 기본 구현이 다른 걸 부름
format!("{}의 넓이는 {:.2}", self.name(), self.area())
}
}
struct Circle { r: f64 }
struct Square { s: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
fn name(&self) -> &str { "원" } // 기본 구현을 덮어쓰기
}
impl Shape for Square {
fn area(&self) -> f64 { self.s * self.s } // name 은 기본값 "도형"
}
println!("{}", Circle { r: 1.0 }.describe()); // 원의 넓이는 3.14
// 트레이트를 인자로 받기
fn print_area(s: &impl Shape) { println!("{}", s.area()); }
fn print_area2<T: Shape>(s: &T) { } // 같은 뜻
남의 타입에 내 트레이트 붙이기 ★
// 자바·C# 에서는 불가능한 일입니다
trait Summary {
fn summary(&self) -> String;
}
impl Summary for String { // 표준 타입에 붙이기
fn summary(&self) -> String {
self.chars().take(20).collect::<String>() + "…"
}
}
impl<T: std::fmt::Debug> Summary for Vec<T> { // 제네릭하게
fn summary(&self) -> String { format!("{}개 항목", self.len()) }
}
"긴 문장입니다".to_string().summary();
vec![1, 2, 3].summary();
// ★ 고아 규칙 (r09): 트레이트나 타입 중 '하나는 내 것'이어야 합니다.
// impl Display for Vec<u8> → ❌ 둘 다 남의 것
// impl Summary for Vec<u8> → ✅ 트레이트가 내 것
// impl Display for MyType → ✅ 타입이 내 것
// 이 성질을 '확장 트레이트' 패턴이라고 부릅니다.
// itertools · anyhow::Context 등 유명 크레이트가 전부 이걸 씁니다.
이렇게도 쓸 수 있다 — 표준 트레이트를 구현한다 🟡 중급
가장 많이 구현하는 것들 ★
use std::fmt;
// Display — 사용자에게 보여 줄 문자열 ({} 로 출력)
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
// ★ Display 를 구현하면 .to_string() 이 공짜로 생깁니다 (ToString 자동 구현)
// From — 변환 (Into 가 자동으로 따라옵니다) ★ 아주 중요
impl From<(f64, f64)> for Point {
fn from(t: (f64, f64)) -> Self { Point { x: t.0, y: t.1 } }
}
let p: Point = (1.0, 2.0).into();
let p = Point::from((1.0, 2.0));
// FromStr — "문자열에서 파싱" (.parse() 가 생깁니다)
impl std::str::FromStr for Point {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> { }
}
let p: Point = "1,2".parse()?;
// Default · PartialEq · Ord — 대개 derive 로 (r07)
// Iterator — 직접 구현하면 for 문에 쓸 수 있습니다 (o12)
트레이트 상속과 제네릭 구현
// 슈퍼트레이트 — "Shape 이려면 Debug 도 있어야 한다"
trait Shape: std::fmt::Debug {
fn area(&self) -> f64;
fn report(&self) { println!("{:?} → {}", self, self.area()); }
// ^^^^^^ Debug 가 보장되므로 쓸 수 있음
}
// 블랭킷 구현 — 조건을 만족하는 모든 타입에 한 번에 ★
trait Loggable { fn log(&self); }
impl<T: std::fmt::Debug> Loggable for T {
fn log(&self) { eprintln!("[log] {self:?}"); }
}
42.log(); "문자열".log(); vec![1].log(); // 전부 됩니다
// ★ 표준 라이브러리의 대표 사례
// impl<T: Display> ToString for T — Display 면 to_string 이 생김
// impl<T, U: From<T>> Into<U> for T — From 을 쓰면 Into 가 따라옴
//
// 블랭킷 구현은 강력하지만 '고아 규칙과 충돌'하기 쉬우니
// 라이브러리에서는 신중하게 쓰세요.
실무에선 이렇게 — 추상화의 경계를 정한다 🔴 실무
테스트를 위한 추상화
// 외부 의존(HTTP · DB · 시계)을 트레이트로 뽑으면 테스트가 쉬워집니다
trait Clock { fn now(&self) -> SystemTime; }
struct SystemClock;
impl Clock for SystemClock { fn now(&self) -> SystemTime { SystemTime::now() } }
struct FixedClock(SystemTime);
impl Clock for FixedClock { fn now(&self) -> SystemTime { self.0 } }
struct Service<C: Clock> { clock: C }
impl<C: Clock> Service<C> {
fn is_expired(&self, t: SystemTime) -> bool { self.clock.now() > t }
}
#[test]
fn expiry() {
let s = Service { clock: FixedClock(fixed_time) }; // ★ 시간을 고정
assert!(s.is_expired(past));
}
// ★ 주의: 모든 것을 트레이트로 뽑지 마세요.
// 구현이 하나뿐인 트레이트는 '간접 층'만 늘립니다.
// 두 번째 구현(테스트용 포함)이 실제로 필요할 때 만드세요.
공개 트레이트를 설계할 때
// ① 메서드를 나중에 추가해도 안 깨지게 — 기본 구현을 주세요
pub trait Codec {
fn encode(&self, v: &Value) -> Vec<u8>;
fn name(&self) -> &str { "unknown" } // 나중에 추가해도 OK
}
// ② 외부에서 구현하지 못하게 막기 (sealed 패턴)
mod private { pub trait Sealed {} }
pub trait Format: private::Sealed { }
impl private::Sealed for Json {}
impl Format for Json {}
// → 사용자는 Format 을 구현할 수 없습니다.
// 나중에 메서드를 추가해도 하위 호환이 깨지지 않습니다 ★
// ③ dyn 으로 쓸 수 있게 유지 (o06)
// 제네릭 메서드나 Self 반환이 있으면 dyn 이 불가능해집니다
pub trait Draw {
fn draw(&self); // ✅ dyn 가능
// fn clone_box(&self) -> Self; // ❌ dyn 불가
fn clone_box(&self) -> Box<dyn Draw>; // ✅ 이렇게 우회
}
한 단계 더 — 연관 타입 · GAT · 트레이트 해석 🧬 전문가
연관 상수와 타입
trait Codec {
const NAME: &'static str; // 연관 상수
type Output; // 연관 타입 (r10)
type Error: std::error::Error; // 제약도 걸 수 있습니다
fn decode(&self, b: &[u8]) -> Result<Self::Output, Self::Error>;
}
impl Codec for Json {
const NAME: &'static str = "json";
type Output = Value;
type Error = serde_json::Error;
fn decode(&self, b: &[u8]) -> Result<Value, serde_json::Error> { }
}
// 쓰는 쪽
fn run<C: Codec>(c: &C, b: &[u8]) -> Result<C::Output, C::Error> {
println!("{}", C::NAME);
c.decode(b)
}
// GAT — 연관 타입이 라이프타임을 받을 수 있습니다 (1.65+)
trait Lender {
type Item<'a> where Self: 'a;
fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>;
}
// → "빌려 주는 이터레이터"를 표현합니다. 버퍼 재사용 API 에 씁니다.
메서드 해석 순서와 모호성
// 이름이 같은 메서드가 여러 곳에 있을 때
trait Pilot { fn fly(&self); }
trait Wizard { fn fly(&self); }
struct Human;
impl Pilot for Human { fn fly(&self) { println!("조종"); } }
impl Wizard for Human { fn fly(&self) { println!("마법"); } }
impl Human { fn fly(&self) { println!("팔 흔들기"); } }
let h = Human;
h.fly(); // "팔 흔들기" — 고유 메서드가 우선 ★
Pilot::fly(&h); // "조종"
Wizard::fly(&h); // "마법"
<Human as Wizard>::fly(&h); // 완전 수식 문법 (연관 함수일 때 필요)
// 컴파일러의 메서드 탐색 순서
// ① 값 자체의 고유 impl (Human::fly)
// ② &self / &mut self 로 자동 참조
// ③ Deref 를 따라가며 반복 (String → str → …) · o07
// ④ 스코프에 있는 트레이트의 impl
//
// ★ "메서드가 없다"는 에러의 흔한 원인: 트레이트를 use 하지 않은 것
// use std::io::Read; ← 이게 없으면 .read() 가 안 보입니다
method not found 에러의 첫 번째 확인 사항 —
그 트레이트를 use 했는지 보세요.
Read, Write, Iterator 확장 트레이트(itertools)가 대표적입니다.
Display·From·FromStr 를 구현할 일이 가장 많습니다.
구현이 하나뿐인 트레이트는 만들지 마세요 — 간접 층만 늘어납니다.트레이트 객체 vs 제네릭 — 빠르게냐 유연하게냐
같은 트레이트를 쓰는 두 가지 방법이 있습니다.
제네릭(<T: Trait>)은 타입별로 코드를 만들어 빠르고,
트레이트 객체(dyn Trait)는 하나의 코드로 여러 타입을 담습니다.
고르는 기준은 명확합니다.
두 방식을 나란히
trait Draw { fn draw(&self); }
// ① 제네릭 — 정적 디스패치
fn render_static<T: Draw>(x: &T) { x.draw(); }
// 컴파일러가 타입별로 함수를 복사해 만듭니다 (단형화 · r10)
// → 호출이 인라인될 수 있고, 비용이 0 입니다
// ② 트레이트 객체 — 동적 디스패치
fn render_dyn(x: &dyn Draw) { x.draw(); }
// 실행 중에 vtable 을 보고 어느 draw 인지 찾습니다
// → 함수 포인터 한 번 경유 (인라인 불가)
// ★ 결정적 차이: 여러 타입을 한 컬렉션에 담기
let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Square { s: 2.0 }), // ✅ 서로 다른 타입이 한 Vec 에
];
for s in &shapes { s.draw(); }
// let v: Vec<impl Draw> = vec![circle, square]; // ❌ 불가능
고르는 기준 ★
| 제네릭 | dyn | |
|---|---|---|
| 호출 비용 | 0 (인라인) | 포인터 1회 |
| 코드 크기 | 타입만큼 ↑ | 하나 |
| 빌드 시간 | 느림 | 빠름 |
| 한 컬렉션에 | 불가 | 가능 |
| 런타임 결정 | 불가 | 가능 |
| 제네릭 메서드 | 가능 | 불가 |
// ★ 실무 기준선
// · 안쪽 루프에서 초당 수백만 번 호출 → 제네릭
// · 플러그인·핸들러 목록 → dyn
// · 설정에 따라 구현이 바뀜 → dyn
// · 라이브러리 공개 API → dyn 쪽이 사용자에게 편할 때가 많음
// · 빌드 시간이 아프다 → dyn
//
// 실제 비용 감각 (직접 재 보세요 · ⚡ e01)
// dyn 호출 오버헤드는 보통 나노초 단위입니다.
// 함수 본문이 조금이라도 하는 일이 있으면 차이가 묻힙니다.
// "dyn 은 느리다"는 대부분 측정 없이 하는 말입니다.
이렇게도 쓸 수 있다 — dyn 이 안 되는 경우 🟡 중급
객체 안전성 (object safety)
// 이 트레이트는 dyn 으로 못 씁니다
trait Bad {
fn new() -> Self; // ❌ Self 를 반환
fn compare<T>(&self, x: T); // ❌ 제네릭 메서드
fn clone_me(&self) -> Self; // ❌ Self 반환
}
// let x: &dyn Bad = ..;
// error[E0038]: the trait `Bad` cannot be made into an object
// 왜? vtable 에는 '함수 포인터'만 들어갑니다.
// · Self 를 반환하면 크기를 모릅니다 (타입마다 다름)
// · 제네릭 메서드는 함수가 여러 개라 포인터 하나로 못 담습니다
// ✅ 우회법
trait Good {
fn clone_box(&self) -> Box<dyn Good>; // Self 대신 Box<dyn>
fn compare(&self, x: &dyn std::any::Any); // 제네릭 대신 dyn
fn new_default() -> Self where Self: Sized; // where Self: Sized 로
// → 이 메서드만 dyn 에서 제외됩니다 ★
}
dyn 을 담는 그릇들
&dyn Draw // 빌린 것 (16바이트: 데이터 ptr + vtable ptr)
Box<dyn Draw> // 힙에 소유
Rc<dyn Draw> // 단일 스레드 공유 (o10)
Arc<dyn Draw + Send + Sync> // 멀티 스레드 공유 ★ 자주 씁니다
// 에러 타입에서 가장 많이 봅니다 (o09)
Box<dyn std::error::Error>
Box<dyn std::error::Error + Send + Sync> // 스레드 경계를 넘을 때
// 클로저도 dyn 으로 담습니다 (o11)
let handlers: Vec<Box<dyn Fn(&Event)>> = vec![
Box::new(|e| println!("{e:?}")),
Box::new(move |e| log.write(e)),
];
// 다운캐스팅 — 구체 타입으로 되돌리기 (가끔 필요합니다)
use std::any::Any;
fn downcast(x: &dyn Any) {
if let Some(c) = x.downcast_ref::<Circle>() { }
}
// ★ Any 를 쓰기 시작하면 대개 설계를 다시 볼 신호입니다 (enum 이 나을 때가 많음)
실무에선 이렇게 — enum 이 답인 경우가 많다 🔴 실무
세 번째 선택지 ★
// 구현이 '내가 아는 유한한 집합' 이라면 enum 이 대개 낫습니다
enum Shape { Circle(Circle), Square(Square) }
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(c) => c.area(),
Shape::Square(s) => s.area(),
}
}
}
let shapes: Vec<Shape> = vec![Shape::Circle(c), Shape::Square(s)];
// ★ enum 의 장점
// · 힙 할당 없음 (Box 불필요)
// · 인라인 가능 (dyn 보다 빠름)
// · 빠짐없음 검사 (r04) — 새 도형을 추가하면 컴파일러가 짚어 줍니다
// · Clone·PartialEq 를 derive 할 수 있음
// dyn 의 장점
// · 사용자가 새 구현을 추가할 수 있음 (플러그인)
// · 내 크레이트를 고치지 않아도 됨
// 판단 한 줄
// "외부에서 구현을 추가할 수 있어야 하나?"
// 예 → dyn / 아니오 → enum ★
// (enum_dispatch 크레이트가 이 변환을 자동화해 줍니다)
제네릭이 API 를 오염시킬 때
// ❌ 제네릭이 전염됩니다
struct Server<S: Storage, L: Logger, C: Clock> { s: S, l: L, c: C }
// → 쓰는 쪽이 매번 타입 3개를 적어야 하고, 함수 시그니처마다 따라다닙니다
// ✅ 경계에서 dyn 으로 바꿔 가둡니다
struct Server {
storage: Box<dyn Storage + Send + Sync>,
logger: Arc<dyn Logger + Send + Sync>,
}
impl Server {
fn new(s: impl Storage + Send + Sync + 'static) -> Self {
Self { storage: Box::new(s), .. } // ★ 생성자만 제네릭
}
}
// ★ 실무 패턴: '바깥은 제네릭으로 편하게 받고, 안에서는 dyn 으로 저장'
// 빌드 시간이 줄고, 타입 시그니처가 짧아지고, 성능 차이는 대개 무시할 수준입니다.
// (초당 수백만 호출되는 안쪽 루프만 제네릭으로 남기세요)
dyn 으로 바꿨을 때
실제로 느려지는지 먼저 재 보세요(⚡ e01).
한 단계 더 — vtable 은 실제로 어떻게 생겼나 🧬 전문가
팻 포인터 두 워드
&dyn Draw 의 메모리 (64비트)
┌──────────────┬──────────────┐
│ data pointer │ vtable ptr │ 16바이트
└──────────────┴──────────────┘
│
▼
vtable (정적 데이터 · 타입마다 하나)
[0] drop_in_place 함수 포인터
[1] size
[2] align
[3] draw 함수 포인터
[4] … (트레이트 메서드 순서대로)
★ C++ 와 다른 점
C++ 는 vtable 포인터가 '객체 안'에 들어갑니다 (객체가 8바이트 커짐).
Rust 는 '참조 쪽'에 붙습니다 → 객체 자체는 커지지 않습니다.
그래서 같은 타입을 dyn 으로도, 제네릭으로도 비용 없이 쓸 수 있습니다.
비용을 실제로 재기
// criterion 으로 두 방식을 비교 (⚡ e01)
fn bench(c: &mut Criterion) {
let shapes_dyn: Vec<Box<dyn Shape>> = make_dyn();
let shapes_enum: Vec<ShapeEnum> = make_enum();
c.bench_function("dyn", |b| b.iter(||
shapes_dyn.iter().map(|s| s.area()).sum::<f64>()));
c.bench_function("enum", |b| b.iter(||
shapes_enum.iter().map(|s| s.area()).sum::<f64>()));
}
// ★ 결과 해석의 요령 (환경마다 다르니 직접 재세요)
// · 함수 본문이 아주 짧으면 enum/제네릭이 눈에 띄게 빠릅니다
// (인라인 + 벡터화가 걸리기 때문 · ⚡ e09)
// · 본문이 수십 나노초 이상 일하면 차이가 거의 사라집니다
// · 캐시 지역성이 더 큰 변수입니다 — Box<dyn> 은 힙에 흩어집니다 (⚡ e04)
// 인라인이 걸렸는지 확인
// cargo asm --lib '내함수' 또는 godbolt.org
Vec<Box<dyn T>> 는 원소마다 힙 할당이라
캐시 미스가 vtable 비용보다 훨씬 큽니다.
성능이 중요하면 enum + Vec 로 연속 배치하는 것이 먼저입니다(⚡ e04).
dyn 은 유연하고 코드가 하나입니다.
구현 집합이 닫혀 있으면 enum 이 셋 중 대개 최선입니다 —
할당도 없고 빠짐없음 검사도 받습니다.
“외부에서 구현을 추가할 수 있어야 하나?” 한 질문으로 고르세요.Deref · Drop · 연산자 오버로딩
Rust 의 연산자는 전부 트레이트입니다 — + 는 Add, [] 는 Index.
그리고 Deref 는 String 을 &str 자리에 넣을 수 있게 하는 장치입니다.
이 둘을 알면 표준 라이브러리가 왜 그렇게 편한지 이해됩니다.
Deref — 자동 변환의 정체 ★
fn greet(s: &str) { }
let owned = String::from("안녕");
greet(&owned); // ★ &String 인데 &str 자리에 들어갑니다
// 이유: String 이 Deref<Target = str> 을 구현하기 때문입니다.
// 컴파일러가 필요할 때 자동으로 &String → &str 로 바꿔 줍니다 (deref coercion)
// 표준 라이브러리의 Deref 사슬
// String → str
// Vec<T> → [T]
// Box<T> → T
// Rc<T> → T
// Arc<T> → T
// MutexGuard<T> → T ★ *guard 로 안을 쓰는 이유
// 그래서 이게 됩니다
let v = vec![3, 1, 2];
v.sort(); // sort 는 [T] 의 메서드인데 Vec 에서 그냥 불림
let b = Box::new(user);
println!("{}", b.name); // Box 를 벗기고 필드 접근
// 직접 구현 — 뉴타입에서 자주 씁니다 (r07)
use std::ops::Deref;
struct Meters(f64);
impl Deref for Meters {
type Target = f64;
fn deref(&self) -> &f64 { &self.0 }
}
let m = Meters(3.5);
println!("{}", m.sqrt()); // f64 의 메서드가 그대로 쓰입니다
Deref 를 쓰면 메서드가 어디서 왔는지 알 수 없게 되어
읽기 어려운 코드가 됩니다.
연산자 트레이트
use std::ops::{Add, Mul, Index, AddAssign, Neg};
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 { x: f64, y: f64 }
impl Add for Vec2 {
type Output = Vec2;
fn add(self, o: Vec2) -> Vec2 { Vec2 { x: self.x + o.x, y: self.y + o.y } }
}
impl Mul<f64> for Vec2 { // 스칼라 곱 (오른쪽 타입이 다름)
type Output = Vec2;
fn mul(self, k: f64) -> Vec2 { Vec2 { x: self.x * k, y: self.y * k } }
}
impl AddAssign for Vec2 { // +=
fn add_assign(&mut self, o: Vec2) { self.x += o.x; self.y += o.y; }
}
impl Neg for Vec2 { type Output = Vec2; fn neg(self) -> Vec2 { self * -1.0 } }
let c = Vec2 { x:1.0, y:2.0 } + Vec2 { x:3.0, y:4.0 };
let d = c * 2.0;
// 인덱싱
impl Index<usize> for Grid {
type Output = [u8];
fn index(&self, row: usize) -> &[u8] { &self.data[row*self.w..(row+1)*self.w] }
}
grid[2][3];
// 비교는 derive 로 (PartialEq, PartialOrd)
// == 는 PartialEq, < 는 PartialOrd 가 담당합니다
이렇게도 쓸 수 있다 — AsRef · Borrow · Into 의 구분 🟡 중급
비슷해 보이는 넷을 구분하기 ★
| 트레이트 | 뜻 | 쓰는 자리 |
|---|---|---|
AsRef<T> | 싸게 참조로 보기 | 인자를 넓게 받기 |
Borrow<T> | 같은 해시·순서 보장 | HashMap 조회 |
Deref | 자동 변환 | 스마트 포인터 |
Into<T> | 소유권 변환 | 값 받기 |
// AsRef — "이걸 &T 로 볼 수 있으면 다 받는다"
fn open(p: impl AsRef<Path>) { let p = p.as_ref(); }
open("a.txt"); open(String::from("b")); open(&path_buf);
// Into — "이걸 T 로 바꿀 수 있으면 다 받는다" (소유권을 가져감)
fn set_name(&mut self, n: impl Into<String>) { self.name = n.into(); }
set_name("리터럴"); set_name(owned_string);
// ★ 고르는 법
// 읽기만 하고 버린다 → AsRef
// 보관한다 → Into
//
// Borrow 는 HashMap<String, V> 에서 &str 로 조회되는 이유입니다 (r06)
// fn get<Q>(&self, k: &Q) -> Option<&V> where String: Borrow<Q>
// → "빌린 형태가 같은 해시·순서를 준다"는 추가 약속이 있습니다
From 을 구현하면 따라오는 것들
struct Celsius(f64);
struct Fahrenheit(f64);
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self { Fahrenheit(c.0 * 9.0 / 5.0 + 32.0) }
}
// 자동으로 생기는 것들 ★
let f: Fahrenheit = Celsius(100.0).into(); // Into 가 공짜로
let f = Fahrenheit::from(Celsius(100.0));
fn takes(x: impl Into<Fahrenheit>) { } // 인자로 넓게 받기
// 에러 변환에서 가장 많이 씁니다 — ? 연산자가 From 을 씁니다 (o08) ★
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self { MyError::Io(e) }
}
fn run() -> Result<(), MyError> {
let s = std::fs::read_to_string("a")?; // io::Error → MyError 자동 변환
Ok(())
}
// TryFrom — 실패할 수 있는 변환
impl TryFrom<i64> for Port {
type Error = &'static str;
fn try_from(v: i64) -> Result<Self, Self::Error> { }
}
실무에선 이렇게 — Drop 으로 자원을 관리한다 🔴 실무
FFI 핸들을 감싸는 정석 패턴 ★
// C 라이브러리의 핸들을 Rust 타입으로 감쌉니다 (🔗 f08)
pub struct Decoder {
raw: *mut ffi::Decoder, // C 가 준 포인터
}
impl Decoder {
pub fn new() -> Result<Self, Error> {
let raw = unsafe { ffi::decoder_create() };
if raw.is_null() { return Err(Error::Alloc); }
Ok(Decoder { raw })
}
pub fn decode(&mut self, input: &[u8]) -> Result<Vec<u8>, Error> { }
}
impl Drop for Decoder {
fn drop(&mut self) {
unsafe { ffi::decoder_destroy(self.raw) }; // ★ 반드시 해제됩니다
}
}
// Send/Sync 를 손으로 붙여야 할 수도 있습니다 (⚡ e06 · 🔬 d08)
unsafe impl Send for Decoder {} // C 라이브러리가 스레드 안전하다면
// ★ 이 패턴이 "안전 추상화"의 기본형입니다.
// 사용자는 unsafe 를 한 줄도 쓰지 않고, 해제를 빠뜨릴 수도 없습니다.
Drop 을 쓸 때의 실무 규칙
// ① 실패할 수 있는 정리는 명시 메서드로도 제공하세요
impl Writer {
pub fn finish(mut self) -> io::Result<()> { // 에러를 받을 수 있음
self.flush()?;
self.done = true;
Ok(())
}
}
impl Drop for Writer {
fn drop(&mut self) {
if !self.done { let _ = self.flush(); } // ★ 에러를 삼킬 수밖에 없음
}
}
// ② Drop 안에서 블로킹·await 하지 마세요 (⚡ e08)
// async 런타임 안에서 Drop 이 블로킹하면 전체가 멈춥니다.
// ③ 순환 참조가 있으면 Drop 이 안 불립니다 (Rc · o10)
// ④ 프로세스가 abort 하면 Drop 이 안 불립니다
// std::process::exit() · panic = "abort" 프로파일 (🛠️ b06)
// → 파일 flush 를 Drop 에만 의존하지 마세요
// ⑤ 테스트로 확인하기
#[test]
fn drops_once() {
let counter = Arc::new(AtomicUsize::new(0));
{ let _g = Guard(counter.clone()); }
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
한 단계 더 — Deref 강제 변환의 규칙 🧬 전문가
언제 자동으로 벗겨지나
// deref coercion 이 일어나는 자리
// ① 함수 인자로 넘길 때 &String → &str
// ② 메서드 호출의 수신자 v.sort() 에서 &Vec → &[T]
// ③ 명시적 & 표현식 let s: &str = &owned;
// 일어나지 않는 자리 ★
// · 제네릭 타입 매칭
fn f<T: AsRef<str>>(x: T) { }
// f(&owned_string) 은 되지만
fn g<T>(x: &T) { }
// g::<str>(&owned) 는 안 됩니다 — T 를 String 으로 추론하고 끝
// · 트레이트 구현 매칭
// impl Trait for str {} 가 있어도 String 이 자동으로 그 impl 을 쓰진 않습니다
// 여러 단계도 따라갑니다
let b: Box<String> = Box::new("안녕".into());
let s: &str = &b; // Box<String> → String → str ★ 두 단계
// DerefMut 도 같은 규칙 (&mut 쪽)
// Deref 는 '싸야' 합니다 — 안에서 계산하면 성능 함정이 됩니다
Drop 순서 · 패닉 · 언와인딩
// 패닉이 나면 스택을 되감으며 Drop 이 호출됩니다 (unwinding)
// → RAII 가 패닉 상황에서도 동작하는 이유입니다
// 하지만 두 경우엔 Drop 이 안 불립니다
// ① panic = "abort" 프로파일 (🛠️ b06 · 바이너리가 작아집니다)
// ② Drop 안에서 다시 패닉 → 즉시 abort
// 이미 패닉 중인지 확인
impl Drop for X {
fn drop(&mut self) {
if std::thread::panicking() {
return; // ★ 패닉 중엔 정리를 건너뜀
}
self.commit();
}
}
// ★ FFI 에서 결정적으로 중요합니다 (🔗 f09)
// 패닉이 C 코드를 가로질러 되감기면 정의되지 않은 동작입니다.
// extern "C" 함수는 반드시 catch_unwind 로 감싸세요.
extern "C" fn callback() {
let _ = std::panic::catch_unwind(|| { /* Rust 코드 */ });
}
extern "C" 함수는 기본적으로 패닉 시 abort 하도록
바뀌어 UB 는 피하지만, 프로세스가 죽습니다.
여전히 경계에서 잡아 에러 코드로 바꾸는 것이 정답입니다(🔗 f09).
Deref 가 String→&str 자동 변환의 정체이고,
연산자는 전부 트레이트입니다. 인자를 넓게 받을 땐 읽으면 AsRef, 보관하면 Into.
Drop 은 FFI 핸들을 감싸는 안전 추상화의 기본형이지만,
실패할 수 있는 정리는 명시 메서드로도 제공하세요.Result 와 ? 연산자 — 예외가 없는 에러 처리
Rust 에는 예외가 없습니다. 실패는 반환값이고, 호출자는 반드시 처리해야 합니다.
번거로울 것 같지만 ? 한 글자가 그 부담을 거의 없앱니다 —
그리고 어느 함수가 실패할 수 있는지 시그니처만 보면 알 수 있게 됩니다.
Result 와 ?
// enum Result<T, E> { Ok(T), Err(E) }
fn read_config(path: &str) -> Result<String, std::io::Error> {
let s = std::fs::read_to_string(path)?; // ★ 실패면 즉시 Err 반환
Ok(s)
}
// ? 가 없다면 이렇게 써야 합니다
fn read_config_verbose(path: &str) -> Result<String, std::io::Error> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(e) => return Err(e),
}
}
// 연쇄로 쓰면 진가가 드러납니다
fn load() -> Result<Config, Error> {
let raw = std::fs::read_to_string("config.toml")?;
let cfg: Config = toml::from_str(&raw)?; // 다른 에러 타입인데도 됨 ★
cfg.validate()?;
Ok(cfg)
}
// ? 는 From<다른에러> for Error 를 자동으로 씁니다 (o07)
// Option 에도 씁니다
fn first_char(s: &str) -> Option<char> {
let c = s.chars().next()?; // None 이면 즉시 None 반환
Some(c.to_ascii_uppercase())
}
파이썬 예외와 비교
# 파이썬 — 어떤 예외가 날지 시그니처에 없습니다
def load():
raw = open("config.toml").read() # FileNotFoundError?
return parse(raw) # ValueError? KeyError?
try:
cfg = load()
except Exception as e: # 무엇을 잡는지 모호해집니다
...
| 파이썬 | Rust |
|---|---|
raise E() | return Err(E) |
| 자동 전파 | ? |
try/except | match / if let |
finally | Drop (자동) |
| 스택 트레이스 | 백트레이스(선택) |
| 어디서든 발생 | 시그니처에 명시 |
#[must_use]).
이렇게도 쓸 수 있다 — Result 를 다루는 관용구 🟡 중급
꺼내고 변환하는 방법들
let r: Result<i32, String> = Ok(5);
r.unwrap(); // 실패면 panic (테스트·프로토타입)
r.expect("설정 읽기 실패"); // panic 하되 이유를 남김 ★
r.unwrap_or(0);
r.unwrap_or_else(|e| { log(e); 0 });
r.unwrap_or_default();
r.is_ok(); r.is_err();
r.ok(); // Result → Option (에러 버림)
r.err(); // 에러만
r.map(|v| v * 2); // 성공 값 변환
r.map_err(|e| MyError::from(e)); // ★ 에러 타입 변환 — 자주 씁니다
r.and_then(|v| next_step(v)); // Result 를 반환하는 함수 연결
r.or_else(|_| fallback()); // 실패하면 대안
// 이터레이터에서 — 하나라도 실패하면 전체 실패 ★
let nums: Result<Vec<i32>, _> =
["1", "2", "x"].iter().map(|s| s.parse::<i32>()).collect();
// Err(ParseIntError)
// 실패한 것만 걸러 내기
let nums: Vec<i32> = strs.iter().filter_map(|s| s.parse().ok()).collect();
// 에러를 모으기
let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(|r| r.is_ok());
main 에서 ? 쓰기
// main 도 Result 를 반환할 수 있습니다
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = load()?;
run(cfg)?;
Ok(())
}
// 실패하면 Debug 출력 후 종료 코드 1 로 끝납니다
// 종료 코드를 직접 정하려면
fn main() {
if let Err(e) = run() {
eprintln!("오류: {e}");
std::process::exit(2); // ★ Drop 이 안 불립니다 (o07)
}
}
// 테스트에서도 (r11)
#[test]
fn t() -> Result<(), Box<dyn std::error::Error>> {
let n: i32 = "42".parse()?;
Ok(())
}
// ★ 반환값을 무시하면 경고가 납니다
// std::fs::remove_file("a"); // warning: unused `Result`
let _ = std::fs::remove_file("a"); // 의도적 무시는 이렇게 명시
실무에선 이렇게 — panic 과 Result 를 나눈다 🔴 실무
기준: 호출자가 대응할 수 있는가 ★
| 상황 | 선택 |
|---|---|
| 파일 없음 · 네트워크 실패 | Result |
| 사용자 입력이 잘못됨 | Result |
| 설정 파싱 실패 | Result |
| 인덱스 범위 초과 | panic (버그) |
| 불변식 위반 | panic |
| OOM · 스택 오버플로 | abort |
// ★ 한 줄 기준
// "이건 프로그래머의 버그인가, 환경의 문제인가?"
// 버그 → panic! (고쳐야 할 코드가 있다)
// 환경 → Result (호출자가 재시도·대체·보고할 수 있다)
// 라이브러리라면 panic 을 문서에 적으세요
/// # 패닉
/// `idx` 가 길이 이상이면 패닉합니다.
pub fn get(&self, idx: usize) -> &T { &self.data[idx] }
// 패닉을 잡을 수도 있습니다 (경계에서만 · 🔗 f09)
let r = std::panic::catch_unwind(|| risky());
// ★ 일반 흐름 제어에 쓰지 마세요. FFI·서버 워커 격리용입니다.
에러를 잃지 않고 전달하기
// ❌ 원인이 사라집니다
fn load() -> Result<Config, String> {
std::fs::read_to_string(p).map_err(|_| "읽기 실패".to_string())?;
}
// → "읽기 실패" 만 남고 '어느 파일인지 · 권한인지 없는 건지' 가 사라짐
// ✅ 맥락을 더하되 원인을 보존 (o09 에서 자세히)
use anyhow::Context;
fn load(p: &Path) -> anyhow::Result<Config> {
let raw = std::fs::read_to_string(p)
.with_context(|| format!("설정 파일 읽기 실패: {}", p.display()))?;
let cfg = toml::from_str(&raw)
.with_context(|| "설정 파싱 실패")?;
Ok(cfg)
}
// 출력 예
// Error: 설정 파일 읽기 실패: /etc/app.toml
//
// Caused by:
// No such file or directory (os error 2) ★ 원인이 남습니다
// 백트레이스 켜기
// RUST_BACKTRACE=1 cargo run
한 단계 더 — ? 는 무엇으로 컴파일되나 🧬 전문가
Try 트레이트
// x? 는 대략 이렇게 펼쳐집니다
match std::ops::Try::branch(x) {
ControlFlow::Continue(v) => v,
ControlFlow::Break(r) => return FromResidual::from_residual(r),
}
// Result 의 경우 실질적으로
match x {
Ok(v) => v,
Err(e) => return Err(From::from(e)), // ★ From 변환이 여기서
}
// 그래서 이런 것이 가능합니다
struct MyErr;
impl From<std::io::Error> for MyErr { }
impl From<std::num::ParseIntError> for MyErr { }
fn f() -> Result<(), MyErr> {
let s = std::fs::read_to_string("a")?; // io::Error → MyErr
let n: i32 = s.trim().parse()?; // ParseIntError → MyErr
Ok(())
}
// 비용: 없습니다. 분기 하나입니다.
// Result 는 예외 테이블도, 언와인딩도 쓰지 않습니다 ★
// → 그래서 '에러가 자주 나는 경로'도 빠릅니다 (C++ 예외와 반대)
Result 의 크기가 성능에 미치는 영향
use std::mem::size_of;
size_of::<Result<(), std::io::Error>>(); // 8 (io::Error 는 박싱돼 있음)
size_of::<Result<u64, BigError>>(); // BigError 가 200바이트면 208 ★
// ❗ 성공 경로도 이 크기를 냅니다 — 반환마다 200바이트를 복사합니다
// ✅ 에러를 박싱하세요
enum MyError { Io(std::io::Error), Parse(Box<ParseDetail>) }
type Result<T> = std::result::Result<T, Box<MyError>>;
// 확인
// cargo clippy 의 result_large_err 린트 (기본 128바이트 이상 경고)
// cargo +nightly rustc -- -Zprint-type-sizes | grep Result
// ★ 실무 효과: 파서·직렬화처럼 작은 함수를 초당 수백만 번 부르는 코드에서
// Result 크기를 줄이는 것만으로 눈에 띄게 빨라지는 경우가 있습니다.
// (직접 재 보세요 — ⚡ e01)
// #[must_use] 로 무시를 막기
#[must_use = "이 결과를 확인하세요"]
pub fn try_send(&self) -> Result<(), SendError> { }
Result 반환값이고, ? 한 글자로 전파합니다.
? 는 From 변환을 자동으로 쓰므로 에러 타입을 갈아 끼울 수 있습니다.
버그면 panic, 환경 문제면 Result 로 나누고,
에러에는 무엇을 하려다 · 어떤 값으로 · 왜를 남기세요.에러 타입 설계 — thiserror 와 anyhow
에러 처리에서 실제로 고민할 것은 하나입니다 — “내 에러 타입을 만들 것인가”.
답은 간단합니다. 라이브러리면 thiserror, 애플리케이션이면 anyhow.
이 한 줄만 알아도 실무 설계가 끝납니다.
thiserror — 라이브러리용 ★
[dependencies]
thiserror = "2"
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("설정 파일을 읽을 수 없습니다: {path}")]
Read { path: String, #[source] source: std::io::Error },
#[error("TOML 문법 오류")]
Parse(#[from] toml::de::Error), // ★ From 이 자동 생성 → ? 로 변환됨
#[error("필수 항목이 없습니다: {0}")]
Missing(String),
#[error("포트 번호가 범위를 벗어남: {0} (1~65535)")]
BadPort(u32),
}
pub type Result<T> = std::result::Result<T, ConfigError>;
// 쓰는 쪽
fn load(p: &str) -> Result<Config> {
let raw = std::fs::read_to_string(p)
.map_err(|e| ConfigError::Read { path: p.into(), source: e })?;
let cfg: Config = toml::from_str(&raw)?; // #[from] 덕분에 자동 변환
Ok(cfg)
}
// ★ 왜 라이브러리는 구체 타입인가
// 사용자가 match 로 '어떤 실패인지' 구분해 대응할 수 있어야 합니다.
// Box<dyn Error> 를 돌려주면 문자열 비교밖에 못 합니다.
anyhow — 애플리케이션용 ★
[dependencies]
anyhow = "1"
use anyhow::{Context, Result, bail, ensure};
fn run(path: &str) -> Result<()> { // Result<(), anyhow::Error>
let raw = std::fs::read_to_string(path)
.with_context(|| format!("설정 읽기 실패: {path}"))?; // 맥락 추가 ★
let cfg: Config = toml::from_str(&raw).context("TOML 파싱 실패")?;
ensure!(cfg.port > 0, "포트는 0보다 커야 합니다 (받은 값: {})", cfg.port);
if cfg.hosts.is_empty() { bail!("호스트 목록이 비었습니다"); }
Ok(())
}
fn main() -> Result<()> {
run("app.toml")
}
// 출력
// Error: 설정 읽기 실패: app.toml
//
// Caused by:
// No such file or directory (os error 2)
// ★ anyhow 의 장점
// · 어떤 에러 타입이든 ? 로 받습니다 (타입을 안 만들어도 됨)
// · 맥락을 층층이 쌓아 원인 사슬을 만듭니다
// · 백트레이스를 자동으로 담습니다 (RUST_BACKTRACE=1)
// 단점: 사용자가 종류별로 대응할 수 없습니다 → 라이브러리에는 부적합
이렇게도 쓸 수 있다 — 둘을 함께 쓰는 실제 구조 🟡 중급
계층별로 나눕니다
내 프로젝트 구조
crates/parser/ ← 라이브러리: thiserror 로 ParserError
crates/storage/ ← 라이브러리: thiserror 로 StorageError
src/main.rs ← 애플리케이션: anyhow 로 전부 받아 사람에게 보고
★ 경계에서 자연스럽게 이어집니다
anyhow::Error 는 어떤 std::error::Error 든 받아들입니다.
그래서 라이브러리는 구체 타입을 내보내고,
앱은 ? 하나로 전부 모읍니다.
// 라이브러리 (thiserror)
pub fn parse(s: &str) -> Result<Ast, ParserError> { }
// 앱 (anyhow)
fn main() -> anyhow::Result<()> {
let ast = parser::parse(&src).context("소스 파싱")?; // ✅ 그냥 됩니다
let out = storage::save(&ast).context("저장")?;
Ok(())
}
// 앱에서도 특정 에러에 대응해야 한다면
match parser::parse(&src) {
Err(ParserError::UnexpectedEof) => retry_with_more_input(),
Err(e) => return Err(e.into()),
Ok(a) => a,
}
에러 사슬을 읽고 다루기
use std::error::Error;
fn print_chain(e: &dyn Error) {
eprintln!("오류: {e}");
let mut src = e.source();
while let Some(s) = src {
eprintln!(" 원인: {s}");
src = s.source();
}
}
// anyhow 는 더 쉽습니다
fn handle(e: anyhow::Error) {
eprintln!("{e:?}"); // 사슬 + 백트레이스 전부
eprintln!("{e:#}"); // 사슬을 한 줄로
// 구체 타입으로 되돌리기
if let Some(io) = e.downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::NotFound { }
}
// 사슬 전체에서 찾기
for cause in e.chain() {
if cause.is::<ParserError>() { }
}
}
// 로깅과 함께 (tracing 크레이트)
tracing::error!(error = ?e, "요청 처리 실패");
실무에선 이렇게 — FFI 경계의 에러 설계 🔴 실무
파이썬·Node 로 나갈 에러 ★
// 내부는 thiserror 로 잘 나눠 두고
#[derive(Error, Debug)]
pub enum CoreError {
#[error("입력이 비었습니다")] Empty,
#[error("길이 초과: {0} > {1}")] TooLong(usize, usize),
#[error("인코딩 오류")] Encoding(#[from] std::str::Utf8Error),
}
// 경계에서 그 언어의 예외로 변환합니다
#[cfg(feature = "python")]
impl From<CoreError> for pyo3::PyErr {
fn from(e: CoreError) -> Self {
use pyo3::exceptions::*;
match e {
CoreError::Empty => PyValueError::new_err(e.to_string()),
CoreError::TooLong(..) => PyOverflowError::new_err(e.to_string()),
CoreError::Encoding(_) => PyUnicodeDecodeError::new_err(e.to_string()),
}
}
}
// → 파이썬 쪽에서 except ValueError 로 자연스럽게 잡힙니다 (🐍 y08)
// ★ 원칙: 에러 '종류'를 언어 경계에서 잃지 마세요.
// 전부 RuntimeError 로 던지면 쓰는 사람이 대응할 수 없습니다.
C ABI 로 나갈 때는 에러 코드
// C 에는 Result 가 없습니다 → 정수 코드 + 마지막 메시지 (🔗 f09)
#[repr(i32)]
pub enum Code { Ok = 0, Empty = 1, TooLong = 2, Internal = 99 }
thread_local! {
static LAST_ERROR: std::cell::RefCell<String> = Default::default();
}
#[no_mangle]
pub extern "C" fn my_process(input: *const u8, len: usize) -> i32 {
let r = std::panic::catch_unwind(|| { // ★ 패닉 차단 (🔗 f09)
let s = unsafe { std::slice::from_raw_parts(input, len) };
core_process(s)
});
match r {
Ok(Ok(_)) => Code::Ok as i32,
Ok(Err(e)) => { set_last_error(e.to_string()); code_of(&e) }
Err(_) => { set_last_error("내부 패닉".into()); Code::Internal as i32 }
}
}
#[no_mangle]
pub extern "C" fn my_last_error(buf: *mut u8, cap: usize) -> usize { }
한 단계 더 — std::error::Error 의 안쪽 🧬 전문가
직접 구현하면 이렇게 됩니다
use std::fmt;
#[derive(Debug)]
pub struct MyError { msg: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.msg) // ★ 자기 것만 씁니다 (원인은 source 로)
}
}
impl std::error::Error for MyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.as_ref().map(|b| &**b as _)
}
}
// ★ Display 에 원인까지 넣지 마세요 — 사슬을 출력할 때 중복됩니다
// ❌ write!(f, "{}: {}", self.msg, self.source)
// ✅ write!(f, "{}", self.msg) + source() 구현
// thiserror 가 만들어 주는 것이 정확히 이 코드입니다.
// #[source] · #[from] 이 source() 구현을 생성합니다.
에러 설계 체크리스트 ★
□ 에러 타입이 작은가 (128바이트 이하 · o08)
큰 필드는 Box 로 감싸세요
□ Send + Sync 인가
스레드·async 를 쓰면 필수입니다 (⚡ e06)
Box<dyn Error> 가 아니라 Box<dyn Error + Send + Sync>
□ #[non_exhaustive] 를 붙였는가 (라이브러리)
나중에 배리언트를 추가해도 사용자 코드가 안 깨집니다 (r08)
□ 원인을 보존하는가 (#[source] / #[from])
map_err(|_| "실패") 는 조사 불가능한 로그를 만듭니다
□ 메시지가 소문자로 시작하고 마침표가 없는가
Rust 관례: "failed to read config" (상위가 이어 붙이기 좋게)
한글이면 "설정 파일을 읽지 못했습니다" 정도로 통일
□ 민감 정보가 들어가지 않는가
비밀번호·토큰이 에러 메시지로 로그에 남는 사고가 흔합니다 ★
□ 재시도 가능한 실패를 구분할 수 있는가
fn is_retryable(&self) -> bool 같은 메서드를 두면 호출자가 편합니다
thiserror 로 구체 타입,
애플리케이션은 anyhow 로 맥락을 쌓기 — 이 한 줄이면 됩니다.
둘은 자연스럽게 이어지므로 계층별로 나눠 쓰세요.
원인을 지우는 map_err(|_| "실패") 만은 피하세요 — 운영에서 조사할 수 없게 됩니다.Box · Rc · RefCell — 규칙의 탈출구 세 개
“주인은 한 명”이 답답할 때가 있습니다 — 여러 곳이 같은 값을 봐야 하거나, 불변인데 안을 고쳐야 할 때입니다. 그럴 때 쓰는 도구가 셋 있지만, 먼저 설계로 풀 수 있는지 확인하는 것이 순서입니다.
Box — 힙에 하나 두기
let b = Box::new(42); // 힙에 값, 스택엔 포인터 8바이트
println!("{}", *b); // 역참조 (Deref 로 대개 생략됨)
// ① 재귀 타입 — 크기를 정할 수 있게 ★
enum List { Cons(i32, Box<List>), Nil }
// Box 없이는 "무한 크기" 라 컴파일 안 됩니다
struct Node { value: i32, next: Option<Box<Node>> } // 링크드 리스트
// ② 큰 값을 옮길 때 (복사 비용 회피 · r05)
fn make() -> Box<[u8; 1_000_000]> { Box::new([0; 1_000_000]) }
// ③ 트레이트 객체 (o06)
let d: Box<dyn Draw> = Box::new(circle);
// ④ 에러 타입 (o08 · o09)
Box<dyn std::error::Error + Send + Sync>
// Box 는 '소유권이 하나'라는 규칙을 그대로 지킵니다.
// 그래서 성능 비용이 사실상 없습니다 (역참조 한 번).
Rc · Arc — 주인이 여럿
use std::rc::Rc;
use std::sync::Arc;
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a); // ★ 데이터 복사가 아니라 카운터 +1
println!("{}", Rc::strong_count(&a)); // 2
// 마지막 Rc 가 사라질 때 데이터가 해제됩니다
// Rc — 단일 스레드 전용 (카운터가 원자 연산이 아니라 빠름)
// Arc — 여러 스레드 (원자 카운터 · ⚡ e06)
let shared = Arc::new(config);
std::thread::spawn({
let c = Arc::clone(&shared);
move || use_config(&c)
});
// ★ 중요: Rc/Arc 안의 값은 '불변'입니다
// let mut v = Rc::new(vec![1]);
// v.push(2); // ❌ Rc<Vec> 를 통해 고칠 수 없습니다
// 고치려면 안쪽 가변성이 필요합니다 ↓
Rc<RefCell<T>> // 단일 스레드
Arc<Mutex<T>> // 멀티 스레드 ★ 가장 흔한 조합 (⚡ e07)
Arc<RwLock<T>> // 읽기가 훨씬 많을 때
// 순환 참조 끊기 — Weak
use std::rc::Weak;
struct Node { parent: Weak<Node>, children: Vec<Rc<Node>> }
// Weak 는 카운트를 올리지 않습니다. upgrade() 로 Option<Rc> 를 얻습니다.
이렇게도 쓸 수 있다 — RefCell — 검사를 런타임으로 미루기 🟡 중급
빌림 검사를 실행 중에 합니다 ★
use std::cell::RefCell;
let c = RefCell::new(vec![1, 2, 3]);
c.borrow_mut().push(4); // 가변 빌림
println!("{:?}", c.borrow()); // 불변 빌림
// ★ 규칙은 같습니다. 다만 어길 때 '컴파일 에러'가 아니라 '패닉'입니다
let b1 = c.borrow();
// let b2 = c.borrow_mut(); // 💥 panic: already borrowed
// 패닉 대신 확인하기
if let Ok(mut v) = c.try_borrow_mut() { v.push(5); }
// Cell — Copy 타입 전용, 통째로 바꾸기만 (빌림이 없어 패닉도 없음)
use std::cell::Cell;
let count = Cell::new(0);
count.set(count.get() + 1);
// 언제 쓰나
// · &self 메서드 안에서 캐시를 갱신해야 할 때 ★
// · 그래프·트리에서 부모를 가리켜야 할 때
// · 콜백 안에서 바깥 상태를 바꿔야 할 때 (o11)
struct Lazy { cache: RefCell<Option<String>> }
impl Lazy {
fn get(&self) -> String { // &self 인데 안을 고칩니다
let mut c = self.cache.borrow_mut();
c.get_or_insert_with(|| expensive()).clone()
}
}
RefCell 패닉은 운영에서 터집니다 — 컴파일러가 못 막아 주므로
빌림 범위를 최대한 짧게 유지하세요(let v = c.borrow().clone(); 처럼).
OnceCell·LazyCell 로 대체할 수 있으면 그쪽이 안전합니다.
고르는 표 ★
| 필요 | 단일 스레드 | 멀티 스레드 |
|---|---|---|
| 힙에 하나 | Box<T> | Box<T> |
| 공유 (읽기) | Rc<T> | Arc<T> |
| 공유 + 수정 | Rc<RefCell> | Arc<Mutex> |
| 읽기 多 | Rc<RefCell> | Arc<RwLock> |
| 숫자 카운터 | Cell | Atomic* |
| 한 번만 초기화 | OnceCell | OnceLock |
| 순환 끊기 | Weak | Weak |
// 비용 감각 (직접 재 보세요 · ⚡ e01)
// Box 역참조 1회 — 사실상 0
// Rc clone 비원자 증감 — 매우 쌈
// Arc clone 원자 증감 — 경합 없으면 쌈, 있으면 캐시 핑퐁 (⚡ e06)
// RefCell 런타임 카운터 확인 — 쌈
// Mutex 경합 없으면 수십 ns, 있으면 컨텍스트 스위치 ★
// ❗ 안티패턴: 루프 안에서 Arc::clone
for _ in 0..1_000_000 { let c = Arc::clone(&x); } // 원자 연산 100만 번
// ✅ 루프 밖에서 한 번 clone 하고 참조를 쓰세요
실무에선 이렇게 — Arc<Mutex<T>> 를 쓰기 전에 🔴 실무
먼저 대안을 검토합니다 ★
// ① 데이터를 나눠 준다 (공유하지 않기) — 가장 좋은 답
let (a, b) = data.split_at_mut(mid); // o02
rayon 의 par_chunks_mut // ⚡ e05
// ② 메시지로 주고받는다 (채널)
let (tx, rx) = std::sync::mpsc::channel();
// "메모리를 공유해 소통하지 말고, 소통해서 메모리를 공유하라"
// ③ 읽기 전용으로 공유한다
let shared = Arc::new(big_readonly_data); // Mutex 불필요 ★
// ④ 원자 타입으로 충분한가
let counter = Arc::new(AtomicU64::new(0));
counter.fetch_add(1, Ordering::Relaxed); // Mutex 보다 훨씬 쌈
// ⑤ 그래도 필요하면
let state = Arc::new(Mutex::new(State::new()));
// ★ 순서를 지키면 Mutex 를 쓸 일이 절반으로 줍니다.
// Rust 는 ①②③ 을 안전하게 쓸 수 있게 만들어 둔 언어입니다 (⚡ e06).
락을 쓸 때의 규칙
// ① 락 범위를 최소로
// ❌
let mut s = state.lock().unwrap();
let result = expensive_io(&s.config); // 락을 잡은 채 I/O ★ 최악
s.results.push(result);
// ✅
let config = { state.lock().unwrap().config.clone() }; // 락 즉시 해제
let result = expensive_io(&config);
state.lock().unwrap().results.push(result);
// ② 락을 잡은 채 다른 락을 잡지 마세요 (데드락)
// 꼭 필요하면 '항상 같은 순서로' 잡으세요
// ③ async 안에서 std::sync::Mutex 를 await 너머로 들고 있지 마세요 (⚡ e08)
// → tokio::sync::Mutex 를 쓰거나, await 전에 놓으세요
// ④ 락 오염(poisoning) — 락을 든 스레드가 패닉하면
let g = m.lock().unwrap_or_else(|e| e.into_inner()); // 무시하고 진행
// parking_lot 크레이트는 오염 개념이 없어 더 단순하고 빠릅니다
// ⑤ 데드락 진단
// RUST_BACKTRACE=1 + gdb thread apply all bt
// parking_lot 의 deadlock_detection feature
한 단계 더 — 안쪽 가변성의 원리 🧬 전문가
UnsafeCell — 유일한 예외
// Rust 의 핵심 가정: &T 가 있으면 그 값은 절대 안 바뀐다
// → 컴파일러가 값을 레지스터에 캐시하고 재정렬해도 안전합니다.
//
// UnsafeCell<T> 만이 이 가정에서 제외됩니다. ★
// Cell · RefCell · Mutex · RwLock · Atomic* 전부 내부적으로 UnsafeCell 입니다.
pub struct Cell<T> { value: UnsafeCell<T> }
// 직접 쓸 일은 거의 없지만, unsafe 추상화를 만들 때 필요합니다 (🔬 d08)
struct MyCell<T> { v: std::cell::UnsafeCell<T> }
impl<T: Copy> MyCell<T> {
fn get(&self) -> T { unsafe { *self.v.get() } }
fn set(&self, x: T) { unsafe { *self.v.get() = x } }
}
// ❗ 이 타입은 Sync 가 아니어야 합니다 (UnsafeCell 이 자동으로 !Sync)
// 아니면 두 스레드가 동시에 set 해 데이터 경쟁이 납니다
// 확인: Cell 은 Send 지만 Sync 가 아닙니다
// Mutex 는 둘 다입니다 (락으로 보호하니까)
Rc 의 메모리 배치와 함정
Rc<T> 의 힙 배치
┌──────────┬──────────┬─────────┐
│ strong │ weak │ T │
│ (usize) │ (usize) │ │
└──────────┴──────────┴─────────┘
▲
Rc<T> = 이 블록을 가리키는 포인터 하나 (8바이트)
★ 실무에서 알아야 할 것
· T 가 해제되는 시점: strong == 0
· 블록 자체가 해제되는 시점: strong == 0 && weak == 0
→ Weak 가 살아 있으면 메모리 블록은 남습니다 (T 는 이미 해제됨)
· 순환 참조가 있으면 strong 이 0 이 안 되어 '진짜로 샙니다' ★
Rust 가 막아 주지 않는 몇 안 되는 누수입니다
# 누수 확인
# Rc::strong_count / Arc::strong_count 를 로그로 찍기
# valgrind --leak-check=full (리눅스)
# Miri 는 누수를 기본으로 잡아 줍니다 (🔬 d07)
unsafe 가 아닙니다 — Rust 의 안전성 보장에
“누수하지 않는다”는 포함되지 않습니다. Rc 순환과 mem::forget 이
안전한 코드로 누수를 만들 수 있는 대표 사례입니다.
Box=힙에 하나, Rc/Arc=주인이 여럿, RefCell/Mutex=안쪽 가변성.
Arc<Mutex<T>> 를 쓰기 전에 나누기 · 채널 · 읽기 전용 공유 · 원자 타입을 먼저 검토하세요.
RefCell 은 패닉이 운영에서 터지므로 빌림 범위를 짧게 유지해야 합니다.클로저 — Fn · FnMut · FnOnce 세 종류
클로저는 이름 없는 함수 + 바깥에서 가져온 값입니다.
파이썬 lambda 와 같은 자리지만, 바깥 값을 “어떻게” 가져오는가에 따라
세 종류로 나뉩니다. 이 구분이 콜백 API 설계의 전부입니다.
기본 문법과 캡처
let add = |a: i32, b: i32| a + b;
let add = |a, b| a + b; // 타입 추론
let square = |x| { let y = x * x; y };
// 바깥 값을 자동으로 가져옵니다 (캡처)
let factor = 3;
let scale = |x| x * factor; // factor 를 빌림
println!("{}", scale(5)); // 15
// move — 소유권을 가져가기 ★
let name = String::from("현우");
let greet = move || println!("안녕 {name}");
// println!("{name}"); // ❌ name 은 클로저가 가져갔습니다
greet();
// 스레드에 넘길 때는 대개 move 가 필요합니다 (o03 · ⚡ e07)
std::thread::spawn(move || { use_data(data) });
// 이터레이터에서 가장 많이 씁니다 (o12)
v.iter().map(|x| x * 2).filter(|x| *x > 5).collect::<Vec<_>>();
v.sort_by_key(|u| u.age);
v.retain(|x| x.active);
세 종류의 차이 ★
| 트레이트 | 캡처 방식 | 호출 |
|---|---|---|
Fn | &T 빌림 | 여러 번 |
FnMut | &mut T 빌림 | 여러 번 |
FnOnce | T 소유 | 한 번 |
let s = String::from("안녕");
let f_fn = || println!("{s}"); // Fn — 읽기만
let mut n = 0;
let f_fnmut = || { n += 1; }; // FnMut — 바깥을 고침
let f_fnonce = move || drop(s); // FnOnce — 소유권을 소모
// 포함 관계: Fn ⊂ FnMut ⊂ FnOnce
// Fn 인 클로저는 FnMut 자리에도, FnOnce 자리에도 들어갑니다 ★
// 인자로 받을 때 — '가장 느슨한 것'을 받으세요
fn call_once(f: impl FnOnce()) { f(); } // 가장 넓게 받힘
fn call_many(f: impl Fn()) { f(); f(); } // 여러 번 부를 때만
fn accumulate(mut f: impl FnMut(i32)) { for i in 0..3 { f(i); } }
// 함수 포인터도 받아집니다
fn double(x: i32) -> i32 { x * 2 }
let v: Vec<i32> = (1..4).map(double).collect(); // ✅ fn 은 Fn 을 구현
이렇게도 쓸 수 있다 — 클로저를 저장하고 반환하기 🟡 중급
클로저는 각자 다른 타입입니다 ★
// 클로저마다 컴파일러가 익명 구조체를 만듭니다.
// 그래서 두 클로저는 시그니처가 같아도 다른 타입입니다.
// let v = vec![|x| x + 1, |x| x * 2]; // ❌ 타입 불일치
// ① Box<dyn Fn> — 여러 개를 담을 때
let ops: Vec<Box<dyn Fn(i32) -> i32>> = vec![
Box::new(|x| x + 1),
Box::new(|x| x * 2),
];
// ② impl Fn — 반환할 때 (타입 이름이 없으므로)
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
let add5 = make_adder(5);
// ③ 구조체 필드로
struct Handler<F: Fn(&Event)> { f: F } // 제네릭 — 빠름
struct Handler2 { f: Box<dyn Fn(&Event) + Send + Sync> } // dyn — 유연
// 콜백 등록 API 의 정석
impl Bus {
pub fn on(&mut self, f: impl Fn(&Event) + Send + Sync + 'static) {
self.handlers.push(Box::new(f));
}
}
캡처가 만드는 흔한 에러
// ① 클로저가 self 를 통째로 빌려 다른 필드를 못 쓰는 문제
// self.items.iter().for_each(|x| self.process(x)); // ❌ self 두 번 빌림
let f = &self.processor; // ✅ 필요한 것만 먼저
self.items.iter().for_each(|x| f.process(x));
// 2021 에디션은 필드 단위로 캡처합니다 (r01) — 상당수가 해결됐습니다
let name = &self.name;
let cb = move || println!("{name}"); // self 전체가 아니라 name 만
// ② FnMut 를 여러 곳에 넘기기
let mut count = 0;
let mut inc = || count += 1;
inc();
// println!("{count}"); // ❌ inc 가 아직 빌리고 있음
drop(inc); // ✅ 또는 스코프를 끝내기
println!("{count}");
// ③ 반환값이 인자를 빌리는 클로저 (o03 의 HRTB)
// fn f() -> impl Fn(&str) -> &str { |x| x } // 대개 애매해집니다
// ✅ 소유 값을 반환하도록 바꾸세요
// ④ async 클로저 — 아직 제약이 있습니다 (⚡ e08)
let fut = async move { work().await }; // async 블록을 쓰세요
실무에선 이렇게 — 콜백 API 를 설계한다 🔴 실무
어떤 형태로 받을 것인가
// ① 즉시 호출하고 버림 → impl FnOnce / impl FnMut (제네릭 · 빠름)
pub fn with_lock<R>(&self, f: impl FnOnce(&mut State) -> R) -> R {
let mut g = self.state.lock().unwrap();
f(&mut g) // ★ 락 범위를 API 가 강제합니다 (o10)
}
bus.with_lock(|s| s.count += 1);
// ② 저장해 두고 나중에 여러 번 → Box<dyn Fn>
pub fn subscribe(&mut self, f: Box<dyn Fn(&Event) + Send + Sync>) { }
// ③ 진행률 보고 — 없어도 되게
pub fn process(&self, data: &[u8], on_progress: Option<&dyn Fn(f32)>) { }
// 또는 기본값을 주는 두 함수로 나누기 (r03 · 기본 인자가 없으므로)
// ④ FFI 로 넘길 콜백은 함수 포인터 + 사용자 데이터 (🔗 f07)
pub type Callback = extern "C" fn(progress: f32, user: *mut std::ffi::c_void);
// ★ 실무 기준
// 내부용·성능 중요 → impl Fn (제네릭)
// 저장·플러그인·경계 통과 → Box<dyn Fn>
Send · Sync · 'static 을 언제 요구하나
// 스레드로 넘어가면 필요합니다 (⚡ e06)
fn spawn(f: impl FnOnce() + Send + 'static) { }
// 여러 스레드가 동시에 부르면 Sync 도
fn register(f: impl Fn(&Event) + Send + Sync + 'static) { }
// ★ 'static 은 "빌린 것이 없다"는 뜻입니다 (o03)
// 클로저가 지역 변수를 빌리면 만족하지 못합니다 → move 로 해결
// 요구를 최소화하는 것이 친절한 API 입니다
// ❌ 항상 Send + Sync + 'static 을 요구
// ✅ 실제로 스레드를 쓸 때만
// 스코프 스레드를 쓰면 'static 이 필요 없습니다 (⚡ e07)
std::thread::scope(|s| {
s.spawn(|| use_local(&local)); // ✅ 빌려도 됩니다
});
// 파이썬 콜백을 받을 때 (🐍 y09)
// PyO3 의 Py<PyAny> 는 Send 지만 호출하려면 GIL 이 필요합니다
// → 다른 스레드로 넘길 때 규칙이 따로 있습니다
한 단계 더 — 클로저는 무엇으로 컴파일되나 🧬 전문가
익명 구조체 + 트레이트 구현
let factor = 3;
let scale = move |x: i32| x * factor;
// 컴파일러가 대략 이렇게 만듭니다
struct __Closure1 { factor: i32 } // 캡처한 것이 필드가 됩니다
impl Fn<(i32,)> for __Closure1 {
fn call(&self, (x,): (i32,)) -> i32 { x * self.factor }
}
// 크기는 '캡처한 것들의 합'입니다
let a = || (); // 0바이트 (ZST) ★
let b = move || factor; // 4바이트
let c = move || big_vec; // 24바이트 (Vec 이동)
println!("{}", std::mem::size_of_val(&a));
// ★ 성능 의미
// · 캡처가 없으면 크기 0 → 넘기는 비용도 0
// · impl Fn 으로 받으면 인라인됩니다 (⚡ e03)
// · Box<dyn Fn> 은 힙 할당 + 간접 호출 (o06 과 같은 트레이드오프)
캡처 규칙의 세부
// 컴파일러는 '가장 약한 캡처'를 고릅니다
let v = vec![1, 2, 3];
let f = || println!("{}", v.len()); // &v 로 충분 → Fn
let g = || v.len(); // 같음
let h = move || v; // 값이 필요 → FnOnce
// move 를 붙여도 Copy 타입은 복사됩니다
let n = 5;
let f = move || n; // n 은 여전히 쓸 수 있습니다 (i32 는 Copy)
// 2021 에디션의 필드 단위 캡처 (r01)
struct S { a: String, b: String }
let s = S { .. };
let f = move || println!("{}", s.a); // ★ s.a 만 가져갑니다
println!("{}", s.b); // ✅ 2021 부터 가능 (2018 은 에러)
// 강제로 전체를 캡처하려면
let f = move || { let _ = &s; println!("{}", s.a); };
// 클로저에서 재귀하기 — 직접은 안 됩니다
fn fib(n: u64) -> u64 { if n < 2 { n } else { fib(n-1) + fib(n-2) } }
// 클로저로 재귀하려면 Box<dyn Fn> + Rc 또는 별도 fn 을 쓰세요
Fn 계열 트레이트입니다.
Fn(읽기) ⊂ FnMut(고침) ⊂ FnOnce(소모) 순으로 느슨해지므로
인자는 가장 느슨한 것으로 받으세요. 스레드로 넘길 땐 move + Send + 'static,
저장할 땐 Box<dyn Fn>, 즉시 쓰면 impl Fn 이 정석입니다.이터레이터 — 지연 평가인데 for 만큼 빠릅니다
.iter().map().filter() 는 부르는 순간 아무 일도 하지 않습니다.
마지막에 collect()·sum() 같은 소비자를 만나야 비로소 돕니다.
그리고 컴파일러가 이 전부를 손으로 쓴 for 문과 같은 기계어로 만듭니다.
세 가지 시작점 ★
let v = vec![1, 2, 3];
v.iter() // &T — 빌려 보기 (v 는 그대로)
v.iter_mut() // &mut T — 고치기
v.into_iter() // T — 가져가기 (v 를 소모)
// for 문은 사실 이 셋의 문법 설탕입니다
for x in &v { } // v.iter()
for x in &mut v { } // v.iter_mut()
for x in v { } // v.into_iter() ★ v 를 못 쓰게 됩니다
// 자주 쓰는 어댑터 (지연 — 아직 아무 일도 안 함)
v.iter()
.map(|x| x * 2) // 변환
.filter(|x| *x > 2) // 거르기
.filter_map(|x| f(x).ok()) // 변환 + 거르기 한 번에 ★
.enumerate() // (인덱스, 값)
.zip(other.iter()) // 두 개 동시에
.take(5) .skip(2) .step_by(2)
.rev() .chain(other) .flatten()
.take_while(|x| *x < 10)
.peekable() // 다음 것을 미리 보기
.inspect(|x| println!("{x:?}")) // 디버깅용 ★
소비자 — 여기서 실제로 돕니다
let v = vec![1, 2, 3, 4];
v.iter().sum::<i32>(); // 10
v.iter().product::<i32>();
v.iter().count();
v.iter().min(); v.iter().max();
v.iter().max_by_key(|x| x.abs());
v.iter().fold(0, |acc, x| acc + x); // 임의의 누적 ★
v.iter().reduce(|a, b| a.max(b));
v.iter().any(|x| *x > 3); // 하나라도 (조기 종료)
v.iter().all(|x| *x > 0); // 전부 (조기 종료)
v.iter().find(|x| **x > 2); // 첫 번째
v.iter().position(|x| *x == 3); // 인덱스
v.iter().for_each(|x| println!("{x}"));
// collect — 무엇으로 모을지 타입이 정합니다 (r02)
let v2: Vec<i32> = it.collect();
let s: String = chars.collect();
let m: HashMap<_, _> = pairs.collect();
let set: HashSet<_> = it.collect();
let r: Result<Vec<_>, _> = results.collect(); // 하나라도 Err 면 Err ★
// 정렬은 슬라이스 메서드입니다 (이터레이터가 아님)
let mut v2: Vec<_> = it.collect();
v2.sort_unstable(); // 안정성이 필요 없으면 이쪽이 빠름
v2.sort_by_key(|u| u.age);
v2.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
이렇게도 쓸 수 있다 — 직접 만들고, 파이썬과 비교하기 🟡 중급
Iterator 를 구현하면 전부 딸려 옵니다
struct Fib { a: u64, b: u64 }
impl Iterator for Fib {
type Item = u64;
fn next(&mut self) -> Option<u64> { // ★ 이 하나만 구현하면 됩니다
let r = self.a;
self.a = self.b;
self.b = r + self.b;
Some(r)
}
}
// map · filter · take · sum … 전부 기본 구현으로 따라옵니다
let v: Vec<u64> = Fib { a: 0, b: 1 }.take(10).collect();
for x in Fib { a: 0, b: 1 }.take_while(|x| *x < 100) { }
// 내 타입을 for 문에 쓰이게 하기
impl IntoIterator for MyCollection {
type Item = Item;
type IntoIter = std::vec::IntoIter<Item>;
fn into_iter(self) -> Self::IntoIter { self.items.into_iter() }
}
// 관례: iter() · iter_mut() · into_iter() 세 개를 다 제공하세요
// 무한 이터레이터
std::iter::repeat(0).take(10);
std::iter::successors(Some(1u64), |x| x.checked_mul(2));
(1..).map(|x| x * x).take(5);
파이썬 관용구 대조표 ★
| 파이썬 | Rust |
|---|---|
[f(x) for x in v] | .map(f).collect() |
[x for x in v if c] | .filter(c).collect() |
sum(v) | .sum() |
any(...) | .any(...) |
enumerate | .enumerate() |
zip | .zip() |
itertools.chain | .chain() |
reduce | .fold() |
sorted(v, key=f) | .sort_by_key(f) |
set(v) | .collect::<HashSet>() |
Counter | entry().or_insert(0) |
groupby | itertools 크레이트 |
| 제너레이터 | impl Iterator |
// itertools — 표준에 없는 것들 (아주 자주 씁니다)
use itertools::Itertools;
v.iter().unique();
v.iter().sorted();
v.iter().chunks(3);
v.iter().tuple_windows::<(_, _)>();
v.iter().group_by(|x| x.kind);
v.iter().join(", "); // 문자열 합치기 ★
실무에선 이렇게 — 읽히는 체인과 성능 🔴 실무
체인이 길어지면 나누세요
// ❌ 한 줄에 여덟 단계 — 디버깅이 지옥입니다
let r: Vec<_> = data.iter().filter(|x| x.ok).map(|x| &x.name)
.filter(|n| n.len() > 3).map(|n| n.to_uppercase())
.filter(|n| !seen.contains(n)).take(10).collect();
// ✅ 의미 단위로 이름을 붙입니다
let valid = data.iter().filter(|x| x.ok);
let names = valid.map(|x| x.name.to_uppercase());
let fresh: Vec<_> = names.filter(|n| !seen.contains(n)).take(10).collect();
// ★ 중간 변수를 만들어도 지연 평가라 성능 비용이 없습니다
// 디버깅 — inspect 를 끼워 넣기
let r: Vec<_> = data.iter()
.inspect(|x| eprintln!("입력: {x:?}"))
.filter(|x| x.ok)
.inspect(|x| eprintln!("통과: {x:?}"))
.collect();
// 타입이 헷갈릴 때
// let _: () = iter; // 일부러 틀려 에러 메시지로 타입 확인 (r02)
할당을 줄이는 요령 ★
// ① collect 를 남발하지 마세요 — 매번 힙 할당입니다
// ❌
let a: Vec<_> = it.map(f).collect();
let b: Vec<_> = a.iter().filter(g).collect();
// ✅ 한 번에
let b: Vec<_> = it.map(f).filter(g).collect();
// ② 크기를 알면 미리 잡기
let mut out = Vec::with_capacity(input.len());
out.extend(input.iter().map(f));
// collect 도 size_hint 를 보고 미리 잡습니다 (ExactSizeIterator 일 때) ★
// ③ 문자열은 to_string 대신 참조로
// ❌ .map(|u| u.name.clone())
// ✅ .map(|u| u.name.as_str()) // 수명이 허락하면
// ④ 기존 버퍼에 모으기
out.clear();
out.extend(it); // 할당 재사용 (o04)
// ⑤ 병렬로 — rayon 은 이터레이터와 같은 모양입니다 ★
use rayon::prelude::*;
let sum: i64 = data.par_iter().map(heavy).sum(); // ⚡ e05
// 측정 없이 최적화하지 마세요 (⚡ e01)
한 단계 더 — 왜 for 문만큼 빠른가 🧬 전문가
추상화가 사라지는 과정
let s: u64 = v.iter().map(|x| x * 2).filter(|x| x % 3 == 0).sum();
// ① 각 어댑터는 next() 를 구현한 구조체입니다
// Sum<Filter<Map<slice::Iter>>>
// ② sum() 이 next() 를 반복 호출합니다
// ③ 컴파일러가 next() 를 전부 인라인합니다 (전부 제네릭 · 단형화 · r10)
// ④ 남은 것은 루프 하나 — 손으로 쓴 for 문과 동일한 기계어 ★
// 확인
// cargo asm --lib my_fn
// godbolt.org 에서 -O 로 비교
// ★ 오히려 더 빠를 수 있는 이유
// · 슬라이스 이터레이터는 범위 검사가 없습니다 (v[i] 는 있음)
// · 길이를 알아 LLVM 이 벡터화하기 쉽습니다 (⚡ e09)
// 벡터화가 걸렸는지 보려면 어셈블리에서 SIMD 명령을 찾으세요
// x86: vpaddd · vmulps ARM: fadd v0.4s
최적화가 깨지는 자리
// ① dyn 이 끼면 인라인이 안 됩니다
let f: Box<dyn Fn(&i32) -> i32> = Box::new(|x| x * 2);
v.iter().map(f) // 호출마다 간접 분기 (o06)
// ② 부동소수점 합은 순서를 못 바꿔 벡터화가 막힙니다
let s: f64 = v.iter().sum(); // 결합 법칙이 성립하지 않음 ★
// → 정확도를 포기해도 되면 청크로 나눠 부분합을 만드세요
// ③ 이터레이터를 컬렉션에 담으면 (Box<dyn Iterator>) 같은 문제
// ④ size_hint 가 부정확하면 collect 가 재할당합니다
// filter 뒤에는 상한만 알 수 있습니다 (0..n)
// ⑤ 조기 종료가 있는 어댑터의 순서
v.iter().map(expensive).take(3); // ✅ 3번만 계산 (지연 평가)
v.iter().filter(cheap).map(expensive); // ✅ 거른 뒤에 비싼 연산
v.iter().map(expensive).filter(cheap); // ❌ 전부 계산한 뒤 거름
// ★ 순서 규칙: 싸고 많이 거르는 것을 앞으로 ★
lines().filter().take(10) 은 10개를 찾는 순간 멈춥니다.
전부 읽어 리스트로 만든 뒤 자르는 파이썬 코드와 결정적으로 다른 지점입니다.
for 문과 같은 기계어가 나옵니다.
싸고 많이 거르는 어댑터를 앞에 두고, collect 는 한 번만 하세요.
par_iter() 로 바꾸면 그대로 병렬이 됩니다(⚡ e05). 다음은 🐍 파이썬 모듈 탭입니다.느린 함수 하나만
Rust 로 갈아 끼웁니다
파이썬을 버리는 게 아닙니다. 프로파일러가 계속 가리키는 그 함수 하나를 Rust 로 만들어
import 해서 쓰는 것이 목표입니다. 쓰는 사람은 Rust 가 있는지도 모릅니다.
파일 두 개, 명령 한 줄이면 첫 모듈이 돕니다 — C++ 와 가장 크게 갈리는 지점입니다.
왜 Rust 로 만드나 — 판단 기준이 먼저
가장 중요한 결정은 “Rust 로 안 만든다”일 때가 많습니다. 네이티브 확장은 빌드·배포·디버깅 비용이 함께 따라옵니다. 측정으로 시작하고, 대안을 먼저 세어 본 뒤 그래도 남는 경우에만 만드세요.
먼저 재세요 — 어디가 느린가
# ① 함수 단위로 어디가 오래 걸리나
python -m cProfile -s cumtime app.py | head -30
# ② 줄 단위로 (pip install line_profiler)
kernprof -l -v app.py # @profile 데코레이터를 붙인 함수
# ③ 샘플링 프로파일러 — 운영 중인 프로세스에도 붙습니다 ★
pip install py-spy
py-spy top --pid 12345
py-spy record -o flame.svg --pid 12345
# ★ 판단 기준
# · 전체의 5% 를 차지하는 함수를 100배 빠르게 해도 전체는 5% 만 빨라집니다
# · 60% 이상을 차지하는 '핫스팟 하나'가 있을 때만 이득이 큽니다
# · 그 함수가 순수 계산인가? (I/O 대기라면 Rust 로 바꿔도 그대로입니다)
Rust 이전에 시도할 것들 ★
| 방법 | 기대 | 비용 |
|---|---|---|
| 알고리즘 개선 | 수십~수천배 | 낮음 |
| numpy 벡터화 | 10~100배 | 낮음 |
| polars / pyarrow | 5~50배 | 낮음 |
| orjson · regex 교체 | 2~10배 | 매우 낮음 |
| multiprocessing | 코어 수만큼 | 중간 |
| numba (JIT) | 10~100배 | 중간 |
| Cython | 10~100배 | 중간 |
| Rust 확장 | 10~200배 | 중간 |
| C++ 확장 | 10~200배 | 높음 |
★ Rust 가 실제로 유리한 경우
· numpy 로 벡터화할 수 없는 '요소마다 다른 분기'가 있는 루프
· 문자열·바이트 파싱 (numpy 가 못 다룸)
· 트리·그래프 순회 같은 포인터 추적 작업
· GIL 때문에 멀티스레드가 안 되는 CPU 작업 (y07)
· 이미 Rust 로 된 좋은 크레이트가 있는 경우 (regex · serde · image)
★ Rust 가 불리한 경우
· 병목이 I/O · 네트워크 대기
· numpy 한 줄로 되는 행렬 연산 (이미 BLAS 가 C 로 최적화돼 있음)
· 팀에 Rust 를 아는 사람이 나뿐인 경우 ★ (유지보수 비용)
· 배포 대상이 다양한 플랫폼인데 CI 를 못 갖춘 경우 (🛠️ b08)
이렇게도 쓸 수 있다 — C++ 확장과 비교해 무엇이 다른가 🟡 중급
이 가이드의 핵심 대비점 ★
| C++ (pybind11) | Rust (PyO3) | |
|---|---|---|
| 빌드 설정 | CMake + scikit-build | maturin 한 줄 |
| 의존성 | vcpkg · 헤더 경로 | Cargo.toml 한 줄 |
| 개발 반복 | 재설정 · 재빌드 | maturin develop |
| 메모리 버그 | ASan 으로 확률적 | 대부분 컴파일 에러 |
| 병렬화 | 스레드 직접 | rayon 한 줄 |
| 배포(wheel) | cibuildwheel 설정 | maturin + CI 템플릿 |
| 성능 | 동급 | 동급 |
| 생태계 성숙 | 더 넓음 | 충분함 |
실제로 쓰이는 사례들
널리 쓰이는 파이썬 패키지 중 Rust 로 만들어진 것들
polars 데이터프레임 — pandas 대체
pydantic-core pydantic v2 의 검증 엔진 (v1 대비 수배)
ruff 린터 — flake8 대비 수십~수백배
uv 패키지 관리자 — pip 대비 수십배
orjson JSON — 표준 json 대비 수배
cryptography 일부 백엔드
tokenizers HuggingFace 토크나이저
★ 공통점
· 파이썬 루프가 병목인 작업 (파싱 · 검증 · 순회)
· 인터페이스는 완전히 파이썬다움 (쓰는 사람은 Rust 를 모름)
· 순수 파이썬 폴백이나 이전 버전이 함께 있음 ★
★ 배울 점
이들은 "파이썬을 Rust 로 다시 썼다"가 아니라
"느린 층 하나를 갈아 끼웠다" 입니다. 우리도 그래야 합니다.
실무에선 이렇게 — 되돌릴 수 있게 시작한다 🔴 실무
폴백을 남기는 구조 ★
# mypkg/__init__.py
import os
_USE_NATIVE = os.getenv("MYPKG_NATIVE", "1") != "0"
if _USE_NATIVE:
try:
from ._native import process # Rust 확장
except ImportError: # 휠이 없는 플랫폼
from ._pure import process # 순수 파이썬 폴백
else:
from ._pure import process
__all__ = ["process"]
# tests/test_parity.py — 두 구현이 같은 결과를 내는지 ★
import pytest
from mypkg._pure import process as pure
from mypkg._native import process as native
@pytest.mark.parametrize("case", load_fixtures())
def test_parity(case):
assert native(case) == pure(case)
MYPKG_NATIVE=0 한 줄로 즉시 되돌릴 수 있습니다.
네이티브 코드는 틀려도 조용히 틀리기 때문에 이 안전장치가 특히 중요합니다(r11 · y12).
경계를 좁게 잡습니다
★ 원칙 ① 경계를 넘는 '횟수'를 데이터 크기와 분리하라
❌ 나쁜 설계 — 요소마다 경계를 넘음
for row in rows: # 100만 번
result.append(rust.process_one(row))
✅ 좋은 설계 — 한 번에 전부 넘김
results = rust.process_batch(rows) # 1번
경계 통과 비용은 호출당 대략 수백 나노초입니다.
100만 번이면 그것만으로 수백 밀리초 — 계산보다 큽니다. ★
★ 원칙 ② 좁고 안정된 인터페이스
❌ Rust 구조체 20개를 파이썬에 노출
✅ 함수 3개 · 클래스 1개 — 나머지는 안에 숨김
내부를 자유롭게 바꿀 수 있고, 문서·스텁도 짧아집니다 (y10)
★ 원칙 ③ 측정하고 결정하라 — "빨라진 것 같다"는 근거가 아닙니다 (⚡ e01)
★ 원칙 ④ 되돌릴 수 있게 — 폴백 + parity 테스트
★ 원칙 ⑤ 안 쓰는 판단도 실력 — y01 의 표를 다시 보세요
한 단계 더 — 비용을 숫자로 세어 보기 🧬 전문가
손익분기점 계산
모델 (직접 재서 값을 채우세요 · ⚡ e01)
파이썬 실행 시간 T_py
Rust 실행 시간 T_rs (보통 T_py / 10 ~ / 200)
경계 통과 비용 C ≈ 0.1 ~ 1 μs / 호출
변환 비용 V ∝ 데이터 크기 (제로카피면 ≈ 0 · y06)
호출 n 번, 데이터 m 개일 때
파이썬: n · T_py(m)
Rust : n · (C + V(m) + T_rs(m))
→ n 이 크고 m 이 작으면 C 가 지배합니다 (배치로 묶으세요)
→ m 이 크면 V 가 지배합니다 (제로카피가 필수 · y06)
실측 감각 (환경마다 다릅니다 — 반드시 직접 재세요)
빈 함수 호출 왕복 약 0.1~0.3 μs
리스트 100만 개 → Vec 수십 ms (복사 발생)
numpy 배열 제로카피 약 0.001 ms (뷰만 만듦) ★
사람 비용도 계산에 넣으세요
★ 자주 잊는 비용들
· CI 빌드 시간이 늘어납니다 (3 OS × 여러 아키텍처 · 🛠️ b08)
· 기여자가 Rust 툴체인을 깔아야 합니다
· 스택 트레이스가 파이썬에서 끊깁니다 (y12)
· 새 파이썬 버전이 나오면 다시 빌드해야 합니다 (abi3 로 완화 · y11)
· 팀의 다른 사람이 고칠 수 있는가 ★
★ 그럼에도 만들 가치가 있는 신호
· 이 코드가 앞으로 몇 년 쓰일 것이 확실하다
· 성능이 제품의 핵심 가치다 (응답 시간 · 처리량 SLA)
· 파이썬으로는 물리적으로 불가능한 지점에 있다 (GIL · y07)
· 이미 Rust 를 쓰는 다른 부분이 있다
★ 판단이 서지 않으면
프로토타입을 하루 만들어 y03 까지 해 보고,
실제 배수를 재서 결정하세요. 하루면 충분합니다.
PyO3 첫 모듈 — 5분이면 됩니다
Rust 함수 하나를 파이썬에서 import 해 쓰기까지 파일 두 개면 충분합니다.
C++ 라면 CMakeLists 를 쓰고 컴파일러를 맞춰야 했던 자리인데,
여기서는 maturin develop 한 줄로 끝납니다.
처음부터 끝까지
# ① 준비 (한 번만)
pip install maturin # 또는 uv tool install maturin
# ② 프로젝트 생성 — 뼈대를 전부 만들어 줍니다 ★
maturin new -b pyo3 fastmod
cd fastmod
# 만들어지는 것
# Cargo.toml Rust 설정
# pyproject.toml 파이썬 패키지 설정
# src/lib.rs 여기에 코드를 씁니다
# ③ 가상환경에 설치 (빌드 + install 을 한 번에)
python -m venv .venv && source .venv/bin/activate # 윈도우: .venv\Scripts\activate
maturin develop --release # ★ 이 한 줄이 전부입니다
# ④ 써 보기
python -c "import fastmod; print(fastmod.sum_squares(1000))"
코드 두 곳
// src/lib.rs
use pyo3::prelude::*;
/// 1..n 의 제곱합을 구합니다.
#[pyfunction]
fn sum_squares(n: u64) -> u64 {
(1..=n).map(|x| x * x).sum()
}
#[pyfunction]
fn count_words(text: &str) -> usize {
text.split_whitespace().count()
}
/// 파이썬 모듈 정의 — 이름이 Cargo.toml 의 lib.name 과 같아야 합니다 ★
#[pymodule]
fn fastmod(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(sum_squares, m)?)?;
m.add_function(wrap_pyfunction!(count_words, m)?)?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
Ok(())
}
# Cargo.toml
[package]
name = "fastmod"
version = "0.1.0"
edition = "2021"
[lib]
name = "fastmod" # ★ #[pymodule] 함수 이름과 같아야 합니다
crate-type = ["cdylib"] # ★ 동적 라이브러리로 (🔗 f02)
[dependencies]
pyo3 = { version = "0.23", features = ["extension-module"] }
이렇게도 쓸 수 있다 — 파이썬다운 인터페이스로 다듬기 🟡 중급
인자 · 기본값 · 키워드
use pyo3::prelude::*;
// 기본값과 키워드 전용 인자
#[pyfunction]
#[pyo3(signature = (text, *, lowercase = true, min_len = 1))]
fn tokenize(text: &str, lowercase: bool, min_len: usize) -> Vec<String> {
text.split_whitespace()
.filter(|w| w.len() >= min_len)
.map(|w| if lowercase { w.to_lowercase() } else { w.to_string() })
.collect()
}
// 파이썬: tokenize("A B", lowercase=False, min_len=1)
// Option 은 None 을 받습니다
#[pyfunction]
#[pyo3(signature = (data, seed = None))]
fn shuffle(data: Vec<i64>, seed: Option<u64>) -> Vec<i64> { }
// 가변 인자
#[pyfunction]
#[pyo3(signature = (*args, **kwargs))]
fn flexible(args: &Bound<'_, PyTuple>, kwargs: Option<&Bound<'_, PyDict>>) { }
// 문서 문자열은 /// 주석이 그대로 __doc__ 이 됩니다 ★
/// 텍스트를 토큰으로 나눕니다.
///
/// Args:
/// text: 입력 문자열
/// Returns:
/// 토큰 리스트
패키지 구조 — 파이썬 코드와 섞기 ★
fastmod/
├─ Cargo.toml
├─ pyproject.toml
├─ src/lib.rs Rust 확장
└─ python/
└─ fastmod/
├─ __init__.py ★ 파이썬 래퍼 (여기가 공개 얼굴)
├─ _pure.py 폴백 구현 (y01)
└─ py.typed 타입 힌트가 있다는 표시 (y10)
# pyproject.toml
[build-system]
requires = ["maturin>=1.7,<2"]
build-backend = "maturin"
[project]
name = "fastmod"
requires-python = ">=3.9"
classifiers = ["Programming Language :: Rust"]
[tool.maturin]
python-source = "python" # ★ 파이썬 소스 위치
module-name = "fastmod._native" # ★ 확장은 서브모듈로 숨깁니다
# python/fastmod/__init__.py
from ._native import sum_squares as _sum_squares
def sum_squares(n: int) -> int:
"""1..n 의 제곱합."""
if n < 0:
raise ValueError("n 은 0 이상이어야 합니다") # 검증은 파이썬에서 ★
return _sum_squares(n)
_native 로 숨기나 — 공개 API 를 파이썬 쪽에 두면
인자 검증·기본값·문서·타입 힌트를 파이썬 관례대로 쓸 수 있고,
내부 Rust 구조를 마음대로 바꿀 수 있습니다(y01 원칙 ②).
실무에선 이렇게 — 개발 흐름을 만든다 🔴 실무
매일 쓰는 명령
# 개발 중 — 빠른 반복
maturin develop # 디버그 빌드 (빠른 빌드 · 느린 실행)
maturin develop --release # 릴리즈 (성능을 잴 땐 반드시 ★)
maturin develop -E test # extras 도 함께 설치
# 테스트
cargo test # Rust 쪽 단위 테스트 (r11)
pytest # 파이썬 쪽 통합 테스트
# 배포용 빌드
maturin build --release # target/wheels/*.whl
maturin publish # PyPI 로 (🛠️ b08)
# 확인
python -c "import fastmod; print(fastmod.__file__)"
pip show -f fastmod
--release 누락입니다(⚡ e01).
첫 모듈에서 반드시 확인할 것
# tests/test_basic.py
import fastmod
def test_import():
assert fastmod.__version__
def test_correctness():
assert fastmod.sum_squares(3) == 14 # 1+4+9
def test_edge_cases():
assert fastmod.sum_squares(0) == 0
assert fastmod.count_words("") == 0
assert fastmod.count_words(" 여러 공백 ") == 2
def test_type_errors():
import pytest
with pytest.raises(TypeError):
fastmod.sum_squares("문자열") # ★ 타입 오류가 제대로 나는가
def test_no_crash_on_large():
fastmod.count_words("x " * 1_000_000) # ★ 큰 입력에 죽지 않는가
# 성능 비교 (같은 조건에서)
python -m timeit -s "import fastmod" "fastmod.sum_squares(1000000)"
python -m timeit "sum(x*x for x in range(1, 1000001))"
# ★ 반드시 --release 로 빌드한 뒤 재세요
한 단계 더 — 확장 모듈은 실제로 무엇인가 🧬 전문가
import 가 하는 일
1. 파이썬이 fastmod/_native.cpython-312-x86_64-linux-gnu.so 를 찾습니다
2. dlopen() 으로 로드합니다
3. PyInit__native 심볼을 찾아 호출합니다 ★
4. 그 함수가 모듈 객체를 만들어 돌려줍니다
# 확인
python -c "import fastmod._native as m; print(m.__file__)"
nm -D fastmod/_native*.so | grep PyInit # 리눅스
dumpbin /exports _native.pyd | findstr PyInit # 윈도우
# ★ #[pymodule] 이 이 PyInit_ 함수를 생성합니다.
# Cargo.toml 의 lib.name 과 함수 이름이 다르면
# "dynamic module does not define module export function" 이 납니다 (y12)
파일 이름의 의미
_native.cpython-312-x86_64-linux-gnu.so
└ 파이썬 버전 ┘ └─ 플랫폼 ─┘
→ 이 파일은 CPython 3.12 · x86_64 리눅스 전용입니다
→ abi3 로 빌드하면 버전 부분이 abi3 가 되어 3.x 전체에서 씁니다 (y11)
Bound<'py, T> — PyO3 의 핵심 타입
use pyo3::prelude::*;
// PyO3 0.21+ 의 API — GIL 을 잡고 있다는 증거를 타입으로 표현합니다
#[pyfunction]
fn inspect(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<String> {
// ^^^^^^^^^^^^ GIL 토큰 ^^^^^^^^^^^^^^^ GIL 에 묶인 참조
let t = obj.get_type().name()?;
Ok(format!("{t}"))
}
// 세 가지 형태
// Python<'py> GIL 을 잡고 있다는 증거 (토큰)
// Bound<'py, T> GIL 에 묶인 파이썬 객체 참조 ★ 대부분 이것
// Py<T> GIL 과 무관하게 보관 가능 (쓰려면 다시 GIL 필요)
// GIL 없이 보관하기 (구조체 필드 · 스레드 간 전달 · y09)
struct Holder { callback: Py<PyAny> }
impl Holder {
fn call(&self, py: Python<'_>) -> PyResult<()> {
self.callback.bind(py).call0()?; // ★ GIL 을 다시 잡아 bind
Ok(())
}
}
// ★ 이 타입 설계 덕분에 "GIL 없이 파이썬 객체를 만졌다"가
// 컴파일 에러가 됩니다. C API 를 직접 쓸 때의 대표적 사고를 막습니다.
maturin new -b pyo3 → src/lib.rs 에 #[pyfunction] →
maturin develop --release. 이게 전부입니다.
Cargo.toml 의 lib.name 과 #[pymodule] 함수 이름을 맞추는 것만 주의하세요.
공개 API 는 파이썬 래퍼에 두고 확장은 _native 로 숨기면 나중이 편합니다.maturin — 빌드가 이렇게 간단합니다
C++ 확장에서 가장 오래 걸리는 일은 코드 작성이 아니라 빌드 시스템 설정이었습니다. CMake · scikit-build-core · 컴파일러 플래그 · 링크 순서… 여기서는 그게 전부 사라집니다. 이 섹션이 이 가이드의 핵심 대비점입니다.
C++ 와 나란히 놓고 보기 ★
# C++ (pybind11 + scikit-build-core) 로 첫 모듈을 만들 때 필요한 것
CMakeLists.txt (약 20~40줄)
pyproject.toml (빌드 백엔드 · CMake 인자)
src/main.cpp
+ 컴파일러 설치 (MSVC Build Tools / gcc / clang)
+ pybind11 를 어떻게 가져올지 결정 (FetchContent / vcpkg / pip)
+ 플랫폼별 플래그 처리
+ 빌드가 깨지면 CMake 에러 메시지를 해석해야 함
# Rust (PyO3 + maturin)
Cargo.toml (7줄)
pyproject.toml (5줄 · maturin new 가 만들어 줌)
src/lib.rs
+ rustup 한 번
maturin 명령 전부
maturin new -b pyo3 이름 # 프로젝트 생성
maturin develop # 현재 venv 에 설치 (개발용) ★
maturin develop --release # 최적화 빌드로 설치
maturin build # wheel 만들기 → target/wheels/
maturin build --release --strip # 배포용 (심볼 제거 · 🛠️ b10)
maturin sdist # 소스 배포판
maturin publish # PyPI 업로드
maturin upload target/wheels/*.whl
# 옵션
--interpreter python3.11 # 특정 파이썬용으로
--features numpy # cargo feature 켜기 (🛠️ b04)
--target aarch64-apple-darwin # 크로스 (🛠️ b07)
--compatibility manylinux2014 # 리눅스 호환 태그
--zig # zig 로 링크해 크로스 빌드 ★
# uv 와 함께 (요즘 가장 빠른 조합)
uv venv && uv pip install maturin
uv run maturin develop --release
이렇게도 쓸 수 있다 — 설정 파일 읽는 법 🟡 중급
pyproject.toml 전체
[build-system]
requires = ["maturin>=1.7,<2"]
build-backend = "maturin"
[project]
name = "fastmod"
description = "빠른 텍스트 처리"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.9"
dependencies = ["numpy>=1.24"] # 런타임 의존성
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: Implementation :: CPython",
]
dynamic = ["version"] # Cargo.toml 에서 가져옴
[project.optional-dependencies]
test = ["pytest>=8", "numpy"]
[project.scripts]
fastmod-cli = "fastmod.cli:main" # 콘솔 스크립트
[tool.maturin]
python-source = "python" # 파이썬 소스 폴더
module-name = "fastmod._native" # 확장 모듈 경로
features = ["pyo3/extension-module"] # 항상 켤 feature ★
include = [{ path = "python/fastmod/py.typed", format = "wheel" }]
Cargo.toml 에서 중요한 줄
[lib]
name = "_native"
crate-type = ["cdylib"] # ★ 반드시 cdylib (🔗 f02)
# rlib 를 같이 두면 cargo test 가 편합니다:
# crate-type = ["cdylib", "rlib"]
[dependencies]
pyo3 = { version = "0.23", features = ["extension-module", "abi3-py39"] }
# ^^^^^^^^^^^^^^^^ ^^^^^^^^^
# extension-module: 파이썬 심볼을 링크하지 않음 (필수 · y12)
# abi3-py39: 3.9 이상 공용 wheel (y11)
numpy = "0.23" # rust-numpy (y06)
rayon = "1" # 병렬 (y07)
[profile.release]
opt-level = 3
lto = "thin" # 링크 타임 최적화 (🛠️ b06)
codegen-units = 1 # 더 느린 빌드, 더 빠른 코드
panic = "abort" # ❗ PyO3 에서는 쓰지 마세요 — 패닉을 예외로 못 바꿉니다
extension-module feature 를 빼면 리눅스·맥에서는 빌드가 되지만
cargo test 가 링크 에러를 냅니다(파이썬 심볼이 없어서).
해결은 crate-type = ["cdylib", "rlib"] + feature 를 optional 로 두는 것입니다(y12).
실무에선 이렇게 — 개발·CI·배포를 한 흐름으로 🔴 실무
CI 에서 wheel 만들기 ★
# maturin 이 GitHub Actions 워크플로를 생성해 줍니다
maturin generate-ci github > .github/workflows/CI.yml
# 생성되는 것의 요약
# · linux (x86_64 · aarch64 · musl) · windows · macos (x86_64 · arm64)
# · 각 플랫폼에서 wheel 빌드 → 아티팩트 업로드
# · 태그를 붙이면 PyPI 에 자동 배포
#
# ★ C++ 라면 cibuildwheel 설정 + 컨테이너 이미지 + 툴체인 준비가
# 필요한 자리인데, 여기서는 명령 한 줄로 생성됩니다 (🛠️ b08)
.github/workflows/CI.yml (핵심 부분)
- uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
manylinux: auto
sccache: true # 빌드 캐시 ★ (🛠️ b10)
- name: publish
if: startsWith(github.ref, 'refs/tags/')
run: maturin upload --skip-existing dist/*
개발 편의 설정
# ① 빌드 캐시 — 재빌드가 크게 빨라집니다
cargo install sccache
export RUSTC_WRAPPER=sccache
# ② 링커 교체 (리눅스에서 링크가 느릴 때)
# .cargo/config.toml
# [target.x86_64-unknown-linux-gnu]
# linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# ③ 파일이 바뀌면 자동 재빌드
cargo install cargo-watch
cargo watch -s "maturin develop && pytest -x"
# ④ 에디터 — rust-analyzer 가 pyo3 매크로를 이해하게
# .vscode/settings.json
# "rust-analyzer.cargo.features": ["pyo3/extension-module"]
# ⑤ 개발 중 빌드 시간을 줄이는 프로파일 (🛠️ b06)
# [profile.dev]
# opt-level = 1 # 디버그인데 너무 느릴 때 절충
sccache 부터 켜세요.
의존성 재컴파일이 사라져 체감이 가장 크게 바뀝니다.
그다음이 lld 링커, 그다음이 의존성 줄이기입니다(🛠️ b10).
한 단계 더 — maturin 이 실제로 하는 일 🧬 전문가
빌드 과정 뜯어보기
maturin develop -v
# 대략 이런 순서입니다
# ① 현재 파이썬 인터프리터를 찾아 버전·ABI·플랫폼 태그를 알아냄
# ② cargo rustc --crate-type cdylib -- (플랫폼별 링크 인자)
# 리눅스/맥: 파이썬 심볼을 '미해결'로 남깁니다 (extension-module)
# 윈도우: python3xx.lib 에 링크해야 합니다 ★ 플랫폼별 차이
# ③ 결과 .so/.pyd 를 올바른 이름으로 복사
# _native.cpython-312-x86_64-linux-gnu.so
# ④ wheel 로 포장하고 pip install
# 왜 리눅스는 심볼을 안 채우나
# 확장 모듈은 이미 파이썬 프로세스 안에서 로드되므로
# 심볼이 런타임에 해결됩니다. 링크하면 오히려 심볼이 중복돼
# 미묘한 크래시가 납니다 (y12 의 대표 함정)
# 만들어진 wheel 안을 보기
unzip -l target/wheels/fastmod-0.1.0-cp312-*.whl
wheel 이름이 말해 주는 것
fastmod-0.1.0-cp312-cp312-manylinux_2_17_x86_64.whl
│ │ │ │ │
│ │ │ │ └ 아키텍처
│ │ │ └ 플랫폼 태그 (glibc 2.17 이상)
│ │ └ ABI 태그
│ └ 파이썬 태그 (CPython 3.12 전용)
└ 버전
fastmod-0.1.0-cp39-abi3-manylinux_2_17_x86_64.whl
^^^^ ★ abi3 — 3.9 이상 모든 CPython 에서 동작 (y11)
★ 태그가 안 맞으면 pip 가 설치를 거부합니다
"is not a supported wheel on this platform"
→ 그 플랫폼용 wheel 이 없는 것입니다 (sdist 로 폴백되어 컴파일 시도)
# 내 환경의 태그 확인
python -c "from packaging.tags import sys_tags; print(next(iter(sys_tags())))"
# manylinux 검사
pip install auditwheel
auditwheel show dist/*.whl # 어떤 라이브러리에 의존하는지 ★
auditwheel repair 가 대개 필요 없습니다 —
외부 C 라이브러리를 링크하지 않으면 의존성이 libc 정도뿐입니다.
C 라이브러리를 쓴다면(🔗 f08) 그때 auditwheel 로 동봉해야 합니다.
maturin develop --release 한 줄이 C++ 의 CMake 설정 전부를 대신합니다.
Cargo.toml 에서 중요한 것은 crate-type = ["cdylib"] 와 extension-module feature 둘뿐입니다.
CI 는 maturin generate-ci github 로 생성하세요.
빌드가 느려지면 sccache 부터 켜면 됩니다.타입 변환 — 자동이지만 복사가 일어납니다
list 를 Vec 으로 받으면 그냥 됩니다. 다만 그 순간 전부 복사됩니다.
원소 100만 개면 100만 번의 변환이 일어납니다.
언제 복사되고 언제 안 되는지를 아는 것이 이 섹션의 전부입니다.
기본 대응표 ★
| 파이썬 | Rust (인자) | 복사 |
|---|---|---|
int | i64 u32… | — |
float | f64 | — |
bool | bool | — |
str | &str | 없음 ★ |
str | String | 있음 |
bytes | &[u8] | 없음 ★ |
list | Vec<T> | 있음 |
dict | HashMap | 있음 |
set | HashSet | 있음 |
tuple | (A, B) | 있음 |
None | Option<T> | — |
| ndarray | PyReadonlyArray1 | 없음 ★ (y06) |
use pyo3::prelude::*;
use std::collections::HashMap;
#[pyfunction]
fn stats(nums: Vec<f64>) -> (f64, f64) { // list[float] → Vec (복사)
let n = nums.len() as f64;
let mean = nums.iter().sum::<f64>() / n;
let var = nums.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
(mean, var.sqrt()) // → tuple
}
#[pyfunction]
fn count(text: &str) -> HashMap<String, usize> { // str → &str (복사 없음) ★
let mut m = HashMap::new();
for w in text.split_whitespace() {
*m.entry(w.to_string()).or_insert(0) += 1;
}
m // → dict
}
복사를 피하는 형태
// ❌ 리스트를 통째로 복사 — 100만 개면 눈에 띕니다
#[pyfunction]
fn sum_slow(v: Vec<f64>) -> f64 { v.iter().sum() }
// ✅ ① numpy 배열로 받기 — 복사 0 (y06)
use numpy::PyReadonlyArray1;
#[pyfunction]
fn sum_fast(a: PyReadonlyArray1<f64>) -> f64 {
a.as_slice().unwrap().iter().sum()
}
// ✅ ② bytes 로 받기 — 복사 0
#[pyfunction]
fn checksum(data: &[u8]) -> u32 {
data.iter().fold(0u32, |a, b| a.wrapping_add(*b as u32))
}
// ✅ ③ 시퀀스를 순회만 (전체 Vec 을 만들지 않음)
use pyo3::types::PyList;
#[pyfunction]
fn sum_iter(v: &Bound<'_, PyList>) -> PyResult<f64> {
let mut s = 0.0;
for item in v.iter() { s += item.extract::<f64>()?; }
Ok(s)
}
// ★ 다만 요소마다 파이썬 객체를 만지므로 numpy 만큼 빠르지는 않습니다.
// 큰 수치 데이터는 반드시 numpy 로 받으세요 (y06)
list[float] 100만 개를 Vec<f64> 로 받는 데
보통 수십 ms가 듭니다. 계산 자체가 1ms 라면 변환이 전부인 셈입니다.
직접 재 보세요(⚡ e01) — 이 확인 하나가 설계를 바꿉니다.
이렇게도 쓸 수 있다 — 직접 변환을 정의하기 🟡 중급
FromPyObject · IntoPyObject
use pyo3::prelude::*;
// 파이썬 dict → 내 구조체 (derive 로 자동)
#[derive(FromPyObject)]
struct Config {
host: String,
port: u16,
#[pyo3(item("timeout_sec"))] // dict 키 이름이 다를 때
timeout: u64,
}
#[pyfunction]
fn connect(cfg: Config) -> PyResult<()> { }
// 파이썬: connect({"host": "a", "port": 80, "timeout_sec": 30})
// 여러 타입을 받기 (union)
#[derive(FromPyObject)]
enum Input {
Text(String),
Numbers(Vec<f64>),
Path(std::path::PathBuf),
}
#[pyfunction]
fn load(input: Input) -> PyResult<String> {
match input {
Input::Text(s) => Ok(s),
Input::Numbers(v) => Ok(format!("{} 개", v.len())),
Input::Path(p) => std::fs::read_to_string(p).map_err(Into::into),
}
}
// ★ 위에서부터 순서대로 시도합니다. 순서가 중요합니다.
반환값 만들기
use pyo3::types::{PyDict, PyList, PyTuple};
// 자동 변환되는 것들 — 그냥 반환하면 됩니다
#[pyfunction] fn a() -> Vec<i64> { vec![1,2,3] } // → list
#[pyfunction] fn b() -> HashMap<String, i64> { } // → dict
#[pyfunction] fn c() -> (i64, String) { } // → tuple
#[pyfunction] fn d() -> Option<i64> { None } // → None
// 직접 만들기 (세밀한 제어가 필요할 때)
#[pyfunction]
fn make_dict(py: Python<'_>) -> PyResult<Bound<'_, PyDict>> {
let d = PyDict::new(py);
d.set_item("name", "현우")?;
d.set_item("scores", vec![1, 2, 3])?;
Ok(d)
}
// 아무 파이썬 객체나
#[pyfunction]
fn passthrough(obj: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
Ok(obj.clone().unbind())
}
// 내 구조체 → 파이썬 객체 (y05 의 #[pyclass] 를 쓰거나)
#[derive(IntoPyObject)]
struct Point { x: f64, y: f64 } // → dict 로 나갑니다
실무에선 이렇게 — 배치 인터페이스로 설계한다 🔴 실무
호출 횟수를 줄이는 것이 먼저 ★
# ❌ 요소마다 경계를 넘음 — 100만 번 × 0.2μs = 0.2초 (계산 전)
results = [fastmod.score(row) for row in rows]
# ✅ 한 번에 넘김
results = fastmod.score_batch(rows)
# ✅✅ numpy 로 넘김 (복사조차 없음 · y06)
import numpy as np
arr = np.asarray(rows, dtype=np.float64)
results = fastmod.score_array(arr)
// 배치 API 는 이렇게 씁니다
#[pyfunction]
fn score_batch(rows: Vec<Vec<f64>>) -> Vec<f64> {
rows.iter().map(|r| score_one(r)).collect()
}
// 병렬까지 (y07)
use rayon::prelude::*;
#[pyfunction]
fn score_batch_par(py: Python<'_>, rows: Vec<Vec<f64>>) -> Vec<f64> {
py.allow_threads(|| rows.par_iter().map(|r| score_one(r)).collect())
}
문자열을 다룰 때의 주의
// str → &str 은 복사가 없지만, 조건이 있습니다
#[pyfunction]
fn f(s: &str) { }
// ★ CPython 이 UTF-8 캐시를 갖고 있으면 그대로 빌려 옵니다.
// 없으면 내부적으로 UTF-8 로 변환합니다 (첫 호출에 한 번)
// 소유가 필요하면 명시적으로
#[pyfunction]
fn store(s: String) { } // 복사가 일어납니다
// 대량 문자열은 bytes 로 주고받는 것이 빠릅니다
#[pyfunction]
fn parse_lines(data: &[u8]) -> PyResult<Vec<String>> {
let text = std::str::from_utf8(data)?; // 검증 1회
Ok(text.lines().map(String::from).collect())
}
// 파이썬: fastmod.parse_lines(open("f.txt","rb").read())
// ❗ 반환 문자열이 많으면 그것도 비용입니다
// → 인덱스·오프셋만 돌려주고 파이썬이 슬라이싱하게 하는 설계도 있습니다
#[pyfunction]
fn find_spans(text: &str, pat: &str) -> Vec<(usize, usize)> { }
text[s:e] 로 잘라 쓰면 됩니다.
한 단계 더 — 변환 비용의 정체 🧬 전문가
list → Vec 이 하는 일
파이썬 list[float] 100만 개의 실제 모습
· PyListObject 하나 (포인터 배열 8MB)
· PyFloatObject 100만 개 (각 24바이트 · 힙에 흩어져 있음) ★
Vec<f64> 로 변환하면
① 리스트 길이를 읽어 Vec::with_capacity
② 포인터를 하나씩 따라가며 (캐시 미스 ★)
③ PyFloat_AsDouble 로 f64 추출
④ Vec 에 push
→ 100만 번의 포인터 추적 + 타입 검사입니다.
numpy 배열이라면 ①만 하고 끝납니다 (연속된 8MB 를 그대로 봄 · y06)
측정 방법 (직접 재 보세요)
python -m timeit -s "import fastmod, random;
d=[random.random() for _ in range(1_000_000)]" "fastmod.sum_slow(d)"
python -m timeit -s "import fastmod, numpy as np;
a=np.random.random(1_000_000)" "fastmod.sum_fast(a)"
extract 의 동작과 오류
// extract 는 '파이썬 객체 → Rust 타입' 시도입니다
let n: i64 = obj.extract()?; // 실패하면 TypeError
// 관대한 변환에 주의하세요
// bool 은 int 로도 추출됩니다 (파이썬에서 True == 1)
// float 자리에 int 를 주면 통과합니다
// → 엄격하게 검사하려면 타입을 먼저 확인하세요
use pyo3::types::PyFloat;
if !obj.is_instance_of::<PyFloat>() {
return Err(PyTypeError::new_err("float 이어야 합니다"));
}
// 큰 정수 오버플로
let n: u8 = obj.extract()?; // 300 을 주면 OverflowError ★
// 성능: extract 는 매번 타입 검사를 합니다
// 루프 안에서 수백만 번 부르면 그것만으로 비용이 됩니다
// → 배치 · numpy 로 우회하세요
// GIL 없이 보관하려면 소유 타입으로 미리 변환
let owned: Vec<f64> = obj.extract()?; // GIL 이 필요 없는 순수 Rust 값
py.allow_threads(|| heavy(&owned)); // ★ 이제 GIL 을 놓을 수 있습니다 (y07)
list·dict 는 전부 복사됩니다.
&str·&[u8]·numpy 배열은 복사가 없습니다.
큰 수치 데이터는 반드시 numpy 로(y06), 그 외에는 배치 API 로 호출 횟수를 줄이세요.
#[derive(FromPyObject)] 로 dict → 구조체 변환을 자동화할 수 있습니다.Rust 구조체를 파이썬 클래스로
함수만으로 부족할 때가 있습니다 — 상태를 들고 있어야 하거나,
초기화 비용이 큰 자원을 재사용해야 할 때입니다.
#[pyclass] 하나면 Rust 구조체가 파이썬 클래스처럼 동작합니다.
기본 형태
use pyo3::prelude::*;
#[pyclass]
struct Counter {
#[pyo3(get)] // 읽기 전용 속성
total: u64,
#[pyo3(get, set)] // 읽고 쓰기
step: u64,
history: Vec<u64>, // 표시 안 함 (내부용)
}
#[pymethods]
impl Counter {
#[new] // __init__
#[pyo3(signature = (step = 1))]
fn new(step: u64) -> Self {
Counter { total: 0, step, history: Vec::new() }
}
fn increment(&mut self) -> u64 {
self.total += self.step;
self.history.push(self.total);
self.total
}
fn reset(&mut self) { self.total = 0; self.history.clear(); }
#[getter] // 계산되는 속성
fn count(&self) -> usize { self.history.len() }
fn __repr__(&self) -> String { format!("Counter(total={})", self.total) }
fn __len__(&self) -> usize { self.history.len() }
}
// 모듈에 등록
#[pymodule]
fn fastmod(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<Counter>()?;
Ok(())
}
파이썬에서 쓰기
from fastmod import Counter
c = Counter(step=5)
c.increment() # 5
c.increment() # 10
print(c.total) # 10 (get)
c.step = 10 # (set)
print(c.count) # 2 (getter)
print(len(c)) # 2 (__len__)
print(c) # Counter(total=10) (__repr__)
# c.total = 99 # ❌ AttributeError — get 만 있으므로
// 자주 쓰는 던더 메서드
#[pymethods]
impl Counter {
fn __str__(&self) -> String { }
fn __eq__(&self, other: &Self) -> bool { self.total == other.total }
fn __hash__(&self) -> u64 { self.total }
fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { slf }
fn __next__(&mut self) -> Option<u64> { }
fn __getitem__(&self, i: usize) -> PyResult<u64> { }
fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { slf } // with 문
fn __exit__(&mut self, _t: &Bound<'_, PyAny>,
_v: &Bound<'_, PyAny>, _tb: &Bound<'_, PyAny>) { self.close(); }
}
// 클래스 메서드 · 정적 메서드
#[pymethods]
impl Counter {
#[staticmethod]
fn version() -> &'static str { env!("CARGO_PKG_VERSION") }
#[classmethod]
fn from_list(_cls: &Bound<'_, PyType>, v: Vec<u64>) -> Self { }
}
이렇게도 쓸 수 있다 — 비싼 자원을 재사용하기 🟡 중급
클래스가 정말 유용한 자리 ★
use regex::Regex;
#[pyclass]
struct Matcher { re: Regex } // ★ 컴파일 비용이 큰 것을 한 번만
#[pymethods]
impl Matcher {
#[new]
fn new(pattern: &str) -> PyResult<Self> {
let re = Regex::new(pattern)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
Ok(Matcher { re })
}
fn find_all(&self, text: &str) -> Vec<String> {
self.re.find_iter(text).map(|m| m.as_str().to_string()).collect()
}
// 배치 처리까지 붙이면 경계 통과가 1번으로 줄어듭니다 (y01 원칙 ①)
fn find_all_batch(&self, texts: Vec<String>) -> Vec<Vec<String>> {
texts.iter().map(|t| self.find_all(t)).collect()
}
}
m = fastmod.Matcher(r"\d+") # 컴파일 1회
for line in huge_file: # 재사용
nums = m.find_all(line)
__exit__).
그 외에는 함수가 더 단순하고 빠릅니다.
가시성과 불변식 지키기
// ❗ #[pyo3(set)] 을 열면 파이썬에서 아무 값이나 넣습니다
#[pyclass]
struct Window {
#[pyo3(get)]
size: usize, // ★ get 만 — 직접 못 바꿈
}
#[pymethods]
impl Window {
fn resize(&mut self, n: usize) -> PyResult<()> {
if n == 0 || n > 4096 {
return Err(pyo3::exceptions::PyValueError::new_err(
"size 는 1~4096 이어야 합니다"));
}
self.size = n; // 검증을 통과해야 바뀝니다 (r07)
Ok(())
}
}
// 서브클래싱 허용 여부
#[pyclass(subclass)] // 파이썬에서 상속 가능
#[pyclass(frozen)] // 불변 — &self 메서드만 (스레드 안전 ★)
#[pyclass(eq, hash)] // PartialEq/Hash 에서 __eq__/__hash__ 생성
#[pyclass(module = "fastmod")] // repr 과 pickle 에 쓰이는 모듈 이름
#[pyclass(name = "Regex")] // 파이썬에서 보일 이름
실무에선 이렇게 — 수명과 스레드 안전 🔴 실무
빌림 규칙이 런타임으로 옮겨 옵니다 ★
// #[pyclass] 는 내부적으로 RefCell 같은 검사를 씁니다 (🔐 o10)
// → 빌림 충돌이 '컴파일 에러'가 아니라 '파이썬 예외'가 됩니다
#[pymethods]
impl Node {
fn add_child(&mut self, other: PyRef<'_, Node>) { }
}
// 파이썬에서 n.add_child(n) 을 하면
// RuntimeError: Already borrowed
// ★ 자기 자신을 넘기는 경우가 실제로 자주 생깁니다
// 방어 방법
// · 가능하면 #[pyclass(frozen)] 으로 두고 &self 만 쓰기
// · 내부 상태를 Mutex/RwLock 으로 감싸기
use std::sync::Mutex;
#[pyclass(frozen)]
struct Shared { inner: Mutex<State> }
#[pymethods]
impl Shared {
fn update(&self, v: u64) { // &self 라 충돌이 없습니다
self.inner.lock().unwrap().value = v;
}
}
// PyRef / PyRefMut — 다른 인스턴스를 빌릴 때
fn merge(&mut self, other: PyRef<'_, Self>) { self.total += other.total; }
복사 · 피클 · 컨텍스트 매니저
use pyo3::types::PyBytes;
#[pymethods]
impl Model {
// copy.deepcopy 지원
fn __copy__(&self) -> Self { self.clone() }
fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> Self { self.clone() }
// pickle 지원 — 멀티프로세싱에 필요합니다 ★
fn __getstate__(&self, py: Python<'_>) -> PyResult<Py<PyBytes>> {
let bytes = bincode::serialize(&self.data).unwrap();
Ok(PyBytes::new(py, &bytes).unbind())
}
fn __setstate__(&mut self, state: &Bound<'_, PyBytes>) -> PyResult<()> {
self.data = bincode::deserialize(state.as_bytes()).unwrap();
Ok(())
}
#[staticmethod]
fn __getnewargs__() -> (u32,) { (0,) }
}
// ★ 피클을 지원하지 않으면 multiprocessing 으로 넘길 때
// "cannot pickle 'Model' object" 가 납니다.
// 확장을 쓰는 코드가 병렬 처리를 하면 반드시 만나는 문제입니다 (y12)
#[pyclass] 인스턴스는 기본적으로
파이썬 스레드가 GIL 로 보호합니다. 하지만 allow_threads(y07) 안에서
같은 객체를 만지면 보호가 사라집니다. frozen + Mutex 조합이 안전합니다.
한 단계 더 — #[pyclass] 가 만들어 내는 것 🧬 전문가
메모리 배치
파이썬 객체 헤더 + PyO3 셀 + 내 구조체가 한 덩어리로 힙에 놓입니다
┌──────────────┬───────────┬──────────────┐
│ PyObject_HEAD│ borrow │ Counter │
│ (refcnt·type)│ flag │ (내 필드들) │
└──────────────┴───────────┴──────────────┘
★ 그래서 Rust 구조체를 '파이썬 객체 안에' 두는 것이고,
Rust 쪽에서 &mut 를 얻으려면 borrow flag 를 확인해야 합니다.
# 확인
python -c "
import fastmod, sys
c = fastmod.Counter()
print(sys.getsizeof(c), sys.getrefcount(c))"
# 매크로가 생성한 코드 보기
cargo install cargo-expand
cargo expand --lib > expanded.rs # ★ #[pyclass] 가 만든 impl 전부
성능 비교 — 함수 vs 클래스
// 클래스 메서드 호출의 추가 비용
// ① 파이썬 메서드 조회 (타입 dict)
// ② self 를 Rust 참조로 변환 (borrow flag 확인)
// ③ 인자 변환
// → 함수 호출보다 조금 더 비쌉니다 (보통 수십 ns 차이)
// ★ 그래서 판단 기준은 성능이 아니라 '상태가 필요한가' 입니다.
// 상태 재사용의 이득(정규식 컴파일 등)이 호출 비용보다 훨씬 큽니다.
// 인스턴스를 Rust 쪽에서 보관하기
#[pyfunction]
fn register(obj: Py<Counter>) -> PyResult<()> {
REGISTRY.lock().unwrap().push(obj); // GIL 없이 보관 가능 (y02)
Ok(())
}
// 순환 참조 주의 — 파이썬 GC 와 연동하려면
#[pyclass]
struct Holder { obj: Option<Py<PyAny>> }
#[pymethods]
impl Holder {
fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
if let Some(o) = &self.obj { visit.call(o)?; }
Ok(())
}
fn __clear__(&mut self) { self.obj = None; }
}
// ★ 파이썬 객체를 필드로 들고 있으면 이걸 구현해야 GC 가 순환을 끊습니다
#[pyclass] + #[pymethods] 로 Rust 구조체가 파이썬 클래스가 됩니다.
초기화가 비싸거나 상태가 필요할 때만 쓰세요 — 그 외에는 함수가 낫습니다.
빌림 충돌이 런타임 예외가 되므로 frozen + Mutex 조합이 안전하고,
멀티프로세싱을 쓴다면 __getstate__ 를 잊지 마세요.numpy 제로카피 — 성능 차이가 가장 큰 지점
여기가 이 탭에서 가장 중요한 섹션입니다. numpy 배열은 연속된 메모리 덩어리라 Rust 슬라이스로 그대로 볼 수 있습니다. 복사가 0 이므로, 데이터가 클수록 이득이 커집니다.
PyReadwriteArray 를 씁니다 — 파이썬 쪽 배열이 그 자리에서 바뀝니다.읽기 — PyReadonlyArray
# Cargo.toml
[dependencies]
pyo3 = { version = "0.23", features = ["extension-module"] }
numpy = "0.23"
use numpy::{PyReadonlyArray1, PyReadonlyArray2};
use pyo3::prelude::*;
#[pyfunction]
fn mean(a: PyReadonlyArray1<f64>) -> f64 {
let s = a.as_slice().unwrap(); // ★ &[f64] — 복사 없음
s.iter().sum::<f64>() / s.len() as f64
}
// 2차원
#[pyfunction]
fn row_sums(a: PyReadonlyArray2<f64>) -> Vec<f64> {
let arr = a.as_array(); // ndarray::ArrayView2
arr.rows().into_iter().map(|r| r.sum()).collect()
}
// as_slice() 가 실패하는 경우 ★
// · 배열이 연속(C-contiguous)이 아닐 때 (a[::2] 같은 뷰)
// → 파이썬 쪽에서 np.ascontiguousarray() 를 부르게 하거나
// → as_array() 로 ndarray 뷰를 쓰세요 (스트라이드를 처리합니다)
import numpy as np, fastmod
a = np.random.random(10_000_000)
fastmod.mean(a) # 복사 없음 — 배열 크기와 무관하게 즉시 시작
쓰기 · 새 배열 만들기
use numpy::{PyArray1, PyArrayMethods, PyReadwriteArray1, IntoPyArray};
// 제자리 수정 (in-place) — 할당조차 없습니다 ★
#[pyfunction]
fn scale_inplace(mut a: PyReadwriteArray1<f64>, k: f64) {
let s = a.as_slice_mut().unwrap();
for x in s.iter_mut() { *x *= k; }
}
// 새 배열 반환
#[pyfunction]
fn doubled<'py>(py: Python<'py>, a: PyReadonlyArray1<f64>)
-> Bound<'py, PyArray1<f64>>
{
let v: Vec<f64> = a.as_slice().unwrap().iter().map(|x| x * 2.0).collect();
v.into_pyarray(py) // Vec 을 numpy 배열로 (소유권 이전)
}
// 출력 버퍼를 받는 형태 — 가장 빠릅니다 (🔐 o04 의 패턴)
#[pyfunction]
fn transform(a: PyReadonlyArray1<f64>, mut out: PyReadwriteArray1<f64>)
-> PyResult<()>
{
let src = a.as_slice()?;
let dst = out.as_slice_mut()?;
if src.len() != dst.len() {
return Err(pyo3::exceptions::PyValueError::new_err("길이가 다릅니다"));
}
for (d, s) in dst.iter_mut().zip(src) { *d = s.sqrt(); }
Ok(())
}
이렇게도 쓸 수 있다 — ndarray 로 다차원 다루기 🟡 중급
이미지 · 행렬 처리
use numpy::{PyReadonlyArray3, PyArray2, IntoPyArray};
use ndarray::{Array2, Axis, s};
// (h, w, 3) 이미지를 흑백으로
#[pyfunction]
fn to_gray<'py>(py: Python<'py>, img: PyReadonlyArray3<u8>)
-> Bound<'py, PyArray2<u8>>
{
let a = img.as_array(); // ArrayView3<u8>
let (h, w, _) = a.dim();
let mut out = Array2::<u8>::zeros((h, w));
for y in 0..h {
for x in 0..w {
let r = a[[y, x, 0]] as u32;
let g = a[[y, x, 1]] as u32;
let b = a[[y, x, 2]] as u32;
out[[y, x]] = ((r * 299 + g * 587 + b * 114) / 1000) as u8;
}
}
out.into_pyarray(py)
}
// ndarray 의 유용한 것들
// a.slice(s![.., 0..3]) // 슬라이싱
// a.sum_axis(Axis(0)) // 축 방향 합
// a.mean_axis(Axis(1))
// a.t() // 전치 (뷰 · 복사 없음)
// a.iter() // 요소 순회 (스트라이드 고려)
// a.as_slice() // 연속일 때만 Some
dtype 과 연속성 확인 ★
// 타입이 다르면 자동 변환되지 않습니다 (TypeError 가 납니다)
#[pyfunction]
fn f(a: PyReadonlyArray1<f64>) { }
// 파이썬: f(np.array([1,2,3])) → ❌ int64 배열
// f(np.array([1,2,3], dtype=np.float64)) → ✅
// ✅ 여러 dtype 을 받으려면 파이썬 래퍼에서 변환하세요
// python/fastmod/__init__.py
// def f(a):
// a = np.ascontiguousarray(a, dtype=np.float64) # ★ 여기서 한 번에
// return _native.f(a)
// Rust 쪽에서 확인하기
#[pyfunction]
fn g(a: &Bound<'_, PyAny>) -> PyResult<f64> {
let arr: PyReadonlyArray1<f64> = a.extract()
.map_err(|_| pyo3::exceptions::PyTypeError::new_err(
"float64 1차원 배열이어야 합니다"))?;
Ok(arr.as_slice()?.iter().sum())
}
// 연속성
// np.ascontiguousarray(a) C 순서로 (필요하면 복사)
// a.flags['C_CONTIGUOUS'] 확인
// ★ as_slice() 가 None 이면 대개 이것 때문입니다
실무에선 이렇게 — numpy 를 이길 수 있는가 🔴 실무
정직한 기대치 ★
★ Rust 가 numpy 를 못 이기는 경우
· a + b, a * b 같은 원소별 연산 → numpy 도 이미 C · SIMD
· 행렬 곱 (a @ b) → BLAS 가 수십 년 최적화됨
· np.sum, np.mean → 이미 벡터화됨
→ 이런 건 Rust 로 옮겨도 같거나 느립니다. 옮기지 마세요.
★ Rust 가 크게 이기는 경우
· 요소마다 분기가 다른 루프
for x in a: if cond(x): ... else: ... ← 파이썬 루프면 100배
· 임시 배열이 여러 개 생기는 식
(a*b + c*d) / (e+f) → numpy 는 중간 배열을 5개 만듭니다
Rust 는 한 번의 순회로 끝냅니다 (메모리 대역폭 이득) ★
· 문자열·바이트 파싱 (numpy 가 잘 못하는 영역)
· 조기 종료가 있는 탐색
· 상태를 이어 가는 순차 처리 (누적·필터·상태 머신)
★ 실측 방법
같은 데이터로 numpy 버전과 Rust 버전을 나란히 재세요 (⚡ e01)
"몇 배"는 데이터 크기·CPU·메모리 대역폭에 따라 크게 달라집니다.
퓨전 — 임시 배열을 없애기
# numpy — 중간 배열이 여러 개 생깁니다
result = np.sqrt(a**2 + b**2) / (c + 1e-9)
# ↑ ↑ ↑ ↑ ↑
# 임시4 임시1 임시2 임시3 임시5 (각각 8MB 씩이면 40MB 순회)
// Rust — 한 번의 순회, 임시 배열 0
#[pyfunction]
fn fused<'py>(py: Python<'py>,
a: PyReadonlyArray1<f64>,
b: PyReadonlyArray1<f64>,
c: PyReadonlyArray1<f64>) -> PyResult<Bound<'py, PyArray1<f64>>>
{
let (a, b, c) = (a.as_slice()?, b.as_slice()?, c.as_slice()?);
let out: Vec<f64> = (0..a.len())
.map(|i| (a[i]*a[i] + b[i]*b[i]).sqrt() / (c[i] + 1e-9))
.collect();
Ok(out.into_pyarray(py))
}
// ★ 메모리 대역폭이 병목인 큰 배열에서 차이가 큽니다.
// numba 의 @njit 도 같은 원리로 빨라집니다 — 비교해 보고 고르세요 (y01)
한 단계 더 — 버퍼 프로토콜과 안전성 🧬 전문가
제로카피가 성립하는 조건
numpy 배열이 Rust 슬라이스가 되는 조건
① dtype 이 정확히 일치 (f64 ↔ np.float64)
② 연속 메모리 (C-contiguous) — as_slice() 를 쓸 때
③ 정렬(alignment)이 맞음
④ GIL 을 잡고 있는 동안만 유효 ★
★ ④ 가 중요합니다
파이썬이 그 배열을 GC 하면 슬라이스는 해제된 메모리를 가리킵니다.
PyReadonlyArray 는 내부적으로 참조를 유지해 이를 막습니다.
→ 그래서 슬라이스를 구조체에 저장해 오래 들고 있으면 안 됩니다.
allow_threads(y07) 안에서 쓰려면
· PyReadonlyArray 를 밖에서 만들고
· as_slice() 로 얻은 &[f64] 만 클로저에 넘깁니다
· 파이썬 쪽이 그 사이에 배열을 바꾸지 않는다는 전제가 필요합니다 ★
경쟁 조건을 만들지 않기
// ❗ 위험한 패턴 — GIL 을 놓은 채 파이썬이 만질 수 있는 메모리를 씀
#[pyfunction]
fn danger(py: Python<'_>, mut a: PyReadwriteArray1<f64>) {
let s = a.as_slice_mut().unwrap();
py.allow_threads(|| { // ★ 다른 파이썬 스레드가 a 를 읽을 수 있음
for x in s.iter_mut() { *x *= 2.0; }
});
}
// PyReadwriteArray 는 배열을 '쓰기 잠금'하므로 numpy 레벨에서는 보호되지만,
// 같은 메모리를 보는 다른 뷰(a[::1] 등)까지 막지는 못합니다.
// ✅ 안전한 관례
// · 입력은 readonly, 출력은 새 배열 또는 전용 out 배열
// · 문서에 "처리 중 입력 배열을 수정하지 마세요" 를 명시
// · 확실히 하려면 Vec 으로 복사한 뒤 allow_threads (복사 비용 vs 안전)
// 검증
// · Miri 는 FFI 경계를 못 봅니다 (🔬 d07)
// · 대신 파이썬 쪽에서 스레드 스트레스 테스트를 쓰세요
// · ASAN 빌드: RUSTFLAGS="-Zsanitizer=address" (nightly)
PyReadonlyArray/PyReadwriteArray 로 복사 없이 받습니다.
as_slice() 가 실패하면 연속 배열이 아닌 것이니 파이썬 래퍼에서 ascontiguousarray 를 부르세요.
원소별 연산은 numpy 를 못 이깁니다 — Rust 는 분기가 있는 루프와 임시 배열이 많은 식에서 이깁니다.GIL 해제 · rayon 병렬 — 파이썬의 한계를 넘는 자리
파이썬 스레드는 GIL 때문에 CPU 작업을 동시에 못 합니다. 그런데 Rust 코드는 GIL 을 놓고 돌 수 있습니다 — 그 순간 다른 파이썬 스레드가 움직이고, rayon 으로 전 코어를 쓸 수 있습니다. 파이썬으로는 물리적으로 불가능한 지점입니다.
rayon 은 iter() 를 par_iter() 로 바꾸는 것이 전부입니다 — 여섯 글자로 병렬화됩니다.allow_threads — GIL 놓기
use pyo3::prelude::*;
#[pyfunction]
fn heavy(py: Python<'_>, data: Vec<f64>) -> f64 {
py.allow_threads(|| { // ★ 이 안에서는 GIL 을 놓습니다
data.iter().map(|x| expensive(*x)).sum()
})
// 클로저 안에서는 파이썬 객체를 만질 수 없습니다 (컴파일러가 막습니다)
}
// ★ 규칙: 클로저에 넘기는 것은 '순수 Rust 값' 이어야 합니다
// Vec<f64> ✅ &[u8] ✅ Bound<PyAny> ❌ (컴파일 에러)
// 언제 놓아야 하나
// · 계산이 밀리초 이상 걸릴 때 ★
// · I/O 를 할 때 (파일·네트워크)
// · 놓고 잡는 비용이 있으니 마이크로초 단위 작업엔 오히려 손해
# 효과 확인 — 파이썬 스레드 4개로 동시에 부르기
from concurrent.futures import ThreadPoolExecutor
import fastmod, time
t = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as ex:
list(ex.map(fastmod.heavy, [data]*4))
print(time.perf_counter() - t)
# GIL 을 놓으면 ≈ 1배 시간, 안 놓으면 ≈ 4배 시간 ★
rayon — 한 줄 병렬화 ★
[dependencies]
rayon = "1"
use rayon::prelude::*;
#[pyfunction]
fn score_all(py: Python<'_>, rows: Vec<Vec<f64>>) -> Vec<f64> {
py.allow_threads(|| {
rows.par_iter() // ★ iter() → par_iter() 여섯 글자
.map(|r| score_one(r))
.collect()
})
}
// numpy 배열을 병렬로 (y06 과 조합)
use numpy::{PyReadonlyArray1, PyArray1, IntoPyArray};
#[pyfunction]
fn transform<'py>(py: Python<'py>, a: PyReadonlyArray1<f64>)
-> PyResult<Bound<'py, PyArray1<f64>>>
{
let src = a.as_slice()?;
let out: Vec<f64> = py.allow_threads(|| {
src.par_iter().map(|x| heavy_fn(*x)).collect()
});
Ok(out.into_pyarray(py))
}
// 제자리 병렬 수정
data.par_chunks_mut(1024).for_each(|c| process(c));
// 스레드 수 제한 (⚡ e05)
rayon::ThreadPoolBuilder::new().num_threads(4).build_global().unwrap();
이렇게도 쓸 수 있다 — GIL 이 실제로 무엇을 막고 있었나 🟡 중급
파이썬의 선택지와 비교
| 방법 | 병렬 | 비용 |
|---|---|---|
| threading | ❌ GIL | — |
| multiprocessing | ✅ | 피클·메모리 복제 |
| numpy 내부 | 일부 ✅ | BLAS 만 |
| Rust + rayon | ✅ | 거의 없음 |
| free-threaded 3.13+ | ✅ | 실험적 |
★ multiprocessing 과 비교했을 때의 이점
· 데이터를 피클해서 복사하지 않습니다 (큰 배열에서 결정적)
· 프로세스 시작 비용이 없습니다 (윈도우에서 특히 큼)
· 메모리를 공유합니다 (8GB 배열을 4개 프로세스가 복제하지 않음)
· 예외·로깅이 그대로 전달됩니다
★ 주의할 점
· rayon 의 스레드 풀은 프로세스 전역입니다
· 이미 멀티프로세싱을 쓰는 코드에서 부르면 과다 구독이 됩니다
(4 프로세스 × 8 스레드 = 32 스레드) → 스레드 수를 제한하세요 ★
· 파이썬 콜백을 rayon 안에서 부르면 GIL 을 다시 잡아야 해
병렬 이득이 사라집니다 (y09)
병렬화가 이득인지 확인하기
// ★ 작은 데이터에서는 오히려 느립니다 (스레드 분배 비용)
#[pyfunction]
fn adaptive(py: Python<'_>, data: Vec<f64>) -> f64 {
py.allow_threads(|| {
if data.len() < 10_000 {
data.iter().map(|x| f(*x)).sum() // 순차
} else {
data.par_iter().map(|x| f(*x)).sum() // 병렬
}
})
}
// 임계값은 반드시 측정해서 정하세요 (⚡ e01 · e05)
// criterion 으로 크기별 벤치마크를 돌리면 교차점이 보입니다
// 청크 크기 조절
data.par_chunks(4096).map(|c| process(c)).sum::<f64>();
// with_min_len 으로 최소 작업 단위 지정
data.par_iter().with_min_len(1000).map(f).sum::<f64>();
// 확인: 실제로 코어를 쓰고 있나
// htop / 작업 관리자에서 CPU 사용률
// time 명령의 user 시간이 real 보다 크면 병렬로 돈 것 ★
실무에선 이렇게 — 스레드 안전을 설계로 보장한다 🔴 실무
컴파일러가 막아 주는 것 ★
// allow_threads 의 클로저는 Send + 'static 을 요구합니다 (🔐 o03)
// → 파이썬 객체(Bound<PyAny>)는 Send 가 아니므로 컴파일 에러가 납니다
#[pyfunction]
fn wrong(py: Python<'_>, obj: &Bound<'_, PyAny>) {
// py.allow_threads(|| { obj.call0(); });
// error: `Bound<'_, PyAny>` cannot be sent between threads safely ★
}
// ✅ GIL 밖에서 쓸 값만 미리 뽑습니다
#[pyfunction]
fn right(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult<f64> {
let data: Vec<f64> = obj.extract()?; // GIL 잡은 채 변환
Ok(py.allow_threads(|| heavy(&data))) // 순수 Rust 값만 넘김
}
// rayon 안에서 데이터 경쟁도 컴파일러가 막습니다 (⚡ e06)
// par_iter().map() 안에서 공유 가변 상태를 만지면 빌드가 안 됩니다
// → 결과를 collect 하거나 원자 타입·Mutex 를 쓰게 됩니다
// ★ 이것이 C++ 확장과 갈리는 실무 지점입니다.
// C++ 에서 GIL 을 놓고 파이썬 객체를 만지면 '가끔' 크래시합니다.
// Rust 에서는 그 코드가 컴파일되지 않습니다.
진행률과 취소 지원
use std::sync::atomic::{AtomicUsize, AtomicBool, Ordering};
use std::sync::Arc;
#[pyclass]
struct Job {
done: Arc<AtomicUsize>,
cancel: Arc<AtomicBool>,
}
#[pymethods]
impl Job {
fn run(&self, py: Python<'_>, data: Vec<f64>) -> PyResult<Vec<f64>> {
let (done, cancel) = (self.done.clone(), self.cancel.clone());
let out = py.allow_threads(move || {
data.par_iter().map(|x| {
done.fetch_add(1, Ordering::Relaxed);
f(*x)
}).collect::<Vec<_>>()
});
Ok(out)
}
#[getter] fn progress(&self) -> usize { self.done.load(Ordering::Relaxed) }
fn cancel(&self) { self.cancel.store(true, Ordering::Relaxed); }
}
// ❗ Ctrl+C 처리 — GIL 을 놓은 동안 파이썬은 시그널을 처리하지 못합니다
// 긴 작업이라면 주기적으로 체크하세요
py.check_signals()?; // KeyboardInterrupt 를 전파 ★
check_signals() 를 부르거나,
취소 플래그를 제공하세요.
한 단계 더 — GIL 의 안쪽과 free-threaded 파이썬 🧬 전문가
allow_threads 가 하는 일
py.allow_threads(f) 는 대략 이렇습니다
PyThreadState *_save = PyEval_SaveThread(); // GIL 해제 + 상태 저장
f(); // Rust 코드 실행
PyEval_RestoreThread(_save); // GIL 재획득
★ 비용
해제·재획득이 각각 수백 나노초 수준입니다.
경합이 심하면 재획득에 훨씬 오래 걸릴 수 있습니다.
→ 마이크로초 단위 작업을 감쌀 때는 순이익이 마이너스입니다.
★ 클로저가 panic 하면?
PyO3 가 잡아서 GIL 을 복구한 뒤 파이썬 예외로 바꿉니다 (y08).
다만 panic = "abort" 프로파일이면 프로세스가 죽습니다 (y03 주의).
★ 재진입 금지
allow_threads 안에서 파이썬 API 를 부르면 UB 입니다.
PyO3 의 타입 시스템이 이를 막지만, unsafe 로 우회하면 뚫립니다.
free-threaded CPython (3.13+)
파이썬 3.13 부터 GIL 없는 빌드가 실험적으로 제공됩니다 (PEP 703).
python3.13t ← 't' 가 free-threaded 빌드
★ 확장 모듈 입장에서 달라지는 것
· 여러 스레드가 '동시에' 내 함수에 들어올 수 있습니다
· #[pyclass] 인스턴스를 GIL 이 더는 보호하지 않습니다 ★
· 모듈이 지원한다고 선언해야 합니다
# PyO3 에서 (버전에 따라 방법이 다릅니다 — 문서 확인)
#[pymodule(gil_used = false)]
fn fastmod(m: &Bound<'_, PyModule>) -> PyResult<()> { }
★ 대비하는 법 (지금부터 해 두면 좋은 것)
· #[pyclass(frozen)] + Mutex/Atomic 으로 상태를 보호 (y05)
· 전역 가변 상태를 만들지 않기 (r02 의 OnceLock 패턴)
· 스레드 스트레스 테스트를 CI 에 두기
★ 아이러니
GIL 이 사라지면 "Rust 로 GIL 을 놓는" 이점 하나는 줄어듭니다.
하지만 '데이터 경쟁을 컴파일러가 막는' 이점은 오히려 더 중요해집니다 (⚡ e06).
py.allow_threads(|| …) 로 GIL 을 놓으면
다른 파이썬 스레드가 움직이고, 안에서 par_iter() 로 전 코어를 씁니다.
클로저에는 순수 Rust 값만 넘길 수 있고, 그걸 컴파일러가 강제합니다.
작은 데이터에는 오히려 손해이니 임계값은 측정해서 정하고,
긴 작업에는 check_signals() 로 Ctrl+C 를 살려 두세요.에러를 파이썬 예외로
Rust 의 Err 가 파이썬에서 ValueError 로 잡히게 연결합니다.
기본 동작만 알면 대부분 끝나지만, 에러 종류를 경계에서 잃지 않는 것이 중요합니다 —
전부 RuntimeError 로 던지면 쓰는 사람이 대응할 수 없습니다.
가장 간단한 형태
use pyo3::prelude::*;
use pyo3::exceptions::{PyValueError, PyIndexError, PyTypeError, PyIOError};
#[pyfunction]
fn divide(a: f64, b: f64) -> PyResult<f64> {
if b == 0.0 {
return Err(PyValueError::new_err("0 으로 나눌 수 없습니다"));
}
Ok(a / b)
}
// 표준 에러는 자동으로 변환됩니다 ★
#[pyfunction]
fn read(path: &str) -> PyResult<String> {
Ok(std::fs::read_to_string(path)?) // io::Error → OSError 자동
}
#[pyfunction]
fn parse(s: &str) -> PyResult<i64> {
Ok(s.parse()?) // ParseIntError → ValueError 자동
}
// 자동 변환되는 것들
// std::io::Error → OSError (세부는 ErrorKind 에 따라)
// ParseIntError → ValueError
// Utf8Error → UnicodeDecodeError
// TryFromIntError → OverflowError
// std::fmt::Error → RuntimeError
내 에러 타입을 연결하기 ★
use thiserror::Error; // (🔐 o09)
#[derive(Error, Debug)]
pub enum CoreError {
#[error("입력이 비었습니다")] Empty,
#[error("길이 초과: {got} > {max}")] TooLong { got: usize, max: usize },
#[error("잘못된 형식: {0}")] BadFormat(String),
#[error("파일 오류")] Io(#[from] std::io::Error),
}
// ★ From 을 구현하면 ? 하나로 변환됩니다
impl From<CoreError> for PyErr {
fn from(e: CoreError) -> PyErr {
match e {
CoreError::Empty => PyValueError::new_err(e.to_string()),
CoreError::TooLong { .. } => PyOverflowError::new_err(e.to_string()),
CoreError::BadFormat(_) => PyValueError::new_err(e.to_string()),
CoreError::Io(io) => PyIOError::new_err(io.to_string()),
}
}
}
#[pyfunction]
fn process(text: &str) -> PyResult<String> {
Ok(core_process(text)?) // CoreError → PyErr 자동
}
try:
fastmod.process("")
except ValueError as e: # ★ 종류별로 잡힙니다
print(e) # 입력이 비었습니다
이렇게도 쓸 수 있다 — 커스텀 예외 클래스 만들기 🟡 중급
내 예외를 모듈에 노출
use pyo3::create_exception;
use pyo3::exceptions::PyException;
// 예외 클래스 정의 — (모듈, 이름, 부모)
create_exception!(fastmod, FastModError, PyException, "fastmod 기본 예외");
create_exception!(fastmod, ParseError, FastModError, "파싱 실패");
create_exception!(fastmod, LimitError, FastModError, "한계 초과");
#[pymodule]
fn fastmod(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("FastModError", m.py().get_type::<FastModError>())?;
m.add("ParseError", m.py().get_type::<ParseError>())?;
m.add("LimitError", m.py().get_type::<LimitError>())?;
Ok(())
}
#[pyfunction]
fn parse(s: &str) -> PyResult<i64> {
s.parse().map_err(|_| ParseError::new_err(format!("숫자가 아님: {s}")))
}
from fastmod import ParseError, FastModError
try:
fastmod.parse("abc")
except ParseError as e: # 구체적으로
...
except FastModError: # 또는 전부 한 번에 ★
...
FastModError 를 부모로 두면
사용자가 “이 라이브러리에서 난 에러 전부”를 한 줄로 잡을 수 있습니다.
널리 쓰이는 파이썬 패키지가 전부 이 구조입니다.
에러에 정보 담기
use pyo3::types::PyDict;
// 예외 객체에 속성 붙이기
#[pyfunction]
fn validate(py: Python<'_>, data: Vec<f64>) -> PyResult<()> {
if let Some(i) = data.iter().position(|x| x.is_nan()) {
let err = ParseError::new_err("NaN 이 있습니다");
let obj = err.value(py);
obj.setattr("index", i)?; // ★ e.index 로 접근 가능
obj.setattr("total", data.len())?;
return Err(err);
}
Ok(())
}
// 원인 사슬 연결 (파이썬의 raise ... from ...)
#[pyfunction]
fn wrapped(py: Python<'_>) -> PyResult<()> {
match inner() {
Err(e) => {
let new = FastModError::new_err("처리 실패");
new.set_cause(py, Some(e)); // __cause__ 설정 ★
Err(new)
}
Ok(_) => Ok(()),
}
}
// 경고 보내기
use pyo3::exceptions::PyDeprecationWarning;
PyErr::warn(py, &py.get_type::<PyDeprecationWarning>(),
c"이 함수는 곧 제거됩니다", 1)?;
실무에선 이렇게 — 패닉을 예외로 바꾸기 🔴 실무
패닉은 자동으로 잡힙니다 — 단, 조건이 있습니다 ★
#[pyfunction]
fn boom() {
panic!("무언가 잘못됨");
}
// 파이썬에서:
// pyo3_runtime.PanicException: 무언가 잘못됨
// → PyO3 가 패닉을 잡아 예외로 바꿔 줍니다
// ❗ 단, 두 경우엔 프로세스가 그대로 죽습니다
// ① Cargo.toml 에 panic = "abort" 가 있을 때 (y03)
// ② allow_threads 밖의 다른 스레드에서 패닉했을 때
// ★ 실무 원칙: 패닉에 의존하지 마세요
// ❌
#[pyfunction]
fn get(v: Vec<i64>, i: usize) -> i64 { v[i] } // 범위 밖이면 패닉
// ✅
#[pyfunction]
fn get(v: Vec<i64>, i: usize) -> PyResult<i64> {
v.get(i).copied()
.ok_or_else(|| PyIndexError::new_err(format!("범위 밖: {i}")))
}
// unwrap 을 남긴 채 배포하지 마세요
// cargo clippy -- -W clippy::unwrap_used -W clippy::expect_used
에러 메시지 품질 체크리스트
□ 무엇을 하려다 실패했는가
❌ "실패"
✅ "설정 파일 파싱 실패: /etc/app.toml"
□ 어떤 값이 문제였는가
❌ "잘못된 입력"
✅ "잘못된 포트: 99999 (허용 1~65535)"
□ 어떻게 고치는가
✅ "float64 배열이어야 합니다. np.asarray(a, dtype=np.float64) 를 쓰세요"
□ 예외 타입이 적절한가
타입이 틀림 → TypeError
값이 범위 밖 → ValueError
인덱스 밖 → IndexError
키 없음 → KeyError
파일·OS → OSError
구현 안 됨 → NotImplementedError
□ 민감 정보가 없는가 (토큰·비밀번호가 메시지에 섞이는 사고가 흔합니다)
□ 파이썬 관례를 따르는가
메시지는 대문자로 시작하지 않고, 마침표를 붙이지 않는 것이 관례입니다
(한글이면 "~합니다" 로 통일하는 편이 읽기 좋습니다)
한 단계 더 — PyErr 의 안쪽 🧬 전문가
지연 생성과 비용
// PyErr 는 '아직 파이썬 객체가 아닌 상태' 로 만들어집니다 (지연 생성)
let e = PyValueError::new_err("메시지"); // 아직 예외 객체를 안 만듦
// → 실제 객체는 파이썬으로 넘어갈 때 만들어집니다
// 에러 경로가 잦아도 비용이 크지 않은 이유입니다 ★
// 이미 발생한 에러 가져오기 (C API 를 직접 쓸 때)
if let Some(e) = PyErr::take(py) { }
// 예외 정보 꺼내기
let e: PyErr = ...;
e.get_type(py); // 예외 클래스
e.value(py); // 예외 인스턴스
e.traceback(py); // 트레이스백
e.is_instance_of::<PyValueError>(py);
// 출력 후 지우기 (콜백 안에서 에러를 삼켜야 할 때)
e.print(py); // stderr 로 출력
e.restore(py); // 파이썬 에러 상태로 되돌리기
// ★ 성능 팁: 에러 메시지를 format! 으로 매번 만들면
// 성공 경로에서도 비용이 듭니다. 지연 생성하세요
.ok_or_else(|| PyValueError::new_err(format!("…{i}"))) // ✅ 실패할 때만
.ok_or(PyValueError::new_err(format!("…{i}"))) // ❌ 항상 만듦
에러 처리 전체 그림
내부 로직 (순수 Rust)
↓ thiserror 로 구체적인 에러 타입 (🔐 o09)
CoreError
↓ impl From<CoreError> for PyErr ← ★ 경계에서 딱 한 번 변환
PyErr
↓ PyO3 가 파이썬으로 던짐
ValueError / ParseError / …
↓
파이썬 사용자가 종류별로 except
★ 이 구조의 이점
· 내부 로직은 파이썬을 전혀 모릅니다 → 순수 Rust 테스트 가능 (r11)
· 같은 로직을 Node 애드온에도 그대로 씁니다 (🟨 j02)
· C ABI 로도 내보낼 수 있습니다 (에러 코드로 변환 · 🔗 f09)
★ 안티패턴
· 로직 안에서 PyErr 를 직접 만들기
→ 그 함수는 파이썬 없이 테스트할 수 없게 됩니다
· 모든 에러를 문자열로 뭉개기
→ 사용자가 대응할 수 없고, 로그로도 조사가 안 됩니다
? 하나로 자동 변환되고,
내 타입은 impl From<MyError> for PyErr 를 경계에서 한 번만 씁니다.
예외 종류를 잃지 마세요 — 전부 RuntimeError 면 쓰는 사람이 대응할 수 없습니다.
스택 트레이스가 끊기므로 메시지에 맥락(무엇을·어떤 값으로·왜)을 담아야 합니다.콜백 — Rust 에서 파이썬 함수 부르기
진행률 보고, 사용자 정의 필터, 이벤트 훅 — Rust 안에서 파이썬 함수를 부르는 자리입니다. 가능하지만 비용이 큽니다. GIL 을 다시 잡아야 하고, 병렬화 이득이 사라집니다. 먼저 콜백 없이 설계할 수 있는지 확인하세요.
기본 형태
use pyo3::prelude::*;
use pyo3::types::PyAny;
#[pyfunction]
fn apply(py: Python<'_>, data: Vec<f64>, f: &Bound<'_, PyAny>)
-> PyResult<Vec<f64>>
{
data.iter()
.map(|x| f.call1((*x,))?.extract::<f64>())
.collect()
}
// 파이썬: fastmod.apply([1,2,3], lambda x: x*2)
// 키워드 인자로 부르기
use pyo3::types::PyDict;
let kwargs = PyDict::new(py);
kwargs.set_item("verbose", true)?;
f.call((arg,), Some(&kwargs))?;
// 인자 없이
f.call0()?;
// 진행률 콜백 — 있을 수도 없을 수도
#[pyfunction]
#[pyo3(signature = (data, on_progress = None))]
fn process(py: Python<'_>, data: Vec<f64>, on_progress: Option<&Bound<'_, PyAny>>)
-> PyResult<f64>
{
let mut sum = 0.0;
for (i, x) in data.iter().enumerate() {
sum += heavy(*x);
if i % 10_000 == 0 {
if let Some(cb) = on_progress {
cb.call1((i as f64 / data.len() as f64,))?; // 가끔만 ★
}
}
}
Ok(sum)
}
비용을 알고 쓰세요 ★
파이썬 콜백 1회의 비용
· 인자를 파이썬 객체로 변환
· 파이썬 함수 호출 (인터프리터 진입)
· 반환값을 Rust 타입으로 변환
→ 보통 수백 나노초 ~ 수 마이크로초
★ 100만 개 원소마다 콜백을 부르면
콜백만으로 0.5~5초입니다. Rust 로 바꾼 의미가 사라집니다.
★ 설계 대안
① 콜백 없이 — 결과를 돌려주고 파이썬이 처리하게
results = fastmod.process(data)
for r in results: handle(r) ← 파이썬 루프지만 한 번뿐
② 배치 콜백 — n개 처리할 때마다 한 번
on_progress(done, total) ← 100번쯤
③ 규칙을 데이터로 받기 — 콜백 대신 설정
fastmod.filter(data, min=0, max=100) ← Rust 안에서 전부 처리
④ 정말 필요하면 표현식을 문자열로 받아 Rust 가 해석
(polars 의 expression API 가 이 방식입니다) ★
df.apply(lambda …) 를 “느리니 되도록 쓰지 말라”고 문서에 적는 이유가
정확히 이것입니다. 표현식 API 를 제공하는 것이 근본 해법입니다.
이렇게도 쓸 수 있다 — GIL 과 콜백의 관계 🟡 중급
콜백을 보관하고 나중에 부르기
use pyo3::prelude::*;
#[pyclass]
struct Bus {
handlers: Vec<Py<PyAny>>, // ★ Py<T> 는 GIL 없이 보관 가능 (y02)
}
#[pymethods]
impl Bus {
#[new] fn new() -> Self { Bus { handlers: Vec::new() } }
fn on(&mut self, f: Py<PyAny>) { self.handlers.push(f); }
fn emit(&self, py: Python<'_>, event: &str) -> PyResult<()> {
for h in &self.handlers {
h.bind(py).call1((event,))?; // ★ bind 로 GIL 에 묶어 호출
}
Ok(())
}
}
// ❗ 주의: 파이썬 객체를 보관하면 순환 참조가 생길 수 있습니다
// → __traverse__ / __clear__ 를 구현하세요 (y05)
병렬 안에서 콜백은 직렬화됩니다 ★
use rayon::prelude::*;
// ❌ 병렬 이득이 사라집니다 — 콜백마다 GIL 을 잡으므로
#[pyfunction]
fn par_with_cb(py: Python<'_>, data: Vec<f64>, f: Py<PyAny>) -> Vec<f64> {
py.allow_threads(|| {
data.par_iter().map(|x| {
Python::with_gil(|py| { // ★ 여기서 전부 줄을 섭니다
f.bind(py).call1((*x,)).unwrap().extract().unwrap()
})
}).collect()
})
}
// ✅ 두 단계로 나누기 — 무거운 계산은 병렬, 콜백은 나중에 한 번
#[pyfunction]
fn par_then_cb(py: Python<'_>, data: Vec<f64>, f: &Bound<'_, PyAny>)
-> PyResult<Vec<f64>>
{
let mid: Vec<f64> = py.allow_threads(|| {
data.par_iter().map(|x| heavy(*x)).collect() // 병렬 ✅
});
f.call1((mid.clone(),))?; // 콜백 1회 ✅
Ok(mid)
}
// ★ 규칙: 콜백은 GIL 안쪽, 계산은 GIL 바깥쪽으로 분리하세요 (y07)
실무에선 이렇게 — 콜백 대신 쓰는 설계 🔴 실무
이터레이터로 뒤집기
// 콜백 대신 '결과를 하나씩 내주는' 객체를 돌려줍니다
#[pyclass]
struct Reader { inner: std::vec::IntoIter<Record> }
#[pymethods]
impl Reader {
fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { slf }
fn __next__(&mut self) -> Option<Record> { self.inner.next() }
}
#[pyfunction]
fn read_records(path: &str) -> PyResult<Reader> { }
# 파이썬이 흐름을 제어합니다 — 콜백보다 훨씬 파이썬답습니다 ★
for rec in fastmod.read_records("big.csv"):
if rec.score > 0.9:
handle(rec)
break # ★ 중간에 멈출 수도 있습니다
콜백 안에서 난 예외 처리
#[pyfunction]
fn apply_safe(py: Python<'_>, data: Vec<f64>, f: &Bound<'_, PyAny>)
-> PyResult<Vec<f64>>
{
let mut out = Vec::with_capacity(data.len());
for (i, x) in data.iter().enumerate() {
match f.call1((*x,)) {
Ok(v) => out.push(v.extract::<f64>()?),
Err(e) => {
// ★ 어디서 실패했는지 맥락을 더해 다시 던집니다
let msg = format!("콜백이 {i} 번째 원소({x})에서 실패");
let new = PyRuntimeError::new_err(msg);
new.set_cause(py, Some(e)); // 원인 보존 (y08)
return Err(new);
}
}
}
Ok(out)
}
// ❗ 콜백 예외를 삼키지 마세요
// .unwrap_or(0.0) 로 넘기면 사용자는 왜 결과가 이상한지 알 수 없습니다.
// 실패를 허용하려면 '건너뛴 개수'를 함께 돌려주세요.
// KeyboardInterrupt 도 콜백에서 전파됩니다 — 그대로 두면 됩니다 ★
한 단계 더 — GIL 재진입과 데드락 🧬 전문가
락 순서가 만드는 데드락 ★
// ❗ 위험: Rust 락을 잡은 채 파이썬 콜백을 부르기
#[pymethods]
impl Registry {
fn notify(&self, py: Python<'_>) -> PyResult<()> {
let g = self.state.lock().unwrap(); // ① Rust 락
for h in &self.handlers {
h.bind(py).call0()?; // ② 파이썬 코드 실행
} // → 이 안에서 다시
Ok(()) // notify() 를 부르면 데드락
}
}
// ✅ 락을 놓고 부르세요 (🔐 o10 의 원칙)
fn notify(&self, py: Python<'_>) -> PyResult<()> {
let handlers: Vec<Py<PyAny>> = {
let g = self.state.lock().unwrap();
g.handlers.clone() // 복사해서 락 해제 ★
};
for h in &handlers { h.bind(py).call0()?; }
Ok(())
}
// 같은 원리로: GIL 을 놓은 스레드가 Rust 락을 기다리고,
// GIL 을 든 스레드가 그 락을 들고 있으면 전체가 멈춥니다.
// → GIL 과 Rust 락을 동시에 잡는 구간을 최소화하세요
Python::with_gil 의 비용과 재진입
// 이미 GIL 을 잡고 있을 때 with_gil 을 다시 불러도 됩니다 (재진입 안전)
Python::with_gil(|py| {
Python::with_gil(|py2| { }) // ✅ 문제없음 (참조 카운트처럼 동작)
});
// 다만 GIL 이 없는 스레드에서 부르면 '획득 대기' 가 발생합니다
std::thread::spawn(|| {
Python::with_gil(|py| { // ★ 여기서 GIL 을 기다립니다
// 파이썬 메인 스레드가 바쁘면 오래 걸릴 수 있습니다
});
});
// 비용 측정 (직접 재 보세요 · ⚡ e01)
// 경합 없을 때 with_gil 왕복: 수십~수백 ns
// 경합 있을 때: 스케줄러에 달렸습니다 (밀리초까지)
// ★ 실무 패턴: 결과를 모아 두었다가 한 번에 GIL 을 잡으세요
let results: Vec<f64> = compute_without_gil();
Python::with_gil(|py| { report(py, &results) })?; // GIL 획득 1회
asyncio 이벤트 루프를 콜백에서 건드리면
루프가 다른 스레드에 있어 예외가 납니다.
pyo3-async-runtimes 크레이트를 쓰거나,
결과만 돌려주고 스케줄링은 파이썬이 하게 두세요.
Py<PyAny>, 호출은 bind(py), 그리고 락을 잡은 채 콜백을 부르지 마세요.타입 힌트 · .pyi 스텁 — 쓰는 사람의 체감
확장 모듈은 편집기가 안을 들여다볼 수 없습니다. 스텁 파일 하나를 붙이면 자동완성·타입 검사·문서가 전부 살아납니다. 작업량은 적은데 체감 차이가 가장 큰 항목입니다.
스텁 파일 만들기
python/fastmod/
├─ __init__.py
├─ __init__.pyi ★ 파이썬 래퍼의 스텁
├─ _native.pyi ★ 확장 모듈의 스텁
└─ py.typed ★ 빈 파일 — "타입 힌트가 있다"는 표시
# python/fastmod/_native.pyi
from typing import Sequence
import numpy as np
import numpy.typing as npt
__version__: str
def sum_squares(n: int) -> int: ...
def tokenize(
text: str,
*,
lowercase: bool = True,
min_len: int = 1,
) -> list[str]: ...
def mean(a: npt.NDArray[np.float64]) -> float: ...
class Counter:
total: int
step: int
def __init__(self, step: int = 1) -> None: ...
def increment(self) -> int: ...
def reset(self) -> None: ...
@property
def count(self) -> int: ...
def __len__(self) -> int: ...
class FastModError(Exception): ...
class ParseError(FastModError): ...
패키징에 포함시키기
# pyproject.toml
[tool.maturin]
python-source = "python"
module-name = "fastmod._native"
include = [
{ path = "python/fastmod/py.typed", format = "wheel" },
{ path = "python/fastmod/*.pyi", format = "wheel" },
]
# 확인 — wheel 안에 들어갔는지
unzip -l target/wheels/*.whl | grep -E "pyi|py.typed"
# mypy 로 검사
pip install mypy
mypy --strict python/
# 사용자 쪽에서 잘 되는지
python -c "
import fastmod
reveal_type(fastmod.sum_squares) # mypy 로 실행하면 타입이 보입니다"
py.typed 를 빠뜨리면 mypy 가 이 패키지에 타입 정보가 없다고 판단해
스텁을 무시합니다. 빈 파일 하나인데 이것 때문에 안 되는 경우가 많습니다.
이렇게도 쓸 수 있다 — 스텁을 자동 생성하기 🟡 중급
도구와 한계
# ① mypy 의 stubgen — 런타임 정보로 뼈대를 만듭니다
pip install mypy
stubgen -m fastmod._native -o python/
# → 인자 이름·기본값은 나오지만 타입은 대부분 Any 입니다
# 손으로 채워야 합니다 (한 번만 하면 됩니다)
# ② pyo3-stub-gen 크레이트 — Rust 어트리뷰트에서 생성 ★
# Cargo.toml: pyo3-stub-gen = "0.7"
# #[gen_stub_pyfunction] 을 붙이고 별도 바이너리로 생성합니다
# ③ 손으로 쓰기 — 함수가 10개 이하면 이게 가장 빠릅니다 ★
// pyo3-stub-gen 예시
use pyo3_stub_gen::derive::{gen_stub_pyfunction, gen_stub_pyclass};
#[gen_stub_pyfunction]
#[pyfunction]
fn sum_squares(n: u64) -> u64 { }
// src/bin/stub_gen.rs
fn main() -> pyo3_stub_gen::Result<()> {
let stub = fastmod::stub_info()?;
stub.generate()?;
Ok(())
}
// cargo run --bin stub_gen
docstring 도 함께 ★
/// 텍스트를 토큰으로 나눕니다.
///
/// Args:
/// text: 입력 문자열
/// lowercase: 소문자로 변환할지
/// min_len: 최소 길이
///
/// Returns:
/// 토큰 리스트
///
/// Raises:
/// ValueError: min_len 이 0 이면
///
/// Example:
/// >>> tokenize("Hello World")
/// ['hello', 'world']
#[pyfunction]
fn tokenize(text: &str, lowercase: bool, min_len: usize) -> Vec<String> { }
# /// 주석이 그대로 __doc__ 이 됩니다
help(fastmod.tokenize)
fastmod.tokenize.__doc__
# ★ Rust 쪽 doc 주석은 cargo doc 에도 쓰이므로
# 한 번 쓰면 두 곳에서 활용됩니다 (r11)
/// 주석을 쓰는 김에 따라오는 보너스로 생각하세요.
실무에선 이렇게 — 스텁이 코드와 어긋나지 않게 🔴 실무
CI 로 검사합니다
# ① 스텁과 실제 모듈이 맞는지 (mypy 도구)
pip install mypy
stubtest fastmod._native --allowlist stubtest-allowlist.txt
# → 스텁에 있는데 없는 함수 · 시그니처 불일치를 잡아 줍니다 ★
# ② 스텁 자체가 유효한 파이썬 타입인지
mypy --strict python/fastmod/
# ③ 예제가 실제로 도는지 (doctest)
python -m doctest -v python/fastmod/__init__.py
# GitHub Actions 에 넣기 (🛠️ b08)
# - run: maturin develop
# - run: stubtest fastmod._native
# - run: mypy --strict python/
stubtest 를 CI 에 넣는 것이 유일하게 확실한 방법입니다.
타입을 정확하게 적기
# numpy 타입
import numpy as np
import numpy.typing as npt
def mean(a: npt.NDArray[np.float64]) -> float: ...
def process(a: npt.ArrayLike) -> npt.NDArray[np.float64]: ...
# 오버로드 — 인자에 따라 반환 타입이 다를 때
from typing import overload, Literal
@overload
def parse(s: str, strict: Literal[True]) -> int: ...
@overload
def parse(s: str, strict: Literal[False] = False) -> int | None: ...
# 제네릭 컨테이너
from collections.abc import Sequence, Iterator
def batch(rows: Sequence[Sequence[float]]) -> list[float]: ...
def stream(path: str) -> Iterator[bytes]: ...
# 파이썬 버전 호환 (3.9 를 지원한다면)
from __future__ import annotations # list[int] 를 3.9 에서도
# 불투명 핸들
class Handle:
def __init__(self) -> None: ...
def close(self) -> None: ...
def __enter__(self) -> Handle: ...
def __exit__(self, *args: object) -> None: ...
한 단계 더 — 런타임 인트로스펙션의 한계 🧬 전문가
확장 함수가 파이썬에 보이는 모습
import fastmod, inspect
type(fastmod.sum_squares)
# <class 'builtin_function_or_method'> ★ 파이썬 함수가 아닙니다
inspect.signature(fastmod.sum_squares)
# ValueError: no signature found for builtin ← 스텁이 필요한 이유 ★
fastmod.sum_squares.__doc__ # ✅ 이건 있습니다
fastmod.sum_squares.__module__ # ✅
fastmod.sum_squares.__name__ # ✅
# 그래서 다음이 잘 안 됩니다
# · functools.wraps 로 감싸기
# · 시그니처 기반 프레임워크 (FastAPI · click · pytest fixture)
# · dataclass 필드 추론
#
# ★ 해결: 파이썬 래퍼를 두세요 (y02)
# def sum_squares(n: int) -> int:
# return _native.sum_squares(n)
# → 시그니처가 생기고, 검증·문서·데코레이터가 전부 자연스러워집니다
텍스트 시그니처 붙이기
// PyO3 는 __text_signature__ 를 자동 생성합니다
#[pyfunction]
#[pyo3(signature = (n, /, *, verbose = false))]
fn f(n: u64, verbose: bool) { }
// 파이썬에서
// fastmod.f.__text_signature__ → "(n, /, *, verbose=False)"
// inspect.signature 가 이걸 읽을 수 있는 경우도 있습니다
// #[pyclass] 도 마찬가지
#[pymethods]
impl Counter {
#[new]
#[pyo3(signature = (step = 1))]
fn new(step: u64) -> Self { }
}
// ★ 그래도 스텁이 정답입니다
// text_signature 는 기본값 표현이 제한적이고 (복잡한 기본값은 ...),
// 타입 정보를 담지 못합니다.
// 최종 권장 구조
// _native (Rust) → __init__.py (파이썬 래퍼) → __init__.pyi (스텁)
// · 래퍼: 검증 · 기본값 · 편의 함수
// · 스텁: 타입 · 자동완성
// · Rust: 계산만
.pyi 스텁 + 빈 py.typed 파일을 wheel 에 포함시키세요.
작업량은 작은데 자동완성·mypy 가 살아나 체감이 가장 큽니다.
stubtest 를 CI 에 넣어 스텁이 낡지 않게 하고,
시그니처 기반 프레임워크와 섞인다면 파이썬 래퍼를 두세요.abi3 — 한 번 빌드로 모든 파이썬
기본 설정으로는 파이썬 버전마다 따로 빌드해야 합니다 — 3.9~3.13 × 3 OS × 2 아키텍처면 wheel 이 30개입니다. abi3 를 켜면 파이썬 축이 사라집니다. 배포 파일이 30개에서 6개로 줍니다.
켜는 방법 — 한 줄
# Cargo.toml
[dependencies]
pyo3 = { version = "0.23", features = ["extension-module", "abi3-py39"] }
# ^^^^^^^^^
# abi3-py38 / abi3-py39 / abi3-py310 …
# "이 버전 이상의 CPython 에서 동작" 을 뜻합니다
# pyproject.toml
[project]
requires-python = ">=3.9" # ★ abi3-py39 와 맞추세요
결과 비교
abi3 없이 (3.9~3.13 지원)
fastmod-0.1.0-cp39-cp39-manylinux_2_17_x86_64.whl
fastmod-0.1.0-cp310-cp310-manylinux_2_17_x86_64.whl
fastmod-0.1.0-cp311-cp311-manylinux_2_17_x86_64.whl
fastmod-0.1.0-cp312-cp312-manylinux_2_17_x86_64.whl
fastmod-0.1.0-cp313-cp313-manylinux_2_17_x86_64.whl
× (linux/macos/windows) × (x86_64/arm64) = 30개
abi3 로
fastmod-0.1.0-cp39-abi3-manylinux_2_17_x86_64.whl
× 6 플랫폼 = 6개 ★
→ CI 시간도 5분의 1 이 됩니다 (🛠️ b08)
대가 — 무엇을 포기하나
| 일반 | abi3 | |
|---|---|---|
| wheel 수 | 많음 | 1/5 |
| 새 파이썬 대응 | 재빌드 | 그냥 됨 |
| 쓸 수 있는 C API | 전부 | 안정 API 만 |
| 성능 | 기준 | 약간 손해 |
| 일부 PyO3 기능 | 전부 | 일부 제한 |
★ abi3 에서 제한되는 것들 (PyO3 버전에 따라 다르니 문서 확인)
· 일부 최적화된 C API 호출을 함수 호출로 우회 (미세한 성능 손해)
· 구조체 내부 필드 직접 접근 불가
· 일부 타입의 특수 동작 (버전별 차이)
★ 실측 감각
대부분의 확장에서 성능 차이는 측정 오차 수준입니다.
경계를 자주 넘는 마이크로 벤치마크에서만 몇 % 보입니다.
→ 직접 재 보고 결정하세요 (⚡ e01)
★ 판단
· 배포하는 패키지 → abi3 를 켜세요 ★
· 사내 단일 환경 전용 → 굳이 필요 없습니다
· 극한 성능이 핵심 → 재서 확인하고 결정
이렇게도 쓸 수 있다 — 호환성 정책 정하기 🟡 중급
최소 버전을 어떻게 고르나
고려할 것
· 파이썬 EOL 일정 (보통 릴리스 후 5년)
· 의존 라이브러리의 최소 버전 (numpy 는 최근 3~4개 버전만 지원)
· 사용자층 (기업 내부는 오래된 버전이 많습니다)
★ 무난한 기준
"현재 지원되는 파이썬 중 가장 오래된 것" 을 고릅니다.
numpy 를 쓴다면 numpy 의 정책(NEP 29 / SPEC 0)을 따르는 것이 안전합니다.
올릴 때
· 마이너 버전 올림에서 최소 버전을 올리지 마세요 (semver · 🛠️ b03)
· 메이저 올림 때 하거나, 최소 한 번의 릴리스 전에 예고하세요
· requires-python 을 올리면 옛 파이썬 사용자는 옛 버전을 계속 받습니다
(pip 이 알아서 호환 버전을 찾습니다)
확인하는 방법
# abi3 로 빌드됐는지
maturin build --release
ls target/wheels/
# fastmod-0.1.0-cp39-abi3-*.whl ← abi3 태그 ★
# 실제로 여러 버전에서 도는지 (가장 확실한 검증)
for v in 3.9 3.10 3.11 3.12 3.13; do
uv venv --python $v .venv-$v
.venv-$v/bin/pip install target/wheels/*abi3*.whl
.venv-$v/bin/python -c "import fastmod; print('$v ok', fastmod.__version__)"
done
# CI 에서도 같은 매트릭스를 돌리세요 (빌드는 한 번, 테스트는 전 버전) ★
# 빌드 job: 1개
# 테스트 job: 파이썬 버전 수만큼 (빌드된 wheel 을 설치해 테스트)
실무에선 이렇게 — 배포 매트릭스를 설계한다 🔴 실무
무엇을 빌드할 것인가 ★
최소 권장 세트 (abi3 기준 · 🛠️ b07·b08)
manylinux x86_64 가장 많이 씁니다 (서버 · CI)
manylinux aarch64 ARM 서버 · 라즈베리파이
musllinux x86_64 Alpine 컨테이너 ★ 빠뜨리면 도커에서 컴파일합니다
macOS x86_64 인텔 맥
macOS arm64 애플 실리콘 ★
Windows x86_64 데스크탑 개발자
+ sdist (소스 배포) — 위에 없는 플랫폼은 여기서 컴파일합니다
★ sdist 를 반드시 올리세요
없으면 지원 안 하는 플랫폼 사용자는 설치 자체가 불가능합니다.
다만 sdist 로 설치하려면 사용자에게 Rust 툴체인이 필요합니다
→ README 에 명시하세요
설치 실패를 줄이는 장치
# pyproject.toml — 플랫폼 정보를 명확히
[project]
requires-python = ">=3.9"
classifiers = [
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Rust",
]
# 순수 파이썬 폴백을 함께 두면 (y01)
# 지원하지 않는 플랫폼에서도 '느리지만 동작' 합니다
try:
from ._native import process
except ImportError:
import warnings
warnings.warn("네이티브 모듈을 쓸 수 없어 순수 파이썬으로 동작합니다")
from ._pure import process
# 사용자가 겪는 대표 오류와 원인
# "no matching distribution found" → 그 플랫폼 wheel 이 없음
# "error: can't find Rust compiler" → sdist 로 폴백돼 컴파일 시도 ★
# "is not a supported wheel" → 태그 불일치 (y03)
한 단계 더 — Limited API 의 안쪽 🧬 전문가
무엇이 '안정' 인가
CPython 의 두 층
① 전체 C API — 버전마다 바뀔 수 있습니다 (구조체 필드 포함)
② Limited API — 버전 간 유지를 약속한 부분집합 (PEP 384)
abi3 = Limited API 만 쓰고, 그것을 지킨 바이너리 인터페이스
★ 왜 일반 빌드는 버전마다 달라지나
PyObject 구조체의 내부 배치, 매크로로 인라인된 접근이
버전마다 바뀝니다. 그래서 3.11 용 .so 를 3.12 에서 로드하면
"undefined symbol" 이나 크래시가 납니다.
★ abi3 가 하는 일
구조체를 직접 만지는 대신 함수를 호출합니다.
그래서 약간 느리지만, 배치가 바뀌어도 동작합니다.
# 검사 도구
pip install abi3audit
abi3audit target/wheels/*.whl # ★ 정말 abi3 를 지켰는지 검증
abi3 로 못 하는 것을 만났을 때
// feature 로 나눠 두면 둘 다 빌드할 수 있습니다 (🛠️ b04)
// Cargo.toml
// [features]
// abi3 = ["pyo3/abi3-py39"]
// fast = [] # 버전별 최적화 경로
#[cfg(feature = "fast")]
fn fast_path() { /* 비안정 API 사용 */ }
#[cfg(not(feature = "fast"))]
fn fast_path() { /* 안정 API 로 */ }
// CI 에서
// 기본 배포: --features abi3
// 성능 중시 사용자용: 버전별 wheel 도 함께 (드묾)
// ★ 실무 결론
// 대부분의 확장은 abi3 로 충분합니다.
// "abi3 때문에 못 하는 것" 을 실제로 만나는 경우는 드물고,
// 만나더라도 파이썬 래퍼로 우회할 수 있는 경우가 많습니다 (y02).
// free-threaded 빌드와의 관계 (y07)
// 3.13t 는 별도 ABI 라 abi3 wheel 이 그대로 쓰이지 않습니다.
// 당분간은 별도 빌드가 필요합니다 — PyO3 문서를 확인하세요.
features = ["abi3-py39"] 한 줄이면
wheel 수가 5분의 1로 줄고 새 파이썬이 나와도 그대로 동작합니다.
대가는 약간의 성능 손해와 일부 API 제한인데, 대부분의 확장에서는 문제가 되지 않습니다.
배포하는 패키지라면 켜세요. sdist 와 musllinux wheel 도 함께 올리는 것을 잊지 마세요.흔한 실패 12가지 — 처음 만들 때 반드시 밟는 것들
여기 모은 것들은 전부 실제로 겪게 되는 문제입니다. 대부분 원인을 알면 1분이면 해결되는데, 모르면 반나절이 갑니다. 막혔을 때 이 목록부터 훑어보세요.
① ~ ⑥ 빌드와 import ★
① ImportError: dynamic module does not define module export function
원인 Cargo.toml 의 [lib] name 과 #[pymodule] 함수 이름이 다름
해결 둘을 맞추거나 pyproject 의 module-name 을 확인 (y02)
② ModuleNotFoundError: No module named 'fastmod'
원인 maturin develop 을 다른 가상환경에서 실행
해결 which python / pip show fastmod 로 확인 후 재설치
③ 링크 에러: undefined reference to `PyList_New' (cargo test 할 때)
원인 extension-module feature 때문에 파이썬 심볼을 안 링크함
해결 crate-type = ["cdylib", "rlib"] + feature 를 optional 로
[features] extension-module = ["pyo3/extension-module"]
cargo test --no-default-features
④ 윈도우에서 링크 실패
원인 python3xx.lib 를 못 찾음 · 32/64비트 불일치
해결 같은 비트의 파이썬 사용 · maturin 이 자동 처리하므로
cargo build 대신 maturin 을 쓰세요 ★
⑤ 바꿨는데 반영이 안 됨
원인 .so 가 이미 로드된 프로세스 / 캐시
해결 파이썬 프로세스를 새로 띄우세요 (주피터는 커널 재시작)
⑥ ImportError: ... GLIBC_2.34 not found
원인 빌드 환경의 glibc 가 실행 환경보다 새로움
해결 manylinux 컨테이너에서 빌드 (maturin-action 이 처리) (🛠️ b08)
⑦ ~ ⑫ 동작과 성능 ★
⑦ Rust 로 바꿨는데 안 빨라졌다
원인 1 --release 를 안 붙임 ★ 가장 흔합니다
원인 2 경계 통과 횟수가 많음 (요소마다 호출) → 배치로 (y01)
원인 3 list → Vec 변환 비용이 지배적 → numpy 로 (y06)
원인 4 .clone() 이 루프 안에 있음 (⚡ e02)
확인 py-spy 로 어디에 시간이 가는지 보세요
⑧ RuntimeError: Already borrowed
원인 같은 #[pyclass] 인스턴스를 두 번 빌림 (자기 자신을 인자로)
해결 frozen + Mutex 로 바꾸기 (y05)
⑨ pickle 실패 — cannot pickle 'X' object
원인 __getstate__/__setstate__ 미구현
해결 y05 참고 · multiprocessing 을 쓰면 반드시 필요
⑩ Ctrl+C 가 안 먹는다
원인 GIL 을 놓은 채 오래 도는 중
해결 주기적으로 py.check_signals() (y07)
⑪ numpy 배열인데 as_slice() 가 None
원인 연속 배열이 아님 (슬라이스 뷰 · 전치)
해결 파이썬 래퍼에서 np.ascontiguousarray (y06)
⑫ 세그폴트 — 파이썬이 그냥 죽는다
원인 unsafe 코드 · FFI · 스레드에서 GIL 없이 파이썬 객체 접근
해결 아래 진단 절차로
이렇게도 쓸 수 있다 — 진단 도구와 절차 🟡 중급
세그폴트를 만났을 때 ★
# ① faulthandler — 파이썬 쪽 위치를 알려 줍니다
python -X faulthandler -c "import fastmod; fastmod.crash()"
PYTHONFAULTHANDLER=1 pytest
# ② 디버그 심볼로 빌드해 gdb 로
# Cargo.toml: [profile.release] debug = true
maturin develop --release
gdb --args python -c "import fastmod; fastmod.crash()"
(gdb) run
(gdb) bt # 스택 트레이스 ★
(gdb) py-bt # 파이썬 스택도 (python-gdb.py 필요)
# ③ 코어 덤프에서
ulimit -c unlimited
gdb python core.12345
# ④ 주소 살균기 (nightly · 리눅스)
RUSTFLAGS="-Zsanitizer=address" cargo +nightly build --target x86_64-unknown-linux-gnu
# ⑤ 맥
lldb -- python -c "import fastmod; fastmod.crash()"
unsafe 블록 · C 라이브러리 연동(🔗 f08) ·
GIL 없이 파이썬 객체 접근 셋 중 하나입니다. 그 세 곳부터 보세요.
성능이 기대만 못할 때
# ① 정말 릴리즈 빌드인가
python -c "
import fastmod, sys
print(fastmod._native.__file__)"
# 파일 시간과 target/release 를 대조하세요
# ② 시간이 어디로 가는가 (파이썬 쪽)
py-spy record -o flame.svg -- python bench.py
# → 네이티브 프레임도 보입니다 (--native 옵션)
# ③ Rust 쪽 프로파일 (⚡ e01)
cargo install flamegraph
cargo flamegraph --bench my_bench
# ④ 경계 통과 비용만 재기
python -m timeit -s "import fastmod" "fastmod.noop()"
# → 이 값 × 호출 횟수가 하한선입니다
# ⑤ 변환 비용만 재기
python -m timeit -s "import fastmod, numpy as np; a=np.zeros(10**6)" \
"fastmod.noop_array(a)" # 제로카피면 거의 0 이어야 합니다 ★
실무에선 이렇게 — 배포 전 체크리스트 🔴 실무
기능 · 안전 ★
□ parity 테스트가 있는가 (기준 구현과 결과 대조) ★
□ 순수 파이썬 폴백이 있는가 (환경 변수로 전환 · y01)
□ 경계 입력을 테스트했는가 (빈 것 · 아주 큰 것 · 잘못된 타입)
□ unwrap()/expect() 가 사용자 입력 경로에 없는가 (y08)
cargo clippy -- -W clippy::unwrap_used
□ 예외 타입이 적절한가 (전부 RuntimeError 가 아닌가)
□ Ctrl+C 가 동작하는가 (긴 작업이면)
□ 스레드 스트레스 테스트를 했는가 (y07)
□ pickle 이 필요한가 (multiprocessing 사용자가 있다면 · y05)
□ 메모리가 누수되지 않는가
반복 호출하며 RSS 를 관찰: for _ in range(10**6): f(data)
□ unsafe 를 썼다면 Miri 를 돌렸는가 (🔬 d07)
패키징 · 문서
□ abi3 를 켰는가 (y11)
□ 필요한 플랫폼 wheel 이 전부 있는가 (musllinux 포함)
□ sdist 를 올렸는가
□ py.typed 와 .pyi 가 wheel 에 들어갔는가 (y10)
unzip -l dist/*.whl | grep -E "pyi|py.typed"
□ stubtest 가 CI 에서 도는가
□ README 에 다음이 있는가
· 설치 방법 (sdist 로 설치할 때 Rust 필요)
· 지원 파이썬 · 플랫폼
· 성능 수치 (측정 환경 명시 ★)
· 폴백 전환 방법
□ 라이선스가 명확한가 (의존 크레이트 라이선스 포함)
cargo deny check licenses (🛠️ b09)
□ 버전이 Cargo.toml 과 일치하는가
★ 첫 배포는 TestPyPI 로 먼저 해 보세요
maturin publish -r testpypi
pip install -i https://test.pypi.org/simple/ fastmod
한 단계 더 — 운영 중 문제를 조사하는 법 🧬 전문가
재현되지 않는 문제
★ 네이티브 확장의 어려움: 로그가 파이썬에서 끊깁니다
대응 ① 확장 안에서도 로깅
pyo3-log 크레이트 — Rust 의 log 매크로를 파이썬 logging 으로
[dependencies] pyo3-log = "0.11"
pyo3_log::init(); // #[pymodule] 안에서
log::debug!("입력 크기 {}", n);
→ 파이썬의 logging 설정을 그대로 따릅니다 ★
대응 ② 결정적 재현을 위한 정보 남기기
· 입력 크기 · 해시 · 버전
· 실패 시 입력을 파일로 덤프하는 디버그 모드
대응 ③ 프로덕션 프로파일링
py-spy dump --pid 12345 # 지금 어디서 멈춰 있나
py-spy top --pid 12345 --native # 네이티브 프레임까지 ★
대응 ④ 크래시 수집
리눅스: systemd-coredump · core_pattern
컨테이너: /proc/sys/kernel/core_pattern 을 호스트에서 설정
되돌릴 수 있는 배포
# ① 런타임 스위치 (y01)
MYPKG_NATIVE=0 python app.py
# ② 버전 고정으로 롤백
pip install fastmod==0.3.2
# ③ 카나리 — 일부 트래픽만 네이티브로
import random, os
_RATIO = float(os.getenv("MYPKG_NATIVE_RATIO", "1.0"))
def process(x):
if random.random() < _RATIO:
return _native.process(x)
return _pure.process(x)
# ④ 결과 대조 모드 — 둘 다 돌려 다르면 로그 ★
def process_verify(x):
a, b = _native.process(x), _pure.process(x)
if a != b:
logger.error("불일치: input=%r native=%r pure=%r", x, a, b)
return b # 안전한 쪽을 반환
--release 누락, 2위는 경계 통과 횟수입니다.
세그폴트가 났다면 unsafe · C 연동 · GIL 없이 파이썬 접근 셋 중 하나입니다.
배포 전에 parity 테스트 · 폴백 · 스텁 · abi3 네 가지를 확인하세요.
다음은 🟨 Node · WASM 탭입니다.서버면 애드온
브라우저면 WASM
JavaScript 쪽으로 나가는 길은 둘입니다 — Node 안에서 도는 네이티브 애드온과 브라우저·엣지에서 도는 WASM. 목적이 완전히 다르므로 그 판단부터 하고 시작합니다. 둘 다 Rust 코드는 거의 그대로 쓸 수 있습니다.
애드온 vs WASM — 무엇을 고르나
둘 다 “Rust 코드를 JS 에서 부른다”지만 돌아가는 곳이 다릅니다. 애드온은 OS 위에서 네이티브로, WASM 은 샌드박스 안에서 돕니다. 이 차이가 성능·배포·할 수 있는 일 전부를 가릅니다.
비교표 ★
| 네이티브 애드온 | WASM | |
|---|---|---|
| 실행 환경 | Node 전용 | 브라우저·Node·엣지 |
| 성능 | 네이티브 100% | 70~95% |
| 파일 접근 | 가능 | 불가(샌드박스) |
| 스레드 | 자유 | 제한적 |
| SIMD | 전부 | 128비트만 |
| 배포 | 플랫폼별 바이너리 | 파일 하나 |
| 설치 | prebuild 필요 | npm 그대로 |
| 크래시 영향 | 프로세스 전체 | 격리됨 |
| 시작 비용 | 즉시 | 모듈 로드 필요 |
★ 한 줄 판단
브라우저에서 돌아야 한다 → WASM (선택지가 없습니다)
Node 서버에서 최고 성능이 필요 → 애드온
Node 인데 배포를 단순하게 → WASM 도 좋은 선택 ★
파일·네트워크·스레드를 써야 함 → 애드온
플러그인처럼 신뢰할 수 없는 코드 → WASM (샌드박스)
세 번째 선택지도 있습니다
③ 별도 프로세스 + IPC
Rust 로 CLI 나 서버를 만들고 JS 가 프로세스를 띄워 통신
장점
· 크래시가 Node 를 죽이지 않습니다 ★
· 언어 경계 문제가 없습니다 (표준 입출력 · HTTP · 유닉스 소켓)
· 배포가 단순합니다 (실행 파일 하나)
· 다른 언어에서도 그대로 씁니다
단점
· 왕복 지연 (수십 μs ~ 수 ms)
· 데이터 직렬화 비용
· 프로세스 관리가 필요합니다
★ 이게 정답인 경우가 생각보다 많습니다
· 호출 빈도가 낮고 데이터가 큰 작업 (이미지 변환 · 빌드 도구)
· 오래 도는 작업 (인덱싱 · 컴파일)
· CLI 로도 쓸모가 있는 기능
④ 그냥 JS 로 두기
Node 는 생각보다 빠릅니다. V8 의 JIT 는 뜨거운 루프를 잘 최적화합니다.
측정 없이 네이티브로 옮기지 마세요 (🐍 y01 과 같은 원칙 · ⚡ e01)
이렇게도 쓸 수 있다 — 실제 사례로 보기 🟡 중급
널리 쓰이는 Rust 기반 npm 패키지
네이티브 애드온 (napi-rs)
@swc/core TypeScript 컴파일러 — babel 대비 수십배
@napi-rs/canvas 이미지 렌더링
lightningcss CSS 파서·번들러
@parcel/* 번들러 일부
WASM (wasm-bindgen)
esbuild-wasm 브라우저에서 도는 번들러
@swc/wasm-web 브라우저용 SWC
rustpython-wasm
각종 이미지·암호·압축 라이브러리
별도 프로세스
rust-analyzer LSP 서버 (에디터가 프로세스로 띄움)
ripgrep VS Code 검색이 내부적으로 사용
turbo · biome CLI 도구
★ 관찰
'개발 도구' 영역에서 Rust 가 특히 많이 쓰입니다.
파싱·AST 순회처럼 JS 가 느린 작업이면서, 성능이 곧 개발 경험이라서입니다.
WASM 이 애드온보다 나은 경우 ★
① 배포 단순성이 성능보다 중요할 때
애드온: linux-x64 · linux-arm64 · musl · darwin-x64 · darwin-arm64 ·
win32-x64 … 6개 이상의 바이너리를 CI 로 만들어야 합니다 (j10)
WASM: .wasm 파일 하나. 어디서나 돕니다. ★
② 라이브러리를 npm 에 공개할 때
설치 실패 문의가 압도적으로 줄어듭니다.
(node-gyp 컴파일 실패는 npm 생태계의 오랜 골칫거리입니다)
③ 서버리스·엣지 (Cloudflare Workers · Deno Deploy · Vercel Edge)
네이티브 애드온을 아예 못 씁니다. WASM 만 가능합니다. ★
④ 신뢰할 수 없는 코드를 실행할 때
샌드박스가 기본입니다.
★ 성능 차이는 생각보다 작습니다
순수 계산은 네이티브의 70~95% 정도 나옵니다 (직접 재 보세요 · ⚡ e10).
대신 큰 데이터를 주고받으면 복사 비용이 생깁니다 (j09).
실무에선 이렇게 — 경계를 설계한다 🔴 실무
호출 횟수를 먼저 봅니다 ★
// ❌ 요소마다 경계를 넘음
const results = rows.map(r => native.process(r)); // 100만 번
// ✅ 배치
const results = native.processBatch(rows); // 1번
// ✅✅ 이미 메모리에 있는 버퍼를 그대로 (j04)
const out = native.processBuffer(inputBuffer); // 복사도 없음
경계 통과 비용 감각 (직접 재 보세요 · ⚡ e01)
napi-rs 빈 함수 호출 대략 수십~수백 ns
WASM 빈 함수 호출 더 쌉니다 (같은 프로세스·같은 VM)
숫자 전달 거의 공짜
문자열 전달 길이에 비례해 복사 (UTF-16 ↔ UTF-8 변환 ★)
Buffer 전달 제로카피 가능 (j04)
★ 문자열이 의외의 함정입니다
JS 문자열은 UTF-16, Rust 는 UTF-8 이라 매번 변환됩니다.
대량 텍스트는 Buffer(바이트)로 주고받는 편이 훨씬 빠릅니다.
이벤트 루프를 막지 않기
★ Node 에서 가장 중요한 제약
동기 함수가 100ms 걸리면
그동안 서버는 아무 요청도 처리하지 못합니다.
판단 기준
< 1ms 동기로 두세요 (비동기 오버헤드가 더 큽니다)
1~10ms 상황에 따라 (요청당 한 번이면 동기도 가능)
> 10ms 반드시 비동기로 (j05) ★
비동기로 만드는 방법
· napi-rs 의 AsyncTask — 스레드 풀에서 실행
· #[napi] async fn — tokio 런타임 사용
· 둘 다 Promise 를 반환합니다
WASM 은?
WASM 도 동기로 돌면 메인 스레드를 막습니다.
브라우저에서는 Web Worker 로, Node 에서는 worker_threads 로 옮기세요.
한 단계 더 — N-API 가 해결한 문제 🧬 전문가
예전에는 Node 버전마다 다시 빌드했습니다
과거 (nan · V8 API 직접 사용)
· V8 의 C++ API 는 Node 마이너 버전마다 바뀌었습니다
· Node 12 용 애드온을 Node 14 에서 로드하면 크래시
· npm install 마다 재컴파일 (node-gyp) → 설치 실패의 주범
현재 (N-API / Node-API)
· C ABI 기반의 안정 인터페이스 ★
· 한 번 빌드하면 Node 버전이 바뀌어도 그대로 동작
· Electron · Deno · Bun 도 지원
NAPI_VERSION 으로 최소 요구 버전을 정합니다
NAPI_VERSION=8 → Node 12.22+ 정도
★ 파이썬의 abi3 와 같은 아이디어입니다 (🐍 y11)
"구조체를 직접 만지지 말고 함수로 호출하라"
# 확인
node -p "process.versions.napi" # 이 Node 가 지원하는 N-API 버전
napi-rs 가 그 위에서 하는 일
N-API 는 C 인터페이스라 그대로 쓰면 장황합니다.
napi-rs 는 매크로로 그것을 감춥니다.
#[napi]
fn add(a: i32, b: i32) -> i32 { a + b }
↓ 매크로가 생성하는 것 (개념)
· napi_value 인자를 i32 로 변환하는 코드
· 타입이 안 맞으면 TypeError 를 던지는 코드
· 결과를 napi_value 로 변환
· 모듈 등록 코드
· TypeScript 타입 정의(.d.ts) 생성 ★ ← 이게 큰 이점입니다
# 생성된 코드 보기
cargo expand --lib
★ .d.ts 자동 생성은 파이썬 쪽(.pyi 를 손으로 · 🐍 y10)보다 나은 점입니다.
타입이 코드와 어긋날 수 없습니다.
napi-rs 첫 모듈 — 빌드까지 다 해 줍니다
binding.gyp 도, node-gyp 도 필요 없습니다.
napi build 한 줄이면 .node 파일과 TypeScript 타입까지 나옵니다.
PyO3 + maturin 조합과 거의 같은 경험입니다(🐍 y03).
처음부터 끝까지
# ① 프로젝트 생성 — 뼈대를 전부 만들어 줍니다
npm create napi@latest fastmod
# 또는
npx @napi-rs/cli new fastmod
cd fastmod
# 만들어지는 것
# Cargo.toml Rust 설정
# package.json npm 설정 + 빌드 스크립트
# src/lib.rs 여기에 코드를 씁니다
# index.js 플랫폼별 .node 를 골라 로드하는 로더 (자동 생성)
# index.d.ts TypeScript 타입 (자동 생성) ★
# ② 빌드
npm run build # napi build --platform --release
# → fastmod.linux-x64-gnu.node + index.d.ts
# ③ 써 보기
node -e "console.log(require('./index.js').sumSquares(1000))"
코드 두 곳
// src/lib.rs
#![deny(clippy::all)]
use napi_derive::napi;
/// 1..n 의 제곱합
#[napi]
pub fn sum_squares(n: u32) -> u32 {
(1..=n).map(|x| x * x).sum()
}
#[napi]
pub fn count_words(text: String) -> u32 {
text.split_whitespace().count() as u32
}
// ★ 함수 이름은 자동으로 camelCase 가 됩니다
// sum_squares → sumSquares
// 그대로 두려면 #[napi(js_name = "sum_squares")]
# Cargo.toml
[package]
name = "fastmod"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # ★ (🔗 f02)
[dependencies]
napi = { version = "2", default-features = false, features = ["napi4"] }
napi-derive = "2"
[build-dependencies]
napi-build = "2"
[profile.release]
lto = true
strip = "symbols" # 바이너리 크기 (🛠️ b10)
// build.rs
fn main() { napi_build::setup(); }
이렇게도 쓸 수 있다 — TypeScript 타입이 자동으로 🟡 중급
생성되는 index.d.ts ★
#[napi(object)]
pub struct Options {
pub lowercase: bool,
pub min_len: u32,
pub separator: Option<String>,
}
#[napi]
pub fn tokenize(text: String, options: Option<Options>) -> Vec<String> { }
// index.d.ts — 자동 생성됩니다 ★
export interface Options {
lowercase: boolean
minLen: number
separator?: string | undefined | null
}
export declare function tokenize(
text: string,
options?: Options | undefined | null
): Array<string>
.pyi 는 손으로 쓰고 stubtest 로
검사해야 했지만(🐍 y10), 여기서는 Rust 시그니처에서 타입이 생성되므로
어긋날 수가 없습니다.
타입 대응표
| JS / TS | Rust |
|---|---|
number | i32 u32 f64 |
bigint | i64 BigInt |
string | String |
boolean | bool |
T[] | Vec<T> |
Buffer | Buffer ★ |
Uint8Array | Uint8Array |
object | #[napi(object)] |
Map | HashMap |
null | Option<T> |
Promise<T> | AsyncTask · async fn |
Error | napi::Error |
| 클래스 | #[napi] struct |
// i64 는 JS number 로 정확히 표현 안 됩니다 (2^53 한계)
#[napi] pub fn big(n: i64) -> i64 { n } // → bigint 로 매핑됩니다
#[napi(ts_return_type = "number")] // 강제로 number 로 (주의)
pub fn small(n: i64) -> i64 { n }
실무에선 이렇게 — 패키지 구조와 스크립트 🔴 실무
package.json 의 핵심
{
"name": "fastmod",
"version": "0.1.0",
"main": "index.js",
"types": "index.d.ts",
"napi": {
"name": "fastmod",
"triples": {
"defaults": true,
"additional": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-musl"
]
}
},
"scripts": {
"build": "napi build --platform --release",
"build:debug": "napi build --platform",
"test": "node --test",
"artifacts": "napi artifacts",
"prepublishOnly": "napi prepublish -t npm"
},
"devDependencies": { "@napi-rs/cli": "^2" },
"files": ["index.js", "index.d.ts"],
"engines": { "node": ">= 12" }
}
.node 바이너리는 메인 패키지에 넣지 않습니다 —
플랫폼별 서브 패키지(fastmod-linux-x64-gnu 등)로 배포하고
optionalDependencies 로 연결합니다.
그래야 사용자가 자기 플랫폼 것만 내려받습니다(j10).
개발 흐름
# 반복 개발
npm run build:debug && node test.js
# 파일이 바뀌면 자동
cargo install cargo-watch
cargo watch -s "npm run build:debug && node --test"
# Rust 쪽 단위 테스트 (napi 없이 도는 순수 로직만 · r11)
cargo test
# ★ 구조 권장: 로직과 바인딩을 분리하세요
# src/core.rs 순수 Rust 로직 — cargo test 가 그대로 돕니다
# src/lib.rs #[napi] 바인딩만 — 얇게
# 타입 정의만 다시 생성
napi build --platform --release --dts index.d.ts
# 여러 진입점 (ESM + CJS)
# package.json 의 exports 필드를 쓰세요
# "exports": { "require": "./index.js", "import": "./index.mjs" }
한 단계 더 — index.js 로더가 하는 일 🧬 전문가
플랫폼 판별과 폴백
// napi 가 생성하는 index.js 의 요약
const { platform, arch } = process
let nativeBinding = null
switch (platform) {
case 'linux':
// glibc 인지 musl 인지 판별 ★
const isMusl = !process.report ||
!/glibc|GNU/.test(process.report.getReport().header.glibcVersionRuntime)
if (arch === 'x64') {
nativeBinding = isMusl
? require('fastmod-linux-x64-musl')
: require('fastmod-linux-x64-gnu')
}
break
case 'darwin': /* … */
case 'win32': /* … */
}
if (!nativeBinding) throw new Error(`지원하지 않는 플랫폼: ${platform}-${arch}`)
module.exports = nativeBinding
// ★ musl 판별을 빠뜨리면 Alpine 도커에서 로드가 실패합니다.
// 가장 흔한 배포 사고 중 하나입니다 (j10)
.node 파일의 정체
# .node 는 그냥 공유 라이브러리입니다 (확장자만 다름)
file fastmod.linux-x64-gnu.node
# ELF 64-bit LSB shared object, x86-64
# 내보내는 심볼
nm -D fastmod.linux-x64-gnu.node | grep napi_register
# napi_register_module_v1 ★ Node 가 이걸 찾습니다
# 의존성 확인 — 순수 Rust 라면 아주 적습니다
ldd fastmod.linux-x64-gnu.node
# libgcc_s.so.1 · libc.so.6 정도
# 크기 확인 (🛠️ b10)
ls -lh *.node
# 보통 수백 KB ~ 수 MB
# strip = "symbols" · lto = true · opt-level = "z" 로 줄일 수 있습니다
# Electron 에서 쓰려면
# 같은 .node 가 그대로 동작합니다 (N-API 덕분 · j01)
# 단, Electron 버전의 N-API 지원 범위를 확인하세요
npm create napi@latest → #[napi] 를 붙이고 → napi build.
binding.gyp 도 node-gyp 도 필요 없고, TypeScript 타입이 자동 생성됩니다.
순수 로직과 바인딩을 파일로 분리해 두면 cargo test 가 그대로 돌아 개발이 훨씬 편합니다.값 변환 — 객체 · 배열 · 옵션
숫자와 문자열은 그냥 됩니다. 실무에서 걸리는 것은
객체를 어떻게 주고받을지와 변환 비용이 어디서 나는지입니다.
대부분 #[napi(object)] 하나로 해결됩니다.
객체 주고받기
use napi_derive::napi;
// JS 평범한 객체 ↔ Rust 구조체 ★
#[napi(object)]
pub struct Point { pub x: f64, pub y: f64 }
#[napi(object)]
pub struct Result {
pub count: u32,
pub items: Vec<String>,
pub meta: Option<Point>,
}
#[napi]
pub fn analyze(points: Vec<Point>) -> Result {
Result {
count: points.len() as u32,
items: points.iter().map(|p| format!("{},{}", p.x, p.y)).collect(),
meta: points.first().cloned(),
}
}
// 필드 이름은 camelCase 로 변환됩니다
#[napi(object)]
pub struct Config {
pub max_size: u32, // JS 에서는 maxSize
#[napi(js_name = "url")]
pub endpoint: String, // 이름을 직접 지정
}
const r = native.analyze([{ x: 1, y: 2 }, { x: 3, y: 4 }])
// { count: 2, items: ['1,2', '3,4'], meta: { x: 1, y: 2 } }
변환 비용을 알기 ★
#[napi(object)] 은 '복사' 입니다
JS 객체의 각 필드를 읽어 Rust 구조체를 만듭니다.
→ 객체 100만 개를 넘기면 100만 번의 프로퍼티 조회가 일어납니다.
★ 대안
① 배열로 평탄화 — 프로퍼티 조회가 사라집니다
xs: Float64Array, ys: Float64Array
② Buffer 로 직렬화 (j04)
③ 객체 대신 개별 인자
측정 감각 (직접 재 보세요 · ⚡ e01)
숫자 인자 하나 거의 공짜
문자열 (짧음) UTF-16 → UTF-8 변환 (수백 ns)
문자열 (1MB) 복사 + 변환 (밀리초 단위)
객체 (필드 5개) 프로퍼티 조회 5회
Vec<f64> 100만 수십 ms ★
Float64Array 100만 제로카피 가능 (j04) ★
Buffer 로 주고받고 Rust 안에서 str::from_utf8 하세요.
이렇게도 쓸 수 있다 — 임의의 JS 값 다루기 🟡 중급
Env 와 JsObject
use napi::{Env, JsObject, JsUnknown, Result, bindgen_prelude::*};
// 동적으로 객체 만들기
#[napi]
pub fn make(env: Env) -> Result<JsObject> {
let mut obj = env.create_object()?;
obj.set("name", "현우")?;
obj.set("scores", vec![1, 2, 3])?;
Ok(obj)
}
// 임의 값 받기
#[napi]
pub fn describe(value: JsUnknown) -> Result<String> {
Ok(match value.get_type()? {
ValueType::String => "문자열",
ValueType::Number => "숫자",
ValueType::Object => "객체",
_ => "기타",
}.to_string())
}
// Either — 여러 타입 중 하나 ★
#[napi]
pub fn flexible(input: Either<String, Vec<u8>>) -> u32 {
match input {
Either::A(s) => s.len() as u32,
Either::B(b) => b.len() as u32,
}
}
// TypeScript: (input: string | Array<number>) => number
// serde 로 통째로 (편하지만 복사가 큽니다)
// features = ["serde-json"]
#[napi]
pub fn from_json(v: serde_json::Value) -> String { v.to_string() }
에러 던지기
use napi::{Error, Status, Result};
#[napi]
pub fn divide(a: f64, b: f64) -> Result<f64> {
if b == 0.0 {
return Err(Error::new(Status::InvalidArg, "0 으로 나눌 수 없습니다"));
}
Ok(a / b)
}
// 내 에러 타입 연결 (🔐 o09 · 🐍 y08 과 같은 구조)
impl From<CoreError> for napi::Error {
fn from(e: CoreError) -> Self {
let status = match e {
CoreError::Empty => Status::InvalidArg,
CoreError::Io(_) => Status::GenericFailure,
_ => Status::GenericFailure,
};
Error::new(status, e.to_string())
}
}
#[napi]
pub fn process(text: String) -> Result<String> {
Ok(core_process(&text)?) // ? 로 자동 변환
}
// JS 쪽에서 커스텀 에러 클래스로 던지기
#[napi]
pub fn throw_custom(env: Env) -> Result<()> {
let err = env.create_error(Error::from_reason("사용자 정의"))?;
// err 에 속성을 붙여 던질 수도 있습니다
Err(Error::from_reason("실패"))
}
실무에선 이렇게 — API 를 JS 답게 만든다 🔴 실무
옵션 객체 · 기본값
#[napi(object)]
pub struct TokenizeOptions {
pub lowercase: Option<bool>,
pub min_len: Option<u32>,
}
#[napi]
pub fn tokenize(text: String, options: Option<TokenizeOptions>) -> Vec<String> {
let o = options.unwrap_or(TokenizeOptions { lowercase: None, min_len: None });
let lowercase = o.lowercase.unwrap_or(true); // ★ 기본값은 Rust 에서
let min_len = o.min_len.unwrap_or(1) as usize;
// …
}
native.tokenize("A B") // 기본값
native.tokenize("A B", { lowercase: false }) // 일부만 ★ JS 관례
// 얇은 JS 래퍼를 두면 더 유연합니다 (🐍 y02 와 같은 원칙)
// index.js 대신 wrapper.js 를 main 으로 두고
// const native = require('./index.js')
// exports.tokenize = (text, opts = {}) => {
// if (typeof text !== 'string') throw new TypeError('text must be string')
// return native.tokenize(text, opts)
// }
숫자 타입 함정 ★
JS 의 number 는 f64 입니다 — 정수는 2^53 까지만 정확합니다.
#[napi] fn f(n: i64) -> i64 → TS 에서 bigint
#[napi] fn g(n: i32) -> i32 → TS 에서 number ✅
#[napi] fn h(n: f64) -> f64 → number ✅
★ 실무 권장
· 카운트·인덱스는 u32/i32 로 (40억까지면 대부분 충분)
· 진짜 큰 수는 bigint 를 받아들이거나 문자열로
· 타임스탬프(ms)는 f64 로 두면 2^53ms ≈ 28만년까지 안전합니다
오버플로 동작
u32 자리에 -1 을 주면? → 변환 에러 (napi 가 검사)
u32 자리에 2^40 을 주면? → 변환 에러
f64 자리에 NaN 을? → NaN 그대로 들어옵니다 ★ 직접 검사하세요
if !x.is_finite() {
return Err(Error::new(Status::InvalidArg, "유한한 수여야 합니다"));
}
한 단계 더 — V8 의 값 표현과 GC 🧬 전문가
napi_value 는 핸들입니다
napi_value 는 V8 객체를 가리키는 '핸들' 입니다.
GC 가 객체를 옮겨도 핸들은 유효합니다 (간접 참조).
★ 중요한 제약
핸들은 '핸들 스코프' 안에서만 유효합니다.
함수가 반환되면 그 스코프가 닫히고 핸들이 무효가 됩니다.
→ JS 값을 Rust 구조체에 저장하려면 Reference 를 만들어야 합니다.
use napi::bindgen_prelude::*;
let r: Ref<JsObject> = obj.create_ref()?; // GC 로부터 보호
// 나중에
let obj = r.get_value(&env)?;
r.unref(env)?; // ★ 반드시 해제
napi-rs 의 고수준 타입(String · Vec · #[napi(object)])은
전부 '값을 Rust 로 복사' 하므로 이 문제가 없습니다.
그래서 대부분의 코드에서는 신경 쓸 일이 없습니다. ★
성능을 재고 확인하기
// 경계 통과 비용만 재기
const { performance } = require('perf_hooks')
const native = require('./index.js')
function bench(name, fn, n = 1_000_000) {
fn() // 워밍업 (JIT)
const t = performance.now()
for (let i = 0; i < n; i++) fn()
console.log(name, ((performance.now() - t) * 1e6 / n).toFixed(1), 'ns/op')
}
bench('noop', () => native.noop())
bench('add', () => native.add(1, 2))
bench('string', () => native.echo('hello'))
bench('object', () => native.point({ x: 1, y: 2 }))
// ★ JS 로 같은 일을 하는 함수와 비교하세요
bench('js add', () => 1 + 2)
// → 경계 통과가 계산보다 훨씬 비싸다는 것이 눈으로 보입니다
// 정확한 벤치마크는 전용 도구로
// npm i -D mitata 또는 tinybench
#[napi(object)] 로 구조체를 주고받으면 TypeScript 타입까지 자동입니다.
다만 객체는 복사이고, 문자열은 UTF-16↔UTF-8 변환이 붙습니다.
대량 데이터는 Buffer·TypedArray 로(j04), 정수는 i32/u32 를 기본으로 쓰세요.Buffer · TypedArray — 제로카피
이미지·오디오·대용량 파일을 다룬다면 여기가 성능의 전부입니다.
Node 의 Buffer 는 연속된 바이트 덩어리라 Rust 슬라이스로 그대로 볼 수 있습니다.
파이썬의 numpy 제로카피와 같은 자리입니다(🐍 y06).
읽기 · 쓰기
use napi::bindgen_prelude::*;
use napi_derive::napi;
// 읽기 — 복사 없음 ★
#[napi]
pub fn checksum(data: Buffer) -> u32 {
let bytes: &[u8] = &data; // Deref 로 슬라이스 (🔐 o07)
bytes.iter().fold(0u32, |a, b| a.wrapping_add(*b as u32))
}
// 제자리 수정 — 할당조차 없습니다
#[napi]
pub fn invert(mut data: Buffer) {
let bytes: &mut [u8] = &mut data;
for b in bytes.iter_mut() { *b = !*b; }
}
// 새 Buffer 반환 (Vec 을 그대로 넘김 — 복사 1회)
#[napi]
pub fn compress(data: Buffer) -> Result<Buffer> {
let out: Vec<u8> = do_compress(&data)?;
Ok(out.into()) // ★ Vec<u8> → Buffer
}
// TypedArray
#[napi]
pub fn scale(mut a: Float64Array, k: f64) {
for x in a.iter_mut() { *x *= k; }
}
#[napi]
pub fn sum_i32(a: Int32Array) -> i64 {
a.iter().map(|x| *x as i64).sum()
}
JS 쪽에서
const fs = require('fs')
const native = require('./index.js')
// 파일을 읽어 그대로 넘기기 — 복사 없음 ★
const buf = fs.readFileSync('image.png')
const sum = native.checksum(buf)
// 제자리 수정
native.invert(buf) // buf 가 직접 바뀝니다
// TypedArray
const arr = new Float64Array(1_000_000)
native.scale(arr, 2.0) // 제로카피
// ArrayBuffer 공유 확인
const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.length)
// ★ Buffer 는 ArrayBuffer 위의 뷰입니다. offset 을 잊지 마세요
Buffer.slice() 는 뷰입니다(복사가 아님).
Buffer.subarray() 도 뷰이고, Buffer.from(buf) 는 복사입니다.
Rust 로 넘길 때 어느 쪽인지 알고 넘기세요 — 제자리 수정이 원본에 반영됩니다.
이렇게도 쓸 수 있다 — 소유권과 수명 🟡 중급
Buffer 를 오래 들고 있을 때 ★
// ❗ Buffer 슬라이스는 그 호출 동안만 유효합니다.
// JS 가 GC 하면 메모리가 사라집니다.
// ❌ 위험
#[napi]
pub struct Holder { data: &'static [u8] } // 이렇게 못 씁니다
// ✅ ① 복사해서 소유
#[napi]
pub struct Holder { data: Vec<u8> }
#[napi]
impl Holder {
#[napi(constructor)]
pub fn new(data: Buffer) -> Self { Holder { data: data.to_vec() } }
}
// ✅ ② Buffer 를 Rust 가 소유하게 만들기 (복사 없이 넘겨받기)
// napi-rs 의 Buffer 는 내부적으로 참조를 유지합니다
#[napi]
pub struct Reader { buf: Buffer } // ★ Buffer 자체를 보관
#[napi]
impl Reader {
#[napi(constructor)]
pub fn new(buf: Buffer) -> Self { Reader { buf } }
#[napi]
pub fn byte_at(&self, i: u32) -> Option<u8> {
self.buf.get(i as usize).copied()
}
}
// ✅ ③ Rust 가 만든 메모리를 JS 에 넘기기 (복사 없이)
#[napi]
pub fn make_buffer(env: Env, n: u32) -> Result<Buffer> {
let v = vec![0u8; n as usize];
Ok(Buffer::from(v)) // Vec 의 소유권이 JS 로 넘어갑니다 ★
}
외부 메모리와 finalizer
// C 라이브러리가 할당한 메모리를 복사 없이 JS 에 노출 (🔗 f08)
use napi::bindgen_prelude::*;
#[napi]
pub fn decode(env: Env, input: Buffer) -> Result<Buffer> {
let (ptr, len) = unsafe { ffi::decode(input.as_ptr(), input.len()) };
unsafe {
Ok(Buffer::from_external(
&env,
ptr,
len,
(), // finalizer 에 넘길 값
|_env, _hint| { ffi::free(ptr) }, // ★ GC 될 때 해제
)?)
}
}
// ★ 이 패턴의 요점
// · JS 가 그 메모리를 다 쓰면 finalizer 가 불립니다
// · 그때까지 C 쪽이 해제하면 안 됩니다 (소유권 규약 · 🔗 f06)
// · finalizer 는 임의의 시점에 임의의 스레드에서 불릴 수 있습니다
// 메모리 압력 알리기 — GC 가 더 적극적으로 돌게
env.adjust_external_memory(len as i64)?;
adjust_external_memory 로 알려 주면 GC 가 적절히 동작합니다.
실무에선 이렇게 — 이미지·스트림 파이프라인 🔴 실무
출력 버퍼를 받는 설계 ★
// ❌ 매번 새 Buffer — 초당 수천 프레임이면 GC 압력이 큽니다
#[napi]
pub fn process(input: Buffer) -> Buffer { }
// ✅ 호출자가 버퍼를 재사용
#[napi]
pub fn process_into(input: Buffer, mut output: Buffer) -> Result<u32> {
let src: &[u8] = &input;
let dst: &mut [u8] = &mut output;
if dst.len() < src.len() {
return Err(Error::new(Status::InvalidArg, "출력 버퍼가 작습니다"));
}
for (d, s) in dst.iter_mut().zip(src) { *d = s.wrapping_add(1); }
Ok(src.len() as u32)
}
// 버퍼를 한 번 만들어 재사용
const out = Buffer.allocUnsafe(1920 * 1080 * 4)
for await (const frame of frames) {
const n = native.processInto(frame, out)
await sink.write(out.subarray(0, n))
}
스트림과 청크 처리
// 상태를 이어 가는 스트리밍 처리기
#[napi]
pub struct Hasher { inner: blake3::Hasher }
#[napi]
impl Hasher {
#[napi(constructor)]
pub fn new() -> Self { Hasher { inner: blake3::Hasher::new() } }
#[napi]
pub fn update(&mut self, chunk: Buffer) { self.inner.update(&chunk); }
#[napi]
pub fn digest(&self) -> Buffer { self.inner.finalize().as_bytes().to_vec().into() }
}
const h = new native.Hasher()
for await (const chunk of fs.createReadStream('big.bin')) {
h.update(chunk) // 청크마다 제로카피
}
console.log(h.digest().toString('hex'))
// ★ 청크 크기는 64KB~1MB 정도가 무난합니다.
// 너무 작으면 경계 통과 횟수가 늘고 (j01),
// 너무 크면 이벤트 루프를 오래 막습니다 (j05)
한 단계 더 — SharedArrayBuffer 와 워커 🧬 전문가
스레드 간 진짜 공유
// worker_threads 와 SharedArrayBuffer
const { Worker } = require('worker_threads')
const sab = new SharedArrayBuffer(1024 * 1024)
const view = new Uint8Array(sab)
const w = new Worker('./worker.js', { workerData: { sab } })
// 워커와 메인이 같은 메모리를 봅니다 — 복사가 없습니다 ★
// Rust 쪽에서 받기
#[napi]
pub fn process_shared(mut data: Uint8Array) {
let bytes: &mut [u8] = &mut data;
// ❗ 다른 스레드가 동시에 이 메모리를 만질 수 있습니다
// 데이터 경쟁을 언어가 막아 주지 못하는 드문 자리입니다 (⚡ e06)
// → 원자 연산을 쓰거나, 영역을 나눠 쓰기로 약속하세요
}
// 영역 분할이 가장 안전합니다 (🔐 o02 의 split_at_mut 과 같은 아이디어)
#[napi]
pub fn process_range(mut data: Uint8Array, start: u32, len: u32) {
let s = &mut data[start as usize..(start + len) as usize];
// 워커마다 겹치지 않는 범위를 담당
}
복사가 실제로 일어나는지 확인
// ① 메모리 사용량으로 확인
const before = process.memoryUsage()
const buf = Buffer.allocUnsafe(100 * 1024 * 1024) // 100MB
native.checksum(buf)
const after = process.memoryUsage()
console.log('external', (after.external - before.external) / 1e6, 'MB')
// 제로카피면 거의 늘지 않습니다 ★
// ② 시간으로 확인 — 크기에 비례하면 복사입니다
for (const n of [1e3, 1e5, 1e7]) {
const b = Buffer.allocUnsafe(n)
const t = performance.now()
native.noop(b)
console.log(n, performance.now() - t)
}
// 제로카피면 크기와 무관하게 일정합니다
// ③ 제자리 수정으로 확인 (가장 확실)
const b = Buffer.from([1, 2, 3])
native.invert(b)
console.log(b) // 바뀌었으면 같은 메모리를 본 것입니다 ★
Buffer·TypedArray 는 Rust 슬라이스로 복사 없이 받습니다.
제자리 수정이 JS 에 그대로 보이므로 그게 제로카피의 증거입니다.
자주 호출된다면 출력 버퍼를 받아 재사용하게 만들고,
Buffer 를 오래 보관하려면 to_vec() 으로 복사하거나 Buffer 자체를 필드로 두세요.비동기 — 이벤트 루프를 막지 않기
Node 에서 가장 중요한 규칙입니다 — 동기 함수가 100ms 걸리면 그동안 서버는 아무 요청도 못 받습니다. napi-rs 는 Promise 를 반환하는 두 가지 방법을 제공합니다.
① AsyncTask — 스레드 풀에서
use napi::{Task, Env, JsNumber, Result};
use napi_derive::napi;
pub struct HeavyTask { data: Vec<f64> }
impl Task for HeavyTask {
type Output = f64;
type JsValue = JsNumber;
// ★ 이 부분이 워커 스레드에서 돕니다 (JS 를 만질 수 없습니다)
fn compute(&mut self) -> Result<f64> {
Ok(self.data.iter().map(|x| expensive(*x)).sum())
}
// 결과를 JS 값으로 (메인 스레드에서 호출됨)
fn resolve(&mut self, env: Env, out: f64) -> Result<JsNumber> {
env.create_double(out)
}
}
#[napi(ts_return_type = "Promise<number>")]
pub fn heavy_async(data: Vec<f64>) -> AsyncTask<HeavyTask> {
AsyncTask::new(HeavyTask { data })
}
const result = await native.heavyAsync(data) // 이벤트 루프가 안 막힙니다 ★
② async fn — tokio 런타임
# Cargo.toml
[dependencies]
napi = { version = "2", features = ["napi4", "async", "tokio_rt"] }
tokio = { version = "1", features = ["fs", "time", "rt-multi-thread"] }
#[napi]
pub async fn read_file(path: String) -> Result<Buffer> {
let data = tokio::fs::read(path).await
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
Ok(data.into())
}
#[napi]
pub async fn fetch_and_parse(url: String) -> Result<String> {
let body = reqwest::get(&url).await?.text().await?;
Ok(parse(&body))
}
// ★ CPU 작업을 async fn 안에서 직접 하면 안 됩니다
// tokio 워커를 막습니다 → spawn_blocking 으로
#[napi]
pub async fn mixed(data: Vec<f64>) -> Result<f64> {
let n = tokio::task::spawn_blocking(move || heavy(&data)).await
.map_err(|e| Error::from_reason(e.to_string()))?;
Ok(n)
}
AsyncTask(libuv 스레드 풀),
I/O·네트워크면 async fn(tokio). 섞여 있으면 async fn 안에서
CPU 부분만 spawn_blocking 으로 빼세요.
이렇게도 쓸 수 있다 — 스레드 풀과 병렬 🟡 중급
rayon 으로 안에서 병렬화 ★
use rayon::prelude::*;
impl Task for BatchTask {
type Output = Vec<f64>;
type JsValue = Vec<f64>;
fn compute(&mut self) -> Result<Vec<f64>> {
// ★ 이미 워커 스레드이므로 여기서 rayon 을 써도 됩니다
Ok(self.rows.par_iter().map(|r| score(r)).collect())
}
fn resolve(&mut self, _env: Env, out: Vec<f64>) -> Result<Vec<f64>> { Ok(out) }
}
// ❗ 스레드 수 주의
// libuv 스레드 풀(기본 4) + rayon 스레드 풀(코어 수) 이 겹칩니다
// → 과다 구독이 되면 오히려 느려집니다 (⚡ e05)
// libuv 풀 크기 조절 (JS 쪽 · 프로세스 시작 전에)
// UV_THREADPOOL_SIZE=8 node app.js
// rayon 풀 크기 조절 (Rust 쪽)
rayon::ThreadPoolBuilder::new().num_threads(4).build_global().ok();
취소와 진행률
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub struct CancellableTask {
data: Vec<f64>,
cancel: Arc<AtomicBool>,
}
impl Task for CancellableTask {
type Output = Option<f64>;
type JsValue = Option<f64>;
fn compute(&mut self) -> Result<Option<f64>> {
let mut sum = 0.0;
for (i, x) in self.data.iter().enumerate() {
if i % 10_000 == 0 && self.cancel.load(Ordering::Relaxed) {
return Ok(None); // ★ 취소됨
}
sum += expensive(*x);
}
Ok(Some(sum))
}
fn resolve(&mut self, _e: Env, o: Option<f64>) -> Result<Option<f64>> { Ok(o) }
}
// AbortSignal 연동 (napi-rs 가 지원합니다)
#[napi]
pub fn heavy(data: Vec<f64>, signal: Option<AbortSignal>) -> AsyncTask<HeavyTask> {
AsyncTask::with_optional_signal(HeavyTask { data }, signal)
}
const ac = new AbortController()
setTimeout(() => ac.abort(), 1000)
try { await native.heavy(data, ac.signal) }
catch (e) { console.log('취소됨') }
실무에선 이렇게 — 언제 동기로 둘 것인가 🔴 실무
비동기도 공짜가 아닙니다 ★
AsyncTask 한 번의 추가 비용
· 스레드 풀에 작업 큐잉
· 컨텍스트 스위치
· 결과를 메인 스레드로 전달 (이벤트 루프 한 바퀴)
· Promise 생성·해결
→ 보통 수십 μs 수준입니다 (직접 재 보세요 · ⚡ e01)
★ 판단표
작업 시간 권장
< 100 μs 동기 ★ (비동기 오버헤드가 작업보다 큽니다)
100μs ~ 1ms 동기 (요청당 몇 번이면)
1 ~ 10ms 상황에 따라 — 처리량 요구를 보세요
> 10ms 비동기 필수
★ 둘 다 제공하는 것이 좋은 API 입니다
#[napi] pub fn hash_sync(data: Buffer) -> Buffer
#[napi] pub fn hash(data: Buffer) -> AsyncTask<HashTask>
(Node 표준 라이브러리도 fs.readFileSync / fs.readFile 로 둘 다 줍니다)
이벤트 루프 지연 측정하기
// 내 애드온이 루프를 막고 있는지 확인 ★
const { monitorEventLoopDelay } = require('perf_hooks')
const h = monitorEventLoopDelay({ resolution: 10 })
h.enable()
// 부하를 주고
for (let i = 0; i < 100; i++) native.heavySync(data)
h.disable()
console.log('평균', h.mean / 1e6, 'ms')
console.log('최대', h.max / 1e6, 'ms') // ★ 이 값이 크면 막고 있는 것
// 운영에서는 지표로 내보내세요
// p99 이벤트 루프 지연이 급증하면 동기 네이티브 호출을 의심
// 부하 테스트로 확인
// autocannon -c 100 -d 10 http://localhost:3000/api
// 동기 버전과 비동기 버전의 p99 를 비교하면 차이가 명확합니다
한 단계 더 — libuv 스레드 풀의 안쪽 🧬 전문가
풀을 누가 함께 쓰나
libuv 스레드 풀(기본 4개)을 쓰는 것들
· fs.* 비동기 함수 (readFile 등)
· dns.lookup
· zlib · crypto 의 일부
· napi_create_async_work → AsyncTask ★
→ 내 애드온이 풀을 오래 점유하면 fs 도 느려집니다.
★ 대응
① UV_THREADPOOL_SIZE 를 늘리기 (최대 1024, 보통 코어 수 × 2)
프로세스 시작 전에 설정해야 합니다 ★
process.env 로 런타임에 바꿔도 안 먹습니다
② 긴 작업은 자체 스레드 풀로 (tokio · rayon)
libuv 풀을 건드리지 않습니다
③ 아주 긴 작업은 worker_threads 나 별도 프로세스로 (j01)
# 현재 값 확인
node -e "console.log(process.env.UV_THREADPOOL_SIZE || 4)"
async fn 과 tokio 런타임의 관계
napi-rs 의 tokio_rt feature 는
프로세스당 tokio 멀티스레드 런타임을 하나 만듭니다.
★ 주의할 점
· 그 런타임의 워커 스레드는 Node 이벤트 루프와 별개입니다
· async fn 안에서 블로킹 호출을 하면 tokio 워커가 막힙니다
std::fs::read() ❌ 블로킹
tokio::fs::read() ✅
무거운 계산 → spawn_blocking
· 런타임 종료 순서 문제 (🔐 o07 의 Drop 순서)
프로세스 종료 시 tokio 태스크가 남아 있으면 행이 걸릴 수 있습니다
→ 종료 훅에서 정리하거나 타임아웃을 두세요
· Node 의 async_hooks · AsyncLocalStorage 컨텍스트는
네이티브 비동기 경계를 넘어가지 않습니다 ★
→ 요청 컨텍스트(트레이싱 ID 등)를 인자로 명시적으로 넘기세요
# tokio 런타임 스레드 확인
# 리눅스: ls /proc/$(pgrep -n node)/task | wc -l
AsyncTask, I/O 는 async fn(tokio)이 기본이고,
섞이면 spawn_blocking 으로 나눕니다.
비동기도 수십 μs 의 비용이 있으니 아주 짧은 작업은 동기로 두고,
monitorEventLoopDelay 로 실제로 막고 있는지 확인하세요.Promise · 스레드 안전 콜백
워커 스레드에서 JS 함수를 부르려면 반드시 메인 스레드로 넘겨야 합니다. V8 은 스레드 안전하지 않기 때문입니다. 그 다리 역할을 하는 것이 ThreadsafeFunction 입니다 — 진행률·이벤트·스트림에 씁니다.
Promise 를 직접 만들기
use napi::bindgen_prelude::*;
use napi::{Env, JsObject, Result};
// ① async fn 이 가장 간단합니다 (j05)
#[napi]
pub async fn simple() -> Result<u32> { Ok(42) }
// ② 직접 deferred 로 (외부 이벤트를 기다릴 때)
#[napi(ts_return_type = "Promise<number>")]
pub fn manual(env: Env) -> Result<JsObject> {
let (deferred, promise) = env.create_deferred()?;
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
match compute() {
Ok(v) => deferred.resolve(move |_env| Ok(v)), // ★ 메인 스레드에서 실행
Err(e) => deferred.reject(Error::from_reason(e.to_string())),
}
});
Ok(promise)
}
// ★ deferred 는 Send 입니다 — 다른 스레드로 넘겨도 됩니다.
// resolve 클로저는 메인 스레드에서 실행되므로 JS 를 만질 수 있습니다.
ThreadsafeFunction — 콜백 ★
use napi::threadsafe_function::{
ThreadsafeFunction, ThreadsafeFunctionCallMode, ErrorStrategy,
};
#[napi]
pub fn watch(
#[napi(ts_arg_type = "(progress: number) => void")]
callback: JsFunction,
) -> Result<()> {
let tsfn: ThreadsafeFunction<f64, ErrorStrategy::Fatal> =
callback.create_threadsafe_function(0, |ctx| {
ctx.env.create_double(ctx.value).map(|v| vec![v])
})?;
std::thread::spawn(move || {
for i in 0..=100 {
std::thread::sleep(std::time::Duration::from_millis(50));
tsfn.call(i as f64 / 100.0, ThreadsafeFunctionCallMode::NonBlocking);
}
// 마지막에 반드시 해제 — 안 하면 프로세스가 안 끝납니다 ★
tsfn.abort().ok();
});
Ok(())
}
native.watch(p => console.log(`${(p * 100).toFixed(0)}%`))
abort() 또는 unref() 를 반드시 부르세요.
“Node 가 안 꺼진다”의 흔한 원인입니다.
이렇게도 쓸 수 있다 — 호출 모드와 큐 🟡 중급
Blocking vs NonBlocking
// NonBlocking — 큐가 차면 버립니다 (진행률에 적합) ★
tsfn.call(value, ThreadsafeFunctionCallMode::NonBlocking);
// Blocking — 큐가 빌 때까지 기다립니다 (데이터 손실 불가일 때)
tsfn.call(value, ThreadsafeFunctionCallMode::Blocking);
// 큐 크기는 create 할 때 정합니다
callback.create_threadsafe_function(
1024, // 0 = 무제한 ★ 메모리 폭증 위험
|ctx| { }
)?;
// ★ 실무 선택
// 진행률·로그 → NonBlocking + 작은 큐 (최신 값만 중요)
// 이벤트·데이터 → Blocking 또는 충분한 큐
// 무제한 큐(0) → 생산이 소비보다 빠르면 메모리가 터집니다
// 에러 전략
// ErrorStrategy::Fatal — 콜백이 (value) 를 받음
// ErrorStrategy::CalleeHandled — 콜백이 (err, value) 를 받음 (Node 관례)
let tsfn: ThreadsafeFunction<f64, ErrorStrategy::CalleeHandled> = ...;
tsfn.call(Ok(value), mode);
tsfn.call(Err(Error::from_reason("실패")), mode);
이벤트 스트림 만들기
#[napi]
pub struct Watcher { tsfn: Option<ThreadsafeFunction<Event, ErrorStrategy::Fatal>> }
#[napi(object)]
pub struct Event { pub kind: String, pub path: String }
#[napi]
impl Watcher {
#[napi(constructor)]
pub fn new() -> Self { Watcher { tsfn: None } }
#[napi]
pub fn on_change(&mut self, callback: JsFunction) -> Result<()> {
let tsfn = callback.create_threadsafe_function(64, |ctx| {
Ok(vec![ctx.value])
})?;
self.tsfn = Some(tsfn);
Ok(())
}
#[napi]
pub fn close(&mut self) -> Result<()> {
if let Some(t) = self.tsfn.take() { t.abort()?; } // ★ 정리
Ok(())
}
}
// ★ 더 JS 다운 대안: AsyncIterator 를 노출하기
// for await (const ev of watcher) { }
// napi-rs 의 generator 지원을 쓰거나, JS 래퍼에서 EventEmitter 로 감싸세요
실무에선 이렇게 — 콜백 비용과 설계 🔴 실무
콜백은 비쌉니다 ★
tsfn.call 한 번의 경로
워커 스레드 → 큐에 넣기 → 이벤트 루프 깨우기
→ 메인 스레드에서 JS 값 생성 → JS 함수 호출
→ 보통 수 μs 이상입니다 (직접 재 보세요 · ⚡ e01)
★ 100만 번 부르면 콜백만으로 수 초입니다.
파이썬 콜백과 같은 문제입니다 (🐍 y09).
설계 대안
① 배치로 — n개마다 한 번, 또는 시간 간격으로
if last.elapsed() > Duration::from_millis(100) { tsfn.call(..) }
② 결과를 모아 한 번에 반환 (콜백 없이)
③ 진행률은 '값'이 아니라 '공유 카운터'로
AtomicU64 를 두고 JS 가 주기적으로 폴링
④ 정말 스트림이 필요하면 Node 스트림 API 로 감싸기
★ 가장 흔한 좋은 선택: ①번 시간 기반 스로틀링
사람이 보는 진행률은 초당 10회면 충분합니다.
JS 래퍼로 다듬기
// 네이티브의 콜백 API 를 JS 관용구로 감싸기
const { EventEmitter } = require('events')
const native = require('./index.js')
class Watcher extends EventEmitter {
constructor(path) {
super()
this._inner = new native.Watcher()
this._inner.onChange(ev => this.emit(ev.kind, ev.path))
}
close() { this._inner.close() }
}
// 또는 AsyncIterator 로
async function* watch(path) {
const queue = []
let notify
const w = new native.Watcher()
w.onChange(ev => { queue.push(ev); notify?.() })
try {
while (true) {
if (queue.length === 0) await new Promise(r => { notify = r })
yield queue.shift()
}
} finally { w.close() } // ★ 정리를 보장
}
EventEmitter·AsyncIterator·Readable 같은 Node 관용구는
JS 로 감싸는 편이 훨씬 자연스럽고, 네이티브 쪽은 단순하게 유지됩니다(🐍 y02 와 같은 원칙).
한 단계 더 — 참조 카운트와 종료 🧬 전문가
ref / unref 의 의미
// tsfn 은 기본적으로 이벤트 루프를 '붙잡습니다' (ref)
// → 살아 있는 동안 Node 가 종료되지 않습니다
tsfn.unref(&env)?; // 붙잡지 않기 — 다른 일이 없으면 종료 허용 ★
tsfn.refer(&env)?; // 다시 붙잡기
tsfn.abort()?; // 완전히 해제 (이후 call 은 실패)
// ★ 언제 unref 하나
// · 백그라운드 감시자처럼 '있어도 종료를 막으면 안 되는' 것
// · setInterval 의 unref() 와 같은 개념입니다
// 정리 순서 문제
// 프로세스 종료 시 워커 스레드가 아직 tsfn.call 을 하면?
// → abort 이후의 call 은 조용히 실패합니다 (크래시는 아님)
// → 하지만 워커가 Rust 자원을 정리하지 못할 수 있습니다
// 안전한 종료 패턴
// ① JS 쪽에서 close() 를 명시적으로 부르게 만들기 ★
// ② process.on('exit') 훅에서 정리
// ③ Rust 쪽에 종료 플래그를 두고 워커가 확인
디버깅 — 프로세스가 안 끝날 때
// 무엇이 이벤트 루프를 붙잡고 있나
const wtf = require('why-is-node-running') // npm i -D why-is-node-running
setTimeout(() => wtf(), 5000)
// Node 내장으로
process.on('beforeExit', () => console.log('종료 직전'))
console.log(process._getActiveHandles().length)
console.log(process._getActiveRequests().length)
// ★ 네이티브 애드온이 원인이면 위 목록에 잘 안 잡힙니다.
// 그럴 때는 tsfn 을 의심하세요.
// 강제 종료 (최후의 수단 — Drop 이 안 불립니다 · 🔐 o07)
process.exit(0)
// 검증 테스트를 하나 두세요
// test('종료된다', async () => {
// const w = new native.Watcher(); w.close()
// // 이 테스트 파일이 타임아웃 없이 끝나면 OK ★
// })
ThreadsafeFunction 을 거쳐야 합니다.
작업이 끝나면 abort() 를 부르세요 — 안 하면 Node 프로세스가 종료되지 않습니다.
콜백은 호출당 수 μs 이므로 시간 기반 스로틀링으로 횟수를 줄이고,
EventEmitter 같은 Node 관용구는 JS 래퍼로 감싸는 것이 정석입니다.클래스 노출 — #[napi] struct
상태를 들고 있어야 하거나 초기화가 비싸면 클래스로 노출합니다.
#[napi] 를 구조체와 impl 에 붙이면 JS 에서 new 로 만드는 클래스가 됩니다.
PyO3 의 #[pyclass] 와 같은 자리입니다(🐍 y05).
기본 형태
use napi_derive::napi;
use regex::Regex;
#[napi]
pub struct Matcher {
re: Regex, // 내부 필드 (JS 에 안 보임)
pub count: u32, // pub 이면 속성으로 노출 ★
}
#[napi]
impl Matcher {
#[napi(constructor)]
pub fn new(pattern: String) -> Result<Self> {
let re = Regex::new(&pattern)
.map_err(|e| Error::new(Status::InvalidArg, e.to_string()))?;
Ok(Matcher { re, count: 0 })
}
#[napi]
pub fn find_all(&mut self, text: String) -> Vec<String> {
self.count += 1;
self.re.find_iter(&text).map(|m| m.as_str().to_string()).collect()
}
#[napi(getter)]
pub fn pattern(&self) -> String { self.re.as_str().to_string() }
#[napi(setter)]
pub fn set_count(&mut self, v: u32) { self.count = v; }
#[napi(factory)] // static 생성자
pub fn from_words(words: Vec<String>) -> Result<Self> { }
#[napi]
pub fn to_string(&self) -> String { format!("Matcher({})", self.re) }
}
const m = new native.Matcher('\\d+')
m.findAll('a1 b22') // ['1', '22']
m.count // 1
m.pattern // '\\d+' (getter)
object vs class ★
#[napi(object)] | #[napi] struct | |
|---|---|---|
| JS 표현 | 평범한 객체 | 클래스 인스턴스 |
| 전달 | 복사 | 참조 (핸들) |
| 메서드 | 없음 | 있음 |
| 내부 필드 | 전부 pub | 숨길 수 있음 |
| 수명 | 호출 동안 | GC 까지 |
| 용도 | 데이터 전달 | 상태·자원 |
★ 고르는 기준 (🐍 y05 와 같습니다)
· 그냥 값 묶음이면 → object
· 초기화가 비싸면 → class
· 상태를 이어 가야 하면 → class
· 정리가 필요하면(파일·연결) → class + close()
★ 성능
object 는 필드마다 프로퍼티 조회가 일어납니다 (j03).
class 는 핸들 하나만 오가므로 큰 상태를 여러 번 쓸 때 훨씬 쌉니다.
이렇게도 쓸 수 있다 — 참조 전달과 GC 🟡 중급
클래스 인스턴스를 인자로 받기
use napi::bindgen_prelude::*;
#[napi]
pub struct Buffer2D { data: Vec<u8>, w: u32, h: u32 }
#[napi]
impl Buffer2D {
#[napi(constructor)]
pub fn new(w: u32, h: u32) -> Self {
Buffer2D { data: vec![0; (w * h) as usize], w, h }
}
// 다른 인스턴스를 참조로 받기 ★
#[napi]
pub fn blit(&mut self, src: &Buffer2D, x: u32, y: u32) {
for row in 0..src.h {
let d = ((y + row) * self.w + x) as usize;
let s = (row * src.w) as usize;
self.data[d..d + src.w as usize]
.copy_from_slice(&src.data[s..s + src.w as usize]);
}
}
// 새 인스턴스 반환
#[napi]
pub fn clone_region(&self, x: u32, y: u32, w: u32, h: u32) -> Buffer2D { }
}
// ❗ &Buffer2D 와 &mut self 를 동시에 — 같은 객체면?
// self.blit(self, 0, 0) 을 JS 에서 하면 napi-rs 가 막습니다
// (PyO3 의 Already borrowed 와 같은 상황 · 🐍 y05)
자원 정리 — Drop 과 close()
#[napi]
pub struct Db { conn: Option<Connection> }
#[napi]
impl Db {
#[napi(constructor)]
pub fn new(path: String) -> Result<Self> { }
#[napi]
pub fn close(&mut self) -> Result<()> {
if let Some(c) = self.conn.take() { c.close()?; } // ★ 명시적 정리
Ok(())
}
#[napi]
pub fn query(&self, sql: String) -> Result<Vec<Row>> {
let c = self.conn.as_ref()
.ok_or_else(|| Error::from_reason("이미 닫혔습니다"))?;
c.query(&sql)
}
}
impl Drop for Db {
fn drop(&mut self) { let _ = self.conn.take(); } // GC 될 때 안전망
}
// ★ GC 시점은 예측할 수 없습니다 (🔐 o07)
// 파일 핸들·연결처럼 한정된 자원은 반드시 close() 를 제공하세요.
// JS 쪽에서 try/finally 나 using(TC39 명시적 자원 관리)으로 쓰게 합니다.
with 같은 것이 JS 에는 오랫동안 없었습니다 —
최근 using 문법(Symbol.dispose)이 들어왔지만 아직 널리 쓰이지 않습니다.
close() 를 제공하고 문서에 명시하는 것이 현실적입니다.
실무에선 이렇게 — 스레드와 클래스 🔴 실무
비동기 메서드 만들기
// 클래스 메서드도 Promise 를 반환할 수 있습니다 (j05)
#[napi]
impl Db {
#[napi]
pub async fn query_async(&self, sql: String) -> Result<Vec<Row>> {
// ❗ &self 를 워커 스레드로 넘길 수 없습니다
// → 필요한 것을 복제하거나 Arc 로 공유하세요 (🔐 o10)
}
}
// ✅ 내부 상태를 Arc 로
#[napi]
pub struct Db { inner: Arc<Pool> }
#[napi]
impl Db {
#[napi]
pub async fn query(&self, sql: String) -> Result<Vec<Row>> {
let pool = self.inner.clone(); // ★ Arc clone (쌈)
tokio::task::spawn_blocking(move || pool.query(&sql))
.await
.map_err(|e| Error::from_reason(e.to_string()))?
}
}
// AsyncTask 를 쓸 때도 같은 원칙
#[napi]
pub fn process(&self, data: Buffer) -> AsyncTask<ProcessTask> {
AsyncTask::new(ProcessTask {
state: self.inner.clone(), // 공유
data: data.to_vec(), // 복사 (Buffer 는 워커로 못 넘김)
})
}
worker_threads 와의 관계
★ 중요: 클래스 인스턴스는 워커 스레드로 전달되지 않습니다
const w = new Worker('./w.js', { workerData: { matcher } })
// ❌ DataCloneError — 네이티브 객체는 구조화 복제가 안 됩니다
★ 대응
① 워커 안에서 각자 만들기 (설정만 전달)
workerData: { pattern: '\\d+' }
워커 안에서: new native.Matcher(pattern)
② 순수 데이터만 주고받기 (Buffer · SharedArrayBuffer · j04)
③ 애초에 Rust 안에서 병렬화 (rayon · j05) ★ 대개 이게 낫습니다
★ 각 워커가 애드온을 로드하면
· .node 는 프로세스당 한 번 로드되고 워커끼리 공유됩니다
· 하지만 Rust 의 전역 상태(static)도 공유됩니다 ★
→ OnceLock · Mutex 로 보호해야 합니다 (r02 · ⚡ e06)
· napi-rs 는 워커별 Env 를 구분해 줍니다
한 단계 더 — 인스턴스가 실제로 어떻게 연결되나 🧬 전문가
wrap 과 finalizer
#[napi] struct 가 만들어 내는 것
① JS 클래스를 정의 (napi_define_class)
② new 하면 JS 객체를 만들고
③ Rust 구조체를 Box 로 힙에 올린 뒤
④ napi_wrap 으로 JS 객체에 그 포인터를 붙입니다 ★
⑤ finalizer 를 등록 — GC 될 때 Box 를 해제
JS 객체 ──wrap──▶ Box<Matcher> (Rust 힙)
│
└─ GC 되면 finalizer → drop(Box) → Drop::drop 호출
★ 그래서
· 메서드 호출마다 napi_unwrap 으로 포인터를 꺼냅니다 (매우 쌈)
· Rust 구조체는 JS 객체와 수명이 묶입니다
· JS 가 참조를 놓으면 언젠가 해제됩니다 (시점은 GC 에 달림)
# 생성된 코드 확인
cargo expand --lib | grep -A 30 "impl.*Matcher"
메모리 누수 진단
// 인스턴스가 정말 해제되는지 확인
const v8 = require('v8')
function measure() {
global.gc?.() // node --expose-gc
return process.memoryUsage()
}
const before = measure()
for (let i = 0; i < 100_000; i++) {
const m = new native.Matcher('\\d+')
m.findAll('test')
}
const after = measure()
console.log('rss 증가', (after.rss - before.rss) / 1e6, 'MB')
// ★ 크게 늘면 Drop 이 안 불리거나 외부 메모리가 남는 것입니다
// 힙 스냅샷으로 JS 쪽 누수 확인
v8.writeHeapSnapshot('/tmp/heap.heapsnapshot')
// Chrome DevTools 에서 열어 보세요
// 네이티브 메모리 누수는
// 리눅스: valgrind --leak-check=full node app.js (느립니다)
// 또는 jemalloc/tcmalloc 의 프로파일러
// 가장 확실한 것: Rust 쪽 단위 테스트에서 Drop 카운트 검증 (🔐 o07)
Arc<AtomicUsize> 를 필드에 넣고 Drop 에서 증가시키면
JS 없이도 해제를 검증할 수 있습니다(r11).
#[napi(object)], 상태·자원이면 #[napi] struct 입니다.
클래스는 핸들만 오가므로 큰 상태를 여러 번 쓸 때 훨씬 쌉니다.
GC 시점은 예측할 수 없으니 한정 자원에는 close() 를 제공하고,
비동기 메서드에서는 내부 상태를 Arc 로 공유하세요.wasm-bindgen — 브라우저에서 도는 Rust
같은 Rust 코드를 브라우저에서 도는 WASM 으로 뽑습니다. 명령 두 줄이면 되고, TypeScript 타입도 함께 나옵니다. 서버리스·엣지 런타임에서도 이 방식만 씁니다(j01).
처음부터 끝까지
# ① 도구 설치 (한 번만)
cargo install wasm-pack
rustup target add wasm32-unknown-unknown
# ② 프로젝트
cargo new --lib fastwasm
cd fastwasm
# ③ 빌드 — 대상에 따라 --target 을 고릅니다 ★
wasm-pack build --target web # <script type="module"> 로 직접
wasm-pack build --target bundler # webpack · vite 등 번들러용
wasm-pack build --target nodejs # Node 에서 require
wasm-pack build --release # 최적화 ★ 크기가 크게 줄어듭니다
# 결과: pkg/
# fastwasm_bg.wasm 실제 바이너리
# fastwasm.js 글루 코드
# fastwasm.d.ts TypeScript 타입 ★
# package.json npm 배포 준비 완료
# Cargo.toml
[lib]
crate-type = ["cdylib", "rlib"] # rlib 도 두면 cargo test 가 됩니다
[dependencies]
wasm-bindgen = "0.2"
[profile.release]
opt-level = "z" # 크기 우선 (j09)
lto = true
코드와 사용
// src/lib.rs
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn sum_squares(n: u32) -> u32 {
(1..=n).map(|x| x * x).sum()
}
#[wasm_bindgen]
pub fn count_words(text: &str) -> usize {
text.split_whitespace().count()
}
// 구조체도 클래스로 나갑니다
#[wasm_bindgen]
pub struct Counter { total: u32 }
#[wasm_bindgen]
impl Counter {
#[wasm_bindgen(constructor)]
pub fn new() -> Counter { Counter { total: 0 } }
pub fn increment(&mut self) -> u32 { self.total += 1; self.total }
#[wasm_bindgen(getter)]
pub fn total(&self) -> u32 { self.total }
}
// 브라우저 (--target web)
import init, { sum_squares, Counter } from './pkg/fastwasm.js'
await init() // ★ WASM 로드 (한 번)
console.log(sum_squares(1000))
const c = new Counter()
c.increment()
이렇게도 쓸 수 있다 — JS 와 주고받기 🟡 중급
타입 대응과 복사
| JS | Rust | 복사 |
|---|---|---|
number | i32 f64 | — |
string | String &str | 있음 ★ |
Uint8Array | Vec<u8> | 있음 |
Uint8Array | &[u8] | 있음 |
| 메모리 뷰 | — | 없음 ★ |
Array | Vec<T> | 있음 |
object | JsValue·serde | 있음 |
| 클래스 | #[wasm_bindgen] | 핸들 |
★ WASM 은 애드온과 다릅니다
WASM 은 '자기만의 선형 메모리' 를 갖습니다.
JS 가 그 메모리를 직접 볼 수는 있지만, JS 힙의 객체를
WASM 이 직접 보지는 못합니다.
→ 문자열·배열은 반드시 WASM 메모리로 복사됩니다.
대신 반대 방향은 제로카피가 가능합니다 ↓
WASM 메모리를 JS 의 Uint8Array 로 '뷰' 하기
제로카피 뷰 만들기 ★
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Image { data: Vec<u8>, w: u32, h: u32 }
#[wasm_bindgen]
impl Image {
#[wasm_bindgen(constructor)]
pub fn new(w: u32, h: u32) -> Image {
Image { data: vec![0; (w * h * 4) as usize], w, h }
}
// ★ WASM 메모리 안의 주소를 알려 줍니다
pub fn data_ptr(&self) -> *const u8 { self.data.as_ptr() }
pub fn data_len(&self) -> usize { self.data.len() }
pub fn invert(&mut self) { for b in &mut self.data { *b = !*b; } }
}
import init, { Image } from './pkg/fastwasm.js'
const wasm = await init()
const img = new Image(640, 480)
// WASM 메모리를 그대로 보는 뷰 — 복사 없음 ★
const view = new Uint8ClampedArray(
wasm.memory.buffer, img.data_ptr(), img.data_len()
)
img.invert() // Rust 가 고치면
ctx.putImageData(new ImageData(view, 640, 480), 0, 0) // JS 가 바로 봅니다
// ❗ 주의: WASM 메모리가 커지면(grow) buffer 가 교체됩니다
// → 뷰가 무효가 됩니다. 할당 후 뷰를 다시 만드세요 ★
wasm.memory.buffer 가 다른 ArrayBuffer 로 교체되어
이전에 만든 TypedArray 가 detached 됩니다.
뷰는 쓰기 직전에 만드세요.
실무에선 이렇게 — JS API 를 Rust 에서 부르기 🔴 실무
web-sys · js-sys
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"console", "Window", "Document", "HtmlCanvasElement",
"CanvasRenderingContext2d", "Performance",
] }
console_error_panic_hook = "0.1" # 패닉을 콘솔에 보이게 ★
use wasm_bindgen::prelude::*;
use web_sys::console;
#[wasm_bindgen(start)]
pub fn main() {
console_error_panic_hook::set_once(); // ★ 반드시 넣으세요
console::log_1(&"WASM 시작".into());
}
#[wasm_bindgen]
pub fn draw(canvas_id: &str) -> Result<(), JsValue> {
let doc = web_sys::window().unwrap().document().unwrap();
let canvas = doc.get_element_by_id(canvas_id).unwrap()
.dyn_into::<web_sys::HtmlCanvasElement>()?;
let ctx = canvas.get_context("2d")?.unwrap()
.dyn_into::<web_sys::CanvasRenderingContext2d>()?;
ctx.fill_rect(10.0, 10.0, 100.0, 50.0);
Ok(())
}
// 임의의 JS 함수 부르기
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
fn alert(s: &str);
type MyClass;
#[wasm_bindgen(method)]
fn my_method(this: &MyClass, x: u32) -> u32;
}
비동기와 Promise
wasm-bindgen-futures = "0.4"
use wasm_bindgen_futures::JsFuture;
#[wasm_bindgen]
pub async fn fetch_json(url: String) -> Result<JsValue, JsValue> {
let win = web_sys::window().unwrap();
let resp = JsFuture::from(win.fetch_with_str(&url)).await?;
let resp: web_sys::Response = resp.dyn_into()?;
JsFuture::from(resp.json()?).await
}
// JS: const data = await fetch_json('/api') ★ Promise 를 반환합니다
★ WASM 의 스레드 제약
· 기본적으로 싱글 스레드입니다
· rayon 을 쓰려면 wasm-bindgen-rayon + SharedArrayBuffer 가 필요하고,
COOP/COEP 헤더를 서버에서 설정해야 합니다 (제약이 큽니다)
· 대개는 Web Worker 에서 WASM 인스턴스를 여러 개 띄우는 편이 현실적입니다
★ 시간 측정
std::time::Instant 는 WASM 에서 패닉합니다 ★
→ web_sys::window().performance().now() 를 쓰거나
instant 크레이트(wasm 대응)를 쓰세요
Instant,
일부 std::net. 크레이트가 이것들을 쓰면 빌드는 되는데 런타임에 패닉합니다.
console_error_panic_hook 을 켜 두는 것이 디버깅의 시작입니다.
한 단계 더 — 글루 코드가 하는 일 🧬 전문가
WASM 은 숫자만 주고받습니다
WebAssembly 명세상 함수 인자는 i32 · i64 · f32 · f64 뿐입니다.
문자열도 객체도 없습니다.
그래서 wasm-bindgen 이 하는 일
① 문자열을 넘길 때
JS 가 UTF-8 로 인코딩 → WASM 메모리에 복사 → (ptr, len) 을 전달
② 문자열을 받을 때
WASM 이 (ptr, len) 반환 → JS 가 그 메모리를 읽어 문자열 생성
③ JS 객체를 넘길 때
'핸들 테이블' 에 넣고 인덱스(i32)를 전달 ★
Rust 의 JsValue 가 그 인덱스입니다
→ 생성되는 fastwasm.js 가 이 변환을 전부 담고 있습니다
# 글루 코드 읽어 보기
cat pkg/fastwasm.js | head -60
★ 성능 의미
· 숫자 전달은 사실상 공짜
· 문자열은 인코딩 + 복사 (짧아도 수백 ns)
· JsValue 는 핸들 테이블 조회
→ 경계를 자주 넘지 말라는 원칙이 여기서도 같습니다 (j01)
WASM 바이너리 들여다보기
# 도구
cargo install wasm-tools
npm i -g wasm-opt # binaryen
# 텍스트 형식으로
wasm-tools print pkg/fastwasm_bg.wasm | head -50
# 무엇을 내보내고 가져오나
wasm-tools print pkg/fastwasm_bg.wasm | grep -E "^\s*\(export|\(import"
# 섹션별 크기
wasm-tools objdump pkg/fastwasm_bg.wasm
# 검증
wasm-tools validate pkg/fastwasm_bg.wasm
# ★ 표준 명령 확인 — 어떤 WASM 기능을 쓰는지
# SIMD · bulk-memory · reference-types 등을 쓰면
# 오래된 브라우저에서 로드가 실패합니다
wasm-tools print *.wasm | grep -c "v128" # SIMD 사용 여부
# 타깃 기능 제한
# RUSTFLAGS="-C target-feature=-simd128" cargo build --target wasm32-unknown-unknown
wasm-pack build --target web --release 한 줄이면
.wasm + 글루 JS + TypeScript 타입이 나옵니다.
문자열·배열은 WASM 메모리로 복사되지만,
WASM 메모리를 JS 가 뷰로 보는 방향은 제로카피입니다 —
다만 메모리가 자라면 뷰가 무효가 되니 쓰기 직전에 만드세요.WASM 크기 줄이기 — 로딩이 곧 성능
브라우저에서는 파일을 받는 시간이 실행 시간보다 클 때가 많습니다. 기본 설정으로 빌드하면 수 MB 가 나오기도 하는데, 몇 가지 설정만으로 수십 KB 까지 줄일 수 있습니다.
효과 순서대로 ★
# ① 릴리즈 빌드 (기본 중의 기본)
wasm-pack build --release
# ② Cargo.toml 프로파일 (🛠️ b06 · b10)
[profile.release]
opt-level = "z" # 크기 우선 ("s" 는 절충, 3 은 속도 우선)
lto = true # 링크 타임 최적화 ★ 효과가 큽니다
codegen-units = 1 # 더 느린 빌드, 더 작은 결과
panic = "abort" # 패닉 언와인딩 코드 제거 ★
strip = true # 심볼 제거
# ③ wasm-opt (binaryen) — wasm-pack 이 자동 실행합니다
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-bulk-memory"]
# ④ 의존성 줄이기 — 가장 효과가 큰 경우가 많습니다
# default-features = false 로 안 쓰는 기능 끄기 (🛠️ b04)
serde = { version = "1", default-features = false, features = ["derive"] }
# 결과 확인
ls -lh pkg/*.wasm
gzip -c pkg/*.wasm | wc -c # ★ 실제 전송 크기는 압축 후입니다
brotli -c pkg/*.wasm | wc -c # 브라우저는 보통 br 을 씁니다
무엇이 큰지 찾기 ★
# twiggy — 함수별 크기 분석
cargo install twiggy
twiggy top pkg/fastwasm_bg.wasm # 큰 것부터
twiggy top -n 20 pkg/fastwasm_bg.wasm
twiggy dominators pkg/fastwasm_bg.wasm # 무엇이 무엇을 끌고 오나 ★
twiggy paths pkg/fastwasm_bg.wasm 함수이름 # 왜 포함됐나
# 출력 예
# Shallow Bytes │ Shallow % │ Item
# ───────────────┼───────────┼─────────────────────────
# 38 210 │ 18.2 % │ core::fmt::… (포매팅)
# 21 044 │ 10.0 % │ regex::…
# 12 803 │ 6.1 % │ std::panicking::…
# ★ 자주 나오는 범인들
# · core::fmt (format! · Display) — println! 을 지우면 크게 줍니다
# · panic 메시지 문자열 — panic = "abort" + panic_immediate_abort
# · regex · chrono · serde_json 같은 큰 크레이트
# · 백트레이스 코드
format!·panic! 메시지가
바이너리의 절반을 차지하는 경우가 많습니다.
WASM 빌드에서는 에러 메시지를 코드로 바꾸는 것만으로도 크게 줍니다.
이렇게도 쓸 수 있다 — 로딩 전략 🟡 중급
스트리밍 컴파일 ★
// ❌ 전부 받고 나서 컴파일
const bytes = await fetch('/f.wasm').then(r => r.arrayBuffer())
const module = await WebAssembly.compile(bytes)
// ✅ 받으면서 동시에 컴파일 — 체감이 크게 다릅니다 ★
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/f.wasm'), imports
)
// wasm-pack 의 init() 은 이미 이걸 씁니다
import init from './pkg/fastwasm.js'
await init() // 내부적으로 instantiateStreaming
// ❗ 서버가 Content-Type: application/wasm 을 보내야 합니다
// 아니면 조용히 느린 경로로 폴백합니다 ★
# 서버 설정 확인
curl -I https://example.com/f.wasm | grep -i content-type
# content-type: application/wasm ✅
# nginx
# types { application/wasm wasm; }
# Vercel/Netlify 는 기본 지원
언제 로드할 것인가
// ① 지연 로드 — 필요할 때만 ★
let wasmPromise = null
async function getWasm() {
wasmPromise ??= import('./pkg/fastwasm.js').then(m => m.default().then(() => m))
return wasmPromise
}
button.onclick = async () => {
const wasm = await getWasm() // 클릭할 때 로드
wasm.process(data)
}
// ② 미리 받아 두기 (사용은 나중)
<link rel="preload" href="/pkg/fastwasm_bg.wasm" as="fetch" crossorigin>
// ③ 캐시 — 해시가 든 파일명 + 긴 max-age
// /pkg/fastwasm_bg.a3f2c1.wasm → Cache-Control: max-age=31536000, immutable
// ④ Web Worker 에서 로드 — 메인 스레드를 막지 않습니다 ★
const w = new Worker('./worker.js', { type: 'module' })
// worker.js 안에서 init() 하고 postMessage 로 통신
// ★ 판단
// 핵심 기능이면 초기 로드, 부가 기능이면 지연 로드.
// 50KB 미만이면 크게 고민하지 않아도 됩니다.
실무에선 이렇게 — 번들러와 함께 쓰기 🔴 실무
Vite · webpack 설정
wasm-pack build --target bundler # 번들러용으로 빌드
// vite.config.js
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
export default {
plugins: [wasm(), topLevelAwait()],
optimizeDeps: { exclude: ['fastwasm'] }, // ★ 사전 번들 제외
}
// webpack 5 — 실험적 기능을 켜야 합니다
module.exports = {
experiments: { asyncWebAssembly: true },
}
★ 자주 겪는 문제
· "Cannot use import statement outside a module"
→ --target 을 잘못 고른 것입니다 (j08)
· WASM 이 base64 로 인라인돼 크기가 33% 커짐
→ 번들러 설정에서 asset 으로 내보내게 하세요
· 개발 서버에서는 되는데 빌드하면 404
→ publicPath / base 설정 확인
· Next.js
→ next.config.js 에서 experiments.asyncWebAssembly 설정
Node · 엣지에서 쓰기
// Node (--target nodejs)
const wasm = require('./pkg/fastwasm.js')
console.log(wasm.sum_squares(1000)) // init 불필요 (동기 로드)
// Cloudflare Workers
import wasmModule from './fastwasm_bg.wasm'
import * as bindings from './fastwasm_bg.js'
const instance = await WebAssembly.instantiate(wasmModule, {
'./fastwasm_bg.js': bindings
})
bindings.__wbg_set_wasm(instance.exports)
// Deno
import init, { sum_squares } from './pkg/fastwasm.js'
await init()
★ 엣지 런타임의 제약
· Cloudflare Workers: WASM 모듈 크기 제한 (플랜에 따라 다름)
· 시작 시간이 요금·지연에 직결됩니다 → 크기가 곧 비용 ★
· 파일 시스템·스레드 없음 (j08 과 동일)
★ Node 에서 WASM vs 애드온 (j01)
· 배포 단순성: WASM 압승 (파일 하나)
· 성능: 애드온이 보통 우세하지만 격차는 작습니다
· 라이브러리로 공개할 거면 WASM 을 먼저 고려하세요
한 단계 더 — 성능을 더 끌어올리기 🧬 전문가
SIMD 와 타깃 기능
# WASM SIMD (128비트) 켜기 — 수치 연산에서 효과가 큽니다
RUSTFLAGS="-C target-feature=+simd128" wasm-pack build --release
# 지원 확인 (브라우저)
# WebAssembly.validate(새 SIMD 명령이 든 모듈)
# 또는 wasm-feature-detect 라이브러리
# ★ 두 벌 빌드 + 런타임 분기가 안전한 방법입니다
# simd 버전과 기본 버전을 만들어 두고 감지해서 고릅니다
# 다른 유용한 기능
# +bulk-memory memcpy 가 빨라집니다 (기본 켜짐)
# +mutable-globals
# +reference-types
# 메모리 초기 크기 지정 (grow 를 줄이면 뷰 무효화도 줄어듭니다 · j08)
RUSTFLAGS="-C link-arg=--initial-memory=16777216" cargo build
할당자 교체와 no_std
# 기본 할당자(dlmalloc)는 코드가 큽니다
[dependencies]
wee_alloc = "0.4" # 아주 작지만 느립니다 (유지보수 주의)
# 또는 talc · lol_alloc
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// ★ 트레이드오프
// wee_alloc: 바이너리 ~10KB 절약, 할당 성능은 떨어집니다
// 할당이 잦은 코드라면 기본 할당자가 낫습니다 — 측정하세요 (⚡ e01)
// 진짜 작게 — 표준 라이브러리 없이
#![no_std]
extern crate alloc;
// 수 KB 까지 줄지만, 쓸 수 있는 것이 크게 제한됩니다.
// 대부분의 실무 코드에는 과합니다.
★ 크기 목표 감각 (참고용 — 코드에 따라 크게 다릅니다)
hello world ~ 수십 KB
간단한 파싱 로직 ~ 100 KB
regex 포함 ~ 300 KB+
serde_json 포함 ~ 200 KB+
이미지 처리(image 크레이트) ~ 1 MB+
→ gzip/brotli 로 보통 1/3 로 줄어듭니다.
실제 전송 크기로 판단하세요.
opt-level = "z" · lto · panic = "abort" · wasm-opt -Oz 네 가지가 기본이고,
twiggy 로 무엇이 큰지 찾는 것이 실제 해결책입니다.
로딩은 instantiateStreaming + 올바른 Content-Type 으로,
부가 기능이면 지연 로드하세요. 실제 크기는 gzip/brotli 후로 판단합니다.prebuild 배포 — 플랫폼 매트릭스
네이티브 애드온의 마지막 관문입니다.
사용자가 Rust 없이 npm install 만으로 쓸 수 있어야 합니다.
플랫폼별로 미리 빌드해 서브 패키지로 배포하는 것이 요즘 표준입니다.
배포 구조 ★
npm 에 올라가는 패키지들
fastmod 메인 (index.js + index.d.ts, 바이너리 없음)
├─ fastmod-linux-x64-gnu optionalDependencies
├─ fastmod-linux-x64-musl
├─ fastmod-linux-arm64-gnu
├─ fastmod-darwin-x64
├─ fastmod-darwin-arm64
└─ fastmod-win32-x64-msvc
★ 동작
npm install fastmod
→ npm 이 os/cpu 필드를 보고 '내 플랫폼 것만' 설치합니다
→ index.js 가 그것을 require 합니다 (j02)
→ 다른 플랫폼 패키지는 optional 이라 실패해도 무시됩니다
// fastmod-linux-x64-gnu/package.json
{
"name": "fastmod-linux-x64-gnu",
"version": "0.1.0",
"os": ["linux"],
"cpu": ["x64"],
"libc": ["glibc"],
"main": "fastmod.linux-x64-gnu.node",
"files": ["fastmod.linux-x64-gnu.node"]
}
CI 로 전부 만들기
# napi-rs 가 워크플로를 생성해 줍니다
npx @napi-rs/cli create-npm-dirs # 서브 패키지 폴더 생성
# .github/workflows/CI.yml 은 create napi 가 이미 만들어 뒀습니다
# 흐름
# ① 플랫폼별 job 에서 napi build --platform --release
# ② 아티팩트 업로드
# ③ 마지막 job 에서 전부 내려받아
# napi artifacts — .node 를 각 npm 폴더로 배치
# napi prepublish — package.json 들을 갱신
# npm publish — 서브 패키지 + 메인 패키지
★ 빌드 방법 (플랫폼별)
linux x64 gnu ubuntu 러너에서 그대로
linux x64 musl alpine 컨테이너 또는 zig 링커 ★
linux arm64 cross · zigbuild · ARM 러너 (🛠️ b07)
darwin x64/arm64 macos 러너 (둘 다 빌드 가능)
win32 x64 windows 러너
→ napi-rs 의 기본 워크플로가 이걸 전부 담고 있습니다.
처음에는 필요한 플랫폼만 남기고 줄이세요 (CI 시간 절약)
Error loading shared library 가 납니다.
이렇게도 쓸 수 있다 — 설치 실패를 줄이기 🟡 중급
폴백 경로 만들기 ★
// index.js — 순서대로 시도
function loadNative() {
// ① 플랫폼 서브 패키지
try { return require('fastmod-linux-x64-gnu') } catch {}
// ② 로컬 빌드 (개발 중 · 소스에서 설치)
try { return require('./fastmod.node') } catch {}
// ③ WASM 폴백 ★ 어떤 플랫폼에서도 동작
try { return require('./wasm/fastmod.js') } catch {}
// ④ 순수 JS 폴백
return require('./js-fallback.js')
}
module.exports = loadNative()
★ WASM 폴백이 특히 좋은 전략입니다 (j01)
같은 Rust 코드를 WASM 으로도 빌드해 두면
지원하지 않는 플랫폼에서도 '느리지만 동작' 합니다.
napi-rs 는 wasm32-wasi 타깃도 지원합니다.
★ 파이썬 쪽과 같은 원칙입니다 (🐍 y01 · y11)
"되돌릴 수 있게 · 없어도 동작하게"
// package.json — 소스 빌드 폴백
{
"scripts": {
"install": "napi-postinstall || node scripts/build-from-source.js"
}
}
// ❗ 다만 postinstall 빌드는 사용자에게 Rust 를 요구합니다.
// 최후의 수단으로만 두고, README 에 명시하세요.
사용자가 겪는 오류와 원인
"Cannot find module 'fastmod-linux-x64-gnu'"
· 그 플랫폼 패키지를 배포 안 했거나
· npm i --no-optional 로 설치했거나
· lockfile 이 다른 플랫폼에서 만들어졌거나 ★
→ npm 은 optionalDependencies 를 lock 에 기록합니다.
CI(linux)에서 만든 lock 을 맥에서 쓰면 문제가 됩니다.
npm 8.6+ 는 개선됐지만, yarn/pnpm 은 설정을 확인하세요.
"Error relocating ...: symbol not found" (Alpine)
· musl 빌드가 없어 glibc 빌드를 로드한 것
"was compiled against a different Node.js version"
· N-API 를 안 쓰고 V8 API 를 쓴 애드온 (j01)
· napi-rs 를 쓰면 이 오류는 나지 않습니다 ★
"invalid ELF header"
· 아키텍처 불일치 (arm64 에서 x64 바이너리)
· 애플 실리콘 맥에서 Rosetta 로 도는 Node 를 쓸 때도
★ 진단 명령
node -p "process.platform + '-' + process.arch"
node -p "process.report.getReport().header.glibcVersionRuntime"
ldd $(node -p "require.resolve('fastmod-linux-x64-gnu')")
실무에선 이렇게 — 배포 체크리스트 🔴 실무
배포 전 확인 ★
□ 필요한 플랫폼을 전부 빌드했는가
최소: linux-x64-gnu · linux-x64-musl · darwin-arm64 · win32-x64
□ 실제로 설치해 봤는가 (각 플랫폼에서)
docker run --rm -it node:20-alpine sh -c "npm i fastmod && node -e '...'"
□ index.d.ts 가 패키지에 들어갔는가
npm pack --dry-run 으로 파일 목록 확인 ★
□ files 필드에 필요한 것만 있는가 (src/ · target/ 제외)
□ 버전이 서브 패키지와 일치하는가
□ engines.node 가 N-API 버전과 맞는가
□ 바이너리 크기가 합리적인가 (🛠️ b10)
strip = "symbols" · lto = true
□ 라이선스 파일이 포함됐는가
□ 첫 배포는 npm publish --tag next 로 미리 검증 ★
설치 검증 스크립트
#!/usr/bin/env bash
# scripts/verify-install.sh — 여러 환경에서 실제 설치 테스트
set -e
for img in node:20 node:20-alpine node:18; do
echo "=== $img ==="
docker run --rm "$img" sh -c "
npm i -g fastmod@latest &&
node -e \"const f=require('fastmod'); console.log(f.sumSquares(10))\"
"
done
# arm64 도 (도커 buildx / QEMU)
docker run --rm --platform linux/arm64 node:20 sh -c "npm i fastmod && ..."
★ CI 에 넣으세요 (🛠️ b08)
배포 job 다음에 '설치 검증' job 을 두고,
실패하면 npm dist-tag 로 되돌립니다
npm dist-tag add fastmod@0.9.0 latest
★ 되돌릴 수 있게
· npm unpublish 는 24시간 제한이 있고 권장되지 않습니다
· 대신 패치 버전을 올려 다시 배포하는 것이 정석입니다
· deprecate 로 경고를 남길 수 있습니다
npm deprecate fastmod@1.0.1 "설치 문제가 있습니다. 1.0.2 를 쓰세요"
한 단계 더 — 바이너리 크기와 신뢰 🧬 전문가
크기 줄이기 (🛠️ b10 과 같은 기법)
[profile.release]
opt-level = 3 # 애드온은 보통 속도 우선 (WASM 과 다릅니다)
lto = "fat"
codegen-units = 1
strip = "symbols" # ★ 심볼 제거 — 크기가 크게 줍니다
panic = "abort" # 애드온에서도 대체로 안전합니다
# (단, 패닉이 프로세스를 죽입니다 — j03 의 에러 처리를 철저히)
# 확인
ls -lh *.node
cargo bloat --release --crates # 어느 크레이트가 큰지 ★
cargo bloat --release -n 20 # 어느 함수가 큰지
# npm 패키지 크기
npm pack --dry-run
npx package-size fastmod
# ★ 실무 감각
# .node 파일이 수 MB 면 사용자가 체감합니다.
# 특히 CI 에서 매번 내려받는 경우 (수천 번/일) 비용이 됩니다.
공급망 신뢰 ★
네이티브 바이너리를 배포한다는 것은
사용자 머신에서 임의 코드를 실행한다는 뜻입니다.
그래서 신뢰 장치가 중요합니다.
□ CI 에서만 배포 (로컬에서 npm publish 금지)
npm 의 Trusted Publishing / provenance 를 켜세요
npm publish --provenance
→ npm 페이지에 '어느 워크플로에서 빌드됐는지' 가 표시됩니다 ★
□ 2FA 를 켜기 (npm 계정 · 자동화 토큰은 최소 권한)
□ 재현 가능한 빌드에 가깝게
rust-toolchain.toml 로 컴파일러 고정 (r01)
Cargo.lock 커밋 (🛠️ b03)
□ 의존성 감사
cargo audit · cargo deny (🛠️ b09)
npm audit
□ 서브 패키지도 같은 조직/계정에서 배포
이름 선점(typosquatting) 방지를 위해 미리 확보해 두세요
★ 사용자 입장의 검증
npm 페이지의 provenance 배지 확인
또는 소스에서 직접 빌드 (cargo build --release)
optionalDependencies 가 요즘 표준이고,
napi-rs 의 CI 템플릿이 이걸 전부 만들어 줍니다.
musl(Alpine)을 빠뜨리지 마세요 — 문의의 대부분이 여기서 옵니다.
WASM 폴백을 두면 어떤 플랫폼에서도 동작하고,
CI 에서만 배포 + provenance 로 신뢰를 확보하세요. 다음은 🔗 C ABI · FFI 탭입니다.한 번 만들어
모든 언어에서
PyO3 는 파이썬 전용이고 napi-rs 는 Node 전용입니다. C ABI 로 내보내면 파이썬·Node·C#·자바·Go·C++ 어디서나 같은 라이브러리를 부릅니다. 대신 안전장치가 전부 사라지므로 규약을 스스로 지켜야 합니다 — 그 규약이 이 탭의 내용입니다.
C ABI 가 공용어인 이유
언어마다 문법도 런타임도 다르지만 “C 함수를 부르는 법”은 모두 압니다. 그래서 언어 간 연동은 대부분 C 인터페이스를 경유합니다. Rust 는 런타임이 없어 이 역할에 특히 잘 맞습니다.
ABI 란 무엇인가
API — 소스 코드 수준의 약속 (함수 이름 · 인자 타입)
ABI — 기계어 수준의 약속 ★
· 인자를 어느 레지스터·스택에 놓는가
· 반환값은 어디로 오는가
· 구조체 필드가 메모리에 어떻게 배치되는가
· 함수 이름이 심볼로 어떻게 적히는가
· 누가 스택을 정리하는가
C ABI 는 이것들이 '플랫폼마다 고정' 돼 있습니다.
x86-64 SysV (리눅스·맥) · Win64 (윈도우) · AAPCS64 (ARM)
★ 그래서 C 로 컴파일된 라이브러리는
어떤 언어에서도 부를 수 있습니다.
Rust 는 "C 처럼 보이는 함수"를 만들 수 있습니다.
언제 C ABI 를 고르나 ★
| 상황 | 권장 |
|---|---|
| 파이썬만 | PyO3 (🐍 y02) |
| Node 만 | napi-rs (🟨 j02) |
| 여러 언어 | C ABI |
| C/C++ 앱에 넣기 | C ABI |
| C# · 자바 · Go | C ABI |
| 게임 엔진 플러그인 | C ABI |
| 기존 C 라이브러리 쓰기 | bindgen (f08) |
| 브라우저 | WASM (🟨 j08) |
★ C ABI 의 대가
· Option · Result · String · Vec 을 못 씁니다 (f03 · f06)
· 메모리 해제 책임을 문서로 정해야 합니다 (f06)
· 패닉이 경계를 넘으면 안 됩니다 (f09)
· 제네릭·트레이트가 사라집니다 (구체 함수만)
· 컴파일러가 지켜 주던 것을 사람이 지켜야 합니다 ★
★ 그럼에도 얻는 것
· 하나의 .so/.dll/.dylib 로 모든 언어 지원
· 언어별 바인딩 코드를 각 언어에서 얇게 작성
· 버전을 오래 유지할 수 있습니다 (안정된 인터페이스)
이렇게도 쓸 수 있다 — 전체 그림 그리기 🟡 중급
계층 구조 ★
.so/.dll 을 부릅니다★ 파일 구조 권장
src/
lib.rs pub 로직 (순수 Rust · cargo test 가 그대로 됨)
ffi.rs #[no_mangle] extern "C" 함수만 — 얇게 ★
include/
mylib.h cbindgen 이 생성 (f04)
bindings/
python/ node/ csharp/ 각 언어의 얇은 래퍼
인터페이스 설계 5원칙 ★
① 좁게 — 함수 10개 이하를 목표로
내부 타입을 노출하지 말고 '핸들 + 동작' 으로 (f03 의 불투명 포인터)
② 안정되게 — 한 번 배포한 시그니처는 바꾸지 않는다
기능 추가는 새 함수로. 구조체는 버전 필드나 크기 필드를 넣어 확장 (f09)
③ 소유권을 명시 — 누가 할당하고 누가 해제하는가 (f06)
이름으로 드러내세요: mylib_string_free · mylib_buffer_free
④ 실패를 코드로 — 예외도 Result 도 없습니다 (f09)
0 = 성공, 음수 = 실패. 마지막 에러 메시지를 얻는 함수를 따로 제공
⑤ 패닉을 가두어라 — 경계 밖으로 나가면 UB (f09)
모든 extern "C" 함수를 catch_unwind 로 감싸세요
★ 이 다섯 가지가 이 탭 전체의 목차이기도 합니다.
실무에선 이렇게 — 무엇을 내보낼지 정한다 🔴 실무
API 를 좁히는 방법
// ❌ Rust 내부 구조를 그대로 노출
#[no_mangle] pub extern "C" fn get_config() -> *const Config;
#[no_mangle] pub extern "C" fn config_get_host(c: *const Config) -> *const c_char;
#[no_mangle] pub extern "C" fn config_get_port(c: *const Config) -> u16;
// … 필드가 늘 때마다 함수가 늘어납니다
// ✅ '하려는 일' 단위로
#[no_mangle] pub extern "C" fn mylib_open(path: *const c_char) -> *mut Handle;
#[no_mangle] pub extern "C" fn mylib_process(h: *mut Handle,
input: *const u8, in_len: usize,
out: *mut u8, out_cap: usize,
out_len: *mut usize) -> i32;
#[no_mangle] pub extern "C" fn mylib_close(h: *mut Handle);
#[no_mangle] pub extern "C" fn mylib_last_error(buf: *mut c_char, cap: usize) -> usize;
#[no_mangle] pub extern "C" fn mylib_version() -> u32;
// ★ 다섯 개면 충분한 경우가 많습니다.
// 설정은 JSON 문자열 하나로 받으면 구조체 ABI 문제도 사라집니다 ★
#[no_mangle] pub extern "C" fn mylib_configure(json: *const c_char) -> i32;
크레이트 설정
[lib]
name = "mylib"
crate-type = ["cdylib", "staticlib", "rlib"]
# ^^^^^^ ^^^^^^^^^^ ^^^^
# cdylib .so/.dll/.dylib — 동적 링크 (대부분 이것) ★
# staticlib .a/.lib — C/C++ 앱에 정적으로 넣을 때
# rlib Rust 크레이트로도 쓰기 (cargo test 를 위해 두면 편합니다)
[dependencies]
libc = "0.2" # c_char · c_void 등
[build-dependencies]
cbindgen = "0.27" # 헤더 자동 생성 (f04)
[profile.release]
lto = true
strip = "symbols" # ★ 내부 심볼 감추기 (🛠️ b10)
cargo build --release
ls target/release/
# libmylib.so (리눅스)
# libmylib.dylib (맥)
# mylib.dll (윈도우) + mylib.dll.lib
# libmylib.a (staticlib)
한 단계 더 — 호출 규약의 실제 🧬 전문가
extern "C" 가 지정하는 것
extern "C" fn f(a: i32, b: i32) -> i32 { a + b }
// x86-64 SysV (리눅스·맥) 에서
// a → edi, b → esi, 반환 → eax
// 호출자가 스택을 정리
//
// Win64 에서
// a → ecx, b → edx, 반환 → eax
// 섀도 스페이스 32바이트 필요
//
// → extern "C" 는 "그 플랫폼의 C 규약을 따르라" 는 뜻입니다.
// 플랫폼별 차이는 컴파일러가 처리합니다. ★
// 다른 규약들 (특수한 경우)
extern "system" fn g(); // 윈도우 API (Win32 에서는 stdcall)
extern "stdcall" fn h(); // 32비트 윈도우 레거시
extern "Rust" fn i(); // 기본값 (불안정 · 🔬 d11)
// 확인
// objdump -d target/release/libmylib.so | grep -A 20 "<mylib_process>:"
런타임이 없다는 이점
Rust 가 C ABI 라이브러리로 특히 좋은 이유
· GC 가 없습니다 — 초기화·정지 시간이 없습니다
· 런타임 초기화가 필요 없습니다 (Go 는 런타임을 띄워야 합니다)
· 예외 테이블 외에는 숨은 비용이 없습니다
· 정적 링크가 자연스럽습니다 (musl 로 완전 정적도 가능 · 🛠️ b07)
★ 비교
C/C++ 같은 수준. 대신 메모리 안전은 사람이 책임
Go cgo 경계 비용이 크고 런타임이 따라옵니다
C#/자바 런타임(CLR/JVM)을 임베드해야 합니다
Rust C 와 같은 비용 + 안전한 내부 ★
★ 남는 의존성 확인
ldd target/release/libmylib.so
linux-vdso · libgcc_s · libc · libm 정도
완전 정적으로 (musl)
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
#[no_mangle] · extern "C" — 내보내기
Rust 함수를 C 가 부를 수 있게 만드는 데 필요한 것은 두 줄입니다.
extern "C" 로 호출 규약을, #[no_mangle] 로 이름을 지정합니다.
나머지는 어떤 타입을 주고받을 수 있는가의 문제입니다.
최소 예제
// src/lib.rs
#[no_mangle]
pub extern "C" fn mylib_add(a: i32, b: i32) -> i32 {
a + b
}
// #[no_mangle] 이름을 그대로 심볼로 (안 붙이면 _ZN5mylib3add17h... 가 됩니다)
// extern "C" C 호출 규약
// pub 크레이트 밖으로
// 2024 에디션부터는 unsafe 표기를 씁니다
// #[unsafe(no_mangle)]
// pub unsafe extern "C" fn mylib_add(a: i32, b: i32) -> i32 { a + b }
cargo build --release
# 심볼 확인 ★ 문제가 생기면 항상 여기부터
nm -D target/release/libmylib.so | grep mylib
# 0000000000007a10 T mylib_add ← T = 텍스트(코드), 전역
# 윈도우
dumpbin /exports target\release\mylib.dll
# 맥
nm -gU target/release/libmylib.dylib | grep mylib
#[no_mangle] 이름은 전역입니다 — 다른 라이브러리와 겹치면
어느 것이 불릴지 알 수 없습니다.
반드시 mylib_ 같은 접두사를 붙이세요.
쓸 수 있는 타입 ★
| C | Rust |
|---|---|
int32_t | i32 |
uint64_t | u64 |
size_t | usize |
double | f64 |
bool | bool ★ |
char* | *const c_char |
void* | *mut c_void |
T* | *mut T |
| 구조체 | #[repr(C)] (f03) |
| 함수 포인터 | extern "C" fn |
| 널 가능 포인터 | Option<&T> ★ |
// ❌ 절대 쓰면 안 되는 것들 (ABI 가 정의되지 않음)
// pub extern "C" fn bad() -> String; // Rust 전용 레이아웃
// pub extern "C" fn bad2() -> Vec<u8>;
// pub extern "C" fn bad3() -> Result<i32, E>;
// pub extern "C" fn bad4(s: &str); // 팻 포인터 (🔐 o04)
// pub extern "C" fn bad5<T>(x: T); // 제네릭
// ✅ 대신
// String → *mut c_char + 해제 함수 (f06)
// Vec<u8> → *mut u8 + len 또는 출력 버퍼
// Result → 에러 코드 i32 (f09)
// &str → *const u8 + usize
// bool 은 Rust 와 C99 모두 1바이트라 안전합니다 ★
// clippy 가 잡아 줍니다
// cargo clippy -- -W clippy::not_unsafe_ptr_arg_deref
이렇게도 쓸 수 있다 — 포인터를 안전하게 다루기 🟡 중급
슬라이스와 문자열 받기
use std::ffi::{c_char, CStr};
use std::slice;
#[no_mangle]
pub unsafe extern "C" fn mylib_checksum(data: *const u8, len: usize) -> u32 {
if data.is_null() || len == 0 { return 0; } // ★ 널 검사 먼저
let bytes = slice::from_raw_parts(data, len); // unsafe (🔬 d03)
bytes.iter().fold(0u32, |a, b| a.wrapping_add(*b as u32))
}
#[no_mangle]
pub unsafe extern "C" fn mylib_count_words(s: *const c_char) -> usize {
if s.is_null() { return 0; }
match CStr::from_ptr(s).to_str() { // UTF-8 검증
Ok(text) => text.split_whitespace().count(),
Err(_) => 0, // 잘못된 UTF-8
}
}
// ★ 안전한 래퍼로 감싸는 습관 (🔬 d08)
#[no_mangle]
pub unsafe extern "C" fn mylib_process(data: *const u8, len: usize) -> i32 {
let Some(bytes) = to_slice(data, len) else { return -1 };
core_process(bytes) // 여기부터는 안전한 Rust
}
unsafe fn to_slice<'a>(p: *const u8, len: usize) -> Option<&'a [u8]> {
if p.is_null() { return None; }
if len == 0 { return Some(&[]); }
Some(slice::from_raw_parts(p, len))
}
len == 0 일 때 널 포인터가 올 수 있습니다 —
slice::from_raw_parts(null, 0) 은 UB 입니다(정렬된 비널 포인터를 요구).
길이 0 을 먼저 처리하는 습관을 들이세요.
출력을 돌려주는 방법 세 가지
// ① 호출자가 버퍼를 준다 ★ 가장 안전하고 흔합니다
#[no_mangle]
pub unsafe extern "C" fn mylib_encode(
input: *const u8, in_len: usize,
out: *mut u8, out_cap: usize, out_len: *mut usize,
) -> i32 {
let Some(src) = to_slice(input, in_len) else { return -1 };
let result = encode(src);
if out.is_null() || out_cap < result.len() {
*out_len = result.len(); // ★ 필요한 크기를 알려 줍니다
return -2; // 버퍼 부족
}
std::ptr::copy_nonoverlapping(result.as_ptr(), out, result.len());
*out_len = result.len();
0
}
// 호출 패턴: 크기를 먼저 물어보고(out=NULL) → 할당 → 다시 호출
// ② 라이브러리가 할당하고 해제 함수를 제공 (f06)
#[no_mangle]
pub extern "C" fn mylib_encode_alloc(input: *const u8, len: usize,
out_len: *mut usize) -> *mut u8;
#[no_mangle]
pub extern "C" fn mylib_free(ptr: *mut u8, len: usize);
// ③ 콜백으로 흘려보내기 (f07)
#[no_mangle]
pub extern "C" fn mylib_encode_stream(input: *const u8, len: usize,
cb: extern "C" fn(*const u8, usize, *mut c_void), user: *mut c_void) -> i32;
실무에선 이렇게 — 심볼 문제를 진단한다 🔴 실무
"symbol not found" 진단 순서 ★
# ① 심볼이 정말 있나
nm -D libmylib.so | grep mylib_add
# 비어 있으면 → #[no_mangle] 을 빠뜨렸거나 pub 이 아님
# ② 이름이 정확한가 (오타 · 접두사)
nm -D libmylib.so | grep -i add
# ③ 맥/윈도우의 언더스코어
# 맥: C 심볼 앞에 _ 가 붙습니다 (nm 출력에는 보이지만 dlsym 은 그대로)
# 윈도우 32비트 stdcall: _name@8 형태
# ④ 라이브러리를 정말 로드했나
LD_DEBUG=libs python -c "import ctypes; ctypes.CDLL('./libmylib.so')"
# ⑤ 아키텍처가 맞나
file libmylib.so
# ELF 64-bit ... x86-64 ← 32비트 파이썬이면 로드 실패
# ⑥ 의존 라이브러리가 있나
ldd libmylib.so | grep "not found"
# ⑦ 최적화로 지워졌나 — 안 쓰이는 함수를 링커가 제거할 수 있습니다
# #[no_mangle] pub 이면 보통 남지만, LTO 설정에 따라 다릅니다
# 확실히 하려면 #[used] 나 링커 스크립트를 쓰세요
심볼 가시성 제어
기본적으로 cdylib 은 #[no_mangle] pub 함수만 내보냅니다.
하지만 정적 링크한 C 라이브러리의 심볼이 함께 새어 나갈 수 있습니다 (f08).
★ 왜 문제인가
같은 프로세스에 다른 버전의 같은 라이브러리가 있으면
심볼이 충돌해 엉뚱한 함수가 불릴 수 있습니다.
(파이썬에서 여러 확장이 각자 libz 를 정적 링크한 경우 등)
★ 대응
# 리눅스 — 버전 스크립트로 내보낼 심볼을 지정
# mylib.map
{ global: mylib_*; local: *; };
# .cargo/config.toml
# rustflags = ["-C", "link-arg=-Wl,--version-script=mylib.map"]
# 맥 — 내보내기 목록
# -Wl,-exported_symbols_list,exported.txt
# 확인
nm -D libmylib.so | grep " T " | wc -l # 내보내는 심볼 개수 ★
strip 과 버전 스크립트로 정리하면 크기도 줄고 충돌도 사라집니다.
한 단계 더 — 맹글링과 링크의 안쪽 🧬 전문가
이름이 어떻게 바뀌나
# Rust 는 이름에 크레이트·모듈·타입 해시를 넣습니다 (같은 이름 허용을 위해)
nm target/release/libmylib.rlib | grep add
# _ZN5mylib5inner3add17h8f3a2c1b9e4d5f6aE
# 디맹글
rustfilt <<< "_ZN5mylib5inner3add17h8f3a2c1b9e4d5f6aE"
# mylib::inner::add::h8f3a2c1b9e4d5f6a
# C++ 도 맹글합니다 (오버로딩 때문)
c++filt <<< "_Z3addii"
# add(int, int)
# ★ #[no_mangle] 은 "그냥 mylib_add 로 적어라" 는 지시입니다.
# 그래서 오버로딩이 불가능하고, 이름 충돌도 직접 피해야 합니다.
# 부분적 대안 — export_name 으로 이름 지정
#[export_name = "mylib_v2_add"]
pub extern "C" fn add_v2(a: i32, b: i32) -> i32 { a + b }
# 버전을 이름에 넣는 기법입니다 (f09)
동적 링크가 실제로 하는 일
dlopen("libmylib.so") 이 하는 일
① 파일을 메모리에 매핑
② 의존 라이브러리를 재귀적으로 로드 (ldd 로 보이는 것들)
③ 재배치(relocation) — 절대 주소를 채움
④ 초기화 함수 실행 (.init_array)
dlsym("mylib_add")
⑤ 심볼 테이블에서 찾아 함수 주소를 반환
★ 실무에서 만나는 것들
· RTLD_GLOBAL vs RTLD_LOCAL — 심볼을 전역에 공개할지
파이썬은 기본 LOCAL 이라 확장끼리 심볼이 안 섞입니다 ★
· LD_LIBRARY_PATH / DYLD_LIBRARY_PATH / PATH(윈도우)
· rpath — 라이브러리 안에 검색 경로를 박아 둠
cargo: rustflags = ["-C", "link-arg=-Wl,-rpath,$ORIGIN"]
· 지연 바인딩 — 처음 호출할 때 주소를 해결 (LD_BIND_NOW 로 끄기)
# 로드 과정 추적
LD_DEBUG=all ./app 2>&1 | head -50
strace -e trace=openat ./app
#[no_mangle] pub extern "C" 세 가지면 내보내기가 끝납니다.
이름은 전역이니 접두사를 반드시 붙이고, String·Vec·&str 은 절대 넘기지 마세요.
포인터를 받으면 널 검사 → 길이 0 처리 → 안전한 래퍼로 넘기는 순서를 지키고,
문제가 생기면 nm -D 로 심볼부터 확인하세요.#[repr(C)] — Rust 는 필드 순서를 바꿉니다
Rust 는 메모리를 아끼려고 구조체 필드를 마음대로 재배치합니다.
그래서 C 와 구조체를 주고받으려면 #[repr(C)] 로 “건드리지 마”라고 해야 합니다.
이걸 빠뜨리면 값이 조용히 깨집니다 — 크래시도 안 나서 더 위험합니다.
cbindgen 으로 생성하세요 — 표기와 헤더가 어긋나는 사고를 막습니다.무슨 일이 일어나나
struct Bad { a: u8, b: u64, c: u8 }
#[repr(C)]
struct Good { a: u8, b: u64, c: u8 }
println!("{} {}", size_of::<Bad>(), size_of::<Good>()); // 16 24
// Bad 의 실제 배치 (컴파일러가 재배치)
// [b: 8바이트][a: 1][c: 1][패딩 6] = 16
// Good 의 배치 (C 규칙 그대로)
// [a: 1][패딩 7][b: 8][c: 1][패딩 7] = 24
// ★ C 쪽에서 Bad 를 { uint8_t a; uint64_t b; uint8_t c; } 로 선언하면
// a 를 읽었는데 b 의 하위 바이트가 나옵니다 — 크래시 없이 값만 틀립니다
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct Point { pub x: f64, pub y: f64 }
#[no_mangle]
pub extern "C" fn mylib_distance(a: Point, b: Point) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
/* C 쪽 */
typedef struct { double x, y; } Point;
double mylib_distance(Point a, Point b);
repr 의 종류 ★
| 표기 | 뜻 |
|---|---|
#[repr(C)] | C 배치 규칙 ★ FFI 기본 |
#[repr(transparent)] | 필드 하나와 동일 배치 |
#[repr(u8)] | enum 판별자 크기 지정 |
#[repr(C, packed)] | 패딩 없음 ★ 위험 |
#[repr(align(64))] | 정렬 강제 (캐시라인) |
| (없음) | Rust 마음대로 |
// transparent — 뉴타입을 C 에 그대로 (r07)
#[repr(transparent)]
pub struct Handle(*mut c_void); // C 에서는 그냥 void*
// C 스타일 enum
#[repr(i32)]
pub enum Status { Ok = 0, NotFound = 1, Error = 2 }
// packed — 네트워크 프로토콜 등
#[repr(C, packed)]
struct Header { kind: u8, len: u32 } // 5바이트 (패딩 없음)
// ❗ packed 필드의 참조를 만들면 UB 입니다 (정렬 위반 · 🔬 d03)
// let r = &h.len; // ❌ 컴파일러가 막습니다
let len = h.len; // ✅ 복사해서 쓰기
let len = std::ptr::addr_of!(h.len); // ✅ 주소만 필요하면
// 정렬 강제 — false sharing 방지 (⚡ e06)
#[repr(align(64))]
struct CacheAligned(AtomicU64);
이렇게도 쓸 수 있다 — enum 과 널 가능 포인터 🟡 중급
Option 이 그대로 통하는 경우 ★
// 널 포인터 최적화 덕분에 이것들은 C 와 배치가 같습니다 (r08)
Option<&T> → T* (None = NULL)
Option<&mut T> → T*
Option<Box<T>> → T*
Option<NonNull<T>> → T*
Option<extern "C" fn(i32)> → void(*)(int) ★ 널 가능 콜백
// 그래서 이렇게 쓸 수 있습니다
#[no_mangle]
pub extern "C" fn mylib_set_callback(cb: Option<extern "C" fn(i32)>) {
match cb {
Some(f) => register(f),
None => unregister(), // C 에서 NULL 을 넘긴 경우 ★
}
}
// 검증 — 크기가 같아야 합니다
const _: () = assert!(
std::mem::size_of::<Option<&u8>>() == std::mem::size_of::<*const u8>()
);
// ★ 이것이 Rust FFI 의 작지만 큰 편의입니다.
// C 의 "널일 수도 있는 포인터" 를 타입으로 표현하면서 비용이 0 입니다.
태그 있는 유니온 표현하기
// Rust enum 은 데이터를 담을 수 있지만 C 에는 그 개념이 없습니다.
// C 스타일로 풀어 써야 합니다.
#[repr(C)]
pub struct Value {
pub kind: ValueKind, // 태그
pub data: ValueData, // 유니온
}
#[repr(i32)]
pub enum ValueKind { Int = 0, Float = 1, Str = 2 }
#[repr(C)]
pub union ValueData {
pub i: i64,
pub f: f64,
pub s: *const c_char,
}
// 읽을 때는 unsafe — 어느 필드가 유효한지 태그로 판단 (🔬 d01)
// ★ 더 간단한 대안들
// ① 종류마다 다른 함수를 제공 (mylib_set_int · mylib_set_str)
// ② JSON 문자열로 주고받기 (f01)
// ③ 태그 + 고정 크기 배열
//
// 실무에서는 ① 이나 ② 가 대부분 낫습니다.
// 유니온은 언어별 바인딩을 전부 복잡하게 만듭니다.
// Rust 1.82+ 는 enum with payload 의 repr(C) 도 정의돼 있지만,
// 다른 언어에서 그걸 정확히 표현하기 어렵습니다.
실무에선 이렇게 — 배치를 테스트로 고정한다 🔴 실무
크기·오프셋을 검증하세요 ★
use std::mem::{size_of, align_of, offset_of};
// 컴파일 타임 검증 — 배치가 바뀌면 빌드가 실패합니다 ★
const _: () = {
assert!(size_of::<Header>() == 16);
assert!(align_of::<Header>() == 8);
assert!(offset_of!(Header, kind) == 0);
assert!(offset_of!(Header, len) == 8);
};
#[test]
fn layout_is_stable() {
assert_eq!(size_of::<Point>(), 16);
assert_eq!(size_of::<Status>(), 4);
assert_eq!(size_of::<Handle>(), size_of::<*mut c_void>());
}
// ★ 왜 중요한가
// 필드를 하나 추가하면 크기가 바뀌고, 이미 배포된 다른 언어의
// 바인딩이 전부 깨집니다. 그것도 조용히 (f09)
// 이 테스트가 있으면 '의도한 변경'인지 리뷰에서 드러납니다.
// C 헤더와 대조하려면 (f04 의 cbindgen 이 대개 해결)
// 빌드에서 헤더를 생성하고, C 컴파일러로 static_assert 를 돌리는
// 테스트를 두는 팀도 있습니다
플랫폼별 차이 주의
★ 플랫폼마다 다른 것들
· usize / size_t 64비트: 8, 32비트·wasm32: 4 ★
· long 리눅스 64: 8, 윈도우 64: 4 ★★ 가장 흔한 함정
· c_char x86: signed, ARM: unsigned
· 구조체 정렬 대체로 같지만 ARM32 등에서 다를 수 있음
· 엔디안 대부분 리틀엔디안이지만 명시하는 것이 안전
★ 대응
· 고정 크기 타입만 쓰세요: i32 · u64 · f64 ★
long · int 를 인터페이스에 두지 마세요
· 크기가 필요하면 u64 로 통일 (usize 대신)
· 바이트 순서는 문서에 명시하고, 필요하면 to_le_bytes (🔐 o04)
// C 타입이 필요하면 libc 를 쓰세요 (플랫폼별로 올바르게 정의됨)
use libc::{c_int, c_long, c_char, size_t};
// 검증
#[test]
fn no_platform_dependent_types() {
// 인터페이스 구조체에 usize 가 없는지 리뷰 항목으로 두세요
}
long 은 4바이트입니다(LLP64). 리눅스는 8바이트(LP64).
C 헤더에서 long 을 보면 어느 플랫폼용인지 반드시 확인하세요 —
Rust 쪽에서 i64 로 매핑하면 윈도우에서 깨집니다.
한 단계 더 — 배치를 눈으로 확인하기 🧬 전문가
도구
# Rust 쪽 — 타입 크기 전부 출력
cargo +nightly rustc --release -- -Zprint-type-sizes | grep -A 5 "Header"
# C 쪽 — 헤더의 배치 확인
cat > t.c <<'EOF'
#include <stdio.h>
#include <stddef.h>
#include "mylib.h"
int main(void) {
printf("size=%zu align=%zu\n", sizeof(Header), _Alignof(Header));
printf("kind@%zu len@%zu\n", offsetof(Header, kind), offsetof(Header, len));
return 0;
}
EOF
gcc t.c -o t && ./t
# 파이썬 ctypes 쪽 (f05)
python -c "
import ctypes
class Header(ctypes.Structure):
_fields_ = [('kind', ctypes.c_uint8), ('len', ctypes.c_uint32)]
print(ctypes.sizeof(Header))
print(Header.len.offset)"
# ★ 세 곳의 숫자가 같아야 합니다. 다르면 그 자리가 버그입니다.
바이트를 직접 들여다보기
// 구조체의 실제 바이트를 덤프해 보기 (디버깅용)
fn dump<T>(v: &T) {
let p = v as *const T as *const u8;
let bytes = unsafe { std::slice::from_raw_parts(p, size_of::<T>()) };
for (i, b) in bytes.iter().enumerate() {
if i % 8 == 0 { print!("\n{i:04x}: "); }
print!("{b:02x} ");
}
println!();
}
let h = Header { kind: 1, len: 0x1234 };
dump(&h);
// 0000: 01 00 00 00 34 12 00 00 ← 패딩과 리틀엔디안이 보입니다 ★
// 안전한 대안 — bytemuck 크레이트
// #[derive(bytemuck::Pod, bytemuck::Zeroable)]
// #[repr(C)]
// struct Header { kind: u32, len: u32 }
// let bytes: &[u8] = bytemuck::bytes_of(&h); // ✅ unsafe 없이
// zerocopy 크레이트도 같은 목적입니다
// 바이트 ↔ 구조체 변환을 컴파일 타임에 검증합니다 (🔬 d08)
bytemuck 이나 zerocopy 를 쓰세요.
unsafe 캐스팅을 직접 쓰지 않고도
정렬·크기·패딩 안전성을 컴파일 타임에 검증해 줍니다.
#[repr(C)].
빠뜨리면 크래시 없이 값만 조용히 깨집니다.
고정 크기 타입(i32·u64·f64)만 쓰고 long·usize 는 피하세요.
크기·오프셋을 const assert 로 고정해 두면 실수로 바뀌는 것을 빌드가 막아 줍니다.cbindgen — 헤더를 손으로 쓰지 마세요
.h 헤더를 손으로 쓰면 반드시 코드와 어긋납니다.
필드 하나 추가했는데 헤더를 안 고치면 조용히 값이 깨집니다(f03).
cbindgen 을 빌드에 붙여 헤더가 항상 코드에서 생성되게 하세요.
붙이는 방법
# Cargo.toml
[build-dependencies]
cbindgen = "0.27"
// build.rs
fn main() {
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let out = std::path::Path::new(&crate_dir).join("include/mylib.h");
std::fs::create_dir_all(out.parent().unwrap()).unwrap();
cbindgen::generate(&crate_dir)
.expect("cbindgen 실패")
.write_to_file(&out);
println!("cargo:rerun-if-changed=src/ffi.rs"); // ★ 바뀔 때만 재생성
}
# cbindgen.toml
language = "C" # 또는 "C++"
include_guard = "MYLIB_H"
autogen_warning = "/* 자동 생성 파일입니다. 고치지 마세요. */"
header = "/* mylib - MIT License */"
cpp_compat = true # C++ 에서도 쓰이게 extern "C" 를 감쌉니다 ★
[export]
prefix = ""
include = ["Point", "Status", "Handle"] # 내보낼 타입을 명시
exclude = ["Internal"]
[enum]
prefix_with_name = true # Status_Ok 형태로 (C 이름 충돌 방지)
[parse]
parse_deps = false
생성 결과
// src/ffi.rs
#[repr(C)]
pub struct Point { pub x: f64, pub y: f64 }
#[repr(i32)]
pub enum Status { Ok = 0, NotFound = 1 }
/// 두 점 사이의 거리를 구합니다.
#[no_mangle]
pub extern "C" fn mylib_distance(a: Point, b: Point) -> f64 { }
/// 핸들을 엽니다. 실패하면 NULL 을 반환합니다.
#[no_mangle]
pub extern "C" fn mylib_open(path: *const c_char) -> *mut Handle { }
/* include/mylib.h — 자동 생성 */
#ifndef MYLIB_H
#define MYLIB_H
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
typedef enum Status { Status_Ok = 0, Status_NotFound = 1 } Status;
typedef struct Handle Handle; /* 불투명 타입 ★ */
typedef struct Point { double x; double y; } Point;
#ifdef __cplusplus
extern "C" {
#endif
/* 두 점 사이의 거리를 구합니다. */
double mylib_distance(Point a, Point b);
/* 핸들을 엽니다. 실패하면 NULL 을 반환합니다. */
struct Handle *mylib_open(const char *path);
#ifdef __cplusplus
}
#endif
#endif /* MYLIB_H */
/// 문서 주석이 그대로 헤더 주석이 됩니다 —
Rust 쪽에 한 번 쓰면 C 헤더 · cargo doc 두 곳에서 쓰입니다(r11).
이렇게도 쓸 수 있다 — 다른 언어의 바인딩도 생성 🟡 중급
언어별 생성 도구 ★
| 대상 | 도구 |
|---|---|
| C / C++ | cbindgen |
| C# | csbindgen |
| 파이썬·Kotlin·Swift | UniFFI ★ |
| Dart / Flutter | flutter_rust_bridge |
| Go | cgo + cbindgen 헤더 |
| 자바 | JNA + cbindgen 헤더 |
★ UniFFI (Mozilla) — 한 정의에서 여러 언어 바인딩
· .udl 파일이나 매크로로 인터페이스를 정의
· 파이썬 · Kotlin · Swift · Ruby 바인딩을 자동 생성
· 에러 · Option · 컬렉션까지 알아서 매핑합니다 ★
· 모바일(Android/iOS) 앱에서 Rust 코어를 쓸 때 사실상 표준
단점
· 생성된 코드가 두껍고 오버헤드가 있습니다
· 성능이 극한으로 중요한 경로에는 부적합
★ 판단
· 언어 2~3개 · 함수 몇 개 → 직접 C ABI (이 탭)
· 언어 4개+ · 타입이 풍부 → UniFFI
· 모바일 크로스 플랫폼 → UniFFI
헤더가 코드와 맞는지 검사
# ① 헤더를 커밋하고, CI 에서 재생성 후 diff ★
cargo build
git diff --exit-code include/mylib.h
# 차이가 있으면 CI 실패 → 헤더를 커밋하지 않은 것
# ② C 컴파일러로 헤더 자체를 검증
gcc -fsyntax-only -x c include/mylib.h
g++ -fsyntax-only -x c++ include/mylib.h # cpp_compat 확인
# ③ 실제로 링크해 보기 (통합 테스트) ★
cat > tests/smoke.c <<'EOF'
#include "mylib.h"
#include <assert.h>
int main(void) {
Point a = {0,0}, b = {3,4};
assert(mylib_distance(a, b) == 5.0);
return 0;
}
EOF
gcc tests/smoke.c -Iinclude -Ltarget/release -lmylib -o smoke
LD_LIBRARY_PATH=target/release ./smoke
# ★ 이 C 스모크 테스트를 CI 에 넣으면
# "Rust 에서는 되는데 C 에서 안 되는" 문제를 배포 전에 잡습니다
실무에선 이렇게 — 패키징까지 🔴 실무
배포에 담을 것
배포 아티팩트 (플랫폼별)
include/mylib.h 헤더 (플랫폼 무관)
lib/libmylib.so 리눅스 동적
lib/libmylib.a 리눅스 정적
lib/mylib.dll + mylib.lib 윈도우
lib/libmylib.dylib 맥
mylib.pc pkg-config 파일 ★
LICENSE · README
# pkg-config 파일 예 (C 개발자가 편해집니다)
prefix=/usr/local
libdir=${prefix}/lib
includedir=${prefix}/include
Name: mylib
Description: 빠른 텍스트 처리
Version: 1.2.0
Libs: -L${libdir} -lmylib
Cflags: -I${includedir}
# 쓰는 쪽
gcc app.c $(pkg-config --cflags --libs mylib) -o app
버전 정보를 헤더에 넣기
// 컴파일 타임 상수는 cbindgen 이 #define 으로 내보냅니다
pub const MYLIB_VERSION_MAJOR: u32 = 1;
pub const MYLIB_VERSION_MINOR: u32 = 2;
// 런타임 확인 함수도 함께 (f09)
#[no_mangle]
pub extern "C" fn mylib_version() -> u32 {
(MYLIB_VERSION_MAJOR << 16) | MYLIB_VERSION_MINOR
}
#[no_mangle]
pub extern "C" fn mylib_version_string() -> *const c_char {
concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
}
// ★ 문자열 리터럴에 \0 을 붙이면 해제가 필요 없는 정적 C 문자열이 됩니다 (f06)
/* 쓰는 쪽에서 헤더와 라이브러리 버전이 맞는지 확인 */
if ((mylib_version() >> 16) != MYLIB_VERSION_MAJOR) {
fprintf(stderr, "헤더와 라이브러리 버전 불일치\n");
return 1;
}
.so 버전이 어긋나는 사고는 흔합니다 —
시스템에 옛 버전이 깔려 있는데 새 헤더로 컴파일하는 경우입니다.
런타임 버전 확인 함수를 넣어 두면 즉시 드러납니다.
한 단계 더 — cbindgen 의 한계와 대응 🧬 전문가
표현 못 하는 것들
// ① 제네릭 — 구체화한 타입만 내보낼 수 있습니다
// pub struct Wrapper<T>(T); // ❌ 헤더에 안 나옴
#[repr(C)] pub struct WrapperU32 { inner: u32 } // ✅ 구체 타입으로
// ② 트레이트 객체 · 클로저 — 불가능
// → 함수 포인터 + user data 로 (f07)
// ③ 라이프타임이 있는 타입 — 무시됩니다
// → 소유권 규약을 문서로 (f06)
// ④ 매크로로 생성한 항목 — parse 설정에 따라 놓칠 수 있습니다
// → [parse] expand = ["mycrate"] 로 매크로 확장 후 파싱 (느립니다)
// ⑤ 의존 크레이트의 타입
// [parse] parse_deps = true, include = ["othercrate"]
// 수동 보완 — 헤더에 직접 넣을 코드
// cbindgen.toml
// after_includes = """
// #define MYLIB_MAX_SIZE 4096
// typedef void (*MylibLogFn)(const char *msg);
// """
C++ 를 위한 배려
# cbindgen.toml
language = "C++" # C++ 헤더로 생성
namespace = "mylib" # 네임스페이스로 감싸기
[enum]
enum_class = true # enum class 로 (타입 안전)
// C++ 사용자를 위한 RAII 래퍼를 함께 제공하면 친절합니다
namespace mylib {
class Handle {
public:
explicit Handle(const char* path) : h_(mylib_open(path)) {
if (!h_) throw std::runtime_error(last_error());
}
~Handle() { if (h_) mylib_close(h_); }
Handle(Handle&& o) noexcept : h_(o.h_) { o.h_ = nullptr; }
Handle(const Handle&) = delete;
std::vector<uint8_t> process(std::span<const uint8_t> in);
private:
::Handle* h_;
};
}
// ★ C ABI 는 최소 공통분모이고, 각 언어에서 그 언어다운 래퍼를 씌우는 것이
// 이 방식의 정석입니다 (🐍 y02 · 🟨 j06 과 같은 원칙)
build.rs + cbindgen 으로 자동 생성하고,
CI 에서 재생성 후 git diff --exit-code 로 어긋남을 막으세요.
C 스모크 테스트를 하나 두면 링크 문제를 배포 전에 잡습니다.
언어가 여럿이면 UniFFI 도 검토하세요.파이썬 ctypes 로 부르기 — 설치도 빌드도 없이
만든 .so/.dll 을 파이썬 표준 라이브러리만으로 바로 부를 수 있습니다.
PyO3 처럼 빌드할 필요도, 패키징할 필요도 없습니다.
프로토타입과 검증에 특히 유용합니다.
가장 짧은 예제
// src/lib.rs
#[no_mangle]
pub extern "C" fn mylib_add(a: i32, b: i32) -> i32 { a + b }
#[no_mangle]
pub unsafe extern "C" fn mylib_sum(p: *const f64, n: usize) -> f64 {
if p.is_null() || n == 0 { return 0.0; }
std::slice::from_raw_parts(p, n).iter().sum()
}
import ctypes, sys, pathlib
# 플랫폼별 파일 이름
name = {"linux": "libmylib.so", "darwin": "libmylib.dylib",
"win32": "mylib.dll"}[sys.platform]
lib = ctypes.CDLL(str(pathlib.Path("target/release") / name))
# ★ 반드시 시그니처를 지정하세요 (안 하면 int 로 가정합니다)
lib.mylib_add.argtypes = [ctypes.c_int32, ctypes.c_int32]
lib.mylib_add.restype = ctypes.c_int32
lib.mylib_sum.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
lib.mylib_sum.restype = ctypes.c_double
print(lib.mylib_add(1, 2)) # 3
# numpy 배열을 그대로 넘기기 — 복사 없음 ★
import numpy as np
a = np.random.random(1_000_000)
ptr = a.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
print(lib.mylib_sum(ptr, a.size))
argtypes·restype 을 빠뜨리면 ctypes 가 인자를 int 로 가정합니다.
f64 를 넘기면 값이 깨지거나 크래시합니다.
모든 함수에 반드시 지정하세요.
타입 대응표 ★
| Rust | ctypes |
|---|---|
i32 | c_int32 |
u64 | c_uint64 |
usize | c_size_t |
f64 | c_double |
bool | c_bool |
*const c_char | c_char_p |
*mut u8 | POINTER(c_ubyte) |
*mut c_void | c_void_p |
#[repr(C)] struct | Structure |
| 함수 포인터 | CFUNCTYPE |
# 구조체 (f03 과 배치가 일치해야 합니다)
class Point(ctypes.Structure):
_fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)]
lib.mylib_distance.argtypes = [Point, Point]
lib.mylib_distance.restype = ctypes.c_double
print(lib.mylib_distance(Point(0, 0), Point(3, 4))) # 5.0
# ★ 검증 습관
assert ctypes.sizeof(Point) == 16 # Rust 쪽 assert 와 대조 (f03)
# 문자열
lib.mylib_count_words.argtypes = [ctypes.c_char_p]
lib.mylib_count_words.restype = ctypes.c_size_t
lib.mylib_count_words("안녕 세상".encode("utf-8")) # bytes 로 넘깁니다 ★
이렇게도 쓸 수 있다 — 출력 버퍼와 에러 처리 🟡 중급
두 번 호출 패턴 (f02 의 ①)
lib.mylib_encode.argtypes = [
ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, # 입력
ctypes.POINTER(ctypes.c_ubyte), ctypes.c_size_t, # 출력 버퍼
ctypes.POINTER(ctypes.c_size_t), # 실제 길이
]
lib.mylib_encode.restype = ctypes.c_int32
def encode(data: bytes) -> bytes:
src = (ctypes.c_ubyte * len(data)).from_buffer_copy(data)
need = ctypes.c_size_t(0)
# ① 필요한 크기 물어보기 (out=NULL)
rc = lib.mylib_encode(src, len(data), None, 0, ctypes.byref(need))
if rc not in (0, -2):
raise RuntimeError(last_error())
# ② 버퍼를 만들어 다시 호출
out = (ctypes.c_ubyte * need.value)()
rc = lib.mylib_encode(src, len(data), out, need.value, ctypes.byref(need))
if rc != 0:
raise RuntimeError(last_error())
return bytes(out[:need.value])
에러 메시지 받기 (f09)
lib.mylib_last_error.argtypes = [ctypes.c_char_p, ctypes.c_size_t]
lib.mylib_last_error.restype = ctypes.c_size_t
def last_error() -> str:
buf = ctypes.create_string_buffer(512)
n = lib.mylib_last_error(buf, len(buf))
return buf.value[:n].decode("utf-8", errors="replace")
# 얇은 파이썬 래퍼로 감싸 예외로 바꾸기 ★
class MylibError(Exception): pass
def check(rc: int) -> None:
if rc != 0:
raise MylibError(f"[{rc}] {last_error()}")
def process(data: bytes) -> bytes:
...
check(lib.mylib_process(...))
# ★ 이 래퍼 층이 있으면 쓰는 사람은 C ABI 를 전혀 모릅니다
# (PyO3 쪽의 파이썬 래퍼와 같은 역할 · 🐍 y02)
실무에선 이렇게 — ctypes vs PyO3 vs cffi 🔴 실무
고르는 기준 ★
| ctypes | PyO3 | |
|---|---|---|
| 빌드 | 불필요 | maturin |
| 호출 비용 | 높음 ★ | 낮음 |
| 타입 안전 | 없음 | 있음 |
| 파이썬다움 | 낮음 | 높음 |
| GIL 해제 | 자동 ★ | 명시 |
| 배포 | 직접 | wheel |
| 다른 언어 공유 | 가능 | 불가 |
★ 판단
· 파이썬만 쓰고 성능이 중요 → PyO3 (🐍 y02)
· 여러 언어에서 공유해야 함 → C ABI + ctypes
· 이미 있는 .so 를 잠깐 써 보기 → ctypes ★
· 호출이 잦고 데이터가 작음 → PyO3 (ctypes 는 호출 비용이 큽니다)
· C 라이브러리 래핑 → cffi 또는 bindgen (f08)
★ ctypes 호출 비용
파이썬에서 인자를 변환하고 FFI 를 거치므로
PyO3 보다 눈에 띄게 비쌉니다 (보통 수 배 · 직접 재 보세요 · ⚡ e01)
→ 루프에서 수백만 번 부르면 차이가 큽니다. 배치로 묶으세요 (🐍 y01)
★ ctypes 의 숨은 이점
ctypes 는 호출 중 GIL 을 자동으로 놓습니다 ★
→ 긴 계산이라면 다른 파이썬 스레드가 그동안 돕니다 (🐍 y07)
라이브러리를 찾는 방법
import ctypes, ctypes.util, sys, pathlib
# ① 패키지 안에 함께 배포 (권장) ★
here = pathlib.Path(__file__).parent
name = {"linux": "libmylib.so", "darwin": "libmylib.dylib",
"win32": "mylib.dll"}[sys.platform]
lib = ctypes.CDLL(str(here / name))
# ② 시스템에서 찾기
path = ctypes.util.find_library("mylib")
lib = ctypes.CDLL(path)
# ③ 환경 변수로 재정의 가능하게
import os
lib = ctypes.CDLL(os.getenv("MYLIB_PATH", str(here / name)))
# 윈도우 주의 ★
# · CDLL 은 cdecl, WinDLL 은 stdcall — extern "C" 면 CDLL
# · 파이썬 3.8+ 는 DLL 검색 경로가 제한됩니다
os.add_dll_directory(str(here)) # 의존 DLL 이 있을 때 필요
# 진단
# 리눅스: LD_DEBUG=libs python app.py
# 맥: DYLD_PRINT_LIBRARIES=1
# 윈도우: Dependencies.exe 또는 Process Monitor
한 단계 더 — 콜백과 수명 함정 🧬 전문가
파이썬 함수를 콜백으로 넘기기 (f07)
CALLBACK = ctypes.CFUNCTYPE(None, ctypes.c_float, ctypes.c_void_p)
# 반환 인자1 인자2
lib.mylib_process.argtypes = [ctypes.c_char_p, CALLBACK, ctypes.c_void_p]
def on_progress(p, user):
print(f"{p*100:.0f}%")
cb = CALLBACK(on_progress) # ★ 반드시 변수에 보관하세요
lib.mylib_process(b"data", cb, None)
# ❗ 가장 흔한 크래시 원인
# lib.mylib_process(b"data", CALLBACK(on_progress), None)
# → 임시 객체가 즉시 GC 되어 함수 포인터가 무효가 됩니다 ★
# 반드시 파이썬 쪽에서 참조를 살려 두세요 (전역이나 인스턴스 필드에)
# 콜백 안에서 예외가 나면?
# ctypes 가 stderr 에 출력하고 무시합니다 (C 로 전파되지 않습니다)
# → 콜백 안을 try/except 로 감싸고 플래그로 알리세요
메모리 수명 주의 ★
# ❌ 임시 객체의 포인터를 넘기기
lib.process(bytes(data)) # 호출 중에는 안전하지만
p = ctypes.cast(bytes(data), ctypes.c_char_p) # ← 이후엔 무효 ★
# ✅ 버퍼를 변수로 유지
buf = (ctypes.c_ubyte * len(data)).from_buffer_copy(data)
lib.process(buf, len(data)) # buf 가 살아 있는 동안 유효
# ✅ 제자리 수정이 필요하면 from_buffer (복사 없음)
arr = bytearray(data)
buf = (ctypes.c_ubyte * len(arr)).from_buffer(arr) # ★ arr 를 직접 봅니다
lib.invert(buf, len(arr))
print(arr) # 바뀌어 있습니다
# numpy 는 .ctypes 로 (앞의 예제)
# 단, 배열이 GC 되지 않도록 참조를 유지해야 합니다
# ★ Rust 쪽이 포인터를 보관한다면 (콜백 등록 등)
# 파이썬 쪽 객체의 수명을 명시적으로 관리해야 합니다.
# 이 경계에서 나는 크래시는 재현이 어렵습니다 — 문서에 규약을 적으세요 (f06)
ctypes.CDLL 로 로드하고 argtypes·restype 을 반드시 지정하세요.
numpy 배열은 .ctypes.data_as() 로 복사 없이 넘어갑니다.
콜백 객체는 변수에 보관해야 하고(GC 주의), 얇은 파이썬 래퍼로 감싸
시그니처와 에러 변환을 한 곳에 모으세요.문자열 · 메모리 소유권 규약
FFI 에서 가장 사고가 잦은 자리입니다. “이 메모리는 누가 해제하나”를 정하지 않으면 반드시 새거나 두 번 해제됩니다. 규칙은 단순합니다 — 할당한 쪽이 해제한다.
문자열을 돌려주는 정석 ★
use std::ffi::{CString, CStr, c_char};
#[no_mangle]
pub extern "C" fn mylib_greet(name: *const c_char) -> *mut c_char {
let name = unsafe {
if name.is_null() { return std::ptr::null_mut(); }
match CStr::from_ptr(name).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
}
};
let msg = format!("안녕 {name}");
match CString::new(msg) { // 내부 널이 있으면 실패
Ok(c) => c.into_raw(), // ★ 소유권을 C 로 넘김
Err(_) => std::ptr::null_mut(),
}
}
// ★ 반드시 짝이 되는 해제 함수를 제공하세요
#[no_mangle]
pub unsafe extern "C" fn mylib_string_free(s: *mut c_char) {
if s.is_null() { return; }
drop(CString::from_raw(s)); // 다시 받아 해제
}
char *msg = mylib_greet("현우");
if (msg) { printf("%s\n", msg); mylib_string_free(msg); } /* ★ 반드시 */
free() 로 해제하면 안 됩니다 — Rust 의 할당자와 C 의 malloc 은
다를 수 있습니다. 반드시 라이브러리가 제공한 해제 함수를 쓰게 하고,
헤더 주석에 명시하세요(f04).
규약을 이름으로 드러내기 ★
★ 네 가지 패턴과 이름 규칙
① 호출자가 버퍼를 준다 — 해제 불필요 (가장 안전) ★
int32_t mylib_encode(const uint8_t *in, size_t in_len,
uint8_t *out, size_t out_cap, size_t *out_len);
② 라이브러리가 할당 — 짝이 되는 free 를 제공
char *mylib_greet(const char *name);
void mylib_string_free(char *s); ← 이름에 free 를 넣으세요
③ 정적 문자열 반환 — 해제 금지
const char *mylib_version_string(void); ← const 로 표시 + 문서에 명시
④ 콜백으로 흘려보냄 — 콜백 안에서만 유효
void mylib_stream(..., void (*cb)(const uint8_t*, size_t, void*), void*);
★ 문서에 반드시 적을 것 (헤더 주석으로 · f04)
· 반환된 포인터를 누가 해제하는가
· 해제 함수 이름
· NULL 반환이 가능한가
· 인자 포인터를 라이브러리가 보관하는가 (수명!)
이렇게도 쓸 수 있다 — 버퍼와 Box 넘기기 🟡 중급
Vec 을 넘기고 되받기
#[repr(C)]
pub struct Buf { pub ptr: *mut u8, pub len: usize, pub cap: usize }
#[no_mangle]
pub extern "C" fn mylib_encode_alloc(input: *const u8, len: usize) -> Buf {
let Some(src) = (unsafe { to_slice(input, len) }) else {
return Buf { ptr: std::ptr::null_mut(), len: 0, cap: 0 };
};
let mut v = encode(src);
let b = Buf { ptr: v.as_mut_ptr(), len: v.len(), cap: v.capacity() };
std::mem::forget(v); // ★ Rust 가 해제하지 않게 (🔐 o01)
b
}
#[no_mangle]
pub unsafe extern "C" fn mylib_buf_free(b: Buf) {
if b.ptr.is_null() { return; }
drop(Vec::from_raw_parts(b.ptr, b.len, b.cap)); // ★ cap 이 반드시 필요
}
// ❗ cap 을 안 돌려주면 Vec 을 복원할 수 없습니다.
// len 만 알고 from_raw_parts(ptr, len, len) 하면
// 할당 크기가 달라 UB 입니다 ★
// 더 안전한 대안 — into_boxed_slice 로 cap == len 을 보장 (🔐 o04)
let boxed: Box<[u8]> = v.into_boxed_slice();
let ptr = Box::into_raw(boxed) as *mut u8; // len 만 있으면 복원 가능
불투명 핸들 — 가장 흔한 패턴 ★
pub struct Session { /* 내부는 감춥니다 */ }
#[no_mangle]
pub extern "C" fn mylib_session_new(cfg: *const c_char) -> *mut Session {
match Session::new(cfg) {
Ok(s) => Box::into_raw(Box::new(s)), // ★ 힙에 두고 포인터만
Err(_) => std::ptr::null_mut(),
}
}
#[no_mangle]
pub unsafe extern "C" fn mylib_session_free(s: *mut Session) {
if s.is_null() { return; }
drop(Box::from_raw(s)); // Drop 도 여기서 돕니다
}
#[no_mangle]
pub unsafe extern "C" fn mylib_session_run(s: *mut Session, x: i32) -> i32 {
let Some(sess) = s.as_mut() else { return -1 }; // ★ 널 검사
sess.run(x)
}
/* 헤더에는 내용 없는 타입만 나옵니다 (cbindgen 이 생성 · f04) */
typedef struct Session Session;
Session *mylib_session_new(const char *cfg);
void mylib_session_free(Session *s);
int32_t mylib_session_run(Session *s, int32_t x);
FILE*, sqlite3*)가 전부 이 방식입니다.
실무에선 이렇게 — 누수와 이중 해제를 잡는다 🔴 실무
검사 방법 ★
# ① 반복 호출하며 메모리 관찰 (가장 실용적)
python -c "
import ctypes, os, psutil
lib = ctypes.CDLL('./libmylib.so')
p = psutil.Process()
for i in range(1_000_000):
s = lib.mylib_greet(b'test')
lib.mylib_string_free(s)
if i % 100_000 == 0: print(i, p.memory_info().rss // 1024, 'KB')"
# ★ RSS 가 계속 늘면 누수입니다
# ② valgrind (리눅스 · 느립니다)
valgrind --leak-check=full --show-leak-kinds=all ./smoke
# ③ ASan (더 빠르고 정확합니다)
RUSTFLAGS="-Zsanitizer=address" cargo +nightly build \
--target x86_64-unknown-linux-gnu
# 이중 해제 · use-after-free 를 즉시 잡습니다 ★
# ④ Miri — unsafe 로직 검증 (FFI 경계 자체는 못 봅니다 · 🔬 d07)
cargo +nightly miri test
방어적으로 설계하기
// ① 해제 후 널로 만들게 유도 — 포인터의 포인터를 받기
#[no_mangle]
pub unsafe extern "C" fn mylib_session_free(s: *mut *mut Session) {
if s.is_null() || (*s).is_null() { return; }
drop(Box::from_raw(*s));
*s = std::ptr::null_mut(); // ★ 호출자의 포인터도 널로
}
// C: mylib_session_free(&sess); → 이중 해제가 구조적으로 막힙니다
// ② 매직 넘버로 유효성 검사 (디버그 빌드에서만)
#[repr(C)]
pub struct Session { magic: u32, /* … */ }
const MAGIC: u32 = 0x4D59_4C42;
unsafe fn as_session<'a>(p: *mut Session) -> Option<&'a mut Session> {
let s = p.as_mut()?;
debug_assert_eq!(s.magic, MAGIC, "무효한 핸들 (이미 해제됐거나 잘못된 포인터)");
Some(s)
}
// ③ 해제 카운터로 테스트 (r11)
#[cfg(test)]
static ALIVE: AtomicUsize = AtomicUsize::new(0);
// new 에서 +1, Drop 에서 -1 → 테스트 끝에 0 인지 확인 ★
한 단계 더 — 할당자와 경계 🧬 전문가
왜 free() 로 못 푸나
Rust 의 기본 할당자는 시스템 malloc 을 씁니다.
그래서 대부분의 플랫폼에서 free() 로도 '우연히' 동작합니다.
★ 하지만 의존하면 안 되는 이유
· #[global_allocator] 를 바꾸면 즉시 깨집니다 (jemalloc · mimalloc)
· 윈도우에서 MSVC CRT 가 다르면 힙이 다릅니다 ★
(라이브러리는 /MD, 앱은 /MT 로 빌드된 경우 등)
· Vec 은 크기·정렬 정보를 요구합니다 (dealloc(ptr, layout))
· 미래에 Rust 가 할당 전략을 바꿀 수 있습니다
★ 결론
반드시 짝이 되는 해제 함수를 제공하고,
헤더 주석과 문서에 "free() 를 쓰지 마세요" 를 명시하세요.
// 할당자를 노출하는 방법도 있습니다 (C 쪽이 크기를 알 때)
#[no_mangle]
pub extern "C" fn mylib_alloc(size: usize) -> *mut u8;
#[no_mangle]
pub extern "C" fn mylib_dealloc(p: *mut u8, size: usize);
수명 규약을 문서로 고정하기
/// 입력 데이터를 처리합니다.
///
/// # 안전성
///
/// - `data` 는 `len` 바이트만큼 읽을 수 있는 유효한 포인터여야 합니다.
/// `len == 0` 이면 `data` 는 NULL 이어도 됩니다.
/// - 이 함수는 `data` 를 **보관하지 않습니다**. 반환 후 해제해도 됩니다.
/// - 반환된 포인터는 `mylib_buf_free` 로만 해제해야 합니다.
/// - NULL 이 반환되면 `mylib_last_error` 로 원인을 확인하세요.
/// - 스레드 안전: 여러 스레드에서 동시에 호출해도 됩니다.
#[no_mangle]
pub unsafe extern "C" fn mylib_process(data: *const u8, len: usize) -> Buf { }
// ★ 이 다섯 항목을 모든 함수에 적으세요
// ① 포인터의 유효 범위
// ② 라이브러리가 보관하는가 (수명)
// ③ 반환값의 해제 책임
// ④ 실패 표현
// ⑤ 스레드 안전성
//
// cbindgen 이 이 주석을 헤더에 그대로 옮깁니다 (f04) —
// 그래서 C 사용자가 헤더만 보고도 규약을 알 수 있습니다 ★
*_free 함수를 제공하고,
free() 를 쓰지 말라고 문서에 명시하세요.
가장 안전한 것은 호출자가 버퍼를 주는 방식이고,
상태가 필요하면 불투명 핸들 + Box::into_raw 가 정석입니다.콜백 — 거꾸로 부르기
진행률 보고, 이벤트 알림, 스트리밍 출력 — Rust 가 다른 언어의 함수를 부르는 자리입니다. C ABI 에는 클로저가 없으므로 함수 포인터 + 사용자 데이터 두 개를 함께 넘기는 것이 전 세계 공통 관용구입니다.
함수 포인터 + user data ★
use std::ffi::c_void;
pub type ProgressFn = extern "C" fn(progress: f32, user: *mut c_void);
#[no_mangle]
pub unsafe extern "C" fn mylib_process(
data: *const u8, len: usize,
cb: Option<ProgressFn>, // ★ Option 으로 널 가능 (f03)
user: *mut c_void, // 호출자의 컨텍스트를 그대로 돌려줌
) -> i32 {
let Some(src) = to_slice(data, len) else { return -1 };
for (i, chunk) in src.chunks(4096).enumerate() {
process_chunk(chunk);
if let Some(f) = cb {
f(i as f32 / (src.len() / 4096) as f32, user); // ★ 호출
}
}
0
}
// ★ user 포인터가 왜 필요한가
// C 함수 포인터는 상태를 담을 수 없습니다 (클로저가 아님).
// 그래서 호출자가 자기 객체 주소를 user 로 주고,
// 콜백 안에서 그것을 다시 캐스팅해 씁니다.
typedef struct { int count; FILE *log; } Ctx;
static void on_progress(float p, void *user) {
Ctx *c = (Ctx *)user; /* ★ 되돌리기 */
c->count++;
fprintf(c->log, "%.0f%%\n", p * 100);
}
Ctx ctx = { 0, stdout };
mylib_process(data, len, on_progress, &ctx);
Rust 안에서 클로저를 넘기려면
// Rust 클로저를 C 콜백으로 변환하는 관용구 (🔐 o11)
extern "C" fn trampoline<F: FnMut(f32)>(p: f32, user: *mut c_void) {
let f = unsafe { &mut *(user as *mut F) }; // ★ 되돌리기
f(p);
}
pub fn process_with<F: FnMut(f32)>(data: &[u8], mut f: F) -> i32 {
unsafe {
mylib_process(
data.as_ptr(), data.len(),
Some(trampoline::<F>),
&mut f as *mut F as *mut c_void, // 클로저 주소를 user 로
)
}
}
// 쓰는 쪽 — 안전한 Rust API 가 됩니다 ★
process_with(&data, |p| println!("{:.0}%", p * 100.0));
// ❗ 주의
// · 클로저가 콜백보다 오래 살아야 합니다 (여기선 함수 스코프 동안 유효)
// · 콜백을 등록해 두고 나중에 부르는 방식이면
// 클로저를 Box 로 힙에 올려 소유권을 넘겨야 합니다 (아래)
이렇게도 쓸 수 있다 — 등록해 두고 나중에 부르기 🟡 중급
수명이 길어질 때
// 콜백을 저장해 두는 경우 — 소유권을 명확히 해야 합니다
pub struct Watcher {
cb: Option<ProgressFn>,
user: *mut c_void,
}
unsafe impl Send for Watcher {} // ★ user 포인터의 스레드 안전은 호출자 책임
#[no_mangle]
pub unsafe extern "C" fn mylib_watcher_set_callback(
w: *mut Watcher, cb: Option<ProgressFn>, user: *mut c_void,
) -> i32 {
let Some(w) = w.as_mut() else { return -1 };
w.cb = cb;
w.user = user;
0
}
// ❗ 문서에 반드시 적을 것 (f06)
// · user 가 가리키는 객체는 watcher 가 해제될 때까지 살아 있어야 합니다
// · 콜백은 어느 스레드에서 불릴 수 있습니다
// · 콜백 안에서 mylib_* 함수를 다시 부르면 데드락일 수 있습니다 ★
// 해제 콜백도 함께 받는 것이 더 안전합니다
pub type FreeFn = extern "C" fn(user: *mut c_void);
// watcher 가 해제될 때 free_user(user) 를 불러 줍니다
언어별 콜백 만들기
# 파이썬 (f05)
CB = ctypes.CFUNCTYPE(None, ctypes.c_float, ctypes.c_void_p)
cb = CB(lambda p, u: print(f"{p*100:.0f}%")) # ★ 변수로 보관 (GC 주의)
lib.mylib_process(data, len(data), cb, None)
// Node (koffi)
const koffi = require('koffi')
const ProgressFn = koffi.proto('void ProgressFn(float, void *)')
const cb = koffi.register(p => console.log(p), koffi.pointer(ProgressFn))
lib.mylib_process(data, data.length, cb, null)
koffi.unregister(cb) // ★ 반드시 해제
// C#
delegate void ProgressFn(float p, IntPtr user);
[DllImport("mylib")]
static extern int mylib_process(byte[] d, nuint len, ProgressFn cb, IntPtr user);
var cb = new ProgressFn((p, u) => Console.WriteLine(p));
GC.KeepAlive(cb); // ★ GC 가 델리게이트를 수거하지 않게
mylib_process(data, (nuint)data.Length, cb, IntPtr.Zero);
GC.KeepAlive,
Node 는 unregister 전까지 유지. 문서에 반드시 경고하세요.
실무에선 이렇게 — 콜백 대신 쓰는 설계 🔴 실무
폴링이 더 나은 경우가 많습니다 ★
// 콜백 대신 상태를 조회하게 하기
use std::sync::atomic::{AtomicU64, Ordering};
pub struct Job { done: AtomicU64, total: u64 }
#[no_mangle]
pub unsafe extern "C" fn mylib_job_progress(j: *const Job) -> f32 {
let Some(j) = j.as_ref() else { return 0.0 };
j.done.load(Ordering::Relaxed) as f32 / j.total as f32
}
// ★ 장점
// · 콜백 수명·GC 문제가 없습니다
// · 스레드 안전을 원자 연산 하나로 해결
// · 호출자가 원하는 주기로 확인 (UI 프레임에 맞춰서)
// · 모든 언어에서 바인딩이 단순합니다
// ★ 콜백이 꼭 필요한 경우
// · 데이터를 스트리밍으로 흘려보낼 때 (전체를 메모리에 못 올림)
// · 이벤트 발생 시점이 중요할 때
// · 호출자가 흐름을 제어해야 할 때 (중단·필터)
콜백에서 지켜야 할 규칙
□ 콜백 안에서 패닉이 나면? (f09)
Rust → 다른 언어 → Rust 로 되감기면 UB 입니다.
→ 콜백을 부르는 쪽(Rust)에서 catch_unwind 로 감쌀 수는 없습니다
(콜백은 남의 코드입니다)
→ 각 언어 바인딩에서 예외를 잡아 에러 코드로 바꾸도록 문서화 ★
□ 콜백이 오래 걸리면?
Rust 쪽 락을 잡은 채 콜백을 부르지 마세요 (데드락 · 🔐 o10)
반드시 락을 놓고 부르세요
□ 콜백이 다시 라이브러리를 부르면? (재진입)
허용할지 문서에 명시하세요. 대개 금지하는 편이 안전합니다.
□ 콜백이 반환값으로 중단을 표현할 수 있게
typedef int (*ProgressFn)(float, void*); /* 0 = 계속, 그 외 = 중단 */
→ 사용자가 취소할 수 있습니다 ★
□ 어느 스레드에서 불리는가
메인 스레드 보장인지, 워커에서도 불리는지 명시하세요
(UI 프레임워크는 대개 메인 스레드에서만 UI 를 만질 수 있습니다)
void 대신 int 를 반환하게 설계해 두면
나중에 취소 기능을 넣을 때 ABI 를 안 깨고 추가할 수 있습니다(f09).
한 단계 더 — 스레드와 콜백 🧬 전문가
워커 스레드에서 콜백 부르기
// Rust 가 스레드를 만들어 콜백을 부르는 경우
#[no_mangle]
pub unsafe extern "C" fn mylib_start_async(
cb: ProgressFn, user: *mut c_void,
) -> i32 {
// ❗ 원시 포인터는 Send 가 아닙니다 — 래퍼가 필요합니다
struct SendPtr(*mut c_void);
unsafe impl Send for SendPtr {} // ★ 안전성은 호출자와의 규약에 의존
let u = SendPtr(user);
std::thread::spawn(move || {
let u = u; // 이동
for i in 0..100 {
cb(i as f32 / 100.0, u.0); // 워커 스레드에서 호출 ★
}
});
0
}
// ★ 문서에 반드시
// "콜백은 임의의 스레드에서 호출됩니다. 스레드 안전하게 작성하세요."
//
// ★ 언어별 함정
// 파이썬: GIL 이 필요합니다 — ctypes 콜백은 자동으로 잡아 줍니다
// C#: UI 스레드가 아니면 Invoke 가 필요합니다
// Node: 이벤트 루프 밖이라 JS 를 직접 못 부릅니다 (tsfn 필요 · 🟨 j06)
더 안전한 대안 — 큐
// 콜백 대신 이벤트를 큐에 쌓고, 호출자가 꺼내 가게 하기
#[repr(C)]
pub struct Event { pub kind: i32, pub value: f64 }
#[no_mangle]
pub unsafe extern "C" fn mylib_poll_event(
h: *mut Session, out: *mut Event,
) -> i32 {
let Some(s) = h.as_mut() else { return -1 };
match s.queue.pop() { // 논블로킹
Some(ev) => { *out = ev; 1 } // 1 = 이벤트 있음
None => 0, // 0 = 없음
}
}
// 호출자 (어느 언어든)
// while (mylib_poll_event(h, &ev) > 0) { handle(ev); }
//
// ★ 장점
// · 콜백 수명·GC·스레드 문제가 전부 사라집니다
// · 호출자가 자기 이벤트 루프에 자연스럽게 통합합니다
// · 게임 엔진·UI 프레임워크와 궁합이 좋습니다 ★
// · 언어별 바인딩이 훨씬 단순해집니다
//
// ★ 단점
// · 폴링 주기만큼 지연이 생깁니다
// · 큐가 넘칠 때의 정책이 필요합니다 (드롭 · 블로킹 · 확장)
void* user data 가 공통 관용구입니다.
콜백 객체의 수명은 호출자 책임이고 GC 함정이 언어마다 있으니 문서에 경고하세요.
중단 신호를 반환값으로 두면 나중에 취소 기능을 ABI 를 깨지 않고 추가할 수 있고,
여러 언어를 지원한다면 폴링·큐 방식이 훨씬 단순합니다.bindgen — 기존 C 라이브러리를 Rust 에서
지금까지는 Rust 를 내보내는 방향이었습니다. 이번엔 반대입니다 — 이미 있는 C 라이브러리를 Rust 에서 쓰는 방법입니다. 헤더만 주면 바인딩이 자동 생성되고, 그 위에 안전한 래퍼를 씌우는 것이 정석입니다.
-sys 크레이트 관례 ★
Rust 생태계의 관례 — 두 층으로 나눕니다
foo-sys C 헤더를 그대로 옮긴 unsafe 바인딩 (bindgen 이 생성)
· 빌드에서 라이브러리를 찾고 링크
· 안전성 보장 없음
foo 그 위의 안전한 래퍼 (사람이 작성) ★
· RAII · Result · 슬라이스
· 사용자는 unsafe 를 한 줄도 안 씁니다
예: libgit2-sys / git2 · openssl-sys / openssl · zstd-sys / zstd
★ 왜 나누나
· -sys 는 기계적으로 생성되므로 버전 업데이트가 쉽습니다
· 안전 래퍼는 설계가 필요하므로 사람이 관리합니다
· 다른 사람이 자기만의 래퍼를 만들 수 있습니다
# foo-sys/Cargo.toml
[package]
name = "foo-sys"
links = "foo" # ★ 같은 C 라이브러리를 두 번 링크하는 것을 막습니다
[build-dependencies]
bindgen = "0.70"
pkg-config = "0.3"
build.rs 로 생성
// build.rs
fn main() {
// ① 시스템에서 라이브러리 찾기
let lib = pkg_config::Config::new()
.atleast_version("1.2")
.probe("libfoo")
.expect("libfoo 를 찾을 수 없습니다");
// → cargo:rustc-link-lib · link-search 를 자동으로 출력합니다
// ② 헤더에서 바인딩 생성
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.clang_args(lib.include_paths.iter()
.map(|p| format!("-I{}", p.display())))
.allowlist_function("foo_.*") // ★ 필요한 것만
.allowlist_type("Foo.*")
.derive_debug(true)
.derive_default(true)
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("bindgen 실패");
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings.write_to_file(out.join("bindings.rs")).unwrap();
}
// src/lib.rs (foo-sys)
#![allow(non_upper_case_globals, non_camel_case_types, non_snake_case)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
이렇게도 쓸 수 있다 — 안전한 래퍼 씌우기 🟡 중급
RAII 로 감싸기 ★
use foo_sys as ffi;
use std::ptr::NonNull;
pub struct Context {
raw: NonNull<ffi::FooContext>, // ★ 널이 아님을 타입으로 (r08)
}
impl Context {
pub fn new() -> Result<Self, Error> {
let raw = unsafe { ffi::foo_create() };
NonNull::new(raw)
.map(|raw| Context { raw })
.ok_or(Error::AllocFailed)
}
pub fn process(&mut self, input: &[u8]) -> Result<Vec<u8>, Error> {
let mut out_len: usize = 0;
let rc = unsafe {
ffi::foo_process(self.raw.as_ptr(),
input.as_ptr(), input.len(),
std::ptr::null_mut(), 0, &mut out_len)
};
if rc != 0 && rc != ffi::FOO_BUFFER_TOO_SMALL { return Err(Error::from(rc)); }
let mut buf = vec![0u8; out_len];
let rc = unsafe {
ffi::foo_process(self.raw.as_ptr(),
input.as_ptr(), input.len(),
buf.as_mut_ptr(), buf.len(), &mut out_len)
};
if rc != 0 { return Err(Error::from(rc)); }
buf.truncate(out_len);
Ok(buf)
}
}
impl Drop for Context {
fn drop(&mut self) { unsafe { ffi::foo_destroy(self.raw.as_ptr()) } }
}
// 스레드 안전성은 C 라이브러리 문서를 읽고 판단하세요 (⚡ e06 · 🔬 d08)
unsafe impl Send for Context {}
// unsafe impl Sync for Context {} ← 정말 스레드 안전한 경우에만 ★
링크 방식 고르기
// build.rs 에서 출력하는 지시들
println!("cargo:rustc-link-lib=foo"); // 동적 링크 (-lfoo)
println!("cargo:rustc-link-lib=static=foo"); // 정적 링크 ★
println!("cargo:rustc-link-search=native=/usr/local/lib");
println!("cargo:rustc-link-lib=framework=CoreFoundation"); // 맥
// C 소스를 함께 컴파일 (cc 크레이트) — 의존성이 사라집니다 ★
// [build-dependencies] cc = "1"
cc::Build::new()
.file("vendor/foo.c")
.include("vendor/include")
.opt_level(3)
.compile("foo");
★ 판단
동적 링크
· 시스템에 이미 있는 라이브러리 (openssl · zlib)
· 배포 시 그 라이브러리가 반드시 있어야 합니다 ★
· 보안 패치가 시스템 업데이트로 적용됩니다
정적 링크 / 소스 동봉 (vendored)
· 배포가 단순합니다 (파일 하나)
· 파이썬 wheel · npm 패키지로 낼 때 사실상 필수 ★
· 라이선스를 확인하세요 (LGPL 은 정적 링크에 제약이 있습니다)
· 대개 vendored feature 로 선택 가능하게 만듭니다 (🛠️ b04)
# 확인
ldd target/release/libmylib.so # 동적 의존성이 뭔지
실무에선 이렇게 — 빌드 재현성 확보 🔴 실무
bindgen 의 현실적 문제들 ★
① libclang 이 필요합니다
빌드 머신마다 설치돼 있어야 합니다.
→ CI 에서 apt install libclang-dev 등
→ 또는 생성된 bindings.rs 를 커밋하고 feature 로 선택 ★
② 헤더 버전에 따라 결과가 달라집니다
시스템 헤더가 다르면 다른 바인딩이 나옵니다.
→ vendored 소스를 쓰면 고정됩니다
③ 생성물이 큽니다 (수천 줄)
→ allowlist 로 필요한 것만 (위 예제)
④ 크로스 컴파일이 까다롭습니다 (🛠️ b07)
타깃 헤더를 clang 에 알려 줘야 합니다
→ --sysroot · --target 인자
★ 실무 권장 구성
[features]
default = ["vendored"]
vendored = [] # 소스를 함께 빌드 (재현 가능)
system = [] # 시스템 라이브러리 사용
bindgen = ["dep:bindgen"] # 바인딩 재생성 (개발자용)
→ 기본은 커밋된 bindings.rs + vendored 소스 ★
파이썬·Node 로 다시 내보내기
★ 이 조합이 실제로 유용한 자리
기존 C 라이브러리 (libfoo)
↓ bindgen
foo-sys (unsafe)
↓ 사람이 작성
foo (안전한 Rust API)
↓ PyO3 / napi-rs
파이썬 · Node 모듈 ★
왜 Rust 를 중간에 두나
· C API 를 직접 파이썬에 노출하면 안전성 검증을 파이썬에서 못 합니다
· Rust 층에서 RAII · 에러 변환 · 슬라이스 검증을 한 번만 하면
파이썬·Node 양쪽이 그 혜택을 받습니다
· 테스트도 Rust 쪽에서 한 번만 (r11)
배포 주의
· 동적 링크한 C 라이브러리가 있으면 wheel/npm 에 동봉해야 합니다
auditwheel repair (파이썬 · 🐍 y11)
· 정적 링크가 배포를 훨씬 단순하게 만듭니다 ★
ctypes 로 직접 부르는 것보다
훨씬 안전하고, 나중에 Node·C# 지원을 추가하기도 쉽습니다.
한 단계 더 — C 라이브러리의 함정 🧬 전문가
문서를 읽고 확인할 것 ★
□ 스레드 안전한가
· 전역 상태를 쓰는가 (errno 같은 것)
· 초기화 함수가 한 번만 불려야 하는가 → OnceLock (r02)
· 컨텍스트별로는 안전한가 → Send 만 붙이고 Sync 는 금지
□ 재진입 가능한가 (콜백 안에서 다시 부를 수 있나)
□ 반환된 포인터의 수명은
· 정적인가 (해제 금지)
· 다음 호출까지만 유효한가 ★ (strtok 같은 것)
· 호출자가 해제하는가 → Drop 으로
□ 에러를 어떻게 알리나
· 반환값 · errno · 별도 함수 (f09)
· errno 는 스레드 로컬입니다 — 호출 직후에 읽어야 합니다
□ 시그널·longjmp 를 쓰는가 ★
· setjmp/longjmp 가 Rust 스택을 건너뛰면 Drop 이 안 불립니다
· libpng · libjpeg 이 이 방식을 씁니다 — 주의가 필요합니다
□ 할당자를 교체할 수 있나
· 그렇다면 Rust 할당자와 맞춰 둘 수 있습니다
검증 도구
# ① 생성된 바인딩 확인
cargo expand --lib | head -100
find target -name bindings.rs -exec head -50 {} \;
# ② 타입 크기가 C 와 맞는지 (bindgen 이 테스트를 생성합니다)
cargo test --package foo-sys
# bindgen 은 layout test 를 자동 생성합니다 ★
# const _: () = assert!(size_of::<FooContext>() == 64);
# ③ ASan 으로 메모리 오류 (Miri 는 C 코드를 못 봅니다 · 🔬 d07)
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test \
--target x86_64-unknown-linux-gnu
# ④ valgrind
valgrind --leak-check=full ./target/debug/deps/foo_test-*
# ⑤ 실제 라이브러리 버전 확인
pkg-config --modversion libfoo
ldd target/release/libmylib.so | grep foo
# ★ C 라이브러리를 링크하는 순간
# Rust 의 안전성 보장이 그 경계에서 끝납니다.
# 래퍼 층에 테스트를 두텁게 쓰는 것이 유일한 방어입니다 (r11)
-sys(bindgen 생성) + 안전 래퍼 두 층으로 감쌉니다.
RAII(Drop) · Result · 슬라이스로 감싸면 사용자는 unsafe 를 안 씁니다.
배포를 단순하게 하려면 정적 링크 또는 vendored 소스를 쓰고,
스레드 안전성은 C 문서를 읽고 Send/Sync 를 결정하세요.패닉이 경계를 넘으면 — 그리고 에러 코드
Rust 패닉이 C 코드를 가로질러 되감기면 정의되지 않은 동작입니다.
그래서 모든 extern "C" 함수는 패닉을 가두고 에러 코드로 바꿔야 합니다.
이 섹션은 FFI 에서 가장 중요한 안전 규칙입니다.
catch_unwind 로 가두기 ★
use std::panic::{catch_unwind, AssertUnwindSafe};
#[no_mangle]
pub unsafe extern "C" fn mylib_process(data: *const u8, len: usize) -> i32 {
let result = catch_unwind(AssertUnwindSafe(|| {
let Some(src) = to_slice(data, len) else { return -1 };
match core_process(src) {
Ok(_) => 0,
Err(e) => { set_last_error(e.to_string()); code_of(&e) }
}
}));
match result {
Ok(code) => code,
Err(_) => { // ★ 패닉을 잡았습니다
set_last_error("내부 오류 (패닉)".into());
-99
}
}
}
// 매크로로 반복을 줄이기
macro_rules! ffi_guard {
($body:expr) => {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) {
Ok(v) => v,
Err(_) => { set_last_error("내부 오류 (패닉)".into()); -99 }
}
};
}
#[no_mangle]
pub unsafe extern "C" fn mylib_encode(...) -> i32 {
ffi_guard!({ /* 본문 */ })
}
extern "C" 함수는 패닉 시 기본적으로 abort 합니다.
UB 는 피하지만 프로세스가 죽습니다 —
파이썬 인터프리터나 Node 서버가 통째로 종료되는 것은 여전히 최악이므로,
잡아서 에러 코드로 바꾸는 것이 정답입니다.
에러 코드 설계 ★
// 안정된 숫자 — 한 번 배포하면 절대 바꾸지 마세요 ★
#[repr(i32)]
pub enum Code {
Ok = 0,
InvalidArg = -1,
BufferTooSmall= -2,
NotFound = -3,
IoError = -4,
Unsupported = -5,
// 새 실패는 새 번호를 추가 (기존 번호 재사용 금지)
Internal = -99,
}
// 스레드 로컬 마지막 에러 (errno 와 같은 방식)
use std::cell::RefCell;
thread_local! {
static LAST_ERROR: RefCell<String> = RefCell::new(String::new());
}
fn set_last_error(msg: String) {
LAST_ERROR.with(|e| *e.borrow_mut() = msg);
}
#[no_mangle]
pub unsafe extern "C" fn mylib_last_error(buf: *mut c_char, cap: usize) -> usize {
LAST_ERROR.with(|e| {
let s = e.borrow();
let bytes = s.as_bytes();
let n = bytes.len().min(cap.saturating_sub(1));
if !buf.is_null() && cap > 0 {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, n);
*buf.add(n) = 0; // 널 종단 ★
}
bytes.len() // 전체 길이를 반환 (버퍼 크기 판단용)
})
}
이렇게도 쓸 수 있다 — 호환을 유지하며 확장하기 🟡 중급
깨지 않고 기능을 추가하는 법 ★
★ 절대 하면 안 되는 것 (배포 후)
· 함수 시그니처 변경 (인자 추가·타입 변경)
· 구조체에 필드 추가·순서 변경 (f03)
· enum 값 변경
· 함수 제거
★ 해도 되는 것
· 새 함수 추가
· 새 enum 값 추가 (기존 값은 유지)
· 동작 개선 (같은 입력 → 같은 출력이면)
★ 확장 가능하게 미리 설계하기
① 버전이 든 구조체
typedef struct { uint32_t size; uint32_t flags; ... } Options;
mylib_init(const Options *o);
→ 함수가 o->size 를 보고 어느 버전인지 판단합니다 ★
(윈도우 API 가 전부 이 방식입니다)
② 예약 필드
typedef struct { int32_t a; int32_t _reserved[4]; } Config;
③ 이름에 버전
mylib_process_v2(...) ← 기존 mylib_process 는 유지
(f02 의 #[export_name] 으로)
④ 설정을 JSON 문자열로 (f01) — 가장 유연합니다
버전이 든 구조체 구현
#[repr(C)]
pub struct Options {
pub size: u32, // ★ 반드시 첫 필드
pub threads: u32,
pub timeout_ms: u64,
// v2 에서 추가
pub retries: u32,
}
#[no_mangle]
pub unsafe extern "C" fn mylib_init(opts: *const Options) -> i32 {
let size = if opts.is_null() { 0 } else { (*opts).size as usize };
let threads = if size >= offset_of!(Options, threads) + 4 {
(*opts).threads
} else { 0 };
let retries = if size >= offset_of!(Options, retries) + 4 {
(*opts).retries // ★ v2 이상만
} else { 3 }; // v1 이면 기본값
// …
0
}
// 호출자 (C)
// Options o = {0};
// o.size = sizeof(o); ← 자기가 아는 크기를 알려 줍니다
// o.threads = 4;
// mylib_init(&o);
//
// → 옛 헤더로 컴파일된 앱도, 새 헤더로 컴파일된 앱도 모두 동작합니다 ★
실무에선 이렇게 — 버전 정책과 SONAME 🔴 실무
런타임 버전 확인
// 호출자가 기능 지원 여부를 물어볼 수 있게 하기
#[no_mangle]
pub extern "C" fn mylib_version() -> u32 {
(1 << 16) | 2 // 1.2
}
#[no_mangle]
pub extern "C" fn mylib_has_feature(name: *const c_char) -> bool {
let Some(n) = cstr_to_str(name) else { return false };
matches!(n, "simd" | "threads" | "compression")
}
// ABI 버전을 따로 두는 것도 좋습니다
#[no_mangle]
pub extern "C" fn mylib_abi_version() -> u32 { 1 }
// → ABI 가 깨지면 이 숫자를 올리고, 호출자가 확인해 거부하게 합니다 ★
if (mylib_abi_version() != MYLIB_EXPECTED_ABI) {
fprintf(stderr, "호환되지 않는 라이브러리 버전\n");
return 1;
}
SONAME 과 심볼 버저닝
# 리눅스의 관례 — 파일 이름에 메이저 버전
# libmylib.so.1.2.0 실제 파일
# libmylib.so.1 → 심링크 (SONAME)
# libmylib.so → 심링크 (개발용)
# SONAME 설정 (build.rs 또는 .cargo/config.toml)
# rustflags = ["-C", "link-arg=-Wl,-soname,libmylib.so.1"]
# 확인
objdump -p libmylib.so | grep SONAME
readelf -d libmylib.so | grep SONAME
# ★ 왜 필요한가
# ABI 를 깨는 변경을 하면 SONAME 의 숫자를 올립니다.
# 그러면 옛 버전과 새 버전이 시스템에 공존할 수 있고,
# 옛 앱은 옛 것을, 새 앱은 새 것을 로드합니다.
# 맥
# install_name 을 씁니다 (-install_name @rpath/libmylib.1.dylib)
# 윈도우
# 파일 이름에 버전을 넣는 것이 관례 (mylib-1.dll)
# ★ 파이썬 wheel · npm 패키지로만 배포한다면
# 각 패키지가 자기 바이너리를 갖고 있으므로 SONAME 이 덜 중요합니다.
# 시스템 라이브러리로 배포할 때 필요합니다.
한 단계 더 — 언와인딩의 실제 🧬 전문가
왜 UB 인가
Rust 패닉은 기본적으로 '스택 되감기(unwinding)' 입니다.
C++ 예외와 같은 메커니즘(DWARF/SEH)을 씁니다.
Rust 함수 → C 함수 → Rust 콜백에서 패닉
↑
여기를 되감으려면 C 프레임의 정리 정보가 필요한데,
C 는 그런 정보를 갖고 있지 않습니다.
→ 정의되지 않은 동작 (대개 abort 또는 힙 손상) ★
★ 대응
① 모든 extern "C" 함수를 catch_unwind 로 감싸기 (이 섹션)
② panic = "abort" 프로파일 — 되감기 자체가 없어집니다
· 바이너리가 작아지고 약간 빨라집니다 (🛠️ b10)
· 하지만 패닉 = 프로세스 종료입니다
· PyO3 에서는 쓰지 마세요 (예외로 못 바꿈 · 🐍 y03)
③ extern "C-unwind" — 되감기를 명시적으로 허용하는 ABI
C++ 예외와 상호 운용해야 할 때만 (Rust 1.71+)
# 확인
cargo rustc --release -- --print cfg | grep panic
RUST_BACKTRACE=1 ./app
패닉을 아예 줄이기 ★
// catch_unwind 는 안전망이지, 설계가 아닙니다.
// FFI 층에서는 패닉이 날 일 자체를 없애세요.
// ❌ 패닉하는 코드
v[i] // 범위 초과 → panic
s.unwrap() // None → panic
a / b // b == 0 → panic (정수)
x as usize // 음수 → 릴리즈에선 거대한 수 (r02)
// ✅
v.get(i).ok_or(Code::InvalidArg)?
s.ok_or(Code::NotFound)?
a.checked_div(b).ok_or(Code::InvalidArg)?
usize::try_from(x).map_err(|_| Code::InvalidArg)?
// clippy 로 강제하기
// #![deny(clippy::unwrap_used, clippy::expect_used,
// clippy::indexing_slicing, clippy::panic)]
// FFI 모듈에만 붙이면 실용적입니다 ★
// 할당 실패는 잡을 수 없습니다
// Rust 는 OOM 시 abort 합니다 (catch_unwind 로 못 잡습니다)
// 큰 할당은 try_reserve 로 미리 확인하세요
let mut v = Vec::new();
v.try_reserve(n).map_err(|_| Code::Internal)?;
#![deny(clippy::unwrap_used)] 를 붙이세요 —
패닉 가능성을 컴파일 타임에 제거하는 것이
catch_unwind 로 잡는 것보다 훨씬 낫습니다.
extern "C" 함수는 catch_unwind 로 패닉을 가두고
에러 코드 + last_error 함수로 실패를 알립니다.
에러 코드 숫자는 한 번 배포하면 절대 바꾸지 마세요.
확장은 새 함수 · 새 코드 · 크기 필드가 든 구조체로 하고,
FFI 층에는 unwrap·인덱싱을 clippy 로 금지하세요.C# · 자바 · Go — 한 라이브러리를 여러 언어에서
C ABI 로 내보냈으면 거의 모든 언어에서 부를 수 있습니다. 언어마다 문법만 다를 뿐 패턴은 같습니다 — 로드 · 시그니처 선언 · 호출 · 해제.
C# — P/Invoke
using System;
using System.Runtime.InteropServices;
internal static partial class Native
{
private const string Lib = "mylib"; // libmylib.so / mylib.dll
// .NET 7+ 의 소스 생성 방식 (더 빠릅니다) ★
[LibraryImport(Lib)]
internal static partial int mylib_add(int a, int b);
[LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)]
internal static partial IntPtr mylib_greet(string name);
[LibraryImport(Lib)]
internal static partial void mylib_string_free(IntPtr s);
// 예전 방식
[DllImport(Lib, CallingConvention = CallingConvention.Cdecl)]
internal static extern double mylib_distance(Point a, Point b);
}
[StructLayout(LayoutKind.Sequential)] // ★ #[repr(C)] 에 대응 (f03)
internal struct Point { public double X, Y; }
// 안전한 래퍼
public static string Greet(string name)
{
IntPtr p = Native.mylib_greet(name);
if (p == IntPtr.Zero) throw new InvalidOperationException(LastError());
try { return Marshal.PtrToStringUTF8(p)!; }
finally { Native.mylib_string_free(p); } // ★ 반드시 해제 (f06)
}
Go — cgo
package mylib
/*
#cgo LDFLAGS: -L${SRCDIR}/lib -lmylib
#cgo CFLAGS: -I${SRCDIR}/include
#include "mylib.h"
#include <stdlib.h>
*/
import "C"
import (
"errors"
"unsafe"
)
func Add(a, b int32) int32 {
return int32(C.mylib_add(C.int32_t(a), C.int32_t(b)))
}
func Greet(name string) (string, error) {
cname := C.CString(name) // Go → C 문자열 (복사)
defer C.free(unsafe.Pointer(cname)) // ★ C 힙이므로 free
p := C.mylib_greet(cname)
if p == nil { return "", errors.New(lastError()) }
defer C.mylib_string_free(p) // ★ 라이브러리 해제 함수 (f06)
return C.GoString(p), nil
}
// 슬라이스 넘기기 — 복사 없음
func Checksum(data []byte) uint32 {
if len(data) == 0 { return 0 }
return uint32(C.mylib_checksum(
(*C.uint8_t)(unsafe.Pointer(&data[0])), C.size_t(len(data))))
}
// ❗ Go 의 GC 가 데이터를 옮길 수 있습니다.
// 호출 중에는 안전하지만, C 쪽이 포인터를 보관하면 안 됩니다 ★
이렇게도 쓸 수 있다 — 자바와 그 외 🟡 중급
자바 — JNA / FFM API
// JNA — 간단하지만 호출 비용이 있습니다
import com.sun.jna.*;
public interface MyLib extends Library {
MyLib INSTANCE = Native.load("mylib", MyLib.class);
int mylib_add(int a, int b);
Pointer mylib_greet(String name);
void mylib_string_free(Pointer s);
}
String greet(String name) {
Pointer p = MyLib.INSTANCE.mylib_greet(name);
if (p == null) throw new IllegalStateException();
try { return p.getString(0); }
finally { MyLib.INSTANCE.mylib_string_free(p); }
}
// Java 22+ 의 FFM API (Project Panama) — JNA 보다 훨씬 빠릅니다 ★
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
try (Arena arena = Arena.ofConfined()) {
SymbolLookup lib = SymbolLookup.libraryLookup("mylib", arena);
MethodHandle add = Linker.nativeLinker().downcallHandle(
lib.find("mylib_add").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_INT,
ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
int r = (int) add.invokeExact(1, 2);
}
// ☕ 자바 가이드에도 관련 내용이 있습니다
그 외 언어들
Node koffi · ffi-napi (🟨 j01 — 다만 napi-rs 가 더 낫습니다)
Deno Deno.dlopen (내장 FFI)
Ruby Fiddle (표준) · ffi 젬
PHP FFI 확장 (7.4+)
Swift C 헤더를 직접 import (브리징 헤더)
Kotlin JNA · Kotlin/Native cinterop
Lua LuaJIT FFI · luajit ffi.cdef
R .C() · Rcpp
Julia ccall — 문법이 매우 간결합니다
Zig @cImport — 헤더를 그대로 읽습니다
★ 공통 패턴
① 라이브러리 로드 (경로 · 이름 · 플랫폼별 확장자)
② 시그니처 선언 (직접 또는 헤더 파싱)
③ 호출 (타입 변환)
④ 해제 (라이브러리가 준 것은 라이브러리 함수로 · f06)
★ 배포할 때
각 언어의 패키지 매니저 관례를 따르세요
C#: NuGet (runtimes/{rid}/native/)
자바: JAR 안에 넣고 임시 폴더로 추출
Go: 정적 링크 권장 (cgo 는 크로스 컴파일이 까다롭습니다)
실무에선 이렇게 — 다언어 배포를 관리한다 🔴 실무
저장소 구조 ★
mylib/
├─ core/ Rust 핵심 로직 (순수 · 테스트 완비)
├─ ffi/ C ABI 층 (얇게 · f01)
│ └─ cbindgen.toml
├─ include/mylib.h 생성된 헤더 (커밋)
├─ bindings/
│ ├─ python/ ctypes 래퍼 + setup.py
│ ├─ node/ koffi 래퍼 + package.json
│ ├─ csharp/ P/Invoke + .csproj
│ └─ go/ cgo 래퍼 + go.mod
├─ examples/ 언어별 최소 예제 ★
└─ .github/workflows/
├─ build.yml 플랫폼별 바이너리
└─ test-bindings.yml 각 언어에서 실제로 부르는 테스트 ★
★ 핵심: 각 언어 바인딩에 '스모크 테스트' 를 두고 CI 에서 돌리세요
· 로드되는가
· 함수가 불리는가
· 값이 맞는가
· 해제가 되는가 (반복 호출하며 메모리 관찰 · f06)
버전 동기화
★ 문제
Rust 코어 1.2.0 을 배포했는데
파이썬 패키지는 1.1.0, C# 은 1.0.0 인 상태가 되기 쉽습니다.
★ 대응
① 버전을 한 곳에서 관리 (Cargo.toml 이 원본)
CI 에서 각 바인딩의 버전 파일을 갱신
② 런타임 버전 확인 (f09)
바인딩이 로드 후 mylib_version() 을 확인해
맞지 않으면 명확한 에러를 냅니다 ★
③ 태그 하나로 전부 배포
git tag v1.2.0 → CI 가 crates.io · PyPI · npm · NuGet 에 동시 배포
★ 릴리스 체크리스트
□ 헤더가 최신인가 (f04 의 git diff 검사)
□ 모든 플랫폼 바이너리가 빌드됐는가
□ 각 언어 스모크 테스트가 통과했는가
□ ABI 를 깨는 변경이 있었는가 → 메이저 올림 + SONAME (f09)
□ CHANGELOG 에 각 언어 사용자를 위한 마이그레이션 안내가 있는가
한 단계 더 — 호출 비용 비교 🧬 전문가
언어별 FFI 오버헤드
★ 직접 재 보세요 (환경마다 크게 다릅니다 · ⚡ e01)
같은 빈 함수를 각 언어에서 호출해 비교하는 벤치마크를 만드세요.
일반적 경향 (상대적)
C / C++ 기준 (거의 0)
Rust → Rust 기준
Go (cgo) 비쌈 — 고루틴 스택 전환이 필요 ★
C# (LibraryImport) 낮음
C# (DllImport) 중간
자바 (FFM) 낮음
자바 (JNA) 높음
파이썬 (ctypes) 높음 (🐍 y01)
파이썬 (PyO3) 낮음
Node (napi-rs) 낮음
Node (koffi) 중간
★ 실무 의미
· Go 는 cgo 호출이 비싸므로 배치가 특히 중요합니다
· 파이썬·자바는 전용 바인딩(PyO3 · JNI)이 FFI 보다 빠릅니다
· 어느 언어든 '경계를 적게 넘는' 설계가 답입니다 (f01 · 🐍 y01)
벤치마크 만들기
// 비교용 함수들을 라이브러리에 넣어 두면 편합니다
#[no_mangle] pub extern "C" fn mylib_noop() {}
#[no_mangle] pub extern "C" fn mylib_add_bench(a: i32, b: i32) -> i32 { a + b }
#[no_mangle] pub unsafe extern "C" fn mylib_sum_bench(p: *const f64, n: usize) -> f64 {
if p.is_null() || n == 0 { return 0.0 }
std::slice::from_raw_parts(p, n).iter().sum()
}
# 파이썬 쪽 측정
import timeit
print(timeit.timeit(lambda: lib.mylib_noop(), number=1_000_000))
# 이 값이 '호출 하나의 하한선' 입니다 ★
# 데이터 크기별로 재서 손익분기점을 찾으세요
for n in (10, 100, 1_000, 100_000):
a = np.random.random(n)
p = a.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
t_rust = timeit.timeit(lambda: lib.mylib_sum_bench(p, n), number=10_000)
t_np = timeit.timeit(lambda: a.sum(), number=10_000)
print(n, t_rust, t_np)
# ★ 작은 n 에서는 numpy 가 이기고, 큰 n 에서 역전되는 지점이 보입니다
도구 하나가
전부를 합니다
C++ 에서 CMake · vcpkg · 컴파일러 플래그 · CI 스크립트를 각각 배워야 했던 자리를
cargo 하나가 대신합니다.
여기서는 실무에 실제로 필요한 것만 — Cargo.toml 읽는 법, feature 설계,
크로스 컴파일, 그리고 빌드가 느려졌을 때 무엇부터 손대야 하는지를 봅니다.
cargo 가 하는 일 — 한 도구로 전부
cargo 는 빌드 도구이자 패키지 관리자이자 테스트 러너이자 문서 생성기입니다. 이 하나가 C++ 의 도구 대여섯 개를 대신합니다 — 그리고 그것이 Rust 로 네이티브 모듈을 만들 때 가장 크게 체감되는 이점입니다.
C++ 와 나란히 ★
| 하는 일 | C++ | Rust |
|---|---|---|
| 빌드 | CMake · Make | cargo |
| 의존성 | vcpkg · Conan | |
| 테스트 | gtest · Catch2 | |
| 벤치마크 | google-benchmark | |
| 문서 | Doxygen | |
| 포맷·린트 | clang-format · tidy |
# 매일 쓰는 명령
cargo new 이름 --lib # 프로젝트 만들기
cargo check # 타입 검사만 ★ 가장 자주 (build 보다 3~5배 빠름)
cargo build # 디버그 빌드
cargo build --release # 최적화 빌드 ★ 성능 측정은 반드시 이것
cargo run # 빌드 + 실행
cargo test # 테스트 (문서 예제까지 · r11)
cargo doc --open # 문서 생성 + 브라우저
cargo add rand # 의존성 추가
cargo clippy # 린트 (b09)
cargo fmt # 포맷 (b09)
cargo bench # 벤치마크 (⚡ e01)
그 외 알아 두면 좋은 것
cargo tree # 의존성 트리 ★
cargo tree -d # 중복된 크레이트 (r09)
cargo tree -i syn # 누가 syn 을 끌어왔나 (역추적)
cargo update # 의존성 갱신 (b03)
cargo clean # target/ 지우기
cargo build --timings # 무엇이 오래 걸리나 → HTML 리포트 ★
cargo build -v # 실제 rustc 명령 보기
cargo metadata --format-version 1 | jq # 프로젝트 정보를 JSON 으로
# 유용한 서브커맨드 (cargo install 로 설치)
cargo install cargo-edit cargo-outdated cargo-audit cargo-deny
cargo install cargo-expand # 매크로 확장 보기 (🔬 d09)
cargo install cargo-bloat # 바이너리에서 뭐가 큰지 (b10)
cargo install cargo-nextest # 더 빠른 테스트 러너 ★
cargo install cargo-watch # 파일 변경 감시
cargo check 를 습관으로 — 코드를 생성하지 않고 타입만 보므로
훨씬 빠릅니다. 에디터의 rust-analyzer 가 저장할 때마다 자동으로 돌려 줍니다.
빌드는 실제로 실행할 때만 하면 됩니다.
이렇게도 쓸 수 있다 — 빌드 결과물이 어디에 생기나 🟡 중급
target 디렉터리
target/
├─ debug/ cargo build
│ ├─ libmylib.so cdylib 결과물
│ ├─ libmylib.rlib Rust 라이브러리
│ ├─ deps/ 의존 크레이트 + 테스트 바이너리
│ ├─ build/ build.rs 실행 결과
│ └─ incremental/ 증분 컴파일 캐시 ★ 크기가 큽니다
├─ release/ cargo build --release
├─ doc/ cargo doc
└─ CACHEDIR.TAG 백업 도구가 건너뛰게 하는 표시
# 크기 확인 — 수 GB 가 되는 것이 정상입니다
du -sh target/*
# 여러 프로젝트가 캐시를 공유하게 (b10)
export CARGO_TARGET_DIR=~/.cargo-shared-target
# 안 쓰는 결과물 정리
cargo install cargo-sweep
cargo sweep --time 30 # 30일 이상 안 쓴 것 삭제
환경 변수와 설정 파일
# .cargo/config.toml — 프로젝트나 홈 디렉터리에
[build]
target-dir = "target"
rustflags = ["-C", "target-cpu=native"] # ❗ 배포용엔 쓰지 마세요 (b06)
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"] # 링커 교체 — 빌드가 빨라집니다 ★
[alias]
c = "check"
t = "test"
rr = "run --release"
[net]
git-fetch-with-cli = true # 사내 git 인증이 필요할 때
# 자주 쓰는 환경 변수
CARGO_TARGET_DIR=... 빌드 결과 위치
RUSTFLAGS="-C ..." 컴파일 플래그 ★ 바꾸면 전체 재빌드
RUST_BACKTRACE=1 패닉 시 스택 트레이스
RUST_LOG=debug log 크레이트 레벨
RUSTC_WRAPPER=sccache 빌드 캐시 (b10)
CARGO_NET_OFFLINE=true 오프라인 빌드
실무에선 이렇게 — build.rs 로 빌드에 끼어들기 🔴 실무
언제 쓰나
// build.rs — 크레이트 컴파일 '전에' 실행됩니다
fn main() {
// ① C 코드 함께 컴파일 (🔗 f08)
cc::Build::new().file("src/fast.c").compile("fast");
// ② 바인딩 생성 (bindgen · cbindgen · 🔗 f04·f08)
// ③ 코드 생성 (프로토콜 정의 → Rust 소스)
let out = std::env::var("OUT_DIR").unwrap();
std::fs::write(format!("{out}/generated.rs"), gen_code()).unwrap();
// ④ 빌드 시각 · git 해시를 상수로 ★
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"]).output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
println!("cargo:rustc-env=GIT_HASH={hash}");
// ⑤ 조건부 컴파일 플래그
if std::env::var("CARGO_CFG_TARGET_OS").unwrap() == "windows" {
println!("cargo:rustc-cfg=feature=\"win\"");
}
// ★ 언제 다시 실행할지 반드시 지정하세요
println!("cargo:rerun-if-changed=src/fast.c");
println!("cargo:rerun-if-env-changed=MYLIB_PATH");
}
// 생성된 코드 쓰기
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
const GIT_HASH: &str = env!("GIT_HASH");
build.rs 의 주의점
□ rerun-if 를 지정하지 않으면
소스가 하나라도 바뀔 때마다 build.rs 가 다시 돕니다 ★
→ 빌드가 눈에 띄게 느려집니다
□ 빌드 머신에 도구를 요구합니다
cc → C 컴파일러 · bindgen → libclang
→ 사용자가 sdist 로 설치할 때 실패의 원인 (🐍 y12)
→ 가능하면 생성물을 커밋하고 feature 로 선택 (🔗 f08)
□ 크로스 컴파일에서 헷갈립니다 (b07)
build.rs 는 '호스트' 에서 컴파일·실행됩니다
HOST vs TARGET 환경 변수를 구분하세요
□ 네트워크를 쓰지 마세요
빌드 재현성이 깨지고, 오프라인 빌드가 불가능해집니다
□ 출력은 OUT_DIR 에만
소스 디렉터리에 쓰면 crates.io 게시가 실패합니다
★ 대안을 먼저 검토하세요
· 상수 → 그냥 코드에 쓰기
· 코드 생성 → 절차 매크로 (🔬 d09)
· 플랫폼 분기 → #[cfg(target_os = "...")]
build.rs 는 임의 코드를 실행합니다 —
의존성을 추가할 때 그 크레이트의 build.rs 도 내 머신에서 돕니다.
공급망 보안 관점에서 cargo-deny 로 감사하세요(b09).
한 단계 더 — cargo 가 rustc 를 부르는 방식 🧬 전문가
지문(fingerprint)과 증분 빌드
cargo 는 크레이트마다 '지문' 을 계산해 저장합니다
· 소스 파일 내용(mtime + 해시)
· 컴파일 플래그 · feature 조합
· 의존 크레이트의 지문
· rustc 버전 · 타깃
하나라도 바뀌면 그 크레이트와 '그것에 의존하는 전부' 를 다시 빌드합니다.
★ 그래서 이런 일이 생깁니다
· RUSTFLAGS 를 바꾸면 → 전체 재빌드
· feature 를 켜고 끄면 → 그 크레이트 이하 전부
· --release 와 debug 를 오가면 → 각각 별도 (둘 다 캐시됩니다)
· CI 에서 캐시가 안 먹으면 → 매번 전체 빌드 (b08)
# 왜 다시 빌드하는지 보기
CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build 2>&1 | head -30
# 무엇이 오래 걸리는지
cargo build --timings
# → target/cargo-timings/cargo-timing.html ★ 병렬화 상태까지 보입니다
직접 rustc 를 부를 때
# cargo 없이 (의존성이 없다면)
rustc --edition 2021 -O src/lib.rs --crate-type cdylib
# cargo 가 넘기는 인자 보기
cargo build -v 2>&1 | grep rustc | head -3
# 특정 크레이트에만 플래그 주기
cargo rustc --release -p mycrate -- -C target-cpu=native
# 어셈블리·LLVM IR 보기 (⚡ e03 · 🔬 d10)
cargo rustc --release -- --emit asm
cargo rustc --release -- --emit llvm-ir
cargo asm --lib my_function # cargo-show-asm (더 편합니다)
# 타입 크기 (r07 · 🔗 f03)
cargo +nightly rustc -- -Zprint-type-sizes
# 컴파일 단계별 시간 (🔬 d10)
cargo +nightly rustc -- -Ztime-passes
cargo 하나가 빌드·의존성·테스트·문서·린트를 전부 합니다.
편집 중엔 cargo check, 성능은 --release,
느려지면 cargo build --timings 로 원인을 보세요.
build.rs 는 강력하지만 rerun-if-changed 를 반드시 지정하고,
가능하면 안 쓰는 편이 배포가 단순해집니다.Cargo.toml — 읽는 법만 알면 됩니다
프로젝트가 무엇을 쓰는지 적어 두는 파일입니다. CMakeLists 처럼 로직이 들어가지 않고, 선언만 있습니다. 실무에서 실제로 만나는 항목만 정리합니다.
전체 구조
[package]
name = "mylib" # crates.io 에서의 이름 (하이픈 허용)
version = "0.1.0" # semver (b03)
edition = "2021" # 언어 에디션 (r01)
rust-version = "1.75" # 최소 컴파일러 버전 ★ 있으면 친절합니다
description = "빠른 텍스트 처리"
license = "MIT OR Apache-2.0" # Rust 생태계의 관례 ★
repository = "https://github.com/me/mylib"
keywords = ["text", "parser"] # 최대 5개
categories = ["text-processing"]
readme = "README.md"
exclude = ["tests/fixtures/*", "benches/data/*"] # 게시에서 제외
[lib]
name = "mylib" # 라이브러리 이름 (언더스코어)
crate-type = ["cdylib", "rlib"] # 🔗 f02 · 🐍 y03
[[bin]] # 실행 파일 (여러 개 가능)
name = "mylib-cli"
path = "src/bin/cli.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
rand = "0.8"
[dev-dependencies] # 테스트·벤치에서만 (배포물에 안 들어감) ★
criterion = "0.5"
[build-dependencies] # build.rs 에서만 (b01)
cc = "1"
[features] # b04
default = ["std"]
std = []
[profile.release] # b06
lto = true
의존성 표기 ★
[dependencies]
# 버전 (자세한 규칙은 b03)
serde = "1.0" # ^1.0 — 1.x 중 최신
serde = "=1.0.195" # 정확히 이 버전
serde = { version = "1", default-features = false } # 기본 feature 끄기 ★
# 경로 (워크스페이스 · b05)
mycore = { path = "../core" }
# git
mylib = { git = "https://github.com/me/mylib", tag = "v1.0" }
mylib = { git = "...", branch = "main" }
mylib = { git = "...", rev = "a1b2c3d" } # ★ 재현성을 위해 rev 권장
# 선택적 (feature 로 켜기 · b04)
tokio = { version = "1", optional = true }
# 플랫폼별 ★
[target.'cfg(windows)'.dependencies]
winapi = "0.3"
[target.'cfg(unix)'.dependencies]
nix = "0.29"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
# 이름 바꿔 쓰기 (같은 크레이트의 두 버전을 함께 쓸 때)
old-rand = { package = "rand", version = "0.7" }
default-features = false 가 실무에서 가장 유용한 옵션입니다 —
안 쓰는 기능이 빠져 빌드 시간과 바이너리 크기가 크게 줍니다(b04·b10).
WASM 빌드에서는 거의 필수입니다(🟨 j09).
이렇게도 쓸 수 있다 — 라이브러리로 게시하기 🟡 중급
crates.io 에 올리기
# ① 계정 · 토큰
cargo login # crates.io 에서 토큰 발급
# ② 게시 전 확인 ★
cargo package --list # 어떤 파일이 들어가나
cargo package # .crate 파일 만들기 (빌드까지 검증)
cargo publish --dry-run
# ③ 게시
cargo publish
# ❗ 되돌릴 수 없습니다
# yank 로 새 프로젝트가 못 쓰게만 할 수 있습니다 (기존은 계속 동작)
cargo yank --version 0.1.1
cargo yank --version 0.1.1 --undo
# 게시 전 체크리스트
# □ license · description · repository 가 있는가 (없으면 게시 거부)
# □ README 가 있는가
# □ 문서 주석이 있는가 (docs.rs 가 자동 생성 · r11)
# □ 예제가 도는가 (cargo test --doc)
# □ 불필요한 파일이 빠졌는가 (exclude)
# □ 이름이 이미 있는지 확인 (선점 주의)
문서와 메타데이터
[package.metadata.docs.rs]
all-features = true # docs.rs 에서 모든 feature 켜기 ★
rustdoc-args = ["--cfg", "docsrs"]
targets = ["x86_64-unknown-linux-gnu"]
// feature 가 필요한 항목에 배지 달기
#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(feature = "tokio")]
#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
pub mod async_api { }
// → docs.rs 에서 "Available on crate feature tokio only" 배지가 붙습니다
# 다른 도구를 위한 메타데이터
[package.metadata.maturin] # 🐍 y03
python-source = "python"
[package.metadata.wasm-pack.profile.release] # 🟨 j09
wasm-opt = ["-Oz"]
[package.metadata.deb] # 데비안 패키지
maintainer = "김현우"
실무에선 이렇게 — 공개 API 를 관리한다 🔴 실무
semver 를 지키는 도구
# 공개 API 가 깨졌는지 자동 검사 ★
cargo install cargo-semver-checks
cargo semver-checks check-release
# 출력 예
# --- failure enum_variant_added: pub enum variant added ---
# Description: A publicly-visible enum has a new variant.
# → #[non_exhaustive] 가 없으면 major 를 올려야 합니다 (r08)
# 공개 API 목록 뽑아 보기
cargo install cargo-public-api
cargo public-api
cargo public-api diff v0.1.0 # 이전 버전과 비교 ★
# CI 에 넣으면 실수로 API 를 깨는 것을 막습니다 (b08)
★ API 를 깨는 변경 (major 올림)
· pub 함수·타입 제거·이름 변경
· 함수 시그니처 변경
· struct 에 pub 필드 추가 (non_exhaustive 없이)
· enum 에 배리언트 추가 (non_exhaustive 없이)
· trait 에 기본 구현 없는 메서드 추가 (🔐 o05)
· 공개 타입의 트레이트 구현 제거
· 최소 rust-version 올림 (논쟁적이지만 minor 로 보는 편)
의존성을 API 에 노출하지 않기 ★
// ❌ serde_json::Value 를 공개 API 에 두면
pub fn parse(s: &str) -> serde_json::Value;
// → serde_json 을 major 업데이트할 때 내 API 도 깨집니다
// 사용자도 같은 버전을 써야 합니다 (r09 의 중복 크레이트 문제)
// ✅ 내 타입으로 감싸기
pub struct Value(serde_json::Value);
pub fn parse(s: &str) -> Result<Value, Error>;
// ✅ 또는 재수출해서 버전을 명확히
pub use serde_json; // 사용자가 mylib::serde_json 으로 씁니다
// ★ 예외: 사실상 표준인 타입은 노출해도 됩니다
// std 타입 · serde 트레이트 · http 크레이트의 타입 등
// 다만 Cargo.toml 에 버전 범위를 넉넉히 잡으세요
// 확인
cargo public-api | grep -v "^pub fn mylib" # 남의 타입이 보이는지
한 단계 더 — Cargo.toml 의 덜 알려진 항목 🧬 전문가
빌드 대상 세부 설정
[lib]
name = "mylib"
path = "src/lib.rs"
test = true # cargo test 대상에 포함
doctest = true # 문서 예제 실행 (r11)
bench = true
doc = true
proc-macro = false # 절차 매크로 크레이트면 true (🔬 d09)
harness = true # false 면 자체 main 을 씁니다 (criterion · ⚡ e01)
required-features = []
[[bench]]
name = "my_bench"
harness = false # ★ criterion 을 쓸 때 필수
[[test]]
name = "integration"
path = "tests/main.rs"
[[example]]
name = "demo"
required-features = ["tokio"] # 이 feature 가 있을 때만 빌드
# 자동 탐색 끄기 (파일이 많을 때 빌드가 빨라집니다)
[package]
autobins = false
autoexamples = false
autotests = false
autobenches = false
links 와 중복 방지
# 같은 C 라이브러리를 두 크레이트가 링크하면 심볼 충돌이 납니다
[package]
links = "foo" # ★ cargo 가 중복을 막아 줍니다 (🔗 f08)
# build.rs 에서 다른 크레이트에 정보 전달
# println!("cargo:include=/path/to/include");
# → 의존하는 크레이트의 build.rs 에서
# env::var("DEP_FOO_INCLUDE") 로 읽을 수 있습니다
# 패치 — 의존성을 임시로 교체 (버그 수정 대기 중일 때) ★
[patch.crates-io]
some-crate = { git = "https://github.com/me/some-crate", branch = "fix" }
some-crate = { path = "../some-crate-fork" }
# 워크스페이스 전체에 적용됩니다 (b05)
# ❗ crates.io 에 게시할 때는 patch 가 무시됩니다 —
# 게시 전에 정식 버전으로 되돌려야 합니다
[patch] 는 개발 중에만 쓰세요 —
게시된 크레이트에서는 무시되므로, 사용자는 패치 없는 원본을 받게 됩니다.
임시 수정이 필요하면 포크를 게시하거나 버그가 고쳐질 때까지 버전을 고정하세요.
Cargo.toml 은 선언만 있는 파일이라 읽는 법만 알면 됩니다.
default-features = false 로 안 쓰는 기능을 끄면 빌드와 크기가 크게 줄고,
[dev-dependencies] 는 배포물에 안 들어갑니다.
라이브러리를 게시한다면 cargo semver-checks 를 CI 에 넣고,
남의 타입을 공개 API 에 노출하지 마세요.의존성 · 버전 · Cargo.lock
"1.0" 이 정확히 무슨 뜻인지, Cargo.lock 을 커밋해야 하는지 —
이 둘만 알면 의존성 관리는 끝납니다.
재현 가능한 빌드가 목표입니다.
버전 표기의 뜻 ★
| 표기 | 허용 범위 |
|---|---|
"1.2.3" | ≥1.2.3, <2.0.0 ★ 기본 |
"^1.2.3" | 위와 같음 |
"~1.2.3" | ≥1.2.3, <1.3.0 |
"=1.2.3" | 정확히 이것만 |
"1.2" | ≥1.2.0, <2.0.0 |
"1" | ≥1.0.0, <2.0.0 |
"0.2.3" | ≥0.2.3, <0.3.0 ★ |
"*" | 아무거나 (게시 금지) |
★ 0.x 는 특별합니다
semver 에서 0.x 는 "아직 안정되지 않음" 이라
0.2 → 0.3 이 호환을 깰 수 있는 변경으로 취급됩니다.
→ Rust 생태계에는 0.x 크레이트가 아주 많습니다 (rand 0.8, pyo3 0.23)
→ 그래서 마이너 업데이트가 실제로 코드를 깨뜨립니다 ★
★ 실무 권장
· 라이브러리를 만든다 → 넓게 ("1", "0.23")
· 애플리케이션·서비스 → 넓게 쓰되 Cargo.lock 을 커밋 ★
· "=1.2.3" 고정은 피하세요 — 보안 패치도 못 받습니다
Cargo.lock 을 커밋할까 ★
★ 공식 권장
· 실행 파일 · 서비스 · 네이티브 모듈 → 커밋 ★
· 라이브러리(crates.io 게시) → 커밋해도 됩니다
(게시할 때는 무시되고, CI 재현성에는 도움이 됩니다)
★ 커밋하면
· 팀·CI·내 노트북이 정확히 같은 버전을 씁니다
· "어제는 됐는데 오늘 CI 가 깨졌다" 가 사라집니다 ★
· 의존성 업데이트가 명시적인 커밋이 됩니다 (리뷰 가능)
★ 커밋하지 않으면
· 매번 최신 호환 버전을 씁니다
· 새 버전의 버그를 바로 만납니다
# 갱신 명령
cargo update # 전부 (lock 범위 안에서)
cargo update -p serde # 하나만
cargo update -p serde --precise 1.0.195
cargo upgrade # cargo-edit — Cargo.toml 의 범위 자체를 올림
# 무엇이 바뀌는지 미리 보기
cargo update --dry-run
Cargo.lock 은 package-lock.json·poetry.lock 과 같은 역할입니다.
네이티브 모듈은 반드시 커밋하세요 — 빌드가 재현되지 않으면 디버깅이 불가능합니다.
이렇게도 쓸 수 있다 — 의존성을 줄이고 감사하기 🟡 중급
얼마나 들어왔는지 보기
cargo tree # 전체 트리
cargo tree --depth 1 # 직접 의존만
cargo tree -d # 중복 크레이트 ★ (r09)
cargo tree -i syn # syn 을 누가 끌어왔나
cargo tree -e no-dev # dev-dependencies 제외
cargo tree --prefix none | sort -u | wc -l # 전체 크레이트 수
# 무엇이 무거운지
cargo bloat --release --crates # 바이너리 기여도 (b10)
cargo build --timings # 빌드 시간 기여도
# ★ 실무 감각
# 작은 유틸 크레이트가 수백 개를 끌고 오는 경우가 있습니다.
# 특히 절차 매크로(syn · quote · proc-macro2)는 빌드 시간에 큽니다.
# "이 기능을 위해 30초를 더 쓸 가치가 있나" 를 물어보세요.
보안·라이선스 감사 ★
# 알려진 취약점
cargo install cargo-audit
cargo audit
# Crate: time
# Version: 0.1.44
# Warning: RUSTSEC-2020-0071 — Potential segfault
# Solution: Upgrade to >=0.2.23
# 종합 정책 검사 (라이선스 · 중복 · 금지 크레이트)
cargo install cargo-deny
cargo deny init # deny.toml 생성
cargo deny check
# deny.toml
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"]
# GPL 계열이 들어오면 실패합니다 ★ (배포 정책에 따라)
[bans]
multiple-versions = "warn"
deny = [{ name = "openssl", reason = "rustls 를 쓰세요" }]
[advisories]
ignore = ["RUSTSEC-2021-0139"] # 검토 후 무시하는 것만
# ★ CI 에 넣으세요 (b08)
# 새 의존성이 GPL 을 끌고 오는 것을 배포 전에 잡습니다
실무에선 이렇게 — 재현 가능한 빌드 🔴 실무
고정해야 할 것 네 가지 ★
① 컴파일러 버전 — rust-toolchain.toml (r01)
[toolchain]
channel = "1.83.0"
② 의존성 버전 — Cargo.lock 커밋
③ 빌드 환경 — 컨테이너 이미지 태그를 고정
FROM rust:1.83.0-slim ← latest 를 쓰지 마세요 ★
④ 빌드 플래그 — .cargo/config.toml 에 명시
RUSTFLAGS 를 CI 스크립트에 흩어 두지 마세요
★ 이 넷을 고정하면
· "왜 CI 에서만 깨지지" 가 거의 사라집니다
· 몇 달 뒤에도 같은 바이너리를 다시 만들 수 있습니다
· 보안 사고 시 정확히 무엇이 들어갔는지 추적할 수 있습니다
# 오프라인 빌드 (에어갭 환경)
cargo vendor > .cargo/config.toml # 의존성 소스를 vendor/ 에 복사
cargo build --offline
업데이트 정책
★ 권장 리듬
· 주 1회 cargo audit 를 CI 로 (자동)
· 월 1회 cargo update + 테스트 (사람이 리뷰)
· 분기 1회 cargo upgrade 로 major 검토
· 보안 권고가 나오면 즉시
★ 자동화
· Dependabot / Renovate 가 Cargo 를 지원합니다
· PR 로 올라오고 CI 가 검증합니다
· minor/patch 는 자동 머지, major 는 사람이 검토 ★
★ 업데이트가 깨졌을 때
cargo update -p 문제크레이트 --precise 이전버전 # 되돌리기
cargo tree -i 문제크레이트 # 누가 요구하는지
★ 의존성을 줄이는 것이 근본 해법입니다
· 표준 라이브러리로 되는 일인가
· 작은 코드를 직접 쓰는 게 나은가 (수십 줄이면 대개 그렇습니다)
· feature 를 꺼서 하위 의존성을 없앨 수 있나 (b04) ★
cargo tree 로 무엇이 딸려 오는지 먼저 보세요.
네이티브 모듈은 사용자 머신에서 빌드될 수도 있어 이 비용이 그대로 전달됩니다(🐍 y12).
한 단계 더 — 버전 해석 알고리즘 🧬 전문가
cargo 는 어떻게 버전을 고르나
① 각 의존성의 요구 범위를 모읍니다
② 호환되는 것끼리 합칩니다
A 가 serde "1.0", B 가 serde "1.2" 를 요구 → 1.x 최신 하나로
③ 호환되지 않으면 여러 벌을 넣습니다 ★
A 가 rand "0.7", B 가 rand "0.8" → 둘 다 빌드에 포함
(0.x 는 마이너가 다르면 비호환이므로)
★ 그래서 생기는 문제 (r09)
· 타입이 달라 서로 못 주고받습니다
· 바이너리가 커지고 빌드가 느려집니다
· 전역 상태를 쓰는 크레이트는 두 벌이 각자 상태를 갖습니다 ★
(log · tracing · rayon 스레드 풀 등에서 미묘한 버그)
# 확인
cargo tree -d
# 해결
# ① 상위 라이브러리를 업데이트
# ② cargo update -p 로 버전을 맞춤
# ③ [patch] 로 임시 통일 (b02)
# 최소 버전으로 테스트 (라이브러리 저자용) ★
cargo +nightly build -Z minimal-versions
# → "1.0" 이라고 적었는데 실제로는 1.5 기능을 쓰는 실수를 잡습니다
feature 통합의 함정 ★
같은 크레이트를 여러 곳이 쓰면 feature 가 '합쳐집니다'.
내 크레이트: serde = { version = "1", default-features = false }
의존성 X: serde = { version = "1", features = ["std"] }
→ 결과: std 가 켜집니다 ★ 내가 끈 것이 무시된 셈
★ 이것이 no_std · WASM 빌드에서 자주 문제가 됩니다 (🟨 j09)
# 어떤 feature 가 최종적으로 켜졌는지 확인
cargo tree -f "{p} {f}"
cargo tree -e features -i serde
# resolver 2 (edition 2021 기본) 가 일부 개선했습니다
# · build-dependencies 와 일반 의존성의 feature 를 분리
# · 타깃별 의존성의 feature 를 분리
[workspace]
resolver = "2"
# 그래도 통합은 일어납니다 — 근본 해결은
# ① 그 feature 를 켜는 의존성을 빼거나
# ② 그 크레이트를 안 쓰거나
# ③ 포크해서 feature 를 끄거나 ([patch])
"1.0" 은 1.x 중 최신, "0.2" 는 0.2.x 만 입니다.
Cargo.lock 은 커밋하세요 — 재현 가능한 빌드의 출발점입니다.
컴파일러 · 의존성 · 컨테이너 이미지 · 빌드 플래그 네 가지를 고정하고,
cargo audit·cargo deny 를 CI 에 넣으세요.
cargo tree -d 로 중복 크레이트를 주기적으로 확인하는 습관도 유용합니다.feature 플래그 — 안 쓰는 건 빌드에서 빼기
기능을 켜고 끌 수 있게 만드는 장치입니다. 끈 기능은 코드도 의존성도 통째로 빠집니다 — 빌드 시간·바이너리 크기·공격 표면이 함께 줄어듭니다. 네이티브 모듈에서는 파이썬용·Node용 바인딩을 나누는 데 특히 유용합니다.
cargo tree 를 보세요 — 대개 안 쓰는 기능이 딸려 왔습니다.정의와 사용
[features]
default = ["std", "compression"] # 기본으로 켜지는 것 ★
std = [] # 기능 이름만 (조건부 컴파일용)
compression = ["dep:zstd"] # 선택적 의존성을 켬
python = ["dep:pyo3"]
node = ["dep:napi", "dep:napi-derive"]
full = ["compression", "python"] # 여러 개를 묶기
[dependencies]
zstd = { version = "0.13", optional = true } # ★ optional 이면 dep: 로 켬
pyo3 = { version = "0.23", optional = true }
napi = { version = "2", optional = true }
// 코드에서 분기
#[cfg(feature = "compression")]
pub fn compress(data: &[u8]) -> Vec<u8> { zstd::encode_all(data, 3).unwrap() }
#[cfg(not(feature = "compression"))]
pub fn compress(data: &[u8]) -> Vec<u8> { data.to_vec() }
#[cfg(feature = "python")]
pub mod python_bindings; // 🐍 y02 · r09
// 조건 조합
#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
#[cfg(any(feature = "a", feature = "b"))]
// 컴파일 타임 검사
#[cfg(all(feature = "python", feature = "node"))]
compile_error!("python 과 node 를 함께 켤 수 없습니다");
cargo build --features compression
cargo build --no-default-features
cargo build --no-default-features --features std
cargo build --all-features # ★ CI 에서 전부 켜고 테스트
네이티브 모듈의 전형적 구성 ★
# 하나의 크레이트로 Rust 라이브러리 · 파이썬 모듈 · C ABI 를 전부
[features]
default = []
python = ["dep:pyo3"] # 🐍 PyO3 바인딩
capi = [] # 🔗 C ABI 내보내기
extension-module = ["pyo3/extension-module"]
[lib]
crate-type = ["cdylib", "rlib"] # rlib 도 두면 cargo test 가 됩니다
src/
├─ lib.rs pub 로직 (feature 무관) ★
├─ core/ 순수 Rust — 여기가 본체
├─ python.rs #[cfg(feature = "python")]
└─ capi.rs #[cfg(feature = "capi")]
빌드 방법
cargo test # 순수 로직만 테스트 (빠름)
maturin develop --features python # 파이썬 모듈 (🐍 y03)
cargo build --features capi --release # C ABI (🔗 f02)
★ 이 구성의 이점
· 핵심 로직을 한 번만 쓰고 여러 대상에 재사용
· cargo test 가 파이썬 없이 돕니다 (🐍 y12 의 링크 에러 회피)
· 각 바인딩은 얇은 변환 층만 담당
이렇게도 쓸 수 있다 — feature 설계 원칙 🟡 중급
더하기만 되어야 합니다 (additive) ★
★ 규칙: feature 를 켜면 '기능이 늘기만' 해야 합니다.
❌ 나쁜 예 — 켜면 동작이 바뀜
[features]
use-fast-hash = [] # 켜면 해시 알고리즘이 바뀜 → 결과가 달라짐
no-std = [] # 켜면 기능이 줄어듦 ★ 이름부터 잘못
✅ 좋은 예
[features]
default = ["std"]
std = [] # 켜면 기능이 늘어남
fast-hash = [] # 켜면 대안이 '추가' 되고 API 로 선택
★ 왜 중요한가 (b03 의 feature 통합)
내 크레이트를 쓰는 A 가 no-std 를 켜면
같은 빌드 안의 B 도 no-std 로 동작하게 됩니다 —
B 는 그걸 원하지 않았는데도요.
★ 그래서 관례
· no_std 지원은 default = ["std"] + --no-default-features 로
· 기능 제거가 아니라 기능 추가로 표현
· 상호 배타적 feature 는 compile_error! 로 막기
no_std 지원하기
// src/lib.rs
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(feature = "std")]
extern crate std;
extern crate alloc; // Vec · String 은 alloc 에 있습니다
use alloc::{vec::Vec, string::String};
// std 가 필요한 것만 분기
#[cfg(feature = "std")]
pub fn read_file(path: &str) -> std::io::Result<Vec<u8>> { }
// ★ no_std 에서 안 되는 것들
// std::io · std::fs · std::net · std::thread
// println! (alloc::format! 은 됩니다)
// HashMap (hashbrown 크레이트로 대체)
// f64 의 일부 메서드 (sqrt · sin — libm 크레이트 필요) ★
// 검증
// cargo build --no-default-features --target thumbv7em-none-eabihf
// → 진짜 no_std 타깃으로 빌드해 봐야 확실합니다
std 를 쓰세요.
다만 WASM 크기를 줄일 때는 검토할 가치가 있습니다(🟨 j09).
실무에선 이렇게 — 조합을 전부 테스트한다 🔴 실무
feature 조합이 깨지는 것을 막기 ★
# feature 가 5개면 조합이 32개입니다. 전부 빌드되나?
cargo install cargo-hack
cargo hack check --each-feature # 각 feature 를 하나씩
cargo hack check --feature-powerset # 모든 조합 ★ (느립니다)
cargo hack check --feature-powerset --depth 2 # 2개씩 조합까지만
cargo hack test --each-feature
# 자주 나는 실수
# · feature A 를 끄면 컴파일이 안 되는 코드
# · use 문이 cfg 밖에 있어 미사용 경고
# · 테스트가 특정 feature 를 전제
# CI 에 최소한 이 세 가지는 넣으세요 (b08)
cargo check --no-default-features
cargo check --all-features
cargo test --all-features
문서에 표시하기
// docs.rs 에서 feature 배지 (b02)
#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(feature = "compression")]
#[cfg_attr(docsrs, doc(cfg(feature = "compression")))]
pub fn compress(data: &[u8]) -> Vec<u8> { }
# README 에 표로 정리하세요
## Features
| feature | 기본 | 설명 |
|---|---|---|
| `std` | ✅ | 표준 라이브러리 (파일·네트워크) |
| `compression` | ✅ | zstd 압축 지원 |
| `python` | | PyO3 파이썬 바인딩 |
| `capi` | | C ABI 내보내기 |
```toml
mylib = { version = "1", default-features = false, features = ["std"] }
```
한 단계 더 — cfg 의 전체 목록 🧬 전문가
쓸 수 있는 조건들
#[cfg(target_os = "linux")] // windows · macos · android · ios · wasi
#[cfg(target_family = "unix")] // unix · windows · wasm
#[cfg(target_arch = "x86_64")] // aarch64 · wasm32 · arm
#[cfg(target_pointer_width = "64")]
#[cfg(target_endian = "little")>]
#[cfg(target_env = "musl")] // gnu · musl · msvc
#[cfg(target_vendor = "apple")]
#[cfg(target_feature = "avx2")] // ⚡ e09
#[cfg(debug_assertions)] // 디버그 빌드 ★
#[cfg(test)] // cargo test (r11)
#[cfg(doc)] // 문서 생성 중
#[cfg(feature = "x")]
#[cfg(unix)] // 축약형
#[cfg(windows)]
// 런타임 확인
if cfg!(debug_assertions) { } // 매크로 형태 — 값으로 씁니다
// 전체 목록 보기
// rustc --print cfg
// rustc --print cfg --target wasm32-unknown-unknown
커스텀 cfg 와 검사
// build.rs 에서 설정 (b01)
println!("cargo:rustc-cfg=has_avx512");
println!("cargo::rustc-check-cfg=cfg(has_avx512)"); // 1.80+ ★ 오타 검출
// 코드에서
#[cfg(has_avx512)]
fn fast_path() { }
// ★ 1.80 부터 cargo 가 '알 수 없는 cfg' 를 경고합니다
// warning: unexpected `cfg` condition name: `has_avx512`
// → build.rs 에서 check-cfg 를 선언하거나
// Cargo.toml 의 [lints.rust] 에 등록하세요
// Cargo.toml
// [lints.rust]
// unexpected_cfgs = { level = "warn", check-cfg = ['cfg(has_avx512)'] }
// ★ 이 경고가 유용한 이유
// #[cfg(feature = "phyton")] ← 오타
// → 예전에는 조용히 코드가 사라졌습니다. 이제는 경고가 뜹니다.
cfg 였습니다 —
조건이 절대 참이 되지 않아 코드가 통째로 사라지는데 아무 경고도 없었습니다.
check-cfg 를 켜 두세요.
python/capi feature 구성이 정석이고,
cargo hack 으로 조합을 CI 에서 검증하세요.
cfg 오타를 잡으려면 check-cfg 를 켜 두면 됩니다.워크스페이스 — 크레이트 여러 개를 한 저장소에
핵심 로직 · 파이썬 바인딩 · Node 바인딩 · CLI 를 각각 크레이트로 나누되 한 저장소에서 함께 빌드합니다. 빌드 캐시를 공유하므로 나누는 편이 오히려 빠릅니다.
구성
mylib/
├─ Cargo.toml 워크스페이스 루트
├─ Cargo.lock ★ 하나만 (전체 공유)
├─ target/ ★ 하나만 (캐시 공유)
├─ crates/
│ ├─ core/ 순수 로직
│ │ ├─ Cargo.toml
│ │ └─ src/lib.rs
│ ├─ python/ PyO3 바인딩 (🐍 y02)
│ ├─ node/ napi-rs 바인딩 (🟨 j02)
│ ├─ capi/ C ABI (🔗 f02)
│ └─ cli/ 명령줄 도구
└─ xtask/ 빌드 자동화 스크립트 (아래)
# 루트 Cargo.toml
[workspace]
resolver = "2" # ★ edition 2021 이면 반드시
members = ["crates/*", "xtask"]
exclude = ["examples/standalone"]
# 공통 메타데이터 — 각 크레이트가 상속받습니다 ★
[workspace.package]
version = "0.3.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/me/mylib"
rust-version = "1.75"
# 공통 의존성 버전 — 한 곳에서 관리 ★
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
thiserror = "2"
mylib-core = { path = "crates/core" }
[profile.release] # 워크스페이스 전체에 적용 (b06)
lto = true
멤버 크레이트
# crates/core/Cargo.toml
[package]
name = "mylib-core"
version.workspace = true # ★ 루트에서 상속
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true # ★ 버전을 여기 안 적습니다
thiserror.workspace = true
# crates/python/Cargo.toml
[package]
name = "mylib-python"
version.workspace = true
[lib]
name = "mylib"
crate-type = ["cdylib"]
[dependencies]
mylib-core.workspace = true
pyo3 = { version = "0.23", features = ["extension-module"] }
# 명령
cargo build # 전부
cargo build -p mylib-core # 하나만 ★
cargo test --workspace # 전부 테스트
cargo test -p mylib-core
cargo run -p mylib-cli
cargo clippy --workspace --all-targets
workspace = true 상속이 큰 편의입니다 —
의존성 버전을 루트 한 곳에서만 관리하므로
크레이트 간 버전이 어긋나는 일이 없습니다(b03 의 중복 문제 예방).
이렇게도 쓸 수 있다 — 언제 나눌 것인가 🟡 중급
나누면 좋은 경우 ★
✅ 나누세요
· 바인딩이 여러 개 (파이썬 · Node · C ABI)
→ 각 바인딩의 무거운 의존성이 서로에게 안 옮습니다
· 일부만 crates.io 에 게시하고 싶을 때
· 빌드가 느린 부분을 분리해 캐시 효과를 볼 때 ★
(자주 바뀌는 코드와 안 바뀌는 코드를 나누면 재빌드가 줄어듭니다)
· 팀이 나뉘어 소유권이 다를 때
· 절차 매크로 (별도 크레이트여야 합니다 · 🔬 d09)
❌ 나누지 마세요
· 파일 몇 개를 정리하려고 (모듈로 충분합니다 · r09)
· 순환 의존이 생길 것 같을 때 (크레이트 간 순환은 불가능합니다)
· 크레이트가 5개를 넘어가면 관리 비용이 커집니다
★ 실무 감각
네이티브 모듈 프로젝트는 보통 2~4개면 충분합니다
core + python (파이썬만)
core + python + node (둘 다)
core + capi + bindings (여러 언어 · 🔗 f10)
xtask 패턴 — 빌드 스크립트를 Rust 로 ★
Makefile · shell 스크립트 대신 Rust 로 빌드 작업을 씁니다.
크로스 플랫폼이고, 타입 검사가 되고, 의존성을 쓸 수 있습니다.
# .cargo/config.toml
[alias]
xtask = "run --package xtask --"
# 사용
cargo xtask build-all
cargo xtask release
cargo xtask bench-compare
// xtask/src/main.rs
use std::process::Command;
fn main() {
let task = std::env::args().nth(1).unwrap_or_default();
match task.as_str() {
"build-all" => {
sh("cargo build --release -p mylib-core");
sh("maturin build --release -m crates/python/Cargo.toml");
sh("napi build --release --cwd crates/node");
}
"release" => { /* 버전 올리기 · 태그 · 배포 */ }
_ => eprintln!("사용법: cargo xtask <build-all|release>"),
}
}
fn sh(cmd: &str) {
let ok = Command::new("sh").arg("-c").arg(cmd).status().unwrap().success();
assert!(ok, "실패: {cmd}");
}
실무에선 이렇게 — 워크스페이스 운영 🔴 실무
버전과 배포
★ 버전 정책 두 가지
① 통합 버전 (권장) — 전부 같은 버전
version.workspace = true
· 관리가 단순합니다
· 릴리스 노트가 하나
· 안 바뀐 크레이트도 버전이 올라갑니다 (문제 없음)
② 개별 버전 — 각자 관리
· crates.io 게시 크레이트가 많을 때
· cargo-release · release-plz 같은 도구가 필요합니다
# 자동화 도구
cargo install cargo-release
cargo release patch --workspace --execute
# → 버전 올림 · CHANGELOG · 태그 · 게시를 한 번에
# release-plz — PR 기반 자동 릴리스 (GitHub Actions)
# 커밋 메시지(conventional commits)를 보고 버전을 정합니다
★ 의존 순서 주의
core 를 먼저 게시해야 python 이 게시됩니다.
cargo release 가 순서를 계산해 줍니다.
빌드 시간 관리
# 어느 크레이트가 오래 걸리나
cargo build --timings
# → 워크스페이스 전체의 병렬화 상태가 한눈에 보입니다 ★
# 자주 바뀌는 것을 잎(leaf)에 두세요
# core(안 바뀜) ← python(자주 바뀜)
# 이렇게 두면 python 만 고칠 때 core 는 재빌드되지 않습니다
# 개발 중에는 필요한 것만
cargo check -p mylib-python
# 테스트도 나눠서
cargo test -p mylib-core # 빠른 단위 테스트
cargo test --workspace # CI 에서 전부
# nextest 로 더 빠르게 ★
cargo install cargo-nextest
cargo nextest run --workspace # 병렬화가 더 좋습니다
# 공통 의존성이 많으면 한 번만 빌드됩니다 (워크스페이스의 핵심 이점)
# 개별 저장소로 나누면 각자 target/ 을 가져 캐시가 안 겹칩니다 ★
target/ 과 Cargo.lock 을 공유하므로
의존성이 한 번만 빌드됩니다. 나누는 편이 대개 빠릅니다.
한 단계 더 — 워크스페이스의 세부 🧬 전문가
알아 두면 좋은 규칙
· Cargo.lock 은 루트에 하나뿐입니다
→ 모든 멤버가 같은 의존성 버전을 씁니다 ★
→ 한 멤버가 serde 1.0.100 을 요구하면 전부 그것을 씁니다
· profile 설정은 루트에서만 유효합니다 (b06)
멤버의 [profile] 은 무시되고 경고가 납니다
· feature 는 기본적으로 통합됩니다 (b03)
cargo build 로 전부 빌드하면 모든 feature 가 켜진 상태로 합쳐집니다
resolver = "2" 가 일부 개선했지만 완전하지는 않습니다
→ 개별 검증은 -p 로 따로 하세요 ★
· default-members 로 기본 빌드 대상을 줄일 수 있습니다
[workspace]
default-members = ["crates/core", "crates/cli"]
→ cargo build 가 python/node 를 건너뜁니다 (개발 편의)
· 워크스페이스 밖 크레이트를 path 로 참조하면
그 크레이트는 워크스페이스에 포함되지 않습니다 (별도 target)
모노레포에서의 CI
# 바뀐 크레이트만 테스트하기 (큰 워크스페이스에서 유용)
- name: 변경 감지
uses: dorny/paths-filter@v3
id: changes
with:
filters: |
core: ['crates/core/**']
python: ['crates/python/**', 'crates/core/**']
- name: core 테스트
if: steps.changes.outputs.core == 'true'
run: cargo test -p mylib-core
# 또는 cargo 가 판단하게
cargo test --workspace # 캐시가 있으면 안 바뀐 건 빨리 끝납니다
# 캐시 설정이 핵심입니다 (b08)
# Swatinem/rust-cache@v2 가 target/ 과 ~/.cargo 를 캐시합니다
# 워크스페이스는 캐시 하나로 전부 커버됩니다 ★
# 게시 전 전체 검증
cargo publish --dry-run -p mylib-core
cargo package --workspace --no-verify # 빠른 확인
target/ 과 Cargo.lock 을 공유하므로
나누는 편이 대개 더 빠릅니다.
핵심 로직 + 바인딩별 크레이트 구성이 네이티브 모듈의 정석이고,
[workspace.dependencies] 로 버전을 한 곳에서 관리하세요.
빌드 자동화는 xtask 패턴으로 Rust 에 두면 크로스 플랫폼이 됩니다.프로파일 — 설정 몇 줄에 10배
디버그 빌드는 최적화가 꺼져 있어 파이썬보다 느릴 수도 있습니다.
“Rust 로 바꿨는데 안 빨라졌다”의 1위 원인이 --release 누락입니다.
그리고 릴리즈 안에서도 설정에 따라 몇 배가 갈립니다.
기본 프로파일
[profile.dev] # cargo build
opt-level = 0 # 최적화 없음 ★ 느립니다
debug = true # 디버그 정보
debug-assertions = true # assert! · 오버플로 검사 (r02)
overflow-checks = true
incremental = true # 증분 컴파일 (빠른 재빌드)
codegen-units = 256 # 병렬 코드 생성 (빠른 빌드, 느린 결과)
lto = false
panic = "unwind"
[profile.release] # cargo build --release
opt-level = 3 # 최대 최적화
debug = false
debug-assertions = false # ★ 오버플로가 조용히 랩어라운드 (r02)
overflow-checks = false
incremental = false
codegen-units = 16
lto = false # ★ 기본은 꺼져 있습니다 — 켜면 더 빨라집니다
panic = "unwind"
# 실측 감각 (직접 재 보세요 · ⚡ e01)
# 같은 코드를 debug 와 release 로 빌드해 비교하면
# 보통 10~100배 차이가 납니다.
# 특히 이터레이터·제네릭이 많은 코드에서 격차가 큽니다 (🔐 o12)
실무에서 쓰는 설정 ★
# 성능 우선 (네이티브 모듈 권장)
[profile.release]
opt-level = 3
lto = "fat" # 크레이트 경계를 넘는 최적화 ★ 효과가 큽니다
codegen-units = 1 # 최적화 기회 최대화 (빌드는 느려집니다)
panic = "abort" # ❗ PyO3 에서는 쓰지 마세요 (🐍 y03)
strip = "symbols" # 심볼 제거 (b10)
# 개발 편의 — 디버그인데 너무 느릴 때 절충 ★
[profile.dev]
opt-level = 1 # 약간의 최적화 (빌드는 조금 느려짐)
[profile.dev.package."*"] # 의존성만 최적화 ★ 아주 유용합니다
opt-level = 3
# → 내 코드는 디버그(빠른 빌드), 라이브러리는 릴리즈(빠른 실행)
# 이미지 처리·암호 같은 무거운 크레이트를 쓸 때 체감이 큽니다
# 벤치마크·프로파일링용 (⚡ e01)
[profile.profiling]
inherits = "release"
debug = true # 릴리즈 성능 + 심볼 (flamegraph 에 필요)
strip = false
cargo build --profile profiling
이렇게도 쓸 수 있다 — 옵션별 효과 🟡 중급
무엇이 무엇을 바꾸나 ★
| 옵션 | 속도 | 크기 | 빌드 |
|---|---|---|---|
opt-level=3 | ★★★ | + | 느림 |
opt-level="s" | ★★ | − | 보통 |
opt-level="z" | ★ | −− | 보통 |
lto="thin" | ★★ | − | +30% |
lto="fat" | ★★★ | −− | +100% |
codegen-units=1 | ★★ | − | 느림 |
panic="abort" | + | −− | — |
strip | — | −−− | — |
★ 조합 권장
네이티브 모듈 (성능 우선)
opt-level = 3, lto = "fat", codegen-units = 1, strip = "symbols"
WASM (크기 우선 · 🟨 j09)
opt-level = "z", lto = true, panic = "abort", strip = true
CLI 도구 (균형)
opt-level = 3, lto = "thin", strip = "symbols"
★ 반드시 측정하세요 (⚡ e01)
lto = "fat" 이 항상 빠른 것은 아닙니다.
코드가 커져 명령어 캐시가 밀리면 오히려 느려질 수 있습니다.
빌드 시간도 2배가 되므로 이득이 있는지 확인하고 켜세요.
target-cpu 의 함정 ★
# 내 CPU 에 맞춰 최적화 — 수치 연산에서 효과가 큽니다 (⚡ e09)
RUSTFLAGS="-C target-cpu=native" cargo build --release
# ❗ 배포용 바이너리에 쓰면 안 됩니다
# 빌드 머신에만 있는 명령(AVX-512 등)을 쓰면
# 사용자 CPU 에서 SIGILL 로 죽습니다 ★
# ✅ 배포용 — 널리 지원되는 기준선
RUSTFLAGS="-C target-cpu=x86-64-v2" cargo build --release
# v1: 기본 SSE2 · v2: SSE4.2 (2009+) · v3: AVX2 (2013+) · v4: AVX-512
# ✅ 더 안전한 방법: 런타임 감지 (⚡ e09)
if is_x86_feature_detected!("avx2") {
unsafe { fast_avx2(data) }
} else {
fallback(data)
}
# 어떤 CPU 기능이 켜졌는지 확인
rustc --print cfg -C target-cpu=native | grep target_feature
# ★ 파이썬 wheel · npm 패키지로 배포한다면
# 기본값(또는 v2)을 쓰고 런타임 감지로 가속하세요.
# "우리 서버에서만 돈다" 가 확실할 때만 native 를 쓰세요.
실무에선 이렇게 — 빌드 시간과 성능의 균형 🔴 실무
단계별 프로파일 운영
# 개발 중 — 빠른 반복이 최우선
[profile.dev]
opt-level = 1
debug = 1 # 줄 정보만 (전체 디버그 정보는 무겁습니다)
[profile.dev.package."*"]
opt-level = 3
# CI 테스트 — 빠른 빌드
[profile.test]
opt-level = 1
# 릴리즈 — 최대 성능
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = "symbols"
# 프로파일링 — 릴리즈 + 심볼
[profile.profiling]
inherits = "release"
debug = true
strip = false
# 개발 반복은 dev, 성능 확인은 release, 원인 분석은 profiling
cargo run # dev
cargo run --release # release
cargo flamegraph --profile profiling # ⚡ e01
디버그 심볼과 배포
★ 딜레마
심볼이 있으면 → 크래시 분석이 가능하지만 바이너리가 큽니다
심볼이 없으면 → 작지만 스택 트레이스가 주소만 나옵니다
★ 실무 해법: 심볼을 분리해 따로 보관 ★
# 리눅스
objcopy --only-keep-debug libmylib.so libmylib.so.debug
objcopy --strip-debug --add-gnu-debuglink=libmylib.so.debug libmylib.so
# → 배포는 stripped, 심볼 파일은 사내 저장소에
# 맥
dsymutil target/release/libmylib.dylib # .dSYM 생성
# split-debuginfo 옵션 (rustc)
[profile.release]
split-debuginfo = "packed" # 별도 파일로 (맥·리눅스)
★ 크래시 리포트 시스템을 쓴다면 (Sentry 등)
빌드 시 심볼을 업로드하고 배포물은 strip 하는 것이 표준입니다.
# 크기 비교
ls -lh target/release/libmylib.so # strip 전후를 비교해 보세요
strip = "symbols" 로
수 MB 를 줄일 수 있습니다. 대신 세그폴트 분석이 어려워지므로(🐍 y12)
심볼 파일을 따로 보관하는 습관을 들이세요.
한 단계 더 — PGO 와 BOLT 🧬 전문가
프로파일 기반 최적화 (PGO)
# ① 계측 빌드로 실행 프로파일 수집
RUSTFLAGS="-Cprofile-generate=/tmp/pgo" cargo build --release
./target/release/mylib-cli 실제와_비슷한_작업
# ② 프로파일 병합
llvm-profdata merge -o /tmp/pgo/merged.profdata /tmp/pgo
# ③ 그 프로파일로 다시 빌드
RUSTFLAGS="-Cprofile-use=/tmp/pgo/merged.profdata" cargo build --release
# ★ 효과 (직접 재 보세요 · ⚡ e01)
# 분기가 많은 코드(파서·인터프리터)에서 수~십수 % 개선이 보고됩니다.
# 수치 계산 위주라면 효과가 작습니다.
# rustc 자신도 PGO 로 빌드됩니다.
# ★ 주의
# · 프로파일이 실제 워크로드와 달라야 의미가 없습니다
# · CI 파이프라인이 복잡해집니다
# · 재현 가능한 빌드가 어려워집니다 (b03)
# → 성능이 제품의 핵심 가치일 때만 도입하세요
그 밖의 고급 옵션
# BOLT — 링크 후 바이너리 재배치 (LLVM 도구)
# 코드 레이아웃을 실행 프로파일에 맞춰 재배열
# i-cache 미스를 줄입니다 (큰 바이너리에서 효과)
# 할당자 교체 — 멀티스레드에서 효과가 큽니다 (⚡ e05)
# [dependencies]
# mimalloc = "0.1"
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
# jemalloc · mimalloc · snmalloc
# ★ 할당이 잦은 워크로드에서 수십 % 차이가 나기도 합니다
# 정적 링크 (배포 단순화 · b07)
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release
# 링커 교체 (빌드 속도 · b10)
# lld · mold — 링크 시간이 크게 줄어듭니다
# 무엇이 실제로 효과 있었는지 반드시 측정하세요
# criterion 으로 before/after 비교 (⚡ e01)
# hyperfine 으로 전체 실행 시간 비교
hyperfine './old' './new'
lto 와 codegen-units=1 정도가 대체로 안전한 기본값이고,
그 이상은 실제 워크로드로 재 보고 결정하세요(⚡ e01).
--release 입니다 (10~100배 차이).
네이티브 모듈은 lto = "fat" · codegen-units = 1 · strip 이 무난한 기본이고,
[profile.dev.package."*"] opt-level = 3 은 개발 체감을 크게 개선합니다.
target-cpu=native 는 배포용에 쓰지 마세요 — 사용자 CPU 에서 죽습니다.크로스 컴파일 — 내 PC 에서 다른 OS 용으로
파이썬 wheel 이든 npm 패키지든 플랫폼별 바이너리가 필요합니다. Rust 는 타깃 추가 한 줄로 시작할 수 있지만, C 라이브러리를 링크하는 순간 난이도가 올라갑니다. 실무에서 실제로 쓰이는 방법만 정리합니다.
가장 간단한 경우 — 순수 Rust
# ① 타깃 추가
rustup target list | grep installed
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-gnu
rustup target add x86_64-pc-windows-gnu
# ② 빌드
cargo build --release --target x86_64-unknown-linux-musl
# → target/x86_64-unknown-linux-musl/release/
# ★ 순수 Rust 라면 이것만으로 되는 경우가 많습니다.
# 링커가 필요하면 아래 방법 중 하나를 씁니다.
# 자주 쓰는 타깃 이름
# x86_64-unknown-linux-gnu 일반 리눅스
# x86_64-unknown-linux-musl Alpine · 정적 링크 ★
# aarch64-unknown-linux-gnu ARM 서버 · 라즈베리파이
# x86_64-apple-darwin 인텔 맥
# aarch64-apple-darwin 애플 실리콘 ★
# x86_64-pc-windows-msvc 윈도우 (MSVC)
# wasm32-unknown-unknown WASM (🟨 j08)
링커 문제를 푸는 세 가지 ★
# ① cargo-zigbuild — zig 를 링커로 (가장 간편) ★
pip install ziglang
cargo install cargo-zigbuild
cargo zigbuild --release --target aarch64-unknown-linux-gnu
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17 # glibc 버전 지정!
# ★ glibc 버전을 지정할 수 있는 것이 큰 장점입니다 (manylinux · 🐍 y11)
# ② cross — 도커 컨테이너에서 빌드
cargo install cross
cross build --release --target aarch64-unknown-linux-gnu
# ★ 도커가 필요하지만 설정이 거의 없습니다
# ③ 직접 툴체인 설치
sudo apt install gcc-aarch64-linux-gnu
# .cargo/config.toml
# [target.aarch64-unknown-linux-gnu]
# linker = "aarch64-linux-gnu-gcc"
# ★ 맥용 크로스는 특히 어렵습니다
# 애플 SDK 라이선스 문제로 리눅스에서 맥 바이너리를 만들기 까다롭습니다
# → CI 의 macOS 러너를 쓰는 것이 정답입니다 (b08)
--zig 옵션을 내장하고 있습니다(🐍 y03) —
maturin build --release --target aarch64-unknown-linux-gnu --zig
한 줄로 ARM용 wheel 이 나옵니다.
이렇게도 쓸 수 있다 — musl 과 정적 링크 🟡 중급
glibc vs musl ★
★ 왜 두 가지가 있나
glibc 대부분의 리눅스 배포판 (Ubuntu · Debian · RHEL)
musl Alpine 리눅스 · 정적 링크에 적합
★ 문제
glibc 는 '하위 호환' 만 됩니다.
glibc 2.35 에서 빌드하면 2.31 시스템에서 로드가 실패합니다 ★
error: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
★ 대응
① manylinux 컨테이너에서 빌드 (오래된 glibc 기준)
maturin 의 maturin-action 이 자동으로 처리합니다 (🐍 y03)
② cargo-zigbuild 로 glibc 버전 지정
--target x86_64-unknown-linux-gnu.2.17
③ musl 로 정적 링크 — glibc 의존이 사라집니다 ★
# musl 정적 빌드
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
ldd target/.../mylib
# "not a dynamic executable" ← 완전 정적 ★
# 확인 — 어떤 glibc 심볼을 요구하나
objdump -T libmylib.so | grep GLIBC | sort -u -t_ -k2 -V | tail -5
musl 의 대가
★ musl 의 malloc 이 느립니다
멀티스레드 할당이 많은 코드에서 눈에 띄게 느려집니다.
→ mimalloc · jemalloc 으로 교체하면 대부분 해결됩니다 (b06)
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
★ 그 외 차이
· DNS 해석 동작이 다릅니다 (musl 은 /etc/nsswitch.conf 를 안 봅니다)
· 일부 로케일 기능이 없습니다
· 동적 로딩(dlopen)에 제약이 있습니다 ★
→ 파이썬 확장은 dlopen 으로 로드되므로 주의가 필요합니다
(musllinux wheel 은 별도로 만듭니다 · 🐍 y11)
★ 판단
· CLI 도구 · 컨테이너 이미지 → musl 정적이 아주 편합니다
· 파이썬 확장 · Node 애드온 → 각 플랫폼용을 따로 만드세요
manylinux(glibc) + musllinux(musl) 둘 다 (🐍 y11 · 🟨 j10)
# 도커에서 확인
docker run --rm -v $PWD:/w -w /w alpine:latest ./target/.../mylib
실무에선 이렇게 — 플랫폼 매트릭스 정하기 🔴 실무
최소 세트 ★
| 타깃 | 왜 필요한가 |
|---|---|
| linux x86_64 gnu | 서버·CI 대부분 |
| linux x86_64 musl | Alpine 도커 ★ |
| linux aarch64 | ARM 서버·Graviton |
| macos aarch64 | 애플 실리콘 ★ |
| macos x86_64 | 인텔 맥 (줄어드는 중) |
| windows x86_64 | 데스크탑 개발자 |
★ 우선순위
① linux x86_64 gnu — 없으면 아무도 못 씁니다
② macos aarch64 — 개발자 대부분이 여기서 개발합니다
③ windows x86_64
④ linux x86_64 musl — 도커 사용자 (문의가 가장 많습니다) ★
⑤ linux aarch64
⑥ macos x86_64
★ 빼도 되는 것
· 32비트 (i686) — 거의 필요 없습니다
· linux armv7 — 라즈베리파이 32비트 (필요하면 요청받고 추가)
★ sdist / 소스 배포는 반드시 함께
목록에 없는 플랫폼 사용자가 직접 빌드할 수 있게 (🐍 y11)
로컬에서 검증하기
# QEMU 로 다른 아키텍처 실행 (리눅스)
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker run --rm --platform linux/arm64 -v $PWD:/w -w /w \
debian:bookworm ./target/aarch64-unknown-linux-gnu/release/mylib-cli
# Alpine 확인
docker run --rm -v $PWD:/w -w /w python:3.12-alpine \
sh -c "pip install /w/target/wheels/*musllinux*.whl && python -c 'import mylib'"
# 윈도우 (wine · 제한적)
wine target/x86_64-pc-windows-gnu/release/mylib-cli.exe
# ★ 가장 확실한 검증은 CI 의 실제 러너입니다 (b08)
# 로컬 크로스 빌드는 '빌드가 되는지' 만 확인하고,
# '실제로 도는지' 는 CI 나 실기기에서 확인하세요
# 타깃별 테스트 실행 (같은 아키텍처면 가능)
cargo test --target x86_64-unknown-linux-musl
glibc 버전 · DLL)은 빌드 시점에 드러나지 않습니다.
한 단계 더 — 타깃 트리플과 sysroot 🧬 전문가
이름 읽는 법
x86_64 - unknown - linux - gnu
│ │ │ └ 환경 (gnu · musl · msvc · gnueabihf)
│ │ └ OS (linux · windows · darwin · wasi · none)
│ └ 벤더 (unknown · pc · apple)
└ 아키텍처 (x86_64 · aarch64 · wasm32 · riscv64)
# 지원 타깃 전부 보기 (Tier 별로 나뉩니다)
rustc --print target-list | wc -l # 200개 이상
rustup target list
# Tier 1: 자동 테스트 · 보장됨 (x86_64 리눅스·맥·윈도우)
# Tier 2: 빌드는 보장, 테스트는 일부 (aarch64 · musl · wasm)
# Tier 3: 커뮤니티 (직접 빌드해야 하는 경우도)
# 타깃 상세 정보
rustc --print target-spec-json -Z unstable-options \
--target aarch64-unknown-linux-gnu
C 의존성이 있을 때
# ★ 여기서 크로스 컴파일이 어려워집니다 (🔗 f08)
# C 라이브러리도 타깃용으로 빌드되거나 준비돼 있어야 합니다
# ① vendored feature 로 소스에서 함께 빌드 (권장) ★
# openssl = { version = "0.10", features = ["vendored"] }
# → cc 크레이트가 타깃용으로 컴파일합니다
# ② cross 의 도커 이미지에 라이브러리 설치
# Cross.toml
# [target.aarch64-unknown-linux-gnu]
# pre-build = ["dpkg --add-architecture arm64",
# "apt-get update && apt-get install -y libssl-dev:arm64"]
# ③ sysroot 를 직접 지정
# CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
# PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu \
# cargo build --target aarch64-unknown-linux-gnu
# build.rs 에서 타깃 확인 (b01)
let target = std::env::var("TARGET").unwrap(); // 크로스 대상
let host = std::env::var("HOST").unwrap(); // 빌드 머신
if target != host { /* 크로스 빌드 중 */ }
# ★ 실무 결론
# C 의존성이 있으면 vendored 를 쓰거나,
# 각 플랫폼의 CI 러너에서 네이티브 빌드하는 편이 훨씬 단순합니다 (b08)
rustup target add + --target 로 끝나고,
링커가 필요하면 cargo-zigbuild 나 cross 를 쓰세요.
glibc 버전 문제는 manylinux 컨테이너나 zigbuild 의 버전 지정으로 풀고,
Alpine 용 musl 빌드를 빠뜨리지 마세요.
C 의존성이 있으면 vendored 또는 네이티브 CI 러너가 훨씬 단순합니다.CI — 태그를 붙이면 자동으로
플랫폼 여섯 개를 손으로 빌드할 수는 없습니다. 태그를 푸시하면 세 OS 에서 빌드해 PyPI·npm 에 올라가는 파이프라인을 만듭니다. maturin·napi-rs 는 워크플로 템플릿을 생성해 줍니다 — 거기서 시작하세요.
기본 CI — 검사와 테스트
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings" # ★ 경고를 에러로
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { components: rustfmt, clippy }
- uses: Swatinem/rust-cache@v2 # ★ 캐시 — 이게 없으면 매번 전체 빌드
- run: cargo fmt --check
- run: cargo clippy --all-targets --all-features
- run: cargo test --all-features
- run: cargo doc --no-deps
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
rust: [stable, "1.75"] # MSRV 도 검증 (b02)
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with: { toolchain: "${{ matrix.rust }}" }
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features
파이썬 wheel 배포 ★
# maturin 이 워크플로를 생성해 줍니다 (🐍 y03)
maturin generate-ci github > .github/workflows/release.yml
# 생성물의 핵심 부분
jobs:
linux:
strategy:
matrix:
target: [x86_64, aarch64, x86_64-unknown-linux-musl]
steps:
- uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist
manylinux: auto # ★ 오래된 glibc 로 빌드 (b07)
sccache: true # 빌드 캐시
- uses: actions/upload-artifact@v4
with: { name: wheels-${{ matrix.target }}, path: dist }
macos:
strategy: { matrix: { target: [x86_64, aarch64] } }
runs-on: macos-latest
# …
release:
needs: [linux, macos, windows, sdist]
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
- uses: PyO3/maturin-action@v1
with:
command: upload
args: --non-interactive --skip-existing wheels-*/*
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
이렇게도 쓸 수 있다 — 캐시로 CI 시간 줄이기 🟡 중급
무엇을 캐시하나 ★
# Swatinem/rust-cache 가 자동으로 처리합니다
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }} # 타깃별로 분리 ★
cache-on-failure: true
workspaces: ". -> target"
# 캐시되는 것
# ~/.cargo/registry 의존성 소스
# ~/.cargo/git
# target/debug/deps 빌드된 의존성 ★ 가장 큽니다
# target/release/deps
# 캐시되지 않는 것 (일부러)
# 내 크레이트의 결과물 — 어차피 매번 바뀝니다
# ★ 효과
# 첫 빌드 5분 → 이후 30초 같은 개선이 흔합니다.
# 캐시가 안 먹는지 확인: 로그에서 "Compiling serde ..." 가 매번 나오면 실패
# sccache — 더 강력한 컴파일 캐시 (b10)
- uses: mozilla-actions/sccache-action@v0.0.6
- run: cargo build --release
env:
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
캐시가 안 먹는 흔한 원인
□ RUSTFLAGS 가 job 마다 다름
→ 캐시 키가 달라집니다. 한 곳에 정의하세요 (env:)
□ 툴체인 버전이 계속 바뀜
→ rust-toolchain.toml 로 고정 (r01 · b03)
□ Cargo.lock 이 없거나 매번 갱신됨
→ 커밋하세요 (b03)
□ 매트릭스마다 같은 키를 씀
→ key 에 os/target 을 넣으세요
□ 캐시 크기 한도 초과 (GitHub 는 저장소당 10GB)
→ target/ 이 너무 크면 일부만 캐시하거나 정리
→ cargo sweep 로 오래된 것 삭제
★ 측정
워크플로 실행 시간을 비교해 보세요.
캐시 적중 시 "Cache restored from key: ..." 로그가 나옵니다.
★ CI 비용 감각
플랫폼 6개 × 파이썬 5버전 = 30 job (abi3 없이 · 🐍 y11)
abi3 를 켜면 6 job 으로 줄어듭니다 ★ 이것만으로도 켤 가치가 있습니다
실무에선 이렇게 — 릴리스 파이프라인 전체 🔴 실무
릴리스 흐름 ★
① 버전 올림 (Cargo.toml · package.json · pyproject.toml)
cargo release patch --workspace 또는 수동
② CHANGELOG 갱신
③ PR → 리뷰 → 머지
④ 태그 푸시
git tag v0.3.1 && git push origin v0.3.1
⑤ CI 가 자동으로
· 전 플랫폼 빌드
· 테스트
· 아티팩트 수집
· PyPI / npm / crates.io 배포
· GitHub Release 생성 (바이너리 첨부)
⑥ 설치 검증 job (🟨 j10)
실제로 pip install / npm install 해서 import 되는지
★ 중요한 것: ⑥ 을 반드시 두세요
배포는 성공했는데 설치가 안 되는 경우가 실제로 자주 있습니다.
(플랫폼 태그 불일치 · 파일 누락 · musl 빠뜨림)
시크릿과 신뢰
# ★ Trusted Publishing (권장) — 토큰 없이 배포
# PyPI · npm 이 GitHub Actions 를 신뢰하도록 설정
jobs:
release:
permissions:
id-token: write # OIDC 토큰 ★
contents: read
steps:
- uses: pypa/gh-action-pypi-publish@release/v1
# 토큰 불필요 — PyPI 에서 저장소를 등록해 두면 됩니다
# npm provenance (🟨 j10)
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
★ 보안 체크리스트
□ 배포는 CI 에서만 (로컬 publish 금지)
□ 토큰은 최소 권한 · 만료 설정
□ 태그 푸시 권한을 제한 (보호된 태그)
□ 릴리스 job 은 환경(environment) 승인을 거치게 ★
environment: production → 사람이 승인해야 실행
□ 의존성 감사를 릴리스 전에 (cargo audit · b09)
□ 서드파티 액션은 커밋 SHA 로 고정
uses: actions/checkout@8f4b7f8 ← 태그 대신 SHA
한 단계 더 — CI 를 빠르고 안정적으로 🧬 전문가
시간을 줄이는 기법
① 단계를 나누세요
빠른 검사(fmt · clippy · check)를 먼저 → 실패하면 즉시 중단
느린 빌드는 그 다음 job 으로
② cargo nextest — 테스트 실행이 더 빠릅니다
cargo install cargo-nextest
cargo nextest run --all-features
③ 릴리스 빌드는 태그일 때만
if: startsWith(github.ref, 'refs/tags/')
④ 매트릭스를 줄이세요
PR 에서는 linux 만, 태그에서만 전 플랫폼 ★
⑤ concurrency 로 중복 실행 취소
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
⑥ 도커 이미지에 툴체인을 미리 넣기 (self-hosted 러너)
⑦ sccache + 원격 캐시 (S3 · GHA)
불안정한 CI 다루기
★ 자주 겪는 불안정 원인
· 테스트가 병렬로 돌며 같은 자원을 씀 (r11)
→ 임시 디렉터리 · 포트 0 으로 자동 할당
· 시간 의존 테스트 (타임아웃 · sleep)
→ 시계를 추상화 (🔐 o05)
· 네트워크 의존 (crates.io · 외부 API)
→ 캐시 · 목 서버
· 러너 성능 편차 (벤치마크를 CI 에서 돌리면 노이즈가 큽니다 ★)
→ 벤치마크는 CI 에서 '회귀 감지' 용으로만, 절대값은 믿지 마세요 (⚡ e01)
★ 진단
· 실패한 job 의 로그를 아티팩트로 남기기
· RUST_BACKTRACE=1 을 항상 켜기
· tmate 액션으로 러너에 SSH 접속 (디버깅용)
★ 플랫폼별 함정
· 윈도우: 경로 구분자 · 줄바꿈(CRLF) · 파일 잠금
git config core.autocrlf false
· 맥: 파일 시스템이 대소문자를 구분하지 않습니다 ★
· Alpine: /bin/sh 가 bash 가 아닙니다
maturin generate-ci github(🐍) 나
napi-rs 템플릿(🟨)에서 시작하는 것이 가장 빠릅니다.
Swatinem/rust-cache 를 반드시 넣고(없으면 매번 전체 빌드),
태그 푸시 → 전 플랫폼 빌드 → 배포 → 설치 검증까지 자동화하세요.
abi3 를 켜면 CI job 이 5분의 1로 줄어듭니다(🐍 y11).clippy · fmt · 문서 — 품질을 자동으로
clippy 는 “이렇게 쓰는 게 낫다”를 알려 주는 선배입니다.
잔소리가 아니라 대부분 진짜 개선이고, 초심자에게는 가장 좋은 학습 도구입니다.
프로젝트 초기에 켜 두세요 — 나중에 켜면 경고 수백 개를 한꺼번에 마주합니다.
clippy 기본
rustup component add clippy
cargo clippy # 기본 린트
cargo clippy --all-targets --all-features # 테스트·예제까지 ★
cargo clippy -- -D warnings # 경고를 에러로 (CI 용)
cargo clippy --fix # 자동 수정 가능한 것 적용 ★
# 실제로 자주 나오는 지적들
· needless_range_loop
for i in 0..v.len() { v[i] } → for x in &v { } (r04)
· redundant_clone
불필요한 .clone() ★ (⚡ e02)
· single_char_pattern
s.split("a") → s.split('a')
· manual_map / option_if_let_else
match 를 .map() 으로 (r08)
· large_enum_variant
배리언트 하나가 너무 큼 → Box 로 (r08)
· too_many_arguments
인자 8개 이상 → 구조체로 (r07)
· missing_safety_doc
unsafe fn 에 # Safety 주석이 없음 ★ (🔬 d08)
cargo clippy --fix 로 자동 수정된 diff 를 읽어 보세요.
린트 설정 ★
# Cargo.toml (1.74+) — 워크스페이스 전체에 적용 가능
[lints.rust]
unsafe_code = "forbid" # unsafe 금지 (FFI 크레이트는 제외)
missing_docs = "warn"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(docsrs)'] } # b04
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 } # 더 엄격 (호불호가 있습니다)
unwrap_used = "warn" # 🔗 f09 · 🐍 y08
expect_used = "warn"
panic = "warn"
indexing_slicing = "warn" # v[i] 대신 v.get(i)
todo = "deny"
dbg_macro = "deny" # dbg! 를 커밋하지 않게 ★
# 워크스페이스 상속 (b05)
# [lints]
# workspace = true
// 코드에서 예외 두기
#[allow(clippy::too_many_arguments)]
fn complex(a: u32, b: u32, /* … */) { }
// 이유를 적으세요 (1.81+ 는 expect 로 '더 이상 필요 없으면 경고')
#[expect(clippy::unwrap_used, reason = "위에서 is_some() 을 확인함")]
let v = opt.unwrap();
// 파일·크레이트 전체
#![allow(clippy::module_name_repetitions)]
이렇게도 쓸 수 있다 — 포맷과 문서 🟡 중급
rustfmt
rustup component add rustfmt
cargo fmt # 전부 정렬
cargo fmt --check # 검사만 (CI 용) ★
cargo fmt -- --emit files
# rustfmt.toml (안정 옵션만)
edition = "2021"
max_width = 100
use_small_heuristics = "Default"
newline_style = "Unix" # ★ 윈도우 팀원과의 CRLF 문제 방지
# nightly 전용 (많이 씁니다만 rustup toolchain 이 필요)
# group_imports = "StdExternalCrate"
# imports_granularity = "Crate"
# wrap_comments = true
★ 포맷 논쟁이 사라집니다
Rust 커뮤니티는 rustfmt 기본값을 거의 그대로 씁니다.
설정을 많이 바꾸면 오히려 남의 코드를 읽기 어려워집니다.
→ max_width 정도만 조정하고 나머지는 두세요.
★ 에디터 설정
VS Code: "editor.formatOnSave": true
IntelliJ: Rust 플러그인의 Run rustfmt on Save
★ pre-commit 훅
#!/bin/sh
cargo fmt --check || { echo "cargo fmt 를 실행하세요"; exit 1; }
문서 (rustdoc)
cargo doc --open # 내 코드 + 의존성 문서
cargo doc --no-deps --open # 내 것만 (빠릅니다)
cargo test --doc # 문서 예제 실행 (r11) ★
#![warn(missing_docs)] // 공개 항목에 문서가 없으면 경고
//! # mylib
//!
//! 빠른 텍스트 처리 라이브러리.
//!
//! ## 빠른 시작
//! ```
//! let n = mylib::count_words("안녕 세상");
//! assert_eq!(n, 2);
//! ```
/// 단어 수를 셉니다.
///
/// # 예제
/// ```
/// assert_eq!(mylib::count_words("a b"), 2);
/// ```
///
/// # 패닉
/// 패닉하지 않습니다.
///
/// # 안전성 ← unsafe fn 이면 필수 (🔬 d08)
pub fn count_words(s: &str) -> usize { s.split_whitespace().count() }
// 문서 안 링크 (intra-doc links) ★
/// [`count_words`] 와 함께 쓰세요. [`std::str::split_whitespace`] 기반입니다.
// 깨진 링크 검사
// #![deny(rustdoc::broken_intra_doc_links)]
실무에선 이렇게 — 팀에 도입하는 순서 🔴 실무
단계적으로 켜기 ★
1주차 — 기본만
cargo fmt --check
cargo clippy -- -D warnings (clippy::all 만)
2주차 — 안전 관련
[lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
→ FFI · 라이브러리 코드부터 적용 (🔗 f09)
3주차 — 문서
#![warn(missing_docs)]
공개 API 부터 채우기
이후 — 필요에 따라
pedantic · nursery 는 선택적으로
(호불호가 갈리고 오탐도 있습니다)
★ 하지 말아야 할 것
· 처음부터 pedantic 전부 켜기 → 경고 수백 개에 팀이 지칩니다
· #![allow(clippy::all)] 로 통째로 끄기 → 도구를 버리는 것
· 이유 없이 #[allow] 붙이기 → reason 을 적으세요
그 밖의 품질 도구
# 커버리지
cargo install cargo-llvm-cov
cargo llvm-cov --all-features --html
cargo llvm-cov --lcov --output-path lcov.info # CI 업로드용
# 죽은 코드 찾기
cargo install cargo-udeps
cargo +nightly udeps # 안 쓰는 의존성 ★
cargo install cargo-machete # 더 빠른 대안
# 문서 커버리지
cargo +nightly rustdoc -- -Z unstable-options --show-coverage
# 의존성 감사 (b03)
cargo audit
cargo deny check
# 공개 API 변경 감지 (b02)
cargo semver-checks check-release
# 전부 한 번에 (Makefile 이나 xtask 로 · b05)
# cargo xtask lint
cargo udeps/cargo machete 로
안 쓰는 의존성을 주기적으로 정리하세요 —
빌드 시간·바이너리 크기·공급망 위험이 함께 줄어듭니다(b03·b10).
한 단계 더 — 커스텀 린트 🧬 전문가
프로젝트 규칙을 강제하기
# clippy.toml — clippy 동작 조정
disallowed-methods = [
{ path = "std::env::var", reason = "설정은 Config 를 통해 읽으세요" },
{ path = "std::process::exit", reason = "Drop 이 안 불립니다 (o07)" },
]
disallowed-types = [
{ path = "std::collections::HashMap", reason = "FxHashMap 을 쓰세요" },
]
too-many-arguments-threshold = 6
type-complexity-threshold = 250
msrv = "1.75" # 이 버전에 없는 API 제안을 막습니다 ★
★ 더 강한 규칙이 필요하면
· dylint — 커스텀 린트를 Rust 로 작성
cargo install cargo-dylint dylint-link
· semgrep · ast-grep — 패턴 기반 검사 (언어 무관)
· 단순한 것은 grep + CI 스크립트로도 충분합니다
! grep -rn "unwrap()" src/ffi/ || exit 1
★ 실무 감각
커스텀 린트를 만들 정도의 규칙이라면
대개 '타입으로 표현할 수 있는지' 를 먼저 검토하세요 (🔬 d08).
타입으로 막을 수 있으면 린트보다 확실합니다.
MSRV 관리
# Cargo.toml
[package]
rust-version = "1.75" # 최소 지원 버전 ★
# CI 에서 MSRV 검증 (b08)
- uses: dtolnay/rust-toolchain@1.75.0
- run: cargo check --all-features
# 의존성이 MSRV 를 올려 버리는 경우
cargo install cargo-msrv
cargo msrv find # 실제 최소 버전 찾기
cargo msrv verify
# 새 버전을 쓰려면 rust-version 을 올리세요
# → semver 상 minor 올림으로 보는 것이 관례입니다 (b02)
★ MSRV 를 얼마나 낮게 유지할까
· 라이브러리 최근 6개월~1년 (사용자층에 따라)
· 애플리케이션 최신을 써도 됩니다
· 기업 내부 배포 환경의 툴체인에 맞추세요
★ 너무 낮으면 새 기능을 못 씁니다
let-else(1.65) · GAT(1.65) · async fn in trait(1.75) 등
실무 편의가 큰 기능들이 최근에 들어왔습니다.
cargo fmt --check + cargo clippy -D warnings 를
프로젝트 초기에 CI 에 넣으세요.
clippy 는 잔소리가 아니라 가장 좋은 학습 도구이고, --fix 로 자동 수정도 됩니다.
문서 예제가 테스트로 실행되므로 문서가 낡을 수 없고,
FFI 코드에는 unwrap_used 를 금지해 두면 안전합니다(🔗 f09).바이너리 크기 · 빌드 시간 줄이기
Rust 의 두 가지 실무 불만입니다 — 빌드가 느리고 바이너리가 큽니다. 둘 다 원인을 측정하면 대부분 해결됩니다. 효과가 큰 순서대로 정리합니다.
빌드 시간 — 효과 순서 ★
# ① 먼저 측정
cargo build --timings
# → target/cargo-timings/*.html
# 어느 크레이트가 오래 걸리고, 병렬화가 어디서 막히는지 보입니다
# ② 링커 교체 — 가장 쉽고 효과가 큽니다 ★
# 리눅스: mold (가장 빠름) 또는 lld
sudo apt install mold
# .cargo/config.toml
# [target.x86_64-unknown-linux-gnu]
# linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
# 맥: 최신 ld64 가 이미 빠릅니다 (또는 zld)
# 윈도우: rust-lld (기본값이 되어 가는 중)
# ③ sccache — 컴파일 결과 캐시
cargo install sccache
export RUSTC_WRAPPER=sccache
sccache --show-stats
# ④ 의존성 줄이기 (b03 · b04)
cargo udeps # 안 쓰는 것
cargo tree -d # 중복
default-features = false # 안 쓰는 기능
# ⑤ 워크스페이스로 나누기 (b05)
# 자주 바뀌는 코드를 잎에 두면 재빌드 범위가 줍니다
# ⑥ 프로파일 조정 (b06)
# [profile.dev]
# debug = 1 # 전체 디버그 정보는 무겁습니다
바이너리 크기 — 효과 순서 ★
# ① 프로파일 (b06)
[profile.release]
opt-level = "z" # 크기 우선 (속도가 중요하면 3 유지)
lto = "fat" # ★ 효과가 큽니다
codegen-units = 1
panic = "abort" # ★ 언와인딩 코드 제거 (PyO3 는 제외 · 🐍 y03)
strip = "symbols" # ★ 심볼 제거 — 가장 큰 효과
# ② 무엇이 큰지 측정
cargo install cargo-bloat
cargo bloat --release --crates # 크레이트별 ★
cargo bloat --release -n 20 # 함수별
# 출력 예
# File .text Size Crate
# 12.1% 38.4% 1.2MiB regex
# 8.3% 26.4% 845KiB serde_json
# 3.1% 9.9% 316KiB std
# 2.0% 6.4% 204KiB mylib ← 내 코드는 의외로 작습니다 ★
# ③ 의존성 줄이기 — 실제로 가장 큰 효과
# regex → 간단한 파싱이면 직접 (수백 KB 절약)
# serde_json → 필요한 것만 (또는 더 작은 대안)
# chrono → time 크레이트
# ④ 제네릭 단형화 줄이기 (r10)
# 큰 제네릭 함수를 비제네릭 안쪽 함수로 나누기
# dyn 으로 바꾸기 (🔐 o06)
이렇게도 쓸 수 있다 — 측정하고 추적하기 🟡 중급
빌드 시간 상세 분석
# 컴파일 단계별 시간 (nightly)
cargo +nightly rustc -- -Ztime-passes 2>&1 | sort -k2 -rn | head -20
# time: 3.421; rss: 512MB LLVM_module_optimize ← 최적화가 대부분
# time: 1.203; rss: 380MB type_check
# time: 0.891; rss: 290MB monomorphization ★ 제네릭이 많으면 큽니다
# 어떤 함수가 코드를 많이 만드나
cargo +nightly rustc --release -- -Zprint-mono-items=lazy | head -30
# self-profile (자세한 프로파일)
cargo +nightly rustc -- -Zself-profile
# → summarize 도구로 분석
# ★ 해석
# LLVM 시간이 크면 → opt-level · codegen-units · 제네릭 줄이기
# type_check 가 크면 → 복잡한 타입·트레이트 해석 (🔐 o06)
# 링크가 크면 → 링커 교체 (mold)
회귀를 막기 ★
# CI 에서 바이너리 크기를 추적
- run: |
SIZE=$(stat -c%s target/release/libmylib.so)
echo "size=$SIZE" >> $GITHUB_OUTPUT
if [ $SIZE -gt 5000000 ]; then
echo "::error::바이너리가 5MB 를 넘었습니다 ($SIZE)"
exit 1
fi
# 빌드 시간도
- run: |
START=$(date +%s)
cargo build --release
echo "빌드 $(($(date +%s) - START))초"
# ★ 임계값을 두면 '어느 PR 이 늘렸는지' 가 드러납니다
# 의존성 하나가 1MB 를 더하는 일이 흔합니다 (b03)
# 로컬에서 비교
cargo bloat --release --crates > before.txt
# … 변경 …
cargo bloat --release --crates > after.txt
diff before.txt after.txt
실무에선 이렇게 — 배포 대상별 목표 🔴 실무
대상마다 기준이 다릅니다
| 대상 | 우선순위 |
|---|---|
| 파이썬 wheel | 속도 > 크기 |
| npm 애드온 | 속도 ≈ 크기 |
| WASM | 크기 ≫ 속도 ★ |
| CLI 도구 | 속도 > 크기 |
| 컨테이너 | 크기 (레이어) |
| 임베디드 | 크기 ≫≫ |
★ 크기가 실제로 문제가 되는 순간
· WASM — 로딩 시간이 곧 사용자 경험 (🟨 j09)
· npm — CI 가 하루에 수천 번 내려받습니다 (🟨 j10)
· 컨테이너 — 이미지 크기가 배포 속도에 직결
· 파이썬 wheel — 보통 수 MB 는 문제되지 않습니다
★ 속도가 실제로 문제가 되는 순간
· 네이티브 모듈의 존재 이유 자체 ★
· opt-level 을 "z" 로 낮추면 수십 % 느려질 수 있습니다
→ 크기를 줄이려다 성능을 잃으면 본말전도입니다. 측정하세요 (⚡ e01)
개발 경험을 지키기
★ 빌드가 느려지면 사람이 코드를 덜 고칩니다
이것이 진짜 비용입니다.
체감을 지키는 방법
① cargo check 를 기본 습관으로 (b01)
② 에디터의 rust-analyzer 설정 확인
"rust-analyzer.cargo.buildScripts.enable": true
"rust-analyzer.check.command": "clippy"
③ 테스트를 나누기 — 빠른 단위 테스트를 자주, 느린 통합 테스트는 CI 에서
④ cargo nextest 로 테스트 실행 시간 단축
⑤ 무거운 의존성은 feature 뒤로 (b04)
⑥ 개발용 프로파일 조정 (b06)
★ 목표 감각
cargo check 1~5초 ← 이게 느리면 작업이 답답해집니다
cargo test 10~30초
cargo build --release 1~5분 (CI 에서만)
★ 이 목표를 넘어가면 위 목록을 순서대로 적용하세요.
cargo check 덕분에 실제 작업 흐름은 대체로 낫습니다.
느려졌다고 느끼면 반드시 측정하세요 — 대개 특정 의존성 하나가 원인입니다.
한 단계 더 — 왜 느리고 왜 큰가 🧬 전문가
근본 원인
빌드가 느린 이유
① 단형화 — 제네릭을 타입마다 복제합니다 (r10 · 🔬 d10)
타입 10개 × 함수 20개 = 코드 200벌을 LLVM 이 최적화
② 크레이트가 컴파일 단위 — 크레이트 하나가 통째로 다시 빌드됩니다
(C++ 는 .cpp 파일 단위라 더 잘게 나뉩니다)
③ 절차 매크로 — syn/quote 를 빌드해야 하고, 확장 비용도 큽니다 (🔬 d09)
④ LLVM 최적화 — opt-level 3 · LTO 는 본질적으로 느립니다
⑤ 링크 — 큰 바이너리 하나를 만드는 데 시간이 듭니다
바이너리가 큰 이유
① 정적 링크가 기본 — 의존성이 전부 들어갑니다 ★
(C 는 libc 를 동적 링크하므로 hello world 가 수십 KB)
② 단형화로 코드가 복제됩니다
③ 패닉 메시지·포맷 코드 (core::fmt 가 수십 KB · 🟨 j09)
④ 디버그 정보 (strip 하면 사라집니다)
⑤ 백트레이스 지원 코드
★ 그래서 근본 대책은
· 의존성 줄이기
· 제네릭 사용을 절제하기 (🔐 o06 의 dyn 전환)
· 안 쓰는 feature 끄기 (b04)
이 셋이 대부분입니다.
실험해 볼 만한 것들
# 병렬 프론트엔드 (nightly · 실험적)
RUSTFLAGS="-Z threads=8" cargo +nightly build
# Cranelift 백엔드 — 디버그 빌드가 훨씬 빠릅니다 (실험적) ★
rustup component add rustc-codegen-cranelift-preview --toolchain nightly
# .cargo/config.toml
# [unstable]
# codegen-backend = true
# [profile.dev]
# codegen-backend = "cranelift"
# 공유 target 디렉터리 (여러 프로젝트가 캐시 공유)
export CARGO_TARGET_DIR=~/.cargo-target
# ❗ 주의: 동시에 여러 빌드를 돌리면 락 경합이 생깁니다
# 램디스크에 target 두기 (SSD 수명 · 속도)
# CARGO_TARGET_DIR=/dev/shm/target cargo build
# 원격 빌드 캐시 (팀 단위)
# sccache + S3/Redis 백엔드
export SCCACHE_BUCKET=my-cache
export SCCACHE_REGION=ap-northeast-2
# ★ 측정 없이 적용하지 마세요
# hyperfine 으로 before/after 를 비교하세요
hyperfine --prepare 'cargo clean' 'cargo build --release'
cargo build --timings 와 cargo bloat.
빌드 시간은 링커 교체(mold) → sccache → 의존성 줄이기 순으로 효과가 크고,
크기는 strip · lto · panic="abort" → 의존성 줄이기 순입니다.
내 코드보다 의존성이 대부분을 차지하는 경우가 많으니 cargo bloat --crates 부터 보세요.
다음은 ⚡ 성능 · 동시성 탭입니다.측정하지 않으면
최적화가 아닙니다
이 탭의 첫 번째 규칙은 “빨라진 것 같다”는 증거가 아니다입니다. 제대로 재는 법부터 잡고, Rust 에서 실제로 효과가 큰 순서대로 봅니다 — 불필요한 할당 제거 → 데이터 배치 → 병렬화. 그리고 데이터 경쟁을 컴파일러가 막아 주는 지점을 확인합니다.
측정 먼저 — criterion 과 프로파일러
최적화에서 가장 흔한 실패는 엉뚱한 곳을 고치는 것입니다.
--release 로 빌드했는지, 워밍업을 했는지, 컴파일러가 코드를 지우지는 않았는지 —
측정이 틀리면 그다음 판단이 전부 틀립니다.
criterion 으로 재기 ★
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "my_bench"
harness = false # ★ 필수 (b02)
// benches/my_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn bench_sum(c: &mut Criterion) {
let data: Vec<f64> = (0..10_000).map(|x| x as f64).collect();
c.bench_function("sum_iter", |b| {
b.iter(|| black_box(&data).iter().sum::<f64>())
// ^^^^^^^^^^ ★ 컴파일러가 계산을 지우지 못하게 막습니다
});
// 크기별 비교 — 손익분기점을 찾을 때 (⚡ e05)
let mut g = c.benchmark_group("sum");
for n in [100, 1_000, 100_000] {
let v: Vec<f64> = (0..n).map(|x| x as f64).collect();
g.bench_with_input(BenchmarkId::new("seq", n), &v, |b, v| {
b.iter(|| v.iter().sum::<f64>())
});
}
g.finish();
}
criterion_group!(benches, bench_sum);
criterion_main!(benches);
cargo bench
# target/criterion/report/index.html ← 그래프 · 이전 실행과 비교 ★
cargo bench -- sum_iter # 이름으로 필터
cargo bench -- --save-baseline before
cargo bench -- --baseline before # 개선 전후 비교 ★
측정이 틀리는 다섯 가지 ★
① --release 를 안 씀
디버그 빌드는 10~100배 느립니다 (b06)
cargo bench 는 자동으로 릴리즈이지만,
직접 잰다면 반드시 --release ★
② 컴파일러가 계산을 지움
결과를 안 쓰면 통째로 삭제됩니다 (⚡ e03)
→ black_box() 로 막으세요
③ 워밍업 없음
첫 실행은 캐시가 비어 있어 느립니다
criterion 이 자동으로 처리합니다
④ 입력이 비현실적
정렬된 배열 · 전부 같은 값 · 너무 작은 데이터
→ 실제 데이터를 쓰거나 비슷하게 생성하세요 ★
⑤ 환경 노이즈
다른 프로세스 · CPU 주파수 변동 · 서멀 스로틀링
→ 여러 번 재고 중앙값을 보세요 (criterion 이 통계를 냅니다)
→ CI 에서 잰 절대값은 믿지 마세요 (b08)
★ 상대 비교를 하세요
"2.3ms" 보다 "이전 대비 40% 빠름" 이 훨씬 신뢰할 수 있습니다.
이렇게도 쓸 수 있다 — 어디가 느린지 찾기 🟡 중급
프로파일러 ★
# ① flamegraph — 가장 직관적입니다
cargo install flamegraph
# 심볼이 필요합니다 (b06)
# [profile.release] debug = true
cargo flamegraph --bench my_bench
cargo flamegraph --bin mylib-cli -- 인자
# → flamegraph.svg (브라우저에서 열어 클릭하며 탐색) ★
# ② perf (리눅스)
perf record -g ./target/release/mylib-cli
perf report
# ③ samply — 크로스 플랫폼 · Firefox Profiler UI ★
cargo install samply
samply record ./target/release/mylib-cli
# ④ 맥
xcrun xctrace record --template 'Time Profiler' --launch ./target/release/app
# ⑤ 파이썬에서 부를 때 (🐍 y12)
py-spy record -o flame.svg --native -- python bench.py
# ★ --native 로 Rust 프레임까지 보입니다
간단한 측정도 유용합니다
use std::time::Instant;
let t = Instant::now();
let result = heavy_work(&data);
eprintln!("heavy_work: {:?}", t.elapsed());
// RAII 타이머 (🔐 o01)
struct Timer(&'static str, Instant);
impl Drop for Timer {
fn drop(&mut self) { eprintln!("{}: {:?}", self.0, self.1.elapsed()); }
}
let _t = Timer("파싱", Instant::now());
// ❗ WASM 에서 Instant 는 패닉합니다 (🟨 j08)
// 구조적 계측 — tracing 크레이트
// #[tracing::instrument]
// fn parse(input: &str) -> Result<Ast> { }
// → 스팬별 소요 시간이 자동으로 기록됩니다
// 카운터로 호출 횟수 세기
static CALLS: AtomicU64 = AtomicU64::new(0);
CALLS.fetch_add(1, Ordering::Relaxed);
# 전체 실행 시간 비교
cargo install hyperfine
hyperfine './target/release/old' './target/release/new'
hyperfine --warmup 3 --runs 20 'cmd'
실무에선 이렇게 — 최적화 순서를 지킨다 🔴 실무
효과가 큰 순서 ★
① 알고리즘 — O(n²) → O(n log n)
다른 모든 것을 합친 것보다 큽니다. 항상 먼저 보세요.
② 불필요한 작업 제거
· 중복 계산 (캐싱)
· 필요 없는 데이터까지 처리
· 조기 종료 기회 (🔐 o12)
③ 할당 줄이기 (e02) ★
Rust 코드가 느린 이유의 대부분이 여기 있습니다
④ 데이터 배치 (e04)
캐시 친화적으로 — 수 배 차이가 납니다
⑤ 병렬화 (e05)
코어 수만큼. 다만 ①~④ 를 먼저 하세요
(느린 코드를 8배 빠르게 하는 것보다 100배 빠르게 만드는 게 낫습니다)
⑥ SIMD · 마이크로 최적화 (e09)
마지막입니다. 이식성과 가독성을 잃습니다.
★ 각 단계마다 측정하세요.
③ 이 효과 없으면 ④ 로 가지 말고 프로파일러를 다시 보세요.
회귀를 막기
# CI 에서 벤치마크 (절대값이 아니라 '회귀' 감지용)
cargo bench -- --save-baseline main
# PR 브랜치에서
cargo bench -- --baseline main
# → 5% 이상 느려지면 알림
# 도구
# criterion + bencher.dev
# codspeed (GitHub Actions 통합 · 노이즈에 강함) ★
# iai-callgrind — 명령어 수를 세므로 CI 노이즈가 없습니다 ★
# ★ iai 방식이 CI 에 특히 적합합니다
# [dev-dependencies]
# iai-callgrind = "0.14"
# 실행 시간 대신 '실행된 명령어 수'를 셉니다
# → 러너 성능과 무관하게 재현 가능
# 성능 목표를 테스트로
#[test]
fn parse_is_fast_enough() {
let t = Instant::now();
for _ in 0..1000 { parse(&input); }
assert!(t.elapsed() < Duration::from_millis(500), "너무 느립니다");
}
// ❗ CI 노이즈에 취약하니 여유를 크게 잡으세요
iai-callgrind(명령어 수) 나
전용 러너를 쓰는 것이 신뢰할 만합니다.
한 단계 더 — 벤치마크가 거짓말하는 경우 🧬 전문가
black_box 가 필요한 이유
// ❌ 이 벤치마크는 0ns 가 나옵니다
b.iter(|| {
let sum: u64 = (0..1000).sum(); // 결과를 안 씀 → 통째로 삭제 ★
});
// ✅
b.iter(|| {
let sum: u64 = (0..1000).sum();
black_box(sum) // "이 값을 쓴다" 고 컴파일러를 속임
});
// 입력도 감춰야 합니다
b.iter(|| process(black_box(&data))); // 상수 폴딩 방지
// ❗ black_box 는 최적화 장벽이라 그 자체가 약간의 비용이 있습니다
// 너무 안쪽 루프에 넣으면 측정이 왜곡됩니다
// 확인 방법 — 어셈블리를 보세요 (e03)
cargo asm --bench my_bench
// 상태를 바꾸는 벤치마크
b.iter_batched(
|| data.clone(), // 매번 새 입력 (측정에서 제외)
|mut d| { d.sort(); d },
criterion::BatchSize::SmallInput,
);
현실과 다른 벤치마크
★ 실제 워크로드와 벤치마크가 갈리는 지점
① 캐시 상태
벤치마크는 같은 데이터를 반복해 L1/L2 에 다 들어가 있습니다.
실제로는 캐시 미스가 훨씬 많습니다 (e04)
→ 데이터 크기를 캐시보다 크게 잡아 보세요
② 분기 예측
같은 입력을 반복하면 분기가 100% 예측됩니다.
실제 데이터는 예측이 자주 빗나갑니다
→ 무작위 입력도 함께 재세요
③ 할당자 상태
반복하면 할당자가 워밍업되어 빨라집니다 (e02)
④ 단일 스레드 가정
실제 서버는 여러 요청이 동시에 돕니다.
메모리 대역폭·캐시를 공유하므로 코어 수만큼 빨라지지 않습니다 (e05)
⑤ 전체 시스템에서의 비중
함수를 100배 빠르게 해도 전체의 2% 면 전체는 2% 만 빨라집니다
→ 엔드투엔드 측정을 반드시 함께 하세요 ★
★ 결론: 마이크로 벤치마크로 '방향'을 잡고,
엔드투엔드 측정으로 '결정'하세요.
--release · black_box · 현실적인 입력 세 가지가 측정의 기본입니다.
criterion 으로 재고 flamegraph 로 어디가 느린지 찾으세요.
최적화는 알고리즘 → 불필요한 작업 → 할당 → 배치 → 병렬 → SIMD 순서로,
각 단계마다 측정하며 진행합니다.할당 줄이기 — 느린 Rust 의 90%
Rust 코드가 기대만큼 안 빠른 이유는 대부분 하나입니다 —
불필요한 .clone() 과 to_string().
소유권 에러를 clone 으로 넘긴 코드가 루프 안에 있으면
C++ 보다 느려집니다. 여기가 가장 먼저 볼 곳입니다.
전형적인 패턴 ★
// ❌ 루프 안의 clone
for row in &rows {
process(row.clone()); // 매번 힙 할당 + 복사
}
// ✅
for row in &rows {
process(row); // 빌려 주기 (🔐 o02)
}
// ❌ 문자열을 매번 만들기
for u in &users {
let key = format!("{}:{}", u.id, u.name); // 할당 1회씩
map.insert(key, u);
}
// ✅ 버퍼 재사용
let mut key = String::with_capacity(64);
for u in &users {
key.clear(); // ★ 용량은 유지됩니다
use std::fmt::Write;
write!(key, "{}:{}", u.id, u.name).unwrap();
map.insert(key.clone(), u); // 여기선 어쩔 수 없이 1회
}
// ❌ collect 를 중간마다
let a: Vec<_> = it.map(f).collect();
let b: Vec<_> = a.iter().filter(g).collect();
// ✅ 한 번에 (🔐 o12)
let b: Vec<_> = it.map(f).filter(g).collect();
// ❌ 크기를 모르고 push
let mut v = Vec::new();
for x in 0..1_000_000 { v.push(x); } // 재할당 ~20회 + 전체 복사
// ✅
let mut v = Vec::with_capacity(1_000_000);
찾는 방법 ★
# ① clippy 가 상당수를 잡아 줍니다 (b09)
cargo clippy -- -W clippy::redundant_clone
cargo clippy -- -W clippy::clone_on_copy
cargo clippy -- -W clippy::unnecessary_to_owned
# ② 눈으로 — 루프 안의 .clone() · .to_string() · format! · to_vec()
grep -n "clone()\|to_string()\|to_vec()" src/**/*.rs
# ③ 할당 횟수를 세기 ★ 가장 확실합니다
# 커스텀 할당자로 카운트
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
ALLOCS.fetch_add(1, Ordering::Relaxed);
System.alloc(l)
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) { System.dealloc(p, l) }
}
#[global_allocator] static A: Counting = Counting;
// 측정
let before = ALLOCS.load(Ordering::Relaxed);
work();
println!("할당 {}회", ALLOCS.load(Ordering::Relaxed) - before);
# ④ 프로파일러에서 malloc/free 가 위에 보이면 확정입니다 (e01)
이렇게도 쓸 수 있다 — 복사를 없애는 도구들 🟡 중급
타입 선택으로 해결하기
// ① 인자는 빌림으로 (r03 · 🔐 o02)
fn f(s: &str) // String 대신
fn g(v: &[T]) // Vec<T> 대신
// ② Cow — 고칠 때만 복사 (🔐 o03)
use std::borrow::Cow;
fn normalize(s: &str) -> Cow<'_, str> {
if s.contains(' ') { Cow::Owned(s.replace(' ', "_")) }
else { Cow::Borrowed(s) } // ★ 대부분의 경우 할당 0
}
// ③ Arc 로 공유 (🔐 o10)
let shared = Arc::new(big_data);
let c = Arc::clone(&shared); // 카운터만 +1 — 쌉니다
// ④ 슬라이스로 부분 참조 (🔐 o04)
let head = &text[..10]; // 복사 없음
// ⑤ 반환을 인덱스로
fn find(&self, pat: &str) -> Option<Range<usize>> // &str 대신
// ⑥ SmallVec — 작으면 스택에 (별도 크레이트)
use smallvec::SmallVec;
let mut v: SmallVec<[u32; 8]> = SmallVec::new(); // 8개까지는 할당 0 ★
// ⑦ 문자열 인터닝 — 반복되는 문자열
// string-interner · lasso 크레이트
// Arc<str> 로 공유 (🔐 o04)
버퍼 재사용 패턴 ★
// 출력 버퍼를 받는 API (🔐 o04 · 🟨 j04)
pub fn process_into(input: &[u8], out: &mut Vec<u8>) {
out.clear(); // ★ 용량 유지
out.extend(input.iter().map(|b| b ^ 0xFF));
}
// 호출부
let mut buf = Vec::with_capacity(4096);
for chunk in stream {
process_into(chunk, &mut buf); // 할당은 첫 회 한 번뿐
sink.write_all(&buf)?;
}
// 객체 풀 — 큰 구조체를 재사용
struct Pool { free: Vec<Buffer> }
impl Pool {
fn get(&mut self) -> Buffer { self.free.pop().unwrap_or_default() }
fn put(&mut self, mut b: Buffer) { b.clear(); self.free.push(b); }
}
// 아레나 — 한꺼번에 할당하고 한꺼번에 해제 (파서에 유용)
// bumpalo 크레이트
// let arena = bumpalo::Bump::new();
// let node = arena.alloc(Node { .. }); // 개별 해제 없음 ★
// ★ 주의: 풀·아레나는 복잡도를 더합니다.
// 먼저 with_capacity 와 clear 로 해결되는지 보세요.
실무에선 이렇게 — 경계에서의 할당 🔴 실무
FFI 경계가 특히 중요합니다 ★
// ❌ 파이썬에서 초당 수천 번 부르는 함수가 매번 할당
#[pyfunction]
fn process(data: Vec<f64>) -> Vec<f64> { // 입력 복사 + 출력 할당
data.iter().map(|x| x * 2.0).collect()
}
// ✅ numpy 제로카피 + 제자리 수정 (🐍 y06)
#[pyfunction]
fn process_inplace(mut a: PyReadwriteArray1<f64>) {
for x in a.as_slice_mut().unwrap() { *x *= 2.0; }
}
// ✅ 또는 출력 배열을 받기
#[pyfunction]
fn process_into(a: PyReadonlyArray1<f64>, mut out: PyReadwriteArray1<f64>) { }
// Node 도 같습니다 (🟨 j04)
#[napi]
pub fn process_into(input: Buffer, mut output: Buffer) -> Result<u32> { }
// ★ 경계 통과 자체도 비용입니다 (🐍 y01 원칙 ①)
// 할당 + 변환 + 호출 오버헤드가 계산보다 클 수 있습니다.
// 배치로 묶어 호출 횟수를 줄이는 것이 먼저입니다.
할당자 교체 (b06)
[dependencies]
mimalloc = { version = "0.1", default-features = false }
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
★ 언제 효과가 있나
· 멀티스레드에서 할당이 잦을 때 ★ (e05)
· musl 로 빌드했을 때 (musl 의 malloc 이 느립니다 · b07)
· 작은 객체를 많이 만들 때
★ 후보
mimalloc 범용적으로 좋습니다
jemalloc 오래 검증됨 (tikv-jemallocator)
snmalloc 멀티스레드 특화
★ 주의
· 바이너리가 수백 KB 커집니다 (b10)
· 파이썬 확장에서는 신중하게 —
프로세스 전역 할당자를 바꾸면 파이썬 자체에도 영향을 줍니다 ★
(효과가 확인될 때만 쓰고, 반드시 측정하세요)
★ 근본은 '할당을 줄이는 것' 입니다.
할당자 교체는 그다음입니다.
#[global_allocator] 를 바꾸면
파이썬 인터프리터의 할당까지 영향을 받습니다.
효과와 부작용을 반드시 측정하고, 확신이 없으면 건드리지 마세요.
한 단계 더 — 할당 비용의 실체 🧬 전문가
무엇이 비싼가
malloc 한 번의 비용 (직접 재 보세요 · e01)
· 빠른 경로 (thread-local 캐시에 맞는 크기가 있음) 수~수십 ns
· 느린 경로 (새 페이지 요청 · mmap) 수 μs ★
· 경합 (여러 스레드가 동시에) 더 큼
★ 진짜 비용은 '그 뒤' 에 있습니다
· 새 메모리는 캐시에 없습니다 → 첫 접근이 캐시 미스 (e04)
· 힙이 조각나 지역성이 나빠집니다
· free 도 비용이 있습니다
★ 그래서
Vec::with_capacity 로 한 번 잡고 재사용하면
'할당 비용' 보다 '캐시 지역성' 에서 더 크게 이득을 봅니다.
# 확인
strace -c -e trace=mmap,munmap,brk ./target/release/app
# → 시스템 콜 횟수가 많으면 큰 할당이 반복되는 것
할당이 사라지는 경우
// ① 컴파일러가 스택으로 승격 (제한적)
let v = vec![1, 2, 3];
let sum: i32 = v.iter().sum();
// → LLVM 이 이 vec 을 스택 배열로 바꿀 수 있습니다 (항상은 아님)
// ② 인라인 + 상수 폴딩
const fn kib(n: usize) -> usize { n * 1024 } // 컴파일 타임 (r03)
// ③ 빈 Vec 은 할당하지 않습니다 (r06)
let v: Vec<u8> = Vec::new(); // 힙 접근 0
assert_eq!(v.capacity(), 0);
// ④ ZST 는 절대 할당하지 않습니다 (r07)
let v: Vec<()> = vec![(); 1_000_000]; // 힙 0바이트 ★
// ⑤ 작은 문자열 최적화(SSO)는 Rust std 에 없습니다 ★
// String 은 항상 힙입니다 (C++ std::string 과 다름)
// → 짧은 문자열이 많으면 compact_str · smol_str 크레이트
// 확인 — 어셈블리에서 __rust_alloc 호출 찾기 (e03)
cargo asm --lib my_function | grep alloc
String 에는
작은 문자열 최적화(SSO)가 없습니다.
짧은 문자열을 대량으로 다룬다면 compact_str 같은 크레이트가
눈에 띄는 차이를 만듭니다.
clone()·format!·collect() 부터 찾으세요.
with_capacity + clear() 로 버퍼 재사용이 가장 효과가 크고,
인자는 &str/&[T], 조건부 복사는 Cow 를 쓰세요.
할당 횟수를 세어 보는 것이 가장 확실한 진단입니다.이터레이터가 for 만큼 빠른 이유
.iter().map().filter().sum() 이 손으로 쓴 for 문과 같은 기계어가 됩니다.
“추상화에는 비용이 없다(zero-cost)”는 말의 실체를 어셈블리로 직접 확인하고,
그 최적화가 깨지는 조건도 함께 봅니다.
어셈블리로 확인하기 ★
cargo install cargo-show-asm
cargo asm --lib "mylib::sum_iter" --rust # 소스와 함께 표시 ★
cargo asm --lib --everything | head -50
# 또는 godbolt.org 에 붙여 넣기 (-O 옵션)
#[no_mangle]
pub fn sum_iter(v: &[u64]) -> u64 {
v.iter().sum()
}
#[no_mangle]
pub fn sum_loop(v: &[u64]) -> u64 {
let mut s = 0;
for i in 0..v.len() { s += v[i]; }
s
}
// → 둘 다 거의 같은 어셈블리가 나옵니다.
// 오히려 iter 쪽이 나은 경우도 있습니다 (범위 검사가 없어서) ★
# 결과에서 확인할 것
· 루프가 하나인가 (map · filter 마다 루프가 생기지 않음)
· 범위 검사(bounds check)가 있는가 — panic 호출이 보이면 있는 것
· SIMD 명령이 있는가 (vpaddq · paddd 등 · e09)
· 함수 호출이 남아 있는가 (인라인이 안 된 것)
왜 사라지나
① 어댑터는 전부 구조체입니다
Map<Filter<slice::Iter<u64>>>
각자 next() 를 구현합니다
② 전부 제네릭이라 단형화됩니다 (r10)
타입이 정확히 정해지므로 동적 호출이 없습니다
③ next() 가 인라인됩니다
#[inline] 이 붙어 있고, 본체가 작습니다
④ 인라인 후 LLVM 이 루프 하나로 합칩니다
중간 구조체는 레지스터로 사라집니다
⑤ 범위 검사 제거
슬라이스 이터레이터는 포인터를 직접 전진시키므로
인덱싱과 달리 검사가 아예 없습니다 ★
★ 그래서 오히려 이터레이터가 빠를 수 있습니다.
v[i] 는 매번 i < len 을 확인해야 합니다.
이렇게도 쓸 수 있다 — 최적화가 깨지는 조건 🟡 중급
인라인이 막히는 자리 ★
// ① dyn — 동적 호출은 인라인 불가 (🔐 o06)
let f: Box<dyn Fn(u64) -> u64> = Box::new(|x| x * 2);
v.iter().map(|x| f(*x)).sum::<u64>(); // 호출마다 간접 분기
// ✅ 제네릭으로
fn apply<F: Fn(u64) -> u64>(v: &[u64], f: F) -> u64 {
v.iter().map(|x| f(*x)).sum()
}
// ② 크레이트 경계 — LTO 가 꺼져 있으면 인라인이 제한됩니다 (b06)
// → lto = "thin" 이상을 켜세요
// → 작은 pub 함수에는 #[inline] (r03)
// ③ 함수가 너무 커서 인라인 비용이 초과
// → 안쪽 루프만 작은 함수로 분리
// ④ 부동소수점 합 — 순서를 못 바꿔 벡터화가 막힙니다 ★
let s: f64 = v.iter().sum(); // 결합 법칙이 성립하지 않음
// 정확도를 조금 포기해도 되면 청크로 부분합
let s: f64 = v.chunks(8).map(|c| c.iter().sum::<f64>()).sum();
// ⑤ 조기 종료가 있는 어댑터 뒤의 순서 (🔐 o12)
v.iter().map(expensive).filter(cheap); // ❌ 전부 계산
v.iter().filter(cheap).map(expensive); // ✅ 거른 뒤에
범위 검사 제거하기
// ❌ 검사가 매번 들어갑니다
fn dot(a: &[f64], b: &[f64]) -> f64 {
let mut s = 0.0;
for i in 0..a.len() { s += a[i] * b[i]; } // b[i] 검사가 남습니다
s
}
// ✅ ① zip — 컴파일러가 길이를 압니다
fn dot(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
// ✅ ② 길이를 먼저 맞추기 (힌트 주기)
fn dot(a: &[f64], b: &[f64]) -> f64 {
let n = a.len().min(b.len());
let (a, b) = (&a[..n], &b[..n]); // ★ 이후 검사가 사라집니다
let mut s = 0.0;
for i in 0..n { s += a[i] * b[i]; }
s
}
// ✅ ③ 배열로 (길이가 타입에 있음 · r10)
fn dot4(a: &[f64; 4], b: &[f64; 4]) -> f64 { }
// ❌ unsafe 는 마지막 수단 (🔬 d01)
// unsafe { *a.get_unchecked(i) }
// → 이득이 측정으로 확인될 때만. 대개 ①②로 충분합니다 ★
실무에선 이렇게 — 읽히면서 빠른 코드 🔴 실무
이터레이터를 믿으세요 ★
// ★ 실무 지침
// 1. 이터레이터로 자연스럽게 쓰세요
// 2. 측정해서 느리면 어셈블리를 보세요
// 3. 그때 for 문이나 unsafe 를 고려하세요
// → 대부분 1단계에서 끝납니다
// 자주 쓰는 고성능 패턴
let sum: u64 = v.iter().sum();
let max = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let count = v.iter().filter(|x| **x > 0).count();
let (evens, odds): (Vec<_>, Vec<_>) = v.iter().partition(|x| *x % 2 == 0);
// 청크 단위 — SIMD 가 잘 걸립니다 (e09)
for chunk in v.chunks_exact(8) { // ★ chunks 보다 exact 가 빠름
// 컴파일러가 길이 8을 알아 벡터화하기 쉽습니다
}
let rem = v.chunks_exact(8).remainder();
// 제자리 변환 — 할당 없음 (e02)
v.iter_mut().for_each(|x| *x *= 2.0);
// 두 슬라이스 동시에
a.iter().zip(b.iter()).for_each(|(x, y)| { });
// 병렬로 바꾸기 — 여섯 글자 (e05)
use rayon::prelude::*;
let sum: u64 = v.par_iter().sum();
비교 실험 만들기
// benches/iter_vs_loop.rs — 직접 확인해 보세요 (e01)
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn variants(c: &mut Criterion) {
let v: Vec<u64> = (0..1_000_000).collect();
let mut g = c.benchmark_group("sum");
g.bench_function("iter", |b| b.iter(|| black_box(&v).iter().sum::<u64>()));
g.bench_function("index", |b| b.iter(|| {
let v = black_box(&v);
let mut s = 0u64;
for i in 0..v.len() { s += v[i]; }
s
}));
g.bench_function("chunks", |b| b.iter(|| {
black_box(&v).chunks_exact(8)
.map(|c| c.iter().sum::<u64>()).sum::<u64>()
}));
g.bench_function("par", |b| b.iter(|| {
use rayon::prelude::*;
black_box(&v).par_iter().sum::<u64>()
}));
g.finish();
}
criterion_group!(b, variants);
criterion_main!(b);
// ★ 결과는 환경마다 다릅니다. 직접 재 보는 것이 이 섹션의 핵심입니다.
한 단계 더 — 컴파일러가 하는 변환 🧬 전문가
주요 최적화 패스
인라인 확장 (inlining)
작은 함수 본체를 호출부에 펼칩니다. 다른 모든 최적화의 전제입니다.
루프 융합 (loop fusion)
map 과 filter 가 각각 루프를 만들지 않고 하나로 합쳐집니다.
스칼라 치환 (SROA)
구조체 필드를 레지스터로 흩어 놓습니다.
→ 이터레이터 어댑터 구조체가 사라지는 이유 ★
루프 언롤링 (unrolling)
반복을 몇 개씩 펼쳐 분기 비용을 줄입니다.
벡터화 (SLP / loop vectorization)
여러 원소를 한 명령으로 (e09)
죽은 코드 제거 (DCE)
결과를 안 쓰면 통째로 삭제 → 벤치마크가 0ns 가 되는 이유 (e01)
# 어떤 최적화가 적용/실패했는지 보기
RUSTFLAGS="-C remark=all" cargo build --release 2>&1 | grep -i vector
cargo rustc --release -- -C llvm-args=-pass-remarks-analysis=loop-vectorize
MIR 수준에서 보기
# Rust 자체 최적화 (LLVM 이전) · 🔬 d10
cargo +nightly rustc --release -- -Zdump-mir=sum_iter
ls mir_dump/
# MIR 최적화 끄고 비교
cargo +nightly rustc -- -Zmir-opt-level=0
# LLVM IR 보기
cargo rustc --release -- --emit llvm-ir
grep -A 30 "define.*sum_iter" target/release/deps/*.ll
# ★ 실무에서 여기까지 볼 일은 드뭅니다.
# cargo asm 으로 최종 결과를 보는 것으로 대부분 충분합니다.
# 인라인이 됐는지 빠르게 확인
cargo asm --lib my_fn | grep -c "call"
# call 이 없으면 전부 인라인된 것 ★
for 문과 같은 기계어가 되고,
범위 검사가 없어 오히려 빠를 수 있습니다.
깨지는 조건은 dyn · LTO 꺼짐 · 부동소수점 합 · 어댑터 순서입니다.
의심되면 cargo asm 으로 직접 확인하세요.메모리 레이아웃과 캐시
같은 알고리즘도 데이터가 메모리에 어떻게 놓여 있느냐에 따라 수 배가 갈립니다. CPU 는 이미 충분히 빠르고, 대부분의 시간을 메모리를 기다리며 보냅니다. 네이티브 언어를 쓰는 진짜 이유가 여기 있습니다 — 배치를 내가 정할 수 있습니다.
비용의 크기 ★
메모리 계층 (대략적인 지연 · CPU 마다 다릅니다)
레지스터 0 사이클
L1 캐시 ~4 사이클
L2 캐시 ~12 사이클
L3 캐시 ~40 사이클
RAM ~200~300 사이클 ★ L1 의 50배 이상
SSD ~수만 사이클
★ 의미
덧셈 하나가 1 사이클인데 RAM 접근이 200 사이클입니다.
→ "계산을 줄이는 것" 보다 "메모리 접근을 줄이는 것" 이 대개 큽니다.
캐시 라인 = 64바이트 ★
메모리는 64바이트 단위로 읽힙니다.
u64 하나를 읽어도 그 주변 8개가 함께 옵니다.
→ 연속으로 접근하면 공짜, 흩어져 접근하면 매번 비용
# 내 CPU 확인
lscpu | grep -i cache
sysctl -a | grep cachesize # 맥
연속 vs 흩어짐
// ✅ 연속 — 캐시 라인 하나에 8개가 들어옵니다
let v: Vec<u64> = (0..1_000_000).collect();
let sum: u64 = v.iter().sum();
// ❌ 흩어짐 — 원소마다 캐시 미스
let v: Vec<Box<u64>> = (0..1_000_000).map(Box::new).collect();
let sum: u64 = v.iter().map(|b| **b).sum(); // ★ 수 배 느립니다
// ❌ 트레이트 객체도 마찬가지 (🔐 o06)
let shapes: Vec<Box<dyn Shape>> = ...; // 힙에 흩어져 있음
// ✅
enum Shape { Circle(f64), Rect(f64, f64) }
let shapes: Vec<Shape> = ...; // 연속 배치 ★
// ❌ 연결 리스트 — 포인터를 따라가며 매번 미스
// ✅ Vec 이 거의 항상 낫습니다 (r06)
// "삽입이 O(1)" 이라는 이론적 장점은
// 캐시 미스 앞에서 대부분 무의미합니다 ★
// 확인 — 캐시 미스 세기 (리눅스)
perf stat -e cache-misses,cache-references,instructions ./target/release/app
Vec 을 쓰세요”가 반복되는 이유가 이것입니다(r06).
원소 수천 개까지는 선형 탐색이 HashMap 보다 빠른 경우가 흔합니다 —
해시 계산과 캐시 미스가 없기 때문입니다.
이렇게도 쓸 수 있다 — AoS 와 SoA 🟡 중급
구조체 배열 vs 배열 구조체 ★
// AoS (Array of Structs) — 자연스러운 형태
struct Particle { x: f32, y: f32, z: f32, mass: f32, name: String }
let ps: Vec<Particle>;
// x 만 순회하면? — 캐시 라인마다 x 는 4바이트뿐,
// 나머지 60바이트는 낭비입니다 ★
for p in &ps { total += p.x; }
// SoA (Struct of Arrays) — 필드별로 나누기
struct Particles {
x: Vec<f32>, y: Vec<f32>, z: Vec<f32>,
mass: Vec<f32>, name: Vec<String>,
}
// x 만 순회하면 캐시 라인이 100% 활용됩니다
for x in &ps.x { total += x; } // ★ 수 배 빠를 수 있습니다
// 게다가 벡터화도 잘 걸립니다 (e09)
// ★ 판단 기준
// · 필드 일부만 자주 순회 → SoA
// · 객체 단위로 접근 → AoS
// · 게임 엔진 · 시뮬레이션 · 데이터프레임 → 대개 SoA
// (polars · arrow 가 컬럼 지향인 이유 · 🐍 y01)
// 도구
// soa_derive 크레이트가 SoA 를 자동 생성해 줍니다
구조체 크기 줄이기
use std::mem::size_of;
// ❌ 필드 배치로 낭비가 생깁니다
struct Big { flag: bool, id: u64, kind: u8, value: f64 }
// Rust 는 재배치하지만 (r07), 크기를 줄일 수 있는 여지가 있습니다
// ✅ 작은 타입 쓰기
struct Small { id: u32, value: f32, kind: u8, flag: bool }
println!("{} {}", size_of::<Big>(), size_of::<Small>());
// ✅ enum 배리언트 크기 맞추기 (r08)
enum Msg { Small(u8), Big([u8; 256]) } // 전체가 257바이트 ★
enum Msg2 { Small(u8), Big(Box<[u8; 256]>) } // 16바이트
// clippy::large_enum_variant 가 잡아 줍니다 (b09)
// ✅ Option 을 공짜로 (r08)
size_of::<Option<&T>>() == size_of::<&T>()
size_of::<Option<NonZeroU32>>() == 4
// ✅ 인덱스로 포인터 대체 (🔐 o01)
struct Node { parent: Option<u32>, children: Vec<u32> } // 8바이트 대신 4
// 확인
cargo +nightly rustc --release -- -Zprint-type-sizes | grep -A 8 "Particle"
// ★ 효과: 구조체가 작아지면 캐시 라인에 더 많이 들어갑니다.
// 100만 개 배열에서 64→32바이트면 메모리 접근이 절반입니다.
실무에선 이렇게 — 접근 패턴을 고친다 🔴 실무
순차 접근으로 바꾸기 ★
// ❌ 2차원 배열을 열 방향으로 순회
for x in 0..w {
for y in 0..h { sum += grid[y * w + x]; } // ★ 매번 캐시 미스
}
// ✅ 행 방향 (메모리 순서대로)
for y in 0..h {
for x in 0..w { sum += grid[y * w + x]; }
}
// → 수 배 차이가 납니다. 반드시 재 보세요 (e01)
// ✅ 행렬 전치는 블록 단위로 (타일링)
const B: usize = 64;
for by in (0..h).step_by(B) {
for bx in (0..w).step_by(B) {
for y in by..(by + B).min(h) {
for x in bx..(bx + B).min(w) { out[x * h + y] = a[y * w + x]; }
}
}
}
// ✅ 정렬해서 접근 (랜덤 인덱스 조회가 많을 때)
indices.sort_unstable(); // 순차 접근이 됩니다
for &i in &indices { sum += data[i]; }
// ✅ 프리페치 힌트 (드물게 필요 · nightly)
// core::intrinsics::prefetch_read_data(ptr, 3);
측정으로 확인하기
# 캐시 미스율 (리눅스)
perf stat -e cache-references,cache-misses,\
L1-dcache-loads,L1-dcache-load-misses ./target/release/app
# 출력 예
# 1,234,567,890 cache-references
# 456,789,012 cache-misses # 37% ★ 높습니다
#
# ★ 해석
# 미스율 < 5% 좋음
# 5~20% 개선 여지
# > 20% 접근 패턴을 봐야 합니다
# 어느 코드에서 미스가 나는지
perf record -e cache-misses ./target/release/app
perf report
# 맥
# Instruments 의 Counters 템플릿
# 캐시 시뮬레이션
valgrind --tool=cachegrind ./target/release/app
cg_annotate cachegrind.out.* # 줄 단위로 미스를 보여 줍니다 ★
한 단계 더 — 정렬과 false sharing 🧬 전문가
false sharing — 스레드를 늘렸는데 느려질 때 ★
// ❌ 두 카운터가 같은 캐시 라인에 있으면
struct Counters { a: AtomicU64, b: AtomicU64 } // 16바이트 — 같은 라인
// 스레드 1이 a 를, 스레드 2가 b 를 갱신하면
// 서로의 캐시 라인을 무효화합니다 (핑퐁) ★
// → 독립적인 데이터인데도 성능이 급락합니다
// ✅ 캐시 라인만큼 떨어뜨리기 (🔗 f03)
#[repr(align(64))]
struct Padded(AtomicU64);
struct Counters { a: Padded, b: Padded }
// ✅ 또는 crossbeam 의 CachePadded
use crossbeam_utils::CachePadded;
struct Counters { a: CachePadded<AtomicU64>, b: CachePadded<AtomicU64> }
// ✅ 가장 좋은 방법: 스레드별 지역 변수로 모으고 마지막에 합치기
let total: u64 = data.par_chunks(1024)
.map(|c| c.iter().sum::<u64>()) // ★ 각자 지역 합계
.sum(); // 마지막에 한 번만
// 확인
perf stat -e cache-misses,LLC-load-misses ./app
// 스레드를 늘렸는데 미스가 급증하면 false sharing 을 의심하세요
정렬과 페이지
use std::mem::align_of;
align_of::<u64>(); // 8
align_of::<f64>(); // 8
// SIMD 타입은 16 · 32 · 64 (e09)
// 정렬된 할당이 필요할 때
#[repr(align(32))]
struct Aligned32([f32; 8]);
// 큰 배열은 페이지 정렬을 고려 (4KB)
// 대용량 처리에서 huge page 를 쓰면 TLB 미스가 줍니다
// 리눅스: madvise(MADV_HUGEPAGE) · transparent huge pages
// ★ 실무에서 여기까지 볼 일
// · 수 GB 데이터를 다루는 처리
// · 실시간 오디오·영상
// · 고빈도 거래
// → 대부분의 네이티브 모듈에서는 e02(할당)·e04(배치)까지면 충분합니다
// TLB 미스 확인
perf stat -e dTLB-load-misses,dTLB-loads ./app
// 메모리 대역폭이 한계인지 확인
// 대역폭 포화 상태면 계산 최적화는 효과가 없습니다
// → 데이터 크기를 줄이거나(압축·작은 타입) 알고리즘을 바꾸세요 ★
f64→f32, 압축, SoA)만이 답입니다.
Vec) · 순차 접근 · 작은 구조체 세 가지가 기본이고,
필드 일부만 순회한다면 SoA 를 검토하세요.
스레드를 늘렸는데 느려지면 false sharing 을,
최적화가 안 먹히면 메모리 대역폭 한계를 의심하세요.rayon — 여섯 글자로 병렬화
.iter() 를 .par_iter() 로 바꾸면 전 코어를 씁니다.
데이터 경쟁은 컴파일러가 막아 주므로(e06) 안심하고 쓸 수 있습니다.
다만 작은 데이터에는 오히려 손해이고, 메모리 대역폭이 한계면 효과가 없습니다.
기본 사용
[dependencies]
rayon = "1"
use rayon::prelude::*;
// 이터레이터를 그대로 병렬로 ★
let sum: u64 = data.par_iter().map(heavy).sum();
let out: Vec<f64> = data.par_iter().map(|x| f(*x)).collect();
let count = data.par_iter().filter(|x| pred(x)).count();
let max = data.par_iter().max();
let any = data.par_iter().any(|x| *x < 0); // 조기 종료도 됩니다
// 제자리 수정
data.par_iter_mut().for_each(|x| *x *= 2.0);
data.par_chunks_mut(1024).for_each(|c| process(c));
// 정렬
v.par_sort();
v.par_sort_unstable_by_key(|u| u.score);
// 두 작업을 동시에
let (a, b) = rayon::join(|| compute_a(), || compute_b());
// 재귀 분할 (퀵소트 · 트리 순회)
fn quicksort<T: Send + Ord>(v: &mut [T]) {
if v.len() <= 1 { return; }
let mid = partition(v);
let (lo, hi) = v.split_at_mut(mid);
rayon::join(|| quicksort(lo), || quicksort(hi)); // ★
}
// 스레드 풀 설정
rayon::ThreadPoolBuilder::new().num_threads(4).build_global().unwrap();
언제 이득인가 ★
★ 이득이 큰 조건
· CPU 바운드 (계산이 많음)
· 작업 단위가 충분히 큼 (원소당 수 μs 이상 또는 원소가 아주 많음)
· 원소끼리 독립적
· 메모리 대역폭에 여유가 있음 (e04)
★ 이득이 없거나 손해인 경우
· 데이터가 작음 (수천 개 미만) — 분배 비용이 더 큽니다
· 메모리 대역폭이 한계 — 코어를 늘려도 그대로 ★
· I/O 바운드 — async 를 쓰세요 (e08)
· 원소마다 락이 필요 — 경합으로 오히려 느려집니다
· 이미 프로세스가 여러 개 (과다 구독)
★ 실측이 답입니다 (e01)
let mut g = c.benchmark_group("par");
for n in [1_000, 10_000, 100_000, 1_000_000] { ... }
→ 교차점이 어디인지 그래프로 보입니다
// 적응형 분기
if data.len() < THRESHOLD {
data.iter().map(f).sum()
} else {
data.par_iter().map(f).sum()
}
이렇게도 쓸 수 있다 — 분할 크기 조절 🟡 중급
작업 훔치기(work stealing)
rayon 은 작업을 재귀적으로 반으로 쪼개고,
놀고 있는 스레드가 남의 작업을 훔쳐 갑니다.
★ 그래서
· 원소마다 비용이 달라도 자동으로 균형이 맞습니다
· 스레드 수를 신경 쓸 필요가 없습니다
· 다만 쪼개는 것 자체에 비용이 있습니다
# 분할 조절
data.par_iter()
.with_min_len(1000) // ★ 최소 이만큼은 한 스레드가
.with_max_len(10000)
.map(f).sum();
# 청크로 직접
data.par_chunks(4096).map(|c| process(c)).sum::<f64>();
★ 튜닝 기준
· 원소당 작업이 아주 짧으면 → with_min_len 을 크게
· 원소마다 비용이 크게 다르면 → 작게 (훔치기가 잘 되도록)
· 기본값으로 시작하고 측정 후 조정하세요 ★
결과를 모으는 방법
use rayon::prelude::*;
use std::sync::atomic::{AtomicU64, Ordering};
// ✅ ① 반환값으로 모으기 (가장 좋습니다)
let sum: u64 = data.par_iter().map(f).sum();
let v: Vec<_> = data.par_iter().map(f).collect(); // 순서가 유지됩니다 ★
// ✅ ② fold + reduce — 스레드별로 모았다가 합치기
let total = data.par_iter()
.fold(|| 0u64, |acc, x| acc + f(x)) // 스레드별 초기값
.reduce(|| 0u64, |a, b| a + b); // 합치기
// ✅ ③ 원자 카운터 (간단한 경우)
let count = AtomicU64::new(0);
data.par_iter().for_each(|x| {
if pred(x) { count.fetch_add(1, Ordering::Relaxed); }
});
// ❗ 경합이 심하면 느려집니다 (e04 의 false sharing)
// ❌ Mutex 로 결과 모으기 — 병렬 이득이 사라집니다
let out = Mutex::new(Vec::new());
data.par_iter().for_each(|x| out.lock().unwrap().push(f(x))); // ★ 나쁨
// ✅ Result 도 collect 됩니다 (🔐 o08)
let r: Result<Vec<_>, E> = data.par_iter().map(try_f).collect();
// 에러가 나면 조기 종료
let r: Result<(), E> = data.par_iter().try_for_each(|x| check(x));
실무에선 이렇게 — 다른 런타임과 함께 쓰기 🔴 실무
과다 구독을 피하세요 ★
★ 문제 상황
· 파이썬 multiprocessing 4개 프로세스 × rayon 8스레드 = 32 스레드
· Node 워커 4개 × rayon 8 = 32
· 컨테이너에 CPU 2개 할당인데 호스트 코어 수(64)로 스레드 생성 ★
→ 컨텍스트 스위치와 캐시 경합으로 오히려 느려집니다
★ 대응
① 스레드 수를 환경에 맞게
let n = std::thread::available_parallelism()
.map(|n| n.get()).unwrap_or(1);
rayon::ThreadPoolBuilder::new().num_threads(n).build_global()?;
② 환경 변수로 조절 가능하게
RAYON_NUM_THREADS=4
③ 컨테이너 CPU 제한을 읽기
available_parallelism 은 cgroup 을 고려합니다 (최신 Rust) ★
이전 버전이면 num_cpus 크레이트의 get_physical
④ 라이브러리라면 전역 풀을 건드리지 마세요
사용자가 정하게 두거나, 전용 풀을 만드세요
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build()?;
pool.install(|| data.par_iter().map(f).sum::<u64>());
파이썬·Node 와 조합
// 🐍 파이썬 — GIL 을 놓고 병렬 (y07)
#[pyfunction]
fn process(py: Python<'_>, data: Vec<f64>) -> Vec<f64> {
py.allow_threads(|| { // ★ GIL 해제
data.par_iter().map(|x| heavy(*x)).collect()
})
}
// ❗ allow_threads 안에서 파이썬 객체를 만지면 컴파일 에러 (안전합니다)
// 🟨 Node — AsyncTask 안에서 (j05)
impl Task for BatchTask {
fn compute(&mut self) -> Result<Vec<f64>> {
Ok(self.rows.par_iter().map(score).collect()) // 이미 워커 스레드
}
}
// ❗ libuv 풀(4) + rayon 풀(코어 수) 이 겹칩니다
// UV_THREADPOOL_SIZE 와 rayon 스레드 수를 함께 조절하세요
// 🟨 WASM — rayon 을 쓰려면 별도 설정이 필요합니다 (j08)
// wasm-bindgen-rayon + SharedArrayBuffer + COOP/COEP 헤더
// 대개는 Web Worker 여러 개가 현실적입니다
allow_threads + par_iter 두 줄로 얻습니다(🐍 y07).
한 단계 더 — 스케일이 안 나올 때 🧬 전문가
원인 진단 ★
스레드 8개인데 4배밖에 안 빨라졌다 — 왜?
① 암달의 법칙
직렬 부분이 10% 면 최대 10배까지만 빨라집니다.
→ 직렬 구간(입출력·수집)을 먼저 줄이세요
② 메모리 대역폭 포화 ★ 가장 흔합니다
계산이 가벼우면 코어가 아니라 메모리가 병목입니다 (e04)
→ perf stat 로 확인. 데이터를 작게 만드는 것이 답입니다
③ false sharing (e04)
→ CachePadded · 지역 누적
④ 락 경합
→ 락 범위 축소 · 원자 연산 · 샤딩 (e07)
⑤ 할당자 경합 (e02)
→ mimalloc · jemalloc 으로 교체
⑥ 하이퍼스레딩
논리 코어 16개여도 물리 코어는 8개입니다.
계산 집약적이면 물리 코어 수가 상한입니다 ★
⑦ 터보 부스트
전 코어 가동 시 클럭이 내려갑니다 (단일 스레드보다 낮음)
# 측정
perf stat -e task-clock,context-switches,cpu-migrations ./app
htop # 코어별 사용률 확인
스케일 곡선 그리기
// 스레드 수를 바꿔 가며 재기
for n in [1, 2, 4, 8, 16] {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(n).build().unwrap();
let t = std::time::Instant::now();
let r = pool.install(|| data.par_iter().map(f).sum::<f64>());
println!("{n} 스레드: {:?}", t.elapsed());
}
// ★ 해석
// 선형에 가까움 → 좋습니다
// 4개에서 평평해짐 → 메모리 대역폭 또는 물리 코어 수
// 늘리니 느려짐 → 경합 또는 과다 구독
// 효율 계산
// 효율 = (1스레드 시간 / n스레드 시간) / n
// 0.8 이상이면 양호합니다
// 실제 워크로드로 확인
// 마이크로 벤치는 캐시가 따뜻해 낙관적입니다 (e01)
// 엔드투엔드로 재세요
.iter() → .par_iter() 여섯 글자면 전 코어를 씁니다.
작은 데이터에는 손해이니 임계값을 측정으로 정하고,
결과는 collect/sum/fold+reduce 로 모으세요(Mutex 로 모으면 이득이 사라집니다).
스케일이 안 나오면 메모리 대역폭을 먼저 의심하세요.Send · Sync — 데이터 경쟁이 빌드 에러
다른 언어에서 동시성 버그는 운영에서 가끔 터지는 재현 불가능한 문제였습니다.
Rust 에서는 컴파일 에러입니다.
그 장치가 Send 와 Sync 두 트레이트입니다 —
이 탭에서 가장 중요한 개념입니다.
두 트레이트의 뜻 ★
Send — "이 값을 다른 스레드로 '보내도' 안전하다"
소유권을 옮기는 것
Sync — "이 값을 여러 스레드가 '동시에 참조해도' 안전하다"
&T 가 Send 인 것과 같습니다
★ 대부분의 타입은 둘 다 자동으로 구현됩니다 (auto trait)
구성 요소가 전부 Send/Sync 면 자동으로 Send/Sync 입니다.
★ 아닌 것들
Rc<T> !Send, !Sync 카운터가 원자적이지 않음 (🔐 o10)
RefCell<T> Send, !Sync 빌림 검사가 원자적이지 않음
Cell<T> Send, !Sync
*const T !Send, !Sync 원시 포인터 (🔬 d03)
MutexGuard !Send 락을 잡은 스레드가 풀어야 함
★ 조합
Arc<T> T: Send+Sync 면 Send+Sync ★
Mutex<T> T: Send 면 Send+Sync ★ (락이 보호하므로)
RwLock<T> T: Send+Sync 면 Send+Sync
컴파일러가 막는 모습
use std::rc::Rc;
use std::sync::Arc;
let data = Rc::new(vec![1, 2, 3]);
// std::thread::spawn(move || { println!("{:?}", data); });
// error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely ★
// help: within this `{closure}`, the trait `Send` is not implemented
// ✅ Arc 로 (🔐 o10)
let data = Arc::new(vec![1, 2, 3]);
let d = Arc::clone(&data);
std::thread::spawn(move || { println!("{:?}", d); });
// 공유 가변 상태
// let counter = Rc::new(RefCell::new(0)); // ❌ 스레드 불가
let counter = Arc::new(Mutex::new(0)); // ✅
let c = Arc::clone(&counter);
std::thread::spawn(move || { *c.lock().unwrap() += 1; });
// ★ 핵심: "실수로 스레드 간 공유" 가 불가능합니다.
// C++ 에서는 그냥 컴파일되고, 가끔 값이 깨집니다.
// Rust 에서는 빌드가 안 됩니다.
이렇게도 쓸 수 있다 — 에러를 읽고 푸는 법 🟡 중급
자주 만나는 에러와 처방 ★
① `Rc<T>` cannot be sent between threads safely
→ Arc 로 바꾸세요
② `RefCell<T>` cannot be shared between threads safely
→ Mutex 나 RwLock 으로
③ `*mut T` cannot be sent between threads safely
→ 래퍼 타입을 만들고 unsafe impl Send (🔗 f07 · 🔬 d08)
단, 정말 안전한지 스스로 증명해야 합니다 ★
④ closure may outlive the current function
→ move 를 붙이거나 thread::scope 를 쓰세요 (🔐 o03 · e07)
⑤ borrowed value does not live long enough
→ 'static 이 필요합니다 → 소유 값으로 넘기기
⑥ `MutexGuard` cannot be sent between threads safely (async)
→ await 를 넘어 락을 들고 있습니다 → 범위를 좁히세요 (e08)
★ 대부분의 처방이 정해져 있습니다.
에러 메시지의 `help:` 를 읽으면 대개 답이 있습니다 (r01).
타입이 Send/Sync 인지 확인하기
// 컴파일 타임 검사 — 테스트로 두면 회귀를 막습니다 ★
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
#[test]
fn types_are_thread_safe() {
assert_send::<MyHandle>();
assert_sync::<MyHandle>();
assert_send::<MyError>(); // 에러도 Send 여야 유용합니다 (🔐 o09)
}
// 또는 상수로
const _: () = {
fn check<T: Send + Sync>() {}
fn assert_all() { check::<MyHandle>(); }
};
// ★ 라이브러리를 만든다면 이 테스트를 반드시 두세요.
// 나중에 필드에 Rc 를 하나 추가하면 조용히 !Send 가 되고,
// 사용자의 멀티스레드 코드가 깨집니다.
// 왜 Send 가 아닌지 알아내기
// let _: &dyn Send = &my_value;
// → 에러 메시지가 어느 필드 때문인지 알려 줍니다 ★
실무에선 이렇게 — FFI 에서의 Send/Sync 🔴 실무
직접 붙여야 할 때 ★
// C 라이브러리 핸들을 감쌀 때 (🔗 f08)
pub struct Decoder { raw: *mut ffi::Decoder }
// 원시 포인터가 있어 자동으로 !Send, !Sync 입니다.
// C 문서를 읽고 판단해 직접 붙입니다.
// ★ 판단 기준
// "이 핸들을 다른 스레드로 옮겨도 되나?" → Send
// "여러 스레드가 동시에 써도 되나?" → Sync
// 대부분의 C 라이브러리는
unsafe impl Send for Decoder {} // 컨텍스트별로는 안전한 경우가 많음
// unsafe impl Sync for Decoder {} // ❌ 동시 접근은 대개 불가
// Sync 가 필요하면 락으로 감싸세요
pub struct SharedDecoder(Mutex<Decoder>);
// → Mutex<T> 는 T: Send 면 Sync 입니다 ★
// ❗ unsafe impl 은 '컴파일러에게 하는 약속' 입니다.
// 틀리면 데이터 경쟁이 나고, 그건 UB 입니다 (🔬 d02).
// 반드시 근거를 주석으로 남기세요
// SAFETY: libfoo 문서에 따르면 Decoder 는 한 번에 한 스레드에서만
// 사용하면 안전하며, 스레드 간 이동은 허용됩니다.
// (docs: https://... section 3.2)
파이썬·Node 바인딩에서
// 🐍 PyO3 (y05 · y07)
// Py<T> 는 Send 입니다 (GIL 로 보호되므로)
// Bound<'py, T> 는 !Send (GIL 에 묶여 있음)
// → allow_threads 클로저에 Bound 를 넣으면 컴파일 에러 ★ 안전합니다
#[pyclass]
struct Handle { inner: Mutex<Decoder> } // frozen + Mutex 가 안전 (y05)
// 🟨 napi-rs (j05·j07)
// AsyncTask 의 compute 는 워커 스레드에서 돕니다
// → Task 구조체가 Send 여야 합니다
// JsObject · Env 는 !Send — 워커로 못 넘깁니다 (설계상 안전)
// 🔗 C ABI 콜백 (f07)
// *mut c_void user data 를 스레드로 넘기려면 래퍼가 필요합니다
struct SendPtr(*mut c_void);
unsafe impl Send for SendPtr {}
// SAFETY: 호출자가 스레드 안전한 포인터를 준다고 문서에 명시함
// ★ 공통 원칙
// 각 바인딩 프레임워크가 !Send 로 막아 주는 것들은
// 전부 '이유가 있어서' 막는 것입니다. 우회하기 전에 왜인지 이해하세요.
unsafe impl Send/Sync 는 신중하게 —
이것이 틀리면 Rust 의 동시성 보장이 통째로 무너집니다.
SAFETY 주석에 근거(문서 링크·조건)를 반드시 남기세요(🔬 d08).
한 단계 더 — auto trait 의 동작 🧬 전문가
자동 구현과 부정 구현
// Send/Sync 는 'auto trait' 입니다
// 구성 요소가 전부 만족하면 자동으로 구현됩니다
struct A { x: u32, y: String } // 자동으로 Send + Sync
struct B { x: u32, y: Rc<u32> } // Rc 때문에 !Send, !Sync
// 명시적으로 막기 (nightly 에서만 negative impl)
// impl !Send for MyType {}
// 안정 버전에서 !Send 로 만드는 방법 ★
use std::marker::PhantomData;
use std::rc::Rc;
pub struct NotSend {
data: u32,
_marker: PhantomData<Rc<()>>, // ★ Rc 를 담은 척 → !Send
}
// PhantomData 의 다른 용도
// PhantomData<T> T 를 소유한 것처럼 (드롭 검사)
// PhantomData<&'a T> 라이프타임을 붙들기 (🔐 o03 의 변성)
// PhantomData<*const T> !Send + !Sync
// 제네릭에서 전파
struct Wrapper<T>(T);
// Wrapper<u32> 는 Send, Wrapper<Rc<u32>> 는 !Send — 자동으로 따라갑니다 ★
Rust 가 막지 못하는 것
★ Send/Sync 가 보장하는 것
· 데이터 경쟁 없음 (data race)
= 두 스레드가 동기화 없이 같은 메모리를 쓰는 일
★ 보장하지 않는 것
· 데드락 ★
두 락을 다른 순서로 잡으면 여전히 멈춥니다 (🔐 o10)
· 경쟁 조건 (race condition)
논리적 순서 문제는 타입으로 못 막습니다
예: 잔액을 읽고 → 검사하고 → 쓰는 사이에 다른 스레드가 바꿈
→ 트랜잭션이나 CAS 로 해결해야 합니다 (🔬 d06)
· 메모리 누수
Rc 순환은 안전한 코드로도 샙니다 (🔐 o10)
· 성능 문제
락 경합·false sharing 은 컴파일러가 모릅니다 (e04)
★ 그래도 큰 차이입니다
데이터 경쟁은 '재현 불가능한 버그' 의 주범이었습니다.
그것 하나가 사라진 것만으로 디버깅 시간이 크게 줍니다.
# 데드락 진단
# parking_lot 의 deadlock_detection feature
# gdb: thread apply all bt
# tokio-console (async · e08)
Send=다른 스레드로 보내도 됨, Sync=동시에 참조해도 됨입니다.
대부분 자동으로 구현되고, Rc·RefCell·원시 포인터가 예외입니다.
데이터 경쟁이 컴파일 에러가 되지만 데드락은 못 막습니다.
FFI 핸들에 unsafe impl Send 를 붙일 때는 SAFETY 주석에 근거를 남기세요.스레드 · 채널 · Mutex
rayon 이 대부분을 해결하지만(e05), 직접 스레드를 다뤄야 할 때가 있습니다 — 백그라운드 작업, 생산자-소비자, 장시간 워커. 세 가지 도구와 각각을 언제 쓰는지 정리합니다.
스레드 만들기
use std::thread;
// 기본 — 'static 이 필요합니다 (🔐 o03)
let h = thread::spawn(move || {
heavy_work(data) // 소유권을 넘김
});
let result = h.join().unwrap(); // 결과 받기 (패닉이면 Err)
// ★ 스코프 스레드 — 지역 변수를 빌릴 수 있습니다 (1.63+)
let data = vec![1, 2, 3];
thread::scope(|s| {
s.spawn(|| println!("{:?}", &data)); // ✅ 빌려도 됩니다
s.spawn(|| println!("{}", data.len()));
}); // 여기서 전부 join
// → 'static 이 필요 없어 훨씬 편합니다
// 슬라이스를 나눠 각자 처리 (🔐 o02)
let (a, b) = data.split_at_mut(mid);
thread::scope(|s| {
s.spawn(|| process(a));
s.spawn(|| process(b));
});
// 이름·스택 크기 지정
thread::Builder::new()
.name("worker".into())
.stack_size(4 * 1024 * 1024)
.spawn(|| { })?;
// 코어 수
let n = std::thread::available_parallelism()?.get();
세 가지 도구 고르기 ★
| 필요 | 도구 |
|---|---|
| 데이터 병렬 처리 | rayon (e05) |
| 작업 전달 | 채널 |
| 공유 상태 수정 | Mutex |
| 읽기가 훨씬 많음 | RwLock |
| 숫자 카운터 | Atomic* |
| 한 번만 초기화 | OnceLock |
| I/O 대기가 많음 | async (e08) |
★ 순서대로 검토하세요 (🔐 o10)
① 나눠 주기 (공유하지 않기) — 가장 좋습니다
② 메시지로 주고받기 (채널)
③ 읽기 전용 공유 (Arc<T>)
④ 원자 타입으로 충분한가
⑤ 그래도 필요하면 Mutex
"메모리를 공유해 소통하지 말고, 소통해서 메모리를 공유하라"
이렇게도 쓸 수 있다 — 채널로 주고받기 🟡 중급
표준 채널과 crossbeam
use std::sync::mpsc;
// 다중 생산자 · 단일 소비자
let (tx, rx) = mpsc::channel();
for i in 0..4 {
let tx = tx.clone();
thread::spawn(move || { tx.send(work(i)).unwrap(); });
}
drop(tx); // ★ 원본을 버려야 rx 가 끝납니다
for result in rx { println!("{result:?}"); }
// 크기 제한 (배압 · backpressure)
let (tx, rx) = mpsc::sync_channel(100); // 100개 차면 send 가 블록됩니다 ★
// crossbeam — 더 빠르고 기능이 많습니다
use crossbeam_channel as cc;
let (tx, rx) = cc::bounded(100);
let (tx, rx) = cc::unbounded();
// 다중 소비자도 가능합니다 (rx.clone())
// select — 여러 채널 중 준비된 것
cc::select! {
recv(rx1) -> msg => handle(msg),
recv(rx2) -> msg => handle(msg),
default(Duration::from_millis(100)) => timeout(),
}
// 파이프라인 구성
// 읽기 스레드 → 채널 → 처리 스레드 여러 개 → 채널 → 쓰기 스레드
// 각 단계가 독립적이라 이해하기 쉽습니다 ★
Mutex 와 원자 타입
use std::sync::{Arc, Mutex, RwLock};
use std::sync::atomic::{AtomicU64, Ordering};
// Mutex — 배타적 접근
let state = Arc::new(Mutex::new(State::new()));
{
let mut g = state.lock().unwrap();
g.count += 1;
} // ★ 스코프로 락 범위를 좁히세요
// RwLock — 읽기는 동시에
let cache = Arc::new(RwLock::new(HashMap::new()));
let v = cache.read().unwrap().get(&key).cloned(); // 여러 스레드 동시
cache.write().unwrap().insert(key, val); // 배타적
// 원자 타입 — 락 없이 (훨씬 쌉니다)
let counter = Arc::new(AtomicU64::new(0));
counter.fetch_add(1, Ordering::Relaxed);
counter.load(Ordering::Relaxed);
// Ordering 은 🔬 d06 에서 자세히. 카운터는 Relaxed 로 충분합니다.
// 한 번만 초기화 (r02)
use std::sync::OnceLock;
static CONFIG: OnceLock<Config> = OnceLock::new();
fn config() -> &'static Config { CONFIG.get_or_init(Config::load) }
// parking_lot — 더 빠르고 단순합니다 (오염 개념 없음)
// use parking_lot::Mutex;
// let g = m.lock(); // unwrap 이 필요 없습니다 ★
실무에선 이렇게 — 락을 잘 쓰는 법 🔴 실무
경합을 줄이는 기법 ★
// ① 락 범위 최소화 (🔐 o10)
// ❌
let mut g = state.lock().unwrap();
let result = expensive_io(&g.config); // 락을 잡은 채 I/O
g.results.push(result);
// ✅
let cfg = { state.lock().unwrap().config.clone() };
let result = expensive_io(&cfg);
state.lock().unwrap().results.push(result);
// ② 샤딩 — 락을 여러 개로 나누기
struct ShardedMap { shards: Vec<Mutex<HashMap<K, V>>> }
impl ShardedMap {
fn get(&self, k: &K) -> Option<V> {
let i = hash(k) as usize % self.shards.len();
self.shards[i].lock().unwrap().get(k).cloned()
}
}
// → dashmap 크레이트가 이걸 잘 만들어 둔 것입니다 ★
// ③ 지역 누적 후 한 번에 합치기 (e05)
let total: u64 = data.par_chunks(1024)
.map(|c| c.iter().sum::<u64>()) // 스레드별 지역 합
.sum();
// ④ 읽기가 많으면 RwLock 또는 arc-swap
use arc_swap::ArcSwap;
static CONFIG: Lazy<ArcSwap<Config>> = ...;
let cfg = CONFIG.load(); // 락 없이 읽기 ★
CONFIG.store(Arc::new(new_config)); // 통째로 교체
// ⑤ 불변 데이터로 만들기 — 락 자체가 불필요해집니다
데드락을 피하는 규칙
□ 락을 잡은 채 다른 락을 잡지 마세요
꼭 필요하면 '항상 같은 순서로' 잡으세요 (락 순서 규칙)
문서에 순서를 적어 두세요
□ 락을 잡은 채 콜백을 부르지 마세요 (🔗 f07 · 🐍 y09)
남의 코드가 다시 내 락을 잡을 수 있습니다 (재진입)
□ 락을 잡은 채 채널에 send 하지 마세요
상대가 락을 기다리고 있으면 멈춥니다
□ 락을 잡은 채 await 하지 마세요 (e08)
std::sync::Mutex 는 async 에서 특히 위험합니다
□ Mutex 안에 Mutex 를 두지 마세요
★ 진단
· parking_lot 의 deadlock_detection feature
· gdb -p PID → thread apply all bt
· 스레드 이름을 붙여 두면 덤프가 읽힙니다
thread::Builder::new().name("indexer".into())
· 타임아웃 있는 락 (try_lock_for) 으로 조기 발견
★ 락 오염 (poisoning)
락을 든 스레드가 패닉하면 이후 lock() 이 Err 를 냅니다
let g = m.lock().unwrap_or_else(|e| e.into_inner()); // 무시하고 진행
parking_lot 은 이 개념이 없어 더 단순합니다
let _ = mutex.lock() 는 락을 잡자마자 놓습니다(🔐 o01).
let _guard = mutex.lock() 라야 유지됩니다 —
이름 없는 _ 와 _이름 의 차이가 실제 버그가 되는 대표 사례입니다.
한 단계 더 — 락의 비용과 대안 🧬 전문가
얼마나 비싼가
★ 대략적인 비용 (직접 재 보세요 · e01)
경합 없는 Mutex lock/unlock 수십 ns (원자 연산 몇 번)
경합 있는 Mutex 컨텍스트 스위치 → 수 μs 이상 ★
RwLock 읽기 (경합 없음) Mutex 와 비슷하거나 약간 비쌈
Atomic fetch_add (Relaxed) 수 ns
Atomic (SeqCst) 더 비쌈 (메모리 배리어 · 🔬 d06)
캐시 라인 핑퐁 수백 ns ★ (e04 의 false sharing)
★ 의미
· 경합이 없으면 락은 싸다 → 겁내지 마세요
· 경합이 생기면 급격히 비싸진다 → 범위를 좁히고 샤딩하세요
· 원자 연산도 경합하면 캐시 핑퐁이 납니다
# 측정
perf stat -e context-switches,cpu-migrations ./app
# 컨텍스트 스위치가 많으면 락 경합을 의심
# 락 경합 프로파일링
# 리눅스: perf lock record / perf lock report
# 또는 lock 획득 시간을 직접 계측
락 없는 자료구조
// 이미 잘 만들어진 것을 쓰세요 (직접 구현은 🔬 d06)
// crossbeam
use crossbeam::queue::{ArrayQueue, SegQueue};
let q = ArrayQueue::new(1024); // 고정 크기 락프리 큐
q.push(item).ok();
q.pop();
// dashmap — 샤딩된 동시 해시맵 ★
use dashmap::DashMap;
let m: DashMap<String, u64> = DashMap::new();
m.insert("a".into(), 1);
*m.entry("a".into()).or_insert(0) += 1;
// arc-swap — 읽기가 압도적으로 많은 설정
use arc_swap::ArcSwap;
// flume — 빠른 채널 (sync/async 양쪽)
// ★ 직접 락프리를 구현하지 마세요
// ABA 문제 · 메모리 회수(epoch/hazard pointer) · 메모리 순서 —
// 전부 정확히 해야 하고, 틀리면 재현 불가능한 버그가 됩니다 (🔬 d06)
// crossbeam 은 이것들을 검증된 형태로 제공합니다.
// 성능 비교는 반드시 실제 워크로드로
// 락프리가 항상 빠르지 않습니다. 경합이 낮으면 Mutex 가 더 빠릅니다 ★
Mutex, 카운터는 Atomic 입니다.
스코프 스레드를 쓰면 지역 변수를 빌릴 수 있어 훨씬 편하고,
락 범위를 좁히고 샤딩하는 것이 경합 해결의 기본입니다.
데드락은 컴파일러가 못 막으니 락 순서 규칙을 문서화하세요.async — 언제 쓰고 언제 쓰지 않나
async 는 기다림이 많을 때만 이득입니다. 계산이 많으면 오히려 손해이고, 코드도 복잡해집니다. 네이티브 모듈에서는 대개 필요 없습니다 — 그럼에도 알아야 하는 이유와, 필요할 때 쓰는 법을 봅니다.
판단 기준 ★
| 작업 | 선택 |
|---|---|
| CPU 계산 | rayon (e05) |
| 파일 몇 개 읽기 | 스레드 (e07) |
| 동시 연결 수천 개 | async |
| HTTP 요청 수백 개 | async |
| DB 커넥션 풀 | async 또는 스레드 |
| 파이썬 확장 | 대개 불필요 ★ |
| Node 애드온 | I/O 면 async (🟨 j05) |
★ async 의 이득
스레드 하나에 수천 개의 대기 작업을 얹을 수 있습니다.
스레드는 스택(기본 2MB)을 차지하지만
Future 는 수십~수백 바이트입니다 ★
★ async 의 비용
· 문법·개념이 복잡해집니다 (Pin · 실행자 · 취소 · 🔬 d05)
· 런타임(tokio)이 의존성으로 들어옵니다 (b10)
· 에러 메시지가 어려워집니다
· 라이브러리가 async 로 물듭니다 (전염성) ★
· CPU 작업을 넣으면 전체가 막힙니다
★ 결론
"동시 대기가 수백 개 이상" 이 아니면 스레드로 충분합니다.
네이티브 모듈은 대개 계산이 목적이므로 async 가 필요 없습니다.
기본 사용
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "time"] }
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let data = tokio::fs::read("a.txt").await?;
// 동시에 여러 개
let (a, b) = tokio::join!(fetch("u1"), fetch("u2"));
// 여러 개를 한꺼번에
let results = futures::future::join_all(
urls.iter().map(|u| fetch(u))
).await;
// 동시 개수 제한 ★ 중요합니다
use futures::stream::{self, StreamExt};
let results: Vec<_> = stream::iter(urls)
.map(|u| fetch(u))
.buffer_unordered(20) // 최대 20개 동시
.collect().await;
// 타임아웃
tokio::time::timeout(Duration::from_secs(5), fetch(u)).await??;
// ★ CPU 작업은 반드시 분리
let r = tokio::task::spawn_blocking(move || heavy(data)).await?;
Ok(())
}
이렇게도 쓸 수 있다 — 흔한 함정 🟡 중급
이벤트 루프를 막는 코드 ★
// ❌ async 안에서 블로킹 호출
async fn bad() {
let data = std::fs::read("big.bin").unwrap(); // ★ 워커 스레드를 막습니다
std::thread::sleep(Duration::from_secs(1)); // ★ 최악
heavy_computation(&data); // ★ CPU 작업
}
// ✅
async fn good() -> anyhow::Result<()> {
let data = tokio::fs::read("big.bin").await?;
tokio::time::sleep(Duration::from_secs(1)).await;
let r = tokio::task::spawn_blocking(move || heavy_computation(&data)).await?;
Ok(())
}
// ❌ 락을 await 너머로 들고 있기
async fn bad2(m: &std::sync::Mutex<State>) {
let g = m.lock().unwrap();
fetch().await; // ★ 컴파일 에러 (MutexGuard 는 !Send · e06)
// 또는 다른 태스크가 영원히 대기
}
// ✅ ① 락을 먼저 놓기
let v = { m.lock().unwrap().value };
fetch().await;
// ✅ ② async 전용 락
// let g = tokio::sync::Mutex::lock(&m).await;
// ❌ 진행률 표시를 위해 매번 await
// await 마다 스케줄러를 거칩니다. 루프 안에서 남발하지 마세요.
취소와 정리
// ★ async 태스크는 언제든 취소될 수 있습니다 (drop 되면 멈춤)
let h = tokio::spawn(async { work().await });
h.abort(); // 다음 await 지점에서 중단됩니다
// 그래서 '중간 상태' 를 남기면 안 됩니다
async fn transfer(&self) {
self.withdraw().await; // ★ 여기서 취소되면?
self.deposit().await; // 돈이 사라집니다
}
// → 취소 안전(cancel-safety)을 고려한 설계가 필요합니다
// select 에서 특히 주의
tokio::select! {
_ = read_exact(&mut buf) => {}, // ★ 중간에 취소되면 데이터 손실
_ = timeout => {},
}
// → 취소 안전한 API 인지 문서를 확인하세요
// 정리 (🔐 o07)
// Drop 안에서 await 를 할 수 없습니다 ★
// → 명시적 shutdown() 메서드를 제공하세요
async fn shutdown(&self) -> Result<()> { self.flush().await }
// 우아한 종료
let token = tokio_util::sync::CancellationToken::new();
tokio::select! {
_ = token.cancelled() => cleanup().await,
r = work() => r,
}
select! 로 감싼 작업은 언제든 중간에 버려질 수 있습니다 —
버려져도 괜찮은 작업인지 반드시 확인하세요.
실무에선 이렇게 — 네이티브 모듈에서의 async 🔴 실무
파이썬과 섞기
// 🐍 대부분의 경우 async 가 필요 없습니다 (y07)
// 계산은 allow_threads + rayon 으로 충분합니다
// 정말 필요한 경우 (HTTP 요청 수백 개 등)
// ① 동기 인터페이스로 감싸기 — 가장 단순합니다 ★
#[pyfunction]
fn fetch_all(py: Python<'_>, urls: Vec<String>) -> PyResult<Vec<String>> {
py.allow_threads(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { fetch_many(urls).await })
}).map_err(Into::into)
}
// ❗ 런타임을 매번 만들면 비쌉니다 → OnceLock 으로 재사용 (r02)
// ② 파이썬 asyncio 와 연동
// pyo3-async-runtimes 크레이트
// → Rust Future 를 파이썬 awaitable 로 변환
// 복잡도가 크게 올라가므로 정말 필요할 때만 ★
// 🟨 Node — 이벤트 루프 때문에 async 가 자연스럽습니다 (j05)
#[napi]
pub async fn fetch(url: String) -> Result<String> {
Ok(reqwest::get(&url).await?.text().await?)
}
런타임 관리
use std::sync::OnceLock;
use tokio::runtime::Runtime;
static RT: OnceLock<Runtime> = OnceLock::new();
fn runtime() -> &'static Runtime {
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(4) // ★ 코어 수에 맞게 (e05)
.enable_all()
.build()
.expect("런타임 생성 실패")
})
}
#[pyfunction]
fn call(py: Python<'_>, url: String) -> PyResult<String> {
py.allow_threads(|| runtime().block_on(fetch(&url)))
.map_err(|e| PyRuntimeError::new_err(e.to_string()))
}
// ★ 주의사항
// · 런타임 종료 순서 (🔐 o07 의 Drop 순서)
// 프로세스 종료 시 태스크가 남아 있으면 행이 걸릴 수 있습니다
// · fork 후에는 런타임이 깨집니다 (multiprocessing 주의) ★
// · 단일 스레드 런타임(current_thread)이 더 단순한 경우도 많습니다
한 단계 더 — Future 는 무엇인가 🧬 전문가
상태 기계로 컴파일됩니다
async fn f() -> u32 {
let a = g().await;
let b = h().await;
a + b
}
// 컴파일러가 대략 이런 enum 을 만듭니다
enum FState {
Start,
WaitingG { fut: GFuture },
WaitingH { a: u32, fut: HFuture },
Done,
}
impl Future for FState {
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<u32> { }
}
// ★ 그래서
// · await 지점의 지역 변수가 전부 상태에 저장됩니다
// → Future 크기가 커집니다 (큰 배열을 await 너머로 들지 마세요)
// · 자기 자신을 참조할 수 있어 Pin 이 필요합니다 (🔬 d05)
// · 힙 할당 없이 스택에서 조합됩니다 (zero-cost)
// Future 크기 확인
println!("{}", std::mem::size_of_val(&f()));
// 크면 Box::pin 으로 힙에 두세요 (재귀 async 에도 필요)
진단과 관찰
# tokio-console — 태스크 상태를 실시간으로 ★
# Cargo.toml: console-subscriber = "0.4"
# RUSTFLAGS="--cfg tokio_unstable" cargo run
tokio-console
# · 어느 태스크가 오래 폴링되는가 (블로킹 의심) ★
# · 대기 중인 태스크 수
# · 깨어난 횟수
# 블로킹 감지
# tokio = { features = ["rt-multi-thread"] }
# RUSTFLAGS="--cfg tokio_unstable"
# → 워커가 오래 블록되면 경고
# 태스크 이름 붙이기
tokio::task::Builder::new().name("indexer").spawn(async { })?;
# 계측
# tracing + tracing-subscriber
#[tracing::instrument]
async fn handle(req: Request) -> Response { }
# ★ async 디버깅은 동기 코드보다 어렵습니다.
# 스택 트레이스가 논리적 호출 순서를 보여 주지 않습니다.
# → 계측(tracing)을 처음부터 넣어 두세요
spawn_blocking 분리 · 락을 await 너머로 들지 않기 세 가지를 지키고,
취소 안전성을 항상 의식하세요.SIMD · 자동 벡터화
CPU 는 한 명령으로 여러 값을 동시에 계산할 수 있습니다. 대부분은 컴파일러가 알아서 해 줍니다 — 우리가 할 일은 그렇게 되도록 코드를 정리하는 것이고, 직접 쓰는 것은 마지막 수단입니다.
자동 벡터화를 유도하기 ★
// ✅ 벡터화되기 쉬운 코드
pub fn scale(v: &mut [f32], k: f32) {
for x in v.iter_mut() { *x *= k; } // ★ 단순 · 분기 없음 · 연속
}
pub fn dot(a: &[f32], b: &[f32]) -> f32 {
let n = a.len().min(b.len());
a[..n].iter().zip(&b[..n]).map(|(x, y)| x * y).sum()
}
// ❌ 벡터화를 막는 것들
// · 분기 (if 가 원소마다 다르게 갈림)
// · 조기 종료 (break · ?)
// · 함수 호출 (인라인 안 되면)
// · 원소 간 의존 (앞 결과를 다음에 씀)
// · 범위 검사 (e03)
// · f64 합의 순서 문제 (e03) ★
// ✅ 분기를 산술로 바꾸기
// for x in v { if *x < 0.0 { *x = 0.0 } } // 분기
for x in v.iter_mut() { *x = x.max(0.0); } // ★ 벡터화됩니다
// ✅ 청크로 나누어 힌트 주기
for c in v.chunks_exact_mut(8) {
for x in c.iter_mut() { *x *= k; }
}
벡터화됐는지 확인하기
# 어셈블리에서 SIMD 명령 찾기 (e03)
cargo asm --lib "mylib::scale" | grep -E "mulps|vmulps|fmul.*v[0-9]"
# x86: xmm/ymm/zmm 레지스터 · vaddps · vmulps
# ARM: v0.4s · fmul v0.4s
# 컴파일러에게 물어보기
RUSTFLAGS="-C remark=all" cargo build --release 2>&1 | grep -i vector
cargo rustc --release -- \
-C llvm-args=-pass-remarks-analysis=loop-vectorize
# "loop not vectorized: ..." 로 이유를 알려 줍니다 ★
# CPU 기능 켜기 (b06 의 주의사항 확인)
RUSTFLAGS="-C target-feature=+avx2,+fma" cargo build --release
RUSTFLAGS="-C target-cpu=x86-64-v3" cargo build --release
# ❗ 배포용에는 native 를 쓰지 마세요
# 내 CPU 가 지원하는 것
rustc --print cfg -C target-cpu=native | grep target_feature
lscpu | grep Flags
x86-64-v2/v3 기준선이나
런타임 감지를 쓰세요.
이렇게도 쓸 수 있다 — 런타임 감지 🟡 중급
CPU 기능을 실행 중에 확인 ★
pub fn sum(v: &[f32]) -> f32 {
#[cfg(target_arch = "x86_64")]
{
if is_x86_feature_detected!("avx2") {
return unsafe { sum_avx2(v) }; // ★ 안전합니다 (감지 후 호출)
}
}
sum_scalar(v)
}
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn sum_avx2(v: &[f32]) -> f32 {
// 이 함수 안에서는 컴파일러가 AVX2 를 자유롭게 씁니다
// 자동 벡터화만으로도 충분한 경우가 많습니다 ★
v.iter().sum()
}
fn sum_scalar(v: &[f32]) -> f32 { v.iter().sum() }
// ★ 이 패턴의 핵심
// #[target_feature] 함수는 자동 벡터화 폭이 넓어집니다.
// 인트린식을 직접 쓰지 않아도 상당한 이득을 봅니다.
// 감지 결과를 캐시 (매번 확인하지 않게)
use std::sync::OnceLock;
static HAS_AVX2: OnceLock<bool> = OnceLock::new();
fn has_avx2() -> bool {
*HAS_AVX2.get_or_init(|| is_x86_feature_detected!("avx2"))
}
이식성 있는 SIMD
// ① wide · simba 크레이트 (안정 버전에서 쓸 수 있습니다)
use wide::f32x8;
pub fn scale(v: &mut [f32], k: f32) {
let kv = f32x8::splat(k);
for c in v.chunks_exact_mut(8) {
let x = f32x8::from(&c[..8]) * kv;
c.copy_from_slice(x.as_array_ref());
}
// 나머지 처리
let rem = v.len() % 8;
for x in &mut v[v.len() - rem..] { *x *= k; }
}
// ② std::simd (nightly · 표준화 진행 중)
#![feature(portable_simd)]
use std::simd::{f32x8, num::SimdFloat};
// ③ 인트린식 직접 (최후의 수단 · 🔬 d10)
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;
unsafe { _mm256_mul_ps(a, b) }
// ★ 이식성이 사라지고, 유지보수가 어려워집니다
// ④ WASM SIMD (🟨 j09)
// RUSTFLAGS="-C target-feature=+simd128"
// 128비트만 지원됩니다
실무에선 이렇게 — 직접 쓰기 전에 🔴 실무
먼저 확인할 것들 ★
□ 이미 잘 만든 크레이트가 있는가
· ndarray + BLAS (행렬 연산)
· faer (선형대수)
· simdutf8 (UTF-8 검증 · 수 배 빠름)
· memchr (바이트 검색 · 아주 빠릅니다) ★
· bytecount · twox-hash · blake3
→ 대부분 이미 SIMD 로 최적화돼 있습니다
□ 메모리 대역폭이 한계는 아닌가 (e04)
SIMD 는 계산을 빠르게 할 뿐, 메모리는 그대로입니다
→ 대역폭 포화 상태면 효과가 없습니다 ★
□ 자동 벡터화가 이미 되고 있지 않은가
어셈블리를 먼저 보세요 (e03)
□ 이 함수가 정말 병목인가 (e01)
전체의 5% 를 4배 빠르게 해도 전체는 4% 개선
★ 실무 결론
직접 SIMD 를 쓸 일은 생각보다 드뭅니다.
· 라이브러리 사용 → 자동 벡터화 유도 → #[target_feature]
· 이 셋으로 대부분 해결됩니다.
쓴다면 지켜야 할 것
// ① 스칼라 버전을 반드시 남기세요
// · 폴백 경로 · 결과 대조 테스트 (r11) ★
#[test]
fn simd_matches_scalar() {
let v: Vec<f32> = (0..1000).map(|x| x as f32).collect();
let a = sum(&v);
let b = sum_scalar(&v);
assert!((a - b).abs() < 1e-3); // 부동소수점은 오차 허용
}
// ② 나머지(remainder) 처리를 잊지 마세요
// 길이가 8의 배수가 아닐 때 마지막 원소들
// ③ 정렬을 확인하세요 (e04)
// 비정렬 로드(loadu)는 약간 느리지만 안전합니다
// ④ 부동소수점 결과가 달라질 수 있습니다 ★
// 합산 순서가 바뀌므로 마지막 자리가 다릅니다
// → 정확한 재현이 필요한 계산이면 주의
// ⑤ unsafe 는 최소 범위로 (🔬 d08)
// #[target_feature] 함수 전체가 unsafe 가 되므로
// 안전한 래퍼로 감싸세요
// ⑥ 벤치마크로 이득을 증명하세요 (e01)
// SIMD 코드가 오히려 느린 경우도 있습니다
// (짧은 배열 · 대역폭 한계 · 다운클럭)
한 단계 더 — 벡터화의 안쪽 🧬 전문가
LLVM 이 하는 두 가지
① 루프 벡터화 (loop vectorization)
for i in 0..n { c[i] = a[i] + b[i] }
→ 4개씩(또는 8개씩) 묶어 한 명령으로
조건
· 반복 횟수를 알거나 런타임에 확인 가능
· 메모리 접근이 겹치지 않음 (aliasing · 🔬 d03) ★
→ Rust 의 &mut 는 배타적이라 aliasing 정보가 정확합니다
C 의 restrict 를 항상 붙인 것과 같은 효과입니다 ★
· 원소 간 의존이 없음
② SLP 벡터화 (superword level parallelism)
독립적인 연산 여러 개를 묶습니다
let (x, y, z) = (a.0 * k, a.1 * k, a.2 * k); // 한 명령으로
★ Rust 가 C 보다 유리한 지점
&mut T 는 "이 참조로만 접근한다" 는 보장입니다 (🔐 o02).
LLVM 이 noalias 를 붙일 수 있어 더 공격적으로 최적화합니다.
→ 같은 코드에서 Rust 가 C 보다 빠른 경우가 여기서 나옵니다.
실험해 보기
// godbolt.org 에 붙여 넣고 -O 로 비교해 보세요
#[no_mangle]
pub fn add_scalar(a: &[f32], b: &[f32], c: &mut [f32]) {
for i in 0..c.len() { c[i] = a[i] + b[i]; }
}
#[no_mangle]
pub fn add_zip(a: &[f32], b: &[f32], c: &mut [f32]) {
for ((c, a), b) in c.iter_mut().zip(a).zip(b) { *c = a + b; }
}
// → zip 쪽이 범위 검사 없이 더 깔끔하게 벡터화됩니다 ★
// 길이를 맞춰 힌트 주기
pub fn add_hint(a: &[f32], b: &[f32], c: &mut [f32]) {
let n = c.len().min(a.len()).min(b.len());
let (a, b, c) = (&a[..n], &b[..n], &mut c[..n]);
for i in 0..n { c[i] = a[i] + b[i]; }
}
// 확인 명령
// cargo asm --lib add_zip
// RUSTFLAGS="-C target-feature=+avx2" 로도 비교
zip/chunks_exact 로 쓰면 됩니다.
어셈블리로 확인하고, 필요하면 #[target_feature] + 런타임 감지를 쓰세요.
직접 인트린식은 마지막 수단이고, 이미 최적화된 크레이트(memchr·simdutf8)를 먼저 찾아보세요.파이썬 · C++ 대비 실제로 몇 배인가
“100배 빨라집니다”는 과장일 때가 많습니다. 실제 배수는 무엇을 어떻게 옮겼느냐에 따라 크게 다릅니다. 정직하게 기대치를 세우고, 직접 재는 방법을 정리합니다.
파이썬 대비 — 무엇이 다른가 ★
★ 파이썬이 느린 이유
· 모든 값이 힙 객체 (int 하나가 28바이트 · 캐시에 불리 · e04)
· 동적 타입 검사가 매 연산마다
· 바이트코드 해석 오버헤드
· GIL 로 CPU 병렬 불가 (🐍 y07)
★ 그래서 배수가 크게 갈립니다
순수 파이썬 루프 대비 보통 10~200배 ★
numpy 벡터화 대비 0.5~5배 (이미 C 입니다)
numpy + 임시 배열 많은 식 2~10배 (퓨전 이득 · 🐍 y06)
파이썬 문자열 파싱 대비 10~100배
I/O 대기가 대부분 1배 (차이 없음) ★
★ 여기에 더해지는 것
· GIL 해제 + rayon → 코어 수만큼 (파이썬으로는 불가능) ★
· 경계 통과 비용은 빼야 합니다 (🐍 y01)
★ 정직한 표현
"이 함수를 Rust 로 옮겨 우리 워크로드에서 32배 빨라졌습니다
(측정 환경: ... , 데이터: ...)"
이렇게 조건을 밝히세요.
C++ 대비 — 거의 같습니다
★ 같은 알고리즘·같은 자료구조라면 실질적으로 동등합니다
둘 다 LLVM 을 쓰고(clang), 같은 최적화를 받습니다.
★ Rust 가 유리할 수 있는 지점
· &mut 의 배타성 → noalias 정보가 정확 (e09) ★
· 이터레이터가 범위 검사 없이 최적화 (e03)
· 기본이 이동이라 불필요한 복사가 적음 (r05)
· 안전해서 최적화를 더 공격적으로 시도할 수 있음
★ C++ 가 유리할 수 있는 지점
· 성숙한 라이브러리 (BLAS · Eigen · TBB · CUDA)
· 작은 문자열 최적화(SSO)가 std::string 에 있음 (e02)
· 컴파일러 확장·인라인 어셈블리 (🔬 d10)
· 특정 도메인의 오랜 튜닝 자산
★ 실제로는 '누가 얼마나 튜닝했느냐' 가 언어 차이보다 큽니다.
벤치마크 게임의 수치는 참고만 하세요 —
대부분 특정 문제에 극단적으로 튜닝된 코드입니다.
이렇게도 쓸 수 있다 — 직접 재는 절차 🟡 중급
공정한 비교 만들기 ★
□ 같은 알고리즘인가
Rust 쪽만 더 좋은 알고리즘을 쓰면 언어 비교가 아닙니다
□ 같은 입력인가
같은 파일 · 같은 크기 · 같은 분포
□ 릴리즈 빌드인가 (b06)
파이썬 쪽도 -O 나 PyPy 를 고려할지 결정
□ 워밍업했는가
JIT(PyPy · V8)는 워밍업이 필수입니다 (🟨 j03)
□ 경계 비용을 포함했는가 ★
"Rust 함수 자체" 가 아니라
"파이썬에서 호출해 결과를 받기까지" 를 재세요
□ 여러 번 재고 중앙값을 봤는가
□ 환경을 밝혔는가
CPU · 코어 수 · OS · 데이터 크기 · 버전
# 파이썬 쪽 측정
import timeit, statistics
ts = timeit.repeat(lambda: fastmod.process(data), number=100, repeat=10)
print(f"{statistics.median(ts)/100*1000:.3f} ms/call")
비교 실험 예시
# bench.py — 세 가지 구현을 나란히
import numpy as np, timeit, fastmod
N = 1_000_000
data = np.random.random(N)
def pure_python(a):
return sum(x * x for x in a if x > 0.5)
def numpy_way(a):
return float((a[a > 0.5] ** 2).sum())
def rust_way(a):
return fastmod.sum_squares_above(a, 0.5) # 🐍 y06 제로카피
for name, f in [("pure", pure_python), ("numpy", numpy_way), ("rust", rust_way)]:
t = min(timeit.repeat(lambda: f(data), number=10, repeat=5)) / 10
print(f"{name:8} {t*1000:8.3f} ms")
# ★ 결과를 그대로 믿지 말고 여러 크기로 재세요
# N = 1_000 / 100_000 / 10_000_000
# 작은 N 에서는 경계 비용이 지배합니다 (🐍 y01)
실무에선 이렇게 — 기대치를 관리한다 🔴 실무
보고할 때의 원칙 ★
❌ "Rust 로 바꿔서 100배 빨라졌습니다"
✅ "파싱 함수를 Rust 로 옮겨, 전체 배치 처리 시간이
42초 → 3.8초 (11배) 로 줄었습니다.
측정: 로그 파일 2.3GB · Ryzen 7 8코어 · Linux
(함수 자체는 60배지만 I/O 가 남아 전체는 11배입니다)"
★ 엔드투엔드를 보고하세요
함수 배수는 개발자에게, 전체 배수는 의사결정자에게 의미가 있습니다.
★ 함께 보고할 것
· 유지보수 비용 (팀에 Rust 를 아는 사람 수 · 🔬 d12)
· 빌드·배포 복잡도 증가 (🛠️ b08)
· 되돌릴 수 있는지 (폴백 · 🐍 y01)
★ 성능이 아닌 이득도 함께
· 메모리 사용량 감소 (파이썬 객체 오버헤드가 사라짐)
· 지연 시간 안정성 (GC 스파이크 없음) ★
· 동시성 (GIL 해제)
→ 평균보다 p99 가 좋아지는 경우가 많습니다
기대에 못 미칠 때 점검표
□ --release 로 빌드했는가 (b06) ★ 1위 원인
□ 경계를 너무 자주 넘는가 (🐍 y01 · 🟨 j01)
→ 배치로 묶으세요
□ 데이터 변환이 지배적인가 (🐍 y04)
→ numpy 제로카피 / Buffer 로 (🐍 y06 · 🟨 j04)
□ 루프 안에 clone 이 있는가 (e02)
□ 캐시 친화적인가 (e04)
□ 병목이 다른 곳인가 (e01)
→ 프로파일러로 확인
□ 원래도 numpy 로 충분히 빨랐는가 (🐍 y01)
→ numpy 를 이기려면 퓨전·분기 있는 루프여야 합니다
□ I/O 바운드인가
→ 언어를 바꿔도 그대로입니다
★ 대부분 위 여덟 개 중 하나입니다.
순서대로 확인하면 원인을 찾을 수 있습니다.
한 단계 더 — 수치를 해석하는 법 🧬 전문가
평균이 아니라 분포를 보세요
★ 파이썬 → Rust 로 바꿀 때 자주 나타나는 패턴
평균: 10배 개선
p50 : 12배 개선
p99 : 40배 개선 ★ ← GC 스파이크가 사라져서
서비스 품질은 p99 가 결정합니다.
평균만 보고하면 이득을 과소평가하게 됩니다.
★ 측정할 것
· 처리량 (초당 건수)
· 지연 시간 분포 (p50 · p95 · p99 · max)
· 메모리 (RSS 최대치 · 평균)
· CPU 사용률 (코어 활용도)
# 지연 분포 측정
# hdrhistogram 크레이트
# 또는 결과를 파일로 뽑아 파이썬으로 분석
# 서버라면 부하 테스트
# oha · wrk · autocannon (🟨 j05)
# → p99 와 처리량을 함께 보세요
회귀를 추적하기
★ 성능은 조용히 나빠집니다
· 의존성 업데이트 (b03)
· 기능 추가로 루프 안에 검사 하나 추가
· 데이터 크기 증가로 캐시를 벗어남 (e04)
★ 추적 체계
① 대표 벤치마크 3~5개를 정하세요 (e01)
② CI 에서 매 커밋 측정 (iai-callgrind 로 노이즈 제거)
③ 임계값을 넘으면 알림
④ 운영 지표와 연결 (실제 워크로드가 진실입니다)
★ 문서에 남길 것
· 측정 방법과 환경
· 기대 배수와 그 근거
· 되돌리는 방법 (🐍 y01 의 폴백)
★ 마지막으로
성능 수치는 '지금 이 환경에서' 의 값입니다.
과장하지 않고 조건을 밝히는 것이
장기적으로 신뢰를 만듭니다.
--release → 경계 횟수 → 변환 비용 → clone 순으로 확인하고,
p99 와 메모리도 함께 보세요. 다음은 🔬 전문가 · unsafe 탭입니다.안전을 잠시
내려놓는 자리
unsafe 는 “아무거나 해도 된다”가 아니라 다섯 가지를 추가로 허용하는 것입니다.
대신 컴파일러가 검사하던 불변식을 사람이 지켜야 합니다.
이 탭은 FFI 를 다루다 보면 반드시 만나는 영역이고,
안전한 껍데기로 감싸 남에게 내놓는 법까지 봅니다.
unsafe 가 실제로 푸는 것 — 다섯 가지
가장 큰 오해는 “unsafe 를 쓰면 검사가 꺼진다”는 것입니다.
타입 검사도, 빌림 검사도 그대로 돕니다.
딱 다섯 가지 동작이 추가로 허용될 뿐입니다.
허용되는 다섯 가지 ★
unsafe {
// ① 원시 포인터 역참조
let x = *raw_ptr; // d03
// ② unsafe 함수 호출 (FFI 포함)
let n = ffi::c_function(arg); // 🔗 f08
// ③ 가변 static 접근 (2024 에디션에서 금지 방향)
// COUNTER += 1;
// ④ union 필드 읽기
let v = my_union.as_int; // 🔗 f03
// ⑤ unsafe 트레이트 구현
// unsafe impl Send for MyType {} // ⚡ e06
}
// ★ 여전히 검사되는 것
// · 타입 검사 (i32 를 String 에 못 넣습니다)
// · 빌림 검사 (&mut 두 개를 동시에 못 만듭니다)
// · 수명 검사
// · 소유권 · Drop
//
// unsafe 는 '검사 해제' 가 아니라 '추가 권한' 입니다.
unsafe 블록은 짧을 수 있습니다 —
위험한 연산 한 줄만 감싸면 되고, 나머지 코드는 여전히 안전합니다.
블록을 최대한 좁게 유지하는 것이 첫 번째 원칙입니다.
두 종류의 unsafe
// ① unsafe 블록 — "내가 규칙을 지켰다"
fn safe_wrapper(v: &[u8], i: usize) -> u8 {
assert!(i < v.len());
unsafe { *v.get_unchecked(i) } // 검사를 했으므로 안전
}
// ② unsafe fn — "부르는 쪽이 규칙을 지켜야 한다"
/// # Safety
/// `p` 는 `len` 바이트만큼 읽을 수 있는 유효한 포인터여야 합니다.
pub unsafe fn read_raw(p: *const u8, len: usize) -> Vec<u8> {
std::slice::from_raw_parts(p, len).to_vec()
}
// ★ unsafe fn 에는 반드시 # Safety 문서를 쓰세요 (d08)
// clippy::missing_safety_doc 이 강제해 줍니다 (🛠️ b09)
// 2024 에디션 — unsafe fn 안이 자동으로 unsafe 블록이 아닙니다
pub unsafe fn f(p: *const u8) {
unsafe { *p }; // ★ 명시적으로 감싸야 합니다
}
// → "이 함수의 어느 부분이 위험한지" 가 드러납니다
// 어트리뷰트도 unsafe 표기 (2024)
#[unsafe(no_mangle)]
pub extern "C" fn exported() { } // 🔗 f02
이렇게도 쓸 수 있다 — 언제 정말 필요한가 🟡 중급
실무에서 unsafe 가 나오는 자리 ★
① FFI — 이 가이드의 주 무대입니다
· C 함수 호출 (🔗 f08)
· 포인터를 슬라이스로 (🔗 f02)
· Box::into_raw / from_raw (🔗 f06)
→ 대부분의 네이티브 모듈에서 unsafe 는 여기뿐입니다
② 성능 — 검사 제거
get_unchecked · from_utf8_unchecked
→ 측정으로 이득이 확인될 때만 (⚡ e03) ★
대개 이터레이터로 해결됩니다
③ 자료구조 구현
Vec · HashMap 같은 것을 직접 만들 때
→ 표준 라이브러리나 크레이트를 먼저 찾으세요
④ Send/Sync 를 손으로 붙일 때 (⚡ e06)
⑤ 초기화되지 않은 메모리 (d04)
큰 버퍼를 0 으로 채우지 않고 쓰기
★ 실무 감각
일반 애플리케이션 코드에서 unsafe 가 필요한 경우는 거의 없습니다.
필요하다고 느끼면 대개 설계를 다시 볼 신호입니다 (🔐 o01).
쓰기 전에 확인할 것
□ 안전한 대안이 정말 없는가
· get_unchecked → 슬라이싱으로 검사 제거 (⚡ e03)
· from_utf8_unchecked → 한 번만 검증하고 재사용
· transmute → bytemuck · zerocopy 크레이트 (🔗 f03)
· 자기 참조 → 인덱스 기반 (🔐 o01)
□ 이득이 측정으로 확인됐는가 (⚡ e01) ★
"빠를 것 같아서" 는 이유가 아닙니다
□ 불변식을 문서로 적을 수 있는가 (d08)
적을 수 없다면 아직 이해하지 못한 것입니다
□ Miri 로 검증할 수 있는가 (d07)
□ 안전한 API 로 감쌀 수 있는가 (d08)
사용자가 unsafe 를 쓰지 않게 하는 것이 목표입니다
★ 크레이트 전체에서 금지하기 (🛠️ b09)
#![forbid(unsafe_code)] // 아예 못 쓰게
#![deny(unsafe_code)] // allow 로 예외 가능
→ FFI 모듈만 예외로 두는 구성이 흔합니다
실무에선 이렇게 — unsafe 를 관리한다 🔴 실무
한곳에 모으세요 ★
src/
├─ lib.rs #![deny(unsafe_code)] ← 대부분 안전
├─ core/ 순수 로직 (unsafe 없음)
└─ ffi/
├─ mod.rs #![allow(unsafe_code)] ← 여기만 예외 ★
└─ raw.rs 원시 포인터를 다루는 곳
★ 이점
· 리뷰 대상이 명확해집니다
· Miri 를 그 모듈에 집중할 수 있습니다 (d07)
· "이 크레이트의 unsafe 는 300줄" 처럼 셀 수 있습니다
# unsafe 를 세어 보기
grep -rn "unsafe" src/ | wc -l
cargo install cargo-geiger
cargo geiger # 의존성의 unsafe 사용량까지 ★
# 리뷰 규칙
# · unsafe 를 추가하는 PR 은 두 명이 리뷰
# · SAFETY 주석이 없으면 반려
# · Miri 테스트가 함께 오지 않으면 반려
SAFETY 주석 쓰기
// 관례: unsafe 블록 바로 위에 SAFETY 주석 ★
let s = unsafe {
// SAFETY: `ptr` 은 위에서 널을 검사했고, `len` 은 호출자가
// 보장하는 유효 길이입니다. 이 함수가 반환되기 전까지
// 해당 메모리는 해제되지 않습니다 (호출자 계약, f06 참조).
std::slice::from_raw_parts(ptr, len)
};
// unsafe fn 에는 # Safety 섹션
/// 버퍼를 처리합니다.
///
/// # Safety
///
/// - `ptr` 은 `len` 바이트에 대해 유효하고 정렬돼 있어야 합니다.
/// - `len == 0` 이면 `ptr` 은 널이어도 됩니다.
/// - 호출 중 다른 스레드가 이 메모리를 수정하면 안 됩니다.
pub unsafe fn process(ptr: *const u8, len: usize) -> u32 { }
// clippy 로 강제 (🛠️ b09)
// #![warn(clippy::undocumented_unsafe_blocks)]
// #![warn(clippy::missing_safety_doc)]
// ★ 주석의 목적
// 6개월 뒤의 내가, 그리고 리뷰어가
// "이게 왜 안전한가" 를 재확인할 수 있어야 합니다.
// "안전함" 한 줄은 주석이 아닙니다.
unsafe 는
아직 안전하지 않은 것입니다.
조건을 명확히 적을 수 있을 때까지 안전한 대안을 찾으세요.
한 단계 더 — 안전성의 경계 🧬 전문가
건전성(soundness)이란
★ 핵심 원칙
"안전한 코드만 써서는 UB 를 일으킬 수 없어야 한다"
이것을 만족하면 그 API 는 '건전(sound)' 합니다.
★ 불건전한 API 의 예
pub fn get(&self, i: usize) -> &T {
unsafe { self.data.get_unchecked(i) } // ❌ 검사 없이 노출
}
→ 사용자가 안전한 코드로 out-of-bounds 를 일으킬 수 있습니다
→ 이 함수는 unsafe fn 이거나, 검사를 해야 합니다
★ 유명한 사례
· 초기 std::mem::uninitialized (지금은 deprecated · d04)
· Rc 를 Send 로 만든 크레이트들 (RUSTSEC 권고가 나왔습니다)
· 라이프타임을 늘리는 transmute
★ 건전성 버그는 보안 취약점입니다
RustSec 데이터베이스에 등록되고, cargo audit 가 잡습니다 (🛠️ b03)
★ 판단 기준
"악의적인 사용자가 안전한 코드만으로 이 API 를 오용해
메모리 안전성을 깨뜨릴 수 있는가?"
→ 그렇다면 설계를 고쳐야 합니다 (d08)
unsafe 를 줄여 온 역사
★ 표준 라이브러리와 생태계가 unsafe 를 대체해 온 것들
mem::uninitialized → MaybeUninit (d04)
transmute (바이트 변환) → bytemuck · zerocopy (🔗 f03)
수동 포인터 산술 → 슬라이스 메서드 (chunks · windows)
자기 참조 구조체 → 인덱스 · ouroboros · Pin (d05)
수동 락프리 구현 → crossbeam (⚡ e07)
C 문자열 처리 → CStr · CString (🔗 f06)
정렬 안 된 읽기 → read_unaligned · bytemuck
★ 교훈
"unsafe 가 필요하다" 고 느끼면
먼저 crates.io 에서 검증된 대안을 찾아보세요.
누군가 이미 그 문제를 안전하게 풀어 두었을 가능성이 높습니다. ★
# 검증된 크레이트인지 확인
# 다운로드 수 · 최근 커밋 · unsafe 사용량 (cargo geiger)
# RustSec 권고 이력 (cargo audit)
unsafe 는 다섯 가지를 추가 허용할 뿐, 검사를 끄지 않습니다.
실무에서는 FFI 경계에서만 만나는 것이 정상이고,
블록을 좁게 · 한 모듈에 모아 · SAFETY 주석을 반드시 남기세요.
안전한 코드만으로 UB 를 만들 수 없어야 그 API 가 건전합니다.UB 목록 — 무엇이 금지인가
“정의되지 않은 동작(UB)”은 틀린 값이 나오는 정도가 아닙니다 — 컴파일러가 “일어날 리 없다”고 가정하고 코드를 지워 버립니다. 다행히 Rust 는 UB 조건이 목록으로 정리돼 있습니다. 이 목록 밖은 안전합니다.
UB 목록 ★
① 널·정렬 안 됨·해제된 포인터 역참조 (d03)
② 데이터 경쟁 — 동기화 없이 동시 접근 (⚡ e06)
③ 유효하지 않은 값 만들기
· bool 에 0/1 이 아닌 값
· char 에 유효하지 않은 코드포인트
· enum 에 없는 판별자 ★
· NonNull 에 0
· &T 에 널이나 댕글링
· 초기화되지 않은 메모리를 값으로 읽기 (d04)
④ 잘못된 aliasing
· &mut T 가 살아 있는 동안 다른 경로로 접근
· &T 가 살아 있는 동안 수정 ★
⑤ 할당 범위를 벗어난 포인터 산술
⑥ str 에 유효하지 않은 UTF-8
⑦ 언와인딩이 extern "C" 경계를 넘음 (🔗 f09)
⑧ 잘못된 인라인 어셈블리 (d10)
⑨ 무한 루프에 부수 효과가 없음 (일부 조건)
★ 이 목록에 없으면 UB 가 아닙니다
· 정수 오버플로 → UB 아님 (랩어라운드 · r02) ★ C++ 와 다릅니다
· 널 참조 생성 → 만들 수 없습니다 (Option 이 있으니)
· 메모리 누수 → UB 아님 (안전합니다 · 🔐 o10)
UB 가 코드를 지우는 예
// ❌ 널일 수 있는 포인터를 먼저 역참조
unsafe fn bad(p: *const i32) -> i32 {
let x = *p; // ① 역참조 → "p 는 널이 아니다" 가정
if p.is_null() { return 0; } // ② 그러므로 항상 거짓
x // ③ → 검사가 통째로 삭제됩니다 ★
}
// 결과: 널을 넘겨도 크래시가 아니라 '검사 없이 진행'
// → 디버그에서는 멀쩡하고 릴리즈에서만 터지는 대표 패턴
// ✅ 검사를 먼저
unsafe fn good(p: *const i32) -> i32 {
if p.is_null() { return 0; }
*p
}
// ❌ 유효하지 않은 enum 값 만들기
#[repr(u8)]
enum Status { Ok = 0, Err = 1 }
let s: Status = unsafe { std::mem::transmute(7u8) }; // ★ UB
// match 가 어느 갈래도 안 타거나, 엉뚱하게 동작합니다
// ✅ TryFrom 으로 검증 (r08)
// ❌ 안전한 코드에서도 UB 를 부를 수 있는 unsafe API
// → 그것이 '불건전' 입니다 (d01)
이렇게도 쓸 수 있다 — C++ 와 비교 🟡 중급
Rust 가 UB 를 줄인 지점 ★
| 상황 | C++ | Rust |
|---|---|---|
| 정수 오버플로 | UB | 랩(정의됨) |
| 배열 범위 초과 | UB | 패닉 |
| 널 역참조 | UB | 불가능 |
| 해제 후 사용 | UB | 컴파일 에러 |
| 이터레이터 무효화 | UB | 컴파일 에러 |
| 데이터 경쟁 | UB | 컴파일 에러 |
| 초기화 안 된 변수 | UB | 컴파일 에러 |
| strict aliasing | UB | 안전 코드엔 없음 |
★ 핵심 차이
C++ 에서는 이 모든 것이 '평범한 코드' 에서 일어날 수 있습니다.
Rust 에서는 unsafe 블록 안에서만 가능합니다.
→ "이 프로그램에 UB 가 있을 수 있는 곳" 이
수만 줄에서 수백 줄로 줄어듭니다 ★
그래서 리뷰와 검증(Miri · d07)을 집중할 수 있습니다.
여전히 조심할 것
// ① 정수 오버플로는 UB 는 아니지만 버그입니다 (r02)
let x: u8 = 255;
let y = x + 1; // 디버그: 패닉 / 릴리즈: 0
// → checked_add · overflow-checks = true (🛠️ b06)
// ② 부동소수점
0.0 / 0.0 // NaN (UB 아님)
f64::INFINITY // 정의됨
(1e308 * 10.0) // inf
let n = f64::NAN as i32; // 0 (Rust 는 saturating cast · 정의됨) ★
// ③ 패닉은 UB 가 아닙니다 — 정의된 동작입니다
// 다만 FFI 경계를 넘으면 UB (🔗 f09)
// ④ 무한 재귀 → 스택 오버플로 → abort (UB 아님, 프로세스 종료)
// ⑤ 메모리 누수 → 안전합니다 (🔐 o10)
std::mem::forget(value); // 완전히 안전한 함수입니다
// ★ 그러니 "Rust 는 안전하다" 는 정확히는
// "메모리 안전성과 데이터 경쟁 없음을 보장한다" 입니다.
// 논리 버그 · 데드락 · 누수 · 패닉은 여전히 사람의 몫입니다.
실무에선 이렇게 — UB 를 예방하는 습관 🔴 실무
FFI 에서 특히 조심할 것 ★
// ① 널·길이 0 을 먼저 처리 (🔗 f02)
unsafe fn to_slice<'a>(p: *const u8, len: usize) -> Option<&'a [u8]> {
if len == 0 { return Some(&[]); } // ★ from_raw_parts(null, 0) 은 UB
if p.is_null() { return None; }
Some(std::slice::from_raw_parts(p, len))
}
// ② 정렬 확인 (d03)
assert_eq!(p as usize % std::mem::align_of::<T>(), 0);
// ③ 수명 — 반환 참조의 수명을 함부로 늘리지 마세요
unsafe fn bad<'a>(p: *const u8) -> &'a str { } // ★ 'a 를 호출자가 정합니다
// → 문서에 유효 범위를 명시하고, 가능하면 소유 값을 반환하세요 (🔗 f06)
// ④ 값 검증
let s = std::str::from_utf8(bytes)?; // ✅ 검증
// let s = unsafe { std::str::from_utf8_unchecked(bytes) }; // ❌ 검증 없이
// ⑤ 패닉 차단 (🔗 f09)
catch_unwind(AssertUnwindSafe(|| { }))
// ⑥ 다른 언어가 메모리를 동시에 수정하지 않는다는 전제를
// 문서에 명시 (🐍 y06 의 numpy 제로카피)
검출 도구 ★
# Miri — Rust 코드의 UB 를 검출합니다 (d07)
cargo +nightly miri test
# ★ FFI 로 넘어간 부분은 못 봅니다
# 주소 살균기 — C 코드까지 봅니다 (🔗 f08)
RUSTFLAGS="-Zsanitizer=address" \
cargo +nightly test --target x86_64-unknown-linux-gnu
# 스레드 살균기 — 데이터 경쟁
RUSTFLAGS="-Zsanitizer=thread" cargo +nightly test --target ...
# 메모리 살균기 — 초기화되지 않은 읽기
RUSTFLAGS="-Zsanitizer=memory" cargo +nightly test --target ...
# valgrind
valgrind --leak-check=full ./target/debug/app
# 디버그 빌드의 런타임 검사도 유용합니다
# debug_assertions 가 켜져 있으면 (🛠️ b06)
# · 정수 오버플로 패닉
# · 일부 unsafe 함수의 debug_assert
# ★ CI 에 Miri 를 넣으세요 (unsafe 가 있다면)
한 단계 더 — Rust 메모리 모델의 현재 🧬 전문가
아직 확정되지 않은 것들
Rust 는 C++ 와 달리 아직 '공식 메모리 모델' 이 없습니다.
UCG(Unsafe Code Guidelines) 작업이 진행 중입니다.
★ 확정되지 않은 영역
· 정확한 aliasing 규칙 (Stacked Borrows vs Tree Borrows) ★
→ Miri 가 두 모델을 모두 지원합니다 (d07)
· 포인터 프로버넌스의 세부 (d03)
· union 의 일부 동작
· 정렬되지 않은 참조의 경계
★ 실무 지침
· 보수적으로 쓰세요 — 애매하면 안전한 대안을
· Miri 가 경고하면 (아직 UB 가 아니더라도) 고치세요
· 표준 라이브러리의 관용구를 따르세요
· 최신 문서를 확인하세요
https://doc.rust-lang.org/nomicon/
https://doc.rust-lang.org/reference/behavior-considered-undefined.html
★ 그럼에도 안정적입니다
확정되지 않은 것은 '아주 미묘한 경계' 이고,
일반적인 FFI 코드는 명확히 정의된 영역 안에 있습니다.
읽어 볼 것
★ 공식 문서
· The Rustonomicon — unsafe Rust 의 교과서 ★
doc.rust-lang.org/nomicon/
· Reference: Behavior considered undefined
UB 목록의 공식 출처
· std 문서의 # Safety 섹션
각 unsafe 함수의 정확한 조건
★ 실무 자료
· Unsafe Code Guidelines Reference (UCG)
· Miri 문서 — 무엇을 검출하고 무엇을 못 하는지
· RustSec 권고 목록 — 실제로 일어난 건전성 버그들 ★
실수 패턴을 배우기에 가장 좋습니다
★ 학습 순서 권장
① 이 탭의 d01 · d02 · d08 (원칙)
② Rustonomicon 의 처음 세 장
③ 실제 크레이트의 unsafe 코드 읽기
(Vec 의 구현 · crossbeam · bytemuck)
④ Miri 로 자기 코드 검증 (d07)
원시 포인터 — NonNull · 정렬 · 프로버넌스
참조(&T)와 달리 원시 포인터(*const T)에는 보장이 없습니다 —
널일 수도, 정렬이 안 됐을 수도, 이미 해제됐을 수도 있습니다.
FFI 에서 매일 만나는 타입이므로 규칙을 정확히 알아야 합니다.
종류와 기본
let x = 42i32;
let p: *const i32 = &x; // 불변 원시 포인터
let mut y = 42i32;
let q: *mut i32 = &mut y; // 가변
// 만드는 것은 안전, 역참조가 unsafe ★
unsafe { println!("{}", *p); }
// 널
let n: *const i32 = std::ptr::null();
p.is_null();
// NonNull — 널이 아님을 타입으로 (r08 의 최적화도 받습니다)
use std::ptr::NonNull;
let nn: NonNull<i32> = NonNull::new(q).expect("널");
unsafe { *nn.as_ptr() }
// size_of::<Option<NonNull<T>>>() == size_of::<*mut T>() ★
// 산술
unsafe {
let p2 = p.add(1); // T 크기 단위로 이동
let p3 = p.offset(-1);
let d = p2.offset_from(p); // 거리
}
// ★ 할당 범위를 벗어나면 '계산만 해도' UB 입니다 (d02)
// 배열의 '마지막 다음 자리' 까지는 합법 (end 포인터)
// 안전한 변환
let s: &[i32] = unsafe { std::slice::from_raw_parts(p, len) };
let p = v.as_ptr();
let p = v.as_mut_ptr();
정렬이 중요한 이유 ★
use std::mem::{align_of, size_of};
// u64 는 8바이트 정렬이 필요합니다
let buf: [u8; 16] = [0; 16];
let p = buf.as_ptr().wrapping_add(1) as *const u64; // ★ 정렬 안 됨
// unsafe { *p } // ❌ UB (일부 CPU 에서는 크래시)
// ✅ 정렬 안 된 읽기 전용 함수
let v = unsafe { p.read_unaligned() };
unsafe { q.write_unaligned(v) };
// ✅ memcpy 로 복사 (컴파일러가 최적화해 줍니다)
let mut v = 0u64;
unsafe {
std::ptr::copy_nonoverlapping(
buf.as_ptr().add(1), &mut v as *mut u64 as *mut u8, 8);
}
// ✅ 가장 좋은 방법 — bytemuck / zerocopy (🔗 f03)
// let v: u64 = u64::from_le_bytes(buf[1..9].try_into().unwrap());
// 정렬 확인
assert_eq!(p as usize % align_of::<u64>(), 0);
p.is_aligned(); // 1.79+
// ★ 네트워크 버퍼·파일 데이터를 구조체로 캐스팅하는 코드가
// 이 함정에 가장 자주 빠집니다. from_le_bytes 를 쓰세요.
이렇게도 쓸 수 있다 — 참조와 포인터 사이 🟡 중급
변환할 때의 규칙
// 참조 → 포인터: 항상 안전
let p = &x as *const i32;
let p = std::ptr::addr_of!(s.field); // ★ 참조를 만들지 않고 주소만
let p = std::ptr::addr_of_mut!(s.field);
// → packed 구조체나 초기화되지 않은 필드에 필요합니다 (🔗 f03 · d04)
// 포인터 → 참조: unsafe (조건이 많습니다)
unsafe {
let r: &i32 = &*p; // 다음을 전부 만족해야 합니다:
// · 널이 아님
// · 정렬됨
// · 유효한 값이 들어 있음 (초기화됨)
// · 'a 동안 해제되지 않음
// · &mut 라면 다른 접근이 없음 (aliasing)
let r = p.as_ref(); // Option<&T> — 널 검사 포함 ★
let r = q.as_mut(); // Option<&mut T>
}
// 읽기·쓰기 (참조를 만들지 않고)
unsafe {
let v = p.read(); // 복사해 옴 (Copy 아니어도 가능)
q.write(value); // 기존 값의 Drop 없이 덮어씀 ★
let old = q.replace(value); // 기존 값을 돌려받음
std::ptr::drop_in_place(q); // Drop 만 실행
}
// ★ write 는 Drop 을 부르지 않습니다 — 초기화되지 않은 자리에 쓸 때 씁니다 (d04)
aliasing 규칙 ★
// ❌ &mut 가 살아 있는 동안 다른 경로로 접근
let mut x = 5;
let r = &mut x;
let p = &x as *const i32; // ★ r 이 살아 있는데 다른 참조 생성
unsafe { println!("{}", *p); } // → UB (Miri 가 잡습니다 · d07)
// ✅ 포인터를 먼저 만들고 그것만 쓰기
let mut x = 5;
let p = &mut x as *mut i32;
unsafe { *p = 6; println!("{}", *p); }
// ★ Rust 의 &mut 는 "이 참조로만 접근한다" 는 강한 보장입니다.
// 그래서 LLVM 에 noalias 를 붙일 수 있고, 최적화가 공격적입니다 (⚡ e09).
// 이 보장을 깨면 컴파일러의 가정이 틀려 코드가 이상해집니다.
// 두 개의 &mut 가 필요한 경우 — 겹치지 않음을 증명하세요
let (a, b) = v.split_at_mut(mid); // ✅ 안전 API (🔐 o02)
// 정말 필요하면 원시 포인터로 유지하고 참조를 만들지 마세요
unsafe {
let p1 = v.as_mut_ptr();
let p2 = p1.add(mid);
// p1 과 p2 로만 접근 — 참조를 만들지 않음
}
실무에선 이렇게 — FFI 포인터 다루기 🔴 실무
방어적인 헬퍼 만들기 ★
// FFI 진입점마다 반복하지 말고 헬퍼로 (🔗 f02)
/// # Safety
/// `p` 가 널이 아니면 `len` 원소만큼 유효해야 합니다.
unsafe fn as_slice<'a, T>(p: *const T, len: usize) -> Option<&'a [T]> {
if len == 0 { return Some(&[]); }
if p.is_null() { return None; }
debug_assert_eq!(p as usize % std::mem::align_of::<T>(), 0, "정렬 위반");
Some(std::slice::from_raw_parts(p, len))
}
/// C 문자열 → &str
unsafe fn as_str<'a>(p: *const c_char) -> Option<&'a str> {
if p.is_null() { return None; }
std::ffi::CStr::from_ptr(p).to_str().ok()
}
/// 핸들 → &mut
unsafe fn as_handle<'a, T>(p: *mut T) -> Option<&'a mut T> {
p.as_mut()
}
// 진입점은 이렇게 짧아집니다
#[no_mangle]
pub unsafe extern "C" fn mylib_process(p: *const u8, len: usize) -> i32 {
let Some(data) = as_slice(p, len) else { return -1 };
core_process(data) // 여기부터는 안전한 Rust ★
}
테스트와 검증
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_is_handled() {
assert_eq!(unsafe { mylib_process(std::ptr::null(), 10) }, -1);
}
#[test]
fn zero_len_is_ok() {
assert_eq!(unsafe { mylib_process(std::ptr::null(), 0) }, 0);
}
#[test]
fn roundtrip() {
let data = b"hello";
assert_eq!(unsafe { mylib_process(data.as_ptr(), data.len()) }, 0);
}
}
// Miri 로 검증 (d07)
// cargo +nightly miri test
// → aliasing · 정렬 · 범위를 전부 확인합니다 ★
// 퍼징도 유용합니다
// cargo install cargo-fuzz
// cargo fuzz run parse
// → 임의 바이트열로 FFI 진입점을 두드립니다
cargo-fuzz + Miri 조합이면 대부분의 UB 를 배포 전에 잡습니다.
한 단계 더 — 프로버넌스 🧬 전문가
포인터는 주소만이 아닙니다
// ★ 포인터에는 '출처(provenance)' 가 함께 붙어 있습니다
// 어느 할당에서 왔는지, 어느 범위에 접근할 수 있는지
let a = [1u8, 2, 3];
let b = [4u8, 5, 6];
let pa = a.as_ptr();
let pb = b.as_ptr();
// 주소가 우연히 인접해도, pa 로 b 에 접근하면 UB 입니다 ★
// unsafe { *pa.add(3) } // ❌ a 의 범위를 벗어남
// 정수로 갔다 오면 프로버넌스가 흐려집니다
let addr = pa as usize;
let p2 = addr as *const u8; // ★ 어느 할당의 것인지 모호해집니다
// → 되도록 피하고, 꼭 필요하면 with_addr 를 쓰세요 (1.84+ 안정화)
let p3 = pa.with_addr(addr); // ✅ pa 의 프로버넌스를 유지하며 주소만 바꿈
let addr = pa.addr(); // ✅ 주소만 꺼내기 (프로버넌스 없이)
let p4 = std::ptr::without_provenance(addr); // 명시적으로 프로버넌스 없음
// ★ 왜 중요한가
// 컴파일러는 "이 포인터가 저 할당을 건드릴 리 없다" 고 가정합니다.
// 정수 왕복으로 그 가정을 깨면 최적화가 코드를 망가뜨립니다.
// Miri 의 -Zmiri-strict-provenance 로 검사할 수 있습니다 (d07)
태그드 포인터와 실무
// 포인터 하위 비트에 플래그를 넣는 기법 (정렬 덕분에 비어 있습니다)
// u64 는 8바이트 정렬 → 하위 3비트가 항상 0
let p: *mut u64 = ...;
let tagged = p.map_addr(|a| a | 1); // ★ 프로버넌스 유지
let flag = tagged.addr() & 1;
let clean = tagged.map_addr(|a| a & !1);
// ★ 실무에서 쓰이는 곳
// · GC · 인터프리터의 값 표현
// · 락프리 자료구조의 버전 태그 (d06)
// · 인덱스와 플래그를 한 워드에
// ❗ 주의
// · 이식성 (정렬 가정이 플랫폼마다 다를 수 있습니다)
// · 디버깅이 어려워집니다
// · Miri 로 반드시 검증하세요
// 대안: 별도 필드나 enum 을 쓰는 것이 대개 낫습니다.
// 메모리를 아끼는 이득이 정말 큰지 측정하세요 (⚡ e01)
MaybeUninit — 초기화되지 않은 메모리
큰 버퍼를 0 으로 채우는 비용이 아까울 때가 있습니다.
하지만 초기화되지 않은 메모리를 값으로 읽는 것은 UB입니다(d02).
MaybeUninit 이 그것을 합법적으로 다루는 유일한 방법입니다.
왜 필요한가
use std::mem::MaybeUninit;
// ❌ 예전 방식 — deprecated, UB 입니다
// let x: u32 = unsafe { std::mem::uninitialized() };
// ★ 왜 UB 인가
// · bool 에 2 가 들어 있으면 match 가 이상해집니다 (d02)
// · 컴파일러가 "초기화됐다" 고 가정해 최적화합니다
// · Drop 이 쓰레기 값에 대해 실행될 수 있습니다
// ✅ MaybeUninit — "아직 값이 없을 수 있다" 를 타입으로
let mut x: MaybeUninit<u32> = MaybeUninit::uninit();
x.write(42);
let v = unsafe { x.assume_init() }; // ★ 쓴 뒤에만 호출
// 0 으로 채우기 (안전)
let z: MaybeUninit<u32> = MaybeUninit::zeroed();
// 배열
let mut arr: [MaybeUninit<u8>; 1024] = unsafe { MaybeUninit::uninit().assume_init() };
// ★ MaybeUninit 배열 자체는 초기화가 필요 없으므로 이 assume_init 은 합법
for (i, slot) in arr.iter_mut().enumerate() { slot.write(i as u8); }
let arr: [u8; 1024] = unsafe { std::mem::transmute(arr) };
실무에서는 대개 필요 없습니다 ★
// ★ 먼저 안전한 대안을 확인하세요
// ① vec![0; n] — 0 으로 채우기
let mut buf = vec![0u8; 1024 * 1024];
// OS 가 0 페이지를 주므로 큰 할당에서는 사실상 공짜입니다 ★
// (calloc 이 페이지를 lazy 하게 매핑합니다)
// ② Vec::with_capacity + extend (r06 · ⚡ e02)
let mut v = Vec::with_capacity(n);
v.extend(iter);
// ③ 읽어서 채우기 — 표준 API 를 쓰세요
let mut buf = vec![0u8; 8192];
let n = reader.read(&mut buf)?;
buf.truncate(n);
// ④ spare_capacity_mut — Vec 의 미사용 영역을 MaybeUninit 로
let mut v: Vec<u8> = Vec::with_capacity(1024);
let spare = v.spare_capacity_mut(); // &mut [MaybeUninit<u8>]
// … 채운 뒤 …
unsafe { v.set_len(written); } // ★ 실제로 쓴 만큼만
// ★ 측정 없이 MaybeUninit 를 쓰지 마세요 (⚡ e01)
// memset 비용은 대개 무시할 수준이고,
// 틀리면 재현 불가능한 버그가 됩니다.
vec![0; n] 은 생각보다 쌉니다 —
OS 가 0 으로 채워진 페이지를 지연 할당하기 때문입니다.
실제로 memset 이 병목인지 프로파일러로 확인한 뒤에 MaybeUninit 를 고려하세요.
이렇게도 쓸 수 있다 — 부분 초기화 다루기 🟡 중급
구조체를 필드별로 채우기
use std::mem::MaybeUninit;
use std::ptr::addr_of_mut;
#[repr(C)]
struct Header { magic: u32, len: u32, name: [u8; 32] }
fn build() -> Header {
let mut h: MaybeUninit<Header> = MaybeUninit::uninit();
let p = h.as_mut_ptr();
unsafe {
// ★ addr_of_mut! 로 참조를 만들지 않고 주소만 (d03)
addr_of_mut!((*p).magic).write(0x4D59_4C42);
addr_of_mut!((*p).len).write(0);
addr_of_mut!((*p).name).write([0; 32]);
h.assume_init() // 전부 채운 뒤에만 ★
}
}
// ❗ 하나라도 빠뜨리면 UB 입니다.
// 그래서 실무에서는 그냥 이렇게 쓰는 것이 낫습니다
fn build_safe() -> Header {
Header { magic: 0x4D59_4C42, len: 0, name: [0; 32] }
}
// → 컴파일러가 최적화하면 결과가 거의 같습니다.
// 측정으로 차이가 확인될 때만 위 방식을 쓰세요 ★
FFI 에서 out 파라미터 받기 ★
// C 함수가 구조체를 채워 주는 흔한 패턴 (🔗 f08)
// int foo_get_info(FooInfo *out);
#[repr(C)]
pub struct FooInfo { version: u32, flags: u32, name: *const c_char }
pub fn get_info() -> Result<FooInfo, Error> {
let mut info = MaybeUninit::<FooInfo>::uninit();
let rc = unsafe { ffi::foo_get_info(info.as_mut_ptr()) };
if rc != 0 { return Err(Error::from(rc)); }
// SAFETY: foo_get_info 가 0 을 반환하면 out 을 완전히 채웠다고
// 문서에 명시돼 있습니다 (libfoo docs §4.1)
Ok(unsafe { info.assume_init() })
}
// ★ 이것이 MaybeUninit 의 가장 정당한 용도입니다.
// C 가 채워 줄 자리를 미리 0 으로 채울 필요가 없습니다.
// 배열도 마찬가지
let mut buf: Vec<MaybeUninit<u8>> = Vec::with_capacity(n);
unsafe {
let written = ffi::foo_read(buf.as_mut_ptr() as *mut u8, n);
buf.set_len(written);
}
// → 안전한 Vec<u8> 로 변환하려면 transmute 또는 재구성이 필요합니다
실무에선 이렇게 — 안전한 래퍼로 감싸기 🔴 실무
패턴 정리 ★
// ★ 원칙: MaybeUninit 를 API 밖으로 내보내지 마세요
// 내부에서만 쓰고, 밖에는 완성된 타입을 돌려줍니다 (d08)
pub struct Decoder { /* … */ }
impl Decoder {
/// 디코딩 결과를 반환합니다.
pub fn decode(&mut self, input: &[u8]) -> Result<Vec<u8>, Error> {
let need = self.output_size(input);
let mut out = Vec::with_capacity(need);
// SAFETY: ffi::decode 는 반환값만큼의 바이트를 채웁니다.
// capacity 는 need 이상이 보장됩니다.
let written = unsafe {
let n = ffi::decode(self.raw, input.as_ptr(), input.len(),
out.as_mut_ptr(), need);
if n < 0 { return Err(Error::from(n)); }
n as usize
};
unsafe { out.set_len(written) }; // ★ 실제로 쓴 만큼
Ok(out)
}
}
// ★ set_len 의 조건
// · len <= capacity
// · 0..len 범위가 전부 초기화됐음
// → 이 둘을 보장하지 못하면 UB 입니다
검증하기
# Miri 는 초기화되지 않은 읽기를 잡습니다 ★ (d07)
cargo +nightly miri test
# 출력 예
# error: Undefined Behavior: using uninitialized data,
# but this operation requires initialized memory
# MemorySanitizer 도 (C 코드까지)
RUSTFLAGS="-Zsanitizer=memory" cargo +nightly test \
--target x86_64-unknown-linux-gnu
# ★ 테스트에서 확인할 것
# · 반환된 Vec 의 모든 바이트를 실제로 읽어 보기
# · 길이가 0 인 경우
# · C 함수가 실패했을 때 set_len 을 호출하지 않는지
#[test]
fn decoded_bytes_are_initialized() {
let out = decoder.decode(input).unwrap();
// 전부 읽어 봅니다 — Miri 가 초기화 여부를 검사합니다 ★
let checksum: u64 = out.iter().map(|b| *b as u64).sum();
assert!(checksum > 0 || out.is_empty());
}
set_len 이 FFI 코드에서 가장 위험한 함수입니다 —
C 함수가 실제로 몇 바이트를 채웠는지 잘못 믿으면
초기화되지 않은 메모리가 그대로 노출됩니다(정보 유출 취약점이 되기도 합니다).
한 단계 더 — 왜 이렇게까지 엄격한가 🧬 전문가
초기화되지 않은 값의 정체
★ "쓰레기 값이 들어 있다" 가 아닙니다
LLVM 에서 초기화되지 않은 메모리는 'poison/undef' 값입니다.
이것은 '아직 정해지지 않은 값' 이고,
읽을 때마다 다른 값일 수 있습니다.
let x: u8 = uninit();
if x == x { } // ★ 거짓일 수 있습니다
if x < 128 && x >= 128 { } // 둘 다 참일 수 있습니다
★ 그래서 컴파일러가 이상한 최적화를 합니다
· 분기를 양쪽 다 지우거나
· 값을 임의로 고정하거나
· 그 코드 경로를 통째로 도달 불가로 판단
★ MaybeUninit<T> 는
"이 메모리는 T 의 유효한 값이 아닐 수 있다" 를 타입으로 표현합니다.
컴파일러가 그 가정을 하지 않으므로 안전하게 다룰 수 있습니다.
size_of::<MaybeUninit<T>>() == size_of::<T>() ★ 비용 0
align_of 도 같습니다
관련 API 정리
use std::mem::MaybeUninit;
MaybeUninit::uninit() // 초기화 안 함
MaybeUninit::zeroed() // 0 으로 (u32 등에는 유효, &T 에는 UB)
MaybeUninit::new(v) // 값으로 시작
m.write(v) // 쓰기 (기존 값 Drop 안 함) ★
m.as_ptr() / as_mut_ptr()
unsafe { m.assume_init() } // 값으로 꺼내기
unsafe { m.assume_init_ref() } // &T 로
unsafe { m.assume_init_drop() } // Drop 실행
// 슬라이스 API (일부는 nightly)
MaybeUninit::write_slice(&mut buf, src);
unsafe { MaybeUninit::slice_assume_init_ref(&buf) };
// ★ 관련된 다른 함정
// · zeroed 는 타입에 따라 UB 입니다
// NonNull · &T · bool(은 괜찮음) · enum(0 이 유효해야) ★
// · Drop 이 있는 타입을 write 로 덮어쓰면 이전 값이 새어 나갑니다
// · assume_init 을 두 번 하면 이중 Drop
// 안전한 대안 크레이트
// zerocopy — FromZeros 트레이트로 "0 이 유효한 타입" 을 검증 ★
MaybeUninit 만이 합법적인 방법입니다.
다만 실무에서 필요한 경우는 드뭅니다 — vec![0; n] 은 생각보다 싸고,
정당한 용도는 FFI 의 out 파라미터 정도입니다.
set_len 은 가장 위험한 함수이니 조건을 반드시 확인하세요.Pin — 움직이면 깨지는 것들
Rust 에서 값은 언제든 메모리 안에서 옮겨질 수 있습니다(이동은 memcpy · r05).
그런데 자기 자신을 가리키는 구조체는 옮겨지면 포인터가 무효가 됩니다.
Pin 은 그것을 타입으로 막는 장치입니다 — async 의 기반이기도 합니다.
문제 상황
struct SelfRef {
data: String,
ptr: *const String, // data 를 가리킴
}
let mut s = SelfRef { data: "안녕".into(), ptr: std::ptr::null() };
s.ptr = &s.data;
let moved = s; // ★ 구조체가 이동하면
// moved.ptr 은 '이전 위치의 data' 를 가리킵니다 → 댕글링
// ★ Rust 는 이동이 자유롭기 때문에 이 문제가 생깁니다.
// C++ 는 이동 생성자에서 포인터를 고칠 수 있지만,
// Rust 의 이동은 memcpy 이고 후크가 없습니다.
// 그래서 Pin 이 필요합니다
use std::pin::Pin;
let mut boxed = Box::pin(SelfRef { data: "안녕".into(), ptr: std::ptr::null() });
// Pin<Box<T>> 는 T 를 밖으로 꺼낼 수 없게 합니다 (T: !Unpin 일 때)
Pin 을 직접 쓸 일은 드뭅니다 —
async 를 쓰면 뒤에서 자동으로 쓰이고,
자기 참조가 필요하면 인덱스 기반(🔐 o01)이나
ouroboros·self_cell 크레이트가 더 실용적입니다.
Unpin — 대부분의 타입은 상관없습니다 ★
// Unpin 은 "옮겨져도 괜찮다" 는 자동 트레이트 (⚡ e06 의 auto trait)
// 거의 모든 타입이 Unpin 입니다: u32 · String · Vec<T> · &T …
fn f<T: Unpin>(p: Pin<&mut T>) {
let r: &mut T = p.get_mut(); // ★ Unpin 이면 그냥 꺼낼 수 있습니다
}
// !Unpin 인 것들
// · async 블록이 만든 Future ★
// · PhantomPinned 를 담은 구조체
// · 자기 참조 구조체
use std::marker::PhantomPinned;
struct Pinned { data: String, ptr: *const String, _pin: PhantomPinned }
// ★ 그래서 실무 감각은
// "Pin 이 에러에 나타나면 async 관련일 가능성이 높다"
// → Box::pin 으로 감싸거나, tokio::pin! 매크로를 쓰면 대개 해결됩니다
use std::pin::pin;
let fut = pin!(async { work().await }); // 스택에 고정 (1.68+)
tokio::pin!(fut); // tokio 매크로
이렇게도 쓸 수 있다 — async 에서 만나는 Pin 🟡 중급
왜 Future 가 !Unpin 인가
async fn f() {
let s = String::from("안녕");
let r = &s; // ★ 지역 변수를 참조
other().await; // 여기서 상태가 저장됩니다
println!("{r}"); // await 이후에도 씁니다
}
// 컴파일러가 만드는 상태 기계는 (⚡ e08)
// struct FFuture { s: String, r: *const String, state: u8 }
// → r 이 s 를 가리킵니다 = 자기 참조 ★
// → 이 Future 가 이동하면 r 이 무효가 됩니다
// → 그래서 !Unpin 이고, poll 은 Pin<&mut Self> 를 받습니다
// 실무에서 만나는 에러
// error: `dyn Future` cannot be unpinned
// → Box::pin(fut) 으로 해결
// 자주 쓰는 패턴
let fut: Pin<Box<dyn Future<Output = u32> + Send>> = Box::pin(async { 42 });
// 재귀 async 함수 — 크기를 정할 수 없으므로 Box 가 필요합니다
fn recurse(n: u32) -> Pin<Box<dyn Future<Output = u32> + Send>> {
Box::pin(async move {
if n == 0 { 0 } else { recurse(n - 1).await + 1 }
})
}
// 또는 async-recursion 크레이트
Pin 을 쓰는 법
use std::pin::Pin;
// 만들기
let p = Box::pin(value); // 힙에 고정
let p = std::pin::pin!(value); // 스택에 고정 (1.68+) ★
// 접근
p.as_ref(); // Pin<&T>
p.as_mut(); // Pin<&mut T>
Pin::get_ref(p); // &T (항상 안전)
unsafe { Pin::get_unchecked_mut(p) }; // &mut T (T: !Unpin 이면 unsafe)
// 필드 투영(projection) — 손으로 하면 까다롭습니다
// → pin-project-lite 크레이트를 쓰세요 ★
use pin_project_lite::pin_project;
pin_project! {
struct Wrapper<F> {
#[pin] inner: F, // 고정이 필요한 필드
count: u32, // 일반 필드
}
}
impl<F: Future> Future for Wrapper<F> {
type Output = F::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<F::Output> {
let this = self.project();
*this.count += 1;
this.inner.poll(cx) // ★ Pin<&mut F> 로 전달됩니다
}
}
실무에선 이렇게 — 자기 참조를 피하는 법 🔴 실무
대안이 대개 낫습니다 ★
// ❌ 자기 참조 구조체를 만들려는 시도
// struct Parser { input: String, current: &str } // 불가능 (🔐 o01)
// ✅ ① 인덱스·범위로 (가장 단순합니다)
struct Parser { input: String, pos: usize }
impl Parser {
fn rest(&self) -> &str { &self.input[self.pos..] }
}
// ✅ ② 소유와 참조를 분리 — 라이프타임 파라미터 (🔐 o03)
struct Parser<'a> { input: &'a str, pos: usize }
let text = load()?; // 소유는 밖에서
let p = Parser { input: &text, pos: 0 };
// ✅ ③ 크레이트를 쓰기
// ouroboros · self_cell · yoke
// → 자기 참조를 안전하게 감싸 줍니다
use self_cell::self_cell;
self_cell!(
struct Owner {
owner: String,
#[covariant] dependent: Parsed,
}
);
// ✅ ④ Arc 로 공유 (🔐 o10)
struct Parser { input: Arc<str>, pos: usize }
// ★ 실무 우선순위: ① → ② → ④ → ③ → Pin 직접
// Pin 을 직접 쓰는 것은 라이브러리 저자의 영역입니다.
FFI 에서의 고정
// C 라이브러리가 우리 구조체의 주소를 보관하는 경우 (🔗 f07)
// → 그 구조체가 이동하면 C 쪽 포인터가 무효가 됩니다
// ✅ Box 로 힙에 고정하고 주소를 넘기기
pub struct Callback { handler: Box<dyn Fn(i32)> }
pub struct Registration {
inner: Box<Callback>, // ★ Box 안의 주소는 이동해도 안 바뀝니다
}
impl Registration {
pub fn new(f: impl Fn(i32) + 'static) -> Self {
let inner = Box::new(Callback { handler: Box::new(f) });
let ptr = &*inner as *const Callback as *mut c_void;
unsafe { ffi::register(trampoline, ptr) };
Registration { inner }
}
}
impl Drop for Registration {
fn drop(&mut self) { unsafe { ffi::unregister() } } // ★ 반드시
}
// ★ Box<T> 를 이동해도 T 의 주소는 그대로입니다.
// 그래서 대부분의 FFI 시나리오에서는 Pin 없이 Box 로 충분합니다.
// (Pin 이 필요한 것은 '스택에 있는 자기 참조' 같은 경우입니다)
Box<T> 안의 T 는 이동하지 않습니다 —
Box 자체(포인터)만 이동합니다.
그래서 FFI 로 주소를 넘길 때는 Box 로 충분하고,
Pin 까지 필요한 경우는 드뭅니다.
한 단계 더 — Pin 의 계약 🧬 전문가
정확히 무엇을 보장하나
Pin<P> 의 계약 (P 는 포인터 타입)
T: Unpin 이면 → 아무 보장도 없습니다 (그냥 P 처럼 동작)
T: !Unpin 이면 → "이 값은 drop 될 때까지 이 주소에 머문다" ★
더 정확히
· Pin<&mut T> 를 만든 이후로 T 는 이동하지 않습니다
· T 의 메모리는 drop 이 호출될 때까지 무효화되지 않습니다
· drop 이 반드시 호출됩니다 (mem::forget 으로 건너뛰면 안 됨) ★
★ 마지막 조건이 미묘합니다
Pin 된 값을 mem::forget 하면 메모리가 재사용될 수 있는데,
C 쪽이 그 주소를 여전히 보고 있으면 UB 입니다.
→ 이것을 'Drop 보장(drop guarantee)' 이라고 합니다.
★ 직접 구현할 때 지켜야 할 것
· Pin<&mut Self> 를 받는 메서드에서 Self 를 이동하지 않기
· get_unchecked_mut 로 꺼낸 참조로 mem::swap · mem::replace 금지
· 필드 투영은 pin-project 를 쓰세요 (손으로 하면 실수하기 쉽습니다)
언제 !Unpin 을 만드나
use std::marker::PhantomPinned;
// 자기 참조나 외부에 주소를 등록한 타입
pub struct Registered {
data: [u8; 1024],
// C 쪽이 &self.data 를 보관 중
_pin: PhantomPinned, // ★ !Unpin 으로 만듭니다
}
impl Registered {
pub fn new() -> Pin<Box<Self>> {
let mut b = Box::pin(Registered { data: [0; 1024], _pin: PhantomPinned });
let ptr = b.data.as_ptr();
unsafe { ffi::register_buffer(ptr, 1024) };
b
}
}
impl Drop for Registered {
fn drop(&mut self) { unsafe { ffi::unregister_buffer() } }
}
// ★ 실무 판단
// 이 정도 상황이면 대개 '핸들 + 명시적 close()' 로 설계하는 편이
// 훨씬 단순하고 다른 언어에서 쓰기도 좋습니다 (🔗 f06 의 불투명 핸들).
// Pin 은 async 런타임·스트림 라이브러리를 만들 때 주로 필요합니다.
Pin 이 그것을 타입으로 막지만, 대부분의 타입은 Unpin 이라 상관없습니다.
실무에서는 async 에서만 만나고, Box::pin·pin!·pin-project-lite 로 해결됩니다.
자기 참조가 필요하면 인덱스 · 라이프타임 분리 · self_cell 을 먼저 검토하세요.메모리 모델 · Ordering
멀티스레드에서 “다른 CPU 가 이 값을 언제 보게 되는가”를 정하는 규칙입니다. CPU 와 컴파일러가 명령 순서를 바꾸기 때문에, 필요한 순서를 명시해야 합니다. 락 프리 자료구조의 기반입니다.
다섯 가지 Ordering ★
| Ordering | 뜻 |
|---|---|
Relaxed | 원자성만 · 순서 보장 없음 |
Acquire | 읽기 — 이후 연산이 앞으로 못 감 |
Release | 쓰기 — 이전 연산이 뒤로 못 감 |
AcqRel | 읽고 쓰기 (fetch_* 에) |
SeqCst | 전역 순서 보장 · 가장 강함 |
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
// ★ 실무 지침
// · 단순 카운터 · 통계 → Relaxed (가장 쌉니다)
// · 플래그로 데이터 전달 → Release/Acquire 짝 ★
// · 헷갈리면 → SeqCst (느리지만 안전)
// 카운터
static COUNT: AtomicU64 = AtomicU64::new(0);
COUNT.fetch_add(1, Ordering::Relaxed);
COUNT.load(Ordering::Relaxed);
// 데이터 전달 — 이 짝이 핵심입니다 ★
static READY: AtomicBool = AtomicBool::new(false);
static mut DATA: u64 = 0;
// 생산자
unsafe { DATA = 42; }
READY.store(true, Ordering::Release); // ★ 이전 쓰기가 먼저 보임을 보장
// 소비자
if READY.load(Ordering::Acquire) { // ★ 이후 읽기가 뒤에 일어남을 보장
unsafe { println!("{}", DATA); } // 42 가 보장됩니다
}
왜 필요한가
★ 순서가 바뀌는 두 가지 이유
① 컴파일러 최적화
독립적인 명령을 재배치합니다.
단일 스레드에서는 결과가 같으므로 합법입니다.
② CPU
· 저장 버퍼 (쓰기가 지연되어 다른 코어에 늦게 보임)
· 비순차 실행 (out-of-order)
· x86 은 비교적 강한 순서를 보장하지만
ARM · RISC-V 는 훨씬 약합니다 ★
★ 그래서 x86 에서만 테스트하면 버그가 안 보입니다.
애플 실리콘·ARM 서버에서 처음 터지는 경우가 실제로 있습니다.
★ Relaxed 로 충분한 경우
· 값 자체만 필요할 때 (카운터 · 통계 · ID 생성)
· 다른 데이터와의 순서가 상관없을 때
★ Release/Acquire 가 필요한 경우
· "데이터를 다 썼다" 를 플래그로 알릴 때
· 락 · 큐 · 참조 카운터의 해제 시점 ★
Relaxed 와 Acquire 가 같은 코드로 컴파일되는 경우가 많습니다 —
버그가 있어도 드러나지 않습니다.
ARM 기기에서도 테스트하고, Miri 와 loom 으로 검증하세요.
이렇게도 쓸 수 있다 — 실전 패턴 🟡 중급
CAS 와 fetch 연산
use std::sync::atomic::{AtomicU64, Ordering};
let a = AtomicU64::new(0);
// 산술
a.fetch_add(1, Ordering::Relaxed);
a.fetch_sub(1, Ordering::Relaxed);
a.fetch_max(5, Ordering::Relaxed);
a.fetch_or(0b1, Ordering::Relaxed);
// 교환
let old = a.swap(10, Ordering::AcqRel);
// CAS — 조건부 교환 ★
match a.compare_exchange(10, 20, Ordering::AcqRel, Ordering::Acquire) {
Ok(prev) => { /* 10 이었고 20 으로 바꿈 */ }
Err(actual) => { /* 10 이 아니었음, 실제 값은 actual */ }
}
// weak 버전 — 루프 안에서 (가짜 실패가 가능하지만 더 빠릅니다)
let mut cur = a.load(Ordering::Relaxed);
loop {
let new = cur * 2;
match a.compare_exchange_weak(cur, new, Ordering::AcqRel, Ordering::Relaxed) {
Ok(_) => break,
Err(v) => cur = v, // 다른 스레드가 바꿨으므로 재시도
}
}
// fetch_update — 위 루프를 간단히
a.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |v| Some(v * 2)).ok();
Arc 가 쓰는 패턴 ★
// Arc 의 clone / drop 이 실제로 하는 일 (🔐 o10)
// clone — 카운터만 올리면 됩니다
self.count.fetch_add(1, Ordering::Relaxed);
// ★ Relaxed 로 충분합니다. 이미 참조를 갖고 있으니
// 다른 데이터와의 순서가 필요 없습니다.
// drop — 여기가 중요합니다
if self.count.fetch_sub(1, Ordering::Release) != 1 { return; }
// ★ Release: 내가 이 데이터에 한 쓰기가
// 마지막 스레드에게 보이도록 보장
std::sync::atomic::fence(Ordering::Acquire);
// ★ Acquire 펜스: 다른 스레드의 쓰기를 전부 보고 나서 해제
unsafe { drop_data() };
// ★ 이 패턴이 Release/Acquire 의 교과서적 사용입니다.
// "마지막 사람이 뒷정리한다" 를 안전하게 구현합니다.
// 펜스 (개별 원자 연산과 별개로 순서를 강제)
use std::sync::atomic::fence;
fence(Ordering::SeqCst);
std::sync::atomic::compiler_fence(Ordering::SeqCst); // 컴파일러만
실무에선 이렇게 — 직접 구현하지 마세요 🔴 실무
이미 만들어진 것을 쓰세요 ★
★ 락 프리 자료구조를 직접 만들면 만나는 문제들
· ABA 문제 — 값이 A→B→A 로 바뀌면 CAS 가 속습니다
· 메모리 회수 — 다른 스레드가 아직 보고 있는 노드를 언제 해제하나
→ epoch 기반 · hazard pointer 가 필요합니다
· 메모리 순서 — 이 섹션의 내용을 전부 정확히
· 검증이 어렵습니다 — 재현 불가능한 버그
★ 검증된 크레이트 (⚡ e07)
crossbeam 큐 · 스택 · epoch 기반 회수 ★
dashmap 동시 해시맵
arc-swap Arc 를 원자적으로 교체
flume 채널
parking_lot 더 빠른 Mutex/RwLock
★ 직접 구현이 정당한 경우
· 표준·크레이트에 없는 특수한 자료구조
· 그것이 제품의 핵심 가치
· loom 으로 검증할 수 있고, 리뷰할 사람이 있음
→ 그렇지 않다면 Mutex 로 시작하고 측정하세요 (⚡ e07)
경합이 없으면 Mutex 는 충분히 빠릅니다.
검증 도구 — loom ★
[dev-dependencies]
loom = "0.7"
// loom 은 가능한 모든 스레드 인터리빙을 시뮬레이션합니다 ★
#[cfg(loom)]
mod tests {
use loom::sync::atomic::{AtomicUsize, Ordering};
use loom::sync::Arc;
use loom::thread;
#[test]
fn concurrent_increment() {
loom::model(|| {
let c = Arc::new(AtomicUsize::new(0));
let c2 = c.clone();
let t = thread::spawn(move || { c2.fetch_add(1, Ordering::Relaxed); });
c.fetch_add(1, Ordering::Relaxed);
t.join().unwrap();
assert_eq!(c.load(Ordering::Relaxed), 2);
});
}
}
RUSTFLAGS="--cfg loom" cargo test --release
# Miri 도 일부 동시성 UB 를 잡습니다 (d07)
cargo +nightly miri test
# ThreadSanitizer
RUSTFLAGS="-Zsanitizer=thread" cargo +nightly test --target ...
한 단계 더 — 하드웨어와의 관계 🧬 전문가
어떤 명령으로 컴파일되나
x86-64 (강한 메모리 모델 · TSO)
load(Relaxed/Acquire/SeqCst) → mov (전부 같습니다) ★
store(Relaxed/Release) → mov
store(SeqCst) → xchg (또는 mov + mfence)
fetch_add → lock xadd
compare_exchange → lock cmpxchg
AArch64 (약한 메모리 모델)
load(Relaxed) → ldr
load(Acquire) → ldar ★ 다릅니다
store(Release) → stlr
fetch_add → ldaddal (또는 ldxr/stxr 루프)
★ 그래서
· x86 에서는 Relaxed 와 Acquire 의 차이가 코드에 안 나타납니다
→ 버그가 숨습니다
· ARM 에서는 실제로 다른 명령이 나옵니다
→ 애플 실리콘·Graviton 에서 처음 터집니다 ★
# 확인
cargo asm --lib my_atomic_fn (⚡ e03)
# godbolt.org 에서 x86 과 aarch64 를 비교해 보세요
SeqCst 를 기본으로 쓸까
★ 찬성
· 가장 이해하기 쉽습니다 (전역 순서가 있는 것처럼)
· 틀릴 여지가 적습니다
· x86 에서는 load 비용이 거의 같습니다
★ 반대
· store 와 RMW 에서 비용이 큽니다 (특히 ARM)
· "왜 SeqCst 인지" 생각하지 않게 됩니다
· 성능이 중요한 핫 패스에서는 손해
★ 실무 권장
① 먼저 SeqCst 로 정확하게 만드세요
② 프로파일러가 그 지점을 가리키면 (⚡ e01)
③ 무엇이 필요한지 분석해 Relaxed/Acquire/Release 로 낮추세요
④ loom 으로 검증하세요
→ "성능을 위해 처음부터 Relaxed" 는 대부분 잘못된 최적화입니다.
카운터처럼 명백한 경우만 예외입니다.
★ 참고 자료
· Rust Atomics and Locks (Mara Bos) — 이 주제의 결정판 ★
· std::sync::atomic 문서
· C++ 메모리 모델 자료 (Rust 가 그것을 따릅니다)
Relaxed, 데이터 전달은 Release/Acquire 짝,
헷갈리면 SeqCst 입니다.
x86 에서는 버그가 안 보이므로 ARM 에서도 테스트하고 loom 으로 검증하세요.
락 프리 자료구조는 직접 만들지 말고 crossbeam 을 쓰는 것이 실무적입니다.Miri — unsafe 를 검증하는 도구
unsafe 코드의 버그는 대부분 테스트를 통과합니다 — 그러다 운영에서 터집니다.
Miri 는 Rust 코드를 해석기로 실행하며 UB 를 검출합니다.
unsafe 를 쓴다면 CI 에 넣지 않을 이유가 없습니다.
설치와 실행
# 설치 (nightly 필요)
rustup +nightly component add miri
# 실행 — 기존 테스트를 그대로 돌립니다 ★
cargo +nightly miri test
cargo +nightly miri test -- my_unsafe_test # 이름으로 필터
cargo +nightly miri run # 바이너리 실행
# 처음 실행 시 표준 라이브러리를 빌드하므로 몇 분 걸립니다
cargo +nightly miri setup
# 유용한 옵션
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test # 프로버넌스 (d03)
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test # 다른 aliasing 모델
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test # 시간·파일 허용
MIRIFLAGS="-Zmiri-seed=42" cargo +nightly miri test # 스레드 순서 고정
MIRIFLAGS="-Zmiri-ignore-leaks" cargo +nightly miri test # 누수 무시
무엇을 잡나 ★
✅ 검출하는 것
· 유효하지 않은 메모리 접근 (범위 밖 · 해제 후)
· 정렬 위반 (d03)
· 초기화되지 않은 메모리 읽기 (d04)
· aliasing 규칙 위반 (Stacked/Tree Borrows) ★
· 데이터 경쟁 (스레드 인터리빙을 시뮬레이션)
· 메모리 누수
· 잘못된 값 (bool 에 2 · 유효하지 않은 enum 등 · d02)
· 정수 → 포인터 캐스팅의 프로버넌스 문제
❌ 못 잡는 것
· FFI 로 넘어간 C 코드 안 ★ (🔗 f08)
→ 그 부분은 ASan/valgrind 로 (d02)
· 인라인 어셈블리 (d10)
· 실행되지 않은 코드 경로
→ 테스트 커버리지가 곧 Miri 커버리지입니다 ★
· 논리 버그 · 데드락
★ 그래서 unsafe 함수마다 '작은 테스트' 를 두는 것이 중요합니다 (d03).
이렇게도 쓸 수 있다 — 에러 메시지 읽기 🟡 중급
실제 출력 예 ★
error: Undefined Behavior: attempting a read access using <3521> at
alloc1520[0x8], but that tag does not exist in the borrow stack
--> src/lib.rs:42:18
|
42 | let v = unsafe { *p };
| ^^ this error occurs as part of an access at
| alloc1520[0x8..0xc]
|
= help: this indicates a potential bug in the program: it performed an
invalid operation, but the Stacked Borrows rules it violated are
still experimental
help: <3521> was created by a SharedReadOnly retag at offsets [0x0..0x10]
--> src/lib.rs:38:13
help: <3521> was later invalidated at offsets [0x0..0x10] by a Unique retag
--> src/lib.rs:40:9
# 읽는 법
# ① 어디서 위반했나 → 42줄
# ② 그 태그가 언제 생겼나 → 38줄에서 &T 를 만듦
# ③ 언제 무효가 됐나 → 40줄에서 &mut 를 만들며 무효화 ★
#
# → "불변 참조를 만든 뒤 가변 참조를 만들고, 다시 불변 참조를 썼다" (d03)
자주 나오는 위반과 해법
// ① &mut 와 원시 포인터를 섞어 쓰기 (d03)
let r = &mut v;
let p = v.as_ptr(); // ★ r 이 살아 있는데 다른 경로
// ✅ 포인터를 먼저 만들고 참조를 만들지 않기
// ② 슬라이스를 만든 뒤 원본을 수정
let s = std::slice::from_raw_parts(v.as_ptr(), v.len());
v.push(1); // ★ s 가 무효화됨
// ✅ 슬라이스 사용을 끝낸 뒤 수정
// ③ Box::from_raw 를 두 번
let b = Box::from_raw(p);
let b2 = Box::from_raw(p); // ★ 이중 해제 (🔗 f06)
// ④ 정렬 위반
let p = buf.as_ptr().add(1) as *const u64;
unsafe { *p }; // ★ 8바이트 정렬 아님
// ✅ read_unaligned 또는 from_le_bytes
// ⑤ 초기화되지 않은 읽기 (d04)
// ⑥ 누수 (Miri 는 기본적으로 잡습니다)
std::mem::forget(value); // 의도적이면 -Zmiri-ignore-leaks
실무에선 이렇게 — CI 에 넣기 🔴 실무
워크플로 (🛠️ b08)
miri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with: { components: miri }
- uses: Swatinem/rust-cache@v2
- run: cargo miri setup
- run: cargo miri test
env:
MIRIFLAGS: -Zmiri-strict-provenance
// 느린 테스트는 Miri 에서 건너뛰기 ★
#[test]
#[cfg_attr(miri, ignore)]
fn huge_dataset() { /* 100만 건 처리 */ }
// Miri 에서만 도는 테스트
#[test]
#[cfg(miri)]
fn miri_only_check() { }
// 코드에서 Miri 여부 확인
if cfg!(miri) { /* 파일 I/O 를 건너뛰기 */ }
// ★ 실무 구성
// · 일반 CI: cargo test (빠름 · 전부)
// · Miri job: unsafe 를 쓰는 크레이트만 · 작은 테스트만
// · 야간 빌드: 전체 Miri
테스트를 Miri 친화적으로 ★
// ★ Miri 는 실행된 코드만 봅니다 — 커버리지가 곧 검증 범위입니다
#[cfg(test)]
mod tests {
use super::*;
// 작고 빠르게 — 각 unsafe 경로를 한 번씩 지나가게
#[test]
fn slice_roundtrip() {
let v = vec![1u8, 2, 3];
let s = unsafe { as_slice(v.as_ptr(), v.len()) }.unwrap();
assert_eq!(s, &[1, 2, 3]);
}
#[test]
fn null_and_empty() {
assert!(unsafe { as_slice::<u8>(std::ptr::null(), 5) }.is_none());
assert!(unsafe { as_slice::<u8>(std::ptr::null(), 0) }.is_some());
}
#[test]
fn handle_lifecycle() {
let h = unsafe { mylib_new() };
assert!(!h.is_null());
unsafe { mylib_free(h) }; // ★ 누수·이중 해제를 Miri 가 검사
}
}
// 퍼징과 조합하면 더 좋습니다 (d03)
// cargo fuzz 로 찾은 입력을 회귀 테스트로 추가 → Miri 로 검증
unsafe 함수마다 정상 · 널 · 길이 0 · 해제 네 가지 테스트를 두면
대부분의 UB 를 잡아냅니다.
한 단계 더 — Stacked Borrows 와 Tree Borrows 🧬 전문가
aliasing 모델이란
Rust 의 참조 규칙(🔐 o02)을 unsafe 코드까지 확장하려면
'정확히 무엇이 허용되는가' 를 정의해야 합니다.
그 후보가 두 가지입니다.
Stacked Borrows (기본)
· 각 메모리 위치마다 '빌림 스택' 을 유지
· &mut 를 만들면 그 위에 태그를 push
· 다른 경로로 접근하면 그 위의 태그를 pop (무효화) ★
· 엄격합니다 — 실제로는 안전한 코드도 거부하는 경우가 있습니다
Tree Borrows (실험적)
· 스택 대신 트리로 관리
· 더 관대하면서도 필요한 최적화는 유지
· 실제 코드에서 오탐이 적습니다
# 둘 다 돌려 보세요
cargo +nightly miri test
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test
★ Stacked Borrows 가 거부하고 Tree Borrows 가 통과한다면
· 당장 UB 는 아닐 수 있지만
· 미래에 문제가 될 수 있는 코드입니다
→ 가능하면 둘 다 통과하도록 고치세요
Miri 로 잡히지 않는 것 대비
# FFI 가 있으면 살균기도 함께 (d02 · 🔗 f08)
RUSTFLAGS="-Zsanitizer=address" \
cargo +nightly test --target x86_64-unknown-linux-gnu
RUSTFLAGS="-Zsanitizer=thread" \
cargo +nightly test --target x86_64-unknown-linux-gnu
# 동시성 로직은 loom (d06)
RUSTFLAGS="--cfg loom" cargo test --release
# 임의 입력은 퍼징
cargo fuzz run my_target -- -max_total_time=300
# ★ 검증 도구 조합 (unsafe 가 있는 프로젝트의 권장 구성)
# cargo test 빠른 회귀
# cargo miri test Rust UB
# ASan/TSan C 연동 · 실제 메모리
# loom 락 프리 동시성
# cargo fuzz 입력 공간 탐색
#
# 전부 돌릴 필요는 없습니다 — 코드에 해당하는 것만 고르세요.
# 순수 Rust + 약간의 unsafe 면 Miri 하나로 충분합니다.
unsafe 를 쓴다면 cargo miri test 를 CI 에 넣으세요.
aliasing · 정렬 · 초기화 · 누수 · 데이터 경쟁을 자동으로 잡아 줍니다.
실행된 코드만 검사하므로 unsafe 함수마다 작은 테스트를 두고,
FFI 로 넘어간 C 코드는 ASan 으로 따로 봐야 합니다.안전 추상화 설계 — 불변식을 문서로
이 탭에서 가장 중요한 섹션입니다.
unsafe 를 쓰는 목적은 안전한 껍데기를 만들어 남에게 내놓는 것이지,
unsafe 를 퍼뜨리는 것이 아닙니다.
사용자가 unsafe 를 한 줄도 쓰지 않게 만드는 것이 목표입니다.
기본 형태 ★
use std::ptr::NonNull;
/// C 디코더를 감싼 안전한 핸들.
///
/// # 불변식
/// - `raw` 는 항상 유효한 디코더를 가리킵니다 (생성자가 널을 거부).
/// - 이 타입이 살아 있는 동안 `raw` 는 해제되지 않습니다.
/// - `raw` 는 이 인스턴스만 소유합니다 (Clone 을 구현하지 않음).
pub struct Decoder {
raw: NonNull<ffi::Decoder>,
}
impl Decoder {
pub fn new() -> Result<Self, Error> {
// SAFETY: decoder_create 는 실패 시 널을 반환합니다.
let raw = unsafe { ffi::decoder_create() };
NonNull::new(raw).map(|raw| Decoder { raw }).ok_or(Error::Alloc)
}
pub fn decode(&mut self, input: &[u8]) -> Result<Vec<u8>, Error> {
// SAFETY: 불변식에 의해 raw 는 유효하고, input 은 Rust 슬라이스라
// ptr/len 이 정확합니다. &mut self 이므로 동시 호출이 없습니다.
let n = unsafe {
ffi::decode(self.raw.as_ptr(), input.as_ptr(), input.len())
};
if n < 0 { return Err(Error::from(n)); }
Ok(self.take_output(n as usize))
}
}
impl Drop for Decoder {
fn drop(&mut self) {
// SAFETY: 불변식에 의해 raw 는 유효하고, 여기서 한 번만 해제됩니다.
unsafe { ffi::decoder_destroy(self.raw.as_ptr()) }
}
}
설계 체크리스트 ★
□ 불변식을 문장으로 쓸 수 있는가
쓸 수 없으면 아직 설계가 안 된 것입니다
□ 생성자가 불변식을 보장하는가
널 검사 · 범위 검사 · 초기화 (r07 의 Percent 패턴)
□ 모든 공개 메서드가 불변식을 유지하는가
&mut self 메서드가 중간 상태를 남기지 않는지
□ 필드가 비공개인가
pub 필드가 있으면 사용자가 불변식을 깰 수 있습니다 ★
□ Drop 이 정확히 한 번 해제하는가
Clone 을 구현했다면 이중 해제가 되지 않는지
□ Send/Sync 가 올바른가 (⚡ e06)
자동으로 붙었다면 정말 맞는지 확인하세요
□ 안전한 코드만으로 UB 를 만들 수 없는가 (d01 의 건전성)
"악의적인 사용자" 를 가정해 보세요
□ 패닉 안전한가
중간에 패닉해도 불변식이 유지되는가 (🔐 o07)
이렇게도 쓸 수 있다 — 불건전해지기 쉬운 자리 🟡 중급
흔한 실수 다섯 가지 ★
// ① 필드를 pub 으로 열기
pub struct Buffer { pub ptr: *mut u8, pub len: usize } // ❌
// → 사용자가 len 을 바꾸면 범위 밖 접근이 됩니다
// ✅ 비공개로 두고 접근자 제공
// ② Clone 을 무심코 derive
#[derive(Clone)] // ❌
pub struct Handle { raw: *mut ffi::Thing }
// → 두 인스턴스가 같은 포인터를 해제합니다 (이중 해제)
// ✅ Clone 을 구현하지 않거나, C 쪽 참조 카운트를 올리기
// ③ 라이프타임을 임의로 만들기
pub fn get<'a>(&self) -> &'a [u8] { // ❌
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
}
// → 'a 를 호출자가 정하므로 self 보다 오래 살 수 있습니다
// ✅ pub fn get(&self) -> &[u8] (self 에 묶입니다 · 🔐 o03)
// ④ Send/Sync 를 근거 없이 붙이기 (⚡ e06)
unsafe impl Sync for Handle {} // ❌ 근거 없음
// ⑤ 콜백 안에서 불변식이 깨질 수 있음
pub fn for_each(&mut self, f: impl FnMut(&T)) {
// f 안에서 사용자가 self 를 만질 수 있다면? (🔗 f07)
}
타입으로 불변식 표현하기 ★
// ① 뉴타입으로 검증된 값 (r07)
pub struct Utf8Bytes(Vec<u8>);
impl Utf8Bytes {
pub fn new(v: Vec<u8>) -> Result<Self, Error> {
std::str::from_utf8(&v)?; // 한 번만 검증
Ok(Utf8Bytes(v))
}
pub fn as_str(&self) -> &str {
// SAFETY: 생성자에서 검증했고, 내부를 바꿀 방법이 없습니다.
unsafe { std::str::from_utf8_unchecked(&self.0) }
}
}
// → 이후 모든 사용처에서 검증 비용이 사라집니다 (⚡ e02)
// ② 타입 상태 — 잘못된 순서를 컴파일 에러로
pub struct Closed;
pub struct Open;
pub struct File<S> { raw: RawFd, _s: PhantomData<S> }
impl File<Closed> { pub fn open(p: &Path) -> Result<File<Open>> { } }
impl File<Open> {
pub fn read(&mut self) -> Result<Vec<u8>> { }
pub fn close(self) -> File<Closed> { }
}
// → 닫힌 파일에서 read 를 부르면 컴파일이 안 됩니다 ★
// ③ NonNull · NonZero 로 값의 범위를 타입에
// ④ sealed 트레이트로 외부 구현 차단 (🔐 o05)
실무에선 이렇게 — FFI 래퍼 완성형 🔴 실무
전체 구조 ★
// crates/foo-sys — 생성된 바인딩 (🔗 f08)
// unsafe · 보장 없음
// crates/foo — 안전한 래퍼
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(clippy::undocumented_unsafe_blocks, clippy::missing_safety_doc)]
mod raw; // unsafe 를 여기 모읍니다 (d01)
mod error;
pub use error::{Error, Result};
/// 디코더. 스레드 간 이동은 가능하지만 공유는 불가합니다.
pub struct Decoder { /* 비공개 */ }
// SAFETY: libfoo 문서 §3.2 — 컨텍스트는 한 번에 한 스레드에서만
// 사용하면 스레드 간 이동이 안전합니다.
unsafe impl Send for Decoder {}
// Sync 는 구현하지 않습니다 (동시 호출 불가)
impl Decoder {
pub fn new() -> Result<Self>;
pub fn decode(&mut self, input: &[u8]) -> Result<Vec<u8>>;
pub fn decode_into(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<()>;
pub fn reset(&mut self);
}
// → 사용자는 unsafe 를 한 줄도 쓰지 않습니다 ★
// crates/foo-python — PyO3 바인딩 (🐍 y02)
// crates/foo-node — napi-rs 바인딩 (🟨 j02)
// 둘 다 foo 의 안전한 API 만 씁니다
테스트와 문서
// ① 불변식이 유지되는지 테스트 (r11)
#[test]
fn drop_frees_once() {
let counter = Arc::new(AtomicUsize::new(0));
{ let _d = Decoder::new().unwrap(); }
// C 쪽 카운터를 확인하거나, 테스트용 훅으로 검증
}
#[test]
fn invalid_input_is_error_not_panic() {
let mut d = Decoder::new().unwrap();
assert!(d.decode(&[0xFF; 3]).is_err()); // 패닉하지 않아야 합니다
}
// ② 컴파일되면 안 되는 코드를 테스트 (trybuild · r11)
// tests/ui/no_sync.rs
// fn assert_sync<T: Sync>() {}
// fn main() { assert_sync::<Decoder>(); } // ★ 컴파일 실패해야 정상
// ③ 문서에 계약을 적기
/// # 스레드 안전성
/// `Decoder` 는 `Send` 이지만 `Sync` 가 아닙니다.
/// 여러 스레드에서 쓰려면 각자 인스턴스를 만들거나 `Mutex` 로 감싸세요.
///
/// # 패닉
/// 이 타입의 메서드는 패닉하지 않습니다. 모든 실패는 `Result` 입니다.
// ④ Miri 로 검증 (d07)
한 단계 더 — 건전성을 증명하는 방법 🧬 전문가
스스로에게 던질 질문
① 이 unsafe 블록이 요구하는 조건은 정확히 무엇인가
std 문서의 # Safety 를 그대로 옮겨 적어 보세요
② 그 조건을 무엇이 보장하는가
· 생성자의 검증
· 타입 시스템 (&mut 의 배타성 · NonNull)
· 필드 비공개
· 외부 문서 (C 라이브러리의 계약)
③ 사용자가 그 보장을 깰 수 있는가
· pub 필드 · Clone · Deref · 콜백 · 패닉
④ 다른 스레드가 깰 수 있는가 (⚡ e06)
⑤ 패닉이나 조기 반환 중간에 깨지는가
Drop 이 불릴 때 불변식이 유지되는가 (🔐 o07)
★ ①~⑤ 를 SAFETY 주석에 답으로 적으세요.
적을 수 없는 항목이 있으면 아직 건전하지 않습니다.
참고할 만한 사례
★ 잘 설계된 안전 추상화를 읽어 보세요
· std::vec::Vec — 가장 교과서적입니다 (용량·길이 불변식)
· std::sync::Mutex — UnsafeCell 을 락으로 감쌈 (🔐 o10)
· bytemuck — 트레이트로 "변환해도 안전한 타입" 을 표현 (🔗 f03)
· crossbeam-epoch — 메모리 회수를 안전 API 로 (⚡ e07)
· rust-numpy — numpy 배열을 슬라이스로 (🐍 y06)
★ 읽는 법
① pub 인 것만 먼저 봅니다 (사용자 관점)
② 불변식 주석을 찾습니다
③ unsafe 블록의 SAFETY 주석과 대조합니다
④ "내가 이걸 깰 수 있나" 를 생각해 봅니다
★ 건전성 버그가 발견되면
· RustSec 에 보고 (보안 취약점으로 다룹니다)
· yank 하고 수정 버전 배포 (🛠️ b02)
· CHANGELOG 에 영향 범위를 명시
★ 이 가이드의 원칙과 이어집니다
"되돌릴 수 있게" · "경계를 좁게" · "측정하고 결정"
안전 추상화는 그 원칙을 타입으로 구현한 것입니다.
unsafe 의 목적은 안전한 껍데기를 만들어 내놓는 것입니다.
불변식을 문장으로 적고, 생성자에서 보장하고, 필드를 비공개로 두세요.
안전한 코드만으로 UB 를 만들 수 없어야 건전한 API 이고,
타입으로 표현할 수 있으면(뉴타입 · 타입 상태) 문서보다 확실합니다.매크로 — 코드를 만드는 코드
println! 의 !, #[derive(Debug)], #[pyfunction], #[napi] —
이 가이드에서 계속 써 온 것들이 전부 매크로입니다.
어떻게 동작하는지 알면 에러 메시지가 읽히고, 필요할 때 직접 만들 수 있습니다.
두 종류 ★
| 선언 매크로 | 절차 매크로 | |
|---|---|---|
| 정의 | macro_rules! | 별도 크레이트 |
| 동작 | 패턴 치환 | Rust 코드로 생성 |
| 빌드 | 가벼움 | 무거움 ★ |
| 예 | vec! println! | #[derive] #[pyfunction] |
| 난이도 | 중간 | 높음 |
// 선언 매크로 — 반복되는 코드를 줄일 때
macro_rules! hashmap {
($($k:expr => $v:expr),* $(,)?) => {{
let mut m = std::collections::HashMap::new();
$( m.insert($k, $v); )*
m
}};
}
let m = hashmap!{ "a" => 1, "b" => 2 };
// FFI 에서 유용한 예 (🔗 f09)
macro_rules! ffi_guard {
($body:expr) => {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) {
Ok(v) => v,
Err(_) => { set_last_error("내부 패닉".into()); -99 }
}
};
}
#[no_mangle]
pub unsafe extern "C" fn mylib_process(p: *const u8, n: usize) -> i32 {
ffi_guard!({ /* 본문 */ })
}
패턴 조각 타입
// macro_rules! 에서 쓰는 지정자
// $x:expr 식 1 + 2
// $x:ident 식별자 my_var
// $x:ty 타입 Vec<u8>
// $x:literal 리터럴 "abc" · 42
// $x:pat 패턴 Some(x)
// $x:stmt 문장
// $x:block { … }
// $x:item 항목 fn f() {}
// $x:tt 토큰 트리 (가장 유연)
// $x:path 경로 std::vec::Vec
// 반복
// $( … ),* 쉼표로 구분해 0회 이상
// $( … );+ 세미콜론으로 1회 이상
// $(,)? 마지막 쉼표 허용 ★
// 위생(hygiene) — 매크로 안의 변수는 밖과 섞이지 않습니다
macro_rules! bad {
() => { let x = 1; }; // ★ 이 x 는 호출부의 x 와 다릅니다
}
// 크레이트 밖으로 내보내기
#[macro_export]
macro_rules! my_macro { () => {}; }
// 사용: use mycrate::my_macro;
// 디버깅
// cargo expand 확장 결과 보기 ★
// trace_macros!(true) (nightly)
이렇게도 쓸 수 있다 — 절차 매크로 🟡 중급
세 가지 형태
# 반드시 별도 크레이트여야 합니다 (b05)
# mycrate-derive/Cargo.toml
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
// ① derive 매크로 — #[derive(MyTrait)]
#[proc_macro_derive(Summary, attributes(summary))]
pub fn derive_summary(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
quote! {
impl Summary for #name {
fn summary(&self) -> String { format!("{}", stringify!(#name)) }
}
}.into()
}
// ② 어트리뷰트 매크로 — #[my_attr]
#[proc_macro_attribute]
pub fn timed(_attr: TokenStream, item: TokenStream) -> TokenStream { }
// ③ 함수형 매크로 — my_macro!(…)
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream { }
// ★ PyO3 의 #[pyfunction] · napi 의 #[napi] 가 ② 입니다 (🐍 y02 · 🟨 j02)
// 무엇을 만들어 내는지 보려면
// cargo expand --lib
비용과 판단 ★
★ 절차 매크로의 비용
· syn · quote · proc-macro2 를 빌드해야 합니다
→ 빌드 시간이 눈에 띄게 늘어납니다 (🛠️ b10)
· 매크로 확장 자체도 컴파일 시간
· 에러 메시지가 확장된 코드를 가리킵니다 (읽기 어려움)
· 디버깅이 어렵습니다
· IDE 지원이 제한적입니다
★ 만들기 전에
□ 선언 매크로로 되는가 (훨씬 가볍습니다)
□ 제네릭·트레이트로 되는가 ★ 대부분 이쪽이 낫습니다
□ 코드 생성 스크립트(build.rs)로 되는가 (🛠️ b01)
□ 그냥 반복해서 쓰면 안 되는가 (10번 이하면 대개 그게 낫습니다)
★ 정당한 경우
· derive 로 보일러플레이트 제거 (Serialize · Debug)
· FFI 바인딩 자동화 (#[pyfunction] · #[napi])
· 컴파일 타임 검증 (SQL 쿼리 · 정규식)
★ 실무 결론
쓰는 것은 자주, 만드는 것은 드물게.
실무에선 이렇게 — 매크로 에러 읽기 🔴 실무
확장 결과를 보세요 ★
cargo install cargo-expand
cargo expand # 전체
cargo expand --lib mymodule # 모듈만
cargo expand --bin mybin
cargo expand --test my_test
# ★ #[pyfunction] 이 무엇을 만드는지 (🐍 y02)
cargo expand --lib | grep -A 40 "sum_squares"
# 출력을 파일로 두고 읽으세요
cargo expand > /tmp/expanded.rs
★ 이렇게 하면 이해되는 것들
· #[derive(Debug)] 가 만드는 impl 의 정확한 형태
· #[pyclass] 가 만드는 파이썬 타입 등록 코드 (🐍 y05)
· #[napi] 가 만드는 타입 변환 코드 (🟨 j03)
· async fn 이 만드는 상태 기계 (⚡ e08)
· println! 의 실제 호출
★ 에러 메시지가 이상할 때
"this error originates in the derive macro `Serialize`"
→ 확장 결과를 보면 어느 필드가 문제인지 보입니다
매크로를 만든다면
// ① 에러를 좋게 만드세요
use syn::spanned::Spanned;
return syn::Error::new(field.span(), "이 필드에는 #[summary] 를 쓸 수 없습니다")
.to_compile_error().into();
// → 사용자 코드의 정확한 위치를 가리킵니다 ★
// ② 컴파일 실패를 테스트 (trybuild)
// tests/ui/bad_usage.rs + bad_usage.stderr
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}
// ③ 위생을 지키세요 — 사용자 코드의 이름과 충돌하지 않게
// proc_macro2::Span::call_site() vs mixed_site()
// ④ 생성 코드에서 절대 경로를 쓰세요
quote! { ::std::vec::Vec::new() } // ★ std 가 가려져도 동작
// ⑤ 문서를 쓰세요 — 무엇을 생성하는지 예제로
// ⑥ 빌드 시간을 재세요 (🛠️ b10)
cargo build --timings
한 단계 더 — 컴파일 파이프라인에서의 위치 🧬 전문가
언제 확장되나
소스 → 어휘 분석 → 파싱 → AST
↓
매크로 확장 ★ (여기)
↓
이름 해석 · 타입 검사 → HIR → MIR → LLVM (d10)
★ 의미
· 매크로는 타입을 모릅니다
#[derive] 는 필드 '이름과 타입 이름' 만 봅니다.
그 타입이 Clone 인지 알 수 없습니다 ★
→ 그래서 생성된 코드에 T: Clone 바운드를 붙여 둡니다
· 매크로는 다른 파일을 못 봅니다 (그 항목의 토큰만)
· 매크로 안에서 매크로를 부를 수 있습니다 (재귀 확장)
recursion_limit 에 걸리면 #![recursion_limit = "256"]
# 확장 단계 관찰 (nightly)
cargo +nightly rustc -- -Zunpretty=expanded
cargo +nightly rustc -- -Zmacro-backtrace
알아 두면 좋은 매크로들
// 컴파일 타임 검사
compile_error!("이 조합은 지원하지 않습니다"); // 🛠️ b04
const _: () = assert!(size_of::<Header>() == 16); // 🔗 f03
// 환경·빌드 정보
env!("CARGO_PKG_VERSION")
option_env!("MY_VAR")
include_str!("../README.md")
include_bytes!("data.bin")
include!(concat!(env!("OUT_DIR"), "/generated.rs")) // 🛠️ b01
// 위치 정보
file!() line!() column!() module_path!()
std::panic::Location::caller() // #[track_caller]
// 디버깅
dbg!(&value); // 값을 출력하고 그대로 반환 ★
// clippy::dbg_macro 로 커밋 방지 (🛠️ b09)
todo!() unimplemented!() unreachable!()
// 조건부
cfg!(debug_assertions)
matches!(x, Some(_)) // r08
// #[track_caller] — 패닉 위치를 호출부로
#[track_caller]
pub fn expect_valid(v: Option<T>) -> T { v.expect("유효하지 않음") }
// → 라이브러리 내부가 아니라 사용자 코드 줄이 표시됩니다 ★
macro_rules!)는 패턴 치환, 절차 매크로는 Rust 코드로 생성합니다.
이 가이드의 #[pyfunction]·#[napi]·#[derive] 가 전부 후자이고,
cargo expand 로 무엇을 만드는지 볼 수 있습니다.
만들기 전에 제네릭·트레이트로 되는지 먼저 확인하세요 — 빌드 시간과 유지보수 비용이 큽니다.컴파일 과정 — MIR · 단형화 · LLVM
소스가 실행 파일이 되기까지 컴파일러 안에서 무슨 일이 일어나는지 봅니다. 빌드가 왜 느린지(🛠️ b10), 왜 이터레이터가 빠른지(⚡ e03), 왜 제네릭에 비용이 없는지(r10)가 여기서 설명됩니다.
단계 ★
소스 (.rs)
│ 어휘 분석 · 파싱
▼
AST
│ 매크로 확장 (d09)
▼
AST (확장됨)
│ 이름 해석 · 모듈 구성 (r09)
▼
HIR (High-level IR)
│ 타입 검사 · 트레이트 해석 (🔐 o05·o06)
▼
THIR → MIR (Mid-level IR) ★ Rust 고유의 표현
│ · 빌림 검사 (borrowck) ← 여기서 소유권 검사 (🔐 o02)
│ · MIR 최적화 (인라인 · 상수 전파)
│ · 단형화 (monomorphization) ← 제네릭을 타입별로 복제 (r10)
▼
LLVM IR
│ LLVM 최적화 (인라인 · 벡터화 · ⚡ e03·e09)
▼
오브젝트 파일 → 링크 → 실행 파일 / .so / .dll
# 각 단계 보기 (nightly)
cargo +nightly rustc -- -Zunpretty=hir
cargo +nightly rustc -- -Zunpretty=mir
cargo +nightly rustc -- -Zdump-mir=my_function
cargo rustc --release -- --emit llvm-ir
cargo rustc --release -- --emit asm
MIR 이 하는 일
MIR 은 '제어 흐름 그래프' 형태의 단순한 표현입니다.
fn add(a: i32, b: i32) -> i32 { a + b }
MIR (개념)
bb0: {
_3 = CheckedAdd(_1, _2);
assert(!move (_3.1: bool), "overflow") -> bb1; ★ 디버그 빌드만
}
bb1: { _0 = move (_3.0: i32); return; }
★ MIR 에서 일어나는 중요한 일들
· 빌림 검사 — 소스가 아니라 MIR 위에서 (🔐 o02)
그래서 NLL 처럼 '마지막 사용까지' 를 정확히 계산합니다
· Drop 삽입 — 스코프 끝에 drop 호출을 끼워 넣습니다 (🔐 o01)
조건부 이동이 있으면 drop flag(숨은 bool)도
· 오버플로 검사 삽입 (debug_assertions · r02)
· MIR 최적화 — 간단한 인라인·상수 전파
-Zmir-opt-level 로 조절
# 확인
cargo +nightly rustc -- -Zdump-mir=add
ls mir_dump/
이렇게도 쓸 수 있다 — 단형화가 만드는 것 🟡 중급
코드 복제 ★
fn process<T: Display>(v: &[T]) { for x in v { println!("{x}"); } }
process(&[1i32]);
process(&[1.0f64]);
process(&["a"]);
// 컴파일 후에는 세 벌이 됩니다
// process::<i32> · process::<f64> · process::<&str>
// → 각자 인라인·최적화되어 비용이 0 입니다 (r10)
// → 대신 코드가 3배가 됩니다 (🛠️ b10)
# 얼마나 생성됐나 보기
cargo +nightly rustc --release -- -Zprint-mono-items=lazy 2>&1 | wc -l
cargo +nightly rustc --release -- -Zprint-mono-items=lazy | grep process
# 어느 크레이트가 코드를 많이 만드나 (🛠️ b10)
cargo bloat --release --crates
cargo llvm-lines --release | head -20 # ★ LLVM IR 줄 수로 순위
# cargo install cargo-llvm-lines
# 출력 예
# Lines Copies Function
# 28,451 312 core::ptr::drop_in_place
# 19,203 89 serde::de::…
# → Copies 가 크면 단형화가 많이 일어난 것입니다 ★
줄이는 방법 (🛠️ b10)
// ① 큰 본체를 비제네릭 함수로 빼기 ★ 가장 효과적
pub fn read_all(path: impl AsRef<Path>) -> io::Result<String> {
fn inner(p: &Path) -> io::Result<String> {
/* 큰 본체 — 한 벌만 생성됩니다 */
}
inner(path.as_ref())
}
// → 표준 라이브러리가 곳곳에서 쓰는 기법입니다
// ② dyn 으로 바꾸기 (🔐 o06)
fn render(items: &[&dyn Draw]) { } // 한 벌만
// ③ 제네릭 파라미터 수 줄이기
// 파라미터 3개 × 타입 5개 = 125 조합이 될 수 있습니다
// ④ 열거형으로 (🔐 o06)
enum Input { Text(String), Bytes(Vec<u8>) }
fn process(i: Input) { } // 한 벌
// ⑤ 자주 쓰는 타입만 미리 인스턴스화
// (라이브러리에서 pub fn 으로 노출하면 사용자 쪽에서 안 만듭니다)
// 측정하고 결정하세요 — 빌드 시간과 바이너리 크기를 재세요 (🛠️ b10)
실무에선 이렇게 — 빌드 시간 진단 🔴 실무
어느 단계가 오래 걸리나
# 크레이트별 (🛠️ b01)
cargo build --timings
# 컴파일 단계별
cargo +nightly rustc --release -- -Ztime-passes 2>&1 | sort -k2 -rn | head
# time: 12.4 LLVM_module_optimize ★ 최적화가 대부분
# time: 3.2 monomorphization_collector ★ 제네릭이 많음
# time: 2.1 type_check_crate
# time: 1.8 link ★ 링커 교체를 검토 (b10)
# 자세한 프로파일
cargo +nightly rustc -- -Zself-profile
# summarize summarize chunk-*.mm_profdata
# ★ 해석과 처방
# LLVM 시간 ↑ → 단형화 줄이기 · codegen-units ↑ · opt-level ↓
# 타입 검사 ↑ → 복잡한 트레이트 해석 · 제네릭 단순화
# 매크로 확장 ↑ → 절차 매크로 사용 검토 (d09)
# 링크 ↑ → mold/lld (🛠️ b10)
코드 생성 단위
rustc 는 크레이트를 여러 'codegen unit' 으로 나눠 병렬로 LLVM 에 넘깁니다.
codegen-units = 16 (릴리즈 기본)
· 병렬화되어 빌드가 빠릅니다
· 유닛 간 인라인이 제한되어 코드가 조금 느립니다
codegen-units = 1
· 전체를 한 번에 최적화 → 더 빠른 코드 · 작은 바이너리
· 빌드가 느립니다 (병렬화 없음)
lto = "thin" / "fat" (🛠️ b06)
· 크레이트 경계를 넘어 최적화
· thin 은 병렬 · fat 은 전체를 한 모듈로
★ 네이티브 모듈 권장 (🛠️ b06)
개발: codegen-units 기본 · opt-level 0~1
배포: codegen-units = 1 · lto = "fat"
→ 배포 빌드만 느리게 두고 개발은 빠르게
# 실험
hyperfine --prepare 'cargo clean' \
'cargo build --release' \
'CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 cargo build --release'
한 단계 더 — 생성 코드 읽기 🧬 전문가
어셈블리까지 (⚡ e03)
cargo install cargo-show-asm
cargo asm --lib "mylib::hot_function" --rust # 소스와 나란히 ★
cargo asm --lib --everything | less
cargo asm --lib --llvm "mylib::hot_function" # LLVM IR
# godbolt.org — 여러 컴파일러 버전·타깃을 비교
# #[no_mangle] pub extern "C" 로 만들면 찾기 쉽습니다
# 확인할 것
# · 인라인됐나 (call 명령이 없는지)
# · 벡터화됐나 (vpaddd · fmul v0.4s · ⚡ e09)
# · 범위 검사가 남았나 (panic 호출)
# · 예상보다 코드가 긴가 (단형화 폭발)
# 인라인 힌트 (r03)
#[inline] #[inline(always)] #[inline(never)]
# → 대부분 필요 없습니다. 측정 후에만.
알아 두면 좋은 것들
★ Rust 가 LLVM 에 주는 추가 정보
· noalias — &mut 는 배타적이므로 (🔐 o02 · ⚡ e09)
C 의 restrict 를 항상 붙인 것과 같은 효과입니다
· nonnull · dereferenceable — 참조는 널이 아니고 유효
· align — 정렬이 보장됨 (d03)
· range 메타데이터 — enum 판별자의 범위
→ 같은 코드에서 Rust 가 C 보다 빠른 경우가 여기서 나옵니다 ★
★ 반대로 최적화를 막는 것
· dyn 호출 (인라인 불가 · 🔐 o06)
· 부동소수점 순서 (⚡ e03)
· 원자 연산 (재배치 금지 · d06)
· unsafe 로 만든 aliasing (컴파일러 가정이 틀림 · d03)
★ 다른 백엔드
· Cranelift — 디버그 빌드 전용, 훨씬 빠른 코드 생성 (🛠️ b10)
· GCC 백엔드 (rustc_codegen_gcc) — 더 많은 아키텍처 지원
· 둘 다 실험적입니다
cargo build --timings 와 cargo asm 두 개면
빌드 시간과 성능 문제의 대부분을 진단할 수 있습니다.
-Ztime-passes 로 어느 단계인지 확인하고,
LLVM 시간이 크면 단형화를 줄이세요(비제네릭 안쪽 함수 · dyn).
&mut 의 배타성이 noalias 로 전달되는 것이 Rust 성능의 숨은 이점입니다.ABI 안정성 — Rust 는 자기 자신과도 안 맞습니다
C++ 에서 “컴파일러 버전이 다르면 링크가 안 된다”가 문제였습니다. Rust 는 한 발 더 나갑니다 — 공식적으로 ABI 를 보장하지 않습니다. 그래서 경계는 항상 C ABI(🔗 f01)여야 하고, 이 제약이 배포 설계를 결정합니다.
무엇이 보장되지 않나 ★
★ 보장되지 않는 것 (repr(Rust) · 기본값)
· 구조체 필드 순서와 패딩 (r07 · 🔗 f03)
· enum 판별자의 크기와 값
· 함수 호출 규약 (extern "Rust")
· 이름 맹글링 형식 (🔗 f02)
· 트레이트 객체 vtable 배치 (🔐 o06)
· Vec · String 의 내부 표현
★ 언제 달라지나
· 컴파일러 버전이 다르면
· 최적화 설정이 다르면 (이론상)
· 같은 코드라도 재컴파일하면 (이론상)
★ 실제로는
같은 컴파일러 버전 · 같은 설정이면 잘 맞습니다.
하지만 '보장' 이 없으므로 의존하면 안 됩니다.
★ 그래서 Rust 라이브러리는
· 소스로 배포합니다 (crates.io · 🛠️ b02)
· 매번 함께 컴파일됩니다 (정적 링크)
· 동적 링크한 Rust .so 를 다른 Rust 앱이 쓰는 방식은 쓰지 않습니다 ★
보장되는 것
// ① #[repr(C)] — C 배치 규칙 (🔗 f03)
#[repr(C)] pub struct Point { pub x: f64, pub y: f64 }
// ② extern "C" — C 호출 규약 (🔗 f02)
#[no_mangle] pub extern "C" fn f(a: i32) -> i32 { a }
// ③ #[repr(transparent)] — 필드 하나와 동일
#[repr(transparent)] pub struct Handle(*mut c_void);
// ④ 원시 포인터 · 정수 · 부동소수점 — 플랫폼 ABI 그대로
// ⑤ 널 포인터 최적화 (r08) — 문서화된 보장 ★
// Option<&T> · Option<Box<T>> · Option<NonNull<T>>
// Option<extern "C" fn()> 는 C 의 널 가능 포인터와 동일
// ★ 이 다섯 가지만 경계에서 쓰면 안전합니다.
// 그것이 🔗 C ABI 탭의 규칙이 그렇게 생긴 이유입니다.
// 확인
const _: () = assert!(std::mem::size_of::<Point>() == 16);
const _: () = assert!(
std::mem::size_of::<Option<&u8>>() == std::mem::size_of::<*const u8>()
);
이렇게도 쓸 수 있다 — 실무에서 부딪히는 지점 🟡 중급
파이썬·Node 확장에서 ★
★ 다행히 대부분 문제가 안 됩니다
· 확장 모듈은 하나의 .so/.pyd 로 통째로 빌드됩니다
· 안에서 쓰는 Rust 크레이트는 전부 정적 링크됩니다
· 밖으로 나가는 경계는 파이썬 C API / N-API (둘 다 C ABI) ★
★ 그래도 조심할 것
① 여러 확장이 같은 Rust 크레이트를 각자 정적 링크
→ 각자 자기 사본을 갖습니다 (전역 상태가 공유되지 않음)
예: 두 확장이 rayon 을 쓰면 스레드 풀이 두 개 (⚡ e05)
예: log 크레이트의 전역 로거가 각자 따로 (r09 의 중복 문제)
② 심볼 충돌 (🔗 f02)
두 확장이 같은 C 라이브러리를 정적 링크하면
심볼이 겹쳐 엉뚱한 버전이 불릴 수 있습니다
→ 버전 스크립트로 심볼을 가리세요
③ 파이썬 ABI (🐍 y11)
abi3 를 쓰면 파이썬 버전 축이 사라집니다
④ Node ABI (🟨 j01)
N-API 덕분에 Node 버전 축이 사라집니다
C++ 와 비교
| C++ | Rust | |
|---|---|---|
| ABI 안정성 | 플랫폼별 표준 | 없음 |
| 동적 링크 | 가능 | 실질적으로 불가 |
| 배포 형태 | 헤더 + .so | 소스 |
| 버전 충돌 | 런타임에 발견 | 빌드에서 |
| 바이너리 크기 | 작음(공유) | 큼(정적) |
| 배포 편의 | 의존성 필요 | 파일 하나 |
★ 트레이드오프
Rust 의 선택: "ABI 를 포기하고 최적화·진화 자유를 얻는다"
잃은 것
· 시스템 라이브러리로 배포하기 어렵습니다
· 바이너리가 큽니다 (🛠️ b10)
· 보안 패치를 위해 재컴파일이 필요합니다
얻은 것
· 필드 재배치 등 최적화 (r07)
· 언어를 계속 개선할 수 있습니다
· "내 PC 에선 되는데" 가 거의 없습니다 ★
· 배포가 단순합니다 (의존 .so 가 없음)
실무에선 이렇게 — 경계를 설계한다 🔴 실무
언제 C ABI 를 쓸까 ★
✅ C ABI 가 필요한 경우
· 다른 언어에서 부를 때 (🔗 f01)
· 플러그인 시스템 (동적 로딩)
· 시스템 라이브러리로 배포
· 컴파일러 버전이 다른 코드와 링크
❌ 필요 없는 경우
· 순수 Rust 프로젝트 (cargo 가 전부 함께 빌드)
· PyO3 · napi-rs 확장 (프레임워크가 경계를 처리)
· WASM (🟨 j08)
★ 플러그인을 만든다면
· C ABI 로 인터페이스를 정의하고 (🔗 f01·f09)
· 버전 확인 함수를 두고 (🔗 f09)
· abi_stable 크레이트를 검토하세요
Rust 타입을 안정적으로 주고받게 해 주는 크레이트입니다
(제약이 많으므로 정말 필요할 때만)
★ 가장 단순한 대안
플러그인을 '별도 프로세스' 로 두고 IPC (🟨 j01)
→ ABI 문제가 통째로 사라집니다
배포 시 확인할 것
# 무엇에 동적 링크했나
ldd target/release/libmylib.so
otool -L target/release/libmylib.dylib # 맥
dumpbin /dependents target\release\mylib.dll
# 순수 Rust 라면 이 정도만 나와야 합니다
# linux-vdso · libgcc_s · libc · libm · libpthread
# 완전 정적 (🛠️ b07)
cargo build --release --target x86_64-unknown-linux-musl
ldd target/.../libmylib.so # "not a dynamic executable"
# 심볼 확인 (🔗 f02)
nm -D libmylib.so | grep " T " | wc -l
# 내보내려던 것보다 훨씬 많으면 정적 링크한 C 라이브러리가 새는 중
# glibc 버전 요구사항 (🛠️ b07)
objdump -T libmylib.so | grep GLIBC | sed 's/.*GLIBC_/GLIBC_/' | sort -uV | tail -3
# ★ 이 네 가지를 CI 에서 확인하면 (🛠️ b08)
# "다른 환경에서 로드 실패" 를 배포 전에 잡습니다
한 단계 더 — 앞으로의 변화 🧬 전문가
논의되고 있는 것들
★ #[repr(crABI)] / extern "crabi" (제안 단계)
Rust 타입 일부(String · Vec · Option 등)를 안정적으로 주고받는
ABI 를 정의하려는 시도입니다. 아직 확정되지 않았습니다.
★ v0 맹글링 (안정화 진행 중)
기존 legacy 맹글링보다 정보가 풍부하고 디맹글이 쉽습니다.
-Csymbol-mangling-version=v0
★ 크레이트 간 동적 링크
-Cprefer-dynamic 옵션이 있지만 실험적이고,
정확히 같은 컴파일러·같은 플래그를 요구합니다.
실무에서 쓰지 마세요.
★ 그래서 당분간
· 경계는 C ABI (🔗 f01)
· 나머지는 정적 링크
· 이 전제로 설계하세요
# 현재 맹글링 확인
nm target/release/libmylib.rlib | head
rustfilt < symbols.txt
이 가이드 전체와의 연결 ★
ABI 불안정이라는 하나의 제약이
이 가이드의 여러 결정을 만들었습니다.
🔗 f01 경계는 C ABI — 다른 선택지가 없습니다
🔗 f02 #[no_mangle] extern "C" — 맹글링·규약을 고정
🔗 f03 #[repr(C)] — 배치를 고정
🔗 f06 소유권 규약 — Vec 을 그대로 못 넘기므로
🔗 f09 에러 코드 — Result 를 그대로 못 넘기므로
🐍 y11 abi3 — 파이썬 쪽 ABI 축을 없애기
🟨 j01 N-API — Node 쪽 ABI 축을 없애기
🛠️ b10 바이너리 크기 — 정적 링크의 대가
★ 정리하면
"Rust 안에서는 마음껏, 경계에서는 C 로"
이 한 줄이 네이티브 모듈 설계의 핵심 원칙입니다.
#[repr(C)] + extern "C" 입니다.
확장 모듈은 통째로 빌드되므로 대개 문제가 없지만,
여러 확장이 같은 크레이트를 각자 링크하면 전역 상태가 공유되지 않는다는 점은 기억하세요.Rust 를 쓰지 말아야 할 때
마지막 섹션입니다. 안 쓰는 판단도 실력입니다. Rust 가 좋은 언어라는 것과, 지금 이 프로젝트에 맞는다는 것은 다른 이야기입니다. 솔직한 기준을 정리합니다.
쓰지 말아야 할 신호 ★
① 팀에 Rust 를 읽을 수 있는 사람이 나뿐이다 ★
· 내가 없으면 아무도 못 고칩니다
· 코드 리뷰가 형식적이 됩니다
· 가장 흔하고 가장 비싼 실패입니다
② 병목이 I/O 나 네트워크다
언어를 바꿔도 그대로입니다 (⚡ e10)
③ numpy · polars 로 이미 충분하다 (🐍 y01)
그것들도 이미 C · Rust 입니다
④ 요구사항이 자주 바뀐다
Rust 는 리팩터링이 안전하지만 '처음 쓰기' 는 느립니다
프로토타입 단계라면 파이썬이 낫습니다
⑤ 배포 대상 플랫폼이 다양한데 CI 를 갖출 여력이 없다 (🛠️ b08)
⑥ 실행 시간이 이미 충분히 빠르다
"빨라서 나쁠 건 없다" 는 이유가 아닙니다
⑦ 기존 C++ 자산이 크고 잘 관리되고 있다
경계를 새로 만드는 비용이 이득보다 클 수 있습니다 (🔵 C++ 가이드)
⑧ 컴파일 시간이 개발 흐름의 병목이 될 규모다 (🛠️ b10)
대안 목록
| 상황 | 대안 |
|---|---|
| 파이썬 루프가 느림 | numpy · polars (🐍 y01) |
| 수치 계산 | numba · JAX |
| 파이썬 확장 소규모 | Cython |
| 기존 C++ 있음 | pybind11 (🔵) |
| JS 가 느림 | V8 최적화 · WASM (🟨 j01) |
| 동시성 | Go |
| JVM 생태계 | Java · Kotlin (☕) |
| 게임 | C# · Unity (🟣) |
| 단순 CLI | Go · 셸 |
★ 특히 Go 와 비교
· 배우기 쉽고 컴파일이 빠릅니다
· GC 가 있어 소유권 고민이 없습니다
· 동시성이 단순합니다
· 대신 지연 시간 편차(GC)와 표현력에서 밀립니다
→ "팀 전체가 빠르게 익혀야 한다" 면 Go 가 현실적입니다.
"지연 시간이 제품의 가치" 라면 Rust 입니다.
이렇게도 쓸 수 있다 — 그럼에도 쓸 만한 경우 🟡 중급
Rust 가 확실히 유리한 자리 ★
① 파이썬·Node 의 핫스팟 하나를 갈아 끼우기 ★
이 가이드의 주제입니다. 범위가 좁아 위험이 작습니다.
· 인터페이스가 몇 개뿐 (🐍 y01 원칙 ②)
· 폴백을 남길 수 있음 (되돌릴 수 있음)
· 팀의 나머지는 파이썬만 알아도 됩니다
② 지연 시간 편차가 중요한 서비스
GC 스파이크가 없습니다 (⚡ e10 의 p99)
③ GIL 때문에 막힌 CPU 병렬 (🐍 y07)
파이썬으로는 물리적으로 불가능합니다
④ 메모리 안전이 곧 보안인 영역
파서 · 암호 · 네트워크 프로토콜
⑤ 여러 언어에서 공유할 코어 (🔗 f10)
한 번 만들어 파이썬·Node·C#·자바에서
⑥ 오래 유지될 인프라 코드
리팩터링이 안전해 수년 뒤에도 고칠 수 있습니다
⑦ 팀이 배우기로 결정했고, 시간을 확보했을 때
보통 2~4주면 실무 코드를 씁니다 (r12)
위험을 줄이며 도입하기
★ 단계적 도입 (이 가이드의 원칙 그대로)
1단계 — 하나의 함수
· 프로파일러가 가리키는 함수 하나만 (🐍 y01)
· 순수 파이썬 폴백을 함께 (🐍 y01 · y12)
· parity 테스트로 결과 대조 (r11)
· 한 주면 끝납니다
2단계 — 측정하고 보고
· 엔드투엔드 배수 · p99 · 메모리 (⚡ e10)
· 빌드·배포 비용도 함께 (🛠️ b08)
3단계 — 팀에 공유
· 두 번째 사람이 읽고 고칠 수 있는지 확인 ★
· 안 되면 여기서 멈추는 것이 옳습니다
4단계 — 범위 확대
· 인접한 핫스팟으로
· 공통 크레이트로 정리 (🛠️ b05)
★ 각 단계에서 되돌릴 수 있어야 합니다.
환경 변수 하나로 폴백되는 구조를 끝까지 유지하세요.
실무에선 이렇게 — 의사결정을 문서로 🔴 실무
결정 기록 템플릿 ★
# ADR-012: 로그 파서를 Rust 확장으로 교체
## 배경
일일 배치의 로그 파싱이 전체 42초 중 31초를 차지.
py-spy 프로파일 결과 `parse_line` 이 74% (측정: 2026-08-01, 2.3GB 로그).
## 검토한 대안
| 방안 | 예상 개선 | 비용 | 판단 |
|---|---|---|---|
| 정규식 최적화 | 1.3배 | 낮음 | 시도함, 부족 |
| numpy 벡터화 | 불가 | — | 문자열이라 부적합 |
| Cython | 5~10배 | 중간 | 유지보수자 1명 |
| **Rust + PyO3** | **20~60배** | 중간 | **채택** |
## 결정
`parse_line` 만 Rust 로 이전. 순수 파이썬 폴백 유지.
`LOGPARSE_NATIVE=0` 으로 즉시 되돌릴 수 있음.
## 결과 (2026-08-15 측정)
전체 42초 → 6.1초 (6.9배). p99 지연 편차도 감소.
CI 시간 +4분 (플랫폼 3종 빌드).
## 위험과 대응
- 유지보수자 2명 확보 (○○, □□) ← 조건이었음
- parity 테스트 CI 필수
- 6개월 뒤 재검토
되돌릴 때의 기준
★ 이럴 때는 솔직하게 되돌리세요
· 기대한 성능이 안 나오고 원인도 못 찾는다 (⚡ e10 점검표를 다 봤는데)
· 유지보수자가 다시 한 명이 됐다
· 빌드·배포 비용이 성능 이득보다 크다
· 버그가 반복되는데 디버깅이 안 된다 (🐍 y12)
★ 되돌리기가 쉬워야 이 판단을 할 수 있습니다
· 폴백 구현을 지우지 마세요
· 환경 변수 스위치를 유지하세요
· parity 테스트를 계속 돌리세요
→ 이 세 가지가 있으면 '되돌림' 이 실패가 아니라 선택이 됩니다 ★
★ 그리고 기록을 남기세요
왜 도입했고 왜 되돌렸는지가
다음 사람(또는 미래의 나)에게 가장 값진 자료입니다.
한 단계 더 — 여기서 어디로 갈까 🧬 전문가
더 깊이 들어가려면
★ 읽을 것
· The Rust Programming Language (공식 책) — 기초를 다시
· Rust for Rustaceans — 중급 이후의 공백을 메웁니다 ★
· Rust Atomics and Locks — 동시성 (d06)
· The Rustonomicon — unsafe (d01·d02)
· Rust Performance Book — 성능 (⚡ 탭)
· Rust API Guidelines — 라이브러리 설계 (🛠️ b02)
★ 읽을 코드
· std 의 Vec · Option · Iterator 구현
· crossbeam · bytemuck · rayon
· PyO3 · napi-rs 의 매크로 구현 (d09)
★ 만들어 볼 것
· 이 가이드의 예제를 실제로 빌드해 보기
· 자기 프로젝트의 핫스팟 하나를 옮겨 보기 (🐍 y01)
· 작은 크레이트를 crates.io 에 올려 보기 (🛠️ b02)
★ 이 가이드의 다른 탭
· 🔵 C++ 가이드 — 같은 목적을 C++ 로 (비교해 보면 이해가 깊어집니다)
· 🐍 파이썬 · ☕ 자바 · 🟨 JS·TS — 부르는 쪽의 관점
이 가이드의 요약 ★
다섯 원칙 (모든 탭에서 반복된 것)
① 경계를 넘는 횟수를 데이터 크기와 분리하라
배치로 묶고, 제로카피로 (🐍 y01·y06 · 🟨 j04)
② 좁고 안정된 경계를 만들어라
C ABI · abi3 · N-API · 함수 몇 개 (🔗 f01 · 🐍 y11 · 🟨 j01)
③ 측정하고 결정하라
--release · criterion · 엔드투엔드 (⚡ e01·e10)
④ 되돌릴 수 있게 하라
폴백 + parity 테스트 + 환경 변수 (🐍 y01·y12)
⑤ 안 쓰는 판단도 실력이다
대안을 먼저 세고, 팀 비용을 계산에 넣어라 (d12)
★ 그리고 Rust 특유의 것
· 소유권은 규칙이 아니라 설계 도구입니다 (🔐 o01)
· 컴파일러 에러는 잔소리가 아니라 조언입니다 (r01)
· 안전한 껍데기를 만드는 것이 unsafe 의 목적입니다 (d08)
언어를 배운 다음
실제로
무언가를 만들기
러스트는 표준 라이브러리가 일부러 작습니다 — JSON 도, HTTP 도, 로깅도 없습니다. 그래서 실무는 어떤 크레이트를 고르느냐가 절반입니다. 이 탭은 거의 모든 러스트 프로젝트가 결국 쓰게 되는 것들을, 고르는 기준과 함께 정리합니다.
생태계 지도 — 무엇을 고를 것인가
crates.io 에는 비슷한 크레이트가 열 개씩 있습니다. 대부분의 경우 사실상 표준이 정해져 있고, 그것을 아는 것만으로 선택 시간이 사라집니다.
크레이트를 고를 때 실제로 보는 것
| 항목 | 확인하는 법 |
|---|---|
| 최근 릴리스 | 1년 넘게 없으면 이유를 확인하세요 (완성된 것일 수도 있습니다) |
| 역의존성 수 | crates.io 의 "Dependents" — 가장 믿을 만한 신호입니다 |
| 버전이 0.x 인가 | 0.x 는 마이너 올림도 깨질 수 있습니다 |
| unsafe 사용량 | cargo geiger 로 확인 |
| 취약점 | cargo audit — CI 에 넣으세요 |
| 라이선스 | cargo deny 로 허용 목록을 강제 |
표준 라이브러리에 없어서 놀라는 것들
- 난수 —
rand크레이트가 필요합니다 - 정규식 —
regex - 날짜와 시간 —
std::time은 시각 계산만, 달력은chrono - JSON · HTTP · 로깅 — 전부 외부
- 비동기 런타임 —
async문법은 언어에, 실행기는 크레이트에
serde — 직렬화의 사실상 표준
JSON · YAML · TOML · MessagePack · CBOR 이 전부 같은 하나의 인터페이스 위에 있습니다. 구조체에 표기 한 줄을 붙이면 형식은 나중에 갈아 끼울 수 있습니다.
기본 — 표기 한 줄
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
id: u64,
name: String,
email: Option<String>, // 없어도 되는 필드
}
// 문자열 ↔ 구조체
let json = serde_json::to_string(&user)?;
let user: User = serde_json::from_str(&json)?;
// 형식을 바꾸려면 크레이트만 바꿉니다
let yaml = serde_yaml::to_string(&user)?;
let toml = toml::to_string(&user)?;
실무에서 반드시 쓰게 되는 속성
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] // 자바스크립트 쪽 관례에 맞춤
#[serde(deny_unknown_fields)] // 오타 난 필드를 에러로
struct Order {
#[serde(rename = "orderId")]
id: u64,
#[serde(default)] // 없으면 기본값
memo: String,
#[serde(skip_serializing_if = "Option::is_none")]
coupon: Option<String>, // None 이면 아예 안 내보냄
#[serde(skip)] // 직렬화 대상에서 제외
cache: Vec<u8>,
#[serde(with = "chrono::serde::ts_seconds")]
created_at: DateTime<Utc>, // 커스텀 변환
}
태그가 붙은 enum — API 설계의 핵심
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Event {
Created { id: u64 },
Updated { id: u64, field: String },
Deleted { id: u64 },
}
// {"type":"created","id":7}
// {"type":"updated","id":7,"field":"name"}
// match 로 받으면 빠뜨린 경우를 컴파일러가 잡아 줍니다
성능과 함정
- 제로카피 역직렬화 —
&'a str로 받으면 문자열을 복사하지 않습니다(입력이 살아 있어야 함) serde_json::Value는 느립니다 — 구조를 아는 데이터는 구조체로 받으세요- u64 정밀도 — 자바스크립트는 53비트까지만 정확합니다. 큰 ID 는 문자열로 내보내세요
- 빌드 시간 —
derive매크로가 많으면 컴파일이 느려집니다 - 스키마 진화 — 필드를 지우기 전에
#[serde(default)]로 한 버전 살려 두세요
CLI 만들기 — clap 으로 30분이면 됩니다
러스트가 가장 자주 쓰이는 자리 중 하나가 커맨드라인 도구입니다 — 의존성 없는 단일 바이너리, 빠른 시작, 크로스 컴파일이 전부 맞아떨어집니다.
구조체 하나가 곧 명령행 스펙
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "mytool", version, about = "파일을 정리합니다")]
struct Cli {
/// 자세한 로그를 출력합니다
#[arg(short, long)]
verbose: bool,
/// 설정 파일 경로
#[arg(short, long, default_value = "config.toml")]
config: PathBuf,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// 대상을 훑기만 합니다
Scan { path: PathBuf },
/// 실제로 정리합니다
Clean {
path: PathBuf,
#[arg(long)] dry_run: bool,
},
}
fn main() {
let cli = Cli::parse(); // 파싱 · 검증 · --help 생성이 전부 여기서
match cli.command { /* ... */ }
}
공짜로 따라오는 것들
--help·--version을 자동 생성 — 주석이 곧 도움말입니다- 타입 검증 —
PathBuf,u16,enum이 자동으로 파싱·검증됩니다 - 필수 인자 누락 시 친절한 에러 메시지
- 셸 자동완성 스크립트 생성 (
clap_complete) - 환경변수 대체 —
#[arg(env = "MYTOOL_CONFIG")]
CLI 를 제대로 만드는 규칙
| 규칙 | 이유 |
|---|---|
| 결과는 stdout | 파이프로 넘길 수 있어야 합니다 |
| 로그는 stderr | 결과와 섞이면 자동화가 깨집니다 |
| 종료 코드 | 성공 0 · 실패 1 이상 — 셸 스크립트가 읽습니다 |
--json 옵션 | 사람용과 기계용 출력을 분리 |
| 파괴적 작업 | --dry-run 을 기본으로 두는 것도 방법입니다 |
진행률과 색
indicatif(진행 바) · owo-colors(색) 을 씁니다.
출력이 터미널이 아닐 때는 색을 끄세요 —
파이프로 넘긴 로그에 제어 문자가 섞입니다.
에러를 사람이 읽게
anyhow 로 문맥을 쌓고, main 이
anyhow::Result 를 반환하게 하면
실패 경로가 자동으로 출력됩니다.
배포
cargo-dist 로 플랫폼별 바이너리와 설치 스크립트를 한 번에 만듭니다.
cargo install 은 쓰는 사람이 컴파일해야 해서 느립니다.
tokio — async 런타임 실전
러스트의 async 는 문법만 언어에 있고 실행기는 없습니다.
그 실행기가 tokio 이고, 서버 코드의 사실상 전부가 이 위에서 돕니다.
가장 자주 쓰는 도구 다섯
// ① 동시에 여러 개 — 전부 기다림
let (a, b) = tokio::join!(fetch_user(1), fetch_order(2));
// ② 먼저 끝나는 것만 — 나머지는 취소됩니다
tokio::select! {
res = fetch() => handle(res),
_ = tokio::time::sleep(Duration::from_secs(3)) => timeout(),
}
// ③ 타임아웃
let res = tokio::time::timeout(Duration::from_secs(2), fetch()).await??;
// ④ 백그라운드로 던지기 (JoinHandle 을 버리면 결과·패닉을 놓칩니다)
let h = tokio::spawn(async move { work().await });
let out = h.await?;
// ⑤ 블로킹 격리
let rows = tokio::task::spawn_blocking(|| heavy_cpu()).await?;
자주 밟는 함정
| 함정 | 결과 |
|---|---|
| await 를 안 붙임 | 아무 일도 안 일어납니다 — Future 는 폴링돼야 돕니다(경고가 나옵니다) |
std::sync::Mutex 를 await 너머로 | 워커가 막힙니다 — tokio::sync::Mutex 를 쓰세요 |
select! 취소 안전성 | 중간에 취소되면 상태가 어중간하게 남을 수 있습니다 |
런타임 안에서 block_on | 즉시 패닉하거나 데드락 |
| 태스크 누수 | spawn 한 것을 아무도 안 기다리면 패닉이 조용히 묻힙니다 |
async-std 용 크레이트를 섞으면
런타임이 두 개 뜨거나 패닉합니다. 크레이트를 고를 때
"tokio 호환"인지부터 확인하는 것이 러스트 비동기 생태계의 현실입니다.
axum — 웹 서버 한 파일로
tokio 팀이 만든 웹 프레임워크입니다. 매크로가 거의 없고 평범한 함수와 타입으로 라우트를 씁니다 — 그래서 에러 메시지가 읽히고, IDE 자동완성도 제대로 됩니다.
돌아가는 최소 서버
use axum::{Router, routing::get, Json, extract::{State, Path}};
use std::sync::Arc;
#[derive(Clone)]
struct AppState { db: Pool }
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let state = AppState { db: pool };
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/users/{id}", get(get_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn get_user(
State(st): State<AppState>,
Path(id): Path<u64>,
) -> Result<Json<User>, AppError> {
let user = repo::find(&st.db, id).await?;
Ok(Json(user))
}
추출기(extractor) — 인자가 곧 명세
| 타입 | 무엇을 꺼내나 |
|---|---|
Path<T> | 경로 변수 — /users/{id} |
Query<T> | 쿼리 문자열 — serde 로 구조체에 매핑 |
Json<T> | 본문 JSON — 파싱 실패는 자동으로 400 |
State<T> | DB 풀 · 설정 같은 공유 상태 |
HeaderMap | 헤더 전체 |
| 직접 만든 타입 | FromRequestParts 구현 — 인증 토큰 검증이 여기 |
Json 등)는
인자 목록의 마지막에 와야 합니다.
에러 타입 하나로 응답을 통일
#[derive(thiserror::Error, Debug)]
enum AppError {
#[error("찾을 수 없습니다")] NotFound,
#[error("권한이 없습니다")] Forbidden,
#[error(transparent)] Other(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (code, msg) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
AppError::Other(e) => {
tracing::error!("{e:?}"); // 내부 정보는 로그로만
(StatusCode::INTERNAL_SERVER_ERROR, "서버 오류".into())
}
};
(code, Json(serde_json::json!({ "error": msg }))).into_response()
}
}
tower 미들웨어 — 실서비스에 필요한 것
use tower_http::{trace::TraceLayer, cors::CorsLayer,
compression::CompressionLayer, timeout::TimeoutLayer};
let app = Router::new()
.route("/users/{id}", get(get_user))
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive()) // 운영에서는 출처를 좁히세요
.layer(TraceLayer::new_for_http()) // 요청 로그
.with_state(state);
.layer() 는 아래에서 위로 감쌉니다.
나중에 붙인 것이 바깥쪽이 되므로,
로깅은 가장 마지막에 붙여야 모든 요청을 기록합니다.
데이터베이스 — sqlx 와 그 대안들
러스트에는 JPA 같은 지배적 ORM 이 없습니다. 대신 쿼리를 직접 쓰되 컴파일 때 검증하는 방식이 가장 널리 쓰입니다.
sqlx — 쿼리를 컴파일 때 검증
SQL 을 그대로 쓰고, 빌드할 때 실제 DB 스키마에 대고 검사합니다. 컬럼 이름 오타나 타입 불일치가 컴파일 에러가 됩니다.
고르는 경우 — SQL 을 직접 통제하고 싶고, 쿼리가 복잡할 때. 가장 많이 쓰입니다.
SeaORM · Diesel
SeaORM 은 async 기반 ORM 으로 엔티티와 관계를 다룹니다. Diesel 은 타입 안전한 쿼리 빌더로 역사가 길고 성능이 좋습니다(동기 기반).
고르는 경우 — CRUD 가 대부분이고 관계 매핑을 자동화하고 싶을 때.
드라이버 직접
tokio-postgres · rusqlite 를 바로 씁니다.
가장 얇고 빠르지만 매핑을 전부 손으로 합니다.
고르는 경우 — 쿼리 몇 개뿐이거나, 극한 성능이 필요할 때.
sqlx 실제 사용
// 풀은 앱 시작에 한 번만 만들고 State 로 공유합니다
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(Duration::from_secs(3))
.connect(&db_url).await?;
// query_as! 는 컴파일 때 스키마와 대조합니다
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
id
).fetch_optional(&pool).await?;
// 트랜잭션
let mut tx = pool.begin().await?;
sqlx::query!("UPDATE accounts SET bal = bal - $1 WHERE id = $2", amt, from)
.execute(&mut *tx).await?;
sqlx::query!("UPDATE accounts SET bal = bal + $1 WHERE id = $2", amt, to)
.execute(&mut *tx).await?;
tx.commit().await?; // 드롭되면 자동 롤백됩니다
실무에서 챙길 것
| 항목 | 내용 |
|---|---|
| 오프라인 빌드 | cargo sqlx prepare 로 .sqlx 를 커밋 — CI 에 DB 가 없어도 빌드됩니다 |
| 마이그레이션 | sqlx migrate — 파일로 버전 관리하고 앱 시작 때 적용 |
| 풀 크기 | 기본값을 그대로 쓰지 마세요. DB 최대 연결 수 ÷ 인스턴스 수 |
| N+1 | ORM 이 없으니 자동으로 생기지는 않지만, 루프 안 쿼리는 여전히 문제입니다 |
| 타입 매핑 | NUMERIC 은 rust_decimal, 시간은 chrono 기능을 켜야 합니다 |
| 트랜잭션 누락 | commit() 을 안 부르고 드롭하면 조용히 롤백됩니다 — 반환값을 꼭 확인 |
? 로 일찍 반환하든, 패닉이 나든
커밋하지 않은 트랜잭션은 반드시 롤백됩니다 — 잊을 수가 없습니다.
reqwest — 외부 API 부르기
HTTP 클라이언트는 reqwest 하나가 사실상 표준입니다. 쓰는 법보다 중요한 것은 타임아웃 · 재시도 · 클라이언트 재사용 세 가지입니다.
클라이언트는 한 번 만들어 재사용합니다
// ❌ 요청마다 만들면 커넥션 풀과 TLS 세션을 매번 새로 만듭니다
// let res = reqwest::get(url).await?;
// ✅ 앱 시작에 한 번 만들어 공유합니다 (내부가 Arc 라 clone 이 쌉니다)
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5)) // 전체 시간 상한
.connect_timeout(Duration::from_secs(2)) // 연결까지의 상한
.pool_max_idle_per_host(16)
.user_agent("mytool/1.0")
.build()?;
let res: ApiResponse = client
.post("https://api.example.com/orders")
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send().await?
.error_for_status()? // 4xx · 5xx 를 에러로 바꿉니다
.json().await?;
놓치기 쉬운 것들
send()는 상태 코드를 보지 않습니다 — 404 도 성공으로 옵니다.error_for_status()를 꼭 붙이세요- 타임아웃 기본값이 없습니다 — 안 걸면 영원히 기다립니다
- 재시도는 직접 —
backoff나tower재시도 레이어를 씁니다. POST 는 멱등키 없이 재시도 금지 - 큰 응답은 스트리밍 —
bytes_stream()으로 받아야 메모리가 안 터집니다 - TLS 백엔드 —
rustls를 쓰면 시스템 OpenSSL 의존이 사라져 배포가 쉬워집니다
tracing — 로그와 관측
println! 로 시작한 로그는 반드시 한계에 부딪힙니다.
러스트의 표준은 log 가 아니라 tracing 입니다 —
구간(span) 개념이 있어 비동기 코드에서도 문맥이 유지됩니다.
기본 설정
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
fn init_log() {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,sqlx=warn".into()))
.with(fmt::layer().json()) // 운영은 JSON, 개발은 기본 포맷
.init();
}
// 쓰는 쪽 — 구조화된 필드로 남깁니다
tracing::info!(user_id = %id, amount, "결제를 시작합니다");
tracing::error!(error = ?e, "결제 실패");
// % = Display 로 출력 · ? = Debug 로 출력
RUST_LOG=debug,hyper=info 처럼 환경변수로 레벨을 모듈별로 조절합니다 —
재빌드 없이 바꿀 수 있습니다.
span — 비동기에서 문맥이 유지되는 이유
#[tracing::instrument(skip(pool), fields(order_id))]
async fn place_order(pool: &Pool, req: OrderReq) -> Result<Order> {
// 이 함수 안에서 나오는 모든 로그에 함수 이름과 인자가 붙습니다
let order = repo::insert(pool, &req).await?;
tracing::Span::current().record("order_id", order.id);
tracing::info!("주문 생성 완료");
Ok(order)
}
로그에 담아야 할 것
- 요청 ID · 추적 ID (분산 추적)
- 사용자·주문 같은 식별자
- 걸린 시간과 결과 코드
- 재시도 횟수 · 폴백 발동 여부
절대 담으면 안 되는 것
- 토큰 · 비밀번호 · API 키
- 주민번호 · 카드번호 전체
- 요청 본문 통째로 (개인정보가 섞입니다)
#[instrument(skip(...))]로 민감 인자를 빼세요
한 단계 더
tracing-opentelemetry— 분산 추적 연동metrics·prometheus— 지표tokio-console— 태스크가 막힌 곳을 실시간으로- 패닉도 로그로 —
std::panic::set_hook
설정 · 환경변수 · 시크릿
설정을 코드에 박아 두면 환경마다 빌드를 새로 해야 합니다.
러스트에는 스프링의 application.yml 같은 표준이 없으니,
규칙을 직접 정해야 합니다.
구조체 하나로 받아 검증까지
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Config {
#[serde(default = "default_port")]
port: u16,
database_url: String,
#[serde(default)]
log_level: String,
max_connections: u32,
}
fn default_port() -> u16 { 3000 }
fn load() -> anyhow::Result<Config> {
// 우선순위 — 기본값 → 파일 → 환경변수
let cfg = config::Config::builder()
.add_source(config::File::with_name("config").required(false))
.add_source(config::Environment::with_prefix("APP").separator("__"))
.build()?
.try_deserialize()?;
Ok(cfg)
}
// APP__DATABASE_URL · APP__PORT 로 덮어씁니다
설계 규칙
| 규칙 | 이유 |
|---|---|
| 시작할 때 한 번에 읽고 검증 | 잘못된 설정으로 뜨느니 안 뜨는 편이 낫습니다 |
| 필수 항목에 기본값 금지 | DB 주소에 기본값을 두면 운영에서 개발 DB 를 봅니다 |
| 우선순위를 문서로 | "왜 이 값이 적용됐지"를 추적할 수 있어야 합니다 |
| Debug 에 시크릿 금지 | secrecy 크레이트나 직접 Debug 구현으로 마스킹 |
| .env 는 개발에서만 | dotenvy. 운영은 오케스트레이터의 시크릿을 씁니다 |
| 기능 플래그 | 설정으로 껐다 켜면 롤백이 배포보다 빠릅니다 |
gitleaks 를 pre-commit 훅과 CI 양쪽에 걸어 두세요.
테스트 전략 — 언어가 도와주는 만큼
러스트는 타입 시스템이 많은 버그를 미리 잡아 줍니다. 그래서 테스트의 초점이 "널일까 아닐까"가 아니라 로직 · 경계 · 통합으로 옮겨 갑니다.
기본 형태
// src/parser.rs 안에 그대로 둡니다
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn 빈_입력은_에러다() {
assert!(parse("").is_err());
}
#[test]
fn 정상_입력을_파싱한다() {
let r = parse("a=1").unwrap();
assert_eq!(r.get("a"), Some(&1));
}
#[tokio::test] // async 테스트
async fn 저장하고_다시_읽는다() {
let repo = Repo::in_memory();
repo.save(&item).await.unwrap();
assert_eq!(repo.find(1).await.unwrap(), Some(item));
}
}
테스트 이름을 한글로 써도 됩니다 — 실패했을 때 무엇이 깨졌는지 바로 읽힙니다.
알아 두면 좋은 도구
| 도구 | 쓰는 곳 |
|---|---|
cargo nextest | 훨씬 빠른 실행 · 테스트별 프로세스 격리 |
insta | 스냅샷 테스트 — 출력이 긴 것에 편합니다 |
proptest | 속성 기반 — 반례를 자동으로 줄여 줍니다 |
mockall | 트레이트 목 생성 |
testcontainers | 실제 DB · Kafka 를 도커로 띄워 통합 테스트 |
criterion | 벤치마크 — 통계와 회귀 감지 포함 |
| 문서 테스트 | 주석 안 예제 코드가 자동으로 테스트됩니다 |
/// 주석 안의 코드 블록은
cargo test 가 실제로 컴파일하고 실행합니다 —
문서가 낡아서 안 도는 예제가 되는 일이 구조적으로 없어집니다.
배포 — 바이너리 하나로 끝내기
러스트의 가장 실용적인 장점 중 하나가 런타임이 필요 없다는 것입니다. JVM 도, 파이썬 인터프리터도, node_modules 도 없이 파일 하나를 옮기면 됩니다.
컨테이너 — 다단계 빌드
# 1단계 — 빌드
FROM rust:1-slim AS builder
WORKDIR /app
# 의존성만 먼저 빌드해 캐시를 살립니다
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main(){}" > src/main.rs && \
cargo build --release && rm -rf src
COPY . .
RUN touch src/main.rs && cargo build --release
# 2단계 — 실행 (수십 MB)
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/myapp /myapp
USER nonroot
ENTRYPOINT ["/myapp"]
cargo-chef 를 쓰면 위 캐시 트릭을 더 정확하게 합니다.
CI 빌드 시간이 크게 줄어듭니다.
정적 링크 — 이미지를 몇 MB 로
# musl 타깃으로 빌드하면 libc 의존이 사라집니다
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# scratch 이미지에 바이너리만 넣을 수 있습니다
# FROM scratch
# COPY myapp /myapp
jemallocator 나 mimalloc 을 함께 쓰세요 —
그냥 바꾸면 처리량이 눈에 띄게 떨어질 수 있습니다.
| 항목 | 내용 |
|---|---|
| 헬스체크 엔드포인트 | /health(살아 있나) 와 /ready(트래픽 받을 준비) 를 분리하세요 |
| 정상 종료 | SIGTERM 을 받으면 새 요청은 거절하고 진행 중인 것만 끝냅니다 — axum::serve(...).with_graceful_shutdown() |
| 패닉 처리 | 기본은 그 태스크만 죽습니다. 프로세스를 죽이고 재시작하는 편이 안전한 경우가 많습니다 |
| 메모리 상한 | 컨테이너 제한을 걸고 실제 사용량을 지표로 보세요 — 러스트도 누수가 없지는 않습니다 |
| 바이너리 크기 | strip = true · opt-level = "z" · panic = "abort" 로 크게 줄어듭니다 |
| 크로스 컴파일 | cross 또는 cargo-zigbuild — 맥에서 리눅스 바이너리를 만들 때 편합니다 |
| CI 캐시 | Swatinem/rust-cache 액션 — 빌드 시간이 절반 이하가 됩니다 |
목적별 크레이트 찾기
"이걸 하려면 뭘 써야 하지?"의 답을 모았습니다. 아래 표에서 하려는 일을 찾아 그대로 쓰세요.
| 하려는 일 | 크레이트 | 메모 |
|---|---|---|
| JSON · YAML · TOML | serde + serde_json 등 | 사실상 유일한 선택 |
| 에러 타입 정의 (라이브러리) | thiserror | enum 에 표기만 붙입니다 |
| 에러 문맥 쌓기 (앱) | anyhow | 라이브러리에는 쓰지 마세요 |
| 커맨드라인 파싱 | clap | derive 기능을 켜세요 |
| 비동기 런타임 | tokio | 기능 플래그를 필요한 것만 |
| 웹 서버 | axum · actix-web | tokio 를 쓰면 axum |
| HTTP 클라이언트 | reqwest | rustls 기능 권장 |
| 데이터베이스 | sqlx · sea-orm | SQL 직접 / ORM |
| Redis | redis · fred | 비동기 지원 확인 |
| Kafka | rdkafka | C 라이브러리 래퍼 — 빌드 의존성 주의 |
| 로깅 · 추적 | tracing + tracing-subscriber | 운영은 JSON 포맷 |
| 지표(메트릭) | metrics · prometheus | 익스포터를 함께 |
| 병렬 처리 | rayon | CPU 작업 전용 — IO 에는 쓰지 마세요 |
| 날짜와 시간 | chrono · time | DB 연동 기능 플래그 확인 |
| 정규식 | regex | 선형 시간 보장 — 역참조는 없습니다 |
| 난수 | rand | 암호용은 rand::rngs::OsRng |
| 해시 · 암호 | sha2 · argon2 · ring | 비밀번호는 반드시 argon2·bcrypt |
| JWT | jsonwebtoken | 만료·서명 검증을 끄지 마세요 |
| UUID | uuid | v4 또는 정렬 가능한 v7 |
| 임의 정밀도 · 통화 | rust_decimal | 돈 계산에 f64 금지 |
| CSV · 엑셀 | csv · calamine · rust_xlsxwriter | 읽기 / 쓰기가 다른 크레이트 |
| 이미지 처리 | image | 큰 이미지는 메모리 확인 |
| 템플릿 | askama · minijinja | 컴파일 검증 / 런타임 |
| 테스트 실행기 | cargo-nextest | 기본 실행기보다 훨씬 빠릅니다 |
| 벤치마크 | criterion | 통계와 회귀 감지 포함 |
| 의존성 점검 | cargo-audit · cargo-deny | CI 에 넣으세요 |
| 바이너리 배포 | cargo-dist | 설치 스크립트까지 만들어 줍니다 |
| WASM 빌드 | wasm-bindgen · wasm-pack | Node 애드온 탭 참고 |
| 파이썬 확장 | pyo3 + maturin | 파이썬 모듈 탭 참고 |
| C 라이브러리 바인딩 | bindgen · cbindgen | 가져오기 / 내보내기 |
크레이트를 늘리기 전에 물어볼 것
- 표준 라이브러리로 열 줄이면 되는 일은 아닌가
- 이미 들어와 있는 크레이트가 같은 기능을 제공하지는 않나
- 이것 하나 때문에 의존성이 몇 개 늘어나나 (
cargo tree) - 빌드 시간과 바이너리 크기에 얼마나 영향이 있나
- 버전 0.x 라면 깨지는 변경을 감당할 수 있나
가이드를 끝까지 읽으셨습니다
러스트를 실무에 쓰는 이유는 결국 두 가지로 모입니다 — 런타임 없이 배포되는 단일 바이너리, 그리고 메모리·동시성 버그가 빌드에서 막힌다는 것. 배우는 비용은 분명히 있지만, 그 비용은 처음 한 번이고 얻는 것은 서비스가 사는 내내 돌아옵니다. 막힐 때는 Ctrl K 검색으로 필요한 섹션을 바로 찾아 쓰세요.
아무것도 없는 PC 를
Rust 개발 기계로
바꾸는 두 시간
이 탭은 운영체제만 깔린 빈 PC 를 가정합니다. 컴파일러도, 링커도, 에디터도 없습니다.
그 상태에서 어디서 무엇을 받고, 무엇을 체크하고, PATH 에 무엇이 들어가고,
확인 명령은 무엇이며, 실패하면 어떤 화면이 뜨는지를 한 줄씩 따라갑니다.
Rust 는 설치가 쉬운 언어로 알려져 있지만 그건 리눅스 이야기이고,
윈도우에서는 링커 하나 때문에 첫날을 통째로 날리는 사람이 매년 나옵니다.
이 탭을 마치면 새 노트북을 받아도 30분 안에 cargo run 이 도는 상태를 만들 수 있습니다.
시작과 끝은 rustup 하나입니다 — 다른 방법은 쓰지 마세요
파이썬은 설치 방법이 열 가지쯤 됩니다(공식 설치본 · 배포판 패키지 · pyenv · conda · uv…).
그래서 “내 파이썬이 어디 있는지” 를 평생 헷갈립니다. Rust 는 그 문제를 처음부터 없앴습니다 —
공식 설치 방법은 rustup 하나이고, 컴파일러 · 패키지 매니저 · 문서 · 포매터가
전부 그 안에 들어 있습니다. 이 섹션에서 받는 곳 · 설치 화면 · 확인 명령까지 끝냅니다.
어디서 받나 — 주소는 하나입니다
공식 주소 https://rustup.rs ★ 이 주소만 기억하면 됩니다
문서 https://www.rust-lang.org/tools/install
윈도우 → 페이지가 자동으로 rustup-init.exe 내려받기 버튼을 보여 줍니다
64비트 인텔·AMD : rustup-init.exe (x86_64)
ARM 윈도우(예: Snapdragon X) : aarch64 용 rustup-init.exe
mac → 한 줄 명령 (인텔 · 애플 실리콘 공통)
리눅스 → 한 줄 명령 (배포판 무관)
# 내려받은 파일이 진짜인지 확인하고 싶다면
# rustup-init.exe 는 Rust 재단 서명이 붙어 있습니다.
# 파일 속성 → 디지털 서명 탭에 "Rust Foundation" 이 보이면 정상입니다.
rust-lang.org 를 흉내 낸 광고 도메인이
주기적으로 나타납니다. 주소창에 rustup.rs 를 직접 치는 것이 가장 안전합니다.
사내망이라 접속이 막힌다면 s07 의 프록시·미러 설정을 먼저 보세요.
받아서 실행하기 — OS 별 한 줄
■ Windows
1) https://rustup.rs 에서 rustup-init.exe 다운로드
2) 더블클릭 (관리자 권한 필요 없습니다 ★ 사용자 폴더에만 씁니다)
3) 검은 콘솔 창이 뜹니다 → 아래 s02 의 화면 설명을 그대로 따라갑니다
# ■ macOS · Linux — 공식 한 줄
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# --proto '=https' HTTPS 아닌 연결로 떨어지지 않게 강제
# --tlsv1.2 구식 TLS 거부
# -sSf 조용히(-s) 하되 에러는 보여 주고(-S) 실패면 종료(-f)
# | sh 받은 스크립트를 그대로 실행
# "파이프로 스크립트 실행"이 불안하면 — 내려받아 읽고 실행해도 똑같습니다
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o rustup-init.sh
less rustup-init.sh # 눈으로 확인
sh rustup-init.sh
~/.rustup · ~/.cargo). 회사 PC 에서 관리자 계정을 못 받아도
Rust 는 깔립니다. 반대로 sudo 를 붙여 설치하면 나중에 권한 문제가 생깁니다(s11).
이렇게도 쓸 수 있다 — 채널이라는 개념: stable · beta · nightly 🟡 중급
6주마다 한 칸씩 밀려 내려옵니다
| 채널 | 정체 | 언제 쓰나 |
|---|---|---|
| stable | 6주마다 나오는 정식판. 하위 호환을 깨지 않겠다는 약속이 붙습니다 | 기본값. 99% |
| beta | 6주 뒤 stable 이 될 것. 미리 CI 에 물려 회귀를 잡습니다 | 라이브러리 관리자 |
| nightly | 매일 밤 빌드. 불안정 기능을 켤 수 있는 유일한 채널 | 매크로·컴파일러 실험 |
| 1.89.0 | 정확한 버전 고정. 재현이 필요한 배포·논문·CI | 운영 서비스 |
# 채널 다루기
rustup show # 지금 뭐가 깔렸고 뭐가 기본인지 ★ 제일 자주 씁니다
rustup update # 깔린 채널 전부 최신으로
rustup toolchain install nightly
rustup default stable # 전역 기본값 지정
# 이번 한 번만 다른 채널로 (+ 문법)
cargo +nightly build
rustc +nightly --version
2026년 8월 기준 버전 감각
최신 안정판 Rust 1.89 대 (1.8x 번호가 계속 올라갑니다)
릴리스 주기 6주 — "1.89 다음은 1.90" 처럼 마이너만 오릅니다
에디션 2015 / 2018 / 2021 / 2024
최신 툴체인의 cargo new 는 edition = "2024" 를 넣습니다
※ 에디션과 컴파일러 버전은 다른 축입니다 (🦀 r01)
버전 번호를 읽는 법
1.89.0
│ │ └ 패치 (보안·회귀 수정)
│ └── 마이너 = 사실상의 "버전". 6주마다 +1
└──── 메이저. Rust 2.0 은 계획에 없습니다 ★
rust-toolchain.toml 로 잠급니다(s08).
실무에선 이렇게 — 설치 직후 30초 점검표 🔴 실무
이 다섯 줄이 다 답하면 설치는 끝난 것입니다
# ① 컴파일러가 PATH 에 잡히나
rustc --version
# rustc 1.89.0 (abcdef012 2026-06-25) ← 이런 줄이 나오면 성공
# ② 패키지 매니저
cargo --version
# cargo 1.89.0 (1234abcd 2026-06-18)
# ③ 관리자 자신과 현재 툴체인 전체 상태 ★ 가장 정보량이 많습니다
rustup show
# installed toolchains
# --------------------
# stable-x86_64-pc-windows-msvc (default)
# active toolchain
# ----------------
# stable-x86_64-pc-windows-msvc (default)
# rustc 1.89.0
# ④ 실제로 기계어까지 만들어지나 — 링커까지 확인하는 유일한 방법 ★★
cargo new hello && cd hello && cargo run
# Compiling hello v0.1.0
# Finished dev profile
# Running `target\debug\hello.exe`
# Hello, world!
# ⑤ 문서가 오프라인으로 열리나 (비행기·폐쇄망에서 생명줄입니다)
rustup doc --std # 표준 라이브러리 문서를 브라우저로
rustc --version 은
링커가 없어도 잘 나옵니다. 컴파일러가 기계어 조각(.o)을 만드는 것과
그것을 실행 파일로 이어 붙이는(link) 것은 다른 프로그램의 일이기 때문입니다.
윈도우에서 첫날을 날리는 사람은 대부분 ③ 에서 “설치 성공”이라 믿고 ④ 에서
link.exe not found 를 만납니다. 반드시 ④ 까지 하세요.
한 단계 더 — rustup 이 디스크에 실제로 만드는 것 🧬 전문가
폴더 두 개가 전부입니다
~/.rustup/ (윈도우: %USERPROFILE%\.rustup)
├─ toolchains/
│ ├─ stable-x86_64-pc-windows-msvc/
│ │ ├─ bin/ rustc.exe cargo.exe rustdoc.exe ← 진짜 실행 파일
│ │ └─ lib/rustlib/ 표준 라이브러리 · 타깃별 .rlib
│ └─ nightly-x86_64-pc-windows-msvc/
├─ downloads/ 받다 만 파일 (지워도 됩니다)
└─ settings.toml 기본 툴체인 · 디렉터리별 override 기록
~/.cargo/ (윈도우: %USERPROFILE%\.cargo)
├─ bin/ rustc cargo rustup clippy-driver rustfmt … ← shim ★ PATH 에 넣는 건 여기
│ + cargo install 로 깐 도구들도 여기로 들어옵니다 (s06)
├─ registry/ crates.io 에서 받은 크레이트 소스 캐시 (수 GB 까지 큽니다)
├─ git/ git 의존성 체크아웃
└─ config.toml 레지스트리 · 링커 · 프록시 설정 (s07 · s09)
# 크기가 궁금할 때
du -sh ~/.cargo ~/.rustup # 보통 합쳐서 2~6 GB
shim — 이름은 같지만 진짜가 아닌 파일
# ~/.cargo/bin/rustc 는 컴파일러가 아닙니다.
# "지금 이 폴더에서 어떤 툴체인을 써야 하지?" 를 판단해서
# ~/.rustup/toolchains/<선택된 것>/bin/rustc 로 넘기는 대리인입니다.
# 진짜가 어디 있는지 보기
rustup which rustc
# C:\Users\me\.rustup\toolchains\stable-…\bin\rustc.exe
rustup which --toolchain nightly cargo
# 그래서 이런 일이 가능합니다
cd ~/projA && rustc --version # 1.89.0 (rust-toolchain.toml 없음 → 기본값)
cd ~/projB && rustc --version # 1.75.0 (rust-toolchain.toml 이 고정)
# 디렉터리별 강제 지정 (파일을 안 만들고)
rustup override set nightly # 이 폴더는 앞으로 nightly
rustup override list
rustup override unset
rustup default ②
폴더 override ③ rust-toolchain.toml. 순서대로 우선순위가 거꾸로입니다
(③ 이 가장 셈). 자세한 우선순위 표는 s08 에 있습니다.
rustup.rs 에서 받아 실행하고, cargo new hello && cargo run 이 “Hello, world!” 를 찍을 때까지가 설치입니다.
rustc --version 만 보고 끝내지 마세요 — 그건 링커를 확인하지 않은 상태입니다.
채널은 고민할 것 없이 stable, 버전 고정이 필요하면 채널이 아니라 rust-toolchain.toml 을 씁니다.Windows — 링커 하나 때문에 첫날이 날아갑니다
Rust 는 윈도우에서 마이크로소프트의 링커(link.exe) 를 빌려 씁니다.
직접 만들지 않고 빌려 쓰는 이유는 윈도우 시스템 라이브러리(kernel32 · user32 …)와
정확히 같은 규약으로 이어 붙여야 하기 때문입니다.
그래서 “Rust 만 깔면 되는 거 아니었나” 라는 기대가 여기서 깨집니다.
이 섹션은 무엇을 · 어디서 · 얼마나 받아야 하는지를 화면 단위로 짚습니다.
rustc --version 은 멀쩡합니다.rustup-init.exe 를 켜면 나오는 첫 화면
Rust Visual C++ prerequisites
Rust requires a linker and Windows API libraries but they don't seem
to be available. These components can be acquired through a Visual
Studio installer.
1) Quick install via the Visual Studio Community installer
(free for individuals, non-enterprise use)
2) Manually install the prerequisites
(for enterprise and advanced users)
3) Don't install the prerequisites
(if you're targeting the GNU ABI)
>
★ 무엇을 고르나
개인 노트북 → 1 (설치 관리자를 대신 띄워 줍니다. 가장 쉬움)
회사 PC · 라이선스 → 2 (Build Tools 를 따로 받습니다 — 아래 카드)
GNU 툴체인 쓸 것 → 3 (그 대신 s02 뒤쪽 GNU 설명을 반드시 읽으세요)
Build Tools 를 직접 받는 경로
① https://visualstudio.microsoft.com/downloads/
페이지 아래쪽 "Tools for Visual Studio" 를 펼칩니다
→ "Build Tools for Visual Studio 2022" 다운로드
② 설치 관리자가 뜨면 워크로드 화면에서
☑ C++ 를 사용한 데스크톱 개발
(영문: Desktop development with C++)
③ 오른쪽 "설치 세부 정보" 에서 이 두 개가 체크됐는지 확인 ★
☑ MSVC v143 - VS 2022 C++ x64/x86 빌드 도구
☑ Windows 11 SDK (Win10 이면 Windows 10 SDK)
※ 이 둘만 있으면 됩니다. CMake · MFC · ATL 등은 필요 없습니다.
최소 구성으로 약 3 GB, 기본 권장 구성이면 6~7 GB 입니다.
④ 설치 후 재부팅은 보통 필요 없지만, 터미널은 반드시 새로 여세요
이렇게도 쓸 수 있다 — 명령 한 줄로 끝내기 (winget) 🟡 중급
Windows 10/11 에 기본 내장된 winget
:: 관리자 PowerShell 에서
:: ① 빌드 도구 — 워크로드까지 지정해 한 번에
winget install --id Microsoft.VisualStudio.2022.BuildTools ^
--override "--quiet --wait --norestart ^
--add Microsoft.VisualStudio.Workload.VCTools ^
--includeRecommended"
:: ② rustup
winget install --id Rustlang.Rustup
:: ③ 새 터미널을 열고 확인
rustc --version
cargo --version
:: winget 이 없다면 (구형 Win10)
:: Microsoft Store 에서 "앱 설치 관리자" 를 업데이트하면 생깁니다
--includeRecommended 가 필요한가 — 워크로드만 지정하면
Windows SDK 가 빠지는 경우가 있습니다. SDK 가 없으면 링커는 있는데
kernel32.lib 를 못 찾아 다른 에러가 납니다(s11 표 참고).
MSVC 툴체인 vs GNU 툴체인
| msvc (기본) | gnu | |
|---|---|---|
| 타깃 이름 | x86_64-pc-windows-msvc | x86_64-pc-windows-gnu |
| 링커 | MS link.exe | MinGW-w64 ld |
| 추가 설치 | Build Tools 필요 | rustup 이 같이 받음 |
| C 라이브러리 | MSVCRT (윈도우 표준) | MinGW 런타임 DLL |
| 디버깅 | PDB · Visual Studio · WinDbg | GDB |
| C/C++ 크레이트 | 거의 전부 지원 | 일부 빌드 실패 |
| 배포 파일 | 단일 exe | DLL 을 같이 줘야 할 때가 있음 |
# GNU 툴체인으로 가야만 하는 상황이라면
rustup toolchain install stable-x86_64-pc-windows-gnu
rustup default stable-x86_64-pc-windows-gnu
rustc --print cfg | findstr target_env # target_env="gnu" 확인
openssl-sys· rdkafka 처럼
C 코드를 끼고 있는 크레이트가 gnu 에서만 깨지는 일이 흔합니다.
실무에선 이렇게 — PATH 는 어디에 무엇이 들어가는가 🔴 실무
PATH 에 넣어야 하는 것은 딱 하나
%USERPROFILE%\.cargo\bin
→ 실제 경로 예: C:\Users\hong\.cargo\bin
이 폴더 하나면 rustc · cargo · rustup · clippy · rustfmt 가
전부 잡힙니다 (전부 shim 이기 때문입니다 — s01)
■ GUI 로 넣기
Win + R → sysdm.cpl → [고급] → [환경 변수]
→ 사용자 변수의 Path → [편집] → [새로 만들기]
→ %USERPROFILE%\.cargo\bin 입력 → 확인
※ 시스템 변수가 아니라 "사용자 변수" 입니다 (관리자 권한 불필요)
■ PowerShell 로 영구 반영 (한 줄)
[Environment]::SetEnvironmentVariable(
"Path",
[Environment]::GetEnvironmentVariable("Path","User") + ";$env:USERPROFILE\.cargo\bin",
"User")
■ 지금 열린 창에서만 임시로
$env:Path += ";$env:USERPROFILE\.cargo\bin"
확인 · 문제 진단
:: 무엇이 실제로 실행되는지 — 여러 개면 여러 줄이 나옵니다 ★
where cargo
where rustc
:: C:\Users\hong\.cargo\bin\cargo.exe ← 정상
:: C:\Program Files\Rust\bin\cargo.exe ← 옛 설치가 남아 있음!
:: PATH 를 한 줄씩 보기 (PowerShell)
$env:Path -split ';'
:: 링커가 잡히는지 — 개발자 프롬프트 밖에서는 보통 안 잡힙니다
where link.exe
:: (아무것도 안 나와도 정상입니다 — 아래 설명)
link.exe 를 PATH 에 넣을 필요는 없습니다 —
rustc 는 레지스트리와 vswhere.exe 로 Visual Studio 설치 위치를
스스로 찾아 필요한 환경 변수를 그때그때 만들어 씁니다.
그래서 “개발자 명령 프롬프트”를 굳이 열 필요가 없습니다.
못 찾을 때만 개발자 프롬프트에서 빌드해 보면 원인이 갈립니다(s11).
한 단계 더 — 회사 PC · 폐쇄망 · 옛 설치 정리 🧬 전문가
설치 위치를 옮겨야 할 때
:: 홈 폴더가 네트워크 드라이브(로밍 프로필)면 빌드가 지옥처럼 느립니다.
:: 두 환경 변수로 위치를 통째로 옮길 수 있습니다.
setx RUSTUP_HOME "D:\rust\.rustup"
setx CARGO_HOME "D:\rust\.cargo"
:: 이 상태에서 rustup-init.exe 를 실행하면 D: 에 깔립니다.
:: PATH 에도 D:\rust\.cargo\bin 을 넣어야 합니다.
:: 이미 깔린 뒤라면 — 폴더를 통째로 옮기고 변수만 바꾸면 됩니다
:: (rustup 은 절대 경로를 파일에 박아 두지 않습니다)
:: 한글 사용자 이름 때문에 문제가 생길 때도 같은 방법이 답입니다 (s11)
setx 는 지금 열린 창에 반영되지 않습니다. 값을 넣은 뒤
새 터미널에서 echo %CARGO_HOME% 로 확인하세요.
또 setx 는 값 길이가 1024자를 넘으면 잘라 버립니다 —
PATH 자체를 setx 로 건드리지 말고 GUI 나 위의 .NET 방식을 쓰세요.
깨끗하게 지우고 다시 깔기
:: ① rustup 이 자기 자신과 툴체인을 전부 지웁니다
rustup self uninstall
:: ② 남은 흔적 확인 (있으면 손으로 삭제)
dir %USERPROFILE%\.rustup
dir %USERPROFILE%\.cargo
:: ~/.cargo/bin 에 cargo install 로 깐 도구가 남아 있을 수 있습니다
:: ③ PATH 에서 옛 항목 제거
:: 특히 "C:\Program Files\Rust*" — 아주 옛날 MSI 설치본입니다.
:: chocolatey · scoop 로 깐 rust 도 여기서 충돌합니다:
choco uninstall rust
scoop uninstall rust
:: ④ 다시 rustup-init.exe
:: ★ 재설치 전에 이것부터 시도해 보세요 — 대개 이걸로 해결됩니다
rustup self update
rustup update
rustup component add rust-src rust-analyzer
choco · scoop ·
apt · brew 전부 마찬가지입니다.
버전이 낡고, rustup 의 채널 전환이 안 되며, rustup component add
가 실패합니다. 공식 경로는 rustup 하나라는 원칙을 지키는 것이 결국 가장 빠릅니다.
%USERPROFILE%\.cargo\bin 하나만 들어가면 됩니다.
확인은 언제나 cargo new → cargo run 까지입니다.macOS · Linux — 한 줄로 끝나지만 두 가지를 더 봐야 합니다
맥과 리눅스에서는 설치 명령이 정말 한 줄입니다. 다만 C 링커와 몇몇 시스템 라이브러리는
Rust 가 주지 않습니다 — 윈도우의 link.exe 문제와 정확히 같은 종류의 문제가
이름만 바꿔 나타납니다. 그리고 배포판 패키지로 Rust 를 깔면 나중에 반드시 후회합니다.
그 두 가지를 여기서 미리 막습니다.
source ~/.cargo/env 한 줄이 가장 자주 빠뜨리는 단계입니다.macOS — 두 줄이면 끝납니다
# ① 애플의 명령행 개발 도구 (clang · ld · 헤더) ★ 먼저
xcode-select --install
# 창이 하나 뜹니다 → [설치] 클릭 → 2~10분 (약 1~2 GB)
# Xcode 전체(15 GB)를 받을 필요는 없습니다. CLT 만으로 충분합니다.
# 이미 있는지 확인
xcode-select -p
# /Library/Developer/CommandLineTools ← 이렇게 나오면 OK
clang --version
# ② rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
# ③ 확인 — 애플 실리콘이면 aarch64-apple-darwin 이 나옵니다
rustc --version
rustc -vV | grep host
# host: aarch64-apple-darwin (M 시리즈)
# host: x86_64-apple-darwin (인텔 맥)
sudo rm -rf /Library/Developer/CommandLineTools 뒤
xcode-select --install 로 다시 깔면 대부분 풀립니다.
Xcode 도 같이 깔려 있다면 sudo xcode-select --switch /Applications/Xcode.app.
Linux — 배포판별로 먼저 깔 것
# ■ Debian · Ubuntu
sudo apt update
sudo apt install -y build-essential curl pkg-config libssl-dev
# build-essential = gcc · g++ · make · libc 헤더 ★ 링커가 여기 있습니다
# ■ Fedora · RHEL · Rocky
sudo dnf install -y gcc gcc-c++ make curl pkgconf-pkg-config openssl-devel
# 또는 sudo dnf groupinstall "Development Tools"
# ■ Arch
sudo pacman -S --needed base-devel curl pkgconf openssl
# ■ Alpine (컨테이너)
apk add --no-cache build-base curl pkgconfig openssl-dev musl-dev
# ■ openSUSE
sudo zypper install -t pattern devel_basis
sudo zypper install libopenssl-devel pkg-config
# 그다음 공통
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
cargo new hello && cd hello && cargo run
이렇게도 쓸 수 있다 — 배포판 패키지로 깔면 안 되는 이유 🟡 중급
❌ apt install rustc cargo
sudo apt install rustc cargo
rustc --version
# rustc 1.75.0 ← 배포판이 얼려 둔 옛 버전
# 여기서 시작되는 연쇄 문제
cargo add axum
# error: package `axum` requires rustc 1.80 or newer
# (crates.io 의 최신 크레이트가 최신 rustc 를 요구합니다)
rustup component add clippy
# error: rustup is not installed ← rustup 자체가 없음
rustup default nightly
# command not found
# 게다가 apt 로 rustup 을 깔면 더 꼬입니다
sudo apt install rustup # 배포판마다 동작이 다릅니다
which cargo # /usr/bin/cargo ← 시스템 것이 앞에 잡힘
# ~/.cargo/bin 의 shim 이 뒤로 밀려 채널 전환이 조용히 무시됩니다 ★
✅ rustup 으로만 관리
# 혹시 이미 깔았다면 먼저 지웁니다
sudo apt remove --purge rustc cargo rust-gdb rust-lldb
hash -r # 셸의 명령 캐시 비우기
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
which -a cargo # ~/.cargo/bin/cargo 만 나와야 합니다 ★
rustc --version # 최신 stable
# 원할 때 무엇이든 됩니다
rustup update
rustup toolchain install nightly
rustup component add clippy rustfmt rust-src rust-analyzer
rustup target add wasm32-unknown-unknown
rust:1.89 이미지는 내부적으로 rustup 을 씁니다.
실무에선 이렇게 — 자주 걸리는 시스템 라이브러리 🔴 실무
이 표에 있는 것만 미리 깔면 90% 가 막힙니다
| 막히는 크레이트 | 필요한 것 | Debian/Ubuntu 패키지 |
|---|---|---|
openssl-sys | OpenSSL 헤더 | libssl-dev + pkg-config |
*-sys 전반 | 라이브러리 위치 탐색기 | pkg-config |
libsqlite3-sys | SQLite (또는 번들 기능) | libsqlite3-dev |
pq-sys (Postgres) | libpq | libpq-dev |
bindgen 계열 | libclang | libclang-dev |
rdkafka | C 컴파일러 · cmake | build-essential cmake |
| GUI (egui · tauri) | X11/Wayland · GTK | libgtk-3-dev 등 |
| 링크 자체 | C 링커 | build-essential |
rustls 기능을 지원합니다.
reqwest = { version = "0.12", default-features = false,
features = ["rustls-tls", "json"] } 처럼 쓰면 순수 Rust TLS 를 써서
시스템 OpenSSL 이 아예 필요 없어집니다. 컨테이너 이미지도 훨씬 작아집니다.
셸 설정 — 무엇이 어디에 추가되나
# rustup 이 만들어 주는 파일
cat ~/.cargo/env
# #!/bin/sh
# case ":${PATH}:" in
# *:"$HOME/.cargo/bin":*) ;;
# *) export PATH="$HOME/.cargo/bin:$PATH" ;;
# esac
# 그리고 아래 파일들 끝에 한 줄이 붙습니다 (있는 것만)
tail -2 ~/.bashrc ~/.zshenv ~/.profile 2>/dev/null
# . "$HOME/.cargo/env"
# ★ 반영이 안 될 때 확인 순서
echo $PATH | tr ':' '\n' | head # .cargo/bin 이 앞에 있나
type -a cargo # 어떤 게 잡히나
ls -l $(which cargo) # shim 인가 시스템 것인가
# zsh 를 쓰는데 안 될 때 — 로그인 셸 여부에 따라 읽는 파일이 다릅니다
echo '. "$HOME/.cargo/env"' >> ~/.zshrc
exec zsh # 셸 다시 시작
ssh host 'cargo build' 는
.bashrc 를 읽지 않습니다. 이럴 땐 스크립트 첫 줄에
. "$HOME/.cargo/env" 를 직접 넣거나
PATH="$HOME/.cargo/bin:$PATH" 를 명시하세요.
한 단계 더 — 컨테이너 · 서버 · 오프라인 설치 🧬 전문가
비대화형 설치 (Dockerfile · CI · Ansible)
# -y 를 넘기면 질문 없이 기본값으로 깔립니다
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain 1.89.0 --profile minimal \
--component clippy rustfmt
# --profile minimal rustc + cargo + std 만 (문서 · rust-docs 제외 → 절반 크기)
# --profile default 위 + rustfmt · clippy · rust-docs ← 사람이 쓸 PC 는 이쪽
# --profile complete 거의 전부 (권장하지 않음 — 자주 깨집니다)
# --no-modify-path 셸 rc 를 건드리지 않습니다 (컨테이너에서 유용)
export PATH="/root/.cargo/bin:$PATH"
# Dockerfile — 공식 이미지를 쓰는 편이 대개 낫습니다
FROM rust:1.89-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
COPY --from=build /app/target/release/myapp /usr/local/bin/
CMD ["myapp"]
인터넷이 없는 서버에 넣기
# rustup 은 결국 static.rust-lang.org 에서 tar 를 받아 풉니다.
# 그 tar 를 인터넷 되는 곳에서 미리 받아 옮기면 됩니다.
# ① 온라인 PC 에서 — 채널 정보와 툴체인 아카이브
BASE=https://static.rust-lang.org
curl -O $BASE/dist/channel-rust-1.89.0.toml
curl -O $BASE/dist/rust-1.89.0-x86_64-unknown-linux-gnu.tar.xz
# ② 폐쇄망 서버로 복사한 뒤 풀고, 딸린 설치 스크립트 실행
tar xf rust-1.89.0-x86_64-unknown-linux-gnu.tar.xz
cd rust-1.89.0-x86_64-unknown-linux-gnu
./install.sh --prefix=$HOME/rust --components=rustc,cargo,rust-std
export PATH="$HOME/rust/bin:$PATH"
# ③ 크레이트는 별도입니다 — cargo vendor 로 통째로 (s07)
# 소스까지 같이 옮겨야 cargo build 가 돕니다.
# ※ rustup 자체를 폐쇄망에서 쓰고 싶다면 미러를 세우고
# RUSTUP_DIST_SERVER 를 그쪽으로 돌립니다 (s07)
rustup target add x86_64-unknown-linux-musl 후
cargo build --release --target x86_64-unknown-linux-musl 로 빌드하면
의존성 없는 단일 바이너리가 나옵니다(s08 에서 이어집니다).
xcode-select --install, 리눅스는 build-essential(또는 그 배포판 대응) 이 먼저입니다.
그다음 공식 한 줄 + source ~/.cargo/env.
apt install rustc 는 쓰지 마세요 — 버전이 낡고 rustup 의 기능이 통째로 사라집니다.
빌드 중간에 *-sys 크레이트가 멈추면 대개 pkg-config 나 -dev 패키지 문제입니다.cargo — 빌드 · 의존성 · 테스트 · 문서가 한 명령에
파이썬은 pip + venv + pytest + black +
build 를 따로 배웁니다. C++ 는 CMake 와 vcpkg 를 따로 배웁니다.
Rust 는 전부 cargo 하나이고, 그래서 배울 명령이 열 개 남짓입니다.
이 섹션에서 그 열 개와 Cargo.toml 의 모든 칸, 그리고
왜 첫 빌드만 오래 걸리는지를 구조로 이해합니다.
실제로 쓰는 명령은 이만큼입니다
# ── 만들기 ────────────────────────────────
cargo new myapp # 실행 파일 프로젝트 (src/main.rs)
cargo new mylib --lib # 라이브러리 프로젝트 (src/lib.rs)
cargo init # 이미 있는 폴더를 프로젝트로
# ── 매일 쓰는 것 ──────────────────────────
cargo check # 타입만 검사 ★ 편집 중엔 이것만
cargo build # 디버그 빌드 → target/debug/
cargo build --release # 최적화 빌드 → target/release/ ★ 성능은 이쪽
cargo run # 빌드 + 실행
cargo run -- --flag 값 # -- 뒤는 내 프로그램에 넘어갑니다
cargo test # 테스트 (문서 안 예제까지 같이)
cargo test 이름 -- --nocapture # 이름 필터 + println! 보이게
# ── 의존성 ────────────────────────────────
cargo add serde --features derive # Cargo.toml 을 안 열어도 됩니다
cargo add tokio --features full
cargo remove serde
cargo update # Cargo.lock 안의 버전을 범위 안에서 올림
cargo tree # 의존성 트리
cargo tree -i syn # syn 을 누가 끌어왔는지 역추적 ★
# ── 그 밖 ─────────────────────────────────
cargo doc --open # 내 코드 + 의존성 문서를 브라우저로 ★★
cargo fmt # 자동 정렬
cargo clippy --all-targets # 린트
cargo clean # target/ 통째로 삭제
cargo install ripgrep # 도구를 ~/.cargo/bin 에 설치 (s06)
Cargo.toml — 칸마다 무슨 뜻인가
[package]
name = "myapp" # 크레이트 이름 (하이픈 허용, 코드에선 _ 로)
version = "0.1.0" # 시맨틱 버전
edition = "2024" # 언어 방언 (컴파일러 버전과 다른 축)
rust-version = "1.85" # 최소 요구 컴파일러 ★ 라이브러리면 꼭 적으세요
license = "MIT OR Apache-2.0"
description = "한 줄 설명" # crates.io 에 올리려면 필수
[dependencies] # 실행에 필요한 것
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
rand = "0.9" # "0.9" = ^0.9 = 0.9.x 안에서 최신
[dev-dependencies] # 테스트 · 예제에서만 (배포물에 안 들어감)
criterion = "0.5"
[build-dependencies] # build.rs 안에서만
cc = "1"
[features] # 켜고 끄는 스위치
default = ["json"]
json = ["dep:serde_json"]
[profile.release] # 최적화 설정 (s09)
lto = true
codegen-units = 1
[[bin]] # 실행 파일이 여러 개일 때
name = "worker"
path = "src/bin/worker.rs"
"1.2.3" 은 정확히 그것이 아니라
^1.2.3, 즉 “1.x 안에서 1.2.3 이상” 이라는 뜻입니다.
0.x 는 예외적으로 마이너까지 고정됩니다("0.9" → 0.9.x).
진짜로 못 박으려면 "=1.2.3" 을 씁니다.
이렇게도 쓸 수 있다 — Cargo.lock 과 target/ 을 어떻게 다루나 🟡 중급
Cargo.lock — 커밋하나, 안 하나
| 프로젝트 종류 | Cargo.lock | 이유 |
|---|---|---|
| 실행 파일 · 서비스 | 커밋합니다 ★ | 어제 통과한 CI 가 오늘도 통과해야 합니다 |
| 라이브러리 (crates.io 배포) | 커밋해도 됩니다 | 배포물에는 안 들어갑니다. CI 재현용으로 두는 편이 요즘 권장 |
| 워크스페이스 | 루트에 하나만 | 멤버별로 생기지 않습니다 |
# Cargo.lock 이 하는 일
# Cargo.toml 은 "1.x 면 아무거나" ← 범위
# Cargo.lock 은 "1.2.7 · 해시 abc" ← 확정
cargo build # lock 이 있으면 그대로 씁니다 (네트워크 최소)
cargo update # 범위 안에서 전부 최신으로 → lock 갱신
cargo update -p serde # serde 만
cargo update -p serde --precise 1.0.210 # 특정 버전으로 못 박기
# CI 에서 lock 이 바뀌면 실패시키기 (재현성 보증) ★
cargo build --locked
cargo test --locked --offline
target/ — 왜 이렇게 커지나
myapp/target/
├─ debug/
│ ├─ myapp(.exe) 내 실행 파일
│ ├─ deps/ 의존성 · 내 코드의 중간 산출물 (.rlib .rmeta)
│ ├─ build/ build.rs 를 컴파일·실행한 결과
│ ├─ incremental/ 증분 컴파일 캐시 ★ 여기가 제일 큽니다
│ └─ .fingerprint/ "뭐가 바뀌었나" 판단용 지문
├─ release/ --release 결과 (debug 와 완전히 별도)
└─ CACHEDIR.TAG 백업 도구가 건너뛰라는 표시
# 크기 확인 · 정리
du -sh target # 흔히 1~10 GB
cargo clean # 전부 삭제
cargo clean -p myapp # 내 크레이트만 (의존성 캐시는 유지) ★
# 여러 프로젝트가 캐시를 공유하게
export CARGO_TARGET_DIR="$HOME/.cargo-target" # 리눅스 · mac
setx CARGO_TARGET_DIR "D:\cargo-target" # 윈도우
target/ 은 절대 커밋하지 않습니다. cargo new 가 만든
.gitignore 에 이미 들어 있습니다. 실수로 올라갔다면
git rm -r --cached target 로 빼세요.
디스크가 부족할 때 가장 먼저 지울 곳도 여기입니다.
실무에선 이렇게 — 워크스페이스로 크레이트를 쪼갠다 🔴 실무
core 하나에만 둡니다.루트 Cargo.toml
# myproj/Cargo.toml — [package] 가 없습니다. 이 파일은 껍데기입니다.
[workspace]
members = ["crates/core", "crates/cli", "crates/pyext"]
resolver = "3" # edition 2024 기본. 2021 이면 "2"
# ★ 버전을 한곳에서 — 멤버들이 이걸 물려받습니다
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
core = { path = "crates/core" }
[workspace.package] # 공통 메타데이터
version = "0.3.0"
edition = "2024"
license = "MIT"
rust-version = "1.85"
[profile.release] # 프로필은 루트에서만 유효합니다 ★
lto = true
# crates/cli/Cargo.toml — workspace = true 로 물려받습니다
[package]
name = "cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
core.workspace = true
serde.workspace = true
clap = { version = "4", features = ["derive"] }
워크스페이스에서 자주 쓰는 명령
cargo build # 기본 멤버 전부
cargo build -p cli # cli 만 (그 의존성인 core 는 자동)
cargo test --workspace # 모든 멤버 테스트
cargo test -p core
cargo run -p cli -- --help
cargo clippy --workspace --all-targets -- -D warnings
# 무엇이 멤버인지 확인
cargo metadata --format-version 1 --no-deps | head -40
# 특정 멤버를 기본에서 빼기 (루트 Cargo.toml)
# [workspace]
# default-members = ["crates/cli"]
pub 를 붙여야 해서 귀찮아집니다.
“따로 배포하거나 따로 링크될 것” 을 기준으로 나누세요.
한 단계 더 — cargo 가 rustc 를 어떻게 부르는지 들여다보기 🧬 전문가
무엇이 오래 걸리는지 눈으로 보기
# 실제 실행되는 rustc 명령을 전부 출력
cargo build -v
# Running `rustc --crate-name myapp --edition=2024 src/main.rs
# --crate-type bin -C opt-level=0 -C debuginfo=2
# -L dependency=/…/target/debug/deps --extern serde=/…/libserde.rlib`
# 크레이트별 컴파일 시간 리포트 (HTML) ★ 느릴 때 제일 먼저
cargo build --timings
# → target/cargo-timings/cargo-timing.html 을 브라우저로 여세요
# 의존성이 몇 개나 되나
cargo tree | wc -l
cargo tree --duplicates # 같은 크레이트의 여러 버전이 함께 들어온 경우 ★
# 무엇이 다시 빌드되는지 이유 보기 (nightly 필요)
cargo +nightly build -Z unstable-options --unit-graph | head
알아 두면 좋은 환경 변수
| 변수 | 효과 |
|---|---|
CARGO_TARGET_DIR | target/ 위치를 공유 폴더로 |
CARGO_HOME | ~/.cargo 위치 변경 |
RUSTFLAGS | rustc 에 넘길 플래그(바꾸면 전체 재빌드) |
CARGO_BUILD_JOBS | 동시 컴파일 수 (기본 = 코어 수) |
CARGO_TERM_COLOR | always — CI 로그에 색 유지 |
RUST_BACKTRACE | 1 / full — 패닉 스택 |
CARGO_NET_OFFLINE | true — 네트워크 금지 |
RUSTFLAGS 를 셸 프로필에 박아 두지 마세요 —
값이 바뀔 때마다 모든 크레이트가 처음부터 다시 빌드됩니다.
영구 설정이 필요하면 ~/.cargo/config.toml 의
[build] rustflags 를 쓰는 편이 낫습니다(s09).
new · check · build · run · test · add · doc · fmt · clippy 아홉 개면 충분합니다.
Cargo.toml 은 범위, Cargo.lock 은 확정 — 서비스라면 lock 을 커밋하고 CI 에서 --locked 를 쓰세요.
프로젝트가 커지면 워크스페이스로 쪼개는 것이 빌드 속도와 구조 양쪽에 답입니다.에디터 — rust-analyzer 하나가 체감의 90%
Rust 는 에디터 지원의 질이 학습 속도를 그대로 결정하는 언어입니다.
타입을 안 적어도 되지만 타입이 무엇인지는 알아야 하고, 소유권 에러는
줄 위에서 즉시 보여야 의미가 있습니다. 그 일을 하는 것이 rust-analyzer 입니다.
이 섹션은 VS Code 설정 파일 실제 내용 · 디버깅 launch.json · 대안 에디터까지 다룹니다.
cargo check 가 그린 것입니다 — 그래서 컴파일 결과와 항상 일치합니다.VS Code — 설치와 확장 네 개
# ① VS Code 받기 — https://code.visualstudio.com
# 윈도우는 winget install --id Microsoft.VisualStudioCode
# mac 은 brew install --cask visual-studio-code
# ② 확장 (명령행으로 한 번에)
code --install-extension rust-lang.rust-analyzer # ★ 필수
code --install-extension vadimcn.vscode-lldb # CodeLLDB — 디버깅
code --install-extension tamasfe.even-better-toml # Cargo.toml 자동완성
code --install-extension serayuzgur.crates # 의존성 최신 버전 표시
# ③ 반드시 제거 — 옛 공식 확장 (지원 종료, rust-analyzer 와 충돌)
code --uninstall-extension rust-lang.rust
# ④ 확인 — 프로젝트 폴더를 열고 상태 바를 봅니다
# "rust-analyzer" 가 뜨고 몇 초 뒤 "indexing" 이 끝나면 정상입니다.
# 명령 팔레트(Ctrl+Shift+P) → "rust-analyzer: Status" 로도 확인됩니다.
Cargo.toml 이 바로 그 폴더에 있어야
rust-analyzer 가 붙습니다. 상위 폴더나 파일 하나만 열면 아무 기능도 안 됩니다.
워크스페이스라면 루트 Cargo.toml 이 있는 폴더를 여세요.
settings.json — 이 값들이 실제로 다릅니다
{
"editor.formatOnSave": true,
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer",
"editor.formatOnSave": true
},
"rust-analyzer.check.command": "clippy",
"rust-analyzer.check.allTargets": true,
"rust-analyzer.inlayHints.typeHints.enable": true,
"rust-analyzer.inlayHints.parameterHints.enable": true,
"rust-analyzer.inlayHints.chainingHints.enable": true,
"rust-analyzer.inlayHints.closureReturnTypeHints.enable": "always",
"rust-analyzer.inlayHints.lifetimeElisionHints.enable": "skip_trivial",
"rust-analyzer.cargo.features": "all",
"rust-analyzer.cargo.buildScripts.enable": true,
"rust-analyzer.procMacro.enable": true,
"rust-analyzer.imports.granularity.group": "module",
"rust-analyzer.imports.prefix": "crate",
"rust-analyzer.files.excludeDirs": ["target", "node_modules"],
"rust-analyzer.cachePriming.enable": true
}
"rust-analyzer.check.command": "clippy"
(저장할 때마다 린트까지 보여 줌) 와 "editor.formatOnSave": true 입니다.
이 둘만으로 코드 리뷰에서 지적받을 일의 절반이 사라집니다.
이렇게도 쓸 수 있다 — 인레이 힌트와 알아 두면 좋은 기능 🟡 중급
인레이 힌트 — 안 적은 타입을 대신 보여 줍니다
// 내가 쓴 코드
let nums = vec![1, 2, 3];
let total = nums.iter().sum();
let name = map.get("k");
// 화면에 회색으로 겹쳐 보이는 것 (파일에는 없습니다)
let nums: Vec<i32> = vec![1, 2, 3];
let total: i32 = nums.iter()
.sum(); // : Sum<&i32> ← 체이닝 힌트
let name: Option<&String> = map.get("k");
// 매개변수 힌트 — 인자가 무슨 뜻인지 보입니다
copy_file(src: "a.txt", dst: "b.txt", overwrite: true);
Option 인지 Result 인지,
&str 인지 String 인지가 눈으로 바로 보입니다.
소유권을 익히는 동안(🔐 탭)에는 이 기능을 절대 끄지 마세요.
답답해지면 Ctrl+Alt 를 눌러 잠깐만 감추는 설정
(editor.inlayHints.enabled: "offUnlessPressed") 을 쓰면 됩니다.
손에 붙여 둘 기능들
| 기능 | 단축키 · 방법 |
|---|---|
| 빠른 수정 (import 자동 추가) | Ctrl+. ★ 가장 자주 |
| 타입 보기 | 마우스 올리기 · Ctrl+K Ctrl+I |
| 정의로 이동 | F12 (표준 라이브러리 안까지 들어갑니다) |
| 이 타입의 메서드 전부 | . 을 찍고 기다리기 |
| match 팔 자동 채우기 | Ctrl+. → Fill match arms ★ |
| 매크로 펼쳐 보기 | 팔레트 → Expand macro recursively |
| 테스트 하나만 실행 | 테스트 위 Run Test 코드렌즈 |
| 실행/디버그 버튼 | fn main 위 Run | Debug |
| rust-analyzer 재시작 | 팔레트 → Restart Server (자주 필요합니다) |
cargo metadata 가 실패하면 아무것도 못 합니다.
터미널에서 cargo check 를 먼저 돌려 진짜 에러를 확인하세요.
실무에선 이렇게 — 중단점을 찍고 F5 로 멈춰 보기 🔴 실무
.vscode/launch.json — CodeLLDB (권장)
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "실행 파일 디버그",
"cargo": {
"args": ["build", "--bin=myapp", "--package=myapp"],
"filter": { "name": "myapp", "kind": "bin" }
},
"args": ["--input", "data.csv"],
"cwd": "${workspaceFolder}",
"env": {
"RUST_LOG": "debug",
"RUST_BACKTRACE": "1"
},
"sourceLanguages": ["rust"]
},
{
"type": "lldb",
"request": "launch",
"name": "단위 테스트 디버그",
"cargo": {
"args": ["test", "--no-run", "--lib", "--package=myapp"],
"filter": { "name": "myapp", "kind": "lib" }
},
"cwd": "${workspaceFolder}"
}
]
}
"cargo" 블록이 핵심입니다 — 경로를 직접 적지 않고
cargo 가 만든 실행 파일을 자동으로 찾아 줍니다.
--release 를 넣으면 최적화 빌드도 디버그할 수 있지만,
변수가 “optimized out” 으로 안 보이는 일이 많습니다.
윈도우 msvc 에서 MS 디버거를 쓸 때
{
"version": "0.2.0",
"configurations": [
{
"name": "MSVC 디버그",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}/target/debug/myapp.exe",
"args": [],
"cwd": "${workspaceFolder}",
"preLaunchTask": "cargo build",
"console": "integratedTerminal"
}
]
}
// .vscode/tasks.json — 위에서 부르는 preLaunchTask
{
"version": "2.0.0",
"tasks": [
{
"label": "cargo build",
"type": "shell",
"command": "cargo build",
"problemMatcher": ["$rustc"],
"group": { "kind": "build", "isDefault": true }
}
]
}
String 이나 Vec 이
원시 포인터로만 보이면 시각화 규칙(pretty printer)이 안 붙은 것입니다.
CodeLLDB 는 기본 내장이고, MSVC 쪽은 .natvis 파일이 툴체인에 들어 있습니다.
대부분의 경우 CodeLLDB 를 쓰는 편이 편합니다.
한 단계 더 — VS Code 말고 다른 선택지 🧬 전문가
에디터 비교 — 2026년 기준
| 에디터 | 강점 | 비용 |
|---|---|---|
| VS Code | 확장 생태계 · 자료가 가장 많음 · 파이썬/Node 와 한 창에서 | 무료 |
| RustRover | JetBrains. 디버거·리팩터링·프로파일러가 통합. 설정이 필요 없음 | 비상업 무료 |
| Zed | 매우 빠름(Rust 로 작성). rust-analyzer 내장 | 무료 |
| Helix | 터미널. LSP 내장, 설정 파일 거의 없음 | 무료 |
| Neovim | rustaceanvim 플러그인. 손에 익었다면 최고 | 무료 |
| IntelliJ IDEA | Rust 플러그인은 종료됨 → RustRover 로 이관 | — |
터미널 에디터 설정 한 줌
# Zed — mac · 리눅스 (윈도우는 미리보기)
# rust-analyzer 가 내장이라 설치 후 바로 됩니다
brew install --cask zed
curl -f https://zed.dev/install.sh | sh
# Helix — 어떤 LSP 가 잡히는지 확인하는 명령이 있습니다
hx --health rust
# Configured language servers:
# ✓ rust-analyzer: /home/me/.cargo/bin/rust-analyzer
# 위 명령이 rust-analyzer 를 못 찾으면 컴포넌트로 깔면 됩니다 ★
rustup component add rust-analyzer
which rust-analyzer # ~/.cargo/bin/rust-analyzer
# Neovim — rustaceanvim (lazy.nvim 기준)
# { "mrcjkb/rustaceanvim", version = "^5", ft = { "rust" } }
# 포매터는 conform.nvim 등에서 rustfmt 를 지정합니다
rustup component add rust-analyzer 로 설치한
~/.cargo/bin/rust-analyzer 를 씁니다.
둘이 버전이 달라도 문제는 없지만, 이상 동작이 있으면 버전을 맞춰 보세요.
check.command 를 clippy 로, 저장 시 포맷을 켜고,
인레이 힌트를 켜 두는 것이 초반 학습 속도를 가장 크게 바꿉니다.
자동완성이 죽으면 먼저 cargo check 를 돌려 프로젝트가 컴파일되는지부터 확인하세요.필수 도구 — clippy · rustfmt 부터 감사 도구까지
Rust 생태계의 도구는 대부분 cargo- 로 시작하는 서브커맨드 형태입니다.
설치하면 cargo 이름 으로 그냥 불립니다. 다만 깔리는 위치가 두 종류여서,
그걸 모르면 “깔았는데 없다”가 반복됩니다. 여기서 어디에 · 어떻게 · 왜를 정리하고
실무에서 실제로 쓰는 여덟 개를 한 줄 사용법과 함께 익힙니다.
clippy — 코드 리뷰를 대신해 줍니다
# 설치 — default 프로필이면 이미 있습니다
rustup component add clippy
cargo clippy --version
# 기본 사용
cargo clippy # 기본 타깃
cargo clippy --all-targets # 테스트 · 예제 · 벤치까지 ★
cargo clippy --workspace --all-targets --all-features
# CI 에서 — 경고 하나라도 있으면 실패시키기 ★★
cargo clippy --all-targets -- -D warnings
# 자동으로 고칠 수 있는 것은 고쳐 버리기
cargo clippy --fix # git 이 깨끗해야 실행됩니다
cargo clippy --fix --allow-dirty --allow-staged
# Cargo.toml — 프로젝트 전체 린트 정책 (팀 규칙을 코드로)
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
unwrap_used = "warn" # unwrap 을 쓰면 경고 ★ 서비스 코드에 유용
expect_used = "allow"
module_name_repetitions = "allow" # pedantic 중 성가신 것만 끄기
if x == true, 불필요한 clone(), len() == 0,
match 로 쓸 수 있는 if let 사슬 같은 것을 잡아 줍니다.
초보일수록 얻는 게 많습니다. 반드시 켜 두세요.
rustfmt — 포맷 논쟁을 없앱니다
rustup component add rustfmt
cargo fmt # 프로젝트 전체 정렬
cargo fmt -- --check # 고치지 말고 어긋난 곳만 알려 주기 (CI 용) ★
cargo fmt -p core # 특정 크레이트만
rustfmt src/main.rs # 파일 하나만
# 프로젝트 루트의 rustfmt.toml — 안정 채널에서 되는 것들
edition = "2024"
max_width = 100 # 기본 100. 팀에 따라 120 도 씁니다
tab_spaces = 4
newline_style = "Unix" # 윈도우 팀원과 섞여도 CRLF 가 안 생깁니다 ★
use_field_init_shorthand = true
use_try_shorthand = true # r#try 대신 ? 를 씁니다
reorder_imports = true
# ── 아래는 nightly 에서만 동작합니다 ──
# cargo +nightly fmt 로 돌려야 적용됩니다
group_imports = "StdExternalCrate" # std / 외부 / crate 순으로 묶기 ★
imports_granularity = "Crate"
wrap_comments = true
comment_width = 100
cargo fmt 를 돌리면 조용히 무시됩니다(에러도 안 납니다).
팀이 그 옵션을 쓰기로 했다면 rustup toolchain install nightly 후
cargo +nightly fmt 를 CI 와 에디터 양쪽에 맞춰 두세요.
이렇게도 쓸 수 있다 — 개발 루프를 빠르게 하는 것들 🟡 중급
cargo-watch · cargo-nextest
# ── 파일이 바뀌면 자동으로 다시 돌리기
cargo install cargo-watch --locked
cargo watch -x check # 저장할 때마다 check ★
cargo watch -x 'clippy --all-targets'
cargo watch -x test
cargo watch -x run # 웹 서버 개발에 좋습니다
cargo watch -c -q -x run # -c 화면 지우기, -q 조용히
cargo watch -w src -x test # src 만 감시
# ※ 요즘은 범용 도구인 watchexec 도 많이 씁니다
# cargo install watchexec-cli --locked
# watchexec -e rs -- cargo test
# ── 테스트 러너 교체 — 빠르고 출력이 읽기 좋습니다
cargo install cargo-nextest --locked
cargo nextest run # 테스트별 별도 프로세스 · 병렬 ★
cargo nextest run -E 'test(parse)' # 필터 식
cargo nextest run --retries 2 # 불안정한 테스트 재시도
cargo nextest run --no-fail-fast
# nextest 가 못 하는 것: 문서 테스트(doctest)
# 그래서 CI 는 보통 이렇게 둘을 같이 돌립니다
cargo nextest run && cargo test --doc
cargo-expand · cargo-edit
# ── 매크로가 실제로 어떤 코드가 되는지 보기 (nightly 필요)
cargo install cargo-expand --locked
rustup toolchain install nightly
cargo expand # 크레이트 전체
cargo expand model::user # 모듈 하나
cargo expand --test my_test
# #[derive(Serialize)] 가 만든 impl 을 눈으로 볼 수 있습니다.
# 매크로 에러가 이해 안 될 때 가장 확실한 방법입니다 ★
# ── cargo-edit — 지금은 cargo 에 흡수되었습니다
# cargo add / cargo remove 는 1.62 부터 기본 내장입니다.
# 아직 별도인 것은 upgrade 뿐입니다.
cargo install cargo-edit --locked
cargo upgrade # Cargo.toml 의 버전 표기 자체를 올림
cargo upgrade --dry-run
cargo upgrade -p serde --incompatible # 메이저 올림까지 허용
# ※ cargo update 는 lock 만 바꿉니다. Cargo.toml 은 안 건드립니다.
# cargo upgrade 는 Cargo.toml 을 고칩니다. 이 차이가 중요합니다 ★
--locked 를 항상 붙이세요 — 도구가 배포될 때 검증한
Cargo.lock 그대로 빌드합니다. 안 붙이면 새 의존성이 딸려 들어와
도구 설치 자체가 컴파일 에러로 실패하는 일이 실제로 자주 있습니다.
실무에선 이렇게 — 보안 · 라이선스 · 의존성 감사 🔴 실무
cargo-audit · cargo-deny
# ── audit : 알려진 취약점만 빠르게
cargo install cargo-audit --locked
cargo audit # Cargo.lock 을 RustSec DB 와 대조
cargo audit fix # 가능한 것은 버전을 올려 줍니다
cargo audit --deny warnings # CI 에서 실패시키기
# ── deny : 취약점 + 라이선스 + 중복 버전 + 출처까지
cargo install cargo-deny --locked
cargo deny init # deny.toml 생성
cargo deny check # 전부
cargo deny check advisories # 취약점만
cargo deny check licenses # 라이선스만 ★ 회사에서 가장 중요
cargo deny check bans # 금지 크레이트 · 중복 버전
# deny.toml — 실무에서 쓰는 최소 형태
[advisories]
version = 2
ignore = [] # 어쩔 수 없는 건 ID 를 적고 사유를 주석으로
[licenses]
version = 2
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0"]
[bans]
multiple-versions = "warn"
deny = [
{ name = "openssl", reason = "rustls 를 씁니다" },
]
[sources]
unknown-registry = "deny" # crates.io 아닌 곳에서 온 것 차단
unknown-git = "deny"
그 밖에 손에 익혀 두면 좋은 것
| 도구 | 한 줄 용도 |
|---|---|
cargo-outdated | 더 새 버전이 나온 의존성 목록 |
cargo-machete | 안 쓰는 의존성 찾아내기 (빌드 시간 절약) |
cargo-llvm-cov | 테스트 커버리지 (--html 로 리포트) |
cargo-bloat | 바이너리에서 무엇이 크기를 차지하나 |
cargo-udeps | 미사용 의존성 (nightly, machete 보다 정확) |
cargo-msrv | 이 코드가 도는 최소 rustc 버전 찾기 |
cargo-binstall | 위 도구들을 빌드 없이 설치 |
cargo-cache | ~/.cargo 캐시 크기 확인·정리 |
# 한 번에 갖추기 — binstall 을 먼저 깔면 나머지가 수 초입니다 ★
cargo install cargo-binstall --locked
cargo binstall -y cargo-nextest cargo-watch cargo-audit \
cargo-deny cargo-expand cargo-machete
# 설치된 도구 목록 · 일괄 갱신
cargo install --list
cargo install-update -a # cargo-update 크레이트가 필요합니다
~/.cargo/bin 이 커지는 것보다,
툴체인을 올릴 때 하나씩 다시 빌드해야 하는 비용이 더 큽니다.
실제로 매일 쓰는 것은 clippy · rustfmt · nextest · watch 넷 정도이고,
audit · deny 는 CI 에만 두는 편이 보통입니다.
한 단계 더 — 팀 전체를 한 파일로 맞추기 🧬 전문가
새 팀원이 클론하면 바로 같은 환경이 되는 구성
| 파일 | 무엇을 고정하나 | 없으면 생기는 일 |
|---|---|---|
rust-toolchain.toml | 컴파일러 버전 · 컴포넌트 · 타깃 | “내 PC 에선 되는데” |
rustfmt.toml | 줄 길이 · import 정렬 | diff 가 포맷 변경으로 뒤덮임 |
clippy.toml | 린트 임계값 · MSRV | 사람마다 다른 경고 |
Cargo.toml [lints] | 린트 수준 자체 | CI 에서만 실패 |
deny.toml | 허용 라이선스 · 금지 크레이트 | 배포 직전에 법무 이슈 |
.cargo/config.toml | 링커 · 레지스트리 · 별칭 | 빌드 속도·경로가 제각각 |
.vscode/settings.json | 에디터 동작 | 포맷이 사람마다 다름 |
# clippy.toml — 자주 쓰는 두 줄
msrv = "1.85" # 이 버전에 없는 관용구는 권하지 않게
too-many-arguments-threshold = 8
# .cargo/config.toml — 팀 공통 별칭 (프로젝트 안에 두면 됩니다)
[alias]
c = "check --all-targets"
t = "nextest run"
l = "clippy --all-targets -- -D warnings"
xt = "test --doc"
cargo run” 한 줄이면 끝나는 것이 목표입니다.
rustup component add, 나머지 cargo-* 도구는 cargo install --locked 입니다.
설치가 느리면 cargo-binstall 을 먼저 깔면 됩니다.
매일 쓰는 것은 cargo clippy · cargo fmt · cargo nextest run · cargo watch -x check 넷이고,
audit · deny 는 CI 에 두세요.크레이트는 어디서 오나 — 캐시 · 미러 · 프록시 · 오프라인
집에서는 cargo build 가 알아서 다 받아 옵니다. 그런데 회사 망에 들어가는 순간
같은 명령이 failed to get response 로 죽습니다. 프록시 · 사내 CA 인증서 ·
차단된 도메인 · 폐쇄망… 이 섹션은 ~/.cargo/config.toml 파일 하나로
그 상황들을 전부 다루는 법입니다. 개인 PC 만 쓰더라도
“캐시가 어디 있고 얼마나 큰지”는 알아 두는 편이 좋습니다.
~/.cargo/registry 캐싱부터 확인하세요.config.toml — 어디에 두느냐로 범위가 정해집니다
적용 범위가 좁은 쪽이 이깁니다 (아래로 갈수록 셈)
① $CARGO_HOME/config.toml ← 내 계정 전체
리눅스·mac : ~/.cargo/config.toml
윈도우 : %USERPROFILE%\.cargo\config.toml
② 상위 폴더들의 .cargo/config.toml ← 루트까지 올라가며 전부 합쳐집니다
/work/.cargo/config.toml
③ 프로젝트의 .cargo/config.toml ← 이 프로젝트만 ★ 팀에 공유할 것은 여기
myproj/.cargo/config.toml
④ 환경 변수 ← 가장 셈
CARGO_HTTP_PROXY · CARGO_NET_OFFLINE · CARGO_BUILD_JOBS …
(설정 키 a.b.c → 환경 변수 CARGO_A_B_C 규칙)
※ 옛 이름 "config" (확장자 없음) 도 여전히 읽힙니다. 새로 만들 땐 config.toml.
※ Cargo.toml 과 헷갈리지 마세요 — 이쪽은 "도구 설정", 저쪽은 "프로젝트 정의".
cargo config get (안정화된 하위 명령) 또는
cargo -Zunstable-options config get --show-origin (nightly) 로
값이 어느 파일에서 왔는지까지 확인할 수 있습니다.
캐시 — 무엇이 어디에 쌓이나
~/.cargo/registry/
├─ index/ 크레이트 목록(인덱스) 캐시
├─ cache/ 내려받은 .crate 압축 파일 그대로
└─ src/ 그것을 푼 소스 — rustc 가 실제로 읽는 곳
~/.cargo/git/
├─ db/ git 의존성의 bare 저장소
└─ checkouts/ 특정 커밋을 꺼내 둔 작업 사본
# 크기 보기 · 정리
du -sh ~/.cargo/registry ~/.cargo/git
cargo install cargo-cache --locked
cargo cache # 항목별 크기 표
cargo cache --autoclean # src/ 는 지우고 cache/ 는 남김 (안전한 정리) ★
# 통째로 지워도 됩니다 — 다음 빌드에서 다시 받습니다
rm -rf ~/.cargo/registry/src
sparse+https://index.crates.io/ 가 기본입니다.
그래서 사내 프록시는 “git 을 통과시키는 것”이 아니라 “HTTPS GET 을 통과시키는 것”이면 됩니다.
이렇게도 쓸 수 있다 — 사내 미러 · 사내 레지스트리 🟡 중급
crates.io 를 통째로 미러로 바꾸기
# ~/.cargo/config.toml (또는 프로젝트의 .cargo/config.toml)
[source.crates-io]
replace-with = "company-mirror"
[source.company-mirror]
registry = "sparse+https://nexus.corp.example.com/repository/crates-io/"
# ★ 이 두 블록이면 끝입니다.
# Cargo.toml 은 한 글자도 고치지 않습니다 — 이름은 그대로 crates.io 를
# 가리키고, 실제 요청만 미러로 갑니다.
# 그래서 오픈소스 프로젝트를 그대로 사내에서 빌드할 수 있습니다.
# 사내 전용 크레이트를 함께 쓰는 경우 — 별도 레지스트리로 등록
[registries.corp]
index = "sparse+https://nexus.corp.example.com/repository/cargo-hosted/"
# 그리고 Cargo.toml 쪽
# [dependencies]
# corp-auth = { version = "2.1", registry = "corp" }
# 인증 — 토큰을 ~/.cargo/credentials.toml 에 저장합니다
cargo login --registry corp
# 또는 환경 변수로 (CI 에서 이 방식을 씁니다)
export CARGO_REGISTRIES_CORP_TOKEN="Bearer xxxxx"
# 사내 레지스트리에 올리기
cargo publish --registry corp
회사 프록시 뒤에서
# ~/.cargo/config.toml
[http]
proxy = "http://proxy.corp.example.com:8080"
cainfo = "C:/certs/corp-root-ca.pem" # 사내 CA 인증서 ★
check-revoke = false # 윈도우에서 CRL 조회가 막혀 멈출 때
timeout = 60
multiplexing = false # HTTP/2 를 막는 장비가 있을 때
[net]
retry = 5
git-fetch-with-cli = true # git 의존성을 시스템 git 으로 받게 ★
# 환경 변수로도 같은 일이 됩니다 (CI · 컨테이너에서 편합니다)
export HTTPS_PROXY="http://proxy.corp.example.com:8080"
export HTTP_PROXY="$HTTPS_PROXY"
export NO_PROXY="localhost,127.0.0.1,.corp.example.com"
# 인증서 문제 진단 순서
curl -v https://index.crates.io/config.json # 여기서 먼저 확인 ★
# SSL certificate problem: unable to get local issuer certificate
# → 사내 CA 를 cainfo 로 지정하거나 시스템 신뢰 저장소에 넣습니다
# rustup 자체도 프록시를 탑니다 (설치 단계에서 막히는 경우)
export RUSTUP_DIST_SERVER="https://mirror.corp.example.com/rust-static"
export RUSTUP_UPDATE_ROOT="$RUSTUP_DIST_SERVER/rustup"
cargo 에
검증을 끄는 설정을 넣는 것. 사내 CA 인증서를 cainfo 로 정식 지정하는 것이
답입니다. 검증을 끄면 사내 모든 개발자가 중간자 공격에 무방비가 됩니다.
실무에선 이렇게 — 네트워크가 아예 없는 환경 (vendor) 🔴 실무
cargo vendor — 의존성 소스를 저장소에 넣습니다
# ① 인터넷 되는 곳에서
cd myproj
cargo vendor
# Vendoring serde v1.0.219 (…)
# …
# To use vendored sources, add this to your .cargo/config.toml:
# (아래 TOML 을 그대로 출력해 줍니다)
# 출력된 설정을 파일로 바로 받기
mkdir -p .cargo
cargo vendor > .cargo/config.toml
# ② vendor/ 와 .cargo/config.toml 을 커밋하거나 압축해 옮깁니다
git add vendor .cargo/config.toml Cargo.lock
du -sh vendor # 보통 수십~수백 MB
# ③ 폐쇄망에서 — 네트워크 없이 그대로 빌드됩니다
cargo build --offline --locked
# cargo vendor 가 만들어 주는 내용
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"
오프라인 모드와 CI 캐싱
# 네트워크를 아예 금지 — 캐시에 없으면 즉시 에러
cargo build --offline
export CARGO_NET_OFFLINE=true # 또는 config 의 [net] offline = true
# "이 빌드가 정말 네트워크 없이 되나?" 를 확인하는 가장 확실한 방법입니다.
# 폐쇄망에 넣기 전에 개발 PC 에서 미리 돌려 보세요 ★
# 필요한 것만 미리 받아 두기 (빌드는 안 함)
cargo fetch --locked
cargo fetch --target x86_64-unknown-linux-musl
# GitHub Actions — 캐시로 빌드 시간을 크게 줄입니다
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-${{ runner.os }}-
# 더 간단한 방법 — 전용 액션이 위 경로를 알아서 잡아 줍니다
- uses: Swatinem/rust-cache@v2
~/.cargo/registry/src 는 빼세요 —
cache/ 의 압축 파일만 있으면 다시 풀 수 있어서
캐시 크기가 절반 이하가 되고 복원도 빨라집니다.
한 단계 더 — git 의존성 · 로컬 경로 · 패치 🧬 전문가
crates.io 밖에서 가져오는 세 가지 방법
[dependencies]
# ① git 저장소에서 — 브랜치 · 태그 · 커밋 지정
mylib = { git = "https://github.com/me/mylib", branch = "main" }
mylib2 = { git = "https://github.com/me/mylib", tag = "v0.4.1" }
mylib3 = { git = "https://github.com/me/mylib", rev = "a1b2c3d" }
# ★ 실무에서는 rev 또는 tag 를 쓰세요. branch 는 어제와 오늘이 달라집니다.
# ② 로컬 폴더에서 — 라이브러리를 같이 개발할 때
mylib4 = { path = "../mylib" }
# ③ 여러 방법을 섞기 — 배포는 crates.io, 개발 중에는 로컬
serde = { version = "1", path = "../serde" }
# version 은 crates.io 에 올릴 때, path 는 로컬 빌드에 쓰입니다
# [patch] — 의존성의 의존성까지 통째로 바꿔치기 ★
# 내가 직접 안 쓰는 크레이트도 갈아 끼울 수 있습니다.
[patch.crates-io]
serde = { git = "https://github.com/me/serde", branch = "fix-1234" }
# 버그를 고쳐 PR 을 올린 뒤 머지될 때까지 쓰는 표준 방법입니다.
# 워크스페이스 루트에만 적어야 효과가 있습니다.
사내 git 인증 · SSH
# cargo 는 자체 git 구현(gix/libgit2)을 씁니다.
# 사내 인증(LDAP · SSO · SSH agent)이 얽히면 시스템 git 에 맡기는 편이 낫습니다.
[net]
git-fetch-with-cli = true # ★ 사내 GitLab/Bitbucket 에서 거의 필수
# SSH 로 받게 하기 — git 설정으로 URL 을 바꿔치기
git config --global url."ssh://git@gitlab.corp.example.com/".insteadOf \
"https://gitlab.corp.example.com/"
# 토큰으로 받기 (CI)
git config --global url."https://oauth2:${TOKEN}@gitlab.corp".insteadOf \
"https://gitlab.corp"
# 잘 되는지 확인
cargo fetch -v 2>&1 | head -30
# 흔한 증상과 원인
# "failed to authenticate" → git-fetch-with-cli 를 켜 보세요
# "the ssh-agent is not running" → 같은 원인
# "network failure seems to have happened" → 프록시 · 방화벽
cargo vendor 로 잘 안 묶일 때가 있습니다 —
서브모듈이나 LFS 를 쓰는 저장소가 특히 그렇습니다.
폐쇄망 배포가 목적이라면 git 의존성을 사내 레지스트리에 미리 publish 해 두는 편이
결국 편합니다.
[source.crates-io] replace-with 한 줄로 전부 미러를 보게 만들고,
프록시·사내 CA 는 [http] proxy·cainfo 로 지정합니다.
폐쇄망은 cargo vendor + --offline --locked 가 정답이고,
인증서 검증을 끄는 우회는 절대 하지 마세요.툴체인 · 타깃 — “왜 내 PC 에서만 다르지” 를 없애는 법
Rust 는 컴파일러가 여러 개 깔려 있는 것이 정상인 언어입니다. stable 로 개발하다 nightly 로 매크로를 펼쳐 보고, wasm 을 빌드하고, 서버용 musl 을 만듭니다. 그래서 “지금 이 폴더에서 어떤 rustc 가 쓰이는가” 를 판단하는 규칙이 있고, 그 규칙을 알면 버전 문제의 90%가 사라집니다. 타깃 추가와 크로스 컴파일도 여기서 함께 봅니다.
툴체인 다루기 — 명령 전부
# ── 설치 · 갱신 · 제거
rustup toolchain install stable
rustup toolchain install nightly
rustup toolchain install 1.85.0 # 정확한 버전
rustup toolchain install nightly-2026-05-01 # 날짜 고정 nightly ★
rustup update # 깔린 것 전부 최신으로
rustup update stable # 하나만
rustup toolchain uninstall nightly-2025-11-01
rustup self update # rustup 자신을 갱신
# ── 무엇이 쓰이나
rustup show
rustup show active-toolchain # 한 줄로 ★
rustup which rustc
rustup which --toolchain nightly cargo
# ── 어떤 것을 쓸지 정하기
rustup default stable # 전역 기본값
rustup override set nightly # 이 폴더만
rustup override unset
rustup override list
RUSTUP_TOOLCHAIN=nightly cargo build # 이번 명령만 (환경 변수)
cargo +nightly build # 이번 명령만 (인자) ★ 가장 간편
nightly-2026-05-01 처럼 날짜를 박는 이유 — 그냥 nightly 는
매일 바뀌므로 어제 되던 빌드가 오늘 깨집니다. 불안정 기능을 쓰는 프로젝트는
반드시 날짜를 고정하고, 올릴 때는 의도적으로 올립니다.
rust-toolchain.toml — 저장소에 넣는 한 파일
# 프로젝트 루트에 두면, 이 폴더 안에서는 항상 이 설정이 쓰입니다.
# rustup 이 없으면 자동으로 받아서 깔기까지 합니다. ★
[toolchain]
channel = "1.89.0" # "stable" · "nightly" · "1.89.0" · "nightly-2026-05-01"
components = ["rustfmt", "clippy", "rust-src", "rust-analyzer"]
targets = ["wasm32-unknown-unknown", "x86_64-unknown-linux-musl"]
profile = "minimal" # minimal · default · complete
# 효과 확인 — 폴더를 옮겨 다니며 버전이 바뀝니다
cd ~/projA && rustc --version # 1.89.0 (toml 이 고정)
cd ~/projB && rustc --version # 1.92.0 (전역 기본값)
# 처음 들어가면 자동으로 받습니다
cd ~/projA
# info: syncing channel updates for '1.89.0-x86_64-pc-windows-msvc'
# info: downloading component 'clippy'
# 이 파일을 무시하고 싶을 때 (임시)
cargo +stable build
channel = "stable" 로 두고 Cargo.toml 의
rust-version(MSRV) 으로 최소 요구만 밝히는 편이 낫습니다.
정확한 버전을 못 박으면 사용자에게 그 버전을 강요하게 됩니다.
반대로 배포되는 서비스는 못 박는 편이 안전합니다.
이렇게도 쓸 수 있다 — 타깃 추가와 크로스 컴파일 🟡 중급
자주 쓰는 타깃 이름
| 타깃 | 쓰는 곳 |
|---|---|
wasm32-unknown-unknown | 브라우저 (wasm-bindgen) |
wasm32-wasip1 | WASI 런타임 · 엣지 |
x86_64-unknown-linux-musl | 정적 링크 단일 바이너리 ★ |
aarch64-unknown-linux-gnu | ARM 서버 · 라즈베리파이 |
aarch64-apple-darwin | 애플 실리콘 맥 |
x86_64-pc-windows-msvc | 윈도우 기본 |
x86_64-pc-windows-gnu | MinGW 링커 (s02) |
aarch64-linux-android | 안드로이드 NDK |
thumbv7em-none-eabihf | 임베디드 (no_std) |
# 목록 보기 · 추가 · 제거
rustup target list | grep musl
rustup target list --installed
rustup target add x86_64-unknown-linux-musl wasm32-unknown-unknown
rustup target remove wasm32-wasip1
# 빌드
cargo build --release --target x86_64-unknown-linux-musl
ls target/x86_64-unknown-linux-musl/release/
타깃만 추가해서는 안 되는 경우
rustup target add 는 "그 타깃용 표준 라이브러리" 만 줍니다.
그 타깃용 링커는 여전히 따로 필요합니다. ★
error: linker `cc` not found
error: could not find native static library ...
■ 해결 — 타깃별 링커를 지정합니다
.cargo/config.toml
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
# 리눅스에서 크로스 링커 받기 (Ubuntu 기준)
sudo apt install gcc-aarch64-linux-gnu musl-tools
# ── 훨씬 쉬운 두 가지 방법 ─────────────────
# ① cross — 도커 이미지 안에서 빌드합니다 (링커 준비가 필요 없음) ★
cargo install cross --locked
cross build --release --target aarch64-unknown-linux-gnu
# ② cargo-zigbuild — zig 를 링커로 씁니다 (도커 불필요, glibc 버전 지정 가능)
cargo install cargo-zigbuild --locked
cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.28
cross 나 cargo-zigbuild 를 쓰는 것이 사실상 표준입니다.
cross 는 도커(또는 podman)가 필요하고, zigbuild 는
zig 툴체인을 함께 받습니다.
실무에선 이렇게 — 컴포넌트와 nightly 기능 🔴 실무
컴포넌트 — 무엇이 있고 언제 필요한가
| 컴포넌트 | 필요한 상황 |
|---|---|
clippy | 린트 (거의 항상) |
rustfmt | 포맷 (거의 항상) |
rust-src | 표준 라이브러리 코드로 F12 이동 · build-std |
rust-analyzer | VS Code 밖의 에디터 |
llvm-tools | 커버리지(cargo-llvm-cov) · 프로파일링 |
rust-docs | 오프라인 문서(rustup doc) |
miri (nightly) | unsafe 코드의 UB 검사 |
rustc-dev (nightly) | 컴파일러 내부 API |
rustup component list # 전부
rustup component list --installed
rustup component add rust-src llvm-tools
rustup component add miri --toolchain nightly
rustup component remove rust-docs # 용량 줄이기
# 프로필로 한 번에 정하기
rustup set profile minimal # 앞으로 설치할 툴체인의 기본값
rustup toolchain install nightly --profile complete
nightly 기능을 켜는 법 (그리고 안 켜는 법)
// 크레이트 루트(main.rs / lib.rs) 맨 위에만 쓸 수 있습니다
#![feature(portable_simd)]
#![feature(let_chains)]
// stable 컴파일러에서는 이 줄 자체가 에러입니다:
// error[E0554]: `#![feature]` may not be used on the stable release channel
# ① 이번 한 번만
cargo +nightly build
# ② 프로젝트 전체를 nightly 로 (rust-toolchain.toml)
# [toolchain]
# channel = "nightly-2026-05-01"
# ③ nightly 전용 cargo 옵션 (-Z)
cargo +nightly build -Z build-std --target x86_64-unknown-linux-musl
cargo +nightly udeps
cargo +nightly miri test
# ④ nightly rustfmt 옵션만 필요할 때 — 포맷만 nightly 로
cargo +nightly fmt
RUSTC_BOOTSTRAP=1 은 쓰지 마세요 — stable 컴파일러에서 불안정 기능을
억지로 여는 뒷문입니다. 컴파일러 개발용이며, 언제든 조용히 깨지고
보증도 없습니다. 실제로 이 값을 켜 둔 CI 가 컴파일러 업그레이드 때
원인 불명으로 죽는 사고가 반복됩니다. 필요하면 정직하게 nightly 를 쓰세요.
한 단계 더 — CI 에서 여러 버전을 한꺼번에 검증하기 🧬 전문가
GitHub Actions — 툴체인 · OS · 타깃 매트릭스
name: ci
on: [push, pull_request]
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
toolchain: ["1.85.0", stable, beta] # MSRV · 현재 · 다음
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo test --locked --all-features
cross:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl, wasm32-unknown-unknown
- run: sudo apt-get install -y musl-tools
- run: cargo build --release --target x86_64-unknown-linux-musl
Cargo.toml 의 rust-version 과 매트릭스의 첫 항목을 같게 두면,
실수로 새 문법을 쓰는 순간 CI 가 잡아 줍니다.
찾기 힘들면 cargo msrv find 로 자동 탐색할 수도 있습니다.
+툴체인 → 환경 변수 → rust-toolchain.toml → override → default 순으로 이깁니다.
팀에서 공유해야 하는 것은 rust-toolchain.toml 하나뿐입니다.
다른 CPU·OS 용으로 빌드하려면 rustup target add + 그 타깃용 링커가 둘 다 필요하고,
링커 준비가 귀찮으면 cross 나 cargo-zigbuild 를 쓰세요.빌드가 느립니다 — 어디에 시간이 가는지 부터 봅니다
Rust 를 처음 쓰는 사람이 가장 자주 하는 불평이 “빌드가 느리다” 입니다. 그런데 느린 이유가 매번 다릅니다 — 첫 빌드는 의존성 컴파일, 두 번째부터는 링크, CI 는 캐시 없음, 윈도우는 백신 검사. 원인이 다르면 처방도 다릅니다. 이 섹션은 측정하는 법 → 링커 교체 → 캐시 → 프로파일 조정 순으로, 가장 효과가 큰 순서대로 정리합니다.
왜 첫 빌드가 그렇게 오래 걸리나
① 의존성을 "소스로" 받아 전부 직접 컴파일합니다
파이썬의 pip 는 미리 빌드된 wheel 을 받습니다.
Java 는 jar(바이트코드)를 그대로 씁니다.
Rust 는 crates.io 에서 소스만 받아 내 PC 에서 컴파일합니다.
→ 왜? 제네릭·인라인·기능 플래그(feature)에 따라 결과가 달라지므로
"미리 만들어 둘 수" 가 없습니다.
② 제네릭이 타입마다 코드를 새로 만듭니다 (단형화)
Vec<u8> 과 Vec<String> 은 서로 다른 기계어가 생깁니다.
빠른 이유이자 느린 이유입니다.
③ 파생 매크로가 코드를 불려 놓습니다
#[derive(Serialize)] 하나가 수십 줄을 만들고, 그걸 또 컴파일합니다.
serde · tokio · clap 이 많은 프로젝트는 여기서 시간이 갑니다.
④ 마지막에 LLVM 최적화 + 링크
--release 는 최적화가 훨씬 세서 몇 배 느립니다.
★ 그래서 두 번째부터는 빠릅니다 — ①~③ 은 캐시되고
바꾼 크레이트와 그것에 의존하는 것만 다시 합니다(증분).
cargo build --timings 를 돌리면
target/cargo-timings/cargo-timing.html 이 생깁니다.
어떤 크레이트가 몇 초를 먹고, 병렬화가 어디서 막히는지 막대로 보입니다.
추측으로 설정을 바꾸기 전에 이걸 먼저 보세요.
가장 효과 큰 순서로 — 체크리스트
| 할 일 | 효과 |
|---|---|
편집 중엔 cargo check | 3~5배 ★ 공짜 |
백신 예외에 target/ 추가 | 윈도우에서 2배 나기도 |
| 링커를 lld/mold 로 | 링크 2~10배 |
debug = 1 또는 0 | 링크·디스크 크게 감소 |
| 안 쓰는 의존성 제거 | cargo machete 로 확인 |
| feature 를 줄이기 | tokio = full 은 과합니다 |
| 워크스페이스로 분리 | 병렬 컴파일 단위 증가 |
| sccache | 여러 프로젝트·CI 에서 큼 |
| RAM 부족 확인 | 스왑이 시작되면 10배 느려집니다 |
# 안 쓰는 의존성 · 무거운 의존성 찾기
cargo machete
cargo tree --duplicates # 같은 크레이트의 두 버전이 동시에 들어옴
cargo tree -e features -p tokio # 어떤 feature 때문에 커졌나
# 예: tokio 를 필요한 것만
# tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
# ("full" 은 거의 언제나 과합니다)
이렇게도 쓸 수 있다 — 링커 교체 — 설정 세 줄 🟡 중급
OS 별 설정 (.cargo/config.toml)
# ■ Linux — mold 가 가장 빠릅니다
# sudo apt install mold clang (또는 배포판 패키지 / 소스 빌드)
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
# ■ Linux — mold 없이 lld 만 쓸 때
# sudo apt install lld clang
# [target.x86_64-unknown-linux-gnu]
# linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# ■ macOS — 최근 Xcode 의 기본 링커가 이미 빠릅니다.
# 그래도 느끼면 lld 를 시도해 보세요.
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# ■ Windows (msvc) — 툴체인에 함께 오는 rust-lld
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
# 디버그 정보를 줄이면 링크가 또 빨라집니다 (아래 프로파일 절과 함께)
[profile.dev]
debug = 1
rust-lld 로 바꾸는 중입니다 —
이미 빨라졌는데 굳이 설정을 얹으면 오히려 문제가 생길 수 있습니다.
바꾸기 전후로 반드시 시간을 재세요:
cargo clean && time cargo build 를 각각 두 번씩.
sccache — 컴파일 결과를 재사용
cargo install sccache --locked
# 켜기 — 두 방법 중 하나
export RUSTC_WRAPPER=sccache # 셸 · CI
# 또는 ~/.cargo/config.toml 에
# [build]
# rustc-wrapper = "sccache"
# 효과 확인 ★
sccache --show-stats
# Compile requests 1204
# Cache hits 987 (82%)
# Cache misses 217
sccache --zero-stats
sccache --stop-server
# 캐시 위치·크기 (기본 10 GB)
export SCCACHE_DIR="$HOME/.cache/sccache"
export SCCACHE_CACHE_SIZE="30G"
# 팀·CI 가 공유하는 원격 캐시도 됩니다 (S3 · Redis · GHA)
export SCCACHE_GHA_ENABLED=true # GitHub Actions
--show-stats 로 확인하세요.
실무에선 이렇게 — 프로파일을 목적별로 나눈다 🔴 실무
Cargo.toml — 실제로 쓰는 프로파일 구성
# ── 개발: 빌드가 빨라야 합니다
[profile.dev]
opt-level = 0
debug = 1 # 2(기본) → 1 : 줄 정보만. 링크와 디스크가 크게 줍니다
incremental = true # 기본값. 끄지 마세요
split-debuginfo = "unpacked" # mac 에서 특히 효과가 큽니다
# ★ 의존성만 최적화 — 내 코드는 디버그, 라이브러리는 빠르게
# 이미지 처리 · 압축 · 암호 라이브러리를 쓸 때 체감이 큽니다
[profile.dev.package."*"]
opt-level = 3
debug = false
# ── 배포: 크기와 속도
[profile.release]
opt-level = 3
lto = "thin" # true(fat) 는 훨씬 느립니다. thin 이 균형점 ★
codegen-units = 16 # 1 로 낮추면 빠른 바이너리 · 느린 빌드
panic = "abort" # 스택 되감기 제거 → 크기 감소 (테스트에는 영향)
strip = "symbols"
# ── 배포 빌드를 디버깅해야 할 때
[profile.release-debug]
inherits = "release"
debug = 1
strip = "none"
# cargo build --profile release-debug
# ── 벤치마크 · 프로파일링용
[profile.profiling]
inherits = "release"
debug = 1
옵션이 실제로 뭘 바꾸나
| 옵션 | 올리면 | 내리면 |
|---|---|---|
opt-level | 실행 빠름 · 빌드 느림 | 반대 |
debug | 디버깅 가능 · 용량 폭증 | 링크 빠름 |
lto | 작고 빠른 결과 · 링크 매우 느림 | 빌드 빠름 |
codegen-units | 빌드 빠름 · 최적화 약간 손해 | 결과 빠름 |
incremental | 재빌드 빠름 · 디스크 사용 | CI 는 끄는 편 |
panic | abort = 작고 빠름 | unwind = 기본 |
strip | 바이너리 크기 감소 | 스택 추적 유지 |
# CI 에서는 증분 빌드가 오히려 낭비입니다 (매번 새 머신)
export CARGO_INCREMENTAL=0
export CARGO_PROFILE_DEV_DEBUG=0 # 환경 변수로 프로파일 덮어쓰기
# 병렬 정도 조절 — 램이 부족하면 오히려 줄이는 게 빠릅니다
cargo build -j 4
export CARGO_BUILD_JOBS=4
# 빌드 전후 비교 (리눅스·mac)
cargo clean && time cargo build
touch src/main.rs && time cargo build # 증분 빌드 시간만
한 단계 더 — 캐시 공유 · cranelift · 디스크 🧬 전문가
디스크와 캐시 위치를 손보기
# 여러 프로젝트가 target/ 을 공유 — 디스크와 시간을 함께 아낍니다
# ~/.cargo/config.toml
# [build]
# target-dir = "/fast-ssd/cargo-target"
# 주의 — 공유하면 프로젝트마다 프로파일이 다를 때 서로를 무효화합니다.
# 워크스페이스 안에서는 이미 공유되므로 굳이 필요 없습니다.
# 램디스크에 두는 방법 (리눅스, 램이 넉넉할 때)
sudo mount -t tmpfs -o size=16G tmpfs /mnt/rust-target
export CARGO_TARGET_DIR=/mnt/rust-target
# 디스크가 꽉 찼을 때 지울 곳 순서
cargo cache --autoclean # ~/.cargo 캐시 정리
find . -name target -type d -prune -exec du -sh {} + # 프로젝트별 크기
cargo clean # 프로젝트 하나
rm -rf ~/.rustup/toolchains/옛버전 # 안 쓰는 툴체인
rustup toolchain uninstall nightly-2025-10-01
디버그 빌드용 대체 백엔드 (cranelift)
# LLVM 대신 cranelift 로 코드 생성 — 디버그 빌드가 빨라집니다.
# 최적화는 약하므로 "개발 중에만" 씁니다.
rustup component add rustc-codegen-cranelift-preview --toolchain nightly
# 쓰기 — 프로젝트의 .cargo/config.toml
# [unstable]
# codegen-backend = true
# [profile.dev]
# codegen-backend = "cranelift"
cargo +nightly build # 디버그 빌드가 눈에 띄게 빨라집니다
%USERPROFILE%\.cargo, %USERPROFILE%\.rustup,
그리고 프로젝트의 target 폴더를 추가하세요.
빌드는 수만 개의 작은 파일을 만들고 지우기 때문에,
실시간 검사가 붙으면 두 배 이상 느려지는 경우가 흔합니다.
--timings) → cargo check 습관 → 백신 예외 → 링커 교체 → 프로파일 조정 입니다.
첫 빌드가 느린 건 의존성을 소스로 컴파일하기 때문이라 정상이고, 두 번째부터 느리면 링크가 병목일 가능성이 큽니다.
RUSTFLAGS 를 함부로 바꾸지 마세요 — 바꾸는 순간 전체 재빌드입니다.파이썬 · Node 와 붙일 준비 — maturin · napi-cli
이 가이드의 목적은 파이썬·Node 가 느린 함수를 Rust 로 갈아 끼우는 것입니다. 그러려면 Rust 만 깔려 있어서는 안 되고, 파이썬 개발 헤더 · 가상환경 · Node 툴체인이 함께 갖춰져야 합니다. 여기서는 문법이 아니라 환경만 다룹니다 — 실제 코드는 🐍 파이썬 모듈 탭과 🟨 Node · WASM 탭에 있습니다. 이 섹션을 마치면 두 탭의 첫 명령이 바로 도는 상태가 됩니다.
파이썬 쪽 준비 — OS 별
# ■ 공통 확인
python3 --version # 3.9 이상이면 충분합니다 (3.13 · 3.14 권장)
python3 -c "import sysconfig; print(sysconfig.get_paths()['include'])"
# → 이 경로에 Python.h 가 있어야 합니다 ★
# ■ Ubuntu · Debian — 헤더가 별도 패키지입니다
sudo apt install -y python3-dev python3-venv python3-pip
# 특정 버전이 필요하면: python3.13-dev
# ■ Fedora · RHEL
sudo dnf install -y python3-devel
# ■ macOS — python.org 설치본이나 pyenv 는 헤더를 함께 갖고 있습니다
brew install python@3.13
# ※ 시스템 파이썬(/usr/bin/python3)은 쓰지 마세요
# ■ Windows — python.org 설치 관리자에서
# ☑ "Add python.exe to PATH"
# ☑ "Download debug binaries" 는 필요 없습니다
# 헤더는 기본 포함됩니다 (…\Python313\include\Python.h)
Python.h: No such file or directory.
파이썬은 깔려 있는데 -dev / -devel 패키지가 없는 것입니다.
리눅스에서 apt install python3 만으로는 헤더가 안 깔립니다.
maturin 설치와 첫 확인
# ① 가상환경을 먼저 만듭니다 ★ (maturin develop 이 여기에 설치합니다)
python3 -m venv .venv
source .venv/bin/activate # 윈도우: .venv\Scripts\activate
# ② maturin 설치 — 세 가지 방법 중 하나
pip install maturin # ★ 가장 무난 (가상환경 안)
uv tool install maturin # uv 를 쓴다면
cargo install maturin --locked # Rust 쪽에 두고 싶다면 (빌드 수 분)
maturin --version
# ③ 30초 확인 — 빈 프로젝트를 만들어 실제로 import 해 봅니다
maturin new -b pyo3 checkmod
cd checkmod
maturin develop # 빌드 → 현재 venv 에 설치
python -c "import checkmod; print(checkmod.sum_as_string(2, 3))"
# 5 ← 이게 나오면 파이썬 연동 환경은 완성입니다 ★★
pip install maturin vs cargo install maturin —
pip 쪽은 미리 빌드된 wheel 을 받아 수 초면 끝나고,
cargo 쪽은 소스를 컴파일해서 수 분이 걸립니다.
결과는 같으므로 pip 쪽을 권합니다. 다만 가상환경마다 다시 깔아야 하는데,
그게 싫으면 uv tool install 이나 pipx 를 쓰세요.
이렇게도 쓸 수 있다 — 프로젝트 파일 두 개만 맞으면 됩니다 🟡 중급
Cargo.toml — cdylib 이 핵심
[package]
name = "fastmod"
version = "0.1.0"
edition = "2024"
[lib]
name = "fastmod" # 파이썬에서 import 할 이름과 같아야 합니다 ★
crate-type = ["cdylib"] # ★★ 이게 없으면 .so/.pyd 가 안 나옵니다
# 테스트도 하고 싶다면: ["cdylib", "rlib"]
[dependencies]
pyo3 = { version = "0.23", features = ["extension-module"] }
# extension-module — 파이썬 자체를 링크하지 않게 합니다.
# 이게 없으면 "undefined symbol: PyExc_..." 류 에러가 납니다.
[profile.release]
lto = "thin"
codegen-units = 1 # 확장 모듈은 결과 속도가 중요하니 낮춰도 좋습니다
# pyproject.toml — maturin new 가 만들어 줍니다
[build-system]
requires = ["maturin>=1.7,<2"]
build-backend = "maturin"
[project]
name = "fastmod"
requires-python = ">=3.9"
classifiers = ["Programming Language :: Rust"]
[tool.maturin]
features = ["pyo3/extension-module"]
maturin 명령과 결과물 위치
maturin develop # 지금 venv 에 설치 (개발 루프) ★
maturin develop --release # 성능을 잴 때는 반드시 이쪽
maturin build # wheel 만들기 → target/wheels/*.whl
maturin build --release --strip
maturin sdist # 소스 배포판
# 결과물 확인
ls target/wheels/
# fastmod-0.1.0-cp313-cp313-manylinux_2_34_x86_64.whl
# 윈도우면 …-win_amd64.whl, mac 이면 …-macosx_11_0_arm64.whl
# 설치된 확장 모듈이 실제로 어디 있나
python -c "import fastmod, sys; print(fastmod.__file__)"
# …/.venv/lib/python3.13/site-packages/fastmod.cpython-313-…so
features = ["extension-module", "abi3-py39"] 로 두면
3.9 이상 어떤 파이썬에서도 도는 wheel 하나가 나옵니다.
배포할 때 조합 수가 확 줄어드는 대신 일부 API 를 못 씁니다.
자세한 이야기는 🐍 파이썬 모듈 탭에 있습니다.
실무에선 이렇게 — Node 쪽 준비 🔴 실무
Node 툴체인과 napi-rs
# ① Node 확인 — 24 LTS 를 권합니다
node --version
npm --version
node -p "process.versions.napi" # N-API 버전 (9 이상이면 넉넉)
# 버전 관리는 fnm 이나 nvm 을 쓰세요
# winget install Schniz.fnm / brew install fnm
# fnm install 24 && fnm use 24
# ② napi-rs CLI
npm install -g @napi-rs/cli
napi --version
# ③ 30초 확인 — 템플릿으로 만들어 바로 빌드
npm create napi@latest checkmod # 대화형으로 몇 가지 묻습니다
cd checkmod
npm install
npm run build # 내부적으로 napi build --platform --release
node -e "console.log(require('./index.js'))"
# 함수 목록이 찍히면 Node 연동 환경 완성입니다 ★
.node 파일도 결국 DLL 이라서 link.exe 가 필요합니다.
npm run build 가 link.exe not found 로 죽으면
Build Tools 설치부터 다시 확인하세요.
WASM 쪽도 함께 준비해 두기
# 브라우저에서 돌릴 것이라면 napi 가 아니라 wasm 입니다
rustup target add wasm32-unknown-unknown
# wasm-pack — 빌드 + npm 패키지 생성까지
cargo install wasm-pack --locked
# 또는 (훨씬 빠릅니다)
cargo binstall wasm-pack
wasm-pack --version
wasm-pack build --target bundler # webpack · vite 용
wasm-pack build --target web # <script type="module"> 용
wasm-pack build --target nodejs
# 크기 줄이기 도구 (선택)
cargo install wasm-opt --locked # 또는 binaryen 패키지
# 확인
ls pkg/
# checkmod_bg.wasm checkmod.js checkmod.d.ts package.json
한 단계 더 — cargo install 이 만드는 것과 PATH 정리 🧬 전문가
도구가 실제로 어디에 놓이나
cargo install 로 깐 것
~/.cargo/bin/maturin(.exe) ← PATH 에 이미 있는 폴더 ★
설치 기록: ~/.cargo/.crates2.json
pip install 로 깐 것 (가상환경)
.venv/bin/maturin ← 활성화해야 잡힙니다
.venv/lib/python3.13/site-packages/
pipx · uv tool 로 깐 것
~/.local/bin/maturin ← PATH 에 넣어야 할 수도 있습니다
각 도구별 격리된 venv 에 실제 본체
npm install -g 로 깐 것
리눅스·mac : /usr/local/bin 또는 ~/.npm-global/bin
윈도우 : %APPDATA%\npm
# 지금 무엇이 잡히는지 확인 ★
which -a maturin # 리눅스 · mac
where maturin # 윈도우
npm root -g
python -c "import sys; print(sys.executable)"
pip install maturin 을 하고
cargo install maturin 도 해 두면, 가상환경 활성화 여부에 따라 다른 것이 실행됩니다.
버전이 다르면 “어제는 됐는데” 가 시작됩니다. 하나만 남기세요.
여기까지 왔는지 확인하는 최종 점검표
| 확인 명령 | 기대 결과 |
|---|---|
rustc --version | 1.8x |
cargo run (새 프로젝트) | Hello, world! |
cargo clippy --version | 버전 출력 |
python3 -c "import sysconfig…" | include 경로 |
maturin --version | 1.x |
maturin develop + import | 숫자가 찍힘 ★ |
node -p "process.versions.napi" | 9 이상 |
napi --version | 버전 출력 |
rustup target list --installed | wasm32 포함 |
# 한 번에 확인하는 스크립트 (리눅스 · mac)
for c in rustc cargo rustup clippy-driver rustfmt maturin node napi; do
printf '%-14s ' "$c"
command -v "$c" >/dev/null && "$c" --version 2>/dev/null | head -1 \
|| echo "없음"
done
python3-dev) + 가상환경 + pip install maturin,
Node 쪽은 Node 24 LTS + @napi-rs/cli 입니다.
확인은 maturin develop 후 import 가 숫자를 찍는 것,
npm run build 후 require 가 되는 것까지 해야 끝입니다.
윈도우는 여기서도 결국 link.exe 가 필요합니다.설치 문제 사전 — 증상 → 원인 → 해결
여기까지 왔는데 뭔가 안 된다면, 그 증상은 거의 확실히 남들도 겪은 것입니다. Rust 의 설치 문제는 종류가 많지 않고 원인이 대부분 네 가지 중 하나입니다 — PATH · 링커 · 시스템 라이브러리 · 네트워크. 이 섹션은 순서대로 좁혀 가는 진단 흐름과, 실제로 화면에 뜨는 에러 문장 그대로를 찾아볼 수 있는 표입니다. 막히면 여기부터 보세요.
Windows — 화면에 뜨는 문장 그대로
| 증상 | 원인 | 해결 |
|---|---|---|
error: linker `link.exe` not found |
MSVC 링커 없음 | Build Tools 설치 + C++ 를 사용한 데스크톱 개발 워크로드 (s02) |
LNK1181: cannot open input file 'kernel32.lib' |
Windows SDK 누락 | 워크로드는 깔았는데 SDK 를 뺀 경우. VS Installer → 수정 → Windows SDK 체크 |
'cargo' is not recognized as an internal or external command |
PATH 미반영 | 터미널을 새로 여세요. 그래도 안 되면 %USERPROFILE%\.cargo\bin 을 사용자 Path 에 추가 |
Access is denied. (os error 5) · could not create link |
백신 실시간 검사 · 파일 잠금 | Windows 보안 제외 항목에 .cargo·.rustup·target 추가. 실행 중인 앱을 먼저 종료 |
The system cannot find the path specified. (os error 3) |
경로 260자 제한 | 긴 경로 켜기(LongPathsEnabled=1) 또는 프로젝트를 C:\src\ 처럼 짧은 곳으로 |
| 빌드 중간에 알 수 없는 실패 · 경로에 한글 사용자 이름 | 비ASCII 경로 | CARGO_HOME·RUSTUP_HOME 을 D:\rust\… 로 옮기고 프로젝트도 ASCII 경로에 (s02) |
error: could not download file from ... |
사내 프록시 · 방화벽 | HTTPS_PROXY 또는 [http] proxy 설정 (s07) |
| 빌드가 유난히 느리고 디스크가 계속 돔 | 백신이 target/ 을 검사 |
제외 항목 추가 — 체감 2배 나는 경우가 흔합니다 (s09) |
rustup: command not found (설치는 성공했는데) |
구형 MSI 설치본 잔재 | C:\Program Files\Rust* 를 PATH 에서 제거 후 rustup 재설치 (s02) |
이렇게도 쓸 수 있다 — mac · 리눅스 · 공통 증상 🟡 중급
macOS · Linux
| 증상 | 해결 |
|---|---|
error: linker `cc` not found | mac: xcode-select --install · Ubuntu: apt install build-essential |
cargo: command not found (설치 직후) | source ~/.cargo/env 또는 새 터미널 (s03) |
failed to run custom build command for openssl-sys | apt install pkg-config libssl-dev · 또는 rustls 기능으로 전환 |
Python.h: No such file or directory | apt install python3-dev (s10) |
xcrun: error: invalid active developer path | macOS 업그레이드 후. CLT 재설치 (s03) |
Permission denied (os error 13) in ~/.cargo | 과거에 sudo 로 깐 흔적. sudo chown -R $USER ~/.cargo ~/.rustup |
signal: 9, SIGKILL · 갑자기 죽음 | 메모리 부족. cargo build -j 2 로 줄이거나 스왑 추가 |
error: rustup is not installed | 배포판 패키지로 깐 rustc. 제거 후 rustup 으로 (s03) |
# 권한이 꼬였을 때 (가장 흔한 리눅스 사고)
ls -ld ~/.cargo ~/.rustup # root 소유면 문제입니다
sudo chown -R "$USER:$USER" ~/.cargo ~/.rustup
# 메모리 부족 확인 (빌드 중 다른 창에서)
free -h
dmesg | tail -20 | grep -i "killed process"
OS 무관 — 흔한 것
| 증상 | 해결 |
|---|---|
package `X` requires rustc 1.9x or newer | rustup update. 못 올리면 cargo add X@옛버전 |
error[E0554]: #![feature] may not be used on the stable release channel | nightly 가 필요합니다 (s08) |
the lock file needs to be updated but --locked was passed | cargo update 후 Cargo.lock 을 커밋 |
Blocking waiting for file lock on package cache | 다른 cargo 프로세스(에디터의 rust-analyzer 포함)가 잡고 있습니다. 기다리거나 종료 |
SSL certificate problem: unable to get local issuer certificate | 사내 CA 를 [http] cainfo 로 지정 (s07) |
spurious network error (3 tries) | 프록시·불안정 회선. [net] retry = 5 |
toolchain '…' is not installed | rustup toolchain install … 또는 rustup self update |
error: no override and no default toolchain set | rustup default stable |
| 디스크 꽉 참 | cargo clean · cargo cache --autoclean (s09) |
cargo check 를 돌리는 동안 터미널에서 cargo build 를 하면
서로 기다립니다. VS Code 설정에서
"rust-analyzer.cargo.targetDir": true 를 켜면
rust-analyzer 만 별도 target 폴더를 쓰게 되어 충돌이 사라집니다.
실무에선 이렇게 — 고치는 순서를 정해 두기 🔴 실무
막혔을 때 순서대로 시도할 것
# ① 가장 싸고 가장 자주 듣는 처방 — 터미널을 새로 열기
# (PATH · 환경 변수는 이미 열린 창에 반영되지 않습니다)
# ② rustup 자신과 툴체인 갱신
rustup self update
rustup update
rustup component add clippy rustfmt
# ③ 이 프로젝트만의 문제인지 확인 — 빈 프로젝트로 갈라내기 ★
cd /tmp && cargo new probe && cd probe && cargo run
# 여기서도 실패 → 환경 문제 (s01~s03)
# 여기는 성공 → 프로젝트 문제 (의존성 · 설정 · 시스템 라이브러리)
# ④ 캐시를 의심 — 지워도 손해가 없습니다
cargo clean
rm -rf ~/.cargo/registry/src
cargo build
# ⑤ 자세한 로그로 진짜 원인 보기
cargo build -v # 실행되는 rustc 명령까지
cargo build -vv # build.rs 의 출력까지 ★ *-sys 문제에 필수
RUST_BACKTRACE=full cargo run
RUSTUP_LOG=debug rustup update # rustup 자체 문제
# ⑥ 마지막 수단 — 깨끗이 지우고 다시
rustup self uninstall
에러 메시지를 읽는 요령
error: failed to run custom build command for `openssl-sys v0.9.104`
Caused by:
process didn't exit successfully: `…/build-script-build` (exit status: 101)
--- stderr
Could not find directory of OpenSSL installation …
★ 읽는 순서
① 맨 윗줄 : 어떤 크레이트에서 났나 → openssl-sys
② Caused by: 실제로 무엇이 실패했나 → build script
③ --- stderr 아래 : ★★ 진짜 원인이 여기 있습니다
"Could not find directory of OpenSSL installation"
④ 그 아래 note: / help: 에 해결책이 적혀 있는 경우가 많습니다
※ 러스트 컴파일 에러(E0xxx)와 빌드 스크립트 실패는 성격이 다릅니다.
E0xxx → 내 코드 문제. rustc --explain E0308 로 설명을 볼 수 있습니다.
build → 환경 문제. 대부분 이 탭(s02 · s03 · s07)에서 해결됩니다.
failed to run custom build command for openssl-sys 처럼요.
그리고 크레이트 이름 + 그 크레이트의 GitHub Issues 를 먼저 보는 편이
일반 검색보다 훨씬 빠릅니다 — 같은 환경의 사람이 이미 적어 두었을 가능성이 큽니다.
한 단계 더 — 그래도 안 될 때 · 남에게 물을 때 🧬 전문가
완전 초기화 — 순서를 지키면 안전합니다
# ① 먼저 무엇이 있는지 기록해 둡니다 (되돌릴 수 있게)
rustup show > ~/rust-before.txt
cargo install --list >> ~/rust-before.txt
# ② rustup 이 자기 자신 + 모든 툴체인을 제거
rustup self uninstall
# ③ 남은 것 확인 — cargo install 로 깐 도구는 남습니다
ls ~/.cargo/bin 2>/dev/null
rm -rf ~/.cargo ~/.rustup # 완전히 비우려면
# ④ PATH 정리 — 셸 rc 의 `. "$HOME/.cargo/env"` 줄은 남겨 둬도 무해합니다
# 윈도우는 환경 변수 편집기에서 옛 항목을 지웁니다
# ⑤ 다시 설치 — 이번엔 확인 명령까지 (s01)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
cargo new probe && cd probe && cargo run
질문할 때 함께 붙일 정보
# 이 다섯 줄의 출력이면 대부분의 질문에 충분합니다
rustc -vV # 버전 + host 트리플 + LLVM 버전 ★
cargo -V
rustup show
cargo tree -i 문제크레이트 # 그 크레이트를 누가 끌어왔나
cargo build -vv 2>&1 | tail -60 # 실제 실패 지점
rustc -vV 출력 예 — 이 정보가 핵심입니다
rustc 1.89.0 (abcdef012 2026-06-25)
binary: rustc
commit-hash: abcdef0123456789
host: x86_64-pc-windows-msvc ← ★ msvc 인가 gnu 인가
release: 1.89.0
LLVM version: 20.1.4
물어볼 곳
· users.rust-lang.org 공식 포럼. 초보 질문 환영 분위기 ★
· 크레이트의 GitHub Issues 그 크레이트 고유 문제라면 여기가 가장 빠릅니다
· Rust 한국 커뮤니티 / 디스코드
· Stack Overflow 에러 문장 그대로 검색
질문 형식
① 하려던 것 ② 실행한 명령 ③ 실제 출력(잘라내지 말 것) ④ 위 환경 정보
⑤ 이미 시도한 것 — 이게 있으면 답이 훨씬 빨리 옵니다
rustup update → ③ 빈 프로젝트로 갈라내기 → ④ cargo build -vv 로 진짜 원인 보기.
재설치는 마지막이고, 물어볼 때는 rustc -vV 와 실제 출력 전문을 꼭 붙이세요.요청 하나가
들어와서 나가기까지를
전부 손에 쥡니다
이 탭은 실전·생태계 탭(🧰 t04~t08)에서 맛만 본 것들을 실제 서비스 수준으로 끌어올립니다. 라우터가 핸들러를 찾는 원리, tower 레이어가 감싸는 순서, 커넥션 풀이 고갈되는 이유, JWT 를 추출기에서 검증하는 법, 부하 시험에서 p99 가 튀는 자리, 그리고 20MB 짜리 컨테이너 이미지로 배포하는 것까지를 순서대로 다룹니다. 마지막 w13 에서는 URL 단축 서비스 하나를 처음부터 끝까지 만듭니다 — Axum + sqlx(Postgres) + Redis + JWT + OpenAPI + Docker Compose 가 전부 들어갑니다. 이 탭을 마치면 “스프링 부트로 하던 일”을 Rust 로 그대로 옮길 수 있습니다.
Rust 백엔드는 언제 이기나 — 그리고 언제 지나
“빠르다”는 말만으로는 팀을 설득할 수 없습니다. 실제로 돈이 되는 지점은 같은 트래픽을 받는 데 인스턴스가 몇 대 필요한가, 그리고 p99 지연이 GC 때문에 튀지 않는가 두 가지입니다. 이 절은 그 두 가지를 숫자와 사례로 확인하고, 프레임워크를 고르는 기준까지 정리합니다.
돈이 되는 지점 — 인스턴스 수와 p99
아래는 “JSON 을 받아 DB 한 번 조회하고 JSON 을 돌려주는” 전형적인 API 를 같은 하드웨어에서 재었을 때 흔히 나오는 범위입니다. 정확한 숫자는 워크로드마다 다르므로 자릿수만 보세요.
| Axum | FastAPI | Spring Boot | |
|---|---|---|---|
| 상주 메모리(RSS) | 15~40MB | 80~200MB | 250~500MB |
| 시작 시간 | 10ms 안팎 | 1~2초 | 3~10초 |
| 처리량 (상대) | 기준 1.0 | 0.05~0.15 | 0.4~0.7 |
| p99 지연 | 평탄 | GIL 경합 | GC 스파이크 |
| 컨테이너 이미지 | 20~40MB | 150~400MB | 200~400MB |
| 같은 트래픽 인스턴스 | 1대 | 6~15대 | 2~3대 |
실제로 바꾼 곳들 — 무엇이 문제였나
| 사례 | 바꾼 이유 |
|---|---|
| Discord Read States | Go 의 GC 가 2분마다 지연 스파이크를 만들었습니다. 캐시가 커질수록 스캔 비용이 늘어 튜닝으로는 못 잡았고, Rust 로 옮기자 스파이크 자체가 사라졌습니다 |
| Cloudflare Pingora | NGINX 를 대체한 프록시. 하루 1조 건대 요청을 처리하면서 CPU·메모리 사용량을 크게 줄였다고 공개했습니다 |
| AWS Firecracker · S3 | 마이크로VM 과 스토리지 노드. 메모리 안전성 + 예측 가능한 지연이 이유입니다 |
| npm · Dropbox | 인증 서비스 · 파일 동기화 엔진을 부분 교체 — 핫패스만 옮기는 전략 |
이렇게도 쓸 수 있다 — 다섯 프레임워크를 나란히 놓고 보기 🟡 중급
| Axum 0.8 | actix-web 4 | Rocket 0.5 | Poem | Loco | |
|---|---|---|---|---|---|
| 런타임 | tokio (그대로) | actix-rt(tokio 기반) | tokio | tokio | tokio |
| 라우팅 표기 | 메서드 호출 | 매크로 + 메서드 | 매크로 | 매크로 | 생성기 |
| 미들웨어 | tower 표준 | 자체 Transform | Fairing | 자체 | tower |
| OpenAPI | utoipa · aide | utoipa | 외부 | 내장 | 내장 |
| DB 통합 | 직접 | 직접 | Rocket-DB | 직접 | SeaORM 내장 |
| 배경 작업 | 직접 | 직접 | 직접 | 직접 | 워커·스케줄러 |
| 에러 메시지 | 난해할 때가 있음 | 보통 | 보통 | 보통 | 보통 |
| 생태계 재사용 | tower-http 전부 | 일부만 | 일부만 | 일부만 | Axum 것 |
| 고르는 순간 | 기본값 | 기존 코드가 있을 때 | 취미·학습 | API 문서가 1순위 | 혼자 빨리 |
왜 tower 재사용이 결정적인가
// Axum 의 Router 는 그 자체가 tower::Service 입니다.
// 그래서 tower / tower-http 의 레이어가 "전부" 그대로 붙습니다.
use tower_http::{
trace::TraceLayer, cors::CorsLayer, compression::CompressionLayer,
timeout::TimeoutLayer, limit::RequestBodyLimitLayer,
request_id::{SetRequestIdLayer, PropagateRequestIdLayer},
catch_panic::CatchPanicLayer, services::ServeDir,
};
// 같은 레이어를 gRPC(tonic) 서버에도, reqwest 클라이언트에도 붙일 수 있습니다.
// actix-web 은 자체 미들웨어 트레이트라서 이 재사용이 안 됩니다. ★
// 정적 파일 서빙도 tower 서비스 하나로 끝납니다
let app = Router::new()
.route("/api/health", get(|| async { "ok" }))
.fallback_service(ServeDir::new("dist")); // SPA 배포에 그대로 씁니다
tower::retry, tower::balance)까지
서버와 클라이언트가 같은 부품을 씁니다(🧰 t07 의 네 겹을 그대로 코드로 옮길 수 있습니다).
Rust 를 고르지 말아야 할 때 ★
❌ 이럴 땐 손해입니다
- 요구사항이 매주 뒤집히는 초기 제품 — 타입을 고치는 비용이 매번 듭니다
- 팀에 Rust 를 아는 사람이 0명이고 마감이 3주
- 로직 대부분이 외부 API 대기 — 병목이 네트워크면 언어가 무의미합니다
- CRUD 관리자 화면 — 스프링·장고의 스캐폴딩을 이길 수 없습니다
- 데이터 과학 파이프라인 — 파이썬 생태계를 못 이깁니다(🐍 y01)
✅ 이럴 땐 확실히 이깁니다
- 지연 SLO 가 걸린 핫패스 — GC 스파이크가 SLO 를 깹니다
- 인스턴스 수가 곧 비용인 대규모 트래픽
- 엣지 · 사이드카 · 프록시 — 메모리 상한이 빡빡한 자리
- 장시간 도는 상태 저장 서버 — 메모리 누수·경합이 치명적
- 계산이 실제로 무거운 API — 이미지·압축·암호·파싱
실무에선 이렇게 — 한 서비스만 떼어 옮기는 전략 🔴 실무
스트랭글러 패턴 — 경로 단위로 잘라 냅니다
① 지금 상태
[Nginx] ──▶ [Spring Boot 모놀리스] ──▶ [Postgres]
② 가장 아픈 경로 하나만 Rust 로 (예: /api/feed — p99 가 튀는 곳)
[Nginx] ─┬─▶ /api/feed ──▶ [Axum 서비스] ──┐
└─▶ 나머지 전부 ─▶ [Spring Boot] ──┴─▶ [Postgres]
★ 같은 DB 를 봅니다. 데이터 이관이 없으니 되돌리기가 쉽습니다
③ 지표로 검증 (1~2주)
p99 · RSS · 에러율 · 인스턴스 수를 나란히 대시보드에 올립니다
문제가 생기면 Nginx 설정 한 줄로 원복 ★ 이 되돌림 경로가 핵심입니다
④ 이득이 확인된 경로만 계속 늘립니다
"전부 Rust 로" 는 목표가 아닙니다 — 이득이 없는 곳은 그대로 둡니다
팀에 도입할 때 실제로 드는 비용
| 항목 | 현실적인 값 |
|---|---|
| 첫 생산성 회복 | 경력 개발자 기준 3~6주 (소유권·수명이 대부분) |
| 빌드 시간 | 중간 규모 서버 풀빌드 2~6분, 증분 5~20초 |
| CI 시간 | 캐시 없으면 10분+ → Swatinem/rust-cache 로 2~3분 |
| 채용 | 지원자 수는 적지만 지원자 질은 높은 편입니다 |
| 라이브러리 공백 | 결제·메시징 SDK 가 없을 수 있습니다 — 먼저 확인 |
| 운영 부담 | 줄어듭니다 — OOM·GC 튜닝·의존성 취약점이 크게 감소 |
reqwest 로 직접 짜면 됩니다).
Axum 구조 완전 이해 — Router · Extractor · Layer 가 맞물리는 방식
실전 탭(🧰 t05)에서 “요청이 네 단계를 지난다”고만 봤습니다. 여기서는 그 네 단계가 각각 어떤 트레이트로 구현돼 있는지를 봅니다. 이걸 알면 “the trait Handler is not implemented” 같은 난해한 에러가 전부 읽히기 시작합니다 — Axum 을 쓰다 막히는 지점의 90%가 여기입니다.
tower 의 Service · Layer 는 이게 전부입니다
// tower::Service — "요청을 받아 미래의 응답을 준다"
pub trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
// 받을 준비가 됐는지 — 백프레셔가 여기서 나옵니다 ★
fn poll_ready(&mut self, cx: &mut Context<'_>)
-> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}
// tower::Layer — "서비스를 감싸 새 서비스를 만든다"
pub trait Layer<S> {
type Service;
fn layer(&self, inner: S) -> Self::Service;
}
// ★ Router 도 Service, 핸들러도 결국 Service, 미들웨어도 Service 입니다.
// 전부 같은 타입으로 조립되기 때문에 순서를 마음대로 바꿔 낄 수 있습니다.
Filter 체인과 같은 역할이지만,
런타임 리플렉션이 아니라 제네릭 중첩으로 만들어집니다.
그래서 실행 비용이 사실상 0이고(인라인됨),
대신 타입 이름이 무섭게 길어집니다 — Router 로 지우면 됩니다.
Router — merge · nest · fallback 세 가지
use axum::{Router, routing::{get, post, put, delete}};
fn user_routes() -> Router<AppState> {
Router::new()
.route("/", get(list).post(create)) // 같은 경로 다른 메서드
.route("/{id}", get(detail).put(update).delete(remove))
}
let api = Router::new()
.nest("/users", user_routes()) // 경로 앞에 접두사를 붙임
.nest("/orders", order_routes())
.merge(health_routes()); // 같은 층에 합침
let app = Router::new()
.nest("/api/v1", api)
.fallback(handler_404) // 매칭 실패
.method_not_allowed_fallback(handler_405) // 경로는 맞고 메서드가 틀림
.with_state(state); // ★ 반드시 마지막
// Axum 0.8 에서 바뀐 것 ★
// 경로 변수 표기가 :id → {id} 로 바뀌었습니다 (0.7 코드는 여기서 깨집니다)
// 와일드카드는 *rest → {*rest}
.route("/files/{*path}", get(serve_file))
.with_state() 는 맨 마지막에 부릅니다.
그 전까지 Router<AppState> 였다가 Router<()> 가 되고,
axum::serve 는 Router<()> 만 받습니다.
“상태를 붙였는데 타입이 안 맞는다”는 에러의 대부분이 순서 문제입니다.
이렇게도 쓸 수 있다 — 추출기를 직접 만들면 핸들러가 깨끗해집니다 🟡 중급
FromRequestParts — 본문을 안 건드리는 추출기
use axum::{extract::FromRequestParts, http::request::Parts};
/// 헤더에서 요청 ID 를 꺼내거나 없으면 새로 만듭니다
pub struct ReqId(pub String);
impl<S: Send + Sync> FromRequestParts<S> for ReqId {
type Rejection = std::convert::Infallible; // 실패할 수 없음
async fn from_request_parts(parts: &mut Parts, _s: &S)
-> Result<Self, Self::Rejection>
{
let id = parts.headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
Ok(ReqId(id))
}
}
// 쓰는 쪽 — 인자 하나 추가하면 끝입니다
async fn handler(ReqId(rid): ReqId) -> String {
format!("req={rid}")
}
#[async_trait] 가 필요 없습니다 —
트레이트에 async fn 을 그대로 씁니다(Rust 1.75+ 의 AFIT).
인터넷의 0.6·0.7 예제를 그대로 붙이면 async_trait 관련 에러가 나는 이유입니다.
검증까지 하는 Json 래퍼 — 실무에서 가장 많이 만듭니다
use axum::extract::{FromRequest, Request, rejection::JsonRejection};
/// axum::Json 을 감싸 "파싱 + validator 검증"을 한 번에
pub struct Valid<T>(pub T);
impl<S, T> FromRequest<S> for Valid<T>
where
S: Send + Sync,
T: serde::de::DeserializeOwned + validator::Validate,
axum::Json<T>: FromRequest<S, Rejection = JsonRejection>,
{
type Rejection = AppError; // 우리 공통 에러 (w03)
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let axum::Json(v) = axum::Json::<T>::from_request(req, state).await?;
v.validate()?; // 실패하면 422 로 나갑니다
Ok(Valid(v))
}
}
// 핸들러가 이렇게 짧아집니다 — 검증 코드가 사라집니다 ★
async fn create_user(
State(st): State<AppState>,
Valid(body): Valid<CreateUser>, // ← 반드시 마지막 인자
) -> Result<Json<User>, AppError> {
Ok(Json(service::create(&st, body).await?))
}
실무에선 이렇게 — 난해한 컴파일 에러를 30초 만에 푸는 법 🔴 실무
이 에러가 뜨면 원인은 넷 중 하나입니다 ★
error[E0277]: the trait bound `fn(...) -> ... {my_handler}:
Handler<_, _>` is not satisfied
--> src/routes.rs:12:31
|
12 | .route("/x", get(my_handler))
| --- ^^^^^^^^^^ the trait `Handler<_, _>` is
| | not implemented
# ★ 원인 체크리스트 — 위에서부터 확인하면 거의 다 걸립니다
# ① 본문 추출기(Json/Form/Bytes/String)가 마지막 인자가 아니다
# ② 인자 중 하나가 FromRequestParts 를 구현하지 않는 타입이다
# (예: &AppState 를 그냥 받으려 함 → State<AppState> 로)
# ③ 반환 타입이 IntoResponse 가 아니다
# (예: anyhow::Result<T> 를 그대로 반환 → 우리 AppError 로 감싸기)
# ④ 핸들러가 async 가 아니거나, 인자가 Send 가 아니다
# (Rc, RefCell, MutexGuard 를 await 너머로 들고 있음)
// 원인을 정확히 짚어 주는 매크로 ★ 개발 중에만 켭니다
// Cargo.toml: axum = { version = "0.8", features = ["macros"] }
#[axum::debug_handler] // ← 이 한 줄이 "몇 번째 인자가 문제"인지 알려 줍니다
async fn my_handler(
Json(body): Json<Req>, // ❌ 본문이 첫 인자
State(st): State<AppState>,
) -> impl IntoResponse { /* ... */ }
State 와 Extension — 무엇이 다른가
State<T> | Extension<T> | |
|---|---|---|
| 검사 시점 | 컴파일 타임 | 런타임 |
| 빠뜨리면 | 빌드 실패 | 500 에러 |
| 타입 개수 | 라우터당 하나 | 여러 개 |
| 주로 쓰는 곳 | 앱 전역 상태 | 미들웨어가 심은 값 |
| 예 | DB 풀 · 설정 · Redis | 인증된 사용자 |
// State 안에 여러 개를 넣고 싶을 때 — FromRef 를 씁니다 ★
use axum::extract::FromRef;
#[derive(Clone)]
struct AppState { db: PgPool, redis: RedisPool, cfg: Arc<Config> }
impl FromRef<AppState> for PgPool {
fn from_ref(s: &AppState) -> PgPool { s.db.clone() }
}
// 핸들러가 필요한 것만 꺼내 씁니다 — 테스트가 쉬워집니다
async fn h(State(db): State<PgPool>) -> &'static str { "ok" }
// 미들웨어가 심은 값은 Extension 으로 받습니다
async fn me(Extension(user): Extension<CurrentUser>) -> Json<CurrentUser> {
Json(user)
}
State, 미들웨어가 요청마다 채워 넣는 값만 Extension 으로 —
이 규칙 하나면 됩니다. 인증 사용자는 Extension 보다
전용 추출기로 만드는 편이 더 안전합니다(w06).
한 단계 더 — 핸들러가 Service 로 바뀌는 지점 🧬 전문가
Handler 트레이트는 왜 인자 개수마다 구현돼 있나
// Axum 내부는 대략 이렇게 생겼습니다 (매크로로 16개까지 생성)
impl<F, Fut, S, T1, T2, Res> Handler<(T1, T2), S> for F
where
F: FnOnce(T1, T2) -> Fut + Clone + Send + 'static,
Fut: Future<Output = Res> + Send,
T1: FromRequestParts<S> + Send, // 앞 인자들
T2: FromRequest<S> + Send, // ★ 마지막만 FromRequest
Res: IntoResponse,
{ /* ... */ }
// 그래서
// · 인자는 최대 16개
// · 마지막 하나만 본문을 소비할 수 있음
// · 전부 타입으로 검사되므로 런타임 라우팅 오류가 없음
//
// 반대로 단점: 조건 중 하나만 안 맞아도
// "Handler 가 구현되지 않았다"는 한 줄로만 나옵니다 → debug_handler 로 풉니다
라우터 없이 서비스만 꽂기 · 요청 본문 상한
use tower::ServiceExt; // .map_request, .oneshot 등
// ① 임의의 tower Service 를 경로에 직접 붙이기
let app = Router::new()
.route_service("/proxy", some_tower_service)
.nest_service("/static", ServeDir::new("assets"));
// ② 본문 크기 상한 ★ 기본값은 2MB 입니다 — 업로드가 있으면 반드시 조정
use tower_http::limit::RequestBodyLimitLayer;
use axum::extract::DefaultBodyLimit;
let app = app
.layer(DefaultBodyLimit::disable()) // Axum 기본 상한 해제
.layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)); // 50MB 로 재설정
// ③ 스트리밍 응답 — 파일을 통째로 메모리에 올리지 않습니다
use tokio_util::io::ReaderStream;
async fn download() -> impl IntoResponse {
let file = tokio::fs::File::open("big.bin").await.unwrap();
let body = axum::body::Body::from_stream(ReaderStream::new(file));
([(header::CONTENT_TYPE, "application/octet-stream")], body)
}
413 으로 죽습니다.
반대로 상한을 풀어 두면 메모리 고갈 공격에 노출됩니다 —
업로드 경로에만 별도 라우터를 만들어 상한을 따로 주는 것이 정석입니다.
Service/Layer 위에 얹혀 있습니다.
규칙은 두 줄로 요약됩니다 — 본문 추출기는 마지막 인자 하나, .with_state() 는 맨 마지막 호출.
막히면 #[axum::debug_handler] 를 붙이세요.프로젝트 골격 — 워크스페이스 · AppState · 설정 · 에러
main.rs 한 파일로 시작한 서버는 파일이 3,000줄이 되는 순간 손을 못 댑니다.
Rust 는 크레이트 경계가 곧 컴파일 단위이자 의존 규칙이라, 폴더만 나누는 다른 언어보다
구조를 강제하기가 훨씬 쉽습니다 — 잘못된 방향으로 use 하면 아예 빌드가 안 됩니다.
덤으로 증분 빌드가 빨라집니다.
워크스페이스 Cargo.toml — 버전을 한 곳에서
# 루트 Cargo.toml
[workspace]
resolver = "2"
members = ["crates/domain", "crates/infra", "crates/api", "bin/server"]
# ★ 의존성 버전을 여기 한 곳에서만 적습니다 (워크스페이스 상속)
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros", "ws"] }
tower = "0.5"
tower-http= { version = "0.6", features = ["trace","cors","compression-gzip","timeout","request-id"] }
serde = { version = "1", features = ["derive"] }
serde_json= "1"
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio-rustls","postgres","macros","migrate","uuid","chrono","json" ] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
uuid = { version = "1", features = ["v7","serde"] }
chrono = { version = "0.4", features = ["serde"] }
# ★ 릴리즈 프로필도 워크스페이스 루트에만 둡니다
[profile.release]
lto = "thin" # 링크 타임 최적화 — 빌드는 느려지고 실행은 빨라집니다
codegen-units = 1 # 최적화 품질 ↑ (빌드 시간 ↑)
strip = true # 디버그 심볼 제거 → 바이너리가 절반 이하로
panic = "abort" # 패닉 시 즉시 종료 (컨테이너에서 재시작이 더 안전)
panic = "abort" 는 신중하게 —
Axum 은 기본적으로 한 요청의 패닉이 그 태스크만 죽이고 서버는 살아 있습니다.
abort 로 두면 요청 하나 때문에 프로세스 전체가 죽습니다.
“죽고 재시작”이 안전한 무상태 서비스에만 쓰고,
그렇지 않으면 CatchPanicLayer 로 500 을 돌려주세요(w05).
폴더가 실제로 생기는 모양
myservice/
├─ Cargo.toml 워크스페이스 정의
├─ .env 개발용 (커밋 금지)
├─ config/
│ ├─ base.toml 공통 기본값
│ ├─ local.toml 개발
│ └─ production.toml 운영 (시크릿은 여기 두지 않습니다)
├─ migrations/ sqlx migrate — 파일 이름이 곧 버전 (w04)
│ ├─ 20260801120000_init.sql
│ └─ 20260812093000_add_index.sql
├─ crates/
│ ├─ domain/src/
│ │ ├─ lib.rs
│ │ ├─ user.rs 엔티티 + 불변식
│ │ ├─ error.rs DomainError (HTTP 를 모릅니다)
│ │ └─ ports.rs trait UserRepo, trait Clock …
│ ├─ infra/src/
│ │ ├─ pg/user_repo.rs UserRepo 의 sqlx 구현
│ │ ├─ redis/cache.rs
│ │ └─ http/payments.rs 외부 API 클라이언트
│ └─ api/src/
│ ├─ lib.rs pub fn router(state) -> Router
│ ├─ routes/users.rs 핸들러
│ ├─ dto.rs 요청·응답 구조체 (엔티티와 분리 ★)
│ ├─ error.rs AppError + IntoResponse
│ └─ mw/auth.rs 인증 추출기 (w06)
├─ bin/server/src/main.rs 30줄이면 충분합니다
└─ tests/ 통합 테스트 (w11)
From<User> for UserResponse 를 한 번 써 두면
변환이 컴파일러로 강제되므로, 분리 비용이 다른 언어보다 훨씬 쌉니다.
이렇게도 쓸 수 있다 — AppState 와 설정을 제대로 만드는 법 🟡 중급
AppState — Arc 를 어디에 두느냐가 관건입니다
use std::sync::Arc;
// ❌ 흔한 실수 — 통째로 Arc 로 감싸고 State<Arc<AppState>> 로 받기
// 핸들러마다 State(Arc<...>) 를 쓰게 되고 FromRef 조합이 지저분해집니다
// ✅ 권장 — 필드마다 이미 싸구려 clone 인 것들을 모읍니다
#[derive(Clone)] // ★ Clone 이 반드시 필요합니다
pub struct AppState {
pub db: sqlx::PgPool, // 내부가 Arc — clone 이 포인터 복사
pub redis: deadpool_redis::Pool, // 마찬가지
pub http: reqwest::Client, // 마찬가지
pub cfg: Arc<Config>, // 큰 구조체는 Arc 로
pub jwt: Arc<JwtKeys>, // 키 재료도 Arc
}
// 필요한 조각만 꺼내 쓰도록 FromRef 를 열어 둡니다 (w02)
impl axum::extract::FromRef<AppState> for sqlx::PgPool {
fn from_ref(s: &AppState) -> Self { s.db.clone() }
}
impl axum::extract::FromRef<AppState> for Arc<Config> {
fn from_ref(s: &AppState) -> Self { s.cfg.clone() }
}
// 요청마다 바뀌는 가변 상태가 필요하면 ★
// 읽기 위주 → Arc<RwLock<T>> 대신 arc-swap 이나 tokio::sync::RwLock
// 카운터 → Arc<AtomicU64>
// 절대 금지 → std::sync::Mutex 를 .await 너머로 들고 있기 (🧰 t04)
설정 — 실전·생태계 탭 t09 를 서버용으로 확장
// crates/api/src/config.rs (기본 규칙은 🧰 t09 참고)
use serde::Deserialize;
use secrecy::{SecretString, ExposeSecret};
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub app: AppCfg,
pub db: DbCfg,
pub auth: AuthCfg,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AppCfg { pub port: u16, pub env: Env, pub base_url: String }
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Env { Local, Staging, Production } // ★ 문자열이 아니라 enum 으로
#[derive(Debug, Deserialize, Clone)]
pub struct DbCfg {
pub url: SecretString, // Debug 로 찍어도 [REDACTED] 로 나옵니다
pub max_connections: u32,
pub acquire_timeout_secs: u64,
}
pub fn load() -> anyhow::Result<Config> {
let _ = dotenvy::dotenv(); // 개발 편의. 없으면 조용히 무시됩니다
let env = std::env::var("APP_ENV").unwrap_or_else(|_| "local".into());
let cfg: Config = config::Config::builder()
.add_source(config::File::with_name("config/base"))
.add_source(config::File::with_name(&format!("config/{env}")).required(false))
// APP__DB__MAX_CONNECTIONS=30 처럼 환경변수가 최우선 ★
.add_source(config::Environment::with_prefix("APP").separator("__"))
.build()?
.try_deserialize()?;
Ok(cfg)
}
application-{profile}.yml 과 같은 구조지만
차이가 하나 있습니다 — 구조체로 역직렬화되므로 오타가 시작 시점에 잡힙니다.
@Value 처럼 런타임에 null 이 들어오는 일이 없습니다.
실무에선 이렇게 — 에러 하나로 모든 응답을 통일합니다 🔴 실무
AppError — 실전 탭 t05 예제를 서비스 수준으로
// crates/api/src/error.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde::Serialize;
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("{0}")] Validation(String),
#[error("인증이 필요합니다")] Unauthorized,
#[error("권한이 없습니다")] Forbidden,
#[error("{0} 을(를) 찾을 수 없습니다")] NotFound(&'static str),
#[error("이미 존재합니다")] Conflict,
#[error("요청이 너무 많습니다")] TooManyRequests,
// ↓ 내부 오류 — 사용자에게 내용을 보여 주지 않습니다 ★
#[error(transparent)] Db(#[from] sqlx::Error),
#[error(transparent)] Other(#[from] anyhow::Error),
}
#[derive(Serialize)]
struct ErrBody<'a> {
code: &'a str, // 기계용 — 클라이언트가 분기하는 값
message: String, // 사람용
#[serde(skip_serializing_if = "Option::is_none")]
trace_id: Option<String>,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// sqlx 의 "행 없음"은 404 로 자동 변환 ★ 핸들러가 깨끗해집니다
let (status, code) = match &self {
AppError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "VALIDATION"),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED"),
AppError::Forbidden => (StatusCode::FORBIDDEN, "FORBIDDEN"),
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "NOT_FOUND"),
AppError::Conflict => (StatusCode::CONFLICT, "CONFLICT"),
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED"),
AppError::Db(sqlx::Error::RowNotFound) => (StatusCode::NOT_FOUND, "NOT_FOUND"),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL"),
};
let message = if status == StatusCode::INTERNAL_SERVER_ERROR {
// 내부 세부 정보는 로그로만 남깁니다 — 응답에 스택을 흘리지 마세요 ★
tracing::error!(error = ?self, "요청 처리 실패");
"서버 오류가 발생했습니다".to_string()
} else {
self.to_string()
};
let trace_id = tracing::Span::current()
.field("trace_id").map(|_| current_trace_id()); // w10
(status, Json(ErrBody { code, message, trace_id })).into_response()
}
}
공통 응답 포맷 — 정해 두면 클라이언트가 편합니다
// 성공 — 껍데기 없이 리소스를 그대로 돌려줍니다
{ "id": "018f...", "email": "a@b.com", "createdAt": "2026-08-26T01:00:00Z" }
// 목록 — 커서 페이지네이션 (offset 은 깊어질수록 느려집니다)
{
"items": [ { "id": "..." } ],
"nextCursor": "eyJpZCI6IjAxOGYifQ",
"hasMore": true
}
// 실패 — 항상 같은 모양 ★
{ "code": "VALIDATION", "message": "email 형식이 아닙니다",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" }
// 성공 응답 유틸 — 201 Created 와 Location 헤더까지 한 줄로
pub struct Created<T>(pub String, pub T);
impl<T: Serialize> IntoResponse for Created<T> {
fn into_response(self) -> Response {
(StatusCode::CREATED,
[(axum::http::header::LOCATION, self.0)],
Json(self.1)).into_response()
}
}
// 핸들러는 결과 타입 별칭 하나로 통일합니다 ★
pub type ApiResult<T> = Result<T, AppError>;
async fn create(/* ... */) -> ApiResult<Created<UserRes>> {
let u = svc::create(...).await?; // ? 하나로 전파
Ok(Created(format!("/api/v1/users/{}", u.id), u.into()))
}
code 로 분기하게 하면,
나중에 상태 코드를 조정해도 앱이 깨지지 않습니다.
한 단계 더 — main.rs 는 30줄이면 충분합니다 🧬 전문가
조립만 하는 main
// bin/server/src/main.rs
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cfg = api::config::load()?; // ① 설정 (실패하면 여기서 종료)
api::observability::init(&cfg)?; // ② 로그·트레이싱 (w10)
let db = infra::pg::pool(&cfg.db).await?; // ③ 커넥션 풀 (w04)
sqlx::migrate!("./migrations").run(&db).await?; // ④ 마이그레이션
let state = api::AppState {
db,
redis: infra::redis::pool(&cfg.redis)?,
http: infra::http::client()?,
cfg: std::sync::Arc::new(cfg.clone()),
jwt: std::sync::Arc::new(api::auth::JwtKeys::new(&cfg.auth)?),
};
let app = api::router(state); // ⑤ 라우터 조립
let addr = format!("0.0.0.0:{}", cfg.app.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(%addr, env = ?cfg.app.env, "서버를 시작합니다");
axum::serve(listener, app)
.with_graceful_shutdown(api::shutdown_signal()) // ⑥ 정상 종료 (w10)
.await?;
Ok(())
}
api::router(state) 가 Router 를 돌려주는 것이 핵심입니다 —
테스트에서 같은 함수를 불러 서버를 띄우지 않고 요청을 넣을 수 있습니다(w11).
main 안에 라우터를 인라인으로 쓰면 그 순간 통합 테스트가 어려워집니다.
의존성 역전 — 트레이트를 domain 에 둡니다
// crates/domain/src/ports.rs — 인터페이스만 (sqlx 를 모릅니다)
#[allow(async_fn_in_trait)]
pub trait UserRepo: Send + Sync + 'static {
async fn find(&self, id: UserId) -> Result<Option<User>, DomainError>;
async fn insert(&self, u: &NewUser) -> Result<User, DomainError>;
}
// crates/infra/src/pg/user_repo.rs — 실제 구현
pub struct PgUserRepo { pool: sqlx::PgPool }
impl UserRepo for PgUserRepo {
async fn find(&self, id: UserId) -> Result<Option<User>, DomainError> {
sqlx::query_as!(UserRow, "SELECT .. WHERE id = $1", id.0)
.fetch_optional(&self.pool).await
.map(|o| o.map(Into::into))
.map_err(|e| DomainError::Storage(e.to_string()))
}
/* ... */
}
// ★ 정적 디스패치 vs 동적 디스패치
// 제네릭 : fn svc<R: UserRepo>(repo: &R) → 빠름, 타입이 번짐
// Arc<dyn>: Arc<dyn UserRepo> → AppState 에 넣기 편함
// 서버에서는 호출당 비용이 무의미하므로 Arc<dyn ...> 이 실용적입니다
// (단, dyn 으로 쓰려면 async_trait 크레이트가 아직 필요합니다)
AppState 는 이미 Arc 인 것들을 모은 Clone 구조체로 둡니다.
설정은 시작할 때 구조체로 한 번에 검증하고, 실패는 AppError 하나가 전부 HTTP 로 번역합니다.
main.rs 는 조립만 합니다.데이터베이스 — sqlx 를 서비스 수준으로 쓰기
실전 탭(🧰 t06)에서 query_as! 와 트랜잭션 기본형을 봤습니다.
실제 서비스에서 사고가 나는 자리는 그다음입니다 —
풀이 고갈되고, 트랜잭션이 길어지고, 루프 안에서 쿼리가 돌고, 테스트가 서로를 오염시킵니다.
이 절은 그 네 가지를 각각 막는 법입니다.
풀 설정 — 기본값을 그대로 쓰면 안 되는 값들
use sqlx::postgres::{PgPoolOptions, PgConnectOptions};
use std::time::Duration;
pub async fn pool(cfg: &DbCfg) -> anyhow::Result<sqlx::PgPool> {
let opts: PgConnectOptions = cfg.url.expose_secret().parse()?;
let opts = opts
.application_name("myservice") // pg_stat_activity 에서 보입니다 ★
.statement_cache_capacity(200); // prepared statement 캐시
let pool = PgPoolOptions::new()
// ★ 앱 인스턴스 수 × max_connections ≤ DB 의 max_connections − 여유
// 예: DB 100, 인스턴스 4대, 관리도구 여유 20 → (100-20)/4 = 20
.max_connections(20)
.min_connections(2) // 콜드 스타트 시 첫 요청 지연 완화
.acquire_timeout(Duration::from_secs(3)) // 대기 상한 ★ 무한 대기 금지
.idle_timeout(Duration::from_secs(600)) // 유휴 연결 정리
.max_lifetime(Duration::from_secs(1800)) // ★ LB·프록시가 끊기 전에 우리가 먼저
.test_before_acquire(true) // 죽은 연결을 잡아냅니다
.connect_with(opts).await?;
Ok(pool)
}
// 풀 상태를 지표로 내보내세요 (w10) — 장애 원인 파악이 30분에서 3분으로
// pool.size(), pool.num_idle()
max_lifetime 을 꼭 거세요. AWS RDS Proxy · PgBouncer ·
클라우드 로드밸런서는 일정 시간 지난 연결을 조용히 끊습니다.
우리가 먼저 재생성하지 않으면 “가끔 첫 쿼리만 실패”하는 유령 버그가 됩니다.
PgBouncer 를 쓸 때 반드시 바꿀 것
| 모드 | sqlx 설정 |
|---|---|
| session | 그대로 써도 됩니다 |
| transaction | .statement_cache_capacity(0) ★ 필수 |
| statement | 트랜잭션이 안 됩니다 — 쓰지 마세요 |
# transaction 풀링에서 캐시를 끄지 않으면 나오는 에러
ERROR: prepared statement "sqlx_s_3" already exists
# 원인 — PgBouncer 가 요청마다 다른 실제 연결에 붙이는데,
# sqlx 는 "이 연결에 이미 준비해 뒀다"고 믿습니다.
# 해결 — statement_cache_capacity(0) 또는 URL 에
# ?statement_cache_capacity=0 를 붙입니다
SeaORM · Diesel 을 고르는 기준 (t06 확장)
| sqlx | SeaORM | Diesel | |
|---|---|---|---|
| 쿼리 | SQL 직접 | 빌더 + 엔티티 | 타입 DSL |
| 컴파일 검증 | 실제 DB 대조 | 없음 | 스키마 대조 |
| 비동기 | 네이티브 | 네이티브 | 동기(+bb8) |
| 관계 로딩 | 직접 | 자동 | 직접 |
| 빌드 시 DB | 필요(오프라인 캐시로 회피) | 불필요 | 불필요 |
| 적합 | 복잡한 쿼리 | CRUD 다수 | 동기 배치 |
이렇게도 쓸 수 있다 — 마이그레이션 · 오프라인 빌드 · 트랜잭션 조립 🟡 중급
마이그레이션 — 파일이 곧 버전입니다
# 도구 설치 (postgres 기능만)
cargo install sqlx-cli --no-default-features --features rustls,postgres
export DATABASE_URL=postgres://app:pw@localhost:5432/myservice
sqlx database create
sqlx migrate add -r init # -r 은 up/down 두 파일을 만듭니다
# migrations/20260826010000_init.up.sql 을 작성한 뒤
sqlx migrate run # 적용
sqlx migrate revert # 되돌리기 (down 이 있을 때)
sqlx migrate info # 무엇이 적용됐는지
# ★ 컴파일 타임 검증 캐시를 만들어 커밋합니다 (CI 에 DB 가 없어도 빌드됨)
cargo sqlx prepare --workspace -- --all-targets
git add .sqlx/
-- 운영에서 안전한 마이그레이션 규칙 ★
-- ❌ 이 한 줄이 큰 테이블에서 수 분간 쓰기를 막습니다
CREATE INDEX idx_links_owner ON links(owner_id);
-- ✅ 락을 잡지 않는 방식 (트랜잭션 밖에서 실행해야 합니다)
CREATE INDEX CONCURRENTLY idx_links_owner ON links(owner_id);
-- ❌ 컬럼 삭제·이름 변경은 배포 중 구버전 앱을 깨뜨립니다
-- ✅ 3단계로: ① 새 컬럼 추가 → ② 양쪽 쓰기 배포 → ③ 옛 컬럼 삭제
sqlx::migrate!() 를 앱 시작에서 돌릴지는 신중히 결정하세요.
인스턴스가 여러 대면 동시에 실행됩니다(sqlx 가 락을 잡아 주긴 합니다).
K8s 라면 initContainer 나 Job 으로 분리하는 편이 안전합니다.
트랜잭션을 함수로 쪼개는 법
use sqlx::{PgPool, Postgres, Transaction, Executor};
// ★ 핵심 관용구 — Executor 제네릭을 받으면
// "풀에서 바로" 도, "트랜잭션 안에서" 도 같은 함수를 씁니다
pub async fn find_by_email<'e, E>(ex: E, email: &str)
-> Result<Option<User>, sqlx::Error>
where E: Executor<'e, Database = Postgres>
{
sqlx::query_as!(User, "SELECT id, email FROM users WHERE email = $1", email)
.fetch_optional(ex).await
}
pub async fn transfer(pool: &PgPool, from: Uuid, to: Uuid, amt: i64)
-> Result<(), AppError>
{
let mut tx: Transaction<'_, Postgres> = pool.begin().await?;
// 잔액 확인은 반드시 잠금과 함께 ★ 안 하면 이중 출금이 납니다
let bal: i64 = sqlx::query_scalar!(
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", from)
.fetch_one(&mut *tx).await?;
if bal < amt { return Err(AppError::Validation("잔액 부족".into())); }
// ↑ 여기서 반환하면 tx 가 Drop 되며 자동 롤백됩니다
sqlx::query!("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amt, from)
.execute(&mut *tx).await?;
sqlx::query!("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amt, to)
.execute(&mut *tx).await?;
tx.commit().await?; // ★ 이걸 빠뜨리면 조용히 롤백
Ok(())
}
// 데드락 대비 — 항상 같은 순서로 잠그세요 (예: id 오름차순)
// 재시도는 SQLSTATE 40001(직렬화 실패)·40P01(데드락) 일 때만
실무에선 이렇게 — N+1 을 없애고, 테스트 DB 를 격리합니다 🔴 실무
N+1 — ORM 이 없어도 생깁니다
// ❌ 게시글 50개 + 작성자 50번 조회 = 51번 왕복 (각 1ms 여도 51ms)
let posts = repo::list_posts(&pool, 50).await?;
let mut out = Vec::new();
for p in posts {
let author = repo::find_user(&pool, p.author_id).await?; // ← 루프 안 쿼리
out.push(PostRes::from(p, author));
}
// ✅ 방법 ① — JOIN 한 번
let rows = sqlx::query_as!(PostWithAuthor, r#"
SELECT p.id, p.title, u.id AS "author_id", u.name AS "author_name"
FROM posts p JOIN users u ON u.id = p.author_id
ORDER BY p.created_at DESC LIMIT $1
"#, 50i64).fetch_all(&pool).await?;
// ✅ 방법 ② — 두 번에 나눠 받고 메모리에서 합치기 (JOIN 이 무거울 때)
let ids: Vec<Uuid> = posts.iter().map(|p| p.author_id).collect();
let users = sqlx::query_as!(User,
"SELECT id, name FROM users WHERE id = ANY($1)", &ids) // ★ ANY($1)
.fetch_all(&pool).await?;
let by_id: HashMap<Uuid, User> = users.into_iter().map(|u| (u.id, u)).collect();
// ✅ 방법 ③ — 여러 독립 쿼리는 동시에 (같은 풀에서 커넥션 2개를 씁니다)
let (a, b) = tokio::try_join!(
repo::stats(&pool, id),
repo::recent(&pool, id),
)?;
tracing 에서
sqlx=debug 를 켜고 요청 하나를 보낸 뒤 쿼리 로그 줄 수를 세는 것입니다.
요청 1건에 쿼리가 10줄 넘게 찍히면 거의 확실합니다.
운영에서는 요청당 쿼리 수를 지표로 내보내세요(w10).
테스트 DB — 서로를 오염시키지 않게
// 방법 ① 트랜잭션 롤백 — 가장 빠릅니다 (단위·리포지토리 테스트)
#[sqlx::test] // ★ sqlx 가 테스트마다 임시 DB 를 만들어 줍니다
async fn 이메일로_찾는다(pool: PgPool) -> sqlx::Result<()> {
sqlx::query!("INSERT INTO users(id,email) VALUES($1,$2)", id, "a@b.com")
.execute(&pool).await?;
let u = find_by_email(&pool, "a@b.com").await?;
assert!(u.is_some());
Ok(()) // 테스트가 끝나면 DB 가 통째로 사라집니다
}
// 방법 ② 테스트마다 별도 스키마 (병렬 실행에 유리)
// CREATE SCHEMA test_abc123; SET search_path TO test_abc123;
// 방법 ③ testcontainers — 진짜 Postgres 컨테이너 (통합 테스트, w11)
# Cargo.toml — 테스트 편의 기능
[dev-dependencies]
sqlx = { workspace = true, features = ["migrate"] }
# #[sqlx::test] 는 migrations/ 를 자동으로 적용합니다
# 픽스처를 쓰고 싶으면 fixtures("users") 로 tests/fixtures/users.sql 로드
@Transactional 테스트와 같은 발상이지만,
#[sqlx::test] 는 한 걸음 더 나아가 테스트마다 데이터베이스 자체를 새로 만들고 지웁니다.
느릴 것 같지만 로컬 Postgres 에서 테스트당 수십 ms 수준이라 실용적입니다.
한 단계 더 — 타입 매핑과 커서 페이지네이션 🧬 전문가
Postgres 타입 ↔ Rust 타입
| Postgres | Rust |
|---|---|
bigint | i64 (u64 는 없습니다 ★) |
uuid | uuid::Uuid |
timestamptz | DateTime<Utc> |
timestamp | NaiveDateTime (쓰지 마세요) |
numeric | rust_decimal::Decimal |
jsonb | sqlx::types::Json<T> |
text[] | Vec<String> |
| enum 타입 | #[derive(sqlx::Type)] |
// DB enum 을 Rust enum 으로 ★
#[derive(sqlx::Type, Serialize, Deserialize, Debug)]
#[sqlx(type_name = "link_status", rename_all = "snake_case")]
pub enum LinkStatus { Active, Disabled, Expired }
// 매크로가 타입을 못 맞힐 때 별칭으로 알려 줍니다
sqlx::query_as!(Row, r#"
SELECT id,
status AS "status: LinkStatus", -- 커스텀 타입 지정
meta AS "meta!: Json<Meta>", -- ! 는 "널 아님"을 단언
note AS "note?: String" -- ? 는 "널 가능"
FROM links WHERE id = $1"#, id)
.fetch_one(&pool).await?;
커서 페이지네이션 — OFFSET 은 깊어질수록 느려집니다
-- ❌ 100,000 페이지째 = 10만 행을 읽고 버립니다
SELECT * FROM links ORDER BY created_at DESC LIMIT 20 OFFSET 2000000;
-- ✅ 커서 — 마지막으로 본 값 이후만 읽습니다 (인덱스로 바로 점프)
SELECT * FROM links
WHERE (created_at, id) < ($1, $2) -- 복합 키로 동점 처리 ★
ORDER BY created_at DESC, id DESC
LIMIT 20;
CREATE INDEX idx_links_cursor ON links (created_at DESC, id DESC);
// 커서를 불투명 문자열로 감싸 클라이언트에 노출합니다
#[derive(Serialize, Deserialize)]
struct Cursor { ts: DateTime<Utc>, id: Uuid }
fn encode(c: &Cursor) -> String {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
URL_SAFE_NO_PAD.encode(serde_json::to_vec(c).unwrap())
}
// 응답 — hasMore 는 LIMIT+1 로 하나 더 읽어 판단합니다
let mut rows = fetch(pool, cursor, limit + 1).await?;
let has_more = rows.len() > limit as usize;
rows.truncate(limit as usize);
created_at 만으로 커서를 만들면 같은 시각의 행에서 문제가 생깁니다 —
반드시 (정렬키, 기본키) 복합으로 비교하세요.
acquire_timeout · max_lifetime 을 반드시 겁니다.
트랜잭션은 밀리초 단위로 짧게 — 외부 API 호출을 절대 안에 넣지 마세요.
N+1 은 ANY($1) 나 JOIN 으로 없애고, 테스트는 #[sqlx::test] 로 DB 를 격리합니다.미들웨어 · tower — 붙이는 순서가 곧 동작입니다
실전 탭(🧰 t05)에서 .layer() 를 네 줄 붙여 봤습니다.
그런데 왜 로깅을 가장 마지막에 붙여야 하는지, 왜 CORS 를 타임아웃 바깥에 둬야 하는지는
설명하지 않았습니다. 이 절은 양파 구조를 정확히 이해하고,
필요한 미들웨어를 직접 만드는 데까지 갑니다.
실서비스 레이어 스택 — 그대로 복사해 쓰세요
use tower::ServiceBuilder;
use tower_http::{
catch_panic::CatchPanicLayer,
request_id::{MakeRequestUuid, SetRequestIdLayer, PropagateRequestIdLayer},
trace::{TraceLayer, DefaultMakeSpan, DefaultOnResponse},
cors::{CorsLayer, AllowOrigin},
compression::CompressionLayer,
timeout::TimeoutLayer,
sensitive_headers::SetSensitiveRequestHeadersLayer,
};
use axum::http::{header, Method};
pub fn router(state: AppState) -> Router {
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list([
"https://app.example.com".parse().unwrap(),
])) // ★ permissive 를 운영에 쓰지 마세요
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true) // 쿠키를 쓸 때만
.max_age(Duration::from_secs(3600)); // 프리플라이트 캐시
// ★ ServiceBuilder 는 "위에서 아래" 가 "바깥에서 안쪽" 입니다
let middleware = ServiceBuilder::new()
.layer(CatchPanicLayer::new()) // ① 최후의 방어
.layer(SetSensitiveRequestHeadersLayer::new([header::AUTHORIZATION]))
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.layer(TraceLayer::new_for_http() // ② 여기서부터 로그
.make_span_with(DefaultMakeSpan::new().include_headers(false))
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO)))
.layer(PropagateRequestIdLayer::x_request_id()) // 응답에도 실어 보냄
.layer(cors) // ③ 프리플라이트는 여기서 끝
.layer(CompressionLayer::new()) // ④ 나가는 길에 압축
.layer(TimeoutLayer::new(Duration::from_secs(15))); // ⑤ 핸들러 상한
Router::new()
.nest("/api/v1", api_routes())
.route("/health", get(health)) // ★ 헬스체크는 로그·인증 제외 라우터로
.layer(middleware)
.with_state(state)
}
SetSensitiveRequestHeadersLayer 를 꼭 넣으세요.
이걸 빼고 include_headers(true) 로 두면
Authorization 헤더가 그대로 로그에 찍힙니다 —
실제로 자주 나는 사고입니다.
어디에 둬야 하나 — 판단표
| 미들웨어 | 위치와 이유 |
|---|---|
CatchPanic | 가장 바깥 — 안쪽 어디서 터져도 잡아야 합니다 |
SetRequestId | 바깥 — 로그·트레이스가 이 값을 씁니다 |
Trace | 바깥 — 막힌 요청도 기록돼야 합니다 |
Cors | 중간 — 인증보다 바깥(프리플라이트에는 토큰이 없습니다) |
Compression | 중간 — 응답을 만든 뒤 압축합니다 |
RateLimit | 인증 앞뒤 둘 다 — IP 기준은 앞, 사용자 기준은 뒤 |
Timeout | 안쪽 — 핸들러 실행 시간만 재야 합니다 |
| 인증 | 가장 안쪽 — 라우터별로 다르게 걸어야 하므로 |
// 인증은 전역이 아니라 "라우터 단위" 로 거는 것이 정석입니다 ★
let public = Router::new()
.route("/auth/login", post(login))
.route("/auth/signup", post(signup));
let private = Router::new()
.route("/me", get(me))
.route("/links", get(list).post(create))
.route_layer(axum::middleware::from_fn_with_state( // ★ route_layer
state.clone(), auth::require_login));
let api = public.merge(private);
// .layer() → 매칭 실패(404)한 요청에도 적용됩니다
// .route_layer() → 이 라우터의 경로에 매칭된 요청에만 적용됩니다 ★
// 인증에 .layer() 를 쓰면 존재하지 않는 경로에도 401 이 나가
// "경로가 있는지 없는지"를 공격자에게 알려 주지 않는 장점도 있습니다
이렇게도 쓸 수 있다 — 속도 제한과 요청 ID 전파 🟡 중급
tower_governor — IP·사용자별 속도 제한
[dependencies]
tower_governor = "0.4" # GCRA(누수 버킷) 알고리즘, 메모리 기반
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
// 초당 5건, 순간 버스트 20건까지 허용
let conf = std::sync::Arc::new(
GovernorConfigBuilder::default()
.per_second(5)
.burst_size(20)
.use_headers() // X-RateLimit-* 헤더를 응답에 붙입니다
.finish().unwrap()
);
// 오래된 버킷을 주기적으로 청소하지 않으면 메모리가 샙니다 ★
let limiter = conf.limiter().clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(60));
loop { tick.tick().await; limiter.retain_recent(); }
});
let app = Router::new()
.route("/auth/login", post(login))
.layer(GovernorLayer { config: conf }); // 로그인에 특히 강하게
// ★ 프록시 뒤에 있으면 peer IP 가 전부 LB 주소입니다.
// SmartIpKeyExtractor 로 X-Forwarded-For 를 보게 하되,
// 신뢰할 수 있는 프록시 뒤일 때만 쓰세요(아니면 위조됩니다).
INCR + EXPIRE, 또는 Lua 로 토큰 버킷)이나
API 게이트웨이 층에서 처리하세요.
요청 ID — 로그와 응답과 하위 호출을 잇습니다
// ① 들어올 때: 헤더에 있으면 쓰고, 없으면 UUID 생성
// SetRequestIdLayer::x_request_id(MakeRequestUuid)
// ② 로그에 심기: TraceLayer 의 span 필드로
// ③ 나갈 때: PropagateRequestIdLayer 가 응답 헤더에도 붙임
// ④ 하위 호출에 전달: reqwest 요청에 같은 값을 실어 보냄 ★
use tower_http::request_id::RequestId;
async fn handler(
req_id: Option<axum::Extension<RequestId>>,
State(st): State<AppState>,
) -> ApiResult<Json<Res>> {
let rid = req_id
.and_then(|e| e.0.header_value().to_str().ok().map(str::to_owned))
.unwrap_or_default();
let out = st.http.get("https://internal/api/x")
.header("x-request-id", &rid) // ★ 이 한 줄로 서비스가 이어집니다
.send().await?;
/* ... */
}
trace_id 를 넣어 둔 이유(w03)가 이것입니다.
실무에선 이렇게 — 미들웨어를 직접 만듭니다 🔴 실무
쉬운 길 — middleware::from_fn
use axum::{middleware::Next, extract::Request, response::Response};
/// 응답 시간을 재고 느린 요청에 경고를 남깁니다
async fn timing(req: Request, next: Next) -> Response {
let path = req.uri().path().to_owned();
let method = req.method().clone();
let started = std::time::Instant::now();
let mut res = next.run(req).await; // ★ 안쪽 서비스 호출
let ms = started.elapsed().as_millis() as u64;
if ms > 500 {
tracing::warn!(%method, %path, ms, "느린 요청");
}
res.headers_mut().insert(
"x-response-time-ms",
ms.to_string().parse().unwrap());
res
}
// 상태가 필요하면 from_fn_with_state
async fn require_api_key(
State(st): State<AppState>,
req: Request,
next: Next,
) -> Result<Response, AppError> {
let key = req.headers().get("x-api-key")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
if !st.cfg.api_keys.contains(key) { return Err(AppError::Forbidden); }
Ok(next.run(req).await)
}
let app = Router::new()
.route("/x", get(h))
.layer(axum::middleware::from_fn(timing))
.layer(axum::middleware::from_fn_with_state(state.clone(), require_api_key));
from_fn 으로 충분합니다.
직접 Service 를 구현해야 하는 경우는
poll_ready 에서 백프레셔를 걸거나,
tonic 같은 다른 서버와 공유할 때 정도입니다.
정석 — Layer + Service 직접 구현
use std::task::{Context, Poll};
use tower::{Layer, Service};
#[derive(Clone)]
pub struct ConcurrencyGuard<S> {
inner: S,
sem: std::sync::Arc<tokio::sync::Semaphore>,
}
#[derive(Clone)]
pub struct ConcurrencyGuardLayer(pub std::sync::Arc<tokio::sync::Semaphore>);
impl<S> Layer<S> for ConcurrencyGuardLayer {
type Service = ConcurrencyGuard<S>;
fn layer(&self, inner: S) -> Self::Service {
ConcurrencyGuard { inner, sem: self.0.clone() }
}
}
impl<S, B> Service<Request<B>> for ConcurrencyGuard<S>
where
S: Service<Request<B>, Response = Response> + Clone + Send + 'static,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<Box<dyn Future<Output =
Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<B>) -> Self::Future {
// ★ Clone 트릭 — poll_ready 를 통과한 인스턴스를 써야 합니다
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
let sem = self.sem.clone();
Box::pin(async move {
let _permit = sem.acquire().await.unwrap(); // 동시 실행 수 제한
inner.call(req).await
})
}
}
std::mem::replace 트릭을 반드시 기억하세요.
tower 규약상 poll_ready 로 준비를 확인한 바로 그 인스턴스에
call 을 해야 합니다. self.inner.clone() 을 그대로 쓰면
준비되지 않은 복제본을 호출하는 셈이라 간헐적 패닉이 납니다.
한 단계 더 — 백프레셔 · 부하 차단 · 압축의 함정 🧬 전문가
과부하일 때 “줄 세우기” 대신 “빨리 거절하기”
use tower::{ServiceBuilder, load_shed::LoadShedLayer,
limit::ConcurrencyLimitLayer, buffer::BufferLayer};
// 순서가 중요합니다 ★
// LoadShed(바깥) → Buffer → ConcurrencyLimit(안쪽)
// 준비 안 됐을 때 기다리지 않고 즉시 503 을 돌려줍니다
let svc = ServiceBuilder::new()
.layer(HandleErrorLayer::new(|e| async move {
// LoadShed 의 Overloaded 에러를 503 으로 번역
(StatusCode::SERVICE_UNAVAILABLE, "잠시 후 다시 시도해 주세요")
}))
.layer(LoadShedLayer::new())
.layer(BufferLayer::new(1024))
.layer(ConcurrencyLimitLayer::new(256))
.service(app);
// ★ 왜 이게 중요한가
// 요청을 무제한으로 큐에 쌓으면 메모리가 늘고, 사용자는 이미 포기했는데
// 서버는 그 요청을 계속 처리합니다("좀비 작업").
// 빨리 거절하면 남은 용량으로 나머지를 정상 처리할 수 있습니다.
tokio-console 로 태스크가 어디서 대기하는지 볼 수 있습니다(w10).
압축·CORS 에서 실제로 겪는 문제
| 증상 | 원인과 해결 |
|---|---|
| SSE·WebSocket 이 끊김 | 압축 레이어가 스트림을 버퍼링합니다 — 해당 경로를 별도 라우터로 분리(w08) |
| 이미지가 더 커짐 | 이미 압축된 형식을 다시 압축 — .compress_when() 으로 콘텐츠 타입 제한 |
| 프리플라이트 401 | 인증이 CORS 보다 바깥 — OPTIONS 에는 토큰이 없습니다 |
| 쿠키가 안 감 | allow_credentials(true) 와 allow_origin 에 * 금지는 함께 갑니다 |
| 타임아웃이 안 걸림 | 핸들러가 .await 없이 CPU 를 돌면 취소점이 없습니다 — spawn_blocking |
| 느린 업로드에 무방비 | 본문 읽기 자체에 상한이 필요 — RequestBodyTimeoutLayer |
// 스트리밍 경로를 압축에서 제외하는 실전 패턴 ★
let streaming = Router::new()
.route("/events", get(sse_handler))
.route("/ws", get(ws_handler));
let normal = Router::new()
.nest("/api/v1", api_routes())
.layer(CompressionLayer::new()); // 압축은 여기에만
let app = normal.merge(streaming).layer(common_middleware);
.layer() 는 나중에 붙인 것이 바깥이고,
헷갈리면 ServiceBuilder 를 써서 읽는 순서와 실행 순서를 맞추세요.
싸고 무조건인 것은 바깥, 비싸고 조건부인 것은 안쪽이 원칙입니다.
인증은 .layer() 가 아니라 .route_layer() 로 라우터 단위로 겁니다.인증 · 인가 — 추출기가 검사를 강제하게 만들기
인증에서 사고가 나는 자리는 알고리즘이 아니라 빠뜨림입니다 — 새 엔드포인트를 추가하면서 권한 검사를 안 넣는 것. Rust 에서는 “인증된 사용자”를 타입으로 만들어 핸들러 인자에 요구하면 검사를 빠뜨린 핸들러가 애초에 컴파일되지 않게 할 수 있습니다. 그 방법을 만듭니다.
비밀번호 — argon2 하나만 기억하면 됩니다
[dependencies]
argon2 = "0.5"
password-hash = "0.5"
rand_core = { version = "0.6", features = ["std"] }
use argon2::{Argon2, PasswordHasher, PasswordVerifier, PasswordHash,
password_hash::{SaltString, rand_core::OsRng}};
pub fn hash_password(plain: &str) -> anyhow::Result<String> {
let salt = SaltString::generate(&mut OsRng); // 소금은 매번 새로
let hash = Argon2::default() // Argon2id 기본 파라미터
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("{e}"))?
.to_string(); // "$argon2id$v=19$m=19456,t=2,p=1$..." 형태 ★
Ok(hash) // 소금과 파라미터가 문자열 안에 함께 들어갑니다
}
pub fn verify_password(plain: &str, stored: &str) -> bool {
PasswordHash::new(stored)
.map(|p| Argon2::default().verify_password(plain.as_bytes(), &p).is_ok())
.unwrap_or(false)
}
// ★ 반드시 spawn_blocking 으로 감싸세요
// argon2 검증은 의도적으로 수십~수백 ms 가 걸리는 CPU 작업입니다.
// 그대로 두면 워커 스레드가 그동안 통째로 막힙니다 (🧰 t04)
let ok = tokio::task::spawn_blocking({
let (p, h) = (plain.to_owned(), stored.to_owned());
move || verify_password(&p, &h)
}).await?;
// ★ 사용자가 없을 때도 더미 해시로 검증을 수행하세요.
// 바로 반환하면 응답 시간 차이로 "그 이메일이 있는지" 가 새어 나갑니다.
m 을 4MiB 로 내리는 것은 공격자를 도와주는 일입니다.
JWT — 발급과 검증
use jsonwebtoken::{encode, decode, Header, Algorithm,
EncodingKey, DecodingKey, Validation};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // 사용자 ID
pub role: Role, // 역할
pub exp: i64, // 만료 (필수 ★)
pub iat: i64, // 발급 시각
pub iss: String, // 발급자
pub aud: String, // 대상 — 다른 서비스 토큰이 넘어오는 걸 막습니다
pub jti: String, // 토큰 고유 ID (폐기 목록용)
}
pub struct JwtKeys { enc: EncodingKey, dec: DecodingKey, val: Validation }
impl JwtKeys {
pub fn new(cfg: &AuthCfg) -> anyhow::Result<Self> {
// HS256: 대칭키(간단) · RS256/EdDSA: 비대칭(공개키 배포 가능) ★
let secret = cfg.jwt_secret.expose_secret().as_bytes();
let mut val = Validation::new(Algorithm::HS256);
val.set_issuer(&[cfg.issuer.as_str()]);
val.set_audience(&[cfg.audience.as_str()]);
val.leeway = 5; // 시계 오차 허용(초)
Ok(Self {
enc: EncodingKey::from_secret(secret),
dec: DecodingKey::from_secret(secret),
val,
})
}
pub fn issue(&self, user: &User, ttl: Duration) -> anyhow::Result<String> {
let now = chrono::Utc::now();
let c = Claims {
sub: user.id.to_string(), role: user.role,
iat: now.timestamp(),
exp: (now + ttl).timestamp(),
iss: "myservice".into(), aud: "myservice-web".into(),
jti: uuid::Uuid::now_v7().to_string(),
};
Ok(encode(&Header::new(Algorithm::HS256), &c, &self.enc)?)
}
pub fn verify(&self, token: &str) -> Result<Claims, AppError> {
decode::<Claims>(token, &self.dec, &self.val)
.map(|d| d.claims)
.map_err(|_| AppError::Unauthorized)
}
}
Validation 의 validate_exp 를 끄기,
② 알고리즘을 토큰의 alg 헤더에서 읽어 쓰기(alg: none 공격),
③ 토큰에 개인정보나 권한 원본 데이터 넣기 — JWT 는 암호화가 아니라 서명이라
누구나 내용을 읽을 수 있습니다.
이렇게도 쓸 수 있다 — 인증을 타입으로 강제하는 추출기 🟡 중급
AuthUser — 이 타입이 있으면 이미 검증된 것입니다
use axum::{extract::FromRequestParts, http::request::Parts, RequestPartsExt};
use axum_extra::{TypedHeader, headers::{Authorization, authorization::Bearer}};
#[derive(Debug, Clone, Serialize)]
pub struct AuthUser { pub id: Uuid, pub role: Role }
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, st: &AppState)
-> Result<Self, Self::Rejection>
{
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| AppError::Unauthorized)?;
let claims = st.jwt.verify(bearer.token())?;
// 폐기된 토큰인지 확인 (선택 — Redis 에 jti 블랙리스트)
if st.revoked.contains(&claims.jti).await? {
return Err(AppError::Unauthorized);
}
Ok(AuthUser {
id: claims.sub.parse().map_err(|_| AppError::Unauthorized)?,
role: claims.role,
})
}
}
// ★ 이제 핸들러가 이렇게 됩니다 — 검사 코드가 한 줄도 없습니다
async fn my_links(
user: AuthUser, // ← 이 인자가 곧 "로그인 필수" 선언
State(st): State<AppState>,
) -> ApiResult<Json<Vec<LinkRes>>> {
Ok(Json(repo::list_by_owner(&st.db, user.id).await?))
}
// 선택적 인증 — 로그인해도 되고 안 해도 되는 엔드포인트
async fn public_page(user: Option<AuthUser>) -> impl IntoResponse {
match user { Some(u) => /* 개인화 */, None => /* 익명 */ }
}
RequireRole — 권한까지 타입으로
use std::marker::PhantomData;
pub trait RoleGuard { const ROLE: Role; }
pub struct Admin; impl RoleGuard for Admin { const ROLE: Role = Role::Admin; }
pub struct Staff; impl RoleGuard for Staff { const ROLE: Role = Role::Staff; }
/// Require<Admin> 을 인자로 받으면 관리자만 통과합니다
pub struct Require<G: RoleGuard>(pub AuthUser, PhantomData<G>);
impl<G: RoleGuard> FromRequestParts<AppState> for Require<G> {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, st: &AppState)
-> Result<Self, Self::Rejection>
{
let user = AuthUser::from_request_parts(parts, st).await?;
if user.role < G::ROLE { return Err(AppError::Forbidden); }
Ok(Require(user, PhantomData))
}
}
// 관리자 전용 핸들러 — 검사를 빠뜨릴 수가 없습니다 ★
async fn delete_user(
Require(admin): Require<Admin>,
Path(id): Path<Uuid>,
State(st): State<AppState>,
) -> ApiResult<StatusCode> {
tracing::warn!(actor = %admin.id, target = %id, "관리자 삭제");
repo::delete_user(&st.db, id).await?;
Ok(StatusCode::NO_CONTENT)
}
WHERE id = $1 AND owner_id = $2 처럼 쿼리 조건에 넣으면
검사를 잊을 수 없고, 남의 것을 조회하면 자연스럽게 404 가 됩니다
(403 을 주면 “그 ID 가 존재한다”는 정보가 샙니다).
실무에선 이렇게 — 리프레시 회전 · 쿠키 세션 · OAuth2 🔴 실무
리프레시 토큰 회전 — 탈취를 감지합니다
CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash bytea NOT NULL, -- ★ 원본이 아니라 해시를 저장
family_id uuid NOT NULL, -- 같은 로그인 세션의 계보
used_at timestamptz, -- 이미 쓰인 토큰인가
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON refresh_tokens(user_id);
CREATE INDEX ON refresh_tokens(family_id);
// 재사용 감지 ★ 보안의 핵심입니다
pub async fn refresh(st: &AppState, raw: &str) -> ApiResult<TokenPair> {
let hash = sha2::Sha256::digest(raw.as_bytes());
let mut tx = st.db.begin().await?;
let row = repo::find_refresh(&mut *tx, &hash).await?
.ok_or(AppError::Unauthorized)?;
if row.used_at.is_some() {
// ★ 이미 쓰인 토큰이 또 왔다 = 누군가 훔쳤다는 강한 신호
// 그 계보 전체를 무효화하고 사용자에게 알립니다
repo::revoke_family(&mut *tx, row.family_id).await?;
tx.commit().await?;
tracing::warn!(user = %row.user_id, "리프레시 토큰 재사용 감지");
return Err(AppError::Unauthorized);
}
if row.expires_at < Utc::now() { return Err(AppError::Unauthorized); }
repo::mark_used(&mut *tx, row.id).await?;
let pair = issue_pair(&mut *tx, st, row.user_id, row.family_id).await?;
tx.commit().await?;
Ok(pair)
}
쿠키 세션 · OAuth2 — 언제 무엇을
| 상황 | 고르는 것 |
|---|---|
| 브라우저 SPA 만 | HttpOnly 쿠키 세션 — XSS 로 토큰을 못 훔칩니다 |
| 모바일 앱 · 서드파티 | JWT — 헤더로 보냅니다 |
| 서비스 간 호출 | mTLS 또는 짧은 수명 JWT |
| 소셜 로그인 | OAuth2 + PKCE |
| 사내 SSO | OIDC — openidconnect 크레이트 |
// ① 쿠키 세션 — tower-sessions
use tower_sessions::{SessionManagerLayer, Expiry, Session};
use tower_sessions_sqlx_store::PostgresStore;
let store = PostgresStore::new(pool.clone());
store.migrate().await?;
let layer = SessionManagerLayer::new(store)
.with_secure(true) // HTTPS 에서만
.with_http_only(true) // JS 가 못 읽음 ★
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_expiry(Expiry::OnInactivity(time::Duration::days(14)));
async fn login(session: Session, /* ... */) -> ApiResult<()> {
session.cycle_id().await?; // ★ 세션 고정 공격 방지
session.insert("user_id", user.id).await?;
Ok(())
}
// ② OAuth2 + PKCE — oauth2 크레이트
use oauth2::{basic::BasicClient, PkceCodeChallenge, CsrfToken, Scope};
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf) = client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("email".into()))
.set_pkce_challenge(challenge)
.url();
// verifier 와 csrf 를 세션에 저장 → 콜백에서 반드시 대조 ★
// (state 를 검증하지 않으면 CSRF 로 계정이 연결됩니다)
SameSite=Lax 로 대부분 막히지만,
상태를 바꾸는 요청은 반드시 POST/PUT/DELETE 로 두고
커스텀 헤더(예: X-Requested-With)를 요구하면 한 겹 더 안전합니다.
한 단계 더 — 키 회전 · 폐기 · 사고 대응 🧬 전문가
비대칭 키와 JWKS — 검증을 남에게 맡깁니다
// HS256(대칭)의 한계 — 검증하려면 서명 키를 줘야 합니다.
// 서비스가 여러 개면 "모두가 발급할 수 있는 키"를 공유하게 됩니다 ★ 위험
// EdDSA/RS256(비대칭) — 인증 서비스만 개인키, 나머지는 공개키만
let header = Header { alg: Algorithm::EdDSA, kid: Some("2026-08".into()), ..Default::default() };
// 검증 쪽은 kid 로 공개키를 골라 씁니다 (JWKS 엔드포인트에서 캐시)
pub struct JwkCache { keys: arc_swap::ArcSwap<HashMap<String, DecodingKey>> }
// 키 회전 절차 ★
// ① 새 키(kid=2026-09) 를 JWKS 에 추가 — 아직 서명에는 안 씁니다
// ② 모든 검증 서비스가 새 JWKS 를 캐시할 때까지 대기(캐시 TTL 이상)
// ③ 서명 키를 새 키로 전환
// ④ 옛 액세스 토큰이 전부 만료된 뒤(15분) 옛 키 제거
// → 이 순서를 지키면 무중단으로 회전됩니다
“지금 당장 로그아웃시켜야 한다”에 답하기
| 방법 | 특징 |
|---|---|
| 짧은 만료(5~15분) | 가장 단순 — 최대 15분 지연을 감수 |
jti 블랙리스트 | Redis 에 만료 시각까지만 보관 — 메모리 적게 듭니다 |
사용자별 token_version | DB 에 정수 하나. 클레임에 넣고 대조 — 조회 1회 추가 |
| 리프레시 계보 폐기 | 재로그인을 강제 — 비밀번호 변경 시 필수 |
| 세션 방식으로 전환 | 즉시 폐기가 필요하면 애초에 세션이 맞습니다 |
// 사고 대응 체크리스트 — 순서가 중요합니다 ★
// ① 서명 키 교체 → 발급된 모든 액세스 토큰이 즉시 무효
// ② refresh_tokens 테이블 전체 삭제 → 전원 재로그인
// ③ 비밀번호 변경 강제 여부 판단 (해시가 샜다면 필수)
// ④ 로그에서 의심 jti·IP 추적 (요청 ID 전파가 있으면 쉬움, w05)
// ⑤ 사용자 공지
// 비밀번호 변경 시 반드시 함께 하는 일
async fn change_password(...) -> ApiResult<()> {
repo::update_hash(&mut *tx, user.id, &new_hash).await?;
repo::revoke_all_refresh(&mut *tx, user.id).await?; // ★ 빠뜨리기 쉬움
repo::bump_token_version(&mut *tx, user.id).await?;
tx.commit().await?;
Ok(())
}
argon2 · jsonwebtoken · rand · ring)만 씁니다.
직접 구현이 필요한 건 정책(만료 시간 · 회전 절차 · 권한 모델)뿐입니다.
spawn_blocking,
토큰은 액세스(짧게) + 리프레시(회전·저장) 두 장으로 나눕니다.
가장 중요한 것은 AuthUser·Require<Admin> 같은 추출기를 만들어
권한 검사를 타입으로 강제하는 것입니다 — 그래야 새 엔드포인트에서 빠뜨리지 않습니다.검증 · 직렬화 · OpenAPI 자동 생성
실전 탭(🧰 t02)에서 serde 의 표기들을 봤습니다. 서버에서는 여기에 두 가지가 더 붙습니다 — 값 규칙 검증(validator) 과 API 문서(utoipa). 셋을 같은 구조체 하나에 얹으면, 스펙과 코드가 어긋나는 문제가 구조적으로 사라집니다.
serde 고급 — 서버에서 실제로 쓰는 것들
use serde::{Serialize, Deserialize};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] // ★ 오타를 에러로
pub struct CreateLink {
pub target_url: String, // → targetUrl
#[serde(default)]
pub custom_slug: Option<String>,
#[serde(default = "default_ttl")]
pub ttl_days: u32,
}
fn default_ttl() -> u32 { 30 }
// ① flatten — 공통 필드를 재사용합니다
#[derive(Deserialize)]
pub struct Paging { pub cursor: Option<String>, pub limit: Option<u32> }
#[derive(Deserialize)]
pub struct ListQuery {
pub q: Option<String>,
#[serde(flatten)] // cursor·limit 가 같은 층에 펼쳐집니다
pub paging: Paging,
}
// ② untagged — 여러 형태를 받아들일 때 (마지막 수단으로만)
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Ids { One(String), Many(Vec<String>) }
// ⚠ untagged 는 에러 메시지가 "data did not match any variant" 로만 나옵니다
// ③ 커스텀 변환 — 문자열로 온 숫자를 받아야 할 때
#[derive(Deserialize)]
pub struct Money {
#[serde(deserialize_with = "de_decimal_from_str")]
pub amount: rust_decimal::Decimal, // "12.34" → Decimal
}
// ④ 응답에서 큰 정수는 문자열로 (자바스크립트 53비트 한계, t02)
#[derive(Serialize)]
pub struct Res {
#[serde(serialize_with = "ser_u64_as_str")]
pub view_count: u64,
}
deny_unknown_fields 는 양날입니다.
오타를 잡아 주지만, 클라이언트가 새 필드를 먼저 보내면 깨집니다.
내부 API·설정 파일에는 켜고, 공개 API 요청에는 끄는 편이 무난합니다
(대신 알 수 없는 필드를 로그로 남기세요).
validator — 값 규칙과 오류 응답
[dependencies]
validator = { version = "0.19", features = ["derive"] }
use validator::{Validate, ValidationError};
#[derive(Deserialize, Validate)]
#[validate(schema(function = "check_slug_rules"))] // 구조체 전체 규칙
pub struct CreateLink {
#[validate(url(message = "URL 형식이 아닙니다"), length(max = 2048))]
pub target_url: String,
#[validate(length(min = 3, max = 32),
regex(path = "SLUG_RE", message = "영문·숫자·하이픈만"))]
pub custom_slug: Option<String>,
#[validate(range(min = 1, max = 3650))]
pub ttl_days: u32,
#[validate(nested)] // 중첩 구조체도 검사
pub meta: LinkMeta,
}
static SLUG_RE: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"^[a-z0-9-]+$").unwrap());
fn check_slug_rules(v: &CreateLink) -> Result<(), ValidationError> {
if v.custom_slug.as_deref() == Some("admin") {
return Err(ValidationError::new("reserved_slug"));
}
Ok(())
}
// 검증 오류를 필드별 목록으로 내보냅니다 (w03 의 AppError 확장)
impl From<validator::ValidationErrors> for AppError {
fn from(e: validator::ValidationErrors) -> Self {
let mut out = Vec::new();
for (field, errs) in e.field_errors() {
for er in errs {
out.push(format!("{field}: {}",
er.message.clone().unwrap_or_else(|| er.code.clone().into())));
}
}
AppError::Validation(out.join(", "))
}
}
// → 422 { "code":"VALIDATION", "message":"target_url: URL 형식이 아닙니다" }
// 그리고 w02 의 Valid<T> 추출기가 이걸 자동으로 호출합니다 ★
이렇게도 쓸 수 있다 — utoipa 로 문서와 Swagger UI 를 자동 생성 🟡 중급
핸들러에 표기를 붙이면 문서가 나옵니다
[dependencies]
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-axum = "0.2" # 라우터와 문서를 동시에 등록
utoipa-swagger-ui = { version = "9", features = ["axum"] }
use utoipa::{OpenApi, ToSchema, IntoParams};
#[derive(Serialize, ToSchema)]
pub struct LinkRes {
/// 링크 고유 ID
#[schema(example = "018f4c2a-...")]
pub id: Uuid,
/// 단축 주소의 마지막 조각
#[schema(example = "abc123")]
pub slug: String,
pub target_url: String,
pub click_count: u64,
}
#[utoipa::path(
post,
path = "/api/v1/links",
tag = "links",
request_body = CreateLink,
responses(
(status = 201, description = "생성됨", body = LinkRes),
(status = 409, description = "슬러그 중복", body = ErrBody),
(status = 422, description = "검증 실패", body = ErrBody),
),
security(("bearer" = []))
)]
pub async fn create_link(
user: AuthUser,
State(st): State<AppState>,
Valid(body): Valid<CreateLink>,
) -> ApiResult<Created<LinkRes>> { /* ... */ }
라우터와 문서를 한 번에 등록합니다
use utoipa_axum::{router::OpenApiRouter, routes};
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(
info(title = "URL 단축 API", version = "1.0.0"),
modifiers(&SecurityAddon),
tags((name = "links", description = "링크 생성·조회"))
)]
struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
use utoipa::openapi::security::{SecurityScheme, HttpBuilder, HttpAuthScheme};
openapi.components.as_mut().unwrap().add_security_scheme(
"bearer",
SecurityScheme::Http(HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT").build()),
);
}
}
pub fn router(state: AppState) -> Router {
// ★ routes! 매크로가 라우팅 등록과 문서 등록을 동시에 합니다
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(create_link, list_links))
.routes(routes!(get_link, delete_link))
.with_state(state)
.split_for_parts();
router.merge(
SwaggerUi::new("/docs").url("/api-docs/openapi.json", api)
)
}
// 개발자는 http://localhost:3000/docs 에서 바로 눌러 볼 수 있습니다 ★
// 운영에서는 /docs 를 사내 IP 로만 열거나 인증 뒤로 숨기세요
openapi.json 을 파일로 떨어뜨리고
openapi-typescript 로 프런트 타입을 만들면,
서버가 필드를 바꾸는 순간 프런트 빌드가 깨집니다 —
런타임에 undefined 로 만나는 것보다 훨씬 낫습니다.
실무에선 이렇게 — API 버전과 스키마 진화 🔴 실무
깨지는 변경과 안 깨지는 변경
| 변경 | 안전한 방법 | |
|---|---|---|
| ✅ | 응답에 필드 추가 | 클라이언트가 무시합니다 |
| ✅ | 선택 요청 필드 추가 | #[serde(default)] 를 함께 |
| ⚠ | 필드 이름 변경 | alias 로 둘 다 받다가 나중에 제거 |
| ❌ | 응답 필드 제거 | 한 버전은 값을 채운 채 deprecated |
| ❌ | 타입 변경(수→문자열) | 새 필드를 추가하고 옛 필드 유지 |
| ❌ | 필수 요청 필드 추가 | 기본값을 주거나 새 엔드포인트 |
| ❌ | enum 값 추가 | 클라이언트가 모르는 값에 견디는지 확인 |
// 이름을 바꿔야 할 때 — 한 버전 동안 둘 다 받습니다
#[derive(Deserialize)]
pub struct Req {
#[serde(alias = "targetUrl", alias = "url")] // 옛 이름들도 허용
pub target_url: String,
}
// enum 에 값이 늘어날 때 — 모르는 값을 버리지 않고 담아 둡니다
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status { Active, Disabled, #[serde(other)] Unknown }
// ↑ 이게 없으면 새 값이 오는 순간 파싱 실패
버전 나누기 — 경로 vs 헤더
// ① 경로 버전 — 가장 흔하고 디버깅이 쉽습니다 ★
let app = Router::new()
.nest("/api/v1", v1::router())
.nest("/api/v2", v2::router());
// v1 과 v2 가 같은 서비스 로직을 공유하고 DTO 만 다르게 둡니다
mod v2 {
pub async fn get_link(...) -> ApiResult<Json<LinkResV2>> {
let link = service::get(&st, id).await?; // 로직은 공유
Ok(Json(LinkResV2::from(link))) // 표현만 다름
}
}
// ② 헤더 버전 — URL 이 깔끔하지만 캐시·로그에서 구분이 어렵습니다
// Accept: application/vnd.myservice.v2+json
// ③ 실전 조언 — 버전을 늘리기 전에 "정말 깨는 변경인가" 를 먼저 보세요.
// v2 를 만들면 v1 을 몇 년 유지해야 합니다.
// 스펙 회귀를 막는 테스트 ★ CI 에 넣으세요
#[test]
fn openapi_스냅샷() {
let json = ApiDoc::openapi().to_pretty_json().unwrap();
insta::assert_snapshot!(json); // 바뀌면 리뷰에서 diff 로 보입니다
}
// 이렇게 해 두면 "실수로 응답 필드를 지운 PR" 이 리뷰에서 눈에 띕니다
springdoc-openapi 와 같은 자리지만 차이가 있습니다 —
utoipa 는 컴파일 시점에 문서를 만듭니다.
리플렉션으로 런타임에 훑는 방식과 달리 시작이 즉시이고,
대신 표기를 빠뜨리면 문서에 안 나옵니다(경고도 없습니다).
Deserialize + Validate + ToSchema 를 얹고,
w02 의 Valid<T> 추출기로 파싱·검증·문서를 한 줄에 묶습니다.
깨는 변경은 필드 제거·타입 변경·필수 필드 추가 셋뿐이라고 기억하고,
나머지는 alias 와 #[serde(other)] 로 부드럽게 넘어가세요.WebSocket · SSE — 실시간을 안전하게 붙이기
실시간 기능은 붙이기는 쉽고 운영이 어렵습니다. 연결이 수천 개로 늘고, 한 사용자의 네트워크가 느려지고, 인스턴스가 두 대가 되는 순간 문제가 시작됩니다. 이 절은 채팅방 하나를 끝까지 만들면서 백프레셔·재연결·수평 확장까지 다룹니다.
SSE — 서버가 일방적으로 밀 때는 이게 더 쉽습니다
use axum::response::sse::{Event, Sse, KeepAlive};
use futures::stream::{self, Stream};
use tokio_stream::wrappers::BroadcastStream;
async fn events(
user: AuthUser,
State(st): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
let rx = st.notify.subscribe(); // broadcast::Receiver
let stream = BroadcastStream::new(rx)
.filter_map(move |msg| {
let m = msg.ok()?; // Lagged 는 건너뜁니다
if m.user_id != user.id { return None; }
Some(Ok(Event::default()
.id(m.seq.to_string()) // ★ Last-Event-ID 재연결에 씀
.event("notification")
.json_data(&m).ok()?))
});
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15)) // ★ 프록시가 끊지 않게
.text("ping"))
}
| SSE | WebSocket | |
|---|---|---|
| 방향 | 서버→클라 only | 양방향 |
| 프로토콜 | 그냥 HTTP | 업그레이드 필요 |
| 재연결 | 브라우저가 자동 | 직접 구현 |
| 프록시 궁합 | 좋음 | 설정 필요 |
| 바이너리 | 불가(텍스트) | 가능 |
| 적합 | 알림 · 진행률 · 로그 | 채팅 · 게임 · 협업 |
WebSocket — 업그레이드와 분리
use axum::extract::ws::{WebSocket, WebSocketUpgrade, Message, Utf8Bytes};
use futures::{SinkExt, StreamExt};
async fn ws_handler(
ws: WebSocketUpgrade,
user: AuthUser, // ★ 업그레이드 전에 인증합니다
Path(room): Path<String>,
State(st): State<AppState>,
) -> impl IntoResponse {
ws
.max_message_size(64 * 1024) // ★ 상한을 반드시 거세요
.max_frame_size(64 * 1024)
.on_upgrade(move |socket| handle_socket(socket, user, room, st))
}
async fn handle_socket(socket: WebSocket, user: AuthUser, room: String, st: AppState) {
let (mut sender, mut receiver) = socket.split(); // ★ 읽기·쓰기 분리
let mut rx = st.rooms.subscribe(&room).await;
// ① 브로드캐스트를 클라이언트로 밀어 넣는 태스크
let mut send_task = tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(msg) => {
let json = serde_json::to_string(&msg).unwrap();
if sender.send(Message::Text(json.into())).await.is_err() { break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
// ★ 놓쳤음을 알리고 계속 — 조용히 넘기면 화면이 어긋납니다
let warn = format!(r#"{{"type":"lagged","missed":{n}}}"#);
let _ = sender.send(Message::Text(warn.into())).await;
}
Err(_) => break, // 채널이 닫힘
}
}
});
// ② 클라이언트가 보낸 것을 받아 방에 뿌리는 태스크
let st2 = st.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
match msg {
Message::Text(t) => {
if t.len() > 4096 { continue; } // 길이 제한
st2.rooms.publish(&room, user.id, t).await;
}
Message::Close(_) => break,
Message::Ping(_) | Message::Pong(_) => {} // axum 이 자동 응답
_ => {}
}
}
});
// ③ 한쪽이 끝나면 다른 쪽도 정리 ★ 이걸 빼면 태스크가 샙니다
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
}
st.rooms.leave(&room, user.id).await;
}
이렇게도 쓸 수 있다 — 채팅방 상태를 관리하는 구조체 🟡 중급
Rooms — 방 목록과 채널을 함께
use std::{collections::HashMap, sync::Arc};
use tokio::sync::{broadcast, RwLock};
#[derive(Clone, Serialize)]
pub struct ChatMsg {
pub seq: u64, pub room: String,
pub user_id: Uuid, pub text: String,
pub at: chrono::DateTime<chrono::Utc>,
}
pub struct Room {
tx: broadcast::Sender<ChatMsg>,
members: std::sync::atomic::AtomicUsize,
}
#[derive(Clone, Default)]
pub struct Rooms(Arc<RwLock<HashMap<String, Arc<Room>>>>);
impl Rooms {
pub async fn subscribe(&self, name: &str) -> broadcast::Receiver<ChatMsg> {
// 있으면 재사용, 없으면 생성 — 읽기 락을 먼저 시도합니다 ★
if let Some(r) = self.0.read().await.get(name) {
r.members.fetch_add(1, Ordering::Relaxed);
return r.tx.subscribe();
}
let mut w = self.0.write().await;
let room = w.entry(name.to_owned()).or_insert_with(|| {
let (tx, _) = broadcast::channel(256); // 방당 버퍼 256
Arc::new(Room { tx, members: AtomicUsize::new(0) })
});
room.members.fetch_add(1, Ordering::Relaxed);
room.tx.subscribe()
}
pub async fn publish(&self, name: &str, user: Uuid, text: Utf8Bytes) {
if let Some(r) = self.0.read().await.get(name) {
// 구독자가 0명이면 send 가 Err 를 돌려줍니다 — 무시해도 됩니다
let _ = r.tx.send(ChatMsg { /* ... */ });
}
}
pub async fn leave(&self, name: &str, _user: Uuid) {
let mut w = self.0.write().await;
if let Some(r) = w.get(name) {
if r.members.fetch_sub(1, Ordering::Relaxed) == 1 {
w.remove(name); // ★ 빈 방을 지웁니다 (안 지우면 누수)
}
}
}
}
인스턴스가 여러 대일 때 — Redis 로 잇습니다
// 구조: 각 인스턴스가 Redis 채널을 구독 → 받은 것을 로컬 broadcast 로 재배포
pub async fn start_bridge(rooms: Rooms, redis_url: &str) -> anyhow::Result<()> {
let client = redis::Client::open(redis_url)?;
let mut pubsub = client.get_async_pubsub().await?;
pubsub.psubscribe("chat:*").await?;
tokio::spawn(async move {
let mut stream = pubsub.on_message();
while let Some(m) = stream.next().await {
let ch: String = m.get_channel_name().to_string();
let payload: String = m.get_payload().unwrap_or_default();
if let Ok(msg) = serde_json::from_str::<ChatMsg>(&payload) {
rooms.deliver_local(&msg.room, msg).await; // 로컬에만 뿌림
}
}
});
Ok(())
}
// publish 는 이제 Redis 로 보냅니다 (자기 자신도 구독으로 돌려받습니다)
pub async fn publish_global(redis: &RedisPool, msg: &ChatMsg) -> anyhow::Result<()> {
let mut conn = redis.get().await?;
redis::cmd("PUBLISH")
.arg(format!("chat:{}", msg.room))
.arg(serde_json::to_string(msg)?)
.query_async::<()>(&mut conn).await?;
Ok(())
}
실무에선 이렇게 — 연결 수천 개를 운영하기 🔴 실무
반드시 걸어야 할 안전장치
| 항목 | 이유와 값 |
|---|---|
| 메시지 크기 상한 | max_message_size — 없으면 메모리 공격에 그대로 노출 |
| 연결당 속도 제한 | 초당 N건 초과 시 끊기 — 봇 하나가 방을 마비시킵니다 |
| 유휴 타임아웃 | Ping 에 응답 없으면 60초 뒤 끊기 |
| 동시 연결 상한 | 세마포어로 제한 — 파일 디스크립터 한계(ulimit -n)도 함께 올리세요 |
| 사용자별 연결 수 | 탭을 100개 열어 두는 사용자가 실제로 있습니다 |
| 인증 만료 | 연결이 몇 시간 유지되면 중간에 토큰이 만료됩니다 — 주기적 재검증 |
| 정상 종료 | SIGTERM 시 Close 프레임을 보내고 끊어야 클라이언트가 즉시 재연결합니다 |
// 유휴 감지 — 직접 Ping 을 보내고 응답을 기다립니다
let mut idle = tokio::time::interval(Duration::from_secs(30));
let mut missed = 0u8;
loop {
tokio::select! {
_ = idle.tick() => {
if missed >= 2 { break; } // 두 번 놓치면 끊기
missed += 1;
if sender.send(Message::Ping(vec![].into())).await.is_err() { break; }
}
Some(Ok(m)) = receiver.next() => {
missed = 0; // 무엇이든 오면 살아 있음
/* 처리 */
}
else => break,
}
}
배포와 프록시에서 실제로 막히는 것들
| 증상 | 원인 |
|---|---|
| 60초마다 끊김 | ALB·Nginx 유휴 타임아웃 — KeepAlive 를 그보다 짧게 |
| SSE 가 한 번에 몰려 옴 | 프록시 버퍼링 — X-Accel-Buffering: no 헤더 또는 압축 레이어 제외(w05) |
| 업그레이드 실패 400 | 프록시가 Upgrade·Connection 헤더를 안 넘김 |
| 배포 때 전원 끊김 | 정상 종료 없이 죽임 — 클라이언트 재연결에 지수 백오프+지터 필수 |
| 메모리가 계속 증가 | 빈 방·끊긴 구독자를 정리 안 함 |
| 한 방에만 부하 집중 | 인기 방은 샤딩하거나 읽기 전용 팬아웃 노드를 둡니다 |
# Nginx 에서 WebSocket 을 통과시키는 최소 설정
location /ws {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s; # KeepAlive 주기보다 길게
proxy_buffering off; # SSE 에도 필요합니다
}
broadcast 는 느린 구독자에게 Lagged 를 주므로 반드시 처리하고,
인스턴스가 둘 이상이면 Redis Pub/Sub 같은 다리가 필요합니다.
메시지 크기·속도·유휴 타임아웃 세 가지 상한은 처음부터 걸어 두세요.gRPC · tonic — 서비스 간 통신과 REST 를 한 포트에
바깥에는 REST/JSON 을 열어 두더라도, 서비스끼리는 gRPC 가 유리합니다 —
스키마가 강제되고, 바이너리라 작고, 스트리밍이 기본이며,
파이썬·자바·Go 클라이언트가 자동으로 생성됩니다.
Rust 쪽 표준은 tonic 이고, tokio·tower 위에 있어 Axum 과 부품을 공유합니다.
proto 정의와 코드 생성
// proto/link.proto
syntax = "proto3";
package link.v1;
service LinkService {
rpc Resolve (ResolveReq) returns (ResolveRes);
rpc CreateLink (CreateReq) returns (LinkMsg);
rpc WatchClicks (WatchReq) returns (stream ClickEvent); // 서버 스트리밍
rpc BulkCreate (stream CreateReq) returns (BulkRes); // 클라 스트리밍
}
message ResolveReq { string slug = 1; }
message ResolveRes { string target_url = 1; bool found = 2; }
message ClickEvent { string slug = 1; int64 at_unix = 2; string country = 3; }
# Cargo.toml
[dependencies]
tonic = "0.13"
prost = "0.13"
tokio-stream = "0.1"
[build-dependencies]
tonic-build = "0.13"
// build.rs — 빌드마다 proto 를 다시 컴파일합니다
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(true)
.build_client(true)
// 생성 타입에 serde 를 얹어 REST 와 공유하고 싶을 때
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
.compile_protos(&["proto/link.proto"], &["proto"])?;
println!("cargo:rerun-if-changed=proto/link.proto");
Ok(())
}
// 쓰는 쪽 — 생성 코드를 모듈로 불러옵니다
pub mod pb { tonic::include_proto!("link.v1"); }
protoc 가 필요합니다. CI·도커 빌드 이미지에
protobuf-compiler 를 설치하거나,
protoc-bin-vendored 를 써서 바이너리를 crate 에 포함시키세요 —
“로컬에선 되는데 CI 에서만 빌드 실패”의 단골 원인입니다.
서버 구현 — 트레이트 하나를 채웁니다
use tonic::{Request, Response, Status};
use pb::link_service_server::{LinkService, LinkServiceServer};
pub struct LinkSvc { pub st: AppState }
#[tonic::async_trait]
impl LinkService for LinkSvc {
async fn resolve(&self, req: Request<pb::ResolveReq>)
-> Result<Response<pb::ResolveRes>, Status>
{
// 메타데이터에서 인증 토큰 읽기 (HTTP 헤더와 같은 자리)
let token = req.metadata().get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| Status::unauthenticated("토큰이 없습니다"))?;
self.st.jwt.verify(token.trim_start_matches("Bearer "))
.map_err(|_| Status::unauthenticated("토큰이 유효하지 않습니다"))?;
let slug = req.into_inner().slug;
match repo::resolve(&self.st.db, &slug).await {
Ok(Some(url)) => Ok(Response::new(pb::ResolveRes { target_url: url, found: true })),
Ok(None) => Err(Status::not_found("없는 슬러그입니다")),
Err(e) => { tracing::error!(?e); Err(Status::internal("서버 오류")) }
}
}
// 서버 스트리밍 — 반환 타입에 스트림을 붙입니다
type WatchClicksStream = std::pin::Pin<Box<dyn tokio_stream::Stream<
Item = Result<pb::ClickEvent, Status>> + Send>>;
async fn watch_clicks(&self, req: Request<pb::WatchReq>)
-> Result<Response<Self::WatchClicksStream>, Status>
{
let rx = self.st.clicks.subscribe();
let stream = tokio_stream::wrappers::BroadcastStream::new(rx)
.filter_map(|r| r.ok().map(Ok));
Ok(Response::new(Box::pin(stream)))
}
}
이렇게도 쓸 수 있다 — Axum 과 같은 포트에 함께 띄우기 🟡 중급
한 포트, 두 프로토콜
use axum::http::header::CONTENT_TYPE;
use tower::steer::Steer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let state = build_state().await?;
let rest = api::router(state.clone()); // Axum
let grpc = tonic::service::Routes::new(
LinkServiceServer::new(LinkSvc { st: state })
).into_axum_router(); // tonic → Router
// content-type 을 보고 갈라 보냅니다 ★
let svc = Steer::new(vec![rest, grpc], |req: &axum::http::Request<_>, _: &[_]| {
let is_grpc = req.headers().get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("application/grpc"));
if is_grpc { 1 } else { 0 }
});
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, tower::make::Shared::new(svc)).await?;
Ok(())
}
// ★ 더 간단한 대안 — 포트를 두 개 쓰는 것도 나쁘지 않습니다
// :8080 REST(외부 노출) · :50051 gRPC(내부망 전용)
// 방화벽·인가 정책을 분리하기 쉬워 실제로는 이쪽이 더 흔합니다
tonic-web)을 얹거나,
Envoy 로 변환하거나, 애초에 브라우저용은 REST 로 두세요 —
마지막 방식이 가장 단순합니다.
다른 언어에서 부르기
# 파이썬 클라이언트 생성
pip install grpcio grpcio-tools
python -m grpc_tools.protoc -I proto \
--python_out=. --grpc_python_out=. proto/link.proto
# 자바 — Gradle protobuf 플러그인이 build 때 생성합니다
# 타입스크립트 — ts-proto 또는 @bufbuild/protobuf
# 파이썬에서 Rust gRPC 서버 호출
import grpc, link_pb2, link_pb2_grpc
with grpc.insecure_channel("localhost:50051") as ch: # 내부망 전용
stub = link_pb2_grpc.LinkServiceStub(ch)
md = (("authorization", f"Bearer {token}"),)
res = stub.Resolve(link_pb2.ResolveReq(slug="abc123"), metadata=md)
print(res.target_url)
# 서버 스트리밍 — for 문으로 그냥 받습니다
for ev in stub.WatchClicks(link_pb2.WatchReq(), metadata=md):
print(ev.slug, ev.country)
# 서버가 잘 도는지 확인 — 리플렉션을 켜면 grpcurl 로 바로 됩니다
# tonic-reflection 크레이트 추가 후
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext -d '{"slug":"abc123"}' \
localhost:50051 link.v1.LinkService/Resolve
reserved 로 막습니다.
필드 추가는 안전, 번호 변경·타입 변경은 파괴적입니다(w07 의 표와 같은 원리).
실무에선 이렇게 — REST 와 gRPC 를 고르는 기준 🔴 실무
비교표
| REST + JSON | gRPC | |
|---|---|---|
| 스키마 | OpenAPI(선택) | proto(강제) |
| 페이로드 | 텍스트 | 바이너리(작음) |
| 브라우저 | 바로 됨 | 중계 필요 |
| 스트리밍 | SSE·WS 별도 | 기본 내장 |
| 디버깅 | curl 로 끝 | grpcurl 필요 |
| 캐시 | HTTP 캐시 | 직접 |
| 로드밸런싱 | L7 그대로 | 연결 유지 때문에 주의 |
| 적합 | 공개 API | 내부 서비스 간 |
tower::balance) 또는
L7 프록시(Envoy·Linkerd) 또는 주기적 연결 재수립입니다.
tower 부품을 gRPC 에도 그대로
use tower::ServiceBuilder;
use tonic::transport::Server;
// ★ Axum 에서 쓰던 레이어를 그대로 씁니다 — 이게 tonic 의 장점입니다
let layer = ServiceBuilder::new()
.layer(TraceLayer::new_for_grpc())
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.into_inner();
Server::builder()
.layer(layer)
.http2_keepalive_interval(Some(Duration::from_secs(30))) // ★ 죽은 연결 감지
.http2_keepalive_timeout(Some(Duration::from_secs(10)))
.add_service(LinkServiceServer::new(svc))
.add_service(health_service) // tonic-health
.add_service(reflection_service) // tonic-reflection (개발용)
.serve_with_shutdown(addr, shutdown_signal())
.await?;
// 클라이언트 쪽 — 재시도·타임아웃·인터셉터
let channel = tonic::transport::Channel::from_static("http://svc:50051")
.connect_timeout(Duration::from_secs(2))
.timeout(Duration::from_secs(5))
.http2_keep_alive_interval(Duration::from_secs(30))
.connect().await?;
// 모든 호출에 토큰을 자동으로 붙이는 인터셉터
let mut client = LinkServiceClient::with_interceptor(channel, move |mut req: Request<()>| {
req.metadata_mut().insert("authorization",
format!("Bearer {}", token).parse().unwrap());
Ok(req)
});
let res = client.resolve(pb::ResolveReq { slug: "abc123".into() }).await?;
Status::not_found, invalid_argument,
permission_denied, resource_exhausted,
unavailable. internal 만 남발하면
클라이언트가 재시도 여부를 판단할 수 없습니다
(unavailable 만 재시도 대상입니다).
build.rs 로 proto 를 코드로 바꾸고, 같은 proto 로 파이썬·자바 클라이언트를 생성해 씁니다.
한 포트에 얹을 수도 있지만 포트를 나누는 편이 운영에서 더 단순합니다.
gRPC 는 연결이 유지되므로 로드밸런싱을 반드시 따로 챙기세요.관측 — 로그 · 지표 · 추적과 정상 종료
실전 탭(🧰 t08)에서 tracing 기본 설정과 #[instrument] 를 봤습니다.
운영에서 실제로 필요한 것은 그다음입니다 —
“어제 오후 3시 12분에 느렸던 그 요청”을 3분 안에 찾아내는 능력.
그러려면 로그 · 지표 · 분산 추적 셋이 같은 trace_id 로 이어져 있어야 합니다.
Subscriber 하나에 레이어를 겹칩니다
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter","json"] }
tracing-opentelemetry = "0.28"
opentelemetry = "0.27"
opentelemetry_sdk = { version = "0.27", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.27", features = ["grpc-tonic"] }
axum-prometheus = "0.7"
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, fmt};
pub fn init(cfg: &Config) -> anyhow::Result<()> {
// ① 필터 — 환경변수로 모듈별 레벨 조절 (재빌드 없이)
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"info,tower_http=debug,sqlx=warn,hyper=warn,h2=warn".into()
});
// ② 포맷 — 운영은 JSON, 개발은 사람이 읽는 형태
let fmt_layer = if cfg.app.env == Env::Production {
fmt::layer().json().flatten_event(true)
.with_current_span(true).with_span_list(false).boxed()
} else {
fmt::layer().pretty().with_target(false).boxed()
};
// ③ OpenTelemetry — 운영에서만
let otel_layer = if cfg.otel.enabled {
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&cfg.otel.endpoint) // http://otel-collector:4317
.build()?;
let provider = opentelemetry_sdk::trace::TracerProvider::builder()
.with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio)
.with_resource(opentelemetry_sdk::Resource::new(vec![
opentelemetry::KeyValue::new("service.name", "myservice"),
opentelemetry::KeyValue::new("deployment.environment", "production"),
]))
.build();
let tracer = opentelemetry::trace::TracerProvider::tracer(&provider, "myservice");
Some(tracing_opentelemetry::layer().with_tracer(tracer))
} else { None };
tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.with(otel_layer)
.init();
// ④ 패닉도 로그로 — 안 하면 컨테이너 로그에만 남고 검색이 안 됩니다
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
tracing::error!(panic = %info, "패닉 발생");
prev(info);
}));
Ok(())
}
provider.shutdown() 을 부르지 않고 프로세스가 죽으면
마지막 몇 초의 추적이 통째로 사라집니다 —
하필 장애 순간의 데이터입니다.
span 을 설계합니다 — 아무 데나 넣지 않습니다
// ★ 규칙 — 요청 단위 span 하나 + "느릴 수 있는 경계"마다 하위 span
#[tracing::instrument(
name = "create_link",
skip(st, body), // ★ 큰 값·민감 값은 반드시 skip
fields(user_id = %user.id, slug = tracing::field::Empty)
)]
pub async fn create_link(
user: AuthUser,
State(st): State<AppState>,
Valid(body): Valid<CreateLink>,
) -> ApiResult<Created<LinkRes>> {
let slug = gen_slug(&body);
// 나중에 알게 된 값을 span 에 채웁니다
tracing::Span::current().record("slug", &slug.as_str());
let link = repo::insert(&st.db, &slug, &body).await?; // 하위 span
cache::warm(&st.redis, &link).await; // 하위 span
tracing::info!(link_id = %link.id, "링크 생성");
Ok(Created(format!("/api/v1/links/{}", link.id), link.into()))
}
// 외부 호출에는 반드시 span 을 답니다 — 원인 파악의 절반이 여기입니다
#[tracing::instrument(skip(client), fields(http.status = tracing::field::Empty))]
async fn call_payment(client: &reqwest::Client, order: Uuid) -> Result<(), AppError> {
let res = client.post(URL).json(&body).send().await?;
tracing::Span::current().record("http.status", res.status().as_u16());
res.error_for_status()?;
Ok(())
}
#[instrument] 함수를 10만 번 부르면
추적 데이터가 폭발합니다. 실무 기준은
“네트워크·디스크·잠금을 건드리는 경계에만” 입니다.
나머지는 샘플링(예: 10%)으로 줄이세요.
이렇게도 쓸 수 있다 — 지표를 만들고 무엇을 볼지 정합니다 🟡 중급
Prometheus 지표 — 기본 + 우리 것
use axum_prometheus::{PrometheusMetricLayerBuilder, metrics};
let (metric_layer, metric_handle) = PrometheusMetricLayerBuilder::new()
// ★ 경로에 ID 가 들어가면 카디널리티가 폭발합니다 — 반드시 그룹화
.with_group_patterns_as("/api/v1/links/:id", &["/api/v1/links/*"])
.with_ignore_patterns(&["/metrics", "/health"])
.with_default_metrics()
.build_pair();
let app = Router::new()
.nest("/api/v1", api_routes())
.route("/metrics", get(move || std::future::ready(metric_handle.render())))
.layer(metric_layer);
// 도메인 지표를 직접 추가합니다 ★ 이게 실제로 가장 쓸모 있습니다
metrics::counter!("links_created_total", "plan" => user.plan.as_str()).increment(1);
metrics::histogram!("db_query_duration_seconds", "query" => "resolve_slug")
.record(elapsed.as_secs_f64());
metrics::gauge!("db_pool_connections", "state" => "idle")
.set(pool.num_idle() as f64);
metrics::gauge!("ws_connections_active").set(rooms.total() as f64);
실제로 알람을 걸 네 가지 (골든 시그널)
| 신호 | 보는 지표와 기준 |
|---|---|
| 지연 | p50·p95·p99 를 따로 — 평균은 아무것도 알려 주지 않습니다 |
| 트래픽 | 초당 요청 수 — 급감도 급증만큼 위험합니다 |
| 에러 | 5xx 비율 · 4xx 비율을 나눠서. 401 급증은 공격 신호 |
| 포화 | DB 풀 대기 · 태스크 큐 길이 · 메모리 · 파일 디스크립터 |
# Prometheus 알람 예 — "증상"에 걸고 "원인"에 걸지 않습니다
groups:
- name: myservice
rules:
- alert: HighErrorRate
expr: |
sum(rate(axum_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(axum_http_requests_total[5m])) > 0.01
for: 5m
annotations:
summary: "5xx 비율이 1% 를 넘었습니다"
- alert: SlowP99
expr: |
histogram_quantile(0.99,
sum by (le) (rate(axum_http_requests_duration_seconds_bucket[5m]))) > 1.0
for: 10m
- alert: DbPoolExhausted
expr: db_pool_connections{state="idle"} == 0
for: 2m
실무에선 이렇게 — 헬스체크와 정상 종료 🔴 실무
/health 와 /ready 는 반드시 다릅니다
use std::sync::atomic::{AtomicBool, Ordering};
pub static READY: AtomicBool = AtomicBool::new(true);
/// 살아 있는가 — 프로세스가 응답하면 OK. DB 를 보지 않습니다 ★
async fn health() -> StatusCode { StatusCode::OK }
/// 트래픽을 받을 준비가 됐는가 — 의존 대상을 확인합니다
async fn ready(State(st): State<AppState>) -> Response {
if !READY.load(Ordering::Relaxed) {
return (StatusCode::SERVICE_UNAVAILABLE, "shutting down").into_response();
}
// 타임아웃을 꼭 거세요 — 헬스체크가 매달리면 더 나쁩니다
let db_ok = tokio::time::timeout(Duration::from_millis(500),
sqlx::query("SELECT 1").execute(&st.db)).await.is_ok();
if db_ok { (StatusCode::OK, "ready").into_response() }
else { (StatusCode::SERVICE_UNAVAILABLE, "db").into_response() }
}
// ★ 왜 분리하나
// /health 가 DB 를 보면, DB 가 잠깐 느려질 때 쿠버네티스가
// "죽었다"고 판단해 모든 파드를 재시작합니다 → 장애가 증폭됩니다.
// 재시작으로 고쳐지는 문제만 /health 에 넣으세요.
정상 종료 — 배포 중 502 를 없애는 순서
pub async fn shutdown_signal() {
let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); };
#[cfg(unix)]
let term = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("signal").recv().await;
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
tracing::info!("종료 신호를 받았습니다");
// ① 먼저 /ready 를 실패로 바꿉니다
READY.store(false, Ordering::Relaxed);
// ② 로드밸런서가 우리를 목록에서 빼는 데 걸리는 시간만큼 기다립니다 ★
// 이 대기가 없으면 이미 라우팅된 요청이 502 로 떨어집니다
tokio::time::sleep(Duration::from_secs(5)).await;
// 이후 axum::serve 가 새 연결을 안 받고 진행 중인 요청만 마칩니다
}
// main 에서
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
// ③ 마지막 정리 — 순서가 중요합니다
otel_provider.shutdown()?; // 남은 추적을 flush
pool.close().await; // 커넥션 반납
tracing::info!("정상 종료 완료");
terminationGracePeriodSeconds 를 함께 늘리세요.
쿠버네티스 기본값은 30초입니다 —
위 대기 5초 + 최대 요청 시간이 그 안에 들어가야 합니다.
넘으면 SIGKILL 로 강제 종료되어 요청이 잘립니다.
한 단계 더 — tokio-console 로 태스크를 들여다봅니다 🧬 전문가
“어디서 막혔는지”를 눈으로 봅니다
# Cargo.toml — 개발·스테이징에서만 켜세요
[dependencies]
console-subscriber = { version = "0.4", optional = true }
tokio = { version = "1", features = ["full", "tracing"] }
[features]
console = ["dep:console-subscriber"]
# 빌드할 때 tokio 계측을 켜야 합니다
RUSTFLAGS="--cfg tokio_unstable" cargo run --features console
# 다른 터미널에서
cargo install --locked tokio-console
tokio-console
# 보이는 것들 ★
# · 태스크별 폴링 시간 — busy 가 긴 태스크 = 워커를 막는 범인
# · 대기 시간(idle) 이 긴 태스크 = 무언가를 기다림
# · 세마포어·뮤텍스별 경합 시간
# · "이 태스크가 언제 생겨서 아직 안 끝났나" — 태스크 누수 탐지
async 안에서
블로킹 파일 IO · 동기 mutex · 무거운 CPU 계산을 하는 코드입니다.
tokio-console 에서 busy 시간이 유독 긴 태스크로 즉시 보입니다.
해결은 spawn_blocking 이거나 rayon 으로 넘기는 것입니다(⚡ e05).
로그 비용을 관리하는 실전 규칙
| 레벨 | 무엇을 남기나 |
|---|---|
error | 사람이 봐야 하는 것만. 알람과 연결됩니다 |
warn | 자동 복구됐지만 반복되면 문제 — 재시도·폴백·Lagged |
info | 요청 요약 · 상태 전이. 요청당 1~3줄을 넘기지 마세요 |
debug | 기본 꺼짐. 환경변수로 잠깐 켭니다 |
trace | 로컬 전용 |
// 샘플링 — 정상 요청은 일부만, 에러는 전부 ★
use tracing_subscriber::filter::FilterFn;
let sampler = FilterFn::new(|meta| {
if *meta.level() <= tracing::Level::WARN { return true; } // 에러·경고는 전부
fastrand::u8(..) < 26 // 나머지는 약 10%
});
// 운영 규모에서 실제로 드는 비용
// 요청 1,000/s × 로그 3줄 × 300바이트 ≈ 하루 78GB
// → 샘플링 10% 로 8GB. 에러는 100% 유지되므로 조사에 지장 없습니다
//
// 추적도 마찬가지 — OTel 의 TraceIdRatioBased 샘플러로 1~10% 만 보냅니다.
// 단, "에러가 난 추적은 반드시 보내기"(tail sampling)는 Collector 쪽에서 설정합니다.
user_id 를 반복해 찍지 마세요(span 이 상속합니다).
tracing 하나로 기록하고 레이어 셋(로그 · OTLP · 지표)으로 내보냅니다.
span 은 네트워크·디스크·잠금 경계에만 달고, 지표 라벨에 ID 를 넣지 마세요.
/health 는 DB 를 보지 않고 /ready 만 봅니다.
정상 종료는 ready 를 먼저 내리고 몇 초 기다린 뒤 마무리합니다.테스트 — 서버를 띄우지 않고 요청을 넣는 법
Axum 의 Router 는 그 자체가 tower::Service 입니다.
그래서 포트를 열지 않고도 요청 하나를 그대로 밀어 넣을 수 있습니다 —
미들웨어 · 추출기 · 핸들러 · 에러 매핑이 전부 진짜로 도는데 밀리초 단위로 끝납니다.
여기서 시작해 실제 DB 통합 테스트와 부하 시험까지 층을 쌓습니다.
(테스트 전반의 원칙은 🧰 t10 을 함께 보세요.)
oneshot — 가장 많이 쓰게 될 형태
[dev-dependencies]
tower = { version = "0.5", features = ["util"] } # ServiceExt::oneshot
http-body-util = "0.1"
serde_json = "1"
mockall = "0.13"
testcontainers = "0.23"
testcontainers-modules = { version = "0.11", features = ["postgres","redis"] }
use axum::{body::Body, http::{Request, StatusCode, header}};
use http_body_util::BodyExt;
use tower::ServiceExt; // ★ .oneshot() 이 여기서 옵니다
async fn json_body<T: serde::de::DeserializeOwned>(res: axum::response::Response) -> T {
let bytes = res.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
#[sqlx::test] // 테스트 전용 DB 를 자동으로 (w04)
async fn 링크를_만들고_조회한다(pool: sqlx::PgPool) {
let state = test_state(pool).await;
let app = api::router(state.clone()); // ★ main 과 같은 라우터
let token = issue_test_token(&state, Role::User);
// ① 생성
let res = app.clone().oneshot(
Request::builder()
.method("POST").uri("/api/v1/links")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"targetUrl":"https://example.com"}"#))
.unwrap()
).await.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
let created: LinkRes = json_body(res).await;
// ② 조회
let res = app.oneshot(
Request::builder()
.uri(format!("/api/v1/links/{}", created.id))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[sqlx::test]
async fn 토큰이_없으면_401(pool: sqlx::PgPool) {
let app = api::router(test_state(pool).await);
let res = app.oneshot(
Request::builder().uri("/api/v1/links").body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
oneshot 은 서비스를 소비합니다.
여러 번 쓰려면 app.clone().oneshot(...) 로 복제하세요
(Router 의 clone 은 쌉니다).
이걸 몰라서 “두 번째 요청이 컴파일 안 된다”로 막히는 경우가 아주 많습니다.
testcontainers — 진짜 DB 를 띄웁니다
use testcontainers_modules::{postgres::Postgres, redis::Redis,
testcontainers::runners::AsyncRunner};
/// 컨테이너를 한 번만 띄우고 모든 테스트가 공유합니다
async fn shared_pg() -> &'static (ContainerAsync<Postgres>, String) {
static PG: tokio::sync::OnceCell<(ContainerAsync<Postgres>, String)>
= tokio::sync::OnceCell::const_new();
PG.get_or_init(|| async {
let node = Postgres::default()
.with_tag("17-alpine")
.start().await.unwrap();
let port = node.get_host_port_ipv4(5432).await.unwrap();
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
(node, url)
}).await
}
#[tokio::test]
async fn 마이그레이션이_적용되고_동시성이_안전하다() {
let (_node, url) = shared_pg().await;
let pool = sqlx::PgPool::connect(url).await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
// ★ 목으로는 절대 못 잡는 것 — 유니크 제약과 동시 삽입
let a = repo::insert_slug(&pool, "same");
let b = repo::insert_slug(&pool, "same");
let (ra, rb) = tokio::join!(a, b);
assert!(ra.is_ok() ^ rb.is_ok(), "하나만 성공해야 합니다");
}
ubuntu-latest 러너에는
도커가 이미 있습니다. 서비스 컨테이너 설정을 YAML 에 쓸 필요 없이
테스트 코드가 스스로 띄우므로, 로컬과 CI 의 동작이 같아집니다.
이렇게도 쓸 수 있다 — 외부 의존을 가짜로 바꾸기 🟡 중급
mockall — 트레이트에 가짜 구현을 끼웁니다
// domain 의 포트(w03)에 목을 붙입니다
#[cfg_attr(test, mockall::automock)]
#[async_trait::async_trait]
pub trait PaymentGateway: Send + Sync {
async fn charge(&self, order: Uuid, amount: i64) -> Result<ChargeId, PayError>;
}
#[tokio::test]
async fn 결제가_실패하면_주문이_취소된다() {
let mut gw = MockPaymentGateway::new();
gw.expect_charge()
.withf(|_, amt| *amt == 10_000) // 인자 검증
.times(1) // 정확히 한 번 호출
.returning(|_, _| Err(PayError::Declined)); // 실패를 주입
let svc = OrderService::new(repo, std::sync::Arc::new(gw));
let err = svc.place(order_req()).await.unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
assert_eq!(repo.status(order_id).await, OrderStatus::Cancelled);
}
// ★ 목을 쓸 자리 / 쓰지 말 자리
// 써야 함 — 결제 · 메일 · SMS · 외부 SaaS (실제로 부르면 안 되는 것)
// 쓰지 말 것 — DB. 목 DB 는 "우리 코드가 맞다"만 검증합니다.
// SQL 오타 · 제약 위반 · 트랜잭션 격리는 진짜 DB 만 잡습니다
wiremock — 외부 HTTP 를 가짜 서버로
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path, header, body_json_string};
#[tokio::test]
async fn 외부_API_가_500이면_재시도한다() {
let server = MockServer::start().await;
// 처음 두 번은 500, 세 번째에 성공
Mock::given(method("POST")).and(path("/v1/charge"))
.respond_with(ResponseTemplate::new(500))
.up_to_n_times(2)
.mount(&server).await;
Mock::given(method("POST")).and(path("/v1/charge"))
.respond_with(ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id":"ch_1"})))
.mount(&server).await;
// ★ 설정으로 base_url 을 주입할 수 있게 만들어 두는 것이 핵심입니다
let client = PaymentClient::new(reqwest::Client::new(), server.uri());
let id = client.charge(order, 10_000).await.unwrap();
assert_eq!(id.0, "ch_1");
}
// 지연을 주입해 타임아웃 동작도 검증합니다
ResponseTemplate::new(200).set_delay(Duration::from_secs(10));
실무에선 이렇게 — 부하 시험으로 숫자를 확보합니다 🔴 실무
oha — 가장 간단한 도구
cargo install oha # Rust 로 만든 부하 도구, 결과를 바로 보여 줍니다
# 동시 100, 30초 동안
oha -c 100 -z 30s http://localhost:8080/api/v1/links/abc123
# 헤더와 본문을 주고 POST
oha -c 50 -z 60s -m POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"targetUrl":"https://example.com"}' \
http://localhost:8080/api/v1/links
# 초당 요청 수를 고정해 "이 정도는 버티나"를 봅니다 ★ 이쪽이 더 유용합니다
oha -q 2000 -z 5m http://localhost:8080/health
Summary:
Success rate: 100.00%
Total: 30.00 secs
Requests/sec: 48213.7
Response time histogram:
0.001 [41203] |■■■■■■■■■■■■■■■■■■■■■■■
0.002 [ 8912] |■■■■
Latency distribution:
50% in 0.0011 secs
95% in 0.0028 secs
99% in 0.0061 secs ★ 여기를 봅니다. 평균이 아니라 p99
--release 로 빌드한 바이너리를 재세요.
디버그 빌드는 10~50배 느립니다.
그리고 부하 도구를 서버와 같은 기계에서 돌리면
CPU 를 서로 뺏어 숫자가 무의미해집니다 — 별도 머신에서 거세요.
무엇을 보고 무엇을 고칠까
| 증상 | 보통의 원인 |
|---|---|
| 처리량이 코어 수에 안 붙음 | 블로킹 호출 · 전역 뮤텍스 경합 |
| p50 은 좋은데 p99 만 나쁨 | DB 풀 대기 · 큐 대기 · 가끔의 캐시 미스 |
| 부하가 오르면 에러율 급증 | 풀 고갈(w04) · 파일 디스크립터 한계 |
| 메모리가 계속 증가 | 태스크 누수 · 무한 큐 · 캐시에 만료 없음 |
| 처리량이 시간이 갈수록 하락 | 커넥션 누수 · 인덱스 없는 쿼리가 테이블 증가와 함께 |
| CPU 는 여유인데 느림 | 거의 항상 기다리는 중 — 추적(w10)으로 어디인지 봅니다 |
# k6 — 시나리오가 필요할 때 (로그인 → 생성 → 조회)
# CI 야간 잡에서 임계값을 넘으면 실패시킵니다
export const options = {
stages: [
{ duration: '1m', target: 200 }, // 램프업
{ duration: '3m', target: 200 }, // 유지
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_duration: ['p(99)<500'], // p99 500ms 미만
http_req_failed: ['rate<0.01'], // 실패율 1% 미만
},
};
tower::ServiceExt::oneshot 으로 서버 없이 요청을 넣는 테스트를 가장 두껍게 쌓고,
DB 는 목이 아니라 #[sqlx::test] 나 testcontainers 로 진짜를 씁니다.
외부 HTTP 는 wiremock, 트레이트는 mockall.
부하는 oha 로 p99 를 재고 지난주와 비교하세요.배포 — 20MB 이미지와 깨지지 않는 CI
실전 탭(🧰 t11)에서 다단계 빌드의 기본형을 봤습니다.
여기서는 실제로 CI 시간을 절반 이하로 줄이는 cargo-chef,
이미지를 20MB 로 만드는 방법, 그리고 클라우드별 배포 방식까지 갑니다.
Rust 배포의 이점은 단순합니다 — 런타임이 필요 없으니 옮길 것이 파일 하나입니다.
Dockerfile — 그대로 복사해 쓰는 판
# syntax=docker/dockerfile:1.7
ARG RUST_VERSION=1.89
# ---------- 1) chef 베이스 ----------
FROM rust:${RUST_VERSION}-slim AS chef
RUN cargo install cargo-chef --locked
WORKDIR /app
# ---------- 2) 의존성 목록 뽑기 ----------
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# ---------- 3) 의존성만 빌드 (여기가 캐시 층) ----------
FROM chef AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# ---------- 4) 내 코드 빌드 ----------
COPY . .
ENV SQLX_OFFLINE=true
RUN cargo build --release --bin server && \
strip target/release/server
# ---------- 5) 런타임 ----------
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=builder /app/target/release/server /app/server
COPY --from=builder /app/config /app/config
COPY --from=builder /app/migrations /app/migrations
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app/server"]
ENV SQLX_OFFLINE=true 를 빼먹지 마세요.
query! 매크로가 빌드할 때 DB 에 접속하려 하는데
도커 빌드 안에는 DB 가 없습니다.
cargo sqlx prepare 로 만든 .sqlx/ 를 반드시 커밋하고
.dockerignore 에서 제외하지 않았는지 확인하세요(w04).
진짜 작은 이미지 — musl 정적 링크
# scratch 로 5~15MB 를 만드는 판
FROM rust:1.89-alpine AS builder
RUN apk add --no-cache musl-dev pkgconfig protobuf-dev
WORKDIR /app
COPY . .
ENV SQLX_OFFLINE=true
# rustls 를 쓰면 OpenSSL 의존이 사라져 정적 링크가 쉬워집니다 ★
RUN cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
# ① HTTPS 를 부른다면 CA 인증서가 필요합니다
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# ② 시간대가 필요하면 tzdata 도 (또는 TZ=UTC 로 통일)
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/server /server
USER 65534:65534
ENTRYPOINT ["/server"]
| 베이스 | 크기 | 주의 |
|---|---|---|
rust:slim 그대로 | 800MB+ | 운영에 쓰지 마세요 |
debian:bookworm-slim | ~90MB | 디버깅 편함 |
alpine | ~15MB | musl 할당자 느림 |
distroless/cc | ~25MB | 실무 기본값. 셸 없음 |
scratch + musl | ~8MB | 인증서·셸·디버깅 전부 없음 |
jemallocator 나 mimalloc 을 함께 넣거나,
몇 MB 를 포기하고 distroless 를 쓰는 편이 대개 이득입니다.
이렇게도 쓸 수 있다 — GitHub Actions CI 를 제대로 🟡 중급
검사 · 테스트 · 빌드 파이프라인
name: ci
on: [push, pull_request]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings" # ★ 경고를 에러로 — 쌓이지 않게 합니다
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { components: rustfmt, clippy }
- uses: Swatinem/rust-cache@v2 # ★ 빌드 시간이 절반 이하로
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D clippy::all
- run: cargo deny check # 라이선스·취약점·중복 의존성
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env: { POSTGRES_PASSWORD: postgres }
options: >-
--health-cmd pg_isready --health-interval 5s --health-retries 10
ports: ['5432:5432']
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo install sqlx-cli --no-default-features --features rustls,postgres
- run: sqlx migrate run
- run: cargo sqlx prepare --check --workspace # ★ .sqlx 가 최신인지 검증
- uses: taiki-e/install-action@nextest
- run: cargo nextest run --workspace
docker:
needs: [check, test]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha # ★ 레이어 캐시를 GHA 에 저장
cache-to: type=gha,mode=max
CI 가 느리거나 깨질 때 확인할 것
| 증상 | 원인과 해결 |
|---|---|
| 매번 전체 재빌드 | Swatinem/rust-cache 누락, 또는 RUSTFLAGS 가 잡마다 다름 (플래그가 바뀌면 캐시가 무효) |
| 도커 빌드가 항상 느림 | cache-from/to: type=gha 누락 · COPY . . 를 너무 앞에서 함 |
query! 빌드 실패 | .sqlx/ 미커밋 또는 SQLX_OFFLINE 미설정 |
| 어제까지 되던 clippy 실패 | 툴체인이 stable 이라 새 린트가 들어옴 — rust-toolchain.toml 로 고정(🦀 r01) |
| protoc not found | 빌드 이미지에 protobuf-compiler 설치(w09) |
| 테스트가 간헐적 실패 | 테스트가 DB·포트를 공유 — #[sqlx::test] 로 격리(w04) |
# .dockerignore — 없으면 target/ 수 GB 를 컨텍스트로 올립니다 ★
target/
.git/
.github/
**/node_modules
*.md
!README.md
# ★ .sqlx 와 migrations 는 절대 넣지 마세요 (빌드에 필요합니다)
latest 를 쓰지 마세요.
${{ github.sha }} 로 태그하고
배포 매니페스트에 그 다이제스트를 박으면,
“어떤 커밋이 지금 돌고 있는지”가 항상 명확합니다.
롤백도 태그만 바꾸면 끝입니다.
실무에선 이렇게 — 컨테이너 없이 · 클라우드별로 🔴 실무
systemd — 단일 서버라면 이게 가장 단순합니다
# /etc/systemd/system/myservice.service
[Unit]
Description=My Rust Service
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=myservice
Group=myservice
WorkingDirectory=/opt/myservice
ExecStart=/opt/myservice/server
EnvironmentFile=/etc/myservice/env # 시크릿은 여기에 (chmod 600)
Restart=always
RestartSec=2
KillSignal=SIGTERM # ★ 정상 종료 신호 (w10)
TimeoutStopSec=30 # 그동안 진행 중 요청을 마칩니다
# 보안 강화 — 거의 공짜입니다
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/myservice
LimitNOFILE=65535 # ★ WebSocket·커넥션이 많으면 필수
[Install]
WantedBy=multi-user.target
# 무중단 배포 흉내 — 소켓 활성화 없이 간단히
sudo systemctl daemon-reload
sudo systemctl restart myservice # 30초 안에 정상 종료 후 재시작
journalctl -u myservice -f # 로그 (JSON 이면 | jq 로)
# 진짜 무중단이 필요하면 서버 두 대 + 로드밸런서, 또는 소켓 활성화
플랫폼별 — 가장 빠른 길
| 플랫폼 | 방식과 요령 |
|---|---|
| Fly.io | fly launch 가 Dockerfile 을 감지합니다. fly.toml 에 헬스체크와 auto_stop_machines 설정. 메모리 256MB 로 충분한 경우가 많습니다 |
| Railway Render | 깃 푸시 배포. Dockerfile 이 있으면 그대로 씁니다. PORT 환경변수를 읽도록 만들어 두세요 |
| AWS ECS Fargate | 태스크 정의에 CPU 0.25 / 메모리 512 로 시작. ALB 헬스체크는 /health, 배포 그레이스 기간을 정상 종료 시간보다 길게 |
| Kubernetes | livenessProbe=/health, readinessProbe=/ready, terminationGracePeriodSeconds: 45. 요청/제한을 메모리 128Mi~256Mi 로 잡아 보세요 |
| Lambda | cargo lambda — 콜드 스타트가 수십 ms 라 Rust 가 특히 잘 맞습니다 |
# Kubernetes 배포에서 실제로 중요한 부분만
spec:
terminationGracePeriodSeconds: 45 # ★ w10 의 대기 5초 + 최대 요청 시간
containers:
- name: server
image: ghcr.io/org/myservice@sha256:...
ports: [{ containerPort: 8080 }]
env:
- name: APP_ENV
value: production
- name: APP__DB__URL
valueFrom: { secretKeyRef: { name: db, key: url } }
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "1000m", memory: "256Mi" }
livenessProbe:
httpGet: { path: /health, port: 8080 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
failureThreshold: 2
cargo-chef 로 의존성 층을 캐시하고,
런타임은 distroless/cc(약 25MB) 를 기본으로 삼습니다.
CI 는 Swatinem/rust-cache + type=gha 레이어 캐시 두 가지로 시간이 절반이 됩니다.
SQLX_OFFLINE 과 .dockerignore 는 빠뜨리기 쉬우니 먼저 확인하세요.실전 프로젝트 — URL 단축 + 통계 서비스를 끝까지
지금까지의 모든 조각을 하나로 잇습니다 — Axum 0.8 + sqlx(Postgres) + Redis(캐시·카운터) + JWT + OpenAPI + Docker Compose. 리다이렉트는 초당 수만 건을 견뎌야 하고, 관리 API 는 인증과 검증이 필요하며, 클릭 통계는 쓰기가 읽기를 막지 않아야 합니다. 이 세 가지 요구가 어떻게 코드로 나뉘는지 보세요.
스키마 — migrations/0001_init.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'user',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE, -- ★ 유니크가 곧 동시성 방어
target_url text NOT NULL,
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at timestamptz,
disabled boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_links_owner ON links(owner_id, created_at DESC);
-- 클릭은 append-only. 파티션을 쓰면 오래된 것을 통째로 떼어 낼 수 있습니다
CREATE TABLE clicks (
id bigserial,
link_id uuid NOT NULL,
at timestamptz NOT NULL DEFAULT now(),
country text,
referer text,
ua_family text
) PARTITION BY RANGE (at);
CREATE TABLE clicks_2026_08 PARTITION OF clicks
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE INDEX ON clicks_2026_08 (link_id, at DESC);
-- 집계는 미리 만들어 둡니다 (조회 때 계산하지 않습니다)
CREATE TABLE click_daily (
link_id uuid NOT NULL,
day date NOT NULL,
cnt bigint NOT NULL DEFAULT 0,
PRIMARY KEY (link_id, day)
);
slug 에 유니크 제약을 거는 것이 동시성 방어의 전부입니다.
“먼저 SELECT 로 확인하고 없으면 INSERT” 는 경쟁 상태입니다 —
그냥 INSERT 하고 유니크 위반 에러를 409 로 번역하는 것이 정답입니다.
리다이렉트 핸들러 — 가장 뜨거운 코드
use axum::{extract::{Path, State, ConnectInfo}, response::Redirect};
pub async fn redirect(
Path(slug): Path<String>,
State(st): State<AppState>,
headers: axum::http::HeaderMap,
) -> Result<Redirect, AppError> {
// ① 캐시 — 히트하면 DB 를 아예 안 봅니다
let target: Option<String> = cache::get_target(&st.redis, &slug).await?;
let target = match target {
Some(t) if t == cache::NEGATIVE => return Err(AppError::NotFound("링크")),
Some(t) => t,
None => {
// ② 미스 — DB 조회 후 캐시를 채웁니다
match repo::resolve(&st.db, &slug).await? {
Some(link) => {
let ttl = link.cache_ttl(); // 만료 임박이면 짧게
cache::set_target(&st.redis, &slug, &link.target_url, ttl).await?;
link.target_url
}
None => {
// ★ 없는 슬러그도 짧게 캐시합니다 — 캐시 관통 공격 방어
cache::set_negative(&st.redis, &slug, Duration::from_secs(60)).await?;
return Err(AppError::NotFound("링크"));
}
}
}
};
// ③ 통계 — 채널에 던지고 즉시 반환합니다 (응답 지연에 포함되지 않습니다)
let _ = st.clicks.try_send(ClickEvent {
slug: slug.clone(),
at: chrono::Utc::now(),
country: headers.get("cf-ipcountry")
.and_then(|v| v.to_str().ok()).map(str::to_owned),
referer: headers.get(axum::http::header::REFERER)
.and_then(|v| v.to_str().ok()).map(|s| s.chars().take(200).collect()),
}); // ★ try_send — 가득 차면 통계를 버립니다. 리다이렉트를 막지 않습니다
// ④ Redis 카운터도 함께 (실시간 조회용, 원자적)
let _ = cache::incr_click(&st.redis, &slug).await;
Ok(Redirect::temporary(&target)) // 302 — 통계를 위해 영구(301)는 피합니다
}
이렇게도 쓸 수 있다 — 생성 API · 캐시 · 배치 워커 🟡 중급
링크 생성 — 유니크 위반을 409 로
const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789"; // 헷갈리는 글자 제외
fn random_slug(len: usize) -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
(0..len).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
}
#[utoipa::path(post, path = "/api/v1/links", tag = "links",
request_body = CreateLink,
responses((status = 201, body = LinkRes), (status = 409, body = ErrBody)),
security(("bearer" = [])))]
pub async fn create_link(
user: AuthUser, // ← 인증 (w06)
State(st): State<AppState>,
Valid(body): Valid<CreateLink>, // ← 검증 (w02·w07)
) -> ApiResult<Created<LinkRes>> {
// 사용자 지정 슬러그가 있으면 한 번만, 없으면 충돌 시 재시도
let mut attempts = 0;
loop {
let slug = body.custom_slug.clone()
.unwrap_or_else(|| random_slug(7));
match repo::insert_link(&st.db, &slug, &body, user.id).await {
Ok(link) => {
let url = format!("{}/{}", st.cfg.app.base_url, link.slug);
return Ok(Created(url, LinkRes::from(link)));
}
// ★ 유니크 위반을 정확히 잡아냅니다 (Postgres SQLSTATE 23505)
Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => {
if body.custom_slug.is_some() { return Err(AppError::Conflict); }
attempts += 1;
if attempts >= 5 { return Err(AppError::Conflict); }
continue; // 랜덤 슬러그면 다시 뽑아 재시도
}
Err(e) => return Err(e.into()),
}
}
}
Redis 캐시 · 카운터
use deadpool_redis::{Pool, redis::AsyncCommands};
pub const NEGATIVE: &str = "__none__"; // 없는 링크를 나타내는 표식
fn key(slug: &str) -> String { format!("l:{slug}") }
pub async fn get_target(pool: &Pool, slug: &str) -> Result<Option<String>, AppError> {
let mut c = pool.get().await.map_err(|e| AppError::Other(e.into()))?;
// ★ 캐시가 죽어도 서비스는 살아야 합니다 — 에러를 미스로 강등
Ok(c.get::<_, Option<String>>(key(slug)).await.unwrap_or(None))
}
pub async fn set_target(pool: &Pool, slug: &str, url: &str, ttl: Duration)
-> Result<(), AppError>
{
let mut c = pool.get().await.map_err(|e| AppError::Other(e.into()))?;
// 만료를 살짝 흩뜨립니다 — 동시에 만료돼 DB 로 몰리는 것을 막습니다 ★
let jitter = fastrand::u64(0..ttl.as_secs() / 5 + 1);
let _: () = c.set_ex(key(slug), url, ttl.as_secs() + jitter).await
.unwrap_or(());
Ok(())
}
// 실시간 카운터 — 원자적으로 증가, 주기적으로 DB 에 반영
pub async fn incr_click(pool: &Pool, slug: &str) -> Result<(), AppError> {
let mut c = pool.get().await.map_err(|e| AppError::Other(e.into()))?;
let k = format!("c:{slug}");
let _: () = redis::pipe().atomic()
.incr(&k, 1).ignore()
.expire(&k, 86_400 * 2).ignore()
.query_async(&mut *c).await.unwrap_or(());
Ok(())
}
// 링크를 수정·삭제하면 캐시를 반드시 무효화합니다 ★ 빠뜨리기 쉬움
pub async fn invalidate(pool: &Pool, slug: &str) {
if let Ok(mut c) = pool.get().await {
let _: Result<(), _> = c.del(key(slug)).await;
}
}
SET NX 로 하나만 채우게 하고
나머지는 잠깐 옛 값을 쓰게 합니다.
실무에선 이렇게 — 배치 워커와 Docker Compose 🔴 실무
클릭 배치 워커 — 한 건씩 INSERT 하지 않습니다
use tokio::sync::mpsc;
pub fn spawn_click_writer(db: sqlx::PgPool) -> mpsc::Sender<ClickEvent> {
let (tx, mut rx) = mpsc::channel::<ClickEvent>(10_000); // 유계 채널 ★
tokio::spawn(async move {
let mut buf: Vec<ClickEvent> = Vec::with_capacity(500);
let mut tick = tokio::time::interval(Duration::from_secs(1));
loop {
tokio::select! {
got = rx.recv() => match got {
Some(ev) => {
buf.push(ev);
if buf.len() >= 500 { flush(&db, &mut buf).await; }
}
None => { flush(&db, &mut buf).await; break; } // 종료 시 남은 것 저장
},
_ = tick.tick() => { if !buf.is_empty() { flush(&db, &mut buf).await; } }
}
}
});
tx
}
async fn flush(db: &sqlx::PgPool, buf: &mut Vec<ClickEvent>) {
if buf.is_empty() { return; }
// ★ UNNEST 로 한 번에 넣습니다 — 500건이 왕복 1회
let ids: Vec<Uuid> = buf.iter().map(|e| e.link_id).collect();
let ats: Vec<_> = buf.iter().map(|e| e.at).collect();
let ctys: Vec<Option<String>> = buf.iter().map(|e| e.country.clone()).collect();
let r = sqlx::query!(
"INSERT INTO clicks (link_id, at, country)
SELECT * FROM UNNEST($1::uuid[], $2::timestamptz[], $3::text[])",
&ids, &ats, &ctys as &[Option<String>]
).execute(db).await;
if let Err(e) = r {
tracing::error!(?e, n = buf.len(), "클릭 배치 저장 실패");
// ★ 통계는 유실을 허용합니다. 여기서 재시도 루프를 돌면
// 장애 때 메모리가 터집니다 — 지표로만 남기고 버립니다
metrics::counter!("clicks_dropped_total").increment(buf.len() as u64);
}
buf.clear();
}
docker-compose.yml — 한 줄로 전부 띄웁니다
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: shortener
ports: ["5432:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
redis:
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "256mb",
"--maxmemory-policy", "allkeys-lru"] # ★ 캐시니까 LRU
ports: ["6379:6379"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
app:
build: .
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
environment:
APP_ENV: local
APP__DB__URL: postgres://app:devpassword@db:5432/shortener
APP__REDIS__URL: redis://redis:6379
APP__AUTH__JWT_SECRET: dev-only-secret-change-me
RUST_LOG: info,tower_http=debug,sqlx=warn
ports: ["8080:8080"]
volumes: { pgdata: }
docker compose up --build # 전부 뜹니다
open http://localhost:8080/docs # Swagger UI (w07)
# 흐름 확인
TOKEN=$(curl -s -XPOST localhost:8080/auth/login \
-H 'content-type: application/json' \
-d '{"email":"a@b.com","password":"pw"}' | jq -r .accessToken)
curl -s -XPOST localhost:8080/api/v1/links \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"targetUrl":"https://example.com"}' | jq
curl -i localhost:8080/abc1234 # 302 Location: https://example.com
curl -s localhost:8080/api/v1/links/{id}/stats -H "authorization: Bearer $TOKEN"
한 단계 더 — 숫자로 확인하고, 더 밀어붙이기 🧬 전문가
같은 서비스를 세 가지로 만들어 재면
같은 로직(캐시 히트 리다이렉트)을 세 스택으로 구현해 같은 조건에서 잰, 흔히 나오는 범위입니다. 하드웨어·설정에 따라 달라지므로 자릿수만 보세요.
| Axum | FastAPI | Spring | |
|---|---|---|---|
| 처리량(상대) | 1.0 | 0.06~0.12 | 0.45~0.65 |
| p99 (캐시 히트) | 1~3ms | 15~40ms | 4~12ms |
| 부하 시 RSS | 25~45MB | 150~350MB | 350~600MB |
| 콜드 스타트 | 10ms 안팎 | 1~2초 | 4~9초 |
| 이미지 크기 | ~25MB | ~250MB | ~300MB |
| p99 안정성 | 평탄 | GIL 경합 | GC 스파이크 |
여기서 더 나아가려면
| 다음 단계 | 무엇을 하나 |
|---|---|
| 인메모리 1차 캐시 | moka 로 프로세스 안에 LRU 를 두면 Redis 왕복도 사라집니다. 무효화가 어려워지므로 TTL 을 짧게 |
| 슬러그를 순번으로 | 랜덤 대신 시퀀스 + base62 — 충돌 재시도가 사라집니다(대신 추측 가능) |
| 읽기 복제본 | 리다이렉트 조회는 리플리카로, 쓰기는 주 DB 로 — 풀을 둘로 나눕니다 |
| 집계 파이프라인 | clicks 를 Kafka 로 보내고 별도 잡이 click_daily 를 채웁니다 |
| 지역 분산 | Fly.io 멀티 리전 + 리플리카 — 리다이렉트는 읽기뿐이라 잘 맞습니다 |
| 남용 방지 | 도메인 블랙리스트 · Safe Browsing 조회 · 사용자별 생성 한도(w05) |
// moka 로 프로세스 내 캐시를 얹는 예 — 가장 효과가 큰 다음 한 걸음 ★
use moka::future::Cache;
#[derive(Clone)]
pub struct AppState {
pub local: Cache<String, String>, // slug → target_url
/* ... */
}
let local = Cache::builder()
.max_capacity(100_000) // 링크 10만 개 ≈ 수십 MB
.time_to_live(Duration::from_secs(60)) // 짧게 — 무효화가 어려우므로
.build();
// 조회 순서: 로컬 → Redis → Postgres
if let Some(t) = st.local.get(&slug).await { return Ok(Redirect::temporary(&t)); }
// 무효화는 "완벽하지 않아도 되는" 선택입니다.
// 60초 안에 반영되면 충분한지 먼저 판단하고, 아니라면
// Redis Pub/Sub 으로 전 인스턴스에 무효화 신호를 뿌리세요(w08 의 다리 구조).
배포 전 최종 점검표
❌ 이게 없으면 배포하지 마세요
- DB 풀의
acquire_timeout·max_lifetime(w04) - 요청 본문 크기 상한과 전역 타임아웃 (w02·w05)
/health와/ready분리 · 정상 종료 (w10)- 로그에서
Authorization마스킹 (w05) - 로그인 경로의 속도 제한 (w05)
- 에러 응답에 내부 정보·스택 미노출 (w03)
✅ 있으면 밤에 잘 잡니다
- p99 · 5xx 비율 · 풀 대기 알람 (w10)
- 요청 ID 가 로그·응답·하위 호출까지 전파 (w05)
- 롤백 절차 — 이미지 태그 한 줄 (w12)
- 마이그레이션이 앞뒤 버전과 호환 (w04)
oneshot테스트가 주요 경로를 덮음 (w11)- 부하 시험 기준선과 지난주 비교 (w11)
이 탭을 다 읽으셨습니다
Rust 백엔드는 처음 2주가 가장 답답하고, 그 뒤로는 계속 편해지는 쪽입니다. 컴파일러와 싸우는 시간이 런타임에 터질 버그를 미리 갚는 것이고, 배포는 파일 하나, 메모리는 수십 MB, p99 는 평탄합니다.
시작은 작게 하세요 — 조회 API 하나를 Axum 으로 옮기고, w10 의 지표를 붙여 스프링·FastAPI 인스턴스와 나란히 놓고 2주 관찰하면 팀을 설득할 숫자가 생깁니다. 막히면 Ctrl K 로 필요한 섹션을 찾고, 기초 문법은 🦀 Rust 기초와 🔐 소유권 탭, 크레이트 선택은 🧰 실전·생태계 탭으로 돌아가세요.
Rust 가
정말로 이기는
자리를 다룹니다
이 탭은 “Rust 로 웹 서버를 만들 수 있다”에서 한 층 더 내려갑니다 —
Tokio 스케줄러가 태스크를 어떻게 굴리는지, 채널과 액터로 Arc<Mutex> 없이 설계하는 법,
바이트를 복사하지 않고 넘기는 법, Kafka·Redis 파이프라인, Polars·DataFusion 같은 데이터 엔진,
할당자 교체와 아레나, 그리고 flamegraph · tokio-console · PGO 로 수치를 끌어올리는 법까지입니다.
마지막 섹션에서 실시간 로그 수집·집계 서비스를 처음부터 끝까지 만들고 부하를 걸어 봅니다.
⚡ 성능 탭이 “언어 안쪽”이라면 이 탭은 “서비스 바깥쪽”입니다.
Rust 가 이기는 자리 — 어디에 쓰면 남는 장사인가
Rust 를 “빠른 언어”로만 소개하면 절반만 맞습니다. C 도 빠르고 C++ 도 빠릅니다. Rust 의 진짜 값어치는 그 속도를 유지하면서 메모리 사고와 데이터 레이스를 컴파일 단계에서 지운다는 데 있습니다. 이 섹션은 “그래서 어떤 일을 Rust 로 다시 써야 이득인가”를 실제 사례와 함께 정리합니다.
제로 비용 추상화 — 예쁘게 써도 안 느려집니다
// 읽기 좋은 이터레이터 체인
pub fn sum_even(v: &[u32]) -> u64 {
v.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x as u64)
.sum()
}
// --release 로 빌드하면 컴파일러가 이걸로 바꿉니다:
// 루프 하나 + SIMD 벡터화. 클로저 호출도, 중간 Vec 도 없습니다.
// godbolt.org 에 붙여 보면 어셈블리가 20줄 남짓입니다.
// 파이썬이라면 sum(x for x in v if x % 2 == 0) — 편하지만
// 제너레이터 프레임 · 정수 객체 할당 · 참조 카운트가 매 원소마다 돕니다.
// 자바라면 IntStream — JIT 가 잘 풀어 주지만 워밍업이 필요하고
// 객체 헤더 · 카드 마킹 같은 비용이 남습니다.
데이터 레이스 — 다른 언어에선 런타임, 여기선 빌드 에러
use std::collections::HashMap;
use std::thread;
fn main() {
let mut map = HashMap::new();
map.insert("hits", 0u64);
// ❌ 두 스레드가 같은 HashMap 을 동시에 건드리려 함
thread::scope(|s| {
s.spawn(|| { map.insert("a", 1); }); // error[E0499]:
s.spawn(|| { map.insert("b", 2); }); // cannot borrow `map` as mutable
}); // more than once at a time
}
// ✅ 공유하려면 '어떻게 공유할지'를 타입으로 적어야 통과합니다
use std::sync::{Arc, Mutex};
let map = Arc::new(Mutex::new(HashMap::new()));
// 또는 아예 공유하지 않는 설계 — 액터 (h03) ★ 이 탭이 미는 방향
concurrent map writes 로 프로세스가 죽습니다.
Java 라면 조용히 값이 깨집니다. Rust 는 그 코드가 애초에 빌드되지 않습니다(⚡ e06 의 Send·Sync).
이렇게도 쓸 수 있다 — C++ 와 항목별로 나란히 놓기 🟡 중급
C++ 대비 — 실무에서 체감되는 것만
| 항목 | C++23 | Rust |
|---|---|---|
| 런타임 속도 | 기준 | 같은 급(±5%) |
| 메모리 사고 | 개발자 책임 | 컴파일러가 차단 |
| 데이터 레이스 | 실행 중 발견 | 빌드 에러 |
| 의존성 | vcpkg/Conan/직접 | cargo 하나 |
| 빌드 시스템 | CMake 학습 필요 | 없음(cargo) |
| 테스트·벤치·문서 | 따로 붙임 | 내장 |
| 컴파일 시간 | 모듈로 개선 중 | 느림(제네릭·LLVM) |
| 기존 자산 | 30년치 | 10년치 + FFI |
| 인력 구하기 | 쉬움 | 어려움 |
| GUI · 게임 엔진 | 성숙 | 미성숙 |
Rust 를 고르면 안 되는 자리도 분명합니다
| 상황 | 더 나은 선택 |
|---|---|
| CRUD 웹 · 관리자 페이지 | Spring · Django · Rails |
| 데이터 분석 · 실험 | 파이썬(무거우면 Polars 로) |
| 2주짜리 프로토타입 | 익숙한 언어 아무거나 |
| 팀에 Rust 경험 0명 | 한 서비스만 파일럿 |
| UI 가 핵심인 앱 | Kotlin · Swift · TS |
| I/O 대기가 99% | 언어를 바꿔도 안 빨라집니다 |
실무에선 이렇게 — 전환은 “가장 뜨거운 한 조각”부터 🔴 실무
❌ 전면 재작성
“파이썬 백엔드 전체를 Rust 로 옮기겠습니다”
6개월 뒤 상태
· 기능은 아직 60% (기존 팀은 그동안 신규 기능을 계속 냄)
· 소유권 에러로 매일 두 시간씩 멈춤
· 라이브러리가 없어 직접 구현한 코드가 늘어남
· 결국 롤백 — “Rust 는 우리 팀엔 안 맞더라”
실패 원인은 언어가 아니라 '전면'입니다.
✅ 뜨거운 조각만 도려내기
① 프로파일로 CPU 상위 3개 함수를 찾는다 (h11)
② 그중 '순수 계산 + 입출력 없음' 인 것 하나를 고른다
③ Rust 로 다시 쓰고 파이썬 모듈로 노출한다 (🐍 y03)
④ A/B 로 수치를 재고 (지연 · CPU · 메모리) 기록한다
⑤ 수치가 팀을 설득하면 다음 조각으로
실제 사례가 전부 이 모양입니다
· Dropbox — 동기화 엔진의 압축·해시 부분
· Discord — 상태(read state) 서비스 한 개
· Figma — 멀티플레이어 서버 한 개
· Cloudflare— 프록시 한 계층(Pingora)
한 단계 더 — “안전하다”가 정확히 무엇을 보장하나 🧬 전문가
막아 주는 것과 안 막아 주는 것
| 사고 | 안전한 Rust 에서 |
|---|---|
| use-after-free | 불가능 |
| 이중 해제 | 불가능 |
| 버퍼 넘침 | 패닉(조용히 안 넘어감) |
| 널 역참조 | 타입에 없음 |
| 데이터 레이스 | 빌드 에러 |
| 이터레이터 무효화 | 빌드 에러 |
| 메모리 누수 | 가능 (안전으로 분류) |
| 데드락 | 가능 — h04 참고 |
| 논리 버그 | 가능 |
| 무한 루프 · OOM | 가능 |
| 정수 오버플로 | 디버그 패닉/릴리즈 wrap |
Arc 순환 참조가 대표적이고, 잡는 법은 h10 에 있습니다.
unsafe 는 “검사를 끄는” 게 아닙니다
// unsafe 블록 안에서도 소유권·빌림·타입 검사는 그대로 돕니다.
// 추가로 '다섯 가지'가 허용될 뿐입니다:
// ① 원시 포인터 역참조 ② unsafe fn 호출
// ③ static mut 접근 ④ union 필드 읽기
// ⑤ unsafe trait 구현 (Send/Sync 를 손으로 붙이기)
// 실무 원칙: unsafe 를 안전한 API 로 '포장'해서 밖으로는 안 보이게 합니다.
pub fn split_at_mut(v: &mut [u32], mid: usize)
-> (&mut [u32], &mut [u32])
{
assert!(mid <= v.len()); // ← 여기서 조건을 보장하고
let ptr = v.as_mut_ptr();
unsafe { // ← 좁은 범위에서만
(std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), v.len() - mid))
}
}
// 표준 라이브러리가 통째로 이 패턴입니다 — Vec · HashMap 내부는 unsafe 투성이고,
// 밖으로는 안전한 인터페이스만 나옵니다. (🔬 d01)
unsafe 를 한 줄도 안 씁니다.
쓰는 곳은 FFI 경계(🔗 f03)와 자료구조 내부뿐입니다.
Tokio 내부 — work-stealing 스케줄러가 태스크를 굴리는 법
tokio::spawn 한 태스크는 그다음에 무슨 일을 겪을까요.
이 그림이 머릿속에 있으면 “왜 서버가 갑자기 멈추는지” · “워커 수를 몇으로 둘지” · “어디에 spawn_blocking 을 쓸지”가
추측이 아니라 계산이 됩니다. 🧰 t04 가 사용법이라면 여기는 구조입니다.
런타임을 손으로 만들어 설정하기
use tokio::runtime::Builder;
fn main() -> anyhow::Result<()> {
let rt = Builder::new_multi_thread()
.worker_threads(8) // 기본값 = 논리 코어 수
.max_blocking_threads(256) // spawn_blocking 풀 상한 (기본 512)
.thread_name("svc-worker") // 스레드 이름 ★ 프로파일에서 바로 보입니다
.thread_stack_size(2 * 1024 * 1024)// 기본 2MB — 재귀가 깊으면 늘림
.enable_all() // 타이머 + I/O 드라이버 켜기
.build()?;
rt.block_on(async {
// 여기부터 async 세계
server().await
})
}
// 매크로 버전 — 위와 같은 일을 합니다
#[tokio::main(worker_threads = 8)]
async fn main() { /* … */ }
// 스레드 하나짜리 — 테스트 · CLI · 임베디드에 ★
#[tokio::main(flavor = "current_thread")]
async fn main() { /* … */ }
std::thread::available_parallelism() 도 cgroup 을 못 보는 경우가 있어
환경변수로 주입하는 편이 안전합니다.
current_thread vs multi_thread — 무엇이 다른가
| current_thread | multi_thread | |
|---|---|---|
| 스레드 | 1 | N (코어 수) |
| work stealing | 없음 | 있음 |
| 태스크 요구 | 'static만 | Send 필수 |
| 동기화 비용 | 거의 없음 | 원자연산·훔치기 |
| 지연 편차 | 작음 | 조금 있음 |
| 쓰는 곳 | 테스트·CLI·프록시 샤딩 | 일반 서버 |
// Send 가 아닌 값을 await 너머로 들고 가야 할 때 (Rc, RefCell 등)
use tokio::task::LocalSet;
let local = LocalSet::new();
local.run_until(async {
// spawn_local 은 같은 스레드에서만 돕니다 → Send 불필요
tokio::task::spawn_local(async {
let rc = std::rc::Rc::new(1); // ★ Send 아님
some_async(&rc).await;
}).await.unwrap();
}).await;
// 고급 패턴: 코어마다 current_thread 런타임을 하나씩 띄우고
// SO_REUSEPORT 로 소켓을 나눠 갖기 → 훔치기 비용 0, 캐시 지역성 최대
// (monoio · glommio 가 이 모델입니다 — h06)
이렇게도 쓸 수 있다 — 블로킹을 판별하고 격리하기 🟡 중급
무엇이 “블로킹”인가 — 판별 기준
| 코드 | 판정 |
|---|---|
std::fs::read_to_string | 블로킹 → tokio::fs 또는 spawn_blocking |
std::thread::sleep | 블로킹 → tokio::time::sleep |
reqwest::blocking | 블로킹 → 비동기 API 로 |
| 이미지 리사이즈 · 압축 | CPU 블로킹 → spawn_blocking / rayon |
| bcrypt · argon2 해싱 | CPU 블로킹(수십~수백 ms) |
큰 serde_json::from_slice | 수 MB 넘으면 블로킹 취급 |
Mutex(std) 잠깐 잠금 | 괜찮음 — 단 await 를 건너지 말 것 |
| DB 드라이버(sqlx·async) | 안전 |
poll 이 10~100µs 를 넘기면 위험합니다.
“대기”든 “계산”이든 워커를 붙잡는다는 점에서 똑같습니다.
확신이 안 서면 tokio-console 의 busy 시간을 보세요(h11).
격리하는 세 가지 방법
use tokio::task;
// ① spawn_blocking — 짧고 가끔인 블로킹 (파일 · 레거시 라이브러리)
let text = task::spawn_blocking(move || {
std::fs::read_to_string("big.log") // 블로킹 풀에서 실행
}).await??; // JoinError + io::Error
// ② block_in_place — 지금 이 워커를 '블로킹 전용'으로 잠시 전환
// (multi_thread 전용. 지역 큐를 다른 워커에게 넘기고 실행합니다)
let sum = task::block_in_place(|| heavy_cpu(&data));
// ③ rayon 으로 넘기고 결과만 async 로 받기 ★ CPU 작업의 정석 (⚡ e05)
let (tx, rx) = tokio::sync::oneshot::channel();
rayon::spawn(move || {
let out = data.par_iter().map(expensive).sum::<f64>();
let _ = tx.send(out);
});
let out = rx.await?; // 워커는 그동안 다른 요청을 처리합니다
spawn_blocking 은 다른 스레드로 보내므로 클로저가 Send + 'static 이어야 합니다.
block_in_place 는 현재 스레드에서 그대로 돌기 때문에 빌린 참조를 그대로 쓸 수 있지만,
current_thread 런타임에선 패닉합니다.
실무에선 이렇게 — 협력적 스케줄링과 런타임 관측 🔴 실무
태스크 예산(budget) — 굶주림을 막는 장치 ★
// Tokio 는 태스크마다 '128번의 자원 연산' 예산을 줍니다.
// 예산을 다 쓰면 그 자원(소켓·채널)이 Pending 을 돌려주고 태스크는 양보합니다.
// → 데이터가 계속 준비된 소켓 하나가 워커를 독점하는 일이 막힙니다.
// ❌ 예산을 소모하지 않는 루프 — 다른 태스크가 굶습니다
loop {
counter += 1; // 순수 계산은 예산을 안 씁니다
if counter > 100_000_000 { break; }
}
// ✅ 직접 양보 지점을 넣습니다
loop {
counter += 1;
if counter % 1024 == 0 {
tokio::task::yield_now().await; // ★ 여기서 스케줄러에 반납
}
if counter > 100_000_000 { break; }
}
// ✅ 예산을 무시하고 끝까지 돌려야 할 때 (셧다운 처리 등)
tokio::task::unconstrained(async { drain_all().await }).await;
런타임 지표를 직접 뽑기
// Cargo.toml
// tokio = { version = "1", features = ["full"] }
// (일부 지표는 unstable — RUSTFLAGS="--cfg tokio_unstable")
let handle = tokio::runtime::Handle::current();
let m = handle.metrics();
println!("워커 수 {}", m.num_workers());
println!("살아있는 태스크 {}", m.num_alive_tasks());
println!("전역 큐 깊이 {}", m.global_queue_depth());
// 이 세 개만 주기적으로 찍어도 사고 원인의 절반이 보입니다
// · 전역 큐 깊이가 계속 증가 → 처리량 < 유입량 (백프레셔 필요 · h03)
// · alive_tasks 가 단조 증가 → 태스크 누수 (JoinHandle 을 버렸거나
// 채널 수신자가 사라져 영원히 대기)
// 30초마다 지표를 로그로
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
tick.tick().await;
tracing::info!(alive = m.num_alive_tasks(),
gq = m.global_queue_depth(), "rt");
}
});
한 단계 더 — 태스크 하나의 실제 메모리와 수명 🧬 전문가
async fn 은 상태 기계가 됩니다
async fn handle(mut sock: TcpStream) -> io::Result<()> {
let mut buf = [0u8; 4096]; // ← 이 4KB 가 태스크 크기에 들어갑니다
let n = sock.read(&mut buf).await?; // await 를 건너므로 저장돼야 함
sock.write_all(&buf[..n]).await?;
Ok(())
}
// 컴파일러가 만드는 것 (개념)
// enum HandleFuture {
// Start { sock: TcpStream, buf: [u8; 4096] },
// WaitingRead { sock: TcpStream, buf: [u8; 4096], n: usize },
// WaitingWrite { … },
// Done,
// }
// 크기 = 가장 큰 변형의 크기 ≈ 4KB + α
// 태스크 크기 확인
println!("{}", std::mem::size_of_val(&handle(sock)));
// ★ 동시 접속 10만이면 4KB × 10만 = 400MB 입니다.
// 버퍼를 힙으로 빼거나(BytesMut · h05) 크기를 줄이세요.
let mut buf = vec![0u8; 4096]; // 태스크는 포인터 24바이트만 들고 있음
clippy::large_futures 린트를 켜거나,
Box::pin(fut) 으로 힙에 눕히세요. 재귀 async 함수는 반드시 박싱해야 합니다
(크기가 무한이 되므로 컴파일 에러가 납니다).
JoinHandle · 취소 · 셧다운
// ① JoinHandle 을 drop 해도 태스크는 계속 돕니다 (분리)
let h = tokio::spawn(work());
drop(h); // work() 는 그대로 실행됨 ★ 자바 Future 와 다름
// ② abort() 는 '다음 await 지점에서' 취소합니다
let h = tokio::spawn(work());
h.abort();
match h.await {
Err(e) if e.is_cancelled() => println!("취소됨"),
Err(e) if e.is_panic() => println!("태스크가 패닉"),
Ok(v) => println!("{v:?}"),
Err(e) => return Err(e.into()),
}
// ③ 여러 태스크를 묶어 함께 취소 — 실무 표준
use tokio_util::task::TaskTracker;
use tokio_util::sync::CancellationToken;
let token = CancellationToken::new();
let tracker = TaskTracker::new();
for i in 0..4 {
let t = token.clone();
tracker.spawn(async move {
tokio::select! {
_ = t.cancelled() => {/* 정리 */}
_ = worker(i) => {}
}
});
}
tracker.close();
tokio::signal::ctrl_c().await?;
token.cancel(); // 전부에게 신호
tracker.wait().await; // 전부 끝날 때까지 ★ 우아한 종료
spawn_blocking·rayon 으로 격리합니다.
컨테이너에서는 워커 수를 명시하고, 전역 큐 깊이와 살아있는 태스크 수를 지표로 찍어 두세요 —
사고의 절반은 이 두 숫자에서 먼저 보입니다.채널 · 액터 — Arc<Mutex> 를 안 쓰는 설계
상태를 여럿이 공유해야 할 때 대부분 Arc<Mutex<T>> 부터 꺼냅니다. 동작은 합니다.
그런데 락 경합 · 데드락 · “누가 언제 잠그는지 모르겠음”이 따라옵니다.
Rust 에서 더 잘 통하는 답은 “상태를 한 태스크가 독점하고 메시지로만 말을 건다” — 액터입니다.
채널 네 종류 — 고르는 표 ★
| 채널 | 보내는 쪽 | 받는 쪽 | 쓰는 곳 |
|---|---|---|---|
mpsc | 여럿 | 하나 | 작업 큐 · 액터 입구 |
oneshot | 하나(1회) | 하나 | 요청-응답 |
broadcast | 여럿 | 모두가 사본 | 이벤트 팬아웃 · WS 푸시 |
watch | 여럿 | 모두(최신만) | 설정 변경 · 셧다운 |
use tokio::sync::{mpsc, oneshot, broadcast, watch};
// ① mpsc — 반드시 bounded 로 (unbounded 는 OOM 으로 가는 길)
let (tx, mut rx) = mpsc::channel::<Job>(1024);
tx.send(job).await?; // 가득 차면 여기서 대기 = 백프레셔 ★
// ② oneshot — 딱 한 번
let (rtx, rrx) = oneshot::channel::<u64>();
rtx.send(42).ok();
let v = rrx.await?;
// ③ broadcast — 늦은 구독자는 값을 '건너뜁니다'
let (btx, mut brx) = broadcast::channel::<Event>(256);
match brx.recv().await {
Ok(e) => handle(e),
Err(broadcast::error::RecvError::Lagged(n)) =>
tracing::warn!("이벤트 {n}개 놓침"), // ★ 반드시 처리
Err(_) => return,
}
// ④ watch — 항상 '가장 최근 값' 하나만
let (wtx, mut wrx) = watch::channel(Config::default());
wtx.send(new_cfg)?;
wrx.changed().await?; // 바뀔 때까지 대기
let cfg = wrx.borrow_and_update().clone();
액터 — 실제로 쓰는 형태 (핸들 + 루프)
use tokio::sync::{mpsc, oneshot};
use std::collections::HashMap;
// ① 메시지 타입 — 응답이 필요하면 oneshot 을 같이 싣습니다
enum Msg {
Incr { key: String },
Get { key: String, reply: oneshot::Sender<u64> },
Snapshot { reply: oneshot::Sender<HashMap<String, u64>> },
}
// ② 액터 본체 — 상태가 그냥 지역 변수입니다 (락 없음!)
async fn run(mut rx: mpsc::Receiver<Msg>) {
let mut counts: HashMap<String, u64> = HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
Msg::Incr { key } => *counts.entry(key).or_default() += 1,
Msg::Get { key, reply } => {
let _ = reply.send(counts.get(&key).copied().unwrap_or(0));
}
Msg::Snapshot { reply } => { let _ = reply.send(counts.clone()); }
}
}
// rx.recv() 가 None → 모든 송신자가 사라짐 → 여기서 정리하고 종료 ★
tracing::info!(keys = counts.len(), "counter actor stopped");
}
// ③ 핸들 — 밖에서는 이것만 보입니다. Clone 해서 마음껏 나눠 줍니다
#[derive(Clone)]
pub struct Counter(mpsc::Sender<Msg>);
impl Counter {
pub fn spawn() -> Self {
let (tx, rx) = mpsc::channel(1024);
tokio::spawn(run(rx));
Counter(tx)
}
pub async fn incr(&self, key: &str) {
let _ = self.0.send(Msg::Incr { key: key.into() }).await;
}
pub async fn get(&self, key: &str) -> u64 {
let (tx, rx) = oneshot::channel();
if self.0.send(Msg::Get { key: key.into(), reply: tx }).await.is_err() {
return 0; // 액터가 죽었음
}
rx.await.unwrap_or(0)
}
}
Counter 를 쓰는 쪽은 락도 Arc 도 모릅니다.
나중에 “액터를 16개로 샤딩”하거나 “다른 프로세스로 분리”해도 호출부 코드가 그대로입니다.
Arc<Mutex> 로 짜면 그 변경이 전부 호출부까지 번집니다.
이렇게도 쓸 수 있다 — 백프레셔 · 부하 차단 🟡 중급
unbounded 는 “장애를 미루는” 선택입니다
// ❌ 흔한 실수 — 급하니까 unbounded
let (tx, rx) = mpsc::unbounded_channel();
// 유입 12,000/s · 처리 8,000/s 이면
// 1분 뒤 큐 24만 개, 10분 뒤 240만 개 → OOM 킬
// 그동안 지연은 계속 늘어나 결국 전부 타임아웃된 요청을 처리하게 됩니다
// ✅ bounded — 유입이 처리보다 빠르면 '앞단'이 느려집니다
let (tx, rx) = mpsc::channel(1024);
// 대응 세 가지 중 하나를 반드시 고릅니다
// (a) 기다린다 — 앞단이 HTTP 라면 클라이언트가 대기
tx.send(job).await?;
// (b) 버린다 — 큐가 차면 즉시 503 (실시간성이 중요할 때 ★)
match tx.try_send(job) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
metrics::counter!("dropped").increment(1);
return Err(StatusCode::SERVICE_UNAVAILABLE.into());
}
Err(mpsc::error::TrySendError::Closed(_)) => return Err(anyhow!("shutdown")),
}
// (c) 시간 제한을 둔다 — 그 안에 못 들어가면 포기
tokio::time::timeout(Duration::from_millis(50), tx.send(job)).await??;
동시 실행 수를 세마포어로 제한
use std::sync::Arc;
use tokio::sync::Semaphore;
// 외부 API 가 초당 100건만 받아 준다 → 동시 20건으로 묶습니다
static LIMIT: usize = 20;
let sem = Arc::new(Semaphore::new(LIMIT));
for url in urls {
let permit = sem.clone().acquire_owned().await?; // 자리가 날 때까지 대기
tokio::spawn(async move {
let _p = permit; // 스코프가 끝나면 자동 반납 ★
fetch(&url).await
});
}
// 즉시 거절해야 할 때
match sem.try_acquire() {
Ok(_p) => do_work().await,
Err(_) => return Err(StatusCode::TOO_MANY_REQUESTS.into()),
}
// axum 이라면 미들웨어로 한 줄 (tower)
use tower::limit::ConcurrencyLimitLayer;
let app = Router::new()
.route("/heavy", post(heavy))
.layer(ConcurrencyLimitLayer::new(20))
.layer(tower::load_shed::LoadShedLayer::new()); // 넘치면 503 ★
실무에선 이렇게 — 샤딩 · 감독 · 채널 크레이트 선택 🔴 실무
액터 하나가 병목이면 — 키로 샤딩
use std::hash::{Hash, Hasher};
pub struct ShardedCounter {
shards: Vec<Counter>, // 위에서 만든 액터 핸들 N개
}
impl ShardedCounter {
pub fn new(n: usize) -> Self {
Self { shards: (0..n).map(|_| Counter::spawn()).collect() }
}
fn pick(&self, key: &str) -> &Counter {
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
&self.shards[(h.finish() as usize) % self.shards.len()]
}
pub async fn incr(&self, key: &str) { self.pick(key).incr(key).await }
pub async fn get(&self, key: &str) -> u64 { self.pick(key).get(key).await }
}
// ★ 같은 키는 항상 같은 액터로 갑니다 → 순서가 보장되고 락이 여전히 없습니다.
// 샤드 수는 보통 코어 수의 1~2배. 전체 합계가 필요하면
// 모든 샤드에 Snapshot 을 뿌리고 join_all 로 모읍니다.
let totals = futures::future::join_all(
self.shards.iter().map(|s| s.snapshot())
).await;
액터가 죽으면 — 감독(supervision)
// 태스크가 패닉하면 그 태스크만 죽고 서버는 살아 있습니다.
// 문제는 '조용히' 죽는다는 것 — JoinHandle 을 보고 있어야 압니다.
fn spawn_supervised(name: &'static str) -> Counter {
let (tx, rx) = mpsc::channel(1024);
let mut rx = Some(rx);
tokio::spawn(async move {
loop {
let r = rx.take().expect("rx");
let h = tokio::spawn(run_returning(r));
match h.await {
Ok(returned_rx) => { rx = Some(returned_rx); } // 정상 종료
Err(e) if e.is_panic() => {
tracing::error!(actor = name, "패닉 — 재시작");
// 여기서는 rx 를 잃었으므로 새 채널이 필요합니다.
// 실전에서는 상태를 외부(Redis·파일)에 두거나
// rx 를 Arc<Mutex<Option<_>>> 로 넘겨 회수합니다.
break;
}
Err(_) => break,
}
}
});
Counter(tx)
}
// 더 간단한 실무 답: 패닉을 아예 프로세스 종료로 만들고 재시작을 오케스트레이터에 맡김
// Cargo.toml → [profile.release] panic = "abort" (h11)
// 쿠버네티스라면 그게 가장 예측 가능합니다.
| 크레이트 | async | 특징 | 고르는 기준 |
|---|---|---|---|
tokio::sync::mpsc | 예 | bounded 시 send().await | 기본값 — 이유 없으면 이걸로 |
std::sync::mpsc | 아니오 | 블로킹 recv() | 순수 스레드 코드에서만 |
crossbeam-channel | 아니오 | 매우 빠름 · select! 지원 | rayon·스레드 파이프라인 (h04) |
flume | 양쪽 | 동기·비동기 모두 · 빠름 | 스레드↔async 경계를 잇는 곳 ★ |
async-channel | 예 | mpmc(수신자도 여럿) | 워커 풀에 일감 뿌리기 |
kanal | 양쪽 | 벤치상 최고 수준 | 극한 처리량 · 성숙도는 낮음 |
한 단계 더 — select! 취소 안전성이라는 함정 🧬 전문가
❌ 취소되면 데이터가 사라집니다
loop {
tokio::select! {
// read 도중에 다른 갈래가 이기면 이 future 는 drop 됩니다.
// 이미 소켓에서 꺼낸 바이트는? — 사라집니다. ★
n = sock.read(&mut buf) => { handle(&buf[..n?]); }
_ = shutdown.cancelled() => break,
}
}
// 더 나쁜 예 — 직접 만든 async 함수
tokio::select! {
_ = save_to_db(&rows) => {} // 절반만 쓰고 취소될 수 있음
_ = timeout => {} // → 부분 쓰기 상태로 남음
}
✅ 취소에 안전한 것만 select! 에 넣습니다
// 취소 안전(cancel-safe)한 대표 목록 — 문서에 명시돼 있습니다
// mpsc::Receiver::recv · broadcast::recv · watch::changed
// tokio::time::sleep · FramedRead::next (h06) · TcpListener::accept
let mut framed = Framed::new(sock, LinesCodec::new());
loop {
tokio::select! {
Some(line) = framed.next() => { handle(line?).await; } // ✅ 안전
_ = shutdown.cancelled() => break,
}
}
// 취소가 위험한 작업은 spawn 으로 떼어 내고 '핸들'만 select! 합니다
let h = tokio::spawn(save_to_db(rows));
tokio::select! {
r = h => r??, // ✅ 취소돼도 저장은 계속 진행
_ = shutdown.cancelled() => { /* 나중에 h.await 로 마무리 */ }
}
select! 의 갈래에는 “중간에 버려도 아무 상태가 안 남는 것”만 두세요.
애매하면 tokio::spawn 으로 떼어 내고 JoinHandle 을 select 하면 됩니다.
이 함정은 테스트에서 거의 안 잡히고 부하가 걸릴 때만 나타나서 특히 고약합니다.
mpsc + oneshot 이고, 그 편이 락보다 읽기도 고치기도 쉽습니다.
채널은 반드시 bounded 로 만들어 백프레셔를 얻고, 큐 길이는 지연 예산으로 계산하세요.
select! 에는 취소 안전한 것만 넣습니다.동기화 고급 — 락을 고르는 기준과 락을 없애는 법
액터로 다 풀리지는 않습니다. 읽기가 압도적으로 많은 설정, 수십만 키의 캐시,
초당 수백만 번 올라가는 카운터 — 각각 정답이 다릅니다.
이 섹션은 std::sync · tokio::sync · parking_lot · DashMap · ArcSwap · 원자 연산을
“언제 무엇을”로 정리합니다.
std::sync::Mutex 와 tokio::sync::Mutex — 언제 무엇
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex as TokioMutex;
// ✅ 대부분의 경우 — std(또는 parking_lot). 잠금 구간에 await 가 없으면 됩니다.
let cache = std::sync::Arc::new(StdMutex::new(HashMap::new()));
{
let mut g = cache.lock().unwrap();
g.insert(k, v); // 수십 나노초 — 워커를 막지 않습니다
} // ★ 스코프를 좁게! 여기서 풀립니다
// ❌ 잠근 채 await — 컴파일은 되지만 재앙입니다 (Send 에러로 막힐 때가 많음)
// let g = cache.lock().unwrap();
// let row = db.query(&g.sql).await?; // 이 몇 ms 동안 모두가 대기
// ✅ 정말로 잠금 구간에 await 가 필요하면 tokio 것을 씁니다
let conn = std::sync::Arc::new(TokioMutex::new(Connection::new()));
let mut g = conn.lock().await; // 대기 중 워커를 반납합니다
g.execute("…").await?;
// ✅ 더 나은 답: 잠금 밖으로 await 를 밀어냅니다
let sql = { cache.lock().unwrap().sql.clone() }; // 락은 여기서 끝
let row = db.query(&sql).await?; // await 는 락 밖에서
lock() 이 Err 를 돌려줍니다.
그래서 .unwrap() 이 붙습니다. parking_lot 은 중독 개념이 없어 lock() 이 바로 가드를 줍니다 —
코드가 깔끔해지는 이유 중 하나입니다.
parking_lot — 표준보다 작고 빠릅니다
# Cargo.toml
[dependencies]
parking_lot = "0.12"
use parking_lot::{Mutex, RwLock};
let m = Mutex::new(0u64);
*m.lock() += 1; // unwrap 없음 (중독 없음)
// 데드락 회피용 시도 잠금
if let Some(mut g) = m.try_lock() { *g += 1; }
if let Some(g) = m.try_lock_for(Duration::from_millis(10)) { /* … */ }
let rw = RwLock::new(Vec::<u8>::new());
{
let r = rw.read(); // 여럿이 동시에
println!("{}", r.len());
}
{
let mut w = rw.write(); // 혼자만
w.push(1);
}
// 디버그 빌드에서 데드락 감지 (feature = "deadlock_detection")
// parking_lot::deadlock::check_deadlock() 을 별도 스레드에서 주기 호출
| std | parking_lot | |
|---|---|---|
| Mutex 크기 | 보통 16~40B | 1바이트 |
| 경합 없을 때 | 빠름 | 더 빠름 |
| 중독(poison) | 있음 | 없음 |
| 공정성 | OS 의존 | eventual fairness |
| 시도 잠금(시간) | 없음 | 있음 |
이렇게도 쓸 수 있다 — RwLock 이 오히려 느려지는 이유 🟡 중급
“읽기가 많으니 RwLock” 은 절반만 맞습니다
RwLock 의 숨은 비용
① 읽기 잠금도 '원자 연산'입니다 — 독자 수를 세야 하니까.
여러 코어가 같은 캐시 라인을 두드립니다 (false sharing 과 같은 문제)
② 그래서 읽기가 짧으면 Mutex 보다 느립니다.
경험칙 — 임계 구역이 100ns 미만이면 Mutex 가 낫습니다.
③ 쓰기 기아(writer starvation) 또는 그 반대가 생깁니다.
std::RwLock 은 OS 구현에 맡기므로 플랫폼마다 다르게 굶습니다.
실측 감각 (8코어, 초당 연산 백만 단위)
임계구역 20ns · 읽기 99% Mutex 승 (RwLock 은 -20~30%)
임계구역 500ns · 읽기 99% RwLock 승 (2~4배)
임계구역 500ns · 읽기 80% 비슷하거나 Mutex 승
임계구역이 길다 → 애초에 설계를 바꿀 것 (액터 · 샤딩)
Mutex 로 짜고 ② 재 보고 ③ 정말 읽기가 길고 많을 때만 RwLock.
“읽기가 많다”는 직관만으로 RwLock 을 쓰면 대개 손해입니다.
ArcSwap — 읽기를 사실상 공짜로
use arc_swap::ArcSwap;
use std::sync::Arc;
// 설정처럼 '읽기 수백만 번 · 쓰기 하루 몇 번' 인 값에 최적입니다
static CONFIG: once_cell::sync::Lazy<ArcSwap<Config>> =
once_cell::sync::Lazy::new(|| ArcSwap::from_pointee(Config::default()));
// 읽기 — 락이 없습니다. 원자 포인터 읽기 + 참조 카운트 하나
fn handle(req: &Request) -> Response {
let cfg = CONFIG.load(); // Guard<Arc<Config>>
if req.len() > cfg.max_body { return too_large(); }
// …
}
// 쓰기 — 통째로 새 값으로 교체. 이미 읽던 쪽은 옛 값을 계속 씁니다(안전)
async fn reload() -> anyhow::Result<()> {
let new = Config::from_file("config.toml").await?;
CONFIG.store(Arc::new(new)); // ★ 무중단 설정 리로드
Ok(())
}
// 라우팅 테이블 · 기능 플래그 · TLS 인증서 · 요금표 — 전부 이 패턴입니다.
// 조건: '부분 수정'이 아니라 '통째 교체'여야 합니다.
tokio::sync::watch 도 같은 일을 하지만
구독자가 “바뀌었다”를 알아야 할 때 씁니다. 그냥 최신 값을 읽기만 하면
ArcSwap 이 더 가볍습니다. 단순 초기화 1회면 std::sync::OnceLock 이면 충분합니다.
실무에선 이렇게 — 동시 맵과 캐시 🔴 실무
DashMap — 샤딩된 RwLock<HashMap>
use dashmap::DashMap;
use std::sync::Arc;
let map: Arc<DashMap<String, u64>> = Arc::new(DashMap::new());
// API 는 HashMap 과 거의 같은데 &mut self 가 필요 없습니다
map.insert("a".into(), 1);
map.entry("a".into()).and_modify(|v| *v += 1).or_insert(1);
if let Some(v) = map.get("a") { println!("{}", *v); }
// 내부: 키 해시 상위 비트로 샤드(기본 4 × 코어 수)를 고르고
// 그 샤드의 RwLock 만 잠급니다 → 서로 다른 키는 경합이 없습니다
// ⚠️ 데드락 1순위 — 가드를 든 채 같은 맵을 또 건드리기
// let a = map.get("a");
// map.insert("b".into(), 2); // ← 같은 샤드면 그대로 멈춥니다 ★
let v = { *map.get("a").unwrap() }; // ✅ 가드를 즉시 떨어뜨립니다
map.insert("b".into(), v + 1);
// ⚠️ iter() 도 샤드 락을 듭니다 — 순회 중 insert 는 위험합니다
DashMap 사고는 거의 전부
Ref/RefMut 가 살아 있는 채로 다시 접근해서 납니다.
{ } 로 감싸거나 .copied()·.clone() 으로 값만 꺼내서 나오세요.
캐시가 필요하면 — moka
// DashMap 은 '지우는 정책'이 없습니다 → 그냥 쓰면 메모리가 무한히 큽니다.
// 만료 · 용량 제한 · 통계가 필요하면 moka 를 쓰세요.
use moka::future::Cache;
use std::time::Duration;
let cache: Cache<u64, Arc<User>> = Cache::builder()
.max_capacity(100_000) // 엔트리 수 상한
.time_to_live(Duration::from_secs(300)) // 넣은 뒤 5분
.time_to_idle(Duration::from_secs(60)) // 안 쓰이면 1분
.build();
// ★ get_with — 같은 키에 대한 동시 요청을 하나로 합칩니다 (thundering herd 방지)
let user = cache.get_with(id, async move {
Arc::new(db::load_user(id).await.unwrap())
}).await;
cache.invalidate(&id).await; // 개별 무효화
cache.run_pending_tasks().await; // 정리 강제 (테스트용)
// 정책 요약
// TinyLFU 기반 축출 — LRU 보다 적중률이 높습니다
// 동기 버전은 moka::sync::Cache
get_with 가 진짜 이유 — 캐시 미스가 동시에 1,000건 나면
평범한 구현은 DB 에 1,000번 갑니다. get_with 는 한 번만 가고 나머지는 기다립니다.
캐시 도입 후 오히려 DB 가 죽는 사고가 여기서 납니다.
한 단계 더 — 원자 연산과 lock-free 큐 🧬 전문가
Atomic — 메모리 순서를 고르는 법
use std::sync::atomic::{AtomicU64, AtomicBool, Ordering};
static REQS: AtomicU64 = AtomicU64::new(0);
// ① 통계 카운터 — Relaxed 로 충분합니다 (순서 보장이 필요 없음)
REQS.fetch_add(1, Ordering::Relaxed);
let n = REQS.load(Ordering::Relaxed);
// ② 플래그로 다른 데이터를 '공개'할 때 — Release/Acquire 짝 ★
static READY: AtomicBool = AtomicBool::new(false);
// 쓰는 쪽
unsafe { DATA = compute(); }
READY.store(true, Ordering::Release); // 이 앞의 쓰기가 전부 보이게 됨
// 읽는 쪽
if READY.load(Ordering::Acquire) { // 보이면 DATA 도 반드시 보임
unsafe { use_data(&DATA); }
}
// ③ 확신이 없으면 SeqCst — 가장 강하고 조금 느립니다
REQS.fetch_add(1, Ordering::SeqCst);
// ④ compare_exchange — 낙관적 갱신 루프
let mut cur = REQS.load(Ordering::Relaxed);
loop {
let next = cur.max(new_value);
match REQS.compare_exchange_weak(cur, next,
Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break,
Err(actual) => cur = actual, // 누가 먼저 바꿨음 → 재시도
}
}
AtomicU64 를 16코어가 fetch_add 하면
같은 캐시 라인을 서로 빼앗아 처리량이 급락합니다(⚡ e04 의 false sharing).
해법은 스레드별 카운터를 두고 읽을 때만 합치기(metrics·prometheus 크레이트가 이렇게 합니다).
crossbeam — 스레드 간 lock-free 도구
use crossbeam::queue::{ArrayQueue, SegQueue};
// ① ArrayQueue — 고정 용량 lock-free MPMC. 가득 차면 push 가 실패합니다
let q: ArrayQueue<Job> = ArrayQueue::new(4096);
if q.push(job).is_err() {
metrics::counter!("queue_full").increment(1); // 즉시 버림 = 부하 차단
}
while let Some(j) = q.pop() { run(j); }
// ② SegQueue — 무제한(세그먼트 단위 확장). 상한이 없으니 주의
let q2: SegQueue<Job> = SegQueue::new();
// ③ 스코프 스레드 — 'static 이 아닌 참조를 스레드에 넘길 수 있습니다
let data = vec![1u64; 1_000_000];
crossbeam::thread::scope(|s| {
for chunk in data.chunks(250_000) {
s.spawn(move |_| chunk.iter().sum::<u64>());
}
}).unwrap();
// (표준의 std::thread::scope 도 같은 일을 합니다 — 1.63 이후)
// ④ epoch 기반 회수 — lock-free 자료구조를 직접 만들 때
// "아무도 안 보게 된 뒤에 해제"를 안전하게 처리합니다 (🔬 d09)
crossbeam·flume·DashMap 처럼
검증된 것을 쓰고, 정말 필요하면 loom 으로 모델 검사(🔬 d10)를 돌리세요.
| 상황 | 도구 | 이유 |
|---|---|---|
| 요청 수 세기 | AtomicU64(코어별 분산) | 락 없음 · 정확도 요구 낮음 |
| 설정 무중단 교체 | ArcSwap | 읽기 비용 ≈ 0 |
| 세션 맵 10만 건 | DashMap · moka | 샤딩으로 경합 분산 |
| DB 커넥션 대여 | 세마포어 + 풀 | 동시 수 제한이 본질 |
| 순서가 중요한 상태 | 액터(h03) | 채널 순서 = 처리 순서 |
| 잠금 구간에 await | tokio::sync::Mutex | 다른 선택지가 없음 |
| 짧은 임계 구역 | parking_lot::Mutex | 가장 단순하고 빠름 |
| 긴 읽기 · 드문 쓰기 | RwLock | 이때만 이득 |
ArcSwap · 샤딩 으로 락을 없앨 수 있는지 먼저 보세요.
남았다면 parking_lot::Mutex 를 짧게 쓰는 것이 기본값이고,
RwLock 은 임계 구역이 길고 읽기가 압도적일 때만 이득입니다.
tokio::sync::Mutex 는 잠근 채 await 해야 할 때만 씁니다.제로카피 — 바이트를 옮기지 않고 넘기기
네트워크 서비스가 느린 이유의 상당 부분은 계산이 아니라 같은 바이트를 서너 번 복사하는 것입니다.
소켓 버퍼 → Vec<u8> → 파싱 결과 String → 다시 직렬화 버퍼.
Rust 는 소유권 덕분에 “복사 안 하고 넘기기”가 안전하게 가능한 드문 언어입니다.
bytes — 서버 코드의 사실상 표준
[dependencies]
bytes = "1"
use bytes::{Bytes, BytesMut, Buf, BufMut};
// BytesMut — 쓰는 쪽. 읽으면서 채워 나갑니다
let mut buf = BytesMut::with_capacity(8 * 1024);
sock.read_buf(&mut buf).await?; // tokio 가 남은 공간에 직접 씁니다
// split_to — 앞의 n 바이트를 '떼어' 냅니다. 복사 없음 ★
let header: BytesMut = buf.split_to(200);
let header: Bytes = header.freeze(); // 불변으로 굳히면 clone 이 공짜
// clone 은 참조 카운트만 올립니다 — Arc<Vec<u8>> 와 같은 비용
let for_log = header.clone();
tokio::spawn(async move { log_it(for_log).await });
// slice — 부분 참조 (역시 복사 없음)
let magic: Bytes = header.slice(0..4);
// Buf 트레이트 — 커서처럼 읽습니다
let mut b = header.clone();
let ver = b.get_u8();
let len = b.get_u32(); // 빅엔디안
let rest = b.copy_to_bytes(len as usize);
// BufMut — 쓰기
let mut out = BytesMut::new();
out.put_u8(1);
out.put_u32(payload.len() as u32);
out.put_slice(&payload);
Arc<Vec<u8>> 로는 부족한가 — Arc<Vec> 는 전체를 공유할 뿐
“앞의 200바이트만”을 따로 넘길 수 없습니다. Bytes 는 같은 할당 위에 여러 뷰를 만들고
각 뷰가 독립적으로 살아 있을 수 있게 합니다. hyper·axum·tonic 이 전부 이 타입을 씁니다.
Cow — “대개는 안 고치는데 가끔 고친다”
use std::borrow::Cow;
// 입력의 95%는 정상이고 5%만 정규화가 필요하다면,
// 95%는 복사 없이 그대로 돌려줍니다.
fn normalize(s: &str) -> Cow<'_, str> {
if s.chars().all(|c| c.is_ascii_lowercase() || c == '_') {
Cow::Borrowed(s) // ★ 할당 0
} else {
Cow::Owned(s.to_lowercase().replace(' ', "_"))
}
}
let a = normalize("user_id"); // Borrowed — 새 메모리 없음
let b = normalize("User Id"); // Owned — 여기서만 할당
// 함수 시그니처에서의 관용구
fn render(tpl: &str, vars: &Vars) -> Cow<'_, str> { /* … */ }
// serde 와 함께 — 역직렬화 시 입력 버퍼를 그대로 빌립니다
#[derive(serde::Deserialize)]
struct Event<'a> {
#[serde(borrow)]
name: Cow<'a, str>, // 이스케이프가 없으면 빌리고, 있으면 소유
ts: u64,
}
// serde_json::from_slice::<Event>(&buf)? ← buf 가 살아 있는 동안만 유효
.into_owned() 로 소유하게 만들거나
처음부터 Bytes 를 쓰세요. “채널 너머로 보낼 것은 소유, 함수 안에서 쓸 것은 빌림”이 기준입니다.
이렇게도 쓸 수 있다 — 복사가 일어나는 자리 찾기 🟡 중급
❌ 요청 하나에 복사 다섯 번
async fn handle(mut sock: TcpStream) -> Result<()> {
let mut v = Vec::new();
sock.read_to_end(&mut v).await?; // ① 소켓 → Vec (불가피)
let s = String::from_utf8(v.clone())?; // ② 통째 복사 ★
let parts: Vec<String> = s.split(',')
.map(|p| p.to_string()) // ③ 조각마다 할당 ★
.collect();
let joined = parts.join("|"); // ④ 또 새 String ★
let out = format!("{}\n", joined); // ⑤ 또 ★
sock.write_all(out.as_bytes()).await?;
Ok(())
}
// 1KB 요청 하나에 할당 수십 번 · 복사 4KB
// 초당 3만 요청 → 초당 120MB 를 옮기고 할당자를 백만 번 부릅니다
✅ 빌려서 쓰고 한 번만 쓴다
async fn handle(mut sock: TcpStream) -> Result<()> {
let mut buf = BytesMut::with_capacity(4096);
sock.read_buf(&mut buf).await?; // ① 불가피한 한 번
let s = std::str::from_utf8(&buf)?; // ② 검사만, 복사 없음 ★
let mut out = BytesMut::with_capacity(buf.len() + 8);
let mut first = true;
for p in s.split(',') { // ③ &str 조각 — 할당 없음 ★
if !first { out.put_u8(b'|'); }
out.put_slice(p.as_bytes()); // ④ 목적지 버퍼에 직접
first = false;
}
out.put_u8(b'\n');
sock.write_all(&out).await?; // ⑤ 한 번만
Ok(())
}
// 할당 2번 · 복사 2KB. 벤치에서 보통 3~6배 차이가 납니다.
cargo clippy -- -W clippy::redundant_clone -W clippy::inefficient_to_string 로 정적 검출,
② dhat 힙 프로파일러로 할당 횟수를 세기(h10),
③ 코드에서 .to_string() · .clone() · .to_vec() · format! 를 검색해
“이게 정말 새 소유가 필요한가”를 하나씩 묻기. 세 번째가 제일 많이 잡힙니다.
실무에선 이렇게 — mmap 으로 큰 파일 다루기 🔴 실무
memmap2 — 파일을 메모리처럼
// [dependencies] memmap2 = "0.9"
use memmap2::Mmap;
use std::fs::File;
// 20GB 로그 파일에서 특정 바이트 열만 훑기
let file = File::open("huge.log")?;
let mmap = unsafe { Mmap::map(&file)? }; // unsafe: 밖에서 파일이 바뀌면 UB
// 이제 &[u8] 처럼 씁니다 — 읽는 페이지만 실제로 메모리에 올라옵니다
let data: &[u8] = &mmap;
let count = data.iter().filter(|&&b| b == b'\n').count();
// rayon 과 조합하면 코어 수만큼 병렬로 (h09)
use rayon::prelude::*;
let count = data.par_chunks(1 << 20)
.map(|c| c.iter().filter(|&&b| b == b'\n').count())
.sum::<usize>();
// 쓰기용
use memmap2::MmapMut;
let mut m = unsafe { MmapMut::map_mut(&file)? };
m[0..4].copy_from_slice(&[1, 2, 3, 4]);
m.flush()?; // 디스크로 내려쓰기
BufReader 가 더 빠를 때가 많습니다(read-ahead 가 잘 듣습니다).
mmap 이 이기는 건 랜덤 접근 · 여러 번 읽음 · 여러 프로세스가 공유할 때입니다.
또 페이지 폴트가 블로킹이므로 async 워커에서 직접 만지면 h02 의 문제가 그대로 납니다 —
spawn_blocking 안에서 쓰세요.
직렬화 포맷 — 무엇을 고를 것인가
| 포맷 | 크기 | 속도 | 쓰는 곳 |
|---|---|---|---|
| JSON | 큼 | 느림 | 외부 API · 사람이 읽음 |
bincode | 작음 | 빠름 | 내부 서비스 간 |
postcard | 가장 작음 | 빠름 | 임베디드 · no_std (h12) |
rkyv | 중간 | 역직렬화 0 | 대용량 · mmap 캐시 |
MessagePack | 작음 | 중간 | 다언어 호환 |
| Protobuf(prost) | 작음 | 중간 | gRPC · 스키마 진화 |
| Arrow/Parquet | 작음 | 열 단위 | 분석(h08) |
// rkyv — '역직렬화가 없는' 포맷 ★
// 바이트를 그대로 구조체로 '들여다봅니다' (파싱 0)
use rkyv::{Archive, Deserialize, Serialize};
#[derive(Archive, Serialize, Deserialize)]
struct Index { ids: Vec<u64>, offsets: Vec<u32> }
// 저장
let bytes = rkyv::to_bytes::<_, 256>(&index)?;
std::fs::write("idx.rkyv", &bytes)?;
// 로드 — mmap 하고 캐스팅만 하면 끝입니다 (수 GB 도 즉시)
let mmap = unsafe { Mmap::map(&File::open("idx.rkyv")?)? };
let archived = unsafe { rkyv::archived_root::<Index>(&mmap) };
let first = archived.ids[0]; // 파싱 없이 바로 접근 ★
// 비교: 1GB 인덱스를 bincode 로 읽으면 수 초 + 1GB 힙,
// rkyv + mmap 이면 수 밀리초 + 실제 만진 페이지만 메모리
한 단계 더 — 운영체제 수준의 제로카피 🧬 전문가
sendfile · splice — 유저 공간을 아예 안 거침
파일을 소켓으로 보낼 때 실제로 일어나는 복사
평범한 read + write (복사 4번 · 컨텍스트 스위치 4번)
디스크 → 커널 페이지 캐시 → 유저 버퍼 → 커널 소켓 버퍼 → NIC
sendfile(2) (복사 2번 · 스위치 2번)
디스크 → 커널 페이지 캐시 ──────────────→ 커널 소켓 버퍼 → NIC
유저 공간을 거치지 않습니다 ★
DMA 스캐터-개더까지 지원되면 사실상 복사 0번
커널 페이지 캐시의 주소만 NIC 에 알려 주고 NIC 가 직접 읽습니다
// Rust 에서 쓰는 법 — 대부분 크레이트가 감싸 줍니다
// tokio 에는 직접 API 가 없어 nix 등을 씁니다
#[cfg(target_os = "linux")]
fn send_file(out: &TcpStream, f: &File, len: usize) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
let n = unsafe {
libc::sendfile(out.as_raw_fd(), f.as_raw_fd(),
std::ptr::null_mut(), len)
};
if n < 0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) }
}
// 실무에서는 정적 파일 서빙에 tower-http 의 ServeDir 를 쓰고,
// 그쪽이 내부적으로 최적 경로를 고르게 두는 편이 낫습니다.
벡터 I/O 와 버퍼 재사용
use tokio::io::AsyncWriteExt;
use std::io::IoSlice;
// ① writev — 흩어진 조각을 한 번의 시스템 콜로 (합치는 복사 없이)
let bufs = [
IoSlice::new(&header),
IoSlice::new(&body),
IoSlice::new(b"\r\n"),
];
sock.write_vectored(&bufs).await?;
// 헤더+본문을 format! 으로 합치던 코드를 이걸로 바꾸면 할당이 사라집니다
// ② 버퍼 풀 — 요청마다 새로 할당하지 않습니다
use std::sync::Arc;
use crossbeam::queue::ArrayQueue;
struct Pool(Arc<ArrayQueue<BytesMut>>);
impl Pool {
fn get(&self) -> BytesMut {
self.0.pop().unwrap_or_else(|| BytesMut::with_capacity(16 * 1024))
}
fn put(&self, mut b: BytesMut) {
b.clear(); // 용량은 유지, 길이만 0
let _ = self.0.push(b); // 가득 차면 그냥 버림
}
}
// ③ 그런데 먼저 재 보세요 — mimalloc 으로 바꾸는 것만으로
// 풀링과 비슷한 효과가 나는 경우가 많습니다 (h10) ★
Bytes·Cow 로 불필요한 복사 제거 →
② 할당자 교체(h10) → ③ 그래도 부족하면 버퍼 풀.
버퍼 풀부터 만들면 복잡도만 얻고 이득은 적습니다.
Bytes/BytesMut 로 다루고,
자를 때는 split_to·slice 로 참조만 나눠 가지세요.
“가끔만 고치는” 문자열은 Cow, 큰 파일은 mmap + spawn_blocking,
내부 통신은 bincode, 즉시 로드해야 하는 대용량 인덱스는 rkyv 입니다.네트워크 저수준 — TCP 직접 · 커스텀 프로토콜 · QUIC
HTTP 만 쓰는 게 아닙니다. 게임 서버 · 로그 수집기 · 메시지 브로커 · 프록시는
바이트를 직접 프레이밍합니다. Rust 는 이 층에서 특히 강합니다 —
tokio-util 의 codec 이 “바이트 스트림 → 메시지 스트림” 변환을 표준화해 두었기 때문입니다.
Framed 가 합니다.TcpListener 를 직접 — 서버의 뼈대
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let listener = TcpListener::bind("0.0.0.0:9000").await?;
let token = CancellationToken::new();
tracing::info!(addr = %listener.local_addr()?, "listening");
loop {
tokio::select! {
res = listener.accept() => {
let (sock, peer) = match res {
Ok(v) => v,
// ★ accept 실패로 루프를 끝내면 안 됩니다 (EMFILE 등)
Err(e) => { tracing::warn!(%e, "accept"); continue; }
};
sock.set_nodelay(true)?; // 작은 응답의 지연을 없앰 ★
let t = token.clone();
tokio::spawn(async move {
if let Err(e) = serve(sock, t).await {
tracing::warn!(%peer, %e, "conn");
}
});
}
_ = tokio::signal::ctrl_c() => { token.cancel(); break; }
}
}
Ok(())
}
set_nodelay(true) 를 잊지 마세요 — 기본은 Nagle 알고리즘이 켜져 있어
작은 패킷을 모아 보냅니다. 요청-응답형 프로토콜에서는 응답마다 최대 40ms 가 붙습니다.
“왜 로컬에서 40ms 씩 걸리지?”의 단골 원인입니다.
커스텀 코덱 — 길이 머리 + 본문
use tokio_util::codec::{Decoder, Encoder, Framed};
use bytes::{Bytes, BytesMut, Buf, BufMut};
pub struct LenCodec { max: usize }
impl Decoder for LenCodec {
type Item = Bytes;
type Error = std::io::Error;
fn decode(&mut self, src: &mut BytesMut)
-> Result<Option<Bytes>, Self::Error>
{
if src.len() < 4 { return Ok(None); } // ① 길이도 안 옴
let len = u32::from_be_bytes(src[..4].try_into().unwrap()) as usize;
if len > self.max { // ★ 반드시 상한 검사
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData, "frame too large"));
}
if src.len() < 4 + len {
src.reserve(4 + len - src.len()); // 미리 확보(재할당 감소)
return Ok(None); // ② 본문이 덜 옴
}
src.advance(4); // 머리 버리고
Ok(Some(src.split_to(len).freeze())) // 본문만 떼어 냄 ★
}
}
impl Encoder<Bytes> for LenCodec {
type Error = std::io::Error;
fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<(), Self::Error> {
dst.reserve(4 + item.len());
dst.put_u32(item.len() as u32);
dst.put_slice(&item);
Ok(())
}
}
LinesCodec(줄 단위) ·
LengthDelimitedCodec(위 코드와 같은 일을 설정으로) · BytesCodec(그대로 통과).
대부분은 LengthDelimitedCodec + serde 조합으로 충분합니다 —
tokio-serde 를 얹으면 구조체가 그대로 오가는 채널이 됩니다.
이렇게도 쓸 수 있다 — Framed 로 읽고 쓰기 🟡 중급
한 연결을 다루는 표준 루프
use futures::{SinkExt, StreamExt};
async fn serve(sock: TcpStream, token: CancellationToken) -> anyhow::Result<()> {
let mut framed = Framed::new(sock, LenCodec { max: 16 * 1024 * 1024 });
loop {
tokio::select! {
// ★ next() 는 취소 안전 — select! 에 넣어도 됩니다 (h03)
frame = framed.next() => {
let Some(frame) = frame else { break }; // 상대가 닫음
let req = frame?;
let resp = handle_frame(req).await?;
framed.send(resp).await?; // 인코더가 머리를 붙임
}
_ = token.cancelled() => {
framed.send(Bytes::from_static(b"BYE")).await.ok();
break;
}
// 유휴 연결 정리 — 좀비 커넥션이 파일 디스크립터를 먹지 않게
_ = tokio::time::sleep(Duration::from_secs(300)) => {
tracing::info!("idle timeout");
break;
}
}
}
Ok(())
}
// 읽기/쓰기를 다른 태스크로 나누고 싶다면
let (mut sink, mut stream) = framed.split();
tokio::spawn(async move { while let Some(f) = stream.next().await { /* … */ } });
UDP — 프레이밍이 이미 되어 있습니다
use tokio::net::UdpSocket;
let sock = UdpSocket::bind("0.0.0.0:9001").await?;
// 수신 버퍼를 키웁니다 — 기본값이면 폭주 시 커널이 조용히 버립니다 ★
// (리눅스: sysctl net.core.rmem_max, socket2 로 SO_RCVBUF 설정)
let mut buf = vec![0u8; 65_535];
loop {
let (n, peer) = sock.recv_from(&mut buf).await?;
// ★ 여기서 무거운 처리를 하면 그동안 들어온 패킷이 커널 버퍼에서 넘칩니다.
// 반드시 '받아서 채널에 넣기'만 하고 처리는 다른 태스크로 (h13)
if tx.try_send(Packet::new(&buf[..n], peer)).is_err() {
DROPPED.fetch_add(1, Ordering::Relaxed); // 버린 수를 반드시 세세요
}
}
// UDP 로 로그·메트릭을 받을 때의 현실
// · 유실은 정상입니다 — 세고, 알람을 걸고, 재전송은 하지 않습니다
// · MTU 1500 을 넘기면 IP 단편화 → 하나만 잃어도 전부 손실
// → 페이로드는 1200바이트 안쪽으로 맞추는 것이 관례입니다
recv_from 으로 돌아갑니다.
파싱·집계·저장은 전부 다른 태스크의 몫입니다(h13 에서 그대로 씁니다).
실무에선 이렇게 — QUIC(quinn)과 HTTP/3 🔴 실무
quinn — UDP 위의 신뢰성 있는 다중 스트림
// [dependencies] quinn = "0.11", rustls = "0.23", rcgen = "0.13"
use quinn::{Endpoint, ServerConfig};
let (cert, key) = generate_self_signed()?; // 실서비스는 진짜 인증서로
let mut cfg = ServerConfig::with_single_cert(vec![cert], key)?;
std::sync::Arc::get_mut(&mut cfg.transport).unwrap()
.max_concurrent_uni_streams(0_u8.into())
.keep_alive_interval(Some(Duration::from_secs(10)));
let endpoint = Endpoint::server(cfg, "0.0.0.0:4433".parse()?)?;
while let Some(incoming) = endpoint.accept().await {
tokio::spawn(async move {
let conn = incoming.await?;
// 한 연결 안에서 스트림을 여러 개 — 서로 막지 않습니다 ★
while let Ok((mut send, mut recv)) = conn.accept_bi().await {
tokio::spawn(async move {
let req = recv.read_to_end(64 * 1024).await?;
send.write_all(&handle(req)).await?;
send.finish()?;
anyhow::Ok(())
});
}
anyhow::Ok(())
});
}
HTTP/3 와 io_uring — 2026 현황
| 기술 | 크레이트 | 상태 |
|---|---|---|
| HTTP/3 서버 | h3 + quinn | 실사용 가능 · API 변동 |
| HTTP/3 클라이언트 | reqwest(feature) | 실험적 |
| QUIC | quinn · quiche | 성숙 |
| io_uring 범용 | tokio-uring | 실험적 · 정체 |
| thread-per-core | monoio · glommio | 실사용 사례 있음 |
| 저수준 uring | io-uring | 안정 · 손이 많이 감 |
// monoio — 코어마다 런타임 하나 + io_uring. Send 가 필요 없습니다
#[monoio::main(driver = "uring")]
async fn main() -> std::io::Result<()> {
let listener = monoio::net::TcpListener::bind("0.0.0.0:8080")?;
loop {
let (stream, _) = listener.accept().await?;
monoio::spawn(async move {
let buf = vec![0u8; 4096];
// ★ 버퍼의 '소유권'을 커널에 넘기고 돌려받는 방식 (완료 기반 I/O)
let (res, buf) = stream.read(buf).await;
let n = res?;
let (res, _) = stream.write_all(buf.slice(..n)).await;
res?;
std::io::Result::Ok(())
});
}
}
한 단계 더 — 소켓 옵션과 커널 튜닝 🧬 전문가
SO_REUSEPORT — 커널이 부하를 나눠 줍니다
// [dependencies] socket2 = "0.5"
use socket2::{Domain, Protocol, Socket, Type};
fn reuseport_listener(addr: std::net::SocketAddr) -> std::io::Result<TcpListener> {
let sock = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
sock.set_reuse_address(true)?;
sock.set_reuse_port(true)?; // ★ 리눅스: 같은 포트에 여러 소켓
sock.set_nonblocking(true)?;
sock.bind(&addr.into())?;
sock.listen(1024)?; // backlog — 기본 128 은 부족할 때가 있음
TcpListener::from_std(sock.into())
}
// 코어마다 스레드 + 각자 리스너 → accept 경합이 사라집니다
for core in 0..num_cpus::get() {
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all().build().unwrap();
rt.block_on(async {
let l = reuseport_listener(addr).unwrap();
loop { let (s, _) = l.accept().await.unwrap(); tokio::spawn(serve(s)); }
});
});
}
// 무중단 배포에도 씁니다 — 새 프로세스가 같은 포트를 열고 옛 프로세스를 종료
부하가 걸릴 때 먼저 보는 커널 값들
# 열 수 있는 파일(=소켓) 수 — 'Too many open files' 의 원인
ulimit -n # 보통 1024 → 서버는 65535 이상으로
# systemd 유닛: LimitNOFILE=1048576
# accept 대기줄 — 넘치면 SYN 이 조용히 버려집니다
sysctl net.core.somaxconn # 4096 권장
sysctl net.ipv4.tcp_max_syn_backlog # 8192
# TIME_WAIT 이 쌓일 때 (짧은 연결이 아주 많은 경우)
sysctl net.ipv4.tcp_tw_reuse # 1
sysctl net.ipv4.ip_local_port_range # 1024 65535
# UDP 수신 버퍼 — 로그 수집기라면 필수 (h13)
sysctl net.core.rmem_max # 16MB 정도로
# 지금 무슨 일이 일어나는지 보기
ss -s # 소켓 요약 (TIME_WAIT · ESTAB 수)
ss -lnt 'sport = :9000' # 그 포트의 Recv-Q / Send-Q ★
netstat -su | grep -i error # UDP 수신 버퍼 넘침 카운터
cat /proc/net/softnet_stat # 커널이 드롭한 패킷
ss -lnt 를 보세요 — Recv-Q 가 계속 크면
애플리케이션이 못 따라가는 것이고, Send-Q 가 크면 상대가 못 받는 것입니다.
원인이 다르면 고칠 곳도 다릅니다.
Decoder 는 “모자라면 Ok(None)” 만 지키면 되고, 반드시 프레임 크기 상한을 두세요.
set_nodelay(true) · accept 실패 시 계속 진행 · UDP 수신 태스크는 받아서 큐에 넣기만.
QUIC 은 모바일·다중 스트림에서 이득이 크고, io_uring 은 아직 특수 목적입니다.데이터 파이프라인 — Kafka · Redis · 스트림 처리
고성능 서비스는 대부분 “받아서 · 변환해서 · 어딘가에 넣는” 파이프라인입니다.
Rust 로 이걸 짜면 같은 처리량을 1/3 의 CPU 와 1/5 의 메모리로 하는 일이 흔합니다.
여기서는 Kafka 소비/생산 · Redis 비동기 풀 · Stream 조합 · 그리고
“정확히 한 번”이 실제로 무엇을 뜻하는지를 다룹니다.
rdkafka — 소비자
[dependencies]
# librdkafka 를 함께 빌드 (cmake 불필요, SSL·SASL 포함)
rdkafka = { version = "0.36", features = ["cmake-build", "ssl", "sasl"] }
use rdkafka::config::ClientConfig;
use rdkafka::consumer::{Consumer, StreamConsumer, CommitMode};
use rdkafka::Message;
let consumer: StreamConsumer = ClientConfig::new()
.set("bootstrap.servers", "kafka:9092")
.set("group.id", "svc-agg")
.set("enable.auto.commit", "false") // ★ 손으로 커밋합니다
.set("auto.offset.reset", "earliest")
.set("session.timeout.ms", "10000")
.set("max.poll.interval.ms", "300000") // 처리가 길면 늘려야 리밸런스를 안 당함
.set("fetch.min.bytes", "65536") // 배치로 받아 처리량 ↑
.create()?;
consumer.subscribe(&["logs"])?;
loop {
match consumer.recv().await { // 내부에서 배치로 가져옵니다
Err(e) => tracing::warn!(%e, "kafka"),
Ok(m) => {
let payload = m.payload().unwrap_or_default(); // &[u8] — 복사 없음
let key = m.key().map(|k| String::from_utf8_lossy(k).to_string());
process(key, payload).await?; // ← 멱등하게 짜야 함
// 매 건 커밋은 느립니다 — 보통 N건 또는 N초마다 (아래 참고)
consumer.commit_message(&m, CommitMode::Async)?;
}
}
}
max.poll.interval.ms 사고 — 한 메시지 처리가 이 시간을 넘기면
브로커가 “이 소비자 죽었다”고 판단해 파티션을 뺏습니다(리밸런스).
그러면 같은 메시지를 다른 소비자가 또 처리합니다.
무거운 처리는 spawn_blocking·별도 워커로 넘기고 poll 루프는 항상 가볍게 유지하세요(h02).
rdkafka — 생산자와 배치 커밋
use rdkafka::producer::{FutureProducer, FutureRecord};
let producer: FutureProducer = ClientConfig::new()
.set("bootstrap.servers", "kafka:9092")
.set("compression.type", "lz4") // 네트워크·저장 비용을 크게 줄임 ★
.set("linger.ms", "10") // 10ms 모아서 한 번에 (처리량 ↑)
.set("batch.size", "1048576")
.set("acks", "all") // 복제본까지 확인 (내구성)
.set("enable.idempotence", "true") // ★ 재시도해도 중복 저장 안 됨
.create()?;
// 보내기 — 응답을 기다리지 않고 계속 쌓을 수 있습니다
let rec = FutureRecord::to("events")
.key(&user_id) // ★ 같은 키 = 같은 파티션 = 순서 보장
.payload(&body);
let _ = producer.send(rec, Duration::from_secs(5)).await;
// ── 배치 커밋 패턴 (소비자 쪽) ──────────────────────────
let mut n = 0usize;
let mut last = std::time::Instant::now();
loop {
let m = consumer.recv().await?;
process(&m).await?;
n += 1;
if n >= 1000 || last.elapsed() > Duration::from_secs(5) {
consumer.commit_consumer_state(CommitMode::Async)?; // 여기까지 처리했다
n = 0; last = std::time::Instant::now();
}
}
// 커밋 주기가 곧 '재시작 시 다시 처리할 최대 건수'입니다 — 멱등성과 짝입니다
이렇게도 쓸 수 있다 — “정확히 한 번”의 진실 🟡 중급
분산 시스템에 진짜 exactly-once 는 없습니다
가능한 것은 둘뿐입니다
at-most-once 유실 가능 · 중복 없음 (커밋을 먼저)
at-least-once 유실 없음 · 중복 가능 (처리를 먼저) ★ 기본값
"exactly-once" 로 불리는 것들의 정체
① Kafka 트랜잭션 — 같은 클러스터 안에서 읽고 쓸 때만 성립
② 멱등 소비 (idempotent) — 중복이 와도 결과가 같게 만드는 것 ★ 실무 정답
③ 결과 저장소의 트랜잭션 — 오프셋과 결과를 '같은 트랜잭션'에 쓰기
즉 현실의 답은 거의 항상 ②입니다 —
"중복을 막는다"가 아니라 "중복이 와도 괜찮게 만든다"
INSERT … ON CONFLICT DO UPDATE),
② 결과가 집합·최대값처럼 “다시 해도 같은 것”이 되게 설계,
③ 처리한 메시지 id 를 Redis SETNX + TTL 로 기억.
③은 TTL 안쪽의 중복만 막는다는 걸 잊지 마세요.
오프셋과 결과를 한 트랜잭션에
// Kafka 의 오프셋 저장소를 안 쓰고, 우리 DB 에 같이 씁니다.
// → 결과와 진행 위치가 항상 일치합니다 (진짜 '한 번처럼' 보임)
let mut tx = pool.begin().await?;
for m in batch.iter() {
sqlx::query!("INSERT INTO events (id, body) VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING", // 멱등 ★
m.id, m.body)
.execute(&mut *tx).await?;
}
// 같은 트랜잭션에 오프셋을 기록
sqlx::query!("INSERT INTO kafka_offsets (topic, partition, offset)
VALUES ($1, $2, $3)
ON CONFLICT (topic, partition)
DO UPDATE SET offset = EXCLUDED.offset",
topic, part, last_offset)
.execute(&mut *tx).await?;
tx.commit().await?; // ★ 결과와 위치가 함께 확정되거나 함께 취소됩니다
// 시작할 때는 DB 의 오프셋으로 seek 합니다
let mut tpl = TopicPartitionList::new();
tpl.add_partition_offset("logs", part, Offset::Offset(saved + 1))?;
consumer.assign(&tpl)?;
실무에선 이렇게 — Redis 비동기 · 커넥션 풀 · NATS 🔴 실무
redis-rs — 풀 · 파이프라인 · 스크립트
[dependencies]
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
# 또는 풀이 필요하면
deadpool-redis = "0.18"
use redis::AsyncCommands;
// ① ConnectionManager — 자동 재연결 + 내부 멀티플렉싱 (풀이 필요 없을 때가 많음)
let client = redis::Client::open("redis://redis:6379")?;
let mut con = client.get_connection_manager().await?;
let _: () = con.set_ex("k", "v", 60).await?;
let v: Option<String> = con.get("k").await?;
// ② 파이프라인 — 왕복을 한 번으로 ★ 여기서 10배씩 납니다
let mut pipe = redis::pipe();
for (k, n) in counts.iter() {
pipe.hincr("counters", k, *n).ignore();
}
pipe.expire("counters", 3600).ignore();
let _: () = pipe.query_async(&mut con).await?;
// 1,000건을 개별 호출하면 왕복 1,000번(≈100ms), 파이프라인이면 1번(≈1ms)
// ③ 원자적 로직은 Lua 스크립트로 (여러 명령의 경합 방지)
let script = redis::Script::new(r"
local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
if cur + tonumber(ARGV[1]) > tonumber(ARGV[2]) then return 0 end
redis.call('INCRBY', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], 60)
return 1
");
let allowed: i32 = script.key("quota:u1").arg(1).arg(100)
.invoke_async(&mut con).await?; // 레이트 리밋 ★
ConnectionManager 하나 또는 코어 수 × 2 정도의 작은 풀이면 충분합니다.
느리다면 왕복 횟수를 줄이세요(파이프라인).
NATS — 가볍고 빠른 메시지 버스
// [dependencies] async-nats = "0.38"
let client = async_nats::connect("nats://nats:4222").await?;
// ① 발행/구독 — 지속성 없음, 아주 빠름 (마이크로초대)
client.publish("metrics.cpu", payload.into()).await?;
let mut sub = client.subscribe("metrics.>").await?; // 와일드카드
while let Some(msg) = sub.next().await {
handle(&msg.payload);
}
// ② 요청-응답이 내장돼 있습니다 (RPC 처럼)
let resp = client.request("svc.echo", b"ping".into()).await?;
// ③ JetStream — 지속성·재전송·중복 제거가 필요할 때
let js = async_nats::jetstream::new(client);
js.create_stream(async_nats::jetstream::stream::Config {
name: "LOGS".into(),
subjects: vec!["logs.>".into()],
max_age: std::time::Duration::from_secs(86400),
..Default::default()
}).await?;
| Kafka | NATS | Redis Stream | |
|---|---|---|---|
| 지연 | 수 ms | 수십 µs | 수백 µs |
| 보존 | 며칠~영구 | JetStream 시 | 메모리 한도 |
| 재처리 | 오프셋 되감기 | 가능 | 제한적 |
| 운영 난이도 | 높음 | 낮음 | 낮음 |
| 쓰는 곳 | 이벤트 원장 | 서비스 간 알림 | 가벼운 큐 |
한 단계 더 — Stream 을 조합해 파이프라인을 만든다 🧬 전문가
futures::Stream — 비동기판 Iterator
use futures::stream::{self, StreamExt, TryStreamExt};
// 소비자 → 파싱 → 필터 → 배치 → 저장, 을 선언적으로 잇습니다
let stream = consumer.stream() // rdkafka 가 Stream 을 줍니다
.map(|r| r.map_err(anyhow::Error::from))
.and_then(|m| async move { parse(m.payload()) }) // 파싱
.try_filter(|e| futures::future::ready(e.level >= Level::Warn))
.chunks(1000); // 1,000건씩 묶기 ★
tokio::pin!(stream);
while let Some(batch) = stream.next().await {
let rows: Vec<Event> = batch.into_iter().collect::<Result<_, _>>()?;
save(&rows).await?;
}
// 동시 처리 — 순서가 상관없으면 buffer_unordered ★
stream::iter(urls)
.map(|u| async move { fetch(u).await })
.buffer_unordered(32) // 동시에 32개까지
.for_each_concurrent(None, |r| async move { handle(r).await })
.await;
// 시간 기준 배치 — "1,000건 또는 1초 중 먼저" (tokio-stream)
use tokio_stream::StreamExt as _;
let mut chunks = tokio_stream::StreamExt::chunks_timeout(
stream, 1000, Duration::from_secs(1));
chunks_timeout 이 실무의 핵심 — 건수만으로 묶으면
트래픽이 적을 때 데이터가 영원히 안 나갑니다. 배치 저장은 언제나
“N건 또는 T초 중 먼저 오는 것”이어야 합니다. h13 에서 그대로 씁니다.
async-stream — 내가 만드는 Stream
// [dependencies] async-stream = "0.3"
use async_stream::try_stream;
use futures::Stream;
// 페이지네이션 API 를 하나의 스트림처럼 보이게 만들기
fn all_users(client: Client) -> impl Stream<Item = anyhow::Result<User>> {
try_stream! {
let mut cursor: Option<String> = None;
loop {
let page = client.list_users(cursor.clone()).await?;
for u in page.items {
yield u; // ★ 제너레이터처럼
}
match page.next_cursor {
Some(c) => cursor = Some(c),
None => break,
}
}
}
}
// 쓰는 쪽은 그냥 스트림입니다 — 페이지 개념이 사라집니다
let mut s = std::pin::pin!(all_users(client));
while let Some(u) = s.next().await {
println!("{}", u?.name);
}
// 파일을 줄 스트림으로
use tokio::io::AsyncBufReadExt;
let f = tokio::fs::File::open("app.log").await?;
let mut lines = tokio::io::BufReader::new(f).lines();
while let Some(line) = lines.next_line().await? { /* … */ }
Vec 에 모아서 반환”하면 100만 건에서 죽지만,
스트림이면 한 건씩 흘러가 메모리가 상수입니다.
여기에 chunks 를 얹으면 배치 처리까지 자연스럽게 붙습니다.
poll 루프는 항상 가볍게 유지해야
리밸런스에 안 물립니다. exactly-once 를 좇지 말고 at-least-once + 멱등 처리로 설계하세요 —
가장 튼튼한 방법은 결과와 오프셋을 같은 DB 트랜잭션에 쓰는 것입니다.
Redis 는 커넥션을 늘리지 말고 왕복을 줄이고(파이프라인),
배치는 언제나 “N건 또는 T초”로 묶습니다.데이터 엔진 — Polars · DataFusion · Arrow
Rust 가 파이썬 세계를 가장 크게 흔든 곳이 여기입니다. Polars(DataFrame)와 DataFusion(SQL 엔진)은 둘 다 Rust 로 쓰였고, Apache Arrow 라는 공통 메모리 형식 위에 서 있습니다. 이 섹션은 “왜 Pandas 보다 빠른가”를 구조로 설명하고 실제로 돌려 봅니다.
Polars — Rust 에서 직접 쓰기
[dependencies]
polars = { version = "0.44", features = [
"lazy", "parquet", "csv", "dtype-datetime", "strings", "temporal"
] }
use polars::prelude::*;
// ★ LazyFrame — 바로 읽지 않고 '계획'만 세웁니다
let df = LazyCsvReader::new("events.csv")
.with_has_header(true)
.finish()?
.filter(col("level").eq(lit("ERROR")))
.group_by([col("service"), col("ts").dt().truncate(lit("1m"))])
.agg([
col("latency_ms").mean().alias("avg_ms"),
col("latency_ms").quantile(lit(0.99), QuantileMethod::Nearest)
.alias("p99_ms"),
len().alias("count"),
])
.sort(["count"], SortMultipleOptions::default().with_order_descending(true))
.limit(20)
.collect()?; // ★ 여기서야 실제로 읽고 계산합니다
println!("{df}");
// 최적화가 실제로 무엇을 했는지 보기
println!("{}", lazy_plan.explain(true)?);
// → predicate pushdown : filter 를 파일 읽기 단계로 밀어 넣음
// projection pushdown : 쓰는 열만 읽음 (나머지는 디스크에서 안 읽음) ★
// 이 둘이 대부분의 속도 차이를 만듭니다
scan_* 를 쓰세요 — read_csv/read_parquet 는 전부 읽지만
scan_csv/scan_parquet 는 계획을 보고 필요한 부분만 읽습니다.
100열짜리 Parquet 에서 3열만 쓰면 디스크 I/O 가 30분의 1 이 됩니다.
Pandas 대비 — 왜 차이가 나나
| 항목 | Pandas | Polars |
|---|---|---|
| 메모리 형식 | NumPy(열)+객체 | Arrow |
| 문자열 | 파이썬 객체 | 연속 바이트 |
| 병렬 | 기본 1코어 | 전 코어 |
| 실행 | 즉시 | 지연 + 최적화 |
| 결측 | NaN 혼란 | 비트맵으로 명확 |
| 메모리 사용 | 기준 | 1/2 ~ 1/5 |
| 큰 파일 | 메모리 초과 | 스트리밍 가능 |
흔히 인용되는 감각적 수치 (데이터·질의에 따라 크게 다릅니다)
group_by + 집계 (1억 행) Pandas 대비 10~30배
CSV 읽기 5~20배
조인 (수천만 행) 5~15배
문자열 연산 10~50배 ★ 차이가 가장 큼
메모리 사용량 1/2~1/5
※ 반드시 본인 데이터로 재세요 (h11).
"10배"는 벤치 데이터의 성질에 크게 좌우됩니다.
행이 수만 개면 차이가 거의 없거나 Pandas 가 빠를 수도 있습니다.
이렇게도 쓸 수 있다 — Parquet 로 쓰고 스트리밍으로 처리 🟡 중급
Parquet 쓰기 — h13 에서 그대로 씁니다
use polars::prelude::*;
use std::fs::File;
// 배치로 모은 데이터를 DataFrame 으로
let mut df = df![
"ts" => ts_vec, // Vec<i64>
"service" => svc_vec, // Vec<String>
"level" => lvl_vec,
"latency" => lat_vec, // Vec<u32>
]?;
// 파티션 경로 규칙 — 조회할 때 폴더 단위로 건너뛸 수 있습니다 ★
let path = format!("data/dt={}/hour={:02}/part-{}.parquet", date, hour, uuid);
std::fs::create_dir_all(std::path::Path::new(&path).parent().unwrap())?;
ParquetWriter::new(File::create(&path)?)
.with_compression(ParquetCompression::Zstd(None)) // lz4 는 빠르고 zstd 는 작음
.with_statistics(StatisticsOptions::full()) // min/max → 건너뛰기 가능 ★
.with_row_group_size(Some(128 * 1024))
.finish(&mut df)?;
// 읽을 때 — 여러 파일을 한 번에 (글롭)
let out = LazyFrame::scan_parquet("data/dt=2026-08-25/**/*.parquet",
ScanArgsParquet::default())?
.filter(col("service").eq(lit("api")))
.group_by([col("level")]).agg([len()])
.collect()?;
DataFusion — 내 데이터에 SQL 을 얹기
// [dependencies] datafusion = "43"
use datafusion::prelude::*;
let ctx = SessionContext::new();
// ① 파일을 테이블로 등록
ctx.register_parquet("logs", "data/**/*.parquet",
ParquetReadOptions::default()).await?;
// ② 그냥 SQL 을 씁니다
let df = ctx.sql("
SELECT service,
COUNT(*) AS n,
APPROX_PERCENTILE_CONT(latency, 0.99) AS p99
FROM logs
WHERE level = 'ERROR' AND ts >= 1756000000
GROUP BY service
ORDER BY n DESC
LIMIT 10
").await?;
df.show().await?;
// ③ 내 함수를 SQL 에서 부르게 만들기 (UDF)
use datafusion::logical_expr::{create_udf, Volatility};
let mask = create_udf("mask_ip", vec![DataType::Utf8], DataType::Utf8,
Volatility::Immutable, Arc::new(mask_impl));
ctx.register_udf(mask);
ctx.sql("SELECT mask_ip(client_ip) FROM logs LIMIT 5").await?.show().await?;
// ④ 결과를 스트림으로 받기 — 메모리 상수 유지
let mut stream = df.execute_stream().await?;
while let Some(batch) = stream.next().await {
let batch = batch?; // RecordBatch (Arrow)
handle(batch);
}
실무에선 이렇게 — 파이썬과 함께 쓰기 · 벡터 검색 🔴 실무
Arrow 로 파이썬과 복사 없이 주고받기
# 파이썬 쪽 — Polars 는 pyo3 로 감싼 이 Rust 엔진 그대로입니다
import polars as pl
df = (pl.scan_parquet("data/**/*.parquet")
.filter(pl.col("level") == "ERROR")
.group_by("service")
.agg(pl.len().alias("n"), pl.col("latency").quantile(0.99))
.sort("n", descending=True)
.collect()) # ← 여기서 Rust 코드가 전 코어로 돕니다
# Pandas 로 넘길 때도 Arrow 를 거치면 복사가 거의 없습니다
pdf = df.to_pandas(use_pyarrow_extension_array=True)
# 반대로: 내 Rust 함수를 파이썬에서 부르고 Arrow 로 결과 받기 (🐍 y03)
# pyo3 + arrow-rs → RecordBatch 를 그대로 넘기면 직렬화가 없습니다
col("x").mean())도 거의 그대로 옮겨집니다.
임베딩 벡터 검색 — HNSW
// 선택지
// hnsw_rs 순수 Rust HNSW · 가볍고 임베드하기 좋음
// instant-distance HNSW · API 가 단순
// qdrant / lancedb DB 로 통째로 (둘 다 Rust 로 쓰였습니다)
// usearch C++ 코어 + Rust 바인딩 · 매우 빠름
// [dependencies] hnsw_rs = "0.3"
use hnsw_rs::prelude::*;
let nb_elem = 1_000_000;
let hnsw = Hnsw::<f32, DistCosine>::new(
24, // max_nb_connection (M) — 크면 정확·느림·메모리↑
nb_elem,
16, // 층 수
200, // ef_construction — 크면 색인 품질↑ 색인 속도↓
DistCosine {},
);
// 색인 — 병렬로 넣을 수 있습니다 (내부적으로 rayon)
let data: Vec<(&Vec<f32>, usize)> = embeddings.iter().zip(0..).collect();
hnsw.parallel_insert(&data);
// 검색 — ef_search 가 정확도/속도 손잡이
hnsw.set_searching_mode(true);
let found = hnsw.search(&query_vec, 10, 64); // top-10, ef=64
for n in found {
println!("id={} dist={:.4}", n.d_id, n.distance);
}
// 100만 벡터 × 768차원 = 약 3GB (f32). 메모리가 부담이면
// · f16 또는 int8 양자화로 절반~1/4
// · 디스크 기반이 필요하면 lancedb / qdrant 로
한 단계 더 — 언제 이 도구들이 손해인가 🧬 전문가
❌ 이럴 땐 오히려 느립니다
· 행이 수천~수만 개
프레임 구성 · 스레드 분배 오버헤드가 계산보다 큽니다.
평범한 Vec + 반복문이 더 빠릅니다.
· 행 단위 임의 접근이 많은 작업
"id 로 한 건 찾아 한 필드 고치기" → 열 저장의 최악 패턴.
그건 DB(또는 HashMap)가 할 일입니다.
· 매번 다른 스키마의 작은 JSON
파싱 비용이 지배적이라 엔진을 바꿔도 그대로입니다.
· 이미 DB 가 인덱스로 잘 처리하는 질의
Parquet 전체 스캔보다 인덱스 조회가 압도적으로 빠릅니다.
✅ 확실히 이기는 자리
· 수백만~수십억 행의 집계 · 조인 · 정렬
· 로그·이벤트를 하루 단위로 말아 리포트 만들기 ★ h13
· 파이썬 배치 잡이 메모리 초과로 죽는 상황
→ scan_parquet + streaming 으로 상수 메모리
· 컨테이너 메모리 한도가 빡빡한 환경
Pandas 8GB → Polars 1.5GB 같은 사례가 흔합니다
· 같은 로직을 파이썬과 서비스에서 함께 써야 할 때
표현식이 거의 동일해 이식 비용이 낮습니다
측정 없이 도입하지 마세요 — 이 표의 위아래가 뒤집히는 데이터도 있습니다.
scan_* 를 써서
필요한 열·행만 읽게 하세요. 작은 데이터에서는 오히려 손해이니
본인 데이터로 재고 도입하세요.CLI · 시스템 도구 — ripgrep 이 빠른 이유를 내 도구에
Rust 가 가장 조용히, 그러나 가장 널리 퍼진 곳은 커맨드라인 도구입니다.
ripgrep · fd · bat · eza · zoxide · uv · ruff — 전부 Rust 입니다.
이 섹션은 그들이 빠른 구체적인 이유와, 같은 기법으로 내 도구를 만드는 법을 다룹니다.
clap — 서브커맨드까지 derive 로
[dependencies]
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
clap_complete = "4"
use clap::{Parser, Subcommand, ValueEnum, ArgAction};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "logtool", version, about = "로그를 훑고 집계합니다")]
struct Cli {
/// 자세히 (-v, -vv, -vvv)
#[arg(short, long, action = ArgAction::Count, global = true)]
verbose: u8,
/// 설정 파일 — 환경변수 LOGTOOL_CONFIG 로도 받습니다
#[arg(short, long, env = "LOGTOOL_CONFIG", global = true)]
config: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// 패턴을 찾습니다
Grep {
pattern: String,
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
/// 동시 처리 수 (기본: 코어 수)
#[arg(short = 'j', long)]
jobs: Option<usize>,
#[arg(long, value_enum, default_value_t = Fmt::Text)]
format: Fmt,
},
/// 집계합니다
Agg {
#[arg(long)] since: Option<String>,
#[arg(long, num_args = 1.., value_delimiter = ',')]
group_by: Vec<String>,
},
}
#[derive(Copy, Clone, ValueEnum)]
enum Fmt { Text, Json, Csv }
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::Grep { pattern, paths, jobs, format } => grep(&pattern, &paths, jobs, format),
Cmd::Agg { since, group_by } => agg(since, &group_by),
}
}
clap_complete::generate(Shell::Bash, &mut Cli::command(), "logtool", &mut io::stdout())
한 줄이면 bash·zsh·fish·PowerShell 스크립트가 나옵니다.
--help 도 doc 주석이 그대로 설명이 됩니다.
병렬 파일 처리 — ignore + rayon
// [dependencies] ignore = "0.4" rayon = "1" memchr = "2" grep-regex/regex = "1"
use ignore::WalkBuilder;
use std::sync::mpsc;
fn grep(pattern: &str, paths: &[PathBuf], jobs: Option<usize>) -> anyhow::Result<()> {
let re = regex::bytes::Regex::new(pattern)?; // ★ bytes 버전 — UTF-8 검증 생략
let (tx, rx) = mpsc::channel::<String>();
let n = jobs.unwrap_or_else(|| std::thread::available_parallelism()
.map(|v| v.get()).unwrap_or(4));
// 출력 전담 스레드 하나 — 여기서만 stdout 을 잠급니다
let printer = std::thread::spawn(move || {
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock()); // ★ 버퍼링 필수
use std::io::Write;
for line in rx { let _ = writeln!(out, "{line}"); }
let _ = out.flush();
});
// 병렬 순회 — .gitignore · 숨김 파일 규칙을 알아서 지킵니다
WalkBuilder::new(&paths[0])
.threads(n)
.hidden(true).git_ignore(true).git_global(true)
.build_parallel()
.run(|| {
let re = re.clone();
let tx = tx.clone();
Box::new(move |res| {
let Ok(entry) = res else { return ignore::WalkState::Continue };
if !entry.file_type().is_some_and(|t| t.is_file()) {
return ignore::WalkState::Continue;
}
if let Ok(buf) = std::fs::read(entry.path()) {
for m in re.find_iter(&buf) {
let _ = tx.send(format!("{}:{}", entry.path().display(), m.start()));
}
}
ignore::WalkState::Continue
})
});
drop(tx); // ★ 이걸 빠뜨리면 printer 가 영원히 안 끝납니다
printer.join().unwrap();
Ok(())
}
이렇게도 쓸 수 있다 — 진행바 · 출력 · 색 🟡 중급
indicatif — 진행바와 다중 막대
// [dependencies] indicatif = { version = "0.17", features = ["rayon"] }
use indicatif::{ProgressBar, ProgressStyle, MultiProgress, ParallelProgressIterator};
use rayon::prelude::*;
let files: Vec<PathBuf> = collect_files();
let pb = ProgressBar::new(files.len() as u64);
pb.set_style(ProgressStyle::with_template(
"{spinner} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}")?
.progress_chars("=>-"));
// rayon 과 그대로 결합 — 스레드 안전합니다
let results: Vec<_> = files.par_iter()
.progress_with(pb.clone())
.map(|f| process(f))
.collect();
pb.finish_with_message("완료");
// 여러 줄을 동시에 (다운로드 여러 개 등)
let m = MultiProgress::new();
let a = m.add(ProgressBar::new(100));
let b = m.add(ProgressBar::new(100));
// ★ 파이프로 넘길 때는 진행바를 끕니다 (터미널이 아니면 쓰레기 문자가 됩니다)
if !std::io::IsTerminal::is_terminal(&std::io::stderr()) {
pb.set_draw_target(indicatif::ProgressDrawTarget::hidden());
}
stdout 은 파이프로 넘길 데이터의 자리입니다.
indicatif 는 기본이 stderr 라 맞게 되어 있지만, 직접 찍는 로그도 같은 원칙을 지키세요.
mytool | grep x 가 깨지면 대개 이 실수입니다.
출력이 느린 진짜 이유 — 버퍼링
use std::io::{self, Write, BufWriter};
// ❌ println! 은 매번 stdout 을 잠그고 줄 단위로 flush 합니다
for line in &lines {
println!("{line}"); // 100만 줄이면 시스템 콜 100만 번
}
// ✅ 락을 한 번만 잡고 버퍼에 모읍니다 — 보통 10~30배 빨라집니다 ★
let stdout = io::stdout();
let mut out = BufWriter::with_capacity(64 * 1024, stdout.lock());
for line in &lines {
writeln!(out, "{line}")?;
}
out.flush()?; // ★ 잊으면 마지막 부분이 안 나옵니다
// 색 — 터미널일 때만 켭니다
use anstream::println as cprintln; // anstyle/anstream 이 자동 판정
// 또는 owo-colors 의 if_supports_color
// 에러 메시지를 사람이 읽게 (anyhow + color-eyre)
fn main() -> anyhow::Result<()> {
color_eyre::install()?;
run().context("로그 처리 실패")?; // 원인 체인이 예쁘게 출력됩니다
Ok(())
}
println! 은 수 초,
BufWriter 는 수십 밀리초입니다.
“내 Rust CLI 가 파이썬보다 느리다”는 신고의 상당수가 이것 하나였습니다.
실무에선 이렇게 — 배포 — 바이너리 하나로 끝내기 🔴 실무
cargo-dist — 릴리스를 자동화
# 설치 후 한 번만 초기화하면 GitHub Actions 워크플로가 생성됩니다
cargo install cargo-dist
cargo dist init # 타깃 · 설치 스크립트 · 릴리스 방식을 물어봅니다
cargo dist build # 로컬에서 확인
cargo dist plan # 무엇이 만들어질지 미리 보기
# 태그를 밀면 나머지는 CI 가 합니다
git tag v0.3.0 && git push --tags
# → 리눅스(x86_64/aarch64 · musl) · macOS(x86_64/arm64) · Windows 빌드
# → tar.gz / zip / .msi 패키징
# → GitHub Release 업로드 + 체크섬
# → 한 줄 설치 스크립트 생성
# curl --proto '=https' -LsSf https://…/logtool-installer.sh | sh
# → homebrew tap · npm 래퍼까지 옵션으로
# Cargo.toml — 배포용 릴리스 프로필 (h11 에서 자세히)
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = "symbols" # 심볼 제거 — 바이너리가 절반 이하로
panic = "abort" # CLI 라면 무난합니다
# 정적 링크 — 어느 리눅스에서든 그냥 도는 바이너리
# rustup target add x86_64-unknown-linux-musl
# cargo build --release --target x86_64-unknown-linux-musl
시작 시간까지 챙기기
CLI 는 '시작 시간'이 곧 체감 속도입니다
파이썬 도구 50~300ms (인터프리터 + import)
Node 도구 80~200ms (V8 부팅)
Java 도구 200~800ms (JVM 부팅)
Rust 바이너리 1~5ms ★ 이게 uv·ruff 가 이긴 진짜 이유
Rust 에서도 느려지는 원인
· 큰 static 테이블을 시작할 때 초기화 → LazyLock 으로 미루기
· 설정 파일·네트워크를 무조건 읽기 → 필요할 때만
· 정규식을 매번 컴파일 → LazyLock<Regex> 로 한 번만 ★
· 디버그 빌드로 재기 → 반드시 --release
use std::sync::LazyLock;
use regex::Regex;
// ❌ 함수가 불릴 때마다 정규식 컴파일 (수백 µs)
fn is_id(s: &str) -> bool { Regex::new(r"^[a-z]+$").unwrap().is_match(s) }
// ✅ 프로그램당 한 번만 (1.80+ 표준. 이전엔 once_cell::sync::Lazy)
static ID: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-z]+$").unwrap());
fn is_id2(s: &str) -> bool { ID.is_match(s) }
// 실행 시간을 직접 재기
// hyperfine 'logtool grep foo .' 'rg foo .' ← 통계까지 내 줍니다
한 단계 더 — memchr 과 “먼저 거르기” 🧬 전문가
SIMD 문자열 탐색 — 직접 써 보기
// [dependencies] memchr = "2"
use memchr::{memchr, memchr_iter, memmem};
let hay: &[u8] = &buf;
// ① 바이트 하나 찾기 — 한 번에 16~64바이트씩 비교합니다
if let Some(pos) = memchr(b'\n', hay) { /* … */ }
// ② 줄 수 세기 — naive 반복문보다 5~20배
let lines = memchr_iter(b'\n', hay).count();
// ③ 부분 문자열 — 자동으로 좋은 알고리즘을 고릅니다
let finder = memmem::Finder::new(b"ERROR"); // ★ 미리 만들어 재사용
for pos in finder.find_iter(hay) {
let start = memchr::memrchr(b'\n', &hay[..pos]).map_or(0, |i| i + 1);
let end = memchr(b'\n', &hay[pos..]).map_or(hay.len(), |i| pos + i);
println!("{}", String::from_utf8_lossy(&hay[start..end]));
}
// ④ 정규식도 내부에서 이걸 씁니다 —
// regex 크레이트는 패턴에서 '반드시 나오는 리터럴'을 뽑아
// memmem 으로 후보를 먼저 찾고, 그 근처에서만 오토마타를 돌립니다.
// 그래서 r"ERROR.*timeout" 같은 패턴이 아주 빠릅니다.
줄을 자르지 않고 처리하기
use std::io::{BufRead, BufReader};
// ❌ lines() 는 줄마다 String 을 새로 할당합니다
for line in BufReader::new(f).lines() {
let line = line?; // 할당 1회 · UTF-8 검증
if line.contains("ERROR") { n += 1; }
}
// ✅ 버퍼 하나를 재사용 (할당 1회로 끝)
let mut r = BufReader::with_capacity(256 * 1024, f);
let mut buf = String::with_capacity(4096);
loop {
buf.clear(); // ★ 용량은 유지
if r.read_line(&mut buf)? == 0 { break; }
if buf.contains("ERROR") { n += 1; }
}
// ✅✅ 아예 바이트로 — UTF-8 검증도 건너뜁니다
let mut r = BufReader::with_capacity(256 * 1024, f);
let mut buf = Vec::with_capacity(4096);
loop {
buf.clear();
if r.read_until(b'\n', &mut buf)? == 0 { break; }
if memmem::find(&buf, b"ERROR").is_some() { n += 1; }
}
// 1GB 로그에서 세 방식이 대략 8초 / 2초 / 0.7초 정도로 갈립니다
lines() · to_string() · collect::<Vec<String>>() 가 루프 안에 있으면
버퍼 재사용으로 바꿀 수 있는지 먼저 보세요(h05·h10).
memchr 로 먼저 거르기
④ 출력은 BufWriter 로 모으기 네 가지에서 나옵니다.
인터페이스는 clap derive, 진행바는 indicatif(stderr), 배포는 cargo-dist + musl 정적 링크.
시작 시간이 1~5ms 라는 점이 도구 시장에서 Rust 가 이긴 이유입니다.메모리 · 할당 — 할당자를 바꾸고 아레나를 쓴다
서버가 느릴 때 코드는 그대로 두고 한 줄로 10~30% 를 얻는 방법이 실제로 있습니다 — 할당자 교체입니다.
여기에 아레나 · SmallVec · Box<[T]> 를 얹으면 할당 횟수 자체가 줄고,
dhat 으로 어디서 얼마나 잡는지 숫자로 볼 수 있습니다.
할당자 교체 — 한 줄로 끝납니다 ★
[dependencies]
mimalloc = { version = "0.1", default-features = false }
# 또는
tikv-jemallocator = "0.6"
// src/main.rs — 이 세 줄이 전부입니다
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
// jemalloc 이라면
// #[global_allocator]
// static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
fn main() { /* 나머지 코드는 그대로 */ }
| 할당자 | 강점 | 고르는 기준 |
|---|---|---|
| 시스템 기본 | 추가 의존성 0 | 리눅스 glibc 는 멀티스레드에 약함 |
| mimalloc | 작고 빠름 · 전 플랫폼 | 기본 추천 — 윈도우에서 특히 큼 |
| jemalloc | 단편화 제어 · 프로파일 내장 | 장시간 구동 서버 · 메모리 분석 필요 |
| snmalloc | 메시지 패싱형에 강함 | 스레드 간 할당/해제가 엇갈릴 때 |
bumpalo — 요청 단위 아레나
// [dependencies] bumpalo = { version = "3", features = ["collections"] }
use bumpalo::Bump;
use bumpalo::collections::{Vec as BVec, String as BString};
// 요청 하나를 처리하는 동안만 사는 임시 객체들
fn handle(input: &str) -> Summary {
let bump = Bump::with_capacity(64 * 1024); // 미리 한 덩어리
let mut tokens: BVec<&str> = BVec::new_in(&bump);
for t in input.split_whitespace() {
tokens.push(t); // 힙 할당 없음
}
let node = bump.alloc(Node { kind: Kind::Root, children: BVec::new_in(&bump) });
// … 파싱 트리를 아레나 안에 만듭니다 …
let s = summarize(node); // 결과만 소유로 꺼냅니다
s
} // ★ 여기서 bump 가 drop → 1,000개가 한 번에 사라짐 (Drop 재귀 없음)
// 반복 사용 — 매 요청마다 새로 잡지 않고 재사용합니다
let mut bump = Bump::new();
loop {
let req = rx.recv().await.unwrap();
handle_in(&bump, req);
bump.reset(); // ★ 메모리는 유지, 포인터만 처음으로 (할당 0회)
}
Drop 이 안 불립니다(bumpalo 기본).
그래서 File·Vec 처럼 해제가 필요한 것을 넣으면 누수가 됩니다.
넣어도 되는 건 순수 데이터(POD 성격)뿐이라고 생각하세요.
또 수명('bump)이 전파되므로 구조체 정의에 수명 파라미터가 번집니다.
이렇게도 쓸 수 있다 — 할당을 아예 안 하는 자료구조 🟡 중급
SmallVec · ArrayVec · Box<[T]>
// [dependencies] smallvec = "1" arrayvec = "0.7" compact_str = "0.8"
use smallvec::{SmallVec, smallvec};
// ① SmallVec — N개까지는 스택에, 넘으면 힙으로 자동 전환
// "거의 항상 3개 이하인데 가끔 100개" 인 경우에 딱 맞습니다
let mut tags: SmallVec<[u32; 4]> = smallvec![1, 2];
tags.push(3); // 여기까지 힙 할당 0
tags.push(4);
tags.push(5); // 여기서 처음 힙으로 이동
// ② ArrayVec — 상한이 확실하면. 넘으면 에러(힙으로 안 갑니다) → no_std 가능
use arrayvec::ArrayVec;
let mut buf: ArrayVec<u8, 64> = ArrayVec::new();
buf.try_push(1).ok();
// ③ Box<[T]> — 더 안 늘릴 Vec 은 여분 용량을 반납합니다
let v: Vec<u64> = (0..1000).collect(); // capacity 가 1024 일 수 있음
let fixed: Box<[u64]> = v.into_boxed_slice(); // 정확히 1000개분만 ★
// 포인터 크기도 Vec(24B) → Box<[T]>(16B) 로 줄어듭니다
// ④ CompactString — 24바이트 안에 들어가는 문자열은 힙을 안 씁니다
use compact_str::CompactString;
let s = CompactString::new("service-api"); // 힙 할당 0 (24바이트 이하)
SmallVec<[T; N]>,
절대 N 을 안 넘으면 ArrayVec, 한 번 만들고 안 바꾸면 Box<[T]>.
아무 근거 없이 SmallVec 을 쓰면 구조체만 커지고 손해입니다.
용량을 미리 잡는 것만으로
// ❌ 재할당이 log2(n) 번 일어나고 그때마다 전체 복사
let mut v = Vec::new();
for i in 0..1_000_000 { v.push(i); } // 확장 20번 · 누적 복사 약 2백만 원소
// ✅ 한 번만 잡습니다
let mut v = Vec::with_capacity(1_000_000);
for i in 0..1_000_000 { v.push(i); } // 확장 0번
// ✅✅ 크기를 안다면 이터레이터로 — 내부에서 알아서 잡습니다
let v: Vec<_> = (0..1_000_000).collect(); // size_hint 를 보고 한 번에
// 문자열도 같습니다
let mut s = String::with_capacity(estimated);
for part in parts { s.push_str(part); } // format! 반복보다 훨씬 빠름
// HashMap 도
let mut m = HashMap::with_capacity_and_hasher(10_000, FxBuildHasher::default());
// ★ 기본 해셔(SipHash)는 DoS 방어용이라 느립니다.
// 외부 입력이 아닌 내부 키라면 rustc-hash 의 FxHashMap 이 2~5배 빠릅니다.
use rustc_hash::FxHashMap;
let mut m: FxHashMap<u64, u32> = FxHashMap::default();
FxHashMap 주의 — 해시 충돌을 의도적으로 만들 수 있어
사용자 입력을 키로 쓰면 HashDoS 위험이 있습니다.
내부 id · 정수 키에만 쓰고, 외부 문자열 키에는 기본 해셔나 ahash 를 쓰세요.
실무에선 이렇게 — 힙 프로파일링과 누수 추적 🔴 실무
dhat — 어디서 얼마나 잡는지 숫자로
[dependencies]
dhat = { version = "0.3", optional = true }
[features]
dhat-heap = ["dep:dhat"]
[profile.release]
debug = 1 # ★ 프로파일에 함수 이름이 나오게 (성능 영향 없음)
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn main() {
#[cfg(feature = "dhat-heap")]
let _profiler = dhat::Profiler::new_heap(); // drop 될 때 리포트 저장
run();
} // → dhat-heap.json 생성 → https://nnethercote.github.io/dh_view/dh_view.html 에 올려 봄
cargo run --release --features dhat-heap
# 리포트에서 보는 것 세 가지
# ① Total blocks — 할당 '횟수' ★ 이게 크면 루프 안에 할당이 있는 것
# ② At t-gmax — 최대 사용 시점의 크기 (OOM 원인)
# ③ At t-end — 끝났는데 남아 있는 것 = 누수 후보 ★
# 대안
valgrind --tool=massif ./target/release/svc # 시간에 따른 힙 그래프
heaptrack ./target/release/svc # GUI · 스택별 집계 (리눅스)
# jemalloc 을 쓰면 내장 프로파일러
MALLOC_CONF=prof:true,lg_prof_interval:30 ./svc
Arc 순환 — Rust 에서 나는 “누수”
use std::sync::{Arc, Weak, Mutex};
// ❌ 부모 → 자식 → 부모 를 전부 Arc 로 하면 카운트가 0이 되지 않습니다
struct Node {
parent: Option<Arc<Node>>, // ★ 여기가 문제
children: Mutex<Vec<Arc<Node>>>,
}
// 부모가 자식을 1 잡고, 자식이 부모를 1 잡음 → 서로 붙잡고 안 죽음
// ✅ 한쪽을 Weak 로 — '소유하지 않는 참조'
struct Node2 {
parent: Option<Weak<Node2>>, // 카운트를 올리지 않습니다
children: Mutex<Vec<Arc<Node2>>>,
}
// 쓸 때 승격
if let Some(p) = node.parent.as_ref().and_then(|w| w.upgrade()) { /* … */ }
// 규칙: '소유 방향'을 정하고 반대 방향은 전부 Weak
// 부모가 자식을 소유 · 자식은 부모를 Weak
// 캐시가 값을 소유 · 값이 캐시를 가리키면 Weak
// 옵저버 목록은 Weak (구독자가 사라지면 자동으로 무효)
// 살아 있는 개수 확인 — 누수 감시에 유용합니다
println!("strong={} weak={}", Arc::strong_count(&a), Arc::weak_count(&a));
num_alive_tasks 로 감지),
② 상한 없는 캐시(DashMap 에 넣기만 함 → moka 로),
③ 구독자 목록에서 안 지운 항목,
④ Box::leak 을 루프에서 호출. 넷 다 Arc 순환보다 훨씬 흔합니다.
한 단계 더 — RSS 가 안 내려가는 이유 🧬 전문가
“메모리를 풀었는데 왜 그대로인가”
Rust 가 free() 했다고 OS 에 돌아가는 게 아닙니다
① 할당자가 붙잡고 있습니다
다음 요청에 다시 쓰려고 캐시로 들고 있습니다 — 정상 동작입니다.
② 단편화
128KB 덩어리 하나가 남아 있으면 그 주변 페이지를 못 돌려줍니다.
요청마다 크기가 들쭉날쭉하면 특히 심합니다.
③ glibc 는 특히 잘 안 돌려줍니다
malloc_trim(0) 을 호출해야 일부가 반환됩니다.
대책
· jemalloc : MALLOC_CONF=dirty_decay_ms:5000,muzzy_decay_ms:5000
· mimalloc : 기본적으로 비교적 잘 반환합니다
· 큰 버퍼는 크기를 '표준화' 해서 재사용 (버퍼 풀 · h05)
· 컨테이너 메모리 한도는 '최대 사용량' 기준으로 잡을 것
(평균 기준으로 잡으면 OOMKilled 를 반복합니다)
메모리 사용량을 직접 재기
// ① 프로세스 RSS — 리눅스
fn rss_bytes() -> Option<u64> {
let s = std::fs::read_to_string("/proc/self/statm").ok()?;
let pages: u64 = s.split_whitespace().nth(1)?.parse().ok()?;
Some(pages * 4096)
}
// ② 컨테이너 한도 안에서 얼마나 쓰는지 (cgroup v2)
// /sys/fs/cgroup/memory.current · memory.max
// ★ 쿠버네티스 OOMKilled 는 이 값 기준입니다 — /proc/meminfo 가 아닙니다
// ③ 타입 하나의 크기
println!("{}", std::mem::size_of::<MyStruct>());
// 필드 순서를 바꾸면 패딩이 줄어드는 경우가 많습니다 (⚡ e04)
// cargo install cargo-show-asm / top-type-sizes
// RUSTFLAGS="-Zprint-type-sizes" cargo +nightly build ← 큰 타입 찾기 ★
// ④ 지표로 노출 (프로메테우스)
metrics::gauge!("process_rss_bytes").set(rss_bytes().unwrap_or(0) as f64);
#[global_allocator] 로 mimalloc 을 얹는 것입니다(한 줄).
그다음이 용량 미리 잡기 · SmallVec · 버퍼 재사용으로 할당 횟수를 줄이는 것이고,
“요청 동안만 사는” 객체가 많으면 아레나가 답입니다.
숫자로 확인할 때는 dhat 의 Total blocks(횟수) · t-gmax(최대) · t-end(누수) 세 개를 보세요.벤치 · 프로파일 — flamegraph · tokio-console · PGO
⚡ e01 에서 criterion 으로 함수를 재는 법을 봤습니다. 여기서는 서비스 전체를 대상으로 합니다 — CPU 가 어디서 타는지(flamegraph), 태스크가 어디서 기다리는지(tokio-console), 그리고 코드를 안 고치고 얻는 LTO · PGO · BOLT 까지.
flamegraph — 5분이면 원인이 보입니다
cargo install flamegraph
# 리눅스: perf 필요 · 커널 설정 한 번
sudo sysctl -w kernel.perf_event_paranoid=1
# Cargo.toml 에 debug 심볼을 켜 둡니다 (릴리스여도 성능 영향 없음)
# [profile.release] debug = 1
cargo flamegraph --bin svc -- --config prod.toml # 실행하며 수집
cargo flamegraph --root --pid 12345 # 이미 도는 프로세스에 붙기
cargo flamegraph --bench my_bench # 벤치를 프로파일
# 읽는 법
# x축 = 시간이 아니라 '샘플 비율' (넓을수록 많이 태움) ★
# y축 = 콜 스택 깊이
# 찾을 것: 넓고 평평한 봉우리 · 예상 못 한 이름(memcpy · malloc · drop_in_place)
# memcpy 가 넓다 → 복사 과다 (h05)
# malloc 이 넓다 → 할당 과다 (h10)
# 드롭이 넓다 → 큰 트리 해제 → 아레나 검토
# 대안 — 샘플링 프로파일러
cargo install samply && samply record ./target/release/svc # 브라우저 UI
perf record -F 999 -g -- ./target/release/svc && perf report
lto·codegen-units=1 이 켜져 있으면
스택이 뭉개집니다. 프로파일용 프로필을 따로 만들어
[profile.profiling] inherits = "release" · debug = 1 · lto = false
로 재는 것이 실무 요령입니다.
tokio-console — 비동기 전용 관측기
[dependencies]
console-subscriber = "0.4"
tokio = { version = "1", features = ["full", "tracing"] }
#[tokio::main]
async fn main() {
console_subscriber::init(); // ★ 이 한 줄 (기본 포트 6669)
// 또는 다른 레이어와 함께
// tracing_subscriber::registry()
// .with(console_subscriber::spawn())
// .with(tracing_subscriber::fmt::layer())
// .init();
run().await;
}
# 빌드할 때 unstable 플래그가 필요합니다
RUSTFLAGS="--cfg tokio_unstable" cargo run --release
# 다른 터미널에서
cargo install tokio-console
tokio-console # http://127.0.0.1:6669
# 화면에서 보는 것
# Total / Busy / Idle 태스크가 실제로 일한 시간 vs 기다린 시간
# Polls 폴링 횟수 — 이상하게 많으면 바쁜 대기(busy loop)
# ⚠ warnings tokio 가 직접 잡아 줍니다
# "task has lost its waker" → 영원히 안 깨어남 (누수)
# "task blocked for N ms" → 폴링이 길다 = 블로킹 ★ h02
# "task spent N% of time busy" → CPU 작업이 async 안에 있음
tokio::task::Builder::new().name("agg") 로 이름을 붙여 두면 훨씬 읽기 쉽습니다.
이렇게도 쓸 수 있다 — 벤치를 신뢰할 수 있게 만들기 🟡 중급
criterion · divan — 함수 단위 A/B
// benches/parse.rs (criterion)
use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId, Throughput};
use std::hint::black_box;
fn bench(c: &mut Criterion) {
let data = std::fs::read("testdata/sample.log").unwrap();
let mut g = c.benchmark_group("parse");
g.throughput(Throughput::Bytes(data.len() as u64)); // MB/s 로 나옵니다 ★
for &chunk in &[1024usize, 8192, 65536] {
g.bench_with_input(BenchmarkId::from_parameter(chunk), &chunk, |b, &n| {
b.iter(|| parse_chunked(black_box(&data), n))
});
}
g.finish();
}
criterion_group!(benches, bench);
criterion_main!(benches);
cargo bench # target/criterion/report/index.html
cargo bench -- --save-baseline before
# … 코드 수정 …
cargo bench -- --baseline before # 변화량 + 통계적 유의성까지 표시 ★
# divan — 더 가볍고 빠릅니다 (#[divan::bench] 어트리뷰트만)
# 마이크로벤치를 자주 돌린다면 이쪽이 편합니다
black_box 없이 결과를 안 쓰면 코드가 통째로 사라집니다,
② 캐시에 다 올라간 작은 데이터로 재면 현실과 다릅니다,
③ CPU 주파수 스케일링·터보 때문에 첫 몇 초와 이후가 다릅니다.
서버라면 벤치보다 부하 테스트(아래)가 훨씬 정직합니다.
부하 테스트 — 서비스는 이렇게 잽니다
# HTTP — oha (Rust 로 작성 · 히스토그램이 예쁨)
cargo install oha
oha -z 60s -c 200 --latency-correction http://localhost:8080/api
# 또는 wrk / bombardier / k6
wrk -t8 -c400 -d60s --latency http://localhost:8080/api
# gRPC — ghz
ghz --insecure -c 100 -z 60s --proto svc.proto --call svc.Api/Get localhost:50051
# TCP·UDP 커스텀 프로토콜은 직접 만듭니다 (h13 에서 실제로 만듭니다)
# 반드시 함께 기록할 것 ★
# · p50 / p90 / p99 / p999 (평균만 보면 아무것도 못 봅니다)
# · 처리량(req/s)과 그때의 CPU · RSS
# · 에러율 · 타임아웃 수
# · 부하를 올렸을 때 '어디서 꺾이는지' (포화점)
oha --latency-correction 이나 wrk2 처럼 고정 요청률 모드를 쓰세요.
실무에선 이렇게 — 릴리스 프로필 완전판 🔴 실무
Cargo.toml — 그대로 복사해 쓰는 설정
# ── 배포용 ────────────────────────────────────────────
[profile.release]
opt-level = 3 # 기본값. 크기가 중요하면 "s" 또는 "z"
lto = "fat" # 크레이트 경계를 넘는 인라인 ★ 보통 5~20%
codegen-units = 1 # 병렬 코드 생성을 포기하고 최적화 극대화
panic = "abort" # 되감기 코드 제거 → 바이너리 ↓ · 약간 빠름
strip = "symbols" # 심볼 제거
incremental = false
debug = 1 # ★ 프로파일용 심볼은 남깁니다 (성능 영향 없음)
overflow-checks = true # 금액·인덱스 계산이 있으면 켜 두는 편이 안전
# ── 프로파일 전용 (스택이 뭉개지지 않게) ────────────────
[profile.profiling]
inherits = "release"
lto = false
codegen-units = 16
debug = 2
# ── 개발 중에도 의존성만은 최적화 (테스트가 훨씬 빨라집니다) ★
[profile.dev.package."*"]
opt-level = 3
# ── 크기를 최소화 (WASM · 임베디드 · h12) ─────────────
[profile.min]
inherits = "release"
opt-level = "z"
lto = "fat"
panic = "abort"
strip = true
panic = "abort" 의 대가 — catch_unwind 가 안 되고,
한 태스크의 패닉이 프로세스 전체를 죽입니다. 서버라면
“쿠버네티스가 재시작해 주니 오히려 낫다”는 쪽과
“한 요청 때문에 전부 죽으면 안 된다”는 쪽이 갈립니다.
멀티테넌트 서버라면 기본값(unwind)을 유지하는 편이 안전합니다.
PGO · BOLT — 코드를 안 고치고 5~20%
# ① PGO (Profile-Guided Optimization)
# 실제 사용 패턴을 수집해 분기 예측·인라인 결정을 개선합니다
rustup component add llvm-tools-preview
# 1단계 — 계측 빌드
RUSTFLAGS="-Cprofile-generate=/tmp/pgo" cargo build --release
# 2단계 — '진짜 같은' 부하를 먹입니다 ★ 이 단계가 품질을 정합니다
./target/release/svc & oha -z 120s -c 100 http://localhost:8080/api; kill %1
# 3단계 — 프로파일 합치기
llvm-profdata merge -o /tmp/pgo/merged.profdata /tmp/pgo
# 4단계 — 최종 빌드
RUSTFLAGS="-Cprofile-use=/tmp/pgo/merged.profdata" cargo build --release
# 실측 보고 감각: 5~15% (분기가 많은 파서·인터프리터에서 더 큼)
# rustc 자신도 PGO 로 빌드됩니다.
# ② BOLT — 링크 후 바이너리의 '코드 배치'를 재정렬 (I-캐시 적중률 ↑)
llvm-bolt ./target/release/svc -o svc.bolt \
-data=perf.fdata -reorder-blocks=ext-tsp -reorder-functions=hfsort \
-split-functions -split-all-cold
# PGO 위에 추가로 2~10% — 큰 바이너리일수록 효과가 큽니다
# ③ target-cpu — 배포 대상이 고정이면
RUSTFLAGS="-C target-cpu=native" cargo build --release
# ★ 다른 CPU 에서 안 돕니다. 컨테이너 배포면 x86-64-v3 정도가 무난합니다
--release 로 재고 있는지 확인(디버그면 10~100배 차이),
② lto = "fat" + codegen-units = 1, ③ 할당자 교체(h10),
④ target-cpu, ⑤ PGO, ⑥ BOLT.
①②③ 이 전체 이득의 대부분이고 ⑤⑥ 은 CI 복잡도를 꽤 올립니다.
한 단계 더 — 인라인과 성능 회귀 감시 🧬 전문가
#[inline] 을 언제 붙이나
// 같은 크레이트 안에서는 컴파일러가 알아서 판단합니다 — 손대지 마세요.
// 문제는 '크레이트 경계'입니다. LTO 없이는 인라인이 안 됩니다.
#[inline] // "인라인해도 좋다" — 다른 크레이트에서도 가능해짐
pub fn fast_hash(x: u64) -> u64 { x.wrapping_mul(0x9E3779B97F4A7C15) }
#[inline(always)] // 강제. 아주 작은 함수에만 ★ 남용하면 코드가 커져 느려짐
fn bit(x: u64, i: u32) -> bool { (x >> i) & 1 == 1 }
#[inline(never)] // 프로파일에서 보고 싶을 때 · 코드 크기를 줄일 때
fn cold_error_path(e: &Error) { /* … */ }
#[cold] // "거의 안 불린다" — 분기 예측·배치에 힌트
fn handle_rare(e: Error) -> ! { panic!("{e}") }
// 실제로 인라인됐는지 확인
// cargo install cargo-show-asm
// cargo asm --lib my_crate::fast_hash --rust ← 어셈블리를 소스와 나란히
#[inline] 이 없어도 됩니다. #[inline] 이 필요한 건 구체 타입의 pub fn 뿐입니다.
CI 에서 성능 회귀 잡기
# .github/workflows/bench.yml — PR 마다 벤치를 비교
name: bench
on: [pull_request]
jobs:
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: 기준(main) 벤치
run: |
git checkout origin/main
cargo bench -- --save-baseline main
- name: 이 PR 벤치
run: |
git checkout -
cargo bench -- --baseline main 2>&1 | tee bench.txt
- name: 회귀 확인
run: |
if grep -q "Performance has regressed" bench.txt; then
echo "::warning::성능 회귀 감지" ; fi
# 마이크로벤치는 CI 러너에서 흔들립니다. 더 안정적인 대안 —
# ① 실행 명령 수를 세기 (하드웨어 노이즈에 둔감)
cargo install iai-callgrind # valgrind 기반 · 편차가 거의 없음
# ② 바이너리 크기 · 의존성 수를 회귀 지표로
cargo bloat --release --crates | head -20
cargo tree --duplicates # 같은 크레이트의 여러 버전 찾기
# ③ 컴파일 시간 감시
cargo build --timings
lto="fat" + codegen-units=1 + 할당자 교체가 대부분이고,
부하 테스트는 반드시 고정 요청률 + p99 로 재세요.임베디드 · WASM · 엣지 — 런타임이 없는 곳에서도 돕니다
Rust 에는 가비지 컬렉터도 런타임도 없습니다. 그래서 운영체제가 없는 마이크로컨트롤러, 브라우저·서버리스 WASM, 엣지 함수 처럼 다른 언어가 못 들어가는 곳에 그대로 들어갑니다. 이 섹션은 그 세 갈래의 현재 실제 상태와 시작하는 법을 정리합니다.
no_std — 시작하는 법
#![no_std] // 표준 라이브러리 대신 core 만
#![no_main] // OS 가 주는 main 진입점 없음
use panic_probe as _; // 패닉 처리기 (필수 — 없으면 링크 에러)
use defmt_rtt as _; // 로그 출력 (RTT 로 디버거에 찍힘)
extern crate alloc; // 힙이 필요하면 (할당자를 직접 지정)
use alloc::vec::Vec;
#[cortex_m_rt::entry] // 진입점
fn main() -> ! { // ★ 절대 반환하지 않습니다
let p = embassy_rp::init(Default::default());
let mut led = embassy_rp::gpio::Output::new(p.PIN_25, Level::Low);
loop {
led.toggle();
cortex_m::asm::delay(8_000_000);
}
}
// core 에는 있는 것 / 없는 것
// 있음: Option Result Iterator 슬라이스 정수 연산 fmt(일부)
// 없음: Vec String HashMap 파일 스레드 시간 (alloc·크레이트로 보충)
// → heapless 크레이트가 고정 크기 Vec/String/HashMap 을 줍니다 ★
Send/Sync 와
critical-section 크레이트 조합으로 인터럽트 공유 데이터가 타입으로 강제됩니다.
embassy — 임베디드용 async 런타임
# .cargo/config.toml
[build]
target = "thumbv6m-none-eabi" # RP2040 (Raspberry Pi Pico)
# ESP32-C3 라면 riscv32imc-unknown-none-elf
[target.thumbv6m-none-eabi]
runner = "probe-rs run --chip RP2040" # cargo run 이 보드에 굽습니다 ★
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
// 태스크가 여러 개여도 스레드는 없습니다 — 협력적 스케줄링 + 인터럽트 ★
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.toggle();
Timer::after(Duration::from_millis(500)).await; // 이 동안 CPU 는 잠듦
}
}
#[embassy_executor::task]
async fn read_sensor(mut adc: Adc<'static>, tx: Sender<'static, ThreadModeRawMutex, u16, 8>) {
loop {
let v = adc.read().await;
tx.send(v).await; // 채널도 그대로 있습니다 (h03 과 같은 개념)
Timer::after(Duration::from_secs(1)).await;
}
}
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
spawner.spawn(blink(Output::new(p.PIN_25, Level::Low))).unwrap();
// 힙 할당 0 · 태스크 크기는 컴파일 타임에 확정됩니다
}
이렇게도 쓸 수 있다 — WASM 과 엣지 런타임 🟡 중급
Cloudflare Workers — workers-rs
// [dependencies] worker = "0.4"
use worker::*;
#[event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
Router::new()
.get("/", |_, _| Response::ok("hello from the edge"))
.get_async("/kv/:key", |_, ctx| async move {
let key = ctx.param("key").unwrap();
let kv = ctx.kv("MY_KV")?; // 엣지 키-값 저장소
match kv.get(key).text().await? {
Some(v) => Response::ok(v),
None => Response::error("not found", 404),
}
})
.post_async("/d1", |mut req, ctx| async move {
let body: serde_json::Value = req.json().await?;
let db = ctx.env.d1("DB")?; // 엣지 SQLite
db.prepare("INSERT INTO logs (body) VALUES (?1)")
.bind(&[body.to_string().into()])?
.run().await?;
Response::ok("ok")
})
.run(req, env).await
}
npm create cloudflare@latest -- my-worker --template=hello-world-rust
npx wrangler dev # 로컬 실행
npx wrangler deploy # 전 세계 300+ 도시에 배포
# 왜 Rust 인가: WASM 이라 콜드 스타트가 거의 0 이고,
# 무거운 계산(이미지 변환 · 암호 · 파싱)을 JS 보다 훨씬 싸게 합니다.
WASI · Spin — 서버 쪽 WASM
# ① 표준 WASI 로 빌드 — 파일·소켓 접근이 있는 WASM
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
wasmtime target/wasm32-wasip1/release/svc.wasm # 어디서든 같은 바이너리
# ② 크기 줄이기 (로딩 시간 = 크기)
cargo install wasm-opt-rs # 또는 binaryen 의 wasm-opt
wasm-opt -Oz -o small.wasm svc.wasm
# [profile.release] opt-level = "z" lto = "fat" panic = "abort" strip = true
# ③ Spin — WASM 마이크로서비스 프레임워크
spin new -t http-rust my-svc
spin build && spin up
spin deploy # Fermyon Cloud 또는 쿠버네티스(SpinKube)
| 플랫폼 | 런타임 | 특징 |
|---|---|---|
| Cloudflare Workers | V8 + WASM | 콜드 스타트 ≈ 0 · KV/D1/R2 |
| Fastly Compute | Wasmtime | 요청마다 새 인스턴스 |
| Spin / SpinKube | Wasmtime | 자체 호스팅 가능 |
| AWS Lambda | 네이티브 | WASM 아님 — 그냥 바이너리 |
| 브라우저 | V8/SM | wasm-bindgen (🟨 j08) |
실무에선 이렇게 — 어디에 Rust 가 맞나 🔴 실무
❌ 굳이 Rust 가 아니어도 되는 곳
· 엣지에서 라우팅·리다이렉트만 하는 함수
JS 로 열 줄이면 끝나고 성능 차이가 안 납니다.
· 아두이노 수준의 취미 프로젝트
C++ 생태계(라이브러리·예제)가 훨씬 두껍습니다.
· 브라우저 UI 전체
Leptos·Dioxus 가 훌륭하지만 채용·생태계에서 React 를 못 이깁니다.
WASM 은 'UI 옆의 무거운 계산'에 쓰는 게 현실적입니다.
· 팀에 임베디드 Rust 경험이 아무도 없고 납기가 3개월
HAL·디버깅 도구에 익숙해지는 데만 몇 주가 갑니다.
✅ Rust 가 확실히 맞는 곳
· 배터리로 도는 기기 — GC 가 없어 전력·지연이 예측 가능
· 안전이 중요한 펌웨어 — 의료·자동차·산업 (인증 사례 증가)
· 엣지에서의 무거운 계산 — 이미지 변환 · 암호 · 파싱 · 압축
· 브라우저에서 도는 계산 코어 — 코덱 · 시뮬레이션 · 편집기 엔진
(Figma · Photoshop Web · Google Earth 가 이 방식입니다)
· 플러그인 시스템 — WASM 으로 샌드박싱 (extism · wasmtime 임베드)
· "같은 로직을 서버 · 브라우저 · 기기에서" 써야 할 때 ★
코어를 no_std 크레이트로 만들고 각 타깃에서 껍데기만 다르게
no_std 크레이트로 짜 두면
그대로 서버 · WASM · 펌웨어 셋에 들어갑니다.
#![cfg_attr(not(feature = "std"), no_std)] 로 두 모드를 함께 지원하는 것이
serde·heapless·postcard 같은 크레이트가 쓰는 방식입니다.
한 단계 더 — 크기와 시작 시간을 실제로 줄이기 🧬 전문가
바이너리에서 무엇이 자리를 차지하나
cargo install cargo-bloat
cargo bloat --release --crates -n 20 # 크레이트별 차지 비율
cargo bloat --release -n 20 # 함수별
# 흔한 범인들
# · 포매팅 기계 (core::fmt) — println!/format! 이 있으면 수십 KB
# → 임베디드에서는 defmt 로 대체 (포맷을 호스트에서 함)
# · 패닉 메시지 문자열 — panic_immediate_abort 로 제거 (nightly)
# · 제네릭 단형화 폭발 — 같은 함수가 타입마다 복제
# → 내부를 &dyn 이나 구체 타입 함수로 빼서 한 벌만 생성 ★
# · regex · serde_json 은 무겁습니다 (엣지·임베디드에서 주의)
# 크기 최적화 조합
# [profile.release] opt-level="z" lto="fat" codegen-units=1 panic="abort" strip=true
# + build-std (nightly): cargo +nightly build -Z build-std=core,alloc \
# -Z build-std-features=panic_immediate_abort --target thumbv6m-none-eabi
단형화 폭발을 줄이는 관용구
// ❌ 제네릭 본문이 타입마다 통째로 복제됩니다
pub fn save<P: AsRef<std::path::Path>>(path: P, data: &[u8]) -> std::io::Result<()> {
// … 200줄 …
std::fs::write(path.as_ref(), data)
}
// ✅ 얇은 껍데기만 제네릭으로, 본문은 한 벌만 ★
pub fn save<P: AsRef<std::path::Path>>(path: P, data: &[u8]) -> std::io::Result<()> {
save_inner(path.as_ref(), data) // 껍데기: 한 줄
}
fn save_inner(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
// … 200줄 … (구체 타입 — 한 벌만 생성됩니다)
std::fs::write(path, data)
}
// 표준 라이브러리가 쓰는 패턴입니다 (fs::write · File::open 등이 전부 이 모양)
// 컴파일 시간도 같이 줄어듭니다.
// 얼마나 복제됐는지 보기
// cargo +nightly rustc -- -Zprint-mono-items=lazy | sort | uniq -c | sort -rn
cargo build --timings 로 찾은 뒤
제네릭 함수의 본문을 구체 타입으로 빼는 것이 가장 자주 효과를 봅니다.
no_std 는 “std 가 없을 뿐”이고 언어와 소유권은 그대로입니다.
임베디드는 embassy + probe-rs, 엣지는 workers-rs · Spin, 브라우저는 wasm-bindgen 이 표준 경로입니다.
가장 값어치 있는 설계는 핵심 로직을 no_std 크레이트로 두고
서버·WASM·펌웨어에서 껍데기만 바꿔 쓰는 것입니다.실전 프로젝트 — 실시간 로그 수집·집계 서비스
지금까지의 조각을 하나로 잇습니다. TCP·UDP 로 로그를 받아(h06) · 채널과 액터로 흘리고(h03) · Parquet 로 배치 저장하고(h08) · Redis 에 실시간 카운터를 올리고(h07) · Axum 으로 조회 API 를 여는 서비스입니다. 마지막에 부하 테스트 수치와 튜닝 기록 · Docker 배포까지 남깁니다.
Cargo.toml — 이 프로젝트가 쓰는 전부
[package]
name = "logsvc"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec", "rt"] }
futures = "0.3"
bytes = "1"
axum = "0.8"
tower-http = { version = "0.6", features = ["trace", "compression-gzip"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
polars = { version = "0.44", features = ["lazy", "parquet", "temporal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
metrics = "0.24"
metrics-exporter-prometheus = "0.16"
anyhow = "1"
mimalloc = { version = "0.1", default-features = false }
[profile.release]
lto = "fat"
codegen-units = 1
debug = 1
strip = "symbols"
src/main.rs(부팅·설정) · ingest.rs(TCP/UDP 수신) ·
parse.rs(Bytes → Event) · agg.rs(집계 액터) ·
sink.rs(Parquet · Redis) · api.rs(Axum).
각 파일이 이 탭의 한 섹션에 대응합니다.
① 수신 — 받아서 넣기만 합니다
// src/ingest.rs
use tokio_util::codec::{FramedRead, LinesCodec};
use tokio::net::{TcpListener, UdpSocket};
use futures::StreamExt;
pub async fn tcp(addr: &str, tx: mpsc::Sender<RawLine>) -> anyhow::Result<()> {
let l = TcpListener::bind(addr).await?;
loop {
let (sock, peer) = match l.accept().await {
Ok(v) => v,
Err(e) => { tracing::warn!(%e, "accept"); continue; } // h06
};
sock.set_nodelay(true).ok();
let tx = tx.clone();
tokio::spawn(async move {
// 한 줄 = 한 로그. 최대 길이를 반드시 제한합니다
let mut fr = FramedRead::new(sock, LinesCodec::new_with_max_length(64 * 1024));
while let Some(line) = fr.next().await {
match line {
Ok(s) => {
// ★ 가득 차면 기다리지 않고 버립니다 — 수신이 막히면 안 됩니다
if tx.try_send(RawLine::from(s)).is_err() {
metrics::counter!("ingest_dropped", "proto" => "tcp").increment(1);
}
}
Err(e) => { tracing::debug!(%peer, %e, "line"); break; }
}
}
});
}
}
pub async fn udp(addr: &str, tx: mpsc::Sender<RawLine>) -> anyhow::Result<()> {
let sock = UdpSocket::bind(addr).await?;
let mut buf = vec![0u8; 65_535];
loop {
let (n, _peer) = sock.recv_from(&mut buf).await?;
// 이 루프에서는 절대 파싱하지 않습니다 (h06 의 철칙)
if tx.try_send(RawLine::from_slice(&buf[..n])).is_err() {
metrics::counter!("ingest_dropped", "proto" => "udp").increment(1);
}
}
}
이렇게도 쓸 수 있다 — 집계 액터와 배치 저장 🟡 중급
② 집계 — 1분 버킷 카운터
// src/agg.rs — h03 의 액터 패턴 그대로
use std::collections::HashMap;
#[derive(Default)]
struct Bucket { count: u64, err: u64, lat: Vec<u32> }
pub async fn run(mut rx: mpsc::Receiver<Event>, redis: ConnectionManager) {
// (분 단위 타임스탬프, 서비스명) → 버킷
let mut buckets: HashMap<(i64, String), Bucket> = HashMap::new();
let mut flush = tokio::time::interval(Duration::from_secs(10));
loop {
tokio::select! {
Some(e) = rx.recv() => { // ★ 취소 안전
let minute = e.ts - (e.ts % 60);
let b = buckets.entry((minute, e.service.clone())).or_default();
b.count += 1;
if e.level >= Level::Error { b.err += 1; }
b.lat.push(e.latency_ms);
}
_ = flush.tick() => {
if buckets.is_empty() { continue; }
// 파이프라인으로 한 번에 (h07) — 왕복이 1회
let mut pipe = redis::pipe();
for ((min, svc), b) in buckets.drain() {
let key = format!("agg:{min}");
pipe.hincr(&key, format!("{svc}:n"), b.count).ignore();
pipe.hincr(&key, format!("{svc}:e"), b.err).ignore();
let mut lat = b.lat; lat.sort_unstable();
let p99 = lat[lat.len() * 99 / 100.min(lat.len() - 1)];
pipe.hset(&key, format!("{svc}:p99"), p99).ignore();
pipe.expire(&key, 7200).ignore(); // 2시간만 보관
}
let mut con = redis.clone();
if let Err(e) = pipe.query_async::<()>(&mut con).await {
tracing::error!(%e, "redis flush"); // 실패해도 서비스는 계속
}
}
else => break,
}
}
}
// 초당 수십만 건이면 이 액터를 서비스명 해시로 8개 샤딩합니다 (h03)
③ 배치 저장 — Parquet
// src/sink.rs
use tokio_stream::StreamExt;
use polars::prelude::*;
pub async fn parquet_sink(rx: mpsc::Receiver<Event>) -> anyhow::Result<()> {
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
// ★ "5만 건 또는 30초 중 먼저" (h07) — 둘 중 하나만 쓰면 반드시 문제가 납니다
let mut chunks = stream.chunks_timeout(50_000, Duration::from_secs(30));
while let Some(batch) = chunks.next().await {
let n = batch.len();
let t0 = std::time::Instant::now();
// 열 단위로 뒤집습니다 (h08 의 Arrow 형식에 맞게)
let mut ts = Vec::with_capacity(n);
let mut svc = Vec::with_capacity(n);
let mut lvl = Vec::with_capacity(n);
let mut lat = Vec::with_capacity(n);
for e in batch {
ts.push(e.ts); svc.push(e.service); lvl.push(e.level as u8); lat.push(e.latency_ms);
}
let mut df = df!["ts" => ts, "service" => svc, "level" => lvl, "latency" => lat]?;
// ★ Parquet 쓰기는 CPU 를 씁니다 → 워커를 막지 않게 격리 (h02)
let path = partition_path(&df)?;
tokio::task::spawn_blocking(move || -> PolarsResult<()> {
std::fs::create_dir_all(std::path::Path::new(&path).parent().unwrap()).ok();
ParquetWriter::new(std::fs::File::create(&path)?)
.with_compression(ParquetCompression::Zstd(None))
.with_statistics(StatisticsOptions::full())
.finish(&mut df)?;
Ok(())
}).await??;
metrics::histogram!("sink_batch_ms").record(t0.elapsed().as_millis() as f64);
metrics::counter!("sink_rows").increment(n as u64);
}
Ok(())
}
실무에선 이렇게 — 조회 API · 배포 · 관측 🔴 실무
④ Axum 조회 API — 뜨거운 데이터와 찬 데이터
// src/api.rs
use axum::{Router, routing::get, extract::{Query, State}, Json};
#[derive(serde::Deserialize)]
struct Q { service: String, from: i64, to: i64 }
async fn stats(State(st): State<AppState>, Query(q): Query<Q>)
-> Result<Json<Vec<Point>>, ApiError>
{
let now = chrono::Utc::now().timestamp();
let hot_from = (now - 7200).max(q.from); // 최근 2시간은 Redis
// ① 뜨거운 구간 — Redis (밀리초)
let mut out = redis_points(&st.redis, &q.service, hot_from, q.to).await?;
// ② 찬 구간 — Parquet 스캔 (수백 ms) · 블로킹이므로 격리 (h02)
if q.from < hot_from {
let (svc, from, to) = (q.service.clone(), q.from, hot_from);
let cold = tokio::task::spawn_blocking(move || -> PolarsResult<Vec<Point>> {
let lf = LazyFrame::scan_parquet("data/**/*.parquet", Default::default())?
.filter(col("service").eq(lit(svc)))
.filter(col("ts").gt_eq(lit(from)).and(col("ts").lt(lit(to))))
.group_by([(col("ts") / lit(60)).alias("m")])
.agg([len().alias("n"), col("latency").mean().alias("avg")]);
to_points(lf.collect()?)
}).await??;
out.splice(0..0, cold); // 앞에 붙입니다
}
Ok(Json(out))
}
pub fn router(st: AppState) -> Router {
Router::new()
.route("/stats", get(stats))
.route("/healthz", get(|| async { "ok" }))
.route("/metrics", get(|State(s): State<AppState>| async move { s.prom.render() }))
.layer(tower_http::trace::TraceLayer::new_for_http())
.layer(tower::limit::ConcurrencyLimitLayer::new(64)) // h03
.layer(tower::load_shed::LoadShedLayer::new())
.with_state(st)
}
⑤ Dockerfile — 다단계 빌드
# syntax=docker/dockerfile:1.7
FROM rust:1.83-bookworm AS build
WORKDIR /app
# ① 의존성만 먼저 캐시 (소스가 바뀌어도 의존성 빌드를 다시 안 함)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main(){}" > src/main.rs \
&& cargo build --release && rm -rf src
# ② 진짜 소스
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release && cp target/release/logsvc /logsvc
# ③ 실행 이미지 — 필요한 것만
FROM gcr.io/distroless/cc-debian12
COPY --from=build /logsvc /logsvc
EXPOSE 9000/tcp 9001/udp 8080/tcp
ENV RUST_LOG=info TOKIO_WORKER_THREADS=4
ENTRYPOINT ["/logsvc"]
# 정적 링크로 더 작게 하려면 (h09)
# FROM rust:1.83-alpine + musl → FROM scratch (이미지 약 10MB)
TOKIO_WORKER_THREADS 를 반드시 주세요 — h02 에서 봤듯
컨테이너 CPU limit 이 2 여도 Tokio 는 호스트 코어 수를 봅니다.
Builder::worker_threads(env 값) 으로 읽어 쓰는 것이 실무 관행입니다.
한 단계 더 — 부하 테스트 기록과 튜닝 일지 🧬 전문가
튜닝 일지 — 순서와 효과
환경: 4 vCPU · 8GB · 컨테이너 · 로그 한 줄 약 220바이트
부하: TCP 커넥션 200개에서 라인을 계속 밀어 넣기 (자체 도구)
지표: 초당 처리 라인 · 유실률 · p99 저장 지연 · RSS · CPU
① 최초 (디버그 빌드) 18,000 line/s ← 함정 ★
② --release 로 빌드 240,000 line/s +13배
③ println! 로그를 tracing 으로 310,000 line/s +29%
(표준출력 락 + flush 가 병목이었음 · h09)
④ 채널 unbounded → bounded(65536) + 드롭 310,000 line/s 유실률이 '보임'
숫자는 그대로지만 OOM 이 사라짐 — 이게 진짜 이득 ★
⑤ 파서에서 String 할당 제거 (Bytes · h05) 470,000 line/s +52%
flamegraph 상단이 memcpy → 파싱 로직으로 바뀜
⑥ mimalloc 으로 교체 (h10) 540,000 line/s +15% (한 줄)
⑦ 파서 워커 1 → 4, 집계 액터 샤딩 4 810,000 line/s +50%
⑧ Parquet 쓰기를 spawn_blocking 으로 810,000 line/s p99 2.4s → 180ms ★
처리량은 그대로인데 꼬리 지연이 사라짐 — h02 그대로
⑨ lto=fat + codegen-units=1 870,000 line/s +7%
⑩ TOKIO_WORKER_THREADS=4 명시 880,000 line/s CPU 사용률 안정
최종: 880,000 line/s · 유실 0 · p99 180ms · RSS 420MB · CPU 3.6/4
운영 체크리스트
반드시 지표로 내보낼 것 (없으면 장애 때 눈이 없습니다)
ingest_lines_total 받은 줄 수 (프로토콜별)
ingest_dropped_total 버린 줄 수 ★ 0 이 아니면 즉시 알람
channel_depth 큐 깊이 (h02 의 global_queue_depth 도 함께)
parse_errors_total 파싱 실패
sink_batch_ms 배치 저장 소요 (히스토그램)
sink_rows_total 저장한 행 수
redis_flush_errors_total Redis 실패
process_rss_bytes 메모리 (h10)
tokio_alive_tasks 살아있는 태스크 ★ 단조 증가면 누수
종료 절차 (데이터를 잃지 않으려면 순서가 중요합니다)
① SIGTERM 수신 → 리스너를 먼저 닫습니다 (새 연결 차단)
② 수신 채널의 송신자를 drop → 파서가 남은 것을 다 처리
③ 집계 액터에 마지막 flush 를 시키고 Redis 반영
④ 배치 수집기의 남은 버퍼를 Parquet 으로 강제 저장 ★
⑤ TaskTracker::wait() 로 전부 끝날 때까지 대기 (h02)
⑥ 종료. 쿠버네티스라면 terminationGracePeriodSeconds 를 넉넉히
웹 화면은 그대로,
속은 Rust 로
바꿔 끼웁니다
Electron 으로 만든 앱은 Chromium 을 통째로 짊어지고 배포됩니다 — 설치 파일 100 MB, 켜자마자 메모리 300 MB. Tauri 는 그 자리에 운영체제가 이미 갖고 있는 WebView 를 끼워 넣고, 앱 로직은 Rust 네이티브 바이너리로 돌립니다. 같은 Vue·React 화면을 쓰면서 설치 파일이 3~10 MB 로 줄고, 파일 스캔·이미지 변환 같은 무거운 일이 수십 배 빨라집니다. 이 탭은 설치 · 첫 앱 · IPC · 상태와 이벤트 · 창과 트레이 · 권한 모델 · sidecar · 빌드와 코드 서명 · 자동 업데이트 · 모바일까지 실제로 배포되는 앱 한 개를 만드는 데 필요한 전부를 다룹니다. 마지막에는 egui · iced · Slint · Dioxus 같은 Rust 네이티브 GUI 와 Leptos · Yew 같은 Rust 웹 프런트까지 놓고, 언제 무엇을 골라야 하는지를 솔직하게 정리합니다. 이 탭을 마치면 서명된 설치 파일과 자동 업데이트가 붙은 데스크톱 앱을 혼자 배포할 수 있습니다.
Tauri 가 무엇이고 왜 갑자기 다들 쓰나
한 줄로 말하면 “Electron 에서 Chromium 과 Node.js 를 빼고 그 자리에 OS 의 WebView 와 Rust 를 넣은 것” 입니다. 화면을 만드는 방법(HTML·CSS·JS)은 똑같으니 프런트 코드는 거의 그대로 옮겨 옵니다. 바뀌는 것은 배포 크기 · 메모리 · 뒷단 언어 · 보안 모델 네 가지이고, 이 섹션은 그 네 가지가 각각 얼마나 달라지는지를 숫자로 봅니다.
숫자로 보는 차이 — 같은 “할 일 목록” 앱 기준
| Electron | Tauri 2 | PySide6 | |
|---|---|---|---|
| 설치 파일 | 85~150 MB | 3~10 MB | 40~80 MB |
| 메모리(idle) | 180~300 MB | 60~120 MB | 60~100 MB |
| 시작 시간 | 1~3초 | 0.2~0.6초 | 0.5~1.5초 |
| 화면 만드는 법 | HTML/CSS/JS | HTML/CSS/JS | 위젯 · QML |
| 뒷단 언어 | JS(Node) | Rust | 파이썬 |
| 렌더링 일관성 | 완전 동일 | OS 별 차이 | 완전 동일 |
| CPU 무거운 일 | 느림 | 네이티브 속도 | 느림(GIL) |
| 모바일 | 불가 | Android · iOS | 사실상 불가 |
| 라이선스 | MIT | MIT / Apache-2.0 | LGPL / 상용 |
Tauri 가 실제로 하는 일 — 세 덩어리뿐
Tauri 앱 = ① 창 + WebView 를 띄우는 Rust 실행 파일
② 그 WebView 안에서 도는 내 프런트엔드
③ 둘을 잇는 IPC 다리 (invoke / event)
┌─ 내 실행 파일 (myapp.exe) ─────────────────┐
│ Rust main() │
│ ├ tauri::Builder 로 앱 구성 │
│ ├ 창을 만들고 WebView 를 붙임 │
│ ├ #[tauri::command] 함수들을 등록 │
│ └ 이벤트 루프 진입 │
│ │
│ ┌─ WebView (OS 제공) ──────────────┐ │
│ │ index.html + Vue/React 번들 │ │
│ │ invoke("scan_dir", {...}) ──────┼──┐ │
│ └──────────────────────────────────┘ │ │
│ ▼ │
│ fn scan_dir(...) -> Result<Vec<Item>> │
│ rayon 으로 12 스레드 병렬 스캔 ★ │
└────────────────────────────────────────────┘
★ 여기가 핵심 — 화면은 웹, 힘쓰는 일은 네이티브
이렇게도 쓸 수 있다 — Tauri 2 에서 무엇이 바뀌었나 🟡 중급
1.x 를 봤던 사람이 놀라는 네 가지
| 주제 | Tauri 1.x | Tauri 2.x |
|---|---|---|
| 플랫폼 | 데스크톱만 | + Android · iOS |
| 기능 켜기 | allowlist 통짜 | capabilities(ACL) |
| 내장 API | 코어에 다 들어감 | 플러그인으로 분리 |
| 창 타입 | Window | WebviewWindow |
| 다중 WebView | 불가 | 한 창에 여러 개 |
| 스트리밍 IPC | 이벤트로 흉내 | Channel<T> |
| JS 패키지 | @tauri-apps/api 통합 | 기능별 분리 설치 |
# 1.x → 2.x 마이그레이션은 CLI 가 대부분 해 줍니다
npm install @tauri-apps/cli@latest
npm run tauri migrate
# 이게 해 주는 것
# · Cargo.toml · package.json 의존성 버전 올리기
# · allowlist → capabilities/default.json 변환 ★
# · 코어에서 빠진 API 를 플러그인 의존성으로 추가
# · tauri.conf.json 스키마 v2 로 재작성
#
# 해 주지 않는 것 (직접 고쳐야 함)
# · Rust 쪽 Window → WebviewWindow 타입 이름
# · import 경로 (@tauri-apps/api/tauri → /core)
import 경로가 바뀐 자리 ★ 여기서 제일 많이 막힙니다
// ❌ Tauri 1.x 예제를 그대로 붙이면 전부 실패합니다
import { invoke } from '@tauri-apps/api/tauri'
import { open } from '@tauri-apps/api/dialog'
import { readTextFile } from '@tauri-apps/api/fs'
import { appWindow } from '@tauri-apps/api/window'
// ✅ Tauri 2.x — 코어와 플러그인이 나뉘었습니다
import { invoke } from '@tauri-apps/api/core' // tauri → core
import { listen, emit } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window' // appWindow 사라짐
import { open } from '@tauri-apps/plugin-dialog' // 별도 패키지 ★
import { readTextFile } from '@tauri-apps/plugin-fs' // 별도 패키지 ★
import { fetch } from '@tauri-apps/plugin-http'
import { Store } from '@tauri-apps/plugin-store'
// 설치도 각각
// npm i @tauri-apps/plugin-dialog @tauri-apps/plugin-fs
// cargo add tauri-plugin-dialog tauri-plugin-fs (src-tauri 안에서)
@tauri-apps/api/tauri 나 tauri.conf.json 의 allowlist 를 쓰고 있으면
1.x 예제이니 그대로 쓰면 안 됩니다. 공식 문서 상단의 버전 표시를 항상 먼저 확인하세요.
실무에선 이렇게 — 언제 Tauri 를 고르고, 언제 고르면 안 되나 🔴 실무
✅ Tauri 가 확실히 유리한 자리
| 상황 | 이유 |
|---|---|
| 사내 배포 도구 | 업데이트를 자주 밀어도 부담 없음 |
| 로컬 파일을 많이 다룸 | 스캔·해시·변환이 네이티브 속도 |
| 이미 웹 앱이 있음 | 프런트를 거의 그대로 재사용 |
| 백신·보안 심사 | 바이너리가 작고 서명이 단순 |
| 데스크톱+모바일 동시 | 같은 프런트로 4 플랫폼 |
| 오프라인 우선 앱 | SQLite + Rust 로 완결 |
실제로 쓰이는 앱들 (2026 기준)
· 개발 도구 — 로그 뷰어 · API 클라이언트 · Git GUI
· 암호화폐 지갑 — 바이너리가 작아 감사(audit)가 쉬움
· 사내 관리 도구 — 파일 정리 · 배치 실행 · 리포트
· 미디어 유틸 — FFmpeg 을 sidecar 로 감싼 변환기(u07)
· 노트 · 지식 관리 — SQLite 전문검색 + 로컬 우선
❌ Tauri 를 피해야 하는 자리
| 상황 | 대안 |
|---|---|
| 픽셀 단위 렌더 일치 필수 | Electron |
| Node 네이티브 모듈 의존 | Electron |
| 구형 Windows 7 지원 | Electron(구버전) |
| 초당 60프레임 그래픽 | egui · SDL · 게임엔진 |
| OS 위젯 룩앤필 필수 | Qt · WinUI · SwiftUI |
| 팀에 Rust 인력 0 + 복잡한 뒷단 | Electron |
한 단계 더 — 보안 모델이 근본적으로 다릅니다 🧬 전문가
Electron 의 위험 지점과 Tauri 의 답
Electron 의 구조적 위험
· nodeIntegration 을 켜면 렌더러에서 require('fs') 가 됩니다
→ XSS 하나가 곧바로 "임의 파일 읽기/쓰기"가 됩니다
· contextIsolation + preload 로 막지만, 이건 '옵션'입니다
· preload 에서 실수로 넓은 API 를 노출하면 원점
Tauri 의 구조
· WebView 안에서는 fs 든 프로세스 실행이든 애초에 없습니다
· Rust 쪽이 명시적으로 등록한 command 만 부를 수 있습니다
· 그 command 조차 capabilities 파일이 허용해야 통과합니다 ★
· 파일 접근은 scope(경로 패턴)까지 걸 수 있습니다 (u06)
즉 Electron 은 "기본 열림 → 잠근다",
Tauri 는 "기본 닫힘 → 하나씩 연다" 입니다.
그래도 남는 위험 — 착각하지 말 것
run_shell(cmd: String) 처럼 넓으면,
XSS 는 그 command 를 통해 무엇이든 실행합니다.
command 를 좁게 설계하는 것이 실제 방어선입니다.
// ❌ 이 하나로 모든 보안 모델이 무너집니다
#[tauri::command]
fn run(cmd: String) -> String { /* 아무거나 실행 */ }
// ✅ 할 수 있는 일을 타입으로 좁힙니다
#[derive(serde::Deserialize)]
enum Task { Thumbnail, Transcode, Probe } // 정해진 것만
#[tauri::command]
fn run_task(task: Task, id: u32) -> Result<(), String> {
// 경로도 인자로 받지 않고 id 로 DB 에서 찾습니다 ★
Ok(())
}
설치 · 첫 앱 — 한 줄이면 창이 뜹니다
Tauri 의 준비물은 Rust 툴체인 + Node + OS 별 WebView 런타임 세 가지입니다.
리눅스만 조금 성가시고 나머지는 대부분 이미 깔려 있습니다.
이 섹션에서 OS 별 준비 → 프로젝트 생성 → 폴더 구조 이해 → tauri dev 로 창 띄우기까지 끝냅니다.
OS 별 준비물 — 여기만 넘기면 나머진 쉽습니다
# ── 공통 ─────────────────────────────────────────
# ① Rust (1.77 이상 필요, 2026-08 안정판 1.89)
# https://rustup.rs
rustup default stable && rustc --version
# ② Node — 프런트 빌드용 (24 LTS 권장)
node -v # v24.x
# ── Windows ──────────────────────────────────────
# ③ MSVC 빌드 도구 (링커) — Rust 가 어차피 필요로 합니다
# Visual Studio Installer → "C++를 사용한 데스크톱 개발"
# ④ WebView2 런타임 — Win10 1803+ / Win11 은 기본 포함 ★
# 없으면 https://developer.microsoft.com/microsoft-edge/webview2/
# ── macOS ────────────────────────────────────────
xcode-select --install # Command Line Tools 만으로 충분
# WKWebView 는 OS 내장 — 따로 깔 것 없음
# ── Linux (Debian · Ubuntu) ──────────────────────
sudo apt install libwebkit2gtk-4.1-dev build-essential \
curl wget file libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev
# ★ 4.0 이 아니라 4.1 입니다 (Tauri 2 부터)
# Fedora: webkit2gtk4.1-devel openssl-devel …
The system library `javascriptcoregtk-4.1` required by crate `javascriptcore-rs-sys` was not found.
webkit2gtk 를 4.0 으로 깔았거나 아예 없을 때 나옵니다.
Tauri 2 는 4.1 만 씁니다. 그리고 배포 대상 배포판과 같은 버전에서 빌드해야 합니다 —
Ubuntu 24.04 에서 만든 .deb 는 22.04 에서 안 돕니다(u09).
첫 프로젝트 — 질문 여섯 개에 답하면 끝
npm create tauri-app@latest
# 또는 cargo install create-tauri-app && cargo create-tauri-app
# 물어보는 것들
# ✔ Project name · my-app
# ✔ Identifier · com.mycompany.myapp ★ 나중에 못 바꿉니다
# ✔ Frontend language · TypeScript / JavaScript
# ✔ Package manager · npm / pnpm / yarn / bun
# ✔ UI template · Vue / React / Svelte / Solid / Vanilla / Angular
# ✔ UI flavor · TypeScript
cd my-app
npm install
npm run tauri dev # ★ 첫 실행은 Rust 컴파일 때문에 1~3분
# 이후 자주 쓰는 명령
npm run tauri dev # 개발
npm run tauri build # 배포본 (u09)
npm run tauri build -- --debug # 디버그 심볼 있는 배포본
npm run tauri info # 환경 진단 ★ 문제 생기면 제일 먼저
com.회사.앱이름 형식이고
macOS 번들 ID · Windows 설치 경로 · 앱 데이터 폴더 · 업데이트 식별자가 전부 여기서 나옵니다.
나중에 바꾸면 기존 사용자의 설정과 DB 가 다른 폴더로 가서 “데이터가 사라졌다”가 됩니다.
.app 이나 .tauri 로 끝나는 값은 거부됩니다.
이렇게도 쓸 수 있다 — 폴더 구조와 설정 파일 읽기 🟡 중급
만들어진 폴더 — 두 세계가 나란히 있습니다
my-app/
├─ package.json 프런트 의존성 · 스크립트
├─ vite.config.ts Vite 설정 (포트 1420 고정됨 ★)
├─ index.html 프런트 진입점
├─ src/ ← 여기는 그냥 웹 앱입니다
│ ├─ main.ts
│ ├─ App.vue
│ └─ assets/
│
└─ src-tauri/ ← 여기부터 Rust 크레이트
├─ Cargo.toml Rust 의존성 (tauri, serde, …)
├─ tauri.conf.json ★ 앱 설정의 중심
├─ build.rs tauri-build 호출 (건드릴 일 거의 없음)
├─ src/
│ ├─ main.rs 진입점 — lib.rs 의 run() 을 부름
│ └─ lib.rs ★ 실제 앱 구성은 여기 (모바일 공용)
├─ capabilities/
│ └─ default.json 권한 설정 (u06)
├─ icons/ 아이콘 세트 (tauri icon 이 생성)
├─ gen/ android/ ios/ 프로젝트 (u10)
└─ target/ 빌드 산출물 ★ 커밋 금지 (수 GB)
main.rs 가 비어 있는 이유 — Tauri 2 는 모바일에서 진입점이 다릅니다(안드로이드는 라이브러리로 로드).
그래서 실제 구성은 전부 lib.rs 의 run() 에 두고,
main.rs 는 데스크톱에서 그걸 부르기만 합니다.
코드는 항상 lib.rs 에 쓰세요 — 그래야 u10 에서 모바일이 공짜로 따라옵니다.
tauri.conf.json — 실무에서 실제로 만지는 항목만
{
"productName": "My App",
"version": "0.1.0",
"identifier": "com.mycompany.myapp",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [{
"title": "My App",
"width": 1100, "height": 720,
"minWidth": 800, "minHeight": 500,
"resizable": true,
"decorations": true,
"center": true
}],
"security": { "csp": null }
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/32x32.png", "icons/128x128.png",
"icons/icon.icns", "icons/icon.ico"]
}
}
// src-tauri/src/lib.rs — 기본 골격
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet]) // u03
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("안녕하세요, {name}!")
}
실무에선 이렇게 — 기존 웹 프로젝트에 Tauri 를 얹기 · 개발 환경 다듬기 🔴 실무
이미 있는 Vue/React 앱에 붙이기
# 기존 프런트 프로젝트 루트에서
npm install -D @tauri-apps/cli@latest
npm install @tauri-apps/api@latest
npx tauri init
# ✔ App name · my-app
# ✔ Window title · My App
# ✔ Web assets 위치 · ../dist ← 빌드 결과 폴더 ★
# ✔ dev server URL · http://localhost:5173 ← 실제 포트 ★
# ✔ dev 명령 · npm run dev
# ✔ build 명령 · npm run build
# package.json 에 스크립트 추가
# "tauri": "tauri"
npm run tauri dev
// vite.config.ts — Tauri 와 같이 쓸 때 권장 설정
export default defineConfig({
clearScreen: false, // Rust 에러가 지워지지 않게
server: {
port: 1420,
strictPort: true, // 포트가 밀리면 앱이 흰 화면 ★
watch: { ignored: ["**/src-tauri/**"] }, // 무한 재빌드 방지 ★
},
// 배포 시 상대 경로로 (tauri://localhost 에서 로드되므로)
base: "./",
build: {
target: "esnext", // WebView2·WKWebView 는 최신입니다
minify: "esbuild",
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
});
개발이 답답할 때 — 세 가지 처방
| 증상 | 원인 · 처방 |
|---|---|
| 흰 화면만 뜸 | devUrl 포트 불일치 · base 미설정 |
| Rust 재빌드 무한 반복 | Vite watch 가 src-tauri 를 감시 |
| 첫 빌드 3분 | 정상 — 이후 증분은 수 초 |
| 콘솔이 안 보임 | 창 우클릭 → 검사 (dev 빌드만) |
Rust println! 안 보임 | 터미널에 나옵니다(창 아님) |
| 변경이 반영 안 됨 | cargo clean 후 재시도 |
# 개발자 도구를 배포본에서도 켜고 싶다면 (내부 도구일 때만)
# src-tauri/Cargo.toml
# [dependencies]
# tauri = { version = "2", features = ["devtools"] }
# Rust 컴파일이 너무 느릴 때 — 링커를 바꿉니다 (🛠️ b09)
# .cargo/config.toml
# [target.x86_64-pc-windows-msvc]
# rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# 개발 중 최적화를 조금 켜면 앱이 훨씬 부드럽습니다
# src-tauri/Cargo.toml
# [profile.dev]
# opt-level = 1
# [profile.dev.package."*"] # 의존성은 최적화 (한 번만 컴파일)
# opt-level = 3 # ★ 이미지·압축 쓰는 앱에서 체감 큼
npm run tauri info 를 습관으로 — Rust·Node·WebView 버전, OS,
플러그인 버전 불일치까지 한 번에 보여 줍니다.
이슈를 올릴 때도 이 출력을 붙이면 답이 빨리 옵니다.
webkit2gtk-4.1 을 챙기면 됩니다.
npm create tauri-app@latest → npm run tauri dev 두 줄로 창이 뜹니다.
프런트는 src/, 앱은 src-tauri/ 이고 실제 코드는 lib.rs 에 씁니다.
identifier 는 처음에 제대로 정하세요 — 나중에 바꾸면 사용자 데이터를 잃습니다.Commands · IPC — JS 에서 Rust 함수를 부르는 법
Tauri 앱의 거의 모든 코드는 결국 이 한 줄로 수렴합니다 — await invoke("함수이름", { 인자 }).
Rust 함수 위에 #[tauri::command] 를 붙이고 등록해 두면, 프런트에서 비동기 함수처럼 부를 수 있습니다.
인자와 반환값은 serde 가 JSON 으로 자동 변환합니다. 이 섹션에서 동기·비동기·에러·스트리밍 네 가지 형태를 전부 봅니다.
가장 기본 — 동기 command
// src-tauri/src/lib.rs
use serde::{Serialize, Deserialize};
// ① 인자와 반환값에 쓸 타입 — derive 두 줄이면 끝
#[derive(Serialize)]
struct FileInfo {
name: String,
size: u64,
is_dir: bool,
}
// ② 함수 위에 매크로 한 줄
#[tauri::command]
fn list_dir(path: String) -> Result<Vec<FileInfo>, String> {
let mut out = Vec::new();
// ❗ map_err 로 std 에러를 String 으로 — E 도 Serialize 여야 합니다
for e in std::fs::read_dir(&path).map_err(|e| e.to_string())? {
let e = e.map_err(|e| e.to_string())?;
let m = e.metadata().map_err(|e| e.to_string())?;
out.push(FileInfo {
name: e.file_name().to_string_lossy().into_owned(),
size: m.len(),
is_dir: m.is_dir(),
});
}
Ok(out)
}
// ③ 등록 — 여기 빠뜨리면 "not found" 가 납니다 ★
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![list_dir])
.run(tauri::generate_context!())
.unwrap();
}
프런트 쪽 — 그냥 async 함수입니다
import { invoke } from '@tauri-apps/api/core'
// Rust 의 FileInfo 와 모양을 맞춘 타입 (u08 에서 자동 생성합니다)
type FileInfo = { name: string; size: number; isDir: boolean }
async function load(path: string) {
try {
// ★ 인자 키는 camelCase 로 씁니다 — Rust 는 snake_case 로 받습니다
const items = await invoke<FileInfo[]>('list_dir', { path })
console.log(items.length, '개')
return items
} catch (err) {
// Err(String) 이 여기로 throw 됩니다
console.error('실패:', err)
return []
}
}
// 인자 이름 규칙을 바꾸고 싶다면
#[tauri::command(rename_all = "snake_case")]
fn list_dir(root_path: String) { } // JS 도 { root_path: ... }
// 기본값은 camelCase ↔ snake_case 자동 변환입니다
// JS invoke('f', { rootPath: 'x' })
// Rust fn f(root_path: String) ✅ 붙습니다
invalid args `rootPath` for command 가 뜹니다.
JS 는 camelCase, Rust 는 snake_case 가 기본 규칙이고,
구조체 필드도 #[serde(rename_all = "camelCase")] 를 붙여야
JS 쪽에서 isDir 로 보입니다(안 붙이면 is_dir).
이렇게도 쓸 수 있다 — 비동기 · 에러 타입 · AppHandle 🟡 중급
async command — 화면이 멈추지 않게 ★
// ❌ 동기 command 안에서 3초 걸리는 일을 하면
// 그동안 창이 얼어붙습니다 (메인 스레드에서 실행되므로)
#[tauri::command]
fn slow_bad() -> u32 {
std::thread::sleep(std::time::Duration::from_secs(3)); // 창 멈춤
42
}
// ✅ ① async 로 선언하면 별도 태스크에서 돕니다
#[tauri::command]
async fn slow_good() -> u32 {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
42
}
// ✅ ② CPU 를 진짜 쓰는 일은 블로킹 풀로 보냅니다 (⚡ e05)
#[tauri::command]
async fn hash_file(path: String) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
Ok(format!("{:x}", md5_like(&bytes)))
})
.await
.map_err(|e| e.to_string())?
}
// ✅ ③ 여러 파일을 병렬로 — rayon (⚡ e04)
#[tauri::command]
async fn hash_many(paths: Vec<String>) -> Vec<String> {
use rayon::prelude::*;
tauri::async_runtime::spawn_blocking(move || {
paths.par_iter().map(|p| hash_one(p)).collect()
}).await.unwrap()
}
async 로 만듭니다.
그리고 CPU 를 태우는 일이면 spawn_blocking 안에 넣습니다.
async 로만 선언하고 그 안에서 동기 파일 I/O 를 하면 비동기 런타임 스레드를 막습니다.
에러를 제대로 — String 말고 타입으로
use serde::Serialize;
// thiserror 로 에러 타입을 만들고 (🔐 o09)
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("파일을 찾을 수 없습니다: {0}")]
NotFound(String),
#[error("권한이 없습니다: {0}")]
Denied(String),
#[error("입출력 오류: {0}")]
Io(#[from] std::io::Error),
}
// ★ Tauri 로 넘기려면 Serialize 가 필요합니다 — 수동 구현이 정석
impl Serialize for AppError {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
type Result<T> = std::result::Result<T, AppError>;
#[tauri::command]
fn read_note(id: u32) -> Result<String> {
let p = format!("notes/{id}.md");
// ? 하나로 전파됩니다 — map_err 도배가 사라집니다 ★
Ok(std::fs::read_to_string(p)?)
}
// 프런트에서는 문자열로 도착합니다
try { await invoke('read_note', { id: 3 }) }
catch (e) { alert(e) } // "파일을 찾을 수 없습니다: notes/3.md"
// 구조화해서 보내고 싶다면 Rust 에서
// #[derive(Serialize)] #[serde(tag = "kind", content = "msg")]
// 로 만들면 JS 가 { kind: "NotFound", msg: "..." } 로 받습니다 ★
실무에선 이렇게 — Channel 로 진행률 스트리밍 🔴 실무
파일 1만 개를 스캔하며 진행률 보내기 ★
use tauri::ipc::Channel;
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
enum ScanEvent {
Started { total: usize },
Progress { done: usize, current: String },
Finished { took_ms: u128 },
}
#[tauri::command]
async fn scan(root: String, on_event: Channel<ScanEvent>) -> Result<(), String> {
let t0 = std::time::Instant::now();
let files: Vec<_> = walkdir::WalkDir::new(&root)
.into_iter().filter_map(|e| e.ok()).collect();
on_event.send(ScanEvent::Started { total: files.len() })
.map_err(|e| e.to_string())?;
for (i, f) in files.iter().enumerate() {
// 무거운 처리…
if i % 100 == 0 { // ★ 매번 보내면 IPC 가 병목
let _ = on_event.send(ScanEvent::Progress {
done: i,
current: f.path().display().to_string(),
});
}
}
let _ = on_event.send(ScanEvent::Finished { took_ms: t0.elapsed().as_millis() });
Ok(())
}
프런트 — 콜백 하나만 넘기면 됩니다
import { invoke, Channel } from '@tauri-apps/api/core'
type ScanEvent =
| { event: 'started'; data: { total: number } }
| { event: 'progress'; data: { done: number; current: string } }
| { event: 'finished'; data: { tookMs: number } }
const onEvent = new Channel<ScanEvent>()
onEvent.onmessage = (m) => {
switch (m.event) {
case 'started': total.value = m.data.total; break
case 'progress': done.value = m.data.done
current.value = m.data.current; break
case 'finished': alert(`끝! ${m.data.tookMs}ms`); break
}
}
await invoke('scan', { root: 'D:/data', onEvent }) // 키 이름 주의 ★
tauri::ipc::Response::new(Vec<u8>) 로 바이트 그대로 보낼 수 있습니다.
한 단계 더 — AppHandle · Window 를 인자로 받기 🧬 전문가
command 가 받을 수 있는 특별한 인자들
use tauri::{AppHandle, Manager, Runtime, WebviewWindow, State};
#[tauri::command]
async fn magic<R: Runtime>(
app: AppHandle<R>, // 앱 전체 핸들 — 어디서든 창·상태·플러그인 접근
window: WebviewWindow<R>, // 이 invoke 를 부른 창
state: State<'_, MyState>, // manage() 로 넣어 둔 상태 (u04)
name: String, // 실제 인자는 뒤에
) -> Result<(), String> {
// 이 창의 제목 바꾸기
window.set_title(&format!("{name} 처리 중")).map_err(|e| e.to_string())?;
// 앱 데이터 폴더 (OS 별로 알아서 다릅니다) ★
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).ok();
// 다른 창 찾기
if let Some(w) = app.get_webview_window("settings") {
w.set_focus().ok();
}
Ok(())
}
invoke('magic', { name: 'x' }) 만 하면 됩니다.
순서 규칙은 없지만 특별 인자를 앞에 두는 것이 관례입니다.
경로 API — 하드코딩하면 배포 때 터집니다 ★
use tauri::Manager;
let p = app.path();
p.app_data_dir()?; // 설정·DB — Win: %APPDATA%\com.x.y
// mac: ~/Library/Application Support/com.x.y
// Linux: ~/.local/share/com.x.y
p.app_config_dir()?; // 설정 전용
p.app_cache_dir()?; // 지워져도 되는 것
p.app_log_dir()?; // 로그
p.download_dir()?; // 사용자 다운로드 폴더
p.resource_dir()?; // ★ 앱에 번들된 리소스 (읽기 전용)
p.temp_dir()?;
// 번들된 리소스 읽기 — tauri.conf.json 의 bundle.resources 에 넣은 파일
let sql = std::fs::read_to_string(p.resource_dir()?.join("schema.sql"))?;
// tauri.conf.json — 리소스를 앱에 같이 싣기
{
"bundle": {
"resources": ["assets/schema.sql", "assets/rules/*.json"]
}
}
./assets/x.json 이 그냥 열립니다.
배포본에서는 작업 폴더가 어디일지 모릅니다.
파일 경로는 반드시 app.path() 를 거치세요.
#[tauri::command] + generate_handler! 등록 + invoke() 세 조각이 전부입니다.
16 ms 넘는 일은 async, CPU 쓰는 일은 spawn_blocking.
에러는 thiserror + Serialize 로 타입화하고,
진행률은 Channel<T> 로 흘려보내되 묶어서 보냅니다.
파일 경로는 언제나 app.path() 로 얻으세요.상태 · 이벤트 — 앱이 기억하고 서로에게 알립니다
command 는 부를 때마다 새로 실행되는 함수입니다. DB 연결 · 설정 · 캐시처럼 앱이 살아 있는 내내 유지할 것은
manage() 로 Tauri 에 맡깁니다. 그리고 Rust 쪽에서 먼저 말을 걸어야 할 때(파일이 바뀌었다, 다운로드가 끝났다)는
이벤트를 씁니다. 이 둘을 알면 “화면이 요청할 때만 도는 앱”에서 “스스로 움직이는 앱”으로 넘어갑니다.
상태 만들기 — manage 한 줄
use std::sync::Mutex;
use std::collections::HashMap;
use tauri::{Manager, State};
// ① 앱이 기억할 것
#[derive(Default)]
struct Counter(Mutex<u32>);
struct AppState {
db: sqlx::SqlitePool, // 비동기 풀은 이미 내부 동기화됨
cache: Mutex<HashMap<String, Vec<u8>>>,
}
pub fn run() {
tauri::Builder::default()
.setup(|app| {
// ② 앱 시작 시 한 번 만들어 맡깁니다
let pool = tauri::async_runtime::block_on(async {
sqlx::SqlitePool::connect("sqlite:app.db").await
})?;
app.manage(AppState { db: pool, cache: Default::default() });
app.manage(Counter::default()); // 타입당 하나씩 ★
Ok(())
})
.invoke_handler(tauri::generate_handler![bump, cached_read])
.run(tauri::generate_context!())
.unwrap();
}
// ③ command 인자로 꺼내 씁니다 — 타입으로 찾습니다
#[tauri::command]
fn bump(counter: State<'_, Counter>) -> u32 {
let mut n = counter.0.lock().unwrap();
*n += 1;
*n
}
manage 는 타입당 하나입니다 — 같은 타입을 두 번 manage 하면
두 번째가 무시됩니다(디버그 빌드에서는 패닉).
여러 개를 담고 싶으면 구조체 하나에 모아 담거나, 서로 다른 뉴타입으로 감싸세요.
그리고 State<T> 로 등록 안 한 타입을 꺼내면 런타임 패닉입니다.
비동기 command 에서 락을 잡을 때 ★
// ❌ std::sync::Mutex 가드를 .await 넘어까지 들고 있으면
// "future is not Send" 컴파일 에러가 납니다
#[tauri::command]
async fn bad(state: State<'_, AppState>) -> Result<(), String> {
let mut c = state.cache.lock().unwrap();
some_async().await; // ❌ 여기서 막힙니다
c.clear();
Ok(())
}
// ✅ ① 락 구간을 블록으로 끊습니다 (가장 흔한 해법)
#[tauri::command]
async fn ok1(state: State<'_, AppState>) -> Result<(), String> {
let snapshot = {
let c = state.cache.lock().unwrap();
c.keys().cloned().collect::<Vec<_>>()
}; // ★ 여기서 가드가 사라집니다
some_async().await;
Ok(())
}
// ✅ ② 정말로 await 를 건너 들고 있어야 하면 tokio::sync::Mutex
use tokio::sync::Mutex as AsyncMutex;
struct AppState2 { cache: AsyncMutex<HashMap<String, Vec<u8>>> }
#[tauri::command]
async fn ok2(state: State<'_, AppState2>) -> Result<(), String> {
let mut c = state.cache.lock().await; // 비동기 락
some_async().await; // ✅ 가능
c.clear();
Ok(())
}
std::sync::Mutex 입니다(훨씬 빠릅니다).
tokio::sync::Mutex 는 “락을 잡은 채로 .await 해야 할 때만” 씁니다.
그런 상황 자체가 대개 설계 문제이니, 먼저 구간을 쪼갤 수 있는지 보세요(⚡ e06).
이렇게도 쓸 수 있다 — 이벤트로 Rust 가 먼저 말 걸기 🟡 중급
Rust → JS 방송
use tauri::{AppHandle, Emitter, Manager};
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadDone { file_name: String, bytes: u64 }
// ① 모든 창에 방송
fn notify_all(app: &AppHandle) {
app.emit("download-done", DownloadDone {
file_name: "report.pdf".into(), bytes: 12_345,
}).ok();
}
// ② 특정 창에만
fn notify_one(app: &AppHandle) {
app.emit_to("settings", "config-reloaded", ()).ok();
}
// ③ 조건으로 고르기
fn notify_some(app: &AppHandle) {
app.emit_filter("tick", 1u32, |t| {
matches!(t, tauri::EventTarget::WebviewWindow { label } if label.starts_with("doc-"))
}).ok();
}
// ④ 백그라운드 스레드에서 주기적으로 — AppHandle 은 Clone 됩니다 ★
fn start_watcher(app: AppHandle) {
std::thread::spawn(move || loop {
std::thread::sleep(std::time::Duration::from_secs(5));
let _ = app.emit("heartbeat", chrono_now());
});
}
JS 쪽 구독 — 해제를 잊지 마세요 ★
import { listen, once, emit } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { onMounted, onUnmounted } from 'vue'
type DownloadDone = { fileName: string; bytes: number }
let un: (() => void) | null = null
onMounted(async () => {
// listen 은 '해제 함수'를 돌려줍니다
un = await listen<DownloadDone>('download-done', (e) => {
console.log(e.payload.fileName, e.payload.bytes)
// e.id 이벤트 id
// e.event 'download-done'
})
})
onUnmounted(() => un?.()) // ★ 안 부르면 콜백이 계속 쌓입니다
// 한 번만 받기
await once('app-ready', () => console.log('준비됨'))
// JS → Rust · JS → 다른 창
await emit('ui-action', { kind: 'refresh' })
// 이 창에만 국한된 이벤트
const w = getCurrentWindow()
await w.listen('tauri://resize', (e) => console.log(e.payload))
await w.emit('local-only', 1)
// Rust 에서 JS 가 보낸 이벤트 받기
use tauri::Listener;
app.listen("ui-action", |event| {
println!("payload = {}", event.payload()); // JSON 문자열입니다
});
invoke 로 다시 읽어 오게 하세요.
이 패턴이 훨씬 튼튼합니다.
실무에선 이렇게 — Tauri 내장 이벤트와 창 생명주기 🔴 실무
알아 두면 쓸모 있는 내장 이벤트
| 이벤트 | 언제 |
|---|---|
tauri://close-requested | 창 닫기 버튼 · 가로챌 수 있음 |
tauri://destroyed | 창이 실제로 사라짐 |
tauri://focus / blur | 포커스 변화 |
tauri://resize / move | 크기·위치 변경 |
tauri://drag-drop | 파일 드롭 (u05) |
tauri://theme-changed | 다크·라이트 전환 |
tauri://window-created | 새 창 생성 |
// "저장 안 한 게 있는데 닫으려 할 때" 막기
import { getCurrentWindow } from '@tauri-apps/api/window'
const w = getCurrentWindow()
await w.onCloseRequested(async (e) => {
if (dirty.value) {
e.preventDefault() // ★ 닫기를 취소
const ok = await confirm('저장하지 않은 변경이 있습니다. 닫을까요?')
if (ok) await w.destroy() // close() 아니라 destroy() ★
}
})
Rust 쪽에서 창 생명주기 다루기
use tauri::{Manager, WindowEvent};
tauri::Builder::default()
.on_window_event(|window, event| match event {
WindowEvent::CloseRequested { api, .. } => {
// 트레이 앱: 닫기를 '숨김'으로 바꿉니다 ★ (u05)
if window.label() == "main" {
api.prevent_close();
window.hide().ok();
}
}
WindowEvent::Focused(true) => {
window.emit("app-focused", ()).ok();
}
WindowEvent::Destroyed => {
println!("{} 창이 사라졌습니다", window.label());
}
_ => {}
})
.setup(|app| {
// 앱 종료 직전 정리 작업
let handle = app.handle().clone();
app.listen("save-all", move |_| {
let _ = flush_db(&handle);
});
Ok(())
})
.build(tauri::generate_context!())
.unwrap()
.run(|_app, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
api.prevent_exit(); // 창이 다 닫혀도 앱은 살아 있게 ★
}
});
CloseRequested 에서 prevent_close + hide,
그리고 ExitRequested 에서 prevent_exit.
이 둘이 없으면 사용자가 X 를 누른 순간 앱이 완전히 죽습니다.
macOS 는 관례가 달라서(창을 닫아도 앱은 Dock 에 남음) 플랫폼별로 나누기도 합니다.
app.manage() 로 맡기고 State<T> 로 꺼내 씁니다(타입당 하나).
락은 짧게, .await 를 넘기지 않게 블록으로 끊으세요.
Rust 가 먼저 말을 걸 때는 emit, 특정 창은 emit_to.
이벤트에는 “바뀌었다”만 싣고 데이터는 다시 invoke 로 가져오는 것이 훨씬 안전합니다.창 · 메뉴 · 트레이 — 웹 페이지가 아니라 앱처럼 보이게
브라우저 탭과 데스크톱 앱을 가르는 것은 결국 여러 개의 창 · 네이티브 메뉴 · 시스템 트레이 · 전역 단축키 · 파일 드롭 입니다. 이것들은 웹 기술로는 흉내조차 못 냅니다. Tauri 는 전부 Rust API 로 제공하고, 일부는 JS 에서도 부를 수 있습니다. 이 섹션에서 “백그라운드에 상주하다가 단축키로 튀어나오는 앱” 을 만드는 데 필요한 조각을 모읍니다.
창을 코드로 만들기 — WebviewWindowBuilder
use tauri::{WebviewUrl, WebviewWindowBuilder, Manager};
#[tauri::command]
async fn open_settings(app: tauri::AppHandle) -> Result<(), String> {
// 이미 열려 있으면 포커스만 ★ 중복 창 방지의 정석
if let Some(w) = app.get_webview_window("settings") {
w.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
WebviewWindowBuilder::new(
&app,
"settings", // label — 유일해야 합니다
WebviewUrl::App("settings.html".into()), // 앱 내부 경로
// WebviewUrl::External("https://…".parse().unwrap()) // 외부 URL
)
.title("설정")
.inner_size(560.0, 420.0)
.min_inner_size(400.0, 300.0)
.resizable(true)
.center()
.always_on_top(false)
.decorations(true) // false 로 하면 제목표시줄 없는 커스텀 창
.transparent(false) // 투명 창 (macOS 는 별도 설정 필요)
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
// JS 에서도 만들 수 있습니다
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
const w = new WebviewWindow('viewer', {
url: '/viewer?id=42',
title: '미리보기', width: 900, height: 600,
})
w.once('tauri://created', () => console.log('열림'))
w.once('tauri://error', (e) => console.error(e))
SPA 라우터를 쓰는 앱의 창 분리 요령
문제 — Vue Router 를 쓰면 settings.html 이라는 파일이 없습니다.
방법 ① 해시/쿼리로 구분 (가장 간단) ★
WebviewUrl::App("index.html#/settings".into())
→ 프런트에서 라우터가 알아서 그립니다
방법 ② 창 label 로 분기
const label = getCurrentWindow().label
if (label === 'settings') router.push('/settings')
방법 ③ Vite 멀티 페이지 (진짜로 파일을 나눔)
vite.config.ts
build: { rollupOptions: { input: {
main: 'index.html', settings: 'settings.html' } } }
→ 창마다 번들이 나뉘어 메모리가 절약됩니다
★ 창이 3개 이상 되면 ③ 이 낫습니다.
창 하나마다 WebView 인스턴스가 하나씩 뜨므로
같은 거대한 번들을 창 수만큼 파싱하게 됩니다.
tauri-plugin-window-state 를 넣으면 끝입니다.
cargo add tauri-plugin-window-state 후 .plugin(tauri_plugin_window_state::Builder::default().build()).
“앱을 껐다 켜면 창이 처음 자리로 돌아간다” 는 불만이 이 한 줄로 사라집니다.
이렇게도 쓸 수 있다 — 네이티브 메뉴와 시스템 트레이 🟡 중급
메뉴 — 만들고, 붙이고, 클릭을 받습니다
use tauri::menu::{Menu, MenuItem, Submenu, PredefinedMenuItem, MenuEvent};
use tauri::Manager;
fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let new_item = MenuItem::with_id(app, "new", "새로 만들기", true, Some("CmdOrCtrl+N"))?;
let open_item = MenuItem::with_id(app, "open", "열기…", true, Some("CmdOrCtrl+O"))?;
let quit_item = PredefinedMenuItem::quit(app, Some("종료"))?;
let file = Submenu::with_items(app, "파일", true, &[
&new_item, &open_item,
&PredefinedMenuItem::separator(app)?,
&quit_item,
])?;
let edit = Submenu::with_items(app, "편집", true, &[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::copy(app, None)?, // ★ 이거 없으면 Ctrl+C 도 안 됩니다
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
])?;
Menu::with_items(app, &[&file, &edit])
}
// 등록 + 클릭 처리
tauri::Builder::default()
.setup(|app| {
let menu = build_menu(app.handle())?;
app.set_menu(menu)?; // macOS: 앱 전역 / Win·Linux: 창에 붙음
Ok(())
})
.on_menu_event(|app, event: MenuEvent| match event.id().as_ref() {
"new" => { app.emit("menu:new", ()).ok(); }
"open" => { app.emit("menu:open", ()).ok(); }
_ => {}
})
PredefinedMenuItem::copy/paste/select_all 을 넣지 않으면 동작하지 않습니다.
메뉴를 커스터마이즈할 땐 편집 서브메뉴를 반드시 포함하세요.
시스템 트레이 — 상주 앱의 얼굴
use tauri::tray::{TrayIconBuilder, TrayIconEvent, MouseButton, MouseButtonState};
use tauri::menu::{Menu, MenuItem};
use tauri::Manager;
.setup(|app| {
let show = MenuItem::with_id(app, "show", "창 열기", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "종료", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
TrayIconBuilder::with_id("main-tray")
.icon(app.default_window_icon().unwrap().clone())
.tooltip("My App — 대기 중")
.menu(&menu)
.show_menu_on_left_click(false) // 좌클릭은 창 열기로 쓰려고 ★
.on_menu_event(|app, e| match e.id().as_ref() {
"show" => { if let Some(w) = app.get_webview_window("main") {
w.show().ok(); w.set_focus().ok(); } }
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up, ..
} = event {
let app = tray.app_handle();
if let Some(w) = app.get_webview_window("main") {
if w.is_visible().unwrap_or(false) { w.hide().ok(); }
else { w.show().ok(); w.set_focus().ok(); }
}
}
})
.build(app)?;
Ok(())
})
libayatana-appindicator3-dev 가 필요하고,
GNOME 은 확장(AppIndicator Support)이 있어야 표시됩니다.
“리눅스에서 트레이는 있을 수도 없을 수도 있다” 고 가정하고,
트레이 없이도 앱을 쓸 수 있게 만들어 두세요.
실무에선 이렇게 — 전역 단축키 · 알림 · 파일 드롭 · 단일 인스턴스 🔴 실무
전역 단축키 + 알림
cd src-tauri
cargo add tauri-plugin-global-shortcut tauri-plugin-notification
cd .. && npm i @tauri-apps/plugin-global-shortcut @tauri-apps/plugin-notification
use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState};
let toggle = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space);
tauri::Builder::default()
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, sc, event| {
if sc == &toggle && event.state() == ShortcutState::Pressed {
if let Some(w) = app.get_webview_window("main") {
if w.is_visible().unwrap_or(false) { w.hide().ok(); }
else { w.show().ok(); w.set_focus().ok(); }
}
}
})
.build(),
)
.setup(move |app| {
app.global_shortcut().register(toggle)?; // ★ 등록해야 동작합니다
Ok(())
})
import { sendNotification, isPermissionGranted,
requestPermission } from '@tauri-apps/plugin-notification'
let ok = await isPermissionGranted()
if (!ok) ok = (await requestPermission()) === 'granted'
if (ok) sendNotification({ title: '완료', body: '1,204개 파일 정리됨' })
register 가 실패하거나 조용히 안 먹습니다.
실패를 사용자에게 알리고 다른 조합을 고르게 하세요.
그리고 Windows 알림은 앱이 “시작 메뉴에 등록”돼야 제목이 제대로 뜹니다(dev 에서는 다르게 보입니다).
파일 드롭 + 중복 실행 막기
use tauri::{DragDropEvent, WindowEvent};
.on_window_event(|window, event| {
if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, position }) = event {
println!("{}개 파일이 {:?} 에 떨어짐", paths.len(), position);
window.emit("files-dropped", paths).ok();
}
})
// JS 쪽 — HTML5 dragover 기본 동작을 반드시 막아야 합니다 ★
import { getCurrentWebview } from '@tauri-apps/api/webview'
await getCurrentWebview().onDragDropEvent((e) => {
if (e.payload.type === 'over') hover.value = true
if (e.payload.type === 'drop') { hover.value = false; add(e.payload.paths) }
if (e.payload.type === 'leave') hover.value = false
})
// 중복 실행 방지 — 두 번째 실행은 첫 번째 창을 깨우고 종료
// cargo add tauri-plugin-single-instance
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
// argv 에는 두 번째 실행의 인자가 들어옵니다 (파일 연결 처리에 유용) ★
if let Some(w) = app.get_webview_window("main") {
w.show().ok(); w.unminimize().ok(); w.set_focus().ok();
}
app.emit("second-instance", argv).ok();
}))
single-instance 플러그인은
반드시 가장 먼저 등록해야 합니다(공식 문서 명시).
나중에 등록하면 두 번째 프로세스가 이미 다른 초기화를 해 버린 뒤라
파일 잠금·포트 충돌이 생깁니다.
WebviewWindowBuilder, 중복 방지는 get_webview_window 로 먼저 확인.
메뉴를 직접 만들 땐 편집 서브메뉴(복사·붙여넣기)를 꼭 포함하세요 — macOS 에서 단축키가 죽습니다.
상주 앱은 트레이 + prevent_close + 전역 단축키 세 조각이면 완성입니다.
single-instance 는 가장 먼저 등록합니다.플러그인 · 권한 — 기본은 전부 잠겨 있습니다
Tauri 2 에서는 파일 읽기 · 대화상자 · HTTP · DB 가 전부 플러그인으로 나뉘어 있고,
플러그인을 깔았다고 바로 쓸 수 있는 것도 아닙니다. capabilities/*.json 에 “이 창은 이 기능을 써도 된다”고 적어야 통과합니다.
처음엔 번거롭지만, 이 구조 덕분에 “XSS 가 곧 파일 삭제”가 되지 않습니다.
이 섹션에서 공식 플러그인 지도 · ACL 읽는 법 · scope 걸기 · 직접 플러그인 만들기를 다룹니다.
공식 플러그인 지도 — 자주 쓰는 것부터
| 플러그인 | 하는 일 |
|---|---|
fs | 파일 읽기·쓰기·감시(scope 필수) |
dialog | 파일 열기·저장·확인 창 |
opener | URL·파일을 기본 앱으로 열기 |
shell | 외부 명령·sidecar 실행 (u07) |
http | CORS 없는 HTTP 요청 ★ |
store | 키-값 설정 저장(JSON) |
sql | SQLite · MySQL · Postgres |
updater | 자동 업데이트 (u09) |
notification | 데스크톱 알림 |
clipboard-manager | 클립보드 읽기·쓰기 |
global-shortcut | 전역 단축키 (u05) |
window-state | 창 크기·위치 기억 |
single-instance | 중복 실행 방지 |
log | 파일·콘솔 로깅 |
os · process | OS 정보 · 재시작·종료 |
deep-link | myapp:// 프로토콜 처리 |
autostart | 부팅 시 자동 실행 |
http 플러그인이 의외로 중요합니다 — WebView 안의 fetch 는
브라우저와 똑같이 CORS 제약을 받습니다.
@tauri-apps/plugin-http 의 fetch 는 Rust 쪽(reqwest)에서 요청하므로
CORS 가 없습니다. 대신 어느 도메인을 허용할지 scope 로 적어야 합니다.
플러그인 넣는 절차 — 항상 세 곳을 고칩니다
# ① Rust 쪽 의존성 (src-tauri 안에서!)
cd src-tauri
cargo add tauri-plugin-fs tauri-plugin-dialog
cd ..
# ② JS 쪽 패키지
npm i @tauri-apps/plugin-fs @tauri-apps/plugin-dialog
// ③-1 lib.rs 에 등록
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
// ③-2 capabilities/default.json 에 권한 추가 ★ 여기를 빠뜨립니다
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "메인 창 기본 권한",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-mkdir",
{ "identifier": "fs:scope",
"allow": ["$APPDATA/**", "$DOCUMENT/MyApp/**"],
"deny": ["$APPDATA/secrets/**"] }
]
}
fs.readTextFile not allowed. Permissions associated with this command: fs:allow-read-text-file
처럼 필요한 permission 이름까지 정확히 찍혀 나옵니다.
그 문자열을 그대로 복사해 permissions 배열에 붙이면 끝입니다.
이렇게도 쓸 수 있다 — permission · scope · 플랫폼별 capability 🟡 중급
용어 세 개만 정리하면 다 읽힙니다
| 용어 | 뜻 |
|---|---|
| permission | “이 command 를 부를 수 있다” 한 장 |
| scope | permission 에 붙는 범위 제한(경로·URL) |
| permission set | permission 여러 장 묶음 (fs:default) |
| capability | “어느 창에 어느 permission” 매핑 |
이름 규칙
<플러그인>:<permission 이름>
fs:allow-read-file 개별 허용
fs:deny-webview-data 개별 거부 (deny 가 항상 이깁니다 ★)
fs:default 그 플러그인의 기본 묶음
core:default Tauri 코어 기본 (창 조작 등)
core:window:allow-set-title 코어도 세분화되어 있습니다
어디에서 이름을 찾나
· 플러그인 문서의 "Permissions" 표
· src-tauri/gen/schemas/*.json ← 자동 생성 (IDE 자동완성 ★)
· 실행 중 에러 메시지에 그대로 나옵니다
"$schema": "../gen/schemas/desktop-schema.json" 한 줄이면
VS Code 가 가능한 permission 이름을 전부 드롭다운으로 띄워 줍니다.
이 스키마는 tauri dev 를 한 번 돌리면 생성됩니다.
창마다 다른 권한 · 플랫폼마다 다른 권한
// capabilities/main.json — 메인 창은 넓게
{
"identifier": "main-caps",
"windows": ["main"],
"permissions": ["core:default", "fs:default", "sql:default"]
}
// capabilities/preview.json — 외부 콘텐츠를 띄우는 창은 최소로 ★
{
"identifier": "preview-caps",
"windows": ["preview-*"], // 와일드카드 가능
"permissions": ["core:window:allow-close"],
"remote": { // 외부 URL 을 띄울 때 필수
"urls": ["https://docs.example.com/*"]
}
}
// 플랫폼 한정
{
"identifier": "desktop-only",
"platforms": ["windows", "macOS", "linux"],
"windows": ["main"],
"permissions": ["global-shortcut:default", "tray:default"]
}
// platforms: ["android", "iOS"] 로 모바일 전용도 만듭니다 (u10)
remote.urls 를 좁히세요.
이걸 열어 두면 그 사이트의 스크립트가 여러분의 command 를 부를 수 있습니다.
광고나 서드파티 스크립트가 섞인 페이지라면 capability 를 아예 주지 않는 것이 정답입니다.
실무에선 이렇게 — 플러그인을 직접 만들기 🔴 실무
언제 플러그인을 만드나 — 대부분은 만들 필요가 없습니다
그냥 #[tauri::command] 로 충분한 경우 (90%)
· 이 앱에서만 쓰는 기능
· 상태 하나와 command 몇 개면 되는 것
플러그인으로 만들 값어치가 있는 경우
① 여러 앱에서 재사용한다 (사내 공통 인증 · 로깅)
② 초기화·정리 생명주기가 필요하다 (연결 풀 · 워커 스레드)
③ 플랫폼별 네이티브 코드가 필요하다 (Android Kotlin · iOS Swift)
④ 권한을 세분화해 배포하고 싶다 (ACL 을 같이 실음)
# 골격 생성
npx @tauri-apps/cli plugin new my-plugin
# → tauri-plugin-my-plugin/
# src/lib.rs · src/commands.rs · src/desktop.rs · src/mobile.rs
# permissions/ guest-js/ android/ ios/
최소 플러그인 한 개 — 30줄
// tauri-plugin-clip/src/lib.rs
use tauri::{plugin::{Builder, TauriPlugin}, Manager, Runtime, State};
use std::sync::Mutex;
#[derive(Default)]
struct History(Mutex<Vec<String>>);
#[tauri::command]
fn push(item: String, h: State<'_, History>) {
let mut v = h.0.lock().unwrap();
v.push(item);
if v.len() > 100 { v.remove(0); }
}
#[tauri::command]
fn recent(h: State<'_, History>) -> Vec<String> {
h.0.lock().unwrap().clone()
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("clip") // ★ 이 이름이 권한 접두사가 됩니다
.invoke_handler(tauri::generate_handler![push, recent])
.setup(|app, _api| {
app.manage(History::default()); // 플러그인 전용 상태
Ok(())
})
.on_event(|_app, event| {
if let tauri::RunEvent::Exit = event { /* 정리 */ }
})
.build()
}
# permissions/default.toml — 권한도 같이 배포합니다
[default]
description = "클립 기록 기본 권한"
permissions = ["allow-push", "allow-recent"]
# 사용하는 쪽 capability
# "permissions": ["clip:default"]
# JS 에서는
# await invoke('plugin:clip|push', { item: 'hi' }) ★ 이름 규칙
.plugin() 등록 + capability 권한 네 곳을 항상 같이 고칩니다.
막히면 에러 메시지에 찍힌 permission 이름을 그대로 복사해 넣으세요.
파일·네트워크는 반드시 scope 로 범위를 좁히고, 외부 URL 창에는 capability 를 주지 마세요.
플러그인 자작은 재사용·생명주기·네이티브 코드 셋 중 하나가 있을 때만 값어치가 있습니다.Sidecar — FFmpeg · 파이썬을 앱에 실어 보냅니다
Rust 로 다시 짜기엔 너무 큰 것들이 있습니다 — FFmpeg · yt-dlp · 파이썬 머신러닝 스크립트 · 사내 CLI 도구. Tauri 는 이런 외부 실행 파일을 설치본에 함께 넣고, 앱에서 실행하고, 출력을 실시간으로 받아오는 기능을 정식으로 제공합니다. 이것을 sidecar 라고 부릅니다. 파일 이름 규칙 하나만 지키면 크로스 플랫폼도 자동으로 처리됩니다.
① 파일 이름 규칙 — 여기가 전부입니다
# 내 타깃 트리플 확인
rustc -vV | grep host
# host: x86_64-pc-windows-msvc
# src-tauri/binaries/ 에 그 이름으로 둡니다
src-tauri/binaries/
ffmpeg-x86_64-pc-windows-msvc.exe # Windows
ffmpeg-x86_64-apple-darwin # macOS Intel
ffmpeg-aarch64-apple-darwin # macOS Apple Silicon
ffmpeg-x86_64-unknown-linux-gnu # Linux
# ★ 빌드할 때 현재 타깃에 맞는 것 하나만 번들에 들어갑니다
# ★ 이름에서 트리플을 떼고 "ffmpeg" 로 부릅니다
# 리눅스·macOS 는 실행 권한 확인
chmod +x src-tauri/binaries/ffmpeg-*
// ② tauri.conf.json — 번들에 포함시킵니다
{
"bundle": {
"externalBin": ["binaries/ffmpeg"] // 트리플 없이 적습니다 ★
}
}
// ③ capabilities/default.json — 실행 권한을 명시합니다
{
"permissions": [
{ "identifier": "shell:allow-execute",
"allow": [{ "name": "binaries/ffmpeg", "sidecar": true,
"args": true }] }
]
}
// args: true 는 "인자 자유" — 보안상 아래처럼 좁히는 편이 낫습니다
// "args": [ "-i", { "validator": "\\S+" }, "-vf", "scale=320:-1", "out.mp4" ]
shell:allow-execute 는
JS 가 직접 sidecar 를 띄울 때만 필요합니다.
실무 권장은 Rust command 안에서만 실행하고 JS 에는 권한을 주지 않는 것입니다 —
그래야 인자를 프런트가 조작할 수 없습니다(u01 의 원칙).
④ Rust 에서 실행하고 출력을 스트리밍
use tauri_plugin_shell::{ShellExt, process::CommandEvent};
use tauri::ipc::Channel;
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "text")]
enum Line { Out(String), Err(String), Done(i32) }
#[tauri::command]
async fn transcode(
app: tauri::AppHandle,
input: String,
on_line: Channel<Line>,
) -> Result<(), String> {
let (mut rx, mut child) = app
.shell()
.sidecar("ffmpeg").map_err(|e| e.to_string())?
.args(["-hide_banner", "-i", &input,
"-vf", "scale=1280:-2", "-y", "out.mp4"])
.spawn().map_err(|e| e.to_string())?;
while let Some(ev) = rx.recv().await {
match ev {
CommandEvent::Stdout(b) => {
let _ = on_line.send(Line::Out(String::from_utf8_lossy(&b).into()));
}
CommandEvent::Stderr(b) => {
// ★ ffmpeg 은 진행률을 stderr 로 보냅니다
let _ = on_line.send(Line::Err(String::from_utf8_lossy(&b).into()));
}
CommandEvent::Terminated(p) => {
let _ = on_line.send(Line::Done(p.code.unwrap_or(-1)));
break;
}
_ => {}
}
}
let _ = child.kill(); // ★ 혹시 남아 있으면 정리
Ok(())
}
stdin 으로 명령을 보낼 수도 있습니다 —
child.write(b"command\n") 로 파이썬 REPL 같은 장기 실행 프로세스와
계속 대화할 수 있습니다. 파이썬 모델 서버를 sidecar 로 띄우고
JSON 한 줄씩 주고받는 패턴이 머신러닝 데스크톱 앱에서 흔합니다.
이렇게도 쓸 수 있다 — 파이썬 스크립트를 sidecar 로 🟡 중급
파이썬을 단일 실행 파일로 굳혀서 싣기
# ① 파이썬 스크립트를 실행 파일로 (PyInstaller)
pip install pyinstaller
pyinstaller --onefile --name worker worker.py
# → dist/worker.exe (보통 15~40 MB, 넘파이 넣으면 100 MB+)
# ② 타깃 트리플 이름으로 복사
cp dist/worker.exe src-tauri/binaries/worker-x86_64-pc-windows-msvc.exe
# ③ tauri.conf.json 에 externalBin: ["binaries/worker"]
# 크기가 부담되면 대안
# · Nuitka (더 작고 빠름, 빌드가 까다로움)
# · 파이썬 임베더블 배포 + 스크립트 (폴더로 실음)
# · 아예 PyO3 로 Rust 에 파이썬을 임베드 (🐍 y08)
# worker.py — 한 줄씩 JSON 을 주고받는 구조가 가장 다루기 쉽습니다
import sys, json
for line in sys.stdin:
req = json.loads(line)
if req["op"] == "predict":
out = {"id": req["id"], "score": run_model(req["text"])}
print(json.dumps(out), flush=True) # ★ flush 필수
elif req["op"] == "quit":
break
flush=True 를 빠뜨리면 영원히 기다립니다 —
파이썬은 파이프로 출력할 때 버퍼링을 합니다.
Rust 쪽은 줄이 안 오니 계속 대기하고, 사용자에겐 “멈춘 앱”으로 보입니다.
python -u 또는 flush=True 를 반드시 쓰세요. Node 스크립트도 같은 문제가 있습니다.
장기 실행 sidecar 관리 — 상태에 담아 둡니다
use std::sync::Mutex;
use tauri_plugin_shell::process::CommandChild;
#[derive(Default)]
struct Worker(Mutex<Option<CommandChild>>);
#[tauri::command]
async fn worker_start(app: tauri::AppHandle, st: tauri::State<'_, Worker>)
-> Result<(), String>
{
if st.0.lock().unwrap().is_some() { return Ok(()); } // 이미 떠 있음
let (mut rx, child) = app.shell().sidecar("worker")
.map_err(|e| e.to_string())?
.spawn().map_err(|e| e.to_string())?;
*st.0.lock().unwrap() = Some(child);
let h = app.clone();
tauri::async_runtime::spawn(async move {
while let Some(ev) = rx.recv().await {
if let CommandEvent::Stdout(b) = ev {
let _ = h.emit("worker-line", String::from_utf8_lossy(&b).to_string());
}
}
});
Ok(())
}
#[tauri::command]
fn worker_send(st: tauri::State<'_, Worker>, json: String) -> Result<(), String> {
if let Some(c) = st.0.lock().unwrap().as_mut() {
c.write(format!("{json}\n").as_bytes()).map_err(|e| e.to_string())?;
}
Ok(())
}
// 앱 종료 시 반드시 정리 ★
fn on_exit(app: &tauri::AppHandle) {
let st: tauri::State<Worker> = app.state();
if let Some(c) = st.0.lock().unwrap().take() { let _ = c.kill(); }
}
실무에선 이렇게 — sidecar 를 쓸지 Rust 로 짤지 🔴 실무
판단표 — 대부분은 Rust crate 로 해결됩니다
| 하려는 일 | 권장 |
|---|---|
| 이미지 리사이즈·포맷 변환 | Rust image |
| ZIP · TAR 압축 | Rust zip |
| CSV · JSON 대용량 처리 | Rust csv·serde |
| SQLite | Rust rusqlite |
| PDF 생성 | Rust printpdf |
| 영상 트랜스코딩 | sidecar FFmpeg |
| 유튜브 다운로드 | sidecar yt-dlp |
| 딥러닝 추론 | sidecar 또는 ort(ONNX) |
| 사내 레거시 CLI | sidecar |
| OCR | sidecar Tesseract |
.onnx 로 내보내면
Rust ort 크레이트로 파이썬 없이 추론할 수 있습니다.
100 MB 짜리 파이썬 sidecar 가 사라집니다.
이미지 분류·임베딩·간단한 NLP 는 이 경로가 훨씬 깔끔합니다.
sidecar 를 쓸 때 각오해야 하는 것
| 문제 | 대응 |
|---|---|
| 설치본이 50~150 MB | 업데이트 부담 — 분리 다운로드 고려 |
| 백신 오탐 | PyInstaller 결과가 특히 잘 걸림 |
| 코드 서명 | sidecar 도 서명 대상(macOS 필수) |
| 플랫폼마다 바이너리 | CI 에서 OS 별로 받아 넣기 |
| 좀비 프로세스 | 종료 시 kill · pid 기록 |
| 라이선스 | FFmpeg GPL 빌드 주의 ★ |
# GitHub Actions — OS 별로 sidecar 바이너리를 받아 넣는 단계
- name: sidecar 준비 (Windows)
if: matrix.os == 'windows-latest'
run: |
curl -L -o ff.zip https://예제/ffmpeg-win64.zip
unzip ff.zip -d tmp
mkdir -p src-tauri/binaries
mv tmp/ffmpeg.exe src-tauri/binaries/ffmpeg-x86_64-pc-windows-msvc.exe
binaries/이름-타깃트리플 + externalBin + app.shell().sidecar() 세 조각입니다.
출력은 CommandEvent 로 한 줄씩 받아 Channel 로 흘려보내고,
종료 시 kill() 을 반드시 부르세요.
다만 Rust crate 로 되는 일이면 그쪽이 거의 언제나 낫습니다 — 용량·서명·백신 문제가 통째로 사라집니다.프런트엔드 연결 — 문자열 invoke 를 없앱니다
invoke('scan_dir', { rootPath }) 는 편하지만 전부 문자열입니다.
명령 이름을 오타 내도, 인자를 빠뜨려도, 반환 타입을 잘못 적어도 실행해 봐야 압니다.
Rust 쪽 시그니처를 바꿨을 때 프런트가 조용히 깨지는 것이 Tauri 프로젝트에서 가장 흔한 버그입니다.
이 섹션은 수동 래퍼 → tauri-specta 자동 생성까지, 그 구멍을 막는 방법을 다룹니다.
1단계 — 손으로 얇은 래퍼를 두는 것만으로도 절반은 해결
// src/api.ts — 앱 전체에서 invoke 를 부르는 유일한 파일 ★
import { invoke, Channel } from '@tauri-apps/api/core'
export type FileInfo = { name: string; size: number; isDir: boolean }
export type ScanEvent =
| { event: 'progress'; data: { done: number } }
| { event: 'finished'; data: { tookMs: number } }
export const api = {
listDir: (path: string) =>
invoke<FileInfo[]>('list_dir', { path }),
scan: (root: string, onEvent: Channel<ScanEvent>) =>
invoke<void>('scan', { root, onEvent }),
saveConfig: (cfg: { theme: 'light' | 'dark'; fontSize: number }) =>
invoke<void>('save_config', { cfg }),
}
// 쓰는 쪽 — 문자열이 사라집니다
import { api } from '@/api'
const items = await api.listDir('D:/data') // 타입 추론 ✅
invoke 는 api.ts 밖에서 부르지 않는다”
규칙 하나면, 명령 이름이 바뀌었을 때 고칠 곳이 한 파일입니다.
ESLint 로 강제할 수도 있습니다(no-restricted-imports 로 컴포넌트에서 @tauri-apps/api/core 금지).
2단계 — tauri-specta 로 자동 생성
# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
specta = { version = "2", features = ["derive"] }
specta-typescript = "0.0.9"
tauri-specta = { version = "2", features = ["derive", "typescript"] }
use tauri_specta::{collect_commands, Builder};
#[derive(serde::Serialize, specta::Type)] // ★ Type 을 추가
#[serde(rename_all = "camelCase")]
pub struct FileInfo { name: String, size: u64, is_dir: bool }
#[tauri::command]
#[specta::specta] // ★ 이 한 줄
fn list_dir(path: String) -> Result<Vec<FileInfo>, String> { todo!() }
pub fn run() {
let builder = Builder::<tauri::Wry>::new()
.commands(collect_commands![list_dir]);
// 개발 빌드에서만 파일을 씁니다 (배포본에는 코드가 안 들어감)
#[cfg(debug_assertions)]
builder.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
).expect("타입 생성 실패");
tauri::Builder::default()
.invoke_handler(builder.invoke_handler()) // ★ generate_handler 대신
.setup(move |app| { builder.mount_events(app); Ok(()) })
.run(tauri::generate_context!())
.unwrap();
}
// 자동 생성된 src/bindings.ts 를 그냥 씁니다
import { commands, type FileInfo } from '@/bindings'
const r = await commands.listDir('D:/data')
if (r.status === 'ok') console.log(r.data.length) // Result 가 그대로 ★
else console.error(r.error)
이렇게도 쓸 수 있다 — Vue · React 에서 쓰기 좋은 모양으로 🟡 중급
Vue — composable 하나로 감쌉니다
// src/composables/useScan.ts
import { ref, onUnmounted } from 'vue'
import { Channel } from '@tauri-apps/api/core'
import { api, type ScanEvent } from '@/api'
export function useScan() {
const running = ref(false)
const done = ref(0)
const total = ref(0)
let ch: Channel<ScanEvent> | null = null
async function start(root: string) {
if (running.value) return
running.value = true; done.value = 0
ch = new Channel<ScanEvent>()
ch.onmessage = (m) => {
if (m.event === 'progress') done.value = m.data.done
if (m.event === 'finished') running.value = false
}
try { await api.scan(root, ch) }
finally { running.value = false; ch = null }
}
onUnmounted(() => { ch = null }) // 화면이 사라지면 참조 해제
return { running, done, total, start }
}
// 이벤트 구독은 반드시 해제까지 한 쌍으로
import { listen } from '@tauri-apps/api/event'
export function useTauriEvent<T>(name: string, cb: (p: T) => void) {
let un: (() => void) | undefined
onMounted(async () => { un = await listen<T>(name, e => cb(e.payload)) })
onUnmounted(() => un?.())
}
React — TanStack Query 와 붙이면 캐시가 공짜
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api'
export function useDir(path: string) {
return useQuery({
queryKey: ['dir', path],
queryFn: () => api.listDir(path),
staleTime: 30_000, // 30초간 재요청 안 함
})
}
export function useRename() {
const qc = useQueryClient()
return useMutation({
mutationFn: (v: { id: number; name: string }) => api.rename(v),
onSuccess: () => qc.invalidateQueries({ queryKey: ['dir'] }),
})
}
// Rust 가 "바뀌었다"고 알려 오면 캐시만 무효화 ★ (u04 의 패턴)
useEffect(() => {
let un: (() => void) | undefined
listen('items-changed', () => qc.invalidateQueries({ queryKey: ['dir'] }))
.then(f => { un = f })
return () => un?.()
}, [])
invoke 는 네트워크 요청과 성격이 같습니다(비동기 · 실패 가능 · 캐시하고 싶음).
TanStack Query · Pinia Colada · SWR 를 그대로 얹으면
로딩·에러·재시도·캐시 무효화를 직접 짜지 않아도 됩니다.
실무에선 이렇게 — Vite 설정 · 개발 서버 vs 번들의 차이 🔴 실무
배포본에서만 깨지는 것들 — 원인은 거의 정해져 있습니다
| 증상 | 원인 |
|---|---|
| 흰 화면 · 404 | base 가 / — "./" 로 |
| 이미지·폰트 안 뜸 | 절대 경로 사용 · public/ 참조 |
환경변수 undefined | VITE_ 접두사 누락 |
| 동적 import 실패 | 코드 분할 청크 경로 |
| 폰트 CORS | 외부 CDN → 로컬로 내려받기 |
| CSP 차단 | app.security.csp 설정 |
// 개발과 배포를 코드에서 구분해야 할 때
if (import.meta.env.DEV) { /* devUrl 로 로드된 상태 */ }
// Tauri 가 넣어 주는 환경변수 (Rust 쪽)
// TAURI_ENV_PLATFORM windows | darwin | linux | android | ios
// TAURI_ENV_ARCH x86_64 | aarch64
// TAURI_ENV_DEBUG true | false
// TAURI_ENV_TARGET_TRIPLE
// 프런트에서 플랫폼 알기
import { platform, version } from '@tauri-apps/plugin-os'
console.log(await platform()) // 'windows' | 'macos' | 'linux' | …
CSP — 켜는 것이 정석입니다
// tauri.conf.json — 기본값 null 은 "CSP 없음" 입니다
{
"app": {
"security": {
"csp": {
"default-src": "'self'",
"img-src": "'self' asset: http://asset.localhost data:",
"style-src": "'self' 'unsafe-inline'",
"script-src": "'self'",
"connect-src": "'self' https://api.example.com ipc: http://ipc.localhost"
},
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/**", "$PICTURE/**"]
}
}
}
}
// 로컬 파일을 <img> 에 띄우려면 asset 프로토콜로 변환합니다
import { convertFileSrc } from '@tauri-apps/api/core'
const src = convertFileSrc('C:/Users/me/Pictures/a.jpg')
// → http://asset.localhost/... 형태 (WebView 가 로드할 수 있는 URL)
file:// 로는 이미지가 안 뜹니다 — WebView 의 보안 정책상 막혀 있습니다.
반드시 convertFileSrc 를 쓰고, assetProtocol.scope 에 그 폴더를 열어 둬야 합니다.
“썸네일이 개발에선 되는데 배포에선 안 나온다”의 90%가 이 scope 누락입니다.
invoke 는 api.ts 한 곳에서만 부르는 규칙부터 세우세요.
프로젝트가 커지면 tauri-specta 로 bindings.ts 를 자동 생성해 Rust 시그니처 변경을 tsc 가 잡게 합니다.
Vite 는 base: "./" · strictPort · src-tauri watch 제외 세 줄이 필수이고,
로컬 이미지는 convertFileSrc + assetProtocol.scope 로 띄웁니다.빌드 · 서명 · 업데이트 — 사용자에게 실제로 닿게 합니다
tauri build 한 줄이면 설치 파일이 나옵니다. 문제는 그 다음입니다 —
“이 앱은 신뢰할 수 없습니다” 경고 · macOS 의 “손상되었습니다” · 업데이트를 어떻게 밀어 넣나.
이 셋을 해결하지 않으면 앱이 만들어져도 아무도 쓰지 못합니다.
이 섹션은 산출물 종류 · 아이콘 · 코드 서명 · updater 플러그인 · GitHub Actions 까지 배포 전 구간을 다룹니다.
빌드 · 아이콘 · 크기 줄이기
# 아이콘 — 1024x1024 PNG 하나면 전부 만들어 줍니다 ★
npm run tauri icon ./app-icon.png
# → src-tauri/icons/ (32x32.png · 128x128.png · icon.icns · icon.ico · 안드로이드용까지)
# 빌드
npm run tauri build
# → src-tauri/target/release/bundle/
# 특정 형식만
npm run tauri build -- --bundles nsis # Windows: NSIS 만
npm run tauri build -- --bundles deb,appimage # Linux
npm run tauri build -- --bundles app,dmg # macOS
# macOS 유니버설 (Intel + Apple Silicon 한 파일)
rustup target add x86_64-apple-darwin aarch64-apple-darwin
npm run tauri build -- --target universal-apple-darwin
# src-tauri/Cargo.toml — 바이너리를 더 줄이고 싶다면 (🛠️ b10)
[profile.release]
opt-level = "s" # 크기 우선 ("z" 는 더 작지만 느려질 수 있음)
lto = true # 링크 타임 최적화 ★ 효과 큼
codegen-units = 1 # 최적화 기회 최대 (빌드는 느려짐)
panic = "abort" # 되감기 코드 제거
strip = true # 심볼 제거 ★ 수 MB 절약
# 이 네 줄로 보통 8 MB → 3.5 MB 정도가 됩니다
panic = "abort" 주의 — 패닉이 나면 즉시 프로세스가 죽습니다.
Tauri 는 command 안의 패닉을 잡아 주지 않으므로,
사용자 데이터를 다루는 앱이라면 unwrap() 을 먼저 다 없애고 켜세요.
아니면 이 줄만 빼도 됩니다(크기 차이는 수백 KB 수준).
코드 서명 — 없으면 사실상 배포 불가
// Windows — tauri.conf.json
{
"bundle": {
"windows": {
"certificateThumbprint": "A1B2C3…", // 인증서 지문
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
}
}
}
// 인증서 종류
// OV(조직 검증) 연 20~40만원 · SmartScreen 평판을 쌓아야 경고가 사라짐
// EV(확장 검증) 연 40~80만원 · 즉시 신뢰 · USB 토큰 또는 클라우드 HSM ★
// Azure Trusted Signing — 월 구독형, CI 친화적 (2024~ 권장 경로)
# macOS — 환경변수로 넘깁니다 (CI 에서도 동일)
export APPLE_CERTIFICATE="$(base64 -i cert.p12)" # base64 인코딩한 .p12
export APPLE_CERTIFICATE_PASSWORD="…"
export APPLE_SIGNING_IDENTITY="Developer ID Application: My Co (TEAMID)"
# 공증(notarize) — 애플 서버가 검사하고 티켓을 붙여 줍니다
export APPLE_ID="me@example.com"
export APPLE_PASSWORD="앱-암호" # 계정 암호 아님 (app-specific)
export APPLE_TEAM_ID="TEAMID"
npm run tauri build # 서명 + 공증까지 자동으로 수행
# 공증은 애플 서버 왕복이라 5~20분 걸립니다 ★
이렇게도 쓸 수 있다 — updater 로 자동 업데이트 붙이기 🟡 중급
설정 — 다섯 단계
# ① 키 쌍 생성 (비밀번호를 걸면 CI 에 password 도 넣어야 합니다)
npm run tauri signer generate -- -w ~/.tauri/myapp.key
# 출력된 "Public key" 를 복사해 둡니다
# ② 플러그인 추가
cd src-tauri && cargo add tauri-plugin-updater tauri-plugin-process && cd ..
npm i @tauri-apps/plugin-updater @tauri-apps/plugin-process
// ③ tauri.conf.json
{
"bundle": { "createUpdaterArtifacts": true },
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6…",
"endpoints": [
"https://github.com/me/myapp/releases/latest/download/latest.json"
],
"windows": { "installMode": "passive" }
}
}
}
// latest.json 이 이렇게 생겼습니다 (tauri-action 이 만들어 줍니다)
{
"version": "1.2.0",
"notes": "검색 속도 개선 · 트레이 아이콘 수정",
"pub_date": "2026-08-20T09:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "dW50cnVzdGVk…",
"url": "https://github.com/me/myapp/releases/download/v1.2.0/myapp_1.2.0_x64-setup.exe"
},
"darwin-aarch64": { "signature": "…", "url": "…app.tar.gz" },
"linux-x86_64": { "signature": "…", "url": "…AppImage" }
}
}
④ 앱에서 확인·설치 — 진행률까지
import { check } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
export async function checkUpdate(silent = false) {
const update = await check()
if (!update) { if (!silent) alert('최신 버전입니다'); return }
const ok = await confirm(
`새 버전 ${update.version} 이 있습니다.\n\n${update.body ?? ''}\n\n지금 설치할까요?`)
if (!ok) return
let total = 0, got = 0
await update.downloadAndInstall((e) => {
switch (e.event) {
case 'Started': total = e.data.contentLength ?? 0; break
case 'Progress': got += e.data.chunkLength
progress.value = total ? got / total : 0; break
case 'Finished': progress.value = 1; break
}
})
await relaunch() // ★ 재시작해야 새 버전이 뜹니다
}
// ⑤ capabilities 권한
{ "permissions": ["updater:default", "process:allow-restart"] }
.deb·.rpm 은
패키지 관리자 영역이라 앱이 스스로 갈아 끼울 수 없습니다.
그리고 Windows 는 설치 중 앱이 종료되므로,
업데이트 전에 저장하지 않은 작업을 먼저 flush 해야 합니다.
실무에선 이렇게 — GitHub Actions 로 4 플랫폼 자동 릴리즈 🔴 실무
워크플로 한 장이면 끝납니다
# .github/workflows/release.yml
name: release
on:
push:
tags: ['v*']
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # Apple Silicon
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest' # Intel
args: '--target x86_64-apple-darwin'
- platform: 'ubuntu-22.04' # ★ 낮은 배포판에서 빌드해야 호환 범위가 넓음
args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: 리눅스 의존성
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
librsvg2-dev patchelf libxdo-dev
- uses: actions/setup-node@v4
with: { node-version: 24, cache: 'npm' }
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- uses: swatinem/rust-cache@v2
with: { workspaces: './src-tauri -> target' }
- run: npm ci
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: ${{ github.ref_name }}
releaseName: 'My App ${{ github.ref_name }}'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
배포 체크리스트 — 릴리즈 전에 한 번씩
| 항목 | |
|---|---|
| □ | tauri.conf.json 의 version 을 올렸나 |
| □ | Cargo.toml·package.json 버전도 맞췄나 |
| □ | 아이콘이 기본 Tauri 로고가 아닌가 |
| □ | identifier 가 com.tauri.dev 그대로 아닌가 ★ |
| □ | 개발자 도구가 배포본에서 꺼져 있나 |
| □ | capabilities 에 불필요한 권한이 없나 |
| □ | CSP 를 null 로 두지 않았나 |
| □ | 서명·공증이 통과했나 (실제 다운로드해서 확인) |
| □ | 깨끗한 VM 에서 설치·실행이 되나 ★ |
| □ | 이전 버전에서 업데이트가 실제로 되나 ★ |
| □ | 로그 폴더에 개인정보가 남지 않나 |
# 버전을 한 번에 올리는 스크립트 (package.json 의 scripts 에)
# "release": "node scripts/bump.mjs && git tag v$npm_package_version && git push --tags"
# 서명 확인 (Windows)
signtool verify /pa /v myapp_1.2.0_x64-setup.exe
# 서명·공증 확인 (macOS)
codesign -dv --verbose=4 "My App.app"
spctl -a -vvv -t install "My App.app" # accepted 가 나와야 정상 ★
xcrun stapler validate "My App.app"
tauri icon 으로 아이콘, tauri build 로 설치 파일,
Windows 는 인증서 · macOS 는 서명 + 공증이 있어야 사용자에게 닿습니다.
자동 업데이트는 키 쌍 생성 → createUpdaterArtifacts → latest.json → check()+relaunch() 네 단계이고,
비밀 키는 반드시 백업하세요. 4 플랫폼 빌드는 tauri-action 매트릭스가 정답입니다.모바일 — 같은 코드로 Android · iOS 까지
Tauri 2 의 가장 큰 변화는 모바일입니다. tauri android init 한 줄이면
지금 만든 데스크톱 앱이 그대로 안드로이드 앱이 됩니다 — 프런트도, command 도, 상태도 같은 코드입니다.
다만 세팅이 무겁고(Android Studio · NDK · Xcode), 데스크톱 전용 기능은 못 씁니다.
기대치를 정확히 맞추는 것이 이 섹션의 목표입니다.
#[cfg(desktop)] 으로 나눠 쓰는 것이 정석입니다.Android 세팅 — 준비물이 많습니다
# ① Android Studio 설치 후 SDK Manager 에서
# · Android SDK Platform (API 34 이상)
# · Android SDK Platform-Tools
# · NDK (Side by side) ★ 이거 빠뜨리기 쉽습니다
# · CMake
# ② 환경변수 (Windows PowerShell 예)
# ANDROID_HOME = C:\Users\me\AppData\Local\Android\Sdk
# NDK_HOME = %ANDROID_HOME%\ndk\27.0.12077973
# JAVA_HOME = Android Studio 의 jbr 폴더
# ③ Rust 타깃 4개
rustup target add \
aarch64-linux-android armv7-linux-androideabi \
i686-linux-android x86_64-linux-android
# ④ 프로젝트에 안드로이드 붙이기
npm run tauri android init
# → src-tauri/gen/android/ (Gradle 프로젝트가 생깁니다)
# ⑤ 실행 (에뮬레이터 또는 USB 디버깅 켠 실기기)
npm run tauri android dev
npm run tauri android dev -- --open # Android Studio 로 열기
# ⑥ 배포용
npm run tauri android build # aab (Play 스토어용)
npm run tauri android build -- --apk # apk (직접 배포용)
NDK_HOME 미설정 (링커를 못 찾음),
② dev 서버 접근 불가. 실기기는 localhost:1420 을 볼 수 없으므로
Tauri 가 ADB 포트 포워딩을 걸어 줍니다. 그래도 안 되면
vite.config.ts 의 server.host 를 PC 의 IP 로 바꾸고
tauri.conf.json 의 devUrl 도 같이 맞추세요.
iOS 세팅 — macOS 에서만 됩니다
# 준비물: macOS + Xcode + Apple Developer 계정(실기기·배포 시)
xcode-select --install
rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim
brew install cocoapods # 의존성 관리
npm run tauri ios init
# → src-tauri/gen/apple/ (Xcode 프로젝트)
npm run tauri ios dev # 시뮬레이터
npm run tauri ios dev -- --open # Xcode 로 열어 실기기·서명 설정
npm run tauri ios build # ipa
# 서명은 Xcode 프로젝트에서 팀을 고르는 것이 가장 쉽습니다
# gen/apple/myapp.xcodeproj → Signing & Capabilities → Team
// 플랫폼별로 코드를 나누는 방법 — cfg 속성 ★
pub fn run() {
let mut b = tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![list_dir]);
#[cfg(desktop)] // 데스크톱에서만 컴파일
{
b = b.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_window_state::Builder::default().build());
}
#[cfg(mobile)] // 모바일에서만
{
b = b.plugin(tauri_plugin_barcode_scanner::init())
.plugin(tauri_plugin_haptics::init());
}
b.setup(|app| {
#[cfg(desktop)]
setup_tray(app.handle())?; // 트레이는 데스크톱 전용
Ok(())
})
.run(tauri::generate_context!()).unwrap();
}
이렇게도 쓸 수 있다 — 모바일에서 실제로 달라지는 것들 🟡 중급
화면과 입력 — 웹 앱 대응과 거의 같습니다
/* ① 노치·홈 인디케이터 피하기 — safe area */
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
/* ② 터치 대상은 최소 44px */
button { min-height: 44px; }
/* ③ 확대·선택·바운스 스크롤 끄기 */
html { -webkit-text-size-adjust: 100%; overscroll-behavior: none; }
* { -webkit-tap-highlight-color: transparent; }
/* ④ 입력창 확대 방지 (iOS) — 16px 미만이면 자동 확대됩니다 */
input, textarea, select { font-size: 16px; }
// tauri.conf.json — 모바일 전용 설정
{
"app": {
"windows": [{ "label": "main", "url": "index.html" }]
},
"bundle": {
"android": { "minSdkVersion": 24 },
"iOS": { "minimumSystemVersion": "13.0" }
}
}
파일과 저장 — 샌드박스를 전제로 설계합니다
// ❌ 모바일에서 이런 코드는 통하지 않습니다
std::fs::read("/sdcard/Download/a.csv") // 권한 없음
// ✅ 앱 전용 폴더만 자유롭게 씁니다
let dir = app.path().app_data_dir()?; // Android: /data/data/<id>/files
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("cache.json"), b"{}")?;
// ✅ 사용자 파일은 반드시 시스템 선택기를 거칩니다
// (안드로이드 SAF · iOS Document Picker)
import { open, save } from '@tauri-apps/plugin-dialog'
import { readFile } from '@tauri-apps/plugin-fs'
// 사용자가 고른 파일만 접근 가능 — 경로가 아니라 '권한이 붙은 URI' 입니다
const picked = await open({ multiple: false, filters: [{ name: 'CSV', extensions: ['csv'] }] })
if (picked) {
const bytes = await readFile(picked) // 이 파일만 열립니다
}
tauri-plugin-sql 은 기본적으로
앱 데이터 폴더 안에 DB 를 만듭니다. 데스크톱 코드에서
"sqlite:./app.db" 처럼 상대 경로를 썼다면
모바일에서 쓰기 권한이 없어 실패합니다.
처음부터 app_data_dir() 기준 절대 경로로 만드세요.
실무에선 이렇게 — 모바일까지 갈지 말지의 판단 🔴 실무
Tauri 모바일 vs 다른 선택지
| Tauri 2 | React Native | Flutter | |
|---|---|---|---|
| UI | WebView | 네이티브 위젯 | 자체 렌더 |
| 데스크톱 | 1급 | 부가 | 지원 |
| 생태계 | 작음 | 매우 큼 | 큼 |
| 앱 크기 | 작음 | 중간 | 중간 |
| 네이티브 API | 플러그인 적음 | 풍부 | 풍부 |
| 성능(무거운 계산) | Rust | JS 브릿지 | Dart |
| 스크롤·제스처 느낌 | 웹 수준 | 네이티브 | 네이티브 |
모바일에서 막힐 때 보는 곳
# 안드로이드 로그 — Rust 의 println! 도 여기로 나옵니다
adb logcat -s RustStdoutStderr:D # Rust 출력만
adb logcat | grep -i "com.mycompany.myapp" # 앱 전체
# WebView 디버깅 ★ 이게 제일 중요합니다
# Chrome 에서 chrome://inspect 를 열면
# USB 로 연결된 기기의 WebView 가 목록에 뜹니다 → inspect 클릭
# → 데스크톱과 똑같은 개발자 도구를 씁니다
# iOS: Safari → 개발자 메뉴 → 기기 이름 → 앱 WebView
# 기기 목록 확인
adb devices
xcrun simctl list devices
# 빌드 캐시가 꼬였을 때
rm -rf src-tauri/gen/android && npm run tauri android init
gen/android · gen/apple 을 커밋할까? — 서명 설정이나
AndroidManifest.xml 을 손댔다면 커밋해야 합니다.
손대지 않았다면 .gitignore 에 넣고 CI 에서 init 을 다시 돌리는 편이 깔끔합니다.
둘 중 하나를 정하고 팀에 공지하세요 — 섞이면 충돌이 지옥입니다.
tauri android init / ios init 이면 같은 코드가 모바일 앱이 됩니다.
준비물은 Android Studio + NDK + 환경변수, iOS 는 macOS + Xcode 입니다.
트레이·다중 창·전역 단축키·sidecar 는 없고, 파일은 샌드박스와 시스템 선택기를 전제로 짜야 합니다.
데스크톱이 주력일 때 곁들이는 용도로는 훌륭하지만, 모바일 전업이라면 아직은 RN·Flutter 가 안전합니다.Rust 네이티브 GUI — egui · iced · Slint · Dioxus 비교
WebView 를 아예 쓰지 않고 Rust 만으로 화면을 그리는 길도 있습니다. HTML·CSS·JS 를 하나도 안 쓰고, 프런트 빌드 도구도 없고, 바이너리 하나로 끝나며, WebView 파편화가 없습니다. 대신 디자인 자유도와 생태계가 웹과 비교가 안 됩니다. 이 섹션에서 다섯 갈래를 각각 30줄 예제로 보고, 언제 Tauri 대신 이쪽을 골라야 하는지 정리합니다.
다섯 갈래 한눈에
| 방식 | 강점 | 약점 | |
|---|---|---|---|
| egui | 즉시 모드 | 가장 쉬움 · 붙이기 쉬움 | 디자인 자유도 낮음 |
| iced | Elm(MVU) | 구조가 깔끔 · 정적 타입 | 위젯 부족 · API 변동 |
| Slint | 선언형 DSL | 디자이너 도구 · 임베디드 | 상용 라이선스 조건 |
| Dioxus | React 식 VDOM | 웹·데스크톱·모바일 | 데스크톱은 WebView 기반 |
| GPUI | GPU 직접 | 매우 빠름(Zed) | 문서 얇음 · 사실상 내부용 |
| Tauri | WebView | 생태계 · 디자인 | WebView 파편화 |
"즉시 모드"와 "보존 모드"가 무슨 뜻인가
보존 모드 (웹 · Qt · iced)
위젯 객체를 만들어 두고 → 바뀔 때만 갱신
장점: 효율적 · 복잡한 레이아웃에 유리
단점: 상태를 두 벌(내 데이터 + 위젯) 관리
즉시 모드 (egui · Dear ImGui)
매 프레임 화면 전체를 코드로 다시 그림 (60fps)
장점: 상태가 한 벌 ★ 코드가 눈에 보이는 대로
단점: 매 프레임 CPU/GPU 를 씀 (배터리)
레이아웃 계산이 한 프레임 늦게 반영되기도 함
egui — 30줄이면 도는 앱
# Cargo.toml
[dependencies]
eframe = "0.31" # egui + 창 + 렌더러 묶음 (0.3x 대 · 최신 안정 확인)
use eframe::egui;
#[derive(Default)]
struct App { name: String, age: u32, items: Vec<String> }
impl eframe::App for App {
// ★ 이 함수가 매 프레임 호출됩니다 — 이게 전부입니다
fn update(&mut self, ctx: &egui::Context, _f: &mut eframe::Frame) {
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.horizontal(|ui| { ui.heading("주소록"); ui.label(format!("{}명", self.items.len())); });
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("이름:");
ui.text_edit_singleline(&mut self.name); // 값이 직접 묶입니다 ★
});
ui.add(egui::Slider::new(&mut self.age, 0..=120).text("나이"));
if ui.button("추가").clicked() { // 콜백이 없습니다 ★
self.items.push(format!("{} ({})", self.name, self.age));
self.name.clear();
}
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
for (i, it) in self.items.clone().iter().enumerate() {
ui.horizontal(|ui| {
ui.label(it);
if ui.small_button("삭제").clicked() { self.items.remove(i); }
});
}
});
});
}
}
fn main() -> eframe::Result<()> {
eframe::run_native("주소록", eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(App::default()))))
}
if ui.button("추가").clicked() { … } 처럼 읽는 순서 그대로가 실행 순서입니다.
상태 관리 라이브러리도, 이벤트 바인딩도 없습니다.
내부 도구를 하루 만에 만들어야 할 때 이만한 것이 없습니다.
단, 한글 폰트를 직접 넣어 줘야 합니다(기본 폰트에 한글이 없습니다).
이렇게도 쓸 수 있다 — iced · Slint · Dioxus 예제 🟡 중급
iced — Elm 아키텍처(상태 · 메시지 · 뷰)
use iced::widget::{button, column, row, text, text_input, scrollable};
use iced::{Element, Task};
#[derive(Default)]
struct App { input: String, items: Vec<String> }
// ① 일어날 수 있는 일을 열거형으로 전부 나열합니다 ★
#[derive(Debug, Clone)]
enum Message { InputChanged(String), Add, Remove(usize) }
impl App {
// ② 메시지를 받아 상태를 바꿉니다 (여기서만 바뀝니다)
fn update(&mut self, msg: Message) -> Task<Message> {
match msg {
Message::InputChanged(s) => self.input = s,
Message::Add => {
if !self.input.is_empty() {
self.items.push(std::mem::take(&mut self.input));
}
}
Message::Remove(i) => { self.items.remove(i); }
}
Task::none()
}
// ③ 상태로부터 화면을 만듭니다 (순수 함수)
fn view(&self) -> Element<Message> {
let list = self.items.iter().enumerate().fold(
column![].spacing(4),
|col, (i, it)| col.push(row![
text(it), button("삭제").on_press(Message::Remove(i))
].spacing(8)),
);
column![
row![
text_input("할 일", &self.input).on_input(Message::InputChanged),
button("추가").on_press(Message::Add),
].spacing(8),
scrollable(list),
].padding(16).spacing(12).into()
}
}
fn main() -> iced::Result {
iced::run("할 일", App::update, App::view)
}
update 하나뿐” 이라는 점입니다.
규모가 커져도 데이터 흐름을 추적하기 쉽습니다.
단점은 API 가 아직 자주 바뀐다는 것 — 버전을 올릴 때마다 코드를 고쳐야 할 각오가 필요합니다.
버전을 Cargo.toml 에 정확히 고정해 두세요.
Slint — 화면을 전용 언어로 씁니다
// ui/app.slint ← QML 을 닮은 선언형 DSL
import { Button, LineEdit, ListView, VerticalBox } from "std-widgets.slint";
export component MainWindow inherits Window {
title: "할 일";
preferred-width: 420px;
preferred-height: 520px;
in-out property <[string]> items; // Rust 와 주고받는 값
callback add(string); // Rust 가 구현할 콜백
VerticalBox {
HorizontalLayout {
input := LineEdit { placeholder-text: "할 일"; }
Button {
text: "추가";
clicked => { root.add(input.text); input.text = ""; }
}
}
ListView {
for it in root.items : Text { text: it; }
}
}
}
// build.rs
fn main() { slint_build::compile("ui/app.slint").unwrap(); }
// main.rs
slint::include_modules!(); // .slint 가 Rust 타입으로 컴파일됩니다 ★
fn main() -> Result<(), slint::PlatformError> {
let ui = MainWindow::new()?;
let weak = ui.as_weak();
ui.on_add(move |text| {
let ui = weak.unwrap();
let mut v: Vec<slint::SharedString> = ui.get_items().iter().collect();
v.push(text);
ui.set_items(slint::ModelRc::new(slint::VecModel::from(v)));
});
ui.run()
}
실무에선 이렇게 — Dioxus 와 최종 선택 기준 🔴 실무
Dioxus — React 를 아는 사람이 가장 빨리 붙습니다
use dioxus::prelude::*;
fn main() { dioxus::launch(App); }
#[component]
fn App() -> Element {
// useState 와 사실상 같습니다
let mut items = use_signal(Vec::<String>::new);
let mut input = use_signal(String::new);
rsx! { // JSX 를 닮은 매크로 ★
div { class: "wrap",
input {
value: "{input}",
oninput: move |e| input.set(e.value()),
}
button {
onclick: move |_| {
if !input().is_empty() { items.write().push(input()); input.set(String::new()); }
},
"추가"
}
ul {
for (i, it) in items().iter().enumerate() {
li { key: "{i}", "{it}" }
}
}
}
}
}
cargo install dioxus-cli
dx new myapp
dx serve --platform desktop # 데스크톱 (WebView 기반)
dx serve --platform web # 웹 (WASM)
dx build --platform android # 모바일
# ★ 주의: Dioxus 데스크톱도 결국 WebView 를 씁니다.
# 즉 "Tauri 와 같은 구조에 프런트만 Rust" 입니다.
# 네이티브 렌더러(Blitz)는 아직 실험 단계입니다.
최종 선택 기준 — 세 문장으로
| 상황 | 고를 것 |
|---|---|
| 사용자에게 파는 앱 | Tauri |
| 이미 웹 프런트가 있음 | Tauri |
| 내부 도구 · 디버그 UI | egui |
| 실시간 그래프 · 시각화 | egui (+ egui_plot) |
| 구조를 오래 유지할 앱 | iced |
| 임베디드 · 키오스크 | Slint |
| 웹·데스크톱·모바일 한 코드 | Dioxus |
| 게임 · 3D | bevy · wgpu |
| 단일 실행 파일이 절대 조건 | egui (5~10 MB) |
cargo build --release 하나로 끝이고,
Node·npm·Vite·WebView 런타임이 전부 없습니다.
CI 도 단순해지고, 리눅스 webkit2gtk 버전 지옥도 없습니다.
include_bytes! 로 바이너리에 넣고 등록해야 합니다.
이걸 모르고 “Rust GUI 는 한글이 안 된다”고 오해하는 경우가 많습니다.
cargo build 하나” 라는 단순함입니다.Rust 로 웹 프런트 — Leptos · Dioxus · Yew, 그리고 솔직한 판단
Rust 코드를 WASM 으로 컴파일해 브라우저에서 돌리는 프레임워크들이 있습니다. Leptos · Dioxus · Yew 가 대표적이고, 셋 다 실제로 동작하며 성능도 좋습니다. Tauri 와 합치면 프런트부터 백엔드까지 전부 Rust 가 됩니다. 이 섹션에서 어떻게 만드는지와 함께 “그래서 이걸 써야 하나”에 대한 솔직한 답을 드립니다.
Leptos — 세밀한 반응성(Signal) 기반
use leptos::prelude::*;
#[component]
fn TodoApp() -> impl IntoView {
let (input, set_input) = signal(String::new());
let (items, set_items) = signal(Vec::<String>::new());
// 파생 값 — 의존하는 시그널이 바뀔 때만 다시 계산됩니다 ★
let count = move || items.get().len();
view! {
<div class="wrap">
<input
prop:value=move || input.get()
on:input=move |e| set_input.set(event_target_value(&e))
/>
<button on:click=move |_| {
let v = input.get();
if !v.is_empty() { set_items.update(|l| l.push(v)); set_input.set(String::new()); }
}>"추가"</button>
<p>{move || format!("{}개", count())}</p>
<ul>
<For each=move || items.get() key=|s| s.clone() let:item>
<li>{item}</li>
</For>
</ul>
</div>
}
}
fn main() { leptos::mount::mount_to_body(TodoApp); }
# 빌드 도구 — trunk
cargo install trunk
rustup target add wasm32-unknown-unknown
trunk serve --open # 개발 서버 + 핫 리로드
trunk build --release # dist/ 생성
# Tauri 와 붙이기 — devUrl 과 frontendDist 만 바꾸면 끝
# "beforeDevCommand": "trunk serve",
# "devUrl": "http://localhost:1420",
# "beforeBuildCommand": "trunk build --release",
# "frontendDist": "../dist"
#[server])까지 갖춰서, Rust 풀스택 웹으로는 현재 가장 완성도가 높습니다.
Yew — 가장 오래됐고 React 를 가장 닮았습니다
use yew::prelude::*;
#[function_component(App)]
fn app() -> Html {
let items = use_state(Vec::<String>::new);
let input = use_state(String::new);
let on_input = {
let input = input.clone();
Callback::from(move |e: InputEvent| {
let t: web_sys::HtmlInputElement = e.target_unchecked_into();
input.set(t.value());
})
};
let on_add = {
let (items, input) = (items.clone(), input.clone());
Callback::from(move |_| {
if !input.is_empty() {
let mut v = (*items).clone();
v.push((*input).clone());
items.set(v);
input.set(String::new());
}
})
};
html! {
<div>
<input value={(*input).clone()} oninput={on_input} />
<button onclick={on_add}>{"추가"}</button>
<ul>{ for items.iter().map(|i| html!{ <li>{i}</li> }) }</ul>
</div>
}
}
| Leptos | Dioxus | Yew | |
|---|---|---|---|
| 모델 | 시그널 | VDOM | VDOM |
| 성능 | 최상 | 좋음 | 보통 |
| SSR | 있음 | 있음 | 제한적 |
| 데스크톱 | Tauri | 내장 | Tauri |
| 모바일 | — | 있음 | — |
| 활발함 | 매우 | 매우 | 느려짐 |
실무에선 이렇게 — 그래서 TS 대신 Rust 프런트를 써야 하나 🔴 실무
솔직한 답 — 거의 대부분 “아니오” 입니다
왜 아니오인가 — 감정이 아니라 비용으로
① 컴포넌트가 없습니다
날짜 선택기 · 데이터 테이블 · 차트 · 드래그 정렬 …
TS 라면 30분이면 붙일 것을 며칠 걸려 직접 만듭니다.
② 디자인 도구와 단절됩니다
Tailwind 는 되지만, shadcn/ui · MUI · Element Plus 같은
완성된 디자인 시스템을 그대로 쓸 수 없습니다.
③ 사람을 못 구합니다
"Rust + Leptos 프런트 개발자" 채용 공고를 상상해 보세요.
유지보수를 넘길 사람이 없다는 뜻입니다.
④ 문제가 생기면 혼자입니다
스택오버플로에 답이 없습니다. 소스를 읽어야 합니다.
⑤ 빌드가 느립니다
프런트 작업의 리듬(고치고 즉시 확인)이 깨집니다.
⑥ 그리고 이득이 생각보다 작습니다
Tauri 앱에서 무거운 계산은 어차피 Rust command 가 합니다.
프런트까지 Rust 로 바꿔서 더 빨라지는 부분은 거의 없습니다. ★
그래도 “예”인 좁은 경우
| 상황 | 이유 |
|---|---|
| 팀에 프런트 인력이 0 | 배울 언어가 하나 줄어듦 |
| 화면에서 무거운 계산 | WASM 이 실제로 빠름 |
| 서버와 로직 공유 | 검증 규칙을 한 번만 작성 |
| 내부 도구 · 수명 짧음 | 유지보수 인수인계 부담 없음 |
| 학습 목적 | 가장 좋은 이유입니다 |
중간 지대 — 이쪽이 현실적인 정답입니다 ★
프런트는 TypeScript (Vue/React) 로 두고,
무거운 계산만 Rust 로 짜서
· Tauri 앱이면 → #[tauri::command] (u03)
· 웹 앱이면 → wasm-bindgen 으로 WASM 모듈 (🟨 j06)
예: 이미지 필터 · 대용량 CSV 파싱 · 암호화 · 압축 · 정규식 대량 매칭
→ 이것만 Rust 로 옮겨도 10~50배가 나옵니다.
→ 화면은 익숙한 도구로 계속 만듭니다.
"전부 Rust" 가 아니라 "필요한 곳만 Rust" 가 거의 언제나 이깁니다.
#[tauri::command] 로 자연스럽게 됩니다.실전 — 로컬 파일 검색 앱을 처음부터 배포까지
앞의 12개 섹션을 한 프로젝트로 엮습니다. 만들 것은 “내 PC 의 파일을 훑어 색인하고, 이름·경로로 즉시 검색하고, 중복 파일을 찾아 주는 앱” 입니다. Windows 검색이 느려서 다들 한 번쯤 만들고 싶어 하는 그 도구입니다. Vue 3 + Tauri 2 + rayon + SQLite(FTS5) 로 만들고, 트레이 상주 · 전역 단축키 · 자동 업데이트 · 코드 서명까지 붙입니다. 폴더 구조와 핵심 코드를 전부 싣습니다.
폴더 구조와 의존성
filefinder/
├─ package.json
├─ vite.config.ts
├─ index.html
├─ src/ # Vue 3 + TypeScript
│ ├─ main.ts
│ ├─ App.vue
│ ├─ api.ts # invoke 래퍼 (u08)
│ ├─ components/
│ │ ├─ SearchBar.vue
│ │ ├─ ResultList.vue # 가상 스크롤
│ │ └─ IndexProgress.vue
│ └─ composables/useIndex.ts
└─ src-tauri/
├─ Cargo.toml
├─ tauri.conf.json
├─ capabilities/default.json
├─ migrations/001_init.sql
├─ icons/
└─ src/
├─ main.rs
├─ lib.rs # Builder 구성 · 트레이 · 단축키
├─ state.rs # AppState · 취소 플래그
├─ index.rs # 수집 + 병렬 해시 + 배치 삽입
├─ search.rs # FTS5 질의 · 중복 찾기
└─ error.rs # AppError
# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] } # 코어에서 직접 씁니다
walkdir = "2"
ignore = "0.4" # .gitignore 규칙 그대로 적용
rayon = "1"
blake3 = "1" # md5·sha 보다 빠릅니다
thiserror = "2"
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true
DB 스키마 — FTS5 가 핵심입니다
-- src-tauri/migrations/001_init.sql
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
ext TEXT,
size INTEGER NOT NULL,
mtime INTEGER NOT NULL,
hash TEXT, -- 큰 파일만 계산 (지연 해시)
indexed_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_files_size ON files(size);
CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash);
CREATE INDEX IF NOT EXISTS idx_files_ext ON files(ext);
-- 이름 검색용 전문검색 가상 표 ★ LIKE '%x%' 보다 수십 배 빠릅니다
CREATE VIRTUAL TABLE IF NOT EXISTS files_fts
USING fts5(name, path, content='files', content_rowid='id',
tokenize='trigram'); -- 한글·부분일치에 trigram 이 유리
-- files 가 바뀌면 fts 도 따라오게 트리거로 묶습니다
CREATE TRIGGER IF NOT EXISTS files_ai AFTER INSERT ON files BEGIN
INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path);
END;
CREATE TRIGGER IF NOT EXISTS files_ad AFTER DELETE ON files BEGIN
INSERT INTO files_fts(files_fts, rowid, name, path)
VALUES ('delete', old.id, old.name, old.path);
END;
tokenize='trigram' 을 쓰는 이유 — 기본 토크나이저는 단어 단위라
보고서 로 2026보고서최종.xlsx 를 못 찾습니다.
trigram 은 세 글자씩 잘라 색인하므로 부분 일치와 한글에 강합니다.
색인 크기가 2~3배가 되지만, 파일 검색에서는 그 값어치가 확실합니다.
이렇게도 쓸 수 있다 — 인덱서 — 병렬로 훑고 묶어서 넣습니다 🟡 중급
src-tauri/src/index.rs
use rayon::prelude::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use tauri::ipc::Channel;
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
pub enum IndexEvent {
Scanning { found: usize },
Hashing { done: usize, total: usize },
Finished { total: usize, took_ms: u128 },
Canceled,
}
struct Row { path: String, name: String, ext: Option<String>,
size: u64, mtime: i64, hash: Option<String> }
#[tauri::command]
pub async fn start_index(
app: tauri::AppHandle,
st: tauri::State<'_, crate::state::AppState>,
roots: Vec<String>,
on_event: Channel<IndexEvent>,
) -> Result<(), String> {
let cancel = st.cancel.clone();
cancel.store(false, Ordering::Relaxed);
let db_path = st.db_path.clone();
// ★ CPU 를 쓰는 일이므로 통째로 블로킹 풀에서 돌립니다 (u03)
tauri::async_runtime::spawn_blocking(move || {
let t0 = std::time::Instant::now();
// ① 수집 — ignore 크레이트가 .gitignore · 숨김 파일을 알아서 걸러 줍니다
let mut paths = Vec::new();
for root in &roots {
for e in ignore::WalkBuilder::new(root).hidden(true).build().flatten() {
if e.file_type().map(|t| t.is_file()).unwrap_or(false) {
paths.push(e.into_path());
if paths.len() % 5000 == 0 {
let _ = on_event.send(IndexEvent::Scanning { found: paths.len() });
}
}
if cancel.load(Ordering::Relaxed) {
let _ = on_event.send(IndexEvent::Canceled);
return;
}
}
}
// ② 병렬 처리 — 여기서 코어 수만큼 빨라집니다 ★
let done = Arc::new(AtomicUsize::new(0));
let total = paths.len();
let rows: Vec<Row> = paths.par_iter()
.filter_map(|p| {
if cancel.load(Ordering::Relaxed) { return None; }
let m = std::fs::metadata(p).ok()?;
let size = m.len();
// 1 MB 이상만 해시 — 전체 해시는 디스크가 병목이 됩니다
let hash = (size >= 1_048_576)
.then(|| std::fs::read(p).ok().map(|b| blake3::hash(&b).to_hex().to_string()))
.flatten();
let n = done.fetch_add(1, Ordering::Relaxed);
if n % 500 == 0 {
let _ = on_event.send(IndexEvent::Hashing { done: n, total });
}
Some(Row {
path: p.display().to_string(),
name: p.file_name()?.to_string_lossy().into_owned(),
ext: p.extension().map(|e| e.to_string_lossy().to_lowercase()),
size,
mtime: m.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64).unwrap_or(0),
hash,
})
})
.collect();
// ③ 배치 삽입 — 한 건씩 넣으면 100배 느립니다 ★
if let Ok(mut conn) = rusqlite::Connection::open(&db_path) {
let _ = conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;");
if let Ok(tx) = conn.transaction() {
{
let mut stmt = tx.prepare(
"INSERT INTO files(path,name,ext,size,mtime,hash,indexed_at)
VALUES (?1,?2,?3,?4,?5,?6,strftime('%s','now'))
ON CONFLICT(path) DO UPDATE SET
size=excluded.size, mtime=excluded.mtime, hash=excluded.hash"
).unwrap();
for r in &rows {
let _ = stmt.execute(rusqlite::params![
r.path, r.name, r.ext, r.size as i64, r.mtime, r.hash]);
}
}
let _ = tx.commit();
}
}
let _ = on_event.send(IndexEvent::Finished {
total: rows.len(), took_ms: t0.elapsed().as_millis() });
});
Ok(())
}
#[tauri::command]
pub fn cancel_index(st: tauri::State<'_, crate::state::AppState>) {
st.cancel.store(true, Ordering::Relaxed); // ★ 취소는 플래그 하나로
}
검색 · 중복 찾기 — src/search.rs
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Hit { pub id: i64, pub path: String, pub name: String,
pub size: i64, pub mtime: i64 }
#[tauri::command]
pub fn search(st: tauri::State<'_, crate::state::AppState>,
q: String, limit: u32) -> Result<Vec<Hit>, String> {
if q.trim().len() < 2 { return Ok(vec![]); } // 너무 짧으면 안 돕니다
let conn = rusqlite::Connection::open(&st.db_path).map_err(|e| e.to_string())?;
// FTS5 질의 — 사용자 입력을 그대로 넣지 않고 따옴표로 감쌉니다 ★
let term = format!("\"{}\"", q.replace('"', ""));
let mut stmt = conn.prepare(
"SELECT f.id, f.path, f.name, f.size, f.mtime
FROM files_fts JOIN files f ON f.id = files_fts.rowid
WHERE files_fts MATCH ?1
ORDER BY rank
LIMIT ?2").map_err(|e| e.to_string())?;
let rows = stmt.query_map(rusqlite::params![term, limit], |r| Ok(Hit {
id: r.get(0)?, path: r.get(1)?, name: r.get(2)?,
size: r.get(3)?, mtime: r.get(4)?,
})).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DupeGroup { pub hash: String, pub size: i64, pub paths: Vec<String> }
#[tauri::command]
pub fn find_dupes(st: tauri::State<'_, crate::state::AppState>)
-> Result<Vec<DupeGroup>, String>
{
let conn = rusqlite::Connection::open(&st.db_path).map_err(|e| e.to_string())?;
// 같은 해시가 2건 이상 = 내용이 같은 파일 ★
let mut stmt = conn.prepare(
"SELECT hash, MAX(size), GROUP_CONCAT(path, char(10))
FROM files
WHERE hash IS NOT NULL
GROUP BY hash HAVING COUNT(*) > 1
ORDER BY MAX(size) * COUNT(*) DESC
LIMIT 500").map_err(|e| e.to_string())?;
let rows = stmt.query_map([], |r| {
let joined: String = r.get(2)?;
Ok(DupeGroup { hash: r.get(0)?, size: r.get(1)?,
paths: joined.split('\n').map(String::from).collect() })
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
NEAR, *, - 같은
FTS 연산자로 해석되어 문법 에러가 납니다(fts5: syntax error).
따옴표로 감싸 리터럴 구문으로 만들거나, 허용 문자만 남기고 걸러야 합니다.
SQL 인젝션과는 별개의 문제이니 바인딩 파라미터를 써도 반드시 처리해야 합니다.
실무에선 이렇게 — 앱 조립 · 화면 · 배포 🔴 실무
src/lib.rs — 전부 여기서 조립됩니다
mod state; mod index; mod search; mod error;
use tauri::{Manager, Emitter};
use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut, ShortcutState};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let toggle = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space);
tauri::Builder::default()
// ★ single-instance 는 반드시 첫 번째 (u05)
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(w) = app.get_webview_window("main") {
w.show().ok(); w.unminimize().ok(); w.set_focus().ok();
}
}))
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, sc, ev| {
if sc == &toggle && ev.state() == ShortcutState::Pressed {
if let Some(w) = app.get_webview_window("main") {
if w.is_visible().unwrap_or(false) { w.hide().ok(); }
else { w.show().ok(); w.set_focus().ok(); }
}
}
}).build())
.setup(move |app| {
// ① DB 경로를 앱 데이터 폴더로 (u03 · u10)
let dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&dir)?;
let db_path = dir.join("index.db");
state::init_schema(&db_path)?; // migrations/001_init.sql 실행
app.manage(state::AppState::new(db_path));
// ② 트레이 (u05)
state::setup_tray(app.handle())?;
// ③ 전역 단축키 등록
app.global_shortcut().register(toggle)?;
// ④ 시작 5초 뒤 업데이트 확인 — 시작 속도를 방해하지 않게 ★
let h = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = h.emit("check-update", ());
});
Ok(())
})
.on_window_event(|w, e| {
if let tauri::WindowEvent::CloseRequested { api, .. } = e {
api.prevent_close(); w.hide().ok(); // 닫기 → 숨김
}
})
.invoke_handler(tauri::generate_handler![
index::start_index, index::cancel_index,
search::search, search::find_dupes,
])
.build(tauri::generate_context!())
.expect("빌드 실패")
.run(|_app, e| {
if let tauri::RunEvent::ExitRequested { api, .. } = e { api.prevent_exit(); }
});
}
화면 — 디바운스와 가상 스크롤이 전부입니다
// src/api.ts
import { invoke, Channel } from '@tauri-apps/api/core'
export type Hit = { id: number; path: string; name: string; size: number; mtime: number }
export type IndexEvent =
| { event: 'scanning'; data: { found: number } }
| { event: 'hashing'; data: { done: number; total: number } }
| { event: 'finished'; data: { total: number; tookMs: number } }
| { event: 'canceled'; data: null }
export const api = {
search: (q: string, limit = 200) => invoke<Hit[]>('search', { q, limit }),
findDupes: () => invoke<{ hash: string; size: number; paths: string[] }[]>('find_dupes'),
startIndex: (roots: string[], onEvent: Channel<IndexEvent>) =>
invoke<void>('start_index', { roots, onEvent }),
cancelIndex: () => invoke<void>('cancel_index'),
}
// src/components/SearchBar.vue 의 핵심 — 디바운스 ★
import { ref, watch } from 'vue'
import { api, type Hit } from '@/api'
const q = ref(''); const hits = ref<Hit[]>([]); let timer: number | undefined
let seq = 0 // 늦게 온 응답이 덮어쓰지 않게 ★
watch(q, (v) => {
clearTimeout(timer)
timer = window.setTimeout(async () => {
const my = ++seq
const r = await api.search(v)
if (my === seq) hits.value = r // 최신 요청 결과만 반영
}, 150)
})
// 결과가 수만 건이면 가상 스크롤은 선택이 아니라 필수입니다
// npm i @tanstack/vue-virtual
// 그리고 파일을 열거나 탐색기에서 보여 주기
import { openPath, revealItemInDir } from '@tauri-apps/plugin-opener'
async function open(h: Hit) { await openPath(h.path) }
async function reveal(h: Hit) { await revealItemInDir(h.path) }
seq 카운터를 꼭 두세요 — 검색어를 빨리 치면 요청이 겹칩니다.
네트워크가 아니라 로컬 IPC 라도 응답 순서는 보장되지 않습니다.
이걸 안 하면 “다 지웠는데 이전 검색 결과가 남아 있다” 는 유령 버그가 생깁니다.
한 단계 더 — 권한 · 설정 · 배포까지 마무리 🧬 전문가
capabilities · tauri.conf.json
// src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "메인 창 권한",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open", // 폴더 고르기만
"opener:allow-open-path",
"opener:allow-reveal-item-in-dir",
"updater:default",
"process:allow-restart",
"global-shortcut:default"
]
}
// ★ fs 권한이 없습니다 — 파일 접근은 전부 Rust command 안에서 하므로
// 프런트에는 파일 시스템 권한을 한 톨도 주지 않습니다 (u01 의 원칙)
// tauri.conf.json (요약)
{
"productName": "FileFinder",
"version": "1.0.0",
"identifier": "com.example.filefinder",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [{ "label": "main", "title": "FileFinder",
"width": 900, "height": 640, "visible": false }],
"security": {
"csp": { "default-src": "'self'", "style-src": "'self' 'unsafe-inline'",
"connect-src": "'self' ipc: http://ipc.localhost" }
}
},
"bundle": {
"active": true, "targets": ["nsis", "dmg", "appimage"],
"createUpdaterArtifacts": true,
"icon": ["icons/32x32.png", "icons/icon.icns", "icons/icon.ico"]
},
"plugins": {
"updater": { "pubkey": "…", "endpoints": ["https://…/latest.json"] }
}
}
"visible": false 에 주목 — 트레이 앱이므로 시작할 때 창을 띄우지 않습니다.
전역 단축키나 트레이 클릭으로만 나타납니다.
부팅 시 자동 실행까지 붙이려면 tauri-plugin-autostart 를 추가하세요.
성능 · 마무리 점검
| 항목 | 측정 · 대책 |
|---|---|
| 50만 파일 색인 | SSD 기준 2~5분 (12코어) |
| DB 크기 | 50만 건 ≈ 120~200 MB |
| 검색 응답 | FTS5 로 10~40 ms |
| 메모리 | 색인 중 300 MB · 평시 90 MB |
| 설치 파일 | 5~7 MB (sidecar 없음) |
| 느리면 ① | 해시 최소 크기를 10 MB 로 |
| 느리면 ② | 배치 크기·트랜잭션 확인 |
| 느리면 ③ | WAL·synchronous=NORMAL 확인 |
여기서 더 나아가려면 (연습 과제)
① 증분 색인 — mtime 이 그대로면 건너뛰기
→ 두 번째 색인이 10초로 줄어듭니다
② 실시간 감시 — notify 크레이트로 폴더 변경 감지
→ 파일이 생기면 즉시 DB 갱신 + emit
③ 미리보기 — 이미지 썸네일 (image 크레이트 + convertFileSrc)
④ 내용 검색 — txt·md·pdf 본문까지 FTS 에 넣기
⑤ 태그 · 즐겨찾기 — 표 하나 더 추가
⑥ 모바일 뷰어 — tauri android init 으로 같은 화면 (u10)
(색인은 데스크톱에서, 조회만 모바일에서)
trash 크레이트)로 하고,
영구 삭제는 명시적 확인을 받으세요.
그리고 같은 그룹의 마지막 한 개는 절대 지우지 못하게 코드로 막아야 합니다 —
이 실수 하나로 앱 전체의 신뢰가 무너집니다.
spawn_blocking + rayon 으로 코어에서, 진행률은 Channel 로, 결과는 SQLite 에.
화면에는 파일 권한을 주지 않고 command 안에서만 파일을 만집니다.
마지막에 트레이 · 전역 단축키 · prevent_close · 업데이터 · 서명 다섯 개를 붙이면
사람들이 실제로 쓰는 앱이 됩니다.Rust 로 만든 코드를
자바·코틀린에서
그냥 부릅니다
JVM 은 느려서 문제인 언어가 아닙니다. 다만 GC 가 멈추는 순간과 이미지·암호·압축처럼 네이티브가 훨씬 나은 작업이 있을 뿐입니다. 그 자리만 Rust 로 갈아 끼우는 길이 네 가지 있습니다 — jni 크레이트 · Panama FFM · UniFFI · JNA. 이 탭은 넷을 전부 실제로 돌려 보고, 어느 상황에 무엇을 고르는지를 숫자로 정리합니다. 끝나면 Spring Boot 엔드포인트와 안드로이드 앱에서 같은 Rust 코어를 부르는 것까지 갑니다.
네 가지 길 — 무엇을 고를지부터 정합니다
자바에서 Rust 를 부르는 방법은 여러 개인데, 대부분의 사고는 잘못 고른 데서 시작합니다. 먼저 넷을 나란히 놓고 호출 비용 · 작성 난이도 · 안전성 · 쓸 수 있는 플랫폼을 비교한 뒤, 2026년 기준 추천을 분명히 정하고 나머지 섹션으로 들어갑니다.
한 표로 비교 ★
| jni | FFM | UniFFI | JNA | |
|---|---|---|---|---|
| 호출 비용 | 가장 낮음 | 거의 같음 | 중간 | 높음 |
| Rust 쪽 작성량 | 많음 | 적음 | 가장 적음 | 적음 |
| 자바 쪽 작성량 | 적음 | 많음(자동화 가능) | 0 — 생성됨 | 적음 |
| 필요 자바 | 8+ | 22+ (25 LTS 권장) | 8+ | 8+ |
| 안드로이드 | ○ | ✕ (미지원) | ◎ | △ (jna-android) |
| iOS · KMP | ✕ | ✕ | ◎ | ✕ |
| 메모리 실수 | 나기 쉬움 | Arena 가 막아 줌 | 거의 없음 | 나기 쉬움 |
| 비동기 지원 | 수동 | 수동 | 내장 | 수동 |
| 다루는 섹션 | v02~v04 | v05 | v06 | v07 |
왜 자바에서 Rust 를 부르나
★ 실제로 이득이 확인되는 자리
· 이미지 · 영상 · 오디오 처리 (리사이즈 · 인코딩 · 필터)
· 압축 · 암호 (zstd · brotli · AEAD) — 자바 구현이 느린 영역
· 파서 — 대용량 CSV · 로그 · 바이너리 프로토콜
· 지오메트리 · 물리 · 매칭 엔진 — 객체 할당이 많아 GC 가 힘든 코드
· 이미 Rust 로 잘 만들어진 크레이트가 있는 경우 ★
image · regex · zstd · ring · rustls · polars · tantivy
· 안드로이드 · iOS 에 같은 로직을 얹어야 하는 경우 (v10 · v11)
★ 이득이 작은 자리 — 굳이 하지 마세요
· JIT 가 이미 잘 하는 순수 정수 루프 (C2 는 상당히 잘 합니다)
· DB·HTTP 대기가 대부분인 코드 — 언어를 바꿔도 그대로 기다립니다
· 초당 수백만 번 부르며 매번 작은 일을 시키는 설계
→ 경계 비용이 계산보다 커집니다. 배치로 묶으세요(v08)
★ GC 관점에서의 진짜 이유
큰 byte[] 를 계속 만들고 버리는 코드는 GC 를 흔듭니다.
네이티브 쪽 메모리(DirectByteBuffer · Arena)로 옮기면
힙 밖에서 처리되어 p99 지연이 눈에 띄게 안정됩니다 (v08)
이렇게도 쓸 수 있다 — 상황을 넣으면 답이 나오는 흐름도 🟡 중급
2026년의 기본값 ★
★ 서버 JVM (Spring Boot · Quarkus · 배치)
→ Panama FFM 을 기본값으로 (v05)
Rust 쪽은 C ABI 함수만 내보내면 되므로
파이썬(🐍 y02) · Node(🟨 j02) · C# 과 코어를 공유할 수 있습니다.
Java 25 LTS 를 쓸 수 있다면 고민할 이유가 거의 없습니다.
★ 안드로이드 · iOS · KMP
→ UniFFI 를 기본값으로 (v06 · v10 · v11)
FFM 은 안드로이드에서 못 씁니다(Android 런타임은 ART 입니다).
직접 JNI 를 쓰면 되지만, iOS 까지 가야 한다면 두 벌을 짜야 합니다.
★ Java 8~21 에 묶여 있고 호출이 아주 잦다
→ jni 크레이트 (v02~v04)
JNI 는 Java 1.1 부터 있었고 앞으로도 사라지지 않습니다.
호출 비용은 넷 중 가장 낮습니다.
★ 라이브러리를 배포하는 입장이라면
→ C ABI 코어 + FFM 어댑터 + JNI 어댑터를 함께 (v09 · v12)
쓰는 쪽 런타임을 우리가 못 정하기 때문입니다.
넷은 배타적이지 않습니다
실제 프로젝트에서 자주 쓰는 조합
┌─────────────────────────────────────────┐
│ core (순수 Rust) │ ← 로직 · cargo test 가 그대로 됨
├─────────────────────────────────────────┤
│ ffi (extern "C") │ jni (JNI 심볼) │ ← 얇은 어댑터 두 벌
├──────────────────────┼──────────────────┤
│ FFM · JNA · UniFFI │ System.load… │
└──────────────────────┴──────────────────┘
★ 핵심 원칙 — 로직은 한 벌, 어댑터만 여럿
· core 크레이트는 JNIEnv 를 절대 모르게 합니다 ★
· 어댑터 크레이트가 변환(문자열 · 배열 · 에러)만 담당합니다
· 그래야 cargo test 로 로직을 검증할 수 있습니다 (v12)
★ 실제 사례
· Mozilla 안드로이드·iOS 공용 컴포넌트를 UniFFI 로 (v06)
· Signal libsignal 을 JNI + Swift 로 배포
· Deno·polars C ABI 코어에 언어별 얇은 층
· Meta·AWS FFM 으로 기존 C 라이브러리 대체 진행 중
JNIEnv 를 로직 함수 안까지 끌고 들어가는 것입니다.
그러면 테스트를 JVM 없이는 못 돌립니다. JNI 코드는 파일 하나에 가둬 두세요(v02).
실무에선 이렇게 — 도입 전에 세어 볼 비용 🔴 실무
따라오는 비용 목록
① 빌드 — CI 가 OS × 아키텍처만큼 늘어납니다 (v09)
linux-x64 · linux-arm64 · macos-arm64 · windows-x64
안드로이드까지면 arm64-v8a · armeabi-v7a · x86_64 추가 (v10)
② 배포 — jar 하나로 끝나지 않습니다
네이티브를 jar 안에 넣고 런타임에 추출하는 로더가 필요합니다 (v09)
③ 디버깅 — 스택 트레이스가 경계에서 끊깁니다
자바 예외에 Rust 쪽 위치가 안 찍힙니다. 로그 규약을 먼저 정하세요 (v04)
④ 사고 — 잘못된 포인터 하나가 JVM 전체를 내립니다 ★
hs_err_pid*.log 만 남고 예외가 아닙니다. 되돌릴 스위치를 두세요.
⑤ 사람 — Rust 를 읽을 수 있는 사람이 팀에 둘 이상인가
하나뿐이면 그 사람이 떠날 때 이 모듈이 그대로 멈춥니다.
★ 되돌릴 수 있게 시작하는 법
자바 인터페이스를 먼저 정의하고 구현을 두 개 두세요.
interface Resizer { byte[] resize(byte[] src, int w, int h); }
· JavaResizer 순수 자바 (기존 구현)
· NativeResizer Rust 호출
스프링이면 @ConditionalOnProperty 로 한 줄에 되돌립니다 (v12)
먼저 재고 시작합니다
// 재기 전에는 아무 결정도 하지 않습니다.
// JMH 로 '자바 구현'의 현재 값을 먼저 확보하세요 (v08).
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
public class ResizeBench {
byte[] jpeg;
@Setup public void setup() throws Exception {
jpeg = Files.readAllBytes(Path.of("sample-4000x3000.jpg"));
}
@Benchmark public byte[] javaImageIO() { return JavaResizer.resize(jpeg, 800, 600); }
// 아직 없는 구현은 나중에 추가합니다
}
# 실행 (Maven 기준)
mvn -q clean package
java -jar target/benchmarks.jar ResizeBench -f 1 -wi 5 -i 10
# ★ 반드시 확인할 것
# · JIT 워밍업 뒤 값인가 (-wi 5 이상)
# · 같은 입력·같은 결과인가 (파일 해시를 비교)
# · GC 로그도 함께 (-Xlog:gc) ← p99 는 여기서 갈립니다
한 단계 더 — JNI 는 왜 불편하고 FFM 은 왜 나왔나 🧬 전문가
JNI 의 설계와 그 대가
JNI 는 1997년(Java 1.1) 설계입니다. 전제가 이랬습니다.
· 네이티브 코드는 '자바를 다시 부르는' 일이 잦다 → JNIEnv 함수표 240여 개
· 객체는 GC 가 옮길 수 있다 → 직접 포인터 금지, 핸들(jobject)만
· 네이티브 안에서 만든 참조는 자동 정리돼야 한다 → 지역 참조 프레임 (v04)
이 전제 때문에 생긴 대가
· 배열 하나 읽는 데도 Get/Release 짝이 필요합니다 (v03)
· 참조 수명 규칙을 사람이 지켜야 합니다 (v04)
· 자바 쪽에 native 메서드 선언 + 심볼 이름 규칙이 필요합니다 (v02)
· JVM 이 JNI 경계에서 최적화를 포기합니다 (인라인 불가)
★ 그럼에도 JNI 는 여전히 가장 빠릅니다
자바 → 네이티브 방향 '단순 호출'만 놓고 보면
스터브가 짧아 오버헤드가 수 나노초 수준입니다 (v08)
Panama 가 바꾼 것
FFM(Foreign Function & Memory) API 연혁
JEP 412/419/424/434/442 인큐베이터 · 프리뷰 반복 (Java 17~21)
JEP 454 Java 22 에서 정식 API 로 확정 ★
JEP 472 Java 24~25 — 네이티브 접근을 '제한된 연산'으로 정리
--enable-native-access 없이 쓰면 경고가 납니다 (v05)
무엇이 달라졌나
· 자바 쪽에서 함수를 '찾아서' 부릅니다 — 네이티브에 자바 전용 심볼이 필요 없음
→ Rust 는 그냥 extern "C" 함수 하나면 끝 ★
· 메모리는 Arena 가 소유합니다 — try-with-resources 로 해제 시점이 명확
· MethodHandle 이라 JIT 가 상수 폴딩·인라인을 할 수 있습니다
· jextract 로 헤더(.h) → 자바 바인딩을 생성합니다
★ 한계
· 안드로이드에는 없습니다 (ART 는 FFM 미구현) → v10 은 JNI/UniFFI
· 네이티브 → 자바 콜백(업콜)은 JNI 보다 비쌉니다
· Java 22 미만에서는 아예 못 씁니다
jni 크레이트 — 첫 모듈이 도는 데까지
JNI 는 이름 규칙만 맞으면 도는 단순한 규약입니다.
자바에 native 메서드를 선언하고, 그 이름에 대응하는 심볼을 Rust 가 내보내고,
System.loadLibrary 로 .so/.dll 을 올리면 끝입니다.
여기서는 파일 세 개로 첫 호출을 성공시키고, Gradle 이 cargo 를 부르게 만듭니다.
파일 세 개로 시작 ★
# Cargo.toml
[package]
name = "mylib"
version = "0.1.0"
edition = "2021"
[lib]
name = "mylib" # → libmylib.so / mylib.dll / libmylib.dylib
crate-type = ["cdylib"] # ★ 동적 라이브러리 (🔗 f02)
[dependencies]
jni = "0.21"
[profile.release]
lto = true
codegen-units = 1
panic = "abort" # ★ 주의 — v04 를 읽고 결정하세요
// src/lib.rs
use jni::JNIEnv;
use jni::objects::JClass;
use jni::sys::jint;
// 자바 : package com.example; class Native { static native int addNumbers(int,int); }
// 심볼 : Java_com_example_Native_addNumbers
#[no_mangle]
pub extern "system" fn Java_com_example_Native_addNumbers(
_env: JNIEnv, // ① JVM 을 다시 부르는 창구
_class: JClass, // ② static 이면 클래스, 인스턴스면 JObject(this)
a: jint, // ③ 여기서부터 실제 인자
b: jint,
) -> jint {
a + b // jint 는 i32 의 별칭입니다
}
extern "system" 을 쓰세요 — 32비트 윈도우에서 JNI 는 stdcall 입니다.
extern "C" 로 적으면 그 플랫폼에서만 스택이 깨집니다.
64비트에서는 같지만 습관을 그렇게 들이는 편이 안전합니다.
자바 쪽과 실행
// src/main/java/com/example/Native.java
package com.example;
public final class Native {
static {
// ① java.library.path 에서 libmylib.so 를 찾습니다
System.loadLibrary("mylib");
// 개발 중에는 절대 경로가 편합니다
// System.load("/abs/path/target/release/libmylib.so");
}
private Native() {}
public static native int addNumbers(int a, int b);
}
# ① Rust 빌드
cargo build --release
ls target/release/ # libmylib.so | mylib.dll | libmylib.dylib
# ② 심볼 확인 ★ 문제가 나면 항상 여기부터
nm -D target/release/libmylib.so | grep Java_
# 0000000000008a10 T Java_com_example_Native_addNumbers
dumpbin /exports target\release\mylib.dll | findstr Java_ # 윈도우
nm -gU target/release/libmylib.dylib | grep Java_ # 맥
# ③ 자바 실행 — 라이브러리 경로를 알려 줍니다
javac -d out src/main/java/com/example/Native.java
java -Djava.library.path=target/release -cp out com.example.Main
-Xcheck:jni 를 개발 내내 켜 두세요 — 잘못된 참조 사용·예외 미처리를
죽기 전에 알려 줍니다. 운영에서는 느려지므로 끕니다(v04).
이렇게도 쓸 수 있다 — 이름 규칙과 파일 이름 규칙 🟡 중급
심볼 이름 만드는 법 ★
규칙
Java_ + 패키지경로(. → _) + _ + 클래스명 + _ + 메서드명
예시
com.example.Native#addNumbers
→ Java_com_example_Native_addNumbers
com.my_app.Codec#encode ← 패키지에 밑줄이 있음 ★
→ Java_com_my_1app_Codec_encode (_ 는 _1 로)
com.example.Outer$Inner#run ← 중첩 클래스
→ Java_com_example_Outer_00024Inner_run ($ 는 _00024)
오버로드가 있으면 시그니처까지 붙습니다 ★
encode(byte[]) 와 encode(String) 이 함께 있으면
→ Java_com_example_Codec_encode___3B (byte[])
→ Java_com_example_Codec_encode__Ljava_lang_String_2
★ 그래서 오버로드는 피하고, 이름을 다르게 두는 편이 낫습니다.
정확한 이름을 얻는 가장 확실한 방법 (오타 방지)
javac -h include src/main/java/com/example/Native.java
cat include/com_example_Native.h # ← 여기 그대로 적혀 있습니다 ★
플랫폼별 파일 이름
| OS | cargo 산출물 | loadLibrary 인자 |
|---|---|---|
| 리눅스 | libmylib.so | "mylib" |
| 맥 | libmylib.dylib | |
| 윈도우 | mylib.dll | |
| 안드로이드 | libmylib.so | "mylib" |
// System.loadLibrary 는 접두사/확장자를 알아서 붙입니다.
// 확인용 — 이 플랫폼에서 어떤 파일을 찾는지
System.out.println(System.mapLibraryName("mylib"));
// 리눅스 : libmylib.so
// 맥 : libmylib.dylib
// 윈도우 : mylib.dll
// 실패하면 무엇을 찾았는지 보여 주는 로그
System.out.println(System.getProperty("java.library.path"));
System.loadLibrary 는 .dylib 를 찾지만
일부 도구는 .jnilib 를 기대합니다. 둘 다 두거나 System.load 로 절대 경로를 주면 끝납니다.
윈도우에서는 mylib.dll 이 다른 DLL(예: vcruntime)에 의존하면
그 DLL 을 못 찾아서 UnsatisfiedLinkError 가 나기도 합니다(v09).
실무에선 이렇게 — Gradle 이 cargo 를 부르게 🔴 실무
build.gradle.kts ★
// build.gradle.kts — cargo 를 빌드에 엮습니다
plugins { java }
val rustDir = layout.projectDirectory.dir("rust")
val rustOut = rustDir.dir("target/release")
val cargoBuild by tasks.registering(Exec::class) {
workingDir = rustDir.asFile
commandLine("cargo", "build", "--release")
// 소스가 안 바뀌면 건너뜁니다 (증분 빌드) ★
inputs.dir(rustDir.dir("src"))
inputs.file(rustDir.file("Cargo.toml"))
outputs.dir(rustOut)
}
// 빌드한 .so 를 리소스로 넣습니다 (v09 의 jar 배치와 이어집니다)
tasks.named<ProcessResources>("processResources") {
dependsOn(cargoBuild)
from(rustOut) {
include("*.so", "*.dll", "*.dylib")
into("native/" + osArchDir()) // native/linux-x86_64/…
}
}
tasks.test {
dependsOn(cargoBuild)
systemProperty("java.library.path", rustOut.asFile.absolutePath)
}
fun osArchDir(): String {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch")
val o = when { os.contains("win") -> "windows"
os.contains("mac") -> "macos"
else -> "linux" }
return "$o-$arch"
}
Maven 이라면
<!-- pom.xml — exec-maven-plugin 으로 cargo 호출 -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>cargo-build</id>
<phase>generate-resources</phase>
<goals><goal>exec</goal></goals>
<configuration>
<executable>cargo</executable>
<workingDirectory>${project.basedir}/rust</workingDirectory>
<arguments>
<argument>build</argument>
<argument>--release</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
★ 권장 디렉터리 구조
myproject/
├─ rust/
│ ├─ Cargo.toml
│ └─ src/
│ ├─ lib.rs pub 로직 — JNIEnv 를 모릅니다 ★
│ └─ jni_api.rs #[no_mangle] 함수만 (얇게)
├─ src/main/java/com/example/
│ ├─ Native.java native 선언 + 로더
│ └─ Resizer.java 공개 API (자바다운 얼굴)
└─ build.gradle.kts
★ 왜 이렇게 나누나
· rust/src/lib.rs 는 cargo test 로 검증됩니다 (JVM 없이)
· jni_api.rs 만 JNI 규약을 압니다 — 사고가 여기로 모입니다
· Native.java 는 package-private 으로 숨기고
Resizer.java 가 검증·문서·오버로드를 담당합니다 (v12)
한 단계 더 — 이름 대신 등록하기 · JNI_OnLoad 🧬 전문가
RegisterNatives — 이름 규칙에서 벗어나기
use jni::{JNIEnv, JavaVM, NativeMethod};
use jni::sys::{jint, jobject, JNI_VERSION_1_6, JNI_ERR};
use std::ffi::c_void;
// 심볼 이름을 안 맞춰도 됩니다 — 로드 시점에 직접 등록합니다
extern "system" fn add(_env: JNIEnv, _c: jobject, a: jint, b: jint) -> jint { a + b }
#[no_mangle]
pub extern "system" fn JNI_OnLoad(vm: JavaVM, _r: *mut c_void) -> jint {
let Ok(mut env) = vm.get_env() else { return JNI_ERR };
let class = match env.find_class("com/example/Native") {
Ok(c) => c,
Err(_) => return JNI_ERR,
};
let methods = [NativeMethod {
name: "addNumbers".into(),
sig: "(II)I".into(), // ★ JNI 시그니처 (v03)
fn_ptr: add as *mut c_void,
}];
if env.register_native_methods(&class, &methods).is_err() {
return JNI_ERR;
}
JNI_VERSION_1_6 // ★ 지원하는 JNI 버전을 반드시 반환
}
// 라이브러리가 내려갈 때 (거의 안 불립니다 — 정리를 여기에 의존하지 마세요)
#[no_mangle]
pub extern "system" fn JNI_OnUnload(_vm: JavaVM, _r: *mut c_void) {}
strip 으로 숨기고 싶을 때입니다. 안드로이드에서는 거의 표준 관행입니다(v10).
JNI 시그니처 문법 ★
| 자바 타입 | 시그니처 | Rust(jni::sys) |
|---|---|---|
| boolean | Z | jboolean (u8) |
| byte | B | jbyte (i8) |
| char | C | jchar (u16) |
| short / int | S / I | jshort/jint |
| long / float | J / F | jlong/jfloat |
| double / void | D / V | jdouble/() |
| String | Ljava/lang/String; | JString |
| byte[] | [B | JByteArray |
| int[][] | [[I | JObjectArray |
메서드 시그니처 = (인자들)반환
int add(int,int) → (II)I
byte[] resize(byte[], int, int) → ([BII)[B
void log(String) → (Ljava/lang/String;)V
String greet(String, boolean) → (Ljava/lang/String;Z)Ljava/lang/String;
★ 손으로 적지 마세요 — 틀리면 조용히 죽습니다(SIGSEGV).
javap -s -p com.example.Native
public static native int addNumbers(int, int);
descriptor: (II)I ← ★ 이 줄을 그대로 복사
javap -s 출력을 복사해서 쓰세요. v04 의 ID 캐싱에서 다시 나옵니다.
crate-type = ["cdylib"] · extern "system" · Java_패키지_클래스_메서드 세 가지만 맞으면 첫 호출이 돕니다.
이름은 javac -h 가 만들어 준 헤더에서 복사하고, 시그니처는 javap -s 에서 복사하세요.
Rust 쪽은 로직(lib.rs)과 JNI 어댑터(jni_api.rs)를 반드시 나눕니다.타입 · 문자열 · 배열 — 경계를 넘는 데이터
숫자는 그냥 넘어갑니다. 문제는 문자열과 배열입니다. 자바 문자열은 UTF-16 이고 JNI 는 Modified UTF-8 이라는 변종을 씁니다. 배열은 복사할지 · 핀으로 고정할지 · 아예 힙 밖에 둘지를 골라야 하고, 이 선택이 성능의 대부분을 정합니다.
기본 타입 대응표 ★
| 자바 | jni::sys | Rust | 비고 |
|---|---|---|---|
| boolean | jboolean | u8 | 0/1 로 오갑니다 |
| byte | jbyte | i8 | 부호 있음 ★ |
| char | jchar | u16 | UTF-16 코드 단위 |
| short | jshort | i16 | |
| int | jint | i32 | |
| long | jlong | i64 | 포인터를 실어 보냄(v04) |
| float | jfloat | f32 | |
| double | jdouble | f64 | |
| String | jstring | JString | 변환 필요 |
| byte[] | jbyteArray | JByteArray | 전략 3가지 |
byte 는 부호 있는 8비트입니다 — Rust 의 i8 입니다.
바이트 데이터를 다룰 때는 u8 로 보고 싶으므로
convert_byte_array 로 Vec<u8> 를 받거나
as u8 로 캐스팅하세요. 부호 확장 버그가 가장 흔한 첫 실수입니다.
문자열 — 양방향
use jni::JNIEnv;
use jni::objects::{JClass, JString};
#[no_mangle]
pub extern "system" fn Java_com_example_Native_greet<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
name: JString<'local>,
) -> JString<'local> {
// ① 자바 String → Rust String
// get_string 은 JavaStr 를 주고, Into<String> 이 구현돼 있습니다
let name: String = match env.get_string(&name) {
Ok(s) => s.into(),
Err(_) => {
let _ = env.throw_new("java/lang/IllegalArgumentException", "name 이 null 입니다");
return JString::default(); // ★ 예외를 던졌으면 즉시 반환 (v04)
}
};
// ② 계산은 순수 Rust 로 (여기서 JNIEnv 를 쓰지 않습니다)
let out = format!("안녕하세요, {name}! 글자 수 {}", name.chars().count());
// ③ Rust String → 자바 String
env.new_string(out).unwrap_or_default()
}
public static native String greet(String name);
// System.out.println(Native.greet("한글")); → 안녕하세요, 한글! 글자 수 2
이렇게도 쓸 수 있다 — Modified UTF-8 이라는 함정 🟡 중급
무엇이 다른가 ★
JNI 의 GetStringUTFChars 가 주는 것은 표준 UTF-8 이 아닙니다.
① NUL(U+0000) 을 0xC0 0x80 두 바이트로 씁니다
→ C 문자열로 다뤄도 중간에 끊기지 않게 하려는 설계
② BMP 밖 문자(이모지 · 일부 한자)를 서로게이트 쌍 두 개로 나눠
각각 3바이트로 인코딩합니다 (표준 UTF-8 이면 4바이트 하나)
"🦀" → 표준 UTF-8 : F0 9F A6 80 (4바이트)
→ Modified : ED A0 BD ED B6 80 (6바이트) ★
무엇이 문제인가
· Rust 의 String 은 표준 UTF-8 만 허용합니다
· 이모지가 든 문자열을 그대로 from_utf8 하면 실패하거나 깨집니다
jni 0.21 은 이것을 알아서 처리합니다
· env.get_string(&s) → 내부에서 Modified UTF-8 을 표준으로 변환 ★
· env.new_string(s) → 표준을 Modified 로 변환해 자바에 전달
→ 크레이트를 쓰는 한 신경 쓸 일이 거의 없습니다.
★ 직접 JNI C API 를 부르거나 다른 언어(C++)와 섞을 때만 문제가 됩니다.
그때는 GetStringChars(UTF-16) 를 받아 직접 변환하는 편이 안전합니다.
UTF-16 을 직접 받기 · 문자열 최적화
use jni::objects::JString;
// 방법 ① 가장 단순 — 크레이트에 맡깁니다 (거의 항상 이걸로)
let s: String = env.get_string(&jstr)?.into();
// 방법 ② 복사 없이 잠깐만 보기 (짧은 검사에만)
let java_str = env.get_string(&jstr)?; // JavaStr — Drop 시 Release
let bytes: &[u8] = java_str.to_bytes(); // Modified UTF-8 원본 ★
if bytes.starts_with(b"http") { /* … */ }
// 방법 ③ 문자열을 아예 안 쓰기 ★ 가장 빠릅니다
// 경계를 넘는 문자열이 많다면 byte[] 로 UTF-8 을 직접 주고받으세요.
// 자바: byte[] b = s.getBytes(StandardCharsets.UTF_8);
// Rust: let v = env.convert_byte_array(&arr)?;
// let s = std::str::from_utf8(&v)?; // 변환 한 번으로 끝
// 반환도 마찬가지 — 큰 문자열이면 byte[] 로 돌려주고
// 자바에서 new String(bytes, UTF_8) 하는 편이 빠릅니다.
get_string 으로 충분합니다.
수십 KB 이상이거나 초당 수만 번이면 byte[] + UTF-8 로 바꾸세요.
Modified UTF-8 변환은 문자열 길이에 비례합니다.
실무에선 이렇게 — 배열 전달 세 가지 전략 🔴 실무
① 복사 · ② 핀 — 코드
use jni::JNIEnv;
use jni::objects::{JByteArray, JClass, JIntArray, ReleaseMode};
// ① 복사 — 가장 단순합니다
#[no_mangle]
pub extern "system" fn Java_com_example_Native_sumBytes(
mut env: JNIEnv, _c: JClass, arr: JByteArray,
) -> jni::sys::jlong {
// jbyte(i8) 배열을 Vec<u8> 로 복사해서 줍니다 ★
let data: Vec<u8> = match env.convert_byte_array(&arr) {
Ok(v) => v,
Err(_) => return -1,
};
data.iter().map(|&b| b as i64).sum()
}
// ② 핀 — 복사를 피하고 결과를 되씁니다
#[no_mangle]
pub extern "system" fn Java_com_example_Native_doubleInPlace(
mut env: JNIEnv, _c: JClass, arr: JIntArray,
) {
// CopyBack : 끝날 때 자바 배열로 값을 되돌려 씁니다
// NoCopyBack : 읽기만 할 때 (되쓰기 비용을 아낍니다) ★
let mut elems = match unsafe { env.get_array_elements(&arr, ReleaseMode::CopyBack) } {
Ok(e) => e,
Err(_) => return,
};
for v in elems.iter_mut() { *v *= 2; }
// elems 가 drop 될 때 Release 가 호출됩니다 (자동) ★
}
get_array_elements 는 복사를 보장하지 않고 안 하는 것도 보장하지 않습니다 —
JVM 구현이 정합니다(HotSpot 은 보통 복사합니다).
“핀을 걸면 복사가 없다”고 믿고 설계하면 기대와 다른 성능이 나옵니다.
진짜 제로카피는 Direct 버퍼뿐입니다.
Critical 영역 — 마지막 수단
use jni::objects::ReleaseMode;
// GetPrimitiveArrayCritical 대응 — jni 0.21 의 이름
let elems = unsafe { env.get_array_elements_critical(&arr, ReleaseMode::NoCopyBack)? };
let slice: &[i8] = &elems;
// … 여기서는 아주 짧은 작업만 …
drop(elems); // 되도록 빨리 놓습니다 ★
★ Critical 영역 안에서 절대 하면 안 되는 것
· 다른 JNI 함수 호출 (자바 메서드 · 예외 · 새 객체)
· 블로킹 (뮤텍스 · I/O · sleep · 채널 대기)
· 긴 계산 ★
왜 위험한가
이 구간 동안 JVM 이 GC 를 미룰 수 있습니다(구현에 따라 STW 유발).
안에서 오래 머물면 애플리케이션 전체가 멈춥니다.
다른 스레드가 GC 를 기다리다 데드락이 나기도 합니다.
★ 실무 결론
Critical 은 "수 마이크로초짜리 memcpy" 정도에만 쓰세요.
이미지 처리처럼 오래 걸리는 일에는 절대 쓰지 않습니다.
큰 데이터 + 긴 작업 = DirectByteBuffer 가 정답입니다 ★
참고 : ZGC · Shenandoah 처럼 이동형 GC 에서는
Critical 이 사실상 복사로 처리되기도 해서 이득이 없습니다.
한 단계 더 — DirectByteBuffer 로 진짜 제로카피 🧬 전문가
Rust 쪽 — 버퍼를 그대로 보기
use jni::objects::{JByteBuffer, JClass};
use jni::sys::jint;
use jni::JNIEnv;
#[no_mangle]
pub extern "system" fn Java_com_example_Native_grayscale(
mut env: JNIEnv, _c: JClass,
buf: JByteBuffer, // ByteBuffer.allocateDirect 로 만든 것이어야 합니다 ★
width: jint, height: jint,
) -> jint {
// ① 주소와 용량을 얻습니다 — 둘 다 실패할 수 있습니다
let ptr = match unsafe { env.get_direct_buffer_address(&buf) } {
Ok(p) if !p.is_null() => p,
_ => return -1, // Direct 가 아니면 여기서 걸립니다
};
let cap = match env.get_direct_buffer_capacity(&buf) {
Ok(c) => c,
Err(_) => return -2,
};
// ② 크기 검증을 반드시 합니다 ★ (여기서 안 막으면 힙 밖을 밟습니다)
let need = (width as usize) * (height as usize) * 4;
if cap < need { return -3; }
// ③ 슬라이스로 보고 순수 Rust 로 처리
let px = unsafe { std::slice::from_raw_parts_mut(ptr, need) };
for chunk in px.chunks_exact_mut(4) {
let g = ((chunk[0] as u32 * 299 + chunk[1] as u32 * 587
+ chunk[2] as u32 * 114) / 1000) as u8;
chunk[0] = g; chunk[1] = g; chunk[2] = g; // 알파는 그대로
}
0
}
자바 쪽 — 버퍼 풀
public final class Frames implements AutoCloseable {
// Direct 버퍼는 만들고 버리는 비용이 큽니다 → 재사용합니다 ★
private static final ThreadLocal<ByteBuffer> POOL =
ThreadLocal.withInitial(() -> ByteBuffer.allocateDirect(4 * 1024 * 1024));
public static ByteBuffer borrow(int need) {
ByteBuffer b = POOL.get();
if (b.capacity() < need) { // 모자라면 키워서 교체
b = ByteBuffer.allocateDirect(need);
POOL.set(b);
}
b.clear();
return b;
}
public static void grayscale(ByteBuffer rgba, int w, int h) {
if (!rgba.isDirect()) throw new IllegalArgumentException("direct 만 됩니다");
int rc = Native.grayscale(rgba, w, h); // ★ 여기서 복사가 없습니다
if (rc != 0) throw new IllegalStateException("native rc=" + rc);
}
@Override public void close() { POOL.remove(); }
}
★ Direct 버퍼 운영 주의점
· 회수는 GC 가 Cleaner 로 처리합니다 — 즉시가 아닙니다
· 총량 상한 : -XX:MaxDirectMemorySize=512m (기본은 -Xmx 와 같음)
· 넘치면 OutOfMemoryError: Direct buffer memory
· 모니터링 : jcmd <pid> VM.native_memory summary
(미리 -XX:NativeMemoryTracking=summary 로 띄워야 합니다)
★ 대안 — 네이티브가 할당하고 자바가 감싸기
Rust 에서 malloc 한 메모리를 env.new_direct_byte_buffer(ptr, len) 로
감싸 자바에 돌려줄 수 있습니다. 이때 해제 책임은 Rust 에 있으므로
자바 쪽에 close() 를 반드시 만들어 두세요 (v04 의 핸들 패턴).
convert_byte_array) · 되쓰기가 필요하면 핀(get_array_elements) ·
1MB 이상이면 DirectByteBuffer 입니다.
Critical 영역은 수 마이크로초짜리에만 쓰고, Direct 버퍼는 풀에 두고 재사용하세요.객체 · 콜백 · 예외 — 참조 수명이 핵심입니다
JNI 에서 받은 JObject 는 포인터가 아니라 핸들입니다.
네이티브 메서드가 반환하는 순간 자동으로 무효가 되고, 다른 스레드로 가져갈 수도 없습니다.
이 규칙 하나를 모르면 재현되지 않는 크래시가 납니다.
여기서 지역/전역 참조 · ID 캐싱 · 예외 · 패닉 · 스레드 attach 를 전부 정리합니다.
자바 객체의 메서드 부르기
use jni::objects::{JClass, JObject, JValue};
use jni::JNIEnv;
#[no_mangle]
pub extern "system" fn Java_com_example_Native_describe(
mut env: JNIEnv, _c: JClass, obj: JObject,
) {
// ① 필드 읽기 — 이름과 시그니처가 맞아야 합니다 (v02)
let width = env.get_field(&obj, "width", "I")
.and_then(|v| v.i())
.unwrap_or(0);
// ② 메서드 호출 — 반환 타입은 시그니처 마지막에서 옵니다
let name: String = match env.call_method(&obj, "getName", "()Ljava/lang/String;", &[]) {
Ok(v) => {
let s = v.l().unwrap(); // JValue → JObject
env.get_string(&s.into()).unwrap().into()
}
Err(_) => { env.exception_clear().ok(); String::new() }
};
// ③ 인자가 있는 호출 — JValue 로 감쌉니다
let _ = env.call_method(&obj, "setLabel",
"(Ljava/lang/String;I)V",
&[JValue::Object(&env.new_string("리사이즈됨").unwrap().into()),
JValue::Int(width)]);
// ④ 새 객체 만들기
let point = env.new_object("com/example/Point", "(II)V",
&[JValue::Int(1), JValue::Int(2)]);
let _ = point;
}
Err(Exception) 로 알려 주므로 ? 나 match 로 반드시 받으세요.
ID 캐싱 — 매번 찾지 않습니다 ★
use std::sync::OnceLock;
use jni::objects::{GlobalRef, JMethodID};
use jni::signature::{Primitive, ReturnType};
// find_class · get_method_id 는 문자열 검색이라 비쌉니다.
// 한 번만 찾고 재사용합니다 ★
static CB_CLASS: OnceLock<GlobalRef> = OnceLock::new();
static CB_ON_PROGRESS: OnceLock<JMethodID> = OnceLock::new();
fn init_ids(env: &mut JNIEnv) -> jni::errors::Result<()> {
if CB_CLASS.get().is_some() { return Ok(()); }
let class = env.find_class("com/example/ProgressListener")?;
let mid = env.get_method_id(&class, "onProgress", "(I)V")?;
// ★ 클래스는 GlobalRef 로 살려 둬야 합니다.
// 클래스가 언로드되면 JMethodID 가 무효가 되기 때문입니다.
let _ = CB_CLASS.set(env.new_global_ref(class)?);
let _ = CB_ON_PROGRESS.set(mid);
Ok(())
}
fn notify(env: &mut JNIEnv, listener: &JObject, pct: i32) {
let Some(&mid) = CB_ON_PROGRESS.get() else { return };
// 캐싱한 ID 로 부르는 빠른 경로 — 이름 검색이 사라집니다
let r = unsafe {
env.call_method_unchecked(listener, mid,
ReturnType::Primitive(Primitive::Void),
&[jni::objects::JValue::Int(pct).as_jni()])
};
if r.is_err() { let _ = env.exception_clear(); }
}
call_method(이름 검색 포함)는 캐싱한 call_method_unchecked 대비
몇 배 느립니다. 콜백을 초당 수천 번 이상 부른다면 캐싱이 필수입니다.
한 번씩 부르는 곳이라면 읽기 쉬운 call_method 그대로 두세요.
이렇게도 쓸 수 있다 — 예외 던지기 · 패닉 가두기 🟡 중급
Rust 의 에러를 자바 예외로
use jni::JNIEnv;
// ★ 규칙 : 예외를 '예약' 한 뒤에는 다른 JNI 함수를 부르지 말고 즉시 반환합니다.
fn throw(env: &mut JNIEnv, class: &str, msg: &str) {
// 이미 예외가 걸려 있으면 덮어쓰지 않습니다
if env.exception_check().unwrap_or(false) { return; }
let _ = env.throw_new(class, msg);
}
pub enum CoreError { BadInput(String), TooLarge(usize), Io(std::io::Error) }
fn report(env: &mut JNIEnv, e: CoreError) {
match e {
CoreError::BadInput(m) =>
throw(env, "java/lang/IllegalArgumentException", &m),
CoreError::TooLarge(n) =>
throw(env, "java/lang/IllegalArgumentException",
&format!("입력이 너무 큽니다: {n} 바이트")),
CoreError::Io(e) =>
throw(env, "java/io/IOException", &e.to_string()),
}
}
// 커스텀 예외를 쓰고 싶다면 클래스를 직접 만들어 던집니다
fn throw_custom(env: &mut JNIEnv, msg: &str) {
let _ = env.throw_new("com/example/NativeException", msg);
}
// 자바 쪽에서는 평범한 예외로 보입니다 ★
try {
byte[] out = Native.resize(src, 800, 600);
} catch (IllegalArgumentException e) {
log.warn("입력이 잘못됐습니다: {}", e.getMessage());
}
패닉이 경계를 넘으면 안 됩니다 ★
use std::panic::{catch_unwind, AssertUnwindSafe};
#[no_mangle]
pub extern "system" fn Java_com_example_Native_resize(
mut env: JNIEnv, _c: JClass, src: JByteArray, w: jint, h: jint,
) -> JByteArray {
// ★ 모든 extern 함수를 이 틀로 감쌉니다
let result = catch_unwind(AssertUnwindSafe(|| {
let data = env.convert_byte_array(&src)?;
let out = core::resize(&data, w as u32, h as u32)?; // 순수 Rust
env.byte_array_from_slice(&out).map_err(Into::into)
}));
match result {
Ok(Ok(arr)) => arr,
Ok(Err(e)) => { report(&mut env, e); JByteArray::default() }
Err(payload) => {
// 패닉 메시지를 최대한 살려서 자바 예외로
let msg = payload.downcast_ref::<&str>().map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "네이티브 패닉".into());
throw(&mut env, "java/lang/RuntimeException", &msg);
JByteArray::default()
}
}
}
panic = "abort" 를 켜면 catch_unwind 가 아무것도 못 잡습니다 —
패닉이 곧 프로세스 종료입니다. JNI 라이브러리라면 panic = "unwind"(기본값)를 유지하고
경계에서 잡는 편이 낫습니다. 바이너리 크기 때문에 abort 를 쓰고 싶다면
패닉이 절대 안 나도록 unwrap 을 전부 없애야 합니다.
실무에선 이렇게 — Rust 스레드에서 자바 콜백 🔴 실무
백그라운드 진행률 콜백
use jni::objects::{GlobalRef, JObject, JValue};
use jni::{JNIEnv, JavaVM};
#[no_mangle]
pub extern "system" fn Java_com_example_Native_processAsync(
mut env: JNIEnv, _c: JClass, listener: JObject,
) {
// ① 스레드로 넘길 수 있는 것만 챙깁니다 ★
let vm: JavaVM = match env.get_java_vm() { Ok(v) => v, Err(_) => return };
let cb: GlobalRef = match env.new_global_ref(&listener) { Ok(g) => g, Err(_) => return };
std::thread::spawn(move || {
// ② 이 스레드를 JVM 에 붙입니다 — 안 붙이면 즉시 크래시
// as_daemon 은 JVM 종료를 막지 않습니다 (백그라운드 작업에 적합)
let mut env = match vm.attach_current_thread_as_daemon() {
Ok(e) => e,
Err(_) => return,
};
for pct in (0..=100).step_by(10) {
std::thread::sleep(std::time::Duration::from_millis(100));
// ③ 반복문 안에서 객체를 만들면 프레임을 열어 줍니다 ★
let r = env.with_local_frame::<_, _, jni::errors::Error>(8, |env| {
env.call_method(cb.as_obj(), "onProgress", "(I)V",
&[JValue::Int(pct)])?;
Ok(())
});
if r.is_err() { let _ = env.exception_clear(); break; }
}
// ④ AttachGuard 가 drop 되면서 detach 됩니다 (자동)
});
}
지켜야 할 규칙 · 진단
★ 스레드 규칙 5가지
① JNIEnv 는 절대 스레드로 넘기지 않습니다 (Send 가 아닙니다)
② JavaVM · GlobalRef 만 넘깁니다
③ 스레드마다 attach 하고, 끝나면 detach 합니다 (Guard 가 자동)
④ attach 는 싸지 않습니다 — 스레드 풀을 쓰고 반복 attach 를 피하세요
⑤ 자주 붙였다 떼면 JVM 이 스레드 목록을 계속 고칩니다 → 오버헤드
★ 콜백 자체를 피하는 설계가 더 낫습니다
· 진행률은 AtomicLong 에 써 두고 자바가 폴링
· 결과는 반환값으로 (콜백 없이)
· 이벤트가 많으면 배치로 모아 한 번에 전달
→ 네이티브 → 자바 방향(업콜)이 반대 방향보다 훨씬 비쌉니다 (v08)
★ 진단 도구
java -Xcheck:jni … 잘못된 참조·예외 미처리를 즉시 잡습니다 ★
java -verbose:jni … 심볼 해석 로그
-XX:+CreateCoredumpOnCrash 크래시 시 코어 덤프
hs_err_pid*.log "Problematic frame" 줄에 .so 이름이 보입니다
★ 자주 나는 크래시와 원인
"attaching to VM failed" attach 없이 JNIEnv 사용
"JNI ERROR: local reference" 지역 참조를 저장했다가 나중에 사용
"GetXxx called with pending exception" 예외 확인 안 함
한 단계 더 — Rust 객체를 자바 객체처럼 쥐어 주기 🧬 전문가
핸들 패턴 — long 에 포인터를 싣습니다
pub struct Engine { model: Vec<f32>, cache: std::collections::HashMap<u64, f32> }
#[no_mangle]
pub extern "system" fn Java_com_example_Engine_nativeNew(
_env: JNIEnv, _c: JClass,
) -> jni::sys::jlong {
let e = Box::new(Engine { model: vec![], cache: Default::default() });
Box::into_raw(e) as jni::sys::jlong // ★ 소유권을 자바에 넘깁니다
}
#[no_mangle]
pub extern "system" fn Java_com_example_Engine_nativeScore(
_env: JNIEnv, _c: JClass, handle: jni::sys::jlong, x: jni::sys::jint,
) -> jni::sys::jfloat {
if handle == 0 { return f32::NAN; }
// ★ Box 로 되돌리지 않고 참조로만 씁니다 (되돌리면 해제됩니다)
let e = unsafe { &mut *(handle as *mut Engine) };
e.score(x as u32)
}
#[no_mangle]
pub extern "system" fn Java_com_example_Engine_nativeFree(
_env: JNIEnv, _c: JClass, handle: jni::sys::jlong,
) {
if handle == 0 { return; }
// ★ 여기서만 Box 로 되돌려 해제합니다 (두 번 부르면 double free)
unsafe { drop(Box::from_raw(handle as *mut Engine)) };
}
자바 쪽에서 안전하게 감싸기 ★
public final class Engine implements AutoCloseable {
private long handle; // 0 = 이미 닫힘
public Engine() { handle = nativeNew(); }
public float score(int x) {
long h = handle;
if (h == 0) throw new IllegalStateException("이미 닫힌 Engine 입니다");
return nativeScore(h, x);
}
@Override public synchronized void close() {
if (handle != 0) { nativeFree(handle); handle = 0; } // ★ 두 번 해제 방지
}
private static native long nativeNew();
private static native float nativeScore(long h, int x);
private static native void nativeFree(long h);
}
// 쓰는 쪽 — try-with-resources 로 해제를 강제합니다
try (Engine e = new Engine()) {
System.out.println(e.score(42));
}
close() 를 잡고 싶다면
java.lang.ref.Cleaner 를 보조 안전망으로만 붙이고,
정상 경로는 반드시 try-with-resources 로 두세요.
handle 을 0 으로 만드는 것이 double free 를 막는 유일한 장치입니다.
GlobalRef, 다른 스레드에서 쓰려면 JavaVM + attach_current_thread 입니다.
클래스·메서드 ID 는 OnceLock 에 캐싱하고, 모든 extern 함수는 catch_unwind 로 감싸
패닉을 자바 예외로 번역하세요. Rust 객체는 long 핸들 + AutoCloseable 로 쥐어 줍니다.Panama FFM 으로 Rust 부르기 — Java 25 의 정답
FFM 은 자바가 네이티브 함수를 찾아서 부르는 방식입니다.
Rust 쪽에는 자바 전용 코드가 한 줄도 없습니다 — 평범한 extern "C" 함수면 됩니다.
그래서 같은 .so 를 파이썬·Node·C# 이 함께 씁니다(🔗 f01).
대신 변환 코드를 자바 쪽에서 써야 하는데, 그건 jextract 가 만들어 줍니다.
Rust 쪽 — C ABI 설계 ★
// src/ffi.rs — 얇게. 로직은 lib.rs 에 (🔗 f01)
use std::ffi::{c_char, CString};
use std::ptr;
#[repr(C)]
pub struct Buf { pub ptr: *mut u8, pub len: usize } // 소유권은 Rust 에 있습니다
/// 성공 0 · 실패 음수. 결과는 out 으로 돌려줍니다.
#[no_mangle]
pub extern "C" fn mylib_resize(
src: *const u8, src_len: usize,
w: u32, h: u32,
out: *mut Buf, // ★ 출력 인자
) -> i32 {
if src.is_null() || out.is_null() || w == 0 || h == 0 { return -1; }
let data = unsafe { std::slice::from_raw_parts(src, src_len) };
let r = std::panic::catch_unwind(|| crate::resize(data, w, h));
match r {
Ok(Ok(mut v)) => {
v.shrink_to_fit();
let b = Buf { ptr: v.as_mut_ptr(), len: v.len() };
std::mem::forget(v); // ★ 해제는 mylib_buf_free 가
unsafe { ptr::write(out, b) };
0
}
Ok(Err(e)) => { set_last_error(e.to_string()); -2 }
Err(_) => { set_last_error("네이티브 패닉".into()); -99 }
}
}
#[no_mangle]
pub extern "C" fn mylib_buf_free(b: Buf) {
if b.ptr.is_null() { return; }
unsafe { drop(Vec::from_raw_parts(b.ptr, b.len, b.len)) };
}
/// 마지막 에러 메시지를 버퍼에 복사하고 길이를 반환합니다.
#[no_mangle]
pub extern "C" fn mylib_last_error(buf: *mut c_char, cap: usize) -> usize { /* … */ 0 }
#[no_mangle]
pub extern "C" fn mylib_version() -> u32 { 1 }
자바 쪽 — 전체 코드 ★
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.nio.file.Path;
public final class MyLib {
// ① 라이브러리 수명 = 프로세스 수명 → 공유 Arena
private static final Arena LIB_ARENA = Arena.ofShared();
private static final SymbolLookup LOOKUP =
SymbolLookup.libraryLookup(Path.of(NativeLoader.extract()), LIB_ARENA);
private static final Linker LINKER = Linker.nativeLinker();
// ② Buf { void* ptr; size_t len; } 의 메모리 배치를 자바로 적습니다
private static final MemoryLayout BUF = MemoryLayout.structLayout(
ValueLayout.ADDRESS.withName("ptr"),
ValueLayout.JAVA_LONG.withName("len")).withName("Buf");
// ③ int mylib_resize(const uint8_t*, size_t, uint32_t, uint32_t, Buf*)
private static final MethodHandle RESIZE = LINKER.downcallHandle(
LOOKUP.find("mylib_resize").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_INT,
ValueLayout.ADDRESS, ValueLayout.JAVA_LONG,
ValueLayout.JAVA_INT, ValueLayout.JAVA_INT,
ValueLayout.ADDRESS));
private static final MethodHandle BUF_FREE = LINKER.downcallHandle(
LOOKUP.find("mylib_buf_free").orElseThrow(),
FunctionDescriptor.ofVoid(BUF));
public static byte[] resize(byte[] src, int w, int h) {
// ④ 이 블록을 벗어나면 할당한 메모리가 전부 해제됩니다 ★
try (Arena arena = Arena.ofConfined()) {
MemorySegment in = arena.allocateFrom(ValueLayout.JAVA_BYTE, src);
MemorySegment out = arena.allocate(BUF);
int rc = (int) RESIZE.invokeExact(in, (long) src.length, w, h, out);
if (rc != 0) throw new IllegalStateException("native rc=" + rc);
MemorySegment ptr = out.get(ValueLayout.ADDRESS, 0);
long len = out.get(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS.byteSize());
// ⑤ 주소만 온 상태 — 길이를 붙여야 읽을 수 있습니다 ★
byte[] result = ptr.reinterpret(len)
.toArray(ValueLayout.JAVA_BYTE);
BUF_FREE.invokeExact(out); // ⑥ 네이티브 메모리 해제
return result;
} catch (Throwable t) {
throw new RuntimeException(t);
}
}
}
이렇게도 쓸 수 있다 — 외워 둘 다섯 가지 개념 🟡 중급
FFM 어휘 대응표 ★
| FFM | C / Rust 로 치면 |
|---|---|
Linker | 호출 규약을 아는 어댑터 |
SymbolLookup | dlopen + dlsym |
FunctionDescriptor | 함수 원형(프로토타입) |
MethodHandle | 함수 포인터 |
MemorySegment | 포인터 + 길이 + 수명 |
MemoryLayout | #[repr(C)] struct |
Arena | 할당자 + 해제 시점 |
ValueLayout.ADDRESS | void* |
VarHandle | 구조체 필드 접근자 |
★ Arena 네 종류 — 수명 정책이 다릅니다
Arena.ofConfined() 한 스레드 전용. close() 시 즉시 해제. 가장 안전 ★
Arena.ofShared() 여러 스레드 공유. close() 가 조금 비쌉니다
Arena.ofAuto() GC 가 회수 — 해제 시점을 모릅니다
Arena.global() 프로세스 끝까지 — 해제 안 함
★ 왜 Arena 가 안전한가
닫힌 Arena 의 MemorySegment 를 건드리면
SIGSEGV 가 아니라 IllegalStateException 이 납니다 ★
(JNI 였다면 그냥 JVM 이 죽습니다 — 이 차이가 큽니다)
배열을 복사 없이 넘기기 — critical
// 기본 downcall 은 자바 배열을 그대로 못 넘깁니다 (GC 가 옮기므로).
// Linker.Option.critical(true) 를 주면 힙 세그먼트를 그대로 넘길 수 있습니다.
private static final MethodHandle SUM = LINKER.downcallHandle(
LOOKUP.find("mylib_sum").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG,
ValueLayout.ADDRESS, ValueLayout.JAVA_LONG),
Linker.Option.critical(true)); // ★ 힙 접근 허용
public static long sum(byte[] data) throws Throwable {
MemorySegment heap = MemorySegment.ofArray(data); // 복사 없음 ★
return (long) SUM.invokeExact(heap, (long) data.length);
}
critical 의 대가 — JNI 의 Critical 영역과 같습니다.
그 함수가 도는 동안 GC 가 방해받을 수 있습니다.
짧고 빠른 함수에만 쓰고, 그 안에서 다시 자바를 부르거나 블로킹하면 안 됩니다.
오래 걸리는 작업은 Arena 로 네이티브 메모리에 복사하는 편이 전체적으로 낫습니다.
// 구조체 필드 접근은 VarHandle 이 편합니다
private static final VarHandle BUF_LEN =
BUF.varHandle(MemoryLayout.PathElement.groupElement("len"));
long len = (long) BUF_LEN.get(out, 0L);
실무에선 이렇게 — cbindgen → jextract 로 자동화 🔴 실무
헤더를 만들고 바인딩을 뽑습니다
# cbindgen.toml — Rust → C 헤더 (🔗 f04)
language = "C"
include_guard = "MYLIB_H"
autogen_warning = "/* 자동 생성 파일입니다 — 고치지 마세요 */"
[export]
prefix = ""
include = ["Buf"]
[parse]
parse_deps = false
# ① 헤더 생성
cargo install cbindgen
cbindgen --config cbindgen.toml --crate mylib --output include/mylib.h
# ② jextract 로 자바 바인딩 생성 (JDK 와 별도 배포 도구)
# https://jdk.java.net/jextract/ 에서 받습니다
jextract --output src/gen \
--target-package com.example.mylib.raw \
--library mylib \
include/mylib.h
# 생성물 — 손으로 안 써도 됩니다 ★
# src/gen/com/example/mylib/raw/mylib_h.java 함수 · 상수
# src/gen/com/example/mylib/raw/Buf.java 구조체 레이아웃 · VarHandle
# ③ 쓰기
# import static com.example.mylib.raw.mylib_h.*;
# int rc = mylib_resize(in, len, w, h, out);
JEP 472 — 실행 옵션 ★
# Java 24 부터 네이티브 접근은 '제한된 연산' 입니다.
# 허용하지 않으면 경고가 나고, 앞으로는 오류가 될 예정입니다.
# 클래스패스 앱
java --enable-native-access=ALL-UNNAMED -jar app.jar
# 모듈 앱 — 필요한 모듈만 여는 편이 낫습니다
java --enable-native-access=com.example.mylib -m com.example/…
# 경고를 오류로 바꿔 미리 검증 (CI 에서 켜 두세요) ★
java --illegal-native-access=deny -jar app.jar
# jar 매니페스트에 적어 두는 방법 (실행 옵션 없이)
# Enable-Native-Access: ALL-UNNAMED
★ 자주 만나는 오류와 원인
UnsatisfiedLinkError / NoSuchElementException
→ LOOKUP.find("이름") 실패. nm -D 로 심볼을 확인하세요 (v02)
WrongMethodTypeException
→ invokeExact 의 인자 타입이 FunctionDescriptor 와 다릅니다.
int 자리에 long 을 주거나 캐스팅을 빠뜨린 경우 ★
(int) handle.invokeExact(...) 처럼 반환도 정확히 캐스팅합니다
IllegalStateException: Already closed
→ 닫힌 Arena 의 세그먼트를 사용. JNI 였다면 크래시였을 것입니다
IllegalArgumentException: Misaligned access
→ 레이아웃 정렬이 안 맞습니다. structLayout 에 paddingLayout 을
빠뜨렸는지 확인하세요 (cbindgen 헤더 + jextract 면 안 생깁니다)
WARNING: A restricted method in java.lang.foreign.Linker has been called
→ --enable-native-access 를 주지 않았습니다
한 단계 더 — 업콜 · 콜백을 네이티브에 넘기기 🧬 전문가
자바 메서드를 함수 포인터로
// Rust : extern "C" fn mylib_each(cb: extern "C" fn(u32) -> ())
static void onProgress(int pct) { // ① 부를 자바 메서드
System.out.println("진행률 " + pct);
}
static MemorySegment progressStub(Arena arena) throws Throwable {
MethodHandle target = MethodHandles.lookup().findStatic(
MyLib.class, "onProgress",
MethodType.methodType(void.class, int.class));
// ② 함수 포인터(스텁)를 만듭니다 — 수명은 arena 가 소유합니다 ★
return LINKER.upcallStub(target,
FunctionDescriptor.ofVoid(ValueLayout.JAVA_INT), arena);
}
try (Arena arena = Arena.ofConfined()) {
MemorySegment cb = progressStub(arena);
EACH.invokeExact(cb); // 네이티브가 이 포인터를 부릅니다
} // ★ arena 를 닫은 뒤에 네이티브가 부르면 크래시입니다 — 수명을 맞추세요
AtomicLong 폴링으로, 결과는 반환값으로 바꿀 수 있으면 그렇게 하세요.
업콜 스텁의 Arena 수명이 네이티브 사용 기간보다 짧으면 그대로 크래시입니다.
FFM 을 감싸는 실무 규칙
① 원시 바인딩(raw)과 공개 API 를 나눕니다
com.example.mylib.raw jextract 생성물 — 손대지 않음
com.example.mylib 사람이 쓰는 얼굴 (검증 · 예외 · 오버로드)
② MethodHandle 은 static final 로 한 번만 만듭니다 ★
매번 downcallHandle 하면 JIT 가 상수로 접지 못해 느려집니다
③ invokeExact 만 씁니다 (invoke 는 느립니다)
타입이 정확해야 하므로 캐스팅을 빠뜨리지 마세요
④ Arena.ofConfined() 를 기본으로, try-with-resources 를 벗어나지 않게
세그먼트를 밖으로 반환하지 마세요 (자바 배열로 복사해서 반환)
⑤ 스레드 규칙
ofConfined 세그먼트는 만든 스레드에서만 씁니다.
가상 스레드에서 네이티브를 부르면 캐리어가 붙잡힙니다(pinning) ★
오래 걸리는 네이티브 호출은 플랫폼 스레드 풀로 보내세요
⑥ 되돌릴 스위치
-Dmylib.native=false 로 순수 자바 구현으로 돌아가게 (v12)
★ 성능 감각 (v08 에서 측정합니다)
빈 downcall 수 나노초 — JNI 와 사실상 동급
critical downcall 더 낮음 (인라인 가능)
업콜(upcall) downcall 의 수십 배
Arena 할당/해제 할당 크기에 비례 — 재사용하세요
SymbolLookup 으로 찾고 downcallHandle 로 핸들을 만들어 invokeExact 로 부르며,
메모리는 Arena 가 소유합니다. 핸들은 static final, Arena 는 ofConfined,
바인딩은 cbindgen → jextract 로 자동 생성하고,
실행에는 --enable-native-access 를 잊지 마세요.UniFFI — 바인딩을 사람이 안 씁니다
UniFFI 는 Mozilla 가 파이어폭스 모바일을 만들면서 나온 도구입니다.
Rust 함수에 표시(#[uniffi::export])만 붙이면
코틀린·스위프트·파이썬 바인딩이 통째로 생성됩니다.
객체 · enum · Result · async · 콜백까지 전부 지원해서,
안드로이드·KMP 라면 가장 빨리 끝나는 길입니다.
설정 · 첫 함수 ★
# Cargo.toml
[package]
name = "imgcore"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"] # ★ lib 도 함께 (bindgen 바이너리가 씁니다)
name = "imgcore"
[dependencies]
uniffi = { version = "0.29", features = ["cli"] }
thiserror = "2"
[build-dependencies]
uniffi = { version = "0.29", features = ["build"] }
[[bin]]
name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"
// uniffi-bindgen.rs — 생성기 실행용 바이너리 (내용은 이게 전부)
fn main() { uniffi::uniffi_bindgen_main() }
// src/lib.rs
uniffi::setup_scaffolding!(); // ★ 이 한 줄이 뼈대를 깝니다
#[uniffi::export]
pub fn resize(data: Vec<u8>, width: u32, height: u32) -> Result<Vec<u8>, ImgError> {
crate::core::resize(&data, width, height)
}
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum ImgError {
#[error("이미지를 읽지 못했습니다: {msg}")]
Decode { msg: String },
#[error("크기가 잘못됐습니다")]
BadSize,
}
생성하고 쓰기
# ① 라이브러리 빌드
cargo build --release
# ② 바인딩 생성 — 라이브러리에서 직접 읽습니다 ★
cargo run --bin uniffi-bindgen -- generate \
--library target/release/libimgcore.so \
--language kotlin \
--out-dir bindings/kotlin
# 스위프트 · 파이썬도 같은 명령으로
cargo run --bin uniffi-bindgen -- generate --library target/release/libimgcore.so \
--language swift --out-dir bindings/swift
cargo run --bin uniffi-bindgen -- generate --library target/release/libimgcore.so \
--language python --out-dir bindings/python
// 생성된 코틀린을 그대로 씁니다 — 예외까지 코틀린답게 나옵니다 ★
import uniffi.imgcore.*
fun main() {
val src = File("in.jpg").readBytes()
try {
val out = resize(src, 800u, 600u) // UInt 로 매핑됩니다
File("out.jpg").writeBytes(out)
} catch (e: ImgException.Decode) {
println("디코드 실패: ${e.message}")
}
}
kotlinx-coroutines 를 씁니다.
Gradle 에 net.java.dev.jna:jna 를 넣어야 하고,
안드로이드에서는 @aar 분류자가 붙은 아티팩트를 씁니다(v10).
이렇게도 쓸 수 있다 — 객체 · enum · 콜백 · async 🟡 중급
표현할 수 있는 것들 ★
use std::sync::Arc;
// ① 값 타입 — 코틀린 data class · 스위프트 struct 로 나옵니다
#[derive(uniffi::Record)]
pub struct Size { pub width: u32, pub height: u32 }
// ② enum — 코틀린 sealed class 로 (연관 값도 됩니다)
#[derive(uniffi::Enum)]
pub enum Fit { Cover, Contain, Exact { w: u32, h: u32 } }
// ③ 상태를 가진 객체 — 코틀린 class 로. 반드시 Arc 로 다룹니다 ★
#[derive(uniffi::Object)]
pub struct Engine { cache: std::sync::Mutex<Vec<u8>> }
#[uniffi::export]
impl Engine {
#[uniffi::constructor]
pub fn new() -> Arc<Self> { Arc::new(Self { cache: Default::default() }) }
pub fn resize(&self, data: Vec<u8>, size: Size, fit: Fit)
-> Result<Vec<u8>, ImgError> { /* … */ Ok(vec![]) }
}
// ④ 콜백 — 코틀린 인터페이스를 Rust 가 부릅니다
#[uniffi::export(with_foreign)]
pub trait Progress: Send + Sync {
fn on_progress(&self, pct: u32);
}
// ⑤ async — 코틀린 suspend 함수로 나옵니다 ★
#[uniffi::export(async_runtime = "tokio")]
pub async fn fetch_and_resize(url: String, size: Size)
-> Result<Vec<u8>, ImgError> { /* … */ Ok(vec![]) }
코틀린 쪽 결과물
// 위 Rust 가 이렇게 나옵니다 — 손으로 쓴 것처럼 자연스럽습니다 ★
data class Size(val width: UInt, val height: UInt)
sealed class Fit {
object Cover : Fit()
object Contain : Fit()
data class Exact(val w: UInt, val h: UInt) : Fit()
}
class Engine : Disposable, AutoCloseable {
constructor()
fun resize(data: ByteArray, size: Size, fit: Fit): ByteArray
override fun close() // ★ Rust 객체 해제 (v04 의 핸들 패턴을 자동으로)
}
interface Progress { fun onProgress(pct: UInt) }
suspend fun fetchAndResize(url: String, size: Size): ByteArray // ★ 코루틴 그대로
// 쓰는 쪽
val engine = Engine()
engine.use { e ->
val out = e.resize(bytes, Size(800u, 600u), Fit.Cover)
}
viewModelScope.launch {
val img = fetchAndResize("https://…", Size(400u, 400u)) // 메인 스레드를 안 막습니다
}
close() 하세요 — 생성된 클래스는 Rust Arc 를 붙잡고 있습니다.
코틀린 use { }, 스위프트는 deinit 가 자동입니다.
안 닫으면 네이티브 메모리가 그대로 남습니다.
실무에선 이렇게 — 빌드 파이프라인에 넣기 🔴 실무
Gradle 에서 한 번에 ★
// build.gradle.kts — cargo 빌드 + 바인딩 생성 + 소스셋 등록
val rustDir = layout.projectDirectory.dir("../rust")
val genDir = layout.buildDirectory.dir("generated/uniffi")
val cargoBuild by tasks.registering(Exec::class) {
workingDir = rustDir.asFile
commandLine("cargo", "build", "--release")
inputs.dir(rustDir.dir("src")); outputs.dir(rustDir.dir("target/release"))
}
val uniffiGen by tasks.registering(Exec::class) {
dependsOn(cargoBuild)
workingDir = rustDir.asFile
commandLine("cargo", "run", "--bin", "uniffi-bindgen", "--",
"generate", "--library", "target/release/libimgcore.so",
"--language", "kotlin",
"--out-dir", genDir.get().asFile.absolutePath)
outputs.dir(genDir)
}
sourceSets["main"].java.srcDir(genDir) // ★ 생성 코드를 소스로 등록
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> { dependsOn(uniffiGen) }
dependencies {
implementation("net.java.dev.jna:jna:5.17.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}
언제 UniFFI 가 아닌가
UniFFI 가 맞는 경우
· 안드로이드 + iOS 에 같은 로직 (v10 · v11)
· 코틀린·스위프트·파이썬을 함께 지원
· 타입이 자주 바뀌는 초기 단계
→ 다시 생성만 하면 됩니다
· 객체·enum·async 를 쓰고 싶다
· JNI 를 배울 시간을 아끼고 싶다
UniFFI 가 아닌 경우
· 초당 수십만 번 부르는 뜨거운 경로
→ JNI 나 FFM 이 낫습니다 (v08)
· 큰 배열을 복사 없이 넘겨야 함
→ UniFFI 는 ByteArray 를 복사합니다 ★
· 이미 자바 전용이고 앞으로도 그렇다
→ FFM 이 더 단순합니다
· 생성 코드를 통제하고 싶다
· 자바(코틀린 아님)에서 직접 쓰고 싶다
→ 코틀린 API 라 자바에서는 어색합니다
Vec<u8> 는 경계에서 복사됩니다.
4K 이미지 한 장(약 24MB)을 주고받으면 왕복 두 번의 복사가 생깁니다.
그런 경로만 JNI + DirectByteBuffer 로 따로 빼는 혼합 설계가 실무에서 흔합니다(v12).
한 단계 더 — 생성 코드가 실제로 하는 일 🧬 전문가
안을 열어 보면
UniFFI 가 만드는 세 겹
① Rust 쪽 스캐폴딩 (매크로가 생성)
uniffi_imgcore_fn_func_resize(...) ← extern "C" 함수
RustBuffer { capacity, len, data } ← 직렬화된 값 컨테이너
RustCallStatus { code, error_buf } ← 에러 전달 구조체
② 라이브러리에 심어 둔 메타데이터
함수 이름 · 인자 타입 · 반환 타입 · 체크섬
→ uniffi-bindgen 이 .so 를 읽어 이 표를 복원합니다 ★
③ 언어별 바인딩
코틀린 : JNA Library 인터페이스 + 변환 코드 + 예외 클래스
스위프트 : @_cdecl 없이 직접 심볼 호출
★ 체크섬이 하는 일
바인딩과 라이브러리 버전이 어긋나면 로드 시점에 즉시 실패합니다.
"UniFFI API checksum mismatch" — 바인딩을 다시 생성하세요.
(조용히 잘못 동작하는 것보다 훨씬 낫습니다)
★ RustBuffer 를 이해하면 성능이 보입니다
복잡한 타입(구조체 · enum · Vec)은 바이트로 직렬화돼 넘어갑니다.
숫자만 주고받는 함수는 직렬화가 없어 빠릅니다.
→ 뜨거운 경로는 인자를 원시 타입으로 설계하세요 ★
udl 방식 · 마이그레이션 · 팁
★ 두 가지 정의 방식
· proc-macro 방식 (#[uniffi::export]) ← 지금의 권장 ★
· UDL 파일 방식 (interface.udl) ← 초기 방식. 여전히 지원
둘을 섞을 수도 있지만, 새 프로젝트는 proc-macro 만 쓰세요.
★ 이름 변환 규칙
Rust snake_case → 코틀린 camelCase · 스위프트 camelCase
Rust ImgError → 코틀린 ImgException (Exception 접미사 ★)
Rust u32 → 코틀린 UInt (부호 없는 타입이 그대로 나옵니다)
Rust Option<T> → 코틀린 T?
★ 버전을 올릴 때
uniffi 버전을 올리면 생성 코드가 바뀝니다.
Rust 크레이트와 바인딩을 반드시 같은 커밋에서 만드세요.
CI 에서 '생성 후 diff 가 없어야 통과' 로 검사하면 어긋남을 막습니다 ★
★ 디버깅
RUST_LOG=debug 와 android_logger 로 Rust 로그를 logcat 에 (v10)
코틀린 예외 스택에는 Rust 프레임이 안 보입니다 —
에러 타입에 컨텍스트 문자열을 넉넉히 담으세요.
★ 참고 — Gobley
Gradle 플러그인으로 cargo 빌드 + 바인딩 생성 + KMP 소스셋 등록까지
묶어 주는 프로젝트가 있습니다. KMP 라면 살펴볼 값어치가 있습니다 (v11).
#[uniffi::export] 표시만으로 코틀린·스위프트·파이썬 바인딩을 생성합니다.
객체 · enum · Result · async · 콜백이 전부 자연스럽게 매핑되고,
안드로이드·iOS·KMP 라면 사실상 기본값입니다.
한계는 큰 배열이 복사된다는 것 — 그 경로만 JNI 로 빼는 혼합 설계를 v12 에서 만듭니다.JNA · JNR — 선언만으로 부르기
JNA 는 자바 인터페이스를 하나 적으면 그게 곧 바인딩인 라이브러리입니다. 빌드 설정도 코드 생성도 없어서 “되는지만 30분 안에 확인” 하는 데 가장 좋습니다. 대신 호출마다 리플렉션과 변환이 들어가 넷 중 가장 느립니다 — 그 대가가 어느 정도인지 알고 쓰면 훌륭한 도구입니다.
JNA 인터페이스 매핑 — 30분 프로토타입 ★
import com.sun.jna.*;
// v05 에서 만든 것과 같은 C ABI 라이브러리를 그대로 씁니다.
public interface MyLib extends Library {
// ① 이름으로 찾아 올립니다 (java.library.path · jna.library.path)
MyLib INSTANCE = Native.load("mylib", MyLib.class);
// ② C 원형을 자바 메서드로 그대로 옮겨 적습니다
int mylib_version();
int mylib_add(int a, int b);
Pointer mylib_open(String path); // const char* → String 자동 변환
void mylib_close(Pointer handle);
int mylib_process(Pointer h, byte[] in, int inLen, byte[] out, int outCap);
}
// ③ 바로 씁니다 — 빌드 설정도 코드 생성도 없습니다
public class Demo {
public static void main(String[] args) {
System.out.println(MyLib.INSTANCE.mylib_version());
System.out.println(MyLib.INSTANCE.mylib_add(2, 3));
}
}
// Gradle 의존성은 이거 하나입니다
dependencies {
implementation("net.java.dev.jna:jna:5.17.0")
implementation("net.java.dev.jna:jna-platform:5.17.0") // OS API 매핑(선택)
}
String ↔ char* 변환,
jar 안의 네이티브 자동 추출(jna.boot.library.path), 구조체 정렬 계산.
v09 에서 직접 만들 로더를 JNA 는 이미 갖고 있습니다.
구조체 · 포인터 · 메모리
import com.sun.jna.*;
import java.util.List;
// Rust: #[repr(C)] pub struct Buf { ptr: *mut u8, len: usize }
public class Buf extends Structure {
public Pointer ptr;
public long len;
@Override protected List<String> getFieldOrder() {
return List.of("ptr", "len"); // ★ 필드 순서를 반드시 알려 줘야 합니다
}
public static class ByValue extends Buf implements Structure.ByValue {}
}
public interface MyLib extends Library {
MyLib INSTANCE = Native.load("mylib", MyLib.class);
int mylib_resize(byte[] src, long len, int w, int h, Buf out);
void mylib_buf_free(Buf.ByValue b); // 값으로 넘기는 구조체
}
// 결과 읽기
Buf out = new Buf();
int rc = MyLib.INSTANCE.mylib_resize(src, src.length, 800, 600, out);
if (rc != 0) throw new IllegalStateException("rc=" + rc);
out.read(); // ★ 네이티브 → 자바 필드 동기화
byte[] result = out.ptr.getByteArray(0, (int) out.len);
// 직접 메모리를 잡을 때
Memory buf = new Memory(1024 * 1024); // 네이티브 힙에 1MB
buf.clear();
// … 넘겨서 채운 뒤 …
byte[] copied = buf.getByteArray(0, 4096);
getFieldOrder() 를 빠뜨리거나 순서가 틀리면 필드가 엉뚱한 오프셋으로 읽힙니다.
예외가 아니라 이상한 값이 나옵니다. 구조체가 많다면
구조체를 아예 안 쓰는 API 설계(🔗 f01 의 JSON 트릭)가 훨씬 안전합니다.
이렇게도 쓸 수 있다 — Direct 매핑과 JNR 🟡 중급
JNA Direct 매핑 — 같은 JNA 인데 훨씬 빠릅니다 ★
import com.sun.jna.Native;
public final class MyLibDirect {
static {
Native.register("mylib"); // ★ 이 클래스의 native 메서드를 직접 연결
}
private MyLibDirect() {}
// JNI 처럼 native 로 선언하지만, 심볼 이름은 메서드 이름 그대로입니다
public static native int mylib_add(int a, int b);
public static native int mylib_sum(java.nio.ByteBuffer buf, int len);
}
★ 인터페이스 매핑 vs Direct 매핑
인터페이스 매핑 (extends Library)
· 호출마다 동적 프록시 → 리플렉션 → 인자 변환
· 가장 쓰기 쉽고 가장 느립니다
· 프로토타입 · 호출이 드문 초기화 함수에 적합
Direct 매핑 (Native.register)
· 로드 시점에 심볼을 메서드에 직접 붙입니다
· 호출 비용이 JNI 에 가까워집니다 (수 배 빨라집니다) ★
· 대신 쓸 수 있는 타입이 제한됩니다
원시 타입 · Pointer · Structure · 직접 버퍼(ByteBuffer)
String 자동 변환은 안 됩니다 → byte[] 로 직접
★ 실무 요령
· 초기화·설정 함수는 인터페이스 매핑으로 편하게
· 뜨거운 함수 몇 개만 Direct 매핑으로 옮깁니다
· 두 방식을 한 프로젝트에서 함께 써도 됩니다
JNR-FFI — JNA 의 빠른 사촌
import jnr.ffi.LibraryLoader;
import jnr.ffi.Pointer;
import jnr.ffi.annotations.In;
public interface MyLib {
int mylib_version();
int mylib_add(int a, int b);
int mylib_sum(@In byte[] data, long len);
}
public class Demo {
public static void main(String[] args) {
MyLib lib = LibraryLoader.create(MyLib.class)
.search("target/release")
.load("mylib");
System.out.println(lib.mylib_add(2, 3));
}
}
JNR-FFI 는 인터페이스를 보고 '스텁을 바이트코드로 생성' 합니다.
· JNA 인터페이스 매핑보다 뚜렷하게 빠릅니다
· JRuby 가 실제로 쓰는 구현이라 검증돼 있습니다
· 다만 문서와 예제가 JNA 보다 적습니다
★ 2026년의 위치
Java 22+ 를 쓸 수 있다면 JNR 자리는 FFM 이 대체합니다.
실제로 JNR 은 "FFM 이 나오기 전까지의 답" 이었습니다.
낮은 자바에 묶여 있고 JNI 를 쓰기 싫다면 여전히 좋은 선택입니다.
의존성
implementation("com.github.jnr:jnr-ffi:2.2.17")
실무에선 이렇게 — 어디까지 JNA 로 갈까 🔴 실무
판단 기준
JNA 로 충분한 경우
· 호출이 초당 수천 번 이하
· 한 번 부르면 1ms 이상 일한다
→ 오버헤드가 0.1% 미만입니다
· 프로토타입 · 사내 도구 · 배치
· 이미 JNA 를 쓰는 프로젝트
(UniFFI 코틀린 바인딩이 그렇습니다)
· 팀에 네이티브 담당자가 없다
JNA 를 벗어나야 하는 경우
· 초당 수십만 번 호출
· 한 번에 하는 일이 마이크로초 단위
· 큰 배열을 자주 주고받는다
→ 변환 비용이 계속 붙습니다
· p99 지연이 제품 지표다
· 네이티브 스레드에서 콜백이 잦다
→ FFM(v05) 이나 JNI(v02) 로
★ 실무에서 자주 쓰는 이행 경로
① JNA 인터페이스 매핑으로 하루 만에 검증 (되는가?)
② 재 본다 (v08) — 오버헤드가 문제인가?
③ 문제면 뜨거운 함수만 Direct 매핑 또는 FFM 으로 이관
④ 인터페이스(자바 쪽 얼굴)는 그대로 두므로 호출부는 안 바뀝니다 ★
JNA 운영 시 챙길 것
★ 라이브러리 탐색 순서 (JNA)
① -Djna.library.path 에 지정한 경로
② java.library.path
③ 클래스패스 안의 리소스 → 임시 폴더로 자동 추출 ★
jar 안 경로 규약 : /linux-x86-64/libmylib.so
/darwin-aarch64/libmylib.dylib
/win32-x86-64/mylib.dll
④ OS 기본 경로
→ ③ 덕분에 v09 의 로더를 직접 안 만들어도 됩니다.
단, 폴더 이름 규약(JNA 식)을 지켜야 합니다 ★
★ 임시 폴더 문제
컨테이너에서 /tmp 가 noexec 이면 추출한 .so 를 못 올립니다.
-Djna.tmpdir=/app/native 로 바꿔 주세요
읽기 전용 루트 파일시스템에서도 같은 문제가 납니다.
★ 예외 안전
네이티브가 죽으면 JVM 이 함께 죽습니다 — JNA 라고 다르지 않습니다.
-Djna.protected=true 는 일부 플랫폼에서만 동작하고
운영에 의존할 수준이 아닙니다. 입력 검증을 Rust 쪽에서 하세요.
★ 버전
jna 와 jna-platform 의 버전을 반드시 같게 맞추세요.
안드로이드에서는 @aar 분류자를 씁니다 (v10)
성능 비교 — 경계 비용을 숫자로
“어느 게 빠른가”는 호출당 얼마인가와 몇 번 부르는가를 함께 봐야 답이 나옵니다. 여기서는 네 방식의 빈 호출 비용을 JMH 로 재고, 큰 배열을 넘길 때의 전략과 GC 와의 상호작용까지 정리합니다. 결론부터 말하면 대부분의 프로젝트에서 경계 비용은 문제가 아닙니다.
JMH 로 직접 재기 ★
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Fork(value = 1, jvmArgs = {"--enable-native-access=ALL-UNNAMED"})
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class BoundaryBench {
private byte[] small; // 1KB
private ByteBuffer direct; // 1KB direct
@Setup public void setup() {
small = new byte[1024];
new Random(42).nextBytes(small);
direct = ByteBuffer.allocateDirect(1024);
direct.put(small).flip();
}
// ① 기준선 — 순수 자바
@Benchmark public int javaAdd() { return 2 + 3; }
// ② JNI 원시 인자
@Benchmark public int jniAdd() { return Native.addNumbers(2, 3); }
// ③ JNI + 배열 복사
@Benchmark public long jniSumArray() { return Native.sumBytes(small); }
// ④ JNI + Direct 버퍼 (복사 없음)
@Benchmark public long jniSumDirect() { return Native.sumDirect(direct, 1024); }
// ⑤ FFM
@Benchmark public int ffmAdd() throws Throwable { return MyLib.add(2, 3); }
// ⑥ JNA
@Benchmark public int jnaAdd() { return MyLib.INSTANCE.mylib_add(2, 3); }
}
--release 로 빌드된 .so 인가,
② JIT 워밍업을 충분히 했는가(-wi 5 이상), ③ 결과를 블랙홀에 넣거나 반환했는가.
셋 중 하나만 빠져도 숫자가 통째로 거짓이 됩니다. 특히 ③ 이 빠지면
JIT 가 호출 자체를 지워 버립니다.
읽는 법 · 흔한 착시
★ 착시 ① "Rust 로 바꿨는데 안 빨라졌다"
· 디버그 빌드였습니다 (--release 확인)
· JIT 가 이미 그 자바 코드를 잘 최적화하고 있었습니다
· 병목이 GC 나 I/O 였습니다 → 프로파일러부터 (⚡ e01)
★ 착시 ② "JNI 가 FFM 보다 열 배 빠르다"
· FFM 을 invoke 로 불렀거나 MethodHandle 을 매번 만들었습니다
· static final + invokeExact 로 고치면 차이가 사라집니다 (v05)
★ 착시 ③ "UniFFI 는 느려서 못 쓴다"
· 초당 수십만 번 부를 때만 그렇습니다
· 이미지 한 장에 10ms 짜리 일이면 0.006% 입니다
★ 착시 ④ 마이크로벤치가 좋은데 실서비스가 안 좋다
· 벤치는 캐시가 뜨겁고 분기 예측이 완벽합니다
· 실제로는 데이터가 캐시에 없고 다른 스레드와 경쟁합니다
· 반드시 실부하(부하 테스트)로 다시 확인하세요 ★
★ 함께 봐야 하는 지표
-Xlog:gc* GC 횟수 · 정지 시간
jcmd <pid> VM.native_memory 네이티브 메모리 증가
p50 / p95 / p99 평균이 아니라 꼬리를 보세요
이렇게도 쓸 수 있다 — 호출 횟수를 줄이는 설계 🟡 중급
큰 배열 전달 전략 비교 ★
| 전략 | 복사 | GC 영향 | 쓰는 곳 |
|---|---|---|---|
convert_byte_array | 1회 | 힙 할당 | 수 KB |
get_array_elements | JVM 결정 | 핀 유발 가능 | 중간 · 되쓰기 |
| Critical 영역 | 0~1회 | GC 방해 ★ | 수 μs 짜리만 |
| DirectByteBuffer | 0 | 없음 | 1MB 이상 |
| FFM Arena 복사 | 1회 | 힙 밖 | 중간~큼 |
| FFM critical | 0 | GC 방해 가능 | 짧은 함수 |
| 네이티브가 할당 | 0 | 없음 | 결과가 클 때 |
★ 크기별 권장 (감각용 · 반드시 직접 재세요)
~ 4 KB 복사가 가장 단순하고 충분합니다
4 ~ 64 KB 복사와 Direct 가 비슷 — 코드가 단순한 쪽으로
64 KB ~ 1 MB Direct 버퍼가 유리해지기 시작합니다
1 MB ~ Direct 버퍼 · 재사용 필수 ★
★ 왕복을 잊지 마세요
입력 복사 + 출력 복사 = 두 번입니다.
4K 이미지(약 24MB)를 UniFFI 로 왕복시키면 48MB 복사가 생깁니다.
이 경로만 JNI + Direct 로 빼는 것이 v12 의 설계입니다.
GC 와의 상호작용
★ 네이티브 모듈이 GC 에 주는 영향 세 가지
① 큰 byte[] 를 계속 만들면 → 젊은 세대가 빨리 차고 승격이 늘어납니다
해결 : 결과를 Direct 버퍼에 쓰고 재사용
② 핀·Critical 이 길면 → GC 가 그 구간을 기다립니다
해결 : Critical 영역은 마이크로초 단위로만
③ 네이티브 메모리는 힙에 안 보입니다 ★
-Xmx 로는 제어되지 않습니다. 컨테이너 메모리 한도를 넘겨
OOMKilled 가 나는 대표적 원인입니다.
해결 : -XX:MaxDirectMemorySize · Rust 쪽 할당량 상한 · NMT 로 감시
★ 어떤 GC 가 유리한가
· G1 (기본) 대부분의 경우 그대로 두면 됩니다
· ZGC p99 가 중요하고 힙이 크면 유리합니다
· 어떤 GC 든 네이티브로 옮긴 데이터는 GC 대상이 아니므로
"옮기는 것" 자체가 이미 GC 압력을 줄입니다 ★
★ 가상 스레드 주의 (Java 21+)
네이티브 호출 중에는 가상 스레드가 캐리어에 고정(pinning)됩니다.
오래 걸리는 네이티브 작업은 전용 플랫폼 스레드 풀로 보내세요 (v12)
Executors.newFixedThreadPool(코어수) 로 격리
한 단계 더 — 실측 예시와 해석 🧬 전문가
JMH 출력 읽기
Benchmark Mode Cnt Score Error Units
BoundaryBench.javaAdd avgt 10 0.9 ± 0.1 ns/op
BoundaryBench.jniAdd avgt 10 8.4 ± 0.4 ns/op
BoundaryBench.ffmAdd avgt 10 10.2 ± 0.6 ns/op
BoundaryBench.jnaAdd avgt 10 1180.5 ± 62.3 ns/op
BoundaryBench.jniSumArray avgt 10 152.7 ± 5.1 ns/op
BoundaryBench.jniSumDirect avgt 10 31.2 ± 1.2 ns/op
★ 이 표에서 읽어야 할 것
· jniAdd 와 ffmAdd 는 사실상 같습니다 → 둘 사이 고민은 무의미
· jnaAdd 는 100배가 넘습니다 → 뜨거운 경로에는 쓰면 안 됩니다
· sumArray(152) vs sumDirect(31) → ★ 복사가 대부분이었습니다
즉 "어떤 FFI 냐"보다 "복사를 했느냐"가 훨씬 큽니다
★ Error 값을 꼭 보세요
Score 차이가 Error 안에 들어오면 '차이 없음' 입니다.
10.2 ± 0.6 과 8.4 ± 0.4 는 구분되지만,
10.2 ± 3.0 이었다면 아무 결론도 못 냅니다.
★ 프로파일러 붙이기
java -jar benchmarks.jar BoundaryBench -prof gc
할당량(norm)이 0 B/op 인지 확인 — 숨은 복사가 보입니다 ★
java -jar benchmarks.jar BoundaryBench -prof perfasm
실제 기계어까지 (리눅스)
엔드투엔드로 다시 재기 ★
# 마이크로벤치가 끝이 아닙니다. 실제 엔드포인트로 재세요.
# (Spring Boot + 이미지 리사이즈 · v12 의 구성)
# ① 부하 도구로 던집니다
oha -z 60s -c 50 -m POST \
-H "Content-Type: application/octet-stream" \
-D sample-4000x3000.jpg \
http://localhost:8080/resize?w=800&h=600
# ② 함께 봅니다
jcmd $(pgrep -f app.jar) VM.native_memory summary # 네이티브 메모리
jcmd $(pgrep -f app.jar) GC.heap_info # 힙
kubectl top pod # 컨테이너 RSS
# ③ 확인 항목
# · p99 지연이 낮아졌는가 (평균이 아니라)
# · RSS 가 시간이 지나도 평평한가 (누수 확인) ★
# · 같은 인스턴스로 처리량이 몇 배가 됐는가
# · GC 정지 시간 총합이 줄었는가
빌드 · 패키징 — jar 하나로 배포하기
자바의 가장 큰 장점은 jar 하나면 어디서나 돈다는 것이었습니다.
네이티브를 붙이면 그게 깨지기 쉬운데, 깨지지 않게 만드는 방법이 정해져 있습니다 —
OS·아키텍처별 .so 를 jar 안에 넣고 런타임에 추출해 올리는 것입니다.
여기서 그 로더와 다중 타깃 빌드 · GitHub Actions 매트릭스 · Maven Central 배포까지 만듭니다.
네이티브 로더 — 실제 코드 ★
package com.example.mylib;
import java.io.*;
import java.nio.file.*;
import java.util.Locale;
public final class NativeLoader {
private static volatile boolean loaded = false;
public static synchronized void load() {
if (loaded) return;
// ① 개발·운영 오버라이드를 먼저 봅니다 (되돌릴 수 있게)
String override = System.getProperty("mylib.native.path");
if (override != null) { System.load(override); loaded = true; return; }
String dir = platformDir(); // linux-x86_64 …
String file = System.mapLibraryName("mylib"); // libmylib.so …
String res = "/native/" + dir + "/" + file;
try (InputStream in = NativeLoader.class.getResourceAsStream(res)) {
if (in == null)
throw new UnsatisfiedLinkError("지원하지 않는 플랫폼입니다: " + dir);
// ② 임시 파일로 꺼냅니다. 경로는 -Dmylib.tmpdir 로 바꿀 수 있게 ★
Path tmpDir = Path.of(System.getProperty("mylib.tmpdir",
System.getProperty("java.io.tmpdir")));
Files.createDirectories(tmpDir);
Path out = Files.createTempFile(tmpDir, "mylib-", suffix(file));
Files.copy(in, out, StandardCopyOption.REPLACE_EXISTING);
out.toFile().setExecutable(true, true);
// ③ 절대 경로로 올립니다 (loadLibrary 가 아니라 load)
System.load(out.toAbsolutePath().toString());
loaded = true;
// ④ 종료 시 정리 — 윈도우는 올린 파일을 못 지웁니다(무시됩니다)
out.toFile().deleteOnExit();
} catch (IOException e) {
throw new UnsatisfiedLinkError("네이티브 추출 실패: " + e.getMessage());
}
}
private static String platformDir() {
String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
String arch = System.getProperty("os.arch").toLowerCase(Locale.ROOT);
String o = os.contains("win") ? "windows"
: os.contains("mac") ? "macos"
: os.contains("linux") ? "linux" : "unknown";
String a = switch (arch) {
case "amd64", "x86_64" -> "x86_64";
case "aarch64", "arm64" -> "aarch64";
default -> arch;
};
return o + "-" + a;
}
private static String suffix(String f) {
int i = f.lastIndexOf('.');
return i < 0 ? ".bin" : f.substring(i);
}
}
다중 타깃 빌드 ★
# ① cross — 도커로 타깃별 툴체인을 준비해 줍니다
cargo install cross --git https://github.com/cross-rs/cross
cross build --release --target x86_64-unknown-linux-gnu
cross build --release --target aarch64-unknown-linux-gnu
# ② cargo-zigbuild — zig 를 링커로 써서 glibc 버전까지 지정 ★
cargo install cargo-zigbuild
rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17
# ^^^^^^
# 오래된 배포판(CentOS 7 계열)에서도 도는 바이너리가 나옵니다
# ③ 맥 — 두 아키텍처를 하나로 (universal)
rustup target add x86_64-apple-darwin aarch64-apple-darwin
cargo build --release --target x86_64-apple-darwin
cargo build --release --target aarch64-apple-darwin
lipo -create -output libmylib.dylib \
target/x86_64-apple-darwin/release/libmylib.dylib \
target/aarch64-apple-darwin/release/libmylib.dylib
# ④ musl — 완전 정적 (glibc 버전 문제를 아예 없앰)
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
.so 는
오래된 서버에서 GLIBC_2.34 not found 로 죽습니다.
cargo-zigbuild 로 하한을 명시하거나 가장 오래된 배포판에서 빌드하세요.
JVM 용 .so 는 musl 정적이 오히려 문제를 일으킬 수 있으니(glibc JVM 과 섞임)
zigbuild 쪽을 권합니다(🛠️ b07).
이렇게도 쓸 수 있다 — GitHub Actions 매트릭스 🟡 중급
네이티브를 먼저 굽고 jar 를 마지막에
# .github/workflows/release.yml
name: release
on:
push:
tags: ["v*"]
jobs:
native:
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, dir: linux-x86_64 }
- { os: ubuntu-latest, target: aarch64-unknown-linux-gnu, dir: linux-aarch64 }
- { os: macos-latest, target: aarch64-apple-darwin, dir: macos-aarch64 }
- { os: macos-13, target: x86_64-apple-darwin, dir: macos-x86_64 }
- { os: windows-latest, target: x86_64-pc-windows-msvc, dir: windows-x86_64 }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- name: build
working-directory: rust
shell: bash
run: |
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
cargo install cargo-zigbuild
cargo zigbuild --release --target ${{ matrix.target }}.2.17
else
cargo build --release --target ${{ matrix.target }}
fi
- uses: actions/upload-artifact@v4
with:
name: native-${{ matrix.dir }}
path: rust/target/${{ matrix.target }}/release/*mylib*
jar:
needs: native
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with: { path: staging }
- name: 리소스 배치
run: |
for d in staging/native-*; do
name="${d#staging/native-}"
mkdir -p src/main/resources/native/$name
cp "$d"/*mylib* src/main/resources/native/$name/
done
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "25" }
- run: ./gradlew build publish
두 가지 배포 형태
| 방식 | 장점 | 단점 |
|---|---|---|
| 뚱뚱한 jar (전부 한 jar) | 쓰는 쪽이 아무것도 안 함 | 크기 ×N |
| 분류자 jar ( -linux-x86_64) | 필요한 것만 받음 | 의존성 선언이 복잡 |
// 분류자 방식 — Gradle 에서 플랫폼별 jar 를 함께 냅니다
val natives = listOf("linux-x86_64", "linux-aarch64",
"macos-aarch64", "windows-x86_64")
natives.forEach { p ->
tasks.register<Jar>("nativeJar-$p") {
archiveClassifier.set(p)
from("src/main/resources/native/$p") { into("native/$p") }
}
}
publishing {
publications {
create<MavenPublication>("maven") {
from(components["java"])
natives.forEach { artifact(tasks["nativeJar-$it"]) }
}
}
}
★ 쓰는 쪽에서는
implementation("com.example:mylib:1.0.0")
runtimeOnly("com.example:mylib:1.0.0:linux-x86_64")
★ 권장
· 사내용·소규모 뚱뚱한 jar (편의가 이깁니다)
· 공개 라이브러리 분류자 + 기본 jar 에 자주 쓰는 두 플랫폼
· 안드로이드 aar v10 의 jniLibs 방식 (완전히 다릅니다)
실무에선 이렇게 — Maven Central 배포와 재현성 🔴 실무
Central 배포 준비물
// build.gradle.kts — Central Portal 배포에 필요한 것들
plugins {
`java-library`
`maven-publish`
signing
}
java {
withSourcesJar(); withJavadocJar() // ★ 둘 다 필수입니다
toolchain { languageVersion = JavaLanguageVersion.of(25) }
}
publishing {
publications.create<MavenPublication>("maven") {
groupId = "io.github.myname"; artifactId = "mylib"; version = "1.0.0"
from(components["java"])
pom {
name = "mylib"
description = "Rust 로 만든 이미지 처리 네이티브 모듈"
url = "https://github.com/myname/mylib"
licenses { license { name = "Apache-2.0"
url = "https://www.apache.org/licenses/LICENSE-2.0" } }
developers { developer { id = "myname"; name = "My Name" } }
scm { url = "https://github.com/myname/mylib" }
}
}
}
signing {
useInMemoryPgpKeys(System.getenv("GPG_KEY"), System.getenv("GPG_PASSPHRASE"))
sign(publishing.publications["maven"])
}
io.github.… 이 가장 쉽습니다),
GPG 서명, sources·javadoc jar, POM 의 license·developer·scm.
하나라도 빠지면 검증에서 막힙니다. 네이티브 jar 에도 서명이 붙어야 합니다.
배포 전에 반드시 확인할 것 ★
# ① jar 안에 정말 다 들어갔나
unzip -l build/libs/mylib-1.0.0.jar | grep native/
# native/linux-x86_64/libmylib.so
# native/linux-aarch64/libmylib.so
# native/macos-aarch64/libmylib.dylib
# native/windows-x86_64/mylib.dll
# ② 각 .so 가 무엇에 의존하나 (배포 대상에 그게 있는가)
ldd build/resources/main/native/linux-x86_64/libmylib.so
objdump -T libmylib.so | grep GLIBC_ | sort -u | tail -3 # 최고 요구 버전 ★
# ③ 심볼이 남아 있나 (strip 을 과하게 하면 사라집니다)
nm -D libmylib.so | grep -E "Java_|mylib_"
# ④ 실제 컨테이너에서 스모크 테스트
docker run --rm -v $PWD:/w -w /w eclipse-temurin:25-jre \
java -jar build/libs/app.jar --selftest
# ⑤ 재현 가능한 빌드 (같은 커밋 → 같은 바이너리)
# Cargo.lock 커밋 · 툴체인 고정 · 빌드 경로 정규화
cat rust/rust-toolchain.toml
# [toolchain]
# channel = "1.89.0" ← ★ 버전을 고정합니다
# 배포용 프로파일 — 크기와 속도를 함께 (🛠️ b10)
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = "debuginfo" # symbols 로 하면 Java_ 심볼까지 지워집니다 ★
panic = "unwind" # JNI 라면 유지 (v04)
한 단계 더 — 로더에서 실제로 겪는 사고들 🧬 전문가
사고 목록과 대응
① /tmp 가 noexec 인 컨테이너
증상 : UnsatisfiedLinkError: failed to map segment
대응 : -Dmylib.tmpdir=/app/native 로 실행 가능한 경로 지정
Dockerfile 에서 그 폴더를 미리 만들고 권한을 줍니다
② 같은 라이브러리를 두 클래스로더가 올림 (톰캣 · OSGi)
증상 : UnsatisfiedLinkError: already loaded in another classloader
대응 : 로더 클래스를 부모 클래스로더(공용 lib)에 두거나
추출 파일 이름에 클래스로더 식별자를 넣습니다
③ 여러 프로세스가 같은 파일에 동시에 씀
증상 : 간헐적 로드 실패 · 잘린 파일
대응 : createTempFile 로 유일한 이름을 쓰거나
내용 해시를 파일명에 넣고 이미 있으면 재사용 ★
④ 윈도우에서 임시 파일이 안 지워짐
증상 : 임시 폴더에 mylib-*.dll 이 쌓임
대응 : 정상입니다(로드된 DLL 은 잠깁니다).
시작할 때 오래된 파일을 정리하는 코드를 넣으세요
⑤ 안티바이러스가 추출된 .dll 을 격리
대응 : 서명된 배포본 사용 · 설치 경로에서 로드하는 옵션 제공
⑥ 버전이 섞임 (jar 는 새것, 로드된 .so 는 옛것)
대응 : mylib_version() 을 만들어 로드 직후 비교하고
다르면 즉시 예외를 던집니다 ★
해시 기반 캐시 로더
// 같은 내용이면 다시 안 꺼내는 방식 — 시작이 빨라지고 파일도 안 쌓입니다
private static Path extractCached(String res, String file) throws IOException {
byte[] bytes;
try (InputStream in = NativeLoader.class.getResourceAsStream(res)) {
if (in == null) throw new FileNotFoundException(res);
bytes = in.readAllBytes();
}
String hash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(bytes)).substring(0, 16);
Path dir = Path.of(System.getProperty("mylib.tmpdir",
System.getProperty("java.io.tmpdir")), "mylib-" + hash);
Files.createDirectories(dir);
Path target = dir.resolve(file);
if (!Files.exists(target) || Files.size(target) != bytes.length) {
// 같은 폴더 안에 임시로 쓴 뒤 원자적으로 옮깁니다 ★
Path tmp = Files.createTempFile(dir, "part-", ".tmp");
Files.write(tmp, bytes);
try { Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); }
catch (FileAlreadyExistsException ignored) { Files.deleteIfExists(tmp); }
}
return target;
}
mylib_version() 을 불러
자바 쪽 상수와 다르면 즉시 예외를 던지세요.
“새 jar 인데 옛 .so 가 올라간” 상태는 조용히 틀린 결과를 냅니다.
native/<os>-<arch>/ 규약으로 jar 안에 넣고,
런타임에 추출 → System.load(절대경로) 로 올립니다.
다중 타깃은 cross · cargo-zigbuild 와 Actions 매트릭스로 굽고,
배포 전에는 ldd·GLIBC 요구 버전·심볼·컨테이너 스모크를 확인하세요.
로더에는 임시 경로 오버라이드와 버전 검사를 반드시 넣습니다.안드로이드 — cargo-ndk 와 jniLibs
안드로이드에는 Panama FFM 이 없습니다. 그래서 길은 JNI 또는 UniFFI 둘 뿐입니다.
다행히 안드로이드는 네이티브를 담는 규약이 아주 명확합니다 —
jniLibs/<ABI>/lib*.so 에 넣으면 APK 설치 시 알아서 골라 들어갑니다.
cargo-ndk · Gradle 플러그인 · 코루틴 연동 · 16KB 페이지까지 한 번에 정리합니다.
준비와 빌드 ★
# ① NDK 설치 — Android Studio 의 SDK Manager 에서 (r27 이상 권장)
export ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/27.2.12479018
# ② Rust 타깃 추가
rustup target add aarch64-linux-android armv7-linux-androideabi \
x86_64-linux-android i686-linux-android
# ③ cargo-ndk — 툴체인·링커 설정을 대신 해 줍니다
cargo install cargo-ndk
# ④ 빌드 + 배치를 한 번에 ★
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
-o ../app/src/main/jniLibs \
build --release
# ⑤ 확인
ls ../app/src/main/jniLibs/arm64-v8a/
# libmyapp.so
# 16KB 페이지 확인 (Android 15+) ★
$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/*/bin/llvm-readelf -l \
../app/src/main/jniLibs/arm64-v8a/libmyapp.so | grep -A1 LOAD | head
# Align 이 0x4000(16384) 이어야 합니다
# rust/.cargo/config.toml — 16KB 정렬을 강제해 둡니다
[target.aarch64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
[target.x86_64-linux-android]
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]
.so 는 그 기기에서 아예 로드되지 않습니다.
NDK r27 이상을 쓰면 기본으로 맞춰지지만, 확인은 llvm-readelf 로 직접 하세요.
코틀린 쪽 — external fun
package com.example.myapp
object NativeCore {
init {
System.loadLibrary("myapp") // libmyapp.so
}
// ★ 심볼 : Java_com_example_myapp_NativeCore_resize
// object 는 자바로 보면 NativeCore 클래스입니다
external fun resize(src: ByteArray, w: Int, h: Int): ByteArray
external fun grayscale(buf: java.nio.ByteBuffer, w: Int, h: Int): Int
external fun version(): Int
}
// 코루틴에서 부르기 — 메인 스레드를 절대 막지 않습니다 ★
class ImageRepository {
suspend fun resize(bytes: ByteArray, w: Int, h: Int): ByteArray =
withContext(Dispatchers.Default) { // CPU 작업 → Default
NativeCore.resize(bytes, w, h)
}
}
// ViewModel
viewModelScope.launch {
_state.value = UiState.Loading
val out = runCatching { repo.resize(bytes, 800, 600) }
_state.value = out.fold(
onSuccess = { UiState.Done(it) },
onFailure = { UiState.Error(it.message ?: "실패") }
)
}
Dispatchers.IO 가 아니라 Dispatchers.Default 입니다 —
네이티브 계산은 CPU 작업입니다. IO 디스패처는 스레드를 많이 만들어
CPU 를 과하게 경쟁시킵니다. 파일을 읽는 부분만 IO 로 감싸세요.
이렇게도 쓸 수 있다 — Gradle 플러그인과 UniFFI 🟡 중급
rust-android-gradle — 빌드에 엮기
// app/build.gradle.kts
plugins {
id("com.android.application")
id("org.mozilla.rust-android-gradle.rust-android") version "0.9.6"
}
cargo {
module = "../rust" // Cargo.toml 이 있는 폴더
libname = "myapp"
targets = listOf("arm64", "arm", "x86_64")
profile = "release"
// 디버그 빌드에서는 arm64 만 굽게 해서 반복을 빠르게 ★
}
android {
ndkVersion = "27.2.12479018"
defaultConfig {
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
}
}
// 자바 컴파일 전에 cargo 가 돌게
tasks.matching { it.name.matches(Regex("merge.*JniLibFolders")) }
.configureEach { dependsOn(tasks.named("cargoBuild")) }
★ 개발 반복을 빠르게 하는 요령
· 디버그 변형에서는 ABI 하나(arm64-v8a)만 빌드합니다
· release 만 세 개 전부 굽습니다
· cargo 빌드 캐시(sccache)를 CI 에 붙이면 크게 줄어듭니다
· 에뮬레이터를 쓴다면 x86_64 를 빠뜨리지 마세요 (자주 겪는 실수)
★ R8 · ProGuard 주의
native 메서드가 있는 클래스는 이름이 바뀌면 안 됩니다 ★
-keepclasseswithmembernames class * { native <methods>; }
UniFFI 를 쓰면 JNA 관련 규칙도 필요합니다
-keep class com.sun.jna.** { *; }
-keep class uniffi.** { *; }
UniFFI 로 가는 길 (권장) ★
# v06 의 코어를 그대로 안드로이드에 얹습니다
cd rust
# ① ABI 별 .so 를 jniLibs 로
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
-o ../app/src/main/jniLibs build --release
# ② 코틀린 바인딩 생성 → 소스셋에 넣습니다
cargo run --bin uniffi-bindgen -- generate \
--library target/aarch64-linux-android/release/libimgcore.so \
--language kotlin \
--out-dir ../app/build/generated/uniffi
// app/build.gradle.kts
android {
sourceSets["main"].java.srcDir("build/generated/uniffi")
}
dependencies {
// ★ 안드로이드에서는 반드시 @aar 분류자입니다
implementation("net.java.dev.jna:jna:5.17.0@aar")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
}
// 쓰는 쪽 — JNI 한 줄도 없이 코틀린답게
import uniffi.imgcore.*
class ImageVm : ViewModel() {
private val engine = Engine() // Rust 객체
fun run(bytes: ByteArray) = viewModelScope.launch {
val out = withContext(Dispatchers.Default) {
engine.resize(bytes, Size(800u, 600u), Fit.Cover)
}
// …
}
override fun onCleared() { engine.close() } // ★ 반드시 닫습니다
}
실무에선 이렇게 — 로그 · 크래시 · 앱 크기 🔴 실무
Rust 로그를 logcat 으로
// Cargo.toml
// [target.'cfg(target_os = "android")'.dependencies]
// android_logger = "0.14"
// log = "0.4"
#[cfg(target_os = "android")]
fn init_logging() {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("rustcore"), // ★ logcat -s rustcore 로 필터
);
}
#[no_mangle]
pub extern "system" fn Java_com_example_myapp_NativeCore_init(
_env: JNIEnv, _c: JClass,
) {
#[cfg(target_os = "android")] init_logging();
// 패닉도 로그로 남깁니다 (안 그러면 아무 흔적 없이 죽습니다) ★
std::panic::set_hook(Box::new(|info| {
log::error!("rust panic: {info}");
}));
log::info!("네이티브 코어 초기화 완료");
}
adb logcat -s rustcore:D
# 네이티브 크래시 분석 (심볼이 있는 .so 가 필요합니다)
adb logcat | $ANDROID_NDK_HOME/ndk-stack -sym \
rust/target/aarch64-linux-android/release
앱 크기 줄이기 · 체크리스트
# 안드로이드용 릴리즈 프로파일 — 크기가 곧 설치 이탈률입니다
[profile.release]
opt-level = "z" # 크기 우선 ("s" 도 시도해 보세요)
lto = "fat"
codegen-units = 1
panic = "abort" # ★ 안드로이드는 abort 가 흔합니다 (크기 절감)
strip = "debuginfo" # 심볼은 남깁니다 (ndk-stack 용은 따로 보관)
★ 크기 확인
ls -la app/src/main/jniLibs/arm64-v8a/libmyapp.so
bloaty libmyapp.so # 무엇이 큰지 (크레이트 단위로 보입니다)
★ 크기를 줄이는 순서
① 안 쓰는 기능 플래그 끄기 (image 크레이트의 포맷 등)
image = { version = "0.25", default-features = false,
features = ["jpeg", "png"] }
② opt-level = "z" · lto = "fat"
③ panic = "abort"
④ 32비트(armeabi-v7a) 를 버릴 수 있는지 검토 — 절반이 줄어듭니다
⑤ Play 의 App Bundle 을 쓰면 사용자는 자기 ABI 만 받습니다 ★
★ 출시 전 체크리스트
□ 16KB 페이지 정렬 확인 (llvm-readelf)
□ 에뮬레이터(x86_64)에서 동작
□ R8 켠 release 빌드에서 동작 (keep 규칙 확인)
□ ndk-stack 으로 네이티브 크래시를 읽을 수 있는가
□ 심볼 파일을 Play Console 에 업로드했는가
□ 네이티브 예외가 앱을 통째로 죽이지 않는가 (v04 의 catch_unwind)
한 단계 더 — ART 와 JNI 의 차이점 🧬 전문가
데스크톱 JVM 과 다른 점
① FFM(Panama) 이 없습니다
→ JNI 또는 UniFFI(내부 JNA) 만 가능합니다
② CheckJNI 가 디버그 빌드에서 기본으로 켜져 있습니다 ★
잘못된 참조 사용을 즉시 잡아 줍니다. 데스크톱보다 엄격합니다.
adb shell setprop debug.checkjni 1 로 강제할 수도 있습니다
③ 지역 참조 한도가 낮고 엄격합니다
반복문에서 객체를 만들면 with_local_frame 이 사실상 필수 (v04)
④ 앱 프로세스가 Zygote 에서 갈라져 나옵니다
JNI_OnLoad 는 앱마다 다시 불립니다. 전역 상태 초기화에 유의하세요.
⑤ 스레드 이름과 우선순위
attach 한 스레드는 안드로이드 스케줄러에서 기본 우선순위입니다.
무거운 작업은 우선순위를 낮춰 UI 를 방해하지 않게 합니다.
⑥ 메모리 압박이 큽니다
기기 메모리가 적고 OOM Killer 가 공격적입니다.
네이티브 할당량 상한을 코드에 넣어 두세요 ★
비트맵을 직접 다루기
// 안드로이드 Bitmap 의 픽셀을 복사 없이 다루는 방법
// (ndk 크레이트가 AndroidBitmap_lockPixels 를 감싸 줍니다)
//
// Cargo.toml
// [target.'cfg(target_os = "android")'.dependencies]
// ndk = "0.9"
// ndk-sys = "0.6"
// 더 간단한 대안 ★ — 자바 쪽에서 Direct 버퍼를 만들어 넘깁니다
// Kotlin:
// val buf = java.nio.ByteBuffer.allocateDirect(w * h * 4)
// bitmap.copyPixelsToBuffer(buf) // Bitmap → Direct 버퍼
// NativeCore.grayscale(buf, w, h) // ★ Rust 가 그 자리에서 수정
// buf.rewind(); bitmap.copyPixelsFromBuffer(buf)
//
// 이 방식은 v03 의 DirectByteBuffer 그대로이고,
// 안드로이드 전용 API 에 묶이지 않아 서버 코드와 공유됩니다.
Bitmap 을 Rust 가 직접 알면
서버·데스크톱에서 같은 코드를 못 씁니다.
바이트 버퍼와 크기만 주고받는 인터페이스로 두면
v12 처럼 Spring Boot 와 안드로이드가 같은 코어를 쓸 수 있습니다.
cargo ndk -t … -o app/src/main/jniLibs build --release 한 줄이면 ABI 배치가 끝나고,
코틀린에서는 external fun + Dispatchers.Default 로 부릅니다.
16KB 페이지 정렬 · R8 keep 규칙 · x86_64 에뮬레이터 · ndk-stack 네 가지를 출시 전에 꼭 확인하세요.Kotlin Multiplatform · iOS — 한 벌로 두 스토어
KMP 는 화면은 각자, 로직은 한 벌로 만드는 방식입니다.
그 “로직 한 벌”을 코틀린 대신 Rust 로 두면 안드로이드·iOS 는 물론
데스크톱과 서버까지 같은 코어를 씁니다.
Kotlin/Native 의 cinterop 을 직접 쓰는 대신 UniFFI 를 쓰는 이유도 여기서 정리합니다.
왜 cinterop 대신 UniFFI 인가 ★
| cinterop | UniFFI | |
|---|---|---|
| 얻는 것 | C 헤더 그대로 | 코틀린다운 API |
| 포인터 | 직접 관리 | 객체로 감싸 줌 |
| 문자열 | 수동 변환 | 자동 |
| 에러 | 정수 코드 | 예외로 매핑 |
| 안드로이드 | 따로 JNI 필요 ★ | 함께 처리 |
| iOS(Swift) | K/N 만 | Swift 도 생성 |
| async | 없음 | suspend 지원 |
| 작성량 | 플랫폼마다 | 한 번 |
★ 핵심 이유
cinterop 은 Kotlin/Native(iOS·macOS·리눅스) 전용입니다.
안드로이드는 Kotlin/JVM 이라 cinterop 이 아예 안 됩니다 ★
→ cinterop 을 쓰면 iOS 는 cinterop, 안드로이드는 JNI 로
같은 코어에 대해 바인딩을 두 벌 짜야 합니다.
UniFFI 는 그 두 벌을 한 정의에서 생성합니다.
이것이 Mozilla 가 UniFFI 를 만든 이유이기도 합니다.
★ cinterop 이 나은 경우
· iOS 만 지원하고 앞으로도 그렇다
· 기존 C 라이브러리를 그대로 쓰고 싶다 (Rust 가 아니라)
· 생성 코드 없이 통제하고 싶다
iOS 용 빌드 — XCFramework 까지
# ① iOS 타깃 추가
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# ② staticlib 도 함께 내보내게 (Cargo.toml)
# crate-type = ["cdylib", "staticlib", "lib"]
# ③ 실기기 · 시뮬레이터 각각 빌드
cargo build --release --target aarch64-apple-ios
cargo build --release --target aarch64-apple-ios-sim
cargo build --release --target x86_64-apple-ios
# ④ 시뮬레이터 두 아키텍처를 하나로 묶습니다
mkdir -p target/sim
lipo -create -output target/sim/libimgcore.a \
target/aarch64-apple-ios-sim/release/libimgcore.a \
target/x86_64-apple-ios/release/libimgcore.a
# ⑤ Swift 바인딩과 헤더 생성
cargo run --bin uniffi-bindgen -- generate \
--library target/aarch64-apple-ios/release/libimgcore.a \
--language swift --out-dir bindings/swift
# ⑥ modulemap 준비 후 XCFramework 로 ★
xcodebuild -create-xcframework \
-library target/aarch64-apple-ios/release/libimgcore.a \
-headers bindings/swift/headers \
-library target/sim/libimgcore.a \
-headers bindings/swift/headers \
-output ImgCore.xcframework
.dylib 는 못 씁니다.
그래서 staticlib 로 만들어 정적 링크합니다.
lipo 로 실기기와 시뮬레이터를 한 슬라이스에 섞으면 안 됩니다 —
XCFramework 가 그 둘을 분리해서 담는 그릇입니다.
이렇게도 쓸 수 있다 — KMP 프로젝트에 붙이기 🟡 중급
build.gradle.kts (KMP)
kotlin {
androidTarget()
jvm("desktop")
listOf(iosArm64(), iosSimulatorArm64()).forEach { t ->
t.binaries.framework { baseName = "shared"; isStatic = true }
}
sourceSets {
val commonMain by getting {
kotlin.srcDir(layout.buildDirectory.dir("generated/uniffi"))
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}
}
val androidMain by getting {
dependencies { implementation("net.java.dev.jna:jna:5.17.0@aar") }
}
val desktopMain by getting {
dependencies { implementation("net.java.dev.jna:jna:5.17.0") }
}
// iosMain 은 XCFramework 를 링크하므로 추가 의존성이 없습니다
}
}
android {
sourceSets["main"].jniLibs.srcDirs("src/androidMain/jniLibs")
}
공통 코드 · 플랫폼별 로딩
// commonMain — 세 플랫폼이 똑같이 씁니다
package com.example.shared
import uniffi.imgcore.*
class ImageService {
private val engine = Engine()
suspend fun thumbnail(bytes: ByteArray): ByteArray =
engine.resize(bytes, Size(320u, 320u), Fit.Cover)
fun close() = engine.close()
}
// 네이티브 로딩만 플랫폼마다 다릅니다
expect object NativeInit { fun ensure() }
// androidMain — jniLibs 에 있으므로 시스템이 알아서 올립니다
actual object NativeInit { actual fun ensure() { /* 필요 없음 */ } }
// desktopMain — jar 에서 꺼내 올립니다 (v09)
actual object NativeInit {
actual fun ensure() { NativeLoader.load() }
}
// iosMain — 정적 링크라 로딩 자체가 없습니다
actual object NativeInit { actual fun ensure() { } }
// iOS 쪽 Swift 에서 직접 쓸 수도 있습니다 (SwiftUI)
import ImgCore
let engine = Engine()
let out = try engine.resize(data: bytes,
size: Size(width: 320, height: 320),
fit: .cover)
실무에선 이렇게 — CI 와 배포 🔴 실무
플랫폼별로 나눠서 굽습니다
# iOS 산출물은 macOS 러너에서만 만들 수 있습니다
jobs:
android-native:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: "aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android" }
- run: cargo install cargo-ndk
- run: |
cd rust
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 \
-o ../shared/src/androidMain/jniLibs build --release
- uses: actions/upload-artifact@v4
with: { name: jnilibs, path: shared/src/androidMain/jniLibs }
ios-xcframework:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: "aarch64-apple-ios,aarch64-apple-ios-sim" }
- run: ./scripts/build-xcframework.sh
- uses: actions/upload-artifact@v4
with: { name: xcframework, path: ImgCore.xcframework }
assemble:
needs: [android-native, ios-xcframework]
runs-on: macos-latest
steps:
- uses: actions/download-artifact@v4
- run: ./gradlew assembleRelease
겪게 되는 문제들
① 바인딩과 라이브러리 버전 불일치
증상 : "UniFFI API checksum mismatch" 로 즉시 실패
대응 : 같은 CI 잡에서 둘 다 만들고 함께 올립니다.
생성 코드는 커밋하지 말고 매번 생성하는 편이 안전합니다 ★
② iOS 시뮬레이터에서만 안 됨
원인 : aarch64-apple-ios-sim 타깃을 빼먹었습니다
(실기기용 aarch64-apple-ios 와 다릅니다 — 자주 겪는 실수)
③ 앱 크기 급증
대응 : v10 의 크기 절감 목록 + iOS 는 dead code stripping 확인
image 크레이트의 포맷 기능을 필요한 것만 켜세요
④ 데스크톱에서만 UnsatisfiedLinkError
원인 : jar 에 그 OS 용 네이티브가 안 들어갔습니다 (v09)
⑤ 빌드 시간
대응 : 로컬 개발은 현재 플랫폼 하나만 굽게 분기하고,
전체 매트릭스는 CI 에서만 돌립니다.
sccache 를 붙이면 러너 간 캐시가 재사용됩니다.
★ 팀 운영 요령
· Rust 코어에 cargo test 를 충분히 두면 플랫폼 테스트가 줄어듭니다
· 코틀린 쪽 테스트는 '바인딩이 붙었는가' 수준으로 얇게
· 코어 변경 시 세 플랫폼 스모크를 자동으로 도는 워크플로 하나
staticlib + XCFramework, 안드로이드는 jniLibs, 데스크톱은 jar 추출 로더로
로딩만 다르고 부르는 코드는 같습니다.실전 — 이미지 리사이즈 모듈을 세 가지로 감쌉니다
마지막으로 진짜 쓸 수 있는 것을 만듭니다.
image 크레이트로 리사이즈 코어를 만들고,
(a) jni + DirectByteBuffer · (b) Panama FFM · (c) UniFFI 세 가지로 감싼 뒤
Spring Boot 엔드포인트와 안드로이드 앱에서 부릅니다.
JMH 수치를 재고, 되돌릴 수 있는 배포까지 마칩니다.
코어 — 순수 Rust ★
# rust/imgcore/Cargo.toml
[package]
name = "imgcore"
version = "0.1.0"
edition = "2021"
[dependencies]
image = { version = "0.25", default-features = false,
features = ["jpeg", "png", "webp"] } # ★ 필요한 포맷만
thiserror = "2"
[dev-dependencies]
criterion = "0.5"
// rust/imgcore/src/lib.rs — JNIEnv 도 extern 도 여기엔 없습니다 ★
use image::{ImageFormat, imageops::FilterType};
#[derive(Debug, thiserror::Error)]
pub enum ImgError {
#[error("이미지를 읽지 못했습니다: {0}")]
Decode(String),
#[error("인코딩에 실패했습니다: {0}")]
Encode(String),
#[error("크기가 잘못됐습니다: {w}x{h}")]
BadSize { w: u32, h: u32 },
}
pub fn resize_jpeg(src: &[u8], w: u32, h: u32, quality: u8)
-> Result<Vec<u8>, ImgError>
{
if w == 0 || h == 0 || w > 20_000 || h > 20_000 {
return Err(ImgError::BadSize { w, h }); // ★ 상한을 코어에서 막습니다
}
let img = image::load_from_memory(src)
.map_err(|e| ImgError::Decode(e.to_string()))?;
// Lanczos3 는 품질이 좋고 Triangle 은 빠릅니다 — 요구에 맞춰 고르세요
let out = img.resize_to_fill(w, h, FilterType::Lanczos3);
let mut buf = std::io::Cursor::new(Vec::with_capacity(src.len() / 4));
out.to_rgb8()
.write_to(&mut buf, ImageFormat::Jpeg)
.map_err(|e| ImgError::Encode(e.to_string()))?;
let _ = quality; // 필요하면 인코더 설정으로
Ok(buf.into_inner())
}
#[cfg(test)]
mod tests {
#[test] fn 크기가_0이면_에러() {
assert!(super::resize_jpeg(&[], 0, 10, 80).is_err());
}
}
(a) jni 어댑터 — Direct 버퍼
// rust/imgjni/src/lib.rs
use jni::objects::{JByteArray, JByteBuffer, JClass};
use jni::sys::{jint, jlong};
use jni::JNIEnv;
use std::panic::{catch_unwind, AssertUnwindSafe};
/// 입력은 Direct 버퍼, 출력도 Direct 버퍼에 씁니다 → 복사 0
/// 반환 : 0 이상이면 출력 바이트 수, 음수면 에러 코드
#[no_mangle]
pub extern "system" fn Java_com_example_img_NativeImg_resizeDirect(
mut env: JNIEnv, _c: JClass,
input: JByteBuffer, in_len: jint,
output: JByteBuffer,
w: jint, h: jint,
) -> jint {
let r = catch_unwind(AssertUnwindSafe(|| -> Result<i32, i32> {
let ip = unsafe { env.get_direct_buffer_address(&input) }.map_err(|_| -1)?;
let icap = env.get_direct_buffer_capacity(&input).map_err(|_| -1)?;
let op = unsafe { env.get_direct_buffer_address(&output) }.map_err(|_| -2)?;
let ocap = env.get_direct_buffer_capacity(&output).map_err(|_| -2)?;
let n = in_len as usize;
if n > icap { return Err(-3); } // ★ 경계 검사
let src = unsafe { std::slice::from_raw_parts(ip, n) };
let out = imgcore::resize_jpeg(src, w as u32, h as u32, 85)
.map_err(|_| -4)?;
if out.len() > ocap { return Err(-5); } // 출력 버퍼 부족
let dst = unsafe { std::slice::from_raw_parts_mut(op, out.len()) };
dst.copy_from_slice(&out);
Ok(out.len() as i32)
}));
match r { Ok(Ok(n)) => n, Ok(Err(c)) => c, Err(_) => -99 }
}
/// 작은 이미지용 — byte[] 를 그대로 받는 단순 경로
#[no_mangle]
pub extern "system" fn Java_com_example_img_NativeImg_resizeBytes<'l>(
mut env: JNIEnv<'l>, _c: JClass<'l>, src: JByteArray<'l>, w: jint, h: jint,
) -> JByteArray<'l> {
let data = match env.convert_byte_array(&src) { Ok(v) => v, Err(_) => return JByteArray::default() };
match imgcore::resize_jpeg(&data, w as u32, h as u32, 85) {
Ok(out) => env.byte_array_from_slice(&out).unwrap_or_default(),
Err(e) => {
let _ = env.throw_new("java/lang/IllegalArgumentException", e.to_string());
JByteArray::default()
}
}
}
이렇게도 쓸 수 있다 — Spring Boot 에 붙이기 🟡 중급
인터페이스 하나에 구현 셋 ★
public interface Resizer {
byte[] resize(byte[] src, int w, int h);
}
// ① 순수 자바 — 되돌아갈 자리 (기준선이기도 합니다)
@Component
@ConditionalOnProperty(name = "img.impl", havingValue = "java", matchIfMissing = true)
class JavaResizer implements Resizer { /* ImageIO 기반 */ }
// ② JNI + Direct 버퍼
@Component
@ConditionalOnProperty(name = "img.impl", havingValue = "jni")
class JniResizer implements Resizer {
private static final ThreadLocal<ByteBuffer> IN =
ThreadLocal.withInitial(() -> ByteBuffer.allocateDirect(32 << 20));
private static final ThreadLocal<ByteBuffer> OUT =
ThreadLocal.withInitial(() -> ByteBuffer.allocateDirect(8 << 20));
@PostConstruct void init() { NativeLoader.load(); }
@Override public byte[] resize(byte[] src, int w, int h) {
ByteBuffer in = IN.get(), out = OUT.get();
if (in.capacity() < src.length) throw new IllegalArgumentException("입력이 너무 큽니다");
in.clear(); in.put(src); in.flip();
int n = NativeImg.resizeDirect(in, src.length, out, w, h);
if (n < 0) throw new IllegalStateException("native rc=" + n);
byte[] result = new byte[n];
out.position(0).get(result, 0, n);
return result;
}
}
// ③ Panama FFM
@Component
@ConditionalOnProperty(name = "img.impl", havingValue = "ffm")
class FfmResizer implements Resizer {
@Override public byte[] resize(byte[] src, int w, int h) {
return MyLib.resize(src, w, h); // v05 의 코드 그대로
}
}
엔드포인트와 스레드 격리
@RestController
@RequestMapping("/resize")
class ResizeController {
private final Resizer resizer;
// ★ 네이티브 작업은 전용 풀로 격리합니다.
// 가상 스레드에서 부르면 캐리어가 붙잡힙니다 (v08)
private final ExecutorService pool =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
ResizeController(Resizer resizer) { this.resizer = resizer; }
@PostMapping(consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE,
produces = MediaType.IMAGE_JPEG_VALUE)
CompletableFuture<ResponseEntity<byte[]>> resize(
@RequestBody byte[] body,
@RequestParam int w, @RequestParam int h) {
if (body.length > 32 * 1024 * 1024) // ★ 상한을 앞에서 막습니다
return CompletableFuture.completedFuture(
ResponseEntity.status(413).build());
return CompletableFuture.supplyAsync(
() -> ResponseEntity.ok(resizer.resize(body, w, h)), pool);
}
@PreDestroy void shutdown() { pool.shutdown(); }
}
# application.yml — 한 줄로 되돌립니다 ★
img:
impl: ffm # java | jni | ffm
# 실행
# java --enable-native-access=ALL-UNNAMED -jar app.jar --img.impl=ffm
--img.impl=java 로 즉시 되돌립니다.
같은 인터페이스라 호출부는 한 줄도 안 바뀝니다.
실무에선 이렇게 — 숫자로 확인하고 배포 🔴 실무
JMH 결과 예시와 해석 ★
대상 : 4000×3000 JPEG (약 3.2MB) → 800×600 JPEG
환경 : 8코어 · JDK 25 · Rust 1.89 release · 값은 예시입니다
Benchmark Mode Cnt Score Error Units
ResizeBench.javaImageIO avgt 10 214.3 ± 8.2 ms/op
ResizeBench.jniBytes avgt 10 48.6 ± 1.9 ms/op
ResizeBench.jniDirect avgt 10 44.1 ± 1.5 ms/op
ResizeBench.ffm avgt 10 47.9 ± 1.7 ms/op
ResizeBench.uniffi avgt 10 52.4 ± 2.1 ms/op
-prof gc (할당량)
javaImageIO 약 190 MB/op ← ★ GC 압력의 정체
jniDirect 약 0.9 MB/op ← 결과 배열만
★ 읽는 법
· 자바 대비 4~5배입니다. "100배"가 아닙니다 — 정직한 폭입니다
· jniBytes 와 jniDirect 차이는 4.5ms — 복사 비용입니다
(3.2MB 왕복이면 이 정도가 맞습니다)
· ffm 과 jni 는 사실상 같습니다 → 편한 쪽을 고르면 됩니다
· uniffi 가 조금 느린 것은 Vec<u8> 복사 때문입니다 (v06)
★ 진짜 이득은 할당량에 있습니다
190MB/op → 0.9MB/op. 초당 20건이면 3.8GB/s 가 사라집니다.
p99 지연과 인스턴스 수가 여기서 갈립니다 ★
부하 테스트와 운영 확인
# ① 같은 조건으로 두 구현을 각각 60초
for impl in java jni ffm; do
java --enable-native-access=ALL-UNNAMED -jar app.jar --img.impl=$impl &
sleep 8
oha -z 60s -c 32 -m POST -D sample.jpg \
-H "Content-Type: application/octet-stream" \
"http://localhost:8080/resize?w=800&h=600" > result-$impl.txt
kill %1; sleep 2
done
# ② 결과 비교 항목
grep -E "Requests/sec|99.00%" result-*.txt
# ③ 운영 확인
jcmd $(pgrep -f app.jar) VM.native_memory summary | head -20
# Direct 버퍼가 계속 늘면 풀이 새는 것입니다 ★
# ④ 컨테이너 이미지
# Dockerfile 에서 네이티브 추출 폴더를 미리 만들어 둡니다
# RUN mkdir -p /app/native
# ENV JAVA_TOOL_OPTIONS="-Dmylib.tmpdir=/app/native \
# --enable-native-access=ALL-UNNAMED"
한 단계 더 — 안드로이드까지 같은 코어로 🧬 전문가
(c) UniFFI 어댑터와 앱
// rust/imguniffi/src/lib.rs — 같은 코어를 얇게 감쌉니다
uniffi::setup_scaffolding!();
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum ImgFail {
#[error("{msg}")] Failed { msg: String },
}
#[uniffi::export]
pub fn resize_jpeg(data: Vec<u8>, width: u32, height: u32)
-> Result<Vec<u8>, ImgFail>
{
imgcore::resize_jpeg(&data, width, height, 85)
.map_err(|e| ImgFail::Failed { msg: e.to_string() })
}
// 안드로이드 — v10 의 절차 그대로
class ThumbVm : ViewModel() {
private val _ui = MutableStateFlow<Ui>(Ui.Idle)
val ui = _ui.asStateFlow()
fun make(bytes: ByteArray) = viewModelScope.launch {
_ui.value = Ui.Loading
_ui.value = try {
val out = withContext(Dispatchers.Default) {
uniffi.imguniffi.resizeJpeg(bytes, 320u, 320u)
}
Ui.Done(BitmapFactory.decodeByteArray(out, 0, out.size))
} catch (e: ImgFailException.Failed) {
Ui.Error(e.message ?: "실패")
}
}
}
워크스페이스와 마무리 체크리스트
# rust/Cargo.toml — 워크스페이스로 묶습니다
[workspace]
members = ["imgcore", "imgjni", "imgffi", "imguniffi"]
resolver = "2"
[workspace.dependencies]
imgcore = { path = "imgcore" }
jni = "0.21"
uniffi = "0.29"
# 각 어댑터는 이렇게 참조합니다
# [dependencies]
# imgcore.workspace = true
# jni.workspace = true
★ 최종 체크리스트
□ 코어에 JNIEnv · extern 이 한 줄도 없다 (cargo test 로 검증 가능)
□ 모든 extern 함수가 catch_unwind 로 감싸여 있다 (v04)
□ 입력 크기·차원 상한을 코어에서 막는다
□ 자바 인터페이스가 있고 순수 자바 구현으로 되돌릴 수 있다
□ 네이티브 버퍼는 풀에서 재사용한다 (v03)
□ 로드 직후 버전을 비교한다 (v09)
□ jar 에 네 플랫폼 네이티브가 들어갔다 (v09)
□ --enable-native-access 를 실행 옵션·매니페스트에 넣었다 (v05)
□ 안드로이드는 16KB 정렬과 R8 keep 규칙을 확인했다 (v10)
□ JMH 수치와 부하 테스트 결과를 문서에 남겼다 (v08)
□ RSS 모니터링 알람이 걸려 있다
★ 다음에 읽으면 좋은 곳
🔗 f01~f09 C ABI 설계를 더 깊게 (에러 · 소유권 · 패닉)
⚡ e01~e05 측정과 병렬화 (rayon 으로 리사이즈를 더 빠르게)
🛠️ b07~b10 크로스 빌드와 배포 크기
🐍 y01 같은 코어를 파이썬에서 (PyO3)
🟨 j02 같은 코어를 Node 에서 (napi-rs)
Rust 로 만든 코어를
C# 앱이
그대로 씁니다
C# 은 이미 좋은 언어입니다. 그래서 전부 옮기는 이야기가 아닙니다.
이미지 처리·파싱·암호·시뮬레이션처럼 C# 으로는 GC 와 싸워야 하는 층 하나를 Rust 로 만들고,
ASP.NET Core·Unity·Godot·MAUI 어디서든 using 한 줄로 부르는 것이 목표입니다.
길은 여러 개지만 2026년의 기본값은 csbindgen 하나로 정리됐습니다 —
build.rs 다섯 줄이면 C# 바인딩이 빌드마다 자동으로 갱신됩니다.
다섯 가지 길 — 무엇을 고를지부터 정합니다
Rust 를 C# 에서 부르는 방법은 다섯 가지지만 실제로 고민되는 건 두 가지뿐입니다.
함수가 몇 개면 손으로 [LibraryImport] 를 쓰고,
그보다 많거나 계속 늘어난다면 csbindgen 으로 자동 생성합니다.
나머지 셋은 “여러 언어에 동시에 낼 때”라는 조건이 붙습니다.
다섯 가지 비교 ★
| 방법 | 바인딩 코드 | Unity | 언제 |
|---|---|---|---|
| 손 P/Invoke LibraryImport | 직접 씁니다 | 가능 | 함수 10개 이하 |
| csbindgen Cysharp | 자동 생성 | 공식 지원 | 대부분 ★ |
| interoptopus | 자동 · 다언어 | 가능 | C#+파이썬+C |
| uniffi-bindgen-cs | 자동 · 고수준 | 제한적 | 모바일 SDK |
| cbindgen + 손 | .h 만 생성 | 가능 | C 고객이 있음 |
extern "C" 함수는 그대로 두고 생성기만 갈아 끼울 수 있습니다.
그래서 손 P/Invoke 로 시작해도 나중에 csbindgen 으로 옮기는 데 비용이 거의 없습니다.
2026년의 추천 — csbindgen
csbindgen (Cysharp · MIT)
· Rust 소스의 #[no_mangle] extern "C" 함수를 읽어
C# 의 partial 클래스 하나를 생성합니다
· 생성물은 [DllImport] / [LibraryImport] 선언 + repr(C) 구조체
· Unity 용 출력 모드가 따로 있습니다 (★ 게임에서 가장 큰 이점)
· bindgen 이 만든 Rust 바인딩도 입력으로 받습니다
→ C 라이브러리 → Rust → C# 3단 연결도 한 번에
왜 이게 기본값인가
① 설정이 build.rs 다섯 줄입니다 (배우는 데 10분)
② 생성물이 사람이 읽을 수 있는 평범한 C# 입니다 ★
(마법이 없어서 문제가 생기면 그 파일을 열어 보면 됩니다)
③ Rust 함수를 고치면 빌드할 때 C# 이 따라 바뀝니다
— 시그니처 불일치로 인한 조용한 메모리 파손이 사라집니다 ★
④ 런타임 의존성이 0 입니다 (생성기는 빌드 때만 씁니다)
한계 (미리 알아 두세요)
· Rust 의 Option · Result · String 은 여전히 못 넘깁니다 (k04)
· 제네릭 · 트레이트도 안 됩니다 (구체 함수만)
· 자동 생성은 '타이핑'을 없애 줄 뿐, '설계'는 여전히 사람 몫입니다
이렇게도 쓸 수 있다 — 나머지 넷은 각각 언제 쓰나 🟡 중급
interoptopus · uniffi
// ── interoptopus — 하나의 정의로 C# · 파이썬 · C 헤더를 함께
use interoptopus::{ffi_function, ffi_type, function, Inventory, InventoryBuilder};
#[ffi_type] // repr(C) + 메타데이터를 함께 답니다
#[repr(C)]
pub struct Point { pub x: f32, pub y: f32 }
#[ffi_function] // ★ 이 한 줄이 시그니처를 '목록'에 등록합니다
#[no_mangle]
pub extern "C" fn distance(a: Point, b: Point) -> f32 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
// 목록을 모아 두면 생성기가 이걸 읽습니다
pub fn my_inventory() -> Inventory {
InventoryBuilder::new().register(function!(distance)).build()
}
// → cargo test 나 별도 bin 에서 C#/파이썬/C 파일을 한 번에 뽑습니다
★ interoptopus 를 고르는 조건
· C# 과 파이썬(또는 C)에 '같은 API' 를 동시에 내야 한다
· 타입 안전한 생성물을 원한다 (SafeHandle · 예외 변환까지)
· 어노테이션을 붙이는 수고를 감수할 수 있다
★ uniffi-bindgen-cs (NordSecurity · Mozilla uniffi 의 C# 백엔드)
· UDL 파일이나 proc-macro 로 '고수준' 인터페이스를 정의
· String · Option<T> · Result<T,E> · Vec<T> 가 그대로 넘어갑니다 ★
(내부적으로 직렬화하므로 공짜는 아닙니다)
· Kotlin · Swift · Python 바인딩이 같이 나옵니다
· 모바일 SDK(안드로이드+iOS+데스크톱)를 한 벌로 낼 때 최고
· 성능이 중요한 뜨거운 경로에는 부적합합니다 (직렬화 비용)
cbindgen · 손 P/Invoke ★
★ cbindgen 은 C# 을 만들지 않습니다
Rust → C 헤더(.h) 만 만듭니다 (🔗 f04)
C# 쪽은 그 헤더를 '보고' 사람이 선언합니다
그래도 유용한 경우
· C/C++ 고객이 이미 있어서 헤더를 어차피 내야 한다
· 헤더를 리뷰 대상 문서로 삼는다 (경계 변경이 diff 에 남습니다 ★)
· Unity 의 C++ 플러그인이나 게임 엔진과도 붙인다
★ 손 P/Invoke 가 오히려 나은 경우
· 함수가 3~5개이고 앞으로도 안 늘 것이 확실하다
· 경계 함수마다 C# 쪽에서 검증 · 예외 변환을 넣고 싶다
· 빌드에 도구를 하나라도 더 넣기 싫은 조직이다
· 이미 있는 남의 Rust 라이브러리(.dll)만 부른다 ★
(소스가 없으면 csbindgen 이 읽을 게 없습니다)
★ 섞어 써도 됩니다 — 실제로 많이들 이렇게 합니다
csbindgen 이 만든 NativeMethods.g.cs 를 '저수준'으로 두고,
그 위에 손으로 쓴 안전한 래퍼 클래스를 얹습니다 (k06)
생성물을 직접 쓰지 말고 감싸세요 — 공개 API 가 안정됩니다
실무에선 이렇게 — 고르기 전에 확인할 네 가지 🔴 실무
체크리스트
① 배포 대상 플랫폼이 몇 개인가
win-x64 하나면 무엇을 써도 됩니다.
win-x64 + linux-x64 + osx-arm64 + android + ios 라면
빌드 파이프라인(k11)이 도구 선택보다 훨씬 큰 일입니다 ★
② C# 쪽이 NativeAOT 인가
NativeAOT 면 정적 링크(staticlib)가 가능해집니다 (k11).
그러면 "DLL 을 어디에 두나" 문제가 통째로 사라집니다.
③ Unity 인가
Unity 는 .NET 이 아니라 Mono/IL2CPP 입니다.
[LibraryImport] 소스 생성기가 안 돕니다 → [DllImport] 를 씁니다 ★
csbindgen 의 Unity 출력 모드가 이걸 처리해 줍니다 (k03 · k09)
④ 경계를 몇 번 넘는가
초당 수백만 번이면 '한 번에 배치로' 로 설계를 바꾸세요 (k05).
초당 수백 번이면 무엇을 써도 오버헤드가 안 보입니다.
★ ①~④ 를 답하고 나면 대개 선택이 하나로 좁혀집니다.
얹지 말아야 할 기대
이런 기대는 어긋납니다
- “C# 코드를 Rust 가 대신 관리해 준다” — GC 는 여전히 C# 것이고, Rust 힙은 Rust 것입니다. 두 힙은 절대 섞이지 않습니다.
- “어차피 다 빨라진다” — 경계를 자주 넘으면 오히려 느려집니다(k05).
- “Rust 니까 안전하다” — 경계 함수 안은
unsafe투성이입니다. 안전한 건 그 안쪽 순수 Rust뿐입니다. - “빌드가 그대로다” — CI 에 Rust 툴체인과 타깃별 빌드가 추가됩니다(k11).
실제로 얻는 것
- GC 압박이 사라진 뜨거운 경로 — 대용량 버퍼를 Rust 힙에 두면 Gen2·LOH 가 안 자랍니다.
- 이미 있는 Rust 생태계 — image · regex · ring · rayon · polars 를 그대로 씁니다.
- 한 벌로 여러 앱 — 서버(ASP.NET)·게임(Unity)·데스크톱(Avalonia)이 같은 코어를 씁니다(k10 · k12).
- 결정적인 성능 — 지터·GC 일시정지가 없어 p99 가 안정됩니다.
한 단계 더 — 경계 비용을 숫자로 🧬 전문가
한 번 넘는 데 얼마나 드나
실측 감각 (x64 · .NET 10 · 릴리즈 · 반드시 직접 재세요 · k12)
blittable 인자만 있는 호출 왕복 약 1~5 ns ★ 거의 공짜
(DisableRuntimeMarshalling 켰을 때 · k04)
string 인자 하나(UTF-8 변환 포함) 약 30~120 ns
구조체 배열 마샬링(요소 1000개) 수 μs (복사 발생)
Span 을 고정해서 포인터만 넘김 약 3~8 ns ★ 제로카피 (k05)
콜백을 통해 C# 으로 되돌아오기 약 5~20 ns
★ 비교 감각
파이썬 ↔ Rust (PyO3) 약 100~300 ns (🐍 y01)
Node ↔ Rust (napi-rs) 약 30~100 ns (🟨 j01)
C# ↔ Rust (P/Invoke) 약 1~5 ns ★ 압도적으로 쌉니다
이유: CLR 은 이미 네이티브 코드이고, blittable 호출은
JIT 가 '직접 점프하는 스텁' 으로 컴파일합니다.
파이썬처럼 인터프리터를 빠져나올 필요가 없습니다.
무엇이 비용을 만드나
경계 통과에서 실제로 일어나는 일 (비싼 순서)
④ 마샬링 (변환) ← 가장 비쌉니다
string · bool · 배열 · 클래스 · 델리게이트
→ 임시 버퍼 할당 + 복사 + 해제
③ GC 전이 (transition)
"지금 관리 코드를 떠납니다" 표시.
GC 가 이 스레드를 세우지 않고 진행할 수 있게 합니다.
SuppressGCTransition 으로 뺄 수 있지만 위험합니다 (k04)
② 핀 고정 (pinning)
GC 가 못 옮기게 고정. fixed / GCHandle.Alloc(Pinned)
짧게 잡으면 거의 공짜, 오래 잡으면 힙이 파편화됩니다 (k05)
① 스텁 호출
JIT 가 만든 점프. blittable 이면 사실상 직접 call 입니다
★ 결론 — 비용의 대부분은 '④ 마샬링' 입니다.
경계 시그니처를 blittable 로만 짜면(k04) 나머지는 신경 쓸 게 없습니다.
이것이 이 탭에서 계속 반복될 한 문장입니다. ★
[LibraryImport], 그 이상이면 csbindgen 이 2026년의 기본값입니다.
무엇을 쓰든 바닥은 같은 C ABI라서 나중에 갈아 끼울 수 있고,
도구보다 훨씬 중요한 건 경계를 blittable 로 설계하는 것(k04)입니다.첫 호출 — 10분이면 dotnet run 이 돕니다
Rust 쪽은 속성 두 줄, C# 쪽은 선언 한 줄이면 함수가 불립니다.
초보가 실제로 막히는 곳은 코드가 아니라 만들어진 .dll 을 C# 실행 폴더로 옮기는 설정 하나입니다.
그것까지 포함해 처음부터 끝까지 갑니다.
① Rust 쪽 — 파일 두 개
# 프로젝트 하나에 Rust 와 C# 을 나란히 둡니다
mkdir demo && cd demo
cargo new --lib native
dotnet new console -o app
# native/Cargo.toml
[package]
name = "mylib"
version = "0.1.0"
edition = "2024"
[lib]
name = "mylib" # ★ 만들어질 파일 이름의 뿌리
crate-type = ["cdylib"] # ★ 동적 라이브러리 (🔗 f02)
[profile.release]
lto = true
codegen-units = 1
// native/src/lib.rs
// 2024 에디션에서는 no_mangle 에 unsafe 표기를 씁니다
#[unsafe(no_mangle)]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
#[unsafe(no_mangle)]
pub extern "C" fn rust_sum_squares(n: u64) -> u64 {
(1..=n).map(|x| x * x).sum()
}
// extern "C" → 그 플랫폼의 C 호출 규약을 쓰라는 뜻 (🔗 f01)
// no_mangle → 심볼 이름을 그대로 rust_add 로 두라는 뜻
// 안 붙이면 _ZN5mylib8rust_add17h... 가 됩니다
cd native && cargo build --release
# 만들어진 것 확인 ★ 문제가 생기면 항상 여기부터
ls target/release/
# mylib.dll + mylib.dll.lib (윈도우)
# libmylib.so (리눅스)
# libmylib.dylib (맥)
# 심볼이 정말 있는지
dumpbin /exports target\release\mylib.dll | findstr rust_ # 윈도우
nm -D target/release/libmylib.so | grep rust_ # 리눅스
② C# 쪽 — 선언과 csproj
// app/Native.cs
using System.Runtime.InteropServices;
internal static partial class Native // ★ partial 이어야 합니다
{
// 파일 이름에서 접두사(lib)와 확장자를 뺀 이름만 씁니다
// 윈도우 mylib.dll · 리눅스 libmylib.so · 맥 libmylib.dylib
// → 전부 "mylib" 하나로 통합니다 ★
[LibraryImport("mylib")]
internal static partial int rust_add(int a, int b);
[LibraryImport("mylib")]
internal static partial ulong rust_sum_squares(ulong n);
}
// app/Program.cs
Console.WriteLine(Native.rust_add(20, 22)); // 42
Console.WriteLine(Native.rust_sum_squares(1000)); // 333833500
<!-- app/app.csproj — ★ 여기가 초보가 막히는 유일한 지점 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<!-- 빌드된 네이티브 파일을 실행 폴더로 복사합니다 -->
<ItemGroup>
<None Include="../native/target/release/mylib.dll"
Condition="Exists('../native/target/release/mylib.dll')"
CopyToOutputDirectory="PreserveNewest" Link="mylib.dll" />
<None Include="../native/target/release/libmylib.so"
Condition="Exists('../native/target/release/libmylib.so')"
CopyToOutputDirectory="PreserveNewest" Link="libmylib.so" />
<None Include="../native/target/release/libmylib.dylib"
Condition="Exists('../native/target/release/libmylib.dylib')"
CopyToOutputDirectory="PreserveNewest" Link="libmylib.dylib" />
</ItemGroup>
</Project>
cd app && dotnet run
# 42
# 333833500
이렇게도 쓸 수 있다 — cargo 빌드를 dotnet 빌드에 묶기 🟡 중급
한 줄로 둘 다 빌드 ★
<!-- app.csproj 에 추가 — dotnet build 가 cargo 를 먼저 부릅니다 -->
<Target Name="BuildRust" BeforeTargets="Build">
<Exec Command="cargo build --release"
WorkingDirectory="$(MSBuildProjectDirectory)/../native" />
</Target>
★ 이걸 넣으면 좋은 이유
· 팀원이 clone 후 'dotnet run' 한 줄로 돕니다
· IDE(Visual Studio · Rider)의 F5 도 그대로 돕니다
· Rust 를 고치고 C# 만 실행해도 반영됩니다 ★
(이걸 빠뜨려서 "왜 안 바뀌지" 로 30분 날리는 일이 흔합니다)
★ 주의
· cargo 가 PATH 에 없으면 CI 에서 깨집니다
→ CI 스텝에 rustup 설치를 넣거나(k11),
Condition="'$(SkipRust)' != 'true'" 로 끌 수 있게 두세요
· 증분 빌드가 잘 돕니다. cargo 가 알아서 안 바뀌면 넘어갑니다
--release 를 $(Configuration) 에 따라 붙이세요.
다만 Rust 디버그 빌드는 정말 느립니다(최적화가 꺼져 있어 10~50배).
성능을 재는 자리라면 C# 이 Debug 라도 Rust 는 항상 --release 로 두는 편이 낫습니다.
LibraryImport vs DllImport ★
| DllImport | LibraryImport | |
|---|---|---|
| 도입 | .NET Framework~ | .NET 7+ |
| 마샬링 코드 | 런타임이 IL 생성 | 컴파일 때 C# 생성 |
| 메서드 | extern | partial |
| NativeAOT | 가능(제약) | 최적 |
| 트리밍 | 경고 날 수 있음 | 안전 |
| 디버깅 | 안이 안 보임 | 생성 코드를 읽음 |
| Unity | 이것만 됩니다 | 안 됨 ★ |
// 생성된 코드를 눈으로 보고 싶다면 (배우기에 가장 좋은 방법 ★)
// app.csproj 에:
// <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
// <CompilerGeneratedFilesOutputPath>generated</CompilerGeneratedFilesOutputPath>
//
// → app/generated/.../Native.g.cs 에 이런 게 들어 있습니다
//
// internal static partial int rust_add(int a, int b)
// {
// int __retVal;
// __retVal = __PInvoke(a, b); // 변환이 하나도 없습니다 ★
// return __retVal;
//
// [DllImport("mylib", EntryPoint = "rust_add",
// ExactSpelling = true)]
// static extern int __PInvoke(int a, int b);
// }
[LibraryImport] 는 Roslyn 소스 생성기가 만듭니다.
Unity 는 자체 컴파일 파이프라인을 쓰고 이 생성기가 돌지 않습니다.
그래서 Unity 대상이면 csbindgen 의 Unity 모드로 [DllImport] 를 뽑습니다(k03 · k09).
실무에선 이렇게 — 윈도우 툴체인 궁합 🔴 실무
MSVC 를 쓰세요 ★
# 지금 무엇을 쓰고 있는지
rustup show
# default host: x86_64-pc-windows-msvc ← ★ 이것이어야 합니다
# (x86_64-pc-windows-gnu 라면 바꾸세요)
rustup default stable-x86_64-pc-windows-msvc
# MSVC 툴체인이 없으면
# Visual Studio Build Tools 설치 →
# "C++를 사용한 데스크톱 개발" 워크로드 + Windows SDK
★ 왜 msvc 인가 (gnu 가 아니라)
· .NET 런타임 자체가 MSVC 로 빌드됩니다
· gnu 툴체인은 libgcc_s_seh-1.dll · libwinpthread-1.dll 같은
MinGW 런타임 DLL 을 옆에 함께 배포해야 합니다 ★
→ 없으면 BadImageFormatException 이나 조용한 로드 실패
· 크래시 덤프 · 심볼(PDB) 도구가 MSVC 쪽에 맞춰져 있습니다
· Unity 윈도우 플러그인도 msvc 를 기대합니다
★ 아키텍처도 맞아야 합니다
C# 이 x64 인데 Rust 를 i686 로 빌드하면
→ BadImageFormatException (파일은 찾았는데 못 읽음)
dotnet 프로젝트의 RuntimeIdentifier 와
cargo 의 --target 을 항상 짝지어 생각하세요 (k11)
세 가지 실패와 처방
| 증상 | 원인과 처방 |
|---|---|
DllNotFoundException | 파일을 못 찾음. 출력 폴더에 있는지 먼저 확인(ls bin/Debug/net10.0). csproj 복사 설정 누락이 대부분 |
EntryPointNotFound | 파일은 찾음, 함수 이름이 없음. no_mangle 빠짐이거나 오타. nm -D/dumpbin 로 확인 |
BadImageFormat | 비트수·아키텍처 불일치. x64 앱에 arm64/x86 라이브러리 |
| 값이 이상함 | 시그니처 불일치. long·bool·usize 를 잘못 매핑(k04) |
| 조용히 죽음 | 패닉이 경계를 넘음(k08) 또는 잘못된 포인터 |
// 로드 실패를 알아보기 쉽게 만드는 스모크 테스트 ★
// 프로그램 시작 직후 한 번만 부릅니다
static void SmokeTest()
{
try
{
if (Native.rust_add(1, 1) != 2)
throw new InvalidOperationException("native ABI mismatch");
}
catch (DllNotFoundException e)
{
Console.Error.WriteLine(
$"네이티브 라이브러리를 못 찾았습니다: {e.Message}\n" +
$"실행 폴더: {AppContext.BaseDirectory}");
throw;
}
}
native/target/release 에 새로 만들었지만
C# 실행 폴더의 사본은 옛날 것일 수 있습니다.
위의 BuildRust 타깃을 넣으면 이 문제가 사라집니다.
한 단계 더 — CLR 은 어떻게 파일을 찾나 🧬 전문가
탐색 순서
[LibraryImport("mylib")] 를 만나면 CLR 은 이 순서로 시도합니다
1. 이미 로드된 것 중에 있나 (핸들 캐시)
2. AssemblyLoadContext 의 DllImportResolver (k10 · 직접 개입 지점 ★)
3. NATIVE_DLL_SEARCH_DIRECTORIES
— .deps.json 의 runtimes/<rid>/native 가 여기에 들어갑니다 (k11)
4. 어셈블리가 있는 폴더 (= 대부분 여기서 찾힙니다)
5. OS 기본 규칙
윈도우: LoadLibraryEx (앱 폴더 · 시스템 · PATH)
리눅스: dlopen (rpath · LD_LIBRARY_PATH · ldconfig 캐시)
맥 : dlopen (rpath · DYLD_LIBRARY_PATH)
각 후보마다 이름 변형을 시도합니다
mylib → mylib.dll / libmylib.dll (윈도우)
mylib → libmylib.so / mylib.so (리눅스)
mylib → libmylib.dylib / mylib.dylib (맥)
★ 그래서 이름에 lib 접두사나 .dll 을 쓰지 마세요
"libmylib.so" 라고 적으면 리눅스에서만 되고 윈도우에서 깨집니다
로딩을 눈으로 보기
# 리눅스 — 어떤 파일을 어떤 순서로 열어 보는지 그대로 보입니다
strace -f -e trace=openat dotnet run 2>&1 | grep mylib
# 윈도우 — Process Monitor 로 CreateFile 필터
# 또는 이벤트 추적
set COREHOST_TRACE=1
set COREHOST_TRACEFILE=host.log
dotnet run
# 라이브러리가 실제로 무엇에 의존하는지
ldd target/release/libmylib.so # 리눅스
otool -L target/release/libmylib.dylib # 맥
dumpbin /dependents target\release\mylib.dll
// 코드에서 직접 로드해 확인하기 (예외 메시지가 훨씬 친절합니다 ★)
using System.Reflection;
using System.Runtime.InteropServices;
var path = Path.Combine(AppContext.BaseDirectory,
OperatingSystem.IsWindows() ? "mylib.dll" : "libmylib.so");
if (!NativeLibrary.TryLoad(path, out var h))
throw new FileNotFoundException($"로드 실패: {path}");
if (!NativeLibrary.TryGetExport(h, "rust_add", out var fp))
throw new EntryPointNotFoundException("rust_add 심볼이 없습니다");
// 함수 포인터로 직접 호출도 됩니다 (k07 의 delegate* 와 같은 형태)
unsafe
{
var add = (delegate* unmanaged[Cdecl]<int, int, int>)fp;
Console.WriteLine(add(20, 22));
}
crate-type = ["cdylib"] 와 #[unsafe(no_mangle)] pub extern "C",
C# 은 [LibraryImport("mylib")] static partial — 이 셋이면 함수가 불립니다.
실제로 막히는 건 DLL 을 출력 폴더로 복사하는 csproj 설정이고,
실패는 파일을 못 찾았나 · 함수 이름이 틀렸나 둘 중 하나입니다.
윈도우에서는 반드시 msvc 툴체인을 쓰세요.csbindgen — 바인딩을 손으로 쓰지 않습니다
k02 의 방식은 함수가 늘면 곧 무너집니다. Rust 시그니처를 고쳤는데 C# 선언을 안 고치면 컴파일은 되고 실행 중에 조용히 메모리가 깨집니다 — FFI 에서 가장 무서운 실패입니다. csbindgen 은 빌드할 때마다 C# 을 다시 만들어 이 불일치를 원천적으로 없앱니다.
설정은 다섯 줄 ★
# native/Cargo.toml
[package]
name = "mylib"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[build-dependencies]
csbindgen = "1" # ★ 빌드 때만 씁니다 (런타임 의존성 0)
// native/build.rs
fn main() {
csbindgen::Builder::default()
.input_extern_file("src/ffi.rs") // 읽을 Rust 파일
.csharp_dll_name("mylib") // [DllImport("mylib")]
.csharp_namespace("MyLib.Native")
.csharp_class_name("NativeMethods")
.csharp_class_accessibility("public") // 기본은 internal
.generate_csharp_file("../app/NativeMethods.g.cs")
.unwrap();
// 경계 파일이 바뀌면 다시 돌리라고 알려 줍니다 ★
println!("cargo:rerun-if-changed=src/ffi.rs");
}
// native/src/ffi.rs — 경계 함수는 여기에만 모읍니다
use crate::core; // 순수 Rust 로직은 따로 (테스트 가능)
#[unsafe(no_mangle)]
pub extern "C" fn mylib_add(a: i32, b: i32) -> i32 {
core::add(a, b)
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_checksum(ptr: *const u8, len: usize) -> u32 {
if ptr.is_null() { return 0; }
let s = unsafe { std::slice::from_raw_parts(ptr, len) };
core::checksum(s)
}
생성되는 C# — 평범합니다
// app/NativeMethods.g.cs ← 자동 생성. 절대 손으로 고치지 마세요 ★
// <auto-generated> by csbindgen
using System;
using System.Runtime.InteropServices;
namespace MyLib.Native
{
public static unsafe partial class NativeMethods
{
const string __DllName = "mylib";
[DllImport(__DllName, EntryPoint = "mylib_add",
CallingConvention = CallingConvention.Cdecl,
ExactSpelling = true)]
public static extern int mylib_add(int a, int b);
[DllImport(__DllName, EntryPoint = "mylib_checksum",
CallingConvention = CallingConvention.Cdecl,
ExactSpelling = true)]
public static extern uint mylib_checksum(byte* ptr, nuint len);
}
}
★ 읽어 두면 좋은 특징 세 가지
① usize → nuint 로 정확히 매핑됩니다
(long 이나 int 로 잘못 쓰면 32비트에서 깨집니다 · k04)
② 포인터는 IntPtr 이 아니라 byte* 로 나옵니다
→ unsafe 가 필요하지만 타입이 살아 있어 안전합니다
(원하면 .csharp_use_nint_types(false) 등으로 조절 가능)
③ ExactSpelling = true
→ 이름 변형(Ansi/Unicode 접미사) 탐색을 끕니다. 더 빠르고 명확합니다
★ 생성물은 .gitignore 하지 마세요
커밋해 두면 Rust 를 안 건드린 사람도 C# 만으로 빌드가 됩니다.
그리고 시그니처 변경이 PR diff 에 그대로 보입니다 ★
src/ffi.rs 로 분리하나 — csbindgen 은 파일을 텍스트로 파싱합니다.
경계 함수를 여기저기 흩어 두면 어떤 파일을 입력으로 줄지 관리가 어려워집니다.
ffi.rs 한 곳에 모으면 “경계에 무엇이 있는가”가 파일 하나로 리뷰됩니다(🔗 f01 원칙 ①).
이렇게도 쓸 수 있다 — 구조체 · 콜백 · Unity 출력 🟡 중급
구조체와 함수 포인터도 자동
// src/ffi.rs
#[repr(C)]
pub struct ImageInfo {
pub width: u32,
pub height: u32,
pub channels: u8,
}
pub type ProgressFn = extern "C" fn(done: u32, total: u32);
#[unsafe(no_mangle)]
pub extern "C" fn mylib_info(path: *const std::ffi::c_char,
out: *mut ImageInfo) -> i32 { 0 }
#[unsafe(no_mangle)]
pub extern "C" fn mylib_run(cb: Option<ProgressFn>) { }
// ^^^^^^ 널을 허용하려면 Option 으로 감쌉니다
// (함수 포인터의 Option 은 널 포인터 최적화로
// 크기가 그대로라 ABI 상 안전합니다 ★)
// 생성되는 C#
[StructLayout(LayoutKind.Sequential)]
public unsafe partial struct ImageInfo
{
public uint width;
public uint height;
public byte channels;
}
[DllImport(__DllName, EntryPoint = "mylib_info", /* … */)]
public static extern int mylib_info(byte* path, ImageInfo* @out);
[DllImport(__DllName, EntryPoint = "mylib_run", /* … */)]
public static extern void mylib_run(
delegate* unmanaged[Cdecl]<uint, uint, void> cb);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 델리게이트가 아니라 함수 포인터로 나옵니다
// → 할당도 GC 도 없습니다 (k07)
Unity 용으로 뽑기 ★
// build.rs — Unity 프로젝트에도 같이 씁니다
fn main() {
let b = || csbindgen::Builder::default()
.input_extern_file("src/ffi.rs")
.csharp_namespace("MyLib.Native")
.csharp_class_name("NativeMethods");
// ① 일반 .NET 앱용
b().csharp_dll_name("mylib")
.generate_csharp_file("../app/NativeMethods.g.cs").unwrap();
// ② Unity 용 — iOS 는 정적 링크라 이름이 __Internal 이어야 합니다 ★
b().csharp_dll_name("mylib")
.csharp_dll_name_if("UNITY_IOS && !UNITY_EDITOR", "__Internal")
.generate_csharp_file(
"../unity/Assets/Scripts/NativeMethods.g.cs").unwrap();
println!("cargo:rerun-if-changed=src/ffi.rs");
}
// ② 가 만드는 것 — 전처리기로 갈라집니다
#if UNITY_IOS && !UNITY_EDITOR
const string __DllName = "__Internal";
#else
const string __DllName = "mylib";
#endif
// → 같은 소스가 에디터·안드로이드·iOS 에서 모두 돕니다 (k09)
staticlib 로 앱에 정적 링크되고, 그때 P/Invoke 이름은 "__Internal" 이어야 합니다.
손으로 관리하면 플랫폼마다 #if 를 직접 써야 하는데, 여기서는 설정 한 줄입니다.
실무에선 이렇게 — 3층 구조로 나눕니다 🔴 실무
프로젝트 구조 ★
myproject/
├─ native/ Rust 크레이트
│ ├─ Cargo.toml crate-type = ["cdylib","staticlib","rlib"]
│ ├─ build.rs csbindgen 설정
│ └─ src/
│ ├─ lib.rs pub mod core; pub mod ffi;
│ ├─ core.rs ★ 순수 Rust — unsafe 0, cargo test 가 그대로
│ └─ ffi.rs ★ 경계만 — catch_unwind + 포인터 검사 (k08)
│
├─ src/MyLib/ C# 라이브러리 (NuGet 으로 낼 것 · k11)
│ ├─ MyLib.csproj
│ ├─ NativeMethods.g.cs ★ 자동 생성 (커밋함 · 손대지 않음)
│ └─ Image.cs ★ 사람이 쓰는 안전한 래퍼 (k06)
│
├─ src/App/ ASP.NET Core · 콘솔 등 (k12)
├─ tests/MyLib.Tests/ xUnit
└─ unity/ Unity 프로젝트 (k09)
★ 세 층의 역할
core.rs 알고리즘. 여기만 보면 평범한 Rust
ffi.rs 번역. 포인터 검사 · 에러 코드 · catch_unwind
Image.cs C# 다움. IDisposable · Span · 예외 · XML 문서
생성물을 직접 쓰지 마세요
// ❌ 앱 코드가 생성물을 직접 부르면
unsafe {
fixed (byte* p = data)
var sum = NativeMethods.mylib_checksum(p, (nuint)data.Length);
}
// → 앱 전체에 unsafe 가 번지고, 시그니처가 바뀌면 다 고쳐야 합니다
// ✅ 얇은 래퍼를 하나 두면
namespace MyLib;
public static class Checksum
{
/// <summary>데이터의 체크섬을 구합니다.</summary>
public static uint Of(ReadOnlySpan<byte> data)
{
if (data.IsEmpty) return 0;
unsafe
{
fixed (byte* p = data)
return NativeMethods.mylib_checksum(p, (nuint)data.Length);
}
}
}
// → 앱에서는 Checksum.Of(bytes) 한 줄. unsafe 도 포인터도 안 보입니다 ★
cargo build 에 덮어써집니다.
“왜 고친 게 사라지지”로 반나절을 날리는 전형적인 사고입니다.
생성물 맨 위의 <auto-generated> 주석을 팀 규약으로 못 박아 두세요.
한 단계 더 — C 라이브러리 → Rust → C# 3단 🧬 전문가
bindgen 결과를 그대로 넘기기
// 시나리오 — libwebp 같은 C 라이브러리를 C# 에서 쓰고 싶다
// ① bindgen 이 C 헤더 → Rust 바인딩을 만듭니다 (🔗 f08)
// ② csbindgen 이 그 Rust 바인딩 → C# 을 만듭니다 ★
// native/build.rs
fn main() {
// ① C 헤더에서 Rust 바인딩
bindgen::Builder::default()
.header("vendor/webp/decode.h")
.generate().unwrap()
.write_to_file("src/webp_sys.rs").unwrap();
// ② 그 Rust 바인딩에서 C# 바인딩
csbindgen::Builder::default()
.input_bindgen_file("src/webp_sys.rs") // ★ extern 이 아니라 bindgen
.csharp_dll_name("webp")
.csharp_class_name("LibWebp")
.generate_csharp_file("../app/LibWebp.g.cs").unwrap();
}
★ 이 조합이 강한 이유
C 헤더가 수천 줄이어도 사람이 한 줄도 안 씁니다.
헤더가 업데이트되면 다시 빌드만 하면 됩니다.
★ 주의할 점
· 생성물이 거대해집니다 → allowlist 로 좁히세요
bindgen: .allowlist_function("WebP.*")
csbindgen: .method_filter(|name| name.starts_with("WebP"))
· C 의 union · 가변 인자(va_list)는 잘 안 넘어옵니다
· 실제로는 Rust 로 한 겹 감싸 '쓰기 좋은 API' 를 내는 편이 낫습니다 ★
자주 쓰는 옵션
| 옵션 | 쓰임 |
|---|---|
input_extern_file | Rust 소스에서 extern "C" 를 읽음 |
input_bindgen_file | bindgen 결과를 읽음 |
method_filter | 이름으로 걸러내기 |
csharp_entry_point_prefix | 실제 심볼에 접두사 추가 |
csharp_method_prefix | C# 쪽 메서드 이름 접두사 |
csharp_dll_name_if | 조건부 DLL 이름(Unity iOS) |
csharp_use_function_pointer | 델리게이트 대신 delegate* |
csharp_type_rename | 생성 타입 이름 바꾸기 |
csharp_imported_namespaces | using 추가 |
csharp_file_header | #nullable disable 등 삽입 |
// 실무에서 자주 쓰는 조합
csbindgen::Builder::default()
.input_extern_file("src/ffi.rs")
.method_filter(|n| n.starts_with("mylib_")) // 내부 함수 제외
.csharp_dll_name("mylib")
.csharp_namespace("MyLib.Native")
.csharp_class_name("NativeMethods")
.csharp_class_accessibility("internal") // ★ 밖에 노출 안 함
.csharp_file_header("#nullable disable\n#pragma warning disable CS8981")
.generate_csharp_file("../src/MyLib/NativeMethods.g.cs")
.unwrap();
__Internal 분기도 설정 한 줄입니다.
순수 로직(core.rs) · 경계(ffi.rs) · C# 래퍼 3층으로 나누고,
생성물은 커밋하되 손대지 않는 것이 규칙입니다.마샬링 — 무엇이 그냥 넘어가고 무엇이 안 넘어가나
경계에서 지켜야 할 규칙은 사실 한 문장입니다 —
“메모리 모양이 같은 것만 넘긴다.”
정수·실수·포인터·#[repr(C)] 구조체는 공짜로 통과하고,
String·Vec·Option·Result 는 아예 못 넘어갑니다.
대신 무엇으로 바꿔 쓰는지가 이 절의 내용입니다.
#[repr(C)] 를 반드시 붙일 것.타입 대응표 ★
| Rust | C# | 비고 |
|---|---|---|
i8 / u8 | sbyte / byte | 공짜 |
i16 / u16 | short / ushort | 공짜 |
i32 / u32 | int / uint | 공짜 |
i64 / u64 | long / ulong | 공짜 |
f32 / f64 | float / double | 공짜 |
usize / isize | nuint / nint | ★ 플랫폼 폭 |
*const T | T* 또는 IntPtr | 공짜 |
bool | byte 로 쓰세요 | ★ 아래 note |
char | 쓰지 마세요 | Rust 4B ≠ C# 2B |
&str·String | 못 넘김 | → *const u8+len |
Vec<T> | 못 넘김 | → (ptr, len) |
Option<T> | 못 넘김 | → 널 포인터·플래그 |
Result<T,E> | 못 넘김 | → 에러 코드(k08) |
#[repr(C)] struct | Sequential struct | 공짜 |
bool 을 그대로 쓰지 마세요 — Rust bool 은 1바이트인데
C# 의 bool 은 P/Invoke 기본이 4바이트 Win32 BOOL 입니다.
[MarshalAs(UnmanagedType.U1)] 로 맞출 수 있지만,
양쪽 다 u8/byte 로 두고 != 0 으로 해석하는 편이 사고가 없습니다.
구조체 — 양쪽 코드
// src/ffi.rs
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Rect {
pub kind: u8, // 0 = 원본, 1 = 축소
pub w: u32,
pub h: u16,
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_area(r: Rect) -> u64 {
r.w as u64 * r.h as u64
}
// 구조체를 '돌려줄' 때는 out 파라미터가 안전합니다 ★
#[unsafe(no_mangle)]
pub extern "C" fn mylib_shrink(r: Rect, out: *mut Rect) -> i32 {
if out.is_null() { return -1; }
unsafe { *out = Rect { kind: 1, w: r.w / 2, h: r.h / 2 }; }
0
}
[StructLayout(LayoutKind.Sequential)]
public struct Rect
{
public byte Kind;
public uint W;
public ushort H;
}
// 크기가 맞는지 시작할 때 한 번 확인하면 사고가 크게 줄어듭니다 ★
static Rect()
{
if (Unsafe.SizeOf<Rect>() != 12)
throw new InvalidOperationException("Rect ABI mismatch");
}
// Rust 쪽에도 같은 검사를 둡니다
// const _: () = assert!(std::mem::size_of::<Rect>() == 12);
out 파라미터 — 큰 구조체를 값으로 반환하면
플랫폼마다 “숨은 반환 포인터” 규약이 달라 미묘하게 어긋날 수 있습니다.
16바이트를 넘으면 out: *mut T 로 받으세요. 에러 코드도 함께 돌려줄 수 있어 일석이조입니다.
이렇게도 쓸 수 있다 — 문자열은 UTF-8 한 가지로 🟡 중급
C# → Rust 로 문자열 넘기기
// 방법 ① LibraryImport 에 맡기기 — 가장 짧습니다
[LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8)]
internal static partial int mylib_open(string path);
// 소스 생성기가 UTF-16 → UTF-8 변환 + 널 종단 + 임시 버퍼 해제까지 만듭니다
// 비용: 문자열 길이만큼 복사 1회 (짧으면 스택, 길면 힙)
// 방법 ② 길이를 함께 넘기기 — 널 종단이 없어도 되고 더 빠릅니다 ★
[LibraryImport("mylib")]
internal static unsafe partial int mylib_open_n(byte* utf8, nuint len);
public static int Open(ReadOnlySpan<char> path)
{
// 스택 버퍼로 변환 — 힙 할당 0
Span<byte> buf = path.Length <= 256
? stackalloc byte[512]
: new byte[Encoding.UTF8.GetMaxByteCount(path.Length)];
int n = Encoding.UTF8.GetBytes(path, buf);
unsafe { fixed (byte* p = buf) return mylib_open_n(p, (nuint)n); }
}
use std::ffi::{c_char, CStr};
// ① 널 종단 문자열을 받는 쪽
#[unsafe(no_mangle)]
pub extern "C" fn mylib_open(path: *const c_char) -> i32 {
if path.is_null() { return -1; }
let s = match unsafe { CStr::from_ptr(path) }.to_str() {
Ok(s) => s,
Err(_) => return -2, // 유효한 UTF-8 이 아님
};
core::open(s).map(|_| 0).unwrap_or(-3)
}
// ② 포인터 + 길이를 받는 쪽 ★ 이쪽을 권합니다
#[unsafe(no_mangle)]
pub extern "C" fn mylib_open_n(utf8: *const u8, len: usize) -> i32 {
if utf8.is_null() { return -1; }
let bytes = unsafe { std::slice::from_raw_parts(utf8, len) };
match std::str::from_utf8(bytes) {
Ok(s) => core::open(s).map(|_| 0).unwrap_or(-3),
Err(_) => -2,
}
}
Rust → C# 으로 문자열 돌려주기
use std::ffi::CString;
use std::ffi::c_char;
// ★ Rust 가 할당했으니 Rust 가 해제해야 합니다 — 짝을 반드시 제공
#[unsafe(no_mangle)]
pub extern "C" fn mylib_version() -> *mut c_char {
CString::new(env!("CARGO_PKG_VERSION"))
.unwrap()
.into_raw() // 소유권을 뗍니다 (해제 안 됨)
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_string_free(p: *mut c_char) {
if p.is_null() { return; }
unsafe { drop(CString::from_raw(p)); } // 다시 받아 해제
}
public static string Version()
{
nint p = NativeMethods.mylib_version();
if (p == 0) return "";
try
{
return Marshal.PtrToStringUTF8(p) ?? ""; // 여기서 C# 이 복사
}
finally
{
NativeMethods.mylib_string_free(p); // ★ 반드시 finally 에서
}
}
Marshal.FreeHGlobal 로 해제하는 것입니다.
Rust 힙과 C# 힙(그리고 CRT 힙)은 서로 다른 할당자입니다.
할당한 쪽이 해제한다 — 이 규칙 하나만 지키면 됩니다. 어기면 즉시 크래시하거나, 더 나쁘게는 한참 뒤에 죽습니다.
실무에선 이렇게 — Option · Result 를 대신할 규약 🔴 실무
에러 코드 설계 ★
// 프로젝트 전체가 공유하는 코드표 — 한 곳에 모읍니다
#[repr(i32)]
pub enum Code {
Ok = 0,
NullPointer = -1,
InvalidUtf8 = -2,
NotFound = -3,
BufferTooSmall = -4, // ★ out_len 에 필요한 크기를 넣어 줍니다
Io = -5,
Panic = -99, // catch_unwind 가 잡았을 때 (k08)
}
// 규약 ① 성공은 0, 실패는 음수 — C 의 오랜 관례입니다
// 규약 ② '값' 은 out 파라미터로 돌려줍니다
// 규약 ③ 자세한 메시지는 별도 함수로 (k08 의 thread_local)
#[unsafe(no_mangle)]
pub extern "C" fn mylib_parse(text: *const u8, len: usize,
out: *mut i64) -> i32 {
if text.is_null() || out.is_null() { return Code::NullPointer as i32; }
let s = unsafe { std::slice::from_raw_parts(text, len) };
match std::str::from_utf8(s).ok().and_then(|s| s.parse::<i64>().ok()) {
Some(v) => { unsafe { *out = v; } Code::Ok as i32 }
None => Code::InvalidUtf8 as i32,
}
}
C# 쪽에서 예외로 바꾸기
public enum MyLibCode
{
Ok = 0, NullPointer = -1, InvalidUtf8 = -2, NotFound = -3,
BufferTooSmall = -4, Io = -5, Panic = -99,
}
public sealed class MyLibException : Exception
{
public MyLibCode Code { get; }
public MyLibException(MyLibCode code, string message)
: base(message) => Code = code;
}
internal static class Check
{
// 모든 네이티브 호출을 이걸로 감쌉니다 ★
public static void Throw(int rc)
{
if (rc == 0) return;
throw new MyLibException((MyLibCode)rc, LastError());
}
static string LastError()
{
Span<byte> buf = stackalloc byte[512];
unsafe
{
fixed (byte* p = buf)
{
nuint n = NativeMethods.mylib_last_error(p, (nuint)buf.Length);
return Encoding.UTF8.GetString(buf[..(int)n]);
}
}
}
}
// 사용부는 C# 답게 보입니다
public static long Parse(ReadOnlySpan<byte> utf8)
{
long v;
unsafe { fixed (byte* p = utf8)
Check.Throw(NativeMethods.mylib_parse(p, (nuint)utf8.Length, &v)); }
return v;
}
한 단계 더 — 마샬링을 아예 꺼서 더 빠르게 🧬 전문가
DisableRuntimeMarshalling
// AssemblyInfo.cs 또는 아무 .cs 파일 최상단에 한 줄
using System.Runtime.CompilerServices;
[assembly: DisableRuntimeMarshalling]
// 이 어셈블리 전체에서
// · 런타임 마샬링 계층이 통째로 빠집니다
// · blittable 이 아닌 타입은 컴파일 에러가 납니다 ★
// (bool · char · string · 배열 · 클래스 · 델리게이트)
// · 남는 건 정수 · 실수 · 포인터 · Sequential 구조체뿐
// 얻는 것
// · 호출당 수 나노초가 더 줄어듭니다
// · NativeAOT 에서 코드 크기가 줄어듭니다 (k11)
// · "실수로 비싼 마샬링을 넣는 일" 이 컴파일 단계에서 막힙니다 ★
// 잃는 것
// · string 을 그냥 넘길 수 없습니다 → 직접 UTF-8 로 바꿔야 합니다
// · 편의 기능이 사라져 코드가 조금 길어집니다
SuppressGCTransition — 조심해서
using System.Runtime.InteropServices;
// GC 전이(transition)까지 생략합니다 — 호출이 거의 인라인 수준이 됩니다
[LibraryImport("mylib")]
[SuppressGCTransition]
internal static partial int mylib_fast_hash(uint x);
// ★ 쓸 수 있는 조건 (하나라도 어기면 GC 가 멈춥니다)
// · 실행 시간이 매우 짧아야 합니다 (수십 ns 수준 · 1μs 를 넘기지 말 것)
// · 블로킹하지 않아야 합니다 (락 · I/O · sleep 금지)
// · 관리 코드로 되돌아오지 않아야 합니다 (콜백 금지 · k07)
// · 패닉하지 않아야 합니다
//
// 어기면? GC 가 이 스레드를 세우지 못해
// → 전체 애플리케이션이 멈춥니다 (진단이 아주 어렵습니다) ★
★ 판단 기준
초당 수백만 번 부르는 아주 짧은 함수에만 씁니다.
이미지 한 장 처리, 파일 파싱 같은 '긴' 작업에는 절대 쓰지 마세요.
★ 재는 법 (k12)
[Benchmark] 로 켠 것과 끈 것을 나란히 재세요.
차이가 5ns 미만이면 위험을 감수할 값어치가 없습니다.
★ Rust 쪽 대응
#[inline] 을 붙여도 경계는 인라인되지 않습니다.
대신 '한 번에 여러 개' 를 처리하는 배치 함수를 만드세요 (k05).
그게 SuppressGCTransition 보다 훨씬 크고 안전한 이득입니다 ★
#[repr(C)] 와 LayoutKind.Sequential 을 짝지어 붙이고,
bool 은 u8, 문자열은 UTF-8 (ptr, len), Option·Result 는 에러 코드 + out 파라미터로 바꿉니다.
새 프로젝트라면 [assembly: DisableRuntimeMarshalling] 을 켜고 시작하세요.배열과 버퍼 — 복사 없이 주고받기
경계에서 성능이 갈리는 지점은 함수 호출 비용이 아니라 데이터 복사입니다.
1MB 를 넘길 때 복사하면 수십 μs, 포인터만 넘기면 수 ns — 만 배 차이입니다.
C# 은 Span·fixed·GCHandle 로 GC 힙을 잠깐 고정할 수 있고,
그 주소를 Rust 가 slice 로 되살려 씁니다.
① C# 배열을 빌려주기
using System.Runtime.InteropServices;
// 가장 흔한 형태 — fixed 로 고정하고 포인터를 넘깁니다
public static uint Checksum(ReadOnlySpan<byte> data)
{
if (data.IsEmpty) return 0;
unsafe
{
fixed (byte* p = data) // ★ GC 가 못 옮기게 고정
return NativeMethods.mylib_checksum(p, (nuint)data.Length);
}
// fixed 블록을 벗어나면 고정이 풀립니다 — 짧게 잡는 것이 핵심
}
// 결과를 C# 버퍼에 채워 받기 — 할당이 0 이 됩니다 ★
public static int Transform(ReadOnlySpan<byte> src, Span<byte> dst)
{
unsafe
{
fixed (byte* s = src)
fixed (byte* d = dst)
{
nuint written = 0;
int rc = NativeMethods.mylib_transform(
s, (nuint)src.Length, d, (nuint)dst.Length, &written);
Check.Throw(rc);
return (int)written;
}
}
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_transform(
src: *const u8, src_len: usize,
dst: *mut u8, dst_cap: usize,
out_written: *mut usize,
) -> i32 {
if src.is_null() || dst.is_null() || out_written.is_null() {
return -1;
}
let s = unsafe { std::slice::from_raw_parts(src, src_len) };
let d = unsafe { std::slice::from_raw_parts_mut(dst, dst_cap) };
// ★ 반드시 용량을 먼저 확인합니다 — 넘치면 힙이 통째로 깨집니다
if d.len() < s.len() {
unsafe { *out_written = s.len(); } // 필요한 크기를 알려 줍니다
return -4; // BufferTooSmall
}
for (i, b) in s.iter().enumerate() { d[i] = b.wrapping_add(1); }
unsafe { *out_written = s.len(); }
0
}
② Rust 버퍼를 빌려주기
#[repr(C)]
pub struct Buf {
pub ptr: *mut u8,
pub len: usize,
pub cap: usize, // ★ 해제할 때 필요합니다 (Vec 은 cap 을 압니다)
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_render(w: u32, h: u32) -> Buf {
let mut v: Vec<u8> = core::render(w, h);
let b = Buf { ptr: v.as_mut_ptr(), len: v.len(), cap: v.capacity() };
std::mem::forget(v); // ★ 여기서 drop 되면 안 됩니다 (소유권을 뗌)
b
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_buf_free(b: Buf) {
if b.ptr.is_null() { return; }
// 준 그대로 돌려받아 Vec 을 복원해 해제합니다
unsafe { drop(Vec::from_raw_parts(b.ptr, b.len, b.cap)); }
}
[StructLayout(LayoutKind.Sequential)]
public unsafe struct Buf { public byte* Ptr; public nuint Len; public nuint Cap; }
public static byte[] RenderCopy(uint w, uint h)
{
Buf b = NativeMethods.mylib_render(w, h);
try
{
unsafe
{
// 복사 없이 보는 창 — 이 Span 은 free 전까지만 유효합니다 ★
var view = new ReadOnlySpan<byte>(b.Ptr, (int)b.Len);
return view.ToArray(); // 필요하면 여기서 한 번만 복사
}
}
finally { NativeMethods.mylib_buf_free(b); } // ★ 빠뜨리면 누수
}
Span 을 free 밖으로 내보내지 마세요 —
Span<T> 는 ref struct 라 필드에 저장할 수 없어 언어가 어느 정도 막아 주지만,
ToArray() 없이 포인터를 들고 나가면 해제된 메모리를 읽게 됩니다.
복사할 것인가, 수명을 관리할 것인가를 API 마다 정하세요.
이렇게도 쓸 수 있다 — 고정하는 세 가지 방법 🟡 중급
fixed · GCHandle · NativeMemory
| 방법 | 수명 | 언제 |
|---|---|---|
fixed | 블록 안 | 동기 호출 ★ |
GCHandle.Alloc(Pinned) | Free 까지 | 비동기·콜백 |
Memory<T>.Pin() | Dispose 까지 | 파이프라인 |
NativeMemory.Alloc | Free 까지 | GC 밖 ★ |
GC.AllocateArray(pinned:true) | 영구 | 장수 버퍼 |
// ① fixed — 가장 짧고 가장 빠릅니다 (사실상 공짜)
unsafe { fixed (byte* p = span) { Native.f(p, (nuint)span.Length); } }
// ② GCHandle — 호출이 끝난 뒤에도 Rust 가 들고 있어야 할 때 (k07)
var h = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
Native.register(h.AddrOfPinnedObject(), (nuint)buffer.Length);
// … Rust 가 이 버퍼를 비동기로 계속 씁니다 …
}
finally { h.Free(); } // ★ 반드시 풀어야 합니다
// ③ NativeMemory — 아예 GC 밖에 잡습니다 (파편화 걱정 0) ★
unsafe
{
void* p = NativeMemory.AlignedAlloc(1 << 20, 64); // 1MB · 64B 정렬
try { Native.process((byte*)p, 1 << 20); }
finally { NativeMemory.AlignedFree(p); }
}
// ④ 오래 살 버퍼는 '고정된 힙' 에 (POH) — 파편화를 막습니다
byte[] big = GC.AllocateArray<byte>(4 << 20, pinned: true);
핀은 짧게 — 왜 그런가 ★
GC 는 살아남은 객체를 한쪽으로 몰아 붙여(compaction) 빈 공간을 만듭니다.
고정된 객체는 못 옮깁니다 → 그 자리가 '말뚝' 이 됩니다.
Gen0/Gen1 힙에 말뚝이 여럿 박히면
· 압축이 부분적으로만 되고
· 빈 조각이 흩어져 할당이 느려지고
· 결국 Gen2 승격과 전체 GC 가 늘어납니다 ★
★ 실무 규칙
· fixed 블록 안에서 '호출 한 번' 만 하고 나오세요
· 몇 밀리초 이상 잡아야 하면 GC 힙이 아니라
NativeMemory 나 POH(pinned object heap)를 쓰세요
· 큰 버퍼(85KB 이상)는 어차피 LOH 라 잘 안 움직입니다 —
그래도 명시적으로 POH 를 쓰는 편이 예측 가능합니다
★ 확인하는 법
dotnet-counters monitor --counters System.Runtime
gen-0-gc-count · gen-2-gc-count · loh-size 를 봅니다
급증하면 핀 시간이 길거나 배열을 매번 새로 만들고 있는 것입니다
ArrayPool<byte>.Shared.Rent() 로 빌려 쓰고 돌려주면
할당도 GC 도 거의 0 이 됩니다. 그 버퍼를 fixed 로 잠깐 고정해 넘기는 조합이
ASP.NET Core 에서 가장 많이 쓰이는 형태입니다(k12).
실무에선 이렇게 — 숫자로 확인합니다 🔴 실무
복사와 제로카피의 차이
측정 감각 (x64 · .NET 10 · 릴리즈 · 반드시 직접 재세요 · k12)
── 1MB 를 Rust 로 넘길 때 ──────────────────────
byte[] 를 마샬러가 복사 약 40 ~ 90 μs
fixed 로 고정하고 포인터만 약 5 ~ 20 ns ★ 수천 배
ArrayPool 대여 + fixed 약 30 ~ 60 ns ★ 할당 0
── 요소 100만 개를 하나씩 vs 한 번에 ────────────
요소마다 호출 (100만 회) 약 3 ~ 10 ms (호출 비용만)
배치 한 번 (ptr, len) 약 0.005 ms ★
── 구조체 배열 ────────────────────────────────
blittable 이면 복사 없음 (핀만)
string 필드가 하나라도 있으면 요소마다 변환 발생 ★ 피하세요
★ 읽는 법
C# ↔ Rust 는 '호출' 이 싼 대신 '복사' 가 비쌉니다.
파이썬(🐍 y06)은 호출도 복사도 비쌉니다.
→ C# 에서는 '배치로 묶기' 보다 '복사 없애기' 가 훨씬 큰 이득입니다 ★
피해야 할 시그니처
복사가 숨어 있습니다
byte[] data를 그대로 인자로 — 마샬러가 고정할 수도, 복사할 수도 있습니다(런타임 판단).string[] names— 요소마다 변환 + 배열 재구성.[MarshalAs(UnmanagedType.LPArray)]+SizeParamIndex— 편하지만 비용이 보이지 않습니다.- Rust 가
Vec을 만들어 반환하는데 해제 함수가 없음 — 누수.
비용이 보이는 시그니처
byte* ptr, nuint len— 복사 여부가 코드에 드러납니다.- 출력도
byte* dst, nuint cap, nuint* written— 호출자가 버퍼를 소유. - Rust 반환 버퍼는
Buf구조체 +buf_free를 한 쌍으로. - C# 공개 API 는
Span/ReadOnlySpan으로 받아 안에서만fixed.
한 단계 더 — 정렬 · SIMD · 큰 페이지 🧬 전문가
정렬을 맞춰 주면 Rust 가 빨라집니다
// Rust 쪽에서 SIMD 로 처리한다면 정렬이 성능을 가릅니다
#[unsafe(no_mangle)]
pub extern "C" fn mylib_sum_f32(ptr: *const f32, len: usize) -> f32 {
if ptr.is_null() { return 0.0; }
let s = unsafe { std::slice::from_raw_parts(ptr, len) };
// 정렬 여부를 확인해 두면 디버깅이 쉽습니다
debug_assert_eq!(ptr as usize % 32, 0, "32바이트 정렬이 아닙니다");
// 자동 벡터화가 잘 되는 형태로 씁니다 (⚡ e05)
s.iter().sum()
}
// C# 에서 정렬된 버퍼 만들기
unsafe
{
// ① 네이티브 정렬 할당 — 가장 확실합니다
float* p = (float*)NativeMemory.AlignedAlloc(
(nuint)(count * sizeof(float)), 32);
try { var sum = NativeMethods.mylib_sum_f32(p, (nuint)count); }
finally { NativeMemory.AlignedFree(p); }
}
// ② GC 배열은 정렬을 보장하지 않습니다 (보통 8 또는 16바이트)
// → Rust 쪽에서 '정렬되지 않아도 되는' 코드를 쓰거나,
// 앞부분 몇 개를 스칼라로 처리한 뒤 벡터 루프에 들어가게 합니다
// (Rust 의 slice::align_to::<T>() 가 이걸 대신해 줍니다 ★)
공유 버퍼를 오래 두는 패턴
시나리오 — 초당 60프레임으로 같은 4MB 버퍼를 계속 넘긴다
(영상 처리 · 게임 · 실시간 오디오)
❌ 매 프레임 new byte[4MB]
→ LOH 가 계속 자라고 Gen2 GC 가 튑니다
✅ 방법 A — C# 이 한 번 할당하고 계속 재사용
byte[] buf = GC.AllocateArray<byte>(4<<20, pinned: true);
→ 주소가 절대 안 변하므로 Rust 에 한 번만 등록해도 됩니다 ★
✅ 방법 B — Rust 가 한 번 할당하고 C# 이 Span 으로 봅니다
var buf = Native.mylib_frame_buffer(); // 항상 같은 주소
var span = new Span<byte>(buf.Ptr, (int)buf.Len);
→ GC 와 완전히 무관합니다. 종료 시 한 번만 해제
✅ 방법 C — 메모리 매핑 파일로 프로세스 간 공유
MemoryMappedFile + Rust 의 memmap2
→ 별도 프로세스로 분리할 때 (크래시 격리 · k08)
★ A 와 B 중에는 '누가 그 데이터의 주인인가' 로 고르세요.
Rust 가 계속 상태를 들고 있다면 B, C# 이 주인이면 A 입니다.
fixed 로 짧게 고정해 넘기고, Rust 버퍼는 Buf 구조체 + buf_free 한 쌍으로 돌려줍니다.
핀은 짧을수록 좋고, 오래 잡아야 하면 NativeMemory 나 고정 힙(POH) 을 쓰세요.
C# 에서는 배치보다 복사 제거가 훨씬 큰 이득입니다.핸들 — Rust 객체의 수명을 C# 이 관리하기
Rust 쪽에 상태(열린 파일·디코더·연결 풀)를 두고 여러 번 호출하고 싶을 때 쓰는 것이 불투명 핸들입니다.
C# 은 주소 하나만 받아 들고 다니고, 안이 무엇인지는 전혀 모릅니다.
핵심은 Rust 의 Drop 과 C# 의 Dispose 를 정확히 한 번씩 짝지어 주는 것입니다.
Box::into_raw ↔ Box::from_raw, SafeHandle ↔ Dispose — 둘 다 정확히 한 번.Rust 쪽 — 세 함수
use std::ffi::{c_char, c_void, CStr};
pub struct Decoder { // ★ repr(C) 가 아니어도 됩니다
inner: core::Decoder, // C# 은 안을 절대 안 보니까요
frames: u64,
}
// ① 만들기 — 실패하면 널을 돌려줍니다
#[unsafe(no_mangle)]
pub extern "C" fn mylib_decoder_open(path: *const c_char) -> *mut c_void {
if path.is_null() { return std::ptr::null_mut(); }
let Ok(s) = (unsafe { CStr::from_ptr(path) }).to_str() else {
return std::ptr::null_mut();
};
match core::Decoder::open(s) {
Ok(inner) => {
let boxed = Box::new(Decoder { inner, frames: 0 });
Box::into_raw(boxed) as *mut c_void // ★ 소유권을 뗍니다
}
Err(_) => std::ptr::null_mut(),
}
}
// ② 쓰기 — 포인터를 참조로 되살립니다
#[unsafe(no_mangle)]
pub extern "C" fn mylib_decoder_next(h: *mut c_void,
dst: *mut u8, cap: usize,
out_len: *mut usize) -> i32 {
if h.is_null() || dst.is_null() { return -1; }
let d = unsafe { &mut *(h as *mut Decoder) }; // ★ 유일한 unsafe
let buf = unsafe { std::slice::from_raw_parts_mut(dst, cap) };
match d.inner.next_frame(buf) {
Ok(n) => { d.frames += 1; unsafe { *out_len = n; } 0 }
Err(_) => -5,
}
}
// ③ 없애기 — 널 검사를 반드시 넣습니다
#[unsafe(no_mangle)]
pub extern "C" fn mylib_decoder_close(h: *mut c_void) {
if h.is_null() { return; }
unsafe { drop(Box::from_raw(h as *mut Decoder)); } // ★ 여기서 Drop
}
C# 쪽 — SafeHandle 래퍼 ★
using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;
// 0 또는 -1 이면 무효인 핸들 — 가장 흔한 규약입니다
internal sealed class DecoderHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public DecoderHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle()
{
NativeMethods.mylib_decoder_close(handle); // ★ 정확히 한 번
return true;
}
}
public sealed class Decoder : IDisposable
{
readonly DecoderHandle _h;
public Decoder(string path)
{
nint p = NativeMethods.mylib_decoder_open(path);
if (p == 0) throw new MyLibException(MyLibCode.NotFound, path);
_h = new DecoderHandle();
Marshal.InitHandle(_h, p); // .NET 6+ — 핸들을 안전하게 설정
}
public int Next(Span<byte> dst)
{
ObjectDisposedException.ThrowIf(_h.IsClosed, this);
bool taken = false;
try
{
_h.DangerousAddRef(ref taken); // ★ 호출 중 해제되지 않게
unsafe
{
fixed (byte* d = dst)
{
nuint n;
Check.Throw(NativeMethods.mylib_decoder_next(
_h.DangerousGetHandle(), d, (nuint)dst.Length, &n));
return (int)n;
}
}
}
finally { if (taken) _h.DangerousRelease(); }
}
public void Dispose() => _h.Dispose();
}
// 쓰는 쪽 — 완전히 C# 답습니다
using var dec = new Decoder("input.mp4");
Span<byte> frame = new byte[1920 * 1080 * 4];
while (dec.Next(frame) > 0) { /* … */ }
이렇게도 쓸 수 있다 — nint 로 그냥 들고 다니면 안 되나 🟡 중급
SafeHandle 이 막아 주는 것들
| 위험 | nint | SafeHandle |
|---|---|---|
| 해제를 잊음 | 누수 | 파이널라이저가 정리 |
| 두 번 해제 | 크래시 | 구조적으로 불가 |
| 사용 중 해제 | use-after-free | AddRef 가 막음 |
| 스레드 경쟁 | 없음 | 참조 계수로 안전 |
| 프로세스 종료 | 누수 | CriticalFinalizer |
| 비용 | 0 | 호출당 수 ns |
// ❌ 이렇게 하면 언젠가 반드시 사고가 납니다
public class BadDecoder
{
nint _h;
public BadDecoder(string p) => _h = Native.mylib_decoder_open(p);
public void Close() => Native.mylib_decoder_close(_h);
// · Close 를 두 번 부르면 이중 해제 → 힙 파손
// · Close 를 안 부르면 누수
// · 다른 스레드가 쓰는 중에 Close 하면 use-after-free ★
}
// △ 최소한 이 정도는 해야 합니다 (SafeHandle 을 못 쓸 때)
public void Close()
{
nint h = Interlocked.Exchange(ref _h, 0); // ★ 원자적으로 뺏어 옵니다
if (h != 0) Native.mylib_decoder_close(h);
}
Rust 쪽 방어 ★
// 널 검사만으로는 부족합니다 — '이미 해제된 주소' 는 널이 아닙니다.
// 실무에서 쓰는 두 가지 보강책
// ① 매직 넘버 — 값싸고 효과가 큽니다
const MAGIC: u32 = 0x5255_5354; // "RUST"
pub struct Decoder { magic: u32, inner: core::Decoder }
fn as_decoder<'a>(h: *mut c_void) -> Option<&'a mut Decoder> {
if h.is_null() { return None; }
let d = unsafe { &mut *(h as *mut Decoder) };
if d.magic != MAGIC { return None; } // ★ 해제 후 접근을 대개 잡습니다
Some(d)
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_decoder_close(h: *mut c_void) {
if h.is_null() { return; }
let d = unsafe { &mut *(h as *mut Decoder) };
if d.magic != MAGIC { return; } // 이중 해제 방지
d.magic = 0; // ★ 먼저 무효화
unsafe { drop(Box::from_raw(h as *mut Decoder)); }
}
// ② 세대 번호가 들어간 핸들 (더 견고하지만 표가 필요합니다)
// u64 = (index << 32) | generation
// 전역 슬랩에 저장하고, 해제할 때 generation 을 올립니다.
// → 옛 핸들로 접근하면 generation 불일치로 확실히 걸립니다 ★
#[cfg(debug_assertions)] 로 감싸면 릴리즈에서는 사라집니다.
개발 중에 이중 해제를 잡는 것이 목적이니까요.
운영에서는 C# 쪽 SafeHandle 이 이미 막고 있습니다.
실무에선 이렇게 — 스레드 안전을 문서로 못 박습니다 🔴 실무
누가 락을 잡나
// 선택 ① Rust 안에서 락 — C# 은 아무 스레드에서나 불러도 됩니다
use std::sync::Mutex;
pub struct Shared { inner: Mutex<core::Decoder> }
#[unsafe(no_mangle)]
pub extern "C" fn mylib_shared_next(h: *mut c_void, /* … */) -> i32 {
let s = unsafe { &*(h as *mut Shared) };
let mut g = match s.inner.lock() {
Ok(g) => g,
Err(_) => return -6, // ★ 다른 스레드가 패닉해 락이 오염됨
};
// …
0
}
// 선택 ② 락 없음 — "한 핸들은 한 스레드에서만" 을 문서로 못 박습니다
// 더 빠르지만 지키기는 사용자 몫입니다.
// C# 쪽 XML 문서에 반드시 적으세요:
// /// <remarks>이 타입은 스레드 안전하지 않습니다.</remarks>
// 선택 ③ 읽기는 여럿, 쓰기는 하나 — RwLock
// 조회가 압도적으로 많은 캐시 · 인덱스에 맞습니다
핸들을 여러 개 관리할 때
★ 실무 체크리스트
① open 이 실패하면 무엇을 돌려주나
널 포인터 + mylib_last_error() (k08). 예외는 못 던집니다.
② close 를 두 번 부르면
아무 일도 안 일어나야 합니다 (널 검사 + 매직)
③ close 한 뒤 다른 함수를 부르면
-1 을 돌려주거나, 디버그 빌드에서 assert 로 즉시 죽입니다 ★
(조용히 잘못된 값을 돌려주는 것이 최악입니다)
④ 프로세스가 죽을 때 정리되나
운영체제가 메모리는 회수합니다.
하지만 '파일 플러시' 같은 건 안 됩니다 →
C# 쪽에 AppDomain.ProcessExit 훅을 두거나,
핸들을 CriticalFinalizerObject 로 만드세요
⑤ 핸들이 수천 개가 되나
그렇다면 개별 Box 보다 슬랩(Vec + free list)이 낫습니다.
할당이 줄고 지역성이 좋아집니다 (⚡ e04)
⑥ C# 쪽에서 유한 자원인가
DecoderHandle 을 IDisposable 로 만들고,
analyzer(CA2000)로 Dispose 누락을 잡게 하세요 ★
한 단계 더 — Arc 를 쓸 때와 안 쓸 때 🧬 전문가
공유가 필요하면 Arc
use std::sync::Arc;
// 하나의 모델을 여러 세션이 공유하는 경우 (추론 서버 · k12)
pub struct Model { /* 수백 MB */ }
pub struct Session { model: Arc<Model>, state: core::State }
#[unsafe(no_mangle)]
pub extern "C" fn mylib_model_load(p: *const c_char) -> *mut c_void {
let m = Arc::new(core::load(/* … */));
Arc::into_raw(m) as *mut c_void // ★ Box 가 아니라 Arc
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_session_new(model: *mut c_void) -> *mut c_void {
// 참조 계수를 하나 올려 세션이 들고 갑니다
let m = unsafe { Arc::from_raw(model as *const Model) };
let cloned = Arc::clone(&m);
std::mem::forget(m); // ★ 원본 계수는 유지
Box::into_raw(Box::new(Session { model: cloned, state: Default::default() }))
as *mut c_void
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_model_release(model: *mut c_void) {
if model.is_null() { return; }
unsafe { drop(Arc::from_raw(model as *const Model)); } // 계수 하나 감소
}
// → 모델은 마지막 세션이 사라질 때 자동으로 해제됩니다 ★
주의점과 대안
★ Arc 를 경계에 쓸 때 주의
· Arc::into_raw / from_raw 는 '계수 이동' 입니다.
실수로 짝을 안 맞추면 계수가 새거나 조기 해제됩니다.
→ clone 을 만들 때 mem::forget 을 빠뜨리는 실수가 가장 흔합니다 ★
· Arc<dyn Trait> 같은 뚱뚱한 포인터는 크기가 16바이트라
*mut c_void 로 못 담습니다 → Box 로 한 번 더 감싸세요
· 순환 참조가 생기면 영원히 해제 안 됩니다 (Weak 을 쓰세요)
★ 대안 — '레지스트리' 패턴 (더 단순합니다)
Rust 안에 전역 HashMap<u64, Arc<Model>> 을 두고
C# 에는 u64 아이디만 넘깁니다.
장점 · 잘못된 포인터가 원천적으로 불가능 (아이디는 그냥 숫자)
· 목록 조회 · 통계 · 강제 정리가 쉬움
· C# 이 아이디를 로그에 찍을 수 있음 ★
단점 · 조회 비용(해시)이 호출마다 붙음
· 전역 상태라 테스트가 조금 불편
핸들이 수십 개 이하이고 진단이 중요하다면 레지스트리,
초당 수백만 번 부른다면 원시 포인터를 고르세요.
Box::into_raw 로 주소만 내주고,
C# 은 그 주소를 SafeHandle 에 담아 들고 다닙니다.
Dispose ↔ ReleaseHandle ↔ Box::from_raw 가 한 줄로 이어지면
누수도 이중 해제도 구조적으로 사라집니다.
Rust 쪽에는 널 검사 + 매직 넘버를 방어로 두세요.콜백 — Rust 가 C# 을 되부를 때 생기는 일
진행률 보고·완료 알림·로그처럼 Rust 가 C# 쪽 코드를 부르는 방향이 필요할 때가 있습니다.
기술적으로는 함수 포인터 하나를 넘기는 것이라 간단하지만,
그 콜백이 어느 스레드에서 실행되는가를 놓치면 사고가 납니다 —
특히 Rust 가 tokio 워커에서 부를 때 그렇습니다.
try, 마지막 줄은 UI 스레드로 되돌리는 Post 입니다.Rust 쪽 — 함수 포인터 받기
use std::ffi::c_void;
// ★ user 포인터를 항상 함께 받으세요 — 콜백이 '누구를 위한 것인지' 를 압니다
pub type ProgressFn = extern "C" fn(user: *mut c_void, done: u32, total: u32);
#[unsafe(no_mangle)]
pub extern "C" fn mylib_process(
ptr: *const u8, len: usize,
cb: Option<ProgressFn>, // ★ 널을 허용하려면 Option
user: *mut c_void,
) -> i32 {
if ptr.is_null() { return -1; }
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
let total = data.len() as u32;
for (i, chunk) in data.chunks(4096).enumerate() {
core::step(chunk);
if let Some(f) = cb {
f(user, (i as u32 + 1) * 4096, total); // ★ 콜백 호출
}
}
0
}
// 스레드로 넘길 때는 포인터를 감싸야 합니다
// (*mut c_void 는 Send 가 아닙니다 — 컴파일러가 막습니다)
struct UserPtr(*mut c_void);
unsafe impl Send for UserPtr {} // ★ "C# 쪽이 스레드 안전을 보장한다" 는 약속
#[unsafe(no_mangle)]
pub extern "C" fn mylib_start(cb: ProgressFn, user: *mut c_void) -> i32 {
let u = UserPtr(user);
std::thread::spawn(move || {
let u = u; // 소유권을 스레드로
for i in 0..100u32 {
core::work();
cb(u.0, i + 1, 100); // 워커 스레드에서 호출
}
});
0 // 즉시 돌아옵니다
}
C# 쪽 — UnmanagedCallersOnly ★
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
public sealed class Job
{
public event Action<int>? Progress;
// ★ static 이어야 하고, 인자는 전부 blittable 이어야 합니다
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static void OnProgress(nint user, uint done, uint total)
{
// ★ 예외가 여기서 밖으로 나가면 프로세스가 죽습니다 — 반드시 감쌉니다
try
{
var job = (Job?)GCHandle.FromIntPtr(user).Target;
job?.Progress?.Invoke((int)(done * 100 / Math.Max(total, 1)));
}
catch { /* 로깅만 하고 삼킵니다 */ }
}
public unsafe void Run(ReadOnlySpan<byte> data)
{
// 자기 자신을 콜백에 전달할 수 있게 핸들을 만듭니다
var h = GCHandle.Alloc(this); // ★ Pinned 가 아니어도 됩니다
try
{
fixed (byte* p = data)
Check.Throw(NativeMethods.mylib_process(
p, (nuint)data.Length,
&OnProgress, // ★ 함수 주소를 직접 넘깁니다
GCHandle.ToIntPtr(h)));
}
finally { h.Free(); } // ★ 반드시 해제
}
}
[UnmanagedCallersOnly] 메서드의 주소는 고정된 네이티브 진입점입니다.
델리게이트를 Marshal.GetFunctionPointerForDelegate 로 넘기면
그 델리게이트를 붙잡아 두지 않는 한 GC 가 수거해 버립니다 —
FFI 에서 가장 흔한 크래시 원인입니다.
이렇게도 쓸 수 있다 — 델리게이트를 써야 할 때(Unity) 🟡 중급
Mono · IL2CPP 에서는 이렇게
using AOT; // Unity
using System.Runtime.InteropServices;
public static class NativeBridge
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void ProgressDelegate(nint user, uint done, uint total);
// ★ IL2CPP 에서는 이 특성이 있어야 역방향 호출이 컴파일됩니다
[MonoPInvokeCallback(typeof(ProgressDelegate))]
static void OnProgress(nint user, uint done, uint total)
{
try { Queue.Enqueue(done, total); } // 메인 스레드로 넘깁니다
catch (Exception e) { Debug.LogException(e); }
}
// ★★ 반드시 static 필드로 붙잡아 둡니다 — 안 그러면 GC 가 수거합니다
static readonly ProgressDelegate _keepAlive = OnProgress;
static GCHandle _pin;
public static void Start()
{
_pin = GCHandle.Alloc(_keepAlive); // 이중 안전장치
NativeMethods.mylib_start(
Marshal.GetFunctionPointerForDelegate(_keepAlive), 0);
}
public static void Stop()
{
NativeMethods.mylib_stop();
if (_pin.IsAllocated) _pin.Free();
}
}
UI 스레드로 되돌리기 ★
// WPF · WinForms · Avalonia · MAUI 공통 — 캡처해 두고 Post 합니다
public sealed class Job
{
readonly SynchronizationContext? _ui = SynchronizationContext.Current;
void Report(int percent)
{
if (_ui is null) { Progress?.Invoke(percent); return; }
_ui.Post(_ => Progress?.Invoke(percent), null); // ★ UI 스레드로
}
}
// 더 나은 방법 — IProgress<T> 를 쓰면 프레임워크가 알아서 합니다
public void Run(ReadOnlySpan<byte> data, IProgress<int>? progress)
{
// Progress<T> 는 생성된 스레드의 SynchronizationContext 를 기억합니다
// → 콜백이 어느 스레드에서 오든 UI 스레드에서 실행됩니다 ★
}
// Unity — 메인 스레드에서만 UnityEngine API 를 쓸 수 있습니다
static readonly ConcurrentQueue<(uint, uint)> _q = new();
// 콜백에서는 큐에 넣기만 합니다 (락 없음 · 할당 없음)
static void OnProgress(nint user, uint done, uint total) => _q.Enqueue((done, total));
// MonoBehaviour.Update 에서 꺼내 씁니다
void Update()
{
while (_q.TryDequeue(out var p))
progressBar.fillAmount = (float)p.Item1 / p.Item2;
}
실무에선 이렇게 — 완료를 Task 로 받기 🔴 실무
콜백을 async/await 로 감싸기
public static class NativeAsync
{
sealed class Pending
{
// ★ RunContinuationsAsynchronously 를 반드시 켜세요
// 안 켜면 이어지는 코드가 Rust 스레드에서 계속 실행됩니다
public readonly TaskCompletionSource<int> Tcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static void OnDone(nint user, int result)
{
GCHandle h = default;
try
{
h = GCHandle.FromIntPtr(user);
((Pending)h.Target!).Tcs.TrySetResult(result);
}
catch { }
finally { if (h.IsAllocated) h.Free(); } // ★ 여기서 정리
}
public static unsafe Task<int> ProcessAsync(ReadOnlyMemory<byte> data,
CancellationToken ct = default)
{
var p = new Pending();
var gh = GCHandle.Alloc(p);
var pin = data.Pin(); // ★ 비동기 동안 고정 유지
ct.Register(() => NativeMethods.mylib_cancel());
int rc = NativeMethods.mylib_process_async(
(byte*)pin.Pointer, (nuint)data.Length,
&OnDone, GCHandle.ToIntPtr(gh));
if (rc != 0) { pin.Dispose(); gh.Free(); Check.Throw(rc); }
return p.Tcs.Task.ContinueWith(t => { pin.Dispose(); return t.Result; });
}
}
체크리스트와 대안
★ 콜백을 쓸 때 반드시 확인할 것
① 콜백 대상 객체를 GCHandle 로 붙잡았는가 (그리고 Free 했는가)
② 넘긴 버퍼가 콜백이 끝날 때까지 고정돼 있는가 (Memory.Pin)
③ 콜백 본문이 try 로 감싸져 있는가 ★
④ 콜백 안에서 오래 걸리는 일을 하지 않는가
(Rust 워커를 붙잡고 있게 됩니다 — 큐에 넣고 즉시 반환하세요)
⑤ 콜백 안에서 다시 Rust 를 부르면 재진입·데드락이 나지 않는가
⑥ 취소를 어떻게 알리는가 (원자적 플래그 · mylib_cancel)
★ 콜백을 아예 안 쓰는 대안 — 자주 더 낫습니다
· 폴링 : mylib_poll_progress(h, &done, &total) 를 C# 타이머가 부름
→ 스레드 문제도 GC 문제도 없습니다 ★
· 채널 : Rust 가 결과를 내부 큐에 쌓고
C# 이 mylib_try_recv(h, buf, cap, &len) 로 꺼냄
· 파일 · 파이프 : 대용량이거나 프로세스를 나눌 때 (k08)
진행률처럼 '가끔' 필요한 정보라면 폴링이 압도적으로 단순합니다.
초당 수천 번 알림이 필요할 때만 콜백을 쓰세요.
한 단계 더 — 역방향 호출의 실제 비용 🧬 전문가
스레드 붙이기(attach)
Rust 가 만든 스레드가 처음 C# 코드를 부를 때
CLR 은 그 스레드를 런타임에 '등록' 합니다 (attach).
· 스레드마다 처음 한 번만 발생 — 수 μs
· 이후 호출은 수 ns
· TLS 슬롯 · GC 정보 · 예외 처리 프레임이 만들어집니다
★ 그래서 이런 패턴이 최악입니다
Rust 가 매 작업마다 새 스레드를 만들어 콜백을 한 번씩 부른다
→ 매번 attach 비용을 냅니다
★ 좋은 패턴
tokio 나 rayon 의 '고정된 워커 풀' 에서 부릅니다.
스레드 수가 코어 수만큼으로 한정되므로 attach 는 총 몇 번뿐입니다 ★
★ 확인하는 법
dotnet-counters 로 threadpool-thread-count 를 보고,
Rust 쪽은 std::thread::current().id() 를 로그에 찍어
스레드가 재사용되는지 확인하세요.
재진입과 데드락
// ❌ 락을 잡은 채로 콜백을 부르면 데드락이 납니다
#[unsafe(no_mangle)]
pub extern "C" fn bad(h: *mut c_void, cb: ProgressFn, user: *mut c_void) {
let s = unsafe { &*(h as *mut Shared) };
let g = s.inner.lock().unwrap(); // ★ 락 획득
cb(user, 1, 1); // C# 이 여기서 다시 mylib_* 를 부르면
// → 같은 락을 다시 잡으려다 영원히 멈춥니다
drop(g);
}
// ✅ 락 밖에서 부릅니다
#[unsafe(no_mangle)]
pub extern "C" fn good(h: *mut c_void, cb: ProgressFn, user: *mut c_void) {
let snapshot = {
let s = unsafe { &*(h as *mut Shared) };
let g = s.inner.lock().unwrap();
g.progress() // 필요한 값만 복사해서 나옵니다
}; // ★ 여기서 락이 풀립니다
cb(user, snapshot.done, snapshot.total);
}
// 규칙 — '콜백을 부르는 순간에는 어떤 락도 잡고 있지 않다' 를
// 문서와 코드 리뷰의 고정 항목으로 두세요 ★
[UnmanagedCallersOnly] + delegate* 로 넘기고(Unity 는 MonoPInvokeCallback + 붙잡아 두기),
본문은 반드시 try 로 감싸고 UI 는 Post 로 되돌립니다.
락을 잡은 채로 콜백을 부르지 마세요.
그리고 대부분의 경우 폴링이 더 단순하고 안전합니다.패닉 — 경계를 넘게 두면 안 됩니다
Rust 의 panic! 은 기본적으로 스택을 되감으며(unwind) 올라갑니다.
그 되감기가 C# 이 만든 스택 프레임을 지나가면 정의되지 않은 동작입니다.
운이 좋으면 즉시 죽고, 나쁘면 한참 뒤에 엉뚱한 곳에서 죽습니다.
경계 함수마다 catch_unwind 로 가둬 에러 코드로 바꾸는 것이 정답입니다.
감싸는 매크로 하나 만들기 ★
use std::panic::{catch_unwind, AssertUnwindSafe};
// 모든 경계 함수에 붙일 매크로 — 한 번 만들어 두면 잊을 일이 없습니다
macro_rules! ffi_guard {
($body:expr) => {
match catch_unwind(AssertUnwindSafe(|| $body)) {
Ok(v) => v,
Err(e) => {
let msg = if let Some(s) = e.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
set_last_error(format!("panic: {msg}")); // 아래에서 정의
-99 // Code::Panic
}
}
};
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_parse(p: *const u8, len: usize, out: *mut i64) -> i32 {
ffi_guard!({
if p.is_null() || out.is_null() { return -1; }
let s = unsafe { std::slice::from_raw_parts(p, len) };
let v = core::parse(s)?; // 여기서 패닉해도 안전합니다 ★
unsafe { *out = v; }
0
})
}
AssertUnwindSafe 는 무엇인가 —
“되감기 도중에 반쯤 고쳐진 상태가 밖에서 보일 수 있다”는 경고를 끄는 표시입니다.
끈다고 안전해지는 게 아니라, 공유 상태를 패닉이 깨뜨리지 않게 설계했다는 약속입니다.
Mutex 는 패닉하면 오염(poisoned) 되니 lock() 결과를 항상 확인하세요.
마지막 에러 메시지 전달 ★
use std::cell::RefCell;
// 스레드마다 하나씩 — 다른 스레드의 에러와 섞이지 않습니다 ★
thread_local! {
static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
}
pub fn set_last_error(msg: String) {
LAST_ERROR.with(|e| *e.borrow_mut() = Some(msg));
}
/// 마지막 에러를 UTF-8 로 buf 에 채우고 쓴 바이트 수를 돌려줍니다.
/// buf 가 널이면 필요한 크기만 돌려줍니다.
#[unsafe(no_mangle)]
pub extern "C" fn mylib_last_error(buf: *mut u8, cap: usize) -> usize {
LAST_ERROR.with(|e| {
let b = e.borrow();
let Some(s) = b.as_ref() else { return 0 };
let bytes = s.as_bytes();
if buf.is_null() { return bytes.len(); } // 크기 조회 모드
let n = bytes.len().min(cap);
unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, n); }
n
})
}
// 에러를 만드는 곳마다 이 함수를 부릅니다
fn to_code(e: core::Error) -> i32 {
set_last_error(e.to_string()); // ★ Display 구현을 그대로 씁니다
match e {
core::Error::NotFound(_) => -3,
core::Error::Io(_) => -5,
_ => -9,
}
}
thread_local 인가 — 전역 변수 하나로 두면
여러 스레드가 동시에 실패했을 때 메시지가 섞입니다.
C 의 errno 도 같은 이유로 스레드 지역입니다.
버퍼를 호출자가 주는 형태라 문자열 해제 문제도 함께 사라집니다(k04).
이렇게도 쓸 수 있다 — C# 예외로 자연스럽게 🟡 중급
예외 변환 계층
public sealed class MyLibException : Exception
{
public MyLibCode Code { get; }
internal MyLibException(MyLibCode code, string msg) : base(msg) => Code = code;
}
// Rust 의 패닉은 '버그' 이므로 별도 타입으로 구분해 두면 좋습니다 ★
public sealed class NativePanicException : MyLibException
{
internal NativePanicException(string msg)
: base(MyLibCode.Panic, "네이티브 패닉: " + msg) { }
}
internal static class Check
{
public static void Throw(int rc)
{
if (rc == 0) return;
string msg = LastError();
throw rc == -99
? new NativePanicException(msg)
: new MyLibException((MyLibCode)rc, msg);
}
static unsafe string LastError()
{
nuint need = NativeMethods.mylib_last_error(null, 0); // 크기 조회
if (need == 0) return "(메시지 없음)";
Span<byte> buf = need <= 512 ? stackalloc byte[(int)need] : new byte[need];
fixed (byte* p = buf)
{
nuint n = NativeMethods.mylib_last_error(p, (nuint)buf.Length);
return Encoding.UTF8.GetString(buf[..(int)n]);
}
}
}
패닉을 로그로 남기기
// 라이브러리 초기화 때 훅을 걸어 두면 스택 트레이스가 남습니다 ★
#[unsafe(no_mangle)]
pub extern "C" fn mylib_init() {
std::panic::set_hook(Box::new(|info| {
// RUST_BACKTRACE=1 이면 백트레이스도 함께
let bt = std::backtrace::Backtrace::force_capture();
let msg = format!("{info}\n{bt}");
set_last_error(msg.clone());
eprintln!("[mylib panic] {msg}"); // stderr 로도
}));
}
// 훅은 '되감기 전' 에 불립니다 — 그래서 위치 정보가 살아 있습니다.
// catch_unwind 는 '되감기 후' 라 페이로드(문자열)밖에 못 봅니다. ★
// C# 쪽에서도 네이티브 크래시를 남기게 해 둡니다
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
File.AppendAllText("crash.log", e.ExceptionObject?.ToString() + "\n");
// .NET 의 네이티브 크래시 덤프 (환경 변수)
// DOTNET_DbgEnableMiniDump=1
// DOTNET_DbgMiniDumpType=2 (Heap)
// DOTNET_DbgMiniDumpName=/tmp/dump_%p
// → dotnet-dump analyze 로 열어 봅니다
[profile.release] debug = 1 이 필요합니다
(심볼만 넣고 최적화는 유지). 파일이 커지므로 심볼을 분리해 따로 보관하는 편이 낫습니다(🛠️ b10).
심볼 없이 받은 크래시 리포트는 주소 나열일 뿐입니다.
실무에선 이렇게 — 패닉을 애초에 줄입니다 🔴 실무
경계 코드에서 금지할 것들
ffi.rs 에 있으면 안 되는 것
unwrap()·expect()— 전부match나let else로.slice[i]직접 인덱싱 —get(i)를 쓰세요.a / b—checked_div.String::from_utf8(...).unwrap().- 메모리 할당 실패(OOM)는 어차피 abort 입니다 — 큰 할당은
try_reserve.
대신 이렇게
let Some(x) = s.get(i) else { return -1 };clippy::unwrap_used를 ffi 모듈에만deny로 켭니다.- 순수 로직(core.rs)에서는
unwrap을 편하게 써도 됩니다 — 경계가 막아 줍니다. cargo fuzz로 경계 함수에 무작위 입력을 넣어 봅니다(🔬 d09).
// src/ffi.rs 맨 위에
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
// → 경계 파일에서만 엄격하게. core.rs 는 평소대로 씁니다 ★
최후의 방어 — 프로세스 분리
어떤 코드는 아무리 조심해도 죽습니다
(외부 코덱 · 오래된 C 라이브러리 · 신뢰할 수 없는 입력)
★ 그럴 때는 아예 프로세스를 나눕니다
C# 앱 ──stdin/stdout──▶ rust-worker (별도 실행 파일)
◀──결과 바이트─────
· 워커가 죽어도 앱은 멀쩡합니다 ★
· 메모리 상한 · 타임아웃을 OS 수준에서 걸 수 있습니다
· 대가: 프로세스 시작 비용(수 ms) + 직렬화 비용
C# 쪽 구현
var psi = new ProcessStartInfo("rust-worker") {
RedirectStandardInput = true, RedirectStandardOutput = true };
using var p = Process.Start(psi)!;
await p.StandardInput.BaseStream.WriteAsync(input);
// 타임아웃 · 종료 코드로 실패를 판정합니다
· 대용량이면 파이프 대신 MemoryMappedFile 로 (k05)
· 워커를 살려 두고 요청만 주고받으면 시작 비용도 한 번뿐입니다
★ 판단 기준 — '이 코드가 죽으면 앱 전체가 죽어도 되는가'
안 된다면 프로세스를 나누세요. FFI 안전 규칙보다 확실합니다.
한 단계 더 — abort 를 골라야 할 때 🧬 전문가
unwind vs abort
| unwind (기본) | abort | |
|---|---|---|
| catch_unwind | 잡힙니다 | 못 잡음 |
| 바이너리 크기 | +5~10% | 작음 |
| 성능 | 사실상 동일 | 동일 |
| Drop 실행 | 됩니다 | 안 됨 |
| C# 라이브러리 | 이것 ★ | 부적합 |
| Unity iOS | 가능 | 고려 대상 |
| WASM | 제한적 | 흔히 씀 |
# ❌ 라이브러리에서는 쓰지 마세요
[profile.release]
panic = "abort"
# ✅ 기본값(unwind)을 유지하고 경계에서 잡습니다
[profile.release]
lto = true
codegen-units = 1
# panic 은 지정하지 않음 = unwind
panic = "abort" 를 기대할 수도 있습니다 —
#[unsafe(no_mangle)] extern "C" 함수는 Rust 1.81 부터 패닉이 새어 나가면 자동으로 abort 합니다.
즉 UB 는 아니게 됐지만 프로세스는 여전히 죽습니다.
catch_unwind 는 여전히 필요합니다.
테스트로 확인하기
// 경계가 정말 패닉을 막는지 테스트로 못 박습니다 ★
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn panic_is_caught_at_boundary() {
// 일부러 패닉하는 입력을 넣습니다
let rc = mylib_parse(std::ptr::null(), 0, std::ptr::null_mut());
assert_eq!(rc, -1); // 널 검사에 걸림
// 내부에서 패닉하도록 만든 테스트 전용 함수
let rc = mylib_test_panic();
assert_eq!(rc, -99); // ★ 잡혔다
}
#[test]
fn null_pointers_never_crash() {
// 모든 경계 함수에 널을 넣어 봅니다 — 죽지 않아야 합니다
assert!(mylib_checksum(std::ptr::null(), 0) == 0);
mylib_decoder_close(std::ptr::null_mut()); // 두 번 불러도 안전
mylib_decoder_close(std::ptr::null_mut());
}
}
// C# 쪽에서도 통합 테스트로 확인합니다
[Fact]
public void NativePanic_BecomesException()
{
var ex = Assert.Throws<NativePanicException>(() => MyLib.TestPanic());
Assert.Contains("panic", ex.Message);
// ★ 중요한 건 '예외가 났다' 가 아니라
// '프로세스가 살아 있다' 는 것입니다
}
extern "C" 함수를 catch_unwind 매크로로 감싸
패닉을 -99 같은 코드로 바꾸고, 메시지는 thread_local + mylib_last_error 로 전달합니다.
C# 쪽은 그것을 전용 예외 타입으로 올립니다.
라이브러리에서는 panic = "abort" 를 쓰지 말고,
죽어도 되는 코드가 아니라면 프로세스 분리를 고려하세요.Unity — 플러그인으로 넣기와 그 함정들
Unity 는 .NET 이 아니라 Mono 또는 IL2CPP 위에서 돕니다.
그래서 [LibraryImport] 가 안 되고, 플랫폼마다 파일을 놓는 자리가 다르며,
에디터가 DLL 을 붙잡고 안 놓아 주는 고유한 문제가 있습니다.
하나씩 실제 해결책과 함께 봅니다.
플랫폼별 빌드 명령 ★
# ── 데스크톱 ──────────────────────────────
cargo build --release # 현재 플랫폼
# 맥 유니버설 (인텔 + 애플 실리콘을 하나로)
rustup target add x86_64-apple-darwin aarch64-apple-darwin
cargo build --release --target x86_64-apple-darwin
cargo build --release --target aarch64-apple-darwin
lipo -create -output mylib.bundle \
target/x86_64-apple-darwin/release/libmylib.dylib \
target/aarch64-apple-darwin/release/libmylib.dylib
# ── 안드로이드 ────────────────────────────
cargo install cargo-ndk
rustup target add aarch64-linux-android armv7-linux-androideabi
# ANDROID_NDK_HOME 를 Unity 가 설치한 NDK 경로로 맞춰 두면 편합니다
cargo ndk -t arm64-v8a -t armeabi-v7a \
-o ../unity/Assets/Plugins/Android/libs build --release
# ── iOS ───────────────────────────────────
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
cargo build --release --target aarch64-apple-ios # 실기기
cargo build --release --target aarch64-apple-ios-sim # 시뮬레이터
cp target/aarch64-apple-ios/release/libmylib.a \
../unity/Assets/Plugins/iOS/
# iOS 를 위해 staticlib 을 함께 냅니다
[lib]
crate-type = ["cdylib", "staticlib"]
# ^^^^^^ ^^^^^^^^^^
# 데스크톱·안드로이드 iOS
C# 쪽 — 하나의 소스로 전부
// Assets/Scripts/NativeMethods.g.cs — csbindgen 이 생성 (k03)
using System.Runtime.InteropServices;
public static unsafe partial class NativeMethods
{
#if UNITY_IOS && !UNITY_EDITOR
const string __DllName = "__Internal"; // ★ 정적 링크
#else
const string __DllName = "mylib";
#endif
[DllImport(__DllName, EntryPoint = "mylib_checksum",
CallingConvention = CallingConvention.Cdecl)]
public static extern uint mylib_checksum(byte* ptr, nuint len);
}
// 쓰는 쪽 — NativeArray 와 잘 맞습니다 ★
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
public sealed class Hasher : MonoBehaviour
{
public uint Hash(NativeArray<byte> data)
{
unsafe
{
// NativeArray 는 이미 GC 밖 메모리라 고정할 필요가 없습니다 ★
byte* p = (byte*)data.GetUnsafeReadOnlyPtr();
return NativeMethods.mylib_checksum(p, (nuint)data.Length);
}
}
// 일반 byte[] 라면 fixed 로
public uint Hash(byte[] data)
{
unsafe { fixed (byte* p = data)
return NativeMethods.mylib_checksum(p, (nuint)data.Length); }
}
}
NativeArray<T> 가 Rust 와 궁합이 가장 좋습니다 —
Unity 가 GC 힙 밖에 직접 할당하므로 핀도 복사도 필요 없고,
주소가 프레임 사이에 변하지 않습니다. Job System 과도 그대로 이어집니다.
byte[] 를 쓰면 매번 fixed 가 필요합니다.
이렇게도 쓸 수 있다 — 에디터가 DLL 을 안 놓아 줄 때 🟡 중급
문제와 우회 ★
★ 증상
① 게임을 플레이해서 P/Invoke 를 한 번 부릅니다
② Rust 를 고치고 cargo build 를 합니다
③ 파일 복사가 실패합니다 — "다른 프로세스가 사용 중입니다"
④ Unity 에디터를 껐다 켜야 합니다 ★ 하루에 수십 번
★ 원인
윈도우 에디터는 처음 P/Invoke 때 LoadLibrary 로 열고
에디터가 살아 있는 동안 절대 FreeLibrary 하지 않습니다.
(맥·리눅스는 상대적으로 덜하지만 같은 문제가 있습니다)
★ 우회 세 가지
A. 에디터에서만 수동 로드 — 가장 확실합니다 (아래 코드)
B. 파일 이름에 타임스탬프를 붙여 매번 새 파일로 만들기
mylib_1723.dll → DllImport 이름을 바꿔야 해서 번거롭습니다
C. 별도 프로세스로 분리 (k08) — 개발 중에만
에디터는 파이프로만 붙으니 잠기지 않습니다
실무에서는 A 를 씁니다.
에디터 전용 수동 로더
#if UNITY_EDITOR_WIN
using System;
using System.Runtime.InteropServices;
using UnityEditor;
[InitializeOnLoad]
public static class NativeReloader
{
[DllImport("kernel32")] static extern nint LoadLibrary(string path);
[DllImport("kernel32")] static extern bool FreeLibrary(nint h);
[DllImport("kernel32")] static extern nint GetProcAddress(nint h, string n);
static nint _lib;
public static unsafe delegate* unmanaged[Cdecl]<byte*, nuint, uint> Checksum;
static NativeReloader()
{
AssemblyReloadEvents.beforeAssemblyReload += Unload;
EditorApplication.playModeStateChanged += s =>
{
if (s == PlayModeStateChange.ExitingPlayMode) Unload();
if (s == PlayModeStateChange.EnteredPlayMode) Load();
};
Load();
}
static unsafe void Load()
{
if (_lib != 0) return;
// ★ 원본이 아니라 '복사본' 을 엽니다 — 원본은 안 잠깁니다
var src = "Assets/Plugins/x86_64/mylib.dll";
var tmp = System.IO.Path.GetTempFileName() + ".dll";
System.IO.File.Copy(src, tmp, true);
_lib = LoadLibrary(tmp);
Checksum = (delegate* unmanaged[Cdecl]<byte*, nuint, uint>)
GetProcAddress(_lib, "mylib_checksum");
}
static void Unload()
{
if (_lib == 0) return;
FreeLibrary(_lib);
_lib = 0;
}
}
#endif
cargo build 를 계속 돌릴 수 있습니다.
빌드 산출물(플레이어)에서는 이 코드가 #if UNITY_EDITOR_WIN 으로 통째로 빠지고
평범한 [DllImport] 가 쓰입니다.
실무에선 이렇게 — IL2CPP · 스트리핑 · 콜백 🔴 실무
IL2CPP 에서 챙길 것
★ ① 역방향 콜백에는 [MonoPInvokeCallback] 이 필수 (k07)
없으면 에디터(Mono)에서는 되는데 IL2CPP 빌드에서 죽습니다.
그리고 반드시 static 메서드여야 합니다.
★ ② 코드 스트리핑 — link.xml 로 지킵니다
Managed Stripping Level 이 높으면
"쓰이지 않는 것처럼 보이는" 콜백 메서드가 지워집니다.
Assets/link.xml
<linker>
<assembly fullname="Assembly-CSharp">
<type fullname="NativeBridge" preserve="all"/>
</assembly>
</linker>
★ ③ 마샬링 제한
IL2CPP 는 일부 복잡한 마샬링을 지원하지 않습니다.
→ blittable 만 쓰면 애초에 문제가 없습니다 (k04) ★
★ ④ 빌드 시간
IL2CPP 빌드는 오래 걸립니다. 네이티브 쪽 문제를
Mono 에디터에서 최대한 잡고 넘어가세요.
★ ⑤ 안드로이드 16KB 페이지 (최신 기기)
NDK r27 이상으로 빌드하고 정렬 옵션을 확인하세요.
cargo-ndk 최신 버전이 처리해 줍니다.
Burst 와 비교 ★
| Burst (C#) | Rust 플러그인 | |
|---|---|---|
| 속도 | 거의 동급 | 동급 |
| 경계 비용 | 0 | 수 ns |
| 디버깅 | Unity 안에서 | 따로 |
| 쓸 수 있는 문법 | 제한적 | Rust 전부 |
| 외부 라이브러리 | 거의 불가 | crates.io 전부 |
| Job System | 완벽 통합 | 수동 |
| 빌드 | Unity 가 처리 | 직접 관리 |
★ Burst 를 쓰세요
· 프레임마다 도는 벡터·행렬 계산
· NativeArray 위의 단순 반복문
· Job System 으로 병렬화할 작업
→ 경계가 없어서 항상 유리합니다
★ Rust 플러그인을 쓰세요
· 이미 있는 크레이트가 필요할 때 ★
(image · rapier · serde · regex · zstd · rustls)
· 서버와 클라이언트가 '같은 코드' 를 써야 할 때 ★★
(결정론적 시뮬레이션 · 리플레이 검증 · 맵 생성)
· 복잡한 자료구조 · 문자열 처리 · 파일 포맷 파싱
· Burst 가 지원하지 않는 문법이 필요할 때
★ 가장 흔한 조합
게임 로직은 Burst, '공유 규칙 엔진' 은 Rust.
서버(ASP.NET · k12)와 클라이언트(Unity)가
같은 .dll 을 쓰면 규칙 불일치 버그가 사라집니다 ★
한 단계 더 — CI 로 다섯 플랫폼 자동화 🧬 전문가
GitHub Actions 골격
name: unity-native
on: [push]
jobs:
build:
strategy:
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
out: Assets/Plugins/x86_64/mylib.dll
src: target/x86_64-pc-windows-msvc/release/mylib.dll
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
out: Assets/Plugins/Linux/x86_64/libmylib.so
src: target/x86_64-unknown-linux-gnu/release/libmylib.so
- os: macos-latest
target: aarch64-apple-darwin
out: Assets/Plugins/macOS/mylib.bundle
src: target/aarch64-apple-darwin/release/libmylib.dylib
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: '${{ matrix.target }}' }
- run: cargo build --release --target ${{ matrix.target }}
working-directory: native
- run: |
mkdir -p "$(dirname unity/${{ matrix.out }})"
cp native/${{ matrix.src }} unity/${{ matrix.out }}
shell: bash
- uses: actions/upload-artifact@v4
with:
name: plugin-${{ matrix.target }}
path: unity/Assets/Plugins/**
플러그인을 커밋할까 말까
★ 방법 A — 바이너리를 리포에 커밋
장점 · Unity 를 여는 사람이 Rust 툴체인 없이 바로 실행
· 아티스트·기획자가 많은 팀에 필수적입니다 ★
단점 · 리포가 커집니다 (Git LFS 를 쓰세요)
· 어느 커밋에서 만든 바이너리인지 헷갈립니다
→ 버전 파일을 함께 커밋하세요
Assets/Plugins/mylib.version.txt (git rev-parse HEAD)
그리고 런타임에 mylib_version() 과 대조합니다 ★
★ 방법 B — CI 아티팩트로 받아서 넣기
장점 · 리포가 깨끗합니다
단점 · 셋업 스텝이 하나 늘고, 오프라인 작업이 어렵습니다
→ 프로그래머만 있는 작은 팀에 맞습니다
★ 방법 C — UPM 패키지로 배포 (규모가 커지면)
네이티브 파일을 담은 사설 UPM 패키지를 만들고
manifest.json 으로 버전을 고정합니다.
여러 게임 프로젝트가 같은 코어를 쓸 때 가장 깔끔합니다 ★
★ 어느 쪽이든 반드시 하세요
게임 시작 시 mylib_version() 을 찍고,
기대값과 다르면 큰 경고를 띄우세요.
'옛날 DLL 로 돌고 있었다' 는 사고가 정말 자주 납니다.
.dll, 맥은 유니버설 .bundle, 안드로이드는 cargo-ndk,
iOS 는 staticlib + "__Internal" 입니다.
Inspector 플랫폼 체크와 .meta 커밋을 빠뜨리지 말고,
에디터 잠금은 복사본을 여는 수동 로더로 우회합니다.
프레임 계산은 Burst, 크레이트가 필요하거나 서버와 코드를 공유해야 하면 Rust 입니다.Godot · Avalonia · MAUI — 앱 프레임워크에 붙이기
Unity 말고도 C# 앱은 많습니다. Godot 4 는 .NET 을 그대로 쓰고,
Avalonia·MAUI 는 데스크톱·모바일 UI 를 담당합니다.
공통 과제는 하나입니다 — 실행 환경마다 다른 위치에 있는 네이티브 파일을 어떻게 찾게 할 것인가.
NativeLibrary.SetDllImportResolver 가 그 만능 열쇠입니다.
0 을 돌려주면 기본 규칙으로 넘어갑니다.크로스 플랫폼 로더 ★
using System.Reflection;
using System.Runtime.InteropServices;
internal static class NativeLoader
{
static int _done;
// 어느 진입점에서든 한 번만 부르면 됩니다
public static void Install()
{
if (Interlocked.Exchange(ref _done, 1) != 0) return;
NativeLibrary.SetDllImportResolver(
typeof(NativeLoader).Assembly, Resolve);
}
static nint Resolve(string name, Assembly asm, DllImportSearchPath? path)
{
if (name != "mylib") return 0; // ★ 모르면 0 — 기본 규칙으로
foreach (var candidate in Candidates())
if (File.Exists(candidate) &&
NativeLibrary.TryLoad(candidate, out var h))
return h;
return 0;
}
static IEnumerable<string> Candidates()
{
string bas = AppContext.BaseDirectory;
string file = OperatingSystem.IsWindows() ? "mylib.dll"
: OperatingSystem.IsMacOS() ? "libmylib.dylib"
: "libmylib.so";
yield return Path.Combine(bas, file); // 옆에
yield return Path.Combine(bas, "runtimes", Rid(), "native", file);
yield return Path.Combine(bas, "native", Rid(), file);
yield return Path.Combine(AppContext.BaseDirectory, "..",
"Frameworks", file); // 맥 앱 번들
}
static string Rid()
{
string os = OperatingSystem.IsWindows() ? "win"
: OperatingSystem.IsMacOS() ? "osx" : "linux";
string arch = RuntimeInformation.ProcessArchitecture switch
{
Architecture.X64 => "x64",
Architecture.Arm64 => "arm64",
Architecture.X86 => "x86",
_ => "x64",
};
return $"{os}-{arch}";
}
}
언제 필요한가
| 상황 | 리졸버가 필요한가 |
|---|---|
| 단일 exe · 파일이 옆에 | 불필요 |
NuGet runtimes/ | 보통 불필요(k11) |
| 단일 파일 게시 | 대체로 필요 ★ |
| Godot 익스포트 | 필요할 때가 많음 |
맥 .app 번들 | 필요 |
| 플러그인 폴더 구조 | 필요 |
| 테스트 실행기 | 가끔 |
// 라이브러리를 만드는 쪽이라면 모듈 초기화자로 자동 설치합니다 ★
using System.Runtime.CompilerServices;
internal static class ModuleInit
{
[ModuleInitializer] // ★ 어셈블리가 로드될 때 자동 실행
internal static void Init() => NativeLoader.Install();
}
// → 쓰는 사람은 아무것도 안 해도 됩니다.
// NuGet 패키지를 만든다면 이 방식이 가장 친절합니다 (k11)
DllNotFoundException("mylib 를 찾지 못했습니다. 시도한 경로: …") 형태가 좋습니다.
이렇게도 쓸 수 있다 — Godot 4 에 붙이기 🟡 중급
C# 스크립트에서 부르기
using Godot;
using System.Runtime.InteropServices;
public partial class WorldGen : Node
{
[LibraryImport("mylib")] // ★ Godot 은 .NET 이라 이게 됩니다
private static unsafe partial int mylib_generate(
ulong seed, byte* dst, nuint cap, nuint* written);
public override void _Ready()
{
NativeLoader.Install(); // 익스포트 대비 (위 코드)
var buf = new byte[256 * 256];
unsafe
{
fixed (byte* p = buf)
{
nuint n;
int rc = mylib_generate(12345, p, (nuint)buf.Length, &n);
if (rc != 0) { GD.PushError($"generate 실패: {rc}"); return; }
GD.Print($"{n} 바이트 생성");
}
}
}
}
★ Godot 익스포트에서 빠뜨리기 쉬운 것
① 네이티브 파일을 프로젝트 폴더 안에 두고
Export 설정의 "Resources → Filters to export non-resource files"
에 *.dll, *.so, *.dylib 를 추가합니다 ★
② 또는 빌드 후 스크립트로 실행 파일 옆에 복사합니다
③ res:// 경로는 pck 안이라 dlopen 이 못 엽니다 —
반드시 실제 파일 시스템 경로(user:// 또는 exe 옆)여야 합니다 ★
④ 맥 .app 번들은 Contents/Frameworks 아래에 두고
리졸버로 그 경로를 잡아 줍니다
gdext 와 비교 ★
| C# + Rust dll | gdext (순수 Rust) | |
|---|---|---|
| Godot 노드 작성 | C# 으로 | Rust 로 |
| 에디터 통합 | C# 쪽만 | Rust 클래스도 노출 |
| 기존 C# 자산 | 그대로 | 못 씀 |
| 학습 곡선 | 낮음 | 중간 |
| 웹 익스포트 | 제한적 | 제한적 |
| 적합한 경우 | 계산 코어만 Rust | 게임 전체 Rust |
★ 고르는 기준
· 이미 C# 으로 게임을 만들고 있다 → 이 탭의 방식(C# + Rust dll)
· 게임 전체를 Rust 로 쓰고 싶다 → gdext (GDExtension)
· 둘을 섞을 수도 있습니다 — 실제로 흔합니다
★ 이 탭 방식의 장점
같은 mylib.dll 을 Unity · Godot · 서버가 함께 씁니다.
엔진을 바꿔도 코어는 그대로입니다 ★
★ gdext 를 볼 때
노드 · 시그널 · 에디터 인스펙터까지 Rust 로 정의하고 싶을 때.
이 경우 C# 은 아예 안 씁니다.
실무에선 이렇게 — Avalonia · MAUI 데스크톱·모바일 🔴 실무
Avalonia — 데스크톱 앱의 코어
// ViewModel 에서 Rust 코어를 비동기로 부릅니다 (k07)
public sealed partial class EditorViewModel : ObservableObject
{
[ObservableProperty] private int _progress;
[ObservableProperty] private Bitmap? _preview;
public async Task ApplyFilterAsync(CancellationToken ct)
{
var progress = new Progress<int>(p => Progress = p);
// ★ Progress<T> 는 만들어진 스레드(UI)로 되돌려 줍니다
byte[] output = await Task.Run(() =>
MyLib.Image.Resize(_source, 1920, 1080, progress), ct);
// ★ Task.Run 으로 UI 스레드에서 벗어납니다
Preview = BitmapFrom(output);
}
}
<!-- Avalonia 앱 csproj — 플랫폼별 네이티브를 함께 게시 -->
<ItemGroup>
<None Include="runtimes/**/*"
CopyToOutputDirectory="PreserveNewest"
Link="runtimes/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<!-- 게시할 때 RID 를 지정하면 해당 것만 들어갑니다 -->
<!-- dotnet publish -r win-x64 -c Release --self-contained -->
await 없이 부르면 창이 그대로 얼어붙습니다.
Rust 가 아무리 빨라도 1초짜리 작업은 1초 동안 UI 를 막습니다.
Task.Run + IProgress<T> 조합이 기본형입니다.
MAUI — 안드로이드 · iOS
<!-- 안드로이드: ABI 별 폴더에 .so 를 넣고 항목으로 등록 -->
<ItemGroup Condition="$(TargetFramework.Contains('-android'))">
<AndroidNativeLibrary Include="native/android/arm64-v8a/libmylib.so">
<Abi>arm64-v8a</Abi>
</AndroidNativeLibrary>
<AndroidNativeLibrary Include="native/android/x86_64/libmylib.so">
<Abi>x86_64</Abi> <!-- 에뮬레이터용 -->
</AndroidNativeLibrary>
</ItemGroup>
<!-- iOS: 정적 라이브러리를 링크합니다 -->
<ItemGroup Condition="$(TargetFramework.Contains('-ios'))">
<NativeReference Include="native/ios/libmylib.a">
<Kind>Static</Kind>
<ForceLoad>True</ForceLoad>
</NativeReference>
</ItemGroup>
// iOS 는 정적 링크라 이름이 "__Internal" 이어야 합니다 (k09 와 같은 이유)
internal static partial class Native
{
#if IOS || MACCATALYST
const string Lib = "__Internal";
#else
const string Lib = "mylib";
#endif
[LibraryImport(Lib, EntryPoint = "mylib_checksum")]
internal static unsafe partial uint Checksum(byte* p, nuint len);
}
# MAUI 용 빌드 타깃
rustup target add aarch64-linux-android x86_64-linux-android \
aarch64-apple-ios aarch64-apple-ios-sim
cargo ndk -t arm64-v8a -t x86_64 -o native/android build --release
cargo build --release --target aarch64-apple-ios
한 단계 더 — 여러 앱이 같은 코어를 공유하기 🧬 전문가
공유 구조
실제로 잘 도는 구성 (k12 의 예제가 이 모양입니다)
native/ Rust 코어 한 벌
└─ src/{core,ffi}.rs
src/MyLib/ C# 래퍼 (NuGet 으로 배포 · k11)
├─ NativeMethods.g.cs
└─ Image.cs · Decoder.cs
apps/
├─ Api/ ASP.NET Core Minimal API (서버)
├─ Desktop/ Avalonia (데스크톱)
├─ Mobile/ MAUI (모바일)
└─ unity/ Unity (게임 · k09)
★ 규칙
① 앱은 MyLib 만 참조합니다 (NativeMethods 를 직접 안 씀)
② Rust 시그니처 변경은 MyLib 의 마이너 버전을 올립니다
③ 각 앱은 MyLib 버전을 고정합니다 (동시에 안 깨지도록)
④ Unity 만 예외 — NuGet 대신 UPM 이나 직접 복사 (k09)
★ 얻는 것
'규칙' 이 한 곳에만 있습니다.
서버가 검증하는 로직과 클라이언트가 예측하는 로직이
같은 기계어라 절대 어긋나지 않습니다 ★
버전 불일치를 잡는 장치
// Rust 쪽 — ABI 버전을 명시적으로 노출합니다
pub const ABI_VERSION: u32 = 3; // 경계가 바뀔 때만 올립니다
#[unsafe(no_mangle)]
pub extern "C" fn mylib_abi_version() -> u32 { ABI_VERSION }
#[unsafe(no_mangle)]
pub extern "C" fn mylib_build_info(buf: *mut u8, cap: usize) -> usize {
let s = format!("{} {} {}",
env!("CARGO_PKG_VERSION"),
option_env!("GIT_SHA").unwrap_or("unknown"),
std::env::consts::ARCH);
// … buf 에 복사 …
s.len()
}
// C# 쪽 — 로드 직후 한 번 확인합니다
internal static class AbiGuard
{
const uint Expected = 3;
public static void Verify()
{
uint actual = NativeMethods.mylib_abi_version();
if (actual != Expected)
throw new InvalidOperationException(
$"네이티브 ABI 불일치: 기대 {Expected}, 실제 {actual}. " +
$"오래된 mylib 파일이 로드됐을 수 있습니다.");
}
}
// ★ 이 검사 하나가 막아 주는 사고
// · 옛날 DLL 이 시스템 폴더에 남아 있어 그게 먼저 로드됨
// · 배포 스크립트가 일부 파일만 갱신함
// · 개발자 PC 에만 새 버전이 있음
// 전부 '값이 이상하다' 로 나타나서 원인 찾기가 매우 어렵습니다
NativeLibrary.SetDllImportResolver + [ModuleInitializer] 조합이 만능 답입니다.
Godot 은 익스포트 필터를, MAUI 는 AndroidNativeLibrary/NativeReference 를,
Avalonia 는 Task.Run + IProgress 를 챙기세요.
그리고 ABI 버전 검사를 로드 직후에 반드시 넣으세요.배포 — NuGet 한 줄로 끝나게 만들기
쓰는 사람이 dotnet add package MyLib 한 줄만 치면
윈도우·리눅스·맥·ARM 어디서든 그냥 도는 것이 목표입니다.
비결은 runtimes/<rid>/native/ 라는 NuGet 규약 하나이고,
나머지는 여러 타깃을 빌드하는 CI 의 문제입니다.
패키징 csproj ★
<!-- src/MyLib/MyLib.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>latest</LangVersion>
<PackageId>MyLib</PackageId>
<Version>1.0.0</Version>
<Description>Rust 코어를 쓰는 이미지 처리 라이브러리</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<!-- CI 가 미리 채워 둔 폴더를 그대로 담습니다 -->
<ItemGroup>
<None Include="../../artifacts/win-x64/mylib.dll"
Pack="true" PackagePath="runtimes/win-x64/native/" />
<None Include="../../artifacts/win-arm64/mylib.dll"
Pack="true" PackagePath="runtimes/win-arm64/native/" />
<None Include="../../artifacts/linux-x64/libmylib.so"
Pack="true" PackagePath="runtimes/linux-x64/native/" />
<None Include="../../artifacts/linux-arm64/libmylib.so"
Pack="true" PackagePath="runtimes/linux-arm64/native/" />
<None Include="../../artifacts/osx-arm64/libmylib.dylib"
Pack="true" PackagePath="runtimes/osx-arm64/native/" />
</ItemGroup>
</Project>
dotnet pack -c Release
unzip -l bin/Release/MyLib.1.0.0.nupkg | grep -E 'lib/|runtimes/'
# ★ 반드시 눈으로 확인하세요. 경로가 한 글자만 틀려도 조용히 빠집니다
여러 타깃 빌드하기
| 방법 | 장점 | 주의 |
|---|---|---|
| OS 별 러너 | 가장 확실 | CI 시간·비용 |
cross | 도커로 리눅스 전부 | 도커 필요 |
cargo-zigbuild | glibc 버전 지정 ★ | zig 설치 |
| musl 타깃 | 알파인·완전 정적 | 별도 RID |
| 맥 유니버설 | 파일 하나 | 맥 러너 필요 |
# ── cargo-zigbuild — 가장 편한 리눅스 크로스 빌드 ★
cargo install cargo-zigbuild
pip install ziglang # 또는 zig 를 직접 설치
rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
# glibc 버전을 낮춰 잡으면 오래된 배포판에서도 돕니다 ★
cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.17
cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.17
# ── musl (알파인 · 완전 정적)
rustup target add x86_64-unknown-linux-musl
cargo zigbuild --release --target x86_64-unknown-linux-musl
# ── cross (도커 기반)
cargo install cross
cross build --release --target aarch64-unknown-linux-gnu
# 확인 — 어떤 glibc 를 요구하는지
objdump -T libmylib.so | grep GLIBC_ | sort -u | tail -3
GLIBC_2.38 not found.
빌드 머신의 glibc 가 실행 머신보다 새로우면 이렇게 됩니다.
오래된 이미지(예: ubuntu 20.04)에서 빌드하거나 cargo-zigbuild 로 버전을 명시하세요.
도커 컨테이너 배포라면 musl 정적 링크가 가장 속 편합니다.
이렇게도 쓸 수 있다 — NativeAOT 와 정적 링크 🟡 중급
Rust 를 실행 파일 안에 넣기 ★
<!-- 앱 csproj — NativeAOT + Rust staticlib 정적 링크 -->
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!-- ① 이 이름의 P/Invoke 는 동적 로드하지 말고 직접 링크하라 -->
<DirectPInvoke Include="mylib" />
<!-- ② 링커에 줄 정적 라이브러리 -->
<NativeLibrary Include="../native/target/release/libmylib.a"
Condition="!$([MSBuild]::IsOSPlatform('Windows'))" />
<NativeLibrary Include="../native/target/release/mylib.lib"
Condition="$([MSBuild]::IsOSPlatform('Windows'))" />
</ItemGroup>
# native/Cargo.toml — staticlib 도 함께 냅니다
[lib]
crate-type = ["cdylib", "staticlib"]
cd native && cargo build --release
cd ../app && dotnet publish -r linux-x64 -c Release
ls -la bin/Release/net10.0/linux-x64/publish/
# app ← ★ 파일 하나. .dll 도 런타임도 필요 없습니다
ldd bin/Release/net10.0/linux-x64/publish/app
# libc · libm · libstdc++ 정도만
트리밍 · 단일 파일
<!-- NativeAOT 가 부담스러우면 트리밍 + 단일 파일 -->
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<SelfContained>true</SelfContained>
<!-- ★ 네이티브 파일도 exe 안에 넣고 실행 시 임시 폴더로 풉니다 -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
★ 트리밍과 FFI 의 궁합
· [LibraryImport] 는 트리밍에 안전합니다 (생성 코드가 정적) ★
· [DllImport] 도 대체로 안전합니다
· 위험한 것은 리플렉션입니다 —
Marshal.GetDelegateForFunctionPointer 로 만든 델리게이트 타입,
Activator.CreateInstance 로 만드는 구조체 등
· 경계에 blittable 만 쓰면(k04) 트리밍 경고가 거의 안 납니다 ★
★ 단일 파일의 함정
IncludeNativeLibrariesForSelfExtract 를 켜면
실행할 때마다 임시 폴더에 네이티브 파일을 풉니다.
· 첫 실행이 느려집니다 (수십 ms)
· 임시 폴더 쓰기 권한이 없는 환경에서 실패합니다
· 백신 소프트웨어가 막는 경우가 있습니다 ★
→ 서버라면 NativeAOT 정적 링크가 훨씬 낫습니다
★ 확인
dotnet publish -r win-x64 -c Release 2>&1 | grep -i "IL2"
(IL2xxx 경고가 트리밍 관련입니다)
실무에선 이렇게 — CI 파이프라인 전체 🔴 실무
빌드 → 모으기 → 패키징
name: release
on:
push:
tags: ['v*']
jobs:
native:
strategy:
matrix:
include:
- { os: windows-latest, rid: win-x64, target: x86_64-pc-windows-msvc, file: mylib.dll }
- { os: windows-latest, rid: win-arm64, target: aarch64-pc-windows-msvc, file: mylib.dll }
- { os: ubuntu-latest, rid: linux-x64, target: x86_64-unknown-linux-gnu, file: libmylib.so }
- { os: ubuntu-latest, rid: linux-arm64, target: aarch64-unknown-linux-gnu, file: libmylib.so }
- { os: macos-latest, rid: osx-arm64, target: aarch64-apple-darwin, file: libmylib.dylib }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: '${{ matrix.target }}' }
- run: cargo install cargo-zigbuild
if: runner.os == 'Linux'
- name: build
working-directory: native
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
cargo zigbuild --release --target ${{ matrix.target }}.2.17
else
cargo build --release --target ${{ matrix.target }}
fi
shell: bash
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.rid }}
path: native/target/${{ matrix.target }}/release/${{ matrix.file }}
pack:
needs: native
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with: { path: artifacts } # artifacts/<rid>/<file>
- run: dotnet pack src/MyLib -c Release -o out
- run: dotnet nuget push out/*.nupkg -k ${{ secrets.NUGET_KEY }} \
-s https://api.nuget.org/v3/index.json
배포 전 체크리스트 ★
① 패키지 안을 눈으로 봤는가
unzip -l *.nupkg | grep runtimes
경로 오타 하나로 해당 플랫폼이 통째로 빠집니다
② 각 RID 에서 실제로 돌려 봤는가
도커로 리눅스, VM 으로 ARM 을 확인하세요.
최소한 mylib_abi_version() 을 부르는 스모크 테스트를 (k10)
③ 심볼을 남겼는가
.snupkg 로 관리 코드를, Rust 쪽은 분리한 심볼 파일을 보관 (🛠️ b10)
크래시 리포트를 못 읽으면 대응이 불가능합니다
④ 라이선스를 모두 담았는가
Rust 의존성 라이선스도 포함해야 합니다 ★
cargo install cargo-about && cargo about generate about.hbs
⑤ 재현 가능한 빌드인가
Cargo.lock 을 커밋하고, 툴체인을 고정하세요
rust-toolchain.toml → channel = "1.89.0"
⑥ 크기를 확인했는가
[profile.release] opt-level = "z" · lto = true · strip = "symbols"
보통 수백 KB ~ 수 MB. 이미지·정규식 크레이트가 크게 만듭니다
⑦ 버전 정책을 정했는가
ABI 가 바뀌면 메이저를 올립니다 (k10 의 ABI_VERSION 과 함께)
한 단계 더 — 런타임 패키지로 쪼개기 🧬 전문가
패키지가 너무 커질 때
문제 — RID 가 여덟 개면 nupkg 하나가 수십 MB 가 됩니다.
모두가 모든 플랫폼을 받게 되죠.
★ 해법 — SkiaSharp · SQLitePCLRaw 가 쓰는 방식
MyLib (관리 코드만 · 아주 작음)
MyLib.runtime.win-x64 (네이티브 하나)
MyLib.runtime.linux-x64
MyLib.runtime.osx-arm64
…
MyLib.nuspec 에서 runtime.json 으로 매핑을 선언하면
복원할 때 필요한 런타임 패키지만 자동으로 당겨옵니다.
runtime.json 예시
{
"runtimes": {
"win-x64": { "MyLib": { "MyLib.runtime.win-x64": "1.0.0" } },
"linux-x64": { "MyLib": { "MyLib.runtime.linux-x64": "1.0.0" } }
}
}
★ 언제 쪼개나
· 네이티브가 5MB 를 넘고 RID 가 5개 이상일 때
· 회사 내부 피드에서 대역폭이 문제일 때
그 아래라면 하나로 묶는 편이 훨씬 관리하기 쉽습니다 ★
도커와 컨테이너
# 멀티 스테이지 — Rust 와 .NET 을 한 번에
FROM rust:1-bookworm AS native
WORKDIR /src
COPY native ./native
RUN cd native && cargo build --release
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
COPY --from=native /src/native/target/release/libmylib.so \
/src/artifacts/linux-x64/
RUN dotnet publish apps/Api -c Release -o /out \
-r linux-x64 --self-contained false
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /out .
ENTRYPOINT ["dotnet", "Api.dll"]
★ 주의
· 빌드 이미지와 실행 이미지의 배포판을 맞추세요
(bookworm 에서 빌드하고 alpine 에서 실행 → glibc 없음 ★)
· alpine 을 쓸 거면 musl 타깃으로 빌드해야 합니다
x86_64-unknown-linux-musl + 실행 이미지 aspnet:10.0-alpine
· NativeAOT 로 만들면 런타임 이미지 자체가 필요 없습니다
FROM gcr.io/distroless/cc 또는 scratch ★
runtimes/<rid>/native/ 규약만 지키면 NuGet 이 알아서 골라 줍니다.
여러 타깃은 OS 별 CI 러너 + cargo-zigbuild 로 만들고,
glibc 버전을 낮게 잡아 배포 범위를 넓히세요.
서버·CLI 라면 NativeAOT + DirectPInvoke + staticlib 로 파일 하나가 되는 것이 가장 좋습니다.
패키징 후에는 반드시 unzip -l 로 눈으로 확인하세요.실전 — 이미지 리사이즈 모듈을 만들어 끝까지
지금까지의 조각을 하나로 잇습니다. Rust 의 image 크레이트로 리사이즈 코어를 만들고,
csbindgen 으로 감싸 ASP.NET Core Minimal API 와 Unity 에서 같이 씁니다.
BenchmarkDotNet 으로 오버헤드를 재고, NuGet 으로 포장하는 데까지 갑니다.
finally.Rust — 코어와 경계
# native/Cargo.toml
[package]
name = "mylib"
edition = "2024"
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[dependencies]
image = { version = "0.25", default-features = false,
features = ["jpeg", "png", "webp"] }
[build-dependencies]
csbindgen = "1"
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
// native/src/core.rs — 순수 Rust. unsafe 없음, cargo test 가 그대로 됩니다
use image::imageops::FilterType;
pub fn resize_to_webp(input: &[u8], w: u32, h: u32, q: f32)
-> Result<Vec<u8>, image::ImageError>
{
let img = image::load_from_memory(input)?;
let out = img.resize(w, h, FilterType::Lanczos3); // 비율 유지
let mut buf = Vec::with_capacity(input.len() / 4);
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
out.write_with_encoder(enc)?;
let _ = q;
Ok(buf)
}
#[cfg(test)]
mod tests {
#[test]
fn resizes() {
let png = include_bytes!("../tests/fixtures/sample.png");
let out = super::resize_to_webp(png, 100, 100, 0.8).unwrap();
assert!(!out.is_empty());
}
}
// native/src/ffi.rs — 경계만. 얇게.
use crate::core;
use std::panic::{catch_unwind, AssertUnwindSafe};
#[repr(C)]
pub struct Buf { pub ptr: *mut u8, pub len: usize, pub cap: usize }
impl Buf {
fn empty() -> Self { Buf { ptr: std::ptr::null_mut(), len: 0, cap: 0 } }
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_resize_webp(
src: *const u8, src_len: usize,
w: u32, h: u32, quality: f32,
out: *mut Buf,
) -> i32 {
let r = catch_unwind(AssertUnwindSafe(|| {
if src.is_null() || out.is_null() { return -1; }
let input = unsafe { std::slice::from_raw_parts(src, src_len) };
match core::resize_to_webp(input, w, h, quality) {
Ok(mut v) => {
let b = Buf { ptr: v.as_mut_ptr(), len: v.len(), cap: v.capacity() };
std::mem::forget(v);
unsafe { *out = b; }
0
}
Err(e) => { crate::ffi::set_last_error(e.to_string()); -5 }
}
}));
match r { Ok(c) => c, Err(_) => -99 }
}
#[unsafe(no_mangle)]
pub extern "C" fn mylib_buf_free(b: Buf) {
if b.ptr.is_null() { return; }
unsafe { drop(Vec::from_raw_parts(b.ptr, b.len, b.cap)); }
}
C# — 래퍼와 Minimal API
// src/MyLib/ImageOps.cs — 사람이 쓰는 얼굴
namespace MyLib;
public static class ImageOps
{
/// <summary>이미지를 리사이즈해 WebP 로 인코딩합니다.</summary>
/// <returns>호출자가 소유하는 새 배열.</returns>
public static byte[] ResizeToWebp(ReadOnlySpan<byte> src,
int width, int height,
float quality = 0.8f)
{
NativeMethods.Buf buf = default;
unsafe
{
fixed (byte* p = src)
{
Check.Throw(NativeMethods.mylib_resize_webp(
p, (nuint)src.Length,
(uint)width, (uint)height, quality, &buf));
}
try
{
return new ReadOnlySpan<byte>(buf.ptr, (int)buf.len).ToArray();
}
finally { NativeMethods.mylib_buf_free(buf); } // ★
}
}
/// 복사 없이 바로 스트림으로 흘려보내는 버전 ★
public static async Task WriteWebpAsync(ReadOnlyMemory<byte> src,
int w, int h, Stream dst, CancellationToken ct = default)
{
NativeMethods.Buf buf = default;
using var pin = src.Pin();
unsafe
{
Check.Throw(NativeMethods.mylib_resize_webp(
(byte*)pin.Pointer, (nuint)src.Length,
(uint)w, (uint)h, 0.8f, &buf));
}
try
{
// Rust 힙을 그대로 가리키는 Memory 를 만들어 씁니다
using var mm = new UnmanagedMemoryManager(buf);
await dst.WriteAsync(mm.Memory, ct);
}
finally { NativeMethods.mylib_buf_free(buf); }
}
}
// apps/Api/Program.cs — ASP.NET Core Minimal API
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();
// 시작할 때 ABI 를 확인합니다 (k10)
AbiGuard.Verify();
app.MapPost("/resize", async (HttpRequest req, int w, int h) =>
{
// 요청 본문을 풀에서 빌린 버퍼로 읽습니다 — 할당 최소화 (k05)
var pool = ArrayPool<byte>.Shared;
byte[] rented = pool.Rent(8 * 1024 * 1024);
try
{
int n = 0, read;
while ((read = await req.Body.ReadAsync(
rented.AsMemory(n))) > 0) n += read;
var webp = ImageOps.ResizeToWebp(rented.AsSpan(0, n), w, h);
return Results.File(webp, "image/webp");
}
catch (MyLibException e)
{
return Results.Problem(e.Message, statusCode: 400);
}
finally { pool.Return(rented); }
});
app.Run();
이렇게도 쓸 수 있다 — 같은 라이브러리를 Unity 에서 🟡 중급
Unity 에서 텍스처로
using UnityEngine;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
public sealed class ThumbnailLoader : MonoBehaviour
{
public Texture2D LoadThumbnail(byte[] jpeg, int w, int h)
{
NativeMethods.Buf buf = default;
unsafe
{
fixed (byte* p = jpeg)
{
int rc = NativeMethods.mylib_resize_webp(
p, (nuint)jpeg.Length, (uint)w, (uint)h, 0.8f, &buf);
if (rc != 0) { Debug.LogError($"resize 실패 {rc}"); return null; }
}
try
{
// Rust 힙을 NativeArray 로 감싸 복사 없이 Unity 에 전달 ★
var na = NativeArrayUnsafeUtility
.ConvertExistingDataToNativeArray<byte>(
buf.ptr, (int)buf.len, Allocator.None);
#if ENABLE_UNITY_COLLECTIONS_CHECKS
NativeArrayUnsafeUtility.SetAtomicSafetyHandle(
ref na, AtomicSafetyHandle.GetTempMemoryHandle());
#endif
var tex = new Texture2D(2, 2);
tex.LoadImage(na.ToArray()); // WebP 는 Unity 가 못 읽으므로
return tex; // 실제로는 RGBA 로 받는 편이 낫습니다
}
finally { NativeMethods.mylib_buf_free(buf); }
}
}
}
Texture2D.LoadImage 는 다시 디코딩합니다.
Rust 가 RGBA8 원시 픽셀을 그대로 돌려주고
tex.LoadRawTextureData(ptr, size) 로 넣는 편이
디코딩 한 번을 통째로 아낍니다. 경계 설계가 성능을 정하는 전형적인 예입니다.
테스트를 양쪽에 둡니다
// tests/MyLib.Tests/ImageOpsTests.cs
public class ImageOpsTests
{
static readonly byte[] Sample = File.ReadAllBytes("fixtures/sample.png");
[Fact]
public void Resize_ProducesWebp()
{
var outBytes = ImageOps.ResizeToWebp(Sample, 100, 100);
Assert.True(outBytes.Length > 0);
Assert.Equal(0x52, outBytes[0]); // "RIFF"
}
[Fact]
public void InvalidInput_ThrowsNotPanic()
{
var garbage = new byte[] { 1, 2, 3, 4 };
var ex = Assert.Throws<MyLibException>(
() => ImageOps.ResizeToWebp(garbage, 10, 10));
Assert.NotEqual(MyLibCode.Panic, ex.Code); // ★ 패닉이 아니라 에러
}
[Fact]
public void NoLeak_Under10kIterations()
{
long before = GC.GetTotalMemory(true);
for (int i = 0; i < 10_000; i++)
ImageOps.ResizeToWebp(Sample, 64, 64);
GC.Collect(); GC.WaitForPendingFinalizers();
long after = GC.GetTotalMemory(true);
// ★ 관리 힙은 안 늘어야 하고,
// 네이티브 누수는 Process.WorkingSet64 로 따로 확인합니다
Assert.True(after - before < 5 * 1024 * 1024);
}
}
실무에선 이렇게 — BenchmarkDotNet 으로 재기 🔴 실무
벤치마크 코드
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser] // ★ 할당량도 함께 보여 줍니다
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class ResizeBench
{
byte[] _jpeg = null!;
[GlobalSetup]
public void Setup() => _jpeg = File.ReadAllBytes("fixtures/photo.jpg");
[Benchmark(Baseline = true)]
public int Managed_ImageSharp()
{
using var img = SixLabors.ImageSharp.Image.Load(_jpeg);
img.Mutate(x => x.Resize(400, 300));
using var ms = new MemoryStream();
img.SaveAsWebp(ms);
return (int)ms.Length;
}
[Benchmark]
public int Rust_Native() => ImageOps.ResizeToWebp(_jpeg, 400, 300).Length;
// 경계 비용만 따로 재기 — 아무 일도 안 하는 함수 ★
[Benchmark]
public int Boundary_Only() => NativeMethods.mylib_noop(1);
}
BenchmarkRunner.Run<ResizeBench>();
dotnet run -c Release --project bench
# ★ 반드시 Release 로. Debug 로 재면 아무 의미가 없습니다
결과를 읽는 법
예시 출력 (환경마다 다릅니다 — 반드시 직접 재세요)
| Method | Mean | Ratio | Allocated |
|------------------- |------------:|------:|------------:|
| Managed_ImageSharp | 18.4 ms | 1.00 | 12.3 MB |
| Rust_Native | 6.1 ms | 0.33 | 380 KB |
| Boundary_Only | 2.3 ns | 0.00 | 0 B |
★ 읽는 순서
① Boundary_Only 를 먼저 봅니다 — 2.3ns
즉 경계 비용은 전체(6.1ms)의 0.00004% 입니다.
"P/Invoke 가 느려서" 라는 말은 여기서 근거를 잃습니다 ★
② Allocated 를 봅니다 — 12.3MB → 380KB
이 차이가 실제 서비스에서는 배수보다 더 중요합니다.
GC 압박이 사라져 p99 지연이 크게 안정됩니다 ★
③ Mean 배수(3배)는 '이 작업' 에 한정된 값입니다.
다른 이미지 · 다른 크기로도 재세요.
★ 함께 재야 할 것
· 동시 요청 100개일 때 (부하 테스트 · 🌐 h 탭)
· 메모리 사용량 추이 (dotnet-counters · 누수 확인)
· 콜드 스타트 (NativeAOT 면 크게 개선 · k11)
★ 하지 말아야 할 것
· Debug 빌드로 재기
· 한 번만 재고 결론 내기 (BenchmarkDotNet 이 반복을 관리합니다)
· 캐시가 데워진 상태와 아닌 상태를 섞기
한 단계 더 — 여기서 더 가려면 🧬 전문가
다음 단계 아이디어
① 병렬 처리 — rayon 을 Rust 쪽에 넣습니다
여러 장을 한 번에 넘기고 Rust 안에서 par_iter 로 처리
→ C# 의 스레드풀을 안 쓰고도 코어를 다 씁니다 (⚡ e06)
경계 호출 1번, 결과는 Buf 배열로
② 스트리밍 — 큰 파일을 통째로 안 올립니다
mylib_stream_new / _write / _finish 3단 API (k06 핸들)
→ 메모리 사용량이 입력 크기와 무관해집니다
③ GPU — wgpu 를 Rust 쪽에서 씁니다
같은 경계 뒤에서 CPU/GPU 를 골라 쓸 수 있습니다
④ 캐시 — Rust 안에 LRU 를 두고 핸들로 관리
같은 이미지 재요청이 많은 서비스에서 큰 효과
⑤ 관측 — mylib_stats(&out) 로 내부 지표를 노출하고
C# 에서 Meter/Counter 로 올립니다 (OpenTelemetry)
⑥ 안전성 — cargo fuzz 로 경계 함수를 두들깁니다 (🔬 d09)
이미지 디코더는 신뢰할 수 없는 입력을 다루는
대표적인 공격 표면입니다 ★
이 탭 전체 요약 카드 ★
| 주제 | 결론 한 줄 |
|---|---|
| 도구 선택 | csbindgen 이 기본값(k01·k03) |
| 첫 호출 | cdylib + no_mangle + LibraryImport(k02) |
| 타입 | blittable 만. bool 은 u8(k04) |
| 문자열 | UTF-8 (ptr, len)(k04) |
| 배열 | fixed 로 짧게 고정(k05) |
| 상태 | Box::into_raw + SafeHandle(k06) |
| 콜백 | UnmanagedCallersOnly · try 필수(k07) |
| 패닉 | catch_unwind 로 전부 가둠(k08) |
| Unity | DllImport · iOS 는 __Internal(k09) |
| 탐색 | SetDllImportResolver(k10) |
| 배포 | runtimes/<rid>/native/(k11) |
| 측정 | BenchmarkDotNet · Release(k12) |
core.rs(순수 로직) · ffi.rs(경계) · C# 래퍼 3층으로 나누면
ASP.NET Core 도 Unity 도 같은 .dll 을 씁니다.
버퍼는 ArrayPool + fixed 로 빌려 쓰고, 결과는 Buf + finally 해제 로 받습니다.
BenchmarkDotNet 으로 경계 비용을 따로 재 보면
수 나노초라는 것을 눈으로 확인할 수 있습니다 —
그다음부터는 Rust 코드 자체를 빠르게 만드는 일만 남습니다(⚡ e 탭).로그는 서비스가
자기 자신에 대해
말할 수 있는 유일한 방법입니다
러스트에는 표준 로깅이 없습니다. 표준 라이브러리가 주는 것은 println! 과 eprintln! 뿐이고,
그 둘로는 운영에서 하루도 못 버팁니다. 대신 생태계가 두 겹으로 나뉜 해법을 만들었습니다 —
라이브러리가 부르는 얇은 파사드(log)와 실행 파일이 딱 한 번 고르는 구현체(env_logger·fern 등),
그리고 비동기 시대에 맞춰 “점”이 아니라 “구간”을 기록하는 tracing 입니다.
이 탭은 그 전부를 왜 이렇게 됐는지부터 설명하고,
레이어 합성 · JSON 구조적 로깅 · 파일 로테이션 · 성능 · OpenTelemetry · 패닉 · 임베디드까지 이어 갑니다.
이 탭을 마치면 새 프로젝트의 init_log() 를 남의 코드를 베끼지 않고 직접 쓸 수 있습니다.
println! 로 시작해서 반드시 막히는 곳 — 무엇이 없는가
누구나 println!("여기까지 왔다") 로 시작합니다. 그리고 누구나 같은 자리에서 막힙니다.
끌 수가 없고, 언제 어디서 났는지 모르고, 컨테이너에서 사라지고, 느립니다.
이 네 가지가 로깅 크레이트가 존재하는 이유 전부입니다.
① 끌 수가 없습니다
// 디버깅하다 넣은 한 줄. 지우는 걸 잊습니다
for row in &rows {
println!("row = {row:?}"); // 100만 건이면 100만 줄
}
// 임시방편으로 이렇게들 합니다 — 그리고 이게 늘어납니다
if std::env::var("DEBUG").is_ok() {
println!("row = {row:?}");
}
// 조건이 실행 시점마다 평가되고, {row:?} 는 조건과 무관하게
// 이미 포맷 인자로 준비됩니다(Debug 호출은 지연되지만 인자 참조는 남습니다)
// 로깅 크레이트라면 — 코드는 그대로, 끄고 켜는 건 바깥에서
log::debug!("row = {row:?}"); // 또는 tracing::debug!
// $ RUST_LOG=info ./app → 이 줄은 아예 실행되지 않습니다
// $ RUST_LOG=debug ./app → 이 줄이 나옵니다
// 레벨이 안 맞으면 포맷 자체를 시작하지 않습니다 ★
debug! 매크로는 먼저 레벨이 켜져 있는지 묻고,
꺼져 있으면 인자 포맷팅을 아예 하지 않습니다. println! 은 무조건 포맷합니다.
무거운 Debug 구현이 붙은 타입에서는 이 차이가 수십 배가 됩니다.
② stdout 은 잠금이 걸린 자원입니다
use std::io::Write;
// println! 이 실제로 하는 일 (개념적으로)
let stdout = std::io::stdout();
let mut lock = stdout.lock(); // ← 전역 뮤텍스를 잡습니다
writeln!(lock, "...").unwrap();
// 락 해제 + 줄바꿈마다 LineWriter 가 flush 합니다
// 스레드 8개가 동시에 println! 을 하면
// 8개가 같은 뮤텍스 앞에 줄을 섭니다 → 직렬화 지점이 생깁니다
// 비동기 런타임에서는 더 나쁩니다 —
// 워커 스레드가 블로킹 I/O 로 멈추면 그 위의 태스크가 전부 멈춥니다 ★
// 굳이 println! 을 써야 한다면 최소한 락을 한 번만 잡습니다
let mut out = std::io::stdout().lock();
for row in &rows {
writeln!(out, "{row:?}")?; // 매번 잠그지 않습니다
}
// 그래도 레벨·시각·구조는 여전히 없습니다
tracing-appender 의 non_blocking 이 존재하는 이유가 이것입니다(LOG 08).
③ 컨테이너에서 로그가 사라지는 세 가지 이유 🟡 중급
버퍼링 · 종료 · 목적지
| 증상 | 진짜 원인 | 처방 |
|---|---|---|
| 마지막 몇 줄이 없다 | 프로세스가 버퍼를 비우기 전에 죽음 | 워커 guard 유지 · flush() |
| 한참 뒤에 몰려 나온다 | 파이프에 붙으면 블록 버퍼링 | 줄 단위 flush 또는 로거 사용 |
| 아무것도 안 보인다 | stdout 이 아니라 stderr 로 나감 | 수집기 설정 확인 · 목적지 통일 |
| JSON 이 깨진다 | 여러 스레드의 줄이 섞임 | 줄 단위 원자적 쓰기 로거 |
| panic 만 안 남는다 | 패닉은 로거를 거치지 않음 | set_hook (LOG 11) |
직접 확인해 보기
# stdout 이 터미널이면 줄 단위, 파이프면 블록 버퍼링입니다
./app # 실시간으로 보임
./app | cat # 뭉쳐서 나옴 ← 이게 컨테이너 상황과 같습니다
# 도커에서 로그가 늦게 보이는지 확인
docker run --rm myapp 2>&1 | ts # moreutils 의 ts 가 시각을 붙여 줍니다
# 쿠버네티스에서는 stdout·stderr 가 같은 곳으로 모입니다
kubectl logs -f deploy/myapp --timestamps
# 종료 직전 몇 줄이 날아가는지 재현
./app & sleep 0.2; kill -TERM %1 # SIGTERM 직후 남는 줄을 봅니다
tracing-appender 를 쓸 때 WorkerGuard 를 살려 두는 것이 그래서 중요합니다.
④ 그래도 println! 이 맞는 자리 🔴 실무
로그가 아니라 “출력”일 때
// ① CLI 의 결과물 — 이건 로그가 아니라 프로그램의 출력입니다
// 파이프로 넘겨 쓰는 값이므로 stdout 이 맞습니다
println!("{}", total);
// ② 진행 상황 · 에러 안내는 stderr 로
eprintln!("입력 파일을 찾을 수 없습니다: {path}");
// 규칙 하나만 외우면 됩니다 ★
// stdout = 다음 프로그램이 먹을 데이터
// stderr = 사람이 읽을 말
// 로거 = 나중에 조사할 기록
// 이 셋을 섞으면 `./app | jq` 가 깨집니다
clap 의 --verbose 를 로그 레벨로 이어 붙이는 것이 관례입니다.
-v 는 info, -vv 는 debug, -vvv 는 trace —
clap-verbosity-flag 크레이트가 이 매핑을 그대로 해 줍니다.
테스트에서의 출력 — 이것도 함정입니다
#[test]
fn t() {
println!("여기"); // 통과하면 안 보이고, 실패해야 보입니다
// cargo test -- --nocapture 로 항상 보이게 할 수 있습니다
}
// 로그를 테스트에서 보고 싶다면 — 전역 로거는 한 번만 설치됩니다
static INIT: std::sync::Once = std::sync::Once::new();
fn init_test_log() {
INIT.call_once(|| {
let _ = tracing_subscriber::fmt()
.with_test_writer() // ★ 테스트 캡처로 보냅니다
.with_env_filter("debug")
.try_init(); // init() 은 두 번째 호출에서 패닉
});
}
init() 과 try_init() 의 차이 — 전역 로거는 프로세스당 한 번만 설치됩니다.
테스트는 같은 프로세스에서 여러 개가 도니 init() 을 쓰면 두 번째부터 패닉합니다.
테스트 코드에서는 반드시 try_init() 을 쓰세요.
println! 의 문제는 “느리다”가 아니라 끌 수 없고 찾을 수 없다는 것입니다.
로깅 크레이트가 주는 것은 딱 넷 — 레벨(끄기) · 메타데이터(언제·어디서) · 구조(찾기) · 비동기 쓰기(안 막기).
stdout 은 프로그램의 출력, stderr 는 사람에게 하는 말, 로거는 나중에 조사할 기록입니다.파사드 구조 — log 크레이트가 인터페이스만 정한 이유
라이브러리는 로그를 남기고 싶지, 로그를 어디로 보낼지 정하고 싶지 않습니다.
그 두 관심사를 갈라 놓은 것이 log 크레이트입니다 —
자바의 SLF4J + Logback 과 정확히 같은 두 겹 구조입니다.
라이브러리 쪽 — log 만 의존합니다
# 내가 만드는 라이브러리 크레이트의 Cargo.toml
[dependencies]
log = "0.4" # ★ 이것만. env_logger 를 넣으면 안 됩니다
# tracing 을 쓰는 라이브러리라면
# tracing = { version = "0.1", default-features = false, features = ["std"] }
// src/lib.rs — 매크로 5종이 전부입니다
pub fn parse(input: &str) -> Result<Doc, Error> {
log::trace!("parse 시작, {} 바이트", input.len());
if input.is_empty() {
log::warn!("빈 입력이 들어왔습니다");
return Err(Error::Empty);
}
log::debug!(target: "mylib::parser", "토큰 {}개", n);
log::info!("문서 하나를 읽었습니다");
log::error!("복구 불가: {e}");
Ok(doc)
}
target: 이 무엇인가 — 기본값은 모듈 경로(mylib::parser)입니다.
이 문자열이 나중에 RUST_LOG 필터의 왼쪽이 됩니다.
직접 지정하면 모듈 구조와 무관하게 기능 단위로 껐다 켤 수 있습니다.
실행 파일 쪽 — 구현체를 딱 한 번
# 실행 파일(bin) 의 Cargo.toml
[dependencies]
log = "0.4"
env_logger = "0.11" # ★ 구현체는 여기에서만 고릅니다
fn main() {
env_logger::init(); // 전역 로거를 설치합니다
log::info!("시작"); // 이제부터 진짜로 출력됩니다
mylib::parse("..."); // 라이브러리의 로그도 같이 나옵니다
}
// 설치하기 전에 남긴 로그는 그냥 버려집니다 —
// init 은 main 의 첫 줄에 두세요 ★
$ RUST_LOG=info cargo run
[2026-09-01T04:12:33Z INFO myapp] 시작
[2026-09-01T04:12:33Z INFO mylib] 문서 하나를 읽었습니다
↑ 시각 ↑레벨 ↑target(모듈 경로)
fern 을 쓰는데
내가 쓴 라이브러리가 env_logger 를 끌고 들어오면,
둘 중 하나만 설치되고 나머지는 조용히 무시됩니다(set_logger 는 두 번째부터 실패).
“왜 어떤 로그만 안 나오지?”의 절반이 이 문제입니다.
파사드의 속을 열어 보기 — Log 트레이트는 세 함수뿐입니다 🟡 중급
직접 구현체 만들어 보기 (30줄)
use log::{Log, Metadata, Record, LevelFilter};
struct MyLogger;
impl Log for MyLogger {
// ① 이 레벨·target 을 받을 것인가 (빠르게 판단해야 합니다)
fn enabled(&self, m: &Metadata) -> bool {
m.level() <= log::Level::Info
}
// ② 실제로 쓰기
fn log(&self, r: &Record) {
if !self.enabled(r.metadata()) { return; }
eprintln!("[{}] {} — {}", r.level(), r.target(), r.args());
// ↑ args() 는 fmt::Arguments
// 여기서 처음 포맷됩니다 ★
}
// ③ 버퍼 비우기 (종료 직전에 불립니다)
fn flush(&self) {}
}
static LOGGER: MyLogger = MyLogger;
fn main() {
log::set_logger(&LOGGER).unwrap();
log::set_max_level(LevelFilter::Info); // ★ 이걸 빼면 아무것도 안 나옵니다
log::info!("직접 만든 로거");
}
set_max_level 을 잊는 실수 — 기본값이 Off 입니다.
매크로는 먼저 전역 최대 레벨과 비교하고(원자적 읽기 한 번), 통과해야 enabled() 를 부릅니다.
이 2단 검사가 꺼진 로그가 거의 공짜인 이유입니다.
파사드가 하는 일은 사실 이게 전부
// log 크레이트 내부의 뼈대 (개념적으로)
static LOGGER: AtomicPtr<dyn Log> = ...; // 전역 슬롯 하나
static MAX_LEVEL: AtomicUsize = ...; // 전역 레벨 하나
// info!(...) 가 펼쳐지는 모양
if log::Level::Info <= log::max_level() { // ① 원자적 비교
let logger = log::logger();
if logger.enabled(&metadata) { // ② 구현체에 묻기
logger.log(&Record::builder() // ③ 여기서 처음 포맷
.args(format_args!("..."))
.level(Level::Info)
.target(module_path!())
.file(Some(file!()))
.line(Some(line!()))
.build());
}
}
| 레벨 | 언제 쓰나 |
|---|---|
error! | 사람이 지금 봐야 하는 것 |
warn! | 돌긴 도는데 세어 둬야 하는 것 |
info! | 운영 기본값 — 업무 사건 |
debug! | 개발자용 — 조사할 때만 켬 |
trace! | 한 줄 한 줄 — 거의 안 켬 |
두 세계를 잇기 — log 로그를 tracing 이 받게 하기 🔴 실무
현실 — 의존성 트리에는 둘 다 있습니다
# 내 프로젝트가 어느 쪽을 쓰는지 세어 보기
cargo tree -i log # log 를 누가 쓰는지 (역방향)
cargo tree -i tracing
# 실제 결과는 보통 이렇습니다
# log ← hyper 이전 세대 · mio · 많은 오래된 크레이트
# tracing ← tokio · tower · axum · sqlx · reqwest(최근)
# 둘 중 하나를 버릴 수 없으니 '이어 붙이는' 것이 정답입니다
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# tracing-log 는 tracing-subscriber 의 기본 기능에 이미 들어 있습니다
tracing-subscriber 는 기본 피처로
tracing-log 를 켜고, init() 이 log 의 전역 로거 자리에
브리지를 꽂습니다. 즉 log::info! 도 tracing 구독자가 받습니다.
반대 방향과, 라이브러리 설계 지침
// ① log → tracing (거의 항상 이 방향)
// tracing-subscriber 의 init()/try_init() 이 자동으로 해 줍니다.
// 수동으로 하려면:
tracing_log::LogTracer::init().unwrap();
// ② tracing → log (구독자 대신 기존 log 구현체를 쓰고 싶을 때)
// tracing 의 "log" 피처를 켜면 이벤트가 log 레코드로도 나갑니다
// tracing = { version = "0.1", features = ["log"] }
// ③ 라이브러리를 만든다면 — 판단 기준
// · 동기 · 범용 유틸리티 → log 만 의존 (가장 가벼움)
// · 비동기 · 요청 단위 문맥 필요 → tracing 만 의존
// · 둘 다 만족시키고 싶다 → tracing + "log" 피처
log 이벤트는 span 정보가 없습니다.
브리지를 지나온 줄에는 요청 ID 가 붙지 않습니다.
“어떤 줄만 문맥이 비어 있다”면 그건 브리지를 타고 온 남의 크레이트 로그입니다.
log 는 인터페이스이고 env_logger·fern 은 구현체입니다.
규칙은 한 줄 — 라이브러리는 파사드만, 실행 파일이 구현체를 딱 한 번.
현실의 프로젝트에는 log 와 tracing 이 섞여 있고,
tracing-subscriber 가 기본으로 둘을 이어 줍니다.tracing 이 다른 점 — 점이 아니라 구간을 기록합니다
log 는 사건(event) 하나를 한 줄로 남깁니다. tracing 은 여기에
구간(span) 을 더합니다 — 시작과 끝이 있고, 중첩되고, 안에서 난 모든 이벤트에 자기 정보를 물려주는 것.
비동기 코드에서 “이 로그가 어느 요청 것인가”를 답할 수 있게 하는 것이 바로 이 개념입니다.
같은 코드를 두 방식으로
// ── log 방식 — 문맥을 손으로 나릅니다 ──────────────
fn handle(req_id: u64, user: u64) {
log::info!("[req {req_id}] 인증 시작 user={user}");
authenticate(req_id, user); // ← id 를 계속 넘겨야 합니다
log::info!("[req {req_id}] 완료");
}
fn authenticate(req_id: u64, user: u64) { // 인자가 늘어납니다
log::info!("[req {req_id}] 인증 성공");
}
// ── tracing 방식 — 문맥이 스택처럼 쌓입니다 ─────────
use tracing::{info, info_span};
fn handle(req_id: u64, user: u64) {
let span = info_span!("handle", req_id, user);
let _e = span.enter(); // ★ 이 시점부터 '안'입니다
info!("인증 시작"); // req_id·user 가 자동으로 붙습니다
authenticate(); // 인자를 안 넘겨도 됩니다
info!("완료");
} // _e 가 드롭되면 span 이 닫힙니다
fn authenticate() {
info!("인증 성공"); // 여기에도 req_id 가 붙습니다
}
enter() 가 돌려주는 것은 가드입니다 — 드롭되는 순간 span 이 닫히고
구간의 길이가 기록됩니다. let _ = span.enter(); 처럼 쓰면
즉시 드롭되어 아무 효과가 없습니다 — 반드시 let _e = 로 이름을 주세요.
span 이 만드는 세 가지 정보
| 얻는 것 | log | tracing |
|---|---|---|
| 사건 한 줄 | ✔ | ✔ |
| 구간의 길이 | 직접 계산 | 자동(busy/idle) |
| 부모-자식 관계 | 없음 | 스택으로 |
| 문맥 상속 | 손으로 전달 | 자동 |
| 비동기 안전 | 스레드 로컬 깨짐 | 태스크에 붙음 |
| 분산 추적 | 불가 | OTel 로 그대로 |
# span 이 켜져 있으면 출력이 이렇게 바뀝니다
2026-09-01T04:12:33Z INFO handle{req_id=7781 user=42}: myapp: 인증 시작
2026-09-01T04:12:33Z INFO handle{req_id=7781 user=42}
:db{table="orders"}: myapp: 느린 쿼리 경고
# ↑ span 스택이 콜론으로 이어집니다 ★
FmtSpan::CLOSE 를 켜면 span 이 닫힐 때
실제로 CPU 를 쓴 시간(busy) 과 .await 로 기다린 시간(idle) 이 따로 찍힙니다.
“느린데 CPU 는 안 쓴다”를 한눈에 구분해 줍니다.
span 을 만드는 세 가지 방법 🟡 중급
매크로 · 어트리뷰트 · 수동
use tracing::{info_span, instrument, Instrument, Level, span};
// ① 매크로 — 동기 코드에서 가장 흔합니다
let sp = info_span!("batch", file = %path.display(), rows = 0u64);
let _g = sp.enter();
// ② 어트리뷰트 — 함수 전체가 span 이 됩니다 (가장 권장)
#[instrument(level = "info", skip(pool), fields(order_id))]
async fn place_order(pool: &Pool, req: OrderReq) -> Result<Order> {
// 인자들이 자동으로 span 필드가 됩니다 (skip 한 것 빼고)
let o = repo::insert(pool, &req).await?;
tracing::Span::current().record("order_id", o.id); // 나중에 채우기
Ok(o)
}
// ③ 수동 — 레벨을 변수로 정하고 싶을 때
let sp = span!(Level::DEBUG, "retry", attempt = 1);
// ④ 비동기에서는 enter() 대신 .instrument() ★★ (LOG 07 에서 자세히)
some_future.instrument(info_span!("job")).await;
async 블록 안에서 span.enter() 가드를
.await 를 넘겨 들고 있는 것. 컴파일은 되지만 span 이 다른 태스크에 새어 나갑니다.
#[instrument] 나 .instrument() 는 이 문제가 구조적으로 없습니다.
#[instrument] 의 옵션 전부
| 옵션 | 하는 일 |
|---|---|
skip(a, b) | 그 인자를 필드에서 뺍니다 |
skip_all | 인자를 전부 뺍니다 (권장 기본) |
fields(x = %v) | 필드를 직접 추가 |
fields(id) | 빈 칸만 만들고 나중에 record |
level = "debug" | span 레벨 (기본 info) |
name = "..." | 함수명 대신 쓸 이름 |
ret | 반환값을 이벤트로 남김 |
err | Err 일 때 error! 로 남김 |
target = "..." | 필터용 target 지정 |
#[instrument(
skip_all, // ★ 큰 구조체를 통째로 찍지 않습니다
fields(order_id = %req.id, amount = req.amount),
err(Debug), // Err 는 Debug 로 찍어 원인 체인까지
)]
async fn pay(req: PayReq) -> Result<Receipt, PayError> { ... }
skip_all 을 기본으로 하세요 — 아무것도 안 적으면 모든 인자가
Debug 로 찍힙니다. 요청 본문·비밀번호·토큰이 여기로 새어 나가는 사고가
실제로 가장 많습니다. 필요한 필드만 골라 fields() 로 명시하세요.
span 의 수명과 “들어간다”의 정확한 뜻 🔴 실무
만들기 · 들어가기 · 닫기는 다 다릅니다
let sp = info_span!("job", id = 7); // ① 생성 — 아직 '현재'가 아님
{
let _g = sp.enter(); // ② 진입 — 이제부터 현재 span
info!("안에서 남긴 로그"); // id=7 이 붙습니다
} // ③ 이탈 — 가드 드롭
info!("밖에서 남긴 로그"); // id=7 이 안 붙습니다
drop(sp); // ④ 닫힘 — 마지막 핸들이 사라질 때
// 한 span 에 여러 번 들어갔다 나올 수 있습니다 ★
// 비동기 태스크는 실제로 이렇게 동작합니다 —
// 폴링될 때마다 enter, .await 에서 exit 를 반복합니다
on_new_span(생성) · on_enter/on_exit(진입·이탈) · on_close(닫힘).
busy 시간은 enter~exit 의 합, 전체 시간은 생성~닫힘입니다.
이 둘을 빼면 .await 로 기다린 시간(idle)이 나옵니다.
span 을 얼마나 많이 만들어도 되나
// 나쁜 예 — 루프 안에서 span 을 100만 개
for row in rows { // ✗
let _g = info_span!("row", id = row.id).enter();
process(row);
}
// 좋은 예 — 바깥에 하나, 안에서는 필드만 갱신하거나 이벤트로
let sp = info_span!("rows", total = rows.len(), done = 0u64);
let _g = sp.enter(); // ✔
for (i, row) in rows.iter().enumerate() {
process(row);
if i % 10_000 == 0 { sp.record("done", i as u64); }
}
// 판단 기준 ★
// span = "나중에 이 구간의 시간을 알고 싶은 단위"
// 그게 아니면 이벤트(info!) 하나로 충분합니다
log 는 점을, tracing 은 구간을 기록합니다.
span 은 시간(길이) · 계층(부모-자식) · 문맥(상속) 세 가지를 한꺼번에 주고,
이것이 비동기에서 “이 로그가 어느 요청 것인가”에 답할 수 있는 유일한 방법입니다.
함수 단위는 #[instrument(skip_all, fields(...))] 를 기본으로 쓰세요.tracing-subscriber 실전 — 레이어를 겹쳐 내 구독자를 조립합니다
tracing 은 기록만 하고, 그걸 받아 처리하는 쪽이 구독자(Subscriber) 입니다.
tracing-subscriber 는 구독자를 통째로 주는 대신 Registry 위에 레이어를 끼우는 방식을 씁니다 —
포맷 하나, 필터 하나, OTel 하나… 필요한 만큼 겹칩니다.
with() 한 번이 층 하나입니다.가장 짧은 것부터 실무형까지
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# "env-filter" 없이는 EnvFilter 를 못 씁니다 ★ 가장 흔한 컴파일 에러
// ① 한 줄 — 실습·예제용
tracing_subscriber::fmt::init();
// ② 필터를 붙인 한 줄 — 작은 CLI 라면 여기까지로 충분합니다
tracing_subscriber::fmt()
.with_env_filter("info,hyper=warn")
.init();
// ③ 레이어 합성 — 서비스는 이 모양이 기본값입니다 ★
use tracing_subscriber::{fmt, EnvFilter, prelude::*};
pub fn init_log() {
let filter = EnvFilter::builder()
.with_default_directive(tracing::level_filters::LevelFilter::INFO.into())
.from_env_lossy(); // RUST_LOG 가 이상해도 죽지 않습니다
let json = std::env::var("LOG_FORMAT").as_deref() == Ok("json");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer()
.with_target(true)
.with_thread_ids(!json)
.with_ansi(!json && std::io::IsTerminal::is_terminal(&std::io::stdout())))
.init();
// json 이면 아래 LOG 05 의 .json() 갈래로 갑니다
}
from_env_lossy() 를 쓰세요 — from_env() 는 RUST_LOG 에
오타가 있으면 에러를 돌려주고, 그걸 unwrap() 하면 서비스가 시작도 못 합니다.
lossy 는 이상한 지시자만 버리고 나머지를 살립니다.
fmt::layer() 의 손잡이들
| 메서드 | 효과 |
|---|---|
.with_target(true) | 모듈 경로 표시 |
.with_thread_ids(true) | 스레드 번호 |
.with_thread_names(true) | 스레드 이름 |
.with_file(true).with_line_number(true) | 소스 위치 |
.with_ansi(false) | 색 끄기 — 파일·수집기용 |
.with_span_events(FmtSpan::CLOSE) | 구간 소요 시간 |
.compact() | 한 줄로 촘촘히 |
.pretty() | 여러 줄로 읽기 좋게 |
.json() | 구조적 로깅 (LOG 05) |
.with_writer(w) | 출력 대상 바꾸기 |
.with_test_writer() | 테스트 캡처로 |
use tracing_subscriber::fmt::format::FmtSpan;
fmt::layer()
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
// 출력 예:
// INFO handle{req_id=7781}: new
// INFO handle{req_id=7781}: close time.busy=1.2ms time.idle=84ms
// ↑ CPU ↑ .await 대기 ★
.with_writer(std::io::stderr) // 로그는 stderr 가 안전합니다
with_ansi 는 자동이 아닙니다 — 파이프로 넘겨도 색 코드가 그대로 나갑니다.
std::io::IsTerminal 로 판단하거나(Rust 1.70+), 운영에서는 무조건 끄세요.
ESC[32m 같은 쓰레기가 로그 저장소에 쌓이는 것을 막습니다.
RUST_LOG 문법 전부 — 이 표 하나면 끝납니다 🟡 중급
지시자(directive) 의 문법
RUST_LOG = 지시자[,지시자,...]
지시자 = target[span{field=value}]=level
└────┘ └──────────────┘ └───┘
모듈 경로 span 이름·필드 레벨
(전부 생략 가능 — 레벨만 써도 됩니다)
level = error < warn < info < debug < trace (또는 off)
숫자 1~5 로도 씁니다 (1=error … 5=trace)
★ 왼쪽에서 오른쪽으로 읽지 말고 "가장 구체적인 지시자가 이긴다"로 읽으세요
# ── 레벨만 ────────────────────────────────
RUST_LOG=info # 전부 info 이상
RUST_LOG=off # 전부 끄기
# ── 모듈별 ────────────────────────────────
RUST_LOG=warn,myapp=debug # 기본 warn, 내 크레이트만 debug ★가장 흔함
RUST_LOG=myapp::db=trace # 특정 모듈만
RUST_LOG=info,hyper=off,h2=off # 시끄러운 크레이트 입막음
RUST_LOG=debug,sqlx::query=info # sqlx 가 쿼리를 전부 찍는 걸 막기
# ── span 이름으로 (tracing 전용) ───────────
RUST_LOG=[request]=debug # request 라는 span 안에서만 debug
RUST_LOG=myapp[db]=trace # myapp 크레이트의 db span 안에서만
# ── span 필드 값으로 (tracing 전용) ★ ──────
RUST_LOG=[request{user_id=42}]=trace # 특정 사용자 요청만 trace
RUST_LOG=[request{path}]=debug # path 필드가 있기만 하면
두 구현의 차이와 실행 중 변경
| 문법 | env_logger | EnvFilter |
|---|---|---|
info | ✔ | ✔ |
myapp=debug | ✔ | ✔ |
a::b=trace | ✔ | ✔ |
[span]=debug | ✗ | ✔ |
[span{k=v}] | ✗ | ✔ |
=debug/정규식 | ✔ | ✗ |
| 실행 중 변경 | ✗ | reload 로 |
// 실행 중에 레벨을 바꾸기 — reload 레이어
use tracing_subscriber::{reload, EnvFilter, prelude::*};
let (filter, handle) = reload::Layer::new(EnvFilter::new("info"));
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.init();
// 나중에 (예: 관리용 HTTP 엔드포인트에서)
async fn set_level(body: String) -> StatusCode {
match handle.modify(|f| *f = EnvFilter::new(&body)) {
Ok(_) => StatusCode::OK, // 재배포 없이 debug 로 전환 ★
Err(_) => StatusCode::BAD_REQUEST,
}
}
trace 로 올려 디스크를 채우는 공격이 됩니다.
내부망 전용 포트에 두거나 관리자 인증 뒤에 두고, 일정 시간 뒤 자동 복귀를 넣는 것이 안전합니다.
레이어별 필터와 “두 곳으로 동시에 쓰기” 🔴 실무
전역 필터 vs 레이어별 필터
use tracing_subscriber::{fmt, filter, EnvFilter, Layer, prelude::*};
// 터미널에는 사람이 읽을 것만, 파일에는 debug 까지 전부
let (file_w, _guard) = tracing_appender::non_blocking(
tracing_appender::rolling::daily("logs", "app.log"));
tracing_subscriber::registry()
// ① 콘솔 — info 이상만, 색 있음
.with(fmt::layer()
.with_ansi(true)
.with_filter(EnvFilter::new("info")))
// ② 파일 — debug 까지, 색 없음, JSON
.with(fmt::layer()
.json()
.with_ansi(false)
.with_writer(file_w)
.with_filter(EnvFilter::new("debug,hyper=warn")))
// ③ 지표용 — 특정 target 만 골라서
.with(MetricsLayer::new()
.with_filter(filter::filter_fn(|m| m.target().starts_with("biz::"))))
.init();
.with(EnvFilter) 는
구독자 전체에, .with_filter(...) 는 그 레이어에만 걸립니다.
전역이 info 인데 레이어가 debug 를 원하면 debug 는 안 옵니다 —
전역을 trace 로 열고 레이어별로 좁히는 것이 정석입니다.
조건부 레이어 — 타입이 안 맞을 때
// ✗ 이건 컴파일 안 됩니다 — if 의 두 갈래 타입이 다릅니다
// let layer = if json { fmt::layer().json() } else { fmt::layer() };
// ✔ 방법 1 — Boxed 로 타입을 지웁니다
use tracing_subscriber::Layer;
let layer = if json {
fmt::layer().json().boxed()
} else {
fmt::layer().pretty().boxed()
};
// ✔ 방법 2 — Option<Layer> 도 Layer 입니다 (None 이면 없는 셈)
let otel = if otel_on { Some(otel_layer()) } else { None };
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(layer)
.with(otel) // None 이어도 그대로 넘깁니다 ★
.init();
// 라이브러리에서 구독자를 설치하면 안 됩니다.
// 대신 "레이어를 돌려주는 함수"를 공개하세요 ★
pub fn my_layer<S>() -> impl Layer<S>
where S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>
{ ... }
init() 은 두 번째 호출에서 패닉하고
try_init() 은 Err 를 돌려줍니다.
라이브러리는 절대 구독자를 설치하지 마세요 — 앱의 로깅 설정을 통째로 빼앗는 셈입니다.
registry() + with() 반복으로 조립합니다.
필터는 앞에(뒤 레이어가 아예 안 불립니다), 색은 터미널일 때만,
RUST_LOG 는 target[span{field}]=level 한 줄 문법이 전부이고
span 필드 필터가 운영에서 가장 강력한 손잡이입니다.구조적 로깅 — 문자열이 아니라 필드로 남깁니다
"주문 7781 이 12000원 결제됨" 은 사람만 읽습니다.
{order_id: 7781, amount: 12000} 은 기계가 집계하고 정렬하고 알람을 겁니다.
같은 정보를 어떻게 담느냐가 “grep 밖에 못 하는 로그”와 “질의할 수 있는 로그”를 가릅니다.
필드 문법 — 시길 세 가지
use tracing::info;
let id: u64 = 7781;
let user = User { id: 42, name: "kim".into() };
let path = std::path::Path::new("/tmp/a.csv");
// ① 시길 없음 — 값을 그대로 (숫자 · bool · &str · char 등)
info!(order_id = id, retried = true, "결제 완료");
// ② % — Display 로 문자열화
info!(file = %path.display(), "파일을 읽었습니다");
// ③ ? — Debug 로 문자열화
info!(user = ?user, "사용자 조회");
// ④ 변수 이름을 그대로 필드 이름으로 (축약형) ★
let amount = 12000;
info!(amount, "금액 확인"); // amount = 12000
// ⑤ 점이 들어간 이름은 따옴표로
info!(http.status = 200, "응답"); // ✗ 컴파일 에러
info!("http.status" = 200, "응답"); // ✔
// ⑥ 메시지는 사실 message 라는 필드일 뿐입니다
info!(message = "결제 완료", order_id = id); // 위 ①과 같습니다
? 를 함부로 쓰지 마세요 — Debug 는 구조체를 통째로 찍습니다.
?req 한 번에 비밀번호와 카드번호가 로그로 새어 나갑니다.
민감한 필드는 #[derive(Debug)] 대신 손으로 Debug 를 구현해
"[redacted]" 을 찍게 하는 것이 가장 확실합니다.
JSON 으로 내보내기
use tracing_subscriber::{fmt, EnvFilter, prelude::*};
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(fmt::layer()
.json()
.flatten_event(true) // fields 를 최상위로 펼칩니다
.with_current_span(true) // 현재 span 을 포함
.with_span_list(true) // 조상 span 전부를 배열로 ★
.with_timer(fmt::time::UtcTime::rfc_3339()))
.init();
{
"timestamp": "2026-09-01T04:12:33.481Z",
"level": "INFO",
"target": "pay::api",
"amount": 12000,
"attempt": 2,
"message": "결제를 승인했습니다",
"span": { "name": "place_order", "order_id": 7781 },
"spans": [ { "name": "http", "req_id": "0f2a" },
{ "name": "place_order", "order_id": 7781 } ]
}
tracing-bunyan-formatter(Bunyan 규격),
tracing-logfmt(logfmt), 또는 fmt::layer().event_format(내 포맷) 으로
직접 구현합니다. ECS·GELF 같은 사내 규격이 있으면 포맷 구현체를 한 번 만들어 두고 재사용하세요.
좋은 로그 한 줄의 모양 🟡 중급
이렇게 쓰지 마세요
info!("주문 {} 를 사용자 {} 가 {}원에 결제했고 재시도 {}회 만에 성공",
order.id, user.id, amount, attempt);
// 문제
// · 집계 불가 — "금액 합계"를 내려면 문자열을 파싱해야 합니다
// · 알람 불가 — "재시도 3회 이상"을 조건으로 걸 수 없습니다
// · 카디널리티 폭발 — 같은 사건인데 문자열이 전부 다릅니다
// · 번역·문구 변경이 곧 대시보드 파손입니다
이렇게 쓰세요
info!(
order_id = order.id,
user_id = user.id,
amount, // 숫자는 숫자로
attempt, // 재시도 횟수도 필드로
outcome = "success", // 값 종류가 적은 것은 라벨로
"결제 완료" // 메시지는 '사건 이름'만
);
// 얻는 것
// · sum(amount) · avg(attempt) 가 바로 됩니다
// · outcome="failure" 로 알람을 겁니다
// · 메시지가 같으니 같은 사건끼리 묶입니다 ★
필드 이름 규칙 — 팀에서 미리 정하세요
| 필드 | 규칙 |
|---|---|
*_id | 식별자는 접미사 통일 |
outcome | success/failure/skipped |
duration_ms | 단위를 이름에 넣습니다 |
error.kind | 에러 분류 (문구 아님) |
attempt | 1부터 세는 정수 |
| ❌ 자유 문자열 | 카디널리티를 폭발시킴 |
user_id 처럼 값이 수백만인 필드를 대시보드의 그룹 기준으로 쓰면
저장소가 죽습니다. 조회용 식별자와 집계용 라벨을 구분하세요.
민감 정보를 구조적으로 막기
use std::fmt;
pub struct Secret(String);
// Debug 도 Display 도 절대 원본을 내놓지 않습니다 ★
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret([redacted])")
}
}
impl fmt::Display for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[redacted]")
}
}
// 이러면 ?req 로 통째로 찍어도 안전합니다
#[derive(Debug)]
struct LoginReq { user: String, password: Secret }
// 카드번호처럼 일부만 남겨야 하면
fn mask_card(s: &str) -> String {
format!("****-****-****-{}", &s[s.len().saturating_sub(4)..])
}
Secret<T> 같은 래퍼를 만들어 타입 시스템이 강제하게 하면
새로 들어온 사람도 사고를 못 냅니다. secrecy 크레이트가 이 패턴을 구현해 둔 것입니다.
%(Display) · ?(Debug).
? 는 통째로 찍히니 민감 타입은 Debug 를 직접 구현해 막고,
단위는 필드 이름에(duration_ms) 넣는 습관을 팀 규칙으로 정하세요.크레이트 비교 — 여섯 갈래, 어떤 상황에 무엇을 고르나
러스트 로깅 크레이트는 많아 보이지만 계보는 세 줄기입니다 —
log 파사드 위의 구현체들, tracing, 그리고 임베디드 전용 defmt.
아래 표는 강도를 보여 줍니다. 정확한 다운로드 수치가 아니라 상대적 위치로 읽으세요.
여섯 갈래를 한 줄씩
| 크레이트 | 정체 | 채택 정도 |
|---|---|---|
log | 파사드(인터페이스) | 사실상 모든 곳 |
env_logger | log 의 기본 구현체 | 널리 씀(예제·CLI) |
tracing | span 이 있는 다음 세대 | 서비스의 사실상 표준 |
fern | log 용 빌더 API | 특정 취향에서 |
slog | 구조적 로깅의 원조 | 신규 채택은 드묾 |
flexi_logger | 파일 회전이 강한 구현체 | 데스크톱·온프렘 |
defmt | 임베디드 전용 · 지연 포맷 | 임베디드의 표준 |
log 가 억 단위, tracing 이 그에 준하는 자릿수이고
slog·fern 은 두 자릿수 배 적습니다.
상황별로 고르기
| 이런 상황이면 | 고르세요 |
|---|---|
| 라이브러리 크레이트를 만든다 | log 만 의존 |
| 작은 CLI · 예제 · 학습 | log+env_logger |
| 비동기 서버 · 마이크로서비스 | tracing |
| 분산 추적이 필요하다 | tracing+OTel |
| 파일 회전·압축·보관이 필수 | flexi_logger 또는tracing-appender |
이미 slog 로 짜여 있다 | 유지 — 이관은 신중히 |
MCU · no_std | defmt |
| WASM(브라우저) | tracing-wasm ·console_log |
tracing,
내가 만드는 라이브러리는 log. 이 둘은 브리지로 이어지므로 섞여도 됩니다.
나머지는 이미 그걸 쓰고 있을 때나 특수 요구가 있을 때의 선택지입니다.
각 크레이트의 실제 코드 🟡 중급
env_logger · fern
// ── env_logger 0.11 — 설정은 빌더로 ──────────────
use env_logger::{Builder, Env, Target};
use std::io::Write;
Builder::from_env(Env::default().default_filter_or("info"))
.target(Target::Stderr)
.format(|buf, r| {
writeln!(buf, "{} [{}] {}: {}",
chrono::Local::now().format("%H:%M:%S%.3f"),
r.level(), r.target(), r.args())
})
.init();
// ── fern — 체이닝으로 여러 출구를 만듭니다 ─────────
fern::Dispatch::new()
.level(log::LevelFilter::Info)
.level_for("myapp::db", log::LevelFilter::Debug)
.chain(std::io::stderr()) // 출구 ①
.chain(fern::log_file("app.log")?) // 출구 ②
.format(|out, msg, rec| {
out.finish(format_args!("[{}] {}", rec.level(), msg))
})
.apply()?;
// 장점 — log 만 쓰면서도 '여러 출구'가 쉽습니다
// 한계 — span 이 없습니다. 비동기 문맥은 여전히 손으로 나릅니다
slog · flexi_logger
// ── slog — 로거를 '값으로' 들고 다닙니다 ───────────
let drain = slog_json::Json::default(std::io::stderr()).fuse();
let drain = slog_async::Async::new(drain).build().fuse();
let root = slog::Logger::root(drain, slog::o!("svc" => "pay"));
let req_log = root.new(slog::o!("req_id" => 7781)); // 자식 로거
slog::info!(req_log, "결제 시작"; "amount" => 12000);
// ★ 문맥을 '로거 객체'로 나릅니다 — 함수 인자로 계속 넘겨야 합니다
// tracing 은 이걸 '현재 span' 으로 바꿔 인자를 없앤 것입니다
// ── flexi_logger — 파일 회전·보관이 기본 탑재 ──────
use flexi_logger::{Logger, Criterion, Naming, Cleanup, FileSpec};
Logger::try_with_str("info, myapp=debug")?
.log_to_file(FileSpec::default().directory("logs"))
.rotate(Criterion::Size(10_000_000), // 10MB 마다
Naming::Timestamps,
Cleanup::KeepLogFiles(7)) // 7개만 보관 ★
.append()
.start()?;
// 데스크톱 앱·온프렘 설치형에서 '수집기가 없을 때' 좋은 선택입니다
slog 는 왜 밀렸나 — 아이디어(구조적 로깅)는 옳았지만
로거 객체를 인자로 나르는 방식이 비동기와 잘 맞지 않았습니다.
tracing 이 같은 아이디어를 암묵적 현재 span 으로 바꾸면서
생태계(tokio·tower·axum·sqlx)가 통째로 그쪽으로 갔습니다.
log 파사드 + 구현체, tracing, defmt.
새 서비스는 tracing, 새 라이브러리는 log, 임베디드는 defmt 로 시작하면
거의 항상 맞습니다. 채택 정도는 crates.io 다운로드와 lib.rs 순위로 직접 확인하세요 —
남의 숫자를 옮겨 적지 말고요.비동기 · Tokio — span 이 .await 를 건너 이어지는 원리
비동기 태스크는 스레드에 붙어 있지 않습니다. .await 에서 양보하고,
다음에 깨어날 때는 다른 워커 스레드일 수 있습니다.
그래서 스레드 로컬에 문맥을 담는 옛 방식은 여기서 깨지고, tracing 은 그것을 구조로 풀었습니다.
왜 enter() 를 .await 너머로 들면 안 되나
// ✗ 컴파일은 되지만 틀린 코드입니다
async fn bad(id: u64) {
let span = tracing::info_span!("job", id);
let _g = span.enter(); // 가드가 .await 를 넘어 살아 있습니다
do_io().await; // ← 여기서 태스크가 양보합니다
tracing::info!("끝");
}
// 무슨 일이 나나
// · 양보한 사이 그 스레드가 '다른 태스크'를 돌립니다
// · 그런데 현재 span 은 여전히 job{id} 로 남아 있습니다
// · 남의 태스크 로그에 내 id 가 붙습니다 ★ 문맥 오염
// ✔ 방법 1 — 어트리뷰트 (거의 항상 이걸 씁니다)
#[tracing::instrument(skip_all, fields(id))]
async fn good1(id: u64) {
do_io().await;
tracing::info!("끝");
}
// ✔ 방법 2 — Instrument 트레이트로 Future 를 감쌉니다
use tracing::Instrument;
async fn good2(id: u64) {
async {
do_io().await;
tracing::info!("끝");
}
.instrument(tracing::info_span!("job", id)) // ★
.await;
}
// ✔ 방법 3 — 동기 블록만 감쌀 때
span.in_scope(|| { /* .await 없는 코드만 */ });
Instrumented<F> 는 poll() 을 감싸서
폴링 직전에 enter, 폴링이 끝나면 exit 합니다.
즉 span 이 Future 자체에 소유되므로, 어느 스레드에서 폴링되든 문맥이 정확히 그 태스크만 따릅니다.
태스크를 띄울 때 문맥 잇기
use tracing::{Instrument, Span};
// ① spawn 은 문맥을 자동으로 물려주지 않습니다
tokio::spawn(async { work().await }); // ✗ 부모 span 없음
// ② 현재 span 을 명시적으로 물려줍니다 ★
tokio::spawn(work().instrument(Span::current()));
// ③ 새 span 을 만들되 부모를 이어 붙입니다
let parent = Span::current();
tokio::spawn(async move {
let sp = tracing::info_span!(parent: &parent, "worker", shard = 3);
async { work().await }.instrument(sp).await;
});
// ④ 아예 부모를 끊고 싶을 때 (배경 잡업 등)
let sp = tracing::info_span!(parent: None, "cron");
// blocking 작업도 마찬가지입니다
let sp = Span::current();
tokio::task::spawn_blocking(move || {
let _g = sp.enter(); // 여기는 .await 가 없으니 enter 가 안전합니다
heavy_cpu_work();
}).await?;
tokio::spawn 은 완전히 독립된 태스크를 만들기 때문에
.instrument(Span::current()) 를 붙여 주지 않으면 문맥이 끊깁니다.
HTTP 서버에 요청 span 붙이기 🟡 중급
Axum · tower-http 의 TraceLayer
[dependencies]
axum = "0.8"
tower-http = { version = "0.6", features = ["trace", "request-id"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
use tower_http::trace::TraceLayer;
use tracing::info_span;
let app = Router::new()
.route("/orders/{id}", get(get_order))
.layer(
TraceLayer::new_for_http()
// 요청마다 span 을 하나 만듭니다 ★
.make_span_with(|req: &http::Request<_>| {
let rid = req.headers().get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
info_span!("http",
method = %req.method(),
path = %req.uri().path(),
request_id = %rid,
status = tracing::field::Empty, // 나중에 채웁니다
)
})
.on_response(|res: &http::Response<_>, lat: std::time::Duration, sp: &tracing::Span| {
sp.record("status", res.status().as_u16());
tracing::info!(duration_ms = lat.as_millis() as u64, "요청 완료");
})
);
TraceLayer 를 가장 바깥(가장 마지막 .layer())에 두어야
인증 실패·타임아웃·패닉까지 전부 기록됩니다. 안쪽에 두면 그 앞에서 잘린 요청은 기록이 없습니다.
이렇게 나옵니다
INFO http{method=GET path=/orders/7781 request_id=0f2a}: 요청 시작
DEBUG http{method=GET path=/orders/7781 request_id=0f2a}
:db{table="orders"}: sqlx: SELECT ... 실행 (4.1ms)
WARN http{method=GET path=/orders/7781 request_id=0f2a}
:db{table="orders"}: 느린 쿼리 duration_ms=412
INFO http{method=GET path=/orders/7781 request_id=0f2a status=200}
: 요청 완료 duration_ms=418
# 한 요청의 모든 줄이 request_id 로 묶입니다 ★
# grep 0f2a app.log → 그 요청에서 일어난 일 전부
// 핸들러 안에서는 아무것도 안 해도 됩니다
#[tracing::instrument(skip(state))] // 부모는 http span
async fn get_order(State(state): State<App>, Path(id): Path<u64>)
-> Result<Json<Order>, AppError>
{
let o = state.repo.find(id).await?; // repo 안의 로그에도 전부 붙습니다
Ok(Json(o))
}
tower-http 의
SetRequestIdLayer/PropagateRequestIdLayer 가
없으면 만들고 있으면 그대로 전달합니다.
프록시·게이트웨이가 이미 붙여 주는 경우가 많으니 덮어쓰지 않는 것이 중요합니다.
tokio-console — 태스크가 어디서 막혔는지 실시간으로 🔴 실무
붙이는 법
[dependencies]
console-subscriber = "0.4"
tokio = { version = "1", features = ["full", "tracing"] } # ★ tracing 피처
[build] # .cargo/config.toml 에
rustflags = ["--cfg", "tokio_unstable"]
use tracing_subscriber::prelude::*;
fn main() {
tracing_subscriber::registry()
.with(console_subscriber::spawn()) // 6669 포트로 노출
.with(tracing_subscriber::fmt::layer())
.with(tracing_subscriber::EnvFilter::from_default_env())
.init();
// 앱 실행 …
}
cargo install --locked tokio-console
tokio-console # 기본 http://127.0.0.1:6669
# 보이는 것
# · 살아 있는 태스크 목록과 각각의 폴링 횟수 · busy/idle 시간
# · '오래 안 깨어난' 태스크 경고 (누수 후보)
# · 한 번 폴링에 너무 오래 머문 태스크 경고 (블로킹 의심) ★
# · 뮤텍스·세마포어 대기 시간
tokio_unstable 은 ABI 안정성이 보장되지 않고,
계측 자체가 오버헤드입니다. 개발·부하 시험 환경에서만 켜는 것이 정석이고,
빌드 프로파일을 따로 두어 실수로 배포되지 않게 하세요.
실제로 잡히는 문제들
| console 이 보여 주는 것 | 진짜 원인 |
|---|---|
| 한 폴링이 수십 ms | 비동기 안에서 블로킹 (파일 I/O · std::sync::Mutex) |
| 태스크 수가 계속 증가 | 완료되지 않는 태스크 누수 |
| idle 만 길다 | 외부 호출 대기 — 내 잘못 아님 |
| busy 만 길다 | CPU 작업 — spawn_blocking 으로 |
| 세마포어 대기 급증 | 커넥션 풀 고갈 |
// 가장 흔한 범인 — 비동기 안의 동기 잠금
use std::sync::Mutex; // ✗ .await 를 넘어 들면 워커가 멈춥니다
use tokio::sync::Mutex as AsyncMutex; // ✔ 대기 중 양보합니다
// 잠금을 .await 너머로 안 들면 std::sync 가 더 빠릅니다 ★
{
let mut g = std_mutex.lock().unwrap();
*g += 1;
} // 여기서 반드시 놓습니다
do_io().await;
tracing 만으로도 절반은 잡힙니다 —
FmtSpan::CLOSE 로 time.busy 와 time.idle 을 찍으면
“느린데 CPU 는 안 쓴다(외부 대기)” 와 “CPU 를 다 쓴다(내 코드)” 가 구분됩니다.
tokio-console 은 그다음 단계의 도구입니다.
async fn 은 #[instrument], 수동 Future 는 .instrument(span),
tokio::spawn 은 .instrument(Span::current()) 를 반드시 붙이세요.
enter() 가드를 .await 너머로 들면 남의 로그에 내 문맥이 새어 나갑니다.파일 출력과 로테이션 — WorkerGuard 를 놓치면 로그가 사라집니다
컨테이너가 아니라면(데스크톱 앱 · 온프렘 설치형 · 배치 작업) 파일로 남겨야 합니다. 그때 필요한 두 가지가 회전(rotation) 과 비동기 쓰기 이고, 비동기 쓰기에는 거의 모두가 한 번씩 당하는 함정이 있습니다.
tracing-appender 기본형
[dependencies]
tracing-appender = "0.2"
use tracing_appender::{non_blocking, rolling};
use tracing_subscriber::{fmt, EnvFilter, prelude::*};
use tracing_appender::non_blocking::WorkerGuard;
// ★ guard 를 반드시 돌려주고, main 이 끝까지 들고 있어야 합니다
pub fn init_log() -> WorkerGuard {
let file = rolling::daily("logs", "app.log");
let (writer, guard) = non_blocking(file);
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(fmt::layer().with_writer(std::io::stderr)) // 사람용
.with(fmt::layer().json().with_ansi(false).with_writer(writer)) // 파일용
.init();
guard
}
fn main() {
let _guard = init_log(); // ★ 이름을 주세요. `let _ =` 이면 즉시 드롭!
run();
} // 여기서 드롭되며 남은 로그를 전부 비웁니다
let _ = init_log(); 로 쓰면
WorkerGuard 가 그 줄에서 즉시 드롭되어 워커 스레드가 종료됩니다.
그러면 파일이 만들어지긴 하는데 내용이 비거나 몇 줄만 있습니다.
_guard 처럼 이름 있는 변수에 담으세요.
회전 정책과 보관 개수
use tracing_appender::rolling::{RollingFileAppender, Rotation};
// 간단형 — 네 가지가 준비돼 있습니다
rolling::minutely("logs", "app.log"); // 개발·시험용
rolling::hourly ("logs", "app.log");
rolling::daily ("logs", "app.log"); // ★ 가장 흔함
rolling::never ("logs", "app.log"); // 회전 없음
// 빌더형 — 이름과 보관 개수를 지정합니다 ★
let file = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix("app")
.filename_suffix("log")
.max_log_files(14) // 14개를 넘으면 오래된 것부터 지웁니다
.build("logs")
.expect("로그 디렉터리를 만들 수 없습니다");
// 만들어지는 파일 이름
// logs/app.2026-08-31.log
// logs/app.2026-09-01.log
tracing-appender 는 시간 기준만 지원합니다.
“10MB 마다”가 필요하면 flexi_logger(Criterion::Size) 를 쓰거나,
리눅스라면 logrotate 에 맡기는 것이 가장 단순합니다
(다만 copytruncate 를 써야 파일 핸들이 안 끊깁니다).
non_blocking 이 실제로 하는 일 🟡 중급
채널 + 워커 스레드 + 유실 정책
use tracing_appender::non_blocking::NonBlockingBuilder;
let (writer, guard) = NonBlockingBuilder::default()
.lossy(true) // ★ 기본값 true — 큐가 차면 '버립니다'
.buffered_lines_limit(128_000)
.thread_name("log-writer")
.finish(file);
// lossy(true) : 큐가 차면 로그를 버리고 앱은 안 막힙니다
// (버린 개수는 stderr 에 경고로 나옵니다)
// lossy(false) : 큐가 찰 때까지 기다립니다 —
// 디스크가 느리면 '앱이 로그 때문에 멈춥니다' ★
| 상황 | 고르는 값 |
|---|---|
| 온라인 서비스 | lossy(true) — 지연이 우선 |
| 감사 로그 · 금융 | lossy(false) — 유실 불가 |
| 배치 작업 | lossy(false) 로도 무방 |
| 큐 크기 | 초당 줄 수 × 최대 지연 초 |
lossy(true) 로 두면 안 됩니다 —
“장애 때 로그가 없다”가 정확히 이때 일어납니다.
부하가 몰리는 순간 큐가 차고, 가장 중요한 그 시점의 로그부터 버려집니다.
종료 시 반드시 비우기
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _guard = init_log(); // ★ 함수 끝까지 삽니다
let server = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal());
if let Err(e) = server.await {
tracing::error!(error = %e, "서버 종료 실패");
}
tracing::info!("정상 종료");
Ok(())
} // _guard 드롭 → 남은 로그 flush
async fn shutdown_signal() {
tokio::signal::ctrl_c().await.ok();
tracing::info!("종료 신호를 받았습니다");
}
// std::process::exit 는 드롭을 건너뜁니다 ★ 절대 주의
fn bad() {
let _guard = init_log();
tracing::info!("이 줄은 파일에 안 남을 수 있습니다");
std::process::exit(1); // ✗ 소멸자가 안 불립니다
}
// 종료 코드를 주고 싶으면 main 에서 돌려주세요
fn main() -> std::process::ExitCode {
let _guard = init_log();
...
std::process::ExitCode::from(1) // ✔ 드롭이 정상적으로 일어납니다
}
디스크가 찼을 때 · 로그가 안 보일 때 🔴 실무
점검 순서 (위에서부터)
# ① 그 파일에 정말 쓰고 있나 — 열린 파일 핸들 확인
lsof -p $(pgrep myapp) | grep '\.log'
ls -l --time-style=full-iso logs/ # 갱신 시각을 봅니다
# ② 디스크·아이노드가 남았나 (아이노드 고갈이 의외로 흔합니다)
df -h /var/log ; df -i /var/log
# ③ 권한 — 컨테이너 안의 uid 가 디렉터리를 못 쓰는 경우
id ; ls -ld logs/
# ④ 필터가 다 막고 있나
RUST_LOG=trace ./myapp 2>&1 | head -20
# ⑤ 회전 뒤에도 같은 파일에 쓰고 있나 (logrotate 사고)
# → copytruncate 를 쓰거나, 앱이 스스로 회전하게 하세요
logrotate 의 고전적 함정 — 기본 동작은 rename 입니다.
프로세스는 이미 열어 둔 파일 핸들을 계속 쓰므로,
새 파일은 비고 지워진 옛 파일에 계속 씁니다(디스크는 안 줄어듭니다).
copytruncate 를 쓰거나 postrotate 에서 앱에 시그널을 보내세요.
로그가 디스크를 채우지 않게
// ① 반복되는 경고는 '샘플링' 하거나 '뭉쳐서' 남깁니다
use std::sync::atomic::{AtomicU64, Ordering};
static DROPPED: AtomicU64 = AtomicU64::new(0);
if let Err(e) = send(msg) {
let n = DROPPED.fetch_add(1, Ordering::Relaxed);
if n % 1000 == 0 { // 1000번에 한 번만
tracing::warn!(dropped = n + 1, error = %e, "전송 실패 누적");
}
}
// ② 루프 안의 debug! 는 span 밖으로 빼고 요약만 남깁니다
tracing::info!(rows = n, elapsed_ms = t.elapsed().as_millis() as u64,
"배치 완료");
// ③ 큰 본문은 길이만 · 해시만
tracing::debug!(body_len = body.len(),
body_sha = %hex(&sha256(&body)[..8]), "요청 본문");
error! 가 쏟아지고, 그게 디스크를 채워
진짜 장애를 만듭니다. 반복 에러에는 누적 카운터 + 주기적 요약을 기본으로 하세요.
WorkerGuard 를 이름 있는 변수로 살려 두기,
회전 정책과 보관 개수(max_log_files) 정하기, 유실 정책(lossy) 을 의식적으로 고르기.
std::process::exit 는 소멸자를 건너뛰므로 마지막 로그가 사라집니다.
컨테이너라면 애초에 stdout 한 줄기가 정답입니다.성능 — 꺼진 로그는 얼마나 싼가, 켠 로그는 얼마나 비싼가
“로그를 많이 남기면 느려진다”는 말은 절반만 맞습니다. 꺼진 로그는 거의 공짜이고(원자적 비교 한 번), 컴파일 타임에 지우면 완전히 0 입니다. 비싼 것은 켠 상태에서의 포맷·할당·쓰기이고, 그건 측정해서 다뤄야 합니다.
① 컴파일 타임 제거 — 진짜 0 으로
# log 크레이트 — 코드에서 아예 사라집니다
[dependencies]
log = { version = "0.4", features = ["max_level_debug",
"release_max_level_info"] }
# max_level_* : 모든 빌드에 적용
# release_max_level_* : --release 빌드에만 적용 ★ 보통 이걸 씁니다
# tracing 도 같은 이름의 피처를 가집니다
tracing = { version = "0.1", features = ["release_max_level_info"] }
// release 빌드에서 이 줄은 '기계어에 없습니다'
log::debug!("무거운 {:?}", expensive_dump());
// ↑ expensive_dump() 도 호출되지 않습니다 ★
// 확인하는 법 — 심볼이 사라졌는지 봅니다
// cargo build --release
// 나 cargo asm / cargo-show-asm 으로 함수 본문을 직접 확인
max_level_trace 를 켜면 전체가 그 값이 됩니다.
반대로 release_max_level_info 를 켜 두면
운영에서 디버그 로그를 켤 방법이 아예 없어집니다 — 그 대가를 알고 쓰세요.
② 실행 중 필터 — 얼마나 싼가
// log 매크로가 펼쳐지는 모양 (개념적)
if Level::Debug <= STATIC_MAX_LEVEL // ① 컴파일 타임 상수 비교
&& Level::Debug <= log::max_level() { // ② 원자적 로드 1회
/* 여기부터가 진짜 비용 */
}
// tracing 은 한 겹 더 있습니다 — 콜사이트 캐시 ★
// 각 매크로 위치마다 'Interest' 를 한 번 계산해 캐시합니다
// Never : 이 위치는 영원히 관심 없음 → 이후 분기조차 거의 없음
// Always : 무조건 기록
// Sometimes : 매번 물어봄 (span 필드 필터를 쓸 때)
| 단계 | 대략의 비용 |
|---|---|
| 컴파일 타임에 제거 | 0 (코드가 없음) |
| 꺼진 레벨 (Never 캐시) | 나노초 단위 · 분기 하나 |
| 켠 이벤트 — 필드 기록 | 수백 나노초 |
| 포맷 + 문자열 할당 | 마이크로초 단위 |
| 동기 파일·터미널 쓰기 | 수십~수백 마이크로초 |
| 비동기 큐로 넘기기 | 채널 send 한 번 |
non_blocking 을 붙이고, 그다음에 레벨을 줄이세요.
할당을 만들지 않는 로그 🟡 중급
매번 String 을 만듭니다
// ① format! 을 미리 만들어 넘기기
tracing::debug!("{}", format!("row {} 처리", i)); // ✗ 항상 할당
// ② to_string() 을 필드에 넣기
tracing::info!(path = path.to_string_lossy().to_string(), "열기"); // ✗
// ③ 꺼져 있어도 인자를 미리 계산
let dump = expensive_dump(); // ✗ 무조건 실행
tracing::trace!(?dump, "덤프");
// ④ 루프 안에서 span 을 새로 만들기
for r in rows { let _g = info_span!("row").entered(); } // ✗
필요할 때만, 참조로
// ① 포맷은 매크로에 맡깁니다 — 꺼져 있으면 아예 안 합니다
tracing::debug!(row = i, "행 처리"); // ✔
// ② Display 시길로 참조만 넘깁니다
tracing::info!(path = %path.display(), "열기"); // ✔
// ③ 켜져 있을 때만 계산 ★
if tracing::enabled!(tracing::Level::TRACE) {
tracing::trace!(dump = ?expensive_dump(), "덤프");
}
// ④ span 은 루프 바깥에 하나, 안에서는 record 로 갱신
let sp = info_span!("rows", done = 0u64);
let _g = sp.enter();
for (i, r) in rows.iter().enumerate() { /* … */ } // ✔
직접 재 보기 — 벤치 방법
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "log_bench"
harness = false
// benches/log_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench(c: &mut Criterion) {
// ① 구독자가 아예 없을 때 (기준선)
c.bench_function("no subscriber", |b| {
b.iter(|| tracing::debug!(v = black_box(42), "x"))
});
// ② 구독자는 있는데 레벨이 꺼져 있을 때
let _g = tracing::subscriber::set_default(
tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).finish());
c.bench_function("filtered out", |b| {
b.iter(|| tracing::debug!(v = black_box(42), "x"))
});
// ③ 켜져 있고 io::sink 로 버릴 때 (포맷 비용만)
// ④ 켜져 있고 파일에 쓸 때 (전체 비용)
}
criterion_group!(g, bench);
criterion_main!(g);
정적 디스패치와 레이어 비용
// registry().with(A).with(B) 는 Layered<B, Layered<A, Registry>> 라는
// '구체 타입'을 만듭니다 — 가상 호출이 아니라 정적 디스패치입니다 ★
// 장점: 인라인 가능 · 오버헤드 거의 없음
// 단점: 타입이 길어져 컴파일 에러가 무섭게 나옵니다
// .boxed() 를 쓰면 동적 디스패치로 바뀝니다 (호출당 가상 호출 한 번)
// 조건부 레이어에는 필요하지만, 뜨거운 경로에 여러 개 쌓지는 마세요
// 실측 없이 최적화하지 않는 규칙 ★
// 1) non_blocking 을 붙였는가
// 2) 운영 레벨이 info 인가
// 3) 루프 안에 debug! 가 있는가
// 이 셋을 먼저 확인하면 나머지는 대개 문제가 아닙니다
debug! 입니다.
FmtSpan::CLOSE 로 구간 시간을 먼저 보고,
그 다음 criterion 으로 네 단계를 나눠 재는 것이 순서입니다.
non_blocking 을 먼저 붙이세요.
release_max_level_info 는 강력하지만 운영에서 디버그를 켤 길을 없애는 대가가 있습니다.분산 추적 — tracing-opentelemetry 로 span 을 서비스 밖으로 내보냅니다
서비스가 둘 이상이면 “이 요청이 어디서 느려졌나”는 한 프로세스의 로그로는 답이 안 나옵니다.
OpenTelemetry(OTel) 는 그 답을 위해 trace_id 하나로 여러 서비스의 span 을 한 트리로 묶는 규격이고,
tracing 의 span 은 이미 그 모양이라 레이어 하나만 끼우면 그대로 나갑니다.
크레이트 네 개 — 버전은 “한 세트”입니다
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-opentelemetry = "0.31" # ★ opentelemetry 0.30 과 짝입니다
opentelemetry = "0.30"
opentelemetry_sdk = "0.30"
opentelemetry-otlp = { version = "0.30", features = ["grpc-tonic"] }
opentelemetry-http = "0.30" # 헤더 주입 · 추출 도우미
# 네 크레이트의 minor 는 함께 움직입니다 — 하나만 올리면
# "trait bound not satisfied" 로 컴파일이 깨집니다.
# 2026년 8월 기준 최신 짝은 crates.io 의 tracing-opentelemetry
# 페이지(README 의 호환표)에서 확인하세요.
tracing-opentelemetry 는 OTel 보다 minor 가 하나 앞선 번호를 쓰는 관례가 있고,
Cargo.lock 에 두 버전이 섞이는 순간 global::set_tracer_provider 가 서로 다른 타입을 보게 됩니다.
cargo tree -d 로 중복을 먼저 지우세요.
init_telemetry() — 프로바이더 + 레이어
use opentelemetry::{global, trace::TracerProvider as _, KeyValue};
use opentelemetry_sdk::{trace::SdkTracerProvider, Resource,
propagation::TraceContextPropagator};
use opentelemetry_otlp::{SpanExporter, WithExportConfig};
use tracing_subscriber::{fmt, EnvFilter, prelude::*};
pub fn init_telemetry() -> anyhow::Result<SdkTracerProvider> {
// ① W3C traceparent 를 읽고 쓰는 전파기 — 이게 없으면 헤더가 안 붙습니다
global::set_text_map_propagator(TraceContextPropagator::new());
// ② OTLP/gRPC exporter — Collector 의 4317 포트로
let exporter = SpanExporter::builder()
.with_tonic()
.with_endpoint("http://otel-collector:4317")
.build()?;
// ③ 프로바이더 — 배치 전송 + 리소스(service.name 은 사실상 필수)
let provider = SdkTracerProvider::builder()
.with_batch_exporter(exporter) // 전용 스레드가 모아서 보냅니다
.with_resource(Resource::builder()
.with_service_name("order-api")
.with_attribute(KeyValue::new("deployment.environment", "prod"))
.build())
.build();
global::set_tracer_provider(provider.clone());
// ④ tracing 레이어 — 내 span 이 OTel span 으로 복제됩니다 ★
let otel = tracing_opentelemetry::layer()
.with_tracer(provider.tracer("order-api"));
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(fmt::layer().json())
.with(otel)
.init();
Ok(provider) // main 이 끝날 때 shutdown() 을 불러야 합니다
}
#[instrument] 로 만든 span 과 info! 이벤트가
그대로 OTel span 과 span event 가 됩니다. LOG 03·07 에서 붙인 문맥이 그대로 분산 추적의 재료입니다.
문맥을 서비스 밖으로 — 헤더 주입과 추출 🟡 중급
호출하는 쪽 — 현재 span 을 헤더에 씁니다
use opentelemetry::global;
use opentelemetry_http::HeaderInjector;
use tracing_opentelemetry::OpenTelemetrySpanExt; // .context() 를 줍니다
#[tracing::instrument(skip_all, fields(sku))]
async fn check_stock(client: &reqwest::Client, sku: &str)
-> anyhow::Result<Stock>
{
let mut req = client.get(format!("http://stock/v1/{sku}")).build()?;
// 현재 tracing span → OTel 문맥 → traceparent 헤더 ★
let cx = tracing::Span::current().context();
global::get_text_map_propagator(|p| {
p.inject_context(&cx, &mut HeaderInjector(req.headers_mut()))
});
// 나가는 헤더: traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
// ↑ trace_id(16바이트) ↑ 내 span_id ↑ 샘플링 플래그
Ok(client.execute(req).await?.json().await?)
}
traceparent 와 tracestate 두 개입니다.
자바(Spring) · Go · .NET 서비스도 같은 헤더를 읽으므로 언어가 섞여도 한 트리가 됩니다.
직접 헤더를 만들지 말고 전파기에 맡기세요 — 플래그 비트와 버전 바이트를 손으로 쓰면 틀립니다.
받는 쪽 — 헤더를 부모로 삼습니다 (Axum)
use opentelemetry::global;
use opentelemetry_http::HeaderExtractor;
use tower_http::trace::TraceLayer;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let app = Router::new()
.route("/v1/{sku}", get(get_stock))
.layer(TraceLayer::new_for_http()
.make_span_with(|req: &http::Request<_>| {
// ① 들어온 헤더에서 부모 문맥을 꺼냅니다 (없으면 빈 문맥)
let parent = global::get_text_map_propagator(|p| {
p.extract(&HeaderExtractor(req.headers()))
});
// ② 요청 span 을 만들고 부모를 이어 붙입니다 ★
let span = tracing::info_span!("http",
method = %req.method(), path = %req.uri().path(),
trace_id = tracing::field::Empty);
span.set_parent(parent);
// ③ 로그 상관용 — trace_id 를 span 필드로 남깁니다
use opentelemetry::trace::TraceContextExt;
let id = span.context().span().span_context().trace_id();
span.record("trace_id", id.to_string());
span
}));
with_span_list) 요청 안의 모든 JSON 로그 줄에 trace_id 가 붙습니다.
Grafana 는 그 값을 클릭하면 Tempo 의 트레이스로 건너뛰게 해 줍니다.
Collector · 샘플링 · 종료 — 운영에서 정하는 세 가지 🔴 실무
용어 대응표와 특수 필드
| OpenTelemetry | tracing 쪽 |
|---|---|
| Trace | 루트 span 부터의 트리 (이름 없음) |
| Span | span — 이름 · 시작 · 끝 |
| Span Event | info! 등 이벤트 |
| Attribute | 필드 (key = value) |
| Status | error 필드가 있으면 ERROR |
| Span Kind | 필드 otel.kind = "server" |
| Span Name | 필드 otel.name 로 덮어쓰기 |
| Resource | 없음 — 프로바이더 설정 |
| Baggage | 없음 — 필드로 직접 나릅니다 |
// 이름을 동적으로 — 경로 템플릿을 span 이름으로 쓰고 싶을 때
tracing::info_span!("http", otel.name = %format!("GET {route}"),
otel.kind = "server");
// 실패를 OTel 상태로 — error 라는 이름의 필드가 있으면 STATUS_ERROR
tracing::error!(error = %e, "재고 조회 실패");
GET /orders/7781 처럼 ID 가 이름에 들어가면
저장소가 이름별로 색인을 만들어 카디널리티가 폭발합니다.
이름은 경로 템플릿(GET /orders/{id}), 값은 속성으로.
Collector 설정 · 샘플링 · 반드시 shutdown()
# otel-collector.yaml — 앱은 Collector 만 알고, 저장소는 여기서 바꿉니다 ★
receivers:
otlp: { protocols: { grpc: { endpoint: 0.0.0.0:4317 } } }
processors:
batch: {}
tail_sampling: # 느리거나 실패한 트레이스는 전부 남기기
policies:
- { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
- { name: slow, type: latency, latency: { threshold_ms: 500 } }
- { name: rest, type: probabilistic, probabilistic: { sampling_percentage: 5 } }
exporters:
otlp/tempo: { endpoint: tempo:4317, tls: { insecure: true } }
service:
pipelines:
traces: { receivers: [otlp], processors: [tail_sampling, batch], exporters: [otlp/tempo] }
// 앱 쪽 샘플링 — 부모 결정을 따르고, 루트만 10% 로
use opentelemetry_sdk::trace::Sampler;
SdkTracerProvider::builder()
.with_sampler(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(0.1))))
// …
// 종료 — WorkerGuard 와 같은 이유입니다 (LOG 08)
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let provider = init_telemetry()?;
run().await?;
provider.shutdown()?; // ★ 배치 큐를 비우고 나서 끝냅니다
Ok(())
}
opentelemetry-appender-tracing 레이어를 하나 더 끼웁니다.
tracing_opentelemetry::layer().
trace_id 를 span 필드로 한 번 남기면 로그와 트레이스가 이어지고,
shutdown() 을 잊으면 마지막 트레이스가 사라집니다. 버전은 네 크레이트를 한 세트로 올리세요.에러와 패닉 — 경계에서 한 번, 그리고 로거를 거치지 않는 패닉 잡기
로그가 가장 지저분해지는 곳이 에러 처리입니다. 같은 에러를 세 층에서 세 번 찍고, 정작 원인은 {e} 로
바깥 한 겹만 남습니다. 반대로 패닉은 로거를 아예 거치지 않아 파일에는 흔적이 없습니다.
이 섹션은 그 둘을 anyhow 체인과 panic::set_hook 으로 정리합니다.
층마다 찍고 다시 던집니다
fn load(path: &str) -> anyhow::Result<Config> {
let s = std::fs::read_to_string(path).map_err(|e| {
tracing::error!("파일 읽기 실패: {e}"); // ① 여기서 한 번
e
})?;
Ok(toml::from_str(&s)?)
}
fn boot() -> anyhow::Result<App> {
let cfg = load("app.toml").map_err(|e| {
tracing::error!("설정 로드 실패: {e}"); // ② 또 한 번
e
})?;
Ok(App::new(cfg))
}
fn main() {
if let Err(e) = boot() {
tracing::error!("시작 실패: {e}"); // ③ 세 번째 — 그런데 원인은 없음
}
}
// 출력 세 줄 · 마지막 줄에는 "설정 로드 실패" 만 남고 "파일 없음"은 사라집니다
안쪽은 문맥만, 경계에서 체인 전체를
use anyhow::Context;
fn load(path: &str) -> anyhow::Result<Config> {
let s = std::fs::read_to_string(path)
.with_context(|| format!("설정 파일 읽기: {path}"))?; // 로그 없음
toml::from_str(&s).context("설정 파싱") // 로그 없음
}
fn boot() -> anyhow::Result<App> {
let cfg = load("app.toml").context("앱 부팅")?; // 로그 없음
Ok(App::new(cfg))
}
fn main() -> std::process::ExitCode {
let _guard = init_log();
match boot() {
Ok(app) => app.run(),
Err(e) => {
// ★ 경계에서 한 번 — {:#} 이 체인을 한 줄로 이어 줍니다
tracing::error!(error = %format!("{e:#}"), "시작 실패");
std::process::ExitCode::FAILURE
}
}
}
// error="앱 부팅: 설정 파일 읽기: app.toml: No such file or directory (os error 2)"
anyhow 에러를 로그에 담는 네 가지 모양
| 포맷 | 나오는 것 | 쓰는 곳 |
|---|---|---|
{e} | 바깥 한 겹만 — "앱 부팅" | 사용자에게 보일 문구 |
{e:#} | 체인을 한 줄로 a: b: c | 로그 필드 ★ |
{e:?} | 여러 줄 + Caused by: | 터미널 · 개발 중 |
{e:#?} | 구조체 덤프 | 거의 안 씀 |
// 함수 단위라면 어트리뷰트가 더 짧습니다 — Err 일 때만 error! 가 나갑니다
#[tracing::instrument(skip_all, err(Debug))] // Debug 라 체인이 여러 줄로
async fn place_order(req: OrderReq) -> anyhow::Result<Order> { ... }
// 한 줄이 좋으면 Display 로 — 단, anyhow 의 Display 는 바깥 한 겹뿐 ★
#[tracing::instrument(skip_all, err)] // = err(Display) → "앱 부팅" 만
// 그래서 anyhow 를 쓰면 err(Debug) 나 수동 {e:#} 이 정답입니다
error = %e 가 가장 흔한 실수입니다 — anyhow::Error 의 Display 는
맨 바깥 문맥만 보여 줍니다. “설정 로드 실패”만 남고 파일 이름도 OS 에러 코드도 사라집니다.
로그 필드에는 %format!("{e:#}") 또는 ?e 를 쓰세요.
어떤 에러를 어느 레벨로 — 팀 규칙
| 상황 | 레벨 | 왜 |
|---|---|---|
| 잘못된 입력 (4xx) | info/debug | 내 잘못이 아님 · 알람 대상 아님 |
| 인증 실패 반복 | warn | 세어 두어야 함 (공격 징후) |
| 외부 의존성 실패 + 재시도 | warn | 성공하면 사건 종료 |
| 재시도 끝에 실패 (5xx) | error | 사람이 봐야 함 |
| 불변식 위반 · 있을 수 없는 상태 | error 또는 패닉 | 버그 — 즉시 조사 |
| 설정 오류로 시작 불가 | error + 종료 | 돌면 안 되는 상태 |
// Axum 경계 — 에러 타입 하나가 로그와 응답을 함께 정합니다 ★
enum AppError { NotFound(String), Invalid(String), Internal(anyhow::Error) }
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, body) = match self {
AppError::NotFound(m) => {
tracing::info!(error = %m, "요청 거절"); // 4xx 는 info
(StatusCode::NOT_FOUND, m)
}
AppError::Invalid(m) => {
tracing::info!(error = %m, "요청 거절");
(StatusCode::BAD_REQUEST, m)
}
AppError::Internal(e) => {
tracing::error!(error = %format!("{e:#}"), "요청 실패"); // 5xx 만 error
// 클라이언트에는 내부 사정을 보내지 않습니다 — 문구 고정
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
}
};
(status, Json(json!({ "error": body }))).into_response()
}
}
error! 로 찍는 순간 알람이 무의미해집니다 — 봇이 잘못된 URL 을 긁기만 해도
에러 대시보드가 빨개집니다. “내가 고칠 수 있는 것만 error”가 기준입니다.
패닉 — 로거를 거치지 않는 유일한 출력을 붙잡기 🔴 실무
panic::set_hook — 기본 훅을 감쌉니다
pub fn install_panic_hook() {
let default_hook = std::panic::take_hook(); // 기본 훅(stderr 출력)을 보관
std::panic::set_hook(Box::new(move |info| {
// 페이로드는 &str 또는 String — 둘 다 시도합니다
// (Rust 1.89 부터는 info.payload_as_str() 한 줄로 됩니다)
let msg = info.payload().downcast_ref::<&str>().copied()
.or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
.unwrap_or("(문자열이 아닌 페이로드)");
let location = info.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()));
// ★ 로거로 — 현재 span 의 문맥(요청 ID 등)이 그대로 붙습니다
tracing::error!(
panic.message = msg,
panic.location = location.as_deref(),
backtrace = %std::backtrace::Backtrace::force_capture(),
"패닉"
);
default_hook(info); // 기본 훅도 불러 stderr 에는 여전히 남깁니다
}));
}
force_capture() 는 RUST_BACKTRACE 와 무관하게 백트레이스를 만듭니다(비용이 있으니 패닉에서만).
init_log() 바로 다음 줄에서 설치하세요.
비동기 태스크 · panic = "abort" · span 백트레이스
// ① Tokio 태스크의 패닉은 JoinError 로 돌아옵니다 — 훅은 그래도 불립니다
let h = tokio::spawn(async { work().await });
if let Err(e) = h.await {
if e.is_panic() { tracing::error!("워커 태스크가 패닉으로 죽었습니다"); }
}
// ② Axum 핸들러의 패닉은 그 연결만 끊습니다 — 500 으로 바꾸려면
use tower_http::catch_panic::CatchPanicLayer;
let app = Router::new().route("/", get(h)).layer(CatchPanicLayer::new());
// ③ span 스택을 에러에 담기 — tracing-error
use tracing_error::{ErrorLayer, SpanTrace};
tracing_subscriber::registry().with(ErrorLayer::default()).with(fmt::layer()).init();
let trace = SpanTrace::capture(); // 지금 열려 있는 span 들: http{..} → db{..}
tracing::error!(spans = %trace, "어느 요청 안에서 났는지");
// color-eyre 를 쓰면 에러 리포트에 이 span 스택이 자동으로 붙습니다
# Cargo.toml — 컨테이너에서 흔히 켜는 설정
[profile.release]
panic = "abort" # unwinding 코드가 빠져 바이너리가 작아집니다
# 대신 소멸자가 안 돕니다 → WorkerGuard 도 flush 못 함 ★
# 훅 안에서 파일에 직접 쓰거나 stderr 에 남기세요 (default_hook 이 그 역할)
panic = "abort" + non_blocking 조합의 함정 — 훅에서 tracing::error! 를
불러도 그 줄은 워커 큐에 들어갈 뿐이고, 곧바로 abort 되면 큐가 비워지지 않습니다.
파일에는 패닉 줄이 없고 stderr 에만 있습니다. 컨테이너라면 stderr 가 수집되니 괜찮지만,
파일 로깅 환경이라면 훅에서 동기 쓰기를 한 번 더 하세요.
.context() 만 붙여 올리고, 경계에서 {e:#} 로 한 번 찍습니다.
anyhow 의 Display 는 바깥 한 겹뿐이니 %e 대신 {e:#} 또는 ?e.
4xx 는 info, 내가 고칠 것만 error. 패닉은 로거를 거치지 않으므로
set_hook 으로 잡아 span 문맥과 함께 남기고, panic = "abort" 면 훅에서 동기로도 씁니다.임베디드 · no_std — defmt 는 포맷을 호스트로 미룹니다
MCU 에는 파일도 stdout 도 없고, 플래시 64KB 에 core::fmt 를 넣을 자리도 없습니다.
defmt(deferred formatting) 는 발상을 뒤집습니다 — 장치는 “몇 번 문장인지”와 원시 값만 보내고,
문자열로 만드는 일은 PC 가 합니다. 그래서 빠르고, 작고, 그 대가로 호스트에 같은 ELF 가 있어야 읽힙니다.
세 조각 — 매크로 · 전송 · 패닉 핸들러
# Cargo.toml (Cortex-M 예 · 2026년 8월 기준 defmt 1.x)
[dependencies]
defmt = "1.0"
defmt-rtt = "1.0" # 전송 계층 — RTT(SRAM 링 버퍼)
panic-probe = { version = "1.0", features = ["print-defmt"] }
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7"
[profile.release]
debug = 2 # ★ 릴리즈에도 디버그 정보를 — .defmt 테이블이 여기 삽니다
# (플래시에는 안 들어갑니다. ELF 파일에만 있습니다)
# .cargo/config.toml
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F411CEUx" # cargo run 이 곧 플래시 + 로그
rustflags = ["-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x"] # ★ 이 링커 스크립트가 없으면 링크 에러
[env]
DEFMT_LOG = "info" # 컴파일 타임 필터 — RUST_LOG 와 같은 문법
# DEFMT_LOG = "debug,my_fw::i2c=trace"
DEFMT_LOG 는 실행이 아니라 빌드 때 읽습니다 — 꺼진 레벨은 기계어에서 사라집니다(LOG 09 의
release_max_level 과 같은 원리). 바꾸면 다시 빌드해야 하고,
defmt 의 빌드 스크립트가 이 변수를 감시하니 cargo build 만 다시 하면 됩니다.
펌웨어 코드 — log 와 거의 같은 얼굴
#![no_std]
#![no_main]
use defmt::{info, warn, unwrap, Format};
use defmt_rtt as _; // ★ 링크만 하면 전역 로거가 됩니다 (use 만으로 충분)
use panic_probe as _; // 패닉을 defmt 로 찍고 프로브를 멈춥니다
#[derive(Format)] // Debug 대신 Format — 호스트에서 포맷됩니다
struct Reading { temp_c: f32, ok: bool }
#[cortex_m_rt::entry]
fn main() -> ! {
let r = Reading { temp_c: 23.5, ok: true };
info!("측정값 {:?}", r); // Format 구현으로 통째로
info!("temp = {=f32} C", r.temp_c); // ★ 타입 힌트 — 값 4바이트만 보냅니다
warn!("배터리 {=u8}%", 12u8);
info!("버퍼 {=[u8]:#x}", &[0xde, 0xad]); // 슬라이스 · 16진수 지정
let v = unwrap!(sensor.read()); // Err 면 defmt 로 남기고 패닉
// ↑ core 의 .unwrap() 은 Debug 포맷 코드를 플래시에 끌어옵니다
loop { cortex_m::asm::wfi(); }
}
{=u8} 같은 타입 힌트가 핵심입니다 — 힌트가 있으면 컴파일러가
정확히 그 바이트 수만 전송합니다. {:?} 는 Format 구현을 따라가므로 조금 더 큽니다.
{=str} 은 문자열을 통째로 보내니 동적 문자열은 되도록 피하세요.
속을 열어 보기 — 인터닝과 프레임 🟡 중급
문자열은 심볼이 되고, 심볼 값이 번호가 됩니다
# 매크로가 펼쳐질 때 (개념적으로)
info!("temp = {=f32} C", t)
→ 링커 섹션 .defmt 에 심볼 하나가 생깁니다
이름 : {"package":"my_fw","tag":"defmt_info","data":"temp = {=f32} C", ...}
값 : 17 ← 링커가 매긴 고유 번호
→ 장치는 이렇게만 보냅니다 (LEB128 로 압축된 번호 + 원시 인자)
[0x11] [0x00 0x00 0xbc 0x41] ← 5바이트 (번호 1 + f32 4)
→ 타임스탬프를 켰다면 앞에 하나 더
defmt::timestamp!("{=u64:us}", now_us());
# 호스트(probe-rs · defmt-print) 는 ELF 를 열어
# 17 → "temp = {=f32} C" 를 찾고, 4바이트를 f32 로 읽어 "temp = 23.5 C" 를 만듭니다
# 확인해 보기
$ nm target/thumbv7em-none-eabihf/release/my_fw | grep defmt_info | head
$ probe-rs run --chip STM32F411CEUx target/.../my_fw # 러너가 자동으로 디코딩
debug = 2)는 ELF 에만 있고 플래시로 안 올라갑니다.
ELF 를 잃어버리면 그 펌웨어의 로그는 영영 못 읽습니다 — 릴리즈마다 ELF 를 보관하세요.
log + RTT 와 견주면
| 항목 | log + rtt-target | defmt |
|---|---|---|
| 포맷하는 곳 | 장치 (core::fmt) | 호스트 |
| 플래시 사용 | 문자열 + 포맷 코드 | 거의 0 |
| 한 줄의 전송량 | 문자열 길이만큼 | 수 바이트 |
| 인터럽트 안에서 | 느려서 위험 | 수 µs |
| 읽는 데 필요한 것 | 터미널 아무거나 | 같은 ELF |
| 레벨 필터 | 실행 시 | 컴파일 시 |
| UART 만 있을 때 | 바로 됨 | defmt-serial + 호스트 디코더 |
| 부동소수 출력 | fmt 가 커짐 | 4바이트 그대로 |
// 라이브러리 크레이트라면 둘 다 지원하는 관례가 있습니다 ★
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[derive(Debug)]
pub struct Frame { pub id: u16, pub len: u8 }
// Cargo.toml
// [features]
// defmt = ["dep:defmt"] # 앱이 켜면 Format 이 붙고, 안 켜면 의존성도 없습니다
defmt 피처가 기본 예제에 들어 있고,
HAL 크레이트들도 위의 cfg_attr 관례를 따릅니다. ESP32 는 espflash 가
defmt 디코딩을 지원하고, 프로브 없이 쓰려면 esp-println 이 대안입니다.
현장에서 겪는 것들 🔴 실무
깨진 출력 · 안 나오는 출력
| 증상 | 원인 → 처방 |
|---|---|
| 글자가 깨져 나옴 | ELF 와 펌웨어 불일치 → 다시 플래시 |
링크 에러 _defmt_* | -Tdefmt.x 누락 → rustflags 확인 |
| 아무것도 안 나옴 | use defmt_rtt as _; 빠짐 · DEFMT_LOG 가 off |
| 앞부분이 잘림 | RTT 버퍼가 차기 전에 호스트가 못 붙음 → 버퍼 키우기 |
| 절전 모드에서 멈춤 | SWD 클록이 꺼짐 → 디버그 중엔 절전 끄기 |
| 패닉 뒤 재부팅 안 함 | panic-probe 는 프로브를 멈추는 것 — 운영에는 다른 핸들러 |
panic-probe 는 개발용입니다(브레이크포인트에서 멈춤).
현장 장비라면 원인을 플래시 한 구석에 적고 리셋하는 핸들러를 직접 두어야 하고,
그 “한 구석”이 임베디드의 로그 파일입니다.
프로브 없이 · 현장에서 로그 남기기
// ① 링 버퍼를 직접 두고 RTT 대신 UART 로 흘리기 — defmt-serial
// (호스트에서 defmt-print 로 디코딩: 같은 ELF 필요)
use defmt_serial as _;
defmt_serial::defmt_serial(uart_tx); // 전송 계층만 바뀌고 매크로는 그대로
// ② 현장 장비 — 플래시의 마지막 섹터를 '블랙박스'로
// · 패닉 핸들러에서 번호 + 인자만 기록 (defmt 프레임 그대로)
// · 다음 부팅 때 그 섹터를 RTT/UART 로 뱉고 지웁니다
// · 회수한 프레임을 그 펌웨어의 ELF 로 디코딩 ★ ELF 보관이 그래서 중요
// ③ 시각 붙이기 — 틱 카운터를 타임스탬프로
defmt::timestamp!("{=u64:ms}", {
TICKS.load(core::sync::atomic::Ordering::Relaxed)
});
DEFMT_LOG) ·
비동기 쓰기(RTT 링 버퍼 = non_blocking) · 보관(ELF = 스키마).
이 탭에서 배운 것이 도구만 바뀌어 그대로 적용됩니다.
defmt 는 포맷을 호스트로 미룹니다 — 장치는 문장 번호와 원시 바이트만 보내고,
플래시에는 문자열이 없습니다. 대가는 같은 ELF 가 있어야 읽힌다는 것이니 릴리즈마다 ELF 를 보관하세요.
{=u8} 타입 힌트 · DEFMT_LOG 컴파일 타임 필터 · -Tdefmt.x 링커 스크립트 세 가지만 기억하면
나머지는 log 와 같습니다.스택은 Vec,
큐는 VecDeque,
빈 것은 None
Rust 표준 라이브러리에는 Stack 도 Queue 도 없습니다.
대신 Vec 이 스택이고, VecDeque 가 큐이자 덱이며, BinaryHeap 이 우선순위 큐입니다.
이 탭은 자료구조 이론이 아니라 2026년 Rust 현업이 실제로 고르는 타입과 관용구를 다룹니다 —
pop() 이 왜 Option 을 돌려주는지, VecDeque 의 원형 버퍼가 왜 as_slices() 로 두 조각을 주는지,
BFS 를 짤 때 빌림 검사기와 싸우지 않는 법, 스레드와 async 에서 어떤 채널을 몇 칸으로 만들지까지.
이 PC 에는 rustc 가 없어 예제 출력은 손으로 추적하고 같은 로직을 node 로 옮겨 값을 맞췄습니다.
한 장 지도 — Rust 에서는 무엇으로 쌓고 줄 세우나
자바는 1996년부터 쌓인 클래스 열댓 개 중에서 골라야 하지만, Rust 는 처음부터 고를 게 거의 없게 설계됐습니다.
단일 스레드라면 Vec · VecDeque · BinaryHeap 셋이 전부이고,
스레드나 async 가 끼면 채널이 큐 자리를 차지합니다. 지도부터 펴 놓고 섹션마다 하나씩 뜯어 봅니다.
컬렉션 전체를 고르는 표는 🦀 Rust 기초 탭 r06에 있으니 여기서는 스택·큐 쪽만 깊게 봅니다.
| 하려는 일 | 2026 권장 | 쓰지 말 것 | 한 줄 이유 |
|---|---|---|---|
| 스택 (LIFO) | Vec<T> — push · pop · last | 직접 만든 연결 스택 | pop() 이 Option<T> 이라 빈 스택이 타입에 드러납니다 (SQ 02) |
| 큐 (FIFO) | VecDeque<T> — push_back · pop_front | Vec::remove(0) | remove(0) 은 꺼낼 때마다 나머지를 전부 당깁니다 O(n) (SQ 03) |
| 덱 (양끝) | VecDeque<T> | LinkedList<T> | 원형 버퍼 한 장 · 인덱싱 O(1) · 캐시 친화 (SQ 04 · 09) |
| 우선순위 큐 | BinaryHeap<T> (+ Reverse) | 넣을 때마다 sort() | 기본이 최대 힙 — 자바·파이썬과 반대입니다 (SQ 05) |
| 스레드 간 전달 | crossbeam-channel · std::sync::mpsc | Arc<Mutex<Vec>> + 폴링 루프 | 비면 기다리고, 보내는 쪽이 모두 사라지면 끝납니다 (SQ 06) |
| async 작업 큐 | tokio::sync::mpsc::channel(n) | unbounded_channel() 기본 사용 | send().await 가 곧 배압(backpressure)입니다 (SQ 07) |
| 락프리 고정 큐 | crossbeam::queue::ArrayQueue | 직접 짠 CAS 큐 | 메모리 회수·ABA 를 검증된 코드가 처리합니다 (SQ 06) |
| 최근 N개 | VecDeque::with_capacity(n) | 매번 Vec 앞을 잘라 내기 | 가득 차면 pop_front 한 번 (SQ 12) |
| 지연 · 재시도 | BinaryHeap<Reverse<(Instant, …)>> · tokio_util DelayQueue | sleep 루프로 전부 훑기 | 가장 이른 것만 보면 됩니다 (SQ 12) |
다섯 줄로 보는 전부
use std::cmp::Reverse;
use std::collections::{BinaryHeap, VecDeque};
fn main() {
let mut stack = vec![1, 2, 3]; // 스택 = Vec
let mut queue = VecDeque::from([1, 2, 3]); // 큐 = VecDeque
let mut maxh = BinaryHeap::from([1, 5, 3]); // 최대 힙
let mut minh = BinaryHeap::from([Reverse(4), Reverse(2)]); // 최소 힙
println!("{:?}", stack.pop()); // Some(3) ← 마지막에 넣은 것
println!("{:?}", queue.pop_front()); // Some(1) ← 처음 넣은 것
println!("{:?}", maxh.pop()); // Some(5) ← 가장 큰 것
println!("{:?}", minh.pop()); // Some(Reverse(2)) ← 가장 작은 것
stack.clear(); queue.clear();
println!("{:?} {:?}", stack.pop(), queue.pop_front()); // None None
}
Option 을 돌려줍니다 — 자바처럼 "비었을 때 예외를 던지는 메서드"와
"null 을 주는 메서드"가 짝으로 있는 게 아니라, 꺼내기는 하나뿐이고 결과가 Option입니다.
빈 컨테이너에서 꺼내는 실수가 컴파일러가 보는 타입으로 올라옵니다.
어디에 있나 — 모듈과 크레이트
| 타입 | 위치 | 메모 |
|---|---|---|
Vec | prelude | import 없이 바로 |
VecDeque | std::collections | 원형 버퍼 |
BinaryHeap | std::collections | 최대 힙 |
Reverse | std::cmp | 순서 뒤집기 래퍼 |
LinkedList | std::collections | 거의 안 씀 |
mpsc | std::sync | 다중 생산 · 단일 소비 |
crossbeam-channel | 크레이트 0.5 | MPMC · select! |
tokio::sync::mpsc | 크레이트 1.x | async 채널 |
ArrayQueue | crossbeam 0.8 | 락프리 고정 크기 |
Stack 타입이 없나 — Vec 의 끝에 넣고 빼는 것이 이미 분할 상환 O(1) 이라
별도 타입이 얻을 게 없기 때문입니다. 대신 중간 원소를 못 건드리게 막고 싶다면
직접 newtype 을 씌웁니다(SQ 02). 자바의 Stack extends Vector 같은 "잘못 물려받은 메서드" 문제가 애초에 생기지 않습니다.
한 단계 더 — Rust 에서 큐 타입을 고를 때 묻는 네 가지 🟡 중급
① 누가 소유하나 ② 누가 동시에 만지나
① 소유 — 큐 안의 값은 '큐가' 소유합니다
VecDeque<String> 값 자체가 들어감 (권장)
VecDeque<&str> 빌린 것 — 원본이 큐보다 오래 살아야 함
VecDeque<usize> 인덱스 — 그래프 BFS 의 정석 (SQ 08)
VecDeque<&mut Node> 거의 항상 빌림 에러로 끝남 (SQ 08)
② 동시성 — 컴파일러가 Send/Sync 로 검사합니다
한 스레드 Vec · VecDeque · BinaryHeap
스레드 여럿 채널 · Arc<Mutex<VecDeque>> · ArrayQueue
async 태스크 여럿 tokio::sync::mpsc · broadcast · watch
Rc<RefCell<VecDeque>> 를 스레드로 넘기면 → 컴파일 에러 E0277
③ 비었을 때 ④ 가득 찼을 때
③ 비었을 때 무엇을 하나
단일 스레드 Option::None 을 받고 분기
스레드 recv() 가 '블록' — 송신자가 다 사라지면 Err
async recv().await 가 '대기' — 닫히면 None
④ 가득 찼을 때 무엇을 하나 (용량이 있는 것만)
VecDeque 가득 차는 일 없음 — 재할당으로 늘어남
sync_channel(n) send() 가 블록 = 배압
crossbeam bounded(n) send() 블록 · try_send() 는 Err(Full)
tokio channel(n) send().await 대기 · try_send() 는 Err(Full)
ArrayQueue push() 가 Err(값) 으로 돌려줌 · force_push 는 덮어씀
broadcast(n) 가장 오래된 것을 덮어씀 → 느린 수신자는 Lagged
Vec, 큐·덱은 VecDeque, 우선순위는 BinaryHeap,
스레드·태스크 사이는 용량을 정한 채널입니다. 넷 다 꺼내기의 결과가 Option(또는 Result)이라
"빈 것에서 꺼냈다"는 버그가 런타임 예외가 아니라 처리하지 않으면 컴파일이 안 되는 분기가 됩니다.스택 — Vec 의 끝이 꼭대기, pop() 은 Option
Vec<T> 는 힙 버퍼 하나와 길이(len)·용량(cap) 두 숫자입니다. 끝에 넣고(push) 끝에서 빼면(pop)
다른 원소를 하나도 옮기지 않으니 그대로 스택입니다. 다른 언어와 결정적으로 다른 점은 하나 —
빈 스택에서 pop() 해도 아무 일도 터지지 않고 None 이 옵니다. 대신 그 None 을 처리하지 않으면 값을 쓸 수 없습니다.
기본 API — push · pop · last · last_mut
fn main() {
let mut stack: Vec<i32> = Vec::new();
stack.push(1);
stack.push(2);
stack.push(3);
println!("{:?}", stack.last()); // Some(3) ← peek (빌려 보기)
if let Some(top) = stack.last_mut() {
*top *= 10; // 꼭대기를 그 자리에서 수정
}
println!("{stack:?}"); // [1, 2, 30]
println!("{:?}", stack.pop()); // Some(30)
println!("{:?}", stack.pop()); // Some(2)
println!("{:?}", stack.pop()); // Some(1)
println!("{:?}", stack.pop()); // None ← 패닉이 아닙니다
println!("{}", stack.is_empty()); // true
}
last() 는 Option<&T> — 값을 꺼내지 않고 빌려 봅니다.
그래서 last() 로 얻은 참조를 들고 있는 동안에는 push 를 못 합니다(빌림 규칙 · 🔐 o02).
들여다본 값을 계속 쓰려면 stack.last().copied() 나 .cloned() 로 복사본을 받으세요.
비우며 꺼내기 — while let 관용구 ★
fn main() {
let mut stack = vec!["a", "b", "c"];
// ★ 가장 많이 쓰는 형태 — 빌 때까지 꼭대기부터
while let Some(x) = stack.pop() {
print!("{x} "); // c b a
}
println!();
// 순회 순서 — Vec 의 iter() 는 '바닥부터' 입니다
let s = vec![1, 2, 3]; // 3 이 꼭대기
let bottom_up: Vec<_> = s.iter().collect();
let top_down: Vec<_> = s.iter().rev().collect();
println!("{bottom_up:?} {top_down:?}"); // [1, 2, 3] [3, 2, 1]
// 1.86+ — 조건이 맞을 때만 꺼내기
let mut v = vec![1, 2, 3, 4];
println!("{:?}", v.pop_if(|x| *x % 2 == 0)); // Some(4)
println!("{:?}", v.pop_if(|x| *x % 2 == 0)); // None (3 은 홀수라 남음)
}
Stack 이 비판받는 지점과 같습니다.
{:?} 와 iter() 는 바닥 → 꼭대기 순서입니다. 꼭대기부터 보여 줘야 하면
.iter().rev() 를 명시적으로 씁니다. Rust 는 적어도 이 순서가 문서와 타입(DoubleEndedIterator)으로 분명합니다.
| 스택 연산 | Rust Vec | 반환 | 자바 ArrayDeque | 파이썬 list |
|---|---|---|---|---|
| 넣기 | push(x) | () | push(x) | append(x) |
| 꺼내기 | pop() | Option<T> | pop() — 비면 예외 | pop() — 비면 IndexError |
| 들여다보기 | last() | Option<&T> | peek() — 비면 null | s[-1] — 비면 IndexError |
| 꼭대기 수정 | last_mut() | Option<&mut T> | pop 후 push | s[-1] = v |
| 조건부 꺼내기 | pop_if(f) (1.86+) | Option<T> | — | — |
| 여러 개 꺼내기 | split_off(k) · drain(k..) | Vec · 반복자 | 반복 pop | s[k:] + del |
| 크기 · 비었나 | len() · is_empty() | usize · bool | size() · isEmpty() | len(s) · not s |
| 꼭대기부터 순회 | iter().rev() | 반복자 | iterator() | reversed(s) |
이렇게도 쓸 수 있다 — 빈 스택을 다루는 네 가지 방법 🟡 중급
후위 표기 계산기 — let-else 로 빈 스택 처리
fn eval_rpn(tokens: &[&str]) -> Result<i64, String> {
let mut st: Vec<i64> = Vec::new();
for &t in tokens {
match t {
"+" | "-" | "*" | "/" => {
// 꼭대기가 오른쪽 피연산자 — 순서 주의 ★
let (Some(b), Some(a)) = (st.pop(), st.pop()) else {
return Err(format!("피연산자 부족: {t}"));
};
st.push(match t {
"+" => a + b,
"-" => a - b,
"*" => a * b,
_ => {
if b == 0 { return Err("0으로 나눔".into()); }
a / b
}
});
}
n => st.push(n.parse::<i64>().map_err(|e| format!("{n}: {e}"))?),
}
}
match st.as_slice() {
[x] => Ok(*x), // 정확히 하나 남아야 정상
_ => Err(format!("식이 잘못됨: 남은 값 {}개", st.len())),
}
}
fn main() {
println!("{:?}", eval_rpn(&["3", "4", "+", "2", "*"])); // Ok(14)
println!("{:?}", eval_rpn(&["10", "3", "-", "4", "/"])); // Ok(1)
println!("{:?}", eval_rpn(&["1", "+"])); // Err("피연산자 부족: +")
println!("{:?}", eval_rpn(&["4", "0", "/"])); // Err("0으로 나눔")
println!("{:?}", eval_rpn(&["1", "2"])); // Err("식이 잘못됨: 남은 값 2개")
}
상황별로 고르는 표
| 쓰는 법 | 언제 |
|---|---|
while let Some(x) = s.pop() | 비울 때까지 처리 — 가장 흔함 |
let Some(x) = s.pop() else { … } | 비면 함수를 빠져나갈 때 (1.65+) |
s.pop()? | Option 을 돌려주는 함수 안 |
s.pop().ok_or(MyErr::Empty)? | Result 함수 — 에러로 바꿔 올림 |
s.pop().unwrap_or_default() | 비면 기본값이 의미상 맞을 때 |
s.pop().expect("…") | 논리상 절대 빌 수 없을 때만 — 이유를 문장으로 |
s.pop().unwrap() | 테스트·예제 외에는 피하기 (SQ 10) |
// expect 를 쓸 자격이 있는 경우 — 바로 위에서 넣었다
stack.push(start);
let first = stack.pop().expect("방금 push 했으므로 비어 있을 수 없음");
// Option 을 돌려주는 함수에서는 ? 한 글자로 끝
fn top_two(s: &mut Vec<i32>) -> Option<(i32, i32)> {
let a = s.pop()?;
let b = s.pop()?; // 하나뿐이면 여기서 None — a 는 이미 빠짐 ★
Some((a, b))
}
top_two 의 함정 — 원소가 하나면 a 를 꺼낸 뒤 None 으로 빠져나가
스택에서 값 하나가 사라집니다. 실패 시 원상 복구가 필요하면 먼저 s.len() >= 2 를 확인하거나
s.split_off(s.len() - 2) 처럼 한 번에 가져오세요.
실무에선 이렇게 — newtype 으로 API 를 좁히고, 재귀를 스택으로 바꾸기 🔴 실무
중간을 못 건드리는 진짜 스택 — newtype
/// 꼭대기만 만질 수 있는 스택. Vec 의 나머지 메서드는 숨깁니다.
#[derive(Debug)]
pub struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
pub fn new() -> Self { Self { items: Vec::new() } }
pub fn with_capacity(n: usize) -> Self { Self { items: Vec::with_capacity(n) } }
pub fn push(&mut self, x: T) { self.items.push(x) }
pub fn pop(&mut self) -> Option<T> { self.items.pop() }
pub fn peek(&self) -> Option<&T> { self.items.last() }
pub fn len(&self) -> usize { self.items.len() }
pub fn is_empty(&self) -> bool { self.items.is_empty() }
}
impl<T> Default for Stack<T> { // clippy::new_without_default 대응
fn default() -> Self { Self::new() }
}
// 꼭대기부터 도는 반복자만 공개 — 순서 혼동을 원천 차단
impl<T> Stack<T> {
pub fn iter_top_down(&self) -> impl Iterator<Item = &T> {
self.items.iter().rev()
}
}
Vec 과 크기·배치가 같고, 메서드는 인라인됩니다.
자바 Stack 이 Vector 를 상속해서 insertElementAt 같은 걸 다 노출한 것과 정반대로,
Rust 는 감싸서 필요한 것만 꺼내 보입니다(🔐 o05).
재귀 대신 명시적 스택 — 깊은 트리에서 스택 오버플로 방지
struct Node {
val: i32,
children: Vec<Node>,
}
// 재귀 버전 — 깊이 10만짜리 사슬이면 스택(리눅스 메인 8MB · 윈도우 1MB)을 넘칠 수 있음
fn sum_rec(n: &Node) -> i64 {
n.val as i64 + n.children.iter().map(sum_rec).sum::<i64>()
}
// 반복 버전 — 힙에 있는 Vec 이 호출 스택 역할
fn sum_iter(root: &Node) -> i64 {
let mut stack: Vec<&Node> = vec![root];
let mut total = 0i64;
while let Some(n) = stack.pop() {
total += n.val as i64;
stack.extend(n.children.iter()); // &Node 만 쌓음 — 복사 없음
}
total
}
fn main() {
let tree = Node { val: 1, children: vec![
Node { val: 2, children: vec![] },
Node { val: 3, children: vec![Node { val: 4, children: vec![] }] },
]};
println!("{} {}", sum_rec(&tree), sum_iter(&tree)); // 10 10
}
stack.extend(n.children.iter().rev()). 그래야 첫 자식이 꼭대기에 와서 먼저 나옵니다.
또 하나, 깊은 트리는 drop 도 재귀라 해제할 때 넘칠 수 있습니다 — 그때는 Drop 을 직접 구현해
자식을 std::mem::take 로 빼서 같은 스택 방식으로 풉니다.
한 단계 더 — 힙 할당이 없는 스택 🧬 전문가
크기가 작고 상한이 있으면 — arrayvec · smallvec
// Cargo.toml: arrayvec = "0.7", smallvec = "1"
use arrayvec::ArrayVec;
use smallvec::SmallVec;
// ArrayVec — 용량이 타입에 박힌 스택. 힙을 전혀 쓰지 않습니다
let mut s: ArrayVec<u32, 16> = ArrayVec::new();
s.push(1); // 꽉 차 있으면 패닉
assert!(s.try_push(2).is_ok()); // 꽉 차면 Err(CapacityError) — 실무는 이쪽
s.pop(); // Option<u32>
// SmallVec — 8개까지는 인라인, 넘치면 힙으로 '넘쳐 흐름'
let mut t: SmallVec<[u32; 8]> = SmallVec::new();
for i in 0..10 { t.push(i); }
println!("{}", t.spilled()); // true (8개를 넘어 힙으로 옮겨 감)
언제 이득인가
| 상황 | 고를 것 |
|---|---|
| 깊이 상한이 분명 (파서 · 상태 기계) | ArrayVec<T, N> |
| 대부분 작고 가끔 큼 | SmallVec<[T; N]> |
| 함수 호출마다 새로 만듦 | 재사용 — clear() 후 다시 씀 |
no_std · 임베디드 | heapless::Vec<T, N> |
| 그 밖 전부 | 그냥 Vec |
Vec::new() 를 하고 있을 때가 아니면
차이가 안 납니다. 제일 싼 최적화는 스택 Vec 을 루프 밖으로 빼서 clear() 로 재사용하는 것입니다
— 용량이 남아 있어 두 번째부터는 할당이 0 입니다(⚡ e02).
Vec 입니다. push · pop · last 세 개가 전부이고,
pop() 의 Option 은 while let · let-else · ? 로 받습니다.
중간 접근을 막고 싶으면 newtype, 순회는 바닥부터라는 것만 기억하면 다른 언어의 스택 함정은 대부분 사라집니다.큐 (FIFO) — VecDeque 의 뒤로 넣고 앞으로 뺍니다
큐는 std::collections::VecDeque<T> 하나로 끝납니다. push_back 으로 넣고 pop_front 로 꺼내면 둘 다 O(1) 입니다.
문제는 Vec 으로도 "되는 것처럼 보인다"는 점입니다 — v.remove(0) 은 컴파일도 되고 테스트도 통과하지만,
꺼낼 때마다 나머지 전부를 한 칸씩 당깁니다. 파이썬의 list.pop(0), JS 의 Array.shift() 와 똑같은 함정입니다.
기본 큐 — push_back · pop_front · front
use std::collections::VecDeque;
fn main() {
let mut q: VecDeque<&str> = VecDeque::new();
q.push_back("a");
q.push_back("b");
q.push_back("c");
println!("{:?}", q.front()); // Some("a") ← peek
println!("{:?}", q.pop_front()); // Some("a")
println!("{q:?}"); // ["b", "c"]
println!("{} {:?}", q.len(), q.get(1)); // 2 Some("c")
println!("{}", q[0]); // b ← 인덱싱도 O(1)
while let Some(x) = q.pop_front() {
print!("{x} "); // b c
}
println!();
println!("{:?}", q.pop_front()); // None
}
VecDeque::new() · VecDeque::with_capacity(n) ·
VecDeque::from([1, 2, 3]) · iter.collect::<VecDeque<_>>().
이미 있는 Vec 은 VecDeque::from(v) 로 바꾸면 버퍼를 그대로 물려받아 O(1) 입니다(복사 없음).
"실패하면?" — Rust 의 API 쌍
| 연산 | 패닉하는 쪽 | 값으로 알려 주는 쪽 |
|---|---|---|
| 꺼내기 | pop_front().unwrap() | pop_front() → Option<T> |
| 맨 앞 보기 | q[0] | front() → Option<&T> |
| i 번째 | q[i] | get(i) · get_mut(i) |
| i 번째 빼기 | — | remove(i) → Option<T> |
| 넣기 | push_back (할당 실패 시 abort) | try_reserve(1)? 후 push |
Queue 는 remove()/poll(), element()/peek() 처럼
같은 일을 두 이름으로 나눴습니다. Rust 는 메서드가 하나이고 결과가 Option 이라,
"예외 쪽"을 원하면 호출하는 사람이 .expect() 를 눈에 보이게 붙입니다.
넣기는 실패하지 않습니다 — 메모리가 바닥나면 프로세스가 중단되므로, 그게 싫은 서버 코드만 try_reserve 를 씁니다.
이렇게도 쓸 수 있다 — 큐로 쓰면 안 되는 것들 🟡 중급
❌ Vec 으로 큐 흉내 — 전부 O(n)
let mut q: Vec<Job> = Vec::new();
q.push(job); // 뒤에 넣기 — 여기까진 괜찮음
let next = q.remove(0); // ❌ 나머지 전부를 한 칸씩 당김 · 비면 패닉
q.insert(0, urgent); // ❌ 앞에 넣기도 전부 밀어냄
let first: Vec<_> = q.drain(..1).collect(); // ❌ 이름만 다른 remove(0)
// ❌ '앞에서 꺼낸 척' — head 인덱스만 올리기
let mut head = 0;
while head < q.len() {
let job = &q[head];
head += 1;
// … job 처리 중 q.push(..) 를 하면 빌림 에러,
// 끝까지 돌아도 이미 처리한 원소의 메모리가 그대로 남습니다
}
✅ VecDeque — 앞에서 빼도 O(1)
use std::collections::VecDeque;
let mut q: VecDeque<Job> = VecDeque::new();
q.push_back(job); // 뒤에 넣기
let next = q.pop_front(); // ✅ O(1) · Option 으로 안전
q.push_front(urgent); // ✅ 앞에 넣기도 O(1) (새치기)
// 원소를 처리하며 새 원소를 넣어도 문제없음
while let Some(job) = q.pop_front() { // job 은 '꺼낸 값' — 빌림 아님
for child in job.spawn_children() {
q.push_back(child); // ✅ q 는 지금 아무도 빌리지 않음
}
}
// 'head 인덱스 + Vec' 을 제대로 만든 것이 바로 VecDeque 의 원형 버퍼입니다 (SQ 04)
remove(0) 의 memmove 가 캐시 안에서 끝나 충분히 빠릅니다. 하지만 "항상 작다"는 가정은 운영에서 자주 깨지므로,
큐라는 의도가 있으면 처음부터 VecDeque 로 쓰는 것이 코드를 읽는 사람에게도 정직합니다.
실무에선 이렇게 — 큐를 구조체에 넣고, 한꺼번에 넣고 빼기 🔴 실무
작업 큐 구조체 — 소유권 그대로
use std::collections::VecDeque;
#[derive(Debug)]
struct Job { id: u32, retries: u8 }
struct Dispatcher {
pending: VecDeque<Job>,
done: Vec<u32>,
}
impl Dispatcher {
fn submit_all(&mut self, jobs: impl IntoIterator<Item = Job>) {
self.pending.extend(jobs); // 뒤에 한꺼번에
}
fn run(&mut self) {
while let Some(mut job) = self.pending.pop_front() {
if job.id % 3 == 0 && job.retries == 0 {
job.retries += 1;
self.pending.push_back(job); // 실패 → 맨 뒤로 다시
continue;
}
self.done.push(job.id);
}
}
}
fn main() {
let mut d = Dispatcher { pending: VecDeque::new(), done: Vec::new() };
d.submit_all((1..=6).map(|id| Job { id, retries: 0 }));
d.run();
println!("{:?}", d.done); // [1, 2, 4, 5, 3, 6]
}
pop_front() 는 값을 소유권째 꺼냅니다.
job 은 더 이상 큐 안에 있지 않으니 self.pending.push_back(job) 을 해도 겹치는 빌림이 없습니다.
"꺼내서 처리하고 필요하면 다시 넣는다" 가 Rust 큐 코드의 기본 리듬입니다.
한꺼번에 — extend · drain · retain · split_off
use std::collections::VecDeque;
fn main() {
let mut q: VecDeque<u32> = (1..=10).collect();
// 앞에서 최대 3개를 배치로 꺼내기 — 남은 게 적어도 안전
let n = q.len().min(3);
let batch: Vec<u32> = q.drain(..n).collect();
println!("{batch:?}"); // [1, 2, 3]
q.retain(|&x| x % 2 == 0); // 조건에 맞는 것만 남김 (순서 유지)
println!("{q:?}"); // [4, 6, 8, 10]
let tail = q.split_off(2); // 2번째부터 떼어 새 덱으로
println!("{q:?} {tail:?}"); // [4, 6] [8, 10]
q.extend([100, 200]); // 뒤에 여러 개
q.append(&mut VecDeque::from([7])); // 다른 덱을 통째로 이어 붙임
println!("{q:?}"); // [4, 6, 100, 200, 7]
// Vec 으로 돌려받기 — 앞이 버퍼 시작이 아니면 O(n) 이동은 있지만 재할당은 없음
let v: Vec<u32> = q.into();
println!("{v:?}"); // [4, 6, 100, 200, 7]
}
drain(..3) 을 그냥 쓰면 원소가 2개뿐일 때 패닉합니다(범위가 len 을 넘음).
위처럼 len().min(k) 로 자르거나, 반복자로 std::iter::from_fn(|| q.pop_front()).take(k) 처럼 꺼내세요.
한 단계 더 — 바이트 큐로 쓰는 VecDeque<u8> 🧬 전문가
Read · Write 를 구현합니다
use std::collections::VecDeque;
use std::io::{Read, Write};
fn main() -> std::io::Result<()> {
let mut buf: VecDeque<u8> = VecDeque::new();
buf.write_all(b"hello ")?; // Write — 뒤에 붙임
buf.write_all(b"world")?;
let mut first = [0u8; 6];
buf.read_exact(&mut first)?; // Read — 앞에서 소비
println!("{:?} {}", std::str::from_utf8(&first).unwrap(), buf.len());
// "hello " 5
Ok(())
}
bytes::BytesMut 가 사실상 표준입니다(🧵 h05 제로카피).
메모리를 직접 다룰 때의 선택지
| 필요 | 방법 |
|---|---|
| OOM 을 에러로 받고 싶다 | q.try_reserve(n)? (1.57+) |
| 다 쓴 뒤 메모리 반납 | q.shrink_to_fit() · shrink_to(n) |
| 버퍼를 다시 쓰고 싶다 | q.clear() — 용량은 유지 |
| 슬라이스로 정렬·검색 | q.make_contiguous().sort() (SQ 04) |
| 정렬된 덱에서 찾기 | q.binary_search(&x) |
shrink_to(1024) 처럼 상한을 정해서 줄이는 것이 실무 감각입니다.
매번 shrink_to_fit() 하면 다음 폭주 때 다시 할당을 반복합니다.
VecDeque 의 push_back + pop_front 입니다.
Vec::remove(0) · insert(0, _) · drain(..1) 은 모두 O(n) 이라 큐가 아닙니다.
꺼낸 값은 소유권째 받으므로, 처리하다 다시 넣어도 빌림 에러가 나지 않습니다.덱의 속 — 원형 버퍼와 as_slices() 의 두 조각
VecDeque 는 버퍼 한 장 위에서 시작 위치(head)와 길이(len)만 움직입니다. 앞에서 빼면 head 가 한 칸 오른쪽으로,
뒤가 끝에 닿으면 버퍼 처음으로 감겨(wrap) 들어갑니다. 그래서 논리적으로는 한 줄인데 메모리에서는 두 토막일 수 있고,
이것이 as_slices() 가 슬라이스를 두 개 돌려주는 이유입니다. 이 구조를 알면 어떤 연산이 O(1) 이고 어떤 게 O(n) 인지 외울 필요가 없습니다.
as_slices() 의 정체입니다.그림을 코드로 — 직접 확인하기
use std::collections::VecDeque;
fn main() {
let mut d: VecDeque<i32> = VecDeque::with_capacity(4);
d.extend([1, 2, 3, 4]);
for _ in 0..3 { d.pop_front(); } // 1 2 3 꺼냄 → head 가 3 으로
d.extend([5, 6, 7]); // 뒤가 끝이라 물리 0·1·2 로 감김
println!("{d:?}"); // [4, 5, 6, 7] ← 논리 순서
println!("{:?}", d.as_slices()); // ([4], [5, 6, 7])
println!("cap={}", d.capacity()); // cap=4
d.push_back(8); // 가득 → 용량 8, 짧은 쪽만 이동
println!("{:?}", d.as_slices()); // ([4], [5, 6, 7, 8])
println!("cap={}", d.capacity()); // cap=8
d.make_contiguous(); // 한 줄로 펴기
println!("{:?}", d.as_slices()); // ([4, 5, 6, 7, 8], [])
}
// as_slices 의 경계와 capacity 값은 '현재 구현'의 결과입니다.
// 문서가 보장하는 것은 두 조각을 이어 붙이면 논리 순서라는 것뿐 —
// 코드가 경계 위치에 기대면 안 됩니다.
두 조각을 다루는 세 가지 방법
use std::collections::VecDeque;
use std::io::Write;
fn dump(d: &VecDeque<u8>, out: &mut impl Write) -> std::io::Result<()> {
// ① 조각마다 처리 — 복사·재배치 0 ★ 가장 좋음
let (a, b) = d.as_slices();
out.write_all(a)?;
out.write_all(b)?;
Ok(())
}
fn main() {
let mut d: VecDeque<i32> = VecDeque::from([3, 1, 2]);
d.push_front(9);
// ② 반복자 — 두 조각을 알아서 이어 줍니다
let s: i32 = d.iter().sum();
println!("{s}"); // 15
// ③ 정렬처럼 '연속된 한 장'이 꼭 필요할 때만 펴기
d.make_contiguous().sort(); // &mut [i32] 를 돌려줌
println!("{d:?}"); // [1, 2, 3, 9]
println!("{:?}", d.binary_search(&3)); // Ok(2)
let mut out = Vec::new();
dump(&VecDeque::from(*b"hi"), &mut out).unwrap();
println!("{out:?}"); // [104, 105]
}
VecDeque 는
Deref<Target=[T]> 가 아닙니다 — 연속이라는 보장이 없으니까요. 함수를
impl IntoIterator<Item = &T> 로 받게 고치거나, 정말 필요할 때만 make_contiguous() 를 부릅니다.
| 연산 | 비용 | 왜 그런가 (원형 버퍼 기준) |
|---|---|---|
push_back · push_front | 분할 상환 O(1) | 빈 칸에 쓰고 head/len 조정 · 가득 차면 두 배로 늘리며 짧은 토막만 이동 |
pop_back · pop_front | O(1) | 값을 꺼내고 head 또는 len 만 조정 |
front · back · get(i) · [i] | O(1) | 물리 위치 = (head + i) mod cap — 계산 한 번 |
insert(i, x) · remove(i) | O(min(i, n−i)) | 가까운 쪽 끝의 원소들만 민다 |
swap_remove_back(i) | O(1) | 맨 뒤 원소로 구멍을 메움 (순서 포기) |
rotate_left(k) | O(min(k, n−k)) | 짧은 쪽을 반대편으로 옮김 |
as_slices | O(1) | 경계만 계산 |
make_contiguous | O(n) | 이미 연속이면 사실상 0 |
contains · iter().position | O(n) | 선형 탐색 — 자주 찾으면 HashSet 을 곁에 |
VecDeque::from(vec) | O(1) | 버퍼를 그대로 물려받음 |
Vec::from(deque) | O(n) 이동 · 재할당 없음 | 감겨 있으면 제자리에서 폄 |
이렇게도 쓸 수 있다 — 양끝 · 회전 · 범위 🟡 중급
덱 연산 한 바퀴
use std::collections::VecDeque;
fn main() {
let mut d: VecDeque<i32> = (1..=6).collect(); // [1, 2, 3, 4, 5, 6]
d.rotate_left(2); // [3, 4, 5, 6, 1, 2]
d.rotate_right(1); // [2, 3, 4, 5, 6, 1]
let mid: Vec<i32> = d.range(1..4).copied().collect();
println!("{mid:?}"); // [3, 4, 5]
let taken: Vec<i32> = d.drain(..2).collect();
println!("{taken:?} {d:?}"); // [2, 3] [4, 5, 6, 1]
d.truncate(2); // ★ '앞쪽' 2개를 남김
d.swap(0, 1);
println!("{d:?}"); // [5, 4]
if let Some(b) = d.back_mut() { *b = 40; }
d.push_front(0);
println!("{:?} {:?}", d.front(), d.back()); // Some(0) Some(40)
}
rotate_left(k) 는 k > len 이면 패닉합니다(나머지 연산을 대신 해 주지 않음).
바퀴 수가 클 수 있으면 d.rotate_left(k % d.len()) 로 — 단, len == 0 이면 0 으로 나눔이니 먼저 확인.
range(a..b) · drain(a..b) 도 범위가 len 을 넘으면 패닉입니다.
앞·뒤 짝 맞춰 외우기
| 일 | 앞 | 뒤 |
|---|---|---|
| 넣기 | push_front | push_back |
| 빼기 | pop_front | pop_back |
| 보기 | front | back |
| 고치기 | front_mut | back_mut |
| 구멍 메우기 | swap_remove_front | swap_remove_back |
| 돌리기 | rotate_left | rotate_right |
| 순회 | iter() | iter().rev() |
push_back + pop_back). 하지만 앞쪽을 안 쓴다면
Vec 이 더 단순하고 슬라이스로 바로 쓰입니다. 자바처럼 "스택도 덱으로" 가 아니라
Rust 는 "앞이 필요할 때만 덱" 입니다.
실무에선 이렇게 — 용량을 알고 쓰기 🔴 실무
1.67 에서 무엇이 바뀌었나
| 1.66 까지 | 1.67 부터 | |
|---|---|---|
| 필드 | tail · head 두 인덱스 | head · len |
| 용량 | 2의 거듭제곱 · 한 칸은 늘 비움 | 제약 없음 |
with_capacity(1000) | 실제 1024 근처 | 요청한 만큼 (보통 정확히) |
VecDeque::from(vec) | 용량을 맞추느라 재할당될 수 있음 | O(1) — 버퍼 그대로 |
다른 언어의 덱은 어떻게 생겼나
| 구현 | 구조 | Rust 와 다른 점 |
|---|---|---|
Rust VecDeque | 원형 버퍼 한 장 | — |
C++ std::deque | 고정 크기 블록 + 블록 포인터 맵 | 양끝 삽입 시 원소 주소가 안 바뀜 · 인덱싱이 한 번 더 간접 |
Python deque | 64칸 블록의 이중 연결 리스트 | 인덱싱이 양끝 근처만 빠름 |
Java ArrayDeque | 원형 배열 | null 금지 · 박싱된 참조만 |
& 로 빌린 채
push_back 하는 코드 자체가 컴파일되지 않습니다. 재할당으로 주소가 바뀌어도 그걸 들고 있는 사람이 없으니,
더 단순하고 빠른 원형 버퍼를 고를 수 있었던 겁니다.
VecDeque 는 원형 버퍼 + head + len 입니다. 양끝 연산과 인덱싱은 O(1),
중간 삽입·삭제는 가까운 끝까지의 거리만큼. 감기면 메모리가 두 조각이라 as_slices() 가 둘을 주고,
한 장이 꼭 필요할 때만 make_contiguous() 로 폅니다.우선순위 큐 — BinaryHeap 은 최대 힙, 최소는 Reverse
BinaryHeap<T> 는 T: Ord 인 값 중 가장 큰 것을 먼저 꺼냅니다. 자바 PriorityQueue ·
파이썬 heapq · C# PriorityQueue 는 모두 가장 작은 것이 먼저라 옮겨 오는 사람마다 한 번씩 넘어집니다.
비교 함수를 넘기는 생성자가 없고, 순서는 타입의 Ord 구현이 정한다는 것도 Rust 만의 방식입니다.
그래서 최소 힙은 std::cmp::Reverse 로 감싸고, 여러 키는 Ord 를 직접 구현합니다.
기본 — 최대 힙 · Reverse 로 최소 힙
use std::cmp::Reverse;
use std::collections::BinaryHeap;
fn main() {
let mut h = BinaryHeap::from(vec![3, 1, 4, 1, 5, 9, 2, 6]); // O(n) 정리
println!("{:?}", h.peek()); // Some(9)
println!("{:?}", h.pop()); // Some(9)
println!("{:?}", h.pop()); // Some(6)
println!("{}", h.len()); // 6
// 최소 힙 — 값을 Reverse 로 감싸 넣고, 꺼낼 때 패턴으로 벗김
let mut mh = BinaryHeap::new();
for x in [3, 1, 4, 1, 5] { mh.push(Reverse(x)); }
while let Some(Reverse(x)) = mh.pop() {
print!("{x} "); // 1 1 3 4 5
}
println!();
// 정렬해서 다 꺼내기 — ★ 오름차순입니다 (pop 순서와 반대)
let sorted = BinaryHeap::from(vec![5, 2, 8, 1]).into_sorted_vec();
println!("{sorted:?}"); // [1, 2, 5, 8]
// iter() · {:?} 는 '배열 순서' — 정렬돼 있지 않습니다
let h2 = BinaryHeap::from(vec![3, 1, 4, 1, 5, 9, 2, 6]);
println!("{:?}", h2.into_vec()); // [9, 6, 4, 1, 5, 3, 2, 1] (구현 세부)
}
연산 비용 — 공식 문서 기준
| 메서드 | 비용 |
|---|---|
peek · peek_mut | O(1) |
push | 기대 O(1) · 최악 O(log n) |
pop | O(log n) |
BinaryHeap::from(vec) | O(n) |
into_sorted_vec | O(n log n) |
into_vec | O(1) — 순서 보장 없음 |
retain (1.70+) | O(n) — 끝나면 다시 정리 |
iter · drain | 임의 순서 |
Reverse 없이 넣는 경우가 그렇습니다.
이렇게도 쓸 수 있다 — 여러 키 · 같은 우선순위 · 실수(f64) 🟡 중급
Ord 직접 구현 — 우선순위 + 들어온 순서
use std::cmp::Ordering;
use std::collections::BinaryHeap;
#[derive(Debug, PartialEq, Eq)]
struct Task { pri: u8, seq: u64, name: String }
impl Ord for Task {
fn cmp(&self, other: &Self) -> Ordering {
self.pri.cmp(&other.pri) // 우선순위 높을수록 '큼'
.then_with(|| other.seq.cmp(&self.seq)) // 같으면 먼저 온 것이 '큼'
}
}
impl PartialOrd for Task {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other)) // ★ 반드시 cmp 에 위임
}
}
fn main() {
let mut q = BinaryHeap::new();
let mut seq = 0u64;
for (pri, name) in [(1, "정리"), (3, "결제"), (3, "환불"), (2, "메일")] {
q.push(Task { pri, seq, name: name.to_string() });
seq += 1;
}
while let Some(t) = q.pop() { print!("{} ", t.name); }
println!(); // 결제 환불 메일 정리
}
seq 없이
pri 만 비교하면 "결제"와 "환불" 중 무엇이 먼저 나올지 아무도 약속하지 않습니다.
FIFO 가 필요하면 단조 증가하는 순번을 두 번째 키로 넣는 것이 정석입니다.
여기서는 seq 가 유일해서 derive 한 PartialEq(이름까지 비교)와 Ord 가 어긋나지 않습니다.
f64 는 Ord 가 아니다 — total_cmp 로 감싸기
use std::cmp::Ordering;
use std::collections::BinaryHeap;
// let h: BinaryHeap<f64>; ❌ the trait bound `f64: Ord` is not satisfied
// NaN 때문에 f64 는 PartialOrd 까지만 구현합니다
#[derive(Debug, Clone, Copy)]
struct Score(f64);
impl PartialEq for Score {
fn eq(&self, o: &Self) -> bool { self.0.total_cmp(&o.0).is_eq() }
}
impl Eq for Score {}
impl PartialOrd for Score {
fn partial_cmp(&self, o: &Self) -> Option<Ordering> { Some(self.cmp(o)) }
}
impl Ord for Score {
fn cmp(&self, o: &Self) -> Ordering { self.0.total_cmp(&o.0) } // 1.62+
}
fn main() {
let mut h: BinaryHeap<Score> =
[0.5, -0.0, 2.5, 0.0, -1.0].into_iter().map(Score).collect();
while let Some(Score(x)) = h.pop() { print!("{x} "); }
println!(); // 2.5 0.5 0 -0 -1 ← -0.0 < +0.0 로 구분
}
// total_cmp 의 순서: 음의 NaN < -∞ < … < -0.0 < +0.0 < … < +∞ < 양의 NaN
// NaN 이 섞여도 패닉하지 않고 어딘가에 '정해진 자리'를 받습니다
// 크레이트로 해결: ordered-float 의 OrderedFloat<f64> · NotNan<f64>
// NotNan 은 만들 때 NaN 을 거부합니다 — 점수·거리처럼 NaN 이 '버그'인 값에
PartialEq 를 derive 하지 않은 이유 — derive 하면 == 는 IEEE 규칙(NaN ≠ NaN, -0.0 == 0.0)을 쓰고
cmp 는 total_cmp 규칙을 써서 둘이 어긋납니다. Eq 와 Ord 가 서로 다른 말을 하면
힙·정렬·BTreeMap 이 이상하게 동작합니다. 같은 비교 함수 하나에서 전부 파생시키세요.
실무에선 이렇게 — top-k · 꼭대기 수정 · 다익스트라 🔴 실무
top-k 와 peek_mut
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::collections::binary_heap::PeekMut;
/// 가장 큰 k 개 — 크기 k 의 '최소' 힙을 유지 (메모리 O(k))
fn top_k(nums: &[i32], k: usize) -> Vec<i32> {
let mut h = BinaryHeap::with_capacity(k + 1);
for &x in nums {
h.push(Reverse(x));
if h.len() > k { h.pop(); } // 가장 작은 것을 버림
}
let mut out: Vec<i32> = h.into_iter().map(|Reverse(x)| x).collect();
out.sort_unstable_by(|a, b| b.cmp(a));
out
}
fn main() {
println!("{:?}", top_k(&[5, 1, 9, 3, 7, 9, 2], 3)); // [9, 9, 7]
// peek_mut — 꼭대기를 그 자리에서 고치면, drop 될 때 제자리로 내려감
let mut h = BinaryHeap::from(vec![10, 7, 3]);
if let Some(mut top) = h.peek_mut() {
*top = 1; // 10 → 1
}
println!("{:?}", h.peek()); // Some(7)
// 조건이 맞을 때만 꺼내기 — peek 후 pop 을 따로 하지 않음
if let Some(top) = h.peek_mut() {
if *top > 5 {
let v = PeekMut::pop(top);
println!("꺼냄 {v}"); // 꺼냄 7
}
}
println!("{:?}", h.into_sorted_vec()); // [1, 3]
}
v.select_nth_unstable(i) 가 평균 O(n) 으로 더 빠릅니다.
힙 top-k 는 스트림처럼 값이 계속 들어올 때 쓰는 도구입니다.
다익스트라 — 완전한 예제
use std::cmp::Reverse;
use std::collections::BinaryHeap;
fn dijkstra(adj: &[Vec<(usize, u64)>], start: usize) -> Vec<Option<u64>> {
let mut dist: Vec<Option<u64>> = vec![None; adj.len()];
let mut heap = BinaryHeap::new();
dist[start] = Some(0);
heap.push(Reverse((0u64, start))); // (거리, 노드) — 튜플은 사전순 비교
while let Some(Reverse((d, u))) = heap.pop() {
if dist[u].is_some_and(|best| d > best) {
continue; // ★ 낡은 항목 — 더 짧은 길을 이미 찾음
}
for &(v, w) in &adj[u] {
let nd = d + w;
if dist[v].is_none_or(|best| nd < best) { // 1.82+
dist[v] = Some(nd);
heap.push(Reverse((nd, v))); // decrease-key 대신 '새로 넣기'
}
}
}
dist
}
fn main() {
let mut adj: Vec<Vec<(usize, u64)>> = vec![Vec::new(); 6];
for (u, v, w) in [(0, 1, 4), (0, 2, 1), (2, 1, 2), (1, 3, 1), (2, 3, 5), (3, 4, 3)] {
adj[u].push((v, w));
}
for (i, d) in dijkstra(&adj, 0).iter().enumerate() {
match d {
Some(d) => println!("{i}: {d}"),
None => println!("{i}: 도달 불가"),
}
}
}
// 0: 0
// 1: 3 (0→2→1)
// 2: 1
// 3: 4 (0→2→1→3)
// 4: 7
// 5: 도달 불가
decrease_key 가 없습니다.
그래서 새 항목을 하나 더 넣고, 꺼낼 때 낡은 것을 건너뛰는(lazy deletion) 위 방식이 표준입니다.
continue 줄을 빼먹으면 답은 맞아도 같은 노드를 여러 번 펼쳐 느려집니다.
한 단계 더 — 넣은 뒤에 순서가 바뀌면 🧬 전문가
힙 안에서 키가 바뀌는 세 가지 경로
① &mut 로 직접 고치기 → 불가능
BinaryHeap 은 iter_mut 을 주지 않습니다. 고칠 수 있는 건
peek_mut 로 받은 꼭대기뿐이고, 그건 drop 때 다시 정리됩니다.
② 내부 가변성 (Cell · RefCell · Arc<Mutex>) 으로 키를 바꾸기
컴파일은 됩니다. 하지만 공식 문서의 표현으로 '논리 오류' —
pop 순서가 틀어지거나 엉뚱한 값이 나옵니다.
메모리 안전(UB)은 깨지지 않고 그 힙 안에서만 망가집니다.
③ Ord 구현이 일관되지 않음 (a<b 이고 b<a 등)
②와 같은 결과. 랜덤·시간에 의존하는 cmp 가 대표적입니다.
→ 키는 넣을 때 '값으로 굳혀서' 넣습니다 (우선순위를 복사해 튜플로).
→ 바뀌는 우선순위가 핵심이면 priority-queue 크레이트
(change_priority 지원) 나 인덱스 힙을 씁니다.
고르는 표 — 힙이 아닐 때
| 필요 | 도구 |
|---|---|
| 최대 · 최소 하나씩만 | BinaryHeap |
| 최소와 최대를 둘 다 꺼냄 | BTreeMap · BTreeSet (pop_first · pop_last) |
| 같은 키 중복 + 순서 | BTreeMap<K, VecDeque<V>> |
| 우선순위 변경이 잦음 | priority-queue 크레이트 |
| 정해진 시각에 꺼냄 | tokio_util::time::DelayQueue (SQ 12) |
| 스레드 여럿이 공유 | Mutex<BinaryHeap> — 락 하나면 충분한 경우가 대부분 |
BTreeSet 은 중복을 하나로 합칩니다 — 자바에서 TreeSet 으로 우선순위 큐를 흉내 내다 같은 값이 사라지는 사고와 똑같습니다.
중복이 가능하면 (우선순위, 순번) 처럼 유일한 키로 만드세요.
BinaryHeap 은 최대 힙이고 최소는 Reverse 입니다. 여러 키는 Ord 를 직접 구현하고
PartialOrd 는 cmp 에 위임, 같은 우선순위의 순서가 필요하면 순번 키, f64 는 total_cmp.
힙 안의 값은 못 고치니 다익스트라처럼 다시 넣고 낡은 것은 건너뜁니다.스레드 안전 큐 — 채널이 기본, 직접 만들면 Mutex + Condvar
자바라면 ArrayBlockingQueue 를 꺼낼 자리에서 Rust 는 채널을 씁니다. 보내는 쪽(Sender)과 받는 쪽(Receiver)이
처음부터 갈라져 있어 "누가 넣고 누가 빼나"가 타입에 드러나고, 보내는 쪽이 모두 사라지면 받는 쪽 루프가 저절로 끝납니다 — 종료 신호를 따로 만들 일이 거의 없습니다.
채널 기초(mpsc · crossbeam 맛보기)는 ⚡ 성능 탭 e07에 있으니, 여기서는 용량 · 종료 · 워커 풀 · 직접 구현을 깊게 봅니다.
| 도구 | 모양 | 용량 | 비었을 때 / 찼을 때 | 고르는 기준 |
|---|---|---|---|---|
std::sync::mpsc::channel | N → 1 | 무제한 | recv 블록 / 안 참 | 의존성 0 · 소비자가 하나 · 생산 속도가 확실히 느릴 때 |
std::sync::mpsc::sync_channel(n) | N → 1 | n (0 = 랑데부) | recv 블록 / send 블록 | 표준만으로 배압이 필요할 때 |
crossbeam_channel::bounded(n) | N → M | n | 블록 / 블록 · select! | 워커 풀 — 수신자를 clone 해서 나눠 받음 ★ |
Arc<Mutex<VecDeque>> + Condvar | 자유 | 직접 정함 | 직접 구현 | 우선순위 · 중복 제거 · 일괄 꺼내기처럼 채널에 없는 규칙이 필요할 때 |
crossbeam::queue::ArrayQueue | N → M | n 고정 | 안 기다림 — None / Err | 락프리 · 폴링하는 구조(게임 루프 · 오디오) |
crossbeam::queue::SegQueue | N → M | 무제한 | 안 기다림 | 락프리 무제한 — 메모리 상한을 따로 관리할 때만 |
std mpsc — 용량 · 송신자 drop 으로 종료
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::sync_channel::<String>(2); // 2개까지 쌓임
let mut handles = Vec::new();
for id in 0..3 {
let tx = tx.clone(); // 생산자마다 사본
handles.push(thread::spawn(move || {
for n in 0..2 {
// 가득 차면 여기서 멈춤 = 배압. 수신자가 사라졌으면 Err
tx.send(format!("p{id}-{n}")).unwrap();
}
})); // 스레드 끝 → 사본 drop
}
drop(tx); // ★ 원본도 버려야 '모든 송신자 사라짐'이 성립
let mut got: Vec<String> = rx.iter().collect(); // 송신자 0 + 비면 끝
got.sort(); // 도착 순서는 매번 다름
println!("{} {:?}", got.len(), got);
// 6 ["p0-0", "p0-1", "p1-0", "p1-1", "p2-0", "p2-1"]
for h in handles { h.join().unwrap(); }
}
drop(tx) 를 빼먹으면 영원히 끝나지 않습니다 — main 이 가진 원본 송신자가 살아 있으니
rx.iter() 는 "아직 누가 보낼 수 있다"며 계속 기다립니다. Rust 채널의 종료 신호는
"마지막 Sender 가 drop 됐다" 하나뿐이라, 송신자 사본이 어디에 남아 있는지가 곧 종료 설계입니다.
crossbeam-channel — 워커 풀 (MPMC)
// Cargo.toml: crossbeam-channel = "0.5"
use crossbeam_channel::bounded;
use std::thread;
fn main() {
let (job_tx, job_rx) = bounded::<u64>(64);
let (res_tx, res_rx) = bounded::<u64>(64);
let workers: Vec<_> = (0..4).map(|_| {
let job_rx = job_rx.clone(); // ★ 수신자도 복제 — 넷이 나눠 받음
let res_tx = res_tx.clone();
thread::spawn(move || {
for n in job_rx { // 일감 송신자가 다 사라지고 비면 끝
res_tx.send(n * n).unwrap();
}
})
}).collect();
drop(res_tx); // 결과 송신자는 워커 사본만 남김
// 보내기는 별도 스레드로 — main 이 보내기·받기를 둘 다 하면
// 두 bounded 채널이 서로 가득 차서 교착될 수 있습니다
let producer = thread::spawn(move || {
for n in 1..=100 { job_tx.send(n).unwrap(); }
}); // 끝나면 job_tx drop → 워커 종료
let total: u64 = res_rx.iter().sum();
producer.join().unwrap();
for w in workers { w.join().unwrap(); }
println!("{total}"); // 338350 (1²+…+100²)
}
Receiver 가 Clone 도 Sync 도 아니라서
Arc<Mutex<Receiver>> 로 감싸야 합니다(Rust 책의 스레드 풀 예제가 그 방식).
동작은 하지만 받을 때마다 락을 잡으니, 워커 풀이면 처음부터 crossbeam-channel 이 깔끔합니다.
이렇게도 쓸 수 있다 — 직접 만드는 유한 블로킹 큐 🟡 중급
Mutex<VecDeque> + Condvar 두 개 — 완전한 예제
use std::collections::VecDeque;
use std::sync::{Condvar, Mutex};
use std::thread;
struct Inner<T> { buf: VecDeque<T>, closed: bool }
pub struct BoundedQueue<T> {
inner: Mutex<Inner<T>>,
not_empty: Condvar,
not_full: Condvar,
cap: usize,
}
impl<T> BoundedQueue<T> {
pub fn new(cap: usize) -> Self {
assert!(cap > 0);
Self {
inner: Mutex::new(Inner { buf: VecDeque::with_capacity(cap), closed: false }),
not_empty: Condvar::new(),
not_full: Condvar::new(),
cap,
}
}
/// 가득 차면 기다림. 닫혔으면 값을 Err 로 돌려줌.
pub fn push(&self, item: T) -> Result<(), T> {
let g = self.inner.lock().unwrap();
let mut g = self.not_full
.wait_while(g, |s| s.buf.len() >= self.cap && !s.closed)
.unwrap(); // 깨어나도 조건을 다시 확인 (가짜 깨움 대비)
if g.closed { return Err(item); }
g.buf.push_back(item);
drop(g); // 락을 먼저 풀고 깨움
self.not_empty.notify_one();
Ok(())
}
/// 비면 기다림. 닫혔고 비었으면 None.
pub fn pop(&self) -> Option<T> {
let g = self.inner.lock().unwrap();
let mut g = self.not_empty
.wait_while(g, |s| s.buf.is_empty() && !s.closed)
.unwrap();
let item = g.buf.pop_front(); // 닫혔어도 남은 것은 꺼내 줌 (drain)
drop(g);
if item.is_some() { self.not_full.notify_one(); }
item
}
pub fn close(&self) {
self.inner.lock().unwrap().closed = true;
self.not_empty.notify_all(); // 잠든 모두를 깨워 종료를 알림
self.not_full.notify_all();
}
}
fn main() {
let q = BoundedQueue::<u64>::new(8);
let q = &q; // 스코프 스레드에 빌려줄 참조
let (count, sum) = thread::scope(|s| { // 1.63+ — Arc 가 필요 없음
let consumers: Vec<_> = (0..3).map(|_| s.spawn(move || {
let (mut n, mut sum) = (0usize, 0u64);
while let Some(x) = q.pop() { n += 1; sum += x; }
(n, sum)
})).collect();
let producers: Vec<_> = (0..2).map(|_| s.spawn(move || {
for i in 1..=100u64 { q.push(i).unwrap(); }
})).collect();
for p in producers { p.join().unwrap(); }
q.close(); // 생산 끝 → 닫기
consumers.into_iter()
.map(|h| h.join().unwrap())
.fold((0, 0), |(a, b), (n, s)| (a + n, b + s))
});
println!("소비 {count}개, 합계 {sum}"); // 소비 200개, 합계 10100
}
이 코드에서 지킨 규칙 다섯 개
| 규칙 | 어기면 |
|---|---|
wait_while 로 조건 재확인 | 가짜 깨움(spurious wakeup)에 빈 큐에서 꺼냄 |
| 조건 변수 두 개 (비었음 · 가득) | 하나로 하면 생산자가 생산자를 깨우는 헛수고 |
깨우기 전에 drop(g) | 깨어난 스레드가 곧바로 락에서 다시 막힘 |
close 는 notify_all | 잠든 소비자 일부가 영원히 안 깨어남 |
| 닫힌 뒤에도 남은 것은 꺼내 줌 | 종료할 때 마지막 일감이 사라짐 |
Send / Sync — 컴파일러가 검사하는 것
VecDeque<T> Send ⇐ T: Send · Sync ⇐ T: Sync
Mutex<VecDeque<T>> Send + Sync ⇐ T: Send (락이 접근을 한 명으로)
Rc<RefCell<..>> !Send · !Sync → 스레드로 넘기면 E0277
mpsc::Sender<T> Send · Sync(1.72+) ⇐ T: Send
mpsc::Receiver<T> Send · !Sync ← 그래서 공유하려면 Mutex
crossbeam Sender/Receiver Send + Sync + Clone ⇐ T: Send
lock() 이 Err 를 돌려줍니다. 위 코드는 unwrap() 으로
"다른 스레드가 패닉했으면 같이 죽는다"를 택했습니다. 살아남아야 하면
lock().unwrap_or_else(|e| e.into_inner()) 로 값을 되찾되, 큐 상태가 반쯤 바뀐 채일 수 있음을 감안해야 합니다.
실무에선 이렇게 — 종료 · 락프리 · 1.67 의 mpsc 🔴 실무
종료 신호 세 가지 — 무엇을 고르나
// ① 송신자 drop — Rust 의 기본값 ★
drop(tx); // 수신 루프는 남은 것을 다 받은 뒤 끝남
// ② 독약(poison pill) — 소비자마다 하나씩 보내야 함
enum Msg { Job(u64), Stop }
for _ in 0..n_workers { tx.send(Msg::Stop).unwrap(); }
// 송신자를 버릴 수 없는 구조(전역 핸들 등)일 때만
// ③ 별도 취소 플래그 + select — 남은 일감을 버리고 즉시 멈출 때
use crossbeam_channel::{select, Receiver};
fn worker(jobs: Receiver<u64>, stop: Receiver<()>) {
loop {
select! {
recv(stop) -> _ => break, // 신호든, 송신자 drop 이든 멈춤
recv(jobs) -> msg => match msg {
Ok(n) => handle(n),
Err(_) => break, // 일감 송신자가 모두 사라짐
},
}
}
}
fn handle(_n: u64) {}
락프리 큐 — ArrayQueue · SegQueue
// Cargo.toml: crossbeam = "0.8"
use crossbeam::queue::{ArrayQueue, SegQueue};
let q = ArrayQueue::new(2); // 고정 용량 · 락 없음 · 기다리지 않음
assert_eq!(q.push(1), Ok(()));
assert_eq!(q.push(2), Ok(()));
assert_eq!(q.push(3), Err(3)); // 가득 → 값을 그대로 돌려줌
assert_eq!(q.force_push(3), Some(1)); // 가장 오래된 1 을 밀어내고 넣음
assert_eq!(q.pop(), Some(2));
let s = SegQueue::new(); // 무제한 · 세그먼트 연결
s.push("a");
assert_eq!(s.pop(), Some("a"));
assert_eq!(s.pop(), None); // 비어도 기다리지 않음 → 폴링용
Sender 가 Sync 가 됐습니다. 다중 소비자 std::sync::mpmc 는
아직 nightly 전용이라, MPMC 와 select! 는 여전히 crossbeam-channel 몫입니다.
락프리 큐를 직접 짜지 마세요 — 이유는 🔬 깊이 탭 d06 에 있습니다.
std::sync::mpsc::sync_channel(n),
워커 풀이면 crossbeam_channel::bounded(n), 채널에 없는 규칙이 필요하면 Mutex<VecDeque> + Condvar 두 개.
종료는 마지막 송신자를 drop 하는 것이고, 수신 쪽은 남은 것을 다 받은 뒤 자연히 끝납니다.async 의 큐 — tokio::sync::mpsc 와 배압 · 종료
async 코드에서 스레드용 채널(std::sync::mpsc)의 recv() 를 부르면 런타임 워커 스레드 자체가 멈춥니다.
그래서 tokio 에는 .await 로 기다리는 채널이 따로 있습니다. 핵심은 하나 —
용량을 정한 channel(n) 의 send().await 가 곧 배압입니다. 소비자가 느리면 생산자가 자연히 늦춰집니다.
채널 네 종류와 액터 패턴은 🧵 서비스 탭 h03에서 다뤘으니, 여기서는 큐로서의 성질 · 워커 풀 · 깔끔한 종료에 집중합니다.
생산자-소비자 + 정상 종료 — 완전한 예제
// Cargo.toml: tokio = { version = "1", features = ["full"] }
// tokio-util = "0.7"
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<u32>(4); // 4칸 — 차면 send 가 대기
let token = CancellationToken::new();
let prod_token = token.clone();
let producer = tokio::spawn(async move {
let mut sent = 0u32;
loop {
tokio::select! {
_ = prod_token.cancelled() => break,
r = tx.send(sent) => {
if r.is_err() { break; } // 수신자가 사라짐
sent += 1;
}
}
}
sent // 여기서 tx 가 drop → 채널이 '닫힘'
});
let consumer = tokio::spawn(async move {
let mut done = 0u32;
while let Some(_job) = rx.recv().await { // 닫히고 비면 None
tokio::time::sleep(Duration::from_millis(1)).await; // 일하는 척
done += 1;
}
done
});
tokio::time::sleep(Duration::from_millis(50)).await;
token.cancel(); // 종료 신호는 입구에만
let sent = producer.await.unwrap();
let done = consumer.await.unwrap();
println!("보냄 {sent} · 처리 {done} · 유실 {}", sent - done);
// 보냄 N · 처리 N · 유실 0 (N 은 실행마다 다름)
}
select! 안의 send — 다른 갈래가 먼저 끝나면 send future 는 버려지고,
tokio 문서는 그때 메시지가 보내지지 않았음을 보장합니다. 여기서는 sent 가 u32 라 다음에 다시 보내면 그만이지만,
String 처럼 옮겨지는 값이면 값 자체를 잃습니다. 그럴 땐 오른쪽처럼 reserve() 로 자리부터 받으세요.
send · try_send · reserve — 가득 찼을 때의 세 태도
use tokio::sync::mpsc::{self, error::TrySendError};
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<u32>(2);
tx.send(1).await.unwrap(); // ① 기다려서라도 넣는다
tx.send(2).await.unwrap();
match tx.try_send(3) { // ② 지금 안 되면 포기 (값은 돌려받음)
Err(TrySendError::Full(v)) => println!("가득 — {v} 은 돌려받음"),
Err(TrySendError::Closed(_)) => println!("수신자 없음"),
Ok(()) => {}
}
println!("{:?}", rx.recv().await); // Some(1)
let permit = tx.reserve().await.unwrap(); // ③ 자리부터 확보 (값은 아직 안 넘김)
permit.send(3); // 이 send 는 실패할 수 없음
drop(tx);
let mut rest = Vec::new();
while let Some(v) = rx.recv().await { rest.push(v); }
println!("{rest:?}"); // [2, 3]
}
// 가득 — 3 은 돌려받음
// Some(1)
// [2, 3]
| 메서드 | 가득 찼을 때 | 쓰는 곳 |
|---|---|---|
send().await | 자리가 날 때까지 대기 | 기본 — 배압을 위로 전파 |
try_send | 즉시 Err(Full(v)) | 버려도 되는 지표 · 로그 샘플링 |
reserve().await | 대기 → Permit | select! 안 · 값 만들기가 비쌀 때 |
send_timeout | 시간 초과 시 Err | "1초 넘게 밀리면 503" |
blocking_send | 스레드를 막고 대기 | async 밖 스레드에서 보낼 때 |
| 채널 | 모양 | 가득 찼을 때 | 느린 수신자 | 큐로서 쓰는 곳 |
|---|---|---|---|---|
mpsc::channel(n) | N → 1 | send 대기 (배압) | 생산자가 늦춰짐 | 작업 큐 · 액터 입구 — 기본값 |
mpsc::unbounded_channel() | N → 1 | 없음 — 계속 자람 | 메모리가 자람 | 생산량이 구조적으로 제한될 때만 (SQ 10) |
broadcast::channel(n) | N → 모두 | 가장 오래된 것을 덮어씀 | Lagged(k) 로 놓친 수를 알림 | 이벤트 팬아웃 — 큐가 아니라 링 |
watch::channel(v) | N → 모두 | 최신 값 하나로 교체 | 중간 값은 못 봄 | 설정 · 상태 — 큐가 아님 |
oneshot::channel() | 1 → 1 | 한 번만 | — | 요청에 대한 응답 한 개 |
async-channel 크레이트 | N → M | send 대기 | — | 수신자를 clone 하는 워커 풀 |
flume 크레이트 | N → M | send 대기 | — | 동기 스레드 ↔ async 양쪽에서 같은 채널 |
이렇게도 쓸 수 있다 — async 워커 풀과 일괄 꺼내기 🟡 중급
수신자 하나를 워커 넷이 나눠 받기
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinSet;
#[tokio::main]
async fn main() {
let (tx, rx) = mpsc::channel::<u64>(16);
let rx = Arc::new(Mutex::new(rx)); // tokio 의 Mutex (await 가능)
let mut set = JoinSet::new();
for _ in 0..4 {
let rx = Arc::clone(&rx);
set.spawn(async move {
let mut sum = 0u64;
loop {
// ★ 락은 이 문장 끝에서 풀립니다 — 처리 중에는 안 잡고 있음
let job = rx.lock().await.recv().await;
let Some(n) = job else { break }; // 닫히고 비면 종료
sum += n * n; // 처리는 락 밖에서
}
sum
});
}
for n in 1..=100 { tx.send(n).await.unwrap(); }
drop(tx); // 입구 닫기
let mut total = 0;
while let Some(r) = set.join_next().await { total += r.unwrap(); }
println!("{total}"); // 338350
}
async-channel 크레이트는 Receiver 가 Clone 이라 Arc<Mutex> 가 필요 없습니다.
일감이 CPU 계산이면 워커 대신 tokio::task::spawn_blocking 이나 rayon 으로 넘기세요(⚡ e05) —
async 워커가 계산을 붙잡으면 같은 스레드의 다른 태스크가 굶습니다.
recv_many — 쌓인 만큼 한 번에
use tokio::sync::mpsc;
async fn writer(mut rx: mpsc::Receiver<String>) {
let mut buf: Vec<String> = Vec::with_capacity(256);
// 최소 1개가 올 때까지 기다린 뒤, 지금 쌓인 것을 최대 256개까지 한 번에
while rx.recv_many(&mut buf, 256).await > 0 {
flush(&buf).await; // DB 에 한 번에 INSERT
buf.clear(); // 용량은 그대로 — 다음 배치에 재사용
}
// 0 을 돌려받았다 = 채널이 닫혔고 비었다
}
async fn flush(rows: &[String]) {
println!("{}건 저장", rows.len());
}
// async 밖(일반 스레드)에서 tokio 채널 쓰기 — blocking_* 짝
let (tx, mut rx) = tokio::sync::mpsc::channel::<u32>(8);
std::thread::spawn(move || {
tx.blocking_send(1).unwrap(); // ★ async 컨텍스트 안에서 부르면 패닉
});
let v = rx.recv().await; // async 쪽은 평소대로
std::sync::mpsc::Receiver::recv() 나 Condvar::wait 를 부르면
tokio 워커 스레드가 통째로 멈춥니다. 컴파일은 되기 때문에 부하가 걸려야 드러납니다.
async 안에서는 .await 로 기다리는 것만 씁니다.
실무에선 이렇게 — 용량은 몇으로 하나 🔴 실무
지연 예산으로 계산하기
용량 ≈ 소비 처리량 × 견딜 수 있는 대기 시간
예) 워커가 초당 2,000건 처리 · 큐에서 0.5초까지 기다려도 됨
→ 2,000 × 0.5 = 1,000칸
너무 작으면 생산자가 자주 멈춤 → 처리량 손해 (순간 폭주를 못 흡수)
너무 크면 폭주 때 큐에 수십 초짜리 일이 쌓임 → 응답이 이미 무의미
+ 메모리 = 용량 × 원소 크기 (Box 로 감싼 큰 구조체면 더)
★ 큐 길이를 지표로 내보내세요
tx.capacity() 지금 남은 칸 수
tx.max_capacity() 만들 때 정한 용량
→ 사용률(1 - capacity/max_capacity)이 늘 90%면 소비자가 부족한 것
가득 찼을 때의 정책을 코드로
use std::time::Duration;
use tokio::sync::mpsc::{error::SendTimeoutError, Sender};
#[derive(Debug)]
enum Enqueue { Ok, Busy }
/// HTTP 핸들러에서 — 0.2초 안에 자리가 안 나면 503 으로 돌려보냄
async fn enqueue(tx: &Sender<String>, job: String) -> Enqueue {
match tx.send_timeout(job, Duration::from_millis(200)).await {
Ok(()) => Enqueue::Ok,
Err(SendTimeoutError::Timeout(_job)) => Enqueue::Busy, // 값은 돌려받음
Err(SendTimeoutError::Closed(_job)) => Enqueue::Busy, // 종료 중
}
}
// send_timeout 은 tokio 의 "time" 기능이 켜져 있어야 합니다 (full 이면 포함)
tokio::sync::mpsc::channel(n) 이고, send().await 가 곧 배압입니다.
종료는 취소 신호로 생산자만 멈추고 → 송신자 drop → 소비자는 남은 것을 비운 뒤 None.
여러 워커가 나눠 받으려면 Arc<Mutex<Receiver>>(락은 한 문장만) 또는 async-channel 을 씁니다.알고리즘 패턴 — Rust 관용구로 쓰는 스택 · 큐 문제
괄호 검사 · 단조 스택 · BFS · 슬라이딩 윈도우 · 0-1 BFS · undo/redo 는 다른 언어와 알고리즘이 같습니다.
Rust 에서 다른 것은 세 가지 습관입니다 — 큐에는 참조가 아니라 인덱스(usize)를 넣고,
꺼낼 때는 while let Some(..) = ..pop(), 이웃 좌표는 checked_add_signed 로 음수를 막습니다.
이론 설명은 CS 가이드 s03 · s09 에 있으니 여기서는 코드와 빌림 검사기 쪽을 봅니다.
괄호 검사 · 다음 큰 수 — 스택
fn balanced(s: &str) -> bool {
let mut st = Vec::new();
for c in s.chars() {
match c {
'(' | '[' | '{' => st.push(c),
')' | ']' | '}' => {
let want = match c { ')' => '(', ']' => '[', _ => '{' };
if st.pop() != Some(want) { return false; } // 비어도 여기서 걸림
}
_ => {}
}
}
st.is_empty() // 열린 채로 끝나면 false
}
fn next_greater(a: &[i32]) -> Vec<Option<i32>> {
let mut ans = vec![None; a.len()];
let mut st: Vec<usize> = Vec::new(); // 값이 아니라 인덱스 ★
for (i, &x) in a.iter().enumerate() {
while let Some(&top) = st.last() {
if a[top] >= x { break; }
ans[top] = Some(x);
st.pop();
}
st.push(i);
}
ans
}
fn main() {
for s in ["{[()]}", "([)]", "((", "a(b)c", ")("] {
print!("{} ", balanced(s)); // true false false true false
}
println!();
println!("{:?}", next_greater(&[2, 1, 2, 4, 3]));
// [Some(4), Some(2), Some(4), None, None]
}
while let Some(&top) = st.last() 가 빌림 에러가 안 나는 이유 — 패턴의 &top 이
usize 를 복사해 오므로 st 를 빌린 참조가 그 줄에서 끝납니다. 그래서 같은 반복 안에서 st.pop() 을 해도 됩니다.
슬라이딩 윈도우 최댓값 · 격자 BFS — 덱
use std::collections::VecDeque;
fn window_max(a: &[i32], k: usize) -> Vec<i32> {
assert!(k > 0);
let mut dq: VecDeque<usize> = VecDeque::new(); // 값이 줄어드는 인덱스들
let mut out = Vec::new();
for i in 0..a.len() {
if dq.front().is_some_and(|&f| f + k <= i) { dq.pop_front(); } // 창 밖
while dq.back().is_some_and(|&b| a[b] <= a[i]) { dq.pop_back(); }
dq.push_back(i);
if i + 1 >= k { out.push(a[dq[0]]); } // 맨 앞이 창의 최댓값
}
out
}
fn bfs_grid(grid: &[&str]) -> Vec<Vec<Option<u32>>> {
let g: Vec<&[u8]> = grid.iter().map(|r| r.as_bytes()).collect();
let (h, w) = (g.len(), g[0].len());
let mut dist = vec![vec![None; w]; h];
let mut q = VecDeque::from([(0usize, 0usize)]);
dist[0][0] = Some(0);
while let Some((r, c)) = q.pop_front() {
let d = dist[r][c].expect("큐에 든 칸은 거리가 정해져 있음");
for (dr, dc) in [(0isize, 1isize), (1, 0), (0, -1), (-1, 0)] {
// 0 에서 -1 을 하면 usize 언더플로 → checked 로 None (1.66+)
let (Some(nr), Some(nc)) =
(r.checked_add_signed(dr), c.checked_add_signed(dc)) else { continue };
if nr >= h || nc >= w || g[nr][nc] == b'#' || dist[nr][nc].is_some() {
continue;
}
dist[nr][nc] = Some(d + 1);
q.push_back((nr, nc));
}
}
dist
}
fn main() {
println!("{:?}", window_max(&[1, 3, -1, -3, 5, 3, 6, 7], 3)); // [3, 3, 5, 5, 6, 7]
for row in bfs_grid(&["S..#", ".#..", "...#", "#..."]) {
let cells: Vec<String> =
row.iter().map(|d| d.map_or("#".into(), |v| v.to_string())).collect();
println!("{}", cells.join(" "));
}
}
// 0 1 2 #
// 1 # 3 4
// 2 3 4 #
// # 4 5 6
이렇게도 쓸 수 있다 — BFS 에서 빌림 검사기와 싸우지 않는 법 🟡 중급
❌ 세 가지 흔한 컴파일 에러
// ① 큐를 빌려서 돌면서 같은 큐에 넣기
for node in q.iter() { // q 를 불변 대여 중
for &next in &adj[*node] {
q.push_back(next); // ❌ error[E0502]
}
}
// ② 큐에 &mut 노드를 넣기 (그래프)
let mut q: VecDeque<&mut Node> = VecDeque::new();
q.push_back(&mut nodes[0]);
while let Some(n) = q.pop_front() {
for &e in &n.edges {
q.push_back(&mut nodes[e]); // ❌ error[E0499]: nodes 를 두 번 가변 대여
}
}
// ③ 메서드가 self 전체를 빌림
impl Bfs {
fn neighbors(&self, u: usize) -> &[usize] { &self.adj[u] }
fn run(&mut self) {
while let Some(u) = self.q.pop_front() {
for &v in self.neighbors(u) { // self 전체를 불변 대여
if !self.seen[v] {
self.seen[v] = true; // ❌ error[E0502]
self.q.push_back(v); // ❌ error[E0502]
}
}
}
}
}
✅ 꺼내서 쓰고 · 인덱스를 넣고 · 필드를 직접
// ① 꺼낸 값(소유권)으로 처리 — 큐는 빌리지 않음
while let Some(node) = q.pop_front() {
for &next in &adj[node] {
q.push_back(next); // ✅
}
}
// ② 큐에는 인덱스(usize), 노드 수정은 꺼낸 뒤 그때그때
let mut q: VecDeque<usize> = VecDeque::from([0]);
nodes[0].dist = Some(0);
while let Some(u) = q.pop_front() {
let d = nodes[u].dist.unwrap_or(0);
for i in 0..nodes[u].edges.len() {
let e = nodes[u].edges[i]; // usize 복사 — 빌림이 이 줄에서 끝남
if nodes[e].dist.is_none() {
nodes[e].dist = Some(d + 1); // ✅ 한 번에 하나만 가변 대여
q.push_back(e);
}
}
}
// ③ 필드를 직접 건드리면 컴파일러가 '서로 다른 필드'로 봄
fn run(&mut self) {
let Self { adj, seen, q } = self; // 필드별로 나눠 빌림
while let Some(u) = q.pop_front() {
for &v in &adj[u] { // adj 불변 · seen/q 가변 — 겹치지 않음
if !seen[v] { seen[v] = true; q.push_back(v); } // ✅
}
}
}
&mut Node 를 큐에 넣으면
같은 노드에 대한 가변 참조가 두 개 생길 수 있고, 그건 Rust 가 절대 허락하지 않는 상태입니다. usize 는 그냥 숫자라
몇 개든 큐에 있을 수 있고, 실제 접근은 꺼낸 순간 nodes[u] 로 한 번에 하나씩만 합니다.
노드 삭제까지 필요하면 slotmap · 그래프 전체면 petgraph(NodeIndex)가 같은 생각을 크레이트로 만든 것입니다.
③의 let Self { adj, seen, q } = self; 는 구조체 필드가 정확히 그 셋일 때 그대로 쓰고, 더 있으면 .. 를 붙입니다.
실무에선 이렇게 — 0-1 BFS 와 undo / redo 🔴 실무
0-1 BFS — 비용 0 은 앞으로, 1 은 뒤로
use std::collections::VecDeque;
/// '#' 을 지나려면 벽을 하나 부숴야 함(비용 1), '.' 은 0.
/// 왼쪽 위 → 오른쪽 아래까지 부술 벽의 최소 개수.
fn min_walls(grid: &[&str]) -> u32 {
let g: Vec<&[u8]> = grid.iter().map(|r| r.as_bytes()).collect();
let (h, w) = (g.len(), g[0].len());
let mut dist = vec![vec![u32::MAX; w]; h];
let mut dq = VecDeque::from([(0usize, 0usize)]);
dist[0][0] = 0;
while let Some((r, c)) = dq.pop_front() {
for (dr, dc) in [(0isize, 1isize), (1, 0), (0, -1), (-1, 0)] {
let (Some(nr), Some(nc)) =
(r.checked_add_signed(dr), c.checked_add_signed(dc)) else { continue };
if nr >= h || nc >= w { continue; }
let cost = u32::from(g[nr][nc] == b'#');
let nd = dist[r][c] + cost;
if nd < dist[nr][nc] {
dist[nr][nc] = nd;
if cost == 0 { dq.push_front((nr, nc)) } // ★ 덱의 양끝을 다 씀
else { dq.push_back((nr, nc)) }
}
}
}
dist[h - 1][w - 1]
}
fn main() {
println!("{}", min_walls(&["..#.", "##.#", "...#", "#.#."])); // 2
}
BinaryHeap 없이 다익스트라와 같은 답을 O(V+E) 로 얻습니다. Rust 에서는 VecDeque 한 개로 끝납니다.
undo / redo — 스택 두 개
#[derive(Debug, Clone)]
enum Edit {
Insert { pos: usize, text: String },
Delete { pos: usize, text: String },
}
struct Editor { buf: String, undo: Vec<Edit>, redo: Vec<Edit> }
impl Editor {
fn apply(&mut self, e: &Edit) {
match e {
Edit::Insert { pos, text } => self.buf.insert_str(*pos, text),
Edit::Delete { pos, text } => self.buf.replace_range(*pos..*pos + text.len(), ""),
}
}
fn inverse(e: &Edit) -> Edit {
match e.clone() {
Edit::Insert { pos, text } => Edit::Delete { pos, text },
Edit::Delete { pos, text } => Edit::Insert { pos, text },
}
}
fn exec(&mut self, e: Edit) {
self.apply(&e);
self.undo.push(e);
self.redo.clear(); // ★ 새 편집이 오면 redo 는 무효
}
fn undo(&mut self) -> bool {
let Some(e) = self.undo.pop() else { return false };
self.apply(&Self::inverse(&e));
self.redo.push(e);
true
}
fn redo(&mut self) -> bool {
let Some(e) = self.redo.pop() else { return false };
self.apply(&e);
self.undo.push(e);
true
}
}
fn main() {
let mut ed = Editor { buf: String::new(), undo: vec![], redo: vec![] };
ed.exec(Edit::Insert { pos: 0, text: "hello".into() });
ed.exec(Edit::Insert { pos: 5, text: " world".into() });
ed.exec(Edit::Delete { pos: 0, text: "hello".into() });
println!("{:?}", ed.buf); // " world"
ed.undo(); println!("{:?}", ed.buf); // "hello world"
ed.undo(); println!("{:?}", ed.buf); // "hello"
ed.redo(); println!("{:?}", ed.buf); // "hello world"
ed.exec(Edit::Insert { pos: 11, text: "!".into() });
println!("{:?} redo={}", ed.buf, ed.redo.len()); // "hello world!" redo=0
}
insert_str · replace_range 는 문자 경계가 아니면 패닉합니다.
한글을 다루는 편집기라면 위치를 char_indices 로 구하세요. 이력에 상한이 필요하면 undo 를
VecDeque 로 바꾸고 넘칠 때 pop_front() 로 가장 오래된 것을 버립니다.
Vec<usize> 에 인덱스를, 큐 문제는 VecDeque 에 인덱스나 좌표를 넣고
while let Some(..) = pop..() 으로 꺼냅니다. 빌림 에러가 나면 ① 큐를 빌려 돌지 말고 꺼내서 쓰기
② &mut 대신 인덱스 ③ self 메서드 대신 필드 직접 — 이 세 가지로 거의 다 풀립니다.성능과 메모리 — 연속 메모리가 거의 항상 이깁니다
복잡도표만 보면 LinkedList 의 양끝 연산도 O(1) 이라 VecDeque 와 같아 보입니다.
실제로 갈리는 것은 원소 하나에 할당이 몇 번 일어나는가와 다음 원소가 메모리의 어디에 있는가입니다.
Rust 는 값을 컨테이너 안에 그대로 넣으므로(자바처럼 박싱하지 않음) 연속 배열의 장점이 특히 큽니다.
벤치마크 수치는 기계마다 다르니 자릿수만 보는 예시로 읽고, 직접 재는 법은 ⚡ 성능 탭 e01 의 criterion 을 쓰세요.
헤더 크기 — 직접 찍어 보기 (64비트)
use std::collections::{BinaryHeap, LinkedList, VecDeque};
fn main() {
println!("{}", std::mem::size_of::<Vec<u64>>()); // 24 ptr·cap·len
println!("{}", std::mem::size_of::<VecDeque<u64>>()); // 32 ptr·cap·head·len
println!("{}", std::mem::size_of::<LinkedList<u64>>()); // 24 head·tail·len
println!("{}", std::mem::size_of::<BinaryHeap<u64>>()); // 24 안은 Vec
println!("{}", std::mem::size_of::<Option<Box<u64>>>()); // 8 널 포인터 최적화
}
| 원소 T = u64 | 원소당 메모리 | 할당 횟수 |
|---|---|---|
Vec · VecDeque | 8B + 남는 용량 | 늘 때만 (log n 번) |
BinaryHeap | 8B + 남는 용량 | 늘 때만 |
VecDeque<Box<u64>> | 8B 포인터 + 8B 값 + 머리말 | 원소마다 |
LinkedList | 24B 노드 + 머리말 | 원소마다 |
LinkedList — 공식 문서가 먼저 말립니다
std::collections::LinkedList 문서의 첫 NOTE:
"It is almost always better to use Vec or VecDeque because
array-based containers are generally faster, more memory
efficient, and make better use of CPU cache."
std::collections 모듈 문서 — LinkedList 를 쓸 때:
· 크기를 모르는 Vec/VecDeque 인데 분할 상환(가끔 큰 복사)을 못 견딜 때
· 리스트를 효율적으로 쪼개고 붙여야 할 때
· 정말로, 진심으로 이중 연결 리스트가 필요하다고 확신할 때
append(두 리스트 이어 붙이기)가 O(1) 이라는 것 하나입니다.
다만 중간 위치 삽입·삭제에 필요한 커서 API 는 아직 nightly 라, 다른 언어에서 연결 리스트를 쓰던 이유
(반복자로 가리킨 곳에 O(1) 삽입)가 stable Rust 에서는 대부분 성립하지 않습니다.
LRU 처럼 "중간에서 빼서 앞으로" 가 필요하면 lru 크레이트나 인덱스 기반 연결(Vec + 이웃 인덱스)을 씁니다.
이렇게도 쓸 수 있다 — 재할당 줄이기 · 값으로 넣을까 Box 로 넣을까 🟡 중급
용량을 미리 · 버퍼는 다시
use std::collections::VecDeque;
fn main() {
// ① 개수를 알면 미리 — 늘어나며 복사하는 일이 0
let mut q: VecDeque<u32> = VecDeque::with_capacity(1_000);
let before = q.capacity();
for i in 0..1_000 { q.push_back(i); }
println!("{}", q.capacity() == before); // true — 재할당 없음
// ② 루프마다 새로 만들지 말고 비워서 다시
let mut frontier: Vec<usize> = Vec::new();
for round in 0..3 {
frontier.clear(); // 용량은 남음
frontier.extend(0..round * 10);
}
// ③ 잠깐 커졌다가 평소 작아지면 상한을 두고 줄이기
q.clear();
q.shrink_to(64); // 최소 64 는 남김
println!("{}", q.capacity() >= 64); // true
}
값 vs Box — 기준표
| 상황 | 넣는 형태 |
|---|---|
| 원소가 작다 (수십~백여 바이트) | 값 그대로 VecDeque<Job> |
| 원소가 매우 크다 (KB 단위) | VecDeque<Box<Job>> — 이동이 포인터 8B |
| 종류가 여럿 (트레이트 객체) | VecDeque<Box<dyn Task>> — 피할 수 없음 |
| enum 인데 한 변형만 큼 | 큰 변형 안쪽만 Box (clippy::large_enum_variant) |
| 여러 곳이 같은 값을 공유 | Arc<Job> — 큐에는 포인터만 |
ArrayDeque<Job> 는 사실 참조의 배열이라 늘 "Box 버전"입니다.
Rust 로 옮기며 습관처럼 Box 를 씌우면 장점(연속 메모리 · 할당 0)을 스스로 버리는 셈입니다.
기본은 값, 크기를 재 보고 문제가 될 때만 Box.
실무에선 이렇게 — 재 보고 고르기 🔴 실무
criterion 으로 큐 세 개 비교
// benches/queue.rs · Cargo.toml [dev-dependencies] criterion = "0.5"
// [[bench]] name = "queue" harness = false
use criterion::{criterion_group, criterion_main, Criterion};
use std::collections::{LinkedList, VecDeque};
use std::hint::black_box;
const N: u64 = 100_000;
fn fifo(c: &mut Criterion) {
c.bench_function("vecdeque", |b| b.iter(|| {
let mut q = VecDeque::new();
for i in 0..N { q.push_back(black_box(i)); }
while let Some(x) = q.pop_front() { black_box(x); }
}));
c.bench_function("linkedlist", |b| b.iter(|| {
let mut q = LinkedList::new();
for i in 0..N { q.push_back(black_box(i)); }
while let Some(x) = q.pop_front() { black_box(x); }
}));
c.bench_function("vec_remove0", |b| b.iter(|| {
let mut q = Vec::new();
for i in 0..N { q.push(black_box(i)); }
while !q.is_empty() { black_box(q.remove(0)); }
}));
}
criterion_group!(benches, fifo);
criterion_main!(benches);
// cargo bench → 결과는 target/criterion/report/index.html
숫자를 읽을 때 기억할 것
| 함정 | 대처 |
|---|---|
| debug 빌드로 잼 | bench 는 release 프로필 — 수십 배 차이 |
| 컴파일러가 루프를 지움 | std::hint::black_box 로 값을 '쓴 척' |
| N 이 작아 캐시에 다 들어감 | N 을 1만 · 100만 · 1억으로 바꿔 곡선을 봄 |
| 할당기 차이 | mimalloc · jemalloc 이면 LinkedList 격차가 줄어듦 (🧵 h10) |
| 한 번 재고 결론 | criterion 의 신뢰 구간 · 변화율(%)을 봄 |
VecDeque 함수가 보일 때만 큐를 의심하세요.
Vec · VecDeque · BinaryHeap)가 기본이고, 원소는 값으로 넣습니다.
LinkedList 는 공식 문서도 권하지 않으며, Box 는 원소가 매우 크거나 트레이트 객체일 때만.
개수를 알면 with_capacity, 루프에서는 clear() 로 버퍼를 다시 씁니다.흔한 실수 — 컴파일은 되는데 틀린 것과 컴파일러가 막아 주는 것
Rust 는 빈 컨테이너 접근과 데이터 경쟁을 타입으로 막아 주지만, O(n) 큐 · 방향을 착각한 힙 · 무한히 자라는 채널 · 필요 이상 오래 잡은 락은 컴파일러가 모릅니다. 여기 모은 열 가지는 리뷰에서 실제로 자주 보이는 것들입니다. 앞쪽 다섯은 "돌기는 도는데 틀린" 것, 뒤쪽 다섯은 "컴파일 에러가 나서 헤매는" 것입니다.
❌ 1 · pop().unwrap() 을 습관처럼
let top = stack.pop().unwrap(); // 비어 있으면 패닉 → 서버면 요청 하나가 죽음
let first = queue[0]; // 비어 있으면 'index out of bounds' 패닉
let job = rx.recv().unwrap(); // 송신자가 다 사라지면 패닉
✅ 빈 경우를 분기로
let Some(top) = stack.pop() else { return Err(Error::EmptyStack) };
let Some(&first) = queue.front() else { return Ok(()) };
while let Ok(job) = rx.recv() { handle(job); } // 끝나면 루프가 자연 종료
❌ 2 · Vec 으로 큐 — remove(0) · insert(0, x)
let mut pending: Vec<Req> = Vec::new();
pending.push(req);
let next = pending.remove(0); // 원소 n 개를 한 칸씩 당김 — O(n)
// 1만 건까지는 티가 안 나다가 100만 건에서 CPU 100%
✅ VecDeque
let mut pending: VecDeque<Req> = VecDeque::new();
pending.push_back(req);
let next = pending.pop_front(); // O(1) · Option<Req>
// 이미 Vec 으로 받았다면 VecDeque::from(vec) — O(1) 변환
❌ 3 · BinaryHeap 이 최소 힙인 줄 알고
// 가장 '이른' 마감부터 처리하려고
let mut h = BinaryHeap::new();
h.push(deadline_a); // Instant
h.push(deadline_b);
let next = h.pop(); // ❌ 가장 '늦은' 마감이 나옴 (최대 힙)
let sorted = h.into_sorted_vec(); // 이건 오름차순 — 그래서 더 헷갈림
✅ Reverse 로 방향을 타입에 박기
use std::cmp::Reverse;
let mut h = BinaryHeap::new();
h.push(Reverse(deadline_a));
h.push(Reverse(deadline_b));
let Some(Reverse(next)) = h.pop() else { return }; // ✅ 가장 이른 것
// 타입이 BinaryHeap<Reverse<Instant>> 라 읽는 사람도 방향을 압니다
❌ 4 · unbounded 채널을 기본으로
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
// 생산: 초당 5,000건 · 소비: DB 가 느려져 초당 3,000건
// → 초당 2,000건씩 쌓임 · 1시간이면 720만 건 · 메모리 계속 증가
// → 결국 OOM kill. 그 전에 큐 대기 시간이 수십 분이라 응답도 무의미
tx.send(event).unwrap(); // 절대 기다리지 않음 = 배압 없음
✅ 용량을 정하고, 가득 찼을 때의 정책을 코드로
let (tx, rx) = tokio::sync::mpsc::channel(1_000);
tx.send(event).await?; // 차면 생산자가 늦춰짐 (배압)
// 버려도 되는 데이터라면
if let Err(TrySendError::Full(_)) = tx.try_send(metric) {
dropped.fetch_add(1, Ordering::Relaxed); // 버린 수는 지표로
}
❌ 5 · while let 으로 잡은 락을 본문 내내 쥐기
let q = Mutex::new(VecDeque::from(jobs));
// 조건식의 임시 MutexGuard 가 '반복 본문 끝까지' 살아 있음
while let Some(job) = q.lock().unwrap().pop_front() {
heavy_work(job); // ← 락을 쥔 채 일함 → 다른 스레드 전부 대기
// 여기서 q.lock() 을 또 부르면 자기 락을 기다려 교착(또는 패닉)
}
✅ let 문에서 꺼내면 그 줄 끝에서 락이 풀림
loop {
let job = q.lock().unwrap().pop_front(); // 가드는 이 문장 끝에서 drop
let Some(job) = job else { break };
heavy_work(job); // ✅ 락 없이 처리
}
// 위 그림의 아래쪽 줄이 이 코드입니다
if let 조건식의 임시값을 else 블록에 들어가기 전에 버리도록 바꿨습니다.
하지만 then 블록과 while let 본문에서는 여전히 살아 있습니다. "에디션 올렸으니 괜찮겠지"가 통하지 않는 부분이라,
락·RefCell 가드는 언제나 let 문으로 먼저 꺼내는 습관이 안전합니다.
컴파일러가 막아 주는 것 — 에러 메시지를 읽고 바로 고치기 🟡 중급
❌ 6 · 순회하면서 같은 컨테이너에 넣기
let mut stack = vec![1, 2, 3];
for x in &stack {
if *x == 2 {
stack.push(20);
// error[E0502]: cannot borrow `stack` as mutable
// because it is also borrowed as immutable
}
}
// 자바라면 ConcurrentModificationException 이 '실행 중에' 났을 코드
✅ 모았다가 나중에 · 또는 작업 목록 패턴
let mut stack = vec![1, 2, 3];
let extra: Vec<i32> = stack.iter().filter(|&&x| x == 2).map(|_| 20).collect();
stack.extend(extra); // [1, 2, 3, 20]
// 새로 생긴 것까지 처리해야 하면 — 꺼내면서 넣기
let mut work = vec![1];
while let Some(x) = work.pop() {
if x < 4 { work.push(x + 1); } // ✅ x 는 꺼낸 값이라 빌림 없음
}
❌ 7 · std Mutex 가드를 .await 너머로
let q: Arc<std::sync::Mutex<VecDeque<Job>>> = /* … */;
tokio::spawn(async move {
let mut g = q.lock().unwrap();
let job = g.pop_front();
send_somewhere(job).await; // 가드가 아직 살아 있음
// error: future cannot be sent between threads safely
// ... `MutexGuard<'_, VecDeque<Job>>` is not `Send`
});
✅ 가드를 블록 안에 가두거나, 채널로
tokio::spawn(async move {
let job = {
let mut g = q.lock().unwrap();
g.pop_front()
}; // ← 가드가 여기서 drop
send_somewhere(job).await; // ✅
});
// 애초에 async 끼리라면 Mutex<VecDeque> 대신
// tokio::sync::mpsc 채널이 정답인 경우가 대부분입니다 (SQ 07)
❌ 8 · Rc<RefCell<VecDeque>> 를 스레드로
let q = Rc::new(RefCell::new(VecDeque::new()));
let q2 = Rc::clone(&q);
std::thread::spawn(move || {
q2.borrow_mut().push_back(1);
// error[E0277]: `Rc<RefCell<VecDeque<i32>>>` cannot be sent
// between threads safely
});
✅ 스레드 사이는 Arc + Mutex, 더 좋게는 채널
let q = Arc::new(Mutex::new(VecDeque::new()));
let q2 = Arc::clone(&q);
std::thread::spawn(move || {
q2.lock().unwrap().push_back(1); // ✅
}).join().unwrap();
println!("{:?}", q.lock().unwrap()); // [1]
❌ 9 · 경계에서 터지는 덱 메서드
let mut d = VecDeque::from([1, 2, 3]);
d.rotate_left(5); // 패닉: k > len
let batch: Vec<_> = d.drain(..10).collect(); // 패닉: 범위가 len 을 넘음
d.truncate(1); // '앞' 1개가 남는다 — 최근 것이 아님!
let v = d.make_contiguous(); // 매 반복마다 부르면 O(n) 반복
✅ 길이로 자르고, 의미를 확인하고
let mut d = VecDeque::from([1, 2, 3]);
if !d.is_empty() { d.rotate_left(5 % d.len()); } // [3, 1, 2]
let n = d.len().min(10);
let batch: Vec<_> = d.drain(..n).collect(); // 있는 만큼
// '최근 N개'를 남기려면 앞을 버림
while d.len() > 1 { d.pop_front(); }
❌ 10 · 힙을 출력하면 정렬돼 있을 거라고
let h = BinaryHeap::from(vec![3, 1, 4, 1, 5, 9, 2, 6]);
println!("{h:?}"); // [9, 6, 4, 1, 5, 3, 2, 1] — 배열 순서
for x in h.iter() { /* 정렬 순서 아님 */ }
let top3: Vec<_> = h.iter().take(3).collect(); // ❌ '위 3개'가 아님
✅ 순서가 필요하면 꺼내거나 정렬
let mut h = BinaryHeap::from(vec![3, 1, 4, 1, 5, 9, 2, 6]);
let top3: Vec<i32> = std::iter::from_fn(|| h.pop()).take(3).collect();
println!("{top3:?}"); // [9, 6, 5]
let rest = h.into_sorted_vec(); // 오름차순 [1, 1, 2, 3, 4]
| 증상 | 흔한 원인 | 처방 |
|---|---|---|
| 데이터가 늘수록 CPU 가 제곱으로 | Vec::remove(0) · insert(0, _) | VecDeque (2) |
| 우선순위가 거꾸로 | 최대 힙인 걸 잊음 | Reverse · Ord 점검 (3) |
| 메모리가 천천히 끝없이 증가 | unbounded 채널 · SegQueue | channel(n) + 가득 찼을 때 정책 (4) |
| 스레드를 늘려도 처리량 그대로 | 락을 쥔 채 작업 | let 문으로 꺼내기 (5) |
| 프로그램이 끝나지 않음 | 송신자 사본이 어딘가 남음 | drop(tx) · 사본 위치 추적 (SQ 06) |
| tokio::spawn 에서 Send 에러 | std 가드 · Rc 를 await 너머로 | 블록으로 가두기 · Arc (7 · 8) |
| 가끔 패닉 "index out of bounds" | q[0] · drain(..k) | front() · len().min(k) (1 · 9) |
while let 조건에서 lock() 하지 않기는 에디션 2024 에서도 유효한 규칙입니다.다른 언어와 비교 — 같은 일을 무엇으로 하나
다른 언어에서 넘어온 사람이 Rust 의 스택·큐에서 놀라는 지점은 대개 세 가지입니다 —
꺼내기가 Option이라 예외도 null 도 없고, 힙이 최대 힙이며, 스레드 큐 대신 채널을 쓴다는 것.
표 한 장에 놓고 보면 Rust 는 C++ 처럼 값을 연속 메모리에 담으면서도, 빈 컨테이너 접근을 C++ 처럼 UB 로 두지 않는 자리에 있습니다.
| 하려는 일 | Rust ★ | Java 25 | Python 3.13 | C# · .NET 10 | C++23 | JS · Go |
|---|---|---|---|---|---|---|
| 스택 | Vec push/pop | ArrayDeque | list | Stack<T> | std::stack · vector | 배열 · 슬라이스 |
| 큐 | VecDeque | ArrayDeque | deque | Queue<T> | std::queue | 직접 구현 · 슬라이스 |
| 덱 | VecDeque | ArrayDeque | deque | 표준에 없음 | std::deque | 없음 · container/list |
| 우선순위 큐 | BinaryHeap (최대) | PriorityQueue (최소) | heapq (최소) | PriorityQueue<E,P> (최소) | priority_queue (최대) | 없음 · container/heap |
| 최소 ↔ 최대 바꾸기 | Reverse(x) | Comparator.reverseOrder() | 음수로 넣기 | 비교자 전달 | std::greater | Less 를 뒤집음 |
| 빈 것에서 꺼냄 | None | null 또는 예외 | IndexError | 예외 · TryDequeue | UB | undefined · panic |
| 스레드 간 큐 | mpsc · crossbeam-channel | ArrayBlockingQueue | queue.Queue | Channel · BlockingCollection | 표준에 없음 (TBB 등) | — · chan |
| 비동기 큐 | tokio::sync::mpsc | 가상 스레드 + BlockingQueue | asyncio.Queue | System.Threading.Channels | 표준에 없음 | async 반복자 · chan |
| 순회 중 수정 | 컴파일 에러 | CME (실행 중) | list 는 조용히 · deque 는 RuntimeError | InvalidOperation (실행 중) | 반복자 무효화 UB | 조용히 이상 동작 |
이렇게도 쓸 수 있다 — 다른 언어 코드를 Rust 로 옮기기 🟡 중급
자바 · 파이썬 한 줄 → Rust 한 줄
Java Rust
Deque<Integer> st = new ArrayDeque<>(); let mut st: Vec<i32> = Vec::new();
st.push(x); st.pop(); st.peek(); st.push(x); st.pop(); st.last();
Queue<T> q = new ArrayDeque<>(); let mut q = VecDeque::new();
q.offer(x); q.poll(); q.peek(); q.push_back(x); q.pop_front(); q.front();
new PriorityQueue<>() (최소) BinaryHeap::new() + Reverse(x)
new PriorityQueue<>(reverseOrder()) BinaryHeap::new() (그대로 최대)
new ArrayBlockingQueue<>(100) crossbeam_channel::bounded(100)
q.put(x) / q.take() tx.send(x) / rx.recv()
Python Rust
st.append(x); st.pop(); st[-1] st.push(x); st.pop(); st.last()
dq = deque(maxlen=100) VecDeque::with_capacity(100) + 직접 pop_front
heapq.heappush(h, (p, x)) h.push(Reverse((p, x)))
heapq.heappop(h) h.pop() → Option<Reverse<(P, X)>>
asyncio.Queue(maxsize=100) tokio::sync::mpsc::channel(100)
await q.put(x) / await q.get() tx.send(x).await / rx.recv().await
옮길 때 다시 확인할 다섯 가지
| 원래 코드에서 | Rust 에서는 |
|---|---|
deque(maxlen=N) 자동 버림 | 자동 아님 — 넣기 전에 직접 pop_front (SQ 12) |
heapq 튜플 비교 | 튜플도 사전순이지만 f64 는 Ord 아님 (SQ 05) |
PriorityQueue 기본 최소 | Reverse 빠뜨리면 순서 반대 |
null 을 큐에 넣어 신호로 | enum Msg { Job(..), Stop } 또는 송신자 drop |
| 큐 안의 객체를 밖에서 수정 | 값이 큐 안에 '소유'됨 — 꺼내서 고치고 다시 넣기, 또는 인덱스·Arc |
deque(maxlen=N) 은 가득 차면 반대쪽을 조용히 버립니다. Rust 에는 그런 옵션이 없어서
직접 써야 하는데, 오히려 무엇이 버려졌는지 돌려받아 로그를 남길 수 있다는 장점이 됩니다(SQ 12 의 Recent).
실무에선 이렇게 — 언어 경계를 넘는 큐 🔴 실무
프로세스 밖의 큐는 언어와 무관합니다
| 필요 | 도구 | Rust 크레이트 |
|---|---|---|
| 서비스 간 작업 큐 | Redis 리스트 · Streams | redis |
| 대용량 이벤트 로그 | Kafka | rdkafka (🧵 h07) |
| 라우팅 · 재시도 | RabbitMQ | lapin |
| 경량 pub/sub | NATS | async-nats |
| DB 를 큐로 | Postgres SKIP LOCKED | sqlx |
파이썬 · Node 에서 Rust 모듈을 부를 때
VecDeque 를 파이썬 객체로 노출하면
원소 하나마다 변환 비용이 듭니다. 대신 Rust 쪽이 큐와 워커를 소유하고, 파이썬·JS 에는
submit(job) · poll_results() 처럼 일괄로 주고받는 함수만 내보내는 것이 정석입니다(🐍 파이썬 탭 · 🟩 Node 탭).
GIL 을 놓고 Rust 스레드가 채널로 일하게 하면 파이썬 스레드 여럿보다 훨씬 확장됩니다.
Vec · 큐/덱 VecDeque · 우선순위 BinaryHeap(최대) · 스레드 mpsc/crossbeam · async tokio mpsc.
다른 언어에서 옮길 때는 힙 방향, maxlen 자동 버림이 없음, null 신호 대신 enum·drop 세 가지를 확인하세요.실무 사례 — 레이트 리미터 · 최근 N개 · 재시도 · 배치 · 워커
앞에서 본 타입들이 실제 서비스 코드에서 어떤 모양으로 쓰이는지 여섯 가지 사례로 묶었습니다.
전부 표준 라이브러리 또는 tokio 생태계만 쓰고, 시간에 의존하는 로직은 시각을 인자로 받게 만들어 테스트에서 값을 고정할 수 있게 했습니다.
이 습관 하나로 레이트 리미터·재시도 큐 같은 코드의 단위 테스트가 sleep 없이 결정적으로 돕니다.
① 레이트 리미터 — 슬라이딩 윈도우 로그
use std::collections::VecDeque;
struct SlidingWindow {
limit: usize,
window_ms: u64,
hits: VecDeque<u64>, // 허용된 요청 시각 (오래된 것이 앞)
}
impl SlidingWindow {
fn new(limit: usize, window_ms: u64) -> Self {
Self { limit, window_ms, hits: VecDeque::with_capacity(limit) }
}
/// 시각을 인자로 받음 → 테스트에서 시간을 고정할 수 있음
fn allow(&mut self, now_ms: u64) -> bool {
while self.hits.front().is_some_and(|&t| t + self.window_ms <= now_ms) {
self.hits.pop_front(); // 창 밖으로 나간 기록 버림
}
if self.hits.len() < self.limit {
self.hits.push_back(now_ms);
true
} else {
false
}
}
}
fn main() {
let mut rl = SlidingWindow::new(3, 1000); // 1초에 3번
let log: Vec<String> = [0, 100, 200, 300, 900, 1000, 1100, 1250, 2000]
.into_iter()
.map(|t| format!("{t}:{}", if rl.allow(t) { "O" } else { "X" }))
.collect();
println!("{}", log.join(" "));
// 0:O 100:O 200:O 300:X 900:X 1000:O 1100:O 1250:O 2000:O
}
u64 밀리초 대신 std::time::Instant 와 Duration,
사용자별이면 HashMap<UserId, SlidingWindow>(오래 안 쓰인 키 정리 필수), 서버가 여러 대면
같은 알고리즘을 Redis 정렬 집합(ZSET)으로 옮깁니다. 한 프로세스 안이라면 governor 크레이트(GCRA)가 메모리를 더 적게 씁니다.
② 최근 N개 — 넘치면 앞을 버림
use std::collections::VecDeque;
struct Recent<T> { cap: usize, buf: VecDeque<T> }
impl<T> Recent<T> {
fn new(cap: usize) -> Self {
assert!(cap > 0);
Self { cap, buf: VecDeque::with_capacity(cap) }
}
/// 넣고, 넘쳤으면 밀려난 가장 오래된 값을 돌려줌
fn push(&mut self, x: T) -> Option<T> {
let evicted = if self.buf.len() == self.cap { self.buf.pop_front() } else { None };
self.buf.push_back(x);
evicted
}
fn newest_first(&self) -> impl Iterator<Item = &T> {
self.buf.iter().rev()
}
}
fn main() {
let mut r = Recent::new(3);
for x in 1..=5 {
if let Some(old) = r.push(x) { print!("버림 {old} · "); }
}
println!(); // 버림 1 · 버림 2 ·
println!("{:?}", r.newest_first().collect::<Vec<_>>()); // [5, 4, 3]
}
// 더 짧게 — 최신을 앞에 넣고 truncate 로 뒤(오래된 것)를 잘라 냄
let mut log: VecDeque<String> = VecDeque::with_capacity(101);
log.push_front(line); // 최신이 맨 앞
log.truncate(100); // truncate 는 '앞쪽 100개'를 남김 → 오래된 것이 잘림
with_capacity(cap) 로 만들면 가득 찬 뒤로는 pop_front 와 push_back 이
같은 버퍼를 돌며 할당이 다시 일어나지 않습니다. 최근 로그 · 최근 지연 시간 표본 · 채팅 스크롤백이 전부 이 모양입니다.
③ 재시도 큐 — 가장 이른 시각부터 (지수 백오프)
use std::cmp::Ordering;
use std::collections::BinaryHeap;
use std::time::{Duration, Instant};
struct Due<J> { at: Instant, seq: u64, attempt: u32, job: J }
// 비교는 (시각, 순번)만 — job 은 Ord 가 아니어도 됨
impl<J> PartialEq for Due<J> {
fn eq(&self, o: &Self) -> bool { (self.at, self.seq) == (o.at, o.seq) }
}
impl<J> Eq for Due<J> {}
impl<J> PartialOrd for Due<J> {
fn partial_cmp(&self, o: &Self) -> Option<Ordering> { Some(self.cmp(o)) }
}
impl<J> Ord for Due<J> {
// 이른 것이 '큼' → 최대 힙이 가장 이른 것부터 꺼냄
fn cmp(&self, o: &Self) -> Ordering { (o.at, o.seq).cmp(&(self.at, self.seq)) }
}
struct RetryQueue<J> { heap: BinaryHeap<Due<J>>, seq: u64 }
impl<J> RetryQueue<J> {
fn new() -> Self { Self { heap: BinaryHeap::new(), seq: 0 } }
fn schedule(&mut self, job: J, attempt: u32, now: Instant) {
let backoff = Duration::from_millis(100 * 2u64.pow(attempt.min(10))); // 100·200·400…
self.seq += 1;
self.heap.push(Due { at: now + backoff, seq: self.seq, attempt, job });
}
fn pop_ready(&mut self, now: Instant) -> Option<(J, u32)> {
if self.heap.peek()?.at > now { return None; } // 가장 이른 것도 아직
self.heap.pop().map(|d| (d.job, d.attempt))
}
fn next_wakeup(&self) -> Option<Instant> { self.heap.peek().map(|d| d.at) }
}
fn main() {
let t0 = Instant::now();
let mut rq = RetryQueue::new();
rq.schedule("a", 0, t0); // +100ms
rq.schedule("b", 2, t0); // +400ms
rq.schedule("c", 1, t0); // +200ms
let now = t0 + Duration::from_millis(250);
while let Some((job, attempt)) = rq.pop_ready(now) {
print!("{job}({attempt}) "); // a(0) c(1)
}
println!();
println!("{:?}", rq.next_wakeup().map(|t| t - t0)); // Some(400ms)
}
next_wakeup 이 핵심 — 워커는 "가장 이른 시각까지만" 자면 됩니다
(tokio::time::sleep_until 또는 recv_timeout). 1초마다 전부 훑는 폴링이 사라집니다.
실제로는 백오프에 무작위 흔들림(jitter)을 더해 재시도가 한꺼번에 몰리지 않게 합니다.
④ 이벤트 버퍼 — 500건 또는 1초마다 한 번에 저장
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::{interval, MissedTickBehavior};
const MAX_BATCH: usize = 500;
async fn batcher(mut rx: mpsc::Receiver<String>) {
let mut buf: Vec<String> = Vec::with_capacity(MAX_BATCH);
let mut tick = interval(Duration::from_secs(1));
tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
let room = MAX_BATCH - buf.len(); // ★ 미리 계산 (아래 &mut buf 와 겹치지 않게)
tokio::select! {
n = rx.recv_many(&mut buf, room) => {
if n == 0 { break; } // 닫혔고 비었음
if buf.len() >= MAX_BATCH { flush(&mut buf).await; }
}
_ = tick.tick() => {
if !buf.is_empty() { flush(&mut buf).await; } // 느린 시간대엔 1초마다
}
}
}
if !buf.is_empty() { flush(&mut buf).await; } // 종료 전 마지막 비우기
}
async fn flush(buf: &mut Vec<String>) {
println!("{}건 저장", buf.len()); // 실제로는 DB 에 한 번에 INSERT
buf.clear();
}
room 을 따로 빼는 이유 — rx.recv_many(&mut buf, MAX_BATCH - buf.len()) 로 한 줄에 쓰면
첫 인자에서 buf 를 가변으로 빌린 상태로 둘째 인자에서 다시 읽게 되어 E0502 입니다.
또 room 이 0 이면 recv_many 가 즉시 0 을 돌려줘 "닫힘"과 구분이 안 되므로,
가득 차는 순간 바로 flush 해서 0 이 되지 않게 했습니다. recv_many 는 select! 안에서 써도 안전한(cancel-safe) 메서드입니다.
실무에선 이렇게 — 작업 큐 + 워커 풀, 그리고 예약 실행 🔴 실무
⑤ 동시 실행 개수 제한 — 세마포어가 곧 큐
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
async fn fetch(url: String) -> usize { url.len() } // 실제로는 HTTP 요청
#[tokio::main]
async fn main() {
let limit = Arc::new(Semaphore::new(8)); // 동시에 최대 8개
let mut set = JoinSet::new();
for i in 1..=20 {
let url = format!("https://example.com/{i}");
// 자리가 날 때까지 여기서 대기 → 생성 속도가 처리 속도에 묶임 (배압)
let permit = Arc::clone(&limit).acquire_owned().await.unwrap();
set.spawn(async move {
let n = fetch(url).await;
drop(permit); // 끝나면 자리 반납
n
});
}
let (mut count, mut bytes) = (0, 0);
while let Some(r) = set.join_next().await {
count += 1;
bytes += r.unwrap();
}
println!("{count}건 · {bytes}바이트"); // 20건 · 431바이트
}
| 일감의 성격 | 워커 풀 모양 |
|---|---|
| I/O 대기 (HTTP · DB) | tokio — Semaphore 또는 mpsc + 워커 N |
| CPU 계산 · 데이터 병렬 | rayon — 큐를 직접 만들지 않음 (⚡ e05) |
| 스레드 기반 파이프라인 | crossbeam-channel bounded + 스레드 N (SQ 06) |
| 프로세스·서버를 넘음 | Redis · Kafka · RabbitMQ (SQ 11) |
⑥ 예약 실행 · 만료 — DelayQueue
// Cargo.toml: tokio-util = { version = "0.7", features = ["time"] }
// tokio-stream = "0.1"
use std::time::Duration;
use tokio_stream::StreamExt; // DelayQueue 는 Stream
use tokio_util::time::DelayQueue;
#[tokio::main]
async fn main() {
let mut dq: DelayQueue<&str> = DelayQueue::new();
let a = dq.insert("세션 A 만료", Duration::from_millis(30));
let _b = dq.insert("세션 B 만료", Duration::from_millis(10));
let c = dq.insert("세션 C 만료", Duration::from_millis(20));
dq.reset(&a, Duration::from_millis(50)); // 활동이 있었으니 연장
let _ = dq.remove(&c); // 로그아웃 → 취소
while let Some(expired) = dq.next().await {
println!("{}", expired.into_inner());
}
// 세션 B 만료
// 세션 A 만료 (비면 None 으로 루프가 끝남)
}
DelayQueue 는 tokio 타이머 휠 위에 있어
넣을 때 받은 키로 바로 연장·취소할 수 있고, 깨어날 시각을 직접 계산할 필요가 없습니다. 세션 만료 · 캐시 TTL · 타임아웃처럼
"넣은 뒤 바뀌는 시각"이 있으면 DelayQueue, 넣고 나면 안 바뀌면 힙이 더 단순합니다.
VecDeque 의 앞에서 버리고 뒤에 넣기, 재시도는
시각을 키로 한 최소 힙, 배치 저장은 recv_many + 시간 상한, 동시 실행 제한은 세마포어, 바뀌는 만료 시각은 DelayQueue.
시간을 인자로 받게 짜면 이 모든 코드를 sleep 없이 테스트할 수 있습니다.폴더가 아니라
크레이트와 가시성이
구조를 지킵니다
Rust 는 다른 언어보다 구조를 지키기가 쉽습니다. 모듈은 mod 로 직접 선언해야 생기고, 기본이 비공개이며,
크레이트끼리는 Cargo.toml 에 적은 방향으로만 use 할 수 있기 때문입니다.
대신 "어디서 나눌지"는 아무도 정해 주지 않습니다. 이 탭은 웹 서비스 · CLI · 데스크톱 · 라이브러리 · 워크스페이스의 권장 구조를 한 장씩 보여 주고,
각 구조에서 무엇을 어디에 두는지, 무엇을 공개하는지, 언제 다음 단계로 넘어가는지를 짧게 정리합니다.
모듈 문법 자체는 🦀 기초 탭 r09, 워크스페이스 설정은 🛠️ 빌드 탭 b05 에 있으니 여기서는 판단에 집중합니다.
Rust 프로젝트의 공통 바탕 — 패키지 하나, 뿌리 파일 둘
유형이 달라도 바닥은 같습니다. Cargo.toml 하나가 패키지이고, 그 안의 src/lib.rs 와 src/main.rs 가 각각 크레이트의 뿌리입니다.
Cargo 는 정해진 자리에 놓인 파일을 설정 없이 찾아내므로, 자리를 지키는 것만으로 빌드 · 테스트 · 예제 · 벤치가 따라옵니다.
모든 유형의 공통 골격 — Cargo 가 자동으로 찾는 자리
myapp/
├─ Cargo.toml 이름 · 의존성 · [lints]
├─ Cargo.lock 실행 파일이면 커밋 (🧰 s04)
├─ src/
│ ├─ lib.rs 라이브러리 크레이트의 뿌리 — 로직은 여기
│ ├─ main.rs 바이너리 크레이트의 뿌리 — 얇게
│ ├─ bin/
│ │ └─ migrate.rs 두 번째 실행 파일 (--bin migrate)
│ ├─ config.rs 모듈 = 파일 하나 (mod config;)
│ ├─ storage.rs storage 모듈 본문 · 하위 모듈 선언
│ └─ storage/
│ ├─ disk.rs storage::disk
│ └─ memory.rs storage::memory
├─ tests/ 통합 테스트 — 공개 API 만 (STR 08)
├─ examples/ cargo run --example 이름 (STR 07)
├─ benches/ cargo bench (STR 08)
└─ build.rs 있을 때만 — 컴파일 전에 실행 (STR 09)
myapp 이면 main.rs 는
myapp::run() 처럼 라이브러리를 남처럼 부릅니다. tests/ 의 통합 테스트도 라이브러리만 볼 수 있으므로,
로직이 main.rs 에 있으면 통합 테스트를 쓸 수 없습니다.
모듈 파일 규칙 — foo.rs + foo/ vs foo/mod.rs
// src/lib.rs — 모듈 트리는 여기서 선언해야 생깁니다
pub mod storage; // src/storage.rs 또는 src/storage/mod.rs
mod config; // src/config.rs
// src/storage.rs — 2018 이후 방식 ★
pub mod disk; // src/storage/disk.rs
pub mod memory; // src/storage/memory.rs
pub use disk::DiskStore; // 자주 쓰는 이름은 한 층 위로 (STR 02)
// 옛 방식이라면 위 내용이 src/storage/mod.rs 에 들어갑니다.
// 컴파일러에게는 둘이 완전히 같습니다.
| 방식 | 장점 | 단점 |
|---|---|---|
storage.rs + storage/ | 에디터 탭에 파일 이름이 그대로 보임 · The Book 이 먼저 소개 | 파일과 폴더가 떨어져 정렬됨 |
storage/mod.rs | 모듈 하나가 폴더 하나에 다 들어감 | 탭마다 mod.rs — 어느 것인지 헷갈림 |
storage.rs 와 storage/mod.rs 가 함께 있으면 E0761(모듈 파일이 두 곳에 있음).
어느 쪽이 "정답"이라기보다 한 크레이트 안에서 섞지 않는 것이 규칙입니다. 새 프로젝트는 2018 방식을 권하고, clippy 로 강제할 수 있습니다(STR 10).
mod · use 문법과 첫 에러는 🦀 r09 참고.
규모별 진화 — 언제 다음 단계로 넘어가나 🟡 중급
lib.rs 로 옮깁니다.| 전환 | 신호 (하나라도 해당하면) | 하는 일 |
|---|---|---|
| main.rs → 모듈 파일 | 한 파일 500줄+ · user_ · order_ 같은 접두사 함수 묶음이 보임 | 접두사를 모듈 이름으로: mod user; |
| 모듈 → lib + main | tests/ 에서 검증하고 싶음 · 두 번째 실행 파일 · 바인딩 계획 | 로직을 lib.rs 로, main.rs 는 10~30줄 |
| lib → 기능 모듈 트리 | 모듈 15개+ · "주문 쪽 · 회원 쪽" 대화가 나옴 | orders.rs + orders/ · pub(crate) 경계 (STR 02) |
| → 워크스페이스 | 한 줄 고쳐도 cargo check 가 수십 초 · 배포 단위 2+ · proc-macro · 무거운 의존성 격리 | crates/* 로 분리 (STR 03) |
lib.rs(로직)와 main.rs(얇은 입구), 테스트 · 예제 · 벤치는 Cargo 가 찾는 자리에.
모듈 파일은 foo.rs + foo/ 로 통일하고, 테스트 · 진입점 · 빌드 시간 · 배포 단위가 늘 때마다 한 단계씩 나눕니다.모듈 가시성으로 경계 만들기 — 폴더 대신 pub 의 범위
다른 언어에서 "이 폴더는 건드리지 마세요"는 문서나 리뷰로 지킵니다. Rust 는 가시성 한정자가 그 일을 합니다.
한 크레이트 안의 기능 모듈 사이 경계는 pub(crate) 와 pub(super) 로, 크레이트 밖과의 경계는 pub use 재노출로 긋습니다.
구조 — 공개 얼굴은 lib.rs 한 곳
netkit/src/
├─ lib.rs 공개 API 목록 (pub use) + prelude
├─ client.rs pub struct Client — 공개 타입의 정의
├─ config.rs pub struct Config
├─ error.rs pub enum Error · pub type Result
├─ wire.rs pub(crate) — 크레이트 내부 프로토콜
└─ wire/
└─ crc.rs pub(super) — wire 만 쓰는 도우미
// src/lib.rs
mod client; // 모듈 자체는 비공개 —
mod config; // 경로(netkit::client::…)를 약속하지 않음
mod error;
mod wire;
pub use client::Client; // 사용자는 netkit::Client 로 씀
pub use config::Config;
pub use error::{Error, Result};
/// `use netkit::prelude::*;` 한 줄로 자주 쓰는 것 전부
pub mod prelude {
pub use crate::{Client, Config, Error, Result};
}
안쪽 경계 — pub(crate) · pub(super)
// src/wire.rs
mod crc; // wire/crc.rs
pub(crate) struct Frame(Vec<u8>); // client.rs 에서는 보이고, 밖에선 안 보임
impl Frame {
pub(crate) fn encode(msg: &str) -> Frame {
let mut buf = msg.as_bytes().to_vec();
buf.extend_from_slice(&crc::crc16(&buf).to_be_bytes());
Frame(buf)
}
}
// src/wire/crc.rs
pub(super) fn crc16(data: &[u8]) -> u16 { // wire 와 그 자식만
data.iter().fold(0u16, |acc, &b| acc.rotate_left(5) ^ b as u16) // 예시용
}
// src/client.rs
use crate::wire::Frame; // OK — 같은 크레이트
// use crate::wire::crc::crc16; // ❌ E0603: crc 모듈이 비공개
pub(crate) 가 몰리는 곳이 다음 크레이트 경계 후보입니다. 기능 모듈 둘이 서로의 pub(crate) 를 잔뜩 쓰면 경계가 틀린 것이고,
한 모듈이 다른 모두에게 pub(crate) 를 제공하면 그 모듈은 따로 떼어 낼 만한 바닥(core)입니다.
❌ 내부 폴더가 곧 공개 경로
// lib.rs
pub mod client;
pub mod wire; // 내부 프로토콜까지 공개
// 사용자 코드
use netkit::client::Client;
use netkit::wire::Frame; // 이제 Frame 을 못 바꿈
// → client.rs 를 쪼개는 순간 모든 사용자가 깨짐
✅ 모듈은 비공개, 이름만 평평하게
// lib.rs
mod client;
mod wire;
pub use client::Client;
// 사용자 코드
use netkit::Client;
// → client.rs 를 client/ 폴더로 쪼개도
// lib.rs 의 pub use 한 줄만 고치면 됨
| 한정자 | 보이는 범위 | 구조에서의 쓰임 |
|---|---|---|
| (없음) | 선언한 모듈과 그 자식 | 모든 것의 기본값 — 일단 여기서 시작 |
pub(super) | 부모 모듈까지 | orders/ 안 파일끼리만 쓰는 도우미 |
pub(in crate::orders) | 지정한 조상 모듈까지 | 깊은 트리에서 경계를 정확히 — 드물게 |
pub(crate) | 크레이트 전체 | 기능 모듈 사이의 내부 API · 공용 타입 |
pub (+ pub use) | 크레이트 밖 | 라이브러리의 약속 · 워크스페이스의 다른 크레이트 |
std::prelude 를 흉내 낸 것으로, 트레이트를 많이 가져와야 쓸 수 있는 크레이트
(예: diesel::prelude · bevy::prelude)에서 빛납니다. 공개 이름이 열 개 남짓이면 pub use 만으로 충분합니다.
비공개 모듈 안의 pub 이 실제로 밖에 닿는지는 rustc 의 unreachable_pub 린트가 짚어 줍니다(STR 10).
lib.rs 의 pub use 로 평평하게.
기능 모듈 사이는 pub(crate), 폴더 안 도우미는 pub(super). 가시성이 곧 경계라서, 경계를 어기면 리뷰가 아니라 컴파일러가 막습니다.Cargo 워크스페이스 — crates/* 와 의존 방향
워크스페이스 명령 · xtask · "언제 나눌 것인가"의 기본은 🛠️ b05 와 🧰 s04 에 있습니다.
여기서는 그 위에 폴더를 어떻게 배치하고, 버전 · 메타데이터 · 린트를 루트에서 어떻게 물려주고, 크레이트 사이 화살표를 어느 쪽으로 둘지만 봅니다.
Rust 에서는 크레이트 경계가 곧 컴파일러가 지키는 경계입니다 — Cargo.toml 에 적지 않은 크레이트는 use 자체가 안 됩니다.
평평한 crates/ — 폴더 이름 = 패키지 이름
shop/
├─ Cargo.toml 가상 매니페스트 — [workspace] 만, [package] 없음
├─ Cargo.lock 워크스페이스에 하나 (전원이 같은 버전)
├─ target/ 하나 — 빌드 캐시 공유
├─ deny.toml 의존 규칙 (STR 10)
├─ crates/
│ ├─ shop-domain/ 규칙 · 타입 — 의존성 최소
│ ├─ shop-infra/ sqlx · reqwest 구현
│ ├─ shop-api/ axum 라우터 · 핸들러
│ ├─ shop-server/ bin — main.rs 30줄
│ └─ shop-cli/ bin — 운영 도구
└─ xtask/ 빌드 자동화 (🛠️ b05)
crates/backend/db/ 처럼 중첩하면 members 글롭과 경로가 복잡해지고 찾기도 어렵습니다.
rust-analyzer 저장소가 이 방식(crates/* 한 층, 폴더 이름 = 패키지 이름)으로 크레이트 수십 개를 운영합니다.
규칙이라기보다 널리 따르는 관례입니다.
루트에서 물려주기 — 버전 · 메타 · 린트
# shop/Cargo.toml
[workspace]
resolver = "3" # edition 2024 짝 — 가상 매니페스트는 직접 적어야 합니다
members = ["crates/*", "xtask"]
[workspace.package]
version = "0.4.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres"] }
shop-domain = { path = "crates/shop-domain" } # 내부 크레이트도 이름 하나로
shop-infra = { path = "crates/shop-infra" }
[workspace.lints.rust]
unsafe_code = "deny"
unreachable_pub = "warn" # STR 02 — 밖에 안 닿는 pub 찾기
[workspace.lints.clippy]
all = { level = "warn", priority = -1 } # 묶음은 priority 를 낮춰야
unwrap_used = "warn" # 개별 설정이 이깁니다
# crates/shop-infra/Cargo.toml
[package]
name = "shop-infra"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
shop-domain.workspace = true
sqlx = { workspace = true, features = ["uuid"] } # 기능은 더할 수만 · 버전은 못 바꿈
tokio.workspace = true
[lints]
workspace = true # ★ 이 줄이 없으면 [workspace.lints] 가 적용되지 않습니다
크레이트를 쪼개는 기준 🟡 중급
| 기준 | 이럴 때 쪼갭니다 | 주의 |
|---|---|---|
| 컴파일 시간 | 크레이트가 병렬 · 증분 컴파일의 단위입니다. 자주 바뀌는 코드와 무거운 의존성(sqlx 매크로 · 거대한 생성 코드)을 떼면 한 줄 수정에 다시 도는 범위가 줄어듭니다 | 쪼개기 전에 cargo build --timings 로 실제 병목 확인 (🛠️ b10) |
| 재사용 | 실행 파일이 둘 이상(server · cli · worker), 파이썬 · Node 바인딩이 같은 로직을 씀 | "언젠가 쓸지도"는 신호가 아닙니다 |
| 경계 | "domain 은 sqlx 를 몰라야 한다"를 리뷰가 아니라 빌드로 지키고 싶음 | 한 크레이트 안의 모듈로는 방향을 강제할 수 없음 — 그래서 쪼갬 |
| 강제 사항 | 절차 매크로(proc-macro = true)는 반드시 별도 크레이트 (🔬 d09) | 매크로 크레이트는 shop-macros 처럼 이름으로 드러냄 |
❌ 버전이 멤버마다 흩어짐
# crates/shop-api/Cargo.toml
serde = "1.0.190"
# crates/shop-infra/Cargo.toml
serde = { version = "1", features = ["derive"] }
# → 올릴 때 파일 다섯 개를 고치고 하나는 빠뜨림
# → 린트도 멤버마다 달라 경고 기준이 제각각
✅ 루트에 한 번, 멤버는 workspace = true
# 루트 [workspace.dependencies] 에 serde 한 줄
# 멤버는
serde.workspace = true
[lints]
workspace = true
# → 버전 · 기능 · 린트 정책이 한 파일에서 보임
[lints] 는 전부 아니면 전무 — workspace = true 와 개별 린트를 같은 표에 섞으면 Cargo 가 거부합니다.
한 크레이트만 예외가 필요하면 코드에 #![allow(...)] 를 이유와 함께 붙이세요.
[workspace.lints] 는 Rust 1.74 부터, resolver = "3" 은 1.84 부터 씁니다.
crates/* 한 층에 폴더 이름 = 패키지 이름, 버전 · 메타 · 린트는 루트에서 물려줍니다.
화살표는 전부 domain 쪽으로 — 쪼개는 이유는 컴파일 시간 · 재사용 · 경계 셋 중 하나여야 합니다.Axum 웹 서비스 — 한 크레이트 안의 routes · handlers · domain · repo
Router · 추출기 · 레이어가 맞물리는 원리는 🌐 w02, 워크스페이스로 나눈 큰 서비스의 골격 · AppState · 설정은 🌐 w03 에 있습니다. 이 섹션은 그 앞 단계 — 크레이트 하나로 시작하는 서비스에서 파일을 어떻게 나누고, 기능별 Router 를 어떻게 합치고, AppState 를 어디에 둘지입니다. 대부분의 서비스는 여기서 시작해 한참을 이 모양으로 삽니다.
크레이트 하나짜리 서비스
api-server/
├─ Cargo.toml
├─ migrations/ sqlx migrate (🌐 w04)
├─ src/
│ ├─ main.rs 설정 → 풀 → app(state) → serve · 20줄
│ ├─ lib.rs pub fn app(state) -> Router ← 테스트도 이걸 부름
│ ├─ config.rs
│ ├─ state.rs AppState (Clone) — 풀 · 설정 · 클라이언트
│ ├─ error.rs AppError + impl IntoResponse
│ ├─ routes.rs 기능별 Router 를 nest · merge 로 합침
│ ├─ handlers.rs pub(crate) mod users; …
│ ├─ handlers/
│ │ ├─ users.rs 추출기 → domain · repo 호출 → 응답
│ │ └─ orders.rs
│ ├─ domain.rs + domain/ 타입 · 규칙 — axum · sqlx 를 모름
│ └─ repo.rs + repo/ sqlx 쿼리 — &PgPool 을 인자로 받음
└─ tests/api.rs app(state) 에 요청을 바로 넣음 (🌐 w11)
AppState 는 크레이트 뿌리의 state.rs — main.rs 가 만들고, lib.rs 의 app() 이
with_state 로 딱 한 번 주입합니다. 하위 라우터는 전부 Router<AppState> 를 돌려주기만 합니다.
필드 구성(풀은 Arc 없이, 큰 설정은 Arc)은 🌐 w03 참고.
기능별 Router 합치기 — nest · merge
// src/lib.rs
pub mod config;
pub mod error;
pub mod state;
mod domain;
mod handlers;
mod repo;
mod routes;
pub use state::AppState;
pub fn app(state: AppState) -> axum::Router {
routes::router().with_state(state) // Router<AppState> → Router
}
// src/routes.rs
use axum::{routing::get, Router};
use crate::{handlers, state::AppState};
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/health", get(handlers::health))
.nest("/api/users", users()) // 접두사를 붙여 끼움
.merge(admin()) // 접두사 없이 합침
}
fn users() -> Router<AppState> {
use handlers::users::*;
Router::new()
.route("/", get(list).post(create))
.route("/{id}", get(get_one)) // 0.8 경로 문법 {id}
}
fn admin() -> Router<AppState> {
Router::new().route("/admin/stats", get(handlers::admin_stats))
}
// src/handlers/users.rs — 얇게: 꺼내고, 부르고, 돌려준다
use axum::{extract::{Path, State}, Json};
use crate::{domain::user::User, error::AppError, repo, state::AppState};
pub(crate) async fn get_one(
State(st): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<User>, AppError> {
let user = repo::users::find(&st.db, id).await? // sqlx::Error → AppError (From)
.ok_or(AppError::NotFound)?;
Ok(Json(user))
}
// list · create 와 handlers.rs 의 health · admin_stats 는 같은 모양이라 생략
❌ 계층 폴더가 기능 20개를 다 품음
src/handlers/ users · orders · coupons · … 20개
src/repo/ users · orders · coupons · … 20개
src/domain/ users · orders · coupons · … 20개
# 쿠폰 기능 하나 고치려고 폴더 세 개를 오갑니다
# 지울 때도 세 군데 — 하나는 꼭 남습니다
✅ 커지면 기능 모듈로 묶기
src/coupons.rs pub(crate) fn router() -> Router<AppState>
src/coupons/
├─ handlers.rs pub(super) — 이 기능만 씀
├─ repo.rs pub(super)
└─ model.rs pub(crate) — 다른 기능도 쓰는 타입만
# routes.rs: .nest("/api/coupons", coupons::router())
| 규모 | 구조 | 넘어가는 신호 |
|---|---|---|
| 엔드포인트 ~10개 | 위의 계층 폴더 (routes · handlers · domain · repo) | 한 폴더에 파일 10개+ · 기능 하나 고치는데 폴더 셋 |
| 기능 5개 이상 | 기능 모듈 (coupons.rs + coupons/) · 기능 안은 pub(super) | domain 에 sqlx 가 새어 들어옴 · 두 번째 실행 파일 |
| 여러 실행 파일 · 팀 | 워크스페이스 domain · infra · api · bin (🌐 w03 · STR 03) | — |
"/")나 빈 경로에 nest 하면 패닉합니다. 접두사 없이 합칠 때는 merge 를 쓰세요.
합치는 라우터들은 같은 상태 타입(Router<AppState>)이어야 하고, with_state 는 맨 바깥에서 한 번만 부릅니다.
lib.rs 의 app(state) 가 입구, routes.rs 가 지도, 핸들러는 얇게, 규칙은 domain, SQL 은 repo.
기능이 늘면 기능 모듈로, 방향을 빌드로 지켜야 하면 크레이트로 넘어갑니다.CLI 도구 — main 은 얇게, 로직은 lib 에
clap 사용법은 🧰 t03, 빠른 CLI 의 요령과 배포는 🚀 h09 에 있습니다. 여기서는 파일 배치만 봅니다 —
#[derive(Parser)] 구조체를 어디에 두고, 서브커맨드를 어떻게 모듈로 나누고, 왜 main.rs 를 열 줄로 줄이는지.
이유는 하나입니다: 로직이 라이브러리에 있어야 함수로 바로 테스트하고, 나중에 GUI · 서버에서도 재사용할 수 있습니다.
서브커맨드가 있는 CLI
mytool/
├─ Cargo.toml
├─ src/
│ ├─ main.rs 10줄 — parse → run → 종료 코드
│ ├─ lib.rs pub fn run(cli: Cli) -> anyhow::Result<()>
│ ├─ cli.rs #[derive(Parser)] Cli · Command · 각 Args
│ ├─ commands.rs pub(crate) mod init; pub(crate) mod sync;
│ ├─ commands/
│ │ ├─ init.rs pub(crate) fn run(args: &InitArgs)
│ │ └─ sync.rs
│ ├─ config.rs 설정 파일 읽기 (🧰 t09)
│ └─ output.rs 사람용 텍스트 / --json 출력을 한곳에
└─ tests/
└─ cli.rs 실제 바이너리 실행 (assert_cmd)
// src/main.rs — 하는 일은 셋: 파싱 · 위임 · 종료 코드
use clap::Parser;
use mytool::cli::Cli;
use std::process::ExitCode;
fn main() -> ExitCode {
match mytool::run(Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("오류: {e:#}"); // anyhow 의 원인 사슬까지 한 줄로
ExitCode::FAILURE
}
}
}
clap 구조체는 cli.rs, 실행은 commands/
// src/cli.rs — 명령행 스펙만. 로직 없음
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(name = "mytool", version, about = "저장소 동기화 도구")]
pub struct Cli {
/// 자세히 출력
#[arg(short, long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// 설정 파일 만들기
Init(InitArgs),
/// 원격과 동기화
Sync(SyncArgs),
}
#[derive(Debug, Args)]
pub struct InitArgs {
#[arg(long, default_value = "mytool.toml")]
pub path: PathBuf,
}
#[derive(Debug, Args)]
pub struct SyncArgs {
#[arg(long)]
pub dry_run: bool,
}
// src/lib.rs
pub mod cli;
mod commands;
mod config;
mod output;
use cli::{Cli, Command};
pub fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
Command::Init(args) => commands::init::run(&args),
Command::Sync(args) => commands::sync::run(&args, cli.verbose),
}
}
#[cfg(test)]
mod tests {
use super::cli::Cli;
use clap::{CommandFactory, Parser};
#[test]
fn cli_spec_is_valid() {
Cli::command().debug_assert(); // 인자 정의 충돌을 테스트에서 잡음
}
#[test]
fn parses_sync() {
let cli = Cli::try_parse_from(["mytool", "sync", "--dry-run"]).unwrap();
assert!(matches!(cli.command, super::Command::Sync(ref a) if a.dry_run));
}
}
| 무엇을 | 어디에 | 이유 |
|---|---|---|
| clap derive 구조체 | cli.rs (pub mod cli) | main · 테스트 · 셸 자동완성 생성(build.rs 나 xtask)이 함께 씀 |
| 서브커맨드 실행 | commands/이름.rs 의 run(&Args) | 명령 하나 = 파일 하나 · 추가 · 삭제가 파일 단위 |
| 출력 형식 | output.rs | println! 이 흩어지면 --json 을 붙일 수 없음 |
| 종료 코드 · 에러 표시 | main.rs 만 | 라이브러리는 Result 를 돌려줄 뿐 exit 하지 않음 |
tests/)에는 Cargo 가 CARGO_BIN_EXE_mytool 환경 변수로 빌드된 실행 파일 경로를 넘겨 줍니다.
assert_cmd 가 이를 감싸 종료 코드 · 표준 출력을 검사합니다. 로직 테스트는 lib 에서, 바이너리 테스트는 "인자가 제대로 이어지나" 정도만.
도구가 커져 GUI 나 서버가 같은 로직을 쓰게 되면 lib 을 crates/mytool-core 로 떼어 워크스페이스로 넘어갑니다(STR 03).
cli.rs 에 clap 구조체, commands/ 에 명령마다 파일 하나, main.rs 는 파싱 · 위임 · 종료 코드 세 가지만.
exit 는 main 에서만 하고, 로직은 라이브러리 함수로 테스트합니다.Tauri 데스크톱 — src-tauri 와 프런트 분리, commands 모듈
만들어진 폴더 하나하나의 뜻은 🖥️ u02, 커맨드 · IPC 는 u03, 권한은 u06, 실전 앱의 전체 구조는 u13 에 있습니다. 여기서는 앱이 커질 때 Rust 쪽 파일을 어떻게 나누고, 로직을 언제 UI 없는 크레이트로 떼는지만 짧게 봅니다.
src-tauri/src — 커맨드는 모듈로
src-tauri/src/
├─ main.rs my_app_lib::run() 만 (데스크톱 진입점)
├─ lib.rs Builder · 플러그인 · invoke_handler 등록
├─ commands.rs pub mod files; pub mod settings;
├─ commands/
│ ├─ files.rs #[tauri::command] pub async fn …
│ └─ settings.rs
├─ state.rs AppState (.manage)
└─ error.rs Serialize 되는 에러 — 프런트가 받음
// src-tauri/src/lib.rs
mod commands;
mod error;
mod state;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(state::AppState::default())
.invoke_handler(tauri::generate_handler![
commands::files::list_dir, // 모듈 경로 그대로
commands::settings::get_settings,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
커지면 — 워크스페이스로 core 분리
# 저장소 루트 Cargo.toml
[workspace]
resolver = "3"
members = ["src-tauri", "crates/core"]
# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
my-app-core = { path = "../crates/core" }
lib.rs 에 두고 pub 을 붙이면 매크로가 만드는 글루 코드 이름이 겹쳐 빌드가 깨집니다(Tauri 공식 문서의 제약).
커맨드는 별도 모듈에 pub fn 으로 두고 generate_handler! 에 경로로 등록하세요 — 위 구조가 그 모양입니다.
capabilities/*.json)은 창 단위로 쪼개 u06 처럼 관리합니다.
src/, Rust 는 src-tauri/, 실제 구성은 lib.rs 의 run().
커맨드는 commands/ 모듈에 pub fn 으로, 로직이 커지면 UI 없는 core 크레이트로 내립니다.라이브러리 크레이트 — 공개 API · feature · semver
앱은 내가 쓰고 내가 고칩니다. 라이브러리는 남이 쓰고, 내가 바꾸면 남의 빌드가 깨집니다.
그래서 라이브러리 구조의 핵심은 폴더가 아니라 "무엇을 약속했나"를 한눈에 보이게 하는 것입니다.
feature 문법과 cargo-hack 은 🛠️ b04, 문서 테스트 문법은 🦀 r11 에 있습니다.
공개 라이브러리 한 벌
fastparse/
├─ Cargo.toml [features] · [package.metadata.docs.rs]
├─ README.md 첫 예제 = examples/basic.rs 와 같게
├─ CHANGELOG.md semver 약속의 기록
├─ src/
│ ├─ lib.rs 목차 — //! 문서 · pub use · feature 게이트
│ ├─ error.rs pub enum Error (#[non_exhaustive])
│ ├─ parser.rs 구현 — 비공개 모듈
│ ├─ json.rs #[cfg(feature = "json")]
│ └─ prelude.rs 트레이트 위주로 작게
├─ examples/
│ └─ basic.rs cargo run --example basic
├─ benches/
│ └─ parse.rs criterion · harness = false (⚡ e01)
└─ tests/
└─ public_api.rs 공개 API 만으로 쓰는 시나리오 (STR 08)
feature 는 더하기만 — additive 원칙
# fastparse/Cargo.toml
[features]
default = ["json"]
json = ["dep:serde_json"] # dep: — 의존성 이름이 feature 로 새지 않게
serde = ["dep:serde"]
tls-rustls = ["dep:rustls"]
tls-native = ["dep:native-tls"]
[dependencies]
serde_json = { version = "1", optional = true }
serde = { version = "1", optional = true, features = ["derive"] }
rustls = { version = "0.23", optional = true }
native-tls = { version = "0.2", optional = true }
[package.metadata.docs.rs]
all-features = true # docs.rs 에 모든 API 를 보이게
// src/lib.rs — feature 가 켜지면 "생기기만" 합니다
#[cfg(feature = "json")]
mod json;
#[cfg(feature = "json")]
pub use json::to_json;
// 둘 다 켜져도 빌드는 되어야 합니다 — 우선순위로 고르기
// (rustls_backend · native_backend 모듈은 각 feature 게이트 아래에 있다고 가정)
#[cfg(feature = "tls-rustls")]
type Tls = rustls_backend::Backend;
#[cfg(all(feature = "tls-native", not(feature = "tls-rustls")))]
type Tls = native_backend::Backend;
tls-rustls, B 가 tls-native 를 켜면 한 빌드에 둘 다 켜집니다.
그래서 "켜면 기능이 사라지는" feature 나 compile_error! 로 막는 상호 배타 feature 는 남의 빌드를 깨뜨립니다. no_std 도 같은 이유로 std feature 를 켜는 쪽으로 설계합니다.
| 규칙 | 왜 | 도구 · 표기 |
|---|---|---|
확장할 enum · 구조체에 #[non_exhaustive] | 나중에 변형 · 필드를 더해도 메이저 버전이 아님 (밖에서는 _ 갈래 필수 · 리터럴 생성 불가) | pub enum Error 에 거의 항상 |
| 공개 구조체 필드는 신중히 | pub 필드는 이름 · 타입 모두 약속. 생성자 + 게터가 바꾸기 쉬움 | 설정류는 빌더 패턴 |
| 예제는 실행되게 | /// 예제는 cargo test 가 컴파일 · 실행 — 문서가 낡으면 빌드가 깨짐 | 네트워크 예제는 no_run |
| feature 조합 검사 | 기본값에서만 빌드되는 조합 함정 | cargo hack check --feature-powerset (🛠️ b04) |
| 게시 전 semver 검사 | "패치 버전인데 함수를 지웠다"를 사람이 놓침 | cargo semver-checks — crates.io 의 직전 버전과 공개 API 비교 |
lib.rs 의 pub use 목차로, 확장할 타입엔 #[non_exhaustive].
feature 는 켜면 생기기만 하게, 게시 전에는 cargo semver-checks 로 약속이 깨졌는지 기계에게 묻습니다.테스트 위치 — 같은 파일 · tests/ · 벤치가 보는 범위
Rust 는 테스트를 어디에 두느냐가 무엇을 볼 수 있느냐를 정합니다. 같은 파일의 mod tests 는 비공개 함수까지,
tests/ 의 파일은 남의 크레이트처럼 공개 API 만 봅니다. 문법과 하네스 내부는 🦀 r11, 서버 테스트는 🌐 w11 에 있습니다.
단위 테스트 — 같은 파일 맨 아래
// src/billing.rs
fn split(total: i64, n: i64) -> (i64, i64) { // 비공개 — 몫 · 나머지
(total / n, total % n)
}
pub fn per_person(total: i64, n: i64) -> i64 {
let (q, r) = split(total, n);
if r > 0 { q + 1 } else { q } // 남는 돈은 올림
}
#[cfg(test)] // cargo test 때만 컴파일
mod tests {
use super::*; // 부모의 비공개 항목까지 보입니다
#[test]
fn split_keeps_remainder() {
assert_eq!(split(10_000, 3), (3333, 1));
}
#[test]
fn rounds_up() {
assert_eq!(per_person(10_000, 3), 3334);
}
}
#[cfg(test)] mod tests; 로 선언만 남기고
src/billing/tests.rs 로 옮깁니다 — 여전히 자식 모듈이라 비공개 항목이 보입니다.
통합 테스트 — tests/ 는 파일마다 별도 크레이트
tests/
├─ common/
│ └─ mod.rs 공용 헬퍼 — tests/common.rs 로 두면
│ "테스트 0개" 크레이트가 하나 더 생김
├─ checkout.rs → 테스트 실행 파일 1
└─ refund.rs → 테스트 실행 파일 2 (링크도 따로)
# 파일이 많아져 링크 시간이 길면 하나로 묶기
tests/
└─ it/
├─ main.rs mod common; mod checkout; mod refund;
├─ common.rs
├─ checkout.rs
└─ refund.rs → 실행 파일 1개
// tests/common/mod.rs
#![allow(dead_code)] // 헬퍼를 안 쓰는 테스트 파일에서 경고 방지
pub fn sample_total() -> i64 { 10_000 }
// tests/checkout.rs
mod common;
use shop::per_person; // lib.rs 의 pub use billing::per_person; 덕분
// shop::billing::split 은 불가 — 비공개
#[test]
fn split_bill_three_ways() {
assert_eq!(per_person(common::sample_total(), 3), 3334);
}
lib.rs 를 두는 가장 실용적인 이유입니다.| 종류 | 위치 | 무엇을 검증하나 |
|---|---|---|
| 단위 | 같은 파일 #[cfg(test)] mod tests | 비공개 함수의 경계값 · 분기 — 빠르고 많게 |
| 통합 | tests/*.rs · tests/*/main.rs | "사용자가 이 순서로 부르면 된다" — 공개 API 시나리오 |
| 문서 | /// · //! 코드 블록 | README · 문서 예제가 지금도 컴파일되나 |
| 바이너리 | tests/cli.rs + CARGO_BIN_EXE_이름 | 인자 · 종료 코드 (STR 05) |
| 벤치 | benches/*.rs + [[bench]] harness = false | 성능 회귀 — criterion (⚡ e01) |
| 워크스페이스 전체 시나리오 | 테스트 전용 크레이트 crates/shop-e2e | 여러 크레이트를 엮는 흐름 — 각 크레이트의 tests/ 에 흩지 않음 |
tests/common/mod.rs.
통합 테스트 파일이 많아 느리면 tests/it/main.rs 하나로 묶습니다.빌드 스크립트 · FFI · 생성 코드 — build.rs · OUT_DIR · *-sys
C 라이브러리를 붙이거나, 프로토콜 정의 · 표에서 코드를 만들어야 하면 컴파일 전에 도는 코드가 필요합니다.
Rust 에서 그 자리는 패키지 루트의 build.rs 하나이고, 생성물은 OUT_DIR 에만 씁니다.
bindgen · cbindgen 의 사용법은 🔗 f08 · f04 에 있으니, 여기서는 파일을 어디에 두는지만 봅니다.
표에서 코드 만들기 — OUT_DIR 로
errcodes/
├─ Cargo.toml
├─ build.rs 패키지 루트 (src/ 안이 아님)
├─ schema/codes.txt NOT_FOUND=404 처럼 한 줄씩
└─ src/
├─ lib.rs
└─ codes.rs include!(…/codes.rs) 한 줄
// build.rs — 작업 디렉터리는 패키지 루트입니다
use std::{env, fs, path::Path};
fn main() {
println!("cargo::rerun-if-changed=schema/codes.txt"); // 이 파일이 바뀔 때만 재실행
let src = fs::read_to_string("schema/codes.txt").unwrap();
let mut out = String::from("pub const CODES: &[(&str, u16)] = &[\n");
for line in src.lines().filter(|l| !l.trim().is_empty()) {
let (name, code) = line.split_once('=').expect("NAME=CODE 형식");
out.push_str(&format!(" ({:?}, {}),\n", name.trim(), code.trim()));
}
out.push_str("];\n");
let dest = Path::new(&env::var("OUT_DIR").unwrap()).join("codes.rs");
fs::write(dest, out).unwrap();
}
// src/codes.rs
include!(concat!(env!("OUT_DIR"), "/codes.rs"));
cargo:: 두 콜론 문법은 Rust 1.77 부터입니다. MSRV 가 그보다 낮으면 옛 문법 cargo:rerun-if-changed= 를 씁니다.
build.rs 가 쓰는 크레이트는 [build-dependencies] 에 따로 적습니다.
*-sys 관례 — 원시 FFI 와 안전한 래퍼를 둘로
fastlz/ 워크스페이스 (예시 이름)
├─ fastlz-sys/
│ ├─ Cargo.toml links = "fastlz" · [build-dependencies] cc
│ ├─ build.rs C 소스 컴파일 또는 시스템 라이브러리 찾기
│ ├─ vendor/fastlz.c 번들한 C 소스 (git submodule 이 흔함)
│ └─ src/lib.rs extern "C" 선언만 — 전부 unsafe
└─ fastlz/
├─ Cargo.toml fastlz-sys 에 의존
└─ src/lib.rs 안전한 API — Result · 소유권 · Drop
// fastlz-sys/build.rs
fn main() {
println!("cargo::rerun-if-changed=vendor/fastlz.c");
cc::Build::new()
.file("vendor/fastlz.c")
.compile("fastlz"); // 정적 라이브러리를 만들고 링크 지시까지 출력
}
links = "fastlz" 는 "이 네이티브 라이브러리를 링크하는 크레이트는 그래프 전체에 하나"를 Cargo 가 보장하게 합니다.
다른 안전한 래퍼를 쓰고 싶은 사람도 같은 -sys 를 공유하면 링크 충돌이 없습니다. 이름 끝의 -sys 는 crates.io 의 관례입니다.
| 생성 코드를 어디에 | 언제 | 주의 |
|---|---|---|
OUT_DIR (커밋 안 함) | 입력이 저장소 안에 있고 생성 도구가 가벼움 · prost-build 같은 코드 생성기 (🌐 w09) | 사용자 PC 에도 생성 도구가 필요 (bindgen 이면 libclang) |
src/generated/ (커밋) | 생성 도구가 무겁거나 드물게 바뀜 · 사용자에게 도구를 요구하고 싶지 않음 | xtask 로 재생성 · CI 에서 "다시 생성해도 diff 0" 검사 |
| 생성 전용 크레이트 | 워크스페이스에서 생성 코드가 큼 (shop-proto) | 그 크레이트만 다시 돌아 증분 빌드가 빨라짐 |
build.rs 는 패키지 루트에, 결과는 OUT_DIR 에, 재실행 조건은 rerun-if-changed 로.
네이티브 라이브러리는 foo-sys(원시) + foo(안전) 둘로 나눕니다.한 장 요약 — 유형 · 규모별 권장 구조와 규칙 강제
앞의 아홉 섹션을 표 한 장과 질문 세 개로 줄입니다. 그리고 정한 규칙을 문서가 아니라 Cargo · 린트 · cargo-deny 가 지키게 만드는 법으로 끝냅니다. Rust 는 크레이트 경계 자체가 강제 수단이라 다른 언어보다 도구가 적게 듭니다.
| 유형 · 규모 | 권장 구조 | 경계를 지키는 것 | 다음 단계 신호 |
|---|---|---|---|
| 학습 · 일회성 스크립트 | main.rs 하나 · 모듈 몇 개 | 없음 | 테스트하고 싶어짐 |
| CLI 도구 | lib.rs + 얇은 main.rs · cli.rs · commands/ (STR 05) | 모듈 비공개 · pub(crate) | GUI · 서버가 같은 로직을 씀 |
| Axum 서비스 · 작음 | 한 크레이트 · routes · handlers · domain · repo (STR 04) | 관례 · 리뷰 | 기능 5개+ · 폴더 셋을 오감 |
| Axum 서비스 · 큼 | 워크스페이스 domain · infra · api · bin (🌐 w03 · STR 03) | 크레이트 의존 선언 · cargo-deny | 팀 · 배포 주기가 갈라짐 |
| Tauri 데스크톱 | src-tauri/ + commands/ · 커지면 crates/core (STR 06) | invoke 경계 · capabilities | core 를 CLI 와 공유 |
| 공개 라이브러리 | lib.rs 목차 · 비공개 모듈 · features · examples (STR 07) | pub use · semver-checks | 확장 크레이트가 생김 → 워크스페이스 |
| 바인딩 여러 개 · FFI | core + python · node · capi (🛠️ b05) · foo-sys + foo (STR 09) | links · 크레이트 경계 | — |
구조 규칙을 도구로 강제하기 🟡 중급
린트 — 루트에 한 번 (STR 03 의 [workspace.lints])
[workspace.lints.rust]
unsafe_code = "deny" # 필요한 모듈만 #![allow(unsafe_code)] — forbid 면 아무도 못 풂 (🔬 d01)
unreachable_pub = "warn" # 밖에 안 닿는 pub → pub(crate) 로 (STR 02)
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
mod_module_files = "warn" # mod.rs 를 쓰면 경고 — foo.rs + foo/ 로 통일 (STR 01)
unwrap_used = "warn" # 테스트는 clippy.toml 의 allow-unwrap-in-tests = true 로 예외
cargo-deny — "이 크레이트는 저 의존성을 직접 쓰지 마"
# deny.toml (워크스페이스 루트)
[bans]
multiple-versions = "warn" # 같은 크레이트 두 버전 = 빌드 시간 · 크기 낭비
deny = [
# sqlx 는 infra 크레이트만 직접 의존할 수 있습니다
{ crate = "sqlx", wrappers = ["shop-infra"] },
{ crate = "openssl" }, # rustls 로 통일
]
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0"]
wrappers 는 "금지된 크레이트를 직접 의존해도 되는 크레이트 목록"입니다 — domain 이나 api 가 sqlx 를 끌어오면 cargo deny check bans 가 실패합니다.
최근 cargo-deny 문법 기준이며, 오래된 버전은 crate 대신 name 을 씁니다. 실행 전 cargo deny init 으로 만든 기본 파일과 맞춰 보세요.
CI 한 단계 — 구조 검사 묶음
# .github/workflows 의 한 job 또는 xtask 의 한 명령으로
cargo clippy --workspace --all-targets -- -D warnings # 린트 위반 = 실패
cargo deny check bans licenses advisories # 의존 규칙 · 라이선스 · 취약점
cargo machete # Cargo.toml 에 있는데 안 쓰는 의존성
cargo semver-checks # 공개 라이브러리만 (STR 07)
# domain 이 프레임워크에 물들지 않았는지 — 의존 트리로 직접 확인
if cargo tree -p shop-domain -e normal --prefix none | grep -qE '^(axum|sqlx|tokio) v'; then
echo "shop-domain 이 axum/sqlx/tokio 에 의존합니다" >&2
exit 1
fi
| 강제 수단 | 무엇을 막나 | 비용 |
|---|---|---|
가시성 (pub(crate) 등) | 모듈 경계 밖 접근 — 컴파일 에러 | 0 |
| 크레이트 의존 선언 | 선언 안 한 방향 · 순환 — 빌드 거부 | 크레이트 분리 비용 |
[workspace.lints] | unsafe · unwrap · 모듈 파일 방식 · 불필요한 pub | 설정 몇 줄 |
| cargo-deny | 금지 의존 · 직접 의존 제한 · 라이선스 · 취약점 | deny.toml 유지 |
| cargo-machete | 안 쓰는 의존성 (빌드 시간) | 가끔 오탐 — 메타데이터로 무시 지정 |
[package.metadata.cargo-machete] ignored = ["이름"] 으로 빼고, 더 정확한 검사가 필요하면 nightly 의 cargo-udeps 를 씁니다(🛠️ b10).
[workspace.lints] · cargo-deny · cargo-machete 를 CI 에 걸어 리뷰가 아니라 빌드가 지키게 합니다.