[Rust 시리즈 6편] 참조와 borrowing 기본기

English version

5편에서 ownership이 "값이 하나의 주인만 가진다"는 규칙이라면, 6편은 "그 값에 잠깐 접근하고 싶을 때 어떤 제약을 지켜야 하는가"에 초점을 맞춥니다. [reference|참조]는 ownership을 넘기지 않고 값을 읽거나 쓰는 통로이고, borrowing은 그 통로를 빌리는 행위입니다. 이번 글에서는 lifetime 심화 규칙을 꺼내지 않고, 불변 참조와 가변 참조가 언제 허용되는지만 몸에 익힙니다.

이 규칙이 존재하는 이유는, 같은 데이터를 누군가는 읽고 다른 누군가는 동시에 바꾸는 상황을 미리 차단하기 위해서입니다. Rust는 이런 충돌을 실행 중에 잡기보다 컴파일 단계에서 먼저 막으려 합니다.

이번 글에서 다룰 개념

  1. 불변 참조 (&T): 값을 읽기만 할 수 있는 렌즈로, 동시에 여러 개를 만들 수 있습니다.
  2. 가변 참조 (&mut T): 값을 수정할 수 있지만, 같은 시점에 오직 하나만 존재해야 합니다.
  3. 핵심 borrowing 규칙: "동시에 여러 불변 참조" 또는 "동시에 하나의 가변 참조"는 괜찮지만, 이 둘을 섞어 쓰는 순간 컴파일러가 막습니다.

예제로 보는 참조 규칙

Rust는 변수 자체가 기본적으로 불변이라는 점에서 시작합니다. 참조도 같은 철학을 따릅니다.

fn main() {
    let note = String::from("Rust ownership");
    let read_only = &note; // 불변 참조
    let also_read = &note; // 또 다른 불변 참조

    println!("{} / {}", read_only, also_read);
    println!("원본도 그대로 쓸 수 있음: {}", note);
}

불변 참조는 값을 복사하지 않고도 여러 곳에서 읽게 해 줍니다. 여기서 중요한 지점은 "참조를 쓰는 동안 원본을 수정하려 하면" 오류가 난다는 점입니다. 반대로 가변 참조는 수정 권한을 빌려 오지만, 동시에 단 하나만 허용됩니다.

fn main() {
    let mut score = 80;
    {
        let change = &mut score; // 가변 참조는 하나만
        *change += 5;
    } // scope이 끝나면 change가 drop되고, 다시 score를 쓸 수 있음

    println!("최종 점수: {}", score);
}

*change는 참조를 역참조(dereference)해서 실제 값을 가리키도록 만드는 연산자입니다. 즉, "참조가 가리키는 원래 값으로 따라간다"는 뜻입니다. 가변 참조를 쓸 때는 mut&mut를 동시에 기억해야 합니다.

동시에 허용되지 않는 조합

Borrowing 규칙을 어기는 코드를 일부러 적어 보고, 컴파일러 메시지를 읽는 연습이 필요합니다.

fn main() {
    let mut label = String::from("Rust");
    let read = &label;
    let write = &mut label; // error: 불변 참조가 살아 있는 동안 가변 참조 금지
    println!("{} {}", read, write);
}

이 오류는 Rust가 데이터 경합을 컴파일 단계에서 차단하려고 하기 때문입니다. 여기서 중요한 표현은 "같은 scope"보다 "같은 시점에 참조가 살아 있는 동안"입니다. 실제 프로젝트에서는 scope을 좁히거나, 필요한 시점 이후로 참조를 옮겨 "동시 접근" 상황을 피합니다.

값으로 넘길 때와 참조로 넘길 때

fn read_title(title: &String) {
    println!("읽기 전용: {}", title);
}

fn main() {
    let title = String::from("Rust notes");
    read_title(&title); // 참조를 넘김
    println!("원본 계속 사용 가능: {}", title);
}

함수 인자에 String을 그대로 넘기면 ownership이 이동하지만, &String이나 &str를 넘기면 읽기 권한만 빌려주는 것입니다. 따라서 &x를 붙이는 순간 "가져가는 것이 아니라 빌려준다"는 의도가 코드에 드러납니다.

작게라도 실전 맥락에 연결하기

이 시리즈에서는 학습 진행 상황을 다루는 간단한 콘솔 프로그램을 계속 발전시킵니다. 우선 학습 진도를 문자열로 관리하고, 참조를 통해 요약을 만들어 봅니다.

fn describe(status: &String) {
    println!("진행 상황: {}", status);
}

fn main() {
    let mut status = String::from("ownership 복습 중");
    describe(&status); // 불변 참조로 읽기만 허용

    update(&mut status); // 가변 참조로 상태 변경
    describe(&status);
}

fn update(status: &mut String) {
    status.push_str(", borrowing 연습");
}

status의 ownership은 main이 유지합니다. describe는 읽을 권한만 빌리고, update는 수정 권한을 단독으로 빌립니다. 함수 시그니처에 & 또는 &mut를 명시하는 순간, "이 함수는 값을 가져가지 않고 빌린다"는 의도가 선명해집니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo checkcargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. starter를 fork한 뒤 src/main.rs를 연다
  2. 본문 예제를 붙여 넣고 cargo check와 cargo run을 차례로 실행한다
  3. 타입, 값, 참조 흐름을 바꿔 컴파일 피드백과 출력 차이를 비교한다

Rust 실습은 브라우저 미리보기보다 터미널 피드백이 더 중요합니다. 여러 파일 구조나 추가 crate가 필요한 예제는 파일 배치를 조금 더 손봐야 할 수 있습니다.

손으로 따라하기

  • String 대신 Vec<i32>를 써서, 불변 참조는 합계를 계산하고 가변 참조는 새 값을 추가하는 예제를 직접 만들어 보세요.
  • 서로 다른 scope을 이용해 불변 참조를 먼저 사용한 뒤 drop하고, 이후 가변 참조를 만드는 코드를 작성해 보세요.
  • 함수 인자로 참조를 넘길 때 컴파일러가 강제하는 lifetime 규칙이 어디까지 암묵적 선언인지 살펴보세요. (완전한 lifetime 표기는 14편에서 다룹니다.)

마무리

참조와 borrowing의 핵심은 "읽기와 쓰기 권한을 분리해 데이터 경합을 언어 차원에서 막는다"는 점입니다. 불변 참조는 다수를, 가변 참조는 단 하나만 허용하는 규칙만 기억해도 이후 슬라이스, 문자열, 구조체 메서드 설계까지 흔들리지 않습니다. 다음 글에서는 문자열과 슬라이스를 다루며 참조 규칙이 실제 데이터 조각을 안전하게 나누는 데 어떻게 도움이 되는지 살펴보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요