TIKI-MINI로 시작하는 ROS2 실무
목차

1. 이론

1.6액션 (Action)

왜 액션이 필요한가

로봇에게 "저 위치로 이동해"라고 명령한다고 생각해 봅시다. 이 작업은 몇 초에서 몇 분까지 걸립니다. 이때 기존 통신 방식에는 아쉬운 점이 있습니다.

방식특징이동 명령에 쓰면
토픽일방향 스트림, 응답 없음명령이 받아들여졌는지, 끝났는지 알 수 없다
서비스요청 하나에 응답 하나응답이 올 때까지 기다리게 되고 중간 상황을 모르며 중단하기 어렵다
액션목표 → 진행 상황(피드백) → 결과, 취소 가능진행률을 보면서 기다리다가 필요하면 취소할 수 있다

액션은 오래 걸리는 작업을 위한 통신 방식입니다. 공식 문서도 액션이 토픽과 서비스 위에 얹힌 구조이고, 서비스와 비슷하지만 취소할 수 있고 꾸준히 피드백을 준다는 점이 다르다고 설명합니다. 대표적인 사용처는 내비게이션(목표 위치까지 이동), 로봇 팔 동작, 정해진 각도만큼 회전 같은 작업입니다.

액션은 서비스와 비슷하지만 진행 상황을 피드백으로 받을 수 있고 중간에 요청을 중단할 수 있습니다. 아래 애니메이션을 먼저 보세요.

클라이언트가 목표를 보내고 서버가 피드백을 계속 보내다 결과를 돌려주는 액션 애니메이션

목표, 피드백, 결과

액션은 클라이언트-서버 구조입니다. 액션 클라이언트가 목표를 보내고, 액션 서버가 작업을 수행합니다. 인터페이스는 세 부분으로 정의되며 --- 로 구분합니다.

부분방향역할예: Fibonacci
목표 (Goal)클라이언트 → 서버무엇을 할지int32 order
결과 (Result)서버 → 클라이언트작업이 끝났을 때 한 번int32[] sequence
피드백 (Feedback)서버 → 클라이언트작업 중 여러 번, 진행 상황int32[] partial_sequence
# Fibonacci.action (action_tutorials_interfaces)
int32 order
---
int32[] sequence
---
int32[] partial_sequence

파일 안의 순서가 목표, 결과, 피드백이라는 점에 주의하세요. 피드백이 결과보다 먼저 올 것 같지만 정의 파일에서는 마지막에 적습니다. ros2 interface show로 출력해 보면 이 순서대로 나옵니다.

모든 목표에는 고유한 goal ID가 붙습니다. 서버는 이 ID로 여러 목표를 구분하고, 클라이언트는 같은 ID로 특정 목표를 취소합니다.

목표의 상태 변화

서버는 목표마다 상태를 관리합니다. 크게 다음 흐름입니다.

  • 클라이언트가 목표를 보내면 서버가 수락 또는 거절합니다. 거절된 목표는 결과도 없이 끝납니다.
  • 수락된 목표는 ACCEPTED에서 EXECUTING(실행 중)이 됩니다.
  • 실행 중에 클라이언트가 취소를 요청하고 서버가 받아들이면 CANCELING을 거쳐 CANCELED로 끝납니다.
  • 정상 종료는 SUCCEEDED, 서버가 스스로 포기하면 ABORTED입니다.

SUCCEEDED, CANCELED, ABORTED는 더 이상 바뀌지 않는 종료 상태입니다. 서버가 실행 함수에서 상태를 지정하지 않고 끝내면 ABORTED로 처리됩니다.

해보기: 액션의 한 사이클 따라가기

아래 다이어그램은 목표 요청부터 결과까지의 메시지 흐름을 한 단계씩 보여 줍니다. 다음 단계를 눌러 하나씩 진행해 보세요. 피드백이 한두 번 도착한 뒤에 취소 요청 보내기를 눌러 취소 경로가 어떻게 달라지는지도 확인해 보세요.

서버의 목표 상태 UNKNOWN
액션 클라이언트액션 서버

시작 전입니다. 다음 단계 또는 자동 재생을 눌러 보세요.

점선 화살표는 피드백(여러 번 가능), 주황색은 취소 경로입니다. 교육용 모형이며 실제 통신이 아닙니다.

생각해 보기

  • 목표 수락 이후에 클라이언트가 결과를 따로 요청하는 이유는 무엇일까요? (힌트: 액션은 서비스 위에서 동작하고, 결과는 작업이 끝날 때까지 기다렸다가 도착합니다.)
  • 취소를 요청했다고 해서 서버가 즉시 멈추는 것은 아닙니다. 서버 코드에는 어떤 처리가 필요할까요?

터미널에서 액션 다루기

개발환경을 구축한 뒤에는 ros2 action 명령으로 액션을 직접 조회하고 목표를 보낼 수 있습니다. 아래는 공식 튜토리얼에서 쓰는 turtlesim의 예입니다. 지금은 읽어 두기만 하세요.

ros2 action list                # 그래프 안의 액션 이름 목록
ros2 action list -t             # 액션 타입까지 표시
ros2 action info /turtle1/rotate_absolute      # 클라이언트/서버 수와 노드 이름
ros2 interface show turtlesim/action/RotateAbsolute   # 목표/결과/피드백 구조

# 목표 보내기: ros2 action send_goal <액션 이름> <액션 타입> <YAML 값>
ros2 action send_goal /turtle1/rotate_absolute turtlesim/action/RotateAbsolute "{theta: 1.57}"

# --feedback을 붙이면 진행 중 피드백도 출력
ros2 action send_goal /turtle1/rotate_absolute turtlesim/action/RotateAbsolute "{theta: -1.57}" --feedback

send_goal 결과에는 Goal accepted with ID: ..., 결과 값, Goal finished with status: SUCCEEDED 같은 줄이 나옵니다. 어떤 노드가 액션 서버/클라이언트인지는 ros2 node info <노드 이름>의 Action Servers, Action Clients 항목에도 나옵니다.

참고용 코드: rclpy 액션 서버

아래 코드는 이 페이지에서 실행되지 않습니다. 이 장에서는 구조를 읽는 용도로만 보고, 브라우저에서 직접 실행해 보는 것은 2.3장에서 합니다. ROS2 Humble 공식 튜토리얼(피보나치 예제)에 취소 처리를 더한 것이며 실제 실행에는 action_tutorials_interfaces 패키지가 필요합니다.

import time

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

from action_tutorials_interfaces.action import Fibonacci

class FibonacciActionServer(Node):
    def __init__(self):
        super().__init__('fibonacci_action_server')
        self._action_server = ActionServer(
            self,
            Fibonacci,
            'fibonacci',                           # 액션 이름
            execute_callback=self.execute_callback,
            goal_callback=self.goal_callback,      # 생략하면 모든 목표를 수락
            cancel_callback=self.cancel_callback)  # 생략하면 모든 취소를 거절

    def goal_callback(self, goal_request):
        if goal_request.order < 0:
            return GoalResponse.REJECT
        return GoalResponse.ACCEPT

    def cancel_callback(self, goal_handle):
        return CancelResponse.ACCEPT

    def execute_callback(self, goal_handle):
        feedback = Fibonacci.Feedback()
        feedback.partial_sequence = [0, 1]

        for i in range(1, goal_handle.request.order):
            if goal_handle.is_cancel_requested:    # 취소 요청이 왔는지 확인
                goal_handle.canceled()
                result = Fibonacci.Result()
                result.sequence = feedback.partial_sequence
                return result

            feedback.partial_sequence.append(
                feedback.partial_sequence[i] + feedback.partial_sequence[i - 1])
            goal_handle.publish_feedback(feedback)  # 진행 상황 전달
            time.sleep(1)

        goal_handle.succeed()                       # 성공 상태로 표시
        result = Fibonacci.Result()
        result.sequence = feedback.partial_sequence
        return result                               # 결과 메시지는 반드시 반환

def main(args=None):
    rclpy.init(args=args)
    rclpy.spin(FibonacciActionServer())

if __name__ == '__main__':
    main()

핵심은 네 가지입니다.

  1. ActionServer(노드, 액션 타입, 액션 이름, execute_callback)로 만듭니다. 이름과 타입이 클라이언트와 같아야 연결됩니다.
  2. execute_callback은 목표가 수락된 뒤 호출되며, 결과 메시지를 반환해야 합니다.
  3. 실행 중 goal_handle.publish_feedback()으로 피드백을 보냅니다.
  4. 끝날 때 succeed(), 취소되면 canceled()로 상태를 정합니다. 상태를 정하지 않으면 ABORTED로 처리됩니다.

이 예제는 time.sleep을 쓰는 단순한 형태입니다. 실제 로봇 제어에서는 실행 함수가 오래 막히지 않도록 구조를 신경 써야 합니다. 이 부분은 2.3장에서 다룹니다.

참고용 코드: rclpy 액션 클라이언트

import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node

from action_tutorials_interfaces.action import Fibonacci

class FibonacciActionClient(Node):
    def __init__(self):
        super().__init__('fibonacci_action_client')
        self._client = ActionClient(self, Fibonacci, 'fibonacci')

    def send_goal(self, order):
        goal = Fibonacci.Goal()
        goal.order = order

        self._client.wait_for_server()
        future = self._client.send_goal_async(
            goal, feedback_callback=self.on_feedback)
        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().info('목표가 거절되었습니다')
            return
        self.get_logger().info('목표가 수락되었습니다')
        result_future = self._goal_handle.get_result_async()
        result_future.add_done_callback(self.on_result)

    def on_feedback(self, feedback_msg):
        partial = feedback_msg.feedback.partial_sequence
        self.get_logger().info(f'피드백: {list(partial)}')

    def on_result(self, future):
        result = future.result().result
        self.get_logger().info(f'결과: {list(result.sequence)}')
        rclpy.shutdown()

    # 취소하려면 self._goal_handle.cancel_goal_async() 를 호출합니다.

def main(args=None):
    rclpy.init(args=args)
    node = FibonacciActionClient()
    node.send_goal(10)
    rclpy.spin(node)

if __name__ == '__main__':
    main()

클라이언트 쪽 흐름은 앞의 다이어그램과 그대로 대응됩니다.

다이어그램 단계클라이언트 코드
목표 요청send_goal_async(goal, feedback_callback=...)
목표 수락/거절반환된 future의 콜백에서 goal_handle.accepted 확인
결과 요청goal_handle.get_result_async()
피드백feedback_callback 호출
결과결과 future의 콜백에서 future.result().result 사용
취소 요청goal_handle.cancel_goal_async()

한눈에 정리

  1. 액션은 오래 걸리는 작업에 쓰는 통신이다. 목표를 보내면 진행 상황(피드백)을 받고 마지막에 결과를 받는다.
  2. 서비스와 달리 취소할 수 있고, 토픽과 달리 목표에 대한 수락과 결과가 있다.
  3. 정의 파일은 목표, 결과, 피드백 순서로 ---로 나눠 적는다.
  4. 목표의 종료 상태는 SUCCEEDED, CANCELED, ABORTED 중 하나다.
  5. ros2 action list, info, send_goal(필요하면 --feedback)으로 터미널에서 다룬다.
  6. 서버는 ActionServer와 execute_callback, 클라이언트는 ActionClient와 send_goal_async를 쓴다.

확인 문제

Q1. 로봇에게 '좌표 (2, 1)로 이동'을 시키면서 도중에 진행 상황을 보고 싶고, 필요하면 중단하고 싶습니다. 토픽, 서비스, 액션 중 무엇이 적합한가요?

액션입니다. 오래 걸리는 작업이고 피드백으로 진행 상황을 받으며 취소도 할 수 있어야 하기 때문입니다. 서비스는 응답이 하나뿐이고 중간 상황을 줄 수 없습니다.

Q2. 서버의 execute_callback이 goal_handle.succeed()나 canceled()를 호출하지 않고 결과만 반환하면 목표는 어떤 상태가 되나요?

공식 튜토리얼에 따르면 실행 콜백에서 상태를 지정하지 않으면 ABORTED로 처리됩니다. 경고도 함께 출력됩니다. 성공이면 succeed(), 취소면 canceled()를 꼭 호출해야 합니다.

Q3. 클라이언트가 취소를 요청했는데 서버가 계속 실행합니다. 서버 코드에서 확인할 것은 무엇인가요?

두 가지입니다. (1) cancel_callback에서 CancelResponse.ACCEPT를 반환하는지 (생략하면 기본값이 거절입니다). (2) 실행 루프 안에서 goal_handle.is_cancel_requested를 주기적으로 확인하고 canceled()로 끝내는지.

다음 장

1.7장에서는 여러 노드를 한 번에 실행하는 런치 파일을 다룹니다. 이 장의 코드를 직접 실행해 보고 싶다면 2.3장에서 액션 서버와 클라이언트를 브라우저에서 돌려 봅니다.