Rust 심화: 특수 타입과 Unsafe Rust

6 minute read

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

자, 여러분! 드디어 우리가 Rust라는 거대한 산의 정상 부근에 도달했습니다. 지금까지는 Rust가 정해준 규칙, 즉 컴파일러라는 아주 깐깐한 선생님의 지도 아래에서 공부했죠? “여기서는 안 돼!”, “그건 위험해!”라는 잔소리를 정말 많이 들으셨을 겁니다.

하지만 오늘 배울 내용은 조금 다릅니다. 오늘은 Rust의 ‘금기 구역’과 ‘특별 관리 구역’을 살펴볼 거예요. 바로 특수 타입과 Unsafe Rust입니다. 이 내용은 쉽게 말해, “선생님, 저 이제 어느 정도 익숙해졌으니 제멋대로 조금만 해볼게요!”라고 선언하는 것과 같습니다. 물론, 제멋대로 하다가 사고가 나면 그 책임은 오롯이 본인이 지는 무시무시한 영역이죠.

하지만 걱정 마세요. 저 재준봇이 여러분이 벼랑 끝으로 떨어지지 않게 찰떡같은 비유로 안전하게 안내해 드릴게요. 준비되셨나요? 바로 시작합니다!

30강: Rust 심화 - 특수 타입과 Unsafe Rust


1. 특수 타입: Rust의 비밀 병기들

Rust에는 일반적인 정수, 실수, 불리언 외에도 아주 특별한 역할을 수행하는 타입들이 있습니다. 가장 대표적인 것이 바로 슬라이스(Slice)와 스마트 포인터(Smart Pointers)입니다.

슬라이스(Slice)란 무엇인가?

슬라이스는 쉽게 비유하자면 ‘책갈피’ 같은 겁니다. 책 전체를 다 가지고 있을 필요 없이, “여기서부터 여기까지가 내가 필요한 부분이야”라고 딱 집어주는 것이죠. 메모리의 전체 소유권을 가져오는 것이 아니라, 특정 범위의 데이터를 ‘참조’만 하는 것입니다.

여기서 우리는 문자열 슬라이스(&str)와 배열 슬라이스(&[T])를 배울 것입니다.

[코드 예제 1] 슬라이스의 세 가지 활용법

슬라이스를 어떻게 사용하는지 세 가지 다른 관점에서 구현해 보겠습니다.

fn main() {
    // 1. 문자열 슬라이스 (&str)
    // 전체 문자열 중 일부만 '빌려오는' 방식입니다.
    let s = String::from("Hello Rust World");
    let hello: &str = &s[0..5]; // 0번째부터 5번째 전까지 딱 집어냅니다.
    println!("인사말: {}", hello); 
    // 설명: s라는 전체 데이터에서 0~4 인덱스만 가리키는 '책갈피'를 만든 것입니다.

    // 2. 배열 슬라이스 (&[T])
    // 고정된 배열에서 특정 구간을 참조합니다.
    let numbers = [10, 20, 30, 40, 50];
    let slice: &[i32] = &numbers[1..4]; // 20, 30, 40 부분만 참조합니다.
    println!("배열 일부: {:?}", slice);
    // 설명: 배열 전체를 복사하는 것이 아니라, 메모리 주소와 길이를 저장해 효율적입니다.

    // 3. 가변 슬라이스 (&mut [T])
    // 참조만 하는 것이 아니라, 빌려온 구간의 내용을 직접 수정합니다.
    let mut mutable_nums = [1, 2, 3, 4, 5];
    let mut_slice = &mut mutable_nums[0..2]; 
    mut_slice[0] = 100; // 첫 번째 요소를 100으로 바꿉니다.
    println!("수정된 배열: {:?}", mutable_nums);
    // 설명: &mut를 사용하면 Rust의 대여 규칙을 준수하면서 특정 구간만 수정할 수 있습니다.
}

재준봇의 뜯어보기 설명

  • &s[0..5]: 이것은 ‘범위 표현식’입니다. 시작과 끝을 지정해 데이터의 일부만 떼어내는 것이죠.
  • &[i32]: 타입 이름에 &가 붙고 []가 들어갔죠? 이것은 “i32 타입들이 모여있는 곳의 일부를 가리키는 포인터”라는 뜻입니다.
  • &mut: Rust는 기본적으로 불변입니다. 하지만 mut를 붙여줌으로써 “내가 이 구간을 잠시 수정할게!”라고 컴파일러에게 허락을 구하는 것입니다.

2. 스마트 포인터: 단순한 주소가 아닌 ‘지능형’ 포인터

일반적인 참조자(&)는 단순히 데이터가 어디 있는지 알려주는 역할만 합니다. 하지만 스마트 포인터는 그 이상의 기능을 수행합니다. 예를 들어, 데이터의 수명을 관리하거나, 여러 곳에서 동시에 데이터를 소유하게 해주는 마법을 부리죠.

가장 많이 쓰이는 세 가지 스마트 포인터를 소개합니다: Box<T>, Rc<T>, Arc<T>.

[코드 예제 2] 스마트 포인터 3형제 구현하기

상황에 따라 어떤 포인터를 써야 하는지 코드로 보여드릴게요.

use std::rc::Rc;
use std::sync::Arc;
use std::thread;

fn main() {
    // 1. Box<T>: 힙(Heap)에 데이터를 저장하고 싶을 때
    // 스택이 아니라 힙에 딱 하나만 존재하게 만들어 소유권을 명확히 합니다.
    let b = Box::new(500);
    println!("Box 값: {}", b);
    // 설명: Box는 데이터를 힙에 넣고 그 주소만 스택에 저장합니다. 크기가 매우 큰 데이터일 때 유리합니다.

    // 2. Rc<T>: 참조 횟수 계산 (Reference Counting)
    // 한 데이터를 여러 곳에서 '공동 소유'하고 싶을 때 사용합니다. (단일 스레드용)
    let shared_data = Rc::new(String::from("공유 데이터"));
    let a = Rc::clone(&shared_data);
    let b = Rc::clone(&shared_data);
    println!("Rc 소유자 수: {}, 값: {}", Rc::strong_count(&shared_data), a);
    // 설명: Rc::clone은 데이터를 복사하는 게 아니라, "나도 이거 같이 쓸게"라고 카운트만 올리는 것입니다.

    // 3. Arc<T>: 원자적 참조 횟수 계산 (Atomic Reference Counting)
    // Rc와 같지만, '멀티 스레드' 환경에서도 안전하게 공유할 수 있습니다.
    let safe_data = Arc::new(100);
    let data_clone = Arc::clone(&safe_data);

    let handle = thread::spawn(move || {
        println!("다른 스레드에서 읽은 값: {}", data_clone);
    });
    
    handle.join().unwrap();
    println!("메인 스레드 값: {}", safe_data);
    // 설명: Arc의 A는 Atomic(원자적)을 의미합니다. 여러 스레드가 동시에 카운트를 올려도 꼬이지 않게 보장합니다.
}

재준봇의 뜯어보기 설명

  • Box::new(): 데이터를 힙 영역으로 강제 이주시키는 명령입니다. 재귀적인 구조(예: 트리)를 만들 때 필수적입니다.
  • Rc::clone(): 주의하세요! clone()이라고 해서 데이터 전체를 복제하는 것이 아닙니다. “참조 카운트”라는 숫자만 하나 올리는 아주 가벼운 작업입니다.
  • Arc<T>: 멀티 스레딩으로 넘어가면 Rc는 사용 불가능합니다. 이때 Arc를 사용해야 컴파일러가 “오케이, 스레드 간 공유 안전해!”라고 허락해 줍니다.

3. Unsafe Rust: 금단의 영역에 발을 들이다

자, 이제 대망의 Unsafe Rust입니다. Rust의 가장 큰 자랑은 “메모리 안전성”이죠? 하지만 세상에는 Rust의 엄격한 규칙으로는 도저히 해결할 수 없는 일들이 있습니다. 예를 들어, 하드웨어 제어, C 언어로 짜인 라이브러리 호출, 혹은 성능을 극한으로 끌어올려야 하는 경우입니다.

이때 사용하는 것이 unsafe 블록입니다. unsafe 블록 안에 코드를 넣으면, Rust 컴파일러는 “알았어, 이제부터는 네가 다 책임져. 나는 간섭 안 할게”라며 눈을 감아버립니다.

Unsafe Rust로 할 수 있는 5가지 (핵심 3가지 집중)

  1. 원시 포인터(Raw Pointers) 역참조
  2. unsafe 함수나 트레이트 메서드 호출
  3. 가변 정적 변수(mutable static variables) 수정
  4. Unsafe 트레이트 구현
  5. Union 접근

[코드 예제 3] Unsafe Rust의 위험한 활용법

실제로 어떻게 작동하는지 세 가지 케이스로 보여드리겠습니다.

fn main() {
    // 1. 원시 포인터 (Raw Pointers)
    // 일반 참조자와 달리, 널(null)일 수도 있고 유효하지 않은 주소일 수도 있습니다.
    let mut num = 10;
    let r1 = &num as *const i32; // 불변 원시 포인터
    let r2 = &mut num as *mut i32; // 가변 원시 포인터

    unsafe {
        println!("원시 포인터 값 읽기: {}", *r1);
        *r2 = 20; // 역참조하여 값 변경
        println!("원시 포인터로 변경된 값: {}", *r2);
    }
    // 설명: *const, *mut는 원시 포인터 타입입니다. 이를 사용해 실제 값에 접근(역참조)하려면 반드시 unsafe 블록이 필요합니다.

    // 2. Unsafe 함수 호출
    // 외부 C 라이브러리 함수나 Rust의 unsafe 함수를 호출합니다.
    unsafe {
        // 실제로는 없는 함수지만, 이런 식으로 외부 라이브러리를 호출할 때 씁니다.
        // call_some_c_library_function(); 
        println!("위험한 함수 영역을 통과했습니다.");
    }
    // 설명: 컴파일러가 안전성을 보장할 수 없는 외부 코드를 실행할 때 사용합니다.

    // 3. 가변 정적 변수 접근
    // 프로그램 전체에서 공유되는 전역 변수를 수정하는 것은 매우 위험합니다.
    unsafe {
        // static mut 변수는 여러 스레드가 동시에 건드릴 수 있어 unsafe 영역에서만 가능합니다.
        // (실제 구현 시에는 static mut 전역 변수를 선언해야 함)
        println!("전역 상태 수정 시도 중...");
    }
    // 설명: 데이터 경합(Data Race)이 발생할 가능성이 매우 높기 때문에 Rust가 엄격히 제한하는 영역입니다.
}

재준봇의 뜯어보기 설명

  • *const T / *mut T: 우리가 알던 &T와는 완전히 다릅니다. 이건 그냥 ‘메모리 주소 숫자’일 뿐입니다. 그 주소에 진짜 데이터가 있는지, 아니면 쓰레기 값이 들어있는지 Rust는 확인하지 않습니다.
  • unsafe { ... }: 이 블록은 “여기서 일어나는 모든 메모리 오류(Segmentation Fault 등)는 내 책임이다”라는 서약서와 같습니다.
  • 역참조(*r1): 포인터가 가리키는 실제 값을 가져오는 행위입니다. 이게 가장 위험하기 때문에 unsafe가 강제됩니다.

🚀 초보자 폭풍 질문!

질문 1: “선생님! &str이랑 String 차이가 계속 헷갈려요. 그냥 둘 다 문자열 아닌가요?”

재준봇의 답변: 아하, 이거 진짜 많이 헷갈리는 부분이죠! 이렇게 생각하세요. String은 ‘내 소유의 커다란 스케치북’입니다. 내용을 지울 수도 있고, 더 그려 넣을 수도 있죠. 반면 &str은 그 스케치북의 특정 페이지를 가리키는 ‘손가락’입니다. 손가락으로는 내용을 수정할 수 없죠? 그냥 “여기 이거 봐!”라고 알려주는 역할만 하는 겁니다. 소유권이 있느냐, 아니면 그냥 빌려와서 보느냐의 차이라고 보시면 됩니다!

질문 2: “Unsafe Rust를 쓰면 코딩 속도가 빨라지나요? 그냥 다 Unsafe로 짜면 안 될까요?”

재준봇의 답변: 오, 위험한 생각입니다! 절대 안 됩니다! Unsafe를 쓴다고 해서 타이핑 속도가 빨라지는 게 아니라, 컴파일러의 검사를 건너뛰는 것입니다. 비유하자면, 안전벨트를 풀고 시속 200km로 달리는 것과 같아요. 목적지에는 빨리 갈 수 있을지 몰라도, 한 번 사고 나면 차가 완전히 박살 납니다. Rust의 진가는 ‘안전함’에 있습니다. 정말 필요한 경우가 아니라면 Unsafe는 쳐다보지도 마세요!


⚠️ 실무주의보

실무에서 Unsafe Rust를 다룰 때 반드시 기억해야 할 점이 있습니다.

주의사항: Unsafe는 최대한 좁게 가둬라!

많은 초보 개발자들이 unsafe 블록을 크게 잡는 실수를 합니다. 하지만 고수들은 unsafe 블록을 아주 작게 쪼개서, 딱 위험한 한 줄만 감쌉니다. 그리고 그 위험한 부분을 ‘안전한 래퍼 함수(Safe Wrapper)’로 감싸서 외부에서는 안전하게 쓸 수 있게 만듭니다.

나쁜 예: unsafe { 100줄의 코드 } $\rightarrow$ 어디서 터졌는지 찾기 불가능. 좋은 예: fn safe_func() { unsafe { 딱 한 줄의 위험한 코드 } } $\rightarrow$ 내부만 잘 검증하면 외부 사용자는 안전함.


마무리하며

오늘 우리는 Rust의 가장 깊은 곳, 특수 타입과 Unsafe Rust라는 험난한 영역을 탐험했습니다. 슬라이스로 메모리를 효율적으로 쓰고, 스마트 포인터로 복잡한 소유권을 해결하며, 때로는 unsafe라는 양날의 검을 다루는 법을 배웠죠.

이 내용들은 한 번에 완벽히 이해하기 어렵습니다. 하지만 기억하세요. 여러분이 지금 느끼는 혼란은 성장의 증거입니다. 직접 코드를 쳐보고, 일부러 에러를 내보면서 익히는 것이 가장 빠릅니다.

여러분, 이제 당신은 Rust의 고급 기능까지 섭렵한 진정한 ‘러스트페이스’가 되기 직전입니다. 다음 강의에서도 더 쉽고 재미있게, 하지만 아주 깊이 있게 찾아오겠습니다.

지금까지 여러분의 든든한 코딩 멘토, 재준봇이었습니다! 고생 많으셨습니다!



<hr>

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

Categories:

Updated: