Rust 기초: Weak로 순환 참조 끊기

Rc로 노드를 만들어 서로 연결하다 보면 이상한 일을 겪게 됩니다. 분명 스코프를 벗어났는데 소멸자가 실행되지 않는 거죠. Rust는 메모리 안전한 언어인데 어떻게 누수가 나느냐고요? 😱
직접 확인해 보겠습니다. 서로를 짝으로 가리키는 노드 둘을 만들고, 해제될 때 메시지를 찍도록 Drop을 구현했습니다.
use std::cell::RefCell;
use std::rc::Rc;
struct Node {
name: String,
partner: RefCell<Option<Rc<Node>>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("{} 해제됨", self.name);
}
}
fn main() {
{
let alice = Rc::new(Node { name: "앨리스".into(), partner: RefCell::new(None) });
let bob = Rc::new(Node { name: "밥".into(), partner: RefCell::new(None) });
*alice.partner.borrow_mut() = Some(Rc::clone(&bob));
*bob.partner.borrow_mut() = Some(Rc::clone(&alice));
println!("앨리스 강한 참조: {}", Rc::strong_count(&alice));
println!("밥 강한 참조: {}", Rc::strong_count(&bob));
println!("스코프를 벗어납니다");
}
println!("스코프를 벗어났습니다");
}
앨리스 강한 참조: 2
밥 강한 참조: 2
스코프를 벗어납니다
스코프를 벗어났습니다
“해제됨”이 한 번도 찍히지 않았습니다. 두 노드 모두 메모리에 그대로 남아 있는 거예요.
강한 참조끼리 물면 카운트가 0이 되지 않습니다
원인은 참조 카운트에 있습니다. Rc는 자기를 가리키는 강한 참조(strong reference)의 수를 세다가 0이 되면 값을 해제하는데, 두 노드가 서로를 붙들고 있으면 이 숫자가 0까지 내려가질 못합니다.
출력에서 각자 2인 걸 보셨죠. 하나는 변수 alice가, 다른 하나는 밥이 들고 있는 참조입니다. 스코프가 끝나면서 변수 쪽 참조가 사라져 1이 되지만, 서로가 서로를 붙든 참조는 그대로 남습니다. 상대가 해제돼야 내 카운트가 줄어드는데 상대도 나를 기다리고 있으니, 둘 다 영영 해제되지 않는 교착에 빠집니다.
flowchart LR
accTitle: 순환 참조로 카운트가 0이 되지 않는 상태
accDescr: 앨리스와 밥이 서로를 강한 참조로 가리켜 각자의 카운트가 1로 남고, 변수가 사라진 뒤에도 해제되지 않는다.
alice["앨리스<br/>강한 참조 1"] -- "강한 참조" --> bob["밥<br/>강한 참조 1"]
bob -- "강한 참조" --> alice
이건 Rust의 버그가 아니라 설계상 한계입니다. 컴파일러는 소유권 규칙으로 사용 후 해제나 이중 해제 같은 위험을 막아 주지만, 메모리 누수 자체는 안전하지 않은 동작으로 보지 않거든요. 순환 참조를 만들지 않는 건 개발자 몫입니다.
Weak는 소유권을 주장하지 않습니다
해법은 고리 중 한쪽을 소유하지 않는 참조로 바꾸는 겁니다. 그게 Weak예요. 값을 가리키기는 하지만 강한 참조 카운트를 올리지 않아서, 해제를 막지 않습니다.
Rc::downgrade로 Rc를 Weak으로 낮추면 됩니다.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
name: String,
partner: RefCell<Weak<Node>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("{} 해제됨", self.name);
}
}
fn main() {
{
let alice = Rc::new(Node { name: "앨리스".into(), partner: RefCell::new(Weak::new()) });
let bob = Rc::new(Node { name: "밥".into(), partner: RefCell::new(Weak::new()) });
*alice.partner.borrow_mut() = Rc::downgrade(&bob);
*bob.partner.borrow_mut() = Rc::downgrade(&alice);
println!("앨리스 강한 참조: {}, 약한 참조: {}",
Rc::strong_count(&alice), Rc::weak_count(&alice));
println!("스코프를 벗어납니다");
}
println!("스코프를 벗어났습니다");
}
앨리스 강한 참조: 1, 약한 참조: 1
스코프를 벗어납니다
밥 해제됨
앨리스 해제됨
스코프를 벗어났습니다
이번에는 둘 다 제때 해제됐습니다. 강한 참조가 1로 줄어든 게 핵심이에요. 변수가 들고 있는 것 하나뿐이라, 스코프가 끝나면 0이 되어 해제가 일어납니다. 약한 참조는 따로 세지만 해제 여부에는 관여하지 않고요.
Weak::new()로 만든 빈 Weak을 초깃값으로 쓴 것도 눈여겨보세요. 아직 아무것도 가리키지 않는 상태를 표현할 수 있어서 Option으로 감쌀 필요가 없습니다.
upgrade로 살아 있는지 확인합니다
Weak은 대상이 이미 해제됐을 수 있으니 값을 바로 꺼내 쓸 수 없습니다. upgrade()로 Option<Rc<T>>를 받아서 확인하는 절차를 거쳐야 해요.
use std::rc::{Rc, Weak};
fn main() {
let strong = Rc::new(String::from("데이터"));
let weak: Weak<String> = Rc::downgrade(&strong);
match weak.upgrade() {
Some(value) => println!("살아 있음: {value}"),
None => println!("이미 해제됨"),
}
drop(strong);
match weak.upgrade() {
Some(value) => println!("살아 있음: {value}"),
None => println!("이미 해제됨"),
}
}
살아 있음: 데이터
이미 해제됨
drop(strong) 전후로 결과가 갈립니다. 이 확인 절차가 번거로워 보일 수 있지만, 사실 Weak이 안전한 이유가 바로 여기에 있어요. 다른 언어의 약한 참조는 해제된 대상을 가리키다 오류를 내는 경우가 있는데, Rust는 타입으로 확인을 강제하니 그럴 일이 없습니다.
upgrade()가 Some을 돌려주는 동안에는 강한 참조가 하나 늘어난 상태라 값이 갑자기 사라지지 않습니다. 확인과 사용 사이에 대상이 해제되는 문제도 자연스럽게 막히는 셈이죠.
값이 해제되는 시점과 할당이 정리되는 시점
강한 참조와 약한 참조는 각각 다른 일을 책임집니다. 강한 참조가 0이 되면 값이 해제되고, 약한 참조까지 0이 되어야 할당된 공간이 정리됩니다. 둘을 나눠 세는 이유가 여기에 있어요.
use std::rc::{Rc, Weak};
struct Data(String);
impl Drop for Data {
fn drop(&mut self) {
println!(" 값 해제: {}", self.0);
}
}
fn main() {
let strong = Rc::new(Data("설정".into()));
let weak: Weak<Data> = Rc::downgrade(&strong);
println!("강한 {}, 약한 {}", Rc::strong_count(&strong), Rc::weak_count(&strong));
println!("strong을 버립니다");
drop(strong);
println!("강한 참조가 0이 되어 값은 이미 해제됐습니다");
println!("upgrade 결과: {:?}", weak.upgrade().is_some());
println!("weak을 버립니다");
drop(weak);
println!("이제 할당 자체가 정리됩니다");
}
강한 1, 약한 1
strong을 버립니다
값 해제: 설정
강한 참조가 0이 되어 값은 이미 해제됐습니다
upgrade 결과: false
weak을 버립니다
이제 할당 자체가 정리됩니다
weak이 아직 살아 있는데도 “값 해제”가 먼저 찍혔습니다. 약한 참조는 값을 붙들어 두지 못한다는 게 이렇게 드러나죠.
그렇다면 weak은 뭘 붙들고 있었을까요? 참조 카운트를 적어 둔 관리 영역입니다. upgrade()가 살았는지 죽었는지 판단하려면 카운트를 읽어야 하는데, 그 공간마저 사라지면 확인할 방법이 없으니까요. 값보다 훨씬 작은 공간이라 부담은 없지만, 약한 참조를 아주 많이 오래 들고 있는 구조라면 한 번쯤 떠올려 볼 만합니다.
부모는 Weak으로, 자식은 Rc로
실전에서 Weak이 가장 많이 쓰이는 곳은 트리 구조입니다. 부모가 자식을 알아야 하고 자식도 부모를 거슬러 올라가야 할 때, 양쪽 다 Rc로 두면 곧바로 순환이 생기거든요.
방향을 정하면 간단합니다. 소유하는 쪽인 부모에서 자식으로는 Rc, 거슬러 올라가는 자식에서 부모로는 Weak을 씁니다.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Folder {
name: String,
parent: RefCell<Weak<Folder>>,
children: RefCell<Vec<Rc<Folder>>>,
}
impl Folder {
fn new(name: &str) -> Rc<Folder> {
Rc::new(Folder {
name: name.to_string(),
parent: RefCell::new(Weak::new()),
children: RefCell::new(Vec::new()),
})
}
fn add_child(parent: &Rc<Folder>, child: Rc<Folder>) {
*child.parent.borrow_mut() = Rc::downgrade(parent);
parent.children.borrow_mut().push(child);
}
fn path(&self) -> String {
match self.parent.borrow().upgrade() {
Some(parent) => format!("{}/{}", parent.path(), self.name),
None => self.name.clone(),
}
}
}
fn main() {
let root = Folder::new("home");
let docs = Folder::new("documents");
let notes = Folder::new("notes");
Folder::add_child(&root, Rc::clone(&docs));
Folder::add_child(&docs, Rc::clone(¬es));
println!("{}", notes.path());
println!("root 자식 수: {}", root.children.borrow().len());
println!("docs 부모: {:?}", docs.parent.borrow().upgrade().map(|p| p.name.clone()));
}
home/documents/notes
root 자식 수: 1
docs 부모: Some("home")
path()가 부모를 타고 올라가며 경로를 만드는데, 이때 upgrade()로 부모가 살아 있는지 확인합니다. 최상위 폴더는 부모가 없으니 None이 나오고 재귀가 멈추죠.
필드를 RefCell로 감싼 이유도 짚고 넘어가면 좋겠습니다. Rc는 불변 참조만 주는데 자식 목록은 나중에 추가해야 하니, 내부 가변성이 필요합니다. Rc<RefCell<T>> 조합이 자료구조를 만들 때 자주 등장하는 이유예요.
마치며
순환 참조는 Rc를 쓰다 보면 언젠가 만나게 되는 함정입니다. 컴파일러가 잡아 주지 않으니 구조를 설계할 때 미리 알아차리는 수밖에 없어요.
기준은 소유 관계입니다. 값을 살려 둘 책임이 있는 방향은 Rc, 그저 참조만 하는 방향은 Weak입니다. 부모와 자식이라면 부모가 자식을 소유하니 아래 방향이 Rc, 거슬러 올라가는 위 방향이 Weak이 되고요. 관찰자나 캐시처럼 대상의 수명에 관여하지 않아야 하는 참조도 Weak이 맞습니다.
여러 스레드에서 같은 자료구조를 다뤄야 한다면 Rc 대신 Arc를 써야 하는데요. Arc에도 Weak이 짝으로 있어서 Arc::downgrade로 같은 방식으로 고리를 끊을 수 있습니다.
더 자세한 내용은 std::rc::Weak - Rust 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0