2024-07-06
HSPACE Rust 특강 #1 — Rust의 핵심 기능
첫 회차는 Rust의 주요 기능을 한 바퀴 도는 시간이었다. 각각은 뒤에서 한 회차씩 따로 다루니까, 여기서는 "이 언어의 설계가 무엇을 골랐는가" 를 잡는 데 집중했다.
Rust는 무엇을 골랐나
프로그래밍 언어의 메모리 관리는 크게 두 갈래다.
- 수동 관리 (C/C++) — 빠르지만, 해제 후 사용(use-after-free)·이중 해제·누수가 전부 개발자 책임이다.
- 가비지 컬렉션 (Java, C#, Go) — 안전하지만 런타임과 GC 일시 정지를 감수해야 한다.
Rust는 세 번째 길을 택했다. 소유권 규칙을 타입 시스템에 넣고 컴파일 시점에
검사해서, 런타임 비용 없이 해제 시점을 결정한다. GC도 없고 free() 도 없는데
누수와 use-after-free가 막힌다는 게 이 언어의 출발점이다.
소유권 (Ownership)
규칙은 세 줄이다.
- 모든 값은 소유자(owner) 를 정확히 하나 가진다.
- 소유자가 스코프를 벗어나면 값은 드롭(drop) 된다.
- 소유권은 이동(move) 할 수 있고, 이동하면 원래 변수는 더 이상 못 쓴다.
C++과 비교하면 차이가 선명하다. C++에서 std::string 을 값으로 넘기면 복사본이
생기지만(혹은 이동 후에도 원본은 여전히 "유효하지만 미지정" 상태), Rust에서는
원본 변수 자체가 무효가 된다.
fn f(mut s: String) {
s.push_str(", world!");
}
fn main() {
let mut s = String::from("Hello");
f(s); // s의 소유권이 f로 이동
// println!("{s}"); // 에러: value used here after move
}
처음에는 이게 불편했는데, 뒤집어 보면 "이 값은 지금 누가 책임지는가"가 코드에 항상 적혀 있다는 뜻이다. 소멸자가 언제 도는지 추론할 필요가 없다.
빌림 (Borrowing)
매번 소유권을 넘기면 아무것도 못 하니, 값을 빌려 쓰는 레퍼런스가 있다.
&T— 공유 레퍼런스. 여러 개 동시에 존재할 수 있고, 읽기만 가능.&mut T— 가변 레퍼런스. 한 번에 하나만 존재할 수 있고, 쓰기 가능.
그리고 이 둘은 동시에 존재할 수 없다.
fn main() {
let mut a = 10;
let b = &a;
{
let c = &mut a; // 에러: `a`가 이미 불변으로 빌려져 있어 가변으로 빌릴 수 없음
*c = 20;
}
println!("a : {a}");
println!("b : {b}"); // b가 여기서 쓰이므로, b의 빌림이 위까지 살아 있다
}
여기서 중요한 건 마지막 줄이다. b 를 마지막에 쓰기 때문에 b 의 빌림이 c 를
만드는 시점까지 살아 있고, 그래서 충돌한다. println! 에서 b 를 빼면 컴파일이
통과한다. 빌림의 수명은 선언 위치가 아니라 마지막 사용 위치까지라는 걸
이때 처음 체감했다.
이 규칙 하나가 데이터 경합(data race)의 정의 — "동시 접근 + 최소 하나는 쓰기" — 를 컴파일 시점에 그대로 차단한다.
수명 (Lifetime)
레퍼런스는 가리키는 값보다 오래 살면 안 된다. 컴파일러가 이를 검사하려면 어떤 레퍼런스가 어떤 값에 묶여 있는지 알아야 하고, 그게 수명 매개변수다.
#[derive(Debug)]
struct Number(i32);
// fn min(a: &Number, b: &Number) -> &Number // 에러: 반환값의 수명을 알 수 없음
fn min<'a>(a: &'a Number, b: &'a Number) -> &'a Number {
if a.0 < b.0 { a } else { b }
}
'a 는 "반환된 레퍼런스는 a 와 b 중 짧은 쪽만큼 산다"는 약속이다. 새로운
제약을 거는 게 아니라, 원래부터 성립해야 하는 사실을 시그니처에 적는 것에
가깝다. 대부분의 경우 수명 생략 규칙 덕분에 직접 쓸 일은 없다.
Rust에서의 OOP
Rust에는 클래스도 상속도 없다. 대신 구조체 + impl 블록 + 트레잇 으로
객체지향의 필요한 부분만 가져온다.
- 캡슐화 —
pub으로 공개 범위를 제어. 메서드는&self/&mut self/self로 수신자의 소유 형태까지 시그니처에 드러난다. - 다형성 — 트레잇으로 해결.
- 상속 — 없다. 대신 조합(composition)과 트레잇 기본 구현을 쓴다.
트레잇은 "이 타입은 이런 일을 할 수 있다"는 계약이다.
trait Moveable {
fn move_to(&mut self, x: i32, y: i32);
}
impl Moveable for Player {
fn move_to(&mut self, x: i32, y: i32) {
if self.stamina <= 0 {
println!("Not enough stamina to move");
return;
}
self.stamina -= 1;
println!("Moving player to ({x}, {y})");
}
}
서로 다른 타입을 한 컨테이너에 담아 같은 인터페이스로 다루려면 트레잇 오브젝트를 쓴다.
let mut objects = vec![
Box::new(Player { /* ... */ }) as Box<dyn Moveable>,
Box::new(Pet { /* ... */ }) as Box<dyn Moveable>,
];
for object in objects.iter_mut() {
object.move_to(10, 20);
}
Box<dyn Moveable> 은 (데이터 포인터, vtable 포인터) 쌍인 뚱뚱한 포인터다.
즉 dyn 을 쓰는 순간 동적 디스패치 비용이 생긴다. 반대로 제네릭
(fn f<T: Moveable>(x: T))은 단형화(monomorphization)되어 타입마다 코드가
찍히므로 정적 디스패치가 되고 인라인도 가능하다. 같은 트레잇을 두 방식으로 쓸 수
있고, 선택은 내 몫이라는 점이 인상적이었다.
열거체, Option, Result
Rust의 enum 은 C의 열거형이 아니라 태그된 합 타입이다. 각 변형이 자기
데이터를 가질 수 있다.
이 위에 표준 라이브러리의 두 타입이 서 있다.
enum Option<T> { Some(T), None }
enum Result<T, E> { Ok(T), Err(E) }
Option 은 널 포인터를 대체한다. "값이 없을 수 있다"가 타입에 적혀 있고,
꺼내려면 반드시 없는 경우를 처리해야 하므로 널 역참조가 구조적으로 사라진다.
let x: Option<i32> = Some(5);
let y: Option<i32> = None;
println!("{}", x.unwrap_or(0));
match y {
Some(v) => println!("Value: {}", v),
None => println!("No value"),
}
Result 는 예외를 대체한다. 실패할 수 있는 함수는 그 사실을 반환 타입에 적는다.
fn square_if_even(num: i32) -> Result<i32, String> {
if num % 2 == 0 {
Ok(num * num)
} else {
Err(String::from("Not even"))
}
}
예외와 달리 호출자가 무시할 수 없다. Result 를 그냥 버리면 경고가 뜨고,
값을 쓰려면 match 든 ? 든 어떤 형태로든 실패 경로를 지나가야 한다.
이 둘을 꺼내는 도구가 패턴 매칭이고, match 는 모든 경우를 다뤘는지
컴파일러가 검사한다(exhaustiveness). 변형을 하나 추가하면 처리 안 한 곳이 전부
컴파일 에러로 잡힌다.
타입 변환
Rust는 암묵적 수치 변환을 하지 않는다.
let a = 10;
let b = 30.4;
// let c = a + b; // 에러: mismatched types
let c = a as f64 + b; // 명시적 변환
C/C++에서 조용히 일어나던 정수 승격·축소가 전부 눈에 보이게 된다. 처음엔 성가시지만, 정밀도 손실이나 부호 문제로 디버깅하던 시간을 생각하면 남는 장사다.
Copy와 Clone
이동 규칙에는 예외가 있다. Copy 를 구현한 타입은 넘겨도 원본이 살아 있다.
Copy— 비트 단위 복사로 충분한 타입(정수,bool,char,f64,Copy타입만 담은 튜플·배열). 대입/전달이 이동이 아니라 복사가 된다.Clone— 명시적으로.clone()을 호출해야 하는 깊은 복사. 힙 할당을 동반할 수 있어서 비용이 드러난다.
// String은 Clone만 구현, Copy는 아님
let name = String::from("Chris");
hello(name.clone()); // 복사본을 넘김
hello(name); // 원본을 넘김 (여기서 이동)
// i32는 Copy + Clone
let num = 5;
println!("{}", square(num));
println!("{}", square(num + 5)); // num은 여전히 유효
String 이 Copy 가 아닌 이유는 명확하다. 힙 버퍼를 가리키므로 비트 복사를 하면
포인터가 둘이 되고, 이중 해제가 난다. "복사가 싸면 Copy, 비싸면 Clone"
이라는 구분이 언어 수준에 박혀 있어서, 비싼 복사가 코드에 항상 .clone() 으로
드러난다.
트레잇이 없으면 못 하는 일: f64 정렬
트레잇 얘기를 실감한 건 이 예제였다.
let mut arr = vec![1.2, 4.5, 3.1, -5.7, 6.3];
// arr.sort(); // 에러: f64는 Ord를 구현하지 않음
arr.sort_by(|a, b| a.partial_cmp(b).unwrap());
sort() 는 Ord(전순서)를 요구하는데, f64 는 NaN 때문에 전순서가 아니다.
NaN 은 자기 자신과도 같지 않으니 비교 규칙이 깨진다. 그래서 f64 는 부분 순서인
PartialOrd 만 구현하고, 정렬하려면 partial_cmp 로 "NaN 이 없다고 가정한다"는
걸 unwrap() 으로 명시해야 한다.
다른 언어라면 런타임에 이상한 정렬 결과로 나타났을 문제가, 여기서는 타입이 안 맞아서 컴파일이 안 되는 형태로 먼저 튀어나온다.
클로저와 동시성 맛보기
클로저는 주변 변수를 캡처하는 함수다. 그런데 어떻게 캡처하느냐에 따라 클로저의 타입이 달라진다.
fn call_twice<F>(closure: F) where F: Fn() {
closure();
closure();
}
fn main() {
let name = String::from("Chris");
let hello = || {
println!("Hello, {name}");
drop(name); // name을 소비한다 → 이 클로저는 FnOnce
};
call_twice(hello); // 에러: FnOnce는 Fn 바운드를 만족하지 못한다
}
drop(name) 이 캡처한 값을 소비하므로 이 클로저는 한 번만 호출할 수 있고,
Fn 이 아니라 FnOnce 다. "두 번 호출"이 컴파일 에러가 된다. 클로저 트레잇
(Fn / FnMut / FnOnce)은 나중에 클로저 회차에서 제대로 다룬다.
동시성도 같은 원리로 걸린다.
// Rc는 스레드 간 이동 불가 (Send가 아님)
// thread::spawn(move || { rc2.clone(); }); // 에러
use std::sync::Arc;
let arc1 = Arc::new("Hyundai".to_string());
let arc2 = arc1.clone();
thread::spawn(move || {
let _ = arc2.clone(); // OK
});
Rc 는 참조 카운트를 원자적이지 않게 올린다. 그래서 스레드 간 공유가 애초에
타입 수준에서 금지되어 있다(Send 미구현). 스레드를 넘기려면 원자적 카운터를
쓰는 Arc 를 써야 한다.
이게 Rust가 말하는 "두려움 없는 동시성(fearless concurrency)" 의 실체다. 새로운 동시성 이론이 있는 게 아니라, 소유권과 트레잇 검사가 그대로 스레드 경계까지 확장된 것뿐이다.
정리
한 회차에 훑고 나서 남은 건, 이 언어의 기능들이 소유권이라는 하나의 뿌리에서 갈라져 나온다는 감각이었다.
- 소유권 → 빌림 → 수명: 메모리 안전
- 같은 규칙이 스레드 경계로 확장 → 데이터 경합 방지
Option/Result+ 패턴 매칭: 널과 예외를 타입으로 대체- 트레잇: 상속 없이 다형성, 정적/동적 디스패치 선택 가능
다음 편은 기본 타입 — 고정 크기 수치 타입, 오버플로 처리 방식, 배열·벡터·슬라이스,
그리고 String 과 &str 의 관계다.