소유권(Ownership)

러스트의 핵심 기능 소유권

소유권이란?

Rust의 메모리 관리 방법이다.

Java, C#, Golang, Python, Javascript 등의 언어는 쓰레기 수집(Garbage Collection, GC)을 사용하여 자동으로 메모리를 관리해주지만, Rust와 같은 언어는 GC를 사용하지 않고 개발자가 직접 메모리를 관리해야한다.

대표적으로 C++의 경우 메모리 할당(Allocate)과 해제(Free)를 명시한지만, Rust는 새로운 접근법으로 메모리를 관리한다.

메모리 스택(Stack)과 힙(Heap)

데이터가 메모리 어디에 있는지가 소유권에 존재 이유이기 때문에 스택과 힙을 이해하는 것이 소유권을 이해하는데 큰 도움이 된다.

Rust, C++과 같은 언어는 값이 메모리안에서 어디에 할당되어 있는지에 따라 개발에 큰 영향을 준다. 스택과 힙은 런타임에서 사용할 수 있는 메모리의 영역이지만 각기 다른 방식으로 구조화 되어 있다.

Untitled

스택은 선형구조로 받아들이는 순서대로 값을 다루기 때문에 빠르다.

  • 모든 데이터는 꼭대기(Top)에서 처리되기 때문에 새로운 데이터를 저장 또는 가져올 공간을 검색할 필요가 없다.
  • 스택에 있는 데이터들은 고정된 크기를 갖고 있어야 한다.

컴파일 타임에서 크기가 결정된 데이터는 스택에 저장되고, 그렇지 않은 데이터는 에 저장된다. 힙에 저장되는 순서는 다음과 같으며 이를 힙 할당(Allocating)이라고 부른다.

  1. 힙에 공간을 검색한다.
  2. 빈 어떤 지점을 찾아서 사용 중으로 표시한다.
  3. 해당 지점에 포인터를 돌려준다.

스택에 포인터를 저장하는 것은 할당이라 부르지 않는다. 포인터는 고정된 길이의 값이므로, 실제 데이터를 사용하고자 할 때는 포인터를 따라간다.

기본적으로 힙에 저장된 데이터를 접근하는 것은 느린데, 그 이유는 현대 프로세서들은 메모리 내부를 덜 뛰어다닐 때 빨라진다. 결론적으로 멀리 떨어져 있는 데이터들 보다는 붙어있는 데이터들에 대한 작업을 하면 빨라진다.(힙으로부터 큰 공간을 할당하는 것 또한 시간이 걸릴 수 있다.)

소유권 규칙

  1. 값은 소유자(Owner)라고 불리는 변수를 갖는다.
  2. 각각의 값은 하나에 소유자만 존재한다.
  3. 오너가 스코프 밖으로 벗어날 때, 값은 버려진다(Dropped).

변수의 스코프

다른 언어와 비슷하게 스코프를 벗어나면 유효하지 않는다.

{
	let s = "hello"; // 스트링 리터럴을 갖는 s 변수 선언
}
// 스코프 끝, s는 유효하지 않음

메모리 할당

위 변수 s 는 고정된 길이의 스트링 리터럴을 갖기 때문에 스택에 값이 저장된다.

힙에 값이 할당되는 경우는 고정된 길이를 갖지 않는 데이터 즉, 런타임에서 길이가 결정되는 데이터이다. 이 데이터들이 Rust에서 어떻게 할당되고 해제되는지 알아보겠다.

String 타입은 변경 가능하고 커질 수 있는 텍스트를 지원하기 위해 만들어졌다. 이는 힙에 메모리 공간을 할당 받아 내용물을 저장할 필요가 있다.

  1. 런타임에 메모리가 요청되어야 한다.
  2. String 의 사용이 끝났을 때 메모리 반납할 방법이 필요하다.

첫 번째는 String::from 을 호출하여 필요한 만큼 메모리를 요청한다. 다른 언어들 사이에서 매우 일반적이다.

하지만, 두번 째는 다르다. GC를 갖는 언어는 GC가 계속하여 메모리 조각을 찾고 지워주기 때문에 개발자가 이를 생각하지 않아도된다. GC가 없을 경우, 할당받은 메모리를 더 이상 필요로하지 않는 시점에 명시적으로 반납하는 코드를 호출해야 한다. 이는 역사적으로 매우 어려운 문제로 취급 받는다.

  • 적재적소에 명시하지 않을 경우 → 메모리 낭비
  • 명시하지 않을 경우 → 메모리 누수
  • 실수로 두 번 반납할 경우 → 버그

딱 한번의 할당(Allocate)과 해제(Free)를 해야한다.

러스트는 다른 방식으로 이 문제를 다룬다. 스코프 밖으로 벗어나는 순간 자동으로 반납된다.

{
	let s = String::from("hello"); // s 변수 선언과 함계 힙에 할당
}
// s는 더이상 유효하지 않고, 메모리가 반납된다.

s 변수는 스코프 밖을 벗어날 때 특별한 함수를 호출한다. 이 함수를 drop 이라고 부른다.

변수와 데이터의 상호작용, 이동(Move)

let x = 5;
let y = x; // x의 값이 y로 복사(Copy)

let s1 = String::from("hello");
let s2 = s1; // s1 -> s2, 이동(move)

println!("{} world!", s1); // 에러 발생

위의 상황처럼 기본적으로 고정된 길이의 스칼라 타입은 값이 복사된다.(x 변수의 값이 y 변수로 복사됨)

아래는 s1 변수의 메모리 구조이다.(스택에서의 s1과 힙 메모리)

name value
ptr
len 5
capacity 5
index value
0 h
1 e
2 l
3 l
4 o

하지만, s1 변수의 경우는 다르다.

  1. s1 변수 선언과 메모리 할당
  2. s2s1을 대입하여 이동(move)
  3. 이동된 s1 무효화

힙 메모리는 복사되지 않는다. 복사된다면 프로그램이 매우 느려질 가능성이 있다. 만약 포인터만 복사된다면? 두 번 drop 함수를 호출하여 버그(Double Free)가 발생하여 보안 취약성 문제를 일으킬 가능성이 있다.

스택 데이터 복사

데이터 복사에 대해 좀 더 설명하자면 다음과 같다.

  • i32 와 같은 모든 정수 타입
  • bool 타입
  • f64 와 같은 부동소수점 타입
  • Copy 가 가능한 타입만으로 구선된 튜플 → (i32, u32)Copy 가능, (i32, String)은 안됨

소유권과 함수

함수에 값을 넘기는 것은 대입과 마찬가지로 이동하거나 복사된다.

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

	take_ownership(s); // s변수가 함수로 이동됨

	let x = 5;

	makes_copy(x); // x변수가 makes_copy 함수에 복사됨
} // s변수는 무효화되어 아무일도 일어나지 않음

fn take_ownership(s: String) { // s변수가 스코프 안으로 들어옴
	println!("{}", s);
} // s변수가 스코프 밖으로 벗어나 drop

fn makes_copy(i: i32) {
	println!("{}", i);
}

반환 값과 스코프

값의 반환 또한 소유권을 이동시킨다.

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

	let s2 = takes_and_gives_back(s1); // s1 변수가 함수로 이동됨
} // 스코프 밖으로 벗어남, s1은 이동되어 아무일도 일어나지 않고, s2는 drop 호출

fn takes_and_gives_back(s: String) -> String {
	s // s는 반환되고, 호출한 쪽의 함수로 이동
}