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 ~ 255i8: -128 ~ 127u16: 0 ~ 65535i32: -2147483648 ~ 2147483647u32: 0 ~ 4294967295i64: -9223372036854775808 ~ 9223372036854775807u64: 0 ~ 18446744073709551615u128: 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 overflownote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceattempt to add with overflow는 “더하다가 오버플로가 났어요”라는 뜻이에요. 일부러 값을 함수 매개변수로 넘겼어요. + 바로 옆에 숫자를 직접 쓰면, 프로그램을 실행하기도 전에 컴파일러가 문제를 찾아내거든요(곧 보게 될 거예요). 프로그램이 죽는 게 가혹하게 느껴질 수도 있지만, 사실은 친절한 거예요. 실제 서비스에서 틀린 점수가 나오는 대신, 내 컴퓨터에서 정확한 줄 번호와 함께 버그를 찾을 수 있으니까요. 패닉에 대해서는 panic 페이지에서 더 알아볼 수 있어요.
릴리스 빌드에서는 값이 한 바퀴 돌아요
섹션 제목: “릴리스 빌드에서는 값이 한 바퀴 돌아요”cargo run --release로 빌드하면 코드를 조금 더 빠르게 만들려고 이 검사를 꺼요. 그러면 숫자가 주행 거리계처럼 한 바퀴 돌아요(wrap around). u8에서 250 + 10은 4가 돼요(260 - 256 = 4이니까요).
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을(를) 출금했어요. 남은 잔고: 70250을(를) 출금할 수 없어요: 잔고가 부족해요Some(65)None결과가 Option이니까 ? 연산자도 쓸 수 있어요. order_total에서 곱셈이 오버플로되면 함수는 그 자리에서 바로 None을 돌려줘요.
saturating_: “끝에서 멈춰”
섹션 제목: “saturating_: “끝에서 멈춰””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_: “둘 다 줘”
섹션 제목: “overflowing_: “둘 다 줘””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, 오버플로 발생: true60, 오버플로 발생: false이 메서드는 다른 것들보다 덜 쓰게 될 거예요. 무엇을 골라야 할지 모르겠다면 checked_가 아주 좋은 기본 선택이에요.
as로 변환할 때의 오버플로
섹션 제목: “as로 변환할 때의 오버플로”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 = 4294967295300은 u8에 들어가지 않아요: out of range integral type conversion attempted들어가요: 200out 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 = -3i32::MIN.checked_abs() = Nonei32::MIN.unsigned_abs() = 214748364810u32.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, _> |
더 작은 타입에 들어가지 않을 수도 있는 값을 변환할 때 |
자주 하는 실수
섹션 제목: “자주 하는 실수”빈 목록의 길이에서 1 빼기
섹션 제목: “빈 목록의 길이에서 1 빼기”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 overflownote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceattempt 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, 평균: 91u8에서 u32로 가는 as 변환은 언제나 안전해요. 모든 u8 값이 u32에 들어가니까요.
연습 문제
섹션 제목: “연습 문제”1. 휴대폰 배터리
섹션 제목: “1. 휴대폰 배터리”휴대폰 배터리 잔량은 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)으로 우리만의 한계를 하나 더 얹어 줘요.
2. 자기 한계를 아는 팩토리얼
섹션 제목: “2. 자기 한계를 아는 팩토리얼”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! = 12020! = 243290200817664000021!은(는) 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
더 알아보기
섹션 제목: “더 알아보기”- 정수형 — The Rust Programming Language 한국어판: 정수 오버플로에 대한 공식 설명이에요. 디버그 빌드의 패닉, 릴리스 빌드의 한 바퀴 돌기, 네 가지 메서드 가족을 다뤄요.
u8— 표준 라이브러리 문서 (영어):checked_,saturating_,wrapping_,overflowing_메서드가 모두 있고, 메서드마다 작은 예제가 붙어 있어요.i32— 표준 라이브러리 문서 (영어): 부호 있는 숫자용 같은 메서드들과checked_abs,unsigned_abs,checked_pow가 있어요.- Overflow — The Rust Reference (영어): 어떤 연산이 오버플로될 수 있고, 그때 어떤 일이 일어나는지에 대한 정확한 규칙이에요.
- Casting — Rust by Example (영어): 한 바퀴 돌고, 잘리고, 끝에서 멈추는
as변환 예제를 실행해 볼 수 있어요.