불변성 — const·mut·val, 누가 무엇을 못 바꾸게 하나
값을 바꿀 수 있는가를 누가·어떻게 통제하는가. C++ const correctness를 발판으로, '기본이 가변이냐 불변이냐'와 '바인딩만 막느냐 값까지 막느냐' 두 축으로 Rust(불변 기본 + mut 옵트인)·Kotlin val·JS const·함수형 영속 자료구조를 비교한다. Kotlin val 리스트 내용이 왜 바뀌는지(바인딩 vs 값), Rust의 &T/&mut T 규칙이 데이터 레이스를 막는 지점까지 대응시킨다.
난이도 중급 · 선행 값 vs 참조 의미론을 봤으면 “바인딩 vs 값”이 쉽다
🗺️ 프로그래밍 언어 개념 로드맵의 한 편
한 줄 요약
값을 바꿀 수 있는가를 누가 통제하느냐로 언어가 갈린다. 가변 상태(mutable state)는 버그와 데이터 레이스의 근원이라, 언어들은 “이건 안 바뀐다”는 보장을 서로 다르게 준다. 두 축이다 — 기본값이 가변이냐 불변이냐, 그리고 불변이 바인딩만 막느냐 값까지 막느냐. C++ const는 값까지 막되 관례에 가깝고, Rust는 불변을 기본값으로 삼아 컴파일러가 강제한다.
어떤 문제를 푸는가
1
2
3
void process(Config& cfg) {
cfg.retries = 0; // 이 함수가 남의 설정을 몰래 바꿨다
}
cfg를 넘긴 쪽은 값이 바뀔 줄 몰랐을 수 있다. 누가 언제 무엇을 바꾸는지 추적이 안 되는 것 — 이게 큰 코드베이스와 멀티스레드에서 버그의 근원이다. 불변성은 “이 값은 여기서 안 바뀐다”를 보장해 그 추적 부담을 없앤다. 어떤 값이 절대 안 바뀐다면 마음 놓고 공유하고, 캐시하고, 여러 스레드가 동시에 읽어도 된다.
문제를 두 축으로 나누면 모델이 정리된다.
- 기본값 — 아무 표시 없는 변수가 가변인가 불변인가.
- 불변의 범위 — “불변”이 변수를 다른 걸 가리키게 하는 것(rebind)만 막는가, 가리키는 값 자체를 바꾸는 것(mutate)까지 막는가.
C++에서 출발 — const correctness
C++ 개발자는 이미 const로 이 통제를 손끝에서 한다.
1
2
3
4
5
const int x = 5; // 값 불변 — x = 6은 컴파일 에러
void print(const std::string& s); // "s를 바꾸지 않겠다"는 약속(시그니처에 명시)
struct Cache {
int get() const; // this를 안 바꾸는 멤버 함수
};
const를 통해 닿는 대상은 바꿀 수 없다 — const std::string&로 받으면 그 문자열을 못 건드린다. “이 함수는 인자를 안 바꾼다”가 타입에 드러난다는 게 핵심이고, 이게 C++이 대규모 코드에서 변경 지점을 통제하는 방식이다.
헷갈리는 지점 — 포인터의 두 const.
const가 “포인터 자체”에 붙는지 “가리키는 값”에 붙는지 갈린다.
1 2 const int* p; // 가리키는 값이 const (p는 다른 데를 가리킬 수 있음) int* const p; // 포인터가 const (가리키는 값은 바꿀 수 있음)이 구분이 바로 아래에서 말할 “바인딩 불변 vs 값 불변”의 C++판이다.
단, C++ const는 강제라기보다 관례에 가깝다. const_cast로 벗겨낼 수 있고(뚫는 순간 UB 위험은 있지만 컴파일은 된다), mutable 멤버는 const 객체 안에서도 바뀐다. 즉 도구는 있지만 우회로가 열려 있다 — 다음 두 모델과 갈리는 지점이다.
모델 ① 가변이 기본 — 불변은 옵트인 (C++, Go, Java, JS)
아무 표시 없는 변수는 가변이고, 안 바뀌게 하려면 const/final을 붙여야 한다.
1
2
int count = 0; // 가변 (기본)
final int max = 100; // 불변 (명시해야)
대부분의 주류 언어가 이 길이다. 편하지만, 불변이 예외라 잘 안 붙인다 — “이건 안 바뀐다”는 정보가 코드에 덜 남는다.
모델 ② 불변이 기본 — 가변은 옵트인 (Rust)
Rust는 뒤집었다. let으로 선언한 변수는 기본 불변이고, 바꾸려면 mut을 명시해야 한다.
1
2
3
4
5
6
7
8
let x = 5;
// x = 6; // ★ 컴파일 에러 — 기본 불변
let mut y = 5;
y = 6; // OK — mut을 명시했으니
fn print(s: &String); // 불변 빌림 — 못 바꾼다
fn push(s: &mut String); // 가변 빌림 — 바꿀 수 있다
기본값이 뒤집힌 효과는 크다. 가변이 눈에 띈다 — mut이 붙은 변수만 의심하면 되니, “이 값이 어디서 바뀌나”의 탐색 범위가 확 준다. 그리고 이건 관례가 아니라 컴파일러가 강제한다. const_cast 같은 뒷문은 없다(가변이 꼭 필요하면 Cell/RefCell의 내부 가변성을 명시적으로 옵트인하거나 unsafe로 내려가야 한다).
여기가 착지점. C++ const correctness를 아는 사람에게 Rust는 “
const를 기본값으로 삼고 컴파일러가 강제하는 것”이다. 대응이 거의 1:1이다 — C++const T&= Rust&T, C++T&(가변 참조) = Rust&mut T. C++에서 습관과 규율로 지키던 const correctness를, Rust는 기본값과 타입 강제로 바꿨다. → 메모리 관리 모델의 “관례 vs 강제” 구도가 여기서도 반복된다.
불변 기본이 데이터 레이스를 막는 뿌리. Rust 빌림 규칙은 “가변 빌림(
&mut)은 배타적, 불변 빌림(&)은 여럿 가능” — 한 값에&mut이 있으면 다른 빌림이 하나도 없어야 한다. 이 aliasing XOR mutability(공유와 변경은 동시에 안 됨)가 곧 데이터 레이스가 성립할 수 없는 조건이다 — 레이스는 “둘 이상이 공유하는데 최소 하나가 쓴다”에서 나는데, 그 조합을 타입이 금지하니까. 불변성 통제와 동시성 안전이 같은 규칙의 양면이다.
모델 ③ 바인딩만 불변 — 값은 가변 (Kotlin val, JS const, Java final)
세 번째는 함정이 숨은 절충이다. val/const/final이 막는 건 재대입(rebind)뿐이고, 가리키는 객체의 내용은 그대로 가변이다.
1
2
3
4
5
6
val a = 5
// a = 6 // ★ 컴파일 에러 — 재대입 금지
val list = mutableListOf(1, 2)
// list = mutableListOf() // 재대입은 금지
list.add(3) // ✅ 내용은 바뀐다! val은 '바인딩'만 잠갔다
val list는 “list라는 이름이 다른 리스트를 가리키지 못한다“만 보장한다. 그 리스트의 내용물은 자유롭게 바뀐다. JS const, Java final도 똑같다 — const obj = {} 뒤 obj.x = 1은 된다.
C++ 개발자가 넘어지는 지점. 이건 앞서 본
int* const p(포인터는 불변, 가리키는 값은 가변)와 정확히 같다. Kotlinval·JSconst는 전부 이 “포인터만 const” 형태다. 반면 C++const T&나 Rust&T는 가리키는 값까지 불변이라 결이 다르다. “불변”이라는 한 단어가 바인딩 불변과 값 불변 두 가지를 가리킨다 — 이 둘을 섞으면val리스트가 왜 바뀌는지에서 반드시 넘어진다.
모델 ④ 아예 안 바꾼다 — 영속 자료구조 (함수형)
극단은 함수형이다. 값을 절대 바꾸지 않는다. “수정”은 원본을 두고 새 버전을 만드는 것이다. 순진하게 하면 매번 통째로 복사라 비쌀 것 같지만, 구조 공유(structural sharing)로 바뀐 부분만 새로 만들고 나머지 노드는 원본과 공유한다.
1
2
3
// Immutable.js — a는 절대 안 바뀐다
const a = List([1, 2, 3]);
const b = a.push(4); // b는 새 리스트, a는 그대로. 내부 노드는 a와 공유
Clojure·Haskell·Elm이 이 모델이고, JS 진영의 Immutable.js·Redux도 같은 발상이다. 값이 절대 안 바뀌니 공유·캐시·시간여행 디버깅이 공짜로 된다. 대가는 갱신마다 새 객체를 만드는 할당 비용이다.
한눈에 비교
| C++ | Rust | Kotlin val·JS const | 함수형(영속) | |
|---|---|---|---|---|
| 기본값 | 가변(const는 옵트인) | 불변(mut 옵트인) | 가변(val/const는 옵트인) | 불변(유일한 길) |
| 불변의 범위 | 값까지(const 접근 통해) | 값까지 + 빌림 규칙 | 바인딩만(내용 가변) | 값 전체(새 버전) |
| 강제성 | 관례(const_cast로 뚫림) | 컴파일 강제 | 컴파일(바인딩 한정) | 언어 전체 |
| 우회 장치 | const_cast, mutable | Cell/RefCell(명시) | (내용은 원래 가변) | 없음 |
흐름: ①은 편의를 위해 가변을 기본으로 두고 불변을 선택하게 한다. ②는 불변을 기본으로 뒤집어 가변을 눈에 띄게 만들고 컴파일러가 강제한다. ③은 재대입만 잠그는 얕은 절충이고, ④는 변경 자체를 없앤다. 무엇을 기본으로 두고 어디까지 강제하느냐의 선택이다.
언어별 정리
- C++:
const를 최대한 붙이는 const correctness가 정석이다. 인자·멤버 함수·반환에 습관적으로const를 달아 변경 지점을 좁힌다. 다만 강제는 아니다. → 값의 복사/공유 관점은 값 vs 참조 의미론과 이어진다. - Rust: 불변이 기본.
mut과&mut이 붙은 곳만 “여기서 바뀐다”는 신호다. 이 통제가 소유권·빌림·동시성 안전과 한 몸이다. - Kotlin/JS:
val/const를 기본으로 쓰되, 그건 바인딩 불변임을 잊지 말 것. 값까지 불변이 필요하면 불변 컬렉션(listOf,Object.freeze)을 따로 써야 한다. - 함수형: 불변이 언어의 전제다. C++/Rust에서 “골라서” 얻는 보장을 기본으로 깔고 시작한다.
C++·Java에서 온 사람이 Rust에서 처음 겪는 마찰이 “왜 다
mut을 붙여야 하지?”다. 하지만 그건 불편이 아니라 기본값이 뒤집힌 것이다 — 안전한 쪽(불변)을 기본으로, 위험한 쪽(가변)을 명시로. C++에서const를 부지런히 붙이던 습관을 언어가 대신 강제해주는 셈이다.
스스로 점검
1. C++ const와 Rust의 “불변 기본”은 어떻게 대응되나?
답
발상이 같고 기본값과 강제성이 다르다. C++ const T& = Rust &T, C++ 가변 참조 T& = Rust &mut T로 거의 1:1 대응한다. 차이는 ① C++은 가변이 기본이고 const를 붙여야 하는 반면 Rust는 불변이 기본이고 mut을 붙여야 하며, ② C++ const는 const_cast로 뚫리는 관례지만 Rust는 컴파일러가 강제한다는 점이다.
2. Kotlin val list인데 list.add(3)이 되는 이유는?
답
val은 바인딩(재대입)만 잠그기 때문. list가 다른 리스트를 가리키게는 못 하지만(list = ... 금지), 가리키는 리스트의 내용은 여전히 가변이다. C++의 int* const p(포인터는 불변, 가리키는 값은 가변)와 같은 형태다. 값까지 불변으로 하려면 불변 컬렉션을 써야 한다. “불변”이 바인딩 불변과 값 불변 둘을 가리킨다는 걸 구분해야 안 넘어진다.
3. Rust의 &T/&mut T 규칙이 데이터 레이스를 막는 원리는?
답
aliasing XOR mutability — 한 값에 가변 빌림(&mut)이 있으면 다른 어떤 빌림도 동시에 존재할 수 없고, 불변 빌림(&)은 여럿 가능하되 그동안 아무도 못 바꾼다. 데이터 레이스는 “둘 이상이 공유하는데 최소 하나가 쓴다”에서 나는데, Rust는 그 공유+변경 조합 자체를 타입으로 금지한다. 그래서 불변성 통제와 동시성 안전이 같은 규칙의 양면이 된다.