Rust 기초 강좌 #5 구조체와 열거형: match와 Option, null이 없는 언어의 설계

4 분 소요

소유권과 빌림이라는 고비를 넘었으니, 이제 데이터를 프로그램의 형태로 조직하는 도구를 배울 차례입니다. Rust의 답은 두 가지입니다. “이 값들은 항상 함께 다닌다"를 표현하는 구조체(struct), 그리고 “이 값은 여러 모습 중 하나다"를 표현하는 열거형(enum)입니다. 특히 열거형과 match의 조합은 Rust에서 null 포인터 에러가 원천적으로 불가능한 이유로 이어집니다.

구조체: 함께 다니는 값들의 묶음 #

src/main.rs
struct User {
    email: String,
    name: String,
    active: bool,
}

let user = User {
    email: String::from("kim@example.com"),
    name: String::from("김개발"),
    active: true,
};
println!("{}", user.email);

클래스가 있는 언어의 데이터 부분과 비슷하지만, 상속은 없습니다. 동작은 impl 블록으로 별도로 붙입니다.

src/main.rs
impl User {
    fn display_name(&self) -> String {
        format!("{} <{}>", self.name, self.email)
    }

    fn deactivate(&mut self) {
        self.active = false;
    }
}

첫 매개변수가 곧 지난 편의 빌림 규칙입니다. 읽기만 하는 메서드는 &self, 상태를 바꾸는 메서드는 &mut self로 받습니다. 메서드 시그니처만 봐도 이 호출이 값을 바꾸는지 아닌지가 드러나고, mut로 선언하지 않은 인스턴스에서 deactivate를 부르면 컴파일 에러가 납니다. 디버깅 출력은 구조체 선언 위에 #[derive(Debug)]를 붙이고 println!("{user:?}")로 찍는 것이 기본기인데, 이 derive의 정체는 8편에서 다룹니다.

열거형: 여러 모습 중 하나 #

다른 언어의 열거형이 “이름 붙은 상수 목록"에 가깝다면, Rust의 열거형은 variant마다 다른 데이터를 담을 수 있습니다.

src/main.rs
enum PaymentMethod {
    Cash,
    Card { number: String, installments: u8 },
    Transfer(String), // 은행 계좌
}

현금은 추가 정보가 없고, 카드는 번호와 할부 개월이 필요하고, 계좌 이체는 계좌 문자열 하나면 됩니다. 이런 데이터를 구조체 하나에 nullable 필드들로 욱여넣으면 “현금인데 카드 번호가 있는” 모순 상태가 가능해집니다. 열거형은 그 모순을 타입 차원에서 제거합니다. 잘못된 상태를 표현조차 할 수 없게 만드는 것이 Rust 데이터 설계의 핵심 습관입니다.

match: 모든 경우를 검사했는가 #

열거형을 소비하는 도구가 match입니다.

src/main.rs
fn fee(method: &PaymentMethod) -> u32 {
    match method {
        PaymentMethod::Cash => 0,
        PaymentMethod::Card { installments, .. } if *installments > 3 => 500,
        PaymentMethod::Card { .. } => 300,
        PaymentMethod::Transfer(_) => 100,
    }
}

switch와 비슷해 보이지만 결정적 차이가 둘 있습니다. 첫째, match는 표현식이라서 각 갈래의 값이 그대로 반환됩니다(2편의 “모든 것이 표현식"이 여기서 진가를 발휘합니다). 둘째, 모든 variant를 처리하지 않으면 컴파일 에러입니다.

컴파일 에러
error[E0004]: non-exhaustive patterns: `PaymentMethod::Transfer(_)` not covered

Transfer 갈래를 지우면 이 에러가 납니다. 나중에 열거형에 variant를 추가하면, 그 열거형을 match하는 모든 코드가 컴파일 에러로 바뀝니다. “결제 수단을 하나 추가했는데 처리 안 한 곳이 어딘가에 남아 있는” 종류의 버그가 컴파일러의 할 일 목록으로 바뀌는 것입니다. 나머지 전부를 뜻하는 _ 갈래도 있지만, 이 안전망을 끄는 행위이므로 기본값 처리가 정말 맞을 때만 씁니다.

Option: null을 대체하는 열거형 #

Rust에는 null이 없습니다. 대신 표준 라이브러리의 열거형 하나가 그 역할을 맡습니다.

Option의 정의
enum Option<T> {
    Some(T),
    None,
}

“값이 없을 수도 있음"이 타입에 드러나고, 컴파일러가 그 처리를 강제합니다. 이미 여러 번 만났습니다. 2편에서 parse의 결과를 unwrap으로 꺼낸 것이 사실 이 처리(정확히는 6편에서 다룰 Result)를 건너뛴 것이었습니다.

src/main.rs
fn find_user(email: &str) -> Option<User> { /* ... */ }

match find_user("kim@example.com") {
    Some(user) => println!("{}", user.display_name()),
    None => println!("해당 사용자가 없습니다."),
}

Option<User>User와 다른 타입이라서, 없을 가능성을 처리하기 전에는 안의 값을 사용할 수 없습니다. null 체크를 잊어서 나는 NullPointerException 계열 사고가 컴파일 에러로 바뀌는 구조입니다. 한 갈래만 관심 있을 때는 match 대신 if let이 간결합니다.

src/main.rs
if let Some(user) = find_user("kim@example.com") {
    println!("{}", user.display_name());
}

unwrap()None이면 그대로 패닉으로 죽는 지름길입니다. 예제와 프로토타입에서는 편하지만, 실무 코드에서 unwrap을 쓸 때의 판단 기준은 다음 편에서 에러 처리 전체와 함께 정리하겠습니다.

정리 #

  • 구조체는 함께 다니는 값의 묶음이고, 동작은 impl 블록으로 붙입니다. &self&mut self 구분에 빌림 규칙이 그대로 이어집니다.
  • Rust의 열거형은 variant마다 다른 데이터를 담습니다. 모순된 상태를 타입 차원에서 표현할 수 없게 만드는 것이 설계의 핵심 습관입니다.
  • match는 모든 경우의 처리를 컴파일러가 강제하는 표현식입니다. variant 추가가 곧 “처리 누락 지점의 컴파일 에러 목록"이 됩니다.
  • null 대신 Option<T>가 있습니다. 값이 없을 가능성이 타입에 드러나므로, null 체크 누락이라는 버그 클래스가 사라집니다.
  • 다음 편은 에러 처리입니다. Option의 형제인 Result, 에러를 전파하는 ? 연산자, 그리고 panic과의 경계를 다루겠습니다.
X