참조와 대여

참조와 대여

앞에서 calculate_length 함수에 String을 넘기면 그 값이 함수로 이동하고, 함수가 끝난 뒤에도 String을 다시 쓰려면 튜플로 값을 돌려받아야 했어요. 소유권을 주고받는 게 이렇게 번거롭다면 어떡할까요? 대신 String 값을 참조(reference)로 넘기면 문제가 풀려요. 참조는 포인터와 비슷하게, 어떤 주소에 저장된 데이터를 따라가서 접근할 수 있게 해주는 값이에요. 다만 그 데이터는 다른 변수가 소유하고 있어요. 포인터와 다른 점은, 참조는 그 수명 동안 특정 타입의 유효한 값을 가리킨다는 게 보장된다는 거예요.

값의 소유권을 가져가지 않고 참조만 받는 calculate_length 함수를 정의해서 써볼게요.

출처: The Rust Programming Language — References and Borrowing

참조 만들기

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을 받죠. 이 앰퍼샌드 &가 참조를 뜻해요. 참조는 어떤 값을 소유하지 않고 가리킬 수 있게 해줍니다.

&s1 문법은 s1의 값을 '가리키지만 소유하지는 않는' 참조를 만들어요. 참조가 값을 소유하지 않으므로, 참조가 더 이상 쓰이지 않아도 그 값 자체는 버려지지 않아요. 함수 시그니처의 &도 마찬가지로 매개변수 s의 타입이 참조임을 나타내요. 변수 s가 유효한 스코프는 여느 함수 매개변수와 같지만, s가 소유권이 없으므로 s가 더 이상 쓰이지 않아도 참조가 가리키는 값은 버려지지 않아요. 함수가 값을 실제로 받는 대신 참조를 매개변수로 받으면, 소유권을 돌려주려고 값을 다시 반환할 필요도 없어요. 애초에 소유권을 가진 적이 없으니까요.

이렇게 참조를 만드는 행위를 '대여(borrowing)'라고 불러요. 현실 세계와 같아요. 어떤 물건이 다른 사람 것이면, 그에게서 빌려 쓸 수 있죠. 다 쓰고 나면 돌려줘야 해요. 그 물건을 소유한 건 아니에요.

대여한 값은 수정할 수 없다

그럼 빌린 참조로 값을 수정하려고 하면 어떻게 될까요? 되는 일이 없어요. 시도해 보면 오류가 나죠. 변수가 기본적으로 불변인 것처럼 참조도 기본적으로 불변이에요. 참조가 가리키는 값을 우리가 수정할 수는 없어요.

가변 참조

수정을 허용하려면 몇 가지를 바꿔서 '가변 참조(mutable reference)'를 쓰면 돼요. 먼저 smut로 바꾸고, change 함수를 호출하는 지점에서 &mut s로 가변 참조를 만들며, 함수 시그니처도 some_string: &mut String처럼 가변 참조를 받도록 바꿔요. 이렇게 하면 change 함수가 빌린 값을 수정한다는 게 명확해져요.

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

    change(&mut s);
}

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

가변 참조에는 큰 제약이 하나 있어요. 어떤 값에 대한 가변 참조를 갖고 있으면, 그 값에 대한 다른 참조는 아무것도 가질 수 없어요. 같은 s에 가변 참조를 두 개 만들려는 아래 코드는 실패해요.

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

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

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

이 오류는 같은 데이터를 한 번에 하나의 가변 대여만 할 수 있다는 뜻이에요. 첫 번째 가변 대여는 r1에 있고 println!에서 쓰일 때까지 유지되어야 하는데, 그 사이에 r1이 대여한 데이터를 또 가변 참조로 잡으려 했기 때문이에요.

이 제약 덕분에 변경은 가능하되 아주 통제된 방식으로 이뤄져요. 다른 언어들은 원할 때마다 수정을 허용하니 Rust를 갓 시작한 사람들은 헷갈리곤 해요. 그러데 이 제약의 이점은 Rust가 데이터 레이스(data race)를 컴파일 타임에 막아준다는 거예요. 데이터 레이스는 세 가지가 동시에 일어날 때 발생해요. 둘 이상의 포인터가 같은 데이터에 동시에 접근하고, 그중 적어도 하나가 쓰기용이고, 접근을 동기화하는 메커니즘이 없는 경우죠. 데이터 레이스는 정의되지 않은 동작을 일으키고 런타임에 추적하기도 어려운데, Rust는 데이터 레이스가 있는 코드를 컴파일조차 거부해서 이 문제를 원천 차단해요.

중괄호로 새 스코프를 만들면 여러 가변 참조를 쓸 수는 있어요. 단, '동시에'만 아니면 됩니다.

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

{
    let r1 = &mut s;
} // 여기서 r1이 스코프를 벗어나므로, 이후 새 참조를 만들어도 문제없음
let r2 = &mut s;

Rust는 가변 참조와 불변 참조를 섞는 데도 비슷한 규칙을 강제해요. 같은 값에 불변 참조를 갖고 있는 동안에는 가변 참조를 가질 수 없어요.

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

let r1 = &s; // 문제없음
let r2 = &s; // 문제없음
let r3 = &mut s; // 큰 문제!

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

불변 참조를 쓰는 사람들은 자기가 읽는 값이 발밑에서 갑자기 바뀔 거라고 기대하지 않아요. 반면 불변 참조는 여러 개가 허용돼요. 데이터를 읽기만 하는 사람은 다른 사람의 읽기에 영향을 줄 수 없으니까요.

한 가지 알아둘 점은, 참조의 스코프는 참조가 도입된 지점부터 마지막으로 쓰인 지점까지라는 거예요. 예를 들어 아래 코드는 컴파일돼요. 불변 참조 r1, r2의 마지막 사용이 println!에 있는데, 그게 가변 참조 r3가 만들어지기 전이거든요. 두 스코프가 겹치지 않으니 허용되는 거죠. 컴파일러는 참조가 스코프 끝보다 먼저 더 이상 안 쓰인다는 걸 알 수 있어요.

대여 오류가 짜증날 때도 있지만, 이건 Rust 컴파일러가 잠재적 버그를 런타임이 아니라 컴파일 타임에, 그것도 정확히 어디가 문제인지 짚어주는 거예요. 나중에 "데이터가 왜 내 생각과 달라졌지?"를 뒤적일 필요가 없어집니다.

댕글링 참조

포인터가 있는 언어에서는 어떤 메모리를 가리키는 포인터를 남겨둔 채 그 메모리를 해제해 버려서 '댕글링 포인터(dangling pointer)'를 실수로 만들기 쉬워요. 즉 다른 곳에 넘어갔을지도 모르는 메모리 위치를 참조하는 포인터 말이죠. 반대로 Rust에서는 컴파일러가 참조가 결코 댕글링이 되지 않게 보장해요. 어떤 데이터에 대한 참조가 있다면, 그 데이터가 참조보다 먼저 스코프를 벗어나지 않게 해요.

댕글링 참조를 만들어 보면 Rust가 어떻게 컴파일 타임 오류로 막는지 알 수 있어요.

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

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

    &s
}

이 오류는 아직 다루지 않은 기능, 즉 생명주기(lifetimes)를 가리켜요. 생명주기는 Chapter 10에서 자세히 다루지만, 생명주기 부분을 무시하면 이 오류 메시지에 문제의 핵심이 담겨 있어요. "이 함수의 반환 타입은 빌린 값을 포함하는데, 빌려올 값이 없다"는 거죠.

왜 그럴까요? sdangle 안에서 만들어지므로 dangle의 코드가 끝나면 s는 메모리에서 해제돼요. 그런데 우리는 s에 대한 참조를 반환하려 했어요. 그 참조는 무효한 String을 가리키게 되는 거죠. 그건 안 됩니다. Rust는 이걸 허용하지 않아요. 해결책은 String을 그대로 반환하는 거예요.

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

    s
}

이 코드는 아무 문제 없이 동작해요. 소유권이 밖으로 이동하고, 아무것도 해제되지 않거든요.

참조의 규칙 정리

지금까지 본 참조의 규칙을 요약하면 이래요.

  • 어느 순간이든 값에 대해 하나의 가변 참조 또는 여러 개의 불변 참조 중 하나만 가질 수 있다.
  • 참조는 항상 유효해야 한다.

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

더 알아보기 (Learn more)

  • 참조에서 시작해 슬라이스로 이어지는 내용은 Chapter 4 — 슬라이스에서 다뤄요.
  • 참조의 유효 범위를 보장하는 생명주기 개념은 Chapter 10 — 생명주기에서 자세히 설명해요.
  • 역참조 연산자 *와 스마트 포인터를 통한 참조 다루기는 Chapter 15에서 볼 수 있어요.