엔드포인트를 읽기만 하는 함수가 소유권까지 가져갈 필요는 없습니다. 검사 횟수를 올리는 함수도 영구 소유권이 아니라 잠깐의 쓰기 권한만 있으면 됩니다. Rust는 이 약속을 타입에 적습니다. 공유 빌림은 &Endpoint, 가변 빌림은 &mut Endpoint입니다.
참조를 포인터라고 줄여 부르면 편하지만 API 설계에 필요한 부분이 빠집니다. 참조를 사용하면 값을 소유하지 않고도 그 값에 접근할 수 있고 일반적인 경우 포인터와 비슷한 표현을 씁니다. 하지만 프로그래밍 계약은 더 넓습니다. 참조는 사용하는 동안 유효해야 하고 안전한 Rust는 그 빌림과 어떤 접근이 겹칠 수 있는지 제한합니다. &T와 &mut T를 메모리 주소보다 접근 권한으로 읽으면 컴파일 오류도 훨씬 이해하기 쉽습니다.
이 글에서는 작은 Rust 2024 엔드포인트 CLI에 그 권한을 적용합니다. 일부러 실패하게 만든 프로그램 두 개는 예상한 진단 코드와 핵심 문구를 출력해야 합니다.
1. 참조를 계약으로 읽기
빌림 연산자는 서로 다른 계약을 만듭니다.
&T는 공유 읽기 권한을 줍니다. 여러 공유 빌림이 동시에 활성화돼도 됩니다.&mut T는 가변 권한을 주며 빌림이 활성화된 동안에는 그 접근이 배타적이어야 합니다.
두 번째 규칙을 "가변 참조는 하나만"이라고 외우기 쉽습니다. 여기에 시간축을 더해야 정확합니다. 살아 있는 빌림 구간이 겹칠 때만 접근이 충돌합니다. 빌림은 바깥 블록이 끝날 때가 아니라 참조가 마지막으로 사용된 지점에서 끝날 수 있습니다. 그래서 다음 코드는 컴파일됩니다.
let mut endpoint = String::from("api");
let shared = &endpoint;
println!("{shared}"); // last use of the shared borrow
let mutable = &mut endpoint;
mutable.push_str("-v2");
가변 빌림이 시작될 때 공유 참조는 더 쓰이지 않으므로 별도 블록이 없어도 됩니다. 물론 중괄호로 경계를 드러내면 코드가 더 명확해질 때도 있습니다. 무조건 블록을 추가하기보다 마지막 사용 지점을 찾고 읽기와 쓰기가 실제로 겹쳐야 하는지부터 살펴보죠.
참조가 빌린 값보다 오래 살아서도 안 됩니다. 그래서 "참조는 메모리 주소"라는 설명만으로 설계를 판단하기 어렵습니다. 주소만 봐서는 대상이 아직 유효한지, 다른 경로로 변경해도 되는지 알 수 없습니다.
2. 도메인 함수의 빌림
예제의 Endpoint는 텍스트를 직접 소유합니다. 렌더링 함수는 값을 관찰하기만 하므로 &Endpoint를 받습니다. 검사 횟수를 바꾸는 함수는 &mut Endpoint를 받으며 이 타입 자체가 변경 가능성을 드러냅니다.
#[derive(Debug, PartialEq, Eq)]
pub struct Endpoint {
name: String,
url: String,
checks: u32,
}
impl Endpoint {
#[must_use]
pub fn new(name: &str, url: &str) -> Self {
Self {
name: name.to_owned(),
url: url.to_owned(),
checks: 0,
}
}
#[must_use]
pub const fn checks(&self) -> u32 {
self.checks
}
}
#[must_use]
pub fn render_endpoint(endpoint: &Endpoint) -> String {
format!(
"{} -> {} (checks: {})",
endpoint.name, endpoint.url, endpoint.checks
)
}
fn increment_checks(endpoint: &mut Endpoint) {
endpoint.checks += 1;
}
pub fn record_check(endpoint: &mut Endpoint) -> String {
increment_checks(&mut *endpoint);
render_endpoint(&*endpoint)
}
render_endpoint는 보고서용 String을 새로 할당하지만 Endpoint나 필드를 복제하지는 않습니다. 호출이 끝난 뒤에도 소유자는 엔드포인트를 쓸 수 있습니다. 공유 참조로는 엔드포인트를 바꿀 수 없으므로 여러 호출자가 동시에 공유 참조를 가져도 됩니다.
record_check는 재빌림을 코드에 그대로 드러냅니다. 기존 &mut Endpoint에서 &mut *endpoint를 만들면 increment_checks 호출 동안만 유지되는 더 짧은 가변 빌림이 생깁니다. 그 호출이 끝난 뒤에는 &*endpoint로 공유 재빌림을 만들어 렌더링합니다. 짧은 재빌림이 끝나면 원래의 가변 참조를 다시 사용할 수 있습니다.
함수 호출에서는 매개변수 타입에 맞는 재빌림을 Rust가 보통 자동으로 넣습니다. 실제 코드는 다음처럼 간결하게 써도 같은 의도를 나타냅니다.
pub fn record_check(endpoint: &mut Endpoint) -> String {
increment_checks(endpoint);
render_endpoint(endpoint)
}
명시형은 동작을 이해할 때 유용합니다. 모든 호출에 습관처럼 붙일 장식은 아닙니다.
3. CLI 경계의 소유권
main이 엔드포인트를 소유하고 보조 함수는 필요한 시간만 빌립니다.
use article_08_borrowing_references::{Endpoint, record_check, render_endpoint};
fn main() {
let mut endpoint = Endpoint::new("api", "https://example.com/health");
let label = render_endpoint(&endpoint);
let audit_copy = render_endpoint(&endpoint);
println!("before: {label}");
println!("audit: {audit_copy}");
println!("after: {}", record_check(&mut endpoint));
println!("owner can still read checks: {}", endpoint.checks());
}
두 render_endpoint 호출은 공유 빌림을 사용합니다. 이어지는 record_check 호출만 호출 시간 동안 배타적인 가변 권한을 얻습니다. 호출 후 main이 endpoint를 다시 읽으므로 가변 빌림이 소유권을 넘기지 않았다는 사실도 확인됩니다.
따라서 함수 시그니처는 짧은 설계 문서 역할을 합니다. Endpoint를 받으면 함수가 값을 소비한다는 뜻입니다. &Endpoint는 기존 상태를 관찰하고 &mut Endpoint는 다른 접근을 막는 동안 기존 상태를 바꿀 수 있음을 뜻합니다. clone()부터 추가하기 전에 세 계약 중 무엇이 맞는지 결정해야 합니다.
4. E0502: 접근 구간 중첩
실패 예제는 Cargo의 일반 빌드 대상 밖에 뒀습니다. 첫 예제는 공유 빌림을 유지한 채 공유 참조가 마지막으로 사용되기 전에 가변 빌림을 만들려고 합니다.
fn main() {
let mut endpoint = String::from("api");
let shared = &endpoint;
let mutable = &mut endpoint;
mutable.push_str("-v2");
println!("{shared}");
}
rustc는 E0502로 이 코드를 거부합니다.
error[E0502]: cannot borrow `endpoint` as mutable because it is also borrowed as immutable
진단에는 설계에 쓸 만한 위치가 세 군데 나옵니다. 공유 빌림이 시작된 곳, 충돌하는 가변 빌림을 시도한 곳, 공유 빌림을 살아 있게 만드는 나중 사용 지점입니다. 셋을 이으면 작은 데이터 흐름도가 됩니다. 읽기를 먼저 끝낼 수 있다면 마지막 사용을 업데이트 앞으로 옮깁니다. 두 작업이 정말 동시에 같은 데이터에 접근해야 한다면 데이터 모델이나 작업 경계를 다시 검토할 차례입니다.
이때 이유를 따지지 않고 endpoint를 복제하면 안 됩니다. 복제는 서로 독립된 데이터를 만들기 때문에 어느 값이 기준인지 모호해지고 접근 충돌 대신 설계 오류를 감출 수 있습니다.
5. E0499: 경쟁하는 쓰기 경로
두 번째 실패 예제에는 사용 구간이 겹치는 가변 빌림이 두 개 있습니다.
fn main() {
let mut endpoint = String::from("api");
let first = &mut endpoint;
let second = &mut endpoint;
first.push_str("-one");
second.push_str("-two");
}
컴파일러는 E0499를 출력합니다.
error[E0499]: cannot borrow `endpoint` as mutable more than once at a time
대부분은 순서대로 접근하면 해결됩니다. first의 사용을 끝내고 나서 second를 만듭니다. 하나의 &mut 빌림 안에서 전체 변경을 수행하는 연산으로 API를 묶는 방법도 있습니다. 데이터가 실제로 분리돼 있다면 서로 다른 구조체 필드를 빌리거나, 영역이 겹치지 않음을 증명하는 안전한 분할 API를 쓸 수 있습니다.
이 오류는 mut를 더 붙이라는 뜻이 아닙니다. 바인딩의 mut는 변경을 허용할 뿐 별칭 규칙을 없애지 않습니다. E0499는 배타적 접근 경로 두 개가 동시에 살아 있다고 알려줍니다.
6. Rust 2024 빌림 예제
전체 프로젝트는 examples/article-08-borrowing-references에 있습니다. 외부 의존성이 없고 다른 글의 크레이트도 사용하지 않습니다.
[package]
name = "article-08-borrowing-references"
version = "0.1.0"
edition = "2024"
publish = false
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
all = "warn"
pedantic = "warn"
해당 디렉터리에서 다음 명령을 실행합니다.
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
./verify_compile_fail.sh
프로그램 출력은 다음과 같습니다.
before: api -> https://example.com/health (checks: 0)
audit: api -> https://example.com/health (checks: 0)
after: api -> https://example.com/health (checks: 1)
owner can still read checks: 1
통합 테스트 세 개가 통과합니다. 동시에 수행하는 공유 읽기, 소유권 이전 없는 변경, 하나의 가변 참조를 사용한 반복 보조 함수 호출을 각각 검사합니다. 컴파일 실패 검증기는 거부돼야 하는 두 파일을 rustc --edition=2024로 컴파일하고 오류 코드와 핵심 진단 문구를 함께 확인합니다.
compile_fail/shared_then_mutable.rs: verified E0502 (cannot borrow `endpoint` as mutable because it is also borrowed as immutable)
compile_fail/two_mutable.rs: verified E0499 (cannot borrow `endpoint` as mutable more than once at a time)
도구체인 버전이 달라지면 컴파일러가 붙이는 설명이나 출력 형식은 바뀔 수 있습니다. 안정적인 오류 코드와 핵심 문구를 함께 확인하면 버전별 전체 진단을 글에 복사하지 않으면서도 실패 예제가 뜻밖에 성공하는 회귀를 잡을 수 있습니다.
7. 단순 규칙의 한계
"여러 읽기 또는 하나의 쓰기"는 안전한 Rust를 익힐 때 유용한 규칙이지만 Rust 메모리 모델 전체를 설명하지는 않습니다. Cell, RefCell, Mutex, RwLock 같은 내부 가변성 타입은 일부 검사를 다른 방식으로 옮깁니다. 원시 포인터에는 별도 규칙이 적용되며 unsafe 코드 관점에서 따로 검토해야 합니다. 이 CLI에는 둘 다 필요 없습니다.
일반적인 도메인 함수라면 의도를 정확히 나타내는 가장 좁은 시그니처에서 시작하는 편이 좋습니다. 관찰에는 &T, 제자리 변경에는 &mut T, 값을 보관하거나 소비해야 할 때는 소유권을 받습니다. 빌림 검사기가 코드를 거부하면 첫 빌림, 마지막 사용, 충돌한 접근을 찾습니다. 대개 이 세 지점에서 빌림을 줄여야 하는지, 작업을 순차 단계로 나눠야 하는지, API 경계를 고쳐야 하는지가 드러납니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 08 프로젝트에서 확인할 수 있습니다.
답글 남기기