1296.10 동기 액션 노드의 RUNNING 미반환 원칙
1. RUNNING 미반환 원칙의 정의
동기 액션 노드(SyncActionNode)의 RUNNING 미반환 원칙(no-RUNNING-return principle)이란, tick() 메서드가 NodeStatus::RUNNING을 반환하는 것을 설계적으로 금지하고 런타임에서 강제적으로 차단하는 규칙이다. 동기 액션 노드의 tick()은 오직 NodeStatus::SUCCESS 또는 NodeStatus::FAILURE만을 반환할 수 있으며, RUNNING을 반환하면 LogicError 예외가 발생하여 트리의 실행이 중단된다.
이 원칙은 행동 트리의 실행 모델에서 동기 액션 노드와 비동기 액션 노드를 명확히 구분하는 근본적인 설계 계약(design contract)이다.
2. 런타임 강제 메커니즘
BehaviorTree.CPP 라이브러리(버전 4.x)는 SyncActionNode::executeTick() 내부에서 tick()의 반환값을 검사하여 RUNNING 미반환 원칙을 런타임에서 강제한다.
NodeStatus SyncActionNode::executeTick()
{
auto status = ActionNodeBase::executeTick();
if (status == NodeStatus::RUNNING)
{
throw LogicError(
"SyncActionNode::executeTick() must not return RUNNING. "
"Did you forget to use StatefulActionNode?");
}
return status;
}
이 검사는 컴파일 타임이 아닌 런타임에서 수행된다. C++의 타입 시스템으로는 NodeStatus 열거형의 특정 값을 컴파일 타임에서 제한할 수 없으므로, 런타임 검사와 예외 발생이 유일한 강제 수단이다. 예외 메시지에 StatefulActionNode로의 전환을 안내하는 문구가 포함되어 있어, 개발자가 오류의 원인과 해결 방안을 즉시 파악할 수 있도록 설계되어 있다.
3. 원칙의 이론적 근거
3.1 단일 Tick 완료 보장
RUNNING 미반환 원칙의 핵심 근거는 단일 Tick 완료(single-tick completion) 보장이다. 동기 액션 노드가 RUNNING을 반환할 수 없으므로, executeTick() 호출 시 행동이 반드시 완료(SUCCESS 또는 FAILURE)된 상태로 반환된다. 이는 부모 제어 노드가 동일 Tick 내에서 즉시 다음 자식으로 진행할 수 있음을 보장한다.
단일 Tick 내 실행 흐름:
Sequence → SyncAction_A (SUCCESS) → SyncAction_B (SUCCESS) → AsyncAction (RUNNING)
↑
동일 Tick 내에서 여기까지 진행
RUNNING이 허용된다면, Sequence는 SyncAction_A에서 RUNNING을 받고 다음 Tick까지 대기하여야 한다. 이는 경량 작업에 불필요한 Tick 오버헤드를 부과하며, 단일 Tick 내 다수의 경량 작업을 연속 처리하는 동기 액션 노드의 설계 목적에 위배된다.
3.2 Halt 불필요성의 보장
RUNNING을 반환하지 않으므로, 동기 액션 노드는 Halt될 필요가 없다. Halt는 RUNNING 상태에 있는 노드에 대해 실행 중인 행동을 중단하고 자원을 정리하는 메커니즘이다. 동기 액션 노드는 RUNNING 상태에 진입하지 않으므로, Halt 시 특별한 정리가 필요 없다.
class SyncActionNode : public ActionNodeBase
{
// halt()는 final로 선언: 파생 클래스에서 오버라이드 불가
virtual void halt() override final
{
setStatus(NodeStatus::IDLE);
}
};
halt() 메서드가 final로 선언된 것은 RUNNING 미반환 원칙의 직접적인 결과이다. RUNNING 상태가 존재하지 않으므로 Halt 정리 로직이 필요 없고, 따라서 파생 클래스에서 halt()를 오버라이드할 이유도 없다.
3.3 상태 전이 단순화
RUNNING 미반환 원칙은 동기 액션 노드의 상태 전이를 극도로 단순화한다.
RUNNING 허용 시 (비동기): RUNNING 미허용 시 (동기):
IDLE → RUNNING → SUCCESS IDLE → SUCCESS
IDLE → RUNNING → FAILURE IDLE → FAILURE
IDLE → RUNNING → ... → RUNNING
비동기 액션 노드는 IDLE에서 RUNNING을 거쳐 최종 상태에 도달하며, RUNNING 상태에서 임의의 Tick 수만큼 머무를 수 있다. 반면 동기 액션 노드는 IDLE에서 최종 상태로 직접 전이하므로, 상태 공간이 축소되고 상태 전이의 예측 가능성이 확보된다.
4. RUNNING 반환 시도의 일반적 원인
개발자가 동기 액션 노드에서 의도치 않게 RUNNING을 반환하는 일반적 원인을 분석한다.
4.1 기반 클래스의 잘못된 선택
가장 빈번한 원인은 비동기 행동을 SyncActionNode로 구현하는 경우이다.
// 잘못된 구현: SyncActionNode에서 RUNNING 반환 시도
class WaitForDuration : public BT::SyncActionNode
{
public:
WaitForDuration(const std::string& name,
const BT::NodeConfig& config)
: SyncActionNode(name, config) {}
BT::NodeStatus tick() override
{
if (!started_)
{
start_time_ = std::chrono::steady_clock::now();
started_ = true;
return BT::NodeStatus::RUNNING; // LogicError 발생
}
auto elapsed = std::chrono::steady_clock::now() - start_time_;
if (elapsed < std::chrono::seconds(5))
return BT::NodeStatus::RUNNING; // LogicError 발생
started_ = false;
return BT::NodeStatus::SUCCESS;
}
private:
bool started_ = false;
std::chrono::steady_clock::time_point start_time_;
};
이 구현은 시간 대기 행동을 동기 액션 노드로 구현하려는 시도이다. 시간 대기는 다수의 Tick에 걸쳐 진행되는 비동기 행동이므로, StatefulActionNode로 구현하여야 한다.
4.2 올바른 구현으로의 전환
위 예시의 올바른 구현은 다음과 같다.
// 올바른 구현: StatefulActionNode 사용
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", 5.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_;
};
StatefulActionNode에서는 onStart()에서 RUNNING을 반환하여 비동기 실행을 시작하고, onRunning()에서 주기적으로 완료 여부를 확인하며, onHalted()에서 중단 시의 정리를 수행한다.
4.3 차단 호출의 동기적 래핑
동기 액션 노드에서 차단 호출을 수행하고 그 결과를 기다리려는 시도 역시 RUNNING 반환의 원인이 될 수 있다.
// 잘못된 패턴: 차단 호출을 위해 RUNNING 반환 시도
BT::NodeStatus tick() override
{
auto future = service_client_->async_send_request(request);
// future가 완료되지 않으면 RUNNING을 반환하려는 유혹
if (future.wait_for(std::chrono::milliseconds(0))
!= std::future_status::ready)
{
return BT::NodeStatus::RUNNING; // LogicError 발생
}
// ...
}
이 경우의 해결책은 두 가지이다. 첫째, StatefulActionNode로 전환하여 onStart()에서 비동기 요청을 전송하고 onRunning()에서 결과를 폴링한다. 둘째, 짧은 타임아웃 내에 완료가 보장되는 경우에 한하여 동기적으로 대기한다.
5. 컴파일 타임 검출의 한계
RUNNING 미반환 원칙은 현재 런타임에서만 강제된다. C++의 타입 시스템으로 이를 컴파일 타임에서 검출하기 어려운 이유는 다음과 같다.
-
열거형 값 제한 불가:
NodeStatus는SUCCESS,FAILURE,RUNNING,IDLE의 네 가지 값을 가지는 열거형이다. C++에서 함수의 반환값을 열거형의 특정 부분 집합으로 제한하는 메커니즘이 존재하지 않는다. -
조건부 반환 경로:
tick()메서드 내부에서 조건문에 의해 RUNNING이 반환되는 경로가 존재할 수 있으며, 이를 정적 분석으로 완전히 검출하기 어렵다. -
간접 반환: 다른 함수의 반환값을 그대로 반환하는 경우, 해당 함수가 RUNNING을 반환할 수 있는지 컴파일 타임에서 판단할 수 없다.
이러한 한계로 인해 BehaviorTree.CPP는 런타임 검사와 명확한 예외 메시지를 통한 조기 실패(fail-fast) 전략을 채택하고 있다.
6. 정적 분석 도구를 통한 보완
런타임 검출의 한계를 보완하기 위해, 정적 분석 도구를 활용하여 RUNNING 반환을 사전에 검출할 수 있다.
6.1 Clang-Tidy 커스텀 검사
Clang-Tidy의 커스텀 검사(custom check)를 작성하여 SyncActionNode 파생 클래스의 tick() 메서드에서 NodeStatus::RUNNING을 반환하는 코드를 검출할 수 있다. 이는 모든 경로를 완전히 검출하지는 못하나, 명시적인 return NodeStatus::RUNNING 문을 조기에 발견하는 데 유효하다.
6.2 코드 리뷰 지침
정적 분석의 한계를 인적 검증으로 보완하기 위해, 코드 리뷰 시 다음 항목을 확인한다.
SyncActionNode파생 클래스의tick()메서드에서RUNNING을 반환하는 경로가 존재하지 않는지 확인한다.tick()메서드가 다른 함수의 반환값을 그대로 전달하는 경우, 해당 함수가 RUNNING을 반환할 가능성이 없는지 확인한다.- 행동의 특성상 RUNNING 상태가 필요한 경우,
StatefulActionNode로의 전환을 권고한다.
7. RUNNING 미반환 원칙이 설계에 미치는 영향
7.1 행동의 경계 결정
RUNNING 미반환 원칙은 하나의 동기 액션 노드가 수행하는 행동의 경계를 결정한다. 단일 Tick 내에서 완료될 수 있는 작업만 동기 액션 노드의 범위 내에 있으며, 이 범위를 초과하는 행동은 분할하거나 비동기 액션 노드로 전환하여야 한다.
| 행동 | Tick 내 완료 가능 | 적합한 노드 유형 |
|---|---|---|
| 블랙보드 값 설정 | 예 | SyncActionNode |
| 수학적 계산 | 예 | SyncActionNode |
| ROS2 토픽 단일 발행 | 예 | SyncActionNode |
| 로그 메시지 출력 | 예 | SyncActionNode |
| 경로 추종 | 아니오 | StatefulActionNode |
| ROS2 액션 서버 호출 | 아니오 | StatefulActionNode |
| 타임아웃 대기 | 아니오 | StatefulActionNode |
| 센서 데이터 수집 대기 | 아니오 | StatefulActionNode |
7.2 트리 구조에 대한 영향
RUNNING 미반환 원칙은 행동 트리의 구조에도 영향을 미친다. 다수의 동기 액션 노드가 Sequence의 자식으로 배치되면, 모든 동기 액션이 동일 Tick 내에서 순차적으로 실행된다. 이는 설정-실행 패턴(setup-execute pattern)의 자연스러운 구현을 가능하게 한다.
<Sequence>
<!-- 설정 단계: 동기 액션, 동일 Tick 내 실행 -->
<SetNavigationMode mode="cautious"/>
<UpdateCostmapParams inflation_radius="0.8"/>
<LogMessage message="내비게이션 시작"/>
<!-- 실행 단계: 비동기 액션, RUNNING 반환 -->
<NavigateToGoal goal="{target}"/>
</Sequence>
세 개의 설정용 동기 액션이 RUNNING을 반환하지 않으므로, 단일 Tick에서 설정이 완료되고 즉시 비동기 내비게이션 행동으로 진행한다. 설정 단계가 별도의 Tick을 소비하지 않는 것은 RUNNING 미반환 원칙의 직접적인 혜택이다.
7.3 테스트 용이성
RUNNING 미반환 원칙은 동기 액션 노드의 테스트를 단순화한다. 테스트에서 단일 executeTick() 호출의 결과가 즉시 확정되므로, 다중 Tick 시나리오를 고려할 필요가 없다.
TEST(SyncActionTest, ReturnsSuccessOnValidInput)
{
BT::NodeConfig config;
// 포트 설정 ...
MyAction action("test", config);
auto status = action.executeTick();
// 단일 호출로 결과 확정: SUCCESS 또는 FAILURE
EXPECT_EQ(status, BT::NodeStatus::SUCCESS);
}
비동기 액션 노드의 테스트에서는 RUNNING 상태에서의 반복 Tick, Halt 호출, 타임아웃 시나리오 등을 고려하여야 하나, 동기 액션 노드는 이러한 복잡성이 원천적으로 배제된다.
8. RUNNING 미반환 원칙의 학술적 배경
행동 트리 이론에서 노드의 반환 상태 제약은 Colledanchise와 Ögren(2018)의 연구에서 형식적으로 다루어졌다. 이들은 행동 트리의 각 노드 유형에 대한 반환 상태 계약을 정의하고, 이 계약의 준수가 트리 전체의 정확성(correctness)을 보장하는 데 필수적임을 증명하였다. 동기 액션 노드의 RUNNING 미반환은 이러한 형식적 반환 상태 계약의 실질적 구현에 해당한다.
BehaviorTree.CPP 라이브러리의 개발자인 Faconti(2022)는 SyncActionNode의 RUNNING 미반환 강제를 라이브러리의 핵심 안전 장치(safety mechanism)로 명시하고, 이를 통해 개발자가 행동의 시간적 특성에 맞는 기반 클래스를 올바르게 선택하도록 유도한다고 기술하였다.