프로그래밍을 하다보면 수없이 많은 에러와 버그를 테스트할 때에도, 심지어 프로덕션 환경에 배포 후에도 관찰할 수 있습니다. 특히 pointer를 직접 가져다 쓰는 C/C++, 심지어 직접적으로 pointer를 가져다 쓰진 않지만 null을 쓸 수 있는 수많은 언어에서는 null 참조 에러 및 취약점이 아직도 빈번하게 일어나고 있습니다. 다행히도 Rust처럼 아예 근본적으로 null 참조가 불가능하도록(unsafe 접근 제외) 언어 레벨에서 막아버리는 방법이나, Option 타입을 활용하여 null 참조를 명시적으로 피해갈 수 있는 방법들이 많이 있습니다. 그러나, 아직 사칙연산에서 발생할 수 있는 overflow, divided by zero에 대해서는 명시적으로 처리하지 않는 것을 꽤 많은 경우에서 확인할 수 있습니다. 이러한 경우는 null 참조보다는 좀 더 희박하게 일어날 것이라 생각되지만(특히 overflow), 항상 위험이 도사리고 있다는 점을 숙지하고 있어야 한다고 생각합니다.
null 참조를 방지하는 방법을 살펴보면 크게 두 가지인 것을 확인할 수 있습니다. Rust처럼 컴파일 타임에 안전성을 체크하거나, 런타임에 wrap 타입을 활용하여 명시적인 체크를 하도록 강요할 수 있습니다. 사칙연산에서도 이런 것을 활용하여 더 안전한 프로그램을 구현할 수 있지 않을까요? 또한, 이런 경우에 성능은 얼마나 하락할 수 있고, 어떻게 대처할 수 있을까요? 벤치마크 코드는 다음 환경에서 확인됐습니다.
다른 언어에서는 어떻게 되는지 잘 알지 못하지만, Rust에서는 다음과 같이 checked operation이 제공됩니다.
https://doc.rust-lang.org/std/primitive.i32.html#method.checked_div
문서에서 제공하는 예시는 다음과 같습니다.
assert_eq!((i32::MIN + 1).checked_div(-1), Some(2147483647));
assert_eq!(i32::MIN.checked_div(-1), None);
assert_eq!((1i32).checked_div(0), None);
마치, Option 타입을 활용하여 null 참조를 막듯이, 사칙연산에 대해서 모든 결과값을 Result로 감싸고 명시적으로 에러가 났을 때 처리할 수 있도록 강제합니다. 간단한 함수를 구현하며 사용해봅시다.
좀 더 사실적인 예제를 만들면 좋을텐데, 당장 기억나는 것이 없으므로 어떤 급수의 합을 구하는 함수를 구현하는 것을 해봅시다.
급수의 항을 정수근사값으로 구하여 더하는 프로그램을 짜 봅시다. 다음과 같이 짜 볼 수 있습니다.
fn calc(n: u32) -> u32 {
let mut result = 0;
for k in 0..=n {
result += (1 + 39_u32.pow(k)) / k;
}
result
}
짜서 calc(2)를 돌려보니 어라? 프로그램이 panic 나면서 종료됩니다. 왤까요? 는 1부터 시작해야 하는데, 제가 실수로 습관적으로 0부터 시작하도록 프로그램을 짜버리고 말았네요. 하지만, 컴파일이 되어 버리고, 런타임에서 돌려보기 전까진 확인할 수 없습니다. 이 예시의 경우에는 한 번만 테스트해보면 바로 문제가 드러나기 때문에 쉽게 버그를 수정할 수 있겠지만, 현실의 비즈니스 로직은 그렇게 만만치 않습니다. 수많은 사칙연산을 통하여 결과를 계산하는데, 타입에서 0을 걸러버리는 Rust의 NonZero 타입을 쓰지 않는 이상 언젠가는 문제가 될 수도 있습니다.
그러나 0..=n을 1..=n으로 수정해봐도, 이 프로그램에는 여전히 문제가 있습니다. calc(10)을 돌려봅시다. 이번에도 panic이 납니다. attempt to multiply with overflow라면서 프로그램이 종료되는 군요. 아하, pow가 u32를 커버하기엔 너무 큰 값을 내놨기 때문에 종료된 것입니다. u64로 바꾸거나 좀 더 큰 값을 다루려면 무한자리를 지원하는 BigInt 같은 라이브러리를 통하여 계산을 해야 할 것입니다. 프로그램의 명세에서 입력값의 범위를 명시하고 있더라도, 현실적으로는 내부 계산과정에서 언제나 overflow 가능성은 도사리고 있습니다. 따라서 이런 경우에도 안전하게 처리해주면 디버깅할 때도 도움이 될 것이고, 잘못된 요청을 잘못되게 처리해버리는 최악의 경우를 예방할 수 있습니다.
이제 안전하게 구현해봅시다. 다음과 같이 checked 붙은 메소드들을 통하여 에러를 처리할 수 있습니다.
fn checked_calc(n: u32) -> Option<u32> {
let mut result: u32 = 0;
for k in 0..=n {
result =
result.checked_add((1_u32.checked_add(39_u32.checked_pow(k)?)?).checked_div(k)?)?;
}
Some(result)
}
실수로 0부터 시작한 계산도 None을 반환하면서 프로그램이 에러없이 안전하게 종료됩니다. 또한, 1부터 시작하게 고친 이후에도 적당히 큰 n을 입력하면 checked_pow에서 실패하기 때문에 None을 반환하면서 안전하게 종료됩니다.
다만, 이렇게 구현하면 코드가 꽤 더러워질 뿐만 아니라, 성능도 하락할 것이라 누구나 당연히 예측할 수 있을 것입니다. 얼마만큼 감소할까요? 위에 주어진 코드에 대해서 비교를 해보도록 합시다. 이 있는 엄청 큰 항이기 때문에 예상하시다시피 u32로 커버할 수 있는 최대 n은 6입니다. 연산이 얼마 들어가진 않겠지만, 일단 아래와 같이 벤치마크 코드를 짜서 cargo criterion으로 돌려봅시다.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
const N: u32 = 6;
fn calc(n: u32) -> u32 {
let mut result = 0;
for k in 1..=n {
result += (1 + 39_u32.pow(k)) / k;
}
result
}
fn checked_calc(n: u32) -> Option<u32> {
let mut result: u32 = 0;
for k in 1..=n {
result =
result.checked_add((1_u32.checked_add(39_u32.checked_pow(k)?)?).checked_div(k)?)?;
}
Some(result)
}
fn bench_calc(c: &mut Criterion) {
c.bench_function(&format!("calc {}", N), |b| b.iter(|| calc(black_box(N))));
}
fn bench_checked_calc(c: &mut Criterion) {
c.bench_function(&format!("checked_calc {}", N), |b| {
b.iter(|| checked_calc(black_box(N)))
});
}
criterion_group!(benches, bench_calc, bench_checked_calc);
criterion_main!(benches);
다음과 같은 결과를 얻었습니다.
calc 6 time: [8.4231 ns 8.4370 ns 8.4495 ns]
checked_calc 6 time: [10.220 ns 10.231 ns 10.242 ns]
일반 calc보다 21.3% 정도 느린 것을 확인할 수 있습니다. 그렇다면, 좀 더 복잡한 연산에서는 어떨까요? 이번에는 우박수를 계산해보도록 하겠습니다. 우박수는 다음과 같이 정의됩니다.
어떤 을 입력을 받고, 이것이 1까지 도달하는데 계산하는 횟수를 계산해보도록 함수를 구현해보겠습니다. 다음과 같이 구현할 수 있습니다.
fn hailstone(mut n: u32) -> u32 {
let mut iter = 0;
loop {
if n == 1 {
return iter;
}
if n % 2 == 0 {
n /= 2;
} else {
n = n * 3 + 1;
}
iter += 1;
}
}
이때 n이 얼마나 커질지, 얼마나 반복될지 전혀 알 수 없기 때문에(물론 u32 범위 안에서 무한루프가 없다는 것은 컴퓨터를 통하여 증명되어 있습니다), checked하는 코드를 넣어서 overflow를 방지하는 코드를 작성해볼 수 있습니다. 그렇게 checked_hailstone을 만들어서 다음과 같이 벤치마크를 구현할 수 있습니다.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
const N: u32 = 27;
fn hailstone(mut n: u32) -> u32 {
let mut iter = 0;
loop {
if n == 1 {
return iter;
}
if n % 2 == 0 {
n /= 2;
} else {
n = n * 3 + 1;
}
iter += 1;
}
}
fn checked_hailstone(mut n: u32) -> Option<u32> {
let mut iter = 0;
loop {
if n == 1 {
return Some(iter);
}
if n.checked_rem(2)? == 0 {
n = n.checked_div(2)?;
} else {
n = n.checked_mul(3)?.checked_add(1)?;
}
iter = iter.checked_add(1)?;
}
}
fn bench_hailstone(c: &mut Criterion) {
c.bench_function(&format!("hailstone {}", N), |b| {
b.iter(|| hailstone(black_box(N)))
});
}
fn bench_checked_hailstone(c: &mut Criterion) {
c.bench_function(&format!("checked_hailstone {}", N), |b| {
b.iter(|| checked_hailstone(black_box(N)))
});
}
criterion_group!(benches, bench_hailstone, bench_checked_hailstone);
criterion_main!(benches);
위키백과 문서에 따라서 각 1, 10, 100, ...보다 작은 수들 중에서 가장 큰 반복횟수를 가진 수들로 벤치마크를 돌려보면 다음과 같습니다.
cargo criterion
hailstone 9 time: [9.9937 ns 10.008 ns 10.025 ns]
checked_hailstone 9 time: [11.524 ns 11.531 ns 11.538 ns]
hailstone 97 time: [121.48 ns 121.79 ns 122.09 ns]
checked_hailstone 97 time: [77.405 ns 77.464 ns 77.539 ns]
hailstone 871 time: [179.05 ns 179.33 ns 179.62 ns]
checked_hailstone 871 time: [117.19 ns 117.28 ns 117.38 ns]
hailstone 6171 time: [260.51 ns 260.78 ns 261.09 ns]
checked_hailstone 6171 time: [174.64 ns 175.01 ns 175.41 ns]
hailstone 77031 time: [344.74 ns 344.94 ns 345.14 ns]
checked_hailstone 77031 time: [232.41 ns 232.65 ns 232.90 ns]
hailstone 837799 time: [517.39 ns 517.84 ns 518.33 ns]
checked_hailstone 837799 time: [352.51 ns 352.88 ns 353.26 ns]
놀랍게도 이번에는 checked 연산을 사용한 것이 대부분 더 빠른 것을 확인할 수 있습니다. 일일히 체크해보니 n.checked_mul(3)?.checked_add(1)? 부분이 어셈블리 최적화가 들어간 것인지, 그냥 곱보다 상당히 빠릅니다. checked_hailstone에서 이것저것 바꿔가면서 checked를 몇 개 빼 보아도 일반 연산보다 조금만 느리거나 오히려 빠른 결과를 관찰할 수 있었습니다. 속도도 빠른데다가, 우박수처럼 예측 불가능한 연산을 할 때 안전하게 연산할 수 있다는 점을 생각하면 이러한 경우에는 checked 연산을 사용하는 것에 반대할 이유가 없어 보일 것입니다. 물론 근본적으로 프로그램의 성능을 인간이 프로그래밍할 때 예측할 수 있다는 것은 큰 오만이기 때문에, 적절하게 checked를 쓰고 벤치마킹을 함으로써 안전성과 퍼포먼스 두 마리의 토끼를 잡을 수 있다고 생각하는 편이 좋을 것입니다.
위에서 예상치도 못하게 check 연산이 빠른 경우를 살펴보게 되었습니다. 하지만, 이 경우에는 checked_mul 같은 것이 최적화된 어셈블리로 돌아가기 때문에 운이 좋은 경우라고 볼 수 있습니다. 하드웨어적인 도움을 받지 못한다면, if문을 사용하여 명시적으로 체크하여 Option<Value>를 반환하는 checked 연산 메소드를 직접 만들어야 할텐데, 이러한 경우에는 성능 하락이 많이 걱정될 수밖에 없습니다.
Rust에서는 null 참조를 safe한 메모리 접근에 한해서는 컴파일 타임에 체크하여 불가능하도록 만들어두었습니다. 그렇다면 사칙연산 같은 것도 compile 타임에 overflow, divided by zero를 체크해볼 수 있지 않을까요? 아직까지 현업에서 많이 활용되는 정적 분석기가 있는지는 모르겠지만, Rust에는 prusti라는 시도가 있습니다.
GitHub README에도 적혀 있지만, integer overflow를 체크할 수 있습니다. 정수를 다룰 때 가장 많이 실수할 수 있는 부분이 바로, 덧셈과 뺄셈에서도 생각보다 쉽게 overflow가 날 수 있다는 점입니다. REAME에 있는 것처럼, 일반적으로 정수 연산을 통한 평균값은 다음과 같이 계산할 수 있습니다.
let mid: i32 = (high + low) / 2;
그러나 이것은 (high + low)가 이하일 경우에만 계산이 가능합니다. i32가 다루는 모든 정수 범위를 포괄할 수 없습니다. 특히 이분탐색을 구현할 때 가장 실수하기 쉬운 부분입니다. 다음과 같이 구현해야 안전하게 i32가 포괄하는 범위에서 계산할 수 있습니다.
let mid: i32 = low + ((high - low) / 2);
prusti는 이런 것을 검증할 수 있게 해줍니다. 위에 checked 연산을 사용하여 런타임에 체크하는 것이 아니기 때문에 이상한 성능 하락이나 에러 케이스를 따로 처리해야 하는 불편함을 감수할 필요가 없습니다.
위에 언급한 정적 분석툴을 사용하는 것도 좋긴 하지만, 아직 얼마만큼 현업에서 사용되는지를 잘 알지 못하기도 하고, 때로는 저런 것들이 컴파일 성능을 상당히 헤칠 수 있습니다. 다른 방법은 어떤 것이 있을까요? 저는 custome subtype을 사용하여 어느 정도 타협을 볼 수 있을 것이라 생각합니다. 이것은 단순한 생각이기 때문에 정말로 실용적으로 쓸 수 있을지는 모르겠지만, 활용할 수 있는 좋은 아이디어는 될 수 있을 것이라 생각합니다.
일반적인 서버를 구현할 때, request에 들어오는 입력 정수값들은 대부분 꽤 작습니다. 가령, 결제 서버여서 상품의 수량이나 금액이 들어온다 한들, 그게 부동산 같은 다른 차원이 아닌 이상 일반 전자상거래 수준에서는 수 백만원 이하일 것입니다. 총합수량도 대량구매가 아니라면 100 이하라고 볼 수 있습니다. 이 금액과 수량은 다음과 같은 타입으로 표현할 수 있습니다.
Price: integer in 0 ~ 10,000,000(inclusive)
Qty: integer in 0 ~ 100(inclusive)
그렇다면 총합 가격은 다음과 같은 타입이 될 수 있을 것입니다.
TotalPrice: Price + Price + ... <= Price * Qty -> integer in 0 ~ 1,000,000,000(inclusive)
TotalPrice는 이제 u32가 다룰 수 있음이 보장되어 있습니다. 이렇게 각 request에 쓸모없게 i32를 그대로 넣지 말고, 엄격한 subtype을 부여한 다음에, 연산으로 확장될 상위 타입을 잘 고려하여 실제 계산 타입을 알맞게 부여하면 안전하게 연산할 수 있음을 보장할 수 있습니다. 이러한 경우의 가장 대표적인 예시는 NonZero 타입을 사용하는 것입니다. Rust의 governor 라이브러리도 이러한 것을 잘 활용하고 있어서 개인적으로 좋아합니다.
https://docs.rs/governor/latest/governor/#quick-example 예시를 살펴보면 Quota::per_second가 0이 되어버리면, RateLimiter로서 제대로 작동할 수 없기 때문에 명시적인 NonZero 타입을 입력받도록 제한하고 있습니다.
이외에도 나눗셈을 할 때 제수에 항상 NonZero만 받도록 강제하면 checked 연산을 사용하지 않고 타입으로도 연산의 안전함을 보일 수 있습니다.
이번 글을 통해서, 단순히 null 참조와 같은 경우 말고도 사칙연산에서도 엄격하게 에러를 핸들할 수 있도록, 또는 원초적으로 에러가 발생하지 않도록 검증하는 것을 알아봤습니다. 생각보다 divided by zero 같은 에러는 자주 나타나기 때문에 이런 방법들을 잘 사용하여서 prod에 나간 제품에서 추적하기 힘든 버그를 만나지 않도록 하면 좋지 않을까 싶습니다.