소멸자 (Destructor)
앞서 힙 메모리를 설명할 때, 할당받은 메모리는 반드시 직접 해제해야 할 책임이 있다고 말씀드렸습니다. 그런데 차용 검사기(Borrow checker)를 소개할 때는 Rust에서 메모리를 직접 관리할 일이 거의 없다고도 했죠.
얼핏 들으면 모순처럼 느껴질 수 있는 이 두 이야기가 어떻게 양립할 수 있는지, **스코프(Scope)**와 소멸자(Destructor) 개념을 통해 알아보겠습니다.
스코프 (Scope)
변수의 스코프란 해당 변수가 프로그램 내에서 유효하게 살아있는 코드 영역을 말합니다.
변수의 스코프는 선언되는 시점부터 시작됩니다. 그리고 다음 두 가지 경우 중 하나에 해당할 때 끝이 납니다.
-
변수가 선언된 블록(즉,
{}로 감싸진 영역)이 끝날 때fn main() { // `x`는 아직 태어나지 않았습니다. let y = "Hello".to_string(); let x = "World".to_string(); // <-- x의 스코프 시작... let h = "!".to_string(); // | } // <------------------------------- ...그리고 여기서 끝납니다. -
변수의 소유권이 다른 곳(함수나 다른 변수)으로 옮겨갈 때
fn compute(t: String) { // 무언가 처리 중... } fn main() { let s = "Hello".to_string(); // <-- s의 스코프 시작... // | compute(s); // <------------------- ...그리고 여기서 끝납니다. // 소유권이 `compute` 함수로 넘어갔기 때문입니다. }
소멸자 (Destructor)
값의 소유권을 가진 변수가 스코프를 벗어나면, Rust는 자동으로 해당 값의 소멸자를 호출합니다. 소멸자는 그 값이 점유하고 있던 리소스, 특히 할당받았던 힙 메모리를 깔끔하게 정리해 줍니다.
우리는 std::mem::drop 함수를 사용하여 소멸자를 수동으로 호출할 수도 있습니다. 그래서 Rust 개발자들은 어떤 값이 스코프를 벗어나 소멸자가 실행되는 것을 흔히 “해당 값이 **드롭(Drop)**되었다“라고 표현합니다.
드롭 시점 시각화하기
컴파일러가 우리 대신 해주는 작업을 눈으로 확인하기 위해, 명시적으로 drop을 호출하는 코드로 바꿔보겠습니다. 앞선 예제를 다시 볼까요?
fn main() {
let y = "Hello".to_string();
let x = "World".to_string();
let h = "!".to_string();
}
이 코드는 컴파일 시점에 사실상 다음과 같이 바뀝니다.
fn main() {
let y = "Hello".to_string();
let x = "World".to_string();
let h = "!".to_string();
// 변수는 선언된 순서의 '역순'으로 드롭됩니다.
drop(h);
drop(x);
drop(y);
}
소유권이 이동하는 두 번째 예제도 살펴봅시다.
fn compute(s: String) {
// 무언가 처리 중...
}
fn main() {
let s = "Hello".to_string();
compute(s);
}
이 코드는 내부적으로 다음과 같이 처리됩니다.
fn compute(t: String) {
// 무언가 처리 중...
drop(t); // <-- 함수가 끝날 때 t가 드롭됩니다.
}
fn main() {
let s = "Hello".to_string();
compute(s);
// 여기서 s를 드롭하지 않습니다! 소유권이 이미 넘어갔기 때문이죠.
}
중요한 차이점을 발견하셨나요? main 함수가 끝날 때 s가 드롭되지 않습니다. 소유권을 함수로 넘긴다는 것은 메모리 정리 책임까지 함께 넘긴다는 뜻이기 때문입니다.
이러한 메커니즘 덕분에 소멸자는 항상 **단 한 번1**만 호출됩니다. 결과적으로 C/C++에서 흔히 발생하는 이중 해제(Double free) 버그를 언어 차원에서 원천적으로 방지합니다.
드롭된 값 다시 사용하기
이미 드롭된 값을 다시 사용하려고 하면 어떻게 될까요?
let x = "Hello".to_string();
drop(x);
println!("{}", x);
이 코드를 컴파일하면 다음과 같은 오류를 만나게 됩니다.
error[E0382]: use of moved value: `x`
drop 함수는 값을 소비합니다. 즉, 호출된 직후 그 값은 더 이상 유효하지 않게 됩니다. 컴파일러는 이미 정리된 메모리에 접근하는 해제 후 사용(Use-after-free) 버그를 미리 차단합니다.
참조와 드롭
만약 변수가 소유권이 없는 참조를 담고 있다면 어떻게 될까요?
let x = 42i32;
let y = &x;
drop(y);
이 경우 drop(y)를 호출해도 아무런 일도 일어나지 않습니다. 컴파일러는 다음과 같은 경고를 띄워줍니다.
warning: calls to `std::mem::drop` with a reference instead of an owned value does nothing
이는 소멸자가 단 한 번만 호출되어야 한다는 원칙 때문입니다. 같은 데이터에 대해 여러 개의 참조가 있을 수 있는데, 그중 하나가 범위를 벗어난다고 데이터를 지워버리면 다른 참조들은 순식간에 유효하지 않은 메모리를 가리키게 되겠죠? 이를 댕글링 포인터(Dangling pointer)라고 합니다. Rust의 소유권 시스템은 이러한 치명적인 버그를 설계 단계에서부터 배제합니다.
Exercise
The exercise for this section is located in 03_ticket_v1/11_destructor
-
Rust는 소멸자 실행을 100% 보장하지는 않습니다. 예를 들어 의도적으로 메모리 누수(Memory leak)를 발생시키는 경우에는 실행되지 않을 수 있습니다. ↩