이 문서는 2판 번역본입니다.

최신 2021 에디션 문서는 https://doc.rust-kr.org 에서 확인하실 수 있습니다.

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

대부분의 경우에서, 소유권은 명확합니다: 여러분은 어떤 변수가 주어진 값을 소유하는지 정확히 압니다. 그러나, 하나의 값이 여러 개의 소유자를 가질 수도 있는 경우가 있습니다. 예를 들면, 그래프 데이터 구조에서, 여러 에지가 동일한 노드를 가리킬 수도 있고, 그 노드는 개념적으로 해당 노드를 가리키는 모든 에지들에 의해 소유됩니다. 노드는 어떠한 에지도 이를 가리키지 않을 때까지는 메모리 정리가 되어서는 안됩니다.

복수 소유권을 가능하게 하기 위해서, 러스트는 Rc<T>라 불리우는 타입을 가지고 있습니다. 이 이름은 참조 카운팅 (reference counting) 의 약자인데, 이는 어떤 값이 계속 사용되는지 혹은 그렇지 않은지를 알기 위해 해당 값에 대한 참조자의 갯수를 계속 추적하는 것입니다. 만일 값에 대한 참조자가 0개라면, 그 값은 어떠한 참조자도 무효화하지 않고 메모리 정리될 수 있습니다.

Rc<T>를 거실의 TV로 상상해보세요. 만일 한 사람이 TV를 보러 들어온다면, TV를 킵니다. 다른 사람들은 거실로 들어와서 TV를 볼 수 있습니다. 마지막 사람이 거실을 나선다면, TV는 더 이상 사용되지 않으므로 이를 끕니다. 만일 다른 사람들이 여전히 TV를 보고 있는 중에 누군가가 이를 끈다면, 남은 TV 시청자들로부터 엄청난 소란이 있을 것입니다!

우리 프로그램의 여러 부분에서 읽을 데이터를 힙에 할당하고 싶고, 어떤 부분이 그 데이터를 마지막에 이용하게 될지 컴파일 타임에는 알 수 없는 경우 Rc<T> 타입을 사용합니다. 만일 어떤 부분이 마지막으로 사용하는지 알 수 있다면, 우리는 그냥 그 해당 부분을 데이터의 소유자로 만들면 되고, 컴파일 타임에 집행되는 보통의 소유권 규칙이 효과를 발생시킬 것입니다.

Rc<T>가 오직 단일 스레드 시나리오 상에서만 사용 가능하다는 점을 주의하세요. 16장에서 동시성 (concurrency) 을 논의할 때, 다중 스레드 프로그램에서는 어떻게 참조 카운팅을 하는지 다루겠습니다.

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

Listing 15-5의 cons list 예제로 돌아가 봅시다. 우리는 Box<T>를 이용해서 이것을 정의했던 것을 상기하세요. 이번에는 세 번째 리스트의 소유권을 둘 다 공유하는 두 개의 리스트를 만들 것인데, 이는 개념적으로 Figure 15-3과 유사하게 보일 것입니다:

Two lists that share ownership of a third list

Figure 15-3: 세 번째 리스트 a의 소유권을 공유하는 두 리스트 bc

우리는 5와 10을 담은 리스트 a를 만들 것입니다. 그런 다음 두 개의 리스트를 더 만들 것입니다: 3으로 시작하는 b와 4로 시작하는 c입니다. 그리고 나서 bc 리스트 둘 모두 5와 10을 담고 있는 첫번째 a 리스트로 계속되게 할 것입니다. 바꿔 말하면, 두 리스트 모두 5와 10을 담은 첫 리스트를 공유하게 될 것입니다.

Listing 15-17에서 보시는 것처럼, 우리가 Box<T>를 가지고 정의한 List를 이용하여 이 시나리오를 구현하는 시도는 작동하지 않을 것입니다:

Filename: src/main.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use 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>를 이용한 두 리스트가 세 번째 리스트의 소유권을 공유하는 시도는 허용되지 않음을 보이는 예

이 코드를 컴파일하면, 다음과 같은 에러를 얻습니다:

error[E0382]: use of moved value: `a`
  --> src/main.rs:13:30
   |
12 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
13 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move
   |
   = note: move occurs because `a` has type `List`, which does not implement
   the `Copy` trait

Cons variant는 이것이 가지고 있는 데이터를 소유하므로, 우리가 b리스트를 만들때, ab 안으로 이동되고 ba를 소유합니다. 그 뒤, c를 생성할 때 a를 다시 이용하는 시도를 할 경우, 이는 a가 이동되었으므로 허용되지 않습니다.

우리는 Cons가 대신 참조자를 갖도록 정의를 변경할 수도 있지만, 그러면 라이프타임 파라미터를 명시해야 할 것입니다. 라이프타임 파라미터를 명시함으로써, 리스트 내의 모든 요소들이 최소한 전체 리스트만큼 오래 살아있을 것입니다. 예를 들어 빌림 검사기는 라이프타임 파라미터를 명시함으로써, let a = Cons(10, &Nil);&Nil과 같은 임시 값에 대한 참조를 사용할 수 있게 해줍니다. 그러나 경우에 따라 적합한 라이프타임 매개변수를 지정하는 것이 어렵거나 비실용적일 수 있습니다.

대신, 우리는 Listing 15-18과 같이 Box<T>의 자리에 Rc<T>를 이용하여 List의 정의를 바꿀 것입니다. 각각의 Cons variant는 이제 어떤 값과 List를 가리키는 Rc<T>를 갖게 될 것입니다. b를 만들때는 a의 소유권을 얻는 대신, a를 가지고 있는 Rc<List>를 클론할 것인데, 이는 참조자의 갯수를 하나에서 둘로 증가시키고 abRc<List> 안에 있는 값을 공유하게 해줍니다. 우리는 또한 c를 만들때도 a를 클론할 것인데, 이는 참조자의 갯수를 둘에서 셋으로 늘립니다. 우리가 Rc::clone을 호출하는 매번마다, 해당 Rc<List>가 가지고 있는 데이터에 대한 참조 카운트는 증가할 것이고, 그 데이터는 참조자가 0개가 되지 않으면 메모리 정리되지 않을 것입니다:

Filename: src/main.rs

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use 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 함수를 호출하고 aRc<List>에 대한 참조자를 인자로서 넘깁니다.

Rc::clone(&a) 보다는 a.clone()을 호출할 수도 있지만, 위의 경우 러스트의 관례는 Rc::clone를 이용하는 것입니다. Rc::clone의 구현체는 대부분의 타입들의 clone 구현체들이 하는 것처럼 모든 데이터의 깊은 복사 (deep copy) 를 만들지 않습니다. Rc::clone의 호출은 오직 참조 카운트만 증가 시키는데, 이는 큰 시간이 들지 않습니다. 데이터의 깊은 복사는 많은 시간이 걸릴 수 있습니다. 참조 카운팅을 위해 Rc::clone을 이용함으로써, 우리는 깊은 복사 종류의 클론과 참조 카운트를 증가시키는 종류의 클론을 시각적으로 구별할 수 있습니다. 코드 내에서 성능 문제를 찾고 있다면, 깊은 복사 클론만 고려할 필요가 있고 Rc::clone 호출은 무시할 수 있는 것입니다.

Rc<T>의 클론 생성은 참조 카운트를 증가시킵니다

Listing 15-18의 동작 예제를 변경하여 a 내부의 Rc<List>에 대한 참조자가 생성되고 드롭될 때 참조 카운트의 변화를 볼 수 있도록 해봅시다.

Listing 15-19에서는 main을 변경하여 리스트 c를 감싸고 있는 내부 스코프를 갖도록 하겠습니다; 그런 다음 우리는 c가 스코프 밖으로 벗어났을 때 참조 카운트가 어떻게 변하는지 볼 수 있습니다. 프로그램 내 참조 카운트가 변하는 각 지점에서, 우리는 참조 카운트 값을 출력할텐데, 이는 Rc::strong_count 함수를 호출함으로써 얻을 수 있습니다. 이 함수는 count 보다는 strong_count라는 이름을 갖고 있는데 이는 Rc<T> 타입이 weak_count도 갖고 있기 때문입니다; weak_count가 무엇을 위해 사용되는지는 “참조 순환 (reference cycles) 방지하기”절에서 볼 것입니다.

Filename: src/main.rs

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use 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: 참조 카운트 출력하기

이 코드는 다음을 출력합니다:

count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

우리는 aRc<List>가 초기 참조 카운트로서 1을 갖는 것을 볼 수 있습니다; 그 다음 우리가 clone을 호출하는 매번마다, 카운트는 1씩 증가합니다. c가 스코프 밖으로 벗어날 때, 카운트는 1만큼 감소합니다. 우리는 참조 카운트를 증가시키기 위해서 Rc::clone를 호출해야 하는 것과 같이 참조 카운트를 감소시키기 위해 어떤 함수를 호출하지 않아도 됩니다: Rc<T> 값이 스코프 밖으로 벗어나면 Drop 트레잇의 구현체가 자동으로 참조 카운트를 감소시킵니다.

이 예제에서 볼수 없는 것은 main의 끝에서 b와 그 다음 a가 스코프 밖을 벗어나서, 카운트가 0이 되고, 그 시점에서 Rc<List>가 완전히 메모리 정리되는 때입니다. Rc<T>를 이용하는 것은 단일값이 복수 개의 소유자를 갖도록 허용해주고, 이 카운트는 소유자중 누구라도 여전히 존재하는 한 값이 계속 유효함을 확실히 해줍니다.

불변 참조자를 통하여, Rc<T>는 읽기 전용으로 우리 프로그램의 여러 부분 사이에서 데이터를 공유하도록 허용해줍니다. 만일 Rc<T>가 또한 복수개의 가변 참조자도 갖는 것을 허용한다면, 우리는 4장에서 논의했던 빌림 규칙 중 하나를 위반할지도 모릅니다: 동일한 위치에 대한 복수개의 가변 빌림은 데이터 레이스 및 데이터 불일치를 야기할 수 있다는 것입니다. 하지만 데이터의 변형을 가능하게 하는 것은 매우 유용하죠! 다음 절에서는 내부 가변성 (interior mutability) 패턴과 이러한 불변성 제약과 함께 동작하기 위해 Rc<T>와 같이 결합하여 사용할 수 있는 RefCell<T> 타입에 대해 논의할 것입니다.