AEGLOSLAB ROS 2 Jazzy 대전

제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 (AND) FAILURE 자식1 자식2 자식3 전체 FAILURE
Fallback (OR) FAILURE FAILURE SUCCESS 자식1 자식2 자식3 전체 SUCCESS
노드동작
Sequence자식을 순서대로. 하나라도 실패하면 전체 실패
Fallback자식을 순서대로. 하나라도 성공하면 전체 성공
ReactiveSequenceSequence + 매 틱마다 앞 자식 재검사
ReactiveFallbackFallback + 동일
PipelineSequenceNav2 고유 — 앞 자식이 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의 동작이 보인다.

RecoveryNode (6회 재시도) PipelineSequence 주 항법 RateController 1Hz → ComputePathToPose FollowPath ReactiveFallback 복구 GoalUpdated? (목표가 바뀌었나) RoundRobin 복구 동작 순환 코스트맵 초기화 제자리 회전 대기 후진

동작 흐름:

상황트리의 반응
정상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 함정 카탈로그

#함정증상대처
1Sequence를 ReactiveSequence 대신조건 변화에 반응 안 함Reactive 계열
2tick()에서 블로킹BT 전체 정지RUNNING + 비동기
3구독 콜백과 tick() 공유 변수 무보호경쟁 조건(2.9)원자·락
4number_of_retries 과대로봇이 오래 헤맴환경에 맞게·알림
5후방 센서 없이 BackUp뒤를 보지 않고 후진복구에서 제거
6재계획 주기 > 플래너 계산 시간계획이 밀림시간 측정
7커스텀 노드 plugin_lib_names 누락노드 미로드목록 추가
8블랙보드 키 오타데이터 전달 실패키 대조
9Groot 버전 불일치연결 실패호환 버전 확인
10number_of_recoveries 미감시어려움을 못 알아챔진단(6.9)
11기본 트리가 모든 환경에 맞다고 가정부적절한 복구환경별 조정
12XML 문법 오류BT 로드 실패xmllint(4.4.9)
13Groot이 안 깔림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가 제공하는 것과 직접 만들어야 하는 것.