학습 목표

  • Topic, Service, Action과 Parameter 중 요구사항에 맞는 Interface를 선택할 수 있다.
  • Goal·Feedback·Result·Cancel과 Goal Handle의 관계를 설명할 수 있다.
  • CLI로 Action을 조사하고 Goal 전송, Feedback 관찰과 Cancel을 수행할 수 있다.
  • Custom .action Interface를 만들고 Python Server·Client에서 사용할 수 있다.
  • Goal 수락·거절, 성공·취소·중단을 구분하여 구현할 수 있다.
  • 동시 Goal과 Preemption 정책을 명시적으로 설계할 수 있다.
  • Executor와 Callback Group이 Action 응답성에 미치는 영향을 설명할 수 있다.
  • 실제 Robot에 Action을 적용할 때 Command 소유권, Watchdog와 독립 Safety 계층을 설계할 수 있다.

1. Action이 필요한 문제

ROS 2 Action은 완료까지 시간이 걸리고, 진행 상황을 알려야 하며, 실행 도중 취소할 수 있어야 하는 작업을 위한 Interface입니다. “몇 초 이상이면 Action”이라는 절대 기준은 없습니다. 시간보다 작업의 의미를 먼저 봅니다.

Action Client                         Action Server
     │ Goal(distance=1.0)                  │
     ├────────────────────────────────────>│
     │ Goal accepted                       │
     │<────────────────────────────────────┤
     │ Feedback(remaining=0.7)             │
     │<────────────────────────────────────┤
     │ Feedback(remaining=0.3)             │
     │<────────────────────────────────────┤
     │ Cancel request                      │
     ├────────────────────────────────────>│
     │ Result(canceled)                    │
     │<────────────────────────────────────┤
요구사항 권장 Interface
연속 Data Stream Topic /scan, /odom, Camera Image
짧은 요청과 한 번의 응답 Service Mode 변경, Controller Reset
진행률·취소가 있는 작업 Action Navigation, Docking, Arm Trajectory
유지되는 설정값 Parameter 최대 속도, Frame 이름

다음 중 하나라도 중요하면 Action을 우선 검토합니다.

  1. 작업 중 Feedback이 필요한가?
  2. 사용자가 중간에 취소할 수 있어야 하는가?
  3. 완료·취소·실패를 최종 상태로 구분해야 하는가?
  4. 하나의 요청이 여러 제어 주기에 걸쳐 실행되는가?

Emergency Stop은 일반 Action Cancel 왕복만으로 구현하지 않습니다. 위험 분석에 따른 독립 안전 회로와 안전 제어 계층이 필요합니다.


2. Action의 다섯 핵심 요소

요소 방향 역할
Goal Client → Server 수행할 목표와 조건
Goal response Server → Client Goal 수락 또는 거절
Feedback Server → Client 실행 중 진행 상황
Result Server → Client 최종 결과 Data
Cancel Client → Server 실행 중단 요청과 수락 여부

Client가 Goal을 보내면 Server는 각 Goal을 구분하는 UUID와 상태를 관리합니다. Client와 Server의 GoalHandle은 특정 Goal의 수락 여부, 상태, 취소와 Result를 다루는 손잡이입니다.

Goal 요청 → ACCEPTED → EXECUTING ─┬→ SUCCEEDED
                                  ├→ CANCELING → CANCELED
                                  └→ ABORTED
Goal 요청 → REJECTED

succeed(), canceled(), abort()는 서로 다른 의미입니다.

  • SUCCEEDED: 목표를 정상 완료했다.
  • CANCELED: 수락한 취소 요청에 따라 안전하게 작업을 끝냈다.
  • ABORTED: 장애물, Sensor 고장, Timeout 등으로 Server가 목표를 완료할 수 없다.
  • REJECTED: 실행을 시작하기 전에 Goal 자체를 받지 않았다.

3. Action 내부 통신을 이해하기

Action은 ROS Graph에서 하나의 이름으로 보이지만 내부적으로 Goal, Result, Cancel을 위한 Service와 Feedback, Status를 위한 Topic을 사용합니다. 사용자는 이 내부 Endpoint를 직접 조합하기보다 Action API를 사용합니다.

ros2 node info /fibonacci_action_server
ros2 action list -t

내부 Topic·Service의 존재는 “Service 여러 개를 직접 호출하면 Action과 같다”는 뜻이 아닙니다. Goal UUID, 상태 전이, Feedback, Cancel과 Result 보관 규칙을 Action Middleware와 Client Library가 일관되게 처리해 줍니다.


4. 준비와 작업 공간

이 실습은 ROS 2가 설치된 Ubuntu Terminal을 기준으로 합니다. 배포판 이름은 환경에 맞게 바꾸십시오.

source /opt/ros/$ROS_DISTRO/setup.bash
printenv ROS_DISTRO
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws

기존 Package를 수정했다면 새 Terminal마다 Overlay도 Source합니다.

source ~/ros2_ws/install/setup.bash

실습 Terminal은 보통 세 개를 사용합니다.

Terminal A: Action Server
Terminal B: Action Client 또는 CLI
Terminal C: Graph·Topic·Log 관찰

5. CLI로 표준 Fibonacci Action 체험하기

5.1 Demo Server 실행

ros2 run action_tutorials_py fibonacci_action_server

다른 Terminal에서 Action 목록과 Type을 확인합니다.

ros2 action list
ros2 action list -t
ros2 action type /fibonacci
ros2 interface show action_tutorials_interfaces/action/Fibonacci

5.2 Goal 전송과 Feedback 보기

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

order가 Goal, 수열 전체가 Result, 계산 도중 커지는 수열이 Feedback입니다. 문법이 헷갈리면 Prototype을 확인합니다.

ros2 interface proto action_tutorials_interfaces/action/Fibonacci
ros2 action info /fibonacci

5.3 Cancel 실험

긴 Goal을 보냅니다.

ros2 action send_goal /fibonacci \
  action_tutorials_interfaces/action/Fibonacci \
  "{order: 1000}" --feedback

실행 중 Ctrl+C를 눌렀을 때 CLI 종료와 Server 측 Goal 취소 처리가 같은지 Server Log로 확인합니다. 실제 Application에서는 아래 Python Client처럼 명시적인 cancel_goal_async()를 사용합니다.


6. Custom Action 설계

이동 Robot이 지정 거리만큼 전진하는 DriveDistance.action을 설계합니다.

# Goal
float32 distance_m
float32 max_speed_mps
---
# Result
float32 traveled_m
string message
---
# Feedback
float32 traveled_m
float32 remaining_m

.action 파일은 첫 번째 --- 위가 Goal, 중간이 Result, 마지막이 Feedback입니다. 상태는 Action Protocol이 이미 제공하므로 success Boolean을 무조건 중복할 필요는 없습니다. Domain 결과가 더 세밀해야 한다면 Error Code를 추가할 수 있습니다.

int32 OK=0
int32 BLOCKED=1
int32 ODOMETRY_LOST=2
int32 error_code
string message

입력 설계 원칙은 다음과 같습니다.

  • 단위를 이름에 넣습니다: distance_m, max_speed_mps.
  • 허용 범위와 음수 의미를 문서화합니다.
  • NaN과 Infinity를 거절합니다.
  • Result에는 실제 달성량과 진단 가능한 이유를 둡니다.
  • Feedback은 너무 빠르게 보내 Network를 포화시키지 않습니다.

7. Interface Package 생성과 Build

cd ~/ros2_ws/src
ros2 pkg create lab_interfaces --build-type ament_cmake
mkdir -p lab_interfaces/action

lab_interfaces/action/DriveDistance.action을 앞 절의 내용으로 저장합니다. CMakeLists.txt에 생성 규칙을 추가합니다.

find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "action/DriveDistance.action"
)

ament_export_dependencies(rosidl_default_runtime)
ament_package()

package.xml에는 다음 의존성을 추가합니다.

<buildtool_depend>ament_cmake</buildtool_depend>
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

Build하고 생성 결과를 확인합니다.

cd ~/ros2_ws
colcon build --packages-select lab_interfaces --symlink-install
source install/setup.bash
ros2 interface show lab_interfaces/action/DriveDistance

Interface를 변경한 뒤에는 Server와 Client를 다시 Build하고 새 Overlay를 Source해야 합니다.


8. Python Package 만들기

cd ~/ros2_ws/src
ros2 pkg create drive_action_py \
  --build-type ament_python \
  --dependencies rclpy lab_interfaces

setup.pyentry_points를 추가합니다.

entry_points={
    'console_scripts': [
        'drive_server = drive_action_py.drive_server:main',
        'drive_client = drive_action_py.drive_client:main',
        'cancel_client = drive_action_py.cancel_client:main',
    ],
},

9. 학습용 Action Server 구현

먼저 Hardware 없이 동작하는 거리 Simulation Server를 만듭니다. asyncio.sleep()을 사용해 Executor가 다른 Callback을 처리할 기회를 줍니다.

# drive_action_py/drive_server.py
import asyncio
import math

import rclpy
from rclpy.action import ActionServer, CancelResponse, GoalResponse
from rclpy.node import Node

from lab_interfaces.action import DriveDistance


class DriveDistanceServer(Node):
    def __init__(self):
        super().__init__('drive_distance_server')
        self._server = ActionServer(
            self,
            DriveDistance,
            'drive_distance',
            execute_callback=self.execute_callback,
            goal_callback=self.goal_callback,
            cancel_callback=self.cancel_callback,
        )

    def goal_callback(self, goal_request):
        distance = float(goal_request.distance_m)
        speed = float(goal_request.max_speed_mps)
        if not math.isfinite(distance) or not math.isfinite(speed):
            self.get_logger().warning('NaN 또는 Infinity Goal 거절')
            return GoalResponse.REJECT
        if abs(distance) > 5.0 or not 0.02 <= speed <= 0.5:
            self.get_logger().warning('허용 범위를 벗어난 Goal 거절')
            return GoalResponse.REJECT
        return GoalResponse.ACCEPT

    def cancel_callback(self, goal_handle):
        self.get_logger().info('Cancel 요청 수락')
        return CancelResponse.ACCEPT

    async def execute_callback(self, goal_handle):
        goal = goal_handle.request
        direction = 1.0 if goal.distance_m >= 0.0 else -1.0
        traveled = 0.0
        dt = 0.1
        feedback = DriveDistance.Feedback()

        while traveled < abs(goal.distance_m):
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                return self.make_result(
                    direction * traveled, '사용자 요청으로 취소됨')

            await asyncio.sleep(dt)
            traveled = min(
                traveled + float(goal.max_speed_mps) * dt,
                abs(float(goal.distance_m)),
            )
            feedback.traveled_m = direction * traveled
            feedback.remaining_m = max(abs(goal.distance_m) - traveled, 0.0)
            goal_handle.publish_feedback(feedback)

        goal_handle.succeed()
        return self.make_result(direction * traveled, '목표 거리 도달')

    @staticmethod
    def make_result(traveled, message):
        result = DriveDistance.Result()
        result.traveled_m = float(traveled)
        result.message = message
        return result

    def destroy_node(self):
        self._server.destroy()
        super().destroy_node()


def main(args=None):
    rclpy.init(args=args)
    node = DriveDistanceServer()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

핵심은 상태 전이 순서입니다.

입력 검증 → Goal 수락 → 반복 실행 → Feedback
                         ├ Cancel 확인 → canceled() → Result
                         ├ 정상 완료 → succeed() → Result
                         └ 실행 불가 → abort() → Result

10. Python Action Client 구현

# drive_action_py/drive_client.py
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node

from lab_interfaces.action import DriveDistance


class DriveDistanceClient(Node):
    def __init__(self):
        super().__init__('drive_distance_client')
        self._client = ActionClient(
            self, DriveDistance, 'drive_distance')

    def send_goal(self, distance, speed):
        if not self._client.wait_for_server(timeout_sec=5.0):
            self.get_logger().error('Action Server 발견 Timeout')
            rclpy.shutdown()
            return

        goal = DriveDistance.Goal()
        goal.distance_m = float(distance)
        goal.max_speed_mps = float(speed)

        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):
        try:
            self._goal_handle = future.result()
        except Exception as exc:
            self.get_logger().error(f'Goal 전송 실패: {exc}')
            rclpy.shutdown()
            return

        if not self._goal_handle.accepted:
            self.get_logger().warning('Goal이 거절되었습니다.')
            rclpy.shutdown()
            return

        self.get_logger().info('Goal 수락됨')
        result_future = self._goal_handle.get_result_async()
        result_future.add_done_callback(self.result_callback)

    def feedback_callback(self, message):
        feedback = message.feedback
        self.get_logger().info(
            f'이동={feedback.traveled_m:.2f} m, '
            f'남음={feedback.remaining_m:.2f} m')

    def result_callback(self, future):
        try:
            wrapped = future.result()
            result = wrapped.result
            self.get_logger().info(
                f'status={wrapped.status}, '
                f'traveled={result.traveled_m:.2f}, '
                f'message={result.message}')
        except Exception as exc:
            self.get_logger().error(f'Result 수신 실패: {exc}')
        finally:
            rclpy.shutdown()


def main(args=None):
    rclpy.init(args=args)
    node = DriveDistanceClient()
    node.send_goal(distance=1.0, speed=0.2)
    try:
        rclpy.spin(node)
    finally:
        node.destroy_node()


if __name__ == '__main__':
    main()

Client의 비동기 흐름을 놓치지 마십시오.

Server 발견 → send_goal_async
                  ↓
            Goal 수락 여부
              ├ 거절 → 종료
              └ 수락 → Feedback 반복 + get_result_async
                                             ↓
                                        최종 Status·Result

11. Build와 실행

cd ~/ros2_ws
colcon build --packages-select lab_interfaces drive_action_py --symlink-install
source install/setup.bash

Terminal A:

source ~/ros2_ws/install/setup.bash
ros2 run drive_action_py drive_server

Terminal B:

source ~/ros2_ws/install/setup.bash
ros2 run drive_action_py drive_client

CLI에서도 같은 Server를 시험합니다.

ros2 action send_goal /drive_distance \
  lab_interfaces/action/DriveDistance \
  "{distance_m: 1.0, max_speed_mps: 0.2}" --feedback

거절 Test도 수행합니다.

ros2 action send_goal /drive_distance \
  lab_interfaces/action/DriveDistance \
  "{distance_m: 100.0, max_speed_mps: 9.0}" --feedback

12. 명시적으로 Cancel하는 Client

Cancel 요청을 보냈다는 사실과 실제로 CANCELED Result를 받은 사실은 다릅니다. Server가 요청을 거절할 수도 있고, 정지 과정 중 Fault가 발생할 수도 있습니다.

# drive_action_py/cancel_client.py
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node

from lab_interfaces.action import DriveDistance


class CancelingClient(Node):
    def __init__(self):
        super().__init__('canceling_drive_client')
        self.client = ActionClient(self, DriveDistance, 'drive_distance')
        self.goal_handle = None
        self.timer = None

    def start(self):
        if not self.client.wait_for_server(timeout_sec=5.0):
            raise RuntimeError('Action Server를 찾지 못했습니다.')
        goal = DriveDistance.Goal()
        goal.distance_m = 3.0
        goal.max_speed_mps = 0.2
        future = self.client.send_goal_async(goal)
        future.add_done_callback(self.on_goal_response)

    def on_goal_response(self, future):
        self.goal_handle = future.result()
        if not self.goal_handle.accepted:
            self.get_logger().warning('Goal 거절')
            rclpy.shutdown()
            return
        result_future = self.goal_handle.get_result_async()
        result_future.add_done_callback(self.on_result)
        self.timer = self.create_timer(2.0, self.request_cancel)

    def request_cancel(self):
        self.timer.cancel()
        self.get_logger().info('Cancel 요청 전송')
        future = self.goal_handle.cancel_goal_async()
        future.add_done_callback(self.on_cancel_response)

    def on_cancel_response(self, future):
        response = future.result()
        if response.goals_canceling:
            self.get_logger().info('Server가 Cancel 요청을 수락함')
        else:
            self.get_logger().warning('Cancel 요청이 수락되지 않음')

    def on_result(self, future):
        wrapped = future.result()
        self.get_logger().info(
            f'최종 status={wrapped.status}, message={wrapped.result.message}')
        rclpy.shutdown()


def main(args=None):
    rclpy.init(args=args)
    node = CancelingClient()
    node.start()
    try:
        rclpy.spin(node)
    finally:
        node.destroy_node()


if __name__ == '__main__':
    main()

실제 Robot Server는 Cancel을 확인한 즉시 최종 Result부터 보내지 말고 먼저 감속·정지 명령을 수행하고 정지 상태를 확인한 뒤 canceled()로 끝내야 합니다.


13. Goal 거절, 동시 Goal과 Preemption 정책

Server는 “새 Goal이 오면 어떻게 할 것인가”를 명확히 정해야 합니다.

정책 동작 적합한 예
Reject new 실행 중이면 새 Goal 거절 Calibration, Firmware Update
Queue 순서대로 대기 비긴급 Batch 작업
Preempt old 기존 Goal 취소 후 새 Goal 실행 Navigation 목적지 변경
Parallel 여러 Goal 동시 실행 독립 자원이 명확한 작업

단일 Robot Base가 두 거리 Goal을 동시에 실행할 수는 없습니다. 가장 단순한 거절 정책은 Lock으로 보호한 active_goal 상태를 두고 goal_callback에서 확인하는 것입니다. 그러나 Goal 수락과 실제 활성화 사이의 Race를 고려해야 하므로 운영용 구현은 handle_accepted_callback에서 Goal 소유권을 원자적으로 관리하는 편이 좋습니다.

from threading import Lock
from rclpy.action import GoalResponse

self._goal_lock = Lock()
self._busy = False

def goal_callback(self, request):
    with self._goal_lock:
        if self._busy:
            return GoalResponse.REJECT
        self._busy = True
    return GoalResponse.ACCEPT

모든 종료 경로에서 finally_busy = False를 복구해야 합니다. 더 복잡한 Preemption은 다음 순서를 지킵니다.

새 Goal 검증 → 기존 Goal Cancel 요청 → 실제 Actuator 정지 확인
            → 새 Goal 소유권 부여 → 새 Goal 실행

단순히 “최신 Goal 변수”만 바꾸면 기존 Callback이 계속 Motor 명령을 발행할 수 있습니다.


14. Executor와 Callback Group

MultiThreadedExecutor가 모든 Action에 무조건 필요한 것은 아닙니다. 핵심은 긴 execute_callback이 Goal, Cancel, Sensor와 Watchdog Callback을 굶기지 않게 만드는 것입니다.

  • async 실행이 자주 양보한다면 Single Thread에서도 응답할 수 있습니다.
  • Blocking I/O나 긴 계산이 있다면 별도 Worker 또는 적절한 Callback Group과 Multi-threading을 검토합니다.
  • Multi-threaded여도 모든 Entity가 같은 Mutually Exclusive Group이면 동시에 실행되지 않을 수 있습니다.
  • Reentrant Group에서는 공유 상태를 Lock이나 Message Passing으로 보호해야 합니다.
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor

self.action_group = ReentrantCallbackGroup()
self.server = ActionServer(
    self,
    DriveDistance,
    'drive_distance',
    execute_callback=self.execute_callback,
    callback_group=self.action_group,
)

executor = MultiThreadedExecutor(num_threads=2)
executor.add_node(node)
executor.spin()

이 Code만 붙이면 안전한 병렬 처리가 완성되는 것은 아닙니다. Goal 소유권, /cmd_vel 발행, Odometry 상태와 종료 Flag에 대한 동시 접근을 함께 설계해야 합니다.


15. 실제 Robot용 DriveDistance 구조

학습용 Server의 traveled += speed × dt는 Command를 적분한 추정일 뿐 실제 이동량이 아닙니다. 실제 Robot에서는 /odom 또는 Localization Pose의 시작점과 현재점을 비교합니다.

import math

def planar_distance(start_x, start_y, current_x, current_y):
    return math.hypot(current_x - start_x, current_y - start_y)

실제 구조는 다음처럼 나눕니다.

Action Server ──> /cmd_vel_action ──> Velocity Mux ──> Safety Filter
                                                          │
/odom ─────────> 진행 거리 계산                           ↓
/scan ─────────> 장애물 조건                         Motor Driver
/estop_state ──> 독립 정지 조건

Navigation, Joystick과 Action Server가 모두 /cmd_vel에 직접 발행하면 마지막으로 도착한 Message가 Motor를 움직여 Command 소유권이 불명확해집니다. Source별 Topic과 Velocity Mux를 사용합니다.

# 개념 예시: 실제 mux Package의 Schema에 맞게 조정
inputs:
  joystick:
    topic: /cmd_vel_joy
    priority: 100
    timeout: 0.3
  navigation:
    topic: /cmd_vel_nav
    priority: 50
    timeout: 0.5
  action:
    topic: /cmd_vel_action
    priority: 40
    timeout: 0.3

Server 반복문의 안전 조건 예시입니다.

if self.estop_active:
    self.publish_stop()
    goal_handle.abort()
    return self.make_result(traveled, '비상정지 상태')

if self.odom_age_sec() > 0.2:
    self.publish_stop()
    goal_handle.abort()
    return self.make_result(traveled, 'Odometry Timeout')

if self.front_clearance_m < self.stop_distance_m:
    self.publish_stop()
    goal_handle.abort()
    return self.make_result(traveled, '전방 장애물')

정상, Cancel, Abort, Exception과 Node 종료 등 모든 경로에서 정지 명령이 나가야 합니다.

try:
    await self.control_until_goal(goal_handle)
finally:
    self.publish_stop()

첫 실험은 바퀴를 바닥에서 띄우거나 Motor Driver를 비활성화한 상태에서 수행합니다. 속도·가속도를 낮게 제한하고 Emergency Stop을 손이 닿는 곳에 두십시오.


16. Timeout, Watchdog와 Server 손실

Action에는 서로 다른 시간이 있습니다.

시간 의미 실패 시 예
Discovery Timeout Server 발견 대기 실행 중단·사용자 알림
Goal response Timeout 수락 여부 대기 통신 상태 진단
Progress Timeout Feedback 또는 진행 변화 없음 Cancel 후 안전 상태 확인
Overall Timeout 전체 임무 제한시간 Cancel·Abort 정책 수행
Command Watchdog 새 Motor 명령이 없는 시간 Driver가 독립적으로 0 명령 적용

Client가 사라졌다고 Server가 자동으로 안전하게 멈춘다고 가정하지 마십시오. Server와 Actuator 계층이 작업 Lease, Command Timeout과 Watchdog를 가져야 합니다. 반대로 Client는 Result Timeout 뒤 Goal이 실행되지 않았다고 단정해서 같은 Goal을 무작정 재전송하면 안 됩니다.


17. Namespace와 Remap

Robot 두 대에 같은 Code를 재사용할 때 Namespace를 사용합니다.

ros2 run drive_action_py drive_server --ros-args -r __ns:=/robot1
ros2 run drive_action_py drive_server --ros-args -r __ns:=/robot2
ros2 action list -t

Client도 같은 Namespace에서 실행하거나 Action 이름을 Remap합니다.

ros2 run drive_action_py drive_client --ros-args \
  -r drive_distance:=/robot1/drive_distance

Code에 절대 이름 /drive_distance를 고정하면 Namespace 재사용이 어려워집니다. 상대 이름 drive_distance를 기본으로 사용합니다.


18. Nav2 Action을 실제로 조사하기

Nav2의 NavigateToPose는 목표 Pose까지 이동하면서 Feedback과 Result를 제공하고 취소할 수 있는 대표적인 Action입니다. Nav2가 실행된 환경에서 확인합니다.

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

CLI Goal 예시는 Map Frame의 Pose와 Quaternion을 정확히 지정해야 합니다.

ros2 action send_goal /navigate_to_pose \
  nav2_msgs/action/NavigateToPose \
  "{pose: {header: {frame_id: map}, pose: {position: {x: 1.0, y: 0.5, z: 0.0}, orientation: {x: 0.0, y: 0.0, z: 0.0, w: 1.0}}}}" \
  --feedback
01실제 주행 전에는 Map·Localization·TF `map
02odom
03base_link`, Costmap와 Sensor 상태를 확인합니다.
ros2 topic echo /amcl_pose --once
ros2 run tf2_ros tf2_echo map base_link
ros2 topic hz /scan
ros2 topic hz /odom

Action의 실제 적용 예는 다음과 같습니다.

  • NavigateToPose: 이동 Robot 목적지 주행
  • FollowJointTrajectory: Manipulator 관절 궤적 추종
  • Docking: 충전 Dock 탐색·접근·접촉·충전 확인
  • Calibration: 여러 Sample을 모으고 중간 진행률과 취소 제공
  • Pick and Place: 접근·파지·이송 단계를 Feedback으로 보고

19. 진단 명령과 증상별 점검

ros2 action list -t
ros2 action info /drive_distance
ros2 node list
ros2 node info /drive_distance_server
ros2 interface show lab_interfaces/action/DriveDistance
ros2 topic echo /rosout
증상 확인 순서
Action이 목록에 없음 Server Process → 이름·Namespace → Domain·Network
Goal이 즉시 거절됨 Goal 범위 → NaN → Busy·Safety Preconditions
Feedback이 없음 execute Callback 진입 → 발행 주기 → Client Callback
Cancel이 늦음 Blocking 작업 → Callback Group → Executor → 정지 절차
Result가 영원히 안 옴 모든 경로의 succeed/canceled/abort와 Result 반환
Robot이 취소 후 움직임 0 명령 발행 → Mux 소유권 → Driver Watchdog
Simulation만 성공 실제 Odometry·TF·Sensor Timestamp와 Command Interface
두 Goal이 충돌 동시 Goal·Preemption 정책과 공유 상태 보호

ros2 action info만으로 Application 내부 상태가 모두 보이지는 않습니다. Goal UUID, 수락, 시작, Feedback, Cancel 요청, 실제 정지, 종료 Status와 소요 시간을 구조화된 Log로 남기십시오.


20. 흔한 실수와 교정

  1. 특정 시간보다 길면 무조건 Action이라고 외운다. Feedback·Cancel·상태 의미로 선택합니다.
  2. Goal 수락을 작업 성공으로 해석한다. 수락은 실행 시작 허가이며 최종 Result가 아닙니다.
  3. Cancel 요청 직후 CANCELED라고 표시한다. 실제 정지 절차 후 종료 상태를 확정합니다.
  4. Exception 경로에서 abort()와 정지를 빠뜨린다. try/finally와 명시적 Result를 둡니다.
  5. Feedback을 제어 주기마다 과도하게 발행한다. UI와 Network에 필요한 속도로 제한합니다.
  6. Command 적분값을 실제 이동 거리라고 믿는다. Odometry나 Localization을 사용합니다.
  7. 여러 Node가 /cmd_vel에 직접 발행한다. Mux와 Command 소유권을 둡니다.
  8. MultiThreadedExecutor만 켜면 해결된다고 생각한다. Callback Group과 공유 상태를 함께 설계합니다.
  9. 새 Goal 정책을 정하지 않는다. 거절·Queue·Preemption·병렬 중 하나를 명시합니다.
  10. Client 종료를 Cancel로 간주한다. 명시적 Cancel과 Server Watchdog를 구현합니다.
  11. Result의 Status를 보지 않고 Data만 읽는다. SUCCEEDED·CANCELED·ABORTED를 먼저 구분합니다.
  12. Action Cancel을 Emergency Stop으로 사용한다. 독립 Safety Architecture를 유지합니다.

21. 실습 체크리스트

  • [ ] Fibonacci Action을 CLI로 조사하고 Feedback을 확인했다.
  • [ ] Goal·Result·Feedback의 .action 순서를 설명할 수 있다.
  • [ ] DriveDistance.action을 생성하고 Build했다.
  • [ ] Python Server와 Client를 실행했다.
  • [ ] 정상 Goal과 범위 밖 Goal을 모두 시험했다.
  • [ ] Cancel 요청 수락과 최종 CANCELED를 구분했다.
  • [ ] Server의 성공·취소·중단 경로를 시험했다.
  • [ ] 동시 Goal 정책을 정하고 검증했다.
  • [ ] Blocking Callback이 Cancel 응답성에 미치는 영향을 확인했다.
  • [ ] Namespace 두 개에서 Action 이름을 분리했다.
  • [ ] 실제 이동량은 Odometry로 계산하도록 설계했다.
  • [ ] Command Mux, Safety Filter와 Driver Watchdog를 검토했다.
  • [ ] 모든 종료 경로가 정지 명령을 보장한다.
  • [ ] Motor 비활성 상태와 Simulation에서 먼저 검증했다.

22. 정리

Action은 단순히 오래 걸리는 Service가 아닙니다. Goal을 수락하거나 거절하고, 실행 중 Feedback을 보내며, Client의 Cancel 요청을 처리하고, 최종적으로 성공·취소·중단 상태와 Result를 전달하는 작업 Protocol입니다.

좋은 Action Server는 입력을 검증하고, 동시 Goal 정책을 명시하며, 모든 종료 경로에서 자원을 안전한 상태로 복구합니다. 좋은 Client는 Server 발견, Goal 수락, Feedback, Cancel 응답과 최종 Status를 각각 구분합니다.

실제 Robot에서는 Action API만으로 안전이 완성되지 않습니다. Odometry와 Sensor 신선도, Command 소유권, Velocity Mux, Driver Watchdog, Emergency Stop과 독립 Safety 계층까지 연결해야 Action이 신뢰할 수 있는 Robot 기능이 됩니다.


ROBOT GLOSSARY

용어 정리

전체 용어 찾아보기 →
Action액션
완료까지 시간이 걸리는 작업에 Goal, Feedback, Result와 Cancel 기능을 제공하는 ROS 2 Interface입니다.
Action Server액션 서버
Goal을 검증·수락하고 작업을 실행하면서 Feedback과 최종 Result를 제공하는 ROS Entity입니다.
Action Client액션 클라이언트
Server에 Goal을 보내고 수락 여부, Feedback, Result를 받거나 Cancel을 요청하는 ROS Entity입니다.
Goal목표
Client가 Action Server에 요청하는 작업 목표와 실행 조건입니다.
Goal Handle목표 핸들
특정 Goal의 수락 여부, 상태, 취소 요청과 Result를 다루는 객체입니다.
Feedback피드백
Action이 실행되는 동안 Server가 Client에 반복적으로 보내는 진행 상황입니다.
Result결과
Action 종료 후 Server가 반환하는 최종 작업 결과 Data입니다.
Cancel취소
실행 중인 Goal을 중단해 달라는 Client 요청과 그 처리 절차입니다.
Goal UUID목표 고유 식별자
동시에 존재할 수 있는 각 Action Goal을 구분하는 고유 식별자입니다.
SUCCEEDED성공 완료
Server가 Goal을 정상적으로 달성하고 Result를 반환한 최종 상태입니다.
CANCELED취소 완료
Server가 Cancel 요청을 수락하고 안전한 중단 절차를 마친 최종 상태입니다.
ABORTED중단
장애물, Sensor 고장이나 Timeout 등으로 Server가 Goal을 완료할 수 없어 종료한 상태입니다.
REJECTED거절
입력 오류, Busy 또는 안전 조건 때문에 Server가 Goal 실행을 시작하지 않은 결과입니다.
Preemption선점
새 Goal을 실행하기 위해 기존 Goal을 취소하거나 중단하고 작업 소유권을 전환하는 정책입니다.
Callback Group콜백 그룹
Executor가 관련 Callback의 상호 배타 또는 재진입 병렬 실행을 결정하는 Group입니다.
Reentrant재진입 가능
같은 Group의 Callback 또는 같은 Callback의 여러 실행을 병렬로 허용하는 실행 규칙입니다.
Watchdog감시 타이머
명령이나 상태 갱신이 제한시간 안에 오지 않으면 독립적으로 안전 동작을 수행하는 기능입니다.
Velocity Mux속도 명령 선택기
여러 속도 명령 Source에서 Priority와 Timeout에 따라 하나의 유효한 명령을 선택하는 구성 요소입니다.
NavigateToPose목표 자세 이동
Nav2에서 Map 좌표의 목표 Pose까지 주행하며 Feedback, Result와 Cancel을 제공하는 Action입니다.
Progress Timeout진행 제한시간
Feedback이나 실제 진행량이 일정 시간 갱신되지 않을 때 Goal 이상으로 판단하는 제한시간입니다.

연습 문제

  1. Topic, Service와 비교하여 Action이 적합한 요구사항 세 가지를 쓰세요.
  2. .action 파일에서 Goal, Result, Feedback의 순서와 구분자를 설명하세요.
  3. Goal이 ACCEPTED되었다는 것과 SUCCEEDED의 차이는 무엇인가요?
  4. REJECTED, CANCELED와 ABORTED가 각각 발생하는 예를 드세요.
  5. ros2 action list -t, type, info, interface show, send_goal --feedback의 역할을 설명하세요.
  6. Cancel 요청이 수락된 직후 바로 canceled()로 끝내면 실제 Robot에서 위험할 수 있는 이유는 무엇인가요?
  7. Server가 Goal의 NaN, Infinity와 범위를 검사해야 하는 이유는 무엇인가요?
  8. 동시 Goal 처리 정책 네 가지와 각각 적합한 예를 쓰세요.
  9. Preemption에서 기존 Goal의 실제 정지를 확인한 뒤 새 Goal을 실행해야 하는 이유는 무엇인가요?
  10. MultiThreadedExecutor가 있어도 Cancel Callback이 실행되지 않을 수 있는 이유는 무엇인가요?
  11. 학습용 속도 × 시간 적분 대신 실제 Robot에서 Odometry를 사용해야 하는 이유는 무엇인가요?
  12. 여러 Node가 /cmd_vel에 직접 발행할 때 생기는 문제와 해결 구조를 설명하세요.
  13. Discovery, Progress, Overall Timeout과 Driver Watchdog의 차이를 설명하세요.
  14. Nav2의 NavigateToPose가 Service보다 Action에 적합한 이유는 무엇인가요?
  15. 취소 후에도 Robot이 움직일 때 Action Callback부터 Motor Driver까지 진단 순서를 설명하세요.

참고 자료