`Rc<T>`, 참조 카운트 스마트 포인터
단일 스레드에서 Rc로 불변 데이터의 소유권을 공유하고 clone·drop에 따라 강한 참조 카운트가 변하는 과정을 추적합니다.
대부분의 경우 소유권은 분명합니다. 어떤 변수가 주어진 값을 소유하는지 정확히 압니다. 하지만 값 하나에 소유자가 여럿인 경우도 있습니다. 예를 들어 그래프 자료 구조에서는 여러 간선이 같은 노드를 가리킬 수 있으며, 그 노드는 개념적으로 자신을 가리키는 모든 간선이 소유합니다. 노드를 가리키는 간선이 하나도 없어 소유자가 없어지기 전까지는 노드를 정리하면 안 됩니다.
여러 소유권은 러스트의 Rc<T> 타입으로 명시적으로 켜야 합니다. Rc<T>는 참조 카운팅(reference counting)의 줄임말입니다. Rc<T> 타입은 값의 참조 수를 추적해, 그 값이 아직 쓰이고 있는지 판단합니다. 값의 참조가 하나도 없으면, 참조가 하나도 유효하지 않게 되지 않고 값을 정리할 수 있습니다.
Rc<T>를 거실의 텔레비전이라고 생각해 보세요. 한 사람이 텔레비전을 보러 들어오면 텔레비전을 켭니다. 다른 사람도 거실에 들어와 텔레비전을 볼 수 있습니다. 마지막 사람이 거실을 나갈 때 텔레비전을 끕니다. 다른 사람이 아직 보는데 누군가 나가며 텔레비전을 끄면, 남은 사람들이 항의할 것입니다.
Rc<T> 타입은 프로그램의 여러 부분이 읽을 데이터를 힙에 할당하고 싶은데, 컴파일 시점에 어느 부분이 그 데이터를 마지막으로 다 쓸지 알 수 없을 때 씁니다. 어느 부분이 마지막으로 끝날지 안다면, 그 부분을 데이터의 소유자로 정하면 되고 컴파일 시점에 적용되는 보통의 소유권 규칙이 작동할 것입니다.
Rc<T>는 단일 스레드 상황에서만 쓴다는 점을 알아 두세요. 이 카운트는 원자적(atomic)으로 바뀌지 않으므로 Rc<T>는 Send도 Sync도 아닙니다. 멀티스레드 프로그램에서 참조 카운팅을 하는 방법은 공유 상태 동시성에서 다룹니다.
Rc<T>를 사용하여 데이터 공유하기
Box<T>와 힙 데이터의 콘스 리스트 예제로 돌아갑니다. 그때는 Box<T>로 리스트를 정의했습니다. 이번에는 세 번째 리스트를 함께 소유하는 두 리스트를 만듭니다. 개념적으로는 아래 그림과 같습니다.
a, b, c가 같은 리스트를 공유한다
b와 c의 다음 포인터는 같은 5를 가리킨다. 각 Cons의 두 칸은 값과 다음 포인터다.
5, 10, Nil로 이어지는 리스트 a를 만듭니다. 그다음 3으로 시작하는 리스트 b와 4로 시작하는 리스트 c를 만들며, 두 리스트의 다음 칸은 모두 같은 5를 가리킵니다. 각 Cons의 두 칸은 값과 다음 칸을 가리키는 포인터입니다. 다시 말해 b와 c가 모두 5와 10이 든 첫 리스트를 함께 씁니다.
Box<T>를 쓴 List의 정의로 이 상황을 구현하려 하면, 다음 코드처럼 동작하지 않습니다.
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
let b = Cons(3, Box::new(a));
let c = Cons(4, Box::new(a));
}error[E0382]: use of moved value: `a`
--> src/main.rs:11:30
|
9 | let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
| - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 | let b = Cons(3, Box::new(a));
| - value moved here
11 | let c = Cons(4, Box::new(a));
| ^ value used here after move
|
note: if `List` implemented `Clone`, you could clone the value
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^ consider implementing `Clone` for this type
...
10 | let b = Cons(3, Box::new(a));
| - you could clone this value
For more information about this error, try `rustc --explain E0382`.Cons 배리언트는 자신이 가진 데이터를 소유하므로, 리스트 b를 만들 때 a가 b 안으로 옮겨 가고 b가 a를 소유합니다. 그다음 c를 만들 때 a를 다시 쓰려 하면, a가 이미 옮겨졌으므로 허용되지 않습니다.
Cons의 정의를 참조를 담도록 바꿀 수도 있지만, 그러면 라이프타임 매개변수를 지정해야 합니다. 라이프타임 매개변수를 지정하면 리스트의 모든 원소가 적어도 리스트 전체만큼 오래 산다고 지정하게 됩니다. 앞의 코드의 원소와 리스트에서는 그렇지만, 모든 상황에서 그렇지는 않습니다.
대신 다음 코드처럼 Box<T> 자리에 Rc<T>를 쓰도록 List의 정의를 바꿉니다. 이제 각 Cons 배리언트는 값 하나와, List를 가리키는 Rc<T>를 가집니다. b를 만들 때 a의 소유권을 가져가는 대신, a가 가진 Rc<List>를 클론해 참조의 수를 하나에서 둘로 늘리고, a와 b가 그 Rc<List> 안의 데이터의 소유권을 함께 갖게 합니다. c를 만들 때도 a를 클론해 참조의 수를 둘에서 셋으로 늘립니다. Rc::clone을 부를 때마다 Rc<List> 안의 데이터에 대한 참조 카운트가 늘어나며, 참조가 0이 되기 전까지 데이터는 정리되지 않습니다.
enum List {
Cons(i32, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
let b = Cons(3, Rc::clone(&a));
let c = Cons(4, Rc::clone(&a));
}Rc<T>는 프렐류드에 없으므로 use 문으로 스코프로 가져와야 합니다. main에서는 5와 10이 든 리스트를 만들어 새 Rc<List>인 a에 저장합니다. 그다음 b와 c를 만들 때 Rc::clone 함수를 부르고, a의 Rc<List>의 참조를 인수로 넘깁니다.
Rc::clone(&a) 대신 a.clone()을 부를 수도 있지만, 이 경우에는 Rc::clone을 쓰는 것이 러스트의 관례입니다. Rc::clone의 구현은 대부분의 타입의 clone 구현처럼 모든 데이터를 깊게 복사하지 않습니다. Rc::clone은 참조 카운트를 늘리기만 하므로 시간이 거의 걸리지 않습니다. 데이터를 깊게 복사하는 데는 시간이 많이 걸릴 수 있습니다. 참조 카운팅에 Rc::clone을 쓰면, 깊은 복사를 하는 클론과 참조 카운트를 늘리는 클론을 눈으로 구분할 수 있습니다. 코드의 성능 문제를 찾을 때는 깊은 복사를 하는 클론만 살펴보면 되고, Rc::clone 호출은 무시해도 됩니다.
Rc<T>를 클론하는 것은 참조 카운트를 증가시킵니다
앞의 예제를 고쳐, a의 Rc<List>의 참조를 만들고 버릴 때 참조 카운트가 바뀌는 것을 봅니다. 다음 코드에서는 main을 고쳐 리스트 c를 안쪽 스코프에 두어, c가 스코프를 벗어날 때 참조 카운트가 어떻게 바뀌는지 봅니다.
enum List {
Cons(i32, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
println!("count after creating a = {}", Rc::strong_count(&a));
let b = Cons(3, Rc::clone(&a));
println!("count after creating b = {}", Rc::strong_count(&a));
{
let c = Cons(4, Rc::clone(&a));
println!("count after creating c = {}", Rc::strong_count(&a));
}
println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}프로그램에서 참조 카운트가 바뀌는 곳마다, Rc::strong_count 함수를 불러 얻은 참조 카운트를 출력합니다. 이 함수의 이름이 count가 아니라 strong_count인 까닭은 Rc<T> 타입에 weak_count도 있기 때문입니다. weak_count가 무엇에 쓰이는지는 순환 참조 방지하기에서 봅니다. 이 코드는 다음을 출력합니다.
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2a의 Rc<List>는 처음 참조 카운트가 1입니다. 그 뒤 clone을 부를 때마다 카운트가 1씩 늘어납니다. c가 스코프를 벗어나면 카운트가 1 줄어듭니다. 참조 카운트를 늘리려면 Rc::clone을 불러야 하지만, 참조 카운트를 줄이는 함수는 부르지 않아도 됩니다. Rc<T> 값이 스코프를 벗어날 때 Drop 트레이트의 구현이 참조 카운트를 자동으로 줄입니다. 아래 그림은 이 강한 참조 카운트가 바뀌는 과정을 일반적인 모델로 보여 줍니다.
Rc 강한 참조 카운트 수명
생성/clone/drop으로 strong 카운트 1→2→1→0, T 정리와 Weak 남은 관리 할당/마지막 해제의 자원 상태이다. 본문 1→2→3→2 예와 일반 모델을 구분한다.
SEQUENCE · SINGLE-THREAD SHARED OWNER
Rc::clone은 데이터를 복사하지 않고 강한 소유자를 늘린다
Rc::clone은 같은 할당의 강한 소유자를 하나 늘린다. 강한 카운트가 0이 되면 내부 T가 drop되지만 Weak가 남아 있으면 할당의 관리 영역은 더 오래 유지될 수 있다.
- 첫 소유자
Rc::new로 강한 소유자 1개가 생깁니다.
- 얕은 clone
Rc::clone은 같은 할당의 강한 카운트를 늘리고 T를 깊게 복제하지 않습니다.
- 중첩 소유자 종료
복제본이 끝나면 강한 카운트가 줄지만 T는 아직 살아 있습니다.
- 마지막 강한 소유자
강한 카운트가 0이 되면 내부 T가 drop됩니다.
- Weak 꼬리
Weak가 남아 있으면 관리 할당은 유지되며 마지막 Weak가 끝날 때 해제됩니다.
본문 예제 14-19 · a가 가리키는 공유 리스트의 카운트
main 끝에서는 b 안의 Rc가 사라져 1, 이어 a가 사라져 0이 됩니다. 위 시퀀스는 중첩 clone 하나의 일반 모델이며 본문 숫자와 구분합니다. Weak 관리 수명은 다음 절의 확장 설명입니다.
Rc는 비원자적 단일 스레드 공유 소유권이며 Send도 Sync도 아니다. 공유 변경에는 Cell·RefCell이 필요하고, 유일 접근의 get_mut 또는 복사-후-쓰기 make_mut는 별도 경로다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4참여자와 7메시지를 세로 도표에서는 7개의 독립적인 송신/수신 쌍으로 반복 표현한다. 번호는 시간 순서이고 화살표는 실제 메시지 방향이며 그룹 사이에 새 간선은 없다. 마지막 Weak→Rc 할당의 반환 방향도 유지한다. 중첩 clone 하나의 일반 모델 1→2→1→0이며 본문 List의 1→2→3→2와 구별한다. Weak가 남았을 때 T는 정리됐어도 관리 할당은 유지될 수 있고 마지막 Weak가 종료될 때 해제된다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 강한 소유 시작 일반 모델의 Rc와 Weak를 준비한다. |
|
|
| clone·drop 강한 핸들 복제와 정리로 카운트를 바꾼다. |
|
|
| 마지막 강한 소유 strong 0에서 T를 정리하되 Weak가 있으면 관리 할당은 남는다. |
|
|
Rc 강한 참조 카운트 수명
생성/clone/drop으로 strong 카운트 1→2→1→0, T 정리와 Weak 남은 관리 할당/마지막 해제의 자원 상태이다. 본문 1→2→3→2 예와 일반 모델을 구분한다.
SEQUENCE · SINGLE-THREAD SHARED OWNER
Rc::clone은 데이터를 복사하지 않고 강한 소유자를 늘린다
Rc::clone은 같은 할당의 강한 소유자를 하나 늘린다. 강한 카운트가 0이 되면 내부 T가 drop되지만 Weak가 남아 있으면 할당의 관리 영역은 더 오래 유지될 수 있다.
- 첫 소유자
Rc::new로 강한 소유자 1개가 생깁니다.
- 얕은 clone
Rc::clone은 같은 할당의 강한 카운트를 늘리고 T를 깊게 복제하지 않습니다.
- 중첩 소유자 종료
복제본이 끝나면 강한 카운트가 줄지만 T는 아직 살아 있습니다.
- 마지막 강한 소유자
강한 카운트가 0이 되면 내부 T가 drop됩니다.
- Weak 꼬리
Weak가 남아 있으면 관리 할당은 유지되며 마지막 Weak가 끝날 때 해제됩니다.
본문 예제 14-19 · a가 가리키는 공유 리스트의 카운트
main 끝에서는 b 안의 Rc가 사라져 1, 이어 a가 사라져 0이 됩니다. 위 시퀀스는 중첩 clone 하나의 일반 모델이며 본문 숫자와 구분합니다. Weak 관리 수명은 다음 절의 확장 설명입니다.
Rc는 비원자적 단일 스레드 공유 소유권이며 Send도 Sync도 아니다. 공유 변경에는 Cell·RefCell이 필요하고, 유일 접근의 get_mut 또는 복사-후-쓰기 make_mut는 별도 경로다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4참여자와 7메시지를 세로 도표에서는 7개의 독립적인 송신/수신 쌍으로 반복 표현한다. 번호는 시간 순서이고 화살표는 실제 메시지 방향이며 그룹 사이에 새 간선은 없다. 마지막 Weak→Rc 할당의 반환 방향도 유지한다. 중첩 clone 하나의 일반 모델 1→2→1→0이며 본문 List의 1→2→3→2와 구별한다. Weak가 남았을 때 T는 정리됐어도 관리 할당은 유지될 수 있고 마지막 Weak가 종료될 때 해제된다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 강한 소유 시작 일반 모델의 Rc와 Weak를 준비한다. |
|
|
| clone·drop 강한 핸들 복제와 정리로 카운트를 바꾼다. |
|
|
| 마지막 강한 소유 strong 0에서 T를 정리하되 Weak가 있으면 관리 할당은 남는다. |
|
|
그림의 위쪽 시퀀스는 클론 하나를 만들었다 버리는 일반적인 모델로, 강한 카운트가 1, 2, 1, 0으로 바뀝니다. Rc::new로 첫 강한 소유자가 생기고, Rc::clone은 같은 할당의 강한 카운트만 늘리며 T를 깊게 복제하지 않습니다. 강한 카운트가 0이 되면 안쪽의 T가 버려집니다. 그림에서 예제 14-19라고 적은 칸이 이 글의 코드이며, a를 만들 때 1, b의 클론으로 2, c의 클론으로 3, c가 스코프를 벗어나며 2가 됩니다. main이 끝나면 b 안의 Rc가 사라져 1이 되고, 이어서 a가 사라져 0이 되며, 그때 Rc<List>가 완전히 정리됩니다. 그림의 Weak가 남아 있을 때 관리 영역이 더 오래 남는 부분은 순환 참조와 메모리 누수에서 다룹니다.
Rc<T>를 쓰면 값 하나에 소유자가 여럿일 수 있고, 카운트는 소유자가 하나라도 남아 있는 동안 값이 유효하도록 보장합니다.
Rc<T>는 불변 참조를 통해 데이터를 프로그램의 여러 부분이 읽기 전용으로 함께 쓰게 해 줍니다. Rc<T>로 가변 참조도 여럿 가질 수 있다면, 참조와 대여에서 이야기한 대여 규칙 가운데 하나를 어기게 될 것입니다. 같은 곳을 가리키는 여러 가변 대여는 데이터 경쟁과 불일치를 일으킬 수 있습니다. 하지만 데이터를 바꿀 수 있는 것은 아주 쓸모가 있습니다. 다음 글에서는 내부 가변성 패턴과, 이 불변성의 제한과 함께 쓸 수 있는 RefCell<T> 타입을 다룹니다. 유일한 소유자일 때 쓰는 Rc::get_mut이나, 공유되어 있으면 복사한 뒤 바꾸는 Rc::make_mut도 있지만, 이것들은 공유한 값을 함께 바꾸는 방법은 아닙니다.
정리
Rc<T>는 참조 카운트로 값 하나에 여러 소유자를 허용하며, 마지막 소유자가 사라질 때 값을 정리합니다.Rc::clone(&a)은 데이터를 깊게 복사하지 않고 강한 참조 카운트만 늘리므로, 깊은 복사를 하는clone과 구분하기 위해 이 형태로 씁니다.Rc<T>값이 스코프를 벗어나면Drop구현이 카운트를 자동으로 줄이며,Rc::strong_count로 지금의 카운트를 볼 수 있습니다.Rc<T>는 카운트를 원자적으로 바꾸지 않으므로 단일 스레드에서만 씁니다.Rc<T>가 주는 공유는 읽기 전용이며, 공유한 값을 바꾸려면 다음 글의RefCell<T>와 함께 씁니다.
연습 문제
1. 카운트 추적
다음 코드의 각 println!은 무엇을 출력하나요? Rc::clone이 카운트를 어떻게 바꾸는지, 안쪽 스코프가 끝날 때와 drop(b)를 부를 때 각각 무엇이 버려지는지를 따라가 보세요.
let a = Rc::new(5);
let b = Rc::clone(&a);
println!("{}", Rc::strong_count(&a));
{
let c = Rc::clone(&b);
println!("{}", Rc::strong_count(&c));
}
drop(b);
println!("{}", Rc::strong_count(&a));해설 보기
a와 b가 같은 할당을 가리키므로 첫 출력은 2입니다. 안쪽 블록에서 c를 클론하면 3이 되며, Rc::strong_count(&c)도 같은 할당의 카운트이므로 3을 출력합니다. 블록이 끝나면 c가 버려져 2가 되고, drop(b)로 1이 되므로 마지막 출력은 1입니다.
2. 왜 바꿀 수 없을까
let a = Rc::new(String::from("hi")); a.push_str("!");는 컴파일되지 않습니다. 까닭은 무엇이며, 바꾸려면 어떤 방법이 있나요?
해설 보기
Rc<T>는 Deref로 &T만 내주고 DerefMut는 구현하지 않으므로, push_str이 필요로 하는 &mut String을 얻을 수 없습니다. 여러 소유자가 같은 값을 동시에 바꾸면 대여 규칙을 어기게 되기 때문입니다. 공유한 값을 바꾸려면 Rc<RefCell<String>>처럼 다음 글의 RefCell<T>와 함께 쓰고, 소유자가 하나뿐일 때만 바꾸면 된다면 Rc::get_mut을 씁니다.
다음 글 RefCell<T>와 내부 가변성 패턴에서는 불변 참조로도 안쪽 값을 바꿀 수 있게 해 주는 RefCell<T>를 살펴봅니다.