제8부 항법
8.6 행동 트리와 BT Navigator
8.6.1 이 장에서 배우는 것 / 선수 지식
Nav2의 서버들(8.1)은 각자 한 가지 일만 한다. 누가 언제 무엇을 호출할지 결정하는 것이 bt_navigator이며, 그 결정 로직이 행동 트리(Behavior Tree, BT) 로 기술된다.
이것이 Nav2를 이해하는 마지막 조각이다. "왜 로봇이 갑자기 제자리에서 회전하는지", "왜 실패했는데 다시 시도하지", "목표를 어떻게 바꾸지"의 답이 전부 BT에 있다.
그리고 BT는 XML 파일이므로 코드를 고치지 않고 행동을 바꿀 수 있다. 이것이 Nav2의 큰 유연성이자, 동시에 "어디를 봐야 할지 모르겠다"의 원인이기도 하다.
배우는 것:
- 왜 상태 기계가 아니라 BT인가 — 설계 근거.
- BT 기본 문법 — 노드 종류와 반환값.
- Nav2 기본 트리 해부 — 실제로 무엇이 일어나는가.
- 복구 구조 — 실패가 어떻게 처리되는가.
- 블랙보드 — 노드 간 데이터 공유.
- 커스텀 노드 — 자기 로직 넣기.
- 시각화와 디버깅 — Groot·로그.
- 등록 노드 실측(8.6.12) — Nav2가 BT.CPP에 더한 제어·데코레이터 노드와 오류 코드 규약.
선수 지식: 8.1(Nav2 구조·액션), 1.8(액션 상태기계), 8.5(플래너·컨트롤러), 4.2.6(pluginlib).
정직: BT 노드 이름·기본 트리 구성은 배포판마다 변경된다. 아래는 구조와 개념에 집중하며, 정확한 노드 목록과 기본 XML은
nav2_behavior_tree패키지가 정본이다. Groot의 버전 호환성도 확인이 필요하다(확인 필요).실행 확인(2026-08-16): Nav2 1.3.12를 설치해 이 장이 쓰는 BT 노드 이름을 실물 기본 트리와 대조했다 —
RecoveryNode·PipelineSequence·RateController·ComputePathToPose·FollowPath·ReactiveFallback·ReactiveSequence·RoundRobin·GoalUpdated·ClearEntireCostmap·Spin·Wait·BackUp전부 일치한다. 다만 루트 태그가 달랐다 — 아래 함정.
함정 —
<root>에BTCPP_format="4"가 필요하다(실행 확인). 이 책은 처음에<root main_tree_to_execute="MainTree">로 적고 있었으나, Jazzy가 싣는 BehaviorTree.CPP는 4.9.0이고 Nav2의 기본 트리는 전부<root BTCPP_format="4" main_tree_to_execute="MainTree">형태다(정정함). 이 속성이 없으면 BT.CPP 4는 그 파일을 구(v3) 포맷으로 간주한다 — 파싱 방식과 허용 문법이 달라지므로, 커스텀 트리를 새로 쓸 때는 반드시 넣으라. ROS 1 시절이나 BT.CPP 3 기준 자료를 복사할 때 걸리는 지점이다.
8.6.2 왜 상태 기계가 아닌가
전통적으로 로봇 행동은 유한 상태 기계(FSM)로 기술했다. BT가 그것을 대체한 이유가 있다.
| 축 | FSM | 행동 트리 |
|---|---|---|
| 상태 추가 | 전이가 $O(n^2)$로 증가 | 노드 하나 추가 |
| 재사용 | 상태가 문맥에 묶임 | 서브트리로 재사용 |
| 반응성 | 전이를 명시해야 | 매 틱마다 재평가 |
| 가독성 | 전이가 많으면 난독 | 계층 구조 |
| 수정 | 여러 전이를 손봄 | 국소적 |
핵심 차이는 "매 틱마다 트리 전체를 다시 평가한다"는 것이다.
FSM에서 "주행 중 배터리가 낮아지면 충전소로"를 구현하려면 모든 주행 상태에서 배터리 전이를 추가해야 한다. BT에서는 트리 상단에 조건 하나를 두면 모든 하위 행동에 자동 적용된다.
견해: BT의 실제 가치는 "반응성이 구조로 표현된다"는 것이다. 우선순위가 높은 조건을 위에 두면, 매 틱마다 먼저 검사되어 필요할 때 즉시 개입한다. 이것을 FSM으로 하려면 전이를 직접 다 그려야 한다.
8.6.3 BT 기본 문법
모든 노드는 세 값 중 하나를 반환한다.
| 반환 | 의미 |
|---|---|
SUCCESS | 성공 |
FAILURE | 실패 |
RUNNING | 진행 중(다음 틱에 다시 호출) |
RUNNING이 BT의 핵심이다. 오래 걸리는 동작이 블로킹하지 않고, 매 틱마다 제어가 루트로 돌아간다.
노드 종류:
| 종류 | 역할 | 대표 |
|---|---|---|
| 제어(Control) | 자식 실행 순서·조건 | Sequence·Fallback |
| 장식(Decorator) | 자식 하나를 감싸 변형 | Inverter·RateController·RetryUntilSuccessful |
| 조건(Condition) | 참·거짓 판정 | IsBatteryLow·GoalUpdated |
| 동작(Action) | 실제 수행 | ComputePathToPose·FollowPath |
두 가지 핵심 제어 노드:
| 노드 | 동작 |
|---|---|
| Sequence | 자식을 순서대로. 하나라도 실패하면 전체 실패 |
| Fallback | 자식을 순서대로. 하나라도 성공하면 전체 성공 |
| ReactiveSequence | Sequence + 매 틱마다 앞 자식 재검사 |
| ReactiveFallback | Fallback + 동일 |
| PipelineSequence | Nav2 고유 — 앞 자식이 RUNNING이어도 다음으로 진행. nav2_tree_nodes.xml에 <Control ID="PipelineSequence">로 등록(실행 확인) |
| RoundRobin | 자식을 번갈아 |
Reactive 계열이 중요하다. 일반 Sequence는 이미 성공한 자식을 다시 검사하지 않지만, Reactive는 매 틱마다 처음부터 검사한다 — 조건이 바뀌면 즉시 반응한다.
함정: Sequence와 ReactiveSequence를 혼동하면 반응성이 사라진다. "배터리가 낮으면 중단"을 일반 Sequence에 두면 한 번 통과한 뒤 다시 검사하지 않는다.
XML 형식:
<root BTCPP_format="4" main_tree_to_execute="MainTree">
<BehaviorTree ID="MainTree">
<RecoveryNode number_of_retries="6" name="NavigateRecovery">
<PipelineSequence name="NavigateWithReplanning">
<RateController hz="1.0">
<ComputePathToPose goal="{goal}" path="{path}"
planner_id="GridBased"/>
</RateController>
<FollowPath path="{path}" controller_id="FollowPath"/>
</PipelineSequence>
<ReactiveFallback name="RecoveryFallback">
<GoalUpdated/>
<RoundRobin name="RecoveryActions">
<ClearEntireCostmap name="ClearLocalCostmap"
service_name="local_costmap/clear_entirely_local_costmap"/>
<Spin spin_dist="1.57"/>
<Wait wait_duration="5"/>
<BackUp backup_dist="0.3" backup_speed="0.05"/>
</RoundRobin>
</ReactiveFallback>
</RecoveryNode>
</BehaviorTree>
</root>
8.6.4 기본 트리 해부
위 XML을 한 줄씩 읽으면 Nav2의 동작이 보인다.
동작 흐름:
| 상황 | 트리의 반응 |
|---|---|
| 정상 | PipelineSequence가 1 Hz로 재계획하며 주행 |
| 계획 실패 | RecoveryNode가 복구 분기로 |
| 복구 후 | 다시 주 항법 시도(최대 6회) |
| 목표 변경 | GoalUpdated가 성공 → 복구 중단하고 재계획 |
| 6회 실패 | 전체 실패 → 액션 ABORTED(1.8.3) |
핵심 구조 셋:
① RecoveryNode — 첫 자식이 실패하면 둘째 자식(복구)을 실행하고 다시 시도한다. 지정 횟수까지.
② PipelineSequence — 계획과 추종을 함께 실행한다. 계획이 RUNNING이어도 추종이 진행되므로, 주행하면서 재계획할 수 있다.
③ RateController — 자식을 지정 주기로만 실행한다. 재계획을 1 Hz로 제한해 계산을 절약한다(8.1.3).
견해:
RateController hz="1.0"이 전역 재계획 주기다. 동적 환경에서는 높이고(2~5 Hz), 정적 환경에서는 낮춘다. 플래너 계산 시간을 측정해 주기 안에 들어오는지 확인해야 한다 — 안 들어오면 계획이 계속 지연된다.
함정:
number_of_retries가 크면 로봇이 오래 수렴하지 못한다. 6회 재시도에 각각 복구 동작까지 하면 수십 초가 걸린다. 안전이 중요한 환경에서는 줄이고 사람에게 알리는 편이 나을 수 있다(8.7).
8.6.5 복구 구조
RoundRobin이 복구 동작을 순환시킨다.
점진적 강도(escalation)가 설계 의도다 — 간단한 조치부터 시도한다.
| 복구 동작 | 해결하는 문제 |
|---|---|
| 코스트맵 초기화 | 유령 장애물(8.4.7) |
| 제자리 회전 | 센서 사각지대·측위 개선(8.3.7) |
| 대기 | 사람·동적 장애물이 지나가길 |
| 후진 | 막다른 곳·너무 가까이 접근 |
견해: 복구 동작의 순서와 구성을 환경에 맞춰 조정하라. 사람이 많은 환경에서는 "대기"를 앞에 두는 것이 자연스럽고(사람이 비켜 준다), 좁은 통로에서는 "후진"이 효과적이다. 기본 트리가 모든 환경에 최적은 아니다.
함정: 후진은 뒤가 안 보이면 위험하다. 대부분의 로봇은 후방 센서가 없거나 약하다.
backup_dist를 작게 하고, 후방 센서가 없다면 후진 복구를 빼는 것을 검토한다(8.7).
8.6.6 블랙보드
노드 간 데이터를 공유하는 키-값 저장소다.
<ComputePathToPose goal="{goal}" path="{path}" planner_id="GridBased"/>
<FollowPath path="{path}" controller_id="FollowPath"/>
{path}가 블랙보드 항목이다. ComputePathToPose가 쓰고 FollowPath가 읽는다.
| 표기 | 의미 |
|---|---|
{name} | 블랙보드에서 읽기·쓰기 |
"literal" | 고정 값 |
Nav2가 미리 넣어 두는 항목:
| 키 | 내용 |
|---|---|
goal | 목표 자세(액션 입력) |
goals | 다중 목표 |
path | 계산된 경로 |
number_of_recoveries | 복구 시도 횟수 |
error_code 계열 | 실패 원인 — 포트 이름은 error_code·error_code_id·error_codes_to_check이고 확인용 노드는 AreErrorCodesPresent다(실행 확인) |
견해: 블랙보드가 BT의 데이터 흐름이다. 노드가 직접 서로를 호출하지 않고 블랙보드를 통해 소통하므로, 노드를 재배치해도 연결이 유지된다. 커스텀 노드를 만들 때 입출력을 블랙보드 포트로 선언하면 기존 노드와 자연스럽게 결합한다.
8.6.7 커스텀 노드
자기 로직을 트리에 넣는 방법이다.
먼저 확인할 것 — 이미 있는 노드를 다시 만들고 있지 않은가(실행 확인 2026-08-16). Nav2 1.3.12가 등록하는 BT 노드는 54종이며, 아래 예제로 만드는 배터리 조건도
IsBatteryLow·IsBatteryCharging이라는 이름으로 이미 실재한다. 커스텀 노드를 쓰기 전에nav2_behavior_tree의nav2_tree_nodes.xml을 한 번 검토하는 편이 빠르다.실재하는 조건 노드 일부:
IsBatteryLow·IsBatteryCharging·IsStuck·IsPathValid·GoalReached·GoalUpdated·GloballyUpdatedGoal·InitialPoseReceived·DistanceTraveled·TimeExpired·TransformAvailable·PathExpiringTimer·PathLongerOnApproach·AreErrorCodesPresent·WouldAControllerRecoveryHelp·WouldAPlannerRecoveryHelp·WouldASmootherRecoveryHelp.그래도 아래 예제는 값을 한다 — 임계값·판단 기준이 우리 시스템 고유일 때(예: "배터리가 낮은가"가 아니라 "지금 남은 거리를 갈 수 있는가")는 결국 직접 써야 하고, 그때 필요한 것이 이 골격이다.
#include "behaviortree_cpp/condition_node.h"
class IsBatteryOkCondition : public BT::ConditionNode
{
public:
IsBatteryOkCondition(const std::string & name, const BT::NodeConfiguration & config)
: BT::ConditionNode(name, config)
{
node_ = config.blackboard->get<rclcpp::Node::SharedPtr>("node");
sub_ = node_->create_subscription<sensor_msgs::msg::BatteryState>(
"/battery", rclcpp::SystemDefaultsQoS(),
[this](const sensor_msgs::msg::BatteryState::SharedPtr msg) {
percentage_ = msg->percentage; // 스레드 안전 고려(2.9)
});
}
static BT::PortsList providedPorts()
{
return { BT::InputPort<double>("min_battery", 0.2, "최소 배터리 비율") };
}
BT::NodeStatus tick() override
{
double min_battery = 0.2;
getInput("min_battery", min_battery);
return percentage_ >= min_battery ? BT::NodeStatus::SUCCESS
: BT::NodeStatus::FAILURE;
}
private:
rclcpp::Node::SharedPtr node_;
rclcpp::Subscription<sensor_msgs::msg::BatteryState>::SharedPtr sub_;
std::atomic<double> percentage_{1.0};
};
// 플러그인 등록 (4.2.6)
BT_REGISTER_NODES(factory)
{
factory.registerNodeType<IsBatteryOkCondition>("IsBatteryOk");
}
bt_navigator:
ros__parameters:
plugin_lib_names:
- nav2_compute_path_to_pose_action_bt_node
- nav2_follow_path_action_bt_node
# ... 기본 노드들 ...
- my_battery_bt_node # ★ 커스텀 노드 추가
<ReactiveSequence>
<IsBatteryOk min_battery="0.15"/>
<PipelineSequence>
<!-- 주 항법 -->
</PipelineSequence>
</ReactiveSequence>
ReactiveSequence 아래에 두었으므로 매 틱마다 배터리를 검사하고, 낮아지면 즉시 항법을 중단한다. 8.6.2에서 말한 "반응성이 구조로 표현된다"의 실제 예다.
함정:
tick()은 BT 실행 스레드에서 호출되며 블로킹하면 안 된다. 서비스 호출이나 긴 계산은RUNNING을 반환하며 비동기로 처리해야 한다(BT::StatefulActionNode계열)(확인 필요: 정확한 기반 클래스). 1.7의 데드락과 같은 성질의 문제다.
함정: 구독 콜백과
tick()이 다른 스레드일 수 있다(2.9). 공유 변수에 원자 연산이나 락이 필요하다.
8.6.8 시각화와 디버깅
Groot로 트리를 보고 실행을 관찰한다.
# Groot 실행 — 주의: ros-jazzy-groot·groot2 는 apt 에 없다(실행 확인). 별도 배포본을 받아야 한다
ros2 run groot Groot
# 또는 Groot2
| 기능 | 설명 |
|---|---|
| 트리 편집 | XML을 GUI로 |
| 실시간 모니터링 | 어느 노드가 실행 중인지 색으로 |
| 로그 재생 | 기록된 실행 추적 |
bt_navigator:
ros__parameters:
default_nav_to_pose_bt_xml: "/path/to/my_tree.xml"
# Groot 모니터링 활성화 (확인 필요: 파라미터 이름)
enable_groot_monitoring: true
groot_zmq_publisher_port: 1666
groot_zmq_server_port: 1667
정직: Groot의 버전 호환성이 자주 문제가 된다. BehaviorTree.CPP의 메이저 버전과 Groot 버전이 맞아야 하며, Jazzy에서 어떤 조합이 동작하는지 확인이 필요하다
(확인 필요).
Groot 없이 디버깅:
# BT 로그 레벨 상승 (1.11.4)
ros2 service call /bt_navigator/set_logger_levels \
rcl_interfaces/srv/SetLoggerLevels \
"{levels: [{name: 'bt_navigator', level: 10}]}"
# 액션 피드백에서 현재 상태
ros2 action send_goal /navigate_to_pose ... --feedback
피드백에 유용한 정보가 들어 있다:
| 필드 | 내용 |
|---|---|
current_pose | 현재 위치 |
distance_remaining | 남은 거리 |
navigation_time | 경과 시간 |
number_of_recoveries | 복구 시도 횟수 |
견해:
number_of_recoveries를 감시하라. 이 값이 계속 증가하면 로봇이 어려움을 겪고 있는 것이고, 원인은 대개 코스트맵(8.4)이나 측위(8.3)에 있다. 진단으로 내보내면(6.9) 현장에서 조기에 알 수 있다.
8.6.9 흔한 트리 수정
실무에서 자주 하는 변경들이다.
① 재계획 주기 조정:
<RateController hz="2.0"> <!-- 1.0 → 2.0 -->
<ComputePathToPose .../>
</RateController>
② 복구 동작 변경:
<RoundRobin name="RecoveryActions">
<ClearEntireCostmap .../>
<Wait wait_duration="3"/> <!-- 사람 환경: 대기를 앞으로 -->
<Spin spin_dist="1.57"/>
<!-- BackUp 제거: 후방 센서 없음 -->
</RoundRobin>
③ 조건 추가:
<ReactiveSequence>
<IsBatteryOk min_battery="0.15"/>
<IsLocalizationOk/> <!-- 커스텀: 측위 신뢰도(8.3.8) -->
<PipelineSequence>...</PipelineSequence>
</ReactiveSequence>
④ 재시도 횟수:
<RecoveryNode number_of_retries="3"> <!-- 6 → 3, 빨리 포기하고 알림 -->
⑤ 목표별 트리 선택:
bt_navigator:
ros__parameters:
default_nav_to_pose_bt_xml: "navigate_to_pose_w_replanning_and_recovery.xml"
default_nav_through_poses_bt_xml: "navigate_through_poses_w_replanning_and_recovery.xml"
액션 목표에서 트리를 지정할 수도 있다 — behavior_tree 필드에 XML 경로를 넣으면 그 목표만 다른 트리로 처리된다(확인 필요: 필드 이름).
견해: "조심스럽게 가야 하는 구간"과 "빠르게 가도 되는 구간"에 다른 트리를 쓰는 것이 유용한 패턴이다. 목표마다 트리를 지정할 수 있으므로, 상위 시스템이 상황에 맞게 고를 수 있다.
8.6.12 Nav2가 등록한 BT 노드 — 실측
nav2_behavior_tree가 배포하는 nav2_tree_nodes.xml이 등록 노드의 정본이다(실행 확인 — Jazzy · Nav2 1.3.x 계열). Nav2는 자체 릴리스 주기를 가지므로 목록은 버전에 매인다(확인 필요). 이 파일을 열면 Nav2가 BT.CPP에 더한 것이 한눈에 보인다.
제어 노드(Control) 셋 — BT.CPP 기본에 없는 Nav2 고유다:
| 노드 | 하는 일 |
|---|---|
PipelineSequence | 앞 자식이 RUNNING이어도 다음 자식을 tick — 계획과 제어를 동시에 실행하는 핵심 |
RecoveryNode | 본 동작 실패 시 복구 자식을 실행하고 재시도 |
RoundRobin | 자식을 번갈아 — 복구 전략을 순환시킬 때 |
데코레이터 일곱 — RateController · DistanceController · SpeedController · GoalUpdater · GoalUpdatedController · PathLongerOnApproach · SingleTrigger.
RateController가 실무에서 가장 많이 조정하는 것이다. 계획 주기를 트리 안에서 정한다 — 전역 계획을 1 Hz로 제한하는 기본 트리의 그 노드다(8.6.5).
오류 코드 전달 규약도 확인됐다. 포트 이름은 error_code·error_code_id·error_codes_to_check이고, AreErrorCodesPresent 노드로 특정 오류가 났는지 검사한다. Nav2 1.3부터 서버들이 실패 이유를 코드로 돌려주므로, "왜 실패했는지에 따라 다른 복구를 하는" 트리를 구성할 수 있다.
8.6.12.1 Groot은 apt로 오지 않는다
이 장은 Groot 실행을 예시로 들었지만, ros-jazzy-groot·ros-jazzy-groot2 둘 다 Jazzy apt 저장소에 없다(실행 확인). BT.CPP 본체(ros-jazzy-behaviortree-cpp 4.9.0)만 apt로 온다.
Groot2는 별도 배포본(AppImage 등)을 받아야 하며, 무료판은 기능이 제한된다는 점도 알아 두어야 한다. 트리를 눈으로 보는 것이 목적이라면 XML을 직접 읽는 편이 빠른 경우도 많다 — 기본 트리는 100줄 남짓이다.
함정: "BT.CPP를 설치했으니 Groot도 있겠지"가 통하지 않는다. 이 장의 모니터링 절(실시간 트리 시각화)은 Groot을 따로 구한 뒤에야 따라 할 수 있다.
8.6.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | Sequence를 ReactiveSequence 대신 | 조건 변화에 반응 안 함 | Reactive 계열 |
| 2 | tick()에서 블로킹 | BT 전체 정지 | RUNNING + 비동기 |
| 3 | 구독 콜백과 tick() 공유 변수 무보호 | 경쟁 조건(2.9) | 원자·락 |
| 4 | number_of_retries 과대 | 로봇이 오래 헤맴 | 환경에 맞게·알림 |
| 5 | 후방 센서 없이 BackUp | 뒤를 보지 않고 후진 | 복구에서 제거 |
| 6 | 재계획 주기 > 플래너 계산 시간 | 계획이 밀림 | 시간 측정 |
| 7 | 커스텀 노드 plugin_lib_names 누락 | 노드 미로드 | 목록 추가 |
| 8 | 블랙보드 키 오타 | 데이터 전달 실패 | 키 대조 |
| 9 | Groot 버전 불일치 | 연결 실패 | 호환 버전 확인 |
| 10 | number_of_recoveries 미감시 | 어려움을 못 알아챔 | 진단(6.9) |
| 11 | 기본 트리가 모든 환경에 맞다고 가정 | 부적절한 복구 | 환경별 조정 |
| 12 | XML 문법 오류 | BT 로드 실패 | xmllint(4.4.9) |
| 13 | Groot이 안 깔림 | apt에 없다 — 별도 배포본 | 8.6.12.1 |
8.6.11 체크포인트
- BT가 FSM보다 나은 점 다섯 가지를 말할 수 있는가. 핵심 차이인 "매 틱마다 재평가"가 무엇을 가능하게 하는가.
- 노드의 세 반환값과
RUNNING의 역할을 설명할 수 있는가. - Sequence와 Fallback의 동작 차이는? Reactive 계열이 왜 필요한가.
- Nav2 기본 트리의 세 핵심 구조(
RecoveryNode·PipelineSequence·RateController)가 각각 무엇을 하는가. RateController hz값이 무엇을 정하며, 설정 시 확인할 것은?- 복구 동작 네 가지와 각각이 해결하는 문제를 짝지을 수 있는가. 점진적 강도의 의도는?
- 후진 복구가 위험한 경우와 대처는?
- 블랙보드가 무엇이며, 커스텀 노드를 만들 때 왜 포트로 선언하는가.
- 커스텀 조건 노드를 트리 어디에 두면 "매 틱 검사"가 되는가.
tick()에서 블로킹하면 안 되는 이유와, 구독 콜백과의 스레드 문제는?- 액션 피드백의 어느 필드를 감시해야 하며 왜인가.
- 실무에서 자주 하는 트리 수정 다섯 가지를 들 수 있는가.
- Nav2가 BT.CPP에 더한 제어 노드 셋은 무엇인가. Groot은 apt로 오는가(8.6.12).
다음 장 8.7은 이 트리가 다루지 못하는 층 — 안전을 다룬다. collision_monitor·속도 평활기·속도 제한, 그리고 Nav2가 제공하는 것과 직접 만들어야 하는 것.