Rust 기초: RefCell로 불변 참조에서 값 바꾸기

Rust 기초: RefCell로 불변 참조에서 값 바꾸기

구조체 안에 호출 횟수를 세는 필드 하나 두려다가 컴파일러에게 막혀 보신 적 있으신가요? 로그를 찍는 김에 카운터도 올리고 싶을 뿐인데 말이죠.

struct Logger {
    count: u32,
}

impl Logger {
    fn log(&self, message: &str) {
        println!("[{}] {}", self.count, message);
        self.count += 1;
    }
}
결과
error[E0594]: cannot assign to `self.count`, which is behind a `&` reference
 --> src/main.rs:8:9
  |
8 |         self.count += 1;
  |         ^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be written
  |
help: consider changing this to be a mutable reference
  |
6 |     fn log(&mut self, message: &str) {
  |             +++

컴파일러 제안대로 &mut self로 바꾸면 되긴 합니다. 그런데 그러면 log를 부르는 쪽도 전부 가변 참조를 들고 있어야 하고, 여러 곳에서 로거를 공유하던 구조가 통째로 무너집니다. 겨우 카운터 하나 때문에요. 😅

소유권과 빌림 규칙이 원래 그렇습니다. 불변 참조가 하나라도 살아 있으면 값을 바꿀 수 없어요. 그런데 이 규칙을 지키면서도 값을 바꿀 방법이 있습니다. 검사 시점을 컴파일에서 실행 중으로 옮기는 겁니다. 이걸 내부 가변성(interior mutability)이라고 부르고, 대표 주자가 RefCell입니다.

RefCell은 빌림 검사를 실행 중으로 미룹니다

바꿀 필드를 RefCell로 감싸면 됩니다.

use std::cell::RefCell;

struct Logger {
    count: RefCell<u32>,
}

impl Logger {
    fn log(&self, message: &str) {
        let mut count = self.count.borrow_mut();
        println!("[{}] {}", count, message);
        *count += 1;
    }
}

fn main() {
    let logger = Logger { count: RefCell::new(0) };
    logger.log("첫 번째");
    logger.log("두 번째");
    logger.log("세 번째");
    println!("최종 호출 횟수: {}", logger.count.borrow());
}
결과
[0] 첫 번째
[1] 두 번째
[2] 세 번째
최종 호출 횟수: 3

log의 시그니처는 그대로 &self인데 내부 값이 바뀌었습니다. 값을 읽을 때는 borrow(), 바꿀 때는 borrow_mut()을 부르는 게 전부예요.

여기서 오해하기 쉬운 지점이 있습니다. RefCell은 빌림 규칙을 없애는 게 아니라 검사 시점을 미룰 뿐입니다. “불변 참조 여럿 또는 가변 참조 하나”라는 규칙은 그대로 살아 있고, 다만 컴파일러 대신 RefCell이 실행 중에 직접 세면서 확인합니다.

규칙을 어기면 실행이 멈춥니다

검사를 미룬 대가는 분명합니다. 규칙을 어겨도 컴파일은 통과하고, 대신 실행 중에 패닉(panic)이 납니다.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);

    let first = data.borrow_mut();
    let second = data.borrow_mut();

    println!("{:?} {:?}", first, second);
}
결과
thread 'main' panicked at src/main.rs:7:23:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

가변으로 두 번 빌리려다 두 번째에서 멈췄습니다. 컴파일러가 잡아 주던 실수가 이제 실행 중에야 드러나는 거죠. 그래서 RefCell은 꼭 필요할 때만 쓰고, &mut self로 해결되는 상황이라면 그쪽을 택하는 편이 낫습니다.

패닉이 부담스럽다면 try_borrow_mut()으로 결과를 값처럼 다룰 수 있습니다.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);
    let guard = data.borrow_mut();

    match data.try_borrow_mut() {
        Ok(_) => println!("빌리기 성공"),
        Err(e) => println!("빌리기 실패: {e}"),
    }

    drop(guard);

    match data.try_borrow_mut() {
        Ok(mut v) => { v.push(4); println!("빌리기 성공: {v:?}"); }
        Err(e) => println!("빌리기 실패: {e}"),
    }
}
결과
빌리기 실패: RefCell already borrowed
빌리기 성공: [1, 2, 3, 4]

drop(guard)로 앞선 빌림을 명시적으로 끝내니 두 번째 시도가 성공합니다. 빌림이 언제 풀리는지가 곧 패닉 여부를 가르므로, borrow_mut()의 결과를 오래 붙들고 있지 않는 게 요령입니다.

Rc와 짝을 이루면 공유하면서 수정할 수 있습니다

RefCell이 가장 자주 등장하는 자리는 Rc와 함께입니다. Rc는 여러 소유자를 허용하지만 불변 참조만 주는데, 그 안을 RefCell로 채우면 공유와 수정을 동시에 얻습니다.

use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let cart = Rc::new(RefCell::new(Vec::<String>::new()));

    let viewer = Rc::clone(&cart);
    let editor = Rc::clone(&cart);

    editor.borrow_mut().push("사과".to_string());
    editor.borrow_mut().push("바나나".to_string());

    println!("소유자 수: {}", Rc::strong_count(&cart));
    println!("장바구니: {:?}", viewer.borrow());
}
결과
소유자 수: 3
장바구니: ["사과", "바나나"]

editor로 넣은 값이 viewer에도 그대로 보입니다. 셋이 같은 데이터를 가리키고 있으니까요. 트리나 그래프처럼 노드끼리 서로를 참조해야 하는 자료구조에서 Rc<RefCell<T>>가 사실상 표준 조합인 이유가 여기에 있습니다.

앞선 예제에서 borrow_mut()을 두 줄로 나눠 쓴 것도 이유가 있어요. 한 문장에 두 번 부르면 첫 빌림이 살아 있는 채로 두 번째가 들어와 패닉합니다.

Cell은 통째로 바꾸고 통째로 꺼냅니다

내부 가변성 타입이 RefCell만 있는 건 아닙니다. Cell은 참조를 아예 빌려주지 않고 값을 통째로 교환하는 방식으로 같은 목적을 이룹니다.

use std::cell::Cell;

struct Counter {
    hits: Cell<u32>,
}

impl Counter {
    fn hit(&self) {
        self.hits.set(self.hits.get() + 1);
    }
}

fn main() {
    let counter = Counter { hits: Cell::new(0) };
    counter.hit();
    counter.hit();
    println!("조회 수: {}", counter.hits.get());

    let old = counter.hits.replace(100);
    println!("이전 값: {old}, 새 값: {}", counter.hits.get());
}
결과
조회 수: 2
이전 값: 2, 새 값: 100

get()은 값을 복사해 오고 set()은 통째로 덮어씁니다. 참조를 내주지 않으니 빌림을 셀 일도 없고, 그래서 패닉도 없어요. 카운터나 플래그처럼 작은 값에는 RefCell보다 Cell이 가볍고 안전합니다.

대신 제약이 있습니다. get()은 값을 복사하는 동작이라 Copy를 구현한 타입에만 쓸 수 있어요.

use std::cell::Cell;

fn main() {
    let name = Cell::new(String::from("dale"));
    println!("{}", name.get());
}
결과
error[E0599]: the method `get` exists for struct `Cell<String>`, but its trait bounds were not satisfied
   --> src/main.rs:5:25
    |
5   |     println!("{}", name.get());
    |                         ^^^
    |
360 | pub struct String {
    | ----------------- doesn't satisfy `String: Copy`

Copy와 Clone의 차이가 여기서 갈립니다. String처럼 힙을 쓰는 타입은 Copy가 아니라서 get()을 못 쓰고, 이럴 때 RefCell로 넘어가면 됩니다. 정리하면 정수나 불리언 같은 작은 Copy 타입은 Cell, 그 밖에는 RefCell입니다.

한 번만 채우는 값이라면 OnceCell과 LazyCell

값을 여러 번 바꿀 게 아니라 딱 한 번만 채우고 싶을 때도 있습니다. 비싼 계산을 처음 요청받을 때까지 미뤘다가 결과를 재사용하는 경우죠. OnceCell과 LazyCell이 그 자리를 맡습니다.

use std::cell::{LazyCell, OnceCell};

struct Profile {
    id: u32,
    display_name: OnceCell<String>,
}

impl Profile {
    fn display_name(&self) -> &str {
        self.display_name.get_or_init(|| {
            println!("표시 이름을 계산합니다");
            format!("사용자 {}", self.id)
        })
    }
}

fn main() {
    let profile = Profile { id: 42, display_name: OnceCell::new() };
    println!("{}", profile.display_name());
    println!("{}", profile.display_name());

    let greeting = LazyCell::new(|| {
        println!("인사말을 만듭니다");
        String::from("안녕하세요")
    });
    println!("아직 만들지 않았습니다");
    println!("{}", *greeting);
}
결과
표시 이름을 계산합니다
사용자 42
사용자 42
아직 만들지 않았습니다
인사말을 만듭니다
안녕하세요

get_or_init은 값이 없으면 클로저를 실행해 채우고 있으면 그냥 돌려줍니다. 계산 메시지가 한 번만 찍힌 게 그 증거예요. LazyCell은 초기화 방법을 만들 때 미리 넘겨 두고 처음 역참조할 때 실행하는데, “아직 만들지 않았습니다”가 먼저 찍힌 순서에서 확인할 수 있습니다.

RefCell과 달리 이 둘은 값을 채운 뒤에는 바꿀 수 없습니다. 한 번 정해지면 끝이라는 제약이 오히려 안전장치가 되어 주죠.

스레드를 넘어가면 쓸 수 없습니다

지금까지 본 네 타입에는 공통된 한계가 있습니다. 빌림을 세는 카운터에 아무런 동기화가 없어서 여러 스레드가 동시에 건드리면 숫자가 꼬입니다. 그래서 Rust는 아예 컴파일 단계에서 막습니다.

use std::cell::OnceCell;

static NAME: OnceCell<String> = OnceCell::new();

fn main() {
    NAME.set(String::from("dale")).unwrap();
}
결과
error[E0277]: `OnceCell<String>` cannot be shared between threads safely
 --> src/main.rs:3:14
  |
3 | static NAME: OnceCell<String> = OnceCell::new();
  |              ^^^^^^^^^^^^^^^^ `OnceCell<String>` cannot be shared between threads safely
  |
  = help: the trait `Sync` is not implemented for `OnceCell<String>`
  = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::OnceLock` instead

static은 모든 스레드가 함께 보는 자리라 Sync를 요구하는데, Cell 계열은 Sync를 구현하지 않습니다. 재미있는 건 Send는 구현한다는 점이에요. 값을 다른 스레드로 통째로 옮기는 건 괜찮지만, 여러 스레드가 동시에 참조하는 건 안 된다는 뜻입니다.

마지막 줄에서 컴파일러가 대안까지 알려주네요. std::cell의 네 타입에는 std::sync 쪽에 짝이 하나씩 있습니다.

단일 스레드 (std::cell)스레드 안전 (std::sync)
RefCell<T>RwLock<T>
Cell<T>Mutex, 원자적 타입
OnceCell<T>OnceLock<T>
LazyCell<T>LazyLock<T>

전역 설정이나 여러 스레드가 함께 쓰는 값이라면 오른쪽 열로 가야 합니다. LazyLock과 OnceLock 쪽에서 전역 값을 지연 초기화하는 방법을 따로 다뤘고, 잠금 없이 정수를 다루는 원자적 타입도 선택지입니다.

마치며

RefCell은 빌림 규칙을 깨는 도구가 아니라 검사 시점을 옮기는 도구입니다. 규칙은 그대로 살아 있고, 어기면 컴파일 오류 대신 패닉으로 돌아올 뿐이죠. 그래서 편리한 만큼 조심스럽게 써야 합니다.

고르는 기준을 정리하면 이렇습니다. &mut self로 풀리면 그게 최선이고, 안 될 때 내부 가변성을 꺼냅니다. 그중 정수나 불리언 같은 Copy 타입은 Cell, 그 밖의 타입은 RefCell, 한 번만 채울 값은 OnceCell이나 LazyCell입니다. 그리고 스레드를 넘나든다면 전부 std::sync 쪽 짝으로 바꿔야 합니다.

Rc<RefCell<T>>로 트리나 그래프를 만들다 보면 노드끼리 서로를 붙들어 메모리가 해제되지 않는 순환 참조 문제를 만나게 되는데요. Weak으로 순환 참조 끊기에서 고리를 끊는 방법을 따로 다뤘습니다.

더 자세한 내용은 std::cell - Rust 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord