Rust Weak와 Cow: 순환 참조 끊기와 복사 줄이기
스마트 포인터 페이지에서 Box, Rc, RefCell을 만나 봤어요. 이 페이지에서는 아주 현실적인 문제 두 가지를 해결해 주는 도구 두 개를 더해요. Weak<T>는 값을 살려 두지 않으면서 그 값을 가리키게 해 줘요. Cow<T>는 함수가 가능하면 빌린 데이터를 그대로 돌려주고, 꼭 필요할 때만 복사하게 해 줘요.
Rc를 집 열쇠라고 생각해 보세요. 누군가 열쇠를 가지고 있는 한 집은 그대로 남아 있어요. Weak는 종이에 적어 둔 주소에 더 가까워요. 주소로 집을 찾아갈 수는 있지만, 모두가 열쇠를 반납해서 집이 없어지면 그 주소로 가도 아무것도 없어요. 잊어버린 집의 관리비를 계속 내는 사람은 없는 거죠.
문제: Rc 순환은 절대 정리되지 않아요
섹션 제목: “문제: Rc 순환은 절대 정리되지 않아요”Rc 값은 강한 참조 카운트(strong count)가 0이 되면 해제(drop)돼요. 이 방식은 잘 동작하는데, 두 값이 서로를 가리키는 Rc를 가지면 문제가 생겨요. 그러면 서로가 서로를 영원히 살려 둬요.
확인해 보기 위해 아래 Person 타입은 Drop을 구현해서, 정리될 때 메시지를 출력해요. 어떤 메시지가 나오는지 지켜보세요.
use std::cell::RefCell;use std::rc::Rc;
struct Person { name: String, friend: RefCell<Option<Rc<Person>>>,}
impl Drop for Person { fn drop(&mut self) { println!("drop 실행: {}", self.name); }}
fn main() { let alice = Rc::new(Person { name: String::from("앨리스"), friend: RefCell::new(None) }); let bob = Rc::new(Person { name: String::from("밥"), friend: RefCell::new(None) });
// 앨리스는 밥을 가리키고, 밥은 다시 앨리스를 가리켜요 *alice.friend.borrow_mut() = Some(Rc::clone(&bob)); *bob.friend.borrow_mut() = Some(Rc::clone(&alice));
println!("앨리스의 강한 참조 카운트: {}", Rc::strong_count(&alice)); println!("밥의 강한 참조 카운트: {}", Rc::strong_count(&bob)); println!("main 끝");}실행 결과:
앨리스의 강한 참조 카운트: 2밥의 강한 참조 카운트: 2main 끝무엇이 빠졌는지 보세요. drop 실행: 앨리스도, drop 실행: 밥도 없어요. main이 끝나면 변수 alice와 bob이 사라지니까 각 카운트는 2에서 1로 줄어요. 하지만 0까지는 절대 내려가지 않아요. 각 사람이 여전히 상대의 friend 필드에 붙잡혀 있거든요. 그 메모리는 누수(leak)돼요.
이게 놀랍더라도 자책하지 마세요. 컴파일러도 잡지 못하고, 프로그램이 죽는 것도 아니에요. 그저 메모리가 영영 돌아오지 않을 뿐이에요. 오래 실행되면서 순환을 계속 만드는 프로그램이라면, 이게 점점 쌓여요.
Weak<T>: 값을 살려 두지 않는 포인터
섹션 제목: “Weak<T>: 값을 살려 두지 않는 포인터”해결 방법은 링크 하나 또는 둘 다를 약한(weak) 링크로 만드는 거예요. Weak는 Rc에서 Rc::downgrade로 얻어요. 약한 링크는 소유자로 세지 않기 때문에, 값이 해제되는 걸 막지 않아요.
값이 이미 사라졌을 수도 있으니 Weak를 바로 쓸 수는 없어요. .upgrade()를 호출하면 Option<Rc<T>>를 받아요. 값이 아직 살아 있으면 Some, 아니면 None이에요.
use std::cell::RefCell;use std::rc::{Rc, Weak};
struct Person { name: String, friend: RefCell<Weak<Person>>,}
impl Drop for Person { fn drop(&mut self) { println!("drop 실행: {}", self.name); }}
fn main() { let alice = Rc::new(Person { name: String::from("앨리스"), friend: RefCell::new(Weak::new()) }); let bob = Rc::new(Person { name: String::from("밥"), friend: RefCell::new(Weak::new()) });
*alice.friend.borrow_mut() = Rc::downgrade(&bob); *bob.friend.borrow_mut() = Rc::downgrade(&alice);
if let Some(friend) = alice.friend.borrow().upgrade() { println!("앨리스의 친구: {}", friend.name); }
println!("앨리스: strong = {}, weak = {}", Rc::strong_count(&alice), Rc::weak_count(&alice)); println!("main 끝");}실행 결과:
앨리스의 친구: 밥앨리스: strong = 1, weak = 1main 끝drop 실행: 밥drop 실행: 앨리스이번에는 drop 실행 줄이 둘 다 나와요. 앨리스에게는 강한 소유자가 하나(alice 변수) 있고, 약한 링크가 하나(밥에게서 온 것) 있어요. main이 끝나면 강한 카운트가 0이 되고, Rust가 앨리스를 정리해요. 밥이 먼저 정리되는 건 변수가 만들어진 순서의 반대로 해제되기 때문이에요.
기억해 둘 점 몇 가지예요.
Weak::new()는 아무것도 가리키지 않는 빈 약한 포인터를 만들어요. 여기에upgrade()를 하면 항상None이 나와요. 초기값으로 쓰기 편해요.Rc::strong_count는 소유자 수를 세요.Rc::weak_count는 약한 링크 수를 세요. 값이 언제 해제될지는 강한 카운트만 결정해요.upgrade()로 돌려받은Rc는 진짜 소유자예요. 다만 잠깐 동안만이에요. 스코프를 벗어나면 강한 카운트가 다시 줄어요.
폴더 트리: 자식은 소유하고, 부모는 약하게
섹션 제목: “폴더 트리: 자식은 소유하고, 부모는 약하게”트리는 Weak가 활약하는 대표적인 곳이에요. 폴더는 하위 폴더를 소유하니까 children은 Rc를 담아요. 하위 폴더도 자기 부모가 누구인지 알고 싶지만, 부모를 소유해서는 안 돼요. 양쪽 방향 모두 Rc를 쓰면 다시 순환이 생기니까요. 그래서 parent는 Weak예요.
use std::cell::RefCell;use std::rc::{Rc, Weak};
struct Folder { name: String, parent: RefCell<Weak<Folder>>, children: RefCell<Vec<Rc<Folder>>>,}
fn new_folder(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 full_path(folder: &Folder) -> String { match folder.parent.borrow().upgrade() { Some(parent) => format!("{}/{}", full_path(&parent), folder.name), None => folder.name.clone(), }}
fn main() { let home = new_folder("home"); let photos = new_folder("photos"); let trips = new_folder("trips");
add_child(&home, Rc::clone(&photos)); add_child(&photos, Rc::clone(&trips));
match photos.parent.borrow().upgrade() { Some(parent) => println!("photos의 부모 폴더: {}", parent.name), None => println!("photos에는 부모 폴더가 없어요"), }
println!("전체 경로: {}", full_path(&trips)); println!("home: strong = {}, weak = {}", Rc::strong_count(&home), Rc::weak_count(&home)); println!("photos: strong = {}, weak = {}", Rc::strong_count(&photos), Rc::weak_count(&photos));}실행 결과:
photos의 부모 폴더: home전체 경로: home/photos/tripshome: strong = 1, weak = 1photos: strong = 2, weak = 1카운트를 천천히 읽어 보세요. photos에는 강한 소유자가 둘 있어요. photos 변수와, home 안의 children 목록이에요. 약한 링크는 하나, trips의 parent 필드예요. home의 강한 소유자는 자기 변수 하나뿐이에요. 아무도 자기 부모를 소유하지 않으니까요. 이게 바로 원하는 모양이에요. 소유권은 트리를 따라 아래로 흐르고, 위로 올라가는 링크는 약해요.
RefCell이 있는 이유는, 폴더가 이미 Rc 안에 들어간 뒤에 parent를 설정하고 children에 추가하기 때문이에요. Rc만으로는 공유된 읽기 전용 접근만 할 수 있거든요.
값을 살려 두지 않는 Weak 목록
섹션 제목: “값을 살려 두지 않는 Weak 목록”Weak는 “지켜보기는 하지만 붙잡지는 않는” 목록에도 아주 좋아요. 리스너(listener), 옵저버(observer), 또는 다른 누군가가 소유한 것들의 캐시 같은 거요. 여기서는 게임 점수가 아직 듣고 있는 쪽에 업데이트를 보내요. 리스너가 해제되면 그 약한 링크는 그냥 동작을 멈춰요.
use std::rc::{Rc, Weak};
struct Listener { name: String,}
fn notify(listeners: &[Weak<Listener>], event: &str) { for weak in listeners { match weak.upgrade() { Some(listener) => println!("{} 수신: {event}", listener.name), None => println!("(사라진 리스너는 건너뛰어요)"), } }}
fn main() { let screen = Rc::new(Listener { name: String::from("화면") }); let logger = Rc::new(Listener { name: String::from("로거") });
let mut listeners: Vec<Weak<Listener>> = Vec::new(); listeners.push(Rc::downgrade(&screen)); listeners.push(Rc::downgrade(&logger));
notify(&listeners, "점수 10점");
drop(logger); // 소유자가 로거를 닫아요
notify(&listeners, "점수 20점");
// 아무것도 가리키지 않는 링크를 정리해요 listeners.retain(|weak| weak.upgrade().is_some()); println!("남은 리스너: {}", listeners.len());}실행 결과:
화면 수신: 점수 10점로거 수신: 점수 10점화면 수신: 점수 20점(사라진 리스너는 건너뛰어요)남은 리스너: 1목록은 로거가 해제되는 걸 전혀 막지 않았어요. 목록이 Rc를 들고 있었다면, 로거는 목록 안에서 계속 살아남아 아무도 원하지 않는 메시지를 계속 받았을 거예요. Weak를 쓰면 언제 사라질지는 소유자가 정하고, 나머지는 upgrade()로 확인만 하면 돼요.
Cow: 빌릴 수 있으면 빌리고, 필요할 때만 복사하기
섹션 제목: “Cow: 빌릴 수 있으면 빌리고, 필요할 때만 복사하기”이번엔 다른 문제예요. 사용자 이름을 정리하는 함수를 만든다고 해 볼게요. 대부분의 이름은 이미 깔끔해요. 몇 개만 공백이 더 붙어 있거나 대문자가 섞여 있어요. 함수가 항상 새 String을 반환한다면, 바꾸지도 않은 이름까지 하나하나 전부 복사하게 돼요.
Cow(“clone on write”, 쓸 때 복제한다는 뜻이에요. std::borrow::Cow에 있어요)가 이 문제를 해결해요. Cow<'_, str>는 배리언트가 두 개인 열거형이에요.
Cow::Borrowed(&str): “손대지 않은 원본 텍스트예요”. 메모리 할당이 없어요.Cow::Owned(String): “바꿔야 해서 새String을 만들었어요”.
use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> { let trimmed = name.trim(); let has_upper = name.chars().any(|c| c.is_uppercase());
if trimmed.len() == name.len() && !has_upper { Cow::Borrowed(name) // 이미 깔끔해요: 원본을 그대로 돌려줘요 } else { Cow::Owned(trimmed.to_lowercase()) // 고쳐야 해요: 새 String을 만들어요 }}
fn main() { for input in ["alice", " Bob ", "CAROL", "dave"] { match normalize(input) { Cow::Borrowed(name) => println!("{input:?} -> {name:?} (빌림, 복사 없음)"), Cow::Owned(name) => println!("{input:?} -> {name:?} (소유, 새 String)"), } }}실행 결과:
"alice" -> "alice" (빌림, 복사 없음)" Bob " -> "bob" (소유, 새 String)"CAROL" -> "carol" (소유, 새 String)"dave" -> "dave" (빌림, 복사 없음)(이 예제는 대문자와 소문자를 바꾸고 길이를 비교하기 때문에 사용자 이름을 영어로 두었어요. 한글에는 대소문자가 없거든요.)
네 이름 중 두 개는 비용이 전혀 들지 않았어요. 입력의 99%가 이미 깔끔한 실제 프로그램이라면, 하지 않아도 되는 복사를 엄청나게 아끼는 셈이에요. Cow<'_, str>의 '_는 “입력에서 빌려 올 수도 있어요”라는 뜻일 뿐이에요. 라이프타임이 아직 헷갈린다면 라이프타임 페이지에서 설명해요.
Cow를 평범한 문자열처럼 쓰기
섹션 제목: “Cow를 평범한 문자열처럼 쓰기”보통은 Cow에 match를 할 필요가 전혀 없어요. Cow는 &str로 역참조(deref)되기 때문에, 문자열 메서드를 바로 호출할 수 있고 &str를 받는 함수에 &name을 넘길 수도 있어요. 정말로 소유한 String이 필요하면 .into_owned()를 호출하세요. 안에 String이 있으면 그걸 꺼내 주고, Cow가 빌린 값이었을 때만 복사해요.
use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> { let trimmed = name.trim(); if trimmed.len() == name.len() && !name.chars().any(|c| c.is_uppercase()) { Cow::Borrowed(name) } else { Cow::Owned(trimmed.to_lowercase()) }}
fn greet(name: &str) { println!("환영해요, {name}!");}
fn main() { let name = normalize(" Eve ");
println!("길이: {}", name.len()); // str 메서드를 바로 쓸 수 있어요 println!("e로 시작하나요: {}", name.starts_with('e')); greet(&name); // &Cow<str>가 &str로 바뀌어요
let saved: String = name.into_owned(); // 이제 평범한 String이에요 println!("사용자 목록에 {saved:?} 저장 완료");}실행 결과:
길이: 3e로 시작하나요: true환영해요, eve!사용자 목록에 "eve" 저장 완료Cow는 다른 타입과도 함께 쓸 수 있어요. 예를 들어 바꿔야 할 수도 있는 슬라이스라면 Cow<'_, [i32]>처럼요. 그래도 텍스트에는 Cow<'_, str>를 가장 많이 보게 될 거예요.
언제 무엇을 쓸까요?
섹션 제목: “언제 무엇을 쓸까요?”지금까지 만난 포인터 비슷한 타입들을 한눈에 정리해 볼게요.
| 타입 | 주는 것 | 이럴 때 쓰세요 |
|---|---|---|
Box<T> |
소유자 하나, 값은 힙에 | 재귀 타입, 또는 싸게 옮기고 싶은 큰 값 |
Rc<T> |
소유자 여럿, 스레드 하나 | 프로그램의 여러 부분이 같은 데이터를 공유할 때 |
Arc<T> |
소유자 여럿, 스레드 사이에서 | Rc와 같지만 스레드끼리 공유할 때(공유 상태 참고) |
Weak<T> |
소유하지 않는 링크 | 부모 링크, 옵저버 목록, 캐시, Rc 순환 끊기 |
RefCell<T> |
공유 참조를 통한 변경, 실행 중에 검사 | Rc 안에 있는 값을 바꿔야 할 때 |
Cow<'_, T> |
빌린 값 또는 소유한 값, 실행 중에 결정 | 입력을 대개 그대로 돌려주는 함수 |
잘 모르겠으면 평범한 소유권과 대여로 시작하세요. 컴파일러나 메모리 누수가 필요하다고 알려 줄 때만 이 도구들을 더하면 돼요.
자주 하는 실수
섹션 제목: “자주 하는 실수”값이 사라진 뒤에 upgrade 결과를 unwrap하기
섹션 제목: “값이 사라진 뒤에 upgrade 결과를 unwrap하기”upgrade()가 Option을 반환하는 데는 이유가 있어요. 습관처럼 .unwrap()을 붙이면, 값이 해제된 순간 프로그램이 패닉을 일으켜요.
use std::rc::Rc;
fn main() { let session = Rc::new(String::from("user-42")); let link = Rc::downgrade(&session);
drop(session); // 세션이 끝나요
let current = link.upgrade().unwrap(); println!("아직 로그인 중: {current}");}실행하면 이렇게 나와요.
thread 'main' (12345) panicked at src/main.rs:9:34:called `Option::unwrap()` on a `None` valuenote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace이 메시지는 Rust가 영어로 출력해요. “None 값에 Option::unwrap()을 호출했다”는 뜻이고, src/main.rs:9:34는 패닉이 일어난 위치(9번째 줄, 34번째 칸)예요. 해결 방법은 “사라졌음”을 평범한 경우로 다루는 거예요. match나 if let을 쓰세요.
match link.upgrade() { Some(current) => println!("아직 로그인 중: {current}"), None => println!("세션이 끝났어요"),}거꾸로 가리키는 링크에 Rc 쓰기
섹션 제목: “거꾸로 가리키는 링크에 Rc 쓰기”자식이 Rc<Parent>를 들고 부모가 Rc<Child>를 들고 있다면, 첫 번째 절에서 본 순환을 만든 거예요. 아무것도 죽지 않고, 아무 경고도 없어요. 메모리가 그냥 영영 돌아오지 않을 뿐이에요. 좋은 규칙이 있어요. 누가 누구를 소유하는지 정하고, 그 방향에는 Rc를, 반대 방향의 모든 링크에는 Weak를 쓰세요.
struct Folder { parent: RefCell<Rc<Folder>>, // 잘못됨: 자식이 부모를 살려 둬요 children: RefCell<Vec<Rc<Folder>>>,}parent를 RefCell<Weak<Folder>>로 바꾸면 순환이 사라져요.
연습 문제
섹션 제목: “연습 문제”1. 삭제된 글에 달린 댓글
섹션 제목: “1. 삭제된 글에 달린 댓글”title이 있는 Post를 만들어 Rc에 담으세요. text와 post: Weak<Post>가 있는 Comment를 만드세요. 글이 아직 있으면 "<text>" (글: <title>)을, 없으면 "<text>" (삭제된 글)을 출력하는 함수 describe(comment: &Comment)를 작성하세요. 함수를 호출하고, 글을 해제한 다음, 다시 호출해 보세요. 힌트: match comment.post.upgrade()를 쓰세요.
정답 보기
use std::rc::{Rc, Weak};
struct Post { title: String,}
struct Comment { text: String, post: Weak<Post>,}
fn describe(comment: &Comment) { match comment.post.upgrade() { Some(post) => println!("\"{}\" (글: {})", comment.text, post.title), None => println!("\"{}\" (삭제된 글)", comment.text), }}
fn main() { let post = Rc::new(Post { title: String::from("Rust 배우기") }); let comment = Comment { text: String::from("좋은 글이에요!"), post: Rc::downgrade(&post) };
describe(&comment); drop(post); describe(&comment);}실행 결과:
"좋은 글이에요!" (글: Rust 배우기)"좋은 글이에요!" (삭제된 글)댓글은 글을 절대 살려 두지 않아요. 하나뿐인 Rc가 해제되면 upgrade()가 None을 반환하고, 댓글은 패닉 없이 침착하게 그 상황을 처리해요.
2. 필요할 때만 텍스트 이스케이프하기
섹션 제목: “2. 필요할 때만 텍스트 이스케이프하기”모든 <를 <로 바꾸는 fn escape(text: &str) -> Cow<'_, str>를 작성하세요. 텍스트에 <가 없으면 빌린 값으로 돌려주세요. "안녕하세요"와 "1 < 2"로 테스트하고, 어느 배리언트를 받았는지 출력하세요. 힌트: 먼저 text.contains('<')로 확인하고, 소유하는 경우에는 text.replace('<', "<")를 쓰세요.
정답 보기
use std::borrow::Cow;
fn escape(text: &str) -> Cow<'_, str> { if text.contains('<') { Cow::Owned(text.replace('<', "<")) } else { Cow::Borrowed(text) }}
fn main() { for text in ["안녕하세요", "1 < 2"] { match escape(text) { Cow::Borrowed(s) => println!("{s} (빌림)"), Cow::Owned(s) => println!("{s} (소유)"), } }}실행 결과:
안녕하세요 (빌림)1 < 2 (소유)실제로 <가 들어 있는 텍스트만 새 String 비용을 치러요. 나머지는 손대지 않은 채 그대로 통과해요.
- 서로를 가리키는 두
Rc값은 강한 카운트가 절대 0이 되지 않아요. 그래서 메모리가 누수되고Drop도 실행되지 않아요. Rc::downgrade는 값을 살려 두지 않는Weak<T>를 만들어요.upgrade()는Option<Rc<T>>를 돌려줘요.- 트리에서는 자식을
Rc로 소유하고, 부모 링크는Weak로 둬요. 소유권은 아래로 흐르고, 약한 링크는 위를 가리켜요. - 옵저버 목록이나 캐시는
Weak를 들고 있으면 실수로 값을 살려 두는 일이 없어요. Cow<'_, str>는 바뀐 게 없으면 빌린 텍스트를, 바뀌었을 때만 소유한String을 돌려줘요.&str로 역참조되고,.into_owned()로String을 얻을 수 있어요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. 두 값이 각각 상대를 가리키는 Rc를 들고 있어요. main이 끝나면 어떻게 될까요?
2. weak.upgrade()는 무엇을 반환할까요?
3. 폴더 트리에서 Weak여야 하는 링크는 어느 쪽일까요?
4. Cow<'_, str>를 반환하는 함수가 새 String을 만들지 않는 건 언제일까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- 순환 참조는 메모리 누수를 발생시킬 수 있습니다 — The Rust Programming Language 한국어판:
Rc순환과, 부모 링크를Weak로 둔 트리를 차근차근 보여 주는 공식 설명이에요. std::rc::Weak— 표준 라이브러리 문서 (영어):upgrade,strong_count,Weak::new를 포함한Weak의 모든 메서드가 있어요.std::rc— 표준 라이브러리 문서 (영어): 강한 카운트와 약한 카운트가 함께 어떻게 동작하는지, 순환을 끊는 더 긴 예제와 함께 설명해요.std::borrow::Cow— 표준 라이브러리 문서 (영어):Cow열거형,to_mut,into_owned, 그리고 문자열 말고 다른 타입의 예제도 있어요.