본문으로 건너뛰기

Chap.4 ownership

· 약 50분
brown
FE developer

소유권(Ownership)은 가비지 컬렉터가 없는 러스트에서 메모리 안정성을 보장하는 비결입니다.

이는 러스트에서 가장 특별한 기능이며, 어떻게 동작하는지 반드시 이해해 둬야 합니다.

따라서 이번 장에서는 소유권을 비롯해 소유권과 관련된 빌림(Borrowing), 슬라이스(Slice) 기능과 러스트에선 데이터를 메모리에 어떻게 저장하는지 알아보겠습니다.

4.1 소유권이 뭔가요?


  • 모든 프로그램은 작동하는 동안 컴퓨터의 메모리 사용 방법을 관리해야 합니다.
  • 몇몇 언어는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 끊임없이 찾는 방식을 채용했고, 다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 택했습니다.
  • 이때 러스트는 제 3의 방식을 택했습니다. '소유권(ownership)' 이라는 시스템을 만들고, 컴파일러가 컴파일 중 검사할 여러 규칙을 정해 메모리를 관리하는 방식이죠.
  • 이 방식은 프로그램 실행 속도에 악영향을 줄 일이 없습니다. 컴파일 타임에 전부 해결하니까요.

스택, 힙 영역

러스트 같은 시스템 프로그래밍 언어에서는 값을 스택에 저장하느냐 힙에 저장하느냐의 차이가 프로그램의 동작 및 프로그래머의 의사 결정에 훨씬 큰 영향을 미칩니다.

스택, 힙 둘 다 여러분이 작성한 프로그램이 런타임 중 이용할 메모리 영역이라는 공통점이 있지만 구조는 각각 다릅니다.

  • 스택은 값이 들어온 순서대로 저장하고, 역순으로 제거합니다. 이를 last in, fist out 이라 하죠
    • 스택에 저장되는 데이터는 모두 명확하고 크기가 정해져 있어야 합니다.
  • 컴파일 타임에 크기를 알 수 없거나, 크기가 변경될 수 있는 데이터는 스택 대신 힙에 저장됩니다.
    • 힙은 스택보다 복잡합니다.
    • 데이터를 힙에 넣을때 먼저 저장할 공간이 있는지 운영체제한테 물어봅니다. 그럼 메모리 할당자는 커다란 힙 영역 안에서 어떤 빈 지점을 찾고, 이 지점은 사용 중이라고 표시한 뒤 해당 지점을 가리키는 포인터(pointer) 를 우리한테 반환합니다. 이 과정을 힙 공간 할당(allocating on the heap), 줄여서 할당(allocation) 이라 합니다 (스택에 값을 푸시하는 것은 할당이라 부르지 않습니다). 포인터는 크기가 정해져 있어 스택에 저장할 수 있으나, 포인터가 가리키는 실제 데이터를 사용하고자 할 때는 포인터를 참조해 해당 포인터가 가리키는 위치로 이동하는 과정을 거쳐야 합니다.
  • 스택 영역은 데이터에 접근하는 방식상 힙 영역보다 속도가 빠릅니다.
  • 메모리 할당자가 새로운 데이터를 저장할 공간을 찾을 필요가 없이 항상 스택의 가장 위에 데이터를 저장하면 되기 때문이죠.
  • 반면에 힙에 공간을 할당하는 작업은 좀 더 많은 작업을 요구하는데, 메모리 할당자가 데이터를 저장하기에 충분한 공간을 먼저 찾고 다음 할당을 위한 준비를 위해 예약을 수행해야 하기 때문입니다.
  • 힙 영역은 포인터가 가리키는 곳을 찾아가는 과정으로 인해 느려집니다.
  • 현대 프로세서는 메모리 내부를 이리저리 왔다 갔다 하는 작업이 적을수록 속도가 빨라지는데, 힙에 있는 데이터들은 서로 멀리 떨어져 있어 프로세서가 계속해서 돌아다녀야 하기 때문이죠.
  • 힙 영역처럼 데이터가 서로 멀리 떨어져 있으면 작업이 느려지고, 반대로 스택 영역처럼 데이터가 서로 붙어 있으면 작업이 빨라집니다. 이외에도, 큰 공간을 할당하는 작업도 힙 영역의 속도를 늦추는 요인입니다.
  • 여러분이 함수를 호출하면, 호출한 함수에 넘겨준 값(값 중엔 힙 영역의 데이터를 가리키는 포인터도 있을 수 있습니다)과 해당 함수의 지역 변수들이 스택에 푸시됩니다.
  • 그리고 이 데이터들은 함수가 종료될 때 pop 됩니다.
  • 코드 어느 부분에서 힙의 어떤 데이터를 사용하는지 추적하고, 힙에서 중복되는 데이터를 최소화하고, 쓰지 않는 데이터를 힙에서 정리해 영역을 확보하는 등의 작업은 모두 소유권과 관련되어 있습니다.
  • 반대로 말하면 여러분이 소유권을 한번 이해하고 나면 스택, 힙 영역으로 고민할 일이 줄어들 거란 뜻이지만, 소유권의 존재 이유가 힙 데이터의 관리라는 점을 알고 있으면 소유권의 동작 방식을 이해하는데에 도움이 됩니다.

소유권 규칙

소유권 규칙부터 알아보겠습니다.

  • 러스트에서, 각각의 값은 소유자(owner) 라는 변수가 정해져 있다.
  • 한 값의 소유자는 동시에 여럿 존재할 수 없다.
  • 소유자가 스코프 밖으로 벗어날 때, 값은 버려진다(dropped).

변수의 스코프(Scope)

스코프란, 프로그램 내에서 개체가 유효한 범위를 말합니다.

let s = "hello";

변수 s는 문자열 리터럴을 나타내며, 문자열 리터럴의 값은 코드 내에 하드코딩되어 있습니다.

이 변수는 선언된 시점부터 현재의 스코프를 벗어날 때까지 유효합니다.

중요한 점은 두 가지입니다.

  1. s 가 스코프 내에 나타나면 유효합니다.
  2. 유효기간은 스코프 밖으로 벗어나기 전까지 입니다.

String 타입

소유권 규칙을 설명하려면 3장 "데이터 타입들" 에서 다룬 타입보다 복잡한 타입이 필요합니다.

앞서 다룬 것들은 전부 스택에 저장되고 스코프를 벗어날 때 pop 되는 타입이지만, 이번에 필요한 건 힙에 저장되면서, 러스트의 데이터 정리과정을 알아보는 데 적합한 타입이거든요.

따라서 String 타입을 예제로 활용하되, 여기서 String 타입을 전부 설명할 순 없으므로 자세한 내용은 8장에서 다루고, 이번 장에선 소유권 관련 부분에만 집중하겠습니다.

이러한 관점은 다른 표준 라이브러리나 여러분이 만들 복잡한 데이터 타입에도 적용됩니다.

  • 여태 보아온 문자열은 코드 내에 하드코딩하는 방식의 '문자열 리터럴(string literal)'이었습니다.
  • 문자열 리터럴은 쓰기 편리하지만, 만능은 아닙니다.
  • 그 이유는 문자열 리터럴이 불변성(immutable)을 지니기에 변경할 수 없다는 점과, 프로그램에 필요한 모든 문자열을 우리가 프로그래밍하는 시점에 알 수는 없다는 점 때문입니다.
  • 사용자한테 문자열을 입력받아 저장하는 기능 등을 만들어야 하는 상황에선 문자열 리터럴을 사용할 수 없죠.
  • 따라서 러스트는 또 다른 문자열 타입인 String 을 제공합니다.
  • 이 타입은 힙에 할당되기 때문에, 컴파일 타임에 크기를 알 수 없는 텍스트도 저장할 수 있습니다.
  • String 타입은 다음과 같이 from 함수와 문자열 리터럴을 이용해 생성 가능합니다.
let s = String::from("hello");

이중 콜론(::)은 우리가 함수를 사용할 때, string_from 같은 함수명 대신 String 타입 하위라는 것을 특정해서 함수를 호출할 수 있도록 하려고 사용하는 네임스페이스 연산자입니다.

메소드 관련 문법은 5장 “메소드 문법” 에서 자세히 다루며, 모듈 및 네임스페이스는 7장 “경로를 사용해 모듈 트리에서 항목 가리키기” 에서 다루고 있습니다.

이 String 문자열은 변경 가능합니다:

let mut s = String::from("hello");
s.push_str(", world!"); // push_str() appends a literal to a String
println!("{}", s); // This will print `hello, world!` ``

하지만, 문자열 리터럴과 String 에 무슨 차이가 있길래 어떤 것은 변경할 수 있고 어떤 것은 변경할 수 없을까요?

차이점은 각 타입의 메모리 사용 방식에 있습니다.

메모리와 할당

  • 문자열 리터럴은 컴파일 타임에 내용을 알 수 있으므로, 텍스트가 최종 실행파일에 하드코딩됩니다.
  • 이 방식은 빠르고 효율적이지만, 문자열이 변하지 않을 경우에만 사용할 수 있습니다.
  • 컴파일 타임에 크기를 알 수 없는 텍스트는 바이너리 파일에 집어넣을 수 없죠.

반면 String 타입은 힙에 메모리를 할당하는 방식을 사용하기 때문에 텍스트 내용 및 크기를 변경할 수 있습니다. 하지만 이는 다음을 의미하기도 합니다:

  • 실행 중 메모리 할당자로부터 메모리를 요청해야 합니다.
  • String 사용을 마쳤을 때 메모리를 해제할 (할당자에게 메모리를 반납할) 방법이 필요합니다.

이 중 첫 번째는 이미 우리 손으로 해결했습니다. String::from 호출 시, 필요한 만큼 메모리를 요청하도록 구현되어 있거든요. 프로그래밍 언어 사이에서 일반적으로 사용하는 방식이죠.

하지만 두 번째는 다릅니다.

  • 가비지 콜렉터 (garbage collector, GC) 를 갖는 언어에선 GC가 사용하지 않는 메모리를 찾아 없애주므로 프로그래머가 신경 쓸 필요 없으나,
  • GC가 없는 언어에선 할당받은 메모리가 필요 없어지는 지점을 프로그래머가 직접 찾아 메모리 해제 코드를 작성해야 합니다. 굉장히 어려운 일이죠.
  • 프로그래머가 놓친 부분이 있다면 메모리 낭비가 발생하고, 메모리 해제 시점을 잘못 잡으면 버그가 생깁니다.
  • 두 번 해제할 경우도 마찬가지로 버그가 발생하겠죠.
  • 따라서 우린 할당(allocate) 과 해제(free) 가 하나씩 짝짓도록 만들어야 합니다.

이 문제를 러스트에선 변수가 자신이 소속된 스코프를 벗어나는 순간 자동으로 메모리를 해제하는 방식으로 해결했습니다.

예시로 보여드리도록 하죠. Listing 4-1 에서 문자열 리터럴을 String 으로 바꿔보았습니다:

{
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
}
// this scope is now over, and s is no
// longer valid`
  • 보시면 String 에서 사용한 메모리를 자연스럽게 해제하는 지점이 있습니다.
  • s 가 스코프 밖으로 벗어날 때인데, 러스트는 변수가 스코프 밖으로 벗어나면 drop 이라는 특별한 함수를 호출합니다.
  • 이 함수는 개발자가 직접 메모리 해제 코드를 작성해 넣을 수 있게 되어있으며, 이 경우 String 개발자가 작성한 메모리 해제 코드가 실행되겠죠.
  • drop 은 닫힌 중괄호 } 가 나타나는 지점에서 자동으로 호출됩니다.

Note: C++ 에선 이런 식으로 객체의 수명이 끝나는 시점에 리소스를 해제하는 패턴을 Resource Acquisition Is Initialization (RAII) 라 합니다. RAII 패턴에 익숙하신 분들이라면 러스트의 drop 함수가 친숙할지도 모르겠네요.

  • 이 패턴은 러스트 코드를 작성하는 데 깊은 영향을 미칩니다.
  • 지금은 단순해 보이지만, 힙 영역을 사용하는 변수가 많아져 상황이 복잡해지면 코드가 예기치 못한 방향으로 동작할 수도 있죠.

변수와 데이터 간 상호작용 방식: 이동(move)

러스트에선 동일한 데이터에 여러 변수가 서로 다른 방식으로 상호작용할 수 있습니다.

정수형을 이용한 예제로 살펴보겠습니다.

  • let x = 5; let y = x;
  • x의 정숫값을 y에 대입
    • 5 를 x 에 바인드(bind)하고,
    • x 값의 복사본을 만들어 y 에 바인드
    • 그럼 xy 두 변수가 생길 겁니다. 각각의 값은 5 가 되겠죠.
    • 실제로도 이와 같은데, 정수형 값은 크기가 정해진 단순한 값이기 때문입니다.
    • 이는 다시 말해, 두 5 값은 스택에 push 된다는 뜻입니다.

이번엔 앞선 예제를 String 으로 바꿔보았습니다:

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

이전 코드와 매우 비슷하니, 동작 방식도 같을 거라고 생각하실 수도 있습니다.

두 번째 줄에서 s1 의 복사본을 생성해 s2 에 바인딩하는 식으로 말이죠. 하지만 이번엔 전혀 다른 방식으로 동작합니다.

String 은 그림 좌측에서 나타나듯, 문자열 내용이 들어 있는 메모리를 가리키는 포인터, 문자열 길이, 메모리 용량 세 부분으로 이루어져 있습니다.

이 데이터는 스택에 저장되며, 우측의 문자열 내용은 힙에 저장됩니다.

메모리 속 String 의 모습

s1 에 바인드된, "hello" 값을 저장하고 있는 String 의 메모리 속 모습

문자열 길이와 메모리 용량이 무슨 차이인가 궁금하실 분들을 위해 간단히 설명해드리자면,

  • 문자열 길이는 String 의 내용이 현재 사용하고 있는 메모리를 바이트 단위로 나타낸 것이고,

  • 메모리 용량은 메모리 할당자가 String 에 할당한 메모리의 양을 뜻합니다.

  • 이번 내용에서는 길이, 용량 사이의 차이는 중요한 내용이 아니니, 이해가 잘 안 되면 용량 값은 무시하셔도 좋습니다.

  • s2 에 s 을 대입하면 String 데이터가 복사됩니다.

  • 이때 데이터는 스택에 있는 데이터, 즉 포인터, 길이, 용량 값을 말하며, 포인터가 가리키는 힙 영역의 데이터는 복사되지 않습니다. 즉, 다음과 같은 메모리 구조를 갖게 됩니다.

s1, s2 는 동일한 데이터를 가리킵니다

Figure 4-2: 변수 s2 가 s1 의 포인터, 길이, 용량 값을 복사했을 때 나타나는 메모리 구조

다음 Figure 4-3 은 힙 메모리 상 데이터까지 복사했을 경우 나타날 구조로, 실제로는 이와 다릅니다. 만약 러스트가 이런 식으로 동작한다면, 힙 내 데이터가 커질수록 s2 = s1 연산은 굉장히 느려질겁니다.

s1 and s2 to two places

Figure 4-3: 러스트에서 힙 데이터까지 복사할 경우의 s2 = s1 연산 결과

앞서 언급한 내용 중 변수가 스코프 밖으로 벗어날 때 러스트에서 자동으로 drop 함수를 호출하여 해당 변수가 사용하는 힙 메모리를 제거한다는 내용이 있었습니다.

하지만 Figure 4-2 처럼 두 포인터가 같은 곳을 가리킬 경우에는 어떻게 될까요?

s2s1 이 스코프 밖으로 벗어날 때 각각 메모리를 해제하게 되면 중복 해제(double free) 오류가 발생할 겁니다.

이는 메모리 안정성 버그 중 하나이며, 보안을 취약하게 만드는 메모리 손상의 원인입니다.

따라서, 러스트에는 여러 포인터가 한 곳을 가리킬 경우를 대비한 규칙이 존재합니다.

  • s1 에 할당한 메모리를 새로 복사하는 대신, 기존의 s1 을 무효화하는 것이죠.
  • 이로써 러스트는 s1 이 스코프를 벗어나더라도 아무것도 해제할 필요가 없어집니다.
  • 그럼 s2 가 만들어진 이후에 s1 을 사용하면 어떻게 될까요?
  • 결론부터 말씀드리면, 사용할 수 없습니다:

여러분이 다른 프로그래밍 언어에서 “얕은 복사(shallow copy)”, “깊은 복사(deep copy)” 라는 용어를 들어보셨다면, 힙 데이터를 복사하지 않고 포인터, 길이, 용량 값만 복사하는 것을 얕은 복사라고 생각하셨을 수도 있지만,

러스트에선 기존의 변수를 무효화하기 때문에 이를 얕은 복사가 아닌 이동(move) 이라 하고, 앞선 코드는 s1 이 s2 로 이동되었다 라고 표현합니다.

s1 이 s2 로 이동됨

Figure 4-4: s1 이 무효화 된 후의 메모리 구조

이로써 문제가 사라졌네요! s2 만이 유효하니, 스코프 밖으로 벗어나면 그대로 자신의 메모리를 해제하면 됩니다.

덧붙이자면, 러스트는 절대 자동으로 “깊은 복사” 로 데이터를 복사하는 일이 없습니다. 따라서, 러스트가 자동으로 수행하는 모든 복사는 런타임 성능면에서 효율적이라 할 수 있습니다.

변수와 데이터 간 상호작용 방식: 클론(clone)

String 의 힙 데이터까지 깊이 복사하고 싶을 땐 clone 이라는 공용 메소드를 사용할 수 있습니다.

다음은 clone 메소드의 사용 예제입니다:

	let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);`

이 코드의 실행 결과는 힙 데이터까지 복사됐을 때의 메모리 구조를 나타낸 Figure 4-3 과 정확히 일치합니다.

여러분은 이 코드에서 clone 호출을 보고, 이 지점에서 성능에 영향이 갈지도 모르는 코드가 실행될 것을 알 수 있습니다. 즉, clone 은 해당 위치에서 무언가 다른 일이 수행될 것을 알려주는 시각적 지시자이기도 합니다.

스택에만 저장된 데이터: 복사(copy)

아직 다루지 않은 부분이 남았습니다. 다음 코드는 앞서 Listing 4-2 에서 본 정수형을 이용하는 코드입니다 (정상적으로 작동합니다):

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

하지만 이 코드는 방금 우리가 배운 내용과 맞지 않는 것처럼 보이네요. clone 을 호출하지도 않았는데 x 는 계속해서 유효하며 y 로 이동되지도 않았습니다.

  • 이유는 정수형 등 컴파일 타임에 크기가 고정된 타입은 모두 스택에 저장되기 때문입니다.
  • 스택에 저장되니, 복사본을 빠르게 만들 수 있고, 따라서 굳이 y 를 생성하고 나면 x 를 무효화 할 필요가 없습니다.
  • 다시 말해 이런 경우엔 깊은 복사와 얕은 복사 간 차이가 없습니다.
  • 여기선 clone 을 호출해도 얕은 복사와 차이가 없으니 생략해도 상관없죠.

러스트에는 정수형 등 스택에 저장되는 타입에 달아 놓을 수 있는 Copy 트레잇이 있습니다 (트레잇은 10장에서 자세히 다룹니다).

만약 어떤 타입에 이 Copy 트레잇이 구현되어 있다면, 대입 연산 후에도 기존 변수를 사용할 수 있죠. 하지만 구현하려는 타입이나, 구현하려는 타입 중 일부분에 Drop 트레잇이 구현된 경우엔 Copy 트레잇을 어노테이션(annotation) 할 수 없습니다.

즉, 스코프 밖으로 벗어났을 때 특정 동작이 요구되는 타입에 Copy 어노테이션을 추가하면 컴파일 오류가 발생합니다. 여러분이 만든 타입에 Copy 어노테이션을 추가하는 방법은 부록 C의 “Derivable Traits” 을 참고 바랍니다.

그래서, Copy 가능한 타입은 뭘까요? 타입마다 문서를 뒤져 정보를 찾아보고 확신을 얻을 수도 있겠지만, 일반적으로 단순한 스칼라 값의 묶음은 Copy 가능하고, 할당이 필요하거나 리소스의 일종인 경우엔 불가능합니다.

Copy 가능한 타입 목록 중 일부를 보여드리겠습니다.

  • 모든 정수형 타입 (예: u32)
  • truefalse 값을 갖는 논리 자료형 bool
  • 모든 부동 소수점 타입 (예: f64)
  • 문자 타입 char
  • Copy 가능한 타입만으로 구성된 튜플 (예를 들어, (i32, i32) 는 Copy 가능하지만 (i32, String) 은 불가능합니다)

소유권과 함수

함수로 값을 전달하는 행위는 변수에 값을 대입하는 행위와 의미가 유사합니다.

함수에 변수를 전달하면 대입 연산과 마찬가지로 이동이나 복사가 일어나기 때문이죠.

  • 러스트는 takes_ownership 함수를 호출한 이후에 s 를 사용하려 할 경우, 컴파일 타입 오류를 발생시킵니다.
  • 이런 정적 검사들이 프로그래머의 여러 실수를 방지해주죠. 어느 지점에서 변수를 사용할 수 있고, 어느 지점에서 소유권 규칙이 여러분을 제재하는지 확인해보려면 main 함수에 sx 변수를 사용하는 코드를 여기저기 추가해보세요.

반환 값과 스코프

소유권은 값을 반환하는 과정에서도 이동합니다.

상황은 다양할지라도, 변수의 소유권 규칙은 언제나 동일합니다.

  • 어떤 값을 다른 변수에 대입하면 값이 이동하고, 힙에 데이터를 갖는 변수가 스코프를 벗어나면, 사전에 해당 데이터가 이동되어 다른 변수가 소유하고 있지 않은 이상 drop 에 의해 데이터가 제거됩니다.

그럼, 함수가 값을 사용할 수 있도록 하되 소유권은 가져가지 않도록 하고 싶다면 어떻게 해야 할까요?

일단 다음과 같이 튜플을 이용해 여러 값을 돌려받는 게 가능하긴 하다는 걸 알려드리겠습니다:

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() returns the length of a String
(s, length)
}

당연하지만 이런 방식으로 매개변수의 소유권을 되돌려받는 것은 좋지 않습니다. 거추장스럽고, 작업량이 필요 이상으로 많아지죠. 다행히도, 러스트에는 이 대신 사용할 참조자(references) 라는 기능을 가지고 있습니다.


4.2 참조자와 Borrow

이번에는 값의 소유권을 넘기는 대신 개체의 참조자(reference) 를 넘겨주는 방법을 소개하도록 하겠습니다.

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()
}

calculate_length 함수에 s1 대신 &s1 을 넘기고, 함수 정의에 String 대신 &String 을 사용했네요.

여기 사용된 앰퍼샌드(&) 기호가 바로 참조자 입니다. 참조자를 사용하면 여러분이 어떤 값의 소유권을 갖지 않고도 해당 값을 참조할 수 있죠. 어떤 원리인지 Figure 4-5 다이어그램으로 알아보겠습니다:

&String s 는 String s1 을 가리킵니다

Figure 4-5: &String s 는 String s1 을 가리킴

Note: & 를 이용한 참조의 반대는 역참조(dereferencing) 라 합니다. 역참조 기호는 * 이며, 8장 에서 몇 번 다뤄보고 15장에서 자세한 내용을 배울 예정입니다.

s1 에 & 를 붙인 &s1 구문은 s1 값을 참조하나, 해당 값을 소유하지 않는 참조자를 생성합니다. 함수 정의에서도 마찬가지로 & 를 사용하여 매개변수 s 가 참조자 타입임을 나타내고 있죠.

참조자는 소유권을 갖지 않으므로, 스코프를 벗어나도 값은 drop 되지 않습니다. 주석으로 보여드리겠습니다.

fn calculate_length(s: &String) -> usize { // s is a reference to a String
s.len()
}
// Here, s goes out of scope.
// But because it does not have ownership of what
// it refers to, nothing happens.`

변수 s 가 유효한 스코프는 여타 함수의 매개변수에 적용되는 스코프와 동일합니다. 하지만 참조자에는 스코프를 벗어났을 때 값이 drop 되지 않는다는 차이점이 있고, 따라서 참조자를 매개변수로 갖는 함수는 소유권을 되돌려주기 위해 값을 다시 반환할 필요도 없습니다.

또한, 이처럼 참조자를 매개변수로 사용하는 것을 borrow(빌림) 이라 합니다. 현실에서도 다른 사람이 소유하고 있는 뭔가를 빌리고, 용무가 끝나면 돌려주는 것처럼요.

변수가 기본적으로 불변성을 지니듯, 레퍼런스도 마찬가지로 참조하는 것을 수정할 수 없습니다.

가변 참조자 (Mutable Reference)

```rust
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}

s 를 mut 로 변경하고, 참조자 생성 코드를 &mut s 로 변경해 가변 참조자를 생성하게 만든 뒤, 함수에서 가변 참조자를 넘겨받도록 some_string: &mut String 으로 수정하는 겁니다.

다만, 가변 참조자에는 특정 스코프 내 어떤 데이터를 가리키는 가변 참조자를 딱 하나만 만들 수 있다는 제한이 있다는 걸 알아두세요.

이 제약으로 인해 가변 참조자는 남용이 불가능합니다. 대부분의 언어에선 원하는 대로 값을 변경할 수 있으니, 러스트 입문자가 익숙해지지 못해 고생하는 원인이기도 하죠.

하지만, 이 제약 덕분에 러스트에선 컴파일 타임에 데이터 레이스(data race) 를 방지할 수 있습니다. 데이터 레이스란 **다음 세 가지 상황이 겹칠 때 일어나는 특정한 레이스 조건(race condition)**입니다:

멀티쓰레드 관련(함수형 프로그래밍이 각광 받은 이유)

  • 둘 이상의 포인터가 동시에 같은 데이터에 접근
  • 포인터 중 하나 이상이 데이터에 쓰기 작업을 시행
  • 데이터 접근 동기화 매커니즘이 존재하지 않음

데이터 레이스는 정의되지 않은 동작을 일으키며, 런타임에 추적하려고 할 때 문제 진단 및 수정이 어렵습니다. 하지만 러스트에선 데이터 레이스가 발생할 가능성이 있는 코드는 아예 컴파일되지 않으니 걱정할 필요가 없죠.

가변 참조자는 불변 참조자가 존재하는 동안에도 생성할 수 없습니다. 불변 참조자를 사용할 때 가변 참조자로 인해 값이 중간에 변경되리라 예상하지 않으니까요. 반면 불변 참조자는 데이터를 읽기만 하니 외부에 영향을 주지 않아 여러 개를 만들 수 있습니다.

참조자는 정의된 지점부터 시작해, 해당 참조자가 마지막으로 사용된 부분까지 유효합니다.

댕글링 참조자(Dangling Reference)

댕글링 포인터(dangling pointer) 란, 어떤 메모리를 가리키는 포인터가 남아있는 상황에서 해당 메모리를 해제해버림으로써, 다른 개체가 할당받았을지도 모르는 메모리를 참조하게 된 포인터를 말합니다.

포인터가 있는 언어에선 자칫 잘못하면 이 댕글링 포인터를 만들기 쉽죠. 하지만 러스트에선 어떤 데이터의 참조자를 만들면, 해당 참조자가 스코프를 벗어나기 이전에 데이터가 먼저 스코프를 벗어나는지 컴파일러에서 확인하여 댕글링 참조자가 생성되지 않도록 보장합니다.

한번, 컴파일 타임 에러가 발생할 만한 댕글링 참조자를 만들어 봅시다:

fn main() {
let reference_to_nothing = dangle();
}

fn dangle() -> &String {
let s = String::from("hello");
&s
}
fn main() {
let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String
let s = String::from("hello"); // s is a new String
&s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped. Its memory goes away.
// Danger!

s 는 dangle 함수 내에서 생성됐기 때문에, 함수가 끝날 때 할당 해제됩니다.

하지만 코드에선 &s 를 반환하려 했고, 이는 유효하지 않은 String 을 가리키는 참조자를 반환하는 행위이기 때문에 오류가 발생합니다.

따라서, 이런 경우엔 String 을 직접 반환해야 합니다.

참조자 규칙

배운 내용을 정리해 봅시다:

  • 여러분은 단 하나의 가변 참조자만 갖거나, 여러 개의 불변 참조자를 가질 수 있습니다.
  • 참조자는 항상 유효해야 합니다.

다음으로 알아볼 것은 참조자의 또 다른 종류인 슬라이스(slice) 입니다.


4.3 슬라이스(Slice)


소유권을 갖지 않는 또 하나의 타입은 슬라이스(slice) 입니다. 이 타입은 컬렉션(collection) 을 통째로 참조하는 것이 아닌, 컬렉션의 연속된 일련의 요소를 참조하는 데 사용합니다.

한번 간단한 함수를 만들어 봅시다.

  • 문자열을 입력받아,
  • 해당 문자열의 첫 번째 단어를 반환하는 함수를요.
  • 공백문자를 찾을 수 없는 경우엔 문자열 전체가 하나의 단어라는 뜻이니 전체 문자열을 반환하도록 합시다.
  • first_word 함수는 소유권을 가질 필요가 없으니 &String 을 매개변수로 갖게 했습니다.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
  • String 을 하나하나 쪼개서 해당 요소가 공백 값인지 확인해야 하므로, as_bytes 메소드를 이용해 바이트 배열로 변환
  • 그 다음, 바이트 배열에 사용할 반복자(iterator)를 iter 메소드로 생성했습니다:
  • iter 메소드는 컬렉션의 각 요소를 반환하고, enumerate 메소드는 iter 의 결과 값을 각각 튜플로 감싸 반환한다는 것만 알아두도록 합시다.
  • 이때 반환하는 튜플은 첫 번째 요소가 인덱스, 두 번째 요소가 해당 요소의 참조자로 이루어져 있습니다.
  • enumerate 메소드가 반환한 튜플은 패턴을 이용해 해체하였습니다.
    • 따라서 for 루프 내에서 i 는 튜플 요소 중 인덱스에 대응하고, &item 은 바이트에 대응됩니다.
    • 이때 & 를 사용하는 이유는 우린 iter().enumerate() 에서 얻은 요소의 참조자가 필요하기 때문입니다.
  • for 반복문 내에선 바이트 리터럴 문법으로 공백 문자를 찾고, 찾으면 해당 위치를 반환합니다.
  • 찾지 못했을 땐 s.len() 으로 문자열의 길이를 반환합니다.
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word will get the value 5
s.clear(); // this empties the String, making it equal to ""
// word still has the value 5 here, but there's no more string that
// we could meaningfully use the value 5 with. word is now totally invalid!
}

Listing 4-8: first_word 함수의 결과를 저장했으나, 이후에 String 의 내용이 변경된 상황

이 코드는 문법적으로 전혀 문제없고, 정상적으로 컴파일됩니다. s.clear() 을 호출한 후에 word 를 사용하는 코드를 작성하더라도, word 는 s 와 분리되어 있으니 결과는 마찬가지죠. 하지만 word 에 담긴 값 5 를 본래 목적대로 s 에서 첫 단어를 추출하는 데 사용할 경우, 버그를 유발할 수도 있습니다. s 의 내용물은 변경되었으니까요.

문자열 슬라이스

문자열 슬라이스(String Slice)는 String 의 일부를 가리키는 참조자를 말합니다:

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

만드는 방식은 String 참조자와 유사하지만, [0..5] 가 추가로 붙어 있네요. 이는 String 전체가 아닌 일부만 가리킨다는 것을 의미합니다.

  • [starting_index..ending_index] 는 starting_index 부터 시작해 ending_index 직전, 즉 ending_index 에서 1을 뺀 위치까지 슬라이스를 생성한다는 의미입니다.
  • 슬라이스는 내부적으로 시작 위치, 길이를 데이터 구조에 저장하며, 길이 값은 ending_index 값에서 starting_index 값을 빼서 계산합니다.
  • 따라서 let world = &s[6..11]; 의 world 는 시작 위치로 s 의 (1부터 시작하여) 7번째 바이트를 가리키는 포인터와, 길이 값 5를 갖는 슬라이스가 되겠죠.

world 는 String s 의 7번째 바이트를 가리키는 포인터와, 길이 값 5 를 갖습니다

.. 범위 표현법은 맨 첫 번째 인덱스부터 시작하는 경우, 앞의 값을 생략할 수 있습니다. 즉 다음 코드에 등장하는 두 슬라이스 표현은 동일합니다:

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

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

마찬가지로, String 맨 마지막 바이트까지 포함하는 슬라이스는 뒤의 값을 생략할 수 있습니다. 다음 코드에 등장하는 두 슬라이스 표현은 동일합니다:

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

let len = s.len();

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

앞뒤 모두 생략할 경우, 전체 문자열이 슬라이스로 생성됩니다. 다음 코드에 등장하는 두 슬라이스 표현은 동일합니다:

	let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];

Note: 본 내용은 문자열 슬라이스를 소개할 목적이기에 ASCII 문자만 사용하여 문제가 발생하지 않았지만, 문자열 슬라이스 생성 시 인덱스는 절대 UTF-8 문자 바이트 중간에 지정해선 안 됩니다. 멀티바이트 문자 중간에 생성할 경우 프로그램은 오류가 발생하고 강제 종료됩니다. UTF-8 문자를 다루는 방법은 8장 “Storing UTF-8 Encoded Text with Strings” 절에서 자세히 알아볼 예정입니다.

여태 다룬 내용을 잘 기억해두고, first_word 함수가 슬라이스를 반환하도록 재작성해보죠. 문자열 슬라이스를 나타내는 타입은 &str 로 작성합니다.

fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}

이제 first_word 가 반환하는 값은 원래 데이터와 분리된 값이 아닙니다. 원래 데이터에서 슬라이스 시작 위치를 가리키는 참조자와, 슬라이스 요소 개수로 구성된 값이죠.

이전 절에서 배운 borrow 규칙 중, 특정 대상의 불변 참조자가 이미 존재할 경우에는 가변 참조자를 만들 수 없다는 규칙이 있었죠. clear 함수는 String 의 길이를 변경해야 하니 가변 참조자가 필요하지만 이미 불변 참조자가 존재하므로 오류가 발생하게 됩니다

그 외 슬라이스

문자열 슬라이스는 문자열에만 특정되어 있습니다. 물론 문자열에만 사용할 수 있는 것이 아닌 더 범용적인 슬라이스 타입도 존재합니다:

let a = [1, 2, 3, 4, 5];

문자열 일부를 참조할 때처럼 배열 일부를 참조할 수 있죠:

let a = [1, 2, 3, 4, 5]; let slice = &a[1..3];

이 슬라이스는 &[i32] 타입입니다. 동작 방식은 문자열 슬라이스와 동일합니다. 슬라이스의 첫 번째 요소를 참조하는 참조자와 슬라이스의 길이를 저장하여 동작하죠. 이런 슬라이스는 모든 컬렉션에 사용 가능합니다(컬렉션은 8장에서 자세히 알아볼 예정입니다).

요약

  • 러스트는 소유권, borrow, 슬라이스는 러스트가 컴파일 타임에 메모리 안정성을 보장하는 비결입니다.
  • 여타 시스템 프로그래밍 언어처럼 프로그래머에게 메모리 사용 제어 권한을 주면서, 어떠한 데이터의 소유자가 스코프를 벗어날 경우 자동으로 해당 데이터를 정리하는 것이 가능하죠.
  • 또한 제어 코드를 추가 작성하고 디버깅할 필요가 사라지니 프로그래머의 일이 줄어드는 결과도 가져옵니다.

소유권은 수많은 러스트 요소들의 동작 방법에 영향을 미치는 개념인 만큼 이후 내용에서도 계속해서 다룰 예정입니다. 그럼 이제 5장에서 struct 로 여러 데이터를 묶는 방법을 알아보죠.