Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

참조 순환은 메모리 누수를 일으킬 수 있다

러스트의 메모리 안전성 보장은, 절대 정리되지 않는 메모리(즉 메모리 누수)를 실수로 만드는 일을 어렵게 하기는 하지만, 완전히 불가능하게 만들지는 않습니다. 메모리 누수를 완전히 막는 것은 러스트의 보장 범위에 포함되지 않습니다. 즉, 메모리 누수는 러스트에서 “메모리 안전한” 문제입니다. Rc<T>RefCell<T> 를 사용하면 러스트가 메모리 누수를 허용한다는 사실을 볼 수 있습니다. 항목들이 서로를 원형으로 참조하는 구조를 만들 수 있기 때문입니다. 그러면 순환 안의 각 항목 참조 수가 절대 0에 도달하지 못해, 값이 영원히 drop 되지 않습니다.

참조 순환 만들기

어떻게 참조 순환이 생길 수 있는지, 그리고 그것을 어떻게 막을 수 있는지 살펴봅시다. 먼저 목록 15-25의 List enum 정의와 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 variant가 가리키는 대상을 바꿀 수 있도록 RefCell<T> 를 담은 cons list 정의

우리는 목록 15-5의 List 정의를 또 다른 방식으로 변형해 사용합니다. 이제 Cons variant의 두 번째 요소는 RefCell<Rc<List>> 입니다. 목록 15-24에서 했던 것처럼 i32 값을 바꾸는 대신, 이번에는 Cons variant가 가리키는 List 값을 바꾸고 싶기 때문입니다. 또한 Cons variant가 있을 때 두 번째 항목에 쉽게 접근할 수 있도록 tail 메서드도 추가했습니다.

목록 15-26에서는 목록 15-25 정의를 사용하는 main 함수를 추가합니다. 이 코드는 리스트 a 와, a 를 가리키는 리스트 b 를 만든 뒤, 다시 ab 를 가리키게 바꿔서 참조 순환을 만듭니다. 중간중간 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 값의 참조 순환 만들기

우리는 먼저 5, Nil 을 담은 List 값을 a 라는 변수 안의 Rc<List> 인스턴스로 만듭니다. 그런 다음 값 10 을 담고 a 를 가리키는 또 다른 Rc<List> 인스턴스를 b 에 만듭니다.

그 다음 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` 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

리스트 ab 안의 Rc<List> 인스턴스 참조 수는, ab 를 가리키도록 바꾼 뒤 모두 2가 됩니다. main 끝에서 러스트는 변수 b 를 drop 하고, b 안의 Rc<List> 참조 수는 2에서 1로 줄어듭니다. 하지만 참조 수가 0이 아니라 1이기 때문에, 힙 안의 그 메모리는 아직 drop 되지 않습니다. 이어서 러스트는 a 도 drop 하며, a 안의 Rc<List> 참조 수도 2에서 1로 줄어듭니다. 하지만 이 경우도 다른 Rc<List> 인스턴스가 여전히 그것을 가리키고 있으므로 메모리를 정리할 수 없습니다. 따라서 이 리스트에 할당된 메모리는 영원히 수거되지 않은 채 남게 됩니다. 이를 그림으로 나타낸 것이 그림 15-4입니다.

A rectangle labeled 'a' that points to a rectangle containing the integer 5. A rectangle labeled 'b' that points to a rectangle containing the integer 10. The rectangle containing 5 points to the rectangle containing 10, and the rectangle containing 10 points back to the rectangle containing 5, creating a cycle.

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

마지막 println! 의 주석을 풀고 프로그램을 실행하면, 러스트는 ab 를 가리키고 b 가 다시 a 를 가리키는 이 순환 구조를 끝없이 출력하려다가 결국 스택 오버플로를 일으키게 됩니다.

이 예제에서는 참조 순환의 결과가 아주 심각하지는 않습니다. 순환을 만들고 나자마자 프로그램이 종료되기 때문입니다. 하지만 더 복잡한 프로그램이 이런 순환 안에 많은 메모리를 할당하고, 그 상태로 오래 유지한다면 프로그램은 필요 이상의 메모리를 사용하게 되고, 심한 경우 시스템의 여유 메모리를 다 써 버릴 수도 있습니다.

참조 순환은 아주 쉽게 만들어지는 것은 아니지만, 불가능한 것도 아닙니다. Rc<T> 를 담은 RefCell<T> 값이나, 내부 가변성과 참조 카운팅을 결합한 비슷한 중첩 타입을 사용할 때는, 순환을 만들지 않도록 여러분이 직접 주의해야 합니다. 러스트가 컴파일러 차원에서 이를 잡아 주지는 않습니다. 참조 순환은 프로그램 안의 논리 버그이며, 이를 줄이기 위해 자동화 테스트, 코드 리뷰, 그 밖의 소프트웨어 개발 기법을 활용해야 합니다.

참조 순환을 피하는 또 다른 방법은, 어떤 참조는 소유권을 표현하고 어떤 참조는 소유권을 표현하지 않도록 데이터 구조를 다시 설계하는 것입니다. 그러면 순환 구조 자체는 가지더라도, 실제로 값이 drop 될지 여부에는 오직 “소유권 관계”만 영향을 주게 할 수 있습니다. 목록 15-25처럼 Cons variant가 리스트를 반드시 소유해야 하는 상황에서는 그런 재구성이 어렵습니다. 이제는 부모 노드와 자식 노드로 이루어진 그래프를 예로 들어, 비소유 참조가 참조 순환을 방지하는 적절한 방법이 되는 경우를 살펴봅시다.

Weak<T> 로 참조 순환 방지하기

지금까지 우리는 Rc::clone 호출이 Rc<T> 인스턴스의 strong_count 를 증가시키고, strong_count 가 0일 때만 Rc<T> 인스턴스가 정리된다는 사실을 보았습니다. 그런데 Rc::downgrade 를 호출해 Rc<T> 에 대한 참조를 넘기면, 그 안의 값에 대한 약한 참조도 만들 수 있습니다. 강한 참조(strong references)Rc<T> 인스턴스의 소유권을 공유하는 방식입니다. 약한 참조(weak references) 는 소유권 관계를 표현하지 않으며, 참조 수 역시 그 값이 언제 정리될지에 영향을 주지 않습니다. 따라서 약한 참조가 포함된 순환은, 그 값들에 대한 강한 참조 수가 0이 되는 순간 자연스럽게 끊어지므로 참조 순환을 만들지 않습니다.

Rc::downgrade 를 호출하면 Weak<T> 타입의 스마트 포인터를 얻게 됩니다. Rc::clonestrong_count 를 1 증가시키는 것과 달리, Rc::downgradeweak_count 를 1 증가시킵니다. Rc<T>strong_count 처럼 weak_count 도 추적하지만, 차이는 Rc<T> 를 정리하는 데 weak_count 가 0일 필요는 없다는 점입니다.

Weak<T> 가 가리키는 값은 이미 drop 되었을 수도 있으므로, Weak<T> 가 가리키는 값을 실제로 사용하려면 그 값이 여전히 존재하는지 먼저 확인해야 합니다. 이를 위해 Weak<T> 인스턴스의 upgrade 메서드를 호출합니다. 그러면 Option<Rc<T>> 가 반환됩니다. Rc<T> 값이 아직 drop 되지 않았다면 Some 을 받고, 이미 drop 되었다면 None 을 받습니다. upgradeOption<Rc<T>> 를 반환하기 때문에, 러스트는 SomeNone 두 경우를 모두 처리하도록 강제하고, 그 덕분에 잘못된 포인터를 쓰는 일이 생기지 않습니다.

예제로는, 다음 항목만 아는 리스트 대신, 자식 노드도 알고 부모 노드도 아는 트리 구조를 만들어 보겠습니다.

트리 자료구조 만들기

먼저 자식 노드를 알고 있는 노드들로 트리를 구성하겠습니다. 우리는 자신만의 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>>>,
}

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> 값으로 정의합니다. 또한 어떤 노드가 다른 노드의 자식인지 변경할 수도 있어야 하므로, children 안의 Vec<Rc<Node>> 주위에 RefCell<T> 를 둡니다.

다음으로 이 구조체 정의를 사용해, 값이 3 이고 자식이 없는 leaf 노드 하나와, 값이 5 이고 leaf 를 자식으로 하나 가진 branch 노드 하나를 목록 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 노드 만들기

우리는 leaf 안의 Rc<Node> 를 clone 해서 branch 안에 저장합니다. 따라서 leaf 안의 Node 는 이제 leafbranch 라는 두 소유자를 갖게 됩니다. 우리는 branch.children 을 통해 branch 에서 leaf 로 갈 수는 있지만, leaf 에서 branch 로 가는 방법은 없습니다. leaf 안에는 branch 에 대한 참조가 없어, 서로 관련 있다는 사실을 모르기 때문입니다. 이제 leafbranch 를 자신의 부모로 알게 만들겠습니다.

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

자식 노드가 부모를 알게 하려면, Node 구조체 정의에 parent 필드를 추가해야 합니다. 문제는 parent 의 타입을 무엇으로 해야 하느냐입니다. Rc<T> 가 될 수는 없다는 점은 분명합니다. 그렇게 하면 leaf.parentbranch 를 가리키고, branch.childrenleaf 를 가리키는 참조 순환이 생기며, 두 값의 strong_count 는 절대 0이 되지 않을 것이기 때문입니다.

관계를 다르게 생각해 보면, 부모 노드는 자식을 소유해야 합니다. 부모 노드가 drop 되면 자식 노드도 함께 drop 되어야 하기 때문입니다. 하지만 자식이 부모를 소유해서는 안 됩니다. 자식 노드를 drop 하더라도 부모는 계속 존재해야 합니다. 이런 경우가 바로 약한 참조를 써야 하는 상황입니다!

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

이제 어떤 노드는 부모를 가리킬 수는 있지만 부모를 소유하지는 않습니다. 목록 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 노드를 만드는 부분은 parent 필드만 빼면 목록 15-27과 비슷합니다. leaf 는 처음에는 부모가 없으므로, 비어 있는 Weak<Node> 참조를 새로 만듭니다.

이 시점에서 leaf 의 부모를 얻기 위해 upgrade 메서드를 사용하면 None 을 얻게 됩니다. 첫 번째 println! 출력에서 이를 확인할 수 있습니다.

leaf parent = None

이제 branch 노드를 만들면, branch 역시 부모가 없으므로 parent 필드 안에 새 Weak<Node> 참조를 갖게 됩니다. 그리고 동시에 leaf 를 자식 중 하나로 넣습니다. branch 안에 Node 인스턴스를 만든 뒤에는, leafparent 필드를 바꿔 branch 를 가리키는 Weak<Node> 참조를 넣을 수 있습니다. 이를 위해 leaf.parent 안의 RefCell<Weak<Node>> 에 대해 borrow_mut 를 호출하고, 그다음 branch 안의 Rc<Node> 에 대해 Rc::downgrade 를 사용해 Weak<Node> 를 만듭니다.

이제 다시 leaf 의 부모를 출력하면, 이번에는 branch 를 담은 Some variant를 얻게 됩니다. 즉 leaf 는 이제 부모에 접근할 수 있습니다! 또한 leaf 자체를 출력할 때도, 목록 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 변화 시각화하기

새 내부 스코프를 만들고 branch 생성을 그 안으로 옮기면, Rc<Node> 인스턴스의 strong_count, weak_count 값이 어떻게 바뀌는지 쉽게 볼 수 있습니다. 그렇게 하면 branch 가 만들어졌다가 스코프를 벗어날 때 어떤 일이 일어나는지도 확인할 수 있습니다. 수정된 코드는 목록 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 를 만들고 strong/weak 참조 수를 살펴보기

leaf 가 생성된 직후, 그 Rc<Node> 는 strong count 1, weak count 0을 가집니다. 내부 스코프 안에서 branch 를 만들고 그것을 leaf 와 연결하면, 그 시점에 branch 안의 Rc<Node> 는 strong count 1, weak count 1을 갖게 됩니다 (leaf.parentWeak<Node>branch 를 가리키기 때문입니다). 그리고 leaf 쪽 count를 출력해 보면, branch.children 안에 leafRc<Node> clone 이 있기 때문에 strong count 는 2가 되고, weak count 는 여전히 0임을 볼 수 있습니다.

내부 스코프가 끝나면 branch 는 스코프를 벗어나고, 그 Rc<Node> 의 strong count 는 0이 되므로 해당 Node 는 drop 됩니다. leaf.parent 에서 온 weak count 1은 Node 가 drop 되는지 여부에 아무 영향도 주지 않으므로, 메모리 누수가 발생하지 않습니다!

스코프가 끝난 뒤 leaf 의 부모에 접근하려 하면 다시 None 을 얻습니다. 프로그램 끝 시점에는 leaf 변수 하나만 그 Rc<Node> 를 가리키고 있으므로, leaf 안의 Rc<Node> 는 다시 strong count 1, weak count 0이 됩니다.

참조 수 관리와 값 drop 로직은 모두 Rc<T>Weak<T>, 그리고 이들의 Drop 구현 안에 들어 있습니다. Node 정의에서 자식에서 부모로 향하는 관계를 Weak<T> 참조로 지정했기 때문에, 부모는 자식을 가리키고 자식도 부모를 가리킬 수 있으면서도 참조 순환과 메모리 누수를 만들지 않는 구조를 얻을 수 있는 것입니다.

정리

이 장에서는 스마트 포인터를 사용해, 러스트가 일반 참조에서 기본으로 제공하는 것과는 다른 보장과 트레이드오프를 만드는 방법을 살펴보았습니다. Box<T> 는 크기가 알려져 있고 힙에 할당된 데이터를 가리킵니다. Rc<T> 는 힙 데이터에 대한 참조 수를 추적해, 하나의 데이터에 여러 소유자가 있을 수 있게 해 줍니다. 내부 가변성을 가진 RefCell<T> 는, 바깥에서 보면 불변 타입이지만 내부 값을 바꿔야 할 때 사용할 수 있는 타입을 제공하며, 대여 규칙을 컴파일 시점이 아니라 런타임에 강제합니다.

또한 스마트 포인터 기능 대부분을 가능하게 하는 Deref, Drop 트레이트도 함께 다루었습니다. 메모리 누수를 일으킬 수 있는 참조 순환과, 그것을 Weak<T> 로 방지하는 방법도 살펴보았습니다.

이 장이 흥미를 자극했고 여러분만의 스마트 포인터를 직접 구현해 보고 싶다면, “The Rustonomicon”을 참고하세요.

다음 장에서는 러스트의 동시성을 다룹니다. 그 과정에서 새로운 스마트 포인터도 몇 가지 더 배우게 됩니다.