Rust 디자인 패턴
디자인 패턴(design pattern)은 자꾸 반복해서 나타나는 문제를 푸는, 검증된 방법이에요. 패턴은 설치하는 라이브러리가 아니에요. 오히려 레시피에 가까워요. “이런 문제가 있을 때는 코드를 이런 모양으로 짜세요” 하는 식이죠.
Java나 C#의 패턴 중 상당수는 Rust에서 모양이 다르거나, 아예 필요 없기도 해요. Rust에는 강력한 타입 시스템, 열거형, 트레이트, 소유권을 바탕으로 한 Rust만의 단골 패턴이 있어요. 대부분의 패턴에 깔린 큰 생각은 같아요. 실수는 컴파일러가 잡게 하자는 거예요. 그러면 그 실수가 사용자에게까지 가지 않아요. 아래 각 절에서는 먼저 문제를 보여 주고, 그 문제를 해결하는 패턴을 소개할게요.
뉴타입: 의미가 다르면 타입도 다르게
섹션 제목: “뉴타입: 의미가 다르면 타입도 다르게”문제. 그냥 f64는 무엇이든 될 수 있어요. 미터, 피트, 초, 유로… 어떤 함수가 미터 단위의 f64를 받는데 피트 값을 넘겨도, 컴파일러는 도와줄 수 없어요. 둘 다 그냥 f64니까요. (1999년 NASA가 화성 기후 궤도선을 잃어버린 것도 바로 이런 단위 혼동 때문이었다는 이야기는 유명해요.)
패턴. 값을 필드 하나짜리 작은 구조체로 감싸요. 이제 Meters와 Feet는 서로 다른 타입이고, Rust는 둘을 섞어 쓰지 못하게 막아 줘요.
#[derive(Debug, Clone, Copy)]struct Meters(f64);
#[derive(Debug, Clone, Copy)]struct Feet(f64);
impl Feet { fn to_meters(self) -> Meters { Meters(self.0 * 0.3048) }}
fn safe_altitude(height: Meters) -> bool { height.0 >= 300.0}
fn main() { let tower = Meters(330.0); let plane = Feet(900.0);
println!("타워는 안전한 높이인가요? {}", safe_altitude(tower)); println!("900피트는 {:.1}미터예요", plane.to_meters().0); println!("비행기는 안전한 높이인가요? {}", safe_altitude(plane.to_meters()));}실행 결과:
타워는 안전한 높이인가요? true900피트는 274.3미터예요비행기는 안전한 높이인가요? false변환을 깜빡하고 safe_altitude(plane)이라고 쓰면, 프로그램은 컴파일조차 되지 않아요.
error[E0308]: mismatched types --> src/main.rs:13:34 |13 | println!("{}", safe_altitude(plane)); | ------------- ^^^^^ expected `Meters`, found `Feet` | | | arguments to this function are incorrect“타입이 맞지 않는다(mismatched types)“는 뜻이에요. Meters를 기대했는데 Feet가 들어왔다고 정확히 알려 주죠.
뉴타입은 실행 시간 비용이 전혀 없어요. 메모리 안에서 Meters는 그냥 f64와 똑같아요. ID(UserId(u32)와 OrderId(u32)), 단위, 검증을 마친 문자열 같은 곳에 써 보세요. 고아 규칙(orphan rule)을 피해 가는 데도 쓰여요(고급 트레이트 참고).
빌더: 옵션이 많아도 결과는 깔끔하게
섹션 제목: “빌더: 옵션이 많아도 결과는 깔끔하게”문제. 어떤 구조체는 필드가 아주 많고, 그중 대부분은 선택 사항이에요. Email::new("[email protected]", "Hi", "", vec![], false) 같은 생성자는 읽기 힘들어요. 이 false는 대체 무슨 뜻일까요? 게다가 주소가 빠졌거나 잘못됐다면요?
패턴. 별도의 빌더(builder) 구조체가 이름 붙은 작은 메서드들로 설정을 모아요. 각 메서드는 빌더를 다시 돌려주기 때문에 호출을 체이닝(chaining)할 수 있어요. 마지막 build()가 모든 것을 확인하고 Result를 돌려줘요.
struct Email { to: String, subject: String, body: String, urgent: bool,}
#[derive(Default)]struct EmailBuilder { to: Option<String>, subject: Option<String>, body: String, urgent: bool,}
impl EmailBuilder { fn new() -> Self { EmailBuilder::default() }
fn to(mut self, address: &str) -> Self { self.to = Some(address.to_string()); self }
fn subject(mut self, subject: &str) -> Self { self.subject = Some(subject.to_string()); self }
fn body(mut self, text: &str) -> Self { self.body = text.to_string(); self }
fn urgent(mut self) -> Self { self.urgent = true; self }
fn build(self) -> Result<Email, String> { let to = self.to.ok_or("받는 사람이 없어요")?; if !to.contains('@') { return Err(format!("'{}'은(는) 이메일 주소가 아니에요", to)); } let subject = self.subject.unwrap_or_else(|| String::from("(제목 없음)")); Ok(Email { to, subject, body: self.body, urgent: self.urgent }) }}
fn show(result: Result<Email, String>) { match result { Ok(e) => println!("준비 완료: to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent), Err(msg) => println!("에러: {}", msg), }}
fn main() { let email = EmailBuilder::new() .subject("점심 같이 먹을래요?") .body("12시에 피자 어때요?") .urgent() .build(); show(email);
show(EmailBuilder::new().subject("안녕하세요").build()); show(EmailBuilder::new().to("ana.example.com").build());}실행 결과:
준비 완료: [email protected] subject=점심 같이 먹을래요? body="12시에 피자 어때요?" urgent=true준비 완료: [email protected] subject=(제목 없음) body="" urgent=false에러: 받는 사람이 없어요에러: 'ana.example.com'은(는) 이메일 주소가 아니에요각 설정 메서드는 mut self를 받아서(빌더의 소유권을 가져와서) 필드 하나를 바꾸고, 빌더를 다시 돌려줘요. 그래서 체이닝이 가능한 거예요. 호출하는 쪽은 신경 쓰는 값만 설정하면 되고, 메서드 이름이 각 값의 의미를 설명해 주고, 규칙을 확인하는 곳은 build() 한 군데뿐이에요. build() 안에서는 ok_or가 받는 사람이 없는 None을 Err로 바꾸고, ?가 그 에러를 바로 반환해요. 빌더는 여러 크레이트에서 볼 수 있어요. 예를 들어 std::thread::Builder와 std::process::Command가 그래요(환경 변수와 프로세스 참고).
Default와 구조체 갱신 문법: 가벼운 대안
섹션 제목: “Default와 구조체 갱신 문법: 가벼운 대안”문제. 빌더는 코드가 꽤 많이 필요해요. 구조체에 확인할 규칙이 없고 적당한 기본값만 있으면 되는 경우라면, 빌더는 너무 거창해요.
패턴. 좋은 기본값으로 Default를 구현하고, 구조체 갱신 문법(struct update syntax) ..을 써서 원하는 필드만 바꿔요.
#[derive(Debug, Clone)]struct ServerConfig { host: String, port: u16, workers: u32, verbose: bool,}
impl Default for ServerConfig { fn default() -> Self { ServerConfig { host: String::from("127.0.0.1"), port: 8080, workers: 4, verbose: false, } }}
fn main() { let dev = ServerConfig { verbose: true, ..Default::default() }; let prod = ServerConfig { host: String::from("0.0.0.0"), workers: 16, ..ServerConfig::default() }; // 기본값 대신 다른 설정에서 시작할 수도 있어요. let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev); println!("{:?}", prod); println!("{:?}", staging);}실행 결과:
ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }..something은 “내가 적지 않은 필드는 전부 something에서 가져와”라는 뜻이에요. 왜 prod.clone()일까요? 구조체 갱신 문법은 나머지 필드를 이동(move)시키는데, host는 String이에요. clone하지 않으면 prod의 일부가 이동해 버려서, 그 뒤에 prod를 출력할 수 없게 돼요. 좋은 기준이 하나 있어요. 어떤 값 조합이든 괜찮다면 Default + ..을, 잘못된 조합이 있을 수 있다면 빌더를 쓰세요.
타입 상태: 컴파일할 때 검사하는 상태 기계
섹션 제목: “타입 상태: 컴파일할 때 검사하는 상태 기계”문제. 어떤 일은 반드시 순서대로 일어나야 해요. 웹 요청은 보내기 전에 URL이 있어야 하죠. 평범한 구조체라면 실행 중에 확인할 거예요(“URL이 설정됐나? 아니면 에러를 반환하자”). 그리고 모든 호출자가 그 에러를 처리해 주기를 바라야 하죠.
패턴. 상태를 타입의 일부로 만들어요. 이것을 타입 상태(typestate) 패턴이라고 해요. Request<NoUrl>에는 아예 send 메서드가 없어요. URL을 설정해야만 Request<HasUrl>로 바뀌고, 그 타입에는 send가 있어요.
// 두 가지 상태. 데이터가 없거나, 그 상태에 필요한 데이터만 가져요.struct NoUrl;struct HasUrl(String);
struct Request<U> { url: U, headers: Vec<(String, String)>,}
impl Request<NoUrl> { fn new() -> Self { Request { url: NoUrl, headers: Vec::new() } }
// 타입이 바뀌어요: Request<NoUrl> -> Request<HasUrl> fn url(self, url: &str) -> Request<HasUrl> { Request { url: HasUrl(url.to_string()), headers: self.headers } }}
// 모든 상태에서 쓸 수 있어요impl<U> Request<U> { fn header(mut self, key: &str, value: &str) -> Self { self.headers.push((key.to_string(), value.to_string())); self }}
// URL이 설정된 뒤에만 쓸 수 있어요impl Request<HasUrl> { fn send(self) -> String { format!("GET {} (헤더 {}개)", self.url.0, self.headers.len()) }}
fn main() { let response = Request::new() .header("Accept", "text/html") .url("https://example.com") .header("User-Agent", "rust-tutorial") .send();
println!("{}", response);}실행 결과:
GET https://example.com (헤더 2개)이제 URL 없이 보내 보세요. Request::new().send()라고 쓰면 컴파일러가 막아요.
error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope --> src/main.rs:22:35 |22 | let response = Request::new().send(); | ^^^^ method not found in `Request<NoUrl>` | = note: the method was found for `Request<HasUrl>`Request<NoUrl>에는 send라는 메서드가 없고, 그 메서드는 Request<HasUrl>에 있다고 알려 주는 메시지예요.
컴파일되는 프로그램에는 이 버그가 존재할 수 없어요. 실행 중 검사도 없고 추가 비용도 없어요. NoUrl은 0바이트를 차지하거든요. 또 url()은 self를 값으로 받기 때문에, 예전의 Request<NoUrl>은 거기서 소비돼요. 그래서 누군가 실수로 “이전” 상태를 계속 쓰는 일도 생기지 않아요. 제네릭을 영리하게 활용한 패턴이니, Playground에서 조금씩 바꿔 보면서 어떻게 동작하는지 감을 잡아 보세요.
RAII와 Drop 가드: 잊어버릴 수 없는 정리
섹션 제목: “RAII와 Drop 가드: 잊어버릴 수 없는 정리”문제. 리소스는 정리가 필요해요. 파일을 닫고, 잠금을 풀고, 임시 폴더를 지우고, “완료”를 출력하고… 정리가 직접 기억해서 호출해야 하는 함수라면 누군가는 꼭 잊어버려요. 특히 중간에 return하거나 에러가 났을 때요.
패턴. 정리 코드를 Drop에 넣어요. 값이 스코프를 벗어나면 Rust가 drop을 자동으로 호출해요. 정상적으로 끝날 때, 중간에 return할 때, ?로 빠져나갈 때, 심지어 패닉이 났을 때도요. 모든 경로에서요. 이것을 RAII(“Resource Acquisition Is Initialization”, 리소스를 얻는 것과 값을 소유하는 것이 같은 단계라는 뜻)라고 해요. 정리만 담당하는 값은 가드(guard)라고 불러요.
struct Guard { name: String,}
impl Guard { fn new(name: &str) -> Guard { println!("{} 시작", name); Guard { name: name.to_string() } }}
impl Drop for Guard { fn drop(&mut self) { println!("{} 정리", self.name); }}
fn risky(fail: bool) -> Result<(), String> { let _guard = Guard::new("위험한 작업"); if fail { return Err(String::from("문제가 생겼어요")); // 이때도 가드가 정리해요 } println!("작업 완료"); Ok(())}
fn main() { { let _block = Guard::new("블록"); println!("블록 안"); } // 여기서 _block이 drop돼요
println!("{:?}", risky(false)); println!("{:?}", risky(true));}실행 결과:
블록 시작블록 안블록 정리위험한 작업 시작작업 완료위험한 작업 정리Ok(())위험한 작업 시작위험한 작업 정리Err("문제가 생겼어요")사실 여러분은 이 패턴을 이미 늘 쓰고 있어요. File은 drop될 때 스스로 닫혀요. MutexGuard(mutex.lock()이 돌려주는 값, 동시성 참고)는 drop될 때 뮤텍스 잠금을 풀어요. Box는 drop될 때 메모리를 해제해요(스마트 포인터). 그래서 Rust 코드에는 “close”나 “free” 호출이 거의 없어요.
From과 Into: 쓰기 편한 함수 인자
섹션 제목: “From과 Into: 쓰기 편한 함수 인자”문제. 함수가 String을 받으면, &str를 가진 호출자는 매번 String::from("Ana")나 "Ana".to_string()을 써야 해요. 또는 함수가 UserId를 받는데 호출자에게는 그냥 u32만 있을 수도 있죠. 사소하지만 코드 곳곳에 잡음이 생겨요.
패턴. 여러분의 타입에 From을 구현하고, 함수에서는 impl Into<T>를 받아요. 그러면 호출자는 변환 가능한 값이라면 무엇이든 넘길 수 있어요.
#[derive(Debug)]struct UserId(u32);
impl From<u32> for UserId { fn from(n: u32) -> Self { UserId(n) }}
#[derive(Debug)]struct User { id: UserId, name: String,}
impl User { fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self { User { id: id.into(), name: name.into() } }}
fn main() { let ana = User::new(1, "Ana"); // u32와 &str let ben = User::new(UserId(2), String::from("Ben")); // 이미 맞는 타입
println!("{:?}", ana); println!("{:?}", ben);}실행 결과:
User { id: UserId(1), name: "Ana" }User { id: UserId(2), name: "Ben" }Into를 직접 구현하지 말고 항상 From을 구현하세요. 그러면 Into는 공짜로 따라와요. 에러 처리에도 같은 원리가 쓰여요. ?는 에러에 From::from을 호출하기 때문에, From<io::Error> for MyError를 구현해 두면 ?가 에러를 알아서 변환해 줘요(사용자 정의 에러 참고). 이 방법은 실패할 수 없는 변환에 쓰세요. 실패할 수 있는 변환이라면 Result를 돌려주는 TryFrom을 쓰면 돼요.
열거형 상태 기계와 잘못된 상태
섹션 제목: “열거형 상태 기계와 잘못된 상태”문제. 주문은 “장바구니”에서 “결제 완료”를 거쳐 “배송”으로 넘어가요. 처음에는 보통 이렇게 짜기 쉬워요.
struct Order { paid: bool, shipped: bool, tracking_number: Option<String>,}하지만 이렇게 하면 말이 안 되는 상태도 만들 수 있어요. paid: false인데 shipped: true라든지, 아직 배송되지 않은 주문에 송장 번호가 있다든지요. 그러면 모든 함수가 이런 불가능한 조합을 일일이 확인해야 해요.
패턴. 잘못된 상태는 아예 표현할 수 없게 만들어요. 상태마다 배리언트(variant) 하나씩을 가진 열거형을 쓰고, 각 배리언트에는 그 상태에 실제로 있는 데이터만 넣어요. 상태 전환은 이전 상태를 받아서 새 상태를 돌려주는 메서드로 만들어요.
#[derive(Debug)]enum Order { Cart { items: Vec<String> }, Paid { items: Vec<String>, amount: u32 }, Shipped { tracking: String }, Cancelled,}
impl Order { fn pay(self, amount: u32) -> Order { match self { Order::Cart { items } => Order::Paid { items, amount }, other => other, // 결제는 장바구니만 할 수 있어요. 다른 상태는 그대로 둬요 } }
fn ship(self, tracking: &str) -> Order { match self { Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() }, other => other, } }
fn cancel(self) -> Order { match self { Order::Shipped { .. } => self, // 취소하기엔 너무 늦었어요 _ => Order::Cancelled, } }
fn status(&self) -> String { match self { Order::Cart { items } => format!("장바구니: 상품 {}개", items.len()), Order::Paid { items, amount } => format!("결제 완료: 금액 {}, 상품 {}개", amount, items.len()), Order::Shipped { tracking } => format!("배송 중: 송장 번호 {}", tracking), Order::Cancelled => String::from("취소됨"), } }}
fn main() { let order = Order::Cart { items: vec![String::from("책"), String::from("펜")] }; println!("{}", order.status());
let order = order.ship("XYZ"); // 아직 결제 전이라 아무 일도 없어요 println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42"); println!("{}", order.status());
let order = order.cancel(); // 이미 배송됐어요 println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel(); println!("{}", other.status());}실행 결과:
장바구니: 상품 2개장바구니: 상품 2개배송 중: 송장 번호 TRACK-42배송 중: 송장 번호 TRACK-42취소됨배송된 주문에는 송장 번호가 빠질 수가 없어요. Shipped 배리언트에는 항상 송장 번호가 있으니까요. 장바구니에는 송장 번호가 있을 수가 없어요. 그런 필드가 없으니까요. 게다가 match는 모든 배리언트를 다뤄야 하므로, 새 상태(예를 들어 Refunded)를 추가하면 고쳐야 할 곳을 컴파일러가 하나하나 짚어 줘요.
열거형일까, 트레이트 객체일까? 객체 지향 언어에서는 흔히 “State” 인터페이스를 두고 상태마다 클래스를 하나씩 만들어요. Rust에서도 트레이트 객체(Box<dyn State>)로 그렇게 할 수 있고, Rust 책에서 그 방법을 보여 줘요. 이렇게 골라 보세요.
- 열거형: 모든 상태를 미리 알고 있을 때.
match의 빠짐없는 검사를 받을 수 있고, 로직이 한곳에 모여요. Rust에서는 보통 이쪽을 골라요. - 트레이트 객체: 다른 사람(또는 다른 크레이트)이 여러분의 코드를 고치지 않고 새 상태를 추가해야 할 때.
- 타입 상태(앞에서 본 것): 잘못된 호출 순서를 실행 중에 무시하는 게 아니라 컴파일 에러로 만들고 싶을 때. 더 엄격하지만, 상태를 컴파일 시점에 알 수 있어야 해요.
자주 하는 실수
섹션 제목: “자주 하는 실수”let _guard = guard 대신 let _ = guard라고 쓰기. 거의 똑같아 보이지만 동작이 달라요. 밑줄로 시작하는 이름(_guard 같은)은 스코프가 끝날 때까지 값을 살려 둬요. 그냥 _는 변수가 아니어서, 값이 즉시 drop돼요.
struct Guard;
impl Drop for Guard { fn drop(&mut self) { println!("가드가 drop됐어요"); }}
fn main() { let _ = Guard; println!("`let _`로 작업하는 중");
let _g = Guard; println!("`let _g`로 작업하는 중");}실행 결과:
가드가 drop됐어요`let _`로 작업하는 중`let _g`로 작업하는 중가드가 drop됐어요let _를 쓰면 정리가 작업보다 먼저 실행됐어요. 잠금이나 타이머라면 정확히 거꾸로 된 셈이죠. 가드에는 항상 변수 이름을 붙이세요.
패턴이 있다는 이유만으로 패턴 쓰기. 필드가 두 개뿐인 구조체에 빌더를 만들거나, 잘못 쓸 일이 없는 곳에 타입 상태를 쓰면 코드만 늘고 더 안전해지지는 않아요. 단순하게 시작하세요. 패턴이 해결하는 문제를 실제로 느낄 때 꺼내 쓰면 돼요.
- 뉴타입(
struct Meters(f64))은 같은 데이터에 서로 다른 타입을 줘서, 혼동을 컴파일러가 잡게 해요. 실행 시간 비용은 없어요. - 빌더는 읽기 쉬운 체이닝 설정과, 검증 후
Result를 돌려주는build()한 곳을 제공해요. 확인할 게 없다면 더 가벼운 Default +..조합을 쓰세요. - 타입 상태는 상태를 타입에 담아서(
Request<NoUrl>과Request<HasUrl>), 잘못된 순서로 호출하면 컴파일되지 않게 해요. - RAII / Drop 가드는 스코프를 벗어나는 모든 경로에서 정리를 자동으로 실행해요.
- From/Into를 쓰면 함수가
impl Into<T>로 여러 입력 타입을 받을 수 있어요. - 열거형으로 상태 기계를 만들면 잘못된 상태를 표현할 수 없게 돼요. 트레이트 객체는 다른 사람이 상태를 추가해야 할 때만 쓰세요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. 뉴타입 struct Meters(f64)는 그냥 f64와 비교해서 무엇을 해 주나요?
2. 이 페이지의 빌더 패턴에서 build()는 무엇을 반환하나요?
3. 타입 상태 예제에서 Request::new()에 send()를 호출할 수 없는 이유는 무엇일까요?
4. Drop 가드의 정리 코드는 언제 실행되나요?
5. let _ = Guard;는 어떻게 동작하나요?
점수: 0 / 5
더 알아보기
섹션 제목: “더 알아보기”- Rust Design Patterns (영어): Rust의 관용구, 패턴, 안티패턴을 모은 무료 커뮤니티 책이에요. 뉴타입, 빌더, RAII 가드도 다뤄요.
- 객체 지향 디자인 패턴 구현하기 - The Rust Programming Language: 상태 패턴을 트레이트 객체로 구현한 다음, 상태를 타입으로 표현하는 방식으로 다시 만들어 봐요.
- Rust API Guidelines (영어): 쓰기 편하고 Rust다운 API를 설계하기 위한 공식 조언이에요(변환, 빌더, 이름 짓기).
- RAII - Rust by Example (영어): 소유권과
Drop이 어떻게 자동 정리를 해 주는지 설명해요. - The Typestate Pattern in Rust - Cliff L. Biffle (영어): 타입 상태 패턴을 더 깊이, 더 많은 예제로 다룬 읽기 쉬운 글이에요.