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

2. 시뮬레이션

2.3액션 서버와 클라이언트 작성

1.6장에서 액션의 구조를 봤습니다. 목표를 보내면 서버가 피드백을 주며 일하고, 끝나면 결과를 돌려주고, 중간에 취소할 수도 있습니다. 이 장은 같은 내용을 Python 코드로 짭니다. 2.2장에서 만든 방식 그대로 정의 파일을 쓰고, 서버와 클라이언트 노드 둘이 한 프로그램에서 통신합니다.

.action 파일

.action 파일은 --- 줄 두 개로 세 구역을 나눕니다. 위에서부터 목표, 결과, 피드백 순서입니다.

# robot_interfaces/action/Drive.action
# 목표: 앞으로 갈 거리(m)
float32 distance
---
# 결과: 실제로 간 거리(m)와 성공 여부
float32 travelled
bool success
---
# 피드백: 남은 거리(m)
float32 remaining

필드를 적는 문법은 2.2장의 .msg와 같습니다. 파이썬에서는 Drive.Goal, Drive.Result, Drive.Feedback 세 클래스가 생깁니다.

서버 쓰기

서버는 ActionServer(노드, 액션 타입, 액션 이름, 실행 콜백)으로 만듭니다. 목표가 수락되면 실행 콜백이 호출되고, 이 콜백이 일을 하는 동안 publish_feedback()으로 진행 상황을 알립니다.

실행 콜백이 지켜야 할 규칙은 세 가지입니다.

  1. 일이 끝나면 goal_handle.succeed()로 목표의 상태를 정합니다. 취소했다면 canceled(), 실패했다면 abort()입니다.
  2. 결과 객체(Drive.Result())를 만들어 return합니다.
  3. 상태를 정하지 않고 return하면 그 목표는 중단된 것으로 처리되고 경고가 찍힙니다.

아래 코드는 0.5 m/s로 가는 가상 로봇입니다. 0.5초마다 달린 만큼을 더하고 남은 거리를 피드백으로 보냅니다.

LIVE

액션 서버와 클라이언트 (Drive)

인터페이스 정의 파일 (1개, 읽기 전용)

action/Drive.action

# 목표: 앞으로 갈 거리(m)
float32 distance
---
# 결과: 실제로 간 거리(m)와 성공 여부
float32 travelled
bool success
---
# 피드백: 남은 거리(m)
float32 remaining

실행하면 클라이언트가 0.5초에 서버가 준비된 것을 확인하고 2.0 m 목표를 보냅니다. 서버가 수락해 실행을 시작하고, 0.5초마다 남은 거리 1.75, 1.50, ... 0.00 m가 피드백으로 도착합니다. 4.5초에 결과가 도착하는데 상태 4는 SUCCEEDED입니다. 위 출력 창의 액션 탭에서는 목표 전달, 수락, 피드백, 결과가 시간순으로 한 줄씩 보입니다.

직접 바꿔 보기

  • goal.distance = 2.0을 4.0으로 바꾸면 피드백이 16번 오고 결과는 8.5초에 옵니다.
  • 서버의 time.sleep(0.5)를 1.0으로 바꾸면 피드백 간격이 1초가 됩니다. 이때 done도 같이 바뀌어야 하는지 생각해 보세요. 이 코드는 0.5초분만 더하므로 속도가 절반으로 느려진 효과가 납니다.
  • ReentrantCallbackGroup()이 있는 줄과 MultiThreadedExecutor를 빼고 SingleThreadedExecutor로 바꿔 보세요. 결과 상태는 같지만 피드백이 모두 4.5초에 한꺼번에 도착합니다. 다음 절에서 이유를 설명합니다.

클라이언트 쓰기

클라이언트는 ActionClient(노드, 액션 타입, 액션 이름)으로 만들고 흐름은 세 단계입니다.

  1. send_goal_async(goal, feedback_callback=...)로 목표를 보냅니다. 서비스의 call_async처럼 future가 돌아옵니다.
  2. 그 future가 끝나면 ClientGoalHandle이 나옵니다. handle.accepted가 True여야 서버가 받아 준 것입니다.
  3. handle.get_result_async()로 결과 future를 받아 완료 콜백을 답니다. 결과에는 status와 result가 들어 있습니다.

피드백은 feedback_callback으로 옵니다. 콜백이 받는 msg에서 msg.feedback.remaining처럼 한 단계 더 들어가 읽는다는 점이 처음에는 헷갈립니다. 피드백 메시지는 목표 ID와 피드백을 감싼 객체입니다.

서버가 준비되기 전에 목표를 보내면 안 되므로 server_is_ready()로 확인하거나 wait_for_server()를 씁니다. 위 코드는 타이머로 0.5초마다 확인합니다.

액션 클라이언트의 호출과 서버의 콜백액션 클라이언트 노드액션 서버 노드send_goal_async(goal)goal_callback(goal_request)목표future.result().acceptedACCEPT 또는 REJECT를 return수락 여부feedback_callback(msg)publish_feedback(fb)진행 중 여러 번get_result_async 콜백succeed() 뒤 Result를 return끝날 때 한 번cancel_goal_async()cancel_callback, canceled()취소(선택)
클라이언트가 부르는 함수와 서버가 실행하는 콜백이 짝을 이룹니다. 파란 상자가 클라이언트, 흰 상자가 서버입니다. 점선은 있을 수도 없을 수도 있는 단계입니다.

실행기(executor)가 결과를 바꾼다

서버의 실행 콜백은 일하는 동안 돌아오지 않습니다. 이 사이에 다른 콜백이 실행되어야 클라이언트가 피드백을 받고, 취소 요청이 서버에 닿습니다. 누가 실행되는지는 executor가 정합니다.

실행 방식sleep 중인 실행 콜백 옆에서
rclpy.spin(노드), SingleThreadedExecutor아무 콜백도 실행되지 않는다. 스레드가 하나이기 때문
MultiThreadedExecutor다른 노드의 콜백은 실행된다
MultiThreadedExecutor + 서버에 ReentrantCallbackGroup같은 서버 노드의 요청 처리(취소 등)도 겹쳐 실행된다

한 프로그램에 서버와 클라이언트를 같이 넣고 SingleThreadedExecutor로 돌리면, 서버가 일하는 동안 클라이언트가 멈춰 피드백이 모두 모였다가 서버가 끝난 뒤에 한꺼번에 도착합니다. 앞 실행기에서 확인한 바로 그 모습입니다. 서버와 클라이언트를 터미널 두 개의 별도 프로세스로 실행하면 이 문제가 없습니다. 실제 교육에서는 보통 그렇게 합니다.

실행기에 따른 피드백 도착 시점MultiThreadedExecutor (서버에 ReentrantCallbackGroup)서버 실행 콜백sleep으로 일하는 중클라이언트 피드백SingleThreadedExecutor서버 실행 콜백sleep으로 일하는 중클라이언트 피드백0초1초2초3초4초4.5초단일 스레드는 서버가 끝난 뒤에야 클라이언트 콜백이 실행됩니다
같은 2.0 m 목표를 두 방식으로 돌렸을 때 피드백 8개가 도착하는 시점입니다. 서버가 일하는 4.5초 동안 클라이언트가 멈춰 있으면 피드백이 끝난 뒤 한꺼번에 옵니다.

서버의 기본 콜백 그룹은 한 번에 콜백 하나만 허용하는 방식(MutuallyExclusiveCallbackGroup)입니다. 실행 콜백이 도는 동안 같은 노드에 들어온 취소 요청을 받으려면 서버를 만들 때 callback_group=ReentrantCallbackGroup()을 줘야 합니다. 앞의 두 예제에 이 줄이 들어 있는 이유입니다.

목표 거절과 취소

실습 두 번째는 서버가 목표를 가려 받고, 클라이언트가 중간에 취소하는 흐름입니다. 서버에 콜백 두 개를 더 답니다.

  • goal_callback(goal_request): 목표가 도착했을 때 GoalResponse.ACCEPT 또는 GoalResponse.REJECT를 돌려줍니다. 아래 코드는 5 m를 넘는 목표를 거절합니다.
  • cancel_callback(goal_handle): 취소 요청이 왔을 때 CancelResponse.ACCEPT 또는 REJECT를 돌려줍니다. 이 콜백을 만들지 않으면 기본값은 REJECT라서 취소가 받아들여지지 않습니다.

취소를 받아 준다고 일이 저절로 멈추지는 않습니다. 취소를 받아 주면 goal_handle.is_cancel_requested가 참이 됩니다. 실행 콜백이 이 값을 직접 확인해서 canceled()를 부르고 결과를 return해야 합니다.

LIVE

목표 거절과 취소 (Drive)

인터페이스 정의 파일 (1개, 읽기 전용)

action/Drive.action

# 목표: 앞으로 갈 거리(m)
float32 distance
---
# 결과: 실제로 간 거리(m)와 성공 여부
float32 travelled
bool success
---
# 피드백: 남은 거리(m)
float32 remaining

클라이언트는 목표를 두 개 보냅니다. 8.0 m 목표는 서버가 거절해서 목표가 거절되었습니다가 찍힙니다. 이어 4.0 m 목표는 수락되어 달리다가, 수락 2초 뒤 클라이언트가 취소를 요청합니다. 서버가 취소를 받아 주고(취소 응답 코드 0) 실행 콜백이 canceled()를 불러 결과가 상태 5(CANCELED)로 돌아옵니다. 그때까지 간 거리는 1.0 m입니다.

직접 바꿔 보기

  • on_cancel이 CancelResponse.REJECT를 돌려주게 바꾸면 취소 응답 코드가 1(거절)이 되고 로봇은 4.0 m를 끝까지 달려 9.0초에 상태 4로 끝납니다.
  • 실행 콜백의 if goal_handle.is_cancel_requested: 블록을 지우면 취소를 받아 줬는데도 서버가 계속 달립니다. 취소 응답은 0인데 로봇은 4.0 m를 끝까지 가고 결과는 상태 4(SUCCEEDED)입니다. 취소 확인을 코드에 넣는 일이 서버 작성자 몫이라는 점을 확인하는 실험입니다.
  • on_goal의 <= 5.0을 <= 10.0으로 바꾸면 8.0 m 목표도 수락됩니다. 이 목표도 수락 2초 뒤 취소되어 1.0 m만 가고, 이어 4.0 m 목표가 같은 일을 겪습니다. 어떤 목표를 거절할지는 on_goal의 조건이 정합니다.

결과 상태 번호

결과의 status는 숫자로 옵니다. 코드에서는 from action_msgs.msg import GoalStatus로 상수를 읽습니다.

번호상수뜻
4GoalStatus.STATUS_SUCCEEDED서버가 succeed()를 불렀다
5GoalStatus.STATUS_CANCELED취소가 받아들여지고 서버가 canceled()를 불렀다
6GoalStatus.STATUS_ABORTED서버가 abort()를 불렀거나 상태를 정하지 않고 return했다

실패를 알리려면 succeed() 대신 abort()를 부르고, 사유는 결과의 필드(success, 메시지 등)에 담습니다.

실제로는 어떻게 하나

패키지와 빌드는 2.2장의 인터페이스 패키지와 거의 같습니다. 달라지는 점은 두 가지입니다.

  • 파일은 action/Drive.action에 두고 rosidl_generate_interfaces에 "action/Drive.action"을 적습니다.
  • 액션은 내부적으로 action_msgs를 쓰므로 package.xml에 <depend>action_msgs</depend>를 추가합니다.

빌드한 뒤 서버를 실행하고, 다른 터미널에서 명령으로 목표를 보내 볼 수 있습니다.

ros2 action list
ros2 action info /drive
ros2 action send_goal /drive robot_interfaces/action/Drive "{distance: 2.0}" --feedback

ros2 action list는 실행 중인 액션을, info는 클라이언트와 서버의 수를, send_goal은 목표를 보내고 --feedback을 붙이면 피드백까지 출력합니다. 코드를 짜기 전에 서버를 이 명령으로 먼저 시험하면 클라이언트 버그와 서버 버그를 나눠 볼 수 있습니다.

로봇에서 액션을 가장 많이 만나는 곳은 내비게이션입니다. Nav2는 NavigateToPose 액션으로 목표 위치를 받고 주행 중에 남은 거리를 피드백으로 줍니다(2.12장, 4.7장). 이 장에서 쓴 목표, 피드백, 취소의 구조가 그대로 쓰입니다.

막혔을 때

증상확인할 것
목표를 보냈는데 아무 일도 없다서버와 클라이언트 노드를 같은 executor에 추가했는지, 액션 이름과 타입이 같은지
handle.accepted가 False서버의 goal_callback이 REJECT를 돌려준 것은 아닌지
피드백이 끝난 뒤에 한꺼번에 온다SingleThreadedExecutor나 spin으로 돌린 것은 아닌지. MultiThreadedExecutor나 프로세스 분리를 쓴다
취소를 요청했는데 로봇이 계속 간다cancel_callback이 ACCEPT를 돌려주는지, 실행 콜백이 is_cancel_requested를 확인하는지, 서버에 ReentrantCallbackGroup이 있는지
Goal state not set, assuming aborted 경고실행 콜백이 succeed(), canceled(), abort() 중 하나를 부르지 않고 return했다

한눈에 정리

  1. .action은 ---로 목표, 결과, 피드백을 나눈다. 필드 문법은 .msg와 같다.
  2. 서버의 실행 콜백은 피드백을 보내고, 상태(succeed, canceled, abort)를 정한 뒤 결과를 return한다.
  3. 클라이언트는 send_goal_async, 수락 확인, get_result_async 순서로 진행하고 피드백은 별도 콜백으로 받는다.
  4. 취소는 서버의 cancel_callback이 받아 주고, 실행 콜백이 is_cancel_requested를 확인해 직접 끝내야 한다.
  5. 한 프로그램에서 서버와 클라이언트를 같이 돌리려면 MultiThreadedExecutor와 ReentrantCallbackGroup이 필요하다.

확인 문제

Q1. 액션 서버의 실행 콜백이 `succeed()`나 `canceled()`를 부르지 않고 결과만 return하면 목표의 최종 상태는 무엇인가요?

중단(ABORTED, 번호 6)으로 처리되고 Goal state not set, assuming aborted 경고가 찍힙니다. 결과를 return하기 전에 상태를 직접 정해야 합니다.

Q2. 클라이언트가 취소를 요청했고 서버의 `cancel_callback`도 ACCEPT를 돌려줬습니다. 그런데 로봇은 계속 달립니다. 서버 코드에서 빠진 것은 무엇인가요?

실행 콜백 안에서 goal_handle.is_cancel_requested를 확인해 goal_handle.canceled()를 부르고 return하는 부분입니다. 취소를 받아 주는 것과 일을 멈추는 것은 별개이고, 일을 멈추는 쪽은 실행 콜백이 맡습니다.

Q3. 서버와 클라이언트를 `SingleThreadedExecutor` 하나에 넣었더니 피드백이 모두 끝난 뒤에 한꺼번에 도착합니다. 이유와 해결 방법은?

서버의 실행 콜백이 sleep으로 일하는 동안 스레드가 하나뿐이라 클라이언트의 피드백 콜백이 실행될 기회가 없습니다. MultiThreadedExecutor(서버에는 ReentrantCallbackGroup)를 쓰거나 서버와 클라이언트를 별도 프로세스로 실행합니다.

다음 장

2.4장에서는 좌표계 변환(tf2)을 발행하고 조회하는 노드를 작성합니다. 1.9장에서 본 프레임 트리를 코드로 직접 만들어 봅니다.