이번 글에서 다룰 내용
이번 편은 스마트 포인터라는 말을 처음 들었을 때 드는 의문부터 풀어 줍니다. 스마트 포인터는 그 자체가 값인 동시에 포인터처럼 동작하는 타입으로, 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());
}
borrow와 borrow_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 check와 cargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요