hello, robot

주행 로봇의 진짜 난제는 “가라”가 아니라 “막혔을 때”예요. 경로가 안 나오면 지도를 비우고 다시, 그래도 안 되면 제자리 회전으로 센서를 갱신하고, 그래도 안 되면 잠깐 기다렸다가, 몇 번을 넘기면 포기. 이 시나리오를 조건문으로 짜면 중첩 분기와 플래그가 뒤엉킨 스파게티가 되고, 복구 단계를 하나 추가할 때마다 기존 분기를 전부 다시 읽어야 해요.

상태 기계로 옮겨도 문제가 남아요. 상태끼리 서로 오갈 수 있으면 전이가 상태 수의 제곱 방향으로 늘고, “어떤 상태에서든 배터리가 부족하면 복귀” 같은 요구 하나가 모든 상태에서 출발하는 간선 N개가 돼요.

행동 트리는 전이를 적지 않아요. 대신 우선순위를 트리의 구조로 적어요. 위·왼쪽 가지가 우선이고 실패하면 다음 가지로 넘어가요. 매 주기 뿌리에서부터 다시 평가하니 상황이 바뀌면 별도의 전이 없이 다른 가지가 선택됩니다. 복구를 추가한다는 게 간선 N개 추가가 아니라 실패 쪽 가지에 자식 하나 꽂기가 되는 거예요.

일은 잎이 하고 제어 노드는 지휘만

노드는 세 부류예요. 실행 노드는 실제 일을 하는 잎이에요. 경로 계산, 경로 추종, 회전, 대기요. 조건 노드는 세상을 읽고 참·거짓만 답하는 잎이고요. 제어 노드는 자식들을 어떤 순서로 실행할지 정하는 내부 노드인데 대표가 셋입니다.

Sequence는 AND예요. 자식을 왼쪽부터 실행해 하나라도 실패하면 거기서 멈추고, 전부 성공해야 성공이에요. “경로를 얻고, 그 경로를 따라간다”처럼 순서가 있는 일이 여기 실려요.

Fallback은 OR예요. 게임 쪽에서 Selector라 부르는 것과 같습니다. 왼쪽부터 시도해 하나라도 성공하면 즉시 성공, 전부 실패해야 실패예요. “본작업, 안 되면 복구 1, 안 되면 복구 2”처럼 대안을 나열하는 자리라, 우선순위가 구조로 적힌다는 말이 실제로 일어나는 곳이에요.

Parallel은 자식 여럿을 함께 돌리고 성공 정족수로 판정하는데 쓰임이 좁아 이름만 알아 두면 돼요.

실행 모델은 틱 하나로 설명됩니다. 엔진이 주기적으로 뿌리에 신호를 주면 제어 노드가 자기 규칙대로 자식에게 내려보내고, 잎에 닿으면 실제 일이 실행되고, 결과가 위로 올라와요. 트리는 이번엔 누구를 시도할지 정하는 의사결정 구조이고 일 자체는 항상 잎이 해요.

RUNNING이 트리를 살립니다

모든 노드는 틱마다 셋 중 하나를 반환해요. 성공, 실패, 그리고 진행 중(RUNNING)이요. 앞의 둘은 직관 그대로니 진행 중이 핵심입니다.

경로 추종은 수십 초 걸리는 일이에요. 만약 추종 액션이 틱 안에서 도착할 때까지 기다렸다가 성공을 반환한다면 그동안 트리 전체가 멈춰요. 조건 재평가도, 우선순위 높은 가지로의 전환도, 복구도 전부 죽습니다. C++ rclcpp — 콜백 수명과 스레딩이 만드는 사고의 “콜백 안에서 기다리지 않는다”와 같은 원리예요. 틱은 모두가 공유하는 실행 흐름이라 한 노드가 붙잡고 있으면 안 돼요.

그래서 오래 걸리는 액션은 첫 틱에 일을 시작만 해 두고 진행 중을 반환하고, 다음 틱부터는 아직 하는 중인지만 확인하다가, 끝나면 성공이나 실패를 반환해요. 액션 노드들이 내부적으로 비동기 액션 클라이언트인 이유가 이겁니다. 목표를 보내 놓고 결과를 나중에 받는 구조가 진행 중과 정확히 맞물려요.

진행 중이 위로 올라갈 때 제어 노드가 어떻게 하느냐로 갈래가 나뉘어요. 기억형은 진행 중을 반환한 자식의 위치를 기억해 다음 틱에 거기서부터 이어가요. 반응형은 매 틱 처음부터 다시 평가해서, 앞의 조건이 뒤집히면 진행 중이던 액션을 중단시키고 다른 가지로 옮겨갑니다. 어느 쪽을 쓰느냐가 곧 “진행 중에도 조건을 다시 보느냐”의 선택이에요.

데코레이터는 자식 하나를 감싸요

자식이 정확히 하나인 제어 노드예요. 실제로 만나는 것은 셋입니다.

Retry는 자식이 실패할 때 정해진 횟수까지 다시 시도해요. 성공과 진행 중은 그대로 통과시킵니다. “경로 계산이 한 번 실패했다고 바로 포기하지 않기”가 이 한 노드로 적혀요.

Inverter는 성공과 실패를 맞바꿔요. 조건 노드를 뒤집을 때 쓰는데, “배터리가 부족하지 않으면 진행”을 배터리 부족 조건에 씌워 표현해요.

RateController는 자식을 지정한 주기로만 실행해요. 트리는 10Hz로 돌지만 재계획은 1Hz면 충분할 때 경로 계산 노드를 감쌉니다.

기본 트리 XML 읽기

내비게이션 스택이 기본으로 로드하는 트리의 뼈대예요.

<RecoveryNode number_of_retries="6" name="NavigateRecovery">
  <PipelineSequence name="NavigateWithReplanning">
    <RateController hz="1.0">
      <RecoveryNode number_of_retries="1">
        <ComputePathToPose goal="{goal}" path="{path}"/>
        <ClearEntireCostmap service_name="global_costmap/clear_entirely_global_costmap"/>
      </RecoveryNode>
    </RateController>
    <RecoveryNode number_of_retries="1">
      <FollowPath path="{path}"/>
      <ClearEntireCostmap service_name="local_costmap/clear_entirely_local_costmap"/>
    </RecoveryNode>
  </PipelineSequence>
  <ReactiveFallback name="RecoveryFallback">
    <GoalUpdated/>
    <RoundRobin name="RecoveryActions">
      <Sequence name="ClearingActions">…</Sequence>
      <Spin spin_dist="1.57"/>
      <Wait wait_duration="5"/>
      <BackUp backup_dist="0.30" backup_speed="0.05"/>
    </RoundRobin>
  </ReactiveFallback>
</RecoveryNode>

위에서부터 읽어요. 바깥의 RecoveryNode는 첫째 자식인 본작업이 실패하면 둘째 자식인 복구를 돌린 뒤 본작업을 다시 시도해요. 여섯 번까지요. 그러니 트리 전체가 “항법을 하되 실패하면 복구하고 다시”라는 한 문장이에요.

본작업 가지는 재계획과 추종을 함께 돌려요. RateController가 재계획을 1Hz로 묶고 추종은 매 틱 돌아요. PipelineSequence는 뒤 자식이 진행 중인 동안에도 앞 자식을 계속 실행하는 변형이라, “달리면서 1초마다 경로를 갱신”이 이 조합으로 표현됩니다. 안쪽의 작은 RecoveryNode들은 각 단계 전용 1차 복구예요. 그 단계가 쓰는 지도를 비우고 한 번 더 시도합니다.

복구 가지는 목표 갱신 조건을 먼저 봐요. 복구 도중 새 목표가 들어오면 복구를 중단하고 본작업으로 돌아가기 위해서예요. RoundRobin은 실행될 때마다 다음 자식으로 넘어가는 제어 노드라, 실패가 반복되면 지도 비우기 → 제자리 회전 → 대기 → 후진을 돌아가며 시도해요. 회전과 후진이 복구인 이유는 센서를 새 각도로 돌려 지도를 갱신하고, 끼인 자리에서 물리적으로 빠져나오기 위해서고요.

중괄호로 감싼 이름은 노드들이 공유하는 키-값 저장소예요. 경로 계산 노드가 결과를 쓰고 추종 노드가 읽는 데이터 통로입니다.

그리고 이 트리는 코드가 아니라 파라미터예요. 다른 XML 파일을 지정하면 재컴파일 없이 시나리오가 통째로 바뀌고, 목표마다 다른 트리를 쓸 수도 있어요. XML의 노드 이름 하나하나가 로드되는 플러그인이라, C++ 인터페이스와 가상 함수 — 플러그인이 로드되는 원리의 구조가 여기서도 반복됩니다.

조건을 어디에 꽂느냐가 설계의 거의 전부

설계 질문 하나로 정리할게요. “무엇을 하고 있든 배터리가 부족해지면 도킹으로 복귀”를 트리 어디에 넣어야 할까요. 답은 뿌리 가까이, 본작업의 왼쪽이에요. 우선순위가 곧 위치니까요.

<ReactiveFallback name="BatteryGate">
  <Sequence>
    <Inverter><IsBatteryLow min_battery="0.20"/></Inverter>
    <!-- 원래의 항법 서브트리 -->
  </Sequence>
  <!-- 도킹 스테이션으로 항법하는 서브트리 -->
</ReactiveFallback>

배터리가 충분한 동안은 조건이 성공이라 항법으로 진행하고 트리는 진행 중이에요. 여기서 반응형이라는 게 핵심입니다. 진행 중에도 매 틱 처음부터 재평가하니, 배터리가 떨어지는 순간 조건이 뒤집히고 진행 중이던 항법이 중단되고 도킹 복귀로 넘어가요.

같은 조건을 기억형 Sequence 안쪽 깊숙이 꽂으면 어떻게 될까요. 이미 지나간 조건은 다시 실행되지 않으니, 항법이 진행 중인 동안 배터리가 바닥나도 트리는 모른 채 계속 달려요. 조건을 어디에 꽂느냐가 곧 언제 다시 검사되느냐이고, 이것이 시나리오 설계의 거의 전부예요.

같은 틀이 다른 작업에도 그대로 쓰여요. “인식 실패면 재촬영 후 재시도, 몇 번을 넘기면 사용자에게 보고”가 Retry 데코레이터와 Fallback 가지 몇 개로 적힙니다.

트리가 부리는 플러그인 중 경로를 만드는 쪽은 경로 계획 — 다익스트라와 A*를 손으로에, 그 경로를 따라가는 쪽은 경로 추종의 전방 주시 거리와 이탈 오차의 최적점에 있어요.