1296.11 비동기 액션 노드 (StatefulActionNode)의 정의
1. StatefulActionNode의 정의
BT::StatefulActionNode는 BehaviorTree.CPP 라이브러리(버전 4.x)에서 제공하는 비동기(asynchronous) 액션 노드의 기반 클래스이다. “비동기“란 행동이 다수의 Tick에 걸쳐 실행되며, 실행 중에 RUNNING 상태를 반환할 수 있음을 의미한다. 단일 Tick 내에서 완료되지 않는 장시간 행동—경로 추종, ROS2 액션 서버 호출, 센서 데이터 수집 대기 등—을 구현하기 위한 노드 유형이다.
StatefulActionNode는 3.x 버전의 AsyncActionNode를 대체하기 위해 4.x에서 도입되었다. 3.x의 AsyncActionNode가 별도 스레드에서 tick()을 실행하는 모델이었다면, StatefulActionNode는 메인 스레드에서 콜백 기반으로 실행되는 비차단(non-blocking) 모델을 채택한다.
2. 클래스 인터페이스
class StatefulActionNode : public ActionNodeBase
{
public:
StatefulActionNode(const std::string& name,
const NodeConfig& config);
// 행동 시작 시 호출 (IDLE → 첫 Tick)
virtual NodeStatus onStart() = 0;
// RUNNING 상태에서 매 Tick마다 호출
virtual NodeStatus onRunning() = 0;
// RUNNING 상태에서 Halt 시 호출
virtual void onHalted() = 0;
// 내부: executeTick()에서 상태에 따라 콜백 분기
NodeStatus executeTick() override final;
// 내부: halt() 처리
void halt() override final;
};
StatefulActionNode의 핵심 설계 특성은 다음과 같다.
2.1 세 가지 콜백 메서드
StatefulActionNode는 행동의 생명주기를 세 가지 순수 가상 콜백으로 분리한다.
-
onStart(): 노드가 IDLE 상태에서 처음 Tick될 때 호출된다. 행동의 초기화를 수행하고, 즉시 완료되면 SUCCESS 또는 FAILURE를, 계속 실행이 필요하면 RUNNING을 반환한다. -
onRunning(): 노드가 RUNNING 상태에 있을 때 매 Tick마다 호출된다. 행동의 진행 상태를 확인하고, 완료되면 SUCCESS 또는 FAILURE를, 계속 실행 중이면 RUNNING을 반환한다. -
onHalted(): 노드가 RUNNING 상태에서 외부에 의해 Halt될 때 호출된다. 진행 중인 행동을 중단하고, 할당된 자원을 정리한다. 반환값이 없다.
2.2 executeTick()의 final 선언
executeTick() 메서드는 final로 선언되어 파생 클래스에서 오버라이드할 수 없다. 이 메서드는 현재 상태에 따라 onStart() 또는 onRunning()을 자동으로 선택하여 호출한다.
NodeStatus StatefulActionNode::executeTick()
{
// 사전 조건 검사
auto pre_result = checkPreConditions();
if (pre_result.has_value())
return pre_result.value();
NodeStatus current_status = status();
if (current_status == NodeStatus::IDLE)
{
// 첫 Tick: onStart() 호출
NodeStatus new_status = onStart();
if (new_status == NodeStatus::IDLE)
{
throw LogicError("onStart() must not return IDLE");
}
setStatus(new_status);
return new_status;
}
else if (current_status == NodeStatus::RUNNING)
{
// 후속 Tick: onRunning() 호출
NodeStatus new_status = onRunning();
if (new_status == NodeStatus::IDLE)
{
throw LogicError("onRunning() must not return IDLE");
}
setStatus(new_status);
return new_status;
}
return current_status;
}
이 구현에서 executeTick()은 상태에 따른 콜백 분기를 자동으로 수행하므로, 파생 클래스의 개발자는 각 콜백의 로직에만 집중할 수 있다.
2.3 halt()의 final 선언
halt() 메서드도 final로 선언되어 있으며, 내부에서 onHalted()를 호출한 후 상태를 IDLE로 설정한다.
void StatefulActionNode::halt()
{
if (status() == NodeStatus::RUNNING)
{
onHalted();
}
setStatus(NodeStatus::IDLE);
}
파생 클래스는 halt()를 직접 오버라이드하지 않고, onHalted() 콜백에서 중단 및 정리 로직을 구현한다.
3. SyncActionNode와의 비교
StatefulActionNode와 SyncActionNode의 근본적 차이를 정리한다.
| 특성 | SyncActionNode | StatefulActionNode |
|---|---|---|
| RUNNING 반환 | 불가 | 가능 |
| 실행 Tick 수 | 1 | 1 이상 |
| 콜백 메서드 | tick() 단일 | onStart(), onRunning(), onHalted() |
| Halt 오버라이드 | 불가 (final) | 불가 (final), onHalted()에서 처리 |
| 실행 스레드 | 메인 스레드 | 메인 스레드 |
| 차단 호출 | 불가 | 불가 |
| 상태 유지 | 선택적 | 필수적 |
| 적합한 행동 | 경량 즉시 완료 작업 | 장시간 비동기 작업 |
양자의 가장 중요한 공통점은 모두 메인 스레드에서 실행된다는 것이다. StatefulActionNode의 “비동기“는 별도 스레드에서의 병렬 실행을 의미하는 것이 아니라, RUNNING 상태를 반환하고 다음 Tick에서 실행을 재개하는 협력적 비동기(cooperative asynchrony) 모델이다. 따라서 onStart()와 onRunning() 내부에서 차단 호출을 수행하면 안 된다.
4. 구현 패턴
4.1 ROS2 액션 클라이언트 래핑
StatefulActionNode의 가장 대표적인 사용 사례는 ROS2 액션 클라이언트를 래핑하는 것이다.
class NavigateToGoal : public BT::StatefulActionNode
{
public:
NavigateToGoal(const std::string& name,
const BT::NodeConfig& config,
rclcpp_action::Client<nav2_msgs::action::NavigateToPose>::SharedPtr client)
: StatefulActionNode(name, config), action_client_(client) {}
static BT::PortsList providedPorts()
{
return {
BT::InputPort<geometry_msgs::msg::PoseStamped>("goal")
};
}
BT::NodeStatus onStart() override
{
geometry_msgs::msg::PoseStamped goal_pose;
if (!getInput("goal", goal_pose))
return BT::NodeStatus::FAILURE;
auto goal_msg = nav2_msgs::action::NavigateToPose::Goal();
goal_msg.pose = goal_pose;
auto send_goal_options =
rclcpp_action::Client<nav2_msgs::action::NavigateToPose>
::SendGoalOptions();
goal_handle_future_ = action_client_->async_send_goal(
goal_msg, send_goal_options);
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus onRunning() override
{
if (!goal_handle_future_.valid())
return BT::NodeStatus::FAILURE;
auto status = goal_handle_future_.wait_for(
std::chrono::milliseconds(0));
if (status == std::future_status::ready)
{
auto goal_handle = goal_handle_future_.get();
if (!goal_handle)
return BT::NodeStatus::FAILURE;
auto result_future =
action_client_->async_get_result(goal_handle);
auto result_status = result_future.wait_for(
std::chrono::milliseconds(0));
if (result_status == std::future_status::ready)
{
auto result = result_future.get();
if (result.code ==
rclcpp_action::ResultCode::SUCCEEDED)
return BT::NodeStatus::SUCCESS;
else
return BT::NodeStatus::FAILURE;
}
}
return BT::NodeStatus::RUNNING;
}
void onHalted() override
{
// 진행 중인 액션 취소
if (goal_handle_future_.valid())
{
auto goal_handle = goal_handle_future_.get();
if (goal_handle)
{
action_client_->async_cancel_goal(goal_handle);
}
}
}
private:
rclcpp_action::Client<nav2_msgs::action::NavigateToPose>::SharedPtr action_client_;
std::shared_future<rclcpp_action::ClientGoalHandle<nav2_msgs::action::NavigateToPose>::SharedPtr> goal_handle_future_;
};
이 패턴에서 onStart()는 ROS2 액션 목표를 전송하고 RUNNING을 반환한다. onRunning()은 비차단 방식으로 액션의 완료 여부를 확인한다. onHalted()는 진행 중인 액션을 취소한다. 이 세 가지 콜백의 역할 분리가 StatefulActionNode의 설계 의도를 정확히 반영한다.
4.2 시간 기반 대기
class WaitForDuration : public BT::StatefulActionNode
{
public:
WaitForDuration(const std::string& name,
const BT::NodeConfig& config)
: StatefulActionNode(name, config) {}
static BT::PortsList providedPorts()
{
return {
BT::InputPort<double>("duration", 1.0, "대기 시간(초)")
};
}
BT::NodeStatus onStart() override
{
double duration;
getInput("duration", duration);
deadline_ = std::chrono::steady_clock::now()
+ std::chrono::duration<double>(duration);
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus onRunning() override
{
if (std::chrono::steady_clock::now() >= deadline_)
return BT::NodeStatus::SUCCESS;
return BT::NodeStatus::RUNNING;
}
void onHalted() override
{
// 특별한 정리 불필요
}
private:
std::chrono::steady_clock::time_point deadline_;
};
onStart()에서 마감 시간을 설정하고, onRunning()에서 현재 시간과 비교하는 비차단 폴링 패턴이다.
5. IDLE 반환의 금지
onStart()와 onRunning() 모두 NodeStatus::IDLE을 반환하는 것이 금지된다. IDLE은 노드의 초기 상태이며 실행 결과가 아니므로, 콜백의 반환값으로 사용할 수 없다. executeTick() 내부에서 IDLE 반환을 검출하면 LogicError 예외가 발생한다.
유효한 반환값의 조합은 다음과 같다.
| 콜백 | SUCCESS | FAILURE | RUNNING | IDLE |
|---|---|---|---|---|
| onStart() | 허용 | 허용 | 허용 | 금지 |
| onRunning() | 허용 | 허용 | 허용 | 금지 |
onStart()가 SUCCESS 또는 FAILURE를 반환하면, 노드는 RUNNING 상태를 거치지 않고 단일 Tick 내에서 완료된다. 이 경우 StatefulActionNode가 SyncActionNode와 동일하게 동작하며, 이는 허용되는 동작이다.
6. StatefulActionNode의 적용 범위
StatefulActionNode가 적합한 행동과 부적합한 행동을 정리한다.
| 적합한 행동 | 부적합한 행동 |
|---|---|
| ROS2 액션 서버 호출 | 블랙보드 값 설정 (경량 작업) |
| 경로 추종 및 내비게이션 | 단순 수학 계산 |
| 타임아웃 대기 | 로그 메시지 출력 |
| 센서 데이터 수집 대기 | 플래그 설정/해제 |
| 매니퓰레이터 동작 실행 | ROS2 토픽 단일 발행 |
| 드론 이착륙 제어 | 파라미터 읽기 |
경량 작업에 StatefulActionNode를 사용하는 것은 금지되지는 않으나, 불필요한 복잡성을 도입하게 된다. onStart()에서 즉시 SUCCESS를 반환하는 StatefulActionNode는 SyncActionNode로 구현하는 것이 적절하다.
7. 설계적 특성
7.1 콜백 분리의 이점
StatefulActionNode의 세 가지 콜백 분리는 다음의 설계적 이점을 제공한다.
-
관심사의 분리(Separation of Concerns): 초기화(
onStart), 진행 확인(onRunning), 중단 처리(onHalted)가 별도의 메서드로 분리되어, 각각의 로직이 독립적으로 이해 가능하고 테스트 가능하다. -
상태 전이의 명시성:
executeTick()이 현재 상태에 따라 콜백을 자동으로 선택하므로, 파생 클래스에서 상태 검사 로직을 직접 구현할 필요가 없다. -
Halt 처리의 보장:
onHalted()가 순수 가상 함수로 선언되어, 파생 클래스에서 반드시 구현하여야 한다. 이는 Halt 처리 누락을 방지한다.
7.2 ROS2 액션과의 구조적 대응
StatefulActionNode의 콜백 구조는 ROS2 액션의 생명주기와 구조적으로 대응된다.
| StatefulActionNode 콜백 | ROS2 액션 생명주기 |
|---|---|
| onStart() | 목표 전송 (send_goal) |
| onRunning() | 피드백 수신 / 결과 폴링 |
| onHalted() | 목표 취소 (cancel_goal) |
이 대응 관계는 StatefulActionNode가 ROS2 기반 로봇 시스템에서 비동기 행동을 구현하기 위한 자연스러운 추상화임을 보여준다. Colledanchise와 Ögren(2018)이 제안한 행동 트리의 형식적 모델에서 비동기 행동 노드의 RUNNING 상태가 “행동이 진행 중이며 아직 완료되지 않았음“을 나타내는 것과 정확히 일치한다.
8. 버전 변천
BehaviorTree.CPP의 비동기 액션 노드 모델은 버전에 따라 변화하였다.
| 버전 | 비동기 액션 클래스 | 실행 모델 |
|---|---|---|
| 3.x | AsyncActionNode | 별도 스레드에서 tick() 실행 |
| 4.x | StatefulActionNode | 메인 스레드에서 콜백 기반 실행 |
| 4.x | ThreadedAction | 3.x 스레드 모델 계승 (제한적 사용) |
3.x의 AsyncActionNode는 tick() 메서드를 별도 스레드에서 실행하고, tick() 내부에서 차단 호출이 가능하였다. 그러나 이 모델은 스레드 안전성 문제, 블랙보드 동시 접근 문제, 자원 정리의 복잡성 등을 수반하였다. 4.x의 StatefulActionNode는 메인 스레드에서의 비차단 콜백 모델로 전환하여 이러한 문제를 해소하였다. Faconti(2022)는 이 전환의 동기로 스레드 안전성 문제의 근본적 해소와 디버깅의 용이성을 제시하였다.