참조 순환으로 인한 메모리 누수

Rust의 메모리 안전성 보장은 실수로 정리되지 않는 메모리(일명 메모리 누수)를 생성하는 것을 어렵게 만들지만, 불가능하게 만들지는 않는다. Rust는 메모리 누수를 완전히 방지하는 것을 보장하지 않는다. 즉, 메모리 누수는 Rust에서 메모리 안전하다. Rc<T>RefCell<T>를 사용하면 Rust가 메모리 누수를 허용한다는 것을 확인할 수 있다. 아이템들이 서로 순환 참조를 하는 참조를 생성할 수 있다. 이렇게 되면 순환에 있는 각 아이템의 참조 카운트가 0에 도달하지 않아 값이 삭제되지 않으므로 메모리 누수가 발생한다.

참조 순환 생성

참조 순환이 어떻게 발생하는지, 그리고 이를 방지하는 방법을 살펴보자. 먼저 List 열거형과 tail 메서드를 정의한 코드를 Listing 15-25에서 확인할 수 있다.

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 리스트 정의

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 값이 서로를 가리키는 참조 순환 생성

a 변수에 5, Nil로 초기화된 List 값을 담은 Rc<List> 인스턴스를 생성한다. 그런 다음 10을 포함하고 a의 리스트를 가리키는 List 값을 담은 Rc<List> 인스턴스를 b 변수에 생성한다.

aNil 대신 b를 가리키도록 수정하여 순환을 만든다. 이를 위해 tail 메서드를 사용해 aRefCell<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를 가리키도록 변경한 후, abRc<List> 인스턴스의 참조 카운트는 모두 2가 된다. main 함수의 끝에서 Rust는 b 변수를 드롭하며, bRc<List> 인스턴스의 참조 카운트를 2에서 1로 감소시킨다. 이 시점에서 Rc<List>가 힙에 가진 메모리는 드롭되지 않는다. 참조 카운트가 1이기 때문이다. 그런 다음 Rust는 a를 드롭하며, aRc<List> 인스턴스의 참조 카운트도 2에서 1로 감소시킨다. 이 인스턴스의 메모리도 드롭되지 않는다. 다른 Rc<List> 인스턴스가 여전히 이를 참조하고 있기 때문이다. 리스트에 할당된 메모리는 영원히 회수되지 않는다. 이 참조 순환을 시각적으로 표현하기 위해 Figure 15-4의 다이어그램을 만들었다.

Reference cycle of lists

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

마지막 println!의 주석을 해제하고 프로그램을 실행하면, Rust는 ab를 가리키고 ba를 가리키는 순환을 출력하려고 시도하다가 스택 오버플로가 발생한다.

실제 프로그램과 비교했을 때, 이 예제에서 참조 순환을 생성한 결과는 그리 심각하지 않다: 참조 순환을 생성한 직후 프로그램이 종료되기 때문이다. 하지만 더 복잡한 프로그램에서 순환을 통해 많은 메모리를 할당하고 오랫동안 유지한다면, 프로그램은 필요 이상의 메모리를 사용하게 되고 시스템에 과부하를 줄 수 있다. 이로 인해 사용 가능한 메모리가 부족해질 수 있다.

참조 순환을 만드는 것은 쉽지 않지만 불가능한 것도 아니다. Rc<T> 값을 포함하는 RefCell<T> 값이나 내부 가변성과 참조 카운팅을 가진 유사한 중첩 타입 조합을 사용한다면, 순환을 생성하지 않도록 주의해야 한다. Rust가 이를 알아채주길 기대할 수 없다. 참조 순환을 생성하는 것은 프로그램의 논리적 버그로, 자동화된 테스트, 코드 리뷰, 기타 소프트웨어 개발 관행을 통해 최소화해야 한다.

참조 순환을 피하는 또 다른 해결책은 데이터 구조를 재구성하여 일부 참조는 소유권을 표현하고 일부 참조는 소유권을 표현하지 않도록 하는 것이다. 그 결과, 소유 관계와 비소유 관계로 이루어진 순환을 만들 수 있으며, 오직 소유 관계만이 값이 드롭될 수 있는지 여부에 영향을 미친다. Listing 15-25에서는 항상 Cons 변형이 자신의 리스트를 소유하길 원하므로 데이터 구조를 재구성할 수 없다. 부모 노드와 자식 노드로 구성된 그래프를 사용해 비소유 관계가 참조 순환을 방지하는 적절한 방법이 되는 예제를 살펴보자.

Weak<T>를 사용해 참조 순환 방지하기

지금까지 Rc::clone을 호출하면 Rc<T> 인스턴스의 strong_count가 증가하고, strong_count가 0이 되어야만 Rc<T> 인스턴스가 정리된다는 것을 확인했다. 또한 Rc<T> 인스턴스에 대한 참조를 Rc::downgrade에 전달하여 값에 대한 약한 참조를 만들 수 있다. 강한 참조는 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 메서드를 호출하면 Option<Rc<T>>를 반환한다. Rc<T> 값이 아직 제거되지 않았다면 Some 결과를 얻고, Rc<T> 값이 제거되었다면 None 결과를 얻는다. upgradeOption<Rc<T>>를 반환하기 때문에 Rust는 SomeNone 경우를 모두 처리하도록 보장하며, 유효하지 않은 포인터가 발생하지 않는다.

예를 들어, 각 항목이 다음 항목만 알고 있는 리스트를 사용하는 대신, 각 항목이 자식 항목과 부모 항목을 모두 알고 있는 트리를 만들어 보자.

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

먼저, 자식 노드에 대한 정보를 알고 있는 노드로 트리를 만들어 보자. Node라는 구조체를 생성하고, 이 구조체는 i32 타입의 값을 가지며 자식 Node에 대한 참조도 포함한다.

파일명: 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> 타입의 값으로 정의한다. 또한 어떤 노드가 다른 노드의 자식인지 수정할 수 있도록 childrenVec<Rc<Node>>를 감싸는 RefCell<T>를 사용한다.

다음으로, 이 구조체 정의를 사용해 leaf라는 이름의 Node 인스턴스를 생성한다. 이 인스턴스는 값 3을 가지며 자식 노드가 없다. 또 다른 인스턴스인 branch는 값 5를 가지고 leaf를 자식 노드 중 하나로 포함한다. 이는 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에 저장한다. 이제 leafNodeleafbranch 두 소유자를 가지게 된다. branch에서 branch.children을 통해 leaf로 접근할 수 있지만, leaf에서 branch로 접근할 방법은 없다. 그 이유는 leafbranch에 대한 참조를 가지고 있지 않으며, 둘이 관련되어 있다는 것을 모르기 때문이다. 다음으로 leafbranch가 부모 노드임을 알 수 있도록 해 보자.

자식 노드에서 부모 노드로의 참조 추가하기

자식 노드가 부모 노드를 인식할 수 있도록 하려면 Node 구조체 정의에 parent 필드를 추가해야 한다. 여기서 문제는 parent의 타입을 어떻게 정할지 결정하는 것이다. Rc<T>를 사용할 수는 없다. 왜냐하면 leaf.parentbranch를 가리키고 branch.childrenleaf를 가리키는 참조 순환을 만들기 때문이다. 이렇게 되면 strong_count 값이 절대 0이 되지 않는다.

관계를 다른 방식으로 생각해보면, 부모 노드는 자식 노드를 소유해야 한다. 즉, 부모 노드가 제거되면 자식 노드도 함께 제거되어야 한다. 그러나 자식 노드는 부모 노드를 소유해서는 안 된다. 자식 노드를 제거하더라도 부모 노드는 여전히 존재해야 한다. 이 경우 약한 참조(weak reference)를 사용해야 한다!

따라서 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>>>,
}

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에서 main 함수를 업데이트하여 leaf 노드가 부모 노드인 branch를 참조할 수 있도록 한다.

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 노드를 생성하는 부분은 Listing 15-27과 비슷하지만, parent 필드가 추가되었다. leaf는 처음에 부모가 없으므로 새로운 빈 Weak<Node> 참조 인스턴스를 생성한다.

이 시점에서 leaf의 부모에 대한 참조를 얻기 위해 upgrade 메서드를 사용하면 None 값을 반환한다. 첫 번째 println! 문의 출력에서 이를 확인할 수 있다:

leaf parent = None

branch 노드를 생성할 때도 parent 필드에 새로운 Weak<Node> 참조가 추가된다. 왜냐하면 branch는 부모 노드가 없기 때문이다. 여전히 branch의 자식 중 하나로 leaf가 있다. branchNode 인스턴스를 생성한 후, leaf를 수정하여 부모에 대한 Weak<Node> 참조를 추가한다. leafparent 필드에 있는 RefCell<Weak<Node>>borrow_mut 메서드를 사용하고, branchRc<Node>에서 Rc::downgrade 함수를 사용해 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 변화 시각화

Rc<Node> 인스턴스의 strong_countweak_count 값이 어떻게 변하는지 확인하기 위해, 새로운 내부 스코프를 만들고 branch 생성 부분을 그 안으로 옮겨보자. 이렇게 하면 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>strong_count가 1이고 weak_count가 0이다. 내부 스코프에서 branch를 생성하고 leaf와 연결하면, 카운트를 출력할 때 branchRc<Node>strong_count가 1이고 weak_count가 1이 된다(leaf.parentWeak<Node>를 사용해 branch를 가리키기 때문). leaf의 카운트를 출력하면 strong_count가 2인 것을 확인할 수 있다. 이는 branchbranch.childrenleafRc<Node> 복사본을 저장했기 때문이다. 하지만 weak_count는 여전히 0이다.

내부 스코프가 끝나면 branch가 스코프를 벗어나고, Rc<Node>strong_count가 0으로 감소한다. 이로 인해 Node가 삭제된다. leaf.parentweak_count가 1이더라도 Node가 삭제되는 데는 영향을 미치지 않으므로 메모리 누수가 발생하지 않는다.

스코프가 끝난 후 leaf의 부모에 접근하려고 하면 다시 None을 얻게 된다. 프로그램이 끝날 때 leafRc<Node>strong_count가 1이고 weak_count가 0이다. 이는 leaf 변수가 다시 Rc<Node>의 유일한 참조이기 때문이다.

카운트 관리와 값 삭제를 위한 모든 로직은 Rc<T>Weak<T> 그리고 Drop 트레이트의 구현에 내장되어 있다. Node 정의에서 자식에서 부모로의 관계를 Weak<T> 참조로 지정함으로써, 부모 노드가 자식 노드를 가리키고 그 반대도 가능하게 하면서도 참조 순환과 메모리 누수를 방지할 수 있다.

요약

이 장에서는 스마트 포인터를 사용해 일반 참조와는 다른 보장과 트레이드오프를 만드는 방법을 다뤘다. Box<T> 타입은 크기가 정해져 있으며 힙에 할당된 데이터를 가리킨다. Rc<T> 타입은 힙에 있는 데이터에 대한 참조 개수를 추적해 데이터가 여러 소유자를 가질 수 있게 한다. RefCell<T> 타입은 내부 가변성을 제공해 불변 타입이 필요하지만 그 타입의 내부 값을 변경해야 할 때 사용할 수 있다. 또한 빌림 규칙을 컴파일 타임이 아닌 런타임에 강제한다.

스마트 포인터의 많은 기능을 가능하게 하는 DerefDrop 트레이트도 살펴봤다. 메모리 누수를 일으킬 수 있는 참조 순환과 이를 Weak<T>를 사용해 방지하는 방법도 탐구했다.

이 장이 흥미를 끌어 직접 스마트 포인터를 구현해 보고 싶다면 “The Rustonomicon”을 참고해 더 유용한 정보를 얻을 수 있다.

다음 장에서는 Rust의 동시성에 대해 이야기할 것이다. 몇 가지 새로운 스마트 포인터도 배울 예정이다.