참조와 빌림 (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)