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()으로 진행 상황을 알립니다.
실행 콜백이 지켜야 할 규칙은 세 가지입니다.
- 일이 끝나면
goal_handle.succeed()로 목표의 상태를 정합니다. 취소했다면canceled(), 실패했다면abort()입니다. - 결과 객체(
Drive.Result())를 만들어 return합니다. - 상태를 정하지 않고 return하면 그 목표는 중단된 것으로 처리되고 경고가 찍힙니다.
아래 코드는 0.5 m/s로 가는 가상 로봇입니다. 0.5초마다 달린 만큼을 더하고 남은 거리를 피드백으로 보냅니다.
액션 서버와 클라이언트 (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(노드, 액션 타입, 액션 이름)으로 만들고 흐름은 세 단계입니다.
send_goal_async(goal, feedback_callback=...)로 목표를 보냅니다. 서비스의call_async처럼 future가 돌아옵니다.- 그 future가 끝나면
ClientGoalHandle이 나옵니다.handle.accepted가True여야 서버가 받아 준 것입니다. handle.get_result_async()로 결과 future를 받아 완료 콜백을 답니다. 결과에는status와result가 들어 있습니다.
피드백은 feedback_callback으로 옵니다. 콜백이 받는 msg에서 msg.feedback.remaining처럼 한 단계 더 들어가 읽는다는 점이 처음에는 헷갈립니다. 피드백 메시지는 목표 ID와 피드백을 감싼 객체입니다.
서버가 준비되기 전에 목표를 보내면 안 되므로 server_is_ready()로 확인하거나 wait_for_server()를 씁니다. 위 코드는 타이머로 0.5초마다 확인합니다.
실행기(executor)가 결과를 바꾼다
서버의 실행 콜백은 일하는 동안 돌아오지 않습니다. 이 사이에 다른 콜백이 실행되어야 클라이언트가 피드백을 받고, 취소 요청이 서버에 닿습니다. 누가 실행되는지는 executor가 정합니다.
| 실행 방식 | sleep 중인 실행 콜백 옆에서 |
|---|---|
rclpy.spin(노드), SingleThreadedExecutor | 아무 콜백도 실행되지 않는다. 스레드가 하나이기 때문 |
MultiThreadedExecutor | 다른 노드의 콜백은 실행된다 |
MultiThreadedExecutor + 서버에 ReentrantCallbackGroup | 같은 서버 노드의 요청 처리(취소 등)도 겹쳐 실행된다 |
한 프로그램에 서버와 클라이언트를 같이 넣고 SingleThreadedExecutor로 돌리면, 서버가 일하는 동안 클라이언트가 멈춰 피드백이 모두 모였다가 서버가 끝난 뒤에 한꺼번에 도착합니다. 앞 실행기에서 확인한 바로 그 모습입니다. 서버와 클라이언트를 터미널 두 개의 별도 프로세스로 실행하면 이 문제가 없습니다. 실제 교육에서는 보통 그렇게 합니다.
서버의 기본 콜백 그룹은 한 번에 콜백 하나만 허용하는 방식(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해야 합니다.
목표 거절과 취소 (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로 상수를 읽습니다.
| 번호 | 상수 | 뜻 |
|---|---|---|
| 4 | GoalStatus.STATUS_SUCCEEDED | 서버가 succeed()를 불렀다 |
| 5 | GoalStatus.STATUS_CANCELED | 취소가 받아들여지고 서버가 canceled()를 불렀다 |
| 6 | GoalStatus.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했다 |
한눈에 정리
.action은---로 목표, 결과, 피드백을 나눈다. 필드 문법은.msg와 같다.- 서버의 실행 콜백은 피드백을 보내고, 상태(
succeed,canceled,abort)를 정한 뒤 결과를 return한다. - 클라이언트는
send_goal_async, 수락 확인,get_result_async순서로 진행하고 피드백은 별도 콜백으로 받는다. - 취소는 서버의
cancel_callback이 받아 주고, 실행 콜백이is_cancel_requested를 확인해 직접 끝내야 한다. - 한 프로그램에서 서버와 클라이언트를 같이 돌리려면
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장에서 본 프레임 트리를 코드로 직접 만들어 봅니다.