Rust의 열거형과 매치(match)를 이용한 타입 안전한 상태 기계 구축
Min-jun Kim
Dev Intern · Leapcell

소개
소프트웨어 개발에서 복잡한 프로세스를 관리하는 것은 종종 여러 상태를 거치는 전환을 포함합니다. 사용자 인터페이스 흐름부터 네트워크 프로토콜 처리 또는 게임 로직에 이르기까지, 상태 기계는 이러한 순차적 동작을 모델링하는 강력한 패러다임입니다. 그러나 상태 기계를 구현하는 것은 까다로울 수 있으며, 상태 전환이 엄격하게 강제되지 않으면 미묘한 버그가 발생하기 쉽습니다. 전통적인 접근 방식은 플래그나 정수 코드를 사용할 수 있으며, 이는 타입 안전성이 부족하여 오류가 발생하기 쉽습니다. 이때 Rust가 빛을 발합니다. 강력한 타입 시스템, 특히 열거형(enum
)과 match
표현식을 통해 Rust는 상태 기계를 구축하는 우아하고 놀랍도록 타입 안전한 방법을 제공합니다. 이 글에서는 Rust의 고유한 기능이 컴파일 타임 보장을 통해 상태와 전환을 정의하는 방법을 자세히 살펴보고, 상태 기계를 더욱 강력하고 이해하기 쉽게 만들 것입니다.
강력한 상태 기계를 위한 핵심 개념
구현 세부 사항을 자세히 살펴보기 전에, 효과적인 상태 기계를 구축하는 데 필수적인 몇 가지 핵심 개념을 명확히 해보겠습니다. 특히 Rust와 같은 타입 안전 언어에서 그렇습니다.
상태 기계(State Machine): 본질적으로 상태 기계는 계산의 수학적 모델입니다. 제한된 수의 '상태' 중 하나에 언제든지 있는 시스템을 설명합니다. 시스템은 일부 입력 또는 이벤트에 응답하여 한 상태에서 다른 상태로 '전환'될 수 있습니다.
상태(State): 시스템이 주어진 시점에 있을 수 있는 특정 조건 또는 모드입니다. Rust에서는 이러한 상태를 열거형을 사용하여 나타낼 것입니다.
전환(Transition): 한 상태에서 다른 상태로 변경하는 행위입니다. 전환은 일반적으로 이벤트나 조건에 의해 트리거되며 동작과 연관될 수 있습니다. Rust의 match
표현식은 이러한 전환을 완전하게 정의하는 데 완벽하게 적합합니다.
타입 안전성(Type Safety): 타입 오류를 방지하는 프로그래밍 언어 기능입니다. 상태 기계의 맥락에서 타입 안전성은 컴파일러가 유효한 상태 전환만 시도되도록 보장하여 런타임이 아닌 컴파일 타임에 불가능하거나 의도하지 않은 전환을 잡아낼 수 있음을 의미합니다. 이는 버그의 위험을 크게 줄입니다.
열거형(Enum, Enumeration): 유한한 수의 명명된 변형 중 하나가 될 수 있는 타입의 Rust 데이터 타입입니다. 열거형은 모든 가능한 상태를 명시적으로 정의할 수 있게 해주므로 타입 안전한 상태 기계의 핵심입니다. 각 변형은 연관된 데이터를 가질 수도 있어 상태가 현재 조건과 관련된 컨텍스트를 유지할 수 있습니다.
매치 표현식(Match Expression): Rust의 강력한 제어 흐름 구조로, 값을 일련의 패턴과 비교할 수 있게 해줍니다. 이는 완전하며, 명시적으로 _
로 무시되는 경우를 제외하고 모든 가능한 경우를 처리해야 함을 의미합니다. 이러한 완전성은 모든 상태 전환이 올바르게 고려되고 처리되도록 보장하는 데 중요합니다.
타입 안전한 상태 기계 구축
Rust의 열거형과 match
표현식의 시너지는 컴파일 타임 보장을 통해 상태 기계를 구현하는 강력한 메커니즘을 제공합니다. 열거형은 모든 가능한 상태를 정의하고 match
표현식은 모든 상태 전환이 명시적으로 처리되도록 보장합니다. 처리되지 않거나 유효하지 않은 전환 시도는 컴파일 타임 오류를 초래하여 버그의 전체 범주를 방지합니다.
TrafficLight
에 대한 간단한 상태 기계를 생각해 봅시다. 교통등은 Red
, Yellow
, Green
일 수 있습니다. 타이머 또는 외부 이벤트에 따라 전환됩니다.
// 1. 열거형으로 상태 정의 #[derive(Debug, PartialEq)] enum TrafficLightState { Red, Yellow, Green, } // 2. 전환을 트리거할 수 있는 이벤트를 정의 enum TrafficLightEvent { TimerElapsed, EmergencyOverride, } // 3. 상태 기계 로직 구현 struct TrafficLight { current_state: TrafficLightState, } impl TrafficLight { // 교통등을 초기화하기 위한 생성자 fn new() -> Self { TrafficLight { current_state: TrafficLightState::Red, // Red 상태에서 시작 } } // 이벤트를 처리하고 상태를 전환하는 메소드 fn handle_event(&mut self, event: TrafficLightEvent) { // match 표현식이 상태 전환을 완전히 처리합니다. self.current_state = match (&self.current_state, event) { // Red에서: (TrafficLightState::Red, TrafficLightEvent::TimerElapsed) => { println!("Light changed from Red to Green."); TrafficLightState::Green } (TrafficLightState::Red, TrafficLightEvent::EmergencyOverride) => { println!("Emergency override from Red to Red."); // 비상 재정의는 Red를 유지하거나 깜박이거나 Yellow로 갈 수 있습니다. // 이 예제에서는 Red를 유지합니다. TrafficLightState::Red } // Green에서: (TrafficLightState::Green, TrafficLightEvent::TimerElapsed) => { println!("Light changed from Green to Yellow."); TrafficLightState::Yellow } (TrafficLightState::Green, TrafficLightEvent::EmergencyOverride) => { println!("Emergency override from Green to Red."); TrafficLightState::Red } // Yellow에서: (TrafficLightState::Yellow, TrafficLightEvent::TimerElapsed) => { println!("Light changed from Yellow to Red."); TrafficLightState::Red } (TrafficLightState::Yellow, TrafficLightEvent::EmergencyOverride) => { println!("Emergency override from Yellow to Red."); TrafficLightState::Red } }; } // 현재 상태를 얻는 헬퍼 fn get_state(&self) -> &TrafficLightState { &self.current_state } } fn main() { let mut light = TrafficLight::new(); println!("Initial state: {:?}", light.get_state()); // 출력: Initial state: Red light.handle_event(TrafficLightEvent::TimerElapsed); // 출력: Light changed from Red to Green. println!("Current state: {:?}", light.get_state()); // 출력: Current state: Green light.handle_event(TrafficLightEvent::TimerElapsed); // 출력: Light changed from Green to Yellow. println!("Current state: {:?}", light.get_state()); // 출력: Current state: Yellow light.handle_event(TrafficLightEvent::EmergencyOverride); // 출력: Emergency override from Yellow to Red. println!("Current state: {:?}", light.get_state()); // 출력: Current state: Red light.handle_event(TrafficLightEvent::TimerElapsed); // 출력: Light changed from Red to Green. println!("Current state: {:?}", light.get_state()); // 출력: Current state: Green }
이 예제에서:
- 상태 정의:
TrafficLightState
열거형은Red
,Yellow
,Green
의 세 가지 가능한 상태를 명확하게 정의합니다. 이는 타입이 안전한 이유는 다른 임의의 문자열이나 숫자가 상태를 나타낼 수 없기 때문입니다. - 이벤트 정의:
TrafficLightEvent
열거형은 상태 변경을 트리거할 수 있는 작업을 정의합니다. - 상태 기계 구조:
TrafficLight
구조체는current_state
를 보유합니다. - 전환 로직:
handle_event
메소드는(&self.current_state, event)
튜플에 대한match
표현식을 사용합니다. 이를 통해 현재 상태와 들어오는 이벤트 모두를 기반으로 전환을 정의할 수 있습니다.match
의 완전성은 모든 가능한(state, event)
조합이 명시적으로 처리되거나 컴파일 타임 오류를 초래하도록 보장합니다. 특정(state, event)
쌍을 잊어버린다면 Rust 컴파일러가 경고를 제공하여 상태 기계에 대해 비교할 수 없는 수준으로 타입 안전성을 강제합니다.
연관된 데이터를 가진 상태
열거형은 데이터도 가질 수 있으며, 이는 특정 컨텍스트를 유지해야 하는 상태에 매우 유용합니다. 예를 들어, Payment
처리 상태 기계를 생각해 봅시다:
#[derive(Debug, PartialEq)] enum PaymentState { Initiated { transaction_id: String }, Processing { transaction_id: String, merchant_id: String }, Approved { transaction_id: String, amount: f64 }, Declined { transaction_id: String, reason: String }, Refunded { transaction_id: String, original_amount: f64, refunded_amount: f64 }, } enum PaymentEvent { StartPayment(String), ProcessPayment(String, String), // transaction_id, merchant_id ApprovePayment(String, f64), // transaction_id, amount DeclinePayment(String, String), // transaction_id, reason InitiateRefund(String, f64, f64), // transaction_id, original_amount, refunded_amount } struct PaymentProcessor { current_state: PaymentState, } impl PaymentProcessor { fn new(initial_id: String) -> Self { PaymentProcessor { current_state: PaymentState::Initiated { transaction_id: initial_id }, } } fn handle_event(&mut self, event: PaymentEvent) -> Result<(), String> { let next_state = match (&self.current_state, event) { (PaymentState::Initiated { transaction_id }, PaymentEvent::ProcessPayment(event_id, merchant_id)) => { if transaction_id == &event_id { PaymentState::Processing { transaction_id: event_id, merchant_id } } else { return Err(format!("Mismatched transaction ID for processing: expected {}, got {}", transaction_id, event_id)); } } (PaymentState::Processing { transaction_id, .. }, PaymentEvent::ApprovePayment(event_id, amount)) => { if transaction_id == &event_id { PaymentState::Approved { transaction_id: event_id, amount } } else { return Err(format!("Mismatched transaction ID for approval: expected {}, got {}", transaction_id, event_id)); } } (PaymentState::Processing { transaction_id, .. }, PaymentEvent::DeclinePayment(event_id, reason)) => { if transaction_id == &event_id { PaymentState::Declined { transaction_id: event_id, reason } } else { return Err(format!("Mismatched transaction ID for decline: expected {}, got {}", transaction_id, event_id)); } } (PaymentState::Approved { transaction_id, amount: original_amount }, PaymentEvent::InitiateRefund(event_id, _, refunded_amount)) => { if transaction_id == &event_id { PaymentState::Refunded { transaction_id: event_id, original_amount: *original_amount, refunded_amount, } } else { return Err(format!("Mismatched transaction ID for refund: expected {}, got {}", transaction_id, event_id)); } } // 잘못된 전환을 위한 캐치올, 타입 안전성과 명시적 오류 처리를 보장합니다. (current, event) => return Err(format!("Invalid transition: {:?} from {:?}", event, current)), }; self.current_state = next_state; Ok(()) } fn get_state(&self) -> &PaymentState { &self.current_state } } fn main() { let mut processor = PaymentProcessor::new("TX123".to_string()); println!("Initial state: {:?}", processor.get_state()); processor.handle_event(PaymentEvent::ProcessPayment("TX123".to_string(), "MercA".to_string())).unwrap(); println!("Current state: {:?}", processor.get_state()); processor.handle_event(PaymentEvent::ApprovePayment("TX123".to_string(), 99.99)).unwrap(); println!("Current state: {:?}", processor.get_state()); let res_err = processor.handle_event(PaymentEvent::DeclinePayment("TX123".to_string(), "Fraud detected".to_string())); assert!(res_err.is_err()); // 승인된 결제를 직접 거부할 수 없습니다. println!("Attempted invalid transition: {:?}", res_err.unwrap_err()); println!("Current state after invalid attempt: {:?}", processor.get_state()); processor.handle_event(PaymentEvent::InitiateRefund("TX123".to_string(), 99.99, 50.00)).unwrap(); println!("Current state: {:?}", processor.get_state()); }
PaymentProcessor
예제에서는 각 PaymentState
변형이 관련 데이터(transaction_id
, amount
, reason
등)를 보유합니다. 이는 특정 상태에 대해 초기화되지 않거나 관련이 없을 수 있는 별도의 필드를 PaymentProcessor
구조체에 별도의 필드를 사용할 필요성을 제거하여 데이터 무결성을 향상시키고 메모리 사용량을 줄입니다. handle_event
메소드는 이제 잘못된 전환을 정상적으로 처리하기 위해 Result
를 반환하지만, 주된 타입 안전성은 match
표현식의 완전성을 통해 처리되지 않은 상태를 방지합니다.
애플리케이션 시나리오
Rust 열거형과 match
를 사용하는 타입 안전한 상태 기계는 다음과 같은 경우에 이상적입니다.
- 네트워크 프로토콜: 핸드셰이크 또는 연결 수명 주기의 단계를 정의합니다.
- 게임 개발: 캐릭터 상태(유휴, 걷기, 공격), 게임 단계(메뉴, 플레이 중, 게임 오버) 또는 AI 동작을 관리합니다.
- 워크플로우 엔진: 명확하고 강제된 단계와 전환으로 비즈니스 프로세스를 모델링합니다.
- 파서 설계: 입력을 처리하면서 파싱 컨텍스트를 추적합니다.
- UI 구성 요소 상태: UI 요소의 비활성화/활성화, 표시/숨김 또는 다양한 상호 작용 상태를 처리합니다.
장점은 분명합니다. 런타임 오류 감소, 명시적 상태 정의로 인한 코드 가독성 향상, 컴파일러가 올바른 상태 로직을 강제하는 데 도움이 되어 유지보수 용이성 향상입니다.
결론
Rust의 강력한 enum
및 match
구조는 상태 기계를 구축하는 데 매우 타입 안전하고 관용적인 접근 방식을 제공합니다. 상태를 열거형 변형으로 정의하고 완전한 패턴 매칭을 통해 전환을 정의함으로써 개발자는 잘못된 상태 전환과 관련된 버그 범주를 제거하여 강력하고 신뢰할 수 있는 시스템 로직을 달성할 수 있습니다. 이 컴파일 타임 검증은 코드의 정확성에 대한 확신을 높일 뿐만 아니라 복잡한 시스템 동작을 훨씬 더 쉽게 이해하고 유지보수할 수 있게 해줍니다. Rust의 타입 안전한 상태 기계는 애플리케이션의 흐름이 항상 예측 가능하고 올바르도록 보장합니다.