Writing Rust Guide
Clean Code/OOP style에서 Rust style로 넘어가는 code writing 가이드
목적: 이 문서는 Rust for Rustaceans와 Programming Rust를 중심으로, 앞서 정리한 Clean Code / A Philosophy of Software Design 기반 code writing guide를 Rust에 맞게 재해석한 개인 학습용 guide다. Rust의 ownership, borrowing, lifetime, trait, enum, Iterator, Result, Option, async, unsafe, FFI 같은 언어적 특징이 code design을 어떻게 바꾸는지 설명한다.
전제: 이 문서는 책의 번역본이 아니다. 원서의 세부 설명과 예시는 각 책을 직접 참고해야 한다. 여기서는 여러 자료의 design idea를 한국어로 재구성하고, 실제 code review와 systems programming에 적용할 수 있는 기준으로 정리한다.
용어 원칙: ownership, borrow checker, trait, generic, enum, module, API, FFI, unsafe, Iterator, async, deep module, complexity, invariant 같은 technical term은 English 그대로 둔다.
주요 참고 자료:
- Jon Gjengset, Rust for Rustaceans: Idiomatic Programming for Experienced Developers — intermediate Rust, API design, error handling, testing, macros, async, unsafe, concurrency, FFI,
no_std, ecosystem. - Jim Blandy, Jason Orendorff, Leonora F. S. Tindall, Programming Rust: Fast, Safe Systems Development — systems programming 관점의 Rust, ownership/reference, traits/generics, iterator, concurrency, async, unsafe, FFI.
- John Ousterhout, A Philosophy of Software Design —
complexity,deep module, information hiding, interface design. - Robert C. Martin, Clean Code — local readability, naming, small functions, testing discipline.
- Rust API Guidelines, Rust Style Guide, Cargo SemVer compatibility, Clippy, Rustonomicon, Firefox Source Docs, Tokio
mini-redis, Serde, ripgrep.
0. 한 문장 요약
좋은 Rust code는 단지 “borrow checker를 통과하는 code”가 아니라, state, ownership, error, concurrency, resource lifetime, API contract를 type과 module boundary에 넣어서 future maintainer가 추측하지 않아도 되는 code다.
기존 OOP/Clean Code guide가 주로 “function/class/name/test를 어떻게 깨끗하게 만들 것인가?”를 묻는다면, Rust guide는 한 단계 더 묻는다.
이 상태는 type으로 표현되는가?
이 resource의 owner는 누구인가?
이 function signature만 보고 allocation, mutation, blocking, failure, concurrency contract를 알 수 있는가?
이 API를 잘못 쓰기 어렵게 만들었는가?
Rust는 partially functional하고 strongly typed한 systems language다. 그래서 code writing의 중심축이 다르다. class hierarchy보다 enum과 trait가 중요하고, mutable object graph보다 ownership tree와 borrowed view가 중요하며, runtime convention보다 compile-time contract가 중요하다.
1. 이 문서를 읽는 법
이 guide는 Rust 초보 문법 tutorial이 아니다. The Rust Book을 읽었거나, 최소한 struct, enum, trait, Result, Option, Vec, String, &str, Iterator, async의 기본 사용법은 안다고 가정한다. 대신 여기서는 “어떤 코드를 짜야 좋은 Rust code인가?”에 집중한다.
읽는 순서는 다음이 좋다.
- Part I에서 OOP-style guide와 Rust-style guide의 차이를 먼저 잡는다.
- Part II에서
ownership,type,module,API중심의 design lens를 익힌다. - Part III에서 실전 coding pattern을 본다.
- Part IV에서 Firefox, Tokio, Serde, ripgrep 같은 real-world code에서 배울 design habit을 정리한다.
- 마지막 checklist를 code review 때 사용한다.
특히 C/C++/Java/Python을 많이 쓴 사람은 “왜 Rust에서는 굳이 이렇게 불편하게 쓰지?”라는 느낌을 자주 받는다. 이 문서의 목표는 그 불편함을 없애는 것이 아니라, 그 불편함이 어떤 design signal인지 해석하는 데 있다. borrow checker가 귀찮은 적처럼 느껴질 때도 있지만, 대부분은 “ownership boundary가 흐릿하다”, “mutable state가 너무 넓게 퍼졌다”, “API가 필요한 것보다 많은 promise를 하고 있다”는 signal이다.
2. 기존 OOP/Clean Code guide의 핵심을 Rust로 다시 쓰기
앞선 guide의 핵심은 대략 다음과 같았다.
- 좋은 code의 목적은
changeability다. readability는 중요하지만, 그 자체가 끝이 아니다.Clean Code의 local discipline과A Philosophy of Software Design의deep module관점은 함께 써야 한다.small function,DRY,comment minimization,TDD,SOLID는 absolute rule이 아니라 heuristic이다.- systems code에서는
resource ownership,error path,concurrency,invariant,performance가 readability만큼 중요하다.
Rust에서는 이 원칙들이 더 강하게 type system으로 들어온다. 예를 들어 C++ code에서 “이 pointer를 누가 free하나?”는 comment나 convention에 의존할 수 있다. Rust에서는 Box<T>, Vec<T>, String, Arc<T>, &T, &mut T, Cow<'a, T> 같은 type이 ownership relation을 드러낸다. Java code에서 “이 object는 null일 수 있나?”는 annotation이나 documentation 문제일 수 있다. Rust에서는 Option<T>가 signature에 나타난다. Python code에서 “이 function이 실패하면 어떻게 되나?”는 runtime convention일 수 있다. Rust에서는 대체로 Result<T, E>가 signature에 나타난다.
즉, Rust에서 좋은 code writing은 “깨끗한 implementation”보다 먼저 “정직한 type signature”에서 시작한다. Signature가 거짓말하면 implementation이 아무리 예뻐도 maintainer는 고생한다.
// 좋지 않은 신호: 실패할 수 있고 allocation도 할 수 있는데 signature가 너무 조용하다.
fn load_config(path: String) -> Config;
// 더 정직한 API: caller가 path ownership을 넘길 필요가 없고, failure가 드러난다.
fn load_config(path: impl AsRef<std::path::Path>) -> Result<Config, ConfigError>;
두 번째 signature는 조금 길지만 더 많은 design information을 담는다. Caller는 String, &str, PathBuf, &Path 등을 넘길 수 있고, 실패 가능성을 다뤄야 한다. Rust에서는 이런 “귀찮음”이 code quality의 일부다.
0. 두 Rust 책의 역할: Programming Rust와 Rust for Rustaceans
이 guide에서 두 책은 서로 다른 역할을 한다. Programming Rust는 Rust를 systems programming language로 이해하기 위한 넓은 foundation이다. Ownership, references, fundamental types, traits/generics, iterators, collections, strings, IO, concurrency, async, macros, unsafe, FFI까지 폭넓게 다룬다. 특히 C/C++ systems programmer가 Rust로 옮겨갈 때 어떤 문제를 Rust가 language-level로 해결하는지 이해하는 데 좋다.
Rust for Rustaceans는 그 다음 단계의 책이다. Rust를 이미 어느 정도 알고 있는 developer가 “왜 idiomatic Rust API는 이렇게 생겼는가?”, “generic과 trait object는 어떻게 trade-off 되는가?”, “unsafe boundary를 어떻게 관리하는가?”, “async, Pin, Waker, no_std, project feature, versioning은 어떻게 생각해야 하는가?” 같은 질문에 답하도록 돕는다. 이 책은 Rust syntax보다 taste, mechanism, trade-off에 가깝다.
| 주제 | Programming Rust의 역할 |
Rust for Rustaceans의 역할 |
이 guide의 적용 |
|---|---|---|---|
| Systems programming | Rust가 C/C++의 undefined behavior와 memory/concurrency bug를 어떻게 줄이는지 큰 그림 제공 | no_std, FFI, unsafe, concurrency의 deeper mechanism 제공 |
OS/codebase design에서 Rust를 안전한 systems language로 쓰는 기준 |
| Ownership/references | Move, borrow, lifetime을 단계적으로 설명 | high-level/low-level memory model, variance, drop check 같은 nuance 설명 | API에서 owned/borrowed 선택, clone/Arc/RefCell smell 판단 |
| Types/traits | Traits, generics, trait objects, associated types의 기본 | coherence, orphan rule, object safety, marker traits, existential types의 design implication | trait vs enum, generic vs dyn, sealed trait, public API evolution |
| Interface design | 여러 chapter에 걸쳐 idiom을 학습 | unsurprising, flexible, obvious, constrained를 API design 원칙으로 제시 |
library API checklist, bool parameter 회피, private fields, builder |
| Error handling | Result, ?, custom error, panic의 기본 |
enumerated vs opaque errors, propagation strategy | library/application error type 분리, error context 설계 |
| Testing/tooling | cargo test, docs, module/test 기본 | fuzzing, performance testing, ecosystem tooling | CI/checklist/fuzz/property testing strategy |
| Unsafe/FFI | unsafe block, raw pointer, C interop의 기본 | unsafe contract, validity, panic safety, FFI layout/detail | safe wrapper, safety comment, repr(C)/CString/pointer+len policy |
| Concurrency/async | thread, channel, mutex, async 개요 | memory ordering, actors, async internals, executors | task lifecycle, cancellation, lock scope, async boundary 설계 |
두 책을 함께 읽을 때 가장 좋은 순서는 “기본 mechanism → design implication → 실제 code reading”이다. 예를 들어 Programming Rust에서 Result와 ?를 이해한 다음, Rust for Rustaceans에서 error representation의 API trade-off를 읽고, 마지막으로 실제 crate에서 error type이 어떻게 구성되는지 보면 학습 효과가 크다.
0.1 이 guide가 두 책을 사용하는 방식
이 문서는 두 책의 chapter를 따라 요약하지 않는다. 대신 code를 작성할 때 반복적으로 마주치는 design decision으로 재구성한다.
- Function argument는 owned로 받을까 borrowed로 받을까?
enum으로 modeling할까,trait로 extension point를 열까?- Library error는 custom enum으로 만들까, opaque error로 둘까?
Arc<Mutex<T>>는 필요한가, channel/owner task가 나은가?unsafe를 public contract로 노출해야 하는가, safe wrapper로 숨길 수 있는가?- Async function은 정말 필요한가, blocking API가 더 단순한가?
- Public struct field를 열어도 되는가, SemVer risk가 큰가?
이 질문들은 Rust 문법 문제라기보다 software design 문제다. Rust의 특징은 이런 design decision이 type signature, trait bound, lifetime, feature flag, public/private boundary에 직접 나타난다는 것이다.
0.2 이전 OOP-style guide와 연결되는 지점
A Philosophy of Software Design의 핵심인 complexity reduction은 Rust에서 다음처럼 구체화된다.
deep module→ small public API + private representation + type-checked invariant.information hiding→ private fields, module privacy, sealed traits, safe wrappers around unsafe/FFI.change amplification감소 → newtype, enum, exhaustive match, SemVer-aware API.cognitive load감소 →Option/Result/enum으로 hidden state를 type에 드러냄.unknown unknowns감소 → panic/error/blocking/safety contract를 rustdoc에 명시.
Clean Code의 핵심인 local readability는 Rust에서 다음처럼 조정된다.
- Small function은 좋지만, borrow/data flow를 깨뜨리는 과한 fragmentation은 피한다.
- Comment는 implementation 설명보다 invariant, safety, error, public contract 설명에 집중한다.
- Test는 behavior뿐 아니라 type-level invariant와 API ergonomics를 확인한다.
- Naming은 Rust convention과 standard library idiom을 따른다:
new,with_capacity,as_ref,into_inner,try_from,iter,iter_mut,into_iter.
0.3 Rust guide의 핵심 thesis
OOP guide의 질문이 “이 class/function은 깨끗한가?”라면 Rust guide의 질문은 다음에 가깝다.
이 type signature는 caller가 알아야 할 contract를 충분히 드러내는가? 그리고 caller가 몰라도 되는 complexity를 private implementation 안에 숨기는가?
좋은 Rust code는 compiler에게 더 많은 사실을 알려준다. 하지만 모든 사실을 type에 올리지는 않는다. Type-level cleverness가 지나치면 reader가 고통받는다. 따라서 Rust design의 목표는 maximum type cleverness가 아니라 minimum sufficient type precision이다. Illegal state와 expensive bug는 type으로 막고, 단순한 local detail은 평범한 code로 둔다.
Part I. OOP-style에서 Rust-style로
3. OOP-style code와 Rust-style code의 중심 차이
OOP-style design은 대개 object를 중심에 둔다. Object는 identity, state, behavior를 함께 가진다. Class는 object의 shape를 정의하고, inheritance는 variation을 표현하며, interface는 polymorphism을 제공한다. 좋은 OOP design은 mutable state를 캡슐화하고, object 간 collaboration을 통해 behavior를 분산한다.
Rust-style design은 다르게 시작한다. Rust에도 struct와 method가 있으므로 surface syntax만 보면 OOP처럼 보일 수 있다. 그러나 Rust의 중심은 object identity가 아니라 value, ownership, type relation이다.
OOP에서 흔한 질문:
- 이 object는 어떤 class의 instance인가?
- 이 class는 어떤 interface를 implement하는가?
- 이 object의 method를 호출하면 internal state가 어떻게 바뀌는가?
- inheritance를 써서 variation을 표현할까?
Rust에서 더 먼저 물어야 하는 질문:
- 이 value의 owner는 누구인가?
- 이 function은 value를 consume하는가, borrow하는가, mutate하는가?
- 이 state transition은
enum으로 표현할 수 있는가? - 이 invalid state를 type으로 막을 수 있는가?
- 이 polymorphism은
generic이 나은가,dyn Trait이 나은가? - 이 API가
Send,Sync,Clone,Debug,Default같은 expected trait을 만족하는가?
OOP code를 Rust로 옮길 때 가장 흔한 mistake는 모든 class를 struct로, 모든 interface를 trait object로, 모든 shared mutable object를 Arc<Mutex<T>>로 기계적으로 바꾸는 것이다. 이렇게 하면 code는 compile될 수 있지만 Rust답지 않다. Rust는 “shared mutable graph”를 기본 모델로 삼지 않는다. Rust다운 code는 많은 경우 data를 소유한 component가 명확하고, 다른 component는 필요한 순간에만 borrowed view를 받는다.
3.1 class hierarchy 대신 enum
OOP에서는 variation을 subclass로 표현하기 쉽다.
Shape
├── Circle
├── Rectangle
└── Polygon
Rust에서는 variation이 closed set이면 enum이 더 natural하다.
#[derive(Debug, Clone, PartialEq)]
pub enum Shape {
Circle { center: Point, radius: f64 },
Rectangle { top_left: Point, size: Size },
Polygon { vertices: Vec<Point> },
}
impl Shape {
pub fn area(&self) -> f64 {
match self {
Shape::Circle { radius, .. } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { size, .. } => size.width * size.height,
Shape::Polygon { vertices } => polygon_area(vertices),
}
}
}
이 design은 Shape의 가능한 종류가 API 안에서 명시된다. New variant를 추가하면 match exhaustiveness check가 영향을 받는 code를 찾아준다. OOP에서는 subclass 추가가 local change처럼 보일 수 있지만, 실제로는 handling logic이 누락될 수 있다. Rust는 closed-world variation에 대해 compiler가 도와준다.
반대로 plugin system처럼 종류가 open set이면 trait object가 맞다.
pub trait Renderer {
fn render(&self, scene: &Scene, target: &mut dyn Write) -> Result<(), RenderError>;
}
pub struct Pipeline {
renderers: Vec<Box<dyn Renderer + Send + Sync>>,
}
핵심은 enum과 trait object를 syntax preference로 고르지 않는 것이다. **Variation이 closed면 enum, open이면 trait**을 먼저 생각한다.
3.2 getter/setter보다 invariant-preserving method
OOP에서 encapsulation을 말하면서도 실제로는 모든 field에 getter/setter를 붙이는 code가 많다. Rust에서는 private field와 public method를 이용해 invariant를 더 엄격하게 지킬 수 있다.
pub struct RateLimit {
max_tokens: NonZeroU32,
refill_per_sec: NonZeroU32,
}
impl RateLimit {
pub fn new(max_tokens: NonZeroU32, refill_per_sec: NonZeroU32) -> Self {
Self { max_tokens, refill_per_sec }
}
pub fn max_tokens(&self) -> NonZeroU32 {
self.max_tokens
}
pub fn set_max_tokens(&mut self, value: NonZeroU32) {
self.max_tokens = value;
}
}
여기서 NonZeroU32를 쓰면 “0은 invalid”라는 rule이 runtime comment가 아니라 type으로 들어간다. 더 나아가 setter를 제공할지 자체도 design decision이다. 모든 field를 바꿀 수 있게 하면 RateLimit의 invariant는 외부 caller의 discipline에 의존한다. Rust API에서는 “무엇을 public으로 만들지”가 곧 long-term contract다.
3.3 dependency injection의 Rust식 해석
OOP에서는 Dependency Inversion을 위해 interface를 만들고 object에 주입한다. Rust에서는 세 가지 선택지가 있다.
generic으로 dependency를 받는다.dyn Trait으로 runtime polymorphism을 사용한다.- function/closure를 parameter로 받는다.
pub trait Clock {
fn now(&self) -> Instant;
}
pub fn expire_old_entries<C: Clock>(cache: &mut Cache, clock: &C) {
let now = clock.now();
cache.retain(|entry| !entry.is_expired_at(now));
}
이 방식은 compile-time dispatch를 사용한다. Test에서는 fake clock을 넘길 수 있다. Binary code에서 여러 clock을 runtime에 섞어야 한다면 &dyn Clock이나 Arc<dyn Clock + Send + Sync>가 적절할 수 있다.
OOP의 “interface를 만들라”는 조언은 Rust에서 “trait을 무조건 만들라”가 아니다. Trait은 powerful하지만 public trait은 SemVer surface가 크다. 내부 function 하나를 test하기 위해 trait을 과하게 만들면 complexity가 늘어난다. Closure parameter가 더 작고 명확할 때도 많다.
4. Rust는 partially functional language다
Rust는 pure functional language가 아니다. Mutation이 있고, loop가 있고, systems-level resource management가 있고, unsafe도 있다. 하지만 Rust는 functional programming의 많은 장점을 적극적으로 사용한다.
letbinding은 immutable이 default다.if,match, block은 expression이다.enum은 algebraic data type처럼 사용된다.Option과Result는 explicit effect를 표현한다.Iteratoradapter는 collection transformation을 declarative하게 만든다.- Closure는 behavior를 값으로 넘길 수 있게 한다.
- Pattern matching은 data shape에 따른 branching을 안전하게 만든다.
4.1 Expression-oriented code
Rust에서는 block이 value를 만들 수 있다.
let mode = if cfg!(debug_assertions) {
Mode::Debug
} else {
Mode::Release
};
이런 style은 temporary mutable variable을 줄인다. OOP/imperative code에서는 다음처럼 쓸 수 있다.
let mut mode = Mode::Release;
if cfg!(debug_assertions) {
mode = Mode::Debug;
}
두 code 모두 맞지만, 첫 번째는 “이 value는 한 번 결정되고 바뀌지 않는다”는 intent가 더 분명하다. Rust에서 mut는 reader에게 보내는 signal이다. mut가 많을수록 reader는 “어디서 바뀌지?”를 추적해야 한다.
4.2 Option과 Result는 control flow다
Rust에서 Option<T>는 nullable value의 safer replacement이고, Result<T, E>는 exception의 explicit replacement다. 중요한 점은 이 둘이 단순한 container가 아니라 control flow를 type으로 표현하는 도구라는 것이다.
fn find_user(id: UserId) -> Option<User>;
fn load_user(id: UserId) -> Result<User, LoadError>;
첫 번째는 “없을 수 있음”이 정상 case다. 두 번째는 “operation이 실패할 수 있음”을 말한다. 둘을 섞지 않는 것이 좋다. None이 “file not found”인지 “permission denied”인지 “network error”인지 알 수 없다면 Result가 맞다. 반대로 “cache miss”처럼 정상적인 absence라면 Option이 맞다.
4.3 Iterator pipeline과 loop의 균형
Functional-ish Rust라고 해서 모든 loop를 iterator chain으로 바꿔야 하는 것은 아니다. Good Rust는 readable Rust다.
좋은 iterator chain:
let active_users: Vec<UserId> = users
.iter()
.filter(|user| user.is_active())
.map(|user| user.id())
.collect();
이 code는 data flow가 선명하다. Filter하고 map하고 collect한다. 하지만 chain이 너무 길어지면 local variables가 있는 loop가 더 낫다.
let mut result = Vec::new();
for user in users {
if !user.is_active() {
continue;
}
let Some(profile) = profiles.get(user.id()) else {
continue;
};
if profile.can_receive_email() {
result.push(EmailTarget::new(user.id(), profile.email().clone()));
}
}
여기서는 early continue와 intermediate name이 이해를 돕는다. Rust의 functional style은 dogma가 아니다. 목표는 mutation을 줄이고 data transformation을 분명하게 만드는 것이다.
4.4 Pattern matching은 readable branching이다
OOP에서는 polymorphic method dispatch로 branching을 숨긴다. Rust에서는 match가 branching을 드러내되 exhaustive check를 제공한다.
match event {
Event::Connected { peer } => self.on_connected(peer),
Event::Disconnected { peer, reason } => self.on_disconnected(peer, reason),
Event::Message { peer, bytes } => self.on_message(peer, bytes),
}
이 code의 장점은 가능한 event set이 한눈에 보인다는 것이다. 미래에 Event::Backpressure variant를 추가하면 compiler가 처리 누락을 알려준다. 이건 Clean Code의 readability와 strong typing이 만나는 지점이다.
5. Rust는 strongly typed language다: type은 documentation보다 강하다
Strong typing은 “type annotation이 많다”는 뜻이 아니다. Rust는 type inference가 강해서 local variable type을 많이 쓰지 않아도 된다. Strong typing의 핵심은 program의 contract가 compiler가 이해할 수 있는 형태로 표현된다는 것이다.
5.1 Primitive obsession을 줄이는 newtype
u64, String, usize만으로 domain을 표현하면 compiler는 구분하지 못한다.
fn transfer(from: u64, to: u64, amount: u64) { ... }
이 signature는 from, to, amount가 모두 같은 type이다. argument order를 바꿔도 compile된다. newtype을 쓰면 domain meaning을 type으로 올릴 수 있다.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AccountId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Cents(u64);
pub fn transfer(from: AccountId, to: AccountId, amount: Cents) -> Result<(), TransferError> {
// ...
Ok(())
}
newtype은 Rust에서 매우 중요한 design pattern이다. 특히 systems/OS code에서는 PageId, FrameId, ProcessId, Fd, Generation, Epoch, Offset, Length를 구분하는 것만으로 많은 bug를 줄인다.
5.2 Illegal state를 unrepresentable하게 만들기
나쁜 state representation:
pub struct Connection {
socket: Option<TcpStream>,
authenticated: bool,
user: Option<UserId>,
}
여기서는 authenticated == true인데 user == None일 수 있다. socket == None인데 method가 호출될 수 있다. 이런 state는 runtime check가 필요하다.
더 나은 representation:
pub struct Connected {
socket: TcpStream,
}
pub struct Authenticated {
socket: TcpStream,
user: UserId,
}
impl Connected {
pub async fn authenticate(self, credential: Credential) -> Result<Authenticated, AuthError> {
let user = verify(&self.socket, credential).await?;
Ok(Authenticated { socket: self.socket, user })
}
}
impl Authenticated {
pub async fn send(&mut self, msg: Message) -> Result<(), SendError> {
write_message(&mut self.socket, msg).await
}
}
send는 authenticated state에서만 존재한다. Caller가 authentication을 깜빡할 수 없다. 이것이 Rust type system을 design에 쓰는 방식이다.
5.3 enum으로 state machine 표현하기
Parser, protocol, scheduler, transaction 같은 것은 state machine이다. bool 여러 개로 state를 표현하면 invalid combination이 생긴다.
pub enum ParserState {
ReadingHeader { buf: HeaderBuf },
ReadingBody { header: Header, remaining: usize, buf: Vec<u8> },
Complete(Message),
Failed(ParseError),
}
각 state는 필요한 data만 가진다. ReadingHeader에는 body buffer가 없다. Complete에는 더 이상 partial buffer가 없다. 이 구조는 Clean Code의 “one responsibility”보다 더 강하다. State별 data ownership까지 분리한다.
5.4 Marker type과 typestate
더 정교한 API에서는 marker type으로 compile-time state를 표현할 수 있다.
pub struct NoPath;
pub struct HasPath;
pub struct Builder<State> {
path: Option<PathBuf>,
_state: std::marker::PhantomData<State>,
}
impl Builder<NoPath> {
pub fn new() -> Self {
Self { path: None, _state: std::marker::PhantomData }
}
pub fn path(self, path: impl Into<PathBuf>) -> Builder<HasPath> {
Builder { path: Some(path.into()), _state: std::marker::PhantomData }
}
}
impl Builder<HasPath> {
pub fn build(self) -> ConfigLoader {
ConfigLoader { path: self.path.unwrap() }
}
}
이 pattern은 build() 전에 path()를 반드시 호출해야 하는 API에 유용하다. 단점은 type이 복잡해진다는 것이다. Public API에 typestate를 도입하기 전에 이 complexity가 misuse 방지 효과보다 작은지 따져야 한다.
Part II. Rust code design의 핵심 lens
6. Complexity를 Rust에서 다시 정의하기
A Philosophy of Software Design의 핵심 개념인 complexity는 Rust에서도 그대로 중요하다. 다만 Rust에서는 complexity가 다음 형태로 자주 나타난다.
- Ownership ambiguity: 누가 value를 소유하고, 누가 borrow하는지 불분명하다.
- Lifetime sprawl: lifetime parameter가 API 전체에 퍼져 caller를 괴롭힌다.
- Trait bound explosion: generic bound가 너무 복잡해져 실제 requirement를 숨긴다.
- Interior mutability abuse:
Rc<RefCell<T>>,Arc<Mutex<T>>가 everywhere로 퍼진다. - Error opacity mismatch: library error가 지나치게 opaque하거나, binary error가 지나치게 detailed하다.
- Async boundary confusion: blocking/sync/async가 섞여 latency와 cancellation이 불명확하다.
- Unsafe leakage:
unsafeinvariant가 safe API 밖으로 새어 나온다. - Feature flag maze:
Cargofeature 조합이 너무 많아 build matrix가 폭발한다.
Rust의 장점은 이런 complexity 중 많은 부분을 compiler가 surface로 끌어올린다는 것이다. 예를 들어 C++에서는 dangling pointer bug가 runtime crash가 될 수 있지만, Rust에서는 lifetime error로 나타난다. 그러나 compiler가 알려준다고 해서 design이 자동으로 좋아지는 것은 아니다. Compile error를 억지로 없애기 위해 clone(), Arc<Mutex<_>>, 'static, Box<dyn Trait>를 남발하면 complexity가 다른 형태로 이동한다.
6.1 Rust에서 strategic programming
Rust에서 strategic programming은 다음 행동을 의미한다.
- public API signature를 성급하게 노출하지 않는다.
- private field로 invariant를 보호한다.
- domain type을 만들어 primitive obsession을 줄인다.
Result와Option을 구분한다.unsafe는 작은 module에 가둔다.Arc<Mutex<T>>를 type alias로 숨기지 말고, 왜 shared mutable state가 필요한지 design한다.- lifetime을 public API로 밀어내기 전에 owned type이나
Cow가 나은지 고민한다. - feature flag를 추가하기 전에 SemVer와 build matrix를 생각한다.
좋은 Rust code는 compile error를 빨리 없애는 code가 아니라, compile error가 알려주는 design pressure를 이해하고 더 나은 boundary를 찾는 code다.
7. deep module은 Rust에서 어떻게 생기는가
deep module은 simple interface 뒤에 많은 capability를 숨기는 module이다. Rust에서 deep module은 보통 다음 조합으로 만들어진다.
pubsurface를 작게 유지한다.- field는 private으로 둔다.
- constructor에서 invariant를 검증한다.
- error type을 명확하게 제공한다.
- 내부 representation은 private module에 숨긴다.
traitimplementation으로 ecosystem과 자연스럽게 연결한다.
pub mod rate_limit {
use std::num::NonZeroU32;
#[derive(Debug, Clone)]
pub struct RateLimiter {
max_tokens: NonZeroU32,
refill_per_sec: NonZeroU32,
tokens: u32,
}
#[derive(Debug, thiserror::Error)]
pub enum RateLimitError {
#[error("max_tokens must be non-zero")]
ZeroMaxTokens,
#[error("refill_per_sec must be non-zero")]
ZeroRefillRate,
}
impl RateLimiter {
pub fn new(max_tokens: NonZeroU32, refill_per_sec: NonZeroU32) -> Self {
Self { max_tokens, refill_per_sec, tokens: max_tokens.get() }
}
pub fn try_acquire(&mut self) -> bool {
if self.tokens == 0 {
return false;
}
self.tokens -= 1;
true
}
}
}
이 API는 tokens를 public으로 노출하지 않는다. Caller는 token count를 마음대로 조작할 수 없다. RateLimiter가 어떤 algorithm을 쓰는지는 private detail이다. 나중에 leaky bucket에서 token bucket으로 바꿔도 public behavior가 같다면 caller는 영향을 덜 받는다.
7.1 Shallow module의 Rust 버전
Rust에서도 shallow module은 많다.
pub fn get_user_name(user: &User) -> &str {
user.name()
}
이 function은 abstraction을 제공하지 않는다. 단지 call을 pass-through한다. 물론 아주 작은 wrapper가 필요한 경우도 있다. 예를 들어 FFI boundary, trait adaptation, logging instrumentation, compatibility shim에서는 wrapper가 의미가 있다. 하지만 wrapper가 design decision을 숨기지 않는다면 call graph만 늘린다.
7.2 Module privacy는 design tool이다
Rust의 privacy는 module 단위다. pub, pub(crate), pub(super), private item을 적극적으로 써야 한다. “나중에 필요할 수 있으니 public으로 열어두자”는 long-term cost가 크다. Public item은 SemVer contract가 된다. Field를 public으로 열면 caller가 struct literal로 만들 수 있고, 나중에 field를 추가/제거/rename하기 어려워진다.
// 위험한 public surface
pub struct Config {
pub timeout_ms: u64,
pub retries: u32,
}
// 더 안전한 public surface
pub struct Config {
timeout: Duration,
retries: NonZeroU32,
}
impl Config {
pub fn builder() -> ConfigBuilder { ... }
pub fn timeout(&self) -> Duration { self.timeout }
pub fn retries(&self) -> NonZeroU32 { self.retries }
}
두 번째 형태는 verbose하지만 future-proof하다. 특히 library crate에서는 이런 design이 중요하다.
8. Function signature는 Rust의 interface comment다
Clean Code에서는 좋은 이름과 작은 function을 강조한다. Rust에서는 function signature 자체가 가장 중요한 documentation이다. 다음 요소를 signature가 드러내야 한다.
- Ownership:
T,&T,&mut T,Box<T>,Arc<T>중 무엇인가? - Failure:
Result<T, E>인가,Option<T>인가, panic인가? - Allocation:
String을 반환하는가,&str을 반환하는가,Cow<'a, str>인가? - Mutation:
&mut self인가, interior mutability인가? - Concurrency:
Send + Sync + 'staticbound가 필요한가? - Dynamic dispatch:
impl Trait인가,dyn Trait인가? - Async:
async fn인가, blocking function인가?
8.1 String vs &str
가장 기본적인 API design question이다.
fn parse_name(input: String) -> Name;
이 signature는 caller에게 owned String을 요구한다. Function이 input을 저장하거나 mutate하거나 consume해야 한다면 맞다. 하지만 대부분 parsing은 borrowed string으로 충분하다.
fn parse_name(input: &str) -> Result<Name, ParseNameError>;
이제 caller는 string literal, String, &str slice를 모두 넘길 수 있다. API restriction이 줄었다.
반대로 return type에서는 owned value가 필요한 경우가 많다.
fn normalize_name(input: &str) -> String;
항상 allocation이 필요하면 String이 맞다. 하지만 input이 이미 normalized면 allocation을 피하고 싶을 수 있다.
fn normalize_name(input: &str) -> Cow<'_, str>;
Cow는 API를 더 유연하게 만들지만 caller에게 complexity를 줄 수도 있다. Hot path나 library API에서는 유용하다. Simple binary code에서는 그냥 String이 더 readable할 수 있다.
8.2 Vec<T> vs &[T]
Function이 sequence를 읽기만 한다면 &[T]를 받는다.
fn checksum(bytes: &[u8]) -> u32;
이 signature는 Vec<u8>, array, slice 모두를 받는다. Vec<u8>를 받으면 caller에게 ownership과 heap allocation representation을 강요한다.
Mutation이 필요하면 &mut [T]를 받는다.
fn normalize(samples: &mut [f32]);
Length를 바꿔야 하면 Vec<T>나 &mut Vec<T>가 필요하다.
fn append_checksum(packet: &mut Vec<u8>);
이 rule은 Rust API design의 기본이다. 필요한 capability만 요구하라. Read만 필요하면 ownership을 요구하지 말고, fixed-size view로 충분하면 growable container를 요구하지 말라.
8.3 impl Trait vs named generic
Argument position에서 impl Trait은 unnamed generic이다.
pub fn write_json(value: impl serde::Serialize) -> Result<Vec<u8>, Error>;
간단한 API에는 좋다. 하지만 type parameter가 여러 곳에서 관계를 가져야 하면 named generic이 낫다.
pub fn copy_all<R, W>(reader: &mut R, writer: &mut W) -> io::Result<u64>
where
R: io::Read,
W: io::Write,
{
io::copy(reader, writer)
}
Return position의 impl Trait은 concrete type을 숨기는 데 좋다.
pub fn active_users(&self) -> impl Iterator<Item = &User> {
self.users.iter().filter(|user| user.is_active())
}
이 API는 caller에게 iterator capability만 제공하고, internal collection representation은 숨긴다. Vec<&User>를 반환했다면 allocation과 representation을 promise하는 셈이다.
8.4 dyn Trait는 언제 쓰는가
dyn Trait는 runtime polymorphism과 heterogeneous collection이 필요할 때 쓴다.
pub struct Router {
handlers: Vec<Box<dyn Handler + Send + Sync>>,
}
하지만 library function argument에 무조건 &dyn Trait를 쓰면 caller가 dynamic dispatch를 강제당한다. Generic을 쓰면 caller가 static dispatch와 dynamic dispatch 중 선택할 수 있다.
pub fn run<H: Handler>(handler: H) { ... }
dyn Trait는 OOP interface와 비슷하지만 같은 것은 아니다. Object safety, lifetime, auto trait, allocation이 함께 고려되어야 한다.
9. Ownership을 design하는 법
Rust에서 ownership은 memory management mechanism이지만 동시에 design language다. Function이 T를 받으면 ownership을 가져간다. &T를 받으면 shared borrow다. &mut T를 받으면 exclusive borrow다. 이 세 선택은 API의 성격을 결정한다.
9.1 self, &self, &mut self
Method receiver는 가장 강한 signal이다.
impl Buffer {
pub fn len(&self) -> usize { ... } // read only
pub fn clear(&mut self) { ... } // mutate in place
pub fn freeze(self) -> FrozenBuffer { ... } // consume and transform
}
self를 consume하는 method는 state transition을 표현하기 좋다. freeze(self)는 caller가 원래 Buffer를 더 이상 사용할 수 없게 한다. 이건 runtime flag보다 안전하다.
9.2 clone()은 design smell일 수도 있고 아닐 수도 있다
Rust beginner는 borrow checker와 싸우다가 clone()으로 해결하는 경우가 많다. clone()이 항상 나쁜 것은 아니다. Small value, reference-counted pointer, configuration data에서는 clone이 적절할 수 있다. 하지만 large Vec, String, buffer, AST, graph를 불필요하게 clone하면 performance와 ownership clarity가 나빠진다.
clone()을 볼 때 질문하라.
- 이 clone은 ownership boundary를 명확하게 하기 위한 의도적인 cost인가?
- 아니면 borrow checker를 조용히 만들기 위한 임시방편인가?
- Type이
Arc<T>라면 cheap clone인가, deep clone인가? - Hot path인가?
- Clone 대신 borrow,
Cow, index, arena, split ownership이 가능한가?
9.3 Arc<Mutex<T>>는 마지막 수단에 가깝다
Arc<Mutex<T>>는 powerful하지만 남용되면 OOP-style shared mutable object graph가 Rust로 돌아온다. 이것은 compile은 되지만 design이 흐려진다.
type SharedState = Arc<Mutex<State>>;
이 type alias는 편하지만 위험하다. Reader는 어떤 lock ordering이 있는지, lock scope가 어디까지인지, contention이 얼마나 되는지 알기 어렵다. 더 나은 design은 shared state를 관리하는 type을 만드는 것이다.
#[derive(Clone)]
pub struct Db {
inner: Arc<Mutex<DbInner>>,
}
impl Db {
pub fn get(&self, key: &str) -> Option<Bytes> {
let inner = self.inner.lock().unwrap();
inner.entries.get(key).cloned()
}
pub fn set(&self, key: String, value: Bytes) {
let mut inner = self.inner.lock().unwrap();
inner.entries.insert(key, value);
}
}
Mutex는 private detail이다. Public API는 operation 중심이다. Lock guard를 caller에게 노출하지 않는다. 이것이 deep module이다.
9.4 Index, handle, arena
Self-referential graph를 Rust에서 표현하려고 하면 lifetime이 어려워진다. 많은 systems code에서는 pointer 대신 index/handle을 사용한다.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NodeId(usize);
pub struct Graph {
nodes: Vec<Node>,
}
impl Graph {
pub fn add_node(&mut self, node: Node) -> NodeId {
let id = NodeId(self.nodes.len());
self.nodes.push(node);
id
}
pub fn node(&self, id: NodeId) -> Option<&Node> {
self.nodes.get(id.0)
}
}
이 design은 pointer invalidation과 lifetime complexity를 줄인다. 단, deletion이 있으면 generation counter를 추가해 stale handle을 막는 것이 좋다.
pub struct Handle {
index: usize,
generation: u32,
}
OS, allocator, ECS, graph, compiler IR에서 이런 pattern은 매우 유용하다.
10. Lifetime을 API 밖으로 밀어내기 전에 생각할 것
Lifetime은 Rust의 핵심이지만, public API에 lifetime parameter가 많아지면 caller의 cognitive load가 올라간다. Lifetime은 “reference가 얼마나 오래 valid한가”를 표현하므로 꼭 필요할 때는 써야 한다. 하지만 불필요하게 lifetime을 public type에 넣으면 API 전체가 빡빡해진다.
10.1 Struct에 reference를 저장하는 것은 신중히
pub struct Parser<'a> {
input: &'a str,
}
이 type은 input을 borrow한다. Allocation 없이 parsing할 수 있고, parsed slice를 input으로부터 반환하기 좋다. 하지만 Parser<'a>를 저장하거나 thread로 보내거나 async boundary를 넘기면 lifetime이 복잡해진다.
대안은 owned data를 저장하는 것이다.
pub struct Parser {
input: String,
}
이 design은 allocation cost를 치르지만 API가 단순해진다. Performance가 중요하고 input lifetime이 자연스럽게 존재하면 borrowed version이 좋다. Application-level code에서는 owned version이 더 실용적일 수 있다.
10.2 Cow로 borrowed/owned를 함께 표현하기
pub struct Document<'a> {
title: Cow<'a, str>,
body: Cow<'a, str>,
}
Cow는 borrowed data를 기본으로 하되 필요할 때 owned data로 바꿀 수 있다. Config parsing, normalization, zero-copy parsing에서 유용하다. 하지만 Cow는 caller에게 lifetime을 노출한다. Public API에서 남발하면 복잡하다.
10.3 Lifetime elision을 믿되, relation은 명확히 하라
Rust는 흔한 lifetime pattern을 생략하게 해준다.
fn first_word(input: &str) -> &str;
이 signature는 output이 input에서 borrow된다는 뜻으로 해석된다. 하지만 두 input 중 어느 쪽에서 output이 나오는지 불명확하면 lifetime을 써야 한다.
fn choose<'a>(left: &'a str, right: &'a str, prefer_left: bool) -> &'a str {
if prefer_left { left } else { right }
}
Lifetime annotation은 “얼마나 오래 사는가”보다 “어떤 reference들이 같은 validity relation을 공유하는가”를 말한다.
10.4 'static은 만능 해결책이 아니다
'static bound를 붙이면 compiler error가 사라지는 경우가 있다. 하지만 'static은 “이 value가 program 끝까지 살아야 한다”는 뜻이 아니라, 대개 “non-static borrow를 포함하지 않는다”는 bound다. 그래도 API에 'static을 요구하면 caller가 borrowed data를 쓰기 어려워진다.
fn spawn_task<F>(f: F)
where
F: Future<Output = ()> + Send + 'static,
{
tokio::spawn(f);
}
이런 API는 task가 current stack보다 오래 살 수 있으므로 자연스럽다. 하지만 단순 callback에 무심코 'static을 요구하면 caller가 불필요하게 Arc나 String clone을 해야 할 수 있다. 'static은 concurrency boundary, global storage, detached task에서 주로 정당화된다.
11. Error handling: Rust에서 failure를 설계하기
Rust는 exception 대신 Result<T, E>를 중심으로 failure를 표현한다. 이 방식은 function signature에 failure를 드러내기 때문에 API design과 code readability에 큰 영향을 준다.
11.1 Library와 binary의 error style은 다르다
Library crate는 caller가 error를 inspect하고 recover할 수 있어야 한다. 따라서 typed error enum이 좋다.
#[derive(Debug, thiserror::Error)]
pub enum ParseConfigError {
#[error("failed to read config file")]
Io(#[from] std::io::Error),
#[error("invalid syntax at line {line}")]
Syntax { line: usize },
#[error("missing required field: {0}")]
MissingField(&'static str),
}
pub fn parse_config(path: impl AsRef<Path>) -> Result<Config, ParseConfigError> {
// ...
Ok(Config::default())
}
Binary/application code는 빠른 propagation과 context가 더 중요할 수 있다. anyhow 같은 opaque error가 편하다.
fn main() -> anyhow::Result<()> {
let cfg = load_config("app.toml").context("loading app config")?;
run(cfg)?;
Ok(())
}
Rule of thumb:
- Public library API: specific error type.
- Application boundary: context-rich opaque error.
- Internal helper: 상황에 따라 either.
11.2 panic!은 bug와 invariant violation에 가깝다
Rust에서 panic!은 recoverable error보다 programmer error나 broken invariant에 적합하다. Index out of bounds, impossible state, test failure 등이 그렇다. File not found, invalid user input, network timeout은 보통 Result가 맞다.
pub fn get_user(&self, id: UserId) -> Result<User, StoreError>;
이 function이 user input 기반 lookup이라면 missing user는 error나 Option이다. 하지만 internal table에서 “이 ID는 방금 insert했으므로 반드시 존재해야 한다”는 invariant가 있으면 expect("inserted user must exist")가 정당화될 수 있다. 중요한 것은 panic message가 invariant를 설명해야 한다는 점이다.
let user = self.users.get(id).expect("user id was allocated from this table");
11.3 Error type에 너무 많은 것을 담지 말라
Error enum을 너무 자세하게 만들면 API가 brittle해진다. Caller가 실제로 구분해야 하는 category만 public으로 노출하라.
pub enum DbError {
NotFound,
PermissionDenied,
Unavailable,
CorruptData,
}
Internal detail은 source chain이나 debug field로 숨길 수 있다. Public error variant가 많을수록 SemVer cost가 늘어난다.
11.4 ?는 control flow를 깨끗하게 만든다
?는 early return이다. 다만 너무 긴 function에서 ?가 많으면 error path가 보이지 않을 수 있다. 중요한 boundary에서는 context를 붙인다.
let bytes = std::fs::read(path.as_ref())
.with_context(|| format!("reading config from {}", path.as_ref().display()))?;
Library code에서는 From conversion을 통해 source error를 보존하고, application code에서는 user-readable context를 붙이는 편이 좋다.
12. Trait design: OOP interface와 같지만 같지 않다
trait은 Rust의 polymorphism mechanism이다. Java/C# interface와 비슷하지만, Rust trait은 더 많은 역할을 한다.
- Generic bound로 compile-time capability를 표현한다.
- Trait object로 runtime polymorphism을 제공한다.
- Associated type으로 type relation을 표현한다.
- Extension trait으로 외부 type에 method를 추가한다.
- Marker trait으로 semantic property를 표현한다.
- Auto trait(
Send,Sync,Unpin)로 compiler-derived property를 전파한다.
12.1 Trait은 capability다
Trait 이름은 “이 type이 무엇인가?”보다 “이 type으로 무엇을 할 수 있는가?”를 말해야 한다.
pub trait Encode {
fn encode(&self, dst: &mut Vec<u8>);
}
pub trait Decode: Sized {
fn decode(src: &[u8]) -> Result<Self, DecodeError>;
}
Manager, Service, Handler 같은 이름은 너무 넓을 수 있다. Encode, ReadFrame, Schedule, Resolve, Allocate처럼 capability가 선명한 이름이 좋다.
12.2 Associated type vs generic trait
Associated type은 “한 implementor에 대해 output type이 하나”일 때 좋다.
pub trait Parser {
type Output;
type Error;
fn parse(&self, input: &str) -> Result<Self::Output, Self::Error>;
}
Generic trait은 같은 type이 여러 target type에 대해 implementation을 가질 수 있을 때 좋다.
pub trait ConvertTo<T> {
fn convert(&self) -> T;
}
Rule of thumb: 한 type에 대해 implementation이 하나면 associated type, 여러 개면 generic parameter.
12.3 Object safety를 public contract로 생각하라
Trait을 dyn Trait로 쓸 수 있는지는 API contract의 일부다. Trait에 generic method나 Self return이 있으면 object safety가 깨질 수 있다.
pub trait CloneablePlugin {
fn clone_plugin(&self) -> Box<dyn CloneablePlugin>;
}
Clone 자체는 object-safe하지 않지만, object-safe wrapper method를 제공할 수 있다. Public trait을 design할 때 “caller가 heterogeneous collection을 만들 필요가 있는가?”를 생각해야 한다.
12.4 Sealed trait
외부 crate가 trait을 implement하지 못하게 막고 싶다면 sealed trait pattern을 쓴다. 이는 future-proofing에 유용하다.
mod sealed {
pub trait Sealed {}
}
pub trait PacketKind: sealed::Sealed {
fn code(&self) -> u8;
}
pub struct Syn;
pub struct Ack;
impl sealed::Sealed for Syn {}
impl sealed::Sealed for Ack {}
impl PacketKind for Syn {
fn code(&self) -> u8 { 1 }
}
impl PacketKind for Ack {
fn code(&self) -> u8 { 2 }
}
이렇게 하면 public trait이지만 external implementation은 막을 수 있다. Standard library처럼 evolution이 중요한 API에서 유용한 pattern이다.
12.5 Trait bound는 “진짜 requirement”를 말해야 한다
fn save<T: Clone + Debug + Serialize>(value: T) { ... }
이 bound들이 정말 필요한가? Clone을 실제로 하는가? Debug는 logging에만 쓰는가? Caller에게 unnecessary bound를 요구하면 API flexibility가 줄어든다.
fn save<T: Serialize>(value: &T) -> Result<(), SaveError> { ... }
더 나은 signature는 ownership도 덜 요구하고, trait bound도 줄인다.
13. Rust API Guidelines를 실전 규칙으로 바꾸기
Rust API Guidelines의 핵심을 code writing rule로 바꾸면 다음과 같다.
13.1 Naming convention은 ecosystem과 연결되는 protocol이다
Rust에서 naming은 단순 style이 아니라 user expectation이다.
as_foo(&self) -> &Foo: cheap borrowed conversion.to_foo(&self) -> Foo: conversion/copy/clone 가능.into_foo(self) -> Foo: self를 consume.iter(&self): shared iterator.iter_mut(&mut self): mutable iterator.into_iter(self): consuming iterator.from_*: associated constructor.try_*: fallible operation.new: obvious constructor.with_*: option/config를 포함한 constructor.
impl Packet {
pub fn as_bytes(&self) -> &[u8] { ... }
pub fn to_vec(&self) -> Vec<u8> { ... }
pub fn into_bytes(self) -> Vec<u8> { ... }
}
이 naming을 따르면 documentation을 덜 읽어도 user가 예측할 수 있다. 반대로 to_bytes가 self를 consume하거나, as_bytes가 allocation하면 surprise가 생긴다.
13.2 Common trait을 적극적으로 derive하라
Rust user는 많은 type이 다음 trait을 갖기를 기대한다.
DebugCloneCopywhen appropriatePartialEq,EqHashPartialOrd,OrdDefaultSerialize,Deserializebehind featureSend,Syncwhere possible
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(u64);
특히 Debug는 거의 항상 derive하라. Test failure와 logging에서 필요하다. Copy는 신중해야 한다. Type이 앞으로 resource를 owning할 가능성이 있으면 Copy를 제공하는 것은 commitment가 된다.
13.3 Conversion trait을 쓰라
Ad-hoc conversion method보다 standard trait이 ecosystem과 잘 맞는다.
impl From<UserId> for u64 {
fn from(id: UserId) -> Self { id.0 }
}
impl TryFrom<u64> for UserId {
type Error = InvalidUserId;
fn try_from(raw: u64) -> Result<Self, Self::Error> {
if raw == 0 { Err(InvalidUserId) } else { Ok(UserId(raw)) }
}
}
From은 infallible conversion, TryFrom은 fallible conversion이다. Into는 보통 직접 implement하지 않고 From을 implement하면 자동으로 얻는다.
13.4 Documentation은 panic, error, safety를 말해야 한다
Rustdoc에는 다음을 꼭 적는다.
- 이 function은 언제
Err를 반환하는가? - 언제
panic할 수 있는가? unsafe fn이면 caller가 지켜야 할 safety contract는 무엇인가?- Blocking/async behavior는 무엇인가?
- Complexity나 allocation behavior가 중요한가?
/// Reads exactly one frame from the underlying stream.
///
/// # Errors
///
/// Returns `FrameError::Io` if the stream fails, and `FrameError::Invalid`
/// if the peer sends malformed data.
///
/// # Cancellation
///
/// If the future is cancelled, partially read bytes remain buffered in `self`.
pub async fn read_frame(&mut self) -> Result<Option<Frame>, FrameError>;
이런 documentation은 Clean Code가 싫어하는 redundant comment가 아니다. Signature만으로 알기 어려운 contract를 드러낸다.
14. SemVer와 public API: Rust library는 미래와 싸운다
Rust에서 public API는 type-level contract가 강하기 때문에 작은 change도 breaking change가 될 수 있다. Cargo SemVer documentation은 layout, trait item, generic bound, function parameter, feature flag 등의 change가 breaking인지 자세히 다룬다. 여기서는 실전 원칙만 정리한다.
14.1 Public field를 열지 말라
pub struct Options {
pub timeout: Duration,
pub retries: u32,
}
이 struct는 caller가 literal로 만들 수 있다. 나중에 field를 추가하면 caller code가 깨질 수 있다. Private field와 builder를 쓰면 evolution이 쉽다.
#[derive(Debug, Clone)]
pub struct Options {
timeout: Duration,
retries: u32,
}
impl Options {
pub fn builder() -> OptionsBuilder { ... }
}
14.2 Public enum은 future variant를 고려하라
Public enum에 variant를 추가하면 exhaustive match를 하는 caller가 깨질 수 있다. Library API라면 #[non_exhaustive]를 고려한다.
#[non_exhaustive]
pub enum ErrorKind {
NotFound,
PermissionDenied,
InvalidData,
}
이렇게 하면 caller는 wildcard arm을 둬야 한다. Evolution을 위해 약간의 inconvenience를 주는 것이다.
14.3 Trait에 method를 추가하는 것은 위험하다
Public trait에 required method를 추가하면 external implementor가 깨진다. Default implementation을 제공하거나 sealed trait을 사용하라.
pub trait Store {
fn get(&self, key: &str) -> Option<Bytes>;
fn contains(&self, key: &str) -> bool {
self.get(key).is_some()
}
}
Default method는 대체로 compatible하지만, method name collision 같은 subtle issue도 있다. Public trait은 신중하게 작게 시작한다.
14.4 Generic bound를 tighten하지 말라
// before
pub fn process<T>(value: T) { ... }
// after
pub fn process<T: Clone>(value: T) { ... }
이 change는 caller가 기존에 Clone이 아닌 type을 사용했다면 breaking이다. Initial API에서 unnecessary flexibility를 줄이는 것도 중요하지만, 한번 public이 된 후 restriction을 늘리는 것은 어렵다.
14.5 Feature flag는 dependency graph를 바꾼다
Feature flag는 편리하지만 complexity를 만든다. default feature에 heavy dependency를 넣으면 no_std, embedded, compile time에 영향을 준다. Feature는 additive하게 설계하는 것이 좋고, feature 조합이 mutually exclusive하면 documentation과 compile error를 명확히 해야 한다.
[features]
default = ["std"]
std = []
serde = ["dep:serde"]
Feature name은 implementation detail보다 user-facing capability를 표현해야 한다. use-serde보다 serde가, enable-foo보다 foo가 보통 낫다.
Part III. 실제 Rust code writing pattern
15. Data modeling: struct, enum, type alias, newtype
Rust code는 data model에서 quality가 많이 결정된다. 나쁜 data model은 모든 function을 복잡하게 만든다. 좋은 data model은 function을 단순하게 만든다.
15.1 type alias는 meaning을 만들지 않는다
type UserId = u64;
type ProductId = u64;
이렇게 해도 UserId와 ProductId는 같은 type이다. 서로 섞어도 compile된다. Documentation에는 도움이 되지만 type safety는 없다. Domain boundary가 중요하면 newtype을 써라.
pub struct UserId(u64);
pub struct ProductId(u64);
15.2 Tuple struct vs named-field struct
Tuple struct는 wrapper나 coordinate처럼 field meaning이 obvious할 때 좋다.
pub struct Bytes(pub u64);
pub struct UserId(u64);
Named-field struct는 field meaning이 중요하거나 field가 많을 때 좋다.
pub struct Window {
start: usize,
end: usize,
}
Boolean pair, integer pair는 특히 named field가 좋다.
// 모호함
fn copy(src: usize, dst: usize, len: usize);
// 더 명확함
pub struct CopyRange {
src_offset: usize,
dst_offset: usize,
len: usize,
}
15.3 Option field는 정말 optional인가?
Struct field가 Option<T>이면 object lifecycle 중 일부 시점에는 값이 없을 수 있다는 뜻이다. 이것이 진짜 domain rule인지, initialization 순서 문제인지 구분하라.
pub struct Server {
listener: Option<TcpListener>,
}
이런 design은 method마다 listener가 있는지 check해야 한다. 더 나은 design은 state를 나누는 것이다.
pub struct ServerBuilder { addr: SocketAddr }
pub struct Server { listener: TcpListener }
impl ServerBuilder {
pub async fn bind(self) -> io::Result<Server> {
Ok(Server { listener: TcpListener::bind(self.addr).await? })
}
}
15.4 bool parameter는 smell일 수 있다
fn open(path: &Path, create: bool, truncate: bool) -> io::Result<File>;
Caller가 open(path, true, false)를 보면 의미가 즉시 보이지 않는다. enum이나 builder가 낫다.
pub enum CreateMode {
OpenExisting,
CreateIfMissing,
CreateNew,
}
pub struct OpenOptions {
create: CreateMode,
truncate: bool,
}
Rust standard library의 OpenOptions 같은 builder는 이런 문제를 해결한다.
15.5 PhantomData는 public API complexity를 올린다
PhantomData는 ownership/lifetime/variance/typestate를 type에 반영할 때 필요하다. 하지만 advanced pattern이다. 단순히 “멋있어 보여서” 쓰면 API를 어렵게 만든다. PhantomData가 필요하다는 것은 compiler가 보지 못하는 relation을 type에 넣고 있다는 뜻이다. 그 relation을 documentation으로 설명해야 한다.
16. Function 작성: 작게보다 coherent하게
Clean Code의 “small function” 조언은 Rust에서도 유효하다. 하지만 Rust에서는 function을 너무 잘게 쪼개면 ownership과 lifetime이 오히려 어려워질 수 있다. 좋은 Rust function은 짧기보다 coherent해야 한다.
16.1 Borrow splitting을 이해하라
다음 code는 흔한 pattern이다.
impl Server {
pub fn tick(&mut self) {
self.poll_network();
self.expire_sessions();
self.flush_metrics();
}
}
겉으로는 clean하다. 하지만 각 method가 &mut self를 받으면 한 번에 whole self를 borrow한다. 내부에서 동시에 다른 field를 borrow해야 하면 borrow checker와 충돌할 수 있다. 이 경우 field-level helper function이 낫다.
pub fn tick(&mut self) {
poll_network(&mut self.network, &mut self.sessions);
expire_sessions(&mut self.sessions, self.clock.now());
flush_metrics(&mut self.metrics);
}
OOP style에서는 method로 쪼개는 것이 자연스럽지만, Rust에서는 free function이나 smaller component method가 ownership boundary를 더 명확하게 만들 때가 있다.
16.2 let else로 happy path를 살려라
let Some(user) = users.get(id) else {
return Err(Error::UnknownUser(id));
};
let Some(email) = user.email() else {
return Ok(());
};
let else는 early return pattern을 깔끔하게 만든다. Nested match보다 main flow가 읽기 쉽다.
16.3 match arm이 길어지면 helper를 쓰라
match command {
Command::Get { key } => self.handle_get(key).await,
Command::Set { key, value } => self.handle_set(key, value).await,
Command::Delete { key } => self.handle_delete(key).await,
}
이 split은 good. Each arm이 protocol command를 처리한다는 abstraction을 만든다. 반대로 helper가 단지 한 줄 wrapper라면 shallow split일 수 있다.
16.4 Inner function으로 generic code bloat를 줄일 수 있다
Generic function 내부에서 type-dependent code와 non-generic code를 나누면 compile time/code size에 도움이 될 수 있다.
pub fn parse_many<T: FromStr>(input: &str) -> Result<Vec<T>, T::Err> {
fn split_items(input: &str) -> impl Iterator<Item = &str> {
input.split(',').map(str::trim).filter(|s| !s.is_empty())
}
split_items(input).map(str::parse).collect()
}
split_items는 generic이 아니다. 이런 pattern은 아주 큰 generic function에서 유용하다. 다만 readability가 더 중요하므로 무조건 적용하지는 않는다.
16.5 Return type을 과하게 concrete하게 만들지 말라
pub fn users(&self) -> Vec<&User>;
이 signature는 allocation을 promise한다. Lazy iteration이면 impl Iterator가 더 좋다.
pub fn users(&self) -> impl Iterator<Item = &User> {
self.users.iter()
}
하지만 public API에서 impl Iterator return은 concrete hidden type이 하나로 고정되어야 한다는 제약이 있다. 여러 branch에서 다른 iterator type이 나오면 Box<dyn Iterator>나 enum wrapper가 필요할 수 있다.
17. Iterator와 collection design
Rust의 Iterator는 functional style의 핵심이자 zero-cost abstraction의 대표 사례다. 하지만 iterator를 잘 쓰려면 ownership mode를 이해해야 한다.
17.1 iter, iter_mut, into_iter
for user in users.iter() { // &User
println!("{}", user.name());
}
for user in users.iter_mut() { // &mut User
user.refresh();
}
for user in users { // User, consuming
send(user);
}
API를 만들 때도 같은 convention을 따른다.
impl Registry {
pub fn iter(&self) -> impl Iterator<Item = &Service> { ... }
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Service> { ... }
pub fn into_services(self) -> impl Iterator<Item = Service> { ... }
}
17.2 Collection을 받는 API
Caller가 any iterable을 넘길 수 있게 하고 싶으면 IntoIterator를 사용한다.
pub fn add_users<I>(&mut self, users: I)
where
I: IntoIterator<Item = User>,
{
self.users.extend(users);
}
이 API는 Vec<User>, array, iterator 모두를 받을 수 있다. 단, Item = User이므로 ownership을 consume한다. Borrowed users를 받아 clone하려면 별도 bound가 필요하다.
pub fn add_user_refs<'a, I>(&mut self, users: I)
where
I: IntoIterator<Item = &'a User>,
{
self.users.extend(users.into_iter().cloned());
}
이런 API는 flexible하지만 signature가 복잡해진다. Public API에서는 실제 use case를 기준으로 선택한다.
17.3 Iterator chain을 named step으로 나누기
긴 chain은 중간 이름을 주면 더 readable하다.
let candidates = users
.iter()
.filter(|user| user.is_active())
.filter(|user| user.region() == region);
let targets: Vec<_> = candidates
.filter_map(|user| user.email_target())
.collect();
중간 iterator는 lazy하다. collect 전까지 allocation하지 않는다. 이런 pattern은 functional style과 readability를 함께 얻는다.
17.4 collect의 type annotation
collect()는 target collection type을 알아야 한다.
let ids: Vec<UserId> = users.iter().map(User::id).collect();
혹은 turbofish를 쓴다.
let ids = users.iter().map(User::id).collect::<Vec<_>>();
Team convention을 정하면 좋다. Return type이 중요한 line에서는 variable annotation이 더 readable할 때가 많다.
17.5 try_fold와 fallible iteration
Fallible loop를 iterator로 표현할 때는 try_fold나 collect::<Result<Vec<_>, _>>()가 유용하다.
let users: Vec<User> = rows
.map(parse_user)
.collect::<Result<Vec<_>, _>>()?;
이 pattern은 “모든 row를 parse하고, 하나라도 실패하면 error”를 짧게 표현한다. 하지만 error handling이 복잡하면 explicit loop가 낫다.
18. Module과 crate 구조
Rust project 구조는 code readability에 큰 영향을 준다. mod.rs, lib.rs, main.rs, src/bin, workspace, feature flag, public re-export는 모두 API design의 일부다.
18.1 Binary와 library를 분리하라
CLI나 server project라도 core logic은 library crate로 분리하면 test하기 쉽다.
mytool/
Cargo.toml
src/
lib.rs # core logic
main.rs # CLI glue
config.rs
engine.rs
error.rs
main.rs는 argument parsing, logging setup, top-level error display에 집중한다. lib.rs는 reusable logic을 가진다.
18.2 prelude는 신중하게
Library가 자주 쓰는 trait/type을 prelude module로 제공할 수 있다.
pub mod prelude {
pub use crate::{Client, Error, Result};
pub use crate::traits::{Encode, Decode};
}
하지만 너무 많은 것을 prelude에 넣으면 namespace pollution이 생긴다. Prelude는 “사용자가 거의 항상 import하는 것”만 포함한다.
18.3 Re-export로 public path를 안정화하라
Internal module structure는 바뀔 수 있다. Public API path는 안정적이어야 한다.
mod codec;
mod connection;
pub use codec::{Decoder, Encoder};
pub use connection::Connection;
Caller는 crate::Connection을 사용하고, 내부 file layout은 바뀌어도 된다.
18.4 pub(crate)로 internal boundary를 만든다
모든 internal item을 private으로 두면 module 간 reuse가 어려울 수 있다. 그렇다고 pub으로 열면 external contract가 된다. pub(crate)는 crate 내부에는 열고 외부에는 숨긴다.
pub(crate) fn parse_frame_header(bytes: &[u8]) -> Result<Header, Error>;
이 function은 internal test와 module collaboration에는 사용되지만 public API는 아니다.
18.5 Workspace는 dependency boundary다
큰 project에서는 workspace를 사용해 crate를 나눈다. Crate boundary는 compile time, feature, dependency, public API를 분리한다. 하지만 crate가 너무 많으면 versioning과 dependency graph가 복잡해진다. Module로 충분한지 crate가 필요한지 구분하라.
Crate로 나누기 좋은 경우:
no_stdcore와stdintegration을 분리.- proc-macro crate.
- FFI boundary.
- optional heavy dependency.
- independent test/build target.
- public library component.
Module로 충분한 경우:
- 단지 file이 길어져서 나누는 경우.
- crate 내부에서만 쓰는 helper.
- same feature set, same dependency set.
19. Async Rust: function color와 resource lifetime
Async Rust는 powerful하지만 design complexity가 크다. async fn은 단지 “non-blocking function”이 아니다. It returns a Future, and values alive across .await become part of that future. Ownership, Send, cancellation, pinning, runtime choice가 모두 영향을 준다.
19.1 Async boundary를 신중하게 정하라
모든 function을 async로 만들면 API가 전염된다. 반대로 blocking function을 async context에서 호출하면 runtime thread를 막는다. Boundary를 명확히 하라.
// CPU-bound synchronous work
fn parse_packet(bytes: &[u8]) -> Result<Packet, ParseError>;
// I/O-bound async work
async fn read_packet(stream: &mut TcpStream) -> Result<Option<Packet>, Error>;
Parsing은 sync, network I/O는 async가 자연스럽다. CPU-bound heavy work를 async task에서 직접 오래 돌리면 executor fairness를 해칠 수 있다. Tokio에서는 spawn_blocking 같은 tool을 고려한다.
19.2 Send future를 의식하라
Multi-threaded executor에서 tokio::spawn하려면 future가 보통 Send + 'static이어야 한다. .await를 사이에 두고 Rc, RefCell, non-Send guard를 들고 있으면 문제가 된다.
async fn bad(m: std::sync::Mutex<Vec<u8>>) {
let guard = m.lock().unwrap();
do_io().await; // guard가 await across로 유지됨: 좋지 않음
drop(guard);
}
Lock guard는 .await 전에 drop하라.
async fn better(m: &std::sync::Mutex<Vec<u8>>) {
let data = {
let guard = m.lock().unwrap();
guard.clone()
};
do_io_with(data).await;
}
19.3 Async mutex와 sync mutex
Async context라고 항상 tokio::sync::Mutex가 필요한 것은 아니다. Lock을 잡고 .await하지 않는 짧은 critical section이라면 std::sync::Mutex가 괜찮을 수 있다. 반대로 lock guard를 .await across로 유지해야 한다면 async mutex를 써야 한다. 그러나 그 design 자체를 다시 생각해야 할 때도 많다.
19.4 Cancellation safety
Rust future는 drop될 수 있다. .await 중간에 task가 cancel되면 partial progress가 남을 수 있다. Public async API는 cancellation behavior를 문서화해야 한다.
/// Writes a full frame.
///
/// # Cancellation safety
///
/// This method is not cancellation-safe. If cancelled, a partial frame may
/// have been written to the underlying stream.
pub async fn write_frame(&mut self, frame: &Frame) -> io::Result<()>;
19.5 Structured concurrency를 선호하라
Detached task를 남발하면 lifetime과 shutdown이 어려워진다. Task를 spawn하면 누가 join하는지, failure는 어디로 가는지, shutdown signal은 어떻게 전달되는지 명확해야 한다.
pub struct Worker {
shutdown: CancellationToken,
handle: JoinHandle<Result<(), WorkerError>>,
}
Worker를 type으로 만들면 task lifecycle이 API로 드러난다.
20. Concurrency: Rust의 Send/Sync를 design에 쓰기
Rust의 concurrency safety는 ownership과 type system에서 나온다. Send는 value를 thread 사이에 move할 수 있음을, Sync는 shared reference를 thread 사이에 share할 수 있음을 의미한다. 대부분 자동으로 derive되지만, type 내부에 raw pointer나 non-thread-safe cell이 있으면 자동으로 막힌다.
20.1 Shared state보다 message passing
Rust는 Arc<Mutex<T>>도 제공하지만, channel을 통한 ownership transfer가 더 단순한 경우가 많다.
let (tx, rx) = std::sync::mpsc::channel::<Command>();
std::thread::spawn(move || {
let mut state = State::new();
while let Ok(cmd) = rx.recv() {
state.apply(cmd);
}
});
State는 worker thread가 단독으로 소유한다. 다른 thread는 command를 보낸다. Lock이 없다. Actor-like model이다.
20.2 Lock scope를 작게 유지하라
let value = {
let map = self.map.lock().unwrap();
map.get(key).cloned()
};
process(value);
Lock guard가 block 끝에서 drop된다. Expensive work를 lock 안에서 하지 않는다. Rust의 lexical scope를 lock scope 표현에 적극적으로 써라.
20.3 Poisoning과 error policy
std::sync::Mutex는 panic 중 lock guard가 drop되면 poisoned 상태가 된다. .unwrap()으로 처리할지, recover할지 policy를 정해야 한다.
let guard = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
이런 선택은 domain-specific하다. Data corruption 가능성이 있으면 panic이 맞을 수 있고, cache처럼 rebuild 가능한 data면 recover가 맞을 수 있다.
20.4 Atomic은 마지막 단계다
Atomic은 lock-free magic이 아니다. Ordering은 어렵고 bug가 subtle하다. 우선 Mutex, RwLock, channel, ArcSwap, higher-level crate를 고려하라. Atomic을 쓰면 invariant와 memory ordering rationale을 comment로 남겨라.
// `ready` is set with Release after all fields are initialized.
// Readers load with Acquire before reading the fields.
ready.store(true, Ordering::Release);
이런 comment는 필수에 가깝다. Code만으로 memory ordering intent는 잘 드러나지 않는다.
21. Unsafe Rust: boundary를 작게, contract를 크게
unsafe는 Rust의 safety guarantee를 끄는 버튼이 아니다. 더 정확히는 compiler가 check하지 못하는 contract를 programmer가 직접 확인했다는 표시다. 좋은 unsafe code는 다음 원칙을 따른다.
unsafeblock을 작게 유지한다.- Unsafe operation을 safe abstraction 뒤에 숨긴다.
- Safety invariant를 documentation으로 명시한다.
- Public
unsafe fn보다 private unsafe + public safe wrapper를 선호한다. Miri, sanitizer, fuzzing, property test로 검증한다.- Panic safety와 drop safety를 고려한다.
21.1 Unsafe boundary 예시
pub struct NonEmptySlice<'a, T> {
slice: &'a [T],
}
impl<'a, T> NonEmptySlice<'a, T> {
pub fn new(slice: &'a [T]) -> Option<Self> {
if slice.is_empty() {
None
} else {
Some(Self { slice })
}
}
pub fn first(&self) -> &T {
// SAFETY: constructor ensures `slice` is never empty.
unsafe { self.slice.get_unchecked(0) }
}
}
이 example은 굳이 unsafe를 쓸 필요가 없지만 pattern을 보여준다. Unsafe block 근처에 invariant가 있어야 한다. SAFETY: comment는 “왜 이 unsafe operation이 sound한가?”를 답해야 한다.
21.2 unsafe fn은 caller에게 contract를 넘긴다
/// # Safety
///
/// `ptr` must be valid for reads of `len` bytes, properly aligned, and must
/// remain valid for the duration of the returned slice.
pub unsafe fn bytes_from_raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
std::slice::from_raw_parts(ptr, len)
}
unsafe fn documentation에는 # Safety section이 있어야 한다. Caller가 무엇을 보장해야 하는지 명확하지 않은 unsafe fn은 bad API다.
21.3 FFI는 unsafe의 대표 boundary다
C/C++는 Rust compiler가 check할 수 없는 world다. FFI에서는 다음을 조심한다.
- Layout:
#[repr(C)],#[repr(transparent)]. - Ownership transfer: 누가 allocate/free하는가?
- Nullability:
*mut T인지Option<NonNull<T>>인지. - String encoding: UTF-8, platform string, C string.
- Panic across FFI boundary.
- Thread safety.
- Callback lifetime.
Firefox 같은 large C++ codebase와 Rust integration에서는 FFI boundary를 단순하게 유지하고, complex type을 직접 넘기기보다 helper crate, generated binding, explicit constructor/destructor를 사용한다. Rust의 safety는 boundary 밖의 C++ code를 자동으로 안전하게 만들지 않는다.
21.4 Unsafe code review checklist
Unsafe code를 review할 때는 일반 readability보다 invariant 중심으로 본다.
- 이
unsafe가 필요한가? Safe API로 대체 가능한가? - Pointer validity, alignment, aliasing, initialization이 보장되는가?
- Panic 중 partially-initialized state가 leak되거나 double-drop되지 않는가?
Send/Syncmanual impl이 generic parameter bound를 정확히 요구하는가?- Public safe API가 unsafe invariant를 깨뜨릴 수 없는가?
- Test가 normal case뿐 아니라 boundary case를 다루는가?
Unsafe code는 “짧다”보다 “증명 가능하다”가 중요하다.
22. Testing Rust code
Rust는 cargo test, #[test], doctest, integration test를 기본 제공한다. 좋은 Rust test는 단순히 output을 확인하는 것이 아니라 type/API/invariant를 검증한다.
22.1 Unit test는 module 안에 둔다
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_user_id() {
let id = UserId::try_from(42).unwrap();
assert_eq!(u64::from(id), 42);
}
}
Internal detail을 test할 수 있다. Public behavior 중심 test와 internal invariant test를 균형 있게 둔다.
22.2 Integration test는 public API만 사용한다
tests/
cli.rs
protocol.rs
Integration test는 external user처럼 crate를 사용한다. Public API usability를 검증하는 데 좋다. Library design이 나쁘면 integration test가 불편해진다.
22.3 Doctest는 API example이다
Rustdoc example은 documentation이면서 test다.
/// Parses a `UserId` from a decimal string.
///
/// ```
/// # use mycrate::UserId;
/// let id: UserId = "42".parse().unwrap();
/// assert_eq!(id.to_string(), "42");
/// ```
pub struct UserId(u64);
Doctest는 user가 처음 보는 example이므로 간결해야 한다. Setup noise는 # hidden line으로 숨길 수 있다.
22.4 Property-based test
Function이 많은 input space에서 invariant를 가져야 한다면 property test가 좋다.
proptest! {
#[test]
fn encode_decode_roundtrip(bytes in proptest::collection::vec(any::<u8>(), 0..1024)) {
let encoded = encode(&bytes);
let decoded = decode(&encoded).unwrap();
prop_assert_eq!(decoded, bytes);
}
}
Protocol, parser, serializer, data structure에 특히 유용하다.
22.5 Fuzzing
Parser, FFI boundary, unsafe code, protocol decoder는 fuzzing 가치가 높다. Rust ecosystem에는 cargo fuzz가 있다. Fuzz target은 작은 API를 대상으로 해야 한다.
fuzz_target!(|data: &[u8]| {
let _ = parse_frame(data);
});
Fuzzing의 첫 목표는 crash/panic/UB를 찾는 것이다. Semantics property를 추가하면 더 강해진다.
22.6 Miri와 sanitizer
Unsafe code나 subtle aliasing을 다루면 Miri를 고려한다. AddressSanitizer, ThreadSanitizer도 native integration 상황에서 도움이 된다. CI에 모든 tool을 항상 켤 수는 없지만, unsafe-heavy crate는 주기적으로 돌리는 것이 좋다.
22.7 Async test
Tokio에서는 #[tokio::test]를 사용한다. Time-dependent test는 real sleep을 피하고 time control utility를 사용한다. Real sleep은 flaky test의 원인이 된다.
#[tokio::test(start_paused = true)]
async fn expires_key_after_timeout() {
let db = Db::new();
db.set("k", "v", Duration::from_secs(10)).await;
tokio::time::advance(Duration::from_secs(11)).await;
assert!(db.get("k").await.is_none());
}
Async test는 cancellation, timeout, shutdown path를 반드시 포함해야 한다.
23. Performance: Rust에서 cost를 보이게 만들기
Rust의 zero-cost abstraction은 “어떤 abstraction이든 공짜”라는 뜻이 아니다. 비용이 없는 abstraction도 있고, 비용이 있는 abstraction도 있다. 중요한 것은 cost model을 이해하는 것이다.
23.1 Allocation을 signature에서 생각하라
String, Vec<T>, Box<T>, Arc<T>는 heap allocation 또는 reference counting을 암시한다. Return type이 String이면 allocation 가능성이 높다. &str이면 borrowed view다. Cow는 둘 다 가능하다.
fn format_user(user: &User) -> String;
fn user_name(user: &User) -> &str;
두 function의 cost는 다르다. Naming도 cost를 암시해야 한다. as_는 cheap, to_는 owned conversion 가능, into_는 consume.
23.2 Monomorphization vs dynamic dispatch
Generic은 static dispatch와 monomorphization을 사용한다. Runtime overhead는 적지만 compile time과 code size가 늘 수 있다. dyn Trait는 code size를 줄이고 runtime polymorphism을 제공하지만 vtable dispatch와 optimization 제한이 있다.
Library에서는 generic으로 caller 선택권을 주는 경우가 많다. Binary에서는 code size/readability를 위해 dyn Trait가 적절할 수 있다.
23.3 String/Vec capacity
많은 push를 할 때 capacity를 예상할 수 있으면 with_capacity를 쓰라.
let mut out = Vec::with_capacity(input.len());
for b in input {
out.push(transform(*b));
}
하지만 premature optimization은 피하라. Hot path인지 measurement가 있는지 확인한다.
23.4 clone cost를 type별로 구분하라
Arc<T>::clone은 reference count 증가다. Vec<T>::clone은 element clone이다. Bytes 같은 shared buffer type은 cheap clone일 수 있다. Code review에서 clone()을 볼 때 type을 확인해야 한다.
23.5 Benchmark와 profiling
Rust code는 release mode에서 성능을 봐야 한다. Debug mode는 overflow check, optimization absence로 다르다. Criterion 같은 benchmark crate를 쓰고, perf, flamegraph, dhat, heaptrack 등으로 bottleneck을 확인한다.
23.6 Performance invariant를 comment하라
// Keep this vector sorted by key. `lookup` relies on binary_search and is on
// the request hot path.
entries: Vec<Entry>,
이 comment는 design constraint다. Future maintainer가 push 후 sort를 빼먹으면 performance/behavior가 깨질 수 있다.
24. Macro design: 마지막에 꺼내는 도구
Rust macro는 powerful하지만 readability와 compile time에 영향을 준다. Macro는 syntax를 만들고, code를 생성하고, repetition을 줄인다. 하지만 error message가 어려워지고 IDE support가 약해질 수 있다.
24.1 Macro를 쓰기 전 질문
- Function이나 generic으로 해결할 수 없는가?
- Trait default method로 해결할 수 없는가?
- Derive macro가 정말 필요한가?
- Macro input syntax가 output을 예측하게 하는가?
- Error message가 user에게 친절한가?
- Generated code가 public API에 어떤 영향을 주는가?
24.2 Declarative macro
반복적인 syntax construction에 좋다.
macro_rules! id_newtype {
($name:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct $name(u64);
};
}
id_newtype!(UserId);
id_newtype!(OrderId);
이 macro는 simple하다. 하지만 macro가 많아지면 generated code를 읽기 어렵다. Newtype이 몇 개 안 되면 그냥 직접 쓰는 편이 낫다.
24.3 Procedural macro
Serde derive처럼 procedural macro는 huge usability gain을 줄 수 있다. 하지만 proc-macro crate는 별도 crate가 필요하고 compile time도 늘어난다. Public macro는 API다. Input syntax와 error message를 정성껏 design해야 한다.
24.4 Macro보다 type으로
Macro는 misuse를 막지 않는다. Type이 막는다. DSL을 만들기 전에 type-level API가 충분한지 확인하라.
Part IV. Real-world Rust code에서 배울 점
25. Firefox: Rust와 C++가 만나는 large system
Firefox는 Rust가 systems programming에서 실제로 쓰이는 대표적 large codebase다. Firefox Source Docs는 Rust code를 작성하고 C++와 interoperate하는 방법을 별도로 문서화한다. 여기서 배울 design lesson은 명확하다.
25.1 FFI boundary는 단순한 type을 선호한다
C++/Rust FFI에서 bool, integer, pointer 같은 단순 type은 비교적 쉽다. String, list, hashmap, complex class는 어렵다. 복잡한 data를 boundary 너머로 직접 넘기기보다 representation을 단순화하거나 helper crate를 사용해야 한다.
Design advice:
- FFI function count를 줄이기보다 boundary contract를 단순하게 하라.
- Raw pointer + length를 직접 쓰면 error-prone하다. Wrapper/helper를 고려하라.
- HashMap 같은 구조는 key/value list로 쪼개는 것이 나을 수 있다.
- Ownership transfer function을 명시하라:
new,destroy,as_string같은 pattern.
25.2 Unsafe는 C++와의 mismatch를 표시한다
Rust declaration과 C++ signature가 mismatch되면 crash 가능성이 있다. 그래서 FFI call은 unsafe다. 이 unsafe는 “이 code가 위험해 보인다”는 느낌 표시가 아니라, compiler가 cross-language contract를 verify할 수 없다는 뜻이다.
Design advice:
unsafe extern "C"function은 가능한 작은 layer에 둔다.- Public safe wrapper를 제공한다.
- Generated binding(
bindgen,cbindgen)을 사용할 때도 generated code를 blind trust하지 않는다. - Layout-sensitive type은
#[repr(C)]또는#[repr(transparent)]를 검토한다.
25.3 Large codebase는 toolchain policy도 design이다
Firefox는 stable Rust를 사용하고, MSRV update를 schedule/policy로 관리한다. 이는 library/project design에서도 중요하다. 최신 Rust feature를 쓰고 싶어도 downstream packager, CI, embedded target, distro support를 고려해야 한다.
Design advice:
- MSRV를 문서화하라.
- Public crate에서는 MSRV bump를 release note에 적어라.
rust-toolchain.toml을 사용할지 team policy를 정하라.- Clippy/rustfmt version 차이로 CI가 흔들리지 않게 하라.
25.4 Firefox docs에서 배우는 testing boundary
Firefox의 Rust test는 normal cargo test로 가능한 crate도 있지만, Gecko symbol에 link해야 하는 crate는 GTest/FFI path가 필요할 수 있다. Lesson은 general하다. Test strategy는 architecture boundary를 따라 달라진다. Pure Rust module은 cargo test/doctest/property test로 충분할 수 있지만, C++/OS/kernel boundary와 연결되면 integration test가 필요하다.
26. Tokio mini-redis: async Rust를 배우는 좋은 작은 system
Tokio mini-redis는 production Redis가 아니라 learning resource다. 하지만 design pattern이 잘 드러난다.
배울 점:
- TCP server는 connection별 task를 spawn한다.
- Client library는 capability를
asyncmethod로 노출한다. - Wire protocol은
Frameintermediate representation으로 모델링한다. Connectiontype은TcpStream을 감싸고 frame send/receive API를 제공한다.- Graceful shutdown, connection limiting, pub/sub, deterministic time test를 보여준다.
- Shared state에는
Dbwrapper type을 사용해 synchronization을 내부에 숨긴다.
Design advice:
- Protocol parsing과 I/O를 분리하라.
- Shared state primitive를 public으로 노출하지 말고 domain type으로 감싸라.
- Graceful shutdown을 feature가 아니라 core lifecycle로 design하라.
- Async code에서 time-dependent test는 mocked time을 사용하라.
- Observability는 나중에 붙이는 것이 아니라 operation boundary에서 설계하라.
27. Serde: trait-centric deep abstraction
Serde의 design은 Rust type system을 잘 활용한 대표 사례다. Serde는 data structure와 data format 사이에 Serialize/Deserialize trait layer를 둔다. 다른 language가 runtime reflection에 의존하는 경우가 많지만, Serde는 trait와 derive macro를 통해 compile-time code generation과 optimization을 활용한다.
배울 점:
trait가 data와 format 사이의 deep interface 역할을 한다.- Derive macro는 user-facing ergonomics를 크게 높인다.
no_std, feature flag, data format ecosystem을 고려한 design이다.- Public trait와 macro가 함께 API를 이룬다.
Design advice:
- Trait는 단순 “interface”가 아니라 ecosystem protocol이 될 수 있다.
- Macro는 boilerplate 제거 이상의 가치를 줄 때 강력하다.
- Data model을 잘 design하면 serialization code는 derive로 충분해진다.
- Reflection 없이도 generic하고 efficient한 API를 만들 수 있다.
28. ripgrep: CLI, performance, testing discipline
ripgrep은 Rust로 작성된 command-line tool의 대표 사례다. README만 봐도 stable Rust/MSRV, feature flag, optional PCRE2, test suite, build modes를 명확히 관리한다. CLI tool design에서 중요한 것은 algorithm만이 아니다. Installability, cross-platform support, release build, feature flag, test coverage, user-facing error가 모두 product quality다.
Design advice:
- CLI tool도 library core와 binary shell을 분리하라.
- Feature flag는 optional capability를 표현하되 stable compiler와 build portability를 해치지 않게 하라.
- Integration test를 통해 CLI behavior를 검증하라.
- Performance-sensitive code는 benchmark와 real workload로 확인하라.
- User-facing output에는
Debugformatting을 그대로 쓰지 말라.
Part V. OOP guide와의 직접 비교
29. Clean Code의 rule을 Rust에서 다시 평가하기
29.1 “Function은 작아야 한다”
Rust에서도 작은 function은 좋다. 하지만 Rust에서 function split은 ownership boundary를 만든다. Method를 너무 잘게 쪼개서 모두 &mut self를 받게 만들면 borrow checker와 충돌하거나, RefCell로 도망가게 된다. Split 기준은 다음이다.
좋은 split:
- 내부 detail을 숨긴다.
- 반복되는 invariant check를 한곳에 둔다.
- ownership/mutation scope를 줄인다.
- error context를 명확히 한다.
- protocol/state transition 단계를 표현한다.
나쁜 split:
- 한 줄 pass-through다.
- 이름만 바꾸고 정보는 추가하지 않는다.
- borrow checker를 피하려고 불필요한
clone을 만든다. - trait object/generic layer를 불필요하게 추가한다.
29.2 “Comment는 나쁜 code smell이다”
Rust에서는 comment를 두 종류로 나눠야 한다.
나쁜 comment:
// increment count
count += 1;
좋은 comment:
// SAFETY: `index` was checked against `self.len` above, and the buffer is
// initialized for all elements in `0..self.len`.
unsafe { self.ptr.add(index).read() }
Rust에서 좋은 comment는 unsafe, memory ordering, FFI contract, panic invariant, cancellation safety, lock ordering, performance invariant를 설명한다. 이런 정보는 code syntax만으로 충분하지 않다.
29.3 “DRY”
Rust에서 premature abstraction은 trait/generic/lifetime complexity를 부를 수 있다. 두 code가 textually 비슷하다고 바로 generic function으로 묶지 말라. 같은 policy인지 확인하라.
fn parse_user_id(s: &str) -> Result<UserId, Error>;
fn parse_order_id(s: &str) -> Result<OrderId, Error>;
두 function이 모두 decimal u64 parsing이라도 domain error message와 validation이 다를 수 있다. 무조건 parse_id<T>()로 합치면 abstraction이 domain difference를 숨긴다.
29.4 “SOLID”
Single Responsibility는 Rust에서도 유효하지만 class 기준이 아니라 invariant 기준으로 생각하라. Open/Closed는 closed enum과 open trait 중 선택하는 문제로 바뀐다. Dependency Inversion은 trait/generic/closure를 선택하는 문제다.
29.5 “TDD”
Rust에서는 compiler가 많은 bug를 먼저 잡는다. 그렇다고 test가 덜 중요해지는 것은 아니다. Test는 type system이 잡지 못하는 semantic property, integration behavior, concurrency schedule, error message, performance regression을 잡는다. Rust의 ideal workflow는 compiler + unit test + doctest + property test + integration test + fuzzing이 조합되는 것이다.
30. A Philosophy of Software Design을 Rust에 적용하기
30.1 Deep module = small public API + strong private invariant
Rust의 private field와 module privacy는 deep module을 만들기 좋다. Public API를 작게 두고, 내부 representation을 자유롭게 바꿀 수 있게 하라.
30.2 Information hiding = ownership hiding
어떤 module이 internal buffer를 Vec<u8>로 갖는지, Bytes로 갖는지, mmap으로 갖는지는 caller가 몰라도 되게 하라. Caller에게 필요한 것은 operation이다.
pub struct BlobStore { /* private */ }
impl BlobStore {
pub fn get(&self, key: &Key) -> Result<Option<Blob>, Error>;
pub fn put(&mut self, key: Key, blob: Blob) -> Result<(), Error>;
}
30.3 Shallow module = unnecessary trait/generic wrapper
Rust에서는 shallow abstraction이 trait 형태로도 나타난다.
pub trait UserGetter {
fn get_user(&self, id: UserId) -> Option<User>;
}
이 trait가 단 하나의 implementation만 있고, polymorphism도 필요 없고, test에도 closure로 충분하다면 shallow abstraction일 수 있다. Trait는 public API cost가 크다.
30.4 Strategic programming = public API를 늦게 확정하기
Rust library에서 public API를 빨리 열면 SemVer burden이 생긴다. Internal module로 실험하고, usage pattern이 안정된 후 public으로 re-export하라.
30.5 Design it twice = type model을 두 번 그리기
Rust에서 design it twice는 특히 효과적이다. 첫 design은 OOP mental model로 나올 가능성이 높다. 두 번째 design에서는 다음 질문을 던져라.
enum으로 state를 표현하면 invalid state가 줄어드는가?newtype으로 ID/offset/length를 구분할 수 있는가?&[T]로 충분한데Vec<T>를 요구하고 있지 않은가?Arc<Mutex<T>>없이 owner thread/channel로 해결 가능한가?- Lifetime-heavy API보다 owned handle/index가 나은가?
- Public trait가 정말 필요한가?
Part VI. Rust code review checklist
31. API review checklist
Public API를 review할 때는 다음을 본다.
Naming
as_,to_,into_convention이 맞는가?iter,iter_mut,into_iter가 expectation과 맞는가?try_prefix가 fallible operation을 잘 나타내는가?- Type 이름이 domain concept를 명확히 나타내는가?
Manager,Helper,Util,Data같은 generic name이 많지 않은가?
Ownership
- Function이 ownership을 가져갈 필요가 있는가?
&str/&[T]로 충분한데String/Vec<T>를 요구하지 않는가?clone()이 intentional한가?Arc<Mutex<T>>가 public API에 새어 나오지 않는가?- Lifetime parameter가 caller에게 불필요하게 전파되지 않는가?
Error
Option과Result가 올바르게 구분되는가?- Library error type이 meaningful한가?
panic!이 recoverable error에 쓰이지 않는가?- Error에 context가 충분한가?
- Error variant가 너무 implementation-specific하지 않은가?
Trait/generic
- Trait가 정말 필요한가?
- Associated type과 generic parameter 선택이 적절한가?
- Object safety를 고려했는가?
- Generic bound가 unnecessary하게 tight하지 않은가?
- Public trait에 future evolution 여지가 있는가?
Module
- Public surface가 작고 안정적인가?
- Field가 private인가?
- Internal representation이 숨겨져 있는가?
- Re-export path가 user-friendly한가?
- Feature flag가 additive하고 문서화되어 있는가?
32. Implementation review checklist
Readability
- Main flow가 보이는가?
match와let else가 적절히 쓰였는가?- Iterator chain이 readable한 길이를 넘지 않는가?
- Temporary variable 이름이 domain meaning을 주는가?
unwrap()/expect()가 invariant를 설명하는가?
Mutation
mut가 필요한 곳에만 있는가?- Lock scope가 작게 제한되어 있는가?
- Interior mutability가 정당한가?
- Shared mutable state가 operation-based wrapper 뒤에 숨겨져 있는가?
Performance
- Hot path에서 unnecessary allocation/clone이 없는가?
with_capacity가 필요한 곳에 있는가?dyn Trait와 generic 선택이 cost model에 맞는가?- Debug formatting이 user-facing output에 쓰이지 않는가?
- Benchmark 없이 micro-optimization이 많지 않은가?
Concurrency/async
- Lock guard가
.awaitacross로 유지되지 않는가? - Detached task lifecycle이 명확한가?
- Shutdown/cancellation path가 있는가?
Send + 'staticbound가 필요한 이유가 있는가?- Backpressure가 고려되었는가?
Unsafe
unsafeblock이 최소화되어 있는가?SAFETY:comment가 있는가?- Public
unsafe fn에# Safetydocs가 있는가? - Pointer alignment/lifetime/aliasing/initialization이 보장되는가?
- Unsafe invariant가 safe API로 깨질 수 없는가?
33. Test review checklist
- Public API doctest가 있는가?
- Error path test가 있는가?
- Boundary case: empty, max, invalid UTF-8, overflow, timeout, cancellation이 있는가?
- Property test가 필요한 parser/serializer/data structure인가?
- Fuzz target이 필요한 unsafe/parser/FFI code인가?
- Async time test가 real sleep에 의존하지 않는가?
- Integration test가 binary/user-facing behavior를 검증하는가?
- Test가 implementation detail에 과하게 묶여 refactoring을 방해하지 않는가?
Part VII. Common anti-patterns
34. OOP를 Rust로 그대로 옮긴 anti-pattern
34.1 Arc<Mutex<Everything>>
pub struct App {
state: Arc<Mutex<AppState>>,
}
이 자체가 나쁜 것은 아니다. 하지만 모든 module이 state.lock()을 하고 내부를 마음대로 조작하면 encapsulation이 사라진다. Better: operation 중심 API를 제공하고 lock을 private으로 숨겨라.
34.2 Getter/setter flood
impl Config {
pub fn get_timeout(&self) -> Duration { self.timeout }
pub fn set_timeout(&mut self, timeout: Duration) { self.timeout = timeout; }
}
Rust convention에서는 getter에 get_ prefix를 보통 쓰지 않는다. 또한 setter가 invariant를 깨뜨릴 수 있다면 제공하지 않는다. Builder나 specific method를 고려한다.
34.3 Trait object everywhere
fn process(reader: &mut dyn Read, writer: &mut dyn Write);
나쁘지 않지만 library에서는 generic이 더 flexible할 수 있다.
fn process<R: Read, W: Write>(reader: &mut R, writer: &mut W);
반대로 binary에서 generic이 code를 복잡하게 만들면 dyn Trait가 더 practical하다. Context가 중요하다.
34.4 Lifetime soup
struct System<'a, 'b, 'c, 'd> {
a: &'a A,
b: &'b B,
c: &'c C,
d: &'d D,
}
이런 type은 사용하기 어렵다. 정말 borrowed aggregate가 필요한지, owned handle/index/Arc가 더 나은지, data structure를 나눌 수 있는지 검토한다.
34.5 String everywhere
fn lookup(key: String) -> Option<String>;
대부분 &str 입력이 더 좋다. Return은 ownership이 필요하면 String이 맞다. Input과 output은 다르게 생각하라.
34.6 unwrap() in library code
Library code에서 unwrap()은 caller input으로 panic할 수 있으면 위험하다. Invariant가 있으면 expect()로 설명하거나, error로 반환하라.
34.7 Macro로 readability 숨기기
Macro가 반복을 줄여도 reader가 generated code를 상상해야 한다면 complexity가 늘 수 있다. 특히 public macro는 documentation과 examples가 필수다.
34.8 Feature flag explosion
Feature 조합이 많으면 CI가 모든 조합을 test하지 못한다. Feature는 additive하고 orthogonal하게 유지하라. Mutually exclusive feature는 compile_error로 막아라.
#[cfg(all(feature = "native-tls", feature = "rustls"))]
compile_error!("features `native-tls` and `rustls` cannot be enabled together");
Part VIII. Systems/OS programming 관점의 Rust
35. OS/Systems code에서 특히 중요한 점
승현님처럼 OS/systems background가 있으면 Rust의 장점과 마찰이 모두 선명하게 보인다. Rust는 C/C++와 비슷한 control을 제공하지만, ownership과 type system이 많은 undefined behavior를 막는다. Systems code에서는 다음 lens가 특히 중요하다.
35.1 Resource ownership
File descriptor, page frame, lock guard, memory mapping, socket, process handle은 모두 resource다. Rust에서는 resource owner type을 만들어 Drop으로 release를 표현할 수 있다.
pub struct Fd(RawFd);
impl Drop for Fd {
fn drop(&mut self) {
unsafe { libc::close(self.0); }
}
}
하지만 Drop에서 error를 반환할 수 없다. Fallible cleanup이 중요하면 explicit close method를 제공한다.
impl Fd {
pub fn close(self) -> io::Result<()> {
let raw = self.0;
std::mem::forget(self);
let rc = unsafe { libc::close(raw) };
if rc == 0 { Ok(()) } else { Err(io::Error::last_os_error()) }
}
}
이 pattern은 조심해야 한다. ManuallyDrop이나 inner Option을 쓰는 더 안전한 design도 있다. 핵심은 fallible destructor 문제를 API에서 숨기지 않는 것이다.
35.2 Address, size, offset type 구분
Kernel-like code에서 usize 하나로 address, length, page number, offset을 모두 표현하면 bug가 생긴다.
pub struct VirtAddr(usize);
pub struct PhysAddr(usize);
pub struct PageNumber(usize);
pub struct PageOffset(usize);
newtype은 cheap하다. Compile-time 구분으로 실수를 줄인다.
35.3 Alignment와 layout
FFI, device register, on-disk format, network packet에서는 layout이 중요하다. Rust default layout은 안정적으로 가정하면 안 된다. #[repr(C)], #[repr(transparent)], byte parsing을 사용하라. repr(packed)는 reference alignment 문제를 일으킬 수 있으므로 조심한다.
35.4 no_std
Embedded/kernel code에서는 std 없이 core/alloc만 사용할 수 있다. no_std support를 public promise로 하면 SemVer contract가 된다. std dependency를 나중에 추가하는 것은 breaking일 수 있다.
35.5 Interrupt/lock context
Rust type system이 모든 OS invariant를 알지는 못한다. “이 function은 interrupt context에서 호출 가능”, “이 lock을 잡은 상태에서만 호출” 같은 rule은 type으로 표현하거나 documentation해야 한다.
pub struct IrqDisabled<'a> {
_guard: &'a mut CpuLocalState,
}
pub fn with_irq_disabled<R>(f: impl FnOnce(IrqDisabled<'_>) -> R) -> R {
// disable irq, call f, restore irq
todo!()
}
Typestate/guard pattern으로 context를 type에 넣을 수 있다. 이런 pattern은 OS code에서 매우 강력하다.
Part IX. 실전 recipe
36. 좋은 Rust API signature recipe
Read-only text input
fn parse(input: &str) -> Result<T, Error>;
Read-only bytes input
fn decode(input: &[u8]) -> Result<T, Error>;
Path input
fn load(path: impl AsRef<Path>) -> Result<T, Error>;
Collection input, consume items
fn extend<I>(&mut self, items: I)
where
I: IntoIterator<Item = Item>;
Writer/Reader
fn write_to<W: Write>(&self, writer: W) -> io::Result<()>;
fn read_from<R: Read>(reader: R) -> io::Result<Self>;
Optional config
Config::builder()
.timeout(Duration::from_secs(5))
.retries(3)
.build()?;
Library error
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error { ... }
Binary error
fn main() -> anyhow::Result<()> { ... }
Async client
impl Client {
pub async fn request(&self, req: Request) -> Result<Response, Error>;
}
Safe wrapper over unsafe resource
pub struct Mapping { ptr: NonNull<u8>, len: usize }
impl Mapping {
pub fn as_slice(&self) -> &[u8] { ... }
}
impl Drop for Mapping { ... }
37. Refactoring recipe
37.1 clone()이 많을 때
- Clone type이 cheap인지 확인한다.
- Function이 ownership 대신 borrow를 받을 수 있는지 본다.
- Data structure를 split해서 disjoint borrow가 가능하게 한다.
- Index/handle을 사용할 수 있는지 본다.
- 정말 shared ownership이면
Arc를 사용하되 mutation은 최소화한다.
37.2 Lifetime error가 복잡할 때
- Struct에 reference를 저장하지 않아도 되는지 확인한다.
- Function return reference가 어느 input과 연결되는지 명확히 한다.
- Owned type으로 바꾸는 cost를 평가한다.
Cow를 고려한다.- Self-referential structure를 만들고 있지 않은지 확인한다.
- 필요하면 arena/index design으로 바꾼다.
37.3 Error type이 지저분할 때
- Caller가 구분해야 하는 error category만 남긴다.
- Source error는
#[from]또는 source chain으로 보존한다. - Application boundary에서 context를 붙인다.
Option으로 표현 가능한 absence와 true error를 구분한다.
37.4 Async code가 꼬일 때
- Sync parsing/business logic을 async I/O에서 분리한다.
.awaitacross로 들고 있는 borrow/lock을 찾는다.- Detached task를 줄이고 join handle을 관리한다.
- Shutdown/cancellation을 type으로 표현한다.
- Backpressure primitive를 도입한다.
37.5 Trait bound가 폭발할 때
- Public generic이 정말 필요한지 본다.
- Associated type으로 relation을 단순화할 수 있는지 본다.
- Helper trait나 sealed trait이 필요한지 본다.
- Binary/internal code라면
dyn Trait로 단순화할 수 있는지 본다. - Bound를 where clause로 옮겨 readability를 높인다.
Part X. 학습 로드맵
38. 4주 Writing Rust 훈련 계획
Week 1: Type-first coding
목표: primitive obsession을 줄이고 Option/Result/enum을 자연스럽게 쓴다.
연습:
- 기존 작은 Python/C++ program을 Rust로 옮기되
String/u64남발을 줄인다. UserId,PageId,Offset,Length를newtype으로 만든다.boolflag를enum으로 바꾼다.- Public field를 private으로 바꾸고 constructor를 만든다.
Review question:
- Invalid state가 type으로 막혔는가?
- Function signature가 failure와 ownership을 드러내는가?
Week 2: Ownership-first API
목표: &str, &[T], &mut T, self receiver를 의식적으로 선택한다.
연습:
Stringinput을&str로 바꾼다.Vec<T>input을&[T]로 바꾼다.- State transition method를
selfconsuming method로 바꾼다. - 불필요한
clone()을 제거한다.
Review question:
- Caller에게 필요한 것보다 많은 ownership을 요구하지 않는가?
- Mutation scope가 최소인가?
Week 3: Trait/API design
목표: trait, generic, dyn Trait, impl Trait를 적절히 선택한다.
연습:
- 작은 parser trait를 만든다.
impl Iteratorreturn API를 만든다.dyn Trait가 필요한 heterogeneous collection을 만든다.- Public trait에 default method와 object safety를 고려한다.
Review question:
- Trait가 real capability를 표현하는가?
- Generic bound가 필요 이상으로 tight하지 않은가?
Week 4: Systems-quality Rust
목표: error, test, unsafe, concurrency boundary를 design한다.
연습:
- Library error enum을 만든다.
- Doctest와 integration test를 추가한다.
- Parser에 property test/fuzz target을 만든다.
- 작은 unsafe wrapper를 만들고
SAFETYcomment를 쓴다. - Async task shutdown을 구현한다.
Review question:
- Unsafe invariant가 safe API로 보호되는가?
- Error와 cancellation behavior가 문서화되어 있는가?
39. 읽을 code 추천
Rust standard library
Vec, Option, Result, Iterator, Arc, Mutex, Path, String docs와 source를 읽는다. Standard library는 naming convention과 trait implementation의 기준점이다.
Tokio mini-redis
Async server/client, wire protocol framing, shared state, graceful shutdown, time-based test를 학습하기 좋다.
Serde
Trait-centric abstraction, derive macro ergonomics, feature flag, no_std support를 배울 수 있다.
ripgrep
CLI structure, performance, feature flag, integration test, cross-platform build를 배울 수 있다.
Firefox Rust docs/code
Large C++ codebase와 Rust integration, FFI boundary, toolchain policy, testing constraints를 배울 수 있다. Searchfox로 Rust/C++/JS/Python이 함께 있는 codebase를 탐색하는 것도 좋은 훈련이다.
Rustonomicon
Unsafe code를 직접 쓰지 않더라도, unsafe invariant와 safe abstraction의 관계를 이해하려면 읽을 가치가 있다. 단, Rust Reference와 최신 docs를 함께 확인한다.
Appendix A. Quick rules
A.1 Input type 선택
| 필요 | 추천 type |
|---|---|
| text read only | &str |
| bytes read only | &[u8] |
| path | impl AsRef<Path> |
| ownership 필요 | String, Vec<T>, PathBuf, T |
| optional value | Option<T> |
| failure | Result<T, E> |
| shared ownership | Rc<T> or Arc<T> |
| shared mutation | wrapper around Mutex<T> / RwLock<T> |
| plugin/open polymorphism | Box<dyn Trait> / Arc<dyn Trait + Send + Sync> |
| compile-time polymorphism | T: Trait / impl Trait |
A.2 Naming convention
| Prefix | Meaning |
|---|---|
as_ |
borrowed/cheap conversion |
to_ |
owned conversion, may allocate/copy |
into_ |
consumes self |
try_ |
fallible operation |
from_ |
constructor/conversion |
with_ |
constructor with options |
iter |
&self iterator |
iter_mut |
&mut self iterator |
into_iter |
consuming iterator |
A.3 Error choice
| 상황 | 추천 |
|---|---|
| 정상적인 absence | Option<T> |
| recoverable failure | Result<T, E> |
| programmer error/invariant broken | panic! / expect |
| library public API | custom error enum |
| binary/application | anyhow style with context |
| FFI boundary | explicit status/error mapping |
A.4 Trait choice
| 상황 | 추천 |
|---|---|
| closed set of variants | enum |
| open set/plugin | trait + dyn Trait |
| high-performance generic | T: Trait |
| hidden concrete return | impl Trait |
| one output type per implementor | associated type |
| multiple impl per type | generic trait parameter |
| future-proof trait | sealed trait / default methods |
A.5 Comment가 필요한 곳
unsafeblock의SAFETYreason.unsafe fn의# Safetycontract.panic가능성과 invariant.memory orderingrationale.lock ordering.FFIownership/layout/nullability.cancellation safety.- Performance-sensitive invariant.
- Public API example.
Appendix B. Example: OOP-style에서 Rust-style로 refactor
B.1 Before: OOP-style shared mutable service
use std::sync::{Arc, Mutex};
pub struct UserService {
db: Arc<Mutex<Database>>,
cache: Arc<Mutex<Cache>>,
}
impl UserService {
pub fn get_user(&self, id: u64) -> Option<User> {
if let Some(user) = self.cache.lock().unwrap().get(id) {
return Some(user.clone());
}
let user = self.db.lock().unwrap().find_user(id)?;
self.cache.lock().unwrap().insert(id, user.clone());
Some(user)
}
}
문제:
u64가 어떤 ID인지 모호하다.Arc<Mutex<_>>가 service 내부에 있지만 lock policy가 불분명하다.- Cache clone cost가 보이지 않는다.
- DB error와 absence가 모두
Option으로 뭉개진다. - Lock acquisition이 여러 번 흩어져 있다.
B.2 After: Rust-style domain/API
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(u64);
#[derive(Debug, thiserror::Error)]
pub enum UserLookupError {
#[error("database error")]
Db(#[from] DbError),
}
#[derive(Clone)]
pub struct UserCache {
inner: Arc<Mutex<CacheInner>>,
}
impl UserCache {
pub fn get(&self, id: UserId) -> Option<User> {
let inner = self.inner.lock().unwrap();
inner.get(id).cloned()
}
pub fn insert(&self, id: UserId, user: User) {
let mut inner = self.inner.lock().unwrap();
inner.insert(id, user);
}
}
pub struct UserService {
db: Database,
cache: UserCache,
}
impl UserService {
pub fn get_user(&self, id: UserId) -> Result<Option<User>, UserLookupError> {
if let Some(user) = self.cache.get(id) {
return Ok(Some(user));
}
let Some(user) = self.db.find_user(id)? else {
return Ok(None);
};
self.cache.insert(id, user.clone());
Ok(Some(user))
}
}
개선점:
UserId가 domain type이다.- DB failure와 missing user가 분리된다.
- Cache synchronization이
UserCache안으로 들어갔다. - Clone은 cache/store boundary에서 의도적으로 발생한다.
- Public API가 caller에게 더 정직하다.
Appendix C. Source notes
이 guide는 다음 관찰을 바탕으로 작성했다.
- Rust for Rustaceans는 Rust 기본서 이후의 gap을 메우는 intermediate guide로, memory/ownership, type layout, trait/coherence, API design, error handling, project structure, testing, macros, async, unsafe, concurrency, FFI,
no_std, ecosystem을 다룬다. - Programming Rust는 Rust를 systems programming language로 설명하고, systems programming을 resource-constrained programming으로 본다. Ownership/reference/traits/generics/iterators/concurrency/async/unsafe/FFI를 practical하게 연결한다.
- Rust API Guidelines는 naming, interoperability, macros, documentation, predictability, flexibility, type safety, dependability, debuggability, future-proofing을 API review checklist로 제공한다.
- Rust Style Guide와 rustfmt는 formatting debate를 줄이고 reader의 pattern matching을 돕는다.
- Cargo SemVer compatibility는 Rust public API change가 언제 breaking인지 매우 구체적으로 설명한다.
- Clippy는 correctness/suspicious/style/complexity/perf 등 lint category로 code quality feedback을 제공한다.
- Rustonomicon은 unsafe Rust의 contract와 Safe/Unsafe boundary를 설명한다. Unsafe code는 safe abstraction과 documentation이 함께 있어야 한다.
- Firefox Source Docs는 large C++/Rust integration에서 FFI, helper crates, toolchain policy, testing constraints가 얼마나 중요한지 보여준다.
- Tokio
mini-redis는 async Rust pattern을 작은 but realistic system 안에서 보여준다. - Serde는 Rust trait system과 derive macro를 이용해 reflection 없이 generic하고 efficient한 serialization layer를 만든다.
- ripgrep은 Rust CLI tool이 performance, portability, feature flag, testing, stable compiler policy를 어떻게 관리하는지 보여주는 좋은 example이다.
References
- Rust API Guidelines: https://rust-lang.github.io/api-guidelines/
- Rust Style Guide: https://doc.rust-lang.org/style-guide/
- Cargo SemVer Compatibility: https://doc.rust-lang.org/cargo/reference/semver.html
- Clippy Documentation: https://doc.rust-lang.org/stable/clippy/
- Rustonomicon: https://doc.rust-lang.org/nomicon/
- Firefox Source Docs — Writing Rust Code: https://firefox-source-docs.mozilla.org/writing-rust-code/index.html
- Firefox Source Docs — Testing & Debugging Rust Code: https://firefox-source-docs.mozilla.org/testing-rust-code/index.html
- Searchfox: https://searchfox.org/
- Tokio
mini-redis: https://github.com/tokio-rs/mini-redis - Tokio: https://github.com/tokio-rs/tokio
- Serde: https://serde.rs/
- ripgrep: https://github.com/BurntSushi/ripgrep