Devin.KR

액션 - 오래 걸리는 작업과 피드백·취소

개발자KR 조회 5

이 장에서 배우는 것

앞 장에서 두리는 센서가 측정한 위치를 필요한 시각의 좌표계로 옮겼다. 이제 두리에게 “목적지까지 이동하라”는 일을 맡길 차례다. 이 명령은 좌표 하나를 계산하는 일과 다르다. 완료까지 시간이 걸리고, 이동 중에 진행 상황을 확인해야 하며, 보행자가 진로를 막으면 작업을 취소할 수도 있어야 한다.

액션(action)은 이렇게 오래 걸리는 작업을 요청하고 추적하는 통신 방식이다. 요청을 접수했다는 응답과 작업을 끝냈다는 응답을 구분하고, 그 사이에 진행 상황을 전달한다. 이 장에서는 실제 모터를 구동하지 않고 배송 경로를 여러 구간으로 나누어 처리한다. 이 작은 모형으로 통신과 상태 전이의 책임을 먼저 익힌다.

  • 액션의 목표, 피드백, 결과가 서로 다른 역할을 맡는 이유를 설명한다.
  • 사용자 정의 액션 형식으로 서버와 클라이언트를 작성한다.
  • 취소 요청의 접수와 실제 작업 종료를 구분한다.
  • 상태 기계(state machine)로 성공·취소·실패 경로를 추적한다.
  • ROS가 없는 환경에서도 순수 Python으로 상태 전이를 재현한다.

문제 상황

두리가 관리실에서 정문까지 물품을 운반한다고 하자. 관제 프로그램이 서비스로 배송을 요청하고 응답이 올 때까지 기다리게 만들면, 응답을 기다리는 동안 두리가 어디까지 갔는지 알기 어렵다. 반대로 서비스가 곧바로 “접수했다”고 응답하게 만들면, 이후 완료 여부와 중단 요청을 별도의 토픽이나 서비스로 연결해야 한다. 배송이 여러 번 반복되면 어느 진행 보고가 어느 요청에 속하는지도 관리해야 한다.

액션은 이 관계를 하나의 목표에 묶는다. 클라이언트는 목표를 보내고, 서버는 수락 여부를 결정한다. 수락한 작업을 실행하는 동안 서버는 피드백(feedback)을 보낸다. 실행이 끝나면 클라이언트는 결과(result)와 종료 상태를 받는다. 중간에 작업을 멈추고 싶다면 같은 목표에 대해 취소를 요청한다.

여기서 취소는 통신 메시지를 없애는 명령이 아니다. 서버가 수행 중인 작업을 정리하고 취소 상태로 끝내 달라는 요청이다. 실제 주행에서는 새 속도 명령을 차단하고, 정지 조건을 확인하고, 필요한 자원을 반환하는 과정이 뒤따른다. 이 장의 예제는 구간 처리 사이에서 취소를 확인하는 방식으로 그 경계를 표현한다.

목표·피드백·결과의 역할

목표(goal)는 수행할 작업의 입력이다. 예제에서는 처리할 배송 구간 수를 목표로 삼는다. 피드백은 현재까지 처리한 구간 수이며, 결과에는 최종 처리 수와 설명을 담는다. 피드백은 관측용 중간 보고이므로, 마지막 피드백을 받았다는 사실만으로 성공을 판정하지 않는다. 최종 판정은 결과 응답에 포함된 상태를 확인해서 내린다.

배송 액션에서 주고받는 정보의 역할
정보예제 내용판단할 수 있는 것
목표구간 4개 처리서버에 맡긴 작업의 범위
수락 응답수락 또는 거절작업을 접수했는지 여부
피드백전체 4개 중 2개 완료실행 중 관측한 진행 상황
결과 응답성공 상태, 완료 4개작업이 어떻게 종료되었는지

액션 정의 파일은 세 부분으로 나뉜다. 첫 부분은 목표, 두 번째는 결과, 세 번째는 피드백이다. 각 부분은 ---로 구분한다. 결과 메시지의 설명 문자열과 액션 프로토콜의 종료 상태는 별개다. 설명에 “성공”이라고 적더라도 서버가 성공 상태로 전이하지 않았다면 올바른 성공 처리가 아니다.

목표 수락 이후 피드백과 결과가 전달되며 취소 요청에는 별도의 응답이 따른다

클라이언트가 목표를 보내면 목표 핸들(goal handle)을 얻는다. 이 객체는 수락 여부를 확인하고, 결과를 요청하고, 해당 목표의 취소를 요청하는 창구다. 서버 쪽에도 목표 핸들이 있으며, 서버는 이를 통해 피드백을 발행하고 성공·취소·실패를 기록한다. 두 핸들은 같은 목표를 가리키지만 맡은 역할이 다르다.

액션 이름도 양쪽에서 일치해야 한다. 예제는 /duri/deliver를 사용한다. 그 아래에서 목표 요청, 결과 요청, 취소 요청과 피드백·상태 전달이 함께 작동한다. 일반적인 응용 코드에서는 내부 통신 이름을 직접 구성하지 않고 ActionServer와 ActionClient를 사용한다.

종료 상태와 취소 경계를 설계하기

목표를 수락하면 상태는 ACCEPTED가 되고, 실행을 시작하면 EXECUTING이 된다. 정상적으로 끝나면 SUCCEEDED, 실행을 계속할 수 없어 서버가 중단하면 ABORTED다. 취소를 받아들이면 CANCELING을 거쳐 정리가 끝난 뒤 CANCELED로 종료한다.

거절은 실행 실패와 구별한다. 구간 수가 0이거나 이미 다른 배송을 수행 중이라면 처음부터 목표를 거절할 수 있다. 이때 클라이언트는 수락되지 않은 핸들을 확인하며, 그 목표에 대해 실행 결과를 기다리지 않는다. 수락한 뒤 장치 오류가 발생한 경우에는 실패 상태와 결과를 남긴다.

수락한 목표는 실행과 취소 처리 상태를 거쳐 성공·취소·실패 중 하나로 종료된다

그림은 예제에서 주로 사용하는 전이를 보여 준다. ROS 2에서는 실행 전 수락 상태에서도 취소 처리를 시작할 수 있고, 취소 처리 중에도 상황에 따라 성공이나 실패로 끝날 수 있다. 따라서 취소 응답을 받았다고 최종 상태를 미리 정하면 안 된다. 취소 요청과 완료 처리가 비슷한 시점에 일어나면 서버가 먼저 완료할 수도 있다.

서버의 취소 콜백은 요청을 받아들일지 결정한다. 이 콜백에서 CancelResponse.ACCEPT를 반환하는 것만으로 작업이 끝나지는 않는다. 실행 코드는 is_cancel_requested를 확인하고, 필요한 정리를 수행하고, canceled()를 호출해야 한다. 결과 객체도 반환해야 클라이언트가 종료 내용을 받는다.

예제에서 취소 확인 간격은 구간 하나를 처리하는 시간에 영향을 받는다. 구간 처리에 0.5초가 걸리면 그동안 들어온 요청은 다음 확인 지점에서 발견된다. 여기에 통신과 스케줄링 지연도 더해진다. 실제 제어 장치가 긴 동기 호출 안에서 멈출 수 없다면, 액션에 취소 기능을 붙이는 것만으로 빠른 정지를 보장할 수 없다.

서버와 클라이언트의 실행 구조

서버는 한 번에 배송 하나만 수락한다. 목표 콜백에서 입력 범위를 검사하고, 잠금으로 사용 중 표시를 보호한다. 실행이 끝나면 finally에서 표시를 해제한다. 이는 여러 목표를 처리하는 순서를 정하는 정책이며, 액션 자체가 자동으로 제공하는 제한은 아니다.

배송 실행 함수에는 시간을 흉내 내는 time.sleep()이 들어간다. 이 함수가 실행 중이어도 취소 요청을 처리할 수 있도록 서버는 재진입 가능한 콜백 그룹과 여러 스레드를 사용하는 실행기를 조합한다. 실행 함수 하나가 작업 스레드를 점유하는 동안 다른 스레드가 취소 요청을 처리한다. 이것은 예제의 실행 구조를 성립시키기 위한 선택이며, 실제 장치 제어 코드를 여러 스레드에서 동시에 호출하라는 뜻은 아니다.

클라이언트는 비동기 API를 사용한다. 목표 전송이 끝나면 수락 여부를 확인하고, 수락했다면 결과 요청을 등록한다. 피드백에서 정한 구간 수에 도달하면 취소를 한 번 요청한다. 취소 응답에서는 요청 대상이 취소 처리 목록에 들어갔는지만 저장하고, 프로그램 종료 여부는 결과 콜백에서 결정한다.

피드백과 목표 수락 응답을 응용 코드가 처리하는 순서도 고려해야 한다. 피드백이 먼저 처리되어도 취소 의도를 잃지 않도록 want_cancel에 기록한다. 핸들이 준비되었을 때 다시 확인하면, 특정 콜백 순서에 기대지 않고 같은 동작을 만들 수 있다.

완성 코드

파일은 작업 디렉터리 duri_actions 아래에 둔다. 인터페이스 패키지는 src/duri_interfaces에 넣고, 세 Python 파일은 작업 디렉터리 바로 아래에 둔다. Python 노드는 패키징하지 않고 python3로 실행하므로, 사용자 정의 인터페이스만 빌드하면 된다.

ROS용 코드는 Jazzy의 rclpy API를 기준으로 작성한다. ROS가 없는 환경에서는 action_model.py를 독립적으로 실행한다. 아래 실행 결과는 코드가 내도록 구성한 예상 출력이며, ROS가 설치되지 않은 검증 환경에서 통신 실행을 확인했다는 뜻은 아니다.

src/duri_interfaces/action/Deliver.action

uint32 checkpoints
---
uint32 completed
string detail
---
uint32 completed
uint32 total

src/duri_interfaces/package.xml

<?xml version="1.0"?>
<package format="3">
  <name>duri_interfaces</name>
  <version>0.1.0</version>
  <description>Delivery action interfaces for Duri.</description>
  <maintainer email="author@example.com">Duri Author</maintainer>
  <license>Apache-2.0</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  <buildtool_depend>rosidl_default_generators</buildtool_depend>
  <depend>action_msgs</depend>
  <exec_depend>rosidl_default_runtime</exec_depend>

  <member_of_group>rosidl_interface_packages</member_of_group>
  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

src/duri_interfaces/CMakeLists.txt

cmake_minimum_required(VERSION 3.8)
project(duri_interfaces)

find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)

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

ament_export_dependencies(rosidl_default_runtime)
ament_package()

delivery_server.py

import threading
import time

import rclpy
from rclpy.action import ActionServer, CancelResponse, GoalResponse
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node

from duri_interfaces.action import Deliver


class DeliveryServer(Node):
    def __init__(self):
        super().__init__("duri_delivery_server")
        self._lock = threading.Lock()
        self._busy = False
        self._group = ReentrantCallbackGroup()
        self._server = ActionServer(
            self,
            Deliver,
            "/duri/deliver",
            execute_callback=self.execute,
            goal_callback=self.accept_goal,
            cancel_callback=self.accept_cancel,
            callback_group=self._group,
        )

    def accept_goal(self, request):
        if not 1 <= request.checkpoints <= 20:
            return GoalResponse.REJECT
        with self._lock:
            if self._busy:
                return GoalResponse.REJECT
            self._busy = True
        return GoalResponse.ACCEPT

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

    def finish_canceled(self, goal_handle, result):
        result.detail = "취소 경계에서 정리 완료"
        goal_handle.canceled()
        return result

    def execute(self, goal_handle):
        result = Deliver.Result()
        result.completed = 0
        total = goal_handle.request.checkpoints
        try:
            for step in range(1, total + 1):
                if goal_handle.is_cancel_requested:
                    return self.finish_canceled(goal_handle, result)

                time.sleep(0.5)
                result.completed = step

                if goal_handle.is_cancel_requested:
                    return self.finish_canceled(goal_handle, result)

                feedback = Deliver.Feedback()
                feedback.completed = step
                feedback.total = total
                goal_handle.publish_feedback(feedback)

            if goal_handle.is_cancel_requested:
                return self.finish_canceled(goal_handle, result)

            result.detail = "배송 구간 완료"
            goal_handle.succeed()
            return result
        except Exception as exc:
            result.detail = f"실행 오류: {type(exc).__name__}"
            if goal_handle.is_active:
                goal_handle.abort()
            return result
        finally:
            with self._lock:
                self._busy = False


def main():
    rclpy.init()
    node = DeliveryServer()
    executor = MultiThreadedExecutor(num_threads=4)
    executor.add_node(node)
    try:
        executor.spin()
    except KeyboardInterrupt:
        pass
    finally:
        executor.shutdown()
        node.destroy_node()
        if rclpy.ok():
            rclpy.shutdown()


if __name__ == "__main__":
    main()

delivery_client.py

import argparse

import rclpy
from action_msgs.msg import GoalStatus
from rclpy.action import ActionClient
from rclpy.node import Node

from duri_interfaces.action import Deliver


class DeliveryClient(Node):
    def __init__(self, cancel_after):
        super().__init__("duri_delivery_client")
        self.client = ActionClient(self, Deliver, "/duri/deliver")
        self.cancel_after = cancel_after
        self.goal_handle = None
        self.want_cancel = False
        self.cancel_sent = False
        self.cancel_accepted = None
        self.done = False
        self.exit_code = 0

    def start(self, checkpoints):
        if not self.client.wait_for_server(timeout_sec=5.0):
            print("서버를 찾지 못했다")
            self.exit_code = 1
            self.done = True
            return
        goal = Deliver.Goal()
        goal.checkpoints = checkpoints
        future = self.client.send_goal_async(
            goal, feedback_callback=self.on_feedback
        )
        future.add_done_callback(self.on_goal)

    def fail(self, exc):
        print(f"통신 오류: {type(exc).__name__}")
        self.exit_code = 1
        self.done = True

    def on_goal(self, future):
        try:
            self.goal_handle = future.result()
            if not self.goal_handle.accepted:
                print("목표가 거절되었다")
                self.exit_code = 1
                self.done = True
                return
            result_future = self.goal_handle.get_result_async()
            result_future.add_done_callback(self.on_result)
            self.try_cancel()
        except Exception as exc:
            self.fail(exc)

    def on_feedback(self, message):
        feedback = message.feedback
        if (
            self.cancel_after > 0
            and feedback.completed >= self.cancel_after
        ):
            self.want_cancel = True
            self.try_cancel()

    def try_cancel(self):
        if (
            self.want_cancel
            and self.goal_handle is not None
            and self.goal_handle.accepted
            and not self.cancel_sent
            and not self.done
        ):
            self.cancel_sent = True
            future = self.goal_handle.cancel_goal_async()
            future.add_done_callback(self.on_cancel)

    def on_cancel(self, future):
        try:
            response = future.result()
            self.cancel_accepted = bool(response.goals_canceling)
        except Exception:
            self.cancel_accepted = False

    def on_result(self, future):
        try:
            response = future.result()
            labels = {
                GoalStatus.STATUS_SUCCEEDED: "성공",
                GoalStatus.STATUS_CANCELED: "취소",
                GoalStatus.STATUS_ABORTED: "실패",
            }
            label = labels.get(response.status, "예상하지 못한 상태")
            result = response.result
            print(
                f"상태={label}, 완료={result.completed}, "
                f"설명={result.detail}"
            )
            if response.status not in (
                GoalStatus.STATUS_SUCCEEDED,
                GoalStatus.STATUS_CANCELED,
            ):
                self.exit_code = 1
            self.done = True
        except Exception as exc:
            self.fail(exc)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--checkpoints", type=int, default=4)
    parser.add_argument("--cancel-after", type=int, default=0)
    args = parser.parse_args()
    if not 0 <= args.checkpoints <= 20:
        parser.error("--checkpoints는 0부터 20까지다")
    if not 0 <= args.cancel_after <= args.checkpoints:
        parser.error("--cancel-after는 0부터 구간 수까지다")

    rclpy.init(args=[])
    node = DeliveryClient(args.cancel_after)
    exit_code = 0
    try:
        node.start(args.checkpoints)
        while rclpy.ok() and not node.done:
            rclpy.spin_once(node, timeout_sec=0.1)
        exit_code = node.exit_code
    except KeyboardInterrupt:
        exit_code = 130
    finally:
        node.destroy_node()
        if rclpy.ok():
            rclpy.shutdown()
    return exit_code


if __name__ == "__main__":
    raise SystemExit(main())

action_model.py

from dataclasses import dataclass
from enum import Enum, auto


class State(Enum):
    ACCEPTED = auto()
    EXECUTING = auto()
    CANCELING = auto()
    SUCCEEDED = auto()
    CANCELED = auto()
    ABORTED = auto()


ALLOWED = {
    State.ACCEPTED: {State.EXECUTING, State.CANCELING},
    State.EXECUTING: {
        State.CANCELING,
        State.SUCCEEDED,
        State.ABORTED,
    },
    State.CANCELING: {
        State.CANCELED,
        State.SUCCEEDED,
        State.ABORTED,
    },
    State.SUCCEEDED: set(),
    State.CANCELED: set(),
    State.ABORTED: set(),
}


@dataclass
class Delivery:
    total: int
    completed: int = 0
    state: State = State.ACCEPTED

    def __post_init__(self):
        if self.total < 1:
            raise ValueError("구간 수는 1 이상이어야 한다")

    def transition(self, target):
        if target not in ALLOWED[self.state]:
            raise ValueError(
                f"{self.state.name} -> {target.name}: 허용되지 않음"
            )
        previous = self.state
        self.state = target
        print(f"{previous.name} -> {target.name}")

    def start(self):
        self.transition(State.EXECUTING)

    def advance(self):
        if self.state is not State.EXECUTING:
            raise ValueError("실행 상태에서만 진행할 수 있다")
        self.completed += 1
        print(f"피드백 {self.completed}/{self.total}")
        if self.completed == self.total:
            self.transition(State.SUCCEEDED)

    def request_cancel(self):
        if self.state not in (State.ACCEPTED, State.EXECUTING):
            return False
        self.transition(State.CANCELING)
        return True

    def finish_cancel(self):
        if self.state is not State.CANCELING:
            raise ValueError("취소 처리 상태가 아니다")
        self.transition(State.CANCELED)

    def fail(self):
        if self.state not in (State.EXECUTING, State.CANCELING):
            raise ValueError("실행 또는 취소 처리 상태가 아니다")
        self.transition(State.ABORTED)

    def show_result(self):
        terminal = {
            State.SUCCEEDED,
            State.CANCELED,
            State.ABORTED,
        }
        if self.state not in terminal:
            raise ValueError("아직 결과가 없다")
        print(f"결과 {self.state.name}, 완료={self.completed}")


def main():
    print("[정상 배송]")
    normal = Delivery(3)
    normal.start()
    for _ in range(3):
        normal.advance()
    normal.show_result()

    print("[취소 배송]")
    canceled = Delivery(4)
    canceled.start()
    canceled.advance()
    canceled.advance()
    accepted = canceled.request_cancel()
    print(f"취소 수락={accepted}")
    canceled.finish_cancel()
    canceled.show_result()

    print("[실패 배송]")
    failed = Delivery(3)
    failed.start()
    failed.advance()
    failed.fail()
    failed.show_result()

    print("[종료 후 재실행]")
    try:
        normal.start()
    except ValueError as exc:
        print(exc)


if __name__ == "__main__":
    main()

줄별 해설

인터페이스와 서버

uint32 checkpoints는 목표의 구간 수다. 자료형이 음수를 표현하지 못한다는 사실과 응용 프로그램에서 허용하는 값의 범위는 다르다. 서버는 추가로 1부터 20까지만 허용한다. 결과와 피드백 양쪽의 completed는 같은 의미를 갖지만, 하나는 종료 시점의 기록이고 다른 하나는 실행 중 관측값이다.

ActionServer(...)는 액션 형식, 이름, 실행 콜백을 연결한다. goal_callback은 수락 정책을, cancel_callback은 취소 수락 정책을 담당한다. 기본 목표 수락 처리가 실행 콜백을 시작하므로 별도의 실행 시작 콜백은 등록하지 않는다.

with self._lock: 안에서 사용 중 여부를 검사하고 곧바로 참으로 바꾼다. 검사와 변경을 떨어뜨리면 두 목표 콜백이 모두 빈 서버라고 판단할 수 있다. 이 잠금은 응용 프로그램의 사용 중 표시를 보호하며, ROS의 목표 상태 전체를 잠그는 장치는 아니다.

result.completed = step는 한 구간 처리를 마친 뒤 실행한다. 잠자는 동안 취소가 들어왔더라도 그 구간은 완료한 것으로 기록한다. 따라서 취소를 요청한 피드백이 2였어도 최종 완료 수는 3일 수 있다. 요청 당시의 진행 수와 정지가 끝난 시점의 진행 수를 구분한 것이다.

publish_feedback()는 중간 보고만 보낸다. 루프 뒤의 succeed()가 성공 상태를 기록하고, return result가 결과 내용을 넘긴다. 취소 경로에서도 canceled()와 결과 반환을 함께 수행한다. 예외 경로는 활성 목표를 abort()로 종료한다.

finally는 성공, 취소, 예외에 따른 반환 모두에서 사용 중 표시를 해제한다. 다만 실제 하드웨어를 사용하는 서버라면 이 자리에 표시 해제만 넣어서는 부족하다. 정지 확인과 자원 해제를 먼저 마쳐야 다음 목표가 같은 장치를 안전하게 사용할 수 있다.

클라이언트

wait_for_server(timeout_sec=5.0)는 서버를 발견할 때까지 기다리는 시간에만 제한을 둔다. 목표 수락이나 작업 완료의 제한 시간이 아니다. 이 예제는 서버가 실행 중이라는 조건에서 결과를 기다린다. 운용 프로그램에서는 서버 단절과 결과 대기 시간 초과를 별도로 처리해야 한다.

send_goal_async()의 반환값은 목표 수락 응답을 기다리는 퓨처(future)다. on_goal()은 그 응답에서 핸들을 얻는다. accepted가 거짓이면 거절을 출력하고 끝내며, 참이면 get_result_async()로 최종 결과를 기다린다. 목표 전송 완료와 배송 완료를 같은 사건으로 취급하지 않는 구조다.

message.feedback는 피드백 본문이다. want_cancel은 취소 조건을 만났다는 기록이고, cancel_sent는 이미 요청을 보냈다는 기록이다. 둘을 나누면 핸들이 늦게 준비되어도 의도를 보존하고, 피드백이 여러 번 도착해도 같은 취소를 반복하지 않는다.

response.goals_canceling은 취소 처리 대상으로 응답된 목표들의 목록이다. 이 예제는 단일 목표를 취소하므로 목록이 비었는지 확인한다. 이 값으로 done을 바꾸지는 않는다. on_result()가 받은 response.status와 response.result가 최종 판단의 근거다.

rclpy.init(args=[])는 예제의 명령행 옵션을 ROS 인자로 다시 해석하지 않게 한다. 이 실행 파일은 자체 옵션만 받는 형태다. 또한 클라이언트의 키보드 인터럽트 처리는 프로세스를 종료할 뿐 서버 목표를 취소하지 않는다. 종료와 원격 작업 취소가 같은 동작이라고 가정해서는 안 된다.

순수 Python 상태 모형

State는 상태 이름을 문자열 오타 없이 다루게 한다. ALLOWED는 현재 상태마다 허용하는 다음 상태의 집합이다. 종료 상태의 집합이 비어 있으므로 이미 끝난 목표를 다시 실행하려는 전이는 거절된다. 새 배송을 하려면 새 객체를 만든다.

transition()은 상태 변경을 한곳에 모으고 변경 전후를 출력한다. request_cancel()은 취소 처리 상태로만 옮기며, finish_cancel()이 취소 종료를 담당한다. 두 메서드 사이에 정지 확인 같은 처리가 들어갈 수 있다는 점이 핵심이다.

advance()는 실행 상태에서만 완료 수를 늘린다. 마지막 구간에서는 즉시 성공한다. 따라서 이 모형에서는 마지막 구간 처리 뒤 취소를 요청하면 받아들여지지 않는다. 실제 ROS의 통신 순서를 흉내 내는 모형이 아니라, 명시한 순서대로 상태 규칙을 확인하는 모형이다.

실행 결과

먼저 작업 디렉터리에서 세 Python 파일의 문법을 확인한다. 이 명령은 경고를 오류로 다루며, 문법 문제가 없으면 아무것도 출력하지 않는다. 모듈을 실제로 가져오지는 않으므로 ROS가 없어도 수행할 수 있다. 인터페이스 생성이나 ROS API 연결까지 검사하는 명령은 아니다.

python3 -W error -m py_compile delivery_server.py delivery_client.py action_model.py

순수 Python 보조 예제는 macOS와 Linux에서 ROS 설치 없이 실행한다.

python3 action_model.py

출력은 다음과 같다.

[정상 배송]
ACCEPTED -> EXECUTING
피드백 1/3
피드백 2/3
피드백 3/3
EXECUTING -> SUCCEEDED
결과 SUCCEEDED, 완료=3
[취소 배송]
ACCEPTED -> EXECUTING
피드백 1/4
피드백 2/4
EXECUTING -> CANCELING
취소 수락=True
CANCELING -> CANCELED
결과 CANCELED, 완료=2
[실패 배송]
ACCEPTED -> EXECUTING
피드백 1/3
EXECUTING -> ABORTED
결과 ABORTED, 완료=1
[종료 후 재실행]
SUCCEEDED -> EXECUTING: 허용되지 않음

ROS 예제에는 Jazzy의 rclpy, 인터페이스 생성 도구, colcon이 필요하다. 각 터미널에서 먼저 설치한 Jazzy 환경을 불러온다. Linux의 일반적인 설치 경로에서는 다음 명령을 사용한다. macOS의 소스 빌드 환경에서는 해당 작업 공간의 설정 파일 경로로 바꾼다. Python은 그 ROS 설치와 연결되는 인터프리터를 사용해야 한다.

source /opt/ros/jazzy/setup.bash

작업 디렉터리에서 인터페이스 패키지를 빌드한다. 빌드 도구의 진행 출력과 소요 시간은 환경마다 다르다.

colcon build --packages-select duri_interfaces

첫 번째 터미널에서 생성한 환경을 불러오고 서버를 실행한다. 서버는 요청을 기다리는 동안 별도의 응용 메시지를 출력하지 않는다. 아래는 Bash 명령이며 Zsh에서는 setup.zsh를 사용한다.

source install/setup.bash
python3 delivery_server.py

두 번째 터미널에서도 Jazzy와 생성한 인터페이스 환경을 불러온 뒤 정상 배송을 요청한다. 다른 목표가 실행 중이지 않고 통신 오류가 없다면 출력은 다음과 같다.

source install/setup.bash
python3 delivery_client.py --checkpoints 4
상태=성공, 완료=4, 설명=배송 구간 완료

구간 수 0은 클라이언트의 전송을 허용하지만 서버가 거절하도록 남겨 둔 입력이다. 정상 배송이 끝난 다음 실행하면 수락 정책을 확인할 수 있다.

python3 delivery_client.py --checkpoints 0
목표가 거절되었다

취소 경로는 다음 명령으로 확인한다. 두 번째 구간 이상의 피드백을 처리하면 취소를 요청한다. 출력 형식은 정상 실행과 같지만 종료 상태와 완료 수는 실제 처리 순서에 따라 달라진다. 취소를 처리했다면 상태는 “취소”, 설명은 “취소 경계에서 정리 완료”이며, 완료가 먼저 끝났다면 성공 결과를 받는다. 따라서 고정된 완료 수를 예상 출력으로 제시하지 않는다.

python3 delivery_client.py --checkpoints 4 --cancel-after 2

실무에서 자주 틀리는 것

취소 수락을 취소 완료로 표시한다

다음 코드는 취소 응답을 받자마자 화면이나 프로그램을 종료한다. 서버가 아직 정리 중이어도 사용자는 끝났다고 판단하게 된다.

def on_cancel(self, future):
    future.result()
    self.done = True

취소 응답에는 접수 여부만 기록한다. 종료 처리는 완성 코드의 on_result()처럼 결과 응답에서 수행한다.

def on_cancel(self, future):
    response = future.result()
    self.cancel_accepted = bool(response.goals_canceling)

결과 객체만 반환하고 종료 상태를 남기지 않는다

결과 필드를 채운다고 목표가 성공하는 것은 아니다. 다음처럼 상태 전이 없이 실행 콜백을 끝내면 의도한 성공 처리가 되지 않는다.

result.detail = "배송 구간 완료"
return result

정상 완료 지점에서는 성공 상태를 기록한 뒤 결과를 반환한다. 취소나 오류라면 해당 종료 메서드를 사용한다.

result.detail = "배송 구간 완료"
goal_handle.succeed()
return result

실행 콜백이 취소 요청의 처리 기회를 막는다

다음 실행 구조에서 긴 동기 실행 콜백이 유일한 실행 스레드를 점유하면, 취소 요청 처리가 늦어진다. 실행 함수에 취소 검사문이 있어도 취소 요청 자체가 처리되지 않았다면 그 검사로 발견할 수 없다.

node = DeliveryServer()
rclpy.spin(node)

완성 코드처럼 액션 서버에 ReentrantCallbackGroup을 지정하고 실행기에도 여러 작업 스레드를 둔다. 실행기만 바꾸고 콜백의 동시 실행을 막아 두어도 문제가 남을 수 있으므로 두 설정을 함께 본다.

node = DeliveryServer()
executor = MultiThreadedExecutor(num_threads=4)
executor.add_node(node)
executor.spin()

완료 수만 보고 성공으로 판정한다

완료 수는 작업 데이터다. 마지막 구간을 끝낸 직후 취소를 확인하면 완료 수가 전체와 같아도 취소 상태로 끝날 수 있다. 따라서 다음 판정은 종료 의미를 잃는다.

success = response.result.completed == requested_total

성공 여부는 프로토콜의 종료 상태로 판정하고, 완료 수는 수행량을 설명하는 데 사용한다.

success = response.status == GoalStatus.STATUS_SUCCEEDED
completed = response.result.completed

한눈에 보기

액션 구현에서 각 단계가 맡는 책임
단계서버의 책임클라이언트의 확인
목표 접수입력과 처리 가능 여부 검사핸들의 accepted
실행 중작업 수행과 피드백 발행피드백 본문
취소 접수취소 허용 여부 결정goals_canceling
취소 완료정리 후 canceled()와 결과 반환최종 상태 CANCELED
정상 완료succeed()와 결과 반환최종 상태 SUCCEEDED
실행 실패abort()와 결과 반환최종 상태 ABORTED

피드백은 진행 상황을 설명하고, 결과 응답은 종료를 확정한다. 취소는 그 둘 사이에 끼어드는 협력적 중단 요청이다. 서버의 취소 확인 경계와 정리 절차가 구체적일수록 클라이언트가 받는 상태도 분명해진다.

API의 세부 계약은 Jazzy 액션 서버 API, Jazzy 액션 클라이언트 API, ROS 2 액션 설계에서 확인할 수 있다. 다음 장에서는 작업 하나의 상태에서 범위를 넓혀 노드를 준비하고 활성화하고 정리하는 순서를 다룬다.

연습 문제

  1. 순수 Python 모형에서 구간 수가 2인 배송을 끝까지 수행한 뒤 request_cancel()을 호출하라. 반환값과 최종 상태를 예상하고 확인하라.
  2. 순수 Python 모형에서 두 구간을 완료한 뒤 취소를 수락하고, 정리 과정의 오류를 fail()로 표현하라. 종료 상태와 완료 수를 제시하라.
  3. ROS 클라이언트가 두 번째 구간의 피드백에서 취소를 요청했는데 결과의 완료 수는 3이었다. 완성 코드에서 이 결과가 가능한 실행 순서를 설명하라.
  4. 서버에 구간 수가 0인 목표를 보냈을 때와, 수락한 작업의 실행 중 예외가 발생했을 때 클라이언트가 확인하는 정보의 차이를 설명하라.

정답과 해설

  1. 반환값은 False이고 상태는 SUCCEEDED로 유지된다. 마지막 advance()가 이미 성공 전이를 수행했으므로 취소 가능한 상태가 아니다.

    job = Delivery(2)
    job.start()
    job.advance()
    job.advance()
    print(job.request_cancel())
    job.show_result()
    

    출력은 다음과 같다.

    ACCEPTED -> EXECUTING
    피드백 1/2
    피드백 2/2
    EXECUTING -> SUCCEEDED
    False
    결과 SUCCEEDED, 완료=2
    
  2. 취소를 수락했어도 정리가 정상적으로 끝나지 않았으므로 실패로 종료한다. 다음 코드는 action_model.py의 클래스 정의를 사용할 수 있는 위치에서 실행한다.

    job = Delivery(4)
    job.start()
    job.advance()
    job.advance()
    job.request_cancel()
    job.fail()
    job.show_result()
    

    출력은 다음과 같다.

    ACCEPTED -> EXECUTING
    피드백 1/4
    피드백 2/4
    EXECUTING -> CANCELING
    CANCELING -> ABORTED
    결과 ABORTED, 완료=2
    
  3. 서버가 두 번째 피드백을 발행한 직후 세 번째 반복의 취소 검사를 통과하고 구간 처리를 시작할 수 있다. 그동안 클라이언트가 피드백을 받아 취소를 보내고 서버가 요청을 수락한다. 실행 함수는 세 번째 구간 처리를 마친 뒤 완료 수를 3으로 기록하고, 이어지는 검사에서 취소를 발견한다. 피드백이 만들어진 시점과 취소를 실행 코드가 관측한 시점 사이에 작업이 진행된 것이다.

  4. 구간 수 0은 목표 콜백에서 거절된다. 클라이언트는 accepted가 거짓인 것을 확인하고 결과 요청을 하지 않는다. 실행 중 예외는 이미 수락한 목표의 실패다. 서버는 abort()를 호출하고 현재 완료 수와 오류 설명을 반환하며, 클라이언트는 결과 응답의 STATUS_ABORTED를 확인한다. 거절은 접수 판단이고 실패는 실행의 종료 판단이다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.