Rust lang - 소유권(Ownership)에 대한 생각
https://rust-book.cs.brown.edu/ch04-00-understanding-ownership.html Understanding Ownership - The Rust Programming LanguageOwnership is Rust’s most unique feature and has deep implications for the rest of the language. It enables Rust to make memory safe
cdbst.tistory.com
서론
이전에 작성한 소유권에 대한 생각을 먼저 읽으면 앞으로 작성한 글에서 무슨 소리를 하는지 이해하기 편할 것이다.
러스트에는 참조와 대여라는 개념이 있는데, 이번 글에서는 이러한 개념이 탄생하게된 이유가 무엇인지 고찰해보고자 한다.
앞서 이해해본 소유권 개념은 러스트로 프로그램을 만드는데 있어서 상당한 불편함을 초래한다. 아래의 예시를 보자.
위 프로그램은 문제가 있다. 러스트에 익숙치 않은 사람은 "이게 왜?" 라는 의문이 저절로 들 것 같다.
문제가 되는 이유에 대해 좀 더 자세히 풀이하면 다음과 같다.
1. greet 함수가 호출되면서 main 함수에서 할당되었던 heap에 할당된 메모리들에 대한 소유권들이 각각 greet함수의 파라미터 g1과 g2로 전달됐다.
2. greet 함수가 return 되면서 자동으로 스택 프레임이 해제되고 동시에 g1, g2도 해제된다.
3. heap에 대한 소유권을 가지고 있던 g1, g2가 해제되면서 동시에 2개의 heap 메모리 모두 해제된다.
4. L3 라인에서 소유권을 잃어버린 m1, m2를 사용하면서 컴파일 에러 발생!. (해제된 메모리에 대한 접근시도 까지 문제이다.)
이 예제를 처음 접했을 때 "너무 불편하지 않나?" 라는 생각부터 먼저 들었다.
그러면 아래 예제와 같이 약간은 미쳐보이는 프로그램을 작성해야 하는 것일까?
(미쳐 보이긴 하지만 greet 함수의 return 값을 설정하는 방법으로 소유권을 다시 되돌려 주면서 문제는 발생하지 않는다.)
이와 같이 불편해 보이고 약간은 미쳐보이는 프로그램 방식을 극복하고자 탄생한 개념이 참조(References)이다.
참조는 말그대로 소유권을 잠시 빌려가기 위한 개념이다. 소유권을 빌려가서 언제 되돌려 주는지 잘 이해할 필요성이 느껴진다.
아래 예제는 위의 미친짓 같아 보이는 프로그램을 소유권을 대여해주는 방식으로 다시 작성한 프로그램이다.
위의 프로그램에서 greet 함수의 두 개의 파라미터 타입 앞에 `&` 기호를 추가한 부분이 중요하다.
& 엠퍼센드를 사용하면 소유권을 넘겨주는 대신 잠시 빌려주도록 할 수 있다.
마찬가지로 greet 함수르 호출할때 기존 변수이름 앞에 `&`를 붙여서 변수를 그대로 넘기는게 아니라 참조를 넘기는 것이다.
이렇게 하면 greet함수 호출시 생성되었던 스택 프레임이 사라질 때 g1, g2가 가르키던 힙 메모리를 해제 하지 않게 된다.
왜냐하면 소유권을 빌려간후 다시 되돌려주기 때문에 함수가 return되면서 data의 drop()을 발생시키지 않기 때문이다.
이와같이 참조를 사용하면 소유권 이전 발생으로 인한 프로그램 작성시 불편한 부분들이 해소될 수 있다.
그러나 참조를 사용하여 얻는 편의성으로 인해 몇 가지 리스크가 있다.
참조(대여) 방식의 리스크
만약 어떤 변수 `origin`에 대해 여러 개의 참조(대여)들 (borrow1, borrow 2, borrow 3 ...)이 존재한다면 어떨까?
특히나 `origin`이 `mut`(변경 가능) 속성으로 생성된 변수라면?
빌려간 여러 개의 참조 변수들이 서로 경쟁하는 data racing 상황이 예상되고 런타임에 프로그램이 불확실성에 노출될 것이다.
아래는 그 구체적인 예시이다.
- 별칭이 있는 데이터의 해제(deallocating):
- 어떤 데이터를 여러 변수가 참조하고 있을 때, 한 변수를 통해 메모리를 해제하면 다른 변수는 이제 해제된(deallocated) 메모리를 가리키게 됩니다.
- 이런 상황을 "댕글링 포인터(dangling pointer)" 또는 "허상 참조(use-after-free)" 문제라고 합니다.
- 이후 그 참조를 사용하면 예측 불가능한 동작이나 프로그램 충돌을 일으킬 수 있습니다.
- 별칭이 있는 데이터의 변경(mutating):
- 여러 곳에서 같은 데이터를 참조하고 있을 때, 한 참조를 통해 데이터를 변경하면 다른 참조가 기대하는 런타임 속성이 무효화될 수 있습니다.
- 예를 들어, 정렬된 배열을 참조하는 코드가 있다고 가정할 때, 다른 참조를 통해 배열의 순서를 바꾸면 정렬 상태를 가정하는 코드가 오작동할 수 있습니다.
- 이는 특히 불변성(immutability)을 가정하는 코드에서 문제가 됩니다.
- 별칭이 있는 데이터의 동시 변경(concurrent mutation):
- 멀티스레딩 환경에서 여러 스레드가 동시에 같은 데이터를 변경하려고 할 때 발생합니다.
- 이런 상황을 "데이터 레이스(data race)"라고 하며, 결과적으로 비결정적(nondeterministic) 동작을 초래합니다.
- 어떤 스레드의 변경이 먼저 적용될지 예측할 수 없고, 부분적으로 업데이트된 불일치 상태가 발생할 수 있습니다.
위 예제를 보면 `L1 - let num: &i32 = &v[2]; ` 에서 참조를 만들고 `L2 - v.push(4) [&v.push(4)]`에서 변경을 했다.
L2의 수행 결과로 기존에 L1이 가르키던 메모리가 해제되는 결과가 발생하고, 이 것을 그대로 컴파일을 허용하게 되면 L3라인에서 메모리 접근 관련 에러가 나타날 것이다.
바로 이러한 불확실성의 이유로 러스트는 컴파일 단계에서 borrowing 규칙을 적용했다.
핵심은 `러스트는 동시에 변수를 여러 곳에서 참조(aliasing)하면서 값을 변경(mutation)하는 것을 금지한다.` 이다.
러스트 컴파일러는 이와같이 Borrowing(대여) 동작에 대한 검증을 수행하는 `borrow checker`로 위와 같이 잘못된 참조(대여) 상황에 대해 검증한다.
borrow checker의 동작 원리에 대해 구체적으로 잘 이해 하고 있다면, 까다로운 러스트 컴파일러가 왜 나의 프로그램 빌드를 거부하는지 좀더 빠르게 이해할 수 있을듯 하다.
Borrow Checker
Borrow checker에 대해 이해하기 위해서는 각각의 변수마다 3 종류의 권한이 있다는 사실부터 이해해야 한다.
- 읽기 (R): 단순히 읽거나, 데이터를 다른 변수에 복사 할 수있음.
- 쓰기 (W): 데이터를 변경할 수 있는 권한.
- 소유 (O): 데이터를 이동 하거나 데이터 리소스 해제 권한.
위의 권한들은 런타임 상황에 적용되는 개념이 아니라 단순히 컴파일 단계의 borrow checker에서 사용되는 개념이다.
- 기본적으로 데이터는 읽기(R), 소유(O) 권한이 있다. 변수 생성시 mut 속성을 추가하면 쓰기(W) 권한까지 있다.
- 생성했던 변수를 &연산을 통해 참조를 생성(즉, 빌려주면)하면, 임시적으로 위의 W 및 O 권한을 잃는다.
- 빌려간 참조가 더 이상 사용되지 않으면 즉시 W, O권한을 빌려준 변수로 돌려준다.
아래 예제는 borrow checker가 위 원칙을 적용하여 확인하는 모습을 보여준다.
위의 예제를 보면 `*num`에 대한 권한과 `num`에 대한 권한을 따로 표기해두고 있다. 역(reverse)참조 연산이 추가된 것인데 전통적인 low level 프로그래밍에 익숙한 사람은 바로 이해할 수 있다. 역참조에 대한 권한이 따로 존재하는 것이 특징이다.
따라서 프로그램에서 *num에 쓰기 권한(W)을 부여하려면 `let num: i32 = &mut v[2];`와 같이 num 변수를 초기화 해야할 것이다.
그리고 공식 문서에서는 권한들은 변수에 정의되는 것이 아니라 `place`라는 것에 정의된다고 한다.
여기서 `place`는 값을 저장할 수 있는 모든 위치를 의미한다.
쉽게 설명하면 대입연산 `=` 왼쪽에 올 수 있는 모든 operand가 `place`이다.
프로그램의 흐름에서 어떠한 메모리 `A` 에 새로운 값이 대입될 때 이때 `A`를 `place`라고 생각하면 될 것이다.
그렇다면 `place`는 더 이상 사용되지 않을 때 권한들을 잃도록 했을까?
먼저 말해보자면 데이터 접근에 대해 mutex를 적용하기 위함이 아닌가한다. mutex를 적용하면 데이터에 대한 무결성 유지를 골칫거리가 해결되기 때문이다.
이와 관련해서 공식 문서에는 아래와 같이 좀 어렵게 설명하고 있다.
- 어떤 권한들은 상호 배제(mutually exclusive)이다.
- 만약 num = &v[2] 한다면, num이 사용되는 동안 v는 변경(mutated)될수 없고 해제(dropped)될 수 없다.
- 하지만 이러한 사실은 num을 영구적으로 재사용 불가능 하다는 의미는 아니다.
그냥 이 설명을 듣고 내가 생각한 것은 이 전에 정리 했던 참조가 더 이상 상용되지 않게 될 때 권한을 되돌려 주는 동작을 설명한 것처러 보인다. 그러니깐 빌려간 (& 연산을 이용해) 녀석이 프로그램 플로우 상 더 이상 사용되지 않는 타이밍에 빌려간 R, W, O 같은 권한을 빌려준 원래 녀석에게 되돌려 준다는 뜻으로 이해했다.
그 다음 주제는 어떻게 borrow checker가 권한 위반을 감지하는 지를 보여준다.
이 설명에 앞서 공식문서는 다음과 같은 원칙을 상기해라고 한다.
Pointer Safety Principle: data should not be aliased and mutated. The goal of these permissions is to ensure that data cannot be mutated if it is aliased.
데이터가 참조되고 있다면, 해당 데이터의 변경은 일어나서는 안된다는 원칙이다. 쉽게 설명해서 어떤 변수를 참조에게 권한을 빌려주고나서 부터는 되돌려 받기 이전까지는 read 권한만을 가진다는 뜻이다.
이 원칙을 잘 이해하고 있다면 아래 프로그램이 왜 문제가 되는지 곧잘 이해할 수 있을 것이다.
borrow checker는 num이 아직 v에게 권한을 돌려주지 않았는데 `v.push(4)`로 v를 변경(mutate)하는 것을 보고 권한 위반을 이유로 프로그램을 컴파일 거부할 것이다.
다음은 `mutable references`에 대해 좀더 알아 보자.
앞서 짧게 설명한 내용중 참조로 place에게 값을 대여할 때 `&` 대신 `&mut`으로 대신할 수 도 있다고 했다.
`&mut`으로 값을 대여하면 역참조(*)를 통해 원본 값을 변경도 가능하다는 의미이다.
위 예시에서 mutable reference를 사용하면 빌려준 원본(여기서는 v)이 R(읽기) 권한까지 잠시 사라지는 것을 확인 할수 있다.
그리고 빌려간 num을 통한 역참조 *num에 W(쓰기)권한이 생기는 것을 확인 할 수 있다.
또 한가지 `변경 참조를 통해 빌려주면 왜 원본의 R(읽기) 권한까 임시적으로 잃도록 했을까?`라는 의문이 생긴다.
원본이 읽기 권한 까지 잃게되면 더 이상 빌려주는 행위도 할수 없다.
`변경 참조`를 통해 빌려주고 나면 더 이상 원본을 통해 빌려주는 행위는 할 수 없다는 의미가 된다. (단하나의 참조만 존재 하게됨)
이러한 borrow checker의 동작은 앞서 설명한 `참조(대여) 방식의 리스크`를 회피하고자 하는 장치처럼 이해된다.
빌려간 녀석이 W(쓰기)권한을 가지고 있기 때문에 언제든 데이터를 변조할 수 있고, 빌려가 있는 상태에서 원본이 데이터를 읽기 할 경우 올바르지 않는 결과를 낳을수도 있기 때문에 이런 장치를 도입한게 아닌가 싶다.
변경 참조의 권한은 다운그레이드 될 수도 있다고 하는데 그 사례는 아래와 같다.
`&mut v[2]`와 같이 변경 참조로 값을 빌리고 또 다시 `&*num`을 통해 역참조 후 다시 변경 불가 참조로 다른 녀석(num2)에게 간접적으로 빌려주게 되면 처음 변경 참조로 빌려줬던 `num`의 W(쓰기) 권한이 박탈 당하는 것을 확인 할 수 있다.
권한들은 참조 생명주기가 끝날 때 반환된다
참조가 최초 생성되면 원래 빌려준 녀석의 권한을 변경시키고, 참조가 더 이상 사용되지 않을 때 얻어오면서 가져왔던 권한들을 반환한다. 참조의 생명주기가 시작되는 지점은 `&`또는 `&mut`을 통해 값을 빌려오는 부분이 되겠고, 생명주기가 끝나는 지점은 프로그램 흐름 상 더 이상 참조가 사용되지 않는 타이밍을 의미한다.
내 생각이지만 생명주기가 끝나는 지점을 사람이 추적하면서 프로그래밍 하는 것은 꽤나 피곤한 일이 될 듯하다.
위 예제에서 y의 생명주기는 `let z = *y;`가 실행되고 끝난다.
생명주기가 끝나면 x로 부터 빌려간 권한들을 다시 x로 돌려주는 것을 확인 할 수있다.
그리고 아래와 같이 조건문이 있을 때 참조의 생명주기는 조건에 따라 달라진다.
요약
1. 빌려간놈보다 빌려준놈이 더 오래 살아 있어야한다.
2. 빌려간놈이 빌려준놈을 바꾸려는 상황에서는 여러번 빌려줄수 없다.
3. 빌려주고나서 되돌려 받기 전까지는 딴 짓 못한다.
4. 빌려주고나서 더 이상 안쓰면 다시 갚더라.
'IT' 카테고리의 다른 글
Rust lang - 소유권(Ownership)에 대한 생각 (0) | 2025.04.18 |
---|---|
Effective Typescript - 11 - 잉여 속성 체크 한계 인지하기 (0) | 2022.01.25 |
Effective Typescript - 10 - 객체 래퍼 타입 피하기 (0) | 2022.01.25 |
Effective Typescript - 9 - 타입 단언보다는 타입 선언을 사용하기 (0) | 2022.01.25 |
Effective Typescript - 8 - 타입 공간과 값 공간의 심벌 구분하기 (0) | 2022.01.24 |