소유권이 뭔가요?
스택과 힙의 메모리 수명을 바탕으로 이동·클론·복사를 구분하고 함수 호출과 스코프에서 값의 소유권을 추적합니다.
소유권(ownership)은 러스트 프로그램이 메모리를 관리하는 방법을 정하는 규칙의 모음입니다. 모든 프로그램은 실행되는 동안 쓰는 메모리를 관리해야 합니다. 어떤 언어는 더 이상 쓰지 않는 메모리를 가비지 컬렉터가 실행 중에 찾아 정리하고, 어떤 언어는 프로그래머가 직접 메모리를 할당하고 해제합니다. 러스트는 세 번째 방법을 택했습니다. 소유권 규칙을 정하고 컴파일러가 컴파일할 때 그 규칙을 검사하며, 규칙을 하나라도 어기면 프로그램이 컴파일되지 않습니다.
규칙은 컴파일할 때 검사되므로, 이를 위한 가비지 컬렉터가 실행 중에 돌지는 않습니다. 소유권은 처음 접하면 낯선 개념이라 익숙해지는 데 시간이 걸리지만, 익숙해질수록 안전하고 효율적인 코드를 자연스럽게 쓰게 되며, 러스트의 다른 기능도 이 개념 위에 서 있습니다. 이 글은 문자열을 예로 들어 소유권 규칙과, 값이 다른 변수나 함수로 넘어갈 때 일어나는 이동, 복제, 복사를 살펴봅니다.
스택과 힙
많은 언어에서는 값이 스택(stack)에 있는지 힙(heap)에 있는지를 신경 쓸 일이 별로 없습니다. 하지만 러스트 같은 시스템 프로그래밍 언어에서는 이 차이가 프로그램의 동작과 프로그래머의 선택에 큰 영향을 주므로, 소유권을 설명하기 전에 두 영역을 간단히 살펴봅니다. 둘 다 프로그램이 실행 중에 쓰는 메모리이지만 구조가 다릅니다. 두 영역이 프로세스의 메모리에서 어디에 놓이는지는 프로세스의 개념에서 다룹니다.
스택은 값을 들어온 순서대로 쌓고 나중에 들어온 것부터 꺼내는 후입선출(last in, first out) 구조입니다. 접시를 쌓을 때 맨 위에 올리고 꺼낼 때도 맨 위에서 꺼내는 것과 같으며, 값을 넣는 것을 푸시(push), 꺼내는 것을 팝(pop)이라고 합니다. 스택에 바로 놓이는 값은 컴파일할 때 크기를 알 수 있어야 하므로, String처럼 실행 중에 크기가 바뀌는 값은 그 내용을 대개 힙에 둡니다.
힙은 스택보다 덜 정돈되어 있습니다. 힙에 값을 넣으려면 메모리 할당자(allocator)에게 필요한 크기의 공간을 요청하고, 할당자는 충분히 큰 빈자리를 찾아 사용 중으로 표시한 뒤 그 주소를 가리키는 포인터(pointer)를 돌려줍니다. 이 과정을 힙 할당, 줄여서 할당(allocation)이라고 하며, 스택에 값을 넣는 것은 할당이라고 부르지 않습니다. 포인터는 크기가 정해져 있어 스택에 둘 수 있지만, 실제 데이터를 쓰려면 포인터를 따라가야 합니다. 식당에 들어가 일행의 수를 말하면 직원이 맞는 빈자리로 안내하고, 늦게 온 일행은 직원에게 물어 자리를 찾아오는 것과 비슷합니다.
스택에 값을 넣는 것은 새 자리를 찾을 필요 없이 언제나 맨 위에 놓으면 되므로 보통 힙에 할당하는 것보다 빠릅니다. 힙의 데이터는 포인터를 따라가야 하고 메모리 여기저기에 흩어져 있을 수 있어서, 함께 모여 있는 데이터보다 다루는 데 시간이 더 걸리기도 합니다. 다만 실제 속도는 데이터의 배치와 접근 방식, 컴파일러의 최적화에 따라 달라지므로, 스택이 언제나 더 빠르다고 단정할 수는 없습니다. 함수를 부르면 함수에 넘긴 값과 함수의 지역 변수가 스택에 놓이고, 함수가 끝나면 스택에서 사라진다고 생각하면 됩니다.
어느 코드가 힙의 어떤 데이터를 쓰는지 추적하고, 힙에 같은 데이터가 중복되지 않게 하고, 쓰지 않는 데이터를 정리해 공간이 모자라지 않게 하는 것이 모두 소유권이 다루는 문제입니다. 소유권을 이해하고 나면 스택과 힙을 자주 의식할 필요는 없지만, 소유권의 주된 목적이 힙의 데이터를 관리하는 것이라는 점을 알면 소유권이 왜 이렇게 동작하는지 이해하기 쉽습니다.
소유권 규칙
앞으로의 예제를 보는 동안 다음 세 규칙을 기억해 둡니다.
- 러스트의 모든 값에는 소유자(owner)가 있습니다.
- 한 값의 소유자는 한 번에 하나뿐입니다.
- 소유자가 범위(scope)를 벗어나면 그 값은 버려집니다(dropped).
아래 그림은 String 값 하나가 만들어져 s1의 소유가 되고, s2로 옮겨진 뒤, 마지막 소유자인 s2의 범위가 끝날 때 한 번 정리되는 과정을 보여 줍니다. 소유권이 옮겨지면 이전 이름 s1은 더 이상 쓸 수 없으며, 정리하는 일도 새 소유자가 넘겨받습니다. 이 과정은 이 글의 뒤에서 자세히 다룹니다.
String 소유권의 이동과 drop 상태
String 생성→s1 소유→let s2=s1 이동→s2 스코프 종료의 동일 값 소유 상태가 변한다. s1 moved·정리 책임 이전·버퍼 한 번 해제를 단계마다 드러내되 표현의 물리적 복사를 단정하지 않는다.
ONE VALUE · ONE CURRENT OWNER
이동된 값의 정리 책임은 새 소유자에게 간다
String의 소유권이 이동하면 이전 이름이 아니라 새 소유자가 정리 책임까지 이어받습니다.
String 이동 예의 상태 모델이동과 출력은 다른 작업
String::from으로 값 생성바인딩
s1이 값의 현재 소유자가 되어 사용과 정리를 책임집니다.let s2 = s1;로 이동소유권과 정리 책임이
s2로 넘어가고s1은 더 이상 사용할 수 없습니다.- 최종 소유자만 유효
이동된 값 때문에
s1에서 별도 정리가 일어나지 않아 중복 해제를 막습니다. s2의 스코프 종료초기화된 최종 소유 값의 소멸자가 실행되어
String버퍼가 한 번 해제됩니다.
String 이동 예입니다. 이동은 소유권의 변화이며 구현에서는 값의 표현을 옮기는 작업이 생략될 수도 있습니다. 핵심은 이전 바인딩을 사용할 수 없고 최종 소유 값만 정리된다는 점입니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 기존 가로 도표와 같은 4노드와 3간선이다. String 생성→s1 소유→s2 소유→s2 정리. s1은 moved 상태가 되어 사용할 수 없으며 따로 버퍼를 해제하지 않는다. String의 실제 표현 이동이 최적화되는지와 무관한 현재 소유자 상태 모델이다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| String 생성 s1이 hello 문자열을 소유한다. |
|
|
| 소유권 이동 let s2=s1 뒤 현재 소유자가 바뀐다. |
|
|
| s2 종료 최종 소유자 범위를 나가 버퍼를 정리한다. |
|
|
String 소유권의 이동과 drop 상태
String 생성→s1 소유→let s2=s1 이동→s2 스코프 종료의 동일 값 소유 상태가 변한다. s1 moved·정리 책임 이전·버퍼 한 번 해제를 단계마다 드러내되 표현의 물리적 복사를 단정하지 않는다.
ONE VALUE · ONE CURRENT OWNER
이동된 값의 정리 책임은 새 소유자에게 간다
String의 소유권이 이동하면 이전 이름이 아니라 새 소유자가 정리 책임까지 이어받습니다.
String 이동 예의 상태 모델이동과 출력은 다른 작업
String::from으로 값 생성바인딩
s1이 값의 현재 소유자가 되어 사용과 정리를 책임집니다.let s2 = s1;로 이동소유권과 정리 책임이
s2로 넘어가고s1은 더 이상 사용할 수 없습니다.- 최종 소유자만 유효
이동된 값 때문에
s1에서 별도 정리가 일어나지 않아 중복 해제를 막습니다. s2의 스코프 종료초기화된 최종 소유 값의 소멸자가 실행되어
String버퍼가 한 번 해제됩니다.
String 이동 예입니다. 이동은 소유권의 변화이며 구현에서는 값의 표현을 옮기는 작업이 생략될 수도 있습니다. 핵심은 이전 바인딩을 사용할 수 없고 최종 소유 값만 정리된다는 점입니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 기존 가로 도표와 같은 4노드와 3간선이다. String 생성→s1 소유→s2 소유→s2 정리. s1은 moved 상태가 되어 사용할 수 없으며 따로 버퍼를 해제하지 않는다. String의 실제 표현 이동이 최적화되는지와 무관한 현재 소유자 상태 모델이다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| String 생성 s1이 hello 문자열을 소유한다. |
|
|
| 소유권 이동 let s2=s1 뒤 현재 소유자가 바뀐다. |
|
|
| s2 종료 최종 소유자 범위를 나가 버퍼를 정리한다. |
|
|
변수의 스코프
2장에서 기본 문법을 익혔으므로, 이제부터는 짧은 예제에서 fn main() {과 그 닫는 중괄호를 생략합니다. 예제를 따라 할 때는 이런 코드를 main 함수 안에 넣으세요. 다만 함수를 따로 정의하는 예제나 오류 메시지를 함께 보여 주는 예제는 main까지 모두 적습니다.
소유권의 첫 예로 변수의 범위, 곧 스코프를 봅니다. 스코프는 프로그램 안에서 어떤 항목이 유효한 범위입니다. 다음 변수를 생각해 봅시다.
let s = "hello";s는 문자열 리터럴을 가리키며, 문자열 리터럴의 값은 프로그램의 코드에 직접 적혀 있습니다. 이 변수는 선언된 곳부터 지금의 스코프가 끝날 때까지 유효합니다.
{ // s는 아직 선언되지 않아 유효하지 않음
let s = "hello"; // 여기부터 s가 유효함
// s로 할 일을 함
} // 스코프가 끝나 s는 더 이상 유효하지 않음s는 스코프 안에 들어온 때부터 유효하고, 스코프를 벗어날 때까지 유효합니다. 여기까지는 다른 언어의 변수와 다르지 않습니다. 이제 String 타입으로 소유권을 살펴봅니다.
String 타입
소유권 규칙을 설명하려면 데이터 타입에서 본 타입보다 복잡한 타입이 필요합니다. 그 타입들은 크기가 정해져 있어 스택에 놓을 수 있고, 다른 변수에 대입해도 원래 변수를 계속 쓸 수 있습니다. 여기서는 힙에 데이터를 두는 타입으로, 러스트가 그 데이터를 언제 정리하는지 알아봅니다. String이 좋은 예이며, 이 글에서는 String 가운데 소유권과 관련된 부분만 봅니다. 같은 내용이 표준 라이브러리의 다른 타입과 직접 만드는 복잡한 타입에도 적용되며, String 자체는 UTF-8 문자열에서 자세히 다룹니다.
지금까지 본 문자열은 코드에 직접 적는 문자열 리터럴이었습니다. 문자열 리터럴은 편하지만 모든 경우에 쓸 수는 없습니다. 불변이어서 바꿀 수 없고, 사용자에게 입력받는 문자열처럼 코드를 쓸 때 내용을 알 수 없는 문자열도 있기 때문입니다. 그래서 러스트에는 힙에 데이터를 두어 컴파일할 때 크기를 알 수 없는 텍스트도 담을 수 있는 String 타입이 따로 있습니다. 문자열 리터럴로 String을 만들려면 from 함수를 씁니다.
let s = String::from("hello");::는 이 from이 String 타입에 딸린 함수라는 것을 나타내므로, string_from 같은 이름을 따로 지을 필요가 없습니다. 이 문법은 메서드 문법과 모듈 경로와 아이템 참조에서 더 다룹니다. 이렇게 만든 String은 내용을 바꿀 수 있습니다.
let mut s = String::from("hello");
s.push_str(", world!"); // 문자열 뒤에 리터럴을 덧붙임
println!("{s}"); // hello, world!를 출력함문자열 리터럴은 바꿀 수 없는데 String은 바꿀 수 있는 까닭은 두 타입이 메모리를 쓰는 방식에 있습니다.
메모리와 할당
문자열 리터럴은 컴파일할 때 내용을 알 수 있으므로 그 텍스트가 실행 파일에 그대로 들어갑니다. 그래서 빠르고 효율적이지만, 내용이 바뀌지 않을 때만 쓸 수 있습니다. 실행하면서 크기가 바뀔 수 있는 텍스트마다 실행 파일에 메모리를 미리 넣어 둘 수는 없습니다.
String은 바꾸고 늘릴 수 있는 텍스트를 담기 위해, 컴파일할 때는 크기를 알 수 없는 메모리를 힙에 할당해 내용을 둡니다. 그러려면 두 가지가 필요합니다. 하나는 실행 중에 메모리 할당자에게 메모리를 요청하는 것이고, 다른 하나는 String을 다 쓴 뒤 그 메모리를 할당자에게 돌려주는 것입니다. 첫째는 String::from을 부를 때 그 구현이 필요한 메모리를 요청하므로 이미 해결되었고, 이는 대부분의 언어에서도 같습니다.
둘째는 다릅니다. 가비지 컬렉터가 있는 언어에서는 가비지 컬렉터가 더 이상 쓰지 않는 메모리를 찾아 정리하므로 신경 쓸 필요가 없습니다. 가비지 컬렉터가 없는 언어에서는 메모리를 언제 쓰지 않게 되는지 프로그래머가 알아내 직접 해제해야 하는데, 이는 오랫동안 어려운 문제였습니다. 해제를 잊으면 메모리가 낭비되고, 너무 일찍 해제하면 유효하지 않은 값을 쓰게 되며, 두 번 해제해도 오류가 됩니다. 할당 한 번에 해제가 정확히 한 번 짝지어져야 합니다.
러스트는 다른 길을 택했습니다. 메모리를 소유한 변수가 스코프를 벗어나면 그 메모리를 자동으로 돌려줍니다. 앞의 스코프 예제를 문자열 리터럴 대신 String으로 바꿔 봅니다.
{
let s = String::from("hello"); // 여기부터 s가 유효함
// s로 할 일을 함
} // 스코프가 끝나 s가 유효하지 않게 됨s가 스코프를 벗어나는 닫는 중괄호가 String이 쓰던 메모리를 할당자에게 돌려줄 자연스러운 때입니다. 변수가 스코프를 벗어나면 러스트는 drop이라는 특별한 함수를 부르며, String을 만든 사람은 이 drop에 메모리를 돌려주는 코드를 넣어 두었습니다. 닫는 중괄호뿐 아니라 함수가 중간에 반환하거나 패닉으로 함수에서 빠져나갈 때도, 아직 버려지지 않은 값에는 drop이 불립니다.
C++에서는 이렇게 항목의 수명이 끝날 때 자원을 해제하는 방식을 RAII(Resource Acquisition Is Initialization)라고 부르며, RAII를 써 보았다면 러스트의 drop이 익숙할 것입니다. 이 방식은 러스트 코드를 쓰는 방식에 깊은 영향을 줍니다. 지금은 간단해 보이지만, 힙에 할당한 데이터를 여러 변수가 쓰려고 할 때는 예상하지 못한 방식으로 동작할 수 있습니다. 그런 경우를 하나씩 봅니다.
변수와 데이터 간 상호작용 방식: 이동
여러 변수가 같은 데이터와 여러 방식으로 상호작용할 수 있습니다. 먼저 정수의 예입니다.
let x = 5;
let y = x;짐작대로 5를 x에 묶고, x의 값을 복사해 y에 묶습니다. 두 변수 x와 y가 생기고 둘 다 5입니다. 정수는 크기가 정해진 단순한 값이라 복사하기 쉽고, 두 개의 5가 모두 스택에 놓입니다. 이번에는 String으로 바꿔 봅니다.
let s1 = String::from("hello");
let s2 = s1;앞의 코드와 비슷해 보여서, 둘째 줄이 s1의 값을 복사해 s2에 묶는다고 생각하기 쉽지만 그렇지 않습니다. String은 세 부분으로 이루어져 있습니다. 문자열의 내용이 담긴 메모리를 가리키는 포인터, 길이, 용량입니다. 이 세 값은 스택에 놓이고, 포인터가 가리키는 내용은 힙에 있습니다. 다음 그림의 왼쪽이 s1의 세 부분이고, 오른쪽이 힙에 있는 "hello"의 내용입니다.
길이는 String의 내용이 지금 쓰고 있는 메모리의 바이트 수이고, 용량은 String이 할당자에게서 받은 메모리의 전체 바이트 수입니다. 둘의 차이는 지금의 이야기에서 중요하지 않으므로 용량은 무시해도 됩니다. s1을 s2에 대입하면 String의 데이터 가운데 스택에 있는 포인터와 길이, 용량이 복사됩니다. 포인터가 가리키는 힙의 데이터는 복사되지 않으므로, 메모리는 다음 그림과 같아집니다.
힙의 데이터까지 복사한다면 다음 그림처럼 될 것이지만, 러스트는 이렇게 하지 않습니다. 이렇게 한다면 힙의 데이터가 클수록 s2 = s1이 아주 느려질 것입니다.
앞에서 변수가 스코프를 벗어나면 러스트가 drop을 불러 그 변수의 힙 메모리를 정리한다고 했습니다. 그런데 두 번째 그림처럼 두 포인터가 같은 곳을 가리키면, s2와 s1이 스코프를 벗어날 때 둘 다 같은 메모리를 해제하려 할 것입니다. 이것이 이중 해제(double free) 오류로, 메모리를 두 번 해제하면 메모리가 손상되어 보안 취약점으로 이어질 수 있습니다.
메모리 안전성을 지키기 위해, 러스트는 let s2 = s1; 뒤로 s1을 더 이상 유효하지 않은 것으로 봅니다. 그래서 s1이 스코프를 벗어나도 해제할 것이 없습니다. s2를 만든 뒤 s1을 쓰면 어떻게 되는지 확인해 봅니다. 이 코드는 컴파일되지 않습니다.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
}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();
| ++++++++E0382는 옮겨진 값을 다시 썼다는 오류입니다. 메시지는 s1이 Copy 트레이트를 구현하지 않는 String이어서 옮겨진다는 것, 3번째 줄에서 값이 옮겨졌다는 것, 5번째 줄에서 옮겨진 뒤에 그 값을 빌려 쓰려 했다는 것을 차례로 짚습니다. println!은 출력할 값을 소유하지 않고 빌려 쓰므로 메시지에 대여(borrow)라는 말이 나오며, 대여는 다음 글 참조와 대여에서 다룹니다.
다른 언어에서 얕은 복사(shallow copy)와 깊은 복사(deep copy)라는 말을 들어 보았다면, 힙의 데이터 없이 포인터와 길이, 용량만 복사하는 것이 얕은 복사처럼 보일 것입니다. 하지만 러스트는 원래 변수까지 무효로 만들므로, 이를 얕은 복사가 아니라 이동(move)이라고 부르며, 위의 코드에서 s1이 s2로 이동했다고 말합니다. 실제로 일어나는 일은 다음 그림과 같습니다.
이제 s2만 유효하므로, s2가 스코프를 벗어날 때 그 메모리를 한 번만 해제하면 됩니다. 여기에는 러스트의 설계 원칙 하나가 담겨 있습니다. 러스트는 데이터를 자동으로 깊은 복사하지 않습니다. 그래서 자동으로 일어나는 복사는 실행 중의 비용이 적다고 볼 수 있습니다.
변수와 데이터 간 상호작용 방식: 클론
스택의 데이터뿐 아니라 힙의 데이터까지 깊이 복사하고 싶다면 clone이라는 메서드를 씁니다. 메서드 문법은 4장에서 다루지만, 메서드는 대부분의 언어에 있으므로 익숙할 것입니다.
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");s1 = hello, s2 = hello이 코드는 잘 동작하며, 힙의 데이터까지 복사한 세 번째 그림의 상태를 만듭니다. s2는 s1과 별개의 힙 메모리를 가지므로, s2의 내용을 바꿔도 s1은 바뀌지 않습니다. clone을 부르는 곳을 보면, 그 자리에서 비용이 클 수도 있는 코드가 실행된다는 것을 알 수 있습니다. clone은 무언가 다른 일이 일어난다는 눈에 보이는 표시이기도 합니다.
Copy 트레이트를 구현한 데이터: 복사
아직 설명하지 않은 것이 있습니다. 다음 정수 코드는 잘 동작합니다.
let x = 5;
let y = x;
println!("x = {x}, y = {y}");x = 5, y = 5clone을 부르지 않았는데도 x는 여전히 유효하고 y로 이동하지 않았으니, 방금 배운 내용과 맞지 않아 보입니다. 정수처럼 컴파일할 때 크기를 아는 타입은 값 전체가 스택에 놓여 실제 값을 복사하는 비용이 적으므로, y를 만든 뒤에 x를 무효로 만들 까닭이 없습니다. 이런 타입에서는 깊은 복사와 얕은 복사의 차이가 없어서, clone을 불러도 보통의 얕은 복사와 다르지 않습니다.
러스트에는 이런 타입에 붙이는 Copy라는 특별한 트레이트(trait)가 있으며, 트레이트는 트레이트와 공통 동작에서 자세히 다룹니다. Copy를 구현한 타입의 값은 대입해도 이동하지 않고 복사되므로, 대입한 뒤에도 원래 변수를 쓸 수 있습니다. 판단의 기준은 값이 스택에 있느냐가 아니라 그 타입이 Copy를 구현했느냐입니다. 타입이나 그 타입의 일부가 Drop 트레이트를 구현했다면 Copy를 붙일 수 없으므로, 스코프를 벗어날 때 특별한 정리가 필요한 타입에 Copy를 붙이면 컴파일 오류가 납니다. 직접 만든 타입에 Copy를 붙이는 방법은 부록 C 파생 가능한 트레이트에 있습니다.
어떤 타입이 Copy인지는 그 타입의 문서에서 확인할 수 있지만, 대체로 단순한 스칼라 값과 그런 값만 묶은 것은 Copy이고, 할당이 필요하거나 자원을 다루는 것은 Copy가 아닙니다. Copy인 타입의 예는 다음과 같습니다.
u32같은 모든 정수 타입- 불리언 타입
bool f64같은 모든 부동 소수점 타입- 문자 타입
char Copy인 타입만으로 이루어진 튜플. 예를 들어(i32, i32)는Copy이지만(i32, String)은 아닙니다.
아래 표는 대입할 때의 이동, clone으로 하는 명시적 복제, Copy에 따른 복사를 비교합니다.
ASSIGNMENT CONTRACT
move·clone·Copy는 원본 유효성과 복제 방식이 다르다
구분 기준은 “스택인가?”가 아니라 타입의 Copy 계약과 명시적 clone 호출입니다.
| 연산과 원본 이후 상태 | 무엇이 복제되는가 | 적용 조건과 정리 |
|---|---|---|
| move
|
자동 깊은 복사는 없다. 구현상 비트가 옮겨질 수 있어도 의미는 소유권 이전이다. | String처럼 Copy가 아닌 값의 기본 대입이다. 최종 소유 값만 drop한다. |
| clone
|
타입이 정의한 명시적 복제를 실행한다. String은 힙 버퍼 내용도 복제한다. |
Clone 구현이 필요하며 비용과 자원 복제·공유 방식은 타입별이다. String 예에서는 두 값이 독립 버퍼를 각각 정리한다. |
| Copy
|
암시적인 단순 비트 복사이며 동작을 재정의할 수 없다. | 타입의 모든 구성 요소가 Copy여야 한다. Drop을 구현한 타입은 Copy일 수 없다. |
- move · 소유권 이전
let s2 = s1;- 원본
s1사용 불가,s2만 유효합니다. - 복제·정리 자동 깊은 복사는 없고 최종 소유 값만 drop합니다.
- clone · 명시적 복제
let s2 = s1.clone();- 원본
s1과s2모두 유효합니다. - 복제·정리 비용과 자원 공유 방식은 타입별입니다.
String예에서는 힙 버퍼도 복제해 두 값이 독립 버퍼를 각각 정리합니다. - Copy · 암시적 비트 복사
let y = x;- 원본
x와y모두 유효합니다. - 조건 모든 구성 요소가
Copy여야 하며Drop타입은Copy일 수 없습니다.
Copy 여부는 값의 크기나 “스택에 저장된다”는 설명만으로 판정하지 않습니다. 실제 타입 문서와 트레이트 구현을 확인해야 합니다.이동하면 원래 변수는 쓸 수 없고 새 변수가 소유자가 되며, 마지막 소유자만 값을 정리합니다. clone은 두 변수가 모두 유효하고, String이라면 힙의 내용까지 복제해 두 값이 각자의 메모리를 따로 정리합니다. Copy는 두 변수가 모두 유효하며, 값의 비트를 그대로 복사할 뿐 따로 정리할 것이 없습니다.
소유권과 함수
함수에 값을 넘기는 것은 변수에 값을 대입하는 것과 같은 방식으로 동작합니다. 변수를 함수에 넘기면 대입할 때처럼 이동하거나 복사됩니다. 다음 예제의 주석은 변수가 언제 스코프에 들어오고 나가는지 보여 줍니다.
fn main() {
let s = String::from("hello"); // s가 스코프에 들어옴
takes_ownership(s); // s의 값이 함수로 이동함
// 그래서 여기부터 s는 유효하지 않음
let x = 5; // x가 스코프에 들어옴
makes_copy(x); // i32는 Copy이므로 x는 이동하지 않고
// 그 뒤에도 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가 스코프를 벗어남. 따로 일어나는 일은 없음hello
5takes_ownership(s)를 부른 뒤에 s를 쓰려 하면 컴파일러가 오류를 냅니다. 이런 정적 검사가 실수를 막아 줍니다. main에서 s와 x를 쓰는 코드를 여기저기 넣어 보면, 어디서 쓸 수 있고 어디서 소유권 규칙이 막는지 직접 확인할 수 있습니다.
반환 값과 스코프
값을 돌려줄 때도 소유권이 이동합니다. 아래 코드는 함수 안에서 만든 값을 호출한 쪽에 넘기는 gives_ownership과, 받은 값을 그대로 돌려주는 takes_and_gives_back을 씁니다. 앞 예제처럼 주석에 각 변수가 언제 스코프에 들어오고 나가는지 적어 두었습니다.
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을 반환하며 부른 함수로 이동함
}아래 그림처럼 takes_and_gives_back에 s2의 값을 넘기면 그 값은 매개변수 a_string의 소유가 되고, 함수가 그 값을 돌려주면 다시 main의 s3가 받아 소유합니다. s2는 값을 넘겨준 뒤로 유효하지 않으므로, main이 끝날 때 버려지는 것은 s3와 s1의 값입니다.
함수 인수와 반환 값의 소유권 추적표
takes_ownership의 String 인수 이동·최종 drop, takes_and_gives_back의 s2→a_string→s3에 동일 값의 전후 상태가 있다. 네 독립 예를 이어 붙이지 않고 한 사례의 호출 전·함수 안·반환 뒤를 보여 주며 i32 Copy와 gives_ownership 비교는 전체 관계도와 표로 보존한다.
OWNERSHIP LEDGER · BY VALUE
함수 경계를 넘을 때 유효한 이름을 추적한다
값으로 받는 매개변수와 반환 표현식은 대입과 같은 규칙을 따릅니다. 각 줄에서 “호출 뒤 어떤 이름이 유효한가?”를 추적합니다.
| 경계 · 호출자 쪽 상태 | 현재 소유자와 정리 |
|---|---|
takes_ownership(s)
|
매개변수 some_string이 소유한다. 함수 끝에서 그 값이 drop되어 버퍼를 정리한다. |
makes_copy(x)
|
매개변수는 독립된 복사 값을 받는다. 자원 소유권을 옮기지 않는다. |
gives_ownership() -> String반환 값이 |
함수의 지역 바인딩에서 호출자의 s1로 소유권이 넘어가며, s1의 스코프 끝에서 정리한다. |
s3 = takes_and_gives_back(s2)호출 때 |
매개변수가 받은 값을 반환해 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가 최종 정리를 맡습니다.
T를 값으로 전달하는 경우만 다룹니다. 참조를 전달해 소유권을 유지하는 대여 규칙은 다음 절에서 이어집니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 10개 노드와 6개 간선이다. takes_ownership은 s→some_string→함수 끝의 정리; makes_copy는 main x가 계속 유효한 채 some_integer 복사본; gives_ownership은 some_string→main s1; takes_and_gives_back은 s2→a_string→s3 이동이다. 네 사례를 연결하는 실행 간선은 없다. 인수 이동, Copy, 반환 이동, 최종 정리 간선의 의미를 라벨로 구별하며 가로/세로 도표의 계약이 같다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호출 전 takes_and_gives_back에 s2의 소유 값을 넘긴다. |
|
|
| 함수 안 a_string이 전달된 값을 소유한다. |
|
|
| 반환 뒤 반환값을 s3가 받아 계속 소유한다. |
|
|
함수 인수와 반환 값의 소유권 추적표
takes_ownership의 String 인수 이동·최종 drop, takes_and_gives_back의 s2→a_string→s3에 동일 값의 전후 상태가 있다. 네 독립 예를 이어 붙이지 않고 한 사례의 호출 전·함수 안·반환 뒤를 보여 주며 i32 Copy와 gives_ownership 비교는 전체 관계도와 표로 보존한다.
OWNERSHIP LEDGER · BY VALUE
함수 경계를 넘을 때 유효한 이름을 추적한다
값으로 받는 매개변수와 반환 표현식은 대입과 같은 규칙을 따릅니다. 각 줄에서 “호출 뒤 어떤 이름이 유효한가?”를 추적합니다.
| 경계 · 호출자 쪽 상태 | 현재 소유자와 정리 |
|---|---|
takes_ownership(s)
|
매개변수 some_string이 소유한다. 함수 끝에서 그 값이 drop되어 버퍼를 정리한다. |
makes_copy(x)
|
매개변수는 독립된 복사 값을 받는다. 자원 소유권을 옮기지 않는다. |
gives_ownership() -> String반환 값이 |
함수의 지역 바인딩에서 호출자의 s1로 소유권이 넘어가며, s1의 스코프 끝에서 정리한다. |
s3 = takes_and_gives_back(s2)호출 때 |
매개변수가 받은 값을 반환해 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가 최종 정리를 맡습니다.
T를 값으로 전달하는 경우만 다룹니다. 참조를 전달해 소유권을 유지하는 대여 규칙은 다음 절에서 이어집니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 10개 노드와 6개 간선이다. takes_ownership은 s→some_string→함수 끝의 정리; makes_copy는 main x가 계속 유효한 채 some_integer 복사본; gives_ownership은 some_string→main s1; takes_and_gives_back은 s2→a_string→s3 이동이다. 네 사례를 연결하는 실행 간선은 없다. 인수 이동, Copy, 반환 이동, 최종 정리 간선의 의미를 라벨로 구별하며 가로/세로 도표의 계약이 같다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 호출 전 takes_and_gives_back에 s2의 소유 값을 넘긴다. |
|
|
| 함수 안 a_string이 전달된 값을 소유한다. |
|
|
| 반환 뒤 반환값을 s3가 받아 계속 소유한다. |
|
|
상황은 달라도 소유권은 언제나 같은 방식으로 움직입니다. 값을 다른 변수에 대입하거나 함수에 넘기고 돌려받으면 Copy가 아닌 값은 이동하고 Copy인 값은 복사되며, 힙의 데이터를 가진 변수가 스코프를 벗어나면 그 값이 다른 변수로 이동하지 않은 한 drop으로 정리됩니다.
이 방식은 잘 동작하지만, 함수를 쓸 때마다 소유권을 넘겼다가 돌려받는 것은 번거롭습니다. 함수에 넘긴 값을 그 뒤에도 쓰고 싶다면, 함수가 만든 결과와 함께 넘겨받은 값까지 돌려줘야 합니다. 러스트에서는 튜플로 여러 값을 돌려줄 수 있으므로 다음과 같이 할 수는 있습니다.
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()은 String의 길이를 돌려줌
(s, length)
}The length of 'hello' is 5.len()이 돌려주는 길이는 글자 수가 아니라 UTF-8로 저장된 바이트 수이며, hello는 ASCII 다섯 글자라 5바이트입니다. 하지만 흔히 쓰는 일치고는 이 방식이 너무 번거롭고, 넘긴 값을 되돌려받는 코드가 매번 필요합니다. 다행히 러스트에는 소유권을 넘기지 않고 값을 쓰게 하는 참조(reference)가 있습니다.
더 깊이: 이동과 복사는 실제로 무엇을 할까
표준 라이브러리의 Copy 문서는 이동과 복사의 차이가 대입한 뒤 원래 변수를 쓸 수 있느냐뿐이라고 설명합니다. 실행되는 코드로 보면 둘 다 값의 비트를 다른 자리로 복사하는 일이 될 수 있고, 그 복사마저 최적화로 사라지기도 합니다. let s2 = s1;에서 실제로 옮겨지는 것은 포인터, 길이, 용량이라는 세 값이며, 이동이라는 규칙이 더하는 것은 그 뒤에 s1을 쓰지 못하게 하고 정리를 s2에게만 맡기는 일입니다. 또 Copy의 동작은 덮어쓸 수 없고 언제나 단순한 비트 복사이므로, 직접 정의한 코드가 실행될 수 있는 clone과 달리 대입에 숨은 비용이 끼어들 수 없습니다.
그렇다면 조건에 따라 옮겨지는 값은 언제 버려질까요? if의 한 갈래에서만 s1을 함수로 넘긴다면, 스코프가 끝날 때 s1을 버려야 하는지 컴파일할 때 정할 수 없습니다. 러스토노미콘의 드롭 플래그 설명에 따르면 러스트는 이런 경우 변수마다 초기화되어 있는지를 나타내는 드롭 플래그(drop flag)를 스택에 두고, 버려야 할 때 그 플래그를 확인해 drop을 부를지 정합니다. 위에서 아래로 곧게 이어지는 코드처럼 초기화 상태를 어느 지점에서나 컴파일할 때 알 수 있다면, 이런 확인 없이 정해진 자리에서 버릴 수 있습니다.
정리
- 소유권은 컴파일러가 컴파일할 때 검사하는 메모리 관리 규칙이며, 실행 중에 가비지 컬렉터를 쓰지 않습니다.
- 모든 값에는 소유자가 하나 있고, 소유자가 스코프를 벗어나면
drop이 불려 값이 정리됩니다. String은 포인터와 길이, 용량을 스택에 두고 내용을 힙에 두며, 대입하면 스택의 세 값만 옮기고 원래 변수를 무효로 만드는 이동이 일어납니다.- 이동한 변수를 다시 쓰면 E0382 오류가 나며, 이 규칙이 이중 해제를 막습니다.
- 힙의 데이터까지 복제하려면
clone을 부르고,Copy를 구현한 타입은 대입해도 복사되어 원래 변수를 계속 쓸 수 있습니다. - 함수에 값을 넘기거나 돌려받을 때도 대입과 같은 이동과 복사 규칙이 적용됩니다.
연습 문제
1. 복제본과 복사본
다음 프로그램은 무엇을 출력하나요? source.clone()을 source로 바꾸면 어떻게 되나요?
fn main() {
let source = String::from("sun");
let mut copied = source.clone();
copied.push_str("flower");
println!("source={source}, copied={copied}");
let original = 10;
let mut current = original;
current += 15;
println!("original={original}, current={current}");
}해설 보기
source=sun, copied=sunflower와 original=10, current=25를 출력합니다. clone이 힙의 내용까지 복제했으므로 copied에 덧붙여도 source는 그대로이고, 정수는 Copy이므로 current를 바꿔도 original은 그대로입니다. source.clone()을 source로 바꾸면 String이 copied로 이동하므로, 그 뒤의 println!에서 source를 쓰는 곳이 E0382 오류로 컴파일되지 않습니다. 정수 쪽은 그대로 Copy이므로 영향이 없습니다.
2. 함수를 부른 뒤 쓸 수 있는 이름
반환 값과 스코프 예제에서 let s3 = takes_and_gives_back(s2); 바로 다음 줄에 println!("{s1} {s3}");를 넣으면 무엇이 출력되나요? 여기에 s2까지 출력하려 하면 어떻게 되나요?
해설 보기
s1은 gives_ownership이 돌려준 yours를, s3는 s2에서 함수를 거쳐 돌아온 hello를 소유하므로 yours hello가 출력됩니다. s2는 값을 takes_and_gives_back에 넘긴 뒤로 유효하지 않으므로, s2를 출력하려 하면 E0382 오류로 컴파일되지 않습니다. 같은 문자열이 돌아왔더라도 그것을 가진 이름은 s3입니다.
3. 튜플은 복사될까
다음 두 대입 뒤에 원래 변수 a와 c를 쓸 수 있나요? let a = (1, 2.5); let b = a;와 let c = (1, String::from("x")); let d = c;
해설 보기
(i32, f64)는 Copy인 타입만으로 이루어졌으므로 튜플 전체가 Copy이고, b에 복사된 뒤에도 a를 쓸 수 있습니다. (i32, String)은 Copy가 아닌 String을 담고 있어 튜플도 Copy가 아니므로, d로 이동한 뒤 c를 쓰면 E0382 오류가 납니다.
다음 글 참조와 대여에서는 소유권을 넘기지 않고 값을 빌려 쓰는 참조와, 참조가 지켜야 할 규칙을 살펴봅니다.