[Rust] 러스트의 꽃, Ownership 파헤치기

나를 가지세요 🙈

Kwoncheol Shin
9 min readMar 17, 2019
Photo by Mike Streicher on Flickr

모든 프로그래밍 언어는 저마다의 방법으로 메모리를 관리한다. 메모리관리 전략은 크게 두 가지로 나뉜다.

  1. Garbage colletion를 이용해 자동으로 안쓰는 메모리를 해제 (e.g. Java, Python, C#, Javascript, Go 등 대부분의 언어)
  2. 메모리 할당,해제를 프로그래머가 직접 명시 (e.g. C, C++, Object-C 등)

Rust는 위 둘 중 어느것도 아닌 ownership 시스템이란 것을 통해 메모리 관리를 한다. 이는 컴파일러가 몇 가지 규칙들을 기준으로 컴파일타임에 실시한다.

오너십은 많은 개발자들에게 생소한 개념일 것이다. 비록 익숙해지기 위해 시간과 노력이 필요하겠지만 익숙해진 후에는 매우 효율적이며 안전한 개발을 할 수 있을 것이다.

오너십을 이해한다는 것은 왜 Rust가 특별한 언어인가를 이해하는 것이기도 하며,

Rust를 100% 활용하기 위해서 꼭 지나쳐야 하는 관문이기도 하다.

오너십의 개념을 제대로 이해하기 이위해서는 Heap과 Stack에 대한 이해가 있어야 한다. 이 개념은 원문을 참고하거나 이 글에서 확인할 수 있다.

오너십 규칙

아래는 오너십의 세 가지 규칙이다. 이 세 가지 규칙을 머릿속에 넣어둔 체로 앞으로 나올 내용들을 이해하길 바란다.

  • Rust의 모든 값(value)은 owner 라 불리는 변수들을 갖고있다.
  • 하나의 값은 하나의 owner 만 가질 수 있다.
  • owner 가 scope 밖으로 나가게 되면 그 값도 사라진다.

변수의 범위(scope)

일반적인 프로그래밍 언어에서 변수의 범위는 scope으로 표현된다. 변수의 유효성은 중괄호로 이해할 수 있다.

{                       // s는 아직 유효하지 않다. 호출시 Error
let s = "hello"; // 여기서부터 s가 유효하다.
// 여기서도 s는 유효하다.
} // 이제 s는 유효하지 않다. 호출시 Error

이제 이 개념을 이용해 Rust의 특별한 String 타입에 대해 이야기해 볼 것이다.

String 타입

오너십에 대해 이해하기 위해서는 지난 글에서 살펴보았던 타입들보다는 좀 더 복잡한 데이터타입이 필요하다. 이를 위해 8장에서 자세히 살펴볼 String 타입을 미리 이야기 해보려 한다.

String 타입은 메모리에 할당되는 방식이 일반 데이터타입과는 조금 다르다. 일반 데이터타입은 변수의 크기가 정해져있기 때문에 정해진 크기의 메모리를 Stack으로부터 할당받지만 String 타입은 컴파일타임에도 해당 변수에 어떤 크기의 값이 들어갈지 모르는 경우도 있다.(e.g. 유저로부터 입력을 받거나 프로그램 진행상황에 따라 다른 값이 들어오게 되거나)

이러한 이유로 String 타입은 Heap 영역으로부터 메모리를 할당받는다. 이를 위해서는

  • 런타임에 메모리를 os에게 요청한다.
  • 해당String 변수로 할 일이 끝났다면 할당받은 메모리를 돌려준다.

위 두 가지 작업이 이루어져야 한다.

첫 번째 작업은 String::new 혹은 String::from 같은 해당 타입의 메서드를 호출함으로 해결된다. 하지만 두 번째 작업은 조금 다르다.

Garbage collector(GC)가 있는 언어들은 GC가 두 번째 작업을 알아서 처리해준다. 하지만 이는 오버헤드가 따르기 때문에 C나 C++같은 퍼포먼스를 중요시하는 언어에서는 프로그래머가 직접 메모리를 해제하는 방법이 쓰인다.

Rust에서는 그 메모리공간을 소유한 변수가 scope을 나가게 될 때 자동으로 메모리가 해제된다. 위에서 봤던 예제를 다시 살펴보자

{                       
let s = "hello"; // 여기서부터 s가 유효하다.
// 여기서도 s는 유효하다.
} // s는 메모리를 반환한다.

변수가 scope에서 나가게 되면 Rust는 drop 이라는 특별한 함수를 자동으로 호출한다. ( 중괄호가 닫힐 때 drop 이 자동으로 불린다)

간단해 보이지만 여러 변수들이 Heap을 사용하게 되면 이 과정이 꽤나 복잡해진다.

예제

다음 코드를 살펴보자

let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);

위 코드를 실행시키면 에러가 발생한다. 엥? 도대체 뭐가 문제지?

error[E0382]: use of moved value: 's1'
-- 후략 --

컴파일러가 “moved value가 사용됐다" 라며 프로그램을 중단시켰다.

왜 이런 에러가 발생한걸까? 이를 알아보기 위해 위 과정이 일어났을 때의 메모리 공간을 살짝 살펴보자.

맨 첫 번째 줄에서 변수 s1 이 할당됐을 때 메모리공간은 다음과 같이 s1 을 저장한다.

그림 1–1

왼쪽 테이블은 Stack 영역을 나타내며 오른쪽 테이블은 Heap 영역을 나타낸다. Stack에서는 Heap영역에서 해당 변수가 시작되는 메모리의 주소와 그 후로 얼만큼이 이 변수가 차지하는 공간인지를 나타내기 위해 len, capacity 값을 갖고있다. 그리고 실제 데이터는 Heap영역에 저장된다.

이제 s2 = s1 이 실행된 후의 메모리공간을 살펴보자.

그림 1–2

s2s1 이 할당받았던 메모리공간을 그대로 가리킨다. 아래와 같은 모양으로 저장되지 않는다는 뜻이다. 만약 아래와 같이 저장된다면 용량이 큰 변수가 여러개로 복사되면 중복된 정보가 쓸데없이 여기저기 퍼지게 될 것이다.

그림 1–3

이제 메모리가 해제되는 상황을 고려해보자. s1s2 가 scope에서 나가게 되면 위에서 언급했듯이 drop 함수가 호출될 것이다. 그리고 scope에서 나가게 된 변수들에 대해 메모리 해제가 자동으로 이루어질 것이다. 그런데 s1s2 는 같은 공간을 가리키고 있다. 이렇게 되면 두 변수 모두 같은 메모리를 해제하려고 시도할 것이고 결국 둘 중 하나는 빈 메모리를 해제하려고 시도하게 된다. 이는 메모리 중복해제 에러로 이어진다.

Rust는 이러한 잠정적인 에러를 막기위해 다른 변수에게 메모리가 복사된 변수를 부르는 것을 허용하지 않는다. 이러한 이유로 s1 으로부터 값을 불러오려는 println 함수에서 에러가 발생하게 된 것이다.

다른 언어들을 사용하며 shallow copy 라는 용어를 들어본 적이 있을 것이다. 메모리 공간만 복사하는 위 과정은 shallow copy 와 비슷해 보일 것이다. 하지만 Rust는 copy된 변수는 그 후로부터 무효화시키기 때문에 Rust에서는 이를 Shallow copy가 아닌 move라고 부른다. 위 예제에서는 “s1s2move됐다” 라고 표현한다. s1 = s2 코드가 실행된 이후 메모리 공간은 아래 그림 1–4와 같아진다.

그림 1–4

결국 scope를 나갈 때는s2만이 메모리 해제를 시도한다.

참고로 Rust는 프로그래머가 명시하지 않는 한 절대 “deep” copy를 하지 않는다. 때문에 자동복사가 일어나는 곳에서도 런타임 퍼포먼스에 영향을 받을 걱정은 하지 않아도 된다.

Clone

만약 변수를 복사할 때 위와 같은 shallow copy가 아닌 deep copy를 하고 싶을 경우가 있을 것이다. 이럴 때는 변수들의 공통 메서드clone 메서드를 사용할 수 있다.

let s1 = String::from("Hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1 ,s2);

위 코드는 정상적으로 실행된다. 하지만 clone 을 이용한 복사는 해당 런타임 상황에 따라 다르게 실행되기 때문에 자주 사용할시 프로그램 속도에 영향을 줄 수 있다.

Stack only Data: Copy

여기서 짚고 넘어갈 것이 하나 더 있다. 다음 코드는 에러없이 잘 실행된다.

let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);

분명clone 을 사용하지 않았는데 에러가 발생하지 않는다. 그 이유는 정수, 문자, 불리언, 배열 등 데이터의 크기가 정해져있는 변수들은 deep copy, shallow copy구별없이 모두 Stack 영역에 값이 저장되기 때문이다. 즉 clone 을 통해서 복사를 하더라도 그렇지 않았을 때와 완전히 같은 방식의 복사가 이루어진다.

Rust에서는 위와 같이 크기가 정해져있는 변수들이 Copy trait이란 것을 갖고있다. Copy trait을 갖는 변수는 위 코드와 같이 값이 복사된 이후에도 복사된 원본 변수를 그대로 사용할 수 있다. String 타입은 Copytrait이 없기 때문에 기존 변수를 사용할 수 없는 것이다. 대신 StringDrop trait을 사용하며 Rust는 CopyDrop trait를 동시에 가질 수 없게 해준다.

일반적으로 스칼라 변수는 Copy 를 가지며 메모리를 할당받을 필요가 없거나 어떤 자원의 형태인 경우에도 Copy 를 갖는다.

다음은 Copy 를 갖는 변수들의 예이다.

  • u32 와 같은 모든 정수타입
  • 불리언타입
  • f64 같은 실수타입
  • 문자타입
  • 튜플 // Copy 를 갖는 타입으로 이루어진 튜플의 경우에만. (i32, i32)Copy 를 갖지만 (i32, String) 은 그렇지 않다.

함수에서의 ownership

함수단위에서 오너십이 동작하는 방식도 크게 다르지 않다.

String 의 경우 함수로 해당 값을 전달하게 되면 함수를 호출한 scope에 있는 String 변수는 이제 해당 scope에서는 사용할 수 없다. 하지만 i32와 같은 Copy 를 갖는 변수는 그렇지 않다.

함수 내부에서는 위에서 설명했던 것과 정확하게 같은 방식으로 동작한다.

정리

오늘 배운 것을 요약해 보면 다음과 같다.

Ownership

  • Rust는 오너십을 기반으로 메모리를 관리한다.
  • 오너십을 가진 변수 scope에서 빠져나갈 경우 Drop 함수가 자동으로 호출된다. 이 때 메모리 해제가 이루어진다.
  • 하나의 값(메모리공간)의 오너십은 하나의 변수만이 가질 수 있으므로 중복해제 에러가 일어나지 않는다.
  • 오너십은 String 타입과 같이 할당 받을 메모리의 크기가 정해져 있지 않은 타입들에 대해서만 적용된다.
  • 함수 단위에서도 오너십은 같은 방식으로 동작한다.

C, C++같은 언어에서는 프로그래머가 malloc 이나 free 를 불러서 직접 메모리를 관리해줘야 했다.

Rust는 이보다는 편리하지만 거의 동일한 수준의 퍼포먼스를 내기 위해서 Ownership을 차용했다. Owner에도 장점과 단점이 존재하겠지만 매력적인 개념인 것은 분명하다.

--

--