Rust 심화: 스마트 포인터 Rc와 Arc
안녕하세요! 여러분의 코딩 구원자, 재준봇입니다!
자, 다들 준비되셨나요? 오늘 우리가 함께 정복할 내용은 Rust의 심화 과정 중에서도 많은 분이 머리를 쥐어뜯는 구간인 스마트 포인터, 그중에서도 Rc와 Arc입니다. 사실 여기까지 오셨다면 여러분은 이미 Rust의 지옥 같은 소유권 개념과 어느 정도 싸워 이기신 분들이에요. 하지만 실무에 가면 소유권 하나만으로는 해결 안 되는 상황이 정말 많이 옵니다.
오늘 제가 아주 찰떡같은 비유로, 코딩 초보자분들도 단번에 이해하실 수 있게 다 퍼부어 드릴게요. 따라오세요!
25강: Rust 심화: 스마트 포인터 Rc와 Arc
1. 왜 스마트 포인터가 필요한가요? (소유권의 한계)
Rust를 배우면서 가장 먼저 배우는 게 소유권이죠? “데이터의 주인은 오직 한 명뿐이다!”라는 철칙 말입니다. 그런데 여기서 문제가 생깁니다. 현실 세계에서는 주인 한 명으로는 부족한 상황이 너무 많거든요.
비유를 들어볼게요. 여러분이 아주 비싸고 귀한 한정판 피규어를 샀다고 칩시다. Rust의 기본 소유권 규칙대로라면, 이 피규어를 친구에게 주는 순간 여러분은 더 이상 피규어를 가질 수 없어요. 소유권이 이전되었으니까요. 그렇다고 빌려주자니(참조), 빌려준 친구가 피규어를 가지고 있는 동안에는 여러분이 피규어를 건드릴 수도 없습니다.
그런데 만약, 이 피규어를 여러 명의 친구가 동시에 공동 소유하고 싶다면 어떻게 해야 할까요? “이 피규어의 주인은 나고, 너고, 그리고 쟤야!”라고 말하고 싶은 상황 말이죠.
이럴 때 등장하는 구원투수가 바로 스마트 포인터입니다. 특히 오늘 배울 Rc와 Arc는 참조 횟수 계산(Reference Counting)이라는 기법을 통해 “공동 소유”를 가능하게 해줍니다.
2. Rc: Reference Counted (단일 스레드용 공동 소유)
먼저 Rc입니다. Rc는 Reference Counted의 약자예요. 쉽게 말해 “이 데이터를 지금 몇 명이 쓰고 있는지 숫자를 세는 장치”라고 생각하면 됩니다.
Rc의 작동 원리
Rc는 힙(Heap) 메모리에 데이터를 저장하고, 그 옆에 작은 메모리 공간을 더 만들어서 “현재 이 데이터를 가리키는 포인터가 몇 개인가”를 기록합니다.
- 새로운 사람이 소유권을 가질 때마다 숫자가 1 증가합니다.
- 누군가 소유권을 포기하면 숫자가 1 감소합니다.
- 숫자가 0이 되는 순간? “아, 이제 아무도 안 쓰는구나!” 하고 메모리에서 깔끔하게 지워버립니다.
진짜 신기하죠? 우리가 일일이 메모리를 해제할 필요 없이, Rust가 알아서 숫자를 세어 처리해 주는 겁니다.
구현 방법 1: 기본적인 Rc 사용법 (공동 소유의 시작)
가장 단순한 형태의 Rc 사용법입니다. Rc::new로 만들고 Rc::clone으로 복제합니다.
use std::rc::Rc;
fn main() {
// 1. Rc::new를 통해 힙에 데이터를 올리고 참조 카운트를 시작합니다.
// 이제 'my_string'은 문자열의 직접적인 주인이 아니라, Rc라는 관리자를 통해 접근합니다.
let my_string = Rc::new(String::from("재준봇의 비밀 강의"));
// 2. Rc::clone을 사용해 소유권을 공유합니다.
// 주의! 이건 데이터 자체를 복사하는 게 아니라 '참조 카운트'만 1 올리는 겁니다.
let a = Rc::clone(&my_string);
let b = Rc::clone(&my_string);
// 3. 이제 my_string, a, b 세 명 모두가 동일한 데이터를 가리키고 있습니다.
println!("원본: {}, 복사본1: {}, 복사본2: {}", my_string, a, b);
// 4. 현재 참조 카운트가 몇인지 확인해볼까요?
// 총 3명이 공유하고 있으므로 값은 3이 나옵니다.
println!("현재 공유 인원: {}", Rc::strong_count(&my_string));
}
코드 뜯어보기:
Rc::new(String::from(...)): 데이터를 힙에 저장하고 카운트를 1로 설정합니다.Rc::clone(&my_string): 여기서 가장 중요한 점!.clone()을 호출하지만, 문자열 전체를 복제하는 무거운 작업이 아니라 단순히 “나도 이거 쓸게!”라고 카운트만 올리는 가벼운 작업입니다.Rc::strong_count: 현재 얼마나 많은 포인터가 이 데이터를 공유하고 있는지 숫자로 보여줍니다.
구현 방법 2: Rc와 RefCell의 조합 (불변성을 깨부수기)
여기서 문제가 하나 터집니다. Rc로 공유한 데이터는 기본적으로 읽기 전용(Immutable)입니다. 왜냐고요? 여러 명이 동시에 데이터를 수정하면 데이터가 엉망이 될 수 있기 때문에 Rust가 안전하게 막아둔 겁니다.
그런데 “난 공유도 하고 싶고, 수정도 하고 싶어!”라는 욕심이 생기겠죠? 이때 사용하는 치트키가 바로 RefCell입니다. 이를 통해 ‘내부 가변성’을 구현할 수 있습니다.
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
// 1. Rc 안에 RefCell을 넣습니다.
// 이렇게 하면 "공동 소유" + "수정 가능"이라는 강력한 조합이 완성됩니다.
let shared_data = Rc::new(RefCell::new(String::from("초보자 단계")));
let a = Rc::clone(&shared_data);
let b = Rc::clone(&shared_data);
// 2. .borrow_mut()를 통해 일시적으로 쓰기 권한을 얻습니다.
// RefCell이 런타임에 "지금 누가 쓰고 있나?"를 체크해서 안전하게 수정하게 해줍니다.
{
let mut data_a = a.borrow_mut();
data_a.push_str(" -> 중급자 단계");
}
{
let mut data_b = b.borrow_mut();
data_b.push_str(" -> 전문가 단계");
}
// 3. 결과 확인: a가 수정하든 b가 수정하든, 결국 같은 데이터를 가리키므로 모두 반영됩니다.
println!("최종 데이터: {}", shared_data.borrow());
}
코드 뜯어보기:
Rc::new(RefCell::new(...)): 이 구조는 Rust 실무에서 정말 많이 쓰이는 패턴입니다.Rc는 공유를 담당하고,RefCell은 수정을 담당합니다.borrow_mut(): 컴파일 타임이 아니라 실행 시간(Runtime)에 빌림 규칙을 검사합니다. 만약 이미 누군가 수정 중인데 또 수정하려고 하면 프로그램이 종료(panic)됩니다.borrow(): 읽기 전용 권한을 얻습니다.
구현 방법 3: 일반 참조(&T) vs Rc (언제 뭘 써야 하나요?)
초보자분들이 가장 많이 묻는 질문입니다. “그냥 & 쓰면 되는 거 아닌가요?”
// 상황 A: 일반 참조 (&T) - 빌려주는 개념
fn use_reference(s: &String) {
println!("빌려온 데이터: {}", s);
}
// 상황 B: Rc<T> - 공동 소유 개념
fn use_rc(s: Rc<String>) {
println!("공동 소유 데이터: {}", s);
}
fn main() {
let my_text = String::from("Rust는 재밌어!");
// 일반 참조는 '빌려주는' 것이므로, 빌려준 기간 동안 원본이 살아있어야 합니다.
use_reference(&my_text);
// Rc는 '소유권을 나누는' 것이므로, 원본이 사라져도 Rc 포인터가 남아있다면 데이터는 유지됩니다.
let shared_text = Rc::new(my_text);
use_rc(Rc::clone(&shared_text));
}
결정적인 차이 설명:
- 일반 참조(
&T): “잠깐만 빌려줘!”입니다. 빌려준 사람이 “이제 돌려줘!” 하거나, 빌려준 사람이 사라지면 더 이상 쓸 수 없습니다. 라이프타임(Lifetime) 문제가 여기서 발생하죠. Rc<T>: “우리 같이 가지자!”입니다. 원본을 만든 사람이 사라져도, 다른 공유자들이 남아있다면 데이터는 메모리에서 죽지 않고 살아남습니다. 즉, 데이터의 생명주기를 예측할 수 없을 때 사용합니다.
초보자 폭풍 질문! Q: 재준봇님!
Rc::clone()을 계속 호출하면 메모리가 꽉 차서 컴퓨터 터지는 거 아니에요?A: 하하, 걱정 마세요!
Rc::clone()은 데이터 전체를 복사하는 게 아니라, 그냥 “숫자 1 올리는 것”뿐입니다. 1GB짜리 데이터를 1,000번clone해도 실제 메모리 사용량은 1GB + 숫자 카운트 몇 개분밖에 안 됩니다. 아주 가볍답니다!
3. Arc: Atomic Reference Counted (멀티 스레드용 공동 소유)
자, 이제 끝판왕 Arc가 등장합니다. Arc는 Atomic Reference Counted의 약자입니다.
Arc가 필요한 이유
앞서 배운 Rc는 아주 훌륭하지만, 치명적인 약점이 있습니다. 바로 “스레드 안전하지 않다”는 점입니다.
만약 여러 스레드가 동시에 Rc의 카운트를 올리려고 하면 어떻게 될까요?
- 스레드 1: “지금 카운트 5네? 6으로 올려야지.”
- 스레드 2: “나도 지금 5로 보이는데? 6으로 올려야지.” 동시에 작업하다 보면 카운트가 7이 되어야 하는데 6이 되는 ‘데이터 경합(Data Race)’이 발생합니다. 이건 메모리 오염으로 이어지는 아주 위험한 상황이죠.
그래서 등장한 것이 Arc입니다. Arc의 A는 Atomic(원자적)의 약자입니다. CPU 레벨에서 “한 번에 하나만 수정해!”라고 보장하는 연산을 사용하여, 여러 스레드에서도 안전하게 카운트를 관리합니다.
구현 방법 1: 기본적인 Arc 사용법 (스레드 간 데이터 공유)
use std::sync::Arc;
use std::thread;
fn main() {
// 1. Rc 대신 Arc를 사용합니다.
// Arc는 멀티 스레드 환경에서도 안전하게 참조 카운트를 관리합니다.
let data = Arc::new(String::from("멀티스레드 안전 지대"));
let mut handles = vec![];
for i in 0..3 {
// 2. 각 스레드로 데이터를 보내기 위해 Arc를 복제합니다.
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
// 3. 이제 각 스레드가 안전하게 동일한 데이터를 읽을 수 있습니다.
println!("스레드 {}에서 읽은 데이터: {}", i, data_clone);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
코드 뜯어보기:
Arc::new(...):Rc와 똑같이 동작하지만, 내부적으로 원자적 연산을 사용하여 스레드 안전성을 확보합니다.move ||: 스레드로data_clone이라는 소유권을 완전히 넘겨주기 위해move키워드를 사용합니다.Arc::clone(&data): 스레드마다 각각의 포인터를 만들어 전달합니다.
구현 방법 2: Arc와 Mutex의 조합 (멀티 스레드에서 수정하기)
Rc 때와 마찬가지로 Arc만으로는 데이터를 수정할 수 없습니다. 멀티 스레드에서 안전하게 데이터를 수정하려면 Mutex(Mutual Exclusion, 상호 배제)라는 자물쇠가 필요합니다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// 1. Arc 안에 Mutex를 넣습니다.
// Arc = 공동 소유 / Mutex = 한 번에 한 명만 수정 가능(자물쇠)
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
// 2. lock()을 호출해 자물쇠를 얻습니다.
// 다른 스레드가 이미 lock을 가졌다면, 풀릴 때까지 여기서 기다립니다.
let mut num = counter_clone.lock().unwrap();
*num += 1;
// 3. 여기서 num(MutexGuard)이 범위를 벗어나면 자동으로 자물쇠가 풀립니다.
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("최종 결과: {}", *counter.lock().unwrap());
}
코드 뜯어보기:
Arc<Mutex<T>>: 이것이 Rust 멀티스레딩의 정석 패턴입니다. “안전하게 공유하고, 안전하게 수정한다”는 뜻이죠.lock().unwrap(): 자물쇠를 획득하려는 시도입니다. 만약 다른 스레드에서 패닉이 발생해 자물쇠가 오염되었다면unwrap에서 에러가 납니다.*num += 1:MutexGuard라는 스마트 포인터를 통해 내부 값에 접근하여 수정합니다.
구현 방법 3: Rc vs Arc 성능 비교 및 선택 기준
마지막으로 어떤 상황에 어떤 것을 써야 하는지 명확하게 정리해 드릴게요.
// [상황 1] 싱글 스레드 프로그램 + 데이터 공유 필요
// -> Rc<T> 사용 (가장 빠름)
// [상황 2] 멀티 스레드 프로그램 + 데이터 읽기만 가능
// -> Arc<T> 사용 (스레드 안전)
// [상황 3] 멀티 스레드 프로그램 + 데이터 수정 가능
// -> Arc<Mutex<T>> 또는 Arc<RwLock<T>> 사용 (가장 안전하지만 가장 무거움)
상세 비교:
- 속도:
Rc>Arc>Arc<Mutex>.Arc는 원자적 연산을 수행하므로Rc보다 약간 더 느립니다. 하지만 싱글 스레드에서Arc를 쓰는 건 닭 잡는 데 소 잡는 칼 쓰는 격이니, 싱글 스레드라면 무조건Rc를 쓰세요. - 안전성:
Arc는 스레드 간의 데이터 경합을 원천 봉쇄합니다. - 복잡도:
Arc<Mutex>는 자물쇠를 관리해야 하므로 코드가 조금 더 복잡해지고, 데드락(Deadlock)이라는 위험 요소가 생깁니다.
실무주의보! 경고: 순환 참조(Circular Reference)를 조심하세요!
내용: A가 B를 가리키고, B가 다시 A를 가리키는 상황이 발생하면 어떻게 될까요? 참조 카운트가 절대 0이 되지 않습니다. 즉, 프로그램이 종료될 때까지 메모리에서 사라지지 않는 ‘메모리 누수’가 발생합니다.
해결책: 이럴 때는 모든 포인터를
Rc나Arc로 만들지 말고, 일부는Weak포인터(Rc::downgrade또는Arc::downgrade)로 만드세요.Weak포인터는 소유권을 가지지 않고 단순히 “참조”만 하기 때문에 카운트를 올리지 않습니다. 덕분에 순환 고리를 끊어낼 수 있습니다.
마무리하며
오늘 우리는 Rust의 고급 기능인 Rc와 Arc에 대해 깊게 파헤쳐 보았습니다.
Rc: “우리 단일 스레드에서 사이좋게 나눠 갖자!” (가벼움, 단일 스레드용)Arc: “여러 스레드가 접속해도 안전하게 나눠 갖자!” (무거움, 멀티 스레드용)RefCell/Mutex: “나눠 갖는 건 좋은데, 수정은 규칙 있게 하자!” (가변성 부여)
처음에는 이 개념들이 복잡하게 느껴지겠지만, 계속 코드를 짜다 보면 “아, 이래서 Arc가 필요했구나!”라고 무릎을 탁 치는 순간이 올 겁니다. Rust가 이렇게 까다롭게 구는 이유는 오직 여러분의 프로그램이 런타임에 터지지 않고 완벽하게 돌아가게 만들기 위해서라는 점, 잊지 마세요!
오늘 강의가 도움이 되셨다면, 직접 코드를 타이핑하며 테스트해 보세요. 오류가 나면 더 좋습니다. 그 오류를 잡는 과정이 진짜 공부니까요!
지금까지 여러분의 친절한 가이드, 재준봇이었습니다! 다음 강의에서 만나요!
<hr>