Rust 개발 패턴
안전하고 성능 좋으며 유지보수하기 쉬운 애플리케이션을 구축하기 위한 관용적 Rust 패턴과 모범 사례입니다.
사용 시기
- 새로운 Rust 코드를 작성할 때
- Rust 코드를 리뷰할 때
- 기존 Rust 코드를 리팩터링할 때
- 크레이트 구조와 모듈 레이아웃을 설계할 때
동작 방식
이 스킬은 다음 여섯 가지 주요 영역에서 관용적 Rust 규칙을 강화합니다: 컴파일 타임에 데이터 경합을 방지하는 소유권과 빌림, thiserror(라이브러리)와 anyhow(애플리케이션)를 사용한 Result/? 에러 전파, 불가능한 상태를 표현 불가능하게 만드는 열거형과 철저한 패턴 매칭, 제로-코스트 추상화를 위한 트레이트와 제네릭, Arc<Mutex<T>>, 채널, async/await를 통한 안전한 동시성, 그리고 도메인별로 구성된 최소한의 pub 노출 영역입니다.
핵심 원칙
1. 소유권과 빌림
Rust의 소유권 시스템은 데이터 경합과 메모리 버그를 컴파일 타임에 방지합니다.
// 좋음: 소유권이 필요하지 않을 때는 참조를 전달하세요
fn process(data: &[u8]) -> usize {
data.len()
}
// 좋음: 저장하거나 소비해야 할 때만 소유권을 가져오세요
fn store(data: Vec<u8>) -> Record {
Record { payload: data }
}
// 나쁨: 빌림 검사기를 피하려고 불필요하게 복제하기
fn process_bad(data: &Vec<u8>) -> usize {
let cloned = data.clone(); // 낭비입니다 — 그냥 빌리세요
cloned.len()
}
유연한 소유권을 위한 Cow 사용
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains(' ') {
Cow::Owned(input.replace(' ', "_"))
} else {
Cow::Borrowed(input) // 변형이 필요 없을 때는 제로-코스트
}
}
에러 처리
Result와 ? 사용 — 프로덕션에서는 절대 unwrap() 금지
// 좋음: 컨텍스트와 함께 에러 전파
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {path}"))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("failed to parse config from {path}"))?;
Ok(config)
}
// 나쁨: 에러 시 패닉
fn load_config_bad(path: &str) -> Config {
let content = std::fs::read_to_string(path).unwrap(); // 패닉!
toml::from_str(&content).unwrap()
}
라이브러리 에러는 thiserror, 애플리케이션 에러는 anyhow
// 라이브러리 코드: 구조화된 타입 에러
use thiserror::Error;
#[derive(Debug, Error)]
pub enum StorageError {
#[error("record not found: {id}")]
NotFound { id: String },
#[error("connection failed")]
Connection(#[from] std::io::Error),
#[error("invalid data: {0}")]
InvalidData(String),
}
// 애플리케이션 코드: 유연한 에러 처리
use anyhow::{bail, Result};
fn run() -> Result<()> {
let config = load_config("app.toml")?;
if config.workers == 0 {
bail!("worker count must be > 0");
}
Ok(())
}
중첩 매칭 대신 Option 컴비네이터
// 좋음: 컴비네이터 체인
fn find_user_email(users: &[User], id: u64) -> Option<String> {
users.iter()
.find(|u| u.id == id)
.map(|u| u.email.clone())
}
// 나쁨: 깊게 중첩된 매칭
fn find_user_email_bad(users: &[User], id: u64) -> Option<String> {
match users.iter().find(|u| u.id == id) {
Some(user) => match &user.email {
email => Some(email.clone()),
},
None => None,
}
}
열거형과 패턴 매칭
상태를 열거형으로 모델링
// 좋음: 불가능한 상태는 표현 불가능
enum ConnectionState {
Disconnected,
Connecting { attempt: u32 },
Connected { session_id: String },
Failed { reason: String, retries: u32 },
}
fn handle(state: &ConnectionState) {
match state {
ConnectionState::Disconnected => connect(),
ConnectionState::Connecting { attempt } if *attempt > 3 => abort(),
ConnectionState::Connecting { .. } => wait(),
ConnectionState::Connected { session_id } => use_session(session_id),
ConnectionState::Failed { retries, .. } if *retries < 5 => retry(),
ConnectionState::Failed { reason, .. } => log_failure(reason),
}
}
철저한 매칭 — 비즈니스 로직에는 Catch-All 금지
// 좋음: 모든 변형을 명시적으로 처리
match command {
Command::Start => start_service(),
Command::Stop => stop_service(),
Command::Restart => restart_service(),
// 새 변형을 추가하면 여기서 처리하도록 강제됨
}
// 나쁨: 와일드카드가 새 변형을 숨김
match command {
Command::Start => start_service(),
_ => {} // Stop, Restart 및 미래 변형을 조용히 무시
}
트레이트와 제네릭
제네릭을 받고 구체 타입을 반환
// 좋음: 제네릭 입력, 구체 출력
fn read_all(reader: &mut impl Read) -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
Ok(buf)
}
// 좋음: 다중 제약을 위한 트레이트 바운드
fn process<T: Display + Send + 'static>(item: T) -> String {
format!("processed: {item}")
}
동적 디스패치를 위한 트레이트 객체
// 이종 컬렉션이나 플러그인 시스템이 필요할 때 사용
trait Handler: Send + Sync {
fn handle(&self, request: &Request) -> Response;
}
struct Router {
handlers: Vec<Box<dyn Handler>>,
}
// 성능이 중요할 때는 제네릭 사용 (모노모피제이션)
fn fast_process<H: Handler>(handler: &H, request: &Request) -> Response {
handler.handle(request)
}
타입 안전성을 위한 Newtype 패턴
// 좋음: 서로 다른 타입이 인수를 혼동하는 것을 방지
struct UserId(u64);
struct OrderId(u64);
fn get_order(user: UserId, order: OrderId) -> Result<Order> {
// 사용자 ID와 주문 ID를 실수로 바꿀 수 없음
todo!()
}
// 나쁨: 인수를 쉽게 바꿔버릴 수 있음
fn get_order_bad(user_id: u64, order_id: u64) -> Result<Order> {
todo!()
}
구조체와 데이터 모델링
복잡한 생성을 위한 빌더 패턴
struct ServerConfig {
host: String,
port: u16,
max_connections: usize,
}
impl ServerConfig {
fn builder(host: impl Into<String>, port: u16) -> ServerConfigBuilder {
ServerConfigBuilder { host: host.into(), port, max_connections: 100 }
}
}
struct ServerConfigBuilder { host: String, port: u16, max_connections: usize }
impl ServerConfigBuilder {
fn max_connections(mut self, n: usize) -> Self { self.max_connections = n; self }
fn build(self) -> ServerConfig {
ServerConfig { host: self.host, port: self.port, max_connections: self.max_connections }
}
}
// 사용법: ServerConfig::builder("localhost", 8080).max_connections(200).build()
반복자와 클로저
수동 루프보다 반복자 체인 선호
// 좋음: 선언적, 지연 평가, 조합 가능
let active_emails: Vec<String> = users.iter()
.filter(|u| u.is_active)
.map(|u| u.email.clone())
.collect();
// 나쁨: 명령형 누적
let mut active_emails = Vec::new();
for user in &users {
if user.is_active {
active_emails.push(user.email.clone());
}
}
타입 어노테이션과 함께 collect() 사용
// 다양한 타입으로 수집
let names: Vec<_> = items.iter().map(|i| &i.name).collect();
let lookup: HashMap<_, _> = items.iter().map(|i| (i.id, i)).collect();
let combined: String = parts.iter().copied().collect();
// Result 수집 — 첫 번째 에러에서 단락
let parsed: Result<Vec<i32>, _> = strings.iter().map(|s| s.parse()).collect();
동시성
공유 가변 상태를 위한 Arc<Mutex<T>>
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let handles: Vec<_> = (0..10).map(|_| {
let counter = Arc::clone(&counter);
std::thread::spawn(move || {
let mut num = counter.lock().expect("mutex poisoned");
*num += 1;
})
}).collect();
for handle in handles {
handle.join().expect("worker thread panicked");
}
메시지 전달을 위한 채널
use std::sync::mpsc;
let (tx, rx) = mpsc::sync_channel(16); // 역압력이 있는 경계 채널
for i in 0..5 {
let tx = tx.clone();
std::thread::spawn(move || {
tx.send(format!("message {i}")).expect("receiver disconnected");
});
}
drop(tx); // 송신자를 닫아 rx 반복자가 종료되도록 함
for msg in rx {
println!("{msg}");
}
Tokio를 사용한 Async
use tokio::time::Duration;
async fn fetch_with_timeout(url: &str) -> Result<String> {
let response = tokio::time::timeout(
Duration::from_secs(5),
reqwest::get(url),
)
.await
.context("request timed out")?
.context("request failed")?;
response.text().await.context("failed to read body")
}
// 동시 태스크 생성
async fn fetch_all(urls: Vec<String>) -> Vec<Result<String>> {
let handles: Vec<_> = urls.into_iter()
.map(|url| tokio::spawn(async move {
fetch_with_timeout(&url).await
}))
.collect();
let mut results = Vec::with_capacity(handles.len());
for handle in handles {
results.push(handle.await.unwrap_or_else(|e| panic!("spawned task panicked: {e}")));
}
results
}
Unsafe 코드
Unsafe가 허용되는 경우
// 허용: 문서화된 불변성을 가진 FFI 경계 (Rust 2024+)
/// # Safety
/// `ptr`는 초기화된 `Widget`에 대한 유효하고 정렬된 포인터여야 합니다.
unsafe fn widget_from_raw<'a>(ptr: *const Widget) -> &'a Widget {
// SAFETY: 호출자가 ptr이 유효하고 정렬되었음을 보장함
unsafe { &*ptr }
}
// 허용: 정확성이 입증된 성능 중요 경로
// SAFETY: 루프 경계로 인해 index가 항상 < len임
unsafe { slice.get_unchecked(index) }
Unsafe가 허용되지 않는 경우
// 나쁨: 빌림 검사기를 우회하기 위해 unsafe 사용
// 나쁨: 편의를 위해 unsafe 사용
// 나쁨: Safety 주석 없이 unsafe 사용
// 나쁨: 관련 없는 타입 간 transmute
모듈 시스템과 크레이트 구조
타입별이 아닌 도메인별로 구성
my_app/
├── src/
│ ├── main.rs
│ ├── lib.rs
│ ├── auth/ # 도메인 모듈
│ │ ├── mod.rs
│ │ ├── token.rs
│ │ └── middleware.rs
│ ├── orders/ # 도메인 모듈
│ │ ├── mod.rs
│ │ ├── model.rs
│ │ └── service.rs
│ └── db/ # 인프라
│ ├── mod.rs
│ └── pool.rs
├── tests/ # 통합 테스트
├── benches/ # 벤치마크
└── Cargo.toml
가시성 — 최소한으로 노출
// 좋음: 내부 공유를 위한 pub(crate)
pub(crate) fn validate_input(input: &str) -> bool {
!input.is_empty()
}
// 좋음: lib.rs에서 공개 API 재-export
pub mod auth;
pub use auth::AuthMiddleware;
// 나쁨: 모든 것을 pub으로 만들기
pub fn internal_helper() {} // pub(crate) 또는 private이어야 함
도구 통합
필수 명령어
# 빌드 및 검사
cargo build
cargo check # 코드 생성 없이 빠른 타입 검사
cargo clippy # 린트 및 제안
cargo fmt # 코드 포맷팅
# 테스팅
cargo test
cargo test -- --nocapture # println 출력 표시
cargo test --lib # 단위 테스트만
cargo test --test integration # 통합 테스트만
# 의존성
cargo audit # 보안 감사
cargo tree # 의존성 트리
cargo update # 의존성 업데이트
# 성능
cargo bench # 벤치마크 실행
빠른 참조: Rust 관용구
| 관용구 | 설명 |
|---|---|
| 복제하지 말고 빌리기 | 소유권이 필요하지 않으면 복제 대신 &T 전달 |
| 불가능한 상태는 표현 불가능하게 | 열거형을 사용하여 유효한 상태만 모델링 |
unwrap() 대신 ? | 에러 전파, 라이브러리/프로덕션 코드에서 패닉 금지 |
| 검증하지 말고 파싱 | 경계에서 구조화되지 않은 데이터를 타입화된 구조체로 변환 |
| 타입 안전성을 위한 Newtype | 인수 교체를 방지하기 위해 기본 타입을 newtype으로 래핑 |
| 루프보다 반복자 선호 | 선언적 체인이 더 명확하고 종종 더 빠름 |
Result에 #[must_use] | 호출자가 반환 값을 처리하도록 강제 |
유연한 소유권을 위한 Cow | 빌림으로 충분할 때 할당 방지 |
| 철저한 매칭 | 비즈니스 크리티컬 열거형에는 와일드카드 _ 금지 |
최소한의 pub 노출 | 내부 API에는 pub(crate) 사용 |
피해야 할 안티패턴
// 나쁨: 프로덕션 코드에서 .unwrap()
let value = map.get("key").unwrap();
// 나쁨: 이유를 이해하지 못하고 빌림 검사기를 만족시키기 위해 .clone()
let data = expensive_data.clone();
process(&original, &data);
// 나쁨: &str로 충분할 때 String 사용
fn greet(name: String) { /* 여기는 &str이어야 함 */ }
// 나쁨: 라이브러리에서 Box<dyn Error> 사용 (대신 thiserror 사용)
fn parse(input: &str) -> Result<Data, Box<dyn std::error::Error>> { todo!() }
// 나쁨: must_use 경고 무시
let _ = validate(input); // Result를 조용히 버림
// 나쁨: async 컨텍스트에서 블로킹
async fn bad_async() {
std::thread::sleep(Duration::from_secs(1)); // 실행기를 블로킹!
// 대신: tokio::time::sleep(Duration::from_secs(1)).await;
}
기억하세요: 컴파일된다면 아마 올바를 것입니다. 하지만 unwrap()을 피하고, unsafe를 최소화하며, 타입 시스템이 당신을 위해 작동하도록 해야 합니다.