AEGLOSLAB ROS 2 Jazzy 대전

제1부 계산 그래프의 코어

1.8 액션 — 목표·피드백·취소의 상태기계

1.8.1 이 장에서 배우는 것 / 선수 지식

액션은 ROS 2에서 가장 복잡하고 가장 과소평가되는 통신 패턴이다. "오래 걸리는 서비스" 정도로 이해하면 절반만 안 것이다 — 액션의 본질은 목표(goal)마다 독립적인 상태기계를 유지하는 것이며, 그 덕분에 취소·중단·다중 목표·결과 조회가 일관되게 다뤄진다. Nav2의 NavigateToPose, MoveIt 2의 MoveGroup, ros2_control의 FollowJointTrajectory — 로봇 시스템의 굵직한 명령은 거의 전부 액션이다.

배우는 것:

  • 액션의 실제 구현 — 서비스 3개 + 토픽 2개가 어떻게 하나의 추상으로 묶이는가.
  • 목표 상태기계 전체 — 상태·전이·누가 전이를 일으키는가.
  • 서버 구현의 정확한 형태 — 세 콜백의 역할과, execute를 어디서 실행해야 하는가.
  • 클라이언트 구현 — 목표 전송·피드백·결과의 비동기 사슬과 데드락 회피.
  • 취소가 실제로 동작하게 만드는 법 — 가장 많이 빠뜨리는 부분.
  • 다중 목표 정책 — 선점·거부·큐잉 중 무엇을 택할 것인가.
  • 결과 보관과 만료 — 결과를 언제까지 들고 있는가.
  • 액션 설계 원칙과 함정.

선수 지식: 1.7(서비스와 데드락 — 액션은 그 위에 세워진다), 1.4(토픽), 1.2.4(action 파일 문법). 콜백 그룹(2.4)이 여기서도 결정적이다.

정직: 상태기계와 프로토콜은 ROS 2 액션 설계 문서(design.ros2.org)와 rcl_action·action_msgs 정의에 근거한다. 결과 만료 시간(result_timeout)의 Jazzy 기본값은 94차에 실행 확인했다(1.8.8) — rcl_action·rclcpp_action은 10초, rclpy는 900초로 언어별로 다르다. 다만 이전 배포판에서 값이 바뀐 이력이 있는지는 여전히 (확인 필요)다. 목표 수락 직후 발행된 첫 피드백이 조용히 유실될 수 있다는 것도 95차에 실행 확인했고, 96차가 정확한 소스 수준 메커니즘과 "안전한 지연 시간은 없다"는 경계를 확정했다(1.8.13). 97차는 EventsExecutor가 로컬호스트의 이 유실을 완전히 없애지만(55/55) 실제 네트워크(별도 컨테이너 + netem 지연)에서는 그 보호가 사라진다는 것을 확인했다(1.8.13). 98차는 그림자 구독을 더한 새 하네스로 재요청("warm") 조건을 다시 측정했는데, 97차의 "executor 무관 전부 유실" 결과가 재현되지 않아 정정했고(로컬 EventsExecutor 85/85·SingleThreadedExecutor 5~8%), 원격 RTT 유실의 원인은 tcpdump·tshark로 패킷 재정렬을 직접 확인해 추론에서 실측으로 승격시켰다(1.8.13). 99차는 커널 타임스탬프(tcpdump)로 로컬 warm 손실이 네트워크 도착 순서와 무관함을 확정했고(240회 전부 커널 도착은 응답이 먼저), 트라이얼 인덱스 분포로 "컨테이너 시작 잡음"·"단순 워밍업" 가설을 배제했으며(초기 7~12회는 항상 안전, 원인은 미확정), rclpy가 이 손실 계열 전체에 면역임을 240회 실측 + 소스로 확정했다(1.8.13). 그 밖의 서술은 이 환경에서 실행 검증하지 않았다(목차 3).

1.8.2 액션은 실제로 무엇인가

액션은 새 프로토콜이 아니다. 서비스 3개와 토픽 2개의 조합이다.

액션 클라이언트 액션 서버 ① send_goal (서비스) ② cancel_goal (서비스) ③ get_result (서비스) ④ feedback (토픽) ⑤ status (토픽)
#이름종류역할
①_action/send_goal서비스목표 제출 → 수락·거부 응답
②_action/cancel_goal서비스취소 요청 → 수락 여부 응답
③_action/get_result서비스결과 요청 → 완료될 때까지 응답 보류
④_action/feedback토픽진행 상황
⑤_action/status토픽모든 목표의 상태 배열

이름이 _로 시작하므로 숨은 엔티티다(1.1.3). 그래서 ros2 topic list에 안 보이고, --include-hidden-topics를 줘야 나온다.

두 가지 중요한 관찰:

  1. get_result는 "긴 서비스"다. 클라이언트가 결과를 요청하면 서버는 목표가 끝날 때까지 응답하지 않는다. 1.7에서 "긴 서비스는 쓰지 말라"고 했는데, 액션 내부는 정확히 그것을 쓴다 — 다만 클라이언트 라이브러리가 이를 완전히 비동기로 감싸므로 사용자는 블로킹을 겪지 않는다. 이것이 액션이 존재하는 이유의 절반이다.
  2. 엔티티가 5개다. 액션 하나가 노드에 만드는 DDS 엔티티는 토픽 하나보다 훨씬 많다. 액션을 수십 개 만들면 디스커버리·메모리 비용이 눈에 띈다(3.3). "모든 명령을 액션으로"가 항상 옳지는 않은 이유다.

1.8.3 목표 상태기계

각 목표는 UUID로 식별되며 독립적인 상태를 갖는다.

목표 수락 목표 거부 실행 시작 취소 요청 성공 실패 취소 완료 취소 전 완료 취소 중 실패 ACCEPTED 거부됨 EXECUTING CANCELING SUCCEEDED ABORTED CANCELED
상태의미누가 전이시키나
ACCEPTED수락됨, 아직 시작 전서버(handle_goal)
EXECUTING실행 중서버
CANCELING취소 요청 접수, 정리 중클라이언트 요청 → 서버 수락
SUCCEEDED정상 완료서버
ABORTED서버가 실패로 종료서버
CANCELED취소 완료서버

설계상 중요한 점 셋:

  • 취소는 요청이지 명령이 아니다. 클라이언트가 취소를 요청해도 서버가 거부할 수 있고, CANCELING 중에 작업이 성공적으로 끝나 SUCCEEDED가 될 수도 있다. "취소했으니 멈췄다"고 가정하면 안 된다 — 최종 상태를 확인해야 한다.
  • ABORTED와 CANCELED는 다르다. 전자는 서버가 실패로 판단한 것, 후자는 클라이언트 요청에 따른 종료. 복구 로직에서 이 둘을 구분해야 한다(Nav2의 복구 동작이 대표적 — 8.7).
  • 거부는 상태가 아니다. 거부된 목표는 상태기계에 들어가지 않는다 — send_goal 응답에서 accepted=false로 끝난다.

1.8.4 서버 구현 — 세 콜백과 실행 스레드

#include "rclcpp_action/rclcpp_action.hpp"

class CalibServer : public rclcpp::Node
{
  using Calibrate = my_interfaces::action::Calibrate;
  using GoalHandle = rclcpp_action::ServerGoalHandle<Calibrate>;

public:
  CalibServer() : Node("calib_server")
  {
    action_server_ = rclcpp_action::create_server<Calibrate>(
      this, "calibrate",
      std::bind(&CalibServer::handle_goal, this, _1, _2),
      std::bind(&CalibServer::handle_cancel, this, _1),
      std::bind(&CalibServer::handle_accepted, this, _1));
  }

private:
  // ① 목표 수락 여부 판단 — 반드시 즉시 반환
  rclcpp_action::GoalResponse handle_goal(
    const rclcpp_action::GoalUUID & uuid,
    std::shared_ptr<const Calibrate::Goal> goal)
  {
    (void)uuid;
    if (goal->axis > 5) {
      RCLCPP_WARN(get_logger(), "잘못된 축: %d — 거부", goal->axis);
      return rclcpp_action::GoalResponse::REJECT;
    }
    if (is_busy_) {
      return rclcpp_action::GoalResponse::REJECT;   // 다중 목표 정책(1.8.6)
    }
    return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
  }

  // ② 취소 요청 판단 — 반드시 즉시 반환
  rclcpp_action::CancelResponse handle_cancel(
    const std::shared_ptr<GoalHandle> goal_handle)
  {
    (void)goal_handle;
    RCLCPP_INFO(get_logger(), "취소 요청 수락");
    return rclcpp_action::CancelResponse::ACCEPT;
  }

  // ③ 수락 후 호출 — 여기서 오래 걸리면 안 된다
  void handle_accepted(const std::shared_ptr<GoalHandle> goal_handle)
  {
    // ⚠️ 반드시 별도 스레드로 넘긴다
    std::thread{std::bind(&CalibServer::execute, this, _1), goal_handle}.detach();
  }

  // 실제 작업 — 별도 스레드에서 실행
  void execute(const std::shared_ptr<GoalHandle> goal_handle)
  {
    auto feedback = std::make_shared<Calibrate::Feedback>();
    auto result = std::make_shared<Calibrate::Result>();
    rclcpp::Rate rate(10);

    for (int i = 0; i < 100 && rclcpp::ok(); ++i) {
      // ★ 취소 확인 — 이것을 빼면 취소가 동작하지 않는다
      if (goal_handle->is_canceling()) {
        result->success = false;
        result->message = "사용자 취소";
        goal_handle->canceled(result);
        return;
      }

      do_one_step(i);

      feedback->progress = i / 100.0;
      feedback->stage = "sweeping";
      goal_handle->publish_feedback(feedback);
      rate.sleep();
    }

    if (rclcpp::ok()) {
      result->success = true;
      result->offset = measured_offset_;
      goal_handle->succeed(result);
    }
  }
};

세 콜백은 전부 executor 스레드에서 실행된다. 그래서 셋 다 즉시 반환해야 한다. 실제 작업은 handle_accepted에서 다른 스레드로 넘긴다.

함정: handle_accepted 안에서 바로 execute()를 호출하면 executor가 그 시간 동안 완전히 막힌다. 그러면 취소 요청도, 피드백 발행도, 다른 콜백도 전부 멈춘다 — 액션을 쓰는 의미가 사라진다. 1.7의 데드락과 같은 원인이다.

std::thread + detach()는 예제용 최소 형태다. 제품 코드에서는 문제가 있다.

문제대안
스레드 수명 관리 불가(detach)스레드 풀·std::jthread·멤버로 보관 후 join
목표마다 스레드 생성 비용워커 스레드 하나 + 작업 큐
실시간 경로에서 동적 스레드 생성미리 만든 스레드 재사용(12.3)
노드 소멸 시 스레드가 잔존함소멸자에서 종료 신호 + join

대안: 타이머 기반 실행. 스레드 없이 상태기계를 타이머로 실행하는 방식도 널리 쓰인다.

void handle_accepted(const std::shared_ptr<GoalHandle> gh) {
  current_goal_ = gh;
  step_ = 0;
  exec_timer_ = create_wall_timer(100ms, [this]() { step_once(); });
}
  • 장점: 스레드 없음. 스레드 안전 문제 없음. executor 하나로 끝.
  • 단점: 작업을 단계로 분할하야 한다. 블로킹 작업에는 못 쓴다.
  • 맞는 경우: 주기적 진행이 자연스러운 작업(경로 추종·스캔), 단일 스레드 executor 유지가 중요한 경우.

견해: 작업이 자연스럽게 주기적이면 타이머 방식, 블로킹 호출이 섞이면 워커 스레드를 택한다. 새 스레드를 목표마다 만드는 형태는 튜토리얼에서 벗어나면 재검토 대상이다.

1.8.5 클라이언트 구현 — 비동기 사슬

class CalibClient : public rclcpp::Node
{
  using Calibrate = my_interfaces::action::Calibrate;
  using GoalHandle = rclcpp_action::ClientGoalHandle<Calibrate>;

public:
  CalibClient() : Node("calib_client")
  {
    client_ = rclcpp_action::create_client<Calibrate>(this, "calibrate");
  }

  void send()
  {
    if (!client_->wait_for_action_server(std::chrono::seconds(5))) {
      RCLCPP_ERROR(get_logger(), "액션 서버 없음");
      return;
    }

    auto goal = Calibrate::Goal();
    goal.axis = 2;

    auto options = rclcpp_action::Client<Calibrate>::SendGoalOptions();

    options.goal_response_callback =
      [this](GoalHandle::SharedPtr handle) {
        if (!handle) {
          RCLCPP_ERROR(get_logger(), "목표가 거부됨");
          return;
        }
        goal_handle_ = handle;   // 취소하려면 보관 필요
        RCLCPP_INFO(get_logger(), "목표 수락됨");
      };

    options.feedback_callback =
      [this](GoalHandle::SharedPtr, const std::shared_ptr<const Calibrate::Feedback> fb) {
        RCLCPP_INFO(get_logger(), "진행 %.0f%% (%s)",
                    fb->progress * 100.0, fb->stage.c_str());
      };

    options.result_callback =
      [this](const GoalHandle::WrappedResult & result) {
        switch (result.code) {
          case rclcpp_action::ResultCode::SUCCEEDED:
            RCLCPP_INFO(get_logger(), "성공: offset=%.4f", result.result->offset);
            break;
          case rclcpp_action::ResultCode::ABORTED:
            RCLCPP_ERROR(get_logger(), "서버가 중단: %s",
                         result.result->message.c_str());
            break;   // 재시도·복구 판단
          case rclcpp_action::ResultCode::CANCELED:
            RCLCPP_WARN(get_logger(), "취소됨");
            break;   // 의도된 종료 — 재시도하지 않음
          default:
            RCLCPP_ERROR(get_logger(), "알 수 없는 결과");
        }
      };

    client_->async_send_goal(goal, options);
  }

  void cancel()
  {
    if (goal_handle_) {
      client_->async_cancel_goal(goal_handle_);   // 응답 콜백도 줄 수 있다
    }
  }
};

세 콜백이 순서대로 오지 않을 수 있다. 피드백이 goal_response_callback보다 먼저 도착하는 경우가 가능하다고 여겨져 왔다 — 95차 실측 결과 실제 위험은 순서 역전이 아니라 "피드백이 조용히 사라지는 것"이었다(1.8.13). 클라이언트 로직이 "첫 피드백은 반드시 온다"를 가정하면 드물게(그리고 조용히) 깨진다.

함정: 액션 클라이언트에도 1.7의 데드락이 그대로 적용된다. 콜백 안에서 spin_until_future_complete로 목표 결과를 기다리면 멈춘다. 위처럼 콜백 체인으로 구성하는 것이 기본이며, 순차 흐름이 필요하면 콜백 그룹 + MultiThreadedExecutor를 쓴다(1.7.4의 ②).

1.8.6 취소를 실제로 동작하게 만들기

가장 많이 빠뜨리는 부분이다. 취소는 자동으로 되지 않는다 — 서버가 협조해야 한다.

ACCEPT 확인 안 함 확인함 REJECT 클라이언트: async_cancel_goal 서버 handle_cancel 호출 ACCEPT? 상태 변화 없음 작업 계속 상태 → CANCELING 실행 루프가 is_canceling() 확인 ☠️ 영원히 CANCELING 작업 계속 진행 정리 후 canceled(result) 상태 → CANCELED

체크리스트:

  • [ ] handle_cancel에서 ACCEPT를 반환하는가.
  • [ ] 실행 루프에서 goal_handle->is_canceling()을 주기적으로 확인하는가.
  • [ ] 확인 주기가 충분히 짧은가(취소 요청 후 정지까지의 시간이 안전 요구를 만족하는가).
  • [ ] 취소 시 하드웨어를 안전 상태로 되돌리는가(모터 정지·브레이크·툴 해제).
  • [ ] goal_handle->canceled(result)를 호출해 상태기계를 닫는가.
  • [ ] 블로킹 호출 중에는 취소를 확인할 수 없다 — 그 구간이 얼마나 긴가.

마지막 항목이 실무에서 가장 피해가 크다. execute 안에서 5초 걸리는 블로킹 I/O를 하면 그 5초 동안 취소가 불가능하다. 작업을 취소 확인이 가능한 단위로 분할하는 것이 액션 서버 설계의 핵심 노동이다.

함정: 취소했는데 로봇이 계속 움직인다는 사고의 대부분이 여기서 온다. 안전 관련 액션(주행·매니퓰레이션)에서는 취소 응답 시간을 명시적 요구사항으로 두고 측정해야 한다(14.10).

1.8.7 다중 목표 정책 — 새 목표가 왔을 때

이미 실행 중인데 새 목표가 오면 어떻게 하는가. 액션 프로토콜은 정하지 않는다 — 서버가 정한다.

① 거부(reject) — 바쁘면 새 목표를 거부.

if (is_busy_) return rclcpp_action::GoalResponse::REJECT;
  • 장점: 가장 단순, 예측 가능.
  • 단점: 클라이언트가 재시도를 관리해야 한다.
  • 맞는 경우: 배타적 자원(단일 그리퍼·단일 캘리브레이션 장비).

② 선점(preempt) — 새 목표를 받고 이전 목표를 중단.

// handle_accepted에서
if (current_goal_ && current_goal_->is_active()) {
  auto r = std::make_shared<Calibrate::Result>();
  r->message = "새 목표에 의해 선점됨";
  current_goal_->abort(r);      // 이전 목표를 ABORTED로 종료
}
current_goal_ = goal_handle;
  • 장점: 최신 명령이 항상 우선한다 — 원격 조작·항법에 자연스럽다.
  • 단점: 이전 목표의 정리(하드웨어 안전 상태)를 반드시 해야 한다. 경쟁 조건 주의.
  • 맞는 경우: Nav2의 NavigateToPose가 이 방식이다. 새 목적지를 주면 이전 주행이 대체된다.

③ 큐잉(queue) — 순서대로 처리.

  • 장점: 모든 목표가 처리된다.
  • 단점: 큐가 자란다. 오래된 목표가 뒤늦게 실행되는 위험(1.6.3.2의 lifespan 문제와 같은 성질). 취소 의미가 복잡해진다.
  • 맞는 경우: 작업 큐 성격이 명확한 경우(적재 순서·검사 목록).
축① 거부② 선점③ 큐잉
구현 복잡도낮음중간높음
최신성낮음높음낮음
목표 유실있음(거부)있음(선점)없음
안전 정리 부담없음있음중간

견해: 의도를 문서화하는 것이 방식 선택보다 중요하다. 클라이언트는 서버의 정책을 알아야 올바르게 쓸 수 있는데, 액션 인터페이스 정의에는 이 정보가 없다. 액션 서버를 만들면 다중 목표 정책을 인터페이스 문서에 명시하라. 생태계 액션을 쓸 때도 이 정책을 먼저 확인해야 한다.

1.8.8 결과 보관과 만료 — 실측

서버는 완료된 목표의 결과를 일정 시간 보관한다. 클라이언트가 늦게 get_result를 요청해도 받을 수 있게 하기 위해서다.

  • 보관 기간이 지나면 결과는 버려지고, 그 뒤 요청은 실패한다.
  • 보관 시간이 길수록 메모리를 쓴다. 목표가 많은 서버에서는 무시할 수 없다.

실행 확인(2026-08-18, ros:jazzy-ros-base, 94차) — Jazzy 기본값을 언어별로 직접 확인했다. rcl_action_server_get_default_options()를 호출하는 C 프로그램을 빌드해 result_timeout 필드를 읽고, rclpy.action.ActionServer.__init__의 시그니처를 조회했다.

계층기본값근거
rcl_action(C, rcl_action_server_get_default_options())10초헤더 주석의 예시값과 일치 — 실제 반환값으로 확인
rclcpp_action::create_server10초create_server.hpp가 옵션 인자 기본값으로 rcl_action_server_get_default_options()를 그대로 씀 — C++ 쪽은 별도 오버라이드가 없다
rclpy.action.ActionServer900초(15분)생성자 키워드 인자 result_timeout=900으로 자체 오버라이드

C++과 Python의 기본값이 90배 차이 난다. rclcpp_action으로 액션 서버를 만들고 result_timeout을 명시하지 않으면, 목표가 끝난 지 10초만 지나도 결과가 사라진다 — 클라이언트가 그 사이에 get_result를 안 보내면(위 "늦게 요청" 시나리오) 조용히 실패로 이어진다. 같은 코드를 rclpy로 옮기면 15분의 여유가 생긴다. 설정 방법은 rclcpp_action::create_server에 rcl_action_server_options_t를 직접 구성해 넘기거나(C++), ActionServer(..., result_timeout=…)(Python)다.

실무적 의미: 클라이언트가 결과를 오래 안 가져가는 설계는 위험하다 — 특히 C++ 서버는 10초라는 짧은 기본값을 명시적으로 늘려야 한다. 목표를 보내고 결과를 즉시 구독하는 형태(위 SendGoalOptions의 result_callback)를 쓰면 이 문제를 만나지 않는다.

확인하지 못한 것 — 이 기본값이 배포판을 거치며 실제로 바뀐 이력이 있는지(Iron·Humble 등 이전 배포판의 값)는 확인하지 않았다(확인 필요). 지금 Jazzy 값만 실측했다.

1.8.9 액션 CLI

ros2 action list -t
ros2 action info /calibrate -t            # 클라이언트·서버 수
ros2 action send_goal /calibrate my_interfaces/action/Calibrate "{axis: 2}"
ros2 action send_goal /calibrate ... --feedback      # 피드백 출력

--feedback 없이 보내면 결과만 기다린다. ros2 action send_goal은 Ctrl-C로 끊어도 목표가 취소되지 않을 수 있다(확인 필요) — CLI 프로세스가 종료돼도 서버는 계속 실행할 수 있다. 이는 액션이 연결 지향이 아니기 때문이며, 안전이 걸린 액션을 CLI로 시험할 때 유의해야 한다.

숨은 토픽을 직접 보려면:

ros2 topic list --include-hidden-topics | grep _action
ros2 topic echo /calibrate/_action/status

status 토픽을 보면 모든 목표의 상태 배열이 나와, 상태기계가 실제로 어떻게 움직이는지 관찰할 수 있다. 액션 디버깅에서 가장 유용한 창이다.

1.8.10 언제 액션인가 — 판단 기준

신호액션이 맞나
1초 이상 걸릴 수 있다✅
중간에 취소해야 한다✅ (다른 수단으로는 표현 불가)
진행률을 알려야 한다✅
결과에 성공·실패 구분이 필요하다✅
즉시 끝난다❌ 서비스(1.7)
지속적 데이터 흐름❌ 토픽(1.4)
설정 값 변경❌ 파라미터(1.9)
노드 전체의 상태 전이❌ 라이프사이클(1.12)

비용을 잊지 말 것: 액션 하나는 엔티티 5개, 상태기계, 스레드 관리, 취소 처리를 요구한다. "혹시 몰라서 액션으로"는 비용이 크다. 취소도 진행률도 실제로 쓰지 않는다면 서비스가 맞다.

1.8.11 함정 카탈로그

#함정증상대처
1handle_accepted에서 직접 실행executor 전체 정지스레드·타이머로 분리
2is_canceling() 미확인취소가 영원히 안 끝남실행 루프에서 주기 확인
3취소 시 하드웨어 정지 누락로봇이 계속 움직임정리 경로 필수(14.10)
4세 콜백 중 하나가 오래 걸림액션 전체 응답성 저하즉시 반환
5목표마다 std::thread detach수명·자원 문제워커 스레드·타이머
6다중 목표 정책 미정의예측 불가 동작명시적 정책 + 문서화
7선점 시 이전 목표 미정리자원 누수·이중 제어abort() + 정리
8클라이언트 콜백에서 동기 대기데드락(1.7)콜백 체인
9goal_handle 미보관취소 불가응답 콜백에서 저장
10ABORTED와 CANCELED 동일 처리잘못된 재시도결과 코드 분기
11피드백에 대용량 데이터대역 폭증참조만(1.2.4)
12결과를 늦게 조회만료로 유실result_callback 사용
13짧은 명령을 액션으로불필요한 복잡도·엔티티서비스
14CLI Ctrl-C가 취소라고 가정로봇이 계속 동작명시적 취소

1.8.12 체크포인트

  • 액션이 서비스 3개 + 토픽 2개로 구현된다는 사실에서, 액션을 남발하면 안 되는 이유를 도출할 수 있는가.
  • get_result가 "긴 서비스"인데도 클라이언트가 블로킹되지 않는 이유는 무엇인가.
  • 목표 상태기계의 상태 여섯 개와 전이를 그릴 수 있는가. CANCELING에서 SUCCEEDED로 갈 수 있다는 사실의 함의는?
  • ABORTED와 CANCELED를 구분해야 하는 이유를 복구 로직 관점에서 설명할 수 있는가.
  • 서버의 세 콜백이 왜 전부 즉시 반환해야 하는가. handle_accepted에서 바로 실행하면 무슨 일이 벌어지는가.
  • 실행을 스레드로 분리하는 방식과 타이머로 분해하는 방식의 장단을 비교하고, 각각이 맞는 경우를 말할 수 있는가.
  • 취소가 실제로 동작하기 위한 체크리스트 여섯 항목을 말할 수 있는가. 그중 안전에 직결되는 것은?
  • 블로킹 I/O가 취소 응답 시간에 미치는 영향과, 그것을 다루는 방법은?
  • 다중 목표 정책 세 가지를 비교하고, Nav2가 어떤 방식인지 말할 수 있는가.
  • 액션 서버를 만들 때 인터페이스 문서에 반드시 적어야 할 것은 무엇인가.
  • 액션 상태기계를 실제로 관찰하려면 어떤 토픽을 보는가.
  • "혹시 몰라서 액션으로"가 비용이 큰 이유를 구체적 비용으로 설명할 수 있는가.
  • result_timeout을 명시하지 않은 C++ 액션 서버는 몇 초 뒤에 결과를 버리는가, 그리고 같은 코드를 Python으로 옮기면 왜 달라지는가(1.8.8).
  • 목표 수락 직후 발행된 첫 피드백을 믿으면 안 되는 이유를 메커니즘까지 설명할 수 있는가(1.8.13).

1.8.13 첫 피드백이 사라지는 경로 — 실측

1.8.5가 "피드백이 goal_response_callback보다 먼저 도착할 수 있다"고 적어 둔 자리를 실측했다.

실행 확인(2026-08-18, ros:jazzy-ros-base, rclcpp_action, 95차) — 서버가 목표를 수락하자마자(handle_accepted 안에서 지연 없이) 피드백을 한 번 발행하도록 만들어 최악의 경우를 재현했다.

조건반복FEEDBACK이 GOAL_RESPONSE보다 먼저 온 횟수GOAL_RESPONSE는 왔는데 FEEDBACK이 안 온 횟수
클라이언트가 목표를 보낸 뒤에야 스핀 시작(스핀 시작 전 유예 0~1000 ms)35회0/3535/35 — 전부 유실
클라이언트가 목표 전송 전부터 계속 스핀 중(정상적인 실사용 패턴)20회0/201/20(5%)

55번 중 단 한 번도 피드백이 goal_response_callback보다 먼저 실행되지 않았다 — "순서가 뒤바뀔 수 있다"는 이 책의 원래 경고는 재현되지 않았다. 대신 훨씬 조용한 문제가 나왔다: *rclcpp_action::Client::async_send_goal의 소스(client.hpp)를 보면, 피드백을 사용자 콜백으로 연결해 주는 GoalHandle은 목표 수락 응답 콜백 안에서 처음 만들어지고 내부 맵(goal_handles_)에 등록된다. 그 등록이 끝나기 전에 도착한 피드백은 어디로도 갈 곳이 없어 그대로 버려진다 — 나중에 재전달되지도, 순서만 바뀌어 나중에 오지도 않는다. 클라이언트가 계속 스핀하고 있어도(정상적인 사용 패턴) 5%의 확률로 이 유실이 관측됐다* — 서버가 수락 직후 지체 없이 피드백을 보내는 설계(진행률 0%를 즉시 알리는 등)라면 특히 위험하다.

정정(2026-08-19, 96차) — 2.3.14와의 연결은 틀렸다. 95차는 "피드백은 SUB, 목표 응답은 CLIENT이니 2.3.14의 최상위 우선순위(SUB가 CLIENT에 우선한다)가 원인일 것"이라고 추정했다. rclcpp_action 소스(rclcpp_action/src/client.cpp, Jazzy 브랜치)를 실제로 읽어 보니 이 추정은 틀렸다. 액션 클라이언트는 목표·결과·취소 세 서비스 클라이언트와 피드백·상태 두 구독을 통틀어 rclcpp::Waitable 객체 하나로 executor에 등록된다(class ClientBase : public rclcpp::Waitable). executor 입장에서 액션 클라이언트는 처음부터 끝까지 WAITABLE 자리 하나일 뿐이며, SUB·CLIENT라는 서로 다른 최상위 엔티티로 나뉘어 있지 않다 — 2.3.14의 5단계 우선순위가 개입할 여지 자체가 없다.

실제 원인은 이 Waitable 내부에 있다. ClientBase::is_ready()(client.cpp 330~380행)는 다섯 항목의 준비 여부를 순서대로 확인하며 처음 찾은 것 하나만 보고하고 즉시 반환한다 — 그 순서가 FeedbackSubscription → StatusSubscription → GoalClient → ResultClient → CancelClient로 하드코딩돼 있다(if (is_feedback_ready) { ...; return true; }가 함수 맨 앞줄). 목표 응답(GoalClient)과 피드백(FeedbackSubscription)이 같은 폴링 주기에 함께 준비되면, is_ready()는 피드백만 보고하고 응답 처리는 다음 주기로 미룬다 — executor는 이 보고를 그대로 받아 피드백 콜백을 먼저 호출하고, 이 시점에 GoalHandle이 아직 없어 조용히 버려진다. 결과는 2.3.14의 발견과 닮았지만 메커니즘은 다르다 — 하나는 최상위 executor가 엔티티 종류 사이에 매기는 우선순위이고, 다른 하나는 액션 클라이언트 자신이 내부에 하드코딩해 둔 우선순위다. 우연히 방향이 같아(둘 다 "구독형"을 먼저 본다) 95차가 혼동했다.

실행 확인(2026-08-19, ros:jazzy-ros-base, rclcpp_action, 96차) — "정확히 언제부터 유실이 멈추는가"를 독립적인 최소 액션(목표를 받으면 handle_accepted에서 지연 없이 피드백을 발행)으로 이분 탐색했다. 클라이언트의 스핀 시작 시점과 서버의 피드백 발행 지연을 각각 변수로 바꿨다.

실험조건반복유실
클라이언트가 목표를 보낸 뒤에 스핀 시작offset 0 ms(스레드를 막 깨운 찰나)4010/40(25%) — 순전한 요행
〃offset 1 ms 이상(1~1000 ms, 9개 값)160159/160(99%) — 사실상 결정적
클라이언트가 계속 스핀 중, 목표를 반복 전송하며 그 사이 유휴 시간(gap)만 바꿈gap 0 ms(쉼 없이 연속 재전송)2011~15/20(55~75%, 반복 측정)
〃gap 20~100 ms5014~19/25(56~76%)
〃gap 300~1000 ms(executor가 완전히 idle 상태에 들어감)5548~55/55(87~100%)

클라이언트 쪽에는 안전한 지연 시간이 없다 — 있다면 오히려 반대 방향이다. 스핀 시작(또는 다음 목표 전송)을 늦출수록 유실은 더 확정적으로 변한다. offset=0(스레드를 깨운 바로 그 찰나)에서만 25%의 요행이 있고, 1 ms만 지나도 사실상 100%다. 이것은 95차가 인용한 "정상 사용 패턴 5%"보다 훨씬 불리하다 — 95차의 저표본(20회, 목표 사이 유휴 시간을 두지 않은 연속 스핀)이 우연히 낮은 값을 추출했을 가능성이 크며, 목표 전송 사이에 조금이라도 유휴 시간(gap)이 있는 더 현실적인 사용 패턴에서는 87% 이상으로 급증한다. "클라이언트가 계속 스핀 중"이라는 것만으로는 안전을 보장하지 못한다.

서버 쪽 지연은 다르게 작동한다 — handle_accepted에서 피드백을 발행하기 전 단 1 ms만 넣어도(gap 0~300 ms 전 구간에서) 유실이 0/60(0%)으로 떨어졌다. 그러나 이 지연은 클라이언트가 이미 스핀 중일 때만 유효하다. 클라이언트가 아직 스핀을 시작하지 않은 상태(위 표의 "뒤에 스핀 시작" 조건)에서는 서버 지연을 200 ms·1000 ms로 늘려도 25/25·15/15(100%) 그대로 유실됐다. 서버 지연 500 ms + 클라이언트가 200 ms째부터 스핀 중(피드백보다 300 ms 먼저 깨어 있는 조합)일 때만 0/20으로 안전했고, 같은 서버 지연에서 클라이언트가 1000 ms째(피드백이 이미 발행된 뒤)에야 스핀을 시작하면 다시 20/20 전부 유실됐다.

정리하면 안전 조건은 "몇 ms"가 아니라 하나의 인과 조건이다: 목표 응답이 도착한 시점과 피드백이 도착한 시점 사이에, 클라이언트 executor가 최소 한 번 이상 그 응답을 능동적으로 처리할 기회를 가져야 한다. 이 창이 존재하는지는 절대 지연 시간이 아니라 두 메시지의 상대 도착 순서로 결정되며, 클라이언트가 스핀 중이 아니면 이 창은 전혀 생기지 않는다.

실행 확인(2026-08-19, ros:jazzy-ros-base, rclcpp_action, 97차) — EventsExecutor는 이 유실을 원천 차단한다, 로컬호스트에서는. 96차가 확정한 원인(ClientBase::is_ready()가 피드백을 항상 먼저 보고하도록 하드코딩됨)은 wait set 기반 executor에만 있는 함수다. EventsExecutor는 목표·결과·취소·피드백·상태 다섯 항목을 set_on_ready_callback으로 각각 독립적인 엔티티 ID로 등록한다(rclcpp_action/src/client.cpp의 EntityType 열거 — 2.3.14) — "하나만 골라 보고한다"는 코드 경로 자체가 없다. 같은 클라이언트 실행 파일을 executor만 바꿔 재현했다.

executor조건반복유실
SingleThreadedExecutor(대조군)목표 전송 후 오프셋 0 ms200/20(0%)
〃오프셋 100 ms1515/15(100%) — 96차와 일치
EventsExecutor오프셋 0·100·500 ms(13개 시행 묶음)550/55(0%) — 전부 성공

오프셋을 아무리 늘려도(0~500 ms) EventsExecutor는 55번 중 단 한 번도 첫 피드백을 놓치지 않았다 — SingleThreadedExecutor가 오프셋 100 ms에서 100% 유실이었던 것과 정반대다. 서버가 목표 응답을 먼저 보내고(수락 처리) 그다음 피드백을 발행하는 한, 그 도착 순서는 로컬호스트에서 거의 항상 보존되고(CHG-118·119가 확인한 "55번 중 순서 역전 0건"), EventsExecutor는 큐에 들어온 순서 그대로 처리하므로 GoalHandle이 항상 먼저 등록된다. 다만 "warm" 모드(클라이언트가 이미 계속 스핀 중이고, 목표를 반복 전송하며 그 사이 유휴 시간을 두는 조건)에서는 EventsExecutor도 안전하지 않았다 — gap 0 ms·300 ms 둘 다 0/20·0/15로 전부 유실됐다(SingleThreadedExecutor도 같은 조건에서 0/20으로 동일) — 96차가 이미 확인한 "유휴 시간이 길수록 유실이 급증한다"는 관계는 executor 종류와 무관하게 남아 있다는 뜻이다. 즉 EventsExecutor가 고치는 것은 "막 시작한 클라이언트"(cold-start) 시나리오뿐이고, "계속 쓰던 클라이언트에 뜸을 들이며 재요청"하는 시나리오는 여전히 위험하다.

실행 확인(2026-08-19, ros:jazzy-ros-base, 97차) — 별도 컨테이너 두 개(도커 사용자 네트워크)로 재현: 유실은 재현되고, RTT는 방향을 뒤집는다. 서버·클라이언트를 별도 컨테이너로 나눠(docker network create + 두 컨테이너) 재현하니 로컬호스트와 같은 패턴이 그대로 나왔다(오프셋 0 ms 안전·100 ms 100% 유실, response_seen은 항상 100%). 이어서 tc netem delay 50ms 5ms를 양쪽 컨테이너에 걸어 왕복 약 100 ms(지터 포함)를 인위적으로 추가했다.

조건RTT≈0(같은 호스트)RTT≈100 ms(netem)
SingleThreadedExecutor, 오프셋 0 ms0/20(0%) 유실(안전)20/40(50%) 유실(20·8·12/20 두 회 집계)
SingleThreadedExecutor, 오프셋 100 ms15/15(100%) 유실6/10(60%) 유실 — 오히려 완화
EventsExecutor, 오프셋 0 ms0/55(0%) 유실(안전)18/35(약 51%) 유실(8+9 / 20+15)

RTT가 커지면 방향이 하나가 아니라 둘로 갈렸다. 오프셋 0 ms(로컬에서 안전하던 조건)는 RTT가 생기자 약 50% 유실로 악화됐고, 반대로 오프셋 100 ms(로컬에서 100% 유실이던 조건)는 60%로 다소 완화됐다 — 두 결과 모두 "오프셋과 실제 응답 도착 시각의 상대 위치"가 핵심이라는 96차의 결론과 일치한다: 로컬에서는 t=100 ms 무렵이면 응답·피드백이 이미 둘 다 도착해 있어 코드 경로상 최악이지만, RTT가 100 ms면 그 시점에 응답조차 아직 도착 전일 수 있어 "동시에 준비되는" 불리한 조건이 덜 만들어진다. 더 중요한 결과는 EventsExecutor다 — 로컬에서 55/55 완벽하게 안전하던 것이 RTT 조건에서는 SingleThreadedExecutor와 거의 같은 약 51% 유실로 수렴했다. EventsExecutor의 안전은 "도착한 순서 그대로 처리한다"는 성질에서 나오는데, 로컬호스트는 송신 순서가 거의 항상 도착 순서와 같지만(그래서 96차가 "55번 중 역전 0건"을 봤다), 실제 네트워크는 지터 때문에 두 메시지의 도착 순서 자체가 뒤집힐 수 있다 — 이 경우 EventsExecutor는 "뒤집힌 순서"를 충실히 그대로 실행할 뿐이라 보호 효과가 사라진다. 이 인과는 97차 시점엔 패킷 캡처로 확정하지 않은 추론이었다 — 98차가 tcpdump로 직접 확정했다(아래 98차 블록). "wait set의 하드코딩된 우선순위 문제"와 "EventsExecutor의 FIFO 순서"라는 서로 다른 메커니즘이 실제 네트워크에서 비슷한 유실률로 수렴하는 이유가 바로 이 패킷 재정렬이다.

실행 확인(2026-08-19, ros:jazzy-ros-base, rclcpp_action, 98차) — "warm" 모드 유실은 재현되지 않았다, 정정한다. 97차가 "executor와 무관하게 유실"이라고 적어 둔 자리(gap 0·300 ms에서 single·events 둘 다 0/20·0/15)를 새로 만든 하네스로 다시 측정했다. 이번엔 응답 콜백과 별개로 /probe/_action/feedback 토픽을 평범한 rclcpp::Subscription으로 직접 구독하는 "그림자" 구독을 추가해, 액션 클라이언트의 GoalHandle 등록 여부와 무관하게 피드백이 실제로 도착했는지를 독립적으로 확인했다 — 97차의 하네스는 이 검증 없이 액션 클라이언트 콜백의 유무만으로 유실을 판정했고, CHG-120-05가 이미 같은 실험 계열에서 하네스 버그 두 건을 찾아낸 바로 그 위험이 여기서도 의심됐다.

executorgap반복유실
EventsExecutor0 ms500/50(0%)
EventsExecutor300 ms350/35(0%)
SingleThreadedExecutor0 ms504/50(8%)
SingleThreadedExecutor300 ms201/20(5%)

97차의 "0/20·0/15, executor 무관 전부 유실"은 이번 회차에서 재현되지 않았다. EventsExecutor는 warm 모드에서도 85번 전부 안전했고, SingleThreadedExecutor도 5~8%로 — 96·97차가 이 조건에서 보고한 55~100%가 아니라 95차가 최초에 관측한 "정상 사용 5%"에 더 가까운 수치로 돌아왔다. 그림자 구독 덕분에 메커니즘도 직접 확인된다 — SingleThreadedExecutor의 손실 5건 전부에서 그림자 구독이 관측한 피드백 도착 시각이 응답 콜백 실행 시각보다 먼저였다(28~99 µs 차) — 96차가 소스로 확정한 is_ready()의 FeedbackSubscription → ⋯ → GoalClient 하드코딩 순서와 정확히 들어맞는다. 정직하게 남긴다: 97차의 하네스 코드는 회차 종료 후 정리돼(컨테이너 삭제) 남아 있지 않아, 두 회차의 결과가 왜 이렇게 갈렸는지 직접 대조하지 못했다 (확인 필요) — 결과 판정 창의 길이나 gap 측정 방식의 차이가 후보다. 이번 일로 얻은 교훈: 재현 하네스 소스를 회차가 끝나도 저장소에 남겨 두면 다음 회차가 그대로 대조할 수 있다 — 지금까지는 컨테이너·네트워크만 정리 대상이었고 코드는 함께 사라졌다.

실행 확인(2026-08-19, 두 컨테이너 + tcpdump/tshark, 98차) — 패킷 재정렬 가설을 실측으로 확정했다. 97차가 "추론"으로 남긴 원격 RTT 유실의 원인을, docker network create + 컨테이너 둘(tc netem delay 50ms 5ms, 양쪽) 환경에서 tcpdump로 직접 캡처해 확인했다. EventsExecutor·SingleThreadedExecutor 각각 오프셋 0 ms로 10회씩 재현하니 6/10(60%)·6/10(60%) 유실로 97차와 같은 자릿수가 나왔다(대조군으로서 하네스가 97차와 일관됨을 먼저 확인). 캡처를 tshark의 RTPS 해부기로 분석해 writerSeqNumber로 재전송과 새 샘플을 구분한 뒤, 같은 트라이얼의 응답·피드백 샘플 쌍(둘 다 writerSeqNumber=4, 서로 다른 두 writer 엔티티)을 하나 특정했다. RTPS 페이로드의 Timestamp 필드는 서버가 응답을 피드백보다 0.105 ms 먼저 만들었다는 것을 보여준다(인과 순서는 정상 — 응답을 먼저 보내고 나서야 handle_accepted에서 피드백을 발행하므로). 그런데 tcpdump가 클라이언트의 NIC에서 캡처한 실제 도착 시각은 피드백 패킷(t=6.726941 s)이 응답 패킷(t=6.728345 s)보다 1.404 ms 먼저 출력됐다 — 서버에서 사실상 동시에(0.1 ms 차) 내보낸 두 패킷이 netem의 패킷별 지터(±5 ms, 패킷마다 독립 적용)를 거치며 도착 순서가 뒤바뀌는 것을 패킷 수준에서 직접 확인했다. 애플리케이션 레벨의 상관관계도 일치한다 — 이번 10회 유실 6건 전부에서 그림자 구독의 "피드백 도착 시각 − 응답 콜백 실행 시각"이 음수였다(1~6 ms 먼저 도착). 97차가 (확인 필요)로 표시해 둔 패킷 재정렬 가설은 이제 실측으로 닫혔다.

실행 확인(2026-08-19, ros:jazzy-ros-base, tcpdump/tshark 커널 타임스탬프, 99차) — 로컬 warm 모드 손실은 네트워크 도착 순서 문제가 아니다. 98차가 "그림자 구독도 같은 executor에서 스케줄링되므로 '도착 순서'인지 'executor 내부 우선순위'인지 완전히 분리하지 못했다"고 남긴 미확인을 닫는다. gap98 하네스에 wall-clock(epoch) 타임스탬프를 더한 gap99 하네스로, 클라이언트 프로세스와 나란히 tcpdump -i lo를 실행해 warm 모드 손실을 재현했다. 첫 함정부터 하나 걸렸다 — Fast DDS는 같은 호스트 내 통신에 기본적으로 공유 메모리(SHM) 전송을 쓰고, 이는 커널 네트워크 스택을 거치지 않는다. SPDP·SEDP 디스커버리는 항상 UDP를 쓰므로 tcpdump에 포착되지만 실제 피드백·응답 페이로드는 SHM을 거쳐 지나가 처음엔 아무것도 포착되지 않았다. useBuiltinTransports=false + UDPv4만 남기는 Fast DDS 프로파일(udp_only.xml)을 적용해야 실제 데이터가 루프백에 나타난다(지연은 2~3배 늘어난다 — SHM이 훨씬 빠르므로, 절대 수치가 아니라 상대 순서를 보는 이 실험 목적엔 영향 없음).

조건반복유실커널 도착 순서(전 트라이얼)
SingleThreadedExecutor, gap 0 ms1005/100(5%)reply_first 100/100
SingleThreadedExecutor, gap 300 ms403/40(7.5%)reply_first 40/40
EventsExecutor, gap 0 ms(대조군)1000/100(0%)reply_first 100/100

커널이 실제로 응답(reply) 패킷을 받은 시각은 240회 전부 — 5%~7.5%의 손실을 포함해서도 — 피드백보다 먼저였다. tshark로 DATA 서브메시지를 서비스별 writer entity id로 골라내고(rr/probe/_action/send_goalReply·rt/probe/_action/feedback, RTPS 페이로드의 PID_ENDPOINT_GUID로 확정), 캡처 안에서의 도착 순서(rank)로 트라이얼과 짝지었다(문자 그대로의 writerSeqNumber는 같은 서버 프로세스에 여러 번 연결되면 이어져 증가해 쓸 수 없었다 — tools/gap_harness/gap99/analyze_pcap.py의 함정 참고). 네트워크는 단 한 번도 순서를 뒤집지 않았는데도 5~7.5%는 여전히 유실됐다. 96차가 소스로 확정한 ClientBase::is_ready()의 FeedbackSubscription → ⋯ → GoalClient 하드코딩 순서와 정확히 들어맞는다 — 손실은 "누가 커널에 먼저 도착했나"가 아니라 "executor가 폴링 사이클 안에서 무엇을 먼저 보고하기로 되어 있나"에 전적으로 달려 있다. 98차가 앱 레벨 그림자 구독만으로는 분리 못 했던 이 둘을, 앱과 완전히 독립된 커널 캡처로 갈랐다.

실행 확인(같은 실험, 트라이얼 인덱스 분포, 99차) — "언제" 손실이 나는지로 세 가설을 갈랐다. 97·98차의 결과가 갈린 이유가 워밍업(초반 시행)인지, 컨테이너 시작 시점의 스케줄링 잡음인지, 실제 확률적 경쟁인지를 트라이얼 인덱스로 좁혔다. 위 100회 연속 프로세스 실행에서 손실은 인덱스 12·13·60·74·81에서만 났다 — 첫 12회(idx 0~11)는 전부 성공했고 마지막 18회(idx 82~99)도 전부 성공했다. 이어서 신선한 컨테이너 15개를 순차로(docker run --rm, 컨테이너마다 서버·클라이언트를 새로 띄움) 15회씩 재현하니(총 225회), 손실 4건이 전부 각 컨테이너의 7~11번째 트라이얼에서만 났다 — 15개 컨테이너의 0~6번째 트라이얼(총 105회)은 전부 성공이었다.

실험총 트라이얼손실손실이 난 위치
단일 장기 실행 프로세스 100회1005idx 12·13·60·74·81(중반~후반, 첫 12·마지막 18은 무사)
신선한 컨테이너 15개(순차), 각 15회2254각 컨테이너의 idx 7·9·10·11(각 컨테이너 첫 7회는 전부 무사)

세 가설 중 둘을 배제한다. "컨테이너를 막 기동한 순간"의 손실은 0건이었다(신선한 컨테이너 105회 전부 성공) — 컨테이너 시작 시점 스케줄링 잡음이 주된 원인이라는 가설은 이 데이터로는 지지되지 않는다. 동시에 "1회차부터 균일한 확률로 언제든 날 수 있다"는 단순 확률 가설도 정확히는 아니다 — 두 실험 모두 처음 7~12회는 예외 없이 안전했고, 그 이후로는 뚜렷한 패턴 없이(중반이든 후반이든) 산발적으로 났다. 이 "초기 무사 구간"이 왜 생기는지는 특정하지 못했다 (확인 필요) — CPU 주파수 스케일링이 초반 몇 회 동안 낮았다가 높아지며 폴링 주기가 바뀌는 것, epoll/조건변수 웨이크업 지연이 프로세스 초반엔 다르게 작동하는 것 등이 후보지만 확인하지 않았다. 초기 구간을 지난 뒤의 손실 분포(중반·후반에 고르게 산발)는 "실제 확률적 경쟁"이라는 설명과 가장 잘 맞는다 — 97차와 98차의 결과가 갈린 이유는 여전히 특정하지 못하지만, 적어도 "워밍업만의 문제"나 "컨테이너 시작만의 문제"는 아니라고 좁혔다.

부수 관측(99차) — 컨테이너를 병렬로 기동하면 완전히 다른 실패 모드가 나온다. 위 컨테이너-시작 실험을 처음엔 15개가 아니라 12개를 동시에(&) 실행해 진행했는데, 손실의 성격이 마이크로초 단위 경쟁이 아니라 그림자 구독에 피드백이 최대 689개까지 쌓였다가 1초 넘게 늦게 한꺼번에 도착하는 형태로 나타났다(180회 중 6회). 이것은 12개의 ROS 2 프로세스가 호스트 CPU 코어를 경합하는 실제 스케줄링 기아 상태이지, 이 절이 찾는 is_ready() 우선순위 경쟁이 아니다 — 순차 실행으로 바꾸자 사라졌다(위 표는 순차 실행 결과다). 측정 방법 자체가 관측되는 현상의 성격을 바꿀 수 있다는 것을 다시 확인한 사례다(CHG-121-03이 이미 강조한 원칙과 같은 맥락).

실행 확인(2026-08-19, ros:jazzy-ros-base, rclpy, 99차) — EventsExecutor는 실재하지만 숨어 있고, rclpy는 이 손실 계열 전체에 면역이다. 98차가 넘긴 "rclpy의 EventsExecutor로 재현" 제안을 진행했다. 먼저 존재부터 확인했다 — rclpy.executors에는 EventsExecutor가 없다. 대신 rclpy.experimental.events_executor.EventsExecutor(임포트 경로가 다른 실험적 모듈)에 있다 — pybind11로 감싼 C++ 구현(_rclpy.EventsExecutor(context))을 반환하는 팩토리 함수이며, add_node·spin·shutdown 등 필요한 메서드는 다 갖췄다. C++ 하네스와 환경변수·출력 형식을 동일하게 맞춘 rclpy 판(gap99/scripts/action_client.py)으로 warm·cold 두 모드, SingleThreadedExecutor·EventsExecutor 둘 다, 그리고 96차가 C++에서 거의 결정적(99~100%)으로 유실시켰던 최악 조건(cold, offset 100 ms)까지 재현했다.

조건executor반복유실
warm, gap 0 msSingleThreadedExecutor1000/100(0%)
warm, gap 300 msSingleThreadedExecutor400/40(0%)
cold, offset 100 ms(C++가 99~100% 유실하던 조건)SingleThreadedExecutor200/20(0%)
warm, gap 0 msEventsExecutor600/60(0%)
cold, offset 100 msEventsExecutor150/15(0%)

총 240회, 손실 0건 — C++이 가장 심하게 무너지던 조건에서도 rclpy는 한 번도 지지 않았다. 그림자 구독은 여러 트라이얼에서 피드백이 응답 콜백보다 먼저 도착했다고 기록했지만(raw_minus_resp_ns가 음수인 경우가 다수), 그런 트라이얼에서도 action_fb_seen은 항상 성공이었다 — 도착 순서 자체가 rclpy에서는 손실과 상관이 없다. 소스로 원인을 확정했다 — rclpy/action/client.py의 send_goal_async()는 목표를 서버에 보내기도 전에, 클라이언트가 스스로 생성한 UUID(self._generate_random_uuid())를 키로 feedback_callback을 self._feedback_callbacks[goal_uuid]에 즉시 동기적으로 등록한다. 반면 96차가 확정한 rclcpp_action의 ClientGoalHandle은 목표 응답 콜백 안에서야 만들어져 등록된다 — 그 전에 도착한 피드백은 갈 곳이 없다. rclpy는 이 등록을 응답을 기다리지 않고 미리 해 두므로, 피드백이 응답보다 먼저 처리되어도(어떤 executor를 쓰든) 조회 테이블에 이미 항목이 있어 정상 전달된다 — C++의 손실은 rclcpp_action의 구현 선택(응답 확인 후 등록)에서 나오는 것이지 ROS 2 액션 프로토콜 자체의 결함이 아니다는 것을 언어 간 비교가 보여준다.

101차 반영(CHG-124): 이 발견을 견해로만 남겨 뒀었는데, 2.8.12.11로 정식 추가했다 — 표·수치는 그대로이며, C++/Python 비대칭은 이제 아홉 축이 아니라 열 축이다.

실행 확인(2026-08-19, ros:jazzy-ros-base, 101차) — "초기 무사 구간"을 좁히려 했으나, 오늘은 그 구간 자체가 재현되지 않았다. 99차가 남긴 제안(초기 7~12회가 왜 안전한지 perf stat이나 /proc 스케줄링 정보로 좁히기)을 gap99 하네스를 그대로 가져와(gap101) 시도했다. perf stat은 이 환경에서 쓸 수 없었다 — 호스트의 kernel.perf_event_paranoid=4(가장 제한적인 값)이고, 게다가 호스트 커널이 7.0.0-28-generic이라는 비표준 버전이라 apt의 linux-tools-generic(6.8.0-138용)이 설치는 되어도 실행되지 않았다(perf not found for kernel 7.0.0-28). 둘 다 컨테이너가 아니라 호스트·커널 수준의 제약이라 컨테이너를 어떻게 기동해도 피할 수 없고, perf_event_paranoid를 낮추려면 호스트 sudo가 필요한데 이 저장소 작업 계정에는 없다(이 호스트는 다른 프로젝트 컨테이너 수십 개가 함께 실행되는 공유 개발 서버라 시스템 전역 sysctl을 바꾸는 것 자체가 이번 회차의 권한 밖이라고 판단했다) — 이 한계를 그대로 인정한다.

대신 /proc·/sys만으로 CPU 주파수·코어 배치·페이지폴트·컨텍스트 스위치를 1~2 ms 간격으로 표본화하는 도구를 새로 만들어(monitor_freq.py) 트라이얼별로 대조했다. CPU 주파수 스케일링과 손실 타이밍 사이에 뚜렷한 상관은 찾지 못했다 — 클라이언트 코어의 주파수는 거의 항상 idle 최저값(800 MHz, intel_pstate powersave)에 머물렀다(트라이얼당 작업이 서브밀리초라 지속 부하로 이어지지 못해 P-state가 거의 오르지 않는다). 산발적인 순간 스파이크(1.2~4.7 GHz)가 있었지만 손실과도, 트라이얼 초반과도 특별한 상관을 보이지 않았다 — 예컨대 안전했던 idx=0에서 4.5 GHz 스파이크가 기록된 반면 바로 다음 idx=1의 손실은 800 MHz대에서 났다. 표본 수가 많지 않아 이 관찰로 CPU 주파수 가설을 완전히 배제하지는 않는다.

더 중요한 결과는 따로 나왔다. "초기 무사 구간"이 클라이언트 프로세스 신선도 때문인지 가르려고, 서버는 그대로 둔 채 클라이언트만 20회 재시작(각 15트라이얼, 총 300회)했더니 손실이 idx=1부터 났다(9건 유실, 6개 반복에서 발생) — 78차의 "idx 0~11은 예외 없이 안전"과 정면으로 어긋났다. 78차의 조건에 더 가깝게 서버·클라이언트를 같은 컨테이너 안에서 함께 재시작(15회×15트라이얼=225회, 7건 유실)해도, 78차와 완전히 똑같은 방법(docker run --rm로 컨테이너를 매회 새로 띄움, 10회×15트라이얼=150회, 4건 유실)해도 마찬가지였다 — 세 방식을 합쳐 45회의 15트라이얼 반복(675 트라이얼) 중 손실이 난 16개 반복에서, 10개(62.5%)가 idx≤6에서 첫 손실을 냈고 idx=1에서 난 경우도 두 번(서로 다른 방식에서 각각 한 번씩) 있었다. 손실의 메커니즘 자체는 동일했다 — 스팟체크한 idx=8 손실 하나를 보면 resp_seen=1 action_fb_seen=0 raw_fb_count=1 raw_minus_resp_ns=-152334로, 96~99차가 확정한 is_ready() 하드코딩 우선순위와 정확히 같은 서명이다(새 버그가 아니다).

정리하면: 이번 회차는 "초기 무사 구간이 왜 생기는가"에 답하지 못했다 — 그 대신 78차가 100+225회에서 관측한 "예외 없이 안전한 초기 구간"이라는 관찰 자체가, 같은 하네스·같은 방법(실제 docker run --rm 포함)으로 오늘 이 호스트에서는 재현되지 않는다는 것을 45회 반복으로 보였다. 두 결과를 모순으로 단정하지 않는다 — 78차와 오늘의 유일하게 확인된 차이는 호스트 부하다(오늘 이 호스트에는 무관한 프로젝트 컨테이너 수십 개가 함께 실행 중이었고 uptime 로드애버리지 약 1.0/20코어였다 — 78차 실행 당시 부하는 기록돼 있지 않아 대조할 수 없다) (확인 필요). "초기 무사 구간"이 실재한다면 호스트 부하에 민감한 조건부 현상일 가능성이 낮지 않다 — 다만 이번 회차의 데이터만으로는 확정할 수 없다(견해).

확인하지 못한 것: 이 실험들은 책의 Calibrate 예시가 아니라 독립적인 최소 액션으로 측정했다(재현 방법은 같다). netem의 정확한 지터 분포(정규분포 가정)가 실제 무선 네트워크와 얼마나 같은지는 여전히 미확인이다. 99차가 커널 타임스탬프로 "도착 순서 대 executor 우선순위"는 닫았지만, "78차가 관측한 초기 무사 구간이 왜 오늘은 재현되지 않는가"가 새로 열렸다 (확인 필요) — 호스트 부하가 후보지만 78차 시점의 부하 기록이 없어 대조하지 못했고, perf가 막혀 있어 CPU 주파수 이외의 스케줄링 지표(런큐 지연 등)는 측정하지 못했다. 97차와 98차의 결과가 갈린 이유(하네스 소스가 남지 않아 직접 대조 못 함, CHG-121)는 이번에도 특정하지 못했다. rclpy가 이 손실 계열에 완전히 면역이라는 것은 확인했지만, rclpy의 EventsExecutor가 96·97차가 C++에서 확정한 다른 실험(다섯 엔티티 순서, waitable 유실)에서도 같은 결과를 내는지는 측정하지 않았다. 실제 다중 호스트(별도 물리 머신) 환경은 여전히 같은 도커 브리지의 컨테이너로 대체했다.

견해: "첫 피드백을 믿지 말라"는 원래 조언은 96~99차 실측으로 더 강해졌다. 서버 쪽 지연은 클라이언트가 이미 스핀 중이라는, 서버가 확인할 수 없는 전제에 기대는 임시방편이다 — 그 전제가 깨지면(클라이언트가 막 시작했거나 재연결한 직후라면) 지연을 아무리 늘려도 소용없다. 진행률 초깃값이 꼭 필요하면 클라이언트가 결과 대신 상태를 조회하는 별도 서비스를 두거나, 서버가 결과 메시지에도 같은 초기값을 채워 첫 피드백을 놓쳐도 최종 결과에서 확인할 수 있게 설계하라 — executor 선택이나 네트워크 조건에 기대지 않는 유일하게 안전한 길이다. 99차가 보태는 것 하나: 이 문제 자체가 rclcpp_action의 구현 선택에서 나온다는 것이 밝혀졌으니, rclpy로 옮길 수 있는 팀이라면 언어 선택 자체가 이 특정 위험을 없애는 실질적인 대안이다 — 다만 2.8.12가 이미 적어 둔 다른 아홉 축의 비용(로깅·GIL·메시지 생성 비용 등)과 맞바꾸는 트레이드오프임을 잊지 말아야 한다.

정정(2026-08-19, 98차) — "재요청 패턴에서 EventsExecutor도 무너진다"는 부분은 재현되지 않았다. 바로 위 견해가 예전에 인용했던 97차의 warm 모드 결과 자체가 이번 회차에 재현되지 않았다(그림자 구독으로 확인, 위 98차 블록) — 로컬호스트에서는 EventsExecutor가 재요청·유휴 시간(gap) 조건에서도 85/85 안전했고, SingleThreadedExecutor도 5~8%에 그쳤다. 실제 네트워크(RTT·지터)에서 두 executor 모두 무너진다는 부분은 그대로 유지된다 — 오히려 이번 회차가 tcpdump로 그 원인(패킷 재정렬)을 직접 확인해 더 단단해졌다. 정리하면 EventsExecutor가 흔들리는 지점은 "클라이언트가 계속 쓰던 상태에서 재요청할 때"가 아니라 "패킷이 실제로 도착 순서가 뒤바뀔 수 있는 네트워크를 지날 때"다 — EventsExecutor의 안전은 애플리케이션 계층의 재요청 패턴이 아니라 전송 계층의 순서 보존 여부에 달려 있다.

다음 장 1.9은 통신 패턴에서 설정으로 넘어간다 — 파라미터의 선언·타입·검증 콜백·이벤트, 그리고 "파라미터로 상태를 관리하지 말라"는 원칙.