Rust 학습 노트 (4) - 잠시 빌려쓰는 변수, borrow

Rust 학습 노트 (4) - 잠시 빌려쓰는 변수, borrow

태그
Rust
최종 수정일
Last updated May 8, 2026
Slug
rust-study-4
Date
May 8, 2026
Published
Published
notion image
이 글은 Rust에서 변수를 옮기지 않고 잠시 빌려쓰는 방법, 즉 borrow를 정리합니다. Rust 학습 노트 시리즈 중 한 편이며, 이전 글에서 다룬 소유권 모델은 일반적인 상황에서 너무 불편합니다. 모든 함수들이 그 소유권을 다 가져가버리면, 사실 변수의 의미가 없어지게 됩니다. 이번 글에서는 borrow를 통해서 어떻게 이를 해결했는지를 다룹니다.
이전 글의 마무리에서 이렇게 적었습니다.
함수에 값을 한 번 넘기면 호출자는 다시 그 값을 쓸 수 없습니다. 그러나, 두 변수가 같은 데이터를 동시에 들여다볼 일은 일반적으로 너무나 필요합니다.
이 한 문장이 borrow의 출발점입니다. Rust를 처음 만지면서 가장 처음 막힌 자리도 정확히 여기였습니다. 함수에 String을 한 번 넘긴 뒤 그 변수를 다시 사용하려고 하니 컴파일러가 가로막았고, 함수 하나에 값을 넘긴 것뿐인데 호출자 쪽 변수가 사라진다는 감각은 JavaScript와 TypeScript 만 다뤄온 제게는 너무 낯설었습니다.
소유권이 한 명에게만 있어야 한다는 규칙은 메모리 안전성을 위해 필요했지만, 그 규칙만으로는 짧은 함수 하나 호출하기도 답답해집니다. Rust는 이 답답함을 풀기 위해 "잠시 빌려쓰기"라는 별도의 장치를 마련해두었습니다.

예시

이전 글의 마지막 예시를 다시 가져오겠습니다.
다음 코드는 함수에 String을 넘긴 뒤 다시 사용하려 한 예시입니다.
fn print_message(s: String) { println!("{}", s); } let message = String::from("hello"); print_message(message); println!("{}", message); // 컴파일 에러
에러 코드는 다음과 같았습니다.
error[E0382]: borrow of moved value: `message` --> main.rs:8:20 | 6 | let message = String::from("hello"); | ------- move occurs because `message` has type `String`, which does not implement the `Copy` trait 7 | print_message(message); | ------- value moved here 8 | println!("{}", message); // 컴파일 에러 | ^^^^^^^ value borrowed here after move | note: consider changing this parameter type in function `print_message` to borrow instead if owning the value isn't necessary --> main.rs:2:25 | 2 | fn print_message(s: String) { | ------------- ^^^^^^ this parameter takes ownership of the value | | | in this function = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info) help: consider cloning the value if the performance cost is acceptable | 7 | print_message(message.clone()); | ++++++++ error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0382`.
print_message(message)라는 호출이 message의 소유권을 함수의 매개변수 s에게 넘겨버린다는 것이 문제였습니다. 함수가 끝나면 s가 scope를 벗어나면서 heap이 해제되고, 호출 이후의 message는 이미 책임을 잃은 변수가 됩니다.
이 동작이 답답한 이유는, 우리가 함수를 호출하는 본래 의도와 어긋나기 때문입니다. print_message라는 이름이 말해주듯 이 함수는 문자열을 읽기만 합니다. 사실 함수마다 다르겠지만, 이 함수는 데이터를 가져가서 책임지겠다는 의지가 전혀 없습니다. 그런데 Rust의 소유권 모델은 함수에 값을 넘기는 모든 순간을 "이 값을 통째로 가져갑니다"로 해석합니다. 잠시 보여주기만 하고 돌려받는 동작이 표현되지 않습니다.
JavaScript에서는 간단하게 처리됩니다.
function printMessage(s) { console.log(s); } const message = "hello"; printMessage(message); console.log(message); // 멀쩡히 동작
JavaScript에서는 함수에 값을 넘기는 일에 책임이라는 개념 자체가 없습니다. 함수는 그냥 값을 보고, 호출자는 그 값을 계속 들고 있습니다. Rust에서 같은 자연스러움을 얻으려면 별도의 표현이 필요합니다. 그 표현이 & 한 글자입니다.

& 한 글자가 하는 일

다음 코드는 위 예시를 borrow로 바꾼 모습입니다.
fn print_message(s: &String) { println!("{}", s); } let message = String::from("hello"); print_message(&message); println!("{}", message); // 멀쩡히 동작합니다.
세 군데가 바뀌었습니다. 함수의 매개변수 타입이 String에서 &String이 되었고, 호출 시 message가 아니라 &message를 넘기며, 마지막 줄의 message가 멀쩡히 살아남습니다.
&참조(reference)를 만드는 연산자입니다. &message는 "message라는 값 자체"가 아니라 "message가 있는 곳을 가리키는 표지"에 가깝습니다. 함수는 이 표지를 받아 들여다보지만, 표지의 주인은 여전히 호출자 쪽의 message입니다. Rust는 이 행동에 borrow, 즉 빌림이라는 이름을 붙였습니다.
이름이 적절한 이유가 있습니다. 빌림이라는 단어에는 두 가지 전제가 깔려 있습니다. 첫째, 빌리는 동안 주인은 그대로다. 책을 도서관에서 빌려도 책의 주인은 도서관입니다. 둘째, 빌린 것은 언젠가 돌려준다. 빌림은 영구적인 양도가 아니라 일시적인 사용입니다. 이 두 전제가 그대로 Rust의 borrow에 박혀 있습니다.
let message = String::from("hello"); │ │ message가 heap "hello"의 주인 ▼ ┌─────────┐ │ "hello" │ └─────────┘ ▲ │ print_message(&message); │ └── &로 만든 표지가 함수에 넘어감. 소유권은 그대로 message에게 있음.
소유권은 옮겨가지 않으므로 함수가 끝나도 heap이 해제되지 않습니다. 함수 호출 이후의 message는 여전히 자기 데이터의 주인이고, 따라서 자유롭게 사용할 수 있습니다

잠깐, 그러면 i32 같은 값은 왜 안 막혔는가

여기까지 읽고 직접 코드를 짜보면 한 가지 이상한 점을 만나게 됩니다. 다음 코드는 함수에 값을 그대로 넘겼는데도 컴파일러가 막아서지 않습니다.
fn double(n: i32) -> i32 { n * 2 } let x = 10; double(x); println!("{}", x); // 멀쩡히 동작
소유권 규칙대로라면 x도 함수에 넘기는 순간 사라져야 할 텐데, i32는 호출 이후에도 멀쩡합니다. 처음 이 동작을 봤을 때 의아했습니다. 같은 함수 호출인데 String은 막히고 i32는 통과한다는 사실이 일관되지 않아 보였습니다.
이전 예시의 에러 메시지에 사실 힌트가 있었습니다.
message has type String, which does not implement the Copy trait
StringCopy trait를 구현하지 않는다고 짚어주고 있습니다. 뒤집으면, Copy trait를 구현한 타입은 소유권이 옮겨가지 않고 값이 복사된다는 뜻입니다. i32, bool, char, f64 같이 stack에 사는 작은 타입들이 여기에 해당합니다. 이런 값들은 크기가 정해져 있고 heap을 가리키지 않으므로, 복사하는 비용이 사실상 무료입니다. 함수에 넘길 때 굳이 소유권을 옮길 필요 없이 그냥 복사 한 번이면 끝납니다.
이 사실을 알고 나면 borrow가 무엇을 푸는 도구인지가 더 분명해집니다. borrow는 복사가 비싼 값을 위한 장치입니다. 작은 값은 그냥 복사로 해결되니 &를 붙일 이유가 없고, String이나 Vec처럼 heap에 데이터를 들고 있는 값은 통째로 복사하는 게 비싸니까 빌림이라는 별도의 길이 필요한 것입니다. 소유권과 빌림이라는 두 개념 사이에 Copy라는 작은 빠져나갈 길이 하나 더 있는 셈입니다.
Copy trait 자체에 대한 더 자세한 이야기, 즉 어떤 타입이 Copy이고 어떤 타입이 아닌지, 사용자 정의 타입에 Copy를 어떻게 붙이는지는 trait를 본격적으로 다루는 후속 글에서 짚어보려고 합니다. 이 글에서는 "stack에 사는 작은 값은 빌림 없이도 자연스럽게 동작한다"는 사실 정도만 잡고 가면 충분합니다.

빌리는 동안 주인은 어디에 있는가

borrow를 조금 알자마자, 한 가지 의문이 따라옵니다. 함수가 &String을 받아서 그 안의 데이터를 들여다보는 동안, message는 어떤 상태에 놓이는가.
답은 단순합니다. 그대로 살아 있되, 어디 가지 않고, 가만히 있어야 합니다. 빌려준 동안에는 함부로 그 값을 손대지 못합니다. 그래야 빌린 쪽이 들여다보는 데이터가 중간에 바뀌지 않을 수 있습니다.
이 규칙이 가장 분명히 드러나는 코드가 다음입니다.
let mut v = vec![1, 2, 3]; let first = &v[0]; // v를 빌려서 첫 요소를 가리킴 v.push(4); // 컴파일 에러 println!("{}", first);
&v[0]이 만드는 참조는 v의 첫 요소를 가리킵니다. 그런데 Vec은 길이가 변할 수 있는 자료 구조라서, v.push(4)로 요소를 추가하다 보면 내부적으로 더 큰 메모리를 새로 할당하고 기존 데이터를 옮길 수도 있습니다. 그 순간 first가 가리키던 메모리는 더 이상 유효한 자리가 아니게 됩니다. 다른 언어에서는 이런 상황이 runtime (실행 시점)에 잘못된 메모리를 들여다보는 버그로 나타나지만, Rust는 컴파일 시점에 막아섭니다.
컴파일러가 보여주는 에러는 다음과 같습니다.
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable --> main.rs:20:5 | 18 | let first = &v[0]; // v를 빌려서 첫 요소를 가리킴 | - immutable borrow occurs here 19 | 20 | v.push(4); // 컴파일 에러 | ^^^^^^^^^ mutable borrow occurs here 21 | 22 | println!("{}", first); | ----- immutable borrow later used here error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0502`.
학습 노트 (2)에서 적었던 표현을 다시 빌리면, 이 에러도 컴파일러의 친절함의 또 다른 면입니다. 단순히 "안 된다"가 아니라, 어디서 빌림이 시작됐고, 어디서 충돌이 일어나며, 어디서 그 빌림이 다시 사용되는지를 한 번에 짚어줍니다. 각 주석의 위치에, 사실 주석이 필요가 없어도 될 것을 알 수 있습니다.

두 종류의 빌림: &&mut

방금 본 에러 메시지에 immutable borrowmutable borrow라는 두 단어가 등장했습니다. Rust의 borrow는 한 종류가 아닙니다. 단순히 읽기만 하는 빌림변경까지 하는 빌림이 따로 있고, 두 빌림은 다른 규칙을 따릅니다.
다음 코드는 두 빌림을 나란히 둔 예시입니다.
let mut s = String::from("hello"); let r1 = &s; // immutable borrow: 읽기만 가능 let r2 = &s; // 또 다른 immutable borrow도 OK println!("{} {}", r1, r2); let r3 = &mut s; // mutable borrow: 변경까지 가능 r3.push_str(", world"); println!("{}", r3);
&s로 만든 빌림은 데이터를 읽기만 합니다. 같은 시점에 여러 개가 동시에 존재해도 괜찮습니다. 모두가 같은 책을 같이 들여다보는 상황이고, 누구도 책을 고치지 않으니 충돌할 일이 없기 때문입니다.
&mut s로 만든 빌림은 데이터를 변경할 수 있습니다. 이쪽은 규칙이 정반대입니다. 같은 시점에 단 하나만 존재할 수 있고, 다른 어떤 빌림과도 공존할 수 없습니다. 한 명이 책을 펼쳐놓고 글자를 고치고 있는 동안, 다른 누구도 그 책을 들여다보거나 같이 고칠 수 없다는 규칙입니다.
이 두 규칙을 한 줄로 압축하면 다음과 같습니다. 공유는 변경을 허락하지 않고, 변경은 공유를 허락하지 않습니다. 같은 데이터를 여러 곳에서 동시에 읽는 것은 안전하고, 한 곳에서만 변경하는 것도 안전합니다. 위험은 그 두 가지가 섞일 때, 즉 한쪽은 읽고 있는 동안 다른 쪽이 그 데이터를 바꾸는 순간에 발생합니다. 이 위험이 다른 언어에서는 race condition이나 iterator invalidation 같은 이름으로 불리는 버그의 정체이며, Rust는 그 가능성을 컴파일 시점에 차단합니다. 다시 한 번 더 Rust 컴파일러의 친절함을 느낄 수 있었습니다.

컴파일러가 막아서는 순간

이 규칙이 실제로 어떻게 작동하는지를 두 가지 에러로 확인해보겠습니다.
다음 코드는 immutable borrow가 살아있는 동안 mutable borrow를 시도하는 경우입니다.
let mut s = String::from("hello"); let r1 = &s; let r2 = &mut s; // 컴파일 에러 println!("{} {}", r1, r2);
--> main.rs:38:27 | 37 | let r1: &String = &s; | -- immutable borrow occurs here 38 | let r2: &mut String = &mut s; // 컴파일 에러 | ^^^^^^ mutable borrow occurs here 39 | 40 | println!("{} {}", r1, r2); | -- immutable borrow later used here error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0502`.
r1이 살아 있는 동안에는 &mut s가 만들어질 수 없습니다. r1이 데이터를 읽고 있는데 그 데이터가 중간에 바뀐다면, r1이 본 값과 실제 값이 어긋나기 때문입니다.
다음 코드는 mutable borrow가 두 개 동시에 만들어지는 경우입니다.
let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; // 컴파일 에러 println!("{} {}", r1, r2);
error[E0499]: cannot borrow `s` as mutable more than once at a time --> main.rs:49:14 | 48 | let r1 = &mut s; | ------ first mutable borrow occurs here 49 | let r2 = &mut s; // 컴파일 에러 | ^^^^^^ second mutable borrow occurs here 50 | 51 | println!("{} {}", r1, r2); | -- first borrow later used here error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0499`.
mutable borrow는 하나만 존재할 수 있습니다. 만약 두 개가 동시에 같은 데이터를 변경할 수 있다면, 어떤 변경이 먼저 일어나는지에 따라 결과가 달라지는 비결정적 동작이 생깁니다. Rust는 그 가능성을 아예 만들지 않습니다.
학습 노트 (3)에서 "변수의 주인은 한 번에 한 명"이라고 정리했습니다. borrow는 그 규칙을 살짝 풀되, 다른 형태로 다시 죕니다. 읽기 빌림은 여럿이 가능하지만 변경은 막힌다. 변경 빌림은 단 하나만 가능하다. 주인이 한 명이라는 원칙이 빌림의 영역에서 다른 모양으로 반복되는 셈입니다.

잠깐, 그러면 동시에 못 쓰는 것 아닌가

여기까지 읽으면 한 가지 의문이 따라옵니다. 같은 변수를 immutable로 빌리고, 그 다음에 mutable로 빌리는 일이 진짜로 막힌다면, 코드를 짜기가 너무 답답하지 않은가.
다행히 Rust 컴파일러는 그렇게 융통성 없게 굴지는 않습니다. 다음 코드는 컴파일이 통과합니다.
let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{} {}", r1, r2); // r1과 r2는 여기까지만 사용됨 let r3 = &mut s; r3.push_str(", world"); println!("{}", r3);
r1r2println! 이후로 더 이상 쓰이지 않습니다. 컴파일러는 이 사실을 추적해서, r1r2의 빌림이 사실상 그 자리에서 끝났다고 판단합니다. 그 다음 줄의 &mut s는 살아있는 immutable borrow가 없는 상태에서 만들어지므로 안전합니다.
이 동작을 non-lexical lifetimes(NLL)라고 부릅니다. 빌림의 유효 범위가 변수의 scope가 아니라 실제 마지막 사용 시점까지로 좁혀집니다. 사실 최근에 lexical scope 에 대해서 글로서 다루다보니까 바로 이해할 수 있었습니다. 관련 글 링크 남겨둡니다. 다시 돌아와서, NLL이 도입되기 전에는 변수가 scope를 벗어날 때까지 빌림이 살아있다고 보아서 더 답답한 코드를 강요했지만, 지금은 컴파일러가 영리해진 덕분에 위 같은 패턴이 자연스럽게 통과합니다.
이 점을 짚는 이유는, 처음 borrow의 규칙을 배울 때 "두 종류의 빌림이 절대 공존할 수 없다"고 외우면 실제 코드와의 거리가 너무 멀어 보이기 때문입니다. 정확히는 "동시에 살아있는 동안에는 공존할 수 없다"입니다. 한쪽이 끝난 뒤 다른 쪽이 시작되는 패턴은 얼마든지 가능합니다.

마무리

borrow는 "주인은 그대로 두고 잠시 들여다본다"는 한 줄로 요약됩니다. &는 그 중 들여다보기만을 의미하는 표시이고, &mut은 들여다보는 동시에 손까지 댈 수 있는 표지입니다. 두 빌림은 한 가지 원칙을 공유합니다. 같은 데이터를 향해 읽기와 쓰기가 동시에 살아있어서는 안 된다. 이 원칙 하나로 race condition과 dangling reference 같은 버그군이 컴파일 시점에 사라집니다.
이전 학습 노트들을 묶어서 보면, Rust의 안전성 모델은 세 단계의 약속으로 쌓여 있습니다. 변수는 값의 주인이고(소유권), 그 주인됨은 잠시 빌려줄 수 있으며(borrow), 빌림은 동시에 읽기와 쓰기로 갈라질 수 없습니다(& vs &mut). 각 약속은 다른 약속의 빈자리를 메우는 형태로 이어집니다.
다음 학습 노트에서는 빌림에 대해 배우다보면, 자연스레 따라오는 또 하나의 질문, "그 빌림은 언제까지 유효한가?"를 다루려고 합니다. 빌림이 영원하지 않다는 사실은 이 글에서 전제로 깔았지만, 그 유효 기간을 컴파일러가 어떻게 추적하는지는 따로 다뤄야 할 주제입니다. 바로 lifetime입니다.

참고 자료