소유권(Ownership)
은 가비지 컬렉터가 없는 러스트에서 메모리 안정성을 보장하는 비결입니다.
이는 러스트에서 가장 특별한 기능이며, 어떻게 동작하는지 반드시 이해해 둬야 합니다.
따라서 이번 장에서는 소유권을 비롯해 소유권과 관련된 빌림(Borrowing)
, 슬라이스(Slice)
기능과 러스트에선 데이터를 메모리에 어떻게 저장하는지 알아보겠습니다.
4.1 소유권이 뭔가요?
- 모든 프로그램은 작동하는 동안 컴퓨터의 메모리 사용 방법을 관리해야 합니다.
- 몇몇 언어는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 끊임없이 찾는 방식을 채용했고, 다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 택했습니다.
- 이때 러스트는 제 3의 방식을 택했습니다. '소유권(ownership)' 이라는 시스템을 만들고, 컴파일러가 컴파일 중 검사할 여러 규칙을 정해 메모리를 관리하는 방식이죠.
- 이 방식은 프로그램 실행 속도에 악영향을 줄 일이 없습니다. 컴파일 타임에 전부 해결하니까요.
스택, 힙 영역
러스트 같은 시스템 프로그래밍 언어에서는 값을 스택에 저장하느냐 힙에 저장하느냐의 차이가 프로그램의 동작 및 프로그래머의 의사 결정에 훨씬 큰 영향을 미칩니다.
스택, 힙 둘 다 여러분이 작성한 프로그램이 런타임 중 이용할 메모리 영역이라는 공통점이 있지만 구조는 각각 다릅니다.
- 스택은 값이 들어온 순서대로 저장하고, 역순으로 제거합니다. 이를 last in, fist out 이라 하죠
- 스택에 저장되는 데이터는 모두 명확하고 크기가 정해져 있어야 합니다.
- 컴파일 타임에 크기를 알 수 없거나, 크기가 변경될 수 있는 데이터는 스택 대신 힙에 저장됩니다.
- 힙은 스택보다 복잡합니다.
- 데이터를 힙에 넣을때 먼저 저장할 공간이 있는지 운영체제한테 물어봅니다. 그럼 메모리 할당자는 커다란 힙 영역 안에서 어떤 빈 지점을 찾고, 이 지점은 사용 중이라고 표시한 뒤 해당 지점을 가리키는 포인터(pointer) 를 우리한테 반환합니다. 이 과정을 힙 공간 할당(allocating on the heap), 줄여서 할당(allocation) 이라 합니다 (스택에 값을 푸시하는 것은 할당이라 부르지 않습니다). 포인터는 크기가 정해져 있어 스택에 저장할 수 있으나, 포인터가 가리키는 실제 데이터를 사용하고자 할 때는 포인터를 참조해 해당 포인터가 가리키는 위치로 이동하는 과정을 거쳐야 합니다.
- 스택 영역은 데이터에 접근하는 방식상 힙 영역보다 속도가 빠릅니다.
- 메모리 할당자가 새로운 데이터를 저장할 공간을 찾을 필요가 없이 항상 스택의 가장 위에 데이터를 저장하면 되기 때문이죠.
- 반면에 힙에 공간을 할당하는 작업은 좀 더 많은 작업을 요구하는데, 메모리 할당자가 데이터를 저장하기에 충분한 공간을 먼저 찾고 다음 할당을 위한 준비를 위해 예약을 수행해야 하기 때문입니다.
- 힙 영역은 포인터가 가리키는 곳을 찾아가는 과정으로 인해 느려집니다.
- 현대 프로세서는 메모리 내부를 이리저리 왔다 갔다 하는 작업이 적을수록 속도가 빨라지는데, 힙에 있는 데이터들은 서로 멀리 떨어져 있어 프로세서가 계속해서 돌아다녀야 하기 때문이죠.
- 힙 영역처럼 데이터가 서로 멀리 떨어져 있으면 작업이 느려지고, 반대로 스택 영역처럼 데이터가 서로 붙어 있으면 작업이 빨라집니다. 이외에도, 큰 공간을 할당하는 작업도 힙 영역의 속도를 늦추는 요인입니다.
- 여러분이 함수를 호출하면, 호출한 함수에 넘겨준 값(값 중엔 힙 영역의 데이터를 가리키는 포인터도 있을 수 있습니다)과 해당 함수의 지역 변수들이 스택에 푸시됩니다.
- 그리고 이 데이터들은 함수가 종료될 때 pop 됩니다.
- 코드 어느 부분에서 힙의 어떤 데이터를 사용하는지 추적하고, 힙에서 중복되는 데이터를 최소화하고, 쓰지 않는 데이터를 힙에서 정리해 영역을 확보하는 등의 작업은 모두 소유권과 관련되어 있습니다.
- 반대로 말하면 여러분이 소유권을 한번 이해하고 나면 스택, 힙 영역으로 고민할 일이 줄어들 거란 뜻이지만, 소유권의 존재 이유가 힙 데이터의 관리라는 점을 알고 있으면 소유권의 동작 방식을 이해하는데에 도움이 됩니다.
소유권 규칙
소유권 규칙부터 알아보겠습니다.
- 러스트에서, 각각의 값은 소유자(owner) 라는 변수가 정해져 있다.
- 한 값의 소유자는 동시에 여럿 존재할 수 없다.
- 소유자가 스코프 밖으로 벗어날 때, 값은 버려진다(dropped).
변수의 스코프(Scope)
스코프란, 프로그램 내에서 개체가 유효한 범위를 말합니다.
let s = "hello";
변수 s
는 문자열 리터럴을 나타내며, 문자열 리터럴의 값은 코드 내에 하드코딩되어 있습니다.
이 변수는 선언된 시점부터 현재의 스코프를 벗어날 때까지 유효합니다.
중요한 점은 두 가지입니다.
s
가 스코프 내에 나타나면 유효합니다.- 유효기간은 스코프 밖으로 벗어나기 전까지 입니다.
String
타입
소유권 규칙을 설명하려면 3장 "데이터 타입들" 에서 다룬 타입보다 복잡한 타입이 필요합니다.
앞서 다룬 것들은 전부 스택에 저장되고 스코프를 벗어날 때 pop 되는 타입이지만, 이번에 필요한 건 힙에 저장되면서, 러스트의 데이터 정리과정을 알아보는 데 적합한 타입이거든요.
따라서 String
타입을 예제로 활용하되, 여기서 String
타입을 전부 설명할 순 없으므로 자세한 내용은 8장에서 다루고, 이번 장에선 소유권 관련 부분에만 집중하겠습니다.
이러한 관점은 다른 표준 라이브러리나 여러분이 만들 복잡한 데이터 타입에도 적용됩니다.
- 여태 보아온 문자열은 코드 내에 하드코딩하는 방식의 '문자열 리터럴(string literal)'이었습니다.
- 문자열 리터럴은 쓰기 편리하지만, 만능은 아닙니다.
- 그 이유는 문자열 리터럴이 불변성(immutable)을 지니기에 변경할 수 없다는 점과, 프로그램에 필요한 모든 문자열을 우리가 프로그래밍하는 시점에 알 수는 없다는 점 때문입니다.
- 사용자한테 문자열을 입력받아 저장하는 기능 등을 만들어야 하는 상황에선 문자열 리터럴을 사용할 수 없죠.
- 따라서 러스트는 또 다른 문자열 타입인
String
을 제공합니다. - 이 타입은 힙에 할당되기 때문에, 컴파일 타임에 크기를 알 수 없는 텍스트도 저장할 수 있습니다.
String
타입은 다음과 같이from
함수와 문자열 리터럴을 이용해 생성 가능합니다.
let s = String::from("hello");
이중 콜론(::
)은 우리가 함수를 사용할 때, string_from
같은 함수명 대신 String
타입 하위라는 것을 특정해서 함수를 호출할 수 있도록 하려고 사용하는 네임스페이스 연산자
입니다.
메소드 관련 문법은 5장 “메소드 문법” 에서 자세히 다루며, 모듈 및 네임스페이스는 7장 “경로를 사용해 모듈 트리에서 항목 가리키기” 에서 다루고 있습니다.
이 String
문자열은 변경 가능합니다:
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
호출 시, 필요한 만큼 메모리를 요청하도록 구현되어 있거든요. 프로그래밍 언어 사이에서 일반적으로 사용하는 방식이죠.
하지만 두 번째는 다릅니다.
- 가비지 콜렉터 (garbage collector, GC) 를 갖는 언어에선 GC가 사용하지 않는 메모리를 찾아 없애주므로 프로그래머가 신경 쓸 필요 없으나,
- GC가 없는 언어에선 할당받은 메모리가 필요 없어지는 지점을 프로그래머가 직접 찾아 메모리 해제 코드를 작성해야 합니다. 굉장히 어려운 일이죠.
- 프로그래머가 놓친 부분이 있다면 메모리 낭비가 발생하고, 메모리 해제 시점을 잘못 잡으면 버그가 생깁니다.
- 두 번 해제할 경우도 마찬가지로 버그가 발생하겠죠.
- 따라서 우린
할당(allocate)
과해제(free)
가 하나씩 짝짓도록 만들어야 합니다.
이 문제를 러스트에선 변수가 자신이 소속된 스코 프를 벗어나는 순간 자동으로 메모리를 해제하는 방식으로 해결했습니다.
예시로 보여드리도록 하죠. Listing 4-1 에서 문자열 리터럴을 String
으로 바꿔보았습니다:
{
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++ 에선 이런 식으로 객체의 수명이 끝나는 시점에 리소스를 해제하는 패턴을 Resource Acquisition Is Initialization (RAII) 라 합니다. RAII 패턴에 익숙하신 분들이라면 러스트의
drop
함수가 친숙할지도 모르겠네요.
- 이 패턴은 러스트 코드를 작성하는 데 깊은 영향을 미칩니다.
- 지금은 단순해 보이지만, 힙 영역을 사용하는 변수가 많아져 상황이 복잡해지면 코드가 예기치 못한 방향으로 동작할 수도 있죠.
변수와 데이터 간 상호작용 방식: 이동(move)
러스트에선 동일한 데이터에 여러 변수가 서로 다른 방식으로 상호작용할 수 있습니다.
정수형을 이용한 예제로 살펴보겠습니다.
let x = 5;
let y = x;
x
의 정숫값을y
에 대입5
를x
에 바인드(bind)하고,x
값의 복사본을 만들어y
에 바인드- 그럼
x
,y
두 변수가 생길 겁니다. 각각의 값은5
가 되겠죠. - 실제로도 이와 같은데, 정수형 값은 크기가 정해진 단순한 값이기 때문입니다.
- 이는 다시 말해, 두
5
값은 스택에 push 된다는 뜻입니다.
이번엔 앞선 예제를 String
으로 바꿔보았습니다:
let s1 = String::from("hello"); let s2 = s1;
이전 코드와 매우 비슷하니, 동작 방식도 같을 거라고 생각하실 수도 있습니다.
두 번째 줄에서 s1
의 복사본을 생성해 s2
에 바인딩하는 식으로 말이죠. 하지만 이번엔 전혀 다른 방식으로 동작합니다.
String
은 그림 좌측에서 나타나듯, 문자열 내용이 들어 있는 메모리를 가리키는 포인터, 문자열 길이, 메모리 용량 세 부분으로 이루어져 있습니다.
이 데이터는 스택에 저장되며, 우측의 문자열 내용은 힙에 저장됩니다.
s1
에 바인드된, "hello"
값을 저장하고 있는 String
의 메모리 속 모습
문자열 길이와 메모리 용량이 무슨 차이인가 궁금하실 분들을 위해 간단히 설명해드리자면,
-
문자열 길이는
String
의 내용이 현재 사용하고 있는 메모리를 바이트 단위로 나타낸 것이고, -
메모리 용량은 메모리 할당자가
String
에 할당한 메모리의 양을 뜻합니다. -
이번 내용에서는 길이, 용량 사이의 차이는 중요한 내용이 아니니, 이해가 잘 안 되면 용량 값은 무시하셔도 좋습니다.
-
s2
에s
을 대입하면String
데이터가 복사됩니다. -
이때 데이터는 스택에 있는 데이터, 즉 포인터, 길이, 용량 값을 말하며, 포인터가 가리키는 힙 영역의 데이터는 복사되지 않습니다. 즉, 다음과 같은 메모리 구조를 갖게 됩니다.
Figure 4-2: 변수 s2
가 s1
의 포인터, 길이, 용량 값을 복사했을 때 나타나는 메모리 구조
다음 Figure 4-3 은 힙 메모리 상 데이터까지 복사했을 경우 나타날 구조로, 실제로는 이와 다릅니다. 만약 러스트가 이런 식으로 동작한다면, 힙 내 데이터가 커질수록 s2 = s1
연산은 굉장히 느려질겁니다.
Figure 4-3: 러스트에서 힙 데이터까지 복사할 경우의 s2 = s1
연산 결과
앞서 언급한 내용 중 변수가 스코프 밖으로 벗어날 때 러스트에서 자동으로 drop
함수를 호출하여 해당 변수가 사용하는 힙 메모리를 제거한다는 내용이 있었습니다.
하지만 Figure 4-2 처럼 두 포인터가 같은 곳을 가리킬 경우에는 어떻게 될까요?
s2
, s1
이 스코프 밖으로 벗어날 때 각각 메모리를 해제하게 되면 중복 해제(double free) 오류가 발생할 겁니다.
이는 메모리 안정성 버그 중 하나이며, 보안을 취약하게 만드는 메모리 손상의 원인입니다.
따라서, 러스트에는 여러 포인터가 한 곳을 가리킬 경우를 대비한 규칙이 존재합니다.
s1
에 할당한 메모리를 새로 복사하는 대신, 기존의s1
을 무효화하는 것이죠.- 이로써 러스트는
s1
이 스코프를 벗어나더라도 아무것도 해제할 필요가 없어집니다. - 그럼
s2
가 만들어진 이후에s1
을 사용하면 어떻게 될까요? - 결론부터 말씀드리면, 사용할 수 없습니다:
여러분이 다른 프로그래밍 언어에서 “얕은 복사(shallow copy)”, “깊은 복사(deep copy)” 라는 용어를 들어보셨다면, 힙 데이터를 복사하지 않고 포인터, 길이, 용량 값만 복사하는 것을 얕은 복사라고 생각하셨을 수도 있지만,
러스트에선 기존의 변수를 무효화하기 때문에 이를 얕은 복사가 아닌 이동(move) 이라 하고, 앞선 코드는 s1
이 s2
로 이동되었다 라고 표현합니다.
Figure 4-4: s1
이 무효화 된 후의 메모리 구조
이로써 문제가 사라졌네요! s2
만이 유효하니, 스코프 밖으로 벗어나면 그대로 자신의 메모리를 해제하면 됩니다.
덧붙이자면, 러스트는 절대 자동으로 “깊은 복사” 로 데이터를 복사하는 일이 없습니다. 따라서, 러스트가 자동으로 수행하는 모든 복사는 런타임 성능면에서 효율적이라 할 수 있습니다.
변수와 데이터 간 상호작용 방식: 클론(clone)
String
의 힙 데이터까지 깊이 복사하고 싶을 땐 clone
이라는 공용 메소드를 사용할 수 있 습니다.
다음은 clone
메소드의 사용 예제입니다:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);`
이 코드의 실행 결과는 힙 데이터까지 복사됐을 때의 메모리 구조를 나타낸 Figure 4-3 과 정확히 일치합니다.
여러분은 이 코드에서 clone
호출을 보고, 이 지점에서 성능에 영향이 갈지도 모르는 코드가 실행될 것을 알 수 있습니다. 즉, clone
은 해당 위치에서 무언가 다른 일이 수행될 것을 알려주는 시각적 지시자이기도 합니다.