본문 바로가기

참조와 대여

값의 소유권을 넘기지 않는 불변·가변 참조를 사용하고 대여 규칙이 데이터 경쟁과 댕글링 참조를 막는 원리를 이해합니다.

앞 절 마지막에 등장한 예제 3-5에서는 String이 calculate_length로 이동해 버린 것 때문에 calculate_length를 호출한 함수로 String을 반환하여, 함수 호출 이후에도 String을 사용할 수 있게 하였습니다.

이렇게 하는 대신 String 값의 참조자를 만들 수 있습니다.

참조자(reference)는 다른 값의 소유권을 가져오지 않고 그 값이 놓인 위치에 접근하도록 하는 &T 또는 &mut T 값입니다. 참조자를 만드는 일을 대여(borrow)라고 합니다.

원시 포인터와 달리 참조자는 정렬되고 null이 아니며, 참조자가 사용되는 동안 해당 타입의 유효한 값을 가리킨다고 가정됩니다. 안전한 러스트에서는 컴파일러가 대여와 수명을 검사해 이 전제를 지키게 합니다.

이번에는 값의 소유권을 넘기는 대신 개체의 참조자를 넘겨주는 방법을 소개하도록 하겠습니다.

다음은 참조자를 매개변수로 받도록 구현한 calculate_length 함수의 정의 및 용례입니다.

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

    let len = calculate_length(&s1);

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

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

먼저, 변수 선언부와 함수 반환 값에 위치하던 튜플 코드가 전부 사라진 것을 볼 수 있습니다.

또한 calculate_length 함수에 s1 대신 &s1을 전달하고, 함수 정의에 String 대신 &String을 사용했네요.

이 앰퍼센드(&) 기호가 참조자를 나타내고, 어떤 값의 소유권을 가져오지 않고 해당 값을 참조할 수 있도록 해 줍니다.

그림 3-5는 이 개념을 도식화한 것입니다.

String s1을 소유권 이동 없이 참조하는 &String s의 포인터 구조 그림 3-5: &String s는 String s1을 가리킴

Note: &를 이용한 참조의 반대는 역참조(dereferencing)라 합니다. 역참조 기호는 *이며, 7장에서 몇 번 다뤄보고 14장에서 자세한 내용을 배울 예정입니다.

함수 호출부를 좀 더 자세히 살펴봅시다.

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

    let len = calculate_length(&s1);

s1에 &를 붙인 &s1 구문은 s1 값을 참조하지만 해당 값을 소유하지 않는 참조자를 생성합니다.

참조자는 값을 소유하지 않습니다. 원래 소유자는 그대로 s1이며, 컴파일러는 이 참조자의 마지막 사용보다 먼저 s1을 이동하거나 버리는 코드를 허용하지 않습니다. 참조자의 사용이 끝나는 일 자체가 대상 값을 버리는 것은 아닙니다.

마찬가지로 함수 시그니처에도 &를 사용하여 매개변수 s가 참조자 타입임을 나타내줍니다.

주석으로 보여드리겠습니다.

fn calculate_length(s: &String) -> usize { // s는 String의 참조자입니다
    s.len()
} // 여기서 s가 스코프 밖으로 벗어납니다. 하지만 참조하는 것을 소유하고 있진 않으므로,
  // 버려지지는 않습니다.

변수 s가 유효한 스코프는 여타 함수의 매개변수에 적용되는 스코프와 동일합니다.

하지만 s에는 소유권이 없으므로 s가 더 이상 사용되지 않는다고 해서 참조 대상인 String이 버려지지는 않습니다.

함수가 실제 값 대신 참조자를 매개변수로 쓴다면 애초에 소유권이 없으니까 이 소유권을 돌려주기 위한 값 반환도 필요 없어집니다.

또한, 이처럼 참조자를 만드는 표현식 &s1은 s1을 공유 대여합니다.

현실에서도 여러분이 다른 사람이 소유하고 있는 뭔가를 빌리고, 용무가 끝나면 돌려주는 것처럼요.

여러분의 소유가 아니니까요.

그럼 빌린 값을 수정하면 어떻게 될까요?

예제 3-6 코드를 실행해 보면 알 수 있으나, 미리 말씀드리자면 이 코드는 작동하지 않습니다.

예제 3-6: 빌린 값을 수정해 보는 코드
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
  |
7 | fn change(some_string: &String) {
  |                        ------- help: consider changing this to be a mutable reference: `&mut String`
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable

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

공유 참조자 &T를 통해서는 일반적으로 참조 대상을 수정할 수 없습니다. UnsafeCell을 기반으로 한 내부 가변성 타입은 안전한 API가 별도의 검사 규칙을 제공하는 예외이며, 여기서는 일반적인 String 대여를 다룹니다.


가변 참조자

앞의 calculate_length(&s1)은 문자열 소유권을 돌려받는 호출이 아닙니다. 호출 중 읽을 권한만 빌리고, s1은 계속 String을 소유합니다. len()도 앞 절처럼 바이트 수를 세므로 가나를 넘기면 6입니다. 아래의 실패 코드들은 각 조건을 보여주는 별도 사본이며, 성공 프로그램에 한꺼번에 이어 붙이는 코드가 아닙니다.

가변 참조자(mutable reference)를 사용하는 식으로 예제 3-6을 살짝만 수정하면 에러를 없앨 수 있습니다.

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");
}

우선 s를 mut로 변경합니다.

그런 다음 change 함수를 호출하는 곳에서 &mut s로 가변 참조자를 생성하고, 이 함수에서 가변 참조자를 전달받도록 some_string: &mut String으로 수정합니다.

이는 change 함수가 빌린 값을 수정할 수 있음을 매우 명확하게 해 줍니다.

가변 참조자는 한 가지 큰 제약사항이 있습니다.

어떤 위치에 대한 가변 대여가 활성화되어 있다면, 그 위치에는 가변 참조자 또는 그 참조자에서 파생된 재대여를 통해서만 접근할 수 있습니다. 같은 위치를 독립적으로 공유 대여하거나 두 번째 가변 대여를 겹치게 만들 수 없습니다.

아래의 코드는 s에 대한 두 개의 가변 참조자 생성을 시도하는 코드로, 작동하지 않습니다.

src/main.rs
    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` due to previous error

이 에러는 s를 가변으로 두 번 이상 빌려올 수 없기 때문에 코드가 유효하지 않다고 말해줍니다.

첫 번째 가변 대여는 r1에 있고, println!에서 사용될 때까지 남아있어야 하지만, 이 가변 참조자의 생성과 사용 사이에서 r1과 같은 데이터를 빌리는 r2의 가변 참조자를 만들고 있습니다.

같은 위치에 대하여 활성 가변 대여를 겹치지 않게 하는 이러한 제약은 쓰기 접근 경로를 독점적으로 유지합니다.

대부분의 언어들이 언제든 값 변경을 허용하기 때문에, 러스트에 갓 입문한 사람들에게는 장애물처럼 다가올 수 있습니다.

이 대여 규칙은 단일 스레드에서도 충돌하는 alias와 변경을 막으며, 여러 스레드에서 안전한 동시성 추상화와 함께 데이터 경합(data race)을 방지하는 토대가 됩니다.

데이터 경합이란 다음 세 가지 상황이 겹칠 때 일어나는 특정한 경합 조건(race condition)입니다.

  • 둘 이상의 포인터가 동시에 같은 데이터에 접근
  • 포인터 중 하나 이상이 데이터에 쓰기 작업을 시행
  • 데이터 접근 동기화 메커니즘이 없음

데이터 경합은 정의되지 않은 동작을 일으키며, 런타임에 추적하려고 할 때 문제 진단 및 수정이 어렵습니다.

안전한 러스트에서는 동기화 없이 이런 충돌 접근을 표현하는 코드를 거부하거나, Mutex 같은 안전한 동시성 타입의 계약을 따르게 하여 데이터 경합을 막습니다.

중괄호로 새로운 스코프를 만들어, 여러 가변 참조자의 대여 구간이 겹치지 않게 하는 방법이 있습니다.

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

    {
        let r1 = &mut s;
        r1.push_str("!");
    } // 여기서 r1이 스코프 밖으로 벗어나며, 따라서 아무 문제없이 새 참조자를 만들 수 있습니다.

    let r2 = &mut s;

가변 참조자와 불변 참조자를 혼용할 때도 유사한 규칙이 적용됩니다.

다음 코드는 컴파일 에러가 발생합니다.

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

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

    println!("{}, {}, and {}", r1, r2, 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!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

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

어떤 값에 대한 불변 참조자가 있는 동안 같은 값의 가변 참조자를 만드는 것 또한 불가능합니다.

불변 참조자를 사용하는 쪽에서는 사용 중 값이 중간에 변경되리라 예상하지 않으니까요.

반면 데이터를 읽기만 하는 기능으로는 다른 쪽에서 값을 읽는 기능에 영향을 주지 않으므로, 여러 개의 불변 참조자를 만드는 것은 가능합니다.

참조자 바인딩의 문법적 스코프와 대여가 충돌 검사에 영향을 주는 구간은 구분해야 합니다. 컴파일러는 제어 흐름에서 참조자의 마지막 사용을 분석해, 그 뒤에는 같은 위치를 새로 대여할 수 있다고 판단할 수 있습니다.

즉, 다음 코드는 불변 참조자가 마지막으로 사용되는 println! 이후에 가변 참조자의 정의가 있으므로 컴파일 에러가 발생하지 않습니다.

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

    let r1 = &s; // 문제없음
    let r2 = &s; // 문제없음
    println!("{} and {}", r1, r2);
    // 이 지점 이후로 변수 r1과 r2는 사용되지 않습니다

    let r3 = &mut s; // 문제없음
    println!("{}", r3);

r1, r2 바인딩의 문법적 스코프는 블록 끝까지 계속되지만, 두 공유 참조의 마지막 사용은 첫 번째 println!입니다. 그 뒤에는 공유 대여가 더 이상 활성 접근에 필요하지 않으므로 가변 참조자 r3를 만들 수 있습니다.

마지막 사용이 공유 대여와 가변 대여를 나눈다

r1/r2 공유 참조의 마지막 사용 뒤 활성 공유 접근이 끝나고 r3 가변 대여가 허용되는 직선 코드의 상태 전환이다. 블록 스코프가 남는 사실과 마지막 사용을 분리하며 뒤에서 공유 참조를 다시 사용하는 E0502는 별도 소스의 컴파일 거부로 표시한다.

같은 String의 공유 참조 r1과 r2가 마지막으로 사용된 뒤 가변 참조 r3를 만들 수 있지만, 공유 참조를 나중에 다시 사용하면 앞선 가변 대여가 거부되는 상태 흐름

BORROW LIVENESS · LAST USE

마지막 공유 참조 사용 뒤에 가변 대여할 수 있다

바인딩의 블록 스코프가 아니라, 제어 흐름에서 참조가 마지막으로 사용되는 지점이 다음 대여와 겹치는지를 봅니다.

공유 대여의 마지막 사용 이후 허용 경로와 조기 변경 거부 경로기존desktop의6노드5간선이다. 참조생성→공유대여활성→활성대여없음→가변대여활성→종료. 공유참조를 뒤에서 다시쓰는 대안 코드에서는 공유대여활성에서 E0502거부로 우회한다. 실패 경로는 성공 경로의 후속실행이 아니다. 마지막사용은 여기서 제시한 직선코드 조건이며 일반제어흐름 설명은 원본문에 유지된다.참조 생성r1, r2: &String공유 대여 활성읽기 · 재공유뒤의 사용까지 유지활성 대여 없음공유 참조 마지막 사용바인딩의 블록은 남음가변 대여 활성r3: &mut String독립 접근은 겹치지 않음가변 대여 종료r3의 마지막 사용 뒤겹치는 대여 거부공유 참조를 뒤에서 사용E0502 · 실행 전 거부완료&mut공유 참조를 재사용거부 경로는 별도 코드 조건허용 뒤에 실패를 실행하지 않음
공유 대여의 마지막 사용과 가변 대여 상태 r1과 r2의 공유 대여가 첫 println에서 마지막으로 사용되면 뒤의 r3 가변 대여가 허용되고, 공유 대여가 이후에도 사용되면 조기 가변 대여는 E0502로 거부됩니다. 마지막 사용&mut s마지막 사용 전 &mut s공유 대여 활성r1, r2: &String읽기·재공유 가능뒤의 사용까지 활성활성 대여 없음after println!공유 참조 마지막 사용바인딩 스코프는 유지가변 대여 활성r3: &mut Stringr3·파생 재대여 경로독립 접근은 겹치지 않음겹치는 접근 · 거부E0502공유 참조를 뒤에서 다시 사용공유 대여와 가변 대여가 겹침r3 마지막 사용 뒤 종료
  1. 공유 참조를 만듭니다.

    r1 = &s와 r2 = &s는 같은 값을 읽는 공유 대여입니다.

  2. 첫 println!이 마지막 사용입니다.

    바인딩은 블록 끝까지 남아도, 뒤에서 다시 사용하지 않으면 공유 대여가 새 접근과 충돌하지 않습니다.

  3. 그 뒤 r3 = &mut s가 허용됩니다.

    가변 대여가 활성인 동안에는 r3와 여기서 파생된 재대여를 통해 접근합니다. r3의 마지막 사용 뒤 가변 대여도 끝날 수 있습니다.

  4. 마지막 사용 전의 가변 대여는 거부됩니다.

    뒤에서 공유 참조를 다시 사용한다면 두 대여가 겹치므로 E0502가 발생합니다.

“마지막 사용”은 단순한 소스 줄 순서가 아니라 컴파일러가 제어 흐름에서 판단한 참조의 사용 구간입니다. 이 예에서는 공유 대여가 끝난 뒤에만 같은 String을 가변 대여합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명기존 가로 도표의 6노드와 5간선이다. 참조 생성→공유 대여 활성→활성 대여 없음→가변 대여 활성→종료. 공유 참조를 뒤에서 다시 쓰는 대안 코드에서는 공유 대여 활성에서 E0502 거부로 우회한다. 실패 경로는 성공 경로의 후속 실행이 아니다. 마지막 사용은 여기서 제시한 직선 코드의 조건이며 일반 제어 흐름 설명은 원래 본문에 유지된다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
공유 읽기

r1과 r2가 s를 공유 대여한다.

  • 접근: 공유 읽기
  • r1/r2: 후속 읽기에 필요
  • 현재 활성 공유 접근과 같은 위치의 독립 변경은 겹칠 수 없다.
마지막 사용 뒤

공유 참조를 더 이상 쓰지 않는다.

  • 대여: 공유 접근 불필요
  • 바인딩 블록: 여전히 남음
  • 직선 코드의 마지막 사용과 문법적 스코프를 구분한다.
가변 대여

r3로 같은 s를 변경할 수 있다.

  • 조건: 겹치는 공유 사용 없음
  • 뒤에서 r1/r2를 다시 쓰는 사본은 이 성공 경로가 아닌 컴파일 거부다.

마지막 사용이 공유 대여와 가변 대여를 나눈다

r1/r2 공유 참조의 마지막 사용 뒤 활성 공유 접근이 끝나고 r3 가변 대여가 허용되는 직선 코드의 상태 전환이다. 블록 스코프가 남는 사실과 마지막 사용을 분리하며 뒤에서 공유 참조를 다시 사용하는 E0502는 별도 소스의 컴파일 거부로 표시한다.

같은 String의 공유 참조 r1과 r2가 마지막으로 사용된 뒤 가변 참조 r3를 만들 수 있지만, 공유 참조를 나중에 다시 사용하면 앞선 가변 대여가 거부되는 상태 흐름

BORROW LIVENESS · LAST USE

마지막 공유 참조 사용 뒤에 가변 대여할 수 있다

바인딩의 블록 스코프가 아니라, 제어 흐름에서 참조가 마지막으로 사용되는 지점이 다음 대여와 겹치는지를 봅니다.

공유 대여의 마지막 사용 이후 허용 경로와 조기 변경 거부 경로기존desktop의6노드5간선이다. 참조생성→공유대여활성→활성대여없음→가변대여활성→종료. 공유참조를 뒤에서 다시쓰는 대안 코드에서는 공유대여활성에서 E0502거부로 우회한다. 실패 경로는 성공 경로의 후속실행이 아니다. 마지막사용은 여기서 제시한 직선코드 조건이며 일반제어흐름 설명은 원본문에 유지된다.참조 생성r1, r2: &String공유 대여 활성읽기 · 재공유뒤의 사용까지 유지활성 대여 없음공유 참조 마지막 사용바인딩의 블록은 남음가변 대여 활성r3: &mut String독립 접근은 겹치지 않음가변 대여 종료r3의 마지막 사용 뒤겹치는 대여 거부공유 참조를 뒤에서 사용E0502 · 실행 전 거부완료&mut공유 참조를 재사용거부 경로는 별도 코드 조건허용 뒤에 실패를 실행하지 않음
공유 대여의 마지막 사용과 가변 대여 상태 r1과 r2의 공유 대여가 첫 println에서 마지막으로 사용되면 뒤의 r3 가변 대여가 허용되고, 공유 대여가 이후에도 사용되면 조기 가변 대여는 E0502로 거부됩니다. 마지막 사용&mut s마지막 사용 전 &mut s공유 대여 활성r1, r2: &String읽기·재공유 가능뒤의 사용까지 활성활성 대여 없음after println!공유 참조 마지막 사용바인딩 스코프는 유지가변 대여 활성r3: &mut Stringr3·파생 재대여 경로독립 접근은 겹치지 않음겹치는 접근 · 거부E0502공유 참조를 뒤에서 다시 사용공유 대여와 가변 대여가 겹침r3 마지막 사용 뒤 종료
  1. 공유 참조를 만듭니다.

    r1 = &s와 r2 = &s는 같은 값을 읽는 공유 대여입니다.

  2. 첫 println!이 마지막 사용입니다.

    바인딩은 블록 끝까지 남아도, 뒤에서 다시 사용하지 않으면 공유 대여가 새 접근과 충돌하지 않습니다.

  3. 그 뒤 r3 = &mut s가 허용됩니다.

    가변 대여가 활성인 동안에는 r3와 여기서 파생된 재대여를 통해 접근합니다. r3의 마지막 사용 뒤 가변 대여도 끝날 수 있습니다.

  4. 마지막 사용 전의 가변 대여는 거부됩니다.

    뒤에서 공유 참조를 다시 사용한다면 두 대여가 겹치므로 E0502가 발생합니다.

“마지막 사용”은 단순한 소스 줄 순서가 아니라 컴파일러가 제어 흐름에서 판단한 참조의 사용 구간입니다. 이 예에서는 공유 대여가 끝난 뒤에만 같은 String을 가변 대여합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명기존 가로 도표의 6노드와 5간선이다. 참조 생성→공유 대여 활성→활성 대여 없음→가변 대여 활성→종료. 공유 참조를 뒤에서 다시 쓰는 대안 코드에서는 공유 대여 활성에서 E0502 거부로 우회한다. 실패 경로는 성공 경로의 후속 실행이 아니다. 마지막 사용은 여기서 제시한 직선 코드의 조건이며 일반 제어 흐름 설명은 원래 본문에 유지된다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
공유 읽기

r1과 r2가 s를 공유 대여한다.

  • 접근: 공유 읽기
  • r1/r2: 후속 읽기에 필요
  • 현재 활성 공유 접근과 같은 위치의 독립 변경은 겹칠 수 없다.
마지막 사용 뒤

공유 참조를 더 이상 쓰지 않는다.

  • 대여: 공유 접근 불필요
  • 바인딩 블록: 여전히 남음
  • 직선 코드의 마지막 사용과 문법적 스코프를 구분한다.
가변 대여

r3로 같은 s를 변경할 수 있다.

  • 조건: 겹치는 공유 사용 없음
  • 뒤에서 r1/r2를 다시 쓰는 사본은 이 성공 경로가 아닌 컴파일 거부다.

예시 이 프로그램은 같은 String을 읽고, 읽는 참조자의 마지막 사용이 끝난 뒤 변경합니다. 이름이 블록 안에 남아 있다는 사실과 참조를 이후에 다시 쓴다는 사실을 구별하세요.

src/main.rs — 마지막 사용 뒤 변경하기
fn main() {
    let mut s = String::from("봄");
    let read = &s;
    println!("before={read}");

    let edit = &mut s;
    edit.push_str("날");
    println!("after={edit}");
    println!("owner={s}");
}

예상 출력은 before=봄, after=봄날, owner=봄날입니다. read는 첫 출력 뒤 다시 사용하지 않고, edit는 두 번째 출력 뒤 다시 사용하지 않습니다. 따라서 이 직선 코드에서는 읽기 대여, 변경 대여, 소유자를 통한 읽기가 겹치지 않습니다. 이 시점에 String을 없애는 것이 아니라 사용 권한을 차례로 바꾸는 것입니다. 공식 마지막 사용 예제도 같은 순서를 설명합니다.

스스로 확인하기 맨 마지막에 println!("again={read}"); 한 줄을 더하면 기존 세 줄을 출력한 다음 실패할까요, 아니면 실행 전에 거부될까요? 어떤 대여가 겹치는지 설명하세요.

나중에 다시 읽는 경우의 풀이

read의 사용이 마지막 추가 출력까지 이어지므로, 그 사이의 &mut s와 공유 대여가 겹칩니다. 컴파일 단계에서 거부되어 세 줄의 출력도 실행되지 않습니다. 먼저 read를 마지막으로 사용하고 그 뒤 변경하도록 순서를 정하거나, 변경 후에 새 읽기 참조자를 만드세요. 나중에 사용할 read를 남긴 채 중괄호만 보기 좋게 배치하는 것으로 해결되지는 않습니다.

이러한 제약 때문에 좀 골치 아플 수도 있습니다만, 이는 러스트 컴파일러가 코드에 숨어 있는 버그를 런타임이 아닌 컴파일 타임에 일찌감치 찾아내어 어느 부분이 문제인지 정확히 짚어주는 기능이란 점을 기억해주세요.

이렇게 하면 원하는 데이터가 나오지 않는 원인을 하나하나 추적하지 않아도 됩니다.


댕글링 참조

댕글링 포인터(dangling pointer)란, 어떤 메모리를 가리키는 포인터가 남아있는 상황에서 일부 메모리를 해제해 버림으로써, 다른 개체가 할당받았을지도 모르는 메모리를 참조하게 된 포인터를 말합니다.

포인터가 있는 언어에서는 자칫 잘못하면 이 댕글링 포인터를 만들기 쉽죠.

하지만 안전한 러스트에서는 어떤 데이터의 참조자를 만들면, 그 참조자가 마지막으로 사용되기 전에 데이터의 수명이 끝나지 않는지 컴파일러가 확인하여 댕글링 참조의 사용을 막습니다.

댕글링 참조를 만들어서 러스트가 어떤 식으로 이것을 컴파일 타임에 방지하는지 살펴봅시다.

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: 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` due to previous error

라이프타임 표기법은 9장에서 다루지만, 여기서는 반환 참조가 호출 뒤에도 살아 있는 어떤 값에서 빌려졌는지를 확인해야 합니다. 함수 안에서 만든 s를 가리키는 참조는 함수가 끝난 뒤의 사용을 지원할 수 없습니다. 반환 타입에 라이프타임 이름을 추가하는 것만으로 로컬 String의 수명이 늘어나지는 않습니다.

이 코드가 문제가 되는 이유를 알려주는 핵심 내용은 다음과 같습니다.

this function's return type contains a borrowed value, but there is no value for it to be borrowed from.
(해석: 이 함수는 빌린 값을 반환하고 있으나, 빌린 실제 값이 존재하지 않습니다.)

dangle 함수에서 어떤 일이 일어나는지 단계별로 알아봅시다.

src/main.rs
fn dangle() -> &String { // dangle은 String의 참조자를 반환합니다

    let s = String::from("hello"); // s는 새로운 String입니다

    &s // String s의 참조자를 반환합니다
} // 여기서 s는 스코프 밖으로 벗어나고 버려집니다. 해당 메모리는 해제됩니다.
  // 위험합니다!

s는 dangle 함수 내에서 생성됐기 때문에, 함수가 끝날 때 할당 해제됩니다.

하지만 코드에서는 &s를 반환하려 했고, 이는 유효하지 않은 String을 가리키는 참조자를 반환하는 행위이기 때문에 에러가 발생합니다.

따라서, 이런 경우엔 String을 직접 반환해야 합니다.

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

    s
}

이 코드는 정상적으로 작동합니다.

반환 시 소유권이 호출자에게 이동하므로 함수 끝에서 버퍼를 해제하지 않습니다. 이후 최종 소유 값이 drop될 때 정리됩니다.

no_dangle은 참조를 빌려주는 함수가 아니라 String의 소유권을 넘기는 함수입니다. 이 함수 정의를 유지하고 main에서 let text = no_dangle(); println!("{text}");를 실행하면 hello를 읽을 수 있습니다. 함수 안의 s라는 이름은 끝나지만, 반환된 String은 text가 소유합니다. 함수 종료와 문자열 값의 최종 정리가 반드시 같은 순간은 아닙니다.


참조자 규칙

참조자에 대해 배운 내용을 정리해봅시다.

  • 같은 위치에 대한 활성 대여는 여러 공유 참조자이거나 하나의 가변 참조자여야 합니다.
  • 공유 대여가 활성화된 동안 일반 데이터는 그 위치를 통해 변경되지 않으며, 가변 대여가 활성화된 동안에는 그 참조자와 파생된 재대여를 제외한 독립 접근이 허용되지 않습니다.
  • 참조자는 정렬되고 null이 아니며 유효한 값을 가리켜야 하고, 참조 대상보다 오래 사용될 수 없습니다.

불변 참조와 가변 참조는 단순히 개수를 세는 규칙이 아니라, 같은 위치에서 활성 접근이 겹치는지와 참조 대상이 충분히 오래 유효한지를 컴파일러가 확인하게 만드는 규칙입니다.

같은 위치의 공유 대여, 가변 대여, 충돌하는 대여, 지역 값을 가리키는 반환 참조를 접근 권한과 소유권 및 컴파일 결과로 비교

SAME PLACE · OVERLAPPING ACCESS

대여는 같은 위치의 접근 구간과 유효성을 검사한다

참조 변수의 총개수가 아니라 같은 위치에 대한 대여가 실제 사용 구간에서 겹치는지, 대상이 그 구간 내내 유효한지를 확인합니다.

안전한 러스트에서 같은 위치를 대여할 때의 접근·소유권 계약
대여 상황 활성 구간의 접근 계약 소유권과 컴파일 결과

여러 공유 대여

r1 = &s

r2 = &s

참조를 통해 읽거나 다시 공유 대여할 수 있습니다. 일반 데이터는 공유 대여가 활성인 동안 변경하지 않습니다. s가 계속 소유합니다. 참조의 사용이 끝나도 대상 값을 drop하지 않으며, 조합은 허용됩니다.

하나의 가변 대여

r = &mut s

r와 여기서 파생된 재대여를 통해 읽고 쓸 수 있습니다. 같은 위치의 독립 접근은 겹칠 수 없습니다. s의 소유권은 이동하지 않습니다. 충돌하는 활성 대여가 없다면 조합은 허용됩니다.

충돌하는 활성 대여

&s + &mut s

두 &mut s

읽기 안정성과 쓰기 독점을 동시에 만족할 수 없습니다. 나중 사용 때문에 앞선 대여가 계속 활성일 수도 있습니다. E0502 또는 E0499 등으로 거부됩니다. 대여의 마지막 사용을 분리해야 합니다.

대상보다 오래 쓰려는 참조

fn dangle()

-> &String

함수 안의 지역 String은 반환 뒤 유효하지 않으므로 그 참조를 호출자에서 사용할 수 없습니다. 유효한 빌림 원본이 없어 거부됩니다. 이 예에서는 String 자체를 반환해 소유권을 이동합니다.
여러 &T · 허용
접근 읽기와 재공유가 가능하고 일반 데이터의 변경은 허용되지 않습니다.
소유권 원래 소유자에게 남으며 참조가 끝나도 대상은 drop되지 않습니다.
하나의 &mut T · 허용
접근 가변 참조와 파생 재대여를 통해 읽고 씁니다.
조건 같은 위치의 독립된 공유·가변 대여가 활성 상태로 겹치지 않아야 합니다.
겹치는 공유·가변 또는 두 가변 · 거부
원인 뒤의 사용 때문에 앞선 대여가 아직 활성일 수 있습니다.
진단 대표적으로 E0502와 E0499가 발생합니다.
대상보다 오래 쓰려는 참조 · 거부
원인 지역 값은 함수 반환 뒤 유효하지 않습니다.
해결 이 예에서는 String 자체를 반환해 소유권을 이동합니다.
이 표는 안전한 러스트에서 같은 위치를 대여하는 입문 규칙입니다. 서로 겹치지 않는 구조체 필드는 별도로 분석될 수 있고, UnsafeCell 기반 내부 가변성 타입은 안전한 API에서 별도의 검사 계약을 제공합니다.

공식 문서

다음으로 알아볼 것은 참조자의 또 다른 종류인 슬라이스(slice)입니다.