소유권이 뭔가요?

소유권은 러스트 프로그램의 메모리 관리법을 지배하는 규칙 모음입니다. 모든 프로그램은 작동하는 동안 컴퓨터의 메모리 사용 방법을 관리해야 합니다. 몇몇 언어는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 정기적으로 찾는 방식을 채택했고, 다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 택했습니다. 이때 러스트는 제3의 방식을 택했습니다: 소유권 (ownership) 이라는 시스템을 만들고, 컴파일러가 컴파일 중에 검사할 여러 규칙을 정해 메모리를 관리하는 방식이지요. 이 규칙 중 하나라도 위반하면 프로그램은 컴파일되지 않습니다. 소유권의 어떠한 특성도 프로그램 실행 속도를 느리게 하지 않습니다.

소유권은 상당히 생소한 개념이기 때문에 이해하고 사용하는 데 시간이 좀 걸릴 겁니다. 하지만 여러분이 러스트라는 언어와 소유권 시스템 규칙에 익숙해질수록, 더 안정적이고 더 효율적인 코드를 쉽게 작성하는 방법을 찾게 될 테니, 포기하지 마세요!

소유권을 이해하고 나면, 러스트라는 언어를 특별한 존재로 만드는 나머지 기능들도 이해할 수 있을 겁니다. 이번 장에서는 일반적인 자료 구조인 문자열 위주의 예제를 통해 소유권을 배워보겠습니다.

스택 영역과 힙 영역

대부분의 프로그래밍 언어에서는 스택, 힙 영역을 주제로 고민할 필요가 많지 않습니다. 하지만 러스트 같은 시스템 프로그래밍 언어에서는 값을 스택에 저장하느냐 힙에 저장하느냐의 차이가 프로그램의 동작 및 프로그래머의 의사 결정에 훨씬 큰 영향을 미칩니다. 소유권과 스택, 힙을 엮은 설명은 후술해 두었으므로 여기서는 각 영역에 대해서만 간략히 설명하겠습니다.

스택, 힙 둘 다 여러분이 작성한 프로그램이 런타임에 이용하게 될 메모리 영역이라는 공통점이 있지만, 구조는 각기 다릅니다. 스택은 값이 들어온 순서대로 저장하고, 역순으로 제거합니다. 이를 후입선출 (last in, first out) 이라 하지요. 쌓여 있는 접시로 예를 들어 보겠습니다. 여러분이 접시 더미에 접시를 추가할 땐 접시 더미의 꼭대기에 쌓고, 반대로 접시를 꺼낼 땐 꼭대기에서 한 장 꺼낼 겁니다. 중간이나 맨 아래에 끼워 넣는 건 쉽지 않겠죠. 이런 식으로 접시 더미, 즉 스택에 데이터를 추가하는 행위를 푸시 (push) 라고 하며, 반대로 스택에서 데이터를 제거하는 행위는 팝 (pop) 이라 합니다. 스택에 저장되는 데이터는 모두 명확하고 크기가 정해져 있어야 합니다. 컴파일 타임에 크기를 알 수 없거나, 크기가 변경될 수 있는 데이터는 스택 대신 힙에 저장되어야 합니다.

힙은 스택보다 복잡합니다. 데이터를 힙에 넣을 때 먼저 저장할 공간이 있는지 운영체제에 물어봅니다. 그러면 메모리 할당자는 커다란 힙 영역 안에서 어떤 빈 지점을 찾고, 이 지점은 사용 중이라고 표시한 뒤 해당 지점을 가리키는 포인터 (pointer) 를 우리한테 반환합니다. 이 과정을 힙 공간 할당 (allocating on the heap), 줄여서 할당 (allocation) 이라 합니다 (스택에 값을 푸시하는 것은 할당이라 부르지 않습니다). 포인터는 크기가 정해져 있어 스택에 저장할 수 있으나, 포인터가 가리키는 실제 데이터를 사용하고자 할 때는 포인터를 참조해 해당 포인터가 가리키는 위치로 이동하는 과정을 거쳐야 합니다. 힙 구조는 레스토랑에서 자리에 앉는 과정으로 비교할 수 있습니다. 레스토랑에 입장하면, 직원에게 인원수를 알립니다. 그러면 직원은 인원수에 맞는 빈 테이블을 찾아 안내하겠죠. 이후에 온 일행이 우리 테이블을 찾을 땐 직원에게 물어 안내받을 겁니다.

스택 영역은 데이터에 접근하는 방식상 힙 영역보다 속도가 빠릅니다. 메모리 할당자가 새로운 데이터를 저장할 공간을 찾을 필요가 없이 항상 스택의 가장 위에 데이터를 저장하면 되기 때문이죠. 반면에 힙에 공간을 할당하는 작업은 좀 더 많은 작업을 요구하는데, 메모리 할당자가 데이터를 저장하기 충분한 공간을 먼저 찾고 다음 할당을 위한 준비를 위해 예약을 수행해야 하기 때문입니다.

힙 영역은 포인터가 가리키는 곳을 찾아가는 과정으로 인해 느려집니다. 현대 프로세서는 메모리 내부를 이리저리 왔다 갔다 하는 작업이 적을수록 속도가 빨라지는데, 힙에 있는 데이터들은 서로 멀리 떨어져 있어 프로세서가 계속해서 돌아다녀야 하기 때문이죠. 앞서 예로 든 레스토랑을 다시 한번 생각해 봅시다. 여러분이 웨이터라면, 다른 테이블로 이동하기 전에 지금 있는 테이블의 주문을 모두 다 받고 나서 이동하는 게 가장 효율적일 겁니다. 반대로 A 테이블에서 하나 주문받고, B 테이블로 이동해서 하나 주문받고, 다시 A로, 다시 B로 이동해 가며 주문받으면 훨씬 느려지겠죠. 프로세서도 마찬가지입니다. 힙 영역처럼 데이터가 서로 멀리 떨어져 있으면 작업이 느려지고, 반대로 스택 영역처럼 데이터가 서로 붙어 있으면 작업이 빨라집니다.

여러분이 함수를 호출하면, 호출한 함수에 넘겨준 값(값 중엔 힙 영역의 데이터를 가리키는 포인터도 있을 수 있습니다)과 해당 함수의 지역 변수들이 스택에 푸시됩니다. 그리고 이 데이터들은 함수가 종료될 때 팝됩니다.

코드 어느 부분에서 힙의 어떤 데이터를 사용하는지 추적하고, 힙에서 중복되는 데이터를 최소화하고, 쓰지 않는 데이터를 힙에서 정리해 영역을 확보하는 등의 작업은 모두 소유권과 관련되어 있습니다. 반대로 말하면 여러분이 소유권을 한번 이해하고 나면 스택, 힙 영역으로 고민할 일이 줄어들 거란 뜻이지만, 소유권의 주요 목표가 힙 데이터의 관리라는 점을 알고 있으면 소유권의 동작 방식을 이해하는 데 도움이 됩니다.

소유권 규칙

소유권 규칙부터 알아보겠습니다. 앞으로 나올 내용을 보는 동안 다음 규칙을 명심하세요:

  • 러스트에서, 각각의 값은 소유자 (owner) 가 정해져 있습니다.
  • 한 값의 소유자는 동시에 여럿 존재할 수 없습니다.
  • 소유자가 스코프 밖으로 벗어날 때, 값은 버려집니다 (dropped).

변수의 스코프

기본 문법은 이미 살펴봤으므로, 앞으로의 예제에서는 fn main() { 코드를 생략하겠습니다. 따라서 앞으로 예제를 따라 하실 때는 직접 main 함수에 코드를 작성해 주셔야 하니 주의해 주세요. 결과적으로 예제가 간략해져서 보일러 플레이트 코드가 아닌 실제 세부 사항에 집중하게끔 해줄 것입니다.

소유권 첫 예제로는 변수의 스코프 (scope) 를 다뤄보도록 하겠습니다. 스코프란, 프로그램 내에서 아이템이 유효한 범위를 말합니다. 다음과 같은 변수가 있다고 가정해 봅시다.

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

변수 s는 문자열 리터럴 (string literal) 을 나타내며, 문자열 리터럴의 값은 코드 내에 하드코딩되어 있습니다. 이 변수는 선언된 시점부터 현재의 스코프를 벗어날 때까지 유효합니다. 예제 4-1에 변수 s가 유효한 지점을 주석으로 표시한 예제입니다:

fn main() {
    {                      // s는 아직 선언되지 않아서 여기서는 유효하지 않습니다
        let s = "hello";   // 이 지점부터 s가 유효합니다

        // s로 어떤 작업을 합니다
    }                      // 이 스코프가 종료되었고, s가 더 이상 유효하지 않습니다
}

예제 4-1: 변수와, 해당 변수가 유효한 스코프

중요한 점은 두 가지입니다.

  1. s가 스코프 내에 나타나면 유효합니다.
  2. 유효기간은 스코프 밖으로 벗어나기 전까지입니다.

여기까지 보면 변수의 유효성과 스코프는 다른 프로그래밍 언어와 비슷합니다. 이제, String 타입을 탐구해 가며 본격적으로 소유권을 이해해 봅시다.

String 타입

소유권 규칙을 설명하려면 3장의 ‘데이터 타입’ 절에서 다룬 타입보다 복잡한 타입이 필요합니다. 앞서 다룬 것들은 명확한 크기를 가지고 있어서, 전부 스택에 저장되고 스코프를 벗어날 때 제거되며, 코드의 다른 쪽에서 별도의 스코프 내에서 같은 값을 사용하려 할 때 새 독립적인 인스턴스를 빠르고 간단하게 만들어 낼 수 있습니다. 하지만 이번에 필요한 건 힙에 저장되면서 러스트의 데이터 정리 과정을 알아보는 데 적합한 타입이지요. String 타입이 좋은 예입니다.

여기서는 String의 소유권과 관련된 부분에만 집중하겠습니다. 이러한 관점은 표준 라이브러리가 제공하는 다른 타입들이나 여러분이 만들 복잡한 데이터 타입에도 적용됩니다. String 타입은 8장에서 더 자세히 다루겠습니다.

여태 보아온 문자열은 코드 내에 하드코딩하는 방식의 문자열 리터럴이었습니다. 문자열 리터럴은 쓰기 편리하지만, 만능은 아닙니다. 그 이유는 문자열 리터럴이 불변성 (immutable) 을 지니기에 변경할 수 없다는 점과, 프로그램에 필요한 모든 문자열을 우리가 프로그래밍하는 시점에 알 수는 없다는 점 때문입니다. 사용자한테 문자열을 입력받아 저장하는 기능 등을 만들어야 하는 상황에서는 문자열 리터럴을 사용할 수 없죠. 따라서 러스트는 또 다른 문자열 타입인 String을 제공합니다. 이 타입은 힙에 할당된 데이터를 다루기 때문에, 컴파일 타임에 크기를 알 수 없는 텍스트도 저장할 수 있습니다. String 타입은 다음과 같이 from 함수와 문자열 리터럴을 이용해 생성 가능합니다.

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

이중 콜론 ::은 우리가 함수를 사용할 때 string_from 같은 함수명을 사용하지 않고 String 타입에 있는 특정된 from 함수라는 것을 지정할 수 있게 해주는 네임스페이스 연산자입니다. 메서드 관련 문법은 5장 ‘메서드 문법’ 에서 자세히 다루며, 모듈 및 네임스페이스는 7장 ‘경로를 사용하여 모듈 트리의 아이템 참조하기’ 에서 다루고 있습니다.

String 문자열은 변경이 가능합니다:

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

    s.push_str(", world!"); // push_str()이 문자열에 리터럴을 추가합니다

    println!("{}", s); // 이 줄이 `hello, world!`를 출력합니다
}

하지만, 문자열 리터럴과 String 에 무슨 차이가 있길래 어떤 것은 변경할 수 있고 어떤 것은 변경할 수 없을까요? 차이점은 각 타입의 메모리 사용 방식에 있습니다.

메모리와 할당

문자열 리터럴은 컴파일 타임에 내용을 알 수 있으므로, 텍스트가 최종 실행파일에 하드코딩됩니다. 이 방식은 빠르고 효율적이지만, 문자열이 변하지 않을 경우에만 사용할 수 있습니다. 컴파일 타임에 크기를 알 수 없고 실행 중 크기가 변할 수도 있는 텍스트는 바이너리 파일에 집어넣을 수 없죠.

반면 String 타입은 힙에 메모리를 할당하는 방식을 사용하기 때문에 텍스트 내용 및 크기를 변경할 수 있습니다. 하지만 이는 다음을 의미하기도 합니다:

  • 실행 중 메모리 할당자로부터 메모리를 요청해야 합니다.
  • String 사용을 마쳤을 때 메모리를 해제할 (즉, 할당자에게 메모리를 반납할) 방법이 필요합니다.

이 중 첫 번째는 이미 우리 손으로 해결했습니다. String::from 호출 시, 필요한 만큼 메모리를 요청하도록 구현되어 있거든요. 프로그래밍 언어 사이에서 일반적으로 사용하는 방식이죠.

하지만 두 번째는 다릅니다. 가비지 컬렉터 (garbage collector, GC) 를 갖는 언어에서는 GC가 사용하지 않는 메모리를 찾아 없애주므로 프로그래머가 신경 쓸 필요 없습니다. GC가 없는 대부분의 언어에서는 할당받은 메모리가 필요 없어지는 지점을 프로그래머가 직접 찾아 메모리 해제 코드를 작성해야 합니다. 이 작업은 역사적으로 어려운 프로그래밍 문제였습니다. 프로그래머가 놓친 부분이 있다면 메모리 낭비가 발생하고, 메모리 해제 시점을 너무 일찍 잡으면 유효하지 않은 변수가 생깁니다. 두 번 해제할 경우도 마찬가지로 버그가 발생합니다. 따라서 allocate (할당) 과 free (해제) 가 하나씩 짝짓도록 만들어야 합니다.

러스트에서는 이 문제를 변수가 자신이 소속된 스코프를 벗어나는 순간 자동으로 메모리를 해제하는 방식으로 해결했습니다. 예시로 보여드리도록 하죠. 예제 4-1에서 문자열 리터럴을 String으로 바꿔봤습니다:

fn main() {
    {
        let s = String::from("hello"); // s는 이 지점부터 유효합니다

        // s를 가지고 무언가 합니다
    }                                  // 이 스코프가 종료되었고, s는 더 이상
                                       // 유효하지 않습니다.
}

보시면 String에서 사용한 메모리를 자연스럽게 해제하는 지점이 있습니다. s가 스코프 밖으로 벗어날 때인데, 러스트는 변수가 스코프 밖으로 벗어나면 drop이라는 특별한 함수를 호출합니다. 이 함수는 해당 타입을 개발한 개발자가 직접 메모리 해제 코드를 작성해 넣을 수 있게 되어있으며, 위의 경우 String 개발자가 작성한 메모리 해제 코드가 실행되겠죠. drop은 닫힌 중괄호 }가 나타나는 지점에서 자동으로 호출됩니다.

Note: C++에서는 이런 식으로 아이템의 수명이 끝나는 시점에 리소스를 해제하는 패턴을 Resource Acquisition Is Initialization (RAII) 라 합니다. RAII 패턴에 익숙하신 분들이라면 러스트의 drop 함수가 친숙할지도 모르겠네요.

이 패턴은 러스트 코드를 작성하는 데 깊은 영향을 미칩니다. 지금은 단순해 보이지만, 힙 영역을 사용하는 변수가 많아져 상황이 복잡해지면 코드가 예기치 못한 방향으로 동작할 수도 있죠. 그러면 지금부터 그런 복잡한 상황들을 더 알아보도록 합시다.

변수와 데이터 간 상호작용 방식: 이동

러스트에서는 동일한 데이터에 여러 변수가 서로 다른 방식으로 상호작용할 수 있습니다. 정수형을 이용한 예제로 살펴보겠습니다.

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

예제 4-2: 변수 x의 정숫값을 y에 대입하기

대충 어떤 일이 일어날지 예상되네요. ‘5x에 바인딩하고, x 값의 복사본을 만들어 y에 바인딩하시오’ 그럼 x, y 두 변수가 생길 겁니다. 각각의 값은 5가 되겠죠. 실제로도 이와 같은데, 정수형 값은 크기가 정해진 단순한 값이기 때문입니다. 이는 다시 말해, 두 5 값은 스택에 푸시된다는 뜻입니다.

이번엔 앞선 예제를 String으로 바꿔보았습니다:

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

이전 코드와 매우 비슷하니, 동작 방식도 같을 것으로 생각하실 수도 있습니다. 두 번째 줄에서 s1의 복사본을 생성해 s2에 바인딩하는 식으로 말이죠. 하지만 이번엔 전혀 다른 방식으로 동작합니다.

그림 4-1를 참고해 주세요. String은 그림 좌측에 표시된 것처럼 세 부분으로 이루어져 있습니다: 문자열 내용이 들어 있는 메모리를 가리키는 포인터, 문자열 길이, 메모리 용량입니다. 이 데이터는 스택에 저장됩니다. 우측은 문자열 내용이 들어있는 힙 메모리입니다.

두 개의 테이블: 첫 번째 테이블은 길이(5), 용량(5)과 두 번째
테이블의 첫 값을 가리키는 포인터를 담고 있는 스택 상의 s1을 표현하고
있습니다. 두 번째 테이블은 힙 상에 있는 문자열 데이터가 바이트 단위로
나타내고 있습니다.

그림 4-1: s1 에 바인딩된, "hello" 값을 저장하고 있는 String의 메모리 속 모습

문자열 길이와 메모리 용량이 무슨 차이인가 궁금하실 분들을 위해 간단히 설명해 드리자면, 문자열 길이는 String의 내용이 현재 사용하고 있는 메모리를 바이트 단위로 나타낸 것이고, 메모리 용량은 메모리 할당자가 String에 할당한 메모리의 양을 뜻합니다. 지금의 맥락에서는 길이, 용량 사이의 차이는 중요한 내용이 아니니, 이해가 잘 안되면 용량 값은 무시하셔도 좋습니다.

s2s1를 대입하면 String 데이터가 복사됩니다. 이때 데이터는 스택에 있는 데이터, 즉 포인터, 길이, 용량 값을 말하며, 포인터가 가리키는 힙 영역의 데이터는 복사되지 않습니다. 즉, 다음과 같은 메모리 구조를 갖게 됩니다.

세 개의 테이블: s1과 s2는 각각 스택 상에 있는 문자열을
나타내고 둘 다 힙 상의 같은 문자열 데이터를 가리키고 있습니다.

그림 4-2: 변수 s2s1의 포인터, 길이, 용량 값을 복사했을 때 나타나는 메모리 구조

아래의 그림 4-3은 힙 메모리의 데이터까지 복사했을 경우 나타날 구조로, 실제로는 이와 다릅니다. 만약 러스트가 이런 식으로 동작한다면, 힙 내 데이터가 커질수록 s2 = s1 연산은 굉장히 느려질 겁니다.

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

그림 4-3: 러스트에서 힙 데이터까지 복사할 경우의 s2 = s1 연산 결과

앞서 언급한 내용 중 변수가 스코프 밖으로 벗어날 때 러스트에서 자동으로 drop 함수를 호출하여 해당 변수가 사용하는 힙 메모리를 제거한다는 내용이 있었습니다. 하지만 그림 4-2처럼 두 포인터가 같은 곳을 가리킬 경우에는 어떻게 될까요? s2, s1이 스코프 밖으로 벗어날 때 각각 메모리를 해제하게 되면 중복 해제 (double free) 에러가 발생할 겁니다. 이는 메모리 안정성 버그 중 하나이며, 보안을 취약하게 만드는 메모리 손상의 원인입니다.

메모리 안정성을 보장하기 위해서, 러스트는 let s2 = s1; 라인 뒤로는 s1이 더 이상 유효하지 않다고 판단합니다. 이로써 러스트는 s1이 스코프를 벗어나더라도 아무것도 해제할 필요가 없어집니다. s2가 만들어진 이후에 s1을 사용하는 경우 어떤 일이 생기는지 확인해 보면, 작동하지 않음을 알 수 있습니다:

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

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

보시는 것과 같이 유효하지 않은 참조자의 사용을 감지했다는 에러가 발생하네요:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
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!("{}, world!", s1);
  |                            ^^ 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` due to previous error

여러분이 다른 프로그래밍 언어에서 얕은 복사 (shallow copy), 깊은 복사 (deep copy) 라는 용어를 들어보셨다면, 힙 데이터를 복사하지 않고 포인터, 길이, 용량 값만 복사하는 것을 얕은 복사라고 생각하셨을 수도 있지만, 러스트에서는 기존의 변수를 무효화하기 때문에 이를 얕은 복사가 아닌 이동 (move) 이라 하고, 앞선 코드는 s1s2이동되었다라고 표현합니다.

세 개의 테이블: s1과 s2 테이블은 스택 상에서 각자의 문자열을
나타내고 있고, 둘 다 힙 상에 있는 같은 문자열 데이터를 가리키고 있습니다.
s1 테이블은 더 이상 유효하지 않기 때문에 회색 처리되었습니다. 오직 s2만
힙 데이터 조회에 사용될 수 있습니다.

그림 4-4: s1이 무효화된 후의 메모리 구조

이로써 문제가 사라졌네요! s2만이 유효하니, 스코프 밖으로 벗어났을 때 자신만 메모리를 해제할 것이고, 문제가 해결됩니다.

덧붙이자면, 러스트는 절대 자동으로 ‘깊은’ 복사로 데이터를 복사하는 일이 없습니다. 따라서, 러스트가 자동으로 수행하는 모든 복사는 런타임 성능 측면에서 효율적이라 할 수 있습니다.

변수와 데이터 간 상호작용 방식: 클론

String의 힙 데이터까지 깊이 복사하고 싶을 땐 clone이라는 공용 메서드를 사용할 수 있습니다. 메서드 문법은 5장에서 다룰 예정이지만, 메서드라는 개념은 대부분의 프로그래밍 언어가 갖는 특성이기 때문에 이미 다뤄보셨을 겁니다.

다음은 clone 메서드의 사용 예제입니다:

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

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

이 코드의 실행 결과는 힙 데이터까지 복사됐을 때의 메모리 구조를 나타낸 그림 4-3과 정확히 일치합니다.

이 코드에서 clone 호출을 보고, 이 지점에서 성능에 영향이 갈 수도 있는 코드가 실행될 것을 알 수 있습니다. 즉, clone은 해당 위치에서 무언가 다른 일이 수행될 것을 알려주는 시각적인 표시이기도 합니다.

스택에만 저장되는 데이터: 복사

아직 다루지 않은 부분이 있습니다. 다음 코드는 앞서 예제 4-2에서 본 정수형을 이용하는 코드입니다 (정상적으로 작동합니다):

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

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

하지만 이 코드는 방금 우리가 배운 내용과 맞지 않는 것처럼 보이네요. clone을 호출하지도 않았는데 x는 계속해서 유효하며 y로 이동되지도 않았습니다.

이유는 정수형 등 컴파일 타임에 크기가 고정되는 타입은 모두 스택에 저장되기 때문입니다. 스택에 저장되니, 복사본을 빠르게 만들 수 있고, 따라서 굳이 y를 생성하고 나면 x를 무효화할 필요가 없습니다. 다시 말해 이런 경우엔 깊은 복사와 얕은 복사 간에 차이가 없습니다. 여기선 clone을 호출해도 얕은 복사와 차이가 없으니 생략해도 상관없죠.

러스트에는 정수형처럼 스택에 저장되는 타입에 달아 놓을 수 있는 Copy 트레이트가 있습니다 (트레이트는 10장에서 자세히 다룹니다). 만약 어떤 타입에 이 Copy 트레이트가 구현되어 있다면, 이 타입의 변수는 사용되어도 이동되지 않고 자명하게 복사되고, 대입 연산 후에도 사용할 수 있죠.

하지만 구현하려는 타입이나, 구현하려는 타입 중 일부분에 Drop 트레이트가 구현된 경우엔 Copy 트레이트를 어노테이션 (annotation) 할 수 없습니다. 즉, 스코프 밖으로 벗어났을 때 특정 동작이 요구되는 타입에 Copy 어노테이션을 추가하면 컴파일 에러가 발생합니다. 여러분이 만든 타입에 Copy 어노테이션을 추가하는 방법은 부록 C의 ‘파생 가능한 트레이트’를 참고 바랍니다.

그래서, Copy 가능한 타입은 뭘까요? 타입마다 문서를 뒤져 정보를 찾아보고 확신을 얻을 수도 있겠지만, 일반적으로 단순한 스칼라 값의 묶음은 Copy 가능하고, 할당이 필요하거나 리소스의 일종인 경우엔 불가능합니다. Copy 가능한 타입 목록 중 일부를 보여드리겠습니다.

  • 모든 정수형 타입 (예: u32)
  • true, false 값을 갖는 논리 자료형 bool
  • 모든 부동 소수점 타입 (예: f64)
  • 문자 타입 char
  • Copy 가능한 타입만으로 구성된 튜플 (예를 들어, (i32, i32)Copy 가능하지만 (i32, String)은 불가능합니다)

소유권과 함수

함수로 값을 전달하는 메커니즘은 변수에 값을 대입할 때와 유사합니다. 함수에 변수를 전달하면 대입 연산과 마찬가지로 이동이나 복사가 일어나기 때문이죠. 예제 4-3에 변수가 스코프를 벗어나는 부분을 주석으로 표시해 보았습니다.

파일명: src/main.rs

fn main() {
    let s = String::from("hello");  // s가 스코프 안으로 들어옵니다

    takes_ownership(s);             // s의 값이 함수로 이동됩니다...
                                    // ... 따라서 여기서는 더 이상 유효하지 않습니다

    let x = 5;                      // x가 스코프 안으로 들어옵니다

    makes_copy(x);                  // x가 함수로 이동될 것입니다만,
                                    // i32는 Copy이므로 앞으로 계속 x를
                                    // 사용해도 좋습니다

} // 여기서 x가 스코프 밖으로 벗어나고 s도 그렇게 됩니다. 그러나 s의 값이 이동되었으므로
  // 별다른 일이 발생하지 않습니다.

fn takes_ownership(some_string: String) { // some_string이 스코프 안으로 들어옵니다
    println!("{}", some_string);
} // 여기서 some_string이 스코프 밖으로 벗어나고 `drop`이 호출됩니다.
  // 메모리가 해제됩니다.

fn makes_copy(some_integer: i32) { // some_integer가 스코프 안으로 들어옵니다
    println!("{}", some_integer);
} // 여기서 some_integer가 스코프 밖으로 벗어납니다. 별다른 일이 발생하지 않습니다.

예제 4-3: 소유권, 스코프가 주석으로 표시된 함수

러스트는 takes_ownership 함수를 호출한 이후에 s를 사용하려 할 경우, 컴파일 타임 에러를 발생시킵니다. 이런 정적 검사들이 프로그래머의 여러 실수를 방지해 주죠. 어느 지점에서 변수를 사용할 수 있고, 어느 지점에서 소유권 규칙이 여러분을 제재하는지 확인해 보려면 main 함수에 s, x 변수를 사용하는 코드를 여기저기 추가해 보세요.

반환 값과 스코프

소유권은 값을 반환하는 과정에서도 이동합니다. 앞서 본 예제 4-3과 비슷한 주석이 달린, 어떤 값을 반환하는 함수에 대한 예제 4-4를 살펴봅시다:

파일명: src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership이 자신의 반환 값을 s1로
                                        // 이동시킵니다

    let s2 = String::from("hello");     // s2가 스코프 안으로 들어옵니다

    let s3 = takes_and_gives_back(s2);  // s2는 takes_and_gives_back로 이동되는데,
                                        // 이 함수 또한 자신의 반환 값을 s3로
                                        // 이동시킵니다
} // 여기서 s3가 스코프 밖으로 벗어나면서 버려집니다. s2는 이동되어서 아무 일도
  // 일어나지 않습니다. s1은 스코프 밖으로 벗어나고 버려집니다.

fn gives_ownership() -> String {             // gives_ownership은 자신의 반환 값을
                                             // 자신의 호출자 함수로 이동시킬
                                             // 것입니다

    let some_string = String::from("yours"); // some_string이 스코프 안으로 들어옵니다

    some_string                              // some_string이 반환되고
                                             // 호출자 함수 쪽으로
                                             // 이동합니다
}

// 이 함수는 String을 취하고 같은 것을 반환합니다
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프 안으로
                                                      // 들어옵니다

    a_string  // a_string이 반환되고 호출자 함수 쪽으로 이동합니다
}

예제 4-4: 반환 값으로 일어나는 소유권 이동

상황은 다양할지라도, 변수의 소유권 규칙은 언제나 동일합니다. 어떤 값을 다른 변수에 대입하면 값이 이동하고, 힙에 데이터를 갖는 변수가 스코프를 벗어나면, 사전에 해당 데이터가 이동하여 소유권이 다른 변수에 이동되지 않은 이상 drop 에 의해 데이터가 제거됩니다.

이런 방식이 동작하더라도, 모든 함수가 소유권을 가졌다가 반납하는 것은 조금 번거롭습니다. 함수에 넘겨줄 값을 함수 호출 이후에도 쓰고 싶은데, 그렇다고 해서 함수로부터 얻고자 하는 결과에 더해서 이후 다시 쓰고 싶은 변수까지 같이 반환받아야 한다면 본말전도나 다름없죠. 그럼, 함수가 값을 사용할 수 있도록 하되 소유권은 가져가지 않도록 하고 싶다면 어떻게 해야 할까요?

러스트에서는 예제 4-5에서처럼 튜플을 사용하여 여러 값을 반환하는 것이 가능합니다:

파일명: src/main.rs

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

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

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

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len()은 String의 길이를 반환합니다

    (s, length)
}

예제 4-5: 매개변수의 소유권을 되돌려주는 방법

하지만 이런 식은 일반적인 컨셉이 되기엔 너무 거추장스럽고 많은 작업량이 수반됩니다. 다행히도, 러스트에는 소유권 이동 없이 값을 사용할 수 있는 참조자 (reference) 라는 기능을 가지고 있습니다.