개요

각 언어들에서는 메모리의 안전성을 보장하기 위해 각자만의 메모리 보호 시스템을 구동시킨다.

 

C/C++ 같은 경우에는 개발자가 직접 메모리를 할당하거나 해제해주어야 하고, Java나 Python 같은 경우에는 가비지 컬렉터(Garbage Collector, 이하 GC)라는 프로그램을 따로 구동시킨다.

 

C/C++이 Java나 Python보다 빠른 속도를 갖는 것 또한, 이처럼 GC가 존재하지 않아서도 있겠다. 하지만, C/C++은 아무래도 사람이 직접적으로 메모리 관리를 해주어야하기 때문에 메모리 관련 버그가 자주 발생하기도 한다.

 

실제로 C/C++은 메모리 관리의 복잡성으로 인해 많은 보안 문제가 지적되고 있다. 실제 사례로 2017년 파이퍼폭스에서 발견된 중대한 버그 69건 중 32건이 메모리 관리와 관련된 버그, 7건이 Null 포인트와 관련된 버그였다고 한다. 이는 그해 발견된 중대 버그 중 56%를 차지했다.

"만들면서 배우는 러스트 프로그래밍" 책의 6 Page 내용 발췌

 

여기서 Rust는 C/C++이 가진 메모리 관리와 Null 포인터 문제를 해결하였으며, 이것을 위해 Rust는 "소유권 시스템"이라는 것을 언어에 녹여내렸다. "소유권 시스템"은 Java나 Python과 같이 따로 GC 프로그램을 구동시키는 것이 아닌 Rust 컴파일러가 직접 작성된 언어가 메모리 안정성을 잘 지키고 있는지 검사하는 시스템이다.

 

즉, Rust는 메모리 안정성을 지키기 위해 GC와 같은 메모리 보호 프로그램을 구동시키는 것이 아닌, 컴파일러가 개발자에게 매우 엄격한 "소유권 시스템"이라는 프로그래밍 규칙을 지키도록 강요한다. 따라서, 속도적인 측면에서 C나 C++에게도 밀릴 일이 없는 것이다.

 

본 페이지에서는 Rust만의 독특한 특징인 "소유권 시스템"이라는 메모리 보호 시스템에 대해서 알아보고 이 시스템이 컴파일러 단계에서 어떻게 메모리를 관리하는지 설명한다.

 

소유권 시스템이란?

Rust는 앞서 개요에서 소개한 것처럼 메모리 안전성을 가지면서도 고속의 처리가 가능한 프로그래밍 언어이다. Rust가 이러한 특징을 갖는 것은 "소유권 시스템"이라는 매우 엄격한 메모리 관리 규칙을 개발자에게 강요하기 때문이다.

 

이러한 엄격한 규칙은 프로세스의 모든 메모리 영역에서 지켜야하는 것은 아니며, Heap 영역에서 지켜주어야 한다.

프로세스의 Stack과 Heap 영역에 대한 내용은 본 페이지에서 다루지 않는다.

 

Rust는 정수형, 실수형, Boolean형 같은 기본 타입과 리터럴 스트링 및 배열(Array)에는 이 규칙이 적용되지 않는다. 이러한 타입은 Heap 영역이 아닌 Stack 영역에 메모리가 할당되기 때문이다.

Heap 영역에 할당되는 메모리는 크기를 알 수 없고 Stack 영역에 할당되는 메모리는 크기를 고정적으로 알 수 있음으로

 

다시 말해, Rust의 "소유권 시스템"은 Heap 영역에서만 이루어지며, Stack 영역에서는 이루어지지 않는다.

 

"소유권 시스템"에서 Heap 영역에 존재하는 특정 메모리의 위치를 갖는 변수가 존재할 것이다. Rust에서 이 변수를 보고 메모리에 대한 소유권을 갖는 "소유자"라고 표현한다.

 

소유자는 오직 하나의 메모리에 대한 소유권을 갖을 수 있으며, 다른 소유자로 소유권이 옮겨 갈 때에는 기존 소유자는 소유권을 잃게 된다. 이것을 소유권의 이동(Move)라고 표현한다.

 

소유권의 Move에 대한 설명은 아래 단락에서 이어서 진행한다.

 

이동(Move)

앞서 설명한 소유권의 Move를 본 단락에서 Rust 코드와 함께 설명한다.

 

fn main()
{
    {
        let s1 = String::from("Hello");
        let s2 = s1; 
    }
}

한가지 추가로 설명할 부분은 Rust는 코드 블록{...}이라 부르는 하나의 Scope 영역에서 자동으로 메모리(Heap 영역)에 대한 할당(alloc)과 해제(free)가 발생한다.

변수가 생성되면 메모리가 할당되고 Scope가 끝나면 해제된다.

 

본론으로 돌아와 위 코드의 main()의 Scope 내에 또 다른 Scope가 존재한다. 이 또다른 Scope안에는 s1이라는 변수가 먼저 String 객체에 대한 소유자가 된다.

 

그 다음 줄에는 s1의 소유권이 s2로 이동하게 되어 String 객체에 대한 소유자는 s2가 갖게된다.

 

이 경우를 그림으로 설명하면 아래와 같다.

 

이와 같이 s1은 Heap영역에 대한 포인터를 잃고 s2에게 포인터와 소유권을 넘기게 된다.

그 후 다음 라인에서 Scope 영역이 끝나게 되는데 이때는 s2가 갖고있는 Heap 영역에 대한 메모리가 해제된다.

 

만약, 위 그림과 같이 s1이 Heap 영역에 대한 소유권을 잃지 않고 s2도 함께 같은 Heap 영역에 대한 소유권을 갖게 되면은 메모리 버그가 발생한다.

 

이 단락 앞에서도 설명하였다시피 Rust에서 메모리 할당과 해제는 하나의 Scope 영역에서 일어난다고 하였다.

 

그런데, s1과 s2가 동시에 같은 Heap 영역에 대한 소유권을 갖고있다면 메모리 할당은 한번 일어났는데 두 소유자가 있기 때문에 두번의 메모리 해제가 발생되어 메모리 버그가 일어나 메모리 안전성이 위배된다.

 

"소유권 시스템"의 Move에 대해서 추가적으로 설명하자면 외부 Scope에 있는 소유권이 내부 Scope로 Move 된다면, 다시 외부에서 못쓰는 규칙이 존재한다.

 

코드로 설명 하자면,

fn main()
{ // 외부 Scope
    let s1 = String::from("Hello");
    
    { // 내부 Scope
        let s2 = s1; 
    }
}

s1은 외부 Scope에 존재하고 s2는 내부 Scope에 존재하는 경우를 예로 들어본다.

 

외부 Scope에서 s1에 String 객체를 할당시킨 뒤 내부 Scope에서 s1의 소유권을 s2로 Move 하였다. 그리고 내부 Scope가 끝이나게 된다.

 

이 경우 s1의 소유권이 s2로 Move한 뒤 내부 Scope가 끝이 났음으로 s2가 갖고있던 메모리 영역은 해제된다.

따라서, s1은 더 이상 사용하지 못하는 변수가 된다.

 

위와 같은 경우 뿐만 아니라, 다른 함수(fn)의 파라미터를 통해서 소유권이 이동한다면 그 함수의 Scope가 끝이 날 때 파라미터로 이동 되기 전에 할당된 소유자는 더이상 사용할 수 없게 된다.

 

이에 대한 예는 아래 코드와 같다.

fn str_ownership_move(s_move: String) {
    let s_temp = s_move;
}

fn main() {
    let s_origin = String::from("Hello");
    str_ownership_move(s_origin);
    // str_ownership_move(s_origin.clone());
    
    println!("{}", s_origin);
}

이 코드에서 main() 함수의 println!()의 s_origin은 아무런 소유권을 갖지 못한 상태가 된다.

해당 코드는 소유권 시스템을 위배하였음으로 컴파일 에러가 발생한다.

 

왜냐하면, str_ownership_move() 함수에서 s_origin의 소유권을 가져간뒤 메모리를 해제시켰기 때문이다.

 

이때는 str_ownership_move(s_origin.clone());와 같이 메모리 자체를 복사하게 되면 컴파일 에러 없이 정상적으로 컴파일되겠지만, String 객체의 메모리 영역이 "Hello"가 아닌 매우 큰 사이즈의 데이터라면 복사하는데 많은 시간을 소모할 것이고 Rust를 사용하는 이유 자체가 없어질 것이다.

 

이러한 특정 소유자의 소유권을 다른 함수로 이동시켜 다시 반환받고 싶은 경우가 있을 것이다. 이때 "소유권 시스템"은 빌림(Borrow)라는 기능을 제공한다.

 

빌림(Borrow)은 아래 단락에서 설명한다.

 

빌림(Borrow)

"소유권 시스템"에서 Borrow란, 소유권을 완전히 가져가는 것이 아니라 일시적으로 가져오고 사용이 끝나면 다시 반납하는 것을 뜻한다.

 

Borrow는 주로 함수에서 특정 Heap 메모리 영역에 대한 소유권이 필요할 때 이용하며, 참조자를 나타내는 "&"을 사용한다. 또한, Borrow한 변수의 내용을 변경하고 싶은 경우에는 "&mut"을 사용한다.

"&"는 immutable borrow이며, "&mut"은 mutable borrow이다. 두 차이점은 아래에서 설명한다.

 

Borrow를 한 경우에는 소유권을 잠시 빌리는 것임으로 특정 변수(소유자)의 소유권이 다른 함수로 이동되어도 메모리 해제가 일어나지 않고 이어서 사용할 수 있게된다.

 

아래 예시 코드를 살펴보자.

fn str_ownership_borrow(s_move: &String) {
    let s_temp = s_move;
}

fn main() {
    let s_origin = String::from("Hello");
    str_ownership_borrow(&s_origin);
    
    println!("{}", s_origin);
}

이 코드에서 main() 함수의 println!()은 앞의 Move 예시와 다르게 컴파일 에러없이 컴파일이 완료되고 프로그램도 정상적으로 구동한다.

물론 str_ownership_move() 함수 내부에 있는 s_temp를 사용하지 않았다는 Warning 문구는 뜨긴 한다..ㅎㅎ

 

앞선 예시에서는 s_origin 변수에 대한 소유권 자체를 다른 함수(str_ownership_move())로 Move하였기 때문에 함수가 종료되면서 메모리 해제가 일어나 소유권을 잃어 컴파일 에러가 발생하는 것이다.

 

하지만, 위 Borrow 예시에서는 s_origin 변수의 소유권을 "&"을 통해 다른 함수(str_ownership_borrow())로 Move가 아닌, Borrow 하였기 때문에 해당 함수가 종료되더라도 소유권이 이동되지 않았음으로 메모리 해제가 일어나지 않아, 컴파일 에러가 발생하지 않는 것이다.

C언어의 Call by Value와 Call by Address 개념을 생각하면 이해하기 쉽다. 

 

그리고 다른 함수로 Borrow된 메모리 내용을 수정할 필요가 있을 것인데, 이 경우에는 "&"을 통해선 메모리의 내용을 변경할 수 없다. 왜냐하면 "&"은 immutable borrow를 뜻하기 때문이다.

 

Borrow를 통해 다른 함수로 빌려진 메모리의 내용을 수정하고 싶을 땐 mutable borrow를 뜻하는 "&mut"을 사용해야 한다. 추가로 "&mut"은 let mut으로 선언된 변수만 사용할 수 있다.

 

이에 대한 예시를 살펴보자.

fn add_(msg: &mut String)
{
    msg.insert(0, '"'); // "Hello, World
    msg.push('"'); // "Hello, World"
}

fn main() {
    let mut my_str = String::from("Hello, World");
    
    println!("{}", my_str); // Hello, World
    add_(&mut my_str);
    println!("{}", my_str); // "Hello, World"
}

add_() 함수는 문자열의 양끝에 "를 추가해주는 함수이다. main()에서 add_()로 my_str 변수를 넘길 때 &mut으로 넘겨서 my_str이 소유하고 있는 메모리에 대한 내용을 변경하는 것을 확인할 수 있다.

 

그리고, 함수에서 Borrow한 소유권의 실제 값을 가져오거나 새로 할당할 때에는 역참조를 나타내는 "*"를 이용한다.

 

예를 들어, 아래 코드와 같다.

fn add_(msg: &mut String, int: &mut i32)
{
    msg.insert(0, '"'); // "Hello, World
    msg.push('"'); // "Hello, World"
    
    *int += 32; // 64
}

fn main() {
    let mut my_str = String::from("Hello, World");
    let mut my_int = 32;
    
    println!("{}, {}", my_str, my_int); // Hello, World, 32
    add_(&mut my_str, &mut my_int);
    println!("{}, {}", my_str, my_int); // "Hello, World", 64
}

Borrow는 Call by Address를 생각하면 된다.

 

Call by Value 형태로 가져올 때에는 기본 타입의 경우에는 메모리 자체가 Copy되고 그 외의 타입인 경우에는 소유권이 Move하게 된다.

fn add_(msg: String, int: i32); //이러한 경우는 Call by Value이다.

 

Rust의 역참조에 대해서는 본 페이지가 아닌 다른 페이지로 구성해서 설명을 진행해야 할 것 같다. 너무 어지럽다..ㅠ

역참조 ref : https://velog.io/@mohadang/Rust-%EC%97%AD%EC%B0%B8%EC%A1%B0

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기