Rust에서 세미콜론 하나는 단순한 구두점이 아닙니다. 함수 끝의 value * 2는 i32 값을 만들지만 value * 2;는 그 값을 버리고 유닛 타입 ()을 만듭니다. 반환 타입을 i32로 적어 놓고 세미콜론을 붙이면 컴파일러가 타입 불일치로 거부하는 이유입니다.
이 글은 다른 언어에서 함수와 조건문을 써 본 Rust 입문자를 위한 글입니다. 작은 엔드포인트 검사 정책을 예로 삼아 함수, 블록 표현식, if, match, loop, while, for를 한 번에 연결합니다. 아래에서 조립하는 완성 크레이트는 Rust 2024 edition에서 rustfmt, Clippy, 단위 테스트를 실행할 수 있습니다.
1. 제어 흐름 범위
완성할 코드는 입출력을 다루지 않는 작은 함수 모음입니다. 성공 여부에 따라 재시도 지연을 고릅니다. 이어서 지연 시간을 분류하고 실패 횟수만큼 backoff를 계산합니다. 분기와 반복을 순수 함수에 가두면 각 경계값을 assert_eq!로 바로 검사할 수 있습니다.
2. 함수 시그니처와 경계
Rust 함수는 fn으로 정의합니다. 매개변수마다 타입을 쓰고 반환값이 있으면 -> 뒤에 반환 타입을 적습니다. 호출보다 정의가 파일 앞에 있어야 할 필요는 없습니다. 같은 스코프에서 보이기만 하면 됩니다.
pub fn retry_delay(
last_check_succeeded: bool,
attempts: u8,
) -> u64 {
if last_check_succeeded {
0
} else if attempts < 3 {
5
} else {
30
}
}
retry_delay의 본문은 블록 표현식입니다. 마지막에 실행되는 if도 표현식이므로 함수의 u64 반환값이 됩니다. return을 사용해 일찍 빠져나올 수도 있지만 이처럼 짧은 함수에서는 마지막 표현식을 반환하는 편이 흐름을 읽기 쉽습니다.
3. 문장과 표현식
문장은 선언 문장과 표현식 문장으로 나뉩니다. let delay = 5;는 이름을 도입하는 문장이고 5, 함수 호출, 블록, if, match는 값을 계산하는 표현식입니다. 표현식 뒤에 세미콜론을 붙여 문장으로 만들면 계산된 값을 버립니다.
fn doubled(
value: i32,
) -> i32 {
value * 2
}
마지막 줄을 value * 2;로 바꾸면 함수 본문에 반환할 마지막 표현식이 사라집니다. 이때 블록 값은 ()입니다. 실제로 잘못된 버전을 rustc --edition 2024로 컴파일하자 다음 진단이 나왔습니다.
error[E0308]: mismatched types
--> src/lib.rs:1:27
|
1 | fn doubled(value: i32) -> i32 {
| ------- ^^^ expected `i32`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return` expression
2 | value * 2;
| - help: remove this semicolon to return this value
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.
반대로 println!("done");처럼 결과보다 부수 효과가 목적이라면 세미콜론이 자연스럽습니다. 세미콜론을 무조건 빼는 규칙이 아니라 그 줄의 값을 다음 계산에 전달할지 묻는 편이 정확합니다.
4. 값을 만드는 if와 match
if를 대입문의 오른쪽에 둘 수 있습니다. 실행 가능한 가지가 여러 개라면 어느 가지를 선택해도 전체 표현식의 타입이 맞아야 합니다. 앞의 함수에서는 세 가지가 모두 정수 리터럴을 내므로 반환 타입 u64로 추론됩니다. if 조건에는 반드시 bool이 필요하며 숫자를 참이나 거짓으로 자동 변환하지 않습니다.
범위가 서로 겹치지 않는 분기에는 match가 읽기 좋습니다.
pub fn latency_label(
milliseconds: u64,
) -> &'static str {
match milliseconds {
0..=199 => "fast",
200..=999 => "slow",
_ => "very slow",
}
}
match는 위에서 아래로 패턴을 검사해 처음 맞는 arm을 실행합니다. _는 앞의 두 범위에 포함되지 않은 나머지를 받습니다. 모든 가능한 u64 값을 처리했으므로 빠지는 경로가 없습니다. 각 arm의 결과도 모두 &'static str로 같습니다.
이 글에서는 정수 범위만 사용합니다. enum과 Option, Result를 이용한 패턴 매칭은 뒤의 상태 모델링 편에서 별도로 다룹니다.
5. 반복문
조건이 유지되는 동안 반복하려면 while이 직접적입니다. 다음 함수는 실패 횟수를 하나씩 줄이며 지연 시간을 두 배로 늘립니다. saturating_mul을 쓴 까닭은 큰 입력에서 정수 overflow 대신 u64::MAX에 머물게 하려는 것입니다.
pub fn backoff_seconds(
initial_seconds: u64,
failures: u8,
) -> u64 {
let mut delay = initial_seconds;
let mut remaining = failures;
while remaining > 0 {
delay = delay.saturating_mul(2);
remaining -= 1;
}
delay
}
컬렉션의 각 원소를 순회한다면 인덱스를 직접 늘리는 while보다 for가 간결합니다.
pub fn count_successes(results: &[bool]) -> usize {
let mut successes = 0;
for succeeded in results {
if *succeeded {
successes += 1;
}
}
successes
}
끝나는 조건을 반복문 안에서 판단하거나 반복문 자체에서 값을 꺼내야 한다면 loop가 맞습니다. break candidate는 반복을 끝내는 동시에 loop 표현식의 값을 지정합니다.
pub fn first_multiple_at_or_after(
start: u64,
divisor: u64,
) -> u64 {
assert!(divisor > 0, "divisor must be positive");
let mut candidate = start;
loop {
if candidate.is_multiple_of(divisor) {
break candidate;
}
candidate += 1;
}
}
while과 for는 보통 ()로 평가됩니다. 일반적인 값으로 평가되는 반복 구조는 loop와 labeled block입니다. 반복문에서 계산한 값을 반환하고 싶다면 위처럼 loop에서 break 값을 사용하거나 반복이 끝난 뒤 누적 변수를 마지막 표현식으로 둡니다.
6. 경계와 정상 경로 테스트
happy path 하나만 확인하면 분기 코드의 절반이 실행되지 않을 수 있습니다. 이 예제는 if의 성공/초기 재시도/최대 시도 가지, match 범위의 양 끝, 빈 slice, overflow 포화, 이미 나누어떨어지는 값까지 검사합니다.
재현하려면 프로젝트를 만들어도 되는 임의의 디렉터리에서 시작합니다. 다음 명령은 현재 디렉터리 아래에 새 크레이트를 만들고 그 크레이트로 이동합니다.
cargo new --lib --edition 2024 functions-expressions-control-flow
cd functions-expressions-control-flow
mkdir -p examples
위에 나온 다섯 개의 pub fn 코드 블록을 표시된 순서대로 src/lib.rs에 복사한 뒤, 아래 테스트 모듈을 같은 파일 끝에 붙입니다. 이 목록이 실제로 실행한 6개 테스트 전부입니다.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn if_expression_selects_each_retry_policy() {
assert_eq!(retry_delay(true, 0), 0);
assert_eq!(retry_delay(false, 0), 5);
assert_eq!(retry_delay(false, 2), 5);
assert_eq!(retry_delay(false, 3), 30);
}
#[test]
fn latency_match_covers_ranges_and_fallback() {
assert_eq!(latency_label(0), "fast");
assert_eq!(latency_label(199), "fast");
assert_eq!(latency_label(200), "slow");
assert_eq!(latency_label(999), "slow");
assert_eq!(latency_label(1_000), "very slow");
}
#[test]
fn while_loop_applies_bounded_backoff() {
assert_eq!(backoff_seconds(5, 0), 5);
assert_eq!(backoff_seconds(5, 3), 40);
assert_eq!(backoff_seconds(u64::MAX, 1), u64::MAX);
}
#[test]
fn for_loop_counts_true_values() {
assert_eq!(count_successes(&[]), 0);
assert_eq!(count_successes(&[true, false, true]), 2);
}
#[test]
fn loop_break_returns_a_value() {
assert_eq!(first_multiple_at_or_after(14, 5), 15);
assert_eq!(first_multiple_at_or_after(15, 5), 15);
}
#[test]
#[should_panic(expected = "divisor must be positive")]
fn zero_divisor_is_rejected() {
first_multiple_at_or_after(10, 0);
}
}
실행 예제를 만들려면 다음 내용을 examples/policy.rs로 저장합니다.
use functions_expressions_control_flow::{
backoff_seconds, count_successes, first_multiple_at_or_after, latency_label, retry_delay,
};
fn main() {
println!("retry delay: {}s", retry_delay(false, 2));
println!("latency: {}", latency_label(240));
println!("backoff: {}s", backoff_seconds(5, 3));
println!("successes: {}", count_successes(&[true, false, true]));
println!("next multiple: {}", first_multiple_at_or_after(14, 5));
}
이제 functions-expressions-control-flow 디렉터리에서 다음 명령을 실행합니다. 첫 명령은 설명용 줄바꿈을 표준 형식으로 정리합니다. 두 번째 명령은 그 형식이 유지되는지 검사합니다. 검증 대상은 위에서 조립한 완성 크레이트입니다. 설명을 위해 따로 떼어 놓은 각 코드 조각이 모두 독립 파일이라는 뜻은 아닙니다.
cargo fmt
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet --example policy
테스트 출력에서 핵심 구간만 발췌하면 다음과 같습니다.
running 6 tests
test tests::for_loop_counts_true_values ... ok
test tests::if_expression_selects_each_retry_policy ... ok
test tests::latency_match_covers_ranges_and_fallback ... ok
test tests::loop_break_returns_a_value ... ok
test tests::while_loop_applies_bounded_backoff ... ok
test tests::zero_divisor_is_rejected - should panic ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
실행 예제의 출력은 다음과 같습니다.
retry delay: 5s
latency: slow
backoff: 40s
successes: 2
next multiple: 15
예제 환경은 rustc 1.98.1, cargo 1.98.1, Rust 2024 edition입니다. 예제에는 외부 crate가 없습니다.
7. 간결한 제어 흐름의 한계
표현식 중심 문법은 코드를 짧게 만들지만 짧은 코드가 자동으로 좋은 코드는 아닙니다. 여러 단계의 계산과 부수 효과를 한 match arm에 몰아넣으면 테스트가 어려워집니다. 값을 결정하는 로직은 작은 함수로 분리하고 파일, 네트워크, 화면 출력은 호출부에 남기는 편이 낫습니다.
반복 횟수와 입력 범위도 API 계약에 포함됩니다. 예제의 backoff는 overflow를 포화 처리하지만 first_multiple_at_or_after는 candidate += 1이 무한히 안전하다고 약속하지 않습니다. 실제 서비스라면 허용 범위를 검증하거나 checked_add로 실패를 표현해야 합니다. assert! 역시 잘못된 사용자 입력을 처리하는 일반 오류 모델은 아닙니다. 여기서는 0인 제수를 프로그래머 오류로 명시하기 위해 사용했습니다.
세미콜론을 볼 때는 문장 끝 표시라고만 생각해서는 안 됩니다. 값을 유지할지 버릴지 결정하는 타입의 경계입니다. 다음 편에서는 이 표현식들에 튜플, 배열, 구조 분해 패턴을 붙여 고정 크기 데이터를 다룹니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 03 프로젝트에서 확인할 수 있습니다.
답글 남기기