이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.

소유권(Ownership)은 Rust의 가장 독특하고 중요한 개념입니다. Go의 가비지 컬렉션과 완전히 다른 접근 방식으로, 컴파일 타임에 메모리 안전성을 보장합니다. 이 섹션은 Rust 학습에서 가장 중요한 부분이므로 충분한 시간을 들여 이해하시기 바랍니다.


3.1 소유권이란 무엇인가?#

메모리 관리의 세 가지 방식#

프로그래밍 언어들은 메모리를 관리하는 방식이 다릅니다:

1. 가비지 컬렉션 (Go, Java, Python, JavaScript)

// Go: GC가 알아서 정리
func example() {
    slice := make([]int, 1000000)
    // 함수 종료 후 GC가 언젠가 정리
}
  • 장점: 개발자가 메모리 관리 신경 안 써도 됨
  • 단점: GC 일시 정지, 예측 불가능한 지연, 메모리 오버헤드

2. 수동 관리 (C, C++)

// C: 직접 할당/해제
int* arr = malloc(sizeof(int) * 1000000);
// 사용
free(arr);  // 잊으면 메모리 누수
// arr 사용 -> use-after-free 버그!
  • 장점: 완전한 제어, 오버헤드 없음
  • 단점: 메모리 누수, use-after-free, 이중 해제 버그

3. 소유권 시스템 (Rust)

// Rust: 컴파일러가 관리
fn example() {
    let vec = vec![0; 1000000];
    // 함수 종료 시 자동으로 해제 (결정론적)
}
  • 장점: GC 없이 메모리 안전, 예측 가능한 성능
  • 단점: 학습 곡선, 컴파일러와의 싸움

Go의 GC vs Rust의 소유권#

측면 Go (GC) Rust (소유권)
메모리 해제 시점 GC가 결정 (비결정론적) 스코프 종료 시 (결정론적)
일시 정지 GC 일시 정지 발생 없음
메모리 오버헤드 GC 메타데이터 필요 최소
지연 시간 예측 불가능할 수 있음 예측 가능
개발 복잡도 낮음 높음 (학습 필요)

Discord의 사례: Discord는 Go에서 Rust로 읽기 상태 서비스를 마이그레이션했습니다. Go 버전에서는 GC로 인한 지연 스파이크가 있었지만, Rust 버전에서는 일관된 성능을 얻었습니다.

스택(Stack) vs 힙(Heap)#

소유권을 이해하려면 메모리 구조를 알아야 합니다:

스택 (Stack):

  • LIFO (Last In, First Out)
  • 빠른 할당/해제 (포인터 이동만)
  • 크기가 컴파일 타임에 알려진 데이터
  • 기본 타입, 고정 배열, 튜플 등

힙 (Heap):

  • 동적 할당
  • 느린 할당/해제 (메모리 관리자 개입)
  • 크기가 런타임에 결정되는 데이터
  • String, Vec, Box 등
// 스택에 할당
let x: i32 = 42;              // 4바이트, 스택
let arr: [i32; 5] = [1,2,3,4,5];  // 20바이트, 스택

// 힙에 할당
let s: String = String::from("hello");  // 힙에 문자열 데이터
let v: Vec<i32> = vec![1, 2, 3];        // 힙에 배열 데이터

// String 내부 구조 (스택에 저장):
// - ptr: 힙 데이터를 가리키는 포인터
// - len: 길이
// - capacity: 용량

3.2 소유권 규칙#

세 가지 핵심 규칙#

  1. Rust의 각 값은 소유자(owner)라고 불리는 변수를 가진다.
  2. 한 번에 하나의 소유자만 존재할 수 있다.
  3. 소유자가 스코프를 벗어나면, 값은 드롭(drop)된다.
fn main() {
    // s가 스코프에 들어옴
    let s = String::from("hello");  // s가 "hello"의 소유자
    
    // s 사용 가능
    println!("{}", s);
    
}   // s가 스코프를 벗어남, "hello" 메모리 해제

소유권 이동(Move)#

Go에는 없는 개념입니다.

힙에 할당된 데이터를 다른 변수에 대입하면 소유권이 이동합니다:

let s1 = String::from("hello");
let s2 = s1;  // 소유권이 s1에서 s2로 이동

println!("{}", s1);  // 컴파일 에러! s1은 더 이상 유효하지 않음
println!("{}", s2);  // OK

왜 이런 설계일까요?

// 만약 복사가 기본이라면...
let s1 = String::from("hello");
let s2 = s1;  // 깊은 복사? 얕은 복사?

// 얕은 복사면: 두 변수가 같은 메모리를 가리킴
//            -> 두 번 해제 시도 -> 이중 해제 버그!
// 깊은 복사면: 항상 전체 복사 -> 성능 문제

// Rust의 해결책: 이동
// s1 무효화, s2만 유효 -> 단일 소유자 보장

Go에서는:

// Go: 슬라이스는 참조 타입
s1 := []int{1, 2, 3}
s2 := s1  // 같은 배열을 가리킴 (얕은 복사)

s2[0] = 999
fmt.Println(s1[0])  // 999 - s1도 변경됨!
// Rust: 이동으로 소유권 명확
let mut v1 = vec![1, 2, 3];
let v2 = v1;  // 이동!

// v1[0] = 999;  // 컴파일 에러! v1 무효
v2[0];  // OK, v2가 소유자

함수와 소유권#

함수에 값을 전달하면 소유권도 이동합니다:

fn main() {
    let s = String::from("hello");
    
    takes_ownership(s);  // s의 소유권이 함수로 이동
    
    // println!("{}", s);  // 컴파일 에러! s는 무효
    
    let x = 5;
    makes_copy(x);  // x는 Copy 타입이라 복사됨
    
    println!("{}", x);  // OK, x는 여전히 유효
}

fn takes_ownership(some_string: String) {
    println!("{}", some_string);
}   // some_string이 드롭됨

fn makes_copy(some_integer: i32) {
    println!("{}", some_integer);
}   // some_integer 스코프 종료, 특별한 일 없음

Copy 트레이트: 이동 대신 복사#

일부 타입은 이동 대신 복사됩니다:

let x = 5;
let y = x;  // 복사! x도 여전히 유효

println!("{}, {}", x, y);  // OK

Copy가 구현된 타입:

  • 모든 정수 타입 (i32, u64 등)
  • 불리언 (bool)
  • 부동소수점 (f32, f64)
  • 문자 (char)
  • Copy 타입만 포함하는 튜플 (예: (i32, i32))

Copy가 없는 타입:

  • String
  • Vec<T>
  • Box<T>
  • 힙 할당이 필요한 모든 타입
// Copy 여부 확인
fn is_copy<T: Copy>() {}

is_copy::<i32>();      // OK
is_copy::<String>();   // 컴파일 에러! String은 Copy가 아님

Clone 트레이트: 명시적 깊은 복사#

소유권을 유지하면서 복사하고 싶다면 clone()을 사용합니다:

let s1 = String::from("hello");
let s2 = s1.clone();  // 깊은 복사

println!("{}, {}", s1, s2);  // 둘 다 유효

// 비용이 있음을 명시적으로 표현
// Go에서는 암묵적이지만, Rust에서는 명시적

3.3 참조와 빌림 (References & Borrowing)#

소유권을 넘기지 않고 값을 사용하고 싶다면 참조를 사용합니다.

불변 참조: &T#

fn main() {
    let s = String::from("hello");
    
    let len = calculate_length(&s);  // s를 빌려줌
    
    println!("'{}' has length {}", s, len);  // s 여전히 유효!
}

fn calculate_length(s: &String) -> usize {
    s.len()
}   // s는 빌림일 뿐, 드롭되지 않음

빌림(Borrowing): 소유권을 가져가지 않고 참조로 접근하는 것

let s = String::from("hello");

let r1 = &s;  // 불변 참조
let r2 = &s;  // 또 다른 불변 참조 - OK!

println!("{}, {}", r1, r2);  // 여러 불변 참조 동시 가능

가변 참조: &mut T#

값을 수정하려면 가변 참조가 필요합니다:

fn main() {
    let mut s = String::from("hello");
    
    change(&mut s);
    
    println!("{}", s);  // "hello, world"
}

fn change(s: &mut String) {
    s.push_str(", world");
}

빌림 규칙#

핵심 규칙: 어떤 시점에든 다음 중 하나만 가질 수 있습니다:

  • 여러 개의 불변 참조 (&T)
  • 또는 하나의 가변 참조 (&mut T)
let mut s = String::from("hello");

// OK: 여러 불변 참조
let r1 = &s;
let r2 = &s;
println!("{}, {}", r1, r2);

// OK: 하나의 가변 참조
let r3 = &mut s;
r3.push_str(" world");

// 컴파일 에러: 불변과 가변 동시
let r1 = &s;
let r2 = &mut s;  // 에러! 불변 참조가 있는 동안 가변 참조 불가
// 컴파일 에러: 여러 가변 참조
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;  // 에러! 이미 가변 빌림 중

println!("{}, {}", r1, r2);

왜 이런 규칙이 필요할까요?

데이터 레이스 방지:

  • 두 포인터가 같은 데이터에 접근
  • 적어도 하나가 쓰기 작업
  • 접근이 동기화되지 않음
// Rust가 방지하는 버그 (Go에서는 가능한 버그)
// Go 예시:
// var slice = []int{1, 2, 3}
// go func() { slice[0] = 100 }()  // 동시 수정
// fmt.Println(slice[0])            // 데이터 레이스!

참조의 스코프와 NLL (Non-Lexical Lifetimes)#

참조의 스코프는 마지막 사용 지점까지입니다:

let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
println!("{}, {}", r1, r2);
// r1, r2는 여기서 더 이상 사용되지 않음

let r3 = &mut s;  // OK! r1, r2의 스코프는 끝남
println!("{}", r3);

Go 포인터와 Rust 참조의 차이#

// Go: 포인터
var x int = 10
var p *int = &x

*p = 20
fmt.Println(x)  // 20

// nil 가능
var q *int = nil
// *q = 10  // 런타임 패닉!
// Rust: 참조
let mut x: i32 = 10;
let p: &mut i32 = &mut x;

*p = 20;
println!("{}", x);  // 20

// null 불가능!
// let q: &i32;  // 초기화 없이 사용 불가
// 선택적 참조는 Option<&T> 사용
let q: Option<&i32> = None;
특성 Go 포인터 Rust 참조
null 가능 예 (nil) 아니오 (Option 사용)
항상 유효 아니오 예 (컴파일러 보장)
자동 역참조 아니오 예 (일부 상황)
빌림 검사 없음 있음

3.4 슬라이스 타입#

문자열 슬라이스: &str#

let s = String::from("hello world");

// 문자열 슬라이스 생성
let hello: &str = &s[0..5];   // "hello"
let world: &str = &s[6..11];  // "world"

// 전체 슬라이스
let whole: &str = &s[..];     // "hello world"

// 처음부터
let start: &str = &s[..5];    // "hello"

// 끝까지
let end: &str = &s[6..];      // "world"

String vs &str:

타입 소유권 저장 위치 가변성 용도
String 소유 가변 가능 문자열 생성/수정
&str 빌림 어디든 불변 문자열 참조/전달
// 문자열 리터럴은 &str
let literal: &str = "hello";  // 프로그램 바이너리에 저장

// String 생성
let owned: String = String::from("hello");
let owned: String = "hello".to_string();
let owned: String = literal.to_owned();

// String에서 &str로 (빌림)
let borrowed: &str = &owned;
let borrowed: &str = owned.as_str();

// 함수 매개변수로는 보통 &str 사용
fn greet(name: &str) {
    println!("Hello, {}!", name);
}

greet("Alice");           // &str 직접
greet(&owned);            // String에서 자동 변환

배열/벡터 슬라이스: &[T]#

let arr = [1, 2, 3, 4, 5];
let vec = vec![1, 2, 3, 4, 5];

// 슬라이스 생성
let slice1: &[i32] = &arr[1..4];   // [2, 3, 4]
let slice2: &[i32] = &vec[1..4];   // [2, 3, 4]

// 함수에서 슬라이스 받기
fn sum(numbers: &[i32]) -> i32 {
    numbers.iter().sum()
}

sum(&arr);        // 배열 전체
sum(&arr[1..4]);  // 배열 일부
sum(&vec);        // 벡터 전체
sum(&vec[1..4]);  // 벡터 일부

Go slice와의 비교#

// Go: slice는 동적 배열 (힙)
slice := []int{1, 2, 3, 4, 5}

// 부분 슬라이스 (같은 배열 공유)
sub := slice[1:4]

// append로 확장
slice = append(slice, 6)
// Rust: Vec<T>가 동적 배열
let mut vec = vec![1, 2, 3, 4, 5];

// 부분 슬라이스 (빌림)
let sub: &[i32] = &vec[1..4];

// push로 확장
// vec.push(6);  // 에러! sub가 vec를 빌리고 있음

// 빌림이 끝나면 OK
drop(sub);  // 명시적 드롭 (또는 스코프 종료)
vec.push(6);

핵심 차이:

  • Go: slice가 배열을 소유하거나 공유
  • Rust: Vec<T>가 소유, &[T]는 빌림

3.5 댕글링 참조 방지#

댕글링 참조(Dangling Reference): 해제된 메모리를 가리키는 참조

// C: 댕글링 포인터 가능
int* dangle() {
    int x = 10;
    return &x;  // x는 함수 종료 시 해제됨
}  // 반환된 포인터는 유효하지 않은 메모리를 가리킴
// Rust: 컴파일 에러로 방지
fn dangle() -> &String {
    let s = String::from("hello");
    &s  // 에러! s는 함수 종료 시 드롭됨
}   // 참조가 가리킬 대상이 없어짐

// 해결: 소유권을 반환
fn no_dangle() -> String {
    let s = String::from("hello");
    s  // 소유권 이동
}

Go에서의 댕글링 문제:

// Go: 클로저에서 변수 캡처 주의
func createFuncs() []func() int {
    var funcs []func() int
    for i := 0; i < 3; i++ {
        funcs = append(funcs, func() int {
            return i  // 모든 클로저가 같은 i를 참조!
        })
    }
    return funcs
}
// funcs[0](), funcs[1](), funcs[2]() 모두 3 반환
// Rust: 컴파일러가 캡처 방식 명확히
fn create_funcs() -> Vec<impl Fn() -> i32> {
    let mut funcs: Vec<Box<dyn Fn() -> i32>> = Vec::new();
    for i in 0..3 {
        funcs.push(Box::new(move || i));  // 각 클로저가 i를 소유
    }
    funcs
}
// 0, 1, 2 반환

3.6 라이프타임 기초#

라이프타임이 필요한 이유#

참조가 유효한 범위를 라이프타임이라고 합니다:

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("{}", r);    // 에러! 'b < 'a
}                         // ---------+

컴파일러는 r의 라이프타임 'ax의 라이프타임 'b보다 길다는 것을 감지합니다.

함수에서의 라이프타임#

참조를 반환하는 함수는 라이프타임을 명시해야 할 수 있습니다:

// 컴파일 에러! 어떤 참조를 반환하는지 불명확
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
// 라이프타임 명시
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

// 의미: 반환값의 라이프타임은 x와 y 중 짧은 쪽과 같다
fn main() {
    let string1 = String::from("long string");
    
    {
        let string2 = String::from("xyz");
        let result = longest(&string1, &string2);
        println!("Longest: {}", result);  // OK
    }
    
    // println!("{}", result);  // 에러! string2가 드롭됨
}

라이프타임 생략 규칙#

많은 경우 라이프타임을 생략할 수 있습니다:

// 생략 가능
fn first_word(s: &str) -> &str {
    // ...
}

// 완전한 형태
fn first_word<'a>(s: &'a str) -> &'a str {
    // ...
}

생략 규칙 (Elision Rules):

  1. 각 입력 참조는 고유한 라이프타임을 받음
  2. 입력 라이프타임이 하나면 출력에 적용
  3. &self&mut self가 있으면 그 라이프타임이 출력에 적용
// 규칙 1 & 2 적용
fn foo(x: &str) -> &str { x }
// fn foo<'a>(x: &'a str) -> &'a str { x }

// 규칙 3 적용
impl MyStruct {
    fn method(&self, x: &str) -> &str { x }
    // fn method<'a, 'b>(&'a self, x: &'b str) -> &'a str { x }
}

구조체에서의 라이프타임#

참조를 포함하는 구조체는 라이프타임 명시 필수:

// 컴파일 에러!
struct Excerpt {
    part: &str,  // 라이프타임 없음
}

// 올바른 형태
struct Excerpt<'a> {
    part: &'a str,
}

// 사용
fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    
    let excerpt = Excerpt {
        part: first_sentence,
    };
    
    println!("{}", excerpt.part);
}
// excerpt는 novel보다 오래 살 수 없음

‘static 라이프타임#

프로그램 전체 기간 동안 유효한 라이프타임:

// 문자열 리터럴은 'static
let s: &'static str = "I live forever!";

// 'static 데이터는 바이너리에 포함됨
static GREETING: &str = "Hello";

흔한 라이프타임 오류와 해결법#

// 오류 1: 반환값이 로컬 변수 참조
fn bad() -> &str {
    let s = String::from("hello");
    &s  // 에러!
}
// 해결: 소유권 반환
fn good() -> String {
    String::from("hello")
}

// 오류 2: 구조체가 참조보다 오래 살려고 함
fn bad2() {
    let excerpt;
    {
        let novel = String::from("...");
        excerpt = Excerpt { part: &novel };
    }
    // println!("{}", excerpt.part);  // 에러!
}

// 오류 3: 가변 빌림 중 불변 빌림
fn bad3() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);  // 에러! v를 가변 빌림하려는데 first가 불변 빌림 중
    println!("{}", first);
}
// 해결: 빌림 순서 조정
fn good3() {
    let mut v = vec![1, 2, 3];
    v.push(4);
    let first = &v[0];
    println!("{}", first);
}

3.7 실전 연습: 소유권 사고방식 익히기#

일반적인 소유권 패턴#

패턴 1: 소유권 가져오기 vs 빌리기

// 소유권 가져오기 - 값을 소비
fn consume(s: String) {
    println!("{}", s);
}  // s 드롭

// 빌리기 - 읽기만
fn borrow(s: &String) {
    println!("{}", s);
}  // 빌림 끝

// 가변 빌리기 - 수정
fn modify(s: &mut String) {
    s.push_str("!");
}

패턴 2: 반환으로 소유권 돌려주기

fn process_and_return(mut s: String) -> String {
    s.push_str(" processed");
    s  // 소유권 반환
}

let s = String::from("data");
let s = process_and_return(s);  // s 재바인딩

패턴 3: 클론으로 독립적인 복사본 만들기

fn needs_ownership(s: String) { /* ... */ }

let original = String::from("hello");
needs_ownership(original.clone());  // 복사본 전달
println!("{}", original);  // 원본 사용 가능

Go 코드를 Rust로 변환할 때 주의점#

// Go: 참조 타입의 암묵적 공유
type User struct {
    Name string
}

func updateName(u *User, name string) {
    u.Name = name
}

func main() {
    user := &User{Name: "Alice"}
    updateName(user, "Bob")
    fmt.Println(user.Name)  // Bob
}
// Rust: 명시적 가변 빌림
struct User {
    name: String,
}

fn update_name(u: &mut User, name: &str) {
    u.name = name.to_string();
}

fn main() {
    let mut user = User { name: String::from("Alice") };
    update_name(&mut user, "Bob");
    println!("{}", user.name);  // Bob
}

컴파일러 에러 메시지 읽는 법#

Rust 컴파일러는 매우 친절합니다:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}", s1);
}
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:4:20
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`
3 |     let s2 = s1;
  |              -- value moved here
4 |     println!("{}", s1);
  |                    ^^ value borrowed here after move
  |
help: consider cloning the value
  |
3 |     let s2 = s1.clone();
  |                ++++++++

에러 메시지가 알려주는 것:

  1. 어디서 이동이 발생했는지
  2. 왜 이동이 발생했는지 (타입 때문)
  3. 어디서 이동된 값을 사용하려 했는지
  4. 해결 방법 제안

소유권 문제 해결 전략#

  1. 클론 사용: 가장 간단, 성능 비용 있음

    let s2 = s1.clone();
    
  2. 참조 사용: 소유권 이동 없이 접근

    let s2 = &s1;
    
  3. 구조 변경: 소유권 흐름에 맞게 코드 재구성

    // 전: 여러 곳에서 소유권 필요
    // 후: 한 곳에서 소유, 나머지는 빌림
    
  4. 스마트 포인터 사용: Rc, Arc (나중에 다룸)

    use std::rc::Rc;
    let s = Rc::new(String::from("shared"));
    let s2 = Rc::clone(&s);
    

3.8 요약#

소유권 핵심 개념#

개념 설명
소유권 각 값은 하나의 소유자를 가짐
이동 대입 시 소유권 이동 (힙 데이터)
복사 Copy 타입은 이동 대신 복사
빌림 참조로 소유권 없이 접근
라이프타임 참조의 유효 범위

Go vs Rust 비교#

상황 Go Rust
대입 얕은 복사 (참조 공유) 이동 또는 복사
함수 전달 값/포인터 선택 소유권/참조 선택
null 참조 가능 (nil) 불가능 (Option 사용)
데이터 레이스 런타임 감지 (race detector) 컴파일 타임 방지

연습 문제#

  1. 소유권 이동: 다음 코드가 컴파일되지 않는 이유를 설명하고 수정하세요:

    let v = vec![1, 2, 3];
    let v2 = v;
    println!("{:?}", v);
    
  2. 빌림 규칙: 다음 코드가 컴파일되지 않는 이유를 설명하고 수정하세요:

    let mut s = String::from("hello");
    let r1 = &s;
    let r2 = &mut s;
    println!("{}, {}", r1, r2);
    
  3. 라이프타임: 다음 함수에 적절한 라이프타임을 추가하세요:

    fn first_word(s: &str) -> &str {
        let bytes = s.as_bytes();
        for (i, &item) in bytes.iter().enumerate() {
            if item == b' ' {
                return &s[0..i];
            }
        }
        &s[..]
    }
    
  4. Go → Rust 변환: 다음 Go 코드를 Rust로 변환하세요:

    func processStrings(strings []string) []string {
        result := make([]string, len(strings))
        for i, s := range strings {
            result[i] = strings.ToUpper(s)
        }
        return result
    }
    
  5. 구조체와 라이프타임: 문자열 슬라이스를 포함하는 Book 구조체를 정의하고, 제목과 저자를 출력하는 메서드를 작성하세요.