AEGLOSLAB ROS 2 Jazzy 대전

제2부 실행 모델·클라이언트 라이브러리 내부

2.10 NodeOptions·노드 인터페이스·확장 포인트

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

제2부를 마무리하며, 지금까지 흩어져 나온 설정 표면을 한곳에 모은다. NodeOptions는 컴포지션(1.13)·intra-process(2.7)·파라미터(1.9)·컨텍스트를 조립하는 지점이고, 노드 인터페이스는 "노드가 아닌 것을 노드처럼 쓰는" 확장 지점이다.

이 장은 두 부류의 독자를 위한 것이다. 애플리케이션 개발자에게는 "이런 설정이 있다"는 지도이고, 프레임워크·라이브러리 개발자에게는 "ROS 2 위에 자기 추상을 세우는 법"이다.

배우는 것:

  • NodeOptions 전수 — 각 옵션이 무엇을 바꾸고 어디서 다뤘는가.
  • Context와 초기화 — rclcpp::init의 실체, 다중 컨텍스트, 신호 처리.
  • 노드 인터페이스 — NodeBaseInterface 계열이 존재하는 이유와 활용.
  • 종료 처리 — on_shutdown 콜백과 정상 종료 설계.
  • 확장 포인트 목록 — ROS 2를 확장하는 공식 지점들.
  • 확장 포인트 API의 실물 대조(2.10.11) — Waitable·ExecutorOptions·guard condition의 Jazzy 형태.
  • 자기 프레임워크를 세울 때의 판단 — 무엇을 감싸고 무엇을 노출할 것인가.

선수 지식: 제2부 전체(2.1~2.9), 1.9(파라미터), 1.13(컴포지션).

정직: NodeOptions의 옵션 목록과 노드 인터페이스 구성은 배포판마다 추가·변경되어 왔다. 아래는 주요 항목이며 전수가 아닐 수 있다(확인 필요). 정본은 rclcpp/node_options.hpp와 rclcpp/node_interfaces/ 헤더다. 목록 자체는 이 환경에서 대조하지 않았다(목차 3) — 다만 확장 포인트 API 셋은 2.10.11에서 헤더와 실행 확인했고, 그 결과로 이 장의 예시 둘을 정정했다.

2.10.2 NodeOptions 전수

rclcpp::NodeOptions options;
options
  .use_intra_process_comms(true)                      // 2.7
  .use_global_arguments(false)                        // 아래 설명
  .arguments({"--ros-args", "-r", "__ns:=/robot1"})   // 1.1.5
  .parameter_overrides({rclcpp::Parameter("rate", 20.0)})  // 1.9
  .allow_undeclared_parameters(false)                 // 1.9.4
  .automatically_declare_parameters_from_overrides(false)
  .start_parameter_services(true)                     // 1.9.3
  .start_parameter_event_publisher(true)
  .enable_rosout(true)                                // 1.11.3
  .use_clock_thread(true)                             // 1.10
  .clock_qos(rclcpp::ClockQoS())
  .context(rclcpp::contexts::get_global_default_context());

auto node = std::make_shared<MyNode>("name", options);
옵션의미언제 바꾸나관련 장
use_intra_process_comms프로세스 내 최적화컴포지션 시 필수2.7
use_global_arguments프로세스 전역 인자를 이 노드에 적용할지다중 노드 프로세스에서 중요(아래)1.1
arguments이 노드 전용 인자프로그램적으로 리매핑1.1.5
parameter_overrides코드에서 파라미터 초기값 주입테스트·동적 구성1.9
allow_undeclared_parameters미선언 허용마이그레이션 중에만1.9.4
automatically_declare_parameters_from_overrides파일의 값을 자동 선언위와 함께1.9.4
start_parameter_services파라미터 서비스 6종 노출끄면 ros2 param 불가1.9.3
start_parameter_event_publisher/parameter_events 발행부하 감소 시1.9.8
enable_rosout/rosout 발행무선 대역 절약1.11.3
use_clock_thread/clock 구독 전용 스레드시뮬 시간 정확도1.10.3
clock_qos·parameter_event_qos 등내부 토픽 QoS특수 환경1.5
context소속 컨텍스트다중 컨텍스트2.10.3
enable_topic_statistics토픽 통계 수집(/statistics)관측성 — 코드 수정 없이 주기·나이 측정(실행 확인)11.7.12
enable_logger_service~/set_logger_levels·~/get_logger_levels 서비스 노출기본 false — 운영 중 로그 레벨을 바꾸려면 미리 켜 두어야 한다(실행 확인)1.11.4

use_global_arguments가 중요한데 자주 간과된다.

한 프로세스에 노드가 여럿일 때(컴포지션 — 1.13), 명령줄의 --ros-args -r ...는 기본적으로 모든 노드에 적용된다. 특정 노드만 다르게 하려면:

rclcpp::NodeOptions options;
options.use_global_arguments(false);         // 전역 인자 무시
options.arguments({"--ros-args", "-r", "__node:=special"});

컴포넌트에서는 컨테이너가 노드별 인자를 주입하므로 대개 신경 쓸 일이 없지만, 수동으로 여러 노드를 만드는 프로세스에서는 필수 지식이다.

함정: start_parameter_services(false)로 끄면 ros2 param이 그 노드에 통하지 않는다. 부하를 줄이려다 운영 진단 수단을 잃는 셈이다. 정말 필요한 경우(수백 개의 경량 노드)가 아니면 켜 두는 편이 낫다.

2.10.3 Context와 초기화

rclcpp::init(argc, argv);      // 전역 기본 컨텍스트 초기화

컨텍스트가 담는 것:

항목설명
rcl_context_t하위 계층 상태
명령줄 인자 파싱 결과리매핑·파라미터·로그 레벨
신호 처리기SIGINT·SIGTERM
종료 상태·콜백on_shutdown
로깅 초기화rcutils 로깅 설정

초기화 옵션:

rclcpp::InitOptions init_options;
init_options.shutdown_on_signal = false;       // 신호 처리를 직접 하겠다
rclcpp::init(argc, argv, init_options);

shutdown_on_signal = false가 필요한 경우: ROS 2를 다른 프레임워크(Qt·게임 엔진·기존 애플리케이션)에 임베드할 때, 그쪽이 이미 신호를 처리하고 있으면 충돌한다. 이때 ROS 2의 자동 신호 처리를 끄고 직접 rclcpp::shutdown()을 호출한다.

다중 컨텍스트:

auto ctx = std::make_shared<rclcpp::Context>();
ctx->init(argc, argv);

rclcpp::NodeOptions options;
options.context(ctx);
auto node = std::make_shared<rclcpp::Node>("isolated_node", options);

rclcpp::ExecutorOptions opts;      // 체이닝이 아니라 멤버다 — 2.10.11.2
opts.context = ctx;
rclcpp::executors::SingleThreadedExecutor exec(opts);

용도: 한 프로세스에서 서로 다른 도메인에 참여해야 할 때. 도메인 브리지가 대표적이다(1.1.7의 ②에서 언급한 브리지).

// 도메인 0과 도메인 1을 잇는 브리지 (개념)
setenv("ROS_DOMAIN_ID", "0", 1);
auto ctx0 = std::make_shared<rclcpp::Context>(); ctx0->init(argc, argv);
setenv("ROS_DOMAIN_ID", "1", 1);
auto ctx1 = std::make_shared<rclcpp::Context>(); ctx1->init(argc, argv);
// 각 컨텍스트에 노드를 만들고 메시지를 옮긴다

정직: 위 환경변수 조작 방식이 모든 RMW에서 의도대로 동작하는지는 (확인 필요)다. 컨텍스트별 도메인 지정을 위한 정식 경로가 있는지 현재 API를 확인하는 편이 낫고, 실무에서는 프로세스를 나누는 편이 단순하고 확실하다.

2.10.4 노드 인터페이스 — 왜 존재하는가

rclcpp::Node는 큰 클래스다. 그런데 많은 라이브러리가 노드 전체가 아니라 그 일부 기능만 필요로 한다. tf2 버퍼는 시계와 파라미터만 필요하고, 어떤 유틸리티는 로거만 필요하다.

// 이 함수는 노드 전체가 아니라 필요한 인터페이스만 받는다
void setup_something(
  rclcpp::node_interfaces::NodeBaseInterface::SharedPtr base,
  rclcpp::node_interfaces::NodeTopicsInterface::SharedPtr topics,
  rclcpp::node_interfaces::NodeLoggingInterface::SharedPtr logging);

// 호출
setup_something(
  node->get_node_base_interface(),
  node->get_node_topics_interface(),
  node->get_node_logging_interface());

주요 인터페이스:

인터페이스제공하는 것
NodeBaseInterface이름·네임스페이스·컨텍스트·콜백 그룹
NodeClockInterface시계(1.10)
NodeGraphInterface그래프 질의(1.1.8)
NodeLoggingInterface로거(1.11)
NodeParametersInterface파라미터(1.9)
NodeServicesInterface서비스 생성
NodeTopicsInterface발행·구독 생성
NodeTimersInterface타이머 생성
NodeWaitablesInterfacewaitable 등록(2.2.3)
NodeTimeSourceInterface시뮬 시간 연결

이것이 주는 세 가지 이점:

  1. LifecycleNode도 받을 수 있다. rclcpp::Node와 rclcpp_lifecycle::LifecycleNode는 상속 관계가 아니지만 같은 인터페이스를 제공한다. 인터페이스를 받는 함수는 둘 다 지원한다 — 라이브러리를 만들 때 결정적으로 중요하다.
// ❌ LifecycleNode를 못 받는다
void setup(rclcpp::Node::SharedPtr node);

// ✅ 둘 다 받는다
void setup(rclcpp::node_interfaces::NodeBaseInterface::SharedPtr base,
           rclcpp::node_interfaces::NodeTopicsInterface::SharedPtr topics);
  1. 의존이 명시된다. 함수 시그니처만 봐도 무엇을 쓰는지 안다.
  2. 테스트가 쉬워진다. 필요한 인터페이스만 모의(mock)할 수 있다(14.5).

견해: 재사용 가능한 라이브러리를 만든다면 Node&가 아니라 인터페이스를 받으라. tf2·Nav2·MoveIt 2 같은 성숙한 라이브러리가 이 방식을 쓰는 이유가 있다. 단일 애플리케이션 노드 안의 헬퍼라면 Node&로 충분하다 — 과한 추상화는 비용이다.

NodeInterfaces 묶음 헬퍼: 인자가 많아지는 것을 줄이기 위해 여러 인터페이스를 한 객체로 묶어 전달하는 헬퍼가 제공된다 — rclcpp::node_interfaces::NodeInterfaces<InterfaceTs...>(node_interfaces/node_interfaces.hpp, 89차 실행 확인). 헤더의 설명 그대로 "a helper class for aggregating node interfaces"이며, 필요한 인터페이스를 템플릿 인자로 나열한다.

2.10.5 종료 처리

정상 종료는 로봇 시스템에서 안전 문제다. 모터를 멈추고, 브레이크를 걸고, 파일을 닫아야 한다.

// ① 컨텍스트 수준 종료 콜백
rclcpp::on_shutdown([]() {
  // rclcpp::shutdown() 시 호출된다
  // 주의: 이 시점에 노드가 이미 정리 중일 수 있다
});

// ② 소멸자 (가장 일반적)
MyNode::~MyNode() {
  stop_hardware();
  if (worker_.joinable()) { running_ = false; worker_.join(); }
}

// ③ 라이프사이클 (가장 명시적)
CallbackReturn on_shutdown(const rclcpp_lifecycle::State &) override {
  emergency_stop();
  return CallbackReturn::SUCCESS;
}

정상 종료 순서:

SIGINT (Ctrl-C) rclcpp 신호 처리기 context shutdown 표시 spin() 루프 종료 on_shutdown 콜백 main 반환 · 노드 소멸 소멸자에서 하드웨어 정지

함정: SIGKILL(kill -9)에서는 아무것도 실행되지 않는다. 하드웨어가 마지막 명령 상태로 남는다. 그래서 하드웨어 쪽에 워치독을 두는 것이 필수다(1.4.8) — 소프트웨어의 정상 종료에만 의존하면 안 된다. 이것은 설계 원칙이지 방어적 코딩이 아니다.

종료 시간 제한: systemd로 관리한다면 TimeoutStopSec이 지나면 SIGKILL이 온다(14.8). 정리 작업이 그 안에 끝나야 한다.

// 종료 시 오래 걸리는 작업은 시간을 제한한다
~MyNode() {
  running_ = false;
  if (worker_.joinable()) {
    // 무한정 기다리지 않도록 워커가 종료 신호를 자주 확인하게 설계
    worker_.join();
  }
}

2.10.6 확장 포인트 목록

ROS 2를 확장하는 공식적인 지점들이다. 자기 프레임워크나 도구를 만들 때의 재료 목록이기도 하다.

확장 포인트무엇을 만드나관련 장
CLI 명령·동사ros2 mycmd 추가(setuptools 엔트리포인트)0.7.2
rmw 구현새 미들웨어 백엔드15.4
typesupport새 직렬화 형식1.3.3
rosidl 생성기새 언어 바인딩1.3.2
컴포넌트동적 로드 가능한 노드1.13
executorrclcpp::Executor 상속2.3
waitable커스텀 대기 가능 엔티티2.2.3
allocator·memory strategy메모리 정책2.6
launch action·substitutionlaunch 확장14.3
rosbag2 저장 플러그인새 기록 형식11.2
RViz2 플러그인시각화11.4
rqt 플러그인GUI 도구11.5
Nav2·MoveIt 2 플러그인플래너·컨트롤러·행동8.5·9.2
ros2_control 하드웨어·컨트롤러하드웨어 추상6.2·6.4
pluginlib 일반임의의 플러그인 체계4.2

waitable이 특히 유용한 확장 지점이다. ROS 2가 모르는 이벤트 원천(하드웨어 인터럽트·외부 소켓·다른 프레임워크의 이벤트 루프)을 executor의 wait set에 통합할 수 있다.

class MyWaitable : public rclcpp::Waitable {
public:
  size_t get_number_of_ready_guard_conditions() override { return 1; }
  void add_to_wait_set(rcl_wait_set_t * wait_set) override { … }
  bool is_ready(rcl_wait_set_t * wait_set) override { … }
  std::shared_ptr<void> take_data() override { … }
  void execute(std::shared_ptr<void> & data) override { … }
};
// 주의: 위 셋은 Jazzy에서 [[deprecated]] 신호를 낸다 — 2.10.11 참조 (89차 실행 확인)
node->get_node_waitables_interface()->add_waitable(waitable, callback_group);

이렇게 하면 외부 이벤트가 ROS 2 콜백처럼 executor에서 처리된다 — 별도 스레드와 동기화가 필요 없어진다. 시리얼·CAN 드라이버(6.7)에서 쓸 수 있는 패턴이다.

2.10.7 자기 프레임워크를 세울 때

ROS 2 위에 조직 전용 추상(공통 노드 기반 클래스·표준 진단·표준 라이프사이클)을 세우는 것은 흔한 일이다. 판단이 필요한 지점들:

① 기반 클래스로 감쌀 것인가

class CompanyNode : public rclcpp_lifecycle::LifecycleNode {
public:
  CompanyNode(const std::string & name, const rclcpp::NodeOptions & opt)
  : LifecycleNode(name, opt) {
    setup_standard_diagnostics();
    setup_standard_parameters();
    log_startup_config();          // 1.11.8
  }
};
  • 장점: 조직 표준(진단·로깅·파라미터 규약)이 자동 적용된다. 보일러플레이트 감소.
  • 단점: 모든 노드가 이 클래스에 묶인다. 외부 라이브러리와 섞기 어려워지고, 업그레이드 시 전체가 영향을 받는다.
  • 대안: 자유 함수·믹스인으로 제공하고 강제하지 않는다.

견해: 기반 클래스보다 "함수 모음 + 컨벤션"이 대체로 낫다. setup_standard_diagnostics(node) 같은 함수를 제공하고 각 노드가 호출하게 하면, 강제성은 낮아지지만 결합도 낮아진다. 특히 인터페이스를 받는 자유 함수로 만들면(2.10.4) Node와 LifecycleNode 모두에 쓸 수 있다.

② 무엇을 노출하고 무엇을 감출 것인가

감싸도 되는 것감싸면 안 되는 것
반복되는 초기화 절차QoS 선택(1.6 — 상황별 판단 필요)
조직 표준 진단·로깅executor·콜백 그룹(2.4 — 노드마다 다름)
파라미터 이름 규약통신 패턴 선택(토픽·서비스·액션)
표준 라이프사이클 절차오류 처리 정책

감추면 안 되는 것들의 공통점: "상황에 따라 달라야 하는 판단"이다. 프레임워크가 이것을 고정하면 나중에 특수한 요구가 왔을 때 프레임워크를 우회하는 코드가 생기고, 그것이 부채가 된다.

③ 버전 정책

조직 프레임워크는 ROS 2 배포판 업그레이드의 병목이 되기 쉽다. Jazzy → 다음 LTS로 옮길 때 프레임워크가 먼저 이식되어야 모든 패키지가 따라올 수 있다. 프레임워크를 얇게 유지하는 것이 이 위험을 줄인다(15.1).

2.10.11 확장 포인트 API를 헤더와 대조했다 — 실측

이 장은 확장 포인트를 다루면서 API 형태 셋에 (확인 필요)를 달아 두었다. Jazzy 헤더를 직접 열어 확인했다(실행 확인 — Jazzy · ros:jazzy-ros-base · rclcpp 28.1.21). 셋 중 둘이 틀렸고 하나는 이름이 확정됐다.

2.10.11.1 rclcpp::Waitable — 예시가 쓰는 셋이 폐기 예정이다

waitable.hpp의 가상 함수는 이렇다.

함수비고
get_number_of_ready_subscriptions() · _timers() · _clients() · _services() · _events() · _guard_conditions()여섯
add_to_wait_set(rcl_wait_set_t & )* 버전은 [[deprecated]]
is_ready(const rcl_wait_set_t & )* 버전은 [[deprecated]]
take_data()순수 가상(= 0) — 반드시 구현
take_data_by_entity_id(size_t id)엔티티별로 나눠 받을 때
execute(const std::shared_ptr<void> & )비-const 참조 버전은 [[deprecated]]
set_on_ready_callback(std::function<void(size_t, int)>) · clear_on_ready_callback()콜백 방식

2.10.7의 예시는 세 군데가 폐기 예정 서명이다 — add_to_wait_set(rcl_wait_set_t *), is_ready(rcl_wait_set_t *), execute(std::shared_ptr<void> &).

컴파일은 되지만 경고가 대량 출력된다. 헤더가 대안을 직접 말해 준다: "Use add_to_wait_set(rcl_wait_set_t & wait_set) signature". 포인터를 참조로, 비-const를 const로 바꾸면 된다. 새로 쓰는 코드는 참조·const 쪽을 쓰라.

2.10.11.2 ExecutorOptions는 체이닝이 아니라 멤버다

이 장의 예시는 rclcpp::ExecutorOptions().context(ctx)처럼 메서드 체이닝을 가정했다. 그런 메서드는 없다.

struct ExecutorOptions            // executor_options.hpp — 필드 셋뿐이다
{
  rclcpp::memory_strategy::MemoryStrategy::SharedPtr memory_strategy;
  rclcpp::Context::SharedPtr context;
  size_t max_conditions;
};
rclcpp::ExecutorOptions opts;     // ✔ Jazzy에서 맞는 형태
opts.context = ctx;
rclcpp::executors::SingleThreadedExecutor exec(opts);

NodeOptions와 혼동하기 쉽다. NodeOptions는 실제로 체이닝을 지원하지만(NodeOptions().use_intra_process_comms(true)), ExecutorOptions는 평범한 구조체다. 같은 …Options 이름이라 같은 방식일 것 같지만 아니다.

2.10.11.3 guard condition은 executor가 아니라 wait set에 넣는다

2.2의 예시는 executor.add_guard_condition(gc)를 적어 두었다. Executor에는 그런 공개 메서드가 없다 — add_node·add_callback_group뿐이다. add_guard_condition은 rclcpp::WaitSet(wait_set_template.hpp) 쪽 API다.

그래서 두 갈래다. ① WaitSet을 직접 쓰는 경우(executor를 안 쓰고 직접 대기) → wait_set.add_guard_condition(gc). ② executor를 쓰는 경우 → guard condition을 Waitable 안에 감싸 노드의 waitables 인터페이스로 등록한다(2.10.11.1의 get_number_of_ready_guard_conditions()가 그 통로다). executor에 직접 연결하는 길은 없다.

정직: 이 방식으로 감싼 커스텀 waitable을 95차에 실제로 등록·trigger()해 실행 확인했다(2.3.14) — SingleThreadedExecutor·MultiThreadedExecutor.spin_some()에서는 매번(15/15·25/25) 정확히 실행됐지만, MultiThreadedExecutor.spin()의 "동시 실행"에서는 9/25·2/15로 자주 완전히 유실됐다(다른 위치로 옮겨지는 것이 아니라 전혀 안 불림 — 119~120차 정정: 이 실험이 명시적 콜백 그룹을 지정했는지는 소스가 남아 있지 않아 확인하지 못했지만(확인 필요), 바로 아래 96차 원인 설명 자체가 "단일 기본 MutuallyExclusive 그룹 공유"를 전제하므로, "동시 실행"이라는 표현은 실제로는 일어나지 않았을 가능성이 크다 — 유실 현상과 96차의 메커니즘 설명 자체는 유효하다, 상세는 2.3.14). 96차가 원인을 확정했다 — rclcpp 소스(executor.cpp)를 보면 구독·서비스·클라이언트·waitable은 "바쁜 콜백 그룹은 건너뛴다"는 판정에 걸리면 이번 wait 배치에서 다시 돌아오지 않는 전진 전용 반복자로 스캔되는데, 구독·서비스·클라이언트는 데이터가 DDS reader에 남아 다음 rcl_wait()에서 다시 포착되지만 guard condition은 엣지 트리거라 한 번 수집되면 트리거가 즉시 소비돼 다시는 ready로 보이지 않는다. 최소 재현(타이머+waitable, 2.3.14)으로 다른 엔티티와 같은 MutuallyExclusive 그룹을 공유하고 스레드가 2개 이상이면 100% 유실, 전용 콜백 그룹으로 분리하면 스레드 수와 무관하게 100% 성공임을 확인했다 — 이진 스위치이지 확률적 현상이 아니다. guard condition 기반 waitable을 MultiThreadedExecutor와 함께 쓴다면 반드시 전용 콜백 그룹을 주라.**

실행 확인(2026-08-19, ros:jazzy-ros-base, rclcpp, 97차) — Reentrant 그룹은 이 유실을 막는다, 그러나 비용이 든다. 96차가 남긴 두 물음(Reentrant도 보호 효과가 있는가, EventsExecutor에서는 어떤가)을 최소 재현으로 닫았다.

① EventsExecutor에 이 방식의 waitable을 등록하면 — 등록 자체가 실패한다. add_node()가 내부적으로 모든 waitable에 set_on_ready_callback()을 호출하는데(2.3.6의 이벤트 큐 구조), 이 절이 지금까지 가르친 API(is_ready·take_data·execute만 오버라이드)는 그 메서드를 오버라이드하지 않는다 — 그러면 기반 클래스 rclcpp::Waitable::set_on_ready_callback()이 그 자리에서 std::runtime_error를 던진다(waitable.cpp: "Custom waitables should override set_on_ready_callback if they want to use it."). 실제로 재현하면 예외가 정확히 그 문구로 발생한다 — spin()을 부르기도 전에, add_node() 한 줄에서 종료된다. 고치려면 set_on_ready_callback·clear_on_ready_callback·take_data_by_entity_id까지 오버라이드해야 하며, 마침 rclcpp::GuardCondition이 set_on_trigger_callback(std::function<void(size_t)>)을 이미 제공하므로 그대로 위임하면 된다. 이렇게 고친 waitable은 80/80(100%) 실행됐다 — MultiThreadedExecutor를 100% 유실로 몰아넣은 것과 똑같은 "바쁜 타이머와 같은 그룹" 조건에서도 무너지지 않았다(폴링이 아니라 콜백 기반이라 애초에 "건너뛰는" 개념 자체가 없다).

② Reentrant 그룹(타이머와 waitable이 같은 그룹) — 45/45(100%) 성공. MutuallyExclusive 공유 그룹(0/35, 96차와 동일 재현)과 정확히 대비된다. 스레드 2개(20/20·25/25 두 차례)·4개(20/20) 전부 성공했고, waitable을 별도 Reentrant 그룹으로 분리해도(96차의 MutuallyExclusive 분리와 같은 조건) 여전히 20/20 성공이었다 — 95차의 견해("논리적으로 그럴 것")가 맞았다. 이유는 can_be_taken_from()이 Reentrant 그룹에서는 사실상 항상 참을 반환해 "바쁘다"는 판정 자체가 나지 않기 때문이다 — 96차가 확정한 유실 메커니즘("바쁜 그룹은 건너뛰고, 건너뛴 guard condition의 트리거는 사라진다")의 전제 자체가 성립하지 않는다.

③ 그러나 Reentrant는 다른 대가를 요구한다 — 실제 데이터 경쟁. 타이머 콜백과 waitable의 execute()가 보호되지 않은 공유 변수에 함께 접근하게 만들고(읽기 → 1 ms 대기 → 쓰기, 2.9.10과 같은 기법) 최종값을 기대치와 비교했다: MutuallyExclusive는 손실 0(143/143, 애초에 동시 실행이 없으므로 당연)이었지만, Reentrant는 스레드 2개에서 31/288·40/352(약 11~14%), 스레드 4개에서 18/460(약 4%)이 유실됐다 — waitable을 별도 그룹으로 분리한 조건에서도 36/287(약 12.5%) 유실됐다. 마지막 결과가 핵심이다 — MultiThreadedExecutor는 서로 다른 콜백 그룹의 콜백도 기본적으로 병렬 실행하므로, "waitable을 격리했다"는 사실이 타이머와의 동시 실행 자체를 막아 주지는 않는다. 콜백 그룹 분리는 "바쁜 그룹은 건너뛴다"는 스케줄링 판정만 변경할 뿐, 그 콜백이 실제로 공유하는 애플리케이션 상태의 스레드 안전은 여전히 호출자의 책임이다(2.9의 락·원자 연산).

guard condition 기반 waitable을 MultiThreadedExecutor와 함께 쓸 때의 정리: Reentrant 그룹(전용이든 공유든)으로 두면 waitable 유실은 사라진다. 그러나 그 waitable이 다른 콜백과 상태를 공유한다면 경쟁 조건은 여전히 남으므로 락·원자 연산으로 따로 막아야 한다 — "유실을 막는 것"과 "스레드 안전을 얻는 것"은 서로 다른 문제이고 Reentrant 그룹은 앞의 것만 풀어 준다.

rclpy는 같은 개념을 다르게 다룬다(105차, 2.8.12.12). 같은 방식으로 감싼 Python Waitable을 SingleThreadedExecutor에서 실행하면 다른 넷보다 항상 먼저 실행된다(rclcpp는 정반대로 항상 마지막). EventsExecutor도 이 waitable을 거부하지만 실패 시점이 다르다 — add_node()가 아니라 첫 spin_once()에서 RuntimeError: Guard conditions not supported가 난다. 자세한 수치와 재현 하네스는 2.8.12.12.

실행 확인(2026-08-19, ros:jazzy-ros-base·ros:humble-ros-base, rclcpp, 112차) — 96차의 유실 메커니즘이 "다른 엔티티와 섞였을 때"만이 아니라 waitable끼리도, 그리고 Jazzy만이 아니라 Humble에서도 똑같이 작동한다. guard condition waitable 여섯 개끼리만(다른 엔티티 종류는 전혀 섞지 않고) 같은 기본 콜백 그룹에 등록해 MultiThreadedExecutor::spin()으로 실행하니, 스레드 2·4·8개·정역방향·Jazzy·Humble 80회 전부 fired=0 — 단 하나도 실행되지 못했다. Humble에는 108차가 확정한 EntityCollection/std::unordered_map이 전혀 없는데도 26/26 100% 유실이라는 것은, 96차가 지목한 원인(전진 전용 반복자 + 엣지 트리거 guard condition)이 PR #2142의 Jazzy 전용 캐시와 무관하게, 두 배포판이 공유하는 더 오래된 executor.cpp 코드에 있다는 뜻이다. 상세는 2.3.14, 하네스는 tools/gap_harness/gap112/.

2.10.8 함정 카탈로그

#함정증상대처
1컴포지션에서 use_intra_process_comms 누락성능 이득 없음명시(2.7)
2다중 노드 프로세스에서 전역 인자 충돌의도치 않은 리매핑use_global_arguments(false)
3start_parameter_services(false)ros2 param 불가진단 수단 상실 인지
4임베드 시 신호 처리 충돌종료가 비정상적으로 동작shutdown_on_signal(false)
5Node&를 받는 라이브러리LifecycleNode 미지원인터페이스 사용
6소멸자에서 하드웨어 미정지종료 후에도 동작명시적 정지
7SIGKILL 대비 없음마지막 명령 유지하드웨어 워치독
8종료 시간이 systemd 제한 초과강제 종료정리 시간 제한
9규모가 큰 조직 프레임워크업그레이드 병목얇게 유지
10프레임워크가 QoS·executor 고정특수 요구에 우회 발생판단은 노출
11다중 컨텍스트를 무리하게복잡·불확실프로세스 분리 검토
12로거 레벨 서비스가 없음기본 비활성 — enable_logger_service2.10.2

2.10.9 체크포인트

  • NodeOptions의 주요 옵션 열 가지와, 각각이 어느 장의 주제와 연결되는지 말할 수 있는가.
  • use_global_arguments(false)가 필요한 상황은 무엇인가.
  • start_parameter_services를 끄면 무엇을 잃는가.
  • Context가 담는 것 다섯 가지와, shutdown_on_signal(false)가 필요한 경우를 말할 수 있는가.
  • 노드 인터페이스가 존재하는 이유 세 가지를 말할 수 있는가. 그중 라이브러리 개발자에게 가장 중요한 것은?
  • rclcpp::Node와 rclcpp_lifecycle::LifecycleNode를 모두 지원하는 함수를 어떻게 작성하는가.
  • 정상 종료 순서를 그리고, SIGKILL에서 무엇이 실행되지 않는지 말할 수 있는가. 그래서 무엇이 필수인가.
  • ROS 2의 확장 포인트를 여덟 가지 이상 들 수 있는가.
  • waitable 확장이 해결하는 문제는 무엇인가. 드라이버 개발에서 어떻게 쓰이는가.
  • 조직 프레임워크에서 감싸도 되는 것과 감추면 안 되는 것의 구분 기준은 무엇인가.
  • 프레임워크를 얇게 유지해야 하는 이유를 배포판 업그레이드 관점에서 설명할 수 있는가.
  • ExecutorOptions와 NodeOptions는 쓰는 방법이 같은가 다른가. 왜 혼동되는가(2.10.11.2).
  • guard condition을 executor에 직접 넣을 수 있는가. 없다면 두 가지 길은 무엇인가(2.10.11.3).

여기까지가 제2부 실행 모델·클라이언트 라이브러리 내부다. 제1부에서 미뤄 두었던 약속을 이행했다 — 콜백이 왜 실행되지 않는지(2.2), 서비스가 왜 멈추는지(2.3·2.4), 주기가 왜 흔들리는지(2.5), 무복사가 언제 성립하는지(2.6·2.7), Python이 어디까지인지(2.8), 경쟁 조건을 어떻게 다루는지(2.9).

제1부와 제2부는 한 프로세스·한 머신 안의 이야기였다. 그런데 로봇 시스템은 여러 프로세스, 여러 컴퓨터, 무선 링크로 흩어져 있다. 노드들이 서로를 어떻게 찾고, 데이터가 어떤 패킷으로 오가며, 왜 유선에서 되던 것이 무선에서 안 되는가 — 이것이 제3부 미들웨어: DDS·RTPS·RMW의 주제다. 3.1에서 DDS의 개념 모델부터 시작한다.

2.10.10 제2부를 마치며 — 실행 모델·클라이언트 라이브러리 내부

이 장은 제2부(2.1~2.10)의 마지막이므로, 부 전체를 하나의 관점으로 묶는다.

제2부가 반복해 말한 세 가지:

  1. 콜백은 저절로 실행되지 않는다. executor가 실행한다. "콜백이 실행되지 않는다"의 답은 거의 항상 executor와 콜백 그룹에 있다(2.2~2.4).
  2. 한 스레드에서 기다리면 멈춘다. 콜백 안에서 같은 executor의 결과를 기다리는 구조는 데드락이다(1.7.6·2.3). 구조를 바꾸는 것 말고는 답이 없다.
  3. 성능은 층에서 온다. 복사·할당·직렬화가 어디서 일어나는지 알면(2.6·2.7) 무엇을 고쳐야 하는지가 정해진다 — 그리고 묶는다고 저절로 빨라지지 않는다(2.7.13).

다음 부에서 무엇이 달라지나: 여기까지는 rclcpp/rclpy 안이었다. 제3부는 그 아래 — rmw와 DDS다. 지금까지 "별도 설정 없이 동작하던" 디스커버리와 전송이 어떤 프로토콜로 어떤 비용을 치르며 이뤄지는지 본다.

제3부는 미들웨어: DDS·RTPS·RMW(3.1~3.10)다.