[Rust] 소유권과 라이프타임을 통한 메모리 관리
Rust가 가지는 특별한 개념인 소유권에 대해 Copilot에게 물었을 때 아래의 답을 얻을 수 있었다.
소유권
Rust의 소유권 시스템은 메모리 안전성을 보장하기 위해 설계되었습니다. 주요 개념은 다음과 같습니다:
- 소유권(Ownership): 각 값은 특정 변수에 소유권이 있습니다. 한 번에 하나의 변수만 값을 소유할 수 있습니다.
- 이동(Move): 소유권이 다른 변수로 이동할 수 있습니다. 이동 후 원래 변수는 더 이상 값을 사용할 수 없습니다.
- 빌림(Borrowing): 소유권을 이동하지 않고 값을 참조할 수 있습니다. 빌림에는 불변 참조와 가변 참조가 있습니다.
- 불변 참조(&): 값을 읽기만 할 수 있습니다.
- 가변 참조(&mut): 값을 변경할 수 있습니다.
- 라이프타임(Lifetime): 참조가 유효한 범위를 나타냅니다. Rust는 라이프타임을 추적하여 유효하지 않은 참조를 방지합니다.
그러면 이러한 소유권은, C 계열 언어의 어떤 문제를 극복하고자 설계된 개념일까?
1. 메모리 누수
C와 C++에서는 프로그래머가 직접 메모리를 할당하고 해제해야 합니다. 이 과정에서 메모리를 제대로 해제하지 않으면 메모리 누수가 발생할 수 있습니다. Rust는 소유권 시스템을 통해 메모리를 자동으로 관리하여 이러한 문제를 방지합니다.
2. 잘못된 메모리 접근
C 계열 언어에서는 포인터를 통해 메모리 주소를 직접 조작할 수 있습니다. 이로 인해 잘못된 메모리 접근(예: 해제된 메모리 접근, 경계 밖 메모리 접근 등)이 발생할 수 있습니다. Rust는 소유권과 빌림 규칙을 통해 이러한 잘못된 접근을 컴파일 타임에 방지합니다.
3. 데이터 경합
멀티스레드 환경에서 여러 스레드가 동시에 동일한 메모리에 접근할 때 데이터 경합이 발생할 수 있습니다. C와 C++에서는 이러한 문제를 해결하기 위해 복잡한 동기화 메커니즘을 사용해야 합니다. Rust는 소유권과 빌림을 통해 데이터 경합을 방지하고, 안전한 동시성을 보장합니다.
4. 안전하지 않은 코드
C와 C++에서는 메모리 안전성을 보장하지 않는 코드가 쉽게 작성될 수 있습니다. Rust는 소유권 시스템을 통해 메모리 안전성을 보장하며, 안전하지 않은 코드를 작성할 때는 명시적으로 unsafe 블록을 사용하도록 요구합니다. 이를 통해 안전하지 않은 코드의 사용을 최소화하고, 코드의 안전성을 높입니다.
요약하자면, 직접 메모리를 할당하고 해제하거나 메모리 주소를 조작하는 등 개발자에게 많은 권한이 주어지기 때문에 반대로 개발자의 실수나 부주의로 인한 문제가 발생할 위험이 커진다. 따라서 Rust는 소유권 등의 개념에 기반하여 이를 원천적으로 제한하여 위험을 감소시키고자 한다. 생성형 AI 기반의 코드 작성이 피할 수 없는 흐름인 시대가 오는 만큼 더더욱 이런 언어 차원에서의 안정성 확보가 좋은 안전장치가 되어줄 것이라고 생각하고, 그래서 Rust가 매력적이다.
// C 코드에서 발생할 수 있는 문제 - 해제된 메모리에 대한 접근
#include <stdio.h>
#include <stdlib.h>
void use_after_free() {
int *ptr = (int *)malloc(sizeof(int));
*ptr = 42;
free(ptr);
printf("%d\n", *ptr); // 해제된 메모리에 접근 -> 정의되지 않은 동작이며 Rust에서는 이와 같은 접근이 불가
}
int main() {
use_after_free();
return 0;
}
// Rust에서의 안정성
fn use_after_free() {
let ptr = Box::new(42); // Box는 힙에 메모리를 할당하고 소유권을 가집니다.
// 메모리는 ptr이 범위를 벗어날 때 자동으로 해제됩니다.
println!("{}", *ptr); // 안전하게 접근
}
fn main() {
use_after_free();
}
예시 코드로 소유권 이해하기
새롭게 "onwership"이라는 프로젝트를 생성하고, main.rs에 아래와 같이 작성해 실행해보았다.
cargo new onwership
fn main() {
let s1 = String::from("Hello"); // s1이 문자열 "Hello"의 소유자입니다.
let s2 = s1; // 소유권이 s1에서 s2로 이동합니다. 이제 s1은 더 이상 사용할 수 없습니다.
// println!("{}", s1); // 이 줄은 컴파일되지 않습니다. s1은 더 이상 유효하지 않습니다.
println!("{}", s2); // s2는 "Hello"를 출력합니다.
let s3 = String::from("World");
let s4 = &s3; // s3의 불변 참조를 s4에 빌려줍니다.
println!("{}", s3); // s3는 여전히 유효합니다.
println!("{}", s4); // s4는 "World"를 출력합니다.
let mut s5 = String::from("Rust");
let s6 = &mut s5; // s5의 가변 참조를 s6에 빌려줍니다.
s6.push_str(" Programming"); // s6를 통해 s5를 변경할 수 있습니다.
println!("{}", s5); // s5는 "Rust Programming"을 출력합니다.
}
s1을 정의하는 순간 생긴 소유권이, s2를 정의하는 순간 s1으로부터 s2로 옮겨갔다(소유권 이동). 따라서 println!("{}", s1); 과 같은 코드는 동작할 수 없다. 실제 해당 주석을 해제하고 컴파일을 시도해보면,
이렇게 친절하게 에러를 뱉어준다. (물론 이미 extension이 설치된 VSCode가 알려준다.)
소유권을 이전 받지 않고 값을 참조하는 방법에는 불변 참조(&)와 가변 참조(&mut)가 있으며 가변 참조로 받아왔을 때만 s6처럼 본인이 소유권을 가지지 않았음에도 s5를 변경할 수 있다. (여전히 소유권 자체는 s5에 있음)
아래의 예시로는 라이프타임의 개념을 이해할 수 있다.
fn main() {
let string1 = String::from("Hello, world!");
let result;
{
let string2 = String::from("Rust");
result = longest(&string1, &string2);
// string2는 이 블록을 벗어나면 소멸됩니다.
println!("A: The longest string is {}", result); // 이 줄은 컴파일되지 않습니다.
}
// println!("B: The longest string is {}", result); // 이 줄은 컴파일되지 않습니다.
}
// 두 문자열 참조를 받아서 더 긴 문자열 참조를 반환하는 함수
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
해당 코드를 실행하면 아래와 같이 출력된다.
그러나 A: 로 시작하는 출력문 주석하고 B: 로 시작하는 출력문을 주석 해제하여 컴파일을 시도하면,
이렇게 에러를 만날 수 있다. 불변 참조(&)로 빌려온 값의 라이프타임이 해당 참조가 이루어진 블록 밖에서는 소멸되어, 출력할 수 없는 것이다. 하나의 함수 내에서도 블록 단위로 수명을 제한할 수 있다는 것이 놀랍다.
Rust 컴파일러는 라이프타임을 프로그래머가 명시적으로 지정할 수 있으며, longest 함수 내 'a 관련 표현이 "lifetime specifier"에 해당한다. a일 필요는 없으며, 원하는 이름을 자유롭게 쓸 수 있다고 한다. ('abc 와 같이 사용 가능)
또한 라이프타임 명시는 아래의 경우에는 생략될 수 있다고 하는데, "규칙에 맞게 작성하면 생략할 수 있다" 정도만 기억하고 보다 상세한 이해는 차차 해나가도록 하자.
- 입력 참조가 하나인 경우: 입력 참조의 라이프타임이 반환 참조의 라이프타임과 동일합니다.
- 여러 입력 참조가 있는 경우: 반환 참조의 라이프타임은 첫 번째 입력 참조의 라이프타임과 동일합니다.
- 메서드의 경우: self의 라이프타임이 반환 참조의 라이프타임과 동일합니다.
아래 코드는 1에 해당하는 입력 참조와 반환 참조의 라이프타임이 동일하다는 것을 컴파일러가 추론할 수 있는 예시이다.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}