Rust에서 let x = 5로 만든 변수에 x = 6을 대입하면 컴파일되지 않습니다. 처음에는 작은 수정 하나까지 막는 제약처럼 보입니다. 하지만 기본 불변성은 값이 바뀌는 지점을 mut로 표시하게 만듭니다. 코드를 읽는 사람은 어느 이름이 그대로 유지되고 어디에서 상태가 변하는지 선언부에서 확인할 수 있습니다.
엔드포인트 검사 간격을 다루는 작은 Rust 2024 프로그램으로 차이를 확인해 보겠습니다. 타입 추론과 타입 표기, mut, shadowing, const를 한 예제 안에서 구분한 뒤 cargo fmt, Clippy, 테스트, 실행까지 검증합니다.
1. 예제의 검증 범위
- 타입이 분명할 때는 컴파일러의 추론을 사용합니다.
- 문자열 파싱처럼 결과 타입이 여러 개일 수 있을 때는 타입을 명시합니다.
- 같은 바인딩의 값이 실제로 변해야 할 때만
mut를 붙입니다. - 변환 단계를 같은 이름으로 표현하려면 shadowing을 사용합니다.
- 프로그램 전체에서 공유할 고정 규칙은 타입을 적은
const로 선언합니다.
예제는 Rust 1.98.1, Cargo 1.98.1, Rust 2024 edition을 사용합니다.
2. let 바인딩과 정적 타입
let 문은 패턴으로 변수를 만들고 타입 표기와 초기화 표현식을 선택적으로 둡니다. 가장 단순한 형태는 다음과 같습니다.
let endpoint = "https://example.com/health";
여기서는 문자열 리터럴이 충분한 정보를 주므로 endpoint의 타입을 직접 적을 필요가 없습니다. Rust는 정적 타입 언어입니다. 타입을 생략했다는 말은 실행 중에 타입이 정해진다는 뜻이 아니라 컴파일러가 컴파일할 때 타입을 추론한다는 뜻입니다.
추론에 필요한 정보가 부족하면 개발자가 경계를 정해야 합니다. parse는 여러 숫자 타입을 만들 수 있으므로 아래 코드에서는 결과를 u64로 지정했습니다. 검사 간격에 쓰는 값이 음수가 아님을 타입에도 반영합니다.
let configured_interval = "45";
let interval_seconds: u64 = configured_interval
.parse()
.expect("interval must be an integer");
타입 표기는 모든 변수에 붙이는 장식이 아닙니다. 컴파일러가 결정할 수 없거나 도메인 경계를 분명히 해야 하는 곳에 쓰는 편이 읽기 쉽습니다.
3. 기본 불변성의 이점
let으로 만든 바인딩은 기본적으로 불변입니다. 이름에 값이 한 번 묶이고 나면 같은 바인딩에 다른 값을 대입할 수 없습니다. 기본 불변성은 한 코드 조각이 값이 바뀌지 않는다고 가정하는 동안 다른 코드가 그 값을 바꿔 생기는 오류를 컴파일 단계에서 막습니다.
이 규칙이 변수를 전혀 쓰지 못하게 만드는 것은 아닙니다. 값을 읽고 함수에 전달하고 새 값을 계산할 수 있습니다. 금지되는 동작은 그 바인딩에 다시 대입하는 일입니다.
let checks_remaining = 3;
checks_remaining -= 1;
Rust 1.98.1에서 이 코드를 별도로 컴파일하면 E0384가 발생했습니다. 진단의 핵심 구간은 다음과 같습니다.
error[E0384]: cannot assign twice to immutable variable `checks_remaining`
help: consider making this binding mutable
수정은 간단하지만 mut를 무조건 붙이기 전에 값이 정말 같은 역할로 계속 변하는지 살펴볼 필요가 있습니다.
4. mut: 같은 타입의 상태 변경
반복 작업의 남은 횟수처럼 하나의 상태가 계속 줄어드는 경우에는 가변 바인딩이 자연스럽습니다.
let mut checks_remaining = 3;
while checks_remaining > 0 {
checks_remaining -= 1;
}
mut는 이 바인딩에 다시 대입할 수 있게 합니다. 바인딩의 타입까지 바꾸지는 않습니다. 아래 코드는 interval이 처음에 &str로 추론됐기 때문에 컴파일되지 않습니다.
let mut interval = "45";
interval = interval.parse::<u64>().expect("interval must be an integer");
실제 컴파일 결과는 E0308이었습니다. 아래 블록은 진단의 핵심 구간을 발췌한 것입니다.
error[E0308]: mismatched types
expected `&str`, found `u64`
여기서 원하는 일은 문자열 상태를 계속 수정하는 것이 아니라 문자열을 숫자로 변환하는 것입니다. 이때는 새 바인딩을 만드는 shadowing이 더 정확합니다.
5. shadowing은 새 바인딩을 만든다
같은 이름으로 let을 다시 쓰면 앞의 바인딩을 가리는 새 바인딩이 생깁니다. 새 바인딩이므로 타입도 달라질 수 있습니다.
let interval_seconds = "45";
let interval_seconds: u64 = interval_seconds
.parse()
.expect("interval must be an integer");
let interval_seconds = interval_seconds.max(30);
세 줄은 각각 입력 문자열, 파싱된 숫자, 정책을 적용한 숫자를 나타냅니다. 이름은 같지만 바인딩은 셋입니다. 각 단계가 끝난 뒤 값은 다시 불변이 됩니다.
shadowing이 언제나 좋은 것은 아닙니다. 이전 값과 새 값을 동시에 비교해야 한다면 raw_interval과 normalized_interval처럼 역할을 이름에 남기는 쪽이 낫습니다. 같은 개념이 변환 단계를 지나며 타입이나 표현만 달라질 때 shadowing이 특히 유용합니다.
6. const: 불변 규칙
상수와 불변 변수는 모두 다시 대입할 수 없지만 선언 방식과 쓰임이 다릅니다. const에는 타입을 반드시 적어야 하며 mut를 붙일 수 없습니다. 상수 초기값은 컴파일할 수 있는 상수 표현식이어야 합니다. 상수는 전역 범위를 포함한 어느 범위에도 선언할 수 있습니다.
const DEFAULT_INTERVAL_SECONDS: u64 = 30;
이 값은 한 번의 실행에서 우연히 정해진 지역 값이 아니라 검사 정책의 기본값입니다. 대문자와 밑줄을 쓰는 상수 이름도 그런 성격을 코드에 드러냅니다.
반대로 사용자 입력이나 파일을 읽어 얻는 값은 런타임에 생기므로 const의 초기값으로 둘 수 없습니다. 그 값은 let 바인딩에 저장합니다.
7. 선언 예제
아래 프로그램은 다섯 가지 선택을 한곳에 모읍니다. endpoint는 추론된 불변 바인딩이고 interval_seconds에는 파싱 경계를 표시하는 타입 표기가 있습니다. 같은 이름을 shadowing해 정규화 결과를 저장하며 반복 횟수만 mut로 바꿉니다.
const DEFAULT_INTERVAL_SECONDS: u64 = 30;
fn normalized_interval(configured: u64) -> u64 {
configured.max(DEFAULT_INTERVAL_SECONDS)
}
fn main() {
let endpoint = "https://example.com/health";
let configured_interval = "45";
let interval_seconds: u64 = configured_interval
.parse()
.expect("interval must be an integer");
let interval_seconds = normalized_interval(interval_seconds);
let mut checks_remaining = 3;
while checks_remaining > 0 {
println!("checking {endpoint} every {interval_seconds}s ({checks_remaining} remaining)");
checks_remaining -= 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_an_interval_above_the_default() {
assert_eq!(normalized_interval(45), 45);
}
#[test]
fn raises_a_short_interval_to_the_default() {
assert_eq!(normalized_interval(10), DEFAULT_INTERVAL_SECONDS);
}
}
실행 결과는 다음과 같습니다.
checking https://example.com/health every 45s (3 remaining)
checking https://example.com/health every 45s (2 remaining)
checking https://example.com/health every 45s (1 remaining)
8. 선언 선택 기준
| 상황 | 선택 | 이유 |
|---|---|---|
| 타입이 초기값과 사용 문맥에서 분명하다 | let value = ... |
불필요한 표기를 줄인다 |
| 파싱 결과나 공개 경계의 타입을 정해야 한다 | let value: Type = ... |
모호함을 없애고 의도를 남긴다 |
| 같은 역할의 상태가 실제로 바뀐다 | let mut value = ... |
변경 가능성을 선언부에 표시한다 |
| 변환 뒤 새 값으로 같은 개념을 이어 간다 | let value = ...를 다시 선언 |
새 바인딩을 만들고 필요하면 타입도 바꾼다 |
| 컴파일 시 정해지는 공유 규칙이다 | const NAME: Type = ... |
항상 불변인 도메인 값을 이름 붙인다 |
mut와 shadowing 사이에서 망설인다면 “이 값이 같은 상태로서 바뀌는가, 아니면 변환 결과가 새 단계인가”를 묻는 편이 좋습니다. 카운터는 전자이고 문자열을 숫자로 파싱하는 작업은 후자입니다.
9. 선언 검증
예제는 의존성이 없는 별도 Cargo 패키지이며 edition은 2024입니다. 다음 명령을 순서대로 실행합니다.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
포맷 검사와 Clippy는 경고 없이 끝나야 합니다. 테스트 출력의 핵심 구간은 다음과 같습니다.
running 2 tests
test tests::keeps_an_interval_above_the_default ... ok
test tests::raises_a_short_interval_to_the_default ... ok
10. 기본 불변성의 한계
mut를 없애는 것이 성능 최적화 규칙은 아닙니다. 루프 카운터나 버퍼처럼 상태 변경이 문제를 가장 직접적으로 표현한다면 mut가 맞습니다. Rust가 요구하는 것은 변경 자체의 금지가 아니라 변경 의도의 명시입니다.
또한 바인딩의 불변성과 모든 내부 상태가 영원히 바뀌지 않는다는 주장은 같지 않습니다. Rust에는 이후 다룰 내부 가변성 타입이 있습니다. 지금 단계에서는 지역 바인딩을 읽을 때 mut가 보이면 그 이름에 재대입이나 가변 빌림이 일어날 수 있다는 신호로 받아들이면 충분합니다.
다음 편에서는 함수 본문에서 이미 사용한 문장과 표현식의 차이를 다룹니다. 특히 블록의 마지막 표현식과 세미콜론이 반환값을 어떻게 바꾸는지 직접 컴파일해 봅니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 02 프로젝트에서 확인할 수 있습니다.
답글 남기기