짧게 답하면 가능하지만, 전제 조건이 있다
Rust는 개발 환경에서 runtime contracts를 강제할 수 있고, 릴리스 빌드에서는 완전히 지워버릴 수 있다. 전제 조건은 이 언어가 contract를 first-class 개념으로 다루지 않는다는 것이다. 필요한 구성 요소는 주어지지만, 직접 연결해야 한다.
debug_assert!는 가장 먼저 떠오르는 출발점이다. 디버그 빌드에서는 평가되고 릴리스에서는 아무것도 남기지 않는다. 간단한 invariants에는 충분하지만, 진짜 contracts에는 preconditions, postconditions, 때로는 loop invariants와 커스텀 실패 동작이 필요하다. 이런 것은 기본으로 제공되지 않는다.
이 글은 외부 crate 없이, 말 그대로 릴리스에서 zero overhead로 실제 design-by-contract 의미를 구현하는 패턴을 보여준다.
Rust에서 runtime contracts가 중요한 이유
Rust의 타입 시스템과 borrow checker는 컴파일 타임에 엄청난 범위의 오류를 잡아낸다. 하지만 logic errors, boundary violations, 깨진 business invariants는 잡지 못한다.
NonZeroU32를 인자로 받는 함수를 작성해도 여전히 잘못된 값으로 나눌 수 있다. 타입 시스템으로 slice bounds를 강제해도 여전히 잘못된 slice를 넘길 수 있다. Contracts는 “컴파일된다”와 “올바르다” 사이의 간극을 메워준다.
반대 의견은 언제나 performance다. 시스템 프로그래밍에서 릴리스 빌드는 약속이다. runtime check를 위한 추가 branch instructions은 그 약속을 어기는 것처럼 느껴진다.
목표는 개발과 테스트 중에는 도움이 되는 곳에 그 검사를 두고, 사용자가 실행하는 바이너리에서는 제거하는 것이다.
Rust가 기본으로 제공하는 것
Rust에는 두 가지 assertion family가 있다. assert!와 assert_eq!는 모든 빌드에서 실행된다. debug_assert!와 debug_assert_eq!는 cfg(debug_assertions)가 활성화될 때만 실행되는데, 기본적으로 디버그 빌드에서만 true다.
pub fn withdraw(balance: &mut i64, amount: i64) {
// 항상 실행된다. 릴리스에서도 비용을 치른다.
assert!(amount > 0, "withdrawal must be positive");
assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
pub fn withdraw_debug_only(balance: &mut i64, amount: i64) {
// 릴리스 빌드에서 사라진다.
debug_assert!(amount > 0, "withdrawal must be positive");
debug_assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
이런 방식은 ad-hoc checks에는 괜찮다. 하지만 systematic contracts에는 부족하다. 왜냐하면:
- preconditions, postconditions, invariants를 위한 내장 syntax가 없다
- build profile마다 contract level을 쉽게 전환할 수 없다
- panicking 대신 logging 같은 커스텀 실패 동작은 모든 호출을 감싸야 한다
매크로로 zero-cost contracts 구축하기
실제로 동작하는 패턴은 디버그에서는 검사로, 릴리스에서는 아무것도 아닌 것으로 확장되는 작은 macro suite다. 릴리스 테스트에서도 contract check를 원한다면 커스텀 cfg 플래그로 전환할 수 있고, 아니면 debug_assertions에 직접 묶으면 된다.
완전하고 동작하는 구현은 다음과 같다:
// contracts.rs
/// Precondition check. Compiles to nothing in release.
#[macro_export]
macro_rules! requires {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Precondition violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
/// Postcondition check. Compiles to nothing in release.
#[macro_export]
macro_rules! ensures {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Postcondition violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
/// Invariant check for data structures. Compiles to nothing in release.
#[macro_export]
macro_rules! invariant {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Invariant violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
사용법은 간단하다:
use contracts::{requires, ensures, invariant};
pub struct Account {
balance: i64,
}
impl Account {
pub fn new(initial: i64) -> Self {
requires!(initial >= 0, "initial balance cannot be negative");
let acct = Self { balance: initial };
ensures!(acct.balance >= 0);
acct
}
pub fn withdraw(&mut self, amount: i64) {
requires!(amount > 0, "amount must be positive");
requires!(
amount <= self.balance,
"amount must not exceed balance"
);
self.balance -= amount;
ensures!(self.balance >= 0, "balance must stay non-negative");
invariant!(self.is_consistent());
}
fn is_consistent(&self) -> bool {
self.balance >= 0
}
}
디버그 빌드에서는 모든 검사가 실행되고 명확한 메시지와 함께 panic한다. 릴리스 빌드에서는 macro가 빈 블록으로 확장된다. 옵티마이저는 아무것도 보지 못하고 어떤 instruction도 생성하지 않는다.
릴리스 빌드가 정말 무료인지 검증하기
macro를 믿는 것만으로는 부족하다. assembly를 직접 확인해야 한다.
withdraw 함수를 릴리스 모드로 빌드하고 출력을 살펴본다:
rustc --edition 2021 -C opt-level=3 --emit asm contracts.rs
cfg(debug_assertions)를 사용하는 macro로는, Account::withdraw의 릴리스 assembly에 산술 연산 그 자체 이상의 panic branch, 문자열 상수, 추가 비교가 전혀 존재하지 않는다. contract checks는 완전히 사라진다.
출력에 panic 관련 심볼이 보인다면, macro에 버그가 있거나 릴리스 profile에서 debug-assertions = true로 빌드하고 있기 때문이다.
언젠가 마주치게 될 profile 함정
Cargo는 Cargo.toml을 통해 릴리스 빌드에서 debug assertions를 활성화할 수 있게 해준다:
[profile.release]
debug-assertions = true
일부 팀은 integration tests에서 문제를 잡기 위해 임시로 이렇게 설정한다. contract 전환으로 cfg(debug_assertions)를 사용하고 있다면, 이것이 릴리스 바이너리를 갑자기 느리게 만든다. 테스트를 위해서는 그럴 수도 있지만, zero cost를 기대했다면 놀라운 일이다.
더 세밀한 제어가 필요하다면, debug_assertions 대신 커스텀 feature를 사용하라:
#[cfg(any(feature = "contracts", debug_assertions))]
이제 contracts는 기본적으로 디버그 빌드에서 실행되고, 특정 테스트 실행이나 canary deployment에서 추가적인 안전성이 필요할 때 --features contracts로 릴리스에서도 명시적으로 활성화할 수 있다.
이것이 Eiffel-style design-by-contract이 아닌 이유
진정한 design-by-contract 언어는 contracts를 상속하고, module boundaries를 넘어 compose할 수 있으며, 때로는 정적으로 증명까지 할 수 있다. Rust macro는 이런 것 중 어느 것도 제공하지 않는다.
얻는 것은 pragmatic한 접근이다. 검사는 call site에서 실행된다. 실패하면 panic한다. 릴리스에서는 사라진다. 이것만으로도 production에 overhead를 실어 보내지 않으면서 개발 중에 엄청난 범위의 버그를 잡을 수 있다.
의도를 문서화하는 데도 충분하다. 함수 맨 위의 requires! 블록은 다음에 읽는 사람에게 작성자가 가정한 것이 정확히 무엇인지 실행 가능한 형태로 알려준다.
릴리스에서 contracts를 유지해야 할 때
모든 contract가 사라져야 하는 것은 아니다. Safety-critical invariants, external input validation, security checks는 종종 assert!에 속하며, debug_assert!나 커스텀 contract macro에 속하지 않는다.
규칙은 간단하다: 조건을 위반하면 memory unsafety, data corruption, security vulnerability가 발생한다면 릴리스에서도 검사를 유지하라. 테스트로 잡혔어야 할 logic errors에는 zero-cost 패턴을 사용하라.
// 릴리스에서 유지하라. Memory safety가 이에 달려 있다.
assert!(!ptr.is_null());
// 릴리스에서 제거하라. Logic bug지만 unsafe는 아니다.
requires!(discount_rate <= 1.0);
한 crate로 패턴화하기
직접 macro를 유지보수하고 싶지 않다면, crates.io의 contracts crate가 거의 정확히 이 패턴을 구현하고 있다. 함수와 struct 정의에 붙는 #[requires], #[ensures], #[invariant] proc macro를 제공한다.
내부적으로는 릴리스에서 검사를 제거하기 위해 똑같은 cfg 확장을 사용한다. 가장 큰 이점은 더 나은 syntax다. 가장 큰 비용은 proc-macro dependency다.
이미 syn과 quote에 의존하고 있는 팀이라면 이 crate는 좋은 선택이다. zero dependencies를 원하는 팀이라면, 위의 macro 패턴은 스무 줄이고 Cargo.toml 변경도 필요 없다.
한 함수부터 시작하라
전체 codebase에 주석을 달 필요는 없다. 까다로운 invariant를 가진 함수 하나를 골라 requires!와 ensures!를 추가하고, 테스트를 실행한 뒤 릴리스 assembly를 확인하라.
디버그에서 실제 버그를 잡으면서 검사가 사라지는 것을 보면, 그 가치는 자명해진다. Contracts는 도입해야 할 framework가 아니다. 한 함수씩 쌓아가는 습관이다.