[Rust 시리즈 16편] 스마트 포인터와 힙 데이터

English version

이번 글에서 다룰 내용

이번 편은 스마트 포인터라는 말을 처음 들었을 때 드는 의문부터 풀어 줍니다. 스마트 포인터는 그 자체가 값인 동시에 포인터처럼 동작하는 타입으로, Box<T>, [[rc|Rc<T>]], [[refcell|RefCell<T>]]가 대표적입니다. 각 타입이 언제 필요하고 어떤 제약을 풀어 주는지부터 설명합니다.

왜 필요한가

Rust는 기본적으로 스택에 위치한 값의 소유권을 명확히 추적합니다. 하지만 재귀적인 자료구조나 여러 주체가 같은 데이터를 참조해야 하는 상황에서는 스택 값만으로는 표현이 어렵습니다. 이때 에 데이터를 두고 포인터를 조작하는 것이 필요한데, 스마트 포인터는 힙 데이터와 소유권 규칙을 동시에 관리합니다.

입문자 기준으로는 먼저 "왜 그냥 참조 &T로는 안 되지?"를 묻는 편이 이해가 쉽습니다.

  • &T: 잠깐 빌려 쓰는 참조
  • Box<T>: 힙에 값을 두고 단일 소유권 유지
  • Rc<T>: 여러 소유자가 함께 읽기 전용 공유
  • [[refcell|RefCell]]: 단일 스레드 안에서 런타임에 가변 빌리기 검사

즉, 스마트 포인터는 단순히 포인터 문법이 아니라 "소유권 규칙을 다른 방식으로 풀어내는 도구"입니다.

Box로 힙에 올리기

Box<T>는 값을 힙에 두고 스택에는 포인터만 두게 해 줍니다. 재귀 타입을 만들거나 큰 데이터를 이동 없이 다루고 싶을 때 유용합니다.

enum Node {
    Value(i32),
    Next(Box<Node>),
}

fn depth(node: &Node) -> u32 {
    match node {
        Node::Value(_) => 1,
        Node::Next(inner) => 1 + depth(inner),
    }
}

fn main() {
    let chain = Node::Next(Box::new(Node::Next(Box::new(Node::Value(10)))));
    println!("depth = {}", depth(&chain));
}

열거형이 자기 자신을 다시 포함하려면 크기가 정해져 있어야 하는데, Box는 포인터 크기로 고정되어 이 제약을 풀어 줍니다.

[[rc|Rc<T>]]로 참조 카운팅하기

[[rc|Rc<T>]](Reference Counted)는 힙 데이터를 여러 소유자가 공유할 수 있게 합니다. 단, 컴파일러가 가변 참조를 동시에 허용하지 않기 때문에 Rc<T>는 불변 공유만 제공합니다. 또한 Rc<T>는 단일 스레드 전용이므로, 여러 스레드에서 공유하려면 18편에서 볼 [[arc|Arc<T>]]가 필요합니다.

use std::rc::Rc;

struct TodoItem {
    title: String,
}

fn main() {
    let shared = Rc::new(TodoItem { title: "Ownership 복습".into() });
    let daily = Rc::clone(&shared);
    let review = Rc::clone(&shared);

    println!("daily = {}", daily.title);
    println!("review = {}", review.title);
    println!("count = {}", Rc::strong_count(&shared));
}

Rc::clone은 데이터 복사를 하지 않고 카운트만 증가시킵니다. 스레드 간 공유는 허용되지 않으므로, 동시성을 다룰 때는 Arc<T>를 사용해야 한다는 점을 미리 기억해 둡니다.

여기서 clone이라는 이름이 붙어 있어도, String 전체를 복사하는 것과는 다릅니다. 이 경우에는 실제 데이터가 아니라 "공유 소유권 카운트"만 하나 늘어난다고 이해하면 됩니다.

[[refcell|RefCell<T>]]로 런타임 가변성 얻기

[[refcell|RefCell<T>]]는 불변 참조로도 내부 데이터를 가변적으로 다룰 수 있게 해 주지만, 규칙 위반은 런타임 패닉으로 이어집니다. 즉, 컴파일 타임 검사 대신 런타임 검사로 borrowing 규칙을 옮기는 내부 가변성 타입입니다.

짧게 정리하면 아래와 같습니다.

  • 컴파일 타임 검사: 더 일찍 막아 주지만 유연성은 적음
  • 런타임 검사(RefCell): 더 유연하지만 잘못 쓰면 실행 중 패닉 발생
use std::cell::RefCell;

fn main() {
    let log = RefCell::new(Vec::new());

    {
        let mut borrow = log.borrow_mut();
        borrow.push("start".to_string());
    }

    println!("entries = {:?}", log.borrow());
}

borrowborrow_mut 함수는 참조를 반환하지만, 동시에 몇 개의 참조가 살아 있는지 추적합니다. 여러 개의 불변 대여는 허용되지만, 가변 대여가 살아 있는 동안 다른 대여가 겹치면 컴파일이 아니라 실행 중에 패닉이 발생합니다.

조합 패턴: Rc<RefCell>

여러 소유자가 데이터를 공유하면서도 일부가 값을 바꿀 수 있어야 한다면 Rc<RefCell<T>> 패턴을 씁니다.

처음 보면 이 조합이 가장 어렵습니다. 그래서 역할을 쪼개서 보면 훨씬 단순합니다.

  • Rc<T>: 여러 소유자 허용
  • RefCell<T>: 내부 값 변경 허용
  • Rc<RefCell<T>>: 여러 소유자가 하나의 값을 공유하면서, 필요할 때 내부 값을 바꿈
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Counter {
    value: u32,
}

fn main() {
    let counter = Rc::new(RefCell::new(Counter { value: 0 }));

    let job_a = Rc::clone(&counter);
    let job_b = Rc::clone(&counter);

    {
        job_a.borrow_mut().value += 1;
    }
    {
        job_b.borrow_mut().value += 2;
    }

    println!("final value = {}", counter.borrow().value);
}

이 패턴은 GUI 컴포넌트 트리나 그래프 구조에서 자주 쓰입니다. 다만 런타임 패닉 가능성이 있으므로, 값 변경 경로가 명확한지 항상 점검해야 합니다. 또 Rc끼리 서로를 가리키는 구조를 만들면 순환 참조 때문에 메모리가 해제되지 않을 수 있으므로, 더 복잡한 그래프에서는 [[weak|Weak<T>]] 같은 도구도 함께 고려해야 합니다.

입문 단계에서는 아래 한 줄로 정리해 두면 좋습니다.

Rc<RefCell<T>>는 편하지만, 컴파일러가 대신 다 막아 주지 못하는 만큼 스스로 더 조심해야 하는 패턴입니다.

연습 과제

  1. Node 열거형에 value() 메서드를 추가해 가장 마지막 Value를 꺼내는 함수를 작성해 보세요.
  2. Rc<RefCell<Vec<String>>>으로 이벤트 로그를 공유하며 각 모듈이 로그를 추가하도록 설계해 보세요.

마무리

스마트 포인터는 힙 데이터를 안전하게 다루는 문법적 도구입니다. Box<T>는 크기 미정 타입을 표현하고, Rc<T>는 불변 공유, RefCell<T>는 런타임 가변성을 제공합니다. 다음 편에서는 이런 데이터 구조를 안정적으로 검증하는 테스트와 문서화 흐름을 익힙니다.

💬 댓글

이 글에 대한 의견을 남겨주세요