Rust의 Enum과 패턴 매칭
1. 개요
많은 언어에서 enum은 단지 이름이 붙은 상수의 목록입니다. Rust에서는 enum의 각 variant가 자신의 데이터를 가질 수 있습니다. match와 결합될 때 컴파일러는 모든 경우를 처리하도록 강제합니다.
이 글은 enum을 정의하는 방법부터 match, guard, binding @, Option, Result, if let, let else, matches!까지 다룹니다. 읽고 나면 enum으로 비즈니스 상태를 모델링하고 흔한 오류들을 피할 수 있습니다.
이 글은 Rust 1.65 이상을 사용하고 (let else 필요) 외부 라이브러리가 필요하지 않습니다.
2. 데이터가 있는 enum 정의하기
주문의 상태를 나타내야 한다고 가정해봅시다:
#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
Pending,
Paid(u64),
Shipped { carrier: String, tracking: String },
Cancelled { reason: String },
}
fn main() {
let statuses = vec![
OrderStatus::Pending,
OrderStatus::Paid(250_000),
OrderStatus::Shipped {
carrier: "GHN".to_string(),
tracking: "GHN123".to_string(),
},
OrderStatus::Cancelled { reason: "재고 부족".to_string() },
];
for s in &statuses {
println!("{:?}", s);
}
}
이 enum은 세 종류의 variant를 가집니다:
Pending은 unit variant로 데이터를 갖지 않습니다.Paid(u64)는 tuple variant로 위치로 결정된 결제 금액을 갖습니다.Shipped { .. }와Cancelled { .. }는 struct variant로 이름이 있는 필드의 데이터를 갖습니다.
#[derive(Debug, Clone, PartialEq)]는 {:?}로 값을 출력하고, 복사하고, ==로 비교할 수 있게 합니다. 테스트를 작성할 때 비교가 필요합니다.
Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "재고 부족" }
핵심 포인트: Pending 상태인 주문은 절대로 “실수로” 송장번호를 가질 수 없습니다. 데이터는 정확히 필요한 variant에만 존재합니다. 많은 필드가 있는 struct에 Option을 사용하면 이런 보장을 얻을 수 없습니다.
3. 기본 match와 철저함(exhaustiveness)
match는 값을 위에서 아래로 각 패턴과 비교합니다. 처음 일치하는 가지가 실행됩니다:
fn describe(status: &OrderStatus) -> String {
match status {
OrderStatus::Pending => "결제 대기 중".to_string(),
OrderStatus::Paid(amount) => format!("결제됨 {amount} VND"),
OrderStatus::Shipped { carrier, tracking } => {
format!("{carrier}를 통해 배송 중, 송장번호 {tracking}")
}
OrderStatus::Cancelled { reason } => format!("취소됨: {reason}"),
}
}
몇 가지 중요한 점:
status의 타입은&OrderStatus입니다. 기본 바인딩 모드 덕분에 평상시처럼 패턴을 작성할 수 있습니다.amount,carrier,tracking변수들은 참조(&u64,&String)가 되어 어떤 데이터도 move되지 않습니다.match는 표현식입니다. 모든 가지는 같은 타입을 반환해야 하는데, 여기서는String입니다.
이제 Cancelled 가지를 삭제해봅시다. 컴파일러가 빌드를 거부합니다:
error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
--> src/main.rs:12:11
|
12 | match status {
| ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered
이것이 match의 가장 큰 가치입니다. 새로운 variant를 추가하면, 그것을 처리하지 않는 모든 match는 오류를 나타냅니다. 어디를 수정해야 하는지 수동으로 찾을 필요가 없습니다.
4. 고급 패턴: tuple, |, guard, range, @
패턴은 enum에만 사용되지 않습니다. tuple을 match하여 여러 조건을 동시에 검사할 수 있습니다:
enum CustomerTier {
Regular,
Silver,
Gold,
}
fn discount(tier: &CustomerTier, total: u64) -> u64 {
match (tier, total) {
(CustomerTier::Gold, _) => total / 10,
(CustomerTier::Silver, t @ 1_000_000..) => t / 20,
(CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
_ => 0,
}
}
fn main() {
println!("{}", discount(&CustomerTier::Gold, 300_000));
println!("{}", discount(&CustomerTier::Silver, 1_200_000));
println!("{}", discount(&CustomerTier::Regular, 2_500_000));
println!("{}", discount(&CustomerTier::Regular, 100_000));
}
각 가지는 다음과 같이 작동합니다:
_는 모든 값과 일치하지만 변수를 바인딩하지 않습니다.t @ 1_000_000..은total이 100만 이상의 범위에 있는지 확인하고, 그 값을t에 바인딩합니다.CustomerTier::Silver | CustomerTier::Regular은|를 사용하여 여러 패턴을 한 가지로 결합합니다.if t >= 2_000_000은 match guard입니다. 이는 패턴이 일치한 후에만 검사되는 추가 조건입니다._ => 0은 기본 가지입니다.u64는 무수히 많은 값을 가지므로 이 가지는 필수입니다.
30000
60000
50000
0
struct variant의 경우, 신경 쓰지 않는 필드를 건너뛰기 위해 ..를 사용합니다:
fn shipping_fee(status: &OrderStatus) -> u64 {
match status {
OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
OrderStatus::Shipped { .. } => 30_000,
_ => 0,
}
}
주의: 컴파일러는 철저함을 검사할 때 guard를 사용하지 않습니다.
Shipped { .. } if ...가지만 작성하고 guard 없는Shipped가지가 없다면, 여전히 모든 경우를 다루기 위해 다른 가지를 추가해야 합니다.
5. Option과 Result도 단지 enum일 뿐
표준 라이브러리는 Option<T>(Some(T)와 None으로 구성)와 Result<T, E>(Ok(T)와 Err(E)로 구성)를 정의합니다. enum이기 때문에 위의 도구들을 정확히 사용하여 처리합니다:
fn find_customer(id: u32) -> Option<&'static str> {
match id {
1 => Some("An"),
2 => Some("Bình"),
_ => None,
}
}
fn parse_quantity(input: &str) -> Result<u32, String> {
match input.trim().parse::<u32>() {
Ok(0) => Err("수량은 0보다 커야 합니다".to_string()),
Ok(n) => Ok(n),
Err(e) => Err(format!("유효하지 않음: {e}")),
}
}
fn main() {
println!("{:?}", find_customer(2));
println!("{:?}", find_customer(9));
println!("{:?}", parse_quantity(" 3 "));
println!("{:?}", parse_quantity("0"));
println!("{:?}", parse_quantity("abc"));
}
Ok(0)은 중첩된 패턴입니다. 결과가 Ok이고 내부 값이 0일 때 일치합니다. Ok(0)이 Ok(n) 앞에 있으므로, 0은 더 일반적인 가지에 도달하기 전에 가로채집니다.
Some("Bình")
None
Ok(3)
Err("수량은 0보다 커야 합니다")
Err("유효하지 않음: invalid digit found in string")
Rust는 null을 갖지 않습니다. “빠질 수 있는” 모든 값은 Option을 거쳐야 하며, 컴파일러는 None 경우를 처리하도록 강제합니다.
6. 간단한 문법: if let, let else, while let, matches!
variant 하나에만 관심이 있을 때 전체 match를 작성하는 것은 길어집니다. Rust는 이 경우를 위한 더 짧은 문법들을 제공합니다.
6.1. if let
fn tracking_code(status: &OrderStatus) -> Option<&str> {
if let OrderStatus::Shipped { tracking, .. } = status {
Some(tracking.as_str())
} else {
None
}
}
if let은 패턴이 일치할 때 블록을 실행합니다. else 부분은 선택사항입니다. 이 문법은 모든 경우를 처리하도록 강제하지 않으므로, 나머지 variant들이 정말로 중요하지 않을 때만 사용하세요.
6.2. let else
let else는 “early return” 패턴에 잘 맞습니다: 일치하면 데이터를 가져오고, 그렇지 않으면 즉시 종료합니다.
fn charge(status: &OrderStatus) -> Result<u64, String> {
let OrderStatus::Paid(amount) = status else {
return Err("주문이 결제되지 않았습니다".to_string());
};
Ok(*amount)
}
let 이후, amount 변수는 함수의 나머지에서 추가 블록을 중첩하지 않고도 사용할 수 있습니다. else 블록은 return, break, continue 또는 panic!으로 현재 흐름에서 벗어나야 합니다.
6.3. while let과 matches!
fn main() {
let mut queue = vec![
OrderStatus::Paid(100_000),
OrderStatus::Pending,
OrderStatus::Paid(400_000),
];
let paid = queue
.iter()
.filter(|s| matches!(s, OrderStatus::Paid(_)))
.count();
println!("결제된 주문 수: {paid}");
while let Some(status) = queue.pop() {
println!("처리 중: {status:?}");
}
}
matches!(값, 패턴)은bool을 반환합니다. 이 매크로는filter나assert!에서 매우 편리합니다.while let Some(status) = queue.pop()은pop()이None을 반환할 때까지 반복합니다.
결제된 주문 수: 2
처리 중: Paid(400000)
처리 중: Pending
처리 중: Paid(100000)
7. enum에 메서드 붙이기: 상태 전이 모델링
enum도 struct처럼 impl을 가집니다. 좋은 사용 방법은 유효한 상태 전이를 나타내는 것입니다:
impl OrderStatus {
fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
match self {
OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
carrier: carrier.to_string(),
tracking: tracking.to_string(),
}),
other => Err(format!("상태 {other:?}에서는 주문을 배송할 수 없습니다")),
}
}
fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
match self {
OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
reason: reason.to_string(),
}),
other => Err(format!("상태 {other:?}에서는 주문을 취소할 수 없습니다")),
}
}
fn is_final(&self) -> bool {
matches!(self, OrderStatus::Cancelled { .. })
}
}
몇 가지 중요한 점:
ship과cancel은 소유권으로self를 받으므로 이전 상태가 소비됩니다. 전이 후 이전 상태 변수를 계속 사용할 수 없습니다.other => ...가지는 나머지 모든 값을other변수에 바인딩하여 오류 메시지에 포함시킵니다.is_final은 읽기 전용이므로&self를 받습니다.
8. 동작에 대한 테스트 작성하기
Rust는 기본 테스트 프레임워크를 갖추고 있습니다. 테스트를 같은 파일의 #[cfg(test)] 모듈에 배치합니다:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paid_order_can_be_shipped() {
let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");
assert_eq!(
result,
Ok(OrderStatus::Shipped {
carrier: "GHN".to_string(),
tracking: "GHN123".to_string(),
})
);
}
#[test]
fn pending_order_cannot_be_shipped() {
let result = OrderStatus::Pending.ship("GHN", "X");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Pending"));
}
#[test]
fn cancelled_order_is_final() {
let status = OrderStatus::Pending.cancel("고객이 변심함").unwrap();
assert!(status.is_final());
assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "고객이 변심함"));
}
}
assert_eq!는 enum이 이미 PartialEq와 Debug를 derive했기 때문에 사용할 수 있습니다. 테스트가 실패하면 Rust는 기대한 값과 실제 값을 모두 출력합니다. 마지막 테스트에서 ref reason은 필드를 참조로 바인딩하므로, status는 matches! 내에서 move되지 않습니다.
cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok
test result: ok. 3 passed; 0 failed; 0 ignored
9. 흔한 오류 및 사용하지 말아야 할 때
_를 남용하여 철저함을 잃기. 자신이 관리하는 enum에 _ => ...를 작성하면, 나중에 새로운 variant를 추가해도 컴파일러는 침묵합니다. 새로운 variant는 기본 가지로 떨어지는데, 보통 이것은 원하는 바가 아닙니다. 비즈니스 enum의 경우 각 variant를 명시적으로 나열하세요.
참조에서 데이터를 move하기. match *status { OrderStatus::Cancelled { reason } => ... }를 작성하면 오류가 발생합니다:
error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference
세 가지 해결책이 있습니다: status 참조에서 직접 match하기, ref reason 사용하기, 또는 정말로 소유권이 필요하면 데이터를 clone하기.
일반적 가지를 구체적 가지 앞에 배치하기. Ok(n)이 Ok(0) 앞에 있으면, Ok(0) 가지는 절대 실행되지 않습니다. 컴파일러는 unreachable pattern 경고를 내놓지만 오류는 아니므로, 쉽게 놓칠 수 있습니다.
너무 큰 variant. enum의 크기는 가장 큰 variant의 크기에 태그를 더한 것입니다. variant가 큰 배열을 포함하면, enum의 모든 값이 그만큼의 메모리를 사용합니다. 그 variant를 Box<...>로 감싸세요. lint clippy::large_enum_variant가 이를 상기시켜줄 것입니다.
주의: 라이브러리를 작성 중이고 나중에 variant를 추가하되 사용자의 코드를 손상시키지 않으려면,
#[non_exhaustive]로 enum을 표시하세요. 그러면 다른 crate의 코드는 반드시_가지를 가져야 합니다.
enum을 사용하면 안 될 때? enum은 경우의 집합이 닫혀 있고 당신이 제어할 때 적합합니다. 예를 들어 주문 상태나 오류 타입입니다. 경우의 집합이 열려 있다면, 예를 들어 라이브러리 사용자가 새로운 결제 방법을 추가해야 한다면, trait를 사용하세요 (Box<dyn PaymentMethod> 또는 generic). variant를 추가하려면 원래 enum을 수정해야 하지만, trait는 외부에서 확장할 수 있습니다.
10. 결론
Rust의 enum은 각 경우가 정확히 자신의 데이터를 가지도록 합니다. match는 컴파일 시 모든 경우를 처리하도록 강제합니다. 당신은 다음을 살펴봤습니다:
- Unit, tuple, struct variant.
- binding,
|, guard, range,@,..이 있는match. - 일반 enum으로서의
Option과Result. - 간단한 문법
if let,let else,while let,matches!. - enum에 메서드를 사용하여 상태 전이를 모델링하는 방법 (테스트 포함).
가장 기억하기 쉬운 규칙: 자신이 소유한 enum의 경우, _ 대신 모든 variant를 나열하세요. 그러면 새로운 variant를 추가할 때마다 컴파일러가 수정해야 할 모든 곳을 알려줄 것입니다.