Rust 구조체와 열거형 | Struct, Enum, Pattern Matching

이 글의 핵심

Rust 구조체와 열거형: Struct, Enum, Pattern Matching. 구조체 (Struct)·메서드 (impl).

시리즈 안내

#03 | 📋 전체 목차 | 이전: #02 소유권 · 다음: #04 에러 처리


들어가며

구조체·열거형(여러 경우 중 하나를 값으로 나타내는 타입)은 필드를 이름 붙여 묶는 Rust의 기본 재료입니다. 소유권 규칙(값의 열쇠가 누구에게 있는지) 위에서 타입을 설계한다는 점만 기억하시면 됩니다. C++에서는 필드에 Box·Rc에 해당하는 개념을 스마트 포인터와 RAII로 표현하며, 필드 간 이동은 이동 의미론과 연결됩니다. 소유권 기초는 이전 글을, 메모리 결함·도구는 메모리 누수 가이드, Valgrind, 누수 탐지 실전을 참고하면 비교하기 좋습니다.


왜 구조체와 열거형이 중요한가

Rust에는 클래스 상속이 없습니다. 대신 “그리고(AND)“는 구조체로, “또는(OR)“은 열거형으로 표현합니다. 사용자는 이름 그리고 나이 그리고 이메일을 가지므로 구조체이고, 메시지는 종료 또는 이동 또는 텍스트이므로 열거형입니다. 이 두 도구를 조합하면 “불가능한 상태를 타입으로 표현할 수 없게” 만들 수 있다는 것이 Rust 타입 설계의 핵심입니다. 예를 들어 연결 상태를 connected: bool과 socket: Option<Socket> 두 필드로 두면 “연결됨인데 소켓이 없음”이라는 모순된 상태가 가능하지만, enum Conn { Disconnected, Connected(Socket) }로 두면 그런 상태를 아예 만들 수 없습니다. 이 글은 구조체와 impl 메서드, 데이터를 담는 열거형, Option/Result, 그리고 이들을 분해하는 match까지 순서대로 다룹니다.

일반·튜플·유닛 구조체

기본 구조체

struct User {
    name: String,
    age: u32,
    email: String,
}
fn main() {
    let user = User {
        name: String::from("홍길동"),
        age: 25,
        email: String::from("[email protected]"),
    };
    
    println!("이름: {}", user.name);
    println!("나이: {}", user.age);
}

구조체를 만들 때는 모든 필드를 빠짐없이 지정해야 합니다. C++처럼 일부 필드를 생략하면 쓰레기 값이나 기본값이 들어가는 일이 없고, missing field 'email' in initializer of 'User' 컴파일 에러가 납니다. 기본값이 필요하면 #[derive(Default)]를 붙이고 User { name: ..., ..Default::default() }처럼 명시적으로 요청해야 합니다.

필드에 &str가 아니라 String을 쓴 것도 의도적인 선택입니다. String은 구조체가 문자열 데이터를 소유한다는 뜻이라, user가 살아 있는 동안 이름이 유효하다는 것이 보장됩니다. name: &str로 쓰면 missing lifetime specifier 에러가 나고, struct User<'a> { name: &'a str }처럼 라이프타임을 붙여야 합니다. 그러면 구조체가 원본 문자열보다 오래 살 수 없다는 제약이 생기므로, 처음 설계할 때는 소유 타입(String, Vec<T>)을 필드로 쓰는 것이 훨씬 편합니다. 가변성은 필드 단위가 아니라 변수 단위라는 점도 C++과 다릅니다. let user로 선언하면 모든 필드가 불변이고, 하나라도 바꾸려면 let mut user로 선언해야 합니다.

구조체 업데이트

let user1 = User {
    name: String::from("홍길동"),
    age: 25,
    email: String::from("[email protected]"),
};
// 일부 필드만 변경
let user2 = User {
    name: String::from("김철수"),
    ..user1  // 나머지 필드는 user1에서 가져옴 (String 필드는 이동, u32는 복사)
};

..user1은 “나머지 필드를 user1에서 가져온다”는 뜻인데, 주석의 “복사”는 정확하지 않습니다. age: u32처럼 Copy 트레이트를 가진 필드는 복사되지만, email: String은 이동합니다. 그래서 이 코드 뒤에는 user1.email을 쓸 수 없고 user1을 통째로 쓰는 것도 불가능하며(borrow of partially moved value: user1), 이동되지 않은 user1.name과 user1.age만 여전히 쓸 수 있습니다. 원본을 계속 써야 한다면 User에 #[derive(Clone)]을 붙이고 ..user1.clone()으로 명시적으로 복제해야 합니다. 이 규칙이 번거로워 보이지만, 복제 비용이 코드에 드러난다는 장점이 있습니다.

튜플 구조체

struct Color(i32, i32, i32);
struct Point(i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0);
println!("R: {}, G: {}, B: {}", black.0, black.1, black.2);

튜플 구조체는 필드 이름이 없는 대신 타입에 이름이 붙습니다. Color와 Point는 둘 다 i32 묶음이지만 서로 다른 타입이라, Point를 받는 함수에 Color를 넘기면 컴파일 에러가 납니다. 이 성질을 이용한 것이 뉴타입(newtype) 패턴입니다. struct UserId(u64);와 struct OrderId(u64);를 따로 두면, 함수 인자 순서를 바꿔 넘기는 실수(cancel(order_id, user_id))를 컴파일러가 잡아 줍니다. 런타임 비용은 전혀 없습니다. 반면 필드가 세 개 이상이고 의미가 서로 다르다면, .0, .1로는 읽는 사람이 뜻을 알기 어려우므로 일반 구조체가 낫습니다.

유닛 구조체

struct AlwaysEqual;
let subject = AlwaysEqual;

필드가 없는 유닛 구조체는 크기가 0바이트입니다. 데이터는 없지만 트레이트를 구현할 대상이 필요할 때 씁니다. 예를 들어 여러 정렬 전략을 struct ByName;, struct ByAge;로 만들고 각각 비교 트레이트를 구현하거나, 상태 기계의 상태를 타입으로 표현(struct Locked; struct Unlocked;)하는 데 쓰입니다. 트레이트는 #05 트레이트에서 자세히 다룹니다.


impl 블록으로 메서드 붙이기

기본 메서드

Rust의 메서드는 impl 블록 안에 정의합니다:

impl User {
    // 1. 연관 함수 (Associated Function) - 생성자 패턴
    fn new(name: String, age: u32, email: String) -> Self {
        // self 매개변수 없음 (인스턴스 불필요)
        // Self: User 타입의 별칭 (타입 이름 반복 방지)
        // User::new()로 호출 (::는 연관 함수 호출)
        User { name, age, email }
        // 필드 초기화 단축 문법 (name: name → name)
    }
    
    // 2. 불변 참조 메서드 (&self)
    fn introduce(&self) {
        // &self: 인스턴스를 불변 참조로 빌림
        // 읽기만 가능, 수정 불가
        // 소유권을 가져가지 않음 (호출 후에도 사용 가능)
        println!("{}입니다. {}세입니다.", self.name, self.age);
    }
    
    // 3. 가변 참조 메서드 (&mut self)
    fn birthday(&mut self) {
        // &mut self: 인스턴스를 가변 참조로 빌림
        // 수정 가능
        // 소유권을 가져가지 않음
        self.age += 1;  // age 필드 수정
    }
    
    // 4. 소유권 이동 메서드 (self)
    fn into_name(self) -> String {
        // self: 인스턴스의 소유권을 가져감
        // 호출 후 원본 인스턴스는 사용 불가
        // 필드를 이동시켜 반환할 때 사용
        self.name
        // name의 소유권이 호출자에게 이동
        // user는 더 이상 유효하지 않음
    }
}
fn main() {
    // 연관 함수로 인스턴스 생성
    let mut user = User::new(
        String::from("홍길동"),
        25,
        String::from("[email protected]")
    );
    // mut: 가변 변수 (birthday 메서드 호출 위해 필요)
    
    // 불변 참조 메서드 호출
    user.introduce();  // 홍길동입니다. 25세입니다.
    // user는 여전히 유효 (빌렸다가 반환)
    
    // 가변 참조 메서드 호출
    user.birthday();  // age가 26으로 증가
    println!("생일 후 나이: {}", user.age);  // 26
    
    // 소유권 이동 메서드 호출
    let name = user.into_name();  // user의 소유권 이동
    println!("이름: {}", name);   // 홍길동
    
    // println!("{}", user.age);  // ❌ 컴파일 에러!
    // 에러: borrow of moved value: `user`
    // user는 into_name()에서 소유권을 잃어 더 이상 사용 불가
}

메서드 호출 방식 비교:

// 연관 함수: Type::function()
let user = User::new(...);
// 메서드: instance.method()
user.introduce();
user.birthday();
// Rust는 자동으로 참조/역참조
// user.introduce() → (&user).introduce()
// 명시적으로 &를 쓸 필요 없음

self를 받는 세 가지 방식은 “이 메서드가 인스턴스에 무엇을 하는가”를 시그니처에 드러냅니다. 기본은 &self이고, 수정이 필요할 때만 &mut self, 인스턴스를 소비하거나 다른 것으로 변환할 때만 self를 씁니다. 표준 라이브러리의 명명 규칙도 이것을 따릅니다. as_로 시작하는 메서드는 참조를 반환하는 가벼운 변환, to_는 복사본을 만드는 변환, into_는 소유권을 가져가는 변환입니다(into_name처럼).

처음 Rust를 쓸 때 가장 자주 만나는 에러가 여기서 나옵니다. let user = User::new(...); user.birthday();처럼 mut 없이 선언한 변수에 &mut self 메서드를 부르면 cannot borrow 'user' as mutable, as it is not declared as mutable이 납니다. 반대로 &self 메서드 안에서 self.age += 1을 하면 cannot assign to 'self.age', which is behind a '&' reference가 납니다. 두 에러 모두 “수정하려면 수정 권한을 명시하라”는 같은 메시지이며, 호출하는 쪽과 메서드 시그니처 중 어느 쪽에 mut이 빠졌는지 확인하면 됩니다. 한 가지 더 알아 둘 것은 into_name(self)가 user.name을 반환할 때 나머지 필드(email)는 그 자리에서 해제된다는 점입니다.

C++ 개발자에게 낯선 부분은 생성자가 언어 기능이 아니라는 점입니다. User::new는 관례적인 이름의 평범한 연관 함수일 뿐이고, new라는 키워드도 없습니다. 그래서 User::with_email(...), User::from_row(...)처럼 목적별 생성 함수를 자유롭게 만들 수 있고, 실패할 수 있는 생성은 fn new(...) -> Result<Self, Error>로 표현합니다. C++의 생성자가 예외 말고는 실패를 알릴 방법이 없는 것과 대조적입니다.

여러 impl 블록

impl User {
    fn new(name: String, age: u32, email: String) -> Self {
        User { name, age, email }
    }
}
impl User {
    fn introduce(&self) {
        println!("{}", self.name);
    }
}

impl 블록을 여러 개로 나누는 것은 문법적으로 하나로 합친 것과 완전히 같습니다. 실무에서 나누는 이유는 주로 트레이트 구현을 분리하거나(impl Display for User), 제네릭 구조체에서 특정 타입에만 메서드를 추가할 때(impl Point<f64> { fn distance(&self) ... }), 또는 #[cfg(test)]로 테스트 전용 헬퍼를 분리할 때입니다. 다만 같은 이름의 메서드를 두 블록에 정의하면 duplicate definitions with name 'introduce' 에러가 납니다. C++처럼 인자 타입만 다른 오버로딩은 Rust에서 지원하지 않기 때문입니다.


데이터를 담는 열거형

기본 열거형

enum Status {
    Active,
    Inactive,
    Pending,
}
fn main() {
    let status = Status::Active;
    
    match status {
        Status::Active => println!("활성"),
        Status::Inactive => println!("비활성"),
        Status::Pending => println!("대기"),
    }
}

match의 가장 큰 가치는 완전성 검사(exhaustiveness check)입니다. 나중에 Status에 Suspended를 추가하면 이 match는 non-exhaustive patterns: 'Status::Suspended' not covered 에러로 컴파일이 실패합니다. 상태 값이 추가될 때 처리 코드가 빠진 곳을 컴파일러가 모두 찾아 주는 셈입니다. 그래서 _ => ... 와일드카드는 신중하게 써야 합니다. 와일드카드를 넣으면 새 variant가 조용히 기본 분기로 빠져서 이 보호 장치가 꺼집니다.

C처럼 정수 값을 매길 수도 있습니다(enum Status { Active = 1, Inactive = 2 }, Status::Active as i32). 하지만 반대 방향(정수 → enum)은 자동으로 되지 않습니다. 잘못된 정수가 enum이 되면 안전성이 깨지기 때문입니다. DB나 네트워크에서 받은 숫자를 enum으로 바꾸려면 TryFrom<i32>를 구현해 유효하지 않은 값을 Err로 처리해야 합니다.

데이터를 가진 열거형

Rust의 열거형은 각 variant가 서로 다른 타입의 데이터를 가질 수 있습니다:

// 열거형 정의: 4가지 메시지 타입
enum Message {
    // 1. 데이터 없는 variant
    Quit,
    
    // 2. 구조체 스타일 (named fields)
    Move { x: i32, y: i32 },
    // x, y: 필드 이름으로 접근
    
    // 3. 튜플 스타일 (unnamed fields)
    Write(String),
    // 0번 인덱스로 접근
    
    // 4. 여러 값을 가진 튜플
    ChangeColor(i32, i32, i32),
    // RGB 값 (r, g, b)
}
impl Message {
    fn call(&self) {
        // match: 모든 variant를 처리 (필수)
        match self {
            // 1. 데이터 없는 variant
            Message::Quit => println!("종료"),
            
            // 2. 구조체 스타일: 필드 이름으로 추출
            Message::Move { x, y } => {
                // x, y: 패턴 매칭으로 필드 값 추출
                println!("이동: ({}, {})", x, y)
            },
            
            // 3. 튜플 스타일: 변수로 추출
            Message::Write(text) => {
                // text: String 값 추출
                println!("메시지: {}", text)
            },
            
            // 4. 여러 값 추출
            Message::ChangeColor(r, g, b) => {
                // r, g, b: 각 i32 값 추출
                println!("색상: RGB({}, {}, {})", r, g, b)
            },
        }
        // match는 모든 경우를 처리해야 함 (exhaustive)
        // 하나라도 빠뜨리면 컴파일 에러
    }
}
fn main() {
    // 각 variant 생성
    let msg1 = Message::Write(String::from("hello"));
    // Write variant에 String 데이터 포함
    
    let msg2 = Message::Move { x: 10, y: 20 };
    // Move variant에 x, y 필드 포함
    
    let msg3 = Message::ChangeColor(255, 0, 0);
    // ChangeColor variant에 RGB 값 포함
    
    let msg4 = Message::Quit;
    // Quit variant (데이터 없음)
    
    // 메서드 호출
    msg1.call();  // 메시지: hello
    msg2.call();  // 이동: (10, 20)
    msg3.call();  // 색상: RGB(255, 0, 0)
    msg4.call();  // 종료
}

call(&self) 안에서 match self를 하면 self가 참조이므로, 패턴에서 꺼낸 x, y, text도 참조(&i32, &String)가 됩니다. 이것을 “기본 바인딩 모드(match ergonomics)“라고 부르며, 덕분에 Message::Write(ref text)처럼 ref를 쓰지 않아도 소유권을 옮기지 않고 값을 빌려 볼 수 있습니다. 반대로 match 대상이 값(match msg)이면 String이 패턴 변수로 이동하므로, 그 뒤로 msg를 쓸 수 없습니다.

메모리 관점에서 열거형의 크기는 가장 큰 variant 크기 + 어떤 variant인지 나타내는 태그입니다. Message는 Write(String)(24바이트)에 맞춰 크기가 정해지므로 Quit처럼 데이터가 없는 값도 같은 공간을 차지합니다. 한 variant만 유난히 크다면(예: 1KB 배열) 모든 Message가 1KB가 되므로, 큰 데이터를 Box로 감싸 포인터 크기로 줄이는 것이 일반적인 최적화입니다. Clippy의 large_enum_variant 경고가 바로 이 상황을 알려 줍니다.

이런 열거형을 C++로 표현하려면 std::variant<Quit, Move, Write, ChangeColor>와 std::visit를 써야 하고, 객체지향 언어라면 추상 클래스와 네 개의 하위 클래스가 필요합니다. Rust는 이것을 언어의 기본 기능으로 넣어, “경우가 정해져 있는 데이터”를 가장 적은 코드로 표현하게 해 줍니다. 열거형의 강점:

// C/Java의 enum: 단순 상수
// enum Status { ACTIVE, INACTIVE }
// Rust의 enum: 각 variant가 다른 타입 데이터 보유
enum Result<T, E> {
    Ok(T),      // 성공 시 T 타입 값
    Err(E),     // 실패 시 E 타입 에러
}
// 타입 안전성: 컴파일 타임에 모든 경우 체크
// match를 사용하면 모든 variant 처리 강제

실전 예시: HTTP 응답:

enum HttpResponse {
    Ok(String),                    // 200: 응답 본문
    NotFound,                      // 404: 데이터 없음
    ServerError { code: u16, msg: String },  // 500: 에러 정보
}
fn handle_response(response: HttpResponse) {
    match response {
        HttpResponse::Ok(body) => {
            println!("성공: {}", body);
        },
        HttpResponse::NotFound => {
            println!("404: 페이지를 찾을 수 없습니다");
        },
        HttpResponse::ServerError { code, msg } => {
            println!("서버 에러 {}: {}", code, msg);
        },
    }
}

이 설계의 장점은 상태마다 필요한 데이터만 존재한다는 것입니다. 상태 코드, 본문, 에러 메시지를 모두 필드로 가진 구조체 하나(struct Resp { status: u16, body: Option<String>, err: Option<String> })로 만들면, “상태는 200인데 에러 메시지가 있음” 같은 모순된 조합을 막을 방법이 없고, 사용하는 쪽은 매번 어떤 필드가 채워져 있는지 추측해야 합니다. 열거형으로 두면 Ok일 때는 본문만, ServerError일 때는 코드와 메시지만 꺼낼 수 있습니다.

handle_response(response: HttpResponse)는 응답을 값으로 받아 body와 msg의 소유권을 패턴 변수로 옮깁니다. 응답을 처리한 뒤 다시 쓸 일이 없다면 이것이 자연스럽고, 호출한 쪽에서도 계속 써야 한다면 &HttpResponse로 받으면 됩니다. 이 예제에서 400번대, 301 같은 다른 상태를 추가하는 순간 match가 컴파일 에러로 알려 준다는 점도 앞의 완전성 검사와 같습니다.


null 대신 Option

Option 사용

fn divide(a: i32, b: i32) -> Option<i32> {
    if b == 0 {
        None
    } else {
        Some(a / b)
    }
}
fn main() {
    let result = divide(10, 2);
    
    match result {
        Some(value) => println!("결과: {}", value),
        None => println!("0으로 나눌 수 없음"),
    }
    
    // unwrap_or로 기본값
    let result2 = divide(10, 0).unwrap_or(0);
    println!("결과: {}", result2);
}

Rust에는 null이 없습니다. 값이 없을 수 있다면 반드시 Option<T>로 감싸야 하고, Option<i32>와 i32는 다른 타입이라서 divide(10, 2) + 1처럼 확인 없이 쓰면 cannot add '{integer}' to 'Option<i32>' 컴파일 에러가 납니다. 다른 언어에서 런타임에 터지던 널 역참조를 컴파일 에러로 옮긴 것이 Option의 핵심입니다. Tony Hoare가 널 참조를 “10억 달러짜리 실수”라고 부른 일화가 자주 인용되는 이유이기도 합니다.

값을 꺼내는 방법에 따라 의미가 다릅니다. unwrap()은 None이면 패닉하므로 “여기서 None이면 프로그램 버그”라고 확신할 때만 쓰고, 그럴 때도 expect("설정 파일에 port가 있어야 함")처럼 이유를 남기는 편이 디버깅에 좋습니다. 그 외에는 match, if let, unwrap_or 계열로 처리합니다. 이 예제의 divide는 설명을 위한 것이고, 표준 라이브러리에 이미 10i32.checked_div(0)이 None을 반환하는 메서드로 들어 있습니다. i32::MIN / -1 같은 오버플로도 함께 처리해 주므로 실제로는 이쪽을 쓰는 것이 낫습니다.

Option 메서드

let x: Option<i32> = Some(5);
// map
let y = x.map(|v| v * 2);  // Some(10)
// and_then
let z = x.and_then(|v| Some(v + 1));  // Some(6)
// unwrap_or
let value = x.unwrap_or(0);  // 5
// unwrap_or_else
let value2 = x.unwrap_or_else(|| {
    println!("기본값 사용");
    0  // 클로저도 i32를 반환해야 함
});

map은 Some 안의 값만 변환하고 None은 그대로 통과시킵니다. and_then은 변환 함수 자체가 Option을 반환할 때 씁니다. map에 Option을 반환하는 함수를 넘기면 Option<Option<i32>>처럼 중첩되기 때문에, “실패할 수 있는 단계를 이어 붙일 때”는 and_then이 맞습니다. 예를 들어 input.parse::<i32>().ok().and_then(|n| divide(100, n))은 파싱 실패와 0으로 나누기를 하나의 Option으로 합칩니다.

unwrap_or와 unwrap_or_else의 차이는 기본값을 언제 계산하느냐입니다. unwrap_or(expensive())는 값이 있어도 expensive()를 먼저 호출하고, unwrap_or_else(|| expensive())는 None일 때만 호출합니다. 그리고 클로저의 반환 타입이 Option 안의 타입과 같아야 합니다. 이 예제의 이전 버전처럼 클로저에 println!만 두면 클로저가 ()를 반환해서 expected 'i32', found '()' 컴파일 에러가 납니다. 블록의 마지막 줄에 세미콜론 없는 값(0)을 두어야 그 값이 반환된다는 Rust 문법 규칙이 여기서도 적용됩니다.


Result<T, E>와 ? 연산자 미리 보기

Result 사용

fn divide_result(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("0으로 나눌 수 없음"))
    } else {
        Ok(a / b)
    }
}
fn main() {
    match divide_result(10, 2) {
        Ok(value) => println!("결과: {}", value),
        Err(e) => println!("에러: {}", e),
    }
}

Result<T, E>는 Option에 “왜 실패했는가”를 더한 타입입니다. Option은 값이 없다는 사실만 전달하지만, Result는 Err(E)에 실패 이유를 담습니다. 예제에서는 간단히 String을 에러 타입으로 썼는데, 호출하는 쪽이 에러 종류에 따라 다르게 처리해야 한다면 문자열 비교보다 enum MathError { DivByZero, Overflow }처럼 에러 자체를 열거형으로 만드는 편이 좋습니다. 그러면 에러 처리에도 앞에서 본 완전성 검사가 적용됩니다.

Result는 #[must_use] 속성이 붙어 있어서 반환값을 그냥 버리면 unused 'Result' that must be used 경고가 나옵니다. 실패할 수 있는 작업의 결과를 무시하는 실수를 컴파일러가 알려 주는 장치입니다.

? 연산자

fn read_file() -> Result<String, std::io::Error> {
    let content = std::fs::read_to_string("file.txt")?;
    Ok(content)
}

?는 “Ok면 값을 꺼내고, Err면 이 함수에서 바로 그 에러를 반환하라”는 줄임말입니다. match로 풀어 쓰면 네 줄이 되는 코드를 한 글자로 줄여 주므로, 여러 단계를 거치는 파일·네트워크 코드가 훨씬 읽기 쉬워집니다. ?는 함수의 반환 타입이 Result나 Option일 때만 쓸 수 있어서, fn main() 안에서 쓰면 the '?' operator can only be used in a function that returns 'Result' or 'Option' 에러가 납니다. fn main() -> Result<(), Box<dyn std::error::Error>>로 바꾸면 해결됩니다. 또 ?는 From 트레이트로 에러 타입을 자동 변환해 주므로, 서로 다른 에러 타입을 하나의 함수에서 전파하는 방법은 #04 에러 처리에서 이어서 다룹니다.


match와 if let 패턴 매칭

기본 매칭

fn main() {
    let number = 7;
    
    match number {
        1 => println!("하나"),
        2 | 3 | 5 | 7 => println!("소수"),
        4..=10 => println!("4~10"),
        _ => println!("기타"),
    }
}

match의 분기는 위에서부터 순서대로 검사되고 처음 맞는 분기 하나만 실행됩니다. 그래서 7은 “소수”에 먼저 걸리고 4..=10 분기는 실행되지 않습니다. 분기 순서를 바꿔 4..=10을 위로 올리면 5와 7도 “4~10”이 됩니다. 이미 앞 분기가 모든 경우를 덮어 뒤 분기가 절대 실행되지 않으면 컴파일러가 unreachable pattern 경고를 냅니다.

정수처럼 가능한 값이 너무 많은 타입은 모든 경우를 나열할 수 없으므로 _가 필수입니다. 여기에 n if n % 2 == 0 => ...처럼 매치 가드를 붙이면 패턴만으로 표현하기 어려운 조건을 추가할 수 있습니다. 다만 가드가 있는 분기는 완전성 검사에서 “조건이 거짓일 수 있다”고 간주되므로, 가드만으로 모든 경우를 나눠도 _ 분기를 요구받습니다.

Option 매칭

fn print_value(opt: Option<i32>) {
    match opt {
        Some(value) => println!("값: {}", value),
        None => println!("값 없음"),
    }
}

if let (간단한 매칭)

let some_value = Some(5);
// match 대신 if let
if let Some(value) = some_value {
    println!("값: {}", value);
}
// else 추가 가능
if let Some(value) = some_value {
    println!("값: {}", value);
} else {
    println!("값 없음");
}

if let은 “관심 있는 패턴 하나만 처리하는 match”입니다. 위 두 번째 예제는 match로 써도 줄 수가 거의 같지만, 한 분기만 필요한 경우(None이면 아무것도 안 함)에는 _ => {}를 쓰지 않아도 되어 훨씬 짧아집니다. Rust 1.65부터는 let Some(value) = some_value else { return; };처럼 쓰는 let-else 문법도 있습니다. 조건이 맞지 않으면 일찍 빠져나가고, 맞으면 이후 코드 전체에서 value를 쓸 수 있어서 중첩이 깊어지는 것을 막아 줍니다. 여러 단계의 검증을 하는 함수라면 if let을 계속 중첩하는 것보다 let-else나 ?로 평평하게 만드는 편이 읽기 좋습니다.


예제: 사용자 시스템과 도형 계산

예제: 사용자 시스템

#[derive(Debug)]
struct User {
    id: u32,
    name: String,
    email: String,
    active: bool,
}
impl User {
    fn new(id: u32, name: String, email: String) -> Self {
        User {
            id,
            name,
            email,
            active: true,
        }
    }
    
    fn deactivate(&mut self) {
        self.active = false;
    }
    
    fn is_active(&self) -> bool {
        self.active
    }
}
fn main() {
    let mut user = User::new(
        1,
        String::from("홍길동"),
        String::from("[email protected]")
    );
    
    println!("{:?}", user);
    
    user.deactivate();
    println!("활성 상태: {}", user.is_active());
}

#[derive(Debug)]는 {:?}로 구조체를 출력할 수 있게 해 주는 코드를 컴파일러가 자동으로 만들어 줍니다. 이것이 없으면 println!("{:?}", user)에서 'User' doesn't implement 'Debug' 에러가 납니다. {:#?}를 쓰면 필드마다 줄을 바꿔 예쁘게 출력합니다. 사용자에게 보여 줄 출력({})은 자동으로 만들어지지 않고 Display 트레이트를 직접 구현해야 하는데, 이는 “디버깅용 표현”과 “사용자용 표현”을 의도적으로 분리한 설계입니다.

이 예제는 active: bool과 deactivate, is_active 메서드로 상태를 캡슐화합니다. 필드를 pub으로 공개하지 않으면 다른 모듈에서는 메서드로만 상태를 바꿀 수 있으므로, “비활성화할 때 로그를 남긴다” 같은 규칙을 한 곳에 모을 수 있습니다. 앞에서 설명한 것처럼 상태가 둘보다 많아지거나 상태마다 다른 데이터가 필요해지면(예: 정지 사유, 정지 해제일), bool 대신 enum AccountStatus { Active, Suspended { reason: String, until: Date } }로 바꾸는 것을 고려할 만합니다.

예제: 도형 계산

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}
impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
            Shape::Rectangle { width, height } => width * height,
            Shape::Triangle { base, height } => 0.5 * base * height,
        }
    }
}
fn main() {
    let shapes = vec![
        Shape::Circle { radius: 5.0 },
        Shape::Rectangle { width: 10.0, height: 20.0 },
        Shape::Triangle { base: 8.0, height: 12.0 },
    ];
    
    for shape in shapes {
        println!("넓이: {:.2}", shape.area());
    }
}

같은 문제를 트레이트로 풀 수도 있습니다. trait Shape { fn area(&self) -> f64; }를 정의하고 Circle, Rectangle, Triangle 구조체가 각각 구현한 뒤 Vec<Box<dyn Shape>>에 담는 방식입니다. 두 방식의 트레이드오프는 분명합니다. 열거형은 도형 종류가 닫혀 있을 때 유리합니다. 힙 할당 없이 Vec<Shape>에 값으로 저장되고, 새 연산(perimeter)을 추가하기 쉬우며, 새 도형을 추가하면 모든 match가 컴파일 에러로 알려 줍니다. 트레이트 객체는 도형 종류가 열려 있을 때 유리합니다. 다른 크레이트나 플러그인이 새 도형을 추가할 수 있지만, 동적 디스패치와 Box 할당 비용이 있고 새 연산을 추가하려면 모든 구현을 고쳐야 합니다.

for shape in shapes는 벡터의 소유권을 가져가므로 루프 뒤에는 shapes를 쓸 수 없습니다. 루프 뒤에도 벡터가 필요하면 for shape in &shapes로 빌려서 순회합니다. area가 &self를 받기 때문에 두 경우 모두 호출에는 문제가 없습니다.


구조체·열거형 요약

  1. struct: 데이터 구조, impl로 메서드 추가
  2. enum: 여러 가능한 값, 데이터 포함 가능
  3. Option: Some/None, Null 안전성
  4. Result: Ok/Err, 에러 처리
  5. match: 패턴 매칭, 모든 경우 처리

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. match 대신 if let을 써도 되는 경우는 언제인가요?

A. Option이나 열거형에서 관심 있는 패턴이 하나뿐이고 나머지는 무시하거나 else 하나로 처리해도 될 때 if let이 더 간결합니다. 다만 if let은 match와 달리 모든 경우를 처리했는지 검사하지 않으므로, 열거형의 각 variant마다 다른 처리가 필요하다면 match를 써야 새 variant가 추가됐을 때 컴파일러가 누락을 알려 줍니다.