Rust 기초: 소유권(Ownership)과 빌림(Borrowing)

Rust를 처음 배우다 보면 컴파일러가 자꾸 “value used here after move”나 “borrow of moved value” 같은 오류를 뱉어서 당황하게 되죠. 분명 올바른 코드를 작성한 것 같은데 컴파일이 안 되니 답답하기도 하고요. 이런 오류들은 모두 Rust의 핵심 개념인 소유권(Ownership)과 관련이 있습니다.

대부분의 프로그래밍 언어는 가비지 컬렉터(GC)를 통해 메모리를 관리하거나, C/C++처럼 프로그래머가 직접 메모리를 할당하고 해제합니다. Rust는 이 두 가지 방식 대신 소유권이라는 독특한 시스템으로 컴파일 시점에 메모리 안전성을 보장하는데요. GC로 인한 성능 저하도 없고, 수동 메모리 관리로 인한 버그도 없는 셈이죠.

이번 글에서는 Rust의 소유권 개념과 참조(References), 빌림(Borrowing)에 대해서 살펴보겠습니다. 아직 Rust의 기본 자료형이 익숙하지 않다면 원시 자료형 글을 먼저 읽어보시는 걸 권장합니다.

소유권이란?

Rust에서 모든 값은 소유자(owner)라는 변수를 가집니다. 소유권에는 세 가지 규칙이 있는데요.

각 값에는 소유자 변수가 하나 있고, 동시에 여러 소유자가 존재할 수는 없습니다. 그리고 소유자가 스코프를 벗어나면 값이 삭제(drop)됩니다.

간단한 예제로 살펴볼까요?

fn main() {
    let email = String::from("user@email.com"); // email이 소유자
    println!("{email}");
} // 여기서 email이 스코프를 벗어나며 메모리가 해제됨

변수 emailString::from("user@email.com")으로 생성된 문자열의 소유자입니다. main 함수가 끝나면 email이 스코프를 벗어나고, Rust는 자동으로 해당 메모리를 해제합니다. 이 과정을 drop이라고 부릅니다.

스택과 힙 메모리

소유권을 제대로 이해하려면 스택(Stack)과 힙(Heap) 메모리의 차이를 알아야 합니다.

스택은 함수 호출과 함께 자동으로 관리되는 메모리 영역입니다. 후입선출(LIFO) 방식으로 동작하고, 데이터를 넣고 빼는 속도가 매우 빠른 대신 컴파일 시점에 크기를 알 수 있는 데이터만 저장할 수 있죠.

힙은 런타임에 동적으로 할당되는 메모리 영역입니다. 크기가 가변적인 데이터를 저장할 수 있어 유연하지만, 할당과 해제에 비용이 더 듭니다.

정수, 부동소수점, 불리언, 고정 크기 배열처럼 컴파일 시점에 크기가 정해진 타입들은 스택에 저장됩니다. 이런 데이터는 복사 비용이 저렴해서 변수에 할당하면 값이 그대로 복사(copy)되고요.

let x = 5;
let y = x; // x의 값이 복사되어 y에 저장됨
println!("{x}, {y}"); // 둘 다 사용 가능

반면 힙에는 런타임에 크기가 결정되거나 변할 수 있는 데이터가 저장됩니다. String, Vec, Box 같은 타입들이 힙을 사용합니다. 힙 데이터는 복사 비용이 크기 때문에 기본적으로 복사 대신 소유권이 이동(move)합니다.

let email1 = String::from("user@email.com");
let email2 = email1; // email1의 소유권이 email2로 이동
// println!("{email1}"); // 오류! email1은 더 이상 유효하지 않음
println!("{email2}"); // email2만 사용 가능

스택에 저장되는 타입들은 Copy 트레이트를 구현하고 있어서 할당 시 자동으로 복사가 일어납니다. 힙 데이터를 가진 타입들은 Copy 트레이트가 없어서 소유권 이동이 발생하는 것이죠.

CopyClone의 차이, 언제 어떤 걸 써야 하는지는 Copy와 Clone 트레이트 글에서 자세히 다루고 있습니다.

소유권 이동 (Move)

소유권 이동은 변수 할당뿐만 아니라 함수에 인자를 전달할 때도 발생합니다.

fn send_email(email: String) {
    println!("전송: {email}");
} // 여기서 email이 drop됨

fn main() {
    let email = String::from("user@email.com");
    send_email(email); // email의 소유권이 함수로 이동
    // println!("{email}"); // 오류! email은 이미 이동됨
}

send_email 함수에 email을 전달하면 소유권이 함수 내부의 매개변수로 이동합니다. 함수가 끝나면 그 매개변수가 스코프를 벗어나면서 메모리가 해제됩니다. 따라서 함수 호출 후에는 원래 변수를 사용할 수 없습니다. 이렇게 함수가 인자의 소유권을 가져가는 것을 소비(consume)한다고도 표현합니다.

함수에서 값을 반환하면 소유권을 다시 호출자에게 전달할 수 있습니다.

fn create_email() -> String {
    let email = String::from("new@example.com");
    email // 소유권을 반환
}

fn main() {
    let email = create_email(); // 반환된 값의 소유권을 받음
    println!("{email}");
}

하지만 매번 소유권을 주고받는 것은 번거롭습니다. 값을 잠시 빌려서 사용하고 싶을 때는 어떻게 해야 할까요?

명시적 복사 (Clone)

소유권 이동 대신 데이터를 통째로 복사하고 싶다면 clone() 메서드를 사용할 수 있습니다.

fn main() {
    let email1 = String::from("user@email.com");
    let email2 = email1.clone(); // 힙 데이터까지 깊은 복사

    println!("{email1}"); // email1도 여전히 사용 가능
    println!("{email2}");
}

clone()은 힙에 있는 데이터까지 전부 복사하기 때문에 비용이 큽니다. 그래서 Rust가 기본적으로 이동을 선택하는 거죠. clone()은 정말로 두 개의 독립적인 복사본이 필요할 때만 쓰는 게 좋습니다.

앞서 스택 데이터(정수, 불리언 등)는 자동으로 복사된다고 했는데요. 이런 타입들은 Copy 트레이트를 구현하고 있어서 clone()을 명시적으로 호출할 필요가 없습니다. 반면 String이나 Vec처럼 힙을 사용하는 타입들은 Copy가 아니라 Clone만 구현하고 있어서 복사하려면 반드시 clone()을 직접 호출해야 합니다.

참조와 빌림 (References & Borrowing)

참조(Reference)를 사용하면 소유권을 이동하지 않고 값을 빌려서 사용할 수 있습니다. & 기호를 사용하여 참조를 생성합니다.

fn get_length(email: &String) -> usize {
    email.len()
} // email은 참조이므로 drop되지 않음

fn main() {
    let email = String::from("user@email.com");
    let len = get_length(&email); // email의 참조를 전달
    println!("이메일 길이: {len}"); // email 여전히 사용 가능
}
결과
이메일 길이: 14

&emailemail을 가리키는 참조를 생성합니다. get_length 함수는 String의 참조인 &String을 받는데요. 참조는 소유권을 가지지 않기 때문에 함수가 끝나도 원본 값은 drop되지 않습니다. 이렇게 참조를 통해 값을 사용하는 것을 빌림(borrowing)이라고 합니다.

참고로 실제 코드에서는 &String 대신 &str을 매개변수로 받는 게 더 관용적인데요. 이 부분은 뒤에 나오는 슬라이스 섹션에서 자세히 다루겠습니다.

기본적으로 참조는 불변(immutable)입니다. 참조를 통해 값을 읽을 수는 있지만 수정할 수는 없습니다.

fn try_modify(email: &String) {
    // email.push_str(".kr"); // 오류! 불변 참조로는 수정 불가
}

가변 참조 (Mutable References)

값을 수정하려면 가변 참조(mutable reference)를 사용해야 합니다. &mut 키워드로 가변 참조를 생성합니다.

fn add_domain(email: &mut String) {
    email.push_str(".kr");
}

fn main() {
    let mut email = String::from("user@email.com");
    add_domain(&mut email);
    println!("{email}");
}
결과
user@email.com.kr

가변 참조를 사용하려면 원본 변수도 mut으로 선언되어 있어야 합니다.

가변 참조에는 중요한 제약이 있습니다. 특정 스코프에서 특정 데이터에 대한 가변 참조는 하나만 존재할 수 있습니다.

let mut email = String::from("user@email.com");

let r1 = &mut email;
// let r2 = &mut email; // 오류! 동시에 두 개의 가변 참조 불가

println!("{r1}");

또한 불변 참조가 있는 동안에는 가변 참조를 만들 수 없습니다.

let mut email = String::from("user@email.com");

let r1 = &email;     // 불변 참조 - OK
let r2 = &email;     // 불변 참조 - OK
// let r3 = &mut email; // 오류! 불변 참조가 있는 동안 가변 참조 불가

println!("{r1}, {r2}");

왜 이런 제약을 두는 걸까요? 데이터 경쟁(data race)을 컴파일 시점에 막기 위해서입니다. 두 개 이상의 포인터가 동시에 같은 데이터에 접근하면서 그 중 하나라도 쓰기를 하면 문제가 생기는데, Rust는 이런 상황 자체를 컴파일되지 않게 만들어버립니다.

댕글링 참조 방지

댕글링 참조(dangling reference)는 이미 해제된 메모리를 가리키는 포인터입니다. C/C++에서는 이런 포인터 때문에 정의되지 않은 동작이 발생하곤 하는데요. Rust 컴파일러는 댕글링 참조를 원천적으로 허용하지 않습니다.

// 이 코드는 컴파일되지 않음
fn dangle() -> &String {
    let email = String::from("user@email.com");
    &email // email의 참조를 반환하려고 함
} // email이 drop되어 참조가 무효화됨

컴파일러는 email이 함수가 끝나면서 drop되기 때문에 그 참조를 반환할 수 없다는 것을 알고 오류를 발생시킵니다. 해결책은 참조 대신 소유권을 반환하는 것입니다.

fn no_dangle() -> String {
    let email = String::from("user@email.com");
    email // 소유권을 호출자에게 이동
}

슬라이스 (Slices)

슬라이스는 컬렉션의 연속된 일부분을 참조하는 방법입니다. 참조의 일종이니 당연히 소유권을 가지지 않고요.

문자열 슬라이스는 &str 타입으로, String의 일부분을 가리킵니다.

fn main() {
    let email = String::from("user@email.com");

    let id = &email[0..4];      // "user"
    let domain = &email[5..14]; // "email.com"

    println!("{id} / {domain}");
}

범위 문법에서 시작 인덱스가 0이면 생략할 수 있고, 끝까지 가면 끝 인덱스도 생략할 수 있습니다.

let email = String::from("user@email.com");

let slice1 = &email[..4];   // "user"
let slice2 = &email[5..];   // "email.com"
let slice3 = &email[..];    // 전체 문자열

슬라이스를 사용하는 실용적인 예제를 살펴보겠습니다. 이메일에서 아이디 부분(@ 앞)을 추출하는 함수입니다.

fn get_email_id(email: &str) -> &str {
    let bytes = email.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b'@' {
            return &email[0..i];
        }
    }

    &email[..]
}

fn main() {
    let email = String::from("user@email.com");
    let id = get_email_id(&email);
    println!("아이디: {id}");
}
결과
아이디: user

여기서 함수 매개변수 타입이 &String이 아니라 &str인 점을 눈여겨보세요. Rust에서는 함수가 문자열을 빌려서 읽기만 할 때 &str을 받는 게 관용적입니다. &str로 받으면 String과 문자열 리터럴 모두 인자로 넘길 수 있어서 더 유연하거든요. Rust의 역참조 강제(Deref coercion) 덕분에 &String은 자동으로 &str로 변환됩니다.

문자열 리터럴도 슬라이스입니다. let email = "user@email.com";에서 email의 타입은 &str로, 바이너리에 저장된 문자열을 가리키는 슬라이스입니다. 그래서 문자열 리터럴은 불변인 것이죠.

마치며

소유권 시스템은 처음에 낯설고 제약이 많게 느껴지지만, 이 규칙들 덕분에 메모리 안전성을 컴파일 시점에 보장받게 됩니다.

핵심만 다시 짚어보면 이렇습니다. 각 값은 소유자가 하나뿐이고, 소유자가 스코프를 벗어나면 값이 drop됩니다. 참조를 쓰면 소유권 이동 없이 값을 빌릴 수 있고요. 불변 참조는 여러 개 만들 수 있지만, 가변 참조는 동시에 하나만 존재합니다.

이 개념들이 익숙해지면 Rust 컴파일러의 오류 메시지가 두렵지 않게 될 겁니다. 소유권을 확장한 스마트 포인터에 관심이 있다면 Box, Rc, Arc 글도 참고해보세요.

Rust의 다른 기초 개념들은 구조체열거형 글에서 살펴보실 수 있습니다.

This work is licensed under CC BY 4.0 CC BY

Discord