Skip to main content
Summary

지난 장에서 우리는 로봇이 목표 지점까지 이동하는 과정을 따라갔다. 목표를 받으면 전역 경로가 만들어지고, 컨트롤러가 그 경로를 따라 속도 명령을 만들며, Costmap은 장애물을 반영한다. 겉으로 보면 로봇은 단순히 "길을 찾고 따라가는 기계"처럼 보인다.

하지만 실제 로봇은 그렇게 순진하게 움직일 수 없다. 복도 한가운데 사람이 멈춰 서 있을 수도 있고, LiDAR가 순간적으로 유리문을 잘못 읽을 수도 있으며, 로봇이 좁은 문턱 앞에서 더 이상 전진하지 못할 수도 있다. 목표 위치가 갑자기 바뀔 수도 있다. 심지어 경로는 멀쩡한데 컨트롤러가 안전한 속도 명령을 찾지 못할 때도 있다.

이때 중요한 질문은 "로봇이 실패했는가?"가 아니다. 진짜 질문은 이것이다.

실패했을 때 로봇은 다음에 무엇을 해야 하는가?

Nav2에서 이 질문에 답하는 핵심 구조가 Behavior Tree, 줄여서 BT다. Behavior Tree는 로봇의 고수준 행동을 트리 형태로 표현한다. 경로를 계산하고, 경로를 따라가고, 실패하면 Costmap을 지우고, 그래도 안 되면 회전하거나 후진하고, 새 목표가 들어오면 복구 행동을 멈추는 식의 판단 흐름을 XML 파일로 정의한다. Nav2의 기본 NavigateToPose 동작도 BehaviorTree.CPP 기반의 XML 트리로 구성되어 있으며, 대표 기본 트리인 navigate_to_pose_w_replanning_and_recovery.xml은 주기적 재계획과 복구 행동을 함께 포함한다.1

Behavior Tree와 로봇 행동 설계

Behavior Tree의 노드는 ROS2 노드가 아니다

먼저 아주 중요한 오해부터 풀어야 한다. 이 책에서 지금까지 "노드"라고 하면 대부분 ROS2 노드를 뜻했다. 예를 들어 planner_server, controller_server, bt_navigator, map_server는 ROS2 그래프에 참여하는 실행 단위다. 이들은 토픽, 서비스, 액션, 파라미터를 통해 서로 통신한다.

Behavior Tree에도 "노드"라는 말이 나온다. 하지만 여기서의 노드는 ROS2 노드가 아니다. BT 노드는 트리 안에 놓인 행동 조각 또는 판단 조각이다. 예를 들어 ComputePathToPose라는 BT Action 노드는 전역 경로 계산을 요청하는 트리의 한 칸이다. GoalUpdated는 목표가 바뀌었는지 확인하는 조건 노드다. RecoveryNode는 실패했을 때 복구 행동을 실행하고 다시 시도할지 결정하는 제어 노드다.

둘을 구분하면 Nav2 구조가 훨씬 선명해진다.

구분ROS2 노드Behavior Tree 노드
의미ROS2 그래프에서 실행되는 프로세스 또는 구성 요소BT 안에서 실행 흐름을 구성하는 행동·조건·제어 요소
예/bt_navigator, /planner_server, /controller_serverSequence, Fallback, ComputePathToPose, FollowPath, Spin
통신토픽, 서비스, 액션, 파라미터tick 호출과 반환 상태, Blackboard 데이터
역할실제 기능 제공기능을 어떤 순서와 조건으로 사용할지 결정

BT Action 노드라는 말도 ROS2 action과 완전히 같은 뜻은 아니다. 다만 Nav2에서는 BT Action 노드가 내부적으로 ROS2 action client처럼 동작하여 planner_server, controller_server, behavior_server 등에 요청을 보내는 경우가 많다. 다시 말해 BT Action 노드는 "트리 안의 행동 노드"이고, 그 행동 노드가 필요하면 ROS2 action이나 service를 호출한다.

이 차이를 이해하지 못하면 이런 혼란이 생긴다. "FollowPath 노드가 안 보이는데 ros2 node list에 왜 없지?" 답은 간단하다. FollowPath는 ROS2 노드가 아니라 BT XML에 등장하는 행동 노드다. 실제 ROS2 노드는 controller_server이고, FollowPath BT 노드는 그 서버에 경로 추종 작업을 요청하는 쪽에 가깝다.

tick: 트리가 살아 움직이는 방식

Behavior Tree는 위에서 아래로 한 번 읽고 끝나는 설정 파일이 아니다. 실행 중인 트리는 계속해서 tick을 받는다. tick은 "지금 이 노드를 실행해 보고 상태를 알려 달라"는 호출이다. 루트 노드에서 시작한 tick은 자식 노드로 전달되고, 각 노드는 자신의 상태를 부모에게 반환한다.

BT 노드가 반환하는 대표 상태는 세 가지다.

상태의미로봇 예시
SUCCESS이 노드의 일이 성공적으로 끝났다경로 계산 완료, 목표 도착, 조건 만족
FAILURE이 노드의 일이 실패했다경로 없음, 조건 불만족, 복구 행동 실패
RUNNING아직 진행 중이다경로 추종 중, 회전 중, 대기 중

이 세 상태만으로 꽤 풍부한 행동을 만들 수 있다. 예를 들어 로봇이 경로를 따라가는 동안 FollowPath는 RUNNING을 반환한다. 목표에 도착하면 SUCCESS가 된다. 컨트롤러가 안전한 속도 명령을 찾지 못하거나 진행 상태 검사에 실패하면 FAILURE가 된다. 그러면 부모 노드는 "그럼 복구 행동을 실행해야겠군" 하고 다음 흐름을 선택한다.

BT에는 Blackboard라는 공유 메모리도 있다. 칠판에 값을 적어 두고 여러 노드가 함께 읽고 쓰는 구조라고 생각하면 된다. Nav2의 BT XML에서 {goal}, {path}, {selected_planner}, {selected_controller} 같은 표현을 본다면 Blackboard 변수라고 이해하면 된다. ComputePathToPose는 {goal}을 읽어 {path}를 쓰고, FollowPath는 그 {path}를 읽어 로봇을 움직인다.

이 구조는 로봇 행동을 아주 유연하게 만든다. 경로 계산 노드와 경로 추종 노드는 서로 직접 붙어 있는 것이 아니라 Blackboard의 path를 통해 느슨하게 연결된다. 그래서 중간에 경로를 다시 계산해도 FollowPath는 새 경로를 받아 이어서 움직일 수 있다.

기본 제어 노드: Sequence, Fallback, Condition, Action

Behavior Tree를 처음 보면 XML 태그가 낯설지만, 핵심 문법은 어렵지 않다. 먼저 가장 기본적인 제어 노드부터 보자.

Sequence는 "순서대로 모두 성공해야 성공"하는 노드다. 첫 번째 자식이 성공하면 두 번째 자식을 실행하고, 두 번째도 성공하면 세 번째로 넘어간다. 중간에 하나라도 실패하면 전체가 실패한다. 로봇 행동으로 바꾸면 "문 열림 확인 → 경로 계산 → 경로 추종"처럼 반드시 순서를 지켜야 하는 작업에 적합하다.

Fallback은 "앞의 방법이 실패하면 다음 방법을 시도"하는 노드다. Selector라고 부르는 경우도 있다. 첫 번째 자식이 성공하면 전체가 성공하고, 실패하면 다음 자식을 실행한다. "기존 경로가 아직 유효하면 그대로 사용하고, 아니면 새 경로를 계산한다" 같은 구조가 Fallback에 어울린다.

Condition은 어떤 조건이 참인지 거짓인지 빠르게 검사하는 노드다. 오래 실행되지 않고 보통 즉시 SUCCESS 또는 FAILURE를 반환한다. GoalUpdated는 새 목표가 들어왔는지 확인하고, PathValid 또는 ValidatePath 계열 노드는 현재 경로가 아직 쓸 수 있는지 확인한다. "조건이 맞으면 다음으로 가고, 아니면 다른 행동을 선택한다"는 판단의 문 역할을 한다.

Action은 실제 작업을 수행하는 노드다. 경로 계산, 경로 추종, Costmap 초기화, 회전, 후진, 대기처럼 시간이 걸리는 행동이 여기에 속한다. Action 노드는 작업이 진행 중이면 RUNNING을 반환하다가 끝나면 SUCCESS 또는 FAILURE를 반환한다.

간단한 BT를 XML로 쓰면 다음처럼 표현할 수 있다.

<root BTCPP_format="4" main_tree_to_execute="MainTree">
  <BehaviorTree ID="MainTree">
    <Sequence name="SimpleNavigation">
      <ComputePathToPose goal="{goal}" path="{path}" />
      <FollowPath path="{path}" />
    </Sequence>
  </BehaviorTree>
</root>

이 트리는 읽기 쉽다. 먼저 목표까지의 경로를 계산한다. 성공하면 그 경로를 따라간다. 둘 다 성공하면 전체 내비게이션이 성공이다. 하지만 이 단순한 트리에는 치명적인 약점이 있다. 경로 계산이 실패하면 끝이다. 경로를 따라가다 막혀도 끝이다. 장애물이 사라지기를 기다리거나, Costmap을 지우거나, 후진해서 빠져나오는 전략이 없다.

실전 로봇에는 이런 단순한 트리만으로 부족하다.

Nav2의 기본 BT는 단순한 Sequence와 Fallback만으로 구성되지 않는다. 내비게이션에 자주 필요한 흐름을 표현하기 위해 몇 가지 특수 제어 노드와 데코레이터를 사용한다.

PipelineSequence: 움직이면서 다시 계획하기

일반적인 Sequence에서는 앞 단계가 성공하면 다음 단계로 넘어간다. 그런데 내비게이션에서는 조금 더 특별한 일이 필요하다. 로봇이 경로를 따라가는 동안에도 전역 경로를 다시 계산하고 싶다. 사람이 복도에 나타나거나, 목표가 조금 바뀌거나, 기존 경로의 일부가 막히면 새 경로가 필요하기 때문이다.

PipelineSequence는 이런 흐름에 적합하다. ComputePathToPose가 경로를 만들고 FollowPath가 그 경로를 따라가는 동안, 앞쪽의 경로 계산 부분도 주기적으로 다시 tick될 수 있다. 말하자면 주방에서 요리를 끝낸 뒤 홀로 나가는 방식이 아니라, 주방과 홀이 동시에 호흡을 맞추며 음식을 계속 내보내는 식이다.

Nav2의 기본 NavigateToPose 트리에서는 이 구조를 통해 로봇이 이동 중에도 전역 경로를 재계산할 수 있다.1

RecoveryNode: 실패하면 복구하고 다시 시도하기

RecoveryNode는 Nav2 BT를 이해하는 핵심이다. 이 노드는 보통 두 자식을 가진다. 첫 번째 자식은 원래 하려던 작업이고, 두 번째 자식은 그 작업이 실패했을 때 실행할 복구 행동이다.

예를 들어 경로 계산을 담당하는 ComputePathToPose가 실패했다고 하자. 실패 원인이 global costmap의 오래된 장애물 정보 때문일 수 있다. 이때 바로 전체 내비게이션을 포기하는 대신, RecoveryNode는 global costmap을 지우는 복구 행동을 실행한 뒤 경로 계산을 다시 시도한다.

RecoveryNode에는 number_of_retries 같은 재시도 제한이 붙는다. 이 제한이 없으면 로봇은 같은 실패를 끝없이 반복할 수 있다. 사람도 문이 잠겨 있으면 몇 번 두드려 볼 수는 있지만, 100번 두드리는 것은 좋은 전략이 아니다. 로봇도 마찬가지다. 재시도는 필요하지만, 실패를 상위 단계로 올려 보내는 기준도 필요하다.

RateController: 너무 자주 하지 않기

BT는 빠른 주기로 tick될 수 있다. 하지만 전역 경로 계산은 비교적 무거운 작업이다. 100Hz로 경로 계산 요청을 보내면 로봇이 똑똑해지는 것이 아니라 CPU가 바빠지고 시스템이 불안정해질 수 있다.

RateController는 자식 노드를 지정한 주기로만 tick하도록 제한한다. Nav2 기본 트리에서는 전역 경로 재계획을 1Hz 수준으로 제한하는 구조가 소개된다.1 즉 로봇은 컨트롤러를 빠르게 돌리면서도, 전역 경로는 적당한 속도로 다시 계산한다. 이것은 "민첩함"과 "계산 비용" 사이의 균형이다.

기본 NavigateToPose BT의 큰 그림

Nav2의 bt_navigator는 목표 pose를 받으면 BT XML을 읽고, BehaviorTree.CPP 엔진을 이용해 트리를 생성한 뒤 실행한다. 또한 작업 종류에 따라 다른 BT XML을 지정할 수 있어, 단일 목표 이동과 다중 경유지 이동, 특수 미션을 서로 다른 트리로 구성할 수 있다.2

기본 NavigateToPose 트리를 아주 크게 보면 두 덩어리로 나눌 수 있다.

첫 번째는 Navigation 서브트리다. 여기서는 실제 내비게이션이 일어난다. 플래너와 컨트롤러를 선택하고, 경로를 계산하고, 경로를 따라간다. 경로 계산 실패나 경로 추종 실패가 생기면 해당 상황에 맞는 작은 복구 행동도 시도한다.

두 번째는 Recovery 서브트리다. Navigation 서브트리 안에서 해결하지 못한 실패가 올라오면, 시스템 수준의 복구 행동을 실행한다. local/global costmap을 지우고, 제자리 회전하고, 기다리고, 후진하는 식이다. 복구 행동이 성공하면 다시 Navigation 서브트리로 돌아가 목표 이동을 재시도한다.

단순화하면 이런 모양이다.

<RecoveryNode name="NavigateRecovery" number_of_retries="6">
  <PipelineSequence name="NavigateWithReplanning">
    <RateController hz="1.0">
      <RecoveryNode name="ComputePathToPose" number_of_retries="1">
        <ComputePathToPose goal="{goal}" path="{path}" />
        <ClearEntireCostmap service_name="global_costmap/clear_entirely_global_costmap" />
      </RecoveryNode>
    </RateController>

    <RecoveryNode name="FollowPath" number_of_retries="1">
      <FollowPath path="{path}" />
      <ClearEntireCostmap service_name="local_costmap/clear_entirely_local_costmap" />
    </RecoveryNode>
  </PipelineSequence>

  <RoundRobin name="RecoveryActions">
    <ClearEntireCostmap service_name="local_costmap/clear_entirely_local_costmap" />
    <Spin spin_dist="1.57" />
    <Wait wait_duration="5.0" />
    <BackUp backup_dist="0.30" />
  </RoundRobin>
</RecoveryNode>

실제 기본 XML은 이보다 더 정교하다. 플래너 선택기, 컨트롤러 선택기, goal checker 선택기, progress checker 선택기, 오류 코드 검사 노드, 목표 업데이트 조건 등이 포함된다. 하지만 큰 흐름은 위와 같다. 계획하고, 따라가고, 실패하면 상황별로 복구하고, 그래도 안 되면 시스템 복구를 시도한다.

이 구조를 이해하면 Nav2가 갑자기 덜 신비로워진다. bt_navigator는 마법사가 아니다. 그저 XML에 정의된 트리를 매우 빠르고 성실하게 tick하면서, 각 노드의 반환 상태에 따라 다음 행동을 선택한다.

ComputePathToPose와 FollowPath의 협업

이제 Navigation 서브트리를 조금 더 가까이에서 보자. 이동 로봇에게 가장 중요한 두 행동은 ComputePathToPose와 FollowPath다.

ComputePathToPose는 목표 pose와 현재 지도, global costmap, 로봇 위치를 바탕으로 전역 경로를 만든다. 이 작업은 planner server 쪽 알고리즘과 연결된다. 예를 들어 NavFn, Smac Planner 같은 플래너가 실제 경로를 계산할 수 있다. BT 노드는 {goal}을 입력으로 받고 {path}를 출력으로 쓴다.

FollowPath는 계산된 {path}를 받아 로봇이 실제로 따라가도록 한다. 이 작업은 controller server와 연결된다. 컨트롤러는 local costmap을 보면서 현재 속도 명령을 만든다. 경로는 전체 지도 기준으로 만들어졌지만, 실제 주행은 바로 앞의 장애물, 로봇의 현재 속도, 회전 가능 반경 같은 지역 상황을 계속 반영한다.

여기서 중요한 점은 둘이 직렬로만 동작하지 않는다는 것이다. PipelineSequence와 RateController 덕분에 로봇은 경로를 따라가는 동안에도 주기적으로 새 경로를 받을 수 있다. 목표가 바뀌면 GoalUpdated 조건이 이를 감지하고, 기존 복구 행동이나 경로 추종 흐름을 끊어 새 목표에 대응할 수 있다. 현재 경로가 더 이상 유효하지 않다고 판단되면 PathValid 또는 ValidatePath 성격의 조건 노드가 재계획 흐름을 유도한다.

실내 로봇을 생각해 보자. 로봇이 회의실 앞을 지나가려는 순간 사람들이 문 앞에 모였다. 기존 전역 경로는 그 문을 통과하도록 되어 있지만, local costmap에는 갑자기 장애물이 가득하다. 이때 로봇은 무작정 밀고 들어가면 안 된다. 컨트롤러가 진행에 실패할 수 있고, 경로가 무효화될 수 있으며, 전역 경로를 다시 계산해야 할 수 있다. BT는 이런 상황에서 어느 행동을 먼저 시도하고, 어느 정도까지 재시도하고, 언제 포기할지를 결정한다.

실패는 한 종류가 아니다

자율주행 로봇에서 "실패"라는 말은 너무 넓다. 실패를 하나로 뭉뚱그리면 좋은 복구 전략을 만들 수 없다. Nav2 BT를 잘 설계하려면 실패를 원인별로 나누어 보아야 한다.

경로 계획 실패는 목표까지 갈 수 있는 전역 경로를 찾지 못했다는 뜻이다. 목표가 벽 안에 찍혔을 수도 있고, static map에서는 길이 있지만 global costmap에서는 장애물로 막혀 있을 수도 있다. 또는 로봇 위치 추정이 틀어져서 현재 위치가 지도 밖으로 튀었을 수도 있다. 이 경우에는 global costmap을 지우거나, 목표 pose를 다시 확인하거나, AMCL과 TF 상태를 점검해야 한다.

제어 실패는 경로는 있지만 지금 당장 안전하게 따라갈 수 없다는 뜻이다. 좁은 통로에서 로봇 footprint가 너무 크게 잡혔거나, local costmap에 장애물이 가까이 들어왔거나, 컨트롤러 파라미터가 로봇의 실제 구동 능력과 맞지 않을 수 있다. 이 경우에는 local costmap clearing, 대기, 후진, 회전 같은 복구 행동이 더 직접적인 도움이 된다.

경로 무효화는 기존에 계산한 경로가 더 이상 믿을 수 없게 되었다는 뜻이다. 동적 장애물이 경로 위에 생겼거나, costmap이 업데이트되며 경로 일부가 lethal cost 영역을 지나가게 되었을 수 있다. 이때는 기존 경로를 고집하는 것보다 재계획이 낫다.

정체 또는 stuck 상태는 로봇이 명령은 받고 있지만 실제 위치가 충분히 변하지 않는 상태다. 바퀴가 미끄러지거나, 전방이 막혔거나, 컨트롤러가 매우 작은 속도만 내보내는 상황일 수 있다. progress checker는 이런 상태를 감지하는 데 사용된다. BT 입장에서는 "경로 추종 실패"로 올라온 신호를 보고 복구 행동을 선택한다.

이렇게 실패를 나누면 복구 행동도 자연스럽게 달라진다. 모든 실패에 대해 무조건 제자리 회전을 시키는 것은 좋은 설계가 아니다. 계획 실패에는 global costmap clearing이 먼저일 수 있고, 제어 실패에는 local costmap clearing이나 BackUp이 먼저일 수 있다. 좁은 통로에서는 Spin이 오히려 위험할 수 있다.

Contextual Recovery와 System Recovery

Nav2 기본 트리의 복구 전략은 크게 두 층으로 이해하면 쉽다.

첫 번째는 Contextual Recovery, 즉 문맥 복구다. 특정 행동이 실패했을 때 그 행동과 관련된 복구를 바로 시도한다. ComputePathToPose가 실패하면 global costmap을 지우는 식이다. 전역 경로 계획은 global costmap을 주로 사용하므로, 계획 실패의 첫 복구로 global costmap clearing이 자연스럽다. 반대로 FollowPath가 실패하면 local costmap을 지운다. 경로 추종과 장애물 회피는 local costmap에 강하게 의존하기 때문이다.

두 번째는 System Recovery, 즉 시스템 수준 복구다. 문맥 복구로 해결되지 않으면 더 큰 범위의 복구 행동을 실행한다. 기본 트리에서는 local/global costmap clearing, Spin, Wait, BackUp 같은 행동이 순서대로 또는 RoundRobin 방식으로 시도된다.1

ClearCostmap은 Costmap에 남아 있는 오래된 장애물 흔적을 지우는 행동이다. LiDAR가 순간적으로 잘못 본 물체, 이미 지나간 사람의 흔적, 센서 잡음으로 생긴 장애물 셀이 문제가 될 때 도움이 된다. 하지만 실제 장애물이 아직 앞에 있다면 costmap을 지워도 곧 다시 표시된다. 따라서 clearing은 만능 청소기가 아니라 "센서와 costmap 상태를 다시 정리하는 기회"에 가깝다.

Spin은 제자리에서 회전하는 행동이다. 로봇이 주변을 다시 관측하여 costmap을 갱신하도록 돕거나, 좁은 상태에서 방향을 바꾸는 데 사용될 수 있다. 하지만 로봇 주변에 충분한 회전 공간이 없으면 위험하다. 따라서 실제 로봇에서는 footprint, inflation radius, collision monitor, 회전 속도 제한을 함께 고려해야 한다.

Wait는 말 그대로 기다리는 행동이다. 동적 장애물이 잠시 막고 있는 상황에서는 기다리는 것이 가장 안전한 선택일 수 있다. 사람이 복도를 지나가는 중이라면 로봇이 후진하거나 회전하는 것보다 잠시 멈추는 편이 낫다.

BackUp은 일정 거리 후진하는 행동이다. 로봇이 너무 가까이 장애물에 붙었거나, 컨트롤러가 전진 방향으로 해를 찾지 못할 때 유용하다. 다만 후방 센서가 없거나 후진 공간이 확인되지 않은 로봇에서는 매우 조심해야 한다. BT에 BackUp을 넣는 것은 "로봇이 후진해도 안전하다"는 시스템 설계상의 가정을 포함한다.

결국 복구 행동은 기술 목록이 아니라 안전 정책이다. "무엇을 할 수 있는가"보다 "무엇을 해도 되는가"를 먼저 정해야 한다.

RoundRobin: 복구 행동을 하나씩 바꿔 가며 시도하기

복구 행동을 항상 같은 순서로 처음부터 다시 시작하면 로봇이 특정 행동만 반복할 수 있다. 예를 들어 costmap clearing만 계속하고 실제로는 아무것도 나아지지 않는 상황이 생길 수 있다.

RoundRobin은 여러 자식 중 다음 행동을 차례로 선택하는 제어 노드다. 기본 복구 서브트리에서는 clearing, spin, wait, backup 같은 행동을 순환하며 시도할 수 있다. 하나의 복구 행동이 성공하면 다시 Navigation 서브트리로 돌아가 이동을 시도한다. 그래도 실패하면 다음 복구 행동으로 넘어간다.

로봇의 입장에서 보면 이런 대화와 비슷하다.

"길이 막혔네. 먼저 costmap을 정리해 볼게."

"아직도 안 되네. 이번에는 주변을 보려고 조금 회전해 볼게."

"그래도 어렵네. 사람이 지나갈 수도 있으니 잠깐 기다려 볼게."

"여전히 안 되네. 조금 뒤로 물러나서 다시 시작해 볼게."

물론 이 시도는 무한히 반복되면 안 된다. 상위 RecoveryNode의 number_of_retries는 전체 복구 시도 횟수를 제한한다. 기본 트리 예시에서는 최상위 복구 노드가 여러 번 재시도하도록 구성되어 있으며, 제한을 넘으면 전체 내비게이션은 실패로 종료된다.1

이 실패 종료는 나쁜 일이 아니다. 오히려 안전한 시스템에서는 반드시 필요하다. 로봇이 스스로 해결하지 못하는 상황이라면 상위 미션 관리자, 원격 운영자, 안전 정지 상태로 제어권을 넘겨야 한다.

Nav2에서 BT XML만 있다고 모든 일이 되는 것은 아니다. XML에 <FollowPath>라고 썼을 때, BehaviorTree.CPP 엔진은 이 태그가 어떤 C++ 클래스와 연결되는지 알아야 한다. 이 연결을 제공하는 대표 패키지가 nav2_behavior_tree다.

nav2_behavior_tree는 Nav2 전용 BT 노드들을 제공하고, ROS2 action과 service를 BT 노드로 연결하기 위한 템플릿과 BehaviorTreeEngine을 포함한다. 예를 들어 FollowPath 같은 BT Action 노드는 내부적으로 Nav2 action 인터페이스와 연결될 수 있으며, 해당 노드 타입은 BehaviorTree factory에 등록되어 XML에서 사용할 수 있게 된다.3

이 구조 덕분에 Nav2는 확장 가능하다. 기본 제공 노드만 사용하는 것도 가능하지만, 프로젝트에 따라 새로운 조건 노드나 행동 노드를 만들 수도 있다. 예를 들어 배송 로봇이라면 "적재함 문이 닫혔는가?"를 확인하는 조건 노드가 필요할 수 있다. 순찰 로봇이라면 "현재 시간이 야간 순찰 시간인가?"를 판단하는 조건 노드가 필요할 수 있다. 엘리베이터를 타는 로봇이라면 "엘리베이터 호출이 완료되었는가?"를 확인하는 service 기반 노드가 필요할 수 있다.

중요한 것은 BT 노드를 너무 거대하게 만들지 않는 것이다. "배송 전체를 처리하는 하나의 거대한 Action 노드"를 만들면 BT를 쓰는 장점이 사라진다. 대신 작은 조건과 작은 행동을 조합하자. 문이 열렸는지 확인하는 노드, 목표까지 이동하는 서브트리, 도착 후 대기하는 노드, 실패 시 운영자에게 알리는 노드를 분리하면 행동 설계가 읽히기 시작한다.

BT XML과 bt_navigator 설정

BT 파일을 바꾸려면 bt_navigator가 어떤 XML을 읽을지 알려 주어야 한다. 일반적으로 Nav2 파라미터 파일에서 default_nav_to_pose_bt_xml 또는 배포판에 맞는 관련 파라미터를 설정한다. 다중 경유지 이동은 default_nav_through_poses_bt_xml 계열 설정을 사용한다.

예시는 다음과 같다.

bt_navigator:
  ros__parameters:
    use_sim_time: true
    global_frame: map
    robot_base_frame: base_link
    odom_topic: /odom
    bt_loop_duration: 10
    default_nav_to_pose_bt_xml: /home/robot/ros2_ws/src/my_robot_navigation/behavior_trees/navigate_to_pose_custom.xml

배포판과 Nav2 버전에 따라 파라미터 이름이나 기본 XML 파일 경로가 조금씩 다를 수 있으므로, 실제 프로젝트에서는 설치된 nav2_bringup 예제 설정과 공식 configuration guide를 함께 확인하는 습관이 좋다. 특히 사용자 정의 BT 노드를 만들었다면 해당 플러그인 라이브러리가 bt_navigator에서 로드되도록 설정해야 한다. XML에 태그를 추가했는데 실행 시 "알 수 없는 노드 타입" 오류가 난다면, 대개 플러그인 등록이나 로딩 설정이 빠진 것이다.

프로젝트 구조는 다음처럼 분리하면 관리하기 좋다.

my_robot_navigation/
├── config/
│   └── nav2_params.yaml
├── behavior_trees/
│   ├── navigate_to_pose_default.xml
│   ├── navigate_delivery.xml
│   └── navigate_patrol.xml
├── launch/
│   └── navigation.launch.py
└── maps/
    └── office.yaml

처음에는 기본 BT XML을 그대로 복사해서 이름만 바꾼 뒤, 아주 작은 수정부터 시작하자. 예를 들어 Wait 시간을 5초에서 2초로 바꾸거나, 특정 환경에서 Spin을 제거해 보는 식이다. 한 번에 트리를 크게 바꾸면 문제가 생겼을 때 원인을 찾기 어렵다.

XML을 읽는 순서

BT XML은 처음에는 복잡해 보인다. 그러나 읽는 순서를 정하면 훨씬 편해진다.

먼저 루트에서 가장 큰 제어 노드를 찾는다. 대개 RecoveryNode, PipelineSequence, ReactiveFallback 같은 노드가 큰 흐름을 만든다. "이 트리는 기본적으로 성공을 향해 순서대로 가는가, 아니면 실패 시 대안을 찾는가?"를 먼저 본다.

그다음 실제 Action 노드를 찾는다. Nav2 내비게이션에서 중요한 Action은 보통 ComputePathToPose, FollowPath, ClearEntireCostmap, Spin, Wait, BackUp이다. 이 노드들이 무엇을 하는지 알면 트리의 뼈대가 보인다.

그다음 조건 노드를 본다. GoalUpdated, PathValid, WouldAPlannerRecoveryHelp, WouldAControllerRecoveryHelp 같은 조건은 실행 흐름의 갈림길이다. 특히 WouldAPlannerRecoveryHelp와 WouldAControllerRecoveryHelp 계열 노드는 오류 코드가 복구 가능한 종류인지 판단하는 데 사용된다. 모든 실패에 무작정 복구 행동을 붙이는 대신, 복구가 도움이 될 만한 상황인지 먼저 확인하는 것이다.

마지막으로 숫자를 본다. number_of_retries, hz, wait_duration, backup_dist, backup_speed, spin_dist 같은 값은 행동의 성격을 크게 바꾼다. 같은 트리 구조라도 재시도 횟수와 대기 시간, 후진 거리만 바뀌어도 로봇의 성격은 완전히 달라진다. 성급한 로봇이 될 수도 있고, 너무 오래 망설이는 로봇이 될 수도 있다.

Groot 계열 도구로 트리 보기

BT는 XML로 읽을 수도 있지만, 시각화 도구로 보면 훨씬 직관적이다. Groot 또는 Groot2 계열 도구는 BehaviorTree.CPP 트리를 그래프 형태로 보여 주고, 경우에 따라 실행 중인 트리의 상태 변화를 관찰하는 데 사용된다. Nav2에서 제공하는 BT 노드 목록 XML을 먼저 불러오면 Nav2 전용 노드가 포함된 트리를 더 편하게 열 수 있다는 실습 사례도 널리 사용된다.4

디버깅할 때는 다음 흐름으로 접근하면 좋다.

첫째, 사용 중인 BT XML 파일이 정말 bt_navigator에 로드되었는지 확인한다. 파일을 수정했는데 로봇 행동이 전혀 바뀌지 않는다면, 엉뚱한 XML을 수정하고 있을 가능성이 있다. launch 파일과 nav2_params.yaml의 경로를 확인하자.

둘째, 트리에서 현재 실패하는 Action 노드를 찾는다. ComputePathToPose가 실패한다면 planner, global costmap, map, TF, 목표 pose를 의심해야 한다. FollowPath가 실패한다면 controller, local costmap, footprint, 속도 제한, 진행 상태 검사를 보아야 한다. Spin이나 BackUp이 실패한다면 behavior server와 로봇의 안전 제약을 확인해야 한다.

셋째, 복구 행동이 실제로 호출되는지 본다. 로봇이 멈췄다고 해서 반드시 복구 행동이 실행된 것은 아니다. 상위 조건에서 복구가 도움이 되지 않는다고 판단했을 수도 있고, 복구 노드 자체가 플러그인 로딩 문제로 실행되지 않았을 수도 있다.

넷째, RViz와 함께 본다. BT는 "왜 이 행동을 선택했는가"를 보여 주고, RViz는 "로봇이 세상을 어떻게 보고 있는가"를 보여 준다. global costmap이 막혀 있는지, local costmap에 장애물이 붙어 있는지, 전역 경로가 이상한 곳을 지나는지, 로봇 footprint가 통로보다 큰지 함께 확인해야 한다.

다섯째, 로그를 아껴 보지 말자. bt_navigator, planner_server, controller_server, behavior_server, local_costmap, global_costmap 로그를 함께 보면 실패 원인이 한곳에만 있지 않다는 사실을 자주 발견한다. BT는 실패를 전달하는 통로이고, 실제 실패 원인은 costmap 설정, TF, 센서, controller 파라미터에 있을 수 있다.

대표 문제별 BT 관점 디버깅

"목표를 찍었는데 경로가 생기지 않는다." 이 경우 BT에서는 ComputePathToPose 쪽을 먼저 본다. 목표 pose가 map frame 기준인지, 로봇 현재 위치가 map 안에 있는지, global costmap이 정상적으로 업데이트되는지 확인한다. 목표가 장애물 안에 찍혔거나 unknown 영역을 지나야 하는데 planner가 이를 허용하지 않으면 경로 계산이 실패할 수 있다.

"경로는 생기는데 로봇이 출발하지 않는다." 이 경우 FollowPath와 controller server를 본다. local costmap에 로봇 주변이 모두 장애물로 표시되어 있을 수 있다. footprint가 실제보다 크게 설정되었거나 inflation radius가 좁은 통로에 비해 너무 클 수도 있다. 컨트롤러의 최소 속도, 최대 속도, 가속도 제한이 로봇 모델과 맞지 않아 유효한 명령을 찾지 못할 수도 있다.

"로봇이 조금 가다가 멈추고 복구 행동만 반복한다." 이 경우 progress checker와 local costmap을 함께 본다. 로봇이 실제로 움직이지 못하는지, 움직이지만 progress checker 기준을 만족하지 못하는지 구분해야 한다. 좁은 환경에서는 기본 복구 행동 중 Spin이 오히려 상황을 악화시킬 수 있다. 이때는 BT에서 Spin을 제거하거나, Wait와 BackUp의 순서를 바꾸거나, 재시도 횟수를 줄이는 설계가 필요하다.

"새 목표를 찍어도 이전 복구 행동을 계속한다." 이 경우 GoalUpdated 조건이 트리에 적절히 들어 있는지 확인한다. Nav2 기본 트리는 목표 업데이트에 빠르게 반응하도록 관련 조건을 사용한다. 사용자 정의 트리를 만들면서 이 조건을 제거하면, 로봇이 새 목표보다 기존 복구 행동을 우선하는 이상한 모습을 보일 수 있다.

"XML을 바꾸자 Nav2가 시작되지 않는다." 이 경우 가장 먼저 XML 문법을 확인한다. 태그가 닫히지 않았거나, 속성 따옴표가 빠졌거나, 등록되지 않은 BT 노드 이름을 사용했을 수 있다. 다음으로 플러그인 로딩 설정을 확인한다. 사용자 정의 노드를 XML에 썼다면 C++ 플러그인이 빌드되고 등록되었으며, bt_navigator가 그 라이브러리를 로드해야 한다.

배송 로봇 행동 설계 패턴

이제 기본 NavigateToPose를 응용해 보자. 배송 로봇은 단순히 목표 지점까지 이동하는 것으로 끝나지 않는다. 물건을 싣고, 목적지로 이동하고, 도착하면 대기하거나 알림을 보내고, 수령이 끝나면 다음 장소로 이동해야 한다.

BT로 표현하면 이런 구조가 가능하다.

<Sequence name="DeliveryMission">
  <IsCargoLoaded />
  <SubTree ID="NavigateToPickupExit" />
  <SubTree ID="NavigateToCustomer" />
  <WaitForDoorOrUser timeout="30" />
  <NotifyArrival />
</Sequence>

여기서 NavigateToCustomer는 기존 NavigateToPose 서브트리를 재사용할 수 있다. 배송 로봇이라고 해서 경로 계획과 경로 추종을 새로 만들 필요는 없다. 기본 내비게이션 서브트리를 임무 행동의 일부로 끼워 넣으면 된다.

중요한 것은 실패 시 상위 상태 전환이다. 고객 위치로 가는 데 실패했을 때 로봇이 복도에서 무한히 Spin을 반복하면 안 된다. 일정 횟수 복구 후에도 실패하면 "배송 실패", "운영자 호출", "안전 대기 위치로 이동" 같은 상위 상태로 넘어가야 한다. BT의 RecoveryNode와 Fallback은 이런 전환을 표현하기 좋다.

순찰 로봇 행동 설계 패턴

순찰 로봇은 여러 지점을 반복해서 돈다. 단순히 NavigateThroughPoses를 사용할 수도 있지만, 실제 순찰에는 더 많은 조건이 붙는다. 배터리가 부족하면 순찰을 중단해야 하고, 특정 구역은 시간대에 따라 건너뛰어야 하며, 한 지점에 도달하지 못하면 다음 지점으로 넘어갈지 전체 임무를 실패 처리할지 정해야 한다.

순찰 BT는 다음 관점으로 설계할 수 있다.

<Fallback name="PatrolOrCharge">
  <Sequence name="BatteryLowBranch">
    <IsBatteryLow />
    <SubTree ID="NavigateToDock" />
  </Sequence>

  <Sequence name="PatrolBranch">
    <SubTree ID="NavigateToWaypointA" />
    <ObserveArea duration="10" />
    <SubTree ID="NavigateToWaypointB" />
    <ObserveArea duration="10" />
  </Sequence>
</Fallback>

이 구조에서 첫 번째 분기는 배터리가 낮을 때만 성공적으로 진행된다. 배터리가 충분하면 IsBatteryLow가 실패하고, Fallback은 순찰 분기로 넘어간다. 이런 식으로 BT는 "평소에는 순찰하지만, 배터리가 낮으면 도킹한다"는 정책을 자연스럽게 표현한다.

다만 순찰 지점 하나에 실패했다고 전체 시스템을 멈출지, 다음 지점으로 넘어갈지는 운영 목적에 따라 다르다. 보안 순찰이라면 특정 지점 실패가 중요 이벤트일 수 있다. 청소 로봇이라면 일단 다음 구역으로 넘어가고 나중에 재시도하는 편이 나을 수 있다. BT 설계는 기술이 아니라 운영 정책을 코드로 옮기는 일이다.

도킹 행동 설계 패턴

도킹은 일반 내비게이션보다 더 섬세하다. 로봇은 먼저 충전기 근처까지 이동한 뒤, 가까운 거리에서는 비전 마커, IR 센서, 접촉 센서 등 더 정밀한 방법을 사용할 수 있다. 그래서 도킹 BT는 보통 "일반 Nav2 이동"과 "정밀 도킹"을 나누어 설계한다.

<RecoveryNode name="DockingMission" number_of_retries="3">
  <Sequence>
    <SubTree ID="NavigateNearDock" />
    <AlignToDockMarker />
    <ApproachDockSlowly />
    <IsCharging />
  </Sequence>

  <Sequence>
    <SafeStop />
    <BackUp backup_dist="0.20" />
    <Wait wait_duration="2.0" />
  </Sequence>
</RecoveryNode>

여기서 NavigateNearDock은 기존 NavigateToPose 서브트리를 재사용한다. 하지만 충전기 바로 앞에서는 일반 controller보다 더 느리고 정밀한 접근 행동이 필요할 수 있다. 실패하면 먼저 안전 정지하고, 조금 후진한 뒤, 다시 정렬을 시도한다.

도킹에서 가장 중요한 조건은 IsCharging이다. 위치상 도착했다고 해서 충전이 시작된 것은 아니다. 충전 접점이 맞지 않으면 로봇은 목표 pose에 도착했더라도 임무에는 실패한 것이다. BT에서는 이런 "물리적 성공 조건"을 별도 Condition 노드로 분리하는 것이 좋다.

대기와 외부 시스템 연동

로봇은 항상 혼자 움직이지 않는다. 자동문, 엘리베이터, 창고 관리 시스템, 호출 앱, 관제 서버와 함께 동작할 수 있다. 이때 BT는 외부 시스템 응답을 행동 흐름에 넣기 좋은 구조다.

예를 들어 자동문 앞에서 로봇은 다음처럼 행동할 수 있다.

<Sequence name="PassAutomaticDoor">
  <SubTree ID="NavigateToDoorFront" />
  <RequestDoorOpen />
  <Fallback>
    <IsDoorOpen />
    <Wait wait_duration="5.0" />
  </Fallback>
  <SubTree ID="NavigateThroughDoor" />
</Sequence>

하지만 위 트리는 아직 부족하다. 문이 열리지 않으면 얼마나 기다릴 것인가? 재요청은 몇 번 할 것인가? 사람이 수동으로 열어 주는 상황을 허용할 것인가? 실패하면 다른 경로를 찾을 것인가? 이런 정책이 빠지면 로봇은 문 앞에서 영원히 기다릴 수 있다.

따라서 외부 시스템 연동 BT에는 항상 timeout과 실패 전환을 넣어야 한다. "기다린다"는 행동은 반드시 "얼마나 기다릴지"와 함께 설계해야 한다.

안전 정지와 상위 상태 전환

복구 행동을 많이 넣는다고 좋은 로봇이 되는 것은 아니다. 좋은 로봇은 스스로 해결할 수 있는 문제와 그렇지 않은 문제를 구분한다. 그리고 해결할 수 없는 문제에서는 안전하게 멈추고 도움을 요청한다.

BT 설계에서 안전 정지는 마지막에 억지로 붙이는 장식이 아니다. 처음부터 포함해야 할 행동이다. 예를 들어 복구 시도 횟수를 초과하면 SafeStop을 실행하고, 모터 명령을 0으로 만들고, 관제 시스템에 실패 상태를 보고하고, 필요하면 경고음을 낸다. 이것은 내비게이션 실패가 아니라 안전한 실패다.

상위 상태 전환도 중요하다. Nav2의 NavigateToPose BT는 "한 목표로 이동"하는 행동에 집중한다. 하지만 전체 로봇 시스템에는 더 큰 상태가 있다. 배송 중, 순찰 중, 충전 중, 수동 조작 중, 오류 대기 중 같은 상태다. BT가 실패를 반환하면 상위 미션 관리자는 다음 결정을 내려야 한다.

예를 들어 배송 중 목표 이동이 실패하면 다음 중 하나를 선택할 수 있다.

  • 같은 목표를 나중에 다시 시도한다.

  • 가장 가까운 안전 대기 위치로 이동한다.

  • 원격 운영자에게 제어권을 넘긴다.

  • 배송을 취소하고 복귀한다.

이 정책은 Nav2 기본 BT만으로 자동 결정되지 않는다. 프로젝트 목적에 맞게 설계해야 한다.

BT를 수정할 때의 원칙

BT는 강력하지만, 무작정 복잡하게 만들면 오히려 디버깅하기 어려워진다. 실전에서는 몇 가지 원칙을 지키는 편이 좋다.

첫째, 기본 트리를 먼저 이해하고 조금씩 바꾼다. Nav2는 여러 예제 BT XML을 제공하며, 사용자는 이를 응용해 자신의 로봇 목적에 맞는 트리를 만들 수 있다.5 처음부터 완전히 새 트리를 쓰기보다 기본 NavigateToPose 트리를 복사해 작은 차이를 만드는 편이 안전하다.

둘째, 복구 행동에는 항상 한계를 둔다. 재시도 횟수, 대기 시간, 후진 거리, 회전 각도를 명확히 정해야 한다. 로봇이 같은 행동을 끝없이 반복한다면 그것은 자율성이 아니라 방황이다.

셋째, 환경에 맞지 않는 복구 행동은 제거하거나 약화한다. 넓은 로비에서는 Spin이 도움이 될 수 있지만, 좁은 미로형 통로에서는 충돌 위험을 키울 수 있다. 후방 센서가 없는 로봇이라면 BackUp을 매우 제한적으로 써야 한다.

넷째, 조건 노드를 적극적으로 사용한다. 배터리 부족, 새 목표 수신, 경로 유효성, 도킹 성공, 화물 적재 여부 같은 조건을 분리하면 트리가 읽기 쉬워진다. 조건을 Action 노드 내부에 숨기면 디버깅이 어려워진다.

다섯째, RViz와 BT 시각화를 함께 사용한다. BT만 보면 로봇이 왜 장애물을 피하지 못하는지 알 수 없고, RViz만 보면 왜 복구 행동이 선택되었는지 알기 어렵다. 둘을 함께 보아야 판단과 인식의 연결이 보인다.

로봇 행동은 경로 위에 세워진다

이 장에서 우리는 Nav2의 Behavior Tree를 통해 로봇의 고수준 행동을 살펴보았다. 경로 계획과 경로 추종은 내비게이션의 핵심이지만, 그것만으로 자율주행 로봇이 완성되지는 않는다. 로봇은 실패를 만나고, 실패를 분류하고, 복구를 시도하고, 그래도 안 되면 안전하게 포기해야 한다.

Behavior Tree는 이 판단을 구조화하는 방법이다. Sequence는 순서를 만들고, Fallback은 대안을 찾고, Condition은 갈림길을 만들고, Action은 실제 일을 한다. Nav2의 PipelineSequence, RecoveryNode, RateController는 이동 로봇에 맞게 이 구조를 더 강력하게 만든다. ComputePathToPose와 FollowPath는 목표 이동의 중심이고, ClearCostmap, Spin, Wait, BackUp은 실패 상황에서 로봇에게 두 번째 기회를 준다.

하지만 가장 중요한 것은 XML 문법이 아니다. 좋은 BT는 로봇의 운영 철학을 담고 있다. 언제 기다릴 것인가, 언제 후진할 것인가, 언제 포기할 것인가, 언제 사람에게 도움을 요청할 것인가. 이 질문에 답할 수 있을 때, 로봇은 단순히 움직이는 장치에서 스스로 행동을 선택하는 시스템으로 한 단계 올라선다.

다음 장부터는 지금까지 배운 내용을 종합 프로젝트로 묶어 간다. 먼저 실내 지도를 만들고, 그 지도 위에서 위치를 추정하고, Nav2를 실행해 목표 지점까지 이동시킬 것이다. 이제 우리는 단지 명령을 실행하는 로봇이 아니라, 실패했을 때의 다음 행동까지 설계하는 로봇 시스템을 만들 준비가 되었다.

참고

1Detailed Behavior Tree Walkthrough — Nav2 documentation

2BT Navigator README — Navigation2 GitHub

3nav2_behavior_tree package documentation

4Behavior tree with NAV2 — Groot and custom BT node example

5Nav2 Behavior Trees — Nav2 documentation

책 목록

ROS2로 완성하는 자율주행 로봇: 노드·TF·SLAM·Nav2 실전 설계

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기