소유권

언어 차원에서 메모리가 낭비되는 것을 막아준다는 점은 러스트를 상당히 독특한 프로그래밍 언어로 만들어 줍니다. 그 중 핵심이 되는 시스템 중 하나는 다른 프로그래밍 언어에서는 찾기 힘든 소유권ownership 시스템입니다. 이번 글에서는 소유권에 대해 알아봅니다.


소유권이란?

메모리 추적

스택 영역 vs 힙 영역

모든 프로그램은 작동하는 동안 운영체제로부터 메모리를 빌려오는데, 이 영역을 힙heap 영역이라 부른다. 그런데 딱 필요한 만큼 메모리를 빌려올 거란 보장은 없다. 구현 언어나 코드에 따라 사용하지 않는 메모리를 계속 가지고 있을 수도 있고, 필요한 메모리를 채 사용하기도 전에 반환해 버릴 수도 있다. 반면 값과 크기가 명확하게 결정되어 있는 데이터가 저장되는 메모리 영역을 스택stack 영역이라 부르는데, 이 영역은 힙 영역에 비해 빠른 대신 사용이 제한적이다. 반대로 힙 영역은 스택 영역에 비해 자유로운 사용이 가능하지만 대신 메모리 할당 등 해야 할 작업이 더 많아 더 느리다.

메모리 관리

어쨌든 스택과 힙 모두 프로그램이 작동하는 동안 다루는 메모리 영역이고, 그래서 메모리를 때에 맞게 적당한 만큼 가져오고 반환하는 것이 중요한데, 언어 차원에서 힙 영역의 메모리를 관리하는 기존의 방법에는 크게 두 가지가 있다.

러스트는 둘 중 어느 방법도 채택하지 않고, 대신 소유권이라는 새로운 방법으로 메모리를 관리한다.

소유권

소유권은 러스트에서 메모리를 관리하는 여러 규칙들의 모음이며, 컴파일러는 이 규칙 중 하나라도 위반하면 컴파일을 중단한다. 소유권을 이해하기 위해서는 러스트에서 데이터와 변수의 어떻게 연결되는지에 주목할 필요가 있다. 다른 언어에 비해 러스트는 유독 데이터의 복사나 유효 범위에 민감하다는 인상을 주는데, 이를 이해하기 위해서는 스코프scope라는 개념을 이해할 필요가 있다.

스코프

어떤 변수의 스코프란, 프로그램 내에서 해당 변수가 유효한 범위를 말한다. 변수는 선언된 시점부터 선언문이 위치한 현재의 스코프를 벗어날 때까지 유효한데, 아래 예시 코드에서는 중괄호가 스코프가 된다. 스코프 내에 나타나면 이 변수는 유효하며, 스코프 바깥으로 벗어나기 전까지만 유효하다.

{   // s는 아직 선언되지 않아서 여기서는 유효하지 않습니다

	let s = String::from("hello"); // s는 여기서부터 유효합니다
	
	// s로 무언가를 합니다
	
} // 이 범위는 이제 끝났고, s는 더 이상 유효하지 않습니다

프로그램이 실행된 이후에 힙 영역에서 사용하는 메모리는 언제나 고유 APIApplication Programming Interface를 통해 다루어지는데, 이는 그렇게 보이지 않을 때도 마찬가지다. 가비지 컬렉터Garbage Collector가 있다면 메모리가 자동으로 해제되기 때문에 프로그래머가 신경 쓸 필요가 없겠지만 코드가 예기치 못한 방향으로 동작할 수도 있으며, 수동으로 관리하는 경우 프로그래머가 놓친 부분이 있거나 메모리를 너무 일찍 풀어버린다면 메모리를 낭비하거나 메모리를 제때 쓰지 못하는 상황이 발생하게 된다. 이 문제를 해결하기 위해 러스트에서는 변수가 스코프 바깥으로 벗어날 때 drop이라는 특별한 함수를 자동으로 호출한다. drop이라는 함수는 힙 영역에 할당된 메모리를 해제하는 함수이며, 닫힌 중괄호}가 나타나면 자동으로 호출된다.

소유권은 하나다

스코프는 수많은 프로그래밍 언어에서 사용하는 일반적인 개념이지만, 러스트에서 스코프를 넘나드는 상황은 다른 언어들과 조금 다르다. 러스트에서는 어떤 변수가 선언될 때 변수가 값의 소유권을 가진 소유자owner가 된다고 말한다. 이 소유권의 두드러지는 특징 중 하나는 어떤 값의 소유권이 유일무이하다는 점인데, 다시 말해 값을 소유하는 변수는 어떤 상황에서도 오직 하나라는 것이다.

소유권 이전

그래서 다른 언어와 달리, 정수형 값처럼 이미 크기가 정해져서 값이 복사되는 경우를 제외하면 러스트의 대입 연산은 일반적으로 우변의 변수에서 좌변의 변수로 값의 소유권을 이전하는 작업이다.[1] 여기서 s1의 소유권은 s2로 옮겨 갔기 때문에 s1은 더 이상 유효하지 않다. 즉, 대입 연산이 이루어지고 나서는 더 이상 s1을 사용할 수 없다.

let s1 = String::from("hello"); 
let s2 = s1;
println!("{}, world!", s1); // 이 줄은 컴파일 오류를 발생시킵니다.

반면 y = x 에서는 x값의 복사본이 y와 연결되어 스택에 두 개의 5 값이 생긴다.

let x = 5;
let y = x;

소유권을 건드리지마

이렇게 소유권이라는 개념이 존재하는 덕분에 다른 언어에서 충분히 발생할 수 있는 실수가 러스트에서는 일어나지 않기도 한다. 함수에 값을 전달하고 값을 돌려받을 때 소유권의 개념은 프로그램이 실행 중일 때 발생할 수 있는 여러 실수들을 컴파일 시간에 바로잡을 수 있게 해준다. 함수로 값을 전달하면 그 값의 소유권도 같이 함수로 이동한다. 그래서 stakes_ownership 함수에 전달하고 나서 사용하려 하면 컴파일 타임에 에러가 발생한다. 함수의 입장에서는 매개변수에 값이 할당되면서 소유권 또한 함수로 넘어온다. 함수를 벗어나면서 }로 인해 drop 함수가 자동으로 호출되기 때문에 함수가 끝나고 main 함수로 돌아온 이후에도 s는 유효하지 않다.

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가 스코프 밖으로 벗어납니다. 별다른 일이 발생하지 않습니다.

함수가 값을 반환할 때도 반환 값의 소유권이 같이 움직인다. 함수의 반환값은 소유권과 함께 이동하기 때문에 s1, s2, s3 모두 함수가 반환하는 값을 그 소유권과 함께 받는다.

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가 스코프 밖으로 벗어나면서 버려집니다. s2는 이동되어서 아무 일도
  // 일어나지 않습니다. s1은 스코프 밖으로 벗어나고 버려집니다.

fn gives_ownership() -> String {             // gives_ownership은 자신의 반환 값을
                                             // 자신의 호출자 함수로 이동시킬
                                             // 것입니다

    let some_string = String::from("yours"); // some_string이 스코프 안으로 들어옵니다

    some_string                              // some_string이 반환되고
                                             // 호출자 함수 쪽으로
                                             // 이동합니다
}

// 이 함수는 String을 취하고 같은 것을 반환합니다
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프 안으로
                                                      // 들어옵니다

    a_string  // a_string이 반환되고 호출자 함수 쪽으로 이동합니다
}

그런데 이렇게 되면 러스트에서 매개변수가 있는 함수를 호출할 때마다 값을 이동시켜야 하는데 이렇게 되면 기존에 있던 값을 함수 호출 이후에는 사용하지 못한다. 설사 함수로부터 돌려 받더라도 그 값은 기존의 값이 아닌 새로운 값이다. 이를 해결해주는 것이 바로 참조reference와 대여borrow다.

참조와 대여

이 글에서 다루는 참조와 대여는 기초적인 수준이다. 실제로 참조와 역참조가 이루어지는 과정을 이해하기 위해서는 트레이트를 알아야 하며, 해당 섹션에서 다루는 내용보다 복잡하다.

참조

러스트에서 어떤 값을 참조하는 아이템을 참조자reference라고 부른다. 참조자는 어떤 데이터을 가리키지만, 그 데이터를 소유하지 않는다. 어떤 것을 가리킨다는 점에서 포인터의 일종이지만, 참조자는 살아있는 동안 가리키는 값referent이 무조건 유효하다.

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

fn calculate_length(s: &String) -> usize { 
	s.len()
}

참조자를 나타내는 기호는 앰퍼센드& 기호다. 변수 앞에 붙으면 해당 변수의 참조자를 나타내고, 매개변수 앞에 붙으면 해당 매개변수가 참조자 타입임을 나타낸다. 그래서 &s1s1의 참조를 생성하고, &String은 매개변수 s가 참조자 타입임을 나타낸다. 참조를 통해 값의 소유권을 넘기지 않아도 다른 함수에서 해당 값을 사용할 수 있고, 마찬가지로 함수가 종료될 때 값이 버려지지 않기 때문에 값을 반환할 필요도 없다. 이렇게 참조자를 만드는 행위를 대여borrowing라고 부르며, 참조자는 말 그대로 어떤 값을 빌렸다가 돌려준다. 참고로, 이때의 참조자는 참조하는 값을 바꿀 수 없기 때문에 불변 참조자다.

역참조

반대로 어떤 참조자의 값에 직접 접근하는 것도 가능한데, 이를 역참조dereferencing이라 부른다. 역참조를 나타내는 기호는 애스터리스크* 기호이며, 어떤 참조자 앞에 붙으면 다음과 같이 참조자 타입이 가리키는 값에 직접 접근할 수 있다.

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{:?}", map);
}

or_insert 메서드는 참조자를 반환하는 메서드다. 그렇기 때문에 참조자 타입인 count가 가리키는 값을 직접 바꾸려면 count를 역참조해야 한다.

가변 참조자

변수가 그렇듯 참조자도 기본적으로 불변이지만, mut 키워드를 통해 가변 참조자를 만들 수 있다. 가변 참조자를 사용하면 소유권을 가져가지 않고 참조하는 변수의 값을 바꿀 수 있다.

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

&mut를 통해 s의 가변 참조자를 생성하기 때문에 소유권을 넘기는 일 없이 change 함수에 잘 전달될 수 있고, 함수에서 참조자를 통해 String타입의 push_str 메서드로 s의 값을 바꿀 수 있으며, 함수가 종료된 뒤에도 s는 유효하다. 이렇게만 들으면 가변 참조자가 좋아 보이지만, 어떤 값에 대한 가변 참조자는 두 개 이상 존재할 수 없다.

둘 이상의 가변 참조자가 생기면 컴파일 에러가 발생한다. 이는 러스트가 데이터 경합data race[2]을 막기 위해 유효한 가변 참조자가 두 개 이상 존재하는 상황을 방지하기 위한 것이다. 여러 포인터가 공유하는 데이터가 교통정리 없이 다루어지는 상황을 막기 위해, 쓰기가 가능한 포인터가 여러 개 연결되어 있는 상황 자체를 없앤 것이다. 하나의 데이터에 대해 읽기만 가능한 기존의 불변 참조자는 여러 개가 동시에 존재할 수 있지만, 가변 참조자는 두 개 이상 존재하지 못할 뿐만 아니라 불변 참조자가 있다면 아예 존재할 수 조차 없다.

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

    let r1 = &s; // 문제없음
    let r2 = &s; // 문제없음
    let r3 = &mut s; // 큰 문제

    println!("{}, {}, and {}", r1, r2, r3);

슬라이스

슬라이스는 컬렉션collection[3]의 연속된 요소들을 참조하는 참조자의 일종이다. 그 중 문자열 슬라이스는 슬라이스를 사용하는 가장 대표적인 예시다.

let s = String::from("hello world");
let hello = &s[0..5]; // &s[..5]와 동일
let world = &s[6..11];

대괄호[] 안에는 슬라이스의 대상이 되는 구간이 들어가는데, &[a..b]라고 되어 있다면 a번째 요소부터 b-1번째 요소까지 슬라이스를 생성한다는 뜻이다. 만약 첫번째 인덱스부터(인덱스 0부터) 시작한다면 앞의 숫자를 생략할 수 있고, 마지막 인덱스까지 구간이 이어진다면 뒤의 숫자를 생략할 수 있다. 앞뒤 모두 생략한다면 문자열 전체를 참조하는 슬라이스가 만들어진다.

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

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];

해당 문법은 비단 문자열 뿐만 아니라 배열, 벡터, 해시맵 등 모든 컬렉션에 적용 가능한 일반적인 문법이지만, 문자열 자체의 특성으로 인해 문자열 슬라이스가 다른 슬라이스와 다른 부분을 감안할 필요가 있다.

라이프타임

지금까지 소유권과 참조에 대해 이야기하면서 논의하지 않은 상황 중 하나는 포인터의 개념을 구현한 다른 언어에서 심심찮게 발생하는 문제 중 하나인 허상 참조dangling reference다. 러스트는 라이프타임lifetime이라는 새로운 개념 덕분에 이 문제가 프로그램을 실행한 이후에 발생하지 않도록 예방할 수 있다. 우선 허상 참조의 개념부터 알아보자.

허상 참조

허상 참조dangling reference라 불리는 이 문제는 포인터는 있는데 포인터가 가리키는 값이 없는 상황에 해당한다. 이때 포인터를 사용하려 하면, 원래 포인터를 경유하여 사용하고자 했던 값이 그 자리에 없기 때문에 여러 문제가 발생할 수 있다.

#include <stdio.h>
#include <stdlib.h>

int* createNumber() {
    int num = 10;
    return &num;  // num은 createNumber 함수의 종료와 함께 사라진다!
}

int main() {
    int *ptr = createNumber();
    printf("The number is: %d\n", *ptr);  // 그래서 ptr에는 num의 메모리 주소였던 주소가 전달되지만, 정작 그 주소에는 num이 없다. 
    return 0;
}

위의 코드는 C로 작성되었는데, 앞서 설명한 허상 참조로 인한 문제가 발생한다. ptr에는 num의 메모리 주소였던 주소가 전달되지만, createNumber함수가 끝나면서 num도 같이 사라지기 때문에 정작 그 주소에는 num이 없고 이로 인해 ptr은 주소를 맞게 받았음에도 불구하고 엉뚱한 값을 가리키게 되는 것이다.

참조자의 유효성

러스트에는 이런 문제가 거의 없다. 참조자가 살아있는 동안 유효한 값을 가리킨다고 확신할 수 있는 것은 러스트 컴파일러의 일부인 대여 검사기borrow checker가 이를 보장해주기 때문이다. 대여 검사기는 변수와 참조자의 수명을 확인하고, 변수가 참조자보다 오래 살지 못하면 컴파일 에러를 일으킨다. 이렇게 할 수 있는 것은 다른 언어에는 없는 라이프타임lifetime이라는 독특한 개념의 공이 크다.

라이프타임

러스트의 모든 참조자는 참조자의 유효성이 보장되는 범위인 라이프타임lifetime을 가진다. 대여 검사기는 바로 이 라이프타임을 검사한다.

fn main() {               // 아래 주석은 라이프타임을 표시한다.
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {}", r); //   |       |
                          // --+       |
}                         // ----------+

만약 참조자보다 참조 대상이 되는 변수의 라이프타임이 더 짧다면, 허상 참조로 인한 문제가 발생할 수 있다. 그렇기 때문에 컴파일 과정에서 대여 검사기는 참조자 r의 라이프타임인 'a와 변수 x의 라이프타임 'b를 비교하고, 참조 대상이 참조자보다 먼저 사라지지 않는다는 것을 확인하고 나서야 컴파일을 허락한다. 그런데 이렇게 핵심적인 개념임에도 불구하고 평소에 라이프타임을 볼 일은 그리 많지 않은데, 이는 자료형처럼 라이프타임도 컴파일러가 알아서 추론하는 것이 일반적이기 때문이다.

그래도 직접 써야 할 때도 있다

그렇다 해도 컴파일러가 모든 상황에서 라이프타임을 확신할 수 있는 것은 아닌데, 이런 상황에서는 'a와 같은 제네릭generic 라이프타임 매개변수를 함수구조체의 정의에 사용한다. 참조자가 있다면 기본적으로 라이프타임을 명시해 줘야 한다. 라이프타임을 명시할 때는 아포스트로피'로 시작하는 라이프타임 매개변수의 이름을 넣는다. 일반적으로 'a, 'b와 같이 짧은 이름을 사용하며, 참조자의 &뒤에 사용하고, 공백 한 칸을 입력해 참조자의 타입과 분리한다.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

라이프타임을 함수 본문이 아닌 시그니처에 적는 이유는 라이프타임의 존재 이유와 연결되어 있는데, 라이프타임은 참조자의 수명을 보장하기 위해 만들어졌기 때문에 라이프타임을 직접 쓴다는 것은 참조자의 수명을 보장한다는 것이고, 특히 함수에 존재하는 여러 참조자의 상대적인 수명 관계를 보장한다는 것이다. 같은 이유로 라이프타임이 하나의 참조자에만 명시되어 있어봐야 의미가 없다. 중요한 것은 참조자 사이의 수명 관계다. 만약 세 참조자 a, b, c가 동일한 라이프타임 매개변수를 가진다면 세 참조자 중 가장 수명이 짧은 참조자의 수명이 다하기 전까지는 이 세 참조자가 모두 유효하다는 것이 보장된다.

이렇게 라이프타임을 명시하는 작업은 대여 검사기에게 라이프타임을 검사할 때 사용할 가이드라인을 제시하며, 따라서 컴파일러가 개발자의 의도를 더 잘 이해하게 해준다. 라이프타임을 직접 쓴다는 것은 참조자의 수명을 보장한다는 것이고, 특히 함수와 구조체에 존재하는 여러 참조자의 상대적인 수명 관계를 보장한다.

점점 늘어나는 생략

다만, 라이프타임을 명시하고 사용할 때 고정된 패턴을 보이는 상황이 여럿 있다는 것을 알게 되면서, 러스트는 점차 라이프타임을 생략하고 컴파일러에게 추론을 맡기는 방향으로 발전하고 있다. 이렇게 흡수된 패턴들을 라이프타임 생략 규칙lifetime elision rules이라 부르고, 이 규칙은 다음의 세 가지 상황에서 개발자가 모든 참조자의 라이프타임을 명시하지 않아도 되게끔 해준다.

  1. 컴파일러는 참조자인 매개변수 각각에게 라이프타임 매개변수를 할당한다. 참조자인 매개변수가 하나라면 라이프타임도 1개, 참조자인 매개변수가 2개라면 라이프타임도 2개다.
  2. 만약 매개변수의 라이프타임을 나타내는 입력 라이프타임input lifetime 매개변수가 1개라면, 반환 값의 라이프타임을 나타내는 출력 라이프타임output lifetime 전체에 입력 라이프타임 매개변수가 적용된다.
  3. 여러 개의 입력 라이프타임 매개변수 중 하나가 &self나 &mut self라면, 즉 메서드라면, self의 라이프타임이 모든 출력 라이프타임 매개변수에 적용된다.

정적 라이프타임

프로그램이 실행하고 종료하는 생애주기 전체동안 참조자를 살아있게 하고 싶다면 정적 라이프타임static lifetime을 명시해야 한다. 예외로, 모든 문자열 리터럴은 프로그램의 시작부터 끝까지 살아있기 때문에 문자열 리터럴은 기본적으로 정적 라이프타임을 가지고, 원한다면 이렇게 프로그램의 시작부터 끝까지 지속되는 참조 관계를 만들어낼 수 있다.

let s: &'static str = "I have a static lifetime.";

참고 자료


  1. 만약 이런 표현이 익숙하지 않다면 이동move 연산이라는 표현이 더 직관적일 것이라 생각한다. ↩︎

  2. 원래는 둘 이상의 스레드thread가 공유 데이터에 동시에 접근하는 상황에서 동기화synchronization 메커니즘 없이 데이터 변경이 일어날 때, 데이터 경합이 발생했다고 말한다. 스레드나 동기화를 이해하기 어렵다면, 당신이 가져가기로 한 케이크 조각을 멋대로 가져가는 당신의 친구를 떠올려 보자. ↩︎

  3. 컬렉션은 다수의 값을 담을 수 있고, 동시에 프로그램 실행 중에도 담고 있는 데이터에 변화를 줄 수 있는 데이터 타입을 일컫는다. ↩︎