Page

관련 포스팅

2023년 9월 12일 화요일

Week01 - Rust 기초 문법 정리2 (Ownership, Borrow, Array)

지난 포스팅에 이어서 Rust 기초 문법을 정리해 보겠다.


Ownership


Ownership(소유권)은 Rust의 가장 핵심적인 개념 중 하나이다. 이는 메모리 관리와 데이터 레이스(data races) 문제를 안전하게 처리하기 위한 기능이라고 볼 수 있는데, Ownership은 변수의 "소유자"가 누구인지 정의하고, 이 소유자가 scope를 벗어나면 해당 변수가 메모리에서 해제되게 하는 역할을 한다.

기본 룰
  • 각 값은 하나의 "소유자"만 가질 수 있음.
  • 값을 빌려올 때는 두 종류의 빌림이 있음
    • mutable(가변)과 immutable(불변).
    • 한 시점에서는 하나의 가변 빌림이나 여러 개의 불변 빌림이 가능함.
    • 둘을 동시에 할 수는 없음.

소유권 전달의 예시:

let s1 = String::from("hello");
let s2 = s1;  // s1의 소유권이 s2로 이동


데이터 빌려오기:

let s1 = String::from("hello");
let s2 = &s1;  // s2는 s1의 불변 참조


가변 빌림:

let mut s1 = String::from("hello");
let s2 = &mut s1;  // s2는 s1의 가변 참조


장점

  • 메모리 누수, 참조되지 않는 메모리(dangling references), 데이터 레이스 등의 문제를 컴파일 타임에 방지할 수 있다.
  • 수동으로 메모리를 관리할 필요가 없으므로 개발자가 더 안전한 코드를 쉽게 작성할 수 있다.

단점

  • 다른 언어에 익숙한 개발자에게는 처음에 어려울 수 있다. 
    • 소유권, 빌림, 라이프타임 등을 명확하게 이해해야 함.
  • 함수에 데이터를 넘길 때 소유권을 어떻게 처리할지 신경써야 하기 때문에, 작성하는 코드가 복잡해질 수 있음.
Ownership은 Rust의 안전성과 효율성을 보장하는 기반이니, 이해하고 잘 활용하는 것이 중요하다 하겠다.



데이터 레이스(Data race) 문제란?


데이터 레이스(data race)는 멀티스레딩 환경에서 발생할 수 있는 문제 중 하나로, 두 개 이상의 스레드가 동시에 같은 메모리 위치에 접근하려고 할 때 발생해. 특히 이 중 적어도 하나의 스레드가 데이터를 수정(write)하려고 하면, 예측 불가능한 결과나 오류를 초래할 수 있다.

데이터 레이스가 발생하면 다음과 같은 문제들이 일어날 수 있다.

  • 불일치한 상태
    • 한 스레드가 데이터를 변경하는 도중에 다른 스레드가 그 데이터를 읽어가면, 데이터의 불일치 상태가 발생할 수 있음.
  • 메모리 오류
    • 하나의 스레드가 메모리를 해제하고, 다른 스레드가 그 메모리에 접근하려고 하면 문제가 발생함.
  • 성능 문제
    • 데이터 레이스를 해결하기 위한 락(lock) 같은 동기화 메커니즘이 과도하게 사용되면, 오히려 성능 저하를 가져올 수 있음.

use std::thread;

fn main() {
    let mut data = vec![1, 2, 3];

    thread::spawn(move || {
        data[0] += 1;
    });

    data[0] += 1;
}


위의 Rust 코드 예제에서는 컴파일러가 데이터 레이스를 잡아내고 컴파일을 실패하게 할 건데, 이렇게 Rust는 컴파일 시점에서 이런 문제를 미리 잡아내게 된다. 그래서 실행 시간의 오류 발생을 최소화하는 것이다.

데이터 레이스는 프로그램의 정확성을 크게 떨어뜨리는 요인 중 하나이므로, 이를 미리 잡아내고 수정하는 것이 중요하다고 할 수있다. Rust의 경우 이 문제를 컴파일 시점에서 잡아내는 강점이 있다.


소유권을 전달하는 방법


Rust에서 소유권(Ownership)은 메모리 안정성을 보장하기 위한 중요한 개념이다. 소유권의 기본 원칙은 "하나의 값은 단 하나의 소유자만 가져야 한다"라는 것이다. 이렇게 하면 메모리 누수나 데이터 레이스 같은 문제를 피할 수가 있다.

소유권 전달 방법에는 크게 3가지가 있다.
  1. Move
  2. Clone
  3. Borrowing


1. Move

소유권을 완전히 다른 변수로 이동시키는 것이다. 이 경우, 원래의 변수는 더 이상 그 값을 사용할 수 없게 된다.

let a = String::from("hello");
let b = a; // 여기서 a의 소유권이 b로 이동(move)함.
// println!("{}", a); // 에러! 'a'는 더 이상 유효한 소유자가 아니게 된다.

소유권을 이동시키기 위해서는 값 타입을 다른 변수에 대입하면 그것의 소유권이 이동된다.

let x = String::from("hello");
let y = x;

위 코드에서 y = x; 가 실행되면, x의 소유권이 y로 이동한다. 즉, y = String::from("hello");이라고 생각할 수 있다. 소유권이 이동한다는 것은 y가 이제 그 데이터를 완전히 '가지고 있다'는 의미이다. 이 상황에서 x는 더 이상 유효하지 않게 된. 즉, x는 그 데이터에 대한 모든 권리를 y에게 넘긴 것이다. 여기서 x가 더 이상 유효하지 않다는 것은 x가 가리키는 데이터에 대한 접근 권한을 잃었다는 것을 의미한다. 하지만 실제로 메모리에서 사라지는 것은 아니다. x라는 변수 자체는 여전히 메모리에 존재하지만, 그 변수가 가리키던 데이터에 대한 '소유권'이 y로 이동했기 때문에, x는 그 데이터에 더 이상 접근할 수 없다.

메모리에서 실제로 사라지는 것은 y가 스코프 밖으로 나갈 때다. Rust의 소유권 시스템 덕분에, y가 스코프를 벗어나면 자동으로 그 데이터는 메모리에서 해제된다. 이러한 자동 메모리 관리가 Rust의 중요한 특징 중 하나다.

이 상태에서 x를 다시 사용하려고 하면 컴파일 에러가 발생한다. 왜냐하면 x는 더 이상 해당 데이터에 접근할 권리가 없기 때문이다. 

여기서 x가 메모리에서 사라지지 않는 이유는 Rust의 소유권 시스템이 변수와 데이터를 분리해서 다루기 때문이다. x는 변수고, String::from("hello")는 데이터다. 소유권 이동은 데이터에 대한 접근 권한을 다른 변수에게 넘기는 것이지, 변수 x 자체를 없애는 것이 아니다.

변수 x가 계속 메모리에 존재하는 것이 문제가 되지 않는 이유는 다음과 같다:

  • 스코프: x가 정의된 스코프가 끝나면, x는 어차피 메모리에서 제거된다.
  • 재할당: x에 다른 값을 할당할 수 있다. 이 경우 x는 새로운 데이터에 대한 소유권을 얻는다.

예를 들어,

let x = String::from("hello");
let y = x;
let x = String::from("world"); // x에 새로운 값을 할당


이런 식으로 x에 다른 값을 할당할 수 있고, 그러면 x는 새로운 데이터에 대한 소유권을 가진다. 따라서 x가 계속 메모리에 존재하는 것이 쓸모 없지 않은 것이다.

2. Clone

값을 복제해서 새로운 소유자에게 전달한다. 원본 값과 소유권은 그대로 유지된다.

let a = String::from("hello");
let b = a.clone(); // 값을 복제해서 'b'에게 전달
println!("{}", a); // 'a'는 여전히 유효하다.

복제를 하려면 위와 같이 .clone() 메서드를 사용한다.


3. Borrowing

소유권을 빌려 쓰는 것. 빌린 값은 원본을 수정할 수 없고, 일정 범위(scope) 안에서만 사용할 수 있다.

fn use_value(value: &String) {
    println!("{}", value);
}
let a = String::from("hello");
use_value(&a); // 'a'의 소유권을 빌려서 함수에 전달
println!("{}", a); // 'a'는 여전히 유효하다.

Borrowing에는 2가지 유형이 있다.
  1. Immutable Borrowing
  2. Mutablie Borrowing

Immutable Borrowing
& 기호를 사용해서 빌림을 한다. 이렇게 하면 값은 변경할 수 없다.

let x = String::from("hello");
let y = &x;

y는 이제 x를 빌렸지만, x를 변경할 수는 없다.

Mutable Borrowing
&mut 기호를 사용한다.

let mut x = String::from("hello");
let y = &mut x;

이제 y는 x를 변경할 수 있는 권한을 가지게 된다. 단, 한 시점에서 하나의 가변 빌림만 있을 수 있다.

이런 문법을 사용해서 Rust에서는 소유권을 효율적이면서도 안전하게 관리할 수 있다. 여기에 더해, 컴파일러가 이 모든 규칙을 검사하기 때문에 실행 시간의 오류를 줄일 수 있는 것이다.

원리

  • 소유권 이동(Move)
    • 메모리 위치에 직접 접근하는 포인터를 다른 변수로 옮긴다. 이렇게 하면 첫 번째 변수는 더 이상 그 메모리에 접근할 수 없어서 메모리 안정성 문제가 발생하지 않는다.
  • 복제(Clone)
    • 메모리를 새로 할당받고 원본 데이터를 복사함. 이렇게 하면 원본 소유자가 그대로 유지되기 때문에 안전하다.
  • 빌림(Borrowing)
    • 원본 소유자가 그대로 유지되고, 빌린 변수는 일시적으로 그 값을 읽거나 쓸 수 있다. 빌림이 끝나면 원본 소유자가 다시 그 값을 제어할 수 있다.

이렇게 Rust에서는 컴파일 시점에서 소유권과 빌림을 검사해서 프로그램이 안전한지 확인한다. 그리고 이런 방식으로 메모리 누수나 데이터 레이스 같은 문제를 미리 막을 수 있다.


Non-Lexical Lifetime

Lifetime은 Rust에서 변수나 데이터가 얼마나 오랫동안 유효한지를 나타내는 개념이다. 여기서 Lifetime은 메모리의 안정성을 보장하는 데 중요한 역할을 한다. Rust는 기본적으로 Non-Lexical Lifetime(NLL)을 사용한다. Rust 2018 에디션부터 NLL이 기본으로 적용되어 있으며, 이를 통해 더 정밀하고 유연한 메모리 관리가 가능하다. NLL은 변수가 실제로 어떻게 사용되는지를 분석하여 lifetime을 결정한다. 이러한 접근 방식은 코드의 메모리 안정성을 높이면서도, 개발자에게 더 많은 유연성을 제공한다.

Lexical Lifetime

Lexical Lifetime은 코드의 구문적인 구조를 기반으로 변수의 lifetime을 결정한다. 즉, 변수가 정의된 블록의 시작과 끝을 기준으로 변수의 lifetime을 판단한다. 이 방식은 코드를 분석하기 쉽고 예측 가능하다는 장점이 있지만, 때로는 너무 보수적으로 메모리를 관리할 수 있다.

예를 들어,

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }
    println!("r: {}", r);
}


위 코드는 Lexical Lifetime 방식에서는 컴파일 에러가 발생한다. 왜냐하면 x의 스코프가 끝나면서 메모리가 해제되므로, r이 더 이상 유효하지 않게 되기 때문이다.

Non-Lexical Lifetime (NLL)

NLL은 더 세밀한 분석을 통해 변수의 실제 사용 패턴을 살펴본다. 이로 인해 코드가 더 유연해지고, 변수를 더 효율적으로 관리할 수 있다.

예를 들어,

fn main() {
    let x;
    if true {
        x = 5;
    }
    // ... (x를 사용하지 않는 코드)
    let y = x; // NLL에서는 여기서 x의 lifetime을 시작한다고 판단
}


위 코드는 NLL 방식에서는 컴파일이 가능하다. 왜냐하면 x는 실제로 y = x;에서 처음으로 사용되므로, 그 전까지의 코드에서 x의 lifetime을 고려할 필요가 없기 때문이다.

차이점

Lexical Lifetime은 구문적인 범위를 기반으로 하기 때문에 보수적이다. 반면 NLL은 더 실질적인 사용 패턴을 분석하여 더 유연한 메모리 관리가 가능하다. NLL은 Rust 2018 에디션부터 기본적으로 적용되고 있다.

간단하게 말하면, Lexical Lifetime은 "변수가 어디에 선언되었는가"를 보고, NLL은 "변수가 어떻게 사용되었는가"를 본다. 이로 인해 NLL이 더 정확한 메모리 관리를 가능하게 한다.


Array


Rust에서 배열(array)은 고정된 크기를 가지며, 모든 원소가 같은 타입이어야 한다. 배열을 선언하는 기본 문법은 다음과 같다.

let 변수명: [타입; 크기] = [값1, 값2, ...];

예를 들어 정수형 배열을 선언하려면:

let arr: [i32; 5] = [1, 2, 3, 4, 5];


Rust만의 특별한 점 중 하나는 배열의 크기가 타입의 일부로 취급된다는 것이다. 이 말은 크기가 다른 배열이면 서로 다른 타입으로 간주된다는 것이다. 예를 들어, [i32; 5]는 길이가 5인 정수 배열의 타입이고 [i32; 6]은 길이가 6인 정수 배열의 타입이다. 이는 서로 다른 타입으로 간주한다. 

fn print_array(arr: [i32; 3]) {
    for i in arr.iter() {
        println!("{}", i);
    }
}

let array1 = [1, 2, 3]; // 이 배열은 [i32; 3] 타입이다.
let array2 = [1, 2, 3, 4]; // 이 배열은 [i32; 4] 타입이다.

print_array(array1); // 가능
// print_array(array2); // 컴파일 에러


예를 들어, 위의 코드에서 처럼 print_array/1의 파라메터를 [i32; 3]으로 받기로 정의했다면 [i32; 4]가 들어갈 경우 에러가 나게된다.


이것은 컴파일 시점에서 배열의 크기가 변하지 않도록 보장해 주며, 배열에 인덱스로 접근할 때 범위를 검사한다. 즉, 이 특성으로 인해 Rust의 타입 시스템의 일부로 배열의 크기가 다르면 그것들이 서로 다른 타입이라고 엄격하게 판단한다. 그리고 이는 타입의 안전성을 높이며, 컴파일 시점에 여러 종류의 에러를 미리 잡을 수 있게 도와준다.


let arr: [i32; 5] = [1, 2, 3, 4, 5];
println!("{}", arr[5]); // 이 코드는 컴파일되지 않는다.


위의 코드에서 println!("{}", arr[5]); 이 코드가 컴파일되지 않는 이유는 배열 arr의 인덱스가 0부터 4까지이기 때문이다. Rust에서 배열 인덱싱은 0부터 시작하고, 이 배열의 크기는 5이기 때문에 유효한 인덱스는 0, 1, 2, 3, 4가 된다.

따라서 arr[5]는 배열의 범위를 벗어나는 인덱스에 접근하는 것이므로, Rust 컴파일러는 이를 컴파일 에러로 잡아낸다. 이렇게 하면 실행 중에 발생할 수 있는 배열 범위 초과 오류를 미리 방지할 수 있다. 따라서 println! 문은 아예 컴파일이 되지 않는 것이다. 이렇게 Rust는 동작의 안전성을 컴파일 단계에서 높이게 된다.


배열을 초기화할 때는 ;를 사용하여 동일한 값으로 채울 수도 있다.

let arr: [i32; 5] = [0; 5]; // 모든 원소가 0인 길이 5의 배열

이러한 특성들로 인해 Rust의 배열은 고정된 크기와 타입 안정성을 제공한다.


Rust에서 배열은 동일한 타입의 여러 값들을 연속적인 메모리 공간에 저장하는 데이터 구조다. 배열은 고정된 크기를 가지고 있다. 이게 Rust 배열의 한정된 점이기도 하며, 동적으로 크기를 변경할 수 없다.

배열 선언과 초기화
배열을 선언하고 초기화하는 가장 기본적인 문법은 아래와 같다.

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

여기서 a는 정수를 원소로 가진 길이가 5인 배열이다.

타입과 길이를 명시적으로 지정할 수도 있다.

let b: [i32; 5] = [1, 2, 3, 4, 5];

배열의 모든 원소를 동일한 값으로 초기화하려면 다음과 같이 할 수 있다.

let c = [3; 5];  // [3, 3, 3, 3, 3]

배열의 원소에 접근하기
배열의 원소에 접근하는 문법은 대괄호([])를 사용한다.

let first = a[0];  // 첫 번째 원소
let second = a[1]; // 두 번째 원소

배열과 반복문
배열을 순회하는 데에는 for 반복문을 사용할 수 있다.

for element in a.iter() {
    println!("{}", element);
}


배열의 크기
배열의 크기는 .len() 메서드로 확인할 수 있다.

let length = a.len();  // 5

배열의 한계
배열은 크기가 고정되어 있다. 동적으로 크기를 변경할 수 없다.
배열의 모든 원소는 동일한 타입이어야 한다.

이런 한계로 인해 더 유연한 데이터 저장이 필요한 경우에는 벡터(Vec<T>)를 사용한다. 벡터는 크기가 동적으로 변경될 수 있고, push와 pop 같은 메서드를 통해 쉽게 원소를 추가하거나 제거할 수 있다.


&a와 a의 차이점


&a와 a의 차이는 소유권과 뮤터블리티(가변성)에 관련된 것이다.

&a는 a 배열의 레퍼런스(참조)를 생성한다. 이렇게 하면 a의 데이터를 읽을 수 있지만 수정은 할 수 없다. 또한, 이 과정에서 a의 소유권이 이동하거나 복제되지 않기 때문에 a를 계속 사용할 수 있다.

a 자체를 사용하면, 그 배열의 소유권이 해당 스코프로 이동하게 된다. Rust에서는 소유권이 중요하므로, 이렇게 하면 원래 a를 선언한 스코프에서는 더 이상 a를 사용할 수 없게 된다.

예를 들어:

let a = [1, 2, 3];

for x in &a {
    println!("{}", x);
}

// 'a'는 여전히 유효하다.
println!("{}", a[0]);

// 이렇게 하면 소유권 문제가 발생한다.
// for x in a {
//     println!("{}", x);
// }

// 'a'는 더 이상 사용할 수 없다.


&a를 사용하면, a를 빌리기만 하는 것이므로 원래의 배열 a를 계속 사용할 수 있다. 이것이 Rust에서 소유권과 빌림이 어떻게 작동하는지의 기본적인 예시다.

결론적으로 소유권을 빌린다는 개념은 그것의 값을 변경만 하지 못하고 값을 사용만 하려고 가져다 쓴다는 얘기와 동일한 것이다.

Rust에서 "빌린다"라는 표현은 주로 참조(reference)를 생성할 때 사용되며, 이것은 변수의 소유권을 이동시키거나 복제하지 않는다. 빌린 변수를 사용하면 원래의 데이터를 읽을 수는 있지만, 뮤터블 참조(&mut)를 생성하지 않는 이상 변경할 수는 없다.

  • 불변 참조(&): 데이터를 읽을 수만 있다.
  • 뮤터블 참조(&mut): 데이터를 읽고 변경할 수 있다.
빌림은 원본 데이터를 안전하게 다른 함수나 스코프에서 사용할 수 있게 해준다. 이렇게 하면 메모리 안전성이 높아지고, 데이터 레이스 같은 병렬 프로그래밍 문제를 예방할 수 있다.

여기서 이런 의문이 들 수 있다.

소유권을 가져오면 되는데 굳이 뮤터블 참조를 할 필요가 있나?

뮤터블 참조를 사용하는 이유는 여러 가지가 있다.

  • 메모리 효율성
    • 소유권을 가져오면 데이터가 복제되거나 새로운 메모리 공간이 할당되어야 한다. 뮤터블 참조를 사용하면 원본 데이터의 위치에서 바로 수정이 가능해서 메모리 사용이 더 효율적이다.
  • 함수와 데이터 분리
    • 함수가 데이터의 소유권을 가져가면 그 데이터는 해당 함수 내에서만 사용 가능하다. 뮤터블 참조를 사용하면 원래의 변수를 다른 곳에서 계속 사용할 수 있다.
  • 데이터 레이스 방지
    • 뮤터블 참조 규칙에 따라 한 번에 하나의 뮤터블 참조만 허용된다. 이렇게 하면 데이터 레이스의 가능성이 줄어든다.
  • 의도 표현
    • 코드를 읽는 사람에게 "이 데이터는 변경될 수 있다"는 사실을 명시적으로 알려주는 것이 좋다. 이렇게 하면 코드의 명확성과 유지 관리성이 높아진다.

즉, 소유권을 전달하는 방법이 간단한 경우도 있지만, 상황에 따라 뮤터블 참조가 더 적합한 경우가 많다.


이번 포스팅에서는 여기까지 이론적인 사항을 정리해본다. 다음 포스팅에서는 실제 코드를 통해서 분석을 해보도록 하겠다.


댓글 없음:

댓글 쓰기

관련 포스팅