[Rust] 소유권 이해하기
![[Rust] 소유권 이해하기](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1740840330522%2Fe8d9492c-8865-40bd-9391-386be0efb3e6.png&w=3840&q=75)
스택 & 힙
소유권의 주 목표는 힙 데이터의 관리이다.
스택과 힙은 모두 프로그램이 런타임에 이용하게 될 메모리 영역이지만, 차이점이 있다.
스택
스택은 후입선출(last in first out)방식으로 값을 처리한다. 입출구가 하나인 통처럼 맨 꼭대기에서 무언가를 집어넣고 꺼내는 것은 가능하지만 중간과 아래에 있는 것은 바로 건드릴 수 없다. 스택에 저장될 데이터는 모두 명확하고 크기가 정해져 있어야 한다. 컴파일 타임에 크기를 알 수 없거나 크기가 변할 수 있는 데이터는 힙에 저장되어야 한다.
함수를 호출하면 호출한 함수에 넘겨준 값과 해당 함수의 로컬 변수들이 스택에 들어가고, 이 데이터들은 함수가 종료될 때 팝된다.
힙
데이터에 힙을 넣을 때 절차는 다음과 같다.
저장할 공간이 있는지 운영체제에 물어보고
메모리 할당자가 힙 영역 내에서 빈 지점을 찾는다.
아까 발견한 빈 지점을 사용 중이라고 표시하고
그 지점을 가리키는 포인터를 반환한다.
이 전체 과정을 힙 공간 할당이라고 한다.
레스토랑에 비유하자면
레스토랑에 방문하는 인원수를 수용할 좌석이 있는지 묻는다.
직원은 빈 테이블을 찾아서
테이블이 사용 중임으로 상태를 바꾸고(비유가 이상하긴 한데 다른 손님이 들어왔을 때 여전히 빈 테이블이라고 하지는 않을테니까...)
손님에게 테이블 위치를 안내할 것이다.
라고 할 수 있다.
힙 영역은 포인터가 가리키는 곳을 찾아가는 과정 때문에 느려진다. 힙에 있는 데이터들은 붙어 있는 것이 아니라 이곳 저곳에 떨어져 있는데 이렇게 되면 프로세서가 돌아다니면서 데이터들을 처리해야하기 때문에 느리다.
소유권 규칙
소유권 규칙은 세 가지가 있다.
각각의 값은 소유자(Owner)가 정해져 있다.
한 값의 소유자는 동시에 여럿 존재할 수 없다.
소유자가 스코프 밖으로 벗어나면 값은 버려진다(dropped).
변수의 스코프
스코프란 프로그램 내에서 아이템이 유효한 범위를 말한다. 예제로 보자면
{ // s가 선언되기 전이다. 아직 유효하지 않다
let s = "Hello"; // s가 선언되어 여기서부터 유효하다
// s로 어떠한 작업을 한다.
} // 스코프가 종료되어 s가 유효하지 않다.
위 에제에서
s가 스코프 내에 나타나면 유효하고
유효기간은 스코프 밖으로 벗어나기 전이다(벗어나면 유효하지 않다)
String
바로 위의 문자열 리터럴은 불변(immutable)하기 때문에 사용자에게 문자열을 입력받아 사용하거나 하는 것은 불가능하다. 그렇기 때문에 러스트는 또 다른 문자열 타입인 String 타입을 제공하는데 소유권 규칙을 이해하기 좋은 타입이다. String 타입은
let s = String::from("Hello");
와 같이 생성한다. ::은 String 타입에 있는 특정한 from함수라는 것을 지정할 수 있게 해주는 네임스페이스 연산자다.
대충 예상이 가겠지만 String은 가변(mutable)하다.
let s = String::from("Hello");
s.push_str(", world!"); //문자열에 리터럴 추가
를 하게되면 s는 Hello, world!가 된다.
그런데 문자열 리터럴과 String이 따로 존재할 이유는 무엇이며, 왜 하나는 불변이고 하나는 가변인가?
메모리와 할당
문자열 리터럴은 컴파일 타임에 내용을 알 수 있고 크기가 고정적이다. 아까 스택과 힙의 설명에 기반하자면 스택에 더 적합해보이는 친구이다.
String은 가변적인 값으로 크기가 변할 수 있다. 힙에 메모리를 할당해서 관리해야 하는 친구다.
어째거나 힙에 메모리를 할당해야 하는 String은
실행 중 메모리 할당자로부터 메모리를 요청하고
사용을 마치면 메모리를 해제해야 한다.
타 언어들과 비교하자만 가비지 컬렉터가 있으면 사용하지 않는 메모리를 자동으로 해제해주므로 신경쓸 필요가 없고, C같은 경우 free()로 해제해줘야 했다.
러스트에서는 이러한 메모리 해제가 변수가 자신이 소속된 스코프를 벗어나는 순간 메모리가 자동으로 해제되는 방식으로 진행된다.
{
let s = String::from("hello");
} // 스코프 종료
위 코드에서 s가 스코프를 벗어날 때(닫힌 중괄호 } 가 나타나는 지점) 러스트는 drop이라는 함수를 자동으로 호출한다.
아직은 별 게 아닌 것 같지만 이러한 패턴 때문에 복잡한 상황이 생기기도 한다.
이동(Move)
let s1 = String::from("hello");
let s2 = s1;
코드의 첫 줄에서는

와 같은 일이 일어난다.
그런데 s2에 s1를 대입할 때 스택에 있는 데이터만 복사되지 포인터가 가리키는 힙 영역의 데이터는 복사되지 않는다.

그래서 위와 같은 메모리 구조를 가지게 된다.
앞에서 변수가 스코프 밖으로 벗어날 때 러스트에서 drop을 호출하여 변수가 사용하는 힙 메모리를 제거한다고 했는데, 포인터 두 개가 같은 곳을 가리키면 어떻게 될까?
s1, s2가 스코프 밖으로 벗어날 때 각각 메모리를 해제하게 되면 중복 해제(double free) 에러가 발생하게 될 것이다.
이 문제를 해결하기 위해 러스트는 let s2 = s1; 라인 뒤로 s1이 유효하지 않다고 판단한다. 그래서
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
는 유효하지 않은 참조자 사용을 감지했다며 컴파일 에러를 일으킬 것이다.
다른 언어의 얕은 복사(shallow copy), 깊은 복사(deep copy) 개념을 떠올려본다면 아까처럼 힙 데이터를 복사하지 않는

이러한 상황을 얕은 복사
힙 메모리까지 복사되는

이러한 상황을 깊은 복사라고 할텐데
러스트에서는 얕은 복사 개념이 존재하지 않는다. 두 포인터가 같은 곳을 가리키는 게 아니라 이제 s1은 유효하지 않아질 것이기 때문이다. s1에서 s2로 이동한 것과 같은 상황이 되는데 진짜로 이동(move)라고 한다.
더해서 러스트는 깊은 복사를 자동으로 수행하는 일이 없다. 힙 메모리까지 복사하는 것은 비효율적인 작업이 될 수 있는데(특히 데이터가 크다면) 러스트가 자동으로 수행하는 복사는 이러한 경우가 없다. 물론 수동으로는 가능하다.
클론(Clone)
이거다. 힙 데이터까지 복사(깊은 복사)하고 싶으면 clone을 쓰면 된다.
let s1 = String::from("Hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
깊은 복사이기 때문에 애초에 포인터는 다른 곳을 가리킨다.(데이터 내용이 같은거지 위치는 다르다) 그래서 s1은 유효한 것.
복사(Copy)
그런데
let x = 1;
let y = x;
println("x = {}, y = {}", x, y);
를 실행해보면 x가 여전히 유효한데 애초에 이런 컴파일 타임에 크기를 알 수 있는 고정 크기 값들은 스택에 저장되기 때문이다.
소유권 개념은 힙 메모리 관리에 적용되는 개념이므로 여기에는 적용되지 않는다.
이런 복사가 가능한 타입은
일반적인 스칼라 타입
복사 가능한 타입으로 구성된 튜플 이 가능하다.
소유권과 함수
함수로 값을 전달하는 것도 비슷한 매커니즘으로 작동한다. 함수에 변수를 전달하면 대입과 마찬가지로 이동이나 복사가 일어나기 때문.
fn main() {
let s = String::from("Hello"); //여기서부터 s가 스코프 안으로
takes_ownership(s): //s의 값이 함수로 이동
// 여기서부턴 s가 유효하지 않다.
// s를 사용하려고 하면 컴파일 에러가 나타날 것.
let x = 5; // x가 스코프 안으로
makes_copy(x); // x가 함수로 Copy
}
fn takes_ownership(some_string: String) { // some_string이 스코프 안으로
println!("{}", some_string);
} // some_string이 스코프 밖으로 벗어남.
// 메모리 해제
fn makes_copy(some_integer: i32) { // some_integer가 스코프 안으로
println!("{}", some_integer);
} // 닫는 중괄호가 나타났지만 어차피 스택 써서 아무런 일이 발생하지 않았다...
원리는 다르지 않다. Copy의 경우 신경 쓸 게 없지만 문자열을 함수에 집어넣어 호출한 이후 다시 s를 사용하려고 하면 컴파일 에러가 나타난다.
반환 값과 스코프
값을 넣는 과정뿐 아니라 반환하는 과정에서도 소유권이 이동한다.
fn main() {
let s1 = gives_ownership(); // gives_ownership이 반환 값을 s1으로
let s2 = String::from("Hello"); // s2가 스코프 안으로
let s3 = takes_and_gives_back(s2); // s2가 함수로 값을 이동
// 함수도 값을 s3으로 이동
} // s3이 스코프 밖으로 벗어나면서 drop
// s1도 스코프 밖으로 벗어나고 버려진다
fn gives_ownership() -> String {
// gives_ownership은 자신의 반환 값을 자신의 호출자 함수로
// 이동시키게 된다
let some_string = String::from("yours"); //some_string이 스코프 안으로
some_string // some_string이 반환되고 호출자 함수로 이동
}
fn takes_and_gives_back(a_string: String) -> String {
// a_string이 스코프 안으로
a_string // a_string이 반환되고 호출자 함수로 이동
}
상황이 달라도 소유권 규칙은 동일하다.
그런데 함수에 값을 넘겨줬어도 그 값을 여전히 쓰고 싶을 수 있다. 그래서 값을 사용할 수 있게 하되 소유권은 가져가지 말라고 하고 싶다면 어떻게 하는가?
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)라는 기능을 가지고 있다.

