[Rust] 소유권

MS Choi·2022년 5월 17일
0

Rust Study

목록 보기
1/4

본 글을 시작하기 전에

먼저 이글을 적는 필자는 원래 C++이나 dart, java같이 메모리를 직접관리하거나 garbage collector와 같이 메모리를 관리해주는 프로그래밍 언어만 사용해보았다.

그래서 rust의 주요 기능을 관통하는 이 소유권이라는 개념에 관심이 많았고, 이번에 공부를 시작하면서 먼저 배워둔 개념때문에 쉬운부분도 있었지만 오히려 그 부분이 더 이해를 힘들게 만드는것도 있었다.

필자가 잘못 이해하거나 설명하는 부분도 있을지도 모르니 언제든 댓글로 피드백을 주면 좋겠다

왜 기존과 다른 방법을 사용할까?

우리는 먼저 왜 Rust가 소유권이라는 독자적인 시스템을 채용했는지 그것 부터 이해를 해야한다. 만약 현재 대부분의 언어에서 사용하는 garbage collector나, 직접적으로 메모리를 관리하는 방법이 크게 문제가 없다면 Rust라는 언어는 등장하지 않았을지도 모른다.

먼저 기존에 사용하던 메모리관리방법에는 다음과 같은 문제가 있다.

  • 포인터를 통한 메모리 관리방법(이런방법을 사용하는 언어를 Unmanaged Laguage 라고 부른다.)
    • 대표적으로 C/C++이 있다.
    • 장점
      • managed Language에 비해 상대적으로 빠르며, 메모리의 할당과 해제를 사용자의 의도에 따라 세밀하게 조정할수 있으므로, 프로그래밍 자유도가 높고 최적화가 용이하다.
    • 단점
      • 프로그래머가 실수할 경우 Memory Leak이 발생할 수 있다.
  • Garbage collector를 사용하는 메모리관리 방법(Managed Laguage라고 부른다)
    • 대표적으로 JAVA, C#, GO가 있다.
    • 장점
      • 런타임 환경에서 다양한 도움, 특히 메모리 관리를 자동으로 해주기 때문에 메모리 누수의 문제에서 보다 자유롭다.
        코드가 런타임환경에 의존하므로 하드웨어나 OS에 종속되지 않는다.
    • 단점
      • 중간 매개체를 두는 만큼 성능적인 부분에서 손실이 날 수 밖에 없다.
      • GC에 의해 의도치 않은 오작동이 발생할 위험성이 존재한다.

위의 설명대로 각각의 방법들은 장점과 단점을 가지고 있다. 안정성을 높인 대신 속도와 자유도를 포기했고, 자유도를 높인대신 안정성을 포기했다. rust는 이러한 두 관리방법의 장점을 모두 가지기 위해 '소유권' 이라는 제 3의 방법을 선택했다고 볼 수 있다.

소유권(ownership)에 대해

먼저 소유권이 어떤 시스템인지 공식 가이드 설명을 참고해보자

모든 프로그램은 실행하는 동안 컴퓨터의 메모리를 관리해야 한다. 어떤 언어는 실행하는 동안 사용하지 않는 메모리를 계속 추적해서 관리하는 garbage collection을 사용하기도 하고, 어떤 언어는 직접 메모리를 할당하고 풀어줘야 한다. Rust는 또 다른 방식을 사용한다: 컴파일 시점에서 컴파일러가 체크하는 ownership 시스템의 규칙을 통해 메모리를 관리한다. 따라서 ownership은 프로그램의 런타임 성능을 저하시키지 않는다.
출처 : https://rinthel.github.io/rust-lang-book-ko/ch04-01-what-is-ownership.html

공식가이드에서도 설명하듯이 위에서 설명한 2가지 방법이 아닌 제 3의방법인 owenrship(소유권)을 사용한다고 말하고 있다.

자 그럼 이제 위의 설명대로 어떤 규칙을 통해 메모리를 관리하길래, 안전하면서 속도에 영향을 주지않는 방법을 사용하는지 알아보자.

소유권의 규칙

본격적인 설명의 들어가기 앞서 이 3가지의 설명을 잘 기억하면서 다음 글을 읽으면 이해가 훨씬 편하다.

  • rust가 다루는 각각의 값은 소유자(owner)라고 부르는 변수를 가지고 있다.
  • 특정 시점에 값의 소유자는 단 하나뿐이다.
  • 소유자가 범위를 벗어나면 그 값은 제거된다.

소유자의 설명에 앞서 programming laguage가 rust로 처음 입문한 사람이라면 '범위'가 어떤의미 인지 모를 수 있기 때문에 이 부분을 먼저 정의하겠다.

자 여기 간단한 rust 코드가 있다.

fn main(){

	let s= "hello"
}

범위에 대한 설명을 하기전에 한번 생각해보자, 위의 코드에 선언된 "hello"를 저장한 변수 s를 접근할 수 있는 범위는 어디까지일까?

아마 다른 언어를 배운 사람들은 이미 짐작했겠지만 자세히 정답을 적어보면 이렇게 적을 수 있다.

fn main(){
					//변수 s가 선언전이기 때문에 사용할 수 없음
	let s= "hello"	//변수 s가 선언됨
    				//변수 s를 이용해 각종 동작을 실행 할 수 있다.
}//여기서 범위를 벗어나므로 더 이상 변수s에 접근 할 수 없다.

간단히 말하면, '변수 s가 선언됨 지점으로 부터 괄호가 끝나기 전까지' 라고 말할 수 있다. 여기서 우리가 말하는 범위는 함수의 중괄호({})를 말하며 중괄호 안에 있으면 범위 안에있다고 말하고 중괄호 밖에 선언되면 범위 밖에있다 말한다.

그러므로 이 예제를 통해 알 수 있는 핵심은 다음 2가지이다.

  • 변수 s가 범위 안으로 들어오면 유효하다.
  • 변수는 범위를 벗어나기 전까지 유효하다.

이 설명을 바탕으로 String 타입에 대해 알아보도록 하자.

String 타입

먼저 int와 String 두가지 타입의 차이를 정확히 알기 위해서는 스택과 힙에 대한 이해가 필요하지만 이 부분을 설명하기에는 너무 글이 길어지도 하니 설명이 잘된 글의 링크를 달아두겠다.
스택과 힙(rust 공식가이드)

앞으로 설명은 위의 글을 읽었다는 가정하에 진행될 예정이므로 꼭! 읽기바란다.

글을 읽고 온사람이라면, 기본적인 데이터들은 스택에서 관리가 된다는것을 이해했을것이다. 하지만 우리가 이제부터 알아볼 String은 스택이 아닌 힙에서 관리된다.

이전 예제에서 우리는 s라는 변수에 하드코딩한 "hello"라는 문자열 리터럴을 할당했다. 하지만 문자열 리터럴은 텍스트를 다루어야 하는 모든 경우에 적합한 방법은 아니다. 그 이유 중 하나는 문자열은 immutable하기 때문이다. 또 다른이유는 코드를 작성하는 시점에 필요한 모든 문자열 값을 알 수 없기 때문이다.

이런 경우에 우리는 String타입을 사용한다. 아 타입은 힙에 할당되므로 컴파일 시점에 알 수 없는 크기의 문자열을 저장할 수 있다. 다음과 같이 from 함수를 이용하면 문자열 리터럴을 이용해 String 인스턴스를 생성할 수 있다.

	let s = String::from("hello");

여기서 콜론(::)을 두개 사용하는건 명확한 타입을 명시해주는거라고 생각하면 된다.

이부분은 C++와 매우 비슷하기 때문에 C++경험이 있는 사람이라면 쉽게 적응할 수 있다.

자 이렇게 생성한 문자열은 이제 변경을 할 수 있다.

let mut s = String::from("hello");//변경을 위한 변수는 무조건 mut키워드를 명시해줘야한다.
s.push_str(", world!")
println!("{}",s);

이 예제를 보고 문자열 리터럴을 mut으로 선언하면 바꿀 수 있지 않을까 생각이 들 수도 있다.

fn main() {
    let mut s = "Hello";
    s.to_owned().push_str(", World!");
    println!("{}",s); 
}

이런 코드를 작성하고 실제로 코드를 동작해보면 에러는 나오지 않지만, 우리가 원하는 결과를 얻을 수 없다는걸 알 수 있다.

어떻게하면 정상적으로 작동할 수 있을까요?

이 코드가 정상적으로 작동하려면 결국은 String으로 받아야하기 때문에 변경할 예정이 있는 문자열 변수라면 그냥 String으로 선언하는게 깔끔하다는걸 알 수 있다.

그렇다면 왜 String은 변경이 가능하고, 문자열 리터럴은 변경이 불가능 한걸까?

메모리와 할당

이 글은 메모리의 구조를 설명하는 글이 아니기 때문에 자세히 설명할 수 는 없다. 프로그래밍 언어들은 일반적으로 하드 코딩된 문자열의 경우 최적화를 위해 미리 바이너리로 바꾸어버린다. 이게 가능한 이유는 이 문자열의 크기를 이미 알고 있고 이게 변하지 않을거라는(불변) 확신이 있기 때문이다.

그렇다면 String의 경우는 어떨까? String으로 선언된 문자열의 경우 그 크기가 변하지 않는다는 보장이 없다. 그렇기 때문에 별도의 메모리를 할당해야만 한다(우리는 이 메모리를 힙이라고 부르며, 이러한 할당을 동적할당이라고 한다)

이렇게 할당이라는 행위를 위해서는 두가지 조건이 필요하다.

  • 해당 메모리는 반드시 런타임에 운영체제에 요청해야한다.
  • String타입의 사용이 완료된 후에는 이 메모리를 운영체제에 다시 돌려줄 방법이 필요하다.

감이 빠른 사람이라면 이 두가지 조건이 무엇을 말하는지 눈치챈 사람도 있을거라고 생각한다. 사실 이 두가지에 대해 rust가 어떻게 동작을 하는지 명확히 설명할 수 있다면 소유권을 거의 다 이해한것이나 마찬가지이다.

우리는 앞에서 다른 언어의 메모리 관리방법을 간단히 설명했고, 그때 2가지의 종류로 구분했다. '프로그래머가 직접적으로 메모리를 관리해야하는 언어', 'GC가 관리를 해주는 언어'

그리고 rust 제3의 방법을 사용한다고 했었다. 이 3가지의 방법을 위의 조건을 만족하기 위해 어떻게 동작하는지 확인해보자

  • 해당 메모리는 반드시 런타임에 운영체제에 요청해야한다.
    • 프로그래머가 직접적으로 메모리를 관리해야하는 언어, GC가 관리를 해주는 언어, rust
      • 사용자가 메모리 할당을 요청한다.(allocate, new, from...)
  • String타입의 사용이 완료된 후에는 이 메모리를 운영체제에 다시 돌려줄 방법이 필요하다.
    • 프로그래머가 직접적으로 메모리를 관리해야하는 언어
      • 사용자가 직접해준다.(delete, free...)
    • GC가 관리해주는 언어
      • 사용자가 신경쓰지 않아도 GC가 알아서 쓰레기주소들을 정리해준다.
    • rust
      • 범위가 벗어나는 순간 해제한다.

우리는 앞에서 '범위'가 무엇을 의미하는지 이미 보았기 때문에 더 설명하지 않겠다.

어떤 타입의 변수가 범위를 벗어나면 drop함수를 호출해서 메모리를 해제한다(이 drop함수는 C++의 RAII패턴과 매우 유사하다. RAII패턴으로 구현된 것이 스마트 포인터이다)

이제 본격적으로 소유권이 실제 상황에서 어떻게 동작하는 지 알아보자

1. 이동(Move)

이전에 다른언어를 배워본 사람이라면 첫 단계부터 험난할 수도 있다.

let x = 5;
let y= x;

이런식으로 일반적인 정수형 데이터에 대해서는 우리가 아는 대로 복사가 진행된다. 그렇다면 String에 대해서도 이렇게 동작을 할까?

let s = String::from("hello");
let s1 = s;

이렇게 선언한뒤 s를 이용해 출력하고 하면 다음과 같은 오류를 볼 수 있을것이다.

이미 이동을 한뒤 값을 대여하고 있어 발생하는 오류라고 적혀있다. 어째서 rust에서는 복사를 허용하지 않았을까?

바로 이중해제에러 때문이다.

rust에서 에러해제 시점은 범위를 벗어났을때라는것을 기억할 것이다. 기본적으로 복사를 할경우 포인터의 길이와 용량만 복사를 한다. 데이터 자체를 복사할 경우 오버헤드가 그만큼 커져 성능이 떨어지기 때문이다.

이제 예제를 다시 살펴보자, s가 s1에게 주소를 복사해주었다면 어땠을까? s를 해제하고 s1을 해제하려는 순간, 메모리 오류가 발생한다. 이제 해제된 쓰레기 값에 대해 다시 해제 동작을 수행하므로 당연한 일이다.

그렇기 때문에 rust에서는 기본적으로 복사(copy)가 아닌 이동(move)를 원칙으로 하고 있다.

하지만 프로그램을 만들다보면 복사본이 필요할 수도 있다. 이런 경우를 대비해서 rust에서는 한가지 함수를 지원하는데, 이 함수가 바로 clone이다. clone을 통하면 데이터를 복사해 정상적으로 동작하게 할 수 있다.

2. 복사(Copy)

앞에서 간단히 살펴보긴 했지만, rust가 복사를 지원하는 경우는 정해져있다.

  • 컴파일 시점에 실제 데이터의 크기를 알 수 있는 경우

즉 문자열 리터럴, 정수형 데이터, boolean 같은 값들을 의미한다. 그렇다면 Copy는 어떻게 동작을 하는걸까?

문자열 리터럴, 정수형 데이터 등에는 Copy라는 trait가 특별한 특성을 제공해준다(이 부분은 나중에 따로 설명하겠다. 그냥 지금은 그렇구나 하고 넘어가자)
이 Copy는 아무곳에서 만들어서 쓸 수가 없는데 타입의 일부에 Drop 트레이트(trait)가 적용되어있으면 Copy trait를 사용할 수 없다.

예를 들어 새로운 타입을 정의하는데 그 안에 String같은 값을 사용해야한다면 그 타입은 Copy trait를 이용할 수 없게 되는것이다.

복사가 허용되는 타입은 다음과 같다.

  • u32와 같은 정수형 타입
  • true와 false값만을 가지는 boolean
  • 문자타입, char
  • f64같은 부동 소수점 타입
  • Copy 트레이트가 적용된 타입을 포함하는 튜플
    EX.(i32,i32)

소유권과 함수

함수에서 값을 받아오고, 파라미터로 전달하는 과정에서도 역시나 소유권이 적용되기 때문에 타입에 따라 복사나 이동이 발생한다.

일단 다음 예를 한번 봐보자

fn main() {
    let s = String::from("hello");  // s가 스코프 안으로 들어왔습니다.

    takes_ownership(s);             // s의 값이 함수 안으로 이동했습니다...
                                    // ... 그리고 이제 더이상 유효하지 않습니다.
    let x = 5;                      // x가 스코프 안으로 들어왔습니다.

    makes_copy(x);                  // x가 함수 안으로 이동했습니다만,
                                    // i32는 Copy가 되므로, x를 이후에 계속
                                    // 사용해도 됩니다.

} // 여기서 x는 스코프 밖으로 나가고, s도 그 후 나갑니다. 하지만 s는 이미 이동되었으므로,
  // 별다른 일이 발생하지 않습니다.

fn takes_ownership(some_string: String) { // some_string이 스코프 안으로 들어왔습니다.
    println!("{}", some_string);
} // 여기서 some_string이 스코프 밖으로 벗어났고 `drop`이 호출됩니다. 메모리는
  // 해제되었습니다.

fn makes_copy(some_integer: i32) { // some_integer이 스코프 안으로 들어왔습니다.
    println!("{}", some_integer);
} // 여기서 some_integer가 스코프 밖으로 벗어났습니다. 별다른 일은 발생하지 않습니다.

이 코드를 보고 왜 이렇게 동작을 하는지 설명을 할 수 있다면 이동과 복사에 대한 개념은 마스터 했다고 볼 수 있다.
(사실 주석을 참고하면 아주 쉽게 이해할 수 있다. 복잡해 보인다고 포기하지 말자)

리턴값과 범위

fn main() {
    let s1 = gives_ownership();         // gives_ownership은 반환값을 s1에게
                                        // 이동시킵니다.

    let s2 = String::from("hello");     // s2가 스코프 안에 들어왔습니다.

    let s3 = takes_and_gives_back(s2);  // s2는 takes_and_gives_back 안으로
                                        // 이동되었고, 이 함수가 반환값을 s3으로도
                                        // 이동시켰습니다.

} // 여기서 s3는 스코프 밖으로 벗어났으며 drop이 호출됩니다. s2는 스코프 밖으로
  // 벗어났지만 이동되었으므로 아무 일도 일어나지 않습니다. s1은 스코프 밖으로
  // 벗어나서 drop이 호출됩니다.

fn gives_ownership() -> String {             // gives_ownership 함수가 반환 값을
                                             // 호출한 쪽으로 이동시킵니다.

    let some_string = String::from("hello"); // some_string이 스코프 안에 들어왔습니다.

    some_string                              // some_string이 반환되고, 호출한 쪽의
                                             // 함수로 이동됩니다.
}

// takes_and_gives_back 함수는 String을 하나 받아서 다른 하나를 반환합니다.
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프
                                                      // 안으로 들어왔습니다.

    a_string  // a_string은 반환되고, 호출한 쪽의 함수로 이동됩니다.
}

만일 우리가 s를 takes_ownership 함수를 호출한 이후에 사용하려 한다면, 러스트는 컴파일 오류를 내준다. 이러한 정적검사는 실수를 방지하기 위해 마련된 기능이다.(아마 C나 C++ 개발을 해본사람이라면 이래서 rust 컴파일러를 사랑 할 수 밖에 없는것 같다.)

하지만.. 변수의 소유권의 규칙은 매번 같은 패턴을 따르기 때문에, 제대로 코딩을 하려면 모든 함수가 소유권을 확보하고 그 값을 리턴하는 방식으로 짜야하지만, 이는 매우 거추장 스러우며 짜증나는 작업이다. 예를 들어 함수에 전달한 값을 다시 사용하기 위해 매번 리헌 해줘야한다고 생각해봐라, 생각만 해도 짜증이 나지 않는가

물론 아래 예 처럼 튜플을 이용하면 그런 문제를 해결 할 수 있다. 하지만 이 방법을 보편적으로 이용하기에는 매우 불편하다.

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()함수는 문자열의 길이를 반환합니다.

    (s, length)
}

이러한 문제를 해결하는 기능은 참조(Reference)이다. 참조부터는 다음글에서 이어서 적도로 하겠다.

profile
다양한 경험을 하는걸 좋아하는 개발자입니다.

0개의 댓글