Tech Wiki

TOPICSSERIES

[Rust 실전 로드맵 13] Rust enum·Option·Result·패턴 매칭으로 상태 표현하기

엔드포인트 검사 결과를 is_up: boolstatus_code: 0으로 저장하면 코드는 짧습니다. 대신 해석할 일이 늘어납니다. 아직 검사하지 않은 상태는 false일까요? DNS 실패와 타임아웃은 어떤 숫자로 구분할까요? 마지막 성공 시각이 없다는 뜻도 0인지, 실제 Unix timestamp 0인지 모호합니다. 필드 조합에 따라 존재해서는 안 되는 상태도 만들어집니다.

Rust에서는 가능한 상태를 enum variant로 나눕니다. 값이 없을 수 있으면 Option<T>, 작업이 실패할 수 있으면 Result<T, E>로 표현할 수 있습니다. match는 가능한 경우를 빠짐없이 처리하는지 컴파일러가 확인합니다. 이 글은 이 타입들을 엔드포인트 검사 모델 하나에 연결합니다.

1. 완성 예제

독립 실행 가능한 Rust 2024 크레이트는 examples/article-13-domain-states에 있습니다. 외부 크레이트나 공유 endpoint-monitor에 의존하지 않습니다.

[package]
name = "article-13-domain-states"
version = "0.1.0"
edition = "2024"
publish = false

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
all = "warn"
pedantic = "warn"

프로젝트 디렉터리에서 다음 명령을 실행하면 됩니다.

cd examples/article-13-domain-states
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

2. 불리언과 특수값의 정보 손실

검사 상태를 평평한 구조체로 만들면 대개 이런 모습이 됩니다.

struct LooseCheckState {
    is_up: bool,
    status_code: u16,
    error_code: i32,
    last_success_at: u64,
}

이 타입은 is_up == true인데 error_code != 0인 값도 허용합니다. status_code == 0이 HTTP 응답 없음이라는 규칙, error_code == -2가 DNS 실패라는 규칙도 타입 밖의 약속입니다. 새 오류를 추가할 때 생산자와 소비자가 같은 특수값 표를 기억해야 합니다.

불리언이 항상 나쁜 것은 아닙니다. 독립적인 예/아니요 속성에는 잘 맞습니다. 문제는 하나의 값이 Pending, Healthy, Unhealthy 중 정확히 하나여야 하는데 여러 필드의 조합으로 그 선택을 흉내 낼 때 생깁니다.

3. variant가 담는 상태와 데이터

CheckState는 세 상태를 직접 열거합니다. 각 variant는 그 상태에서만 유효한 데이터를 가집니다.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckFailure {
    Dns,
    Timeout { limit_ms: u64 },
    HttpStatus { status_code: u16 },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckState {
    Pending,
    Healthy {
        status_code: u16,
        latency_ms: u64,
    },
    Unhealthy {
        failure: CheckFailure,
        last_success_at: Option<u64>,
    },
}

Pending에는 HTTP 상태 코드가 없습니다. Healthy에는 성공 응답 코드와 지연 시간이 있고 실패 사유는 없습니다. UnhealthyCheckFailure를 반드시 가지며 마지막 성공 시각은 없을 수도 있습니다. HealthyUnhealthy를 동시에 만드는 방법 자체가 사라집니다.

중첩 enum도 역할이 있습니다. CheckState는 검사 생명주기의 상태를 나타내고 CheckFailure는 실패 원인을 나타냅니다. 두 개를 하나의 거대한 enum으로 합치면 상태 전이와 오류 분류가 섞입니다. 반대로 실제로 같은 방식으로 처리할 실패까지 지나치게 세분하면 match arm만 늘어납니다. variant 경계는 소비자가 다르게 처리해야 하는 정보에 맞춰 잡는 편이 낫습니다.

4. Option: 부재, Result: 성공과 실패

Option<T>Result<T, E>는 모두 표준 라이브러리 enum이지만 질문이 다릅니다.

  • Option<u64>는 마지막 성공 시각이 Some(timestamp)이거나 None이라고 말합니다. None은 오류 코드가 아니라 값의 부재입니다.
  • Result<HttpObservation, TransportError>는 이번 전송 작업이 Ok(observation)으로 끝났거나 Err(error)로 실패했다고 말합니다.
  • CheckState는 그 결과를 도메인에서 오래 보관할 상태로 바꿉니다.

검사 결과를 상태로 분류하는 함수는 세 타입의 경계를 보여줍니다.

#[must_use]
pub fn classify_check(
    observation: Result<HttpObservation, TransportError>,
    last_success_at: Option<u64>,
) -> CheckState {
    match observation {
        Ok(HttpObservation {
            status_code,
            latency_ms,
        }) if (200..=299).contains(&status_code) => CheckState::Healthy {
            status_code,
            latency_ms,
        },
        Ok(HttpObservation { status_code, .. }) => CheckState::Unhealthy {
            failure: CheckFailure::HttpStatus { status_code },
            last_success_at,
        },
        Err(TransportError::Dns) => CheckState::Unhealthy {
            failure: CheckFailure::Dns,
            last_success_at,
        },
        Err(TransportError::Timeout { limit_ms }) => CheckState::Unhealthy {
            failure: CheckFailure::Timeout { limit_ms },
            last_success_at,
        },
    }
}

Ok가 언제나 건강 상태를 뜻하지는 않습니다. 전송에는 성공했어도 HTTP 503을 받으면 도메인 상태는 Unhealthy입니다. 그래서 전송 계층의 TransportError와 모니터링 도메인의 CheckFailure를 구분했습니다. 타임아웃 제한과 HTTP 상태 코드도 variant 안에 남아 있어 로그나 재시도 정책이 숫자 표를 역으로 해석할 필요가 없습니다.

첫 arm에는 match guard가 있습니다. 패턴이 HttpObservation의 필드를 꺼낸 뒤 guard가 200부터 299까지인지 확인합니다. guard가 거짓이면 다음 Ok arm으로 넘어갑니다. guard만으로 전체 Ok 범위를 처리했다고 간주되지 않으므로 뒤의 arm이 나머지 응답 코드를 받습니다.

5. 빠짐없는 match와 변경 지점

match는 첫 번째로 맞는 패턴의 arm을 실행하며 모든 가능한 값을 다뤄야 합니다. 아래 컴파일 실패 예제는 Unhealthy arm을 일부러 빠뜨렸습니다.

#[derive(Debug)]
enum CheckState {
    Pending,
    Healthy,
    Unhealthy,
}

fn label(state: CheckState) -> &'static str {
    match state {
        CheckState::Pending => "pending",
        CheckState::Healthy => "healthy",
    }
}

fn main() {}

rustc 1.98.1은 E0004로 누락된 variant를 지정합니다.

error[E0004]: non-exhaustive patterns: `CheckState::Unhealthy` not covered
  --> tests/ui/non_exhaustive_match.rs:9:11
   |
 9 |     match state {
   |           ^^^^^ pattern `CheckState::Unhealthy` not covered
   |
note: `CheckState` defined here
  --> tests/ui/non_exhaustive_match.rs:2:6
   |
 2 | enum CheckState {
   |      ^^^^^^^^^^
...
 5 |     Unhealthy,
   |     --------- not covered
   = note: the matched value is of type `CheckState`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
11 ~         CheckState::Healthy => "healthy",
12 ~         CheckState::Unhealthy => todo!(),
   |

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0004`.

이 검사는 enum에 variant를 추가할 때 특히 유용합니다. 구체적인 variant를 나열한 match는 새 경우를 처리해야 할 위치에서 컴파일 오류를 냅니다. _ wildcard는 앞으로 추가될 variant까지 한꺼번에 받으므로, 모든 상태를 의도적으로 검토해야 하는 핵심 도메인 로직에서는 그 신호를 숨길 수 있습니다. 반면 알 수 없는 입력을 같은 방식으로 무시하는 UI 필터처럼 나머지 경우가 정말 동일하다면 wildcard가 맞을 수 있습니다.

6. 전체 분기는 match, 한 경우는 if let

상태를 사람이 읽을 문자열로 바꾸는 함수는 세 variant 모두 결과가 필요하므로 match가 어울립니다. Unhealthy 안에서는 let...else로 마지막 성공 시각이 없는 경로를 먼저 끝냅니다.

#[must_use]
pub fn describe_state(state: &CheckState) -> String {
    match state {
        CheckState::Pending => "pending: no check has run".to_owned(),
        CheckState::Healthy {
            status_code,
            latency_ms,
        } => format!("healthy: HTTP {status_code} in {latency_ms} ms"),
        CheckState::Unhealthy {
            failure,
            last_success_at,
        } => {
            let Some(timestamp) = last_success_at else {
                return format!("unhealthy: {failure}; never succeeded");
            };
            format!("unhealthy: {failure}; last success at {timestamp}")
        }
    }
}

let Some(timestamp) = last_success_at else { ... };else 블록은 반환처럼 흐름을 벗어나야 합니다. 그 뒤에서는 timestamp를 평범한 u64로 쓸 수 있습니다. 성공 패턴을 본문에 남기고 실패 경로를 일찍 끝내고 싶을 때 읽기 좋습니다.

성공 상태만 기록하고 나머지는 아무 일도 하지 않는다면 if let이 더 짧습니다.

pub fn record(&mut self, state: &CheckState) {
    if let CheckState::Healthy {
        status_code,
        latency_ms,
    } = state
    {
        self.entries.push((*status_code, *latency_ms));
    }
}

if let은 한 패턴을 간결하게 처리하는 대신 match의 빠짐없는 검사를 요구하지 않습니다. 따라서 모든 variant가 서로 다른 정책을 가져야 하는 분류 함수에 쓰면 새 상태를 조용히 무시할 수 있습니다. 구문 길이보다 누락을 잡아야 하는지가 선택 기준입니다.

7. unwrap 없이 타입 처리하기

OptionResult를 도입한 뒤 곧바로 unwrap()을 호출하면 부재와 실패를 타입에 넣은 이점을 호출 지점에서 버리게 됩니다. 불변식 때문에 실패가 불가능하다고 증명된 좁은 내부 코드도 있을 수 있지만 엔드포인트 입력과 네트워크 결과는 보통 그런 조건이 아닙니다.

이 예제의 프로덕션 경로는 match, if let, let...else로 각 경우를 처리합니다. 라이브러리 사용자가 오류를 결정해야 한다면 Result를 반환하되 현재 함수가 문맥을 더해 상위 호출자에게 넘길 수 있다면 ? 연산자가 맞을 수 있습니다. 복구 정책이 있는 지점에서는 variant별로 직접 분기합니다. 하나의 문법을 모든 위치에 강제할 이유는 없습니다.

8. enum 검증

Rust 2024 에디션, stable rustc 1.98.1, Cargo 1.98.1에서 포맷, 전체 target 검사, 경고를 오류로 처리한 Clippy, 테스트, 실행이 모두 통과해야 합니다. 테스트 모음에는 단위 테스트 5개와 E0004 진단 고정 테스트 1개가 있습니다. 컴파일러 문구는 도구 체인 버전에 따라 바뀔 수 있어 예제가 stderr 바이트를 비교합니다.

cargo run --quiet의 표준 출력은 다음과 같습니다.

healthy: HTTP 204 in 37 ms
unhealthy: HTTP status 503; last success at 1700000000
unhealthy: timed out after 800 ms; never succeeded
recorded success: HTTP 204 in 37 ms

이 모델에는 is_up과 오류 특수값이 없습니다. 유효한 상태는 variant로 제한됩니다. 선택 데이터는 Option, 실패 가능한 작업은 Result로 구분됩니다. 새 상태를 추가하면 전체 분기를 맡은 match가 검토할 코드를 가리킵니다.

전체 소스 코드

이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 13 프로젝트에서 확인할 수 있습니다.

출처


1개 응답

  1. […] 다음 글Rust enum·Option·Result·패턴 매칭으로 상태 표현하기 […]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Tech Wiki

Built with WordPress · Learn in public.