구조체와 튜플을 조합한 데이터 모델링

Rust에서 구조체(struct)와 튜플(tuple)을 조합해 복잡한 데이터 모델링을 하는 방법은, 각 자료구조의 장점을 살려 중첩(nesting)하거나, 서로 포함시켜 계층적인 구조를 만드는 것입니다. 이렇게 하면 의미 있는 필드(구조체)와 위치 기반 데이터(튜플)를 효과적으로 결합할 수 있습니다.

실제 프로젝트에서는 구조체로 주요 엔티티(예: 사용자, 상품, 센서 등)를 정의하고,
구조체의 일부 필드를 튜플로 선언해 위치, 좌표, 설정값 등 간단한 데이터를 묶어 표현하거나, 구조체로 선언해서 의미를 명확히 합니다.

1. 구조체 안에 튜플을 포함하는 예시

struct Employee {
name: String,
age: u32,
// (연, 월, 일) 생년월일을 튜플로 표현
birth_date: (u16, u8, u8),
}

fn main() {
let emp = Employee {
name: String::from("Kim"),
age: 28,
birth_date: (1997, 5, 14),
};
println!("{}의 생년월일: {}-{}-{}", emp.name, emp.birth_date.0, emp.birth_date.1, emp.birth_date.2);
}
  • 구조체는 필드의 의미를 명확히하고, 튜플은 간단한 데이터 묶음에 적합합니다.
  • Emplyee라는 구조체를 선언(정의)하면서 필드명과 형식을 지정하는데, birthdate는 생년월일의 연,월,일을 튜플 형식으로 지정한 것입니다.
  • let 문을 이용해서 Employee의 instance를 생성하고, 여기서는 emp, 출력할 때는 emp를 이용해서 emp.필드명 식으로 하면 되는데, 튜플 타입은 emp.필드명 다음에 튜플이므로 index를 붙여서 emp.birth_date.0, .1, .2식으로 표현합니다.
  • 출력값은 Kim의 생년월일: 1997-5-14입니다. 두 자릿수로 출력하려면 {:02}로 수정하면 됩니다. 두 자릿수로 출력하는데, 부족하면 0으로 채우라는 의미입니다.

2. 튜플 안에 구조체를 포함하는 예시

struct Product {
id: u32,
name: String,
}

fn main() {
// (상품, 수량) 형태로 장바구니 항목 표현
let cart_item: (Product, u32) = (
Product { id: 1, name: String::from("Book") },
3,
);
println!("{}: {}개", cart_item.0.name, cart_item.1);
}
  • 튜플로 여러 정보를 임시로 묶되, 각 요소가 구조체라면 의미를 명확히 할 수 있습니다.
  • cart_item을 튜플 형식으로 지정해서 Product와 수량을 받는데, Product를 구조체와 연결해서 id와 name으로 의미를 명확히하는 것입니다.
  • 튜플 속에 구조체가 들어있으므로 출력할 때 cart_item 다음에 인덱스를 적고, 구조체의 필드명을 적어서 표시합니다. 예) cart_item.0.id, cart_item.0.name, cart_item.1
  • 출력 결과는 ‘Book: 3개’입니다.

3. 중첩 구조체와 튜플을 활용한 복합 모델

struct Address {
city: String,
zip: String,
}

// (위도, 경도) 위치 정보를 튜플로 표현
struct Store {
name: String,
address: Address,
location: (f64, f64),
}

fn main() {
let store = Store {
name: "Rust Mart".to_string(),
address: Address {
city: "Seoul".to_string(),
zip: "12345".to_string(),
},
location: (37.5665, 126.9780),
};
println!("{} ({}, {}) - 위치: ({}, {})",
store.name, store.address.city, store.address.zip, store.location.0, store.location.1
);
}
  • Address 구조체를 정의한 다음 Address 구조체를 Store의 address 필드의 type으로 사용하고, Store의 location은 위도와 경도를 튜플 형식으로 정의했습니다.
  • 따라서, Store 구조체의 인스턴스를 만들 때도 address를 Address 구조체로 입력하고, location은 위도와 경도를 튜플 형식으로 입력했습니다.
  • 그리고, 출력할 때는 인스턴스명.필드명인데, address는 구조체이므로 다시 한번 더 필드명을 적어주었고, tuple 타입은 필드명 다음에 인덱스를 추가했습니다.
  • 출력 결과는 ‘Rust Mart (Seoul, 12345) – 위치: (37.5665, 126.978)’입니다.

4. 튜플 구조체와 일반 구조체 조합

struct Point(i32, i32, i32);

struct Sensor {
id: u32,
position: Point,
}

fn main() {
let sensor = Sensor { id: 101, position: Point(10, 20, 30) };
println!("센서 {} 위치: ({}, {}, {})", sensor.id, sensor.position.0, sensor.position.1, sensor.position.2);
}
  • 이번에는 튜플 구조체를 정의한 다음, 일반 구조체의 타입으로 사용한 예입니다.
  • 일반 구조체의 타입이 튜플이냐 아니냐만 다를 뿐 표현하는 방식은 위와 동일합니다.

이처럼 구조체와 튜플을 조합하면 복잡한 데이터도 명확하고 효율적으로 모델링할 수 있습니다.

  • 구조체는 필드의 의미와 계층 구조를,
  • 튜플은 간단한 값 묶음이나 위치 기반 데이터를 담당하게 하여,
  • 코드의 가독성과 확장성을 모두 높일 수 있습니다

5. 튜플 구조체로 타입 구분

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

fn draw_sphere(center: Point, color: Color) {
// center와 color가 같은 (i32, i32, i32) 구조지만, 타입이 달라 혼동 방지
// This function would contain logic to draw a sphere at the given center
// with the specified color.

println!("Drawing sphere at center: ({}, {}, {}) with color: ({}, {}, {})",
center.0, center.1, center.2,
color.0, color.1, color.2);
}

fn main() {
let center = Point(0, 0, 0);
let color = Color(255, 0, 0); // Red color

draw_sphere(center, color);
}
  • 위와 같이 구조체를 튜플 형식으로 지정하면 draw_sphere 함수에서 입력 타입이 구조체 형식과 맞는지 체크하는데,
  • 아래와 같이 함수의 인수를 튜플 형식으로 지정하면 둘 다 튜플 형식이기 때문에 center 자리에 Point 구조체 타입이 아닌 color 튜플을 넣어도 맞는 타입인지 체크를 못합니다.
  • 튜플 구조체(예: struct Point(i32, i32, i32);)를사용하면,
    동일한 데이터 구조라도 타입별로 구분할 수 있어 실수 방지 및 타입 안전성을 높입니다.
fn draw_sphere(center: (i32, i32, i32), color: (i32, i32, i32)) {
...
}

fn main() {
let center = (0, 0, 0);
let color = (255, 0, 0); // Red color

draw_sphere(color,center);
}

6. 함수 반환값 및 임시 데이터

함수에서 여러 값을 반환할 때 튜플을 사용하고,
이 반환값을 구조체의 필드로 저장하거나, 여러 구조체 인스턴스를 튜플로 묶어 일시적으로 처리할 수 있습니다.

fn min_max(numbers: &[i32]) -> (i32, i32) {
let min = *numbers.iter().min().unwrap();
let max = *numbers.iter().max().unwrap();
(min, max)
}

struct Stats {
min: i32,
max: i32,
}

fn main() {
let numbers = [3, 7, 2, 9, 4];
let (min, max) = min_max(&numbers);

let stats = Stats { min, max };
println!("최솟값: {}, 최댓값: {}", stats.min, stats.max);
}
  • let (min, max) = min_max(&numbers);
    => numbers 배열을 참조로 가져와서 min_max 함수를 처리한 다음 결괏값을 min, max 튜플에 넣고,
  • let stats = Stats { min, max };
    => min과 max를 Stats 구조체에 넣어 stats 인스턴스(또는 변수)를 만듭니다.
    min: min, max: max라고 쓰는 것이 정석이지만 필드명과 변수명이 같기 때문에 필드명만 적으면 됩니다.
  • 그리고, 인스턴스의 min과 max를 출력하는 것입니다.

7. 설정값, 좌표, 범위 등 불변 데이터 관리

고정된 설정값이나 좌표와 같이, 변경되지 않는 데이터는 튜플로 관리하고,
이 값을 구조체의 일부로 포함시켜 사용합니다.

struct DbConfig { 
host: String,
port: u16,
credentials: (String, String), // (username, password)
}

fn main() {
let db_config = DbConfig {
host: String::from("localhost"),
port: 5432,
credentials: (String::from("user"), String::from("password")),
};

println!("DB 호스트: {}", db_config.host);
println!("DB 포트: {}", db_config.port);
println!("DB 사용자명: {}", db_config.credentials.0);
println!("DB 비밀번호: {}", db_config.credentials.1);
}

8. 튜플과 달리 Struct는 메서드와 함께 활용

구조체에 메서드를 구현하여 데이터와 동작을 결합할 수 있습니다.
예를 들어, 2차원 평면상의 점(Point)에 대해 특정 축 위에 있는지 판별하는 메서드를 추가할 수 있습니다.

struct Point(f32, f32);

impl Point {
fn on_x_axis(&self) -> bool {
self.0 == 0.0
}
fn on_y_axis(&self) -> bool {
self.1 == 0.0
}
}

fn main() {
let point = Point(0.0, 0.0);
if point.on_x_axis() && point.on_y_axis() {
println!("원점에 있습니다.");
}
}
  • 구조체에 메서드를 추가해 객체 지향적으로 사용할 수 있습니다.
  • 구조체의 메서드를 만들려면 impl 구조체라고 명명하고, 그 안에서 함수(fn, 메소드)를 작성하는데, 첫번째 인수는 &self, 구조체 자체입니다.
  • fn on_x_axis(&self) -> bool은 구조체를 인수로 받아 bool 형식인 True, False를 반환합니다.
  • self.0 == 0.0
    => 세미콜론으로 끝나지 않으므로 반환값인 표현식으로 첫번째 튜플 값이 0.0인지 비교해서 같다면 True를 반환하고, 아니면 False를 반환하는 것입니다.
  • self.1 == 0.0는 튜플의 두번째 값이 0.0인지 비교하는 것입니다.
  • 출력값은 튜플의 값이 모두 0.0이므로 ‘원점에 있습니다.’입니다.

9. 요약

  • 튜플: 간단한 값 묶음, 여러 값 반환, 임시 데이터에 적합
  • 구조체: 명확한 의미의 데이터 구조, 필드 이름, 가독성·유지보수성 강조
  • 튜플 구조체: 같은 구조이지만 다른 의미의 타입 구분으로 타입 안전성을 강화

에러 처리 (panic, Result, Option, unwrap, expect, ? 연산자)

Rust는 안전한 시스템 프로그래밍 언어답게, 명시적이고 예측 가능한 에러 처리 방식을 제공합니다. 오늘은 Rust의 주요 에러 처리 도구인 panic, Result, Option, unwrap, expect, ? 연산자에 대해 다루겠습니다.


1. 패닉(panic!)

fn main() {
    panic!("예기치 못한 오류 발생!");
}
  • panic!를 사용하면 프로그램이 즉시 종료되고, panic! 안에 있는 메시지를 보여줍니다.
  • 디버깅 중 주로 사용되며, 복구 불가능한 에러에 적합합니다.

let v = vec![1, 2, 3];
v[99]; // 존재하지 않는 인덱스 → 자동 panic!

Vector v의 요소가 3개이고, index의 최대값이 2인데, 인덱스를 99로 지정하면 “index가 경계를 넘어섰다”는 메시지를 보여주면서 프로그램이 멈춥니다.


2. Result<T, E> 타입

Resut<T, E>는 복구 가능한 에러 처리를 위한 열거형으로, 성공했을 때는 Ok(T), 에러가 발생했을 때는 Err(E)를 반환합니다.

enum Result<T, E> {
    Ok(T),
    Err(E),
}

[예제]

use std::fs::File;

fn main() {
    let result = File::open("hello.txt");

    match result {
        Ok(file) => println!("파일 열기 성공"),
        Err(e) => println!("에러 발생: {}", e),
    }
}
  • use std::fs::File;
    : 표준 라이브러리의 fs 모듈에 정의된 File 구조체를 현재 스코프에서 사용할 수 있도록 가져오는 구문입니다. 
  • let result = File::open(“hello.txt”);
    : hello.txt 파일을 여는데, 그 결과를 result에 저장합니다. result는 Result 열거형으로 File 구조체와 Error라는 2개의 variant(변형)를 가지고 있습니다.
  • match result {
    : match 연산자를 이용해 result의 결과값에 따라 처리합니다.
  • Ok(file) => println!(“파일 열기 성공”),
    : 파일 열기가 성공(Ok)이면 File 구조체 형식의 file을 variant로 갖습니다.
  • Err(e) => println!(“에러 발생: {}”, e),
    : 파일 열기에 실패하면 Err가 발생하는데, e라는 에러 종류를 갖습니다.

프로그램과 같은 폴더에 hello.txt가 없으면
“에러 발생: 지정된 파일을 찾을 수 없습니다. (os error 2)”가 화면에 출력되고,
파일이 있으면 “파일 열기 성공”이 출력됩니다.

Ok(file) => println!(“{:?} 파일 열기 성공”, file), 라고 코드를 수정하고 실행하면, hello.txt 파일이 있을 경우 file 구조체가 출력문(println!)에 전달되어
“File { handle: 0xb4, path: “\\?\D:\rust-practice\day12\hello.txt” } 파일 열기 성공”이 출력됩니다.


3. Option<T> 타입

값이 있을 수도 있고[Some(T)] 없을 수도 있음(None)을 나타냅니다. Result의 경우는 Ok(T), Err(E)인 것과 대비됩니다.

fn main() {
    let some_number = Some(10);
    // let some_number: Option<i32> = None;

    match some_number {
        Some(n) => println!("Some: {n}"),
        None => println!("None"),
    }
}
  • let some_number = Some(10);인 상태에서 위 코드를 실행하면
    Match 제어 흐름 연산자에서 Some(n)에 해당되므로 “Some: 10″이 화면에 출력되고,
  • 첫번째 줄 let some_number = Some(10);을 주석 처리하고, 두번째 줄의 주석을 제거하면 None과 매칭되어 “None”이 하면에 출력됩니다.

4. unwrap, expect

let f = File::open("hello.txt").unwrap(); // 실패 시 panic!
let f = File::open("hello.txt").expect("파일 열기 실패"); // 사용자 메시지 포함
  • unwrap과 expect는 Some(T)이거나, Ok(T)인 경우 내부의 T를 꺼내는(unwrap) 기능을 하고, None이거나, Err(E)인 경우는 빠르게 실패(fail fast)하고 싶을 때 사용하는데, expect는 unwrap와 달리 에러 메시지를 제공합니다.

  • hello.txt가 있을 경우 println!(“{:?} 파일 열기 성공”, f}를 이용해 f를 출력해보면 둘 다 아래와 같이 f를 출력합니다.
    File { handle: 0xb4, path: “\\?\D:\rust-practice\day12\hello.txt” } 파일 열기 성공
    File { handle: 0xb8, path: “\\?\D:\rust-practice\day12\hello.txt” } 파일 열기 성공

  • 그러나, 파일이 없으면 둘다 패닉이 발생하고, 에러 메시지를 표출하는데,
    let f = File::open(“hello.txt”).unwrap();은 사용자 메시지가 없고,

  • let f = File::open(“hello.txt”).expect(“파일 열기 실패”);은 에러 메시지 전에 “파일 읽기 실패”라는 사용자 메시지를 표시하는 것만 다릅니다.

5. ? 연산자(에러 전파)

use std::fs::File;
use std::io::{self, Read};

fn read_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt");
    let mut content = String::from("This is a test file.\n");
    f.read_to_string(&mut content)?;
    Ok(content)
}

fn main() {
    match read_file() {
        Ok(content) => println!("파일 내용:\n{}", content),
        Err(e) => eprintln!("파일 읽기 오류: {}", e),
    }
}
  • Err이면, 다시 말해 hello.txt가 없을 경우, ?는 panic을 발생시키지 않고 즉시 리턴을 해서 main문의 Err 분기를 처리합니다.
  • 그러나, ?가 없으면 다음 줄의 f.read_to_string(&mut content)?;으로 넘어가는데, read_to_string 메소드가 없다고 하면서 컴파일 에러가 발생합니다.
  • Result 타입에서만 사용 가능합니다.


6. Option<T>와 Result<T, E> 비교

타입의미실패 시
Option<T>값이 있을 수도[Some(T)], 없을 수도 있음(None)None
Result<T, E>성공[Ok(T)], 또는 실패[Err(E)] 결과 포함Err(E)

둘 다 match, if let, unwrap 등을 통해 사용가능합니다.


7. 요약

도구용도설명
panic!치명적 에러프로그램 즉시 종료
Result<T, E>복구 가능한 에러성공과 실패 구분
unwrap/expect빠르게 실패간결하지만 안전하지 않음
? 연산자에러 전파Result를 간단히 처리
Option<T>존재 여부 표현값이 있을 수도 없을 수도 있음