Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

소유권이란 무엇인가?

소유권(ownership) 은 러스트 프로그램이 메모리를 관리하는 방식을 지배하는 규칙 집합입니다. 모든 프로그램은 실행 중 컴퓨터 메모리를 어떻게 사용할지 관리해야 합니다. 어떤 언어는 프로그램이 실행되는 동안 더 이상 쓰이지 않는 메모리를 정기적으로 찾아 정리하는 가비지 컬렉션을 사용합니다. 또 다른 언어에서는 프로그래머가 메모리를 명시적으로 할당하고 해제해야 합니다. 러스트는 세 번째 접근법을 사용합니다. 컴파일러가 검사하는 규칙 집합을 가진 소유권 시스템을 통해 메모리를 관리합니다. 이 규칙 가운데 하나라도 어기면 프로그램은 컴파일되지 않습니다. 소유권의 어떤 기능도 프로그램이 실행되는 동안 성능을 느리게 만들지 않습니다.

소유권은 많은 프로그래머에게 새로운 개념이기 때문에 익숙해지는 데 시간이 조금 걸립니다. 좋은 소식은, 러스트와 소유권 규칙에 익숙해질수록 안전하고 효율적인 코드를 자연스럽게 작성하는 일이 점점 쉬워진다는 점입니다. 계속 연습해 보세요!

소유권을 이해하게 되면, 러스트를 독특하게 만드는 기능들을 이해할 수 있는 단단한 기초를 얻게 됩니다. 이 장에서는 아주 흔한 자료구조인 문자열을 중심으로 몇 가지 예를 직접 따라가며 소유권을 배워 보겠습니다.

스택과 힙

많은 프로그래밍 언어에서는 스택과 힙을 자주 의식할 필요가 없습니다. 하지만 러스트 같은 시스템 프로그래밍 언어에서는 어떤 값이 스택에 있는지 힙에 있는지가 언어의 동작 방식과 왜 특정 선택을 해야 하는지에 영향을 줍니다. 이 장의 뒤에서 소유권의 일부를 스택과 힙에 연결해 설명할 것이므로, 먼저 짧게 정리해 두겠습니다.

스택과 힙은 둘 다 런타임에 코드가 사용할 수 있는 메모리의 일부이지만, 구조가 다릅니다. 스택은 값을 들어온 순서대로 저장하고, 반대 순서로 제거합니다. 이것을 후입선출(last in, first out; LIFO) 이라고 합니다. 접시 더미를 떠올려 보세요. 접시를 더 얹을 때는 맨 위에 올리고, 접시 하나가 필요할 때는 맨 위에서 꺼냅니다. 가운데나 맨 아래에서 넣거나 빼는 방식은 잘 맞지 않겠지요. 데이터를 추가하는 것을 스택에 푸시한다(push onto the stack) 고 하고, 데이터를 제거하는 것을 스택에서 팝한다(pop off the stack) 고 합니다. 스택에 저장되는 모든 데이터는 크기가 알려져 있고 고정되어 있어야 합니다. 컴파일 시점에 크기를 알 수 없거나, 크기가 바뀔 수 있는 데이터는 대신 힙에 저장해야 합니다.

힙은 덜 정돈되어 있습니다. 힙에 데이터를 넣을 때는 일정한 양의 공간을 요청합니다. 메모리 할당자는 힙 안에서 그 크기를 수용할 수 있는 빈 자리를 찾고, 그 자리를 사용 중으로 표시한 뒤, 그 위치의 주소인 포인터(pointer) 를 반환합니다. 이 과정을 힙에 할당한다(allocating on the heap) 고 하며, 때로는 줄여서 그냥 할당한다 고도 합니다(스택에 값을 푸시하는 것은 보통 할당이라고 하지 않습니다). 힙 데이터에 대한 포인터는 크기가 알려져 있고 고정되어 있으므로 포인터 자체는 스택에 저장할 수 있지만, 실제 데이터에 접근하려면 포인터를 따라가야 합니다. 식당에 들어가 자리를 안내받는 상황을 떠올려 보세요. 여러분이 인원 수를 말하면, 안내 직원이 모두가 앉을 수 있는 빈 테이블을 찾아 거기로 데려다줍니다. 일행 중 누군가 늦게 오면, 이미 어디 앉았는지 물어본 뒤 여러분을 찾아갈 수 있습니다.

스택에 푸시하는 작업은 힙에 할당하는 것보다 빠릅니다. 할당자가 새 데이터를 넣을 위치를 탐색할 필요가 없기 때문입니다. 그 위치는 언제나 스택 맨 위에 있으니까요. 반대로 힙에 공간을 할당하려면 더 많은 작업이 필요합니다. 할당자가 데이터를 담을 만큼 충분히 큰 공간을 먼저 찾아야 하고, 다음 할당을 준비하기 위한 bookkeeping도 해야 하기 때문입니다.

힙의 데이터에 접근하는 것은 보통 스택의 데이터에 접근하는 것보다 느립니다. 그곳까지 가려면 포인터를 따라가야 하기 때문입니다. 현대 프로세서는 메모리 안을 덜 이리저리 뛰어다닐수록 더 빠릅니다. 다시 식당 비유로 설명하면, 여러 테이블의 주문을 받는 서버는 한 테이블의 주문을 전부 받은 뒤 다음 테이블로 이동하는 편이 가장 효율적입니다. A 테이블에서 하나 받고, B 테이블에서 하나 받고, 다시 A, 다시 B로 오가는 것은 훨씬 느립니다. 같은 이유로 프로세서도 다른 데이터와 가까이 있는 데이터(스택의 경우)로 일할 때, 멀리 떨어진 데이터(힙의 경우)보다 더 효율적으로 동작할 수 있습니다.

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

코드의 어떤 부분이 힙의 어떤 데이터를 사용하고 있는지 추적하기, 힙에 중복 데이터를 최소화하기, 더 이상 사용하지 않는 힙 데이터를 정리해 공간을 낭비하지 않기 같은 문제들은 모두 소유권이 해결하고자 하는 대상입니다. 소유권을 이해하고 나면 스택과 힙을 자주 의식할 필요는 없어집니다. 하지만 소유권의 주된 목적이 힙 데이터를 관리하는 데 있다는 사실을 알고 있으면, 왜 소유권이 그런 방식으로 동작하는지를 이해하는 데 도움이 됩니다.

소유권 규칙

먼저 소유권 규칙을 살펴봅시다. 뒤이어 나오는 예제를 따라가는 동안 다음 규칙을 계속 머릿속에 두세요.

  • 러스트의 각 값은 소유자(owner) 를 가진다.
  • 어떤 값에는 한 시점에 오직 하나의 소유자만 있을 수 있다.
  • 소유자가 스코프를 벗어나면 값은 버려진다(drop 된다).

변수 스코프

이제 기본적인 러스트 문법을 지나왔으므로, 예제마다 fn main() { 전체를 계속 포함하지는 않겠습니다. 직접 따라 하신다면 아래 예제들을 수동으로 main 함수 안에 넣어 주세요. 이렇게 하면 보일러플레이트 코드보다 실제 핵심에 집중할 수 있어 예제가 좀 더 간결해집니다.

소유권의 첫 번째 예로, 몇 가지 변수의 스코프를 살펴보겠습니다. 스코프(scope) 는 어떤 항목이 유효한 프로그램 구간을 뜻합니다. 다음 변수를 보세요.

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

변수 s 는 문자열 리터럴을 가리킵니다. 이 문자열 값은 프로그램 텍스트 안에 하드코딩되어 있습니다. 이 변수는 선언된 시점부터 현재 스코프가 끝날 때까지 유효합니다. 목록 4-1은 변수 s 가 어디서 유효한지를 주석으로 표시한 프로그램입니다.

fn main() {
    {                      // s is not valid here, since 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 가 스코프 안으로 들어오면 유효해집니다.
  • s 는 스코프 밖으로 나갈 때까지 유효합니다.

이 시점에서 스코프와 변수의 유효 기간 사이 관계는 다른 프로그래밍 언어와 비슷합니다. 이제 여기에 String 타입을 도입해 이해를 확장해 보겠습니다.

String 타입

소유권 규칙을 설명하려면 3장의 [“데이터 타입”] 절에서 다룬 타입들보다 좀 더 복잡한 데이터 타입이 필요합니다. 앞서 다룬 타입들은 크기가 알려져 있고, 스택에 저장할 수 있으며, 스코프가 끝나면 스택에서 제거할 수 있고, 다른 코드가 같은 값을 다른 스코프에서 사용해야 할 때도 빠르고 단순하게 복사해 독립적인 새 인스턴스를 만들 수 있습니다. 하지만 우리는 힙에 저장되는 데이터를 살펴보고, 러스트가 언제 그 데이터를 정리해야 하는지 어떻게 아는지 알아보고 싶습니다. String 타입은 이 목적에 아주 좋은 예입니다.

여기서는 String 의 여러 측면 중 소유권과 관련된 부분에 집중하겠습니다. 이런 특징은 표준 라이브러리가 제공하는 다른 복잡한 데이터 타입이나 여러분이 직접 만든 타입에도 똑같이 적용됩니다. 소유권과 직접 관련 없는 String 의 성질은 8장 에서 다룹니다.

우리는 이미 문자열 리터럴을 보았습니다. 문자열 리터럴은 문자열 값이 프로그램 코드 안에 하드코딩되어 있습니다. 문자열 리터럴은 편리하지만, 우리가 텍스트를 다루고 싶어 하는 모든 상황에 적합하지는 않습니다. 한 가지 이유는 불변이라는 점입니다. 또 다른 이유는, 모든 문자열 값을 우리가 코드를 작성하는 시점에 미리 알 수는 없기 때문입니다. 예를 들어 사용자 입력을 받아 저장하고 싶다면 어떨까요? 이런 상황을 위해 러스트에는 String 타입이 있습니다. 이 타입은 힙에 할당된 데이터를 관리하므로, 컴파일 시점에 길이를 알 수 없는 텍스트 양도 저장할 수 있습니다. 문자열 리터럴에서 String 을 만들려면 다음처럼 from 함수를 사용합니다.

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

이중 콜론 :: 연산자는 from 함수를 string_from 같은 별도 이름으로 쓰는 대신 String 타입 아래에 네임스페이스된 함수로 사용하게 해 줍니다. 이 문법은 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 를 짝지어야 합니다.

러스트는 다른 길을 택합니다. 메모리는 그것을 소유한 변수가 스코프를 벗어날 때 자동으로 반환됩니다. 목록 4-1의 스코프 예제를 문자열 리터럴 대신 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 가 스코프를 벗어나는 순간입니다. 변수가 스코프를 벗어나면, 러스트는 우리를 대신해 특별한 함수를 호출합니다. 이 함수의 이름은 drop 이며, String 작성자는 이 함수 안에 메모리를 반환하는 코드를 넣을 수 있습니다. 러스트는 닫는 중괄호를 만났을 때 drop 을 자동으로 호출합니다.

Note: C++ 에서는 항목의 생애주기 끝에서 자원을 해제하는 이 패턴을 RAII(Resource Acquisition Is Initialization) 라고 부르기도 합니다. RAII 패턴을 사용해 본 적이 있다면, 러스트의 drop 함수도 익숙하게 느껴질 것입니다.

이 패턴은 러스트 코드가 작성되는 방식에 아주 큰 영향을 미칩니다. 지금은 단순해 보이지만, 힙에 할당한 같은 데이터를 여러 변수가 사용하려는 더 복잡한 상황에서는 코드 동작이 예상과 다를 수 있습니다. 이제 그런 상황을 몇 가지 살펴보겠습니다.

이동(move)으로 변수와 데이터가 상호작용하는 방식

러스트에서는 여러 변수가 같은 데이터와 다양한 방식으로 상호작용할 수 있습니다. 목록 4-2는 정수를 사용하는 예를 보여 줍니다.

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

이 코드가 무엇을 하는지는 대략 짐작할 수 있습니다. “값 5x 에 바인딩하고, 그다음 x 의 값을 복사해 y 에 바인딩한다”는 의미입니다. 실제로도 그렇습니다. 정수는 크기가 알려져 있고 고정된 단순한 값이므로, 두 개의 5 값 모두 스택에 푸시됩니다.

이제 String 버전을 봅시다.

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

겉보기에는 매우 비슷하므로, 같은 방식으로 동작할 것이라고 생각할 수도 있습니다. 즉 두 번째 줄이 s1 의 값을 복사해 s2 에 바인딩한다고 생각하기 쉽습니다. 하지만 실제로는 그렇지 않습니다.

String 이 내부적으로 어떻게 생겼는지 그림 4-1을 봅시다. String 은 왼쪽에 보이는 세 부분으로 이루어져 있습니다. 문자열 내용을 담는 메모리를 가리키는 포인터, 길이, 용량입니다. 이 데이터 묶음은 스택에 저장됩니다. 오른쪽에는 실제 문자열 내용을 담고 있는 힙 메모리가 있습니다.

Two tables: the first table contains the representation of s1 on the
stack, consisting of its length (5), capacity (5), and a pointer to the first
value in the second table. The second table contains the representation of the
string data on the heap, byte by byte.

그림 4-1: 값 "hello" 를 가진 Strings1 에 바인딩되었을 때의 메모리 표현

길이는 현재 String 내용이 사용하는 메모리 양(바이트 수)입니다. 용량은 String 이 할당자로부터 받은 총 메모리 양(바이트 수)입니다. 길이와 용량의 차이는 중요하지만, 여기서는 무시해도 괜찮습니다.

s1s2 에 대입하면, String 데이터가 복사됩니다. 즉 스택에 있는 포인터, 길이, 용량이 복사됩니다. 포인터가 가리키는 힙 데이터 자체는 복사하지 않습니다. 다시 말해 메모리 표현은 그림 4-2처럼 됩니다.

Three tables: tables s1 and s2 representing those strings on the
stack, respectively, and both pointing to the same string data on the heap.

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

메모리 표현은 그림 4-3처럼 되지 않습니다. 만약 러스트가 힙 데이터까지 함께 복사했다면 메모리는 그 그림처럼 보였을 것입니다. 그렇게 되면 힙 데이터가 클 때 s2 = s1 연산은 런타임 성능 측면에서 매우 비싸질 수 있습니다.

Four tables: two tables representing the stack data for s1 and s2,
and each points to its own copy of string data on the heap.

그림 4-3: 러스트가 힙 데이터까지 복사했다면 s2 = s1 이 보였을 또 다른 가능성

앞에서 변수가 스코프를 벗어나면 러스트가 자동으로 drop 함수를 호출해 그 변수의 힙 메모리를 정리한다고 말했습니다. 그런데 그림 4-2에서는 두 데이터 포인터가 같은 위치를 가리키고 있습니다. 이것은 문제가 됩니다. s2s1 이 스코프를 벗어나면 둘 다 같은 메모리를 해제하려 할 것이기 때문입니다. 이것을 이중 해제(double free) 오류라고 하며, 앞에서 언급한 메모리 안전성 버그 중 하나입니다. 메모리를 두 번 해제하면 메모리 손상이 발생할 수 있고, 이는 보안 취약점으로 이어질 수도 있습니다.

메모리 안전성을 보장하기 위해, 러스트는 let s2 = s1; 이후 s1 을 더 이상 유효하지 않다고 간주합니다. 그러면 s1 이 스코프를 벗어날 때 러스트는 아무 것도 해제할 필요가 없어집니다. s2 가 만들어진 뒤 s1 을 사용하려 하면 어떤 일이 일어나는지 보세요. 동작하지 않습니다.

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

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

러스트는 무효화된 참조를 사용하는 것을 막기 때문에 다음과 같은 오류를 받게 됩니다.

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
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) 라는 용어를 들어 본 적이 있다면, 데이터 자체를 복사하지 않고 포인터, 길이, 용량만 복사하는 이 개념은 얕은 복사와 비슷하게 들릴 것입니다. 하지만 러스트는 동시에 첫 번째 변수도 무효화하기 때문에, 이것을 얕은 복사라고 부르지 않고 이동(move) 이라고 부릅니다. 이 예에서는 s1s2이동되었다 고 말합니다. 실제 상태는 그림 4-4와 같습니다.

Three tables: tables s1 and s2 representing those strings on the
stack, respectively, and both pointing to the same string data on the heap.
Table s1 is grayed out because s1 is no longer valid; only s2 can be used to
access the heap data.

그림 4-4: s1 이 무효화된 뒤의 메모리 표현

이것으로 문제가 해결됩니다. 이제 유효한 것은 s2 하나뿐이므로, s2 가 스코프를 벗어날 때 그 하나만 메모리를 해제하면 됩니다.

더 나아가, 여기에는 하나의 설계 선택이 암시되어 있습니다. 러스트는 여러분의 데이터에 대해 “깊은” 복사를 자동으로 만들지 않습니다. 따라서 러스트에서의 자동 복사는 런타임 성능 측면에서 비용이 낮다고 가정할 수 있습니다.

스코프와 대입

이와 반대되는 방향의 관계도 있습니다. 스코프, 소유권, 그리고 drop 함수를 통한 메모리 해제 사이의 관계입니다. 기존 변수에 완전히 새로운 값을 대입하면, 러스트는 즉시 drop 을 호출해 원래 값의 메모리를 해제합니다. 예를 들어 다음 코드를 보세요.

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

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

우리는 먼저 변수 s 를 선언하고 값 "hello"String 에 바인딩합니다. 그리고 곧바로 "ahoy" 값을 가진 새로운 String 을 만들어 s 에 대입합니다. 이 시점에는 원래 힙 값 "hello" 를 가리키는 것은 아무 것도 없습니다. 그림 4-5는 지금의 스택과 힙 데이터를 보여 줍니다.

One table representing the string value on the stack, pointing to
the second piece of string data (ahoy) on the heap, with the original string
data (hello) grayed out because it cannot be accessed anymore.

그림 4-5: 초기 값이 완전히 교체된 뒤의 메모리 표현

따라서 원래 문자열은 즉시 스코프를 벗어난 것으로 처리됩니다. 러스트는 그 값에 대해 drop 함수를 실행하고, 메모리는 곧바로 해제됩니다. 마지막에 값을 출력하면 결과는 "ahoy, world!" 가 됩니다.

clone 으로 변수와 데이터가 상호작용하는 방식

만약 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 을 호출해도 일반 복사와 다를 바가 없고 그냥 생략할 수 있습니다.

러스트에는 Copy 트레이트라는 특별한 표시가 있습니다. 정수처럼 스택에 저장되는 타입에 이 트레이트를 붙일 수 있습니다(10장에서 트레이트를 더 자세히 다룹니다). 어떤 타입이 Copy 트레이트를 구현하면, 그 타입을 사용하는 변수는 이동되지 않고 단순히 복사됩니다. 따라서 다른 변수에 대입한 뒤에도 계속 유효합니다.

러스트는 타입 자체나 그 일부라도 Drop 트레이트를 구현했다면, 그 타입에 Copy 주석을 붙이게 하지 않습니다. 값이 스코프를 벗어날 때 특별한 일이 일어나야 하는데, 그 타입에 Copy 를 추가하면 컴파일 시점 오류가 납니다. 여러분의 타입에 Copy 주석을 추가해 트레이트를 구현하는 방법은 부록 C의 [“파생 가능한 트레이트”] derivable-traits를 참고하세요.

그렇다면 어떤 타입이 Copy 트레이트를 구현할까요? 정확한 것은 해당 타입의 문서를 확인하면 되지만, 일반적으로 단순한 스칼라 값들의 묶음은 Copy 를 구현할 수 있고, 할당이 필요하거나 어떤 형태의 자원인 것은 구현할 수 없습니다. Copy 를 구현하는 타입에는 다음과 같은 것들이 있습니다.

  • u32 같은 모든 정수 타입
  • true, false 값을 가지는 불리언 타입 bool
  • f64 같은 모든 부동소수점 타입
  • 문자 타입 char
  • 내부 요소들도 모두 Copy 를 구현하는 경우의 튜플. 예를 들어 (i32, i32)Copy 이지만 (i32, String) 은 아닙니다.

소유권과 함수

값을 함수에 넘기는 동작은 값을 변수에 대입하는 동작과 비슷합니다. 변수를 함수에 넘기면 대입과 마찬가지로 이동되거나 복사됩니다. 목록 4-3은 변수들이 어디서 스코프 안으로 들어오고 어디서 나가는지 주석으로 표시한 예시입니다.

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,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, 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 를 다시 사용하려 하면, 러스트는 컴파일 시점 오류를 냅니다. 이런 정적 검사가 실수를 막아 줍니다. 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 에 의해 정리됩니다.

이 방식은 동작하긴 하지만, 함수가 값을 받을 때마다 소유권을 가져가고 다시 돌려주는 것은 다소 번거롭습니다. 함수가 값을 사용만 하고 소유권은 가져가지 않게 하고 싶다면 어떻게 해야 할까요? 다시 사용하려면 우리가 넘긴 것은 결과와 별도로 그대로 다시 돌려받아야 한다는 점도 꽤 성가십니다.

러스트는 목록 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: 매개변수의 소유권 반환하기

하지만 이것은 너무 장황하고, 원래 흔해야 할 개념치고는 일이 많습니다. 다행히 러스트는 소유권을 옮기지 않고도 값을 사용할 수 있게 해 주는 기능을 제공합니다. 바로 참조입니다.