한때 주로 안드로이드를 Java로 프로그래밍하던 시절이 있었습니다. 말만 해도 강산이 변했을 법한 시간이 흘렀음이 느껴지네요. Java에서 Apache의 HttpClient로 API를 콜하고 데이터를 받아오는 것을 짜고 그랬습니다. 이런 코드를 짤 때 여러 Exception(예외)을 만나볼 수 있습니다. IOException이라든가 MalformedURLException 등의 구체적인 에러를 보는 경우가 많았습니다. 아마 기억상으로는 컴파일러가 필수적으로 예외 처리 코드를 짜라고 강요했던 거 같은데, 투덜대면서 e.prineStackTrace()만 대충 넣어줬던 기억이 나네요. 또, 특히 Array/List쪽을 다룰 때에는 툭하면 IndexOutOfBoundsException을 보기도 했고, 잠깐 멍 때리고 코드를 짜면 ArithmeticException를 산술 연산에서 보기도 하였죠.
위의 에러들은 어떤 공통점과 차이점을 가질까요? 공통점으로는 모두 다 Exception으로, try-catch 구문을 통하여 에러를 처리할 수 있다는 점입니다. 차이점으로는 뒤의 IndexOutOfBoundsException과 ArithmeticException은 RuntimeException으로, 앞의 예외들과 달리 예측할 수 없는 에러라는 것입니다. 각 분류가 checked exception(명시적으로 처리해줘야 하는 에러), unchecked exception(예측할 수 없어 처리를 꼭 요구하지는 않는 에러)라고 볼 수 있습니다.
이전 글에서 밝힌 것처럼 저는 정말로 발생 가능한 모든 에러는 다 처리할 수 있어야 한다고 생각합니다. 그런 방법 중의 하나가 산술 연산 등을 실행할 때 check하여 실행할 수 있는 명령어였고요. 이번 글에서는 이렇게 check하는 명령어를 강요하여, 프로그램 자체가 안전함을 보장할 수 있도록 하는 프로그래밍 방법을 간단하게 제안해보고자 합니다. 사실, 제안이라기보다 이미 있는 방식을 좀 더 도입하고자 주장하는 것에 가깝습니다.
checked operation을 사용하는 것은 좋습니다. 하지만, 내가 짠 코드 부분 말고 다른 부분(타인이 짠 코드나 라이브러리 등)에서는 정말 잘 체크하여 사용하고 있는지 알 방법이 없습니다. 간단하게 이 말로 요약할 수 있습니다: "내가 호출하고자 하는 함수는 panic(runtime exception)이 발생하지 않음을 확인하거나 보장할 수 있을까?"
Rust에는 이런 게 있습니다: no-panic. README에도 있는 내용이지만 단 한 줄로 설명 가능합니다. #[no_panic] attribute를 부여함으로써, linking 타임에 이 함수가 절대로 panic을 일으키지 않음을 증명하여 줍니다. 지난 글에서 다뤘던 우박수 예제에 적용해봅시다.
use no_panic::no_panic;
#[no_panic]
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;
}
}
#[no_panic]
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 main() {
let n = 837799;
println!("{} {:?}", hailstone(n), checked_hailstone(n));
}
debug 모드로 빌드하면 다른 데에서도 debug_assert가 엄청나게 많이 들어가있는지 false positive는 아니더라도, 저희가 원하는 결과를 얻을 수 없으므로 아래와 같이 Cargo.toml에 release 모드에서도 integer overflow를 체크할 수 있도록 하고 cargo build --release로 확인해봅시다.
[profile.release]
overflow-checks = true
$ cargo build --release
Compiling ...
error: linking with `cc` failed: exit status: 1
|
= note: ...
= note: Undefined symbols for architecture arm64:
"_
ERROR[no-panic]: detected panic in function `hailstone`
", referenced from:
core::ptr::drop_in_place$LT$rust_playground..hailstone..__NoPanic$GT$::h71f76d8b495c149a in rust_playground-774837751d2d30da.rust_playground.ce4fbfa9-cgu.1.rcgu.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
error: could not compile ... due to previous error
이런 요상한 에러가 나면서(너무 번잡스럽지만 linker 레벨에서 체크하게 하는 것이라 어쩔 수 없는 거 같긴 합니다) hailstone 함수가 panic이 일어날 수 있다고 보여줍니다. 반면에, checked operation으로 도배한 checked_hailstone은 에러가 나지 않음을 확인할 수 있습니다. 이렇게 컴파일 타임에서 이 함수는 내부에서 unsafe한 시도를 하지 않으면 절대로 프로그램이 panic으로 죽는 일이 없음을 보일 수 있습니다.
위와 같이 단순히 #[no_panic]을 막 붙여서 사용할 수 있을 것입니다. 다만, panic이 있는지를 체크하는데에도 비용이 들어가기 때문에 no-panic Github issue에서는 다음과 같이 wrapper 함수를 따로 정의하여 쓰는 것을 추천하기도 합니다
#[inline(never)]
#[no_panic]
#[cfg(test)]
fn wrapper() {
...
}
절대 inline하지 않아서 함수의 정보가 날라가는 불상사를 막아주고, test할 때만 체크하도록 하여 낭비를 줄여줍니다.
실제로 Rust의 libm에서도 비슷한 방식으로 사용하고 있는 것으로 보입니다. https://github.com/rust-lang/libm/blob/master/src/math/acos.rs#L62
코드에 열심히 check operation을 사용하여 panic을 막아볼 수 있습니다. 그러나 타인이 작성한 코드나 라이브러리에서는 그것이 정말 panic을 안 일으키는지 확인할 방법이 없을 뿐더러, 코드를 짜는 자신이 실수할 수도 있기 때문에 이럴 때 활용할 수 있는 no-panic을 간단하게 소개해봤습니다. 이러한 것에 일일히 신경 쓰는 것이 곧 생산성 저하나 과도한 집착으로 보여질 수도 있으나, 믿을 것 없는 세상에서 내 코드라도 믿어보려면 이런 노력을 해보는 게 좋지 않을까 싶습니다.