제1부 계산 그래프의 코어
1.13 컴포지션 — 컴포넌트 노드와 프로세스 내 결합
1.13.1 이 장에서 배우는 것 / 선수 지식
1.1에서 "노드는 프로세스가 아니다"라고 했다. 이 장은 그 사실을 성능 도구로 바꾸는 방법이다. 여러 노드를 한 프로세스에 담으면 그 사이의 통신에서 직렬화·복사·네트워크 스택을 전부 건너뛸 수 있다 — 6 MB 크기의 이미지가 포인터 하나가 된다(1.3.6).
ROS 1의 nodelet이 하던 일이지만, ROS 2에서는 모든 노드가 처음부터 컴포넌트로 작성될 수 있고, 배치는 실행 시점에 결정된다. 이것이 ROS 2 아키텍처의 핵심 유연성 중 하나다.
배우는 것:
- 컴포넌트 작성법 — 일반 노드와 무엇이 다른가, 등록 매크로의 정체.
- 컨테이너 세 종류 — 단일 스레드·멀티스레드·격리형의 차이와 선택.
- launch에서의 조립 — 정적 구성과 실행 중 로드.
- intra-process 통신 — 컴포지션과 함께 켜야 실제 이득이 나온다.
- 잃는 것 — 크래시 전파, 디버깅 난이도, 자원 격리 상실.
- 배치 설계 — 어떤 노드를 함께 묶을 것인가.
선수 지식: 1.1(노드), 1.4(토픽 경로), 1.3.6(직렬화 우회), 1.12(라이프사이클 — 컴포넌트와 결합 가능). intra-process의 내부는 2.7, executor 상세는 2.3에서 이어받는다.
정직: 컴포넌트 컨테이너의 종류·동작은
rclcpp_components소스와 문서에 근거한다. 성능 이득 수치는 하드웨어·메시지 크기·구독자 수에 따라 자릿수가 달라지므로 이 장에서는 구체 수치를 단정하지 않고 측정 방법을 제시한다(목차 3).
1.13.2 왜 컴포지션인가
| 비용 | 별도 프로세스 | 같은 프로세스(intra-process) |
|---|---|---|
| 직렬화·역직렬화 | 있음 | 없음 |
| 메모리 복사 | 있음 | 조건 만족 시 0회 |
| 네트워크 스택 | 통과 | 없음 |
| 컨텍스트 스위치 | 있음 | 줄어듦 |
| DDS 참가자 | 프로세스마다 1개 | 컨테이너 전체가 1개(실행 확인 — 3.3.5) → 디스커버리 부하 감소 |
| 디스커버리 엔티티(발행·구독) | 노드마다 | 여전히 노드마다 |
효과가 큰 경우는 명확하다: 큰 메시지를 높은 빈도로. 이미지·포인트클라우드 파이프라인이 대표적이다. 반대로 작은 메시지를 저빈도로 주고받는 노드들을 묶어도 이득은 미미하다 — 묶는 이유가 성능이 아니라면 격리를 잃을 이유도 없다.
1.13.3 컴포넌트 작성
일반 노드와의 차이는 두 가지뿐이다.
// my_component.hpp
#include "rclcpp/rclcpp.hpp"
namespace my_pkg
{
class ImageProcessor : public rclcpp::Node
{
public:
// ① 생성자가 NodeOptions를 받아 기반 클래스에 넘겨야 한다
explicit ImageProcessor(const rclcpp::NodeOptions & options)
: Node("image_processor", options)
{
sub_ = create_subscription<sensor_msgs::msg::Image>(
"image_raw", rclcpp::SensorDataQoS(),
std::bind(&ImageProcessor::on_image, this, std::placeholders::_1));
pub_ = create_publisher<sensor_msgs::msg::Image>(
"image_processed", rclcpp::SensorDataQoS());
}
private:
void on_image(sensor_msgs::msg::Image::UniquePtr msg); // UniquePtr — 1.4.5
rclcpp::Subscription<sensor_msgs::msg::Image>::SharedPtr sub_;
rclcpp::Publisher<sensor_msgs::msg::Image>::SharedPtr pub_;
};
}
// my_component.cpp 끝에
#include "rclcpp_components/register_node_macro.hpp"
// ② 등록 매크로
RCLCPP_COMPONENTS_REGISTER_NODE(my_pkg::ImageProcessor)
NodeOptions를 받는 것이 결정적이다. 컨테이너가 이 인자로 리매핑·파라미터·intra-process 설정을 주입한다. 인자를 받지 않는 노드는 컨테이너에 로드될 수 없고, 로드되더라도 설정이 전달되지 않는다.
CMakeLists.txt:
find_package(rclcpp_components REQUIRED)
add_library(image_processor_component SHARED src/my_component.cpp)
target_compile_definitions(image_processor_component PRIVATE "COMPOSITION_BUILDING_DLL")
ament_target_dependencies(image_processor_component rclcpp rclcpp_components sensor_msgs)
rclcpp_components_register_nodes(image_processor_component "my_pkg::ImageProcessor")
install(TARGETS image_processor_component
ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin)
독립 실행 파일도 함께 제공하는 것이 관행이다 — 개발·디버깅에는 단독 실행이 편하기 때문이다.
// standalone.cpp — 같은 컴포넌트를 단독 프로세스로
int main(int argc, char ** argv) {
rclcpp::init(argc, argv);
rclcpp::NodeOptions options;
auto node = std::make_shared<my_pkg::ImageProcessor>(options);
rclcpp::spin(node);
rclcpp::shutdown();
}
견해: 새 노드는 기본적으로 컴포넌트로 작성하라. 비용은 생성자 시그니처 하나와 매크로 한 줄이고, 나중에 성능이 필요해졌을 때 코드 변경 없이 배치만 바꿀 수 있다. 처음부터 일반 노드로 만들면 나중에 전환하는 데 리팩터링이 필요하다.
ros2 component types # 시스템에 등록된 컴포넌트 목록
1.13.4 컨테이너 세 종류
ros2 run rclcpp_components component_container # ① 단일 스레드
ros2 run rclcpp_components component_container_mt # ② 멀티스레드
ros2 run rclcpp_components component_container_isolated # ③ 노드별 executor
| 컨테이너 | executor | 특징 |
|---|---|---|
component_container | SingleThreaded 하나 | 모든 노드의 모든 콜백이 한 스레드에서 순차 실행 |
component_container_mt | MultiThreaded 하나 | 콜백 그룹 정책에 따라 병렬 실행 |
component_container_isolated | 노드마다 별도 executor | 노드 간 간섭 최소화 |
선택 기준:
① 단일 스레드 — 가장 단순하고 예측 가능하다. 콜백이 순차 실행되므로 노드 간 데이터 공유에 락이 필요 없다(같은 스레드이므로). 대신 한 노드의 느린 콜백이 다른 모든 노드를 막는다. 콜백이 전부 짧고 부하가 작을 때 적합.
② 멀티스레드 — 병렬 처리가 가능하지만 스레드 안전을 직접 책임져야 한다(2.9). 콜백 그룹 설계가 필수다(2.4). 서비스 호출이 얽히면 1.7의 데드락 회피에도 필요하다.
③ 격리형 — 노드마다 executor가 따로 돌아 한 노드의 지연이 다른 노드에 전파되지 않는다. intra-process의 이득은 유지하면서 실행 격리를 얻는다.
견해: ③(isolated)이 기본값으로 좋은 선택인 경우가 많다. ①의 "한 노드가 전부를 막는" 위험과 ②의 "전역 스레드 안전 부담"을 모두 피하면서 통신 최적화는 그대로 얻는다. 다만 스레드 수가 노드 수에 비례해 늘어나므로 코어가 적은 임베디드에서는 재검토가 필요하다.
(확인 필요: isolated 컨테이너의 executor 종류·스레드 수 기본 정책)
1.13.5 launch에서 조립하기
정적 구성 — 컨테이너와 컴포넌트를 함께 실행한다:
from launch import LaunchDescription
from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode
def generate_launch_description():
container = ComposableNodeContainer(
name='perception_container',
namespace='',
package='rclcpp_components',
executable='component_container_isolated',
composable_node_descriptions=[
ComposableNode(
package='my_pkg', plugin='my_pkg::CameraDriver',
name='camera',
parameters=[{'device': '/dev/video0', 'fps': 30}],
extra_arguments=[{'use_intra_process_comms': True}], # ★ 필수
),
ComposableNode(
package='my_pkg', plugin='my_pkg::ImageProcessor',
name='processor',
remappings=[('image_raw', '/camera/image_raw')],
extra_arguments=[{'use_intra_process_comms': True}],
),
],
output='screen',
)
return LaunchDescription([container])
use_intra_process_comms: True를 누락하면 컴포지션의 성능 이득이 대부분 사라진다. 같은 프로세스에 있어도 DDS를 통해 통신하게 된다. 이것이 가장 흔한 실수다 — "컴포지션했는데 빨라지지 않았다"의 대부분이 이 한 줄이다.
실행 중 로드·언로드:
ros2 run rclcpp_components component_container_isolated &
ros2 component load /ComponentManager my_pkg my_pkg::ImageProcessor
ros2 component list
ros2 component unload /ComponentManager 1 # ID로 언로드
동적 로드는 테스트·진단에 유용하다 — 실행 중인 시스템에 관측 컴포넌트를 잠시 로드했다 떼는 식으로. 제품 배포에서는 launch 정적 구성이 재현성 면에서 낫다.
기존 컨테이너에 추가:
from launch_ros.actions import LoadComposableNodes
LoadComposableNodes(
target_container='/perception_container',
composable_node_descriptions=[ComposableNode(...)],
)
이 형태는 launch 파일을 모듈로 나눌 때 유용하다 — 기본 파이프라인 launch와 선택적 기능 launch를 분리하되 같은 컨테이너를 공유한다.
1.13.6 intra-process 통신 — 이득의 실체
컴포지션만으로는 부족하고 intra-process가 켜져야 실제 이득이 난다. 조건이 있다.
| 조건 | 설명 |
|---|---|
| 같은 프로세스 | 컴포지션 또는 수동 조합 |
use_intra_process_comms(true) | 발행·구독 양쪽 노드 모두 |
| QoS 호환 | 일반 규칙과 동일(1.5) |
발행이 unique_ptr | const& 발행은 복사가 남는다 |
복사 횟수(1.4.5 재확인):
| 발행 | 구독 | 복사 |
|---|---|---|
unique_ptr | unique_ptr 구독자 1개 | 0 |
unique_ptr | shared_ptr 구독자 N개 | 0(공유 — 수정 금지) |
unique_ptr | unique_ptr 구독자 N개 | N-1 |
const& | 무엇이든 | 최소 1 |
함정: intra-process에는 제약이 있다 — 다만 그 목록이 생각보다 짧다.
TRANSIENT_LOCAL은 동작한다(늦은 구독자가 과거 메시지를 받고, 전달 경로도 intra-process가 맞다 — 실행 확인, 2.7.13.4). 대신 비용을 낸다 — 무복사는 유지되지만 지연이VOLATILE대비 약 60배다. 대상은 토픽뿐이며 서비스·액션은 아니다(2.7.12.1). 프로세스 밖 구독자가 있으면 그쪽은 일반 경로로 간다. 정확한 조건은 2.7.4와 2.7.13에 있다.
측정 없이 믿지 말 것:
# 컴포지션 전후 비교
top -H -p $(pgrep -f component_container) # 스레드별 CPU
ros2 topic hz /image_processed # 처리량 변화
ros2 topic bw /image_raw # 프로세스 밖 트래픽이 줄었나
더 정확한 측정은 ros2_tracing으로 발행→콜백 지연을 직접 측정하는 것이다(11.6·11.7). "빨라진 것 같다"는 근거가 되지 못한다.
1.13.7 무엇을 잃는가
컴포지션에도 비용이 든다. 이 절이 이 장에서 가장 중요하다.
| 잃는 것 | 설명 | 완화 |
|---|---|---|
| 크래시 격리 | 한 컴포넌트의 segfault가 컨테이너 전체를 강제 종료한다 | 위험한 컴포넌트는 분리, 안정성 검증 |
| 메모리 격리 | 한 노드의 누수·오염이 전체에 영향 | 정적 분석·sanitizer(4.10) |
| 자원 제어 | 컨테이너 단위로만 cgroup·우선순위 지정 가능 | 실시간 노드는 분리(12.2) |
| 재시작 단위 | 한 노드만 재시작 불가 | 라이프사이클로 대체(1.12) |
| 디버깅 단순성 | 스택 트레이스에 여러 노드가 섞임 | 로거 이름으로 구분(1.11.2) |
| 언어 혼합 | C++ 컴포넌트와 Python 노드는 같은 컨테이너에 못 담는다 | 경계 설계 |
| 의존 충돌 | 한 프로세스에 모든 라이브러리가 로드됨 | 버전 충돌 주의 |
크래시 전파가 가장 실질적인 위험이다. 별도 프로세스면 감시자가 재시작하면 되지만, 컨테이너가 종료되면 그 안의 노드 전부가 함께 종료된다 — 그리고 그중 하나는 안전 관련 노드일 수 있다.
견해: 안전에 관여하는 노드(비상 정지·안전 감시·워치독)는 절대 다른 노드와 같은 컨테이너에 두지 않는다. 이것은 성능 최적화보다 우선하는 원칙이다. 안전 감시 노드가 인지 파이프라인의 버그로 함께 종료되는 시스템은 안전 논증을 세울 수 없다(14.10).
디버깅 팁:
# 어떤 컴포넌트가 어디에 있는지
ros2 component list
# 컨테이너 프로세스의 스레드 확인
ps -T -p $(pgrep -f component_container)
# gdb를 연결하면 모든 노드의 스택이 보인다 — 로거 이름으로 구분
gdb -p $(pgrep -f component_container)
1.13.8 배치 설계 — 무엇을 함께 묶을 것인가
묶는 것이 좋은 조합:
| 조합 | 이유 |
|---|---|
| 카메라 드라이버 + 디베이어 + 정류(rectify) | 이미지가 연속으로 흐른다 |
| LiDAR 드라이버 + 포인트클라우드 필터 | 대용량 연속 처리 |
| 스테레오 쌍 + 시차 계산 | 두 이미지가 한곳에 모여야 한다 |
분리하는 것이 좋은 조합:
| 대상 | 이유 |
|---|---|
| 안전 감시·비상 정지 | 격리 필수 |
| 실시간 제어 루프 | 별도 CPU·우선순위 필요(12.2) |
| 실험적·불안정 코드 | 크래시 전파 방지 |
| Python 노드 | 컨테이너 공유 불가 |
| 자주 재시작하는 노드 | 재시작 단위 분리 |
전형적인 로봇 시스템 배치 예:
| 프로세스 | 담는 것 |
|---|---|
| 인지 컨테이너 | 카메라·LiDAR 드라이버 + 전처리 파이프라인 |
| 항법 컨테이너 | Nav2 서버들(자체 컴포넌트로 제공됨 — 8.1) |
| 제어 프로세스 | ros2_control(단독, 실시간 우선순위 — 6.6) |
| 안전 프로세스 | 워치독·비상 정지(단독) |
| 관리 프로세스 | 진단·기록·상태 보고(Python 가능) |
견해: 배치는 성능·안정성·운영의 트레이드오프이지 순수 성능 문제가 아니다. "전부 한 컨테이너에"는 벤치마크에서는 좋아 보이지만 현장에서 무너진다. 시작은 분리된 프로세스로 하고, 측정으로 병목이 확인된 경로만 묶는 순서가 안전하다.
1.13.9 라이프사이클 + 컴포넌트
둘을 함께 쓸 수 있다.
class ManagedProcessor : public rclcpp_lifecycle::LifecycleNode
{
public:
explicit ManagedProcessor(const rclcpp::NodeOptions & options)
: rclcpp_lifecycle::LifecycleNode("managed_processor", options) { … }
};
RCLCPP_COMPONENTS_REGISTER_NODE(my_pkg::ManagedProcessor)
이 조합이 강력한 이유: 컴포지션으로 통신 비용을 없애면서, 라이프사이클로 개별 노드의 재시작 단위를 되찾는다(1.13.7의 "재시작 단위" 상실을 부분적으로 보상). Nav2가 정확히 이 구조다 — 컴포넌트로 제공되면서 라이프사이클로 관리된다.
1.13.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | use_intra_process_comms 누락 | 컴포지션했는데 안 빨라짐 | extra_arguments에 추가 |
| 2 | NodeOptions 미수용 생성자 | 컴포넌트 로드 실패·설정 미전달 | 시그니처 수정 |
| 3 | const& 발행 | 복사가 남음 | unique_ptr 발행 |
| 4 | shared_ptr 콜백에서 메시지 수정 | 다른 구독자 오염 | unique_ptr 콜백(1.4.5) |
| 5 | 안전 노드를 컨테이너에 포함 | 크래시 전파 | 분리 |
| 6 | 단일 스레드 컨테이너에 느린 콜백 | 전체 정지 | _mt·_isolated |
| 7 | 멀티스레드 컨테이너에서 락 누락 | 경쟁 조건 | 콜백 그룹·동기화(2.4·2.9) |
| 8 | Python 노드를 C++ 컨테이너에 | 로드 불가 | 별도 프로세스 |
| 9 | 측정 없이 컴포지션 | 이득 불명·복잡도만 증가 | 트레이싱 측정(11.6) |
| 10 | 전부 한 컨테이너에 | 크래시·운영 경직 | 병목 경로만 |
| 11 | 등록 매크로 누락 | component types에 안 보임 | RCLCPP_COMPONENTS_REGISTER_NODE |
| 12 | CMake 등록 누락 | 라이브러리는 있는데 로드 실패 | rclcpp_components_register_nodes |
1.13.11 체크포인트
- 컴포지션이 없애는 비용 네 가지를 말할 수 있는가. 효과가 큰 조건과 미미한 조건은?
- 컴포넌트가 일반 노드와 다른 두 가지는 무엇인가.
NodeOptions를 받아야 하는 이유는? - 컨테이너 세 종류의 차이와 각각이 맞는 상황을 말할 수 있는가.
- "컴포지션했는데 빨라지지 않았다"의 가장 흔한 원인은 무엇인가.
- intra-process에서 복사 0을 달성하는 조건 네 가지를 나열할 수 있는가.
- 컴포지션으로 잃는 것 일곱 가지 중, 안전 시스템에서 가장 중요한 것은 무엇이며 어떻게 대응하는가.
- 어떤 노드 쌍을 묶을지 판단하는 절차를 그릴 수 있는가.
- 안전 감시 노드를 다른 노드와 같은 컨테이너에 두면 안 되는 이유를 안전 논증 관점에서 설명할 수 있는가.
- 라이프사이클과 컴포넌트를 함께 쓰면 무엇을 되찾는가.
- 컴포지션의 효과를 측정하는 방법 세 가지를 말할 수 있는가. "빨라진 것 같다"가 왜 근거가 못 되는가.
여기까지가 제1부 계산 그래프의 코어다. 노드·이름·인터페이스·직렬화·토픽·QoS·서비스·액션·파라미터·시간·로깅·라이프사이클·컴포지션 — ROS 2 애플리케이션을 구성하는 재료가 모두 나왔다.
그런데 이 장들을 관통하며 반복해서 등장한 것이 있다. executor다. 콜백이 실행되지 않는다(1.4), 서비스가 데드락에 빠진다(1.7), 액션 실행이 전체를 막는다(1.8), 컨테이너 종류를 골라야 한다(1.13) — 전부 실행 모델의 문제였고, 그때마다 "2.2~2.4에서 다룬다"고 미뤄 두었다. 다음 제2부가 그 약속을 이행한다. 2.1에서 rclcpp·rcl·rmw 계층을 해부하는 것부터 시작한다.
1.13.12 제1부를 마치며 — 계산 그래프의 코어
이 장은 제1부(1.1~1.13)의 마지막이므로, 부 전체를 하나의 관점으로 묶는다.
제1부가 반복해 말한 세 가지:
- 이름이 먼저다. 노드·토픽·서비스가 안 보이는 문제의 대부분은 통신이 아니라 이름·네임스페이스·리매핑에서 온다(1.1). 그래프를 의심하기 전에 이름을 확인하라.
- QoS는 설정이 아니라 계약이다. 호환되지 않으면 연결 자체가 성립하지 않고, 아무도 알려 주지 않는다(1.5·1.6).
ros2 topic info -v가 그 계약을 보여 주는 유일한 창이다. - 통신 수단은 의미로 고른다. 토픽·서비스·액션·파라미터는 성능이 아니라 상호작용의 성격으로 갈린다(1.7~1.9). 잘못 고르면 나중에 구조적 비용으로 치른다.
다음 부에서 무엇이 달라지나: 제1부가 "무엇이 오가는가"였다면 *제2부는 "그것이 언제 어떤 스레드에서 처리되는가"***다. 같은 코드가 executor 설정 하나로 동작이 바뀌는 세계로 들어간다.
제2부는 실행 모델·클라이언트 라이브러리 내부(2.1~2.10)다.