Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

내부 가변성(Interior mutability)

Sendersend 시그니처를 잠시 살펴봅시다.

impl<T> Sender<T> {
    pub fn send(&self, t: T) -> Result<(), SendError<T>> {
        // [...]
    }
}

send 메서드는 인자로 &self를 받습니다. 그런데 이 메서드는 분명히 채널에 새로운 메시지를 추가하는 변이(Mutation) 작업을 수행합니다. 게다가 더 흥미로운 사실은 Sender가 복제 가능하다는 것입니다. 즉, 여러 개의 Sender 인스턴스가 각기 다른 스레드에서 동시에 채널의 상태를 수정하려 할 수도 있습니다.

이것이 우리가 클라이언트-서버 아키텍처를 구현할 수 있는 핵심 원리입니다. 하지만 대체 어떻게 이게 가능한 걸까요? Rust의 엄격한 빌림 규칙을 위반하고 있는 건 아닐까요? 어떻게 불변 참조를 통해 데이터를 수정할 수 있는 걸까요?

불변 참조 대신 공유 참조(Shared reference)

이전 챕터에서 빌림 검사기를 처음 소개할 때, Rust에는 두 가지 종류의 참조가 있다고 말씀드렸습니다.

  • 불변 참조(&T)
  • 가변 참조(&mut T)

사실 이들을 다음과 같이 부르는 것이 더 정확한 표현입니다.

  • 공유 참조(Shared reference, &T)
  • 배타적 참조(Exclusive reference, &mut T)

“불변/가변“이라는 용어는 입문자가 Rust의 개념을 잡는 데는 아주 좋지만, 사실 전체 이야기의 일부일 뿐입니다. &T가 가리키는 데이터가 반드시 불변이라는 보장은 없거든요. 하지만 걱정하지 마세요! Rust가 약속하는 안전성은 여전히 굳건합니다. 단지 용어가 겉으로 보이는 것보다 조금 더 깊은 의미를 담고 있을 뿐입니다.

UnsafeCell

타입이 공유 참조를 통해 내부 데이터를 변경할 수 있도록 설계된 경우, 이를 **내부 가변성(Interior mutability)**을 가졌다고 말합니다.

기본적으로 Rust 컴파일러는 공유 참조가 가리키는 값은 변하지 않는다고 가정하고 코드 최적화를 수행합니다. 값을 캐싱하거나 실행 순서를 바꾸는 등 성능을 높이기 위한 온갖 작업을 처리하죠.

데이터를 UnsafeCell로 감싸면 컴파일러에게 “잠깐, 이 공유 참조는 실제로 값을 바꿀 수 있으니까 너무 마음대로 최적화하지 마!“라고 알려주는 효과가 있습니다. 내부 가변성을 허용하는 모든 타입(예: Mutex, RefCell 등)은 직간접적으로 이 UnsafeCell을 사용하고 있습니다.

하지만 오해는 금물입니다! UnsafeCell이 빌림 검사기를 무시하게 해주는 마법 지팡이는 아닙니다. unsafe 코드 역시 Rust의 엄격한 빌림 및 별칭 규칙을 따라야 합니다. 이는 단지 Rust의 타입 시스템만으로는 표현하기 어려운 **안전한 추상화(Safe abstraction)**를 구축하기 위해 제공되는 고급 도구일 뿐입니다. unsafe 키워드를 쓴다는 건 개발자가 컴파일러에게 “내가 이 코드의 불변성을 책임질 테니 믿어줘“라고 말하는 것과 같습니다.

우리는 이 과정에서 UnsafeCell을 직접 만지거나 unsafe 코드를 작성하지는 않을 겁니다. 하지만 Rust에서 매일 사용하는 타입들이 어떻게 돌아가는지 이해하는 것은 매우 중요합니다.

대표적인 예시

내부 가변성을 활용하는 표준 라이브러리의 주요 타입들을 살펴봅시다.

참조 카운팅(Rc)

Rc는 참조 횟수를 세는 포인터입니다. 값을 Rc로 감싸면 여러 곳에서 데이터를 공유할 수 있고, 마지막 참조가 사라질 때 비로소 데이터가 해제됩니다. Rc에 담긴 값 자체는 불변이지만, Rc는 내부적으로 UnsafeCell을 사용해 공유 참조를 통해서도 참조 카운트를 올리고 내릴 수 있게 구현되어 있습니다.

use std::rc::Rc;

let a: Rc<String> = Rc::new("내 문자열".to_string());
// 참조 카운트가 1입니다.
assert_eq!(Rc::strong_count(&a), 1);

// `clone`을 해도 문자열 자체가 복사되지는 않습니다!
// 대신 참조 카운트가 올라갑니다.
let b = Rc::clone(&a);
assert_eq!(Rc::strong_count(&a), 2);

RefCell

RefCell은 Rust에서 내부 가변성을 가장 잘 보여주는 예입니다. RefCell에 대한 공유 참조만 가지고 있어도 내부의 값을 바꿀 수 있습니다.

이는 **런타임 빌림 검사(Runtime borrow checking)**를 통해 실현됩니다. 컴파일 시점이 아닌 프로그램이 실행되는 동안 참조 규칙을 검사하는 것이죠. 만약 규칙을 어기면 프로그램은 패닉(Panic)을 일으켜 안전성을 보장합니다.

use std::cell::RefCell;

let x = RefCell::new(42);

let y = x.borrow(); // 불변 빌림 (성공)
let z = x.borrow_mut(); // 패닉! 이미 빌려진 상태에서 가변 빌림을 시도했습니다.

Exercise

The exercise for this section is located in 07_threads/06_interior_mutability