참조와 빌림 (References & Borrowing)

Rust의 핵심 개념 중 하나는 메모리 안전성을 컴파일 타임에 보장하는 것으로서 소유권 개념을 기반으로 한 참조(reference)와 빌림(borrowing)을 통해 변수의 소유권을 넘기지 않고도 다른 함수나 변수에서 데이터를 읽거나 수정할 수 있게 해줍니다.

Rust의 참조와 빌림은 처음엔 까다로워 보일 수 있습니다. 하지만 이 시스템 덕분에 Rust는 런타임 오류 없이 안전한 코드 작성이 가능합니다. 메모리 안전성을 수동으로 관리할 필요 없이, 컴파일러가 우리의 실수를 미리 잡아줍니다.


1. 불변 참조 (Immutable Reference)

가장 기본적인 형태는 불변 참조입니다. 값의 소유권을 넘기지 않으면서 읽기 권한만 주는 방식입니다.

fn main() {
    let s = String::from("hello");
    print_str(&s);
    println!("main: {}", s);
}

fn print_str(s: &String) {
    println!("func: {}", s);
}

(소유권이 이동(move)된 경우)
위 글에서 살펴본 바와 같이 main 함수의 print_str의 인수를 &s가 아니라 s로 바꾸고, fn print_str의 인수 s를 &String이 아니라 String으로 바꾸고 실행하면 s의 소유권이 함수로 넘어갔고, 함수 종료와 함께 메모리에서 해제됐기때문에 main함수의 println!문에서 에러가 발생합니다.

(참조한(빌린) 경우)
그래서 s를 함수 실행이후에도 사용하려면 &s라고 표현해서 s를 참조(borrow)해야 합니다. print_str 함수는 s의 복사본(clone)을 받는 것이 아니라 참조된 주소값을 받아 사용합니다. 중요한 점은 빌렸기때문에 print_str 함수 실행이 끝난 뒤에도 s는 여전히 main 함수에서 유효하게 사용 가능하다는 점입니다.

주의해야 할 점은 print_str함수로 인수를 전달할 때도 &s라고 &를 붙이고, print_str함수에도 인수의 타입을 지정할 때도 &String이라고 &표시를 해야한다는 것입니다.

핵심 포인트:
불변 참조는 소유권을 넘기지 않으며, 데이터를 읽기만 할 수 있다.

Rust는 동시에 여러 개의 불변 참조를 허용합니다. 읽기만 하는 작업이므로 충돌이 없기 때문입니다.

fn main() {
    let s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    println!("{}, {}", r1, r2); // OK
}

위 코드를 실행하면 hello, hello라고 화면에 표시됩니다.


2. 가변 참조 (Mutable Reference)

값을 변경하고 싶다면, 가변 참조(mutable reference)를 사용해야 합니다. 불변 참조와 달리, 가변 참조는 값을 수정할 수 있는 권한을 갖습니다.

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

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

이 코드에서 &mut s는 가변 참조입니다. change 함수는 s의 실제 데이터를 수정할 수 있습니다. s를 변경할 수 있어야 하기때문에 let으로 s 변수를 선언할 때도 mut를 붙였습니다.

s.push_str(” world”);는 s라는 변수에 ” world”를 추가하는 것입니다.
따라서, 위 코드를 실행하면 화면에 changed: hello world라고 표시됩니다.

신기한 것은 change 함수에서 s의 참조값을 변경하고 값을 반환하지 않았는데도 main 함수의 s값이 변했다는 것입니다. 가변 참조라 main 함수의 s에 영향을 줍니다.

하지만 Rust는 여기서 아주 엄격한 규칙을 적용합니다:

  • 가변 참조는 동시에 하나만 존재 가능
  • 가변 참조와 불변 참조는 동시에 존재할 수 없음
fn main() {
    let mut s = String::from("hello");
    let r1 = &mut s;
    let r2 = &mut s; // 오류: 두 개의 가변 참조!
    println!("{}, {}", r1, r2);
}

이 규칙은 데이터 경쟁(data race)을 방지하기 위함입니다. 여러 스레드나 함수에서 동시에 하나의 값을 변경하게 되면 런타임 오류가 발생할 수 있는데, Rust는 컴파일 타임에 이를 미리 막아줍니다.


3. 불변과 가변 참조는 함께 못 쓴다

다음 코드를 보겠습니다.

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;
    let r2 = &mut s; // 오류: 두 개의 가변 참조!
    println!("{}, {}", r1, r2);
}

불변 참조가 존재하는 동안에는 어떤 가변 참조도 허용되지 않습니다. 이는 데이터 일관성을 지키기 위한 Rust의 철칙입니다.

위 코드를 실행하면 아래와 같이 “불변 참조로 빌렸기때문에 가변 참조로 빌릴 수 없다”는 에러메시지가 표시됩니다.


4. 스코프와 참조

참조가 유효한 범위(scope)는 매우 중요합니다.

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

    {
        let r1 = &mut s;
        r1.push_str("!");
        println!("r1: {r1}"); // r1의 유효 범위 안에서 사용
    } // r1의 유효 범위가 끝났으므로...


    let r2 = &mut s; // 가능!
    r2.push_str(" again");
    println!("r2: {r2}"); // r2의 유효 범위 안에서 사용
}

Rust는 스코프가 겹치지 않는 한, 가변 참조를 연속적으로 허용합니다. 위 예제를 보면 r1의 유효 범위가 끝났기 때문에 가변 참조 r2가 생성될 수 있습니다.

위 코드를 실행하면 r1.push_str(“!”);으로 s 변수에 !가 추가되고, r2.push_str(” again”);로 s에 ” again”이 추가되어
r1: hello!
r2: hello! again
이 출력됩니다.


5. 참조는 기본적으로 일시적

참조는 값의 복사본이 아니라 원본을 일시적으로 사용하는 방식입니다. 다시 말해 원본보다 오래 살 수 없습니다.

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");
    &s
}

dangle 함수는 컴파일 에러가 발생합니다. 왜냐하면 dangle 함수 실행결과 &s를 반환하는데, 함수가 끝나면서 s가 메모리에서 해제되기 때문에 참조할 변수 s가 없기 때문입니다. Rust는 이런 dangling reference를 허용하지 않습니다.

따라서, 위 코드를 s를 직접 반환하도록 아래와 같이 dangle 함수를 수정해야 합니다.

fn dangle() -> String {
    let s = String::from("hello");
    s
}

반환 값의 형식에서 &를 제거하고, 반환값에서도 &를 제거하는 것입니다.


6. 요약: 빌림 규칙 정리

  • 불변 참조 &T: 여러 개 가능, 읽기 전용
  • 가변 참조 &mut T: 한번에 하나만 가능, 읽기 + 쓰기
  • 불변 참조와 가변 참조는 동시에 사용할 수 없음
  • 참조는 항상 원본(소유자)보다 먼저 소멸해야 함(dangling reference)

소유권(Ownership)

Rust의 가장 중요한 개념 중 하나는 소유권(Ownership)입니다. 이 개념은 메모리 안전성을 보장하면서도 가비지 컬렉션 없이 성능을 유지하는 핵심 메커니즘입니다. 소유권 개념은 힙에 저장된 데이터에 대해서만 적용되며, 힙의 데이터는 다른 변수에 대입하거나 함수의 인수로 전달 시 이동이 원칙입니다.


스택(Stack)과 힙(heap)

구분스택(Stack)힙(Heap)
저장 방식선입후출 (LIFO)필요할 때 동적으로 저장
속도빠름느림
용도크기가 고정된 데이터 (ex. 정수, 포인터 등)크기가 가변적인 데이터 (ex. String, Vec)
할당 방식컴파일 타임에 크기 결정런타임에 크기 결정
메모리 해제자동 (블록 종료 시)직접 해제 필요 (Rust는 drop)

🔹 기본 규칙

  1. 러스트의 각각의 값은 해당값의 오너(owner)라고 불리우는 변수를 갖고 있다.
  2. 한번에 딱 하나의 오너만 존재할 수 있다.
  3. 오너가 스코프 밖으로 벗어나는 때, 값은 버려진다(dropped).

🔸 문자열 이동(Move)

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}", s1); // 에러! s1은 더 이상 유효하지 않음
}
  • String은 힙(heap)에 저장되는 데이터입니다.
  • s2 = s1은 복사가 아니고, 소유권이 s1에서 s2로 이동(move)됩니다.
  • 따라서, s1은 더 이상 유효하지 않으며, 컴파일 에러가 발생합니다. 그러나, s2를 화면 출력하면 문제 없습니다.
String 타입은 복사가 아니라 이동이 되기 때문에 "이동 후 빌림"오류가 발행합니다.

빨간 색으로 표시된 error만 문제가 되며, 노란 색으로 표시된 warning은 실행에 영향이 없기에 무시하거나 참고만해도 됩니다.


✅ 클론(Clone) 사용

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1: {}, s2: {}", s1, s2);
}
  • .clone()을 사용하면 깊은 복사가 수행됩니다.
  • 깊은 복사는 값(포인터, 길이, 용량)뿐만 아니라 (포인터가 가리키는) 참조된 데이터까지 복사하여 완전히 독립적인 객체를 생성하는 것입니다.

🔸 스택 값은 복사(Copy)

fn main() {
    let x = 5;
    let y = x;

    println!("x: {}, y: {}", x, y);
}
  • 정수형(i32 등)은 스택에 저장되며, Copy 트레잇이 적용되어 자동 복사됩니다.
  • String은 Copy가 아니라 Clone이 필요합니다.

🛠️ 함수와 소유권

fn main() {
    let s = String::from("Rust");
    take_ownership(s);

    // println!("{}", s); // 에러! 소유권이 함수로 이동
}

fn take_ownership(s: String) {
    println!("받은 문자열: {}", s);
}
  • 함수로 전달하면 소유권이 이동되어 원래 변수는 더 이상 사용할 수 없습니다.
  • 위에서 println! 앞의 주석을 ctrl + / 키를 눌러 해제한 후 실행하면 소유권 이동으로 컴파일 오류가 발생합니다.

소유권을 되돌리기

fn main() {
    let s1 = String::from("hi");
    let s2 = return_ownership(s1);
    println!("{}", s2);
}

fn return_ownership(s: String) -> String {
    s
}
  • 위와 같이 하면 return_ownership 함수가 실행 후 String type으로 s를 반환하고, 그 값을 s2가 받기때문에 문제없이 s2를 화면에 출력할 수 있습니다.

🛠️ 스코프와 Drop

스코프를 벗어나면 스택이든 힙이든 저장된 데이터가 메모리에서 해제(Drop)됩니다.

> 힙(heap)

fn main() {
    let s = String::from("Hello");  // s는 문자열 "Hello"를 소유합니다.

    {
        let s1 = s; // s의 소유권이 s1으로 이동합니다. s는 더 이상 값을 소유하지 않습니다.
        println!("s1 = {}", s1);
    } // s1이 스코프를 벗어나고, 값이 해제됩니다.

    println!("s = {}", s); // 에러 발생: s, s1은 더 이상 유효하지 않습니다.
    // println!("s1 = {}", s1); // 에러 발생: s, s1은 더 이상 유효하지 않습니다.
}
  • 중괄호로 둘어쌓인 영역인 Scope입니다. 위 main 함수는 바깥쪽에 스코프가 하나 있고, 그 안에 스코프가 있는 이중 구조입니다. 바깥쪽 스코프의 변수는 안쪽 스코프에도 유효하게 적용되지만, 안쪽 스코프의 변수는 바깥쪽 스코프에서 유효하지 않습니다.
  • 힙의 데이터는 다른 변수에 대입하면 이동됩니다.
  • 안쪽 스코프에서 s1으로 데이터가 이동되었으므로 바깥쪽 스코프의 println!문에 주석을 하고 실행하면 안쪽 println!문은 정상적으로 출력됩니다.
  • 안쪽 스코프를 벗어나면 s1의 데이터가 메모리에서 해제되므로 s1은 더 이상 존재하지 않게 되므로, println!(“s1 = {}”, s1);에서 에러가 발생합니다.
  • 또한 s는 s1으로 이동되었으므로 println!(“s = {}”, s); 실행 시 에러가 발생합니다.

> 스택(stack)

  • 그러나, 스택 데이터인 정수를 s에 대입하면 이동이 아니라 s1으로 복사되고, s1은 스크프를 벗어나면 메모리에서 해제되므로 바깥쪽 스코프의 println!(“s1 = {}”, s1);이 오류가 발생하는 것은 힙 데이터인 String일 때와 같습니다.


그러나, s는 바깥쪽 스코프의 변수이므로 스코프를 벗어나도 해제되지 않고 s를 출력하는 안쪽, 바깥쪽 println!문 모두 정상적으로 작동합니다. 이 때 에러가 발생하는 s1 출력문은 주석으로 처리해야 합니다.

🧠 요약

개념설명
소유권값의 유일한 소유자가 존재
Move소유권이 다른 변수로 이동
Clone깊은 복사 (스택과 힙 데이터를 모두 복사)
Copy스택의 값은 자동 복사 가능
함수 전달인자로 주면 소유권도 함께 전달됨