러스트의 스마트 포인터: Box, Rc, Arc

1. 개요

기본적으로 Rust의 모든 값은 하나의 소유자만 가지며, Rust는 컴파일할 때 값의 크기를 알아야 합니다. 많은 실제 상황에서는 이 두 조건을 만족하지 않습니다: 재귀적 데이터 타입, 여러 타입을 포함하는 리스트, 또는 여러 소유자가 필요한 데이터.

이 글에서는 가장 흔한 세 가지 스마트 포인터인 Box<T>, Rc<T>, Arc<T>를 설명합니다. 이 글을 읽고 나면, 각 상황에 맞는 타입을 선택하는 방법, Weak, RefCell, Mutex와의 결합 방법, 그리고 자주 발생하는 오류에 대해 알게 될 것입니다.

2. 스마트 포인터란?

스마트 포인터는 포인터를 감싸는 struct이며 두 가지 중요한 trait을 구현합니다:

  • Deref: 스마트 포인터를 일반 참조처럼 사용할 수 있게 해줍니다 (*ptr, 메서드 직접 호출).
  • Drop: 스마트 포인터가 scope를 벗어날 때 리소스를 자동으로 해제합니다.

String과 Vec<T>도 실제로는 스마트 포인터입니다. Box, Rc, Arc는 heap의 데이터를 소유하는 방식에 중점을 둡니다.

세 가지 모두 표준 라이브러리에 포함됩니다. Cargo.toml에 추가 dependency를 넣을 필요가 없습니다.

3. Box<T>: 단일 소유자, heap의 데이터

3.1. 기본 할당

fn main() {
    let order_total = Box::new(250_000u64);
    println!("합계: {}", order_total);

    let doubled = *order_total * 2;
    println!("2배: {}", doubled);
}

Box::new는 값 250_000을 heap에 할당합니다. Stack에는 고정 크기의 포인터만 남습니다. Box는 T가 Display를 구현할 때 Display를 구현하므로 println!을 바로 사용할 수 있습니다. 연산자 *는 Deref의 도움으로 내부 값을 가져옵니다. order_total이 main을 벗어날 때 heap 메모리는 자동으로 해제됩니다.

합계: 250000
2배: 500000

위의 예제처럼 u64를 Box에 감싸는 것은 특별한 이점이 없습니다. 이 코드는 단지 문법을 설명하기 위한 것입니다. Box는 다음 두 가지 경우에 정말 유용합니다.

3.2. 재귀적 데이터 타입

주문 처리 프로세스를 일련의 단계로 나타낸다고 가정해봅시다:

#[derive(Debug)]
enum OrderStep {
    Done,
    Next(String, Box<OrderStep>),
}

use OrderStep::{Done, Next};

fn count(step: &OrderStep) -> usize {
    match step {
        Done => 0,
        Next(_, rest) => 1 + count(rest),
    }
}

fn main() {
    let flow = Next(
        "주문".into(),
        Box::new(Next(
            "결제".into(),
            Box::new(Next("배송".into(), Box::new(Done))),
        )),
    );
    println!("단계 수: {}", count(&flow));
}

Box 없이 Next(String, OrderStep)을 작성하면 컴파일러가 오류를 보고합니다:

error[E0072]: recursive type `OrderStep` has infinite size

이유는 하나의 OrderStep이 다른 OrderStep을 포함하고, 그것이 또 다른 것을 포함하므로 크기가 무한해지기 때문입니다. Box<OrderStep>은 항상 포인터와 같은 크기를 가지므로 enum의 크기는 유한해집니다.

함수 count에서 rest의 타입은 &Box<OrderStep>입니다. Rust는 deref coercion 덕분에 이를 자동으로 &OrderStep으로 변환합니다.

단계 수: 3

3.3. Trait 객체

Box<dyn Trait>을 사용하면 같은 컬렉션에 여러 타입을 저장할 수 있습니다:

trait PaymentMethod {
    fn pay(&self, amount: u64) -> String;
}

struct Cash;
struct Card {
    last4: String,
}

impl PaymentMethod for Cash {
    fn pay(&self, amount: u64) -> String {
        format!("현금: {amount}")
    }
}

impl PaymentMethod for Card {
    fn pay(&self, amount: u64) -> String {
        format!("카드 ****{}: {amount}", self.last4)
    }
}

fn main() {
    let methods: Vec<Box<dyn PaymentMethod>> = vec![
        Box::new(Cash),
        Box::new(Card { last4: "4242".into() }),
    ];
    for m in &methods {
        println!("{}", m.pay(100_000));
    }
}

Cash와 Card는 크기가 다르므로 같은 Vec에 직접 넣을 수 없습니다. Box<dyn PaymentMethod>로 감싸면 모든 요소가 같은 크기를 가집니다 (데이터 포인터와 vtable 포인터로 이루어진 fat pointer). pay 호출은 런타임에 동적 디스패치됩니다.

현금: 100000
카드 ****4242: 100000

주의: 모든 요소가 같은 타입이라면 Box<dyn …> 대신 generic (impl PaymentMethod 또는 <T: PaymentMethod>)을 사용하세요. Generic은 정적 디스패치를 사용하므로 heap 할당이 필요 없고 vtable을 통한 호출도 불필요합니다.

4. Rc<T>: 단일 스레드 내 여러 소유자

4.1. 참조 카운팅

여러 Order가 하나의 Customer에 속한다고 가정해봅시다. 각 주문에 고객 정보를 복사하고 싶지 않으며, 어느 주문이 가장 오래 “살아” 있을지 알 수 없어서 소유자를 정할 수 없습니다. 이것이 Rc (Reference Counted)를 사용할 때입니다:

use std::rc::Rc;

struct Customer {
    name: String,
}

struct Order {
    id: u32,
    customer: Rc<Customer>,
}

fn main() {
    let alice = Rc::new(Customer { name: "Alice".into() });
    println!("생성 후: {}", Rc::strong_count(&alice));

    let o1 = Order { id: 1, customer: Rc::clone(&alice) };
    {
        let o2 = Order { id: 2, customer: Rc::clone(&alice) };
        println!("블록 내부: {}", Rc::strong_count(&alice));
        println!("주문 {} (고객: {})", o2.id, o2.customer.name);
    }
    println!("블록 후: {}", Rc::strong_count(&alice));
    println!("주문 {} (고객: {})", o1.id, o1.customer.name);
}

Rc::clone은 Customer를 복사하지 않습니다. 단지 strong count를 1 증가시킵니다. o2가 block의 끝에서 드롭될 때, 카운트는 1 감소합니다. Customer는 카운트가 0이 될 때만 해제됩니다.

생성 후: 1
블록 내부: 3
주문 2 (고객: Alice)
블록 후: 2
주문 1 (고객: Alice)

관례상 alice.clone() 대신 Rc::clone(&alice)로 작성하는 것이 좋습니다. 이렇게 쓰면 독자가 이것이 저비용의 카운트 증가 연산이지 deep copy가 아니라는 것을 명확히 알 수 있습니다.

4.2. Rc<RefCell<T>>로 데이터 변경

Rc<T>는 불변 참조(&T)만 제공합니다. 여러 소유자가 데이터를 수정해야 한다면 RefCell과 결합하세요:

use std::cell::RefCell;
use std::rc::Rc;

struct Cart {
    items: Vec<String>,
}

fn main() {
    let cart = Rc::new(RefCell::new(Cart { items: vec![] }));

    let web = Rc::clone(&cart);
    let mobile = Rc::clone(&cart);

    web.borrow_mut().items.push("셔츠".into());
    mobile.borrow_mut().items.push("삿갓".into());

    println!("{:?}", cart.borrow().items);
}

RefCell은 borrow 규칙 검사를 컴파일 시간에서 런타임으로 옮깁니다. borrow_mut()은 guard를 반환합니다. 이 guard는 문장의 끝에서 즉시 해제되므로 다음 호출은 여전히 유효합니다.

["셔츠", "삿갓"]

주의: 다른 borrow() 또는 borrow_mut()이 여전히 살아 있는 상태에서 borrow_mut()을 호출하면 프로그램이 already borrowed: BorrowMutError로 panic합니다. guard를 가능한 한 짧게 유지하세요. panic 대신 오류를 처리하고 싶다면 try_borrow_mut()을 사용하세요.

4.3. 순환 참조와 Weak<T>

Customer가 Rc<Order>의 리스트를 가지고 있고, 각 Order가 다시 Rc<Customer>를 가지고 있다면, 두 카운트는 절대 0이 되지 않습니다. 결과는 메모리 누수입니다. Rust는 컴파일 시간에 이 오류를 방지할 수 없습니다. 해결책은 한 방향 참조에 Weak을 사용하는 것입니다:

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Customer {
    name: String,
    orders: RefCell<Vec<Rc<Order>>>,
}

struct Order {
    id: u32,
    customer: Weak<Customer>,
}

fn main() {
    let bob = Rc::new(Customer {
        name: "Bob".into(),
        orders: RefCell::new(vec![]),
    });
    let order = Rc::new(Order { id: 7, customer: Rc::downgrade(&bob) });
    bob.orders.borrow_mut().push(Rc::clone(&order));

    if let Some(c) = order.customer.upgrade() {
        println!("주문 {}는 고객 {}", order.id, c.name);
    }
    println!("strong={}, weak={}", Rc::strong_count(&bob), Rc::weak_count(&bob));

    drop(bob);
    println!("고객이 있나요? {}", order.customer.upgrade().is_some());
}

Rc::downgrade는 Weak을 생성합니다. Weak은 weak count만 증가시키고 데이터를 살게 유지하지 않습니다. 사용하려면 upgrade()를 호출하여 Option<Rc<T>>를 얻습니다. 데이터가 이미 해제되었으면 결과는 None입니다. drop(bob) 후 Customer의 strong count는 0이 되므로 Customer는 해제되지만, order의 Weak은 여전히 존재합니다.

주문 7는 고객 Bob
strong=1, weak=1
고객이 있나요? false

일반적인 규칙: “부모 → 자식” 방향에는 Rc, “자식 → 부모” 방향에는 Weak을 사용하세요.

5. Arc<T>: 다중 스레드 간 여러 소유자

5.1. Rc가 스레드에서 작동하지 않는 이유

Rc는 일반 덧셈을 사용하여 카운트를 업데이트하므로 여러 스레드가 접근할 때 안전하지 않습니다. 따라서 Rc는 Send trait을 구현하지 않습니다. Rc를 thread::spawn에 전달하려고 하면 컴파일러가 거부합니다:

error[E0277]: `Rc<Customer>` cannot be sent between threads safely

이것은 Rust의 강점입니다: data race 오류가 컴파일 시간에 차단됩니다. Arc (Atomically Reference Counted)는 Rc와 동일한 API를 가지지만 카운트 업데이트에 atomic 연산을 사용합니다.

5.2. 읽기 전용 데이터 공유

use std::sync::Arc;
use std::thread;

struct ShopConfig {
    currency: String,
    vat_percent: u64,
}

fn main() {
    let config = Arc::new(ShopConfig { currency: "VND".into(), vat_percent: 10 });

    let handles: Vec<_> = [100_000u64, 200_000]
        .into_iter()
        .map(|price| {
            let config = Arc::clone(&config);
            thread::spawn(move || {
                let vat = price * config.vat_percent / 100;
                println!("{} + 세금 = {} {}", price, price + vat, config.currency);
            })
        })
        .collect();

    for h in handles {
        h.join().unwrap();
    }
}

각 스레드는 move를 통해 자신의 Arc 복사본을 받습니다. 모든 복사본은 같은 ShopConfig를 가리킵니다. 데이터는 읽기 전용이므로 잠금이 필요하지 않습니다. 두 줄의 출력 순서는 실행마다 달라질 수 있습니다.

100000 + 세금 = 110000 VND
200000 + 세금 = 220000 VND

5.3. Arc<Mutex<T>>로 데이터 변경

RefCell은 스레드 간에 안전하지 않습니다. 다중 스레드에 해당하는 것은 Mutex (또는 RwLock)입니다:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let revenue = Arc::new(Mutex::new(0u64));
    let mut handles = Vec::new();

    for amount in [100u64, 250, 400] {
        let revenue = Arc::clone(&revenue);
        handles.push(thread::spawn(move || {
            *revenue.lock().unwrap() += amount;
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
    println!("수익: {}", *revenue.lock().unwrap());
}

lock()은 MutexGuard를 반환합니다. guard가 드롭될 때 잠금이 자동으로 해제되며, 이는 문장의 끝입니다. unwrap()은 mutex가 poisoned된 경우를 처리합니다. 즉, 다른 스레드가 잠금을 유지하는 동안 panic했을 때입니다.

수익: 750

주의: 단순한 정수 카운터의 경우, Arc<AtomicU64>가 Arc<Mutex<u64>>보다 빠릅니다. 또한 단일 스레드만 있는 곳에서는 Arc를 사용하지 마세요. atomic 연산은 일반 덧셈보다 비싸지만, 차이는 크지 않습니다.

6. 참조 카운팅 동작에 대한 테스트 작성

Rust에는 내장된 테스트 프레임워크가 있으므로 추가 crate가 필요하지 않습니다. 다음 테스트를 src/lib.rs 또는 src/main.rs의 끝에 배치하세요:

#[cfg(test)]
mod tests {
    use std::rc::{Rc, Weak};
    use std::sync::{Arc, Mutex};
    use std::thread;

    struct Customer {
        name: String,
    }

    #[test]
    fn rc_clone_increases_strong_count_and_drop_decreases_it() {
        let alice = Rc::new(Customer { name: "Alice".into() });
        let copy = Rc::clone(&alice);
        assert_eq!(Rc::strong_count(&alice), 2);
        assert!(Rc::ptr_eq(&alice, &copy));

        drop(copy);
        assert_eq!(Rc::strong_count(&alice), 1);
        assert_eq!(alice.name, "Alice");
    }

    #[test]
    fn weak_upgrade_returns_none_after_owner_dropped() {
        let bob = Rc::new(Customer { name: "Bob".into() });
        let weak: Weak<Customer> = Rc::downgrade(&bob);
        assert!(weak.upgrade().is_some());

        drop(bob);
        assert!(weak.upgrade().is_none());
    }

    #[test]
    fn arc_mutex_sums_correctly_across_threads() {
        let revenue = Arc::new(Mutex::new(0u64));
        let handles: Vec<_> = (1..=100)
            .map(|i| {
                let revenue = Arc::clone(&revenue);
                thread::spawn(move || *revenue.lock().unwrap() += i)
            })
            .collect();
        handles.into_iter().for_each(|h| h.join().unwrap());

        assert_eq!(*revenue.lock().unwrap(), 5050);
        assert_eq!(Arc::strong_count(&revenue), 1);
    }
}

첫 번째 테스트는 카운트를 확인하고 Rc::ptr_eq를 사용하여 두 Rc가 같은 메모리 영역을 가리키는지 확인합니다. 즉, deep copy가 없다는 뜻입니다.

두 번째 테스트는 Weak이 데이터를 살게 유지하지 않음을 확인합니다.

세 번째 테스트는 100개의 스레드를 실행하여 누적되고, 결과는 5050이어야 합니다. 모든 스레드가 join된 후, strong reference는 하나만 남습니다.

테스트 실행:

cargo test
running 3 tests
test tests::rc_clone_increases_strong_count_and_drop_decreases_it ... ok
test tests::weak_upgrade_returns_none_after_owner_dropped ... ok
test tests::arc_mutex_sums_correctly_across_threads ... ok

test result: ok. 3 passed; 0 failed

7. 어떤 타입을 선택할 것인가?

필요 선택
단일 소유자, heap 필요 (재귀적 타입, trait 객체, 대용량 데이터의 저비용 이동) Box<T>
여러 소유자, 단일 스레드, 읽기 전용 Rc<T>
여러 소유자, 단일 스레드, 수정 필요 Rc<RefCell<T>>
여러 소유자, 다중 스레드, 읽기 전용 Arc<T>
여러 소유자, 다중 스레드, 수정 필요 Arc<Mutex<T>> / Arc<RwLock<T>>
역 참조, 캐시, 순환 참조 방지 Weak<T>

일반적인 오류:

  • 스마트 포인터가 필요할 때만 borrow 사용. 함수가 데이터만 읽는다면 Rc<Customer> 대신 &Customer를 받으세요. 함수가 더 유연해지고 카운트를 증가하거나 감소시킬 필요가 없습니다.
  • Rc<RefCell<T>>의 남용. 이 패턴이 코드에 밀집된 것은 Java나 C#의 OOP 사고방식을 가져오고 있다는 신호입니다. ownership을 재정리하거나 Vec의 index를 사용하는 것을 고려하세요 (arena 패턴).
  • 순환 참조. Rc와 Arc 모두 두 객체가 strong reference로 서로를 가리킬 때 메모리 누수가 발생할 수 있습니다. 한 방향에는 Weak을 사용하세요.
  • MutexGuard를 너무 오래 유지. 특히 async 코드에서 .await를 통해 guard를 유지하거나 여러 스레드에서 서로 다른 순서로 여러 mutex를 잠금할 때 위험합니다. 이는 deadlock을 초래합니다.

8. 결론

Box<T>는 단일 소유자로 데이터를 heap에 배치해야 할 때 사용하며, 재귀적 타입과 trait 객체가 대표적입니다. Rc<T>는 단일 스레드 내 여러 소유자를 허용하고, Arc<T>는 atomic 카운트 덕분에 여러 스레드 간에도 같은 역할을 합니다. 스마트 포인터 자체는 읽기만 허용합니다. 데이터를 수정해야 할 때는 RefCell (단일 스레드) 또는 Mutex/RwLock (다중 스레드)과 결합하고, 순환 참조를 피하기 위해 Weak을 사용합니다. 일반적인 borrow로 시작하고, 기본 ownership 규칙이 정말 충분하지 않을 때만 스마트 포인터로 전환하세요.

rust스마트포인터소유권동시성메모리