Rc<T>, 참조 카운트 스마트 포인터

대부분의 경우, 소유권은 명확하다. 특정 값이 어떤 변수에 의해 소유되는지 정확히 알 수 있다. 하지만 어떤 경우에는 하나의 값이 여러 소유자를 가질 수 있다. 예를 들어, 그래프 데이터 구조에서 여러 간선이 동일한 노드를 가리킬 수 있으며, 이 노드는 개념적으로 그 노드를 가리키는 모든 간선에 의해 소유된다. 노드는 더 이상 간선이 없어 소유자가 없는 경우에만 정리되어야 한다.

Rust에서는 Rc<T> 타입을 사용해 명시적으로 다중 소유권을 활성화해야 한다. Rc<T>참조 카운팅(reference counting) 의 약자로, 이 타입은 값에 대한 참조 수를 추적해 값이 여전히 사용 중인지 여부를 결정한다. 값에 대한 참조가 0개가 되면, 그 값은 더 이상 사용되지 않으므로 정리할 수 있다.

Rc<T>를 가족 방에 있는 TV라고 상상해 보자. 한 사람이 방에 들어와 TV를 켜면, 다른 사람들도 방에 들어와 TV를 볼 수 있다. 마지막 사람이 방을 나갈 때, 더 이상 TV가 사용되지 않으므로 TV를 끈다. 만약 누군가가 다른 사람들이 아직 TV를 보고 있는데 TV를 꺼버리면, 남아 있는 시청자들이 불만을 가질 것이다!

Rc<T> 타입은 프로그램의 여러 부분이 읽을 수 있도록 힙에 데이터를 할당하고, 컴파일 타임에 어떤 부분이 데이터를 마지막으로 사용할지 결정할 수 없을 때 사용한다. 만약 어떤 부분이 마지막으로 데이터를 사용할지 알고 있다면, 그 부분을 데이터의 소유자로 만들고, 컴파일 타임에 적용되는 일반적인 소유권 규칙을 따를 수 있다.

Rc<T>는 단일 스레드 시나리오에서만 사용된다는 점에 유의해야 한다. 16장에서 동시성을 다룰 때, 멀티스레드 프로그램에서 참조 카운팅을 어떻게 수행하는지 설명할 것이다.

Rc<T>를 사용해 데이터 공유하기

리스트 15-5의 cons 리스트 예제로 돌아가 보자. 이전에는 Box<T>를 사용해 리스트를 정의했다. 이번에는 두 개의 리스트가 세 번째 리스트를 공유하는 상황을 만들어 볼 것이다. 개념적으로는 그림 15-3과 비슷한 구조다.

세 번째 리스트를 공유하는 두 개의 리스트

그림 15-3: 리스트 bc가 리스트 a를 공유하는 구조

먼저 5와 10을 포함하는 리스트 a를 만든다. 그런 다음 두 개의 리스트 bc를 생성한다. b는 3으로 시작하고, c는 4로 시작한다. 그리고 두 리스트 모두 5와 10을 포함하는 리스트 a를 이어받는다. 즉, 두 리스트가 5와 10을 포함하는 리스트를 공유하게 된다.

이 시나리오를 Box<T>를 사용해 구현하려고 하면 리스트 15-17과 같이 동작하지 않는다.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}
Listing 15-17: Box<T>를 사용해 두 리스트가 세 번째 리스트를 공유하려 할 때 발생하는 문제

이 코드를 컴파일하면 다음과 같은 에러가 발생한다.

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

Cons 변형은 자신이 포함한 데이터의 소유권을 갖는다. 따라서 리스트 b를 생성할 때 ab로 이동하고, ba를 소유하게 된다. 그런 다음 리스트 c를 생성할 때 다시 a를 사용하려고 하면, a가 이미 이동되었기 때문에 허용되지 않는다.

Cons의 정의를 변경해 참조를 포함하도록 할 수도 있지만, 그렇게 하면 라이프타임 매개변수를 명시해야 한다. 라이프타임 매개변수를 명시하면 리스트의 모든 요소가 적어도 리스트 전체만큼 오래 살아있음을 보장한다. 리스트 15-17의 요소와 리스트는 이 조건을 만족하지만, 모든 상황에서 그렇지는 않다.

대신 Box<T> 대신 Rc<T>를 사용해 List를 정의한다. 리스트 15-18에서 볼 수 있듯이, 각 Cons 변형은 값과 List를 가리키는 Rc<T>를 포함한다. b를 생성할 때 a의 소유권을 가져가는 대신, a가 포함하는 Rc<List>를 복제한다. 이렇게 하면 참조 수가 1에서 2로 증가하고, abRc<List>의 데이터를 공유하게 된다. c를 생성할 때도 a를 복제해 참조 수를 2에서 3으로 증가시킨다. Rc::clone을 호출할 때마다 Rc<List> 내부 데이터의 참조 수가 증가하며, 참조 수가 0이 되기 전까지 데이터는 정리되지 않는다.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}
Listing 15-18: Rc<T>를 사용한 List 정의

Rc<T>를 스코프로 가져오기 위해 use 문을 추가해야 한다. main 함수에서는 5와 10을 포함하는 리스트를 생성하고, 이를 a라는 새로운 Rc<List>에 저장한다. 그런 다음 bc를 생성할 때 Rc::clone 함수를 호출하고, a에 있는 Rc<List>의 참조를 인자로 전달한다.

a.clone() 대신 Rc::clone(&a)를 호출할 수도 있지만, 러스트에서는 이 경우 Rc::clone을 사용하는 것이 관례다. Rc::clone의 구현은 대부분의 타입의 clone 구현과 달리 모든 데이터를 깊은 복사하지 않는다. Rc::clone 호출은 참조 수만 증가시키며, 이는 시간이 거의 걸리지 않는다. 데이터의 깊은 복사는 시간이 많이 걸릴 수 있다. Rc::clone을 사용해 참조 수를 증가시키면, 깊은 복사와 참조 수 증가를 시각적으로 구분할 수 있다. 코드에서 성능 문제를 찾을 때는 깊은 복사만 고려하면 되고, Rc::clone 호출은 무시해도 된다.

Rc<T>를 복제하면 참조 카운트가 증가한다

리스트 15-18의 예제를 수정해 a에 있는 Rc<List>에 대한 참조를 생성하고 제거할 때 참조 카운트가 어떻게 변하는지 살펴보자.

리스트 15-19에서는 main 함수를 수정해 리스트 c 주위에 내부 스코프를 추가한다. 이렇게 하면 c가 스코프를 벗어날 때 참조 카운트가 어떻게 변하는지 확인할 수 있다.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
Listing 15-19: 참조 카운트 출력

프로그램에서 참조 카운트가 변하는 각 지점에서 Rc::strong_count 함수를 호출해 참조 카운트를 출력한다. 이 함수는 count가 아닌 strong_count라는 이름을 사용하는데, 그 이유는 Rc<T> 타입이 weak_count도 가지고 있기 때문이다. weak_count의 용도는 Weak<T>를 사용해 참조 순환 방지하기”에서 살펴볼 것이다.

이 코드는 다음과 같은 결과를 출력한다:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

a에 있는 Rc<List>의 초기 참조 카운트는 1이다. 이후 clone을 호출할 때마다 카운트가 1씩 증가한다. c가 스코프를 벗어나면 카운트가 1 감소한다. 참조 카운트를 증가시키기 위해 Rc::clone을 호출해야 하는 것과 달리, 참조 카운트를 감소시키기 위해 별도의 함수를 호출할 필요는 없다. Rc<T> 값이 스코프를 벗어나면 Drop 트레이트의 구현이 자동으로 참조 카운트를 감소시킨다.

이 예제에서는 볼 수 없지만, main 함수의 끝에서 ba가 스코프를 벗어나면 카운트가 0이 되고, Rc<List>는 완전히 정리된다. Rc<T>를 사용하면 단일 값이 여러 소유자를 가질 수 있으며, 카운트는 소유자 중 하나라도 존재하는 한 값이 유효하도록 보장한다.

Rc<T>는 불변 참조를 통해 프로그램의 여러 부분에서 데이터를 읽기 전용으로 공유할 수 있게 해준다. 만약 Rc<T>가 여러 가변 참조도 허용한다면, 4장에서 다룬 빌림 규칙 중 하나를 위반할 수 있다. 동일한 위치에 대한 여러 가변 빌림은 데이터 경쟁과 불일치를 초래할 수 있다. 하지만 데이터를 변경할 수 있는 기능은 매우 유용하다! 다음 섹션에서는 내부 가변성 패턴과 Rc<T>와 함께 사용할 수 있는 RefCell<T> 타입에 대해 논의할 것이다.