이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.

이 섹션에서는 Rust에서 데이터를 구조화하는 방법을 학습합니다. Go 개발자에게 구조체는 익숙하겠지만, Rust의 열거형(enum)은 Go의 const/iota보다 훨씬 강력하며, 패턴 매칭은 switch문을 완전히 새로운 수준으로 끌어올립니다.


4.1 구조체 (Struct)#

구조체 정의와 인스턴스 생성#

// 구조체 정의
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

// 인스턴스 생성
fn main() {
    let user = User {
        username: String::from("alice"),
        email: String::from("alice@example.com"),
        sign_in_count: 1,
        active: true,
    };
    
    // 필드 접근
    println!("Username: {}", user.username);
}

Go struct와의 문법 비교:

// Go
type User struct {
    Username     string
    Email        string
    SignInCount  int64
    Active       bool
}

func main() {
    user := User{
        Username:    "alice",
        Email:       "alice@example.com",
        SignInCount: 1,
        Active:      true,
    }
}
// Rust
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user = User {
        username: String::from("alice"),
        email: String::from("alice@example.com"),
        sign_in_count: 1,
        active: true,
    };
}

주요 차이점:

  • Go: 대문자로 시작하면 public
  • Rust: pub 키워드로 명시
  • Go: ,로 필드 구분 (마지막 필드 후 선택)
  • Rust: ,로 필드 구분 (마지막 필드 후 권장)

필드 초기화 축약 문법#

변수 이름이 필드 이름과 같으면 축약 가능:

fn build_user(email: String, username: String) -> User {
    User {
        email,      // email: email 대신
        username,   // username: username 대신
        active: true,
        sign_in_count: 1,
    }
}

Go에서도 비슷한 축약이 가능합니다 (embedding):

// Go: 임베딩과는 다른 개념
user := User{
    Username: username,  // 축약 없음
    Email:    email,
}

구조체 업데이트 문법: .. 스프레드#

다른 인스턴스의 값을 기반으로 새 인스턴스 생성:

let user1 = User {
    username: String::from("alice"),
    email: String::from("alice@example.com"),
    sign_in_count: 1,
    active: true,
};

// user1의 나머지 필드 사용
let user2 = User {
    email: String::from("bob@example.com"),
    ..user1  // 나머지 필드는 user1에서
};

// 주의: user1.username은 이동됨!
// println!("{}", user1.username);  // 에러!
println!("{}", user1.sign_in_count);  // OK, Copy 타입

Go에서는 명시적 복사가 필요합니다:

// Go
user2 := User{
    Email:       "bob@example.com",
    Username:    user1.Username,  // 명시적
    Active:      user1.Active,
    SignInCount: user1.SignInCount,
}

튜플 구조체#

이름 없는 필드를 가진 구조체:

struct Point(i32, i32, i32);
struct Color(u8, u8, u8);

fn main() {
    let origin = Point(0, 0, 0);
    let black = Color(0, 0, 0);
    
    // 인덱스로 접근
    println!("x: {}", origin.0);
    
    // 구조 분해
    let Point(x, y, z) = origin;
    
    // 타입이 다르므로 혼용 불가
    // let p: Point = black;  // 에러!
}

Go에는 직접적인 대응이 없습니다. 타입 별칭과 비슷하지만 더 강력합니다:

// Go: 타입 별칭은 같은 타입
type Point [3]int
type Color [3]int

// 구분되지 않음...
var p Point = Color{0, 0, 0}  // 가능

유닛 구조체#

필드가 없는 구조체:

struct Marker;

fn main() {
    let m = Marker;
    // 트레이트 구현에 유용
}

가시성과 pub 키워드#

// lib.rs
pub struct User {
    pub username: String,  // public 필드
    email: String,         // private 필드
    pub(crate) internal: String,  // 크레이트 내부에서만
}

// 생성자 필요 (private 필드 때문)
impl User {
    pub fn new(username: String, email: String) -> Self {
        User {
            username,
            email,
            internal: String::new(),
        }
    }
}

Go와 비교:

// Go: 대문자 = public, 소문자 = private
type User struct {
    Username string  // public
    email    string  // private
}

4.2 메서드와 연관 함수#

impl 블록#

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // 메서드 (self 받음)
    fn area(&self) -> u32 {
        self.width * self.height
    }
    
    // 가변 메서드
    fn double(&mut self) {
        self.width *= 2;
        self.height *= 2;
    }
    
    // 소유권 가져가는 메서드
    fn destroy(self) -> (u32, u32) {
        (self.width, self.height)
    }
}

fn main() {
    let mut rect = Rectangle { width: 30, height: 50 };
    
    println!("Area: {}", rect.area());
    
    rect.double();
    println!("Area after double: {}", rect.area());
    
    let (w, h) = rect.destroy();
    // rect은 더 이상 사용 불가
}

self, &self, &mut self#

시그니처 의미 사용 시점
self 소유권 가져감 인스턴스 변환/소비
&self 불변 빌림 읽기 전용
&mut self 가변 빌림 수정 필요

Go 리시버와 비교:

// Go: 값 리시버 vs 포인터 리시버
func (r Rectangle) Area() int {
    return r.Width * r.Height
}

func (r *Rectangle) Double() {
    r.Width *= 2
    r.Height *= 2
}
// Rust
impl Rectangle {
    fn area(&self) -> u32 {  // Go의 값 리시버와 유사
        self.width * self.height
    }
    
    fn double(&mut self) {  // Go의 포인터 리시버와 유사
        self.width *= 2;
        self.height *= 2;
    }
}

연관 함수 (Associated Functions)#

self를 받지 않는 함수. Go의 생성자 패턴과 유사:

impl Rectangle {
    // 연관 함수 (생성자)
    fn new(width: u32, height: u32) -> Self {
        Rectangle { width, height }
    }
    
    fn square(size: u32) -> Self {
        Rectangle {
            width: size,
            height: size,
        }
    }
}

fn main() {
    // :: 로 호출
    let rect = Rectangle::new(30, 50);
    let square = Rectangle::square(10);
}

Go에서의 생성자:

// Go: 관례적인 New 함수
func NewRectangle(width, height int) *Rectangle {
    return &Rectangle{Width: width, Height: height}
}

여러 impl 블록#

같은 타입에 여러 impl 블록 가능:

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn perimeter(&self) -> u32 {
        2 * (self.width + self.height)
    }
}

// 트레이트 구현도 별도 블록
impl std::fmt::Display for Rectangle {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{}x{}", self.width, self.height)
    }
}

4.3 열거형 (Enum)#

기본 열거형#

enum Direction {
    North,
    South,
    East,
    West,
}

fn main() {
    let dir = Direction::North;
    
    match dir {
        Direction::North => println!("Going north"),
        Direction::South => println!("Going south"),
        Direction::East => println!("Going east"),
        Direction::West => println!("Going west"),
    }
}

Go의 const + iota vs Rust enum#

// Go: const와 iota
type Direction int

const (
    North Direction = iota
    South
    East
    West
)

// 타입 안전성 약함
var d Direction = 100  // 유효하지 않은 값이지만 컴파일됨
// Rust: 열거형
enum Direction {
    North,
    South,
    East,
    West,
}

// 타입 안전
// let d: Direction = 100;  // 컴파일 에러!

데이터를 담는 열거형#

Go에 없는 매우 강력한 기능!

enum Message {
    Quit,                        // 데이터 없음
    Move { x: i32, y: i32 },     // 익명 구조체
    Write(String),               // 단일 값
    ChangeColor(u8, u8, u8),     // 튜플
}

fn main() {
    let msg1 = Message::Quit;
    let msg2 = Message::Move { x: 10, y: 20 };
    let msg3 = Message::Write(String::from("hello"));
    let msg4 = Message::ChangeColor(255, 0, 0);
    
    process_message(msg2);
}

fn process_message(msg: Message) {
    match msg {
        Message::Quit => println!("Quit"),
        Message::Move { x, y } => println!("Move to ({}, {})", x, y),
        Message::Write(text) => println!("Write: {}", text),
        Message::ChangeColor(r, g, b) => {
            println!("Color: rgb({}, {}, {})", r, g, b)
        }
    }
}

Go에서 비슷한 패턴을 구현하려면:

// Go: 인터페이스와 구조체 조합 필요
type Message interface {
    isMessage()
}

type Quit struct{}
func (Quit) isMessage() {}

type Move struct {
    X, Y int
}
func (Move) isMessage() {}

type Write struct {
    Text string
}
func (Write) isMessage() {}

// 타입 스위치로 처리
func processMessage(msg Message) {
    switch m := msg.(type) {
    case Quit:
        fmt.Println("Quit")
    case Move:
        fmt.Printf("Move to (%d, %d)\n", m.X, m.Y)
    case Write:
        fmt.Println("Write:", m.Text)
    }
}

Option: null 없는 세상#

Rust에는 null이 없습니다. 대신 Option<T>를 사용합니다:

enum Option<T> {
    Some(T),  // 값이 있음
    None,     // 값이 없음
}
fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some(String::from("Alice"))
    } else {
        None
    }
}

fn main() {
    let user = find_user(1);
    
    // Option은 반드시 처리해야 함
    match user {
        Some(name) => println!("Found: {}", name),
        None => println!("Not found"),
    }
    
    // 또는 if let
    if let Some(name) = find_user(2) {
        println!("Found: {}", name);
    } else {
        println!("Not found");
    }
    
    // 메서드 활용
    let name = find_user(1).unwrap_or(String::from("Unknown"));
}

Go의 nil과 비교:

// Go: nil 검사 필요
func findUser(id int) *User {
    if id == 1 {
        return &User{Name: "Alice"}
    }
    return nil
}

func main() {
    user := findUser(1)
    if user != nil {  // nil 검사
        fmt.Println(user.Name)
    }
    
    // nil 검사 없이 접근하면 패닉
    // user := findUser(2)
    // fmt.Println(user.Name)  // 런타임 패닉!
}
// Rust: 컴파일러가 강제
fn find_user(id: u32) -> Option<User> {
    if id == 1 {
        Some(User { name: String::from("Alice") })
    } else {
        None
    }
}

fn main() {
    let user = find_user(1);
    
    // 컴파일 에러! Option에 직접 접근 불가
    // println!("{}", user.name);
    
    // 반드시 처리
    if let Some(u) = user {
        println!("{}", u.name);
    }
}

Result<T, E>: 에러 처리의 새로운 패러다임#

enum Result<T, E> {
    Ok(T),   // 성공
    Err(E),  // 실패
}
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("division by zero"))
    } else {
        Ok(a / b)
    }
}

fn main() {
    match divide(10, 2) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }
    
    // ? 연산자로 전파
    fn calculate() -> Result<i32, String> {
        let a = divide(10, 2)?;  // 에러면 조기 반환
        let b = divide(a, 3)?;
        Ok(b)
    }
}

Go의 (value, error) 반환과 비교:

// Go
func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 2)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}
측면 Go Rust
에러 무시 가능 (result, _ := ...) 컴파일 경고/에러
타입 (T, error) 관례 Result<T, E> 타입
전파 if err != nil { return } ? 연산자
강제성 관례에 의존 컴파일러가 강제

4.4 패턴 매칭#

match 표현식#

fn main() {
    let number = 13;
    
    match number {
        1 => println!("One"),
        2 | 3 | 5 | 7 | 11 | 13 => println!("Prime"),
        13..=19 => println!("Teen"),  // 범위
        _ => println!("Other"),       // 기본
    }
}

Go switch와의 비교#

Exhaustive 검사 (모든 경우 처리 필수):

// Go: 기본값 없어도 컴파일됨
type Direction int
const (
    North Direction = iota
    South
    East
    West
)

func describe(d Direction) string {
    switch d {
    case North:
        return "north"
    case South:
        return "south"
    // East, West 누락해도 컴파일됨
    default:
        return "unknown"
    }
}
// Rust: 모든 경우 처리 필수
enum Direction { North, South, East, West }

fn describe(d: Direction) -> &'static str {
    match d {
        Direction::North => "north",
        Direction::South => "south",
        // 컴파일 에러! East, West 미처리
    }
}

// 올바른 버전
fn describe(d: Direction) -> &'static str {
    match d {
        Direction::North => "north",
        Direction::South => "south",
        Direction::East => "east",
        Direction::West => "west",
    }
}

표현식으로 사용:

let description = match direction {
    Direction::North => "going up",
    Direction::South => "going down",
    _ => "going sideways",
};

if let: 단일 패턴 매칭#

let some_value = Some(42);

// match
match some_value {
    Some(x) => println!("Got: {}", x),
    None => (),
}

// if let (더 간결)
if let Some(x) = some_value {
    println!("Got: {}", x);
}

// else와 결합
if let Some(x) = some_value {
    println!("Got: {}", x);
} else {
    println!("Got nothing");
}

while let: 반복 패턴 매칭#

let mut stack = vec![1, 2, 3];

// pop()이 Some을 반환하는 동안 반복
while let Some(top) = stack.pop() {
    println!("{}", top);  // 3, 2, 1
}

패턴 문법 총정리#

리터럴 매칭:

match x {
    1 => println!("one"),
    2 => println!("two"),
    _ => println!("other"),
}

변수 바인딩:

match x {
    n => println!("got {}", n),  // n에 바인딩
}

다중 패턴:

match x {
    1 | 2 | 3 => println!("one, two, or three"),
    _ => println!("other"),
}

범위:

match x {
    1..=5 => println!("1 to 5"),
    _ => println!("other"),
}

match c {
    'a'..='z' => println!("lowercase"),
    'A'..='Z' => println!("uppercase"),
    _ => println!("other"),
}

구조 분해:

struct Point { x: i32, y: i32 }

let p = Point { x: 0, y: 7 };

match p {
    Point { x: 0, y } => println!("on y-axis at {}", y),
    Point { x, y: 0 } => println!("on x-axis at {}", x),
    Point { x, y } => println!("at ({}, {})", x, y),
}

// 열거형 구조 분해
enum Message {
    Move { x: i32, y: i32 },
    Write(String),
}

match msg {
    Message::Move { x, y } => println!("Move to ({}, {})", x, y),
    Message::Write(text) => println!("Write: {}", text),
}

가드 조건:

match num {
    x if x < 0 => println!("negative"),
    x if x > 0 => println!("positive"),
    _ => println!("zero"),
}

@ 바인딩:

match age {
    n @ 0..=12 => println!("child aged {}", n),
    n @ 13..=19 => println!("teen aged {}", n),
    n => println!("adult aged {}", n),
}

_ 와일드카드:

match tuple {
    (_, y, _) => println!("y = {}", y),  // x, z 무시
}

.. 나머지 무시:

struct Point3D { x: i32, y: i32, z: i32 }

match point {
    Point3D { y, .. } => println!("y = {}", y),  // x, z 무시
}

match numbers {
    [first, .., last] => println!("{} to {}", first, last),
    _ => (),
}

4.5 실전 패턴: 타입으로 상태 표현하기#

상태 머신 패턴#

// 상태를 타입으로 표현
enum ConnectionState {
    Disconnected,
    Connecting { address: String },
    Connected { socket: TcpSocket },
    Error { message: String },
}

struct Connection {
    state: ConnectionState,
}

impl Connection {
    fn new() -> Self {
        Connection {
            state: ConnectionState::Disconnected,
        }
    }
    
    fn connect(&mut self, address: &str) {
        match &self.state {
            ConnectionState::Disconnected => {
                self.state = ConnectionState::Connecting {
                    address: address.to_string(),
                };
            }
            _ => println!("Already connecting or connected"),
        }
    }
    
    fn handle_event(&mut self, event: Event) {
        self.state = match std::mem::take(&mut self.state) {
            ConnectionState::Connecting { address } => {
                match event {
                    Event::Connected(socket) => {
                        ConnectionState::Connected { socket }
                    }
                    Event::Error(msg) => {
                        ConnectionState::Error { message: msg }
                    }
                    _ => ConnectionState::Connecting { address },
                }
            }
            other => other,
        };
    }
}

Go interface vs Rust enum: 선택 기준#

Rust enum을 선택해야 할 때:

  • 가능한 변형이 고정되어 있고 알려져 있음
  • 모든 변형을 한 곳에서 처리해야 함
  • 데이터 타입이 변형마다 다름

Go interface (Rust trait 객체)를 선택해야 할 때:

  • 확장 가능해야 함 (새 타입 추가)
  • 다른 패키지/크레이트에서 구현 가능해야 함
  • 동적 디스패치가 필요함
// enum: 닫힌 타입, 모든 변형 알려짐
enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
        Shape::Triangle { base, height } => base * height / 2.0,
    }
}

// trait: 열린 타입, 확장 가능
trait Shape {
    fn area(&self) -> f64;
}

struct Circle { radius: f64 }
impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

// 새 타입 추가 가능
struct Hexagon { side: f64 }
impl Shape for Hexagon {
    fn area(&self) -> f64 {
        // ...
    }
}

newtype 패턴: 타입 안전성 강화#

// 단순 타입 별칭 (약한 타입 안전성)
type Meters = f64;
type Seconds = f64;

fn speed(distance: Meters, time: Seconds) -> f64 {
    distance / time
}

let d: Meters = 100.0;
let t: Seconds = 10.0;
speed(t, d);  // 컴파일됨! 잘못된 순서

// newtype 패턴 (강한 타입 안전성)
struct Meters(f64);
struct Seconds(f64);

fn speed(distance: Meters, time: Seconds) -> f64 {
    distance.0 / time.0
}

let d = Meters(100.0);
let t = Seconds(10.0);
// speed(t, d);  // 컴파일 에러!
speed(d, t);  // OK
// 실전 예: ID 타입
struct UserId(u64);
struct OrderId(u64);

fn get_user(id: UserId) -> User { /* ... */ }
fn get_order(id: OrderId) -> Order { /* ... */ }

let user_id = UserId(42);
let order_id = OrderId(42);

get_user(user_id);   // OK
// get_user(order_id);  // 컴파일 에러! 타입 불일치

4.6 요약#

구조체#

측면 Go Rust
정의 type Name struct {} struct Name {}
가시성 대문자 = public pub 키워드
메서드 func (r *T) Method() impl T { fn method(&self) }
생성자 NewT() 관례 T::new() 연관 함수
업데이트 명시적 복사 ..other 스프레드

열거형#

측면 Go Rust
정의 const + iota enum Name {}
데이터 포함 불가 가능
타입 안전성 약함 강함
null 처리 nil Option<T>
에러 처리 (T, error) Result<T, E>

패턴 매칭#

측면 Go switch Rust match
완전성 검사 없음 있음 (필수)
표현식 아니오
구조 분해 제한적 풍부함
가드 조건 없음 있음

연습 문제#

  1. 구조체: Book 구조체를 정의하고 new, description 메서드를 구현하세요.

  2. 열거형: 웹 요청 결과를 나타내는 Response 열거형을 정의하세요:

    • Success { data: String, status: u16 }
    • Error { message: String, status: u16 }
    • Loading
  3. 패턴 매칭: 위 Response 열거형을 처리하는 함수를 작성하세요.

  4. Option 활용: 정수 벡터에서 첫 번째 짝수를 찾는 함수를 작성하세요.

    fn first_even(numbers: &[i32]) -> Option<i32>
    
  5. newtype 패턴: Email 타입을 정의하고, 유효성 검사를 포함한 생성자를 만드세요.

  6. 상태 머신: 주문 상태(Pending, Paid, Shipped, Delivered, Cancelled)를 열거형으로 모델링하고, 상태 전이 메서드를 구현하세요.