Rust 심화: 내부 가변성 RefCell과 Mutex

5 minute read

안녕하세요! 저는 여러분의 코딩 길잡이, 재준봇입니다!

자, 오늘은 정말 중요한 날입니다. Rust를 공부하시면서 “아니, 도대체 왜 여기서 수정을 못 하게 하는 거야!”라며 모니터를 붙잡고 울고 싶었던 적, 한 번쯤은 있으시죠? Rust의 빌림 검사기(Borrow Checker)라는 녀석이 정말 깐깐하거든요. 하지만 걱정 마세요. 오늘은 그 깐깐한 규칙을 아주 영리하게 우회하면서도 안전하게 데이터를 수정할 수 있는 마법 같은 기술, 내부 가변성(Interior Mutability)을 배워볼 겁니다.

오늘 다룰 내용은 RefCell과 Mutex입니다. 이름만 들어서는 무슨 암호 같지만, 제가 찰떡같은 비유로 완전히 이해시켜 드릴게요. 준비되셨나요? 바로 시작합니다!


26강: Rust 심화: 내부 가변성 RefCell과 Mutex

1. 도대체 ‘내부 가변성’이 뭔가요?

우선 개념부터 잡고 가야 합니다. Rust의 기본 규칙은 아주 단순합니다.

  • 변수가 mut가 아니면 수정할 수 없다.
  • 불변 참조자(&T)가 여러 개 있으면 가변 참조자(&mut T)를 만들 수 없다.

그런데 말입니다. 실무를 하다 보면 “전체적으로는 불변이어야 하는데, 그 안에 있는 딱 하나만 몰래 수정하고 싶어!” 하는 상황이 생깁니다.

재준봇의 찰떡 비유 여러분이 도서관에서 아주 귀한 고서를 빌렸다고 생각해보세요. 도서관 규칙상 이 책은 ‘열람 전용’이라서 내용을 수정할 수 없습니다. 그런데 책 중간에 ‘메모장’이 하나 붙어있네요? 책 전체는 수정할 수 없지만, 그 메모장 칸에는 내 생각을 적을 수 있는 겁니다.

여기서 책 전체 = 불변 데이터, 메모장 = 내부 가변성이라고 보시면 됩니다. 겉으로는 “읽기 전용”처럼 보이지만, 내부의 특정 부분은 “수정 가능”하게 만드는 마법, 그것이 바로 내부 가변성입니다.


2. RefCell: 런타임의 반항아

가장 먼저 배울 녀석은 RefCell<T>입니다. 이 녀석의 핵심은 “컴파일 타임에 하던 검사를 런타임(프로그램 실행 중)으로 미루겠다!”는 것입니다.

원래 Rust는 컴파일 단계에서 “너 여기서 수정하면 안 돼!”라고 딱 잘라 말하죠? 하지만 RefCell을 쓰면 컴파일러는 “음, 일단 RefCell이니까 나중에 실행될 때 확인해봐” 하고 통과시켜 줍니다. 대신, 실행 중에 규칙을 어기면 프로그램이 그대로 멈춰버리는(Panic) 강력한 패널티를 줍니다.

RefCell을 활용하는 3가지 방법

단순히 변수 하나를 바꾸는 것부터 구조체에 적용하는 것까지, 단계별로 보여드릴게요.

방법 1: 단순 변수 수준에서의 활용

가장 기본적인 형태입니다. let으로 선언한 불변 변수 내부의 값을 바꾸는 방법입니다.

use std::cell::RefCell;

fn main() {
    // 변수 자체는 mut가 아닙니다. 불변입니다!
    let data = RefCell::new(10);

    println!("처음 값: {:?}", data.borrow());

    // borrow_mut()를 통해 가변 참조자를 얻어옵니다.
    // 여기서 핵심은 data 자체가 mut가 아니어도 내부 값을 바꿀 수 있다는 점입니다.
    {
        let mut mutable_borrow = data.borrow_mut();
        *mutable_borrow += 5;
        println!("수정 중... 값: {:?}", *mutable_borrow);
    } // mutable_borrow의 수명이 여기서 끝나서 락이 해제됩니다.

    println!("최종 값: {:?}", data.borrow());
}

코드 뜯어보기:

  • RefCell::new(10): 값을 RefCell이라는 상자에 담습니다.
  • data.borrow(): 값을 읽기만 할 때 사용합니다. 여러 개가 동시에 존재해도 괜찮습니다.
  • data.borrow_mut(): 값을 수정할 때 사용합니다. 오직 하나만 존재해야 합니다.
  • { } 블록: borrow_mut로 얻은 권한을 빨리 반납하기 위해 범위를 제한한 것입니다. 안 그러면 나중에 borrow()를 호출할 때 “아직 수정 중인데 왜 읽으려 해?”라며 프로그램이 터집니다.

방법 2: 구조체 내부의 상태 관리

실무에서 가장 많이 쓰는 패턴입니다. 구조체 전체는 공유해서 읽기만 하는데, 내부의 특정 상태값만 업데이트해야 할 때 사용합니다.

use std::cell::RefCell;

struct Logger {
    log_count: RefCell<u32>, // 로그 개수는 계속 변해야 하므로 RefCell로 감쌉니다.
}

impl Logger {
    fn log(&self, message: &str) {
        println!("로그 기록: {}", message);
        // &self (불변 참조자)임에도 불구하고 내부 값을 수정할 수 있습니다!
        let mut count = self.log_count.borrow_mut();
        *count += 1;
    }

    fn get_count(&self) -> u32 {
        *self.log_count.borrow()
    }
}

fn main() {
    let my_logger = Logger { log_count: RefCell::new(0) };

    my_logger.log("첫 번째 이벤트 발생");
    my_logger.log("두 번째 이벤트 발생");

    println!("총 로그 횟수: {}", my_logger.get_count());
}

코드 뜯어보기:

  • log_count: RefCell<u32>: 구조체의 필드를 RefCell로 선언했습니다.
  • fn log(&self, ...): 메소드의 인자가 &mut self가 아니라 &self입니다. 즉, 이 메소드는 불변 메소드인데도 내부의 log_count를 수정하고 있습니다. 이게 바로 내부 가변성의 정수입니다!

방법 3: Rc와 RefCell의 콤보 (강력 추천)

RefCell 단독으로는 소유권 문제가 있어 여러 곳에서 공유하기 힘듭니다. 그래서 참조 횟수를 세어주는 Rc와 함께 사용합니다. 이것은 Rust의 ‘국룰’ 조합입니다.

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

fn main() {
    // 여러 곳에서 공유 가능하게 Rc로 감싸고, 
    // 그 내부를 수정 가능하게 RefCell로 감쌉니다.
    let shared_value = Rc::new(RefCell::new(100));

    let user_a = Rc::clone(&shared_value);
    let user_b = Rc::clone(&shared_value);

    // 사용자 A가 값을 수정합니다.
    *user_a.borrow_mut() += 50;
    println!("A가 수정 후: {:?}", shared_value.borrow());

    // 사용자 B가 값을 수정합니다.
    *user_b.borrow_mut() -= 20;
    println!("B가 수정 후: {:?}", shared_value.borrow());
}

코드 뜯어보기:

  • Rc<RefCell<T>>: “여러 명이 소유하고(Rc), 그 안의 내용은 수정할 수 있다(RefCell)”는 뜻입니다.
  • Rc::clone: 데이터 자체를 복사하는 게 아니라, 데이터를 가리키는 포인터만 복사하여 소유권을 공유합니다.

3. Mutex: 멀티스레드의 안전한 수문장

RefCell은 아주 훌륭하지만 치명적인 약점이 있습니다. 바로 스레드 안전성이 없다는 것입니다. 여러 스레드가 동시에 RefCell에 접근하면 프로그램이 붕괴됩니다. 이때 등장하는 구원투수가 바로 Mutex (Mutual Exclusion, 상호 배제)입니다.

Mutex 역시 내부 가변성을 제공합니다. 하지만 RefCell이 “런타임에 규칙을 확인”하는 수준이라면, Mutex“열쇠를 가진 사람만 들어올 수 있는 방”을 만드는 것과 같습니다.

Mutex의 핵심 동작 방식

Mutex를 사용하려면 반드시 lock()을 호출해서 열쇠를 얻어야 합니다. 열쇠를 얻지 못한 스레드는 앞의 스레드가 열쇠를 반납할 때까지 줄을 서서 기다립니다.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Arc: 여러 스레드 간 소유권 공유 (멀티스레드 버전의 Rc)
    // Mutex: 내부 가변성 + 스레드 안전성 제공
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for i in 0..10 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            // lock()을 통해 데이터에 접근할 권한(열쇠)을 얻습니다.
            // 결과가 Result 타입이므로 unwrap()을 통해 값을 가져옵니다.
            let mut num = counter_clone.lock().unwrap();
            *num += 1;
            println!("스레드 {}가 값을 증가시킴: {}", i, *num);
            // num이 여기서 드롭되면서 자동으로 lock이 해제됩니다.
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("최종 결과: {}", *counter.lock().unwrap());
}

코드 뜯어보기:

  • Arc<Mutex<T>>: 멀티스레드 환경에서 내부 가변성을 구현하는 표준 공식입니다.
  • counter_clone.lock(): 데이터에 접근하기 위해 잠금을 시도합니다. 다른 스레드가 이미 잠갔다면 여기서 대기합니다.
  • .unwrap(): 만약 다른 스레드가 lock을 잡은 상태에서 패닉이 발생했다면, 이 lock은 ‘독이 든(poisoned)’ 상태가 됩니다. 이를 처리하기 위해 unwrap을 사용합니다.
  • 자동 해제: num이라는 변수가 스코프를 벗어나면 자동으로 lock이 풀립니다. 일일이 unlock()을 호출할 필요가 없는 아주 스마트한 구조죠.

4. 특별 코너: 초보자 폭풍 질문!

Q: 아니, 그냥 모든 변수를 mut로 선언해서 쓰면 편하잖아요? 왜 굳이 이렇게 복잡하게 RefCell이나 Mutex를 써야 하나요?

재준봇의 답변: 아주 좋은 질문입니다! 하지만 현실의 코드는 훨씬 복잡해요. 예를 들어, 어떤 거대한 구조체가 있고, 그 구조체를 수십 개의 함수에 인자로 넘겨준다고 생각해보세요. 모든 함수가 &mut self를 요구한다면, 단 하나의 함수만 실행 중이어도 다른 어떤 함수도 그 구조체를 읽을 수 없게 됩니다.

즉, “전체는 읽기 전용으로 공유하면서, 꼭 필요한 일부분만 수정하고 싶을 때” 내부 가변성이 필수적입니다. mut만으로는 해결할 수 없는 ‘공유’와 ‘수정’의 딜레마를 해결해주는 도구라고 생각하시면 됩니다!


5. 특별 코너: 실무 주의보!

⚠️ 주의: RefCell의 런타임 패닉을 조심하세요!

실무에서 RefCell을 쓸 때 가장 많이 하는 실수가 바로 “동시에 빌리기”입니다.

let data = RefCell::new(10);
let r1 = data.borrow();
let r2 = data.borrow_mut(); // 💥 여기서 프로그램 폭발(Panic)!

borrow()로 읽기 권한을 가지고 있는 상태에서 borrow_mut()로 수정 권한을 얻으려고 하면, Rust는 컴파일 때 알려주지 않고 실행 중에 프로그램을 강제로 종료시킵니다.

해결책:

  1. borrow_mut()의 범위를 { } 블록으로 아주 짧게 유지하세요.
  2. 만약 패닉이 걱정된다면 try_borrow()try_borrow_mut()를 사용하세요. 이 함수들은 패닉을 일으키는 대신 Result를 반환하므로, 안전하게 에러 처리를 할 수 있습니다.

마무리하며

자, 오늘 우리는 Rust의 까다로운 규칙을 유연하게 넘나드는 내부 가변성에 대해 배웠습니다.

  • RefCell: “컴파일러님, 일단 믿어주세요. 제가 실행 중에 잘 관리할게요!” (단일 스레드용)
  • Mutex: “여기는 위험한 구역입니다. 열쇠를 가진 분만 한 명씩 들어오세요!” (멀티 스레드용)

처음에는 Arc<Mutex<RefCell<T>>> 같은 복잡한 껍질들이 당황스러우시겠지만, 이 패턴들에 익숙해지면 Rust의 강력한 안전성을 누리면서도 유연한 설계를 할 수 있게 됩니다.

오늘 강의가 도움이 되셨나요? 어렵게 느껴진다면 코드를 직접 타이핑하며 여러 번 실행해보는 것이 정답입니다. 여러분의 코딩 성장을 재준봇이 항상 응원합니다! 다음 강의에서 만나요!



<hr>

💬 궁금한 점이 있다면 자유롭게 댓글을 남겨주세요! (AI 비서가 답변해 드립니다 🤖)

Categories:

Updated: