본문으로 건너뛰기

안동민 개발노트

본문 시작

소유권이 뭔가요?

스택과 힙의 메모리 수명을 바탕으로 이동·클론·복사를 구분하고 함수 호출과 스코프에서 값의 소유권을 추적합니다.

소유권(ownership)은 러스트에서 가장 독특한 기능이며 언어 전반에 깊은 영향을 끼칩니다.

소유권은 러스트가 가비지 컬렉터 없이 메모리 안정성을 보장하도록 해주므로, 소유권이 어떻게 동작하는지를 이해하는 것은 중요합니다.

이 글에서는 스택과 힙을 바탕으로 소유권, 이동·복제·복사, 함수 인수와 반환 값의 소유권을 다룹니다. 값을 소유하지 않고 사용하는 대여(borrowing)와 슬라이스(slice)는 다음 절에서 이어집니다.

먼저 소유권 규칙의 큰 틀을 정리하고, 이후 예제로 동작을 확인하겠습니다.

소유권은 러스트 프로그램의 메모리 관리법을 지배하는 규칙 모음입니다.

모든 프로그램은 작동하는 동안 컴퓨터의 메모리 사용 방법을 관리해야 합니다.

몇몇 언어는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 정기적으로 찾는 방식을 채택했고, 다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 택했습니다.

이때 러스트는 제3의 방식을 택했습니다.

소유권(ownership)이라는 시스템을 만들고, 컴파일러가 컴파일 중에 검사할 여러 규칙을 정해 메모리를 관리하는 방식이지요.

이 규칙 중 하나라도 위반하면 프로그램은 컴파일되지 않습니다.

소유권 규칙 자체는 컴파일 타임에 검사되므로, 이를 확인하기 위한 추적 가비지 컬렉터가 런타임에 추가되지는 않습니다. 다만 힙 할당, clone, drop처럼 프로그램이 실제로 요청한 작업에는 당연히 실행 비용이 듭니다.

소유권은 상당히 생소한 개념이기 때문에 이해하고 사용하는 데 시간이 좀 걸릴 겁니다.

하지만 여러분이 러스트라는 언어와 소유권 시스템 규칙에 익숙해질수록, 더 안정적이고 더 효율적인 코드를 쉽게 작성하는 방법을 찾게 될 테니, 포기하지 마세요!

소유권을 이해하고 나면, 러스트라는 언어를 특별한 존재로 만드는 나머지 기능들도 이해할 수 있을 겁니다.

이 절에서는 일반적인 자료 구조인 문자열 위주의 예제를 통해 소유권을 배워보겠습니다.


스택 영역과 힙 영역

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

스택, 힙 둘 다 여러분이 작성한 프로그램이 런타임에 이용하게 될 메모리 영역이라는 공통점이 있지만, 구조는 각기 다릅니다. 스택은 값이 들어온 순서대로 저장하고, 역순으로 제거합니다. 이를 후입선출(last in, first out) 이라 하지요. 쌓여 있는 접시로 예를 들어 보겠습니다. 여러분이 접시 더미에 접시를 추가할 땐 접시 더미의 꼭대기에 쌓고, 반대로 접시를 꺼낼 땐 꼭대기에서 한 장 꺼낼 겁니다. 중간이나 맨 아래에 끼워 넣는 건 쉽지 않겠죠. 이런 식으로 접시 더미, 즉 스택에 데이터를 추가하는 행위를 푸시(push) 라고 하며, 반대로 스택에서 데이터를 제거하는 행위는 팝(pop) 이라 합니다. 스택 프레임 안에 직접 놓이는 값은 컴파일 타임에 배치 크기를 알 수 있어야 합니다. 크기를 컴파일 타임에 알 수 없는 동적 크기 타입은 보통 크기가 정해진 포인터 뒤에서 다루며, String이나 Vec<T>처럼 실행 중에 커질 수 있는 컬렉션은 흔히 힙 버퍼를 사용합니다. 그렇다고 크기를 알 수 없거나 변하는 모든 값이 자동으로 힙에 놓인다는 뜻은 아닙니다.

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

스택 프레임의 공간을 확보하는 작업은 보통 스택 포인터를 조정하는 정도라 저렴합니다. 반면에 힙에 공간을 할당하는 작업은 일반적으로 더 많은 작업을 요구하는데, 메모리 할당자가 데이터를 저장하기 충분한 공간을 먼저 찾고 다음 할당을 위한 준비를 위해 예약을 수행해야 하기 때문입니다.

힙 데이터는 포인터 간접 참조와 흩어진 배치 때문에 캐시 효율이 낮아질 수 있습니다. 다만 실제 접근 성능은 데이터 배치, 접근 패턴, 캐시, 컴파일러 최적화에 따라 달라지므로 모든 스택 접근이 모든 힙 접근보다 빠르다고 단정할 수는 없습니다. 앞서 예로 든 레스토랑을 다시 한번 생각해봅시다. 여러분이 웨이터라면, 다른 테이블로 이동하기 전에 지금 있는 테이블의 주문을 모두 다 받고 나서 이동하는 게 가장 효율적일 겁니다. 반대로 A 테이블에서 하나 주문받고, B 테이블로 이동해서 하나 주문받고, 다시 A로, 다시 B로 이동해 가며 주문받으면 훨씬 느려지겠죠. 프로세서도 마찬가지로, 필요한 데이터가 가까이 모여 있을수록 캐시를 효과적으로 활용할 가능성이 커집니다.

개념적으로 함수 호출마다 인자와 지역 변수를 위한 스택 프레임이 생겼다가 함수가 종료될 때 사라집니다. 실제 ABI와 최적화 결과에서는 일부 값이 레지스터에 전달되거나 스택 저장 자체가 생략될 수 있지만, 이 구현 세부 사항은 소유권 규칙을 바꾸지 않습니다.

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


소유권 규칙

소유권 규칙부터 알아보겠습니다.

앞으로 나올 내용을 보는 동안 다음 규칙을 명심하세요.

  • 러스트에서, 각각의 값은 소유자(owner) 가 정해져 있습니다.
  • 한 값의 소유자는 동시에 여럿 존재할 수 없습니다.
  • 소유자가 스코프 밖으로 벗어날 때, 값은 버려집니다(dropped).
String 값이 생성되어 한 바인딩의 소유가 되고, 이동하면 이전 바인딩이 무효화되며, 최종 소유자의 스코프가 끝날 때 한 번 정리되는 상태 흐름

ONE VALUE · ONE CURRENT OWNER

String의 소유권이 이동하면 이전 이름이 아니라 새 소유자가 정리 책임까지 이어받습니다.

String 소유권의 생성, 이동, drop 상태 String 값은 s1이 소유하다가 대입으로 s2에 이동하며, s1은 사용할 수 없고 s2의 스코프가 끝날 때 값이 한 번 정리됩니다. 생성 let s2 = s1 스코프 끝 현재 소유자 s1: String · "hello" 유효 · 사용 가능 현재 소유자 s2 · s1: moved s1 사용 불가 · 정리 책임 이전 drop(s2) 버퍼 해제
  1. String::from으로 값 생성

    바인딩 s1이 값의 현재 소유자가 되어 사용과 정리를 책임집니다.

  2. let s2 = s1;로 이동

    소유권과 정리 책임이 s2로 넘어가고 s1은 더 이상 사용할 수 없습니다.

  3. 최종 소유자만 유효

    이동된 값 때문에 s1에서 별도 정리가 일어나지 않아 중복 해제를 막습니다.

  4. s2의 스코프 종료

    초기화된 최종 소유 값의 소멸자가 실행되어 String 버퍼가 한 번 해제됩니다.

이 흐름은 String 이동 예입니다. 이동은 소유권의 변화이며 구현에서는 값의 표현을 옮기는 작업이 생략될 수도 있습니다. 핵심은 이전 바인딩을 사용할 수 없고 최종 소유 값만 정리된다는 점입니다.

변수의 스코프

기본 문법은 이미 살펴봤으므로, 앞으로의 예제에서는 fn main() { 코드를 생략하겠습니다.

따라서 앞으로 예제를 따라 하실 때는 직접 main 함수에 코드를 작성해 주셔야 하니 주의해 주세요.

결과적으로 예제가 간략해져서 보일러 플레이트 코드가 아닌 실제 세부 사항에 집중하게끔 해줄 것입니다.

소유권 첫 예제로는 변수의 스코프(scope)를 다뤄보도록 하겠습니다.

스코프란, 프로그램 내에서 아이템이 유효한 범위를 말합니다.

다음과 같은 변수가 있다고 가정해봅시다.

let s = "hello";

변수 s는 문자열 리터럴(string literal)을 나타내며, 문자열 리터럴의 값은 코드 내에 하드코딩되어 있습니다.

이 변수는 선언된 시점부터 현재의 스코프를 벗어날 때까지 유효합니다.

예제 3-1에 변수 s가 유효한 지점을 주석으로 표시한 예제입니다.

예제 3-1: 변수와, 해당 변수가 유효한 스코프
    {                      // s는 아직 선언되지 않아서 여기서는 유효하지 않습니다
        let s = "hello";   // 이 지점부터 s가 유효합니다

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

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

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

여기까지 보면 변수의 유효성과 스코프는 다른 프로그래밍 언어와 비슷합니다.

이제, String 타입을 탐구해 가며 본격적으로 소유권을 이해해봅시다.


String 타입

소유권 규칙을 설명하려면 2장의 ‘데이터 타입’ 절에서 다룬 타입보다 복잡한 타입이 필요합니다.

앞서 예로 든 정수 같은 타입은 크기가 정해져 있고 Copy를 구현하므로, 값을 다른 변수에 대입해도 기존 변수와 새 변수를 모두 사용할 수 있습니다. 값이 실제로 스택과 레지스터 중 어디에 놓이는지는 구현 세부 사항이며, Copy 여부를 저장 위치만으로 판단하지는 않습니다.

하지만 이번에 필요한 건 힙에 할당된 데이터를 소유하고 정리하면서 러스트의 데이터 수명 관리를 알아보는 데 적합한 타입이지요.

String 타입이 좋은 예입니다.

여기서는 String의 소유권과 관련된 부분에만 집중하겠습니다.

이러한 관점은 표준 라이브러리가 제공하는 다른 타입들이나 여러분이 만들 복잡한 데이터 타입에도 적용됩니다.

String 타입은 7장에서 더 자세히 다루겠습니다.

여태 보아온 문자열은 코드 내에 하드코딩하는 방식의 문자열 리터럴이었습니다.

문자열 리터럴은 쓰기 편리하지만, 만능은 아닙니다.

그 이유는 문자열 리터럴이 불변성(immutable)을 지니기에 변경할 수 없다는 점과, 프로그램에 필요한 모든 문자열을 우리가 프로그래밍하는 시점에 알 수는 없다는 점 때문입니다.

사용자한테 문자열을 입력받아 저장하는 기능 등을 만들어야 하는 상황에서는 문자열 리터럴을 사용할 수 없죠.

따라서 러스트는 또 다른 문자열 타입인 String을 제공합니다.

이 타입은 힙에 할당된 데이터를 다루기 때문에, 컴파일 타임에 크기를 알 수 없는 텍스트도 저장할 수 있습니다.

String 타입은 다음과 같이 from 함수와 문자열 리터럴을 이용해 생성 가능합니다.

let s = String::from("hello");

이중 콜론 ::은 우리가 함수를 사용할 때 string_from 같은 함수명을 사용하지 않고 String 타입에 있는 특정된 from 함수라는 것을 지정할 수 있게 해주는 네임스페이스 연산자입니다.

메서드 관련 문법은 4장 ‘메서드 문법’에서 자세히 다루며, 모듈 및 네임스페이스는 6장 ‘경로를 사용하여 모듈 트리의 아이템 참조하기’에서 다루고 있습니다.

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

    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(해제)가 하나씩 짝짓도록 만들어야 합니다.

러스트에서는 힙 메모리를 소유한 값이 이동되지 않은 채 드롭되는 지점에 도달하면 필요한 정리 코드를 실행하는 방식으로 이 문제를 해결합니다.

예시로 보여드리도록 하죠.

예제 3-1에서 문자열 리터럴을 String으로 바꿔봤습니다.

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

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

보시면 String에서 사용한 메모리를 자연스럽게 해제하는 지점이 있습니다.

s가 이동되지 않은 채 수명을 마치는 지점인데, 러스트 컴파일러는 필요한 경우 Drop::drop을 포함한 드롭 코드를 삽입합니다.

이 함수는 해당 타입을 개발한 개발자가 직접 메모리 해제 코드를 작성해 넣을 수 있게 되어있으며, 위의 경우 String 개발자가 작성한 메모리 해제 코드가 실행되겠죠.

이 예제에서는 닫힌 중괄호 }에서 수명이 끝나지만, 함수의 조기 반환이나 패닉으로 스택을 되감을 때도 아직 초기화된 값을 위한 드롭 코드가 실행될 수 있습니다.

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

이 패턴은 러스트 코드를 작성하는 데 깊은 영향을 미칩니다.

지금은 단순해 보이지만, 힙 영역을 사용하는 변수가 많아져 상황이 복잡해지면 코드가 예기치 못한 방향으로 동작할 수도 있죠.

그러면 지금부터 그런 복잡한 상황들을 더 알아보겠습니다.

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

러스트에서는 동일한 데이터에 여러 변수가 서로 다른 방식으로 상호작용할 수 있습니다.

정수형을 이용한 예제로 살펴보겠습니다.

예제 3-2: 변수 x의 정숫값을 y에 대입하기
    let x = 5;
    let y = x;

대충 어떤 일이 일어날지 예상되네요.

5x에 바인딩하고, x 값의 복사본을 만들어 y에 바인딩하시오’ 그럼 x, y 두 변수가 생길 겁니다.

각각의 값은 5가 되겠죠.

실제로도 이와 같은데, 이 예제의 정수형 i32Copy를 구현하기 때문입니다. 개념적으로 두 바인딩은 각각 5 값을 가지며, 실제 저장 위치는 컴파일러가 결정합니다.

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

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

이전 코드와 매우 비슷하니, 동작 방식도 같을 것으로 생각하실 수도 있습니다.

두 번째 줄에서 s1의 복사본을 생성해 s2에 바인딩하는 식으로 말이죠.

하지만 이번엔 전혀 다른 방식으로 동작합니다.

그림 3-1를 참고해 주세요.

String은 그림 좌측에 표시된 것처럼 세 부분으로 이루어져 있습니다.

문자열 내용이 들어 있는 메모리를 가리키는 포인터, 문자열 길이, 메모리 용량입니다.

개념적으로 이 세 필드가 String 값의 표현을 이루고, 포인터가 별도의 힙 버퍼를 가리킵니다. 값 자체가 스택이나 레지스터 중 어디에 놓이는지는 구현과 최적화에 따라 달라질 수 있습니다.

우측은 문자열 내용이 들어있는 힙 메모리입니다.

스택의 포인터·길이·용량이 힙의 hello 문자열을 가리키는 String 메모리 구조 그림 3-1: s1 에 바인딩된, "hello" 값을 저장하고 있는 String의 메모리 속 모습

문자열 길이와 메모리 용량이 무슨 차이인가 궁금하실 분들을 위해 간단히 설명해 드리자면, 문자열 길이는 String의 내용이 현재 사용하고 있는 메모리를 바이트 단위로 나타낸 것이고, 메모리 용량은 메모리 할당자가 String에 할당한 메모리의 양을 뜻합니다.

지금의 맥락에서는 길이, 용량 사이의 차이는 중요한 내용이 아니니, 이해가 잘 안되면 용량 값은 무시하셔도 좋습니다.

s2s1를 대입하면 String 데이터가 복사됩니다.

이 이동 과정에서 포인터, 길이, 용량으로 이루어진 표현은 새 바인딩으로 전달되지만, 포인터가 가리키는 힙 버퍼까지 복제되지는 않습니다. 컴파일러는 이 표현을 실제로 복사하는 작업도 생략할 수 있습니다.

즉, 다음과 같은 메모리 구조를 갖게 됩니다.

s1의 포인터·길이·용량이 s2로 복사되어 같은 힙 문자열을 가리키는 구조 그림 3-2: 변수 s2s1의 포인터, 길이, 용량 값을 복사했을 때 나타나는 메모리 구조

아래의 그림 3-3은 힙 메모리의 데이터까지 복사했을 경우 나타날 구조로, 실제로는 이와 다릅니다.

만약 러스트가 이런 식으로 동작한다면, 힙 내 데이터가 커질수록 s2 = s1 연산은 굉장히 느려질 겁니다.

s1과 s2가 각각 복제된 힙 문자열을 소유한다고 가정한 깊은 복사 구조 그림 3-3: 러스트에서 힙 데이터까지 복사할 경우의 s2 = s1 연산 결과

앞서 설명했듯이, 초기화된 소유자가 이동되지 않은 채 수명을 마치면 러스트는 드롭 코드를 실행하고 String의 힙 버퍼를 해제합니다.

하지만 그림 3-2처럼 두 포인터가 같은 곳을 가리킬 경우에는 어떻게 될까요?

s2, s1이 스코프 밖으로 벗어날 때 각각 메모리를 해제하게 되면 중복 해제(double free) 에러가 발생할 겁니다.

이는 메모리 안정성 버그 중 하나이며, 보안을 취약하게 만드는 메모리 손상의 원인입니다.

메모리 안정성을 보장하기 위해서, 러스트는 let s2 = s1; 라인 뒤로는 s1이 더 이상 유효하지 않다고 판단합니다.

이로써 러스트는 s1이 스코프를 벗어나더라도 아무것도 해제할 필요가 없어집니다.

s2가 만들어진 이후에 s1을 사용하는 경우 어떤 일이 생기는지 확인해 보면, 작동하지 않음을 알 수 있습니다.

    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이동되었다라고 표현합니다.

String 소유권이 s1에서 s2로 이동해 s1이 무효화된 메모리 구조 그림 3-4: s1이 무효화된 후의 메모리 구조

이로써 문제가 사라졌네요!

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

덧붙이자면, 러스트는 절대 자동으로 ‘깊은’ 복사로 데이터를 복사하는 일이 없습니다.

Copy는 암시적인 깊은 복제를 하지 않고 값의 비트를 복사합니다. 비용은 값의 크기와 최종 코드 생성 결과에 따라 달라질 수 있습니다.

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

String의 힙 데이터까지 깊이 복사하고 싶을 땐 clone이라는 공용 메서드를 사용할 수 있습니다.

메서드 문법은 5장에서 다룰 예정이지만, 메서드라는 개념은 대부분의 프로그래밍 언어가 갖는 특성이기 때문에 이미 다뤄보셨을 겁니다.

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

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

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

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

이 코드에서 clone 호출을 보고, 이 지점에서 성능에 영향이 갈 수도 있는 코드가 실행될 것을 알 수 있습니다.

즉, clone은 해당 위치에서 무언가 다른 일이 수행될 것을 알려주는 시각적인 표시이기도 합니다.

Copy 트레이트를 구현한 데이터: 복사

아직 다루지 않은 부분이 있습니다.

다음 코드는 앞서 예제 3-2에서 본 정수형을 이용하는 코드입니다(정상적으로 작동합니다).

    let x = 5;
    let y = x;

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

하지만 이 코드는 방금 우리가 배운 내용과 맞지 않는 것처럼 보이네요.

clone을 호출하지도 않았는데 x는 계속해서 유효하며 y로 이동되지도 않았습니다.

이유는 이 예제의 정수형 i32Copy 트레이트를 구현하기 때문입니다.

Copy는 단순히 “스택에 저장되는가”로 판정하는 규칙이 아닙니다. 타입이 Copy를 구현하면 대입 시 값이 암시적으로 비트 복사되고 원본도 계속 유효합니다.

러스트에는 정수형 같은 타입에 구현할 수 있는 Copy 트레이트가 있습니다(트레이트는 9장에서 자세히 다룹니다).

만약 어떤 타입에 이 Copy 트레이트가 구현되어 있다면, 이 타입의 변수는 사용되어도 이동되지 않고 자명하게 복사되고, 대입 연산 후에도 사용할 수 있죠.

하지만 구현하려는 타입이나, 구현하려는 타입 중 일부분에 Drop 트레이트가 구현된 경우엔 Copy 트레이트를 어노테이션(annotation) 할 수 없습니다.

즉, 스코프 밖으로 벗어났을 때 특정 동작이 요구되는 타입에 Copy 어노테이션을 추가하면 컴파일 에러가 발생합니다.

여러분이 만든 타입에 Copy 어노테이션을 추가하는 방법은 부록 C의 ‘파생 가능한 트레이트’를 참고 바랍니다.

그래서, Copy 가능한 타입은 뭘까요?

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

Copy 가능한 타입 목록 중 일부를 보여드리겠습니다.

  • 모든 정수형 타입(예: u32)
  • true, false 값을 갖는 논리 자료형 bool
  • 모든 부동 소수점 타입(예: f64)
  • 문자 타입 char
  • Copy 가능한 타입만으로 구성된 튜플(예를 들어, (i32, i32)Copy 가능하지만 (i32, String)은 불가능합니다)
Rust 대입에서 move, 명시적 clone, Copy 트레이트 기반 암시적 복사가 원본 유효성, 데이터 중복, 적용 조건, drop 책임을 어떻게 다르게 만드는지 비교

ASSIGNMENT CONTRACT

구분 기준은 “스택인가?”가 아니라 타입의 Copy 계약과 명시적 clone 호출입니다.

let target = source; 전후의 원본 유효성과 정리 책임
연산과 원본 이후 상태 무엇이 복제되는가 적용 조건과 정리
move · s1은 사용할 수 없고 s2가 현재 소유자가 된다. 자동 깊은 복사는 없다. 구현상 비트가 옮겨질 수 있어도 의미는 소유권 이전이다. String처럼 Copy가 아닌 값의 기본 대입이다. 최종 소유 값만 drop한다.
clone · s1s2가 모두 유효하다. 타입이 정의한 명시적 복제를 실행한다. String은 힙 버퍼 내용도 복제한다. Clone 구현이 필요하며 비용과 자원 복제·공유 방식은 타입별이다. String 예에서는 두 값이 독립 버퍼를 각각 정리한다.
Copy · xy가 모두 유효하다. 암시적인 단순 비트 복사이며 동작을 재정의할 수 없다. 타입의 모든 구성 요소가 Copy여야 한다. Drop을 구현한 타입은 Copy일 수 없다.
move · 소유권 이전
let s2 = s1;
원본 s1 사용 불가, s2만 유효합니다.
복제·정리 자동 깊은 복사는 없고 최종 소유 값만 drop합니다.
clone · 명시적 복제
let s2 = s1.clone();
원본 s1s2 모두 유효합니다.
복제·정리 비용과 자원 공유 방식은 타입별입니다. String 예에서는 힙 버퍼도 복제해 두 값이 독립 버퍼를 각각 정리합니다.
Copy · 암시적 비트 복사
let y = x;
원본 xy 모두 유효합니다.
조건 모든 구성 요소가 Copy여야 하며 Drop 타입은 Copy일 수 없습니다.
Copy 여부는 값의 크기나 “스택에 저장된다”는 설명만으로 판정하지 않습니다. 실제 타입 문서와 트레이트 구현을 확인해야 합니다.

소유권과 함수

함수로 값을 전달하는 메커니즘은 변수에 값을 대입할 때와 유사합니다.

함수에 변수를 전달하면 대입 연산과 마찬가지로 이동이나 복사가 일어나기 때문이죠.

예제 3-3에 변수가 스코프를 벗어나는 부분을 주석으로 표시해 보았습니다.

예제 3-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가 스코프 밖으로 벗어납니다. 별다른 일이 발생하지 않습니다.

러스트는 takes_ownership 함수를 호출한 이후에 s를 사용하려 할 경우, 컴파일 타임 에러를 발생시킵니다.

이런 정적 검사들이 프로그래머의 여러 실수를 방지해 주죠.

어느 지점에서 변수를 사용할 수 있고, 어느 지점에서 소유권 규칙이 여러분을 제재하는지 확인해 보려면 main 함수에 s, x 변수를 사용하는 코드를 여기저기 추가해보세요.


반환 값과 스코프

소유권은 값을 반환하는 과정에서도 이동합니다.

앞서 본 예제 3-3과 비슷한 주석이 달린, 어떤 값을 반환하는 함수에 대한 예제 3-4를 살펴봅시다.

예제 3-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이 반환되고 호출자 함수 쪽으로 이동합니다
}
String과 i32를 함수 인수로 전달하거나 String을 반환할 때 호출자 바인딩의 유효성, 새 소유자, 최종 drop 책임을 추적하는 표

OWNERSHIP LEDGER · BY VALUE

값으로 받는 매개변수와 반환 표현식은 대입과 같은 규칙을 따릅니다. 각 줄에서 “호출 뒤 어떤 이름이 유효한가?”를 추적합니다.

예제 3-3과 3-4의 인수·반환 소유권 추적
경계 · 호출자 쪽 상태 현재 소유자와 정리
takes_ownership(s) · String 값이 이동하므로 호출 뒤 s는 사용할 수 없다. 매개변수 some_string이 소유한다. 함수 끝에서 그 값이 drop되어 버퍼를 정리한다.
makes_copy(x) · i32: Copy이므로 호출 뒤에도 x가 유효하다. 매개변수는 독립된 복사 값을 받는다. 자원 소유권을 옮기지 않는다.
gives_ownership() -> String · 반환 값이 s1로 이동하며 s1이 유효해진다. 함수의 지역 바인딩에서 호출자의 s1로 소유권이 넘어가며, s1의 스코프 끝에서 정리한다.
s3 = takes_and_gives_back(s2) · 호출 때 s2가 매개변수로 이동하고, 반환 뒤에는 s3만 유효하다. 매개변수가 받은 값을 반환해 s3가 최종 소유자가 된다. s3의 스코프 끝에서 정리한다.
takes_ownership(s)
호출자 String 값이 이동해 s는 사용할 수 없습니다.
소유·정리 some_string이 소유하며 함수 끝에서 그 값을 drop합니다.
makes_copy(x)
호출자 i32: Copy이므로 호출 뒤에도 x가 유효합니다.
소유·정리 매개변수는 독립된 복사 값을 받으며 자원 소유권을 옮기지 않습니다.
gives_ownership() -> String
호출자 반환 값이 s1로 이동해 s1이 유효해집니다.
소유·정리 호출자의 s1이 소유하며 그 스코프 끝에서 정리합니다.
s3 = takes_and_gives_back(s2)
호출자 s2가 매개변수로 이동하고 반환 뒤에는 s3만 유효합니다.
소유·정리 반환으로 소유권을 받은 s3가 최종 정리를 맡습니다.
이 ledger는 T를 값으로 전달하는 경우만 다룹니다. 참조를 전달해 소유권을 유지하는 대여 규칙은 다음 절에서 이어집니다.

상황은 다양할지라도, 변수의 소유권 규칙은 언제나 동일합니다.

값을 다른 변수에 대입하거나 함수에 값으로 전달·반환하면, Copy가 아닌 값의 소유권은 이동하고 Copy 값은 복사됩니다. 초기화된 소유자가 이동되지 않은 채 수명을 마치면 필요한 드롭 코드가 실행되어 소유한 리소스를 정리합니다.

이런 방식이 동작하더라도, 모든 함수가 소유권을 가졌다가 반납하는 것은 조금 번거롭습니다.

함수에 넘겨줄 값을 함수 호출 이후에도 쓰고 싶은데, 그렇다고 해서 함수로부터 얻고자 하는 결과에 더해서 이후 다시 쓰고 싶은 변수까지 같이 반환받아야 한다면 본말전도나 다름없죠.

그럼, 함수가 값을 사용할 수 있도록 하되 소유권은 가져가지 않도록 하고 싶다면 어떻게 해야 할까요?

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

예제 3-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)
}

하지만 이런 식은 일반적인 컨셉이 되기엔 너무 거추장스럽고 많은 작업량이 수반됩니다.

다행히도, 러스트에는 소유권 이동 없이 값을 사용할 수 있는 참조자(reference) 라는 기능을 가지고 있습니다.

소유권을 넘길지, 복제할지, 잠시 빌릴지는 값의 크기보다 “호출 이후 원래 변수를 계속 써야 하는가”를 먼저 기준으로 삼으면 더 명확합니다. 다음 절에서는 함수가 값을 사용하되 소유권은 가져가지 않는 참조자와 대여 규칙을 살펴봅니다.