제2부 실행 모델·클라이언트 라이브러리 내부
2.1 계층 해부 — rclcpp·rcl·rmw·rosidl·DDS
2.1.1 이 장에서 배우는 것 / 선수 지식
제1부에서 우리는 계속 "이것은 rmw 계층에서 정해진다", "rcl이 검증한다" 같은 말을 써 왔다. 이 장은 그 계층들을 실제로 열어 본다. 목적은 두 가지다. 첫째, 오류 메시지와 스택 트레이스를 읽을 수 있게 하는 것 — 문제가 어느 층에서 났는지 알면 검색 범위가 10분의 1이 된다. 둘째, 어디까지가 ROS 2의 약속이고 어디부터가 벤더의 재량인지 구분하는 것.
배우는 것:
- 각 계층의 책임 경계 — 무엇을 하고 무엇을 하지 않는가.
- 왜 이렇게 나눴는가 — 언어 바인딩 재사용과 미들웨어 교체라는 두 목표.
- 객체가 계층을 가로지르는 방식 —
rclcpp::Node에서 DDS DomainParticipant까지. - 오류가 전파되는 방식 —
rcutils오류 상태와 예외의 경계. - 계층화의 대가 — 간접 참조, 디버깅 난이도, 기능 손실.
- 소스를 읽는 법 — 문제를 만났을 때 어느 저장소의 어느 파일을 볼 것인가.
- 다른 클라이언트 라이브러리 — rclpy·rclc·비공식 바인딩이 이 그림 어디에 놓이는가.
- 링커로 본 계층(2.1.12) — 그림과 실물이 어디서 다른가, 벤더는 언제 결합되는가.
선수 지식: 제1부 전체. 특히 1.3(typesupport), 1.4(발행 경로), 1.5(QoS).
정직: 계층 구조와 책임 분담은 ROS 2 설계 문서와
ros2/rcl·ros2/rmw·ros2/rclcpp저장소 구조에 근거한다. 함수 이름·시그니처는 배포판에 따라 달라질 수 있으며 이 환경에서 소스를 내려받아 대조하지 않았다(목차 3). 실제 코드가 언제나 정본이다.
2.1.2 전체 그림
| 계층 | 언어 | 책임 | 책임 아님 |
|---|---|---|---|
rclcpp·rclpy | C++·Python | 사용자 API, 실행 모델(executor), 수명 관리, 편의 기능 | 미들웨어 통신 |
rcl | C | 이름 검증·확장, 리매핑, 파라미터 기반 로직, 인자 파싱, 그래프 캐시, 로깅 초기화 | 실행 모델, 스레드 |
rmw | C(헤더) | 인터페이스 정의만. 함수 시그니처와 자료구조 | 구현 |
rmw_*_cpp | C++ | rmw 인터페이스를 특정 미들웨어로 구현. QoS 매핑, 엔티티 생성 | ROS 개념(파라미터·리매핑) |
| DDS 구현 | C++ | RTPS·디스커버리·전송·신뢰성 | ROS 개념 전부 |
가장 중요한 경계 두 개:
rcl은 실행 모델을 모른다. executor·스레드·콜백 디스패치는 전부rclcpp·rclpy에 있다. 그래서 1.7의 데드락은rclcpp문제이지rcl이나 DDS의 문제가 아니다.rmw아래는 ROS를 모른다. DDS 구현은 노드·파라미터·리매핑 같은 개념을 알지 못한다.rmw_*_cpp가 번역기 역할을 한다.
2.1.3 왜 이렇게 나눴는가
목표 ① 언어 바인딩의 재사용. 새 언어 바인딩(rclrs·rclnodejs 등)을 만들 때 이름 검증·리매핑·인자 파싱을 다시 구현하지 않아도 된다. rcl을 호출하면 된다.
목표 ② 미들웨어 교체. RMW_IMPLEMENTATION 환경변수 하나로 DDS 벤더를 바꿀 수 있다(0.4·3.5). 재빌드가 필요 없는 이유는 typesupport와 rmw 구현이 런타임에 dlopen 되기 때문이다(1.3.3).
RMW_IMPLEMENTATION=rmw_cyclonedds_cpp ros2 run demo_nodes_cpp talker
목표 ③ ROS 1의 교훈. ROS 1은 전송 계층이 코어에 내장돼 있어 교체가 불가능했다. ROS 2는 그 결합을 끊는 것을 명시적 목표로 삼았다(0.2).
대가:
| 대가 | 설명 |
|---|---|
| 최소 공통분모 | 벤더별 고급 기능이 rmw 인터페이스에 없으면 못 쓴다(1.5.3.7의 OWNERSHIP 등) |
| 간접 비용 | 호출이 여러 층을 지난다. 대개 무시할 만하나 초고빈도에서는 누적 |
| 디버깅 복잡도 | 스택 트레이스가 길고 층을 넘나든다 |
| 오류 메시지 품질 | 하위 층 오류가 상위로 오며 문맥을 잃는다 |
| 버전 정합 | 층마다 별도 패키지 — 혼합 버전 시 미묘한 문제 |
2.1.4 객체가 계층을 가로지르는 방식
rclcpp::Node를 만들면 실제로 무엇이 생기는가.
| ROS 2 개념 | rcl | rmw | DDS(전형적 매핑) |
|---|---|---|---|
| Node | rcl_node_t | rmw_node_t | DomainParticipant 또는 그 하위 |
| Publisher | rcl_publisher_t | rmw_publisher_t | DataWriter |
| Subscription | rcl_subscription_t | rmw_subscription_t | DataReader |
| Service | rcl_service_t | rmw_service_t | DataWriter + DataReader 쌍(1.7.2) |
| Topic 이름 | 확장·검증 후 | 접두어 부착 | DDS 토픽 이름 |
| QoS | — | rmw_qos_profile_t | DDS QoS 정책 |
실행 확인(2026-08-16) — Jazzy 기본 RMW에서는 프로세스(컨텍스트)당 참가자 하나다. 한 프로세스에 노드 4개를 만들고
ros2 topic info -v로 각 엔드포인트의 GID를 비교했더니, 네 노드의 GUID prefix(앞 12바이트)가 전부 동일하고 뒤쪽 엔티티 ID만 달랐다. 반면 별도 프로세스인ros2데몬은 prefix가 달랐다. RTPS에서 GUID prefix가 참가자를 식별하므로(3.2.3), 같은 prefix = 같은 참가자다.확인한 것은
rmw_fastrtps_cpp(Jazzy 기본)이며, 다른 벤더·버전은 다를 수 있다(확인 필요). 이 차이가 3.3.5·3.6에서 중요해진다.
토픽 이름 접두어: ROS 2의 /scan은 DDS 상에서 그대로 /scan이 아니다. rt/scan(topic), rq/…Request·rr/…Reply(service) 같은 접두어가 붙는다. 이는 ROS 토픽과 비ROS DDS 토픽을 구분하기 위한 것이며, 비ROS DDS 애플리케이션과 연동할 때 반드시 알아야 하는 세부다(1.2.6·3.6).
2.1.5 오류가 전파되는 방식
하위 층은 반환 코드, 상위 층은 예외.
/* rcl / rmw — C이므로 반환 코드 */
rcl_ret_t ret = rcl_publish(&publisher, &msg, NULL);
if (ret != RCL_RET_OK) {
/* 오류 상세는 별도 저장소에 */
rcutils_error_string_t err = rcutils_get_error_string();
rcutils_reset_error();
}
// rclcpp — 예외로 변환
try {
pub->publish(msg);
} catch (const rclcpp::exceptions::RCLError & e) {
RCLCPP_ERROR(logger, "발행 실패: %s", e.what());
}
rcutils의 오류 상태는 스레드 지역(thread-local)이다. 그래서 다음이 성립한다.
- 오류를 확인하지 않고 다음 호출을 하면 이전 오류 메시지가 남아 혼동을 준다.
- 여러 스레드에서 각자의 오류 상태를 갖는다.
rcutils_reset_error()를 호출하지 않으면 "오류 상태가 이미 설정됨" 경고가 나올 수 있다.
흔히 만나는 예외들:
| 예외 | 원인 | 어느 층에서 |
|---|---|---|
rclcpp::exceptions::InvalidNodeNameError | 이름 규칙 위반 | rcl 검증(1.1.3) |
rclcpp::exceptions::InvalidTopicNameError | 토픽 이름 위반 | rcl |
rclcpp::exceptions::ParameterNotDeclaredException | 미선언 파라미터 | rclcpp(1.9.4) |
rclcpp::exceptions::RCLError | 일반 rcl 실패 | rcl 이하 |
rclcpp::exceptions::RCLInvalidArgument | 잘못된 인자 | rcl |
std::runtime_error(다양) | 타입 불일치·상태 오류 | rclcpp |
함정: DDS 층의 오류는 예외로 전파되지 않는 경우가 많다. 벤더 로그(stderr)로만 나오거나, 조용히 무시된다. "설정이 적용되지 않았는데 오류도 없다"면 벤더 로그 수준을 높여서 확인해야 한다(3.7·3.8).
2.1.6 계층별 저장소 — 문제를 만났을 때 어디를 보나
| 저장소 | 무엇이 있나 | 언제 보나 |
|---|---|---|
ros2/rclcpp | Node·executor·publisher·QoS 래퍼 | 실행 모델·API 동작 |
ros2/rclpy | Python 바인딩·executor | rclpy 특유 문제(2.8) |
ros2/rcl | 이름 확장·리매핑·인자 파싱·그래프 | "왜 이 이름이 됐나" |
ros2/rmw | 인터페이스 헤더·QoS 구조체 | 무엇이 표준인지 확인 |
ros2/rmw_implementation | 런타임 rmw 선택 로직 | RMW 로딩 실패 |
ros2/rmw_fastrtps 등 | 벤더 어댑터 | QoS 매핑·엔티티 생성 |
ros2/rosidl* | 코드 생성·typesupport | 타입 문제(1.3) |
ros2/rcutils·rcpputils | 로깅·오류·문자열 | 저수준 유틸 |
실전 팁: 오류 메시지 문자열을 그대로 GitHub 코드 검색에 넣으면 대개 발생 지점을 바로 찾는다. ROS 2의 오류 메시지는 소스에 리터럴로 하드코딩돼 있는 경우가 많아 이 방법이 잘 통한다.
소스를 로컬에 두고 보는 법:
# 특정 배포판의 소스 전체를 내려받아 참조용으로
mkdir -p ~/ros2_src && cd ~/ros2_src
vcs import --input https://raw.githubusercontent.com/ros2/ros2/jazzy/ros2.repos src
# (확인 필요: 브랜치·파일 경로는 배포판마다 다름 — 4.7)
빌드까지 할 필요는 없다. 읽기용 소스 트리를 두는 것만으로도 디버깅 속도가 크게 달라진다(4.7).
2.1.7 다른 클라이언트 라이브러리
| 라이브러리 | 언어 | 위치 | 특징 |
|---|---|---|---|
rclcpp | C++ | rcl 위 | 표준. 기능 가장 풍부 |
rclpy | Python | rcl 위(C 확장) | GIL 제약(2.8) |
rclc | C | rcl 위 | 경량. micro-ROS의 기반(6.8) |
rclrs | Rust | rcl 위 | 커뮤니티 주도(확인 필요: 성숙도·지원 배포판) |
rclnodejs·기타 | 다양 | rcl 위 | 커뮤니티 |
rclc가 흥미로운 이유: rclcpp의 executor는 동적 할당과 C++ 런타임에 의존해 MCU에서 쓰기 어렵다. rclc는 정적 할당과 결정적 실행 순서를 목표로 별도 executor를 제공한다 — RTOS·MCU 환경(micro-ROS)에서 쓰이며, 실행 순서를 명시적으로 지정할 수 있어 결정성 측면에서 rclcpp보다 유리한 면이 있다(12.5).
견해:
rclcpp와rclpy가 같은rcl을 공유한다는 사실은 실무에서 유용하다. 이름 해석·리매핑·파라미터 파일 처리가 두 언어에서 동일하게 동작하므로, 한쪽에서 배운 규칙이 다른 쪽에 그대로 적용된다. 반대로 executor 동작은 공유하지 않으므로 실행 모델 관련 지식은 언어별로 따로 익혀야 한다(2.3 vs 2.8).
2.1.8 계층을 아는 것이 실제로 도움이 되는 순간
사례 ① "QoS를 설정했는데 적용되지 않는다"
- rclcpp에서 설정 → rmw 구조체로 변환 → 벤더가 DDS QoS로 매핑.
- 벤더가 XML 프로파일을 갖고 있으면 그것이 우선할 수 있다(3.7).
- 확인 순서: 코드 →
ros2 topic info -v(rmw가 보고하는 실제 값) → 벤더 XML.
사례 ② "다른 머신에서 노드가 안 보인다"
- rclcpp·rcl은 무관하다. 디스커버리는 DDS 층의 일이다.
- 확인 순서:
ROS_DOMAIN_ID→ 멀티캐스트 도달성 → 벤더 설정 → 방화벽(3.9). - 코드를 읽을 이유가 없다.
사례 ③ "이름이 예상과 다르게 붙었다"
- rcl의 이름 확장·리매핑 로직(1.1.4).
ros2 node info로 실제 결과 확인.
사례 ④ "콜백이 실행되지 않는다"
- 순수하게 rclcpp executor 문제(2.2·2.3).
- DDS를 의심할 이유가 없다 —
ros2 topic hz로 데이터가 흐르는지 먼저 확인하면 층이 갈린다(1.4.9).
견해: 디버깅의 첫 단계는 "이것은 어느 층의 문제인가"를 가르는 것이다. 이 판단만 정확해도 시간의 대부분을 아낀다. 위 네 사례가 각각 rmw·DDS·rcl·rclcpp에 대응한다는 점을 기억해 두면 유용하다.
2.1.9 계층화가 못 해 주는 것
이식성은 완전하지 않다. "RMW를 바꿔도 코드는 그대로"는 API 수준의 이야기이며, 실제로는 다음이 달라진다.
| 달라지는 것 | 설명 |
|---|---|
| 성능 특성 | 지연·처리량·CPU가 벤더마다 다르다(3.6) |
| 기본값 | 버퍼 크기·전송 설정 등 |
| 튜닝 방법 | XML 형식이 전혀 다르다(3.7·3.8) |
| 디스커버리 동작 | 참가자 매핑·SEDP 세부 |
| 지원 기능 | loaned message·공유메모리 지원 여부(2.6) |
| 오류 메시지 | 완전히 다르다 |
| 대규모 확장성 | 노드 수가 많아질 때의 거동 |
따라서 "나중에 벤더를 바꾸면 되지"는 위험한 계획이다. 바꾸는 것 자체는 쉽지만, 바꾼 뒤 성능·안정성을 다시 검증하는 비용이 실제 비용이다. 벤더 선택은 초기에 하고, 바꾼다면 전체 재검증을 예산에 넣어야 한다(3.6).
2.1.12 계층을 링커로 확인했다 — 실측
이 장의 계층도는 설계 의도를 그린 것이다. 실제 바이너리의 의존 관계가 그 그림과 같은지를 readelf·nm으로 확인했다(실행 확인, Jazzy).
2.1.12.1 사용자 실행 파일은 rclcpp 하나만 본다
ros2 run demo_nodes_cpp talker의 실행 파일이 직접 링크하는 것:
librclcpp.so libclass_loader.so libconsole_bridge.so librcutils.so libc libgcc_s
| 확인 | 결과 |
|---|---|
rcl을 직접 링크하는가 | 아니오 |
rmw를 직접 링크하는가 | 아니오 |
fastrtps를 직접 링크하는가 | 아니오 — 0회 |
사용자 코드는 미들웨어를 컴파일 시점에 전혀 모른다. 이것이 "DDS 벤더를 바꿔도 재컴파일이 필요 없다"(3.5)의 물리적 근거다 — 바이너리에 벤더 이름이 전혀 없다.
2.1.12.2 상위 층은 아래를 건너뛰기도 한다
계층도는 rclcpp → rcl → rmw로 그렸지만, 실제 링크는 그렇게 단정하지 않다.
| 라이브러리 | ROS 쪽 직접 의존(발췌) |
|---|---|
librclcpp.so | librcl · librmw · librmw_implementation · librosidl_runtime_c · libtracetools · libament_index_cpp |
librcl.so | librmw · librmw_implementation · librosidl_runtime_c · librcl_yaml_param_parser · libtracetools |
librmw.so | librcutils 뿐 |
rclcpp가 rmw를 직접 링크한다 — rcl을 거치지 않고. QoS 구조체·타입 핸들처럼 rmw가 정의한 타입을 rclcpp가 직접 다루기 때문이다(2.1.4의 "객체가 계층을 가로지르는 방식"이 링크 수준에서 이렇게 나타난다).
계층은 "호출 방향"의 규율이지 "링크 금지"가 아니다. 그림을 "상위 계층은 바로 하위 계층만 부른다"로 읽으면 틀린다. 정확히는 하위 계층은 상위 계층을 절대 모른다는 쪽이 계층화의 본질이다 —
librmw.so가rcutils하나만 의존하는 것이 그 증거다.
2.1.12.3 도해에 없던 상자 — rmw_implementation
계층도에서 빠져 있던 층이 실물에 있다. librmw_implementation.so가 그것이며, 벤더를 고르는 일만 한다.
librmw_implementation.so 의 의존:
libament_index_cpp.so librcpputils.so librcutils.so libc libgcc_s
벤더 라이브러리가 하나도 없다. 대신 rcpputils::SharedLibrary의 심볼을 참조한다.
U rcpputils::SharedLibrary::get_symbol(...)
U rcpputils::SharedLibrary::has_symbol(...)
U rcpputils::SharedLibrary::get_library_path()
즉 실행 시점에 공유 라이브러리를 열어 심볼을 찾는다. 바이너리 안에는 기본값 문자열 rmw_fastrtps_cpp만 들어 있다.
RMW_IMPLEMENTATION환경변수가 왜 재링크 없이 동작하는지가 여기서 설명된다(0.4.4·3.5.5). 그리고 왜 오타가 조용히 실패하는지도 — 이름으로 라이브러리를 찾는 구조이므로, 없는 이름을 주면 로드 단계에서 실패하지 컴파일러가 검출해 주지 않는다.
2.1.12.4 타입서포트도 같은 구조다
계층도의 점선(rosidl typesupport)은 하나의 상자지만, 실물은 세 종류 × 두 언어다.
| 라이브러리 | 역할 |
|---|---|
librosidl_typesupport_c.so · ..._cpp.so | 디스패처 — 런타임에 고른다 |
librosidl_typesupport_fastrtps_c.so · ..._cpp.so | 벤더 전용 직렬화 |
librosidl_typesupport_introspection_c.so · ..._cpp.so | 필드 순회(제네릭 도구용, 1.3.8) |
메시지 패키지가 무엇을 의존하는지 보면 구조가 드러난다.
libstd_msgs__rosidl_typesupport_cpp.so → librosidl_typesupport_cpp.so
+ libstd_msgs__rosidl_generator_c.so
메시지 패키지도 벤더를 링크하지 않는다. 그리고 디스패처(librosidl_typesupport_cpp.so)는 SharedLibrary 심볼을 3개 참조한다 — rmw_implementation과 똑같이 런타임 로드다.
벤더 결합은 런타임에 두 곳에서 일어난다 — ①
rmw_implementation이 rmw 구현을, ②rosidl_typesupport_*가 직렬화기를. 둘이 같은 벤더로 맞아야 한다. 3.5.5가 말한 "일부 노드만 다른 RMW면 조용히 통신이 안 된다"의 구조적 근원이 이것이다.
2.1.12.5 그래서 2.1.6의 "어디를 보나" 표가 이렇게 굳는다
| 증상 | 실물 근거 |
|---|---|
| 콜백·데드락 | librclcpp.so — 사용자 코드가 유일하게 직접 링크하는 층 |
| 이름·리매핑 | librcl.so — librcl_yaml_param_parser를 의존하는 층이 여기다 |
| RMW를 못 찾음·벤더 불일치 | librmw_implementation.so — 런타임 로드가 일어나는 지점 |
| 직렬화·타입 불일치 | librosidl_typesupport_* — 역시 런타임 로드 |
| 연결·디스커버리 | librmw_fastrtps_cpp.so → libfastrtps.so·libfastcdr.so |
2.1.12.6 확인하지 못한 것
- Cyclone DDS·zenoh 구성에서도 같은 구조인지는 측정하지 않았다
(확인 필요)— 기본 설치의 Fast DDS만 봤다. rclpy의 확장 모듈(_rclpy)이 어느 층을 링크하는지는 별도다(확인 필요).
2.1.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | 층 판단 없이 디버깅 | 잘못된 곳을 오래 봄 | 2.1.8의 분류 |
| 2 | DDS 오류를 예외로 기대 | 조용한 실패 | 벤더 로그 활성화 |
| 3 | rcutils 오류 미리셋 | 옛 오류 메시지가 남음 | rcutils_reset_error() |
| 4 | 노드=DomainParticipant로 가정 | 디스커버리 예측 실패 | 벤더 매핑 확인(3.3) |
| 5 | DDS 토픽 이름을 /scan으로 가정 | 비ROS 연동 실패 | rt/ 접두어(2.1.4) |
| 6 | 층별 버전 혼합 | 미묘한 비호환 | 같은 배포판으로 통일 |
| 7 | "벤더는 나중에 바꾸면 된다" | 재검증 비용 급증 | 초기 결정(3.6) |
| 8 | rmw에 없는 DDS 기능 기대 | API가 없음 | XML 또는 앱 레벨(1.5.3.7) |
| 9 | 계층도를 "바로 하위 계층만 부른다"로 읽음 | 실제 링크와 다름 | 하위 계층이 상위 계층을 모른다가 본질(2.1.12.2) |
| 10 | RMW_IMPLEMENTATION 오타 | 컴파일러가 검출하지 않는다 — 로드 실패 | 이름 기반 런타임 로드(2.1.12.3) |
2.1.11 체크포인트
- 다섯 계층의 책임을 각각 한 문장으로 말할 수 있는가.
rcl이 모르는 것은 무엇인가. - 계층 분리의 두 가지 목표와, 그 대가 다섯 가지를 말할 수 있는가.
RMW_IMPLEMENTATION교체에 재빌드가 필요 없는 이유를 1.3.3과 연결해 설명할 수 있는가.rclcpp::Node하나가 아래 층에서 어떤 객체들로 이어지는가. 그 대응이 벤더마다 다를 수 있는 지점은?- ROS 토픽
/scan이 DDS 상에서 어떤 이름이 되는가. 왜 그런가. - 오류가 C 층에서 C++ 층으로 넘어올 때 어떻게 변환되는가.
rcutils오류 상태의 성질은? - 다음 각 증상이 어느 층의 문제인가: 콜백이 안 돔 / 다른 머신에서 안 보임 / 이름이 다르게 붙음 / QoS가 안 먹음.
rclc가rclcpp와 다른 목표를 갖는 이유는 무엇인가.- "RMW는 나중에 바꾸면 된다"가 왜 위험한 계획인가.
- 사용자 실행 파일은 어느 라이브러리를 직접 링크하는가.
fastrtps는 몇 번 나오는가(2.1.12.1). - 계층도에 없던 층 하나는 무엇이며 무슨 일을 하는가(2.1.12.3).
- 벤더 결합이 런타임에 일어나는 두 곳을 말할 수 있는가(2.1.12.4).
다음 장 2.2는 이 그림에서 rclcpp가 담당하는 부분 — wait set과 executor의 원리로 들어간다. 제1부에서 미뤄 둔 모든 실행 모델 문제가 여기서 풀리기 시작한다.