제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 | 타이머 생성 |
NodeWaitablesInterface | waitable 등록(2.2.3) |
NodeTimeSourceInterface | 시뮬 시간 연결 |
이것이 주는 세 가지 이점:
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);
- 의존이 명시된다. 함수 시그니처만 봐도 무엇을 쓰는지 안다.
- 테스트가 쉬워진다. 필요한 인터페이스만 모의(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;
}
정상 종료 순서:
함정: 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 |
| executor | rclcpp::Executor 상속 | 2.3 |
| waitable | 커스텀 대기 가능 엔티티 | 2.2.3 |
| allocator·memory strategy | 메모리 정책 | 2.6 |
| launch action·substitution | launch 확장 | 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). 같은 방식으로 감싼 PythonWaitable을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) |
| 3 | start_parameter_services(false) | ros2 param 불가 | 진단 수단 상실 인지 |
| 4 | 임베드 시 신호 처리 충돌 | 종료가 비정상적으로 동작 | shutdown_on_signal(false) |
| 5 | Node&를 받는 라이브러리 | LifecycleNode 미지원 | 인터페이스 사용 |
| 6 | 소멸자에서 하드웨어 미정지 | 종료 후에도 동작 | 명시적 정지 |
| 7 | SIGKILL 대비 없음 | 마지막 명령 유지 | 하드웨어 워치독 |
| 8 | 종료 시간이 systemd 제한 초과 | 강제 종료 | 정리 시간 제한 |
| 9 | 규모가 큰 조직 프레임워크 | 업그레이드 병목 | 얇게 유지 |
| 10 | 프레임워크가 QoS·executor 고정 | 특수 요구에 우회 발생 | 판단은 노출 |
| 11 | 다중 컨텍스트를 무리하게 | 복잡·불확실 | 프로세스 분리 검토 |
| 12 | 로거 레벨 서비스가 없음 | 기본 비활성 — enable_logger_service | 2.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부가 반복해 말한 세 가지:
- 콜백은 저절로 실행되지 않는다. executor가 실행한다. "콜백이 실행되지 않는다"의 답은 거의 항상 executor와 콜백 그룹에 있다(2.2~2.4).
- 한 스레드에서 기다리면 멈춘다. 콜백 안에서 같은 executor의 결과를 기다리는 구조는 데드락이다(1.7.6·2.3). 구조를 바꾸는 것 말고는 답이 없다.
- 성능은 층에서 온다. 복사·할당·직렬화가 어디서 일어나는지 알면(2.6·2.7) 무엇을 고쳐야 하는지가 정해진다 — 그리고 묶는다고 저절로 빨라지지 않는다(2.7.13).
다음 부에서 무엇이 달라지나: 여기까지는 rclcpp/rclpy 안이었다. 제3부는 그 아래 — rmw와 DDS다. 지금까지 "별도 설정 없이 동작하던" 디스커버리와 전송이 어떤 프로토콜로 어떤 비용을 치르며 이뤄지는지 본다.
제3부는 미들웨어: DDS·RTPS·RMW(3.1~3.10)다.