Rust 기초: Rc로 단일 스레드에서 데이터 공유하기

Rust의 소유권 시스템은 “한 번에 하나의 소유자만 존재할 수 있다”는 명확한 규칙을 가지고 있습니다. 대부분의 경우 이 규칙만으로 충분하지만, 때로는 여러 부분에서 같은 데이터를 소유해야 하는 상황이 있습니다. 예를 들어 그래프 자료구조에서 여러 노드가 같은 노드를 가리켜야 할 수 있습니다.

단일 스레드 환경에서 이런 공유 소유권이 필요할 때 Rc(Reference Counted)를 사용합니다. Rc는 참조 카운팅을 통해 여러 소유자가 같은 데이터를 공유할 수 있게 해주는 스마트 포인터입니다. 데이터를 가리키는 참조가 몇 개인지 추적하고, 마지막 참조가 사라질 때 메모리를 자동으로 해제합니다.

이 글에서는 Rc가 무엇이고, 어떻게 사용하는지, 그리고 순환 참조 문제를 어떻게 해결하는지 살펴보겠습니다. 소유권과 빌림의 기본 개념은 소유권과 빌림 글에서 다루고 있으니 참고하시기 바랍니다.

Rc가 필요한 이유

소유권 규칙에 따르면 값은 정확히 하나의 소유자만 가질 수 있습니다. 하지만 여러 부분에서 같은 데이터를 읽어야 하는 경우가 있습니다. 참조를 사용할 수도 있지만, 참조는 라이프타임 제약이 있어서 복잡한 자료구조를 만들 때 불편합니다.

대표적인 예가 그래프 자료구조입니다. 여러 노드가 같은 노드를 참조해야 하는 상황이 있는데, 이런 구조를 일반 소유권 규칙만으로 표현하기는 어렵습니다.

struct Node {
    value: i32,
    neighbors: Vec<Box<Node>>,
}

fn main() {
    let shared = Box::new(Node {
        value: 3,
        neighbors: vec![],
    });

    let node1 = Node {
        value: 1,
        // 오류! shared의 소유권을 이동할 수 없음
        neighbors: vec![shared],
    };

    let node2 = Node {
        value: 2,
        // shared는 이미 이동됨
        neighbors: vec![shared],
    };
}

위 코드는 노드 1과 2가 모두 동일한 노드를 이웃으로 참조하려는 그래프 구조입니다. 하지만 Box를 사용하면 소유권이 이동하기 때문에, sharednode1node2에서 동시에 사용할 수 없습니다. 이런 상황에서 Rc를 사용하면 여러 노드가 같은 노드를 공유할 수 있습니다.

Rc는 단일 스레드 환경에서만 사용할 수 있습니다. 멀티스레드 환경에서 데이터를 공유하려면 Arc를 사용해야 합니다.

Rc 기본 사용법

Rc는 Rc::new()로 생성합니다. Rc를 복제하면 데이터가 복사되는 것이 아니라, 같은 데이터를 가리키는 새로운 Rc가 만들어지고 참조 카운트가 증가합니다. 모든 Rc가 drop될 때 참조 카운트가 0이 되면, 그때 비로소 데이터가 메모리에서 해제됩니다.

use std::rc::Rc;

fn main() {
    let data = Rc::new(String::from("공유 데이터"));

    println!("참조 카운트: {}", Rc::strong_count(&data));

    {
        let data2 = Rc::clone(&data);
        let data3 = Rc::clone(&data);

        println!("참조 카운트: {}", Rc::strong_count(&data));
        println!("데이터: {}", data2);
    }

    println!("참조 카운트: {}", Rc::strong_count(&data));
}
결과
참조 카운트: 1
참조 카운트: 3
데이터: 공유 데이터
참조 카운트: 1

처음 data를 생성했을 때 참조 카운트는 1입니다. data2data3를 복제하면 참조 카운트가 3으로 증가합니다. 세 개의 Rc가 모두 같은 String 데이터를 가리키고 있습니다. 내부 스코프를 벗어나면 data2data3가 drop되면서 참조 카운트가 다시 1로 감소합니다.

Rc::clone(&rc)은 데이터를 깊게 복사하지 않고, 단순히 참조 카운트만 증가시키는 저렴한 연산입니다. rc.clone()과 동일하지만, Rc::clone()을 명시적으로 사용하면 이것이 얕은 복사임을 코드를 읽는 사람에게 명확히 알릴 수 있습니다.

앞서 본 그래프 예제를 Rc로 구현하면 다음과 같습니다.

use std::rc::Rc;

struct Node {
    value: i32,
    neighbors: Vec<Rc<Node>>,
}

fn main() {
    let shared = Rc::new(Node {
        value: 3,
        neighbors: vec![],
    });

    let node1 = Node {
        value: 1,
        neighbors: vec![Rc::clone(&shared)],
    };

    let node2 = Node {
        value: 2,
        neighbors: vec![Rc::clone(&shared)],
    };

    println!("shared 참조 카운트: {}", Rc::strong_count(&shared));
}
결과
shared 참조 카운트: 3

이제 노드 1과 2가 아무 문제없이 동일한 노드를 이웃으로 공유할 수 있게 되었습니다. 참조 카운트가 3인 이유는 원래의 shared와 두 노드(node1, node2)가 가진 복제본 때문입니다. 이렇게 Rc를 사용하면 여러 노드가 같은 노드를 공유하는 그래프 구조를 자연스럽게 표현할 수 있습니다.

Rc의 불변성과 내부 가변성

Rc가 제공하는 것은 불변 참조입니다. 여러 소유자가 있기 때문에, 누군가 데이터를 수정하면 다른 소유자에게 예상치 못한 영향을 줄 수 있습니다. 따라서 Rc를 통해서는 데이터를 읽을 수만 있고 수정할 수 없습니다.

use std::rc::Rc;

fn main() {
    let data = Rc::new(String::from("안녕"));

    // 오류! Rc는 불변 참조만 제공
    // data.push_str(" 하세요");
}

Rc를 통해 데이터를 수정하려면 내부 가변성(interior mutability)을 제공하는 타입과 함께 사용해야 합니다. RefCell이 대표적입니다. Rc<RefCell<T>> 패턴을 사용하면 여러 소유자가 데이터를 공유하면서도 런타임에 빌림 규칙을 검사하여 안전하게 수정할 수 있습니다.

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

fn main() {
    let data = Rc::new(RefCell::new(String::from("안녕")));

    let data2 = Rc::clone(&data);

    data2.borrow_mut().push_str(" 하세요");

    println!("데이터: {}", data.borrow());
}
결과
데이터: 안녕 하세요

RefCellborrow_mut()를 호출하면 가변 참조를, borrow()를 호출하면 불변 참조를 얻을 수 있습니다. 빌림 규칙은 런타임에 검사되므로, 규칙을 위반하면 패닉이 발생합니다. 하지만 대부분의 경우 Rc는 읽기 전용으로 사용하는 것이 더 안전하고 명확합니다.

순환 참조와 메모리 누수

Rc를 사용할 때 주의해야 할 점은 순환 참조(circular reference) 문제입니다. 예를 들어 그래프에서 두 노드가 서로를 가리키면 참조 카운트가 절대 0이 되지 않아 메모리 누수가 발생할 수 있습니다.

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

struct Node {
    value: i32,
    neighbors: Vec<Rc<RefCell<Node>>>,
}

fn main() {
    let node1 = Rc::new(RefCell::new(Node {
        value: 1,
        neighbors: vec![],
    }));

    let node2 = Rc::new(RefCell::new(Node {
        value: 2,
        neighbors: vec![],
    }));

    // 순환 참조 생성: node1 -> node2, node2 -> node1
    node1.borrow_mut().neighbors.push(Rc::clone(&node2));
    node2.borrow_mut().neighbors.push(Rc::clone(&node1));

    println!("node1 참조 카운트: {}", Rc::strong_count(&node1));
    println!("node2 참조 카운트: {}", Rc::strong_count(&node2));
}
결과
node1 참조 카운트: 2
node2 참조 카운트: 2

node1과 node2가 서로를 이웃으로 가리키는 순환 구조입니다. 함수가 끝나도 각 노드의 참조 카운트는 1로 남아 있어 메모리가 해제되지 않습니다. 이것이 순환 참조로 인한 메모리 누수입니다.

이 문제를 해결하려면 약한 참조(weak reference)인 Weak를 사용해야 합니다. Weak는 참조 카운트를 증가시키지 않는 참조입니다. Rc::downgrade()를 호출하면 Weak 참조를 얻을 수 있고, Weak::upgrade()를 호출하면 다시 Rc로 변환할 수 있습니다.

use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    value: i32,
    neighbors: Vec<Rc<RefCell<Node>>>,
    back_edges: Vec<Weak<RefCell<Node>>>,
}

fn main() {
    let node1 = Rc::new(RefCell::new(Node {
        value: 1,
        neighbors: vec![],
        back_edges: vec![],
    }));

    let node2 = Rc::new(RefCell::new(Node {
        value: 2,
        neighbors: vec![],
        back_edges: vec![],
    }));

    // node1 -> node2 강한 참조 (순방향 간선)
    node1.borrow_mut().neighbors.push(Rc::clone(&node2));
    // node2 -> node1 약한 참조 (역방향 간선)
    node2.borrow_mut().back_edges.push(Rc::downgrade(&node1));

    println!("node1 강한 참조: {}", Rc::strong_count(&node1));
    println!("node1 약한 참조: {}", Rc::weak_count(&node1));
    println!("node2 강한 참조: {}", Rc::strong_count(&node2));
}
결과
node1 강한 참조: 1
node1 약한 참조: 1
node2 강한 참조: 2

node1은 node2를 강한 참조(Rc)로 가리키고, node2는 node1을 약한 참조(Weak)로 가리킵니다. 이렇게 하면 순환 참조가 있어도 메모리 누수가 발생하지 않습니다. node1의 강한 참조 카운트가 0이 되면 node1이 해제되고, 그러면 node2도 해제됩니다.

Weak는 데이터가 이미 해제되었을 수 있으므로, upgrade()Option<Rc<T>>를 반환합니다. 데이터가 아직 살아있으면 Some(Rc<T>)를, 이미 해제되었으면 None을 반환합니다.

use std::rc::{Rc, Weak};

fn main() {
    let strong = Rc::new(42);
    let weak: Weak<i32> = Rc::downgrade(&strong);

    // 데이터가 살아있을 때
    if let Some(rc) = weak.upgrade() {
        println!("값: {}", rc);
    }

    drop(strong);

    // 데이터가 해제된 후
    if weak.upgrade().is_none() {
        println!("데이터가 해제되었습니다");
    }
}
결과
값: 42
데이터가 해제되었습니다

Rc vs Arc

Rc는 단일 스레드 환경에서만 사용할 수 있습니다. 참조 카운트를 일반 정수로 관리하기 때문에, 여러 스레드에서 동시에 접근하면 데이터 경쟁이 발생할 수 있습니다. 실제로 Rc를 스레드 간에 전송하려고 하면 컴파일 오류가 발생합니다.

멀티스레드 환경에서는 Arc(Atomic Reference Counted)를 사용해야 합니다. Arc는 원자적 연산을 사용하여 참조 카운트를 관리하므로 스레드 안전합니다. 다만 원자적 연산은 일반 정수 연산보다 느리기 때문에, 단일 스레드 환경에서는 Rc를 사용하는 것이 더 효율적입니다.

언제 무엇을 사용할지 정리하면 다음과 같습니다. 단일 스레드에서 공유 소유권이 필요하면 Rc를 사용하세요. 멀티스레드에서 공유 소유권이 필요하면 Arc를 사용하세요. 데이터를 수정해야 한다면 Rc는 RefCell과, Arc는 Mutex와 함께 사용하세요.

Arc에 대한 자세한 내용은 Arc로 스레드 간 데이터 공유하기 글을 참고하시기 바랍니다.

마치며

Rc는 참조 카운팅을 통해 단일 스레드 환경에서 여러 소유자가 같은 데이터를 공유할 수 있게 해주는 스마트 포인터입니다. 그래프 같은 복잡한 자료구조를 만들 때 유용하며, 참조 카운트가 자동으로 관리되므로 메모리를 직접 관리할 필요가 없습니다.

순환 참조로 인한 메모리 누수를 방지하려면 Weak 참조를 적절히 사용해야 합니다. 그래프에서는 순방향 간선은 강한 참조로, 역방향 간선은 약한 참조로 구분하는 패턴이 일반적입니다. 이렇게 하면 소유권 계층이 명확해지고 메모리 누수를 방지할 수 있습니다.

Rc는 불변 참조만 제공하므로, 데이터를 수정하려면 RefCell 같은 내부 가변성 타입과 함께 사용해야 합니다. 하지만 가능하면 불변으로 유지하는 것이 더 안전하고 이해하기 쉽습니다.

소유권과 빌림의 기본 개념은 소유권과 빌림 글을 참고하시기 바랍니다. 공유 소유권이 필요 없고 단순히 힙에 데이터를 저장하고 싶다면 Box 글을 참고하시기 바랍니다. 멀티스레드 환경에서의 공유 소유권은 Arc로 스레드 간 데이터 공유하기 글에서 다루고 있습니다. Rc를 통해 내부 값의 메서드를 바로 호출할 수 있는 원리가 궁금하다면 Deref 트레이트와 역참조 강제를 참고하시기 바랍니다.

This work is licensed under CC BY 4.0 CC BY

Discord