순환 참조와 메모리 누수
Rc와 RefCell의 강한 순환 참조가 메모리를 남기는 과정을 확인하고 Weak 부모 참조로 트리의 소유권 순환을 끊습니다.
러스트의 메모리 안전성은 예기치 않은 해제를 막아 메모리 누수(memory leak)를 만들기 어렵게 하지만, 완전히 불가능하게 하지는 않습니다.
메모리 누수의 완전한 방지는 러스트의 보장 범위에 포함되지 않으며, Rc<T>와 RefCell<T>를 함께 사용할 때 이 특성을 확인할 수 있습니다.
아이템들이 서로를 순환 참조하면 고리 안의 참조 카운트가 0이 되지 않아 값이 버려지지 않고, 결과적으로 메모리 누수가 발생할 수 있습니다.
순환 참조 만들기
예제 14-25의 List 열거형과 tail 메서드 정의를 시작으로
어떻게 순환 참조가 생길 수 있고, 이를 어떻게 방지하는지
알아봅시다.
RefCell<T>를 가지고 있어서
Cons 배리언트가 참조하는 것을 변경할 수 있는 cons 리스트 정의
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
fn main() {}
예제 14-5의 List 정의의 또 다른 변형이 이용되고 있습니다.
이제 Cons 배리언트 내의 두 번째 요소는 RefCell<Rc<List>>인데, 이는 예제 14-24에서 했던 것처럼 i32 값을 변경하는 능력을 가진 대신, Cons 배리언트가 가리키고 있는 List 값을 변경하길 원한다는 의미입니다.
또한 tail 메서드를 추가하여 Cons 배리언트를 갖고 있다면 두 번째 아이템에 접근하기 편하게 만들었습니다.
예제 14-26에서는 예제 14-25에서 사용한 main 함수를 추가하고 있습니다.
이 코드는 a에 리스트를 만들고 b에는 a의 리스트를 가리키고 있는 리스트를 만들어 넣었습니다.
그다음 a의 리스트가 b를 가리키도록 수정하는데, 이것이 순환 참조를 생성합니다.
이 과정에서 참조 카운트가 얼마인지 여러 곳에서 확인하기 위해 곳곳에 println! 구문들을 넣었습니다.
List 값이 서로를 가리키는
순환 참조 생성하기
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
println!("a initial rc count = {}", Rc::strong_count(&a));
println!("a next item = {:?}", a.tail());
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
println!("a rc count after b creation = {}", Rc::strong_count(&a));
println!("b initial rc count = {}", Rc::strong_count(&b));
println!("b next item = {:?}", b.tail());
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}
println!("b rc count after changing a = {}", Rc::strong_count(&b));
println!("a rc count after changing a = {}", Rc::strong_count(&a));
// Uncomment the next line to see that we have a cycle;
// it will overflow the stack
// println!("a next item = {:?}", a.tail());
}
초깃값 리스트 5, Nil을 가진 List 값을 갖는 Rc<List> 인스턴스를 만들어 a 변수에 넣었습니다.
그다음 Rc<List> 인스턴스를 만들어서 b에 넣었는데, 여기에는 10과 a의 리스트를 가리키고 있는 또 다른 List 값이 있습니다.
a를 수정하여 이것이 Nil 대신 b를 가리키도록 하였는데, 이렇게 순환이 만들어집니다.
이는 tail 메서드를 사용하여 a에 있는 RefCell<Rc<List>>로부터 참조자를 얻어오는 식으로 이루어졌는데, 이것을 link라는 변수에 넣었습니다.
그다음 RefCell<Rc<List>>의 borrow_mut 메서드를 사용하여 셀 안에 저장된 Nil을 가리키는 Rc<List> 값을 b의 Rc<List>로 바꾸었습니다.
잠시 마지막 println! 문이 실행되지 않도록 주석 처리하고서 이 코드를 실행시키면
아래와 같은 출력을 얻게 됩니다.
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished dev [unoptimized + debuginfo] target(s) in 0.53s
Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2
a의 리스트가 b를 가리키도록 변경한 이후 a와 b의 Rc<List> 인스턴스의 참조 카운트는 둘 다 2입니다.
main의 끝에서 러스트는 b를 버리는데, 이는 b의 Rc<List> 참조 카운트를 2에서 1로 줄입니다.
Rc<List>가 힙에 보유한 메모리는 이 시점에서 해제되지 않을 것인데, 그 이유는 참조 카운트가 0이 아닌 1이기 때문입니다.
그런 다음 러스트는 a를 버리고, 이는 마찬가지로 a의 Rc<List> 인스턴스가 가진 참조 카운트를 2에서 1로 줄입니다.
이 인스턴스의 메모리 또한 버려질 수 없는데, 왜냐하면 이쪽의 Rc<List> 인스턴스도 여전히 무언가를 참조하기 때문입니다.
리스트에 할당된 메모리는 정리되지 않은 채 영원히 남을 것입니다.
이러한 순환 참조를 시각화하기 위해 그림 14-4의 다이어그램을 만들었습니다.
a와 b가 서로를 가리키고
있는 순환 참조
만일 여러분이 마지막 println!의 주석을 해제하고 프로그램을 실행해 보면, 러스트는
a를 가리키고 있는 b를 가리키고 있는 a를 가리키고 있는 등등 스택 오버플로우가
날 때까지 이 순환을 출력하려 할 것입니다.
실제 프로그램과 비교했을 때 이 예제에서의 순환 참조 생성 결과는 그렇게까지 심각하진 않습니다.
순환 참조가 생성된 직후 프로그램이 종료되니까요.
하지만, 더 복잡한 프로그램이 많은 양의 메모리를 순환 참조하여 할당하고 오랜 시간 동안 이걸 가지고 있게 되면, 프로그램은 필요한 양보다 더 많은 메모리를 사용하게 되고 사용 가능한 메모리를 다 써버리게 되어 시스템을 멈추게 할지도 모릅니다.
예제 14-26에서 a와 b가 서로를 강한 참조로 붙잡으면, 스코프가 끝날 때
카운트가 어떻게 남는지 아래처럼 따라갈 수 있습니다.
Rc 순환과 Weak 비소유 간선
본문 a/b strong 2에서 외부 b/a가 사라져도 순환 각각 1이 남는 상태를 보여 줄 수 있다. Weak 역방향 비교는 별도 설계이며 실행 중 자동 교체가 아니다.
DEPENDENCY · OWNERSHIP CYCLE
강한 순환은 남고 Weak 역방향 간선은 수명을 소유하지 않는다
강한 Rc 간선만으로 닫힌 순환을 만들면 외부 소유자가 사라져도 각 강한 카운트가 0이 되지 않는다. 역방향 관찰 간선을 Weak로 설계하면 값의 수명을 소유하지 않으며 upgrade는 Option을 반환한다.
- 강한 순환
Node A와 B가 서로를 Rc로 강하게 소유하면 외부 소유자가 사라져도 강한 카운트가 남습니다.
- 수명 결과
강한 순환은 메모리 안전하지만 내부 T를 drop하지 못해 논리적 누수를 만듭니다.
- Weak 선택
부모 관찰처럼 소유하지 않아야 할 역방향 간선을 Weak로 바꿉니다.
- upgrade
대상이 살아 있을 때 Some(Rc<T>), drop된 뒤 None이므로 항상 Option을 처리합니다.
본문의 Cons(5)·Cons(10) · 외부 변수 종료 후에도 1
a와 b가 서로 강한 Rc를 저장한 뒤 둘의 strong_count는 각각 2입니다. main의 b, a가 순서대로 사라져도 각 노드는 상대가 가진 Rc 때문에 1을 유지합니다. 순환 출력의 stack overflow와 참조 카운트로 인한 값 누수는 다른 현상입니다.
Weak가 임의의 순환을 자동으로 고치지는 않는다. 소유하지 않아야 할 간선을 설계자가 명시적으로 골라야 하며, 강한 순환은 메모리 안전하지만 논리적 수명과 자원 사용을 깨뜨릴 수 있다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 두 영역은 독립적인 대안이다. 각 외부 Rc→상위 노드→하위 노드는 강한 소유권을 나타낸다. 왼쪽 하위 B→A 역방향도 강하므로 외부 소유자가 끝나도 순환이 남고, 오른쪽 Child→Parent는 Weak 점선으로 수명을 소유하지 않는다. 총 6노드와 6간선은 가로/세로 도표에서 동일하며 두 영역 사이에 간선은 없다. 역방향 라벨은 각 외부 레일에서 8px 떨어진 빈 공간에 놓고 레일을 덮지 않는다. Weak는 임의의 다른 강한 순환을 자동 제거하지 않는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 강한 순환 a/b의 서로 강한 연결과 외부 소유가 있다. |
|
|
| 외부 소유 종료 외부 b/a가 사라져도 순환 소유는 남는다. |
|
|
| 대안 비교 Weak 역방향 설계를 별도 사본으로 놓는다. |
|
|
Rc 순환과 Weak 비소유 간선
본문 a/b strong 2에서 외부 b/a가 사라져도 순환 각각 1이 남는 상태를 보여 줄 수 있다. Weak 역방향 비교는 별도 설계이며 실행 중 자동 교체가 아니다.
DEPENDENCY · OWNERSHIP CYCLE
강한 순환은 남고 Weak 역방향 간선은 수명을 소유하지 않는다
강한 Rc 간선만으로 닫힌 순환을 만들면 외부 소유자가 사라져도 각 강한 카운트가 0이 되지 않는다. 역방향 관찰 간선을 Weak로 설계하면 값의 수명을 소유하지 않으며 upgrade는 Option을 반환한다.
- 강한 순환
Node A와 B가 서로를 Rc로 강하게 소유하면 외부 소유자가 사라져도 강한 카운트가 남습니다.
- 수명 결과
강한 순환은 메모리 안전하지만 내부 T를 drop하지 못해 논리적 누수를 만듭니다.
- Weak 선택
부모 관찰처럼 소유하지 않아야 할 역방향 간선을 Weak로 바꿉니다.
- upgrade
대상이 살아 있을 때 Some(Rc<T>), drop된 뒤 None이므로 항상 Option을 처리합니다.
본문의 Cons(5)·Cons(10) · 외부 변수 종료 후에도 1
a와 b가 서로 강한 Rc를 저장한 뒤 둘의 strong_count는 각각 2입니다. main의 b, a가 순서대로 사라져도 각 노드는 상대가 가진 Rc 때문에 1을 유지합니다. 순환 출력의 stack overflow와 참조 카운트로 인한 값 누수는 다른 현상입니다.
Weak가 임의의 순환을 자동으로 고치지는 않는다. 소유하지 않아야 할 간선을 설계자가 명시적으로 골라야 하며, 강한 순환은 메모리 안전하지만 논리적 수명과 자원 사용을 깨뜨릴 수 있다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 두 영역은 독립적인 대안이다. 각 외부 Rc→상위 노드→하위 노드는 강한 소유권을 나타낸다. 왼쪽 하위 B→A 역방향도 강하므로 외부 소유자가 끝나도 순환이 남고, 오른쪽 Child→Parent는 Weak 점선으로 수명을 소유하지 않는다. 총 6노드와 6간선은 가로/세로 도표에서 동일하며 두 영역 사이에 간선은 없다. 역방향 라벨은 각 외부 레일에서 8px 떨어진 빈 공간에 놓고 레일을 덮지 않는다. Weak는 임의의 다른 강한 순환을 자동 제거하지 않는다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 강한 순환 a/b의 서로 강한 연결과 외부 소유가 있다. |
|
|
| 외부 소유 종료 외부 b/a가 사라져도 순환 소유는 남는다. |
|
|
| 대안 비교 Weak 역방향 설계를 별도 사본으로 놓는다. |
|
|
순환 참조를 만드는 것은 쉽게 이루어지지는 않지만, 불가능한 것도 아닙니다.
만일 여러분이 Rc<T> 값을 가지고 있는 RefCell<T> 혹은 그와 유사하게 내부 가변성 및 참조 카운팅 기능이 있는 타입들의 중첩된 조합을 사용한다면, 여러분이 직접 순환을 만들지 않음을 보장해야 합니다; 이 순환을 찾아내는 것을 러스트에 의지할 수는 없습니다.
순환 참조를 만드는 것은 프로그램의 논리적 버그로서, 자동화된 테스트, 코드 리뷰, 그 밖의 소프트웨어 개발 실천 방법 등을 통해 최소화해야 할 것입니다.
순환 참조를 피하는 또 다른 해결책은 데이터 구조를 재구성하여 어떤 참조자는 소유권을 갖고 어떤 참조자는 그렇지 않도록 하는 것입니다.
결과적으로 몇 개의 소유권 관계와 몇 개의 소유권 없는 관계로 이루어진 순환을 만들 수 있으며, 소유권 관계들만이 값을 버릴지 말지에 관해 영향을 주게 됩니다.
예제 14-25에서는 Cons 배리언트가 언제나 리스트를 소유하기를 원하므로, 데이터 구조를 재구성하는 것은 불가능합니다.
부모 노드와 자식 노드로 구성된 그래프를 이용한 예제를 살펴보면서 소유권 없는 관계가 순환 참조를 방지하는 적절한 방법이 되는 때가 언제인지 알아봅시다.
순환 참조 방지하기: Rc<T>를 Weak<T>로 바꾸기
지금까지 Rc::clone을 호출하는 것은 Rc<T> 인스턴스의 strong_count를 증가시키고, Rc<T> 인스턴스는 자신의 strong_count가 0이 된 경우에만 제거되는 것을 보았습니다.
Rc::downgrade에 Rc<T>의 참조자를 넣어서 호출하면 Rc<T> 인스턴스 내의 값을 가리키는 약한 참조(weak reference)를 만드는 것도 가능합니다.
강한 참조는 Rc<T> 인스턴스의 소유권을 공유할 수 있는 방법입니다.
약한 참조는 소유권 관계를 표현하지 않고, 약한 참조의 개수는 Rc<T> 인스턴스가 제거되는 경우에 영향을 주지 않습니다.
아래 부모·자식 예처럼 소유권 순환을 만드는 역방향 간선을 약한 참조로 바꾸면, 그 간선은 부모를 살려 두지 않습니다. 다른 강한 순환이 따로 남아 있다면 Weak 하나를 넣었다는 이유만으로 모두 해결되는 것은 아닙니다.
Rc::downgrade를 호출하면 Weak<T> 타입의 스마트 포인터를 얻게 됩니다.
Rc::downgrade는 Rc<T> 인스턴스의 strong_count를 1 증가시키는 대신 weak_count를 1 증가시킵니다.
Rc<T> 타입은 strong_count와 유사한 방식으로 weak_count를 사용하여 Weak<T> 참조가 몇 개 있는지 추적합니다.
차이점은 내부 T 값이 drop되기 위해 약한 참조가 0일 필요는 없다는 것입니다. Weak가 남아 있으면 관리 할당은 계속 존재할 수 있고 마지막 Weak가 사라질 때 해제됩니다.
Weak<T>가 참조하고 있는 값이 이미 버려졌을지도 모르기 때문에, Weak<T>가 가리키고 있는 값으로 어떤 일을 하기 위해서는 그 값이 여전히 존재하는지를 반드시 확인해야 합니다.
이를 위해 Weak<T>의 upgrade 메서드를 호출하는데, 이 메서드는 Option<Rc<T>>를 반환할 것입니다.
만일 Rc<T> 값이 아직 버려지지 않았다면 Some 결과를 얻게 될 것이고 Rc<T> 값이 버려졌다면 None 결괏값을 얻게 될 것입니다.
upgrade의 결과가 Option<Rc<T>>이므로 Some 안의 Rc를 사용하기 전에 None인 경우를 처리해야 합니다. None이면 대상의 내부 값을 더 이상 사용할 수 없습니다.
강한 참조와 약한 참조를 역할로 나누면 순환 방지 기준을 더 쉽게 잡을 수 있습니다.
Weak 부모 참조와 upgrade 수명
leaf만 생성→branch 5가 leaf 3 소유/Weak 부모→branch 마지막 strong drop→leaf strong 1/upgrade None의 구체적 수명·카운트 변화이다.
RUST · WEAK PARENT & LIVE CHILD
부모가 사라져도 바깥 Rc가 가진 leaf는 살아 있다
본문 예제 14-29의 branch(5)와 leaf(3)를 따라갑니다. 강한 children 간선은 소유권, 약한 parent 간선은 관찰이며 아래 숫자는 반환 Rc를 추가로 보유하지 않는 시점입니다.
1 · leaf만 생성
leaf: strong 1, weak 0. parent는 Weak::new()이므로 upgrade는 None입니다.
2 · 내부 scope의 branch
branch가 leaf의 Rc를 저장하고 leaf.parent에 Rc::downgrade(&branch)를 설정합니다. branch: 1/1, leaf: 2/0. parent upgrade는 Some(Rc<Node>)입니다.
3 · 마지막 branch Rc drop
branch의 Node가 drop되고 children 안 Rc도 사라집니다. 외부 leaf는 남아 strong 1, weak 0이고 parent upgrade는 None입니다. parent Weak는 내부 값의 수명을 늘리지 않습니다.
upgrade의 Some 안 Rc를 저장하면 새 강한 소유자가 생기므로 부모는 그것이 끝날 때까지 살아 있습니다. Weak의 관리 할당 수명은 내부 Node 값의 수명과 다릅니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | branch 값 5는 leaf 값 3을 children의 Rc로 소유한다. leaf의 parent는 branch의 Weak다. scope 안의 카운트는 branch strong 1, weak 1, leaf strong 2, weak 0이다. |
| 관계·조건 설명 | 원래 가로 도표의 두 노드와 두 방향 간선이다. branch.children의 Rc는 branch→leaf의 강한 소유권, leaf.parent의 Weak는 leaf→branch의 관찰 점선이다. 수치는 내부 branch가 살아 있는 본문의 중간 상태인 branch strong 1, weak 1 / leaf strong 2, weak 0이며 순차적인 두 값이 아니다. 마지막 branch Rc가 끝나면 leaf strong 1, weak 0이고 upgrade는 None이다. 별도 upgrade의 Some으로 얻은 Rc를 계속 보유하면 branch의 수명이 연장되므로 마지막이라는 조건을 지킨다. |
| 노드·연결선 라벨 | children이 leaf를 소유 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| leaf만 존재 leaf 3을 외부 Rc가 소유한다. |
|
|
| branch 연결 branch 5가 leaf를 소유하고 leaf는 Weak 부모를 보관한다. |
|
|
| branch 수명 종료 마지막 branch strong이 사라진 뒤 leaf가 남는다. |
|
|
Weak 부모 참조와 upgrade 수명
leaf만 생성→branch 5가 leaf 3 소유/Weak 부모→branch 마지막 strong drop→leaf strong 1/upgrade None의 구체적 수명·카운트 변화이다.
RUST · WEAK PARENT & LIVE CHILD
부모가 사라져도 바깥 Rc가 가진 leaf는 살아 있다
본문 예제 14-29의 branch(5)와 leaf(3)를 따라갑니다. 강한 children 간선은 소유권, 약한 parent 간선은 관찰이며 아래 숫자는 반환 Rc를 추가로 보유하지 않는 시점입니다.
1 · leaf만 생성
leaf: strong 1, weak 0. parent는 Weak::new()이므로 upgrade는 None입니다.
2 · 내부 scope의 branch
branch가 leaf의 Rc를 저장하고 leaf.parent에 Rc::downgrade(&branch)를 설정합니다. branch: 1/1, leaf: 2/0. parent upgrade는 Some(Rc<Node>)입니다.
3 · 마지막 branch Rc drop
branch의 Node가 drop되고 children 안 Rc도 사라집니다. 외부 leaf는 남아 strong 1, weak 0이고 parent upgrade는 None입니다. parent Weak는 내부 값의 수명을 늘리지 않습니다.
upgrade의 Some 안 Rc를 저장하면 새 강한 소유자가 생기므로 부모는 그것이 끝날 때까지 살아 있습니다. Weak의 관리 할당 수명은 내부 Node 값의 수명과 다릅니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | branch 값 5는 leaf 값 3을 children의 Rc로 소유한다. leaf의 parent는 branch의 Weak다. scope 안의 카운트는 branch strong 1, weak 1, leaf strong 2, weak 0이다. |
| 관계·조건 설명 | 원래 가로 도표의 두 노드와 두 방향 간선이다. branch.children의 Rc는 branch→leaf의 강한 소유권, leaf.parent의 Weak는 leaf→branch의 관찰 점선이다. 수치는 내부 branch가 살아 있는 본문의 중간 상태인 branch strong 1, weak 1 / leaf strong 2, weak 0이며 순차적인 두 값이 아니다. 마지막 branch Rc가 끝나면 leaf strong 1, weak 0이고 upgrade는 None이다. 별도 upgrade의 Some으로 얻은 Rc를 계속 보유하면 branch의 수명이 연장되므로 마지막이라는 조건을 지킨다. |
| 노드·연결선 라벨 | children이 leaf를 소유 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| leaf만 존재 leaf 3을 외부 Rc가 소유한다. |
|
|
| branch 연결 branch 5가 leaf를 소유하고 leaf는 Weak 부모를 보관한다. |
|
|
| branch 수명 종료 마지막 branch strong이 사라진 뒤 leaf가 남는다. |
|
|
예제로 리스트처럼 어떤 아이템이 오직 다음 아이템에 대해서만 알고 있는 데이터 구조 말고, 자식 아이템 그리고 부모 아이템에 대해 모두 알고 있는 아이템을 갖는 트리를 만들어 보겠습니다.
트리 데이터 구조 만들기: 자식 노드를 가진 Node
자기 자식 노드에 대해 알고 있는 노드로 이루어진 트리를 만드는 것으로 시작해 보겠습니다.
i32 값과 함께 자식 Node 값들의 참조자들도 가지고 있는 Node라는 이름의 구조체를 만들겠습니다.
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>,
}
Node가 자기 자식들을 소유하도록 하고, 이 소유권을 공유하여 트리의 각 Node에 직접 접근할 수 있도록 하고 싶습니다.
이렇게 하기 위해 Vec<T> 아이템이 Rc<Node> 타입의 값이 되도록 정의하였습니다.
또한 어떤 노드가 다른 노드의 자식이 되도록 수정하려고 Vec<Rc<Node>>를 RefCell<T>로 감싼 children을 갖도록 하였습니다.
그다음 예제 14-27처럼 이 구조체 정의를 이용하여 3 값을 갖고 자식 노드가 없는
leaf라는 이름의 Node 인스턴스, 그리고 5 값과 leaf를 자식으로 갖는
branch라는 이름의 인스턴스를 만들도록 하겠습니다.
leaf 노드와
이 leaf를 자식으로 갖는 branch 노드 만들기
fn main() {
let leaf = Rc::new(Node {
value: 3,
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
}
leaf 변수의 Rc<Node> 값과 branch.children에 저장된 복제 Rc<Node> 값은 같은 Node 할당을 가리킵니다. 두 Rc 값은 각각 따로 소유되고, 내부 Node 값의 수명은 남아 있는 강한 참조가 함께 책임집니다. Node의 소유자가 둘이라는 말은 이 할당을 가리키는 강한 Rc 값이 둘이라는 뜻입니다.
branch로부터 branch.children을 통하여 leaf까지 접근할 수 있게 되었지만, leaf에서부터 branch로 접근할 방법은 없습니다.
그 원인은 leaf가 branch에 대한 참조자를 가지고 있지 않고 이들 간의 연관성을 알지 못하기 때문입니다.
leaf에게 branch가 자신의 부모임을 알려주고 싶습니다.
이걸 다음에 해보겠습니다.
자식에서 부모로 가는 참조자 추가하기
자식 노드가 자신의 부모를 알도록 하기 위해서는 parent 필드를 Node 구조체 정의에 추가할 필요가 있습니다.
문제는 parent의 타입을 결정하는 데에 있습니다.
여기에 Rc<T>를 넣게 되면 branch를 가리키고 있는 leaf.parent와 leaf를 가리키고 있는 branch.children으로 이루어진 순환 참조를 만들게 되며, 이들의 strong_count 값이 결코 0이 되지 않게 하는 원인을 제공할 것이기 때문에, 여기에 Rc<T>를 사용할 수 없음을 알고 있습니다.
이 관계들을 다른 방식으로 생각해 보면, 부모 노드는 자신의 자식들을 소유해야 합니다.
부모 노드가 버려지면 자식에 대해 가진 강한 참조도 해제됩니다. 다른 Rc 소유자가 남아 있다면 자식은 계속 살아 있습니다. 아래 예제에서 바깥쪽의 leaf가 바로 그런 소유자입니다.
하지만, 자식은 자신의 부모를 소유해서는 안 됩니다.
즉 자식 노드가 버려지더라도 그 부모는 여전히 존재해야 합니다.
이것이 바로 약한 참조를 위한 경우입니다!
따라서 Rc<T> 대신 Weak<T>를 이용하여, 특별히 RefCell<Weak<Node>>를 이용하여 parent의 타입을 만들겠습니다.
이제 Node 구조체 정의는 아래와 같이 생겼습니다.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
노드는 자신의 부모 노드를 참조할 수 있게 되겠지만 그 부모를 소유하지는 않습니다.
예제 14-28에서는 main을 업데이트하여 이 새로운 정의를 사용하도록 해서 leaf 노드가 자기 부모인 branch를 참조할 수 있는 방법을 갖도록 합니다.
branch의 약한 참조를
갖는 leaf 노드
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
leaf 노드를 만드는 것이 parent 필드를 제외하고는 예제 14-27과 비슷해 보입니다.
leaf는 부모 없이 시작돼서, 새 비어있는 Weak<Node> 참조자 인스턴스를 생성하였습니다.
이 시점에서 upgrade 메서드를 사용하여 leaf의 부모에 대한 참조자를 얻는 시도를 하면 None 값을 얻습니다.
첫 번째 println! 구문에서는 아래와 같은 출력을 보게 됩니다.
leaf parent = Nonebranch 노드를 생성할 때도 parent 필드에 새로운 Weak<Node> 참조자를 넣었는데, 이는 branch에게는 부모 노드가 없기 때문입니다.
leaf는 여전히 branch의 자식 중 하나입니다.
일단 branch의 Node 인스턴스를 갖게 되면, leaf를 수정하여 자기 부모에 대한 Weak<Node> 참조자를 갖도록 할 수 있습니다.
leaf의 parent 필드의 RefCell<Weak<Node>>에 있는 borrow_mut 메서드를 사용하고, 그런 다음 Rc::downgrade 함수를 사용하여 branch의 Rc<Node>로부터 branch에 대한 Weak<Node> 참조자를 생성하였습니다.
leaf의 부모를 다시 한번 출력할 때는 branch를 가지고 있는 Some 배리언트를 얻게 될 것입니다.
이제 leaf는 자기 부모에 접근할 수 있습니다!
leaf를 출력할 때 예제 14-26에서와 같이 궁극적으로 스택 오버플로우로 끝나버리는 그 순환 문제도 피하게 되었습니다; Weak<Node> 참조자는 (Weak)로 출력됩니다.
leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })무한 출력이 없다는 사실만으로 그래프에 강한 순환이 없다고 판단할 수는 없습니다. 이 예제에서는 부모를 향하는 간선이 Weak<Node>여서 부모의 수명을 소유하지 않는지 확인해야 합니다.
또한 Rc::strong_count와 Rc::weak_count를 호출하여 얻은 값을 살펴보는 것으로도 알 수 있습니다.
strong_count와 weak_count의 변화를 시각화하기
새로운 내부 스코프를 만들고 branch의 생성 과정을 이 스코프로 옮겨서 Rc<Node> 인스턴스의 strong_count와 weak_count 값이 어떻게 변하는지 살펴봅시다.
그렇게 하면 branch가 만들어질 때와, 그 후 스코프 밖으로 벗어났을 때 어떤 일이 생기는지 알 수 있습니다.
수정본은 예제 14-29와 같습니다.
leaf/branch 스코프 진입·종료에 따른
strong_count와 weak_count 변화
예제 14-29: 내부 스코프에서 branch를 만들고
강한 참조 카운트와 약한 참조 카운트 시험하기
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
{
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!(
"branch strong = {}, weak = {}",
Rc::strong_count(&branch),
Rc::weak_count(&branch),
);
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}
leaf가 생성된 다음, 이것의 Rc<Node>는 강한 참조 카운트 1개와 약한 참조 카운트 0개를 갖습니다.
내부 스코프에서 branch를 만들고 leaf와 연관 짓게 되는데, 이때 카운트를 출력하면 branch의 Rc<Node>는 강한 참조 카운트 1개와(Weak<Node>로 branch를 가리키는 leaf.parent에 대한) 약한 참조 카운트 1개를 갖고 있을 것입니다.
leaf 내의 카운트를 출력하면 강한 참조 카운트 2개를 갖고 있음을 보게 될 것인데, 이는 branch가 이제 branch.children에 저장된 leaf의 Rc<Node>에 대한 클론을 가지게 되었지만, 약한 참조는 여전히 0개이기 때문입니다.
내부 스코프가 끝나게 되면, branch는 스코프 밖으로 벗어나게 되고 Rc<Node>의 강한 참조 카운트는 0으로 줄어들게 되므로, 이것의 Node는 버려집니다.
leaf.parent의 약한 참조 카운트 1개는 Node가 버려질지 말지와는 아무런 관계가 없으므로, 아무런 메모리 누수도 발생하지 않습니다!
만일 이 스코프의 끝부분 뒤에 leaf의 부모에 접근 시도를 한다면 다시 None을 얻게 될 것입니다.
프로그램의 끝부분에서, leaf의 Rc<Node>는 강한 참조 카운트 1개와 약한 참조 카운트 0개를 갖고 있는데, 이제 leaf 변수가 다시 Rc<Node>에 대한 유일한 참조자이기 때문입니다.
예시
upgrade는 단순한 생존 여부 표시가 아니라 살아 있는 대상의 새 강한 Rc를 반환합니다. 예제 14-29의 Node 정의를 유지하고 main을 다음으로 바꾸면, 그 반환값을 바깥에 보관한 경우를 볼 수 있습니다.
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
let kept_parent;
{
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
kept_parent = leaf.parent.borrow().upgrade().expect("live parent");
}
println!("leaf strong={}", Rc::strong_count(&leaf));
assert!(leaf.parent.borrow().upgrade().is_some());
drop(kept_parent);
println!("leaf strong={}", Rc::strong_count(&leaf));
assert!(leaf.parent.borrow().upgrade().is_none());
}예상 출력은 leaf strong=2, leaf strong=1입니다. 내부 스코프의 branch 바인딩이 끝나도 kept_parent가 부모의 강한 소유자로 남습니다. 그 Rc를 drop해야 부모 Node와 children에 든 Rc가 정리됩니다. 각 assert의 임시 upgrade 결과는 문장 끝에 사라지며 출력 지점에 추가로 보관한 소유자가 아닙니다.
스스로 수명 설명하기
위에서 내부 스코프가 끝난 뒤 kept_parent를 copy로 한 번 clone하고, kept_parent만 drop했다고 합시다. 그때 부모의 strong_count와 leaf의 strong_count, parent upgrade의 Some/None을 구하세요. 이어 copy까지 drop했을 때도 구하세요. 또한 원 a·b 강한 순환에서 외부 변수 둘이 살아 있을 때 a→b 저장 간선만 Nil로 바꾸면 b의 강한 카운트가 어떻게 바뀌나요?
보관한 반환값과 순환 간선 확인
clone 뒤 두 부모 Rc 중 kept_parent만 drop하면 copy 하나가 남아 부모 strong은 1, leaf strong은 부모 children과 바깥 leaf를 합쳐 2이며 upgrade는 Some입니다. copy도 drop하면 부모 내부 값이 정리되고 leaf strong은 1, upgrade는 None입니다. Weak의 관리 할당이 남을 수 있다는 설명과 부모 Node 값이 아직 살아 있다는 설명은 다릅니다.
a→b를 Nil로 바꾸면 b의 카운트는 외부 b 한 개만 남아 2에서 1로 줄고, a는 외부 a와 b→a 때문에 아직 2입니다. 이어 외부 b가 끝나면 b가 정리되며 b→a도 없어져 a가 1이 됩니다. 원 2·2 상태에서 외부 변수만 끝낸 경우의 1·1과는 제거한 간선이 다릅니다. 순환 전체를 Debug로 무한 출력하는 실험은 필요하지 않습니다.
Weak::upgrade의 반환 타입과 내부 값의 수명을 확인해 보세요.
참조 카운트와 값 버리기를 관리하는 모든 로직은 Rc<T>와 Weak<T>, 그리고 이들의 Drop 트레이트에 대한 구현부에 만들어져 있습니다.
자식과 부모의 관계가 Weak<T> 참조자로 있어야 함을 Node의 정의에 특정함으로써, 여러분은 순환 참조와 메모리 누수를 만들지 않으면서 자식 노드를 가리키는 부모 노드 혹은 그 반대의 것을 만들 수 있습니다.
스마트 포인터 요약
이번 장에서는 러스트가 일반적인 참조자를 가지고 기본적으로 보장하는 것들과는 다른 보장과 절충안을 만들어 내기 위해 스마트 포인터를 사용하는 방법을 다루었습니다.
Box<T> 타입은 알려진 크기를 갖고 있고 힙에 할당된 데이터를 가리킵니다.
Rc<T> 타입은 힙에 있는 데이터에 대한 참조자의 개수를 추적하여 그 데이터가 여러 개의 소유자를 가질 수 있도록 합니다.
내부 가변성을 갖춘 RefCell<T> 타입은 불변 타입이 필요하지만 그 타입의 내부 값을 변경할 필요가 있을 때 사용할 수 있습니다; 이것은 또한 컴파일 타임 대신 런타임에 대여 규칙을 따르도록 강제합니다.
또한 Deref 및 Drop 트레이트를 다루었는데, 이는 스마트 포인터의 수많은 기능을 활성화해 줍니다.
메모리 누수를 발생시킬 수 있는 순환 참조와, Weak<T>를 이용하여 이를 방지하는 방법도 탐구하였습니다.
이번 장이 여러분의 흥미를 자극하여 직접 여러분만의 스마트 포인터를 구현하고 싶어졌다면, ‘러스토노미콘’에서 더 유용한 정보를 확인하세요.
다음에는 러스트의 동시성에 대해 이야기해보겠습니다.
심지어 몇 가지 새로운 스마트 포인터에 대해서도 배우게 될 것입니다.
순환 참조와 메모리 누수 학습 판단 기준표
순환 참조와 메모리 누수의 마지막 점검은 실제 호출 모양과 실패 처리 경계가 자연스럽게 이어지는지에 맞춥니다.