참조 순환은 메모리를 누수시킬 수 있어요

참조 순환은 메모리를 누수시킬 수 있어요

Rust의 메모리 안전 보장 덕분에, 정리되지 않는 메모리(메모리 누수라고 하죠)를 실수로 만드는 것이 어려워졌지만 불가능해진 건 아니에요. 메모리 누수를 완전히 막는 것은 Rust의 보장 중 하나가 아니며, 즉 Rust에서 메모리 누수는 메모리 안전합니다. Rc<T>RefCell<T>를 사용하면 Rust가 메모리 누수를 허용한다는 것을 볼 수 있어요. 항목들이 순환을 이루며 서로를 가리키는 참조를 만드는 게 가능해서, 순환 안의 각 항목의 참조 수가 결코 0이 되지 않고 값들이 절대 드롭되지 않기 때문이에요.

출처: The Rust Book

참조 순환 만들기 (Creating a Reference Cycle)

참조 순환이 어떻게 일어날 수 있는지, 어떻게 막는지 살펴볼게요. Listing 15-25의 List 열거형 정의와 tail 메서드부터 시작합니다.

Filename: 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,
        }
    }
}

fn main() {}

Listing 15-25: Cons 변형이 무엇을 가리키는지 수정할 수 있도록 RefCell<T>를 담는 cons list 정의

Listing 15-5에서의 List 정의의 또 다른 변형을 사용하고 있어요. Cons 변형의 두 번째 요소는 이제 RefCell<Rc<List>>인데, 이는 Listing 15-24에서처럼 i32 값을 수정하는 대신 Cons 변형이 가리키는 List 값을 수정하고 싶다는 뜻이에요. 또한 Cons 변형이면 두 번째 항목에 쉽게 접근할 수 있도록 tail 메서드도 추가했어요.

Listing 15-26에서는 Listing 15-25의 정의를 사용하는 main 함수를 추가해요. 이 코드는 a에 리스트를 만들고 a의 리스트를 가리키는 리스트를 b에 만들어요. 그런 다음 a의 리스트가 b를 가리키도록 수정해 참조 순환을 만듭니다. 과정의 여러 지점에서 참조 수가 무엇인지 보여주는 println! 문들이 있죠.

Filename: 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,
        }
    }
}

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());
}

Listing 15-26: 서로를 가리키는 두 List 값의 참조 순환 만들기

변수 a5, Nil의 초기 목록을 담은 List 값을 가진 Rc<List> 인스턴스를 만들어요. 그런 다음 변수 b에 값 10을 담고 a의 리스트를 가리키는 또 다른 List 값을 가진 Rc<List> 인스턴스를 만듭니다.

aNil 대신 b를 가리키도록 수정해 순환을 만들어요. 그러기 위해 tail 메서드로 a 안의 RefCell<Rc<List>>에 대한 참조를 얻어 link 변수에 넣습니다. 그런 다음 RefCell<Rc<List>>borrow_mut 메서드로 안쪽 값을, Nil 값을 담은 Rc<List>에서 bRc<List>로 변경해요.

이 코드를 실행하면(마지막 println!은 지금 잠시 주석 처리해 두고) 다음 출력을 얻습니다.

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [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를 가리키도록 바꾼 뒤, ab 양쪽의 Rc<List> 인스턴스의 참조 수가 2가 돼요. main 끝에서 Rust는 변수 b를 드롭해 b Rc<List> 인스턴스의 참조 수를 2에서 1로 줄입니다. 참조 수가 0이 아니라 1이므로 이 시점에서 Rc<List>가 힙에 가진 메모리는 드롭되지 않아요. 그런 다음 Rust가 a를 드롭해 a Rc<List> 인스턴스의 참조 수도 2에서 1로 줄이죠. 이 인스턴스의 메모리 역시 다른 Rc<List> 인스턴스가 여전히 그것을 가리키기 때문에 드롭될 수 없습니다. 리스트에 할당된 메모리는 영원히 수집되지 않은 채 남아요. 이 참조 순환을 시각화하기 위해 Figure 15-4의 다이어그램을 만들었어요.

Figure 15-4: 서로를 가리키는 리스트 ab의 참조 순환

마지막 println!의 주석을 해제하고 프로그램을 실행하면, Rust가 이 순환을 ab를 가리키고 ba를 가리키며 계속 출력하려고 하다가 스택을 넘치게(overflow) 됩니다.

실제 프로그램과 비교하면 이 예시에서 참조 순환을 만든 결과는 그다지 심각하지 않아요. 참조 순환을 만든 직후 프로그램이 끝나니까요. 하지만 더 복잡한 프로그램이 순환에 많은 메모리를 할당하고 오랫동안 그걸 붙들고 있다면, 프로그램이 필요 이상으로 많은 메모리를 사용해 시스템에 부담을 줘 가용 메모리를 고갈시킬 수도 있습니다.

참조 순환을 만드는 것은 쉽지 않지만, 불가능한 것도 아니에요. Rc<T> 값이나 내부 가변성과 참조 카운팅을 가진 타입의 중첩 조합을 담는 RefCell<T> 값이 있다면, 순환을 만들지 않도록 반드시 주의해야 해요. Rust가 그것을 잡아주리라 기대하면 안 됩니다. 참조 순환을 만드는 것은 프로그램의 논리 버그이므로, 자동화된 테스트, 코드 리뷰, 그 외 소프트웨어 개발 관행으로 최소화해야 합니다.

참조 순환을 피하는 또 다른 해결책은, 어떤 참조는 소유권을 표현하고 어떤 참조는 그렇지 않도록 자료 구조를 재구성하는 거예요. 그 결과 일부 소유 관계와 일부 비소유 관계로 이루어진 순환을 가질 수 있고, 값이 드롭될 수 있는지에는 소유 관계만 영향을 줍니다. Listing 15-25에서는 항상 Cons 변형이 자신의 리스트를 소유하기를 원하므로 자료 구조를 재구성하는 게 불가능해요. 부모 노드와 자식 노드로 이루어진 그래프 예시를 살펴보면서, 비소유 관계가 언제 참조 순환을 막는 적절한 방법이 되는지 볼게요.

Weak<T>로 참조 순환 막기 (Preventing Reference Cycles Using Weak<T>)

지금까지 Rc::clone을 호출하면 Rc<T> 인스턴스의 strong_count가 증가하고, Rc<T> 인스턴스는 strong_count가 0일 때만 정리된다는 것을 보여줬어요. Rc::downgrade를 호출하고 Rc<T>에 대한 참조를 전달하면, Rc<T> 인스턴스 안의 값에 대한 약한 참조(weak reference)를 만들 수도 있어요. 강한 참조(strong 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<T> 참조가 존재하는지 추적하는 데 weak_count를 사용해요. 차이는 Rc<T> 인스턴스가 정리되기 위해 weak_count가 0일 필요가 없다는 점입니다.

Weak<T>가 참조하는 값은 이미 드롭되었을 수 있으므로, Weak<T>가 가리키는 값으로 무엇이든 하려면 그 값이 여전히 존재하는지 확인해야 해요. Weak<T> 인스턴스의 upgrade 메서드를 호출해 확인하는데, 이 메서드는 Option<Rc<T>>를 반환해요. Rc<T> 값이 아직 드롭되지 않았다면 Some 결과를, 드롭되었다면 None 결과를 얻습니다. upgradeOption<Rc<T>>를 반환하므로 Rust는 Some 경우와 None 경우를 모두 처리하도록 보장하고, 유효하지 않은 포인터가 생기지 않아요.

예시로, 항목이 다음 항목만 아는 리스트 대신, 항목이 자식 항목과 부모 항목을 모두 아는 트리를 만들어 볼게요.

트리 자료 구조 만들기 (Creating a Tree Data Structure)

먼저 자식 노드를 아는 노드로 트리를 만들 거예요. 자신의 i32 값과 자식 Node 값에 대한 참조를 담는 Node라는 구조체를 만들어 보겠습니다.

Filename: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

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

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)]),
    });
}

Node가 자식을 소유하고, 그 소유권을 변수들과 공유해서 트리의 각 Node에 직접 접근할 수 있게 하고 싶어요. 그러려면 Vec<T> 항목을 Rc<Node> 타입의 값으로 정의하면 됩니다. 또한 다른 노드가 어떤 노드의 자식인지 수정하고 싶으므로, Vec<Rc<Node>> 주위에 childrenRefCell<T>를 두어요.

다음으로 구조체 정의를 사용해 값 3과 자식이 없는 leaf라는 Node 인스턴스 하나와, 값 5와 자식 중 하나로 leaf를 가진 branch라는 인스턴스 하나를 만들어요. Listing 15-27이 그것을 보여줍니다.

Filename: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

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

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)]),
    });
}

Listing 15-27: 자식이 없는 leaf 노드와 leaf를 자식 중 하나로 가진 branch 노드 만들기

leafRc<Node>를 클론해 branch에 저장하므로, leafNode는 이제 leafbranch라는 두 소유자를 가져요. branch.children을 통해 branch에서 leaf로 갈 수 있지만, leaf에서 branch로 갈 방법은 없어요. 그 이유는 leafbranch에 대한 참조를 가지지 않아 둘이 관련이 있다는 걸 알지 못하기 때문이에요. leafbranch가 자신의 부모라는 것을 알게 하고 싶어요. 그것을 다음으로 해 볼게요.

자식에서 부모로의 참조 추가하기 (Adding a Reference from a Child to Its Parent)

자식 노드가 부모를 알게 하려면 Node 구조체 정의에 parent 필드를 추가해야 해요. 문제는 parent의 타입이 무엇이어야 하는지를 결정하는 것이에요. Rc<T>를 담을 수는 없다는 걸 알고 있어요. leaf.parentbranch를 가리키고 branch.childrenleaf를 가리키면 참조 순환이 생겨 strong_count 값이 절대 0이 되지 않으니까요.

관계를 다른 방식으로 생각해 보면, 부모 노드는 자식을 소유해야 해요. 부모 노드가 드롭되면 자식 노드도 드롭되어야 하죠. 하지만 자식은 부모를 소유하면 안 됩니다. 자식 노드를 드롭해도 부모는 여전히 존재해야 해요. 이는 약한 참조가 필요한 경우예요!

그래서 Rc<T> 대신 parent의 타입을 Weak<T>로 만들 거예요. 구체적으로는 RefCell<Weak<Node>>로요. 이제 Node 구조체 정의는 이렇게 생겼어요.

Filename: 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>>>,
}

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());
}

노드는 부모 노드를 참조할 수 있지만 부모를 소유하지는 않아요. Listing 15-28에서는 leaf 노드가 부모인 branch를 참조할 방법을 가지도록 main을 이 새 정의로 갱신합니다.

Filename: 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>>>,
}

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());
}

Listing 15-28: 부모 노드 branch에 대한 약한 참조를 가진 leaf 노드

leaf 노드를 만드는 것은 parent 필드를 제외하면 Listing 15-27과 비슷해요. leaf는 처음에 부모가 없으므로 새로운 빈 Weak<Node> 참조 인스턴스를 만듭니다.

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

leaf parent = None

branch 노드를 만들면, branch에는 부모 노드가 없으므로 그 parent 필드에도 새 Weak<Node> 참조가 있게 돼요. 여전히 branch의 자식 중 하나로 leaf를 가지죠. branchNode 인스턴스를 가지게 되면, leaf를 수정해 부모에 대한 Weak<Node> 참조를 줄 수 있어요. leafparent 필드의 RefCell<Weak<Node>>borrow_mut 메서드를 사용하고, 그런 다음 Rc::downgrade 함수로 branchRc<Node>에서 branch에 대한 Weak<Node> 참조를 만듭니다.

leaf의 부모를 다시 출력하면 이번에는 branch를 담은 Some 변형을 얻어요. 이제 leaf가 부모에 접근할 수 있습니다! leaf를 출력할 때도 Listing 15-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: [] } }] } })

무한 출력이 없다는 것은 이 코드가 참조 순환을 만들지 않았다는 뜻이에요. Rc::strong_countRc::weak_count를 호출해 얻는 값들을 봐도 알 수 있어요.

strong_countweak_count의 변화 시각화하기 (Visualizing Changes to strong_count and weak_count)

새 내부 스코프를 만들고 branch의 생성을 그 스코프로 옮겨 보면서, Rc<Node> 인스턴스의 strong_countweak_count 값이 어떻게 변하는지 살펴볼게요. 그렇게 하면 branch가 생성되고 스코프를 벗어날 때 드롭되면서 무슨 일이 일어나는지 볼 수 있어요. 수정 사항은 Listing 15-29에 나와 있어요.

Filename: 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>>>,
}

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),
    );
}

Listing 15-29: 내부 스코프에서 branch를 만들고 강한/약한 참조 수 살펴보기

leaf가 만들어진 후에, 그 Rc<Node>는 강한 수 1과 약한 수 0을 가져요. 내부 스코프에서 branch를 만들고 leaf와 연결하면, 수를 출력할 때 branchRc<Node>는 강한 수 1과 약한 수 1(leaf.parentWeak<Node>branch를 가리키므로)을 가지게 돼요. leaf의 수를 출력하면, branch가 이제 branch.children에 저장된 leafRc<Node> 클론을 가지므로 강한 수는 2가 되지만 약한 수는 여전히 0이에요.

내부 스코프가 끝나면 branch가 스코프를 벗어나 Rc<Node>의 강한 수가 0으로 줄어 그 Node가 드롭돼요. leaf.parent에서 온 약한 수 1은 Node가 드롭되는지에 아무 영향을 주지 않으므로, 메모리 누수가 생기지 않아요!

스코프가 끝난 뒤 leaf의 부모에 접근하려고 하면 다시 None을 얻어요. 프로그램 끝에서 leafRc<Node>는 강한 수 1과 약한 수 0을 가지는데, 변수 leaf가 다시 Rc<Node>에 대한 유일한 참조가 되기 때문이에요.

수와 값 드롭을 관리하는 모든 로직은 Rc<T>Weak<T> 및 그들의 Drop 트레이트 구현에 내장되어 있어요. Node의 정의에서 자식에서 부모로의 관계가 Weak<T> 참조여야 한다고 지정하면, 참조 순환과 메모리 누수를 만들지 않고 부모 노드가 자식 노드를 가리키고 그 반대도 가능하게 할 수 있습니다.

정리 (Summary)

이 장에서는 스마트 포인터를 사용해, Rust가 일반 참조로 기본 제공하는 것과 다른 보장과 트레이드오프를 만드는 방법을 다뤘어요. Box<T> 타입은 알려진 크기를 가지며 힙에 할당된 데이터를 가리켜요. Rc<T> 타입은 힙에 있는 데이터에 대한 참조 수를 추적해 그 데이터에 여러 소유자가 있을 수 있게 해 줍니다. RefCell<T> 타입은 내부 가변성을 통해, 불변 타입이 필요하지만 그 타입의 내부 값을 변경해야 할 때 사용할 수 있는 타입을 주고, 빌림 규칙을 컴파일 타임 대신 런타임에 강제해요.

또한 스마트 포인터의 많은 기능을 가능하게 하는 DerefDrop 트레이트도 다뤘어요. 메모리 누수를 일으킬 수 있는 참조 순환과 Weak<T>로 그것을 막는 방법도 살펴봤죠.

이 장이 흥미를 끌었고 직접 스마트 포인터를 구현해 보고 싶다면, "Rustonomicon"에서 더 유용한 정보를 확인할 수 있어요.

다음으로 Rust의 동시성에 대해 이야기해 볼게요. 새로운 스마트 포인터 몇 개도 배우게 될 거예요.

더 알아보기 (Learn more)

  • Weak<T> 문서upgrade, Rc::downgrade 등 약한 참조의 전체 API를 확인해 보세요.
  • The Rustonomicon — 고급 주제로 직접 스마트 포인터를 구현해 보려면 참고하세요.
  • 16장에서 동시성과 스레드 안전한 스마트 포인터를 살펴보세요.