콘텐츠로 이동

Rust 스마트 포인터

소유권과 대여 규칙만으로도 대부분의 상황을 해결할 수 있어요. 하지만 “주인은 딱 하나”라는 틀에 잘 맞지 않는 경우가 실제로 몇 가지 있어요. 실행해 보기 전까지 크기를 알 수 없는 값, 정말로 주인이 여럿 필요한 데이터, 공유 참조자를 통해 바꿔야 하는 값 같은 경우예요. 스마트 포인터(smart pointer)는 바로 이런 경우를 위한 Rust의 해결책이에요.

기본적으로 값은 스택에 저장되고, 컴파일러는 그 크기를 미리 알아야 해요. Box<T>는 값을 힙으로 옮기고, 그 값을 가리키는 크기가 고정된 포인터를 건네줘요. 컴파일할 때 타입의 크기를 알 수 없을 때 유용한데, 가장 흔한 예가 재귀 타입이에요.

fn main() {
let boxed = Box::new(5);
println!("{}", *boxed); // *로 안의 값을 꺼내요
}

실행 결과:

5
▶ 직접 실행해 보기

Box가 꼭 필요한 대표적인 예는 자기 자신을 품는 타입이에요. 연결 리스트의 노드처럼요.

enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
match list {
List::Cons(value, _) => println!("첫 번째 값: {value}"),
List::Nil => println!("비어 있어요"),
}
}

실행 결과:

첫 번째 값: 1
▶ 직접 실행해 보기

Box가 없다면 Cons(i32, List)를 저장하는 데 무한한 공간이 필요해요. List 안에 List가 있고, 그 안에 또 List가 있고, 끝없이 이어지니까요. 반면 Box<List>는 크기가 고정된 포인터라서, 컴파일러가 필요한 공간을 계산할 수 있어요.

소유권 규칙에 따르면 값의 주인은 딱 하나예요. 대부분은 그게 맞아요. 그런데 가끔은 프로그램의 여러 부분이 정말로 같은 데이터를 함께 가져야 할 때가 있어요. Rc<T>(“reference counted”, 참조 카운트)는 지금 주인이 몇 명인지 세어 두는 방식으로 이걸 가능하게 해 줘요.

use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("공유 데이터"));
let b = Rc::clone(&a); // 깊은 복사가 아니에요. 주인이 한 명 더 생길 뿐이에요
let c = Rc::clone(&a);
println!("{a} {b} {c}");
println!("주인 수: {}", Rc::strong_count(&a));
}

실행 결과:

공유 데이터 공유 데이터 공유 데이터
주인 수: 3
▶ 직접 실행해 보기

Rc::clone은 안에 있는 데이터를 복사하지 않아요. 카운터를 하나 올리고, 같은 값을 가리키는 참조를 하나 더 돌려줄 뿐이에요. 데이터는 모든 주인이 스코프를 벗어난 뒤에야 정리돼요.

RefCell<T>: 공유 참조자를 통해 값 바꾸기

섹션 제목: “RefCell<T>: 공유 참조자를 통해 값 바꾸기”

보통 대여 규칙은 컴파일할 때 검사해요. 읽기 전용 참조자 여러 개, 아니면 가변 참조자 하나. 둘을 동시에 가질 수는 없어요. RefCell<T>는 이 검사를 실행 시간(런타임)으로 옮겨요. 그래서 겉보기에는 mut가 아닌 공유 참조자를 통해서도 값을 바꿀 수 있어요.

use std::cell::RefCell;
fn main() {
let value = RefCell::new(5);
*value.borrow_mut() += 1;
println!("{}", value.borrow());
}

실행 결과:

6
▶ 직접 실행해 보기

RefCell은 Rc와 짝지어 쓰는 경우가 가장 많아요. Rc<RefCell<T>>를 쓰면 주인이 여럿이면서도 값을 바꿀 수 있는 값을 만들 수 있어요. Rc<T>만으로는 할 수 없는 일이에요.

처음부터 스마트 포인터로 시작하지 마세요. 평범한 소유권과 대여로 시작하고, 컴파일러가 그걸로는 안 된다고 분명히 말해 줄 때만 꺼내 쓰면 돼요. 재귀 타입이라면 Box, 정말로 주인이 여럿 필요한 값이라면 Rc, 대여 규칙이 허락하지 않는 공유 참조자를 통한 변경이라면 RefCell이에요.

Box 절에 나온 List 열거형을 써요. 1, 2, 3으로 된 리스트를 만들고, 모든 값을 더하는 함수 fn sum(list: &List) -> i32를 작성해 보세요. 힌트: 리스트에 match를 써요. Cons(value, rest)라면 답은 value에 rest의 합을 더한 값이고, Nil이라면 0이에요.

정답 보기
enum List {
Cons(i32, Box<List>),
Nil,
}
fn sum(list: &List) -> i32 {
match list {
List::Cons(value, rest) => value + sum(rest),
List::Nil => 0,
}
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Cons(3, Box::new(List::Nil))))));
println!("합계: {}", sum(&list));
}

실행 결과:

합계: 6
▶ 직접 실행해 보기

함수가 Nil에 닿을 때까지 리스트의 나머지 부분에 대해 자기 자신을 호출해요. &List가 필요한 자리에 rest(&Box<List>)를 그대로 넘길 수 있다는 점도 눈여겨보세요. Rust가 Box 안쪽을 알아서 들여다봐 줘요.

alice와 bob 두 사람이 함께 소유하고, 둘 다 항목을 추가할 수 있는 장보기 목록(Vec<String>) 하나를 만들어 보세요. 각자 항목을 하나씩 추가한 다음, 목록과 주인 수를 출력해요. 힌트: Vec을 Rc<RefCell<...>>로 감싸고, 각 사람에게 Rc::clone을 하나씩 주고, .borrow_mut().push(...)로 추가해요.

정답 보기
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let list = Rc::new(RefCell::new(Vec::new()));
let alice = Rc::clone(&list);
let bob = Rc::clone(&list);
alice.borrow_mut().push(String::from("우유"));
bob.borrow_mut().push(String::from("빵"));
println!("{:?}", list.borrow());
println!("주인 수: {}", Rc::strong_count(&list));
}

실행 결과:

["우유", "빵"]
주인 수: 3
▶ 직접 실행해 보기

주인은 list, alice, bob 이렇게 셋이에요. 셋 모두 같은 Vec을 가리키니까, alice를 통해 추가한 항목이 bob과 list에서도 보여요. RefCell이 변경을 맡아 주기 때문에 어떤 변수에도 mut가 필요 없어요.

  • Box<T>는 값을 힙에 저장하고 크기가 고정된 포인터로 가리켜요. 재귀 타입에 꼭 필요해요.
  • Rc<T>는 참조 카운트로 단일 스레드 코드에서 같은 값의 주인을 여럿 둘 수 있게 해 줘요. 스레드 사이에서는 Arc<T>를 써요.
  • RefCell<T>는 대여 규칙을 컴파일 시간이 아니라 실행 시간에 검사해서, 공유 참조자를 통한 변경을 허용해요. 대신 규칙을 어기면 패닉이 일어나요.
  • 평범한 소유권과 대여로는 해결되지 않을 때만 이것들을 꺼내 쓰세요.

이해도 점검

1. 연결 리스트 노드 같은 재귀 타입에는 왜 Box<T>가 필요할까요?

2. Rc::clone(&a)는 실제로 무슨 일을 할까요?

3. RefCell<T>는 대여 규칙을 언제 검사할까요?

4. Rc<T>만으로는 안 되고 Rc<RefCell<T>>로는 되는 일은 무엇일까요?

점수: 0 / 4