콘텐츠로 건너뛰기
Home » Rust 기초 (1)

Rust 기초 (1)

데이터의 변수와 메모리 관리 방식

Rust는 정적 타이핑 언어로, 컴파일 시점에 모든 변수의 타입이 결정됩니다. Rust는 다양한 데이터 타입을 가지고 있는데, 이러한 타입들은 메모리를 사용하는 특성에 따라 스칼라 타입과 복합형으로 구분됩니다. 스칼라 타입은 하나의 단일 값을 의미하는데, 불리언(bool), 정수, 실수, 문자(char) 타입등이 여기에 속합니다. 복합형은 한 가지 정보로만 구성되는 데이터가 아니며, 같은 타입이어도 크기가 다를 수 있는 유형입니다. 문자열(String) 같은 타입이 복합형에 해당합니다.

스칼라 타입은 단일 값이며, 항상 그 크기가 고정되어 있습니다. 따라서 이러한 타입들은 스택에 저장됩니다. 문자열과 같은 복합형은 데이터를 힙 영역에 저장합니다. 힙 영역에 데이터를 쓰기 위해서는 필요한 크기 만큼의 공간을 할당받아야 하고, 사용이 끝난 메모리는 반환해야 합니다. 힙 메모리를 관리하는 방식으로 널리 사용되는 방식 중에는 가비지컬렉터(GC)를 사용하는 방식과 수동으로 메모리를 할당/해제하는 방식이 있습니다. Rust는 이런 전통적인 메모리 관리와 약간 다른 방법을 사용하는데, 변수가 정의된 스코프를 벗어나는 시점에, 해당 영역에서 정의된 변수가 사용하는 메모리 공간을 강제로 반환합니다. 이 때 주의해야 할 것은, 다른 스코프에서 계속 사용해야 할 데이터를 미리 반환해서는 안되며, 한 번 해제한 메모리 위치를 다시 해제해서도 안됩니다. 또 할당해놓은 메모리를 해제하는 것을 누락해서도 안되죠.

그래서 Rust는 메모리를 할당하여 변수에 대입하는 시점에, 그 변수가 해당 메모리 혹은 메모리를 점유한 값에 대한 소유권을 가지고 있다고 기록합니다. 그리고 스코프를 벗어날 때 내부의 모든 변수에 대해서 그 변수가 소유권을 가지고 있는 데이터의 메모리를 정리합니다. (이를 drop이라고 표현합니다.)

변수에 값을 할당하는 let 구문을 사용하면 해당 변수가 그 값을 소유하는 것으로 간주합니다.

fn main() {
    let a = 24;   // 1)
}                 // 2)

위 예의 1) 위치에서 새로운 변수 a가 스코프로 들어옵니다. 그리고 2) 에서 스코프를 빠져나가는 시점에 메모리를 반환합니다.

그런데 우리는 let 구문의 우변(등호의 오른쪽)에 값이나 표현식이 아닌 다른 변수를 사용할 수도 있습니다. 다음과 같이 말이죠.

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

이 때, let y = x; 의 동작을 상상해보면 새로운 정수값 5가 생성되고 y라는 이름에 바인딩됩니다. 그리고 이 값은 스택에 추가되어 실제로 2개의 정수 값이 스택에 존재하게 됩니다. (실제로 컴파일된 코드를 실행할 때 메모리 내에서 벌어지는 일도 이와 같습니다). 하지만 여기서 5는 i32 타입의 정수형으로 스칼라 값입니다. 문자열에 대해서도 같은 동작이 가능할까요?

let x = String::from("5");
let y = x;
println!("{}, {}", x, y);  // ERROR!!!

이 코드는 세 번째 라인에서 “borrow of moved value: ‘x'”라는 에러가 나면서 컴파일이 되지 않습니다. 변수가 힙에 할당된 데이터와 바인딩되어 있다면 이 변수를 다른 변수에 대입할 때에는 데이터가 복제되지 않습니다. (다른 프로그래밍 언어에서 말하는 얕은 복사와 깊은 복사 중 얕은 복사에 해당하는 동작에 가깝다고 볼 수 있습니다.) 왜냐하면 힙에 저장된 데이터를 복제하려면, 정확히 같은 크기만큼의 메모리를 또 할당받아야 하기 때문에, 여기서는 명시적으로 메모리를 할당하고 데이터가 복제되는 일이 벌어지는 대신, let y = x; 가 작동할 때, String 타입 데이터의 소유권이 x에서 y로 이전(move)됩니다. 소유권이 이전되면서 x는 더 이상 유효하지 않은 변수가 됩니다. 따라서 소유권 이전 이후에 기존 변수인 x를 다른 곳에서 액세스하게 되면, 에러가 나면서 컴파일이 되지 않습니다. 이 부분이 다른 프로그래밍 언어와 Rust의 코드에서 가장 크게 다른 부분입니다.

함수

함수의 파라미터는 함수 내부의 지역 변수처럼 취급되는데, Rust도 예외는 아닙니다. 함수를 호출할 때에는 현재 스코프의 변수를 함수의 인자로 전달하는 것이 가능한데, 이 동작은 대입 구문과 완전히 동일한 매커니즘으로 작동하여, 함수 호출 시 변수를 넘기면 변수가 가지고 있던 소유권이 함수의 인자쪽으로 넘어가 버립니다. 즉, 함수 호출 시 넣어주는 인자에 변수가 포함되면, 변수는 그 시점부터 데이터에 대한 소유권을 잃고 사용할 수 없는 값이 됩니다.

fn main() {
    let x = String::from("world");
    greet(x);                       // 1)
    println!("{}", x);              // 3) ERROR!!
}

fn greet(name: String) {
    println!("hello, {}", name);
}                                   // 2) name에 대한 scope 종료 

위 예제에서는 main에서 생성한 String타입의 변수 x를 함수 greet의 인자로 전달합니다. 이 과정에서 소유권이 함수 greet의 인자 name으로 이전되고, x는 더 이상 유요한 변수가 아니게 됩니다. 함수 greet의 실행이 끝나게 되면 해당 스코프가 종료되고, name에 대한 드롭이 이루어지면서 생성됐던 문자열 데이터가 제거되고 메모리가 반환됩니다. 이후 다시 main에서 x의 값에 액세스하려고 시도하기 때문에 에러가 발생합니다.

만약 3)의 코드를 삭제한다면 이 코드는 문제없이 컴파일 될 것입니다. 만약 greet을 호출한 이후에도 그 값을 계속 사용해야 한다면 어떻게 해야 할까요? 당장 생각해볼 수 있는 방법은 greet 함수가 인자로 전달 받은 값을 리턴하는 것입니다. 함수가 특정한 변수의 값을 리턴하면 다른 변수에 다시 대입하는 것처럼 소유권을 잃게 됩니다. 소유권은 해당 함수의 리턴값을 대입하게 되는 호출부의 변수가 갖게 되겠죠.

fn main() {
    let x = String::from("world");
    let y = greet(x);
    println!("{}", y);
}

fn greet(name: String) -> String {
    println!("hello, {}", name);
    name      // <-- ; 이 있으면 구문으로 인식되면서 오류가 됩니다.
}

이런 식으로 함수가 인자로 받은 값을 다시 리턴하여 소유권을 main 함수로 되돌려주는 방식은 상당히 번거롭습니다. 만약 제 3의 값을 리턴해야 하는 함수라면 원래의 인자와 새로운 리턴값을 한 번에 리턴해야 하고, 이 함수를 사용해야하는 코드 역시 지저분해집니다.

이런 귀찮음을 해결할 수 있는 수단으로 참조(reference)가 소개됩니다. 참조는 특정한 변수에 대한 포인터와 비슷한 것으로 이해하면 되는데, C의 포인터 처럼 고정된 크기를 가지며 소유권을 갖지 않습니다. 참조 타입의 변수는 어떤 값도 소유하지 않으므로, 스코프가 끝나는 지점에서 메모리를 해제할 필요가 없습니다. 따라서 보통 함수에서는 외부의 값을 빌려온다고 생각하고, 함수 인자를 참조로 선언하는 경우가 일반적이라 할 수 있습니다.

fn greet(name: &String) {
    println!("Hello, {}", name);
}

파라미터 name의 타입을 &String으로 표기했습니다. 이것은 String 타입에 대한 참조라고 합니다. 함수의 본문 내에서 name은 마치 String 타입의 값인 것처럼 사용이 가능합니다. 대신에 참조를 인자로 받는 함수를 호출할 때에는 참조를 “만들어서” 호출해주어야 합니다. 즉 결과적으로 코드를 보면 명시적으로 참조를 넘기고 있다는 것이 확연히 드러납니다.

fn main() {
    let x = String::from("world");
    greet(&x);
    println!("{}", x);
}

참조를 만들어서 그 변수의 값을 사용하는 것을 빌려온다borrow라고 표현합니다. 레퍼런스는 소유권을 빌리기만 하기 때문에 함수 greet()를 호출한 후에도 변수 x의 문자열 객체에 대한 소유권은 그대로 유지되며, 계속해서 사용할 수 있습니다. 더 끝까지 간다면 문자열을 출력한 후, main() 함수의 스코프가 끝나는 시점이 오면, x의 수명 주기는 끝나고, drop이 일어납니다. 이 때 문자열에게 할당되었던 메모리도 반환됩니다.

가변 참조 (mutable reference)

Rust에서 참조는 기본적으로 불변immutable입니다. 따라서 참조를 사용하여 값을 전달하는 것은 기본적으로 소유권에 대한 이전 없이 ‘읽기’를 허용해주는 것이라 할 수 있습니다. 하지만 실제로 유용한 프로그램을 작성하려면 데이터를 실행 시간에 동적으로 변경해야 하는 경우도 많습니다. 그렇다면 인자로 전달받는 값을 변경해야 할 때에는 참조가 아닌 변수를 그대로 전달해야 할까요?

이런 문제를 해결하기 위해서 Rust에는 가변참조라는 기능도 있습니다. 가변참조는 원본의 값을 변경하는 기능을 지원하는 참조입니다. 이를 변수의 파라미터로 정의하는 것과, 실제로 호출할 때 사용하는 방식에는 약간의 차이가 있습니다.

함수 시그니처에서 인자의 타입이 문자열인 경우 x: String 과 같이 표현합니다. 만약 파라미터 x를 문자열에 대한 참조로 받을 경우, (x: &String) 으로 타입 앞에 & 을 붙여서 참조임을 선언합니다. 가변 참조라면 (x: &mut String) 으로 마치 키워드처럼 &mut를 써서 가변 참조임을 표현할 수 있습니다.

그리고 함수를 호출할 때에는 참조는 greet(&x) 와 같이 명시적으로 참조를 만들어서 전달했는데, 가변 참조인 경우에도 greet(&mut x) 와 같이 가변참조를 명시적으로 만들어서 전달해야 합니다. 이 두 경우에서도 함수 내부에서는 해당 파라미터는 이름으로만 사용합니다.

fn main() {
  let mut x = String::from("wor");   // 1)
  greet(&mut x);                     
  println!("{}", x);                 // world 가 출력됨
}

fn greet(name: &mut String) {        // 2) 가변참조를 사용하는 함수
  println!("Hello, {}", name);
  name.push_str("ld");
}
  1. 변경 가능한 참조를 사용하기 위해서는 원래의 변수가 변경가능해야 합니다. 따라서 let mut x로 변수를 정의해야 합니다.
  2. 변경 가능한 참조를 인자로 받는 함수에 참조를 전달하기 위해서는 &mut x 로 변경가능한 참조를 만들어서 전달해야 합니다.
  3. 변경 가능한 참조를 인자로 받는 함수는, 파라미터의 타입을 &mut String 으로 명시해주어야 합니다.

C에서는 변수 이름 앞에 &을 붙여서 해당 변수의 메모리 주소를 사용할 수 있습니다. 이것은 메모리 주소에 대한 표기법이지, 이 때 포인터가 따로 생성되는 것은 아닙니다. 하지만 Rust에서는 &x 라는 표현을 사용하는 시점에, x의 값을 빌리는 참조가 만들어집니다. 변경 가능한 참조를 만들기 위해서는 &mut 을 사용하여 만들 수 있습니다. 변경 가능한 참조를 만들기 위해서는 원래의 변수가 변경가능해야 한다는 제약이 있습니다.

케이스 참조가변참조
함수 시그니처fn greet(x: &String) { ...fn greet(x: &mut String) { ...
함수 내부에서 사용이름으로만 사용 ( x )이름으로만 사용 ( x )
호출할 때greet(&x)greet(&mut x)

변경할 수 있는 참조를 만들 기 위해서는 원래 변수가 mut 과 함께 선언되어 mutable하다는 것이 명시되어 있어야 합니다. 소유권을 이전하는 경우에는 원래의 변수가 mutable하지 않더라도 상관 없습니다. 소유권을 넘겨받는 쪽에서 mut 를 인자 이름 앞에 사용해주면 변경 가능한 변수로 사용하게 됩니다. 그래서 아래 코드는 의외로 잘 컴파일되고 실행도 됩니다.

fn main() {
  let x = String::from("world")

  // x는 `let`으로 선언되었지만, 소유권을 greet()에 넘겨주게 된다. 
  // 이때 greet()의 시그니처에서 인자값은 mut으로 선언되어 있고, 그 내부에서 값을 변경할 것이다.
  greet(x);

  // --> x는 소유권이 없으므로 더 이상 데이터를 사용할 수 없음
}

fn greet(mut name: String) {  
       // <-- 애초에 파라미터가 mut 할 뿐이지, 여기로 넘겨지는 값이 불변인지 아닌지는 관심사가 아님
  name.push_str("!!")
  println!("Hello, {}", name);
}
  

가변 참조의 제약

가변 참조를 사용하면 소유권을 갖는 변수의 스코프 외부에서도 변수의 데이터를 조작할 수 있게 됩니다. 그런데 스코프 외부에서 값이 변경 가능해질 가능성에 대해서는 어떻게 해야 할까요?

Rust에서는 외부 스코프에서 값이 변경되는 것을 걱정할 필요는 없습니다. 언어차원에서 가변참조는 1개만 가질 수 있게끔 제약이 되기 때문입니다. 동시에 두 곳 이상에서 같은 데이터를 변경하려고 하는 시도 자체를 원천적으로 차단합니다. 이 뿐 만이 아니라, 같은 데이터를 동시에 한쪽에서는 읽으려하고, 다른 한쪽에서는 변경하려고 할 때에도 동시성에 의한 데이터 손상 문제가 야기될 수 있는데, Rust에서는 가변 참조는 1개 객체에 대해서 1개만 존재하도록 강제되기 때문에, 데이터 안전성에 대해서는 크게 염려하지 않아도 됩니다.

두 개의 변수 A와 B가 같은 리소스를 액세스할 때 데이터 경쟁이 발생할 수 있습니다. 두 변수가 모두 읽기만 한다면 사실 이것은 큰 문제가 아닙니다. 그런데 A와 B 중 한쪽이 동시에 액세스하면서 쓰기를 시도하는데, 둘 사이의 동기화 매커니즘이 없다면 A가 값을 읽는 중간에 B가 변경해버려서 A가 망가진 데이터를 읽고 사용할 수 있는 문제가 있습니다. 보통 이런 문제가 발생했을 때의 결과는 ‘정의되지 않은’ (무슨 일이 일어날지 모르는) 동작을 하는데, 재현 및 분석이 매우 어렵기 때문에 역사적으로도 유서깊은 골칫덩이 입니다.

Rust에서는 변경가능한 참조가 있는 경우, 다른 참조를 사용할 수 없게 만들면서 참조간의 데이터 경쟁이 일어나지 않도록 합니다. 데이터 경쟁이 일어날 수 있는 경우를 컴파일러가 사전에 에러로 감지할 수 있기 때문에, 아주 주의깊은 프로그래머가 아니더라도 이러한 실수를 하지 않도록 도와줍니다.

  1. 변경가능한 참조가 있으면 다른 참조를 만들 수 없음
  2. 다른 참조가 있으면 변경 가능한 참조를 만들 수 없음
  3. 불변 참조만 있는 경우에는 다른 불변 참조를 추가로 만들 수 있음
fn main() {
  let mut x = String::from("hello");
  let y = &x;
  x.push_str(", world"); // <-- ERROR: 
                         // push_str 은 x를 변경하기 때문에 변경 가능한 참조를 만드려고 함. 
                         // 그러나 이미 immutable로 대여했기 때문에 mutable로 대여할 수 없음
  println!("{}", y);
}

앞서도 한 번 언급했지만, mutable한 변수의 데이터를 변경하기 위해서는 함수나 메소드를 이용해야 하는데, 이 때 암묵적으로 해당 변수의 변경가능한 참조를 만드려고 시도하게 됩니다. 따라서 현재 스코프내에서 값을 변경하려고 시도하는 코드가 포함되어 있다면, 같은 스코프 내에서는 다른 함수에 해당 함수를 빌려줄 수 없다는 황당한 결론을 얻을 수 있습니다.

따라서 변경이 발생하는 코드 앞에서, { ... } 블럭을 사용하여, 하위 스코프를 만들고, 이 스코프 내에서 불변 참조를 만들어서 사용합니다. 이렇게 하면 스코프가 끝날 때 불변 참조가 파괴되기 때문에, 그 이후에 가변참조가 생성되면서 소유권을 빌려가게 되는 것에는 문제가 제기되지 않습니다.

fn main() {
  let mut x = String::from("Hello");
  {
    let y = &x;
    println!("{}", y);
  } // <-- y의 스코프가 끝남
  x.push_str(", world");
  println!("{}", x);
}

부록 – 참조의 유효성

Rust에서 참조는 비교적 Lazy하게 만들어지는 것처럼 보입니다. 앞에서 변경 가능한 변수의 불변 참조를 만드는 예에서 마지막에 참조를 사용하는 부분을, 참조가 아닌 원래 변수를 사용하도록 변경해보겠습니다.

fn main() {
  let mut x = String::from("hello");
  let y = &x;
  x.push_str(", world");
  println!("{}", x);
}

이 코드는 변수 y를 선언만 하고 사용하지 않았다는 경고가 뜰 뿐, 정상적으로 컴파일 됩니다. 이것은 실제로 참조에 액세스하려는 시점에 참조가 만들어지는 것인지, 아니면 스코프 내에서 해당 참조를 사용하지 않았기 때문에 무시하는 것인지는 좀 더 공부를 해봐야 하는 것 같습니다.

정리

이상으로 Rust의 메모리 관리 방식인 소유권과 대여에 대해서 간단히 살펴보았습니다.

  • 메모리를 할당/해제하는 책임을 데이터에 대한 소유권을 갖는 변수를 1개로 한정하여 해결한다.
  • 외부 스코프로 값을 주고 받기 위해서 참조를 사용할 수 있으며, 참조는 데이터 경쟁을 차단하기 위해 변경 가능한 참조인 경우에는 한 군데에서만 사용하도록 한다.

변수의 타입과 메모리 소유권

  • 크기가 고정된 타입 – 스택 영역에 저장
  • 복합형 – 힙 영역에 저장. 메모리를 할당받고 반환해야 함
  • 메모리를 할당할 때, 대입되는 변수가 그 메모리를 소유함.
  • 변수가 스코프를 벗어날 때, 변수의 값은 파괴되거나 메모리를 반환함.(드롭) 참조인 경우에는 드롭하지 않음
  • 변수를 다른 변수에 바인딩하면 소유권 이전. 스칼라값은 복사됨
  • 변수를 함수에 전달할 때에도 소유권 이전.
  • 소유권 이전을 막기 위해서는 참조를 사용

참조

  • 명시적으로 &refname 이나 &mut refname 으로 표기하여 불변∙가변참조를 생성할 수 있음.
  • 가변참조는 1개의 참조만 유효함
  • 가변참조가 있다면 불변참조를 만들 수 없음. 단, 하위 스코프를 만들고 하위 스코프에서 불변참조만 사용한다면 해당 스코프에서는 허용됨

댓글 남기기