Tech Wiki

TOPICSSERIES

[Rust 실전 로드맵 07] Rust 소유권 이해하기: 이동·복사·스택·힙

String을 다른 변수에 대입한 뒤 원래 변수를 쓰면 컴파일 오류가 납니다. 같은 모양의 코드에서 u8은 멀쩡합니다. 차이는 값이 스택에 있느냐보다 타입이 Copy인지, 그리고 표현식이 소유권을 넘기는지에 달려 있습니다.

이 글의 목표는 코드를 보기 전에 다음 줄에서 어느 바인딩이 유효한지 예측하는 것입니다. 예측이 되면 E0382를 만날 때마다 반사적으로 clone()을 붙이지 않아도 됩니다.

fn main() {
    let name = String::from("api");
    let registered_name = name;

    println!("registered: {registered_name}");
    println!("original: {name}");
}

마지막 줄은 컴파일되지 않습니다. String 값의 소유권이 registered_name으로 이동했기 때문입니다. 반면 u8처럼 Copy를 구현한 타입은 대입과 함수 호출에서 묵시적으로 복사되므로 이전 바인딩도 계속 쓸 수 있습니다.

1. 소유권과 유효성

소유권에는 세 규칙이 있습니다. 각 값에는 소유자가 있습니다. 한 시점의 소유자는 하나이며 그 소유자가 스코프를 벗어나면 값이 버려집니다. 입문 단계에서는 여기서 '소유자'를 현재 그 값을 사용할 책임이 있는 바인딩으로 읽으면 충분합니다.

let y = x;를 만났을 때는 타입부터 확인합니다.

연산 뒤 상태 T: Copy T: !Copy
let y = x; x, y 모두 유효 y가 소유하고 x는 무효
take(x) (fn take(v: T)) 호출 뒤 x 유효 소유권이 매개변수로 이동해 x는 무효
let y = x.clone(); (T: Clone) 둘 다 유효 둘 다 유효하지만 복제 의미와 비용은 타입마다 다름
let y = make(); 반환값의 소유권을 y가 받음 반환값의 소유권을 y가 받음

'무효'는 메모리가 즉시 사라졌다는 뜻이 아닙니다. Rust 프로그램에서 해당 바인딩으로 값에 다시 접근할 수 없다는 뜻입니다. 실제 기계 코드에서 비트 복사가 일어났는지도 별개입니다. 이동과 복사는 둘 다 메모리 비트 복사로 구현될 수 있고 최적화로 없어질 수도 있습니다. 관찰해야 할 언어 규칙은 연산 뒤 원래 바인딩을 사용할 수 있는지입니다.

2. String 대입과 이동

String은 UTF-8 바이트를 담은 growable 문자열입니다. 개념적 구성 요소는 버퍼 포인터, 길이, 용량이며 문자열 내용의 버퍼는 힙에 저장됩니다.

단순히 이 세 구성 요소를 복사한 뒤 두 String을 모두 소유자로 취급하면 같은 버퍼를 두 번 정리할 위험이 생깁니다. Rust는 StringCopy를 구현하지 않습니다. 따라서 대입은 버퍼를 복제하지 않고 소유권을 새 바인딩으로 넘깁니다. 이전 바인딩의 사용은 컴파일 단계에서 막힙니다.

위 코드를 stable rustc 1.98.1로 컴파일한 진단은 다음과 같습니다.

error[E0382]: borrow of moved value: `name`
 --> tests/ui/use_after_assignment.rs:6:26
  |
2 |     let name = String::from("api");
  |         ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
3 |     let registered_name = name;
  |                           ---- value moved here
...
6 |     println!("original: {name}");
  |                          ^^^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let registered_name = name.clone();
  |                               ++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.

도움말은 clone()을 가능한 수정안으로 제시할 뿐 설계상 정답이라고 보장하지 않습니다. registered_name만 필요하다면 원래 코드는 이동 자체가 맞고 마지막 println!을 지워야 합니다.

3. 스택과 힙은 판정 기준이 아니다

스택은 고정 크기 값을 LIFO 순서로 다룹니다. 힙 할당은 allocator가 사용할 공간을 찾아 포인터를 돌려준다는 모델로 설명할 수 있습니다. String은 힙 버퍼를 소유하므로 이동의 필요성을 보여 주기 좋은 예입니다.

그렇다고 '스택 값은 복사되고 힙 값은 이동한다'고 외우면 곧 틀립니다.

  • Box<i32> 자체는 고정 크기 포인터지만 Copy가 아니어서 대입하면 이동합니다.
  • [u8; 4096]은 모든 원소가 Copy이므로 배열도 Copy입니다. 크기가 크다고 자동으로 이동 타입이 되지 않습니다.
  • &String 같은 공유 참조는 가리키는 StringCopy가 아니어도 참조 값 자체는 Copy입니다.

어떤 로컬 값이 최종 기계 코드에서 반드시 특정 스택 슬롯에 존재한다고 약속할 수도 없습니다. 최적화기는 관찰 가능한 동작을 보존하면서 저장과 복사를 없애거나 재배치할 수 있습니다. String의 힙 버퍼와 소유권 규칙을 설명하되, 디버그 빌드의 한 메모리 배치를 언어 보장으로 확대해서는 안 됩니다.

4. Copy는 타입의 계약이다

Copy는 비트를 단순 복사해 값을 복제할 수 있는 타입을 표시하는 trait입니다. 복사는 대입이나 함수 인자 전달에서 묵시적으로 일어나지만 구현자가 동작을 바꿀 수는 없습니다. u8, bool, char, 정수와 부동소수점 타입, Copy 원소로만 이뤄진 튜플과 배열 등이 여기에 들어갑니다.

#[must_use]
pub const fn doubled_retry_budget(retries: u8) -> u8 {
    retries * 2
}

let retries = 3;
let doubled = doubled_retry_budget(retries);

assert_eq!(retries, 3);
assert_eq!(doubled, 6);

retries는 함수 호출 시 매개변수로 복사됩니다. 호출 뒤에도 원래 바인딩이 유효합니다.

사용자 정의 타입은 모든 필드가 Copy일 때 Copy를 구현할 수 있습니다. 반대로 String 필드를 가진 구조체는 그 조건을 만족하지 못합니다. Drop을 구현한 타입도 Copy가 될 수 없습니다. 리소스 정리 책임을 단순 비트 복사로 늘릴 수 없기 때문입니다.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
    name: String,
    url: String,
    retries: u8,
}

retries 필드는 Copy지만 nameurl은 아닙니다. 구조체 전체는 Clone만 파생하고 Copy는 파생하지 않습니다. endpoint.retries()가 반환한 u8은 복사할 수 있어도 Endpoint를 값으로 넘기면 이동합니다.

5. 함수 호출의 소유권

값 매개변수 endpoint: Endpoint는 호출자가 소유권을 넘긴다는 API입니다. 아래 register는 값을 소비한 뒤 반환 튜플에 다시 넣어 호출자에게 소유권을 돌려줍니다.

#[must_use]
pub fn register(endpoint: Endpoint) -> (Endpoint, String) {
    let summary = format!("registered {} -> {}", endpoint.name, endpoint.url);
    (endpoint, summary)
}

호출부에서는 반환된 소유자를 새로 바인딩합니다.

let endpoint = Endpoint::new("api", "https://example.com/health", 3);
let (endpoint, summary) = register(endpoint);

println!("{summary}");
println!("active owner: {}", endpoint.name());

첫 번째 endpoint는 호출 순간 무효가 됩니다. 반환된 Endpoint를 같은 이름으로 shadowing한 새 바인딩이 이후 값을 소유합니다. 이름은 같지만 유효성 구간은 다릅니다.

값을 돌려주는 패턴이 언제나 좋은 API라는 뜻은 아닙니다. 함수가 읽기만 한다면 참조를 받는 편이 자연스럽습니다. 빌림은 다음 편에서 자세히 다룹니다. 여기서는 함수 인자도 대입과 마찬가지로 Copy면 복사되고 그렇지 않으면 이동한다는 규칙만 잡으면 됩니다.

소유권을 돌려받지 않은 채 원래 변수를 쓰면 같은 E0382가 발생합니다.

fn consume(value: String) {
    println!("consumed: {value}");
}

fn main() {
    let name = String::from("api");
    consume(name);
    println!("after call: {name}");
}

컴파일러는 consume의 매개변수가 소유권을 가져갈 필요가 없다면 매개변수 타입을 빌림으로 바꾸라고 안내하고 소유자가 둘 필요가 있다면 clone()도 후보로 제시합니다. 둘은 비용만 다른 같은 수정이 아닙니다. 함수의 소유권 계약을 다르게 만듭니다.

6. 의도적인 clone()

Clone은 명시적으로 새 값을 만드는 trait입니다. Copy와 달리 구현이 임의 코드를 실행할 수 있고 비용도 타입마다 다릅니다. String::clone()은 문자열 버퍼를 복제합니다. 반면 RcArcclone()은 같은 데이터에 대한 공유 소유권을 늘립니다. 그러므로 clone()을 항상 deep copy라고 부르는 것도 정확하지 않습니다.

아래 예제에서는 운영용 엔드포인트와 이름을 바꿀 감사 스냅샷이 각각 독립된 소유 값을 가져야 합니다. 이 요구라면 복제가 의도와 맞습니다.

let endpoint = Endpoint::new("api", "https://example.com/health", 3);
let mut audit = endpoint.clone();
audit.rename("api-audit");

assert_eq!(endpoint.name(), "api");
assert_eq!(audit.name(), "api-audit");

E0382를 만났을 때는 다음 순서로 판단하면 과도한 복제를 줄일 수 있습니다.

  1. 이전 소유자가 더는 필요 없다면 값을 이동합니다.
  2. 함수가 잠깐 읽거나 고치기만 한다면 참조를 받도록 API를 바꿉니다.
  3. 제어 흐름이 끝난 뒤 소유권을 계속 써야 한다면 반환값으로 다시 받거나 데이터 구조를 재배치합니다.
  4. 서로 독립된 소유 값이 정말 둘 필요할 때 clone()을 씁니다. 공유 소유권이 요구사항이면 그에 맞는 타입을 별도로 검토합니다.

컴파일러의 clone 도움말은 4번 선택지가 문법상 가능하다는 말입니다. 1~3번보다 적절하다는 성능·설계 판단까지 대신하지 않습니다.

7. Rust 2024 예제 실행

전체 프로젝트는 프로젝트 디렉터리의 examples/article-07-ownership-moves에 있습니다. 외부 의존성은 없으며 src/lib.rs, src/main.rs, tests/ui만으로 예제와 실패 사례를 확인할 수 있습니다.

cd examples/article-07-ownership-moves
cargo run --quiet
registered api -> https://example.com/health
retry budget: 3 -> 6
independent owners: api and api-audit

정상 코드와 문서의 compile_fail 예제는 다음 명령으로 검사했습니다.

cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features

라이브러리 단위 테스트 3개와 compile_fail 문서 테스트 1개가 통과했고 실패는 0개였습니다.

대입 뒤 사용과 함수 호출 뒤 사용은 각각 tests/ui/use_after_assignment.rs, tests/ui/use_after_call.rsrustc --edition 2024로 컴파일해 두 경우 모두 E0382와 비정상 종료가 발생해야 합니다.

8. 예측 연습

아래 네 줄은 이제 실행하지 않고도 판정할 수 있습니다.

let a = String::from("api");
let b = a;
let n = 3_u8;
let m = n;

bString의 새 소유자이고 a는 무효입니다. u8: Copy이므로 nm은 둘 다 유효합니다. 이 판정에는 값이 몇 바이트인지, 디버거에서 어느 주소로 보이는지, 힙 할당이 몇 번 일어났는지가 필요하지 않습니다. 타입의 trait와 소유권이 넘어가는 표현식만 보면 됩니다.

전체 소스 코드

이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 07 프로젝트에서 확인할 수 있습니다.

출처


답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Tech Wiki

Built with WordPress · Learn in public.