스마트 포인터 - Box·Rc·RefCell·Weak
이 장에서 배우는 것
기본서에서 소유권은 값마다 주인이 하나이고, 빌림은 컴파일 시점에 검사된다는 규칙으로 다뤘다. 이 규칙만으로는 쓰기 어려운 구조가 있다. 크기를 미리 알 수 없는 재귀 타입, 여러 곳이 함께 가리키는 하나의 상품 정보, 읽기 전용 참조 너머에서 값을 바꿔야 하는 상황, 부모와 자식이 서로를 가리키는 트리가 그렇다. 표준 라이브러리의 스마트 포인터(smart pointer)는 이런 구조를 안전하게 표현하는 도구다. 이 장에서는 창고 프로그램에 입고 이력, 주문과 상품의 공유, 창고 구역 트리를 차례로 붙이면서 네 가지 타입과 두 가지 트레이트를 익힌다.
- Box 로 재귀 타입의 크기를 유한하게 만들고, 값이 힙에 놓이는 방식을 설명한다.
- Rc 의 참조 카운트로 한 값을 여러 소유자가 공유하게 하고, 카운트가 변하는 시점을 예측한다.
- RefCell 의 내부 가변성과 실행 중 빌림 검사를 이해하고, 패닉이 나는 조건을 코드에서 찾아낸다.
- Rc<RefCell<T>> 조합과 Weak 로 순환 참조를 끊는 방법을 쓴다.
- Deref 와 Drop 트레이트로 스마트 포인터가 참조처럼 쓰이고 정리되는 원리를 설명한다.
문제 상황
창고 프로그램에 세 가지 요구가 들어왔다. 첫째, 상품별 입고 이력을 쌓아 두고 합계를 내야 한다. 이력은 건수가 정해져 있지 않아 배열 길이로 고정할 수 없고, 앞 기록을 가리키는 연결 구조가 자연스럽다. 둘째, 같은 상품에 대한 주문이 여러 건 들어오는데 모든 주문이 같은 재고 숫자를 봐야 한다. 주문마다 상품을 복제하면 한 주문이 재고를 줄여도 다른 주문은 옛 숫자를 보게 된다. 셋째, 창고 구역은 본관, 선반, 칸처럼 트리를 이룬다. 칸에서 위로 올라가 전체 경로를 만들고 싶은데, 부모가 자식을 소유하고 자식이 다시 부모를 소유하면 어느 쪽도 해제되지 않는다.
세 요구는 각각 다른 도구로 풀린다. 아래에서 하나씩 풀고, 마지막에 한 프로그램으로 묶는다.
Box 와 재귀 타입
Box<T> 는 값을 힙(heap)에 두고 스택에는 포인터 하나만 남기는 스마트 포인터다. 소유자는 하나이고, Box 가 범위를 벗어나면 힙의 값도 함께 해제된다. 기본서의 소유권 규칙이 그대로 적용되므로 새로 외울 규칙은 없다.
Box 가 필요한 대표 경우는 재귀 타입이다. 다음 열거형은 컴파일되지 않는다.
enum History {
Empty,
Entry { qty: i32, prev: History },
}
컴파일러는 error[E0072]: recursive type `History` has infinite size 라고 알려 준다. Entry 안에 History 가 통째로 들어 있으니 크기를 계산하면 끝이 없다. prev 를 Box<History> 로 바꾸면 Entry 의 크기는 정수 하나와 포인터 하나로 고정된다. 다음 값은 힙에 따로 놓인다.
그림처럼 가장 최근 기록만 스택의 변수가 갖고, 나머지는 포인터를 따라가면 나온다. 합계는 재귀 호출로 계산한다. 이력이 매우 길면 재귀가 깊어지므로, 실제로 수만 건을 쌓는 구조라면 반복문으로 순회하는 편이 안전하다. 이 장의 예제는 몇 건뿐이라 재귀로 둔다.
Box 는 재귀 타입 말고도 큰 값을 이동할 때 복사 비용을 줄이거나, 크기를 모르는 타입을 다룰 때 쓴다. 후자는 앞서 트레이트 객체를 다룬 주제와 이어지므로 여기서는 재귀 타입에 집중한다.
Rc, RefCell, Weak
Rc: 소유자를 여러 명으로
Rc<T> (reference counting)는 한 값에 소유자를 여럿 허용한다. Rc::clone(&a) 는 값을 복사하지 않고 참조 카운트만 1 올린다. Rc 하나가 사라질 때마다 카운트가 1 줄고, 0 이 되면 값이 해제된다. Rc::strong_count 로 현재 값을 볼 수 있다. 복제 비용이 정수 덧셈 하나라서 관례상 a.clone() 보다 Rc::clone(&a) 로 쓴다. 깊은 복사가 아니라 카운트 증가임이 코드에서 바로 보이기 때문이다.
Rc 가 주는 것은 공유된 읽기 접근뿐이다. Rc 로 감싼 값을 바꾸려 하면 컴파일이 막는다. 여러 소유자가 동시에 쓸 수 있으면 빌림 규칙이 깨지기 때문이다. 스레드 사이에서 쓰는 Arc 는 스레드를 다루는 장에서 따로 다룬다. Rc 는 카운트 갱신이 원자적이지 않아 한 스레드 안에서만 쓸 수 있다.
RefCell: 검사 시점을 실행 중으로
기본서의 빌림 규칙은 컴파일 시점에 검사되고, 읽기 참조 여럿 또는 쓰기 참조 하나만 허용한다. RefCell<T> 는 같은 규칙을 실행 중에 검사한다. 이를 내부 가변성(interior mutability)이라 부른다. &RefCell<T> 만 있어도 borrow() 로 읽기용 Ref 를, borrow_mut() 로 쓰기용 RefMut 를 얻는다. RefCell 은 지금 살아 있는 Ref 와 RefMut 를 세어 두고, 규칙에 어긋나는 요청이 오면 패닉을 일으킨다. 패닉 없이 확인하려면 try_borrow 와 try_borrow_mut 가 Result 를 돌려준다.
이 검사는 규칙을 없애는 것이 아니라 확인을 미루는 것이다. 컴파일러가 증명하지 못하는 안전한 패턴을 허용하는 대신, 위반은 실행 중 패닉으로 드러난다. 그래서 Ref 와 RefMut 의 생존 범위를 짧게 유지하는 것이 중요하다.
Rc<RefCell<T>>: 공유하면서 바꾸기
두 타입을 겹치면 여러 소유자가 하나의 값을 공유하면서 각자 수정할 수 있다. 창고의 상품과 주문이 이 경우다. 모든 주문이 같은 Rc<RefCell<Product>> 를 들고 있으므로, 한 주문이 재고를 줄이면 다른 주문도 줄어든 숫자를 본다. 아래 표는 네 타입을 비교한다.
| 타입 | 소유자 | 빌림 검사 | 스레드 간 사용 |
|---|---|---|---|
| Box<T> | 하나 | 컴파일 시점 | T 가 허용하면 가능 |
| Rc<T> | 여럿(읽기 전용) | 컴파일 시점 | 불가 |
| RefCell<T> | 하나 | 실행 중 | 공유 불가 |
| Weak<T> | 소유하지 않음 | upgrade 시 확인 | 불가 |
Weak: 순환을 끊는 약한 참조
부모가 자식을 Rc 로 갖고 자식도 부모를 Rc 로 가지면 서로의 카운트가 영원히 0 이 되지 않아 메모리가 해제되지 않는다. 이를 참조 순환이라 한다. 안전한 코드에서도 만들 수 있고 컴파일러는 막지 않는다. 해법은 한쪽을 Weak<T> 로 바꾸는 것이다. Rc::downgrade(&rc) 로 만든 Weak 는 값을 살려 두지 않고 weak 카운트만 올린다. 값을 쓰려면 upgrade() 로 Option<Rc<T>> 를 얻는다. 값이 이미 해제됐으면 None 이다.
트리에서는 소유 방향이 부모에서 자식이다. 따라서 부모에서 자식으로는 Rc, 자식에서 부모로는 Weak 를 쓴다. 자식이 부모를 살려 둘 이유가 없기 때문이다.
Deref 와 Drop
스마트 포인터가 참조처럼 쓰이는 것은 Deref 트레이트 덕분이다. fn deref(&self) -> &Self::Target 만 구현하면 *x 가 동작하고, 메서드 호출과 인자 전달에서 자동 변환(deref coercion)이 일어난다. &String 이 &str 로 넘어가는 것도 같은 원리다. 아래 예제의 Sku 는 문자열 하나를 감싼 타입인데, Deref 를 구현하면 &str 의 메서드를 그대로 쓸 수 있고 &Sku 를 &str 인자 자리에 넘길 수 있다. 값을 바꾸는 쪽에는 DerefMut 가 있다.
Drop 은 값이 범위를 벗어날 때 실행할 정리 코드를 정한다. 변수는 선언의 반대 순서로 해제되고, 구조체는 자기 drop 이 먼저 실행된 뒤 필드가 선언 순서로 해제된다. drop 메서드를 직접 부를 수는 없다. value.drop() 은 error[E0040]: explicit use of destructor method 로 막히고, 일찍 해제하려면 std::mem::drop(value) 로 소유권을 넘긴다. 아래 예제는 Drop 으로 Zone 해제 시점을 출력해 순환이 없을 때 정리 순서가 어떻게 되는지 확인한다.
완성 코드
입고 이력, 주문과 상품 공유, 구역 트리, SKU 래퍼를 한 파일에 담았다. src/main.rs 하나로 동작한다.
use std::cell::RefCell;
use std::ops::Deref;
use std::rc::{Rc, Weak};
enum History {
Empty,
Entry { qty: i32, prev: Box<History> },
}
impl History {
fn push(self, qty: i32) -> History {
History::Entry {
qty,
prev: Box::new(self),
}
}
fn total(&self) -> i32 {
match self {
History::Empty => 0,
History::Entry { qty, prev } => qty + prev.total(),
}
}
fn depth(&self) -> usize {
match self {
History::Empty => 0,
History::Entry { prev, .. } => 1 + prev.depth(),
}
}
}
struct Product {
name: String,
stock: i32,
}
struct Order {
id: u32,
product: Rc<RefCell<Product>>,
qty: i32,
}
impl Order {
fn fulfill(&self) -> Result<(), String> {
let mut p = self.product.borrow_mut();
if p.stock < self.qty {
return Err(format!(
"주문 {}: {} 재고 부족 (재고 {}, 요청 {})",
self.id, p.name, p.stock, self.qty
));
}
p.stock -= self.qty;
Ok(())
}
}
struct Zone {
name: String,
parent: RefCell<Weak<Zone>>,
children: RefCell<Vec<Rc<Zone>>>,
}
impl Zone {
fn new(name: &str) -> Rc<Zone> {
Rc::new(Zone {
name: name.to_string(),
parent: RefCell::new(Weak::new()),
children: RefCell::new(Vec::new()),
})
}
fn add_child(parent: &Rc<Zone>, child: Rc<Zone>) {
*child.parent.borrow_mut() = Rc::downgrade(parent);
parent.children.borrow_mut().push(child);
}
fn path(&self) -> String {
match self.parent.borrow().upgrade() {
Some(p) => format!("{}/{}", p.path(), self.name),
None => self.name.clone(),
}
}
}
impl Drop for Zone {
fn drop(&mut self) {
println!("해제: {}", self.name);
}
}
struct Sku(String);
impl Deref for Sku {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
fn starts_with_bolt(code: &str) -> bool {
code.starts_with("BOLT")
}
fn main() {
let history = History::Empty.push(10).push(5).push(7);
println!("입고 이력 {}건, 합계 {}", history.depth(), history.total());
let bolt = Rc::new(RefCell::new(Product {
name: "볼트".to_string(),
stock: 20,
}));
println!("참조 수(생성 직후): {}", Rc::strong_count(&bolt));
let orders = vec![
Order { id: 1, product: Rc::clone(&bolt), qty: 8 },
Order { id: 2, product: Rc::clone(&bolt), qty: 8 },
Order { id: 3, product: Rc::clone(&bolt), qty: 8 },
];
println!("참조 수(주문 3건 연결): {}", Rc::strong_count(&bolt));
for order in &orders {
match order.fulfill() {
Ok(()) => println!("주문 {} 처리, 남은 재고 {}", order.id, bolt.borrow().stock),
Err(msg) => println!("{msg}"),
}
}
drop(orders);
println!("참조 수(주문 정리 후): {}", Rc::strong_count(&bolt));
let reading = bolt.borrow();
let during = if bolt.try_borrow_mut().is_ok() { "성공" } else { "거부" };
println!("읽는 중 쓰기 시도: {during}");
drop(reading);
let after = if bolt.try_borrow_mut().is_ok() { "성공" } else { "거부" };
println!("읽기 끝난 뒤 쓰기 시도: {after}");
let root = Zone::new("본관");
let shelf = Zone::new("A선반");
let bin = Zone::new("3번칸");
Zone::add_child(&root, Rc::clone(&shelf));
Zone::add_child(&shelf, Rc::clone(&bin));
println!("경로: {}", bin.path());
println!("본관 하위 구역 {}개", root.children.borrow().len());
for zone in [&root, &shelf, &bin] {
println!(
"{} strong={} weak={}",
zone.name,
Rc::strong_count(zone),
Rc::weak_count(zone)
);
}
let weak = {
let temp = Zone::new("임시구역");
let weak = Rc::downgrade(&temp);
println!("범위 안 upgrade: {}", weak.upgrade().is_some());
weak
};
println!("범위 밖 upgrade: {}", weak.upgrade().is_some());
let sku = Sku("BOLT-M8".to_string());
println!("SKU 길이 {}, 소문자 {}", sku.len(), sku.to_lowercase());
println!("접두어 일치: {}", starts_with_bolt(&sku));
println!("main 종료 직전");
}
줄별 해설
- History 열거형:
prev: Box<History>덕분에 Entry 의 크기가 고정된다.push는 self 를 값으로 받아 Box 안으로 옮기고 새 Entry 를 돌려준다. 기존 이력의 소유권이 새 노드로 넘어가는 모양이다. - total 과 depth:
self가 참조이므로 패턴의qty는&i32다.qty + prev.total()은&i32 + i32로 계산된다.prev는&Box<History>이지만 Deref 로 자동 변환돼prev.total()이 바로 호출된다. - Product 와 Order: 주문은 상품을 복제하지 않고
Rc<RefCell<Product>>를 들고 있다.fulfill은&self만 받는데도borrow_mut()로 재고를 바꾼다. 함수가 끝나면 RefMut 가 해제되어 쓰기 빌림도 끝난다. - fulfill 의 오류 경로: 재고가 모자라면 Err 를 일찍 돌려준다. 이때 RefMut 는 return 으로 함께 해제된다. 메시지는 읽기만 하므로
p.name과p.stock접근에 문제가 없다. - Zone 구조체: 부모는
RefCell<Weak<Zone>>다. Rc 로 감싼 뒤에는 필드를 직접 바꿀 수 없으므로, 부모 연결과 자식 목록 모두 RefCell 로 감싸 나중에 채운다.Weak::new()는 아무것도 가리키지 않는 빈 Weak 다. - add_child: 자식의 parent 를
Rc::downgrade(parent)로 채우고, 부모의 children 에 자식을 넣는다. 부모에서 자식으로만 강한 소유가 생기므로 순환이 없다. - path:
upgrade()가 Some 이면 부모의 경로 뒤에 자기 이름을 붙이는 재귀다. match 의 검사 대상에 만든 임시 Ref 는 match 가 끝날 때까지 살아 있지만, 재귀 호출은 다른 Zone 의 RefCell 을 빌리므로 충돌하지 않는다. - Drop for Zone: 해제 시점을 출력한다. Zone 의 drop 이 먼저 실행되고 그다음 children 필드가 해제되므로 부모의 이름이 먼저 나온다.
- Sku:
type Target = str로 Deref 를 구현했다.sku.len()과sku.to_lowercase()는 str 의 메서드이고,starts_with_bolt(&sku)는&Sku가&str로 변환되어 호출된다. - main 의 카운트 출력: 주문 세 건을 만들면
bolt변수 하나와 주문 세 개를 합쳐 strong 이 4 다.drop(orders)로 벡터를 지우면 1 로 돌아온다. - try_borrow_mut 시연:
reading이 살아 있는 동안은 거부되고,drop(reading)뒤에는 성공한다. 같은 호출이 Ref 의 생존 여부만으로 결과가 갈리는 것을 보여 준다. 성공했을 때 돌아온 RefMut 는 조건식이 끝나면 바로 해제된다. - 구역 카운트: root 는 변수 하나만 갖고 있어 strong 1 이고, shelf 의 parent 가 가리켜 weak 1 이다. shelf 는 변수와 root 의 children 으로 strong 2, bin 의 parent 로 weak 1 이다. bin 은 변수와 shelf 의 children 으로 strong 2 이고 자식이 없어 weak 0 이다.
- 임시구역 블록: 블록 안에서는 upgrade 가 성공한다. 블록이 끝나면 temp 가 해제되어 "해제: 임시구역" 이 출력되고, 밖에서 같은 Weak 의 upgrade 는 None 이다.
- 종료 시점: main 이 끝나면 변수가 선언의 반대 순서로 해제된다. bin 과 shelf 변수가 먼저 카운트를 줄이지만 root 변수가 사라질 때 비로소 root 의 카운트가 0 이 된다. 그 뒤 root 가 가진 shelf, shelf 가 가진 bin 순으로 해제된다.
실행 결과
$ cargo run
입고 이력 3건, 합계 22
참조 수(생성 직후): 1
참조 수(주문 3건 연결): 4
주문 1 처리, 남은 재고 12
주문 2 처리, 남은 재고 4
주문 3: 볼트 재고 부족 (재고 4, 요청 8)
참조 수(주문 정리 후): 1
읽는 중 쓰기 시도: 거부
읽기 끝난 뒤 쓰기 시도: 성공
경로: 본관/A선반/3번칸
본관 하위 구역 1개
본관 strong=1 weak=1
A선반 strong=2 weak=1
3번칸 strong=2 weak=0
범위 안 upgrade: true
해제: 임시구역
범위 밖 upgrade: false
SKU 길이 7, 소문자 bolt-m8
접두어 일치: true
main 종료 직전
해제: 본관
해제: A선반
해제: 3번칸
실무에서 자주 틀리는 것
1. Rc 만으로 값을 바꾸려 한다
주문 여러 건이 상품을 공유하게 만들고 Rc 만 쓰면 재고를 줄이는 순간 막힌다.
let p = Rc::new(Product { name: "볼트".to_string(), stock: 20 });
p.stock -= 8; // 컴파일 오류
오류 요지는 error[E0594]: cannot assign to data in an `Rc` 다. Rc 는 공유 접근만 주기 때문이다. 바꿀 값은 RefCell 로 감싼다.
let p = Rc::new(RefCell::new(Product { name: "볼트".to_string(), stock: 20 }));
p.borrow_mut().stock -= 8;
2. 순회 중에 같은 RefCell 을 수정한다
let log = RefCell::new(vec![1, 2]);
for n in log.borrow().iter() {
if *n == 1 {
log.borrow_mut().push(9); // 실행 중 패닉
}
}
컴파일은 통과하지만 실행하면 already borrowed: BorrowMutError 패닉이 난다. for 문이 끝날 때까지 읽기용 Ref 가 살아 있기 때문이다. 순회할 내용을 먼저 복사해 읽기 빌림을 끝낸 뒤 수정한다.
let snapshot: Vec<i32> = log.borrow().clone();
for n in snapshot {
if n == 1 {
log.borrow_mut().push(9);
}
}
3. 부모와 자식을 모두 Rc 로 연결한다
struct Zone {
name: String,
parent: RefCell<Option<Rc<Zone>>>, // 순환 소유
children: RefCell<Vec<Rc<Zone>>>,
}
컴파일도 되고 실행도 되지만, 프로그램이 끝날 때까지 Zone 이 해제되지 않아 Drop 의 출력도 나오지 않는다. 에러가 없는 누수라 찾기 어렵다. 자식에서 부모로 가는 쪽을 Weak 로 바꾼다.
parent: RefCell<Weak<Zone>>,
4. Weak 의 upgrade 결과를 검사하지 않는다
let name = child.parent.borrow().upgrade().unwrap().name.clone();
부모가 이미 해제됐다면 unwrap 이 패닉을 일으킨다. Weak 는 값이 사라질 수 있다는 사실을 타입으로 드러내므로 None 경로를 처리한다.
let name = match child.parent.borrow().upgrade() {
Some(p) => p.name.clone(),
None => "(부모 없음)".to_string(),
};
한눈에 보기
| 상황 | 쓰는 것 | 비용 | 주의 |
|---|---|---|---|
| 재귀 타입, 큰 값 이동 | Box<T> | 힙 할당 1회 | 소유자는 하나 |
| 한 값을 여럿이 읽기 | Rc<T> | 카운트 증감 | 스레드 간 불가 |
| 공유 값을 수정 | Rc<RefCell<T>> | 빌림 상태 검사 | 위반 시 패닉 |
| 부모를 가리키는 역참조 | Weak<T> | weak 카운트 | upgrade 가 None 일 수 있음 |
| 참조처럼 쓰이는 래퍼 | Deref 구현 | 없음 | 남용하면 의도가 흐려짐 |
| 해제 시 정리 작업 | Drop 구현 | 없음 | 직접 호출 불가 |
| 동작 | strong | weak | 비고 |
|---|---|---|---|
| Rc::new | 1 | 0 | 값 생성 |
| Rc::clone | +1 | 변화 없음 | 값은 복사하지 않음 |
| Rc::downgrade | 변화 없음 | +1 | 값을 살려 두지 않음 |
| upgrade 성공 | +1 | 변화 없음 | 돌려받은 Rc 가 해제되면 -1 |
| Rc 해제 | -1 | 변화 없음 | 0 이면 값 해제 |
자세한 API 는 std::rc 모듈 문서와 std::cell 모듈 문서에서 확인할 수 있다.
연습 문제
- History 에 가장 최근 입고 수량을 돌려주는
fn last(&self) -> Option<i32>를 추가하라. 이력이 비었으면 None 이다. - 다음 코드에서 두 번의
strong_count출력은 각각 얼마인가.let a = Rc::new(5); let b = Rc::clone(&a); { let c = Rc::clone(&b); println!("{}", Rc::strong_count(&a)); } println!("{}", Rc::strong_count(&a)); - 다음 코드는 실행 중 패닉이 난다. 이유를 설명하고 고쳐라.
let stock = RefCell::new(3); let current = stock.borrow(); *stock.borrow_mut() += *current; - Zone 에 자신이 트리의 몇 번째 깊이인지 돌려주는
fn depth(&self) -> usize를 추가하라. 최상위 구역은 0 이다. 완성 코드에서bin.depth()는 얼마가 되는가.
정답과 해설
- match 로 두 경우를 나눈다.
self가 참조라서qty는&i32이므로 값을 꺼낸다.
완성 코드의 history 에 호출하면 가장 나중에 push 한 7 이 나온다.fn last(&self) -> Option<i32> { match self { History::Empty => None, History::Entry { qty, .. } => Some(*qty), } } - 첫 번째는 3, 두 번째는 2 다. a, b, c 가 모두 같은 값의 소유자라 안쪽 블록에서는 3 이다. 블록이 끝나면 c 가 해제되어 카운트가 2 로 돌아온다.
- 첫 줄의
current는 Ref 라서 변수가 살아 있는 동안 읽기 빌림이 유지된다. 그 상태에서borrow_mut()를 부르니 규칙 위반으로 패닉이 난다. 값을 복사해 읽기 빌림을 먼저 끝내면 된다.
이제let current = *stock.borrow(); *stock.borrow_mut() += current;current는 i32 이고, 앞 줄의 Ref 는 문장이 끝날 때 해제된다. - 부모가 있으면 부모 깊이에 1 을 더하고, 없으면 0 이다.
fn depth(&self) -> usize { match self.parent.borrow().upgrade() { Some(p) => p.depth() + 1, None => 0, } }bin.depth()는 3번칸에서 A선반, 본관으로 두 번 올라가므로 2 다. Weak 의 upgrade 가 None 이면 최상위로 본다는 점에서 부모가 먼저 해제된 경우도 안전하게 처리된다.