- 들어가며 — 스마트 포인터란 무엇인가
- Box — 값을 힙에 놓기
- Rc — 소유권을 나눠 갖기
- Arc — 스레드를 넘나드는 공유 소유
- RefCell과 내부 가변성 — 규칙을 런타임으로 옮기기
- Mutex — 스레드 안전한 내부 가변성
- Deref 강제 변환 — 스마트 포인터를 값처럼
- Rc 순환 참조와 Weak — 메모리 누수 막기
- 무엇을 언제 쓸까 — 정리
- 마치며
- 참고 자료
들어가며 — 스마트 포인터란 무엇인가
Rust의 소유권 시스템은 강력하지만, 때로는 너무 엄격하게 느껴집니다. 값 하나에 소유자는 오직 하나, 빌림은 규칙에 따라, 크기는 컴파일 타임에 확정. 이 규칙만으로는 재귀 자료구조나 여러 곳에서 공유되는 상태를 표현하기가 어렵습니다. 그 간극을 메우는 것이 **스마트 포인터(smart pointer)**입니다.
스마트 포인터는 그냥 주소를 담는 원시 포인터가 아니라, 데이터를 가리키면서 추가로 메타데이터와 동작을 함께 지니는 자료구조입니다. 대부분 어떤 값을 소유하고, 스코프를 벗어날 때 그 값을 정리하는 책임까지 집니다. 사실 여러분이 매일 쓰는 String 이나 Vec<T> 도 힙 메모리를 소유하고 관리한다는 점에서 이미 스마트 포인터입니다.
이 글에서는 표준 라이브러리의 핵심 스마트 포인터 다섯 가지, Box, Rc, Arc, RefCell, Mutex 를 하나씩 짚습니다. 각각이 어떤 문제를 풀기 위해 존재하는지, 언제 손을 뻗어야 하는지, 그리고 잘못 쓰면 어떤 함정에 빠지는지를 예제로 익히겠습니다.
Box — 값을 힙에 놓기
가장 단순한 스마트 포인터는 Box<T> 입니다. 하는 일은 이름 그대로입니다. 값을 스택이 아니라 **힙(heap)**에 놓고, 그 힙 주소를 담은 포인터를 스택에 둡니다. 소유권은 여전히 하나뿐이라 규칙이 단순합니다.
fn main() {
let boxed: Box<i32> = Box::new(5);
println!("{}", boxed); // 5 — Deref 덕분에 값처럼 쓰인다
// boxed가 스코프를 벗어나면 힙 메모리도 자동 해제된다
}
정수 하나를 힙에 두는 것 자체는 실익이 거의 없습니다. Box 가 빛나는 상황은 따로 있습니다.
첫째, 재귀 타입입니다. Rust는 타입의 크기를 컴파일 타임에 알아야 하는데, 자기 자신을 직접 포함하는 타입은 크기가 무한대가 되어 버립니다. 다음 코드는 컴파일되지 않습니다.
// 컴파일 에러: recursive type has infinite size
enum List {
Cons(i32, List),
Nil,
}
List 안에 다시 List 가 통째로 들어가니 크기를 확정할 수 없습니다. 여기서 Box 를 끼우면, 안쪽 값은 힙에 있고 스택에는 포인터(크기가 고정된 값)만 남으므로 크기가 확정됩니다.
enum List {
Cons(i32, Box<List>),
Nil,
}
use List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
// 이제 크기가 확정되어 컴파일된다
}
둘째, 큰 값을 옮길 때입니다. 큰 구조체를 이동하면 스택 위에서 바이트를 통째로 복사해야 하지만, Box 로 감싸면 힙에 두고 포인터만 옮기면 됩니다.
셋째, 트레이트 객체입니다. 서로 다른 구체 타입을 하나의 트레이트로 다루려면 크기를 알 수 없으므로 Box<dyn Trait> 형태로 힙에 두고 포인터로 다룹니다. 이 이야기는 Deref 절에서 다시 이어집니다.
Rc — 소유권을 나눠 갖기
Box 는 소유자가 하나라는 전제 위에 있습니다. 그런데 하나의 데이터를 여러 곳에서 함께 소유해야 할 때가 있습니다. 그래프의 한 노드를 여러 노드가 가리키거나, 여러 자료구조가 같은 설정 값을 공유하는 경우처럼요. 이때 쓰는 것이 Rc<T>, 참조 카운팅(reference counting) 포인터입니다.
Rc 는 값과 함께 "지금 이 값을 몇 명이 소유하고 있는가"를 세는 카운터를 둡니다. Rc::clone 을 호출하면 데이터를 복제하는 게 아니라 카운터만 1 늘리고, 소유자 하나가 스코프를 벗어나면 카운터가 1 줄어듭니다. 카운터가 0이 되는 순간 값이 실제로 해제됩니다.
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("shared config"));
println!("count = {}", Rc::strong_count(&a)); // 1
let b = Rc::clone(&a); // 데이터 복사가 아니라 카운트만 +1
println!("count = {}", Rc::strong_count(&a)); // 2
{
let c = Rc::clone(&a);
println!("count = {}", Rc::strong_count(&a)); // 3
} // 여기서 c가 사라지며 카운트 -1
println!("count = {}", Rc::strong_count(&a)); // 2
}
Rc::clone(&a) 는 관례적으로 a.clone() 대신 씁니다. 값 전체를 깊게 복사하는 일반적인 clone 과 구분하기 위해서입니다. 이 호출은 값을 그대로 두고 카운터만 건드리므로 비용이 매우 저렴합니다.
한 가지 중요한 제약이 있습니다. Rc 는 단일 스레드 전용입니다. 카운터를 원자적(atomic)으로 갱신하지 않기 때문에, 여러 스레드가 동시에 카운트를 건드리면 값이 깨질 수 있습니다. 그래서 Rc 는 Send 도 Sync 도 아니며, 스레드 경계를 넘기려 하면 컴파일러가 막아 줍니다. 여러 스레드에서 공유하려면 다음의 Arc 를 써야 합니다.
Arc — 스레드를 넘나드는 공유 소유
Arc<T> 는 Atomic Reference Counted의 줄임말로, Rc 와 API가 사실상 같지만 카운터를 원자적 연산으로 갱신합니다. 덕분에 여러 스레드에서 안전하게 공유할 수 있습니다.
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data = Arc::clone(&data); // 각 스레드에 넘길 소유권 하나씩
let handle = thread::spawn(move || {
let sum: i32 = data.iter().sum();
println!("thread {i}: sum = {sum}");
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
그렇다면 왜 항상 Arc 를 쓰지 않을까요? 원자적 연산은 일반 정수 연산보다 비쌉니다. 단일 스레드에서만 공유한다면 그 비용을 낼 이유가 없으므로 Rc 가 더 빠릅니다. 필요할 때만 Arc, 나머지는 Rc 가 원칙입니다. 컴파일러가 스레드 안전성을 강제하므로, 필요한데 Rc 를 쓰면 어차피 컴파일이 되지 않아 실수할 여지도 적습니다.
한편 Rc 든 Arc 든 공유하는 값은 기본적으로 불변입니다. 여러 소유자가 있는데 그중 하나가 값을 바꾸면 빌림 규칙(공유 참조가 있는 동안 변경 불가)이 깨지기 때문입니다. 그런데 실제로는 공유하면서도 값을 바꾸고 싶을 때가 많습니다. 여기서 내부 가변성이 등장합니다.
RefCell과 내부 가변성 — 규칙을 런타임으로 옮기기
Rust의 빌림 규칙은 보통 컴파일 타임에 검사됩니다. "불변 참조는 여럿 또는 가변 참조는 하나, 둘을 동시에는 안 됨." 그런데 RefCell<T> 는 이 검사를 런타임으로 미룹니다. 이것이 **내부 가변성(interior mutability)**입니다. 겉보기에는 불변 참조만 가지고 있어도, 내부의 값을 바꿀 수 있게 해 주는 패턴입니다.
RefCell 은 borrow() 로 불변 참조를, borrow_mut() 으로 가변 참조를 빌려 줍니다. 규칙 위반을 컴파일러가 아니라 런타임에 검사하며, 위반하면 컴파일 에러 대신 **패닉(panic)**이 납니다.
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(5);
{
let mut m = cell.borrow_mut(); // 가변 대여
*m += 10;
} // m이 여기서 반납된다
println!("{}", cell.borrow()); // 15
// 규칙 위반의 예 — 실행 중 패닉이 난다
let _a = cell.borrow_mut();
let _b = cell.borrow_mut(); // panic: already borrowed
}
컴파일러가 잡아 주던 걸 왜 굳이 런타임으로 미룰까요? 컴파일 타임 검사는 안전하지만 보수적이라, 실제로는 안전한데도 거부하는 코드가 있습니다. RefCell 은 "내가 규칙을 지킨다는 걸 알지만 컴파일러를 설득하기 어려운" 상황에서 탈출구가 됩니다. 다만 그 대가로 안전성 검사가 런타임으로 옮겨가고, 규칙을 어기면 패닉으로 드러납니다.
RefCell 의 진가는 Rc 와 조합할 때 나옵니다. Rc<RefCell<T>> 는 "여러 소유자가 공유하면서 값을 바꿀 수도 있는" 흔한 패턴입니다.
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let clone = Rc::clone(&shared);
clone.borrow_mut().push(4); // 공유된 값을 변경
println!("{:?}", shared.borrow()); // [1, 2, 3, 4]
}
주의할 점 하나. RefCell 역시 단일 스레드 전용입니다. 여러 스레드에서 내부 가변성이 필요하면 다음의 Mutex 로 넘어가야 합니다.
Mutex — 스레드 안전한 내부 가변성
여러 스레드에서 공유하는 값을 바꾸려면 Mutex<T>(뮤텍스, mutual exclusion)를 씁니다. Mutex 는 한 번에 한 스레드만 값에 접근하도록 **잠금(lock)**으로 보호합니다. lock() 을 호출하면 잠금을 얻을 때까지 기다리고, 얻으면 값에 접근할 수 있는 가드를 돌려줍니다. 가드가 스코프를 벗어나면 잠금이 자동으로 풀립니다.
Rc<RefCell<T>> 의 스레드 안전 버전은 Arc<Mutex<T>> 입니다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // 잠금 획득
*num += 1;
}); // 가드가 사라지며 잠금 해제
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("result = {}", *counter.lock().unwrap()); // 10
}
Arc 로 여러 스레드에 소유권을 나눠 주고, Mutex 로 값 변경을 직렬화합니다. 이 조합이 Rust에서 스레드 간 공유 가변 상태를 다루는 표준 관용구입니다.
읽기가 압도적으로 많은 상황이라면 RwLock<T> 도 있습니다. 읽기 잠금은 여러 스레드가 동시에 가질 수 있고, 쓰기 잠금만 배타적입니다. 마지막으로, 뮤텍스에는 교착 상태(deadlock) 라는 함정이 있습니다. 두 스레드가 서로가 쥔 잠금을 기다리면 영원히 멈춥니다. 잠금 획득 순서를 일관되게 유지하는 것이 기본 방어책입니다.
Deref 강제 변환 — 스마트 포인터를 값처럼
앞선 예제에서 Box<i32> 를 println! 에 그냥 넘겨도 정수처럼 출력됐습니다. 이것을 가능하게 하는 것이 Deref 트레이트와 **Deref 강제 변환(deref coercion)**입니다.
Deref 를 구현한 타입은 * 역참조 연산자로 안쪽 값에 접근할 수 있습니다. 게다가 컴파일러는 필요할 때 이 변환을 자동으로 끼워 넣습니다. 예를 들어 &Box<String> 을 &str 을 받는 함수에 넘기면, 컴파일러가 Box<String> → String → str 순서로 알아서 역참조를 연쇄 적용합니다.
fn greet(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let boxed = Box::new(String::from("Rust"));
greet(&boxed); // &Box<String>가 &str로 자동 변환된다
}
이 자동 변환 덕분에 스마트 포인터를 마치 안쪽 값인 것처럼 자연스럽게 쓸 수 있습니다. 그래서 앞서 언급한 트레이트 객체 Box<dyn Trait> 나 Vec<Box<dyn Trait>> 도 별다른 문법 부담 없이 다뤄집니다. 역참조 한 번으로 트레이트의 메서드를 바로 호출할 수 있기 때문입니다.
Rc 순환 참조와 Weak — 메모리 누수 막기
Rust는 메모리 안전성으로 유명하지만, Rc 를 잘못 쓰면 메모리 누수가 생길 수 있습니다. 두 Rc 가 서로를 가리키는 순환 참조를 만들면, 두 값의 참조 카운트가 서로 때문에 영원히 0으로 내려가지 못합니다. 그러면 둘 다 해제되지 않고 메모리에 남습니다.
부모-자식 트리를 생각해 봅시다. 부모는 자식을 소유(Rc)하고, 자식도 부모를 알아야 한다고 해서 부모를 Rc 로 가리키면 순환이 생깁니다. 부모→자식, 자식→부모 양방향으로 강한 참조가 걸려 카운트가 절대 0이 되지 않습니다.
해결책은 Weak<T>, 약한 참조입니다. Rc::downgrade 로 만드는 Weak 는 값을 가리키긴 하지만 소유권을 주장하지 않습니다. 즉 강한 참조 카운트(strong_count)를 늘리지 않으므로, 순환을 끊습니다. 소유는 한 방향(부모→자식)만 강하게, 반대 방향(자식→부모)은 약하게 거는 것이 원칙입니다.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
value: i32,
parent: RefCell<Weak<Node>>, // 부모는 약하게 (소유 안 함)
children: RefCell<Vec<Rc<Node>>>, // 자식은 강하게 (소유함)
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
// 자식이 부모를 약하게 가리킨다 — 순환을 만들지 않는다
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
// 약한 참조는 upgrade로 꺼내 쓴다 (사라졌으면 None)
if let Some(parent) = leaf.parent.borrow().upgrade() {
println!("leaf's parent value = {}", parent.value); // 5
}
}
Weak 가 가리키는 값은 이미 해제됐을 수 있으므로, 값에 접근하려면 upgrade() 로 Option<Rc<T>> 를 받아 살아 있는지 확인해야 합니다. 살아 있으면 Some, 이미 사라졌으면 None 입니다. 이 한 겹의 확인이, 순환 없이 양방향 참조를 안전하게 다루는 핵심입니다.
무엇을 언제 쓸까 — 정리
지금까지 본 스마트 포인터를 고르는 기준을 압축하면 이렇습니다.
Box<T>: 값을 힙에 하나의 소유자로 두고 싶을 때. 재귀 타입, 큰 값 이동, 트레이트 객체.Rc<T>: 단일 스레드에서 하나의 값을 여러 소유자가 공유할 때. 읽기 전용 공유.Arc<T>: 여러 스레드에서 공유 소유가 필요할 때.Rc의 스레드 안전 버전.RefCell<T>: 단일 스레드에서 불변 참조 뒤로 값을 바꾸고 싶을 때(내부 가변성). 흔히Rc<RefCell<T>>.Mutex<T>/RwLock<T>: 여러 스레드에서 공유 가변 상태가 필요할 때. 흔히Arc<Mutex<T>>.Weak<T>:Rc/Arc의 순환 참조를 끊어 누수를 막을 때. 소유하지 않는 역방향 참조.
핵심 감각은 세 축입니다. 소유자가 하나인가 여럿인가(Box vs Rc/Arc), 단일 스레드인가 다중 스레드인가(Rc/RefCell vs Arc/Mutex), 그리고 불변인가 내부 가변이 필요한가(그냥 Rc vs Rc/RefCell). 이 세 질문에 답하면 대개 정답이 하나로 좁혀집니다.
마치며
스마트 포인터는 Rust의 소유권 규칙을 우회하는 뒷문이 아닙니다. 오히려 그 규칙을 더 넓은 상황으로 확장하는 도구입니다. Box 는 크기와 위치의 제약을, Rc 와 Arc 는 단일 소유의 제약을, RefCell 과 Mutex 는 불변 공유의 제약을 각각 안전하게 풀어 줍니다. 그리고 그 안전을 위해 Weak 라는 안전장치까지 갖췄습니다.
처음에는 Rc<RefCell<T>> 같은 중첩이 낯설게 느껴지지만, 각 층이 어떤 문제를 푸는지 알고 나면 오히려 의도가 또렷하게 읽힙니다. 필요한 최소한의 도구만 골라 쓰는 절제가, Rust에서 안전하면서도 유연한 자료구조를 만드는 길입니다.
참고 자료
- The Rust Programming Language — Smart Pointers 장: https://doc.rust-lang.org/book/ch15-00-smart-pointers.html
- Rust 표준 라이브러리:
std::boxed::Box: https://doc.rust-lang.org/std/boxed/struct.Box.html - Rust 표준 라이브러리:
std::rc::Rc: https://doc.rust-lang.org/std/rc/struct.Rc.html - Rust 표준 라이브러리:
std::sync::Arc: https://doc.rust-lang.org/std/sync/struct.Arc.html - Rust 표준 라이브러리:
std::cell::RefCell: https://doc.rust-lang.org/std/cell/struct.RefCell.html - Rustonomicon — 내부 가변성과 관련 개념: https://doc.rust-lang.org/nomicon/
현재 단락 (1/166)
Rust의 소유권 시스템은 강력하지만, 때로는 너무 엄격하게 느껴집니다. 값 하나에 소유자는 오직 하나, 빌림은 규칙에 따라, 크기는 컴파일 타임에 확정. 이 규칙만으로는 재귀 자...