Rust · 기본
소유권부터 배우는 Rust
값과 변수 - 불변이 기본인 언어
let·mut, 섀도잉, 정수·부동소수·bool·char, 정수 오버플로 동작(debug/release), 튜플·배열
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 cargo 로 프로젝트를 만들고 첫 프로그램을 빌드해 실행했다. 이번 장에서는 그 프로그램 안에서 값을 이름에 묶어 두고 다루는 방법을 배운다. 가계부 도구는 잔액, 지출 금액, 분류 기호, 하루 기록 같은 값을 계속 들고 있어야 한다. Rust 는 이 값들을 기본적으로 바꿀 수 없게 만들고, 바꾸려면 그 의도를 코드에 적도록 요구한다. 이 규칙이 처음에는 번거롭게 느껴지지만, 뒤에서 배울 소유권과 빌림을 이해하는 바탕이 된다.
let과let mut의 차이를 설명하고, 불변 변수에 재대입할 때 나오는 컴파일 오류를 읽을 수 있다.- 섀도잉(shadowing)이 무엇이며
mut재대입과 어떻게 다른지 구분한다. - 정수·부동소수점·bool·char 타입을 고르고, 정수 오버플로가 debug 와 release 빌드에서 어떻게 다르게 동작하는지 설명한다.
- 튜플과 배열로 여러 값을 묶고, 인덱스와 구조 분해로 꺼낸다.
문제 상황
가계부 도구를 만든다고 하자. 월초 잔액이 있고, 수입과 지출이 들어올 때마다 잔액이 달라진다. 한편 "이번 달이 몇 월인가" 같은 값은 실행 중에 바뀌면 곤란하다. 다른 언어에서는 두 경우 모두 변수를 선언해 쓰고, 실수로 값을 덮어써도 언어가 알려 주지 않는다.
또 하나의 문제는 숫자 크기다. 하루 지출을 0 에서 255 사이의 작은 정수 타입에 담았는데 합계가 260 이 되면 어떻게 되는가. 어떤 언어는 조용히 4 로 돌려놓고, 어떤 언어는 아주 큰 수로 자동 확장한다. Rust 는 빌드 방식에 따라 동작이 달라지므로, 이 차이를 모르고 있으면 개발 중에는 멀쩡하던 프로그램이 배포판에서 잘못된 금액을 낼 수 있다.
이 장에서는 위 두 문제를 작은 실험으로 확인하면서 Rust 의 기본 값 타입과 변수 규칙을 익힌다.
변수: 불변이 기본이다
let 과 let mut
let 으로 만든 변수는 기본적으로 값을 바꿀 수 없다. 값을 바꾸고 싶다면 선언할 때 mut(mutable, 가변)을 붙인다. 코드를 읽는 사람은 mut 가 없는 변수를 보면 "이 값은 끝까지 그대로"라고 믿고 읽어도 된다. 반대로 mut 가 보이면 그 아래 어딘가에서 값이 바뀐다는 신호다.
다음 코드는 오류를 낸다.
fn main() {
let balance = 100;
balance = 200;
println!("{balance}");
}
컴파일러는 error[E0384]: cannot assign twice to immutable variable `balance` 라고 알려 준다. 요지는 "불변 변수에 두 번 대입할 수 없다"이다. 컴파일러는 mut balance 로 선언하라는 도움말도 함께 보여 준다.
타입은 대부분 적지 않아도 된다. let balance = 100; 에서 컴파일러가 정수 리터럴(코드에 직접 적은 값)을 보고 타입을 정하는데, 이를 타입 추론(type inference)이라 한다. 다른 근거가 없으면 정수는 i32, 소수는 f64 로 정해진다. 타입을 직접 밝히려면 let count: u8 = 250; 처럼 이름 뒤에 콜론과 타입을 쓴다.
상수와의 차이
이름에 const 를 쓰면 컴파일 시점에 값이 정해지는 상수가 된다. 상수는 타입을 반드시 적고 이름은 관례상 대문자와 밑줄로 쓴다. 이 장의 예제는 let 만 사용하고, 상수는 필요한 장에서 다시 만난다.
섀도잉: 같은 이름으로 새 변수를 만든다
같은 이름으로 let 을 다시 쓰면 새 변수가 만들어지고, 이후에는 이전 변수 대신 새 변수가 그 이름으로 불린다. 이를 섀도잉(shadowing, 가려짐)이라 한다. 이전 값이 바뀌는 것이 아니라 새 값이 하나 더 생기는 것이다. 그래서 mut 없이도 값을 가공해 나갈 수 있고, 타입도 바꿀 수 있다.
let memo = "coffee";
let memo = memo.len();
첫 줄의 memo 는 글자 조각이고, 둘째 줄의 memo 는 그 길이인 정수다. 둘째 줄의 오른쪽 memo 는 아직 첫 번째 변수를 가리킨다는 점에 주의한다. 반면 mut 변수에 다른 타입 값을 대입하려 하면 컴파일 오류가 난다.
let mut memo = "coffee";
memo = memo.len();
이 코드는 error[E0308]: mismatched types 를 낸다. 요지는 &str 자리에 usize 가 왔다는 것이다. &str 은 문자열 리터럴의 타입인데, 문자열은 뒤에서 자세히 다룬다. 지금은 "글자 조각의 타입"으로만 읽어 두자.
섀도잉은 블록(중괄호로 묶은 영역) 안에서도 쓸 수 있다. 블록 안에서 가린 이름은 블록이 끝나면 원래 변수로 돌아온다. 완성 코드의 rate 예제가 이를 보여 준다.
기본 값 타입
정수
정수 타입은 부호 유무와 비트 수로 이름이 정해진다. i 는 음수를 표현할 수 있는 부호 있는 정수, u 는 0 이상만 표현하는 부호 없는 정수이고, 뒤의 숫자가 비트 수다. isize 와 usize 는 실행하는 컴퓨터의 포인터 크기를 따르며, 배열 길이와 인덱스에 쓰인다.
| 타입 | 범위 | 크기 | 가계부에서의 용도 |
|---|---|---|---|
| u8 | 0 ~ 255 | 1바이트 | 월, 일 같은 작은 값 |
| i32 | -2147483648 ~ 2147483647 | 4바이트 | 정수 리터럴의 기본 타입 |
| i64 | 약 ±9.22×10^18 | 8바이트 | 원 단위 금액 합계 |
| usize | 플랫폼에 따라 다름 | 포인터 크기 | 길이, 인덱스 |
큰 수는 1_200_000 처럼 밑줄로 자릿수를 나눠 쓸 수 있다. 밑줄은 값에 영향을 주지 않는다. 각 타입의 경계 값은 u8::MAX, i32::MIN 처럼 읽는다.
정수끼리 나누면 소수점 아래가 버려진다. 7 / 2 는 3 이고 7 % 2(나머지)는 1 이다. 소수까지 필요하면 as f64 로 부동소수점으로 바꾼 뒤 나눈다. as 는 값을 다른 숫자 타입으로 바꾸는 연산자다.
서로 다른 정수 타입은 자동으로 섞이지 않는다. i32 값과 i64 값을 더하면 error[E0308]: mismatched types 와 cannot add `i64` to `i32` 가 나온다. 이 오류는 뒤의 "자주 틀리는 것"에서 다시 다룬다.
부동소수점, bool, char
소수는 f32 와 f64 두 가지이고 기본은 f64 다. 부동소수점은 이진수로 소수를 근사하기 때문에 0.1 + 0.2 == 0.3 이 거짓이 된다. 그래서 금액은 소수 대신 원 단위 정수로 저장하는 편이 안전하다.
bool 은 true 와 false 두 값만 가지며 1바이트를 차지한다. Rust 는 숫자 0 이나 빈 문자열을 참·거짓으로 자동 해석하지 않는다.
char 는 글자 한 개를 나타내며 작은따옴표로 쓴다. 크기는 4바이트이고 유니코드 글자 하나를 담을 수 있어 '가' 나 '₩' 도 char 다. 큰따옴표로 쓴 "가" 는 문자열이라 타입이 다르다. 글자를 UTF-8 로 저장할 때 필요한 바이트 수는 글자마다 다르며, '가' 는 3바이트다.
정수 오버플로
정수 타입이 표현할 수 있는 범위를 넘는 계산을 오버플로(overflow)라 한다. Rust 에서 결과는 빌드 방식에 따라 갈린다.
fn add_days(days: u8, extra: u8) -> u8 {
days + extra
}
fn main() {
println!("{}", add_days(250, 10));
}
cargo run 은 기본으로 debug 빌드를 만든다. debug 빌드는 오버플로를 검사하고, 넘치면 프로그램이 즉시 중단(panic)된다. 메시지에는 attempt to add with overflow 가 나온다. 반면 cargo run --release 로 만든 release 빌드는 기본적으로 이 검사를 끄고, 결과를 범위 안으로 돌려 감는다. 260 은 256 을 뺀 4 가 된다. 두 결과가 다르므로 debug 에서만 시험하고 배포하면 위험하다.
덧붙이면 250 + 10 처럼 컴파일러가 값을 미리 알 수 있는 계산이 넘치면 빌드 단계에서 this arithmetic operation will overflow 오류가 난다. 위 예제가 함수 인자를 쓰는 이유다.
빌드 방식에 기대지 말고 원하는 동작을 직접 고르는 방법이 있다. 정수 타입의 메서드가 그 수단이다.
| 메서드 | 넘칠 때 동작 | 결과 | 쓰는 때 |
|---|---|---|---|
| wrapping_add | 범위 안으로 돌려 감음 | 4 | 돌아가는 값이 의도일 때 |
| saturating_add | 경계 값에서 멈춤 | 255 | 상한이 있는 값 |
| checked_add | 넘쳤음을 값으로 알림 | None | 넘침을 직접 처리할 때 |
| overflowing_add | 돌린 값과 넘침 여부 반환 | (4, true) | 둘 다 필요할 때 |
checked_add 는 Some(255) 나 None 같은 값을 돌려준다. 이것은 "값이 있거나 없음"을 나타내는 타입이며, 자세한 내용은 Option 을 다루는 장에서 배운다. 이 장에서는 출력에 {:?} 를 써서 그대로 확인만 한다.
튜플과 배열
튜플
튜플(tuple)은 서로 다른 타입의 값을 하나로 묶는다. 길이는 정해져 있고 바뀌지 않는다. 지출 한 건을 (항목, 부호, 금액)으로 묶으면 ("점심", '-', 8_500) 이다. 요소는 entry.0, entry.2 처럼 점과 번호로 꺼내거나, let (label, kind, amount) = entry; 처럼 한 번에 풀어 낼 수 있다. 후자를 구조 분해(destructuring)라 한다.
배열
배열(array)은 같은 타입의 값을 고정된 개수만큼 담는다. 타입은 [i32; 7] 처럼 요소 타입과 길이를 함께 나타낸다. 요소는 0 부터 시작하는 인덱스로 꺼내고, 길이는 len() 으로 얻는다. [0; 3] 은 0 을 세 개 채운 배열을 만든다. 길이가 늘거나 줄어드는 목록은 컬렉션을 다루는 장에서 배울 Vec 이 맡는다.
범위를 벗어난 인덱스는 실행 중 panic 을 일으키고, 메시지는 index out of bounds: the len is 7 but the index is 9 형태다. 이 검사는 debug 와 release 모두에서 동작한다. 정수 오버플로와 달리 빌드 방식에 따라 사라지지 않는다.
완성 코드
지금까지의 내용을 하나의 src/main.rs 에 모았다. 앞 장에서 만든 프로젝트의 파일을 이 내용으로 바꾸고 실행한다.
fn main() {
// 1. 불변과 가변
let month = 9;
let mut balance = 1_200_000;
println!("{month}월 시작 잔액: {balance}원");
balance += 350_000;
balance -= 48_500;
println!("{month}월 현재 잔액: {balance}원");
// 2. 섀도잉
let memo = "coffee";
let memo = memo.len();
println!("메모 길이: {memo}");
let fee = 1_250;
let fee = fee * 3;
println!("수수료 합계: {fee}원");
let rate = 3;
{
let rate = rate * 10;
println!("블록 안 rate: {rate}");
}
println!("블록 밖 rate: {rate}");
// 3. 정수와 오버플로
let count: u8 = 250;
println!("wrapping_add: {}", count.wrapping_add(10));
println!("saturating_add: {}", count.saturating_add(10));
println!("checked_add(10): {:?}", count.checked_add(10));
println!("checked_add(5): {:?}", count.checked_add(5));
let (value, overflowed) = count.overflowing_add(10);
println!("overflowing_add: {value} {overflowed}");
println!("u8 범위: {} ~ {}", u8::MIN, u8::MAX);
println!("i32 범위: {} ~ {}", i32::MIN, i32::MAX);
let total = 7;
let people = 2;
println!("몫 {}, 나머지 {}", total / people, total % people);
println!("실수 나눗셈: {}", total as f64 / people as f64);
// 4. 부동소수점, bool, char
let price: f64 = 12_000.0;
let tax_rate = 0.1;
let with_tax = price * (1.0 + tax_rate);
println!("세금 포함: {with_tax:.2}");
println!("0.1 + 0.2 == 0.3 ? {}", 0.1 + 0.2 == 0.3);
let is_income = false;
let sign = '-';
let icon = '₩';
println!("수입 여부: {is_income}, 부호: {sign}, 기호: {icon}");
println!(
"char 크기: {} 바이트, bool 크기: {} 바이트",
size_of::<char>(),
size_of::<bool>()
);
println!("'가' 는 UTF-8 로 {} 바이트", '가'.len_utf8());
// 5. 튜플
let entry: (&str, char, i64) = ("점심", '-', 8_500);
let (label, kind, amount) = entry;
println!("{label} {kind}{amount}");
println!("금액만: {}", entry.2);
// 6. 배열
let weekly = [12_000, 8_500, 0, 23_000, 5_500, 41_000, 9_000];
let weekend = weekly[5] + weekly[6];
println!("이번 주 {}일 기록, 주말 지출 {weekend}원", weekly.len());
println!("첫날 {}원, 마지막 날 {}원", weekly[0], weekly[weekly.len() - 1]);
let zeros = [0; 3];
println!("{zeros:?}");
// 7. as 변환으로 값이 잘리는 경우
let wide: i32 = 300;
let narrow = wide as u8;
println!("300 as u8 = {narrow}");
}
줄별 해설
1. 불변과 가변. month 는 mut 가 없어 끝까지 9 다. balance 는 mut 로 선언했으므로 += 와 -= 로 값을 바꾼다. 출력 문자열의 {balance} 는 같은 이름의 변수 값을 그 자리에 넣는다.
2. 섀도잉. 첫 memo 는 글자 조각이고, 둘째 let memo = memo.len(); 는 그 길이(6)를 새 memo 에 담는다. fee 는 같은 타입으로 섀도잉해 값만 가공한다. rate 는 블록 안에서만 30 으로 가려지고, 블록이 끝나면 3 이 다시 보인다.
3. 정수와 오버플로. count 는 u8 로 명시했다. 네 메서드는 같은 250 + 10 을 서로 다르게 처리한다. {:?} 는 개발자용 출력 형식으로 None 이나 Some(255) 를 그대로 보여 준다. overflowing_add 가 돌려주는 두 값은 튜플이며, let (value, overflowed) 로 바로 풀어 받았다. u8::MIN 과 i32::MAX 는 각 타입이 가진 경계 값이다. 이어지는 total / people 은 정수 나눗셈이고, as f64 를 붙인 쪽은 실수 나눗셈이다.
4. 부동소수점, bool, char. {with_tax:.2} 는 소수점 아래 두 자리까지 출력하라는 뜻이다. 12000 에 1.1 을 곱한 값은 이진 근사 때문에 13200 에서 아주 조금 벗어날 수 있으므로 자릿수를 정해 출력했다. size_of::<char>() 는 타입의 크기를 바이트로 알려 준다. len_utf8() 는 글자 하나가 UTF-8 로 몇 바이트인지 돌려준다.
5. 튜플. 타입 표기 (&str, char, i64) 는 세 요소의 타입을 순서대로 적은 것이다. 구조 분해로 세 값을 각각의 변수에 받고, 원본 entry 는 이후에도 쓸 수 있다. 요소가 모두 복사가 가능한 단순한 값이기 때문이다. 이 조건은 소유권 장에서 정확히 다룬다.
6. 배열. 리터럴이 모두 정수이므로 weekly 의 타입은 [i32; 7] 이다. weekly.len() - 1 은 마지막 인덱스이며, len() 이 usize 를 돌려주므로 인덱스로 바로 쓸 수 있다. {zeros:?} 는 배열 전체를 Debug 형식으로 출력한다.
7. as 변환. 300 은 u8 의 범위 밖이다. as 는 오류를 내지 않고 상위 비트를 잘라내며, 300 을 256 으로 나눈 나머지 44 가 남는다.
실행 결과
$ cargo run --quiet
9월 시작 잔액: 1200000원
9월 현재 잔액: 1501500원
메모 길이: 6
수수료 합계: 3750원
블록 안 rate: 30
블록 밖 rate: 3
wrapping_add: 4
saturating_add: 255
checked_add(10): None
checked_add(5): Some(255)
overflowing_add: 4 true
u8 범위: 0 ~ 255
i32 범위: -2147483648 ~ 2147483647
몫 3, 나머지 1
실수 나눗셈: 3.5
세금 포함: 13200.00
0.1 + 0.2 == 0.3 ? false
수입 여부: false, 부호: -, 기호: ₩
char 크기: 4 바이트, bool 크기: 1 바이트
'가' 는 UTF-8 로 3 바이트
점심 -8500
금액만: 8500
이번 주 7일 기록, 주말 지출 50000원
첫날 12000원, 마지막 날 9000원
[0, 0, 0]
300 as u8 = 44
--quiet 는 컴파일 진행 메시지를 감추고 프로그램 출력만 보여 준다. 이 코드는 오버플로 계산을 메서드로 명시했으므로 cargo run --release --quiet 로 실행해도 출력이 같다.
실무에서 자주 틀리는 것
1. 바뀌는 값을 mut 없이 선언한다
누적 합계처럼 값이 바뀌는 변수를 let 만으로 선언하면 재대입 오류가 난다.
// 틀린 코드
let total = 0;
total += 8_500;
// error[E0384]: cannot assign twice to immutable variable `total`
// 고친 코드
let mut total = 0;
total += 8_500;
바뀌지 않는 변수까지 mut 로 선언하면 읽는 사람이 값이 바뀐다고 오해한다. 컴파일러가 요구할 때만 붙이는 습관이 좋다.
2. mut 변수의 타입을 바꾸려 한다
mut 는 값을 바꿀 뿐 타입은 바꾸지 못한다.
// 틀린 코드
let mut amount = "8500";
amount = amount.len();
// error[E0308]: mismatched types (expected `&str`, found `usize`)
// 고친 코드
let amount = "8500";
let amount = amount.len();
타입이 달라지는 가공은 섀도잉으로 쓴다. 이때 이름은 그대로 두어도 되고, 뜻이 달라진다면 amount_len 처럼 새 이름을 붙이는 편이 더 읽기 좋다.
3. 다른 정수 타입을 섞고, as 로 잘라 버린다
// 틀린 코드
let a: i32 = 5;
let b: i64 = 6;
let sum = a + b;
// error[E0308]: mismatched types (expected `i32`, found `i64`)
// error[E0277]: cannot add `i64` to `i32`
// 고친 코드: 넓은 타입으로 맞춘다
let a: i32 = 5;
let b: i64 = 6;
let sum = i64::from(a) + b;
넓은 타입으로 옮길 때는 값이 보존되는 i64::from 을 쓴다. 반대로 as u8 처럼 좁은 타입으로 바꾸면 완성 코드의 300 → 44 처럼 조용히 값이 잘린다. 좁히는 변환은 값의 범위를 확인한 뒤에만 쓴다.
4. release 에서도 오버플로가 검사된다고 믿는다
// 틀린 코드: debug 에서는 panic, release 에서는 4 로 감김
fn add_days(days: u8, extra: u8) -> u8 {
days + extra
}
// 고친 코드: 넘침을 값으로 받는다
fn add_days(days: u8, extra: u8) -> Option<u8> {
days.checked_add(extra)
}
금액과 개수처럼 넘치면 안 되는 값은 checked_add 나 saturating_add 로 의도를 코드에 적는다. 반환 타입의 Option 은 뒤에서 자세히 배운다.
한눈에 보기
| 주제 | 문법 또는 규칙 | 기억할 점 | 확인 방법 |
|---|---|---|---|
| 불변 변수 | let x = 1; | 재대입하면 컴파일 오류 | E0384 |
| 가변 변수 | let mut x = 1; | 값만 바뀌고 타입은 그대로 | x += 1 |
| 섀도잉 | let x = x + 1; | 새 변수가 이름을 가림, 타입 변경 가능 | 블록이 끝나면 원래 값 |
| 정수 오버플로 | debug / release | panic / 돌려 감김 | checked_add 등으로 명시 |
| 튜플 | (항목, 부호, 금액) | 타입이 달라도 되고 길이 고정 | t.0, 구조 분해 |
| 배열 | [i32; 7] | 타입이 같고 길이 고정 | a[0], a.len() |
| 타입 | 리터럴 예 | 크기 | 주의 |
|---|---|---|---|
| i32 / i64 | 1_200_000 | 4 / 8바이트 | 서로 자동 변환되지 않음 |
| f64 | 12_000.0 | 8바이트 | 금액에는 쓰지 않음 |
| bool | true | 1바이트 | 숫자로 대신할 수 없음 |
| char | '₩' | 4바이트 | 작은따옴표, 글자 한 개 |
표준 라이브러리에서 정수 타입의 메서드를 확인하려면 u8 의 공식 문서를 참고한다.
연습 문제
- 다음 코드는 컴파일되지 않는다. 오류 코드와 이유를 말하고 고쳐라.
let count = 0; count += 1; println!("{count}"); - 다음 코드의 출력을 예상하라.
let x = 5; let x = x + 1; { let x = x * 2; println!("{x}"); } println!("{x}"); let n: u8 = 200;에 100 을 더할 때wrapping_add,saturating_add,checked_add의 결과를 각각 적어라.- 튜플
("월세", 500_000)을 만들어 두 요소를 변수로 풀어 출력하고, 고정비 세 개(500000, 45000, 30000)를 담은 배열의 합계도 출력하는 프로그램을 작성하라.
정답과 해설
1. error[E0384]: cannot assign twice to immutable variable `count` 가 나온다. count 가 불변인데 += 로 값을 바꾸려 했기 때문이다. let mut count = 0; 으로 고친다.
2. 출력은 두 줄이다.
12
6
둘째 줄에서 x 는 6 이 된다. 블록 안에서 x * 2 로 만든 12 는 블록 안에서만 유효하고, 블록 밖에서는 6 인 x 가 다시 보인다.
3. 200 + 100 = 300 이다. wrapping_add 는 300 - 256 = 44, saturating_add 는 255, checked_add 는 None 이다.
4. 예시 답안이다.
fn main() {
let rent = ("월세", 500_000);
let (name, cost) = rent;
let costs = [500_000, 45_000, 30_000];
println!("{name} {cost}");
println!("{}", costs[0] + costs[1] + costs[2]);
}
출력은 다음과 같다.
월세 500000
575000
튜플은 let (name, cost) = rent; 로 풀었고, 배열은 인덱스 0, 1, 2 로 꺼내 더했다. 요소 개수가 정해져 있고 인덱스가 범위 안이므로 panic 없이 끝난다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.