본문 바로가기

순환 참조와 메모리 누수

Rc와 RefCell의 강한 순환 참조가 메모리를 남기는 과정을 확인하고 Weak 부모 참조로 트리의 소유권 순환을 끊습니다.

러스트의 메모리 안전성 보장은 실수로 결코 정리되지 않는 메모리, 곧 메모리 누수(memory leak)를 만들기 어렵게 하지만 불가능하게 하지는 않습니다. 메모리 누수를 완전히 막는 것은 러스트가 보장하는 것에 들어 있지 않습니다. 곧 러스트에서 메모리 누수는 메모리 안전합니다. 러스트가 Rc<T>와 RefCell<T>로 메모리 누수를 허용한다는 것을 볼 수 있습니다. 항목들이 서로를 순환해서 가리키는 참조를 만들 수 있기 때문입니다. 그러면 순환에 있는 각 항목의 참조 카운트가 결코 0이 되지 않아, 값이 결코 버려지지 않으므로 메모리 누수가 생깁니다.

순환 참조 만들기

순환 참조가 어떻게 생길 수 있고 어떻게 막을 수 있는지, 다음 코드의 List 열거형 정의와 tail 메서드로 시작해 살펴봅니다. 아래 코드에는 타입과 메서드의 정의만 있고, 순환을 실제로 만드는 main은 이어서 따로 봅니다.

src/main.rs
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,
        }
    }
}

이 List 정의는 Box<T>와 힙 데이터의 것과 다릅니다. Cons 배리언트의 두 번째 원소가 이제 RefCell<Rc<List>>입니다. 곧 RefCell<T>와 내부 가변성 패턴에서처럼 i32 값을 바꾸는 대신, Cons 배리언트가 가리키는 List 값을 바꾸고 싶다는 뜻입니다. Cons 배리언트가 있다면 두 번째 항목에 편하게 접근할 수 있도록 tail 메서드도 더했습니다.

다음 코드는 이 정의를 쓰는 main 함수입니다. a에 리스트를 만들고, a의 리스트를 가리키는 리스트를 b에 만듭니다. 그다음 a의 리스트가 b를 가리키도록 바꿔 순환 참조를 만듭니다. 이 과정의 여러 지점에서 참조 카운트가 얼마인지 보여 주는 println! 문이 있습니다.

src/main.rs
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());
}

변수 a에 처음 리스트 5, Nil을 담은 List 값을 가진 Rc<List> 인스턴스를 만듭니다. 그다음 변수 b에 값 10을 가지고 a의 리스트를 가리키는 다른 List 값을 가진 Rc<List> 인스턴스를 만듭니다.

a가 Nil 대신 b를 가리키도록 바꿔 순환을 만듭니다. tail 메서드로 a의 RefCell<Rc<List>>의 참조를 얻어 변수 link에 넣습니다. 그다음 RefCell<Rc<List>>에 borrow_mut 메서드를 써서, 안쪽 값을 Nil 값을 가진 Rc<List>에서 b의 Rc<List>로 바꿉니다. 마지막 println!을 주석으로 둔 채 이 코드를 실행하면 다음과 같이 출력됩니다.

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> 인스턴스가 여전히 그것을 가리키고 있기 때문입니다. 리스트에 할당된 메모리는 영원히 정리되지 않은 채 남습니다. 이 순환 참조를 그림으로 나타내면 다음과 같습니다.

Rc 참조를 가진 리스트 a와 b가 서로를 가리키는 순환 참조

마지막 println!의 주석을 지우고 프로그램을 실행하면, 러스트는 a가 b를 가리키고 b가 a를 가리키는 식으로 이 순환을 출력하려다 스택이 넘쳐(stack overflow) 프로그램이 끝납니다. 출력하다가 스택이 넘치는 것과, 참조 카운트 때문에 값이 버려지지 않는 누수는 서로 다른 현상입니다.

실제 프로그램과 비교하면, 이 예에서 순환 참조를 만든 결과는 그리 심각하지 않습니다. 순환 참조를 만든 바로 뒤에 프로그램이 끝나기 때문입니다. 하지만 더 복잡한 프로그램이 순환으로 많은 메모리를 할당하고 오랫동안 쥐고 있다면, 프로그램이 필요한 것보다 많은 메모리를 쓰게 되고 시스템을 압박해 쓸 수 있는 메모리가 바닥날 수 있습니다. 아래 그림은 이렇게 서로를 강하게 소유하는 구조와, 역방향 간선 하나를 Weak로 바꾼 구조를 나란히 보여 줍니다.

Rc 순환과 Weak 비소유 간선

본문 a/b strong 2에서 외부 b/a가 사라져도 순환 각각 1이 남는 상태를 보여 줄 수 있다. Weak 역방향 비교는 별도 설계이며 실행 중 자동 교체가 아니다.

DEPENDENCY · OWNERSHIP CYCLE

강한 순환은 남고 Weak 역방향 간선은 수명을 소유하지 않는다

강한 Rc 간선만으로 닫힌 순환을 만들면 외부 소유자가 사라져도 각 강한 카운트가 0이 되지 않는다. 역방향 관찰 간선을 Weak로 설계하면 값의 수명을 소유하지 않으며 upgrade는 Option을 반환한다.

Rc 순환과 Weak 비소유 간선 외부 Rc 소유자가 사라진 뒤에도 강한 순환이 값을 유지하는 구조와 한 역방향 간선을 Weak로 바꿔 수명 순환을 끊는 구조의 비교 강한 역방향 소유권 Weak 역방향 관찰 CYCLE Weak 관찰 EXTERNAL 외부 Rc 소유자 scope exit에서 drop RANK 1 Rc<Node A> B를 강하게 소유 RANK 2 Rc<Node B> A를 강하게 소유 EXTERNAL 외부 Rc 소유자 scope exit에서 drop RANK 1 Rc<Parent> 자식을 강하게 소유 RANK 2 Rc<Child> 부모는 Weak로 관찰 실선은 강한 소유권 · 점선은 Weak 관찰 · upgrade는 Option<Rc<T>>
강한 역방향과 Weak 역방향두영역은독립대안이다. 각외부Rc→상위노드→하위노드는강한소유권이다. 왼쪽하위B→A역방향도강하므로외부소유자가끝나도순환이남고,오른쪽Child→Parent는Weak점선으로수명을소유하지않는다. 총6노드6간선이desktop/mobile동일하며두영역사이간선은없다. 역방향라벨은각외부레일에서8px떨어진빈공간에놓고레일을덮지않는다. Weak는임의의다른강한순환을자동제거하지않는다.강한 역방향 소유권외부 Rc 소유자scope 끝에서 dropNode AB를 Rc로 소유Node BA를 Rc로 소유RcRcRcWeak 역방향 관찰외부 Rc 소유자scope 끝에서 dropParent자식을 Rc로 소유Child부모를 Weak 관찰RcRcWeak실선: 강한 소유권 · 점선: Weak 관찰
독립된 두 소유권 구조두영역은독립대안이다. 각외부Rc→상위노드→하위노드는강한소유권이다. 왼쪽하위B→A역방향도강하므로외부소유자가끝나도순환이남고,오른쪽Child→Parent는Weak점선으로수명을소유하지않는다. 총6노드6간선이desktop/mobile동일하며두영역사이간선은없다. 역방향라벨은각외부레일에서8px떨어진빈공간에놓고레일을덮지않는다. Weak는임의의다른강한순환을자동제거하지않는다.강한 역방향 소유권외부 Rc 소유자scope 끝에서 dropNode AB를 Rc로 소유Node BA를 Rc로 소유RcRcRcWeak 역방향 관찰외부 Rc 소유자scope 끝에서 dropParent자식을 Rc로 소유Child부모를 Weak 관찰RcRcWeak
  1. 강한 순환

    Node A와 B가 서로를 Rc로 강하게 소유하면 외부 소유자가 사라져도 강한 카운트가 남습니다.

  2. 수명 결과

    강한 순환은 메모리 안전하지만 내부 T를 drop하지 못해 논리적 누수를 만듭니다.

  3. Weak 선택

    부모 관찰처럼 소유하지 않아야 할 역방향 간선을 Weak로 바꿉니다.

  4. 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의 서로 강한 연결과 외부 소유가 있다.

  • a strong: 2
  • b strong: 2
  • 각 객체에 외부와 순환 간선의 강한 소유가 남는다.
외부 소유 종료

외부 b/a가 사라져도 순환 소유는 남는다.

  • a strong: 1
  • b strong: 1
  • 카운트가 0이 아니므로 T가 정리되지 않는다.
대안 비교

Weak 역방향 설계를 별도 사본으로 놓는다.

  • 대안: 한 역방향 Weak
  • 현재 순환: 자동 변경되지 않음
  • 설계 변경은 실행 중 자동으로 순환을 끊는 동작이 아니다.

Rc 순환과 Weak 비소유 간선

본문 a/b strong 2에서 외부 b/a가 사라져도 순환 각각 1이 남는 상태를 보여 줄 수 있다. Weak 역방향 비교는 별도 설계이며 실행 중 자동 교체가 아니다.

DEPENDENCY · OWNERSHIP CYCLE

강한 순환은 남고 Weak 역방향 간선은 수명을 소유하지 않는다

강한 Rc 간선만으로 닫힌 순환을 만들면 외부 소유자가 사라져도 각 강한 카운트가 0이 되지 않는다. 역방향 관찰 간선을 Weak로 설계하면 값의 수명을 소유하지 않으며 upgrade는 Option을 반환한다.

Rc 순환과 Weak 비소유 간선 외부 Rc 소유자가 사라진 뒤에도 강한 순환이 값을 유지하는 구조와 한 역방향 간선을 Weak로 바꿔 수명 순환을 끊는 구조의 비교 강한 역방향 소유권 Weak 역방향 관찰 CYCLE Weak 관찰 EXTERNAL 외부 Rc 소유자 scope exit에서 drop RANK 1 Rc<Node A> B를 강하게 소유 RANK 2 Rc<Node B> A를 강하게 소유 EXTERNAL 외부 Rc 소유자 scope exit에서 drop RANK 1 Rc<Parent> 자식을 강하게 소유 RANK 2 Rc<Child> 부모는 Weak로 관찰 실선은 강한 소유권 · 점선은 Weak 관찰 · upgrade는 Option<Rc<T>>
강한 역방향과 Weak 역방향두영역은독립대안이다. 각외부Rc→상위노드→하위노드는강한소유권이다. 왼쪽하위B→A역방향도강하므로외부소유자가끝나도순환이남고,오른쪽Child→Parent는Weak점선으로수명을소유하지않는다. 총6노드6간선이desktop/mobile동일하며두영역사이간선은없다. 역방향라벨은각외부레일에서8px떨어진빈공간에놓고레일을덮지않는다. Weak는임의의다른강한순환을자동제거하지않는다.강한 역방향 소유권외부 Rc 소유자scope 끝에서 dropNode AB를 Rc로 소유Node BA를 Rc로 소유RcRcRcWeak 역방향 관찰외부 Rc 소유자scope 끝에서 dropParent자식을 Rc로 소유Child부모를 Weak 관찰RcRcWeak실선: 강한 소유권 · 점선: Weak 관찰
독립된 두 소유권 구조두영역은독립대안이다. 각외부Rc→상위노드→하위노드는강한소유권이다. 왼쪽하위B→A역방향도강하므로외부소유자가끝나도순환이남고,오른쪽Child→Parent는Weak점선으로수명을소유하지않는다. 총6노드6간선이desktop/mobile동일하며두영역사이간선은없다. 역방향라벨은각외부레일에서8px떨어진빈공간에놓고레일을덮지않는다. Weak는임의의다른강한순환을자동제거하지않는다.강한 역방향 소유권외부 Rc 소유자scope 끝에서 dropNode AB를 Rc로 소유Node BA를 Rc로 소유RcRcRcWeak 역방향 관찰외부 Rc 소유자scope 끝에서 dropParent자식을 Rc로 소유Child부모를 Weak 관찰RcRcWeak
  1. 강한 순환

    Node A와 B가 서로를 Rc로 강하게 소유하면 외부 소유자가 사라져도 강한 카운트가 남습니다.

  2. 수명 결과

    강한 순환은 메모리 안전하지만 내부 T를 drop하지 못해 논리적 누수를 만듭니다.

  3. Weak 선택

    부모 관찰처럼 소유하지 않아야 할 역방향 간선을 Weak로 바꿉니다.

  4. 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의 서로 강한 연결과 외부 소유가 있다.

  • a strong: 2
  • b strong: 2
  • 각 객체에 외부와 순환 간선의 강한 소유가 남는다.
외부 소유 종료

외부 b/a가 사라져도 순환 소유는 남는다.

  • a strong: 1
  • b strong: 1
  • 카운트가 0이 아니므로 T가 정리되지 않는다.
대안 비교

Weak 역방향 설계를 별도 사본으로 놓는다.

  • 대안: 한 역방향 Weak
  • 현재 순환: 자동 변경되지 않음
  • 설계 변경은 실행 중 자동으로 순환을 끊는 동작이 아니다.

두 영역은 서로 독립된 대안입니다. 왼쪽에서는 Node A가 B를, B가 A를 Rc로 강하게 소유하므로, 바깥의 Rc 소유자가 스코프를 벗어나도 강한 카운트가 남습니다. 이 글의 Cons(5)와 Cons(10)에서도 서로 강한 Rc를 저장한 뒤 둘의 strong_count는 각각 2이고, main의 b와 a가 차례로 사라져도 상대가 가진 Rc 때문에 1이 남습니다. 강한 순환은 메모리 안전하지만, 안쪽 값을 버리지 못해 논리적인 누수를 만듭니다. 오른쪽에서는 부모를 가리키는 것처럼 소유하지 않아야 할 역방향 간선을 Weak로 바꿉니다. Weak의 upgrade는 대상이 살아 있으면 Some(Rc<T>)를, 버려진 뒤에는 None을 돌려주므로 언제나 Option을 처리해야 합니다. Weak가 다른 강한 순환을 저절로 고쳐 주지는 않으므로, 어느 간선이 소유하지 않아야 하는지는 설계하는 사람이 정해야 합니다.

순환 참조를 만드는 것은 쉽지 않지만 불가능하지도 않습니다. Rc<T> 값을 담은 RefCell<T> 값이나, 내부 가변성과 참조 카운팅을 가진 타입들을 비슷하게 중첩해 조합한다면, 순환을 만들지 않도록 직접 확인해야 합니다. 러스트가 잡아 줄 것이라고 기대할 수는 없습니다. 순환 참조를 만드는 것은 프로그램의 논리 버그이며, 자동화된 테스트나 코드 리뷰 같은 소프트웨어 개발 관행으로 줄여야 합니다.

순환 참조를 피하는 또 다른 방법은 자료 구조를 다시 짜서, 어떤 참조는 소유권을 나타내고 어떤 참조는 그렇지 않게 하는 것입니다. 그러면 순환이 소유 관계와 소유하지 않는 관계로 이루어지게 되고, 소유 관계만 값이 버려질지 아닐지에 영향을 줍니다. 앞의 코드에서는 언제나 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> 인스턴스가 언제 정리될지에 영향을 주지 않습니다. 약한 참조가 관련된 순환은 그 값들의 강한 참조 카운트가 0이 되면 끊어지므로, 순환 참조를 만들지 않습니다.

Rc::downgrade를 부르면 Weak<T> 타입의 스마트 포인터를 얻습니다. Rc::downgrade를 부르면 Rc<T> 인스턴스의 strong_count를 1 늘리는 대신 weak_count를 1 늘립니다. Rc<T> 타입은 strong_count와 비슷하게 weak_count로 Weak<T> 참조가 몇 개 있는지 추적합니다. 다른 점은 Rc<T> 인스턴스가 정리되는 데 weak_count가 0일 필요는 없다는 것입니다.

Weak<T>가 가리키는 값은 이미 버려졌을 수 있으므로, Weak<T>가 가리키는 값으로 무언가를 하려면 그 값이 아직 있는지 확인해야 합니다. Weak<T> 인스턴스에 upgrade 메서드를 불러 이것을 확인하며, upgrade는 Option<Rc<T>>를 돌려줍니다. Rc<T> 값이 아직 버려지지 않았다면 Some을, 버려졌다면 None을 결과로 얻습니다. upgrade가 Option<Rc<T>>를 돌려주므로, 러스트는 Some과 None 두 경우를 모두 다루게 하며, 유효하지 않은 포인터가 생기지 않습니다. 아래 그림은 이 글에서 만들 트리의 branch와 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를 추가로 보유하지 않는 시점입니다.

본문 branch와 leaf의 강한 하향 간선, 약한 상향 간선branch 값 5는 leaf 값 3을 children의 Rc로 소유한다. leaf parent는 branch의 Weak. scope 안 카운트는 branch strong1 weak1, leaf strong2 weak0이다.branch · 값 5strong = 1 · weak = 1leaf · 값 3strong = 2 · weak = 0children: Rc<Node>parent: Weak<Node>
자식 소유와 부모 관찰의 반대 방향원 desktop의두노드두방향간선이다. branch.children의Rc는branch→leaf강한소유권,leaf.parent의Weak는leaf→branch관찰점선이다. 수치는내부branch가살아있는본문의중간상태branchstrong1weak1/leafstrong2weak0이며순차두값이아니다. 마지막branchRc가끝나면leafstrong1weak0,upgradeNone이다. 별도upgradeSome으로얻은Rc를계속보유하면branch수명이연장되므로마지막이라는조건을지킨다.branch · 값 5strong 1 / weak 1children가 leaf 소유leaf · 값 3strong 2 / weak 0parent는 WeakWeakRc

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가 소유한다.

  • leaf 값: 3
  • leaf strong: 1
  • 부모는 아직 없으므로 upgrade할 강한 대상이 없다.
branch 연결

branch 5가 leaf를 소유하고 leaf는 Weak 부모를 보관한다.

  • branch 값: 5
  • 부모 관계: Weak
  • 부모 방향을 Weak로 두어 leaf가 branch를 강하게 붙잡지 않는다.
branch 수명 종료

마지막 branch strong이 사라진 뒤 leaf가 남는다.

  • leaf strong: 1
  • parent.upgrade: None
  • T가 정리된 부모는 Weak upgrade로 다시 살릴 수 없다; 저장한 Some은 새 strong을 추가한다.

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를 추가로 보유하지 않는 시점입니다.

본문 branch와 leaf의 강한 하향 간선, 약한 상향 간선branch 값 5는 leaf 값 3을 children의 Rc로 소유한다. leaf parent는 branch의 Weak. scope 안 카운트는 branch strong1 weak1, leaf strong2 weak0이다.branch · 값 5strong = 1 · weak = 1leaf · 값 3strong = 2 · weak = 0children: Rc<Node>parent: Weak<Node>
자식 소유와 부모 관찰의 반대 방향원 desktop의두노드두방향간선이다. branch.children의Rc는branch→leaf강한소유권,leaf.parent의Weak는leaf→branch관찰점선이다. 수치는내부branch가살아있는본문의중간상태branchstrong1weak1/leafstrong2weak0이며순차두값이아니다. 마지막branchRc가끝나면leafstrong1weak0,upgradeNone이다. 별도upgradeSome으로얻은Rc를계속보유하면branch수명이연장되므로마지막이라는조건을지킨다.branch · 값 5strong 1 / weak 1children가 leaf 소유leaf · 값 3strong 2 / weak 0parent는 WeakWeakRc

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가 소유한다.

  • leaf 값: 3
  • leaf strong: 1
  • 부모는 아직 없으므로 upgrade할 강한 대상이 없다.
branch 연결

branch 5가 leaf를 소유하고 leaf는 Weak 부모를 보관한다.

  • branch 값: 5
  • 부모 관계: Weak
  • 부모 방향을 Weak로 두어 leaf가 branch를 강하게 붙잡지 않는다.
branch 수명 종료

마지막 branch strong이 사라진 뒤 leaf가 남는다.

  • leaf strong: 1
  • parent.upgrade: None
  • T가 정리된 부모는 Weak upgrade로 다시 살릴 수 없다; 저장한 Some은 새 strong을 추가한다.

그림에서 예제 14-29라고 적은 것은 이 글의 마지막 예제입니다. 값 5를 가진 branch는 children의 Rc로 값 3을 가진 leaf를 소유하고, leaf의 parent는 branch를 Weak로 관찰합니다. 처음에 leaf만 만들면 leaf의 카운트는 강한 참조 1, 약한 참조 0이고, parent가 Weak::new()이므로 upgrade는 None입니다. 안쪽 스코프에서 branch가 leaf의 Rc를 저장하고 leaf.parent를 Rc::downgrade(&branch)로 정하면, branch는 1과 1, leaf는 2와 0이 되고 upgrade는 Some(Rc<Node>)를 돌려줍니다. 마지막 branch의 Rc가 사라지면 branch의 Node와 그 children 안의 Rc도 사라지고, 바깥의 leaf는 1과 0으로 남으며 upgrade는 None을 돌려줍니다. upgrade로 얻은 Some 안의 Rc를 계속 저장해 두면 새 강한 소유자가 생기므로, 그것이 끝날 때까지 부모가 살아 있게 됩니다.

List처럼 다음 항목만 아는 리스트 대신, 자식 항목과 부모 항목을 아는 항목들로 이루어진 트리를 만들어 봅니다.

트리 데이터 구조 만들기: 자식 노드를 가진 Node

먼저 자식 노드를 아는 노드들로 트리를 만듭니다. 자기 i32 값과 자식 Node 값들의 참조를 가진 Node라는 구조체를 만듭니다.

src/main.rs
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> 타입의 값으로 정의합니다. 또 어떤 노드가 다른 노드의 자식인지 바꿀 수 있기를 원하므로, children에 Vec<Rc<Node>>를 감싼 RefCell<T>를 둡니다.

다음으로 이 구조체 정의를 써서, 다음 코드처럼 값 3을 가지고 자식이 없는 leaf라는 Node 인스턴스와, 값 5를 가지고 leaf를 자식 하나로 가진 branch라는 다른 인스턴스를 만듭니다.

src/main.rs
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에 저장하므로, 이제 leaf의 Node에는 leaf와 branch라는 두 소유자가 있습니다. branch.children을 통해 branch에서 leaf로 갈 수는 있지만, leaf에서 branch로 갈 방법은 없습니다. leaf가 branch의 참조를 갖고 있지 않아 둘이 관련되어 있다는 것을 모르기 때문입니다. leaf가 branch가 자기 부모라는 것을 알게 하고 싶습니다. 다음에 그것을 합니다.

자식에서 부모로 가는 참조자 추가하기

자식 노드가 부모를 알게 하려면, Node 구조체의 정의에 parent 필드를 더해야 합니다. 문제는 parent의 타입을 무엇으로 할지 정하는 것입니다. Rc<T>를 담을 수는 없습니다. 그러면 leaf.parent가 branch를 가리키고 branch.children이 leaf를 가리키는 순환 참조가 생겨, strong_count가 결코 0이 되지 않을 것입니다.

관계를 다른 방식으로 생각해 보면, 부모 노드는 자식을 소유해야 합니다. 부모 노드가 버려지면 자식 노드도 버려져야 합니다. 하지만 자식은 부모를 소유하면 안 됩니다. 자식 노드를 버려도 부모는 여전히 있어야 합니다. 이것이 약한 참조를 쓸 상황입니다.

그래서 Rc<T> 대신 parent의 타입을 Weak<T>, 구체적으로는 RefCell<Weak<Node>>로 정합니다. 이제 Node 구조체의 정의는 다음과 같습니다.

src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

노드는 부모 노드를 가리킬 수 있지만 부모를 소유하지는 않습니다. 다음 코드는 이 새 정의를 쓰도록 main을 고쳐, leaf 노드가 부모인 branch를 가리킬 수 있게 한 것입니다.

src/main.rs
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 필드를 빼면 앞의 코드와 비슷합니다. leaf는 부모 없이 시작하므로, 새 빈 Weak<Node> 참조 인스턴스를 만듭니다.

이 시점에 upgrade 메서드로 leaf의 부모의 참조를 얻으려 하면 None 값을 얻습니다. 첫 println! 문의 출력에서 이것을 볼 수 있습니다.

leaf parent = None

branch 노드를 만들 때도 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를 출력할 때, 앞의 코드에서처럼 결국 스택이 넘치는 순환도 피할 수 있습니다. 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: [] } }] } })

출력이 끝없이 이어지지 않는다는 것은 이 코드가 순환 참조를 만들지 않았다는 뜻입니다. Rc::strong_count와 Rc::weak_count를 불러 얻은 값을 봐도 이것을 알 수 있습니다.

strong_count와 weak_count의 변화를 시각화하기

새 안쪽 스코프를 만들고 branch를 만드는 코드를 그 스코프로 옮겨, Rc<Node> 인스턴스의 strong_count와 weak_count 값이 어떻게 바뀌는지 봅니다. 그러면 branch가 만들어졌다가 스코프를 벗어나 버려질 때 무슨 일이 일어나는지 볼 수 있습니다. 바꾼 코드는 다음과 같습니다.

src/main.rs
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 strong = 1, weak = 0
branch strong = 1, weak = 1
leaf strong = 2, weak = 0
leaf parent = None
leaf strong = 1, weak = 0

leaf를 만든 뒤, leaf의 Rc<Node>는 강한 카운트가 1, 약한 카운트가 0입니다. 안쪽 스코프에서 branch를 만들어 leaf와 연결하면, 이 시점에 branch의 Rc<Node>는 강한 카운트가 1이고 약한 카운트가 1입니다. leaf.parent가 Weak<Node>로 branch를 가리키기 때문입니다. 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>의 유일한 참조이기 때문입니다. 아래 그림은 스코프에 따라 두 카운트가 바뀌는 과정을 정리한 것입니다.

leaf와 branch의 스코프 변화에 따른 strong_count와 weak_count 변화

카운트를 관리하고 값을 버리는 로직은 모두 Rc<T>와 Weak<T>, 그리고 그것들의 Drop 트레이트 구현에 들어 있습니다. Node의 정의에서 자식에서 부모로 가는 관계를 Weak<T> 참조로 정하면, 순환 참조와 메모리 누수를 만들지 않고도 부모 노드가 자식 노드를 가리키고 자식 노드가 부모 노드를 가리키게 할 수 있습니다.

더 깊이: Weak가 남아 있으면 메모리는 언제 돌아갈까

강한 카운트가 0이 되는 것과 메모리가 할당자에게 돌아가는 것은 같은 시점이 아닐 수 있습니다. std::rc 문서에 따르면 할당을 가리키는 마지막 Rc가 사라질 때 그 할당에 저장된 안쪽 값은 버려지지만, Weak 포인터는 안쪽 값을 살려 두지 않는 대신 할당 자체, 곧 안쪽 값이 놓여 있던 저장 공간은 살려 둡니다. 그래서 branch가 버려진 뒤에도 leaf.parent의 Weak가 남아 있는 동안에는 branch의 Node가 쓰던 자리가 할당자에게 돌아가지 않고, 마지막 Weak까지 사라져야 돌아갑니다.

이 차이가 문제가 되는 것은 안쪽 값의 크기가 클 때입니다. 안쪽 값이 Vec처럼 자기 버퍼를 따로 힙에 두는 타입이라면 그 버퍼는 강한 카운트가 0이 될 때 함께 해제되지만, Rc 할당 안에 바로 놓인 큰 배열 같은 데이터는 Weak가 남아 있는 동안 그 크기만큼 자리를 차지합니다. 또 Weak<T>가 T로 자동 역참조되지 않는 것도, 문서가 설명하듯 안쪽 값이 이미 버려졌을 수 있기 때문입니다. 값을 쓰려면 언제나 upgrade로 그 값이 아직 있는지 확인해야 합니다.

정리

이번 장에서는 스마트 포인터로 보통의 참조와는 다른 보장과 트레이드오프를 얻는 방법을 다루었습니다. Box<T> 타입은 크기가 정해져 있고 힙에 할당된 데이터를 가리킵니다. Rc<T> 타입은 힙의 데이터에 대한 참조의 수를 추적해, 그 데이터에 소유자가 여럿일 수 있게 합니다. 내부 가변성을 가진 RefCell<T> 타입은, 불변 타입이 필요하지만 그 타입의 안쪽 값을 바꿔야 할 때 쓸 수 있는 타입을 주며, 대여 규칙을 컴파일 시점 대신 런타임에 적용합니다. 스마트 포인터의 많은 기능을 가능하게 하는 Deref와 Drop 트레이트도 다루었습니다. 직접 스마트 포인터를 만들고 싶다면 The Rustonomicon을 참고하세요.

  • Rc<T>와 RefCell<T>로 서로를 강하게 가리키는 순환을 만들면 강한 카운트가 0이 되지 않아 메모리가 새며, 이것은 메모리 안전하지만 논리 버그입니다.
  • Rc::downgrade는 강한 카운트 대신 weak_count를 늘리는 Weak<T>를 만들고, 약한 참조는 값이 버려질지 아닐지에 영향을 주지 않습니다.
  • Weak<T>의 upgrade는 값이 살아 있으면 Some(Rc<T>), 버려졌으면 None을 돌려주므로 두 경우를 모두 다룹니다.
  • 트리에서는 부모가 자식을 Rc로 소유하고, 자식은 부모를 Weak로 가리켜 순환 없이 양방향으로 오갈 수 있습니다.
  • 어느 간선을 소유하지 않는 관계로 둘지는 설계하는 사람이 정하며, Weak가 순환을 저절로 고쳐 주지는 않습니다.

연습 문제

1. 카운트가 얼마일까

이 글의 트리 예제에서, 안쪽 스코프 안에 let extra = leaf.parent.borrow().upgrade();를 더하고 그 뒤에 branch의 카운트를 출력하면 얼마가 나오나요? extra를 스코프 밖에 선언해 두고 안에서 값을 넣는다면, 스코프가 끝난 뒤 leaf.parent.borrow().upgrade()는 무엇을 돌려주나요?

해설 보기

upgrade가 Some(Rc<Node>)를 돌려주어 extra가 강한 참조 하나를 더 가지므로, branch는 강한 카운트 2, 약한 카운트 1이 됩니다. extra를 스코프 밖에 두면, 안쪽 스코프가 끝나 branch 변수가 사라져도 extra가 강한 참조를 쥐고 있어 branch의 Node가 버려지지 않으므로, upgrade는 여전히 Some을 돌려줍니다. upgrade로 얻은 Rc를 오래 저장하면 부모의 수명이 늘어납니다.

2. 어느 쪽을 약하게 할까

게시판에서 Post는 여러 Comment를 가지고, 각 Comment는 자신이 달린 Post를 알아야 합니다. 둘 다 Rc로 서로를 가리키면 어떻게 되고, 무엇을 Weak로 바꿔야 하나요?

해설 보기

Post와 Comment가 서로를 Rc로 가리키면 강한 순환이 생겨, 바깥의 변수가 모두 사라져도 둘의 강한 카운트가 0이 되지 않아 메모리가 남습니다. 게시물이 사라지면 댓글도 함께 사라져야 하므로 Post가 Vec<Rc<Comment>>로 댓글을 소유하고, Comment는 Weak<Post>로 게시물을 가리키게 합니다. 댓글에서 게시물에 접근할 때는 upgrade로 Option을 받아, 게시물이 이미 사라진 경우도 처리합니다.

이것으로 14장을 마칩니다. 다음 장의 스레드 동시 실행부터는 여러 스레드로 코드를 동시에 실행하는 방법과, 그때 러스트가 주는 보장을 살펴봅니다.