- Published on
Rust 이터레이터·fold로 상태머신 부작용 제거
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
상태머신(state machine)을 구현하다 보면 코드가 쉽게 ‘부작용 덩어리’가 됩니다. 루프 밖에서 mut state 를 들고 돌리고, 중간에 break 나 continue 로 흐름을 끊고, 플래그(seen_x, is_valid)가 늘어나면서 로직이 스파게티가 되는 식입니다. Rust는 이런 패턴을 강력하게 제어할 수 있지만, 반대로 말하면 설계를 잘못하면 소유권/빌림 규칙 때문에 코드가 더 복잡해지기도 합니다.
이 글은 이터레이터 + fold(또는 try_fold)로 상태머신을 순수 함수처럼 구성해, 부작용을 줄이고 테스트 가능성을 높이는 방법을 정리합니다. 핵심은 “입력 스트림을 왼쪽에서 오른쪽으로 스캔하며 상태를 누적한다”는 관점을 코드로 고정하는 것입니다.
관련해서 Rust 이터레이터로 루프 중복을 제거하는 최적화 관점은 Rust 이터레이터·클로저로 N+1 루프 제거 최적화 글도 함께 보면 맥락이 이어집니다.
왜 상태머신이 부작용에 취약한가
전형적인 문제는 다음 3가지입니다.
- 가변 상태가 루프 외부로 새어 나감:
state가 여러 함수에서 공유되면, 어디서 상태가 바뀌는지 추적이 어렵습니다. - 제어 흐름이 분기 폭발: 이벤트 종류가 늘어날수록
match내부에서 또if가 늘고, 중간 탈출(break)과 예외 처리(Result)가 섞입니다. - 테스트가 어려움: 특정 이벤트 시퀀스를 넣었을 때 상태 전이가 맞는지 확인하려면, 내부 상태를 노출하거나 복잡한 목킹이 필요해집니다.
fold 패턴은 이 문제를 “상태 누적”이라는 하나의 구조로 묶어줍니다. 매 이벤트마다 State -> State 또는 State -> Result<State, E> 변환을 수행하고, 그 변환만 테스트하면 됩니다.
fold로 상태 전이 모델링하기
예제: 간단한 프로토콜 파서(상태머신)
다음은 텍스트 스트림에서 토큰을 읽어 “섹션”을 파싱한다고 가정한 예입니다.
BEGIN name을 만나면 섹션 시작END를 만나면 섹션 종료- 섹션 내부에서는
key=value형태만 허용 - 섹션 밖에서
key=value가 오면 에러
이런 로직을 while 루프와 mut 로 작성하면 금방 복잡해집니다. 대신 이벤트를 먼저 정의합니다.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Event {
Begin(String),
End,
Kv { key: String, value: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ParseError {
UnexpectedEnd,
KvOutsideSection,
NestedBegin,
EndWithoutBegin,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct Acc {
in_section: bool,
section_name: Option<String>,
items: Vec<(String, String)>,
}
이제 핵심은 “이벤트 하나를 받아 누적 상태를 갱신하는 함수”입니다.
fn step(mut acc: Acc, ev: Event) -> Result<Acc, ParseError> {
match ev {
Event::Begin(name) => {
if acc.in_section {
return Err(ParseError::NestedBegin);
}
acc.in_section = true;
acc.section_name = Some(name);
Ok(acc)
}
Event::End => {
if !acc.in_section {
return Err(ParseError::EndWithoutBegin);
}
acc.in_section = false;
Ok(acc)
}
Event::Kv { key, value } => {
if !acc.in_section {
return Err(ParseError::KvOutsideSection);
}
acc.items.push((key, value));
Ok(acc)
}
}
}
여기서 중요한 포인트는 step 이 외부 상태를 건드리지 않고, 오직 입력(acc, ev)으로만 결과를 만든다는 점입니다. 이제 전체 파싱은 try_fold 로 끝납니다.
fn parse(events: impl IntoIterator<Item = Event>) -> Result<Acc, ParseError> {
let acc = events
.into_iter()
.try_fold(Acc::default(), step)?;
if acc.in_section {
return Err(ParseError::UnexpectedEnd);
}
Ok(acc)
}
try_fold는 중간에Err가 나오면 즉시 종료합니다.- 루프 중간
break를 직접 쓰지 않아도 됩니다. - 상태 전이 규칙은
step하나로 모입니다.
테스트가 쉬워지는 이유
상태머신 테스트는 “입력 시퀀스 → 최종 상태/에러”만 보면 됩니다.
#[test]
fn kv_outside_section_is_error() {
let events = vec![Event::Kv { key: "a".into(), value: "1".into() }];
assert_eq!(parse(events), Err(ParseError::KvOutsideSection));
}
#[test]
fn parse_section_items() {
let events = vec![
Event::Begin("s".into()),
Event::Kv { key: "a".into(), value: "1".into() },
Event::Kv { key: "b".into(), value: "2".into() },
Event::End,
];
let acc = parse(events).unwrap();
assert_eq!(acc.section_name, Some("s".into()));
assert_eq!(acc.items.len(), 2);
}
내부에서 어떤 mut 가 있었는지, 루프가 어디서 continue 했는지 같은 구현 디테일에 덜 의존하게 됩니다.
fold 패턴을 더 “상태머신답게” 만드는 팁
1) 상태를 enum으로 분리해 불가능한 상태를 제거
불리언(in_section)은 상태 조합을 늘립니다. Rust에서는 상태를 enum 으로 만들면 “불가능한 상태”를 타입 레벨에서 없앨 수 있습니다.
#[derive(Debug, Clone, PartialEq, Eq)]
enum State {
Outside,
Inside { name: String, items: Vec<(String, String)> },
}
fn step_state(state: State, ev: Event) -> Result<State, ParseError> {
match (state, ev) {
(State::Outside, Event::Begin(name)) => Ok(State::Inside { name, items: vec![] }),
(State::Outside, Event::End) => Err(ParseError::EndWithoutBegin),
(State::Outside, Event::Kv { .. }) => Err(ParseError::KvOutsideSection),
(State::Inside { .. }, Event::Begin(_)) => Err(ParseError::NestedBegin),
(State::Inside { name, items }, Event::Kv { key, value }) => {
let mut items = items;
items.push((key, value));
Ok(State::Inside { name, items })
}
(State::Inside { name, items }, Event::End) => {
// 섹션 종료 시 Outside로 전이하되, 결과를 별도로 리턴하고 싶다면
// Acc에 completed를 넣거나, 별도 Output 구조를 설계합니다.
let _ = (name, items);
Ok(State::Outside)
}
}
}
fn parse_state(events: impl IntoIterator<Item = Event>) -> Result<State, ParseError> {
events.into_iter().try_fold(State::Outside, step_state)
}
이 패턴의 장점은 “in_section=true 인데 section_name=None” 같은 어정쩡한 상태를 원천 차단한다는 점입니다.
2) 출력이 필요하면 누적기(Acc)에 Vec 로 수집
현실의 상태머신은 최종 상태만 필요한 게 아니라, 중간 산출물(토큰, 경고, 메트릭)이 필요합니다. 이때도 fold 가 유용합니다.
#[derive(Debug, Default)]
struct Acc2 {
state: State,
warnings: Vec<String>,
}
fn step_with_warnings(mut acc: Acc2, ev: Event) -> Result<Acc2, ParseError> {
// 예: Outside에서 End를 만나면 에러 대신 경고로 처리하고 계속 진행
match (&acc.state, &ev) {
(State::Outside, Event::End) => {
acc.warnings.push("END without BEGIN ignored".into());
return Ok(acc);
}
_ => {}
}
acc.state = step_state(acc.state, ev)?;
Ok(acc)
}
fn parse_with_warnings(events: impl IntoIterator<Item = Event>) -> Result<Acc2, ParseError> {
events.into_iter().try_fold(Acc2::default(), step_with_warnings)
}
“에러는 즉시 중단, 경고는 누적” 같은 정책을 한 곳에 모을 수 있습니다.
3) 성능: 불필요한 clone을 피하고, 소유권 이동을 의도적으로 설계
fold 를 쓰면 매 스텝마다 Acc 를 이동(move)합니다. Rust에서는 이동이 곧 복사가 아니기 때문에 대개 비용이 크지 않지만, 내부에 큰 Vec 가 있고 매번 clone 을 해버리면 손해가 커집니다.
step(mut acc, ev)처럼 누적기를 값으로 받고 내부에서mut로 수정한 뒤 반환하면,Vec는 재할당 없이 그대로 유지됩니다.- 상태를
enum으로 만들었을 때도Vec를 새로 만들지 말고, 기존Vec를 꺼내push후 다시 넣는 방식(위 예제처럼)을 쓰면 됩니다.
또한 입력이 참조로 올 수 있다면 Event 를 EventRef 로 바꾸고, 누적기는 필요한 값만 소유하도록 설계할 수도 있습니다.
enum EventRef<'a> {
Begin(&'a str),
End,
Kv { key: &'a str, value: &'a str },
}
다만 이 경우 라이프타임이 전파되므로, “최종 결과가 입력을 참조해도 되는가”를 먼저 결정해야 합니다.
fold vs for-loop: 무엇이 더 좋은가
for 루프가 항상 나쁜 건 아닙니다. 다만 다음 조건이면 fold 가 특히 유리합니다.
- 상태 전이 규칙을 순수 함수로 고정하고 싶다
- 중간 에러에서 즉시 중단하려고
Result를 쓰고 있다 (try_fold가 자연스럽다) - 테스트에서 “이벤트 시퀀스 → 결과”를 간단히 검증하고 싶다
- 로직이 커져서 “상태, 출력, 경고/메트릭”을 구조적으로 나누고 싶다
반대로, 루프 내부에서 여러 컬렉션을 동시에 갱신하거나, 인덱스 기반 랜덤 액세스가 핵심인 알고리즘은 fold 가 오히려 가독성을 해칠 수 있습니다. 중요한 건 “상태머신을 스트림 스캔으로 표현할 수 있는가”입니다.
실전 패턴: 이벤트 생성과 상태 전이를 분리
상태머신이 복잡해질수록 이벤트 생성(lexing) 과 상태 전이(parsing) 를 분리하는 게 유지보수에 도움이 됩니다.
- 1단계: 원시 입력을
Iterator로 토큰/이벤트로 변환 (map,filter_map) - 2단계: 이벤트 스트림을
try_fold로 축약
이 구조는 Kotlin의 Sequence 나 Flow 로 스트림 중복 연산을 제거하는 방식과도 유사합니다. 관심 있다면 Kotlin Flow+Sequence로 스트림 중복 연산 제거 도 참고할 만합니다.
간단한 예로, 문자열 라인을 이벤트로 바꾸는 레이어를 붙여보겠습니다.
fn to_event(line: &str) -> Option<Event> {
let line = line.trim();
if line.is_empty() {
return None;
}
if let Some(rest) = line.strip_prefix("BEGIN ") {
return Some(Event::Begin(rest.to_string()));
}
if line == "END" {
return Some(Event::End);
}
if let Some((k, v)) = line.split_once('=') {
return Some(Event::Kv { key: k.trim().into(), value: v.trim().into() });
}
// 알 수 없는 라인은 여기서 버리거나, 별도 Error 이벤트로 바꿀 수도 있습니다.
None
}
fn parse_lines(input: &str) -> Result<Acc, ParseError> {
let events = input.lines().filter_map(to_event);
parse(events)
}
이렇게 하면 “상태 전이 로직”은 입력 포맷 변화와 분리됩니다. 운영 환경에서 포맷이 조금 바뀌어도 to_event 만 고치면 되고, step 은 그대로 재사용할 수 있습니다.
디버깅과 관측성: inspect로 부작용을 ‘통제된 곳’에만 두기
부작용을 완전히 없애기 어렵다면, 최소한 위치를 통제해야 합니다. 이터레이터 체인에서는 inspect 로 로깅을 끼워 넣을 수 있습니다.
fn parse_with_trace(events: impl IntoIterator<Item = Event>) -> Result<Acc, ParseError> {
events
.into_iter()
.inspect(|ev| {
// 로깅은 여기서만
eprintln!("event: {:?}", ev);
})
.try_fold(Acc::default(), step)
}
이 방식은 “상태 전이 함수는 순수하게 유지하고, 관측(로그/메트릭)은 파이프라인 외곽에서만 수행”하게 만들어줍니다.
정리
- 상태머신의 부작용은 보통
mut state공유, 분기 폭발, 중간 탈출로 커집니다. - Rust에서는 이터레이터와
fold/try_fold로 이벤트 스트림을 상태로 축약하는 구조를 만들면, 상태 전이 규칙을 한 곳에 모으고 테스트를 단순화할 수 있습니다. - 불리언 플래그 대신
enum State로 불가능한 상태를 제거하면, 로직과 타입이 서로를 검증하는 형태가 됩니다. - 출력/경고/메트릭은
Acc에 누적하고, 로깅 같은 부작용은inspect같은 외곽에서 통제하는 게 좋습니다.
상태머신을 “루프와 플래그”가 아니라 “스트림 축약”으로 바라보면, Rust의 소유권 모델이 오히려 설계를 깔끔하게 강제해주는 편이 됩니다. 다음에 상태 전이가 늘어나기 시작하면, step 을 순수 함수로 고정하고 try_fold 로 감싸는 구조부터 적용해보세요.