🟠 코틀린↗ 🔵 C++↗ 🔌 C · 임베디드↗ 🐍 파이썬↗ ☕ 자바↗ 🟨 JS·TS↗ 🟣 C#↗ 🗄️ DB↗ 🌐 서버기술↗ 🎓 CS 기술↗
👋 처음이신가요? 이 가이드는 설치와 환경 세팅부터 시작합니다 — 기초 문법 · 소유권을 지나 Axum 서버 · 고성능 서비스 · Tauri 앱, 그리고 파이썬 · Node 에서 불러 쓸 모듈까지 이어집니다 · 🔍 검색(Ctrl K)에 “Tauri 배포”처럼 하려는 일을 한글로 입력 · 코드 상자에 마우스를 올리면 복사 버튼이 나타납니다
Rust 2021 · 안전한 시스템 언어 · 서버 · 도구 · 네이티브 모듈

속도를 지키면서
메모리 사고를
없앤 언어입니다

Rust 의 강점은 C++ 만큼 빠르면서 메모리 사고와 데이터 레이스를 컴파일 단계에서 막는다는 것입니다 — 가비지 컬렉터가 없으니 멈춤도 없습니다. 그래서 Linux 커널 · Android · Cloudflare · AWS Firecracker · Deno · uv · ruff · Polars 가 전부 Rust 로 다시 쓰이고 있습니다. 이 가이드는 설치와 환경 세팅부터 기초 문법 · 소유권 · 트레이트, Axum 백엔드 서버 · 고성능 서비스 · Tauri 데스크톱 앱, 그리고 파이썬 · Node 에서 불러 쓰는 모듈까지 한 페이지로 잇습니다. 이 탭을 마치면 Rust 코드를 읽고 고칠 수 있게 됩니다.

12SECTIONS
2021EDITION
0사전 지식
4깊이 층
RUST 01

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
C++ 와 가장 크게 갈리는 지점 — C++ 는 컴파일러를 고르고, CMake 를 배우고, 패키지 매니저를 따로 깔아야 첫 줄이 돕니다. Rust 는 rustup 한 번, cargo run 한 번입니다. 이 차이가 🐍 파이썬 모듈 탭에서 그대로 이어집니다 (maturin develop 한 줄, 🐍 y03).

왜 이 귀찮음을 감수하나

파이썬Rust
실행 방식한 줄씩 해석미리 기계어로
타입 검사실행 중에컴파일할 때
에러를 보는 시점그 줄에 도달할 때실행 전
속도기준수십~수백 배
메모리 관리자동(GC)컴파일러가 검사
널 오류실행 중 터짐존재하지 않음
고치고 확인즉시빌드 기다림
오해 하나 — “Rust 가 빠르다”가 내가 쓴 Rust 코드가 빠르다는 뜻은 아닙니다. .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
이 가이드에서 계속 반복될 조언 — Rust 는 에러 메시지가 문서입니다. 막혔을 때 검색보다 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 을 커밋하면 이 질문 자체가 사라집니다. ★
고정 vs 최신 — 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
에디션 ≠ 버전 — 에디션은 3년에 한 번, 컴파일러 버전은 6주마다 나옵니다. 새 문법 대부분은 에디션과 무관하게 최신 컴파일러면 그냥 쓸 수 있습니다. 에디션은 “기존 코드의 의미를 바꾸는 변경”만 담습니다.
정리 — rustup 으로 깔고 cargo new · cargo run 두 줄이면 시작입니다. 편집 중엔 cargo check, 성능을 볼 땐 반드시 --release. 막히면 help: 줄을 먼저 읽으세요 — Rust 에러 메시지는 대부분 답까지 적어 줍니다. 팀 프로젝트라면 rust-toolchain.toml 을 커밋하는 것으로 시작하세요.
RUST 02

변수 · 타입 — 기본이 “못 바꿈”입니다

파이썬·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메모
inti32 i64 u32…크기·부호를 고름
floatf64 f32기본은 f64
boolbool같음
strString &str둘로 나뉨 (r06)
listVec<T>같은 타입만
tuple(A, B)같음
dictHashMap<K,V>r06
NoneOption<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 크레이트를 쓰세요 ★
NaN 주의 — 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 는 조용히 잘라 냅니다.
값을 바꾸고 싶을 때 — 길이 두 갈래로 갈립니다 ① let — 다시 대입 못 함 let x = 5; x = 6; ← 컴파일 에러 이 이름은 끝까지 이 값 이 보장이 기본값입니다 ② let mut — 자리를 바꿈 let mut x = 5; x = 6; ← 통과 메모리 자리는 그대로, 담긴 값만 덮어씁니다 ③ 섀도잉 — 새 변수 let x = 5; let x = "다섯"; 앞의 x 를 가린 별개 변수 타입까지 바꿀 수 있습니다 고르는 기준 — 값이 바뀌는 자리인가, 이름을 재활용하는 자리인가 루프 카운터·버퍼처럼 같은 것이 계속 변하면 mut, 문자열을 숫자로 파싱하듯 단계를 거치면 섀도잉이 읽기 좋습니다 mut 을 습관적으로 붙이면 "이 값은 여기서 안 바뀐다"는 컴파일러의 보장을 스스로 버리는 셈입니다
기본이 불변이라 바뀌는 자리에만 mut 이 보입니다 — 코드를 읽을 때 그 자리만 의심하면 됩니다.
RUST 03

함수와 표현식 — 세미콜론 하나가 반환을 바꿉니다

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   ★ 소유권을 받는다는 신호
이 표 하나가 초반의 절반입니다 — Rust 초심자가 컴파일 에러에 갇히는 이유의 대부분은 “읽기만 하면 되는데 소유권을 받는 함수”를 만들었기 때문입니다. 자세한 건 🔐 o02 에서 봅니다.

실무에선 이렇게 — 유연한 시그니처와 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); }
Rust 에는 기본 인자도, 오버로딩도 없습니다. 이름이 다른 함수(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 라는 반환 타입으로 표현합니다.
RUST 04

조건 · 반복 · 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 연쇄보다 느리지 않습니다.
//   가독성을 이유로 피할 필요가 전혀 없습니다.
널 포인터 최적화가 FFI 에서 중요합니다 — Option<extern "C" fn()> 이나 Option<&T> 는 C 의 널 가능 포인터와 메모리 배치가 정확히 같습니다. 그래서 경계 너머로 그대로 주고받을 수 있습니다(🔗 f03·f07).
정리 — if·for 는 파이썬과 거의 같고, 새로 배울 건 match 하나입니다. 빠짐없음 검사가 나중에 코드를 고칠 때 안전망이 되므로 _ => {} 를 남용하지 마세요. 갈래가 하나면 if let·let else, 반복은 인덱스보다 이터레이터를 먼저 떠올리세요.
match — 빠뜨리면 빌드가 막힙니다. 그게 기능입니다 enum Status { Active, Paused, Done } 세 갈래를 모두 다룸 Status::Active => "동작 중" Status::Paused => "멈춤" Status::Done => "끝" 컴파일 통과 하나를 빠뜨림 Status::Active => "동작 중" Status::Paused => "멈춤" error[E0004] — Done 이 처리되지 않음 빠진 이름까지 알려 줍니다 진짜 값은 나중에 나옵니다 Status 에 Archived 를 추가하면, 그 열거형을 쓰는 모든 match 가 컴파일 에러가 됩니다 고쳐야 할 자리의 목록이 그대로 나옵니다 — 다른 언어라면 운영에서 발견할 누락입니다 그래서 _ => 로 뭉뚱그리는 습관을 조심하세요 — 이 안전망을 스스로 끄는 셈입니다
Rust 의 match 는 문법 편의가 아니라 빠뜨림을 컴파일 에러로 바꾸는 장치입니다.
RUST 05

소유권 첫 만남 — 값을 넘기면 원래 것을 못 씁니다

Rust 를 배우다 처음 벽에 부딪히는 자리입니다. 겁먹을 필요는 없습니다 — “값의 주인은 항상 한 명” 이 한 줄이 전부입니다. 여기서는 에러 메시지를 알아보는 수준까지만 하고, 깊은 규칙은 🔐 소유권 탭에서 봅니다.

let b = a; 한 줄에서 실제로 일어나는 일 ① String 처럼 힙을 쓰는 타입 — 소유권이 옮겨 갑니다 a 더 이상 못 씁니다 이동 b {주소, 길이, 용량} 힙 · "안녕" (그대로) 복사되는 것은 스택 정보뿐 힙 데이터는 건드리지 않습니다 그래서 이동은 언제나 빠릅니다 a 를 다시 쓰면 컴파일 에러 value borrowed after move ② i32 처럼 Copy 인 타입 — 그냥 복사됩니다 x = 5 계속 쓸 수 있습니다 y = 5 별개의 값 힙을 안 쓰는 작은 타입은 복사 비용이 이동과 같습니다 Copy 를 구현한 타입 — 정수 · 불리언 · char · 참조
"이동"이라는 이름과 달리 메모리는 옮겨지지 않습니다 — 옮겨지는 것은 지울 책임입니다.

이동(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 만은 처음부터 피하세요. 자세한 규칙은 🔐 소유권 탭에서 이어집니다.
RUST 06

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();
“반복 중 수정” 문제를 파이썬은 런타임 에러로, C++ 는 미정의 동작으로, Rust 는 컴파일 에러로 다룹니다. 처음엔 답답하지만 운영 중 재현 안 되는 크래시가 사라지는 대가입니다.
정리 — Vec<T>=list, String=str, HashMap=dict 입니다. 문자열은 소유(String)와 빌림(&str)이 나뉘고, 인자는 &str·&[T] 로 받는 것이 정석입니다. UTF-8 이라 인덱싱 대신 .chars(). 크기를 알면 with_capacity 로 미리 잡으세요.
Vec 은 자리가 모자라면 두 배 버퍼를 새로 잡고 전부 옮깁니다 len 1 / cap 1 a 가득 찼습니다 push b → cap 2 a b 새 버퍼 + 1칸 복사 push c → cap 4 a b c 또 새 버퍼 + 2칸 복사 cap 8 · 16 … 복사량은 2배씩 늘어납니다 n 개 넣으면 총 O(n) Vec::with_capacity(n) — 크기를 알면 한 번에 잡습니다 위의 복사가 한 번도 일어나지 않습니다. 파일 한 줄씩 읽어 담는 큰 루프에서는 눈에 띄게 빨라집니다 재할당이 일어나면 원소의 주소가 바뀝니다 — 그래서 Vec 을 빌린 상태에서 push 하는 코드는 컴파일이 막습니다 다른 언어에서 "반복 중 수정"으로 나던 런타임 크래시가, 여기서는 컴파일 에러로 앞당겨집니다
늘어나는 비용은 보이지 않을 뿐 사라지지 않습니다 — 크기를 알면 미리 잡아 두세요.
RUST 07

구조체와 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
classstruct + impl
__init__fn new() -> Self (관례)
self&self / &mut self / self
@classmethodself 없는 연관 함수
__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).
클래스를 셋으로 갈라 둔 형태입니다 다른 언어의 class 필드 메서드 implements 인터페이스 Rust struct User { id: u64, name: String } 담는 값만 적습니다 — 동작은 한 줄도 없습니다 impl User { fn new(..) -> Self · fn greet(&self) } 그 타입의 동작 — 여러 블록으로 나눠 적어도 됩니다 impl Display for User { .. } 규격을 만족시키는 구현 — 남의 타입에도 붙일 수 있습니다 &self 는 읽기 · &mut self 는 고치기 · self 는 소유권을 가져가 소비합니다 연관 함수(fn new)는 self 가 없어 User::new() 처럼 부릅니다 — 다른 언어의 정적 메서드 자리입니다 상속은 없습니다 — 공통 동작은 트레이트로, 재사용은 조합(필드로 품기)으로 만듭니다
데이터와 동작이 나뉘어 있다는 점이 Rust 설계의 출발점입니다.
RUST 08

열거형과 Option — 널 오류가 존재하지 않는 이유

Rust 의 enum 은 다른 언어의 열거형보다 훨씬 강력합니다 — 각 배리언트가 값을 담을 수 있습니다. 그래서 “없을 수도 있음”을 None 이 아니라 Option<T> 라는 타입으로 표현하고, 그 결과 널 참조 오류가 언어에서 사라집니다.

널 참조 오류가 러스트에 존재하지 않는 이유 ① 다른 언어 — 모든 참조가 널일 수 있다 String s = findUser(id); 타입만 봐서는 널인지 알 수 없습니다 검사를 빠뜨려도 컴파일은 됩니다 운영 중에 NullPointerException 으로 터집니다 ② 러스트 — 없을 수 있으면 타입에 적는다 let s: Option<String> = find_user(id); 타입이 "없을 수도 있다"를 말해 줍니다 꺼내려면 반드시 두 갈래를 처리해야 합니다 빠뜨리면 컴파일이 막힙니다 꺼내는 방법 — 상황에 맞게 고릅니다 match 두 갈래를 다 씁니다 if let 한쪽만 관심 있을 때 unwrap_or 기본값으로 대체 unwrap 없으면 즉시 패닉
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 에서 봅니다.
정리 — Rust 의 enum 은 값을 담는 “이것 아니면 저것”입니다. Option<T> 가 널을 대체하고, 꺼내지 않으면 쓸 수 없어서 널 오류가 사라집니다. 설계할 때는 “불가능한 상태를 표현할 수 없게” enum 으로 묶으세요. Option<&T>·Option<Box<T>> 는 추가 비용이 0 입니다.
RUST 09

모듈 · 크레이트 — 파일이 자동으로 모듈이 되지 않습니다

파이썬은 파일을 만들면 그대로 모듈입니다. Rust 는 mod 로 “이 파일도 내 식구”라고 선언해야 합니다. 한 번만 이해하면 되는 규칙이지만, 처음 파일을 나눌 때 반드시 걸리는 자리라 여기서 정리합니다.

패키지 · 크레이트 · 모듈 — 헷갈리는 세 단어 패키지 (Cargo.toml 하나) — cargo 가 다루는 단위 바이너리 크레이트 src/main.rs — 실행 파일 라이브러리 크레이트 src/lib.rs — 남이 쓰는 것 mod parser src/parser.rs mod storage src/storage/mod.rs mod util src/util.rs 파일을 만들었다고 모듈이 되지 않습니다 — 부모에서 mod 로 선언해야 하고, 기본은 비공개라 pub 이 필요합니다.
파이썬처럼 "파일 = 모듈"이 아닙니다 — 모듈 트리는 내가 직접 선언합니다.

파일을 나누는 최소 규칙

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 로 공개 얼굴을 정리하세요 — 내부 구조를 자유롭게 바꿀 수 있게 됩니다.
RUST 10

제네릭 입문 — 타입을 나중에 채우는 틀

같은 코드를 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); }
}
C++ 템플릿과의 차이 — C++ 는 쓰다가 안 되면 에러가 납니다(수백 줄). Rust 는 정의할 때 제약을 적어야 하고, 안 맞으면 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() }
정리 — 제네릭은 타입별로 코드를 복사해 만들어(단형화) 실행 비용이 0 입니다. 대신 빌드 시간과 바이너리가 늘어납니다. 제약(T: Clone)을 미리 적어야 하고, 그 덕분에 에러가 짧습니다. 애플리케이션 로직은 구체 타입으로 시작하고 두 번째 타입이 실제로 필요해질 때 일반화하세요.
RUST 11

테스트와 문서 — 언어에 내장돼 있습니다

테스트 프레임워크를 고를 필요가 없습니다. 같은 파일 아래에 붙여 쓰고 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 되는지, 타입이 맞는지, 값이 같은지
되돌릴 수 있게 만드는 원칙 — 네이티브 구현 옆에 순수 파이썬 폴백을 남기고, parity 테스트로 둘을 대조하세요. 문제가 생기면 환경 변수 하나로 되돌릴 수 있습니다(🐍 y01·y12).

한 단계 더 — 테스트 하네스의 안쪽 🧬 전문가

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 테스트를 반드시 두세요 — 네이티브 코드는 틀려도 조용히 틀립니다.
RUST 12

치트시트 — 파이썬 · C++ 에서 Rust 로

이 표 하나면 남의 Rust 코드를 읽을 수 있습니다. 왼쪽에서 하던 일을 오른쪽에서 어떻게 쓰는지만 모았습니다. 막힐 때 이 섹션으로 돌아오세요.

파이썬 → Rust

파이썬Rust
x = 5let x = 5;
재대입let mut x = 5;
print(x)println!("{x}");
f"{a}-{b}"format!("{a}-{b}")
listVec<T>
dictHashMap<K,V>
setHashSet<T>
NoneOption<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/exceptResult + ?
raisereturn Err(...)
with open(..)스코프 + Drop
classstruct + impl
lambda x: x+1|x| x + 1
import xuse x; (+ mod)
pip installcargo add

C++ → Rust

C++Rust
std::vector<T>Vec<T>
std::stringString
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
상속없음 → 트레이트 · 합성
namespacemod
헤더/소스 분리없음 (한 파일)
CMakeCargo.toml
vcpkg/conancargo 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)
}
줄 수는 두 배쯤이지만 실행 속도는 보통 20~50배입니다(직접 재 보세요 — 환경에 따라 크게 달라집니다). 그리고 파일이 없을 때·인코딩이 깨졌을 때를 타입이 강제로 처리하게 만듭니다. ⚡ e10 에서 실측 방법을 봅니다.

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
학습 곡선낮음높음높음
생태계 성숙최고최고중간
컴파일 시간—느림느림
정직하게 — Rust 는 배우는 데 시간이 듭니다(보통 2~4주면 실무 코드를 씁니다). 그리고 컴파일이 느립니다. 팀 전체가 새로 배워야 한다면 그 비용을 계산에 넣어야 합니다(🔬 d12).

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 경험자가 없는 경우 ★
  · 컴파일 시간이 개발 흐름의 병목인 대형 프로젝트
정리 — 이 표를 옆에 두면 남의 Rust 코드가 읽힙니다. 처음 2주는 clone()·unwrap() 을 써도 됩니다 — 일단 돌아가게 만드는 것이 먼저입니다. 막히는 순서는 정해져 있으니(소유권 → 문자열 → 라이프타임 → 에러) 해당 섹션으로 바로 가세요. 다음은 🔐 소유권 · 트레이트 탭입니다.
소유권 · 빌림 · 라이프타임 · 트레이트 · 에러 처리

Rust 가 GC 없이
안전한 이유
여기 전부 있습니다

이 탭이 Rust 의 심장입니다. “값의 주인은 한 명” 이라는 한 줄에서 빌림·라이프타임·Result·Arc 가 전부 따라 나옵니다. 여기를 넘으면 남의 Rust 코드가 읽히고, 컴파일 에러가 잔소리가 아니라 조언으로 보이기 시작합니다.

12SECTIONS
3소유권 규칙
0GC · 런타임
4깊이 층
OWN 01

소유권 규칙 — 세 줄이 전부입니다

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%가 이해됩니다.
파이썬과 비교 — 파이썬은 참조 카운트가 0이 될 때 해제되므로 “언제 해제되는지 코드만 봐서는 모릅니다”. Rust 는 스코프를 보면 정확히 알 수 있습니다 — 그래서 파일·소켓·락 같은 자원도 메모리와 똑같이 다룰 수 있습니다.

이렇게도 쓸 수 있다 — 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)
“Rust 와 싸우고 있다”면 대개 다른 언어의 자료 구조를 그대로 옮기려는 중입니다. 특히 그래프·트리는 인덱스로, 상속은 합성으로 바꾸면 대부분 풀립니다.

한 단계 더 — 소유권은 런타임에 무엇을 남기나 🧬 전문가

비용은 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)
Drop 순서의 미묘한 함정 — 구조체 필드는 선언 순서대로 해제됩니다. runtime 필드와 그것을 쓰는 handle 필드가 있다면 선언 순서가 곧 종료 순서가 되어, tokio 런타임 종료 시 행이 걸리는 원인이 되곤 합니다(⚡ e08).
정리 — 주인은 한 명, 주인이 사라지면 값도 사라진다 — 이게 전부입니다. 그래서 메모리뿐 아니라 파일·락·연결도 똑같이 자동 정리됩니다. 설계할 때는 “이 데이터의 주인은 누구인가”를 먼저 정하세요. 검사는 컴파일 타임에 끝나므로 런타임 비용은 0 입니다.
대입은 복사가 아니라 '주인이 바뀌는 일'입니다 ① let s1 = String::from("hi"); s1 (스택) ptr · len 2 · cap 2 주인입니다 "hi" 힙 ② let s2 = s1; — 이동 s1 — 못 씀 s2 (새 주인) "hi" 힙은 그대로 ③ 스코프 끝 s2 가 drop 힙 해제 한 번 이중 해제 없음 스택의 세 값(ptr·len·cap)만 복사되고, 힙의 글자는 옮겨 다니지 않습니다 그래서 이동은 크기와 상관없이 항상 쌉니다 — 1GB 짜리 Vec 을 넘겨도 복사되는 것은 포인터 세 개뿐입니다 깊은 복사가 필요하면 s1.clone() — 비용이 드는 연산이 코드에 눈으로 보인다는 것이 이 설계의 핵심입니다 i32 처럼 힙이 없는 작은 값은 Copy 라 그냥 복사됩니다 — 이동을 신경 쓸 자리는 힙을 쥔 타입뿐입니다
주인은 언제나 한 명이라 누가 언제 해제하는지 코드만 보고 알 수 있습니다.
OWN 02

빌림 — 읽기는 여럿, 쓰기는 하나

값을 넘길 때마다 소유권이 옮겨 가면 아무것도 못 합니다. 그래서 잠깐 빌려 보는 참조(&)가 있습니다. 규칙은 딱 하나 — “읽기 참조는 여러 개, 쓰기 참조는 하나, 둘은 공존 못 함”. 이 한 줄이 데이터 경쟁을 언어 차원에서 없앱니다.

빌림 규칙을 시간축으로 보면 — 겹치느냐가 전부입니다 ① 불변 참조 여럿 — 겹쳐도 됩니다 &s (r1) &s (r2) 시간 → 읽기만 하므로 서로 방해하지 않습니다 ② 가변 참조 — 그 구간에 혼자여야 합니다 &mut s &s — 여기서 컴파일 에러 시간 → 참조의 수명은 '마지막으로 쓰인 곳'까지입니다 — r1 을 마지막으로 쓴 뒤라면 &mut 을 새로 만들 수 있습니다(NLL).
이 한 규칙이 데이터 경쟁 · 이터레이터 무효화 · 해제 후 사용을 한꺼번에 없앱니다.

빌림 규칙

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 가 마련돼 있습니다.
정리 — 읽기 참조는 여럿, 쓰기 참조는 하나, 둘은 공존 불가. 참조의 수명은 마지막 사용까지(NLL)라 순서만 바꿔도 풀리는 경우가 많습니다. 에러 메시지의 빌린 곳 · 충돌한 곳 · 나중에 쓴 곳 세 줄 중 하나를 옮기세요. 설계에서는 &self/&mut self 구분이 곧 동시성 계약입니다.
OWN 03

라이프타임 — 'a 는 무엇을 말하는가

'a 를 보면 겁이 나지만, 뜻은 단순합니다 — “이 참조는 저것보다 오래 살면 안 된다”는 표시일 뿐입니다. 대부분은 컴파일러가 알아서 붙여 줍니다. 우리가 적어야 하는 경우는 몇 가지로 정해져 있습니다.

'a 는 수명을 늘리는 것이 아니라 관계를 설명하는 표기입니다 x: &'a str 바깥에서 빌려 온 것 y: &'a str 이것도 빌려 온 것 longest(x, y) 둘 중 하나를 돌려준다 &'a str 둘 중 짧은 쪽만큼 컴파일러가 물어보는 것은 하나입니다 — "돌려준 참조는 언제까지 유효한가" 함수 안을 들여다보지 않고 시그니처만으로 판단하기 때문에, 반환값이 어느 입력에서 왔는지 우리가 알려 줘야 합니다 대부분의 경우는 규칙으로 자동 추론됩니다 — 명시가 필요한 자리는 생각보다 적습니다
"라이프타임을 붙였더니 컴파일된다"가 아니라 "이미 있던 관계를 적었을 뿐"입니다.

왜 필요한가 — 댕글링을 막습니다

// ❌ 컴파일러가 이걸 막아야 합니다
// 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 는 실행 파일에서 완전히 동일합니다 (포인터+길이).
실무에서 HRTB 를 직접 쓸 일은 거의 없습니다. for<'a> 가 에러 메시지에 나타나면 대개 클로저 인자의 참조를 반환하려 해서입니다 — 소유 값을 반환하도록 바꾸면 사라집니다.
정리 — 'a 는 “이것보다 오래 살지 마라”는 관계 표시일 뿐, 수명을 늘리지 않습니다. 생략 규칙 덕분에 실무 코드의 90% 는 안 적습니다. 막히면 ①소유 값으로 바꾸기 → ②인덱스 반환 → ③콜백 → ④Arc 순서로 시도하세요. 'static 을 붙여 에러를 지우는 것은 대개 잘못된 처방입니다.
OWN 04

슬라이스 — 복사 없이 들여다보는 창

슬라이스는 “어디서부터 몇 개”만 담은 두 워드짜리 값입니다. 복사가 없어 빠르지만, 원본이 바뀌면 창이 무효가 됩니다 — 그 위험을 컴파일러가 막아 주는 것이 핵심입니다.

슬라이스의 정체

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++ 와 갈리는 지점입니다.
OWN 05

트레이트 — “이런 일을 할 수 있다”는 자격증

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>;  // ✅ 이렇게 우회
}
버전 호환의 핵심 — 공개 트레이트에 메서드를 추가하면 그것을 구현한 모든 사용자 코드가 깨집니다. 기본 구현을 주거나 sealed 로 막아 두는 것이 라이브러리의 기본 방어입니다(🛠️ b03).

한 단계 더 — 연관 타입 · 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 를 구현할 일이 가장 많습니다. 구현이 하나뿐인 트레이트는 만들지 마세요 — 간접 층만 늘어납니다.
OWN 06

트레이트 객체 vs 제네릭 — 빠르게냐 유연하게냐

같은 트레이트를 쓰는 두 가지 방법이 있습니다. 제네릭(<T: Trait>)은 타입별로 코드를 만들어 빠르고, 트레이트 객체(dyn Trait)는 하나의 코드로 여러 타입을 담습니다. 고르는 기준은 명확합니다.

같은 트레이트를 쓰는 두 가지 방법 — 언제 결정하느냐 ① 제네릭 — 컴파일 때 결정(정적 디스패치) fn draw<T: Shape>(s: &T) draw_원 draw_사각형 쓰인 타입마다 함수가 따로 만들어집니다 주소가 고정되어 인라인까지 됩니다 대신 바이너리가 커집니다 ② 트레이트 객체 — 실행 때 결정(동적 디스패치) fn draw(s: &dyn Shape) 함수 하나 + vtable 로 점프 함수는 하나만 생깁니다 서로 다른 타입을 한 Vec 에 담을 수 있습니다 인라인이 안 되고 포인터가 두 배 크기입니다 고르는 기준 타입이 컴파일 때 정해지고 성능이 중요하면 제네릭 · 런타임에 종류가 섞이거나 컴파일 시간·바이너리 크기가 걱정되면 dyn 플러그인 · 콜백 목록 · 설정에 따라 구현이 바뀌는 곳은 거의 항상 dyn 이 답입니다
C++ 의 템플릿과 가상 함수의 관계와 정확히 같은 트레이드오프입니다.

두 방식을 나란히

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 으로 저장'
//   빌드 시간이 줄고, 타입 시그니처가 짧아지고, 성능 차이는 대개 무시할 수준입니다.
//   (초당 수백만 호출되는 안쪽 루프만 제네릭으로 남기세요)
측정 없이 제네릭을 고르지 마세요 — 대형 Rust 프로젝트의 빌드 시간 문제는 거의 항상 과도한 제네릭에서 옵니다(🛠️ b10). 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 이 셋 중 대개 최선입니다 — 할당도 없고 빠짐없음 검사도 받습니다. “외부에서 구현을 추가할 수 있어야 하나?” 한 질문으로 고르세요.
OWN 07

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 를 남용하지 마세요 — “스마트 포인터일 때만” 구현하는 것이 원칙입니다. 상속 흉내를 내려고 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 코드 */ });
}
Rust 2024 부터 extern "C" 함수는 기본적으로 패닉 시 abort 하도록 바뀌어 UB 는 피하지만, 프로세스가 죽습니다. 여전히 경계에서 잡아 에러 코드로 바꾸는 것이 정답입니다(🔗 f09).
정리 — Deref 가 String→&str 자동 변환의 정체이고, 연산자는 전부 트레이트입니다. 인자를 넓게 받을 땐 읽으면 AsRef, 보관하면 Into. Drop 은 FFI 핸들을 감싸는 안전 추상화의 기본형이지만, 실패할 수 있는 정리는 명시 메서드로도 제공하세요.
OWN 08

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/exceptmatch / if let
finallyDrop (자동)
스택 트레이스백트레이스(선택)
어디서든 발생시그니처에 명시
실무에서 오는 가장 큰 차이 — 리뷰할 때 “이 함수가 실패할 수 있나”를 본문을 읽지 않고 알 수 있습니다. 그리고 실패를 무시하면 컴파일 경고가 뜹니다(#[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 로 나누고, 에러에는 무엇을 하려다 · 어떤 값으로 · 왜를 남기세요.
? 는 갈림길입니다 — 성공이면 값을 꺼내고, 실패면 그 자리에서 나갑니다 read_to_string(p)? 실패할 수 있는 호출 Result<String, E> 둘 중 하나가 들어 있음 Ok(s) → s 를 꺼내 다음 줄로 이어집니다 Err(e) → 즉시 return 호출자에게 그대로 올라감 올라가는 길에 From 변환이 한 번 끼어듭니다 io::Error 를 돌려주는 함수를 MyError 를 쓰는 함수 안에서 ? 로 부르면, impl From<io::Error> for MyError 가 자동으로 불립니다 예외와 다른 점 — 시그니처에 -> Result 가 적혀 있어야만 실패가 존재합니다. 본문을 읽지 않고도 실패 가능성을 알 수 있습니다 그래서 Result 를 그냥 버리면 컴파일 경고가 뜹니다 — 처리를 잊는 경로가 생기지 않습니다
에러는 돌아오는 값이라 흐름이 코드에 그대로 보입니다 — 숨은 점프가 없습니다.
OWN 09

에러 타입 설계 — 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 { }
에러 코드는 절대 바꾸지 마세요 — 한 번 배포하면 다른 언어의 코드가 그 숫자에 의존합니다. 새 실패는 새 코드를 추가하고, 알 수 없는 코드는 “일반 오류”로 처리하도록 문서에 명시하세요(🔗 f09).

한 단계 더 — 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(|_| "실패") 만은 피하세요 — 운영에서 조사할 수 없게 됩니다.
OWN 10

Box · Rc · RefCell — 규칙의 탈출구 세 개

“주인은 한 명”이 답답할 때가 있습니다 — 여러 곳이 같은 값을 봐야 하거나, 불변인데 안을 고쳐야 할 때입니다. 그럴 때 쓰는 도구가 셋 있지만, 먼저 설계로 풀 수 있는지 확인하는 것이 순서입니다.

규칙의 탈출구 세 개 — 무엇을 포기하고 무엇을 얻나 Box<T> 값을 힙에 둡니다 소유자는 여전히 하나 크기를 모르는 타입에 런타임 비용 없음 재귀 구조 · dyn 담기 Rc<T> 소유자를 여럿 허용 참조 개수를 셉니다 0 이 되면 해제 단일 스레드 전용 순환 참조는 누수 RefCell<T> 빌림 검사를 실행 때로 불변 참조로도 내부 수정 borrow · borrow_mut 규칙을 어기면 패닉 컴파일러가 못 막습니다 스레드를 넘나든다면 짝이 바뀝니다 Arc<Mutex<T>> Rc → Arc (원자적 카운트) · RefCell → Mutex 또는 RwLock 먼저 물어볼 것 정말 공유가 필요한가 — 복제하거나 구조를 바꾸면 풀립니다
셋 다 "규칙을 어기는" 도구가 아닙니다 — 검사 시점을 옮기거나 소유 모델을 바꿀 뿐입니다.

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>
숫자 카운터CellAtomic*
한 번만 초기화OnceCellOnceLock
순환 끊기WeakWeak
// 비용 감각 (직접 재 보세요 · ⚡ 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
Rust 가 막아 주는 것과 못 막는 것 — 데이터 경쟁은 컴파일러가 막습니다(⚡ e06). 하지만 데드락은 못 막습니다 — 그건 논리 오류이지 메모리 안전 문제가 아니기 때문입니다.

한 단계 더 — 안쪽 가변성의 원리 🧬 전문가

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 은 패닉이 운영에서 터지므로 빌림 범위를 짧게 유지해야 합니다.
OWN 11

클로저 — 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 빌림여러 번
FnOnceT 소유한 번
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 이 정석입니다.
OWN 12

이터레이터 — 지연 평가인데 for 만큼 빠릅니다

.iter().map().filter() 는 부르는 순간 아무 일도 하지 않습니다. 마지막에 collect()·sum() 같은 소비자를 만나야 비로소 돕니다. 그리고 컴파일러가 이 전부를 손으로 쓴 for 문과 같은 기계어로 만듭니다.

이터레이터 체인 — 원소 하나가 전체를 통과하고, 그다음 원소가 갑니다 원소 1개 v.iter() .filter(...) 통과할지 판단 .map(...) 변환 .sum() 여기서 실행 흔한 오해 "map 이 새 Vec 을 만들고, filter 가 또 만든다" — 파이썬 리스트 컴프리헨션의 감각입니다 실제 중간 컬렉션이 하나도 만들어지지 않습니다 소비자를 만나기 전까지 아무 일도 안 합니다 그래서 for 문과 같은 기계어가 나옵니다 — 이것이 "제로 코스트 추상화"의 대표 사례입니다 rayon 을 쓰면 iter() 를 par_iter() 로 바꾸는 것만으로 같은 체인이 여러 코어에서 돕니다
체인이 길어져도 느려지지 않습니다 — 인덱스로 직접 도는 코드를 쓸 이유가 없습니다.

세 가지 시작점 ★

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>()
Counterentry().or_insert(0)
groupbyitertools 크레이트
제너레이터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). 다음은 🐍 파이썬 모듈 탭입니다.
PyO3 · maturin · numpy 제로카피 · GIL 해제 · abi3

느린 함수 하나만
Rust 로 갈아 끼웁니다

파이썬을 버리는 게 아닙니다. 프로파일러가 계속 가리키는 그 함수 하나를 Rust 로 만들어 import 해서 쓰는 것이 목표입니다. 쓰는 사람은 Rust 가 있는지도 모릅니다. 파일 두 개, 명령 한 줄이면 첫 모듈이 돕니다 — C++ 와 가장 크게 갈리는 지점입니다.

12SECTIONS
2파일이면 시작
10~100×기대 배수
4깊이 층
PY 01

왜 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 로 바꿔도 그대로입니다)
가장 흔한 실수 — 측정 없이 “여기가 느릴 것 같다”로 시작하는 것입니다. 실제로는 JSON 파싱·문자열 조작·정규식이 병목인 경우가 많고, 그건 대개 기존 C 확장 라이브러리를 바꾸는 것으로 해결됩니다.

Rust 이전에 시도할 것들 ★

방법기대비용
알고리즘 개선수십~수천배낮음
numpy 벡터화10~100배낮음
polars / pyarrow5~50배낮음
orjson · regex 교체2~10배매우 낮음
multiprocessing코어 수만큼중간
numba (JIT)10~100배중간
Cython10~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-buildmaturin 한 줄
의존성vcpkg · 헤더 경로Cargo.toml 한 줄
개발 반복재설정 · 재빌드maturin develop
메모리 버그ASan 으로 확률적대부분 컴파일 에러
병렬화스레드 직접rayon 한 줄
배포(wheel)cibuildwheel 설정maturin + CI 템플릿
성능동급동급
생태계 성숙더 넓음충분함
정직하게 — 순수 실행 성능은 거의 차이가 없습니다. 차이는 전부 빌드·배포·디버깅에 드는 사람 시간에서 납니다. 기존 C++ 자산이 많다면 C++ 가 낫고(🔵 C++ 가이드), 새로 시작하는 모듈이라면 Rust 쪽이 빠르게 끝납니다.

실제로 쓰이는 사례들

널리 쓰이는 파이썬 패키지 중 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 까지 해 보고,
  실제 배수를 재서 결정하세요. 하루면 충분합니다.
가장 비싼 실패 — 만들어 놓고 아무도 유지보수하지 못하는 확장입니다. 팀에 Rust 를 읽을 수 있는 사람이 둘 이상인지 먼저 확인하세요. 아니라면 numba·Cython 이 더 나은 선택일 수 있습니다(🔬 d12).
정리 — 측정 먼저, 그리고 numpy·polars·알고리즘 개선을 먼저 시도하세요. Rust 가 유리한 것은 벡터화가 안 되는 루프 · 문자열 파싱 · GIL 이 막는 CPU 작업입니다. 시작할 때는 순수 파이썬 폴백 + parity 테스트를 함께 두고, 경계를 넘는 횟수를 데이터 크기와 분리하세요.
어떤 코드가 Rust 로 옮겨서 이득인가 — 먼저 재고 판단합니다 이득이 큽니다 — 순수 파이썬 반복문이 오래 도는 코드 문자열 파싱 · 좌표 계산 · 트리 순회 · 커스텀 알고리즘 — 보통 10~100배 이득이 큽니다 — GIL 때문에 코어를 못 쓰는 코드 Rust 쪽에서 GIL 을 놓고 rayon 으로 돌리면 코어 수만큼 그대로 붙습니다 이득이 작습니다 — 이미 numpy · pandas 로 벡터화한 코드 그 안은 이미 C 입니다. 잘해야 몇십 퍼센트이고 오히려 느려지는 경우도 있습니다 거의 차이가 없습니다 — 대기가 대부분인 코드 HTTP 응답 · 디스크 · DB 대기는 언어를 바꿔도 그대로 기다립니다 순서는 항상 같습니다 — 프로파일을 떠서 가장 오래 걸리는 함수 하나를 찾고, 그것만 옮깁니다
전부 옮길 필요는 없습니다 — 시간의 대부분을 쓰는 함수 하나면 충분한 경우가 많습니다.
PY 02

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
디버그 빌드는 정말 느립니다 — 최적화가 꺼져 있어 파이썬보다 느린 경우도 있습니다. “Rust 로 바꿨는데 안 빨라졌다”의 첫 번째 원인이 --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 로 숨기면 나중이 편합니다.
PY 03

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 한 번
이 차이가 실무에서 의미하는 것 — 첫 모듈까지의 시간이 반나절에서 5분으로 줄고, 기여자가 진입하기 쉬워집니다. 성능이 같다면 이 차이가 도구 선택의 실제 이유가 됩니다.

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        # 어떤 라이브러리에 의존하는지 ★
순수 Rust 라면 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 부터 켜면 됩니다.
전통적인 C 확장이 하던 일을 maturin 이 한 줄로 접습니다 ① 예전 — 손으로 거치던 단계 setup.py Extension 작성 컴파일러 지정 플래그·링크 플랫폼별 빌드 win·mac·linux wheel 태그 손으로 맞추기 반나절 이 걸립니다 ② 지금 — Cargo.toml 하나면 됩니다 Cargo.toml + pyproject.toml maturin develop — 현재 venv 에 바로 설치 고치고 곧바로 import 해서 확인 build --release — 배포용 wheel 태그·ABI 를 알아서 붙여 줍니다 개발 루프가 고치고 → maturin develop → 파이썬에서 확인 세 걸음으로 줄어듭니다 — 기여자가 진입하는 문턱도 같이 낮아집니다
빌드가 쉬워지면 Rust 로 옮길지 말지를 실험으로 정할 수 있습니다 — 이 차이가 가장 큽니다.
PY 04

타입 변환 — 자동이지만 복사가 일어납니다

list 를 Vec 으로 받으면 그냥 됩니다. 다만 그 순간 전부 복사됩니다. 원소 100만 개면 100만 번의 변환이 일어납니다. 언제 복사되고 언제 안 되는지를 아는 것이 이 섹션의 전부입니다.

기본 대응표 ★

파이썬Rust (인자)복사
inti64 u32…—
floatf64—
boolbool—
str&str없음 ★
strString있음
bytes&[u8]없음 ★
listVec<T>있음
dictHashMap있음
setHashSet있음
tuple(A, B)있음
NoneOption<T>—
ndarrayPyReadonlyArray1없음 ★ (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 → 구조체 변환을 자동화할 수 있습니다.
변환 비용 — 무엇을 어떻게 넘기느냐가 성능을 가릅니다 list[int] → Vec<i64> · 원소마다 변환 + 복사 길이에 비례합니다. 100만 개면 이 변환만으로 계산보다 오래 걸리기도 합니다 str → String · UTF-8 로 복사 읽기만 한다면 &str 로 빌려 받으세요 — 복사가 사라집니다 numpy 배열 → &[f64] · 복사 없음 (제로카피) 메모리 배치가 같아 그대로 들여다봅니다 — 100MB 배열도 넘기는 비용이 0 에 가깝습니다 큰 데이터를 한 번에 · 작은 호출을 반복하지 않기 호출 한 번에도 고정 비용이 있습니다 — 100만 번 부르면 그 비용이 전체를 잡아먹습니다
PyO3 성능의 승부처는 Rust 코드가 아니라 경계를 몇 번 넘느냐입니다.
PY 05

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)
GIL 과 스레드 — #[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__ 를 잊지 마세요.
#[pyclass] — Rust 구조체가 그대로 파이썬 타입이 됩니다 #[pyclass] struct Engine { model: Model } #[pymethods] impl Engine { #[new] fn new(path) -> Self fn predict(&self, x) -> f64 fn reset(&mut self) 노출 e = mypkg.Engine("model.bin") — #[new] 가 생성자가 됩니다 e.predict(x) — &self 메서드는 여러 번 겹쳐 호출해도 됩니다 e.reset() — &mut self 는 그 순간 혼자여야 합니다 빌림 검사가 컴파일이 아니라 실행 시점으로 옮겨 옵니다 파이썬 콜백이 같은 객체의 메서드를 다시 부르면 RuntimeError: Already borrowed 가 납니다 — 콜백 안에서 자기 자신을 만지지 마세요 상태가 없다면 클래스로 만들 이유가 없습니다 — 초기화가 비싸거나 상태를 이어 가야 할 때만 클래스를 쓰고, 나머지는 함수가 더 빠릅니다
파이썬 쪽에서는 평범한 클래스로 보입니다 — Rust 라는 사실이 사용자에게 드러나지 않습니다.
PY 06

numpy 제로카피 — 성능 차이가 가장 큰 지점

여기가 이 탭에서 가장 중요한 섹션입니다. numpy 배열은 연속된 메모리 덩어리라 Rust 슬라이스로 그대로 볼 수 있습니다. 복사가 0 이므로, 데이터가 클수록 이득이 커집니다.

100만 개 배열을 넘길 때 — 여기서 성능 차이가 가장 크게 납니다 ① Vec<f64> 로 받기 파이썬 객체 100만 개를 하나씩 변환 새 Vec — 추가 메모리 8MB 변환 시간이 계산 시간보다 클 때가 많습니다 ② PyReadonlyArray1<f64> 로 받기 numpy 가 이미 가진 연속 메모리 as_slice() → &[f64] 그대로 봅니다 복사 0회 · 추가 메모리 0바이트 제로카피가 조용히 깨지는 자리 ① 배열이 연속이 아닐 때 (슬라이싱 결과) — as_slice() 가 None 을 돌려줍니다 ② dtype 이 다를 때 — float32 배열을 f64 로 받으면 변환이 일어납니다. 파이썬 쪽에서 맞춰 주세요
쓰기까지 하려면 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 이면 대개 이것 때문입니다
실무 관례 — 변환과 검증은 파이썬 래퍼에서 하고, Rust 쪽은 “float64 연속 배열이 온다”를 전제로 단순하게 쓰는 것이 코드도 짧고 에러 메시지도 파이썬답습니다(y02).

실무에선 이렇게 — 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)
제로카피의 대가 — 복사가 없다는 것은 파이썬과 Rust 가 같은 메모리를 본다는 뜻입니다. 누가 언제 쓰는지를 API 문서에 명시하는 것이 안전 설계의 일부입니다(y01 원칙 ②).
정리 — numpy 배열은 PyReadonlyArray/PyReadwriteArray 로 복사 없이 받습니다. as_slice() 가 실패하면 연속 배열이 아닌 것이니 파이썬 래퍼에서 ascontiguousarray 를 부르세요. 원소별 연산은 numpy 를 못 이깁니다 — Rust 는 분기가 있는 루프와 임시 배열이 많은 식에서 이깁니다.
PY 07

GIL 해제 · rayon 병렬 — 파이썬의 한계를 넘는 자리

파이썬 스레드는 GIL 때문에 CPU 작업을 동시에 못 합니다. 그런데 Rust 코드는 GIL 을 놓고 돌 수 있습니다 — 그 순간 다른 파이썬 스레드가 움직이고, rayon 으로 전 코어를 쓸 수 있습니다. 파이썬으로는 물리적으로 불가능한 지점입니다.

파이썬이 오래 못 하던 일 — 진짜 멀티코어 계산 파이썬 호출 GIL 을 쥔 상태 py.allow_threads 여기서 GIL 을 놓는다 코어 1 — par_iter 조각 코어 2 — par_iter 조각 코어 3 · 4 ... 놓은 구간에서 지켜야 할 것 파이썬 객체를 절대 만지지 않습니다 필요한 데이터는 미리 슬라이스로 꺼내 둡니다 러스트가 유리한 이유 Send · Sync 를 컴파일러가 검사합니다 데이터 경쟁이 빌드 에러로 잡힙니다 — C++ 와 결정적 차이 계산 시간이 짧으면 GIL 을 놓고 잡는 비용이 더 큽니다 — 밀리초 단위 이상일 때만 의미가 있습니다.
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 를 전파 ★
Ctrl+C 가 안 먹는 확장은 사용자를 가장 화나게 합니다. 긴 루프라면 수백 밀리초마다 GIL 을 잡아 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 를 살려 두세요.
PY 08

에러를 파이썬 예외로

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

□ 민감 정보가 없는가 (토큰·비밀번호가 메시지에 섞이는 사고가 흔합니다)

□ 파이썬 관례를 따르는가
    메시지는 대문자로 시작하지 않고, 마침표를 붙이지 않는 것이 관례입니다
    (한글이면 "~합니다" 로 통일하는 편이 읽기 좋습니다)
스택 트레이스가 끊깁니다 — 파이썬 트레이스백에는 확장 함수 이름 한 줄만 나오고 Rust 쪽 위치는 보이지 않습니다. 그래서 에러 메시지에 맥락을 충분히 담는 것이 디버깅의 전부가 됩니다(y12).

한 단계 더 — 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 면 쓰는 사람이 대응할 수 없습니다. 스택 트레이스가 끊기므로 메시지에 맥락(무엇을·어떤 값으로·왜)을 담아야 합니다.
PY 09

콜백 — 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 가 이 방식입니다) ★
널리 쓰이는 라이브러리의 선택 — polars·pandas 가 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                    # ★ 중간에 멈출 수도 있습니다
“콜백을 받는 API”보다 “이터레이터를 주는 API”가 대개 낫습니다 — 조기 종료·예외 처리·디버깅이 전부 파이썬 쪽에서 자연스러워집니다. 다만 호출당 경계 통과가 생기므로 배치 크기를 조절할 수 있게 하세요(y01).

콜백 안에서 난 예외 처리

#[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 크레이트를 쓰거나, 결과만 돌려주고 스케줄링은 파이썬이 하게 두세요.
정리 — 파이썬 콜백은 호출당 수백 ns~수 μs 이고, 병렬 안에서는 GIL 때문에 줄을 섭니다. 먼저 콜백 없는 설계(결과 반환 · 배치 진행률 · 설정으로 규칙 전달 · 이터레이터)를 검토하세요. 보관은 Py<PyAny>, 호출은 bind(py), 그리고 락을 잡은 채 콜백을 부르지 마세요.
PY 10

타입 힌트 · .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)
스텁과 docstring 중 하나만 한다면 스텁입니다 — 편집기 자동완성이 되는 것이 사용자 체감에서 가장 큽니다. docstring 은 /// 주석을 쓰는 김에 따라오는 보너스로 생각하세요.

실무에선 이렇게 — 스텁이 코드와 어긋나지 않게 🔴 실무

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 에 넣어 스텁이 낡지 않게 하고, 시그니처 기반 프레임워크와 섞인다면 파이썬 래퍼를 두세요.
PY 11

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 의 진짜 이점은 파일 수가 아니라 “새 파이썬이 나와도 재배포하지 않아도 된다”입니다. 파이썬 3.14 가 나온 날 사용자가 바로 설치할 수 있습니다.

실무에선 이렇게 — 배포 매트릭스를 설계한다 🔴 실무

무엇을 빌드할 것인가 ★

최소 권장 세트 (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 도 함께 올리는 것을 잊지 마세요.
abi3 — 빌드 매트릭스가 30개에서 6개로 줄어듭니다 abi3 없이 파이썬 3.9 · 3.10 · 3.11 · 3.12 · 3.13 × Linux · macOS · Windows × x86_64 · arm64 휠 약 30개 — CI 시간도 30배 abi3-py39 로 빌드 파이썬 3.9 이상 전부 같은 파일 × Linux · macOS · Windows × x86_64 · arm64 휠 6개 — 새 파이썬이 나와도 그대로 대신 안정 ABI 밖의 최신 내부 API 는 쓸 수 없습니다 라이브러리를 배포한다면 거의 항상 abi3 가 답입니다 — 새 파이썬이 나올 때마다 재빌드하지 않아도 됩니다
배포 부담의 대부분은 코드가 아니라 곱해지는 조합의 수에서 옵니다.
PY 12

흔한 실패 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()"
순수 안전 Rust 라면 세그폴트가 나지 않습니다. 났다면 원인은 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            # 안전한 쪽을 반환
④ 대조 모드는 도입 초기에 특히 유용합니다 — 성능은 손해지만 실제 트래픽으로 parity 를 검증할 수 있습니다. 며칠 돌려 불일치가 없으면 네이티브로 완전히 전환하세요.
정리 — 막히면 이 목록부터 보세요. “안 빨라졌다”의 1위는 --release 누락, 2위는 경계 통과 횟수입니다. 세그폴트가 났다면 unsafe · C 연동 · GIL 없이 파이썬 접근 셋 중 하나입니다. 배포 전에 parity 테스트 · 폴백 · 스텁 · abi3 네 가지를 확인하세요. 다음은 🟨 Node · WASM 탭입니다.
napi-rs · tokio · wasm-bindgen · wasm-pack · 번들 최적화

서버면 애드온
브라우저면 WASM

JavaScript 쪽으로 나가는 길은 둘입니다 — Node 안에서 도는 네이티브 애드온과 브라우저·엣지에서 도는 WASM. 목적이 완전히 다르므로 그 판단부터 하고 시작합니다. 둘 다 Rust 코드는 거의 그대로 쓸 수 있습니다.

10SECTIONS
2배포 경로
N-API버전 무관
4깊이 층
NODE 01

애드온 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)
파이썬과 다른 점 — 파이썬은 인터프리터라 루프 자체가 느립니다. JS 는 JIT 가 있어 단순 수치 루프는 이미 빠릅니다. 그래서 Node 에서 네이티브가 이기는 지점은 SIMD · 병렬 · 특수 알고리즘 · 기존 Rust 크레이트 활용 쪽입니다.

이렇게도 쓸 수 있다 — 실제 사례로 보기 🟡 중급

널리 쓰이는 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 로 옮기세요.
파이썬과 가장 다른 점 — 파이썬은 GIL 을 놓으면 되지만(🐍 y07), Node 는 싱글 스레드 이벤트 루프라 막는 순간 서버 전체가 멈춥니다. 네이티브 애드온을 만들 때 비동기 설계가 성능보다 먼저입니다.

한 단계 더 — 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)보다 나은 점입니다.
  타입이 코드와 어긋날 수 없습니다.
정리 — 브라우저·엣지면 WASM, Node 서버에서 최고 성능이면 애드온, 크래시 격리·CLI 겸용이면 별도 프로세스입니다. 애드온은 N-API 덕분에 Node 버전이 바뀌어도 재빌드가 필요 없지만, 플랫폼별 바이너리를 CI 로 만들어야 합니다(j10). 무엇을 고르든 10ms 이상 걸리면 비동기로 만드세요.
애드온이냐 WASM 이냐 — 배포와 권한에서 갈립니다 네이티브 애드온 (napi-rs) 가장 빠릅니다 — 네이티브 속도 그대로 파일 · 소켓 · OS API 에 직접 접근 스레드를 자유롭게 씁니다 OS · 아키텍처마다 빌드해야 합니다 Node 전용 — 브라우저에서는 못 씁니다 서버 · CLI · 빌드 도구에 적합 WebAssembly (wasm-bindgen) 한 번 빌드하면 어디서나 같은 파일 브라우저와 Node 에서 같은 코드 샌드박스 — 파일 접근은 호스트를 통해서만 네이티브보다 대체로 조금 느립니다 경계를 넘을 때 데이터 복사가 생깁니다 에디터 · 미리보기 · 공용 로직에 적합 브라우저에서도 쓸 계획이 있으면 WASM · 서버 전용이면 애드온 둘 다 필요할 수도 있습니다 — 핵심 로직을 한 크레이트에 두고 얇은 래퍼만 둘로 나누면 됩니다
고르는 기준은 성능이 아니라 어디서 돌아야 하는가입니다.
NODE 02

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 / TSRust
numberi32 u32 f64
biginti64 BigInt
stringString
booleanbool
T[]Vec<T>
BufferBuffer ★
Uint8ArrayUint8Array
object#[napi(object)]
MapHashMap
nullOption<T>
Promise<T>AsyncTask · async fn
Errornapi::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 지원 범위를 확인하세요
Deno·Bun 에서도 N-API 애드온을 로드할 수 있습니다. 다만 지원 범위가 Node 보다 좁을 수 있으니 대상 런타임에서 실제로 테스트하세요.
정리 — npm create napi@latest → #[napi] 를 붙이고 → napi build. binding.gyp 도 node-gyp 도 필요 없고, TypeScript 타입이 자동 생성됩니다. 순수 로직과 바인딩을 파일로 분리해 두면 cargo test 가 그대로 돌아 개발이 훨씬 편합니다.
NODE 03

값 변환 — 객체 · 배열 · 옵션

숫자와 문자열은 그냥 됩니다. 실무에서 걸리는 것은 객체를 어떻게 주고받을지와 변환 비용이 어디서 나는지입니다. 대부분 #[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) ★
문자열이 파이썬보다 비쌉니다 — JS 는 UTF-16, Rust 는 UTF-8 이라 주고받을 때마다 인코딩 변환이 일어납니다. 대량 텍스트는 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
V8 의 JIT 때문에 워밍업 없이 잰 수치는 믿을 수 없습니다. 같은 함수를 수만 번 돌린 뒤부터 재세요. Rust 쪽 벤치마크는 criterion 으로 따로 하는 것이 정확합니다(⚡ e01).
정리 — #[napi(object)] 로 구조체를 주고받으면 TypeScript 타입까지 자동입니다. 다만 객체는 복사이고, 문자열은 UTF-16↔UTF-8 변환이 붙습니다. 대량 데이터는 Buffer·TypedArray 로(j04), 정수는 i32/u32 를 기본으로 쓰세요.
경계를 넘을 때 무엇이 복사되는지 — 이것만 알면 됩니다 자바스크립트 Rust 경계를 넘는 비용 number · boolean f64 · i32 · bool 없음 — 값 그대로 전달됩니다 Array Vec<T> 원소 개수만큼 복사 — 길면 부담됩니다 object · { a, b } #[napi(object)] struct 필드마다 읽어 복사 — 얕지 않습니다 string String UTF-16 → UTF-8 재인코딩 — 가장 비쌈 Buffer · TypedArray &[u8] 복사 없음 — 같은 메모리를 빌립니다 그래서 설계 규칙은 하나입니다 — 경계를 자주 넘지 말고, 넘을 때는 크게 넘으세요. 큰 데이터는 문자열 대신 Buffer 로 주고받는 편이 훨씬 쌉니다
병목은 대개 Rust 안이 아니라 경계를 넘나든 횟수에 있습니다.
NODE 04

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)?;
V8 은 외부 메모리를 모릅니다 — 큰 버퍼를 많이 만들면 힙은 여유로운데 RSS 는 폭증합니다. 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)
스트리밍이 잘 맞는 이유 — Node 는 스트림 문화가 강한 런타임입니다. 전체를 메모리에 올리지 않고 청크 단위로 넘기는 API가 관용적이면서 성능도 좋습니다.

한 단계 더 — 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)     // 바뀌었으면 같은 메모리를 본 것입니다 ★
③ 이 가장 확실한 검증입니다 — 제자리 수정이 JS 쪽에 보이면 같은 메모리를 공유하고 있다는 증거입니다. 테스트에 이 검사를 하나 넣어 두면 리팩터링 중 복사가 끼어드는 것을 잡습니다.
정리 — Buffer·TypedArray 는 Rust 슬라이스로 복사 없이 받습니다. 제자리 수정이 JS 에 그대로 보이므로 그게 제로카피의 증거입니다. 자주 호출된다면 출력 버퍼를 받아 재사용하게 만들고, Buffer 를 오래 보관하려면 to_vec() 으로 복사하거나 Buffer 자체를 필드로 두세요.
버퍼를 넘길 때 — 복사하느냐 빌리느냐 복사 — Buffer → Vec<u8> 같은 내용이 메모리에 두 벌 생기고, 길이에 비례하는 시간이 듭니다 100MB 파일이면 복사만으로 수십 밀리초 — 이후 계산은 안전하고 자유롭습니다 빌리기 — Buffer → &[u8] 같은 메모리를 그대로 봅니다 — 넘기는 비용이 사실상 0 입니다 대신 빌린 동안 JS 쪽에서 그 버퍼가 사라지면 안 됩니다 동기 함수 안에서만 쓴다면 빌리기 · 비동기로 넘긴다면 복사하거나 소유권을 받기 결과를 돌려줄 때도 같습니다 — Vec 을 그대로 넘기면 소유권이 넘어가 추가 복사가 없습니다
경계에서 잃는 성능의 대부분은 계산이 아니라 같은 바이트를 다시 쓰는 일입니다.
NODE 05

비동기 — 이벤트 루프를 막지 않기

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)
}
고르는 기준 — CPU 작업이면 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 를 비교하면 차이가 명확합니다
가장 흔한 운영 사고 — 개발 중에는 요청이 하나뿐이라 동기 함수도 빨라 보입니다. 동시 요청 100개가 들어오면 전부 줄을 섭니다. 부하 테스트로 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
파이썬과 결정적으로 다른 점 — 파이썬은 GIL 만 놓으면 됐지만(🐍 y07), Node 는 이벤트 루프 · libuv 풀 · tokio 런타임 세 층을 함께 고려해야 합니다. 어느 스레드에서 도는 코드인지를 항상 의식하세요.
정리 — 10ms 이상 걸리면 반드시 비동기로 만드세요. CPU 작업은 AsyncTask, I/O 는 async fn(tokio)이 기본이고, 섞이면 spawn_blocking 으로 나눕니다. 비동기도 수십 μs 의 비용이 있으니 아주 짧은 작업은 동기로 두고, monitorEventLoopDelay 로 실제로 막고 있는지 확인하세요.
애드온이 서버를 멈추는 이유 — 어느 스레드에서 도느냐 동기 애드온 — 이벤트 루프 스레드에서 그대로 5초짜리 계산이면 그 5초 동안 다른 요청도 타이머도 전혀 처리되지 않습니다 Rust 가 빠르다는 사실과 무관합니다 — 어차피 한 스레드를 점유합니다 비동기 애드온 — 워커 스레드 풀로 넘김 #[napi] 에 async 를 붙이면 계산이 별도 스레드에서 돌고 JS 는 프로미스를 받습니다 이벤트 루프는 그동안 다른 요청을 계속 받습니다 기준은 대략 1밀리초입니다 — 그보다 짧으면 동기가 오히려 빠릅니다(전환 비용이 더 큽니다) 워커 풀은 기본 4개입니다 — 오래 걸리는 작업을 여럿 던지면 풀 자체가 밀립니다 Rust 안에서 자체 스레드를 쓸 때는 JS 콜백을 부르는 방식을 반드시 스레드 안전한 것으로 쓰세요
네이티브를 붙였는데 서버가 느려졌다면 십중팔구 동기로 만든 무거운 함수입니다.
NODE 06

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)}%`))
tsfn 이 살아 있으면 프로세스가 종료되지 않습니다 — 이벤트 루프에 참조가 남기 때문입니다. 작업이 끝나면 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() }         // ★ 정리를 보장
}
얇은 JS 래퍼를 두는 것이 정석입니다 — 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 ★
//   })
정리 — 워커 스레드에서 JS 를 부르려면 ThreadsafeFunction 을 거쳐야 합니다. 작업이 끝나면 abort() 를 부르세요 — 안 하면 Node 프로세스가 종료되지 않습니다. 콜백은 호출당 수 μs 이므로 시간 기반 스로틀링으로 횟수를 줄이고, EventEmitter 같은 Node 관용구는 JS 래퍼로 감싸는 것이 정석입니다.
NODE 07

클래스 노출 — #[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 를 구분해 줍니다
실무 결론 — Node 에서 병렬 처리가 필요하면 worker_threads 로 나누기보다 Rust 안에서 rayon 으로 처리하는 편이 훨씬 간단하고 빠릅니다. 데이터 복제가 없기 때문입니다.

한 단계 더 — 인스턴스가 실제로 어떻게 연결되나 🧬 전문가

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)
Drop 카운트 테스트를 Rust 쪽에 하나 두세요 — Arc<AtomicUsize> 를 필드에 넣고 Drop 에서 증가시키면 JS 없이도 해제를 검증할 수 있습니다(r11).
정리 — 값 묶음이면 #[napi(object)], 상태·자원이면 #[napi] struct 입니다. 클래스는 핸들만 오가므로 큰 상태를 여러 번 쓸 때 훨씬 쌉니다. GC 시점은 예측할 수 없으니 한정 자원에는 close() 를 제공하고, 비동기 메서드에서는 내부 상태를 Arc 로 공유하세요.
NODE 08

wasm-bindgen — 브라우저에서 도는 Rust

같은 Rust 코드를 브라우저에서 도는 WASM 으로 뽑습니다. 명령 두 줄이면 되고, TypeScript 타입도 함께 나옵니다. 서버리스·엣지 런타임에서도 이 방식만 씁니다(j01).

napi-rs 애드온과 wasm-bindgen — 갈림길은 '어디서 돌 것인가' 네이티브 애드온 (napi-rs) Node 안에서 직접 실행 — 호출 비용이 거의 0 파일 · 소켓 · 스레드를 전부 쓸 수 있습니다 tokio · rayon 같은 크레이트를 그대로 플랫폼 × 아키텍처마다 빌드가 필요합니다 패닉이 나면 프로세스가 죽습니다 WebAssembly (wasm-bindgen) 한 번 빌드해 Node · 브라우저 양쪽에서 샌드박스 — 죽어도 호스트는 멀쩡합니다 배포가 npm 패키지 하나로 끝납니다 파일 · 소켓 접근에 제약이 있습니다 네이티브의 50~80% 속도 고르는 기준 브라우저에서도 돌아야 하면 WASM · 서버에서만 쓰고 성능이 최우선이면 애드온 · 둘 다 필요하면 코어를 공유하고 껍데기만 두 벌
러스트는 두 껍데기를 같은 코어에 씌우기 쉬운 언어입니다 — 크레이트를 나눠 두면 양쪽을 함께 냅니다.

처음부터 끝까지

# ① 도구 설치 (한 번만)
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 와 주고받기 🟡 중급

타입 대응과 복사

JSRust복사
numberi32 f64—
stringString &str있음 ★
Uint8ArrayVec<u8>있음
Uint8Array&[u8]있음
메모리 뷰—없음 ★
ArrayVec<T>있음
objectJsValue·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 버그 — 메모리를 새로 할당하면 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 대응)를 쓰세요
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 가 뷰로 보는 방향은 제로카피입니다 — 다만 메모리가 자라면 뷰가 무효가 되니 쓰기 직전에 만드세요.
NODE 09

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 후로 판단합니다.
줄이는 순서 — 위에서부터 효과가 큽니다 ① 기본 --release 1.8MB ② opt-level="z" · lto 960KB 코드 크기 우선 ③ panic = "abort" 620KB 포맷·되감기 코드가 통째로 빠짐 ④ wasm-opt -Oz 410KB 쓰지 않는 코드·타입 정리 ⑤ brotli 전송 140KB 사용자가 실제로 받는 크기 — 판단은 여기서 0 500 1000 1500 크기(KB) 가장 흔한 발견은 패닉 메시지 포맷 코드가 바이너리의 절반이라는 것입니다. twiggy 로 무엇이 큰지 먼저 보고 나서 손대세요 숫자는 예시입니다 — 프로젝트마다 다르지만 순서와 효과의 크기 차이는 대체로 이렇게 나옵니다. 그래도 크면 기능을 나눠 지연 로드합니다 ①→② 는 설정 한 줄, ④→⑤ 는 서버 설정 한 줄입니다 — 코드를 고치기 전에 이것부터 하세요
웹에서는 받는 시간이 도는 시간보다 큽니다 — 크기가 곧 성능입니다.
NODE 10

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 시간 절약)
musl(Alpine)을 빠뜨리지 마세요 — 도커 이미지의 상당수가 Alpine 기반이라 “도커에서만 안 돼요” 문의가 가장 많이 옵니다. glibc 빌드를 musl 에서 로드하면 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)
파이썬 wheel 과 같은 문제입니다(🐍 y11) — 바이너리를 배포하는 순간 신뢰가 핵심이 됩니다. CI 에서만 배포 + provenance 두 가지는 반드시 갖추세요.
정리 — 플랫폼별 서브 패키지 + optionalDependencies 가 요즘 표준이고, napi-rs 의 CI 템플릿이 이걸 전부 만들어 줍니다. musl(Alpine)을 빠뜨리지 마세요 — 문의의 대부분이 여기서 옵니다. WASM 폴백을 두면 어떤 플랫폼에서도 동작하고, CI 에서만 배포 + provenance 로 신뢰를 확보하세요. 다음은 🔗 C ABI · FFI 탭입니다.
설치 경험 — 미리 빌드해 두었느냐로 갈립니다 미리 빌드가 있을 때 npm install 이 맞는 파일만 내려받습니다 몇 초 · 도구 사슬 불필요 CI 에서도 그대로 통과합니다 없을 때 사용자 PC 에서 직접 컴파일합니다 Rust 설치 필요 · 몇 분 · 실패 가능 "설치가 안 돼요" 문의의 주범입니다 CI 에서 만들어 둘 조합 linux x64 · arm64 macOS x64 · arm64 windows x64 musl (알파인) 별도 알파인 리눅스는 표준 리눅스 빌드가 안 돕니다 — 도커를 쓴다면 musl 빌드를 꼭 넣으세요 napi-rs 의 CI 템플릿이 이 매트릭스를 그대로 만들어 줍니다 — 직접 짜지 마세요
네이티브 패키지의 성패는 성능이 아니라 설치가 조용히 끝나는가에서 갈립니다.
#[no_mangle] · repr(C) · cbindgen · bindgen · catch_unwind

한 번 만들어
모든 언어에서

PyO3 는 파이썬 전용이고 napi-rs 는 Node 전용입니다. C ABI 로 내보내면 파이썬·Node·C#·자바·Go·C++ 어디서나 같은 라이브러리를 부릅니다. 대신 안전장치가 전부 사라지므로 규약을 스스로 지켜야 합니다 — 그 규약이 이 탭의 내용입니다.

10SECTIONS
1공용어 (C ABI)
∞부를 수 있는 언어
4깊이 층
FFI 01

C ABI 가 공용어인 이유

언어마다 문법도 런타임도 다르지만 “C 함수를 부르는 법”은 모두 압니다. 그래서 언어 간 연동은 대부분 C 인터페이스를 경유합니다. Rust 는 런타임이 없어 이 역할에 특히 잘 맞습니다.

ABI 란 무엇인가

API — 소스 코드 수준의 약속 (함수 이름 · 인자 타입)
ABI — 기계어 수준의 약속 ★
        · 인자를 어느 레지스터·스택에 놓는가
        · 반환값은 어디로 오는가
        · 구조체 필드가 메모리에 어떻게 배치되는가
        · 함수 이름이 심볼로 어떻게 적히는가
        · 누가 스택을 정리하는가

C ABI 는 이것들이 '플랫폼마다 고정' 돼 있습니다.
  x86-64 SysV (리눅스·맥)  ·  Win64 (윈도우)  ·  AAPCS64 (ARM)

★ 그래서 C 로 컴파일된 라이브러리는
  어떤 언어에서도 부를 수 있습니다.
  Rust 는 "C 처럼 보이는 함수"를 만들 수 있습니다.
Rust ABI 는 불안정합니다 — 컴파일러 버전이 다르면 같은 Rust 끼리도 안 맞을 수 있습니다(🔬 d11). 그래서 경계는 반드시 C ABI 여야 합니다.

언제 C ABI 를 고르나 ★

상황권장
파이썬만PyO3 (🐍 y02)
Node 만napi-rs (🟨 j02)
여러 언어C ABI
C/C++ 앱에 넣기C ABI
C# · 자바 · GoC ABI
게임 엔진 플러그인C ABI
기존 C 라이브러리 쓰기bindgen (f08)
브라우저WASM (🟨 j08)
★ C ABI 의 대가
  · Option · Result · String · Vec 을 못 씁니다 (f03 · f06)
  · 메모리 해제 책임을 문서로 정해야 합니다 (f06)
  · 패닉이 경계를 넘으면 안 됩니다 (f09)
  · 제네릭·트레이트가 사라집니다 (구체 함수만)
  · 컴파일러가 지켜 주던 것을 사람이 지켜야 합니다 ★

★ 그럼에도 얻는 것
  · 하나의 .so/.dll/.dylib 로 모든 언어 지원
  · 언어별 바인딩 코드를 각 언어에서 얇게 작성
  · 버전을 오래 유지할 수 있습니다 (안정된 인터페이스)

이렇게도 쓸 수 있다 — 전체 그림 그리기 🟡 중급

계층 구조 ★

Rust 핵심 로직 순수 Rust · 테스트 가능 C ABI 층 extern "C" · repr(C) · 에러 코드 🐍 ctypes 🟨 koffi 🟣 DllImport ☕ JNA · Go
핵심 로직은 한 벌, C ABI 층만 얇게 두면 네 언어가 같은 .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;
설정을 JSON 으로 받는 트릭 — 구조체를 주고받으면 필드가 늘 때마다 모든 언어의 바인딩을 고쳐야 합니다. JSON 문자열 하나면 추가·삭제가 자유롭고 하위 호환도 쉽습니다. 성능이 중요한 경로가 아니라면 좋은 선택입니다.

크레이트 설정

[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
정리 — C ABI 는 언어 간 공용어이고, Rust 는 런타임이 없어 이 역할에 잘 맞습니다. 대신 Option·Result·String 을 못 쓰고, 안전장치가 사라집니다. 좁게 · 안정되게 · 소유권 명시 · 에러는 코드로 · 패닉은 가두기 — 이 다섯 원칙이 이 탭의 전부입니다.
FFI 02

#[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_ 같은 접두사를 붙이세요.

쓸 수 있는 타입 ★

CRust
int32_ti32
uint64_tu64
size_tusize
doublef64
boolbool ★
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      # 내보내는 심볼 개수 ★
심볼 개수를 세어 보세요 — 함수 5개를 내보내려는데 수천 개가 보인다면 정적 링크한 C 라이브러리가 새고 있는 것입니다. 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 로 심볼부터 확인하세요.
FFI 03

#[repr(C)] — Rust 는 필드 순서를 바꿉니다

Rust 는 메모리를 아끼려고 구조체 필드를 마음대로 재배치합니다. 그래서 C 와 구조체를 주고받으려면 #[repr(C)] 로 “건드리지 마”라고 해야 합니다. 이걸 빠뜨리면 값이 조용히 깨집니다 — 크래시도 안 나서 더 위험합니다.

러스트는 필드 순서를 마음대로 바꿉니다 — 경계에서는 막아야 합니다 ① 기본 배치 — 순서 보장 없음 struct P { a: u8, n: u32, b: u8 } n (u32) a b 컴파일러가 빈칸을 줄이려고 재배치했습니다 ② #[repr(C)] — 선언 순서 그대로 #[repr(C)] struct P { a, n, b } a 빈칸 3 n (u32) b C 컴파일러와 같은 규칙 — 그대로 주고받습니다 경계를 넘는 타입에 붙여야 하는 표기 #[repr(C)] — 구조체 · 공용체 배치를 C 규칙으로 / #[repr(transparent)] — 한 필드짜리 래퍼를 원본과 같게 #[repr(u8)] 등 — enum 의 표현 크기를 고정 / 기본 enum · 튜플 · Vec · String 은 절대 그대로 넘기지 마세요
헤더는 손으로 쓰지 말고 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 캐스팅을 직접 쓰지 않고도 정렬·크기·패딩 안전성을 컴파일 타임에 검증해 줍니다.
정리 — FFI 로 나가는 구조체에는 반드시 #[repr(C)]. 빠뜨리면 크래시 없이 값만 조용히 깨집니다. 고정 크기 타입(i32·u64·f64)만 쓰고 long·usize 는 피하세요. 크기·오프셋을 const assert 로 고정해 두면 실수로 바뀌는 것을 빌드가 막아 줍니다.
FFI 04

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·SwiftUniFFI ★
Dart / Flutterflutter_rust_bridge
Gocgo + 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 도 검토하세요.
헤더가 소스와 어긋나는 순간, 컴파일러는 아무 말도 하지 않습니다 ① 손으로 쓴 헤더 — 조용히 깨집니다 Rust: struct Cfg { u32, u64 } 로 바뀜 헤더: struct Cfg { u32, u32 } 옛날 그대로 ≠ 컴파일은 통과합니다 필드 위치가 밀려 값이 깨집니다 ② cbindgen — 소스가 곧 헤더입니다 Rust 소스 #[repr(C)] 타입 build.rs cbindgen 실행 mylib.h 매번 다시 생성 C 쪽 빌드 항상 일치 CI 에 한 줄 더 — 재생성한 헤더와 저장소의 헤더를 diff 해서 다르면 실패. 헤더 갱신을 잊은 커밋이 그 자리에서 걸립니다 더불어 C 쪽에서 sizeof 를 확인하는 스모크 테스트를 하나 두면, 정렬이 다른 플랫폼에서 나는 어긋남까지 잡힙니다
FFI 에서 가장 무서운 버그는 에러 없이 값만 틀리는 것입니다 — 헤더는 손으로 쓰지 마세요.
FFI 05

파이썬 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 를 넘기면 값이 깨지거나 크래시합니다. 모든 함수에 반드시 지정하세요.

타입 대응표 ★

Rustctypes
i32c_int32
u64c_uint64
usizec_size_t
f64c_double
boolc_bool
*const c_charc_char_p
*mut u8POINTER(c_ubyte)
*mut c_voidc_void_p
#[repr(C)] structStructure
함수 포인터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 🔴 실무

고르는 기준 ★

ctypesPyO3
빌드불필요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 는 안전망이 없습니다 — 시그니처를 틀리게 적으면 조용히 값이 깨지거나 세그폴트가 납니다. 그래서 얇은 파이썬 래퍼 모듈을 만들어 시그니처를 한 곳에서만 관리하는 것이 정석입니다.
정리 — ctypes.CDLL 로 로드하고 argtypes·restype 을 반드시 지정하세요. numpy 배열은 .ctypes.data_as() 로 복사 없이 넘어갑니다. 콜백 객체는 변수에 보관해야 하고(GC 주의), 얇은 파이썬 래퍼로 감싸 시그니처와 에러 변환을 한 곳에 모으세요.
FFI 06

문자열 · 메모리 소유권 규약

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);
불투명 핸들이 정석인 이유 — 내부 구조가 헤더에 안 나오므로 필드를 자유롭게 바꿔도 사용자 코드가 안 깨집니다. C 라이브러리(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 인지 확인 ★
이중 해제는 즉시 크래시하지 않습니다 — 힙이 망가진 뒤 한참 뒤 엉뚱한 곳에서 터집니다. 그래서 재현이 어렵습니다. ASan 을 CI 에 붙이는 것이 가장 확실한 대응입니다.

한 단계 더 — 할당자와 경계 🧬 전문가

왜 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 에 넘길 때 CString::into_raw 로 포인터를 넘기고, 해제 함수(rs_free_string)도 함께 내보냅니다 C 가 free() 로 풀면 안 됩니다 — 할당자가 달라 그 자리에서 깨집니다 C 가 만들어 Rust 에 넘길 때 CStr::from_ptr 로 빌려 읽기만 합니다 — Rust 는 해제하지 않습니다 계속 보관해야 하면 to_owned() 로 복사해 두고 원본은 잊습니다 문자열은 널로 끝나야 하고, 중간에 널 바이트가 있으면 거기서 잘립니다 그리고 C 쪽 문자열이 올바른 UTF-8 이라는 보장은 없습니다 — 검사하거나 바이트로 다루세요 규약을 헤더 주석에 적어 두세요 — "이 포인터는 rs_free_string 으로만 해제" 한 줄이 사고를 막습니다
FFI 버그의 대부분은 타입이 아니라 해제 책임이 애매한 데서 나옵니다.
FFI 07

콜백 — 거꾸로 부르기

진행률 보고, 이벤트 알림, 스트리밍 출력 — 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 되면 함수 포인터가 무효가 됩니다. 파이썬은 변수 보관, C# 은 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 를 만질 수 있습니다)
중단 신호를 반환값으로 두는 것은 비용이 0 이면서 매우 유용합니다. 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 프레임워크와 궁합이 좋습니다 ★
//   · 언어별 바인딩이 훨씬 단순해집니다
//
// ★ 단점
//   · 폴링 주기만큼 지연이 생깁니다
//   · 큐가 넘칠 때의 정책이 필요합니다 (드롭 · 블로킹 · 확장)
여러 언어를 지원할수록 큐 방식이 유리합니다 — 콜백은 언어마다 다른 함정(GC · GIL · UI 스레드)이 있지만, 폴링은 어디서나 똑같이 동작합니다.
정리 — C ABI 콜백은 함수 포인터 + void* user data 가 공통 관용구입니다. 콜백 객체의 수명은 호출자 책임이고 GC 함정이 언어마다 있으니 문서에 경고하세요. 중단 신호를 반환값으로 두면 나중에 취소 기능을 ABI 를 깨지 않고 추가할 수 있고, 여러 언어를 지원한다면 폴링·큐 방식이 훨씬 단순합니다.
콜백은 '함수 포인터 + 사용자 데이터' 한 쌍입니다 호출자 (C · C# · Go) fn on_progress(ctx, pct) ctx = 내 객체 포인터 이 둘을 등록합니다 ① 등록 ③ ctx 와 함께 거꾸로 호출 Rust 라이브러리 cb: extern fn(*mut c_void, u32) ctx 를 그대로 보관 ② 오래 걸리는 작업 진행 반환값으로 중단 신호 0 이 아니면 작업을 멈춥니다 세 언어가 공통으로 밟는 지뢰 ⓐ 콜백 객체가 GC 로 사라짐 → 등록해 둔 동안 살려 두는 것은 호출자 책임입니다 ⓑ 콜백 안의 패닉이 경계를 넘음 → catch_unwind 로 반드시 막습니다 더 단순한 길 — 폴링과 큐 Rust 는 진행 상황을 내부 큐에 쌓아 두고, 호출자가 poll_progress() 로 꺼내 갑니다 스레드·GC·예외 문제가 한꺼번에 사라집니다 지원할 언어가 늘수록 이쪽이 유리합니다
거꾸로 부르는 순간 수명과 스레드가 상대 언어의 규칙을 따릅니다 — 문서에 반드시 적어 두세요.
FFI 08

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)
    · 정적 링크가 배포를 훨씬 단순하게 만듭니다 ★
기존 C 라이브러리를 파이썬에 노출하는 일이라면 Rust 를 중간층으로 두는 것이 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)
정리 — 기존 C 라이브러리는 -sys(bindgen 생성) + 안전 래퍼 두 층으로 감쌉니다. RAII(Drop) · Result · 슬라이스로 감싸면 사용자는 unsafe 를 안 씁니다. 배포를 단순하게 하려면 정적 링크 또는 vendored 소스를 쓰고, 스레드 안전성은 C 문서를 읽고 Send/Sync 를 결정하세요.
FFI 09

패닉이 경계를 넘으면 — 그리고 에러 코드

Rust 패닉이 C 코드를 가로질러 되감기면 정의되지 않은 동작입니다. 그래서 모든 extern "C" 함수는 패닉을 가두고 에러 코드로 바꿔야 합니다. 이 섹션은 FFI 에서 가장 중요한 안전 규칙입니다.

패닉이 C 경계를 넘으면 — 미정의 동작입니다 러스트 함수 안에서 패닉 unwrap · 범위 밖 인덱스 되감기가 C 프레임으로 C 는 되감기를 모릅니다 UB 보통은 즉시 abort 막는 방법 두 가지 — 하나는 반드시 해야 합니다 ① catch_unwind 로 감싸기 공개하는 extern "C" 함수마다 씌웁니다 패닉을 잡아 에러 코드로 바꿔 돌려줍니다 부르는 쪽 언어가 정상적으로 처리할 수 있습니다 단, 잡을 수 없는 패닉도 있습니다 ② panic = "abort" 로 설정 Cargo.toml 프로파일에서 지정합니다 되감기 자체가 없어져 경계를 넘을 일이 없습니다 바이너리도 작아지고 조금 빨라집니다 대신 프로세스가 통째로 죽습니다
라이브러리로 배포한다면 ①번을 반드시 하세요 — ②는 쓰는 쪽의 선택이라 강제할 수 없습니다.

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!({ /* 본문 */ })
}
Rust 2024 에디션부터 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)?;
FFI 모듈에 #![deny(clippy::unwrap_used)] 를 붙이세요 — 패닉 가능성을 컴파일 타임에 제거하는 것이 catch_unwind 로 잡는 것보다 훨씬 낫습니다.
정리 — 모든 extern "C" 함수는 catch_unwind 로 패닉을 가두고 에러 코드 + last_error 함수로 실패를 알립니다. 에러 코드 숫자는 한 번 배포하면 절대 바꾸지 마세요. 확장은 새 함수 · 새 코드 · 크기 필드가 든 구조체로 하고, FFI 층에는 unwrap·인덱싱을 clippy 로 금지하세요.
FFI 10

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)
}
csbindgen 크레이트를 쓰면 위 P/Invoke 코드를 Rust 소스에서 자동 생성할 수 있습니다(f04). Unity 에서 Rust 를 쓸 때 널리 쓰이는 방식입니다(🟣 C# 가이드 참고).

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 에 각 언어 사용자를 위한 마이그레이션 안내가 있는가
언어를 하나 더 지원하는 비용은 생각보다 큽니다 — 바인딩 코드 + 테스트 + 패키징 + CI + 문서. 정말 필요한 언어만 지원하고, 나머지는 C 헤더와 예제를 제공해 사용자가 직접 바인딩하게 하는 것도 좋은 전략입니다.

한 단계 더 — 호출 비용 비교 🧬 전문가

언어별 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 ABI 하나면 C# · 자바 · Go · Ruby · Swift 어디서나 부를 수 있고, 패턴은 로드 → 시그니처 선언 → 호출 → 해제로 같습니다. 각 언어 바인딩에 스모크 테스트를 두고 CI 에서 돌리고, 런타임 버전 확인으로 불일치를 즉시 드러내세요. 언어를 늘리는 비용은 실제로 크니 필요한 것만 지원하는 편이 낫습니다. 다음은 🛠️ 빌드 · 배포 탭입니다.
한 번 만들어 네 언어에서 — 규약만 지키면 됩니다 core (Rust) → cdylib .so · .dll · .dylib Python ctypes · cffi C# DllImport Java FFM · JNA Go cgo 공통 규약 네 가지 #[repr(C)] 로 배치 고정 · 에러는 정수 코드 · 문자열은 널 종료 · 해제 함수를 함께 제공 패닉이 경계를 넘지 않게 막는 것도 규약의 일부입니다 — 넘어가면 그대로 프로세스가 죽습니다
언어를 넘나드는 코드의 가치는 성능보다 같은 로직을 한 번만 관리한다는 데 있습니다.
cargo · Cargo.toml · feature · 워크스페이스 · 크로스 · CI

도구 하나가
전부를 합니다

C++ 에서 CMake · vcpkg · 컴파일러 플래그 · CI 스크립트를 각각 배워야 했던 자리를 cargo 하나가 대신합니다. 여기서는 실무에 실제로 필요한 것만 — Cargo.toml 읽는 법, feature 설계, 크로스 컴파일, 그리고 빌드가 느려졌을 때 무엇부터 손대야 하는지를 봅니다.

10SECTIONS
1도구로 전부
3OS 자동 빌드
4깊이 층
BUILD 01

cargo 가 하는 일 — 한 도구로 전부

cargo 는 빌드 도구이자 패키지 관리자이자 테스트 러너이자 문서 생성기입니다. 이 하나가 C++ 의 도구 대여섯 개를 대신합니다 — 그리고 그것이 Rust 로 네이티브 모듈을 만들 때 가장 크게 체감되는 이점입니다.

C++ 와 나란히 ★

하는 일C++Rust
빌드CMake · Makecargo
의존성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 하나가 맡는 일 — 다른 언어에서는 도구 여섯 개 cargo build 의존성 + 컴파일 cargo test 단위 · 통합 · 문서 예제 cargo doc HTML 문서 생성 cargo fmt 서식 논쟁 종료 cargo clippy 관용구까지 지적 cargo bench 성능 측정 cargo publish crates.io 배포 cargo install CLI 도구 설치 C++ 이라면 — cmake + vcpkg + ctest + doxygen + clang-format + clang-tidy 각각 설정 파일이 따로 있고, 팀마다 조합이 다릅니다 그래서 새 저장소를 받아도 README 를 읽을 일이 거의 없습니다 — cargo build 로 시작하면 됩니다 cargo 하위 명령은 확장됩니다 — cargo-expand · cargo-audit · cargo-nextest 를 설치해 쓰세요
Rust 를 처음 쓸 때 가장 먼저 체감하는 것은 언어가 아니라 도구가 하나라는 점입니다.
BUILD 02

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 = "김현우"
네이티브 모듈을 만든다면 crates.io 게시는 선택입니다 — PyPI·npm 으로만 배포해도 됩니다. 다만 핵심 로직 크레이트를 따로 게시해 두면 다른 사람이 Rust 에서도 쓸 수 있고, docs.rs 문서가 공짜로 생깁니다.

실무에선 이렇게 — 공개 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 에 노출하지 마세요.
BUILD 03

의존성 · 버전 · 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
파이썬·Node 와 같은 이야기입니다 — 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 로 중복 크레이트를 주기적으로 확인하는 습관도 유용합니다.
버전은 범위를 적고, 실제로 뽑힌 값은 lock 에 남습니다 Cargo.toml — 내 의도 serde = "1.2" 1.2.0 이상 2.0.0 미만이면 무엇이든 사람이 손으로 적는 파일 Cargo.lock — 실제 결과 serde 1.2.7 (정확히 이 버전) cargo 가 자동으로 씁니다 저장소에 함께 커밋하세요 잠금 파일이 있으면 몇 달 뒤에 빌드해도 같은 결과가 나옵니다 "내 PC 에선 되는데" 의 절반은 여기서 사라집니다 업데이트는 cargo update 로 명시적으로 — 빌드할 때 슬쩍 올라가는 일은 없습니다 0.x 는 규칙이 다릅니다 — "0.3" 은 0.3.x 안에서만 움직입니다(0.4 로 안 올라갑니다) 보안 점검은 cargo audit — CI 에 넣어 두면 알려진 취약점이 들어온 날 바로 알 수 있습니다
재현 가능한 빌드의 조건은 복잡한 설정이 아니라 잠금 파일을 커밋했는가 하나입니다.
BUILD 04

feature 플래그 — 안 쓰는 건 빌드에서 빼기

기능을 켜고 끌 수 있게 만드는 장치입니다. 끈 기능은 코드도 의존성도 통째로 빠집니다 — 빌드 시간·바이너리 크기·공격 표면이 함께 줄어듭니다. 네이티브 모듈에서는 파이썬용·Node용 바인딩을 나누는 데 특히 유용합니다.

feature 플래그 — 안 쓰는 코드는 아예 컴파일하지 않습니다 ① default-features = false + 필요한 것만 features = ["json"] 필요한 크레이트 빌드 20초 · 3MB ② features = ["full"] 쓰지 않는 것까지 전부 의존성 트리가 몇 배로 늘어납니다 빌드 3분 · 12MB 알아 두어야 할 두 가지 성질 기능은 합쳐집니다(additive) A 크레이트가 켠 기능이 내 빌드에도 켜집니다 "내가 안 켰는데 왜 들어왔지"의 답이 여기입니다 확인하는 명령 cargo tree -e features — 누가 무엇을 켰는지 cargo tree -d — 중복 버전이 들어왔는지
빌드가 느리다고 느껴지면 먼저 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 타깃으로 빌드해 봐야 확실합니다
네이티브 모듈에 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"] }
```
feature 를 늘리면 조합이 지수로 늘어납니다 — 5개를 넘어가면 유지보수가 어려워집니다. 정말 필요한 것만 두고, 나머지는 별도 크레이트로 나누는 편이 낫습니다(b05).

한 단계 더 — 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 를 켜 두세요.
정리 — feature 는 기능을 더하기만 해야 하고(additive), 끄면 코드와 의존성이 통째로 빠집니다. 네이티브 모듈은 핵심 로직 + python/capi feature 구성이 정석이고, cargo hack 으로 조합을 CI 에서 검증하세요. cfg 오타를 잡으려면 check-cfg 를 켜 두면 됩니다.
BUILD 05

워크스페이스 — 크레이트 여러 개를 한 저장소에

핵심 로직 · 파이썬 바인딩 · 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 에 두면 크로스 플랫폼이 됩니다.
한 저장소 · 여러 크레이트 — 캐시와 버전은 루트에서 하나로 Cargo.toml [workspace] members = [...] core 순수 로직 · 테스트 py PyO3 바인딩 node napi-rs 바인딩 cli 바이너리 바인딩은 모두 core 에만 의존합니다 공유되는 것 Cargo.lock 하나 · target/ 하나 · 의존성 빌드 한 번 [workspace.dependencies] 로 버전을 한 곳에서 고정 "나누면 느려진다"는 오해입니다 크레이트는 컴파일 단위라 나눌수록 병렬로 돌고, 한 곳을 고쳐도 그 크레이트만 다시 빌드됩니다
핵심 로직을 바인딩과 분리해 두면 파이썬·Node·CLI 를 같은 코드 위에 올릴 수 있습니다.
BUILD 06

프로파일 — 설정 몇 줄에 10배

디버그 빌드는 최적화가 꺼져 있어 파이썬보다 느릴 수도 있습니다. “Rust 로 바꿨는데 안 빨라졌다”의 1위 원인이 --release 누락입니다. 그리고 릴리즈 안에서도 설정에 따라 몇 배가 갈립니다.

"러스트가 느린데요" — 거의 항상 디버그 빌드로 잰 것입니다 dev (기본) — cargo run opt-level = 0 · 최적화 없음 디버그 정보 포함 · 오버플로 검사 켜짐 빌드가 빠릅니다 실행 속도가 릴리즈의 1/10 ~ 1/50 release — cargo run --release opt-level = 3 · 인라인과 벡터화 오버플로 검사 꺼짐(감쌈으로 동작) 빌드가 느립니다 성능을 잴 때는 반드시 이쪽으로 더 줄이고 싶을 때 — Cargo.toml 에 몇 줄 lto = "fat" 크레이트 경계까지 최적화 codegen-units = 1 병렬 코드 생성을 포기 panic = "abort" 되감기 코드를 뺍니다
세 줄을 다 켜면 빌드는 몇 배 느려지고 바이너리는 눈에 띄게 작아집니다 — 릴리즈 프로파일에만 넣으세요.

기본 프로파일

[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 전후를 비교해 보세요
파이썬 wheel 은 크기가 중요합니다 — 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 에서 죽습니다.
BUILD 07

크로스 컴파일 — 내 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)
maturin 은 --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 muslAlpine 도커 ★
linux aarch64ARM 서버·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)
정리 — 순수 Rust 는 rustup target add + --target 로 끝나고, 링커가 필요하면 cargo-zigbuild 나 cross 를 쓰세요. glibc 버전 문제는 manylinux 컨테이너나 zigbuild 의 버전 지정으로 풀고, Alpine 용 musl 빌드를 빠뜨리지 마세요. C 의존성이 있으면 vendored 또는 네이티브 CI 러너가 훨씬 단순합니다.
크로스 컴파일 난이도는 'C 를 링크하느냐'에서 갈립니다 내 PC x86_64 · 윈도우 순수 Rust — rustup target add + cargo build --target 두 줄이면 끝 C 라이브러리 링크 — 그 타깃용 C 툴체인과 헤더가 따로 필요합니다 애플 실리콘 · 코드 서명 — 대개 그 플랫폼의 CI 러너가 답입니다 리눅스에서 특히 자주 밟는 것 glibc 는 앞으로만 호환됩니다 최신 우분투에서 빌드한 바이너리는 낡은 서버에서 GLIBC_2.35 not found 로 죽습니다 → 가장 낮은 배포판 컨테이너에서 빌드합니다 알파인은 glibc 가 아니라 musl 입니다 도커 이미지가 알파인이면 musl 타깃 wheel 을 따로 만들어야 설치조차 되지 않습니다 → maturin 은 manylinux · musllinux 를 함께 냅니다 "빌드가 됐다"는 "동작한다"가 아닙니다 — 새로 낸 타깃은 그 플랫폼에서 한 번은 실제로 실행해 보세요
배포에서 새는 시간은 대부분 타깃 하나를 빠뜨린 데서 나옵니다.
BUILD 08

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 에서만 배포 + provenance 두 가지는 반드시 갖추세요(🟨 j10).

한 단계 더 — 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 가 아닙니다
CI 는 “어제까지 되던 것이 오늘 안 되는” 것을 잡는 장치입니다. 불안정한 테스트를 방치하면 모두가 실패를 무시하기 시작합니다 — 불안정한 테스트는 고치거나 지우세요.
정리 — maturin generate-ci github(🐍) 나 napi-rs 템플릿(🟨)에서 시작하는 것이 가장 빠릅니다. Swatinem/rust-cache 를 반드시 넣고(없으면 매번 전체 빌드), 태그 푸시 → 전 플랫폼 빌드 → 배포 → 설치 검증까지 자동화하세요. abi3 를 켜면 CI job 이 5분의 1로 줄어듭니다(🐍 y11).
BUILD 09

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)
초심자에게 가장 좋은 학습 도구입니다 — clippy 가 지적하는 것을 이유까지 읽으면 “Rust 답게 쓰는 법”이 자연스럽게 익혀집니다. 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)]
문서 예제가 테스트로 실행된다는 것이 Rust 문서 문화의 핵심입니다(r11). API 를 바꾸면 문서가 깨지고 CI 가 막습니다 — “문서가 낡았다”는 문제가 구조적으로 사라집니다.

실무에선 이렇게 — 팀에 도입하는 순서 🔴 실무

단계적으로 켜기 ★

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).
BUILD 10

바이너리 크기 · 빌드 시간 줄이기

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 에서만)

★ 이 목표를 넘어가면 위 목록을 순서대로 적용하세요.
C++ 와 비교하면 Rust 빌드가 느린 것은 사실이지만, 증분 빌드와 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 부터 보세요. 다음은 ⚡ 성능 · 동시성 탭입니다.
바이너리 크기 — 설정 몇 줄로 8MB 에서 0.9MB 까지 0 2 4 6 8 MB 기본 (debug) 8.0 MB --release 3.2 MB + lto = "fat" 2.6 MB + codegen-units = 1 2.4 MB + strip = true 1.6 MB + opt-level="z" · panic=abort 0.9 MB — 처음의 1/9 마지막 줄은 맞바꿈입니다 — 크기 우선은 속도를 조금 내주고, panic=abort 는 되감기를 포기합니다 CLI 나 컨테이너 이미지라면 거의 항상 이득이고, 계산 위주 서버라면 opt-level 은 3 으로 두세요
숫자는 프로젝트마다 다르지만 줄어드는 순서는 대체로 이대로입니다.
criterion · flamegraph · rayon · Send/Sync · SIMD

측정하지 않으면
최적화가 아닙니다

이 탭의 첫 번째 규칙은 “빨라진 것 같다”는 증거가 아니다입니다. 제대로 재는 법부터 잡고, Rust 에서 실제로 효과가 큰 순서대로 봅니다 — 불필요한 할당 제거 → 데이터 배치 → 병렬화. 그리고 데이터 경쟁을 컴파일러가 막아 주는 지점을 확인합니다.

10SECTIONS
0데이터 경쟁
6글자로 병렬화
4깊이 층
PERF 01

측정 먼저 — 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 프레임까지 보입니다
flamegraph 읽는 법 — 가로 폭이 시간이고, 세로는 호출 깊이입니다. 넓고 평평한 봉우리를 찾으세요 — 거기가 시간을 먹는 곳입니다. 위쪽이 뾰족하면 그 함수 자체가, 아래가 넓으면 하위 호출이 원인입니다.

간단한 측정도 유용합니다

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 노이즈에 취약하니 여유를 크게 잡으세요
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 순서로, 각 단계마다 측정하며 진행합니다.
성능 작업의 순환 — 재고, 하나 고치고, 다시 잽니다 1. 프로파일 perf · samply 어디가 뜨거운가 2. 가장 큰 것 하나 상위 한 항목만 여러 개 동시 금지 3. 고치기 할당 · 알고리즘 자료구조 순으로 4. 다시 측정 criterion 실제로 빨라졌나 여전히 느리면 1번으로 — 다음으로 큰 항목이 이미 보입니다 건너뛰면 생기는 일 감으로 고친 최적화의 대부분은 전체의 1퍼센트짜리 자리였고, 코드만 읽기 어려워집니다 여러 곳을 한꺼번에 고치면 무엇이 효과였는지 영영 알 수 없습니다 벤치마크는 release 로 재세요 — debug 빌드 수치는 아무것도 말해 주지 않습니다 최적화가 통째로 지워지지 않도록 결과를 std::hint::black_box 로 감싸세요
가장 빠른 최적화는 안 해도 되는 최적화를 안 하는 것입니다.
PERF 02

할당 줄이기 — 느린 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)
할당 횟수를 세는 것이 가장 확실한 진단입니다. “100만 개를 처리하는데 할당이 300만 번”이면 고칠 곳이 명확합니다. 테스트에 이 검사를 넣어 회귀를 막는 팀도 있습니다.

이렇게도 쓸 수 있다 — 복사를 없애는 도구들 🟡 중급

타입 선택으로 해결하기

// ① 인자는 빌림으로 (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
C++ 개발자가 놀라는 지점 — Rust String 에는 작은 문자열 최적화(SSO)가 없습니다. 짧은 문자열을 대량으로 다룬다면 compact_str 같은 크레이트가 눈에 띄는 차이를 만듭니다.
정리 — 루프 안의 clone()·format!·collect() 부터 찾으세요. with_capacity + clear() 로 버퍼 재사용이 가장 효과가 크고, 인자는 &str/&[T], 조건부 복사는 Cow 를 쓰세요. 할당 횟수를 세어 보는 것이 가장 확실한 진단입니다.
느린 Rust 의 90% — 반복문 안의 할당 할당이 반복마다 일어나는 모양 for line in lines { let s = line.to_string(); .. } — 줄마다 새 String let v: Vec<_> = it.collect(); 를 루프 안에서 — 반복마다 새 Vec 용량을 미리 잡기 Vec::with_capacity(n) 늘어날 때마다 다시 잡고 옮기는 일이 사라집니다 버퍼 재사용 buf.clear() 후 다시 채우기 clear 는 용량을 유지합니다 할당이 첫 회 한 번뿐 빌려 쓰기 String 대신 &str 바꿀 때만 복사하려면 Cow 대개 복사가 아예 없습니다 clone() 이 죄는 아닙니다 — 문제는 뜨거운 반복문 안에 있는 clone 입니다. 밖에서는 마음껏 쓰세요 to_string · format! · collect 를 반복문 안에서 찾아보는 것만으로 절반은 잡힙니다 그래도 부족하면 마지막에 할당자 교체를 봅니다 — 순서상 가장 나중입니다
Rust 가 느리다면 언어 탓이 아니라 같은 메모리를 계속 새로 만들고 있는 것입니다.
PERF 03

이터레이터가 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 이 없으면 전부 인라인된 것 ★
“zero-cost abstraction” 은 마케팅 문구가 아닙니다 — 어셈블리로 확인할 수 있는 사실입니다. 한 번 직접 확인해 보면 이터레이터를 쓰는 데 주저가 없어집니다.
정리 — 이터레이터는 인라인 + 루프 융합으로 for 문과 같은 기계어가 되고, 범위 검사가 없어 오히려 빠를 수 있습니다. 깨지는 조건은 dyn · LTO 꺼짐 · 부동소수점 합 · 어댑터 순서입니다. 의심되면 cargo asm 으로 직접 확인하세요.
세 단계로 보이지만, 만들어지는 것은 루프 하나입니다 우리가 쓴 코드 v.iter() .map(f) .filter(g) .sum() 각 단계는 그저 next() 호출입니다 인라인 + 융합 컴파일된 결과 for x in v { let y = f(x); if g(y) { acc += y; } } 원소를 한 번만 훑습니다 — 중간 배열이 만들어지지 않습니다 파이썬과 갈리는 지점 [f(x) for x in v] 는 리스트를 실제로 만들어 메모리에 올립니다 깨지는 자리는 정해져 있습니다 — 중간에 collect() 로 끊을 때 · Box<dyn Iterator> 로 감쌀 때 · 클로저가 커서 인라인이 안 될 때
"zero-cost" 는 구호가 아니라 어셈블리로 확인되는 사실입니다 — 이터레이터를 주저 없이 쓰세요.
PERF 04

메모리 레이아웃과 캐시

같은 알고리즘도 데이터가 메모리에 어떻게 놓여 있느냐에 따라 수 배가 갈립니다. 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.*      # 줄 단위로 미스를 보여 줍니다 ★
메모리 대역폭이 한계일 때는 병렬화가 안 먹힙니다 — 코어를 8개 써도 메모리가 못 따라오면 그대로입니다(e05). 배치를 먼저 고치고 병렬화하는 순서가 중요합니다.

한 단계 더 — 정렬과 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

// 메모리 대역폭이 한계인지 확인
//   대역폭 포화 상태면 계산 최적화는 효과가 없습니다
//   → 데이터 크기를 줄이거나(압축·작은 타입) 알고리즘을 바꾸세요 ★
“메모리 대역폭이 한계인가”를 먼저 확인하세요 — 그렇다면 SIMD 도 병렬화도 효과가 없습니다. 데이터를 작게 만드는 것(f64→f32, 압축, SoA)만이 답입니다.
정리 — RAM 접근은 L1 의 50배이고 캐시 라인은 64바이트입니다. 연속 배치(Vec) · 순차 접근 · 작은 구조체 세 가지가 기본이고, 필드 일부만 순회한다면 SoA 를 검토하세요. 스레드를 늘렸는데 느려지면 false sharing 을, 최적화가 안 먹히면 메모리 대역폭 한계를 의심하세요.
캐시는 64바이트씩 읽어 옵니다 — 배치가 곧 속도입니다 구조체 배열 — Vec<Particle { x, y, z, mass }> x y z m x y z m x 만 훑으면 y · z · mass 가 함께 올라옵니다 — 읽어 온 것의 4분의 1만 씁니다 배열의 구조체 — struct { xs: Vec<f32>, ys: Vec<f32>, .. } x x x x x x x x 필요한 값만 빽빽하게 — 읽어 온 덩어리를 전부 씁니다. 자동 벡터화도 잘 걸립니다 항상 옳은 것은 아닙니다 — 한 입자의 모든 필드를 함께 쓰는 코드라면 위쪽이 낫습니다 구조체 필드 순서는 Rust 가 알아서 정렬해 빈틈을 줄입니다 — C 배치가 필요할 때만 repr(C)
같은 알고리즘도 메모리를 훑는 순서에 따라 몇 배가 갈립니다.
PERF 05

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 여러 개가 현실적입니다
파이썬에서 이 조합이 특히 강력합니다 — GIL 때문에 파이썬 스레드로는 불가능했던 진짜 병렬 계산을 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)
//   엔드투엔드로 재세요
병렬화 전에 e02(할당)와 e04(배치)를 먼저 하세요. 느린 코드를 8배 빠르게 하는 것보다, 먼저 100배 빠르게 만든 뒤 병렬화하는 편이 훨씬 큽니다.
정리 — .iter() → .par_iter() 여섯 글자면 전 코어를 씁니다. 작은 데이터에는 손해이니 임계값을 측정으로 정하고, 결과는 collect/sum/fold+reduce 로 모으세요(Mutex 로 모으면 이득이 사라집니다). 스케일이 안 나오면 메모리 대역폭을 먼저 의심하세요.
.iter() 를 .par_iter() 로 — 그 뒤에서 일어나는 일 데이터 100만 개 반씩 재귀적으로 쪼갭니다 앞 50만 뒤 50만 코어마다 큐 하나 — 먼저 끝난 스레드가 훔쳐 갑니다 코어 1 ■■■■ 아직 바쁨 코어 2 ■■ 곧 빔 코어 3 비었습니다 놀지 않고 코어 1 의 큐 뒤에서 가져옵니다 데이터 경쟁은 컴파일러가 이미 막았습니다 효과가 없는 두 경우 — 데이터가 작으면 쪼개는 비용이 더 크고, 단순 합산처럼 메모리 대역폭이 한계인 작업은 코어를 늘려도 그대로입니다 순서대로 e02(할당 줄이기) → e04(메모리 배치) 를 먼저 손보세요 — 병렬화는 그 다음입니다
Send·Sync 를 컴파일러가 확인해 주므로 병렬화가 무섭지 않은 일이 됩니다.
PERF 06

Send · Sync — 데이터 경쟁이 빌드 에러

다른 언어에서 동시성 버그는 운영에서 가끔 터지는 재현 불가능한 문제였습니다. Rust 에서는 컴파일 에러입니다. 그 장치가 Send 와 Sync 두 트레이트입니다 — 이 탭에서 가장 중요한 개념입니다.

Send 와 Sync — 데이터 경쟁이 빌드 에러가 되는 장치 Send — 다른 스레드로 옮겨도 되는 값 소유권을 통째로 넘길 수 있다는 표시입니다 thread::spawn 에 넘기려면 이것이 필요합니다 대부분의 타입이 자동으로 만족합니다 Sync — 참조로 동시에 봐도 되는 값 여러 스레드가 &T 로 함께 읽어도 안전 &T 가 Send 인 것과 같은 뜻입니다 Mutex 는 안이 Send 면 Sync 가 됩니다 그래서 이런 일이 벌어집니다 Rc<T> 를 스레드로 넘기면 참조 개수를 원자적으로 세지 않습니다 `Rc` cannot be sent between threads — 컴파일 에러 Arc<T> 로 바꾸면 개수를 원자적으로 세므로 안전합니다 그대로 통과합니다 — 고칠 곳을 컴파일러가 짚어 줍니다
C++ 에서는 런타임에 가끔 터지던 것이 러스트에서는 빌드에서 막힙니다 — 이것이 실질적 차이입니다.

두 트레이트의 뜻 ★

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 에서는 빌드가 안 됩니다.
이것이 “fearless concurrency”의 실체입니다 — 용감해서가 아니라 컴파일러가 검사해 주기 때문에 겁내지 않아도 됩니다. rayon 을 마음 놓고 쓸 수 있는 이유이기도 합니다(e05).

이렇게도 쓸 수 있다 — 에러를 읽고 푸는 법 🟡 중급

자주 만나는 에러와 처방 ★

① `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 주석에 근거를 남기세요.
PERF 07

스레드 · 채널 · 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 는 느리다”는 오해입니다 — 경합이 없으면 수십 나노초로 매우 쌉니다. 문제는 경합이므로, 락프리로 가기 전에 범위 축소 · 샤딩 · 지역 누적을 먼저 시도하세요.
정리 — 데이터 병렬은 rayon(e05), 작업 전달은 채널, 공유 상태는 Mutex, 카운터는 Atomic 입니다. 스코프 스레드를 쓰면 지역 변수를 빌릴 수 있어 훨씬 편하고, 락 범위를 좁히고 샤딩하는 것이 경합 해결의 기본입니다. 데드락은 컴파일러가 못 막으니 락 순서 규칙을 문서화하세요.
PERF 08

async — 언제 쓰고 언제 쓰지 않나

async 는 기다림이 많을 때만 이득입니다. 계산이 많으면 오히려 손해이고, 코드도 복잡해집니다. 네이티브 모듈에서는 대개 필요 없습니다 — 그럼에도 알아야 하는 이유와, 필요할 때 쓰는 법을 봅니다.

스레드와 async — 언제 async 가 필요한가 스레드 (std::thread · rayon) 하나당 기본 스택 8MB 가상 · 실제 수십 KB 문맥 전환을 OS 가 합니다 코드가 평범합니다 — 함수 색깔 문제가 없습니다 CPU 계산 병렬화에는 이쪽이 정답입니다 수천 개를 넘기면 부담이 커집니다 async (tokio) 대기 중에는 스택을 차지하지 않습니다 전환을 런타임이 사용자 공간에서 처리 동시 연결 수만 개를 감당합니다 async 가 코드 전체로 번집니다 에러 메시지와 디버깅이 어렵습니다 async 안에서 절대 하면 안 되는 것 std::thread::sleep · 동기 파일 IO · 무거운 계산 — 워커 스레드를 통째로 막아 다른 태스크가 전부 멈춥니다 피할 수 없다면 tokio::task::spawn_blocking 으로 별도 풀에 넘기세요
기준은 단순합니다 — 기다림이 많으면 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,
}
취소 안전성은 async 에서 가장 미묘한 주제입니다. 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)이 더 단순한 경우도 많습니다
네이티브 모듈에 async 를 넣기 전에 한 번 더 물어보세요 — “동시에 기다리는 것이 수백 개인가?” 아니라면 스레드 풀이 훨씬 단순하고 디버깅하기 쉽습니다.

한 단계 더 — 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)을 처음부터 넣어 두세요
정리 — async 는 동시 대기가 수백 개 이상일 때만 이득입니다. CPU 작업은 rayon(e05), 파일 몇 개는 스레드(e07)로 충분합니다. 쓴다면 블로킹 호출 금지 · spawn_blocking 분리 · 락을 await 너머로 들지 않기 세 가지를 지키고, 취소 안전성을 항상 의식하세요.
PERF 09

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
기본 빌드는 SSE2 만 씁니다(x86-64 기준) — AVX2 를 켜면 4배 폭이 8배로 늘어납니다. 배포용이라면 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 코드가 오히려 느린 경우도 있습니다
//    (짧은 배열 · 대역폭 한계 · 다운클럭)
AVX-512 는 다운클럭을 유발할 수 있습니다 — 일부 CPU 에서 AVX-512 명령을 쓰면 전체 클럭이 내려가 오히려 느려지는 경우가 보고됐습니다. 반드시 실제 워크로드로 측정하세요.

한 단계 더 — 벡터화의 안쪽 🧬 전문가

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)를 먼저 찾아보세요.
PERF 10

파이썬 · 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 를 고르는 실제 이유는 성능이 아닙니다(🔬 d12) — 같은 성능을 더 안전하게, 더 쉬운 빌드·배포로 얻는 것입니다. C++ 대비 성능 이득을 기대하고 옮기면 실망합니다.

이렇게도 쓸 수 있다 — 직접 재는 절차 🟡 중급

공정한 비교 만들기 ★

□ 같은 알고리즘인가
    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)
이 실험을 저장소에 두세요 — README 에 수치를 적을 때 재현 방법을 함께 제공하면 신뢰가 다릅니다. 환경이 바뀌면 수치도 바뀐다는 것을 명시하세요.

실무에선 이렇게 — 기대치를 관리한다 🔴 실무

보고할 때의 원칙 ★

❌ "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 바운드인가
    → 언어를 바꿔도 그대로입니다

★ 대부분 위 여덟 개 중 하나입니다.
  순서대로 확인하면 원인을 찾을 수 있습니다.
“기대만큼 안 빨라졌다”가 곧 실패는 아닙니다 — 메모리 사용량 · p99 지연 · 병렬 확장성에서 이득이 있는지 보세요. 그것도 없다면 솔직하게 되돌리는 것이 옳은 판단입니다(🔬 d12).

한 단계 더 — 수치를 해석하는 법 🧬 전문가

평균이 아니라 분포를 보세요

★ 파이썬 → 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 의 폴백)

★ 마지막으로
  성능 수치는 '지금 이 환경에서' 의 값입니다.
  과장하지 않고 조건을 밝히는 것이
  장기적으로 신뢰를 만듭니다.
이 가이드가 반복해 온 원칙입니다 — 측정하고 결정하라, 과장하지 마라, 되돌릴 수 있게 하라. 성능 작업에서 이 셋을 지키면 신뢰를 잃지 않습니다.
정리 — 파이썬 순수 루프 대비 10~200배, numpy 대비 0.5~5배, C++ 대비 거의 동등입니다 — 무엇과 비교하느냐가 전부입니다. 엔드투엔드로 재고 환경을 밝히세요. 기대에 못 미치면 --release → 경계 횟수 → 변환 비용 → clone 순으로 확인하고, p99 와 메모리도 함께 보세요. 다음은 🔬 전문가 · unsafe 탭입니다.
unsafe · UB · Pin · Ordering · Miri · 매크로 · ABI

안전을 잠시
내려놓는 자리

unsafe 는 “아무거나 해도 된다”가 아니라 다섯 가지를 추가로 허용하는 것입니다. 대신 컴파일러가 검사하던 불변식을 사람이 지켜야 합니다. 이 탭은 FFI 를 다루다 보면 반드시 만나는 영역이고, 안전한 껍데기로 감싸 남에게 내놓는 법까지 봅니다.

12SECTIONS
5unsafe 가 푸는 것
MiriUB 검출
4깊이 층
DEEP 01

unsafe 가 실제로 푸는 것 — 다섯 가지

가장 큰 오해는 “unsafe 를 쓰면 검사가 꺼진다”는 것입니다. 타입 검사도, 빌림 검사도 그대로 돕니다. 딱 다섯 가지 동작이 추가로 허용될 뿐입니다.

unsafe 가 푸는 것은 딱 다섯 가지뿐입니다 ① 원시 포인터(*const T · *mut T)를 역참조하기 ② unsafe 로 표시된 함수 부르기 — FFI 함수가 전부 여기에 해당합니다 ③ 가변 정적 변수(static mut) 읽고 쓰기 ④ unsafe 트레이트 구현하기 — Send · Sync 를 손으로 붙일 때 ⑤ 공용체(union)의 필드 읽기 그대로 동작하는 것 소유권 · 빌림 검사 · 타입 검사 · 수명 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개월 뒤의 내가, 그리고 리뷰어가
//   "이게 왜 안전한가" 를 재확인할 수 있어야 합니다.
//   "안전함" 한 줄은 주석이 아닙니다.
SAFETY 주석을 쓸 수 없다면 그 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 가 건전합니다.
DEEP 02

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)
UB 는 “그 줄에서 터진다”가 아닙니다 — 프로그램 전체에 대한 가정이 깨져 전혀 다른 곳에서 이상하게 동작합니다. 디버깅이 극도로 어려운 이유입니다.

이렇게도 쓸 수 있다 — C++ 와 비교 🟡 중급

Rust 가 UB 를 줄인 지점 ★

상황C++Rust
정수 오버플로UB랩(정의됨)
배열 범위 초과UB패닉
널 역참조UB불가능
해제 후 사용UB컴파일 에러
이터레이터 무효화UB컴파일 에러
데이터 경쟁UB컴파일 에러
초기화 안 된 변수UB컴파일 에러
strict aliasingUB안전 코드엔 없음
★ 핵심 차이
  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 가 있다면)
Miri 와 살균기는 역할이 다릅니다 — Miri 는 Rust 규칙(aliasing·정렬·초기화)을, ASan/TSan 은 실제 메모리 오류(C 코드 포함)를 봅니다. FFI 를 쓴다면 둘 다 필요합니다.

한 단계 더 — 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)
정리 — UB 는 목록으로 정리돼 있고, 그 밖은 안전합니다. 정수 오버플로는 UB 가 아니고(C++ 와 다름), 메모리 누수도 안전합니다. FFI 에서는 널·길이 0·정렬·수명·패닉 다섯 가지를 항상 확인하고, Miri(Rust 규칙) + ASan(실제 메모리) 둘 다 돌리세요.
DEEP 03

원시 포인터 — 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 를 쓰세요.
x86 은 정렬 안 된 접근을 허용하지만 ARM 일부에서는 크래시합니다. “내 PC 에서는 되는데 라즈베리파이·애플 실리콘에서 죽는다”의 원인이 정렬 위반인 경우가 많습니다.

이렇게도 쓸 수 있다 — 참조와 포인터 사이 🟡 중급

변환할 때의 규칙

// 참조 → 포인터: 항상 안전
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 진입점을 두드립니다
FFI 진입점에 퍼징을 붙이면 효과가 큽니다 — 잘못된 길이·널·깨진 UTF-8 같은 입력을 자동으로 만들어 두드립니다. 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)
정리 — 원시 포인터는 널·정렬·유효성·수명·aliasing 다섯 가지를 사람이 책임집니다. FFI 에서는 널 검사 → 길이 0 처리 → 정렬 확인 → 안전한 슬라이스로 넘기는 헬퍼 함수를 만들어 재사용하세요. 정렬 위반은 ARM 에서 크래시하고, 정수 왕복은 프로버넌스를 흐립니다.
DEEP 04

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 이 유효한 타입" 을 검증 ★
정리 — 초기화되지 않은 메모리를 값으로 읽으면 UB 이고, MaybeUninit 만이 합법적인 방법입니다. 다만 실무에서 필요한 경우는 드뭅니다 — vec![0; n] 은 생각보다 싸고, 정당한 용도는 FFI 의 out 파라미터 정도입니다. set_len 은 가장 위험한 함수이니 조건을 반드시 확인하세요.
초기화되지 않은 메모리는 '아직 값이 아닙니다' ① MaybeUninit::uninit() ? ? ? ? 메모리는 잡혔지만 값으로 읽으면 UB ② ptr 로 완전히 써 넣기 7 · "ok" · 0x1f as_mut_ptr().write(v) 모든 필드를 빠짐없이 ③ assume_init() 진짜 T 여기서부터 평범한 값 Drop 도 정상 동작 assume_init 은 검사가 아니라 '내가 보증한다'는 선언입니다 한 필드라도 안 썼는데 이 줄을 지나면, 그 순간부터 프로그램 전체가 정의되지 않은 상태가 됩니다 — 크래시는 한참 뒤에 납니다 정당한 용도는 좁습니다 — FFI 의 out 파라미터(C 함수가 채워 주는 구조체)와 큰 버퍼의 memset 회피 정도입니다 그마저도 리눅스는 0 페이지를 지연 할당하므로, memset 이 정말 병목인지 프로파일러로 확인한 뒤에 손대세요
"쓰레기 값이 들어 있다"가 아니라 "값이라는 개념 자체가 아직 없다"가 정확한 이해입니다.
DEEP 05

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 런타임·스트림 라이브러리를 만들 때 주로 필요합니다.
정리 — Rust 의 값은 언제든 memcpy 로 이동하므로 자기 참조가 깨집니다. Pin 이 그것을 타입으로 막지만, 대부분의 타입은 Unpin 이라 상관없습니다. 실무에서는 async 에서만 만나고, Box::pin·pin!·pin-project-lite 로 해결됩니다. 자기 참조가 필요하면 인덱스 · 라이프타임 분리 · self_cell 을 먼저 검토하세요.
Pin — 움직이면 깨지는 값에 붙이는 약속 옮기기 전 data: "안녕" ptr → data 포인터가 자기 안을 가리킵니다 이동 다른 주소로 옮겨진 뒤 data: "안녕" ptr → 옛 주소 포인터만 그대로 — 이미 남의 메모리입니다 Pin<P> 가 하는 일 "이 값은 해제될 때까지 이 자리에 있다" 를 타입으로 보장합니다 — 옮기는 코드가 컴파일되지 않습니다 async 블록이 대표적입니다 — await 를 넘는 참조가 자기 안을 가리키므로 실행 전에 고정합니다 직접 쓸 일은 드뭅니다 — Box::pin · tokio::pin! 을 통해 만나는 것이 보통입니다 대부분의 타입은 Unpin 이라 이 제약과 무관합니다 — 마음 놓고 옮겨도 됩니다
Pin 은 새 개념이라기보다 "움직이면 안 된다" 를 컴파일러에 알리는 방법입니다.
DEEP 06

메모리 모델 · 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 가 필요한 경우
  · "데이터를 다 썼다" 를 플래그로 알릴 때
  · 락 · 큐 · 참조 카운터의 해제 시점 ★
x86 에서는 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 ...
loom 은 모든 인터리빙을 시도하므로 “가끔 터지는” 동시성 버그를 결정적으로 재현합니다. 락 프리 코드를 쓴다면 loom 테스트 없이는 배포하지 마세요.

한 단계 더 — 하드웨어와의 관계 🧬 전문가

어떤 명령으로 컴파일되나

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 을 쓰는 것이 실무적입니다.
Ordering — 무엇까지 보장할 것인가 Relaxed — 값만 맞습니다 증가와 감소가 유실되지 않는 것만 보장합니다. 다른 변수와의 순서는 보장하지 않습니다 쓰는 곳 — 통계 카운터처럼 순서가 상관없는 값 Acquire / Release — 짝으로 씁니다 Release 로 쓰기 전에 한 일들이, Acquire 로 읽은 쪽에서 반드시 보입니다 쓰는 곳 — 잠금 · 준비 완료 깃발 · 한 번만 초기화되는 값 SeqCst — 모든 스레드가 같은 순서를 봅니다 가장 강하고 가장 비쌉니다. 여러 원자 변수 사이의 순서까지 따져야 할 때 필요합니다 확신이 없다면 여기서 시작하세요 — 맞게 만든 다음에 필요하면 낮춥니다 이 영역의 버그는 재현이 거의 안 됩니다 — 몇 달에 한 번 이상한 값이 보이는 형태로 나타납니다 직접 만들기 전에 Mutex · channel · OnceLock 으로 되는지 먼저 보세요. 대부분 됩니다
원자적 연산에서 어려운 것은 원자성이 아니라 다른 쓰기가 언제 보이는가입니다.
DEEP 07

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        # 누수 무시
느립니다 — 해석 실행이라 보통 수십~수백 배 느립니다. 그래서 작고 빠른 단위 테스트를 따로 두고 그것만 Miri 로 돌리는 것이 실무적입니다.

무엇을 잡나 ★

✅ 검출하는 것
  · 유효하지 않은 메모리 접근 (범위 밖 · 해제 후)
  · 정렬 위반 (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 로 검증
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 하나로 충분합니다.
파이썬·Node 확장에서 특히 유용합니다 — FFI 경계 자체는 Miri 가 못 보지만, 포인터를 슬라이스로 바꾸는 헬퍼(🔗 f02 · d03)는 순수 Rust 이므로 Miri 로 완전히 검증할 수 있습니다.
정리 — unsafe 를 쓴다면 cargo miri test 를 CI 에 넣으세요. aliasing · 정렬 · 초기화 · 누수 · 데이터 경쟁을 자동으로 잡아 줍니다. 실행된 코드만 검사하므로 unsafe 함수마다 작은 테스트를 두고, FFI 로 넘어간 C 코드는 ASan 으로 따로 봐야 합니다.
UB 가 있는 코드도 테스트는 대개 통과합니다 unsafe 블록 from_raw_parts(p, len) 정렬이 어긋난 포인터 cargo test — 통과합니다 그 하드웨어에서 우연히 동작했을 뿐, 최적화 설정이 바뀌면 깨집니다 cargo miri test — 그 줄에서 멈춰 세웁니다 해석기로 실행하며 포인터의 유래·정렬·초기화·누수·데이터 경쟁을 함께 추적합니다 Miri 의 한계 — 지나간 길만 봅니다 정적 분석이 아니라 실행입니다. 테스트가 밟지 않은 분기의 UB 는 보이지 않습니다 → 널 · 길이 0 · 해제 직후 같은 경계 입력을 테스트로 그래서 이렇게 씁니다 느립니다(수십 배) — 전체 테스트를 돌리지 말고 unsafe 를 감싼 작은 단위 테스트만 겨냥합니다 → CI 에 nightly 잡 하나로 넣어 두면 충분합니다 FFI 로 넘어간 C 코드 안은 Miri 가 보지 못합니다 — 그쪽은 ASan · Valgrind 의 몫입니다
unsafe 를 쓴다면 Miri 를 CI 에 넣지 않을 이유가 없습니다 — 잡히는 것이 전부 진짜 버그입니다.
DEEP 08

안전 추상화 설계 — 불변식을 문서로

이 탭에서 가장 중요한 섹션입니다. 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)
“이 타입은 패닉하지 않습니다”를 문서에 적을 수 있다면 그 래퍼는 잘 설계된 것입니다 — FFI 경계(🔗 f09)와 파이썬·Node 바인딩(🐍 y08)에서 그대로 이득이 됩니다.

한 단계 더 — 건전성을 증명하는 방법 🧬 전문가

스스로에게 던질 질문

① 이 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 이고, 타입으로 표현할 수 있으면(뉴타입 · 타입 상태) 문서보다 확실합니다.
DEEP 09

매크로 — 코드를 만드는 코드

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 로 무엇을 만드는지 볼 수 있습니다. 만들기 전에 제네릭·트레이트로 되는지 먼저 확인하세요 — 빌드 시간과 유지보수 비용이 큽니다.
매크로 — 컴파일 도중에 코드를 만들어 넣습니다 소스 #[derive(Serialize)] 매크로 확장 코드를 생성해 끼워 넣음 확장된 코드 impl Serialize for .. 평소대로 타입 검사 선언 매크로 — macro_rules! 패턴을 보고 코드 조각을 끼워 넣습니다 vec! · println! 이 이것입니다 간단한 반복 제거에 알맞습니다 절차 매크로 — 별도 크레이트 문법 구조를 입력으로 받아 자유롭게 생성 derive · 속성 · 함수형 세 가지 형태 serde · tokio::main 이 이것입니다 cargo expand 로 실제 생성된 코드를 볼 수 있습니다 — 에러 메시지가 이해 안 될 때 가장 빠른 길입니다 매크로는 빌드 시간을 늘리고 읽기를 어렵게 합니다 — 함수나 제네릭으로 되면 그쪽이 낫습니다 쓸 만한 자리 — 같은 impl 을 타입 스무 개에 반복해서 써야 할 때
매크로를 배우는 목적은 만드는 것보다 남이 만든 것이 무엇을 펼치는지 읽는 것에 가깝습니다.
DEEP 10

컴파일 과정 — MIR · 단형화 · LLVM

소스가 실행 파일이 되기까지 컴파일러 안에서 무슨 일이 일어나는지 봅니다. 빌드가 왜 느린지(🛠️ b10), 왜 이터레이터가 빠른지(⚡ e03), 왜 제네릭에 비용이 없는지(r10)가 여기서 설명됩니다.

러스트 컴파일이 느린 이유는 어느 단계에 있나 1 파싱 · 매크로 전개 파생 매크로와 절차 매크로가 여기서 코드를 만들어 냅니다 — serde 를 많이 쓰면 여기가 늘어납니다 2 타입 검사 · 트레이트 해결 제네릭 경계를 만족하는지 찾습니다. 트레이트가 복잡하면 여기서 시간이 걸립니다 3 MIR — 빌림 검사가 도는 자리 소유권과 수명 검사가 여기서 끝납니다. 실행 파일에는 흔적이 남지 않습니다 4 단형화 — 제네릭을 타입마다 복제 Vec<u8> 과 Vec<String> 이 별개 코드가 됩니다. 코드량이 여기서 크게 불어납니다 5 LLVM 최적화 · 코드 생성 불어난 코드를 전부 최적화합니다 — 릴리즈 빌드 시간의 대부분이 여기입니다
빌드를 줄이려면 4·5단계를 건드려야 합니다 — 제네릭을 dyn 으로 바꾸거나 크레이트를 쪼개는 것이 효과적입니다.

단계 ★

소스 (.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 두 개면 빌드 시간과 성능 문제의 대부분을 진단할 수 있습니다.
정리 — MIR 에서 빌림 검사와 Drop 삽입이 일어나고, 단형화로 제네릭이 타입별로 복제된 뒤 LLVM 이 최적화합니다. 빌드가 느리면 -Ztime-passes 로 어느 단계인지 확인하고, LLVM 시간이 크면 단형화를 줄이세요(비제네릭 안쪽 함수 · dyn). &mut 의 배타성이 noalias 로 전달되는 것이 Rust 성능의 숨은 이점입니다.
DEEP 11

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)
#   "다른 환경에서 로드 실패" 를 배포 전에 잡습니다
정적 링크가 기본이라 배포가 오히려 쉽습니다 — C++ 처럼 “런타임 재배포 패키지를 깔아 주세요”가 없습니다. 파일 하나만 있으면 동작하는 것이 Rust 배포의 큰 장점입니다.

한 단계 더 — 앞으로의 변화 🧬 전문가

논의되고 있는 것들

★ #[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 로"
  이 한 줄이 네이티브 모듈 설계의 핵심 원칙입니다.
정리 — Rust 는 ABI 를 보장하지 않습니다 — 자기 자신과도. 그래서 정적 링크가 기본이고, 경계는 반드시 #[repr(C)] + extern "C" 입니다. 확장 모듈은 통째로 빌드되므로 대개 문제가 없지만, 여러 확장이 같은 크레이트를 각자 링크하면 전역 상태가 공유되지 않는다는 점은 기억하세요.
DEEP 12

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 (🟣)
단순 CLIGo · 셸
★ 특히 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)
정리 — 팀에 읽을 사람이 둘 이상인가가 가장 중요한 질문입니다. 범위를 좁게(함수 하나) 시작하고, 폴백을 남기고, 측정해서 보고하세요. 기대만 못하면 솔직하게 되돌리는 것이 옳은 판단이고, 되돌릴 수 있게 만들어 두었다면 그것은 실패가 아니라 선택입니다. 여기까지 읽으셨다면 이제 직접 만들어 볼 차례입니다. 🦀
serde · clap · tokio · axum · sqlx · tracing

언어를 배운 다음
실제로
무언가를 만들기

러스트는 표준 라이브러리가 일부러 작습니다 — JSON 도, HTTP 도, 로깅도 없습니다. 그래서 실무는 어떤 크레이트를 고르느냐가 절반입니다. 이 탭은 거의 모든 러스트 프로젝트가 결국 쓰게 되는 것들을, 고르는 기준과 함께 정리합니다.

12SECTIONS
40+실전 크레이트
2완성 예제 (CLI · 웹)
100%복붙해서 도는 코드
TOOL 01

생태계 지도 — 무엇을 고를 것인가

crates.io 에는 비슷한 크레이트가 열 개씩 있습니다. 대부분의 경우 사실상 표준이 정해져 있고, 그것을 아는 것만으로 선택 시간이 사라집니다.

분야별로 사실상 정해져 있는 선택 — 고민할 필요가 없는 것들 직렬화 · serde JSON · TOML 전부 이 위에 에러 · thiserror + anyhow 라이브러리 / 앱 비동기 런타임 · tokio 점유율이 압도적입니다 웹 서버 · axum actix-web 도 여전히 강함 HTTP 클라이언트 · reqwest 사실상 유일 DB · sqlx · sea-orm 쿼리형 / ORM 형 로깅 · 관측 · tracing log 보다 이쪽이 표준 CLI · clap derive 방식이 압도적으로 편합니다 병렬 · rayon iter 를 par_iter 로 바꾸면 끝 시간 · chrono · time 둘 다 널리 쓰입니다 테스트 보조 · 벤치는 criterion · 속성 기반은 proptest · 목은 mockall · 스냅샷은 insta 가 자주 쓰입니다
고민이 되면 의존하는 크레이트가 많은 쪽을 고르세요 — 생태계가 붙어 있다는 뜻입니다.

크레이트를 고를 때 실제로 보는 것

항목확인하는 법
최근 릴리스1년 넘게 없으면 이유를 확인하세요 (완성된 것일 수도 있습니다)
역의존성 수crates.io 의 "Dependents" — 가장 믿을 만한 신호입니다
버전이 0.x 인가0.x 는 마이너 올림도 깨질 수 있습니다
unsafe 사용량cargo geiger 로 확인
취약점cargo audit — CI 에 넣으세요
라이선스cargo deny 로 허용 목록을 강제

표준 라이브러리에 없어서 놀라는 것들

  • 난수 — rand 크레이트가 필요합니다
  • 정규식 — regex
  • 날짜와 시간 — std::time 은 시각 계산만, 달력은 chrono
  • JSON · HTTP · 로깅 — 전부 외부
  • 비동기 런타임 — async 문법은 언어에, 실행기는 크레이트에
표준을 작게 유지하는 것은 의도된 선택입니다 — 표준에 들어가면 영원히 못 바꾸기 때문입니다.
TOOL 02

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 로 받으면 빠뜨린 경우를 컴파일러가 잡아 줍니다
이벤트 · 메시지 · 상태를 태그 enum 으로 정의하면 새 종류를 추가할 때 고쳐야 할 자리를 컴파일러가 전부 알려 줍니다.

성능과 함정

  • 제로카피 역직렬화 — &'a str 로 받으면 문자열을 복사하지 않습니다(입력이 살아 있어야 함)
  • serde_json::Value 는 느립니다 — 구조를 아는 데이터는 구조체로 받으세요
  • u64 정밀도 — 자바스크립트는 53비트까지만 정확합니다. 큰 ID 는 문자열로 내보내세요
  • 빌드 시간 — derive 매크로가 많으면 컴파일이 느려집니다
  • 스키마 진화 — 필드를 지우기 전에 #[serde(default)] 로 한 버전 살려 두세요
타입은 하나, 형식은 갈아 끼웁니다 — 가운데에 공통 모델이 있습니다 내 구조체 #[derive(Serialize, Deserialize)] struct Order { .. } 컴파일 때 생성 serde 데이터 모델 맵 · 시퀀스 · 정수 문자열 · 옵션 · enum 형식과 무관한 중간 표현 serde_json — API · 설정 serde_yaml · toml bincode · msgpack 필드 이름을 코드와 따로 둡니다 #[serde(rename_all = "camelCase")] #[serde(default)] · #[serde(skip)] JSON 은 camelCase, Rust 는 snake_case 를 지킵니다 스키마가 바뀌면 컴파일러가 알려 줍니다 필드를 하나 지우면 그 필드를 쓰던 자리가 전부 에러로 드러납니다 — 런타임에 KeyError 로 만나지 않습니다 단, u64 는 JSON 정밀도를 넘습니다 — 문자열로 보내세요
형식을 바꾸는 일이 의존성 한 줄 교체로 끝납니다 — 타입은 손대지 않습니다.
TOOL 03

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 은 쓰는 사람이 컴파일해야 해서 느립니다.

TOOL 04

tokio — async 런타임 실전

러스트의 async 는 문법만 언어에 있고 실행기는 없습니다. 그 실행기가 tokio 이고, 서버 코드의 사실상 전부가 이 위에서 돕니다.

tokio 런타임 — 태스크는 스레드보다 훨씬 가볍습니다 태스크 수만 개 각각 수백 바이트 스케줄러 준비된 것만 골라 실행 워커 스레드 1 (코어당 하나) 워커 스레드 2 워커 스레드 3 · 4 ... 일이 몰리면 — 훔쳐 옵니다(work stealing) 한 워커가 비면 남의 큐에서 가져옵니다 부하가 자동으로 고르게 퍼집니다 블로킹은 반드시 격리 spawn_blocking 이 별도 풀로 보냅니다(기본 512개) 워커를 막는 코드 — 증상이 아주 특이합니다 "평소엔 멀쩡한데 특정 요청이 오면 서버 전체가 몇 초 멈춘다" — 거의 항상 async 안의 블로킹 호출입니다
태스크는 스레드가 아닙니다 — 수만 개를 만들어도 되지만, 하나가 막히면 워커 하나가 통째로 멈춥니다.

가장 자주 쓰는 도구 다섯

// ① 동시에 여러 개 — 전부 기다림
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 한 것을 아무도 안 기다리면 패닉이 조용히 묻힙니다
런타임을 하나만 쓰세요. tokio 위에 async-std 용 크레이트를 섞으면 런타임이 두 개 뜨거나 패닉합니다. 크레이트를 고를 때 "tokio 호환"인지부터 확인하는 것이 러스트 비동기 생태계의 현실입니다.
TOOL 05

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 등)는 인자 목록의 마지막에 와야 합니다.
요청 한 건이 지나가는 길 — 네 단계뿐입니다 요청 hyper 가 받습니다 tower 미들웨어 층 로깅 · CORS · 타임아웃 · 인증 라우터 — 핸들러 찾기 경로와 메서드로 매칭 추출기 → 핸들러 인자 Path · Query · Json · State 를 꺼냅니다 IntoResponse → 응답 반환값이 자동으로 응답이 됩니다 에러도 같은 길로 내 에러 타입에 IntoResponse 를 구현하면 ? 연산자로 던진 실패가 그대로 응답이 됩니다 상태 코드 매핑을 한 곳에
매크로가 아니라 타입 시스템으로 조립되기 때문에, 잘못 쓰면 컴파일 단계에서 걸립니다.

에러 타입 하나로 응답을 통일

#[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() 는 아래에서 위로 감쌉니다. 나중에 붙인 것이 바깥쪽이 되므로, 로깅은 가장 마지막에 붙여야 모든 요청을 기록합니다.
TOOL 06

데이터베이스 — 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+1ORM 이 없으니 자동으로 생기지는 않지만, 루프 안 쿼리는 여전히 문제입니다
타입 매핑NUMERIC 은 rust_decimal, 시간은 chrono 기능을 켜야 합니다
트랜잭션 누락commit() 을 안 부르고 드롭하면 조용히 롤백됩니다 — 반환값을 꼭 확인
Drop 이 롤백을 해 준다는 점이 러스트의 이득입니다. 예외가 나든, ? 로 일찍 반환하든, 패닉이 나든 커밋하지 않은 트랜잭션은 반드시 롤백됩니다 — 잊을 수가 없습니다.
sqlx — 쿼리를 직접 쓰되, 틀리면 컴파일이 안 됩니다 query!("select id, nmae from users") 빌드 중 DB 에 물어봄 컬럼 이름 · 타입 · null 컴파일 에러: no column "nmae" 오타가 배포가 아니라 빌드에서 걸립니다 CI 에 DB 가 없어도 됩니다 cargo sqlx prepare 로 검증 결과를 .sqlx/ 에 저장하고 커밋해 두면, 오프라인에서도 같은 검사가 돌아갑니다 고르는 기준 — 지배적 ORM 이 없습니다 SQL 을 쓰겠다면 sqlx · 관계 매핑이 많으면 SeaORM 스키마를 코드로 관리하겠다면 Diesel 트랜잭션 — 커밋하지 않고 스코프를 벗어나면 Drop 이 롤백합니다 중간에 ? 로 빠져나가도 열린 트랜잭션이 남지 않습니다. 다른 언어에서 finally 를 빠뜨려 생기던 커넥션 누수가 구조적으로 사라집니다 풀 크기는 DB 최대 연결 수 ÷ 인스턴스 수로 잡습니다 — 크게 잡을수록 빠른 것이 아닙니다
쿼리를 감추지 않으면서 오타와 타입만 컴파일러가 봐 줍니다 — 이 조합이 Rust 쪽 주류입니다.
TOOL 07

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 의존이 사라져 배포가 쉬워집니다
외부 호출을 감싸는 네 겹 — 안쪽부터 쌓습니다 ① 타임아웃 — 없으면 나머지 셋이 전부 무의미합니다 연결 타임아웃과 전체 타임아웃을 따로 겁니다. 상대가 느려질 때 우리 쪽 스레드·태스크를 지킵니다 ② 재시도 + 지수 백오프 + 지터 일시적 오류만 재시도합니다. 지터가 없으면 모든 인스턴스가 동시에 다시 몰려 상대를 한 번 더 죽입니다 ③ 서킷 브레이커 실패율이 기준을 넘으면 잠시 호출 자체를 끊습니다 — 이미 죽은 서비스를 계속 두드리지 않습니다 ④ 폴백 캐시된 값 · 기본값 · 부분 응답. "이 기능만 잠깐 안 되는 것"과 "전체 장애"의 차이가 여기서 갈립니다
순서를 지키세요 — 타임아웃 없이 재시도를 붙이면 장애가 배로 증폭됩니다.
TOOL 08

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)
}
태스크가 워커 스레드를 옮겨 다녀도 span 이 따라갑니다. 스레드 로컬에 기대는 기존 로깅 방식이 async 에서 깨지는 문제를 구조적으로 해결한 것이 tracing 입니다.

로그에 담아야 할 것

  • 요청 ID · 추적 ID (분산 추적)
  • 사용자·주문 같은 식별자
  • 걸린 시간과 결과 코드
  • 재시도 횟수 · 폴백 발동 여부

절대 담으면 안 되는 것

  • 토큰 · 비밀번호 · API 키
  • 주민번호 · 카드번호 전체
  • 요청 본문 통째로 (개인정보가 섞입니다)
  • #[instrument(skip(...))] 로 민감 인자를 빼세요

한 단계 더

  • tracing-opentelemetry — 분산 추적 연동
  • metrics · prometheus — 지표
  • tokio-console — 태스크가 막힌 곳을 실시간으로
  • 패닉도 로그로 — std::panic::set_hook
TOOL 09

설정 · 환경변수 · 시크릿

설정을 코드에 박아 두면 환경마다 빌드를 새로 해야 합니다. 러스트에는 스프링의 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 양쪽에 걸어 두세요.
TOOL 10

테스트 전략 — 언어가 도와주는 만큼

러스트는 타입 시스템이 많은 버그를 미리 잡아 줍니다. 그래서 테스트의 초점이 "널일까 아닐까"가 아니라 로직 · 경계 · 통합으로 옮겨 갑니다.

테스트 네 층 — 아래층이 두꺼울수록 위층이 편해집니다 ④ 속성 기반 · 퍼징 — proptest · cargo-fuzz 입력을 자동으로 만들어 반례를 찾습니다. 파서 · 인코더 · 자료구조에 특히 효과가 큽니다 ③ 통합 테스트 — tests/ 디렉터리 공개 API 만 씁니다. 실제 DB 는 testcontainers 로 띄우세요 — 목보다 훨씬 믿을 만합니다 ② 단위 테스트 — #[cfg(test)] mod tests 같은 파일 안에 둡니다. private 함수도 테스트할 수 있고, 릴리즈 빌드에는 안 들어갑니다 ① 컴파일러가 이미 잡는 것 — 공짜입니다 널 · 데이터 경쟁 · 해제 후 사용 · 빠뜨린 match 갈래 — 다른 언어에서 테스트로 잡던 것들입니다 그래서 러스트에서는 "값이 널인지" 같은 테스트를 쓸 일이 없습니다 — 그 시간을 로직 검증에 씁니다.
타입으로 표현할 수 있는 것은 테스트가 아니라 타입으로 막는 것이 러스트다운 방식입니다.

기본 형태

// 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 가 실제로 컴파일하고 실행합니다 — 문서가 낡아서 안 도는 예제가 되는 일이 구조적으로 없어집니다.
TOOL 11

배포 — 바이너리 하나로 끝내기

러스트의 가장 실용적인 장점 중 하나가 런타임이 필요 없다는 것입니다. 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
musl 은 기본 할당자가 느립니다. 할당이 잦은 서버라면 jemallocator 나 mimalloc 을 함께 쓰세요 — 그냥 바꾸면 처리량이 눈에 띄게 떨어질 수 있습니다.
항목내용
헬스체크 엔드포인트/health(살아 있나) 와 /ready(트래픽 받을 준비) 를 분리하세요
정상 종료SIGTERM 을 받으면 새 요청은 거절하고 진행 중인 것만 끝냅니다 — axum::serve(...).with_graceful_shutdown()
패닉 처리기본은 그 태스크만 죽습니다. 프로세스를 죽이고 재시작하는 편이 안전한 경우가 많습니다
메모리 상한컨테이너 제한을 걸고 실제 사용량을 지표로 보세요 — 러스트도 누수가 없지는 않습니다
바이너리 크기strip = true · opt-level = "z" · panic = "abort" 로 크게 줄어듭니다
크로스 컴파일cross 또는 cargo-zigbuild — 맥에서 리눅스 바이너리를 만들 때 편합니다
CI 캐시Swatinem/rust-cache 액션 — 빌드 시간이 절반 이하가 됩니다
TOOL 12

목적별 크레이트 찾기

"이걸 하려면 뭘 써야 하지?"의 답을 모았습니다. 아래 표에서 하려는 일을 찾아 그대로 쓰세요.

하려는 일크레이트메모
JSON · YAML · TOMLserde + serde_json 등사실상 유일한 선택
에러 타입 정의 (라이브러리)thiserrorenum 에 표기만 붙입니다
에러 문맥 쌓기 (앱)anyhow라이브러리에는 쓰지 마세요
커맨드라인 파싱clapderive 기능을 켜세요
비동기 런타임tokio기능 플래그를 필요한 것만
웹 서버axum · actix-webtokio 를 쓰면 axum
HTTP 클라이언트reqwestrustls 기능 권장
데이터베이스sqlx · sea-ormSQL 직접 / ORM
Redisredis · fred비동기 지원 확인
KafkardkafkaC 라이브러리 래퍼 — 빌드 의존성 주의
로깅 · 추적tracing + tracing-subscriber운영은 JSON 포맷
지표(메트릭)metrics · prometheus익스포터를 함께
병렬 처리rayonCPU 작업 전용 — IO 에는 쓰지 마세요
날짜와 시간chrono · timeDB 연동 기능 플래그 확인
정규식regex선형 시간 보장 — 역참조는 없습니다
난수rand암호용은 rand::rngs::OsRng
해시 · 암호sha2 · argon2 · ring비밀번호는 반드시 argon2·bcrypt
JWTjsonwebtoken만료·서명 검증을 끄지 마세요
UUIDuuidv4 또는 정렬 가능한 v7
임의 정밀도 · 통화rust_decimal돈 계산에 f64 금지
CSV · 엑셀csv · calamine · rust_xlsxwriter읽기 / 쓰기가 다른 크레이트
이미지 처리image큰 이미지는 메모리 확인
템플릿askama · minijinja컴파일 검증 / 런타임
테스트 실행기cargo-nextest기본 실행기보다 훨씬 빠릅니다
벤치마크criterion통계와 회귀 감지 포함
의존성 점검cargo-audit · cargo-denyCI 에 넣으세요
바이너리 배포cargo-dist설치 스크립트까지 만들어 줍니다
WASM 빌드wasm-bindgen · wasm-packNode 애드온 탭 참고
파이썬 확장pyo3 + maturin파이썬 모듈 탭 참고
C 라이브러리 바인딩bindgen · cbindgen가져오기 / 내보내기

크레이트를 늘리기 전에 물어볼 것

  • 표준 라이브러리로 열 줄이면 되는 일은 아닌가
  • 이미 들어와 있는 크레이트가 같은 기능을 제공하지는 않나
  • 이것 하나 때문에 의존성이 몇 개 늘어나나 (cargo tree)
  • 빌드 시간과 바이너리 크기에 얼마나 영향이 있나
  • 버전 0.x 라면 깨지는 변경을 감당할 수 있나

가이드를 끝까지 읽으셨습니다

러스트를 실무에 쓰는 이유는 결국 두 가지로 모입니다 — 런타임 없이 배포되는 단일 바이너리, 그리고 메모리·동시성 버그가 빌드에서 막힌다는 것. 배우는 비용은 분명히 있지만, 그 비용은 처음 한 번이고 얻는 것은 서비스가 사는 내내 돌아옵니다. 막힐 때는 Ctrl K 검색으로 필요한 섹션을 바로 찾아 쓰세요.

rustup · MSVC 링커 · cargo · rust-analyzer · 2026-08 기준 Rust 1.89 안정판

아무것도 없는 PC 를
Rust 개발 기계로
바꾸는 두 시간

이 탭은 운영체제만 깔린 빈 PC 를 가정합니다. 컴파일러도, 링커도, 에디터도 없습니다. 그 상태에서 어디서 무엇을 받고, 무엇을 체크하고, PATH 에 무엇이 들어가고, 확인 명령은 무엇이며, 실패하면 어떤 화면이 뜨는지를 한 줄씩 따라갑니다. Rust 는 설치가 쉬운 언어로 알려져 있지만 그건 리눅스 이야기이고, 윈도우에서는 링커 하나 때문에 첫날을 통째로 날리는 사람이 매년 나옵니다. 이 탭을 마치면 새 노트북을 받아도 30분 안에 cargo run 이 도는 상태를 만들 수 있습니다.

11SECTIONS
1.89기준 Rust
0사전 지식
3OS 전부
SETUP 01

시작과 끝은 rustup 하나입니다 — 다른 방법은 쓰지 마세요

파이썬은 설치 방법이 열 가지쯤 됩니다(공식 설치본 · 배포판 패키지 · pyenv · conda · uv…). 그래서 “내 파이썬이 어디 있는지” 를 평생 헷갈립니다. Rust 는 그 문제를 처음부터 없앴습니다 — 공식 설치 방법은 rustup 하나이고, 컴파일러 · 패키지 매니저 · 문서 · 포매터가 전부 그 안에 들어 있습니다. 이 섹션에서 받는 곳 · 설치 화면 · 확인 명령까지 끝냅니다.

rustup 하나가 관리하는 것 — 툴체인 · 컴포넌트 · 타깃 rustup 설치 · 전환 · 삭제를 전부 맡는 관리자 ~/.rustup 에 진짜 툴체인이 들어갑니다 ~/.cargo/bin 에는 얇은 대리 실행 파일 (shim) 만 놓입니다 rustc 를 부르면 shim 이 현재 툴체인으로 넘깁니다 stable ★ 기본 1.89.0-x86_64-pc- windows-msvc beta 6주 뒤 stable 이 될 것 nightly 매일 · 불안정 기능용 1.75.0 같은 고정 옛 프로젝트 재현용 툴체인에 든 것 rustc · cargo · rustdoc rust-std (표준 라이브러리) component clippy · rustfmt rust-analyzer · rust-src target wasm32 · aarch64 등 다른 CPU·OS 용 표준lib 확인은 세 줄 — rustc --version · cargo --version · rustup show. 셋 다 답하면 설치는 성공입니다
rustup 은 버전 관리자입니다 — 파이썬의 pyenv, Node 의 nvm 자리를 공식 도구가 차지하고 있습니다.

어디서 받나 — 주소는 하나입니다

공식 주소   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 은 전부 사용자 홈 폴더에만 씁니다 (~/.rustup · ~/.cargo). 회사 PC 에서 관리자 계정을 못 받아도 Rust 는 깔립니다. 반대로 sudo 를 붙여 설치하면 나중에 권한 문제가 생깁니다(s11).

이렇게도 쓸 수 있다 — 채널이라는 개념: stable · beta · nightly 🟡 중급

6주마다 한 칸씩 밀려 내려옵니다

채널정체언제 쓰나
stable6주마다 나오는 정식판. 하위 호환을 깨지 않겠다는 약속이 붙습니다기본값. 99%
beta6주 뒤 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 은 계획에 없습니다 ★
자바·파이썬과 다른 점 — 자바는 LTS(21 · 25) 를 고르는 문화가 있고 파이썬은 3.13 · 3.14 사이 문법 차이를 신경 써야 합니다. Rust 에는 LTS 가 없습니다. “항상 최신 stable” 이 공식 권장이고, 하위 호환이 깨지지 않으므로 그래도 됩니다. 묶어 둘 필요가 있으면 채널이 아니라 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
왜 이 구조를 아는 것이 중요한가 — “왜 내 PC 에서만 버전이 다르지?” 의 답이 거의 항상 이 세 곳 중 하나이기 때문입니다: ① rustup default ② 폴더 override ③ rust-toolchain.toml. 순서대로 우선순위가 거꾸로입니다 (③ 이 가장 셈). 자세한 우선순위 표는 s08 에 있습니다.
정리 — rustup.rs 에서 받아 실행하고, cargo new hello && cargo run 이 “Hello, world!” 를 찍을 때까지가 설치입니다. rustc --version 만 보고 끝내지 마세요 — 그건 링커를 확인하지 않은 상태입니다. 채널은 고민할 것 없이 stable, 버전 고정이 필요하면 채널이 아니라 rust-toolchain.toml 을 씁니다.
SETUP 02

Windows — 링커 하나 때문에 첫날이 날아갑니다

Rust 는 윈도우에서 마이크로소프트의 링커(link.exe) 를 빌려 씁니다. 직접 만들지 않고 빌려 쓰는 이유는 윈도우 시스템 라이브러리(kernel32 · user32 …)와 정확히 같은 규약으로 이어 붙여야 하기 때문입니다. 그래서 “Rust 만 깔면 되는 거 아니었나” 라는 기대가 여기서 깨집니다. 이 섹션은 무엇을 · 어디서 · 얼마나 받아야 하는지를 화면 단위로 짚습니다.

cargo build 가 실제로 거치는 다섯 칸 — 넷째 칸만 Rust 것이 아닙니다 src/main.rs 내가 쓴 코드 rustc 타입검사 · 최적화 *.o · *.rlib 기계어 조각 link.exe Rust 것이 아님 hello.exe 실행 파일 없으면 만나는 화면 error: linker `link.exe` not found note: the msvc targets depend on the msvc linker = note: program not found 컴파일은 끝났는데 실행 파일만 안 나온 상태입니다 Visual Studio Build Tools 워크로드: C++ 를 사용한 데스크톱 개발 → MSVC v143 컴파일러 툴셋 + link.exe → Windows SDK (헤더 · 시스템 라이브러리) 설치 3~7 GB · 관리자 권한 필요 대안 — GNU 툴체인(x86_64-pc-windows-gnu)은 MinGW 링커를 씁니다. rustup 이 함께 받지만 MSVC 만 지원하는 크레이트가 있습니다
넷째 칸이 비어 있으면 컴파일은 성공하고 링크만 실패합니다 — 그래서 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 설명을 반드시 읽으세요)
1번을 고르면 Visual Studio “Community” 가 깔립니다 — 개인은 무료지만 기업 사용은 라이선스 위반이 될 수 있습니다(직원 250명 초과 또는 매출 기준). 회사 PC 라면 2번을 고르고 Build Tools 를 받으세요. Build Tools 는 빌드 용도로 조직 규모와 무관하게 무료입니다.

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 입니다.

④ 설치 후 재부팅은 보통 필요 없지만, 터미널은 반드시 새로 여세요
이미 Visual Studio 가 깔린 PC 라면 새로 받을 필요가 없습니다. 시작 메뉴에서 Visual Studio Installer 를 열고 수정 → C++ 를 사용한 데스크톱 개발 체크만 추가하면 됩니다.

이렇게도 쓸 수 있다 — 명령 한 줄로 끝내기 (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-msvcx86_64-pc-windows-gnu
링커MS link.exeMinGW-w64 ld
추가 설치Build Tools 필요rustup 이 같이 받음
C 라이브러리MSVCRT (윈도우 표준)MinGW 런타임 DLL
디버깅PDB · Visual Studio · WinDbgGDB
C/C++ 크레이트거의 전부 지원일부 빌드 실패
배포 파일단일 exeDLL 을 같이 줘야 할 때가 있음
# 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" 확인
결론 — 특별한 이유가 없으면 msvc 를 쓰세요. GNU 는 “Build Tools 를 못 깔 때의 탈출구”지 기본 선택이 아닙니다. openssl-sys· rdkafka 처럼 C 코드를 끼고 있는 크레이트가 gnu 에서만 깨지는 일이 흔합니다.

실무에선 이렇게 — PATH 는 어디에 무엇이 들어가는가 🔴 실무

cargo 를 쳤을 때 — 쉘이 PATH 를 앞에서부터 훑습니다 cargo ⏎ cmd · PowerShell ① 현재 폴더 (cmd 만 · PowerShell 은 안 봅니다) ② C:\Windows\system32 ③ C:\Program Files\Git\cmd ④ %USERPROFILE%\.cargo\bin ★ 여기서 찾음 ⑤ 그 뒤 항목은 보지 않습니다 실행 cargo.exe 확인 — where cargo (cmd) · Get-Command cargo (PowerShell) · which cargo (bash) PATH 를 고쳤으면 새 터미널을 여세요 — 이미 열린 창은 옛 PATH 를 그대로 씁니다 rustup 이 자동으로 넣지만, 회사 정책으로 막히면 손으로 넣어야 합니다
앞에 있는 것이 이깁니다 — 옛 Rust 가 앞줄에 남아 있으면 새로 깔아도 옛 버전이 실행됩니다.

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
패키지 매니저로 Rust 를 깔지 마세요 — choco · scoop · apt · brew 전부 마찬가지입니다. 버전이 낡고, rustup 의 채널 전환이 안 되며, rustup component add 가 실패합니다. 공식 경로는 rustup 하나라는 원칙을 지키는 것이 결국 가장 빠릅니다.
정리 — 윈도우에서 Rust 설치는 사실상 “Build Tools 를 까는 일” 입니다. C++ 를 사용한 데스크톱 개발 워크로드 + Windows SDK 두 개만 체크하면 되고, PATH 에는 %USERPROFILE%\.cargo\bin 하나만 들어가면 됩니다. 확인은 언제나 cargo new → cargo run 까지입니다.
SETUP 03

macOS · Linux — 한 줄로 끝나지만 두 가지를 더 봐야 합니다

맥과 리눅스에서는 설치 명령이 정말 한 줄입니다. 다만 C 링커와 몇몇 시스템 라이브러리는 Rust 가 주지 않습니다 — 윈도우의 link.exe 문제와 정확히 같은 종류의 문제가 이름만 바꿔 나타납니다. 그리고 배포판 패키지로 Rust 를 깔면 나중에 반드시 후회합니다. 그 두 가지를 여기서 미리 막습니다.

설치 한 줄이 끝난 뒤 실제로 바뀐 네 가지 1 rustup-init 임시로 받아 실행 끝나면 자기 삭제 2 ~/.rustup 툴체인 실제 본체 약 1.5 GB 3 ~/.cargo/bin shim 이 놓입니다 PATH 는 여기 하나 4 셸 rc 수정 .bashrc · .zshrc .profile 에 한 줄 지금 열린 창에는 반영되지 않습니다 — 방법은 둘 source ~/.cargo/env 지금 이 창에 즉시 반영됩니다 새 터미널 열기 rc 파일을 다시 읽습니다 Rust 가 아니라 운영체제가 줘야 하는 것 C 링커 · 헤더 mac: xcode-select --install / 리눅스: build-essential C 라이브러리 pkg-config · libssl-dev — 없으면 빌드 중간에 멈춥니다
설치가 끝나도 지금 창은 모릅니다 — 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       (인텔 맥)
macOS 업그레이드 뒤에 갑자기 링크가 깨지면 CLT 가 옛 SDK 를 가리키는 경우입니다. 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
딱 하나의 예외 — 배포판 자체를 만드는 사람(패키지 메인테이너)과 재현 가능 빌드가 법적 요구인 환경(정부·금융 폐쇄망 이미지)에서는 시스템 패키지를 씁니다. 그 외 개발자는 전부 rustup 입니다. 도커 이미지 안에서도 마찬가지로, 공식 rust:1.89 이미지는 내부적으로 rustup 을 씁니다.

실무에선 이렇게 — 자주 걸리는 시스템 라이브러리 🔴 실무

이 표에 있는 것만 미리 깔면 90% 가 막힙니다

막히는 크레이트필요한 것Debian/Ubuntu 패키지
openssl-sysOpenSSL 헤더libssl-dev + pkg-config
*-sys 전반라이브러리 위치 탐색기pkg-config
libsqlite3-sysSQLite (또는 번들 기능)libsqlite3-dev
pq-sys (Postgres)libpqlibpq-dev
bindgen 계열libclanglibclang-dev
rdkafkaC 컴파일러 · cmakebuild-essential cmake
GUI (egui · tauri)X11/Wayland · GTKlibgtk-3-dev 등
링크 자체C 링커build-essential
OpenSSL 을 아예 피하는 길 — 많은 크레이트가 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                               # 셸 다시 시작
비대화형 셸에서 안 잡히는 문제 — CI 스크립트나 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)
musl 로 정적 링크 — 배포 서버의 glibc 버전이 낮아 실행이 안 될 때가 있습니다. 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 패키지 문제입니다.
SETUP 04

cargo — 빌드 · 의존성 · 테스트 · 문서가 한 명령에

파이썬은 pip + venv + pytest + black + build 를 따로 배웁니다. C++ 는 CMake 와 vcpkg 를 따로 배웁니다. Rust 는 전부 cargo 하나이고, 그래서 배울 명령이 열 개 남짓입니다. 이 섹션에서 그 열 개와 Cargo.toml 의 모든 칸, 그리고 왜 첫 빌드만 오래 걸리는지를 구조로 이해합니다.

cargo build 한 줄이 실제로 하는 여섯 칸 ① Cargo.toml 무엇을 쓸지 선언 버전은 범위로 적습니다 ② Cargo.lock 정확히 어떤 버전인지 없으면 여기서 만듭니다 ③ ~/.cargo/registry 소스 캐시 — 없으면 crates.io 에서 내려받음 ④ rustc 병렬 컴파일 크레이트 단위로 나눠서 여기가 가장 오래 걸립니다 ⑤ 링크 link.exe (윈도우) cc · ld (mac · 리눅스) ⑥ target/debug/hello 실행 파일 완성 cargo run 은 여기에 실행까지 cargo check — ④ 를 타입 검사까지만 하고 코드 생성·링크를 건너뜁니다. 3~5배 빠릅니다 cargo build — 여섯 칸 전부. 두 번째부터는 바뀐 크레이트만 다시 합니다(증분) cargo build --release — 최적화를 켜고 target/release/ 로. 성능 측정은 반드시 이쪽
첫 빌드가 느린 이유는 ③ 다운로드 + ④ 의존성 전체 컴파일 때문입니다 — 두 번째부터는 내 코드만 다시 합니다.

실제로 쓰는 명령은 이만큼입니다

# ── 만들기 ────────────────────────────────
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 로 빼세요. 디스크가 부족할 때 가장 먼저 지울 곳도 여기입니다.

실무에선 이렇게 — 워크스페이스로 크레이트를 쪼갠다 🔴 실무

워크스페이스 — 여러 크레이트가 Cargo.lock 과 target/ 을 공유합니다 myproj/Cargo.toml [workspace] members = ["crates/*"] resolver = "3" Cargo.lock — 하나 루트에만 생깁니다 target/ — 하나 빌드 결과를 전부 공유 crates/cli bin — clap 명령행 crates/pyext cdylib — pyo3 (s10) crates/wasm cdylib — 브라우저용 crates/core lib — 순수 로직 cargo build -p cli · cargo test --workspace · 버전은 [workspace.dependencies] 로 한곳에서 관리합니다 core 를 한 번 빌드하면 cli · pyext · wasm 이 그 결과를 그대로 재사용합니다
파이썬 확장 · CLI · WASM 을 같은 로직 위에 얹을 때의 기본형입니다 — 로직은 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"]
언제 쪼개나 — 크레이트를 나누면 병렬 컴파일 단위가 늘어 빌드가 빨라지고, “파이썬 확장과 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_DIRtarget/ 위치를 공유 폴더로
CARGO_HOME~/.cargo 위치 변경
RUSTFLAGSrustc 에 넘길 플래그(바꾸면 전체 재빌드)
CARGO_BUILD_JOBS동시 컴파일 수 (기본 = 코어 수)
CARGO_TERM_COLORalways — CI 로그에 색 유지
RUST_BACKTRACE1 / full — 패닉 스택
CARGO_NET_OFFLINEtrue — 네트워크 금지
RUSTFLAGS 를 셸 프로필에 박아 두지 마세요 — 값이 바뀔 때마다 모든 크레이트가 처음부터 다시 빌드됩니다. 영구 설정이 필요하면 ~/.cargo/config.toml 의 [build] rustflags 를 쓰는 편이 낫습니다(s09).
정리 — 외울 명령은 new · check · build · run · test · add · doc · fmt · clippy 아홉 개면 충분합니다. Cargo.toml 은 범위, Cargo.lock 은 확정 — 서비스라면 lock 을 커밋하고 CI 에서 --locked 를 쓰세요. 프로젝트가 커지면 워크스페이스로 쪼개는 것이 빌드 속도와 구조 양쪽에 답입니다.
SETUP 05

에디터 — rust-analyzer 하나가 체감의 90%

Rust 는 에디터 지원의 질이 학습 속도를 그대로 결정하는 언어입니다. 타입을 안 적어도 되지만 타입이 무엇인지는 알아야 하고, 소유권 에러는 줄 위에서 즉시 보여야 의미가 있습니다. 그 일을 하는 것이 rust-analyzer 입니다. 이 섹션은 VS Code 설정 파일 실제 내용 · 디버깅 launch.json · 대안 에디터까지 다룹니다.

에디터는 Rust 를 모릅니다 — 아는 프로세스에게 물어봅니다 VS Code 화면 · 편집만 담당 언어 지식은 0 입니다 LSP · JSON-RPC rust-analyzer 타입 추론 · 자동완성 별도 프로세스로 상주 cargo check 진단(diagnostics) 저장할 때마다 실행 디버깅 — 실행 파일에 붙는 별도 확장이 필요합니다 .vscode/launch.json F5 로 빌드 + 실행 중단점 · 변수 보기 cargo 통합 설정 가능 CodeLLDB — mac · 리눅스 · 윈도우 전부에서 동작 ★ 기본 선택 C/C++ (Microsoft) — 윈도우 msvc 툴체인에서 PDB 디버깅 확장 네 개면 충분합니다 — rust-analyzer · CodeLLDB · Even Better TOML · crates ★ 옛 확장 rust-lang.rust 는 지원이 끝났습니다. 깔려 있으면 반드시 제거하세요
빨간 줄은 에디터가 아니라 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 (자주 필요합니다)
자동완성이 갑자기 죽었다면 90%는 프로젝트가 컴파일되지 않는 상태입니다. rust-analyzer 는 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 와 한 창에서무료
RustRoverJetBrains. 디버거·리팩터링·프로파일러가 통합. 설정이 필요 없음비상업 무료
Zed매우 빠름(Rust 로 작성). rust-analyzer 내장무료
Helix터미널. LSP 내장, 설정 파일 거의 없음무료
Neovimrustaceanvim 플러그인. 손에 익었다면 최고무료
IntelliJ IDEARust 플러그인은 종료됨 → RustRover 로 이관—
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 를 지정합니다
rust-analyzer 를 어디서 받나 — VS Code 확장은 자기 것을 자동으로 내려받습니다. 그 밖의 에디터는 rustup component add rust-analyzer 로 설치한 ~/.cargo/bin/rust-analyzer 를 씁니다. 둘이 버전이 달라도 문제는 없지만, 이상 동작이 있으면 버전을 맞춰 보세요.
정리 — VS Code + rust-analyzer + CodeLLDB 세 개가 사실상 표준입니다. 설정에서 check.command 를 clippy 로, 저장 시 포맷을 켜고, 인레이 힌트를 켜 두는 것이 초반 학습 속도를 가장 크게 바꿉니다. 자동완성이 죽으면 먼저 cargo check 를 돌려 프로젝트가 컴파일되는지부터 확인하세요.
SETUP 06

필수 도구 — clippy · rustfmt 부터 감사 도구까지

Rust 생태계의 도구는 대부분 cargo- 로 시작하는 서브커맨드 형태입니다. 설치하면 cargo 이름 으로 그냥 불립니다. 다만 깔리는 위치가 두 종류여서, 그걸 모르면 “깔았는데 없다”가 반복됩니다. 여기서 어디에 · 어떻게 · 왜를 정리하고 실무에서 실제로 쓰는 여덟 개를 한 줄 사용법과 함께 익힙니다.

도구는 두 곳에 깔립니다 — 어느 쪽인지 알면 문제가 안 생깁니다 ① rustup component add clippy · rustfmt · rust-src rust-analyzer · llvm-tools 설치 위치 ~/.rustup/toolchains/…/bin 이미 빌드된 것을 받습니다 — 수 초 rustup update 때 함께 갱신됩니다 툴체인을 바꾸면 다시 깔아야 합니다 ② cargo install cargo-nextest · cargo-watch cargo-expand · audit · deny 설치 위치 ~/.cargo/bin 소스를 직접 빌드합니다 — 수 분 --locked 를 붙이세요 (재현성) rustup 과 무관 — 따로 갱신합니다 cargo-binstall — 소스 빌드 대신 미리 만들어진 바이너리를 받습니다 cargo binstall cargo-nextest → 빌드 없이 수 초. GitHub 릴리스에서 내려받습니다 확인 — cargo --list 로 쓸 수 있는 서브커맨드 전부 보기 · ls ~/.cargo/bin
“깔았는데 없다” 의 원인은 대부분 ① 로 깔아야 할 것을 ② 로 찾거나 그 반대입니다.

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 중 성가신 것만 끄기
clippy 는 “문법 검사”가 아니라 “관용구 교사”입니다 — 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
“왜 내 rustfmt.toml 이 무시되지?” — 위 표시처럼 일부 옵션은 nightly 전용입니다. stable 로 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” 한 줄이면 끝나는 것이 목표입니다.
정리 — clippy · rustfmt 는 rustup component add, 나머지 cargo-* 도구는 cargo install --locked 입니다. 설치가 느리면 cargo-binstall 을 먼저 깔면 됩니다. 매일 쓰는 것은 cargo clippy · cargo fmt · cargo nextest run · cargo watch -x check 넷이고, audit · deny 는 CI 에 두세요.
SETUP 07

크레이트는 어디서 오나 — 캐시 · 미러 · 프록시 · 오프라인

집에서는 cargo build 가 알아서 다 받아 옵니다. 그런데 회사 망에 들어가는 순간 같은 명령이 failed to get response 로 죽습니다. 프록시 · 사내 CA 인증서 · 차단된 도메인 · 폐쇄망… 이 섹션은 ~/.cargo/config.toml 파일 하나로 그 상황들을 전부 다루는 법입니다. 개인 PC 만 쓰더라도 “캐시가 어디 있고 얼마나 큰지”는 알아 두는 편이 좋습니다.

크레이트가 오는 길 — ~/.cargo/config.toml 이 방향을 정합니다 cargo build serde 1.0.219 가 필요하다고 판단 ① 로컬 캐시 ~/.cargo/registry 있으면 여기서 끝 — 네트워크를 안 씁니다 없으면 ② config.toml 이 결정 [source] · [registries] [http] proxy · cainfo [net] offline · git-fetch crates.io (기본) index.crates.io — 목록 static.crates.io — 파일 사내 미러 · 레지스트리 Artifactory · Nexus 사내 전용 크레이트도 vendor/ 폴더 소스를 저장소에 함께 네트워크 0 — 폐쇄망 확인 — cargo build -v 로 어디서 받는지 보기 · CARGO_NET_OFFLINE=true 로 차단 시험 사내망이면 [source.crates-io] replace-with 한 줄로 전부 미러를 보게 만들 수 있습니다
캐시가 있으면 네트워크를 아예 안 탑니다 — CI 가 느리다면 ~/.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 인덱스 — 예전에는 crates.io 의 전체 목록 git 저장소를 통째로 받았습니다 (첫 빌드가 몇 분씩 걸리던 이유). 지금은 HTTP 로 필요한 항목만 가져오는 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" → 프록시 · 방화벽
git 의존성은 cargo vendor 로 잘 안 묶일 때가 있습니다 — 서브모듈이나 LFS 를 쓰는 저장소가 특히 그렇습니다. 폐쇄망 배포가 목적이라면 git 의존성을 사내 레지스트리에 미리 publish 해 두는 편이 결국 편합니다.
정리 — 크레이트는 ① 로컬 캐시 → ② config.toml 이 가리키는 곳 순으로 옵니다. 사내망이라면 [source.crates-io] replace-with 한 줄로 전부 미러를 보게 만들고, 프록시·사내 CA 는 [http] proxy·cainfo 로 지정합니다. 폐쇄망은 cargo vendor + --offline --locked 가 정답이고, 인증서 검증을 끄는 우회는 절대 하지 마세요.
SETUP 08

툴체인 · 타깃 — “왜 내 PC 에서만 다르지” 를 없애는 법

Rust 는 컴파일러가 여러 개 깔려 있는 것이 정상인 언어입니다. stable 로 개발하다 nightly 로 매크로를 펼쳐 보고, wasm 을 빌드하고, 서버용 musl 을 만듭니다. 그래서 “지금 이 폴더에서 어떤 rustc 가 쓰이는가” 를 판단하는 규칙이 있고, 그 규칙을 알면 버전 문제의 90%가 사라집니다. 타깃 추가와 크로스 컴파일도 여기서 함께 봅니다.

이 폴더에서 어떤 rustc 가 쓰이나 — 위에서부터 먼저 이깁니다 ① cargo +nightly build — 이번 한 번만 (가장 셈) ② RUSTUP_TOOLCHAIN 환경 변수 — CI 에서 자주 씁니다 ③ rust-toolchain.toml — 저장소에 커밋. 팀 전체가 같은 버전 ★ ④ rustup override set — 이 폴더에만, 내 PC 에만 ⑤ rustup default — 전역 기본값 (가장 약함) 확인 명령 rustup show rustup show active-toolchain rustup toolchain list -v rustup which rustc rustup override list rustup target list --installed rustup component list --installed 이 일곱 줄이면 "왜 이 버전이지?" 가 즉시 풀립니다 rustup target add — 그 CPU·OS 용 표준 라이브러리를 받습니다 (컴파일러는 하나 그대로) cargo build --target wasm32-unknown-unknown → target/wasm32-unknown-unknown/ 에 결과가 나옵니다
③ 이 유일하게 저장소에 남는 것입니다 — 나머지는 내 PC 에서만 유효합니다.

툴체인 다루기 — 명령 전부

# ── 설치 · 갱신 · 제거
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
라이브러리 vs 서비스 — 라이브러리를 만든다면 channel = "stable" 로 두고 Cargo.toml 의 rust-version(MSRV) 으로 최소 요구만 밝히는 편이 낫습니다. 정확한 버전을 못 박으면 사용자에게 그 버전을 강요하게 됩니다. 반대로 배포되는 서비스는 못 박는 편이 안전합니다.

이렇게도 쓸 수 있다 — 타깃 추가와 크로스 컴파일 🟡 중급

자주 쓰는 타깃 이름

타깃쓰는 곳
wasm32-unknown-unknown브라우저 (wasm-bindgen)
wasm32-wasip1WASI 런타임 · 엣지
x86_64-unknown-linux-musl정적 링크 단일 바이너리 ★
aarch64-unknown-linux-gnuARM 서버 · 라즈베리파이
aarch64-apple-darwin애플 실리콘 맥
x86_64-pc-windows-msvc윈도우 기본
x86_64-pc-windows-gnuMinGW 링커 (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-analyzerVS 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
MSRV(최소 지원 rustc 버전) 를 CI 에 넣는 것이 라이브러리를 만들 때의 기본기입니다. Cargo.toml 의 rust-version 과 매트릭스의 첫 항목을 같게 두면, 실수로 새 문법을 쓰는 순간 CI 가 잡아 줍니다. 찾기 힘들면 cargo msrv find 로 자동 탐색할 수도 있습니다.
정리 — 컴파일러 선택은 +툴체인 → 환경 변수 → rust-toolchain.toml → override → default 순으로 이깁니다. 팀에서 공유해야 하는 것은 rust-toolchain.toml 하나뿐입니다. 다른 CPU·OS 용으로 빌드하려면 rustup target add + 그 타깃용 링커가 둘 다 필요하고, 링커 준비가 귀찮으면 cross 나 cargo-zigbuild 를 쓰세요.
SETUP 09

빌드가 느립니다 — 어디에 시간이 가는지 부터 봅니다

Rust 를 처음 쓰는 사람이 가장 자주 하는 불평이 “빌드가 느리다” 입니다. 그런데 느린 이유가 매번 다릅니다 — 첫 빌드는 의존성 컴파일, 두 번째부터는 링크, CI 는 캐시 없음, 윈도우는 백신 검사. 원인이 다르면 처방도 다릅니다. 이 섹션은 측정하는 법 → 링커 교체 → 캐시 → 프로파일 조정 순으로, 가장 효과가 큰 순서대로 정리합니다.

첫 빌드 vs 두 번째 빌드 — 시간이 어디로 가는가 첫 빌드 (cargo build 를 처음 돌릴 때) 받기 의존성 컴파일 — 여기가 대부분입니다 내 코드 링크 두 번째부터 (한 줄만 고쳤을 때) 캐시 적중 내 코드만 다시 링크 — 이제 여기가 병목입니다 ① 링커 교체 lld · mold · wild 링크만 2~10배 빨라짐 .cargo/config.toml 한 줄 ② sccache 컴파일 결과를 캐시 여러 프로젝트가 공유 CI 에서 특히 효과적 ③ 프로파일 조정 debug 정보 줄이기 codegen-units 올리기 의존성만 최적화 측정이 먼저 — cargo build --timings 로 어떤 크레이트가 오래 걸리는지 확인합니다 RUSTFLAGS 를 바꾸면 전체가 다시 빌드됩니다 — 이것저것 켜 보다 더 느려지는 흔한 원인
첫 빌드와 두 번째 빌드는 느린 이유가 다릅니다 — 처방도 달라야 합니다.

왜 첫 빌드가 그렇게 오래 걸리나

① 의존성을 "소스로" 받아 전부 직접 컴파일합니다
   파이썬의 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 check3~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
언제 효과가 있나 — 여러 프로젝트가 같은 의존성을 쓸 때와 CI 에서 매번 새 머신을 받을 때입니다. 혼자 프로젝트 하나만 쓴다면 cargo 의 증분 빌드로 이미 충분해서 체감이 거의 없을 수도 있습니다. 켠 뒤 --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 는 끄는 편
panicabort = 작고 빠름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           # 디버그 빌드가 눈에 띄게 빨라집니다
윈도우 사용자가 가장 먼저 해야 할 일 — Windows 보안 → 바이러스 및 위협 방지 → 제외 항목에 %USERPROFILE%\.cargo, %USERPROFILE%\.rustup, 그리고 프로젝트의 target 폴더를 추가하세요. 빌드는 수만 개의 작은 파일을 만들고 지우기 때문에, 실시간 검사가 붙으면 두 배 이상 느려지는 경우가 흔합니다.
정리 — 순서는 측정(--timings) → cargo check 습관 → 백신 예외 → 링커 교체 → 프로파일 조정 입니다. 첫 빌드가 느린 건 의존성을 소스로 컴파일하기 때문이라 정상이고, 두 번째부터 느리면 링크가 병목일 가능성이 큽니다. RUSTFLAGS 를 함부로 바꾸지 마세요 — 바꾸는 순간 전체 재빌드입니다.
SETUP 10

파이썬 · Node 와 붙일 준비 — maturin · napi-cli

이 가이드의 목적은 파이썬·Node 가 느린 함수를 Rust 로 갈아 끼우는 것입니다. 그러려면 Rust 만 깔려 있어서는 안 되고, 파이썬 개발 헤더 · 가상환경 · Node 툴체인이 함께 갖춰져야 합니다. 여기서는 문법이 아니라 환경만 다룹니다 — 실제 코드는 🐍 파이썬 모듈 탭과 🟨 Node · WASM 탭에 있습니다. 이 섹션을 마치면 두 탭의 첫 명령이 바로 도는 상태가 됩니다.

Rust 코드를 파이썬·Node 가 불러 쓸 파일로 만드는 준비물 파이썬 — pyo3 + maturin src/lib.rs #[pymodule] maturin 빌드 + 패키징 .pyd / .so 확장 모듈 import 파이썬에서 Node — napi-rs src/lib.rs #[napi] @napi-rs/cli 빌드 + 타입 생성 index.node 네이티브 애드온 require Node 에서 미리 갖춰야 하는 것 — 여기가 비면 첫 빌드에서 멈춥니다 파이썬 쪽 python3 --version · 개발 헤더(python3-dev) pip install maturin (가상환경 안에서) Cargo.toml 에 crate-type = ["cdylib"] Node 쪽 node --version (24 LTS) · npm npm i -g @napi-rs/cli 윈도우는 s02 의 MSVC 링커가 그대로 필요
두 경로 모두 C 링커까지 내려갑니다 — s02 · s03 을 건너뛰면 여기서 반드시 막힙니다.

파이썬 쪽 준비 — 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
abi3 — 한 번 빌드해 여러 파이썬 버전에서 쓰기 — 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 연동 환경 완성입니다 ★
윈도우에서 여기서 막히면 원인은 거의 s02 입니다 — .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
Node 네이티브 애드온 vs WASM — 서버(Node) 에서 최고 성능이 필요하면 napi-rs, 브라우저나 배포 편의(단일 파일·샌드박스) 가 중요하면 WASM 입니다. 환경 준비 부담은 WASM 쪽이 훨씬 가볍습니다 — C 링커가 필요 없습니다.

한 단계 더 — 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 --version1.8x
cargo run (새 프로젝트)Hello, world!
cargo clippy --version버전 출력
python3 -c "import sysconfig…"include 경로
maturin --version1.x
maturin develop + import숫자가 찍힘 ★
node -p "process.versions.napi"9 이상
napi --version버전 출력
rustup target list --installedwasm32 포함
# 한 번에 확인하는 스크립트 (리눅스 · 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 가 필요합니다.
SETUP 11

설치 문제 사전 — 증상 → 원인 → 해결

여기까지 왔는데 뭔가 안 된다면, 그 증상은 거의 확실히 남들도 겪은 것입니다. Rust 의 설치 문제는 종류가 많지 않고 원인이 대부분 네 가지 중 하나입니다 — PATH · 링커 · 시스템 라이브러리 · 네트워크. 이 섹션은 순서대로 좁혀 가는 진단 흐름과, 실제로 화면에 뜨는 에러 문장 그대로를 찾아볼 수 있는 표입니다. 막히면 여기부터 보세요.

설치 문제 진단 — 위에서부터 순서대로만 확인하세요 확인 순서 아니오면 여기로 ① rustc --version 이 답하나? PATH 에 ~/.cargo/bin 이 없거나 옛 창 → s01 확인표 · s02 PATH 절 ② cargo new + cargo run 이 되나? 링커가 없습니다 — link.exe · cc → s02(윈도우) · s03(mac · 리눅스) ③ cargo add serde 가 받아지나? 네트워크 · 프록시 · 사내 CA 인증서 → s07 config.toml 설정 ④ *-sys 크레이트가 빌드되나? 시스템 라이브러리·헤더가 부족합니다 → pkg-config · libssl-dev — s03 표 네 개를 모두 통과하면 환경 완성 — s10 의 maturin · napi 확인으로 넘어가세요
순서를 지키는 것이 핵심입니다 — ②를 건너뛰고 ④를 고치려 들면 시간만 씁니다.

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 foundmac: xcode-select --install · Ubuntu: apt install build-essential
cargo: command not found (설치 직후)source ~/.cargo/env 또는 새 터미널 (s03)
failed to run custom build command for openssl-sysapt install pkg-config libssl-dev · 또는 rustls 기능으로 전환
Python.h: No such file or directoryapt install python3-dev (s10)
xcrun: error: invalid active developer pathmacOS 업그레이드 후. 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 newerrustup update. 못 올리면 cargo add X@옛버전
error[E0554]: #![feature] may not be used on the stable release channelnightly 가 필요합니다 (s08)
the lock file needs to be updated but --locked was passedcargo 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 installedrustup toolchain install … 또는 rustup self update
error: no override and no default toolchain setrustup default stable
디스크 꽉 참cargo clean · cargo cache --autoclean (s09)
rust-analyzer 가 파일 잠금을 잡는 문제는 생각보다 자주 겪습니다. 에디터가 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
재설치는 마지막 수단입니다 — 실제로 재설치로 풀리는 문제는 드뭅니다. 대부분은 PATH · 링커 · 권한 셋 중 하나이고, 그 셋은 재설치해도 그대로 남습니다. ③ 의 “빈 프로젝트로 갈라내기”를 먼저 해 보세요.

질문할 때 함께 붙일 정보

# 이 다섯 줄의 출력이면 대부분의 질문에 충분합니다
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             에러 문장 그대로 검색

질문 형식
  ① 하려던 것 ② 실행한 명령 ③ 실제 출력(잘라내지 말 것) ④ 위 환경 정보
  ⑤ 이미 시도한 것 — 이게 있으면 답이 훨씬 빨리 옵니다
정리 — 설치 문제의 원인은 사실상 PATH · 링커 · 시스템 라이브러리 · 네트워크 넷뿐입니다. 순서는 ① 새 터미널 → ② rustup update → ③ 빈 프로젝트로 갈라내기 → ④ cargo build -vv 로 진짜 원인 보기. 재설치는 마지막이고, 물어볼 때는 rustc -vV 와 실제 출력 전문을 꼭 붙이세요.
Axum 0.8 · Tokio 1.x · tower · sqlx 0.8 · tonic 0.13 · OpenTelemetry · 2026 기준

요청 하나가
들어와서 나가기까지를
전부 손에 쥡니다

이 탭은 실전·생태계 탭(🧰 t04~t08)에서 맛만 본 것들을 실제 서비스 수준으로 끌어올립니다. 라우터가 핸들러를 찾는 원리, tower 레이어가 감싸는 순서, 커넥션 풀이 고갈되는 이유, JWT 를 추출기에서 검증하는 법, 부하 시험에서 p99 가 튀는 자리, 그리고 20MB 짜리 컨테이너 이미지로 배포하는 것까지를 순서대로 다룹니다. 마지막 w13 에서는 URL 단축 서비스 하나를 처음부터 끝까지 만듭니다 — Axum + sqlx(Postgres) + Redis + JWT + OpenAPI + Docker Compose 가 전부 들어갑니다. 이 탭을 마치면 “스프링 부트로 하던 일”을 Rust 로 그대로 옮길 수 있습니다.

13SECTIONS
Axum 0.8기준
13시각화
1완성 서비스
WEB 01

Rust 백엔드는 언제 이기나 — 그리고 언제 지나

“빠르다”는 말만으로는 팀을 설득할 수 없습니다. 실제로 돈이 되는 지점은 같은 트래픽을 받는 데 인스턴스가 몇 대 필요한가, 그리고 p99 지연이 GC 때문에 튀지 않는가 두 가지입니다. 이 절은 그 두 가지를 숫자와 사례로 확인하고, 프레임워크를 고르는 기준까지 정리합니다.

프레임워크 고르는 흐름 — 위에서부터 예·아니오로 내려갑니다 새 HTTP 서비스 시작 2026년 기본값은 Axum 0.8 ❶ 팀이 tokio 를 쓴다 reqwest · sqlx · tonic 을 이미 쓰는가 예 Axum 0.8 — 사실상 기본값 tower 레이어를 그대로 공유합니다. 매크로가 없어 에러가 읽힙니다 ❷ 벤치 수치가 우선 액터 모델 · 오래된 레퍼런스가 필요 actix-web 4.x TechEmpower 상위 단골. 자체 런타임 위에 얹혀 있습니다 ❸ 레일스처럼 통째로 스캐폴딩 · 배치 · 인증까지 한 번에 Loco — Axum 을 감싼 풀스택 생성기와 관례가 있습니다. 규모가 작을수록 이득이 큽니다 ❹ 매크로 라우팅 취향 데코레이터처럼 쓰고 싶다 Rocket 0.5 · Poem Poem 은 OpenAPI 가 내장. 생태계 크기는 위 둘보다 작습니다 넷 다 아니라면 고민하지 말고 Axum 으로 시작하세요 — 나중에 옮길 일이 가장 적습니다
고르는 기준은 성능이 아니라 내가 쓸 크레이트들이 어느 런타임에 붙어 있는가입니다.

돈이 되는 지점 — 인스턴스 수와 p99

아래는 “JSON 을 받아 DB 한 번 조회하고 JSON 을 돌려주는” 전형적인 API 를 같은 하드웨어에서 재었을 때 흔히 나오는 범위입니다. 정확한 숫자는 워크로드마다 다르므로 자릿수만 보세요.

AxumFastAPISpring Boot
상주 메모리(RSS)15~40MB80~200MB250~500MB
시작 시간10ms 안팎1~2초3~10초
처리량 (상대)기준 1.00.05~0.150.4~0.7
p99 지연평탄GIL 경합GC 스파이크
컨테이너 이미지20~40MB150~400MB200~400MB
같은 트래픽 인스턴스1대6~15대2~3대
실제 청구서에서 갈리는 것 — 처리량보다 메모리입니다. 클라우드 인스턴스는 대개 vCPU 와 메모리가 묶여 팔리는데, Rust 서비스는 메모리 상한을 128~256MB 로 잡아도 돕니다. 같은 노드에 파드를 3~5배 더 밀어 넣을 수 있다는 뜻입니다.

실제로 바꾼 곳들 — 무엇이 문제였나

사례바꾼 이유
Discord
Read States
Go 의 GC 가 2분마다 지연 스파이크를 만들었습니다. 캐시가 커질수록 스캔 비용이 늘어 튜닝으로는 못 잡았고, Rust 로 옮기자 스파이크 자체가 사라졌습니다
Cloudflare
Pingora
NGINX 를 대체한 프록시. 하루 1조 건대 요청을 처리하면서 CPU·메모리 사용량을 크게 줄였다고 공개했습니다
AWS
Firecracker · S3
마이크로VM 과 스토리지 노드. 메모리 안전성 + 예측 가능한 지연이 이유입니다
npm · Dropbox인증 서비스 · 파일 동기화 엔진을 부분 교체 — 핫패스만 옮기는 전략
공통점을 보세요 — 전부 “지연이 튀면 안 되는 핫패스”입니다. 관리자 페이지나 배치 잡을 Rust 로 다시 쓴 곳은 없습니다. 전체 이관이 아니라 한 서비스만 옮기는 것이 현실적인 시작입니다.

이렇게도 쓸 수 있다 — 다섯 프레임워크를 나란히 놓고 보기 🟡 중급

Axum 0.8actix-web 4Rocket 0.5PoemLoco
런타임tokio (그대로)actix-rt(tokio 기반)tokiotokiotokio
라우팅 표기메서드 호출매크로 + 메서드매크로매크로생성기
미들웨어tower 표준자체 TransformFairing자체tower
OpenAPIutoipa · aideutoipa외부내장내장
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 로" 는 목표가 아닙니다 — 이득이 없는 곳은 그대로 둡니다
가장 흔한 실패 — 첫 이관 대상으로 가장 복잡한 도메인을 고르는 것입니다. 첫 대상은 로직이 단순하고 트래픽이 많고 지연이 중요한 경로여야 합니다. 조회 API 하나가 이상적입니다.

팀에 도입할 때 실제로 드는 비용

항목현실적인 값
첫 생산성 회복경력 개발자 기준 3~6주 (소유권·수명이 대부분)
빌드 시간중간 규모 서버 풀빌드 2~6분, 증분 5~20초
CI 시간캐시 없으면 10분+ → Swatinem/rust-cache 로 2~3분
채용지원자 수는 적지만 지원자 질은 높은 편입니다
라이브러리 공백결제·메시징 SDK 가 없을 수 있습니다 — 먼저 확인
운영 부담줄어듭니다 — OOM·GC 튜닝·의존성 취약점이 크게 감소
도입 전 체크리스트 한 줄 — 우리가 쓰는 결제 게이트웨이 · 사내 인증 · 메시지 브로커 · APM 에이전트에 Rust 클라이언트가 있는지 먼저 확인하세요. 여기서 막히면 나머지 장점이 다 무의미해집니다(HTTP API 만 있으면 reqwest 로 직접 짜면 됩니다).
정리 — Rust 백엔드가 이기는 자리는 지연이 튀면 안 되고, 인스턴스 수가 곧 비용인 핫패스입니다. 프레임워크는 Axum 0.8 이 기본값이고, 이유는 성능이 아니라 tower 생태계를 통째로 재사용하기 때문입니다. 도입은 전면 이관이 아니라 가장 아픈 경로 하나부터, 원복 경로를 만들어 두고 시작하세요.
WEB 02

Axum 구조 완전 이해 — Router · Extractor · Layer 가 맞물리는 방식

실전 탭(🧰 t05)에서 “요청이 네 단계를 지난다”고만 봤습니다. 여기서는 그 네 단계가 각각 어떤 트레이트로 구현돼 있는지를 봅니다. 이걸 알면 “the trait Handler is not implemented” 같은 난해한 에러가 전부 읽히기 시작합니다 — Axum 을 쓰다 막히는 지점의 90%가 여기입니다.

요청 한 건 — 어떤 트레이트가 어디서 호출되는가 hyper 1.x Request<Incoming> tower Layer 스택 — 바깥에서 안쪽으로 Trace → RequestId → Cors → Compression → Timeout → 내 Layer Router — 경로 매칭 후 MethodRouter 선택 matchit 트리로 /users/{id} 를 찾고, GET·POST 별로 갈라집니다 핸들러 인자 = 추출기. 순서에 규칙이 있습니다 앞 인자들 — FromRequestParts 헤더 · 경로 · 쿼리 · State · 확장(Extension) 본문을 건드리지 않으므로 몇 개든 가능합니다 인증 검사 추출기를 여기에 둡니다 (w06) 마지막 인자 — FromRequest Json<T> · Form<T> · Bytes · String · Multipart 본문 스트림을 소비하므로 딱 하나만, 맨 뒤에만 순서를 어기면 Handler 미구현 에러가 납니다 Rejection → 즉시 응답 핸들러는 호출되지 않습니다 추출 성공 → 핸들러 호출 평범한 async fn 입니다 반환값 → IntoResponse → Response, 그리고 Layer 스택을 역순으로 빠져나감 (StatusCode, HeaderMap, Json<T>) 같은 튜플도 그대로 응답이 됩니다. 압축·로그는 나가는 길에 다시 걸립니다 핵심 — 라우팅·추출·응답이 전부 타입으로 표현되므로, 잘못 조립하면 실행이 아니라 컴파일에서 걸립니다
본문을 소비하는 추출기는 맨 마지막 인자 하나뿐 — 이 규칙 하나가 Axum 에러의 절반입니다.

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}")
}
Axum 0.8 부터는 #[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)
}
기본 본문 상한 2MB 를 모르고 배포하면 업로드가 413 으로 죽습니다. 반대로 상한을 풀어 두면 메모리 고갈 공격에 노출됩니다 — 업로드 경로에만 별도 라우터를 만들어 상한을 따로 주는 것이 정석입니다.
정리 — Axum 은 Router · Handler · Extractor · IntoResponse 가 전부 트레이트이고, tower 의 Service/Layer 위에 얹혀 있습니다. 규칙은 두 줄로 요약됩니다 — 본문 추출기는 마지막 인자 하나, .with_state() 는 맨 마지막 호출. 막히면 #[axum::debug_handler] 를 붙이세요.
WEB 03

프로젝트 골격 — 워크스페이스 · AppState · 설정 · 에러

main.rs 한 파일로 시작한 서버는 파일이 3,000줄이 되는 순간 손을 못 댑니다. Rust 는 크레이트 경계가 곧 컴파일 단위이자 의존 규칙이라, 폴더만 나누는 다른 언어보다 구조를 강제하기가 훨씬 쉽습니다 — 잘못된 방향으로 use 하면 아예 빌드가 안 됩니다. 덤으로 증분 빌드가 빨라집니다.

워크스페이스 — 의존 화살표가 전부 아래(domain)를 향합니다 bin/server — main.rs 설정 읽기 · 풀 생성 · 라우터 조립 · 실행 api 크레이트 — 바깥과 닿는 층 axum 핸들러 · 추출기 · 요청/응답 DTO · 라우터 OpenAPI 표기 · 인증 미들웨어 · 에러 → HTTP 매핑 HTTP 를 아는 코드는 전부 여기에만 있습니다 infra 크레이트 — 바깥에 나가는 층 sqlx 구현체 · redis · reqwest · S3 · 메일 domain 이 정의한 트레이트를 실제로 구현합니다 SQL 과 커넥션이 보이는 코드는 여기에만 domain 크레이트 — 의존성이 거의 없습니다 엔티티 · 값 객체 · 도메인 에러 · 순수 계산 로직 trait UserRepo · trait Clock · trait Mailer — 인터페이스만 선언 serde 정도만 씁니다. 그래서 컴파일도 테스트도 가장 빠릅니다 이 방향이 주는 실제 이득 Postgres 를 다른 것으로 바꿔도 domain 은 한 줄도 안 바뀝니다. 테스트는 트레이트에 가짜 구현을 끼워 DB 없이 돌립니다
폴더가 아니라 크레이트로 나누면 잘못된 의존이 컴파일 에러가 됩니다 — 규칙이 문서가 아니라 빌드가 됩니다.

워크스페이스 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)
DTO 와 엔티티를 분리하는 이유 — 하나로 쓰면 DB 컬럼을 바꾸는 순간 API 스펙이 깨집니다. Rust 는 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()))
}
에러 코드를 문자열 상수로 정하고 문서에 박아 두세요. 클라이언트가 HTTP 상태 코드가 아니라 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 크레이트가 아직 필요합니다)
처음부터 이 구조로 시작하지 마세요. 크레이트가 셋이면 빌드·리팩터링 비용이 먼저 옵니다. 단일 크레이트 안에서 모듈로 나누다가, 팀이 둘 이상이 되거나 빌드가 느려질 때 크레이트로 쪼개는 순서가 현실적입니다.
정리 — domain(순수) ← api(HTTP) · infra(외부) 방향으로만 의존하게 만들고, AppState 는 이미 Arc 인 것들을 모은 Clone 구조체로 둡니다. 설정은 시작할 때 구조체로 한 번에 검증하고, 실패는 AppError 하나가 전부 HTTP 로 번역합니다. main.rs 는 조립만 합니다.
WEB 04

데이터베이스 — sqlx 를 서비스 수준으로 쓰기

실전 탭(🧰 t06)에서 query_as! 와 트랜잭션 기본형을 봤습니다. 실제 서비스에서 사고가 나는 자리는 그다음입니다 — 풀이 고갈되고, 트랜잭션이 길어지고, 루프 안에서 쿼리가 돌고, 테스트가 서로를 오염시킵니다. 이 절은 그 네 가지를 각각 막는 법입니다.

커넥션 풀 — 태스크는 수만 개, 실제 연결은 스무 개입니다 동시 요청 태스크 수천 ~ 수만 개 각각 수백 바이트 PgPool — max_connections 유휴 연결을 재사용합니다. 없으면 새로 열고, 상한에 닿으면 획득 대기 줄에 세웁니다 PostgreSQL 연결 하나당 프로세스 max_connections=100 풀 고갈 — 장애의 실제 모습 acquire_timeout 을 넘기면 "PoolTimedOut" 으로 실패합니다. DB 는 한가한데 앱만 죽는 전형적 증상입니다 트랜잭션의 수명 — 커넥션을 점유하는 구간입니다 1 pool.begin() — 커넥션 하나를 점유합니다. 여기서부터 반납 전까지 남이 못 씁니다 2 execute(&mut *tx) — 같은 커넥션 위에서 질의를 실행합니다 3 tx.commit() — 반영하고 커넥션을 풀에 반납. ? 로 빠져나가면 Drop 이 자동 롤백합니다 ❌ 트랜잭션 안에서 외부 호출 begin → HTTP 결제 API 대기 3초 → commit 3초 동안 커넥션 하나가 묶입니다. 동시 30건이면 풀 전멸 ✅ 외부 호출은 트랜잭션 밖으로 결제 호출 → 결과를 받고 나서 → begin · update · commit 트랜잭션은 밀리초 단위로 짧게 유지합니다
풀 크기를 늘려도 해결되지 않습니다 — 트랜잭션을 짧게 만드는 것이 유일한 답입니다.

풀 설정 — 기본값을 그대로 쓰면 안 되는 값들

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 확장)

sqlxSeaORMDiesel
쿼리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),
)?;
N+1 을 찾는 가장 빠른 방법 — 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 타입

PostgresRust
biginti64 (u64 는 없습니다 ★)
uuiduuid::Uuid
timestamptzDateTime<Utc>
timestampNaiveDateTime (쓰지 마세요)
numericrust_decimal::Decimal
jsonbsqlx::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 만으로 커서를 만들면 같은 시각의 행에서 문제가 생깁니다 — 반드시 (정렬키, 기본키) 복합으로 비교하세요.
정리 — 풀은 DB 최대 연결 수를 인스턴스 수로 나눠 잡고 acquire_timeout · max_lifetime 을 반드시 겁니다. 트랜잭션은 밀리초 단위로 짧게 — 외부 API 호출을 절대 안에 넣지 마세요. N+1 은 ANY($1) 나 JOIN 으로 없애고, 테스트는 #[sqlx::test] 로 DB 를 격리합니다.
WEB 05

미들웨어 · tower — 붙이는 순서가 곧 동작입니다

실전 탭(🧰 t05)에서 .layer() 를 네 줄 붙여 봤습니다. 그런데 왜 로깅을 가장 마지막에 붙여야 하는지, 왜 CORS 를 타임아웃 바깥에 둬야 하는지는 설명하지 않았습니다. 이 절은 양파 구조를 정확히 이해하고, 필요한 미들웨어를 직접 만드는 데까지 갑니다.

미들웨어는 양파입니다 — 들어갈 때와 나올 때 두 번 지납니다 ① CatchPanicLayer — 패닉을 500 으로 ② SetRequestId · TraceLayer — 로그 ③ CorsLayer — 프리플라이트 응답 ④ Compression · BodyLimit ⑤ Timeout · Auth 핸들러 코드에서는 반대로 씁니다 .layer(A) .layer(B) .layer(C) ← 가장 바깥 요청은 C → B → A 순서로 통과합니다 ServiceBuilder 로 뒤집기 ServiceBuilder::new() .layer(C).layer(B).layer(A) 위에서 아래가 바깥 → 안쪽 순서 읽는 순서와 실행 순서가 같아집니다 순서를 틀리면 실제로 생기는 일 로깅이 안쪽이면 CORS 에서 막힌 요청이 로그에 안 남습니다. 타임아웃이 CORS 바깥이면 프리플라이트도 타임아웃 대상이 됩니다 압축이 인증보다 안쪽이면 401 응답까지 압축하느라 CPU 를 씁니다 — 사소해 보이지만 공격 트래픽에서 차이가 큽니다
규칙 한 줄 — 싸고 무조건 해야 하는 것을 바깥에, 비싸고 조건부인 것을 안쪽에 둡니다.

실서비스 레이어 스택 — 그대로 복사해 쓰세요

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 를 보게 하되,
//   신뢰할 수 있는 프록시 뒤일 때만 쓰세요(아니면 위조됩니다).
인스턴스가 여러 대면 메모리 기반 제한은 N배로 뚫립니다. 3대면 실제 허용치가 초당 15건이 됩니다. 전역 제한이 필요하면 Redis 기반(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?;
    /* ... */
}
장애 대응 시간이 실제로 줄어드는 지점입니다. 사용자가 캡처한 화면에 요청 ID 가 찍혀 있으면 로그 검색 한 번으로 그 요청의 전 구간이 나옵니다. 에러 응답 본문에 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));
90% 는 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);

// ★ 왜 이게 중요한가
//   요청을 무제한으로 큐에 쌓으면 메모리가 늘고, 사용자는 이미 포기했는데
//   서버는 그 요청을 계속 처리합니다("좀비 작업").
//   빨리 거절하면 남은 용량으로 나머지를 정상 처리할 수 있습니다.
큐 대기 시간을 지표로 보세요 — 처리 시간은 정상인데 p99 가 튄다면 대개 큐에서 기다린 시간입니다. 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() 로 라우터 단위로 겁니다.
WEB 06

인증 · 인가 — 추출기가 검사를 강제하게 만들기

인증에서 사고가 나는 자리는 알고리즘이 아니라 빠뜨림입니다 — 새 엔드포인트를 추가하면서 권한 검사를 안 넣는 것. Rust 에서는 “인증된 사용자”를 타입으로 만들어 핸들러 인자에 요구하면 검사를 빠뜨린 핸들러가 애초에 컴파일되지 않게 할 수 있습니다. 그 방법을 만듭니다.

JWT — 로그인부터 권한 검사까지 다섯 단계 1 로그인 — POST /auth/login argon2 로 저장된 해시와 대조합니다. 평문·MD5·SHA 저장은 지금도 사고의 1순위입니다 2 토큰 두 장 발급 — 액세스 + 리프레시 액세스는 15분·무상태 검증, 리프레시는 30일·저장소에 기록해 언제든 폐기할 수 있게 합니다 3 이후 요청 — Authorization: Bearer 액세스토큰 서버는 상태를 안 봅니다. 그래서 인스턴스를 늘려도 세션 공유가 필요 없습니다 4 추출기가 검증 — 서명 · 만료 · 발급자 · 대상 실패하면 Rejection 이 곧바로 401 이 되고 핸들러는 아예 호출되지 않습니다 (w02) 5 인가 — RequireRole · 소유권 검사 추출기 핸들러 인자에 AdminUser 를 요구하면, 관리자 검사를 빠뜨린 코드는 타입이 안 맞습니다 액세스가 만료되면 401 → 리프레시로 재발급 → 옛 리프레시는 즉시 폐기(회전) 브라우저 전용이면 쿠키 세션이 낫습니다 HttpOnly · Secure · SameSite 로 탈취를 막습니다
핵심은 알고리즘이 아니라 검사를 빠뜨릴 수 없는 구조를 만드는 것입니다.

비밀번호 — 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?;

// ★ 사용자가 없을 때도 더미 해시로 검증을 수행하세요.
//   바로 반환하면 응답 시간 차이로 "그 이메일이 있는지" 가 새어 나갑니다.
메모리 파라미터를 낮추지 마세요. Argon2 의 방어력은 메모리 비용에서 나옵니다. 기본값(19MiB)이 부담되면 인스턴스를 늘리는 편이 낫지, 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
사내 SSOOIDC — 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 로 계정이 연결됩니다)
쿠키를 쓰면 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_versionDB 에 정수 하나. 클레임에 넣고 대조 — 조회 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)만 씁니다. 직접 구현이 필요한 건 정책(만료 시간 · 회전 절차 · 권한 모델)뿐입니다.
정리 — 비밀번호는 argon2 + spawn_blocking, 토큰은 액세스(짧게) + 리프레시(회전·저장) 두 장으로 나눕니다. 가장 중요한 것은 AuthUser·Require<Admin> 같은 추출기를 만들어 권한 검사를 타입으로 강제하는 것입니다 — 그래야 새 엔드포인트에서 빠뜨리지 않습니다.
WEB 07

검증 · 직렬화 · OpenAPI 자동 생성

실전 탭(🧰 t02)에서 serde 의 표기들을 봤습니다. 서버에서는 여기에 두 가지가 더 붙습니다 — 값 규칙 검증(validator) 과 API 문서(utoipa). 셋을 같은 구조체 하나에 얹으면, 스펙과 코드가 어긋나는 문제가 구조적으로 사라집니다.

구조체 하나 = 파싱 규칙 + 검증 규칙 + API 문서 요청 DTO 하나 #[derive(Deserialize, Validate, ToSchema)] struct CreateLink { .. } 규칙은 필드 위에만 씁니다 ① serde — JSON 을 구조체로 형식·타입이 틀리면 JsonRejection → 400. "숫자 자리에 문자열" 만 봅니다 ② validator — 값 규칙 길이 · 범위 · 정규식 · 커스텀 함수. 어긋나면 422 와 필드별 오류 목록을 돌려줍니다 ③ utoipa — OpenAPI 3.1 문서 Swagger UI 로 바로 보고, 타입스크립트·코틀린 클라이언트를 생성해 씁니다 한 곳만 고치면 셋이 함께 바뀝니다 필드를 지우면 문서에서도 사라지고, 그 필드를 쓰던 코드는 컴파일 에러가 됩니다. "문서가 낡는" 상태가 만들어질 수 없습니다 단, 문서를 손으로 쓴 주석(#[utoipa::path])은 여전히 낡을 수 있습니다 — 응답 예시와 에러 코드는 테스트로 지켜야 합니다
스펙 우선(spec-first)이 아니라 코드가 곧 스펙입니다 — 어긋날 여지를 없앱니다.

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 로만 열거나 인증 뒤로 숨기세요
클라이언트 코드를 생성해 쓰세요. CI 에서 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 는 컴파일 시점에 문서를 만듭니다. 리플렉션으로 런타임에 훑는 방식과 달리 시작이 즉시이고, 대신 표기를 빠뜨리면 문서에 안 나옵니다(경고도 없습니다).
정리 — 요청 DTO 하나에 Deserialize + Validate + ToSchema 를 얹고, w02 의 Valid<T> 추출기로 파싱·검증·문서를 한 줄에 묶습니다. 깨는 변경은 필드 제거·타입 변경·필수 필드 추가 셋뿐이라고 기억하고, 나머지는 alias 와 #[serde(other)] 로 부드럽게 넘어가세요.
WEB 08

WebSocket · SSE — 실시간을 안전하게 붙이기

실시간 기능은 붙이기는 쉽고 운영이 어렵습니다. 연결이 수천 개로 늘고, 한 사용자의 네트워크가 느려지고, 인스턴스가 두 대가 되는 순간 문제가 시작됩니다. 이 절은 채팅방 하나를 끝까지 만들면서 백프레셔·재연결·수평 확장까지 다룹니다.

broadcast 채널 — 한 번 보내면 모든 구독자에게 복사됩니다 tx.send(msg) 보내는 쪽은 막히지 않습니다 channel(capacity) 고정 크기 링 버퍼 — 넘치면 덮어씁니다 구독자 A — 바로바로 받음 구독자 B — 바로바로 받음 구독자 C — Lagged(n) 느린 구독자가 생기는 이유 모바일 네트워크 · 탭이 백그라운드 · TCP 창이 막힘 그동안 링 버퍼가 한 바퀴 돌면 메시지를 건너뜁니다 실제 대책 Lagged 를 만나면 "일부 놓쳤습니다" 를 알리고 재동기화하거나 일정 횟수를 넘으면 연결을 끊습니다 — 용량 키우기는 임시방편 인스턴스가 두 대가 되는 순간 broadcast 는 프로세스 안에서만 퍼집니다. A 인스턴스에 붙은 사람과 B 인스턴스에 붙은 사람은 서로의 메시지를 못 받습니다 해결 — Redis Pub/Sub · NATS · Kafka 를 사이에 두고, 각 인스턴스가 그것을 구독해 로컬 broadcast 로 다시 뿌립니다
실시간의 어려움은 전송이 아니라 느린 구독자와 여러 인스턴스에 있습니다.

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"))
}
SSEWebSocket
방향서버→클라 only양방향
프로토콜그냥 HTTP업그레이드 필요
재연결브라우저가 자동직접 구현
프록시 궁합좋음설정 필요
바이너리불가(텍스트)가능
적합알림 · 진행률 · 로그채팅 · 게임 · 협업
먼저 SSE 를 검토하세요. “서버가 밀어 주기만 하면 되는” 요구가 생각보다 많고, SSE 는 재연결과 인증이 그냥 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(())
}
Redis Pub/Sub 은 저장하지 않습니다. 구독자가 잠깐 끊긴 사이의 메시지는 사라집니다. “놓치면 안 되는” 메시지라면 Redis Streams · NATS JetStream · Kafka 처럼 오프셋이 있는 것을 쓰고, DB 에도 저장해 재접속 시 마지막 seq 이후를 다시 조회하게 하세요.

실무에선 이렇게 — 연결 수천 개를 운영하기 🔴 실무

반드시 걸어야 할 안전장치

항목이유와 값
메시지 크기 상한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 에도 필요합니다
}
연결 하나의 비용 — Rust 에서 WebSocket 연결 하나는 태스크 두 개 + 버퍼 정도라 대략 수 KB입니다. 연결 1만 개가 수십 MB 수준이라, 메모리보다 파일 디스크립터와 네트워크 대역이 먼저 한계에 닿습니다.
정리 — 서버가 밀기만 하면 되면 SSE, 양방향이면 WebSocket 입니다. broadcast 는 느린 구독자에게 Lagged 를 주므로 반드시 처리하고, 인스턴스가 둘 이상이면 Redis Pub/Sub 같은 다리가 필요합니다. 메시지 크기·속도·유휴 타임아웃 세 가지 상한은 처음부터 걸어 두세요.
WEB 09

gRPC · tonic — 서비스 간 통신과 REST 를 한 포트에

바깥에는 REST/JSON 을 열어 두더라도, 서비스끼리는 gRPC 가 유리합니다 — 스키마가 강제되고, 바이너리라 작고, 스트리밍이 기본이며, 파이썬·자바·Go 클라이언트가 자동으로 생성됩니다. Rust 쪽 표준은 tonic 이고, tokio·tower 위에 있어 Axum 과 부품을 공유합니다.

REST 와 gRPC 를 한 포트에서 — 헤더로 갈라 보냅니다 요청 도착 :8080 둘 다 HTTP/2 위입니다 content-type 확인 application/grpc 로 시작하는가 tonic gRPC 서비스 서비스 간 호출 · 스트리밍 Axum REST 라우터 브라우저 · 외부 클라이언트 코드는 proto 하나에서 나옵니다 link.proto 메시지 · 서비스 정의 build.rs — tonic_build 빌드할 때마다 자동 실행 생성된 Rust 코드 트레이트 + 클라이언트 같은 proto 로 다른 언어 클라이언트도 만듭니다 파이썬 grpcio-tools · 자바 protoc-gen-grpc-java · 타입스크립트 ts-proto — 스키마가 한 곳이라 어긋날 수 없습니다 proto 파일은 별도 저장소나 서브모듈로 공유하고, 버전 태그를 붙여 관리합니다
REST 는 사람과 브라우저에게, gRPC 는 서비스끼리 — 같은 프로세스에서 둘 다 열 수 있습니다.

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(내부망 전용)
//   방화벽·인가 정책을 분리하기 쉬워 실제로는 이쪽이 더 흔합니다
브라우저에서 gRPC 를 직접 부를 수는 없습니다. 필요하면 gRPC-Web(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
버전 규칙을 지키세요 — proto 에서 필드 번호는 절대 재사용 금지, 지운 번호는 reserved 로 막습니다. 필드 추가는 안전, 번호 변경·타입 변경은 파괴적입니다(w07 의 표와 같은 원리).

실무에선 이렇게 — REST 와 gRPC 를 고르는 기준 🔴 실무

비교표

REST + JSONgRPC
스키마OpenAPI(선택)proto(강제)
페이로드텍스트바이너리(작음)
브라우저바로 됨중계 필요
스트리밍SSE·WS 별도기본 내장
디버깅curl 로 끝grpcurl 필요
캐시HTTP 캐시직접
로드밸런싱L7 그대로연결 유지 때문에 주의
적합공개 API내부 서비스 간
gRPC 의 로드밸런싱 함정 — HTTP/2 는 연결 하나를 계속 재사용합니다. L4 로드밸런서 뒤에 두면 클라이언트가 특정 서버에 붙어 부하가 쏠립니다. 해결은 클라이언트 측 LB(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 만 재시도 대상입니다).
정리 — 바깥은 REST, 안쪽은 gRPC 가 무난한 조합입니다. build.rs 로 proto 를 코드로 바꾸고, 같은 proto 로 파이썬·자바 클라이언트를 생성해 씁니다. 한 포트에 얹을 수도 있지만 포트를 나누는 편이 운영에서 더 단순합니다. gRPC 는 연결이 유지되므로 로드밸런싱을 반드시 따로 챙기세요.
WEB 10

관측 — 로그 · 지표 · 추적과 정상 종료

실전 탭(🧰 t08)에서 tracing 기본 설정과 #[instrument] 를 봤습니다. 운영에서 실제로 필요한 것은 그다음입니다 — “어제 오후 3시 12분에 느렸던 그 요청”을 3분 안에 찾아내는 능력. 그러려면 로그 · 지표 · 분산 추적 셋이 같은 trace_id 로 이어져 있어야 합니다.

한 번 기록하면 세 갈래로 나갑니다 — 이어 주는 것은 trace_id 애플리케이션 tracing::info!(...) #[instrument] span 요청마다 span 이 열리고 중첩된 하위 span 이 붙습니다 Subscriber 하나에 레이어 셋 fmt::layer().json() 표준 출력으로 한 줄 한 이벤트 tracing-opentelemetry OTLP 로 span 을 내보냅니다 axum-prometheus /metrics 로 지표를 노출 로그 수집 · 검색 Loki · CloudWatch · OpenSearch OTel Collector Jaeger · Tempo · Datadog Prometheus Grafana 대시보드 · 알람 셋을 잇는 것 — trace_id 하나 대시보드에서 p99 가 튄 구간을 찍으면 그 시각의 추적 목록이 나오고, 추적 하나를 열면 그 요청의 로그가 전부 따라옵니다 에러 응답 본문에도 trace_id 를 넣어 두면(w03) 사용자 문의 캡처 한 장으로 바로 추적됩니다 배포 중 요청을 흘리지 않으려면 /health 는 살아 있음, /ready 는 트래픽 받을 준비 — SIGTERM 을 받으면 먼저 /ready 를 실패로 바꾸고 진행 중인 요청만 마칩니다
지표로 이상을 발견하고, 추적으로 어디서 느린지 보고, 로그로 왜 그런지 읽습니다.

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(())
}
배치 익스포터를 쓸 때는 종료 시 flush 를 하세요. 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(())
}
span 을 너무 잘게 쪼개면 비용이 큽니다. 루프 안에서 #[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);
카디널리티가 관측 비용의 전부입니다. 라벨에 사용자 ID · 이메일 · 슬러그 · 전체 URL 을 넣으면 시계열이 수백만 개가 되어 Prometheus 가 먼저 죽습니다. 라벨은 값의 종류가 수십 개 이하인 것만 씁니다(plan, method, status).

실제로 알람을 걸 네 가지 (골든 시그널)

신호보는 지표와 기준
지연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
알람은 “사람이 지금 뭔가 해야 하는 것”만 거세요. CPU 80% 같은 알람은 무시하는 습관을 만들고, 그 습관이 진짜 장애 때 알람을 놓치게 합니다.

실무에선 이렇게 — 헬스체크와 정상 종료 🔴 실무

/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 쪽에서 설정합니다.
로그가 비싸다고 줄이면 장애 때 아무것도 못 봅니다. 줄일 것은 양이 아니라 중복입니다 — 같은 정보를 span 필드로 한 번만 남기고, 하위 함수에서 user_id 를 반복해 찍지 마세요(span 이 상속합니다).
정리 — tracing 하나로 기록하고 레이어 셋(로그 · OTLP · 지표)으로 내보냅니다. span 은 네트워크·디스크·잠금 경계에만 달고, 지표 라벨에 ID 를 넣지 마세요. /health 는 DB 를 보지 않고 /ready 만 봅니다. 정상 종료는 ready 를 먼저 내리고 몇 초 기다린 뒤 마무리합니다.
WEB 11

테스트 — 서버를 띄우지 않고 요청을 넣는 법

Axum 의 Router 는 그 자체가 tower::Service 입니다. 그래서 포트를 열지 않고도 요청 하나를 그대로 밀어 넣을 수 있습니다 — 미들웨어 · 추출기 · 핸들러 · 에러 매핑이 전부 진짜로 도는데 밀리초 단위로 끝납니다. 여기서 시작해 실제 DB 통합 테스트와 부하 시험까지 층을 쌓습니다. (테스트 전반의 원칙은 🧰 t10 을 함께 보세요.)

요청을 넣는 네 가지 깊이 — 아래일수록 빠르고 위일수록 진짜에 가깝습니다 ④ 부하 시험 — oha · k6 · vegeta 처리량 · p50/p95/p99 · RSS · 에러율을 잽니다. 배포 전 회귀 확인용으로 CI 야간 잡에 넣습니다 느림 — 수 분 단위 ③ testcontainers — 진짜 Postgres · Redis 컨테이너 마이그레이션 · 실제 SQL · 트랜잭션 · 인덱스가 검증됩니다. 목으로는 절대 못 잡는 버그를 잡습니다 첫 실행에 이미지 내려받기, 이후 테스트당 수백 ms ② 임의 포트에 서버 + reqwest 로 호출 실제 TCP 를 지납니다. 압축 · CORS · 헤더 · 리다이렉트까지 확인해야 할 때만 씁니다 테스트당 수십 ms ① oneshot — 네트워크 없이 Router 에 요청을 직접 미들웨어 · 추출기 · 인증 · 에러 매핑이 전부 실제로 돕니다. 직렬화·TCP 오버헤드만 없습니다 테스트당 1ms 미만 — 여기를 가장 두껍게 쌓습니다 권장 비율 — ① 이 수백 개, ③ 이 수십 개, ② 는 필요한 몇 개, ④ 는 야간에 한 번
Router 가 Service 이기 때문에 서버를 띄우는 비용 없이 진짜 경로를 테스트할 수 있습니다.

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(), "하나만 성공해야 합니다");
}
CI 에서도 그냥 됩니다 — GitHub Actions 의 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));
테스트하기 쉬운 구조가 곧 좋은 구조입니다. 외부 URL 을 코드에 박아 두면 목을 끼울 수가 없습니다 — 설정에서 주입(w03)하면 테스트가 자연스럽게 가능해집니다. “테스트를 위해 구조를 바꾼다”가 아니라 원래 그렇게 두는 것이 맞습니다.

실무에선 이렇게 — 부하 시험으로 숫자를 확보합니다 🔴 실무

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% 미만
  },
};
비교 대상을 만드세요. 절대 수치보다 “지난주 대비”가 훨씬 유용합니다. CI 야간 잡에서 같은 시나리오를 돌려 p99 가 20% 나빠지면 알람을 걸면, 성능 회귀를 배포 전에 잡을 수 있습니다.
정리 — tower::ServiceExt::oneshot 으로 서버 없이 요청을 넣는 테스트를 가장 두껍게 쌓고, DB 는 목이 아니라 #[sqlx::test] 나 testcontainers 로 진짜를 씁니다. 외부 HTTP 는 wiremock, 트레이트는 mockall. 부하는 oha 로 p99 를 재고 지난주와 비교하세요.
WEB 12

배포 — 20MB 이미지와 깨지지 않는 CI

실전 탭(🧰 t11)에서 다단계 빌드의 기본형을 봤습니다. 여기서는 실제로 CI 시간을 절반 이하로 줄이는 cargo-chef, 이미지를 20MB 로 만드는 방법, 그리고 클라우드별 배포 방식까지 갑니다. Rust 배포의 이점은 단순합니다 — 런타임이 필요 없으니 옮길 것이 파일 하나입니다.

cargo-chef 멀티스테이지 — 의존성 캐시를 살리는 순서 1 chef prepare — recipe.json 만들기 Cargo.toml 과 Cargo.lock 에서 의존성 목록만 뽑습니다. 소스 내용은 보지 않습니다 2 chef cook --release — 의존성만 미리 컴파일 여기가 도커 캐시 층입니다. 의존성이 그대로면 이 층을 통째로 건너뜁니다 — 수 분이 0초가 됩니다 3 소스 복사 후 내 코드만 컴파일 중간 규모 서비스에서 보통 20~60초. 의존성을 안 건드리는 커밋이라면 매번 이 시간만 듭니다 4 런타임 이미지 — 바이너리 하나만 복사 distroless/cc 약 25MB · musl 정적 링크 + scratch 약 5~15MB · alpine 약 15MB 주의 — scratch 에는 CA 인증서도 타임존도 없습니다. HTTPS 를 부른다면 ca-certificates 를 함께 복사해야 합니다
캐시가 살아 있으면 바뀐 코드만 컴파일합니다 — CI 시간이 체감상 가장 크게 줄어드는 지점입니다.

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~15MBmusl 할당자 느림
distroless/cc~25MB실무 기본값. 셸 없음
scratch + musl~8MB인증서·셸·디버깅 전부 없음
musl 의 기본 할당자는 느립니다. 할당이 잦은 서버라면 처리량이 눈에 띄게 떨어질 수 있습니다 — 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.iofly launch 가 Dockerfile 을 감지합니다. fly.toml 에 헬스체크와 auto_stop_machines 설정. 메모리 256MB 로 충분한 경우가 많습니다
Railway
Render
깃 푸시 배포. Dockerfile 이 있으면 그대로 씁니다. PORT 환경변수를 읽도록 만들어 두세요
AWS ECS
Fargate
태스크 정의에 CPU 0.25 / 메모리 512 로 시작. ALB 헬스체크는 /health, 배포 그레이스 기간을 정상 종료 시간보다 길게
KuberneteslivenessProbe=/health, readinessProbe=/ready, terminationGracePeriodSeconds: 45. 요청/제한을 메모리 128Mi~256Mi 로 잡아 보세요
Lambdacargo 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
메모리 제한을 낮게 잡아 보세요. JVM 이라면 위험한 설정이지만, Rust 서비스는 128~256Mi 로도 도는 경우가 흔합니다. 같은 노드에 파드를 몇 개 더 넣을 수 있다는 뜻이고, 그게 w01 에서 말한 비용 절감의 실체입니다. 단, 실제 RSS 를 지표로 관찰한 뒤 조이세요.
정리 — cargo-chef 로 의존성 층을 캐시하고, 런타임은 distroless/cc(약 25MB) 를 기본으로 삼습니다. CI 는 Swatinem/rust-cache + type=gha 레이어 캐시 두 가지로 시간이 절반이 됩니다. SQLX_OFFLINE 과 .dockerignore 는 빠뜨리기 쉬우니 먼저 확인하세요.
WEB 13

실전 프로젝트 — URL 단축 + 통계 서비스를 끝까지

지금까지의 모든 조각을 하나로 잇습니다 — Axum 0.8 + sqlx(Postgres) + Redis(캐시·카운터) + JWT + OpenAPI + Docker Compose. 리다이렉트는 초당 수만 건을 견뎌야 하고, 관리 API 는 인증과 검증이 필요하며, 클릭 통계는 쓰기가 읽기를 막지 않아야 합니다. 이 세 가지 요구가 어떻게 코드로 나뉘는지 보세요.

요청 세 종류가 서로 다른 길을 갑니다 — 서로를 막지 않게 ① 리다이렉트 — 가장 뜨거운 경로 GET /{slug} 인증 없음 Redis 캐시 히트하면 여기서 끝 Postgres 미스일 때만 · 캐시 채움 302 응답 Location 헤더 ② 클릭 통계 — 쓰기가 읽기를 막지 않게 클릭 이벤트 응답을 보낸 뒤 mpsc 채널 막히지 않음 · 가득 차면 버림 배치 워커 1초 또는 500건마다 clicks 테이블 한 번에 INSERT ③ 관리 API — 인증과 검증이 필요한 경로 POST /api/v1/links JSON 본문 AuthUser 추출기 JWT 검증 · 실패 시 401 slug 생성 · 저장 중복이면 409 201 Created Location 헤더 설계의 핵심 — 뜨거운 경로에서 DB 와 디스크 쓰기를 완전히 걷어냈습니다 캐시 히트면 왕복이 Redis 한 번뿐이고, 통계 기록은 채널에 던지는 것으로 끝나 응답 지연에 포함되지 않습니다
같은 서비스 안에서도 경로마다 요구가 다릅니다 — 그것을 구조로 분리하는 것이 설계입니다.

스키마 — 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)는 피합니다
}
301 을 쓰면 브라우저가 영구 캐시합니다 — 링크를 수정하거나 삭제해도 사용자 브라우저는 계속 옛 주소로 갑니다. 클릭 통계도 안 잡힙니다. 단축 서비스는 302(또는 307) 가 맞습니다.

이렇게도 쓸 수 있다 — 생성 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;
    }
}
캐시 스탬피드 — 인기 링크의 캐시가 만료되는 순간 수천 요청이 동시에 DB 로 갑니다. 위의 지터가 1차 방어이고, 더 강하게 막으려면 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();
}
“무엇을 잃어도 되는가”를 먼저 정하세요. 클릭 통계는 몇 건 유실돼도 되는 데이터이고, 링크 생성은 절대 유실되면 안 되는 데이터입니다. 이 구분이 유계 채널 + try_send 와 트랜잭션 + 커밋 의 차이를 만듭니다.

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"

한 단계 더 — 숫자로 확인하고, 더 밀어붙이기 🧬 전문가

같은 서비스를 세 가지로 만들어 재면

같은 로직(캐시 히트 리다이렉트)을 세 스택으로 구현해 같은 조건에서 잰, 흔히 나오는 범위입니다. 하드웨어·설정에 따라 달라지므로 자릿수만 보세요.

AxumFastAPISpring
처리량(상대)1.00.06~0.120.45~0.65
p99 (캐시 히트)1~3ms15~40ms4~12ms
부하 시 RSS25~45MB150~350MB350~600MB
콜드 스타트10ms 안팎1~2초4~9초
이미지 크기~25MB~250MB~300MB
p99 안정성평탄GIL 경합GC 스파이크
주의해서 읽으세요 — 이 차이의 상당 부분은 언어가 아니라 구조에서 옵니다. FastAPI 도 워커를 여러 개 띄우면 처리량이 오르고, Spring 도 가상 스레드와 튜닝으로 좋아집니다. 변하지 않는 것은 메모리와 콜드 스타트이고, 그게 인스턴스 수와 비용을 정합니다(w01).

여기서 더 나아가려면

다음 단계무엇을 하나
인메모리 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 기초와 🔐 소유권 탭, 크레이트 선택은 🧰 실전·생태계 탭으로 돌아가세요.

정리 — 하나의 서비스 안에서도 경로마다 요구가 다릅니다 — 리다이렉트는 캐시로 DB 를 걷어내고, 통계는 채널과 배치로 응답에서 떼어내고, 관리 API 는 인증·검증·트랜잭션을 제대로 겁니다. 그리고 무엇을 잃어도 되는지를 먼저 정하면 나머지 설계가 따라옵니다.
Tokio 1.x · 액터 · 제로카피 · QUIC · Kafka · Polars · PGO — 2026 기준

Rust 가
정말로 이기는
자리를 다룹니다

이 탭은 “Rust 로 웹 서버를 만들 수 있다”에서 한 층 더 내려갑니다 — Tokio 스케줄러가 태스크를 어떻게 굴리는지, 채널과 액터로 Arc<Mutex> 없이 설계하는 법, 바이트를 복사하지 않고 넘기는 법, Kafka·Redis 파이프라인, Polars·DataFusion 같은 데이터 엔진, 할당자 교체와 아레나, 그리고 flamegraph · tokio-console · PGO 로 수치를 끌어올리는 법까지입니다. 마지막 섹션에서 실시간 로그 수집·집계 서비스를 처음부터 끝까지 만들고 부하를 걸어 봅니다. ⚡ 성능 탭이 “언어 안쪽”이라면 이 탭은 “서비스 바깥쪽”입니다.

13SECTIONS
Tokio 1.x기준
13시각화
1완성 프로젝트
SVC 01

Rust 가 이기는 자리 — 어디에 쓰면 남는 장사인가

Rust 를 “빠른 언어”로만 소개하면 절반만 맞습니다. C 도 빠르고 C++ 도 빠릅니다. Rust 의 진짜 값어치는 그 속도를 유지하면서 메모리 사고와 데이터 레이스를 컴파일 단계에서 지운다는 데 있습니다. 이 섹션은 “그래서 어떤 일을 Rust 로 다시 써야 이득인가”를 실제 사례와 함께 정리합니다.

시스템 소프트웨어를 쓸 때 원래는 둘 중 하나를 포기했습니다 C · C++ 속도 · 제어 : 최고 use-after-free · 버퍼 넘침 데이터 레이스 : 실행 중 터짐 MS · 구글 통계 : 보안 결함 약 70% Java · Go · C# 메모리 안전 : 확보 GC 멈춤 : p99 가 튐 메모리 : 2~5배 더 씀 커널 · 임베디드에는 못 들어감 Rust 속도 : C++ 와 같은 급 사고 : 컴파일 에러로 GC 없음 : 멈춤 없음 대가 : 배우는 시간 · 빌드 시간 그래서 2020년 이후 실제로 다시 쓰인 것들 운영체제 · 인프라 Linux 커널 드라이버(6.1+) · Android 시스템 구성요소 Windows 커널 일부 · AWS Firecracker(마이크로 VM) Cloudflare Pingora — nginx 대체, CPU 3분의 1 Discord 상태 서비스 — Go 에서 옮겨 p99 급락 개발자 도구 · 데이터 Deno · Turbopack · Biome · SWC — JS 도구 체인 uv · ruff — 파이썬 도구, 기존 대비 10~100배 Polars · DataFusion — DataFrame · SQL 엔진 ripgrep · fd · bat · zoxide — CLI 표준이 됨
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 가 잘 풀어 주지만 워밍업이 필요하고
//   객체 헤더 · 카드 마킹 같은 비용이 남습니다.
“제로 비용”의 정확한 뜻 — “쓰지 않으면 비용이 없고, 쓰더라도 손으로 짠 것보다 느리지 않다”(비야네 스트롭스트룹의 정의)입니다. 추상화가 컴파일 시점에 사라지기 때문이고, 그 근거는 제네릭이 단형화(monomorphization)되고 트레이트 호출 대부분이 정적으로 결정되기 때문입니다(🔬 d03).

데이터 레이스 — 다른 언어에선 런타임, 여기선 빌드 에러

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) ★ 이 탭이 미는 방향
Go 와 결정적으로 갈리는 지점 — Go 에서 같은 map 을 두 고루틴이 건드리면 컴파일은 되고 운영에서 concurrent map writes 로 프로세스가 죽습니다. Java 라면 조용히 값이 깨집니다. Rust 는 그 코드가 애초에 빌드되지 않습니다(⚡ e06 의 Send·Sync).

이렇게도 쓸 수 있다 — C++ 와 항목별로 나란히 놓기 🟡 중급

C++ 대비 — 실무에서 체감되는 것만

항목C++23Rust
런타임 속도기준같은 급(±5%)
메모리 사고개발자 책임컴파일러가 차단
데이터 레이스실행 중 발견빌드 에러
의존성vcpkg/Conan/직접cargo 하나
빌드 시스템CMake 학습 필요없음(cargo)
테스트·벤치·문서따로 붙임내장
컴파일 시간모듈로 개선 중느림(제네릭·LLVM)
기존 자산30년치10년치 + FFI
인력 구하기쉬움어려움
GUI · 게임 엔진성숙미성숙
정직하게 — 아래 절반은 C++ 가 이깁니다. 그래서 전면 교체가 아니라 “새로 쓰는 부분부터”가 업계의 실제 전략입니다. Linux · Android · Windows 전부 그렇게 들어갔습니다.

Rust 를 고르면 안 되는 자리도 분명합니다

상황더 나은 선택
CRUD 웹 · 관리자 페이지Spring · Django · Rails
데이터 분석 · 실험파이썬(무거우면 Polars 로)
2주짜리 프로토타입익숙한 언어 아무거나
팀에 Rust 경험 0명한 서비스만 파일럿
UI 가 핵심인 앱Kotlin · Swift · TS
I/O 대기가 99%언어를 바꿔도 안 빨라집니다
가장 흔한 오판 — “느려서 Rust 로 바꾸자”. 병목이 DB 쿼리·네트워크 왕복이면 언어를 바꿔도 그대로입니다. 먼저 재세요(h11). Rust 가 이득인 건 CPU 를 태우거나 · 메모리를 아끼거나 · 꼬리 지연(p99)을 잡아야 할 때입니다.

실무에선 이렇게 — 전환은 “가장 뜨거운 한 조각”부터 🔴 실무

❌ 전면 재작성
“파이썬 백엔드 전체를 Rust 로 옮기겠습니다”

  6개월 뒤 상태
    · 기능은 아직 60%          (기존 팀은 그동안 신규 기능을 계속 냄)
    · 소유권 에러로 매일 두 시간씩 멈춤
    · 라이브러리가 없어 직접 구현한 코드가 늘어남
    · 결국 롤백 — “Rust 는 우리 팀엔 안 맞더라”

  실패 원인은 언어가 아니라 '전면'입니다.
✅ 뜨거운 조각만 도려내기
① 프로파일로 CPU 상위 3개 함수를 찾는다        (h11)
② 그중 '순수 계산 + 입출력 없음' 인 것 하나를 고른다
③ Rust 로 다시 쓰고 파이썬 모듈로 노출한다      (🐍 y03)
④ A/B 로 수치를 재고 (지연 · CPU · 메모리) 기록한다
⑤ 수치가 팀을 설득하면 다음 조각으로

  실제 사례가 전부 이 모양입니다
    · Dropbox   — 동기화 엔진의 압축·해시 부분
    · Discord   — 상태(read state) 서비스 한 개
    · Figma     — 멀티플레이어 서버 한 개
    · Cloudflare— 프록시 한 계층(Pingora)
이 탭을 읽는 순서 — 서버를 짓는 중이면 h02 → h03 → h04(런타임·채널·락)가 먼저입니다. 데이터 처리라면 h05 → h07 → h08(제로카피·파이프라인·엔진), 이미 돌고 있는 걸 빠르게 만들어야 하면 h10 → h11(할당·프로파일)로 바로 가세요. h13 은 그 전부를 한 프로젝트에 합친 것입니다.

한 단계 더 — “안전하다”가 정확히 무엇을 보장하나 🧬 전문가

막아 주는 것과 안 막아 주는 것

사고안전한 Rust 에서
use-after-free불가능
이중 해제불가능
버퍼 넘침패닉(조용히 안 넘어감)
널 역참조타입에 없음
데이터 레이스빌드 에러
이터레이터 무효화빌드 에러
메모리 누수가능 (안전으로 분류)
데드락가능 — h04 참고
논리 버그가능
무한 루프 · OOM가능
정수 오버플로디버그 패닉/릴리즈 wrap
“메모리 누수가 안전이라니?” — Rust 의 안전 정의는 “미정의 동작(UB)이 없다”이지 “버그가 없다”가 아닙니다. 누수는 UB 가 아니라 그냥 메모리를 안 돌려준 상태입니다. 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 비율 — 실측 조사(cargo-geiger 등)에서 애플리케이션 크레이트의 99% 이상이 unsafe 를 한 줄도 안 씁니다. 쓰는 곳은 FFI 경계(🔗 f03)와 자료구조 내부뿐입니다.
정리 — Rust 를 고르는 이유는 “빠르다”가 아니라 “빠른데 안 죽는다”입니다. CPU 를 태우거나 · 메모리를 아끼거나 · p99 를 잡아야 하는 조각에 넣으면 남는 장사이고, I/O 대기가 대부분인 코드에 넣으면 빌드 시간만 늘어납니다. 전환은 가장 뜨거운 함수 하나부터, 그리고 반드시 수치를 재서 하세요.
SVC 02

Tokio 내부 — work-stealing 스케줄러가 태스크를 굴리는 법

tokio::spawn 한 태스크는 그다음에 무슨 일을 겪을까요. 이 그림이 머릿속에 있으면 “왜 서버가 갑자기 멈추는지” · “워커 수를 몇으로 둘지” · “어디에 spawn_blocking 을 쓸지”가 추측이 아니라 계산이 됩니다. 🧰 t04 가 사용법이라면 여기는 구조입니다.

워커 스레드 하나가 다음 일을 찾는 순서 — 위에서 아래로 워커 #0 LIFO 슬롯 1칸 방금 깨어난 태스크 — 캐시가 아직 따뜻함 지역 큐 (256칸 고정) 락 없는 링 버퍼 · 자기 것만 push 가득 차면 절반을 전역 큐로 밀어냄 전역 큐 (공용) 넘친 태스크 · 런타임 밖에서 spawn 된 것 61번 폴링마다 한 번은 여기부터 봄 남의 지역 큐에서 훔치기 무작위 워커를 골라 절반을 가져옴 실패하면 잠듦(park) → 깨울 때 unpark ① 비었으면 → ② 비었으면 → 이 구조가 무너지는 단 하나의 경우 — 폴링 중 블로킹 워커 안에서 std::fs::read / 무거운 계산 그 워커는 다음 폴링을 못 함 — 지역 큐 256개가 통째로 대기 코어 8개 중 3개가 막히면 처리량이 그대로 5/8 로 spawn_blocking 으로 격리 별도 블로킹 풀(기본 최대 512 스레드)로 보냄 워커는 즉시 다음 태스크로 — 큐가 흐름을 유지
태스크는 스레드가 아니라 폴링 단위입니다 — 폴링 한 번이 길어지면 그 워커가 통째로 멈춥니다.

런타임을 손으로 만들어 설정하기

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() { /* … */ }
워커 수를 몇으로? — 기본값(코어 수)에서 시작하고, 컨테이너에서는 반드시 명시하세요. 쿠버네티스에서 CPU limit 이 2코어여도 Tokio 는 호스트의 64코어를 보고 워커를 64개 만듭니다 — 컨텍스트 스위칭만 늘어납니다. std::thread::available_parallelism() 도 cgroup 을 못 보는 경우가 있어 환경변수로 주입하는 편이 안전합니다.

current_thread vs multi_thread — 무엇이 다른가

current_threadmulti_thread
스레드1N (코어 수)
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;
증상으로 알아보기 — “요청 대부분은 1ms 인데 가끔 500ms 가 찍힌다”는 거의 항상 같은 워커에서 누가 오래 붙잡고 있었다는 뜻입니다. 평균이 아니라 p99 히스토그램을 보세요(🧰 t08 의 tracing + h11).

런타임 지표를 직접 뽑기

// 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바이트만 들고 있음
큰 Future 잡는 법 — 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;           // 전부 끝날 때까지 ★ 우아한 종료
정리 — 워커는 LIFO 슬롯 → 지역 큐 → 전역 큐 → 훔치기 순으로 일을 찾습니다. 이 흐름을 끊는 유일한 것이 긴 폴링이고, 그래서 블로킹은 spawn_blocking·rayon 으로 격리합니다. 컨테이너에서는 워커 수를 명시하고, 전역 큐 깊이와 살아있는 태스크 수를 지표로 찍어 두세요 — 사고의 절반은 이 두 숫자에서 먼저 보입니다.
SVC 03

채널 · 액터 — Arc<Mutex> 를 안 쓰는 설계

상태를 여럿이 공유해야 할 때 대부분 Arc<Mutex<T>> 부터 꺼냅니다. 동작은 합니다. 그런데 락 경합 · 데드락 · “누가 언제 잠그는지 모르겠음”이 따라옵니다. Rust 에서 더 잘 통하는 답은 “상태를 한 태스크가 독점하고 메시지로만 말을 건다” — 액터입니다.

같은 카운터를 여러 태스크가 올린다 — 두 가지 설계 ① Arc<Mutex<T>> — 자리를 두고 다툽니다 태스크 A · B · C 가 동시에 lock() 을 시도 한 번에 하나만 통과 — 나머지는 대기 잠근 채 .await 하면 다른 태스크가 영영 못 들어옴 코어를 늘려도 이 지점은 안 빨라집니다 읽기가 압도적이면 RwLock · ArcSwap 이 답 (h04) ② 액터 — 상태 주인이 한 명입니다 상태는 액터 태스크 안의 평범한 지역 변수 락이 아예 없음 — 소유자가 하나뿐이라 순서가 채널 순서로 결정 — 재현 가능 대기는 채널 큐에서 — 백프레셔가 자연스럽게 생김 부하가 크면 샤딩 (액터 N개 · 키로 나눔) 액터 한 바퀴 — 메시지가 도는 길 1 호출자 tx.send(msg).await 2 mpsc 큐 capacity 1024 3 액터 루프 (단 하나) while let Some(m) = rx.recv().await 4 응답은 메시지에 실은 oneshot 채널로 돌아옵니다 — 호출자는 그것만 await 큐가 가득 차면 send 가 기다립니다 → 그게 곧 백프레셔이고, 폭주를 자동으로 막아 줍니다
락은 “같은 자리를 나눠 쓰는” 설계고 액터는 “주인에게 부탁하는” 설계입니다 — 후자가 훨씬 디버깅하기 쉽습니다.

채널 네 종류 — 고르는 표 ★

채널보내는 쪽받는 쪽쓰는 곳
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??;
큐 길이는 “지연 예산”으로 정하세요 — 처리량이 8,000/s 이고 허용 지연이 100ms 라면 큐는 800개면 충분합니다. 그보다 길면 어차피 타임아웃될 요청을 메모리에 쌓아 두는 것뿐입니다.

동시 실행 수를 세마포어로 제한

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 ★
Semaphore vs 큐 — 세마포어는 “동시에 몇 개”, 채널은 “대기줄 길이”를 제한합니다. 보통 둘 다 씁니다: 채널로 유입을 받고, 세마포어로 하류 자원(DB 커넥션·외부 API)을 보호합니다.

실무에선 이렇게 — 샤딩 · 감독 · 채널 크레이트 선택 🔴 실무

액터 하나가 병목이면 — 키로 샤딩

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! 에는 취소 안전한 것만 넣습니다.
SVC 04

동기화 고급 — 락을 고르는 기준과 락을 없애는 법

액터로 다 풀리지는 않습니다. 읽기가 압도적으로 많은 설정, 수십만 키의 캐시, 초당 수백만 번 올라가는 카운터 — 각각 정답이 다릅니다. 이 섹션은 std::sync · tokio::sync · parking_lot · DashMap · ArcSwap · 원자 연산을 “언제 무엇을”로 정리합니다.

공유 상태를 만났을 때 — 위에서부터 순서대로 물어봅니다 Q1. 한 태스크가 독점할 수 있나? 예 → 액터 + mpsc (h03) ★ 첫 번째 답 Q2. 값이 숫자·플래그 하나인가? 예 → AtomicU64 · AtomicBool (락 없음) Q3. 통째로 갈아끼우는 설정인가? 예 → ArcSwap (읽기 비용 ≈ 원자 읽기) Q4. 키가 많은 맵인가? 예 → DashMap · moka (샤딩된 락) Q5. 잠근 채 .await 해야 하나? 예 → tokio::sync::Mutex (느립니다) 전부 아니오 → parking_lot::Mutex 로 짧게 잠급니다. RwLock 은 읽기가 쓰기보다 100배 이상일 때만 이득입니다
락을 고르기 전에 “락이 정말 필요한가”를 네 번 묻습니다 — 넷 중 하나에서 대개 걸립니다.

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 는 락 밖에서
표준 Mutex 는 “독(poison)”이 됩니다 — 잠근 스레드가 패닉하면 이후 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() 을 별도 스레드에서 주기 호출
stdparking_lot
Mutex 크기보통 16~40B1바이트
경합 없을 때빠름더 빠름
중독(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)
lock-free 를 직접 만들지 마세요 — ABA 문제 · 메모리 회수 · 메모리 순서가 얽혀 전문가도 틀립니다. crossbeam·flume·DashMap 처럼 검증된 것을 쓰고, 정말 필요하면 loom 으로 모델 검사(🔬 d10)를 돌리세요.
상황도구이유
요청 수 세기AtomicU64(코어별 분산)락 없음 · 정확도 요구 낮음
설정 무중단 교체ArcSwap읽기 비용 ≈ 0
세션 맵 10만 건DashMap · moka샤딩으로 경합 분산
DB 커넥션 대여세마포어 + 풀동시 수 제한이 본질
순서가 중요한 상태액터(h03)채널 순서 = 처리 순서
잠금 구간에 awaittokio::sync::Mutex다른 선택지가 없음
짧은 임계 구역parking_lot::Mutex가장 단순하고 빠름
긴 읽기 · 드문 쓰기RwLock이때만 이득
정리 — 락을 고르기 전에 액터 · 원자변수 · ArcSwap · 샤딩 으로 락을 없앨 수 있는지 먼저 보세요. 남았다면 parking_lot::Mutex 를 짧게 쓰는 것이 기본값이고, RwLock 은 임계 구역이 길고 읽기가 압도적일 때만 이득입니다. tokio::sync::Mutex 는 잠근 채 await 해야 할 때만 씁니다.
SVC 05

제로카피 — 바이트를 옮기지 않고 넘기기

네트워크 서비스가 느린 이유의 상당 부분은 계산이 아니라 같은 바이트를 서너 번 복사하는 것입니다. 소켓 버퍼 → Vec<u8> → 파싱 결과 String → 다시 직렬화 버퍼. Rust 는 소유권 덕분에 “복사 안 하고 넘기기”가 안전하게 가능한 드문 언어입니다.

한 번 읽은 8KB 를 헤더·본문·꼬리로 나눠 쓸 때 ① Vec<u8> 을 to_vec() 으로 자르면 — 매번 새 메모리 + memcpy 원본 8KB 헤더 사본 200B 본문 사본 7KB 꼬리 사본 800B 할당 3번 + 복사 8KB — 초당 5만 요청이면 초당 400MB 를 헛되이 옮깁니다 ② Bytes — 버퍼는 하나, 조각들은 '어디부터 몇 바이트'만 들고 있습니다 힙에 있는 실제 버퍼 8KB — 참조 카운트 3 할당 0회 · 복사 0바이트 Bytes A offset 0 · len 200 Bytes B offset 200 · len 7168 Bytes C offset 7368 · len 800 셋 중 하나만 남아도 버퍼는 살아 있고, 마지막 조각이 drop 될 때 한 번에 해제됩니다 — 수동 관리가 필요 없습니다
복사 대신 참조 — 소유권이 있으니 “언제 해제할지”를 사람이 안 정해도 됩니다.

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()?;                                 // 디스크로 내려쓰기
mmap 이 항상 빠르지는 않습니다 — 순차로 한 번만 읽는다면 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 입니다.
SVC 06

네트워크 저수준 — TCP 직접 · 커스텀 프로토콜 · QUIC

HTTP 만 쓰는 게 아닙니다. 게임 서버 · 로그 수집기 · 메시지 브로커 · 프록시는 바이트를 직접 프레이밍합니다. Rust 는 이 층에서 특히 강합니다 — tokio-util 의 codec 이 “바이트 스트림 → 메시지 스트림” 변환을 표준화해 두었기 때문입니다.

TCP 에는 '메시지 경계'가 없습니다 — 코덱이 그걸 만듭니다 소켓에서 실제로 읽히는 모습 (read 세 번) read #1 → 6바이트 [len=10][본문 2바이트만] read #2 → 30바이트 [남은 8][len=12][12][len=6][3] read #3 → 3바이트 [앞 메시지의 남은 3] Decoder — 누적 버퍼(BytesMut)를 들고 매번 같은 질문을 합니다 ① 4바이트(길이)가 있나? 없으면 Ok(None) — "더 읽어 줘" ② 길이만큼 본문이 다 있나? 있으면 split_to 로 떼어 메시지로, 없으면 다시 Ok(None) 위쪽 코드가 보는 것 — 완성된 메시지만 Msg #1 Msg #2 Msg #3 아직 안 온 것은 버퍼에 대기 while let Some(msg) = framed.next().await — 위 코드는 TCP 의 조각남을 전혀 몰라도 됩니다 · 이 next() 는 취소 안전합니다
코덱은 “모자라면 None, 다 왔으면 Some” 한 규칙만 지키면 됩니다 — 나머지는 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바이트 안쪽으로 맞추는 것이 관례입니다
UDP 수신 루프의 철칙 — 수신 태스크는 아무 일도 하지 않습니다. 받아서 큐에 넣기만 하고 즉시 다음 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(())
    });
}
QUIC 이 이기는 지점 — ① head-of-line blocking 없음(스트림 하나가 패킷을 잃어도 다른 스트림은 진행), ② 0-RTT 재연결, ③ 연결 마이그레이션(와이파이↔LTE 전환에도 연결 유지), ④ TLS 1.3 내장. 모바일 클라이언트·잦은 재연결 환경에서 체감이 큽니다.

HTTP/3 와 io_uring — 2026 현황

기술크레이트상태
HTTP/3 서버h3 + quinn실사용 가능 · API 변동
HTTP/3 클라이언트reqwest(feature)실험적
QUICquinn · quiche성숙
io_uring 범용tokio-uring실험적 · 정체
thread-per-coremonoio · glommio실사용 사례 있음
저수준 uringio-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(())
        });
    }
}
io_uring 을 지금 도입할 것인가 — 벤치에서는 tokio 대비 작은 요청 위주일 때 20~40% 가 나오지만, 생태계가 전부 tokio 기준입니다(sqlx·reqwest·axum 전부). 실무 권고는 “프록시·캐시처럼 의존성이 거의 없는 단일 목적 서비스”에만 검토하는 것입니다.

한 단계 더 — 소켓 옵션과 커널 튜닝 🧬 전문가

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 가 크면 상대가 못 받는 것입니다. 원인이 다르면 고칠 곳도 다릅니다.
정리 — TCP 는 메시지 경계가 없으므로 항상 코덱이 필요합니다 — Decoder 는 “모자라면 Ok(None)” 만 지키면 되고, 반드시 프레임 크기 상한을 두세요. set_nodelay(true) · accept 실패 시 계속 진행 · UDP 수신 태스크는 받아서 큐에 넣기만. QUIC 은 모바일·다중 스트림에서 이득이 크고, io_uring 은 아직 특수 목적입니다.
SVC 07

데이터 파이프라인 — Kafka · Redis · 스트림 처리

고성능 서비스는 대부분 “받아서 · 변환해서 · 어딘가에 넣는” 파이프라인입니다. Rust 로 이걸 짜면 같은 처리량을 1/3 의 CPU 와 1/5 의 메모리로 하는 일이 흔합니다. 여기서는 Kafka 소비/생산 · Redis 비동기 풀 · Stream 조합 · 그리고 “정확히 한 번”이 실제로 무엇을 뜻하는지를 다룹니다.

토픽 = 파티션들의 묶음 · 파티션 = 순서가 보장된 로그 하나 토픽 logs (파티션 6개) — 키 해시로 나뉩니다 P0 P1 P2 P3 P4 P5 소비자 그룹 svc-agg — 파티션 하나당 소비자 하나 소비자 A — P0 · P1 소비자 B — P2 · P3 C — P4 D — P5 7번째는 놀게 됨 한 메시지가 처리되는 순서 — 커밋 위치가 의미를 바꿉니다 1 poll → 메시지 2 처리 (DB 쓰기 등) 3 오프셋 커밋 커밋을 2번 앞에 두면 — at-most-once 처리 중 죽으면 그 메시지는 영영 사라집니다 지금 그림대로면 — at-least-once (기본) 3번 직전에 죽으면 재시작 후 다시 처리 → 멱등성이 필요
파티션 수가 동시성의 상한이고, 커밋 위치가 유실이냐 중복이냐를 정합니다.

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) — 중복이 와도 결과가 같게 만드는 것 ★ 실무 정답
  ③ 결과 저장소의 트랜잭션 — 오프셋과 결과를 '같은 트랜잭션'에 쓰기

즉 현실의 답은 거의 항상 ②입니다 —
  "중복을 막는다"가 아니라 "중복이 와도 괜찮게 만든다"
멱등성 만드는 세 가지 수단 — ① 고유 키 + UPSERT(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)?;
이 패턴의 이름 — “transactional outbox” 의 소비자 쪽 버전입니다. 추가 인프라 없이 DB 트랜잭션 하나로 문제를 옮겨 놓기 때문에 운영 난이도가 Kafka 트랜잭션보다 훨씬 낮습니다(🌐 서버기술 가이드에 더 자세히 있습니다).

실무에선 이렇게 — 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?;   // 레이트 리밋 ★
풀 크기의 함정 — Redis 는 단일 스레드로 명령을 처리합니다. 커넥션을 200개 열어도 처리 속도는 그대로이고 메모리와 컨텍스트 스위치만 늘어납니다. 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?;
KafkaNATSRedis 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 를 얹으면 배치 처리까지 자연스럽게 붙습니다.
정리 — Kafka 는 파티션 수가 동시성의 상한이고, poll 루프는 항상 가볍게 유지해야 리밸런스에 안 물립니다. exactly-once 를 좇지 말고 at-least-once + 멱등 처리로 설계하세요 — 가장 튼튼한 방법은 결과와 오프셋을 같은 DB 트랜잭션에 쓰는 것입니다. Redis 는 커넥션을 늘리지 말고 왕복을 줄이고(파이프라인), 배치는 언제나 “N건 또는 T초”로 묶습니다.
SVC 08

데이터 엔진 — Polars · DataFusion · Arrow

Rust 가 파이썬 세계를 가장 크게 흔든 곳이 여기입니다. Polars(DataFrame)와 DataFusion(SQL 엔진)은 둘 다 Rust 로 쓰였고, Apache Arrow 라는 공통 메모리 형식 위에 서 있습니다. 이 섹션은 “왜 Pandas 보다 빠른가”를 구조로 설명하고 실제로 돌려 봅니다.

같은 표를 메모리에 놓는 두 가지 방법 행 단위 (Pandas 이전 · 일반 DB) — SUM(price) 하나에 [id0 name0 price0 qty0][id1 name1 price1 qty1]… price 만 필요한데 name·qty 까지 캐시에 올라옴 캐시 라인 64B 중 실제로 쓰는 건 8B — 87% 낭비 SIMD 도 못 씁니다 (값들이 떨어져 있어서) 열 단위 (Arrow · Parquet · Polars) [id0 id1 id2…][name0 name1…][price0 price1 price2…] price 열만 연속으로 읽음 — 캐시 100% 활용 한 번에 8개씩 SIMD 로 더함 (⚡ e09) 같은 값이 모여 있어 압축률도 훨씬 좋음 그 위에 쌓인 것들 — 아래에서 위로 Parquet — 디스크에 열 단위로 저장하는 파일 형식 (압축 · 통계 · 열 건너뛰기) Apache Arrow — 메모리에서의 열 형식 표준. 언어·프로세스 간 복사 없이 공유 Polars — DataFrame · 지연 실행 · 쿼리 최적화 DataFusion — SQL 엔진 · 커스텀 함수 · 분산 기반 둘 다 Rust 로 쓰였고 파이썬 바인딩(pyo3)을 통해 그대로 노출됩니다 — 파이썬에서 쓰는 Polars 가 곧 이 Rust 코드입니다
빠른 이유는 마법이 아니라 메모리 배치 · 캐시 · SIMD · 지연 실행 네 가지입니다.

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 대비 — 왜 차이가 나나

항목PandasPolars
메모리 형식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()?;
작은 파일 문제 — 1분마다 파일을 만들면 하루 1,440개, 한 달에 4만 개가 됩니다. 메타데이터 읽기만으로 조회가 느려집니다. 실무에서는 파일 하나를 최소 수십 MB가 되게 배치를 키우고, 하루가 지나면 작은 파일을 합치는 정리 작업(compaction)을 돌립니다.

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);
}
Polars vs DataFusion — Polars 는 “DataFrame API 가 편한” 쪽, DataFusion 은 “SQL 과 확장이 필요한” 쪽입니다. 내 서비스에 쿼리 엔진을 내장하고 싶다면(사용자가 SQL 을 입력) DataFusion, 데이터 변환 파이프라인이면 Polars 가 보통 더 간결합니다.

실무에선 이렇게 — 파이썬과 함께 쓰기 · 벡터 검색 🔴 실무

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 를 그대로 넘기면 직렬화가 없습니다
실무에서 가장 현실적인 조합 — 분석·탐색은 파이썬 Polars, 운영 파이프라인은 Rust Polars. 같은 엔진이라 결과가 동일하고, 표현식(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 같은 사례가 흔합니다
· 같은 로직을 파이썬과 서비스에서 함께 써야 할 때
    표현식이 거의 동일해 이식 비용이 낮습니다

측정 없이 도입하지 마세요 — 이 표의 위아래가 뒤집히는 데이터도 있습니다.
정리 — Polars·DataFusion 이 빠른 이유는 열 저장(Arrow) · 전 코어 · 지연 실행 최적화 셋입니다. 파일은 Parquet + 통계 + 파티션 경로로 쓰고 읽을 때는 scan_* 를 써서 필요한 열·행만 읽게 하세요. 작은 데이터에서는 오히려 손해이니 본인 데이터로 재고 도입하세요.
SVC 09

CLI · 시스템 도구 — ripgrep 이 빠른 이유를 내 도구에

Rust 가 가장 조용히, 그러나 가장 널리 퍼진 곳은 커맨드라인 도구입니다. ripgrep · fd · bat · eza · zoxide · uv · ruff — 전부 Rust 입니다. 이 섹션은 그들이 빠른 구체적인 이유와, 같은 기법으로 내 도구를 만드는 법을 다룹니다.

ripgrep 계열 도구의 구조 — 네 갈래가 동시에 돕니다 병렬 디렉터리 순회 ignore 크레이트 일감 채널 .gitignore 는 여기서 걸림 워커 N개 코어당 하나 출력 1개 순서 유지 워커 하나가 파일 하나에 하는 일 ① mmap 또는 큰 버퍼로 읽기 줄 단위로 자르지 않습니다 ② SIMD 로 후보 바이트 찾기 memchr — 한 번에 32바이트씩 ③ 후보 근처만 정규식 확인 전체 줄에 정규식을 안 돌림 느린 구현과 갈리는 지점 — 네 가지 한 줄씩 read + String 할당 모든 줄에 정규식 엔진 호출 단일 스레드 · 출력마다 flush → 같은 작업이 10~50배 느려집니다 큰 버퍼 한 번에 · 할당 재사용 memchr 로 먼저 거르고 정규식은 최후에 코어 수만큼 병렬 · 출력은 락 하나로 모음 → 이 네 가지가 체감 차이의 대부분입니다
빠른 CLI 의 비밀은 알고리즘이 아니라 할당을 안 하고 · 병렬로 · 먼저 거르는 것입니다.

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());
}
진행바는 stderr 로 — 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(())
}
실측 감각 — 100만 줄 출력에서 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).
정리 — 빠른 CLI 는 ① 병렬 순회 ② 큰 버퍼 · 할당 재사용 ③ memchr 로 먼저 거르기 ④ 출력은 BufWriter 로 모으기 네 가지에서 나옵니다. 인터페이스는 clap derive, 진행바는 indicatif(stderr), 배포는 cargo-dist + musl 정적 링크. 시작 시간이 1~5ms 라는 점이 도구 시장에서 Rust 가 이긴 이유입니다.
SVC 10

메모리 · 할당 — 할당자를 바꾸고 아레나를 쓴다

서버가 느릴 때 코드는 그대로 두고 한 줄로 10~30% 를 얻는 방법이 실제로 있습니다 — 할당자 교체입니다. 여기에 아레나 · SmallVec · Box<[T]> 를 얹으면 할당 횟수 자체가 줄고, dhat 으로 어디서 얼마나 잡는지 숫자로 볼 수 있습니다.

요청 하나를 파싱하며 노드 1,000개를 만들 때 ① 개별 할당 — 노드마다 malloc Box::new(Node) × 1,000 할당자 호출 1,000번 (크기 계산 · 자유 목록 탐색 · 락) 메모리가 힙 여기저기 흩어짐 → 캐시 미스 해제도 1,000번 — Drop 이 트리를 재귀로 내려감 단편화가 쌓이면 RSS 가 안 내려갑니다 ② 아레나 — 큰 덩어리 하나에 밀어 넣기 arena.alloc(Node) × 1,000 할당 = 포인터를 앞으로 미는 것 한 번 (수 나노초) 메모리가 연속 → 순회할 때 캐시가 잘 맞음 해제 = 아레나 통째로 버리기 한 번 ★ 대신 개별 해제가 불가능합니다 (수명이 같아야 함) 아레나 안쪽 — bump 포인터가 앞으로만 갑니다 노드 A 노드 B 노드 C 아직 안 쓴 공간 — 다음 할당은 여기서 시작 bump 포인터 쓰면 안 되는 곳 — 수명이 제각각인 장기 객체 · 개별 해제가 필요한 캐시. 아레나는 "요청 하나 동안만 사는 것들" 전용입니다
아레나는 “같이 태어나 같이 죽는” 객체 전용입니다 — 그 조건이 맞으면 압도적입니다.

할당자 교체 — 한 줄로 끝납니다 ★

[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메시지 패싱형에 강함스레드 간 할당/해제가 엇갈릴 때
얼마나 좋아지나 — 할당이 잦은 서버에서 처리량 10~30% · p99 개선이 흔합니다. 멀티스레드 할당 경합이 심할수록 차이가 큽니다. 공짜에 가까운 개선이니 벤치를 먼저 잡아 두고(h11) 한번 재 보세요. 단, musl 타깃의 기본 할당자는 특히 느리므로 musl 정적 빌드라면 거의 필수입니다.

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바이트 이하)
고르는 기준 — 분포를 보고 정합니다. 원소 수가 대부분 N 이하면 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));
서버에서 실제로 자주 새는 것들 — ① 끝나지 않는 태스크(수신자가 사라진 채널을 계속 기다림 · h02 의 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);
OOMKilled 대응 순서 — ① cgroup 기준으로 실제 최대치를 재고 ② dhat 으로 t-gmax 시점의 큰 항목을 찾고 ③ 그게 큐·캐시라면 상한을 걸고 ④ 그게 태스크당 버퍼라면 크기를 줄이거나 풀링(h02 의 Future 크기 · h05). 메모리 한도만 올리는 건 마지막 수단입니다.
정리 — 가장 싼 개선은 #[global_allocator] 로 mimalloc 을 얹는 것입니다(한 줄). 그다음이 용량 미리 잡기 · SmallVec · 버퍼 재사용으로 할당 횟수를 줄이는 것이고, “요청 동안만 사는” 객체가 많으면 아레나가 답입니다. 숫자로 확인할 때는 dhat 의 Total blocks(횟수) · t-gmax(최대) · t-end(누수) 세 개를 보세요.
SVC 11

벤치 · 프로파일 — flamegraph · tokio-console · PGO

⚡ e01 에서 criterion 으로 함수를 재는 법을 봤습니다. 여기서는 서비스 전체를 대상으로 합니다 — CPU 가 어디서 타는지(flamegraph), 태스크가 어디서 기다리는지(tokio-console), 그리고 코드를 안 고치고 얻는 LTO · PGO · BOLT 까지.

증상을 먼저 정하고 도구를 고릅니다 — 반대로 하면 시간만 씁니다 CPU 100% · 느림 계산이 문제 cargo flamegraph · perf record — 어느 함수가 태우는지 넓은 봉우리를 찾습니다. 상위 3개가 대개 80% 입니다 CPU 낮은데 느림 대기가 문제 tokio-console — 태스크별 busy / idle / poll 횟수 busy 가 긴 태스크 = 블로킹 · idle 이 긴 태스크 = 하류 대기 메모리가 문제 OOM · RSS 증가 dhat · heaptrack · jemalloc prof (h10) 할당 횟수 · 최대 사용 시점 · 끝나고 남은 것 일부 요청만 느림 p99 만 나쁨 tracing 스팬 + 히스토그램 (🧰 t08) 구간별로 쪼개면 어디서 튀는지 바로 보입니다 함수 하나를 A/B 비교할 때만 criterion · divan 을 씁니다 — 서비스 전체를 마이크로벤치로 이해하려 들지 마세요
도구는 증상마다 다릅니다 — “느리다”를 먼저 CPU · 대기 · 메모리 · 꼬리 중 하나로 좁히세요.

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 안에 있음
이 도구가 유일하게 답하는 질문 — “어떤 태스크가 워커를 붙잡고 있나”. flamegraph 는 함수를 보여 주지만 어느 태스크의 것인지는 모릅니다. 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
#   · 에러율 · 타임아웃 수
#   · 부하를 올렸을 때 '어디서 꺾이는지' (포화점)
Coordinated Omission 을 조심하세요 — 부하 도구가 응답을 기다렸다가 다음 요청을 보내면 서버가 멈춘 동안의 지연이 측정에서 빠집니다. 그래서 p99 가 실제보다 훨씬 좋게 나옵니다. 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
정리 — 증상을 먼저 CPU · 대기 · 메모리 · 꼬리 중 하나로 좁히고 도구를 고르세요. CPU 는 flamegraph, 비동기 대기는 tokio-console, 메모리는 dhat 입니다. 설정으로 얻는 이득은 lto="fat" + codegen-units=1 + 할당자 교체가 대부분이고, 부하 테스트는 반드시 고정 요청률 + p99 로 재세요.
SVC 12

임베디드 · WASM · 엣지 — 런타임이 없는 곳에서도 돕니다

Rust 에는 가비지 컬렉터도 런타임도 없습니다. 그래서 운영체제가 없는 마이크로컨트롤러, 브라우저·서버리스 WASM, 엣지 함수 처럼 다른 언어가 못 들어가는 곳에 그대로 들어갑니다. 이 섹션은 그 세 갈래의 현재 실제 상태와 시작하는 법을 정리합니다.

같은 언어가 KB 부터 GB 까지 — 표준 라이브러리 사용 범위만 달라집니다 no_std — 수십 KB MCU · 펌웨어 · 부트로더 core + alloc 만 (OS · 힙 선택) embassy · embedded-hal RP2040 · ESP32 · STM32 · nRF GC 가 없어 실시간성이 예측 가능 WASM — 수 MB 브라우저 · 엣지 · 플러그인 wasm32-unknown-unknown wasm32-wasip2 (시스템 접근) wasm-bindgen · workers-rs · Spin 시작 1ms 이하 — 콜드 스타트가 없음 서버 — GB 일반 std + tokio 이 탭의 h01~h11 전부 스레드 · 파일 · 네트워크 자유 축약 없이 생태계 전부 사용 가장 흔한 자리 경계에서 무엇이 사라지나 no_std 에서 없는 것 std::thread · std::fs · std::net · println! Vec · String 은 alloc 을 붙이면 사용 가능 패닉 처리기와 진입점을 직접 정의해야 함 WASM 에서 조심할 것 스레드 제한적 · 시간·난수는 호스트가 제공 usize 가 32비트 — 큰 인덱스 계산 주의 바이너리 크기가 곧 로딩 시간 (opt-level="z")
차이는 “무엇이 없느냐”일 뿐 언어·소유권·크레이트 대부분은 그대로입니다.

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 을 줍니다 ★
왜 임베디드에서 Rust 인가 — C 에서 가장 흔한 사고가 버퍼 넘침 · 널 포인터 · 인터럽트와 메인 루프의 데이터 레이스인데 셋 다 컴파일 단계에서 막힙니다. 특히 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 · 태스크 크기는 컴파일 타임에 확정됩니다
}
async 가 임베디드에 잘 맞는 이유 — 태스크가 대기할 때 CPU 를 재우고 인터럽트로 깨우기 때문에 전력 소모가 크게 줄어듭니다. RTOS 처럼 태스크마다 스택을 잡을 필요도 없습니다(상태 기계라서).

이렇게도 쓸 수 있다 — 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 WorkersV8 + WASM콜드 스타트 ≈ 0 · KV/D1/R2
Fastly ComputeWasmtime요청마다 새 인스턴스
Spin / SpinKubeWasmtime자체 호스팅 가능
AWS Lambda네이티브WASM 아님 — 그냥 바이너리
브라우저V8/SMwasm-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·펌웨어에서 껍데기만 바꿔 쓰는 것입니다.
SVC 13

실전 프로젝트 — 실시간 로그 수집·집계 서비스

지금까지의 조각을 하나로 잇습니다. TCP·UDP 로 로그를 받아(h06) · 채널과 액터로 흘리고(h03) · Parquet 로 배치 저장하고(h08) · Redis 에 실시간 카운터를 올리고(h07) · Axum 으로 조회 API 를 여는 서비스입니다. 마지막에 부하 테스트 수치와 튜닝 기록 · Docker 배포까지 남깁니다.

전체 구성 — 각 단계가 이 탭의 어느 섹션인지 함께 표시 클라이언트 TCP 프레임 UDP 패킷 수신 태스크 codec 으로 자르기만 h06 유계 채널 65536 가득 차면 버리고 셈 h03 백프레셔 파서 워커 N개 Bytes → Event h05 제로카피 집계 액터 (샤드 8) 1분 버킷 카운터 · p99 h03 액터 · h04 샤딩 배치 수집기 5만 건 또는 30초 h07 chunks_timeout Redis HINCRBY 파이프라인 · TTL 2시간 Parquet dt=날짜/hour=시 파티션 Axum 조회 API — 최근 2시간은 Redis 에서, 그 이전은 Parquet 스캔으로. 같은 응답 형태로 합쳐 돌려줍니다 (h08 · 🧰 t05)
수신은 받기만, 무거운 일은 전부 뒤에서 — 이 분리가 폭주에도 안 죽는 이유입니다.

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 를 넉넉히
다음으로 확장하기 — ① 수신을 Kafka 소비자로 바꾸면 재처리가 가능해집니다(h07), ② 조회를 DataFusion SQL 로 열면 임의 질의를 받을 수 있습니다(h08), ③ 노드를 늘릴 때는 서비스명 해시로 샤딩해 같은 키가 같은 노드로 가게 합니다(h03).
정리 — 고성능 서비스의 뼈대는 늘 같습니다 — 수신은 받기만 하고 · 유계 채널로 흘리고 · 무거운 일은 격리하고 · 배치로 저장합니다. 이 네 가지를 지키면 폭주해도 죽지 않고, 죽지 않으면 수치를 재서 하나씩 올릴 수 있습니다. 이 탭의 나머지 열두 섹션은 전부 그 “하나씩”의 목록이었습니다.
Tauri 2.x · 시스템 WebView · IPC · 플러그인 · 코드 서명 · 자동 업데이트 · egui · iced · Slint · Dioxus (2026-08 기준)

웹 화면은 그대로,
속은 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 웹 프런트까지 놓고, 언제 무엇을 골라야 하는지를 솔직하게 정리합니다. 이 탭을 마치면 서명된 설치 파일과 자동 업데이트가 붙은 데스크톱 앱을 혼자 배포할 수 있습니다.

13SECTIONS
Tauri 2기준
3~10MB배포 크기
100%복붙해서 도는 예제
TAURI 01

Tauri 가 무엇이고 왜 갑자기 다들 쓰나

한 줄로 말하면 “Electron 에서 Chromium 과 Node.js 를 빼고 그 자리에 OS 의 WebView 와 Rust 를 넣은 것” 입니다. 화면을 만드는 방법(HTML·CSS·JS)은 똑같으니 프런트 코드는 거의 그대로 옮겨 옵니다. 바뀌는 것은 배포 크기 · 메모리 · 뒷단 언어 · 보안 모델 네 가지이고, 이 섹션은 그 네 가지가 각각 얼마나 달라지는지를 숫자로 봅니다.

같은 화면, 다른 껍데기 — 배포물 안에 무엇이 들어가는가 Electron — 브라우저를 통째로 넣습니다 ① 내 HTML · CSS · JS ② Chromium 엔진 전체 ~120 MB 렌더러 프로세스 · V8 · Blink ③ Node.js 런타임 ~30 MB 앱 로직도 JS 로 씁니다 설치 파일 85~150 MB 실행 메모리 200 MB 이상 · 시작 1~3초 Tauri — OS 가 이미 가진 WebView 를 빌립니다 ① 내 HTML · CSS · JS (그대로) ② WebView 는 OS 것 — 0 MB Win: WebView2 · mac: WKWebView ③ 앱 로직은 Rust 네이티브 실행 파일 파일 · 스레드 · DB 를 직접 다룹니다 설치 파일 3~10 MB 실행 메모리 60~120 MB · 시작 0.2~0.6초 대신 치르는 대가 — 공짜가 아닙니다 WebView 버전이 OS 마다 다릅니다 — Chromium 을 들고 다니지 않으니 “이 CSS 가 맥에서만 깨진다”가 생깁니다 뒷단은 Rust 입니다 — Node 모듈을 그냥 require 할 수 없고, 필요하면 sidecar(u07) 로 따로 실어야 합니다
Tauri 가 작은 이유는 최적화가 아니라 브라우저를 안 들고 다니기 때문입니다 — 그 대가가 WebView 파편화입니다.

숫자로 보는 차이 — 같은 “할 일 목록” 앱 기준

ElectronTauri 2PySide6
설치 파일85~150 MB3~10 MB40~80 MB
메모리(idle)180~300 MB60~120 MB60~100 MB
시작 시간1~3초0.2~0.6초0.5~1.5초
화면 만드는 법HTML/CSS/JSHTML/CSS/JS위젯 · QML
뒷단 언어JS(Node)Rust파이썬
렌더링 일관성완전 동일OS 별 차이완전 동일
CPU 무거운 일느림네이티브 속도느림(GIL)
모바일불가Android · iOS사실상 불가
라이선스MITMIT / Apache-2.0LGPL / 상용
“설치 파일 3 MB” 의 진짜 의미 — 다운로드가 빨라지는 것보다, 업데이트를 자주 밀어 넣을 수 있게 된다는 쪽이 훨씬 큽니다. Electron 앱은 업데이트마다 100 MB 를 다시 받게 하기 미안해서 릴리즈를 모으게 되고, Tauri 는 버그 고친 날 바로 내보내도 사용자가 눈치채지 못합니다(u09 업데이터).

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 는 Rust 를 잘해야 쓴다”는 말은 절반만 맞습니다. 파일 읽기 · 대화상자 · HTTP · DB 는 전부 공식 플러그인이 JS API 로 제공합니다(u06). Rust 코드를 한 줄도 안 쓰고 앱 하나를 만들 수 있습니다. Rust 는 플러그인으로 안 되는 일을 할 때만 등장합니다.

이렇게도 쓸 수 있다 — Tauri 2 에서 무엇이 바뀌었나 🟡 중급

1.x 를 봤던 사람이 놀라는 네 가지

주제Tauri 1.xTauri 2.x
플랫폼데스크톱만+ Android · iOS
기능 켜기allowlist 통짜capabilities(ACL)
내장 API코어에 다 들어감플러그인으로 분리
창 타입WindowWebviewWindow
다중 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 안에서)
인터넷 예제의 절반은 아직 1.x 입니다 — 검색해서 나온 코드가 @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
가장 정직한 판단 기준 — “뒷단에서 무거운 일을 하는가” 하나입니다. 화면만 보여 주고 서버 API 만 호출하는 앱이라면 Tauri 의 이득은 용량과 메모리뿐이고, WebView 파편화라는 비용을 새로 집니다. 반대로 파일 수만 개를 훑거나, 이미지·영상을 변환하거나, 로컬 DB 를 쓰는 앱이라면 Tauri 는 압도적입니다 — 그 부분을 Rust 로 쓰면 수십 배 차이가 납니다(⚡ e01).

한 단계 더 — 보안 모델이 근본적으로 다릅니다 🧬 전문가

Electron 의 위험 지점과 Tauri 의 답

Electron 의 구조적 위험
  · nodeIntegration 을 켜면 렌더러에서 require('fs') 가 됩니다
    → XSS 하나가 곧바로 "임의 파일 읽기/쓰기"가 됩니다
  · contextIsolation + preload 로 막지만, 이건 '옵션'입니다
  · preload 에서 실수로 넓은 API 를 노출하면 원점

Tauri 의 구조
  · WebView 안에서는 fs 든 프로세스 실행이든 애초에 없습니다
  · Rust 쪽이 명시적으로 등록한 command 만 부를 수 있습니다
  · 그 command 조차 capabilities 파일이 허용해야 통과합니다 ★
  · 파일 접근은 scope(경로 패턴)까지 걸 수 있습니다 (u06)

즉 Electron 은 "기본 열림 → 잠근다",
   Tauri 는  "기본 닫힘 → 하나씩 연다" 입니다.

그래도 남는 위험 — 착각하지 말 것

Tauri 를 쓴다고 XSS 가 안전해지지 않습니다. 허용된 command 가 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(())
}
실무 원칙 — command 인자로 경로·명령어·SQL 을 받지 마세요. “무엇을 할지”만 받고 “어디에 할지”는 Rust 쪽이 정합니다. 이 원칙 하나가 u06 의 권한 설정보다 실제로 더 많은 사고를 막습니다.
정리 — Tauri 는 Electron 에서 Chromium·Node 를 빼고 OS WebView + Rust 를 넣은 것입니다. 설치 파일 100 MB → 5 MB, 메모리 3분의 1, 무거운 일은 네이티브 속도. 대가는 WebView 파편화와 Rust입니다. 뒷단에서 실제로 힘쓰는 일이 있는 앱이라면 고민 없이 Tauri, 화면만 보여 주는 앱이라면 이득이 크지 않습니다.
TAURI 02

설치 · 첫 앱 — 한 줄이면 창이 뜹니다

Tauri 의 준비물은 Rust 툴체인 + Node + OS 별 WebView 런타임 세 가지입니다. 리눅스만 조금 성가시고 나머지는 대부분 이미 깔려 있습니다. 이 섹션에서 OS 별 준비 → 프로젝트 생성 → 폴더 구조 이해 → tauri dev 로 창 띄우기까지 끝냅니다.

tauri dev 한 줄이 실제로 하는 일 — 네 단계 1 tauri dev tauri.conf.json 의 beforeDevCommand 와 devUrl 을 읽습니다 2 프런트 dev 서버 npm run dev 실행 Vite 가 1420 포트에 뜰 때까지 기다립니다 3 cargo build src-tauri 를 디버그로 컴파일합니다 첫 빌드 1~3분 ★ 4 창 + WebView 실행 파일이 창을 열고 WebView 가 1420 을 불러옵니다 고치면 어떻게 되나 — 두 갈래로 갈립니다 프런트 파일(.vue · .ts · .css) → dev 서버의 핫 리로드가 즉시 반영합니다. 앱을 끄지 않습니다 Rust 파일(.rs · Cargo.toml) → 다시 컴파일하고 앱을 재시작합니다 (수 초 ~ 수십 초) dev 와 build 가 보는 화면이 다릅니다 ★ dev = devUrl(개발 서버)를 봅니다 · build = frontendDist 폴더의 정적 파일이 실행 파일 안에 박혀 들어갑니다 — 배포본에는 서버가 없습니다
개발 중엔 웹 개발과 똑같이 돌아가고, 빌드하면 화면 파일이 실행 파일 안으로 들어갑니다.

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            # 환경 진단 ★ 문제 생기면 제일 먼저
identifier 를 신중히 — 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, 플러그인 버전 불일치까지 한 번에 보여 줍니다. 이슈를 올릴 때도 이 출력을 붙이면 답이 빨리 옵니다.
정리 — 준비물은 Rust + Node + OS WebView 뿐이고, 리눅스만 webkit2gtk-4.1 을 챙기면 됩니다. npm create tauri-app@latest → npm run tauri dev 두 줄로 창이 뜹니다. 프런트는 src/, 앱은 src-tauri/ 이고 실제 코드는 lib.rs 에 씁니다. identifier 는 처음에 제대로 정하세요 — 나중에 바꾸면 사용자 데이터를 잃습니다.
TAURI 03

Commands · IPC — JS 에서 Rust 함수를 부르는 법

Tauri 앱의 거의 모든 코드는 결국 이 한 줄로 수렴합니다 — await invoke("함수이름", { 인자 }). Rust 함수 위에 #[tauri::command] 를 붙이고 등록해 두면, 프런트에서 비동기 함수처럼 부를 수 있습니다. 인자와 반환값은 serde 가 JSON 으로 자동 변환합니다. 이 섹션에서 동기·비동기·에러·스트리밍 네 가지 형태를 전부 봅니다.

invoke 한 번의 왕복 — 여섯 단계 WebView · JavaScript Rust core 1 invoke("scan_dir", { rootPath: "D:/data" }) → Promise 를 돌려받고 기다립니다 2 generate_handler! 로 등록된 목록에서 "scan_dir" 이름을 찾습니다 3 serde 역직렬화 — JS 의 rootPath 가 Rust 의 root_path: String 으로 4 함수 실행 — async 면 별도 태스크에서 동기 함수는 메인 스레드를 막습니다 ★ 5 Result<T, E> 를 다시 JSON 으로 직렬화합니다 (T: Serialize 필요) 6 Ok → Promise resolve (값) Err → Promise reject (throw) 이름을 틀리면 Command scan_dir not found — 오타는 런타임에야 드러납니다. u08 의 타입 생성으로 막습니다 반환 타입에 Serialize 가 없으면 컴파일 자체가 실패합니다 — 이건 오히려 다행입니다
IPC 는 결국 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()
}
규칙 하나만 외우세요 — 16 ms 안에 못 끝나는 일은 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 })   // 키 이름 주의 ★
Channel vs 이벤트(u04) — 둘 다 Rust → JS 단방향 푸시입니다. Channel 은 “이 호출에 딸린 스트림”이라 호출자만 받고 자동으로 정리됩니다. 이벤트는 앱 전역 방송이라 아무나 구독합니다. 진행률·로그 스트림은 Channel, “설정이 바뀌었다” 같은 알림은 이벤트가 맞습니다.
IPC 는 공짜가 아닙니다 — 메시지 하나마다 JSON 직렬화 + WebView 경계 통과가 일어납니다. 초당 수천 건을 보내면 UI 가 오히려 느려집니다. 100건마다 · 또는 50 ms 마다 로 묶어서 보내세요. 큰 바이너리는 JSON 대신 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(())
}
이 인자들은 JS 에서 넘기지 않습니다 — Tauri 가 알아서 채웁니다. 그래서 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"]
  }
}
dev 에서는 되는데 build 하면 파일을 못 찾는다 — 거의 항상 이 문제입니다. 개발 중엔 현재 작업 폴더가 프로젝트 루트라 ./assets/x.json 이 그냥 열립니다. 배포본에서는 작업 폴더가 어디일지 모릅니다. 파일 경로는 반드시 app.path() 를 거치세요.
정리 — #[tauri::command] + generate_handler! 등록 + invoke() 세 조각이 전부입니다. 16 ms 넘는 일은 async, CPU 쓰는 일은 spawn_blocking. 에러는 thiserror + Serialize 로 타입화하고, 진행률은 Channel<T> 로 흘려보내되 묶어서 보냅니다. 파일 경로는 언제나 app.path() 로 얻으세요.
TAURI 04

상태 · 이벤트 — 앱이 기억하고 서로에게 알립니다

command 는 부를 때마다 새로 실행되는 함수입니다. DB 연결 · 설정 · 캐시처럼 앱이 살아 있는 내내 유지할 것은 manage() 로 Tauri 에 맡깁니다. 그리고 Rust 쪽에서 먼저 말을 걸어야 할 때(파일이 바뀌었다, 다운로드가 끝났다)는 이벤트를 씁니다. 이 둘을 알면 “화면이 요청할 때만 도는 앱”에서 “스스로 움직이는 앱”으로 넘어갑니다.

상태는 하나, 이벤트는 방송 — 창이 여러 개여도 코어는 하나입니다 WebView — main 창 invoke('add_item') · listen('items-changed', fn) WebView — settings 창 invoke('save_config') · listen('items-changed', fn) invoke emit 이벤트 버스 app.emit("items-changed", payload) → 열려 있는 모든 창의 listen 콜백 · 한 창만 원하면 app.emit_to("settings", …) Rust core — 앱 하나에 상태도 하나 app.manage(AppState) 타입당 딱 하나 Mutex<Inner> / RwLock 동시 접근을 막습니다 DB 풀 · 설정 · 캐시 여기 담습니다 주의 — 락(lock)을 잡은 채 .await 를 하면 다른 command 가 전부 멈춥니다. 잠금 구간은 최대한 짧게 끊으세요
command 는 매번 새로 도는 함수, 상태는 앱이 사는 동안 하나, 이벤트는 전체 방송입니다.

상태 만들기 — 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(())
}
어느 Mutex 를 쓰나 — 기본은 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 문자열입니다
});
이벤트를 상태 동기화에 쓰지 마세요 — 이벤트는 “놓칠 수 있는 알림” 입니다. 창이 아직 안 떴거나, 리스너 등록 전이면 그 이벤트는 그냥 사라집니다. “무엇이 바뀌었다”만 알리고, 실제 데이터는 JS 가 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 05

창 · 메뉴 · 트레이 — 웹 페이지가 아니라 앱처럼 보이게

브라우저 탭과 데스크톱 앱을 가르는 것은 결국 여러 개의 창 · 네이티브 메뉴 · 시스템 트레이 · 전역 단축키 · 파일 드롭 입니다. 이것들은 웹 기술로는 흉내조차 못 냅니다. Tauri 는 전부 Rust API 로 제공하고, 일부는 JS 에서도 부를 수 있습니다. 이 섹션에서 “백그라운드에 상주하다가 단축키로 튀어나오는 앱” 을 만드는 데 필요한 조각을 모읍니다.

어디에 붙는가 — 층이 다르면 살아 있는 시간도 다릅니다 ① OS 층 — 창이 없어도 살아 있습니다 시스템 트레이 아이콘 + 우클릭 메뉴 · 전역 단축키(Ctrl+Shift+Space) 데스크톱 알림 · 자동 시작 · 단일 인스턴스 잠금 ② 창 층 — 창마다 하나씩 WebviewWindow: 제목 · 크기 · 항상 위 · 투명 · 장식 없음 창 메뉴바(Windows·Linux) · 파일 드롭 · 크기 기억 ③ WebView 층 — 여기부터는 그냥 웹 HTML/CSS 로 만든 사이드바 · 툴바 · 컨텍스트 메뉴 모양은 자유지만 앱이 꺼지면 같이 사라집니다 어디에 만들까 창이 닫혀 있어도 반응해야 하나? → ① OS 층 창마다 달라야 하나? → ② 창 층 모양을 자유롭게 만들고 싶은가? → ③ WebView 층 macOS 는 메뉴가 앱 전역입니다 ★ 플랫폼 차이 — Windows·Linux 는 메뉴가 창에 붙고, macOS 는 화면 맨 위 한 곳에 붙습니다. 같은 코드로 두 모양이 나옵니다
“앱 같다”는 느낌의 대부분은 OS 층에서 나옵니다 — 트레이와 전역 단축키가 그 핵심입니다.

창을 코드로 만들기 — 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(); }
        _ => {}
    })
macOS 에서 Ctrl+C 가 안 된다는 제보 — 메뉴를 직접 만들면 기본 편집 메뉴가 사라집니다. macOS 는 복사·붙여넣기 단축키가 메뉴 항목에 묶여 있어서, 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 06

플러그인 · 권한 — 기본은 전부 잠겨 있습니다

Tauri 2 에서는 파일 읽기 · 대화상자 · HTTP · DB 가 전부 플러그인으로 나뉘어 있고, 플러그인을 깔았다고 바로 쓸 수 있는 것도 아닙니다. capabilities/*.json 에 “이 창은 이 기능을 써도 된다”고 적어야 통과합니다. 처음엔 번거롭지만, 이 구조 덕분에 “XSS 가 곧 파일 삭제”가 되지 않습니다. 이 섹션에서 공식 플러그인 지도 · ACL 읽는 법 · scope 걸기 · 직접 플러그인 만들기를 다룹니다.

권한은 네 번 좁혀집니다 — 하나라도 막히면 그 자리에서 거부 ① JS 호출 — readTextFile('C:/secret/pw.txt') 플러그인 JS 래퍼가 내부적으로 invoke 를 부릅니다 ② 이 창이 대상인가 — capability 의 windows 목록 "windows": ["main"] 인데 settings 창이 불렀다면 거부 ③ 그 명령을 허용하는 permission 이 있나 "fs:allow-read-text-file" 이 없으면 거부 ④ scope — 경로 패턴 안인가 allow: $APPDATA/** 만 열어 뒀다면 C:/secret 은 거부 모두 통과 → Rust 구현이 실제로 파일을 읽습니다 capabilities/default.json { "identifier": "default", "windows": ["main"], "permissions": [ "core:default", "dialog:allow-open", "fs:allow-read-text-file", { "identifier": "fs:scope", "allow": [ "$APPDATA/**" ] } ] }
Electron 은 열려 있는 것을 잠그고, Tauri 는 잠긴 것을 하나씩 엽니다 — 그래서 처음이 귀찮습니다.

공식 플러그인 지도 — 자주 쓰는 것부터

플러그인하는 일
fs파일 읽기·쓰기·감시(scope 필수)
dialog파일 열기·저장·확인 창
openerURL·파일을 기본 앱으로 열기
shell외부 명령·sidecar 실행 (u07)
httpCORS 없는 HTTP 요청 ★
store키-값 설정 저장(JSON)
sqlSQLite · MySQL · Postgres
updater자동 업데이트 (u09)
notification데스크톱 알림
clipboard-manager클립보드 읽기·쓰기
global-shortcut전역 단축키 (u05)
window-state창 크기·위치 기억
single-instance중복 실행 방지
log파일·콘솔 로깅
os · processOS 정보 · 재시작·종료
deep-linkmyapp:// 프로토콜 처리
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/**"] }
  ]
}
“왜 아무 일도 안 일어나지?” 의 90% — ③-2 를 빼먹은 것입니다. 콘솔에 fs.readTextFile not allowed. Permissions associated with this command: fs:allow-read-text-file 처럼 필요한 permission 이름까지 정확히 찍혀 나옵니다. 그 문자열을 그대로 복사해 permissions 배열에 붙이면 끝입니다.

이렇게도 쓸 수 있다 — permission · scope · 플랫폼별 capability 🟡 중급

용어 세 개만 정리하면 다 읽힙니다

용어뜻
permission“이 command 를 부를 수 있다” 한 장
scopepermission 에 붙는 범위 제한(경로·URL)
permission setpermission 여러 장 묶음 (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 자동완성 ★)
  · 실행 중 에러 메시지에 그대로 나옵니다
IDE 자동완성을 켜세요 — capability 파일 맨 위의 "$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)
외부 URL 을 창에 띄운다면 반드시 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' })   ★ 이름 규칙
정리 — 플러그인은 cargo add + npm i + .plugin() 등록 + capability 권한 네 곳을 항상 같이 고칩니다. 막히면 에러 메시지에 찍힌 permission 이름을 그대로 복사해 넣으세요. 파일·네트워크는 반드시 scope 로 범위를 좁히고, 외부 URL 창에는 capability 를 주지 마세요. 플러그인 자작은 재사용·생명주기·네이티브 코드 셋 중 하나가 있을 때만 값어치가 있습니다.
TAURI 07

Sidecar — FFmpeg · 파이썬을 앱에 실어 보냅니다

Rust 로 다시 짜기엔 너무 큰 것들이 있습니다 — FFmpeg · yt-dlp · 파이썬 머신러닝 스크립트 · 사내 CLI 도구. Tauri 는 이런 외부 실행 파일을 설치본에 함께 넣고, 앱에서 실행하고, 출력을 실시간으로 받아오는 기능을 정식으로 제공합니다. 이것을 sidecar 라고 부릅니다. 파일 이름 규칙 하나만 지키면 크로스 플랫폼도 자동으로 처리됩니다.

sidecar — 외부 실행 파일을 앱에 싣고, 파이프로 대화합니다 설치본 안 myapp.exe binaries/ ffmpeg-x86_64- pc-windows-msvc.exe 빌드 시 이 플랫폼 것만 Rust core app.shell().sidecar("ffmpeg") .args([...]).spawn() stdin → 명령 보내기 stdout → 한 줄씩 받기 stderr → 진행률·에러 WebView Channel 로 흘려보내 진행률 막대로 그림 frame= 1204 fps=58 → 60% 완료 함정 — 자식 프로세스는 앱과 같이 죽지 않습니다 창을 닫아도 ffmpeg 가 CPU 를 계속 먹습니다. 종료 시 child.kill() 을 반드시 부르고, 강제 종료까지 대비해 pid 를 기록해 두세요 언제 sidecar 대신 Rust 로 짜나 crate 가 이미 있으면(image · zip · csv · rusqlite) Rust 가 낫습니다 — 용량 수십 MB 와 백신 오탐, 서명 문제가 전부 사라집니다
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" ]
Rust 에서만 실행할 거면 JS 권한은 필요 없습니다 — 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
SQLiteRust rusqlite
PDF 생성Rust printpdf
영상 트랜스코딩sidecar FFmpeg
유튜브 다운로드sidecar yt-dlp
딥러닝 추론sidecar 또는 ort(ONNX)
사내 레거시 CLIsidecar
OCRsidecar Tesseract
ONNX Runtime 을 기억해 두세요 — 파이썬으로 학습한 모델을 .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
FFmpeg 라이선스 — 배포되는 FFmpeg 빌드 중 상당수가 GPL 입니다. 상용 클로즈드 소스 앱에 GPL 빌드를 번들하면 소스 공개 의무가 생깁니다. LGPL 빌드를 쓰거나(x264 등 GPL 코덱 제외), 사용자가 직접 설치한 것을 찾아 쓰는 방식으로 가세요.
정리 — sidecar 는 binaries/이름-타깃트리플 + externalBin + app.shell().sidecar() 세 조각입니다. 출력은 CommandEvent 로 한 줄씩 받아 Channel 로 흘려보내고, 종료 시 kill() 을 반드시 부르세요. 다만 Rust crate 로 되는 일이면 그쪽이 거의 언제나 낫습니다 — 용량·서명·백신 문제가 통째로 사라집니다.
TAURI 08

프런트엔드 연결 — 문자열 invoke 를 없앱니다

invoke('scan_dir', { rootPath }) 는 편하지만 전부 문자열입니다. 명령 이름을 오타 내도, 인자를 빠뜨려도, 반환 타입을 잘못 적어도 실행해 봐야 압니다. Rust 쪽 시그니처를 바꿨을 때 프런트가 조용히 깨지는 것이 Tauri 프로젝트에서 가장 흔한 버그입니다. 이 섹션은 수동 래퍼 → tauri-specta 자동 생성까지, 그 구멍을 막는 방법을 다룹니다.

Rust 시그니처에서 TS 타입까지 — 손으로 맞추지 않습니다 1 Rust 명령 #[tauri::command] #[specta::specta] fn scan_dir(...) 2 Builder 에 모음 collect_commands![ scan_dir, save, ] 3 파일 생성 dev 빌드 · cargo test → src/bindings.ts 타입까지 전부 4 프런트 import { commands } commands.scanDir(p) 자동완성 · 타입검사 문자열 invoke — 실행해 봐야 압니다 invoke('scan_dirr', { rootPat: p }) · 명령 이름 오타 → 런타임 에러 · 인자 이름 오타 → 런타임 에러 · 반환 타입은 내가 적은 대로 믿음 · Rust 를 고쳐도 프런트는 모릅니다 생성된 래퍼 — 빌드 때 잡힙니다 commands.scanDir(p) · 이름·인자·반환 타입 전부 검사 · 에디터 자동완성 · 정의로 이동 · Rust 시그니처를 바꾸면 tsc 가 빨간 줄 · Result 타입도 그대로 넘어옵니다
Tauri 프로젝트에서 가장 흔한 버그는 Rust 를 고치고 프런트를 안 고친 것입니다 — 타입 생성이 이걸 막습니다.

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 번들의 차이 🔴 실무

배포본에서만 깨지는 것들 — 원인은 거의 정해져 있습니다

증상원인
흰 화면 · 404base 가 / — "./" 로
이미지·폰트 안 뜸절대 경로 사용 · public/ 참조
환경변수 undefinedVITE_ 접두사 누락
동적 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 09

빌드 · 서명 · 업데이트 — 사용자에게 실제로 닿게 합니다

tauri build 한 줄이면 설치 파일이 나옵니다. 문제는 그 다음입니다 — “이 앱은 신뢰할 수 없습니다” 경고 · macOS 의 “손상되었습니다” · 업데이트를 어떻게 밀어 넣나. 이 셋을 해결하지 않으면 앱이 만들어져도 아무도 쓰지 못합니다. 이 섹션은 산출물 종류 · 아이콘 · 코드 서명 · updater 플러그인 · GitHub Actions 까지 배포 전 구간을 다룹니다.

tauri build 가 만드는 것 — 플랫폼마다 다릅니다 Windows myapp.exe (단독 실행) myapp_1.0.0_x64_en-US.msi myapp_1.0.0_x64-setup.exe 서명: signtool · Azure Trusted Signing 서명 없으면 SmartScreen "알 수 없는 게시자" 경고 macOS My App.app (번들) My App_1.0.0_aarch64.dmg 서명: Developer ID + notarize(공증) 필수 공증 없으면 "손상되어 열 수 없습니다" Intel · ARM 따로 또는 universal Linux myapp_1.0.0_amd64.deb myapp-1.0.0-1.x86_64.rpm myapp_1.0.0_amd64.AppImage 서명 대신 배포판 호환이 문제입니다 Ubuntu 24.04 에서 만든 deb 는 22.04 에서 안 돕니다 크로스 컴파일은 사실상 불가능합니다 Windows 에서 dmg 를, macOS 에서 msi 를 만들 수 없습니다 — 각 OS 의 네이티브 툴체인과 WebView 헤더가 필요하기 때문입니다 정답은 CI 매트릭스 — GitHub Actions 에서 windows·macos·ubuntu 러너를 동시에 돌립니다 (tauri-action)
앱을 만드는 것보다 서명과 공증이 더 오래 걸립니다 — 처음부터 일정에 넣어 두세요.

빌드 · 아이콘 · 크기 줄이기

# 아이콘 — 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분 걸립니다 ★
Apple Developer Program 연 $99 는 피할 수 없습니다. 서명·공증 없이 배포하면 사용자가 “앱이 손상되었습니다” 를 보고, 우클릭 → 열기 같은 우회 방법을 안내해야 합니다 — 일반 사용자에게는 사실상 배포 실패입니다. sidecar 바이너리도 함께 서명되어야 통과합니다(u07).

이렇게도 쓸 수 있다 — updater 로 자동 업데이트 붙이기 🟡 중급

자동 업데이트 — 서명 검증이 핵심입니다 1 tauri signer generate — 키 쌍 생성 공개 키 → tauri.conf.json · 비밀 키 → CI 시크릿 2 tauri build — 설치 파일 + .sig 서명 파일 createUpdaterArtifacts 를 켜야 생성됩니다 ★ 3 GitHub Releases 에 업로드 latest.json (버전 · 주소 · 서명) 도 함께 4 실행 중인 앱이 latest.json 조회 현재 버전과 비교 (semver) 5 설치 파일 다운로드 → 공개 키로 서명 검증 검증 실패 시 설치 중단 6 설치 후 relaunch() — 앱 재시작 Windows 는 설치 관리자가 잠깐 뜹니다 비밀 키를 잃어버리면 되돌릴 수 없습니다 공개 키를 바꾸면 기존 사용자의 앱은 새 서명을 못 믿습니다 — 전원이 수동 재설치를 해야 합니다. 키는 반드시 안전한 곳에 백업하세요
업데이트가 서명으로 잠겨 있어서 중간자가 가짜 업데이트를 밀어 넣을 수 없습니다.

설정 — 다섯 단계

# ① 키 쌍 생성 (비밀번호를 걸면 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"] }
리눅스는 AppImage 만 자동 업데이트가 됩니다 — .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"
“깨끗한 VM 에서 확인”을 절대 건너뛰지 마세요 — 개발 PC 에는 Visual C++ 재배포 패키지 · WebView2 · 각종 DLL 이 이미 깔려 있습니다. 사용자 PC 에 없는 것을 놓치면 “실행하면 아무 일도 안 일어난다” 는 제보를 받게 됩니다. Windows Sandbox 나 새 VM 이미지를 한 번 돌리는 것으로 끝나는 일입니다.
정리 — tauri icon 으로 아이콘, tauri build 로 설치 파일, Windows 는 인증서 · macOS 는 서명 + 공증이 있어야 사용자에게 닿습니다. 자동 업데이트는 키 쌍 생성 → createUpdaterArtifacts → latest.json → check()+relaunch() 네 단계이고, 비밀 키는 반드시 백업하세요. 4 플랫폼 빌드는 tauri-action 매트릭스가 정답입니다.
TAURI 10

모바일 — 같은 코드로 Android · iOS 까지

Tauri 2 의 가장 큰 변화는 모바일입니다. tauri android init 한 줄이면 지금 만든 데스크톱 앱이 그대로 안드로이드 앱이 됩니다 — 프런트도, command 도, 상태도 같은 코드입니다. 다만 세팅이 무겁고(Android Studio · NDK · Xcode), 데스크톱 전용 기능은 못 씁니다. 기대치를 정확히 맞추는 것이 이 섹션의 목표입니다.

한 코드베이스, 네 플랫폼 — 무엇이 공유되고 무엇이 갈리나 공유되는 것 — 코드의 대부분 프런트엔드 화면(Vue/React) · #[tauri::command] 함수 · manage 한 상태 · 이벤트 · SQLite 데이터 계층 Windows WebView2 msi · nsis macOS · Linux WKWebView · webkit2gtk dmg · deb · AppImage Android Kotlin 껍데기 + 시스템 WebView · apk/aab iOS Swift 껍데기 + WKWebView · ipa 모바일에서 못 쓰는 것 트레이 아이콘 · 다중 창 · 네이티브 메뉴바 전역 단축키 · sidecar 외부 실행 파일 임의 경로 파일 접근(샌드박스) 모바일에서만 쓰는 것 barcode-scanner · biometric(지문·페이스) haptics(진동) · nfc · geolocation deep-link · 푸시 알림(별도 연동)
코드는 대부분 공유되지만 UI 와 OS 기능은 갈립니다 — #[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" }
  }
}
모바일 WebView 는 최신입니다 — 안드로이드는 Play 스토어로 갱신되는 Android System WebView(Chromium 기반), iOS 는 WKWebView(Safari 엔진). 데스크톱 리눅스의 webkit2gtk 보다 오히려 신문법 지원이 좋습니다. 실제로 가장 오래된 엔진은 리눅스이니, CSS 하한선은 그쪽에 맞추면 됩니다.

파일과 저장 — 샌드박스를 전제로 설계합니다

// ❌ 모바일에서 이런 코드는 통하지 않습니다
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)      // 이 파일만 열립니다
}
SQLite 경로에 주의 — tauri-plugin-sql 은 기본적으로 앱 데이터 폴더 안에 DB 를 만듭니다. 데스크톱 코드에서 "sqlite:./app.db" 처럼 상대 경로를 썼다면 모바일에서 쓰기 권한이 없어 실패합니다. 처음부터 app_data_dir() 기준 절대 경로로 만드세요.

실무에선 이렇게 — 모바일까지 갈지 말지의 판단 🔴 실무

Tauri 모바일 vs 다른 선택지

Tauri 2React NativeFlutter
UIWebView네이티브 위젯자체 렌더
데스크톱1급부가지원
생태계작음매우 큼큼
앱 크기작음중간중간
네이티브 API플러그인 적음풍부풍부
성능(무거운 계산)RustJS 브릿지Dart
스크롤·제스처 느낌웹 수준네이티브네이티브
솔직한 판단 — “모바일이 주력”이면 Tauri 는 아직 1순위가 아닙니다. 푸시 알림 · 인앱 결제 · 지도 SDK 같은 모바일 필수 연동의 플러그인이 얇습니다. Tauri 모바일이 빛나는 자리는 “데스크톱이 주력인데 모바일 뷰어도 있으면 좋겠다” 는 경우입니다 — 그때는 추가 비용이 거의 0 입니다.

모바일에서 막힐 때 보는 곳

# 안드로이드 로그 — 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 가 안전합니다.
TAURI 11

Rust 네이티브 GUI — egui · iced · Slint · Dioxus 비교

WebView 를 아예 쓰지 않고 Rust 만으로 화면을 그리는 길도 있습니다. HTML·CSS·JS 를 하나도 안 쓰고, 프런트 빌드 도구도 없고, 바이너리 하나로 끝나며, WebView 파편화가 없습니다. 대신 디자인 자유도와 생태계가 웹과 비교가 안 됩니다. 이 섹션에서 다섯 갈래를 각각 30줄 예제로 보고, 언제 Tauri 대신 이쪽을 골라야 하는지 정리합니다.

무엇으로 그릴까 — 위에서부터 답하면 됩니다 ① 디자이너가 만든 정교한 화면 · 애니메이션이 필요한가? 예 → Tauri (웹 기술) ② 개발자용 도구 · 매 프레임 다시 그리는 화면인가? 예 → egui (즉시 모드) ③ 상태가 복잡한 일반 데스크톱 앱인가? 예 → iced (Elm 구조) ④ 임베디드 기기 · 디자이너 도구 연동이 필요한가? 예 → Slint (.slint DSL) ⑤ 리액트 방식 + 웹·모바일까지 한 코드로 → Dioxus
첫 질문에서 대부분 Tauri 로 빠집니다 — 네이티브 GUI 는 화면보다 배포 단순함이 중요할 때의 선택입니다.

다섯 갈래 한눈에

방식강점약점
egui즉시 모드가장 쉬움 · 붙이기 쉬움디자인 자유도 낮음
icedElm(MVU)구조가 깔끔 · 정적 타입위젯 부족 · API 변동
Slint선언형 DSL디자이너 도구 · 임베디드상용 라이선스 조건
DioxusReact 식 VDOM웹·데스크톱·모바일데스크톱은 WebView 기반
GPUIGPU 직접매우 빠름(Zed)문서 얇음 · 사실상 내부용
TauriWebView생태계 · 디자인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()))))
}
egui 의 매력은 “콜백이 없다” 입니다 — 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)
}
iced 의 장점은 “상태가 바뀌는 곳이 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()
}
Slint 라이선스를 먼저 확인하세요 — GPL, 로열티 프리 상용, 유료 상용의 세 갈래로 제공됩니다. 오픈소스가 아닌 상용 제품에 쓸 때 조건이 붙습니다. 임베디드(라즈베리파이 · MCU)에서 Rust GUI 를 돌려야 한다면 사실상 유일한 선택이라 그 값어치는 충분합니다.

실무에선 이렇게 — 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
내부 도구 · 디버그 UIegui
실시간 그래프 · 시각화egui (+ egui_plot)
구조를 오래 유지할 앱iced
임베디드 · 키오스크Slint
웹·데스크톱·모바일 한 코드Dioxus
게임 · 3Dbevy · wgpu
단일 실행 파일이 절대 조건egui (5~10 MB)
네이티브 GUI 의 진짜 이점은 “빌드가 하나” 입니다 — cargo build --release 하나로 끝이고, Node·npm·Vite·WebView 런타임이 전부 없습니다. CI 도 단순해지고, 리눅스 webkit2gtk 버전 지옥도 없습니다.
한글 폰트를 잊지 마세요 — egui·iced 는 기본 폰트에 한글 글리프가 없어 네모(두부)로 나옵니다. 나눔고딕·Pretendard 같은 폰트를 include_bytes! 로 바이너리에 넣고 등록해야 합니다. 이걸 모르고 “Rust GUI 는 한글이 안 된다”고 오해하는 경우가 많습니다.
정리 — 사람에게 파는 앱이면 거의 언제나 Tauri 입니다 — 디자인과 생태계가 압도적입니다. 내부 도구·실시간 시각화는 egui(가장 쉽고 빠릅니다), 오래 유지할 구조는 iced, 임베디드는 Slint, 웹·모바일까지 한 코드면 Dioxus. 네이티브 GUI 의 진짜 이점은 화면이 아니라 “빌드가 cargo build 하나” 라는 단순함입니다.
TAURI 12

Rust 로 웹 프런트 — Leptos · Dioxus · Yew, 그리고 솔직한 판단

Rust 코드를 WASM 으로 컴파일해 브라우저에서 돌리는 프레임워크들이 있습니다. Leptos · Dioxus · Yew 가 대표적이고, 셋 다 실제로 동작하며 성능도 좋습니다. Tauri 와 합치면 프런트부터 백엔드까지 전부 Rust 가 됩니다. 이 섹션에서 어떻게 만드는지와 함께 “그래서 이걸 써야 하나”에 대한 솔직한 답을 드립니다.

Rust 프런트가 브라우저에서 돌기까지 Rust 소스 view! / rsx! 로 화면을 씁니다 wasm32 빌드 cargo build --target wasm32 wasm-bindgen JS 접착 코드 생성 DOM 을 부를 수 있게 trunk · dx html · css 와 함께 묶기 WebView 또는 브라우저 가 로드 현실적인 비용 번들이 큽니다 — gzip 후에도 보통 200~600 KB 첫 로딩에 WASM 파싱·인스턴스화 시간이 듭니다 컴포넌트 라이브러리가 사실상 없습니다 DOM 조작마다 JS 경계를 넘습니다 막혔을 때 검색해도 답이 잘 안 나옵니다 그래도 맞는 자리 계산이 무거운 화면 — 이미지 편집 · 시뮬레이션 같은 로직을 서버와 프런트가 공유할 때 팀 전체가 Rust 만 쓰는 소규모 내부 도구 타입 안전을 끝까지 끌고 가고 싶을 때 Leptos 는 서버 함수·SSR 까지 갖췄습니다
기술적으로는 훌륭합니다 — 문제는 생태계와 사람입니다.

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"
Leptos 는 Solid.js 와 같은 계열입니다 — 가상 DOM 이 아니라 시그널이 바뀐 그 DOM 노드만 갱신합니다. 그래서 런타임 성능은 최상위권입니다. SSR · 서버 함수(#[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>
    }
}
LeptosDioxusYew
모델시그널VDOMVDOM
성능최상좋음보통
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" 가 거의 언제나 이깁니다.
이 판단은 언젠가 바뀔 수 있습니다 — Leptos·Dioxus 는 빠르게 좋아지고 있고, 컴포넌트 생태계도 조금씩 쌓이고 있습니다. 지금 시점(2026 년 중반)의 판단이라는 점만 기억하시고, 새 프로젝트를 시작할 때 다시 확인해 보세요.
정리 — Rust 웹 프런트는 기술적으로 훌륭하지만 생태계가 얇습니다. Leptos(성능·SSR) · Dioxus(React 식·멀티 플랫폼) · Yew(가장 오래됨) 순으로 보면 됩니다. 현실적인 정답은 “화면은 TypeScript, 무거운 계산만 Rust” 입니다 — Tauri 에서는 그게 이미 #[tauri::command] 로 자연스럽게 됩니다.
TAURI 13

실전 — 로컬 파일 검색 앱을 처음부터 배포까지

앞의 12개 섹션을 한 프로젝트로 엮습니다. 만들 것은 “내 PC 의 파일을 훑어 색인하고, 이름·경로로 즉시 검색하고, 중복 파일을 찾아 주는 앱” 입니다. Windows 검색이 느려서 다들 한 번쯤 만들고 싶어 하는 그 도구입니다. Vue 3 + Tauri 2 + rayon + SQLite(FTS5) 로 만들고, 트레이 상주 · 전역 단축키 · 자동 업데이트 · 코드 서명까지 붙입니다. 폴더 구조와 핵심 코드를 전부 싣습니다.

앱 구조 한 장 — 화면 · IPC · 코어 · 저장소 ① Vue 화면 검색창(디바운스 150ms) · 결과 가상 스크롤 · 진행률 막대 · 중복 그룹 보기 ② IPC invoke: start_index · search · find_dupes · cancel | Channel: 진행률 스트림 ③ Rust 코어 수집 — walkdir ignore 규칙 적용 병렬 — rayon 크기 · blake3 해시 기록 — 배치 삽입 1000건 묶어 트랜잭션 ④ SQLite (앱 데이터 폴더) files 표 + files_fts 전문검색 가상 표 · 이름 검색은 FTS5, 중복은 해시 GROUP BY 상주 기능 트레이 아이콘 좌클릭 → 창 토글 전역 단축키 Ctrl+Shift+Space 닫기 → 숨김 (prevent_close) 창 크기 기억 window-state 자동 업데이트 시작 후 5초에 확인 단일 인스턴스 잠금
무거운 일은 전부 ③ 코어에서 끝나고, 화면은 결과만 받아 그립니다 — 이것이 Tauri 를 쓰는 이유입니다.

폴더 구조와 의존성

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())
}
FTS5 질의에 사용자 입력을 그대로 넣지 마세요 — 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 크레이트)로 하고, 영구 삭제는 명시적 확인을 받으세요. 그리고 같은 그룹의 마지막 한 개는 절대 지우지 못하게 코드로 막아야 합니다 — 이 실수 하나로 앱 전체의 신뢰가 무너집니다.
정리 — 실전 Tauri 앱의 뼈대는 언제나 같습니다 — 무거운 일은 spawn_blocking + rayon 으로 코어에서, 진행률은 Channel 로, 결과는 SQLite 에. 화면에는 파일 권한을 주지 않고 command 안에서만 파일을 만집니다. 마지막에 트레이 · 전역 단축키 · prevent_close · 업데이터 · 서명 다섯 개를 붙이면 사람들이 실제로 쓰는 앱이 됩니다.
jni 0.21 · Panama FFM (Java 25 LTS) · UniFFI · cargo-ndk · JNA 5.x

Rust 로 만든 코드를
자바·코틀린에서
그냥 부릅니다

JVM 은 느려서 문제인 언어가 아닙니다. 다만 GC 가 멈추는 순간과 이미지·암호·압축처럼 네이티브가 훨씬 나은 작업이 있을 뿐입니다. 그 자리만 Rust 로 갈아 끼우는 길이 네 가지 있습니다 — jni 크레이트 · Panama FFM · UniFFI · JNA. 이 탭은 넷을 전부 실제로 돌려 보고, 어느 상황에 무엇을 고르는지를 숫자로 정리합니다. 끝나면 Spring Boot 엔드포인트와 안드로이드 앱에서 같은 Rust 코어를 부르는 것까지 갑니다.

12SECTIONS
4연결 방법
Java 25LTS 기준
100%복붙해서 도는 예제
JVM 01

네 가지 길 — 무엇을 고를지부터 정합니다

자바에서 Rust 를 부르는 방법은 여러 개인데, 대부분의 사고는 잘못 고른 데서 시작합니다. 먼저 넷을 나란히 놓고 호출 비용 · 작성 난이도 · 안전성 · 쓸 수 있는 플랫폼을 비교한 뒤, 2026년 기준 추천을 분명히 정하고 나머지 섹션으로 들어갑니다.

자바·코틀린에서 Rust 를 부르는 네 가지 길 — 성격이 전부 다릅니다 ① jni 크레이트 — JNI 규약을 Rust 안에서 직접 구현합니다 호출이 가장 빠릅니다 · Java 8~25 · 안드로이드 포함 어디서나 · 대신 코드가 장황합니다 ② Panama FFM — 자바가 Rust 의 C ABI 함수를 직접 부릅니다 Java 22 이상 필요 · Rust 쪽은 평범한 extern "C" 하나 · jextract 로 바인딩 자동 생성 ③ UniFFI — 인터페이스만 적으면 코틀린·스위프트 바인딩이 나옵니다 안드로이드·KMP 에서 가장 빨리 끝납니다 · 생성 코드 속이 JNA 라 호출은 조금 느립니다 ④ JNA · JNR — 자바 인터페이스 선언만으로 부릅니다 빌드 설정이 없어 프로토타입에 좋습니다 · 호출당 오버헤드는 넷 중 가장 큽니다 고르는 기준은 성능이 아니라 대개 "어떤 런타임과 어떤 플랫폼에 배포하는가" 입니다
넷 다 실무에서 쓰입니다 — 런타임 버전과 배포 플랫폼이 사실상 답을 정해 줍니다.

한 표로 비교 ★

jniFFMUniFFIJNA
호출 비용가장 낮음거의 같음중간높음
Rust 쪽 작성량많음적음가장 적음적음
자바 쪽 작성량적음많음(자동화 가능)0 — 생성됨적음
필요 자바8+22+ (25 LTS 권장)8+8+
안드로이드○✕ (미지원)◎△ (jna-android)
iOS · KMP✕✕◎✕
메모리 실수나기 쉬움Arena 가 막아 줌거의 없음나기 쉬움
비동기 지원수동수동내장수동
다루는 섹션v02~v04v05v06v07
표를 읽는 법 — 호출 비용 차이는 호출 한 번당 수십~수백 나노초 수준입니다. 한 번 부르고 10ms 짜리 일을 시킨다면 넷 중 무엇을 써도 차이가 없습니다. 차이가 드러나는 곳은 초당 수십만 번 부르는 경로뿐입니다(v08).

왜 자바에서 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)

이렇게도 쓸 수 있다 — 상황을 넣으면 답이 나오는 흐름도 🟡 중급

상황을 넣으면 답이 나옵니다 — 위에서부터 해당되는 첫 줄이 답입니다 안드로이드 · iOS · KMP 앱 여러 플랫폼에 같은 로직을 얹어야 합니다 UniFFI 바인딩이 자동으로 나옵니다 (v06) 서버 JVM · Java 22 이상 가능 런타임 버전을 우리가 정할 수 있습니다 Panama FFM extern "C" + jextract (v05) Java 8~21 고정 · 호출이 잦음 고객사 런타임을 바꿀 수 없습니다 jni 크레이트 직접 구현합니다 (v02~v04) 하루 안에 되는지만 보고 싶다 성능·배포는 아직 생각하지 않습니다 JNA 인터페이스 선언만 하면 됩니다 (v07) 고민이 길어지면 UniFFI 로 시작하세요 — 나중에 FFM 이나 jni 로 바꾸기도 쉽습니다
넷 중 먼저 배제되는 것이 대개 셋입니다 — 플랫폼과 런타임 버전이 결정합니다.

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 는 여기서 갈립니다
기대치 — 이미지 리사이즈·압축·파싱에서 2~10배가 흔한 폭입니다. “100배”는 대개 자바 쪽 구현이 나빴던 것이지 언어 차이가 아닙니다. 먼저 자바 구현을 제대로 만들어 비교하세요(v08 · v12).

한 단계 더 — 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 는 “네이티브가 JVM 을 부른다”는 전제로, FFM 은 “JVM 이 네이티브를 부른다”는 전제로 설계됐습니다. Rust 모듈은 대부분 후자이므로 FFM 쪽이 자연스럽습니다.
정리 — 서버 JVM 이면 Panama FFM, 안드로이드·KMP 면 UniFFI, Java 21 이하에 묶였고 호출이 잦으면 jni 크레이트, 프로토타입이면 JNA 가 2026년의 기본값입니다. 무엇을 고르든 로직은 순수 Rust 한 벌, 어댑터만 여럿으로 두고, 재고 나서 도입하세요.
JVM 02

jni 크레이트 — 첫 모듈이 도는 데까지

JNI 는 이름 규칙만 맞으면 도는 단순한 규약입니다. 자바에 native 메서드를 선언하고, 그 이름에 대응하는 심볼을 Rust 가 내보내고, System.loadLibrary 로 .so/.dll 을 올리면 끝입니다. 여기서는 파일 세 개로 첫 호출을 성공시키고, Gradle 이 cargo 를 부르게 만듭니다.

자바가 native 메서드를 부르면 실제로 무슨 일이 일어나나 1 System.loadLibrary("mylib") OS 가 libmylib.so / mylib.dll 을 프로세스에 올립니다 2 심볼 이름으로 찾습니다 Java_com_example_Native_addNumbers 를 dlsym 으로 3 주소를 메서드에 연결해 둡니다 두 번째 호출부터는 찾지 않고 바로 점프합니다 Rust 쪽 함수 extern "system" fn Java_com_example_… 1번째 인자 : JNIEnv — JVM 을 다시 부르는 창구 2번째 인자 : JClass 또는 JObject (this) 3번째부터 : 실제 인자 (jint · jlong · JString …) 반환값은 C 타입으로 jint · jlong · jobject — 그대로 자바 스택으로 심볼을 못 찾으면 UnsatisfiedLinkError — 이름 규칙을 다시 보세요 이름 규칙 : Java_ + 패키지(점을 밑줄로) + _ + 클래스 + _ + 메서드 이름 안에 원래 밑줄이 있으면 _1 로, $ 는 _00024 로 바뀝니다 — 실수가 잦은 지점입니다
JNI 는 이름으로 찾는 규약입니다 — 첫 사고의 90%가 심볼 이름 오타입니다.

파일 세 개로 시작 ★

# 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     # ← 여기 그대로 적혀 있습니다 ★

플랫폼별 파일 이름

OScargo 산출물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) {}
언제 쓰나 — 클래스가 많아 이름이 길어질 때, 난독화(ProGuard·R8)로 패키지가 바뀔 때, 심볼을 strip 으로 숨기고 싶을 때입니다. 안드로이드에서는 거의 표준 관행입니다(v10).

JNI 시그니처 문법 ★

자바 타입시그니처Rust(jni::sys)
booleanZjboolean (u8)
byteBjbyte (i8)
charCjchar (u16)
short / intS / Ijshort/jint
long / floatJ / Fjlong/jfloat
double / voidD / Vjdouble/()
StringLjava/lang/String;JString
byte[][BJByteArray
int[][][[IJObjectArray
메서드 시그니처 = (인자들)반환

  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          ← ★ 이 줄을 그대로 복사
시그니처 오타는 예외가 아니라 크래시입니다 — JVM 이 잘못된 인자 배치를 읽습니다. javap -s 출력을 복사해서 쓰세요. v04 의 ID 캐싱에서 다시 나옵니다.
정리 — JNI 는 이름으로 찾는 규약입니다. crate-type = ["cdylib"] · extern "system" · Java_패키지_클래스_메서드 세 가지만 맞으면 첫 호출이 돕니다. 이름은 javac -h 가 만들어 준 헤더에서 복사하고, 시그니처는 javap -s 에서 복사하세요. Rust 쪽은 로직(lib.rs)과 JNI 어댑터(jni_api.rs)를 반드시 나눕니다.
JVM 03

타입 · 문자열 · 배열 — 경계를 넘는 데이터

숫자는 그냥 넘어갑니다. 문제는 문자열과 배열입니다. 자바 문자열은 UTF-16 이고 JNI 는 Modified UTF-8 이라는 변종을 씁니다. 배열은 복사할지 · 핀으로 고정할지 · 아예 힙 밖에 둘지를 골라야 하고, 이 선택이 성능의 대부분을 정합니다.

기본 타입 대응표 ★

자바jni::sysRust비고
booleanjbooleanu80/1 로 오갑니다
bytejbytei8부호 있음 ★
charjcharu16UTF-16 코드 단위
shortjshorti16
intjinti32
longjlongi64포인터를 실어 보냄(v04)
floatjfloatf32
doublejdoublef64
StringjstringJString변환 필요
byte[]jbyteArrayJByteArray전략 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 변환은 문자열 길이에 비례합니다.

실무에선 이렇게 — 배열 전달 세 가지 전략 🔴 실무

자바 배열을 Rust 로 넘기는 세 가지 — 크기가 선택을 정합니다 ① 복사 convert_byte_array Vec 을 새로 만듭니다 가장 안전하고 짧습니다 GC 를 전혀 막지 않습니다 비용 : 크기에 비례 수 KB 까지는 이걸로 ② 핀 (빌려 쓰기) get_array_elements 원소를 직접 빌립니다 복사 여부는 JVM 이 결정 ReleaseMode 로 되쓰기 놓는 것을 잊으면 누수 중간 크기 · 되쓰기 필요 ③ Direct 버퍼 allocateDirect + 주소 공유 자바 힙 바깥에 잡습니다 복사가 아예 없습니다 GC 가 옮기지 못합니다 수명은 사람이 관리 1MB 이상이면 이것 고르는 법 — 작으면 ① · 결과를 자바 배열에 되써야 하면 ② · 크고 반복되면 ③ 셋 다 결과는 같습니다. 다른 것은 "복사가 몇 번 일어나는가" 뿐입니다.
배열 전달의 성능 차이는 전부 복사 횟수에서 납니다 — 크기가 전략을 정합니다.

① 복사 · ② 핀 — 코드

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 로 진짜 제로카피 🧬 전문가

복사 경로 vs 제로카피 경로 — 같은 데이터, 다른 비용 자바 byte[] 힙 안 · GC 가 옮깁니다 네이티브 버퍼 새로 잡고 memcpy Rust &[u8] 여기서 처리합니다 복사 1회 돌아올 때 또 1회 ByteBuffer.allocateDirect(n) 자바 힙 바깥 · GC 가 옮기지 못합니다 get_direct_buffer_address 같은 주소를 그대로 봅니다 — 복사 0 Direct 버퍼는 회수가 늦습니다 — 매번 만들지 말고 풀에 두고 재사용하세요 읽기 전용으로 넘길 때는 asReadOnlyBuffer() 를 쓰고, 용량은 반드시 함께 넘깁니다
큰 데이터는 힙 밖에 두고 주소만 공유합니다 — 복사가 사라지면 GC 압력도 사라집니다.

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
}
이 함수가 도는 동안 자바 쪽에서 같은 버퍼를 건드리면 데이터 경쟁입니다. JNI 는 아무것도 막아 주지 않습니다. 버퍼 하나는 한 스레드만 — 규약을 문서에 적으세요.

자바 쪽 — 버퍼 풀

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 의 핸들 패턴).
정리 — 숫자는 그냥 넘어가고, 문자열은 크레이트가 Modified UTF-8 을 처리해 줍니다. 배열은 수 KB 면 복사(convert_byte_array) · 되쓰기가 필요하면 핀(get_array_elements) · 1MB 이상이면 DirectByteBuffer 입니다. Critical 영역은 수 마이크로초짜리에만 쓰고, Direct 버퍼는 풀에 두고 재사용하세요.
JVM 04

객체 · 콜백 · 예외 — 참조 수명이 핵심입니다

JNI 에서 받은 JObject 는 포인터가 아니라 핸들입니다. 네이티브 메서드가 반환하는 순간 자동으로 무효가 되고, 다른 스레드로 가져갈 수도 없습니다. 이 규칙 하나를 모르면 재현되지 않는 크래시가 납니다. 여기서 지역/전역 참조 · ID 캐싱 · 예외 · 패닉 · 스레드 attach 를 전부 정리합니다.

JNI 참조 두 종류 — 수명과 스레드 규칙이 전혀 다릅니다 지역 참조 — JObject · JString · JClass 네이티브 메서드가 반환하면 자동으로 해제됩니다 다른 스레드로 넘길 수 없습니다 (그 스레드에서 무효) 구조체 필드에 저장하면 안 됩니다 — 나중에 쓰면 크래시 기본 슬롯이 16개뿐입니다 (JVM 이 더 줄 수도 있음) 반복문에서 객체를 만들면 with_local_frame 으로 감싸세요 전역 참조 — GlobalRef env.new_global_ref(obj) 로 만듭니다 메서드가 끝나도 살아 있습니다 스레드 사이에서 공유할 수 있습니다 (Send + Sync) drop 하면 해제됩니다 — 안 하면 그 객체가 영영 안 죽습니다 콜백 객체 · 캐싱한 클래스는 전부 여기에 둡니다 다른 스레드에서 JNI 를 쓰려면 반드시 붙여야 합니다 env.get_java_vm() 으로 JavaVM 을 얻어 스레드로 넘기고, vm.attach_current_thread() 로 붙입니다 붙이지 않은 스레드에서 JNIEnv 를 쓰면 예외가 아니라 즉시 크래시(SIGSEGV)입니다 ★ 외우는 문장 하나 : "JNIEnv 는 스레드마다 다르고, JavaVM 은 프로세스에 하나다" 그래서 스레드로 넘길 수 있는 것은 JavaVM 과 GlobalRef 뿐입니다
JNI 사고의 대부분은 지역 참조를 오래 들고 있거나 스레드를 안 붙인 것입니다.

자바 객체의 메서드 부르기

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;
}
JNI 호출 뒤에는 항상 예외를 확인해야 합니다 — 자바 메서드가 예외를 던졌는데 그대로 다음 JNI 함수를 부르면 정의되지 않은 동작입니다. jni 크레이트는 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));
}
finalize 는 쓰지 마세요(제거됐습니다). 깜빡한 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 로 쥐어 줍니다.
JVM 05

Panama FFM 으로 Rust 부르기 — Java 25 의 정답

FFM 은 자바가 네이티브 함수를 찾아서 부르는 방식입니다. Rust 쪽에는 자바 전용 코드가 한 줄도 없습니다 — 평범한 extern "C" 함수면 됩니다. 그래서 같은 .so 를 파이썬·Node·C# 이 함께 씁니다(🔗 f01). 대신 변환 코드를 자바 쪽에서 써야 하는데, 그건 jextract 가 만들어 줍니다.

Panama FFM — 자바가 Rust 의 C ABI 함수를 직접 찾아 부릅니다 자바 쪽 Rust 쪽 (평범한 cdylib) SymbolLookup.libraryLookup 라이브러리를 올리고 함수 이름을 찾습니다 libmylib.so · mylib.dll cargo build --release 산출물 그대로 Linker.downcallHandle(주소, 서술자) 인자·반환 레이아웃을 적어 핸들을 만듭니다 #[no_mangle] mylib_resize 심볼 이름이 그대로 남아 있어야 찾습니다 handle.invokeExact(...) JIT 가 상수 폴딩·인라인을 할 수 있습니다 extern "C" fn mylib_resize(...) JNIEnv 도 자바 전용 인자도 없습니다 Arena 에서 MemorySegment 할당 try-with-resources 를 벗어나면 자동 해제 *const u8 · *mut usize 같은 주소를 그대로 읽고 씁니다 — 복사 0 Rust 쪽이 자바를 전혀 모른다는 점이 핵심입니다 — 같은 라이브러리를 다른 언어도 씁니다 Java 22 에서 정식(JEP 454) · Java 24~25 부터 --enable-native-access 가 필요합니다(JEP 472)
FFM 은 바인딩이 자바 쪽에만 있습니다 — 네이티브는 언어 중립으로 남습니다.

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 }
설계 규칙 세 가지 — ① 불투명 포인터로 내부를 숨기고, ② 에러는 정수 코드로, ③ 할당한 쪽이 해제합니다. 이 셋만 지키면 자바·파이썬·C# 바인딩이 전부 기계적으로 나옵니다(🔗 f06).

자바 쪽 — 전체 코드 ★

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 어휘 대응표 ★

FFMC / Rust 로 치면
Linker호출 규약을 아는 어댑터
SymbolLookupdlopen + dlsym
FunctionDescriptor함수 원형(프로토타입)
MethodHandle함수 포인터
MemorySegment포인터 + 길이 + 수명
MemoryLayout#[repr(C)] struct
Arena할당자 + 해제 시점
ValueLayout.ADDRESSvoid*
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);
jextract 는 JDK 에 포함돼 있지 않습니다 — 별도로 받아 CI 에 넣어야 합니다. 생성 코드는 커밋해 두는 편이 빌드가 단순합니다. 헤더가 바뀔 때만 다시 돌리고, diff 를 코드 리뷰에서 봅니다.

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 를 닫은 뒤에 네이티브가 부르면 크래시입니다 — 수명을 맞추세요
업콜은 비쌉니다 — 네이티브 → 자바 방향은 downcall 보다 훨씬 느립니다(v08). 진행률은 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 할당/해제       할당 크기에 비례 — 재사용하세요
정리 — FFM 은 Rust 쪽을 언어 중립으로 남겨 두는 방식입니다. SymbolLookup 으로 찾고 downcallHandle 로 핸들을 만들어 invokeExact 로 부르며, 메모리는 Arena 가 소유합니다. 핸들은 static final, Arena 는 ofConfined, 바인딩은 cbindgen → jextract 로 자동 생성하고, 실행에는 --enable-native-access 를 잊지 마세요.
JVM 06

UniFFI — 바인딩을 사람이 안 씁니다

UniFFI 는 Mozilla 가 파이어폭스 모바일을 만들면서 나온 도구입니다. Rust 함수에 표시(#[uniffi::export])만 붙이면 코틀린·스위프트·파이썬 바인딩이 통째로 생성됩니다. 객체 · enum · Result · async · 콜백까지 전부 지원해서, 안드로이드·KMP 라면 가장 빨리 끝나는 길입니다.

UniFFI 파이프라인 — 표시를 붙이면 바인딩이 생성됩니다 ① Rust 코드 #[uniffi::export] 표시만 ② cargo build cdylib 안에 메타데이터 동봉 ③ uniffi-bindgen 라이브러리를 읽어 생성 Kotlin 바인딩 (.kt) 안드로이드 · 서버 JVM · KMP Swift 바인딩 iOS · XCFramework Python 바인딩 같은 코어를 그대로 (🐍 y01) 사람이 손으로 쓰는 바인딩 코드는 한 줄도 없습니다 — 타입이 바뀌면 다시 생성만 합니다 생성된 코틀린은 내부적으로 JNA 를 씁니다 — 그래서 호출 비용은 JNI 보다 조금 큽니다
인터페이스가 Rust 소스 자체입니다 — 따로 관리할 IDL 파일이 없습니다.

설정 · 첫 함수 ★

# 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}")
    }
}
런타임 의존성 — 생성된 코틀린은 JNA 와 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 라 자바에서는 어색합니다
가장 큰 제약은 복사입니다 — UniFFI 의 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 는 #[uniffi::export] 표시만으로 코틀린·스위프트·파이썬 바인딩을 생성합니다. 객체 · enum · Result · async · 콜백이 전부 자연스럽게 매핑되고, 안드로이드·iOS·KMP 라면 사실상 기본값입니다. 한계는 큰 배열이 복사된다는 것 — 그 경로만 JNI 로 빼는 혼합 설계를 v12 에서 만듭니다.
JVM 07

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 매핑(선택)
}
JNA 가 자동으로 해 주는 것 — 플랫폼별 파일 이름 결정, 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)
정리 — JNA 는 인터페이스 선언 하나로 끝나는 가장 빠른 시작입니다. 호출이 드물거나 한 번에 오래 일하는 함수라면 그대로 운영에 써도 됩니다. 빨라야 하면 Direct 매핑으로 옮기고, 그래도 부족하면 FFM·JNI 로 갑니다. 자바 쪽 인터페이스를 잘 잡아 두면 이관해도 호출부는 그대로입니다.
JVM 08

성능 비교 — 경계 비용을 숫자로

“어느 게 빠른가”는 호출당 얼마인가와 몇 번 부르는가를 함께 봐야 답이 나옵니다. 여기서는 네 방식의 빈 호출 비용을 JMH 로 재고, 큰 배열을 넘길 때의 전략과 GC 와의 상호작용까지 정리합니다. 결론부터 말하면 대부분의 프로젝트에서 경계 비용은 문제가 아닙니다.

호출 한 번당 오버헤드 — 가로는 로그 눈금 (환경마다 다릅니다 · 감각용) 자바 메서드 호출 (기준) 약 1 ns FFM downcall + critical 약 5 ns JNI — 원시 인자만 약 8 ns FFM downcall (기본) 약 10 ns JNI + byte[] 1KB 복사 약 150 ns 업콜 — 네이티브가 자바를 부름 약 250 ns UniFFI 함수 호출 약 600 ns JNA 인터페이스 매핑 약 1,200 ns 한 번 불러 1ms 이상 일하는 함수라면 넷 다 오차 범위입니다 — 고민할 값어치가 없습니다 차이가 드러나는 곳은 초당 수십만 번 부르며 매번 작은 일을 시키는 경로뿐입니다
숫자보다 중요한 것은 호출 횟수입니다 — 배치로 묶으면 이 표 전체가 무의미해집니다.

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              평균이 아니라 꼬리를 보세요

이렇게도 쓸 수 있다 — 호출 횟수를 줄이는 설계 🟡 중급

경계를 넘는 횟수를 데이터 크기와 분리하세요 요소마다 부르기 — 나쁜 설계 for (row : rows) native.processOne(row); 100만 개 → 경계 통과 100만 번 호출당 100ns 만 잡아도 그것만 0.1초 계산보다 경계 비용이 커집니다 한 번에 넘기기 — 좋은 설계 native.processBatch(rows); 100만 개 → 경계 통과 1번 오버헤드가 사실상 사라집니다 Rust 안에서 rayon 으로 병렬화까지 (⚡ e05) 배치 크기는 캐시와 지연 사이의 절충입니다 — 보통 수천~수만 개가 좋은 출발점입니다 결과가 큰 배열이면 미리 잡아 둔 Direct 버퍼에 써 넣게 해서 할당까지 없앱니다 한 번 부르는 데 1ms 이상 걸리는 함수라면 이 고민 자체가 필요 없습니다
경계 비용을 줄이는 가장 확실한 방법은 빠른 호출이 아니라 적은 호출입니다.

큰 배열 전달 전략 비교 ★

전략복사GC 영향쓰는 곳
convert_byte_array1회힙 할당수 KB
get_array_elementsJVM 결정핀 유발 가능중간 · 되쓰기
Critical 영역0~1회GC 방해 ★수 μs 짜리만
DirectByteBuffer0없음1MB 이상
FFM Arena 복사1회힙 밖중간~큼
FFM critical0GC 방해 가능짧은 함수
네이티브가 할당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 정지 시간 총합이 줄었는가
보고할 때의 형식 — “Rust 가 빠릅니다”가 아니라 “동일 인스턴스에서 p99 가 340ms → 95ms, RSS 는 1.8GB → 900MB” 처럼 씁니다. 같은 부하 · 같은 하드웨어 · 같은 결과임을 함께 적어야 근거가 됩니다.
정리 — 빈 호출 비용은 JNI ≈ FFM ≪ UniFFI < JNA 이지만, 실제 성능을 가르는 것은 복사 횟수와 호출 횟수입니다. 배치로 묶고 · Direct 버퍼를 재사용하면 어떤 방식을 골라도 충분히 빠릅니다. 재는 순서는 JMH → 부하 테스트 → p99·RSS 확인 입니다.
JVM 09

빌드 · 패키징 — jar 하나로 배포하기

자바의 가장 큰 장점은 jar 하나면 어디서나 돈다는 것이었습니다. 네이티브를 붙이면 그게 깨지기 쉬운데, 깨지지 않게 만드는 방법이 정해져 있습니다 — OS·아키텍처별 .so 를 jar 안에 넣고 런타임에 추출해 올리는 것입니다. 여기서 그 로더와 다중 타깃 빌드 · GitHub Actions 매트릭스 · Maven Central 배포까지 만듭니다.

jar 하나에 모든 플랫폼을 담고, 실행할 때 꺼내 올립니다 app.jar 안 배치 native/linux-x86_64/libmylib.so native/linux-aarch64/libmylib.so native/macos-aarch64/libmylib.dylib native/windows-x86_64/mylib.dll native/checksums.txt — 무결성 확인용 폴더 이름 규약을 코드와 빌드가 함께 지켜야 합니다 1 os.name · os.arch → 폴더 이름 2 리소스를 임시 파일로 복사 3 System.load(절대 경로) 4 종료 시 삭제 (윈도우는 다음 실행에) JNA 와 UniFFI 는 이 로더를 이미 갖고 있습니다 — 직접 만드는 것은 JNI·FFM 일 때입니다 임시 폴더가 noexec 이면 로드가 실패합니다 — 컨테이너에서 특히 자주 겪습니다 플랫폼별 jar 를 따로 내는 방법도 있습니다(분류자) — 배포 크기를 줄일 때 씁니다
jar 하나로 배포하는 값어치는 큽니다 — 쓰는 쪽이 아무것도 안 해도 됩니다.

네이티브 로더 — 실제 코드 ★

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
리눅스 배포의 진짜 함정은 glibc 버전입니다 — 새 우분투에서 빌드한 .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 요구 버전·심볼·컨테이너 스모크를 확인하세요. 로더에는 임시 경로 오버라이드와 버전 검사를 반드시 넣습니다.
JVM 10

안드로이드 — cargo-ndk 와 jniLibs

안드로이드에는 Panama FFM 이 없습니다. 그래서 길은 JNI 또는 UniFFI 둘 뿐입니다. 다행히 안드로이드는 네이티브를 담는 규약이 아주 명확합니다 — jniLibs/<ABI>/lib*.so 에 넣으면 APK 설치 시 알아서 골라 들어갑니다. cargo-ndk · Gradle 플러그인 · 코루틴 연동 · 16KB 페이지까지 한 번에 정리합니다.

안드로이드 빌드 흐름 — 세 타깃을 한 번에 굽고 ABI 폴더로 배치합니다 aarch64-linux-android 요즘 실기기 대부분 armv7-linux-androideabi 구형 32비트 기기 x86_64-linux-android 에뮬레이터 cargo-ndk 가 세 타깃을 한 번에 빌드하고 ABI 폴더로 바로 넣습니다 cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o app/src/main/jniLibs build --release app/src/main/jniLibs/ arm64-v8a/libmyapp.so — 설치 시 기기에 맞는 것만 골라 들어갑니다 armeabi-v7a/libmyapp.so — 32비트 기기를 버릴 수 있으면 빼도 됩니다 x86_64/libmyapp.so — 에뮬레이터에서 안 돌면 이게 빠진 것입니다 Android 15+ 는 16KB 페이지를 지원해야 합니다 — NDK r27 이상 또는 max-page-size 지정
안드로이드는 배치 규약이 명확합니다 — ABI 폴더에 넣으면 나머지는 시스템이 합니다.

준비와 빌드 ★

# ① 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"]
16KB 페이지 — Android 15 부터 16KB 페이지 기기가 나옵니다. 정렬이 4KB 인 .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 에 묶이지 않아 서버 코드와 공유됩니다.
가능하면 안드로이드 전용 API 를 피하세요 — Bitmap 을 Rust 가 직접 알면 서버·데스크톱에서 같은 코드를 못 씁니다. 바이트 버퍼와 크기만 주고받는 인터페이스로 두면 v12 처럼 Spring Boot 와 안드로이드가 같은 코어를 쓸 수 있습니다.
정리 — 안드로이드는 FFM 이 없어 JNI 또는 UniFFI 입니다. cargo ndk -t … -o app/src/main/jniLibs build --release 한 줄이면 ABI 배치가 끝나고, 코틀린에서는 external fun + Dispatchers.Default 로 부릅니다. 16KB 페이지 정렬 · R8 keep 규칙 · x86_64 에뮬레이터 · ndk-stack 네 가지를 출시 전에 꼭 확인하세요.
JVM 11

Kotlin Multiplatform · iOS — 한 벌로 두 스토어

KMP 는 화면은 각자, 로직은 한 벌로 만드는 방식입니다. 그 “로직 한 벌”을 코틀린 대신 Rust 로 두면 안드로이드·iOS 는 물론 데스크톱과 서버까지 같은 코어를 씁니다. Kotlin/Native 의 cinterop 을 직접 쓰는 대신 UniFFI 를 쓰는 이유도 여기서 정리합니다.

KMP 소스셋 — 화면은 각자, Rust 코어는 한 벌 commonMain — 공통 인터페이스 + UniFFI 가 생성한 코틀린 코드 expect/actual 없이도 대부분 여기서 끝납니다 — 생성 코드가 이미 공통 API 입니다 androidMain jniLibs/<ABI>/libcore.so jna:5.17.0@aar 필요 iosMain XCFramework 로 정적 링크 Swift 바인딩을 함께 배포 desktopMain jar 안 네이티브 추출 (v09) Compose Multiplatform 세 갈래가 같은 Rust 코어를 부릅니다 — 고칠 곳이 한 군데뿐이라는 것이 전부입니다 플랫폼마다 다른 것은 "어떻게 로드하느냐" 뿐이고, 부르는 코드는 같습니다
KMP 의 값어치는 고칠 곳이 한 군데라는 데 있습니다 — 코어가 Rust 여도 똑같습니다.

왜 cinterop 대신 UniFFI 인가 ★

cinteropUniFFI
얻는 것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
iOS 는 동적 라이브러리 제약이 있습니다 — 앱 번들 밖의 .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")
}
Gobley 라는 Gradle 플러그인이 cargo 빌드 · 바인딩 생성 · 소스셋 등록 · XCFramework 조립을 한 번에 처리해 줍니다. 직접 태스크를 엮는 대신 먼저 살펴볼 값어치가 있습니다. 직접 엮는 쪽이 무엇이 도는지 보인다는 장점은 있습니다.

공통 코드 · 플랫폼별 로딩

// 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 를 충분히 두면 플랫폼 테스트가 줄어듭니다
  · 코틀린 쪽 테스트는 '바인딩이 붙었는가' 수준으로 얇게
  · 코어 변경 시 세 플랫폼 스모크를 자동으로 도는 워크플로 하나
정리 — KMP 에서는 UniFFI 하나로 안드로이드·iOS·데스크톱 바인딩이 전부 나옵니다. cinterop 은 Kotlin/Native 전용이라 안드로이드를 못 덮습니다 — 그래서 두 벌이 됩니다. iOS 는 staticlib + XCFramework, 안드로이드는 jniLibs, 데스크톱은 jar 추출 로더로 로딩만 다르고 부르는 코드는 같습니다.
JVM 12

실전 — 이미지 리사이즈 모듈을 세 가지로 감쌉니다

마지막으로 진짜 쓸 수 있는 것을 만듭니다. image 크레이트로 리사이즈 코어를 만들고, (a) jni + DirectByteBuffer · (b) Panama FFM · (c) UniFFI 세 가지로 감싼 뒤 Spring Boot 엔드포인트와 안드로이드 앱에서 부릅니다. JMH 수치를 재고, 되돌릴 수 있는 배포까지 마칩니다.

하나의 코어, 세 개의 어댑터, 두 개의 소비자 imgcore — 순수 Rust image 크레이트 · 디코드 → 리사이즈 → 인코드 · cargo test 가 그대로 (a) jni 어댑터 DirectByteBuffer 로 복사 없이 처리합니다 (b) extern "C" Panama FFM 이 부릅니다 Java 25 LTS 기준 (c) UniFFI export 코틀린·스위프트 생성 앱에서 그대로 사용 Spring Boot 엔드포인트 POST /resize — 전용 스레드 풀에서 호출 안드로이드 앱 ViewModel + Dispatchers.Default 로직은 한 벌이고 어댑터만 셋입니다 — 이것이 이 탭 전체의 결론입니다
어댑터를 늘리는 비용은 작습니다 — 로직을 여러 벌 두는 비용이 클 뿐입니다.

코어 — 순수 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"
배포 후 최소 일주일은 RSS 그래프를 보세요 — 네이티브 누수는 힙 지표에 전혀 안 나타납니다. 평평하지 않고 계단처럼 오르면 Direct 버퍼나 Rust 쪽 해제 누락입니다(v03 · v04).

한 단계 더 — 안드로이드까지 같은 코어로 🧬 전문가

(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 코어 한 벌 위에 jni · extern "C" · UniFFI 세 어댑터를 얹으면 Spring Boot 와 안드로이드가 같은 로직을 씁니다. 자바 쪽은 인터페이스 하나에 구현 셋으로 두어 설정 한 줄로 되돌릴 수 있게 하고, JMH → 부하 테스트 → RSS 감시 순서로 확인하세요. 실제 이득은 속도보다 할당량과 p99 에서 크게 납니다.
csbindgen · LibraryImport · .NET 10 · Unity IL2CPP · NativeAOT · NuGet runtimes

Rust 로 만든 코어를
C# 앱이
그대로 씁니다

C# 은 이미 좋은 언어입니다. 그래서 전부 옮기는 이야기가 아닙니다. 이미지 처리·파싱·암호·시뮬레이션처럼 C# 으로는 GC 와 싸워야 하는 층 하나를 Rust 로 만들고, ASP.NET Core·Unity·Godot·MAUI 어디서든 using 한 줄로 부르는 것이 목표입니다. 길은 여러 개지만 2026년의 기본값은 csbindgen 하나로 정리됐습니다 — build.rs 다섯 줄이면 C# 바인딩이 빌드마다 자동으로 갱신됩니다.

12SECTIONS
.NET 10기준
0손으로 쓴 바인딩
4깊이 층
C# 01

다섯 가지 길 — 무엇을 고를지부터 정합니다

Rust 를 C# 에서 부르는 방법은 다섯 가지지만 실제로 고민되는 건 두 가지뿐입니다. 함수가 몇 개면 손으로 [LibraryImport] 를 쓰고, 그보다 많거나 계속 늘어난다면 csbindgen 으로 자동 생성합니다. 나머지 셋은 “여러 언어에 동시에 낼 때”라는 조건이 붙습니다.

위에서부터 읽고, 처음 맞는 줄에서 멈춥니다 — 아래로 갈수록 드문 상황입니다 함수가 열 개 안쪽이다 → 손으로 LibraryImport 를 씁니다 추가 도구가 없습니다. 선언 다섯 줄이면 끝나고, 늘어나기 시작하면 그때 아래로 내려가면 됩니다 함수가 계속 는다 · Unity 도 쓴다 → csbindgen ★ 2026년의 기본값 build.rs 다섯 줄로 NativeMethods.g.cs 가 빌드마다 갱신됩니다. 구조체·콜백까지 자동입니다 C# 말고 파이썬 · Node 에도 같은 API 를 낸다 → interoptopus · uniffi 한 벌의 Rust 정의에서 여러 언어 바인딩을 함께 뽑습니다. 대신 쓸 수 있는 타입이 제한됩니다 이미 C 헤더(.h)로 배포 중이다 → cbindgen 으로 헤더만 냅니다 C 와 C++ 쪽 고객이 이미 있는 경우입니다. C# 은 그 헤더를 보고 손으로 선언합니다 C# 런타임을 Rust 안에 임베드한다 → 방향이 반대입니다 netcorehost 로 되긴 합니다. 플러그인 호스트를 만드는 게 아니라면 거의 쓸 일이 없습니다
고민되는 건 사실상 위의 두 줄뿐입니다 — 손으로 쓸까, csbindgen 에 맡길까.

다섯 가지 비교 ★

방법바인딩 코드Unity언제
손 P/Invoke
LibraryImport
직접 씁니다가능함수 10개 이하
csbindgen
Cysharp
자동 생성공식 지원대부분 ★
interoptopus자동 · 다언어가능C#+파이썬+C
uniffi-bindgen-cs자동 · 고수준제한적모바일 SDK
cbindgen + 손.h 만 생성가능C 고객이 있음
다섯 개가 서로 배타적이지 않습니다 — 전부 바닥은 같은 C ABI(🔗 f01)입니다. Rust 쪽 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 가 안정됩니다
생성기를 골랐다고 끝이 아닙니다 — 어떤 도구를 써도 Option·Result·String 이 경계를 못 넘는다는 사실은 그대로입니다(uniffi 만 예외이고, 그건 직렬화로 우회한 것입니다). 경계 설계(k04)가 도구 선택보다 훨씬 중요합니다.

실무에선 이렇게 — 고르기 전에 확인할 네 가지 🔴 실무

체크리스트

① 배포 대상 플랫폼이 몇 개인가
   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 가 '직접 점프하는 스텁' 으로 컴파일합니다.
  파이썬처럼 인터프리터를 빠져나올 필요가 없습니다.
이 숫자가 뜻하는 것 — C# ↔ Rust 경계는 파이썬보다 두 자릿수 쌉니다. 그래서 “배치로 묶어라”라는 조언이 파이썬만큼 절박하지 않습니다. 그래도 초당 수백만 번이면 여전히 보이니, 그때는 k05 를 보세요.

무엇이 비용을 만드나

경계 통과에서 실제로 일어나는 일 (비싼 순서)

  ④ 마샬링 (변환)          ← 가장 비쌉니다
     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)입니다.
C# 02

첫 호출 — 10분이면 dotnet run 이 돕니다

Rust 쪽은 속성 두 줄, C# 쪽은 선언 한 줄이면 함수가 불립니다. 초보가 실제로 막히는 곳은 코드가 아니라 만들어진 .dll 을 C# 실행 폴더로 옮기는 설정 하나입니다. 그것까지 포함해 처음부터 끝까지 갑니다.

C# 한 줄이 Rust 함수에 닿기까지 — 다섯 단계 C# 호출부 Native.rust_add(1,2) 생성 스텁 LibraryImport 런타임 로더 LoadLibrary/dlopen mylib.dll 내보낸 심볼 표 Rust 함수 extern C · 최적화됨 단계마다 실제로 하는 일 첫 호출 때만 ③④ 를 합니다 — 이후에는 주소가 캐시되어 곧바로 ⑤ 로 점프합니다 그래서 두 번째 호출부터는 C# 안에서 다른 메서드를 부르는 것과 비용이 거의 같습니다 여기서 실패하면 ③ 아니면 ④ 입니다 — DllNotFound / EntryPointNotFound ③ 은 파일을 못 찾은 것, ④ 는 파일은 찾았는데 그 이름의 함수가 없는 것입니다
두 가지 실패만 알면 됩니다 — 파일을 못 찾았나(③), 함수 이름이 틀렸나(④).

① 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 ★

DllImportLibraryImport
도입.NET Framework~.NET 7+
마샬링 코드런타임이 IL 생성컴파일 때 C# 생성
메서드externpartial
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);
//   }
왜 Unity 는 DllImport 인가 — [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;
    }
}
가장 아까운 30분 — Rust 를 고쳤는데 반영이 안 되는 경우입니다. cargo 는 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));
}
정리 — Rust 는 crate-type = ["cdylib"] 와 #[unsafe(no_mangle)] pub extern "C", C# 은 [LibraryImport("mylib")] static partial — 이 셋이면 함수가 불립니다. 실제로 막히는 건 DLL 을 출력 폴더로 복사하는 csproj 설정이고, 실패는 파일을 못 찾았나 · 함수 이름이 틀렸나 둘 중 하나입니다. 윈도우에서는 반드시 msvc 툴체인을 쓰세요.
C# 03

csbindgen — 바인딩을 손으로 쓰지 않습니다

k02 의 방식은 함수가 늘면 곧 무너집니다. Rust 시그니처를 고쳤는데 C# 선언을 안 고치면 컴파일은 되고 실행 중에 조용히 메모리가 깨집니다 — FFI 에서 가장 무서운 실패입니다. csbindgen 은 빌드할 때마다 C# 을 다시 만들어 이 불일치를 원천적으로 없앱니다.

csbindgen 을 넣으면 빌드 한 번에 두 결과물이 나옵니다 src/ffi.rs — 사람이 쓰는 유일한 경계 파일 #[unsafe(no_mangle)] pub extern "C" fn ... 만 모아 둡니다 build.rs · csbindgen 시그니처를 파싱합니다 (빌드마다) rustc · cargo build 기계어로 컴파일합니다 NativeMethods.g.cs DllImport 선언 · repr(C) 구조체 mylib.dll / .so / .dylib 실제로 실행되는 코드 C# 프로젝트 — 손으로 쓴 P/Invoke 선언 0 줄 Rust 시그니처를 고치면 다음 빌드에서 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)
이 한 줄이 Unity 를 쓰는 이유입니다 — iOS 는 동적 라이브러리를 금지하므로 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_fileRust 소스에서 extern "C" 를 읽음
input_bindgen_filebindgen 결과를 읽음
method_filter이름으로 걸러내기
csharp_entry_point_prefix실제 심볼에 접두사 추가
csharp_method_prefixC# 쪽 메서드 이름 접두사
csharp_dll_name_if조건부 DLL 이름(Unity iOS)
csharp_use_function_pointer델리게이트 대신 delegate*
csharp_type_rename생성 타입 이름 바꾸기
csharp_imported_namespacesusing 추가
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();
정리 — csbindgen 은 build.rs 다섯 줄로 Rust 경계 파일을 읽어 C# 바인딩을 빌드마다 다시 만듭니다. 구조체·포인터·함수 포인터까지 자동이고, Unity 용 __Internal 분기도 설정 한 줄입니다. 순수 로직(core.rs) · 경계(ffi.rs) · C# 래퍼 3층으로 나누고, 생성물은 커밋하되 손대지 않는 것이 규칙입니다.
C# 04

마샬링 — 무엇이 그냥 넘어가고 무엇이 안 넘어가나

경계에서 지켜야 할 규칙은 사실 한 문장입니다 — “메모리 모양이 같은 것만 넘긴다.” 정수·실수·포인터·#[repr(C)] 구조체는 공짜로 통과하고, String·Vec·Option·Result 는 아예 못 넘어갑니다. 대신 무엇으로 바꿔 쓰는지가 이 절의 내용입니다.

#[repr(C)] struct Rect { kind: u8, w: u32, h: u16 } 가 메모리에 놓이는 모습 kind 패딩 3 w (u32) h 꼬리 2 0 1 2 3 4 5 6 7 8 9 10 11 크기 12바이트 · 정렬 4바이트 — 필드 순서를 그대로 지킨 결과입니다 C# 의 [StructLayout(LayoutKind.Sequential)] 구조체가 완전히 같은 배치를 만듭니다 그래서 변환이 필요 없습니다 — 두 언어가 같은 12바이트를 그대로 읽고 씁니다 #[repr(C)] 를 빼면 Rust 는 필드를 마음대로 재배치합니다 기본 표현(repr(Rust))은 크기를 줄이려고 순서를 바꿉니다. 컴파일러 버전이 달라지면 배치도 달라질 수 있습니다 경계를 넘는 모든 구조체에는 예외 없이 #[repr(C)] 를 붙이세요 — 안 붙여도 컴파일은 됩니다
경계 구조체의 규칙은 하나입니다 — #[repr(C)] 를 반드시 붙일 것.

타입 대응표 ★

RustC#비고
i8 / u8sbyte / byte공짜
i16 / u16short / ushort공짜
i32 / u32int / uint공짜
i64 / u64long / ulong공짜
f32 / f64float / double공짜
usize / isizenuint / nint★ 플랫폼 폭
*const TT* 또는 IntPtr공짜
boolbyte 로 쓰세요★ 아래 note
char쓰지 마세요Rust 4B ≠ C# 2B
&str·String못 넘김→ *const u8+len
Vec<T>못 넘김→ (ptr, len)
Option<T>못 넘김→ 널 포인터·플래그
Result<T,E>못 넘김→ 에러 코드(k08)
#[repr(C)] structSequential 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 에서
    }
}
절대 하면 안 되는 것 — Rust 가 준 포인터를 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 로 바꿔야 합니다
//   · 편의 기능이 사라져 코드가 조금 길어집니다
새 프로젝트라면 켜고 시작하는 편을 권합니다 — 경계 시그니처가 강제로 blittable 로만 유지되므로, 나중에 “왜 여기만 느리지”를 찾을 일이 없습니다. 번거로운 변환은 C# 래퍼 한 곳(k03)에 모아 두면 됩니다.

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] 을 켜고 시작하세요.
C# 05

배열과 버퍼 — 복사 없이 주고받기

경계에서 성능이 갈리는 지점은 함수 호출 비용이 아니라 데이터 복사입니다. 1MB 를 넘길 때 복사하면 수십 μs, 포인터만 넘기면 수 ns — 만 배 차이입니다. C# 은 Span·fixed·GCHandle 로 GC 힙을 잠깐 고정할 수 있고, 그 주소를 Rust 가 slice 로 되살려 씁니다.

누가 할당하고 누가 해제하나 — 두 가지 패턴뿐입니다 ① C# 이 가진 배열을 Rust 에 빌려줍니다 — 복사 0 C# byte[] · Span fixed 로 잠깐 고정합니다 (ptr, len) 두 인자 주소와 길이를 따로 넘깁니다 Rust &[u8] from_raw_parts — 복사 없음 ② Rust 가 만든 버퍼를 C# 에 빌려줍니다 — 해제 함수를 반드시 함께 Rust Vec<u8> 소유권을 떼어 냅니다 (ptr, len) 반환 C# 은 주소만 받습니다 C# Span<byte> 복사 없이 읽습니다 ②는 다 쓴 뒤 mylib_free(ptr, len) 를 반드시 불러야 합니다 C# 의 GC 는 Rust 힙을 전혀 모릅니다. 안 부르면 조용히 새어 나가고 프로세스가 계속 커집니다 try/finally 로 묶거나, 더 낫게는 SafeHandle 로 감싸 Dispose 에 맡기세요 (k06)
복사가 사라지는 건 양쪽 다 같은 메모리를 보기 때문입니다 — 대신 수명을 사람이 지켜야 합니다.

① 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.AllocFree 까지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.
규칙 하나로 요약하면 — 경계에는 포인터와 길이만 다닙니다. 배열·문자열·컬렉션은 C# 래퍼 안쪽에서만 존재하게 하세요. 그러면 “왜 여기서 복사가 생기지”를 시그니처만 보고 알 수 있습니다.

한 단계 더 — 정렬 · 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 입니다.
정리 — 경계 시그니처는 포인터와 길이만 다니게 하세요. C# 배열은 fixed 로 짧게 고정해 넘기고, Rust 버퍼는 Buf 구조체 + buf_free 한 쌍으로 돌려줍니다. 핀은 짧을수록 좋고, 오래 잡아야 하면 NativeMemory 나 고정 힙(POH) 을 쓰세요. C# 에서는 배치보다 복사 제거가 훨씬 큰 이득입니다.
C# 06

핸들 — Rust 객체의 수명을 C# 이 관리하기

Rust 쪽에 상태(열린 파일·디코더·연결 풀)를 두고 여러 번 호출하고 싶을 때 쓰는 것이 불투명 핸들입니다. C# 은 주소 하나만 받아 들고 다니고, 안이 무엇인지는 전혀 모릅니다. 핵심은 Rust 의 Drop 과 C# 의 Dispose 를 정확히 한 번씩 짝지어 주는 것입니다.

불투명 핸들의 일생 — 다섯 단계 1 C# 이 mylib_open() 호출 → Rust 가 Box::new 로 상태를 힙에 만듭니다 Box::into_raw 로 소유권을 떼어 주소(*mut State)만 돌려줍니다 — Rust 는 이제 이걸 자동 해제하지 않습니다 2 C# 은 그 주소를 SafeHandle 에 담습니다 — 여기서부터 GC 가 수명을 추적합니다 nint 로 들고 다니면 잊어버리기 쉽지만, SafeHandle 은 잊어도 파이널라이저가 정리해 줍니다 3 작업할 때마다 같은 주소를 그대로 다시 넘깁니다 Rust 쪽은 &mut *(ptr as *mut State) 로 되살려 씁니다 — 여기가 unsafe 가 필요한 유일한 지점입니다 4 Dispose() 또는 파이널라이저 → ReleaseHandle() 이 mylib_close 를 부릅니다 using 문으로 감싸면 스코프를 벗어날 때 자동입니다 — 예외가 나도 반드시 불립니다 5 Rust 가 Box::from_raw 로 다시 받아 drop — 여기서 진짜로 메모리가 풀립니다 SafeHandle 이 close 를 두 번 부르지 않도록 막아 주므로 이중 해제 사고가 구조적으로 사라집니다 핵심은 하나입니다 — into_raw 한 번, from_raw 한 번. 정확히 한 쌍이어야 합니다
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 이 막아 주는 것들

위험nintSafeHandle
해제를 잊음누수파이널라이저가 정리
두 번 해제크래시구조적으로 불가
사용 중 해제use-after-freeAddRef 가 막음
스레드 경쟁없음참조 계수로 안전
프로세스 종료누수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# 이 아이디를 로그에 찍을 수 있음 ★
  단점  · 조회 비용(해시)이 호출마다 붙음
        · 전역 상태라 테스트가 조금 불편

  핸들이 수십 개 이하이고 진단이 중요하다면 레지스트리,
  초당 수백만 번 부른다면 원시 포인터를 고르세요.
정리 — Rust 상태는 Box::into_raw 로 주소만 내주고, C# 은 그 주소를 SafeHandle 에 담아 들고 다닙니다. Dispose ↔ ReleaseHandle ↔ Box::from_raw 가 한 줄로 이어지면 누수도 이중 해제도 구조적으로 사라집니다. Rust 쪽에는 널 검사 + 매직 넘버를 방어로 두세요.
C# 07

콜백 — Rust 가 C# 을 되부를 때 생기는 일

진행률 보고·완료 알림·로그처럼 Rust 가 C# 쪽 코드를 부르는 방향이 필요할 때가 있습니다. 기술적으로는 함수 포인터 하나를 넘기는 것이라 간단하지만, 그 콜백이 어느 스레드에서 실행되는가를 놓치면 사고가 납니다 — 특히 Rust 가 tokio 워커에서 부를 때 그렇습니다.

콜백은 Rust 의 스레드에서 실행됩니다 — C# 이 만든 적 없는 스레드입니다 C# 메인 스레드 Rust 워커 스레드 (tokio · rayon) mylib_start(fp, user) 함수 포인터를 넘기고 즉시 돌아옵니다 작업 실행 여러 스레드가 동시에 돌 수도 있습니다 fp(user, done, total) 호출 Rust 스레드가 직접 관리 코드로 들어갑니다 C# 콜백 본문이 실행됨 여기는 UI 스레드가 아닙니다 여기서 나는 사고 세 가지 ① 델리게이트를 GC 가 수거해 버림 — 붙잡아 두지 않으면 나중에 엉뚱한 주소로 점프합니다 ② UI 컨트롤을 직접 건드림 — 이 스레드는 UI 스레드가 아니라 예외가 나거나 화면이 깨집니다 ③ 콜백 안의 예외가 밖으로 샘 — 네이티브 프레임을 지나가므로 프로세스가 즉시 죽습니다
콜백 본문의 첫 줄은 언제나 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();
    }
}
지역 변수에 델리게이트를 담아 넘기면 반드시 죽습니다 — 메서드가 끝나면 참조가 사라지고, 다음 GC 때 수거됩니다. 그다음 Rust 가 그 주소로 점프하면 아무 데나 실행합니다. 재현이 안 되는 랜덤 크래시의 전형적인 원인입니다.

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);
}

// 규칙 — '콜백을 부르는 순간에는 어떤 락도 잡고 있지 않다' 를
//        문서와 코드 리뷰의 고정 항목으로 두세요 ★
정리 — 콜백은 Rust 의 스레드에서 실행됩니다. C# 쪽은 [UnmanagedCallersOnly] + delegate* 로 넘기고(Unity 는 MonoPInvokeCallback + 붙잡아 두기), 본문은 반드시 try 로 감싸고 UI 는 Post 로 되돌립니다. 락을 잡은 채로 콜백을 부르지 마세요. 그리고 대부분의 경우 폴링이 더 단순하고 안전합니다.
C# 08

패닉 — 경계를 넘게 두면 안 됩니다

Rust 의 panic! 은 기본적으로 스택을 되감으며(unwind) 올라갑니다. 그 되감기가 C# 이 만든 스택 프레임을 지나가면 정의되지 않은 동작입니다. 운이 좋으면 즉시 죽고, 나쁘면 한참 뒤에 엉뚱한 곳에서 죽습니다. 경계 함수마다 catch_unwind 로 가둬 에러 코드로 바꾸는 것이 정답입니다.

패닉은 경계에서 멈춰 세우고 숫자로 바꿔 내보냅니다 core.rs 에서 panic! unwrap · 인덱스 초과 · 0 나눗셈 ffi.rs 의 catch_unwind 여기서 반드시 멈춰 세웁니다 에러 코드 -99 반환 C# 래퍼가 예외로 바꿉니다 catch_unwind 가 없으면 — 되감기가 C# 스택 프레임을 지나갑니다 정의되지 않은 동작입니다. 즉시 죽으면 다행이고, 힙이 조용히 깨진 채 계속 도는 경우가 더 무섭습니다 .NET 은 이런 상황을 진단할 수 없습니다 — 크래시 덤프에도 원인이 안 남습니다 profile 에 panic = "abort" 를 넣으면 catch_unwind 가 아예 못 잡습니다 프로세스가 그 자리에서 종료됩니다. 바이너리는 작아지지만 라이브러리에는 맞지 않습니다 경계에서 잡아 코드로 바꾸려면 되감기(unwind)를 유지해야 합니다 — 기본값 그대로 두세요 규칙 — extern "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" 를 쓰지 말고, 죽어도 되는 코드가 아니라면 프로세스 분리를 고려하세요.
C# 09

Unity — 플러그인으로 넣기와 그 함정들

Unity 는 .NET 이 아니라 Mono 또는 IL2CPP 위에서 돕니다. 그래서 [LibraryImport] 가 안 되고, 플랫폼마다 파일을 놓는 자리가 다르며, 에디터가 DLL 을 붙잡고 안 놓아 주는 고유한 문제가 있습니다. 하나씩 실제 해결책과 함께 봅니다.

Unity 에서 네이티브 라이브러리를 놓는 자리 — 플랫폼마다 다릅니다 Assets/Plugins/x86_64/mylib.dll 윈도우 에디터와 스탠드얼론 — cargo build --release (반드시 msvc 툴체인) Assets/Plugins/macOS/mylib.bundle 맥 — x86_64 와 aarch64 를 lipo 로 합친 유니버설 바이너리여야 둘 다 돕니다 Assets/Plugins/Linux/x86_64/libmylib.so 리눅스 스탠드얼론 — glibc 버전이 낮은 컨테이너에서 빌드하면 호환 범위가 넓어집니다 Assets/Plugins/Android/libs/arm64-v8a/libmylib.so 안드로이드 — cargo-ndk 로 빌드합니다. armeabi-v7a 도 필요하면 폴더를 하나 더 Assets/Plugins/iOS/libmylib.a iOS — 동적 라이브러리 금지. staticlib 로 빌드하고 DllImport 이름을 __Internal 로 씁니다 각 파일 Inspector 의 플랫폼 체크가 맞아야 합니다 체크가 틀리면 에디터에서는 잘 돌다가 빌드 결과물에는 파일이 아예 안 들어갑니다 .meta 파일을 반드시 커밋하세요 — 이 설정이 거기에 저장됩니다
Unity 에서 “에디터에선 되는데 빌드하면 안 된다”의 원인은 대개 Inspector 플랫폼 체크입니다.

플랫폼별 빌드 명령 ★

# ── 데스크톱 ──────────────────────────────
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 로 돌고 있었다' 는 사고가 정말 자주 납니다.
정리 — Unity 는 플랫폼마다 놓는 자리와 형식이 다릅니다 — 윈도우는 .dll, 맥은 유니버설 .bundle, 안드로이드는 cargo-ndk, iOS 는 staticlib + "__Internal" 입니다. Inspector 플랫폼 체크와 .meta 커밋을 빠뜨리지 말고, 에디터 잠금은 복사본을 여는 수동 로더로 우회합니다. 프레임 계산은 Burst, 크레이트가 필요하거나 서버와 코드를 공유해야 하면 Rust 입니다.
C# 10

Godot · Avalonia · MAUI — 앱 프레임워크에 붙이기

Unity 말고도 C# 앱은 많습니다. Godot 4 는 .NET 을 그대로 쓰고, Avalonia·MAUI 는 데스크톱·모바일 UI 를 담당합니다. 공통 과제는 하나입니다 — 실행 환경마다 다른 위치에 있는 네이티브 파일을 어떻게 찾게 할 것인가. NativeLibrary.SetDllImportResolver 가 그 만능 열쇠입니다.

SetDllImportResolver — 파일을 찾는 규칙을 직접 씁니다 첫 P/Invoke 호출 DllImport("mylib") 내가 등록한 Resolver 경로를 직접 계산합니다 NativeLibrary.Load 핸들을 돌려주면 그걸 씁니다 0 을 돌려주면 런타임 기본 탐색으로 넘어갑니다 즉 실패해도 손해가 없습니다 — 아는 경우만 처리하고 나머지는 넘기면 됩니다 리졸버 안에서 흔히 만드는 경로 runtimes/win-x64/native/mylib.dll · runtimes/win-arm64/native/mylib.dll runtimes/linux-x64/native/libmylib.so · runtimes/linux-musl-x64/native/libmylib.so runtimes/osx-arm64/native/libmylib.dylib · runtimes/osx-x64/native/libmylib.dylib
리졸버는 실패해도 안전합니다 — 아는 경우만 처리하고 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 dllgdext (순수 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 -->
UI 스레드에서 무거운 네이티브 호출을 하지 마세요 — 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 버전 검사를 로드 직후에 반드시 넣으세요.
C# 11

배포 — NuGet 한 줄로 끝나게 만들기

쓰는 사람이 dotnet add package MyLib 한 줄만 치면 윈도우·리눅스·맥·ARM 어디서든 그냥 도는 것이 목표입니다. 비결은 runtimes/<rid>/native/ 라는 NuGet 규약 하나이고, 나머지는 여러 타깃을 빌드하는 CI 의 문제입니다.

MyLib.1.0.0.nupkg 안의 구조 — 규약만 지키면 자동으로 골라집니다 lib/net10.0/MyLib.dll 관리 코드 — 플랫폼과 무관하게 하나뿐입니다. 여기에 C# 래퍼와 생성된 바인딩이 들어갑니다 runtimes/win-x64/native/mylib.dll runtimes/win-arm64/native/mylib.dll runtimes/linux-x64/native/libmylib.so runtimes/linux-musl-x64/native/libmylib.so runtimes/osx-arm64/native/libmylib.dylib 복원할 때 지금 환경의 RID 에 맞는 것 하나만 출력 폴더로 복사됩니다 쓰는 사람은 아무 설정도 하지 않습니다 — DllImport 이름만 맞으면 자동으로 찾힙니다
규약을 지키면 NuGet 이 알아서 고릅니다 — 리졸버(k10)조차 대개 필요 없습니다.

패키징 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-zigbuildglibc 버전 지정 ★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++ 정도만
이 조합의 값어치 — 파일 하나로 끝나므로 “DLL 을 어디에 두나(k02·k10)” 문제가 통째로 사라집니다. 컨테이너 이미지도 수십 MB 로 줄고, 시작 시간이 수 ms 로 떨어집니다. 서버·CLI 라면 가장 좋은 배포 형태입니다.

트리밍 · 단일 파일

<!-- 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 로 눈으로 확인하세요.
C# 12

실전 — 이미지 리사이즈 모듈을 만들어 끝까지

지금까지의 조각을 하나로 잇습니다. Rust 의 image 크레이트로 리사이즈 코어를 만들고, csbindgen 으로 감싸 ASP.NET Core Minimal API 와 Unity 에서 같이 씁니다. BenchmarkDotNet 으로 오버헤드를 재고, NuGet 으로 포장하는 데까지 갑니다.

이미지 한 장이 처리되는 경로 — 관리 힙 할당이 거의 없습니다 1 HTTP 요청으로 이미지 바이트가 들어옵니다 ArrayPool.Shared.Rent 로 버퍼를 빌립니다 — 새로 할당하지 않습니다 2 fixed 로 잠깐 고정하고 (ptr, len) 을 넘깁니다 복사가 없습니다. 고정은 호출 한 번 동안만 유지됩니다 (k05) 3 Rust 가 image 크레이트로 디코딩 · 리사이즈 · WebP 인코딩 여기가 전체 시간의 99% 입니다 — 경계 비용은 아예 보이지 않습니다 4 결과 Vec 의 소유권을 떼어 Buf { ptr, len, cap } 으로 돌려줍니다 C# 은 주소만 받습니다. Rust 힙에 있으므로 GC 와 무관합니다 5 Span 으로 응답 스트림에 쓰고, finally 에서 mylib_buf_free 이 한 줄을 빠뜨리면 요청마다 새어 나갑니다 — SafeHandle 로 감싸면 더 안전합니다 (k06) 경계는 두 번뿐입니다 — 넘길 때 한 번, 해제할 때 한 번. 나머지는 전부 안쪽 일입니다
실전 구조의 핵심 — 경계를 적게, 버퍼는 재사용, 해제는 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); }
        }
    }
}
Unity 에 넘길 때는 인코딩하지 마세요 — 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)
UnityDllImport · iOS 는 __Internal(k09)
탐색SetDllImportResolver(k10)
배포runtimes/<rid>/native/(k11)
측정BenchmarkDotNet · Release(k12)
가장 중요한 두 문장으로 줄이면 — ① 경계에는 포인터와 길이만 다니게 한다. ② 할당한 쪽이 해제한다. 나머지는 전부 이 둘의 응용입니다. 도구가 바뀌어도(interoptopus·uniffi) 이 두 문장은 그대로입니다.
정리 — core.rs(순수 로직) · ffi.rs(경계) · C# 래퍼 3층으로 나누면 ASP.NET Core 도 Unity 도 같은 .dll 을 씁니다. 버퍼는 ArrayPool + fixed 로 빌려 쓰고, 결과는 Buf + finally 해제 로 받습니다. BenchmarkDotNet 으로 경계 비용을 따로 재 보면 수 나노초라는 것을 눈으로 확인할 수 있습니다 — 그다음부터는 Rust 코드 자체를 빠르게 만드는 일만 남습니다(⚡ e 탭).
Rust 1.89 · log 0.4 · tracing 0.1 · tracing-subscriber 0.3 · env_logger 0.11 · OpenTelemetry · defmt

로그는 서비스가
자기 자신에 대해
말할 수 있는 유일한 방법입니다

러스트에는 표준 로깅이 없습니다. 표준 라이브러리가 주는 것은 println! 과 eprintln! 뿐이고, 그 둘로는 운영에서 하루도 못 버팁니다. 대신 생태계가 두 겹으로 나뉜 해법을 만들었습니다 — 라이브러리가 부르는 얇은 파사드(log)와 실행 파일이 딱 한 번 고르는 구현체(env_logger·fern 등), 그리고 비동기 시대에 맞춰 “점”이 아니라 “구간”을 기록하는 tracing 입니다. 이 탭은 그 전부를 왜 이렇게 됐는지부터 설명하고, 레이어 합성 · JSON 구조적 로깅 · 파일 로테이션 · 성능 · OpenTelemetry · 패닉 · 임베디드까지 이어 갑니다. 이 탭을 마치면 새 프로젝트의 init_log() 를 남의 코드를 베끼지 않고 직접 쓸 수 있습니다.

12SECTIONS
Rust 1.89기준
12시각화
6크레이트 비교
LOG 01

println! 로 시작해서 반드시 막히는 곳 — 무엇이 없는가

누구나 println!("여기까지 왔다") 로 시작합니다. 그리고 누구나 같은 자리에서 막힙니다. 끌 수가 없고, 언제 어디서 났는지 모르고, 컨테이너에서 사라지고, 느립니다. 이 네 가지가 로깅 크레이트가 존재하는 이유 전부입니다.

같은 한 줄을 남기는 두 가지 방법 — 무엇이 다른가 println!("user {id} 결제") info!(user_id = %id, "결제") 레벨이 없다 — 끄려면 코드를 고쳐야 합니다 시각 · 모듈 · 스레드가 안 붙습니다 stdout 을 매번 잠급니다 (전역 뮤텍스) 구조가 없어 grep 말고는 방법이 없습니다 테스트 출력에 그대로 섞여 나옵니다 레벨 5단계 — 환경변수로 끄고 켭니다 시각 · target · 스레드가 자동으로 쓰기는 전용 워커 스레드에 넘깁니다 필드가 남아 JSON 으로 질의됩니다 테스트에서는 캡처 레이어로 뺍니다 "디버깅용"으로 넣은 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:?}")?;     // 매번 잠그지 않습니다
}
// 그래도 레벨·시각·구조는 여전히 없습니다
비동기에서 특히 위험합니다 — Tokio 워커 스레드는 보통 CPU 코어 수만큼입니다. 그 중 하나가 느린 터미널이나 파이프에 블로킹 쓰기를 하면 거기에 얹힌 태스크 전부가 대기합니다. tracing-appender 의 non_blocking 이 존재하는 이유가 이것입니다(LOG 08).

③ 컨테이너에서 로그가 사라지는 세 가지 이유 🟡 중급

버퍼링 · 종료 · 목적지

증상진짜 원인처방
마지막 몇 줄이 없다프로세스가 버퍼를 비우기 전에 죽음워커 guard 유지 · flush()
한참 뒤에 몰려 나온다파이프에 붙으면 블록 버퍼링줄 단위 flush 또는 로거 사용
아무것도 안 보인다stdout 이 아니라 stderr 로 나감수집기 설정 확인 · 목적지 통일
JSON 이 깨진다여러 스레드의 줄이 섞임줄 단위 원자적 쓰기 로거
panic 만 안 남는다패닉은 로거를 거치지 않음set_hook (LOG 11)
12-Factor 원칙 — 컨테이너 안의 앱은 파일이 아니라 stdout 한 줄기로만 씁니다. 모으고 회전시키고 보관하는 일은 도커·쿠버네티스·수집기가 합니다. 예외는 임베디드·데스크톱 앱처럼 수집기가 없는 환경뿐입니다(LOG 08).

직접 확인해 보기

# 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` 가 깨집니다
CLI 라면 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 02

파사드 구조 — log 크레이트가 인터페이스만 정한 이유

라이브러리는 로그를 남기고 싶지, 로그를 어디로 보낼지 정하고 싶지 않습니다. 그 두 관심사를 갈라 놓은 것이 log 크레이트입니다 — 자바의 SLF4J + Logback 과 정확히 같은 두 겹 구조입니다.

두 겹 구조 — 라이브러리는 파사드만 부르고, 실행 파일이 구현체를 고릅니다 내 앱 · 라이브러리 크레이트 log::info! 를 부를 뿐 — 어디로 갈지 모릅니다 log 크레이트 (파사드) Log 트레이트 · 전역 로거 한 개 · 매크로 5종 구현체 — env_logger · fern · … 실행 파일이 시작할 때 딱 한 번 등록합니다 포맷 · 필터 · 출력 대상 색 · 시각 형식 · 모듈별 레벨 · 파일 회전 stdout · stderr · 파일 · syslog 호출 방향 자바의 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 가 기본으로 둘을 이어 줍니다.
LOG 03

tracing 이 다른 점 — 점이 아니라 구간을 기록합니다

log 는 사건(event) 하나를 한 줄로 남깁니다. tracing 은 여기에 구간(span) 을 더합니다 — 시작과 끝이 있고, 중첩되고, 안에서 난 모든 이벤트에 자기 정보를 물려주는 것. 비동기 코드에서 “이 로그가 어느 요청 것인가”를 답할 수 있게 하는 것이 바로 이 개념입니다.

span 은 시작과 끝이 있는 '구간' — 이벤트는 그 구간 안의 한 점입니다 0 20 40 60 80 100 ms handle_request └ authenticate └ db::query └ row 매핑 └ render 이벤트 인증 성공 느린 쿼리 경고 응답 200 "느린 쿼리 경고" 한 줄에 db::query 와 handle_request 의 필드가 전부 붙습니다 막대의 길이가 곧 span 의 수명 — log 로는 이 길이를 기록할 수 없습니다
span 은 겹쳐 쌓이는 문맥입니다 — 이벤트는 자기를 감싼 모든 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 이 만드는 세 가지 정보

얻는 것logtracing
사건 한 줄✔✔
구간의 길이직접 계산자동(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 스택이 콜론으로 이어집니다 ★
busy 와 idle — 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반환값을 이벤트로 남김
errErr 일 때 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!) 하나로 충분합니다
비용 감각 — 꺼진 span 은 거의 공짜(콜사이트 캐시된 정적 판단)지만, 켜진 span 은 Registry 에 등록·해제되고 필드가 저장됩니다. 요청 · 배치 · 외부 호출 정도의 단위에 두는 것이 정석이고, 내부 루프 한 바퀴마다 만드는 것은 거의 항상 과합니다.
정리 — log 는 점을, tracing 은 구간을 기록합니다. span 은 시간(길이) · 계층(부모-자식) · 문맥(상속) 세 가지를 한꺼번에 주고, 이것이 비동기에서 “이 로그가 어느 요청 것인가”에 답할 수 있는 유일한 방법입니다. 함수 단위는 #[instrument(skip_all, fields(...))] 를 기본으로 쓰세요.
LOG 04

tracing-subscriber 실전 — 레이어를 겹쳐 내 구독자를 조립합니다

tracing 은 기록만 하고, 그걸 받아 처리하는 쪽이 구독자(Subscriber) 입니다. tracing-subscriber 는 구독자를 통째로 주는 대신 Registry 위에 레이어를 끼우는 방식을 씁니다 — 포맷 하나, 필터 하나, OTel 하나… 필요한 만큼 겹칩니다.

Registry 하나에 레이어를 겹쳐 끼웁니다 — with() 를 부를 때마다 한 겹 이벤트 info!(...) Registry span 저장소 EnvFilter RUST_LOG fmt::layer 포맷 · 색 Writer stdout · 파일 registry().with(filter).with(fmt).init() — 이 한 줄이 위 그림 전체입니다 끼워 넣을 수 있는 층 — 순서는 자유지만 필터는 앞쪽이 이득입니다 ErrorLayer span 백트레이스 OpenTelemetry TraceId 부착 console_subscriber tokio-console JsonFields 필드 보관 필터를 앞에 두면 뒤쪽 레이어가 아예 불리지 않습니다 — 순서가 곧 비용입니다
구독자는 완성품이 아니라 조립품입니다 — 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 필드가 있기만 하면
필드 값 필터가 진짜 무기입니다 — 운영에서 “특정 고객만 이상하다”일 때 그 고객 요청만 trace 로 켤 수 있습니다. 전체를 trace 로 켰다가 디스크를 채우는 사고를 막아 줍니다.

두 구현의 차이와 실행 중 변경

문법env_loggerEnvFilter
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 필드 필터가 운영에서 가장 강력한 손잡이입니다.
LOG 05

구조적 로깅 — 문자열이 아니라 필드로 남깁니다

"주문 7781 이 12000원 결제됨" 은 사람만 읽습니다. {order_id: 7781, amount: 12000} 은 기계가 집계하고 정렬하고 알람을 겁니다. 같은 정보를 어떻게 담느냐가 “grep 밖에 못 하는 로그”와 “질의할 수 있는 로그”를 가릅니다.

JSON 로그 한 줄 — 어느 조각을 누가 채우는가 ① subscriber 가 채웁니다 ② EnvFilter 가 보는 곳 ③ 기본값은 모듈 경로 {"timestamp":"2026-09-01T04:12:33.481Z","level":"INFO","target":"pay::api", "span":{"name":"place_order","order_id":7781}, "fields":{"amount":12000,"attempt":2,"message":"결제를 승인했습니다"}} ④ 값은 문자열이 아니라 숫자 ⑤ span 필드는 자동으로 붙습니다 ⑥ 사람이 읽는 메시지는 맨 뒤 %value 는 Display · ?value 는 Debug · 시길이 없으면 값 그대로(숫자 · bool) 이 한 줄을 만드는 것은 fmt::layer().json() 하나 — 개발에서는 pretty() 를 씁니다
구조적 로깅의 요점은 message 를 줄이고 필드를 늘리는 것입니다.

필드 문법 — 시길 세 가지

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식별자는 접미사 통일
outcomesuccess/failure/skipped
duration_ms단위를 이름에 넣습니다
error.kind에러 분류 (문구 아님)
attempt1부터 세는 정수
❌ 자유 문자열카디널리티를 폭발시킴
카디널리티가 왜 문제인가 — 로그 저장소는 필드 값의 종류 수만큼 색인을 만듭니다. 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 06

크레이트 비교 — 여섯 갈래, 어떤 상황에 무엇을 고르나

러스트 로깅 크레이트는 많아 보이지만 계보는 세 줄기입니다 — log 파사드 위의 구현체들, tracing, 그리고 임베디드 전용 defmt. 아래 표는 강도를 보여 줍니다. 정확한 다운로드 수치가 아니라 상대적 위치로 읽으세요.

로깅 크레이트 여섯 갈래 — 색이 진할수록 그 항목이 강하다는 뜻입니다 span 성능 설정 유연성 생태계 도입 쉬움 log + env_logger 없음 높음 낮음 넓음 아주 쉬움 tracing 있음 높음 아주 높음 가장 넓음 보통 fern 없음 보통 아주 높음 보통 쉬움 slog 부분 높음 높음 좁음 어려움 flexi_logger 없음 보통 높음 보통 쉬움 defmt (임베디드) 부분 아주 높음 낮음 임베디드만 보통 강도 약함 → 강함 · 색은 훑어보기용이고 판단은 글자로 합니다 채택 정도는 crates.io 다운로드 · lib.rs 순위 · GitHub star 추이로 확인하세요
기준이 다섯이면 표가 정직합니다 — 한 줄로 좋은 것은 없습니다.

여섯 갈래를 한 줄씩

크레이트정체채택 정도
log파사드(인터페이스)사실상 모든 곳
env_loggerlog 의 기본 구현체널리 씀(예제·CLI)
tracingspan 이 있는 다음 세대서비스의 사실상 표준
fernlog 용 빌더 API특정 취향에서
slog구조적 로깅의 원조신규 채택은 드묾
flexi_logger파일 회전이 강한 구현체데스크톱·온프렘
defmt임베디드 전용 · 지연 포맷임베디드의 표준
수치를 직접 확인하는 법 — crates.io 의 각 크레이트 페이지에 최근 90일 다운로드가 있고, lib.rs 는 카테고리별 순위를 매겨 둡니다. GitHub star 는 추이만 보세요(절대값은 오래된 크레이트에 유리합니다). 2026년 8월 기준 대략 log 가 억 단위, tracing 이 그에 준하는 자릿수이고 slog·fern 은 두 자릿수 배 적습니다.

상황별로 고르기

이런 상황이면고르세요
라이브러리 크레이트를 만든다log 만 의존
작은 CLI · 예제 · 학습log+env_logger
비동기 서버 · 마이크로서비스tracing
분산 추적이 필요하다tracing+OTel
파일 회전·압축·보관이 필수flexi_logger 또는
tracing-appender
이미 slog 로 짜여 있다유지 — 이관은 신중히
MCU · no_stddefmt
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 순위로 직접 확인하세요 — 남의 숫자를 옮겨 적지 말고요.
LOG 07

비동기 · Tokio — span 이 .await 를 건너 이어지는 원리

비동기 태스크는 스레드에 붙어 있지 않습니다. .await 에서 양보하고, 다음에 깨어날 때는 다른 워커 스레드일 수 있습니다. 그래서 스레드 로컬에 문맥을 담는 옛 방식은 여기서 깨지고, tracing 은 그것을 구조로 풀었습니다.

태스크가 워커 스레드를 옮겨 다녀도 span 스택은 태스크를 따라갑니다 워커 A 스레드 1 워커 B 스레드 2 span 진입 order_id=7781 .await 에서 양보 span 을 잠시 닫습니다 다른 스레드에서 재개 같은 span 이 이어집니다 로그 출력 order_id 가 붙습니다 스레드 로컬로 문맥을 나르는 방식은 여기서 깨집니다 — span 은 태스크에 붙습니다 직접 만든 Future 라면 .instrument(span) 이 필요합니다 (async fn 은 어트리뷰트로 충분)
span 은 스레드가 아니라 Future 에 붙어 있습니다 — 그래서 워커를 옮겨도 안 끊깁니다.

왜 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?;
“요청 ID 가 배경 작업 로그에 안 붙는다”는 거의 항상 ①번 실수입니다. 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))
}
요청 ID 는 직접 만들지 마세요 — 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 은 그다음 단계의 도구입니다.
정리 — 비동기에서 문맥이 유지되는 이유는 span 이 스레드가 아니라 Future 에 붙기 때문입니다. async fn 은 #[instrument], 수동 Future 는 .instrument(span), tokio::spawn 은 .instrument(Span::current()) 를 반드시 붙이세요. enter() 가드를 .await 너머로 들면 남의 로그에 내 문맥이 새어 나갑니다.
LOG 08

파일 출력과 로테이션 — WorkerGuard 를 놓치면 로그가 사라집니다

컨테이너가 아니라면(데스크톱 앱 · 온프렘 설치형 · 배치 작업) 파일로 남겨야 합니다. 그때 필요한 두 가지가 회전(rotation) 과 비동기 쓰기 이고, 비동기 쓰기에는 거의 모두가 한 번씩 당하는 함정이 있습니다.

non_blocking — 로그를 쓰는 일은 전용 워커 스레드에 넘깁니다 채널 자정마다 새 파일 앱 스레드 info! 호출만 워커 스레드 묶어서 파일에 씁니다 08-30 08-31 오늘 · 쓰는 중 rolling::daily("logs", "app.log") _guard 를 드롭하면 버퍼가 비워지기 전에 프로세스가 끝납니다 let (nb, _guard) = non_blocking(file); — _guard 는 main 이 끝날 때까지 살아 있어야 합니다 회전은 파일 이름으로만 합니다 — 오래된 파일 삭제는 max_log_files 나 배치가 맡습니다
원통 셋은 같은 로거가 만든 날짜별 파일입니다 — 회전은 이름 바꾸기가 아닙니다.

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)   // ✔ 드롭이 정상적으로 일어납니다
}
컨테이너라면 파일을 쓰지 마세요 — stdout 한 줄기로만 쓰고 수집(Fluent Bit · Vector · Promtail)과 보관은 플랫폼에 맡기는 것이 정석입니다. 파일 로깅은 수집기를 둘 수 없는 환경의 해법입니다.

디스크가 찼을 때 · 로그가 안 보일 때 🔴 실무

점검 순서 (위에서부터)

# ① 그 파일에 정말 쓰고 있나 — 열린 파일 핸들 확인
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]), "요청 본문");
로그 폭주는 장애의 증상이자 원인입니다 — 외부 API 가 죽으면 초당 수만 줄의 error! 가 쏟아지고, 그게 디스크를 채워 진짜 장애를 만듭니다. 반복 에러에는 누적 카운터 + 주기적 요약을 기본으로 하세요.
정리 — 파일 로깅의 핵심은 셋 — WorkerGuard 를 이름 있는 변수로 살려 두기, 회전 정책과 보관 개수(max_log_files) 정하기, 유실 정책(lossy) 을 의식적으로 고르기. std::process::exit 는 소멸자를 건너뛰므로 마지막 로그가 사라집니다. 컨테이너라면 애초에 stdout 한 줄기가 정답입니다.
LOG 09

성능 — 꺼진 로그는 얼마나 싼가, 켠 로그는 얼마나 비싼가

“로그를 많이 남기면 느려진다”는 말은 절반만 맞습니다. 꺼진 로그는 거의 공짜이고(원자적 비교 한 번), 컴파일 타임에 지우면 완전히 0 입니다. 비싼 것은 켠 상태에서의 포맷·할당·쓰기이고, 그건 측정해서 다뤄야 합니다.

로그를 한 단계 줄이면 무엇이 좋아지고 무엇을 잃는가 조사용 정보 CPU · 지연 디스크 · 전송 검색 속도 TRACE 까지 전부 가장 많음 가장 큼 폭증 느림 DEBUG 까지 많음 큼 큼 보통 INFO — 운영 기본값 충분 작음 작음 빠름 WARN 이상만 부족 거의 없음 아주 작음 가장 빠름 컴파일 타임 제거 복구 불가 0 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 으로 함수 본문을 직접 확인
피처는 합쳐집니다(feature unification) — 의존성 중 하나가 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);
네 단계를 따로 재세요 — ①②의 차이가 필터 비용, ②③의 차이가 포맷 비용, ③④의 차이가 I/O 비용입니다. 이렇게 나눠야 “무엇을 줄여야 하는지”가 나옵니다. 보통 ④가 나머지 셋을 합친 것보다 훨씬 큽니다.

정적 디스패치와 레이어 비용

// registry().with(A).with(B) 는 Layered<B, Layered<A, Registry>> 라는
// '구체 타입'을 만듭니다 — 가상 호출이 아니라 정적 디스패치입니다 ★
//   장점: 인라인 가능 · 오버헤드 거의 없음
//   단점: 타입이 길어져 컴파일 에러가 무섭게 나옵니다

// .boxed() 를 쓰면 동적 디스패치로 바뀝니다 (호출당 가상 호출 한 번)
//   조건부 레이어에는 필요하지만, 뜨거운 경로에 여러 개 쌓지는 마세요

// 실측 없이 최적화하지 않는 규칙 ★
//   1) non_blocking 을 붙였는가
//   2) 운영 레벨이 info 인가
//   3) 루프 안에 debug! 가 있는가
//   이 셋을 먼저 확인하면 나머지는 대개 문제가 아닙니다
측정 없이 “로그를 줄이자”고 하지 마세요 — 실제 병목은 거의 항상 동기 쓰기이거나 루프 안의 debug! 입니다. FmtSpan::CLOSE 로 구간 시간을 먼저 보고, 그 다음 criterion 으로 네 단계를 나눠 재는 것이 순서입니다.
정리 — 비용의 계단은 0(컴파일 제거) → 분기 하나(꺼짐) → 필드 기록 → 포맷 → I/O 입니다. 거의 모든 문제는 맨 마지막 I/O에 있으므로 non_blocking 을 먼저 붙이세요. release_max_level_info 는 강력하지만 운영에서 디버그를 켤 길을 없애는 대가가 있습니다.
LOG 10

분산 추적 — tracing-opentelemetry 로 span 을 서비스 밖으로 내보냅니다

서비스가 둘 이상이면 “이 요청이 어디서 느려졌나”는 한 프로세스의 로그로는 답이 안 나옵니다. OpenTelemetry(OTel) 는 그 답을 위해 trace_id 하나로 여러 서비스의 span 을 한 트리로 묶는 규격이고, tracing 의 span 은 이미 그 모양이라 레이어 하나만 끼우면 그대로 나갑니다.

traceparent 헤더 한 줄이 서비스를 잇고 — span 은 각자 Collector 로 보냅니다 주문 API 재고 서비스 OTel Collector Jaeger · Tempo 1. GET /stock · traceparent 2. 헤더 → 부모 span (set_parent) 3. OTLP export (배치 · 비동기) 4. 200 OK — 재고 있음 5. OTLP export — 루트 span 닫힘 6. 배치로 저장소에 보냅니다 7. 조회 — trace_id 하나로 두 서비스의 span 이 한 트리로 보입니다 로그에도 같은 trace_id 를 필드로 남기면 — 로그 한 줄에서 트레이스로 바로 건너뜁니다 점선 구름은 내 프로세스 밖 — Collector 는 자체 호스팅, 저장소는 SaaS 여도 됩니다
서비스는 헤더로 문맥을 넘기고 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 의 호환표)에서 확인하세요.
가장 많이 막히는 곳이 버전입니다 — OpenTelemetry 러스트 SDK 는 아직 1.0 이전이라 minor 마다 API 가 바뀝니다. 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?)
}
헤더 규격은 W3C Trace Context — 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
        }));
③ 이 로그와 트레이스를 잇는 전부입니다 — span 필드는 안쪽 이벤트에 상속되므로 (LOG 05 의 with_span_list) 요청 안의 모든 JSON 로그 줄에 trace_id 가 붙습니다. Grafana 는 그 값을 클릭하면 Tempo 의 트레이스로 건너뛰게 해 줍니다.

Collector · 샘플링 · 종료 — 운영에서 정하는 세 가지 🔴 실무

용어 대응표와 특수 필드

OpenTelemetrytracing 쪽
Trace루트 span 부터의 트리 (이름 없음)
Spanspan — 이름 · 시작 · 끝
Span Eventinfo! 등 이벤트
Attribute필드 (key = value)
Statuserror 필드가 있으면 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, "재고 조회 실패");
span 이름에 값을 넣지 마세요 — 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(())
}
저장소 고르기 — 시작은 Jaeger(컨테이너 하나로 UI 까지), 로그를 Loki 에 두고 있다면 Grafana Tempo (trace_id 클릭 연동), 운영 인력이 없으면 Datadog · Honeycomb · Grafana Cloud 같은 SaaS. 어느 쪽이든 앱은 Collector 만 보게 두면 저장소 교체가 YAML 한 줄입니다. 로그 자체도 OTLP 로 보내려면 opentelemetry-appender-tracing 레이어를 하나 더 끼웁니다.
정리 — 분산 추적은 새 계측이 아니라 기존 span 을 내보내는 것입니다. 필요한 것은 셋 — 전파기(헤더를 읽고 씀) · exporter + 프로바이더(OTLP 로 Collector 에) · tracing_opentelemetry::layer(). trace_id 를 span 필드로 한 번 남기면 로그와 트레이스가 이어지고, shutdown() 을 잊으면 마지막 트레이스가 사라집니다. 버전은 네 크레이트를 한 세트로 올리세요.
LOG 11

에러와 패닉 — 경계에서 한 번, 그리고 로거를 거치지 않는 패닉 잡기

로그가 가장 지저분해지는 곳이 에러 처리입니다. 같은 에러를 세 층에서 세 번 찍고, 정작 원인은 {e} 로 바깥 한 겹만 남습니다. 반대로 패닉은 로거를 아예 거치지 않아 파일에는 흔적이 없습니다. 이 섹션은 그 둘을 anyhow 체인과 panic::set_hook 으로 정리합니다.

에러가 났다 — 어디에서 로그를 남길 것인가 (답은 '경계에서 한 번') 여기서 복구할 수 있는가? 예 warn! 하고 복구합니다 재시도 · 기본값 · 대체 경로 '로그하고 다시 던지기'는 같은 에러를 세 번 찍습니다 — 가장 흔한 중복 로그 아니오 호출자에게 문맥을 더할 수 있는가? 예 .context("주문 조회")? 로 올립니다 로그 없음 — 문맥만 붙여서 ? 문맥은 위로 갈수록 '왜 했나'가 붙습니다 아니오 여기가 경계인가? (핸들러 · main · 태스크) 예 error! 한 번 — 체인 전부 {e:#} 응답 · 종료 코드로 바꿉니다 여기서만 error! 를 씁니다 — 그 위엔 아무도 없으니까요 아니오 그냥 ? 로 올립니다 로그도 문맥도 안 붙입니다 패닉은 이 그림 밖입니다 — 로거를 거치지 않으니 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()
    }
}
4xx 를 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 에는 여전히 남깁니다
    }));
}
훅은 unwinding 전에 불립니다 — 그래서 현재 span 이 아직 살아 있고 요청 ID 가 붙습니다. 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" 면 훅에서 동기로도 씁니다.
LOG 12

임베디드 · no_std — defmt 는 포맷을 호스트로 미룹니다

MCU 에는 파일도 stdout 도 없고, 플래시 64KB 에 core::fmt 를 넣을 자리도 없습니다. defmt(deferred formatting) 는 발상을 뒤집습니다 — 장치는 “몇 번 문장인지”와 원시 값만 보내고, 문자열로 만드는 일은 PC 가 합니다. 그래서 빠르고, 작고, 그 대가로 호스트에 같은 ELF 가 있어야 읽힙니다.

포맷 문자열은 장치를 떠나지 않습니다 — 장치는 번호와 원시 바이트만 보냅니다 장치 MCU 호스트 PC info!("t={=f32}", t) 포맷 문자열은 .defmt 섹션에 번호 + 원시 바이트 u16 번호 + f32 값 = 6바이트 RTT 링 버퍼 (SRAM) core::fmt 코드가 플래시에 없음 probe-rs 가 SWD 로 읽음 ELF 의 .defmt 테이블 조회 번호 → 문자열 복원 인자를 형식에 맞춰 포맷 터미널에 한 줄 출력 INFO t=23.5 main.rs:12 같은 한 줄이 문자열이었다면 — 'INFO t=23.5' 만 해도 십여 바이트에 포맷 코드까지 플래시에 들어갑니다 그래서 defmt 가 임베디드의 표준입니다 — 대가는 '호스트에 같은 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   # 러너가 자동으로 디코딩
플래시에 문자열이 없다는 뜻 — 로그 문구를 100개 늘려도 펌웨어 크기는 거의 그대로입니다. 디버그 정보(debug = 2)는 ELF 에만 있고 플래시로 안 올라갑니다. ELF 를 잃어버리면 그 펌웨어의 로그는 영영 못 읽습니다 — 릴리즈마다 ELF 를 보관하세요.

log + RTT 와 견주면

항목log + rtt-targetdefmt
포맷하는 곳장치 (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 이 붙고, 안 켜면 의존성도 없습니다
Embassy 와 함께 쓰면 — 비동기 임베디드 프레임워크 Embassy 는 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 와 같습니다.
Rust 1.8x · edition 2024 · Vec · VecDeque · BinaryHeap · mpsc · crossbeam · tokio 1.x

스택은 Vec,
큐는 VecDeque,
빈 것은 None

Rust 표준 라이브러리에는 Stack 도 Queue 도 없습니다. 대신 Vec 이 스택이고, VecDeque 가 큐이자 덱이며, BinaryHeap 이 우선순위 큐입니다. 이 탭은 자료구조 이론이 아니라 2026년 Rust 현업이 실제로 고르는 타입과 관용구를 다룹니다 — pop() 이 왜 Option 을 돌려주는지, VecDeque 의 원형 버퍼가 왜 as_slices() 로 두 조각을 주는지, BFS 를 짤 때 빌림 검사기와 싸우지 않는 법, 스레드와 async 에서 어떤 채널을 몇 칸으로 만들지까지. 이 PC 에는 rustc 가 없어 예제 출력은 손으로 추적하고 같은 로직을 node 로 옮겨 값을 맞췄습니다.

12SECTIONS
1.8xRust stable 기준
14시각화
0unsafe 블록
SQ 01

한 장 지도 — Rust 에서는 무엇으로 쌓고 줄 세우나

자바는 1996년부터 쌓인 클래스 열댓 개 중에서 골라야 하지만, Rust 는 처음부터 고를 게 거의 없게 설계됐습니다. 단일 스레드라면 Vec · VecDeque · BinaryHeap 셋이 전부이고, 스레드나 async 가 끼면 채널이 큐 자리를 차지합니다. 지도부터 펴 놓고 섹션마다 하나씩 뜯어 봅니다. 컬렉션 전체를 고르는 표는 🦀 Rust 기초 탭 r06에 있으니 여기서는 스택·큐 쪽만 깊게 봅니다.

무엇을 쓸까 — 마름모가 질문, 초록 상자가 2026년의 답입니다 스레드·태스크끼리 주고받나? 예 채널 — SQ 06 · 07 async 면 tokio mpsc(n) 아니오 우선순위대로 꺼내나? 예 BinaryHeap 최대 힙 · 최소는 Reverse 아니오 앞에서 꺼내거나 양끝을 쓰나? 예 · FIFO VecDeque — 큐 · 덱 push_back · pop_front 아니오 · LIFO Vec — 스택 push · pop · last 쓰지 말 것 Vec::remove(0) 으로 큐 → VecDeque (SQ 03) LinkedList → VecDeque (SQ 09) Rc<RefCell<…>> 공유 큐 → 채널 · Mutex (SQ 06) 기본값처럼 쓰는 unbounded → channel(n) (SQ 07) 단일 스레드라면 답은 거의 항상 아래 셋 중 하나 스택 전용 타입이 없다는 것 자체가 Rust 의 답입니다 — Vec 이 이미 스택이니까요
결정 흐름도 — 단일 스레드면 Vec · VecDeque · BinaryHeap, 스레드·태스크 사이면 용량을 정한 채널. 나머지는 거의 다 실수입니다.
하려는 일2026 권장쓰지 말 것한 줄 이유
스택 (LIFO)Vec<T> — push · pop · last직접 만든 연결 스택pop() 이 Option<T> 이라 빈 스택이 타입에 드러납니다 (SQ 02)
큐 (FIFO)VecDeque<T> — push_back · pop_frontVec::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::mpscArc<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 DelayQueuesleep 루프로 전부 훑기가장 이른 것만 보면 됩니다 (SQ 12)
이름은 1.0 그대로, 바뀐 것은 내부와 주변 도구입니다 (간격은 비례 아님) Vec · VecDeque BinaryHeap · mpsc 1.0 · 2015 1.63 · 2022 thread::scope VecDeque 재작성 mpsc ← crossbeam 이식 1.67 · 2023 1.72 · 2023 Sender 가 Sync Option::is_none_or 1.82 · 2024 1.85 · 2025 edition 2024 Vec::pop_if 1.86 · 2025 이 탭 기준 1.8x 코드를 바꿀 일은 거의 없고, 같은 코드가 버전이 오를수록 빨라졌습니다
타임라인 — 자바처럼 "옛 타입을 버리는" 역사가 없습니다. 1.67 의 두 재작성도 API 는 그대로 두고 속만 바꿨습니다.

다섯 줄로 보는 전부

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입니다. 빈 컨테이너에서 꺼내는 실수가 컴파일러가 보는 타입으로 올라옵니다.

어디에 있나 — 모듈과 크레이트

타입위치메모
Vecpreludeimport 없이 바로
VecDequestd::collections원형 버퍼
BinaryHeapstd::collections최대 힙
Reversestd::cmp순서 뒤집기 래퍼
LinkedListstd::collections거의 안 씀
mpscstd::sync다중 생산 · 단일 소비
crossbeam-channel크레이트 0.5MPMC · select!
tokio::sync::mpsc크레이트 1.xasync 채널
ArrayQueuecrossbeam 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
④ 를 정하지 않으면 메모리가 정합니다 — 생산이 소비보다 빠른 순간 무제한 큐는 끝없이 자랍니다. Rust 가 메모리 안전을 보장해도 OOM 은 막아 주지 않습니다. 채널은 기본이 용량 있는 쪽입니다(SQ 07 · 10).
정리 — 스택은 Vec, 큐·덱은 VecDeque, 우선순위는 BinaryHeap, 스레드·태스크 사이는 용량을 정한 채널입니다. 넷 다 꺼내기의 결과가 Option(또는 Result)이라 "빈 것에서 꺼냈다"는 버그가 런타임 예외가 아니라 처리하지 않으면 컴파일이 안 되는 분기가 됩니다.
SQ 02

스택 — Vec 의 끝이 꼭대기, pop() 은 Option

Vec<T> 는 힙 버퍼 하나와 길이(len)·용량(cap) 두 숫자입니다. 끝에 넣고(push) 끝에서 빼면(pop) 다른 원소를 하나도 옮기지 않으니 그대로 스택입니다. 다른 언어와 결정적으로 다른 점은 하나 — 빈 스택에서 pop() 해도 아무 일도 터지지 않고 None 이 옵니다. 대신 그 None 을 처리하지 않으면 값을 쓸 수 없습니다.

Vec 스택의 실제 모습 — 꼭대기는 언제나 buf[len - 1] 입니다 스택 프레임 (24바이트) ptr len 3 cap 4 힙 버퍼 — 용량 4 칸 10 20 30 ← 꼭대기 (빈 칸) len 뒤는 초기화 안 된 자리 push(40) 빈 칸에 쓰고 len 3 → 4 O(1) · 아무것도 안 옮김 한 번 더 push cap 초과 → 새 버퍼(8칸) 할당 전부 복사 · 분할 상환 O(1) pop() len 을 줄이고 값을 옮겨 줌 Some(30) · 비었으면 None pop 은 메모리를 돌려주지 않습니다 — cap 은 그대로, len 만 줄어듭니다 돌려받고 싶으면 shrink_to_fit() — 대개는 그냥 두는 편이 다음 push 에 유리합니다
메모리 배치 — 스택 연산은 len 숫자 하나를 올리고 내리는 일입니다. 그래서 별도 Stack 타입이 필요 없습니다.

기본 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() — 비면 nulls[-1] — 비면 IndexError
꼭대기 수정last_mut()Option<&mut T>pop 후 pushs[-1] = v
조건부 꺼내기pop_if(f) (1.86+)Option<T>——
여러 개 꺼내기split_off(k) · drain(k..)Vec · 반복자반복 pops[k:] + del
크기 · 비었나len() · is_empty()usize · boolsize() · 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()
    }
}
비용은 0 — 필드 하나짜리 구조체는 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
먼저 재 보세요 — 할당 한 번은 수십 ns 입니다. 핫 루프에서 매번 Vec::new() 를 하고 있을 때가 아니면 차이가 안 납니다. 제일 싼 최적화는 스택 Vec 을 루프 밖으로 빼서 clear() 로 재사용하는 것입니다 — 용량이 남아 있어 두 번째부터는 할당이 0 입니다(⚡ e02).
정리 — 스택은 Vec 입니다. push · pop · last 세 개가 전부이고, pop() 의 Option 은 while let · let-else · ? 로 받습니다. 중간 접근을 막고 싶으면 newtype, 순회는 바닥부터라는 것만 기억하면 다른 언어의 스택 함정은 대부분 사라집니다.
SQ 03

큐 (FIFO) — VecDeque 의 뒤로 넣고 앞으로 뺍니다

큐는 std::collections::VecDeque<T> 하나로 끝납니다. push_back 으로 넣고 pop_front 로 꺼내면 둘 다 O(1) 입니다. 문제는 Vec 으로도 "되는 것처럼 보인다"는 점입니다 — v.remove(0) 은 컴파일도 되고 테스트도 통과하지만, 꺼낼 때마다 나머지 전부를 한 칸씩 당깁니다. 파이썬의 list.pop(0), JS 의 Array.shift() 와 똑같은 함정입니다.

n 개를 전부 꺼낼 때 '옮겨지는 원소 수' — 시간이 아니라 일의 양입니다 0 10억 20억 30억 40억 50억 Vec::remove(0) — n(n-1)/2 · 10만 개면 약 50억 VecDeque::pop_front — 항상 0 (head 만 이동) 0 2만 4만 6만 8만 10만 개 작은 n 에서는 둘 다 빠릅니다 — 그래서 테스트가 통과하고 운영에서 터집니다 곡선이 위로 휘면 O(n²) 입니다. 데이터가 10배면 시간은 100배가 됩니다
선 그래프 — remove(0) 큐는 n² 로 휘고 VecDeque 는 바닥에 붙어 있습니다. 소요 시간이 아니라 옮김 횟수(계산값)입니다.

기본 큐 — 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)
Vec 에서 앞을 빼도 되는 유일한 경우 — 원소가 수십 개 이하로 항상 작다는 게 보장될 때입니다. 그때는 오히려 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(())
}
쓰는 곳 — 네트워크에서 받은 조각을 쌓아 두고 완성된 프레임만 앞에서 잘라 내는 파서 버퍼. 다만 async 네트워크 코드라면 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)
clear() 는 메모리를 돌려주지 않습니다 — 잠깐 10만 개까지 불었다가 평소엔 10개인 큐라면, 한가할 때 shrink_to(1024) 처럼 상한을 정해서 줄이는 것이 실무 감각입니다. 매번 shrink_to_fit() 하면 다음 폭주 때 다시 할당을 반복합니다.
정리 — 큐는 VecDeque 의 push_back + pop_front 입니다. Vec::remove(0) · insert(0, _) · drain(..1) 은 모두 O(n) 이라 큐가 아닙니다. 꺼낸 값은 소유권째 받으므로, 처리하다 다시 넣어도 빌림 에러가 나지 않습니다.
SQ 04

덱의 속 — 원형 버퍼와 as_slices() 의 두 조각

VecDeque 는 버퍼 한 장 위에서 시작 위치(head)와 길이(len)만 움직입니다. 앞에서 빼면 head 가 한 칸 오른쪽으로, 뒤가 끝에 닿으면 버퍼 처음으로 감겨(wrap) 들어갑니다. 그래서 논리적으로는 한 줄인데 메모리에서는 두 토막일 수 있고, 이것이 as_slices() 가 슬라이스를 두 개 돌려주는 이유입니다. 이 구조를 알면 어떤 연산이 O(1) 이고 어떤 게 O(n) 인지 외울 필요가 없습니다.

같은 덱의 세 순간 — 숫자는 값, 위의 작은 숫자는 물리 칸 번호 ① 용량 4 0 1 2 3 5 6 7 4 head=3 · len=4 · 논리 순서 4 5 6 7 as_slices() → ([4], [5, 6, 7]) 가득 참 → 짧은 쪽(1칸)만 끝으로 ② 용량 8 0 3 7 5 6 7 8 4 head=7 · len=5 · as_slices() → ([4], [5, 6, 7, 8]) — 5 6 7 은 그 자리 그대로 ③ 펴기 4 5 6 7 8 make_contiguous() — O(n) as_slices() → ([4, 5, 6, 7, 8], []) 감긴 상태가 정상입니다 — 펴는 건 정렬처럼 '한 장짜리 슬라이스'가 필요할 때만 용량이 늘 때도 전부가 아니라 짧은 토막만 옮깁니다 (현재 표준 라이브러리 구현)
메모리 배치 — 덱은 head 와 len 두 숫자로 버퍼 위를 돕니다. 감겨 있으면 두 조각, 그게 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]
}
"&[T] 를 받는 함수에 넘기고 싶다" 가 가장 흔한 질문입니다. VecDeque 는 Deref<Target=[T]> 가 아닙니다 — 연속이라는 보장이 없으니까요. 함수를 impl IntoIterator<Item = &T> 로 받게 고치거나, 정말 필요할 때만 make_contiguous() 를 부릅니다.
연산비용왜 그런가 (원형 버퍼 기준)
push_back · push_front분할 상환 O(1)빈 칸에 쓰고 head/len 조정 · 가득 차면 두 배로 늘리며 짧은 토막만 이동
pop_back · pop_frontO(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_slicesO(1)경계만 계산
make_contiguousO(n)이미 연속이면 사실상 0
contains · iter().positionO(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_frontpush_back
빼기pop_frontpop_back
보기frontback
고치기front_mutback_mut
구멍 메우기swap_remove_frontswap_remove_back
돌리기rotate_leftrotate_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) — 버퍼 그대로
코드는 그대로입니다 — API 는 한 글자도 안 바뀌었고, 옛 버전 기준 설명(“용량은 2의 거듭제곱”)을 블로그에서 보면 지금은 아니라는 것만 알면 됩니다. 용량 숫자를 로직에 쓰지 마세요 — 보장되는 건 “최소 그만큼”뿐입니다.

다른 언어의 덱은 어떻게 생겼나

구현구조Rust 와 다른 점
Rust VecDeque원형 버퍼 한 장—
C++ std::deque고정 크기 블록 + 블록 포인터 맵양끝 삽입 시 원소 주소가 안 바뀜 · 인덱싱이 한 번 더 간접
Python deque64칸 블록의 이중 연결 리스트인덱싱이 양끝 근처만 빠름
Java ArrayDeque원형 배열null 금지 · 박싱된 참조만
C++ deque 의 "주소가 안 바뀜"이 Rust 엔 왜 없어도 되나 — Rust 에서는 원소를 & 로 빌린 채 push_back 하는 코드 자체가 컴파일되지 않습니다. 재할당으로 주소가 바뀌어도 그걸 들고 있는 사람이 없으니, 더 단순하고 빠른 원형 버퍼를 고를 수 있었던 겁니다.
정리 — VecDeque 는 원형 버퍼 + head + len 입니다. 양끝 연산과 인덱싱은 O(1), 중간 삽입·삭제는 가까운 끝까지의 거리만큼. 감기면 메모리가 두 조각이라 as_slices() 가 둘을 주고, 한 장이 꼭 필요할 때만 make_contiguous() 로 폅니다.
SQ 05

우선순위 큐 — BinaryHeap 은 최대 힙, 최소는 Reverse

BinaryHeap<T> 는 T: Ord 인 값 중 가장 큰 것을 먼저 꺼냅니다. 자바 PriorityQueue · 파이썬 heapq · C# PriorityQueue 는 모두 가장 작은 것이 먼저라 옮겨 오는 사람마다 한 번씩 넘어집니다. 비교 함수를 넘기는 생성자가 없고, 순서는 타입의 Ord 구현이 정한다는 것도 Rust 만의 방식입니다. 그래서 최소 힙은 std::cmp::Reverse 로 감싸고, 여러 키는 Ord 를 직접 구현합니다.

트리는 머릿속에만 있고, 메모리에는 Vec 한 장입니다 — push(7) 의 경로 9 6 4 1 5 3 2 1 7 ① 7 > 1 교환 ② 7 > 6 교환 ③ 9 ≥ 7 멈춤 실제 저장 — 칸 번호 0 … 8 0 1 2 3 4 5 6 7 8 9 6 4 1 5 3 2 1 push(7) 뒤 9 7 4 6 5 3 2 1 1 칸 i 의 부모 (i−1)/2 · 자식 2i+1 · 2i+2 포인터가 없습니다 — 연속된 배열이라 캐시에 잘 맞습니다 초록 칸은 자리를 옮긴 원소, 붉은 칸이 새로 들어온 7 peek 은 칸 0 하나 · push 는 위로 최대 log n 번 · pop 은 끝 원소를 루트로 옮겨 아래로 BinaryHeap::from(vec) 은 아래층부터 한 번에 정리해 O(n) — push 를 n 번 하는 것보다 쌉니다
트리와 배열 — "트리"는 칸 번호 계산일 뿐입니다. 배열 순서는 정렬 순서가 아니라는 것도 여기서 보입니다.

기본 — 최대 힙 · 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_mutO(1)
push기대 O(1) · 최악 O(log n)
popO(log n)
BinaryHeap::from(vec)O(n)
into_sorted_vecO(n log n)
into_vecO(1) — 순서 보장 없음
retain (1.70+)O(n) — 끝나면 다시 정리
iter · drain임의 순서
push 가 "기대 O(1)"인 이유 — 문서는 무작위 순서로 넣을 때 평균적으로 위로 거의 안 올라간다고 설명합니다. 반대로 오름차순으로 계속 넣으면(최대 힙에 점점 큰 값) 매번 루트까지 올라가 O(log n) 이 됩니다. 타임스탬프처럼 단조 증가하는 키를 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!();                                      // 결제 환불 메일 정리
}
같은 우선순위의 순서는 보장되지 않습니다 — 힙은 안정(stable)하지 않아서, 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. 힙 안의 값은 못 고치니 다익스트라처럼 다시 넣고 낡은 것은 건너뜁니다.
SQ 06

스레드 안전 큐 — 채널이 기본, 직접 만들면 Mutex + Condvar

자바라면 ArrayBlockingQueue 를 꺼낼 자리에서 Rust 는 채널을 씁니다. 보내는 쪽(Sender)과 받는 쪽(Receiver)이 처음부터 갈라져 있어 "누가 넣고 누가 빼나"가 타입에 드러나고, 보내는 쪽이 모두 사라지면 받는 쪽 루프가 저절로 끝납니다 — 종료 신호를 따로 만들 일이 거의 없습니다. 채널 기초(mpsc · crossbeam 맛보기)는 ⚡ 성능 탭 e07에 있으니, 여기서는 용량 · 종료 · 워커 풀 · 직접 구현을 깊게 봅니다.

블로킹 큐의 대화 — 세로가 시간, 점선 화살표가 '깨우기' 생산자 스레드 큐 (Mutex + Condvar) 소비자 스레드 1. pop() — 비어 있음 → not_empty 에서 잠듦 2. push(job) — 락 · push_back 3. notify_one → 깨어나 Some(job) 4. push — 가득 참 → not_full 에서 잠듦 5. pop() — 한 칸 비움 6. notify_one → push 마저 완료 7. close() → notify_all 8. 남은 것 다 꺼낸 뒤 None → 종료 흐린 막대가 잠든 구간 — CPU 를 쓰지 않고 기다립니다 (바쁜 대기 없음) 채널(mpsc · crossbeam)은 이 대화 전체를 안에 숨겨 둔 완성품입니다
시퀀스 — 비면 소비자가, 가득 차면 생산자가 잠듭니다. 그게 배압이고, 종료는 close 한 번 + 남은 것 비우기입니다.
도구모양용량비었을 때 / 찼을 때고르는 기준
std::sync::mpsc::channelN → 1무제한recv 블록 / 안 참의존성 0 · 소비자가 하나 · 생산 속도가 확실히 느릴 때
std::sync::mpsc::sync_channel(n)N → 1n (0 = 랑데부)recv 블록 / send 블록표준만으로 배압이 필요할 때
crossbeam_channel::bounded(n)N → Mn블록 / 블록 · select!워커 풀 — 수신자를 clone 해서 나눠 받음 ★
Arc<Mutex<VecDeque>> + Condvar자유직접 정함직접 구현우선순위 · 중복 제거 · 일괄 꺼내기처럼 채널에 없는 규칙이 필요할 때
crossbeam::queue::ArrayQueueN → Mn 고정안 기다림 — None / Err락프리 · 폴링하는 구조(게임 루프 · 오디오)
crossbeam::queue::SegQueueN → 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²)
}
std mpsc 로 같은 걸 하려면 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
poison — 락을 쥔 스레드가 패닉하면 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) {}
"남은 일을 마저 할 것인가"가 선택 기준입니다 — ①②는 다 처리하고(drain) 끝나고, ③은 바로 끝납니다. 서버 종료라면 보통 ①로 입구를 닫고 타임아웃 안에서 drain 합니다(SQ 07).

락프리 큐 — 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);          // 비어도 기다리지 않음 → 폴링용
std mpsc 는 1.67 부터 crossbeam-channel 을 이식한 구현입니다. 그 전의 오래된 구현은 버그와 성능 문제로 악명이 높아 "std mpsc 는 쓰지 말고 crossbeam" 이 정설이었는데, 지금은 단일 소비자라면 std 로도 충분합니다. 이어서 1.72 에서 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 하는 것이고, 수신 쪽은 남은 것을 다 받은 뒤 자연히 끝납니다.
SQ 07

async 의 큐 — tokio::sync::mpsc 와 배압 · 종료

async 코드에서 스레드용 채널(std::sync::mpsc)의 recv() 를 부르면 런타임 워커 스레드 자체가 멈춥니다. 그래서 tokio 에는 .await 로 기다리는 채널이 따로 있습니다. 핵심은 하나 — 용량을 정한 channel(n) 의 send().await 가 곧 배압입니다. 소비자가 느리면 생산자가 자연히 늦춰집니다. 채널 네 종류와 액터 패턴은 🧵 서비스 탭 h03에서 다뤘으니, 여기서는 큐로서의 성질 · 워커 풀 · 깔끔한 종료에 집중합니다.

정상 종료 — 입구를 닫고(송신자 drop), 남은 것을 비우고(drain), 기다린다(join) 생산자 채널(4) 워커 main send(n).await 가득 → 여기서 대기 cancelled → break tx drop 버퍼에 쌓임 4/4 = 배압 닫힘 · 남은 것 유지 recv().await 처리 (느림) drain 후 None token.cancel() join · 유실 0 호박색 화살표가 배압 — 채널이 생산자를 붙잡아 소비 속도에 맞춥니다 취소는 생산자만 멈추고, 워커는 송신자가 사라질 때까지 남은 것을 끝까지 처리합니다
스윔레인 — 취소 신호는 입구에만 보내고, 출구는 채널이 닫히는 것으로 자연히 끝나게 합니다. 그래야 유실이 0 입니다.

생산자-소비자 + 정상 종료 — 완전한 예제

// 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대기 → Permitselect! 안 · 값 만들기가 비쌀 때
send_timeout시간 초과 시 Err"1초 넘게 밀리면 503"
blocking_send스레드를 막고 대기async 밖 스레드에서 보낼 때
채널모양가득 찼을 때느린 수신자큐로서 쓰는 곳
mpsc::channel(n)N → 1send 대기 (배압)생산자가 늦춰짐작업 큐 · 액터 입구 — 기본값
mpsc::unbounded_channel()N → 1없음 — 계속 자람메모리가 자람생산량이 구조적으로 제한될 때만 (SQ 10)
broadcast::channel(n)N → 모두가장 오래된 것을 덮어씀Lagged(k) 로 놓친 수를 알림이벤트 팬아웃 — 큐가 아니라 링
watch::channel(v)N → 모두최신 값 하나로 교체중간 값은 못 봄설정 · 상태 — 큐가 아님
oneshot::channel()1 → 1한 번만—요청에 대한 응답 한 개
async-channel 크레이트N → Msend 대기—수신자를 clone 하는 워커 풀
flume 크레이트N → Msend 대기—동기 스레드 ↔ 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 쪽은 평소대로
반대 방향의 실수 — 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 이면 포함)
배압의 끝은 사용자입니다 — 채널이 생산자를 기다리게 하고, 생산자(HTTP 핸들러)는 클라이언트에게 429·503 으로 "나중에"를 돌려줍니다. 이 사슬이 끊긴 곳(unbounded 하나)에서 메모리가 터집니다.
정리 — async 의 큐는 tokio::sync::mpsc::channel(n) 이고, send().await 가 곧 배압입니다. 종료는 취소 신호로 생산자만 멈추고 → 송신자 drop → 소비자는 남은 것을 비운 뒤 None. 여러 워커가 나눠 받으려면 Arc<Mutex<Receiver>>(락은 한 문장만) 또는 async-channel 을 씁니다.
SQ 08

알고리즘 패턴 — Rust 관용구로 쓰는 스택 · 큐 문제

괄호 검사 · 단조 스택 · BFS · 슬라이딩 윈도우 · 0-1 BFS · undo/redo 는 다른 언어와 알고리즘이 같습니다. Rust 에서 다른 것은 세 가지 습관입니다 — 큐에는 참조가 아니라 인덱스(usize)를 넣고, 꺼낼 때는 while let Some(..) = ..pop(), 이웃 좌표는 checked_add_signed 로 음수를 막습니다. 이론 설명은 CS 가이드 s03 · s09 에 있으니 여기서는 코드와 빌림 검사기 쪽을 봅니다.

다음 큰 수 — 큰 값이 오면 스택에서 작은 것들이 '답을 받고' 빠집니다 a[0]=2 a[1]=1 a[2]=2 a[3]=4 a[4]=3 → 4 → 2 → 4 None None 단계별 스택 (인덱스) i=0 · 2 — 빔 → push [0] i=1 · 1 — 2 ≥ 1 → push [0, 1] i=2 · 2 — pop 1 (답 2) [0, 2] i=3 · 4 — pop 2, 0 (답 4) [3] i=4 · 3 — 4 ≥ 3 → push [3, 4] 끝까지 남은 3, 4 는 답 없음(None) 각 인덱스는 한 번 들어가고 한 번 나옵니다 — 이중 루프처럼 보여도 전체 O(n) 스택 안의 값은 늘 '아래가 크거나 같은' 순서 — 그래서 단조(monotonic) 스택입니다
막대와 스택 스냅숏 — 화살표 끝이 곧 pop 되는 순간입니다. 인덱스를 쌓아야 답을 제자리에 적을 수 있습니다.

괄호 검사 · 다음 큰 수 — 스택

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
}
왜 덱인가 — 가중치가 0과 1뿐이면 덱의 앞쪽에는 거리 d, 뒤쪽에는 d+1 만 있게 유지할 수 있어 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
}
pos 는 바이트 위치입니다 — insert_str · replace_range 는 문자 경계가 아니면 패닉합니다. 한글을 다루는 편집기라면 위치를 char_indices 로 구하세요. 이력에 상한이 필요하면 undo 를 VecDeque 로 바꾸고 넘칠 때 pop_front() 로 가장 오래된 것을 버립니다.
정리 — 스택 문제는 Vec<usize> 에 인덱스를, 큐 문제는 VecDeque 에 인덱스나 좌표를 넣고 while let Some(..) = pop..() 으로 꺼냅니다. 빌림 에러가 나면 ① 큐를 빌려 돌지 말고 꺼내서 쓰기 ② &mut 대신 인덱스 ③ self 메서드 대신 필드 직접 — 이 세 가지로 거의 다 풀립니다.
SQ 09

성능과 메모리 — 연속 메모리가 거의 항상 이깁니다

복잡도표만 보면 LinkedList 의 양끝 연산도 O(1) 이라 VecDeque 와 같아 보입니다. 실제로 갈리는 것은 원소 하나에 할당이 몇 번 일어나는가와 다음 원소가 메모리의 어디에 있는가입니다. Rust 는 값을 컨테이너 안에 그대로 넣으므로(자바처럼 박싱하지 않음) 연속 배열의 장점이 특히 큽니다. 벤치마크 수치는 기계마다 다르니 자릿수만 보는 예시로 읽고, 직접 재는 법은 ⚡ 성능 탭 e01 의 criterion 을 쓰세요.

10만 개 넣고 FIFO 로 다 꺼내기 — 예시 값 · x 축은 한 칸이 10배 0.01 0.1 1 10 100 1000 ms VecDeque<u64> ≈ 0.1 ms · 할당 몇 번뿐 VecDeque<Box<u64>> ≈ 2 ms · 원소마다 할당·해제 LinkedList<u64> ≈ 3 ms · 노드 할당 + 포인터 추적 Vec::remove(0) ≈ 800 ms · O(n²) 이동 같은 O(1) 이라도 '할당 1회'가 붙으면 한 자릿수가 달라집니다 수치는 원리로 잡은 예시입니다 — 내 기계의 값은 criterion 으로 직접 재세요
막대(로그 눈금) — 연속 버퍼 · 원소별 할당 · O(n²) 가 각각 한 자릿수 이상씩 차이 납니다. 예시 값이므로 순서와 자릿수만 믿으세요.
u64 하나를 담는 데 드는 자리 — 캐시 줄(64B) 하나에 몇 개가 들어가나 VecDeque 8B 8B 8개 / 64B Vec<Box> ptr ptr ptr 값 8B 값 8B 값 8B 원소당 읽기 2번 + 할당기 머리말 LinkedList prev·next·값 24B prev · next · 값 prev · next · 값 다음 주소를 읽어야 다음 위치를 앎 → CPU 의 미리 읽기(prefetch)가 안 통함 값 8B 를 위해 VecDeque 8B · Vec<Box> 16B+ · LinkedList 24B+ (할당기 몫 별도)
메모리 배치 — 같은 원소 하나에 8B 대 24B+, 그리고 연속이냐 흩어졌냐. 속도 차이의 대부분이 여기서 나옵니다.

헤더 크기 — 직접 찍어 보기 (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 · VecDeque8B + 남는 용량늘 때만 (log n 번)
BinaryHeap8B + 남는 용량늘 때만
VecDeque<Box<u64>>8B 포인터 + 8B 값 + 머리말원소마다
LinkedList24B 노드 + 머리말원소마다

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 의 신뢰 구간 · 변화율(%)을 봄
큐 자체가 병목인 경우는 드뭅니다 — 대부분은 큐에 넣는 원소를 만드는 비용(String 할당 · clone)이나 락 경합(SQ 10) 쪽입니다. 프로파일러(flamegraph · 🧵 h11)에서 VecDeque 함수가 보일 때만 큐를 의심하세요.
정리 — 연속 버퍼(Vec · VecDeque · BinaryHeap)가 기본이고, 원소는 값으로 넣습니다. LinkedList 는 공식 문서도 권하지 않으며, Box 는 원소가 매우 크거나 트레이트 객체일 때만. 개수를 알면 with_capacity, 루프에서는 clear() 로 버퍼를 다시 씁니다.
SQ 10

흔한 실수 — 컴파일은 되는데 틀린 것과 컴파일러가 막아 주는 것

Rust 는 빈 컨테이너 접근과 데이터 경쟁을 타입으로 막아 주지만, O(n) 큐 · 방향을 착각한 힙 · 무한히 자라는 채널 · 필요 이상 오래 잡은 락은 컴파일러가 모릅니다. 여기 모은 열 가지는 리뷰에서 실제로 자주 보이는 것들입니다. 앞쪽 다섯은 "돌기는 도는데 틀린" 것, 뒤쪽 다섯은 "컴파일 에러가 나서 헤매는" 것입니다.

같은 9칸의 시간 — 락을 '언제 푸느냐'만 다릅니다 ❌ while let Some(j) = q.lock().unwrap().pop_front() { work(j) } 스레드 A 스레드 B 3건 ✅ let j = q.lock().unwrap().pop_front(); → 락은 이 줄에서 끝 스레드 A 스레드 B 6건 0 3 6 9 락을 쥔 채 락 없이 처리 락 기다림 while let · match · if let 의 조건식에서 만든 임시값(가드)은 본문 끝까지 살아 있습니다
간트 — 위는 락을 쥔 채 일하느라 스레드가 둘이어도 하나처럼 돌고, 아래는 꺼내는 순간만 잠가 두 배를 처리합니다.
❌ 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);                           // ✅ 락 없이 처리
}
// 위 그림의 아래쪽 줄이 이 코드입니다
edition 2024 에서 바뀐 것과 안 바뀐 것 — 2024 에디션은 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 채널 · SegQueuechannel(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)
정리 — 컴파일러가 막는 것(순회 중 수정 · Send 위반)은 꺼내서 쓰기 · 가드를 블록에 가두기로 풀고, 컴파일러가 모르는 것(O(n) 큐 · 힙 방향 · unbounded · 오래 쥔 락)은 타입 선택과 습관으로 막습니다. 특히 while let 조건에서 lock() 하지 않기는 에디션 2024 에서도 유효한 규칙입니다.
SQ 11

다른 언어와 비교 — 같은 일을 무엇으로 하나

다른 언어에서 넘어온 사람이 Rust 의 스택·큐에서 놀라는 지점은 대개 세 가지입니다 — 꺼내기가 Option이라 예외도 null 도 없고, 힙이 최대 힙이며, 스레드 큐 대신 채널을 쓴다는 것. 표 한 장에 놓고 보면 Rust 는 C++ 처럼 값을 연속 메모리에 담으면서도, 빈 컨테이너 접근을 C++ 처럼 UB 로 두지 않는 자리에 있습니다.

색이 진할수록 실수가 잦은 칸 — 글자가 실제 동작입니다 빈 것에서 꺼내면 힙 기본 방향 큐 표준 타입 스레드 간 큐 Rust None (Option) 최대 힙 · 반대 주의 VecDeque mpsc · crossbeam Java poll null · pop 예외 최소 힙 ArrayDeque BlockingQueue Python IndexError 최소 힙 (heapq) collections.deque queue.Queue C# 예외 · TryPop false 최소 힙 (.NET 6+) Queue<T> Channel<T> C++ UB (검사 없음) 최대 힙 std::deque · queue 표준에 없음 JS · TS undefined 표준에 없음 없음 · shift() O(n) 단일 스레드 Go panic (인덱스) Less 로 정함 슬라이스 · list chan 흐림 = 안전 · 진함 = 실수가 잦음 (값은 칸 안 글자) Rust 의 유일한 진한 칸이 '힙 방향' — 옮겨 올 때 가장 먼저 확인할 것입니다
매트릭스 — Rust 줄은 거의 흐린 칸입니다. 남은 한 칸 "최대 힙"만 기억하면 다른 언어 코드를 옮길 때 사고가 줄어듭니다.
하려는 일Rust ★Java 25Python 3.13C# · .NET 10C++23JS · Go
스택Vec push/popArrayDequelistStack<T>std::stack · vector배열 · 슬라이스
큐VecDequeArrayDequedequeQueue<T>std::queue직접 구현 · 슬라이스
덱VecDequeArrayDequedeque표준에 없음std::deque없음 · container/list
우선순위 큐BinaryHeap (최대)PriorityQueue (최소)heapq (최소)PriorityQueue<E,P> (최소)priority_queue (최대)없음 · container/heap
최소 ↔ 최대 바꾸기Reverse(x)Comparator.reverseOrder()음수로 넣기비교자 전달std::greaterLess 를 뒤집음
빈 것에서 꺼냄Nonenull 또는 예외IndexError예외 · TryDequeueUBundefined · panic
스레드 간 큐mpsc · crossbeam-channelArrayBlockingQueuequeue.QueueChannel · BlockingCollection표준에 없음 (TBB 등)— · chan
비동기 큐tokio::sync::mpsc가상 스레드 + BlockingQueueasyncio.QueueSystem.Threading.Channels표준에 없음async 반복자 · chan
순회 중 수정컴파일 에러CME (실행 중)list 는 조용히 · deque 는 RuntimeErrorInvalidOperation (실행 중)반복자 무효화 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 리스트 · Streamsredis
대용량 이벤트 로그Kafkardkafka (🧵 h07)
라우팅 · 재시도RabbitMQlapin
경량 pub/subNATSasync-nats
DB 를 큐로Postgres SKIP LOCKEDsqlx

파이썬 · Node 에서 Rust 모듈을 부를 때

큐를 경계 너머로 넘기지 마세요 — PyO3 · napi-rs 로 만든 Rust 모듈이 VecDeque 를 파이썬 객체로 노출하면 원소 하나마다 변환 비용이 듭니다. 대신 Rust 쪽이 큐와 워커를 소유하고, 파이썬·JS 에는 submit(job) · poll_results() 처럼 일괄로 주고받는 함수만 내보내는 것이 정석입니다(🐍 파이썬 탭 · 🟩 Node 탭). GIL 을 놓고 Rust 스레드가 채널로 일하게 하면 파이썬 스레드 여럿보다 훨씬 확장됩니다.
정리 — 스택 Vec · 큐/덱 VecDeque · 우선순위 BinaryHeap(최대) · 스레드 mpsc/crossbeam · async tokio mpsc. 다른 언어에서 옮길 때는 힙 방향, maxlen 자동 버림이 없음, null 신호 대신 enum·drop 세 가지를 확인하세요.
SQ 12

실무 사례 — 레이트 리미터 · 최근 N개 · 재시도 · 배치 · 워커

앞에서 본 타입들이 실제 서비스 코드에서 어떤 모양으로 쓰이는지 여섯 가지 사례로 묶었습니다. 전부 표준 라이브러리 또는 tokio 생태계만 쓰고, 시간에 의존하는 로직은 시각을 인자로 받게 만들어 테스트에서 값을 고정할 수 있게 했습니다. 이 습관 하나로 레이트 리미터·재시도 큐 같은 코드의 단위 테스트가 sleep 없이 결정적으로 돕니다.

1초에 3번 — 각 요청 시점에서 '지난 1초'를 되돌아봅니다 O O O X X O O O O 0 100 200 300 900 1000 1100 1250 2000 ms t=300 → 창 안 0·100·200 = 3개 → 거절 t=1000 → 0 은 밀려남 · 2개 → 허용 t=2000 → 1100·1250 = 2개 → 허용 VecDeque 앞에서 '오래된 기록'을 버리고 뒤에 '새 기록'을 넣습니다 — 길이는 최대 3 창의 왼쪽 끝은 열린 구간: t − 1000 과 같은 시각의 기록은 이미 창 밖입니다
시간축 — 슬라이딩 윈도우는 앞에서 버리고 뒤에 넣는 덱 그 자체입니다. 메모리는 허용 횟수만큼만 씁니다.

① 레이트 리미터 — 슬라이딩 윈도우 로그

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 으로 루프가 끝남)
}
BinaryHeap 재시도 큐(③)와 무엇이 다른가 — DelayQueue 는 tokio 타이머 휠 위에 있어 넣을 때 받은 키로 바로 연장·취소할 수 있고, 깨어날 시각을 직접 계산할 필요가 없습니다. 세션 만료 · 캐시 TTL · 타임아웃처럼 "넣은 뒤 바뀌는 시각"이 있으면 DelayQueue, 넣고 나면 안 바뀌면 힙이 더 단순합니다.
정리 — 레이트 리미터와 최근 N개는 VecDeque 의 앞에서 버리고 뒤에 넣기, 재시도는 시각을 키로 한 최소 힙, 배치 저장은 recv_many + 시간 상한, 동시 실행 제한은 세마포어, 바뀌는 만료 시각은 DelayQueue. 시간을 인자로 받게 짜면 이 모든 코드를 sleep 없이 테스트할 수 있습니다.
패키지 · 크레이트 · 모듈 · 가시성 · 워크스페이스 · Axum · CLI · Tauri · 라이브러리 · build.rs · Rust 1.89 · edition 2024

폴더가 아니라
크레이트와 가시성이
구조를 지킵니다

Rust 는 다른 언어보다 구조를 지키기가 쉽습니다. 모듈은 mod 로 직접 선언해야 생기고, 기본이 비공개이며, 크레이트끼리는 Cargo.toml 에 적은 방향으로만 use 할 수 있기 때문입니다. 대신 "어디서 나눌지"는 아무도 정해 주지 않습니다. 이 탭은 웹 서비스 · CLI · 데스크톱 · 라이브러리 · 워크스페이스의 권장 구조를 한 장씩 보여 주고, 각 구조에서 무엇을 어디에 두는지, 무엇을 공개하는지, 언제 다음 단계로 넘어가는지를 짧게 정리합니다. 모듈 문법 자체는 🦀 기초 탭 r09, 워크스페이스 설정은 🛠️ 빌드 탭 b05 에 있으니 여기서는 판단에 집중합니다.

10SECTIONS
5프로젝트 유형
4가시성 단계
2024edition 기준
STR 01

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)
lib.rs + main.rs 를 같이 두는 것이 기본값입니다. 패키지 이름이 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 참고.

규모별 진화 — 언제 다음 단계로 넘어가나 🟡 중급

규모별 진화 — 위는 구조, 아래는 그 단계로 넘어가는 신호 main.rs 하나 fn 이 전부 한 파일 모듈 파일 몇 개 mod config; mod db; lib + main src/lib.rs + tests/ 기능 모듈 트리 users.rs + users/ 워크스페이스 crates/* 프로토타입 cargo new main.rs 500줄+ 스크롤로 못 찾음 테스트 · 두 번째 bin 로직을 lib 로 모듈 15개+ 기능 경계가 보임 빌드 느림 · 배포 2+ [workspace] 넘어가는 신호는 줄 수보다 테스트 · 진입점 · 빌드 시간 · 배포 단위입니다
초록 점이 권장 출발점 — 첫 테스트나 두 번째 실행 파일이 생기는 순간 로직을 lib.rs 로 옮깁니다.
전환신호 (하나라도 해당하면)하는 일
main.rs → 모듈 파일한 파일 500줄+ · user_ · order_ 같은 접두사 함수 묶음이 보임접두사를 모듈 이름으로: mod user;
모듈 → lib + maintests/ 에서 검증하고 싶음 · 두 번째 실행 파일 · 바인딩 계획로직을 lib.rs 로, main.rs 는 10~30줄
lib → 기능 모듈 트리모듈 15개+ · "주문 쪽 · 회원 쪽" 대화가 나옴orders.rs + orders/ · pub(crate) 경계 (STR 02)
→ 워크스페이스한 줄 고쳐도 cargo check 가 수십 초 · 배포 단위 2+ · proc-macro · 무거운 의존성 격리crates/* 로 분리 (STR 03)
숫자는 경험칙입니다 — 500줄 · 15개는 공식 기준이 아니라 "이쯤에서 대개 불편해진다"는 감각입니다. 공식으로 정해진 것은 자리(Cargo 의 자동 탐색 규칙)와 모듈 파일 규칙뿐이고, 나머지는 팀이 정해 STR 10 의 도구로 지킵니다.
정리 — 패키지 하나에 lib.rs(로직)와 main.rs(얇은 입구), 테스트 · 예제 · 벤치는 Cargo 가 찾는 자리에. 모듈 파일은 foo.rs + foo/ 로 통일하고, 테스트 · 진입점 · 빌드 시간 · 배포 단위가 늘 때마다 한 단계씩 나눕니다.
STR 02

모듈 가시성으로 경계 만들기 — 폴더 대신 pub 의 범위

다른 언어에서 "이 폴더는 건드리지 마세요"는 문서나 리뷰로 지킵니다. Rust 는 가시성 한정자가 그 일을 합니다. 한 크레이트 안의 기능 모듈 사이 경계는 pub(crate) 와 pub(super) 로, 크레이트 밖과의 경계는 pub use 재노출로 긋습니다.

바깥 원일수록 보는 사람이 많고, 바꾸면 깨지는 코드도 많습니다 비공개 pub(super) pub(crate) pub pub — 크레이트 밖 사용자까지 — 바꾸면 semver 메이저 (STR 07) pub(crate) — 크레이트 안 어디서나 — 바꾸면 컴파일러가 전부 찾아 줌 pub(super) — 부모 모듈까지 — 형제 파일이 나눠 쓰는 도우미 (없음) — 이 모듈과 자식만 — 기본값 · 마음대로 바꿈 기본은 비공개 — 필요한 만큼만 한 칸씩 바깥으로 엽니다
동심원 — pub 은 약속입니다. 바깥 원에 올린 이름만 바꿀 때 사용자 코드가 깨집니다.

구조 — 공개 얼굴은 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)크레이트 밖라이브러리의 약속 · 워크스페이스의 다른 크레이트
prelude 는 관례이지 의무가 아닙니다 — std 의 std::prelude 를 흉내 낸 것으로, 트레이트를 많이 가져와야 쓸 수 있는 크레이트 (예: diesel::prelude · bevy::prelude)에서 빛납니다. 공개 이름이 열 개 남짓이면 pub use 만으로 충분합니다. 비공개 모듈 안의 pub 이 실제로 밖에 닿는지는 rustc 의 unreachable_pub 린트가 짚어 줍니다(STR 10).
정리 — 모듈은 비공개로 선언하고 공개 이름은 lib.rs 의 pub use 로 평평하게. 기능 모듈 사이는 pub(crate), 폴더 안 도우미는 pub(super). 가시성이 곧 경계라서, 경계를 어기면 리뷰가 아니라 컴파일러가 막습니다.
STR 03

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] 가 적용되지 않습니다
화살표 = [dependencies] — 모두 domain 쪽으로 흐릅니다 실행 파일 server cli 실행 파일 HTTP 를 아는 층 api infra sqlx · reqwest domain 의존성이 가장 적은 바닥 serde · thiserror 정도 테스트가 가장 빠릅니다 domain → infra 는 순환 — Cargo 가 거부합니다 선언하지 않은 화살표는 존재할 수 없습니다 — 경계가 문서가 아니라 빌드입니다
의존 그래프 — 화살표가 한쪽(domain)으로만 흐르면 바닥을 바꾸지 않고 위를 갈아 끼울 수 있습니다.

크레이트를 쪼개는 기준 🟡 중급

기준이럴 때 쪼갭니다주의
컴파일 시간크레이트가 병렬 · 증분 컴파일의 단위입니다. 자주 바뀌는 코드와 무거운 의존성(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 쪽으로 — 쪼개는 이유는 컴파일 시간 · 재사용 · 경계 셋 중 하나여야 합니다.
STR 04

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 는 같은 모양이라 생략
위에서 아래로만 부릅니다 — 오른쪽 둘은 모두가 씁니다 routes.rs 경로 · 메서드 → 핸들러 연결 · nest / merge 로 기능별 Router 합치기 handlers/ State · Path · Json 추출 → 호출 → Result<Json, AppError> domain/ 타입 · 규칙 · axum/sqlx 모름 repo/ sqlx 쿼리 · &PgPool 인자 state.rs AppState — 풀 · 설정 with_state 로 한 번 주입 error.rs AppError → 응답 코드 ? 로 어디서나 전파 domain 에 axum · sqlx 가 보이면 경계가 무너진 것입니다 한 크레이트 안에서는 컴파일러가 이 방향을 막지 못합니다
층 구조 — 핸들러는 얇게, 규칙은 domain, SQL 은 repo. 이 방향을 빌드로 지키고 싶어지면 🌐 w03 처럼 크레이트로 나눕니다.
❌ 계층 폴더가 기능 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)—
Axum 0.8 의 nest — 루트("/")나 빈 경로에 nest 하면 패닉합니다. 접두사 없이 합칠 때는 merge 를 쓰세요. 합치는 라우터들은 같은 상태 타입(Router<AppState>)이어야 하고, with_state 는 맨 바깥에서 한 번만 부릅니다.
정리 — lib.rs 의 app(state) 가 입구, routes.rs 가 지도, 핸들러는 얇게, 규칙은 domain, SQL 은 repo. 기능이 늘면 기능 모듈로, 방향을 빌드로 지켜야 하면 크레이트로 넘어갑니다.
STR 05

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));
    }
}
같은 도구, 다른 배치 — 테스트와 재사용이 갈립니다 main.rs 에 전부 main 은 얇게 · lib 에 로직 fn main() { 인자 파싱 · 설정 파일 읽기 파일 처리 300줄 println! 로 바로 출력 실패하면 process::exit(1) } 테스트 = 바이너리 실행뿐 · 재사용 불가 main.rs — 10줄 Cli::parse() → run(cli) → ExitCode lib.rs + commands/ 로직 전부 · 함수로 바로 테스트 같은 로직을 GUI · 서버에서도 씁니다 main 이 하는 일은 셋 — 파싱 · 위임 · 종료 코드 결정
좌우 대조 — main.rs 를 얇게 두면 로직이 라이브러리가 되고, 테스트와 재사용이 공짜로 따라옵니다.
무엇을어디에이유
clap derive 구조체cli.rs (pub mod cli)main · 테스트 · 셸 자동완성 생성(build.rs 나 xtask)이 함께 씀
서브커맨드 실행commands/이름.rs 의 run(&Args)명령 하나 = 파일 하나 · 추가 · 삭제가 파일 단위
출력 형식output.rsprintln! 이 흩어지면 --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 에서만 하고, 로직은 라이브러리 함수로 테스트합니다.
STR 06

Tauri 데스크톱 — src-tauri 와 프런트 분리, commands 모듈

만들어진 폴더 하나하나의 뜻은 🖥️ u02, 커맨드 · IPC 는 u03, 권한은 u06, 실전 앱의 전체 구조는 u13 에 있습니다. 여기서는 앱이 커질 때 Rust 쪽 파일을 어떻게 나누고, 로직을 언제 UI 없는 크레이트로 떼는지만 짧게 봅니다.

두 세계가 나란히 — 만나는 곳은 invoke 한 줄뿐입니다 my-app/ (저장소) src/ Vue · React — 그냥 웹 앱 src-tauri/ Rust 크레이트 · 앱 셸 crates/core/ (커지면) UI 없는 로직 invoke 의존 lib.rs Builder · 등록만 commands/ #[tauri::command] capabilities/ 창별 권한 JSON 로직이 커지면 core 로 빼서 Tauri 없이 테스트합니다
트리 — 프런트와 Rust 는 invoke 로만 만나고, 무거운 로직은 UI 를 모르는 core 로 내려갑니다.

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! 에 경로로 등록하세요 — 위 구조가 그 모양입니다.
core 로 떼는 신호 — 커맨드 함수 안에 수백 줄이 생김 · 같은 로직을 CLI 나 서버에서도 쓰고 싶음 · Tauri 없이 단위 테스트하고 싶음. 커맨드는 "core 를 부르고 에러를 프런트용으로 바꾸는" 얇은 어댑터로 남깁니다. 권한(capabilities/*.json)은 창 단위로 쪼개 u06 처럼 관리합니다.
정리 — 프런트는 src/, Rust 는 src-tauri/, 실제 구성은 lib.rs 의 run(). 커맨드는 commands/ 모듈에 pub fn 으로, 로직이 커지면 UI 없는 core 크레이트로 내립니다.
STR 07

라이브러리 크레이트 — 공개 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;
feature 는 의존 그래프 전체에서 합쳐집니다 — 내 크레이트를 쓰는 A 가 tls-rustls, B 가 tls-native 를 켜면 한 빌드에 둘 다 켜집니다. 그래서 "켜면 기능이 사라지는" feature 나 compile_error! 로 막는 상호 배타 feature 는 남의 빌드를 깨뜨립니다. no_std 도 같은 이유로 std feature 를 켜는 쪽으로 설계합니다.
lib.rs 는 목차입니다 — 사용자가 보는 이름은 전부 여기서 정해집니다 //! 빠른 파서. 예제는 examples/ 참고 mod parser; mod error; pub use parser::{Parser, Options}; pub use error::{Error, Result}; #[cfg(feature = "json")] mod json; #[cfg(feature = "json")] pub use json::to_json; pub mod prelude; docs.rs 첫 화면 — 소개와 첫 예제 구현 파일은 비공개 — 경로를 약속하지 않음 평평한 공개 이름 — fastparse::Parser Error · Result 별칭은 루트에 feature 를 켜야 생기는 이름 (더하기만) 트레이트 위주로 작게 · 선택 사항 parser.rs 를 폴더로 쪼개도 사용자 코드는 한 줄도 안 바뀝니다
해부도 — 공개 이름은 lib.rs 한 파일에서 전부 보여야 합니다. 그래야 리뷰에서 "약속이 늘었나"를 바로 압니다.
규칙왜도구 · 표기
확장할 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 로 약속이 깨졌는지 기계에게 묻습니다.
STR 08

테스트 위치 — 같은 파일 · 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);
}
어디에 두느냐가 곧 무엇을 보느냐 — 진한 칸이 가능 비공개 fn pub(crate) pub API dev-deps 단위 (mod tests) ✓ 가능 ✓ 가능 ✓ 가능 ✓ 가능 통합 (tests/) ✗ 안 됨 ✗ 안 됨 ✓ 가능 ✓ 가능 문서 테스트 (///) ✗ 안 됨 ✗ 안 됨 ✓ 가능 ✓ 가능 examples · benches ✗ 안 됨 ✗ 안 됨 ✓ 가능 ✓ 가능 비공개를 보는 건 mod tests 하나뿐 — 나머지는 모두 '밖의 사용자'입니다 그래서 로직이 main.rs 에만 있으면 아래 세 줄을 하나도 못 씁니다
매트릭스 — 아래 세 줄은 별도 크레이트로 컴파일되므로 공개 API 만 봅니다. 이것이 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/ 에 흩지 않음
정리 — 비공개 로직은 같은 파일의 mod tests, 공개 API 시나리오는 tests/, 공용 헬퍼는 tests/common/mod.rs. 통합 테스트 파일이 많아 느리면 tests/it/main.rs 하나로 묶습니다.
STR 09

빌드 스크립트 · FFI · 생성 코드 — build.rs · OUT_DIR · *-sys

C 라이브러리를 붙이거나, 프로토콜 정의 · 표에서 코드를 만들어야 하면 컴파일 전에 도는 코드가 필요합니다. Rust 에서 그 자리는 패키지 루트의 build.rs 하나이고, 생성물은 OUT_DIR 에만 씁니다. bindgen · cbindgen 의 사용법은 🔗 f08 · f04 에 있으니, 여기서는 파일을 어디에 두는지만 봅니다.

build.rs 가 낀 빌드 — 되돌아가지 않는 다섯 칸 build.rs 본체보다 먼저 실행 OUT_DIR 생성 파일은 여기만 include! concat! + env! 컴파일 크레이트 본체 링크 rustc-link-lib rerun-if-changed 가 없으면 매번 재실행 링크 지시도 build.rs 가 출력합니다 src/ 에 쓰지 마세요 — build.rs 의 출력은 OUT_DIR 안에만 둡니다
파이프라인 — build.rs 는 앞칸에서 한 번 돌고, 본체는 OUT_DIR 의 결과만 끌어옵니다.

표에서 코드 만들기 — 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(안전) 둘로 나눕니다.
STR 10

한 장 요약 — 유형 · 규모별 권장 구조와 규칙 강제

앞의 아홉 섹션을 표 한 장과 질문 세 개로 줄입니다. 그리고 정한 규칙을 문서가 아니라 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 경계 · capabilitiescore 를 CLI 와 공유
공개 라이브러리lib.rs 목차 · 비공개 모듈 · features · examples (STR 07)pub use · semver-checks확장 크레이트가 생김 → 워크스페이스
바인딩 여러 개 · FFIcore + python · node · capi (🛠️ b05) · foo-sys + foo (STR 09)links · 크레이트 경계—
무엇으로 시작할 것인가 — 마름모가 질문, 초록 상자가 답입니다 실행 파일 2개+ 또는 로직 공유? 아니오 예 워크스페이스 crates/* · STR 03 남이 쓸 라이브러리인가? 예 라이브러리 구조 lib.rs 목차 · STR 07 아니오 500줄 이상 또는 테스트할 로직? 예 lib + main + 모듈 기본값 · STR 01 아니오 main.rs 하나 학습 · 일회성 도구 어느 갈래로 가도 결국 lib.rs + 얇은 main.rs 를 지나갑니다 규칙을 정했다면 → 아래 lints · cargo-deny 로 고정 구조는 한 번 고르는 게 아니라 신호가 올 때 다시 이 그림을 타는 것입니다
질문 셋이면 시작 구조가 정해집니다 — 대부분의 답은 "lib.rs + 얇은 main.rs"입니다.

구조 규칙을 도구로 강제하기 🟡 중급

린트 — 루트에 한 번 (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안 쓰는 의존성 (빌드 시간)가끔 오탐 — 메타데이터로 무시 지정
cargo-machete 는 소스를 문자열로 훑는 방식이라 매크로 안에서만 쓰는 의존성을 "안 쓴다"고 볼 수 있습니다. 그런 크레이트는 [package.metadata.cargo-machete] ignored = ["이름"] 으로 빼고, 더 정확한 검사가 필요하면 nightly 의 cargo-udeps 를 씁니다(🛠️ b10).
정리 — 시작은 대부분 lib.rs + 얇은 main.rs, 실행 파일이 둘이 되면 워크스페이스. 경계는 가시성과 크레이트 의존 선언이 먼저 지키고, 나머지는 [workspace.lints] · cargo-deny · cargo-machete 를 CI 에 걸어 리뷰가 아니라 빌드가 지키게 합니다.