[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>>는 편하지만, 컴파일러가 대신 다 막아 주지 못하는 만큼 스스로 더 조심해야 하는 패턴입니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo checkcargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. starter를 fork한 뒤 src/main.rs를 연다
  2. 본문 예제를 붙여 넣고 cargo check와 cargo run을 차례로 실행한다
  3. 타입, 값, 참조 흐름을 바꿔 컴파일 피드백과 출력 차이를 비교한다

Rust 실습은 브라우저 미리보기보다 터미널 피드백이 더 중요합니다. 여러 파일 구조나 추가 crate가 필요한 예제는 파일 배치를 조금 더 손봐야 할 수 있습니다.

연습 과제

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

마무리

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

💬 댓글

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