데이터 구조를 타입 시스템으로 표현하라

기본 타입

러스트에서는 다양한 크기로 부호 있는 정수 타입(i8, i16, i32, i64, i128)과 부호 없는 정수 타입(u8, u16, u32, u64, u128)을 제공한다.

또한 부호 있는 정수 타입(isize)와 부호 없는 정수 타입(usize)도 제공한다. 이런 타입은 타깃 시스템의 포인터 크기에 맞게 제공되지만 러스트에서는 포인터 타입과 정수 타입을 서로 변환할 일이 많지 않아서 큰 의미는 없다.

그보다는 표준 컬렉션이 크기를 len() 함수를 통해 usize 로 반환하기 때문에 컬렉션에 담긴 항목에 대한 인덱스를 표현하는 데 usize 값을 자주 사용한다. 이렇게 해도 메모리에 있는 컬렉션의 항목 수가 시스템 메모리의 주소 공간보다 많을 수 없기 때문에 용량 문제는 발생하지 않는다.

정수 타입만 봐도 러스트가 C++ 보다 훨씬 엄격하다는 것을 알 수 있다.

러스트에서는 다음과 같이 큰 정수 타입(i32)을 작은 정수 타입(i16)에 넣으려고 하면:

let x: i32 = 42;
let y: i16 = x;

컴파일 오류가 발생한다:

error[E0308]: mismatched types
--> src/main.rs:3:18
|
3 |     let y: i16 = x;
|            ---   ^ expected `i16`, found `i32`
|            |
|            expected due to this
|
help: you can convert an `i32` to an `i16` and panic if the converted value doesn't fit
|
3 |     let y: i16 = x.try_into().unwrap();
|                   ++++++++++++++++++++
 
For more information about this error, try `rustc --explain E0308`.

이와 반대로 작은 정수 타입 값을 큰 정수 타입에 넣는 것처럼 안전해 보이는 작업도 허용하지 않는다.

let x = 42i32;
let y = i64 = x;

타입 접미사가 붙은 정수 리터럴을 테스트 해보았다:

error[E0423]: expected value, found builtin type `i64`
 --> src/main.rs:3:13
  |
3 |     let y = i64 = x;
  |             ^^^
  |
help: you might have meant to use `:` for type annotation
  |
3 -     let y = i64 = x;
3 +     let y: i64 = x;
  |
help: consider importing one of these functions instead
  |
1 + use fastrand::i64;
  |
1 + use nom::character::complete::i64;
  |
1 + use nom::character::streaming::i64;
  |
1 + use nom::number::complete::i64;
  |
  = and 7 other candidates
 
For more information about this error, try `rustc --explain E0423`.

컴파일러가 제시한 해결법을 보면 오류 처리까지는 하지 않더라도 타입 만큼은 명시적으로 변환해야 함을 알 수 있다.

이 밖에도 러스트는 다양한 타입 시스템을 가지고 있다:

  • bool
  • f32, f64
  • Unit
  • char

이 중에서도 char 타입은 조금 더 특이한데, Go 언어의 Rune 타입처럼 유니코드 값을 갖는데, 내부적으로 4바이트로 표현됨에도 불구하고, 32비트 정수와의 암묵적인 변환은 허용하지 않는다.

이처럼 러스트의 타입 시스템은 엄격하기 때문에 항상 대상을 명확히 표현해야 한다. u32 값은 char 과는 엄연히 다르고 UTF-8 바이 시퀀스(sequence) 와 다르며 UTF-8 바이트 시퀀스는 임의 타입의 바이트 시퀀스와 다르다.

따라서 자신이 표현하려는 대상을 구체적으로 명시해야 한다.

여기에 파일 시스템이 관련되어 있다면 상황은 더 복잡해진다. 왜냐하면 널리 사용되는 플랫폼에서 파일 이름은 임의의 바이트와 UTF-8 시퀀스 사이의 뭔가로 되어 있기 때문이다. 이와 관련된 조엘 스폴스키의 유명한 게시물(https://oreil.ly/wWy7T)를 참고해보자.

물론 다양한 타입 사이의 변환을 도와주는 헬퍼 메서드가 있지만 실패할 가능성을 처리하든지 아니면 명시적으로 무시하도록 시그니처가 정의되어 있다.

예를 들어 유니코드 코드 포인트는 항상 32비트로 표현되므로 au32 로 표현할 수는 있지만 그 반대로 하기에는 쉽지 않다.

u32 값이 모두 올바른 유니코드 코드 포인트가 아니기 떄문이다.

char::from_u32

Option<char> 를 반환하며, 호출자는 실패한 경우를 처리할 수 있어야 한다.

fn main() {
    // 안전한 변환: Option<char>를 반환
    let c1 = char::from_u32(65);        // Some('A')
    let c2 = char::from_u32(0x1F600);   // Some('😀')
    let c3 = char::from_u32(0xD800);    // None (유효하지 않은 유니코드)
    
    println!("{:?}", c1);
    println!("{:?}", c2);
    println!("{:?}", c3);
 
    // 실패 처리 방법
    let value = 65;
    if let Some(ch) = char::from_u32(value) {
        println!("변환 성공: {}", ch);
    } else {
        println!("유효하지 않은 유니코드 값");
    }
}
char::from_u32_unchecked

정상적으로 변환된다고 가정하지만, 그 가정이 성립하지 않는 경우에는 정의되지 않는 동작이 발생할 수 있다. 그래서 이 함수는 unsafe 로 지정되며 이 함수를 호출하는 측에도 unsafe 코드를 작성해야 한다.

fn main() {
    // char::from_u32 - 안전한 변환, Option<char> 반환
    let valid_unicode = char::from_u32(65);
    println!("Valid unicode (65): {:?}", valid_unicode); // Some('A')
    
    let emoji = char::from_u32(0x1F600);
    println!("Emoji (0x1F600): {:?}", emoji); // Some('😀')
    
    // 유효하지 않은 유니코드 값
    let invalid = char::from_u32(0xD800); // surrogate 범위
    println!("Invalid unicode (0xD800): {:?}", invalid); // None
    
    let too_large = char::from_u32(0x110000);
    println!("Too large (0x110000): {:?}", too_large); // None
    
    // char::from_u32_unchecked - unsafe 함수
    // 올바른 값이라고 확신할 때만 사용
    unsafe {
        let c = char::from_u32_unchecked(65);
        println!("Unchecked (65): {:?}", c); // 'A'
        
        // 주의: 유효하지 않은 값을 사용하면 정의되지 않은 동작 발생!
        // let invalid = char::from_u32_unchecked(0xD800); // ⚠️ UB!
    }
    
    // 실전 예제: Option 처리
    match char::from_u32(0x41) {
        Some(c) => println!("성공적으로 변환: {}", c),
        None => println!("변환 실패"),
    }
    
    // unwrap_or로 기본값 제공
    let c = char::from_u32(0xFFFFFFFF).unwrap_or('?');
    println!("변환 실패시 기본값: {}", c); // '?'
}

묶음 타입

여러 값을 묶을 수 있는 묶음 타입(aggregate type)에 대해서 알아보자. 러스트의 묶음 타입은 다른 언어와 비슷하다.

배열

타입이 같은 인스턴스 여러 개를 배열에 담을 수 있다. 이때 인스턴스의 개수는 컴파일 타임에 결정되어야 한다. 예를 들어 [u32; 4] 는 4바이트 정수 네 개가 연달아 담긴다.

fn main() {
    // 배열 선언: [타입; 길이]
    let numbers: [u32; 4] = [10, 20, 30, 40];
    println!("배열: {:?}", numbers);
    println!("첫 번째 원소: {}", numbers[0]);
    println!("배열 길이: {}", numbers.len());
    
    // 같은 값으로 초기화
    let zeros = [0; 5]; // [0, 0, 0, 0, 0]
    println!("0으로 초기화: {:?}", zeros);
    
    // 배열 순회
    for num in numbers.iter() {
        println!("원소: {}", num);
    }
}
튜플

타입이 다른 인스턴스 여러 개를 튜플에 담을 수 있다. 원소의 개수와 각 원소의 타입은 컴파일 타입에 결정되어야 한다. 튜플의 예로 (WidgetOffset, WidgetSize, WidgetColor) 등이 있다.

하지만 (i32, i32, &'static str, bool) 처럼 튜플을 구성하는 원소 타입을 명확히 구분해야 한다면 각 원소마다 이름을 지정해서 구조체로 만드는 것이 낫다.

fn main() {
    // 기본 튜플
    let tuple: (i32, f64, &str) = (42, 3.14, "hello");
    println!("튜플: {:?}", tuple);
    
    // 인덱스로 접근
    println!("첫 번째: {}", tuple.0);
    println!("두 번째: {}", tuple.1);
    println!("세 번째: {}", tuple.2);
    
    // 구조 분해 (destructuring)
    let (x, y, z) = tuple;
    println!("x={}, y={}, z={}", x, y, z);
    
    // 의미 있는 타입 별칭 사용 예시
    type WidgetOffset = i32;
    type WidgetSize = i32;
    type WidgetColor = &'static str;
    
    let widget: (WidgetOffset, WidgetSize, WidgetColor) = (10, 100, "red");
    println!("위젯: {:?}", widget);
    
    // 나쁜 예: 타입이 명확하지 않음
    let confusing: (i32, i32, &'static str, bool) = (1, 2, "test", true);
    // 이런 경우는 구조체를 사용하는 것이 낫다!
}
구조체

구조체도 튜플처럼 타입이 서로 다른 인스턴스를 묶을 수 있고 타입도 컴파일 타임에 정해야 하지만 구조체 전체 뿐 아니라 개별 필드에도 이름을 붙여서 참조할 수 있다.

// 구조체 정의
struct Widget {
    offset: i32,
    size: i32,
    color: &'static str,
    visible: bool,
}
 
fn main() {
    // 구조체 인스턴스 생성
    let widget = Widget {
        offset: 10,
        size: 100,
        color: "red",
        visible: true,
    };
    
    // 필드명으로 접근 (튜플의 .0, .1보다 명확함)
    println!("오프셋: {}", widget.offset);
    println!("크기: {}", widget.size);
    println!("색상: {}", widget.color);
    println!("표시: {}", widget.visible);
    
    // 구조체 분해
    let Widget { offset, size, color, visible } = widget;
    println!("분해된 값들: offset={}, size={}, color={}, visible={}", 
             offset, size, color, visible);
}
튜플 구조체

러스트에는 구조체와 튜플을 혼합한 튜플 구조체가 있다. 튜플 구조체는 구조체 전체에 대해서는 이름을 붙일 수 있지만 개별 필드에는 이름이 없고 s.0 s.1 등과 같은 숫자로 표현한다.

// 튜플 구조체 정의
struct Point(i32, i32);
struct Color(u8, u8, u8);
struct Wrapper(String);
 
fn main() {
    // 튜플 구조체 생성
    let point = Point(10, 20);
    let color = Color(255, 0, 0);
    let wrapper = Wrapper(String::from("Hello"));
    
    // 숫자 인덱스로 접근
    println!("Point: ({}, {})", point.0, point.1);
    println!("Color RGB: ({}, {}, {})", color.0, color.1, color.2);
    println!("Wrapped value: {}", wrapper.0);
    
    // 구조 분해
    let Point(x, y) = point;
    println!("x={}, y={}", x, y);
    
    // 튜플 구조체의 장점: 타입 안정성
    // Point와 Color는 둘 다 (i32, i32) 또는 (u8, u8, u8)이지만
    // 서로 다른 타입으로 취급됨
    fn move_point(p: Point) -> Point {
        Point(p.0 + 1, p.1 + 1)
    }
    
    let new_point = move_point(point);
    println!("Moved point: ({}, {})", new_point.0, new_point.1);
    
    // move_point(color); // 컴파일 에러! Color는 Point가 아님
}

Enum

러스트 타입 시스템에서 핵심적인 역할을 하는 enum 에 대해 알아보자.

enum 의 기본 형태만 보면 그리 특별하지 않다. 다른 언어와 마찬가지로 러스트의 enum 도 각 원소마다 숫자를 할당해 상호 배타적인 값으로 구성된 집합을 정의할 수 있다.

enum HttpResultCode {
    Ok = 200,
    NotFound = 404,
    Teapot = 418,
    InternalServerError = 500,
}
 
let code = HttpResultCode::Ok;
 
assert_eq!(code as i32, 200);

각 enum 정의마다 타입이 별도로 생성되므로 단순히 bool 타입 인수를 받도록 정의할 때보다 가독성과 유지 보수성을 높일 수 있다.

print_page(/* both_sides =  */ true, /* color = */ false);

이 코드를 다음처럼 enum 타입 한 쌍으로 정의할 수 있다.

pub enum Sides {
    Both,
    Single,
}
 
pub enum Output {
    BlackAndWhite,
    Color,
}
 
pub fn print_page(sides: Sides, output: Output) {
    match (sides, output) {
        (Sides::Both, Output::BlackAndWhite) => println!("Printing both sides in black and white"),
        (Sides::Both, Output::Color) => println!("Printing both sides in color"),
        (Sides::Single, Output::BlackAndWhite) => println!("Printing single side in black and white"),
        (Sides::Single, Output::Color) => println!("Printing single side in color"),
    }
}

그러면 다음과 같이 호출 지점의 가독성과 타입 안정성을 높일 수 있다.

print_page(Sides::Both, Output::Color);

bool 타입 인수를 받도록 정의할 때와 달리, 라이브러리 사용자가 실수로 인수의 순서를 바꿔적으면 컴파일러가 즉시 오류 메세지를 출력한다.

fn main() {
    // 올바른 사용
    print_page(Sides::Both, Output::Color);
    print_page(Sides::Single, Output::BlackAndWhite);
    
    // 컴파일 에러! 인수 순서가 바뀜
    // print_page(Output::Color, Sides::Both);
    // error[E0308]: mismatched types
    //  --> expected enum `Sides`, found enum `Output`
    
    // bool을 사용했다면 이런 실수를 컴파일러가 잡아낼 수 없음
    // print_page_bool(true, false);  // 어느 것이 sides이고 어느 것이 output인지?
    // print_page_bool(false, true);  // 이것도 컴파일은 되지만 의미가 불명확
}
 
// 나쁜 예: bool 사용 (비교용)
fn print_page_bool(both_sides: bool, color: bool) {
    // 인수의 의미가 불명확하고, 순서를 바꿔도 컴파일러가 감지 못함
    match (both_sides, color) {
        (true, true) => println!("Printing both sides in color"),
        (true, false) => println!("Printing both sides in black and white"),
        (false, true) => println!("Printing single side in color"),
        (false, false) => println!("Printing single side in black and white"),
    }
}
뉴타입(new type) 패턴을 활용하여 안정성과 유지보수성 확보하기

뉴타입 패턴을 이용해 원시 타입(bool, i32 등)을 래핑하면 타입 안정성과 유지보수성을 모두 확보할 수 있다.

// 뉴타입 패턴: 튜플 구조체로 기존 타입을 래핑
struct BothSides(bool);
struct ColorOutput(bool);
 
fn print_page_newtype(both_sides: BothSides, color: ColorOutput) {
    match (both_sides.0, color.0) {
        (true, true) => println!("Printing both sides in color"),
        (true, false) => println!("Printing both sides in black and white"),
        (false, true) => println!("Printing single side in color"),
        (false, false) => println!("Printing single side in black and white"),
    }
}
 
fn main() {
    // 타입이 명확해서 인수 순서를 바꾸면 컴파일 에러 발생
    print_page_newtype(BothSides(true), ColorOutput(true));
    
    // print_page_newtype(ColorOutput(true), BothSides(true)); // 컴파일 에러!
}

선택 가이드:

  • 항상 두 가지 상태만 존재한다면 → 뉴타입 패턴 사용
  • 나중에 새로운 대안(예: Sides::BothAlternativeOrientation)이 추가될 가능성이 있다면 → enum 사용
Enum의 완전성 검사 (Exhaustiveness Checking)

컴파일러는 enum으로 표현되는 모든 경우의 수를 프로그래머가 반드시 검토하도록 요구한다.

enum HttpResultCode {
    Ok,
    NotFound,
    Teapot,
}
 
fn main() {
    let code = HttpResultCode::Ok;
    
    // 오류 발생: 모든 variant를 처리하지 않음
    // let msg = match code {
    //     HttpResultCode::Ok => "Ok",
    //     HttpResultCode::NotFound => "Not found",
    //     // HttpResultCode::Teapot 케이스 누락!
    // };
    // error[E0004]: non-exhaustive patterns: `Teapot` not covered
    
    // 올바른 예: 모든 variant 처리
    let msg = match code {
        HttpResultCode::Ok => "Ok",
        HttpResultCode::NotFound => "Not found",
        HttpResultCode::Teapot => "I'm a teapot",
    };
    println!("{}", msg);
    
    // 또는 와일드카드 패턴 사용
    let msg2 = match code {
        HttpResultCode::Ok => "Ok",
        _ => "Other status",
    };
    println!("{}", msg2);
}

필드가 있는 Enum

러스트 enum 의 진정한 강력함은 각 배리언트(varient)마다 데이터를 가질 수 있는 능력에 있다. 이를 통해 묶음 타입이 대수적 데이터 타입(ADT)처럼 작동하게 만들 수 있다. 다른 주류 언어를 사용하던 프로그래머는 이러한 점이 생소할 수 있는데 C/C++ 에서 enumunion 을 조합한 것에 타입 안정성이 보장되는 것과 같다.

use std::collections::{HashMap, HashSet};
 
pub enum SchedulerState {
    Inert,
    Pending(HashSet<Job>),
    Running(HashMap<CpuId, Vec<Job>>),
}

이 타입 정의만 보면 Job 은 Pending 상태 큐(queue)에 들어가 있다가 스케쥴러가 완전히 활성화되는 시점에 CPU 풀(pool)에 할당된다고 예상할 수 있다.

이런식의 구성이야말로 바로 이번 아이템의 핵심 주제인 러스트는 어떻게 타입 시스템을 통해 프로그램 컨셉을 디자인하는가를 보여주는 단적인 예이다.

다음과 같이 필드나 매개변수의 유효성 조건에 대한 주석이 달린다면, 개념을 타입 시스템에 제대로 표현하지 못했다는 뜻이다.

pub struct DisplayProps {
    pub x: u32,
    pub y: u32,
    pub monochrome: bool,
    pub fg_color: RgbColor,
}

이런 코드는 다음과 같이 데이터를 담을 수 있는 enum 으로 표현하는 것이 바람직하다.

pub enum Color {
    Monochrome,
    Foreground(RgbColor),
}
 
pub struct DisplayProps {
    pub x: u32,
    pub y: u32,
    pub color: Color,
}

이 예제는 즉, 유효하지 않은 상태가 타입에 표현될 수 없게 만들어야 한다 는 이번 아이템의 핵심 주제를 잘 보여준다.

흔히 사용하는 Enum 타입

다시 enum 의 강력함에 대한 주제로 돌아와서 흔히 사용하는 두 가지 enum 타입을 알아보자.

Option

첫번째 enum 타입은 Option 이다. 이 타입은 특정 타입의 값이 있을수도 있고(Some(T)), 없을수도 있음(None)을 나타낸다. 값이 없을수도 있는 경우는 반드시 Option 으로 표현한다.

여기서 한가지 고려할 점은, 컬렉션을 다룰때 원소가 없는 경우와 컬렉션이 없는 경우가 같은 의미인지 결정해야 한다.

대부분의 상황에서는 두 경우를 구분할 필요가 없어서, 예를 들면 Vec<Thing> 을 사용해서, 컬렉션 자체가 없다는 것을 원소가 0개인 것으로 표현해도 된다.

#[derive(Debug)]
struct Thing {
    name: String,
}
 
fn main() {
    // 대부분의 경우: Vec<Thing>으로 충분
    let empty_list: Vec<Thing> = Vec::new();
    let items = vec![
        Thing { name: "Item1".to_string() },
        Thing { name: "Item2".to_string() },
    ];
    
    println!("빈 리스트 길이: {}", empty_list.len()); // 0
    println!("아이템 리스트 길이: {}", items.len()); // 2
    
    // 특별한 경우: Option<Vec<Thing>>으로 구분 필요
    let no_payload: Option<Vec<u8>> = None;           // 페이로드 없음 (별도 전송)
    let empty_payload: Option<Vec<u8>> = Some(vec![]); // 빈 페이로드
    let data_payload: Option<Vec<u8>> = Some(vec![1, 2, 3]); // 데이터 있음
    
    match no_payload {
        None => println!("페이로드가 제공되지 않음"),
        Some(ref data) if data.is_empty() => println!("빈 페이로드"),
        Some(ref data) => println!("데이터: {:?}", data),
    }
    
    match empty_payload {
        None => println!("페이로드가 제공되지 않음"),
        Some(ref data) if data.is_empty() => println!("빈 페이로드"),
        Some(ref data) => println!("데이터: {:?}", data),
    }
}

하지만 이런 두 경우를 Option<Vec<Thing>> 으로 구분해야 할 상황은 드물지만 분명히 있다. 예를 들어 암호화 시스템에서 페이로드가 별도로 전송되는 경우와 빈 페이로드가 제공되는 경우를 분명히 구분해야 한다.

SQL Column 에 대한 Null 마커 사용 여부를 둘러싼 논쟁

Result<T, E>

두번째 enum 타입은 오류 처리에서 흔히 사용되는 Result 다. 호출한 함수가 실패할 경우 그 실패를 어떻게 전달해야 할까? 이전에는 특수 센티넬 값, 예를 들면 리눅스에서는 시스템 콜의 -errno 또는 글로벌 변수인 POSIX 시스템의 errno 를 사용했다. 최근에는 다중 반환값 또는 튜플 반환값을 지원하는 언어인 Go 언어의 경우 (result, error) 쌍을 반환하는 관례를 따른다.

여기서는 error 가 0이 아니라면 Result 에 0에 해당하는 적절한 값이 들어간다고 가정한다.

 

바로 이런 경우에 러스트의 enum 을 사용하면 된다. 실패할 수 있는 연산 결과는 항상 Result<T, E> 로 표현한다. 여기서 T 타입은 Ok 배리언트에 성공 결과를 담고 E 타입은 Err 배리언트에 실패했을 때의 세부 오류 정보를 담는다. 이처럼 표준 타입을 사용하면 설계 의도를 명확히 드러낼 수 있다. 또한 표준 변환과 오류 처리를 사용할 수 있으므로 ? 연산자로 오류 처리를 간소화할 수 있다.