Rust의 소유권 이야기

scalalang2
CURG
Published in
19 min readDec 20, 2021

2021년, Rust는 블록체인을 공부하는 개발자들의 가장 큰 관심사 중 하나였습니다. 혜성처럼 등장한 솔라나 블록체인이 러스트로 작성되었고 Terra 블록체인의 스마트 컨트랙트 작성 언어 이기도 합니다. 오늘은 Rust라는 새로운 프로그래밍 언어가 등장하게 된 배경과 소유권 개념을 가볍게 보고자 합니다.

메모리 관리의 두 종류

거의 모든 시스템에서 메모리 관리는 프로그래머의 주요 관심사 중 하나입니다. 메모리 관리는 크게 명시적 메모리 관리자동 메모리 관리로 나뉩니다.

명시적 메모리 관리

명시적 메모리 관리란 개발자가 사용한 메모리를 직접 해제하는 방식을 말하는데요. C/C++이 대표적으로 명시적 메모리 관리를 하는 프로그래밍 언어이죠. 개발자가 직접 malloc와 free함수를 사용해서 메모리를 동적으로 할당하고 해제합니다.

하지만, 명시적으로 메모리 관리를 하는 건 매우 어려운 일이라고 하는데요. 값이 없는 변수를 참조하면 댕글링 포인터라 부르고, 메모리 해제를 두 번 하면 에러가 발생하고 개발자가 까먹고 해제를 하지 않으면 메모리 릭이 발생합니다. 전문적인 개발자도 힘들어하는게 메모리 관리이기 때문에 요즘 만들어지는 대부분의 언어는 자동 메모리 관리 기능을 기본적으로 가지고 있습니다.

자동 메모리 관리

자동 메모리 관리는 현대 모든 새로 만들어진 언어가 제공하는 기능이구요. 프로그래머로 하여금 메모리 관리를 신경쓰지 않도록 해줍니다. 메모리는 유한한데 반해 마치 무한한 메모리가 존재하는 것 처럼 코딩할 수 있도록 해줍니다. 예를 들어, 아래 Java 프로그램은 무한히 동작할 거에요.

import java.util.UUID;public class A {
public static void main(String []args) {
while (true) {
System.out.println(new String(UUID.randomUUID().toString()));
}
}
}

자동 메모리 관리를 구현하는데는 크게 2가지 접근법이 존재합니다. 레퍼런스 카운팅(RC: Reference Count)과 가비지 컬렉터(GC:Garbage Collector)입니다. 두 접근법 모두 사용하지 않는 객체는 자동으로 수거해서 메모리에 공간을 만드는 작업인데요. GC에 대해서 잠깐 이야기 하자면, GC는 mutator와 collector 두 가지 컴포넌트로 구성됩니다. mutator는 코드 실행 컨텍스트를 가지는 쓰레드로 흔히 collector를 제외한 모든 쓰레드를 mutator라고 부릅니다. collector는 실제 쓰지 않는 객체(=메모리)를 수거해가는 쓰레드입니다.

그런데, GC가 들어가는 순간 프로그램이 무거워집니다. 일단 쓰레기를 수집하는 동안 모든 mutator 쓰레드가 동작을 멈춰야 합니다. (=STW: Stop The Wrold), 그리고 쓰레기인지 아닌지 식별하기 위한 추가 정보가 객체에 붙어야 하기 때문에 공간 오버헤드도 발생하는데요. 대체 어느 정도로 무거운 작업이길래 GC가 문제가 될까요?

국제 메모리 심포지엄에서 발표된 초기 Go언어 설계안

위 장표는 2018년 국제 메모리 관리 심포지엄에서 Go언어 설계자가 2014년 당시 Go언어의 설계안을 보여준 자료입니다. SLO라는 건 서비스 레벨 목표를 말하는데요. 이런 목표를 가지고 설계되었다 라는 의미입니다. 가만 보면 GC가 CPU의 25% 정도 점유하길 바라며, 50ms 마다 10ms의 STW pause를 가지도록 설계했다고 합니다. 즉 1초에 약 160ms는 메모리를 정리하는데 쓰겠다고 한건데요. 영상 편집이나 게임과 같이 CPU가 많이 드는 프로그램에서는 치명적일 수 있습니다.

당시, Google이 개발한 프로그래밍 언어 조차 이것이 최선이었다는 건 GC가 얼마나 무거운 작업인지 알 수 있습니다. (물론 지금은 GC가 이것 보다 훨씬 빠릅니다) 오늘 이야기 하게 될 Rust의 소유권은 바로 이 GC를 제거해서 성능을 높이는 접근법을 말합니다. 본격적으로 소유권 개념을 이야기 하기 이전에 레퍼런스 카운팅과 가비지 컬렉터의 개념을 잠깐 소개합니다.

레퍼런스 카운팅

레퍼런스 카운팅은 메모리 관리를 자동으로 하기 위한 패러다임 중 하니입니다. 동작 원리는 매우 간단합니다.

  1. 모든 객체에 레퍼런스 카운트 (RC) 정보를 기입해 둡니다.
  2. 변수가 참조하면 RC가 1 증가하고, 참조하지 않으면 1 감소 시킵니다.
  3. RC를 감소시킬 때, 값이 0이 되면 메모리를 해제합니다.

아래 예제를 보면 A,B,C,D 각 객체는 잠조되고 있는 회수에 따라 RC의 값이 부여됩니다. 왼쪽에 있는 흰 네모박스는 roots 라고 부르며, 스택 프레임, 레지스터 등에 있는 변수를 의미한다고 보시면 됩니다.

각 객체 A,B,C,D에 레퍼런스 카운트가 기록된 그림

위 객체 중 D의 RC값은 2입니다. C로부터도 참조되고 있구요, B에서도 참조되고 있습니다. 만약 변수의 참조가 사라진다면 연쇄적으로 RC의 값이 줄어들게 됩니다. 아래 그림에서는 A의 참조가 사라진 경우 입니다. A의 값은 0이되고 메모리에서 해제됩니다. 마찬가지로 B도 자신을 참조하고 있는 객체가 없으니 RC의 값이 1 감소하고, 값이 0이 되므로 메모리에서 해제됩니다.

A와 B의 메모리가 해제된 모습

언뜻 보면, GC의 STW도 발생하지 않고 굉장히 깔끔한 방법인 것 같지만 하나 가장 큰 문제가 있는데요. 더블 링크드 리스트나 복잡한 그래프 구조에서는 순환 참조 (Circular Reference) 문제가 발생합니다.

객체 B, D, E가 각각 서로를 참조하고 있다.

위 그림에서는 객체 B, D, E가 서로를 참조하며 순환 그래프를 이루고 있습니다. 만약 이 경우에서 A와 C의 참조를 없앤다면, A와 C는 각각 RC가 0이 되면서 메모리에서 해제되는데요. 반면에 객체 B, D의 RC값은 1로 유지되면서 메모리에서 해제되지 않는 문제가 발생합니다.

객체가 메모리만 차지하는 현상이 발생한다. (순환 참조 문제)

위 그림에서는 A와 C의 객체를 해제한 모습을 보여주는데요. 힙에 할당된 객체를 참조하고 있는 변수가 없음에도, 메모리만 차지하는 객체가 존재하는 현상이 나타납니다. 이를 순환 참조 문제라고 부르고 이 문제를 해결하기 위해 가비지 컬렉션(GC)이 이용됩니다.

지금도 레퍼런스 카운팅을 쓰긴 합니다. 대표적으로 iOS 앱을 개발할 때 쓰는 Swift언어가 레퍼런스 카운팅으로 메모리를 관리하는데요. 개발자가 순환참조가 발생하지 않게끔 잘 코딩하면 문제가 발생하지 않으면서 GC보다 더 높은 성능을 이끌어 냅니다. 다만, 자동 메모리 관리라고 하기엔 개발자가 계속 객체 간의 참조 관계를 분석해야 하는게 불편하고 메모리 누수가 있다면 어디서 누수가 났는지 파악하기 어렵겠죠.

가비지 컬렉션

가비지 컬렉션을 사용하면 레퍼런스 카운팅과는 달리 순환 참조 문제가 발생하지 않습니다. Stop The World 때문에 성능은 더 느리겠지만 개발자가 참조 관계를 생각하지 않아도 되기 때문에 더 편리합니다. 가비지 컬렉션의 종류가 굉장히 많은데요. 가장 기본이 되는 Mark Sweep에서 대해서 짧게 설명해 보겠습니다. 일단, 원리 자체는 간단합니다.

  • Mark Phase | root에서 출발해서 모든 도달 가능한 객체를 Mark 합니다.
  • Sweep Phase | 모든 힙 메모리의 객체를 확인하면서 Mark 되지 않은 객체를 제거합니다.
Mark-Sweep 알고리즘의 동작 원리

우선 Roots란 글로벌 변수/ 지역 변수/ 스택 프레임/ 레지스터 등에 저장된 변수에서 출발하는 지점을 뜻합니다. Mark 단계에서는 해당 변수에서 출발해서 모든 도달 가능한 객체(Reachable Object)를 탐색한 후 Mark합니다. 위 예시에서는 각각 A,B,C,D,E 객체가 marked 되었고

이 후 Sweep 단계에서는 모든 객체를 탐색하면서, 마크 되지 않은 객체를 해제 합니다. 그림을 그리다 보니 D, D, D로 동일하게 그려서 오해를 살 수 도 있는데요. 제 원래 의도는 모두 다른 객체를 의미합니다. 빨간색 테두리를 가진 객체가 도달 되지 않아서 메모리를 해제합니다. Sweep에서는 모든 객체를 확인해야 하기 때문에 항상 Mark 단계에서 확인하는 객체보다 많습니다.

이 Mark Sweep 에서는 두 가지 단점이 존재합니다.

  1. (STW) Mark / Sweep 각 단계마다 컬렉터를 제외한 모든 쓰레드를 중지 시켜야 합니다. 중간에 힙 상태를 바꾸면서 메모리가 충돌되면 제대로 해제하지 못합니다. 그래서 GC를 하려면 끝까지 다 해야 하구요. 하지 않으려면 아예 하지 말았어야 합니다.
  2. (효율성) 프로그램이 사용하는 힙 메모리가 커지면 커질수록 확인해야 하는 객체의 수가 증가하기 때문에 부담이 됩니다. 메모리가 16GB라면 모든 16GB짜리 객체를 확인해야 하는데, 이는 프로그램이 부담이 되죠. 그래서 자바 같은 언어에서는 확인해야 하는 객체를 줄이기 위해서 각 GC마다 너무 자주 보이는 객체는 항상 쓰나 보다 하고 탐색 대상에서 제외 시킵니다. 이를 조금 멋있는 말로 (약한 세대 가설 : Week Generational Hypothesis) 이라고 부릅니다.

Go 언어에서는 이 효율성 문제를 해결 하기 위해 Tri-Color Marking 알고리즘이라는 Inremental GC를 사용하고 있습니다. 나중에 별도 포스팅에서 다룰 예정인데 컨셉만 설명하자면, Incremental GC에서는 GC 작업을 나눠서 할 수 있습니다. 예를 들어, 100%의 GC작업이 있다면 50%만 미리 해두고 나머지 50%는 나중에 해서 끝내는 방식입니다.

러스트(Rust)의 소유권

이제 오늘의 메인 주제, 러스트의 소유권 이야기로 넘어와봅시다. 러스트는 모질라의 직원이었던 Graydon Hoare의 개인 프로젝트 였는데요. 회사에서 공식적으로 지원해줌으로써 현재의 명성을 갖게된 프로그래밍 언어입니다. 그리고 가비지 컬렉션과 레퍼런스 카운팅이 없습니다.

러스트에서는 이 자동 메모리 관리라는 문제를 제 3의 방법인 소유권(Onwership)이라는 걸로 해결합니다. 소유권(Ownership)은 Rust의 메모리 관리 패러다임인데요. 아래 3가지 소유권 규칙을 잘 기억해주세요.

  1. 러스트에서 모든 변수는 소유자(owner)라고 불리는 변수를 가지고 있다.
  2. 한 순간에 소유자는 단 하나이다.
  3. 소유자가 scope를 벗어나면 해당 값은 제거된다.

규칙 3번째 부터 한 번 예시를 통해 이해해 봅시다. 여기서 나오는 모든 예시는 Rust의 공식 문서[1]에 나온 예제를 사용했습니다. 그리고 모든 예시는 온라인 실행기[2] 에서 실행해 볼 수 있으니까요. 한 번 해보면서 익숙해지시기 바랍니다 : )

fn main() {
{ // scope A
let s = String::from("hello, world");
}
// 스코프를 벗어난다. s 객체의 drop 함수를 실행시켜서 메모리를 지운다.
}

변수 s는 스코프 A에서 생성되었다가 스코프를 벗어나면 러스트가 자동으로 변수 s가 참조하는 객체의 drop함수를 실행시켜서 메모리를 지웁니다. 러스트는 기초 자료형과 같이 크기가 고정되고 불변한 값은 보통 스택에 저장하는데요. 문자열 처럼 길이가 변하는 값은 객체로 힙 메모리에 저장합니다.

소유권의 이동

기초 자료형은 변수를 대입할 때 값을 복사하고 힙에 저장된 객체는 참조가 복사되어서 대입됩니다. 아래 코드에서 s1과 s2의 두 변수를 메모리에서 보면 아래 그림처럼 도식화 할 수 있습니다.

let s1 = String::from("hello");
let s2 = s1;
변수 s1과 s2를 메모리상에서 도식화한 그림

ptr 에서 실제 값을 참조하고 있고 len과 capacity는 각각 메모리 상에서 값이 어느 정도 크기로 참조하는지 나타냅니다. 변수를 대입할 때 참조를 복사하는게 아니라 값을 복사해버리면 동일한 객체가 여러개 존재하게 되서 매우 비효율적인 프로그램이 됩니다.

만약, s1과 s2 두 변수를 대입할 때 참조가 아닌 값이 복사된다면 매우 비효율적인 프로그램이 된다.

여기서 문제는, 소유권 규칙 3번 째에 의하면 scope를 벗어나는 변수는 drop함수를 실행시킨다는 것을 기억하실 겁니다. 즉 s1과 s2변수 각각 drop이 두 번 실행되어서 동일한 공간의 메모리 해제를 2번하게 되는 문제가 발생하는데요. 러스트의 해결방법은 s1을 다른 곳에 대입 시켯으면 변수 s1을 바로 invalidate 시켜 버립니다. 이를 소유권이 이동 (Move) 했다고 표현합니다. 즉, 참조가 옮겨 갈 뿐 절대 Shallow Copy나 Deep Copy를 하지 않습니다.

{
let s1 = String::from("hello");
let s2 = s1; // 변수 s1이 들고 있단 객체의 소유자가 변한다.
println!("{}", s1);
}

위 프로그램은 러스트에서는 컴파일 되지 않습니다. s1의 변수의 소유자가 s2로 이동했는데, s1을 참조하고 있기 때문에 러스트는 아래 그림처럼 컴파일 에러를 보여줍니다.

s1의 값이 소유권이 이동한 후 borrow를 했기 때문에 컴파일 에러가 발생한다.

함수 파라미터에서의 소유권 이동

함수의 파라미터로 넘길 때에도 소유권 이전이 발생합니다. 아래 예시에서, print_something 함수는 text라는 변수를 받는데요. 이를 실제로 호출할 때 파라미터로 소유권을 넘겼기 때문에 변수 s1을 쓰려고 하면 컴파일 에러가 발생합니다. (???: 뭔가 싶죠.. 다른 프로그래밍 언어에서 이런 종류의 에러를 발생시키는 언어는 처음 봅니다)

fn main() {
let s1 = String::from("Hello, World!");
print_something(s1); // s1의 소유권이 이동함
println!("{}", s1); // 컴파일 에러 발생
}
fn print_something(text: String) {
println!("{}", text)
}

아래 예시 코드처럼, 함수에서 리턴 값을 이용해서 소유권을 다시 받아올 수도 있습니다. takes 함수는 파라미터로 받은 값을 그대로 리턴해서 소유권을 이전시키구요. gives 함수도 지역 변수로 생성한 객체의 소유권을 리턴해서 이전 시키기 때문에 에러가 발생하지 않습니다.

fn main() {
let s1 = gives();
let s2 = String::from("hello");
let s3 = takes(s2);
}
fn gives() -> String {
let some_string = String::from("yours");
some_string // return 구문은 생략해서 쓴다.
}
fn takes(a_string: String) -> String {
a_string
}

물론, 매번 이런식으로 코딩하면 매우 피곤할겁니다. 그래서 레퍼런스(&)를 생성해서 파라미터로 넘겨서 소유권을 이전시키지 않고도 사용할 수 있습니다.

fn main() {
let s1 = String::from("hello");
let len = len(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn len(s: &String) -> usize {
s.len()
}

위 예시에서, len함수는 s변수의 타입을 &String으로 선언해서 레퍼런스를 받겠다고 선언합니다. 실제로 호출부에서도 len(&s1)으로 변수의 레퍼런스를 생성해서 넘겨주는데요. 이 때는 소유권 이전이 발생하지 않습니다. 실제 메모리 상에서는 s1의 변수를 참조하는 객체를 생성해서 할당해주는 모습을 보입니다.

파라미터 s는 s1을 참조하고 사용하고 있다.

이런 레퍼런스 생성이 가능한 이유는 변수 s의 생명주기(Lifetime)가 s1보다 짧기 때문에 가능합니다. 위 예시 코드를 한 번 더 쪼개서 보면 변수 s1의 객체의 생명주기(Lifetime)을 스코프가 벗어나는 위치까지로 볼 수 있습니다. 그리고 len 함수를 호출 할 때 레퍼런스를 생성해서 보내주는데요. 이 때, len함수를 호출하면서 넘긴 파라미터의 생명주기가 s1의 생명주기보다 짧기 때문에 레퍼런스가 항상 값을 가지고 있다는게 보장됩니다.

fn main() {
let s1 = String::from("hello"); +
let len = len(&s1); |
println!("The length of '{}' is {}.", s1, len); |
} + s1의 생명주기
fn len(s: &String) -> usize {
s.len()
}

레퍼런스의 값을 수정하기

러스트는 레퍼런스로 빌려온 변수의 값을 수정하는 것을 제한합니다. 그 이유를 이해하려면 현재 프로그래밍 패러다임에서 가장 중요한 동시성 이슈에 대해서 알 필요가 있습니다. 예전에 비해서, 멀티 쓰레드를 현명하게 다루는게 더욱 중요해졌기 때문에 요즘 나오는 언어는 언어 자체 스펙에서 동시성 제어 기능을 지원하고 있는데요. 고 언어에서는 채널과 고루틴이 그 중요한 역할을 하고 있습니다.

멀티 쓰레드 환경에서는 항상 deadlock과 race condition 문제가 발생할 수 있는데요. 러스트는 하나의 변수를 변경할 수 있는 권한을 한 소유자로 제한 하기 때문에 데이터에 있어서는 race condition 문제를 제거할 수 있도록 해줍니다. 주의할 점은 러스트는 Thread-safety를 보장하고자 하는게 아니라 Memory-safety를 보장하려는 것이기 때문에 이를 혼동해서는 안됩니다.

아래 예시를 볼까요? 원래는 레퍼런스의 값을 수정할 수 없다고 했는데요. &mut로 레퍼런스를 생성하면 값을 수정할 수 있는 레퍼런스가 만들어지고 이는 수정할 수 있습니다.

let mut x = String::from("hello");
let y = &mut x;

y.push_str(", world");

println!("x = {}", x); // x = hello, world

하지만, 만약 위 코드가 아래와 같이 변형된다면 어떻게 될까요? 자세히 보시면 x를 사용하는 println 함수를 y.push_str을 호출하는 부분보다 위로 올렸습니다. 이 경우에는 컴파일 에러가 발생합니다.

fn main() {
let mut x = String::from("hello");
let y = &mut x;

println!("x = {}", x); // ???

y.push_str(", world");
}
error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
--> src/main.rs:5:24
|
3 | let y = &mut x;
| ------ mutable borrow occurs here
4 |
5 | println!("x = {}", x); // ???
| ^ immutable borrow occurs here
6 |
7 | y.push_str(", world");
| --------------------- mutable borrow later used here

Rust에서는 왜 이런 상황에서 컴파일 에러를 발생 시킬까요? Memory-safety를 보장하기 위해서 입니다. 위 예시에서는 먼저 hello를 출력하고 y가 나중에 값을 바꾸면 되는 것 같지만, 멀티 쓰레드 환경에서 생각해봅시다.

① 먼저 y가 문자열 값을 바꾸면 메모리에서 새 공간에 할당한 뒤에 기존 메모리는 해제시켜야 합니다. (크기가 달라졌기 때문입니다), ② 원래 변수 x가 가지고 있는 참조 객체가 새로운 공간에 할당되었는데요. 이 때 println! 함수로 먼저 사용한 값이 메모리 상에서는 이미 사라진 공간일 수도 있습니다. 메모리가 안전하지 않은 케이스입니다. 예시는 문자열이 었지만 vector를 사용하는 경우에도 동일한 컴파일 에러가 발생합니다.

fn main() {
let mut items = vec![1];
let item = items.last();
items.push(2); // push함수를 실행하면서 메모리에 재할당 되는데요. 그러면 변수 item이 어느 순간 invalidate한 변수 일 수 있습니다.
}

마찬가지로 Memory-safety를 이유로 해서 불변 레퍼런스와 가변 레퍼런스를 동시에 가질 수는 없습니다. 모두 불변 레퍼런스라면 문제가 발생하지 않습니다. 이렇게 컴파일러 단계에서 강력하게 메모리 안정성을 추구하기 때문에 러스트에서는 Fearless Concurrency[3]라고 부르는 듯 합니다.

fn main() {
let mut s = String::from("hello");
let r1 = &s; // no problem
let r2 = &s; // no problem
let r3 = &mut s; // BIG PROBLEM
println!("{}, {}, and {}", r1, r2, r3);
}
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:5:14
|
3 | let r1 = &s; // no problem
| -- immutable borrow occurs here
4 | let r2 = &s; // no problem
5 | let r3 = &mut s; // BIG PROBLEM
| ^^^^^^ mutable borrow occurs here
6 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here

마치며

오랜 역사를 가진 C++에서는 이미 스코프를 벗어날 때 메모리를 해제시켜주자는 RAII 패턴 등 다양한 방식으로 메모리 관리에 대한 고민을 하고 있었습니다. Rust를 공부해보면서 느낀점은 C++에서 좋다고 알려진 디자인 패턴들을 컴파일러 단계에서 강력하게 프로그래머를 훈련시키는 느낌을 받아서 C++을 배경으로 가진 프로그래머라면 쉽게 배울 것 같고 GC를 가진 파이썬, 자바, Node.js를 배경으로 가진 프로그래머라면 익숙해지는데 까지는 많은 연습이 필요할 것 같다는 느낌을 받았습니다. 하지만 하다 보면.. 언젠가 익숙해지겠죠?

오늘은 러스트의 오너쉽 개념을 가볍게 알아보았는데요. 사실 러스트에서 메모리와 관련된 모든 내용을 다루진 않았습니다. 아마 Lifetime Annotation까지는 공부해야 제대로 쓸 수 있을 것 같은데요. 추후에 시간이 난다면 준비해보겠습니다.

--

--

scalalang2
CURG
Writer for

평범한 프로그래머입니다. 취미 논문 찾아보기, 코딩 컨테스트, 언리얼 엔진 등 / Twitter @scalalang2 / AtCoder @scalalang