Skip to main content
Summary

서비스로는 부족한 순간

지난 장에서 우리는 서비스가 '요청 하나, 응답 하나'로 이루어진 통신이라는 것을 배웠다. 서비스는 짧고 분명한 일에 잘 어울린다. 예를 들어 시뮬레이터를 리셋하거나, 로봇의 LED를 켜거나, 현재 배터리 상태를 한 번 물어보는 일은 서비스로 처리하기 좋다.

그런데 로봇에게 이런 명령을 내린다고 생각해 보자.

"저 복도 끝 회의실 앞까지 이동해."

이 명령은 단번에 끝나지 않는다. 로봇은 먼저 현재 위치를 확인하고, 지도를 보고, 경로를 만들고, 장애물을 피하고, 바퀴 속도를 조절하며 한참을 움직인다. 이동 도중 사람이 갑자기 앞을 막을 수도 있고, 사용자가 마음을 바꿔 다른 목적지를 지정할 수도 있다. 이때 클라이언트가 최종 응답만 멍하니 기다리고 있으면 곤란하다. 지금 얼마나 갔는지, 남은 거리는 얼마인지, 취소할 수 있는지 알아야 한다.

이런 일을 위해 ROS2에는 액션Action이 있다. 액션은 오래 걸리는 작업을 위한 통신 방식이며, 목표Goal를 보내고, 실행 중 피드백Feedback을 받고, 마지막에 결과Result를 받는 구조로 동작한다. ROS2 공식 문서에서도 액션을 장시간 실행 작업에 적합한 통신 방식으로 설명한다.1

토픽, 서비스, 액션을 한눈에 비교하면 다음과 같다.

구분토픽서비스액션
통신 방식발행/구독요청/응답목표/피드백/결과
주 용도계속 흐르는 데이터짧은 일회성 요청오래 걸리는 작업
예/scan, /odom, /cmd_vel지도 저장, 상태 조회, 리셋목표 지점 이동, 도킹, 로봇팔 동작
중간 진행 상황없음없음있음
실행 중 취소직접 구현 필요보통 부적합기본 개념에 포함

중요한 기준은 작업 시간만이 아니다. 핵심은 진행 상황을 추적해야 하는가, 중간에 취소할 수 있어야 하는가, 성공과 실패를 명확히 구분해야 하는가이다. 이 세 질문에 '예'라고 답한다면 액션을 떠올리면 된다.

액션으로 오래 걸리는 작업 제어하기

Goal, Feedback, Result

액션은 겉으로 보면 세 가지 메시지로 이루어진다.

Goal은 클라이언트가 서버에게 보내는 목표다. 이동 로봇이라면 목표 위치와 방향이 될 수 있고, 로봇팔이라면 집어야 할 물체의 위치나 관절 목표값이 될 수 있다. 자동 도킹이라면 충전 스테이션 ID나 도킹 방식이 Goal에 들어갈 수 있다.

Feedback은 작업이 진행되는 동안 서버가 클라이언트에게 반복해서 보내는 중간 보고다. 로봇이 이동 중이라면 현재 위치, 남은 거리, 예상 도착 시간, 복구 동작 횟수 같은 값이 피드백이 될 수 있다. 피드백은 '아직 끝나지 않았지만 잘 진행 중인지'를 알려 주는 신호다.

Result는 작업이 끝났을 때 한 번 반환되는 최종 결과다. 성공했는지, 실패했다면 왜 실패했는지, 최종 위치가 어디인지 같은 정보가 들어간다.

배달 로봇을 예로 들어 보자. 관제 시스템이 로봇에게 '3층 회의실 B로 이동'이라는 Goal을 보낸다. 로봇은 이동하면서 '남은 거리 12.4m', '예상 시간 18초', '현재 복도 통과 중' 같은 Feedback을 보낸다. 목적지에 도착하면 '성공, 최종 위치 오차 0.08m'라는 Result를 반환한다. 만약 중간에 엘리베이터 앞이 막혀 더 이상 진행할 수 없다면 실패 Result를 반환할 수 있다.

액션은 그래서 로봇 작업에 매우 자연스럽다. 로봇의 일은 대개 한 번의 함수 호출처럼 즉시 끝나지 않는다. 로봇은 움직이고, 기다리고, 관찰하고, 판단하고, 다시 시도한다. 액션은 이런 시간의 흐름을 통신 모델 안에 담아낸다.

액션의 속살: 세 서비스와 두 토픽

사용자 관점에서 액션은 Goal, Feedback, Result로 보인다. 하지만 내부를 들여다보면 액션은 토픽과 서비스를 조합해서 만들어진다. ROS2 액션 설계 문서에 따르면 액션은 내부적으로 목표 전송 서비스, 결과 조회 서비스, 취소 서비스, 피드백 토픽, 상태 토픽으로 구성된다.2

액션 이름이 /fibonacci라면 내부에는 대략 다음과 같은 통신 경로가 만들어진다.

내부 통신역할방향
/fibonacci/_action/send_goalGoal을 보내고 수락 여부를 받음클라이언트 → 서버
/fibonacci/_action/get_result최종 Result를 요청하고 받음클라이언트 → 서버
/fibonacci/_action/cancel_goal실행 중인 Goal 취소 요청클라이언트 → 서버
/fibonacci/_action/feedback실행 중 Feedback 발행서버 → 클라이언트
/fibonacci/_action/statusGoal 상태 변화 알림서버 → 클라이언트

평소 ros2 topic list나 ros2 service list를 실행하면 이 내부 토픽과 서비스가 보이지 않을 수 있다. ROS2는 액션 내부 구현을 숨겨 액션을 하나의 통신 단위처럼 다룰 수 있게 한다. 필요하면 숨겨진 항목까지 확인할 수 있다.

ros2 topic list --include-hidden-topics
ros2 service list --include-hidden-services

이 구조를 알면 액션이 왜 서비스와 비슷하면서도 다른지 이해된다. Goal을 보내고 Result를 받는 부분은 서비스처럼 요청과 응답을 사용한다. 하지만 중간 Feedback은 토픽처럼 계속 흘러온다. 그리고 별도의 취소 서비스가 있기 때문에 실행 중인 작업을 멈추도록 요청할 수 있다.

액션 서버는 Goal마다 상태 기계를 관리한다. Goal이 수락되면 ACCEPTED 상태가 되고, 실제 실행이 시작되면 EXECUTING 상태가 된다. 취소 요청이 수락되면 CANCELING을 거쳐 CANCELED로 끝날 수 있다. 정상 완료되면 SUCCEEDED, 서버 내부 판단으로 실패하면 ABORTED가 된다. 반대로 애초에 거절된 Goal은 실행 상태 기계에 들어가지 않는다. 이것은 뒤에서 클라이언트 코드로 직접 구분해 볼 것이다.

명령어로 액션 관찰하기

먼저 익숙한 turtlesim으로 액션을 관찰해 보자. 터미널 두 개를 열고 다음 노드를 실행한다.

ros2 run turtlesim turtlesim_node
ros2 run turtlesim turtle_teleop_key

teleop 터미널을 보면 방향키로 거북이를 움직이는 안내와 함께 G, B, V, C, D, E, R, T 키로 절대 방향 회전을 시킬 수 있다는 안내가 나온다. 방향키 이동은 토픽 /turtle1/cmd_vel을 사용하지만, 특정 방향으로 회전하는 동작은 액션으로 처리된다. 회전은 즉시 끝나는 일이 아니고, 도중에 F 키로 취소할 수 있기 때문이다.

현재 ROS 그래프에 있는 액션 목록을 보자.

ros2 action list

출력은 보통 다음과 비슷하다.

/turtle1/rotate_absolute

타입까지 함께 보려면 -t 옵션을 붙인다.

ros2 action list -t

배포판에 따라 타입 이름은 조금 다를 수 있다. 최신 배포판에서는 다음처럼 보일 수 있다.

/turtle1/rotate_absolute [turtlesim_msgs/action/RotateAbsolute]

일부 배포판이나 예전 문서에서는 turtlesim/action/RotateAbsolute로 표시된다. 실습할 때는 반드시 자신의 터미널에 출력된 타입을 그대로 사용하자.

액션 서버와 클라이언트가 어느 노드에 있는지도 확인할 수 있다.

ros2 action info /turtle1/rotate_absolute

또는 노드 정보에서 직접 볼 수도 있다.

ros2 node info /turtlesim
ros2 node info /teleop_turtle

/turtlesim 노드는 /turtle1/rotate_absolute 액션 서버를 제공하고, /teleop_turtle 노드는 그 액션에 Goal을 보내는 클라이언트다.

액션 타입의 구조를 보려면 ros2 interface show를 사용한다.

ros2 interface show turtlesim_msgs/action/RotateAbsolute

출력은 다음과 같은 세 구역으로 나뉜다.

float32 theta
---
float32 delta
---
float32 remaining

첫 번째 구역은 Goal이다. 여기서는 목표 각도 theta를 보낸다. 두 번째 구역은 Result다. delta는 회전 결과를 나타낸다. 세 번째 구역은 Feedback이다. remaining은 목표 각도까지 남은 회전량이다.

명령어로 Goal을 직접 보내 보자.

ros2 action send_goal /turtle1/rotate_absolute turtlesim_msgs/action/RotateAbsolute '{theta: 1.57}'

피드백까지 보고 싶다면 --feedback을 붙인다.

ros2 action send_goal /turtle1/rotate_absolute turtlesim_msgs/action/RotateAbsolute '{theta: -1.57}' --feedback

출력에는 Goal이 수락되었는지, Goal ID가 무엇인지, 실행 중 Feedback이 무엇인지, 최종 상태가 SUCCEEDED인지 등이 나타난다. 이 작은 거북이 회전 예제는 액션의 거의 모든 핵심을 담고 있다. 목표를 보내고, 중간 변화를 보고, 완료 결과를 확인한다.

Python으로 액션 서버 만들기

이번에는 직접 액션 서버와 클라이언트를 만들어 보자. 예제로는 ROS2 튜토리얼에서 자주 사용하는 Fibonacci 액션을 사용한다. 피보나치 수열 계산은 로봇 이동은 아니지만, 액션 흐름을 연습하기에 좋다. 숫자를 하나씩 계산하면서 중간 수열을 Feedback으로 보내고, 마지막 수열을 Result로 반환할 수 있기 때문이다.

먼저 예제 패키지를 만든다.

cd ~/ros2_ws/src
ros2 pkg create robot_action_examples --build-type ament_python --dependencies rclpy action_tutorials_interfaces action_msgs

시스템에 action_tutorials_interfaces가 없다면 설치한다.

sudo apt install ros-$ROS_DISTRO-action-tutorials-interfaces

action_tutorials_interfaces/action/Fibonacci 구조는 다음과 같다.

int32 order
---
int32[] sequence
---
int32[] partial_sequence

Goal은 만들고 싶은 수열의 길이 order다. Result는 최종 수열 sequence다. Feedback은 계산 중인 부분 수열 partial_sequence다.

이제 robot_action_examples/robot_action_examples/fibonacci_server.py 파일을 만든다.

import time

import rclpy
from action_tutorials_interfaces.action import Fibonacci
from rclpy.action import ActionServer, CancelResponse, GoalResponse
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node


class FibonacciServer(Node):
    def __init__(self):
        super().__init__('fibonacci_server')
        self._server = ActionServer(
            self,
            Fibonacci,
            'fibonacci',
            execute_callback=self.execute_callback,
            goal_callback=self.goal_callback,
            cancel_callback=self.cancel_callback,
            callback_group=ReentrantCallbackGroup(),
        )
        self.get_logger().info('fibonacci action server ready')

    def goal_callback(self, goal_request):
        order = goal_request.order

        if order < 2 or order > 30:
            self.get_logger().warn(f'goal rejected: order={order}')
            return GoalResponse.REJECT

        self.get_logger().info(f'goal accepted: order={order}')
        return GoalResponse.ACCEPT

    def cancel_callback(self, goal_handle):
        self.get_logger().info('cancel request accepted')
        return CancelResponse.ACCEPT

    def execute_callback(self, goal_handle):
        order = goal_handle.request.order

        feedback = Fibonacci.Feedback()
        feedback.partial_sequence = [0, 1]

        result = Fibonacci.Result()

        for i in range(2, order):
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                result.sequence = feedback.partial_sequence
                self.get_logger().info('goal canceled')
                return result

            if order == 13 and i == 5:
                goal_handle.abort()
                result.sequence = feedback.partial_sequence
                self.get_logger().error('goal aborted: simulated failure')
                return result

            next_value = feedback.partial_sequence[-1] + feedback.partial_sequence[-2]
            feedback.partial_sequence.append(next_value)

            goal_handle.publish_feedback(feedback)
            self.get_logger().info(f'feedback: {feedback.partial_sequence}')
            time.sleep(1.0)

        goal_handle.succeed()
        result.sequence = feedback.partial_sequence
        self.get_logger().info('goal succeeded')
        return result


def main(args=None):
    rclpy.init(args=args)
    node = FibonacciServer()

    executor = MultiThreadedExecutor()
    executor.add_node(node)

    try:
        executor.spin()
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

서버 코드에서 중요한 부분은 네 가지다.

goal_callback은 Goal을 받을지 거절할지 결정한다. 여기서는 order가 2보다 작거나 30보다 크면 거절했다. 이렇게 거절된 Goal은 실행되지 않으며, 클라이언트는 '거절됨'을 즉시 알 수 있다.

cancel_callback은 취소 요청을 받을지 결정한다. 취소 요청은 항상 성공하는 것이 아니다. 서버가 안전상 이유로 취소를 거부할 수도 있다. 예를 들어 로봇팔이 물체를 들고 있는 중이라면 아무 시점에나 멈추면 위험할 수 있다.

execute_callback은 실제 작업을 수행한다. 수열을 하나씩 계산하고, 매 단계마다 publish_feedback()으로 중간 결과를 보낸다.

goal_handle은 현재 Goal의 손잡이 같은 객체다. 서버는 이 객체를 통해 취소 요청이 들어왔는지 확인하고, 성공 succeed(), 실패 abort(), 취소 완료 canceled()를 표시한다.

여기서는 order == 13일 때 일부러 실패시키도록 만들었다. 실전 로봇에서는 경로 계획 실패, 센서 이상, 모터 오류, 안전 정지 같은 상황에서 abort()를 호출할 수 있다.

ReentrantCallbackGroup과 MultiThreadedExecutor를 사용한 것도 눈여겨보자. 긴 작업을 실행하는 동안 취소 요청 같은 다른 콜백을 처리하려면 실행 구조를 신경 써야 한다. 단일 스레드에서 오래 자고 있는 콜백은 취소 요청을 즉시 처리하지 못할 수 있다.

Python으로 액션 클라이언트 만들기

이제 클라이언트를 작성하자. robot_action_examples/robot_action_examples/fibonacci_client.py 파일을 만든다.

import sys

import rclpy
from action_msgs.msg import GoalStatus
from action_tutorials_interfaces.action import Fibonacci
from rclpy.action import ActionClient
from rclpy.node import Node


class FibonacciClient(Node):
    def __init__(self):
        super().__init__('fibonacci_client')
        self._client = ActionClient(self, Fibonacci, 'fibonacci')
        self._goal_handle = None
        self._cancel_timer = None
        self._cancel_after_sec = None

    def send_goal(self, order, cancel_after_sec=None):
        self._cancel_after_sec = cancel_after_sec

        goal = Fibonacci.Goal()
        goal.order = order

        self.get_logger().info('waiting for action server...')
        self._client.wait_for_server()

        self.get_logger().info(f'sending goal: order={order}')
        future = self._client.send_goal_async(
            goal,
            feedback_callback=self.feedback_callback,
        )
        future.add_done_callback(self.goal_response_callback)

    def goal_response_callback(self, future):
        self._goal_handle = future.result()

        if not self._goal_handle.accepted:
            self.get_logger().warn('goal rejected by server')
            rclpy.shutdown()
            return

        self.get_logger().info('goal accepted by server')

        if self._cancel_after_sec is not None:
            self._cancel_timer = self.create_timer(
                self._cancel_after_sec,
                self.cancel_goal,
            )

        result_future = self._goal_handle.get_result_async()
        result_future.add_done_callback(self.result_callback)

    def feedback_callback(self, msg):
        feedback = msg.feedback
        self.get_logger().info(
            f'feedback received: {feedback.partial_sequence}'
        )

    def cancel_goal(self):
        if self._cancel_timer is not None:
            self._cancel_timer.cancel()

        if self._goal_handle is None:
            return

        self.get_logger().warn('requesting goal cancel')
        future = self._goal_handle.cancel_goal_async()
        future.add_done_callback(self.cancel_done_callback)

    def cancel_done_callback(self, future):
        response = future.result()

        if len(response.goals_canceling) > 0:
            self.get_logger().info('cancel request accepted')
        else:
            self.get_logger().warn('cancel request rejected or goal already finished')

    def result_callback(self, future):
        response = future.result()
        status = response.status
        sequence = response.result.sequence

        if status == GoalStatus.STATUS_SUCCEEDED:
            self.get_logger().info(f'succeeded: {sequence}')
        elif status == GoalStatus.STATUS_CANCELED:
            self.get_logger().warn(f'canceled: {sequence}')
        elif status == GoalStatus.STATUS_ABORTED:
            self.get_logger().error(f'aborted: {sequence}')
        else:
            self.get_logger().warn(f'finished with status={status}: {sequence}')

        rclpy.shutdown()


def main(args=None):
    rclpy.init(args=args)
    node = FibonacciClient()

    order = int(sys.argv[1]) if len(sys.argv) >= 2 else 10
    cancel_after = float(sys.argv[2]) if len(sys.argv) >= 3 else None

    node.send_goal(order, cancel_after_sec=cancel_after)
    rclpy.spin(node)


if __name__ == '__main__':
    main()

클라이언트 코드의 흐름은 액션을 이해하는 데 아주 중요하다.

먼저 wait_for_server()로 액션 서버가 준비될 때까지 기다린다. 서버가 없는 상태에서 목표를 보내면 아무 일도 일어나지 않는다. 그다음 send_goal_async()로 Goal을 보낸다. 여기서 바로 Result가 오는 것이 아니다. 먼저 서버가 Goal을 수락했는지 거절했는지 응답한다.

서버가 Goal을 수락하면 goal_handle을 얻는다. 클라이언트는 이 goal handle을 통해 나중에 Result를 요청하고, 필요하면 취소도 요청한다. 서버가 Goal을 거절하면 실행은 시작되지 않으므로 Result를 기다릴 필요가 없다.

Feedback은 feedback_callback으로 들어온다. 이 콜백은 작업이 끝날 때까지 여러 번 호출될 수 있다. 이동 로봇이라면 이곳에서 남은 거리나 현재 위치를 화면에 표시할 수 있다.

마지막으로 result_callback에서 상태값을 확인한다. STATUS_SUCCEEDED, STATUS_CANCELED, STATUS_ABORTED를 구분해야 한다. 단순히 Result 메시지만 보고 성공했다고 판단하면 안 된다. 실패한 작업도 Result 메시지를 반환할 수 있기 때문이다.

setup.py의 entry_points에는 실행 명령을 등록한다.

entry_points={
    'console_scripts': [
        'fibonacci_server = robot_action_examples.fibonacci_server:main',
        'fibonacci_client = robot_action_examples.fibonacci_client:main',
    ],
},

빌드하고 환경을 다시 불러온다.

cd ~/ros2_ws
colcon build --packages-select robot_action_examples
source install/setup.bash

서버를 실행한다.

ros2 run robot_action_examples fibonacci_server

다른 터미널에서 정상 성공 Goal을 보내 보자.

ros2 run robot_action_examples fibonacci_client 10

거절 상황도 확인한다.

ros2 run robot_action_examples fibonacci_client 1

서버의 goal_callback에서 order < 2를 거절했으므로 클라이언트에는 goal rejected by server가 출력된다.

실패 상황도 확인한다.

ros2 run robot_action_examples fibonacci_client 13

서버 코드에서 order == 13이면 일부러 abort()를 호출했다. 클라이언트는 STATUS_ABORTED를 받아 실패로 처리한다.

마지막으로 취소를 확인한다. 두 번째 인자로 취소까지 기다릴 초를 넣는다.

ros2 run robot_action_examples fibonacci_client 20 3

클라이언트는 3초 뒤 취소 요청을 보낸다. 서버가 취소를 수락하면 실행 중이던 Goal은 STATUS_CANCELED로 끝난다.

같은 액션 서버에 CLI로도 Goal을 보낼 수 있다.

ros2 action send_goal /fibonacci action_tutorials_interfaces/action/Fibonacci '{order: 10}' --feedback

이제 액션의 전체 흐름이 보인다. Goal을 보낸다. 서버가 수락하거나 거절한다. 수락되면 실행 중 Feedback이 반복된다. 중간에 취소할 수 있다. 끝나면 Result와 상태값을 확인한다.

목표 취소, 거절, 실패, 성공을 구분하기

로봇 시스템에서 이 네 가지를 구분하는 일은 생각보다 중요하다.

Goal 거절은 시작도 하지 않은 것이다. 예를 들어 목표 지점이 지도 밖에 있거나, 로봇이 현재 비상 정지 상태라면 서버는 Goal을 거절할 수 있다. 이때 클라이언트는 다른 Goal을 만들거나 사용자에게 즉시 오류를 알려야 한다.

취소는 클라이언트가 멈추라고 요청한 것이다. 사용자가 목적지를 바꾸었거나, 상위 행동 계획기가 현재 작업을 중단하기로 판단했을 수 있다. 취소는 실패와 다르다. 서버가 할 수 없어서 실패한 것이 아니라, 외부 요청에 따라 멈춘 것이다.

실패는 서버가 실행 중 문제를 만나 작업을 끝까지 수행하지 못한 것이다. 자율주행에서는 경로를 찾지 못했거나, 장애물 때문에 오래 갇혔거나, 위치 추정이 불안정해졌을 때 실패할 수 있다.

성공은 서버가 목표를 달성한 것이다. 하지만 성공도 완벽함을 의미하지는 않는다. 이동 로봇은 허용 오차 안에 들어오면 성공으로 판정할 수 있다. 예를 들어 목표점에서 5cm 이내, 목표 방향에서 5도 이내라면 성공으로 볼 수 있다.

그래서 클라이언트는 늘 다음 순서로 판단하는 습관을 들이면 좋다. 먼저 Goal이 수락되었는지 본다. 실행 중 Feedback을 통해 진행 상황을 감시한다. 필요하면 cancel을 요청한다. 마지막으로 Result의 상태값을 읽고 성공, 취소, 실패를 구분한다.

이 책의 뒤쪽에서 우리는 Nav2를 사용해 로봇을 목표 지점까지 자율 이동시킬 것이다. Nav2는 ROS2의 대표적인 내비게이션 프레임워크이며, 위치 추정, 경로 계획, 제어, 장애물 회피, 행동 트리 등을 조합해 모바일 로봇의 자율주행을 구성한다.3

Nav2에서 가장 자주 만나는 액션 중 하나가 NavigateToPose다. 이름 그대로 특정 자세Pose까지 이동하라는 액션이다. 여기서 자세는 위치뿐 아니라 방향까지 포함한다.

보통 액션 이름은 다음과 같다.

/navigate_to_pose

로봇에 네임스페이스가 붙어 있으면 /robot1/navigate_to_pose처럼 보일 수 있다. 먼저 목록과 정보를 확인한다.

ros2 action list -t
ros2 action info /navigate_to_pose
ros2 interface show nav2_msgs/action/NavigateToPose

NavigateToPose 액션의 Goal에는 geometry_msgs/PoseStamped 형식의 목표 자세와 선택적인 Behavior Tree 경로가 들어간다. Feedback에는 현재 자세, 이동 시간, 예상 남은 시간, 복구 동작 횟수, 남은 거리 같은 값이 포함된다. Result에는 오류 코드와 오류 메시지가 포함된다.4

간단한 목표를 CLI로 보내면 다음과 같다.

ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose '
pose:
  header:
    frame_id: map
  pose:
    position:
      x: 1.0
      y: 2.0
      z: 0.0
    orientation:
      x: 0.0
      y: 0.0
      z: 0.0
      w: 1.0
behavior_tree: ''' --feedback

여기서 frame_id: map은 목표가 지도 좌표계 기준이라는 뜻이다. position의 x, y는 지도 위 목표 위치다. orientation은 쿼터니언Quaternion으로 표현한 방향이다. 쿼터니언은 3차원 회전을 나타내는 방식인데, 평면 이동 로봇에서는 보통 z축 회전, 즉 yaw만 신경 쓰면 된다. 위 예제의 z: 0.0, w: 1.0은 지도 x축 방향을 바라보는 자세라고 생각하면 된다.

이 Goal을 받은 Nav2는 단순히 바퀴에 속도 명령 하나를 보내고 끝내지 않는다. 내부적으로 현재 위치를 확인하고, 지도와 costmap을 사용해 전역 경로를 만들고, controller가 그 경로를 따라가도록 속도 명령을 생성한다. 도중에 장애물이 나타나면 지역 경로를 조정하거나 복구 동작을 실행할 수도 있다. 그래서 NavigateToPose는 서비스가 아니라 액션이어야 한다.

실행 중에는 --feedback 출력으로 남은 거리와 예상 시간 등을 볼 수 있다. 로봇이 목표에 도착하면 SUCCEEDED로 끝난다. 중간에 사용자가 취소하면 CANCELED가 될 수 있고, 경로를 만들 수 없거나 제어가 실패하면 ABORTED가 될 수 있다. 이 상태값은 관제 프로그램이나 상위 행동 계획기가 다음 행동을 결정하는 근거가 된다.

어떤 작업을 액션으로 모델링할까

액션을 남용할 필요는 없다. 모든 명령을 액션으로 만들면 구조가 오히려 복잡해진다. 하지만 다음 조건을 만족한다면 액션이 좋은 선택이다.

  • 실행 시간이 길고, 클라이언트가 기다리는 동안 진행 상황을 알고 싶다.

  • 실행 중 사용자가 취소하거나 다른 목표로 바꿀 가능성이 있다.

  • 성공, 실패, 취소를 명확히 구분해 상위 로직이 후속 행동을 결정해야 한다.

이 기준으로 보면 자율 이동은 전형적인 액션이다. 목표 위치까지 이동하는 동안 남은 거리와 현재 위치를 보고 싶고, 사용자가 목표를 바꿀 수 있으며, 실패하면 복구 동작이나 재시도를 해야 한다.

로봇팔 동작도 액션에 잘 맞는다. 물체를 집기 위해 팔을 움직이는 일은 시간이 걸리고, 궤적 실행 중 충돌 위험이 생기면 중단해야 한다. 최종적으로 물체를 잡았는지, 실패했다면 왜 실패했는지도 알아야 한다.

자동 도킹 역시 액션이 어울린다. 충전 스테이션을 찾고, 정렬하고, 천천히 접근하고, 접촉을 확인하는 과정은 여러 단계로 이루어진다. 도중에 배터리 상태나 도킹 정렬 오차를 Feedback으로 보낼 수 있다.

웨이포인트 순회도 액션으로 모델링하기 좋다. 여러 지점을 차례로 방문하면서 현재 몇 번째 지점에 도착했는지, 다음 목표는 어디인지, 전체 임무가 얼마나 남았는지 알려 줄 수 있다.

반대로 단순히 현재 모드를 바꾸는 일, 파라미터 값을 읽고 쓰는 일, 짧은 계산 결과를 요청하는 일은 서비스가 더 단순하다. 센서 데이터처럼 계속 흐르는 값은 토픽이 맞다. 좋은 ROS2 설계란 멋진 통신 방식을 많이 쓰는 것이 아니라, 작업의 성격에 맞는 방식을 고르는 것이다.

액션 디버깅 습관

액션이 동작하지 않을 때는 다음 순서로 확인하면 좋다.

먼저 액션이 존재하는지 본다.

ros2 action list -t

액션 이름이 보이지 않으면 서버가 실행되지 않았거나 네임스페이스가 다를 수 있다. Nav2라면 lifecycle 상태가 아직 active가 아닐 수도 있다.

다음으로 액션 서버와 클라이언트를 확인한다.

ros2 action info /navigate_to_pose

서버 수가 0이면 Goal을 받을 노드가 없는 것이다. 클라이언트 수가 0인 것은 문제일 수도 있고 아닐 수도 있다. 아직 Goal을 보내는 노드가 없을 뿐일 수 있다.

타입 구조를 확인한다.

ros2 interface show nav2_msgs/action/NavigateToPose

Goal 필드 이름을 틀리면 CLI에서 Goal을 보낼 수 없다. 특히 YAML 형식은 들여쓰기와 중괄호 표현에 민감하므로 천천히 확인해야 한다.

직접 Goal을 보내 본다.

ros2 action send_goal /fibonacci action_tutorials_interfaces/action/Fibonacci '{order: 10}' --feedback

Feedback이 오면 서버가 실행 중이라는 뜻이다. Goal이 거절되면 서버의 Goal 수락 조건을 확인해야 한다. Goal은 수락되지만 Result가 실패라면 실행 중 조건, 센서 입력, 내부 알고리즘 상태를 봐야 한다.

필요하면 내부 토픽과 서비스를 확인한다.

ros2 topic list --include-hidden-topics
ros2 service list --include-hidden-services

액션은 하나의 이름으로 보이지만 내부적으로 여러 통신 경로를 사용한다는 사실을 기억하면 디버깅 시야가 넓어진다.

액션은 로봇의 '작업 계약서'다

토픽은 흐르는 데이터다. 서비스는 짧은 질문과 답변이다. 액션은 조금 다르다. 액션은 클라이언트와 서버가 맺는 작업 계약에 가깝다.

클라이언트는 '이 목표를 수행해 달라'고 요청한다. 서버는 '받겠다' 또는 '받을 수 없다'고 답한다. 받은 뒤에는 진행 상황을 보고하고, 끝났을 때 결과를 돌려준다. 중간에 상황이 바뀌면 취소할 수도 있다. 이 흐름은 사람이 로봇에게 일을 맡기는 방식과 닮아 있다.

자율주행 로봇을 만들수록 액션은 더 자주 등장한다. 목표 지점 이동, 경로 추종, 복구 행동, 웨이포인트 주행, 도킹, 로봇팔 동작은 모두 시간이 걸리고 상태가 변하는 작업이다. 이제 우리는 그런 작업을 ROS2에서 어떻게 표현해야 하는지 알게 되었다.

다음 장에서는 파라미터와 Launch를 다룬다. 지금까지 노드, 토픽, 서비스, 액션을 하나씩 배웠다면, 이제 여러 노드를 한 번에 실행하고 설정값을 분리해 실제 프로젝트처럼 구성하는 방법으로 넘어갈 차례다.

참고

1Understanding actions — ROS 2 Documentation

2ROS 2 Design: Actions

3Nav2 Documentation

4Nav2 ROS Actions — Clearpath Robotics Documentation

책 목록

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

전체 목차 보기

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

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

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

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

참여하기