21.8.2.1.1. `switch-case` 블록 내에서 `current_state` 변이 전, 조건 평가와 진입 액션(Entry Action), 퇴장 액션(Exit Action)을 분리하는 구조체 패턴 구현

21.8.2.1.1. switch-case 블록 내에서 current_state 변이 전, 조건 평가와 진입 액션(Entry Action), 퇴장 액션(Exit Action)을 분리하는 구조체 패턴 구현

훌륭하게 5단계 AppState를 정의했다면, 이제 Run() 함수 안에서 각각의 상태 코어(예: run_state_climb())를 어떻게 쪼아야 할지 고민할 차례다.

초보자들이 FSM을 짤 때 가장 많이 저지르는 실수는 조건 검사(Condition)와 행동(Action)을 한 줄에 뭉뚱그려 짜버리는 이른바 ’스파게티 상태 코드’를 연성하는 것이다.

// [초보자의 끔찍한 FSM 코드 예시]
void run_state_climb() {
    if (altitude >= TARGET_ALT) {
        open_servo();                // 1. 동작 먼저 실행하고
        _current_state = TRIGGER;    // 2. 상태를 천이함
        printf("Dropping!");         // 3. 메시지 출력
    } else {
        close_servo();               // 4. 상승 중에는 계속 서보를 닫고 있음 (강박증)
    }
}

이 코드가 문제인 이유는 **“상태를 천이하는 순간(Transition)”**과 **“해당 상태에 머무는 동안(Do Action)”**을 코드로 분리하지 않았기 때문이다.
결과적으로 상승(CLIMB)하는 5분 내내 50Hz 주기로 close_servo() 함수가 수만 번 호출되며 시스템 자원을 갉아먹는다.

1. 정석적인 FSM 패턴: Entry, Do, Exit 분리

이를 완벽하게 해결하기 위해, 우리는 상태 머신 설계의 대원칙인 모어 머신(Moore Machine) 아키텍처를 차용하여 함수 내부를 세 가지 논리 구역으로 쪼개서(Decoupling) 작성한다.

  1. Entry Action (진입 액션): 이전 상태에서 방금 이 상태로 막 넘어왔을 때, 딱 한 번만 실행되는 초기화 잡업. (예: 서보모터 열기, 타이머 시작)
  2. Do Action (지속 액션 / 조건 평가): 이 상태에 머무는 동안 계속해서 평가해야 하는 탈출 조건(Condition) 검사. (예: 타이머가 3초를 넘었는가?)
  3. Exit Action (퇴장 액션): 조건이 만족되어 다음 상태로 넘어가기 직전에 마무리 짓는 작업.

이를 위해 클래스 멤버 변수로 “내가 방금 막 상태를 바꿨는가?“를 증명하는 부울 플래그(Boolean Flag) 하나를 추가한다.

// 헤더 파일 추가 변수
bool _state_just_entered{true};

2. run_state_trigger() 함수의 정석 구현

가장 복잡한 ‘투하(TRIGGER)’ 상태를 예시로, 이 3박자 패턴을 완벽히 적용해 보자.

void PayloadAutoDrop::run_state_trigger() {

    // -------------------------------------------------------------
    // 1. Entry Action: 방금 CLIMB에서 TRIGGER로 넘어온 찰나(First Tick)
    // -------------------------------------------------------------
    if (_state_just_entered) {
        PX4_INFO("State: TRIGGER - Opening Servo!");
        
        // 서보 밸브를 활짝 연다 (상태에 진입할 때 딱 1번만 실행됨!)
        open_payload_servo(); 
        
        // 타이머 스톱워치를 누른다.
        _drop_start_time = hrt_absolute_time(); 
        
        // 진입 액션을 끝냈으므로 플래그를 내린다.
        _state_just_entered = false; 
    }


    // -------------------------------------------------------------
    // 2. Do Action & Condition: 트리거 상태에 머무는 지속적인 방어 로직
    // -------------------------------------------------------------
    // QGC에서 설정한 'Drop Duration' 파라미터 값(예: 3.5초)을 꺼내온다.
    float drop_duration_s = _param_drop_duration.get();
    
    // 현재 시간과 스톱워치 기록의 차이를 계산한다.
    hrt_abstime now = hrt_absolute_time();
    float elapsed_s = (now - _drop_start_time) / 1e6f;


    // -------------------------------------------------------------
    // 3. Exit Action & Transition: 탈출 조건 만족 시 다음 상태로 패스
    // -------------------------------------------------------------
    if (elapsed_s >= drop_duration_s) {
        
        // 서보 밸브를 다시 닫는다. (퇴장 전 딱 1번 실행)
        close_payload_servo();
        
        // 다음 상태(응답 확인)로 천이한다!
        _current_state = AppState::CONFIRM_ACK;
        
        // [핵심] 다음 상태가 자신의 Entry Action을 실행할 수 있도록 플래그를 다시 올려준다!
        _state_just_entered = true; 
    }
}

이 패턴의 아름다움은 open_payload_servo() 명령이 FSM이 50Hz로 미친 듯이 도는 와중에도 단 1번만 깔끔하게 실행된다는 데 있다.
하드웨어(모터나 통신 버스)에 불필요한 과부하(Spamming)를 주지 않으면서도 로직의 전환은 20ms 이내로 칼같이 이루어진다.

FSM의 뼈대에 근육까지 완벽하게 붙였다.
하지만 여기서 끝이 아니다. 센서는 현실 세계의 물건이기에 항상 거칠고 날카로운 노이즈(Noise)를 수반한다. 고도가 49.9m에서 50.1m로 튀었다(Spike)고 해서 무턱대고 상태를 천이시킨다면 약이 흩날리게 될 것이다.
순수 소프트웨어적으로 센서 노이즈를 짓누르고(필터링) 확고부동한 이벤트 발동(Trigger) 조건을 만들어내는 21.8.3장에서 완벽한 모듈의 마지막 조각을 완성해 보자.