새로운 언어를 배우기 시작할 때, 전력 질주할 수 있을 거라 기대했지만 첫 걸음부터 발이 꼬여버리는 그런 기분을 아시나요? Rust를 처음 접했을 때 제 모습이 딱 그랬습니다. 저는 몇 개의 배열을 파싱하고 변경하는 아주 평범한 JavaScript 유틸리티를 가지고 있었고, 커피 한 잔 마시는 동안 금방 다시 작성할 수 있을 거라 생각했습니다. 에디터를 열고 자연스럽게 느껴지는 대로 코드를 타이핑한 뒤 컴파일러를 실행했습니다. 그런데 컴파일러가 폭발했습니다. 구문 오류(syntax error) 때문도, 세미콜론이 빠져서도 아니었습니다. 이미 이동(moved)된 값을 사용하려고 했다는 메시지를 띄웠습니다. 저는 화면을 멍하니 바라보았습니다. 이동(Moved)? 저는 아무것도 이동시킨 적이 없습니다. 그저 다른 변수에 할당했을 뿐입니다.
JavaScript의 안락한 영역
JavaScript는 데이터를 공용 화이트보드처럼 다루도록 훈련시킵니다. 객체나 배열을 선언하고, 함수에 전달하고, 그 함수가 속성을 추가하게 한 뒤, 다시 다른 곳으로 전달합니다. 가비지 컬렉터는 보이지 않는 청소부처럼 배경에서 대기하며, 당신이 버린 것들을 쓸어 담을 준비를 합니다. 당신은 "이 문자열의 소유권은 누구에게 있는가?"라고 묻지 않습니다. 대신 "이걸 읽을 수 있는가?"라고 묻습니다. 답이 '예'라면, 당신은 그것을 건드립니다. 참조가 더 필요하면 그냥 let b = a라고 쓰고 계속 진행하면 됩니다. 두 변수 모두 동일한 메모리 영역을 가리키며, 만약 a가 이를 변경하면 b도 즉시 그 변화를 확인합니다. 편리하죠. 하지만 혼란스럽기도 합니다. 그래도 런타임이 뒷정리를 처리해주기 때문에 그 혼란이 당신을 공격하는 일은 거의 없습니다.
첫 번째 컴파일러의 충격
Rust는 당신을 믿지 않습니다. 가혹하게 들릴 수도 있지만, 이것이 당신이 가장 먼저 배우게 될 사실입니다. 이 언어는 세 가지 엄격한 규칙을 가진 소유권 시스템을 중심으로 구축되었습니다. 첫째, 모든 값은 정확히 하나의 소유자를 가집니다. 둘째, 한 번에 오직 하나의 소유자만 존재할 수 있습니다. 셋째, 소유자가 스코프(scope)를 벗어나면 Rust는 자동으로 해당 값을 드롭(drop)합니다. 가비지 컬렉터도 없고, 명시적으로 선택하지 않는 한 배경에서 돌아가는 참조 카운팅(reference counting)도 없습니다. 오직 이 세 가지 규칙만이 존재하며, 코드가 실행되기도 전에 컴파일러에 의해 강제됩니다.
그 JavaScript 유틸리티를 Rust 버전으로 작성할 때, 저는 자연스럽게 느껴지는 대로 코드를 짰습니다. Rust의 배열 격인 벡터(vector)를 생성하고, 이를 두 번째 변수에 할당했습니다. 그러고 나서 첫 번째 변수를 사용하려고 했습니다. 컴파일러는 거부했습니다. JavaScript에서 let b = a는 참조를 복사합니다. 하지만 Rust에서는 소유권을 이동(move)시킵니다. 원래의 변수는 무효화됩니다. 컴파일러가 이렇게 하는 이유는, 두 개의 경로가 실수로 동일한 메모리를 짓밟는 일이 없도록 보장하기 위해서입니다. 이것이 바로 다른 시스템에서 데이터 경합(data race)이나 use-after-free 버그가 발생하는 방식입니다.
왜 Rust는 당신의 장난감을 뺏어가는가
JavaScript에서의 이 패턴을 보세요:
let a = [1, 2, 3];
let b = a;
a.push(4);
// Both a and b see 4.
이것은 몸에 밴 습관입니다. 두 번 생각할 필요도 없죠. 하지만 Rust에서는 그 똑같은 직관 때문에 코드를 다 타이핑하기도 전에 컴파일러에게 거절당할 것입니다. 일단 b가 벡터의 소유권을 가지게 되면, a는 빈 이름일 뿐입니다. a에 값을 추가할 수도 없고, 심지어 들여다볼 수도 없습니다. 이제 메모리는 b의 것입니다.
이것은 무엇을 방지하는지 깨닫기 전까지는 마치 벌을 받는 것처럼 느껴집니다. 만약 두 변수가 조율 없이 동일한 힙(heap) 데이터를 모두 변경할 수 있다면, 메모리 오염의 위험이 생깁니다. Rust는 데이터 전송을 명시적으로 만듦으로써 이러한 범주의 버그를 완전히 제거합니다. 컴파일러는 까다롭게 구는 것이 아닙니다. 문지기 역할을 하는 것입니다. 컴파일러는 코드의 매 단계마다 어떤 부분이 어떤 데이터에 책임을 지는지 결정하도록 강제합니다.
빌림(Borrowing): 사실은 기능인 우회 방법
물론, 모든 할당이 영구적으로 소유권을 이전한다면 프로그램을 작성하는 일은 매우 고될 것입니다. 모든 것을 클론(clone)해야 할 것이고, 이는 메모리와 속도를 낭비하게 될 것입니다. Rust는 이를 '빌림(borrowing)'으로 해결합니다.
빌림을 사용하면 값을 가져오지 않고도 사용할 수 있습니다. 두 가지 유형이 있으며, 그 차이가 중요합니다.
- 불변 빌림(Immutable borrows),
&T로 표기: 데이터를 읽을 수 있게 해줍니다. 원하는 만큼 동시에...
