전체 회차
C-0292026-08-25

Rust in 100 Seconds

Rust가 가비지 컬렉터 없이 소유권과 빌림, 컴파일 시점의 빌림 검사를 통해 메모리 안전성과 시스템 수준의 성능을 함께 확보하는 원리를 설명한다.

#Rust#소유권#빌림#메모리 안전성#시스템 프로그래밍
01

영상

· video
2:29youtu.be/5C_HPTJg5ek
03

학습 자료

· material

Rust의 메모리 안전성: 소유권과 빌림

출처: https://www.youtube.com/watch?v=5C_HPTJg5ek · Fireship · 2:29 · 2021-10-12

요지

  • Rust는 가비지 컬렉터 없이도 소유권과 빌림 규칙을 통해 메모리 안전성을 확보하는 컴파일 언어다.
  • 각 값에는 하나의 소유자가 있으며, 소유자가 유효 범위를 벗어나면 값과 관련된 자원이 자동으로 정리된다.
  • 참조를 빌리면 소유권을 넘기지 않고 값에 접근할 수 있고, 빌림 검사기가 잘못된 참조와 데이터 경합 가능성을 컴파일 단계에서 차단한다.
  • 변수는 기본적으로 불변이며, 변경이 필요할 때만 mut를 명시해 상태 변화의 범위를 드러낸다.
  • Rust는 시스템 프로그래밍 수준의 성능과 제어력을 제공하므로 운영체제, 게임 엔진, 데이터베이스, WebAssembly처럼 성능이 중요한 분야에 적합하다.
  • Cargo와 표준 라이브러리는 프로젝트 생성, 빌드, 의존성 관리와 입출력·파일 시스템·동시성 구현을 지원한다.

개요

Rust는 높은 수준의 문법과 낮은 수준의 성능을 함께 지향하는 정적 타입 컴파일 언어다. 개발자가 메모리 배치와 자원 수명에 관여할 수 있으면서도, 해제된 메모리 접근이나 중복 해제처럼 전통적인 시스템 언어에서 발생하기 쉬운 오류를 컴파일 단계에서 예방한다.

그 중심에는 소유권, 빌림, 수명이라는 규칙이 있다. 별도의 가비지 컬렉터가 실행 중에 메모리를 추적하는 대신 컴파일러가 코드의 자원 사용을 미리 검증한다. 따라서 실행 시간의 가비지 컬렉션 중단 없이 예측 가능한 성능을 얻을 수 있다.

배경 / 사전 지식

프로그램의 값은 흔히 스택과 힙이라는 메모리 영역을 이용한다. 스택은 크기를 컴파일 시점에 알 수 있고 수명이 명확한 값을 빠르게 다루는 데 적합하다. 힙은 크기가 가변적이거나 실행 중 동적으로 확보해야 하는 데이터를 담는 데 사용되며, 할당과 해제 비용이 더 든다. 실제 저장 위치는 타입과 컴파일러 최적화에 따라 달라질 수 있으므로 “불변 값은 스택, 가변 값은 힙”으로 단순화해서는 안 된다.

가비지 컬렉터를 사용하는 언어는 더 이상 참조되지 않는 객체를 런타임이 찾아 회수한다. C나 C++처럼 수동 메모리 관리를 허용하는 언어에서는 개발자가 할당과 해제를 직접 통제할 수 있지만, 댕글링 포인터, 이중 해제, 메모리 누수 같은 결함을 만들 가능성도 있다. Rust는 컴파일러가 소유권 규칙을 검사하고 값의 수명이 끝나는 지점에 정리 코드를 삽입하는 방식을 택한다.

Rust 프로젝트는 보통 패키지 관리자이자 빌드 도구인 Cargo로 관리한다. Cargo 패키지는 크레이트(crate)라고 부르며, 실행 프로그램 크레이트의 기본 진입점은 src/main.rsmain 함수다.

핵심 개념

소유권

Rust의 각 값에는 그 값을 소유하는 변수가 있다. 한 시점에 원칙적으로 하나의 소유자가 존재하며, 소유자가 유효 범위를 벗어나면 Rust가 drop을 호출해 관련 자원을 정리한다. 힙 데이터를 가진 값을 다른 변수에 대입하면 단순 복사가 아니라 소유권 이동(move)이 일어날 수 있다.

let first = String::from("Rust");
let second = first;

println!("{second}");
// println!("{first}"); // 오류: first의 소유권이 second로 이동했다.

정수처럼 Copy 특성을 구현한 단순 타입은 대입해도 값이 복사되므로 원래 변수를 계속 사용할 수 있다.

빌림과 참조

함수에 값을 넘긴 뒤에도 소유권을 유지하고 싶다면 &T 참조를 사용해 값을 빌려준다. 읽기 전용 참조는 동시에 여러 개 존재할 수 있다. 값을 변경하려면 &mut T가 필요하며, 해당 범위에서는 같은 값에 대한 다른 활성 참조와 함께 사용할 수 없다.

이 규칙은 여러 실행 흐름이 같은 메모리를 동기화 없이 변경하는 데이터 경합을 예방하는 기반이 된다.

빌림 검사기와 수명

빌림 검사기(borrow checker)는 참조가 원본 값보다 오래 살아남지 않는지, 가변 참조가 안전하게 배타성을 유지하는지 컴파일 시점에 검증한다. 수명(lifetime)은 참조가 유효한 범위를 나타내며, 대부분은 컴파일러가 추론한다. 여러 참조 사이의 관계가 모호한 함수에서는 'a 같은 수명 매개변수를 직접 표기하기도 한다.

불변성과 가변성

let으로 선언한 변수는 기본적으로 불변이다. 값을 바꿔야 할 때는 let mut로 의도를 명시한다. 불변 기본값은 예상하지 못한 상태 변화를 줄이고, 컴파일러가 참조의 안전성을 판단하기 쉽게 한다.

작동 원리

  1. 개발자가 변수나 String, Vec<T> 같은 값을 생성하면 해당 값의 소유자가 정해진다.
  2. 소유권을 가진 값을 다른 변수나 함수에 값으로 전달하면 타입에 따라 복사되거나 소유권이 이동한다.
  3. 소유권을 유지한 채 접근만 허용하려면 &T, 변경까지 허용하려면 &mut T 참조를 전달한다.
  4. 컴파일러의 빌림 검사기는 참조가 가리키는 값보다 오래 유지되지 않는지 검사한다.
  5. 또한 같은 데이터에 대한 가변 참조가 동시에 중복되거나 읽기 참조와 충돌하지 않는지 확인한다.
  6. 모든 검사를 통과한 프로그램은 기계어 실행 파일로 컴파일된다. 소유자가 범위를 벗어나는 지점에는 자원을 정리하는 코드가 결정론적으로 실행된다.

이 검증은 주로 컴파일 시점에 수행되므로, 소유권 자체를 추적하기 위한 별도의 런타임 가비지 컬렉터가 필요하지 않다.

코드 예시

다음 프로그램은 문자열의 소유권을 main에 유지하면서 함수에 읽기 참조를 빌려주고, 이후 가변 참조로 내용을 변경한다.

fn print_length(text: &String) {
    println!("길이: {}", text.len());
}

fn add_language(text: &mut String) {
    text.push_str(" Rust");
}

fn main() {
    let mut message = String::from("Hello");

    print_length(&message);
    add_language(&mut message);

    println!("{message}");
}

새 프로젝트에서 실행하려면 다음 명령을 사용한다.

cargo new ownership_demo
cd ownership_demo
# 위 Rust 코드를 src/main.rs에 넣는다.
cargo run

print_length(&message)는 읽기 전용으로 빌리므로 message의 소유권이 이동하지 않는다. add_language(&mut message)는 배타적인 가변 참조를 받아 문자열을 수정한다. 각 함수 호출이 끝나면 빌림도 끝나므로 main은 마지막 println!에서 값을 다시 사용할 수 있다.

함정·실수

  • 힙 데이터를 가진 값을 함수에 그대로 넘기면 소유권이 이동해 이후 원래 변수를 사용하지 못할 수 있다. 함수가 값의 소유자가 될 필요가 없다면 &T를 전달한다.
  • 같은 값에 대해 두 개의 가변 참조를 동시에 만들 수 없다. 변경 작업의 범위를 줄이거나 순차적으로 수행한다.
  • 불변 참조가 사용되는 동안 가변 참조를 만들면 충돌한다. 마지막 참조 사용 이후로 변경 시점을 옮기거나 별도 범위를 만든다.
  • 참조 대상보다 오래 살아남는 참조를 반환하려 하면 컴파일되지 않는다. 함수 내부에서 만든 지역 값의 참조 대신 소유된 값을 반환한다.
  • 컴파일 오류를 피하려고 무조건 clone()을 사용하면 불필요한 힙 할당과 복사가 늘어난다. 먼저 이동이 필요한지, 참조로 충분한지 판단한다.
  • mut가 값 자체를 항상 힙에 저장한다는 뜻은 아니다. 가변성, 저장 위치, 소유권은 서로 다른 개념이다.

베스트 프랙티스

  • 함수가 값을 소비해야 할 때만 T를 받고, 읽기만 하면 &T, 변경해야 하면 &mut T를 받는다.
  • 문자열을 읽기만 하는 함수에는 가능하면 &String보다 더 일반적인 문자열 슬라이스 &str을 사용한다.
  • 가변 상태와 가변 참조의 유효 범위를 작게 유지해 코드의 변경 지점을 명확히 한다.
  • 소유권 이동이 의도된 API에는 into_처럼 소비를 드러내는 이름을 사용하고, 대여하는 API와 구분한다.
  • 빌림 검사기 오류는 단순한 문법 장애가 아니라 데이터의 소유자와 수명이 불분명하다는 신호로 해석한다.
  • 반복적인 clone()이나 복잡한 수명 표기가 나타나면 데이터 구조와 책임 경계를 먼저 재검토한다.
  • cargo check로 빠르게 타입과 소유권 검사를 수행하고, 실행이 필요할 때 cargo run, 배포용 최적화 빌드에는 cargo build --release를 사용한다.

참고

  • Rust 패키지 관리자와 빌드 도구: Cargo
  • Rust 컴파일러: rustc
  • Rust 표준 라이브러리: 입출력, 파일 시스템, 동시성 등을 위한 모듈 제공
  • 영상에서 언급한 활용 분야: 게임 엔진, 데이터베이스, 운영체제, WebAssembly
  • 영상에서 언급한 역사: 2007년 Graydon Hoare의 개인 프로젝트로 시작했으며 2009년 Mozilla의 후원을 받음