본문 바로가기

러스트의 가변성(Mutability) 이야기

기술적인 이야기/기타 개발 2020. 9. 4.
반응형

가변성(Mutability)과 불변성(Immutability)은 현대적인 언어에서는 제법 잘 다뤄지는 주제일지도 모릅니다. 그도 그럴게 코드의 구조나 성능이나 메모리 효율성이나 관리 등 다양한 면에서 영향을 끼치기 때문이니깐요. 러스트(Rust)도 비슷한 이야기를 할 거리가 있네요.

사실 이 이야기는 초반에 변수나 타입과 관련된 이야기와 함께 등장했었어야 하는 게 아닐까 할 정도로 기초적인 내용일지도 모릅니다.

불변 변수(Immutable Variables)와 가변 변수(Mutable Variables)

불변 변수는 생성될 때 받은 초기값을 변경할 수 없는 변수를 의미합니다. 이미 앞선 글들에서도 언급했지만 러스트의 변수는 기본적으로 불변성을 가집니다.

let age = 16;
age = 17;      // ERROR

위처럼 let으로 선언된 변수는 한번 값을 넣으면 다시는 값을 바꾸는 것이 불가능합니다.

분명 이 부분은 C 언어와는 다릅니다. C 언어는 상수를 제외하고는 애초에 불변 변수라는 개념이 없으니까요. 파이썬(Python)도 불변 개념은 일부분의 타입에서만 지원되고 기본은 가변 변수죠. Swift는 불변과 가변을 별도의 방법으로 정의합니다. 이와는 조금 다르게 러스트는 불변을 기본으로 채택하고 있습니다.

어쨌거나 불변이 있다는 말은 가변도 있다는 말이 됩니다.

let mut age = 16;
age = 17;

mut라는 속성을 추가하는 것으로 해당 변수는 가변성을 가지게 됩니다. 이렇게 하면 해당 변수의 값을 마음대로 바꿀 수 있게 됩니다.

이렇게 불변이든 가변이든 별로 어려울 건 없지만 왜 굳이 기본이 불변인 것일까요? 왜 가변을 불편하게 만들었을까요?

 

가변성(Mutability)

가변 변수는 값을 바꿀 수 있는 변수입니다. 이 말을 약간 자세히 정리하자면 변수의 값을 변경해야 할 때 해당 변수가 가리키는 메모리의 값을 직접 바꾸는 형태를 의미합니다. 물론 원시 타입에 한해서는 약간 다르겠지만 일단 넘어갑니다.

가변 변수는 데이터를 변경할 때 메모리에 바로 값을 쓰기 때문에 일반적으로 쓰기가 빠릅니다. 물론 복잡한 자료구조 타입의 경우는 예외일 수도 있습니다만 어쨌든 값을 바꾸는 것이 빠른 편입니다.

언어별로 특징이 다를 수도 있겠지만, 이런 가변성은 변수를 다른 변수에 대입하는 과정에서 발생하는 일이 단순한 편입니다. 바로 레퍼런스를 넘겨받는다는 점이지요. 즉 한 변수를 다른 변수에 대입했다면 둘 다 같은 레퍼런스를 가리키게 되어 결국 한 변수의 값을 바꾸면 다른 변수의 값도 바뀌게 되는 것과 같은 결과가 나타납니다. (물론 러스트의 원시 타입들에는 해당하지 않는 내용일 수도 있습니다.)

이런 레퍼런스 복제 특성은 싱글톤(Singleton) 패턴 등 공유 인스턴스를 만들 때는 유리합니다.

하지만, 여러 곳에서 레퍼런스를 다루다 보면 필연적으로 값을 바꾸다 다른 곳에서 참조하는 곳까지 값이 바뀌는 문제라던가, 멀티스레딩 환경에서는 값이 바뀌는 타이밍을 동기 하기 위한 여러 장치가 들어가야 할 때도 있어서 복잡해지는 등 여러 문제를 유발하기도 합니다. 거기다 메모리 해제 시점도 레퍼런스가 더 이상 공유되지 않는 시점을 잡아야 해서 코드 설계를 잘해야 한다는 단점도 있습니다.

 

불변성(Immutability)

불변 변수는 이미 앞에서 살펴봤지만 인스턴스 생성을 한번만 할 수 있는 변수입니다. 따라서 생성 이후 값을 바꾸는 것도 일반적으로 불가능합니다. 상수와 비슷하게 느껴질 수 있지만 동적으로 생성이 가능하다는 점에서 상수와는 약간 다르긴 합니다.

불변 변수는 불편하고 느릴 수 있습니다. 값을 바꾸는 것이 아닌 새로 생성하는 형태로 변경된 데이터를 취득해야 하기 때문입니다.

let x = 10;
let y = x + 20;

불변 변수는 대입 과정에서 가변 변수와는 다른 특징을 보일 수 있습니다. 물론 이도 언어나 타입마다 다를 수도 있습니다만, 보통 불변 변수를 다른 불변 변수를 초기화하는 값으로 사용하게 되면 레퍼런스 대입이 일어나는 것이 아니라 복제, 즉 새 인스턴스를 생성한 뒤 메모리의 내용을 복사하는 방식이 사용됩니다. 따라서 대입 후 두 변수는 값은 값을 가지지만 다른 레퍼런스를 가지게 됩니다.

결과적으로 불변 변수는 공유 용도로는 사용하기 어렵습니다.

하지만 값이 동일해도 다른 레퍼런스를 할당 받는 만큼 각 변수의 독립성(?)도 철저히 지켜진다는 특징 때문에 마음껏 뿌리고 마음껏 해제해도 문제 될 경우가 별로 없으며, 멀티스레딩 환경에서 발생하는 다양한 락 문제 등에서 해방될 수 있다는 점이 대표적인 장점으로 꼽힙니다.

 

섀도잉(Shadowing)

러스트의 경우 불변 변수의 값을 연산하는 코드를 좀 더 쉽게 작성하기 위해 섀도잉이라는 독특한 기능을 제공합니다.

let x = 10;
let x = 20;  // shadowing
println!("{}", x);

위 코드에서 x는 불변 변수로 생성되었지만 다시 let을 이용해 재정의하는 방식으로 마치 가변 변수와 비슷한 스타일의 문법을 제공합니다. 다른 언어라면 이미 존재하는 심볼을 또 생성한다고 에러를 빼액거리겠지만 러스트는 이게 가능합니다. 결과적으로 위 코드는 20을 콘솔에 표시합니다.

섀도잉은 자기 자신의 이전 값을 참조하는 것도 가능합니다.

let x = 10;
let x = x * 20;  // shadowing
println!("{}", x);

위 코드에서 x를 재선언하면서 이전에 선언된 x의 값을 참조하는 모습을 볼 수 있습니다. 결과적으로 위 코드는 200을 출력합니다.

러스트는 불변성이 기본인 만큼 섀도잉을 이용하면 불변성의 불편함을 약간은 해소하는 데 도움을 주는 것 같습니다.

 

가변성 함수 매개변수

불변성 기본이라는 러스트의 특징은 함수 매개변수에도 동일합니다. 약간은 억지스러운 예제를 살펴봅시다.

fn make_double(x: i32) -> i32 {
    x = x * 2;
    x
}

함수의 매개변수는 기본적으로 불변성을 가지기 때문에 위 코드는 에러가 발생합니다.

억지스럽겠지만 이를 원하는 대로 하기 위해 아래와 같이 수정할 수 있습니다.

fn make_double(mut x: i32) -> i32 {
    x = x * 2;
    x
}

물론 왜 이따위로 코딩했냐는 이야기가 나올 수준의 예제가 되어버렸네요. 이건 정말 쓸모없는 것 같네요. 그냥 재미로만 봐주세요.

다만 조금 위험하긴 하지만 이런 경우 아래처럼 레퍼런스를 넘겨서 원본을 변조시키는 방식을 사용할 수는 있습니다.

fn make_double(x: &mut i32) {
    *x = *x * 2;
}

fn main() {
    let mut x = 10i32;
    make_double(&mut x);
    println!("{}", x);
}

위 코드는 포인터의 설명이 필요하지만 일단은 이런 식으로 할 수 있다는 예제입니다. 결과는 물론 20이 찍힙니다.

 

맺음말

사실 저도 불변성과 가변성에 대해 명확하게 설명하기에는 지식이 좀 부족합니다. 그래서 위에서 적어놓은 말들이 틀릴 수도 있습니다. 그저 이런 설명은 제가 이해하는 방식을 적은 것으로 이해해 주시면 감사하겠습니다.

어쨌거나 성능이 필요한 곳을 제외하면 불변성을 잘 활용하면 제법 안전하고 깔끔한 코드를 만들 수도 있습니다. 함수형 프로그래밍 언어에서도 불변성은 아주 중요한 특성이기 때문에 러스트에서도 함수형 스타일을 잘 활용할 수 있을 거라 생각되네요.

728x90
반응형

댓글