참조와 빌림

참조와 빌림 (References and Borrowing)

앞에서 튜플로 만든 코드가 어색했던 이유, 기억나세요? calculate_length를 호출한 뒤에도 String을 계속 쓰려고 그 값을 다시 돌려받아야 했죠. 그건 String이 함수 안으로 이동(move) 됐기 때문이에요. 그런데 이번엔 그 값을 이동시키지 않고, 참조(reference) 를 넘겨주는 방법을 배워 볼게요.

출처: Rust 공식문서

본문

참조는 포인터와 비슷해요. 주소를 따라가면 그 주소에 저장된 데이터에 접근할 수 있는데, 그 데이터는 다른 어떤 변수의 소유라는 뜻이죠. 다만 포인터와 달리, 참조는 그 참조가 살아 있는 동안 특정 타입의 유효한 값을 가리킨다고 보장돼요.

값의 소유권을 가져가지 않고 참조를 매개변수로 받는 calculate_length는 이렇게 정의하고 사용해요.

// Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

먼저 눈에 띄는 건, 튜플 코드에서 변수 선언과 함수 반환 값을 만지던 게 전부 사라졌다는 점이에요. 그리고 calculate_length&s1을 넘기고, 함수 정의에서는 String 대신 &String을 받죠. 이 앰퍼샌드(&)들이 바로 참조를 나타내고, 소유권을 가져가지 않고 어떤 값을 가리킬 수 있게 해줘요.

Note: &로 참조하는 것의 반대는 역참조(dereferencing) 인데, 역참조 연산자 *로 수행해요. 역참조 연산자의 쓰임새 중 일부는 8장에서 보고, 역참조의 자세한 내용은 15장에서 다룰 거예요.

&s1 구문은 s1의 값을 가리키는 참조를 만들지만 그 값을 소유하지는 않아요. 참조가 소유권이 없기 때문에, 참조가 더 이상 쓰이지 않게 돼도 그 값은 버려지지(drop) 않아요.

함수 시그니처도 마찬가지로 &를 써서 s라는 매개변수의 타입이 참조임을 나타내요. 주석을 붙여 보면 이렇게 돼요.

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

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because s does not have ownership of what
  // it refers to, the String is not dropped.

변수 s가 유효한 스코프는 다른 함수 매개변수의 스코프와 같아요. 다만 참조가 가리키는 값은 s가 소유권이 없기 때문에 s를 다 쓰고 나서도 버려지지 않아요. 함수가 실제 값 대신 참조를 매개변수로 받으면, 애초에 소유권을 가진 적이 없으니 소유권을 돌려주려고 값을 반환할 필요도 없어요.

참조를 만드는 동작을 우리는 빌림(borrowing) 이라고 불러요. 현실에서도 남의 물건을 빌리면 다 쓴 뒤에 돌려줘야 하잖아요. 그 물건의 주인이 내가 아니니까요. 그와 같아요.

자, 그럼 빌리고 있는 걸 수정하려고 하면 어떻게 될까요? 4-6 리스트의 코드를 직접 실행해 보세요. 스포일러지만, 안 돼요!

// Filename: src/main.rs
fn main() {
    let s = String::from("hello");

    change(&s);
}

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

이런 에러가 나와요.

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

변수가 기본적으로 불변(immutable)인 것처럼, 참조도 기본적으로 불변이에요. 참조하고 있는 걸 마음대로 수정할 수는 없어요.

가변 참조 (Mutable References)

4-6 리스트의 코드를 고쳐서 빌린 값을 수정할 수 있게 하려면 몇 가지만 바꾸면 돼요. 바로 가변 참조(mutable reference) 를 쓰는 거예요.

// Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

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

먼저 smut으로 바꿔요. 그리고 change 함수를 호출하는 자리에서 &mut s로 가변 참조를 만들고, 함수 시그니처도 some_string: &mut String으로 가변 참조를 받도록 고쳐요. 이렇게 하면 change 함수가 빌린 값을 수정한다는 게 아주 명확해져요.

가변 참조에는 큰 제약이 하나 있어요. 값에 대한 가변 참조가 있으면, 그 값에 대한 다른 참조는 하나도 만들 수 없어요. s에 대한 가변 참조 두 개를 만들려는 다음 코드는 실패해요.

// Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

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

에러는 이렇게 나와요.

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{r1}, {r2}");
  |                -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

이 에러는 s를 한 번에 두 번 이상 가변으로 빌릴 수 없어서 코드가 잘못됐다고 알려주는 거예요. 첫 번째 가변 빌림인 r1println!에서 쓰일 때까지 유지돼야 하는데, 그 가변 참조가 만들어지고 나서 쓰이기 전에 r1과 같은 데이터를 빌리는 또 다른 가변 참조 r2를 만들려고 했죠.

같은 데이터에 동시에 여러 가변 참조를 만들 수 없다는 제약은, 수정은 허용하되 아주 통제된 방식으로만 허용한다는 뜻이에요. 대부분의 언어는 아무 때나 수정할 수 있게 놔두기 때문에, Rust를 처음 배우는 분들이 이 지점에서 많이 헤매곤 해요. 이 제약의 이점은 Rust가 데이터 경쟁(data race)을 컴파일 타임에 막아준다는 거예요. 데이터 경쟁은 경쟁 조건(race condition)과 비슷한데, 다음 세 가지가 동시에 일어날 때 발생해요.

  • 서로 다른 두 개 이상의 포인터가 같은 데이터에 동시에 접근할 때
  • 그중 적어도 하나는 데이터를 쓰려고 할 때
  • 동시성을 동기화할 메커니즘이 없을 때

데이터 경쟁은 정의되지 않은 동작(undefined behavior)을 일으키고, 런타임에 추적하려고 하면 원인을 찾아 고치기가 어려워요. Rust는 이런 코드를 컴파일 자체를 거부해서 데이터 경쟁 문제를 애초에 막아요.

늘 그렇듯이 중괄호로 새 스코프를 만들면 가변 참조를 여러 개 쓸 수 있어요. 다만 동시에만 아니라면요.

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

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

Rust는 가변 참조와 불변 참조를 섞는 경우에도 비슷한 규칙을 적용해요. 다음 코드는 에러가 나요.

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

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

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

에러는 이렇게 나와요.

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

어휴, 같은 값에 불변 참조가 있는 동안에는 가변 참조도 만들 수 없다는 거예요. 불변 참조를 쓰는 쪽에서는 그 값이 갑자기 바뀔 거라고 기대하지 않으니까요. 하지만 불변 참조는 여러 개가 허용돼요. 데이터를 그냥 읽기만 하는 사람들은 다른 사람의 읽기에 영향을 줄 수 없으니까요.

여기서 참조의 스코프가 어디서 끝나는지 주의 깊게 볼 필요가 있어요. 참조의 스코프는 도입된 지점부터 그 참조를 마지막으로 사용하는 지점까지예요. 예를 들어 다음 코드는 컴파일돼요. 불변 참조를 마지막으로 쓴 게 println!이고, 그게 가변 참조가 도입되기 전이기 때문이에요.

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

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{r1} and {r2}");
    // Variables r1 and r2 will not be used after this point.

    let r3 = &mut s; // no problem
    println!("{r3}");
}

불변 참조 r1r2의 스코프는 마지막으로 쓰인 println! 뒤에서 끝나고, 그건 가변 참조 r3가 만들어지기 전이에요. 두 스코프가 겹치지 않으니 이 코드는 허용돼요. 컴파일러는 스코프가 끝나기 전 어느 지점에서 그 참조가 더 이상 쓰이지 않는다는 걸 알 수 있어요.

빌림 에러가 가끔 짜증나기도 하지만, 기억해 주세요. 그건 Rust 컴파일러가 잠재적인 버그를 런타임이 아니라 컴파일 타임에 일찍 짚어 주고, 정확히 어디가 문제인지 보여 주는 거예요. 그러면 나중에 데이터가 왜 내가 생각했던 게 아닌지 뒤져 볼 필요가 없어져요.

허상 참조 (Dangling References)

포인터를 쓰는 언어에서는 메모리를 해제하면서도 그 메모리를 가리키는 포인터를 남겨두는 바람에 허상 포인터(dangling pointer) — 이미 다른 사람에게 넘어갔을지도 모르는 메모리 위치를 가리키는 포인터 — 를 실수로 만들기 쉬워요. Rust는 반대로, 컴파일러가 참조가 절대 허상 참조(dangling reference)가 되지 않도록 보장해요. 어떤 데이터에 대한 참조가 있으면, 컴파일러는 그 데이터를 가리키는 참조보다 데이터가 먼저 스코프 밖으로 나가 버리지 않게 해주죠.

허상 참조를 만들어 보려고 하면 Rust가 컴파일 타임 에러로 어떻게 막아 주는지 볼게요.

// Filename: src/main.rs
fn main() {
    let reference_to_nothing = dangle();
}

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

    &s
}

이런 에러가 나와요.

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
5 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

이 에러 메시지는 우리가 아직 다루지 않은 기능을 가리켜요. 바로 라이프타임(lifetime) 이에요. 라이프타임의 자세한 내용은 10장에서 다룰 거예요. 하지만 라이프타임에 관한 부분을 빼고 보면, 이 메시지에 이 코드가 왜 문제인지의 핵심이 담겨 있어요.

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

dangle 코드의 각 단계에서 정확히 무슨 일이 일어나는지 자세히 볼게요.

// Filename: src/main.rs
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope and is dropped, so its memory goes away.
  // Danger!

sdangle 안에서 만들어지기 때문에 dangle의 코드가 끝나면 s는 해제(deallocate)돼요. 그런데 우리는 그 s를 가리키는 참조를 반환하려고 했죠. 그 말은 이 참조가 유효하지 않은 String을 가리키게 된다는 거예요. 그건 곤란하죠! Rust는 우리가 이렇게 하는 걸 허락하지 않아요.

여기서의 해결책은 String을 그대로 반환하는 거예요.

fn main() {
    let string = no_dangle();
}

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

    s
}

이건 아무 문제 없이 동작해요. 소유권이 밖으로 이동되고, 아무것도 해제되지 않으니까요.

참조의 규칙 (The Rules of References)

지금까지 배운 참조에 대한 내용을 정리하면 이래요.

  • 어떤 순간이든, 가변 참조 하나 또는 불변 참조 여러 개 중에서 하나만 가질 수 있어요. (둘 다 동시에는 불가능해요.)
  • 참조는 항상 유효해야 해요.

다음으로는 다른 종류의 참조, 슬라이스(slices)를 살펴볼 거예요.

더 알아보기

  • 바로 다음 장: 4.3 슬라이스 타입 — ch04-03-slices
  • 라이프타임(lifetime)에 대한 자세한 설명은 10장에서 다뤄요.
  • 역참조 연산자 *의 자세한 내용은 15장에서 다뤄요.