소유권이란 무엇인가?

소유권은 Rust 프로그램이 메모리를 관리하는 방식을 규정하는 일련의 규칙이다. 모든 프로그램은 실행 중에 컴퓨터의 메모리를 어떻게 사용할지 관리해야 한다. 어떤 언어는 가비지 컬렉션을 통해 프로그램이 실행되는 동안 더 이상 사용되지 않는 메모리를 정기적으로 찾아내고, 다른 언어에서는 프로그래머가 명시적으로 메모리를 할당하고 해제해야 한다. Rust는 세 번째 방식을 사용한다. 컴파일러가 확인하는 일련의 규칙을 통해 소유권 시스템으로 메모리를 관리한다. 만약 규칙 중 하나라도 위반되면 프로그램은 컴파일되지 않는다. 소유권의 어떤 기능도 프로그램 실행 속도를 느리게 하지 않는다.

소유권은 많은 프로그래머에게 새로운 개념이기 때문에 익숙해지려면 시간이 필요하다. 다행히 Rust와 소유권 시스템의 규칙에 익숙해질수록 안전하고 효율적인 코드를 자연스럽게 작성하기가 쉬워진다. 꾸준히 노력하자!

소유권을 이해하면 Rust를 독특하게 만드는 기능들을 깊이 있게 이해할 수 있는 기반을 마련하게 된다. 이 장에서는 매우 일반적인 데이터 구조인 문자열을 중심으로 몇 가지 예제를 통해 소유권을 배운다.

스택과 힙

많은 프로그래밍 언어는 스택과 힙에 대해 자주 생각할 필요가 없다. 하지만 Rust와 같은 시스템 프로그래밍 언어에서는 값이 스택에 있는지 힙에 있는지가 언어의 동작 방식과 특정 결정을 내려야 하는 이유에 영향을 미친다. 소유권의 일부는 이 장의 뒷부분에서 스택과 힙과 관련하여 설명할 것이므로, 여기서는 간단히 설명한다.

스택과 힙은 모두 코드가 런타임에 사용할 수 있는 메모리의 일부이지만, 구조가 다르다. 스택은 값을 받은 순서대로 저장하고 반대 순서로 제거한다. 이를 *후입선출(LIFO)*이라고 한다. 접시 더미를 생각해 보자. 접시를 더 추가할 때는 더미 위에 올리고, 접시가 필요할 때는 위에서 하나를 꺼낸다. 중간이나 아래에서 접시를 추가하거나 제거하는 것은 잘 작동하지 않는다! 데이터를 추가하는 것을 스택에 푸시라고 하고, 데이터를 제거하는 것을 스택에서 팝이라고 한다. 스택에 저장된 모든 데이터는 컴파일 시점에 알려진 고정된 크기를 가져야 한다. 컴파일 시점에 크기가 알려지지 않았거나 크기가 변할 수 있는 데이터는 대신 힙에 저장해야 한다.

힙은 덜 정리되어 있다. 데이터를 힙에 넣을 때는 일정량의 공간을 요청한다. 메모리 할당자는 힙에서 충분히 큰 빈 공간을 찾아 사용 중으로 표시하고, 그 위치의 주소인 포인터를 반환한다. 이 과정을 힙에 할당이라고 하며, 때로는 단순히 할당이라고 줄여 부르기도 한다(스택에 값을 푸시하는 것은 할당으로 간주되지 않는다). 힙에 대한 포인터는 알려진 고정된 크기이기 때문에 포인터를 스택에 저장할 수 있지만, 실제 데이터를 원할 때는 포인터를 따라가야 한다. 레스토랑에 앉는 것을 생각해 보자. 들어갈 때 함께 있는 사람 수를 말하면, 호스트가 모두 앉을 수 있는 빈 테이블을 찾아 안내한다. 만약 누군가 늦게 오면, 어디에 앉았는지 물어보고 찾아올 수 있다.

스택에 푸시하는 것은 힙에 할당하는 것보다 빠르다. 할당자는 새로운 데이터를 저장할 장소를 찾을 필요가 없기 때문이다. 그 위치는 항상 스택의 맨 위에 있다. 반면 힙에 공간을 할당하려면 더 많은 작업이 필요하다. 할당자는 먼저 데이터를 담을 수 있는 충분히 큰 공간을 찾은 다음, 다음 할당을 준비하기 위해 기록을 관리해야 한다.

힙의 데이터에 접근하는 것은 스택의 데이터에 접근하는 것보다 느리다. 포인터를 따라가야 하기 때문이다. 현대 프로세서는 메모리에서 덜 이동할수록 더 빠르게 작동한다. 레스토랑의 서버가 여러 테이블에서 주문을 받는 것을 계속 비유로 생각해 보자. 한 테이블에서 모든 주문을 받고 다음 테이블로 이동하는 것이 가장 효율적이다. 테이블 A에서 주문을 받고, 테이블 B에서 주문을 받고, 다시 테이블 A에서 주문을 받고, 다시 테이블 B에서 주문을 받는 것은 훨씬 더 느린 과정이다. 마찬가지로 프로세서는 다른 데이터와 가까이 있는 데이터(스택에 있는 것처럼)를 처리할 때 더 잘 작동한다. 힙에 있는 데이터처럼 멀리 떨어진 데이터를 처리할 때는 그렇지 않다.

코드가 함수를 호출할 때, 함수에 전달된 값(힙에 있는 데이터에 대한 포인터를 포함할 수 있음)과 함수의 지역 변수는 스택에 푸시된다. 함수가 끝나면 그 값들은 스택에서 팝된다.

코드의 어떤 부분이 힙의 어떤 데이터를 사용하고 있는지 추적하고, 힙에 중복된 데이터의 양을 최소화하며, 사용되지 않는 데이터를 정리해 공간이 부족하지 않도록 하는 것은 모두 소유권이 해결하는 문제들이다. 소유권을 이해하면 스택과 힙에 대해 자주 생각할 필요가 없지만, 소유권의 주요 목적이 힙 데이터를 관리하는 것임을 알면 왜 그런 방식으로 작동하는지 이해하는 데 도움이 된다.

소유권 규칙

먼저 Rust의 소유권 규칙에 대해 살펴보자. 이 규칙들을 기억하면서 예제를 통해 자세히 알아보자:

  • Rust에서 모든 값은 _소유자_를 가진다.
  • 한 번에 하나의 소유자만 존재할 수 있다.
  • 소유자가 스코프를 벗어나면 값은 자동으로 해제된다.

변수의 스코프

기본적인 Rust 문법을 배웠으니, 이제부터는 모든 예제에 fn main() { 코드를 포함하지 않을 것이다. 따라서 예제를 따라하려면 직접 main 함수 안에 코드를 넣어야 한다. 이렇게 하면 예제가 더 간결해지고, 보일러플레이트 코드보다 실제 중요한 내용에 집중할 수 있다.

소유권의 첫 번째 예제로 변수의 _스코프_를 살펴보자. 스코프는 프로그램 내에서 항목이 유효한 범위를 의미한다. 다음 변수를 보자:

#![allow(unused)]
fn main() {
let s = "hello";
}

변수 s는 문자열 리터럴을 참조하며, 이 문자열 값은 프로그램 텍스트에 하드코딩되어 있다. 변수는 선언된 시점부터 현재 _스코프_가 끝날 때까지 유효하다. Listing 4-1은 변수 s가 유효한 범위를 주석으로 표시한 프로그램을 보여준다.

fn main() {
    {                      // s is not valid here, it’s not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}
Listing 4-1: 변수와 그 변수가 유효한 스코프

다시 말해, 여기에는 두 가지 중요한 시점이 있다:

  • s가 스코프에 들어오면 유효해진다.
  • 스코프를 벗어날 때까지 유효하다.

이 시점에서 스코프와 변수의 유효성 간의 관계는 다른 프로그래밍 언어와 유사하다. 이제 이 이해를 바탕으로 String 타입을 소개하며 더 깊이 알아볼 것이다.

String 타입

소유권 규칙을 설명하기 위해, 우리는 “데이터 타입” 섹션에서 다룬 것보다 더 복잡한 데이터 타입이 필요하다. 이전에 다룬 타입들은 크기가 고정되어 있으며, 스택에 저장되고 스코프가 끝나면 스택에서 제거된다. 또한, 다른 코드 부분에서 동일한 값을 다른 스코프에서 사용해야 할 때, 빠르고 간단하게 복사하여 독립적인 인스턴스를 만들 수 있다. 하지만 우리는 힙에 저장된 데이터를 살펴보고, Rust가 언제 그 데이터를 정리하는지 알아볼 필요가 있다. String 타입은 이를 설명하기에 적합한 예제다.

우리는 String의 소유권과 관련된 부분에 집중할 것이다. 이러한 측면은 표준 라이브러리에서 제공되거나 여러분이 직접 만든 다른 복잡한 데이터 타입에도 적용된다. String에 대해 더 깊이 다루는 내용은 8장에서 논의할 것이다.

우리는 이미 문자열 리터럴을 보았다. 문자열 리터럴은 프로그램에 하드코딩된 문자열 값이다. 문자열 리터럴은 편리하지만, 모든 상황에 적합하지는 않다. 한 가지 이유는 불변성 때문이다. 또 다른 이유는 코드를 작성할 때 모든 문자열 값을 알 수 없다는 점이다. 예를 들어, 사용자 입력을 받아 저장하려면 어떻게 해야 할까? 이러한 상황을 위해 Rust는 두 번째 문자열 타입인 String을 제공한다. 이 타입은 힙에 할당된 데이터를 관리하며, 컴파일 시점에 알 수 없는 크기의 텍스트를 저장할 수 있다. from 함수를 사용하여 문자열 리터럴로부터 String을 생성할 수 있다. 예를 들면 다음과 같다:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

더블 콜론 :: 연산자를 사용하면 String 타입 아래에 있는 특정 from 함수를 네임스페이스로 지정할 수 있다. 이는 string_from과 같은 이름을 사용하는 것보다 더 명확하다. 이 구문에 대해서는 5장의 “메서드 구문” 섹션과 7장의 “모듈 트리에서 항목을 참조하는 경로”에서 모듈과 함께 네임스페이싱을 논의할 때 더 자세히 설명할 것이다.

이러한 종류의 문자열은 변경할 수 있다:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // This will print `hello, world!`
}

그렇다면 여기서 차이점은 무엇일까? 왜 String은 변경할 수 있지만 리터럴은 변경할 수 없는가? 그 차이는 이 두 타입이 메모리를 다루는 방식에 있다.

메모리와 할당

문자열 리터럴의 경우 컴파일 시점에 내용을 알 수 있으므로, 텍스트가 최종 실행 파일에 직접 하드코딩된다. 이 때문에 문자열 리터럴은 빠르고 효율적이다. 하지만 이러한 특성은 문자열 리터럴의 불변성에서 비롯된다. 안타깝게도, 컴파일 시점에 크기를 알 수 없고 프로그램 실행 중에 크기가 변할 수 있는 텍스트 조각을 바이너리에 포함할 수는 없다.

String 타입은 가변적이고 확장 가능한 텍스트를 지원하기 위해, 컴파일 시점에 크기를 알 수 없는 메모리를 힙에 할당하여 내용을 저장한다. 이는 다음을 의미한다:

  • 프로그램 실행 중에 메모리 할당자로부터 메모리를 요청해야 한다.
  • String 사용이 끝나면 이 메모리를 할당자에게 반환할 방법이 필요하다.

첫 번째 부분은 우리가 직접 처리한다: String::from을 호출하면, 그 구현이 필요한 메모리를 요청한다. 이는 거의 모든 프로그래밍 언어에서 보편적인 방식이다.

하지만 두 번째 부분은 다르다. 가비지 컬렉터(GC)가 있는 언어에서는, GC가 더 이상 사용되지 않는 메모리를 추적하고 정리하므로 우리가 신경 쓸 필요가 없다. GC가 없는 대부분의 언어에서는, 메모리가 더 이상 사용되지 않을 시점을 파악하고 명시적으로 메모리를 해제하는 코드를 호출하는 것이 우리의 책임이다. 이 작업을 정확히 수행하는 것은 역사적으로 어려운 프로그래밍 문제였다. 메모리 해제를 잊으면 메모리가 낭비되고, 너무 일찍 해제하면 유효하지 않은 변수가 생기며, 두 번 해제하면 버그가 발생한다. 정확히 한 번의 allocate와 한 번의 free를 짝지어야 한다.

Rust는 다른 방식을 선택한다: 메모리를 소유한 변수가 스코프를 벗어나면 자동으로 메모리가 반환된다. 다음은 문자열 리터럴 대신 String을 사용한 스코프 예제이다:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

String이 필요로 하는 메모리를 반환할 수 있는 자연스러운 시점은 s가 스코프를 벗어날 때이다. 변수가 스코프를 벗어나면 Rust는 우리를 위해 특별한 함수를 호출한다. 이 함수는 drop이라고 하며, String의 작성자가 메모리를 반환하는 코드를 넣는 곳이다. Rust는 닫는 중괄호에서 drop을 자동으로 호출한다.

참고: C++에서는 아이템의 수명이 끝날 때 리소스를 해제하는 이 패턴을 _리소스 획득은 초기화(RAII)_라고 부르기도 한다. Rust의 drop 함수는 RAII 패턴을 사용해본 사람이라면 익숙할 것이다.

이 패턴은 Rust 코드 작성 방식에 깊은 영향을 미친다. 지금은 단순해 보일 수 있지만, 힙에 할당한 데이터를 여러 변수가 사용하려는 더 복잡한 상황에서는 코드의 동작이 예상치 못한 결과를 초래할 수 있다. 이제 그러한 상황을 몇 가지 살펴보자.

변수와 데이터의 이동(Move) 상호작용

Rust에서는 여러 변수가 동일한 데이터와 다양한 방식으로 상호작용할 수 있다. Listing 4-2에서 정수를 사용한 예제를 통해 이를 살펴보자.

fn main() {
    let x = 5;
    let y = x;
}
Listing 4-2: 변수 x의 정수 값을 y에 할당

이 코드가 무엇을 하는지 짐작할 수 있다: “값 5x에 바인딩한 다음, x의 값을 복사하여 y에 바인딩한다.” 이제 두 변수 xy가 모두 5라는 값을 갖게 된다. 정수는 크기가 고정된 단순한 값이기 때문에 스택에 두 개의 5 값이 저장된다.

이제 String 버전을 살펴보자:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

이 코드는 매우 유사해 보이므로, 동작 방식도 같을 것이라고 추측할 수 있다: 즉, 두 번째 줄에서 s1의 값을 복사하여 s2에 바인딩할 것이라고 생각할 수 있다. 하지만 실제로는 그렇지 않다.

String이 내부적으로 어떻게 동작하는지 이해하기 위해 Figure 4-1을 살펴보자. String은 왼쪽에 표시된 세 부분으로 구성된다: 문자열 내용을 담고 있는 메모리의 포인터, 길이, 그리고 용량. 이 데이터 그룹은 스택에 저장된다. 오른쪽에는 힙에 저장된 문자열 내용이 있다.

두 개의 테이블: 첫 번째 테이블은 스택에 있는 s1의 표현을 포함하며, 길이(5), 용량(5), 그리고 두 번째 테이블의 첫 번째 값을 가리키는 포인터로 구성된다. 두 번째 테이블은 힙에 있는 문자열 데이터를 바이트 단위로 나타낸다.

Figure 4-1: "hello" 값을 가진 Strings1에 바인딩된 메모리 표현

길이는 String의 내용이 현재 사용 중인 메모리 크기(바이트 단위)이고, 용량은 할당자로부터 받은 총 메모리 크기(바이트 단위)이다. 길이와 용량의 차이는 중요하지만, 이 맥락에서는 용량을 무시해도 괜찮다.

s1s2에 할당할 때, String 데이터가 복사된다. 이는 스택에 있는 포인터, 길이, 용량을 복사한다는 의미이다. 포인터가 참조하는 힙의 데이터는 복사하지 않는다. 즉, 메모리 내 데이터 표현은 Figure 4-2와 같다.

세 개의 테이블: 스택에 있는 s1과 s2를 나타내는 테이블과, 둘 다 힙에 있는 동일한 문자열 데이터를 가리키는 테이블.

Figure 4-2: s1의 포인터, 길이, 용량을 복사한 s2 변수의 메모리 표현

이 표현은 Figure 4-3과 같지 않다. Figure 4-3은 Rust가 힙 데이터도 복사한 경우의 메모리 상태를 보여준다. Rust가 이렇게 동작했다면, 힙 데이터가 큰 경우 s2 = s1 연산이 런타임 성능 측면에서 매우 비용이 많이 들었을 것이다.

네 개의 테이블: s1과 s2의 스택 데이터를 나타내는 두 개의 테이블과, 각각 힙에 있는 자신의 문자열 데이터 복사본을 가리키는 테이블.

Figure 4-3: Rust가 힙 데이터도 복사한 경우 s2 = s1이 수행할 수 있는 또 다른 가능성

앞서 변수가 스코프를 벗어나면 Rust가 자동으로 drop 함수를 호출하여 해당 변수의 힙 메모리를 정리한다고 설명했다. 하지만 Figure 4-2는 두 데이터 포인터가 동일한 위치를 가리키는 것을 보여준다. 이는 문제가 된다: s2s1이 스코프를 벗어날 때 둘 다 동일한 메모리를 해제하려고 시도한다. 이를 이중 해제(double free) 오류라고 하며, 이전에 언급한 메모리 안전 버그 중 하나이다. 메모리를 두 번 해제하면 메모리 손상이 발생할 수 있고, 이는 잠재적으로 보안 취약점으로 이어질 수 있다.

메모리 안전을 보장하기 위해, let s2 = s1; 줄 이후 Rust는 s1을 더 이상 유효하지 않은 것으로 간주한다. 따라서 s1이 스코프를 벗어날 때 Rust는 아무것도 해제할 필요가 없다. s2가 생성된 후 s1을 사용하려고 하면 어떤 일이 발생하는지 확인해보자; 작동하지 않을 것이다:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

Rust가 무효화된 참조를 사용하지 못하도록 막기 때문에 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:15
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |               ^^^^ value borrowed here after move
  |
  = 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
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

다른 언어를 사용하면서 _얕은 복사(shallow copy)_와 _깊은 복사(deep copy)_라는 용어를 들어본 적이 있다면, 데이터를 복사하지 않고 포인터, 길이, 용량만 복사하는 개념이 얕은 복사처럼 들릴 수 있다. 하지만 Rust는 첫 번째 변수도 무효화하기 때문에 이를 얕은 복사라고 부르지 않고 _이동(move)_이라고 한다. 이 예제에서는 s1s2로 _이동되었다_고 말한다. 따라서 실제로 발생하는 일은 Figure 4-4와 같다.

세 개의 테이블: 스택에 있는 s1과 s2를 나타내는 테이블과, 둘 다 힙에 있는 동일한 문자열 데이터를 가리키는 테이블. s1 테이블은 회색으로 표시되어 있으며, s1이 더 이상 유효하지 않기 때문에 s2만 힙 데이터에 접근할 수 있다.

Figure 4-4: s1이 무효화된 후의 메모리 표현

이렇게 하면 문제가 해결된다! s2만 유효하기 때문에, s2가 스코프를 벗어날 때 메모리를 해제하면 된다.

또한, 이로 인해 암시적으로 내려지는 설계 선택이 있다: Rust는 절대로 데이터의 “깊은” 복사를 자동으로 생성하지 않는다. 따라서 모든 자동 복사는 런타임 성능 측면에서 비용이 적다고 가정할 수 있다.

스코프와 할당

스코프, 소유권, 그리고 drop 함수를 통해 메모리가 해제되는 관계는 이와 반대로 작동한다. 기존 변수에 완전히 새로운 값을 할당하면, Rust는 drop을 호출해 원래 값의 메모리를 즉시 해제한다. 다음 코드를 예로 들어보자:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

우선 변수 s를 선언하고 "hello"라는 값을 가진 String에 바인딩한다. 그런 다음 즉시 "ahoy"라는 값을 가진 새로운 String을 생성하고 s에 할당한다. 이 시점에서 힙에 있는 원래 값은 더 이상 참조되지 않는다.

스택에 있는 문자열 값을 나타내는 테이블 s가 힙에 있는 두 번째 문자열 데이터(ahoy)를 가리키고, 원래의 문자열 데이터(hello)는 더 이상 접근할 수 없어 회색으로 표시됨.

그림 4-5: 원래 값이 완전히 대체된 후의 메모리 표현.

따라서 원래 문자열은 즉시 스코프를 벗어난다. Rust는 drop 함수를 실행해 해당 메모리를 즉시 해제한다. 마지막에 값을 출력하면 "ahoy, world!"가 표시된다.

변수와 데이터의 복제 상호작용

만약 스택 데이터뿐만 아니라 String의 힙 데이터까지 깊은 복사를 하고 싶다면, clone이라는 일반적인 메서드를 사용할 수 있다. 메서드 문법은 5장에서 자세히 다룰 예정이지만, 메서드는 많은 프로그래밍 언어에서 흔히 사용되는 기능이기 때문에 이미 접해본 적이 있을 것이다.

다음은 clone 메서드를 사용한 예제이다:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

이 코드는 정상적으로 동작하며, 그림 4-3에 나온 것처럼 힙 데이터가 복사되는 동작을 명확히 보여준다.

clone 호출을 보면, 어떤 임의의 코드가 실행되고 있으며 그 코드가 비용이 많이 들 수 있다는 것을 알 수 있다. 이는 무언가 다른 일이 일어나고 있다는 시각적 지표 역할을 한다.

스택 전용 데이터: Copy

아직 다루지 않은 또 다른 중요한 개념이 있다. 정수를 사용하는 이 코드는 잘 작동하며 유효하다. 이 코드의 일부는 리스트 4-2에서 보여준 것과 같다.

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

하지만 이 코드는 방금 배운 내용과 모순되는 것처럼 보인다. clone을 호출하지 않았는데도 x는 여전히 유효하며, y로 이동되지 않았다.

그 이유는 컴파일 시점에 크기가 알려진 정수와 같은 타입들은 스택에 완전히 저장되기 때문에 실제 값을 복사하는 것이 빠르기 때문이다. 이는 y 변수를 생성한 후에도 x가 유효한 상태를 유지하는 것을 막을 이유가 없음을 의미한다. 다시 말해, 여기서는 깊은 복사와 얕은 복사 사이에 차이가 없으므로 clone을 호출해도 일반적인 얕은 복사와 다르게 동작하지 않는다. 따라서 clone을 생략할 수 있다.

Rust는 스택에 저장되는 타입에 사용할 수 있는 Copy 트레이트라는 특별한 어노테이션을 제공한다. 정수가 그 예이다. (트레이트에 대해서는 10장에서 더 자세히 다룬다.) 어떤 타입이 Copy 트레이트를 구현하면, 해당 타입을 사용하는 변수는 이동하지 않고 단순히 복사되며, 다른 변수에 할당된 후에도 여전히 유효하다.

만약 타입이나 그 일부가 Drop 트레이트를 구현한 경우, Rust는 해당 타입에 Copy 어노테이션을 추가하는 것을 허용하지 않는다. 값이 스코프를 벗어날 때 특별한 처리가 필요한 타입에 Copy 어노테이션을 추가하면 컴파일 시점에 오류가 발생한다. Copy 트레이트를 구현하기 위해 타입에 Copy 어노테이션을 추가하는 방법은 부록 C의 “파생 가능한 트레이트”를 참고하라.

그렇다면 어떤 타입이 Copy 트레이트를 구현할까? 특정 타입에 대한 문서를 확인하면 확실히 알 수 있지만, 일반적으로 단순한 스칼라 값들로 구성된 그룹은 Copy를 구현할 수 있다. 반면, 할당이 필요하거나 리소스 형태인 타입은 Copy를 구현할 수 없다. 다음은 Copy를 구현하는 타입의 예시이다:

  • u32와 같은 모든 정수 타입
  • truefalse 값을 가지는 불리언 타입 bool
  • f64와 같은 모든 부동소수점 타입
  • 문자 타입 char
  • Copy를 구현하는 타입만 포함하는 튜플. 예를 들어, (i32, i32)Copy를 구현하지만, (i32, String)은 구현하지 않는다.

소유권과 함수

함수에 값을 전달하는 메커니즘은 변수에 값을 할당하는 것과 유사하다. 변수를 함수에 전달하면 할당과 마찬가지로 값이 이동하거나 복사된다. 아래 예제에서는 변수가 스코프에 들어가고 나가는 위치를 주석으로 표시했다.

Filename: src/main.rs
fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // because i32 implements the Copy trait,
                                    // x does NOT move into the function,
    println!("{}", x);              // so it's okay to use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
Listing 4-3: 소유권과 스코프가 주석 처리된 함수 예제

takes_ownership 함수를 호출한 후에 s를 사용하려고 하면 Rust는 컴파일 타임 오류를 발생시킨다. 이러한 정적 검사는 실수를 방지하는 데 도움이 된다. main 함수에 sx를 사용하는 코드를 추가해 보면, 어디서 사용할 수 있고 어디서 소유권 규칙 때문에 사용할 수 없는지 확인할 수 있다.

반환 값과 스코프

반환 값을 통해 소유권을 이전할 수도 있다. 리스트 4-4는 값을 반환하는 함수 예제를 보여준다. 이 예제는 리스트 4-3과 유사한 주석을 포함한다.

Filename: src/main.rs
fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}
Listing 4-4: 반환 값의 소유권 이전

변수의 소유권은 항상 동일한 패턴을 따른다. 값을 다른 변수에 할당하면 소유권이 이동한다. 힙에 데이터를 포함한 변수가 스코프를 벗어나면, 데이터의 소유권이 다른 변수로 이동하지 않는 한 drop에 의해 값이 정리된다.

이 방식은 동작하지만, 모든 함수에서 소유권을 가져온 뒤 다시 반환하는 것은 다소 번거롭다. 함수가 값을 사용하되 소유권을 가져가지 않게 하려면 어떻게 해야 할까? 함수 내부에서 반환하고 싶은 데이터 외에도, 다시 사용하려면 전달한 모든 것을 반환해야 한다는 점은 상당히 불편하다.

Rust는 튜플을 사용해 여러 값을 반환할 수 있도록 지원한다. 리스트 4-5에서 이를 확인할 수 있다.

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}
Listing 4-5: 매개변수의 소유권 반환

하지만 이는 너무 많은 절차와 작업을 요구한다. 다행히 Rust에는 소유권을 이전하지 않고 값을 사용할 수 있는 기능이 있다. 이를 _참조_라고 부른다.