Box, Rc, Arc 차이: Rust 스마트 포인터, 언제 무엇을 쓰나
기초 강좌를 마치며 “스마트 포인터는 The Book으로"라고 미뤄 둔 공백을 이 글에서 회수합니다. 소유권 규칙(소유자는 하나, 스코프 끝에서 해제)은 대부분의 코드에 충분하지만, 가끔 그 규칙만으로는 표현이 안 되는 구조가 나옵니다. 소유자가 여럿이어야 하거나, 크기를 컴파일 타임에 알 수 없거나, 불변 참조 너머로 수정이 필요한 경우입니다. 표준 라이브러리의 스마트 포인터들이 그 탈출구이고, 종류는 사실상 넷이면 정리됩니다.
Box: 값을 힙에 둔다
#
Box는 가장 단순한 스마트 포인터로, 값을 힙에 두고 그 소유권을 갖는 포인터입니다. 소유자는 여전히 하나이므로 소유권 규칙은 그대로이고, 바뀌는 것은 값의 위치뿐입니다. 필요한 상황은 셋입니다.
// 1. 재귀 타입: 크기를 확정하기 위해
enum Tree {
Leaf(i32),
Node(Box<Tree>, Box<Tree>), // Box 없이는 크기 무한 → 컴파일 에러 E0072
}
// 2. dyn 트레이트: 크기가 다른 타입들을 한 타입으로
let handlers: Vec<Box<dyn Fn(&str)>> = vec![/* ... */];
// 3. 큰 값의 이동: 복사 대신 포인터만 이동
let big = Box::new([0u8; 1_000_000]);재귀 타입은 “자기 자신을 담는 구조체의 크기는 무한"이라는 문제를 포인터 크기로 고정해 풀고, Box<dyn Trait>는 기초 8편에서 본 동적 디스패치의 그 형태입니다. 셋 다 공통점은 소유자는 하나 그대로라는 것입니다. 공유가 필요하면 다음으로 넘어갑니다.
Rc: 소유자를 여럿으로 (단일 스레드)
#
use std::rc::Rc;
let shared = Rc::new(String::from("공유 설정"));
let a = Rc::clone(&shared); // 데이터 복사가 아니라 카운트 +1
let b = Rc::clone(&shared);
println!("소유자 수: {}", Rc::strong_count(&shared)); // 3
Rc(reference counting)는 “이 값을 참조하는 소유자 수"를 세고, 카운트가 0이 되는 순간 해제합니다. 소유자가 하나라는 규칙의 공식적인 예외이고, 그래프·트리에서 한 노드를 여러 부모가 가리키는 구조처럼 누가 마지막까지 쓸지 컴파일 타임에 정할 수 없는 공유가 용도입니다. Rc::clone은 데이터를 복사하지 않고 카운트만 올리므로 값싼 연산입니다. 두 가지 한계를 기억해야 합니다. 첫째, 얻는 것은 공유된 불변 접근입니다(수정은 아래 RefCell과의 조합으로). 둘째, 서로가 서로를 가리키는 순환 참조는 카운트가 0이 되지 못해 메모리가 해제되지 않습니다. 순환이 생기는 설계(자식→부모 역참조 등)에서는 카운트를 세지 않는 Weak를 씁니다.
Arc: 스레드를 건너는 Rc
#
Rc의 카운트 증감은 원자적이지 않아서, 컴파일러가 스레드 간 이동 자체를 거부합니다. 멀티스레드 공유에는 카운트를 원자 연산으로 처리하는 Arc(atomic Rc)를 씁니다.
use std::sync::Arc;
use std::thread;
let config = Arc::new(String::from("공유 설정"));
let for_worker = Arc::clone(&config);
thread::spawn(move || println!("{for_worker}"));원자 연산 비용이 있으므로 단일 스레드에서는 Rc, 스레드를 건너면 Arc라는 구분이 그대로 선택 기준입니다. 잘못 골라도 컴파일러가 “Rc는 스레드 간 이동 불가"라고 막아 주므로, 외울 필요보다 이해할 필요가 있는 구분입니다.
RefCell: 빌림 검사를 런타임으로 미룬다
#
Rc가 준 것은 공유 불변 접근이었습니다. 공유하면서 수정까지 필요하면, 빌림 규칙의 검사를 컴파일 타임에서 런타임으로 옮기는 RefCell이 등장합니다.
use std::cell::RefCell;
let counter = RefCell::new(0);
*counter.borrow_mut() += 1; // 런타임에 가변 빌림 검사
let now = counter.borrow(); // 런타임에 불변 빌림 검사
규칙 자체(가변 하나 또는 불변 여럿)는 같고, 어기면 컴파일 에러 대신 런타임 패닉이 됩니다. 컴파일러가 증명하지 못하는 패턴을 개발자가 책임지고 여는 문이므로, “우선 RefCell로 덮자"가 아니라 설계로 풀 수 없을 때의 최후 수단으로 두는 것이 건강한 순서입니다.
실무 관용구: 조합의 두 형태 #
넷을 조합하면 실무에서 자주 보는 두 형태가 나옵니다.
Rc<RefCell<T>>— 단일 스레드에서 “여럿이 소유하고 수정도 하는” 상태. GUI 위젯 트리, 인터프리터의 환경 객체 같은 구조의 정석입니다.Arc<Mutex<T>>— 멀티스레드 판.RefCell의 자리를 락인Mutex가 대신합니다. 다만 실전 5편에서 봤듯, 병렬 집계처럼 나눠서 합칠 수 있는 문제에 이 형태를 쓰면 락 경합으로 느려집니다. 구조로 공유를 없앨 수 있으면 그쪽이 먼저,Arc<Mutex<T>>는 정말 공유 상태가 본질일 때만이 순서입니다.
선택 순서도 #
- 소유자가 하나로 충분한가? → 그냥 값. 힙 배치·재귀·dyn이 필요할 때만
Box. - 소유자가 여럿 필요한가? → 단일 스레드
Rc, 멀티스레드Arc. - 공유하면서 수정도 필요한가? →
Rc<RefCell<T>>/Arc<Mutex<T>>. 단, 구조 변경으로 공유를 없앨 수 있는지 먼저 검토.
정리 #
Box는 위치만 힙으로 옮긴 단독 소유입니다. 재귀 타입, dyn 트레이트, 큰 값 이동의 세 상황이 용도입니다.Rc는 참조 카운트로 소유자를 여럿 만드는 단일 스레드 도구입니다. 공유는 불변이고, 순환 참조는Weak로 끊습니다.Arc는 카운트를 원자 연산으로 바꾼 멀티스레드 판입니다. 잘못 골라도 컴파일러가 경계를 지켜 줍니다.RefCell은 빌림 검사를 런타임 패닉으로 미루는 최후 수단입니다. 설계로 풀 수 없을 때만 엽니다.- 조합 관용구는
Rc<RefCell<T>>(단일 스레드)와Arc<Mutex<T>>(멀티스레드)입니다. 다만 공유 자체를 없애는 구조(fold·reduce)가 가능하면 그쪽이 먼저입니다.