Rust 기초 강좌 #4 참조와 빌림: &와 &mut, 빌림 규칙 관련 컴파일 에러 해결

5 분 소요

지난 편 끝에서 함수에 값을 넘길 때마다 소유권을 뺏기고 반환으로 돌려받는 어색한 코드를 봤습니다. 이번 편의 참조(reference)가 그 답입니다. 소유권은 원래 자리에 두고 값에 접근할 권한만 빌리는 것, Rust 용어로 빌림(borrowing)입니다. 그리고 이 빌림에 걸린 규칙 하나가 소유권과 함께 Rust 안전성의 나머지 절반을 담당합니다.

참조: 소유권 없이 읽기 #

src/main.rs
fn count_chars(text: &String) -> usize {
    text.chars().count()
} // text는 참조일 뿐이라 여기서 아무것도 해제되지 않는다

let summary = String::from("월간 요약");
let n = count_chars(&summary); // &로 참조만 전달
println!("{summary}: {n}자");  // summary는 여전히 유효

&summary는 값을 넘기는 대신 값을 가리키는 참조를 만듭니다. 함수는 값을 읽을 수 있지만 소유자가 아니므로, 함수가 끝나도 값은 해제되지 않고 summary는 그대로 쓸 수 있습니다. 지난 편의 “돌려받기 반환” 코드가 통째로 사라집니다. 읽기만 하는 함수의 매개변수는 참조로 받는 것이 Rust의 기본형입니다.

가변 참조: 빌려서 수정하기 #

수정까지 필요하면 &mut로 빌립니다. 빌려주는 쪽 변수 자체도 mut여야 합니다.

src/main.rs
fn append_signature(body: &mut String) {
    body.push_str("\n- 드림");
}

let mut mail = String::from("안녕하세요.");
append_signature(&mut mail);

호출부에 &mut가 명시되는 점이 중요합니다. 다른 언어에서는 함수가 인자를 바꾸는지 시그니처만 봐서는 모를 때가 많지만, Rust에서는 수정될 수 있는 전달이 호출하는 쪽 코드에 표시됩니다.

빌림 규칙: 읽기는 여럿, 쓰기는 하나 #

참조에는 규칙이 하나 있습니다. 한 시점에 불변 참조(&)는 몇 개든 가능하지만, 가변 참조(&mut)는 하나만 허용되며 두 종류를 동시에 사용할 수는 없습니다. 읽는 사람이 있는 동안 값이 바뀌지 않는다는 보장입니다.

이 규칙이 실제로 막아 주는 사고를 보겠습니다. 컬렉션을 순회하면서 그 컬렉션에 추가하는, 많은 언어에서 미묘한 버그나 런타임 예외가 되는 코드입니다.

src/main.rs
let mut items = vec![1, 2, 3];
for item in &items {          // items를 불변으로 빌리는 중
    if *item == 2 {
        items.push(99);       // 컴파일 에러: 가변 빌림 시도
    }
}
컴파일 에러
error[E0502]: cannot borrow `items` as mutable because it is also
              borrowed as immutable
  |
2 |     for item in &items {
  |                 ------ immutable borrow occurs here
4 |             items.push(99);
  |             ^^^^^^^^^^^^^^ mutable borrow occurs here

push는 내부 저장 공간이 부족하면 데이터 전체를 새 메모리로 옮기는데, 그 순간 순회 중인 참조는 해제된 옛 메모리를 가리키게 됩니다. C++라면 미정의 동작, 자바라면 런타임의 ConcurrentModificationException이 되는 상황이 Rust에서는 컴파일 에러입니다. 멀티스레드로 가면 같은 규칙이 데이터 레이스(한쪽이 쓰는 동안 다른 쪽이 읽는 경쟁)를 컴파일 타임에 차단하는 장치가 됩니다.

cannot borrow as mutable 에러를 만나면 싸울 것이 아니라 빌림의 범위를 좁히는 것이 정석입니다. 참조는 마지막으로 사용된 지점까지만 살아 있는 것으로 계산되므로, 읽기를 먼저 끝내고 수정을 뒤로 옮기면 대부분 풀립니다. 위 예제라면 순회에서는 추가할 값만 모으고, 순회가 끝난 뒤 한꺼번에 push하는 식입니다.

댕글링 참조: 해제된 메모리를 가리킬 수 없다 #

src/main.rs
fn make_greeting() -> &String {
    let s = String::from("안녕하세요");
    &s // 컴파일 에러: s는 함수가 끝나면 해제된다
}

지역 변수의 참조를 반환하면, 함수가 끝나는 순간 값은 해제되고 참조만 남습니다. C에서 스택 주소를 반환하는 고전적 버그입니다. Rust 컴파일러는 “빌린 값이 참조보다 먼저 죽는” 모든 경우를 거부합니다. 이 경우 답은 참조가 아니라 String 자체를 반환해 소유권을 넘기는 것입니다. 참조가 얼마나 오래 유효한지를 다루는 이 검사의 전체 모습이 9편에서 다룰 수명(lifetime)입니다.

슬라이스: 일부만 빌리기 #

참조의 변형으로, 컬렉션의 연속된 일부를 빌리는 슬라이스가 있습니다.

src/main.rs
let scores = vec![90, 85, 72, 60];
let top: &[i32] = &scores[..2];    // 앞 두 개만 빌림

let title = String::from("Rust 기초 강좌");
let first: &str = &title[..4];     // "Rust"

문자열 슬라이스 &str은 특히 중요합니다. 문자열 리터럴("hello")의 타입이 바로 &str이고, 함수가 문자열을 읽기만 한다면 매개변수는 &String보다 &str로 받는 것이 관례입니다. String이든 리터럴이든 다 받을 수 있기 때문입니다. 앞의 count_chars도 실무 형태로 고치면 이렇게 됩니다.

src/main.rs
fn count_chars(text: &str) -> usize {
    text.chars().count()
}

한 가지 주의는 문자열 슬라이스의 인덱스가 문자 수가 아니라 바이트 위치라는 점입니다. 한글은 UTF-8에서 한 글자가 3바이트라서 &title[..4] 같은 임의 절단은 글자 중간을 자르면 패닉이 납니다. 문자열의 이런 성질은 7편에서 제대로 다룹니다.

정리 #

  • 참조 &는 소유권을 옮기지 않고 값을 읽는 빌림입니다. 읽기 전용 함수 매개변수의 기본형이고, 수정이 필요하면 &mut로 빌립니다.
  • 빌림 규칙은 하나입니다. 불변 참조는 여럿, 가변 참조는 하나, 동시는 불가. 반복 중 수정과 데이터 레이스가 이 규칙 하나로 컴파일 타임에 차단됩니다.
  • cannot borrow as mutable 에러는 빌림 범위를 좁히는 방향으로 풉니다. 읽기를 먼저 끝내고 수정을 뒤로 모으는 것이 정석입니다.
  • 해제될 값을 가리키는 참조 반환은 컴파일이 거부합니다. 그 경우 소유권 자체를 반환합니다.
  • 문자열을 읽는 함수는 &String이 아니라 &str로 받는 것이 관례입니다. 리터럴과 String을 모두 받을 수 있습니다.
  • 다음 편에서는 데이터를 구조화하는 도구인 구조체와 열거형, 그리고 Rust에서 null을 대체하는 Optionmatch를 다루겠습니다.
X