스마트 포인터(Smart Pointer)

Rust는 메모리 안전성을 보장하기 위해 소유권 시스템을 사용하며, 여기에 스마트 포인터라이프타임이 핵심 역할을 합니다. 스마트 포인터는 데이터를 가리키는 기능이외에 소유권, 참조 카운팅 등 추가 기능을 포함하며, Box<T>, Rc<T>, RefCell<T>에 대해 설명하겠습니다.


1. 스마트 포인터(Smart Pointer)란?

  • 일반 포인터처럼 데이터를 가리키지만, 추가적인 기능(소유권, 참조 카운팅 등)을 포함합니다.
  • 표준 스마트 포인터의 종류에는 아래 세 가지가 있습니다.
    1. Box<T>: 일반 포인터는 스택에 저장되는데, Box는 힙에 데이터를 저장합니다.
    2. Rc<T>: 참조 카운팅(reference counting)을 통한 공유
    3. RefCell<T>: 런타임 시점 가변성 검사

2. 스마트 포인터의 종류

가. Box<T> – 힙에 저장

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}
  • let b = Box::new(5);
    5는 i32 타입이므로 스택에 저장되는 것인데,
    Box::new를 통해 힙에 저장하고, 변수 b에 할당하므로
    변수 b는 Box<i32> 타입이 되며, 힙에 있는 5를 가리킵니다.
  • println!(“b = {}”, b);
    Box는 Deref(역참조) trait을 구현하고 있어서,
    자동으로 Box 안의 값을 꺼내서 5를 출력해 줍니다.

변수 b는 Box 타입이 되며, 힙에 있는 5를 가리킵니다.

  • 박스는 여러분이 데이터를 스택이 아니라 힙에 저장할 수 있도록 해줍니다.
  • 구조체 간 재귀 타입 정의 시 유용합니다.
  • 아래와 같이 재귀 타입의 enum 정의시, 다시 말해 List가 List를 포함하고 잇으므로, 컴파일 타임에서는 List 타입의 크기를 알 수 없으므로 아래를 실행하면
enum List {
    Cons(i32, List),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

error[E0072]: recursive type ‘List’ has infinite size
(에러[E0072]: 재귀 타입 List가 무한한 크기를 가지고 있습니다)
란 에러 메시지가 나옵니다.

재귀 타입 열거형 라이프타임 오류

따라서, 아래와 같이 List를 Box로 감싸야 합니다.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

Box를 사용해서 완성한 코드는 아래와 같습니다.

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1,
        Box::new(Cons(2,
            Box::new(Cons(3,
                Box::new(Nil))))));
    println!("list = {:?}", list);
}
  • List 열거형을 출력하기 위해서 #[derive(Debug)]를 추가해야 하며, println!에서는 {:?}로 출력 포맷을 지정해야 합니다.
  • Box::new로 List를 구현합니다.

나. Rc<T> – 여러 소유자 공유

어떤 값이 계속 사용되는지 아니면 그렇지 않은지를 알기 위해 해당 값에 대한 참조자의 갯수를 계속 추적하는 것입니다. 만일 값에 대한 참조자가 0개라면, 그 값은 어떠한 참조자도 무효화하지 않고 메모리에서 정리될 수 있습니다.

우리 프로그램의 여러 부분에서 읽을 데이터를 힙에 할당하고, 어떤 부분이 그 데이터를 마지막에 이용하게 될지 컴파일 타임에는 알 수 없는 경우 Rc 타입을 사용합니다. 만일 어떤 부분이 마지막으로 사용할지 컴파일 타임에 알 수 있다면 보통의 소유권 규칙이 적용됩니다.

use std::rc::Rc;

fn main() {
    let a = Rc::new(10);
    let b = Rc::clone(&a);
    println!("a = {}, b = {}", a, b);
}
  • use std::rc::Rc;
    Rc는 프렐루드(prelude, 프렐루드는 Rust가 모든 Rust 프로그램에 자동으로 가져오는 항목들의 목록입니다. )에 포함되어 있지 않으므로 use 구문을 추가했습니다. Box는 use를 사용하지 않는 것과 대비됩니다.
  • let a = Rc::new(10);
    10을 Rc에 저장한 후 a에 할당합니다.
    따라서, a는 Rc<i32> 타입이 되고, 다시 말하면 a는 10을 가리키는 참조 카운팅 스마트 포인터입니다.
    이 경우 참조 카운트는 1입니다.
  • let b = Rc::clone(&a);
    a.clone()을 호출할 수도 있지만, 러스트의 관례는 Rc::clone(&a)를 사용합니다. Rc::clone은 clone 이 하는 것처럼 깊은 복사 (deep copy) 를 만들지 않고, 오직 참조 카운트만 증가 시켜 큰 시간이 들지 않습니다.
    이제 참조 카운트는 2가 됩니다.
  • println!(“a = {}, b = {}”, a, b);
    Rc는 Deref(역참조) trait을 구현해서 자동으로 &T처럼 동작하므로 내부 값이 출력됩니다.
    출력 값은 a = 10, b = 10입니다.
스마트 포인터(Rc)
스마트 포인터(Rc)
  • 단일 스레드 환경에서만 사용가능합니다.
  • 참조수는 Rc::strong_count(&a)로 확인 가능합니다.

다. RefCell<T> – 런타임 내부 가변성

내부 가변성 (interior mutability) 은 어떤 데이터에 대한 불변 참조자가 있을 때라도 여러분이 데이터를 변형할 수 있게 해주는 러스트의 디자인 패턴입니다. 보통 이러한 동작은 빌림 규칙에 의해 허용되지 않습니다.

불변 및 가변 참조자를 만들때, 우리는 각각 & 및 &mut 문법을 사용합니다. RefCell을 이용할때는 borrow와 borrow_mut 메소드를 사용하는데, 이들은 RefCell이 소유한 안전한 API 중 일부입니다. borrow 메소드는 스마트 포인터 타입인 Ref<T>를 반환하고, borrow_mut는 스마트 포인터 타입 RefMut<T>를 반환합니다. 두 타입 모두 Deref를 구현하였으므로 우리는 이들을 보통의 참조자처럼 다룰 수 있습니다.

만일 런타임 시 빌림 규칙을 위반한다면, RefCell의 구현체는 panic!을 일으킵니다.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(1);
    *data.borrow_mut() += 1;
    println!("data = {:?}", data.borrow());
}
  • let data = RefCell::new(1);
    1이라는 값을 감싼 RefCell<i32>를 생성한 후 data에 할당하므로, data는 RefCell<i32> 타입이며, 내부에 1을 가지고 있습니다.
    data는 mut가 없으므로 불변 참조입니다.
  • *data.borrow_mut() += 1;
    .borrow_mut()은 RefCell<i32>인 data의 내부 값을 가변 참조(mutably borrow) 하겠다는 의미입니다.

    * 연산자는 실제 값을 꺼내는 역할을 합니다.
    따라서, 이 코드는 data의 내부값을 꺼내서 1을 더한다는 의미입니다.

    이 함수는 런타임에 현재 가변 참조가 가능한지 검사해서
    이미 다른 가변/불변 참조가 있으면 panic!이 발생합니다.
  • println!(“data = {:?}”, data.borrow());
    .borrow()는 내부 값을 불변 참조(immutably borrow)하겠다는 뜻입니다.
    {:?}는 Debug 포맷 출력입니다.
    이 시점에서 내부 값은 2입니다.
    따라서, 출력 결과는
    data = 2 입니다.

Box와 Rc는 컴파일 타임 불변성, RefCell은 런타임 가변성


3. Box<T>, Rc<T>, RefCell<T> 비교

  • Rc<T>는 동일한 데이터에 대해 복수개의 소유자를 가능하게 하지만, Box<T>와 RefCell<T>는 단일 소유자만 갖습니다.
  • Box<T>는 컴파일 타임에 검사된 불변 혹은 가변 빌림을 허용하고, Rc<T>는 오직 컴파일 타임에 검사된 불변 빌림만 허용하지만, RefCell<T>는 런타임에 검사된 불변 혹은 가변 빌림을 허용합니다.
  • RefCell<T>가 런타임에 검사된 가변 빌림을 허용하기 때문에, RefCell<T>가 불변일 때라도 RefCell<T> 내부의 값을 변경할 수 있습니다.

4. 요약 정리

항목설명
Box<T>힙에 값 저장, 단일 소유자
Rc<T>다중 소유자 참조 카운팅, 단일 스레드만 지원
RefCell<T>내부 가변성 제공, 런타임시 체크

변수, 불변성, 데이터 타입

Rust는 안전성을 위해 변수는 기본적으로 불변(immutable) 입니다. 따라서, 필요한 경우 변수의 값을 변경하려면 명시적으로 가변(mut)으로 선언해야 합니다. 변수 선언 시 let을 사용하고, 스칼라 타입은 숫자, 불리언, 문자가 있으며, 타입을 지정해야 하는 정적 타입 언어이나 타입 추론이 가능합니다.


🔹 준비

Code의 터미널 창에서 D:\rust-practice 폴더로 이동한 다음
cargo new day2라고 입력해서 day2 패키지를 만들고
cd day2라고 입력해서 day2 폴더로 이동합니다.

main.rs는 day2 아래 src 폴더에 있습니다.

🔹 변수 선언

fn main() {
let x = 5;
println!("x의 값은: {}", x);
}
  • let 키워드는 변수를 선언합니다.
  • x는 기본적으로 불변 변수입니다. 변수인데 불변이라고 하니 Rust는 참 독특한 개념의 언어입니다.
  • {}는 print, 다시 말해 출력 시 변수가 위치할 자리(placeholder)입니다.

실행

위 코드를 복사해서 에디터의 main.rs의 내용을 덮어쓰고 저장한 다음
cargo run을 실행하면
출력되는 값은 “x의 값은: 5″입니다.

출력 구문 변경

중괄호안에 변수를 넣어
println!(“x의 값은: {x}”);
과 같이 구문을 바꿀 수 있습니다.

main.rs 위의 run 실행

Code를 닫았다가 열면 rust-analyzer가 다시 실행되면서 아래와 같이 main.rs위에 Run|Debug가 생기므로

main.rs 위에 run 명령어가 보이고, 이를 눌러 rust 패키지를 실행할 수 있다.
흰색 동그라미는 파일이 저장되지 않았다는 의미

run을 눌러 실행할 수 있습니다.

cargo run을 할 때는 먼저 파일을 저장해야 하는데 위 run을 누르면 자동으로 파일을 저장하고, cargo run을 실행하므로 너무 편리합니다.

🔁 값을 바꾸려면?

fn main() {
let x = 5;
x = 6; // 에러! 기본 변수는 값 변경 불가
}

오류 메시지: cannot assign twice to immutable variable x
(불편 변수인 x에 값을 두 번 할당할 수 없다)

//는 주석 표시입니다. 다시 말해 // 다음의 문장은 프로그램 실행 시 제외됩니다.

기존 구문을 주석 처리하고 위 구문을 추가할 때
해당하는 2줄을 마우스로 끌어서 선택하고 Ctrl + /키를 누르면 맨 앞에 // 표시가 추가됩니다.

fn main() {
    // let x = 5;
    // println!("x의 값은: {x}");

    let x = 5;
    x = 6; // 에러! 기본 변수는 값 변경 불가    
}

🔧 가변 변수 (mutable)

fn main() {
let mut x = 5;
println!("처음 x: {x}");
x = 6;
println!("바뀐 x: {x}");
}
  • mut 키워드를 사용하면 값 변경이 가능합니다.

실행

실행하면 화면에 아래와 같이 출력됩니다.
처음 x: 5
바뀐 x: 6


🧮 스칼라 타입(Scalar Types)

스칼라(scalar)는 하나의 값으로 표현되는 타입입니다. Rust는 정수(integer), 부동소수형 숫자(floating-point numbers), boolean(논리형), 그리고 문자(char)라는 네 가지 타입을 보유하고 있습니다. Rust는 정적 타입 언어이며, 대부분 타입은 명시하지 않아도 추론됩니다.

정적 타입 언어는 컴파일 시 변수 타입이 결정되는 언어이고, 동적 타입 언어는 런타임 시 변수 타입이 결정되는 언어, 다시 말해, 코드를 실행할 때 알아서 변수 타입을 판단해주는 언어입니다. 그러나 Rust는 정적 타입 언어임에도 변수 타입을 프로그램에서 지정하지 않더라도 변수 타입을 추론한다는 것이 다른 점입니다.

숫자형

정수형은 소수점이 없는 숫자이고, 부동소수형은 소수점이 있는 숫자입니다. 3을 부동소수형으로 표현할 경우 소수점이하가 0이더라도 3.0이라고 써야지 3으로 쓰면 안되며, 최소한 3.이라고 소수점을 찍어야 합니다.

위 코드에서 let a = 10;이라고 변수에 정수를 대입하면 : i32를 입력하지 않아도 rust analyzer가 : i32를 알아서 추가해서 표시합니다. 3.14도 마찬가지로 : f64라는 타입을 자동으로 표시합니다. 이것이 추론입니다.

: i32 또는 : f64가 없어도 프로그램 실행에 전혀 문제가 없으며, 직접 입력하려면 : i32라고 입력해야 합니다. 아래와 같이 Code에서 타입을 입력한 것은 녹색으로 보이고, 추론한 것은 회색으로 보입니다.

숫자형의 종류

불리언 & 문자

let t: bool = true;
let c: char = 'R'; // 유니코드 문자

마찬가지로 : bool을 입려하지 않아도 값이 true이므로 boolean으로 추론되고, 문자 하나를 입력했으므로 : char로 자동으로 표시됩니다. 이때 문자는 반드시 작은따옴표 안에 입력해야 하며, “R”로 바꾸면 에러가 발생합니다.

파이썬은 자유롭게 작은따옴표 또는 큰따옴표를 사용할 수 있는 것과 다릅니다.


※ 문자 한 개(char)가 아니라 “I am a boy”과 같은 문자열은 String 타입으로 구분되므로 나중에 별도로 다루도록 하겠습니다.


📌 변수 섀도잉(Shadowing)

Rust에서는 같은 이름의 변수로 새로운 값을 선언할 수 있습니다.

fn main() {
let x = 5;
let x = x + 1;
let x = x * 2;
println!("x의 값: {x}"); // 12
}
  • let을 반복 사용하면 기존 변수를 덮어쓰는(shadowing) 것처럼 새 변수 선언이 가능합니다.
  • 타입을 변경하는 것도 허용됩니다. 아래는 x가 i32 형식이었다가 char 형식으로 변경된 것을 보여줍니다.

‘R’; 다음의 주석 “// 변수 재정의 가능”도 입력한 것이 아니고 rust analyzer가 알아서 입력한 것입니다. 참 똑똑합니다.

위 코드의 출력 값은
x의 값: 12 (x는 5 -> 6 -> 12가 됨)


🧠 정리

  • 변수는 기본 불변 → 변경하려면 mut 사용
  • 정적 타입 → 타입을 명시하는 것이 원칙이지만 타입 추론 가능
  • Shadowing으로 가변처럼 활용 가능하나, 메모리 안정성을 유의해서 사용하지 않는 것이 바람직함