콘텐츠로 이동

Rust 정수 오버플로

Rust의 정수 타입은 모두 크기가 정해져 있어요. u8에는 0부터 255까지의 숫자만 들어가고, 그 밖의 숫자는 들어갈 자리가 없어요. 그럼 255에 1을 더하면 어떻게 될까요? 답이 들어가지 않아요. 이걸 정수 오버플로(integer overflow)라고 해요.

세 자리짜리 주행 거리계가 달린 오래된 자동차를 떠올려 보세요. 999킬로미터를 넘으면 다시 000으로 돌아가요. 장난감이라면 괜찮지만, 은행 잔고라면 큰일이에요. Rust는 여기서 두 가지 방법으로 도와줘요. 개발하는 동안에는 문제를 요란하게 잡아내요. 그리고 대신 어떻게 해야 할지 정확히 말할 수 있는 간단한 메서드를 줘요. 걱정하지 마세요. 메서드는 네 종류뿐이고, 모두 같은 패턴을 따라요. 이 페이지는 데이터 타입과 연산자 페이지 내용을 바탕으로 해요.

타입마다 가장 작은 값(MIN)과 가장 큰 값(MAX)이 있어요. u8::MAX나 i32::MIN 같은 상수로 출력해 볼 수 있어요.

fn main() {
println!("u8: {} ~ {}", u8::MIN, u8::MAX);
println!("i8: {} ~ {}", i8::MIN, i8::MAX);
println!("u16: {} ~ {}", u16::MIN, u16::MAX);
println!("i32: {} ~ {}", i32::MIN, i32::MAX);
println!("u32: {} ~ {}", u32::MIN, u32::MAX);
println!("i64: {} ~ {}", i64::MIN, i64::MAX);
println!("u64: {} ~ {}", u64::MIN, u64::MAX);
println!("u128: {} ~ {}", u128::MIN, u128::MAX);
}

실행 결과:

u8: 0 ~ 255
i8: -128 ~ 127
u16: 0 ~ 65535
i32: -2147483648 ~ 2147483647
u32: 0 ~ 4294967295
i64: -9223372036854775808 ~ 9223372036854775807
u64: 0 ~ 18446744073709551615
u128: 0 ~ 340282366920938463463374607431768211455
▶ 직접 실행해 보기

전체 가족을 표 하나로 정리하면 이래요.

타입 비트 가장 작은 값 가장 큰 값
i8 / u8 8 -128 / 0 127 / 255
i16 / u16 16 -32,768 / 0 32,767 / 65,535
i32 / u32 32 약 -21억 / 0 약 21억 / 약 43억
i64 / u64 64 약 -9.2 × 10^18 / 0 약 9.2 × 10^18 / 1.8 × 10^19
i128 / u128 128 약 -1.7 × 10^38 / 0 약 1.7 × 10^38 / 3.4 × 10^38
isize / usize 대부분의 컴퓨터에서 64 i64와 같음 / 0 i64 / u64와 같음

부호 있는 타입(i)은 범위의 절반을 음수에 써요. 부호 없는 타입(u)은 0부터 시작하니까 가장 큰 값이 약 두 배예요. 이 범위를 벗어나면 모두 오버플로예요. MIN보다 작아지는 경우(u8에서 0 - 1 같은)도 마찬가지예요. 이걸 언더플로(underflow)라고 부르기도 하지만, Rust는 똑같이 다뤄요.

숫자가 오버플로되면 Rust는 어떻게 할까요?

섹션 제목: “숫자가 오버플로되면 Rust는 어떻게 할까요?”

디버그 빌드에서는 패닉이 나요

섹션 제목: “디버그 빌드에서는 패닉이 나요”

cargo run(디버그 빌드)으로 프로그램을 실행하면 Rust는 모든 +, -, *를 검사해요. 답이 들어가지 않으면 분명한 메시지와 함께 프로그램을 멈춰요. 다음은 게임에서 u8 점수에 보너스 점수를 더하는 코드예요.

fn add_points(score: u8, bonus: u8) -> u8 {
score + bonus
}
fn main() {
let total = add_points(250, 10);
println!("합계: {total}");
}

실행하면 이렇게 나와요.

thread 'main' (12345) panicked at src/main.rs:2:5:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ 직접 실행해 보기

attempt to add with overflow는 “더하다가 오버플로가 났어요”라는 뜻이에요. 일부러 값을 함수 매개변수로 넘겼어요. + 바로 옆에 숫자를 직접 쓰면, 프로그램을 실행하기도 전에 컴파일러가 문제를 찾아내거든요(곧 보게 될 거예요). 프로그램이 죽는 게 가혹하게 느껴질 수도 있지만, 사실은 친절한 거예요. 실제 서비스에서 틀린 점수가 나오는 대신, 내 컴퓨터에서 정확한 줄 번호와 함께 버그를 찾을 수 있으니까요. 패닉에 대해서는 panic 페이지에서 더 알아볼 수 있어요.

릴리스 빌드에서는 값이 한 바퀴 돌아요

섹션 제목: “릴리스 빌드에서는 값이 한 바퀴 돌아요”

cargo run --release로 빌드하면 코드를 조금 더 빠르게 만들려고 이 검사를 꺼요. 그러면 숫자가 주행 거리계처럼 한 바퀴 돌아요(wrap around). u8에서 250 + 10은 4가 돼요(260 - 256 = 4이니까요).

Terminal window
cargo run --release
합계: 4

패닉도, 경고도 없이 그냥 조용히 틀린 숫자가 나와요. 그래서 오버플로가 “우연히” 일어나는 데 기대면 안 돼요. 한 바퀴 도는 동작을 원한다면 wrapping_add(아래에서 볼게요)로 그렇다고 분명히 말하세요. 이 사이트의 “Try it” 버튼은 디버그 빌드로 실행하기 때문에, 거기서 이 동작을 보려면 wrapping_add를 쓰면 돼요.

fn main() {
let score: u8 = 250;
println!("{}", score.wrapping_add(10));
}

실행 결과:

4
▶ 직접 실행해 보기

릴리스 빌드에서도 패닉이 나게 하고 싶다면 Cargo.toml에서 검사를 다시 켤 수 있어요.

[profile.release]
overflow-checks = true

숫자를 직접 쓰면 컴파일조차 안 돼요

섹션 제목: “숫자를 직접 쓰면 컴파일조차 안 돼요”

컴파일러가 계산 전체를 볼 수 있으면 빌드를 거부해요.

fn main() {
let level: u8 = 255 + 1;
println!("{level}");
}
error: this arithmetic operation will overflow
--> src/main.rs:2:21
|
2 | let level: u8 = 255 + 1;
| ^^^^^^^ attempt to compute `u8::MAX + 1_u8`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default

“이 산술 연산은 오버플로가 날 거예요”라는 에러예요. let level: u8 = 256;처럼 리터럴 자체가 너무 큰 경우도 마찬가지로, literal out of range for u8(u8의 범위를 벗어난 리터럴) 에러가 나요.

오버플로가 절대 안 일어나길 바라는 대신, 어떻게 할지 직접 고를 수 있어요. 모든 정수 타입에는 이를 위한 메서드 가족이 네 개 있어요. u8, i32, u64 등 어떤 타입에서든 똑같이 동작해요.

checked_: “안 들어가면 알려 줘”

섹션 제목: “checked_: “안 들어가면 알려 줘””

checked_add, checked_sub, checked_mul은 Option을 돌려줘요. 들어가면 Some(답), 안 들어가면 None이에요. 돈처럼 틀린 숫자가 진짜 문제가 되는 곳에서는 이게 가장 안전한 선택이에요. 은행 잔고는 0 아래로 내려갈 수 없으니, u64에 checked_sub를 쓰면 출금을 알아서 거절해 줘요.

fn withdraw(balance: u64, amount: u64) -> Option<u64> {
balance.checked_sub(amount)
}
fn order_total(price: u32, quantity: u32, shipping: u32) -> Option<u32> {
price.checked_mul(quantity)?.checked_add(shipping)
}
fn main() {
let balance: u64 = 100;
for amount in [30, 250] {
match withdraw(balance, amount) {
Some(left) => println!("{amount}을(를) 출금했어요. 남은 잔고: {left}"),
None => println!("{amount}을(를) 출금할 수 없어요: 잔고가 부족해요"),
}
}
println!("{:?}", order_total(20, 3, 5));
println!("{:?}", order_total(2_000_000_000, 3, 5));
}

실행 결과:

30을(를) 출금했어요. 남은 잔고: 70
250을(를) 출금할 수 없어요: 잔고가 부족해요
Some(65)
None
▶ 직접 실행해 보기

결과가 Option이니까 ? 연산자도 쓸 수 있어요. order_total에서 곱셈이 오버플로되면 함수는 그 자리에서 바로 None을 돌려줘요.

saturating_add와 saturating_sub는 절대 MIN이나 MAX를 넘어가지 않아요. 가득 찬 컵처럼 거기서 그냥 멈춰요. 체력 바, 볼륨 조절기, 재고 수량처럼 자연스러운 바닥이나 천장이 있는 값에 딱 맞아요.

fn main() {
let health: u8 = 20;
let after_hit = health.saturating_sub(35);
println!("큰 공격을 받은 뒤 체력: {after_hit}");
let volume: u8 = 90;
let louder = volume.saturating_add(25).min(100);
println!("볼륨을 올린 뒤: {louder}");
let stock: u32 = 3;
let left = stock.saturating_sub(5);
println!("남은 재고: {left}");
}

실행 결과:

큰 공격을 받은 뒤 체력: 0
볼륨을 올린 뒤: 100
남은 재고: 0
▶ 직접 실행해 보기

볼륨 줄에는 한 단계가 더 필요해요. saturating_add는 타입 자체의 한계, 즉 u8의 255에서만 멈춰요. 볼륨 조절기는 100에서 멈춰야 하니까 .min(100)으로 우리만의 한계에 맞춰 잘라 줘요.

wrapping_: “일부러 한 바퀴 돌아”

섹션 제목: “wrapping_: “일부러 한 바퀴 돌아””

한 바퀴 도는 게 바로 원하는 동작일 때도 있어요. 254, 255, 0, 1로 이어지는 번호표 카운터가 그렇고, 숫자가 뒤섞이는 게 목적인 간단한 해시 함수도 그래요. wrapping_add와 wrapping_mul은 디버그든 릴리스든 모든 빌드에서 이렇게 동작해요.

fn simple_hash(text: &str) -> u32 {
let mut hash: u32 = 5381;
for byte in text.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u32);
}
hash
}
fn main() {
let mut ticket: u8 = 254;
for _ in 0..4 {
println!("번호표 {ticket}");
ticket = ticket.wrapping_add(1);
}
println!("\"hello\"의 해시: {}", simple_hash("hello"));
println!("\"a much longer sentence\"의 해시: {}", simple_hash("a much longer sentence"));
}

실행 결과:

번호표 254
번호표 255
번호표 0
번호표 1
"hello"의 해시: 261238937
"a much longer sentence"의 해시: 3373490575
▶ 직접 실행해 보기

해시하는 문장은 영어 그대로 두었어요. 해시는 바이트마다 계산하는데, 한글은 한 글자가 3바이트라서 결과가 완전히 달라지거든요. 긴 문장은 평범한 *와 +로 계산하면 u32를 몇 번이고 오버플로시킬 거예요. wrapping_ 메서드를 쓰면 괜찮아요. 해시는 원래 그렇게 동작하라고 만든 거니까요.

overflowing_add는 튜플을 돌려줘요. 한 바퀴 돈 답, 그리고 오버플로가 났는지 알려 주는 bool이에요. 한 바퀴 돈 값을 쓰면서도 그런 일이 있었는지 알고 싶을 때 편리해요.

fn main() {
let (value, overflowed) = 250u8.overflowing_add(10);
println!("{value}, 오버플로 발생: {overflowed}");
let (value, overflowed) = 50u8.overflowing_add(10);
println!("{value}, 오버플로 발생: {overflowed}");
}

실행 결과:

4, 오버플로 발생: true
60, 오버플로 발생: false
▶ 직접 실행해 보기

이 메서드는 다른 것들보다 덜 쓰게 될 거예요. 무엇을 골라야 할지 모르겠다면 checked_가 아주 좋은 기본 선택이에요.

as로 타입을 변환하면 디버그 빌드에서도 절대 패닉이 나지 않아요. 값이 들어가지 않으면 조용히 잘려서 들어가요. 많은 사람이 이 점에 놀라니까 조심해서 쓰세요.

fn main() {
let big: i32 = 300;
let negative: i32 = -1;
println!("300 as u8 = {}", big as u8);
println!("-1 as u32 = {}", negative as u32);
match u8::try_from(big) {
Ok(small) => println!("들어가요: {small}"),
Err(e) => println!("300은 u8에 들어가지 않아요: {e}"),
}
match u8::try_from(200) {
Ok(small) => println!("들어가요: {small}"),
Err(e) => println!("200은 u8에 들어가지 않아요: {e}"),
}
}

실행 결과:

300 as u8 = 44
-1 as u32 = 4294967295
300은 u8에 들어가지 않아요: out of range integral type conversion attempted
들어가요: 200
▶ 직접 실행해 보기

out of range integral type conversion attempted는 Rust가 출력하는 에러 메시지로, “범위를 벗어난 정수 타입 변환을 시도했어요”라는 뜻이에요. 300 as u8은 가장 낮은 8비트만 남기는데, 그게 44예요. -1 as u32는 가장 큰 u32가 돼요. 둘 다 아무 경고도 하지 않아요. u8::try_from은 먼저 확인하고 Result를 주니까, 어떻게 할지 직접 정할 수 있어요. 좋은 습관은 이래요. 값이 들어간다는 걸 확실히 알 때만 as를 쓰고, 프로그램 바깥에서 온 값에는 try_from을 쓰세요. (실수는 다르게 동작해요. 300.7 as u8은 한 바퀴 도는 대신 255에서 멈춰요.) 타입 변환과 타입 변환 트레이트 페이지에서 더 깊이 다뤄요.

그 밖에 숫자가 놀라게 하는 경우

섹션 제목: “그 밖에 숫자가 놀라게 하는 경우”

사람들이 자주 걸려 넘어지는 것들을 한곳에 모았어요.

fn main() {
println!("7 / 2 = {}", 7 / 2);
println!("-7 / 2 = {}", -7 / 2);
println!("i32::MIN.checked_abs() = {:?}", i32::MIN.checked_abs());
println!("i32::MIN.unsigned_abs() = {}", i32::MIN.unsigned_abs());
println!("10u32.checked_pow(9) = {:?}", 10u32.checked_pow(9));
println!("10u32.checked_pow(10) = {:?}", 10u32.checked_pow(10));
}

실행 결과:

7 / 2 = 3
-7 / 2 = -3
i32::MIN.checked_abs() = None
i32::MIN.unsigned_abs() = 2147483648
10u32.checked_pow(9) = Some(1000000000)
10u32.checked_pow(10) = None
▶ 직접 실행해 보기
  • 정수 나눗셈은 나머지를 버려요. 반올림하지 않고 0 쪽으로 잘라내서, -7 / 2는 -3이에요. 나머지가 필요하면 %를 쓰고, 소수점이 필요하면 실수를 쓰세요.
  • abs()도 오버플로가 날 수 있어요. i32는 -2147483648까지 내려가지만 2147483647까지만 올라가서, i32::MIN의 양수 버전은 들어가지 않아요. i32::MIN.abs()는 디버그 빌드에서 attempt to negate with overflow(부호를 바꾸다가 오버플로가 났어요)라는 패닉을 일으켜요. checked_abs()를 쓰거나, 값이 들어가는 u32를 돌려주는 unsigned_abs()를 쓰세요.
  • pow는 금방 커져요. 10의 10제곱은 벌써 u32에 들어가지 않아요. checked_pow, saturating_pow, wrapping_pow도 있어요.

값이 언제나 한 바퀴 돌거나 언제나 끝에서 멈춰야 한다면, std::num::Wrapping이나 std::num::Saturating으로 감쌀 수 있어요. 그러면 평범한 +와 -가 알맞게 동작해서, 깜빡할 일이 없어요.

use std::num::{Saturating, Wrapping};
fn main() {
let mut ticket = Wrapping(254u8);
ticket += 1;
ticket += 1;
ticket += 1;
println!("번호표: {ticket}");
let mut battery = Saturating(10u8);
battery -= 25;
println!("배터리: {battery}");
}

실행 결과:

번호표: 1
배터리: 0
▶ 직접 실행해 보기
메서드 가족 반환값 이럴 때 쓰세요
+ - * 숫자 (디버그에서는 패닉, 릴리스에서는 한 바퀴 돌아요) 오버플로가 날 수 없다고 확신할 때
checked_add 등 Option<T> 틀린 숫자가 곧 버그일 때: 돈, 크기, ID
saturating_add 등 MIN이나 MAX에서 멈춘 숫자 자연스러운 바닥이나 천장이 있을 때: 체력, 볼륨, 재고
wrapping_add 등 한 바퀴 돈 숫자 한 바퀴 도는 게 목적일 때: 해시, 순환하는 카운터
overflowing_add 등 (T, bool) 한 바퀴 돈 값과 함께, 돌았는지도 알고 싶을 때
T::try_from(x) Result<T, _> 더 작은 타입에 들어가지 않을 수도 있는 값을 변환할 때

len()은 음수가 될 수 없는 usize를 돌려줘요. 빈 목록이라면 len() - 1은 -1이 되어야 하니 오버플로가 나요.

fn last_index(tasks: &[String]) -> usize {
tasks.len() - 1
}
fn main() {
let tasks: Vec<String> = Vec::new();
println!("마지막 할 일의 인덱스: {}", last_index(&tasks));
}

실행하면 이렇게 나와요.

thread 'main' (12345) panicked at src/main.rs:2:5:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ 직접 실행해 보기

attempt to subtract with overflow는 “빼다가 오버플로가 났어요”라는 뜻이에요. 거의 모든 사람이 적어도 한 번은 이 실수를 해요. 목록이 비어 있을 수 있다는 걸 타입으로 드러내면 해결돼요. Option<usize>를 돌려주는 tasks.len().checked_sub(1)을 반환하세요. 마지막 항목만 필요하다면 tasks.last()가 더 간단해요.

작은 타입으로 작은 숫자 더하기

섹션 제목: “작은 타입으로 작은 숫자 더하기”

시험 점수 90, 85, 100은 각각 u8에 들어가요. 하지만 합계인 275는 들어가지 않아요. 그래서 let total: u8 = scores.iter().sum();은 attempt to add with overflow 패닉을 일으켜요. 패닉이 가리키는 위치가 표준 라이브러리 안쪽이라 헷갈릴 수 있지만, 원인은 여러분의 u8이에요. 더하기 전에 각 값을 더 큰 타입으로 바꾸세요.

fn main() {
let scores: Vec<u8> = vec![90, 85, 100];
let total: u32 = scores.iter().map(|&s| s as u32).sum();
println!("합계: {total}, 평균: {}", total / 3);
}

실행 결과:

합계: 275, 평균: 91
▶ 직접 실행해 보기

u8에서 u32로 가는 as 변환은 언제나 안전해요. 모든 u8 값이 u32에 들어가니까요.

휴대폰 배터리 잔량은 0부터 100까지의 u8이에요. 100을 넘거나 0 아래로 내려가지 않는 charge(level: u8, amount: u8) -> u8과 drain(level: u8, amount: u8) -> u8을 작성하세요. 80에서 시작해서 30만큼 충전하고, 그다음 130만큼 소모한 뒤, 단계마다 잔량을 출력하세요. 힌트: 충전에는 saturating_add와 .min(100)을, 소모에는 saturating_sub를 쓰세요.

정답 보기
fn charge(level: u8, amount: u8) -> u8 {
level.saturating_add(amount).min(100)
}
fn drain(level: u8, amount: u8) -> u8 {
level.saturating_sub(amount)
}
fn main() {
let mut level: u8 = 80;
println!("시작: {level}%");
level = charge(level, 30);
println!("충전 후: {level}%");
level = drain(level, 130);
println!("하루를 보낸 뒤: {level}%");
}

실행 결과:

시작: 80%
충전 후: 100%
하루를 보낸 뒤: 0%
▶ 직접 실행해 보기

saturating_sub는 알아서 0에서 멈춰요. 충전할 때는 타입의 한계가 255라서, .min(100)으로 우리만의 한계를 하나 더 얹어 줘요.

1 × 2 × … × n을 곱하고, 답이 u64에 담기에 너무 커지면 None을 돌려주는 fn factorial(n: u64) -> Option<u64>를 작성하세요. 5, 20, 21에 대한 결과를 출력하세요. 힌트: let mut result: u64 = 1;로 시작해서 for 반복문 안에서 result.checked_mul(i)?를 쓰세요.

정답 보기
fn factorial(n: u64) -> Option<u64> {
let mut result: u64 = 1;
for i in 1..=n {
result = result.checked_mul(i)?;
}
Some(result)
}
fn main() {
for n in [5, 20, 21] {
match factorial(n) {
Some(value) => println!("{n}! = {value}"),
None => println!("{n}!은(는) u64에 담기엔 너무 커요"),
}
}
}

실행 결과:

5! = 120
20! = 2432902008176640000
21!은(는) u64에 담기엔 너무 커요
▶ 직접 실행해 보기

곱셈 하나라도 들어가지 않는 순간, ?가 함수 전체에서 None을 돌려줘요. u64에 담을 수 있는 가장 큰 팩토리얼은 20!이에요.

  • 모든 정수 타입은 T::MIN부터 T::MAX까지 범위가 정해져 있어요. 그 범위를 벗어나면 오버플로예요.
  • 디버그 빌드에서는 attempt to add with overflow 패닉이 나요. 릴리스 빌드에서는 조용히 한 바퀴 돌아요. 컴파일러가 볼 수 있는 오버플로는 컴파일 에러예요.
  • 동작은 직접 고르세요. checked_는 Option을 주고, saturating_는 끝에서 멈추고, wrapping_은 한 바퀴 돌고, overflowing_은 값과 bool을 함께 줘요.
  • as는 절대 검사하지 않고 숫자를 조용히 바꿀 수 있어요. 값이 들어가지 않을 수도 있다면 T::try_from을 쓰세요.
  • 빈 목록의 len() - 1, 작은 타입으로 구한 합계, MIN의 abs(), 그리고 pow를 조심하세요.

이해도 점검

1. 값이 변수에서 올 때, 디버그 빌드에서 250u8 + 10은 어떻게 될까요?

2. 100u64.checked_sub(250)은 무엇을 돌려줄까요?

3. 체력 바는 절대 0 아래로 내려가면 안 돼요. 가장 잘 맞는 메서드는 무엇일까요?

4. 300_i32 as u8은 무엇일까요?

점수: 0 / 4