6. 구조체와 열거형으로 데이터 모델링하기
Rust를 배우다 보면 어느 순간 이런 생각이 듭니다. "문법은 조금 알겠는데, 실제 데이터를 어떻게 설계하지?" 바로 이 지점에서 구조체(struct)와 열거형(enum)이 등장합니다.
변수와 함수만으로도 작은 예제는 만들 수 있습니다. 하지만 프로그램이 조금만 커지면, 관련 있는 값을 묶고, 가능한 상태를 제한하고, 이상한 값을 애초에 못 넣게 만드는 일이 훨씬 중요해집니다. Rust는 이걸 아주 진지하게 다룹니다. 데이터를 잘 설계하면, 코드가 덜 흔들리고, 버그도 줄고, 읽기도 쉬워집니다.
이 장에서는 Rust답게 현실의 데이터를 표현하는 법을 배웁니다. 핵심은 단순합니다. 여러 속성을 함께 묶고 싶으면 struct, 여러 경우 중 하나를 표현하고 싶으면 enum을 사용합니다. 그리고 impl로 그 데이터에 어울리는 동작을 붙입니다.
왜 데이터 모델링이 중요한가
예를 들어 할 일 관리 앱을 만든다고 해봅시다. 할 일 하나에는 제목, 설명, 마감일, 완료 여부 같은 정보가 있습니다. 이걸 각각 따로 관리하면 금방 엉망이 됩니다.
let title = String::from("Rust 공부");
let description = String::from("구조체와 열거형 정리하기");
let done = false;지금은 괜찮아 보이지만, 할 일이 100개가 되면 어떨까요? 제목과 완료 상태가 서로 다른 할 일끼리 뒤섞이기 쉽습니다. 이럴 때 Rust는 "서로 관련된 데이터는 하나의 타입으로 묶어라"라고 말합니다.1
struct: 관련 있는 데이터를 하나로 묶기
구조체는 현실 세계의 "한 개체"를 표현할 때 아주 좋습니다. 사용자, 게시글, 상품, 좌표, 주문, 할 일 같은 것들이 전부 구조체 후보입니다.
가장 기본적인 형태부터 보겠습니다.
struct Task {
title: String,
done: bool,
}
fn main() {
let task = Task {
title: String::from("Rust 챕터 읽기"),
done: false,
};
println!("{} / 완료 여부: {}", task.title, task.done);
}Task는 title과 done을 가진 새로운 타입입니다. 이제 title 따로, done 따로 흩어놓지 않아도 됩니다. task.title, task.done처럼 점 표기법으로 접근할 수 있습니다.1
구조체의 장점은 단순히 묶는 데서 끝나지 않습니다. 이름이 생기기 때문에 코드의 의미가 선명해집니다. String 두 개와 bool 하나가 있는 것보다, Task 하나가 있다는 표현이 훨씬 읽기 쉽습니다.
구조체 인스턴스 만들기와 수정하기
구조체 값을 만들 때는 각 필드에 값을 넣습니다.
struct User {
name: String,
email: String,
active: bool,
}
fn main() {
let mut user = User {
name: String::from("지민"),
email: String::from("[email protected]"),
active: true,
};
user.email = String::from("[email protected]");
println!("{} / {}", user.name, user.email);
}중요한 점이 하나 있습니다. 필드만 따로 가변으로 만드는 것이 아니라, 구조체 인스턴스 전체를 mut로 선언해야 수정할 수 있습니다.1
구조체를 만들 때 변수 이름과 필드 이름이 같다면 더 짧게 쓸 수도 있습니다.
struct User {
name: String,
email: String,
active: bool,
}
fn create_user(name: String, email: String) -> User {
User {
name,
email,
active: true,
}
}이걸 필드 초기화 축약 문법이라고 생각하면 됩니다. name: name을 name으로 줄인 것입니다.
또 비슷한 구조체를 복사해 새 값을 만들 때는 업데이트 문법도 유용합니다.
struct User {
name: String,
email: String,
active: bool,
}
fn main() {
let user1 = User {
name: String::from("민수"),
email: String::from("[email protected]"),
active: true,
};
let user2 = User {
name: String::from("서연"),
email: String::from("[email protected]"),
..user1
};
println!("{}", user2.name);
}여기서 ..user1은 지정하지 않은 나머지 필드를 가져옵니다. 다만 String처럼 복사가 아니라 이동(move) 되는 필드는 원본을 계속 못 쓸 수 있으니 주의해야 합니다.2
struct에도 여러 모양이 있다
초보자는 보통 이름 있는 필드를 가진 구조체만 먼저 익혀도 충분합니다. 그래도 Rust에는 구조체가 세 가지 모양으로 있다는 정도는 알아두면 좋습니다.
struct User {
name: String,
age: u32,
}
struct Color(u8, u8, u8);
struct Marker;첫 번째는 가장 흔한 일반 구조체입니다. 필드 이름이 있어서 읽기 쉽습니다.
두 번째는 튜플 구조체입니다. 필드 이름은 없고 순서로 접근합니다. Color(255, 0, 0)처럼 쓸 수 있습니다. 모양은 튜플과 비슷하지만, Color라는 별도 타입이 생긴다는 점이 중요합니다. 그냥 (u8, u8, u8)보다 의미가 분명합니다.1
세 번째는 유닛 구조체입니다. 필드가 아예 없습니다. 지금 단계에서는 "데이터는 없지만 타입 이름이 필요할 때도 있구나" 정도로만 이해해도 충분합니다.1
실전에서는 대부분 일반 구조체를 가장 많이 씁니다.
impl: 데이터에 동작을 붙이기
구조체는 데이터를 담는 상자입니다. 그런데 상자만 덩그러니 있으면 조금 심심하죠. 할 일은 완료할 수 있어야 하고, 사각형은 넓이를 계산할 수 있어야 하고, 사용자 정보는 상태를 바꿀 수 있어야 합니다.
이럴 때 impl 블록을 사용합니다.
struct Task {
title: String,
done: bool,
}
impl Task {
fn mark_done(&mut self) {
self.done = true;
}
fn status(&self) -> &str {
if self.done {
"완료"
} else {
"진행 중"
}
}
}
fn main() {
let mut task = Task {
title: String::from("예제 작성"),
done: false,
};
println!("{}", task.status());
task.mark_done();
println!("{}", task.status());
}impl Task는 "Task 타입에 어울리는 기능을 여기에 모아두자"라는 뜻입니다. Rust에서 메서드는 impl 블록 안에 정의합니다.3
메서드의 첫 번째 인자로 자주 보게 되는 것이 self입니다. 이건 "이 메서드를 호출한 바로 그 값 자신"이라고 생각하면 됩니다.
&self: 읽기만 할 때 사용&mut self: 값을 바꿀 때 사용self: 값을 통째로 가져가며 소비할 때 사용
이 구분은 이전 장의 소유권, 빌림 규칙과 그대로 연결됩니다. Rust는 메서드에서도 그 규칙을 절대 잊지 않습니다. 그래서 코드가 조금 엄격해 보여도, 나중에는 "아, 이 함수가 읽기만 하는지 바꾸는지 한눈에 보이네"라는 장점이 됩니다.
연관 함수: 생성자처럼 쓰는 함수
impl 안의 함수 중에는 self를 받지 않는 것도 있습니다. 이런 함수는 보통 타입명::함수명() 형태로 호출합니다. Rust에서는 이런 함수를 연관 함수(associated function) 라고 부릅니다.3
struct Task {
title: String,
done: bool,
}
impl Task {
fn new(title: String) -> Self {
Self {
title,
done: false,
}
}
}
fn main() {
let task = Task::new(String::from("구조체 복습"));
println!("{}", task.title);
}Rust에는 Java의 constructor 같은 특별한 문법이 없습니다. 대신 new라는 이름을 관례처럼 자주 씁니다.1
여기서 Self는 지금 impl하고 있는 타입, 즉 Task를 가리킵니다. 그래서 Task 대신 Self를 써도 됩니다.
enum: 여러 경우 중 하나를 표현하기
이제 구조체의 친구이자 라이벌 같은 존재, enum을 봅시다.
구조체가 "이 데이터는 이런 필드를 가진다"를 표현한다면, 열거형은 "이 값은 가능한 여러 경우 중 정확히 하나다"를 표현합니다.4
예를 들어 네트워크 주소가 IPv4이거나 IPv6이거나 둘 중 하나라는 개념은 enum으로 표현하기 좋습니다. Rust 공식 문서도 바로 이런 예를 사용합니다.4
아주 단순한 enum은 이렇게 생깁니다.
enum Priority {
Low,
Medium,
High,
}Priority 타입의 값은 Low, Medium, High 중 하나만 될 수 있습니다. Low이면서 동시에 High일 수는 없습니다. 이게 enum의 핵심입니다.
이런 설계의 장점은 엄청 큽니다. 문자열로 우선순위를 저장하면 "high", "HIGH", "hihg" 같은 오타가 다 들어갈 수 있습니다. 하지만 enum을 쓰면 가능한 경우가 타입 수준에서 고정됩니다.
enum은 값도 함께 담을 수 있다
Rust의 enum은 특히 강력합니다. 많은 언어의 enum은 단순히 이름 목록에 가깝지만, Rust enum의 각 변형(variant)은 데이터를 함께 담을 수 있습니다.45
enum TaskStatus {
Todo,
InProgress { percent: u8 },
Blocked(String),
Done { finished_at: String },
}이 enum은 네 가지 상태를 가집니다.
Todo: 아직 시작 안 함InProgress { percent: u8 }: 진행률 포함Blocked(String): 막힌 이유 포함Done { finished_at: String }: 완료 시점 포함
이제 TaskStatus 하나만 봐도 상태에 따라 어떤 데이터가 필요한지 자연스럽게 드러납니다. "진행 중이면 퍼센트가 있어야 하고, 막혔으면 이유가 있어야 한다"는 규칙이 타입 안에 녹아 있는 셈입니다.
이게 바로 Rust다운 데이터 모델링입니다. 잘못된 상태를 만들기 어렵게 설계하는 것입니다.
enum 값 만들기
enum의 변형은 이름공간::변형명 형태로 사용합니다.
enum Priority {
Low,
Medium,
High,
}
fn main() {
let p1 = Priority::Low;
let p2 = Priority::High;
}데이터를 가진 변형도 만들 수 있습니다.
enum TaskStatus {
Todo,
InProgress { percent: u8 },
Blocked(String),
}
fn main() {
let s1 = TaskStatus::Todo;
let s2 = TaskStatus::InProgress { percent: 40 };
let s3 = TaskStatus::Blocked(String::from("인터넷 연결 문제"));
}열거형 변형은 마치 생성 함수처럼 보일 때가 많습니다. 실제로 TaskStatus::Blocked(...) 같은 형태는 해당 enum 값을 만들어 주는 통로라고 생각해도 좋습니다.4
struct와 enum을 함께 쓰면 진짜 실전이 시작된다
보통 프로그램은 구조체만으로도, enum만으로도 끝나지 않습니다. 둘을 함께 써야 현실을 잘 표현할 수 있습니다.
할 일 앱을 조금 더 그럴듯하게 만들어 보겠습니다.
#[derive(Debug)]
enum Priority {
Low,
Medium,
High,
}
#[derive(Debug)]
enum TaskStatus {
Todo,
InProgress { percent: u8 },
Blocked(String),
Done,
}
#[derive(Debug)]
struct Task {
title: String,
priority: Priority,
status: TaskStatus,
}이 설계는 꽤 마음에 듭니다.
Task는 하나의 할 일이고, 그 안에는 제목이 있습니다. 우선순위는 Priority enum으로 제한됩니다. 상태는 TaskStatus enum으로 제한됩니다. 즉, 문자열 몇 개를 마구 넣는 방식보다 훨씬 단단합니다.
이제 동작도 붙여봅시다.
impl Task {
fn new(title: String, priority: Priority) -> Self {
Self {
title,
priority,
status: TaskStatus::Todo,
}
}
fn start(&mut self) {
self.status = TaskStatus::InProgress { percent: 0 };
}
fn set_progress(&mut self, percent: u8) {
self.status = TaskStatus::InProgress { percent };
}
fn block(&mut self, reason: String) {
self.status = TaskStatus::Blocked(reason);
}
fn complete(&mut self) {
self.status = TaskStatus::Done;
}
fn summary(&self) -> String {
let status_text = match &self.status {
TaskStatus::Todo => String::from("시작 전"),
TaskStatus::InProgress { percent } => format!("진행 중 ({}%)", percent),
TaskStatus::Blocked(reason) => format!("중단됨: {}", reason),
TaskStatus::Done => String::from("완료"),
};
format!("[{:?}] {} - {}", self.priority, self.title, status_text)
}
}
fn main() {
let mut task = Task::new(String::from("enum 정리하기"), Priority::High);
println!("{}", task.summary());
task.start();
task.set_progress(60);
println!("{}", task.summary());
task.block(String::from("커피가 부족함"));
println!("{}", task.summary());
}여기서 match는 enum을 다룰 때 거의 세트 메뉴처럼 따라옵니다. enum 값이 어떤 변형인지 확인하고, 그 안의 데이터까지 꺼내 쓸 수 있기 때문입니다. 이 장에서는 "enum을 사용할 때 match가 자주 필요하다" 정도만 익혀두면 충분합니다. 패턴 매칭은 뒤 장에서 더 깊게 다룹니다.
enum에도 impl을 붙일 수 있다
많은 초보자가 구조체에만 메서드를 붙일 수 있다고 생각하는데, enum에도 가능합니다.6
#[derive(Debug)]
enum Priority {
Low,
Medium,
High,
}
impl Priority {
fn label(&self) -> &str {
match self {
Priority::Low => "낮음",
Priority::Medium => "보통",
Priority::High => "높음",
}
}
}
fn main() {
let p = Priority::High;
println!("{}", p.label());
}이렇게 하면 데이터와 관련 로직이 가까이 모여 있어 읽기 쉬워집니다. Priority를 해석하는 규칙이 여기저기 흩어지지 않는 것도 큰 장점입니다.
언제 struct를 쓰고, 언제 enum을 쓸까
처음에는 이 둘이 헷갈릴 수 있습니다. 가장 쉬운 기준은 아래와 같습니다.
구조체는 동시에 여러 속성을 가져야 하는 대상을 표현할 때 씁니다. 예를 들면 사람, 상품, 게시글, 주문, 좌표 같은 것들입니다.
열거형은 여러 가능한 경우 중 하나만 선택되는 값을 표현할 때 씁니다. 예를 들면 상태, 방향, 우선순위, 메시지 종류, 결제 방식 같은 것들입니다.
조금 더 직관적으로 말하면 이렇습니다.
"이것은 무엇을 가지고 있나?"에 답하면 struct
"이것은 어떤 경우 중 하나인가?"에 답하면 enum
예를 들어 Task는 제목도 있고 상태도 있고 우선순위도 있으니 struct입니다. 반면 TaskStatus는 시작 전이거나, 진행 중이거나, 막혔거나, 완료이므로 enum입니다.
좋은 데이터 모델은 이상한 상태를 막는다
Rust에서 데이터 모델링을 잘한다는 것은 단순히 보기 좋은 타입 이름을 만드는 일이 아닙니다. 말이 안 되는 상태를 미리 차단하는 일에 가깝습니다.
예를 들어 이런 설계는 아쉽습니다.
struct Task {
title: String,
done: bool,
progress: u8,
}겉보기에 괜찮아 보여도 문제가 있습니다. done = true인데 progress = 20이면 이건 완료인가요, 진행 중인가요? 사람마다 해석이 달라질 수 있습니다.
반대로 enum을 쓰면 이런 모순을 줄일 수 있습니다.
enum TaskStatus {
Todo,
InProgress { percent: u8 },
Done,
}
struct Task {
title: String,
status: TaskStatus,
}이제 Done인데 동시에 percent = 20인 상태는 만들 수 없습니다. 타입 설계가 문제를 미리 막아 주기 때문입니다. Rust가 안전한 언어라는 말은 메모리 안전성만 뜻하는 것이 아닙니다. 도메인 모델도 더 안전하게 만들 수 있다는 뜻입니다.
초보자가 자주 하는 실수
처음 struct와 enum을 배울 때 흔히 하는 실수가 있습니다.
상태를 전부
String으로 저장하기서로 관련 있는 값을 구조체로 묶지 않고 따로따로 들고 다니기
메서드가 값을 바꾸는데도
&self로 선언하기반대로 읽기만 하는데
self를 써서 소유권을 불필요하게 넘겨버리기
예를 들어 상태를 String으로 저장하면 오타와 잘못된 값이 쉽게 들어옵니다. "done", "Done", "finished"가 모두 다른 값이 되어 버릴 수 있습니다. 이런 경우 enum이 훨씬 낫습니다.
또 메서드 선언에서 self를 잘못 선택하면 "왜 이 값을 다시 못 쓰지?" 같은 에러를 만나게 됩니다. 그럴 땐 메서드가 읽기만 하는지, 수정하는지, 아예 소비하는지 먼저 생각해 보세요.
디버깅할 때는 Debug 파생을 붙여보자
구조체나 enum을 빠르게 확인하고 싶을 때는 #[derive(Debug)]를 붙이는 습관이 유용합니다. 그러면 println!("{:?}", value);로 값을 출력할 수 있습니다.1
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 3, y: 4 };
println!("{:?}", p);
}초보자에게 이건 작은 손전등 같은 기능입니다. 값이 지금 어떻게 생겼는지 확인하는 데 아주 도움이 됩니다.
정리: 좋은 Rust 코드는 좋은 타입에서 시작한다
이 장의 핵심을 한 문장으로 정리하면 이렇습니다.
Rust에서는 로직보다 먼저 데이터를 잘 설계하는 것이 중요하다.
구조체는 관련 있는 데이터를 하나로 묶습니다. 열거형은 가능한 상태를 제한합니다. impl은 그 데이터에 어울리는 동작을 붙입니다. 이 셋이 합쳐지면, 코드가 단순히 "작동하는 코드"를 넘어서 "흔들리지 않는 코드"가 됩니다.
지금은 예제가 작아 보여도, 나중에 웹 서버를 만들든 CLI 도구를 만들든 게임을 만들든 결국 계속 같은 질문을 하게 됩니다.
"이 데이터를 어떤 타입으로 표현해야 가장 자연스럽고, 가장 안전할까?"
이 질문에 잘 답할수록 Rust 실력이 빠르게 늘어납니다.
다음 장에서는 enum의 아주 유명한 실전 주인공인 Option과 Result를 만나게 됩니다. "값이 없을 수도 있다", "실패할 수도 있다"를 타입으로 표현하는 법을 배우면, Rust가 왜 버그를 미리 막아 주는 언어인지 더 선명하게 느껴질 것입니다.
참고
1Structs & Enums | Luca Nerlich - Tech Documentation
2Structs & Enums | Luca Nerlich - Tech Documentation
3Keyword impl - Rust Standard Library
4Defining an Enum - The Rust Programming Language
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.