러스트의 메모리 관리 알아보기
컨텐츠 정보
- 조회 692
본문
러스트 프로그래밍 언어에는 다른 시스템 프로그래밍을 위한 언어와 비슷한 개념이 많다. 예를 들어 러스트는 스택에서 할당된 메모리와 힙에서 할당된 메모리를 구분한다. 또한 스코프 내에 선언된 변수가 해당 스코프 외부에서는 사용되지 않도록 한다.
그러나 러스트는 이러한 동작을 자체적인 방식으로 구현하며, 이는 메모리 관리의 작동 방식에 큰 영향을 미친다. 러스트는 프로그램의 수명 동안 리소스가 생성, 보존, 폐기되는 방법을 설명하기 위해 다양한 소유권 개념을 사용한다. 러스트의 소유권 개념을 다루는 방법은 러스트 프로그래머가 처음에 거쳐야 하는 중요한 단계다. 이 간략한 가이드가 시작하는 데 도움이 될 것이다.
러스트의 스코프 관리 방법
C++를 비롯한 몇몇 언어에는 RAII, 즉 ‘리소스 획득은 초기화’라는 규칙이 있다. 메모리와 같은 객체를 위한 리소스는 프로그램에서 객체가 유지되는 시간과 연계된다는 것이다. 러스트 역시 이 규칙을 사용한다. 즉, 리소스가 두 번 이상 풀리거나 할당이 해제된 다음 사용되는 일이 없도록 한다.
다른 언어와 마찬가지로 러스트에서도 모든 객체에는 함수의 본문, 수동으로 선언된 스코프와 같은 스코프가 있다. 객체의 스코프는 객체가 유효한 것으로 간주되는 기간이다. 그 스코프를 벗어나면 객체는 존재하지 않으므로 참조할 수 없고 객체의 메모리는 자동으로 삭제된다. 주어진 스코프 내에 선언되는 모든 것은 그 스코프가 존재하는 동안만 “유지된다”.
다음 예제에서 data는 other_data가 선언된 안쪽 스코프를 포함해서 main() 전체에 걸쳐 유지되지만 other_data는 더 작은 스코프 내에서만 사용할 수 있다.
fn main() { let mut data = 1; { data = 3; let mut other_data = 2; } other_data=4;}이 코드를 컴파일하면 끝에서 두 번째 줄에서 cannot find value `other_data` in this scope 오류가 발생한다.
스코프를 벗어난 것은 액세스할 수 없을 뿐만 아니라 해당 메모리도 자동으로 해제된다. 또한 컴파일러는 프로그램이 작동하는 내내 객체의 가용성을 추적하므로 무언가가 스코프를 벗어난 이후에 액세스하려고 시도하면 컴파일러 오류가 발생한다.
이 예제는 모든 변수에 크기가 고정된 스택 할당 변수를 사용한다. Box 타입을 사용하면 크기가 변할 수 있고 사용 유연성이 더 높은 힙 할당 변수를 얻을 수 있다.
fn main() { let mut data = Box::new(1); { data = 3; let mut other_data = Box::new(2); } other_data=4;}이 코드 역시 같은 이유로 처음에는 컴파일되지 않지만 다음과 같이 조금만 수정하면 컴파일된다.
fn main() { let mut data = Box::new(1); { data = 3; let mut other_data = Box::new(2); }}이 코드를 실행하면 other_data는 스코프 내에서 힙 할당된 다음 스코프를 벗어날 때 자동으로 할당이 해제된다. data의 경우도 마찬가지로, main() 함수 스코프 내에서 생성되고 main()이 종료되면 자동으로 폐기된다. 컴파일러는 컴파일 타임에 이러한 모든 내용을 볼 수 있으므로 스코프와 관련된 실수가 있으면 컴파일이 되지 않는다.
러스트의 소유권
스코프와 RAII 외에 러스트의 또 다른 핵심 개념은 소유권이다. 객체는 한 번에 하나의 소유자 또는 라이브 참조만 가질 수 있다. 변수 간에 객체 소유권을 옮길 수 있지만 한 번에 두 곳 이상에서 가변적으로 객체를 참조할 수는 없다.
fn main() { let a = Box::new(5); let _b = a; drop(a);}이 예제에서는 힙 할당으로 a에 값을 만들고 a에 _b를 할당한다. 즉, a에서 값을 빼내 옮겼으므로 drop()을 사용해 수동으로 값 할당을 해제하려고 시도하면 use of moved value: `a` 오류가 발생한다. 그러나 마지막 줄을 drop(_b)로 변경하면 아무 문제도 없다. 이 경우 현재의 유효한 소유자를 통해 해당 값을 조작하는 것이기 때문이다.
함수 호출 역시 함수로 전달된 요소의 소유권을 가질 수 있다. 다음은 ‘예제로 배우는 러스트’에 있는 코드를 약간 수정한 것이다.
fn bye(v:Box){ println!("{} is not being returned", v);}fn main() { let a = Box::new(5); let _b = a; bye(_b); drop(_b);}이 코드를 컴파일하려고 하면 drop(_b) 줄에서 use of moved value: `_b` 오류가 발생한다.
bye()를 호출하면 전달된 변수는 bye()의 소유가 된다. 또한 이 함수는 값을 반환하지 않으므로 _b의 수명이 사실상 종료되고 할당도 해제된다. drop()은 호출된다 해도 어차피 아무 효력도 없을 것이다. 반면 다음 코드는 정상 작동한다.
fn bye(v:Box)-> Box{ println!("{} is being returned", v); return v;}fn main() { let a = Box::new(5); let _b = a; let mut c = bye(_b); c = 32; drop(c);}여기서는 bye()의 값을 반환하고, 완전히 새로운 소유자인 c가 이 값을 받는다. 이제 c를 사용해서 수동 할당 해제를 포함해 원하는 작업을 할 수 있다.
소유자를 바꿀 때 할 수 있는 또 다른 일은 가변성 규칙 변경이다. 불변성 객체를 가변성 객체로 만들거나 그 반대로 할 수 있다.
fn bye(v:Box)-> Box{ println!("{} is being returned", v); return v;}fn main() { let a = Box::new(5); let _b = a; let mut c = bye(_b); *c = 32; drop(c);}이 예제에서 a와 _b는 모두 불변성이다. 그러나 c는 가변적이며, a와 _b가 참조하는 것의 소유권을 획득하면 c를 다시 할당할 수 있다(단, Box에 포함된 값을 가리키기 위해 *c로 참조해야 함).
기억해야 할 중요한 점은 러스트가 이러한 모든 규칙을 사전에 적용한다는 것이다. 차용과 스코프의 작동 방식을 준수하지 않는 코드는 컴파일되지 않는다. 이 특성 덕분에 다양한 메모리 버그의 프로덕션 유입이 원천 차단된다. 그러나 이를 위해 프로그래머는 무엇이 어디에서 사용되는지에 세심한 주의를 기울여야 한다.
자동 메모리 관리와 러스트 타입
앞에서 Box 타입을 사용해 메모리를 힙 할당하고 스코프를 벗어나면 자동으로 삭제하는 부분에 대해 언급했는데, 러스트에는 다양한 시나리오에서 메모리를 자동으로 관리하는 데 사용할 수 있는 다른 타입도 있다.
Rc, 즉 “참조 계산(reference counted)” 객체는 객체의 클론 수를 추적한다. 새 클론이 만들어지면 참조 카운트가 1 증가하고 클론이 스코프를 벗어나면 1 감소한다. 참조 카운트가 0에 도달하면 Rc 객체는 삭제된다. Arc 타입(원자 참조 카운트)의 경우 이와 동일한 동작을 스레드 간에도 허용한다.
Rc/Arc와 Box의 핵심적인 차이는 Box는 객체의 독점 소유권을 취해 변경하도록 허용하는 반면 Rc와 Arc의 경우 소유권을 공유하므로 객체는 읽기 전용만 될 수 있다는 점이다.
Rc/Arc 객체는 Box와 동일한 규칙을 따르므로 수명과 차용에 대한 더 큰 범위의 러스트 규칙에 구속된다. 즉, 이러한 검사를 우회하는 데는 사용할 수 없으며, 프로그램의 구조로 인해 특정 데이터에 대해 존재하는 리더의 수를 알기가 어려울 때 사용하는 객체다.
가변 메모리 관리에 사용되는 또 다른 타입은 RefCell이다. 이 타입을 사용하면 하나의 가변성 참조 또는 여러 개의 불변성 참조를 가질 수 있지만, 이러한 사용에 대한 규칙은 컴파일 타임이 아닌 런타임에 적용된다. RefCell에는 두 가지 강력한 규칙이 있다. 단일 스레드 코드에서만 사용할 수 있으며, 런타임에 RefCell의 차용 규칙을 위반할 경우 프로그램이 길을 잃게 된다는 것이다. 따라서 RefCell은 런타임에만 해결 가능한 좁은 범위의 문제에 한해 효과적이다.
러스트 메모리 관리 vs. 가비지 수집
러스트에도 참조 카운팅(자바, C#, 파이썬과 같은 가비지 수집 메모리 관리형 언어에 사용되는 메커니즘)을 허용하는 타입이 있지만 일반적으로 러스트는 “가비지 수집” 또는 “메모리 관리형” 언어로 간주되지 않는다. 러스트의 메모리 관리는 런타임에 처리되는 것이 아니라 컴파일 타임에 결정적으로 계획된다. 참조 카운트 타입도 객체 수명, 스코프, 소유권에 대한 러스트의 규칙을 따라야 하며 이 모든 규칙은 컴파일 타임에 확인돼야 한다.
또한 런타임 메모리 관리를 사용하는 언어는 일반적으로 메모리가 할당되고 회수되는 시점이나 방법을 직접 제어하는 기능을 제공하지 않는다. 높은 수준의 조정 기능을 제공하는 경우는 있지만 러스트(또는 C, C++)에 있는 것과 같은 세부적인 제어 기능은 없다. 이는 러스트의 타협점이다. 러스트에서는 모든 메모리 사용을 처리하기 위해 더 많은 선행 작업이 필요하지만, 그 대신 런타임에 빠른 실행과 더 예측 가능하고 안정적인 메모리 관리라는 보상이 있다.
dl-itworldkorea@foundryco.com
관련자료
-
링크
-
이전
-
다음






