AEGLOSLAB ROS 2 Jazzy 대전

제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 / rclc 언어별 클라이언트 라이브러리 rcl 공통 C 로직 rmw 추상 인터페이스 (C 헤더) rmw_implementation 런타임에 벤더를 고른다 rmw_fastrtps_cpp rmw_cyclonedds_cpp rmw_zenoh_cpp … DDS 구현 (또는 다른 미들웨어) 전송: UDP · 공유메모리 · TCP rosidl typesupport
계층언어책임책임 아님
rclcpp·rclpyC++·Python사용자 API, 실행 모델(executor), 수명 관리, 편의 기능미들웨어 통신
rclC이름 검증·확장, 리매핑, 파라미터 기반 로직, 인자 파싱, 그래프 캐시, 로깅 초기화실행 모델, 스레드
rmwC(헤더)인터페이스 정의만. 함수 시그니처와 자료구조구현
rmw_*_cppC++rmw 인터페이스를 특정 미들웨어로 구현. QoS 매핑, 엔티티 생성ROS 개념(파라미터·리매핑)
DDS 구현C++RTPS·디스커버리·전송·신뢰성ROS 개념 전부

가장 중요한 경계 두 개:

  1. rcl은 실행 모델을 모른다. executor·스레드·콜백 디스패치는 전부 rclcpp·rclpy에 있다. 그래서 1.7의 데드락은 rclcpp 문제이지 rcl이나 DDS의 문제가 아니다.
  2. 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를 만들면 실제로 무엇이 생기는가.

rclcpp::Node rcl_node_t rmw_node_t DDS DomainParticipant (또는 그 일부) rclcpp::Context rcl_context_t rmw_context_t
ROS 2 개념rclrmwDDS(전형적 매핑)
Nodercl_node_trmw_node_tDomainParticipant 또는 그 하위
Publisherrcl_publisher_trmw_publisher_tDataWriter
Subscriptionrcl_subscription_trmw_subscription_tDataReader
Servicercl_service_trmw_service_tDataWriter + DataReader 쌍(1.7.2)
Topic 이름확장·검증 후접두어 부착DDS 토픽 이름
QoS—rmw_qos_profile_tDDS 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 계층별 저장소 — 문제를 만났을 때 어디를 보나

QoS가 다르게 적용됨 연결· 디스커버리 메시지 필드· 생성 코드 콜백이 안 돌거나 데드락 이름·리매핑· 인자 문제 발생 어떤 증상? ros2/rmw + rmw_*_cpp QoS 변환 ros2/rclcpp executor · callback group ros2/rcl arguments · node · remap DDS 벤더 저장소 ros2/rosidl*
저장소무엇이 있나언제 보나
ros2/rclcppNode·executor·publisher·QoS 래퍼실행 모델·API 동작
ros2/rclpyPython 바인딩·executorrclpy 특유 문제(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 다른 클라이언트 라이브러리

라이브러리언어위치특징
rclcppC++rcl 위표준. 기능 가장 풍부
rclpyPythonrcl 위(C 확장)GIL 제약(2.8)
rclcCrcl 위경량. micro-ROS의 기반(6.8)
rclrsRustrcl 위커뮤니티 주도(확인 필요: 성숙도·지원 배포판)
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.solibrcl · librmw · librmw_implementation · librosidl_runtime_c · libtracetools · libament_index_cpp
librcl.solibrmw · librmw_implementation · librosidl_runtime_c · librcl_yaml_param_parser · libtracetools
librmw.solibrcutils 뿐

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의 분류
2DDS 오류를 예외로 기대조용한 실패벤더 로그 활성화
3rcutils 오류 미리셋옛 오류 메시지가 남음rcutils_reset_error()
4노드=DomainParticipant로 가정디스커버리 예측 실패벤더 매핑 확인(3.3)
5DDS 토픽 이름을 /scan으로 가정비ROS 연동 실패rt/ 접두어(2.1.4)
6층별 버전 혼합미묘한 비호환같은 배포판으로 통일
7"벤더는 나중에 바꾸면 된다"재검증 비용 급증초기 결정(3.6)
8rmw에 없는 DDS 기능 기대API가 없음XML 또는 앱 레벨(1.5.3.7)
9계층도를 "바로 하위 계층만 부른다"로 읽음실제 링크와 다름하위 계층이 상위 계층을 모른다가 본질(2.1.12.2)
10RMW_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부에서 미뤄 둔 모든 실행 모델 문제가 여기서 풀리기 시작한다.