`RefCell<T>`와 내부 가변성 패턴
RefCell이 대여 규칙을 런타임에 검사하는 방식을 익히고 목 객체와 Rc 조합으로 공유 데이터의 내부 가변성을 구현합니다.
내부 가변성(interior mutability)은 데이터의 불변 참조가 있을 때도 그 데이터를 바꿀 수 있게 해 주는 러스트의 설계 패턴입니다. 보통은 대여 규칙이 이런 동작을 허용하지 않습니다. 데이터를 바꾸기 위해 이 패턴은 데이터 구조 안에서 unsafe 코드를 써, 바꾸기와 대여를 다스리는 러스트의 보통 규칙을 비틉니다. unsafe 코드는 컴파일러에게 우리가 직접 규칙을 확인하고 있으니 컴파일러가 확인해 주기를 기대하지 않는다고 알립니다. unsafe 코드는 안전하지 않은 러스트에서 더 이야기합니다.
내부 가변성 패턴을 쓰는 타입은, 컴파일러가 보장할 수는 없더라도 런타임에 대여 규칙이 지켜진다는 것을 보장할 수 있을 때만 씁니다. 그때 관련된 unsafe 코드는 안전한 API로 감싸지고, 바깥에서 보기에 그 타입은 여전히 불변입니다. 내부 가변성 패턴을 따르는 RefCell<T> 타입으로 이 개념을 살펴봅니다.
RefCell<T>로 런타임에 대여 규칙 집행하기
Rc<T>와 달리 RefCell<T> 타입은 자신이 담은 데이터의 유일한 소유권을 나타냅니다. 그렇다면 RefCell<T>는 Box<T> 같은 타입과 무엇이 다를까요? 참조와 대여에서 배운 대여 규칙을 떠올려 보세요.
| 대여 규칙 |
|---|
| 어느 시점에든 가변 참조 하나나 불변 참조 여러 개 가운데 하나만 가질 수 있습니다. 둘을 함께 가질 수는 없습니다. |
| 참조는 언제나 유효해야 합니다. |
참조와 Box<T>에서는 대여 규칙의 불변 조건이 컴파일 시점에 적용됩니다. RefCell<T>에서는 이 불변 조건이 런타임에 적용됩니다. 참조에서 이 규칙을 어기면 컴파일 오류가 납니다. RefCell<T>에서 이 규칙을 어기면 프로그램이 패닉을 내고 끝납니다.
대여 규칙을 컴파일 시점에 확인하면 개발 과정에서 오류를 더 일찍 잡을 수 있고, 모든 분석이 미리 끝나므로 런타임 성능에 영향이 없다는 장점이 있습니다. 그래서 대부분의 경우에는 컴파일 시점에 대여 규칙을 확인하는 것이 가장 좋은 선택이며, 그것이 러스트의 기본값인 까닭입니다.
대여 규칙을 런타임에 확인하는 장점은, 컴파일 시점의 확인으로는 허용되지 않았을 메모리 안전한 몇몇 상황을 허용한다는 것입니다. 러스트 컴파일러 같은 정적 분석은 본래 보수적입니다. 코드의 어떤 성질은 코드를 분석해서는 알아낼 수 없습니다. 가장 유명한 예가 정지 문제(halting problem)이며, 이 교재의 범위를 벗어나지만 찾아볼 만한 흥미로운 주제입니다.
어떤 분석은 불가능하므로, 러스트 컴파일러는 코드가 소유권 규칙을 지키는지 확신할 수 없으면 올바른 프로그램을 거부할 수도 있습니다. 이런 면에서 보수적입니다. 러스트가 잘못된 프로그램을 받아들인다면, 사용자는 러스트가 하는 보장을 믿을 수 없을 것입니다. 반면 러스트가 올바른 프로그램을 거부하면 프로그래머는 불편하겠지만, 끔찍한 일이 일어나지는 않습니다. RefCell<T> 타입은 코드가 대여 규칙을 지킨다고 확신하지만 컴파일러가 그것을 이해하고 보장하지 못할 때 쓸모가 있습니다.
Rc<T>처럼 RefCell<T>도 단일 스레드 상황에서만 쓰며, 멀티스레드 상황에서 쓰려 하면 컴파일 오류가 납니다. 멀티스레드 프로그램에서 RefCell<T>의 기능을 얻는 방법은 공유 상태 동시성에서 이야기합니다. 다음은 Box<T>, Rc<T>, RefCell<T>를 고르는 기준을 정리한 것입니다.
| 타입 | 소유자 | 대여 확인 |
|---|---|---|
Box<T> | 하나 | 불변 대여와 가변 대여를 컴파일 시점에 확인합니다. |
Rc<T> | 여럿 | 불변 대여만 허용하며 컴파일 시점에 확인합니다. |
RefCell<T> | 하나 | 불변 대여와 가변 대여를 런타임에 확인합니다. |
RefCell<T>는 런타임에 확인되는 가변 대여를 허용하므로, RefCell<T> 자체가 불변이더라도 RefCell<T> 안의 값을 바꿀 수 있습니다. 아래 그림은 RefCell<T>가 런타임에 대여 상태를 어떻게 추적하는지 보여 줍니다.
RefCell 동적 대여 상태 머신
공유 Ref를 보유한 채 try_borrow_mut의 Err를 처리하는 별도 선택 예제이다. Err 뒤 기존 guard는 유지하고 마지막 guard drop 뒤 새 RefMut를 얻는다. 본문의 borrow_mut panic이 자동 복구되어 이어 실행되는 경로가 아니다.
RUST · RUNTIME BORROW STATES
RefCell은 guard가 살아 있는 동안 대여 상태를 유지한다
보통의 전체 값 borrow/borrow_mut를 기준으로 공유·배타 대여와 guard 해제를 추적합니다. 런타임 검사는 별칭 규칙을 없애는 기능이 아닙니다.
대여 없음
borrow 또는 borrow_mut를 시작할 수 있습니다.
공유 대여 · Ref
여러 Ref를 함께 보유할 수 있습니다. 하나가 drop되어도 다른 Ref가 남으면 유지되며 마지막 Ref drop 후 대여 없음으로 돌아갑니다.
배타 대여 · RefMut
전체 값의 RefMut 하나가 활성화되면 다른 공유/가변 대여는 충돌합니다. drop 후 대여 없음으로 돌아갑니다.
충돌 요청 · 상태가 아닌 결과
borrow/borrow_mut는 panic, try_borrow/try_borrow_mut는 Err입니다. try 오류로 기존 guard가 사라지지는 않습니다.
본문 MockMessenger · &self에서 메시지 기록
self.sent_messages
.borrow_mut()
.push(String::from(message));sent_messages는 RefCell<Vec<String>>입니다. 임시 RefMut guard는 이 문장이 끝날 때 drop됩니다. 앞의 트레이트/메서드 정의 안에서 쓰는 핵심 부분입니다.
본문의 두 번째 borrow_mut · panic
let one = cell.borrow_mut();
let two = cell.borrow_mut();값 전체를 빌리는 첫 guard가 살아 있으면 둘째 호출이 panic합니다. try_borrow_mut라면 Err를 처리할 수 있습니다. cell은 본문 sent_messages를 짧게 부른 이름입니다.
내부 카운터 표현은 비공개입니다. guard의 map_split 같은 분할 API는 별도 계약입니다. RefCell은 !Sync이며 T: Send이면 값 자체의 이동은 가능합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4노드와 6간선이다. none→shared/unique는 borrow/borrow_mut, shared→none은 마지막 Ref 종료, unique→none은 RefMut 종료다. shared→충돌과 unique→충돌은 금지 요청의 결과이며 충돌 노드는 새 대여 상태가 아니다. try 메서드의 Err가 살아 있는 guard를 자동 해제하지 않는다. 두 중앙 하향 간선은 공유/배타 대여의 선택을, 왼쪽/오른쪽 바깥 레일은 해제 후 복귀를 나타낸다. 노드를 관통하지 않고 각 본래 대상에 연결한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 대여 없는 상태 RefCell의 런타임 접근 guard가 없다. |
|
|
| 공유 대여 · try 오류 여러 Ref가 있는 동안 try_borrow_mut는 Err를 반환하고 이를 처리한다. |
|
|
| 정리 후 독점 마지막 Ref drop 뒤 RefMut를 얻고 그 guard drop 뒤 다시 비운다. |
|
|
RefCell 동적 대여 상태 머신
공유 Ref를 보유한 채 try_borrow_mut의 Err를 처리하는 별도 선택 예제이다. Err 뒤 기존 guard는 유지하고 마지막 guard drop 뒤 새 RefMut를 얻는다. 본문의 borrow_mut panic이 자동 복구되어 이어 실행되는 경로가 아니다.
RUST · RUNTIME BORROW STATES
RefCell은 guard가 살아 있는 동안 대여 상태를 유지한다
보통의 전체 값 borrow/borrow_mut를 기준으로 공유·배타 대여와 guard 해제를 추적합니다. 런타임 검사는 별칭 규칙을 없애는 기능이 아닙니다.
대여 없음
borrow 또는 borrow_mut를 시작할 수 있습니다.
공유 대여 · Ref
여러 Ref를 함께 보유할 수 있습니다. 하나가 drop되어도 다른 Ref가 남으면 유지되며 마지막 Ref drop 후 대여 없음으로 돌아갑니다.
배타 대여 · RefMut
전체 값의 RefMut 하나가 활성화되면 다른 공유/가변 대여는 충돌합니다. drop 후 대여 없음으로 돌아갑니다.
충돌 요청 · 상태가 아닌 결과
borrow/borrow_mut는 panic, try_borrow/try_borrow_mut는 Err입니다. try 오류로 기존 guard가 사라지지는 않습니다.
본문 MockMessenger · &self에서 메시지 기록
self.sent_messages
.borrow_mut()
.push(String::from(message));sent_messages는 RefCell<Vec<String>>입니다. 임시 RefMut guard는 이 문장이 끝날 때 drop됩니다. 앞의 트레이트/메서드 정의 안에서 쓰는 핵심 부분입니다.
본문의 두 번째 borrow_mut · panic
let one = cell.borrow_mut();
let two = cell.borrow_mut();값 전체를 빌리는 첫 guard가 살아 있으면 둘째 호출이 panic합니다. try_borrow_mut라면 Err를 처리할 수 있습니다. cell은 본문 sent_messages를 짧게 부른 이름입니다.
내부 카운터 표현은 비공개입니다. guard의 map_split 같은 분할 API는 별도 계약입니다. RefCell은 !Sync이며 T: Send이면 값 자체의 이동은 가능합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4노드와 6간선이다. none→shared/unique는 borrow/borrow_mut, shared→none은 마지막 Ref 종료, unique→none은 RefMut 종료다. shared→충돌과 unique→충돌은 금지 요청의 결과이며 충돌 노드는 새 대여 상태가 아니다. try 메서드의 Err가 살아 있는 guard를 자동 해제하지 않는다. 두 중앙 하향 간선은 공유/배타 대여의 선택을, 왼쪽/오른쪽 바깥 레일은 해제 후 복귀를 나타낸다. 노드를 관통하지 않고 각 본래 대상에 연결한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 대여 없는 상태 RefCell의 런타임 접근 guard가 없다. |
|
|
| 공유 대여 · try 오류 여러 Ref가 있는 동안 try_borrow_mut는 Err를 반환하고 이를 처리한다. |
|
|
| 정리 후 독점 마지막 Ref drop 뒤 RefMut를 얻고 그 guard drop 뒤 다시 비운다. |
|
|
처음에는 대여가 없으므로 borrow도 borrow_mut도 시작할 수 있습니다. borrow()로 공유 대여를 시작하면 Ref 가드가 여럿 함께 있을 수 있고, 하나가 버려져도 다른 Ref가 남아 있으면 공유 대여 상태가 유지되다가 마지막 Ref가 버려지면 대여 없음으로 돌아갑니다. borrow_mut()로 배타 대여를 시작하면 값 전체에 대한 RefMut 하나만 있을 수 있고, 그것이 버려지면 대여 없음으로 돌아갑니다. 이 규칙에 어긋나는 요청을 하면 borrow와 borrow_mut는 패닉을 내고, try_borrow와 try_borrow_mut는 Err를 돌려줍니다. 이 실패는 새 대여 상태가 아니며, Err를 받았다고 살아 있는 가드가 저절로 풀리지는 않습니다. 런타임에 확인한다고 별칭 규칙이 사라지는 것이 아니라, 확인하는 시점만 바뀌는 것입니다.
내부 가변성: 불변값에 대한 가변 대여
대여 규칙의 결과로, 불변 값이 있으면 그것을 가변으로 빌릴 수 없습니다. 예를 들어 다음 코드는 컴파일되지 않습니다.
fn main() {
let x = 5;
let y = &mut x;
}error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
--> src/main.rs:3:13
|
3 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
2 | let mut x = 5;
| +++
For more information about this error, try `rustc --explain E0596`.하지만 값이 자신의 메서드 안에서는 스스로를 바꾸면서도, 다른 코드에게는 불변으로 보이는 것이 쓸모 있는 상황이 있습니다. 값의 메서드 바깥의 코드는 값을 바꿀 수 없습니다. RefCell<T>를 쓰는 것은 내부 가변성을 얻는 한 방법이지만, RefCell<T>가 대여 규칙을 완전히 피해 가는 것은 아닙니다. 컴파일러의 대여 검사기는 이 내부 가변성을 허용하고, 대여 규칙은 대신 런타임에 확인됩니다. 규칙을 어기면 컴파일 오류 대신 panic!이 납니다.
불변 값을 바꾸는 데 RefCell<T>를 쓰는 실용적인 예를 보며, 왜 이것이 쓸모 있는지 살펴봅니다.
내부 가변성에 대한 용례: 목 객체
프로그래머는 테스트할 때 가끔 어떤 타입 대신 다른 타입을 써서, 특정 동작을 관찰하고 그것이 올바르게 구현되었는지 확인합니다. 이 대역 타입을 테스트 더블(test double)이라고 합니다. 영화 제작에서 배우 대신 어려운 장면을 연기하는 스턴트 대역처럼 생각하면 됩니다. 테스트 더블은 테스트를 실행할 때 다른 타입을 대신합니다. 목 객체(mock object)는 테스트 동안 무슨 일이 일어났는지 기록해, 올바른 동작이 일어났는지 단언할 수 있게 해 주는 특정한 종류의 테스트 더블이며, 스텁과 목처럼 테스트 더블을 나누는 종류는 통합·인수·회귀 테스트에서 테스트 대역이라는 이름으로 다룹니다.
러스트에는 다른 언어와 같은 의미의 객체가 없고, 다른 언어처럼 표준 라이브러리에 목 객체 기능이 들어 있지도 않습니다. 하지만 목 객체와 같은 목적으로 쓸 구조체는 분명히 만들 수 있습니다.
테스트할 상황은 이렇습니다. 어떤 값이 최댓값에 얼마나 가까운지 추적하고, 현재 값이 최댓값에 얼마나 가까운지에 따라 메시지를 보내는 라이브러리를 만듭니다. 예를 들어 이 라이브러리로 사용자가 쓸 수 있는 API 호출 횟수의 할당량을 추적할 수 있습니다.
이 라이브러리는 값이 최댓값에 얼마나 가까운지 추적하고, 언제 어떤 메시지를 보낼지만 제공합니다. 이 라이브러리를 쓰는 애플리케이션이 메시지를 보내는 방법을 제공해야 합니다. 애플리케이션은 메시지를 애플리케이션에 남기거나, 이메일을 보내거나, 문자 메시지를 보내는 식의 일을 할 수 있습니다. 라이브러리는 그 세부 사항을 알 필요가 없습니다. 필요한 것은 우리가 제공할 Messenger라는 트레이트를 구현한 무언가뿐입니다. 다음은 그 라이브러리 코드입니다.
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}이 코드에서 중요한 부분은 Messenger 트레이트에 self의 불변 참조와 메시지의 텍스트를 받는 send 메서드 하나가 있다는 것입니다. 이 트레이트가 목 객체가 구현해야 하는 인터페이스이며, 그래야 목 객체를 실제 객체와 같은 방식으로 쓸 수 있습니다. 또 다른 중요한 부분은 LimitTracker의 set_value 메서드의 동작을 테스트하고 싶다는 것입니다. value 매개변수로 넘기는 값은 바꿀 수 있지만, set_value는 단언할 만한 것을 아무것도 돌려주지 않습니다. Messenger 트레이트를 구현한 무언가와 max의 특정 값으로 LimitTracker를 만들었을 때, value에 여러 숫자를 넘기면 메신저가 알맞은 메시지를 보내라는 요청을 받는다고 말할 수 있기를 원합니다.
send를 부를 때 이메일이나 문자 메시지를 보내는 대신, 보내라는 요청을 받은 메시지만 기록하는 목 객체가 필요합니다. 목 객체의 새 인스턴스를 만들고, 그 목 객체를 쓰는 LimitTracker를 만들고, LimitTracker의 set_value 메서드를 부른 뒤, 목 객체에 기대한 메시지가 있는지 확인하면 됩니다. 다음 코드는 그렇게 목 객체를 구현하려는 시도이지만, 대여 검사기가 허용하지 않습니다. 앞의 라이브러리 코드 아래에 테스트 모듈을 더한 src/lib.rs 전체입니다.
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockMessenger {
sent_messages: Vec<String>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: vec![],
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.len(), 1);
}
}이 테스트 코드는 보내라는 요청을 받은 메시지를 기록할 String 값의 Vec인 sent_messages 필드를 가진 MockMessenger 구조체를 정의합니다. 또 빈 메시지 목록으로 시작하는 새 MockMessenger 값을 편하게 만들 수 있도록 연관 함수 new도 정의합니다. 그다음 MockMessenger에 Messenger 트레이트를 구현해 MockMessenger를 LimitTracker에 줄 수 있게 합니다. send 메서드의 정의에서는 매개변수로 받은 메시지를 MockMessenger의 sent_messages 목록에 저장합니다.
테스트에서는 max의 100퍼센트의 75퍼센트가 넘는 값으로 value를 정하라고 LimitTracker에 말하면 무슨 일이 일어나는지 테스트합니다. 먼저 빈 메시지 목록으로 시작하는 새 MockMessenger를 만듭니다. 그다음 새 LimitTracker를 만들고, 새 MockMessenger의 참조와 max 값 100을 줍니다. LimitTracker의 set_value 메서드를 75퍼센트가 넘는 80으로 부릅니다. 그다음 MockMessenger가 추적하는 메시지 목록에 메시지가 하나 있어야 한다고 단언합니다. 하지만 이 테스트에는 문제가 하나 있습니다.
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
--> src/lib.rs:58:13
|
58 | self.sent_messages.push(String::from(message));
| ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so it cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
|
2 ~ fn send(&mut self, msg: &str);
3 | }
...
56 | impl Messenger for MockMessenger {
57 ~ fn send(&mut self, message: &str) {
|
For more information about this error, try `rustc --explain E0596`.send 메서드가 self의 불변 참조를 받으므로, 메시지를 추적하려고 MockMessenger를 바꿀 수 없습니다. 오류 메시지의 제안대로 impl 메서드와 트레이트 정의 모두에서 &mut self를 쓸 수도 없습니다. 테스트만을 위해 Messenger 트레이트를 바꾸고 싶지 않기 때문입니다. 대신 기존 설계와 함께 테스트 코드가 올바르게 동작하는 방법을 찾아야 합니다.
이것이 내부 가변성이 도울 수 있는 상황입니다. sent_messages를 RefCell<T> 안에 저장하면, send 메서드가 sent_messages를 바꿔 지금까지 본 메시지를 저장할 수 있게 됩니다. 다음 코드가 그렇게 고친 테스트 모듈입니다. 라이브러리 부분은 앞과 같습니다.
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: RefCell::new(vec![]),
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.borrow_mut().push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
// --생략--
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}sent_messages 필드는 이제 Vec<String> 대신 RefCell<Vec<String>> 타입입니다. new 함수에서는 빈 벡터를 감싼 새 RefCell<Vec<String>> 인스턴스를 만듭니다.
send 메서드의 구현에서 첫 매개변수는 여전히 트레이트의 정의와 맞는 self의 불변 대여입니다. self.sent_messages의 RefCell<Vec<String>>에 borrow_mut를 불러, RefCell<Vec<String>> 안의 값, 곧 벡터의 가변 참조를 얻습니다. 그다음 벡터의 가변 참조에 push를 불러, 테스트 동안 보낸 메시지를 기록할 수 있습니다. 그림의 아래쪽에서처럼 이때 만들어진 임시 RefMut 가드는 이 문장이 끝날 때 버려집니다.
마지막으로 바꿀 것은 단언입니다. 안쪽 벡터에 항목이 몇 개 있는지 보려면, RefCell<Vec<String>>에 borrow를 불러 벡터의 불변 참조를 얻습니다. RefCell<T>를 어떻게 쓰는지 보았으니, 어떻게 동작하는지 자세히 봅니다.
RefCell<T>로 런타임에 대여 추적하기
불변 참조와 가변 참조를 만들 때는 각각 &와 &mut 문법을 씁니다. RefCell<T>에서는 RefCell<T>에 속한 안전한 API인 borrow와 borrow_mut 메서드를 씁니다. borrow 메서드는 스마트 포인터 타입 Ref<T>를, borrow_mut는 스마트 포인터 타입 RefMut<T>를 돌려줍니다. 두 타입 모두 Deref를 구현하므로 보통의 참조처럼 다룰 수 있습니다.
RefCell<T>는 지금 살아 있는 Ref<T>와 RefMut<T> 스마트 포인터가 몇 개인지 추적합니다. borrow를 부를 때마다 RefCell<T>는 살아 있는 불변 대여의 수를 늘립니다. Ref<T> 값이 스코프를 벗어나면 불변 대여의 수가 1 줄어듭니다. 컴파일 시점의 대여 규칙처럼, RefCell<T>는 어느 시점에든 불변 대여 여러 개나 가변 대여 하나를 갖도록 허용합니다.
이 규칙을 어기려 하면, 참조에서처럼 컴파일 오류가 나는 대신 RefCell<T>의 구현이 런타임에 패닉을 냅니다. 다음 코드는 앞의 send 구현을 고친 것입니다. 일부러 같은 스코프에서 살아 있는 가변 대여 두 개를 만들어, RefCell<T>가 런타임에 이것을 막는 것을 보여 줍니다.
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
let mut one_borrow = self.sent_messages.borrow_mut();
let mut two_borrow = self.sent_messages.borrow_mut();
one_borrow.push(String::from(message));
two_borrow.push(String::from(message));
}
}borrow_mut가 돌려준 RefMut<T> 스마트 포인터를 담을 변수 one_borrow를 만듭니다. 그다음 같은 방식으로 two_borrow 변수에 다른 가변 대여를 만듭니다. 이렇게 하면 같은 스코프에 가변 참조가 두 개 생기며, 이것은 허용되지 않습니다. 라이브러리의 테스트를 실행하면 이 코드는 오류 없이 컴파일되지만, 테스트는 실패합니다.
running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED
failures:
---- tests::it_sends_an_over_75_percent_warning_message stdout ----
thread 'tests::it_sends_an_over_75_percent_warning_message' (6017887) panicked at src/lib.rs:60:53:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_sends_an_over_75_percent_warning_message
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`코드가 RefCell already borrowed라는 메시지로 패닉을 냈습니다. 위치 60:53은 앞의 src/lib.rs 전체에 use std::cell::RefCell;을 더한 파일에서 두 번째 borrow_mut가 있는 곳입니다. 이것이 RefCell<T>가 런타임에 대여 규칙 위반을 다루는 방식입니다. 그림 아래쪽의 let one = cell.borrow_mut(); let two = cell.borrow_mut();도 같은 상황을 짧게 줄인 것이며, try_borrow_mut를 썼다면 패닉 대신 Err를 받아 처리할 수 있습니다.
여기서처럼 대여 오류를 컴파일 시점이 아니라 런타임에 잡기로 하면, 개발 과정의 더 늦은 때에 코드의 실수를 발견하게 될 수 있습니다. 코드를 프로덕션에 배포할 때까지 발견하지 못할 수도 있습니다. 또 컴파일 시점 대신 런타임에 대여를 추적하므로, 코드가 런타임 성능 비용을 조금 치르게 됩니다. 하지만 RefCell<T>를 쓰면, 불변 값만 허용되는 상황에서 쓰면서도 지금까지 본 메시지를 기록하려고 자신을 바꿀 수 있는 목 객체를 쓸 수 있습니다. 보통의 참조가 제공하는 것보다 더 많은 기능을 얻으려고, 이 트레이드오프를 감수하고 RefCell<T>를 쓸 수 있습니다.
Rc<T>와 RefCell<T>를 조합하여 가변 데이터의 복수 소유자 만들기
RefCell<T>를 쓰는 흔한 방법은 Rc<T>와 함께 쓰는 것입니다. Rc<T>는 어떤 데이터에 여러 소유자를 두게 해 주지만, 그 데이터에 대한 불변 접근만 줍니다. RefCell<T>를 담은 Rc<T>가 있으면, 여러 소유자를 가질 수 있으면서 바꿀 수도 있는 값을 얻을 수 있습니다.
예를 들어 Rc<T>, 참조 카운트 스마트 포인터의 콘스 리스트 예제에서 Rc<T>로 여러 리스트가 다른 리스트의 소유권을 함께 갖게 했던 것을 떠올려 보세요. Rc<T>는 불변 값만 담으므로, 리스트를 만든 뒤에는 리스트의 값을 바꿀 수 없습니다. RefCell<T>를 더해 리스트의 값을 바꿀 수 있게 합니다. 다음 코드는 Cons의 정의에 RefCell<T>를 써서, 모든 리스트에 저장된 값을 바꿀 수 있게 한 것입니다.
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let value = Rc::new(RefCell::new(5));
let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));
*value.borrow_mut() += 10;
println!("a after = {a:?}");
println!("b after = {b:?}");
println!("c after = {c:?}");
}Rc<RefCell<i32>>의 인스턴스인 값을 만들어 value라는 변수에 저장해, 나중에 직접 접근할 수 있게 합니다. 그다음 value를 담은 Cons 배리언트로 a의 List를 만듭니다. value를 클론해야 a와 value 모두 안쪽 값 5의 소유권을 가지며, value에서 a로 소유권을 옮기거나 a가 value를 빌리지 않아도 됩니다.
리스트 a를 Rc<T>로 감싸, 리스트 b와 c를 만들 때 둘 모두 a를 가리킬 수 있게 합니다. 이것은 Rc<T>, 참조 카운트 스마트 포인터에서 한 것과 같습니다.
a, b, c의 리스트를 만든 뒤, value의 값에 10을 더하려 합니다. value에 borrow_mut를 불러 이 일을 하는데, 이때 메서드 문법에서 이야기한 자동 역참조 기능으로 Rc<T>를 역참조해 안쪽의 RefCell<T> 값에 이릅니다. borrow_mut 메서드는 RefMut<T> 스마트 포인터를 돌려주고, 그것에 역참조 연산자를 써서 안쪽 값을 바꿉니다.
a, b, c를 출력하면, 모두 5 대신 바뀐 값 15를 가진다는 것을 볼 수 있습니다.
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))이 기법은 아주 깔끔합니다. RefCell<T>를 써서, 바깥에서 보기에는 불변인 List 값을 얻었습니다. 하지만 RefCell<T>의 메서드로 내부 가변성에 접근할 수 있으므로, 필요할 때 데이터를 바꿀 수 있습니다. 대여 규칙을 런타임에 확인하므로 데이터 경쟁으로부터 보호되며, 자료 구조에 이런 유연함을 얻으려고 약간의 속도를 내주는 것이 가치 있을 때가 있습니다. RefCell<T>는 멀티스레드 코드에서 동작하지 않는다는 점을 알아 두세요. RefCell<T>는 Sync가 아니며, 다만 T가 Send라면 값 자체를 다른 스레드로 옮길 수는 있습니다. Mutex<T>가 RefCell<T>의 스레드 안전한 판이며, Mutex<T>는 공유 상태 동시성에서 다룹니다.
더 깊이: 내부 가변성의 바닥에 있는 UnsafeCell
공유 참조가 가리키는 값은 바뀌지 않는다는 것이 러스트의 기본 규칙이며, 이 규칙을 어기는 것은 정의되지 않은 동작입니다. Reference의 내부 가변성 설명에 따르면 이 요구를 끌 수 있는 유일하게 허용된 방법이 std::cell::UnsafeCell<T>입니다. UnsafeCell<T>가 불변으로 여러 곳에서 공유되어 있어도 그 안의 T를 바꾸거나 그 가변 참조를 얻는 것은 허용되며, 내부 가변성을 가진 다른 타입은 모두 UnsafeCell<T>를 필드로 두어 만듭니다. RefCell<T>가 안에서 쓴다는 unsafe 코드가 바로 이 UnsafeCell을 다루는 코드이고, 런타임의 대여 확인은 그 코드를 안전한 API로 감싸는 장치입니다.
표준 라이브러리의 std::cell 문서는 이 위에 성격이 다른 셀 타입을 여럿 둡니다. Cell<T>는 값을 통째로 넣고 꺼내는 방식으로만 안쪽을 바꾸므로 안쪽 값의 참조를 결코 내주지 않고, 그래서 대여를 추적할 필요가 없습니다. 문서는 숫자처럼 복사나 이동의 비용이 작은 타입에는 될 수 있으면 Cell<T>를 먼저 고르라고 권합니다. 한 번만 값을 정하면 되는 경우에는 OnceCell<T>가 있어서, Cell과 달리 안쪽의 &T를 내주면서도 RefCell과 달리 런타임 검사가 필요 없습니다.
이 셀 타입들은 모두 단일 스레드용이어서 Sync를 구현하지 않으며, 여러 스레드에서 같은 일을 하려면 Mutex<T>, RwLock<T>, OnceLock<T>, 원자적 타입을 씁니다. 문서는 내부 가변성을 일종의 마지막 수단으로 보고, 바깥에서 보기에 불변인 것의 안쪽 상태를 바꿔야 할 때나 논리적으로는 불변인 메서드의 구현 세부 사항처럼 꼭 필요한 곳에 쓰라고 권합니다.
정리
- 내부 가변성은 불변 참조만 있어도 안쪽 값을 바꾸게 해 주는 패턴으로,
RefCell<T>는 대여 규칙을 컴파일 시점 대신 런타임에 확인합니다. borrow()는Ref<T>를,borrow_mut()는RefMut<T>를 돌려주며, 가드가 살아 있는 동안 공유 대여 여러 개나 배타 대여 하나만 허용합니다.- 규칙을 어기면
borrow와borrow_mut는RefCell already borrowed같은 메시지로 패닉을 내고,try_borrow와try_borrow_mut는Err를 돌려줍니다. - 트레이트가
&self를 받는 목 객체에서 호출 기록을 남길 때처럼, 바깥 인터페이스를 바꾸지 않고 안쪽 상태를 바꿔야 할 때 씁니다. Rc<RefCell<T>>로 여러 소유자가 함께 바꿀 수 있는 값을 만들 수 있으며, 둘 다 단일 스레드 전용입니다.
연습 문제
1. 패닉이 날까
다음 코드에서 패닉이 나는 줄이 있나요? 있다면 어느 줄이고, 어떻게 고치나요?
let cell = RefCell::new(vec![1, 2, 3]);
let first = cell.borrow();
let len = cell.borrow().len();
cell.borrow_mut().push(len);
println!("{}", first[0]);해설 보기
first가 공유 대여를 쥐고 있는 동안 셋째 줄의 borrow()는 공유 대여를 하나 더 하는 것이라 괜찮지만, 넷째 줄의 borrow_mut()는 first가 아직 살아 있으므로 패닉을 냅니다. first는 Ref 가드여서 마지막으로 쓰인 줄이 아니라 스코프가 끝날 때 버려지고, 그때까지 공유 대여가 이어집니다. 그래서 println!을 borrow_mut 앞으로 옮기기만 해서는 고쳐지지 않습니다. 옮긴 뒤 drop(first);로 가드를 먼저 버리거나, let first = cell.borrow()[0];처럼 값을 복사해 가드를 곧바로 버리고 마지막 줄을 println!("{first}");로 바꾸면 고칠 수 있습니다.
2. 무엇을 고를까
(가) 설정 값을 여러 구조체가 함께 읽기만 한다. (나) 여러 노드가 같은 카운터를 함께 가지며 각자 1씩 늘린다. (다) 함수 하나가 큰 배열을 힙에 두고 혼자 바꾼다. 단일 스레드라고 할 때 각각 Box<T>, Rc<T>, Rc<RefCell<T>> 가운데 무엇이 알맞나요?
해설 보기
(가)는 여러 소유자가 읽기만 하므로 Rc<T>가 알맞습니다. (나)는 여러 소유자가 함께 바꾸어야 하므로 Rc<RefCell<T>>를 쓰고, 각 노드에서 borrow_mut()로 늘립니다. (다)는 소유자가 하나이고 컴파일 시점에 대여를 확인할 수 있으므로 Box<T>나 그냥 Vec<T>면 충분하며, 런타임 확인 비용을 치를 까닭이 없습니다.
다음 글 순환 참조와 메모리 누수에서는 Rc<T>와 RefCell<T>로 순환 참조를 만들면 메모리가 새는 과정과, Weak<T>로 그것을 막는 방법을 살펴봅니다.