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()
핵심은 네 가지입니다.
ActionServer(노드, 액션 타입, 액션 이름, execute_callback)로 만듭니다. 이름과 타입이 클라이언트와 같아야 연결됩니다.execute_callback은 목표가 수락된 뒤 호출되며, 결과 메시지를 반환해야 합니다.- 실행 중
goal_handle.publish_feedback()으로 피드백을 보냅니다. - 끝날 때
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() |
한눈에 정리
- 액션은 오래 걸리는 작업에 쓰는 통신이다. 목표를 보내면 진행 상황(피드백)을 받고 마지막에 결과를 받는다.
- 서비스와 달리 취소할 수 있고, 토픽과 달리 목표에 대한 수락과 결과가 있다.
- 정의 파일은 목표, 결과, 피드백 순서로
---로 나눠 적는다. - 목표의 종료 상태는
SUCCEEDED,CANCELED,ABORTED중 하나다. ros2 action list,info,send_goal(필요하면--feedback)으로 터미널에서 다룬다.- 서버는
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장에서 액션 서버와 클라이언트를 브라우저에서 돌려 봅니다.