Box·Rc·RefCell — 규칙을 우회해야 할 때
지금까지 배운 규칙만으로는 못 만드는 자료구조가 있습니다. 대표적으로 연결 리스트, 트리(부모가 자식을 가리키고 자식도 부모를 가리켜야 하는 경우), 여러 곳에서 공유해야 하는 캐시 같은 것들입니다. "값 하나에 소유자 하나"라는 규칙을 곧이곧대로 적용하면 이런 구조는 아예 표현이 안 됩니다. Rust는 이런 경우를 위해 규칙을 어기는 게 아니라, 규칙을 지키면서 우회하는 타입들을 표준 라이브러리에 마련해뒀습니다. 이번 챕터에서 다루는 세 가지가 그것입니다.
Box<T> — 힙에 데이터를 두는 가장 단순한 방법
enum Tree {
Leaf(i32),
Node(Box<Tree>, Box<Tree>),
}
fn main() {
let tree = Tree::Node(
Box::new(Tree::Leaf(1)),
Box::new(Tree::Node(Box::new(Tree::Leaf(2)), Box::new(Tree::Leaf(3)))),
);
fn sum(t: &Tree) -> i32 {
match t {
Tree::Leaf(v) => *v,
Tree::Node(l, r) => sum(l) + sum(r),
}
}
println!("합계: {}", sum(&tree));
}enum Tree를 Box 없이 정의하면 컴파일러가 이렇게 거절합니다 — Tree의 크기가 무한히 재귀적이라 계산할 수 없다고. Node가 Tree를 직접 담으면, Tree의 크기를 알기 위해 Tree의 크기를 먼저 알아야 하는 순환에 빠집니다. Box<Tree>는 그 안의 데이터를 힙에 두고, 스택에는 고정 크기 포인터 하나만 남깁니다. 그래서 Tree의 크기가 유한하게 확정됩니다.
Box<T>가 하는 일은 딱 이것뿐입니다 — 소유권 규칙은 그대로 유지한 채, 데이터를 스택이 아니라 힙에 둘 뿐입니다. 소유자는 여전히 하나이고, 스코프가 끝나면 여전히 자동으로 drop됩니다. 셋 중 가장 이해하기 쉬운 타입이라 여기서 시작했습니다.
Rc<T> — 여러 곳에서 같이 소유하기
"값 하나에 소유자 하나" 규칙을 정말로 위반해야 하는 경우도 있습니다. 그래프 구조에서 노드 하나를 여러 부모가 참조하는 경우가 대표적입니다.
use std::rc::Rc;
fn main() {
let shared = Rc::new(String::from("공유되는 설정값"));
let owner_a = Rc::clone(&shared);
let owner_b = Rc::clone(&shared);
println!("참조 수: {}", Rc::strong_count(&shared)); // 3
println!("{owner_a}, {owner_b}");
}Rc는 "Reference Counted"의 줄임말입니다. Rc::clone은 데이터를 복제하는 게 아니라 참조 카운트를 하나 늘릴 뿐입니다(그래서 이 이름이 .clone()이라 헷갈리기 쉽습니다 — 데이터 복제가 아니라는 걸 기억해야 합니다). 카운트가 0이 될 때, 즉 마지막 소유자마저 스코프를 벗어날 때 비로소 실제 데이터가 drop됩니다. 자바나 파이썬의 가비지 컬렉터와 비슷한 느낌이지만 범위가 좁습니다 — 순환 참조를 스스로 탐지해서 회수하지는 못합니다. Rc끼리 서로를 가리키는 순환 구조를 만들면 메모리가 영원히 회수되지 않는 누수가 생길 수 있고, 이걸 막으려면 Weak<T>라는 짝꿍 타입을 따로 써야 합니다. 이 책에서는 다루지 않지만, Rc를 실무에서 쓰기 시작하면 곧 마주칠 이름입니다.
RefCell<T> — 컴파일 타임 규칙을 런타임으로 미루기
지금까지 본 빌림 규칙(불변 참조 여럿 또는 가변 참조 하나)은 전부 컴파일 타임에 검사됩니다. RefCell<T>는 이 검사를 컴파일 타임 대신 런타임으로 미룹니다.
use std::cell::RefCell;
fn main() {
let counter = RefCell::new(0);
*counter.borrow_mut() += 1;
*counter.borrow_mut() += 1;
println!("현재 값: {}", counter.borrow());
}counter는 let으로 선언됐고 mut가 없는데도 .borrow_mut()으로 값을 바꿀 수 있습니다. 이걸 내부 가변성(interior mutability) 이라고 부릅니다 — 겉으로는 불변처럼 보이는 변수 안에서 실제로는 값이 바뀝니다. RefCell은 컴파일 타임에는 항상 통과시켜주는 대신, "불변 참조와 가변 참조가 동시에 존재하면 안 된다"는 규칙 위반을 실행 중에 감지해서 패닉을 일으킵니다.
use std::cell::RefCell;
fn main() {
let counter = RefCell::new(0);
let r1 = counter.borrow_mut();
let r2 = counter.borrow_mut(); // 컴파일은 되지만 실행 중 패닉
println!("{r1} {r2}");
}이 코드는 컴파일러가 아무 말도 안 합니다. 대신 실행하면 already borrowed: BorrowMutError로 패닉이 납니다. 컴파일 타임 검사가 런타임 검사로 바뀌었을 뿐, 규칙 자체가 없어진 건 아닙니다.
Rc<RefCell<T>> — 흔한 조합
Rc는 "여러 곳에서 공유"를, RefCell은 "공유된 값을 바꾸기"를 담당합니다. 둘을 같이 써야 하는 상황이 실무에서 자주 나옵니다.
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared_log = Rc::new(RefCell::new(Vec::<String>::new()));
let logger_a = Rc::clone(&shared_log);
let logger_b = Rc::clone(&shared_log);
logger_a.borrow_mut().push(String::from("A에서 기록"));
logger_b.borrow_mut().push(String::from("B에서 기록"));
println!("{:?}", shared_log.borrow());
}이 패턴을 처음 보면 타입이 겹겹이 쌓여서(Rc<RefCell<Vec<String>>>) 부담스러울 수 있습니다. 하지만 역할을 나눠서 보면 단순합니다 — Rc는 "누가 몇 명이나 이걸 들고 있는가"를 관리하고, RefCell은 "그 안의 값을 실제로 바꿀 때 규칙을 지키는가"를 관리합니다. 다만 이 조합을 아무 데나 쓰는 건 권하지 않습니다. 대부분의 코드는 소유권 구조를 조금만 다시 생각하면 Box나 평범한 참조만으로 풀립니다. Rc<RefCell<T>>는 정말로 소유자가 여럿이면서 그 값을 바꿔야 하는, 그래프나 옵저버 패턴 같은 제한된 경우에 남겨두는 게 좋습니다.