점심 무렵부터 러스트의 기본을 만져 보며 소유권과 빌림(참조), 구조체와 열거형, self 수신자, 모듈과 공개/비공개 등 핵심 개념을 간단한 예제로 정리한 기록.
TLDR; 오늘은 회사에 하루 휴가를 냈지만, 대부분의 시간은 결국 출장비 정산을 했다. 우선순위란 참… 전적으로 내 탓이다. 😄
점심때쯤 되면 뇌에 윤활유가 도는지, 뭔가 다른 걸 해보고 싶어졌다. 그래서 러스트의 기본을 조금 탐색해 봤다. 어떤 친구가 그랬다. 누군가 영향력 있는 사람이(누군지는 모르겠지만) “빌리언”을 계속 외치면 사람들은 어느새 빌리언을 믿게 된다고. 어쨌든 내 유튜브 피드에 홀려서 한 번 파보기로 했다. 대체 러스트가 뭔데? 그리고 본론으로 들어가기 전에, 난 프로그래밍 언어와 문제 해결을 좋아한다. 한 언어에 매달리는 타입은 아니다. 다 좋아한다. MS Access만 빼고(이런).
설치: 자바에서 sdkman 같은 걸 쓰듯이, Rustup은 시작하기 좋은 훌륭한 도구다.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 새 프로젝트 시작
cargo new rust_example
# 실행하기
cd rust_example && cargo run
자바에서는 객체가 GC(가비지 컬렉터)로 관리된다. 누구나 필요하면서도 싫어하는 존재랄까(개인적인 의견). 그리고 이는 우리가 주로 “참조”를 다룬다는 의미이기도 하다. 참조의 좋은 점은 멋진 객체 모델을 쉽게 만들 수 있다는 것. 덜 좋은 점은 그걸 관리하고 메모리를 잘 쓰게 하는 일이다. 그래서 “가비지 컬렉터 👽”가 있다. 자바가 참조/객체를 접근성 측면에서 가져왔다면, 러스트는 다른 접근을 택한다. 바로 “항상 오직 하나의 소유자만 존재한다”는 규칙이다. 예를 들어, String(자바의 StringBuilder와 비슷)을 다루는 아래 코드를 보자.
let s1 = String::from("hello"); // s1이 String 데이터를 소유한다
let s2 = s1; // 소유권이 s1에서 s2로 이동(MOVE)한다.
println!("s1: {}", s1); // 컴파일 오류: `s1`의 값이 이동되었다.
println!("s2: {}", s2); // 이제 s2가 유효한 소유자다.
자바에서는 s1과 s2가 같은 참조를 가리킬 것이다. 하지만 러스트에서는 한 번 참조가 새로운 ‘소유자’를 갖게 되면, 기존 것은 사용할 수 없다.
요약하자면: 자바에서 객체를 전달하면 참조를 전달하는 것이다. 코드의 여러 부분이 같은 객체에 대한 참조를 가질 수 있다. 러스트는 다르다. 러스트의 각 값에는 소유자가 있고, 한 번에 단 하나만 존재한다. 소유자가 스코프를 벗어나면 그 값은 소멸(drop)된다(메모리가 해제됨).
흥미로운 개념이다. 충분히 설득력이 있어 보인다. 더 큰 프로젝트에서 직접 배우고 실험해 봐야 할 듯.
자, 이 시점에서 모든 것에는 소유자가 있다. 납득. 하지만 프로젝트에서는 자체 데이터 타입이나 헬퍼 함수를 정의하다 보면 참조를 공유해야 한다. 러스트에서 참조는 포인터와 비슷하지만 “유효한 데이터”를 가리킨다는 보장이 있다. 즉, “그 데이터에는 소유자가 있다”는 뜻이라고 이해했다. 기본적으로 모든 참조는 명시적으로 지정하지 않는 한 불변이다. 그리고 한 번에 하나의 소유자만 존재할 수 있으므로, 가변 참조(&mut T)는 동시에 오직 하나만, 불변 참조(&T)는 여러 개를 가질 수 있다. 예를 들어:
// 이 함수는 String에 대한 불변 참조를 받는다.
// 소유권은 가져가지 않는다.
fn calculate_length(some_string: &String) -> usize {
some_string.len()
} // `some_string`은 스코프를 벗어나지만, 가리키는 데이터는 drop되지 않는다.
// 이 함수는 가변 참조를 받는다.
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
가변 참조
(&mut T)는 한 번에 하나만, 불변 참조(&T)는 여러 개를 가질 수 있다. 단, 둘을 동시에 가질 수는 없다. 이것이 러스트의 핵심 안전성 보장 중 하나다.
대부분의 애플리케이션은 어느 정도의 로직과 데이터 타입이 필요하다. 러스트에서는 예를 들어 구조체를 이렇게 정의할 수 있다:
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
자바의 필드만 있는 클래스와 비슷하다. 본질적으로 구조체는 키-값 쌍처럼 동작한다. 값은 다음과 같이 사용하거나 할당할 수 있다.
fn main() {
let mut user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
user1.email = String::from("anotheremail@example.com");
println!("사용자 이메일: {}", user1.email);
}
러스트에도 열거형(enum)이 있지만 자바와는 꽤 다르다. 열거형은 여러 가능한 “변형(variant)” 중 하나가 될 수 있는 타입이다. 가장 중요한 enum은 아마도 null 가능성을 다루는 Option<T>일 것이다. 자바의 Enum이 고정된 상수 집합이라면, 러스트의 enum은 더 복잡한 데이터도 담을 수 있어 더 유연하다. 덕분에 패턴 매칭도 강력해진다. 아래 코드를 보자. Enum에는 Car, Bicycle 두 가지 변형이 있다. match는 available_transport를 검사한다. 자바 21에서도 패턴 매칭이 정식 기능이 되었고, 자바 25에서는 더 발전했다.
enum Transport {
Car(String),
Bicycle(String),
}
/// 차고를 확인해 어떤 교통수단이 가능한지 결정한다.
fn check_garage() -> Transport {
let car_model = String::from("Tesla Model Y");
Transport::Car(car_model)
}
fn main() {
let available_transport = check_garage();
// `match`는 러스트에서 enum을 다루는 표준 방법이다.
// 가능한 모든 변형을 처리하도록 보장해 코드가 더 안전해진다.
match available_transport {
Transport::Car(model) => {
println!("오늘은 차를 타고 갑니다! 모델: {}", model);
}
Transport::Bicycle(brand) => {
println!("오늘은 자전거를 타네요. 브랜드: {}", brand);
}
}
}
self는 자바의 this와 유사하다. 물론 러스트에서는 다르게 다뤄진다. 메서드의 첫 번째 특별한 매개변수로, 그 메서드가 호출되는 struct, enum, 또는 trait object의 특정 인스턴스를 나타낸다. 핵심적인 차이는 소유권이다.
이 경우 메서드는 인스턴스의 데이터를 읽을 수 있지만 수정할 수는 없다. 호출자는 인스턴스에 대한 완전한 소유권을 유지한다. 호출 후에도 인스턴스는 이전처럼 사용할 수 있다. 아래 예시에서 구조체의 값을 대입하려고 하면 안 된다.
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
// 이 메서드는 Rectangle을 불변으로 빌린다.
fn area(&self) -> u32 {
self.width = 5; // 컴파일 오류! 빌린 데이터를 수정할 수 없다.
self.width * self.height
}
}
이 경우 메서드는 인스턴스의 데이터를 읽고 수정할 수 있다. 호출자는 소유권을 유지한다. 자바에서처럼 setter를 만들고 싶다면 소유권을 위해 &mut를 사용한다. 아래 예시에서는 표준 라이브러리의 가변 배열 타입 Vec을 사용해 Stack을 만든다. push와 pop 같은 쓰기 연산에는 &mut를 쓰고, is_empty 같은 읽기 연산에는 소유권이 필요 없으니 &self만 있으면 된다.
struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
// 새로운 빈 스택을 만드는 "정적 메서드".
// 러스트에서는 이를 연관 함수(associated function)라고 부른다.
fn new() -> Self {
Stack { items: Vec::new() }
}
// 스택에 항목을 푸시한다.
// `&mut self`는 인스턴스에 대한 가변 빌림으로, 자바의 `this`와 비슷하다.
fn push(&mut self, item: T) {
self.items.push(item);
}
// 항목을 팝(pop)한다.
// 스택이 비어 있을 수 있으므로 Option<T>를 반환한다!
fn pop(&mut self) -> Option<T> {
self.items.pop()
}
// 최상단 항목을 들여다보는 불변 빌림.
fn peek(&self) -> Option<&T> {
self.items.last()
}
// 스택이 비었는지 확인한다.
fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
fn main() {
let mut my_stack = Stack::new();
my_stack.push(1);
my_stack.push(2);
my_stack.push(3);
println!("최상단 항목: {:?}", my_stack.peek()); // Some(3)
let popped = my_stack.pop();
println!("팝된 항목: {:?}", popped); // Some(3)
println!("스택이 비었나? {}", my_stack.is_empty()); // false
my_stack.pop();
my_stack.pop();
let last_pop = my_stack.pop();
println!("마지막 pop: {:?}", last_pop); // None
println!("스택이 비었나? {}", my_stack.is_empty()); // true
}
또 하나 흥미로웠던 건 let popped = my_stack.pop()라는 줄이다. 안전을 위해 let popped: Option<i32> = my_stack.pop();처럼 써야 한다고 생각했는데, 그럴 필요가 없었다. 컴파일러가 Stack<i32>의 pop 메서드 시그니처 fn pop(&mut self) -> Option<T>를 보고, T가 i32라는 걸 아니까 이 호출이 Option<i32>를 반환한다는 걸 추론한다. 그래서 변수 popped의 타입도 자동으로 Option<i32>가 된다. 멋지다. 물론 컴파일러가 항상 알아내는 건 아니다.
**언제 타입을 명시해야 할까?**함수 시그니처: 러스트는 모든 함수 인자와 반환 타입을 명시하도록 요구한다. 이는 인터페이스를 안정적이고 명확하게 하려는 의도적인 설계다.
fn process_popped_item(item: Option<i32>) {
match item {
Some(number) => println!("숫자 처리 중: {}", number),
None => println!("스택에 처리할 항목이 없습니다."),
}
}
전체 Stack<T>를 함수로 넘겨도 같은 규칙이 적용된다.
// 스택의 최상단을 들여다보기만 하는 함수
fn inspect_stack(stack: &Stack<i32>) {
match stack.peek() {
Some(top_item) => println!("최상단 항목은 {}입니다.", top_item),
None => println!("스택이 비었습니다."),
}
// `stack`은 참조일 뿐이다. `main`의 원래 `my_stack`은 그대로다.
}
// main에서:
// let mut my_stack = ...;
inspect_stack(&my_stack); // '&'로 참조를 전달한다
타입이 모호할 때: 때로는 컴파일러가 스스로 알아낼 수 없다. 아주 흔한 예로, 이터레이터의 collect()는 여러 종류의 컬렉션을 만들 수 있다.
let numbers = (0..10); // 숫자 이터레이터
// 이 코드는 컴파일되지 않는다. 어떤 컬렉션이어야 할까?
let collected = numbers.collect();
// 정답. 컴파일러에게 Vec<i32>를 원한다고 알려준다.
let collected: Vec<i32> = numbers.collect();
사람을 위한 가독성: 특히 복잡한 타입이나 함수 체인이 길어질 때, 컴파일러가 필요로 하지 않더라도 타입 주석을 추가하면 다른 개발자(혹은 미래의 나 😄)가 코드를 더 빨리 이해할 수 있다.
이 형태의 self는 인스턴스의 소유권을 완전히 가져간다. 인스턴스가 메서드로 이동(move)된다. 메서드 호출 이후, 호출자는 그 인스턴스를 더 이상 사용할 수 없다. 빌더 패턴을 만든다고 해보자.
fn build(self) -> Result<Pizza, String> {
if self.size.is_empty() {
return Err("Size는 필수 값입니다.".to_string());
}
// 빌더의 상태를 사용해 최종 Pizza를 생성한다
Ok(Pizza {
size: self.size,
// 선택 필드에는 기본값을 제공하기 위해 `unwrap_or` 사용
has_stuffed_crust: self.has_stuffed_crust.unwrap_or(false),
toppings: self.toppings.unwrap_or_else(Vec::new), // 기본은 빈 벡터
})
}
// 사용 예시
let cheese_pizza = PizzaBuilder::new("Large").build().unwrap();
대문자
Self는 변수명이 아니라 타입 별칭(type alias)이다.
이쯤 되니 main.rs 밖에 내 데이터 타입을 만드는 법이 궁금해졌다. 그러려면 먼저 타입을 다른 파일에 두어야 한다. 예를 들어 그 파일 이름을 bank_account.rs라고 하자.
pub struct BankAccount {
owner: String,
balance: f64,
}
impl BankAccount {
pub fn new(owner: String) -> Self {
BankAccount {
owner,
balance: 0.0,
}
}
pub fn deposit(&mut self, amount: f64) {
if amount > 0.0 {
self.balance += amount;
println!("입금: ${}", amount);
}
}
pub fn balance(&self) -> f64 {
self.balance
}
}
여기서 함수들에 pub 키워드를 붙이지 않으면 사용할 수 없다. 즉 기본은 모두 private이다. 그러고 나서 이 모듈을 쓰고 싶은 곳에 다음 줄을 추가한다.
mod bank_account;
use bank_account::BankAccount;
차이를 보겠는가? bank_account.rs는 모듈 이름과 같지만, 그 안에 있는 타입은 BankAccount다. 관례는 있겠지만, 러스트가 타입 이름을 강하게 제한하진 않는다. 자바에서 public 클래스를 정의할 때와는 조금 다르다.
물론 배울 거리는 훨씬 더 많겠지만, 몇 시간을 투자한 출발치고 나쁘지 않다. 몇몇 문법이 마음에 든다. 머리를 새로 배선해 주는 느낌도 있다. 결국 언어는 적재적소에 쓰이는 올바른 도구여야 한다. 오늘의 정리:
소유권 기반 메모리 관리: 자바의 가비지 컬렉터와 달리, 러스트는 소유권 시스템으로 메모리를 관리한다. 각 값에는 단 하나의 소유자가 있고, 소유자가 스코프를 벗어나면 메모리가 해제된다. 값을 새 변수에 할당하면 소유권이 이동하고, 원래 변수는 무효화된다.
빌림과 참조: 소유권을 넘기지 않고 데이터에 접근하기 위해 러스트는 “빌림”이라 부르는 참조를 사용한다. 컴파일러는 핵심 규칙을 강제한다. 어느 시점이든 가변 참조 &mut T는 하나만, 불변 참조 &T는 여러 개를 가질 수 있지만 둘을 동시에 가질 수는 없다.
구조체와 열거형으로 데이터 구성: 러스트의 구조체는 이름 있는 필드로 사용자 정의 타입을 만든다. 자바의 필드만 있는 클래스와 비슷하다. 열거형은 변형이 데이터를 가질 수 있어 자바보다 강력하며, match를 통한 견고한 패턴 매칭을 가능하게 한다. null 가능성을 다루는 Option<T>도 이에 기반한다.
메서드 수신자 타입과 self: 자바의 this에 해당하는 self는 메서드가 인스턴스 데이터와 상호작용하는 방식을 명시한다. 불변 빌림 &self, 가변 빌림 &mut self, 그리고 인스턴스를 이동시키는 전체 소유권 self가 있다.
모듈성과 기본 비공개: 코드는 mod 키워드로 선언한 모듈로 구성한다. 모듈 내부의 항목(구조체, 함수, 필드 등)은 기본적으로 비공개다. 외부에서 접근하려면 pub 키워드를 붙여 공개해야 한다.